476 lines
18 KiB
C++
476 lines
18 KiB
C++
#include "ContentView.h"
|
||
#include "UI/CanvasRulers.h"
|
||
#include "UI/ColorPaletteControls.h"
|
||
#include "Logging.h"
|
||
#include "Document/ProjectWorkspace.h"
|
||
#include "IO/ImageFileDrop.h"
|
||
#include "UI/LayersPanel.h"
|
||
#include "UI/BrushControls.h"
|
||
#include "UI/CropControls.h"
|
||
#include "UI/GradientControls.h"
|
||
#include "UI/ShapeControls.h"
|
||
#include "UI/TypeControls.h"
|
||
#include "UI/LassoControls.h"
|
||
#include "UI/NavigationToolHeader.h"
|
||
#include "UI/TransformInspector.h"
|
||
#include "UI/NewCanvasSheet.h"
|
||
#include "UI/ToolHeaderStyle.h"
|
||
#include "UI/ToolIcons.h"
|
||
#include <QCheckBox>
|
||
#include <QDragEnterEvent>
|
||
#include <QDropEvent>
|
||
#include <QFrame>
|
||
#include <QLocale>
|
||
#include <QPainter>
|
||
#include <QProgressBar>
|
||
#include <QMouseEvent>
|
||
#include <QScrollArea>
|
||
#include <QSettings>
|
||
#include <cmath>
|
||
#include <map>
|
||
|
||
namespace {
|
||
const NavigationTool railTools[] = {NavigationTool::move, NavigationTool::marquee, NavigationTool::lasso, NavigationTool::wand,
|
||
NavigationTool::crop, NavigationTool::brush, NavigationTool::spotHealing, NavigationTool::cloneStamp,
|
||
NavigationTool::blur, NavigationTool::gradient, NavigationTool::shape, NavigationTool::type,
|
||
NavigationTool::eyedropper, NavigationTool::hand, NavigationTool::zoom};
|
||
|
||
// Swift's Eyedropper bar: its title and the ring's checkbox.
|
||
ToolHeaderBar *eyedropperBar(EditorSession &session, QWidget *parent)
|
||
{
|
||
auto *bar = new ToolHeaderBar(QStringLiteral("Eyedropper"), parent);
|
||
auto *ring = new QCheckBox(QStringLiteral("Sample Ring"), bar);
|
||
ring->setObjectName(QStringLiteral("sampleRing"));
|
||
ring->setChecked(session.showsSampleRing());
|
||
QObject::connect(ring, &QCheckBox::toggled, &session, &EditorSession::setShowsSampleRing);
|
||
// It follows the session, which others may set.
|
||
QObject::connect(&session, &EditorSession::changed, ring, [ring, &session] {
|
||
const QSignalBlocker quiet(ring);
|
||
ring->setChecked(session.showsSampleRing());
|
||
});
|
||
bar->row->setSpacing(16);
|
||
bar->row->insertWidget(1, ring);
|
||
return bar;
|
||
}
|
||
}
|
||
|
||
// A rail button: its tool's icon; a plate when chosen.
|
||
class ToolButton : public QToolButton {
|
||
public:
|
||
ToolButton(NavigationTool tool, QWidget *parent) : QToolButton(parent), m_tool(tool)
|
||
{
|
||
setCheckable(true);
|
||
setFixedSize(36, 36);
|
||
setFocusPolicy(Qt::NoFocus);
|
||
setToolTip(label(tool));
|
||
setAccessibleName(label(tool));
|
||
}
|
||
|
||
protected:
|
||
void paintEvent(QPaintEvent *) override
|
||
{
|
||
QPainter painter(this);
|
||
painter.setRenderHint(QPainter::Antialiasing);
|
||
QColor ink = palette().color(QPalette::WindowText);
|
||
if (isChecked()) {
|
||
QColor plate = ink, edge = ink;
|
||
plate.setAlphaF(0.12);
|
||
edge.setAlphaF(0.14);
|
||
painter.setPen(edge);
|
||
painter.setBrush(plate);
|
||
painter.drawRoundedRect(QRectF(0.5, 0.5, 35, 35), 7, 7);
|
||
}
|
||
ToolIcons::paint(painter, m_tool, QPointF(9, 9), ToolIcons::points, ink, m_kind);
|
||
}
|
||
|
||
public:
|
||
// The Marquee's and the Lasso's icons follow their kinds.
|
||
void setKind(ToolIconKind kind)
|
||
{
|
||
if (kind == m_kind)
|
||
return;
|
||
m_kind = kind;
|
||
update();
|
||
}
|
||
|
||
private:
|
||
const NavigationTool m_tool;
|
||
ToolIconKind m_kind = ToolIconKind::plain;
|
||
};
|
||
|
||
namespace {
|
||
// Swift's PanelResizeEdge: a divider with an 8-point grip.
|
||
class PanelResizeEdge : public QWidget {
|
||
public:
|
||
PanelResizeEdge(LayersPanel &panel, QWidget *parent) : QWidget(parent), m_panel(panel)
|
||
{
|
||
setObjectName(QStringLiteral("layersPanelEdge"));
|
||
setFixedWidth(8);
|
||
setCursor(Qt::SplitHCursor);
|
||
setToolTip(QStringLiteral("Drag to resize the panel"));
|
||
}
|
||
|
||
protected:
|
||
void paintEvent(QPaintEvent *) override
|
||
{
|
||
QPainter painter(this);
|
||
painter.fillRect(QRect(width() / 2, 0, 1, height()), palette().color(QPalette::Mid));
|
||
}
|
||
void mousePressEvent(QMouseEvent *event) override
|
||
{
|
||
if (event->button() == Qt::LeftButton)
|
||
m_start = std::pair(event->globalPosition().x(), double(m_panel.width()));
|
||
}
|
||
void mouseMoveEvent(QMouseEvent *event) override
|
||
{
|
||
if (!m_start)
|
||
return;
|
||
// Dragged left, the panel widens; whole points, within range.
|
||
const double wanted = std::round(m_start->second - (event->globalPosition().x() - m_start->first));
|
||
const double width = std::min(LayersPanel::maximumWidth, std::max(LayersPanel::minimumWidth, wanted));
|
||
if (width == m_panel.width())
|
||
return;
|
||
m_panel.setFixedWidth(int(width));
|
||
ContentView::setLayersPanelWidth(width);
|
||
}
|
||
void mouseReleaseEvent(QMouseEvent *) override { m_start = std::nullopt; }
|
||
|
||
private:
|
||
LayersPanel &m_panel;
|
||
std::optional<std::pair<double, double>> m_start;
|
||
};
|
||
|
||
// Swift's overlay: a 3-point accent ring inside the canvas.
|
||
class DropRing : public QWidget {
|
||
public:
|
||
explicit DropRing(QWidget *parent) : QWidget(parent)
|
||
{
|
||
setObjectName(QStringLiteral("dropRing"));
|
||
setAttribute(Qt::WA_TransparentForMouseEvents);
|
||
hide();
|
||
}
|
||
|
||
protected:
|
||
void paintEvent(QPaintEvent *) override
|
||
{
|
||
QPainter painter(this);
|
||
painter.setRenderHint(QPainter::Antialiasing);
|
||
painter.setPen(QPen(palette().color(QPalette::Highlight), 3));
|
||
painter.setBrush(Qt::NoBrush);
|
||
painter.drawRoundedRect(QRectF(rect()).adjusted(4.5, 4.5, -4.5, -4.5), 8, 8);
|
||
}
|
||
};
|
||
|
||
QFrame *divider(QFrame::Shape shape, QWidget *parent)
|
||
{
|
||
auto *line = new QFrame(parent);
|
||
line->setFrameShape(shape);
|
||
line->setFrameShadow(QFrame::Plain);
|
||
line->setForegroundRole(QPalette::Mid);
|
||
return line;
|
||
}
|
||
|
||
QLabel *statusText(QLabel *label)
|
||
{
|
||
QFont font = label->font();
|
||
font.setPixelSize(11);
|
||
label->setFont(font);
|
||
label->setForegroundRole(QPalette::PlaceholderText);
|
||
return label;
|
||
}
|
||
|
||
// A row's widest item shrinks first; this ends in dots.
|
||
class ElidedLabel : public QLabel {
|
||
public:
|
||
using QLabel::QLabel;
|
||
|
||
protected:
|
||
void paintEvent(QPaintEvent *) override
|
||
{
|
||
// A widget's painter starts with its foreground role's pen.
|
||
QPainter painter(this);
|
||
painter.drawText(rect(), Qt::AlignRight | Qt::AlignVCenter, fontMetrics().elidedText(text(), Qt::ElideRight, width()));
|
||
}
|
||
};
|
||
}
|
||
|
||
ContentView::ContentView(EditorSession &session, ProjectController *projects, QWidget *parent)
|
||
: QWidget(parent), m_session(session), m_projects(projects), m_column(new QVBoxLayout(this)), m_canvasSlot(new QGridLayout),
|
||
m_canvas(new CanvasView(session, this)), m_layersPanel(new LayersPanel(session, this)), m_dock(new QWidget(this)), m_dropRing(new DropRing(this)), m_zoom(statusText(new QLabel(this))), m_dimensions(statusText(new QLabel(this))), m_colour(statusText(new QLabel(this))),
|
||
m_activity(statusText(new ElidedLabel(this))),
|
||
m_spinner(new QProgressBar(this))
|
||
{
|
||
setMinimumSize(800, 520);
|
||
m_column->setContentsMargins(0, 0, 0, 0);
|
||
m_column->setSpacing(0);
|
||
|
||
auto *tools = new QWidget(this);
|
||
auto *toolColumn = new QVBoxLayout(tools);
|
||
// Room for the palette's overhang; the tools stay centred.
|
||
toolColumn->setContentsMargins(7, 16, 7, 12);
|
||
toolColumn->setSpacing(10);
|
||
for (const NavigationTool tool : railTools) {
|
||
auto *button = new ToolButton(tool, tools);
|
||
// A refused click stays silent: set the buttons again.
|
||
connect(button, &QToolButton::clicked, this, [this, tool] {
|
||
m_session.selectTool(tool);
|
||
synchronize();
|
||
});
|
||
toolColumn->addWidget(button, 0, Qt::AlignHCenter);
|
||
m_toolButtons.push_back({tool, button});
|
||
}
|
||
// Swift's 8 points above the swatches, less their overhang.
|
||
toolColumn->addSpacing(5);
|
||
toolColumn->addWidget(new ColorPaletteControls(session, tools), 0, Qt::AlignHCenter);
|
||
toolColumn->addStretch(1);
|
||
// Too short a window scrolls the rail, not the bars.
|
||
auto *rail = new QScrollArea(this);
|
||
rail->setObjectName(QStringLiteral("toolRail"));
|
||
rail->setWidget(tools);
|
||
rail->setWidgetResizable(true);
|
||
rail->setFixedWidth(56);
|
||
rail->setFrameShape(QFrame::NoFrame);
|
||
rail->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
|
||
rail->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
|
||
|
||
auto *canvas = new QWidget(this);
|
||
canvas->setLayout(m_canvasSlot);
|
||
// Swift's canvas fills its area: no layout gutter.
|
||
m_canvasSlot->setContentsMargins(0, 0, 0, 0);
|
||
// The welcome sits over the canvas, as Swift's ZStack.
|
||
m_canvasSlot->addWidget(m_canvas, 0, 0);
|
||
m_canvasSlot->addWidget(m_dropRing, 0, 0);
|
||
setAcceptDrops(true);
|
||
|
||
auto *middle = new QHBoxLayout;
|
||
middle->setSpacing(0);
|
||
middle->addWidget(rail);
|
||
middle->addWidget(divider(QFrame::VLine, this));
|
||
// The rulers frame the canvas above and left, Swift's stacks.
|
||
auto *framed = new QWidget(this);
|
||
auto *rulers = new QGridLayout(framed);
|
||
rulers->setContentsMargins(0, 0, 0, 0);
|
||
rulers->setSpacing(0);
|
||
m_rulers = {new CanvasRulerCorner(framed), new CanvasRulerView(session, CanvasGuide::Axis::horizontal, *m_canvas, framed),
|
||
new CanvasRulerView(session, CanvasGuide::Axis::vertical, *m_canvas, framed)};
|
||
rulers->addWidget(m_rulers[0], 0, 0);
|
||
rulers->addWidget(m_rulers[1], 0, 1);
|
||
rulers->addWidget(m_rulers[2], 1, 0);
|
||
rulers->addWidget(canvas, 1, 1);
|
||
middle->addWidget(framed, 1);
|
||
m_layersPanel->setFixedWidth(int(layersPanelWidth()));
|
||
middle->addWidget(new PanelResizeEdge(*m_layersPanel, this));
|
||
middle->addWidget(m_layersPanel);
|
||
m_dock->setObjectName(QStringLiteral("panelDock"));
|
||
m_dock->setFixedWidth(FloatingPanel::dockedWidth);
|
||
auto *docked = new QVBoxLayout(m_dock);
|
||
docked->setContentsMargins(0, 0, 0, 0);
|
||
m_dock->hide();
|
||
middle->addWidget(m_dock);
|
||
|
||
m_zoom->setObjectName(QStringLiteral("zoomStatus"));
|
||
m_zoom->setFixedWidth(62);
|
||
m_dimensions->setObjectName(QStringLiteral("canvasDimensions"));
|
||
m_colour->setObjectName(QStringLiteral("colourStatus"));
|
||
m_activity->setObjectName(QStringLiteral("activityStatus"));
|
||
m_spinner->setRange(0, 0);
|
||
m_spinner->setTextVisible(false);
|
||
m_spinner->setFixedSize(36, 6);
|
||
auto *status = new QWidget(this);
|
||
status->setObjectName(QStringLiteral("statusBar"));
|
||
status->setFixedHeight(30);
|
||
auto *statusRow = new QHBoxLayout(status);
|
||
statusRow->setContentsMargins(18, 0, 18, 0);
|
||
statusRow->setSpacing(16);
|
||
statusRow->addWidget(m_zoom);
|
||
statusRow->addWidget(m_dimensions);
|
||
statusRow->addWidget(m_colour);
|
||
statusRow->addWidget(m_spinner, 0, Qt::AlignRight);
|
||
// The hint takes what is left, and shrinks first.
|
||
statusRow->addWidget(m_activity, 1);
|
||
|
||
m_column->addWidget(divider(QFrame::HLine, this));
|
||
m_column->addLayout(middle, 1);
|
||
m_column->addWidget(divider(QFrame::HLine, this));
|
||
m_column->addWidget(status);
|
||
|
||
// The filter panel follows the session by itself.
|
||
connect(&m_session, &EditorSession::changed, this, &ContentView::synchronize);
|
||
synchronize();
|
||
}
|
||
|
||
// ~QWidget closes the window after members go; stop listening first.
|
||
ContentView::~ContentView()
|
||
{
|
||
disconnect(&m_session, &EditorSession::changed, this, &ContentView::synchronize);
|
||
}
|
||
|
||
// Another tab's layer, files or a picture, while free.
|
||
bool ContentView::acceptsDrop(const QMimeData &data) const
|
||
{
|
||
if (!(data.hasFormat(ProjectWorkspace::layerType) || ImageFileDrop::holdsImages(data)))
|
||
return false;
|
||
// Swift's session guards; a workspace's are canSwitch's own.
|
||
if (!m_workspace)
|
||
return !m_session.levels() && !m_session.isProjectBusy() && !m_session.showsNewDocument() && !m_session.showsImporter()
|
||
&& !m_session.renamingLayerID();
|
||
return m_workspace->canSwitch() && m_workspace->canReceiveDrag(data, m_workspace->current().id);
|
||
}
|
||
|
||
void ContentView::dragEnterEvent(QDragEnterEvent *event)
|
||
{
|
||
if (!acceptsDrop(*event->mimeData()))
|
||
return;
|
||
event->setDropAction(Qt::CopyAction);
|
||
event->accept();
|
||
m_dropRing->raise();
|
||
m_dropRing->show();
|
||
}
|
||
|
||
void ContentView::dragMoveEvent(QDragMoveEvent *event)
|
||
{
|
||
if (!acceptsDrop(*event->mimeData())) {
|
||
event->ignore();
|
||
return;
|
||
}
|
||
event->setDropAction(Qt::CopyAction);
|
||
event->accept();
|
||
}
|
||
|
||
void ContentView::dragLeaveEvent(QDragLeaveEvent *)
|
||
{
|
||
m_dropRing->hide();
|
||
}
|
||
|
||
void ContentView::dropEvent(QDropEvent *event)
|
||
{
|
||
m_dropRing->hide();
|
||
if (!acceptsDrop(*event->mimeData()))
|
||
return;
|
||
event->setDropAction(Qt::CopyAction);
|
||
event->accept();
|
||
// Over the canvas the drop names a document point.
|
||
std::optional<QPointF> point;
|
||
const QPointF inCanvas = m_canvas->mapFrom(this, event->position().toPoint());
|
||
if (m_session.document() && m_canvas->rect().contains(inCanvas.toPoint()))
|
||
point = m_session.viewport.documentPoint(inCanvas, m_session.document()->size());
|
||
if (m_workspace)
|
||
m_workspace->receiveProviders(*event->mimeData(), m_workspace->current().id, point);
|
||
else
|
||
ImageFileDrop::importProviders(*event->mimeData(), m_session, point);
|
||
}
|
||
|
||
double ContentView::layersPanelWidth()
|
||
{
|
||
const double stored = QSettings().value(QStringLiteral("layersPanelWidth"), LayersPanel::defaultWidth).toDouble();
|
||
// A stored width outside the range is the default, logged.
|
||
if (stored >= LayersPanel::minimumWidth && stored <= LayersPanel::maximumWidth)
|
||
return stored;
|
||
qCWarning(lcApp) << "layersPanelWidth" << stored << "is out of range, using" << LayersPanel::defaultWidth;
|
||
return LayersPanel::defaultWidth;
|
||
}
|
||
|
||
void ContentView::setLayersPanelWidth(double width)
|
||
{
|
||
QSettings().setValue(QStringLiteral("layersPanelWidth"), width);
|
||
}
|
||
|
||
// Shows what the session holds: every line compares, then sets.
|
||
void ContentView::synchronize()
|
||
{
|
||
m_canvas->consumeFocusRequest(m_session.canvasFocusRequest());
|
||
m_canvas->synchronizeDisplay();
|
||
if (m_session.document() && !m_hadDocument)
|
||
QMetaObject::invokeMethod(&m_session, &EditorSession::requestCanvasFocus, Qt::QueuedConnection);
|
||
m_hadDocument = m_session.document().has_value();
|
||
showHeader(m_session.tool());
|
||
for (const auto &[tool, button] : m_toolButtons) {
|
||
button->setChecked(m_session.tool() == tool);
|
||
button->setKind(iconKind(tool));
|
||
}
|
||
const std::optional<CanvasDocument> &document = m_session.document();
|
||
showWelcome(!document);
|
||
for (QWidget *ruler : m_rulers)
|
||
ruler->setVisible(m_session.showsRulers() && document);
|
||
m_zoom->setVisible(document.has_value());
|
||
m_dimensions->setVisible(document.has_value());
|
||
if (document) {
|
||
const QLocale english(QLocale::English, QLocale::UnitedStates);
|
||
m_zoom->setText(percent(m_session.viewport.zoom()));
|
||
m_dimensions->setText(QStringLiteral("%1 × %2 px").arg(english.toString(document->width), english.toString(document->height)));
|
||
}
|
||
m_colour->setText(document ? QStringLiteral("sRGB · Transparent") : QStringLiteral("Ready when you are"));
|
||
m_spinner->setVisible(m_session.showsBusy() || m_session.isImporting());
|
||
m_activity->setText(m_session.showsBusy() ? QStringLiteral("Working…") : m_session.isImporting() ? QStringLiteral("Importing images…")
|
||
: hint(m_session.tool(), iconKind(m_session.tool()), m_session.blurMode(), m_session.shapeKind()));
|
||
showImporter();
|
||
showConversionSheet();
|
||
showRawDevelopSheet();
|
||
showAlert(m_importAlert, QStringLiteral("Import couldn’t finish"), m_session.importError(), [this] { m_session.setImportError(std::nullopt); });
|
||
showAlert(m_brushAlert, QStringLiteral("Couldn’t paint"), m_session.brushError(), [this] { m_session.setBrushError(std::nullopt); });
|
||
showAlert(m_cropAlert, QStringLiteral("Couldn’t crop"), m_session.cropError(), [this] { m_session.setCropError(std::nullopt); });
|
||
synchronizePanels();
|
||
}
|
||
|
||
// Swift's `if document == nil` makes a fresh sheet.
|
||
void ContentView::showWelcome(bool shown)
|
||
{
|
||
if (shown == (m_welcome != nullptr))
|
||
return;
|
||
delete m_welcome;
|
||
m_welcome = nullptr;
|
||
if (!shown)
|
||
return;
|
||
m_welcome = new NewCanvasSheet(
|
||
m_session, [this](int width, int height) { m_session.createNewProject(width, height); },
|
||
[this] {
|
||
if (m_projects)
|
||
m_projects->open();
|
||
},
|
||
this);
|
||
m_canvasSlot->addWidget(m_welcome, 0, 0, Qt::AlignCenter);
|
||
m_welcome->show();
|
||
}
|
||
|
||
void ContentView::showHeader(NavigationTool tool)
|
||
{
|
||
// Shared bars: Hand and Zoom, selection tools, brush tools.
|
||
const auto kind = [](NavigationTool each) {
|
||
return each == NavigationTool::hand || each == NavigationTool::zoom ? NavigationTool::hand
|
||
: isSelectionTool(each) ? NavigationTool::lasso
|
||
: isBrushTool(each) ? NavigationTool::brush
|
||
: each;
|
||
};
|
||
if (m_shownTool && kind(*m_shownTool) == kind(tool))
|
||
return;
|
||
m_shownTool = tool;
|
||
delete m_header;
|
||
// A tool without a bar yet keeps the bar's height.
|
||
if (tool == NavigationTool::idle)
|
||
m_header = new ToolHeaderBar(QStringLiteral("Select a tool"), this);
|
||
else if (tool == NavigationTool::eyedropper)
|
||
m_header = eyedropperBar(m_session, this);
|
||
else if (kind(tool) == NavigationTool::hand)
|
||
m_header = new NavigationToolHeader(m_session, this);
|
||
else if (tool == NavigationTool::move)
|
||
m_header = new TransformInspector(m_session, this);
|
||
else if (isSelectionTool(tool))
|
||
m_header = new LassoControls(m_session, this);
|
||
else if (isBrushTool(tool))
|
||
m_header = new BrushControls(m_session, this);
|
||
else if (tool == NavigationTool::gradient)
|
||
m_header = new GradientControls(m_session, this);
|
||
else if (tool == NavigationTool::shape)
|
||
m_header = new ShapeControls(m_session, this);
|
||
else if (tool == NavigationTool::type)
|
||
m_header = new TypeControls(m_session, this);
|
||
else if (tool == NavigationTool::crop)
|
||
m_header = new CropControls(m_session, this);
|
||
else
|
||
m_header = new ToolHeaderBar(QString(), this);
|
||
m_header->setObjectName(QStringLiteral("toolHeader"));
|
||
m_column->insertWidget(0, m_header);
|
||
// A layout shows a late child only later: show now.
|
||
m_header->show();
|
||
}
|