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: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ resizable vector layers and preserves the monitor's native pixels on scaled disp
mesh-gradient backdrops, and rendered drop shadows on standard backdrop cards.
- Cut tool: drag across a band of the image to remove it and collapse the gap, with a
live preview and dashed seam marker while dragging; annotations shift to follow.
Hold **Ctrl** (or **Ctrl+X**) to insert a transparent band instead; the toolbar
icon swaps to a split-plus while Ctrl is down.
- Pin a finished capture as a bottom-right always-on-top layer surface, launched
from the same `omasnap` executable and visible on every workspace.
- Crash-resistant working documents under `/run/user/<UID>/omasnap/` (falling back to
Expand Down Expand Up @@ -361,7 +363,7 @@ without reaching for the pointer.
| `R` | Rectangle; hover the shape button for rectangle, ellipse, and fill controls; `Alt`+wheel rounds corners |
| `E` | Ellipse; shares the shape submenu and filled/hollow toggle |
| `D` | Redact; press again to toggle randomized pixelation or solid redaction |
| `X` | Cut out a band; drag to preview the crossed-out strip, then release to remove and collapse it |
| `X` | Cut out a band; drag to preview the crossed-out strip, then release to remove and collapse it. **Ctrl** (with Cut armed, or **Ctrl+X**) inserts a band instead; the cut icon becomes a split-plus |
| `T` | Text on a cream readability pill, with Neucha as the default. Click for a one-line label, or drag a box to give it room for several lines: Enter moves to the next line while there is room and commits on the last one; `Shift+Enter` always adds a line; `Esc` commits too but keeps the label selected, so `Backspace` removes it; clicking away keeps the text; press T again to toggle the pill |
| `Shift+T` | Cycle the next or selected text through Neucha, JetBrains Mono, and Inter Display |
| `O` | Recognize and copy all text in the current image |
Expand Down
7 changes: 4 additions & 3 deletions docs/editing-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,10 @@ Two operations *do* need to touch real pixels before export, and both are
still fully undoable because of how they're kept in the log:

- **Cut** (`Operation::Type::Cut`) removes a band of the image and shifts
everything after it. The working image (`capture_.source`) after a cut is
`composeCuts(pristineSource_, cuts_)` — recomputed from the untouched
original plus the list of cut ops every time the list changes
everything after it, or **inserts** a transparent band (`cut.insert`) and
shifts everything after the seam out. The working image (`capture_.source`)
after a cut is `composeCuts(pristineSource_, cuts_)` — recomputed from the
untouched original plus the list of cut ops every time the list changes
(`CaptureEditor::refreshComposedCapture()`). Undo a cut and the composed
image is rebuilt without it; `pristineSource_` was never modified.
- **Redaction** exists to permanently destroy sensitive content, so it is
Expand Down
3 changes: 3 additions & 0 deletions src/capture.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1821,6 +1821,8 @@ QJsonObject operationToJson(const Operation &operation) {
object.insert(QStringLiteral("sourceEnd"), operation.cut.sourceEnd);
object.insert(QStringLiteral("logicalStart"), operation.cut.logicalStart);
object.insert(QStringLiteral("logicalEnd"), operation.cut.logicalEnd);
if (operation.cut.insert)
object.insert(QStringLiteral("insert"), true);
break;
}
return object;
Expand Down Expand Up @@ -1894,6 +1896,7 @@ bool operationFromJson(const QJsonObject &object, Operation &operation,
object.value(QStringLiteral("logicalStart")).toInt();
operation.cut.logicalEnd =
object.value(QStringLiteral("logicalEnd")).toInt();
operation.cut.insert = object.value(QStringLiteral("insert")).toBool();
return true;
}
error = QStringLiteral("Operation log has an unknown operation type");
Expand Down
72 changes: 66 additions & 6 deletions src/cut.cpp
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
/** @fileoverview Band cut-out engine: removes a horizontal or vertical band
* from an image and collapses the gap (Snagit-style "cut out", ported from
* omapic). */
/** @fileoverview Band cut engine: removes a horizontal or vertical band and
* collapses the gap, or inserts transparent space at the seam. */
#include "cut.hpp"

#include <QPainter>
Expand Down Expand Up @@ -44,27 +43,82 @@ QImage removeBand(const QImage &source, Qt::Orientation orientation,
return out;
}

QImage insertBand(const QImage &source, Qt::Orientation orientation,
int start, int end) {
if (source.isNull())
return source;
const int extent =
orientation == Qt::Horizontal ? source.height() : source.width();
start = std::clamp(start, 0, extent);
end = std::clamp(end, start, extent);
const int band = end - start;
Comment on lines +50 to +54
if (band <= 0)
return source;
QImage image = source;
if (image.format() != QImage::Format_ARGB32 &&
image.format() != QImage::Format_ARGB32_Premultiplied)
image = image.convertToFormat(QImage::Format_ARGB32_Premultiplied);
if (orientation == Qt::Horizontal) {
const int height = image.height();
QImage out(image.width(), height + band, image.format());
out.setDevicePixelRatio(image.devicePixelRatio());
out.fill(Qt::transparent);
for (int y = 0; y < start; ++y)
std::memcpy(out.scanLine(y), image.constScanLine(y), image.bytesPerLine());
for (int y = start; y < height; ++y)
std::memcpy(out.scanLine(y + band), image.constScanLine(y),
image.bytesPerLine());
return out;
}
const int width = image.width();
QImage out(width + band, image.height(), image.format());
out.setDevicePixelRatio(image.devicePixelRatio());
out.fill(Qt::transparent);
QPainter painter(&out);
painter.setCompositionMode(QPainter::CompositionMode_Source);
painter.drawImage(QPoint(0, 0), image, QRect(0, 0, start, image.height()));
painter.drawImage(QPoint(start + band, 0), image,
QRect(start, 0, width - start, image.height()));
return out;
}

QImage applyCutOp(const QImage &source, const CutOp &cut) {
return cut.insert ? insertBand(source, cut.orientation, cut.sourceStart,
cut.sourceEnd)
: removeBand(source, cut.orientation, cut.sourceStart,
cut.sourceEnd);
}

QImage composeCuts(const QImage &pristine, const QVector<CutOp> &cuts) {
QImage image = pristine;
for (const CutOp &cut : cuts)
image = removeBand(image, cut.orientation, cut.sourceStart, cut.sourceEnd);
image = applyCutOp(image, cut);
return image;
}

QSize composedLogicalSize(QSize pristineLogical, const QVector<CutOp> &cuts) {
for (const CutOp &cut : cuts) {
const int band = cut.logicalEnd - cut.logicalStart;
if (band <= 0)
continue;
if (cut.insert) {
if (cut.orientation == Qt::Horizontal)
pristineLogical.setHeight(pristineLogical.height() + band);
else
pristineLogical.setWidth(pristineLogical.width() + band);
continue;
}
if (cut.orientation == Qt::Horizontal) {
const int extent = pristineLogical.height();
// Mirror removeBand()'s no-op guard so this can never disagree with
// what composeCuts() actually produces: an empty or full-extent band
// leaves the image unchanged.
if (band <= 0 || band >= extent)
if (band >= extent)
continue;
pristineLogical.setHeight(std::max(1, extent - band));
} else {
const int extent = pristineLogical.width();
if (band <= 0 || band >= extent)
if (band >= extent)
continue;
pristineLogical.setWidth(std::max(1, extent - band));
}
Expand All @@ -79,3 +133,9 @@ qreal shiftForCut(qreal value, qreal start, qreal end) {
return value - (end - start);
return start;
}

qreal shiftForInsert(qreal value, qreal start, qreal size) {
if (size <= 0.0 || value < start)
return value;
return value + size;
}
13 changes: 13 additions & 0 deletions src/cut.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ struct CutOp {
int sourceEnd = 0;
int logicalStart = 0;
int logicalEnd = 0;
bool insert = false;
bool operator==(const CutOp &) const = default;
};

Expand All @@ -28,6 +29,14 @@ struct CutOp {
Qt::Orientation orientation, int start,
int end);

/** Inserts a transparent band `[start, end)` and shifts the rest out. An
* empty band is a no-op. */
[[nodiscard]] QImage insertBand(const QImage &source,
Qt::Orientation orientation, int start,
int end);

[[nodiscard]] QImage applyCutOp(const QImage &source, const CutOp &cut);

/** Replays `cuts` in order over the pristine image. */
[[nodiscard]] QImage composeCuts(const QImage &pristine,
const QVector<CutOp> &cuts);
Expand All @@ -39,3 +48,7 @@ struct CutOp {
/** Shifts one coordinate for a removed band [start, end): values past the
* band shift back by the band size, values inside clamp to the seam. */
[[nodiscard]] qreal shiftForCut(qreal value, qreal start, qreal end);

/** Shifts one coordinate for an inserted band of `size` at `start`: values
* on or past the seam move out by `size`. */
[[nodiscard]] qreal shiftForInsert(qreal value, qreal start, qreal size);
Loading