From 5bfbc6cd5d95ee34393c12413c50a2e82d8e9e43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Thu, 1 Oct 2026 17:14:47 -0400 Subject: [PATCH 1/2] =?UTF-8?q?Quit=20settles=20a=20pending=20gradient,=20?= =?UTF-8?q?a=20pixel=20move=20and=20open=20dialogs;=20Dither=20=E2=80=BA?= =?UTF-8?q?=20Scanlines=20(CRT)=20on=20all=20cores=20(13.17)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- kernels/DitherPixels.c | 163 ++++++++++++- kernels/DitherPixels.h | 10 +- src/Document/Dither.cpp | 67 +++++- src/Document/Dither.h | 19 +- src/Document/ProjectWorkspace.cpp | 32 ++- src/Document/ProjectWorkspace.h | 2 + src/UI/FilterSheet+Dither.cpp | 19 +- tests/DitherSheetTests.cpp | 4 +- tests/DitherTests.cpp | 4 +- tests/QuitPendingEditsTests.cpp | 246 +++++++++++++++++++ tests/ScanlinesTests.cpp | 377 ++++++++++++++++++++++++++++++ tests/Tests.cmake | 2 + 12 files changed, 913 insertions(+), 32 deletions(-) create mode 100644 tests/QuitPendingEditsTests.cpp create mode 100644 tests/ScanlinesTests.cpp diff --git a/kernels/DitherPixels.c b/kernels/DitherPixels.c index 1f33852..caa40e1 100644 --- a/kernels/DitherPixels.c +++ b/kernels/DitherPixels.c @@ -1,9 +1,30 @@ #include "DitherPixels.h" #include #include +#include + +// Linux: GCD's dispatch_apply, run on QtConcurrent by Document/Dither.cpp. Blocks become functions with a context. +void dither_bands(size_t bands, void (*band)(void *context, size_t index), void *context); static inline float clamp01(float v) { return v < 0 ? 0 : v > 1 ? 1 : v; } +typedef void (*band_body)(void *context, size_t start, size_t end); +typedef struct { size_t count, size; band_body body; void *context; } band_job; + +static void run_band(void *context, size_t band) { + const band_job *job = context; + size_t count = job->count, size = job->size; + size_t start = band * size, end = start + size < count ? start + size : count; + if (start < end) job->body(job->context, start, end); +} + +// Runs `body` over `count` items split into a few runs per core, each a (start, end) range, all at once. +static void in_bands(size_t count, band_body body, void *context) { + size_t bands = count < 64 ? 1 : 32, size = (count + bands - 1) / bands; + band_job job = { count, size, body, context }; + dither_bands(bands, run_band, &job); +} + // Density darkens (positive) or lightens as a gamma, so black and white stay put; contrast pivots on mid gray. static inline float adjust_tone(float v, float gamma, float contrast) { v = powf(clamp01(v), gamma); @@ -114,19 +135,17 @@ static inline void write_pixel(uint8_t *px, float r, float g, float b) { px[2] = (uint8_t)lroundf(clamp01(b) * a * 255.0f); } -int dither_apply(uint8_t *rgba, size_t width, size_t height, size_t stride, const DitherParams *p) { - size_t count = width * height; - if (!count) return 1; - int planes = p->originalColors ? 3 : 1; - float *tone = malloc(count * sizeof(float) * (size_t)planes); - uint8_t *alpha = malloc(count); - // The image's own colors, unadjusted: halftone dots and glyphs take them in Original mode. - float *source = p->originalColors ? malloc(count * sizeof(float) * 3) : NULL; - if (!tone || !alpha || (p->originalColors && !source)) { free(tone); free(alpha); free(source); return 0; } +typedef struct { + uint8_t *rgba; size_t stride, width, count; uint8_t *alpha; float *tone, *source; const DitherParams *p; + float gamma, contrast; +} tone_job; - float gamma = exp2f(p->density * 1.5f); - float contrast = p->contrast >= 0 ? 1.0f / (1.0f - 0.95f * p->contrast) : 1.0f + p->contrast; - for (size_t y = 0; y < height; ++y) { +static void tone_band(void *context, size_t first, size_t last) { + const tone_job *job = context; + uint8_t *rgba = job->rgba; size_t stride = job->stride, width = job->width, count = job->count; + uint8_t *alpha = job->alpha; float *tone = job->tone, *source = job->source; const DitherParams *p = job->p; + float gamma = job->gamma, contrast = job->contrast; + for (size_t y = first; y < last; ++y) { const uint8_t *row = rgba + y * stride; for (size_t x = 0; x < width; ++x) { const uint8_t *px = row + x * 4; @@ -147,6 +166,90 @@ int dither_apply(uint8_t *rgba, size_t width, size_t height, size_t stride, cons } } } +} + +typedef struct { + uint8_t *rgba; size_t stride, width, height, count; int planes; const uint8_t *alpha; const float *tone; + const DitherParams *p; size_t spacing; float middle, dots; const float *screen, *phosphor; atomic_int failed; +} scan_job; + +static void scan_band(void *context, size_t firstLine, size_t lastLine) { + scan_job *job = context; + uint8_t *rgba = job->rgba; size_t stride = job->stride, width = job->width, height = job->height, count = job->count; + int planes = job->planes; const uint8_t *alpha = job->alpha; const float *tone = job->tone; const DitherParams *p = job->p; + size_t spacing = job->spacing; float middle = job->middle, dots = job->dots; + const float *screen = job->screen, *phosphor = job->phosphor; + float *scan = malloc(width * sizeof(float) * (size_t)planes); + if (!scan) { atomic_store(&job->failed, 1); return; } + for (size_t line = firstLine; line < lastLine; ++line) { + size_t top = line * spacing; + size_t bottom = top + spacing < height ? top + spacing : height; + // Wobble: each line is pushed sideways, a slow wave down the screen with a quicker one over it, as a + // CRT's picture wavers when its sync drifts. + float wave = sinf((float)line * 0.45f) * 0.7f + sinf((float)line * 1.7f + 1.3f) * 0.3f; + long shift = lroundf(p->wobble * wave); + for (size_t x = 0; x < width; ++x) { + float sum[3] = { 0, 0, 0 }; int n = 0; + long sx = (long)x - shift; + if (sx >= 0 && sx < (long)width) + for (size_t y = top; y < bottom; ++y) { + size_t at = y * width + (size_t)sx; + if (!alpha[at]) continue; + for (int c = 0; c < planes; ++c) sum[c] += tone[(size_t)c * count + at]; + ++n; + } + for (int c = 0; c < planes; ++c) scan[(size_t)c * width + x] = n ? sum[c] / (float)n : 0; + } + for (size_t y = top; y < bottom; ++y) { + uint8_t *row = rgba + y * stride; + float offset = fabsf((float)(y - top) + 0.5f - middle); + for (size_t x = 0; x < width; ++x) { + if (!alpha[y * width + x]) continue; + // Dots: the line breaks into beads, one every line spacing, each lit in the color at its middle. + float along = fmodf((float)x + 0.5f, (float)spacing) - middle; + long centered = lroundf((float)x - along * dots); + size_t at = centered < 0 ? 0 : (size_t)centered >= width ? width - 1 : (size_t)centered; + float r, g, b, t; + if (p->originalColors) { + r = scan[at]; g = scan[width + at]; b = scan[2 * width + at]; + t = 0.2126f * r + 0.7152f * g + 0.0722f * b; + } else { + t = scan[at]; + r = screen[0] + (phosphor[0] - screen[0]) * t; + g = screen[1] + (phosphor[1] - screen[1]) * t; + b = screen[2] + (phosphor[2] - screen[2]) * t; + } + // The beam is driven brighter than the picture, making up for the dark screen between lines. + r *= 1.35f; g *= 1.35f; b *= 1.35f; + // Half the beam's height: a thin line in the shadows, most of the way across in the highlights, + // always leaving dark screen between lines. + float beam = middle * (0.2f + 0.5f * sqrtf(clamp01(t))); + float across = along * dots, distance = sqrtf(offset * offset + across * across); + float cover = clamp01(beam - distance + 0.5f); + // Between the lines, the screen: black in Original, else the dark color. + float br = p->originalColors ? 0 : screen[0], bg = p->originalColors ? 0 : screen[1]; + float bb = p->originalColors ? 0 : screen[2]; + write_pixel(row + x * 4, br + (r - br) * cover, bg + (g - bg) * cover, bb + (b - bb) * cover); + } + } + } + free(scan); +} + +int dither_apply(uint8_t *rgba, size_t width, size_t height, size_t stride, const DitherParams *p) { + size_t count = width * height; + if (!count) return 1; + int planes = p->originalColors ? 3 : 1; + float *tone = malloc(count * sizeof(float) * (size_t)planes); + uint8_t *alpha = malloc(count); + // The image's own colors, unadjusted: halftone dots and glyphs take them in Original mode. + float *source = p->originalColors ? malloc(count * sizeof(float) * 3) : NULL; + if (!tone || !alpha || (p->originalColors && !source)) { free(tone); free(alpha); free(source); return 0; } + + float gamma = exp2f(p->density * 1.5f); + float contrast = p->contrast >= 0 ? 1.0f / (1.0f - 0.95f * p->contrast) : 1.0f + p->contrast; + tone_job toning = { rgba, stride, width, count, alpha, tone, source, p, gamma, contrast }; + in_bands(height, tone_band, &toning); float dark[3] = { p->dark[0] / 255.0f, p->dark[1] / 255.0f, p->dark[2] / 255.0f }; float light[3] = { p->light[0] / 255.0f, p->light[1] / 255.0f, p->light[2] / 255.0f }; @@ -183,6 +286,18 @@ int dither_apply(uint8_t *rgba, size_t width, size_t height, size_t stride, cons } } } + } else if (style == DITHER_SCANLINES) { + // A CRT: each line scans the image, its tone along the line the average of the rows it covers. The beam glows + // brighter and blooms thicker where the picture is light, and the screen between lines stays dark. + size_t spacing = (size_t)(p->cell < 2 ? 2 : p->cell); + float middle = (float)spacing / 2, dots = clamp01(p->dots); + // Each line is drawn on its own, so the lines are shared out across the cores. + size_t lines = (height + spacing - 1) / spacing; + const float *screen = dark, *phosphor = light; + scan_job scanning = { rgba, stride, width, height, count, planes, alpha, tone, p, spacing, middle, dots, screen, phosphor, 0 }; + in_bands(lines, scan_band, &scanning); + int failed = atomic_load(&scanning.failed); + if (failed) { free(tone); free(alpha); free(source); return 0; } } else { // Marks (halftone shapes, patterns, glyphs) cover as much of each spot as the tone calls for. On light, they // stand for darkness and are drawn in the dark color; light on dark, the reverse. @@ -277,3 +392,27 @@ void dither_dots(uint8_t *rgba, size_t width, size_t height, size_t stride, int } } } + +typedef struct { uint8_t *rgba; const uint8_t *glow; size_t width, stride; float amount; } glow_job; + +static void glow_band(void *context, size_t first, size_t last) { + const glow_job *job = context; + uint8_t *rgba = job->rgba; const uint8_t *glow = job->glow; size_t width = job->width, stride = job->stride; + float amount = job->amount; + for (size_t y = first; y < last; ++y) { + uint8_t *row = rgba + y * stride; + const uint8_t *light = glow + y * stride; + for (size_t x = 0; x < width * 4; x += 4) { + float a = row[x + 3]; + for (int c = 0; c < 3; ++c) { + float v = (float)row[x + c] + (float)light[x + c] * amount * a / 255.0f; + row[x + c] = (uint8_t)lroundf(v > a ? a : v); + } + } + } +} + +void dither_glow(uint8_t *rgba, const uint8_t *glow, size_t width, size_t height, size_t stride, float amount) { + glow_job glowing = { rgba, glow, width, stride, amount }; + in_bands(height, glow_band, &glowing); +} diff --git a/kernels/DitherPixels.h b/kernels/DitherPixels.h index 41561c8..dc3769d 100644 --- a/kernels/DitherPixels.h +++ b/kernels/DitherPixels.h @@ -8,7 +8,7 @@ enum { DITHER_ATKINSON, DITHER_FLOYD_STEINBERG, DITHER_BAYER_2, DITHER_BAYER_4, DITHER_BAYER_8, DITHER_DOTS, DITHER_LINES, DITHER_DIAMONDS, - DITHER_PATTERNS, DITHER_GLYPHS + DITHER_PATTERNS, DITHER_GLYPHS, DITHER_SCANLINES }; typedef struct { @@ -20,7 +20,8 @@ typedef struct { // −1…1: darker (more ink) or lighter, and flatter or punchier, before dithering. float density; float contrast; - // Halftone and glyph cells, in pixels, and the halftone screen's angle in radians. + // Halftone and glyph cells, in pixels (scanlines: the distance between lines), and the halftone screen's angle in + // radians. int cell; float angle; // Halftone dots, patterns and glyphs mark the light tones on the dark color instead of the dark on the light. @@ -37,6 +38,9 @@ typedef struct { const uint8_t *glyphs; const float *glyphCoverage; int glyphCount; + // Scanlines: how far each line breaks into round dots (0–1), and how many pixels its wobble pushes it sideways. + float dots; + float wobble; } DitherParams; // Dithers premultiplied RGBA pixels (4 bytes per pixel, `stride` bytes per row) in place. Alpha is kept and fully @@ -45,4 +49,6 @@ int dither_apply(uint8_t *rgba, size_t width, size_t height, size_t stride, cons // Turns each `block` × `block` square of premultiplied RGBA pixels into a round dot in its own color on `gap` (straight // sRGB), like the lit pixels of a dot-matrix screen. The dot's edge is smoothed and alpha is kept. void dither_dots(uint8_t *rgba, size_t width, size_t height, size_t stride, int block, const uint8_t *gap); +// Adds `glow` (premultiplied RGBA, same layout) over the pixels at `amount`, never past their own alpha. +void dither_glow(uint8_t *rgba, const uint8_t *glow, size_t width, size_t height, size_t stride, float amount); #endif diff --git a/src/Document/Dither.cpp b/src/Document/Dither.cpp index 24b0bd5..a3853ea 100644 --- a/src/Document/Dither.cpp +++ b/src/Document/Dither.cpp @@ -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 #include #include #include #include #include +#include #include 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 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 darkColor = two ? bytes(dark) : std::array{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), ¶ms) == 0; }); if (failed) diff --git a/src/Document/Dither.h b/src/Document/Dither.h index 06f88df..c25ed82 100644 --- a/src/Document/Dither.h +++ b/src/Document/Dither.h @@ -6,10 +6,10 @@ #include // 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; }; diff --git a/src/Document/ProjectWorkspace.cpp b/src/Document/ProjectWorkspace.cpp index 6fe8a5f..d7a8c11 100644 --- a/src/Document/ProjectWorkspace.cpp +++ b/src/Document/ProjectWorkspace.cpp @@ -220,14 +220,40 @@ bool ProjectWorkspace::finishTextEditing() return true; } +void ProjectWorkspace::settlePendingEdits(std::function done) +{ + // Canvas edits are applied; dialogs cancelled, as their Cancel. + const std::shared_ptr settled = tab(current().id); + settled->session.commitGradient([self = QPointer(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 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> order, size_t index, std::function done) diff --git a/src/Document/ProjectWorkspace.h b/src/Document/ProjectWorkspace.h index f0609c7..1924b17 100644 --- a/src/Document/ProjectWorkspace.h +++ b/src/Document/ProjectWorkspace.h @@ -42,6 +42,8 @@ public: void close(QUuid id, std::function done = {}); void removeTab(QUuid id); std::vector> quitOrder() const; + // Applies canvas edits and cancels dialogs before a quit. + void settlePendingEdits(std::function done); void confirmQuit(std::function done); void closeWindow(QWidget *closing); void receive(const QList &urls, std::optional destination = std::nullopt, diff --git a/src/UI/FilterSheet+Dither.cpp b/src/UI/FilterSheet+Dither.cpp index 5cca135..471011e 100644 --- a/src/UI/FilterSheet+Dither.cpp +++ b/src/UI/FilterSheet+Dither.cpp @@ -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); }); diff --git a/tests/DitherSheetTests.cpp b/tests/DitherSheetTests.cpp index 387276e..464e5b1 100644 --- a/tests/DitherSheetTests.cpp +++ b/tests/DitherSheetTests.cpp @@ -179,13 +179,13 @@ void DitherSheetTests::theControlsFollowTheStyle() dither.colors = DitherColors::twoColors; }); QCOMPARE(shown.shown(), sorted(QStringList{"Style", "Pixel Size", "Density", "Contrast", "Colors", "Light on Dark", "Swatches"})); - // Style's menu: ten looks in four groups. + // Style's menu: eleven looks in four groups. QComboBox &style = shown.child("ditherStyle"); QStringList items; for (int index = 0; index < style.count(); ++index) items << (style.itemText(index).isEmpty() ? QStringLiteral("—") : style.itemText(index)); QCOMPARE(items, (QStringList{"Atkinson (Classic Mac)", "Floyd–Steinberg", "—", "Bayer 2 × 2", "Bayer 4 × 4", "Bayer 8 × 8", "—", "Halftone Dots", - "Halftone Lines", "Halftone Diamonds", "—", "Mac Patterns", "ASCII"})); + "Halftone Lines", "Halftone Diamonds", "—", "Mac Patterns", "ASCII", "Scanlines (CRT)"})); QCOMPARE(style.currentText(), QString("Mac Patterns")); // Its quick preview never shows the busy line. QVERIFY(!shown.child("filterSpinner").isVisibleTo(shown.sheet.get())); diff --git a/tests/DitherTests.cpp b/tests/DitherTests.cpp index 7177ffd..0471e57 100644 --- a/tests/DitherTests.cpp +++ b/tests/DitherTests.cpp @@ -67,11 +67,11 @@ void DitherTests::stylesKeepSwiftsNamesOrderAndGroups() for (const DitherStyle each : allDitherStyles) names << rawValue(each); QCOMPARE(names, (QStringList{"Atkinson (Classic Mac)", "Floyd–Steinberg", "Bayer 2 × 2", "Bayer 4 × 4", "Bayer 8 × 8", "Halftone Dots", - "Halftone Lines", "Halftone Diamonds", "Mac Patterns", "ASCII"})); + "Halftone Lines", "Halftone Diamonds", "Mac Patterns", "ASCII", "Scanlines (CRT)"})); QString groups; for (const DitherStyle each : allDitherStyles) groups += QString::number(ditherGroup(each)); - QCOMPARE(groups, QString("0011122233")); + QCOMPARE(groups, QString("00111222333")); QVERIFY(diffuses(DitherStyle::floydSteinberg) && !diffuses(DitherStyle::bayer2)); QVERIFY(hasTones(DitherStyle::bayer8) && !hasTones(DitherStyle::dots)); QVERIFY(isHalftone(DitherStyle::diamonds) && !isHalftone(DitherStyle::patterns)); diff --git a/tests/QuitPendingEditsTests.cpp b/tests/QuitPendingEditsTests.cpp new file mode 100644 index 0000000..206e988 --- /dev/null +++ b/tests/QuitPendingEditsTests.cpp @@ -0,0 +1,246 @@ +#include "DialogDesk.h" +#include "Document/ProjectWorkspace.h" +#include "SelectionFixtures.h" +#include "UI/ProjectWorkspaceView.h" +#include + +// Swift 1.4.4: quit applies canvas edits and cancels dialogs. +namespace { +ImportedImage filled(const QColor &colour) +{ + QImage pixels(100, 20, QImage::Format_RGBA8888_Premultiplied); + pixels.fill(colour); + return ImportedImage(pixels, pixels, QStringLiteral("Fixture")); +} + +// Swift's await: true once the call's callback has run. +bool awaited(const std::function)> &call) +{ + bool done = false; + call([&done] { done = true; }); + return QTest::qWaitFor([&done] { return done; }, 10'000); +} + +std::optional answer(ProjectWorkspace &workspace) +{ + std::optional result; + workspace.confirmQuit([&](bool value) { result = value; }); + return QTest::qWaitFor([&] { return result.has_value(); }, 10'000) ? result : std::nullopt; +} + +// Swift's test: a gradient waiting for Apply. +void pendGradient(EditorSession &session) +{ + session.createDocument(100, 20); + session.addBlankLayer(); + session.selectTool(NavigationTool::gradient); + session.beginGradient(QPointF(0, 10)); + session.moveGradient(std::nullopt, QPointF(100, 10)); + session.endGradientDrag(); +} + +ImageIdentity identity(const EditorSession &session) +{ + return session.activeLayer().value().asset.value().identity(); +} +} + +class QuitPendingEditsTests : public QObject { + Q_OBJECT +private slots: + void quittingAppliesAPendingGradient(); + void quittingAppliesAPixelMove(); + void quittingCancelsAnOpenDialog(); + void quittingCancelsTheOtherDialogs(); + void quittingCancelsAnAdjustmentsEditor(); + void aWorkspaceGoneMidSettleCallsNothing(); + void closingTheWindowAppliesTheGradientThenAsks(); + void aBusyProjectHoldsTheQuit(); + void aQuitWhileManagingIsRefusedAtOnce(); +}; + +void QuitPendingEditsTests::quittingAppliesAPendingGradient() +{ + ProjectWorkspace workspace; + EditorSession &session = workspace.current().session; + pendGradient(session); + QVERIFY(!workspace.canSwitch()); + const int count = session.history.undoCount(); + QVERIFY(awaited([&](std::function done) { workspace.settlePendingEdits(std::move(done)); })); + QVERIFY(!session.gradientEdit()); + QCOMPARE(session.history.undoCount(), count + 1); + QCOMPARE(session.history.undoName(), QString("Gradient")); + QVERIFY(workspace.canSwitch()); +} + +void QuitPendingEditsTests::quittingAppliesAPixelMove() +{ + ProjectWorkspace workspace; + EditorSession &session = workspace.current().session; + session.createDocument(100, 20); + session.insert(filled(QColor(200, 100, 50))); + session.applySelection(rectPath(QRectF(10, 5, 10, 10)), SelectionMode::replace, "Select"); + QVERIFY(session.beginPixelMove()); + session.movePixels(QSizeF(30, 0)); + QVERIFY(!workspace.canSwitch()); + const int count = session.history.undoCount(); + QVERIFY(awaited([&](std::function done) { workspace.settlePendingEdits(std::move(done)); })); + QVERIFY(!session.pixelMove()); + QCOMPARE(session.history.undoCount(), count + 1); + QCOMPARE(session.history.undoName(), QString("Move Pixels")); + QCOMPARE(session.selection().value().path.boundingRect(), QRectF(40, 5, 10, 10)); + QVERIFY(workspace.canSwitch()); +} + +void QuitPendingEditsTests::quittingCancelsAnOpenDialog() +{ + ProjectWorkspace workspace; + EditorSession &session = workspace.current().session; + session.createDocument(100, 20); + session.insert(filled(QColor(200, 100, 50))); + const ImageIdentity original = identity(session); + const int count = session.history.undoCount(); + const std::vector> opens{[&] { session.beginFilter(FilterKind::gaussianBlur); }, + [&] { session.beginHueSaturation(); }, [&] { session.beginLevels(); }}; + for (const std::function &open : opens) { + open(); + QVERIFY(!workspace.canSwitch()); + QVERIFY(awaited([&](std::function done) { workspace.settlePendingEdits(std::move(done)); })); + QVERIFY(workspace.canSwitch()); + QVERIFY(identity(session) == original); + } + QVERIFY(!session.filterEdit() && !session.hueSaturation() && !session.levels()); + QCOMPARE(session.history.undoCount(), count); +} + +void QuitPendingEditsTests::quittingCancelsTheOtherDialogs() +{ + ProjectWorkspace workspace; + EditorSession &session = workspace.current().session; + session.createDocument(100, 20); + session.insert(filled(QColor(200, 100, 50))); + const QPainterPath before = rectPath(QRectF(10, 5, 10, 10)); + session.applySelection(before, SelectionMode::replace, "Select"); + const PaletteColor foreground = session.foregroundColor(); + const auto settle = [&] { return awaited([&](std::function done) { workspace.settlePendingEdits(std::move(done)); }); }; + // Color Range: the selection goes back as it was. + session.beginColorRange(); + QVERIFY(session.colorRange() && !workspace.canSwitch()); + QVERIFY(settle()); + QVERIFY(!session.colorRange()); + QCOMPARE(session.selection().value().path.boundingRect(), before.boundingRect()); + // Expand, Contract or Feather: its panel goes. + session.setSelectionAmountOperation(SelectionAmountOperation::feather); + QVERIFY(!workspace.canSwitch()); + QVERIFY(settle()); + QVERIFY(!session.selectionAmountOperation()); + // The colour picker: closed, its colour not taken. + session.openColorPicker(false); + session.setColorPickerHSB(PickerHSB(120, 1, 1)); + QVERIFY(!workspace.canSwitch()); + QVERIFY(settle()); + QVERIFY(!session.colorPicker()); + QVERIFY(session.foregroundColor() == foreground); + QVERIFY(workspace.canSwitch()); +} + +void QuitPendingEditsTests::quittingCancelsAnAdjustmentsEditor() +{ + ProjectWorkspace workspace; + EditorSession &session = workspace.current().session; + session.createDocument(100, 20); + session.insert(filled(Qt::white)); + session.addAdjustment(AdjustmentKind::exposure); + const QUuid id = session.adjustmentEditingID().value(); + const LayerAdjustment made = session.activeLayer().value().adjustment.value(); + QVERIFY(awaited([&](std::function done) { session.beginAdjustmentEditing(id, std::move(done)); })); + FilterSettings settings = session.filterEdit().value().settings; + settings.exposure.exposure = 2; + session.updateFilter(settings, true); + QVERIFY(!(session.activeLayer().value().adjustment.value() == made)); + QVERIFY(!workspace.canSwitch()); + QVERIFY(awaited([&](std::function done) { workspace.settlePendingEdits(std::move(done)); })); + QVERIFY(!session.adjustmentEditingID() && !session.adjustmentOriginal() && !session.filterEdit()); + QVERIFY(session.activeLayer().value().adjustment.value() == made); + QVERIFY(workspace.canSwitch()); +} + +void QuitPendingEditsTests::aWorkspaceGoneMidSettleCallsNothing() +{ + auto workspace = std::make_unique(); + const std::shared_ptr tab = workspace->tabs().front(); + pendGradient(tab->session); + bool called = false; + workspace->settlePendingEdits([&called] { called = true; }); + workspace.reset(); + // The tab lives on and its gradient lands; no answer. + QTRY_VERIFY(!tab->session.gradientEdit()); + QTest::qWait(100); + QVERIFY(!called); + QCOMPARE(tab->session.history.undoName(), QString("Gradient")); +} + +void QuitPendingEditsTests::closingTheWindowAppliesTheGradientThenAsks() +{ + ProjectWorkspace workspace; + ProjectWorkspaceView view(workspace); + view.show(); + EditorSession &session = workspace.current().session; + pendGradient(session); + session.history.markSaved(); + DialogDesk desk; + desk.replies = {"Cancel"}; + // The gradient is a step by the time it asks. + desk.note = [&] { return QStringLiteral("pending=%1 step=%2").arg(session.gradientEdit().has_value()).arg(session.history.undoName()); }; + view.close(); + QTRY_COMPARE(desk.seen.size(), 1); + QVERIFY2(desk.seen[0].startsWith("alert|2|Save changes to Untitled?") && desk.seen[0].endsWith("|pending=0 step=Gradient"), + qPrintable(desk.seen[0])); + QTRY_VERIFY(!workspace.isManaging()); + QVERIFY(view.isVisible()); +} + +void QuitPendingEditsTests::aBusyProjectHoldsTheQuit() +{ + ProjectWorkspace workspace; + EditorSession &session = workspace.current().session; + pendGradient(session); + session.setIsProjectBusy(true); + DialogDesk desk; + QTest::ignoreMessage(QtWarningMsg, "Quit refused: the project is still busy"); + QCOMPARE(answer(workspace), std::optional(false)); + // Nothing asked, nothing applied: the gradient waits. + QVERIFY(desk.seen.isEmpty()); + QVERIFY(session.gradientEdit()); + QVERIFY(!workspace.isManaging()); + session.setIsProjectBusy(false); +} + +void QuitPendingEditsTests::aQuitWhileManagingIsRefusedAtOnce() +{ + ProjectWorkspace workspace; + EditorSession &session = workspace.current().session; + session.createDocument(100, 20); + DialogDesk desk; + desk.replies = {"Cancel"}; + std::optional second; + bool settled = false; + bool answered = false; + // While the first quit asks, a second is refused unasked. + desk.note = [&] { + session.setSelectionAmountOperation(SelectionAmountOperation::feather); + workspace.confirmQuit([&](bool value) { second = value; }); + answered = QTest::qWaitFor([&] { return second.has_value(); }, 5000); + settled = !session.selectionAmountOperation(); + session.setSelectionAmountOperation(std::nullopt); + return QString(); + }; + QCOMPARE(answer(workspace), std::optional(false)); + QCOMPARE(desk.seen.size(), 1); + QVERIFY(answered); + QCOMPARE(second, std::optional(false)); + QVERIFY(!settled); +} + +QTEST_MAIN(QuitPendingEditsTests) +#include "QuitPendingEditsTests.moc" diff --git a/tests/ScanlinesTests.cpp b/tests/ScanlinesTests.cpp new file mode 100644 index 0000000..45f31ce --- /dev/null +++ b/tests/ScanlinesTests.cpp @@ -0,0 +1,377 @@ +#include "Document/BrushStroke.h" +#include "Document/Dither.h" +#include "Document/EditorSession.h" +#include "Document/PixelAdjust.h" +#include "UI/FilterSheet.h" +#include +#include +#include +#include +#include +#include +#include +#include + +// Swift 1.4.4's Dither › Scanlines (CRT), on every core. +namespace { +QImage filled(int width, int height, QColor colour) +{ + QImage image = BrushRaster::context(width, height, false); + image.fill(colour); + return image; +} + +DitherSettings scanlines(double spacing, double glow = 0) +{ + DitherSettings settings; + settings.style = DitherStyle::scanlines; + settings.lineSpacing = spacing; + settings.glow = glow; + return settings; +} + +// One channel of one column, top to bottom. +std::vector column(const QImage &image, int x, int channel = 0) +{ + std::vector values; + for (int y = 0; y < image.height(); ++y) + values.push_back(image.constScanLine(y)[x * 4 + channel]); + return values; +} + +std::vector row(const QImage &image, int y, int channel = 0) +{ + std::vector values; + for (int x = 0; x < image.width(); ++x) + values.push_back(image.constScanLine(y)[x * 4 + channel]); + return values; +} + +// Swift's test: one column of a flat gray's render. +std::vector gray(int level, double spacing, double glow = 0) +{ + return column(scanlines(spacing, glow).apply(filled(16, 32, QColor(level, level, level))), 5); +} + +std::vector repeated(const std::vector &band, int times) +{ + std::vector all; + for (int each = 0; each < times; ++each) + all.insert(all.end(), band.begin(), band.end()); + return all; +} + +struct Shown { + EditorSession session; + std::unique_ptr sheet; + Shown() + { + session.createDocument(40, 30); + const QImage image = filled(40, 30, QColor(200, 120, 40)); + session.insert(ImportedImage(image, image, QStringLiteral("Colour"))); + session.beginFilter(FilterKind::dither); + FilterSettings settings = session.filterEdit().value().settings; + settings.dither.style = DitherStyle::scanlines; + session.updateFilter(settings, true); + sheet = std::make_unique(session); + sheet->show(); + } + const DitherSettings &dither() const { return session.filterEdit().value().settings.dither; } + // The shown rows in the column's order, with their help. + QStringList rows() const + { + QStringList titles; + QLayout *column = sheet->layout(); + for (int index = 0; index < column->count(); ++index) { + QWidget *each = column->itemAt(index)->widget(); + if (!each || each->isHidden()) + continue; + if (auto *box = qobject_cast(each); box && box->text() != QLatin1String("Preview")) + titles << box->text(); + for (QLabel *label : each->findChildren(QString(), Qt::FindDirectChildrenOnly)) { + if (label->buddy()) + titles << label->text() + u'|' + each->toolTip(); + } + } + return titles; + } +}; +} + +class ScanlinesTests : public QObject { + Q_OBJECT +private slots: + void scanlinesAreLinesOfLightThatBloomWithBrightness(); + void glowLightsBetweenTheLines(); + void dotsBreakTheLinesIntoBeads(); + void wobbleMovesLinesSideways(); + void theStyleDrawsAtFullResolutionWithoutMarks(); + void normalizingClampsTheFourSettings(); + void eachLineIsTheAverageOfItsRows(); + void theScreenIsTheDarkColourOrBlack(); + void clearPixelsStayClear(); + void everyBandOfATallImageIsDrawn(); + void theGlowIsTheBlurredLightAtItsAmount(); + void theGlowNeverPassesAlpha(); + void theSheetShowsScanlinesRows(); + void theRowsWriteTheirSettings(); +}; + +void ScanlinesTests::scanlinesAreLinesOfLightThatBloomWithBrightness() +{ + const std::vector white = gray(255, 8), dim = gray(89, 8), black = gray(0, 8); + for (int band = 0; band < 4; ++band) { + const std::vector rows(white.begin() + band * 8, white.begin() + band * 8 + 8); + QVERIFY(rows[3] == 255 && rows[4] == 255); + QVERIFY(std::any_of(rows.begin(), rows.end(), [](int value) { return value < 40; })); + } + QVERIFY(std::accumulate(dim.begin(), dim.end(), 0) * 2 < std::accumulate(white.begin(), white.end(), 0)); + QVERIFY(std::all_of(black.begin(), black.end(), [](int value) { return value == 0; })); + // The beam's half height, 2.8 white, about 1.98 at 0.35. + QCOMPARE(white, repeated({0, 255, 255, 255, 255, 255, 255, 0}, 4)); + QCOMPARE(dim, repeated({0, 0, 118, 120, 120, 118, 0, 0}, 4)); +} + +void ScanlinesTests::glowLightsBetweenTheLines() +{ + const std::vector plain = gray(255, 8), glowing = gray(255, 8, 100); + QVERIFY2(glowing[0] > plain[0] + 40, qPrintable(QString::number(glowing[0]))); +} + +void ScanlinesTests::dotsBreakTheLinesIntoBeads() +{ + const QImage white = filled(64, 16, Qt::white); + DitherSettings settings = scanlines(8); + QCOMPARE(row(settings.apply(white), 4), std::vector(64, 255)); + settings.dots = 100; + // Each bead lit at its middle, dark at its ends. + const std::vector lit = row(settings.apply(white), 4); + QCOMPARE(std::vector(lit.begin(), lit.begin() + 8), (std::vector{0, 255, 255, 255, 255, 255, 255, 0})); + QCOMPARE(std::vector(lit.begin() + 8, lit.begin() + 16), (std::vector{0, 255, 255, 255, 255, 255, 255, 0})); + // Half dots: the bead ends dim off the middle. + settings.dots = 50; + QCOMPARE(row(settings.apply(white), 1)[0], 85); +} + +void ScanlinesTests::wobbleMovesLinesSideways() +{ + QImage edge = filled(64, 64, Qt::black); + QPainter(&edge).fillRect(QRect(32, 0, 32, 64), Qt::white); + const auto edges = [&edge](double wobble) { + DitherSettings settings = scanlines(8); + settings.wobble = wobble; + const QImage result = settings.apply(edge); + std::vector found; + for (int line = 0; line < 8; ++line) { + const std::vector middle = row(result, line * 8 + 4); + found.push_back(int(std::find_if(middle.begin(), middle.end(), [](int value) { return value > 128; }) - middle.begin())); + } + return found; + }; + QCOMPARE(edges(0), std::vector(8, 32)); + // Each line's shift: 12 times the two waves, rounded. + QCOMPARE(edges(12), (std::vector{35, 36, 35, 41, 44, 37, 32, 34})); +} + +void ScanlinesTests::theStyleDrawsAtFullResolutionWithoutMarks() +{ + QVERIFY(!usesPixelSize(DitherStyle::scanlines) && !usesPixelSize(DitherStyle::ascii) && usesPixelSize(DitherStyle::patterns)); + QVERIFY(!drawsMarks(DitherStyle::scanlines) && drawsMarks(DitherStyle::ascii)); + QVERIFY(!hasTones(DitherStyle::scanlines) && !isHalftone(DitherStyle::scanlines) && ditherGroup(DitherStyle::scanlines) == 3); + // A pixel size changes nothing: no chunks, no dots. + const QImage image = filled(20, 24, QColor(180, 90, 30)); + DitherSettings settings = scanlines(4); + const QImage plain = settings.apply(image); + settings.pixelSize = 5; + settings.pixelShape = DitherPixelShape::dot; + QCOMPARE(settings.apply(image), plain); + // The cell is the line spacing, not the cell size. + settings.cellSize = 32; + QCOMPARE(settings.apply(image), plain); +} + +void ScanlinesTests::normalizingClampsTheFourSettings() +{ + DitherSettings settings; + QCOMPARE(settings.lineSpacing, 4.0); + QCOMPARE(settings.glow, 35.0); + QCOMPARE(settings.dots, 0.0); + QCOMPARE(settings.wobble, 0.0); + settings.lineSpacing = 1.4; + settings.glow = 150; + settings.dots = -5; + settings.wobble = 3.5; + DitherSettings result = settings.normalized(); + QCOMPARE(result.lineSpacing, 2.0); + QCOMPARE(result.glow, 100.0); + QCOMPARE(result.dots, 0.0); + QCOMPARE(result.wobble, 3.5); + settings.lineSpacing = 40; + settings.glow = -1; + settings.dots = 120; + settings.wobble = 70; + result = settings.normalized(); + QCOMPARE(result.lineSpacing, 32.0); + QCOMPARE(result.glow, 0.0); + QCOMPARE(result.dots, 100.0); + QCOMPARE(result.wobble, 64.0); + // Rounded within; not a number takes the default. + settings.lineSpacing = 5.5; + settings.glow = std::nan(""); + settings.dots = std::nan(""); + settings.wobble = std::nan(""); + result = settings.normalized(); + QCOMPARE(result.lineSpacing, 6.0); + QCOMPARE(result.glow, 35.0); + QCOMPARE(result.dots, 0.0); + QCOMPARE(result.wobble, 0.0); + settings.lineSpacing = std::nan(""); + QCOMPARE(settings.normalized().lineSpacing, 4.0); + // Glow and dots keep their fractions. + settings.glow = 12.5; + settings.dots = 33.3; + QCOMPARE(settings.normalized().glow, 12.5); + QCOMPARE(settings.normalized().dots, 33.3); +} + +void ScanlinesTests::eachLineIsTheAverageOfItsRows() +{ + // Rows of white and black: each line lights at half. + QImage stripes = filled(8, 16, Qt::black); + for (int y = 0; y < 16; y += 2) + QPainter(&stripes).fillRect(QRect(0, y, 8, 1), Qt::white); + QCOMPARE(column(scanlines(8).apply(stripes), 3), repeated({0, 37, 172, 172, 172, 172, 37, 0}, 2)); +} + +void ScanlinesTests::theScreenIsTheDarkColourOrBlack() +{ + const QImage image = filled(8, 8, QColor(255, 128, 0)); + DitherSettings settings = scanlines(8); + settings.colors = DitherColors::twoColors; + settings.dark = AdjustmentColor(0, 0, 1); + settings.light = AdjustmentColor(1, 1, 0); + const QImage two = settings.apply(image); + // Off the line the dark colour; on it, the mix. + QCOMPARE(two.pixelColor(2, 0), QColor(0, 0, 255)); + QCOMPARE(two.pixelColor(2, 4), QColor(197, 197, 147)); + settings.colors = DitherColors::original; + const QImage original = settings.apply(image); + QCOMPARE(original.pixelColor(2, 0), QColor(0, 0, 0)); + QCOMPARE(original.pixelColor(2, 4), QColor(255, 173, 0)); +} + +void ScanlinesTests::clearPixelsStayClear() +{ + QImage image = filled(8, 16, Qt::white); + QPainter painter(&image); + painter.setCompositionMode(QPainter::CompositionMode_Source); + painter.fillRect(QRect(0, 0, 8, 8), Qt::transparent); + painter.end(); + const QImage result = scanlines(8, 100).apply(image); + QCOMPARE(column(result, 2, 3), (std::vector{0, 0, 0, 0, 0, 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255})); + QCOMPARE(column(result, 2, 0)[4], 0); +} + +void ScanlinesTests::everyBandOfATallImageIsDrawn() +{ + // 150 two-row lines: 32 bands of 5, two empty. + const QImage image = filled(3, 300, Qt::white); + QCOMPARE(column(scanlines(2).apply(image), 1), repeated({241, 241}, 150)); + // 70 lines of 8: bands of 3, the last one. + const std::vector tall = column(scanlines(8).apply(filled(3, 560, Qt::white)), 1); + QCOMPARE(tall, repeated({0, 255, 255, 255, 255, 255, 255, 0}, 70)); +} + +void ScanlinesTests::theGlowIsTheBlurredLightAtItsAmount() +{ + QImage image = filled(64, 160, Qt::black); + QPainter(&image).fillRect(QRect(0, 0, 32, 40), Qt::white); + const QImage lines = scanlines(8).apply(image); + // Swift's first glow: the lines blurred whole, sigma 27. + const QImage blurred = PixelAdjust::gaussianBlur(lines, 8 * 3 + 3, true); + for (const double glow : {20.0, 100.0}) { + const QImage result = scanlines(8, glow).apply(image); + const float amount = float(glow / 100 * 2.5); + int worst = 0; + for (int y = 0; y < 160; ++y) { + for (int x = 0; x < 64; ++x) { + const int plain = lines.constScanLine(y)[x * 4], light = blurred.constScanLine(y)[x * 4]; + const int expected = int(std::lround(std::min(255.0f, float(plain) + float(light) * amount))); + worst = std::max(worst, std::abs(result.constScanLine(y)[x * 4] - expected)); + } + } + // Three levels of the shrink's blur, times 2.5. + QVERIFY2(worst <= 8, qPrintable(QString::number(worst))); + } + // Below the light, at amount 0.5, it fades. + const std::vector fading = column(scanlines(8, 20).apply(image), 16); + QVERIFY(fading[48] > 20 && fading[48] < fading[40] && fading[120] < 3); +} + +void ScanlinesTests::theGlowNeverPassesAlpha() +{ + QImage image = BrushRaster::context(16, 32, false); + image.fill(QColor(255, 255, 255, 128)); + const QImage result = scanlines(4, 100).apply(image); + for (int y = 0; y < 32; ++y) { + for (int x = 0; x < 16; ++x) { + const uchar *at = result.constScanLine(y) + x * 4; + QCOMPARE(int(at[3]), 128); + QVERIFY(at[0] <= 128 && at[1] <= 128 && at[2] <= 128); + } + } + QCOMPARE(int(result.constScanLine(0)[0]), 128); +} + +void ScanlinesTests::theSheetShowsScanlinesRows() +{ + Shown shown; + QCOMPARE(shown.rows(), (QStringList{"Style|", "Line Spacing|How far apart the screen's lines are", + "Glow|Light blooming around the lines, like a CRT's phosphors", "Dots|Break the lines into glowing beads", + "Wobble|Make the lines waver sideways down the screen, like a CRT losing sync", + "Density|More ink (darker) or less before dithering", "Contrast|", "Colors|"})); + // Each slider's travel reaches its range's ends. + const std::tuple sliders[] = { + {"lineSpacingSlider", &DitherSettings::lineSpacing, 2, 32}, {"glowSlider", &DitherSettings::glow, 0, 100}, + {"dotsSlider", &DitherSettings::dots, 0, 100}, {"wobbleSlider", &DitherSettings::wobble, 0, 64}}; + for (const auto &[name, setting, low, high] : sliders) { + QSlider *slider = shown.sheet->findChild(QString::fromLatin1(name)); + QVERIFY2(slider, name); + slider->setValue(slider->maximum()); + QCOMPARE(shown.dither().*setting, high); + slider->setValue(slider->minimum()); + QCOMPARE(shown.dither().*setting, low); + } + // Pixel Size and Pixel Shape stay away under Scanlines. + FilterSettings settings = shown.session.filterEdit().value().settings; + settings.dither.pixelSize = 4; + shown.session.updateFilter(settings, true); + QVERIFY(!shown.rows().join(',').contains("Pixel")); + settings.dither.style = DitherStyle::bayer4; + shown.session.updateFilter(settings, true); + QVERIFY(shown.rows().contains("Pixel Shape|Draw each chunky pixel as a solid square, or as a round dot like a dot-matrix screen")); + QVERIFY(!shown.rows().join(',').contains("Glow")); +} + +void ScanlinesTests::theRowsWriteTheirSettings() +{ + Shown shown; + const std::tuple rows[] = { + {"lineSpacingField", &DitherSettings::lineSpacing, "12", 12}, {"glowField", &DitherSettings::glow, "60", 60}, + {"dotsField", &DitherSettings::dots, "25", 25}, {"wobbleField", &DitherSettings::wobble, "9", 9}}; + for (const auto &[name, setting, typed, expected] : rows) { + const DitherSettings before = shown.dither(); + QLineEdit *field = shown.sheet->findChild(QString::fromLatin1(name)); + QVERIFY2(field, name); + field->setFocus(); + field->selectAll(); + QTest::keyClicks(field, QString::fromLatin1(typed)); + QTest::keyClick(field, Qt::Key_Return); + DitherSettings wanted = before; + wanted.*setting = expected; + QVERIFY2(shown.dither() == wanted, name); + } +} + +QTEST_MAIN(ScanlinesTests) +#include "ScanlinesTests.moc" diff --git a/tests/Tests.cmake b/tests/Tests.cmake index 80012e2..522dac3 100644 --- a/tests/Tests.cmake +++ b/tests/Tests.cmake @@ -166,6 +166,8 @@ compositor_test(ProjectReceiveTests) compositor_test(ProjectReplaceTests) compositor_test(ProjectWorkspaceFlowTests) compositor_test(QuitWhileEditingTextTests) +compositor_test(QuitPendingEditsTests) +compositor_test(ScanlinesTests) compositor_test(OpenRecentTests) compositor_test(ColoredSliderTests) compositor_test(SheetTeardownTests) From a77cce459861278a477150694f9aeaf72d01394b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Thu, 1 Oct 2026 18:10:13 -0400 Subject: [PATCH 2/2] Camera Raw curves as Photoshop's: the fitted parametric curve, the tone curve on each channel, one drag in the graph (13.18) --- CMakeLists.txt | 1 + kernels/AdjustPixels.c | 26 +- kernels/AdjustPixels.h | 4 +- src/Document/CameraRawColor.cpp | 45 +-- src/Document/CameraRawColor.h | 8 +- src/UI/CameraRawColorControls+Curve.cpp | 114 +++++++ src/UI/CameraRawColorControls.cpp | 117 +++---- src/UI/CameraRawColorControls.h | 16 + tests/CameraRawColorTests.cpp | 13 +- tests/CameraRawCurveGraphTests.cpp | 400 ++++++++++++++++++++++++ tests/CameraRawCurveMixerTests.cpp | 14 +- tests/CameraRawCurveTests.cpp | 213 +++++++++++++ tests/CameraRawGroupDrawTests.cpp | 12 +- tests/CameraRawGroupRowsTests.cpp | 2 +- tests/CameraRawGroupWordsTests.cpp | 14 +- tests/CameraRawWiringTests.cpp | 4 +- tests/Tests.cmake | 2 + 17 files changed, 862 insertions(+), 143 deletions(-) create mode 100644 src/UI/CameraRawColorControls+Curve.cpp create mode 100644 tests/CameraRawCurveGraphTests.cpp create mode 100644 tests/CameraRawCurveTests.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 8983403..4ef15e3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -135,6 +135,7 @@ add_library(compositor_core STATIC src/UI/TransformInspector.cpp src/UI/EffectsSheet.cpp src/UI/CameraRawColorControls.cpp + src/UI/CameraRawColorControls+Curve.cpp src/UI/CameraRawColorControls+Grading.cpp src/UI/CameraRawColorControls+Mixer.cpp src/UI/CameraRawControls.cpp diff --git a/kernels/AdjustPixels.c b/kernels/AdjustPixels.c index 6901235..c8619e1 100644 --- a/kernels/AdjustPixels.c +++ b/kernels/AdjustPixels.c @@ -729,7 +729,7 @@ static double point_weight(double h, double s, double l, const float *point) { } void adjust_camera_raw_curve_color(uint8_t *rgba, size_t width, size_t height, size_t stride, - const float *lumaLut, const float *redLut, const float *greenLut, const float *blueLut, + const float *toneLut, const float *redLut, const float *greenLut, const float *blueLut, double refineSaturation, const float *mixer, int pointCount, const float *points, const float *grade, double blending, double balance, int visualize) { for (size_t y = 0; y < height; ++y) { @@ -739,16 +739,22 @@ void adjust_camera_raw_curve_color(uint8_t *rgba, size_t width, size_t height, s double alpha = p[3]; if (!alpha) continue; double r = fmin(1.0, p[0] / alpha), g = fmin(1.0, p[1] / alpha), b = fmin(1.0, p[2] / alpha); - double tone = rec709(r, g, b); - double mapped = lut_at(lumaLut, tone); - scale_luminance(&r, &g, &b, mapped); - if (refineSaturation != 0 && tone > 1e-4) { - double factor = 1 + refineSaturation * (mapped / tone - 1); - double lum = rec709(r, g, b); - r = camera_clamp(lum + (r - lum) * factor); - g = camera_clamp(lum + (g - lum) * factor); - b = camera_clamp(lum + (b - lum) * factor); + // The tone curve works on red, green and blue alike, as Photoshop's does, so contrast brings color strength + // with it. Refine Saturation below zero eases toward changing brightness alone (−100), and above zero adds + // more color. + double curvedR = lut_at(toneLut, r), curvedG = lut_at(toneLut, g), curvedB = lut_at(toneLut, b); + if (refineSaturation < 0) { + double br = r, bg = g, bb = b; + scale_luminance(&br, &bg, &bb, lut_at(toneLut, rec709(r, g, b))); + double k = -refineSaturation; + curvedR += (br - curvedR) * k; curvedG += (bg - curvedG) * k; curvedB += (bb - curvedB) * k; + } else if (refineSaturation > 0) { + double lum = rec709(curvedR, curvedG, curvedB), factor = 1 + refineSaturation; + curvedR = camera_clamp(lum + (curvedR - lum) * factor); + curvedG = camera_clamp(lum + (curvedG - lum) * factor); + curvedB = camera_clamp(lum + (curvedB - lum) * factor); } + r = curvedR; g = curvedG; b = curvedB; r = lut_at(redLut, r); g = lut_at(greenLut, g); b = lut_at(blueLut, b); double h, s, l; rgb_to_hsl(r, g, b, &h, &s, &l); diff --git a/kernels/AdjustPixels.h b/kernels/AdjustPixels.h index f171684..0232148 100644 --- a/kernels/AdjustPixels.h +++ b/kernels/AdjustPixels.h @@ -48,12 +48,12 @@ void adjust_camera_raw(uint8_t *rgba, size_t width, size_t height, size_t stride // the radii match a full-size render. Grain is applied separately. Alpha is kept. // Blue over clipped shadows and red over clipped highlights, on top of the grade. Preview only. void adjust_camera_raw_clip_overlay(uint8_t *rgba, size_t width, size_t height, size_t stride, int shadows, int highlights); -// Curve, Color Mixer, and Color Grading after the basic grade. `lumaLut` and the channel LUTs are 256 +// Curve, Color Mixer, and Color Grading after the basic grade. `toneLut` and the channel LUTs are 256 // entries. `mixer` is 24 floats: hue, saturation, luminance for eight families, −1…1. Each point color is // 9 floats (hue, saturation, luminance, three shifts −1…1, three range half-widths). `grade` is four wheels // of hue turns, saturation 0…1, and luminance −1…1. `visualize` darkens pixels outside that point color. void adjust_camera_raw_curve_color(uint8_t *rgba, size_t width, size_t height, size_t stride, - const float *lumaLut, const float *redLut, const float *greenLut, const float *blueLut, + const float *toneLut, const float *redLut, const float *greenLut, const float *blueLut, double refineSaturation, const float *mixer, int pointCount, const float *points, const float *grade, double blending, double balance, int visualize); void adjust_camera_raw_effects(uint8_t *rgba, size_t width, size_t height, size_t stride, diff --git a/src/Document/CameraRawColor.cpp b/src/Document/CameraRawColor.cpp index 11495bd..7d3f418 100644 --- a/src/Document/CameraRawColor.cpp +++ b/src/Document/CameraRawColor.cpp @@ -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 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 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 CameraRawCurveSettings::toneTable() const { std::vector 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 luma = shape.lumaTable(), redTable = shape.channelTable(shape.red), greenTable = shape.channelTable(shape.green), + const std::vector 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); } diff --git a/src/Document/CameraRawColor.h b/src/Document/CameraRawColor.h index b7e6036..ead27d1 100644 --- a/src/Document/CameraRawColor.h +++ b/src/Document/CameraRawColor.h @@ -33,15 +33,19 @@ struct CameraRawCurveSettings { static std::vector strongContrast(); static bool isLinear(const std::vector &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 lumaTable() const; + // Applied to red, green and blue alike. + std::vector toneTable() const; std::vector channelTable(const std::vector &points) const; CameraRawCurveSettings nudged(CameraRawPointChannel channel, double tone, double delta) const; // Swift's region(for:): the parametric amount a tone moves. double ®ion(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. diff --git a/src/UI/CameraRawColorControls+Curve.cpp b/src/UI/CameraRawColorControls+Curve.cpp new file mode 100644 index 0000000..8052c46 --- /dev/null +++ b/src/UI/CameraRawColorControls+Curve.cpp @@ -0,0 +1,114 @@ +#include "UI/CameraRawColorControls.h" +#include +#include +#include + +// Swift's curve graph drags, picked once at the press. +std::optional 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 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::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 points = currentPoints(); + // On a point, that point; the first of equals. + std::optional 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 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) + points[drag.index].x = std::min(points[drag.index + 1].x - 0.01, std::max(points[drag.index - 1].x + 0.01, 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 points = currentPoints(); + const double x = at.x() / size.width(); + // The nearest inner point by x, within 0.04; ends stay. + std::optional 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); +} diff --git a/src/UI/CameraRawColorControls.cpp b/src/UI/CameraRawColorControls.cpp index 2645c14..2f86a87 100644 --- a/src/UI/CameraRawColorControls.cpp +++ b/src/UI/CameraRawColorControls.cpp @@ -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 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 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 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 nearestInner(const std::vector &points, double x) const - { - std::optional 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 points = m_owner.currentPoints(); - const double x = at.x() / width(); - const std::optional 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 points = m_owner.currentPoints(); - const double x = at.x() / width(); - const std::optional 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 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 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 diff --git a/src/UI/CameraRawColorControls.h b/src/UI/CameraRawColorControls.h index 404c4c4..2412bf7 100644 --- a/src/UI/CameraRawColorControls.h +++ b/src/UI/CameraRawColorControls.h @@ -28,9 +28,25 @@ private: void update(const std::function &change); void store(const std::vector &points); void panel(const std::function &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 parametricDrag(QPointF at, QSizeF size) const; + std::optional 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 m_drag; + std::optional m_selectedPoint; + CameraRawPointChannel m_shownChannel = CameraRawPointChannel::rgb; QButtonGroup *m_page; QWidget *m_channelBox; QButtonGroup *m_channel; diff --git a/tests/CameraRawColorTests.cpp b/tests/CameraRawColorTests.cpp index fec4af9..b495084 100644 --- a/tests/CameraRawColorTests.cpp +++ b/tests/CameraRawColorTests.cpp @@ -29,7 +29,7 @@ class CameraRawColorTests : public QObject { Q_OBJECT private slots: void curvesAreRepairedIntoOrder(); - void theParametricCurveLiftsItsRegion(); + void aToneFindsItsRegion(); void aNudgeMovesTheFirstNearestPointWithinRange(); void splitsAndAmountsStayInTheirRanges(); void mixerFamiliesOverlapRoundTheWheel(); @@ -56,17 +56,8 @@ void CameraRawColorTests::curvesAreRepairedIntoOrder() QCOMPARE(table[100], float(100 / 255.0)); } -void CameraRawColorTests::theParametricCurveLiftsItsRegion() +void CameraRawColorTests::aToneFindsItsRegion() { - CameraRawCurveSettings curve; - curve.shadows = 100; - // The region's middle takes the whole 22%. - QVERIFY(std::abs(curve.parametric(0.125) - 0.345) < 1e-12); - QCOMPARE(curve.parametric(0.375), 0.375); - curve.highlights = -100; - curve.lightSplit = 100; - // A region of no width still takes a fiftieth's span. - QVERIFY(std::abs(curve.parametric(1) - 0.78) < 1e-12); // Region picks the amount a tone moves. CameraRawCurveSettings regions; regions.region(0.1) = 1; diff --git a/tests/CameraRawCurveGraphTests.cpp b/tests/CameraRawCurveGraphTests.cpp new file mode 100644 index 0000000..d95a673 --- /dev/null +++ b/tests/CameraRawCurveGraphTests.cpp @@ -0,0 +1,400 @@ +#include "Document/BrushStroke.h" +#include "Document/EditorSession.h" +#include "UI/CameraRawColorControls.h" +#include +#include +#include +#include + +// Swift 1.4.5: the Camera Raw curve graph's one drag. +namespace { +struct Graph { + EditorSession session; + std::unique_ptr controls; + QWidget *graph = nullptr; + explicit Graph(bool point = false) + { + session.createDocument(8, 8); + QImage image = BrushRaster::context(8, 8, false); + image.fill(QColor(200, 120, 60)); + session.insert(ImportedImage(image, image, QStringLiteral("Warm"))); + session.beginFilter(FilterKind::cameraRaw); + controls = std::make_unique(session); + controls->resize(374, 600); + controls->show(); + graph = controls->findChild(QStringLiteral("cameraRawCurveGraph")); + if (point) + controls->findChild(QStringLiteral("curvePage1"))->click(); + } + const CameraRawCurveSettings &curve() const { return session.filterEdit().value().settings.cameraRaw.curve; } + void set(const std::function &change) + { + FilterSettings settings = session.filterEdit().value().settings; + change(settings.cameraRaw.curve); + session.updateFilter(settings, true); + } + // A curve position in the graph's own points. + QPoint at(double x, double y) const { return QPoint(int(std::lround(x * graph->width())), int(std::lround((1 - y) * graph->height()))); } + void press(QPoint where, Qt::MouseButton button = Qt::LeftButton) const { QTest::mousePress(graph, button, {}, where); } + void move(QPoint where, Qt::MouseButtons buttons = Qt::LeftButton) const + { + QMouseEvent event(QEvent::MouseMove, where, graph->mapToGlobal(where), Qt::NoButton, buttons, Qt::NoModifier); + QApplication::sendEvent(graph, &event); + } + void release(QPoint where) const { QTest::mouseRelease(graph, Qt::LeftButton, {}, where); } + QLabel &readout() const { return *controls->findChild(QStringLiteral("curveSelectedPoint")); } + QColor dot(double x, double y) const { return graph->grab().toImage().pixelColor(at(x, y)); } +}; +} + +class CameraRawCurveGraphTests : public QObject { + Q_OBJECT +private slots: + void aRegionDragLiftsOrLowersThePressedRegion(); + void theRegionIsPickedOnceByItsTone(); + void dividersMoveInTheBottomStripAndStayOrdered(); + void aPointIsPickedByDistanceAndKept(); + void aPressElsewhereAddsAndDragsAPoint(); + void addingIsRefusedNearAnEdgeAPointOrSixteen(); + void theChosenPointIsLitAndReadOut(); + void aNewChannelOrPresetDropsTheChoice(); + void aDoubleClickRemovesAnInnerPointAndTheChoice(); + void onlyTheLeftButtonDragsAndAPressStartsAfresh(); + void theGraphSaysWhatADragDoes(); +}; + +void CameraRawCurveGraphTests::aRegionDragLiftsOrLowersThePressedRegion() +{ + Graph graph; + const int h = graph.graph->height(); + graph.set([](CameraRawCurveSettings &curve) { curve.lights = 10; }); + // Up lifts: a quarter height adds 50. + graph.press(QPoint(int(graph.graph->width() * 0.6), 100)); + graph.move(QPoint(int(graph.graph->width() * 0.6), 100 - h / 4)); + QCOMPARE(graph.curve().lights, std::round(10 + double(h / 4) / h * 200)); + // Down lowers, clamped at −100, rounded to whole. + graph.move(QPoint(int(graph.graph->width() * 0.6), 100 + h)); + QCOMPARE(graph.curve().lights, -100.0); + graph.move(QPoint(10, 100 + 3)); + QCOMPARE(graph.curve().lights, std::round(10 - 3.0 / h * 200)); + graph.release(QPoint(10, 103)); + QVERIFY(graph.curve().shadows == 0 && graph.curve().darks == 0 && graph.curve().highlights == 0); + // Far up, +100 at most. + graph.press(QPoint(int(graph.graph->width() * 0.9), 120)); + graph.move(QPoint(int(graph.graph->width() * 0.9), 120 - 4 * h)); + QCOMPARE(graph.curve().highlights, 100.0); +} + +void CameraRawCurveGraphTests::theRegionIsPickedOnceByItsTone() +{ + Graph graph; + const int w = graph.graph->width(); + graph.set([](CameraRawCurveSettings &curve) { curve.shadowSplit = 20; }); + // A tone on a divider joins the region above. + const std::pair regions[] = { + {0.1, &CameraRawCurveSettings::shadows}, {0.2, &CameraRawCurveSettings::darks}, {0.5, &CameraRawCurveSettings::lights}, {0.75, &CameraRawCurveSettings::highlights}}; + for (const auto &[tone, key] : regions) { + const CameraRawCurveSettings before = graph.curve(); + graph.press(QPoint(int(std::lround(tone * w)), 60)); + graph.move(QPoint(int(std::lround(tone * w)), 50)); + graph.release(QPoint(int(std::lround(tone * w)), 50)); + CameraRawCurveSettings expected = before; + expected.*key = std::round(10.0 / graph.graph->height() * 200); + QVERIFY2(graph.curve() == expected, qPrintable(QString::number(tone))); + } + // At tone 50 exactly, each divider hands it upward. + QCOMPARE(w % 2, 0); + const std::tuple, double CameraRawCurveSettings::*> onDividers[] = { + {{50, 60, 80}, &CameraRawCurveSettings::darks}, {{20, 50, 80}, &CameraRawCurveSettings::lights}, {{20, 30, 50}, &CameraRawCurveSettings::highlights}}; + for (const auto &[splits, key] : onDividers) { + graph.set([&splits](CameraRawCurveSettings &curve) { + curve = CameraRawCurveSettings(); + curve.shadowSplit = splits[0]; + curve.darkSplit = splits[1]; + curve.lightSplit = splits[2]; + }); + graph.press(QPoint(w / 2, 60)); + graph.move(QPoint(w / 2, 50)); + graph.release(QPoint(w / 2, 50)); + QVERIFY(graph.curve().*key > 0); + } + // Kept through the drag: across another region, the same one. + graph.set([](CameraRawCurveSettings &curve) { curve.shadows = curve.darks = curve.lights = curve.highlights = 0; }); + graph.press(QPoint(int(0.1 * w), 60)); + graph.move(QPoint(int(0.9 * w), 40)); + QVERIFY(graph.curve().shadows > 0 && graph.curve().highlights == 0); +} + +void CameraRawCurveGraphTests::dividersMoveInTheBottomStripAndStayOrdered() +{ + Graph graph; + const int w = graph.graph->width(), h = graph.graph->height(); + // The strip is the bottom 18 points. + graph.press(QPoint(int(0.5 * w), h - 18)); + graph.move(QPoint(int(0.6 * w), h - 30)); + graph.release(QPoint(int(0.6 * w), h - 30)); + QCOMPARE(graph.curve().darkSplit, 50.0); + QVERIFY(graph.curve().lights > 0); + graph.set([](CameraRawCurveSettings &curve) { curve.lights = 0; }); + // Each divider, the nearest to the press, between its neighbours. + const auto dragged = [&](double from, double to) { + graph.press(QPoint(int(std::lround(from * w)), h - 17)); + graph.move(QPoint(int(std::lround(to * w)), 10)); + graph.release(QPoint(int(std::lround(to * w)), 10)); + }; + dragged(0.3, 0.9); + QCOMPARE(graph.curve().shadowSplit, 48.0); + // Past 2 and 98 the settings' own limits hold. + dragged(0.47, 0.0); + QCOMPARE(graph.curve().shadowSplit, 5.0); + dragged(0.55, 0.9); + QCOMPARE(graph.curve().darkSplit, 73.0); + dragged(0.73, 0.01); + QCOMPARE(graph.curve().darkSplit, 7.0); + dragged(0.74, 0.0); + QCOMPARE(graph.curve().lightSplit, 9.0); + dragged(0.095, 1.2); + QCOMPARE(graph.curve().lightSplit, 98.0); + QVERIFY(graph.curve().shadows == 0 && graph.curve().darks == 0 && graph.curve().lights == 0 && graph.curve().highlights == 0); + // At tone 50 exactly, between 40 and 60: the first. + QCOMPARE(w % 2, 0); + graph.set([](CameraRawCurveSettings &curve) { + curve.shadowSplit = 40; + curve.darkSplit = 60; + curve.lightSplit = 80; + }); + graph.press(QPoint(w / 2, h - 5)); + graph.move(QPoint(int(0.45 * w), h - 5)); + QVERIFY(std::abs(graph.curve().shadowSplit - 45) < 0.5 && graph.curve().darkSplit == 60); +} + +void CameraRawCurveGraphTests::aPointIsPickedByDistanceAndKept() +{ + Graph graph(true); + graph.set([](CameraRawCurveSettings &curve) { curve.rgb = {{0, 0}, {0.3, 0.3}, {0.36, 0.5}, {1, 1}}; }); + // Nearer by distance, not by x: the second. + graph.press(graph.at(0.33, 0.48)); + graph.move(graph.at(0.33, 0.6)); + QCOMPARE(graph.curve().rgb.size(), size_t(4)); + QVERIFY(std::abs(graph.curve().rgb[2].y - 0.6) < 0.01 && graph.curve().rgb[1] == (CurvePoint{0.3, 0.3})); + // Kept as it passes x 0.99, held before the end. + graph.move(graph.at(1.2, 0.2)); + QVERIFY(std::abs(graph.curve().rgb[2].x - 0.99) < 1e-9 && graph.curve().rgb[1] == (CurvePoint{0.3, 0.3})); + // Onto its neighbour: a hundredth past, then repaired away. + graph.move(graph.at(0.1, 0.2)); + QCOMPARE(graph.curve().rgb.size(), size_t(3)); + QVERIFY(graph.curve().rgb[1] == (CurvePoint{0.3, 0.3})); + graph.release(graph.at(0.1, 0.2)); + // Onto the next point: a hundredth short; that one goes. + graph.set([](CameraRawCurveSettings &curve) { curve.rgb = {{0, 0}, {0.3, 0.3}, {0.5, 0.5}, {1, 1}}; }); + graph.press(graph.at(0.3, 0.3)); + graph.move(graph.at(0.8, 0.3)); + graph.release(graph.at(0.8, 0.3)); + QCOMPARE(graph.curve().rgb.size(), size_t(3)); + QVERIFY(std::abs(graph.curve().rgb[1].x - 0.49) < 1e-9); + // 0.05 away takes the point; 0.06 away adds one. + graph.set([](CameraRawCurveSettings &curve) { curve.rgb = {{0, 0}, {0.5, 0.5}, {1, 1}}; }); + graph.press(graph.at(0.53, 0.54)); + graph.release(graph.at(0.53, 0.54)); + QCOMPARE(graph.curve().rgb.size(), size_t(3)); + QVERIFY(std::abs(graph.curve().rgb[1].x - 0.53) < 0.003); + graph.set([](CameraRawCurveSettings &curve) { curve.rgb = {{0, 0}, {0.5, 0.5}, {1, 1}}; }); + graph.press(graph.at(0.54, 0.545)); + graph.release(graph.at(0.54, 0.545)); + QCOMPARE(graph.curve().rgb.size(), size_t(4)); + QVERIFY(graph.curve().rgb[1] == (CurvePoint{0.5, 0.5})); +} + +void CameraRawCurveGraphTests::aPressElsewhereAddsAndDragsAPoint() +{ + Graph graph(true); + graph.press(graph.at(0.25, 0.6)); + QCOMPARE(graph.curve().rgb.size(), size_t(3)); + QVERIFY(std::abs(graph.curve().rgb[1].x - 0.25) < 0.01 && std::abs(graph.curve().rgb[1].y - 0.6) < 0.01); + // The same press drags it on, in order. + graph.move(graph.at(0.4, 0.7)); + QVERIFY(std::abs(graph.curve().rgb[1].x - 0.4) < 0.01 && std::abs(graph.curve().rgb[1].y - 0.7) < 0.01); + graph.release(graph.at(0.4, 0.7)); + // Added below, sorted in at its x. + graph.press(graph.at(0.8, 0.95)); + graph.move(graph.at(0.7, 1.4)); + graph.release(graph.at(0.7, 1.4)); + QCOMPARE(graph.curve().rgb.size(), size_t(4)); + QVERIFY(std::abs(graph.curve().rgb[2].x - 0.7) < 0.01 && graph.curve().rgb[2].y == 1); + QVERIFY(std::abs(graph.curve().rgb[1].x - 0.4) < 0.01); + // The ends move their output alone. + graph.press(graph.at(1, 1)); + graph.move(graph.at(0.6, 0.8)); + graph.release(graph.at(0.6, 0.8)); + QVERIFY(graph.curve().rgb.back().x == 1 && std::abs(graph.curve().rgb.back().y - 0.8) < 0.01); + graph.press(graph.at(0, 0)); + graph.move(graph.at(0.3, -0.5)); + graph.release(graph.at(0.3, -0.5)); + QVERIFY(graph.curve().rgb.front() == (CurvePoint{0, 0})); +} + +void CameraRawCurveGraphTests::addingIsRefusedNearAnEdgeAPointOrSixteen() +{ + Graph graph(true); + // Too near an end's x, or a point's. + const int w = graph.graph->width(); + graph.press(QPoint(2, 20)); + graph.move(QPoint(40, 30)); + graph.release(QPoint(40, 30)); + graph.press(QPoint(w - 2, 140)); + graph.release(QPoint(w - 2, 140)); + QVERIFY(graph.curve().rgb == CameraRawCurveSettings::linear()); + graph.set([](CameraRawCurveSettings &curve) { curve.rgb = {{0, 0}, {0.5, 0.2}, {1, 1}}; }); + graph.press(graph.at(0.505, 0.9)); + graph.move(graph.at(0.6, 0.9)); + graph.release(graph.at(0.6, 0.9)); + QVERIFY(graph.curve().rgb == (std::vector{{0, 0}, {0.5, 0.2}, {1, 1}})); + QVERIFY(!graph.readout().isVisible()); + // Sixteen points take no seventeenth. + graph.set([](CameraRawCurveSettings &curve) { + curve.rgb = {{0, 0}}; + for (int index = 1; index < 15; ++index) + curve.rgb.push_back({index * 0.06, index * 0.06}); + curve.rgb.push_back({1, 1}); + }); + QCOMPARE(graph.curve().rgb.size(), size_t(16)); + graph.press(graph.at(0.93, 0.2)); + graph.release(graph.at(0.93, 0.2)); + QCOMPARE(graph.curve().rgb.size(), size_t(16)); + // Fifteen take a sixteenth. + graph.set([](CameraRawCurveSettings &curve) { curve.rgb.erase(curve.rgb.begin() + 1); }); + graph.press(graph.at(0.93, 0.2)); + graph.release(graph.at(0.93, 0.2)); + QCOMPARE(graph.curve().rgb.size(), size_t(16)); +} + +void CameraRawCurveGraphTests::theChosenPointIsLitAndReadOut() +{ + Graph graph(true); + graph.set([](CameraRawCurveSettings &curve) { curve.rgb = {{0, 0}, {0.3, 0.4}, {0.7, 0.6}, {1, 1}}; }); + const QColor accent = graph.graph->palette().color(QPalette::Highlight); + QCOMPARE(graph.dot(0.3, 0.4), QColor(Qt::white)); + QVERIFY(!graph.readout().isVisible()); + const auto shown = [&graph](size_t index) { + const CurvePoint point = graph.curve().rgb[index]; + return QStringLiteral("In %1 Out %2").arg(std::lround(point.x * 255)).arg(std::lround(point.y * 255)); + }; + // The press takes the point to the pointer. + graph.press(graph.at(0.3, 0.4)); + QVERIFY(std::abs(graph.curve().rgb[1].x - 0.3) < 0.003 && std::abs(graph.curve().rgb[1].y - 0.4) < 0.007); + QCOMPARE(graph.dot(0.3, 0.4), accent); + QCOMPARE(graph.dot(0.7, 0.6), QColor(Qt::white)); + QCOMPARE(graph.readout().text(), shown(1)); + // The readout follows the drag. + graph.move(graph.at(0.4, 0.5)); + QCOMPARE(graph.readout().text(), shown(1)); + QVERIFY(graph.readout().text().startsWith("In 102 Out 12")); + graph.release(graph.at(0.4, 0.5)); + // An end point is chosen too. + graph.press(graph.at(1, 1)); + graph.release(graph.at(1, 1)); + QCOMPARE(graph.readout().text(), QString("In 255 Out 255")); + QCOMPARE(graph.graph->grab().toImage().pixelColor(graph.graph->width() - 2, 2), accent); + // A new point is chosen as it is made. + graph.press(graph.at(0.85, 0.2)); + graph.release(graph.at(0.85, 0.2)); + QCOMPARE(graph.readout().text(), shown(3)); + QVERIFY(std::abs(graph.curve().rgb[3].x - 0.85) < 0.003); + QCOMPARE(graph.dot(0.85, 0.2), accent); + // A choice past the points changed elsewhere hides. + graph.press(graph.at(1, 1)); + graph.release(graph.at(1, 1)); + graph.set([](CameraRawCurveSettings &curve) { curve.rgb = CameraRawCurveSettings::linear(); }); + QVERIFY(!graph.readout().isVisible()); +} + +void CameraRawCurveGraphTests::aNewChannelOrPresetDropsTheChoice() +{ + Graph graph(true); + graph.set([](CameraRawCurveSettings &curve) { curve.rgb = curve.red = {{0, 0}, {0.3, 0.4}, {1, 1}}; }); + graph.press(graph.at(0.3, 0.4)); + QVERIFY(graph.readout().isVisible()); + // Another channel: no choice, and the held drag lets go. + graph.controls->findChild(QStringLiteral("pointChannel1"))->click(); + QVERIFY(!graph.readout().isVisible()); + QCOMPARE(graph.dot(0.3, 0.4), QColor(Qt::white)); + graph.move(graph.at(0.3, 0.9)); + QVERIFY(graph.curve().red == (std::vector{{0, 0}, {0.3, 0.4}, {1, 1}})); + graph.release(graph.at(0.3, 0.9)); + // A preset's points drop it too; Custom keeps it. + graph.press(graph.at(0.3, 0.4)); + graph.release(graph.at(0.3, 0.4)); + auto &preset = *graph.controls->findChild(QStringLiteral("curvePreset")); + emit preset.activated(0); + graph.set([](CameraRawCurveSettings &curve) { curve.refineSaturation = 10; }); + QVERIFY(graph.readout().isVisible()); + emit preset.activated(2); + QVERIFY(graph.curve().red == CameraRawCurveSettings::mediumContrast()); + QVERIFY(!graph.readout().isVisible()); + QCOMPARE(graph.dot(0.25, 0.18), QColor(Qt::white)); +} + +void CameraRawCurveGraphTests::aDoubleClickRemovesAnInnerPointAndTheChoice() +{ + Graph graph(true); + graph.set([](CameraRawCurveSettings &curve) { curve.rgb = {{0, 0}, {0.5, 0.5}, {1, 1}}; }); + QTest::mouseDClick(graph.graph->window()->windowHandle(), Qt::LeftButton, {}, graph.graph->mapTo(graph.graph->window(), graph.at(0.5, 0.5))); + QVERIFY(graph.curve().rgb == CameraRawCurveSettings::linear()); + QVERIFY(!graph.readout().isVisible()); + // On empty ground: one click adds, two remove. + QTest::mouseDClick(graph.graph->window()->windowHandle(), Qt::LeftButton, {}, graph.graph->mapTo(graph.graph->window(), graph.at(0.4, 0.8))); + QVERIFY(graph.curve().rgb == CameraRawCurveSettings::linear()); + // A move after it drags nothing. + graph.set([](CameraRawCurveSettings &curve) { curve.rgb = {{0, 0}, {0.3, 0.3}, {0.6, 0.6}, {1, 1}}; }); + QTest::mouseDClick(graph.graph, Qt::LeftButton, {}, graph.at(0.3, 0.3)); + graph.move(graph.at(0.5, 0.9)); + QVERIFY(graph.curve().rgb == (std::vector{{0, 0}, {0.6, 0.6}, {1, 1}})); + // An end point stays, though chosen. + QTest::mouseDClick(graph.graph, Qt::LeftButton, {}, graph.at(1, 1)); + QCOMPARE(graph.curve().rgb.size(), size_t(3)); + QCOMPARE(graph.readout().text(), QString("In 255 Out 255")); +} + +void CameraRawCurveGraphTests::onlyTheLeftButtonDragsAndAPressStartsAfresh() +{ + Graph graph(true); + graph.press(graph.at(0.5, 0.8), Qt::RightButton); + graph.move(graph.at(0.5, 0.2), Qt::RightButton); + QVERIFY(graph.curve().rgb == CameraRawCurveSettings::linear()); + graph.set([](CameraRawCurveSettings &curve) { curve.rgb = {{0, 0}, {0.5, 0.5}, {1, 1}}; }); + QTest::mouseDClick(graph.graph, Qt::RightButton, {}, graph.at(0.5, 0.5)); + QVERIFY(graph.curve().rgb == (std::vector{{0, 0}, {0.5, 0.5}, {1, 1}})); + // A move without the left button drags nothing. + graph.press(graph.at(0.5, 0.5)); + graph.move(graph.at(0.5, 0.9), Qt::RightButton); + QVERIFY(std::abs(graph.curve().rgb[1].y - 0.5) < 0.01); + graph.release(graph.at(0.5, 0.5)); + // A lost release: the next press picks its own target. + graph.set([](CameraRawCurveSettings &curve) { curve.rgb = {{0, 0}, {0.3, 0.3}, {0.7, 0.7}, {1, 1}}; }); + graph.press(graph.at(0.3, 0.3)); + graph.press(graph.at(0.7, 0.7)); + graph.move(graph.at(0.7, 0.9)); + QVERIFY(std::abs(graph.curve().rgb[1].y - 0.3) < 0.01 && std::abs(graph.curve().rgb[2].y - 0.9) < 0.01); + // A release lets go: moves then change nothing. + graph.release(graph.at(0.7, 0.9)); + const std::vector held = graph.curve().rgb; + graph.move(graph.at(0.5, 0.1)); + QVERIFY(graph.curve().rgb == held); + // Parametric too: a release ends the region. + graph.controls->findChild(QStringLiteral("curvePage0"))->click(); + graph.press(QPoint(30, 80)); + graph.release(QPoint(30, 80)); + graph.move(QPoint(30, 10)); + QCOMPARE(graph.curve().shadows, 0.0); +} + +void CameraRawCurveGraphTests::theGraphSaysWhatADragDoes() +{ + Graph graph; + QCOMPARE(graph.graph->toolTip(), QString("Drag up or down to lift or lower those tones. Drag a divider along the bottom to change which tones each region covers.")); + graph.controls->findChild(QStringLiteral("curvePage1"))->click(); + QCOMPARE(graph.graph->toolTip(), QString("Drag a point. Click to add one. Double-click a point to remove it.")); +} + +QTEST_MAIN(CameraRawCurveGraphTests) +#include "CameraRawCurveGraphTests.moc" diff --git a/tests/CameraRawCurveMixerTests.cpp b/tests/CameraRawCurveMixerTests.cpp index a631439..a34bb82 100644 --- a/tests/CameraRawCurveMixerTests.cpp +++ b/tests/CameraRawCurveMixerTests.cpp @@ -94,14 +94,20 @@ void CameraRawCurveMixerTests::theParametricPageMovesRegionsAndDividers() QTest::mouseDClick(&slider, Qt::LeftButton, {}, knob(slider)); QCOMPARE(group.raw().curve.*member, 0.0); } - // A drag moves the nearest divider, within 2 and 98. + // Along the bottom: the nearest divider, kept ordered. auto &graph = group.child(QStringLiteral("cameraRawCurveGraph")); - press(graph, QPoint(int(graph.width() * 0.3), 75)); - drag(graph, QPoint(int(graph.width() * 0.35), 75)); + const int h = graph.height(); + press(graph, QPoint(int(graph.width() * 0.3), h - 5)); + drag(graph, QPoint(int(graph.width() * 0.35), h - 5)); QVERIFY(std::abs(group.raw().curve.shadowSplit - 35) < 1 && group.raw().curve.darkSplit == 50); drag(graph, QPoint(graph.width() + 40, 75)); - QVERIFY(group.raw().curve.lightSplit == 98 || group.raw().curve.shadowSplit == 98 || group.raw().curve.darkSplit == 98); + QCOMPARE(group.raw().curve.shadowSplit, 48.0); QTest::mouseRelease(&graph, Qt::LeftButton, {}, QPoint(graph.width() + 40, 75)); + // Above it a drag lifts the region under the press. + press(graph, QPoint(int(graph.width() * 0.3), 75)); + drag(graph, QPoint(int(graph.width() * 0.9), 75 - h / 4)); + QCOMPARE(group.raw().curve.shadows, std::round(double(h / 4) / h * 200)); + QTest::mouseRelease(&graph, Qt::LeftButton, {}, QPoint(int(graph.width() * 0.9), 75 - h / 4)); // A double click here leaves the point curve's points. group.set([](CameraRawSettings &settings) { settings.curve.rgb = {{0, 0}, {0.5, 0.5}, {1, 1}}; }); QTest::mouseDClick(&graph, Qt::LeftButton, {}, QPoint(graph.width() / 2, graph.height() / 2)); diff --git a/tests/CameraRawCurveTests.cpp b/tests/CameraRawCurveTests.cpp new file mode 100644 index 0000000..6005ca8 --- /dev/null +++ b/tests/CameraRawCurveTests.cpp @@ -0,0 +1,213 @@ +#include "CameraRawFixtures.h" +#include +#include + +// Swift 1.4.5: Camera Raw's curves as Photoshop's, the model. +using namespace CameraRawFixtures; + +namespace { +// Swift's bend, written out apart from the code. +double bent(double tone, double lower, double low, double upper, double high) +{ + if (tone < lower && lower > 0) + return lower * std::pow(tone / lower, std::pow(2.0, -low / 100 * 1.66)); + if (tone > upper && upper < 1) + return 1 - (1 - upper) * std::pow((1 - tone) / (1 - upper), std::pow(2.0, high / 100 * 1.66)); + return tone; +} + +// The kernel's table lookup: linear between entries. +double looked(const std::vector &table, double value) +{ + const double at = value * 255; + const int below = int(std::floor(at)); + if (below >= 255) + return table[255]; + return table[size_t(below)] + (table[size_t(below + 1)] - table[size_t(below)]) * (at - below); +} + +double luminance(const std::array &pixel) +{ + return 0.2126 * pixel[0] + 0.7152 * pixel[1] + 0.0722 * pixel[2]; +} +} + +class CameraRawCurveTests : public QObject { + Q_OBJECT +private slots: + void parametricCurveIsSmooth(); + void parametricCurveMatchesPhotoshop(); + void curveDeepensColorLikePhotoshop(); + void theCurvePassesThroughEachBentAnchor(); + void eachRegionBendsOnlyItsRange(); + void theToneTableRunsThroughThePointCurve(); + void refineSaturationEasesOrStrengthens(); +}; + +void CameraRawCurveTests::parametricCurveIsSmooth() +{ + CameraRawCurveSettings curve; + QCOMPARE(curve.parametric(0.3), 0.3); + for (const std::array &amounts : {std::array{100.0, 0.0, 0.0, 0.0}, {0, 100, -100, 0}, {-100, 50, 100, -60}}) { + curve.shadows = amounts[0]; + curve.darks = amounts[1]; + curve.lights = amounts[2]; + curve.highlights = amounts[3]; + std::vector samples; + for (int index = 0; index <= 200; ++index) + samples.push_back(curve.parametric(index / 200.0)); + QCOMPARE(samples.front(), 0.0); + QCOMPARE(samples.back(), 1.0); + for (size_t index = 1; index < samples.size(); ++index) + QVERIFY(samples[index] >= samples[index - 1] - 1e-9); + // Finer sampling halves the slope's jumps; a corner keeps them. + const auto jump = [&curve](int steps) { + std::vector slopes; + double last = curve.parametric(0); + for (int index = 1; index <= steps; ++index) { + const double value = curve.parametric(double(index) / steps); + slopes.push_back((value - last) * steps); + last = value; + } + double worst = 0; + for (size_t index = 1; index < slopes.size(); ++index) + worst = std::max(worst, std::abs(slopes[index] - slopes[index - 1])); + return worst; + }; + QVERIFY(jump(400) < jump(200) * 0.7); + } + curve = CameraRawCurveSettings(); + curve.darks = 100; + const double before = curve.parametric(0.4); + curve.darkSplit = 70; + QVERIFY(curve.parametric(0.4) > before); +} + +void CameraRawCurveTests::parametricCurveMatchesPhotoshop() +{ + CameraRawCurveSettings curve; + curve.darks = -51; + curve.lights = 59; + const std::pair photoshop[] = {{0.093, 0.011}, {0.192, 0.089}, {0.267, 0.174}, {0.367, 0.310}, {0.491, 0.498}, + {0.616, 0.698}, {0.690, 0.804}, {0.765, 0.886}, {0.840, 0.947}, {0.915, 0.982}}; + for (const auto &[tone, expected] : photoshop) + QVERIFY2(std::abs(curve.parametric(tone) - expected) < 0.035, qPrintable(QString::number(curve.parametric(tone)))); +} + +void CameraRawCurveTests::curveDeepensColorLikePhotoshop() +{ + const QImage orange = image(2, 2, 0.85, 0.35, 0.1); + CameraRawSettings settings; + settings.curve.darks = -51; + settings.curve.lights = 59; + const std::array curved = pixels(settings.apply(orange))[0]; + QVERIFY2(curved[0] > 230 && curved[1] < 80, qPrintable(QString("%1 %2").arg(curved[0]).arg(curved[1]))); + settings.curve.refineSaturation = -100; + const std::array brightness = pixels(settings.apply(orange))[0]; + QVERIFY(double(brightness[1]) / double(brightness[0]) > 0.35); +} + +void CameraRawCurveTests::theCurvePassesThroughEachBentAnchor() +{ + CameraRawCurveSettings curve; + curve.shadows = 40; + curve.darks = -51; + curve.lights = 59; + curve.highlights = -30; + curve.shadowSplit = 20; + curve.darkSplit = 45; + curve.lightSplit = 80; + // Shadows and highlights first, then darks and lights. + for (int index = 0; index <= 32; ++index) { + const double x = index / 32.0; + const double expected = bent(bent(x, 0.2, 40, 0.8, -30), 0.45, -51, 0.45, 59); + QVERIFY2(std::abs(curve.parametric(x) - expected) < 1e-9, qPrintable(QString::number(x))); + } + // Between anchors the smooth curve, not a straight line. + const double middle = (bent(bent(1 / 32.0, 0.2, 40, 0.8, -30), 0.45, -51, 0.45, 59) + bent(bent(2 / 32.0, 0.2, 40, 0.8, -30), 0.45, -51, 0.45, 59)) / 2; + QVERIFY(std::abs(curve.parametric(1.5 / 32) - middle) > 1e-6); +} + +void CameraRawCurveTests::eachRegionBendsOnlyItsRange() +{ + // Shadows and highlights bend past their dividers alone. + CameraRawCurveSettings shadows; + shadows.shadows = 100; + QVERIFY(shadows.parametric(4 / 32.0) > 4 / 32.0 + 0.05); + QVERIFY(std::abs(shadows.parametric(0.25) - 0.25) < 1e-9); + QVERIFY(std::abs(shadows.parametric(0.5) - 0.5) < 1e-9); + CameraRawCurveSettings highlights; + highlights.highlights = -100; + QVERIFY(highlights.parametric(28 / 32.0) < 28 / 32.0 - 0.05); + QVERIFY(std::abs(highlights.parametric(0.75) - 0.75) < 1e-9); + // Darks cover all below the middle; lights all above it. + CameraRawCurveSettings darks; + darks.darks = 100; + QVERIFY(darks.parametric(4 / 32.0) > 4 / 32.0 + 0.02 && darks.parametric(12 / 32.0) > 12 / 32.0 + 0.05); + QVERIFY(std::abs(darks.parametric(0.5) - 0.5) < 1e-9 && std::abs(darks.parametric(0.75) - 0.75) < 1e-9); + CameraRawCurveSettings lights; + lights.lights = -100; + QVERIFY(lights.parametric(20 / 32.0) < 20 / 32.0 - 0.05 && lights.parametric(28 / 32.0) < 28 / 32.0 - 0.02); + QVERIFY(std::abs(lights.parametric(0.25) - 0.25) < 1e-9); + // A divider at an end bends nothing past it. + CameraRawCurveSettings ends; + ends.shadows = 100; + ends.highlights = 100; + ends.shadowSplit = 0; + ends.lightSplit = 100; + for (int index = 0; index <= 32; ++index) + QVERIFY(std::abs(ends.parametric(index / 32.0) - index / 32.0) < 1e-9); +} + +void CameraRawCurveTests::theToneTableRunsThroughThePointCurve() +{ + CameraRawCurveSettings curve; + curve.lights = 59; + curve.rgb = {{0, 0}, {0.5, 0.6}, {1, 1}}; + const std::vector table = curve.toneTable(); + QCOMPARE(table.size(), size_t(256)); + CameraRawCurveSettings point; + point.rgb = curve.rgb; + // Without amounts, the point curve alone: 0.5 to 0.6. + QVERIFY(std::abs(point.toneTable()[128] - 0.6) < 0.005); + for (const int index : {0, 64, 128, 200, 255}) { + const double expected = point.toneTable()[size_t(std::lround(curve.parametric(index / 255.0) * 255))]; + QVERIFY2(std::abs(table[size_t(index)] - expected) < 0.01, qPrintable(QString::number(index))); + } + QCOMPARE(table[255], 1.0f); +} + +void CameraRawCurveTests::refineSaturationEasesOrStrengthens() +{ + const QImage orange = image(2, 2, 0.85, 0.35, 0.1); + CameraRawSettings settings; + settings.curve.darks = -51; + settings.curve.lights = 59; + // At zero each channel runs through the tone curve alone. + const std::vector table = settings.curve.toneTable(); + const std::array source = pixels(orange)[0], curved = pixels(settings.apply(orange))[0]; + for (int channel = 0; channel < 3; ++channel) + QVERIFY2(std::abs(curved[size_t(channel)] - looked(table, source[size_t(channel)] / 255.0) * 255) <= 1, qPrintable(QString::number(channel))); + // −100: the colour scaled to the curved brightness, hue kept. + settings.curve.refineSaturation = -100; + const std::array brightness = pixels(settings.apply(orange))[0]; + const double mapped = looked(table, luminance(source) / 255) * 255; + QVERIFY(std::abs(luminance(brightness) - mapped) <= 1.5); + QVERIFY(std::abs(double(brightness[1]) / brightness[0] - double(source[1]) / source[0]) < 0.02); + // −50: halfway between the two. + settings.curve.refineSaturation = -50; + const std::array half = pixels(settings.apply(orange))[0]; + for (int channel = 0; channel < 3; ++channel) + QVERIFY(std::abs(half[size_t(channel)] - (curved[size_t(channel)] + brightness[size_t(channel)]) / 2.0) <= 1.5); + // +50: half again as far from luminance. + settings.curve.refineSaturation = 50; + const std::array stronger = pixels(settings.apply(orange))[0]; + const double lum = luminance(curved); + for (int channel = 0; channel < 3; ++channel) { + const double expected = std::clamp(lum + (curved[size_t(channel)] - lum) * 1.5, 0.0, 255.0); + QVERIFY2(std::abs(stronger[size_t(channel)] - expected) <= 1.5, qPrintable(QString("%1 %2").arg(stronger[size_t(channel)]).arg(expected))); + } +} + +QTEST_MAIN(CameraRawCurveTests) +#include "CameraRawCurveTests.moc" diff --git a/tests/CameraRawGroupDrawTests.cpp b/tests/CameraRawGroupDrawTests.cpp index d2636c6..f5afb9e 100644 --- a/tests/CameraRawGroupDrawTests.cpp +++ b/tests/CameraRawGroupDrawTests.cpp @@ -89,7 +89,7 @@ void CameraRawGroupDrawTests::theCurvePagesShowTheirOwnControls() QCOMPARE(preset.toolTip(), QString("Replaces this curve with a straight line or a contrast curve.")); QCOMPARE(channel.toolTip(), QString("RGB changes brightness. Red, green, and blue also shift the color.")); QCOMPARE(group.child(QStringLiteral("cameraRawCurveGraph")).toolTip(), - QString("Drag a point. Click the curve to add one. Double-click a point to remove it.")); + QString("Drag a point. Click to add one. Double-click a point to remove it.")); QCOMPARE(group.child(QStringLiteral("curveSelectedPoint")).toolTip(), QString("Input and output of the selected curve point.")); QCOMPARE(group.child(QStringLiteral("curveTargeted")).toolTip(), QString("Drag on the picture to move the curve for the tone under the pointer.")); @@ -126,20 +126,20 @@ void CameraRawGroupDrawTests::endPointsStayAndARealDoubleClickRemoves() group.arm([](CameraRawPanel &panel) { panel.curvePage = CameraRawCurvePage::point; }); auto &graph = group.child(QStringLiteral("cameraRawCurveGraph")); const int w = graph.width(), h = graph.height(); - // With no inner point a drag moves nothing. + // An end point keeps its x; its output follows. QTest::mousePress(&graph, Qt::LeftButton, {}, QPoint(2, h - 2)); QMouseEvent move(QEvent::MouseMove, QPointF(40, 40), graph.mapToGlobal(QPointF(40, 40)), Qt::NoButton, Qt::LeftButton, Qt::NoModifier); QApplication::sendEvent(&graph, &move); QTest::mouseRelease(&graph, Qt::LeftButton, {}, QPoint(40, 40)); - QVERIFY(group.raw().curve.rgb == CameraRawCurveSettings::linear()); - // An inner point is held within 0.02 and 0.98. + QCOMPARE(group.raw().curve.rgb.size(), size_t(2)); + QVERIFY(group.raw().curve.rgb[0].x == 0 && std::abs(group.raw().curve.rgb[0].y - (1 - 40.0 / h)) < 1e-9 && group.raw().curve.rgb[1] == (CurvePoint{1, 1})); + // Past the start: a hundredth in, then repaired away. group.set([](CameraRawSettings &settings) { settings.curve.rgb = {{0, 0}, {0.5, 0.5}, {1, 1}}; }); QTest::mousePress(&graph, Qt::LeftButton, {}, QPoint(w / 2, h / 2)); QMouseEvent far(QEvent::MouseMove, QPointF(-50, h / 2), graph.mapToGlobal(QPointF(-50, h / 2)), Qt::NoButton, Qt::LeftButton, Qt::NoModifier); QApplication::sendEvent(&graph, &far); QTest::mouseRelease(&graph, Qt::LeftButton, {}, QPoint(-50, h / 2)); - QCOMPARE(group.raw().curve.rgb.size(), size_t(3)); - QVERIFY(std::abs(group.raw().curve.rgb[1].x - 0.02) < 1e-9 && group.raw().curve.rgb.front().x == 0 && group.raw().curve.rgb.back().x == 1); + QVERIFY(group.raw().curve.rgb == CameraRawCurveSettings::linear()); // A real double click: press, release, double click, release. group.set([](CameraRawSettings &settings) { settings.curve.rgb = {{0, 0}, {0.5, 0.5}, {1, 1}}; }); doubleClick(graph, QPoint(w / 2, h / 2)); diff --git a/tests/CameraRawGroupRowsTests.cpp b/tests/CameraRawGroupRowsTests.cpp index 124dff0..877b4be 100644 --- a/tests/CameraRawGroupRowsTests.cpp +++ b/tests/CameraRawGroupRowsTests.cpp @@ -83,7 +83,7 @@ void CameraRawGroupRowsTests::curveRows() checkRow(*group.controls, group.session, row); group.arm([](CameraRawPanel &panel) { panel.curvePage = CameraRawCurvePage::point; }); checkRow(*group.controls, group.session, - {"refineSaturation", "Refine Saturation", "How much the RGB curve also changes color strength. Zero keeps it to brightness.", -100, 100, 0, false, + {"refineSaturation", "Refine Saturation", "How much the curve also changes color strength. Zero matches Photoshop; lower keeps it to brightness, higher adds more color.", -100, 100, 0, false, 48, 88, true, false, curve(&CameraRawCurveSettings::refineSaturation)}); } diff --git a/tests/CameraRawGroupWordsTests.cpp b/tests/CameraRawGroupWordsTests.cpp index a0e89f4..b0a10d9 100644 --- a/tests/CameraRawGroupWordsTests.cpp +++ b/tests/CameraRawGroupWordsTests.cpp @@ -88,7 +88,7 @@ void CameraRawGroupWordsTests::everyViewCarriesSwiftsWords() curve.show(); holds(*curve.controls, {"Parametric", "Point", "RGB", "Red", "Green", "Blue", "Parametric lifts tonal regions. Point places anchors on the curve.", "RGB changes brightness. Red, green, and blue also shift the color.", - "Drag a divider to change which tones the neighboring sliders affect.", "Targeted Adjustment", "Preset"}); + "Drag up or down to lift or lower those tones. Drag a divider along the bottom to change which tones each region covers.", "Targeted Adjustment", "Preset"}); Group mixer; mixer.set([](CameraRawSettings &settings) { settings.mixer.points = {CameraRawPointColor{.hue = 30, .saturation = 1, .luminance = 0.5}}; }); mixer.show(); @@ -228,7 +228,7 @@ void CameraRawGroupWordsTests::theCurveShowsItsLineItsSelectedPointAndRepaints() Group curve; curve.show(); auto &graph = curve.child(QStringLiteral("cameraRawCurveGraph")); - // Lights at 100 lift their region's middle by 0.22. + // The drawn curve is the model's parametric curve. curve.set([](CameraRawSettings &settings) { settings.curve.lights = 100; }); QImage drawn = graph.grab().toImage(); const int w = drawn.width(), h = drawn.height(); @@ -237,7 +237,7 @@ void CameraRawGroupWordsTests::theCurveShowsItsLineItsSelectedPointAndRepaints() for (int y = 0; y < h; ++y) if (qGray(drawn.pixel(int(0.625 * w), y)) > qGray(drawn.pixel(int(0.625 * w), brightest))) brightest = y; - QVERIFY2(std::abs(brightest - (1 - 0.845) * h) < 3, qPrintable(QString::number(brightest))); + QVERIFY2(std::abs(brightest - (1 - curve.raw().curve.parametric(0.625)) * h) < 3, qPrintable(QString::number(brightest))); // A write repaints the graph without being asked. struct Paints : QObject { int count = 0; @@ -253,10 +253,14 @@ void CameraRawGroupWordsTests::theCurveShowsItsLineItsSelectedPointAndRepaints() paints.count = 0; curve.set([](CameraRawSettings &settings) { settings.curve.darkSplit = 60; }); QTRY_VERIFY(paints.count > 0); - // The selected point is the last inner one. + // No point is chosen until one is pressed. curve.arm([](CameraRawPanel &panel) { panel.curvePage = CameraRawCurvePage::point; }); curve.set([](CameraRawSettings &settings) { settings.curve.rgb = {{0, 0}, {0.2, 0.3}, {0.6, 0.7}, {1, 1}}; }); - QCOMPARE(curve.child(QStringLiteral("curveSelectedPoint")).text(), QString("In 153 Out 179")); + auto &readout = curve.child(QStringLiteral("curveSelectedPoint")); + QVERIFY(!readout.isVisible()); + QTest::mouseClick(&graph, Qt::LeftButton, {}, QPoint(int(0.6 * graph.width()), int(0.3 * graph.height()))); + QVERIFY(readout.isVisible()); + QCOMPARE(readout.text(), QString("In 153 Out 179")); } void CameraRawGroupWordsTests::widgetsFollowTheModelAndGuardsHold() diff --git a/tests/CameraRawWiringTests.cpp b/tests/CameraRawWiringTests.cpp index 9c934e0..7b20ffa 100644 --- a/tests/CameraRawWiringTests.cpp +++ b/tests/CameraRawWiringTests.cpp @@ -238,7 +238,7 @@ void CameraRawWiringTests::curveMixerAndGradingReachTheKernelInOrder() settings.mixer.points = {{200, 0.6, 0.4, 25, -35, 15, 40, 0.5, 0.3, false}, {40, 0.8, 0.6, -10, 20, -45, 60, 0.3, 0.6, false}}; settings.grading = {{210, 40, -20}, {100, 25, 10}, {30, 55, 15}, {300, 10, -5}, 70, -30}; const CameraRawCurveSettings shape = curve.normalized(); - const std::vector luma = shape.lumaTable(), red = shape.channelTable(shape.red), green = shape.channelTable(shape.green), + const std::vector tone = shape.toneTable(), red = shape.channelTable(shape.red), green = shape.channelTable(shape.green), blue = shape.channelTable(shape.blue); std::vector mixer; for (size_t index = 0; index < 8; ++index) @@ -253,7 +253,7 @@ void CameraRawWiringTests::curveMixerAndGradingReachTheKernelInOrder() float(30 / 360.0), 0.55f, 0.15f, float(300 / 360.0), 0.1f, -0.05f}; for (const int visualize : {-1, 1}) { const QImage direct = kernel(picture, [&](uchar *bytes, size_t w, size_t h, size_t stride) { - adjust_camera_raw_curve_color(bytes, w, h, stride, luma.data(), red.data(), green.data(), blue.data(), 0.4, mixer.data(), 2, points.data(), + adjust_camera_raw_curve_color(bytes, w, h, stride, tone.data(), red.data(), green.data(), blue.data(), 0.4, mixer.data(), 2, points.data(), grade.data(), 0.7, -0.3, visualize); }); QVERIFY(pixels(settings.apply(picture, std::nullopt, 1, 0, visualize)) == pixels(direct)); diff --git a/tests/Tests.cmake b/tests/Tests.cmake index 522dac3..6d28c75 100644 --- a/tests/Tests.cmake +++ b/tests/Tests.cmake @@ -168,6 +168,8 @@ compositor_test(ProjectWorkspaceFlowTests) compositor_test(QuitWhileEditingTextTests) compositor_test(QuitPendingEditsTests) compositor_test(ScanlinesTests) +compositor_test(CameraRawCurveTests) +compositor_test(CameraRawCurveGraphTests) compositor_test(OpenRecentTests) compositor_test(ColoredSliderTests) compositor_test(SheetTeardownTests)