Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ add_library(omasnap-core STATIC
src/recent-snaps.hpp
src/output-config.cpp
src/output-config.hpp
src/output-image.cpp
src/output-image.hpp
src/overlay-chrome.cpp
src/overlay-chrome.hpp
src/eyedropper.cpp
Expand Down Expand Up @@ -149,6 +151,8 @@ qt_add_executable(omasnap-smoke
tests/cut-mapping-smoke.hpp
tests/palette-config-smoke.cpp
tests/palette-config-smoke.hpp
tests/output-image-smoke.cpp
tests/output-image-smoke.hpp
src/cli-path.cpp
src/recent-snaps.cpp
src/recent-snaps.hpp
Expand Down
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,8 @@ directory = ~/Pictures/Captures
# Filename pattern, without extension (.png is appended).
# Default: screenshot-{date}_{time}-{app}
filename = screenshot-{date}_{time}-{app}
# Optional: Copy/Save at logical size on scaled monitors. Default: false.
logical_size = false

[colors]
# Up to eight preset colors for the palette, and the initial custom color.
Expand All @@ -294,6 +296,15 @@ image = ~/Pictures/backdrops/desk.jpg
default = custom
```

Set `[output] logical_size = true` to downscale Copy/Save output by the
capture's monitor scale. For example, a 600-pixel-wide capture at 2x becomes
300 pixels wide. Fractional results round to the nearest pixel, with a minimum
of one pixel per dimension. The complete rendered image, including annotations
and backdrops, is resized once at export. This also applies to quick output and
scrolling captures; native-resolution output remains the default. Editing,
OCR, pins, and the recents shelf retain their original resolution. Ordinary
image files without stored capture scale are not resized.

Filename tokens:

| Token | Expands to |
Expand Down
8 changes: 8 additions & 0 deletions docs/editing-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,14 @@ snapshot. Each of those calls `renderCapture` once, off the UI thread (see
[threading.md](threading.md)), and writes the result. Until one of those
happens, everything remains a log entry you can undo.

Copy/Save can optionally downscale this completed raster to logical size
(`output/logical_size`). Resizing runs after rendering, including redaction,
on the output worker. It never changes the working source, operation log,
OCR input, or pinned image; native-resolution exports remain the default.
Stitched captures keep their original monitor scale separately in the working
log's `outputScale` field because their editing coordinates are native pixels.
This makes logical-size export consistent after reopening them from recents.

## The two exceptions, and why they're still safe

Two operations *do* need to touch real pixels before export, and both are
Expand Down
6 changes: 6 additions & 0 deletions src/capture.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1916,6 +1916,8 @@ bool saveOperationLog(const QString &path, const OperationLog &log,
root.insert(QStringLiteral("index"), log.index);
root.insert(QStringLiteral("nextId"), QString::number(log.nextId));
root.insert(QStringLiteral("nextMarker"), log.nextMarker);
if (std::isfinite(log.outputScale) && log.outputScale > 0.0)
root.insert(QStringLiteral("outputScale"), log.outputScale);
if (log.previewSize.isValid()) {
root.insert(QStringLiteral("previewWidth"), log.previewSize.width());
root.insert(QStringLiteral("previewHeight"), log.previewSize.height());
Expand Down Expand Up @@ -1962,6 +1964,9 @@ bool loadOperationLog(const QString &path, OperationLog &log, QString &error) {
loaded.index = root.value(QStringLiteral("index")).toInt();
loaded.nextId = root.value(QStringLiteral("nextId")).toString().toULongLong();
loaded.nextMarker = root.value(QStringLiteral("nextMarker")).toInt(1);
const qreal outputScale = root.value(QStringLiteral("outputScale")).toDouble();
if (std::isfinite(outputScale) && outputScale > 0.0)
loaded.outputScale = outputScale;
loaded.previewSize =
QSize(root.value(QStringLiteral("previewWidth")).toInt(),
root.value(QStringLiteral("previewHeight")).toInt());
Expand Down Expand Up @@ -2058,6 +2063,7 @@ void describeFileCapture(CaptureData &capture, QImage image,
capture = CaptureData();
capture.previewSize = image.size();
capture.monitor.scale = 1.0;
capture.outputScale = log.outputScale;
if (log.previewSize.isValid() && !log.previewSize.isEmpty() &&
log.previewSize.width() <= image.width() &&
log.previewSize.height() <= image.height()) {
Expand Down
6 changes: 6 additions & 0 deletions src/capture.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ struct CaptureData {
/** Logical size the native source image is presented at. */
QSize previewSize;
QVector<WindowTarget> windows;
/** Original monitor scale for images edited in native-pixel coordinates
* (scroll stitches). Zero uses monitor.scale for ordinary captures. */
qreal outputScale = 0.0;
};

enum class BackgroundStyle {
Expand Down Expand Up @@ -130,6 +133,9 @@ struct OperationLog {
/// monitor reopens at the same scale. Invalid when unknown.
QSize previewSize;

/// Export scale override, independent of the operation coordinate space.
qreal outputScale = 0.0;

bool operator==(const OperationLog &) const = default;
};

Expand Down
18 changes: 13 additions & 5 deletions src/editor.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
#include "icons.hpp"
#include "eyedropper.hpp"
#include "output-config.hpp"
#include "output-image.hpp"
#include "overlay-chrome.hpp"
#include "palette-config.hpp"
#include "recent-snaps.hpp"
Expand Down Expand Up @@ -2438,6 +2439,7 @@ bool CaptureEditor::restoreOperationLog(const QString &path, QString &error) {
nextAnnotationId_ = std::max<quint64>(log.nextId, 1);
nextMarker_ = std::max(log.nextMarker, 1);
replayLog();
capture_.outputScale = log.outputScale;
phase_ = Phase::Edit;
scheduleSnapshot();
return true;
Expand Down Expand Up @@ -2842,7 +2844,7 @@ void CaptureEditor::startSnapshotRender() {
const QString path = snapshotPath_;
const QString logPath = operationLogPath(path);
const OperationLog log{ops_, opIndex_, nextAnnotationId_, nextMarker_,
pristineLogicalSize_};
pristineLogicalSize_, capture_.outputScale};
const bool writeSource = !sourceWritten_ || !QFile::exists(path);
snapshotWatcher_.setFuture(QtConcurrent::run(
[source, path, logPath, log, writeSource] {
Expand Down Expand Up @@ -3424,9 +3426,11 @@ void CaptureEditor::finish(OutputMode mode) {
backdrop, appSlug, mode]() {
FinishResult result;
result.mode = mode;
const QImage image = renderCapture(captureCopy, selection, annotations,
background, imageShadow,
canvasBoundary, backdrop);
const QImage image = prepareOutputImage(
renderCapture(captureCopy, selection, annotations, background,
imageShadow, canvasBoundary, backdrop),
captureCopy.outputScale > 0.0 ? captureCopy.outputScale
: captureCopy.monitor.scale);
if (!image.isNull())
result.thumbnail = image.scaled(kRecentThumbEdge, kRecentThumbEdge,
Qt::KeepAspectRatio,
Expand Down Expand Up @@ -5598,7 +5602,11 @@ void CaptureEditor::adoptStitched(const QImage &image) {
}
const bool veryLong = image.width() > stitch::kWidelyOpenableEdge ||
image.height() > stitch::kWidelyOpenableEdge;
adoptImage(image, OperationLog(), SelectTab::Scroll,
// Stitches are edited at 1:1 native pixels. Keep their origin scale only
// for optional logical-size exports, and persist it with the working log.
OperationLog log;
log.outputScale = liveMonitor_.scale;
adoptImage(image, log, SelectTab::Scroll,
veryLong
? QStringLiteral("Very long capture (%1 × %2) · edits and "
"saves here as usual, but many apps cannot "
Expand Down
4 changes: 3 additions & 1 deletion src/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
#include "editor.hpp"
#include "instance-lock.hpp"
#include "overlay-chrome.hpp"
#include "output-image.hpp"
#include "pin.hpp"
#include "recent-snaps.hpp"
#include "startup-timing.hpp"
Expand Down Expand Up @@ -377,7 +378,8 @@ int main(int argc, char **argv) {
: renderCapture(capture,
QRectF(QPointF(), capture.previewSize), {},
BackgroundStyle::None);
if (!quickOutput(output, quickOutputMode, outputError)) {
if (!quickOutput(prepareOutputImage(output, capture.monitor.scale),
quickOutputMode, outputError)) {
qCritical().noquote() << outputError;
return 1;
}
Expand Down
3 changes: 3 additions & 0 deletions src/output-config.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ OutputConfig loadOutputConfig(const QString &filePath) {
settings.value(QStringLiteral("output/filename")).toString().trimmed();
if (!filename.isEmpty())
config.filename = filename;
config.logicalSize =
settings.value(QStringLiteral("output/logical_size")).toString()
.trimmed().compare(QStringLiteral("true"), Qt::CaseInsensitive) == 0;
return config;
}

Expand Down
4 changes: 3 additions & 1 deletion src/output-config.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,11 @@ struct OutputConfig {
/** Filename pattern without extension. Tokens: `{date}` (yyyy-MM-dd),
* `{time}` (HH-mm-ss), `{app}` (slug of the app under the selection). */
QString filename = QStringLiteral("screenshot-{date}_{time}-{app}");
/** Copy/Save at logical size on scaled monitors; native pixels by default. */
bool logicalSize = false;
};

/** Reads [output] directory and [output] filename. A missing file or key
/** Reads [output] directory, filename, and logical_size. A missing file or key
* leaves the default untouched; `~` in directory expands to $HOME. */
[[nodiscard]] OutputConfig loadOutputConfig(const QString &filePath);

Expand Down
26 changes: 26 additions & 0 deletions src/output-image.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
#include "output-image.hpp"
#include "output-config.hpp"

#include <QImage>
#include <QSize>
#include <QtCore/qnumeric.h>
#include <QtCore/qtypes.h>
#include <QtCore/qnamespace.h>

#include <algorithm>
#include <cmath>

QImage prepareOutputImage(const QImage &image, qreal scale) {
if (image.isNull() || !std::isfinite(scale) || scale <= 1.0 ||
!loadOutputConfig(defaultConfigPath()).logicalSize)
return image;

// Scale the completed canvas, including backdrops and annotations. Never
// resample source pixels before redaction has destroyed sensitive content.
const QSize size(std::max(1, qRound(image.width() / scale)),
std::max(1, qRound(image.height() / scale)));
QImage output = image.scaled(size, Qt::IgnoreAspectRatio,
Qt::SmoothTransformation);
output.setDevicePixelRatio(1.0);
return output;
}
8 changes: 8 additions & 0 deletions src/output-image.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/** @fileoverview Final Copy/Save image sizing, after rendering and redaction. */
#pragma once

#include <QImage>

/** Reads output configuration and optionally downsizes a flattened image.
* Call on the output worker, never while painting or persisting source pixels. */
[[nodiscard]] QImage prepareOutputImage(const QImage &image, qreal scale);
5 changes: 5 additions & 0 deletions tests/editor-smoke.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
*/
#include "capture.hpp"
#include "output-config.hpp"
#include "output-image-smoke.hpp"
#include "cli-path.hpp"
#include "clipboard-smoke.hpp"
#include "cut-mapping-smoke.hpp"
Expand Down Expand Up @@ -8992,6 +8993,10 @@ int main(int argc, char **argv) {
}

QString instanceError;
if (!runOutputImageSmoke(application, instanceError)) {
qWarning().noquote() << instanceError;
return EXIT_FAILURE;
}
if (!runInstanceLockSmoke(instanceError)) {
qWarning().noquote() << instanceError;
return 85;
Expand Down
Loading
Loading