494 lines
15 KiB
C++
494 lines
15 KiB
C++
#include "Document/Selection.h"
|
|
#include "Document/BrushStroke.h"
|
|
#include "Document/EditorSession.h"
|
|
#include "Document/PixelAdjust.h"
|
|
#include <QPainterPathStroker>
|
|
#include <cmath>
|
|
#include <stdexcept>
|
|
|
|
namespace {
|
|
QPainterPath rectPath(const QRectF &rect)
|
|
{
|
|
QPainterPath path;
|
|
path.addRect(rect);
|
|
return path;
|
|
}
|
|
|
|
QPainterPath canvasPath(const CanvasDocument &document)
|
|
{
|
|
return rectPath(QRectF(QPointF(0, 0), document.size()));
|
|
}
|
|
|
|
void fill(QPainter &painter, const QPainterPath &path, bool antialiased)
|
|
{
|
|
painter.setRenderHint(QPainter::Antialiasing, antialiased);
|
|
painter.fillPath(path, Qt::white);
|
|
}
|
|
}
|
|
|
|
QString rawValue(LassoKind kind)
|
|
{
|
|
switch (kind) {
|
|
case LassoKind::freehand:
|
|
return QStringLiteral("Freehand");
|
|
case LassoKind::polygonal:
|
|
return QStringLiteral("Polygonal");
|
|
case LassoKind::rectangle:
|
|
return QStringLiteral("Rectangle");
|
|
case LassoKind::ellipse:
|
|
return QStringLiteral("Ellipse");
|
|
}
|
|
throw std::logic_error("unknown lasso kind");
|
|
}
|
|
|
|
QString rawValue(SelectionAmountOperation operation)
|
|
{
|
|
switch (operation) {
|
|
case SelectionAmountOperation::expand:
|
|
return QStringLiteral("Expand");
|
|
case SelectionAmountOperation::contract:
|
|
return QStringLiteral("Contract");
|
|
case SelectionAmountOperation::feather:
|
|
return QStringLiteral("Feather");
|
|
}
|
|
throw std::logic_error("unknown selection amount operation");
|
|
}
|
|
|
|
QString rawValue(SelectionMode mode)
|
|
{
|
|
switch (mode) {
|
|
case SelectionMode::replace:
|
|
return QStringLiteral("New");
|
|
case SelectionMode::add:
|
|
return QStringLiteral("Add");
|
|
case SelectionMode::subtract:
|
|
return QStringLiteral("Subtract");
|
|
}
|
|
throw std::logic_error("unknown selection mode");
|
|
}
|
|
|
|
bool DocumentSelection::isEmpty() const
|
|
{
|
|
return path.isEmpty() || path.boundingRect().isEmpty();
|
|
}
|
|
|
|
QImage DocumentSelection::coverage(int width, int height) const
|
|
{
|
|
QImage image = BrushRaster::context(width, height, true);
|
|
{
|
|
QPainter painter(&image);
|
|
fill(painter, path, antialiased || feather > 0);
|
|
}
|
|
if (!(feather > 0))
|
|
return image;
|
|
// A feathered edge fades either side of the outline.
|
|
return PixelAdjust::gaussianBlur(image, feather / 2, true);
|
|
}
|
|
|
|
QRectF DocumentSelection::coverageBounds() const
|
|
{
|
|
// Four standard deviations keep the falloff past the outline.
|
|
const double reach = std::ceil(feather * 2);
|
|
return path.boundingRect().adjusted(-reach, -reach, reach, reach);
|
|
}
|
|
|
|
SelectionClip DocumentSelection::clip(QSizeF canvas) const
|
|
{
|
|
const QRect region = coverageBounds().adjusted(-1, -1, 1, 1).toAlignedRect()
|
|
.intersected(QRect(0, 0, int(canvas.width()), int(canvas.height())));
|
|
if (isEmpty() || region.isEmpty())
|
|
return SelectionClip{QRectF(), std::nullopt};
|
|
const DocumentSelection local{path.translated(-region.x(), -region.y()), antialiased, feather};
|
|
return SelectionClip{QRectF(region), local.coverage(region.width(), region.height())};
|
|
}
|
|
|
|
QRectF DragBox::rect(QPointF anchor, QPointF point, bool square, bool fromCenter)
|
|
{
|
|
double dx = std::round(point.x()) - anchor.x(), dy = std::round(point.y()) - anchor.y();
|
|
if (square) {
|
|
const double side = std::max(std::abs(dx), std::abs(dy));
|
|
dx = dx < 0 ? -side : side;
|
|
dy = dy < 0 ? -side : side;
|
|
}
|
|
if (fromCenter)
|
|
return QRectF(anchor.x() - std::abs(dx), anchor.y() - std::abs(dy), std::abs(dx) * 2, std::abs(dy) * 2);
|
|
return QRectF(std::min(anchor.x(), anchor.x() + dx), std::min(anchor.y(), anchor.y() + dy), std::abs(dx), std::abs(dy));
|
|
}
|
|
|
|
std::optional<DocumentSelection> EditorSession::selection() const
|
|
{
|
|
return m_document ? m_document->selection : std::nullopt;
|
|
}
|
|
|
|
bool EditorSession::canEditSelection() const
|
|
{
|
|
return canEditLayers();
|
|
}
|
|
|
|
SelectionMode EditorSession::selectionMode(bool shift, bool option) const
|
|
{
|
|
return option ? SelectionMode::subtract : shift ? SelectionMode::add : m_selectionModeChoice;
|
|
}
|
|
|
|
SelectionMode EditorSession::lassoCursorMode(bool shift, bool option) const
|
|
{
|
|
return m_lassoDraft ? m_lassoDraft->mode : selectionMode(shift, option);
|
|
}
|
|
|
|
SelectionMode EditorSession::displayedSelectionMode() const
|
|
{
|
|
return m_lassoDraft ? m_lassoDraft->mode : m_heldSelectionMode.value_or(m_selectionModeChoice);
|
|
}
|
|
|
|
void EditorSession::updateHeldSelectionKeys(bool shift, bool option)
|
|
{
|
|
const std::optional<SelectionMode> held = option ? std::optional(SelectionMode::subtract) : shift ? std::optional(SelectionMode::add) : std::nullopt;
|
|
if (m_heldSelectionMode == held)
|
|
return;
|
|
m_heldSelectionMode = held;
|
|
notify();
|
|
}
|
|
|
|
void EditorSession::setLassoKind(LassoKind kind)
|
|
{
|
|
m_lassoKind = kind;
|
|
notify();
|
|
}
|
|
|
|
void EditorSession::setMarqueeKind(LassoKind kind)
|
|
{
|
|
m_marqueeKind = kind;
|
|
notify();
|
|
}
|
|
|
|
void EditorSession::setSelectionModeChoice(SelectionMode mode)
|
|
{
|
|
m_selectionModeChoice = mode;
|
|
notify();
|
|
}
|
|
|
|
void EditorSession::setSelectionAntialiased(bool antialiased)
|
|
{
|
|
m_selectionAntialiased = antialiased;
|
|
notify();
|
|
}
|
|
|
|
void EditorSession::setSelectionExpandAmount(int amount)
|
|
{
|
|
m_selectionExpandAmount = amount;
|
|
notify();
|
|
}
|
|
|
|
void EditorSession::setSelectionContractAmount(int amount)
|
|
{
|
|
m_selectionContractAmount = amount;
|
|
notify();
|
|
}
|
|
|
|
void EditorSession::setSelectionFeatherAmount(int amount)
|
|
{
|
|
m_selectionFeatherAmount = amount;
|
|
notify();
|
|
}
|
|
|
|
void EditorSession::setSelectionAmountOperation(std::optional<SelectionAmountOperation> operation)
|
|
{
|
|
m_selectionAmountOperation = operation;
|
|
resumeFileRequests();
|
|
notify();
|
|
}
|
|
|
|
void EditorSession::beginLasso(QPointF point, SelectionMode mode)
|
|
{
|
|
// The Magic Wand selects with a click, never an outline.
|
|
if (!isSelectionTool(m_tool) || m_tool == NavigationTool::wand || !canEditSelection() || m_selectionMoveOrigin)
|
|
return;
|
|
if (m_tool == NavigationTool::marquee) {
|
|
const QPointF anchor(std::round(point.x()), std::round(point.y()));
|
|
m_lassoDraft = LassoDraft{{anchor}, std::nullopt, mode, m_marqueeKind, anchor};
|
|
} else {
|
|
m_lassoDraft = LassoDraft{{point}, std::nullopt, mode, m_lassoKind, std::nullopt};
|
|
}
|
|
notify();
|
|
}
|
|
|
|
void EditorSession::dragMarquee(QPointF point, bool square, bool fromCenter)
|
|
{
|
|
if (!m_lassoDraft || (m_lassoDraft->kind != LassoKind::rectangle && m_lassoDraft->kind != LassoKind::ellipse) || !m_lassoDraft->anchor
|
|
|| !std::isfinite(point.x()) || !std::isfinite(point.y()))
|
|
return;
|
|
const QRectF rect = DragBox::rect(*m_lassoDraft->anchor, point, square, fromCenter);
|
|
m_lassoDraft->points = {rect.topLeft(), rect.topRight(), rect.bottomRight(), rect.bottomLeft()};
|
|
notify();
|
|
}
|
|
|
|
void EditorSession::extendLasso(QPointF point)
|
|
{
|
|
if (!m_lassoDraft || !std::isfinite(point.x()) || !std::isfinite(point.y()))
|
|
return;
|
|
// Points closer than a quarter pixel are skipped.
|
|
const QPointF last = m_lassoDraft->points.back();
|
|
if (std::hypot(point.x() - last.x(), point.y() - last.y()) < 0.25)
|
|
return;
|
|
m_lassoDraft->points.push_back(point);
|
|
notify();
|
|
}
|
|
|
|
void EditorSession::moveLassoCursor(std::optional<QPointF> point)
|
|
{
|
|
if (!m_lassoDraft)
|
|
return;
|
|
m_lassoDraft->cursor = point;
|
|
notify();
|
|
}
|
|
|
|
void EditorSession::removeLastLassoPoint()
|
|
{
|
|
if (!m_lassoDraft)
|
|
return;
|
|
m_lassoDraft->points.pop_back();
|
|
if (m_lassoDraft->points.empty())
|
|
m_lassoDraft = std::nullopt;
|
|
notify();
|
|
}
|
|
|
|
void EditorSession::cancelLasso()
|
|
{
|
|
if (!m_lassoDraft)
|
|
return;
|
|
m_lassoDraft = std::nullopt;
|
|
notify();
|
|
}
|
|
|
|
// The key chooses the tool; the bar switches its kind.
|
|
void EditorSession::pressMarqueeKey()
|
|
{
|
|
selectTool(NavigationTool::marquee);
|
|
}
|
|
|
|
void EditorSession::pressLassoKey()
|
|
{
|
|
selectTool(NavigationTool::lasso);
|
|
}
|
|
|
|
void EditorSession::toggleMarqueeKind()
|
|
{
|
|
cancelLasso();
|
|
m_marqueeKind = m_marqueeKind == LassoKind::rectangle ? LassoKind::ellipse : LassoKind::rectangle;
|
|
notify();
|
|
}
|
|
|
|
void EditorSession::toggleLassoKind()
|
|
{
|
|
cancelLasso();
|
|
m_lassoKind = m_lassoKind == LassoKind::freehand ? LassoKind::polygonal : LassoKind::freehand;
|
|
notify();
|
|
}
|
|
|
|
void EditorSession::finishLasso()
|
|
{
|
|
if (!m_lassoDraft)
|
|
return;
|
|
const LassoDraft draft = *std::exchange(m_lassoDraft, std::nullopt);
|
|
QPainterPath outline;
|
|
outline.setFillRule(Qt::WindingFill);
|
|
if (draft.kind == LassoKind::ellipse && draft.points.size() == 4) {
|
|
// The drag's box, whole pixels; the oval fills it.
|
|
double left = draft.points[0].x(), top = draft.points[0].y(), right = left, bottom = top;
|
|
for (const QPointF &point : draft.points) {
|
|
left = std::min(left, point.x());
|
|
right = std::max(right, point.x());
|
|
top = std::min(top, point.y());
|
|
bottom = std::max(bottom, point.y());
|
|
}
|
|
outline.addEllipse(QRectF(left, top, right - left, bottom - top));
|
|
} else {
|
|
outline.moveTo(draft.points.front());
|
|
for (std::size_t index = 1; index < draft.points.size(); ++index)
|
|
outline.lineTo(draft.points[index]);
|
|
outline.closeSubpath();
|
|
}
|
|
const QRectF bounds = outline.boundingRect();
|
|
if ((draft.points.size() < 3 && draft.kind != LassoKind::ellipse) || bounds.width() <= 0 || bounds.height() <= 0) {
|
|
// A click that encloses nothing deselects in New mode.
|
|
if (draft.mode == SelectionMode::replace)
|
|
deselect();
|
|
notify();
|
|
return;
|
|
}
|
|
applySelection(outline, draft.mode,
|
|
draft.kind == LassoKind::freehand ? QStringLiteral("Lasso") : draft.kind == LassoKind::polygonal ? QStringLiteral("Polygonal Lasso")
|
|
: draft.kind == LassoKind::ellipse ? QStringLiteral("Elliptical Marquee") : QStringLiteral("Rectangular Marquee"));
|
|
notify();
|
|
}
|
|
|
|
void EditorSession::applySelection(const QPainterPath &shape, SelectionMode mode, const QString &name)
|
|
{
|
|
if (!m_document || !canEditSelection())
|
|
return;
|
|
const QPainterPath clipped = shape.intersected(canvasPath(*m_document));
|
|
const std::optional<DocumentSelection> current = selection();
|
|
QPainterPath result;
|
|
switch (mode) {
|
|
case SelectionMode::replace:
|
|
result = clipped;
|
|
break;
|
|
case SelectionMode::add:
|
|
result = current ? current->path.united(clipped) : clipped;
|
|
break;
|
|
case SelectionMode::subtract:
|
|
// Nothing to subtract from: nothing changes.
|
|
if (!current)
|
|
return;
|
|
result = current->path.subtracted(clipped);
|
|
break;
|
|
}
|
|
setSelection(DocumentSelection{result, m_selectionAntialiased}, name);
|
|
}
|
|
|
|
void EditorSession::setSelection(std::optional<DocumentSelection> value, const QString &name)
|
|
{
|
|
if (!m_document || !canEditSelection() || value == selection())
|
|
return;
|
|
beginEdit(name);
|
|
m_document->selection = std::move(value);
|
|
endEdit();
|
|
}
|
|
|
|
bool EditorSession::canMoveSelection(QPointF point) const
|
|
{
|
|
const std::optional<DocumentSelection> current = selection();
|
|
if (!current || current->isEmpty() || !canEditSelection() || m_lassoDraft)
|
|
return false;
|
|
return current->path.contains(point);
|
|
}
|
|
|
|
bool EditorSession::beginSelectionMove()
|
|
{
|
|
const std::optional<DocumentSelection> current = selection();
|
|
if (m_selectionMoveOrigin || !current || current->isEmpty() || !canEditSelection())
|
|
return false;
|
|
beginEdit(QStringLiteral("Move Selection"));
|
|
m_selectionMoveOrigin = current;
|
|
return true;
|
|
}
|
|
|
|
void EditorSession::moveSelection(QSizeF offset)
|
|
{
|
|
// The document may have gone mid-drag: nothing to move.
|
|
if (!m_selectionMoveOrigin || !m_document)
|
|
return;
|
|
// Whole pixels, so edges stay crisp; not re-clipped.
|
|
m_document->selection = DocumentSelection{m_selectionMoveOrigin->path.translated(std::round(offset.width()), std::round(offset.height())),
|
|
m_selectionMoveOrigin->antialiased, m_selectionMoveOrigin->feather};
|
|
notify();
|
|
}
|
|
|
|
void EditorSession::endSelectionMove()
|
|
{
|
|
if (!m_selectionMoveOrigin)
|
|
return;
|
|
m_selectionMoveOrigin = std::nullopt;
|
|
endEdit();
|
|
}
|
|
|
|
void EditorSession::nudgeSelection(double dx, double dy)
|
|
{
|
|
if (!beginSelectionMove())
|
|
return;
|
|
moveSelection(QSizeF(dx, dy));
|
|
endSelectionMove();
|
|
}
|
|
|
|
bool EditorSession::canModifySelection() const
|
|
{
|
|
const std::optional<DocumentSelection> current = selection();
|
|
return current && !current->isEmpty() && canEditSelection() && !m_lassoDraft;
|
|
}
|
|
|
|
void EditorSession::expandSelection(int amount)
|
|
{
|
|
resizeSelection(amount, QStringLiteral("Expand Selection"));
|
|
}
|
|
|
|
void EditorSession::contractSelection(int amount)
|
|
{
|
|
resizeSelection(-amount, QStringLiteral("Contract Selection"));
|
|
}
|
|
|
|
void EditorSession::promptSelectionAmount(SelectionAmountOperation operation)
|
|
{
|
|
if (!canModifySelection())
|
|
return;
|
|
setSelectionAmountOperation(operation);
|
|
}
|
|
|
|
void EditorSession::confirmSelectionAmount(int amount)
|
|
{
|
|
const std::optional<SelectionAmountOperation> operation = m_selectionAmountOperation;
|
|
if (!operation || amount < 1 || amount > (*operation == SelectionAmountOperation::feather ? 250 : 500))
|
|
return;
|
|
setSelectionAmountOperation(std::nullopt);
|
|
switch (*operation) {
|
|
case SelectionAmountOperation::expand:
|
|
setSelectionExpandAmount(amount);
|
|
expandSelection(amount);
|
|
break;
|
|
case SelectionAmountOperation::contract:
|
|
setSelectionContractAmount(amount);
|
|
contractSelection(amount);
|
|
break;
|
|
case SelectionAmountOperation::feather:
|
|
setSelectionFeatherAmount(amount);
|
|
featherSelection(amount);
|
|
break;
|
|
}
|
|
}
|
|
|
|
void EditorSession::featherSelection(int amount)
|
|
{
|
|
const std::optional<DocumentSelection> current = selection();
|
|
if (!canModifySelection() || !current || amount <= 0)
|
|
return;
|
|
// Two soft edges spread less than their sum.
|
|
const double softened = std::sqrt(current->feather * current->feather + double(amount) * amount);
|
|
setSelection(DocumentSelection{current->path, current->antialiased, std::min(250.0, softened)}, QStringLiteral("Feather Selection"));
|
|
}
|
|
|
|
void EditorSession::resizeSelection(double delta, const QString &name)
|
|
{
|
|
const std::optional<DocumentSelection> current = selection();
|
|
if (!m_document || !current || !canModifySelection() || delta == 0 || std::abs(delta) > 500)
|
|
return;
|
|
// A band `|delta|` wide on each side, added or removed.
|
|
QPainterPathStroker stroker;
|
|
stroker.setWidth(std::abs(delta) * 2);
|
|
stroker.setCapStyle(Qt::RoundCap);
|
|
stroker.setJoinStyle(Qt::RoundJoin);
|
|
const QPainterPath band = stroker.createStroke(current->path);
|
|
const QPainterPath result = delta > 0 ? current->path.united(band).intersected(canvasPath(*m_document)) : current->path.subtracted(band);
|
|
setSelection(DocumentSelection{result, current->antialiased, current->feather}, name);
|
|
}
|
|
|
|
void EditorSession::selectAll()
|
|
{
|
|
if (!m_document)
|
|
return;
|
|
setSelection(DocumentSelection{canvasPath(*m_document)}, QStringLiteral("Select All"));
|
|
}
|
|
|
|
void EditorSession::deselect()
|
|
{
|
|
if (!selection())
|
|
return;
|
|
setSelection(std::nullopt, QStringLiteral("Deselect"));
|
|
}
|
|
|
|
void EditorSession::invertSelection()
|
|
{
|
|
const std::optional<DocumentSelection> current = selection();
|
|
if (!m_document || !current)
|
|
return;
|
|
setSelection(DocumentSelection{canvasPath(*m_document).subtracted(current->path), current->antialiased, current->feather}, QStringLiteral("Inverse"));
|
|
}
|