Skip to content
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -366,7 +366,7 @@ without reaching for the pointer.
| `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 |
| `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 |
| `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; long text wraps at the current canvas edge by default, while moving it or dragging its width handle beyond that edge expands the canvas; 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 |
| `B` | Cycle shadowed colors, window gray (shadowed and flat), and Off |
Expand Down
74 changes: 65 additions & 9 deletions src/capture.cpp
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
/** @fileoverview Captures, renders, saves, and shares screenshots. */
#include <QTextLayout>
#include <QTextOption>
#include "capture.hpp"
#include "output-config.hpp"
#include "startup-timing.hpp"
Expand Down Expand Up @@ -67,13 +69,62 @@ QFont annotationTextFont(qreal size, TextFont textFont) {
return font;
}

QRectF annotationTextBounds(const Annotation &annotation) {
qreal annotationTextWrapWidth(const Annotation &annotation,
qreal canvasWidth) {
if (annotation.textWidth > 0.0)
return annotation.textWidth;
if (canvasWidth <= 0.0)
return 0.0;
// Room left before the right edge. Narrower than this and the text would be
// wrapping to a sliver, so leave it on one line and let it run.
const qreal room = canvasWidth - annotation.start.x();
return room >= kMinimumTextWrapWidth ? room : 0.0;
}

QStringList annotationTextLines(const Annotation &annotation,
qreal canvasWidth) {
const QStringList paragraphs = annotation.text.split('\n');
const qreal wrap = annotationTextWrapWidth(annotation, canvasWidth);
if (wrap <= 0.0)
return paragraphs;
QStringList lines;
for (const QString &paragraph : paragraphs) {
if (paragraph.isEmpty()) {
lines.push_back(paragraph);
continue;
}
QTextLayout layout(paragraph,
annotationTextFont(annotation.size, annotation.textFont));
QTextOption option;
option.setWrapMode(QTextOption::WrapAtWordBoundaryOrAnywhere);
layout.setTextOption(option);
layout.beginLayout();
while (true) {
QTextLine line = layout.createLine();
if (!line.isValid())
break;
line.setLineWidth(wrap);
lines.push_back(paragraph.mid(line.textStart(), line.textLength()));
}
layout.endLayout();
}
return lines;
}

QRectF annotationTextBounds(const Annotation &annotation,
qreal canvasWidth) {
const QFontMetricsF metrics(
annotationTextFont(annotation.size, annotation.textFont));
const QStringList lines = annotation.text.split('\n');
const QStringList lines = annotationTextLines(annotation, canvasWidth);
qreal widestLine = 0.0;
for (const QString &line : lines)
widestLine = std::max(widestLine, metrics.horizontalAdvance(line));
for (const QString &line : lines) {
// QTextLayout excludes trailing wrap whitespace from naturalTextWidth;
// keep indentation, but match that painted width for the pill.
QString visible = line;
while (!visible.isEmpty() && visible.back().isSpace())
visible.chop(1);
widestLine = std::max(widestLine, metrics.horizontalAdvance(visible));
}
const QRectF glyphs(
annotation.start.x(), annotation.start.y() - metrics.ascent(),
widestLine,
Expand Down Expand Up @@ -416,7 +467,8 @@ QVector<WindowTarget> parseWindows(const QByteArray &json,
return result;
}

void drawAnnotation(QPainter &painter, const Annotation &annotation) {
void drawAnnotation(QPainter &painter, const Annotation &annotation,
qreal canvasWidth = 0.0) {
// Redactions replace source pixels in renderCapture before ordinary vector
// annotations are painted. They must never be approximated by a translucent
// overlay here because that could leave recoverable source data in exports.
Expand Down Expand Up @@ -524,7 +576,7 @@ void drawAnnotation(QPainter &painter, const Annotation &annotation) {
if (annotation.textBackground == TextBackground::Pill) {
// A cream pill under the glyphs keeps text readable on any capture or
// shape beneath it (the default text background).
const QRectF pill = annotationTextBounds(annotation);
const QRectF pill = annotationTextBounds(annotation, canvasWidth);
const qreal radius = std::min(pill.height() / 4.0, 6.0);
painter.setPen(Qt::NoPen);
painter.setBrush(QColor(248, 245, 235));
Expand All @@ -534,7 +586,7 @@ void drawAnnotation(QPainter &painter, const Annotation &annotation) {
painter.setPen(annotation.color);
painter.setBrush(Qt::NoBrush);
const QFontMetricsF metrics(font);
const QStringList lines = annotation.text.split('\n');
const QStringList lines = annotationTextLines(annotation, canvasWidth);
if (annotation.textBackground == TextBackground::Outline) {
// A white halo whatever the color: screenshots are mostly light UI, where
// a dark halo reads as a drop shadow rather than a cut-out, and white
Expand Down Expand Up @@ -711,8 +763,9 @@ QRect pixelSelection(const CaptureData &capture, const QRectF &selection) {

} // namespace

void paintAnnotation(QPainter &painter, const Annotation &annotation) {
drawAnnotation(painter, annotation);
void paintAnnotation(QPainter &painter, const Annotation &annotation,
qreal canvasWidth) {
drawAnnotation(painter, annotation, canvasWidth);
}

QPainterPath spotlightPath(const Annotation &annotation) {
Expand Down Expand Up @@ -1680,6 +1733,8 @@ QJsonObject annotationToJson(const Annotation &annotation) {
object.insert(QStringLiteral("color"),
annotation.color.name(QColor::HexArgb));
object.insert(QStringLiteral("size"), annotation.size);
if (annotation.kind == Annotation::Kind::Text)
object.insert(QStringLiteral("textWidth"), annotation.textWidth);
if (!annotation.text.isEmpty())
object.insert(QStringLiteral("text"), annotation.text);
if (annotation.kind == Annotation::Kind::Text)
Expand Down Expand Up @@ -1736,6 +1791,7 @@ bool annotationFromJson(const QJsonObject &object, Annotation &annotation,
annotation.color = QColor(object.value(QStringLiteral("color")).toString());
annotation.size = object.value(QStringLiteral("size")).toDouble(4.0);
annotation.text = object.value(QStringLiteral("text")).toString();
annotation.textWidth = object.value(QStringLiteral("textWidth")).toDouble(0.0);
annotation.textFont = textFontFromStyleName(
object.value(QStringLiteral("textFont")).toString());
annotation.number = object.value(QStringLiteral("number")).toInt();
Expand Down
21 changes: 19 additions & 2 deletions src/capture.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@ struct Annotation {
/// Typeface is a layer property so reopened and duplicated labels keep it.
TextFont textFont = TextFont::Neucha;
quint64 id = 0;
/// Wrap width for text layers in image px; 0 leaves the layer unbounded.
qreal textWidth = 0.0;
Comment on lines +96 to +97

bool operator==(const Annotation &) const = default;
};
Expand Down Expand Up @@ -166,7 +168,21 @@ enum class AnnotationLayer { Redaction, Default };
bool includeWindows, QString &error);
/** Bounds of a text layer's glyph box, or of its readability pill when it
* has one; `start` is the baseline origin. */
[[nodiscard]] QRectF annotationTextBounds(const Annotation &annotation);
/// Narrowest wrap width worth producing; below this a line would be a sliver,
/// so the text stays on one line instead.
inline constexpr qreal kMinimumTextWrapWidth = 48.0;
/** Width a text layer wraps to: its own `textWidth`, else the room left
* before an optional right edge (`canvasWidth`, 0 = unbounded). The editor
* supplies that edge while typing, then stores an explicit width only when
* the draft actually wrapped. */
[[nodiscard]] qreal annotationTextWrapWidth(const Annotation &annotation,
qreal canvasWidth);
/** The display lines of a text layer: hard newlines first, then each of those
* word-wrapped to annotationTextWrapWidth(). */
[[nodiscard]] QStringList annotationTextLines(const Annotation &annotation,
qreal canvasWidth = 0.0);
[[nodiscard]] QRectF annotationTextBounds(const Annotation &annotation,
qreal canvasWidth = 0.0);
/**
* Pixel-aligned annotation space selected by `boundaryMode`. Grow contains
* every painted extent, Frame stops at the normal backdrop frame, and Image
Expand Down Expand Up @@ -252,7 +268,8 @@ void describeFileCapture(CaptureData &capture, QImage image,
[[nodiscard]] bool quickOutput(const QImage &image, QuickOutputMode mode,
QString &error);
[[nodiscard]] bool copyTextToClipboard(const QString &text, QString &error);
void paintAnnotation(QPainter &painter, const Annotation &annotation);
void paintAnnotation(QPainter &painter, const Annotation &annotation,
qreal canvasWidth = 0.0);
[[nodiscard]] QPainterPath spotlightPath(const Annotation &annotation);
void paintSpotlights(QPainter &painter, const QImage &source,
const QRectF &targetBounds, const QRectF &sourceRect,
Expand Down
Loading
Loading