14.20: the Navigator minimap from 300%

This commit is contained in:
ZacharyZhang-NY committed 2026-10-11 11:22:15 -04:00
1 parent bd4602fed9
commit 085b4dac6e
17 files changed
+733 -1

No files matched your search

+4
View File
@@ -12,6 +12,7 @@
#include "UI/TypeControls.h"
#include "UI/LassoControls.h"
#include "UI/LayerMaskMenu.h"
#include "UI/NavigatorMinimap.h"
#include "UI/NavigationToolHeader.h"
#include "UI/TransformInspector.h"
#include "UI/NewCanvasSheet.h"
@@ -244,6 +245,9 @@ ContentView::ContentView(EditorSession &session, ProjectController *projects, QW
m_canvasSlot->addWidget(m_dropRing, 0, 0);
// A layout takes no clicks; the canvas keeps the rest.
auto *foot = new QVBoxLayout;
// Swift's minimap sits 12 in, at the top right.
foot->setContentsMargins(12, 12, 12, 0);
foot->addWidget(new NavigatorMinimap(session, canvas), 0, Qt::AlignRight);
foot->addStretch(1);
foot->addWidget(m_maskAloneBadge, 0, Qt::AlignHCenter);
foot->addSpacing(14);
+54
View File
@@ -0,0 +1,54 @@
#include "Document/EditorSession.h"
#include "Document/BrushStroke.h"
#include "IO/ImageExporter.h"
#include "Logging.h"
#include <QPainter>
#include <cmath>
// Swift's EditorSession+Navigator.swift: the document in small.
namespace {
// Every layer and placed mask scaled; blurs shrink with them.
CanvasDocument placed(const CanvasDocument &document, double x, double y, int width, int height)
{
const auto scaled = [x, y](LayerTransform transform) {
transform.origin = QPointF(transform.origin.x() * x, transform.origin.y() * y);
transform.size = QSizeF(transform.size.width() * x, transform.size.height() * y);
return transform;
};
CanvasDocument small = document;
small.width = width;
small.height = height;
for (ImageLayer &layer : small.layers) {
layer.transform = scaled(layer.transform);
if (layer.mask && layer.mask->placement)
layer.mask->placement = scaled(*layer.mask->placement);
if (layer.adjustment && layer.adjustment->blurRadius)
layer.adjustment->blurRadius = *layer.adjustment->blurRadius * x;
if (layer.adjustment && layer.adjustment->motionDistance)
layer.adjustment->motionDistance = *layer.adjustment->motionDistance * x;
}
return small;
}
}
// At the picture's size: adjustment surfaces take what they draw.
std::optional<QImage> EditorSession::navigatorImage(int maxSide) const
{
if (!m_document || m_document->width <= 0 || m_document->height <= 0 || maxSide <= 0)
return std::nullopt;
const CanvasDocument &document = *m_document;
const double scale = std::min(1.0, double(maxSide) / std::max(document.width, document.height));
const int width = std::max(1, int(std::lround(document.width * scale))), height = std::max(1, int(std::lround(document.height * scale)));
QImage context;
try {
context = BrushRaster::context(width, height, false);
} catch (const ExportError &error) {
qCWarning(lcRendering) << "the navigator's picture could not be made:" << error.what();
return std::nullopt;
}
QPainter painter(&context);
// A bare session: edits in progress stay out.
EditorSession().drawLiveComposite(placed(document, double(width) / document.width, double(height) / document.height, width, height), painter);
painter.end();
return context;
}
+4
View File
@@ -0,0 +1,4 @@
// Swift's EditorSession+Navigator extension: the document in small.
public:
// Its longer side `maxSide` at most, never enlarged.
std::optional<QImage> navigatorImage(int maxSide) const;
+1
View File
@@ -313,6 +313,7 @@ public:
#include "Document/Guides+Session.h"
#include "Document/ObjectSelection+Session.h"
#include "Document/ColorRangeSelection+Session.h"
#include "Document/EditorSession+Navigator.h"
signals:
void changed();
+4
View File
@@ -32,6 +32,10 @@ public:
// The next stop past the zoom, else the zoom.
double keyboardZoomTarget(int step) const;
void translate(QSizeF by);
// Swift's NavigatorGeometry extension: what the canvas shows, in pixels.
QRectF visibleDocumentRect(QSizeF documentSize) const;
// `point` to the middle at the same zoom; Fit stops.
void centerView(QPointF point, QSizeF documentSize);
friend bool operator==(const CanvasViewport &lhs, const CanvasViewport &rhs);
private:
+49
View File
@@ -0,0 +1,49 @@
#include "Rendering/NavigatorGeometry.h"
#include <algorithm>
#include <cmath>
QRectF NavigatorGeometry::imageRect() const
{
if (!(documentSize.width() > 0 && documentSize.height() > 0 && box.width() > 0 && box.height() > 0))
return QRectF();
const double scale = std::min(box.width() / documentSize.width(), box.height() / documentSize.height());
const QSizeF size = documentSize * scale;
return QRectF(QPointF((box.width() - size.width()) / 2, (box.height() - size.height()) / 2), size);
}
double NavigatorGeometry::scale() const
{
return documentSize.width() > 0 ? imageRect().width() / documentSize.width() : 0;
}
QRectF NavigatorGeometry::thumbnailRect(QRectF documentRect) const
{
const QRectF image = imageRect();
const double scale = this->scale();
return QRectF(image.left() + documentRect.left() * scale, image.top() + documentRect.top() * scale, documentRect.width() * scale,
documentRect.height() * scale);
}
QPointF NavigatorGeometry::documentPoint(QPointF point) const
{
const double scale = this->scale();
if (!(scale > 0))
return QPointF();
const QRectF image = imageRect();
return QPointF(std::clamp((point.x() - image.left()) / scale, 0.0, documentSize.width()),
std::clamp((point.y() - image.top()) / scale, 0.0, documentSize.height()));
}
QRectF CanvasViewport::visibleDocumentRect(QSizeF documentSize) const
{
// The viewport never flips: Swift's min and abs are dead.
return QRectF(documentPoint(QPointF(0, 0), documentSize), documentPoint(QPointF(viewSize.width(), viewSize.height()), documentSize));
}
// Through translate, so the view stops following Fit.
void CanvasViewport::centerView(QPointF point, QSizeF documentSize)
{
const double scale = pointsPerPixel();
const QSizeF target(documentSize.width() * scale / 2 - point.x() * scale, documentSize.height() * scale / 2 - point.y() * scale);
translate(QSizeF(target.width() - pan.width(), target.height() - pan.height()));
}
+17
View File
@@ -0,0 +1,17 @@
#pragma once
#include "Rendering/CanvasViewport.h"
// The document fitted and centred in the Navigator's box.
struct NavigatorGeometry {
QSizeF documentSize;
QSizeF box;
QRectF imageRect() const;
QRectF thumbnailRect(QRectF documentRect) const;
// The document pixel under a box point, held inside.
QPointF documentPoint(QPointF point) const;
friend bool operator==(const NavigatorGeometry &, const NavigatorGeometry &) = default;
private:
// Box points per document pixel.
double scale() const;
};
+6
View File
@@ -2,6 +2,7 @@
#include "IO/RecentProjects.h"
#include "Logging.h"
#include "UI/KeyboardShortcuts.h"
#include "UI/NavigatorMinimap.h"
#include "UI/ProjectWorkspaceView.h"
#include <QApplication>
#include <QMenu>
@@ -142,6 +143,10 @@ CompositorMenus::CompositorMenus(ProjectWorkspace &workspace, QMenuBar &bar, Pro
};
add(view, QStringLiteral("zoomIn"), QStringLiteral("Zoom In"), QKeySequence(Qt::CTRL | Qt::Key_Equal), [zoomBy] { zoomBy(1); });
add(view, QStringLiteral("zoomOut"), QStringLiteral("Zoom Out"), QKeySequence(Qt::CTRL | Qt::Key_Minus), [zoomBy] { zoomBy(-1); });
add(view, QStringLiteral("navigator"), QStringLiteral("Navigator (300% and above)"), QKeySequence(), [this] {
NavigatorMinimap::setVisibleSetting(!NavigatorMinimap::visibleSetting());
session().notify();
})->setCheckable(true);
add(view, QStringLiteral("pixelGrid"), QStringLiteral("Pixel Grid (800% and above)"), QKeySequence(), [this] { session().setShowsPixelGrid(!session().showsPixelGrid()); })
->setCheckable(true);
// Ctrl+H: the Move tool's box; the desktop hides apps.
@@ -369,6 +374,7 @@ void CompositorMenus::synchronize()
action(QStringLiteral("redo"))->setEnabled(typing || s.canRedo());
for (const char *name : {"fit", "actualPixels", "zoomIn", "zoomOut"})
action(QString::fromLatin1(name))->setEnabled(drawn);
action(QStringLiteral("navigator"))->setChecked(NavigatorMinimap::visibleSetting());
action(QStringLiteral("pixelGrid"))->setChecked(s.showsPixelGrid());
action(QStringLiteral("toggleFullscreen"))->setChecked(s.canvasOnly());
action(QStringLiteral("toggleFullscreen"))->setEnabled(s.canToggleCanvasOnly());
+128
View File
@@ -0,0 +1,128 @@
#include "UI/NavigatorMinimap.h"
#include "Rendering/NavigatorGeometry.h"
#include <QMouseEvent>
#include <QPainter>
#include <QPainterPath>
#include <QSettings>
#include <cmath>
bool NavigatorMinimap::visibleSetting()
{
return QSettings().value(QLatin1String(visibleKey), false).toBool();
}
void NavigatorMinimap::setVisibleSetting(bool visible)
{
QSettings().setValue(QLatin1String(visibleKey), visible);
}
QSizeF NavigatorMinimap::sizeFor(QSizeF document)
{
if (!(document.width() > 0 && document.height() > 0))
return QSizeF(0, 0);
const double scale = std::min(largest.width() / document.width(), largest.height() / document.height());
return QSizeF(std::round(document.width() * scale), std::round(document.height() * scale));
}
NavigatorMinimap::RenderKey::RenderKey(const std::optional<CanvasDocument> &document, int revision)
: documentID(document ? document->id : QUuid()), size(document ? document->size() : QSizeF(0, 0)),
layers(document ? document->layers : std::vector<ImageLayer>()), revision(revision)
{
}
NavigatorMinimap::NavigatorMinimap(EditorSession &session, QWidget *parent) : QWidget(parent), m_session(session), m_wait(new QTimer(this))
{
setObjectName(QStringLiteral("navigatorMinimap"));
setAccessibleName(QStringLiteral("Navigator"));
setAccessibleDescription(QStringLiteral("Click or drag to move the view"));
setCursor(Qt::ArrowCursor);
m_wait->setObjectName(QStringLiteral("navigatorWait"));
m_wait->setSingleShot(true);
m_wait->setInterval(250);
m_wait->setTimerType(Qt::PreciseTimer);
connect(m_wait, &QTimer::timeout, this, &NavigatorMinimap::render);
connect(&session, &EditorSession::changed, this, &NavigatorMinimap::synchronize);
synchronize();
}
// Swift's ContentView condition, then its task(id:).
void NavigatorMinimap::synchronize()
{
const std::optional<CanvasDocument> &document = m_session.document();
const bool shown = visibleSetting() && !m_session.canvasOnly() && document && m_session.viewport.zoom() >= zoomShown;
setVisible(shown);
if (!shown) {
// SwiftUI drops the view and its picture with it.
m_picture = QImage();
m_key.reset();
m_wait->stop();
return;
}
setFixedSize(sizeFor(document->size()).toSize());
RenderKey key(document, m_session.brushRevision());
if (key != m_key) {
m_key = std::move(key);
if (m_picture.isNull())
render();
else
m_wait->start();
}
update();
}
void NavigatorMinimap::render()
{
m_picture = m_session.navigatorImage(picturePixels).value_or(QImage());
update();
}
void NavigatorMinimap::centre(QPointF point)
{
const QSizeF document = m_session.document().value().size();
const NavigatorGeometry geometry{document, sizeFor(document)};
m_session.viewport.centerView(geometry.documentPoint(point), document);
m_session.notify();
}
void NavigatorMinimap::mousePressEvent(QMouseEvent *event)
{
if (event->button() == Qt::LeftButton)
centre(event->position());
}
void NavigatorMinimap::mouseMoveEvent(QMouseEvent *event)
{
if (event->buttons() & Qt::LeftButton)
centre(event->position());
}
void NavigatorMinimap::paintEvent(QPaintEvent *)
{
QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing);
const QRectF bounds(rect());
QPainterPath shape;
shape.addRoundedRect(bounds, 4, 4);
painter.save();
painter.setClipPath(shape);
painter.fillRect(bounds, QColor::fromRgbF(0.11f, 0.11f, 0.11f));
if (!m_picture.isNull()) {
painter.setRenderHint(QPainter::SmoothPixmapTransform);
painter.drawImage(bounds, m_picture);
}
// The view's box in the accent, inside its rect.
const QSizeF document = m_session.document().value().size();
const NavigatorGeometry geometry{document, bounds.size()};
const QRectF shown = geometry.thumbnailRect(m_session.viewport.visibleDocumentRect(document)).intersected(geometry.imageRect());
if (!shown.isEmpty()) {
const QRectF box(shown.topLeft(), QSizeF(std::max(3.0, shown.width()), std::max(3.0, shown.height())));
painter.setPen(QPen(palette().color(QPalette::Active, QPalette::Highlight), 2.5));
painter.setBrush(Qt::NoBrush);
painter.drawRect(box.adjusted(1.25, 1.25, -1.25, -1.25));
}
painter.restore();
// A white rim at 25%, inside the rounded edge.
painter.setPen(QPen(QColor::fromRgbF(1, 1, 1, 0.25f), 1));
painter.setBrush(Qt::NoBrush);
painter.drawRoundedRect(bounds.adjusted(0.5, 0.5, -0.5, -0.5), 3.5, 3.5);
}
+50
View File
@@ -0,0 +1,50 @@
#pragma once
#include "Document/EditorSession.h"
#include <QTimer>
#include <QWidget>
// Swift's NavigatorMinimap: the document in small, from 300%.
class NavigatorMinimap : public QWidget {
Q_OBJECT
public:
static constexpr double zoomShown = 3;
// The most room the document takes, in points.
static constexpr QSizeF largest{126, 90};
// Pixels on the picture's longer side.
static constexpr int picturePixels = 300;
// Swift's AppStorage key; off until View › Navigator.
static constexpr const char *visibleKey = "navigator.visible";
static bool visibleSetting();
static void setVisibleSetting(bool visible);
// Fitted inside `largest`, its proportions kept, rounded.
static QSizeF sizeFor(QSizeF document);
// What the picture comes from: layers, not the selection.
struct RenderKey {
QUuid documentID;
QSizeF size;
std::vector<ImageLayer> layers;
int revision = 0;
RenderKey(const std::optional<CanvasDocument> &document, int revision);
friend bool operator==(const RenderKey &, const RenderKey &) = default;
};
explicit NavigatorMinimap(EditorSession &session, QWidget *parent = nullptr);
void synchronize();
const QImage &picture() const { return m_picture; }
protected:
void paintEvent(QPaintEvent *event) override;
void mousePressEvent(QMouseEvent *event) override;
void mouseMoveEvent(QMouseEvent *event) override;
private:
void render();
void centre(QPointF point);
EditorSession &m_session;
QImage m_picture;
std::optional<RenderKey> m_key;
// Edits settle first: the picture redraws once, afterwards.
QTimer *const m_wait;
};