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 @@ -72,6 +72,8 @@ add_library(omasnap-core STATIC
src/scroll-inject.hpp
src/startup-timing.cpp
src/startup-timing.hpp
src/stroke-smoothing.cpp
src/stroke-smoothing.hpp
${PROTOCOL_SOURCES}
src/editor.cpp
src/editor.hpp
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/stroke-smoothing-smoke.cpp
tests/stroke-smoothing-smoke.hpp
src/cli-path.cpp
src/recent-snaps.cpp
src/recent-snaps.hpp
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -358,7 +358,7 @@ without reaching for the pointer.
| `A` | Arrow |
| `S` | Spotlight/loupe; press again to cycle ellipse, rectangle, rounded |
| `L` | Straight line |
| `F` | Freehand stroke |
| `F` | Freehand pen; medium `3/6` smoothing by default, while `0/6` preserves the raw pointer path |
| `H` | Highlighter; Snap mode uses a mouse-following I-beam at the nearby text height, then locks the drag straight to that row. Press `H` again (or click the active toolbar button) for Normal freehand mode, where wheel or `Alt`+wheel changes thickness; Snap keeps detected-row height automatic and wheel sets only its off-text fallback |
| `I` | Eyedropper in the color popover · sample the image as the custom color |
| `C` | Numbered marker |
Expand All @@ -373,7 +373,7 @@ without reaching for the pointer.
| `Shift+B` | Toggle the screenshot card's drop shadow; on by default |
| `G` / `Shift+G` | Cycle canvas boundaries forward/backward: Framed, Overflow, Image. Framed auto-grows with the normal frame; Overflow grows only the sides needed by annotations with no frame; Image clips at the original screenshot edge |
| `1`–`8` | Set annotation color; `7` is black and `8` is white |
| Wheel | Scale selected layer, magnify the spotlight under the cursor, or change active tool size (`Alt`+wheel: rectangle corner radius or spotlight border); while just viewing a zoomed capture, scroll it like a document |
| Wheel | Scale selected layer, magnify the spotlight under the cursor, or change active tool size (`Alt`+wheel: selected pen smoothing from 0–6, the next pen's smoothing when none is selected, rectangle corner radius, or spotlight border); while just viewing a zoomed capture, scroll it like a document |
| `Shift`+wheel | Scroll a zoomed capture sideways (a wide stitch); never changes the zoom |
| `Ctrl`+wheel · middle-drag | Zoom about the cursor · pan by dragging |
| `+` / `-` / `0` (also with `Ctrl`) | Zoom in / out / fit |
Expand Down
8 changes: 8 additions & 0 deletions docs/threading.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,3 +130,11 @@ earlier in the same call.
See also [editing-model.md](editing-model.md) for what state a background
render is allowed to read, and [dependencies.md](dependencies.md) for the
processes (`tesseract`, `wl-copy`/`wl-paste`, `hyprctl`) these workers spawn.

## Pen smoothing budget

Release-time smoothing bounds the iterative RDP pass to 32,768 point-to-segment
comparisons over at most 2,048 samples. When that budget runs out, unexamined
spans retain their samples; no quadratic scan continues on the input thread.
At most three Chaikin passes then produce 16,384 points. The initial arc-length
resampling remains linear in the raw stroke length.
36 changes: 31 additions & 5 deletions src/capture.cpp
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/** @fileoverview Captures, renders, saves, and shares screenshots. */
#include "capture.hpp"
#include "stroke-smoothing.hpp"
#include "output-config.hpp"
#include "startup-timing.hpp"

Expand Down Expand Up @@ -461,12 +462,22 @@ void drawAnnotation(QPainter &painter, const Annotation &annotation) {
if (annotation.points.size() < 2)
return;
QPainterPath stroke(annotation.points.first());
for (int index = 1; index + 1 < annotation.points.size(); ++index) {
const QPointF midpoint =
(annotation.points.at(index) + annotation.points.at(index + 1)) / 2.0;
stroke.quadTo(annotation.points.at(index), midpoint);
if (annotation.kind == Annotation::Kind::Freehand) {
// Release-time Chaikin points already describe the curve. Connecting
// them directly matches its geometry in preview, hit-testing and export
// instead of applying a second, unrelated quadratic approximation.
for (qsizetype index = 1; index < annotation.points.size(); ++index)
stroke.lineTo(annotation.points.at(index));
} else {
for (int index = 1; index + 1 < annotation.points.size(); ++index) {
const QPointF midpoint =
(annotation.points.at(index) + annotation.points.at(index + 1)) /
2.0;
stroke.quadTo(annotation.points.at(index), midpoint);
}
if (annotation.points.size() > 1)
stroke.lineTo(annotation.points.last());
}
stroke.lineTo(annotation.points.last());
painter.setBrush(Qt::NoBrush);
if (annotation.kind == Annotation::Kind::Highlighter) {
QColor ink = annotation.color;
Expand Down Expand Up @@ -1693,6 +1704,14 @@ QJsonObject annotationToJson(const Annotation &annotation) {
points.push_back(pointArray(point));
object.insert(QStringLiteral("points"), points);
}
if (annotation.kind == Annotation::Kind::Freehand) {
QJsonArray rawPoints;
for (const QPointF &point : annotation.rawPoints)
rawPoints.push_back(pointArray(point));
object.insert(QStringLiteral("rawPoints"), rawPoints);
object.insert(QStringLiteral("smoothingLevel"),
annotation.smoothingLevel);
}
if (annotation.kind == Annotation::Kind::Redaction) {
object.insert(QStringLiteral("redactionStyle"),
annotation.redactionStyle == RedactionStyle::Solid
Expand Down Expand Up @@ -1742,6 +1761,13 @@ bool annotationFromJson(const QJsonObject &object, Annotation &annotation,
annotation.points.clear();
for (const QJsonValue point : object.value(QStringLiteral("points")).toArray())
annotation.points.push_back(pointFromArray(point));
annotation.rawPoints.clear();
for (const QJsonValue point :
object.value(QStringLiteral("rawPoints")).toArray())
annotation.rawPoints.push_back(pointFromArray(point));
annotation.smoothingLevel = std::clamp(
object.value(QStringLiteral("smoothingLevel")).toInt(0),
stroke::minimumSmoothingLevel, stroke::maximumSmoothingLevel);
const QString redactionStyle =
object.value(QStringLiteral("redactionStyle")).toString();
annotation.redactionStyle = redactionStyle == QStringLiteral("solid")
Expand Down
4 changes: 4 additions & 0 deletions src/capture.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,10 @@ struct Annotation {
/// Typeface is a layer property so reopened and duplicated labels keep it.
TextFont textFont = TextFont::Neucha;
quint64 id = 0;
/// Raw pointer geometry retained so smoothing changes never compound.
QVector<QPointF> rawPoints{};
/// Pen post-stroke smoothing level (0--6); unused by other layer kinds.
int smoothingLevel = 0;

bool operator==(const Annotation &) const = default;
};
Expand Down
167 changes: 130 additions & 37 deletions src/editor.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -289,11 +289,16 @@ qreal strokeHitTolerance(const Annotation &annotation) {
}
void translateAnnotation(Annotation &annotation, const QPointF &delta) {
annotation.start += delta;
if (hasEndpointHandles(annotation.kind))
if (hasEndpointHandles(annotation.kind) ||
annotation.kind == Annotation::Kind::Freehand)
annotation.end += delta;
if (isStrokeKind(annotation.kind)) {
for (QPointF &point : annotation.points)
point += delta;
if (annotation.kind == Annotation::Kind::Freehand) {
for (QPointF &point : annotation.rawPoints)
point += delta;
}
}
}

Expand Down Expand Up @@ -1203,14 +1208,19 @@ void CaptureEditor::applyBoxResize(Annotation &annotation, Interaction handle,
const qreal scaleY =
(original.height() > 0 ? box.height() / original.height() : 1.0) *
(flippedY ? -1.0 : 1.0);
for (int index = 0; index < annotation.points.size(); ++index) {
const QPointF relative =
originalAnnotation_.points.at(index) - original.topLeft();
const QPointF anchor(scaleX < 0 ? box.right() : box.left(),
scaleY < 0 ? box.bottom() : box.top());
annotation.points[index] =
anchor + QPointF(relative.x() * scaleX, relative.y() * scaleY);
}
const QPointF anchor(scaleX < 0 ? box.right() : box.left(),
scaleY < 0 ? box.bottom() : box.top());
const auto resizePoints = [&](QVector<QPointF> &resized,
const QVector<QPointF> &source) {
resized.resize(source.size());
for (qsizetype index = 0; index < source.size(); ++index) {
const QPointF relative = source.at(index) - original.topLeft();
resized[index] =
anchor + QPointF(relative.x() * scaleX, relative.y() * scaleY);
}
};
resizePoints(annotation.points, originalAnnotation_.points);
resizePoints(annotation.rawPoints, originalAnnotation_.rawPoints);
if (!annotation.points.isEmpty()) {
annotation.start = annotation.points.first();
annotation.end = annotation.points.last();
Expand Down Expand Up @@ -1310,9 +1320,14 @@ QString CaptureEditor::toolStatus() const {
return QStringLiteral("Cut · drag a band to remove it");
case Tool::Highlighter:
return highlighterStatus();
case Tool::Freehand:
return QStringLiteral("Pen · size %1 · smoothing %2/%3 · wheel size · "
"Alt+wheel smoothing")
.arg(size)
.arg(freehandSmoothingLevel_)
.arg(stroke::maximumSmoothingLevel);
case Tool::Arrow:
case Tool::Line:
case Tool::Freehand:
break;
}
const QString name = tool_ == Tool::Arrow ? QStringLiteral("Arrow")
Expand Down Expand Up @@ -1510,11 +1525,11 @@ bool CaptureEditor::adjustSelectedAnnotationRing(int step) {
// Alt+wheel is the secondary control, and with a layer selected it belongs
// to that layer rather than to what the next one will look like. Kinds with
// no second setting say so, and the wheel falls through to the armed tool.
beginSelectionAdjust();
if (selectedAnnotation_ < 0 || selectedAnnotation_ >= annotations_.size())
return false;
Annotation &annotation = annotations_[selectedAnnotation_];
if (annotation.kind == Annotation::Kind::Rectangle) {
beginSelectionAdjust();
// The selected rectangle's own corners, undoably; the armed tool's
// default radius stays what it was.
annotation.cornerRadius =
Expand All @@ -1525,12 +1540,38 @@ bool CaptureEditor::adjustSelectedAnnotationRing(int step) {
commitPatch({selectedAnnotation_});
return true;
}
if (annotation.kind != Annotation::Kind::Spotlight)
if (annotation.kind == Annotation::Kind::Spotlight) {
beginSelectionAdjust();
annotation.size =
std::clamp(annotation.size + step * 2.0, 0.0, 12.0);
setStatus(spotlightStatus(annotation.spotlightShape,
annotation.magnification, annotation.size));
commitPatch({selectedAnnotation_});
return true;
}
if (annotation.kind != Annotation::Kind::Freehand)
return false;
annotation.size = std::clamp(annotation.size + step * 2.0, 0.0, 12.0);
setStatus(spotlightStatus(annotation.spotlightShape,
annotation.magnification, annotation.size));
commitPatch({selectedAnnotation_});

beginSelectionAdjust();
if (annotation.rawPoints.isEmpty()) {
setStatus(QStringLiteral("Pen stroke has no smoothing baseline"));
return true;
}
const int next = std::clamp(annotation.smoothingLevel + step,
stroke::minimumSmoothingLevel,
stroke::maximumSmoothingLevel);
setStatus(QStringLiteral("Pen smoothing %1/%2 · Alt+wheel adjusts · "
"Ctrl+Z undoes")
.arg(next)
.arg(stroke::maximumSmoothingLevel));
if (next != annotation.smoothingLevel) {
annotation.smoothingLevel = next;
annotation.points =
stroke::smoothFreehand(annotation.rawPoints, annotation.smoothingLevel);
annotation.start = annotation.points.first();
annotation.end = annotation.points.last();
commitPatch({selectedAnnotation_});
}
return true;
}

Expand Down Expand Up @@ -2240,8 +2281,11 @@ CaptureEditor::toolbarButtons(QVector<qreal> *groupDividers,
QStringLiteral("Line · L · Shift snaps 45° · Size %1 · Wheel")
.arg(qRound(annotationSize_)));
add(36, QStringLiteral("tool-freehand"), {},
QStringLiteral("Freehand · F · Size %1 · Wheel")
.arg(qRound(annotationSize_)));
QStringLiteral("Freehand · F · Size %1 · Wheel · Smoothing %2/%3 · "
"Alt+Wheel")
.arg(qRound(annotationSize_))
.arg(freehandSmoothingLevel_)
.arg(stroke::maximumSmoothingLevel));
add(36, QStringLiteral("tool-highlighter"), {}, highlighterTooltip());
add(36, QStringLiteral("tool-marker"), {},
QStringLiteral("Number marker · C · Size %1 · Wheel")
Expand Down Expand Up @@ -2723,6 +2767,8 @@ void CaptureEditor::replayLog() {
shift(annotation.end);
for (QPointF &point : annotation.points)
shift(point);
for (QPointF &point : annotation.rawPoints)
shift(point);
}
if (band > 0.0) {
if (horizontal)
Expand Down Expand Up @@ -4264,6 +4310,8 @@ void CaptureEditor::mouseMoveEvent(QMouseEvent *event) {
if (isStrokeKind(annotation.kind)) {
for (QPointF &point : annotation.points)
point += annotationDelta;
for (QPointF &point : annotation.rawPoints)
point += annotationDelta;
if (!annotation.points.isEmpty()) {
annotation.start = annotation.points.first();
annotation.end = annotation.points.last();
Expand Down Expand Up @@ -4293,13 +4341,7 @@ void CaptureEditor::mouseMoveEvent(QMouseEvent *event) {
const int index = selectedAnnotations_.at(position);
Annotation &annotation = annotations_[index];
annotation = originalSelectedAnnotations_.at(position);
annotation.start += delta;
if (hasEndpointHandles(annotation.kind))
annotation.end += delta;
if (isStrokeKind(annotation.kind)) {
for (QPointF &strokePoint : annotation.points)
strokePoint += delta;
}
translateAnnotation(annotation, delta);
}
dragChanged_ = true;
} else {
Expand Down Expand Up @@ -4331,12 +4373,22 @@ void CaptureEditor::mouseMoveEvent(QMouseEvent *event) {
? std::max<qreal>(0.05, (point.y() - originalBounds.top()) /
originalBounds.height())
: 1.0;
for (int index = 0; index < annotation.points.size(); ++index) {
const QPointF relative =
originalAnnotation_.points.at(index) - originalBounds.topLeft();
annotation.points[index] =
originalBounds.topLeft() +
QPointF(relative.x() * scaleX, relative.y() * scaleY);
const auto resizePoints = [&](QVector<QPointF> &resized,
const QVector<QPointF> &source) {
resized.resize(source.size());
for (qsizetype index = 0; index < source.size(); ++index) {
const QPointF relative =
source.at(index) - originalBounds.topLeft();
resized[index] =
originalBounds.topLeft() +
QPointF(relative.x() * scaleX, relative.y() * scaleY);
}
};
resizePoints(annotation.points, originalAnnotation_.points);
resizePoints(annotation.rawPoints, originalAnnotation_.rawPoints);
if (!annotation.points.isEmpty()) {
annotation.start = annotation.points.first();
annotation.end = annotation.points.last();
}
} else if (annotation.kind == Annotation::Kind::Marker) {
annotation.size = std::clamp(
Expand Down Expand Up @@ -5020,8 +5072,21 @@ void CaptureEditor::mouseReleaseEvent(QMouseEvent *event) {
creationConstraintActive_ = false;
creationCenteredActive_ = false;
if (tool_ == Tool::Freehand || tool_ == Tool::Highlighter) {
if (freehandPoints_.isEmpty() ||
QLineF(freehandPoints_.last(), end).length() >= 1.0)
if (tool_ == Tool::Freehand) {
if (freehandPoints_.isEmpty())
freehandPoints_.push_back(end);
else if (freehandPoints_.size() == 1 ||
QLineF(freehandPoints_.last(), end).length() >= 0.001)
freehandPoints_.push_back(end);
else
freehandPoints_.last() = end;
// Live smoothing intentionally trails slow input. The post-stroke pass
// starts from that cleaner trace, but the visible gesture must still
// begin and finish exactly under the pointer.
freehandPoints_.first() = dragStart_;
freehandPoints_.last() = end;
} else if (freehandPoints_.isEmpty() ||
QLineF(freehandPoints_.last(), end).length() >= 1.0)
freehandPoints_.push_back(end);
qreal length = 0;
for (int index = 1; index < freehandPoints_.size(); ++index)
Expand All @@ -5038,11 +5103,26 @@ void CaptureEditor::mouseReleaseEvent(QMouseEvent *event) {
annotation.size = highlighter && highlighterLock_
? highlighterLock_->annotationSize
: annotationSize_;
annotation.points = std::move(freehandPoints_);
if (highlighter) {
annotation.points = std::move(freehandPoints_);
} else {
// Preserve exact endpoints and raw intermediate samples so level zero
// is truly unsmoothed and later level changes never compound.
annotation.rawPoints = std::move(freehandPoints_);
annotation.smoothingLevel = freehandSmoothingLevel_;
annotation.points = stroke::smoothFreehand(annotation.rawPoints,
annotation.smoothingLevel);
annotation.start = annotation.points.first();
annotation.end = annotation.points.last();
}
selectedAnnotation_ = -1;
setStatus(highlighter ? highlighterStatus()
: QStringLiteral("Stroke added · Esc for select "
"mode"));
setStatus(
highlighter
? highlighterStatus()
: QStringLiteral("Stroke added · smoothing %1/%2 · Esc for "
"select mode")
.arg(annotation.smoothingLevel)
.arg(stroke::maximumSmoothingLevel));
commitAnnotate(std::move(annotation));
}
freehandPoints_.clear();
Expand Down Expand Up @@ -5231,6 +5311,13 @@ void CaptureEditor::wheelEvent(QWheelEvent *event) {
.arg(annotationTextFontName(textFont_))
.arg(QString::fromLatin1(kTextSizeNames.at(
static_cast<std::size_t>(textSizeIndex_)))));
} else if (tool_ == Tool::Freehand && !layerSelected &&
modifiers.testFlag(Qt::AltModifier)) {
freehandSmoothingLevel_ =
std::clamp(freehandSmoothingLevel_ + step,
stroke::minimumSmoothingLevel,
stroke::maximumSmoothingLevel);
setStatus(toolStatus());
} else if (tool_ == Tool::Spotlight &&
event->modifiers().testFlag(Qt::AltModifier)) {
// Alt+wheel is the spotlight's secondary control: the ring around the
Expand Down Expand Up @@ -6722,6 +6809,12 @@ void CaptureEditor::paintEdit(QPainter &painter) {
tooltip = QStringLiteral("Ellipse · %1 · Size %2 · Scroll wheel")
.arg(fillName(fillShapes_))
.arg(qRound(annotationSize_));
} else if (tool_ == Tool::Freehand) {
tooltip = QStringLiteral("Size %1 · Scroll wheel · Smoothing %2/%3 "
"· Alt+Wheel")
.arg(qRound(annotationSize_))
.arg(freehandSmoothingLevel_)
.arg(stroke::maximumSmoothingLevel);
} else {
tooltip = QStringLiteral("Size %1 · Scroll wheel")
.arg(qRound(annotationSize_));
Expand Down
Loading
Loading