Black & White, Color Balance and Invert adjustments (12.7b)
This commit is contained in:
1 parent
7d47cc367a
commit
4291f4249a
33 files changed
+819
-52
No files matched your search
@@ -75,15 +75,21 @@ void EditorSession::openAdjustmentEditor(QUuid id, const LayerAdjustment &origin
|
||||
m_hueSaturation.emplace(layer.id, made.asset.value(), std::nullopt, layer.transform);
|
||||
m_hueSaturation->settings = original.resolvedHSV();
|
||||
break;
|
||||
case AdjustmentKind::invert:
|
||||
throw std::logic_error("Invert has no editor to open");
|
||||
case AdjustmentKind::curves:
|
||||
case AdjustmentKind::exposure:
|
||||
case AdjustmentKind::gradientMap:
|
||||
case AdjustmentKind::grain: {
|
||||
case AdjustmentKind::grain:
|
||||
case AdjustmentKind::blackWhite:
|
||||
case AdjustmentKind::colorBalance: {
|
||||
FilterSettings settings;
|
||||
settings.curves = original.curves;
|
||||
settings.exposure = original.exposure();
|
||||
settings.gradientMap = original.gradientMap();
|
||||
settings.grain = original.grain();
|
||||
settings.blackWhite = original.blackWhite();
|
||||
settings.colorBalance = original.colorBalance();
|
||||
m_filterEdit.emplace(filterKind(original.kind).value(), layer, std::nullopt, settings, std::nullopt);
|
||||
break;
|
||||
}
|
||||
@@ -113,6 +119,9 @@ LayerAdjustment EditorSession::editedAdjustment() const
|
||||
case AdjustmentKind::exposure: value.setExposure(m_filterEdit.value().settings.exposure); break;
|
||||
case AdjustmentKind::gradientMap: value.setGradientMap(m_filterEdit.value().settings.gradientMap); break;
|
||||
case AdjustmentKind::grain: value.setGrain(m_filterEdit.value().settings.grain); break;
|
||||
case AdjustmentKind::blackWhite: value.setBlackWhite(m_filterEdit.value().settings.blackWhite); break;
|
||||
case AdjustmentKind::colorBalance: value.setColorBalance(m_filterEdit.value().settings.colorBalance); break;
|
||||
case AdjustmentKind::invert: throw std::logic_error("Invert has no editor to read");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -121,6 +121,9 @@ void EditorSession::renderFilterPreview()
|
||||
return Filtered{std::nullopt, QString::fromUtf8(error.what()), job.settings};
|
||||
} catch (const ExportError &error) {
|
||||
return Filtered{std::nullopt, QString::fromUtf8(error.what()), job.settings};
|
||||
} catch (const ProjectError &error) {
|
||||
// Settings past their bounds: Black & White, Color Balance.
|
||||
return Filtered{std::nullopt, QString::fromUtf8(error.what()), job.settings};
|
||||
}
|
||||
}));
|
||||
}
|
||||
@@ -228,6 +231,8 @@ void EditorSession::commitFilter(std::function<void()> done)
|
||||
return FilterMade{std::nullopt, std::nullopt, QString::fromUtf8(error.what())};
|
||||
} catch (const ExportError &error) {
|
||||
return FilterMade{std::nullopt, std::nullopt, QString::fromUtf8(error.what())};
|
||||
} catch (const ProjectError &error) {
|
||||
return FilterMade{std::nullopt, std::nullopt, QString::fromUtf8(error.what())};
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -30,6 +30,8 @@ QString rawValue(FilterKind kind)
|
||||
case FilterKind::exposure: return QStringLiteral("Exposure");
|
||||
case FilterKind::gradientMap: return QStringLiteral("Gradient Map");
|
||||
case FilterKind::grain: return QStringLiteral("Grain");
|
||||
case FilterKind::blackWhite: return QStringLiteral("Black & White");
|
||||
case FilterKind::colorBalance: return QStringLiteral("Color Balance");
|
||||
}
|
||||
throw std::logic_error("unknown filter kind");
|
||||
}
|
||||
@@ -46,7 +48,8 @@ QString rawValue(BackgroundQuality quality)
|
||||
|
||||
bool isImageAdjustment(FilterKind kind)
|
||||
{
|
||||
return kind == FilterKind::curves || kind == FilterKind::exposure || kind == FilterKind::gradientMap || kind == FilterKind::grain;
|
||||
return kind == FilterKind::curves || kind == FilterKind::exposure || kind == FilterKind::gradientMap || kind == FilterKind::grain
|
||||
|| kind == FilterKind::blackWhite || kind == FilterKind::colorBalance;
|
||||
}
|
||||
|
||||
FilterSettings FilterSettings::normalized() const
|
||||
@@ -113,6 +116,8 @@ QImage PixelFilter::run(const FilterJob &job)
|
||||
case FilterKind::curves: image = settings.curves.apply(job.image); break;
|
||||
case FilterKind::exposure: image = settings.exposure.apply(job.image); break;
|
||||
case FilterKind::gradientMap: image = settings.gradientMap.apply(job.image); break;
|
||||
case FilterKind::blackWhite: image = settings.blackWhite.apply(job.image); break;
|
||||
case FilterKind::colorBalance: image = settings.colorBalance.apply(job.image); break;
|
||||
// Grain sits in layer pixels; the job's seed patterns it.
|
||||
case FilterKind::grain: image = settings.grain.apply(job.image, QPointF(), 1 / job.scale, job.seed); break;
|
||||
case FilterKind::removeBackground: image = SubjectRemoval::run(job.image, settings); break;
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
struct ImageLayer;
|
||||
|
||||
// The Filter and Image menus' filters.
|
||||
enum class FilterKind { gaussianBlur, motionBlur, addNoise, lensCorrection, removeBackground, contentAwareFill, curves, exposure, gradientMap, grain };
|
||||
enum class FilterKind { gaussianBlur, motionBlur, addNoise, lensCorrection, removeBackground, contentAwareFill, curves, exposure, gradientMap, grain, blackWhite, colorBalance };
|
||||
// The menu's title, Swift's rawValue.
|
||||
QString rawValue(FilterKind kind);
|
||||
// Made without settings: its preview is its result.
|
||||
@@ -25,7 +25,7 @@ bool isImageAdjustment(FilterKind kind);
|
||||
inline constexpr std::array allFilterKinds{FilterKind::gaussianBlur, FilterKind::motionBlur, FilterKind::addNoise,
|
||||
FilterKind::lensCorrection, FilterKind::removeBackground, FilterKind::contentAwareFill,
|
||||
FilterKind::curves, FilterKind::exposure, FilterKind::gradientMap,
|
||||
FilterKind::grain};
|
||||
FilterKind::grain, FilterKind::blackWhite, FilterKind::colorBalance};
|
||||
|
||||
// Remove Background's mask: the model's alone, or refined.
|
||||
enum class BackgroundQuality { basic, advanced };
|
||||
@@ -50,6 +50,8 @@ struct FilterSettings {
|
||||
ExposureSettings exposure{};
|
||||
GradientMapSettings gradientMap{};
|
||||
GrainSettings grain{};
|
||||
BlackWhiteSettings blackWhite{};
|
||||
ColorBalanceSettings colorBalance{};
|
||||
BackgroundQuality backgroundQuality = BackgroundQuality::basic;
|
||||
// How far, in pixels, the mask is pulled onto edges.
|
||||
double refineEdges = 12;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
#include "Document/BrushStroke.h"
|
||||
#include "IO/ProjectStore.h"
|
||||
#include <QPainter>
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
extern "C" {
|
||||
@@ -131,3 +132,57 @@ QImage GrainSettings::apply(const QImage &image, QPointF origin, double unitsPer
|
||||
adjust_grain(pixels, size_t(width), size_t(height), size_t(stride), amount, size, roughness, pattern, origin.x(), origin.y(), unitsPerPixel);
|
||||
});
|
||||
}
|
||||
|
||||
bool BlackWhiteSettings::isValid() const
|
||||
{
|
||||
const std::array weights{reds, yellows, greens, cyans, blues, magentas};
|
||||
return std::all_of(weights.begin(), weights.end(), [](double weight) { return weight >= low && weight <= high; })
|
||||
&& tintHue >= 0 && tintHue <= 360 && tintSaturation >= 0 && tintSaturation <= 100;
|
||||
}
|
||||
|
||||
QImage BlackWhiteSettings::apply(const QImage &image) const
|
||||
{
|
||||
if (!isValid())
|
||||
throw ProjectError(ProjectError::Kind::invalid);
|
||||
// The kernel's order: red, yellow, green, cyan, blue, magenta.
|
||||
const std::array<float, 6> weights{float(reds / 100), float(yellows / 100), float(greens / 100),
|
||||
float(cyans / 100), float(blues / 100), float(magentas / 100)};
|
||||
return ImageAdjustmentPixels::run(image, [&](uchar *pixels, int width, int height, qsizetype stride) {
|
||||
adjust_black_white(pixels, size_t(width), size_t(height), size_t(stride), weights.data(), tint ? 1 : 0, tintHue, tintSaturation / 100);
|
||||
});
|
||||
}
|
||||
|
||||
namespace {
|
||||
std::array<double, 9> amounts(const ColorBalanceSettings &settings)
|
||||
{
|
||||
return {settings.shadowCyanRed, settings.shadowMagentaGreen, settings.shadowYellowBlue, settings.midCyanRed, settings.midMagentaGreen,
|
||||
settings.midYellowBlue, settings.highlightCyanRed, settings.highlightMagentaGreen, settings.highlightYellowBlue};
|
||||
}
|
||||
}
|
||||
|
||||
bool ColorBalanceSettings::isValid() const
|
||||
{
|
||||
const std::array<double, 9> all = amounts(*this);
|
||||
return std::all_of(all.begin(), all.end(), [](double amount) { return amount >= low && amount <= high; });
|
||||
}
|
||||
|
||||
bool ColorBalanceSettings::isIdentity() const
|
||||
{
|
||||
const std::array<double, 9> all = amounts(*this);
|
||||
return std::all_of(all.begin(), all.end(), [](double amount) { return amount == 0; });
|
||||
}
|
||||
|
||||
QImage ColorBalanceSettings::apply(const QImage &image) const
|
||||
{
|
||||
if (!isValid())
|
||||
throw ProjectError(ProjectError::Kind::invalid);
|
||||
if (isIdentity())
|
||||
return image;
|
||||
std::array<float, 9> ranges;
|
||||
const std::array<double, 9> all = amounts(*this);
|
||||
std::transform(all.begin(), all.end(), ranges.begin(), [](double amount) { return float(amount / 100); });
|
||||
return ImageAdjustmentPixels::run(image, [&](uchar *pixels, int width, int height, qsizetype stride) {
|
||||
adjust_color_balance(pixels, size_t(width), size_t(height), size_t(stride), ranges.data(), ranges.data() + 3, ranges.data() + 6,
|
||||
preserveLuminosity ? 1 : 0);
|
||||
});
|
||||
}
|
||||
@@ -74,3 +74,42 @@ struct GrainSettings {
|
||||
QImage apply(const QImage &image, QPointF origin = QPointF(), double unitsPerPixel = 1, std::optional<quint32> seed = std::nullopt) const;
|
||||
friend bool operator==(const GrainSettings &, const GrainSettings &) = default;
|
||||
};
|
||||
|
||||
// Photoshop's Black & White: how bright each colour family turns.
|
||||
struct BlackWhiteSettings {
|
||||
static constexpr double low = -200, high = 300;
|
||||
// Photoshop's defaults, a percent each.
|
||||
double reds = 40;
|
||||
double yellows = 60;
|
||||
double greens = 40;
|
||||
double cyans = 60;
|
||||
double blues = 20;
|
||||
double magentas = 80;
|
||||
// Colours the result, keeping its tones: sepia, cyanotype.
|
||||
bool tint = false;
|
||||
double tintHue = 40;
|
||||
double tintSaturation = 20;
|
||||
bool isValid() const;
|
||||
QImage apply(const QImage &image) const;
|
||||
friend bool operator==(const BlackWhiteSettings &, const BlackWhiteSettings &) = default;
|
||||
};
|
||||
|
||||
// Color Balance: each pair shifted in shadows, midtones and highlights.
|
||||
struct ColorBalanceSettings {
|
||||
static constexpr double low = -100, high = 100;
|
||||
double shadowCyanRed = 0;
|
||||
double shadowMagentaGreen = 0;
|
||||
double shadowYellowBlue = 0;
|
||||
double midCyanRed = 0;
|
||||
double midMagentaGreen = 0;
|
||||
double midYellowBlue = 0;
|
||||
double highlightCyanRed = 0;
|
||||
double highlightMagentaGreen = 0;
|
||||
double highlightYellowBlue = 0;
|
||||
// Puts each pixel's brightness back, so only colour moves.
|
||||
bool preserveLuminosity = true;
|
||||
bool isValid() const;
|
||||
bool isIdentity() const;
|
||||
QImage apply(const QImage &image) const;
|
||||
friend bool operator==(const ColorBalanceSettings &, const ColorBalanceSettings &) = default;
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
#include "Document/LayerAdjustment.h"
|
||||
#include "Document/EditorSession.h"
|
||||
#include "Document/PixelInvert.h"
|
||||
#include <QRandomGenerator>
|
||||
#include <cmath>
|
||||
|
||||
@@ -12,6 +13,9 @@ QString rawValue(AdjustmentKind kind)
|
||||
case AdjustmentKind::exposure: return QStringLiteral("Exposure");
|
||||
case AdjustmentKind::gradientMap: return QStringLiteral("Gradient Map");
|
||||
case AdjustmentKind::grain: return QStringLiteral("Grain");
|
||||
case AdjustmentKind::invert: return QStringLiteral("Invert");
|
||||
case AdjustmentKind::blackWhite: return QStringLiteral("Black & White");
|
||||
case AdjustmentKind::colorBalance: return QStringLiteral("Color Balance");
|
||||
}
|
||||
throw std::logic_error("unknown adjustment kind");
|
||||
}
|
||||
@@ -32,12 +36,21 @@ std::optional<FilterKind> filterKind(AdjustmentKind kind)
|
||||
case AdjustmentKind::exposure: return FilterKind::exposure;
|
||||
case AdjustmentKind::gradientMap: return FilterKind::gradientMap;
|
||||
case AdjustmentKind::grain: return FilterKind::grain;
|
||||
case AdjustmentKind::blackWhite: return FilterKind::blackWhite;
|
||||
case AdjustmentKind::colorBalance: return FilterKind::colorBalance;
|
||||
// Panels of their own; Invert has nothing to set.
|
||||
case AdjustmentKind::hsv:
|
||||
case AdjustmentKind::levels: return std::nullopt;
|
||||
case AdjustmentKind::levels:
|
||||
case AdjustmentKind::invert: return std::nullopt;
|
||||
}
|
||||
throw std::logic_error("unknown adjustment kind");
|
||||
}
|
||||
|
||||
bool isEditable(AdjustmentKind kind)
|
||||
{
|
||||
return kind != AdjustmentKind::invert;
|
||||
}
|
||||
|
||||
HueSaturationSettings LayerAdjustment::resolvedHSV() const
|
||||
{
|
||||
return hsvSettings.value_or(HueSaturationSettings(hue, saturation, lightness, colorize));
|
||||
@@ -73,6 +86,26 @@ void LayerAdjustment::setGrain(const GrainSettings &value)
|
||||
grainSettings = value;
|
||||
}
|
||||
|
||||
BlackWhiteSettings LayerAdjustment::blackWhite() const
|
||||
{
|
||||
return blackWhiteSettings.value_or(BlackWhiteSettings());
|
||||
}
|
||||
|
||||
void LayerAdjustment::setBlackWhite(const BlackWhiteSettings &value)
|
||||
{
|
||||
blackWhiteSettings = value;
|
||||
}
|
||||
|
||||
ColorBalanceSettings LayerAdjustment::colorBalance() const
|
||||
{
|
||||
return colorBalanceSettings.value_or(ColorBalanceSettings());
|
||||
}
|
||||
|
||||
void LayerAdjustment::setColorBalance(const ColorBalanceSettings &value)
|
||||
{
|
||||
colorBalanceSettings = value;
|
||||
}
|
||||
|
||||
// Bounds refuse what is no number: no isFinite terms.
|
||||
bool LayerAdjustment::isValid() const
|
||||
{
|
||||
@@ -96,7 +129,8 @@ bool LayerAdjustment::isValid() const
|
||||
if (!(range == range.normalized()))
|
||||
return false;
|
||||
}
|
||||
return curves.isValid() && exposure().isValid() && gradientMap().isValid() && grain().isValid();
|
||||
return curves.isValid() && exposure().isValid() && gradientMap().isValid() && grain().isValid() && blackWhite().isValid()
|
||||
&& colorBalance().isValid();
|
||||
}
|
||||
|
||||
QImage LayerAdjustment::apply(const QImage &image, std::optional<QRectF> region) const
|
||||
@@ -108,6 +142,9 @@ QImage LayerAdjustment::apply(const QImage &image, std::optional<QRectF> region)
|
||||
case AdjustmentKind::curves: return curves.apply(image);
|
||||
case AdjustmentKind::exposure: return exposure().apply(image);
|
||||
case AdjustmentKind::gradientMap: return gradientMap().apply(image);
|
||||
case AdjustmentKind::blackWhite: return blackWhite().apply(image);
|
||||
case AdjustmentKind::colorBalance: return colorBalance().apply(image);
|
||||
case AdjustmentKind::invert: return PixelInvert::run(PixelInvert::Job{image, false, QTransform(), std::nullopt});
|
||||
case AdjustmentKind::grain: {
|
||||
// Grain sits in the document, however the image is cut.
|
||||
const QRectF area = region.value_or(QRectF(0, 0, image.width(), image.height()));
|
||||
@@ -143,7 +180,9 @@ void EditorSession::addAdjustment(AdjustmentKind kind)
|
||||
m_collapsedGroupIDs.remove(*layer.parentID);
|
||||
setActiveLayerID(layer.id);
|
||||
endEdit();
|
||||
setAdjustmentEditingID(layer.id);
|
||||
// Invert has nothing to set: it applies, no editor opens.
|
||||
if (isEditable(kind))
|
||||
setAdjustmentEditingID(layer.id);
|
||||
}
|
||||
|
||||
void EditorSession::updateAdjustment(QUuid id, const LayerAdjustment &value)
|
||||
|
||||
@@ -10,15 +10,18 @@
|
||||
#include <optional>
|
||||
|
||||
// An adjustment layer's kind, in the Layer menu's order.
|
||||
enum class AdjustmentKind { hsv, levels, curves, exposure, gradientMap, grain };
|
||||
enum class AdjustmentKind { hsv, levels, curves, exposure, gradientMap, grain, invert, blackWhite, colorBalance };
|
||||
inline constexpr std::array allAdjustmentKinds{AdjustmentKind::hsv, AdjustmentKind::levels, AdjustmentKind::curves,
|
||||
AdjustmentKind::exposure, AdjustmentKind::gradientMap, AdjustmentKind::grain};
|
||||
AdjustmentKind::exposure, AdjustmentKind::gradientMap, AdjustmentKind::grain,
|
||||
AdjustmentKind::invert, AdjustmentKind::blackWhite, AdjustmentKind::colorBalance};
|
||||
// Swift's rawValue: the new layer's name and the manifest's word.
|
||||
QString rawValue(AdjustmentKind kind);
|
||||
// The kind a manifest names, or none.
|
||||
std::optional<AdjustmentKind> adjustmentKind(const QString &text);
|
||||
// The filter panel that edits it; Levels and Hue/Saturation: none.
|
||||
// The filter panel that edits it; Levels, Hue/Saturation, Invert: none.
|
||||
std::optional<FilterKind> filterKind(AdjustmentKind kind);
|
||||
// Every kind but Invert opens an editor.
|
||||
bool isEditable(AdjustmentKind kind);
|
||||
|
||||
// An adjustment layer's settings; its kind reads only its own.
|
||||
struct LayerAdjustment {
|
||||
@@ -35,6 +38,8 @@ struct LayerAdjustment {
|
||||
std::optional<ExposureSettings> exposureSettings = std::nullopt;
|
||||
std::optional<GradientMapSettings> gradientMapSettings = std::nullopt;
|
||||
std::optional<GrainSettings> grainSettings = std::nullopt;
|
||||
std::optional<BlackWhiteSettings> blackWhiteSettings = std::nullopt;
|
||||
std::optional<ColorBalanceSettings> colorBalanceSettings = std::nullopt;
|
||||
HueSaturationSettings resolvedHSV() const;
|
||||
// Swift's computed properties over the optional settings.
|
||||
ExposureSettings exposure() const;
|
||||
@@ -43,6 +48,10 @@ struct LayerAdjustment {
|
||||
void setGradientMap(const GradientMapSettings &value);
|
||||
GrainSettings grain() const;
|
||||
void setGrain(const GrainSettings &value);
|
||||
BlackWhiteSettings blackWhite() const;
|
||||
void setBlackWhite(const BlackWhiteSettings &value);
|
||||
ColorBalanceSettings colorBalance() const;
|
||||
void setColorBalance(const ColorBalanceSettings &value);
|
||||
bool isValid() const;
|
||||
// `region`: the document `image` covers, whole pixels by default.
|
||||
QImage apply(const QImage &image, std::optional<QRectF> region = std::nullopt) const;
|
||||
|
||||
@@ -129,6 +129,25 @@ GrainSettings grain(const QJsonValue &value)
|
||||
.seed = quint32(seed)};
|
||||
}
|
||||
|
||||
BlackWhiteSettings blackWhite(const QJsonValue &value)
|
||||
{
|
||||
const QJsonObject object = value.toObject();
|
||||
return {.reds = number(object.value("reds")), .yellows = number(object.value("yellows")), .greens = number(object.value("greens")),
|
||||
.cyans = number(object.value("cyans")), .blues = number(object.value("blues")), .magentas = number(object.value("magentas")),
|
||||
.tint = boolean(object.value("tint")), .tintHue = number(object.value("tintHue")),
|
||||
.tintSaturation = number(object.value("tintSaturation"))};
|
||||
}
|
||||
|
||||
ColorBalanceSettings colorBalance(const QJsonValue &value)
|
||||
{
|
||||
const QJsonObject object = value.toObject();
|
||||
return {.shadowCyanRed = number(object.value("shadowCyanRed")), .shadowMagentaGreen = number(object.value("shadowMagentaGreen")),
|
||||
.shadowYellowBlue = number(object.value("shadowYellowBlue")), .midCyanRed = number(object.value("midCyanRed")),
|
||||
.midMagentaGreen = number(object.value("midMagentaGreen")), .midYellowBlue = number(object.value("midYellowBlue")),
|
||||
.highlightCyanRed = number(object.value("highlightCyanRed")), .highlightMagentaGreen = number(object.value("highlightMagentaGreen")),
|
||||
.highlightYellowBlue = number(object.value("highlightYellowBlue")), .preserveLuminosity = boolean(object.value("preserveLuminosity"))};
|
||||
}
|
||||
|
||||
QJsonObject encoded(const AdjustmentColor &colour)
|
||||
{
|
||||
return {{"red", colour.red}, {"green", colour.green}, {"blue", colour.blue}};
|
||||
@@ -184,7 +203,9 @@ LayerAdjustment ManifestJson::adjustment(const QJsonValue &value)
|
||||
.curves = curves(object.value("curves")),
|
||||
.exposureSettings = optional(object, "exposureSettings", exposure),
|
||||
.gradientMapSettings = optional(object, "gradientMapSettings", gradientMap),
|
||||
.grainSettings = optional(object, "grainSettings", grain)};
|
||||
.grainSettings = optional(object, "grainSettings", grain),
|
||||
.blackWhiteSettings = optional(object, "blackWhiteSettings", blackWhite),
|
||||
.colorBalanceSettings = optional(object, "colorBalanceSettings", colorBalance)};
|
||||
}
|
||||
|
||||
QJsonObject ManifestJson::encoded(const LayerAdjustment &adjustment)
|
||||
@@ -212,5 +233,20 @@ QJsonObject ManifestJson::encoded(const LayerAdjustment &adjustment)
|
||||
object.insert("grainSettings", QJsonObject{{"amount", grain.amount}, {"size", grain.size}, {"roughness", grain.roughness},
|
||||
{"seed", qint64(grain.seed)}});
|
||||
}
|
||||
if (adjustment.blackWhiteSettings) {
|
||||
const BlackWhiteSettings &gray = *adjustment.blackWhiteSettings;
|
||||
object.insert("blackWhiteSettings", QJsonObject{{"reds", gray.reds}, {"yellows", gray.yellows}, {"greens", gray.greens}, {"cyans", gray.cyans},
|
||||
{"blues", gray.blues}, {"magentas", gray.magentas}, {"tint", gray.tint},
|
||||
{"tintHue", gray.tintHue}, {"tintSaturation", gray.tintSaturation}});
|
||||
}
|
||||
if (adjustment.colorBalanceSettings) {
|
||||
const ColorBalanceSettings &balance = *adjustment.colorBalanceSettings;
|
||||
object.insert("colorBalanceSettings",
|
||||
QJsonObject{{"shadowCyanRed", balance.shadowCyanRed}, {"shadowMagentaGreen", balance.shadowMagentaGreen},
|
||||
{"shadowYellowBlue", balance.shadowYellowBlue}, {"midCyanRed", balance.midCyanRed},
|
||||
{"midMagentaGreen", balance.midMagentaGreen}, {"midYellowBlue", balance.midYellowBlue},
|
||||
{"highlightCyanRed", balance.highlightCyanRed}, {"highlightMagentaGreen", balance.highlightMagentaGreen},
|
||||
{"highlightYellowBlue", balance.highlightYellowBlue}, {"preserveLuminosity", balance.preserveLuminosity}});
|
||||
}
|
||||
return object;
|
||||
}
|
||||
@@ -18,14 +18,14 @@ NativeLayerList *focusedList()
|
||||
// An entry's name: Hue/Saturation is newHueSaturationAdjustment.
|
||||
QString adjustmentName(AdjustmentKind kind)
|
||||
{
|
||||
return QStringLiteral("new%1Adjustment").arg(rawValue(kind).remove(QLatin1Char(' ')).remove(QLatin1Char('/')));
|
||||
return QStringLiteral("new%1Adjustment").arg(rawValue(kind).remove(QLatin1Char(' ')).remove(QLatin1Char('/')).remove(QLatin1Char('&')));
|
||||
}
|
||||
|
||||
// A filter's entry from Swift's title: gaussianBlur.
|
||||
QString filterName(FilterKind kind)
|
||||
{
|
||||
QString name = rawValue(kind);
|
||||
name.remove(QLatin1Char(' '));
|
||||
name.remove(QLatin1Char(' ')).remove(QLatin1Char('&'));
|
||||
name[0] = name[0].toLower();
|
||||
return name;
|
||||
}
|
||||
@@ -203,8 +203,8 @@ CompositorMenus::CompositorMenus(ProjectWorkspace &workspace, QMenuBar &bar, QWi
|
||||
add(image, QStringLiteral("levels"), QStringLiteral("Levels…"), QKeySequence(Qt::CTRL | Qt::Key_L), [this] { session().beginLevels(); });
|
||||
add(image, QStringLiteral("hueSaturation"), QStringLiteral("Hue/Saturation…"), QKeySequence(Qt::CTRL | Qt::Key_U),
|
||||
[this] { session().beginHueSaturation(); });
|
||||
for (const FilterKind kind : {FilterKind::exposure, FilterKind::gradientMap, FilterKind::grain})
|
||||
add(image, filterName(kind), rawValue(kind) + QStringLiteral("…"), QKeySequence(), [this, kind] { session().beginFilter(kind); });
|
||||
for (const FilterKind kind : {FilterKind::blackWhite, FilterKind::colorBalance, FilterKind::exposure, FilterKind::gradientMap, FilterKind::grain})
|
||||
add(image, filterName(kind), rawValue(kind).replace(QLatin1Char('&'), QStringLiteral("&&")) + QStringLiteral("…"), QKeySequence(), [this, kind] { session().beginFilter(kind); });
|
||||
add(image, QStringLiteral("invert"), QStringLiteral("Invert"), QKeySequence(Qt::CTRL | Qt::Key_I), [this] { session().invertPixels(); });
|
||||
image->addSeparator();
|
||||
add(image, QStringLiteral("canvasSize"), QStringLiteral("Canvas Size…"), QKeySequence(Qt::CTRL | Qt::ALT | Qt::Key_C), [this] { projects().canvasSize(); });
|
||||
@@ -223,7 +223,9 @@ CompositorMenus::CompositorMenus(ProjectWorkspace &workspace, QMenuBar &bar, QWi
|
||||
QMenu *adjustments = layer->addMenu(QStringLiteral("New Adjustment Layer"));
|
||||
adjustments->menuAction()->setObjectName(QStringLiteral("newAdjustmentLayer"));
|
||||
for (const AdjustmentKind kind : allAdjustmentKinds)
|
||||
add(adjustments, adjustmentName(kind), rawValue(kind) + QStringLiteral("…"), QKeySequence(), [this, kind] { session().addAdjustment(kind); });
|
||||
// A menu reads a lone ampersand as a mnemonic: doubled.
|
||||
add(adjustments, adjustmentName(kind), rawValue(kind).replace(QLatin1Char('&'), QStringLiteral("&&")) + (isEditable(kind) ? QStringLiteral("…") : QString()), QKeySequence(),
|
||||
[this, kind] { session().addAdjustment(kind); });
|
||||
add(layer, QStringLiteral("editAdjustment"), QStringLiteral("Edit Adjustment…"), QKeySequence(),
|
||||
[this] { session().setAdjustmentEditingID(session().activeLayerID()); });
|
||||
layer->addSeparator();
|
||||
@@ -323,10 +325,12 @@ void CompositorMenus::synchronize()
|
||||
action(QStringLiteral("exportPNG"))->setEnabled(drawn && p.canStart());
|
||||
action(QStringLiteral("exportJPEG"))->setEnabled(drawn && p.canStart());
|
||||
action(QStringLiteral("closeProject"))->setEnabled(p.canStart());
|
||||
// A step's ampersand, doubled: a lone one is a mnemonic.
|
||||
const auto shown = [](QString name) { return name.replace(QLatin1Char('&'), QStringLiteral("&&")); };
|
||||
// In a field, Undo is the field's and bare.
|
||||
action(QStringLiteral("undo"))->setText(!typing && s.canUndo() ? QStringLiteral("Undo %1").arg(s.history.undoName()) : QStringLiteral("Undo"));
|
||||
action(QStringLiteral("undo"))->setText(!typing && s.canUndo() ? QStringLiteral("Undo %1").arg(shown(s.history.undoName())) : QStringLiteral("Undo"));
|
||||
action(QStringLiteral("undo"))->setEnabled(typing || s.canUndo());
|
||||
action(QStringLiteral("redo"))->setText(!typing && s.canRedo() ? QStringLiteral("Redo %1").arg(s.history.redoName()) : QStringLiteral("Redo"));
|
||||
action(QStringLiteral("redo"))->setText(!typing && s.canRedo() ? QStringLiteral("Redo %1").arg(shown(s.history.redoName())) : QStringLiteral("Redo"));
|
||||
action(QStringLiteral("redo"))->setEnabled(typing || s.canRedo());
|
||||
for (const char *name : {"fit", "actualPixels", "zoomIn", "zoomOut"})
|
||||
action(QString::fromLatin1(name))->setEnabled(drawn);
|
||||
@@ -364,7 +368,8 @@ void CompositorMenus::synchronize()
|
||||
action(QStringLiteral("flipCanvasHorizontal"))->setEnabled(s.canEditLayers());
|
||||
action(QStringLiteral("flipCanvasVertical"))->setEnabled(s.canEditLayers());
|
||||
action(QStringLiteral("newAdjustmentLayer"))->setEnabled(s.canEditLayers());
|
||||
action(QStringLiteral("editAdjustment"))->setEnabled(s.canEditLayers() && active && active->adjustment);
|
||||
// Beyond Swift: Invert has no editor, so nothing to open.
|
||||
action(QStringLiteral("editAdjustment"))->setEnabled(s.canEditLayers() && active && active->adjustment && isEditable(active->adjustment->kind));
|
||||
action(QStringLiteral("transformLayer"))->setText(s.canTransformSelection() ? QStringLiteral("Transform Selection") : QStringLiteral("Transform Layer"));
|
||||
action(QStringLiteral("transformLayer"))->setEnabled(s.canTransform() || s.canTransformSelection());
|
||||
action(QStringLiteral("clippingMask"))->setText(active && active->maskSourceID ? QStringLiteral("Release Clipping Mask") : QStringLiteral("Create Clipping Mask"));
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
#include "UI/FilterSheet.h"
|
||||
#include <QCheckBox>
|
||||
#include <QLabel>
|
||||
#include <QSlider>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
// Black & White's and Color Balance's rows, as Swift's FilterSheet.
|
||||
void FilterSheet::blackWhite()
|
||||
{
|
||||
// Each slider: how bright that family of colours becomes.
|
||||
const std::vector<std::pair<QString, double BlackWhiteSettings::*>> families{
|
||||
{QStringLiteral("Reds"), &BlackWhiteSettings::reds}, {QStringLiteral("Yellows"), &BlackWhiteSettings::yellows},
|
||||
{QStringLiteral("Greens"), &BlackWhiteSettings::greens}, {QStringLiteral("Cyans"), &BlackWhiteSettings::cyans},
|
||||
{QStringLiteral("Blues"), &BlackWhiteSettings::blues}, {QStringLiteral("Magentas"), &BlackWhiteSettings::magentas}};
|
||||
for (const auto &[title, field] : families)
|
||||
control(title, [field](FilterSettings &settings) -> double & { return settings.blackWhite.*field; }, BlackWhiteSettings::low,
|
||||
BlackWhiteSettings::high, QStringLiteral("%"), 0, false);
|
||||
flag(QStringLiteral("Tint"), QStringLiteral("Color the result while keeping its tones, for a sepia or a cyanotype"),
|
||||
[](FilterSettings &settings) -> bool & { return settings.blackWhite.tint; });
|
||||
control(QStringLiteral("Hue"), [](FilterSettings &settings) -> double & { return settings.blackWhite.tintHue; }, 0, 360, QStringLiteral("°"), 0,
|
||||
false);
|
||||
control(QStringLiteral("Saturation"), [](FilterSettings &settings) -> double & { return settings.blackWhite.tintSaturation; }, 0, 100,
|
||||
QStringLiteral("%"), 0, false);
|
||||
for (size_t index = m_controls.size() - 2; index < m_controls.size(); ++index)
|
||||
m_shownWhen.emplace_back(m_controls[index].slider->parentWidget(), [](const FilterSettings &settings) { return settings.blackWhite.tint; });
|
||||
}
|
||||
|
||||
void FilterSheet::colorBalance()
|
||||
{
|
||||
const std::vector<std::pair<QString, std::array<double ColorBalanceSettings::*, 3>>> ranges{
|
||||
{QStringLiteral("Shadows"),
|
||||
{&ColorBalanceSettings::shadowCyanRed, &ColorBalanceSettings::shadowMagentaGreen, &ColorBalanceSettings::shadowYellowBlue}},
|
||||
{QStringLiteral("Midtones"), {&ColorBalanceSettings::midCyanRed, &ColorBalanceSettings::midMagentaGreen, &ColorBalanceSettings::midYellowBlue}},
|
||||
{QStringLiteral("Highlights"),
|
||||
{&ColorBalanceSettings::highlightCyanRed, &ColorBalanceSettings::highlightMagentaGreen, &ColorBalanceSettings::highlightYellowBlue}}};
|
||||
const std::array<QString, 3> pairs{QStringLiteral("Cyan / Red"), QStringLiteral("Magenta / Green"), QStringLiteral("Yellow / Blue")};
|
||||
for (const auto &[range, fields] : ranges) {
|
||||
headline(range);
|
||||
for (size_t pair = 0; pair < 3; ++pair)
|
||||
control(pairs[pair], [field = fields[pair]](FilterSettings &settings) -> double & { return settings.colorBalance.*field; },
|
||||
ColorBalanceSettings::low, ColorBalanceSettings::high, QString(), 0, false);
|
||||
}
|
||||
flag(QStringLiteral("Preserve Luminosity"), QStringLiteral("Put each pixel's brightness back afterwards, so only the color moves"),
|
||||
[](FilterSettings &settings) -> bool & { return settings.colorBalance.preserveLuminosity; });
|
||||
}
|
||||
|
||||
void FilterSheet::flag(const QString &title, const QString &help, std::function<bool &(FilterSettings &)> key)
|
||||
{
|
||||
auto *box = new QCheckBox(title, this);
|
||||
box->setToolTip(help);
|
||||
connect(box, &QCheckBox::clicked, this, [this, key](bool on) { update([&key, on](FilterSettings &settings) { key(settings) = on; }); });
|
||||
m_column->addWidget(box);
|
||||
m_flags.emplace_back(box, std::move(key));
|
||||
}
|
||||
|
||||
void FilterSheet::headline(const QString &title)
|
||||
{
|
||||
auto *label = new QLabel(title, this);
|
||||
QFont font = label->font();
|
||||
font.setPixelSize(13);
|
||||
font.setWeight(QFont::DemiBold);
|
||||
label->setFont(font);
|
||||
m_column->addWidget(label);
|
||||
}
|
||||
@@ -181,6 +181,8 @@ FilterSheet::FilterSheet(EditorSession &session, QWidget *parent)
|
||||
GrainSettings::roughnessLow, GrainSettings::roughnessHigh, QString(), 0, false);
|
||||
break;
|
||||
case FilterKind::removeBackground: background(); break;
|
||||
case FilterKind::blackWhite: blackWhite(); break;
|
||||
case FilterKind::colorBalance: colorBalance(); break;
|
||||
case FilterKind::contentAwareFill:
|
||||
m_column->addWidget(words(QStringLiteral("Fill the selection using surrounding pixels from this layer."), false, this));
|
||||
break;
|
||||
@@ -356,7 +358,7 @@ void FilterSheet::background()
|
||||
for (qsizetype index = 0; index < helps.size(); ++index) {
|
||||
QWidget *advanced = m_controls[size_t(index)].slider->parentWidget();
|
||||
advanced->setToolTip(helps[index]);
|
||||
m_advanced.push_back(advanced);
|
||||
m_shownWhen.emplace_back(advanced, [](const FilterSettings &settings) { return settings.backgroundQuality == BackgroundQuality::advanced; });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -409,8 +411,12 @@ void FilterSheet::synchronize()
|
||||
m_monochromatic->setChecked(edit.settings.monochromatic);
|
||||
if (m_quality)
|
||||
m_quality->button(int(edit.settings.backgroundQuality))->setChecked(true);
|
||||
for (QWidget *advanced : m_advanced)
|
||||
advanced->setVisible(edit.settings.backgroundQuality == BackgroundQuality::advanced);
|
||||
for (auto &[box, key] : m_flags) {
|
||||
FilterSettings copy = edit.settings;
|
||||
box->setChecked(key(copy));
|
||||
}
|
||||
for (const auto &[row, shows] : m_shownWhen)
|
||||
row->setVisible(shows(edit.settings));
|
||||
m_preview->setChecked(edit.preview);
|
||||
m_error->setText(edit.previewError.value_or(QString()));
|
||||
m_error->setVisible(edit.previewError.has_value());
|
||||
|
||||
+10
-2
@@ -55,6 +55,13 @@ private:
|
||||
void noise();
|
||||
// Remove Background's words, Quality, and Advanced's three rows.
|
||||
void background();
|
||||
// Black & White's six families and Tint; Color Balance's nine.
|
||||
void blackWhite();
|
||||
void colorBalance();
|
||||
// Swift's Toggle over a flag, with its help.
|
||||
void flag(const QString &title, const QString &help, std::function<bool &(FilterSettings &)> key);
|
||||
// Swift's headline over a group of rows.
|
||||
void headline(const QString &title);
|
||||
// Swift's `settings` and `update`: the defaults once the edit goes.
|
||||
FilterSettings settings() const;
|
||||
void update(const std::function<void(FilterSettings &)> &change);
|
||||
@@ -68,8 +75,9 @@ private:
|
||||
QButtonGroup *m_distribution = nullptr;
|
||||
QCheckBox *m_monochromatic = nullptr;
|
||||
QButtonGroup *m_quality = nullptr;
|
||||
// Shown while the quality is Advanced, as Swift's `if`.
|
||||
std::vector<QWidget *> m_advanced;
|
||||
std::vector<std::pair<QCheckBox *, std::function<bool &(FilterSettings &)>>> m_flags;
|
||||
// Rows under Swift's `if`: Advanced's, Tint's.
|
||||
std::vector<std::pair<QWidget *, std::function<bool(const FilterSettings &)>>> m_shownWhen;
|
||||
QCheckBox *const m_preview;
|
||||
QLabel *const m_error;
|
||||
QLabel *const m_limited;
|
||||
|
||||
@@ -93,6 +93,45 @@ void halfFilledCircle(QPainter &painter)
|
||||
painter.drawEllipse(QPointF(9, 9), 7, 7);
|
||||
}
|
||||
|
||||
// Swift's circle.righthalf.filled: the other half, Invert's.
|
||||
void rightHalfCircle(QPainter &painter)
|
||||
{
|
||||
QPainterPath half;
|
||||
half.moveTo(9, 2);
|
||||
half.arcTo(QRectF(2, 2, 14, 14), 90, -180);
|
||||
half.closeSubpath();
|
||||
painter.fillPath(half, painter.pen().color());
|
||||
painter.drawEllipse(QPointF(9, 9), 7, 7);
|
||||
}
|
||||
|
||||
// Swift's circle.filled.pattern.diagonalline.rectangle: Black & White's.
|
||||
void hatchedCircle(QPainter &painter)
|
||||
{
|
||||
const QRectF frame(2, 4, 14, 10);
|
||||
painter.drawRoundedRect(frame, 2, 2);
|
||||
painter.save();
|
||||
painter.setClipRect(frame);
|
||||
for (double x = -6; x < 16; x += 3)
|
||||
painter.drawLine(QPointF(x, 14), QPointF(x + 10, 4));
|
||||
painter.restore();
|
||||
painter.setBrush(painter.pen().color());
|
||||
painter.drawEllipse(QPointF(9, 9), 3.2, 3.2);
|
||||
}
|
||||
|
||||
// Swift's scale.3d: three axes from one corner, Color Balance's.
|
||||
void axes(QPainter &painter)
|
||||
{
|
||||
const QPointF origin(7, 11);
|
||||
const auto arrow = [&](QPointF tip, QPointF back1, QPointF back2) {
|
||||
painter.drawLine(origin, tip);
|
||||
painter.drawLine(tip, back1);
|
||||
painter.drawLine(tip, back2);
|
||||
};
|
||||
arrow(QPointF(7, 2.5), QPointF(5.2, 4.5), QPointF(8.8, 4.5));
|
||||
arrow(QPointF(16, 11), QPointF(14, 9.2), QPointF(14, 12.8));
|
||||
arrow(QPointF(2.5, 15.5), QPointF(2.6, 12.9), QPointF(5.1, 15.4));
|
||||
}
|
||||
|
||||
// Swift's slider.horizontal.3: three tracks, each with a knob.
|
||||
void sliders(QPainter &painter)
|
||||
{
|
||||
@@ -201,6 +240,9 @@ void LayerIcons::paint(QPainter &painter, LayerIcon icon, QPointF origin, double
|
||||
case LayerIcon::paintPalette: paintPalette(painter); break;
|
||||
case LayerIcon::circleGrid: circleGrid(painter); break;
|
||||
case LayerIcon::sparkles: sparkles(painter); break;
|
||||
case LayerIcon::rightHalfCircle: rightHalfCircle(painter); break;
|
||||
case LayerIcon::hatchedCircle: hatchedCircle(painter); break;
|
||||
case LayerIcon::axes: axes(painter); break;
|
||||
}
|
||||
painter.restore();
|
||||
}
|
||||
@@ -224,6 +266,9 @@ LayerIcon LayerIcons::symbol(AdjustmentKind kind)
|
||||
case AdjustmentKind::exposure: return LayerIcon::plusMinusCircle;
|
||||
case AdjustmentKind::gradientMap: return LayerIcon::paintPalette;
|
||||
case AdjustmentKind::grain: return LayerIcon::circleGrid;
|
||||
case AdjustmentKind::invert: return LayerIcon::rightHalfCircle;
|
||||
case AdjustmentKind::blackWhite: return LayerIcon::hatchedCircle;
|
||||
case AdjustmentKind::colorBalance: return LayerIcon::axes;
|
||||
}
|
||||
throw std::logic_error("unknown adjustment kind");
|
||||
}
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@
|
||||
// The layers panel's icons, drawn: SF Symbols have no twin.
|
||||
enum class LayerIcon {
|
||||
eye, eyeSlash, chevronRight, chevronDown, folder, link, newLayer, newFolder, addMask, trash, layers, text,
|
||||
halfFilledCircle, sliders, curvePath, plusMinusCircle, paintPalette, circleGrid, sparkles
|
||||
halfFilledCircle, sliders, curvePath, plusMinusCircle, paintPalette, circleGrid, sparkles, rightHalfCircle, hatchedCircle, axes
|
||||
};
|
||||
|
||||
namespace LayerIcons {
|
||||
|
||||
@@ -93,7 +93,7 @@ LayersPanel::LayersPanel(EditorSession &session, QWidget *parent)
|
||||
m_adjustments = footerButton(QStringLiteral("addAdjustment"), QStringLiteral("New adjustment layer"), QStringLiteral("New adjustment layer"), [] {});
|
||||
auto *kinds = new QMenu(m_adjustments);
|
||||
for (const AdjustmentKind kind : allAdjustmentKinds)
|
||||
kinds->addAction(rawValue(kind), this, [this, kind] { m_session.addAdjustment(kind); });
|
||||
kinds->addAction(rawValue(kind).replace(QLatin1Char('&'), QStringLiteral("&&")), this, [this, kind] { m_session.addAdjustment(kind); });
|
||||
m_adjustments->setMenu(kinds);
|
||||
m_adjustments->setPopupMode(QToolButton::InstantPopup);
|
||||
footer->addWidget(m_adjustments);
|
||||
|
||||
@@ -120,6 +120,7 @@ void LayerCell::configure(const ImageLayer &layer, bool enabled, int depth, bool
|
||||
const QSizeF canvas = session.document() ? session.document()->size() : QSizeF(1, 1);
|
||||
m_editableText = layer.liveText().has_value();
|
||||
m_isAdjustment = layer.adjustment.has_value();
|
||||
m_editableAdjustment = m_isAdjustment && isEditable(layer.adjustment->kind);
|
||||
m_thumbnailSize = layer.isGroup || m_editableText || m_isAdjustment ? QSize(36, 36) : CanvasThumbnail::fittedSize(canvas, 36);
|
||||
const ThumbnailKey key{layer.asset ? std::optional(layer.asset->identity()) : std::nullopt, layer.transform, canvas, ink, m_editableText};
|
||||
if (m_layerID != layer.id || m_thumbnailKey != key) {
|
||||
@@ -306,7 +307,7 @@ void LayerCell::mouseDoubleClickEvent(QMouseEvent *event)
|
||||
session.editActiveText();
|
||||
return;
|
||||
}
|
||||
if (m_isAdjustment) {
|
||||
if (m_editableAdjustment) {
|
||||
session.setAdjustmentEditingID(m_layerID);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -155,6 +155,12 @@ void NativeLayerList::focusList()
|
||||
setFocus(Qt::MouseFocusReason);
|
||||
}
|
||||
|
||||
// Closing hides it: a rename's focus-out commits, unheard here.
|
||||
NativeLayerList::~NativeLayerList()
|
||||
{
|
||||
disconnect(&m_session, &EditorSession::changed, this, &NativeLayerList::update);
|
||||
}
|
||||
|
||||
// Tab reaches the keys, not the focus chain, as Swift.
|
||||
bool NativeLayerList::event(QEvent *event)
|
||||
{
|
||||
|
||||
@@ -166,6 +166,8 @@ private:
|
||||
bool m_isGroup = false;
|
||||
bool m_editableText = false;
|
||||
bool m_isAdjustment = false;
|
||||
// Swift's isEditable: Invert's row renames instead.
|
||||
bool m_editableAdjustment = false;
|
||||
int m_indent = 0;
|
||||
std::optional<QPoint> m_press;
|
||||
// Alt at the press makes the drag a copy.
|
||||
@@ -203,6 +205,7 @@ class NativeLayerList : public QScrollArea {
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit NativeLayerList(EditorSession &session, QWidget *parent = nullptr);
|
||||
~NativeLayerList() override;
|
||||
// A dragged mask carries its layer's id under this type.
|
||||
static const QString maskType;
|
||||
// A row drag names its list, as Swift checks tables.
|
||||
|
||||
Reference in new issue
Block a user