Merge branch 'main' into port-d

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

No files matched your search

+13 -1
View File
@@ -11,6 +11,7 @@
#include "UI/ShapeControls.h"
#include "UI/TypeControls.h"
#include "UI/LassoControls.h"
#include "UI/LayerMaskMenu.h"
#include "UI/NavigationToolHeader.h"
#include "UI/TransformInspector.h"
#include "UI/NewCanvasSheet.h"
@@ -196,7 +197,8 @@ protected:
ContentView::ContentView(EditorSession &session, ProjectController *projects, QWidget *parent)
: QWidget(parent), m_session(session), m_projects(projects), m_column(new QVBoxLayout(this)), m_canvasSlot(new QGridLayout),
m_canvas(new CanvasView(session, this)), m_layersPanel(new LayersPanel(session, this)), m_dock(new QWidget(this)), m_dropRing(new DropRing(this)), m_zoom(statusText(new QLabel(this))), m_dimensions(statusText(new QLabel(this))), m_colour(statusText(new QLabel(this))),
m_canvas(new CanvasView(session, this)), m_layersPanel(new LayersPanel(session, this)), m_dock(new QWidget(this)), m_dropRing(new DropRing(this)),
m_maskAloneBadge(new MaskAloneBadge(session, this)), m_zoom(statusText(new QLabel(this))), m_dimensions(statusText(new QLabel(this))), m_colour(statusText(new QLabel(this))),
m_activity(statusText(new ElidedLabel(this))),
m_spinner(new QProgressBar(this))
{
@@ -240,6 +242,12 @@ ContentView::ContentView(EditorSession &session, ProjectController *projects, QW
// The welcome sits over the canvas, as Swift's ZStack.
m_canvasSlot->addWidget(m_canvas, 0, 0);
m_canvasSlot->addWidget(m_dropRing, 0, 0);
// A layout takes no clicks; the canvas keeps the rest.
auto *foot = new QVBoxLayout;
foot->addStretch(1);
foot->addWidget(m_maskAloneBadge, 0, Qt::AlignHCenter);
foot->addSpacing(14);
m_canvasSlot->addLayout(foot, 0, 0);
setAcceptDrops(true);
auto *middle = new QHBoxLayout;
@@ -390,6 +398,10 @@ void ContentView::synchronize()
}
const std::optional<CanvasDocument> &document = m_session.document();
showWelcome(!document);
const std::optional<ImageLayer> alone = m_session.maskAloneLayer();
if (alone)
m_maskAloneBadge->setLayerName(alone->name);
m_maskAloneBadge->setVisible(alone.has_value());
for (QWidget *ruler : m_rulers)
ruler->setVisible(m_session.showsRulers() && document);
m_zoom->setVisible(document.has_value());
+3
View File
@@ -14,6 +14,7 @@
#include <QWidget>
class LayersPanel;
class MaskAloneBadge;
class ToolButton;
class ProjectWorkspace;
class QFileDialog;
@@ -74,6 +75,8 @@ private:
QWidget *const m_dock;
// An accent ring while a drop may land.
QWidget *const m_dropRing;
// Over the canvas's foot while a mask shows alone.
MaskAloneBadge *const m_maskAloneBadge;
// Swift 1.1.7's rulers: the corner, then the two strips.
std::vector<QWidget *> m_rulers;
ProjectWorkspace *m_workspace = nullptr;
+32
View File
@@ -3,6 +3,7 @@
#include "IO/ImageExporter.h"
#include <QtConcurrent>
#include <cmath>
#include <cstring>
#include <numeric>
#include <stdexcept>
@@ -28,6 +29,37 @@ QImage BrushRaster::context(int width, int height, bool mask)
return surface;
}
QImage BrushRaster::copy(const QImage &image)
{
QImage result = context(image.width(), image.height(), false);
if (image.format() != QImage::Format_RGBA8888_Premultiplied) {
QPainter painter(&result);
draw(image, QRectF(0, 0, image.width(), image.height()), painter);
return result;
}
for (int y = 0; y < image.height(); ++y)
std::memcpy(result.scanLine(y), image.constScanLine(y), size_t(image.width()) * 4);
return result;
}
void BrushRaster::inBands(qsizetype count, const std::function<void(qsizetype, qsizetype)> &body)
{
const int bands = count < 250'000 ? 1 : QThread::idealThreadCount() * 2;
const qsizetype size = (count + bands - 1) / bands;
try {
std::vector<int> indices(static_cast<size_t>(bands));
std::iota(indices.begin(), indices.end(), 0);
PoolMap::blocking(indices, [&](int band) {
const qsizetype start = band * size;
if (start < count)
body(start, std::min(size, count - start));
});
} catch (const std::bad_alloc &) {
// Out of memory fails as a context would.
throw ExportError(ExportError::Kind::render);
}
}
void BrushRaster::draw(const QImage &image, const QRectF &rect, QPainter &context, const QRectF &source)
{
context.save();
+4
View File
@@ -46,6 +46,10 @@ struct BrushSettings {
// Shared raster drawing; coverage is copied without color conversion.
namespace BrushRaster {
QImage context(int width, int height, bool mask);
// A colour context holding `image`; its own layout copies bytewise.
QImage copy(const QImage &image);
// Runs `body` over `count` pixels, band by band: start, length.
void inBands(qsizetype count, const std::function<void(qsizetype, qsizetype)> &body);
// Copies `image`, or its `source` part, into `rect`.
void draw(const QImage &image, const QRectF &rect, QPainter &context, const QRectF &source = QRectF());
// A solid colour through gray coverage, and a clip, 1:1.
+27 -18
View File
@@ -79,25 +79,34 @@ bool CameraRawCurveSettings::adjusts() const
|| !isLinear(green) || !isLinear(blue);
}
double CameraRawCurveSettings::parametric(double tone) const
// Bends tones below `lower` by `low`, above `upper` by `high`.
double CameraRawCurveSettings::bend(double tone, double lower, double low, double upper, double high)
{
const double shadow = shadowSplit / 100, dark = darkSplit / 100, light = lightSplit / 100;
double amount, lo, hi;
if (tone < shadow)
amount = shadows, lo = 0, hi = shadow;
else if (tone < dark)
amount = darks, lo = shadow, hi = dark;
else if (tone < light)
amount = lights, lo = dark, hi = light;
else
amount = highlights, lo = light, hi = 1;
const double span = std::max(0.02, hi - lo);
const double weight = 1 - std::abs(tone - (lo + hi) / 2) / (span / 2);
// A tone in its region weighs 0 to 1, unfloored.
return std::min(1.0, std::max(0.0, tone + (amount / 100) * weight * 0.22));
// Fitted to Photoshop: Darks −51 dips 0.1 there.
const double strength = 1.66;
if (tone < lower)
return lower * std::pow(tone / lower, std::pow(2, -low / 100 * strength));
if (tone > upper) {
const double rest = 1 - upper;
return 1 - rest * std::pow((1 - tone) / rest, std::pow(2, high / 100 * strength));
}
return tone;
}
std::vector<float> CameraRawCurveSettings::lumaTable() const
double CameraRawCurveSettings::parametric(double tone) const
{
if (shadows == 0 && darks == 0 && lights == 0 && highlights == 0)
return tone;
// Gamma bends, smoothed as Image › Curves smooths.
std::vector<CurvePoint> anchors;
for (int index = 0; index <= 32; ++index) {
const double x = index / 32.0;
anchors.push_back({x, bend(bend(x, shadowSplit / 100, shadows, lightSplit / 100, highlights), darkSplit / 100, darks, darkSplit / 100, lights)});
}
return point(tone, anchors);
}
std::vector<float> CameraRawCurveSettings::toneTable() const
{
std::vector<float> table(256);
for (int index = 0; index < 256; ++index)
@@ -262,10 +271,10 @@ void CameraRawSettings::applyCurveColor(uchar *pixels, int width, int height, qs
const CameraRawCurveSettings shape = curve.normalized();
const CameraRawMixerSettings mixing = mixer.normalized();
const CameraRawGradingSettings grade = grading.normalized();
const std::vector<float> luma = shape.lumaTable(), redTable = shape.channelTable(shape.red), greenTable = shape.channelTable(shape.green),
const std::vector<float> tone = shape.toneTable(), redTable = shape.channelTable(shape.red), greenTable = shape.channelTable(shape.green),
blueTable = shape.channelTable(shape.blue), mixerFloats = mixing.mixerFloats(), pointFloats = mixing.pointFloats(),
gradeFloats = grade.gradeFloats();
adjust_camera_raw_curve_color(pixels, size_t(width), size_t(height), size_t(stride), luma.data(), redTable.data(), greenTable.data(),
adjust_camera_raw_curve_color(pixels, size_t(width), size_t(height), size_t(stride), tone.data(), redTable.data(), greenTable.data(),
blueTable.data(), shape.refineSaturation / 100, mixerFloats.data(), int(mixing.points.size()), pointFloats.data(),
gradeFloats.data(), grade.blending / 100, grade.balance / 100, visualize);
}
+6 -2
View File
@@ -33,15 +33,19 @@ struct CameraRawCurveSettings {
static std::vector<CurvePoint> strongContrast();
static bool isLinear(const std::vector<CurvePoint> &points);
bool adjusts() const;
// Lifts or lowers the region a tone belongs to.
// Photoshop's parametric curve: gamma bends, smoothed as Curves.
double parametric(double tone) const;
std::vector<float> lumaTable() const;
// Applied to red, green and blue alike.
std::vector<float> toneTable() const;
std::vector<float> channelTable(const std::vector<CurvePoint> &points) const;
CameraRawCurveSettings nudged(CameraRawPointChannel channel, double tone, double delta) const;
// Swift's region(for:): the parametric amount a tone moves.
double &region(double tone);
CameraRawCurveSettings normalized() const;
friend bool operator==(const CameraRawCurveSettings &, const CameraRawCurveSettings &) = default;
private:
static double bend(double tone, double lower, double low, double upper, double high);
};
// One picked colour and how far its adjustment reaches.
+62 -5
View File
@@ -1,17 +1,28 @@
#include "Document/Dither.h"
#include "Document/BrushStroke.h"
#include "Document/PixelAdjust.h"
#include "IO/ImageExporter.h"
#include "Rendering/PoolMap.h"
#include <QFontMetricsF>
#include <QPainter>
#include <QPainterPath>
#include <QTextBoundaryFinder>
#include <algorithm>
#include <cmath>
#include <numeric>
#include <set>
extern "C" {
#include "DitherPixels.h"
}
// The kernel's bands, where Swift's kernel calls dispatch_apply.
extern "C" void dither_bands(size_t bands, void (*band)(void *context, size_t index), void *context)
{
std::vector<size_t> indices(bands);
std::iota(indices.begin(), indices.end(), size_t(0));
PoolMap::blocking(indices, [band, context](size_t index) { band(context, index); });
}
// Swift's Dither.swift: the settings, the chunky pixels, the glyphs.
namespace {
// Swift's Characters: the text's graphemes.
@@ -53,6 +64,7 @@ QString rawValue(DitherStyle style)
case DitherStyle::diamonds: return QStringLiteral("Halftone Diamonds");
case DitherStyle::patterns: return QStringLiteral("Mac Patterns");
case DitherStyle::ascii: return QStringLiteral("ASCII");
case DitherStyle::scanlines: return QStringLiteral("Scanlines (CRT)");
}
throw std::logic_error("unknown dither style");
}
@@ -80,7 +92,12 @@ bool isHalftone(DitherStyle style)
bool drawsMarks(DitherStyle style)
{
return !hasTones(style);
return !hasTones(style) && style != DitherStyle::scanlines;
}
bool usesPixelSize(DitherStyle style)
{
return style != DitherStyle::ascii && style != DitherStyle::scanlines;
}
QString rawValue(DitherPixelShape shape)
@@ -105,6 +122,10 @@ DitherSettings DitherSettings::normalized() const
result.pixelSize = std::round(clamp(pixelSize, pixelSizeLow, pixelSizeHigh, 2));
result.cellSize = std::round(clamp(cellSize, cellSizeLow, cellSizeHigh, 8));
result.textSize = std::round(clamp(textSize, textSizeLow, textSizeHigh, 14));
result.lineSpacing = std::round(clamp(lineSpacing, lineSpacingLow, lineSpacingHigh, 4));
result.glow = clamp(glow, 0, 100, 35);
result.dots = clamp(dots, 0, 100, 0);
result.wobble = clamp(wobble, wobbleLow, wobbleHigh, 0);
result.angle = clamp(angle, -90, 90, 45);
result.levels = std::round(clamp(levels, levelsLow, levelsHigh, 2));
result.diffusion = clamp(diffusion, 0, 100, 100);
@@ -125,8 +146,8 @@ DitherSettings DitherSettings::normalized() const
QImage DitherSettings::apply(const QImage &image) const
{
const DitherSettings settings = normalized();
// ASCII draws at full resolution: chunks would blur its letters.
const int block = settings.style == DitherStyle::ascii ? 1 : int(settings.pixelSize);
// ASCII and Scanlines draw full size: chunks would blur them.
const int block = usesPixelSize(settings.style) ? int(settings.pixelSize) : 1;
const QImage source = image.convertToFormat(QImage::Format_RGBA8888_Premultiplied);
if (source.isNull())
throw ExportError(ExportError::Kind::render);
@@ -153,6 +174,8 @@ QImage DitherSettings::apply(const QImage &image) const
}
}
const QImage dithered = settings.dither(working);
if (settings.style == DitherStyle::scanlines && settings.glow > 0)
return settings.glowing(dithered);
if (block <= 1)
return dithered;
// Blown back up without smoothing.
@@ -171,9 +194,42 @@ QImage DitherSettings::apply(const QImage &image) const
return full;
}
// The lines' light blurred wide and added back: phosphor bloom.
QImage DitherSettings::glowing(const QImage &image) const
{
// Blurred small, scaled back up: same light, less work.
const double sigma = lineSpacing * 3 + 3, shrink = std::max(1.0, std::floor(sigma / 4));
const int width = image.width(), height = image.height();
// Swift clamps first: edges repeat outward before shrinking.
const int step = int(shrink), pad = int(std::ceil(sigma * 3 / shrink)) * step;
QImage padded = BrushRaster::context(width + 2 * pad, height + 2 * pad, false);
for (int y = 0; y < padded.height(); ++y) {
const uchar *source = image.constScanLine(std::clamp(y - pad, 0, height - 1));
uchar *out = padded.scanLine(y);
for (int x = 0; x < padded.width(); ++x)
std::copy_n(source + std::clamp(x - pad, 0, width - 1) * 4, 4, out + x * 4);
}
const int smallWidth = (padded.width() + step - 1) / step, smallHeight = (padded.height() + step - 1) / step;
const QImage small = padded.scaled(smallWidth, smallHeight, Qt::IgnoreAspectRatio, Qt::SmoothTransformation);
if (small.isNull())
throw ExportError(ExportError::Kind::render);
const QImage blurred = PixelAdjust::gaussianBlur(small, sigma / shrink, true)
.scaled(smallWidth * step, smallHeight * step, Qt::IgnoreAspectRatio, Qt::SmoothTransformation)
.convertToFormat(QImage::Format_RGBA8888_Premultiplied);
QImage result = image.copy();
if (blurred.isNull() || result.isNull())
throw ExportError(ExportError::Kind::render);
// The scaled light's own rows, cropped to the image.
QImage bloom = BrushRaster::context(width, height, false);
for (int y = 0; y < height; ++y)
std::copy_n(blurred.constScanLine(y + pad) + pad * 4, width * 4, bloom.scanLine(y));
dither_glow(result.bits(), bloom.constBits(), size_t(width), size_t(height), size_t(result.bytesPerLine()), float(glow / 100 * 2.5));
return result;
}
QImage DitherSettings::dither(const QImage &image) const
{
const int cell = int(cellSize);
const int cell = int(style == DitherStyle::scanlines ? lineSpacing : cellSize);
const Glyphs glyphs = style == DitherStyle::ascii ? DitherSettings::glyphs(characters.isEmpty() ? defaultCharacters() : characters, int(textSize)) : Glyphs();
const bool two = colors == DitherColors::twoColors;
const std::array<uint8_t, 3> darkColor = two ? bytes(dark) : std::array<uint8_t, 3>{0, 0, 0};
@@ -184,7 +240,8 @@ QImage DitherSettings::dither(const QImage &image) const
.contrast = float(contrast / 100), .cell = cell, .angle = float(angle * M_PI / 180), .lightOnDark = lightOnDark ? 1 : 0,
.originalColors = colors == DitherColors::original ? 1 : 0, .dark = {darkColor[0], darkColor[1], darkColor[2]},
.light = {lightColor[0], lightColor[1], lightColor[2]}, .glyphWidth = glyphs.width, .glyphHeight = glyphs.height, .glyphs = glyphs.maps.data(),
.glyphCoverage = glyphs.coverage.data(), .glyphCount = int(glyphs.coverage.size())};
.glyphCoverage = glyphs.coverage.data(), .glyphCount = int(glyphs.coverage.size()), .dots = float(dots / 100),
.wobble = float(wobble)};
failed = dither_apply(pixels, size_t(width), size_t(height), size_t(stride), &params) == 0;
});
if (failed)
+15 -4
View File
@@ -6,10 +6,10 @@
#include <vector>
// Filter › Dither's looks, in the panel's order and DitherPixels.h's.
enum class DitherStyle { atkinson, floydSteinberg, bayer2, bayer4, bayer8, dots, lines, diamonds, patterns, ascii };
inline constexpr std::array allDitherStyles{DitherStyle::atkinson, DitherStyle::floydSteinberg, DitherStyle::bayer2, DitherStyle::bayer4,
DitherStyle::bayer8, DitherStyle::dots, DitherStyle::lines, DitherStyle::diamonds,
DitherStyle::patterns, DitherStyle::ascii};
enum class DitherStyle { atkinson, floydSteinberg, bayer2, bayer4, bayer8, dots, lines, diamonds, patterns, ascii, scanlines };
inline constexpr std::array allDitherStyles{DitherStyle::atkinson, DitherStyle::floydSteinberg, DitherStyle::bayer2, DitherStyle::bayer4,
DitherStyle::bayer8, DitherStyle::dots, DitherStyle::lines, DitherStyle::diamonds,
DitherStyle::patterns, DitherStyle::ascii, DitherStyle::scanlines};
QString rawValue(DitherStyle style);
// Swift's groups: diffusion, ordered, halftone, marks.
int ditherGroup(DitherStyle style);
@@ -19,6 +19,8 @@ bool diffuses(DitherStyle style);
bool hasTones(DitherStyle style);
bool isHalftone(DitherStyle style);
bool drawsMarks(DitherStyle style);
// ASCII and Scanlines draw at full resolution, not chunky.
bool usesPixelSize(DitherStyle style);
// A chunky pixel: a square, or a dot on dark.
enum class DitherPixelShape { square, dot };
@@ -35,6 +37,8 @@ struct DitherSettings {
static constexpr double cellSizeLow = 4, cellSizeHigh = 64;
static constexpr double textSizeLow = 6, textSizeHigh = 64;
static constexpr double levelsLow = 2, levelsHigh = 8;
static constexpr double lineSpacingLow = 2, lineSpacingHigh = 32;
static constexpr double wobbleLow = 0, wobbleHigh = 64;
static QString defaultCharacters() { return QStringLiteral(" .:-=+*#%@"); }
DitherStyle style = DitherStyle::atkinson;
// Each dithered pixel covers this many layer pixels a side.
@@ -44,6 +48,12 @@ struct DitherSettings {
double cellSize = 8;
// ASCII's line height in layer pixels; letters about 0.6 wide.
double textSize = 14;
// Scanlines: the lines' distance apart, in layer pixels.
double lineSpacing = 4;
// Glow and dots 0–100%; wobble sideways in pixels.
double glow = 35;
double dots = 0;
double wobble = 0;
// The halftone screen's angle, in degrees.
double angle = 45;
// Tones per channel; 2 is 1-bit.
@@ -76,4 +86,5 @@ struct DitherSettings {
private:
QImage dither(const QImage &image) const;
QImage glowing(const QImage &image) const;
};
+2 -2
View File
@@ -104,8 +104,8 @@ struct SessionJobs {
FilterSettings settings;
// Camera Raw's scope of the grade itself.
std::optional<CameraRawScope> scope = std::nullopt;
// Its grid's version: a newer grid drops it.
quint64 sourceVersion = 0;
// The grown layer it was made from: it shows there.
std::optional<LayerTransform> placement = std::nullopt;
};
struct FilterMade {
std::optional<ImportedImage> asset;
+2 -1
View File
@@ -75,7 +75,8 @@ struct CanvasDocument {
QSizeF size() const { return QSizeF(width, height); }
static std::optional<int> validDimension(const QString &value);
std::vector<LayerHierarchy::Entry> hierarchyEntries() const;
// Drawing order and what shows (LayerOrder), kept between asks.
LayerOrder::Result hierarchy() const;
QSet<QUuid> effectiveVisibleIDs() const;
QHash<QUuid, double> effectiveOpacities() const;
std::vector<ImageLayer> renderLayers() const;
+2 -2
View File
@@ -27,7 +27,7 @@ std::optional<ProjectSnapshot> EditorSession::projectSnapshot() const
void EditorSession::installProject(const ProjectSnapshot &snapshot, const QString &path)
{
m_collapsedGroupIDs.clear();
m_isMaskSelected = false;
setIsMaskSelected(false);
m_cropRect.reset();
m_guideDrag = std::nullopt;
m_transformEdit = std::nullopt;
@@ -78,7 +78,7 @@ void EditorSession::setProjectPath(std::optional<QString> path)
void EditorSession::clearProject()
{
m_collapsedGroupIDs.clear();
m_isMaskSelected = false;
setIsMaskSelected(false);
m_cropRect.reset();
m_transformEdit = std::nullopt;
m_guideDrag = std::nullopt;
+2 -2
View File
@@ -257,8 +257,8 @@ LayerTransform EditorSession::displayedTransform(const ImageLayer &layer) const
if (const std::optional<LayerTransform> pending = pendingTransform(layer))
return *pending;
// Content-Aware Fill past the edge previews on the grown layer.
if (m_filterEdit && m_filterEdit->grownTransform && m_filterEdit->previewImage(layer.id))
return *m_filterEdit->grownTransform;
if (m_filterEdit && m_filterEdit->preparedTransform && m_filterEdit->previewImage(layer.id))
return *m_filterEdit->preparedTransform;
return layer.transform;
}
+17 -2
View File
@@ -35,7 +35,7 @@ void EditorSession::setActiveLayerID(std::optional<QUuid> id)
{
// Another layer: its pixels are the target again.
if (id != m_activeLayerID)
m_isMaskSelected = false;
setIsMaskSelected(false);
m_activeLayerID = id;
m_selectedLayerIDs = id ? QSet<QUuid>{*id} : QSet<QUuid>();
notify();
@@ -234,7 +234,7 @@ void EditorSession::restore(const DocumentHistory::Snapshot &snapshot)
m_document = snapshot.document;
setActiveLayerID(snapshot.activeLayerID);
const std::optional<ImageLayer> active = activeLayer();
m_isMaskSelected = keepsMaskTarget && active && active->mask;
setIsMaskSelected(keepsMaskTarget && active && active->mask);
if (changedCanvas && m_document)
viewport.fit(m_document->size());
qCInfo(lcApp) << "restored a history snapshot of" << (m_document ? int(m_document->layers.size()) : 0) << "layers";
@@ -381,3 +381,18 @@ void EditorSession::requestCanvasFocus()
++m_canvasFocusRequest;
notify();
}
void EditorSession::setIsMaskSelected(bool value)
{
m_isMaskSelected = value;
if (!value)
m_viewsMaskAlone = false;
}
void EditorSession::setViewsMaskAlone(bool value)
{
if (m_viewsMaskAlone == value)
return;
m_viewsMaskAlone = value;
notify();
}
+4 -3
View File
@@ -93,10 +93,11 @@ public:
void selectTool(NavigationTool value);
// Tab: the current tool's next mode.
void cycleToolMode();
// Whether the mask thumbnail, not the layer's, is the target.
bool isMaskSelected() const { return m_isMaskSelected; }
// Alt-click: the targeted mask alone, gray, on the canvas.
bool viewsMaskAlone() const { return m_viewsMaskAlone; }
void setViewsMaskAlone(bool value);
// A blend mode tried on by hovering; nothing is saved.
struct BlendPreview {
QUuid layerID;
@@ -132,7 +133,6 @@ public:
// An unlinked mask, selected, transforms without its layer.
bool transformTargetsMask() const;
void nudgeLayer(double dx, double dy);
const std::optional<LassoDraft> &lassoDraft() const { return m_lassoDraft; }
LassoKind lassoKind() const { return m_lassoKind; }
void setLassoKind(LassoKind kind);
@@ -350,6 +350,7 @@ private:
std::optional<BlendPreview> m_blendPreview;
std::optional<QUuid> m_opacityEditLayerID;
bool m_isMaskSelected = false;
bool m_viewsMaskAlone = false;
std::optional<QUuid> m_activeLayerID;
bool m_isProjectBusy = false;
bool m_showsNewDocument = false;
+13 -17
View File
@@ -80,10 +80,6 @@ void EditorSession::updateFilter(const FilterSettings &settings, bool preview)
if (FilterEdit::blurMargin(edit.kind, edit.settings) > edit.grownMargin) {
try {
edit.growForBlur();
edit.preparedPreview.reset();
// A waiting job moves to the grid its version names.
if (edit.pending)
edit.pending = edit.previewJob();
} catch (const ProjectError &error) {
setBrushError(QString::fromUtf8(error.what()));
} catch (const ExportError &error) {
@@ -111,6 +107,7 @@ void EditorSession::updateFilter(const FilterSettings &settings, bool preview)
return;
}
edit.pending = edit.previewJob();
edit.pendingTransform = edit.grownTransform;
renderFilterPreview();
}
@@ -127,22 +124,22 @@ void EditorSession::renderFilterPreview()
const FilterJob job = *std::exchange(edit.pending, std::nullopt);
m_filterPreviewFor = edit.id;
m_filterRendering = true;
m_filterPreview.setFuture(QtConcurrent::run([job, version = edit.previewSourceVersion]() -> Filtered {
m_filterPreview.setFuture(QtConcurrent::run([job, placement = edit.pendingTransform]() -> Filtered {
try {
if (job.kind == FilterKind::cameraRaw) {
auto [image, scope] = CameraRawScope::preview(job);
return Filtered{std::move(image), std::nullopt, job.settings, std::move(scope), version};
return Filtered{std::move(image), std::nullopt, job.settings, std::move(scope), placement};
}
return Filtered{PixelFilter::run(job), std::nullopt, job.settings, std::nullopt, version};
return Filtered{PixelFilter::run(job), std::nullopt, job.settings, std::nullopt, placement};
} catch (const ContentFillError &error) {
return Filtered{std::nullopt, QString::fromUtf8(error.what()), job.settings, std::nullopt, version};
return Filtered{std::nullopt, QString::fromUtf8(error.what()), job.settings, std::nullopt, placement};
} catch (const SubjectRemovalError &error) {
return Filtered{std::nullopt, QString::fromUtf8(error.what()), job.settings, std::nullopt, version};
return Filtered{std::nullopt, QString::fromUtf8(error.what()), job.settings, std::nullopt, placement};
} catch (const ExportError &error) {
return Filtered{std::nullopt, QString::fromUtf8(error.what()), job.settings, std::nullopt, version};
return Filtered{std::nullopt, QString::fromUtf8(error.what()), job.settings, std::nullopt, placement};
} catch (const ProjectError &error) {
// Settings past their bounds: Black & White, Color Balance.
return Filtered{std::nullopt, QString::fromUtf8(error.what()), job.settings, std::nullopt, version};
return Filtered{std::nullopt, QString::fromUtf8(error.what()), job.settings, std::nullopt, placement};
}
}));
}
@@ -153,18 +150,17 @@ void EditorSession::finishFilterPreview()
{
m_filterRendering = false;
const Filtered result = m_filterPreview.result();
// A cancelled edit takes nothing; an older grid renders again.
if (m_filterEdit && m_filterEdit->id == m_filterPreviewFor && result.sourceVersion != m_filterEdit->previewSourceVersion) {
m_filterEdit->preparing = false;
notify();
} else if (m_filterEdit && m_filterEdit->id == m_filterPreviewFor) {
// A cancelled edit takes nothing.
if (m_filterEdit && m_filterEdit->id == m_filterPreviewFor) {
FilterEdit &edit = *m_filterEdit;
edit.preparing = false;
edit.previewError = result.failure;
if (result.scope)
edit.rawPanel.scope = result.scope;
// Made before the layer grew, it shows there till replaced.
if (edit.preview || isAutomatic(edit.kind)) {
edit.preparedPreview = result.image;
edit.preparedTransform = result.placement;
edit.preparedSettings = result.settings;
++m_brushRevision;
}
@@ -351,7 +347,7 @@ void EditorSession::finishFilterCommit()
mask.isEnabled = true;
mask.placement = std::nullopt;
m_document->layers[index].mask = mask;
m_isMaskSelected = true;
setIsMaskSelected(true);
endEdit();
finish();
return;
-1
View File
@@ -272,7 +272,6 @@ void FilterEdit::grow(const QRectF &extent)
void FilterEdit::prepare(const ImportedImage &source, const LayerTransform &placed)
{
const QSize size = source.size();
++previewSourceVersion;
mapping = BrushRaster::pixelToDocument(placed, size.width(), size.height());
// Noise, grain and dither preview whole: enlarged, they look coarse.
const bool whole = kind == FilterKind::addNoise || kind == FilterKind::grain || kind == FilterKind::dither || kind == FilterKind::contentAwareFill
+4 -2
View File
@@ -171,10 +171,12 @@ public:
// Add Noise's grain, fixed while the panel is open.
const quint32 seed = QRandomGenerator::global()->generate();
std::optional<QImage> preparedPreview;
// Where the prepared preview sits; none: the layer's own place.
std::optional<LayerTransform> preparedTransform;
// The grown layer `pending` is made from.
std::optional<LayerTransform> pendingTransform;
// The settings the prepared preview was made with.
std::optional<FilterSettings> preparedSettings;
// Moves with each new grid: an older render lands nothing.
quint64 previewSourceVersion = 0;
std::optional<FilterJob> pending;
CameraRawPanel rawPanel;
// Vignette on an empty layer: the canvas it fills.
+62 -59
View File
@@ -1,13 +1,14 @@
#include "Document/BrushStroke.h"
#include "Rendering/PoolMap.h"
#include "Document/HueSaturation.h"
#include "Document/PixelAdjust.h"
#include "IO/ImageExporter.h"
#include <QPainter>
#include <QtConcurrent>
#include <algorithm>
#include <cmath>
#include <numeric>
#include <deque>
#include <mutex>
extern "C" {
#include "LevelsPixels.h"
}
namespace {
struct HSL {
@@ -48,38 +49,38 @@ HueSaturationFilter::Color toRGB(double hue, double saturation, double lightness
return {clamp(color.red), clamp(color.green), clamp(color.blue)};
}
// Core Image's colour cube: unpremultiplied, trilinear, premultiplied back.
void applyCube(uchar *bits, qsizetype stride, int width, const std::vector<float> &cube, const std::vector<int> &rows)
// The last tables built: building costs more than applying.
struct CubeCache {
std::mutex lock;
std::deque<std::pair<HueSaturationSettings, std::vector<float>>> cubes;
};
CubeCache &cubeCache()
{
constexpr int size = HueSaturationFilter::dimension, last = size - 1;
PoolMap::blocking(rows, [&](int y) {
uchar *pixel = bits + y * stride;
for (int x = 0; x < width; ++x, pixel += 4) {
const float alpha = pixel[3];
if (alpha == 0) {
std::fill_n(pixel, 3, uchar(0));
continue;
static CubeCache cache;
return cache;
}
std::vector<float> built(const HueSaturationSettings &settings)
{
const std::vector<HueSaturationFilter::HueResponse> response = HueSaturationFilter::hueResponse(settings);
constexpr int dimension = HueSaturationFilter::dimension;
std::vector<float> values(size_t(dimension * dimension * dimension * 4));
size_t index = 0;
const double step = dimension - 1;
for (int blue = 0; blue < dimension; ++blue) {
for (int green = 0; green < dimension; ++green) {
for (int red = 0; red < dimension; ++red) {
const HueSaturationFilter::Color color = HueSaturationFilter::adjust(red / step, green / step, blue / step, settings, response);
values[index] = float(color.red);
values[index + 1] = float(color.green);
values[index + 2] = float(color.blue);
values[index + 3] = 1;
index += 4;
}
float place[3], fraction[3];
int low[3];
for (int channel = 0; channel < 3; ++channel) {
place[channel] = std::min(1.0f, pixel[channel] / alpha) * last;
low[channel] = std::min(int(place[channel]), last - 1);
fraction[channel] = place[channel] - float(low[channel]);
}
float out[3] = {0, 0, 0};
for (int corner = 0; corner < 8; ++corner) {
const int r = low[0] + (corner & 1), g = low[1] + (corner >> 1 & 1), b = low[2] + (corner >> 2 & 1);
const float share = ((corner & 1) ? fraction[0] : 1 - fraction[0]) * ((corner >> 1 & 1) ? fraction[1] : 1 - fraction[1])
* ((corner >> 2 & 1) ? fraction[2] : 1 - fraction[2]);
const float *entry = &cube[size_t(((b * size + g) * size + r) * 4)];
for (int channel = 0; channel < 3; ++channel)
out[channel] += share * entry[channel];
}
for (int channel = 0; channel < 3; ++channel)
pixel[channel] = uchar(std::lround(std::min(1.0f, std::max(0.0f, out[channel])) * alpha));
}
});
}
return values;
}
}
@@ -104,22 +105,20 @@ std::vector<HueSaturationFilter::HueResponse> HueSaturationFilter::hueResponse(c
std::vector<float> HueSaturationFilter::cube(const HueSaturationSettings &settings)
{
const std::vector<HueResponse> response = hueResponse(settings);
std::vector<float> values(size_t(dimension * dimension * dimension * 4));
size_t index = 0;
const double step = dimension - 1;
for (int blue = 0; blue < dimension; ++blue) {
for (int green = 0; green < dimension; ++green) {
for (int red = 0; red < dimension; ++red) {
const Color color = adjust(red / step, green / step, blue / step, settings, response);
values[index] = float(color.red);
values[index + 1] = float(color.green);
values[index + 2] = float(color.blue);
values[index + 3] = 1;
index += 4;
}
CubeCache &cache = cubeCache();
{
const std::lock_guard<std::mutex> held(cache.lock);
for (const auto &[known, values] : cache.cubes) {
if (known == settings)
return values;
}
}
std::vector<float> values = built(settings);
const std::lock_guard<std::mutex> held(cache.lock);
std::erase_if(cache.cubes, [&](const auto &entry) { return entry.first == settings; });
cache.cubes.emplace_front(settings, values);
if (cache.cubes.size() > 8)
cache.cubes.pop_back();
return values;
}
@@ -144,8 +143,7 @@ HueSaturationFilter::Color HueSaturationFilter::adjust(double red, double green,
hue = std::fmod(hue + sampled.shift, 360);
if (hue < 0)
hue += 360;
// Multiplied, so neutral grays stay neutral.
saturation = std::min(1.0, std::max(0.0, saturation * (1 + sampled.saturation / 100)));
saturation = adjustedSaturation(saturation, sampled.saturation);
}
// Toward white above zero, black below, reaching either at 100.
const double amount = std::min(1.0, std::max(-1.0, lightnessAmount));
@@ -153,6 +151,15 @@ HueSaturationFilter::Color HueSaturationFilter::adjust(double red, double green,
return toRGB(hue, saturation, std::min(1.0, std::max(0.0, lightness)));
}
double HueSaturationFilter::adjustedSaturation(double saturation, double amount)
{
const double share = std::min(1.0, std::max(-1.0, amount / 100));
// Multiplied both ways, so neutral grays stay neutral.
if (!(share > 0))
return std::max(0.0, saturation * (1 + share));
return share >= 1 ? (saturation > 0 ? 1 : 0) : std::min(1.0, saturation / (1 - share));
}
double HueSaturationFilter::shiftedHue(double hue, const HueSaturationSettings &settings)
{
double shift = 0;
@@ -166,23 +173,19 @@ double HueSaturationFilter::shiftedHue(double hue, const HueSaturationSettings &
AdjustedPixels HueSaturationFilter::run(const HueSaturationJob &job)
{
const int width = job.image.width(), height = job.image.height();
QImage result = BrushRaster::context(width, height, false);
{
QPainter painter(&result);
BrushRaster::draw(job.image, QRectF(0, 0, width, height), painter);
}
// Across the cores: it runs over the view each frame.
QImage result = BrushRaster::copy(job.image);
std::vector<float> table;
std::vector<int> rows;
try {
table = cube(job.settings);
rows.resize(size_t(height));
} catch (const std::bad_alloc &) {
// Out of memory fails as a context would.
throw ExportError(ExportError::Kind::render);
}
std::iota(rows.begin(), rows.end(), 0);
applyCube(result.bits(), result.bytesPerLine(), width, table, rows);
// The lookup unpremultiplies around itself.
uchar *const pixels = result.bits();
BrushRaster::inBands(qsizetype(result.width()) * result.height(),
[&](qsizetype start, qsizetype length) { cube_apply(pixels + start * 4, size_t(length), table.data(), dimension); });
// Swift blends over the image drawn again: the same pixels.
if (job.selection)
result = PixelAdjust::blend(result, job.image, *job.selection, job.pixelToDocument, false);
+2
View File
@@ -131,6 +131,8 @@ std::vector<HueResponse> hueResponse(const HueSaturationSettings &settings);
std::vector<float> cube(const HueSaturationSettings &settings);
Color adjust(double red, double green, double blue, const HueSaturationSettings &settings);
Color adjust(double red, double green, double blue, const HueSaturationSettings &settings, const std::vector<HueResponse> &response);
// Photoshop's: down toward gray, up by dividing by what's left.
double adjustedSaturation(double saturation, double amount);
// The hue a spectrum swatch becomes.
double shiftedHue(double hue, const HueSaturationSettings &settings);
}
+2 -1
View File
@@ -242,7 +242,8 @@ QImage LayerAdjustment::apply(const QImage &image, std::optional<QRectF> region,
FilterJob job{filterKind(kind).value(), image, settings, scale, std::nullopt, QTransform(), resolvedNoiseSeed()};
// Swift's region origin, counted in the image's own pixels.
if (region)
job.noiseOrigin = region->topLeft() * (image.width() / region->width());
job.noiseOrigin = QPointF(region->left() * image.width() / std::max(1.0, region->width()),
region->top() * image.height() / std::max(1.0, region->height()));
return PixelFilter::run(job);
}
case AdjustmentKind::invert: return PixelInvert::run(PixelInvert::Job{image, false, QTransform(), std::nullopt});
+1 -1
View File
@@ -97,7 +97,7 @@ void EditorSession::selectEffect(LayerEffectKind kind, QUuid id, bool editing)
finishEffectsEditing(false);
// Swift's `selectedLayerIDs = [id]`: selectLayer sets it, unrefused here.
selectLayer(id);
m_isMaskSelected = false;
setIsMaskSelected(false);
m_effectSelection = selection;
if (editing && m_effectsEditing != selection) {
m_effectsEditingOriginal = m_document->layers[size_t(indexOf(m_document->layers, id))].effects.value_or(LayerEffects());
+108
View File
@@ -0,0 +1,108 @@
#include "Document/EditorSession.h"
#include "Document/LayerGroups.h"
#include <map>
#include <mutex>
#include <stdexcept>
namespace {
using Children = std::map<std::optional<QUuid>, std::vector<const LayerOrder::Node *>>;
void visit(const Children &children, const std::optional<QUuid> &parent, int depth, bool shown, LayerOrder::Result &result)
{
if (depth > 64)
return;
const auto found = children.find(parent);
if (found == children.end())
return;
for (const LayerOrder::Node *node : found->second) {
const bool effective = shown && node->isVisible;
result.order.push_back(node->id);
if (effective) {
result.visible.insert(node->id);
if (!node->isGroup)
result.drawn.push_back(node->id);
}
if (node->isGroup)
visit(children, node->id, depth + 1, effective, result);
}
}
// The last nodes asked about and their answer.
struct Known {
std::mutex lock;
std::optional<std::pair<std::vector<LayerOrder::Node>, LayerOrder::Result>> last;
};
Known &known()
{
static Known value;
return value;
}
}
LayerOrder::Result LayerOrder::resolve(const std::vector<ImageLayer> &layers)
{
std::vector<Node> nodes;
nodes.reserve(layers.size());
for (const ImageLayer &layer : layers)
nodes.push_back(Node{layer.id, layer.parentID, layer.isGroup, layer.isVisible});
Known &cache = known();
{
const std::lock_guard<std::mutex> held(cache.lock);
if (cache.last && cache.last->first == nodes)
return cache.last->second;
}
Children children;
for (const Node &node : nodes)
children[node.parentID].push_back(&node);
Result result;
visit(children, std::nullopt, 0, true, result);
const std::lock_guard<std::mutex> held(cache.lock);
cache.last.emplace(std::move(nodes), result);
return result;
}
LayerOrder::Result CanvasDocument::hierarchy() const
{
return LayerOrder::resolve(layers);
}
QSet<QUuid> CanvasDocument::effectiveVisibleIDs() const
{
return hierarchy().visible;
}
QHash<QUuid, double> CanvasDocument::effectiveOpacities() const
{
// Each layer's opacity and folder, no whole layers copied.
QHash<QUuid, std::pair<double, std::optional<QUuid>>> folders;
for (const ImageLayer &layer : layers) {
if (folders.contains(layer.id))
throw std::logic_error("two layers share an id");
folders.insert(layer.id, {layer.opacity, layer.parentID});
}
QHash<QUuid, double> result;
for (auto entry = folders.cbegin(); entry != folders.cend(); ++entry) {
result.insert(entry.key(), LayerOpacity::effective(entry->first, entry->second, [&folders](QUuid id) {
return folders.contains(id) ? std::optional(folders.value(id)) : std::nullopt;
}));
}
return result;
}
std::vector<ImageLayer> CanvasDocument::renderLayers() const
{
const std::vector<QUuid> order = hierarchy().drawn;
if (order.empty())
return {};
std::map<QUuid, size_t> index;
for (size_t place = 0; place < layers.size(); ++place) {
if (!index.emplace(layers[place].id, place).second)
throw std::logic_error("two layers share an id");
}
std::vector<ImageLayer> result;
result.reserve(order.size());
for (const QUuid id : order)
result.push_back(layers[index.at(id)]);
return result;
}
+3
View File
@@ -7,6 +7,9 @@ public:
void selectLayers(const QSet<QUuid> &ids, std::optional<QUuid> primary);
void extendSelection(QUuid id);
void groupSelectedLayers();
// The active layer is a folder: something to unwrap.
bool canUngroupLayers() const;
void ungroupLayers();
std::vector<LayerHierarchy::Entry> layerRows() const;
bool canPlaceLayer(QUuid id, std::optional<QUuid> parent) const;
bool placeLayer(QUuid id, std::optional<QUuid> parent, std::optional<QUuid> above = std::nullopt, bool atBottom = false);
+42 -51
View File
@@ -121,19 +121,6 @@ double ProjectLayerRecord::effectiveOpacity(const std::map<QUuid, ProjectLayerRe
});
}
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();
@@ -159,40 +146,6 @@ ProjectLayerRecord ImageLayer::hierarchyRecord() const
.text = typed ? std::optional(typed->style) : std::nullopt};
}
std::vector<LayerHierarchy::Entry> CanvasDocument::hierarchyEntries() const
{
std::vector<ProjectLayerRecord> records;
for (const ImageLayer &layer : layers)
records.push_back(layer.hierarchyRecord());
return LayerHierarchy::entries(records);
}
QSet<QUuid> CanvasDocument::effectiveVisibleIDs() const
{
QSet<QUuid> result;
for (const LayerHierarchy::Entry &entry : hierarchyEntries()) {
if (entry.visible)
result.insert(entry.layer.id);
}
return result;
}
std::vector<ImageLayer> CanvasDocument::renderLayers() const
{
QHash<QUuid, const ImageLayer *> byID;
for (const ImageLayer &layer : layers) {
if (byID.contains(layer.id))
throw std::logic_error("two layers share an id");
byID.insert(layer.id, &layer);
}
std::vector<ImageLayer> result;
for (const LayerHierarchy::Entry &entry : hierarchyEntries()) {
if (entry.visible && entry.layer.isGroup != true)
result.push_back(*byID.value(entry.layer.id));
}
return result;
}
void EditorSession::selectLayers(const QSet<QUuid> &ids, std::optional<QUuid> primary)
{
dropEffectSelection();
@@ -264,10 +217,10 @@ void EditorSession::groupSelectedLayers()
}
std::vector<QUuid> ordered;
std::vector<Chain> chains;
for (const LayerHierarchy::Entry &entry : m_document->hierarchyEntries()) {
if (rootIDs.contains(entry.layer.id)) {
ordered.push_back(entry.layer.id);
chains.push_back(ancestors(entry.layer.id));
for (const QUuid id : m_document->hierarchy().order) {
if (rootIDs.contains(id)) {
ordered.push_back(id);
chains.push_back(ancestors(id));
}
}
// The nearest folder they all share; the root when none.
@@ -317,6 +270,44 @@ void EditorSession::groupSelectedLayers()
endEdit();
}
bool EditorSession::canUngroupLayers() const
{
const std::optional<ImageLayer> active = activeLayer();
return canEditLayers() && active && active->isGroup;
}
// The folder's children take its place, in order.
void EditorSession::ungroupLayers()
{
if (!canUngroupLayers())
return;
const ImageLayer group = activeLayer().value();
std::vector<ImageLayer> children;
for (const ImageLayer &layer : m_document->layers) {
if (layer.parentID == group.id) {
children.push_back(layer);
children.back().parentID = group.parentID;
}
}
std::vector<ImageLayer> layers;
QSet<QUuid> childIDs;
for (const ImageLayer &child : children)
childIDs.insert(child.id);
for (const ImageLayer &layer : m_document->layers) {
if (layer.id == group.id)
layers.insert(layers.end(), children.begin(), children.end());
else if (!childIDs.contains(layer.id))
layers.push_back(layer);
}
releaseDetachedClipping(layers);
finishOpacityEdit();
beginEdit(QStringLiteral("Ungroup Layers"));
m_document->layers = layers;
selectLayers(childIDs, children.empty() ? std::nullopt : std::optional(children.front().id));
m_collapsedGroupIDs.remove(group.id);
endEdit();
}
std::vector<LayerHierarchy::Entry> EditorSession::layerRows() const
{
std::vector<ProjectLayerRecord> records;
+23
View File
@@ -23,3 +23,26 @@ namespace LayerOpacity {
using Node = std::function<std::optional<std::pair<double, std::optional<QUuid>>>(QUuid)>;
double effective(double own, std::optional<QUuid> parent, const Node &folder);
}
struct ImageLayer;
// Swift's LayerOrder: the hierarchy from ids, folders and visibility alone.
namespace LayerOrder {
struct Node {
QUuid id;
std::optional<QUuid> parentID;
bool isGroup;
bool isVisible;
friend bool operator==(const Node &, const Node &) = default;
};
struct Result {
// Every layer and folder, as LayerHierarchy::entries lists them.
std::vector<QUuid> order;
// Visible, in folders that are.
QSet<QUuid> visible;
// The layers that show, folders left out, bottom to top.
std::vector<QUuid> drawn;
};
// Kept until a node changes: the canvas asks each event.
Result resolve(const std::vector<ImageLayer> &layers);
}
+21
View File
@@ -0,0 +1,21 @@
#include "Document/EditorSession.h"
// Swift's mask shown alone, an Alt-click on its thumbnail.
std::optional<ImageLayer> EditorSession::maskAloneLayer() const
{
const std::optional<ImageLayer> layer = activeLayer();
if (!m_viewsMaskAlone || !m_isMaskSelected || !layer || !layer->mask)
return std::nullopt;
return layer;
}
void EditorSession::toggleMaskAlone(QUuid id)
{
const std::optional<ImageLayer> shown = maskAloneLayer();
const bool showing = shown && shown->id == id;
selectLayerTarget(id, true);
if (m_activeLayerID != id || !m_isMaskSelected)
return;
m_viewsMaskAlone = !showing;
notify();
}
+6
View File
@@ -15,6 +15,12 @@ public:
std::optional<QImage> maskDistortPreview(const ImageLayer &layer) const;
// The selection made a mask; without one, a solid mask.
void addMask(bool revealing = true);
// The layer whose mask the canvas shows alone, if any.
std::optional<ImageLayer> maskAloneLayer() const;
// Alt-click on a mask thumbnail: alone, or the composite again.
void toggleMaskAlone(QUuid id);
private:
// Swift's didSet: the pixels targeted end the mask view.
void setIsMaskSelected(bool value);
void commitMaskTransform();
+9 -8
View File
@@ -262,7 +262,7 @@ void EditorSession::selectLayerTarget(QUuid id, bool mask)
resolveGradient();
selectLayer(id);
const std::optional<ImageLayer> active = activeLayer();
m_isMaskSelected = mask && active && active->mask;
setIsMaskSelected(mask && active && active->mask);
notify();
}
@@ -286,14 +286,15 @@ void EditorSession::addMask(bool revealing)
// The selection's coverage, soft where it is feathered.
QImage image = PixelAdjust::coverage(current->clip(m_document->size()), width, height,
BrushRaster::pixelToDocument(active->transform, width, height));
if (revealing)
// Revealing, white inside; hiding, black inside.
if (!revealing)
image.invertPixels();
const LayerMask mask(LayerMask::assetFrom(image));
finishOpacityEdit();
beginEdit(QStringLiteral("Add Mask from Selection"));
beginEdit(revealing ? QStringLiteral("Reveal Selection") : QStringLiteral("Hide Selection"));
m_document->layers[size_t(index)].mask = mask;
m_document->selection = std::nullopt;
m_isMaskSelected = true;
setIsMaskSelected(true);
endEdit();
} catch (const std::runtime_error &error) {
qCWarning(lcApp).noquote() << "cannot make a mask from the selection:" << error.what();
@@ -312,7 +313,7 @@ void EditorSession::addLayerMask(bool revealing)
if (layer.id == active->id)
layer.mask = LayerMask::solid(revealing);
}
m_isMaskSelected = true;
setIsMaskSelected(true);
endEdit();
}
@@ -341,7 +342,7 @@ void EditorSession::deleteLayerMask()
if (layer.id == active->id)
layer.mask = std::nullopt;
}
m_isMaskSelected = false;
setIsMaskSelected(false);
endEdit();
}
@@ -371,7 +372,7 @@ void EditorSession::copyMask(QUuid source, QUuid target)
beginEdit(find(target)->mask ? QStringLiteral("Replace Layer Mask") : QStringLiteral("Copy Layer Mask"));
find(target)->mask = mask;
selectLayer(target);
m_isMaskSelected = true;
setIsMaskSelected(true);
endEdit();
}
@@ -392,7 +393,7 @@ std::optional<LayerTransform> EditorSession::displayedMaskPlacement(const ImageL
if (!layer.mask)
return std::nullopt;
// Previewed on a grown layer, the mask keeps its bounds.
if (m_filterEdit && m_filterEdit->grownTransform && m_filterEdit->previewImage(layer.id))
if (m_filterEdit && m_filterEdit->preparedTransform && m_filterEdit->previewImage(layer.id))
return layer.mask->placement.value_or(layer.transform);
if (m_transformEdit && m_transformEdit->group) {
const TransformGroup &group = *m_transformEdit->group;
+5 -3
View File
@@ -104,15 +104,17 @@ QImage LevelsFilter::run(const LevelsJob &job)
{
if (job.settings.isIdentity())
return job.image;
QImage context = drawn(job.image);
QImage context = BrushRaster::copy(job.image);
// On the stack: a heap failure would escape the catch.
std::array<float, 3 * 256> tables;
for (size_t channel = 0; channel < 3; ++channel) {
for (size_t value = 0; value <= 255; ++value)
tables[channel * 256 + value] = float(job.settings.apply(double(value) / 255, allLevelsChannels[channel + 1]));
}
// The kernel unpremultiplies each edge itself (Swift's cc5aac4 does twice).
levels_apply(context.bits(), size_t(context.width()) * size_t(context.height()), tables.data());
// The kernel unpremultiplies edges itself; bands use every core.
uchar *const pixels = context.bits();
BrushRaster::inBands(qsizetype(context.width()) * context.height(),
[&](qsizetype start, qsizetype length) { levels_apply(pixels + start * 4, size_t(length), tables.data()); });
if (job.selection)
return PixelAdjust::blend(context, job.image, *job.selection, job.mapping, false);
return context;
+44 -3
View File
@@ -4,6 +4,7 @@
#include <QFileDialog>
#include <QFileInfo>
#include <QTimer>
#include <algorithm>
const QString ProjectWorkspace::layerType = QStringLiteral("com.compositor.layer-row");
@@ -96,6 +97,20 @@ void ProjectWorkspace::select(QUuid id)
emit changed();
}
void ProjectWorkspace::moveTab(QUuid id, int index)
{
const auto from = std::ranges::find_if(m_tabs, [id](const std::shared_ptr<ProjectTab> &tab) { return tab->id == id; });
if (from == m_tabs.end())
return;
const auto target = std::clamp<std::ptrdiff_t>(index, 0, std::ptrdiff_t(m_tabs.size()) - 1);
if (target == from - m_tabs.begin())
return;
const std::shared_ptr<ProjectTab> tab = *from;
m_tabs.erase(from);
m_tabs.insert(m_tabs.begin() + target, tab);
emit changed();
}
void ProjectWorkspace::newCanvas()
{
if (!canSwitch())
@@ -220,14 +235,40 @@ bool ProjectWorkspace::finishTextEditing()
return true;
}
void ProjectWorkspace::settlePendingEdits(std::function<void()> done)
{
// Canvas edits are applied; dialogs cancelled, as their Cancel.
const std::shared_ptr<ProjectTab> settled = tab(current().id);
settled->session.commitGradient([self = QPointer<ProjectWorkspace>(this), settled, done] {
settled->session.finishPixelMove([self, settled, done] {
EditorSession &session = settled->session;
session.cancelFilter();
session.cancelHueSaturation();
session.cancelLevels();
session.cancelColorRange();
session.setSelectionAmountOperation(std::nullopt);
session.closeColorPicker(false);
if (self)
done();
});
});
}
void ProjectWorkspace::confirmQuit(std::function<void(bool)> done)
{
if (!finishTextEditing() || !canSwitch()) {
if (m_isManaging || !finishTextEditing()) {
finish(done, false);
return;
}
setManaging(true);
askNext(quitOrder(), 0, std::move(done));
settlePendingEdits([this, done] {
if (!canSwitch()) {
qCWarning(lcApp) << "Quit refused: the project is still busy";
finish(done, false);
return;
}
setManaging(true);
askNext(quitOrder(), 0, done);
});
}
void ProjectWorkspace::askNext(std::vector<std::shared_ptr<ProjectTab>> order, size_t index, std::function<void(bool)> done)
+4
View File
@@ -36,12 +36,16 @@ public:
bool canSwitch() const;
ProjectTab &addTab(bool reuseEmpty = true);
void select(QUuid id);
// A dragged tab lands at `index`, clamped; chrome, never undone.
void moveTab(QUuid id, int index);
void newCanvas();
// Each `done` runs from the event loop, never inline.
void open(std::optional<QString> path = std::nullopt, std::function<void(bool)> done = {});
void close(QUuid id, std::function<void()> done = {});
void removeTab(QUuid id);
std::vector<std::shared_ptr<ProjectTab>> quitOrder() const;
// Applies canvas edits and cancels dialogs before a quit.
void settlePendingEdits(std::function<void()> done);
void confirmQuit(std::function<void(bool)> done);
void closeWindow(QWidget *closing);
void receive(const QList<QUrl> &urls, std::optional<QUuid> destination = std::nullopt,
+33
View File
@@ -45,6 +45,24 @@ QByteArray encode(QImage image, const char *format, int quality, double resoluti
throw ExportError(ExportError::Kind::encode);
return data;
}
QByteArray scaledJPEG(const QImage &image, double longSide)
try {
const double scale = std::min(1.0, longSide / std::max(image.width(), image.height()));
const QSize size(std::max(1, int(std::round(image.width() * scale))), std::max(1, int(std::round(image.height() * scale))));
const QImage scaled = image.scaled(size, Qt::IgnoreAspectRatio, Qt::SmoothTransformation);
QImage flattened(size, QImage::Format_RGB888);
if (scaled.isNull() || flattened.isNull())
throw ExportError(ExportError::Kind::render);
flattened.fill(Qt::white);
{
QPainter painter(&flattened);
painter.drawImage(QRectF(flattened.rect()), scaled);
}
return encode(flattened, "jpeg", 80, 72);
} catch (const std::bad_alloc &) {
throw ExportError(ExportError::Kind::render);
}
}
ExportError::ExportError(Kind kind) : std::runtime_error(description(kind)), kind(kind) {}
@@ -118,6 +136,21 @@ try {
throw ExportError(ExportError::Kind::render);
}
std::optional<QuickLookImages> ImageExporter::quickLookImages(const ProjectSnapshot &snapshot)
{
if (snapshot.manifest.width * snapshot.manifest.height > 50'000'000) {
qCInfo(lcIO) << "no Quick Look preview for" << snapshot.manifest.width << "x" << snapshot.manifest.height;
return std::nullopt;
}
try {
return QuickLookImages{scaledJPEG(render(snapshot).image, 1024)};
} catch (const std::runtime_error &error) {
// Swift's try? saves without one; the log says why.
qCWarning(lcIO) << "no Quick Look preview:" << error.what();
return std::nullopt;
}
}
QByteArray ImageExporter::pngData(const ProjectSnapshot &snapshot)
{
const ExportRaster raster = render(snapshot);
+3
View File
@@ -3,6 +3,7 @@
#include <QByteArray>
#include <QImage>
#include <functional>
#include <optional>
#include <stdexcept>
class ExportError : public std::runtime_error {
@@ -39,6 +40,8 @@ struct JPEGResult {
namespace ImageExporter {
ExportRaster render(const ProjectSnapshot &snapshot);
// Flattened on white, 1024 a side; none past 50 megapixels.
std::optional<QuickLookImages> quickLookImages(const ProjectSnapshot &snapshot);
QByteArray pngData(const ProjectSnapshot &snapshot);
// Stops at Swift's three points once `cancelled` says so.
JPEGResult jpeg(const ExportRaster &raster, const JPEGOptions &options, const std::function<bool()> &cancelled = {});
+33 -1
View File
@@ -1,6 +1,10 @@
#include "IO/PSD/PSDDocumentBuilder.h"
#include "Document/PixelAdjust.h"
#include "IO/ProjectStore.h"
#include "Document/BrushStroke.h"
#include "IO/ImageExporter.h"
#include "Logging.h"
#include <QPainter>
namespace {
ImportedImage imported(const QImage &image, const QString &name)
@@ -114,7 +118,10 @@ PSDImport PSDDocumentBuilder::makeImport(const PSDDocument &document, const std:
ImageLayer layer = layerFor(record, canvas, assets, typed);
if (record.mask) {
try {
layer.mask = LayerMask(LayerMask::assetFrom(*record.mask), record.maskEnabled, std::nullopt, record.maskLinked);
const std::optional<QImage> placed = maskOnLayerGrid(*record.mask, record, layer, canvas);
if (!placed)
throw ExportError(ExportError::Kind::render);
layer.mask = LayerMask(LayerMask::assetFrom(*placed), record.maskEnabled, std::nullopt, record.maskLinked);
} catch (const std::runtime_error &) {
// Swift's `try?`: a mask that fails is reported, not fatal.
conversions.push_back(PSDConversion{.layerName = record.name,
@@ -147,3 +154,28 @@ PSDImport PSDDocumentBuilder::makeImport(const PSDDocument &document, const std:
}
return PSDImport{document.width, document.height, document.resolution, std::move(layers), std::move(conversions)};
}
std::optional<QImage> PSDDocumentBuilder::maskOnLayerGrid(const QImage &patch, const PSDRecord &record, const ImageLayer &layer, QSizeF canvas)
{
const QSizeF grid = layer.asset ? QSizeF(layer.asset->size()) : canvas;
const QRectF placed(layer.transform.origin, layer.transform.size);
if (grid.width() < 1 || grid.height() < 1 || !(placed.width() > 0) || !(placed.height() > 0) || !(record.maskBounds.width() > 0)
|| !(record.maskBounds.height() > 0))
return patch;
const double scaleX = grid.width() / placed.width(), scaleY = grid.height() / placed.height();
const QRectF rect((record.maskBounds.left() - placed.left()) * scaleX, (record.maskBounds.top() - placed.top()) * scaleY,
record.maskBounds.width() * scaleX, record.maskBounds.height() * scaleY);
const int width = int(grid.width()), height = int(grid.height());
// Already the layer's grid: nothing to place.
if (rect.toAlignedRect() == QRect(0, 0, width, height) && patch.size() == QSize(width, height))
return patch;
QImage context(width, height, QImage::Format_Grayscale8);
if (context.isNull()) {
qCWarning(lcIO) << "a PSD layer mask could not be placed on its layer:" << QSize(width, height);
return std::nullopt;
}
context.fill(uint(record.maskDefault));
QPainter painter(&context);
BrushRaster::draw(patch, rect, painter);
return context;
}
+2
View File
@@ -15,4 +15,6 @@ namespace PSDDocumentBuilder {
// Each pixel record's asset and thumbnail, off the UI thread.
std::map<QUuid, ImportedImage> assets(const PSDDocument &document);
PSDImport makeImport(const PSDDocument &document, const std::map<QUuid, ImportedImage> &assets = {});
// The stored patch on the layer's grid; none without memory.
std::optional<QImage> maskOnLayerGrid(const QImage &patch, const PSDRecord &record, const ImageLayer &layer, QSizeF canvas);
}
+25 -11
View File
@@ -1,6 +1,7 @@
#include "IO/PSD/PSDReader.h"
#include <QtEndian>
#include <algorithm>
#include <cmath>
namespace {
quint16 u16(const QByteArray &data, qsizetype at)
@@ -12,21 +13,23 @@ qint16 i16(const QByteArray &data, qsizetype at)
{
return qFromBigEndian<qint16>(data.constData() + at);
}
}
std::optional<LayerAdjustment> levels(const QByteArray &data)
std::optional<LayerAdjustment> PSDAdjustments::levels(const QByteArray &data)
{
if (data.size() < 292)
return std::nullopt;
LevelsSettings settings;
for (int channel = 0; channel < 4; ++channel) {
const qsizetype base = 2 + channel * 10;
settings.ranges[size_t(channel)] = LevelRange{double(u16(data, base)), double(u16(data, base + 8)) / 256, double(u16(data, base + 2)),
settings.ranges[size_t(channel)] = LevelRange{double(u16(data, base)), double(u16(data, base + 8)) / 100, double(u16(data, base + 2)),
double(u16(data, base + 4)), double(u16(data, base + 6))}
.normalized();
}
return LayerAdjustment{.kind = AdjustmentKind::levels, .levels = settings};
}
namespace {
std::optional<LayerAdjustment> curves(const QByteArray &data)
{
if (data.size() < 5)
@@ -66,22 +69,33 @@ std::optional<LayerAdjustment> curves(const QByteArray &data)
return std::nullopt;
return LayerAdjustment{.kind = AdjustmentKind::curves, .curves = settings};
}
}
std::optional<LayerAdjustment> hue(const QByteArray &data)
std::optional<LayerAdjustment> PSDAdjustments::hue(const QByteArray &data)
{
if (data.size() < 4)
if (data.size() < 16)
return std::nullopt;
HueSaturationSettings settings(0, 0, 0, data[2] != 0);
qsizetype at = 4;
for (const ColorRange range : allColorRanges) {
if (at + 6 > data.size())
const bool colorize = data[2] != 0;
HueSaturationSettings settings(0, 0, 0, colorize);
const auto values = [&data](qsizetype at) { return RangeAdjustment{double(i16(data, at)), double(i16(data, at + 2)), double(i16(data, at + 4))}; };
// Colorize has values of its own; else the Master's.
settings.adjustments[ColorRange::master] = values(colorize ? 4 : 10);
if (colorize)
return LayerAdjustment{.kind = AdjustmentKind::hsv, .hsvSettings = settings};
qsizetype at = 16;
for (const ColorRange range : colorRanges) {
if (at + 14 > data.size())
break;
settings.adjustments[range] = RangeAdjustment{double(i16(data, at)), double(i16(data, at + 2)), double(i16(data, at + 4))};
at += 6;
const auto degrees = [&data](qsizetype from) {
const double value = std::fmod(double(i16(data, from)), 360);
return value < 0 ? value + 360 : value;
};
settings.bands[range] = HueBand{degrees(at), degrees(at + 2), degrees(at + 4), degrees(at + 6)};
settings.adjustments[range] = values(at + 8);
at += 14;
}
return LayerAdjustment{.kind = AdjustmentKind::hsv, .hsvSettings = settings};
}
}
std::optional<LayerAdjustment> PSDAdjustments::parse(const std::map<QString, QByteArray> &extra)
{
+1 -2
View File
@@ -180,8 +180,7 @@ PSDRecords::RawLayer PSDRecords::readRecord(Cursor &cursor, bool isPSB)
layer.maskRight = cursor.i32();
layer.sourceMaskTop = layer.maskTop, layer.sourceMaskLeft = layer.maskLeft;
layer.sourceMaskBottom = layer.maskBottom, layer.sourceMaskRight = layer.maskRight;
// The mask's default colour is skipped, as Swift ignores it.
cursor.skip(1);
layer.maskDefault = cursor.u8();
const uchar flags = cursor.u8();
layer.maskDisabled = (flags & 2) != 0;
layer.maskLinked = (flags & 1) == 0;
+2
View File
@@ -50,6 +50,8 @@ struct RawLayer {
std::map<QString, QByteArray> extra;
qint64 maskTop = 0, maskLeft = 0, maskBottom = 0, maskRight = 0;
qint64 sourceMaskTop = 0, sourceMaskLeft = 0, sourceMaskBottom = 0, sourceMaskRight = 0;
// The mask's value outside its stored bounds.
uchar maskDefault = 255;
bool maskDisabled = false;
bool maskLinked = true;
bool maskFromRender = false;
+2
View File
@@ -80,6 +80,8 @@ std::vector<PSDRecord> assemble(const std::vector<RawLayer> &raw, QSizeF canvas,
}
}
record.mask = layer.maskFromRender ? std::nullopt : layer.maskImage;
record.maskBounds = QRectF(layer.maskLeft, layer.maskTop, layer.maskRight - layer.maskLeft, layer.maskBottom - layer.maskTop);
record.maskDefault = layer.maskDefault;
record.maskEnabled = !layer.maskDisabled;
record.maskLinked = layer.maskLinked;
if (!isGroup)
+4
View File
@@ -21,4 +21,8 @@ extern const std::set<QString> adjustmentKeys;
// Levels, Curves and Hue/Saturation layers, as Swift reads them.
namespace PSDAdjustments {
std::optional<LayerAdjustment> parse(const std::map<QString, QByteArray> &extra);
// 'levl': each channel's levels, gamma in hundredths.
std::optional<LayerAdjustment> levels(const QByteArray &data);
// 'hue2': Colorize or the Master, then each range's values.
std::optional<LayerAdjustment> hue(const QByteArray &data);
}
+3
View File
@@ -38,6 +38,9 @@ struct PSDRecord {
QRectF bounds;
std::optional<QImage> image;
std::optional<QImage> mask;
// Where `mask` sits, and Photoshop's value everywhere else.
QRectF maskBounds;
uchar maskDefault = 255;
bool maskEnabled = true;
bool maskLinked = true;
std::optional<LayerAdjustment> adjustment;
+2 -1
View File
@@ -1,6 +1,7 @@
#include "IO/ProjectController.h"
#include "IO/RecentProjects.h"
#include "Document/ProjectWorkspace.h"
#include "IO/ImageExporter.h"
#include "IO/ProjectStore.h"
#include "Logging.h"
#include <QFileDialog>
@@ -199,7 +200,7 @@ void ProjectController::write(const Prepared &prepared, std::function<void(bool)
// The store blocks on the disk: off the UI thread.
watcher->setFuture(QtConcurrent::run([snapshot, destination]() -> std::optional<QString> {
try {
ProjectStore::save(snapshot, destination);
ProjectStore::save(snapshot, destination, ImageExporter::quickLookImages(snapshot));
return std::nullopt;
} catch (const std::runtime_error &error) {
return QString::fromUtf8(error.what());
+8 -1
View File
@@ -276,7 +276,7 @@ ProjectError::ProjectError(Kind kind, std::optional<qint64> version, const QStri
{
}
void ProjectStore::save(const ProjectSnapshot &snapshot, const QString &path)
void ProjectStore::save(const ProjectSnapshot &snapshot, const QString &path, const std::optional<QuickLookImages> &quickLook)
{
validate(snapshot.manifest);
std::vector<std::pair<QString, QByteArray>> files;
@@ -323,6 +323,13 @@ void ProjectStore::save(const ProjectSnapshot &snapshot, const QString &path)
for (const auto &[filename, data] : files)
writeFile(staged + QLatin1String("/images/") + filename, data);
syncDirectory(staged + QLatin1String("/images"));
// Quick Look's Space-bar preview; loading ignores it.
if (quickLook) {
if (!QDir().mkdir(staged + QLatin1String("/QuickLook")))
fail(QStringLiteral("could not create"), staged + QLatin1String("/QuickLook"), QStringLiteral("the folder cannot be made"));
writeFile(staged + QLatin1String("/QuickLook/Preview.jpg"), quickLook->preview);
syncDirectory(staged + QLatin1String("/QuickLook"));
}
syncDirectory(staged);
replace(staged, destination.absoluteFilePath(), destination.absolutePath(), staging.keep);
qCInfo(lcIO) << "saved" << destination.absoluteFilePath() << "with" << snapshot.manifest.layers.size() << "layers and" << files.size() << "images";
+6 -1
View File
@@ -97,9 +97,14 @@ private:
ProjectError(Kind kind, std::optional<qint64> version, const QString &description);
};
// Swift's, in ImageExporter.swift; here, as the exporter includes the store.
struct QuickLookImages {
QByteArray preview;
};
// A .comp project: a directory holding manifest.json and images/.
namespace ProjectStore {
// Replaces the project in one step or leaves it untouched.
void save(const ProjectSnapshot &snapshot, const QString &path);
void save(const ProjectSnapshot &snapshot, const QString &path, const std::optional<QuickLookImages> &quickLook = std::nullopt);
ProjectSnapshot load(const QString &path);
}
+12 -3
View File
@@ -1,6 +1,7 @@
#include "Rendering/AdjustmentSurface.h"
#include "Document/BrushStroke.h"
#include "Logging.h"
#include "Rendering/LayerRenderer.h"
#include <QPaintEngine>
void AdjustmentSurface::draw(QPainter &context, const std::function<void(QPainter &)> &body, double padding, qint64 pixelBudget)
@@ -15,11 +16,18 @@ void AdjustmentSurface::draw(QPainter &context, const std::function<void(QPainte
// Spatial adjustments read a halo; the clip keeps what shows.
const QRectF shown = toDevice.inverted().mapRect(QRectF(output));
const QRect area = padding > 0 ? toDevice.mapRect(shown.adjusted(-padding, -padding, padding, padding)).toAlignedRect() : output;
if (qint64(area.width()) * area.height() > pixelBudget) {
// Device pixels; past the budget, painter units.
double scale = 1;
QSize size = area.size();
if (qint64(size.width()) * size.height() > pixelBudget) {
scale = 1 / LayerRenderer::deviceScale(context);
size = QSize(std::max(1, qRound(area.width() * scale)), std::max(1, qRound(area.height() * scale)));
}
if (qint64(size.width()) * size.height() > pixelBudget) {
qCWarning(lcRendering) << "an adjustment surface passes its pixel budget:" << area.size();
return;
}
QImage surface(area.size(), QImage::Format_RGBA8888_Premultiplied);
QImage surface(size, QImage::Format_RGBA8888_Premultiplied);
if (surface.isNull()) {
qCWarning(lcRendering) << "an adjustment surface could not be allocated:" << area.size();
return;
@@ -28,12 +36,13 @@ void AdjustmentSurface::draw(QPainter &context, const std::function<void(QPainte
{
// The body draws in the painter's own coordinates.
QPainter painter(&surface);
painter.setTransform(context.deviceTransform() * QTransform::fromTranslate(-area.left(), -area.top()));
painter.setTransform(context.deviceTransform() * QTransform::fromTranslate(-area.left(), -area.top()) * QTransform::fromScale(scale, scale));
body(painter);
}
// Drawn back under the painter's own opacity, mode and clip.
context.save();
context.setWorldTransform(context.deviceTransform().inverted() * context.worldTransform());
context.setRenderHint(QPainter::SmoothPixmapTransform, scale != 1);
context.drawImage(QRectF(area), surface);
context.restore();
}
+65
View File
@@ -0,0 +1,65 @@
#include "Rendering/EditorCanvas.h"
#include "IO/ImageExporter.h"
#include "Rendering/DownsampleCache.h"
#include "Rendering/LayerRenderer.h"
#include "Rendering/RasterSnapshot.h"
#include <QPainter>
// Swift's drawMaskAlone: gray across the canvas, edge tone past it.
void CanvasView::drawMaskAlone(const ImageLayer &layer, const CanvasDocument &document, double scale, const Center &center, QPainter &context)
{
const LayerMask &mask = layer.mask.value();
const int tone = qRound(LayerMask::background(mask.asset.thumbnail) * 255);
context.fillRect(QRectF(center(QPointF(0, 0)), document.size() * scale), QColor(tone, tone, tone));
const std::optional<GradientEdit> &gradient = m_session.gradientEdit();
const BrushStroke *stroke = m_session.brushStroke() ? m_session.brushStroke() : gradient ? gradient->raster.get() : nullptr;
// A stroke or gradient laid into it shows at once.
if (stroke && stroke->isMask && stroke->layer.id == layer.id) {
const LayerTransform &placed = stroke->paintTransform;
const std::shared_ptr<const RasterSnapshot> &raster = mask.asset.raster;
LayerRenderer::drawBrushPreview(raster ? QImage() : mask.asset.image(), placed, center(placed.center()), context, {.scale = scale},
{.patches = stroke->patches(), .pixelWidth = stroke->width, .pixelHeight = stroke->height,
.paintingMask = false, .sourceRect = stroke->sourceRect, .raster = raster});
return;
}
// Where a transform drag shows it, as the composite.
const LayerTransform placed = m_session.displayedMaskPlacement(layer).value_or(m_session.displayedTransform(layer));
LayerRenderer::draw(mask.asset.image(), placed, center(placed.center()), context, {.scale = scale});
}
// The folder's live mask, multiplied into the coverage.
FolderMaskClip::Applier CanvasView::liveFolderMaskClip(const BrushStroke &edit, double scale, const Center &center) const
{
return [&edit, scale, center](const QPainter &painter, QImage &coverage) {
QImage live(coverage.size(), QImage::Format_Grayscale8);
if (live.isNull())
throw ExportError(ExportError::Kind::render);
// Outside the mask's bounds stays hidden, as when committed.
live.fill(0);
LayerTransform transform = edit.paintTransform;
transform.sampling = LayerSampling::nearest;
const std::optional<ImportedImage> &base = edit.layer.mask ? std::optional(edit.layer.mask->asset) : std::nullopt;
{
QPainter drawing(&live);
drawing.setTransform(painter.deviceTransform());
// Swift's displayImage: halved near the size drawn.
const double device = LayerRenderer::deviceScale(drawing);
const auto shown = [device](const QImage &image, double width) {
return DownsampleCache::shared().imageDrawnAt(image, width * device / std::max(1, image.width()));
};
const std::shared_ptr<const RasterSnapshot> raster = base ? base->raster : nullptr;
const QImage image = base && !raster ? shown(base->image(), transform.size.width() * scale) : QImage();
const QImage rasterBase = raster && !raster->base.isNull()
? shown(raster->base, transform.size.width() * scale * raster->baseRect.width() / std::max(1, raster->width))
: QImage();
LayerRenderer::drawBrushPreview(image, transform, center(transform.center()), drawing, {.scale = scale},
{.patches = edit.patches(), .pixelWidth = edit.width, .pixelHeight = edit.height, .paintingMask = false,
.sourceRect = edit.sourceRect, .raster = raster, .rasterBase = rasterBase});
}
QPainter multiplying(&coverage);
if (!multiplying.isActive())
throw ExportError(ExportError::Kind::render);
multiplying.setCompositionMode(QPainter::CompositionMode_DestinationIn);
multiplying.drawImage(QRectF(coverage.rect()), BrushRaster::alphaView(live));
};
}
+10 -38
View File
@@ -85,7 +85,8 @@ CanvasView::DisplayState CanvasView::displayState() const
.layers = {},
.folderMasks = {},
.textStyle = m_session.textDraft() ? std::optional(m_session.textDraft()->style) : std::nullopt,
.textTransform = m_session.textDraft() && m_inlineTextEditor ? std::optional(m_inlineTextEditor->shownTransform()) : std::nullopt};
.textTransform = m_session.textDraft() && m_inlineTextEditor ? std::optional(m_inlineTextEditor->shownTransform()) : std::nullopt,
.maskAlone = m_session.maskAloneLayer() ? std::optional(m_session.maskAloneLayer()->mask->asset.identity()) : std::nullopt};
if (!document)
return state;
// A hidden source still clips: live masks count every layer.
@@ -260,45 +261,12 @@ void CanvasView::draw(QPainter &context, const QRectF &dirty)
context.restore();
}
// The folder's live mask, multiplied into the coverage.
FolderMaskClip::Applier CanvasView::liveFolderMaskClip(const BrushStroke &edit, double scale, const Center &center) const
{
return [&edit, scale, center](const QPainter &painter, QImage &coverage) {
QImage live(coverage.size(), QImage::Format_Grayscale8);
if (live.isNull())
throw ExportError(ExportError::Kind::render);
// Outside the mask's bounds stays hidden, as when committed.
live.fill(0);
LayerTransform transform = edit.paintTransform;
transform.sampling = LayerSampling::nearest;
const std::optional<ImportedImage> &base = edit.layer.mask ? std::optional(edit.layer.mask->asset) : std::nullopt;
{
QPainter drawing(&live);
drawing.setTransform(painter.deviceTransform());
// Swift's displayImage: halved near the size drawn.
const double device = LayerRenderer::deviceScale(drawing);
const auto shown = [device](const QImage &image, double width) {
return DownsampleCache::shared().imageDrawnAt(image, width * device / std::max(1, image.width()));
};
const std::shared_ptr<const RasterSnapshot> raster = base ? base->raster : nullptr;
const QImage image = base && !raster ? shown(base->image(), transform.size.width() * scale) : QImage();
const QImage rasterBase = raster && !raster->base.isNull()
? shown(raster->base, transform.size.width() * scale * raster->baseRect.width() / std::max(1, raster->width))
: QImage();
LayerRenderer::drawBrushPreview(image, transform, center(transform.center()), drawing, {.scale = scale},
{.patches = edit.patches(), .pixelWidth = edit.width, .pixelHeight = edit.height, .paintingMask = false,
.sourceRect = edit.sourceRect, .raster = raster, .rasterBase = rasterBase});
}
QPainter multiplying(&coverage);
if (!multiplying.isActive())
throw ExportError(ExportError::Kind::render);
multiplying.setCompositionMode(QPainter::CompositionMode_DestinationIn);
multiplying.drawImage(QRectF(coverage.rect()), BrushRaster::alphaView(live));
};
}
void CanvasView::drawLayers(const CanvasDocument &document, double scale, const Center &center, QPainter &context)
{
if (const std::optional<ImageLayer> alone = m_session.maskAloneLayer()) {
drawMaskAlone(*alone, document, scale, center, context);
return;
}
handOnDraftEffects(document);
m_session.effectsPreviews.prepare(document.layers);
std::map<QUuid, ImageLayer> byID;
@@ -430,6 +398,10 @@ void CanvasView::drawLayers(const CanvasDocument &document, double scale, const
live.adjustment = [&](QUuid id) { return byID.contains(id) ? byID.at(id).adjustment : std::nullopt; };
live.adjustmentOpacity = [&](QUuid id) { return byID.at(id).effectiveOpacity(byID); };
live.adjustmentScale = scale;
const QPointF corner = center(QPointF(0, 0));
live.adjustmentRegion = [corner, scale](const QRectF &rect) {
return QRectF((rect.left() - corner.x()) / scale, (rect.top() - corner.y()) / scale, rect.width() / scale, rect.height() / scale);
};
live.adjustmentClip = [&](QUuid id, const QPainter &painter, QImage &coverage) {
const ImageLayer &layer = byID.at(id);
if (!layer.mask || !layer.mask->isEnabled)
+4
View File
@@ -122,6 +122,8 @@ private:
// Typed text, which the canvas draws as pixels.
std::optional<LayerTextStyle> textStyle;
std::optional<LayerTransform> textTransform;
// The mask shown alone, though the composite may skip it.
std::optional<ImageIdentity> maskAlone;
friend bool operator==(const DisplayState &, const DisplayState &) = default;
};
// A Zoom press: drags zoom about it, clicks step.
@@ -146,6 +148,8 @@ private:
void drawStroke(const BrushStroke &stroke, const ImageLayer &layer, const LayerTransform &transform, const std::optional<QImage> &mask,
const LayerRenderer::Options &options, double scale, const Center &center, QPainter &target);
void drawLayers(const CanvasDocument &document, double scale, const Center &center, QPainter &context);
// Alt-click's view: the mask in gray, strokes included.
void drawMaskAlone(const ImageLayer &layer, const CanvasDocument &document, double scale, const Center &center, QPainter &context);
// Typed text as pixels, where the editor shows it.
struct DraftText {
QImage image;
+3 -5
View File
@@ -20,8 +20,6 @@ QPainter::CompositionMode compositionMode(LayerBlendMode mode)
return QPainter::CompositionMode_Screen;
case LayerBlendMode::overlay:
return QPainter::CompositionMode_Overlay;
case LayerBlendMode::softLight:
return QPainter::CompositionMode_SoftLight;
case LayerBlendMode::darken:
return QPainter::CompositionMode_Darken;
case LayerBlendMode::lighten:
@@ -56,7 +54,7 @@ void LayerRenderer::draw(const QImage &image, const LayerTransform &transform, Q
const double device = deviceScale(context);
const Reduced source = reduced(image, width, device, transform.sampling);
const InterpolationQuality quality = interpolation(
transform.sampling, width * device / std::max(1, image.width()) * (1 << source.level));
transform.sampling, width * device / std::max(1, image.width()) * (1 << source.level), transform.radians() == 0);
const QRectF bounds(-width / 2, -height / 2, width, height);
QTransform placement;
placement.translate(center.x(), center.y());
@@ -294,9 +292,9 @@ void LayerRenderer::drawCoverage(const QImage &image, const LayerTransform &tran
context.restore();
}
InterpolationQuality LayerRenderer::interpolation(LayerSampling sampling, double finalFactor)
InterpolationQuality LayerRenderer::interpolation(LayerSampling sampling, double finalFactor, bool upright)
{
if (sampling == LayerSampling::nearest)
if (sampling == LayerSampling::nearest || (upright && std::abs(finalFactor - 1) < 0.001))
return InterpolationQuality::none;
return finalFactor <= 1 ? InterpolationQuality::low : quality(sampling);
}
+2 -1
View File
@@ -57,7 +57,8 @@ void drawBrushPreview(const QImage &image, const LayerTransform &transform, QPoi
void composite(QPainter &context, const QTransform &placement, const QRectF &extent, LayerSampling sampling,
InterpolationQuality quality, const Options &options, const QRectF &maskBounds,
const std::function<void(QPainter &)> &body, const std::function<void(QPainter &)> &veil = {});
InterpolationQuality interpolation(LayerSampling sampling, double finalFactor);
// An upright layer drawn pixel for pixel copies straight across.
InterpolationQuality interpolation(LayerSampling sampling, double finalFactor, bool upright = false);
Reduced reduced(const QImage &image, double width, double device, LayerSampling sampling);
QRectF coverage(const Reduced &reduced, const QRectF &bounds);
double deviceScale(const QPainter &context);
+4 -3
View File
@@ -208,8 +208,9 @@ void LiveMaskRenderer::adjust(QUuid id, QPainter &context, const QImage &clip)
qCWarning(lcRendering) << "an adjustment passes its pixel budget:" << device->size();
return;
}
// The document the device shows, where Grain's pattern sits.
const QRectF region = context.deviceTransform().inverted().mapRect(QRectF(device->rect()));
// What the device shows; Grain and Noise keep the document.
const QRectF shown = context.deviceTransform().inverted().mapRect(QRectF(device->rect()));
const QRectF region = adjustmentRegion ? adjustmentRegion(shown) : shown;
try {
// The kernels' byte order, one image pixel a device pixel.
QImage original = device->convertToFormat(QImage::Format_RGBA8888_Premultiplied).copy();
@@ -217,7 +218,7 @@ void LiveMaskRenderer::adjust(QUuid id, QPainter &context, const QImage &clip)
throw ExportError(ExportError::Kind::render);
original.setDevicePixelRatio(1);
// Device pixels a document pixel, where Swift's surface counts points.
QImage adjusted = adjustment(id).value().apply(original, region, adjustmentScale * device->width() / region.width());
QImage adjusted = adjustment(id).value().apply(original, region, adjustmentScale * device->width() / shown.width());
const auto found = m_modes.find(id);
const LayerBlendMode mode = found != m_modes.end() ? found->second : LayerBlendMode::normal;
if (mode != LayerBlendMode::normal)
+2
View File
@@ -31,6 +31,8 @@ public:
std::function<void(QUuid, const QPainter &, QImage &coverage)> adjustmentClip = [](QUuid, const QPainter &, QImage &) {};
// Painter units a document pixel: the canvas's zoom.
double adjustmentScale = 1;
// The document a painter region shows: the canvas maps it.
std::function<QRectF(const QRectF &)> adjustmentRegion;
private:
// The pixels beneath, adjusted and mixed back through coverage.
+6
View File
@@ -1,5 +1,6 @@
#include "Rendering/SeparableBlend.h"
#include <algorithm>
#include <cmath>
#include <stdexcept>
namespace {
@@ -22,6 +23,7 @@ float dodge(float backdrop, float source)
bool SeparableBlend::needsSurface(LayerBlendMode mode)
{
switch (mode) {
case LayerBlendMode::softLight:
case LayerBlendMode::linearBurn:
case LayerBlendMode::linearDodge:
case LayerBlendMode::vividLight:
@@ -39,6 +41,10 @@ bool SeparableBlend::needsSurface(LayerBlendMode mode)
float SeparableBlend::channel(LayerBlendMode mode, float backdrop, float source)
{
switch (mode) {
case LayerBlendMode::softLight:
// Photoshop's, as Core Image's: the PDF's strays 17 levels.
return source <= 0.5f ? 2 * backdrop * source + backdrop * backdrop * (1 - 2 * source)
: 2 * backdrop * (1 - source) + std::sqrt(backdrop) * (2 * source - 1);
case LayerBlendMode::linearBurn:
return std::max(0.0f, backdrop + source - 1);
case LayerBlendMode::linearDodge:
+1 -1
View File
@@ -1,7 +1,7 @@
#pragma once
#include "Document/LayerAppearance.h"
// Swift's SeparableBlend: channel-by-channel modes QPainter lacks.
// Swift's SeparableBlend: channel-by-channel modes QPainter lacks or differs on.
namespace SeparableBlend {
// Blended by hand against a surface, Swift's `needsSurface`.
bool needsSurface(LayerBlendMode mode);
+2 -1
View File
@@ -150,7 +150,8 @@ void withFrame(int pixelWidth, int pixelHeight, const QRectF &painted, const QRe
return;
const double device = LayerRenderer::deviceScale(context);
const int level = transform.sampling == LayerSampling::nearest ? 0 : DownsampleCache::level(width * device / pixelWidth);
const InterpolationQuality quality = LayerRenderer::interpolation(transform.sampling, width * device / pixelWidth * (1 << level));
const InterpolationQuality quality = LayerRenderer::interpolation(transform.sampling, width * device / pixelWidth * (1 << level),
transform.radians() == 0);
QTransform placement;
placement.translate(center.x(), center.y());
placement.rotateRadians(transform.radians());
+117
View File
@@ -0,0 +1,117 @@
#include "UI/CameraRawColorControls.h"
#include <algorithm>
#include <array>
#include <cmath>
// Swift's curve graph drags, picked once at the press.
std::optional<CameraRawCurveControls::Drag> CameraRawCurveControls::parametricDrag(QPointF at, QSizeF size) const
{
const CameraRawCurveSettings curve = raw().curve;
const double tone = at.x() / std::max(size.width(), 1.0) * 100;
const std::array<double, 3> splits{curve.shadowSplit, curve.darkSplit, curve.lightSplit};
// Along the bottom, the nearest divider, the first of equals.
if (at.y() > size.height() - 18) {
size_t nearest = 0;
for (size_t index = 1; index < splits.size(); ++index)
if (std::abs(splits[index] - tone) < std::abs(splits[nearest] - tone))
nearest = index;
return Drag{.kind = Drag::Kind::divider, .index = nearest, .from = at};
}
double CameraRawCurveSettings::*key = tone < splits[0] ? &CameraRawCurveSettings::shadows
: tone < splits[1] ? &CameraRawCurveSettings::darks
: tone < splits[2] ? &CameraRawCurveSettings::lights
: &CameraRawCurveSettings::highlights;
return Drag{.kind = Drag::Kind::region, .key = key, .start = curve.*key, .from = at};
}
std::optional<CameraRawCurveControls::Drag> CameraRawCurveControls::pointDrag(QPointF at, QSizeF size)
{
const double x = at.x() / std::max(size.width(), 1.0), y = 1 - at.y() / std::max(size.height(), 1.0);
std::vector<CurvePoint> points = currentPoints();
// On a point, that point; the first of equals.
std::optional<size_t> near;
for (size_t index = 0; index < points.size(); ++index)
if (!near || std::hypot(points[index].x - x, points[index].y - y) < std::hypot(points[*near].x - x, points[*near].y - y))
near = index;
if (near && std::hypot(points[*near].x - x, points[*near].y - y) < 0.055) {
m_selectedPoint = near;
return Drag{.kind = Drag::Kind::point, .index = *near, .from = at};
}
// Else a new point; the ends bound its x.
if (points.size() >= 16 || !std::all_of(points.begin(), points.end(), [x](const CurvePoint &each) { return std::abs(each.x - x) > 0.01; }))
return std::nullopt;
const CurvePoint point{x, y};
points.push_back(point);
std::stable_sort(points.begin(), points.end(), [](const CurvePoint &left, const CurvePoint &right) { return left.x < right.x; });
store(points);
const size_t index = size_t(std::find(points.begin(), points.end(), point) - points.begin());
m_selectedPoint = index;
return Drag{.kind = Drag::Kind::point, .index = index, .from = at};
}
void CameraRawCurveControls::beginDrag(QPointF at, QSizeF size)
{
// Qt may lose a release: every press picks afresh.
const bool parametric = !m_session.filterEdit() || m_session.filterEdit()->rawPanel.curvePage == CameraRawCurvePage::parametric;
m_drag = parametric ? parametricDrag(at, size) : pointDrag(at, size);
continueDrag(at, size);
}
void CameraRawCurveControls::continueDrag(QPointF at, QSizeF size)
{
if (!m_drag)
return;
const double x = at.x() / std::max(size.width(), 1.0), y = 1 - at.y() / std::max(size.height(), 1.0);
const Drag drag = *m_drag;
switch (drag.kind) {
case Drag::Kind::point: {
std::vector<CurvePoint> points = currentPoints();
if (drag.index >= points.size())
return;
points[drag.index].y = y;
// Ends stay ends; the rest keep their order.
if (drag.index > 0 && drag.index < points.size() - 1) {
// Fix beyond Swift: just inside, so the repair keeps it.
const double inside = 1e-9;
points[drag.index].x = std::min(points[drag.index + 1].x - 0.01 - inside, std::max(points[drag.index - 1].x + 0.01 + inside, x));
}
store(points);
return;
}
case Drag::Kind::divider: {
const double value = x * 100;
update([&drag, value](CameraRawSettings &settings) {
// Below the next divider; normalizing does the rest.
CameraRawCurveSettings &curve = settings.curve;
if (drag.index == 0)
curve.shadowSplit = std::min(value, curve.darkSplit - 2);
else if (drag.index == 1)
curve.darkSplit = std::min(curve.lightSplit - 2, value);
else
curve.lightSplit = value;
});
return;
}
case Drag::Kind::region: {
const double amount = std::min(100.0, std::max(-100.0, drag.start - (at.y() - drag.from.y()) / std::max(size.height(), 1.0) * 200));
update([&drag, amount](CameraRawSettings &settings) { settings.curve.*drag.key = std::round(amount); });
return;
}
}
}
void CameraRawCurveControls::removePoint(QPointF at, QSizeF size)
{
std::vector<CurvePoint> points = currentPoints();
const double x = at.x() / size.width();
// The nearest inner point by x, within 0.04; ends stay.
std::optional<size_t> nearest;
for (size_t index = 1; index + 1 < points.size(); ++index)
if (!nearest || std::abs(points[index].x - x) < std::abs(points[*nearest].x - x))
nearest = index;
if (!nearest || !(std::abs(points[*nearest].x - x) < 0.04))
return;
points.erase(points.begin() + std::ptrdiff_t(*nearest));
m_selectedPoint.reset();
store(points);
}
+35 -82
View File
@@ -87,103 +87,45 @@ protected:
return;
}
painter.setPen(Qt::NoPen);
painter.setBrush(Qt::white);
for (const CurvePoint &point : m_owner.currentPoints())
painter.drawEllipse(QRectF(point.x * w - 4, (1 - point.y) * h - 4, 8, 8));
const std::vector<CurvePoint> points = m_owner.currentPoints();
for (size_t index = 0; index < points.size(); ++index) {
painter.setBrush(m_owner.m_selectedPoint == index ? palette().color(QPalette::Highlight) : QColor(Qt::white));
painter.drawEllipse(QRectF(points[index].x * w - 4, (1 - points[index].y) * h - 4, 8, 8));
}
}
// Swift's two drags: still, a press adds; moved, it drags.
// Swift's one drag: picked at the press, kept until release.
void mousePressEvent(QMouseEvent *event) override
{
if (event->button() == Qt::LeftButton)
m_press = event->position();
m_owner.beginDrag(event->position(), size());
}
void mouseMoveEvent(QMouseEvent *event) override
{
if (!m_press || !event->buttons().testFlag(Qt::LeftButton))
return;
if (!m_moved && QLineF(*m_press, event->position()).length() < 2)
return;
m_moved = true;
if (parametric())
moveDivider(event->position().x());
else
movePoint(event->position());
if (event->buttons().testFlag(Qt::LeftButton))
m_owner.continueDrag(event->position(), size());
}
void mouseReleaseEvent(QMouseEvent *event) override
{
if (event->button() == Qt::LeftButton && m_press && !m_moved && event->position() == *m_press && !parametric())
addPoint(event->position());
m_press.reset();
m_moved = false;
if (event->button() == Qt::LeftButton)
m_owner.m_drag.reset();
}
// The double click's own release must add nothing back.
// The second press drags too; then the point goes.
void mouseDoubleClickEvent(QMouseEvent *event) override
{
if (event->button() == Qt::LeftButton && !parametric())
removePoint(event->position());
if (event->button() != Qt::LeftButton)
return;
m_owner.beginDrag(event->position(), size());
if (!parametric())
m_owner.removePoint(event->position(), size());
m_owner.m_drag.reset();
}
private:
void moveDivider(double x)
{
const double value = std::min(98.0, std::max(2.0, x / std::max(double(width()), 1.0) * 100));
const CameraRawCurveSettings curve = m_owner.raw().curve;
const std::array<double, 3> splits{curve.shadowSplit, curve.darkSplit, curve.lightSplit};
size_t nearest = 0;
for (size_t index = 1; index < 3; ++index)
if (std::abs(splits[index] - value) < std::abs(splits[nearest] - value))
nearest = index;
m_owner.update([nearest, value](CameraRawSettings &settings) {
(nearest == 0 ? settings.curve.shadowSplit : nearest == 1 ? settings.curve.darkSplit : settings.curve.lightSplit) = value;
});
}
void addPoint(QPointF at)
{
std::vector<CurvePoint> points = m_owner.currentPoints();
points.push_back({std::min(0.99, std::max(0.01, at.x() / width())), std::min(1.0, std::max(0.0, 1 - at.y() / height()))});
m_owner.store(points);
}
// The nearest inner point by x; ends stay.
std::optional<size_t> nearestInner(const std::vector<CurvePoint> &points, double x) const
{
std::optional<size_t> nearest;
for (size_t index = 1; index + 1 < points.size(); ++index)
if (!nearest || std::abs(points[index].x - x) < std::abs(points[*nearest].x - x))
nearest = index;
return nearest;
}
void movePoint(QPointF at)
{
std::vector<CurvePoint> points = m_owner.currentPoints();
const double x = at.x() / width();
const std::optional<size_t> index = nearestInner(points, x);
if (!index)
return;
points[*index] = {std::min(0.98, std::max(0.02, x)), std::min(1.0, std::max(0.0, 1 - at.y() / height()))};
m_owner.store(points);
}
void removePoint(QPointF at)
{
std::vector<CurvePoint> points = m_owner.currentPoints();
const double x = at.x() / width();
const std::optional<size_t> index = nearestInner(points, x);
if (!index || !(std::abs(points[*index].x - x) < 0.04))
return;
points.erase(points.begin() + std::ptrdiff_t(*index));
m_owner.store(points);
}
CameraRawCurveControls &m_owner;
std::optional<QPointF> m_press;
bool m_moved = false;
};
namespace {
@@ -250,6 +192,9 @@ CameraRawCurveControls::CameraRawCurveControls(EditorSession &session, QWidget *
presetRow->addStretch(1);
points->addLayout(presetRow);
connect(m_preset, &QComboBox::activated, this, [this](int preset) {
// A fix beyond Swift: a preset's points drop the choice.
if (preset != 0)
m_selectedPoint.reset();
if (preset == 1)
store(CameraRawCurveSettings::linear());
else if (preset == 2)
@@ -258,7 +203,7 @@ CameraRawCurveControls::CameraRawCurveControls(EditorSession &session, QWidget *
store(CameraRawCurveSettings::strongContrast());
});
m_refine = new CameraRawRow({.name = QStringLiteral("refineSaturation"), .title = QStringLiteral("Refine Saturation"),
.help = QStringLiteral("How much the RGB curve also changes color strength. Zero keeps it to brightness."), .titleWidth = 88,
.help = QStringLiteral("How much the curve also changes color strength. Zero matches Photoshop; lower keeps it to brightness, higher adds more color."), .titleWidth = 88,
.fixedTitle = true, .fieldWidth = 48, .scrub = 1},
[this] { return raw().curve.refineSaturation; },
[this](double value) { update([value](CameraRawSettings &settings) { settings.curve.refineSaturation = value; }); },
@@ -334,15 +279,23 @@ void CameraRawCurveControls::synchronize()
m_channelBox->setVisible(point);
const CameraRawPointChannel channel = edit ? edit->rawPanel.pointChannel : CameraRawPointChannel::rgb;
m_channel->button(int(channel))->setChecked(true);
m_graph->setToolTip(point ? QStringLiteral("Drag a point. Click the curve to add one. Double-click a point to remove it.")
: QStringLiteral("Drag a divider to change which tones the neighboring sliders affect."));
// Swift's onChange: another channel drops the choice and drag.
if (channel != m_shownChannel) {
m_shownChannel = channel;
m_selectedPoint.reset();
m_drag.reset();
}
m_graph->setToolTip(point ? QStringLiteral("Drag a point. Click to add one. Double-click a point to remove it.")
: QStringLiteral("Drag up or down to lift or lower those tones. Drag a divider along the bottom to change which tones each region covers."));
m_graph->update();
m_amounts->setVisible(!point);
m_points->setVisible(point);
const std::vector<CurvePoint> points = currentPoints();
// Swift's selectedPoint: the last inner point, else the last.
const CurvePoint selected = points.size() > 2 ? points[points.size() - 2] : points.back();
m_selected->setText(QStringLiteral("In %1 Out %2").arg(std::lround(selected.x * 255)).arg(std::lround(selected.y * 255)));
// Swift's selectedPoint: the chosen index, while it exists.
const bool chosen = m_selectedPoint && *m_selectedPoint < points.size();
m_selected->setVisible(chosen);
if (chosen)
m_selected->setText(QStringLiteral("In %1 Out %2").arg(std::lround(points[*m_selectedPoint].x * 255)).arg(std::lround(points[*m_selectedPoint].y * 255)));
const QSignalBlocker quiet(m_preset);
m_preset->setCurrentIndex(points == CameraRawCurveSettings::linear() ? 1
: points == CameraRawCurveSettings::mediumContrast() ? 2
+16
View File
@@ -28,9 +28,25 @@ private:
void update(const std::function<void(CameraRawSettings &)> &change);
void store(const std::vector<CurvePoint> &points);
void panel(const std::function<void(CameraRawPanel &)> &change);
// Swift's Drag: a point, divider, or region.
struct Drag {
enum class Kind { point, divider, region } kind;
size_t index = 0;
double CameraRawCurveSettings::*key = nullptr;
double start = 0;
QPointF from;
};
std::optional<Drag> parametricDrag(QPointF at, QSizeF size) const;
std::optional<Drag> pointDrag(QPointF at, QSizeF size);
void beginDrag(QPointF at, QSizeF size);
void continueDrag(QPointF at, QSizeF size);
void removePoint(QPointF at, QSizeF size);
friend class CameraRawCurveGraph;
EditorSession &m_session;
std::optional<Drag> m_drag;
std::optional<size_t> m_selectedPoint;
CameraRawPointChannel m_shownChannel = CameraRawPointChannel::rgb;
QButtonGroup *m_page;
QWidget *m_channelBox;
QButtonGroup *m_channel;
+2
View File
@@ -243,6 +243,7 @@ CompositorMenus::CompositorMenus(ProjectWorkspace &workspace, QMenuBar &bar, QWi
});
layer->addSeparator();
add(layer, QStringLiteral("groupLayers"), QStringLiteral("Group Selected Layers"), QKeySequence(Qt::CTRL | Qt::Key_G), [this] { session().groupSelectedLayers(); });
add(layer, QStringLiteral("ungroupLayers"), QStringLiteral("Ungroup Layers"), QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_G), [this] { session().ungroupLayers(); });
add(layer, QStringLiteral("moveOutOfFolder"), QStringLiteral("Move Out of Folder"), QKeySequence(), [this] { session().moveActiveLayerOutOfGroup(); });
add(layer, QStringLiteral("newBlankLayer"), QStringLiteral("New Blank Layer"), QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_N), [this] { session().addBlankLayer(); });
add(layer, QStringLiteral("renameLayer"), QStringLiteral("Rename Layer…"), QKeySequence(), [this] { session().setRenamingLayerID(session().activeLayerID()); });
@@ -395,6 +396,7 @@ void CompositorMenus::synchronize()
action(QStringLiteral("clippingMask"))->setText(active && active->maskSourceID ? QStringLiteral("Release Clipping Mask") : QStringLiteral("Create Clipping Mask"));
action(QStringLiteral("clippingMask"))->setEnabled(s.activeLayerID() && s.canToggleClippingMask(*s.activeLayerID()));
action(QStringLiteral("groupLayers"))->setEnabled(s.canEditLayers());
action(QStringLiteral("ungroupLayers"))->setEnabled(s.canUngroupLayers());
action(QStringLiteral("moveOutOfFolder"))->setEnabled(s.canEditLayers() && active && active->parentID);
action(QStringLiteral("newBlankLayer"))->setEnabled(s.canEditLayers());
action(QStringLiteral("renameLayer"))->setEnabled(s.canEditLayers() && active);
+17 -2
View File
@@ -48,10 +48,25 @@ void FilterSheet::dither()
});
control(QStringLiteral("Pixel Size"), key(&DitherSettings::pixelSize), DitherSettings::pixelSizeLow, DitherSettings::pixelSizeHigh, QStringLiteral("px"), 0, false);
box()->setToolTip(QStringLiteral("Make each dithered pixel this many pixels across, for a chunky old-screen look"));
m_shownWhen.emplace_back(box(), [](const FilterSettings &settings) { return settings.dither.style != DitherStyle::ascii; });
m_shownWhen.emplace_back(box(), [](const FilterSettings &settings) { return usesPixelSize(settings.dither.style); });
control(QStringLiteral("Text Size"), key(&DitherSettings::textSize), DitherSettings::textSizeLow, DitherSettings::textSizeHigh, QStringLiteral("px"), 0, false);
box()->setToolTip(QStringLiteral("The height of each line of characters"));
m_shownWhen.emplace_back(box(), [](const FilterSettings &settings) { return settings.dither.style == DitherStyle::ascii; });
// Scanlines' four rows.
const auto scanlines = [](const FilterSettings &settings) { return settings.dither.style == DitherStyle::scanlines; };
control(QStringLiteral("Line Spacing"), key(&DitherSettings::lineSpacing), DitherSettings::lineSpacingLow, DitherSettings::lineSpacingHigh, QStringLiteral("px"), 0,
false);
box()->setToolTip(QStringLiteral("How far apart the screen's lines are"));
m_shownWhen.emplace_back(box(), scanlines);
control(QStringLiteral("Glow"), key(&DitherSettings::glow), 0, 100, QStringLiteral("%"), 0, false);
box()->setToolTip(QStringLiteral("Light blooming around the lines, like a CRT's phosphors"));
m_shownWhen.emplace_back(box(), scanlines);
control(QStringLiteral("Dots"), key(&DitherSettings::dots), 0, 100, QStringLiteral("%"), 0, false);
box()->setToolTip(QStringLiteral("Break the lines into glowing beads"));
m_shownWhen.emplace_back(box(), scanlines);
control(QStringLiteral("Wobble"), key(&DitherSettings::wobble), DitherSettings::wobbleLow, DitherSettings::wobbleHigh, QStringLiteral("px"), 0, false);
box()->setToolTip(QStringLiteral("Make the lines waver sideways down the screen, like a CRT losing sync"));
m_shownWhen.emplace_back(box(), scanlines);
control(QStringLiteral("Cell Size"), key(&DitherSettings::cellSize), DitherSettings::cellSizeLow, DitherSettings::cellSizeHigh, QStringLiteral("px"), 0, false);
m_shownWhen.emplace_back(box(), [](const FilterSettings &settings) { return isHalftone(settings.dither.style); });
control(QStringLiteral("Angle"), key(&DitherSettings::angle), -90, 90, QStringLiteral("°"), 0, false);
@@ -133,7 +148,7 @@ void FilterSheet::dither()
const QSignalBlocker quiet(shape);
shape->setCurrentIndex(int(settings.dither.pixelShape));
});
m_shownWhen.emplace_back(shape->parentWidget(), [](const FilterSettings &settings) { return settings.dither.pixelSize > 1 && settings.dither.style != DitherStyle::ascii; });
m_shownWhen.emplace_back(shape->parentWidget(), [](const FilterSettings &settings) { return settings.dither.pixelSize > 1 && usesPixelSize(settings.dither.style); });
flag(QStringLiteral("Light on Dark"), QStringLiteral("Draw the marks for the light tones on the dark color, like a glowing screen"),
[](FilterSettings &settings) -> bool & { return settings.dither.lightOnDark; });
m_shownWhen.emplace_back(m_flags.back().first, [](const FilterSettings &settings) { return drawsMarks(settings.dither.style); });
+1 -1
View File
@@ -134,7 +134,7 @@ const std::vector<ShortcutDefinition> &ShortcutDefinition::all()
entry("Levels", "l", 1, true), entry("Hue/Saturation", "u", 1, true), entry("Invert Pixels / Mask", "i", 1, true),
entry("Canvas Size", "c", 3, true), entry("Image Size", "i", 3, true), entry("Transform Layer / Selection", "t", 1, true),
entry("Duplicate / Layer via Copy", "j", 1, true), entry("Toggle Clipping Mask", "g", 3, true),
entry("Group Layers", "g", 1, true), entry("New Blank Layer", "n", 9, true), entry("Move Layer Up", "]", 1, true),
entry("Group Layers", "g", 1, true), entry("Ungroup Layers", "g", 9, true), entry("New Blank Layer", "n", 9, true), entry("Move Layer Up", "]", 1, true),
entry("Move Layer Down", "[", 1, true), entry("Merge Layers", "e", 1, true), entry("Show Grid", "'", 1, true),
entry("Show Guides", ";", 1, true), entry("Show Rulers", "r", 1, true), entry("Snap", ";", 9, true), entry("Lock Guides", ";", 3, true)};
const std::vector<std::pair<const char *, QString>> tools{
+6
View File
@@ -313,6 +313,12 @@ void LayerIcons::paint(QPainter &painter, LayerIcon icon, QPointF origin, double
case LayerIcon::dottedCircle: dottedCircle(painter); break;
case LayerIcon::plusMagnifyingGlass: magnifyingGlass(painter, true); break;
case LayerIcon::minusMagnifyingGlass: magnifyingGlass(painter, false); break;
// Swift's xmark at bold weight.
case LayerIcon::xmark:
painter.setPen(QPen(colour, 2.6, Qt::SolidLine, Qt::RoundCap));
painter.drawLine(QPointF(4, 4), QPointF(14, 14));
painter.drawLine(QPointF(14, 4), QPointF(4, 14));
break;
}
painter.restore();
}
+1 -1
View File
@@ -8,7 +8,7 @@
enum class LayerIcon {
eye, eyeSlash, chevronRight, chevronDown, folder, link, newLayer, newFolder, addMask, trash, layers, text,
halfFilledCircle, sliders, curvePath, plusMinusCircle, paintPalette, circleGrid, sparkles, rightHalfCircle, hatchedCircle, axes,
scope, lineDiagonal, drop, wind, dottedCircle, plusMagnifyingGlass, minusMagnifyingGlass
scope, lineDiagonal, drop, wind, dottedCircle, plusMagnifyingGlass, minusMagnifyingGlass, xmark
};
namespace LayerIcons {
+58 -2
View File
@@ -1,6 +1,9 @@
#include "UI/LayerMaskMenu.h"
#include "UI/LayerIcons.h"
#include <QEvent>
#include <QGuiApplication>
#include <QHBoxLayout>
#include <QPainter>
LayerMaskMenu::LayerMaskMenu(EditorSession &session, QWidget *parent) : QToolButton(parent), m_session(session)
{
@@ -8,7 +11,8 @@ LayerMaskMenu::LayerMaskMenu(EditorSession &session, QWidget *parent) : QToolBut
setAccessibleName(QStringLiteral("Add layer mask"));
setAutoRaise(true);
setFixedSize(32, 40);
connect(this, &QToolButton::clicked, this, [this] { m_session.addMask(); });
// Swift reads Option from the event that clicked.
connect(this, &QToolButton::clicked, this, [this] { m_session.addMask(!QGuiApplication::keyboardModifiers().testFlag(Qt::AltModifier)); });
connect(&m_session, &EditorSession::changed, this, &LayerMaskMenu::synchronize);
synchronize();
}
@@ -17,7 +21,8 @@ void LayerMaskMenu::synchronize()
{
const std::optional<ImageLayer> active = m_session.activeLayer();
setEnabled(m_session.canEditMask() && !(active && active->mask));
setToolTip(m_session.selection() ? QStringLiteral("Add layer mask (the selection becomes black)") : QStringLiteral("Add layer mask"));
setToolTip(m_session.selection() ? QStringLiteral("Add layer mask revealing the selection (Alt-click to hide it)")
: QStringLiteral("Add layer mask (Alt-click for a black mask)"));
setIcon(LayerIcons::pixmap(LayerIcon::addMask, 16, palette().color(QPalette::PlaceholderText), devicePixelRatio()));
}
@@ -27,3 +32,54 @@ void LayerMaskMenu::changeEvent(QEvent *event)
if (event->type() == QEvent::PaletteChange)
synchronize();
}
MaskAloneBadge::MaskAloneBadge(EditorSession &session, QWidget *parent) : QWidget(parent), m_name(new QLabel(this))
{
setObjectName(QStringLiteral("maskAloneBadge"));
setFixedHeight(26);
const auto font = [](QLabel *label, bool semibold, double alpha) {
QFont face = label->font();
face.setPixelSize(12);
face.setWeight(semibold ? QFont::DemiBold : QFont::Normal);
label->setFont(face);
QPalette colours = label->palette();
colours.setColor(QPalette::WindowText, QColor::fromRgbF(1, 1, 1, alpha));
label->setPalette(colours);
};
auto *icon = new QLabel(this);
icon->setPixmap(LayerIcons::pixmap(LayerIcon::addMask, 11, Qt::white, devicePixelRatioF()));
auto *title = new QLabel(QStringLiteral("Layer Mask"), this);
title->setObjectName(QStringLiteral("maskAloneTitle"));
font(title, true, 1);
m_name->setObjectName(QStringLiteral("maskAloneName"));
m_name->setTextFormat(Qt::PlainText);
m_name->setMaximumWidth(220);
font(m_name, false, 0.6);
auto *close = new QToolButton(this);
close->setObjectName(QStringLiteral("maskAloneClose"));
close->setAutoRaise(true);
close->setIcon(LayerIcons::pixmap(LayerIcon::xmark, 9, Qt::white, devicePixelRatioF()));
close->setToolTip(QStringLiteral("Show the image again (or Alt-click the mask thumbnail)"));
close->setAccessibleName(QStringLiteral("Stop viewing the mask"));
connect(close, &QToolButton::clicked, this, [&session] { session.setViewsMaskAlone(false); });
auto *row = new QHBoxLayout(this);
row->setContentsMargins(11, 0, 8, 0);
row->setSpacing(7);
for (QWidget *widget : std::initializer_list<QWidget *>{icon, title, m_name, close})
row->addWidget(widget);
}
// Swift's byTruncatingTail within 220 points.
void MaskAloneBadge::setLayerName(const QString &name)
{
m_name->setText(m_name->fontMetrics().elidedText(name, Qt::ElideRight, 220));
}
void MaskAloneBadge::paintEvent(QPaintEvent *)
{
QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing);
painter.setPen(QPen(QColor::fromRgbF(1, 1, 1, 0.14), 1));
painter.setBrush(QColor::fromRgbF(0, 0, 0, 0.75));
painter.drawRoundedRect(QRectF(rect()).adjusted(0.5, 0.5, -0.5, -0.5), 13, 13);
}
+16 -1
View File
@@ -1,8 +1,9 @@
#pragma once
#include "Document/EditorSession.h"
#include <QLabel>
#include <QToolButton>
// One click adds a mask: white, or the selection black.
// One click adds a mask revealing the selection; Alt, hiding.
class LayerMaskMenu : public QToolButton {
Q_OBJECT
public:
@@ -16,3 +17,17 @@ private:
EditorSession &m_session;
};
// While a mask shows alone: whose, and a way back.
class MaskAloneBadge : public QWidget {
Q_OBJECT
public:
explicit MaskAloneBadge(EditorSession &session, QWidget *parent = nullptr);
void setLayerName(const QString &name);
protected:
void paintEvent(QPaintEvent *event) override;
private:
QLabel *const m_name;
};
+6 -8
View File
@@ -70,9 +70,10 @@ LayerThumbnailButton::LayerThumbnailButton(NativeLayerList &list, bool maskTarge
setAutoRaise(true);
}
void LayerThumbnailButton::setTargeted(bool targeted)
void LayerThumbnailButton::setTargeted(bool targeted, bool alone)
{
m_targeted = targeted;
m_alone = alone;
QWidget::update();
}
@@ -155,12 +156,9 @@ void LayerThumbnailButton::mouseReleaseEvent(QMouseEvent *event)
if (!m_altMaskPress || event->button() != Qt::LeftButton)
return;
endAltMaskPress();
// Released where pressed: a click, not a drag; Shift toggles.
if (!rect().contains(event->position().toPoint()))
return;
m_list.session().selectLayerTarget(layerID, true);
if (event->modifiers().testFlag(Qt::ShiftModifier))
m_list.session().toggleLayerMask();
// Released where pressed: the mask alone, or the image again.
if (rect().contains(event->position().toPoint()))
m_list.session().toggleMaskAlone(layerID);
}
// With a modifier the second press is the button's again.
@@ -179,7 +177,7 @@ void LayerThumbnailButton::paintEvent(QPaintEvent *)
if (!m_targeted)
return;
painter.setRenderHint(QPainter::Antialiasing);
painter.setPen(QPen(palette().color(QPalette::Highlight), 2));
painter.setPen(QPen(m_alone ? QColor(Qt::white) : palette().color(QPalette::Highlight), 2));
painter.setBrush(Qt::NoBrush);
painter.drawRoundedRect(QRectF(rect()).adjusted(1, 1, -1, -1), 3, 3);
}
+2 -2
View File
@@ -31,7 +31,7 @@ LayerCell::LayerCell(NativeLayerList &list)
connect(m_disclosure, &QToolButton::clicked, this, [this] { m_list.session().toggleGroupExpansion(m_layerID); });
m_thumbnail->setObjectName(QStringLiteral("layerThumbnail"));
m_maskThumbnail->setObjectName(QStringLiteral("maskThumbnail"));
m_maskThumbnail->setToolTip(QStringLiteral("Select layer mask; Shift-click to enable/disable; Ctrl-click to select its black areas (Ctrl-Shift adds, Ctrl-Alt subtracts)"));
m_maskThumbnail->setToolTip(QStringLiteral("Select layer mask; Alt-click to view it alone; Shift-click to enable/disable; Ctrl-click to select its black areas (Ctrl-Shift adds, Ctrl-Alt subtracts)"));
m_link->setObjectName(QStringLiteral("maskLink"));
m_link->setAutoRaise(true);
m_link->setFixedSize(9, 20);
@@ -159,7 +159,7 @@ void LayerCell::updateTarget()
const bool active = session.activeLayerID() == m_layerID && session.selectedLayerIDs().size() == 1;
const bool mask = session.isMaskSelected();
m_thumbnail->setTargeted(active && !mask);
m_maskThumbnail->setTargeted(active && mask);
m_maskThumbnail->setTargeted(active && mask, active && session.maskAloneLayer());
QWidget::update();
}
+34 -2
View File
@@ -13,6 +13,7 @@
#include <QPainter>
#include <QPainterPath>
#include <QScrollBar>
#include <cmath>
const QString NativeLayerList::maskType = QStringLiteral("com.compositor.layer-mask");
const QString NativeLayerList::sourceType = QStringLiteral("com.compositor.layer-list");
@@ -299,6 +300,37 @@ QCursor NativeLayerList::clippingCursor(bool releasing, double ratio)
return QCursor(pixmap, 3, 3);
}
// Swift's eye.fill: an almond, a ring round the pupil.
QCursor NativeLayerList::showMaskCursor(double ratio)
{
const QCursor base = CanvasView::duplicateCursor(ratio);
const QPointF hotSpot = base.hotSpot();
const QRectF eye(hotSpot + QPointF(15, 18), QSizeF(7.5, 5.5));
const QSizeF baseSize = base.pixmap().deviceIndependentSize();
const QSize size(int(std::ceil(std::max(baseSize.width(), eye.right() + 2))), int(std::ceil(std::max(baseSize.height(), eye.bottom() + 2))));
QPixmap pixmap(size * ratio);
pixmap.setDevicePixelRatio(ratio);
pixmap.fill(Qt::transparent);
QPainter painter(&pixmap);
painter.setRenderHint(QPainter::Antialiasing, true);
QPainterPath shape;
shape.setFillRule(Qt::OddEvenFill);
shape.moveTo(eye.left(), eye.center().y());
shape.quadTo(eye.center().x(), eye.top() - eye.height() / 2, eye.right(), eye.center().y());
shape.quadTo(eye.center().x(), eye.bottom() + eye.height() / 2, eye.left(), eye.center().y());
shape.addEllipse(eye.center(), 1.9, 1.9);
shape.addEllipse(eye.center(), 1.1, 1.1);
// The eye first, haloed, so the arrows sit in front.
for (int step = 0; step < 16; ++step) {
const double angle = step * M_PI / 8;
painter.fillPath(shape.translated(std::cos(angle), std::sin(angle)), Qt::white);
}
painter.fillPath(shape, Qt::black);
painter.drawPixmap(QPointF(0, 0), base.pixmap());
painter.end();
return QCursor(pixmap, int(hotSpot.x()), int(hotSpot.y()));
}
QCursor NativeLayerList::cursorFor(QPoint listPoint, Qt::KeyboardModifiers modifiers) const
{
const int row = rowAt(listPoint);
@@ -317,9 +349,9 @@ QCursor NativeLayerList::cursorFor(QPoint listPoint, Qt::KeyboardModifiers modif
return QCursor(Qt::ArrowCursor);
}
const bool editable = m_session.canEditLayers();
// A mask copies, the row duplicates, the strip clips.
// A mask shows alone, the row duplicates, the strip clips.
if (cell.maskThumbnail().isVisible() && cell.maskThumbnail().geometry().contains(inCell))
return editable ? CanvasView::duplicateCursor(ratio) : QCursor(Qt::ArrowCursor);
return showMaskCursor(ratio);
if (!isClippingZone(cell, inCell))
return editable ? CanvasView::duplicateCursor(ratio) : QCursor(Qt::ArrowCursor);
if (!m_session.canToggleClippingMask(layer.id))
+4
View File
@@ -1,5 +1,6 @@
#include "UI/NativeLayerList.h"
#include <QMenu>
#include <algorithm>
// Swift's table-level context menu (1.2.5): entries and validation.
std::unique_ptr<QMenu> NativeLayerList::contextMenu(QUuid id)
@@ -40,6 +41,9 @@ std::unique_ptr<QMenu> NativeLayerList::contextMenu(QUuid id)
entry(*menu, "groupLayers", QStringLiteral("Group Selected Layers"),
session.canEditLayers() && session.document() && session.document()->layers.size() < 10'000 && !session.selectedLayerIDs().isEmpty(),
[&session] { session.groupSelectedLayers(); });
// A folder right-clicked can be ungrouped.
if (std::ranges::any_of(session.document()->layers, [id](const ImageLayer &layer) { return layer.id == id && layer.isGroup; }))
entry(*menu, "ungroupLayers", QStringLiteral("Ungroup Layers"), session.canUngroupLayers(), [&session] { session.ungroupLayers(); });
entry(*menu, "moveOut", QStringLiteral("Move Out of Folder"), session.canEditLayers() && active && active->parentID,
[&session] { session.moveActiveLayerOutOfGroup(); });
entry(*menu, "mergeLayers", session.mergeTitle(), session.canMergeLayers(), [&session] { session.mergeLayers(); });
+5 -2
View File
@@ -43,8 +43,8 @@ public:
LayerThumbnailButton(NativeLayerList &list, bool maskTarget, QWidget *parent);
QUuid layerID;
const bool isMaskTarget;
// An accent ring while the target is selected.
void setTargeted(bool targeted);
// An accent ring while targeted; white while shown alone.
void setTargeted(bool targeted, bool alone = false);
bool isTargeted() const { return m_targeted; }
~LayerThumbnailButton() override;
@@ -63,6 +63,7 @@ private:
NativeLayerList &m_list;
bool m_targeted = false;
bool m_alone = false;
// Alt on a mask: chosen on release, or dragged.
std::optional<QPoint> m_altMaskPress;
};
@@ -240,6 +241,8 @@ public:
// The cursor Alt asks for here: clip, duplicate, arrow.
QCursor cursorFor(QPoint listPoint, Qt::KeyboardModifiers modifiers) const;
static QCursor clippingCursor(bool releasing, double ratio);
// Alt over a mask: the duplicate pointer, an eye behind.
static QCursor showMaskCursor(double ratio);
protected:
bool event(QEvent *event) override;
+95 -4
View File
@@ -5,9 +5,13 @@
#include <QBuffer>
#include <QClipboard>
#include <QGridLayout>
#include <QActionGroup>
#include <QImageReader>
#include <QMenu>
#include <QPainter>
#include <QShortcut>
#include <QVBoxLayout>
#include <algorithm>
namespace {
QLabel *text(const QString &words, int pixels, QFont::Weight weight, QPalette::ColorRole role, QWidget *parent)
@@ -20,12 +24,58 @@ QLabel *text(const QString &words, int pixels, QFont::Weight weight, QPalette::C
label->setForegroundRole(role);
return label;
}
// Swift's More button: three dots drawn exactly, flush right.
class MoreButton : public QToolButton {
public:
using QToolButton::QToolButton;
protected:
void paintEvent(QPaintEvent *) override
{
QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing);
painter.setPen(Qt::NoPen);
painter.setBrush(palette().color(QPalette::WindowText));
const double dot = 2.5, top = (height() - 5 * dot) / 2;
for (int index = 0; index < 3; ++index)
painter.drawEllipse(QRectF(width() - dot, top + 2 * dot * index, dot, dot));
}
};
}
const std::vector<std::vector<CanvasPreset>> CanvasPreset::groups = {
{
{QStringLiteral("4K"), 3840, 2160},
{QStringLiteral("1440p"), 2560, 1440},
{QStringLiteral("1080p"), 1920, 1080},
},
{
{QStringLiteral("iPhone 18 Pro"), 1206, 2622},
{QStringLiteral("iPhone 18 Pro Max"), 1320, 2868},
{QStringLiteral("MacBook Pro 14\""), 3024, 1964},
{QStringLiteral("MacBook Pro 16\""), 3456, 2234},
{QStringLiteral("Studio Display"), 5120, 2880},
},
{
{QStringLiteral("Instagram Square"), 1080, 1080},
{QStringLiteral("Instagram Portrait"), 1080, 1350},
{QStringLiteral("Instagram Story"), 1080, 1920},
{QStringLiteral("YouTube Thumb"), 1080, 608},
},
};
const std::vector<CanvasPreset> CanvasPreset::all = [] {
std::vector<CanvasPreset> presets;
for (const std::vector<CanvasPreset> &group : groups)
presets.insert(presets.end(), group.begin(), group.end());
return presets;
}();
NewCanvasSheet::NewCanvasSheet(EditorSession &session, std::function<void(int, int)> onCreate, std::function<void()> onOpen, QWidget *parent)
: QWidget(parent), m_session(session), m_onCreate(std::move(onCreate)), m_width(new QLineEdit(QStringLiteral("1920"), this)),
m_height(new QLineEdit(QStringLiteral("1080"), this)), m_note(text(QString(), 12, QFont::Normal, QPalette::PlaceholderText, this)),
m_create(new QPushButton(QStringLiteral("Create canvas"), this))
m_create(new QPushButton(QStringLiteral("Create canvas"), this)), m_presets(new MoreButton(this))
{
// Swift's fields stretch the sheet to its full 500 points.
setFixedWidth(500);
@@ -62,17 +112,46 @@ NewCanvasSheet::NewCanvasSheet(EditorSession &session, std::function<void(int, i
auto *column = new QVBoxLayout(this);
column->setContentsMargins(28, 28, 28, 28);
column->setSpacing(24);
auto *heading = new QVBoxLayout;
heading->setSpacing(6);
auto *heading = new QHBoxLayout;
heading->addWidget(text(QStringLiteral("New canvas"), 17, QFont::DemiBold, QPalette::WindowText, this));
heading->addWidget(text(QStringLiteral("A blank space for your next composition."), 13, QFont::Normal, QPalette::PlaceholderText, this));
heading->addStretch(1);
heading->addWidget(m_presets);
column->addLayout(heading);
column->addLayout(fields);
column->addWidget(m_note);
column->addLayout(buttons);
m_presets->setObjectName(QStringLiteral("presetSizes"));
m_presets->setFixedSize(28, 28);
m_presets->setPopupMode(QToolButton::InstantPopup);
m_presets->setToolTip(QStringLiteral("Preset sizes for screens and common formats"));
m_presets->setAccessibleName(QStringLiteral("Preset sizes"));
auto *menu = new QMenu(m_presets);
auto *choices = new QActionGroup(menu);
// Custom changes nothing; the match stays checked.
QAction *custom = choices->addAction(menu->addAction(QStringLiteral("Custom")));
custom->setCheckable(true);
custom->setData(-1);
connect(custom, &QAction::triggered, this, &NewCanvasSheet::checkPreset);
int index = 0;
for (const std::vector<CanvasPreset> &group : CanvasPreset::groups) {
menu->addSeparator();
for (const CanvasPreset &preset : group) {
QAction *action = choices->addAction(menu->addAction(preset.title));
action->setCheckable(true);
action->setData(index++);
connect(action, &QAction::triggered, this, [this, preset] {
m_width->setText(QString::number(preset.width));
m_height->setText(QString::number(preset.height));
});
}
}
m_presets->setMenu(menu);
connect(m_width, &QLineEdit::textChanged, this, &NewCanvasSheet::validate);
connect(m_height, &QLineEdit::textChanged, this, &NewCanvasSheet::validate);
connect(m_width, &QLineEdit::textChanged, this, &NewCanvasSheet::checkPreset);
connect(m_height, &QLineEdit::textChanged, this, &NewCanvasSheet::checkPreset);
// Return anywhere on the sheet is the default action.
std::vector<QShortcut *> returns;
for (const Qt::Key key : {Qt::Key_Return, Qt::Key_Enter})
@@ -90,6 +169,18 @@ NewCanvasSheet::NewCanvasSheet(EditorSession &session, std::function<void(int, i
connect(&m_session, &EditorSession::changed, this, &NewCanvasSheet::synchronize);
synchronize();
validate();
checkPreset();
}
void NewCanvasSheet::checkPreset()
{
const auto matched = std::ranges::find_if(CanvasPreset::all, [this](const CanvasPreset &preset) {
return QString::number(preset.width) == m_width->text() && QString::number(preset.height) == m_height->text();
});
const QVariant index(matched == CanvasPreset::all.end() ? -1 : int(matched - CanvasPreset::all.begin()));
for (QAction *action : m_presets->menu()->actions())
if (action->data() == index)
action->setChecked(true);
}
void NewCanvasSheet::synchronize()
+15
View File
@@ -4,8 +4,20 @@
#include <QLineEdit>
#include <QMimeData>
#include <QPushButton>
#include <QToolButton>
#include <QWidget>
#include <functional>
#include <vector>
// New Canvas sizes in pixels, upright as held.
struct CanvasPreset {
QString title;
int width;
int height;
// Resolutions, Apple screens, then social formats; the menu divides them.
static const std::vector<std::vector<CanvasPreset>> groups;
static const std::vector<CanvasPreset> all;
};
// The welcome: a new canvas, a project or an image.
class NewCanvasSheet : public QWidget {
@@ -23,6 +35,8 @@ private:
void synchronize();
void validate();
void create();
// Checks the preset the fields match, else Custom.
void checkPreset();
EditorSession &m_session;
const std::function<void(int, int)> m_onCreate;
@@ -30,5 +44,6 @@ private:
QLineEdit *const m_height;
QLabel *const m_note;
QPushButton *const m_create;
QToolButton *const m_presets;
bool m_suggestedClipboardSize = false;
};
+58
View File
@@ -0,0 +1,58 @@
#include "UI/ProjectTabLayout.h"
#include <algorithm>
double ProjectTabOverflow::contentWidth() const
{
// A pill always has a tab after it.
return visible.empty() ? 0 : visible.back().x + visible.back().width;
}
QString projectTabOverflowLabel(int hiddenCount)
{
return hiddenCount == 1 ? QStringLiteral("1 more tab") : QStringLiteral("%1 more tabs").arg(hiddenCount);
}
ProjectTabOverflow projectTabOverflow(const std::vector<QUuid> &order, const std::map<QUuid, double> &widths, QUuid selectedID,
double availableWidth, const std::function<double(int)> &pillWidth)
{
if (order.empty())
return {};
const auto span = [&](auto first, auto last) {
double total = 0;
for (auto id = first; id != last; ++id)
total += widths.at(*id);
// Never asked of no tabs.
return total + projectTabSpacing * double(std::distance(first, last) - 1);
};
const auto place = [&](const std::vector<QUuid> &ids, double x) {
std::vector<ProjectTabSlot> placed;
for (const QUuid &id : ids) {
placed.push_back({id, x, widths.at(id)});
x += widths.at(id) + projectTabSpacing;
}
return placed;
};
// Not yet measured, or everything fits: no pill.
if (availableWidth <= 0 || span(order.begin(), order.end()) <= availableWidth)
return {place(order, 0), {}, std::nullopt};
qsizetype shown = qsizetype(order.size());
while (shown > 1) {
const qsizetype hidden = qsizetype(order.size()) - shown;
if (pillWidth(int(hidden)) + projectTabSpacing + span(order.end() - shown, order.end()) <= availableWidth)
break;
shown -= 1;
}
std::vector<QUuid> visibleIDs(order.end() - shown, order.end()), hiddenIDs(order.begin(), order.end() - shown);
if (const auto selected = std::ranges::find(hiddenIDs, selectedID); selected != hiddenIDs.end()) {
hiddenIDs.erase(selected);
hiddenIDs.push_back(visibleIDs[0]);
visibleIDs[0] = selectedID;
// A wider selected tab: the tabs after it make room.
while (visibleIDs.size() > 1 && pillWidth(int(hiddenIDs.size())) + projectTabSpacing + span(visibleIDs.begin(), visibleIDs.end()) > availableWidth) {
hiddenIDs.push_back(visibleIDs[1]);
visibleIDs.erase(visibleIDs.begin() + 1);
}
}
const double pill = pillWidth(int(hiddenIDs.size()));
return {place(visibleIDs, pill + projectTabSpacing), hiddenIDs, ProjectTabPillSlot{0, pill}};
}
+41
View File
@@ -0,0 +1,41 @@
#pragma once
#include <QString>
#include <QUuid>
#include <functional>
#include <map>
#include <optional>
#include <vector>
// Between pills, and after the overflow pill.
inline constexpr double projectTabSpacing = 6;
// A tab's place in the strip.
struct ProjectTabSlot {
QUuid id;
double x;
double width;
bool operator==(const ProjectTabSlot &) const = default;
};
// The overflow pill's place: it is no tab.
struct ProjectTabPillSlot {
double x;
double width;
bool operator==(const ProjectTabPillSlot &) const = default;
};
// What the strip draws: tabs that fit, the rest.
struct ProjectTabOverflow {
std::vector<ProjectTabSlot> visible;
std::vector<QUuid> hiddenIDs;
std::optional<ProjectTabPillSlot> pill;
double contentWidth() const;
bool operator==(const ProjectTabOverflow &) const = default;
};
// "N more tabs", singular for one.
QString projectTabOverflowLabel(int hiddenCount);
// Drops tabs from the front until the rest fit.
ProjectTabOverflow projectTabOverflow(const std::vector<QUuid> &order, const std::map<QUuid, double> &widths, QUuid selectedID,
double availableWidth, const std::function<double(int)> &pillWidth);
+208
View File
@@ -0,0 +1,208 @@
#include "UI/ProjectTabs.h"
#include <QPropertyAnimation>
#include <algorithm>
#include <cmath>
ProjectTabStrip::ProjectTabStrip(ProjectWorkspace &workspace, QWidget *parent)
: QWidget(parent), m_workspace(workspace), m_pill(new OverflowTabsPill(workspace, this))
{
setObjectName(QStringLiteral("projectTabs"));
setAccessibleName(QStringLiteral("Project tabs"));
setFixedHeight(34);
// The toolbar's free width; the toolbar shows through.
setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
connect(&m_workspace, &ProjectWorkspace::changed, this, &ProjectTabStrip::synchronize);
synchronize();
}
// Every tab in a row, as if unmeasured.
QSize ProjectTabStrip::sizeHint() const
{
std::vector<QUuid> order;
for (const std::shared_ptr<ProjectTab> &tab : m_workspace.tabs())
order.push_back(tab->id);
return {int(std::ceil(projectTabOverflow(order, widths(), m_workspace.selectedID(), 0, &OverflowTabsPill::pillWidth).contentWidth())), 34};
}
QList<ProjectTabButton *> ProjectTabStrip::buttons() const
{
return QList<ProjectTabButton *>(m_buttons.begin(), m_buttons.end());
}
void ProjectTabStrip::synchronize()
{
// Buttons follow the tabs: kept where the tab stays.
std::vector<ProjectTabButton *> kept;
for (const std::shared_ptr<ProjectTab> &tab : m_workspace.tabs()) {
const auto found = std::ranges::find(m_buttons, tab, &ProjectTabButton::tab);
ProjectTabButton *button = nullptr;
if (found == m_buttons.end()) {
const QUuid id = tab->id;
button = new ProjectTabButton(m_workspace, tab, [this, id](TabDragPhase phase, double translation) { handleReorder(id, phase, translation); }, this);
// A title or dot changes the widths.
connect(&tab->session, &EditorSession::changed, button, [this] { layOut(); });
} else {
button = *found;
m_buttons.erase(found);
}
button->synchronize();
kept.push_back(button);
}
for (ProjectTabButton *gone : m_buttons)
delete gone;
m_buttons = std::move(kept);
// A drag whose tab closed under it is over.
if (m_reorder && !m_workspace.tab(m_reorder->id))
m_reorder.reset();
layOut();
}
std::map<QUuid, double> ProjectTabStrip::widths() const
{
std::map<QUuid, double> result;
for (const ProjectTabButton *button : m_buttons)
result.insert({button->tab->id, button->sizeHint().width()});
return result;
}
ProjectTabOverflow ProjectTabStrip::overflow() const
{
std::vector<QUuid> order;
for (const std::shared_ptr<ProjectTab> &tab : m_workspace.tabs())
order.push_back(tab->id);
return projectTabOverflow(order, widths(), m_workspace.selectedID(), width(), &OverflowTabsPill::pillWidth);
}
void ProjectTabStrip::layOut(bool animated)
{
const ProjectTabOverflow layout = overflow();
m_pill->setVisible(layout.pill.has_value());
if (layout.pill) {
m_pill->setHiddenIDs(layout.hiddenIDs);
m_pill->setGeometry(int(layout.pill->x), 3, int(layout.pill->width), 28);
}
for (ProjectTabButton *button : m_buttons) {
const auto slot = std::ranges::find(layout.visible, button->tab->id, &ProjectTabSlot::id);
button->setVisible(slot != layout.visible.end());
if (slot == layout.visible.end())
continue;
button->resize(int(slot->width), 28);
const QPoint target(int(std::round(renderX(*slot, layout.contentWidth()))), 3);
// The dragged tab follows the pointer; the others ease aside.
auto *slide = button->findChild<QPropertyAnimation *>(QString(), Qt::FindDirectChildrenOnly);
const bool eases = animated && !(m_reorder && m_reorder->id == button->tab->id);
if (slide && (!eases || slide->endValue() != QVariant(target)))
delete std::exchange(slide, nullptr);
if (!eases)
button->move(target);
else if (!slide && button->pos() != target) {
slide = new QPropertyAnimation(button, "pos", button);
slide->setDuration(150);
slide->setEasingCurve(QEasingCurve::OutQuad);
slide->setEndValue(target);
slide->start(QAbstractAnimation::DeleteWhenStopped);
}
}
updateGeometry();
}
double ProjectTabStrip::renderX(const ProjectTabSlot &slot, double contentWidth) const
{
if (!m_reorder)
return slot.x;
if (slot.id == m_reorder->id)
return std::min(std::max(m_reorder->originX + m_reorder->translation, m_reorder->startX), std::max(m_reorder->startX, contentWidth - slot.width));
const auto index = std::ranges::find(m_reorder->others, slot.id);
if (index == m_reorder->others.end())
return slot.x;
const double x = m_reorder->compactedX.at(slot.id);
return index - m_reorder->others.begin() >= m_reorder->targetIndex ? x + m_reorder->widths.at(m_reorder->id) + projectTabSpacing : x;
}
// The gap nearest the dragged tab: where it lands.
int ProjectTabStrip::Reorder::nearestSlot() const
{
const double x = originX + translation;
std::vector<double> gaps;
for (const QUuid &other : others)
gaps.push_back(compactedX.at(other));
gaps.push_back(others.empty() ? startX : compactedX.at(others.back()) + widths.at(others.back()) + projectTabSpacing);
return int(std::ranges::min_element(gaps, {}, [x](double slot) { return std::abs(slot - x); }) - gaps.begin());
}
void ProjectTabStrip::handleReorder(QUuid id, TabDragPhase phase, double translation)
{
if (phase == TabDragPhase::changed) {
// Another tab's drag lost its release, as Swift's timer finds.
if (m_reorder && m_reorder->id != id)
m_reorder.reset();
if (!m_reorder) {
if (!m_workspace.canSwitch() || std::abs(translation) < 3)
return;
// Dragging a tab selects it, as in Safari.
m_workspace.select(id);
m_reorder = makeReorder(id);
// Above the tabs it crosses.
for (ProjectTabButton *button : m_buttons) {
if (button->tab->id == id)
button->raise();
}
}
m_reorder->translation = translation;
m_reorder->targetIndex = m_reorder->nearestSlot();
layOut(true);
return;
}
// Busy by the drop: the tabs go back.
if (m_reorder && m_reorder->id == id && m_workspace.canSwitch())
commitReorder(*m_reorder);
m_reorder.reset();
layOut(true);
}
ProjectTabStrip::Reorder ProjectTabStrip::makeReorder(QUuid id) const
{
const ProjectTabOverflow layout = overflow();
Reorder state{.id = id, .others = {}, .widths = widths(), .compactedX = {}, .startX = layout.visible.empty() ? 0 : layout.visible.front().x,
.originX = 0};
double x = state.startX;
state.originX = state.startX;
for (const ProjectTabSlot &slot : layout.visible) {
if (slot.id == id) {
state.originX = slot.x;
continue;
}
state.others.push_back(slot.id);
state.compactedX.insert({slot.id, x});
x += state.widths.at(slot.id) + projectTabSpacing;
}
return state;
}
// Only the tabs visible as the drag began ever move.
void ProjectTabStrip::commitReorder(const Reorder &state)
{
std::vector<QUuid> order;
for (const std::shared_ptr<ProjectTab> &tab : m_workspace.tabs())
order.push_back(tab->id);
// A tab closed mid-drag has no place.
const auto position = [&order](QUuid id) -> std::optional<qsizetype> {
const auto found = std::ranges::find(order, id);
return found == order.end() ? std::nullopt : std::optional(found - order.begin());
};
const qsizetype from = position(state.id).value();
qsizetype target = from;
if (const auto neighbor = state.targetIndex < int(state.others.size()) ? position(state.others[size_t(state.targetIndex)]) : std::nullopt)
target = *neighbor;
else if (const auto last = state.others.empty() ? std::nullopt : position(state.others.back()))
target = *last + 1;
if (from < target)
target -= 1;
m_workspace.moveTab(state.id, int(target));
}
void ProjectTabStrip::resizeEvent(QResizeEvent *event)
{
QWidget::resizeEvent(event);
layOut();
}
+87 -71
View File
@@ -6,11 +6,12 @@
#include <QEvent>
#include <QFontMetricsF>
#include <QHBoxLayout>
#include <QMenu>
#include <QMouseEvent>
#include <QPainter>
#include <QScrollBar>
#include <QPainterPath>
#include <QStyleOptionToolButton>
#include <QStylePainter>
#include <QTimer>
namespace {
QFont tabFont(bool active)
@@ -47,8 +48,10 @@ int labelWidth(const QString &title, bool active, bool modified)
}
}
ProjectTabButton::ProjectTabButton(ProjectWorkspace &workspace, std::shared_ptr<ProjectTab> tab, QWidget *parent)
: QWidget(parent), tab(std::move(tab)), m_workspace(workspace), m_select(new TabTitle(this)), m_close(new QToolButton(this))
ProjectTabButton::ProjectTabButton(ProjectWorkspace &workspace, std::shared_ptr<ProjectTab> tab, std::function<void(TabDragPhase, double)> onReorder,
QWidget *parent)
: QWidget(parent), tab(std::move(tab)), m_workspace(workspace), m_onReorder(std::move(onReorder)), m_select(new TabTitle(this)),
m_close(new QToolButton(this))
{
// 28 high: the strip's 34 less its margins.
m_select->setObjectName(QStringLiteral("selectTab"));
@@ -65,6 +68,7 @@ ProjectTabButton::ProjectTabButton(ProjectWorkspace &workspace, std::shared_ptr<
row->addWidget(m_select);
row->addWidget(m_close);
setAcceptDrops(true);
m_select->installEventFilter(this);
connect(m_select, &QToolButton::clicked, this, [this] { m_workspace.select(this->tab->id); });
connect(m_close, &QToolButton::clicked, this, [this] { m_workspace.close(this->tab->id); });
// The strip syncs every button on the workspace's news.
@@ -93,6 +97,26 @@ void ProjectTabButton::synchronize()
setProperty("active", active);
}
// A press moved three points reorders; measured in the window.
bool ProjectTabButton::eventFilter(QObject *watched, QEvent *event)
{
const auto *mouse = static_cast<QMouseEvent *>(event);
if (event->type() == QEvent::MouseButtonPress && mouse->button() == Qt::LeftButton) {
m_press = mouse->globalPosition();
m_dragging = false;
} else if (event->type() == QEvent::MouseMove && m_press && mouse->buttons().testFlag(Qt::LeftButton)) {
m_dragging = m_dragging || QLineF(*m_press, mouse->globalPosition()).length() >= 3;
if (m_dragging)
m_onReorder(TabDragPhase::changed, mouse->globalPosition().x() - m_press->x());
} else if (event->type() == QEvent::MouseButtonRelease && mouse->button() == Qt::LeftButton && m_press) {
const double translation = mouse->globalPosition().x() - m_press->x();
m_press.reset();
if (std::exchange(m_dragging, false))
m_onReorder(TabDragPhase::ended, translation);
}
return QWidget::eventFilter(watched, event);
}
// Another tab's layer, unless Alt duplicates it, or files.
bool ProjectTabButton::acceptsDrop(const QMimeData &data) const
{
@@ -237,82 +261,74 @@ void NewCanvasButton::dropEvent(QDropEvent *event)
m_workspace.receiveProviders(*event->mimeData(), std::nullopt);
}
ProjectTabStrip::ProjectTabStrip(ProjectWorkspace &workspace, QWidget *parent)
: QScrollArea(parent), m_workspace(workspace), m_row(new QWidget(this))
OverflowTabsPill::OverflowTabsPill(ProjectWorkspace &workspace, QWidget *parent) : QToolButton(parent), m_workspace(workspace)
{
setObjectName(QStringLiteral("projectTabs"));
setAccessibleName(QStringLiteral("Project tabs"));
setFixedHeight(34);
setFrameShape(QFrame::NoFrame);
// The toolbar shows through; a stylesheet would freeze the palette.
setAutoFillBackground(false);
viewport()->setAutoFillBackground(false);
setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
auto *row = new QHBoxLayout(m_row);
row->setContentsMargins(0, 3, 0, 3);
row->setSpacing(6);
row->addStretch(1);
setWidget(m_row);
// setWidget turns the row's fill on; the toolbar shows through.
m_row->setAutoFillBackground(false);
m_row->installEventFilter(this);
connect(&m_workspace, &ProjectWorkspace::changed, this, &ProjectTabStrip::synchronize);
synchronize();
setObjectName(QStringLiteral("projectTabsOverflow"));
setFocusPolicy(Qt::NoFocus);
QFont font;
font.setPixelSize(12);
font.setWeight(QFont::Medium);
setFont(font);
connect(this, &QToolButton::pressed, this, &OverflowTabsPill::showMenu);
}
QList<ProjectTabButton *> ProjectTabStrip::buttons() const
double OverflowTabsPill::pillWidth(int hiddenCount)
{
QList<ProjectTabButton *> result;
for (int index = 0; index < m_row->layout()->count(); ++index) {
if (auto *button = qobject_cast<ProjectTabButton *>(m_row->layout()->itemAt(index)->widget()))
result << button;
}
return result;
QFont font;
font.setPixelSize(12);
font.setWeight(QFont::Medium);
// Leading, gap before the chevron, chevron, trailing.
return std::ceil(QFontMetricsF(font).horizontalAdvance(projectTabOverflowLabel(hiddenCount))) + 11 + 4 + 10 + 11;
}
void ProjectTabStrip::synchronize()
void OverflowTabsPill::setHiddenIDs(std::vector<QUuid> hiddenIDs)
{
// Buttons follow the tabs: kept where the tab stays.
auto *row = static_cast<QHBoxLayout *>(m_row->layout());
QList<ProjectTabButton *> shown = buttons();
int position = 0;
for (const std::shared_ptr<ProjectTab> &tab : m_workspace.tabs()) {
const auto kept = std::find_if(shown.begin(), shown.end(), [&](ProjectTabButton *button) { return button->tab == tab; });
ProjectTabButton *button = kept == shown.end() ? new ProjectTabButton(m_workspace, tab, m_row) : *kept;
if (kept != shown.end())
shown.erase(kept);
row->removeWidget(button);
row->insertWidget(position, button);
button->show();
button->synchronize();
position += 1;
}
for (ProjectTabButton *gone : shown)
delete gone;
// The row is as wide as its tabs, never squeezed.
m_row->resize(m_row->sizeHint());
showFront();
m_hiddenIDs = std::move(hiddenIDs);
const QString label = projectTabOverflowLabel(int(m_hiddenIDs.size()));
setText(label);
setToolTip(label);
setAccessibleName(label);
update();
}
bool ProjectTabStrip::eventFilter(QObject *watched, QEvent *event)
// Below the pill: hidden tabs in order, unsaved marked.
void OverflowTabsPill::showMenu()
{
// A title changed size: the row takes its new width.
if (event->type() == QEvent::LayoutRequest)
m_row->resize(m_row->sizeHint());
return QScrollArea::eventFilter(watched, event);
}
// Only a new front tab scrolls into view.
void ProjectTabStrip::showFront()
{
if (std::exchange(m_shownFront, m_workspace.selectedID()) == m_workspace.selectedID())
setDown(false);
if (!m_workspace.canSwitch())
return;
// Posted, as Swift's: the row's layout request runs first.
QTimer::singleShot(0, this, [this] {
for (ProjectTabButton *button : buttons()) {
if (button->tab->id == m_workspace.selectedID())
ensureWidgetVisible(button, 0, 0);
}
});
auto *menu = new QMenu(this);
menu->setObjectName(QStringLiteral("projectTabsOverflowMenu"));
menu->setAttribute(Qt::WA_DeleteOnClose);
for (const QUuid &id : m_hiddenIDs) {
const std::shared_ptr<ProjectTab> tab = m_workspace.tab(id);
const QString title = (tab->session.isModified() ? QStringLiteral("• ") : QString()) + tab->title();
connect(menu->addAction(title), &QAction::triggered, this, [this, id] { m_workspace.select(id); });
}
menu->popup(mapToGlobal(QPoint(0, height() + 4)));
}
void OverflowTabsPill::paintEvent(QPaintEvent *)
{
// An inactive tab's capsule; the label dims while busy.
QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing);
QColor fill = palette().color(QPalette::WindowText), edge = fill;
fill.setAlphaF(0.035);
edge.setAlphaF(0.08);
painter.setPen(QPen(edge, 1));
painter.setBrush(fill);
painter.drawRoundedRect(QRectF(rect()).adjusted(0.5, 0.5, -0.5, -0.5), 14, 14);
painter.setOpacity(m_workspace.canSwitch() ? 1 : 0.5);
painter.setPen(palette().color(QPalette::ButtonText));
painter.drawText(QRectF(11, 0, width() - 36, height()), Qt::AlignLeft | Qt::AlignVCenter, text());
// Swift's chevron.down, nine points, medium.
QPainterPath chevron;
const double left = width() - 21, middle = height() / 2.0;
chevron.moveTo(left + 1, middle - 2);
chevron.lineTo(left + 5, middle + 2);
chevron.lineTo(left + 9, middle - 2);
painter.setPen(QPen(palette().color(QPalette::ButtonText), 1.4, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin));
painter.setBrush(Qt::NoBrush);
painter.drawPath(chevron);
}
+61 -9
View File
@@ -1,14 +1,18 @@
#pragma once
#include "Document/ProjectWorkspace.h"
#include <QScrollArea>
#include "UI/ProjectTabLayout.h"
#include <QToolButton>
#include <QWidget>
// Swift's TabDragPhase: a reorder drag goes on, or ends.
enum class TabDragPhase { changed, ended };
// One tab: its title, the unsaved dot, its close button.
class ProjectTabButton : public QWidget {
Q_OBJECT
public:
ProjectTabButton(ProjectWorkspace &workspace, std::shared_ptr<ProjectTab> tab, QWidget *parent = nullptr);
ProjectTabButton(ProjectWorkspace &workspace, std::shared_ptr<ProjectTab> tab, std::function<void(TabDragPhase, double)> onReorder,
QWidget *parent = nullptr);
const std::shared_ptr<ProjectTab> tab;
// What the session and the workspace hold, shown.
@@ -16,6 +20,7 @@ public:
bool isActive() const;
protected:
bool eventFilter(QObject *watched, QEvent *event) override;
void paintEvent(QPaintEvent *event) override;
void dragEnterEvent(QDragEnterEvent *event) override;
void dragMoveEvent(QDragMoveEvent *event) override;
@@ -27,9 +32,13 @@ private:
void setTargeted(bool targeted);
ProjectWorkspace &m_workspace;
const std::function<void(TabDragPhase, double)> m_onReorder;
QToolButton *const m_select;
QToolButton *const m_close;
bool m_targeted = false;
// Swift's DragGesture: the press, in the window's space.
std::optional<QPointF> m_press;
bool m_dragging = false;
};
// Swift's NewProjectDropTarget: the New canvas button takes drops.
@@ -53,23 +62,66 @@ private:
bool m_targeted = false;
};
// The row of tabs, one a project, scrolling when long.
class ProjectTabStrip : public QScrollArea {
// Swift's OverflowTabsPill: the tabs that do not fit, a menu.
class OverflowTabsPill : public QToolButton {
Q_OBJECT
public:
explicit OverflowTabsPill(ProjectWorkspace &workspace, QWidget *parent = nullptr);
// Swift's projectTabOverflowPillWidth.
static double pillWidth(int hiddenCount);
void setHiddenIDs(std::vector<QUuid> hiddenIDs);
protected:
void paintEvent(QPaintEvent *event) override;
private:
void showMenu();
ProjectWorkspace &m_workspace;
std::vector<QUuid> m_hiddenIDs;
};
// The row of tabs; the rest gather in a menu.
class ProjectTabStrip : public QWidget {
Q_OBJECT
public:
explicit ProjectTabStrip(ProjectWorkspace &workspace, QWidget *parent = nullptr);
QList<ProjectTabButton *> buttons() const;
OverflowTabsPill *pill() const { return m_pill; }
QSize sizeHint() const override;
protected:
bool eventFilter(QObject *watched, QEvent *event) override;
void resizeEvent(QResizeEvent *event) override;
private:
// Swift's TabReorderState, taken once as the drag starts.
struct Reorder {
QUuid id;
std::vector<QUuid> others;
std::map<QUuid, double> widths;
std::map<QUuid, double> compactedX;
double startX;
double originX;
double translation = 0;
int targetIndex = 0;
int nearestSlot() const;
};
void synchronize();
void showFront();
std::map<QUuid, double> widths() const;
ProjectTabOverflow overflow() const;
// Places the pill and the tabs; others slide when `animated`.
void layOut(bool animated = false);
double renderX(const ProjectTabSlot &slot, double contentWidth) const;
void handleReorder(QUuid id, TabDragPhase phase, double translation);
Reorder makeReorder(QUuid id) const;
void commitReorder(const Reorder &state);
ProjectWorkspace &m_workspace;
QWidget *const m_row;
// Swift's lastSelectedID: only a new front tab scrolls.
std::optional<QUuid> m_shownFront;
OverflowTabsPill *const m_pill;
std::vector<ProjectTabButton *> m_buttons;
std::optional<Reorder> m_reorder;
};
+1 -4
View File
@@ -26,11 +26,8 @@ ProjectWorkspaceView::ProjectWorkspaceView(ProjectWorkspace &workspace, QWidget
auto *newButton = new NewCanvasButton(m_workspace, m_toolbar);
newButton->setDefaultAction(m_newCanvas);
m_toolbar->addWidget(newButton);
m_toolbar->addWidget(m_tabs);
// The strip takes the toolbar's free width; zooms stay right.
auto *spacer = new QWidget(m_toolbar);
spacer->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred);
m_toolbar->addWidget(spacer);
m_toolbar->addWidget(m_tabs);
m_fit = action(m_toolbar, QStringLiteral("Fit"), QStringLiteral("Fit canvas in window (Ctrl+0)"), QStringLiteral("fitCanvas"));
m_actualPixels = action(m_toolbar, QStringLiteral("100%"), QStringLiteral("Actual pixels (Ctrl+1)"), QStringLiteral("actualPixels"));
m_zoomIn = action(m_toolbar, QStringLiteral("+"), QStringLiteral("Zoom in (Ctrl++)"), QStringLiteral("zoomIn"));