diff --git a/README.md b/README.md index 68f1edec..755ce3cf 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/src/capture.cpp b/src/capture.cpp index 8d43355d..0d7dd85e 100644 --- a/src/capture.cpp +++ b/src/capture.cpp @@ -1,4 +1,6 @@ /** @fileoverview Captures, renders, saves, and shares screenshots. */ +#include +#include #include "capture.hpp" #include "output-config.hpp" #include "startup-timing.hpp" @@ -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 ¶graph : 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, @@ -416,7 +467,8 @@ QVector 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. @@ -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)); @@ -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 @@ -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) { @@ -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) @@ -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(); diff --git a/src/capture.hpp b/src/capture.hpp index 108b0b4e..e239ba15 100644 --- a/src/capture.hpp +++ b/src/capture.hpp @@ -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; bool operator==(const Annotation &) const = default; }; @@ -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 @@ -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, diff --git a/src/editor.cpp b/src/editor.cpp index 1c0a9aaf..6cc7b396 100644 --- a/src/editor.cpp +++ b/src/editor.cpp @@ -36,6 +36,8 @@ #include #include #include +#include +#include #include #include #include @@ -60,11 +62,61 @@ class InlineTextEdit final : public QPlainTextEdit { setFrameShape(QFrame::NoFrame); setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - setWordWrapMode(QTextOption::NoWrap); + // Wrap like the committed text will: at word boundaries, anywhere within + // a word too long to fit. The width clamp decides when wrapping bites. + setLineWrapMode(QPlainTextEdit::NoWrap); document()->setDocumentMargin(0); } using QPlainTextEdit::cursorRect; using QPlainTextEdit::setViewportMargins; + + void setLogicalWrap(Annotation annotation, qreal canvasWidth) { + logicalText_ = std::move(annotation); + canvasWidth_ = canvasWidth; + applyLogicalWrap(); + } + +protected: + void resizeEvent(QResizeEvent *event) override { + QPlainTextEdit::resizeEvent(event); + applyLogicalWrap(); + } + +private: + void applyLogicalWrap() { + if (!logicalText_ || applyingWrap_) + return; + applyingWrap_ = true; + const QFontMetricsF metrics(font()); + auto *plainLayout = static_cast(document()->documentLayout()); + for (QTextBlock block = document()->begin(); block.isValid(); block = block.next()) { + plainLayout->ensureBlockLayout(block); + Annotation paragraph = *logicalText_; + paragraph.text = block.text(); + const QStringList lines = annotationTextLines(paragraph, canvasWidth_); + QTextLayout *layout = block.layout(); + QTextOption option = layout->textOption(); + option.setWrapMode(QTextOption::WrapAnywhere); + layout->setTextOption(option); + layout->beginLayout(); + int row = 0; + for (const QString &text : lines) { + QTextLine line = layout->createLine(); + if (!line.isValid()) + break; + line.setNumColumns(text.size()); + line.setPosition(QPointF(0, row++ * metrics.lineSpacing())); + } + layout->endLayout(); + block.setLineCount(std::max(1, layout->lineCount())); + } + plainLayout->requestUpdate(); + applyingWrap_ = false; + } + + std::optional logicalText_; + qreal canvasWidth_ = 0; + bool applyingWrap_ = false; }; namespace { @@ -3093,13 +3145,34 @@ void CaptureEditor::ensureTextEditor() { widestLine, metrics.horizontalAdvance(line + QStringLiteral(" "))); const int sidePadding = textEditPill_ ? qRound(std::max(4.0, metrics.height() * 0.18)) : 0; - const int desiredWidth = std::max(48, widestLine + sidePadding * 2); - const int availableWidth = - std::max(48, qRound(editImageRect().right() - textEditor_->x())); - const int lineCount = std::max(1, static_cast(lines.size())); + const qreal remaining = canvasRect_.right() - textPoint_.x(); + const int desiredWidth = textEditWrapWidth_ > 0.0 + ? std::max(1, qRound(textEditWrapWidth_ * editScale())) + sidePadding * 2 + : std::max(48, widestLine + sidePadding * 2); + // Match the committed layout's image-space minimum. A draft with too + // little room stays unbounded and grows the canvas when committed. + const int width = textEditWrapWidth_ <= 0.0 && + remaining >= kMinimumTextWrapWidth + ? std::min(desiredWidth, + qRound(remaining * editScale()) + sidePadding * 2) + : desiredWidth; + textEditor_->resize(width, textEditor_->height()); + Annotation logical; + logical.kind = Annotation::Kind::Text; + logical.start = textPoint_; + logical.size = textSize_; + logical.textFont = textEditFont_; + logical.textWidth = textEditWrapWidth_; + textEditor_->setLogicalWrap(logical, canvasRect_.right()); + // QPlainTextEdit needs a little more than QFontMetrics::height(): its + // block layout keeps leading/descent outside the nominal line box. + // Wrapped lines are not the newline count either, so the laid-out + // document is the only thing that knows how tall the draft is now. + const int wrapped = + std::max(1, qRound(textEditor_->document()->size().height())); const int desiredHeight = - lineCount * metrics.lineSpacing() + metrics.descent() + 4; - textEditor_->resize(std::min(desiredWidth, availableWidth), desiredHeight); + wrapped * metrics.lineSpacing() + metrics.descent() + 4; + textEditor_->resize(width, desiredHeight); textEditor_->verticalScrollBar()->setValue(0); QTimer::singleShot(0, textEditor_, [editor = textEditor_] { editor->verticalScrollBar()->setValue(0); @@ -3142,8 +3215,8 @@ void CaptureEditor::beginText(const QPointF &point, int annotationIndex, const qreal scale = editScale(); const QPointF position = sourceFrame.topLeft() + textPoint_ * scale; QFont displayFont = annotationTextFont(textSize_, textEditFont_); - displayFont.setPixelSize( - std::max(12, qRound(displayFont.pixelSize() * scale))); + displayFont.setPointSizeF(displayFont.pixelSize() * scale * 72.0 / + logicalDpiY()); const QFontMetrics metrics(displayFont); textEditor_->setFont(displayFont); textLineCapacity_ = std::max(1, lineCapacity); @@ -3154,6 +3227,12 @@ void CaptureEditor::beginText(const QPointF &point, int annotationIndex, : textBackground_; const bool pill = background == TextBackground::Pill; textEditPill_ = pill; + // Re-editing a wrapped layer keeps its width, so the draft breaks + // exactly where the committed text did. + textEditWrapWidth_ = + annotationIndex >= 0 && annotationIndex < annotations_.size() + ? annotations_.at(annotationIndex).textWidth + : 0.0; const int pillPad = pill ? qRound(std::max(4.0, metrics.height() * 0.18)) : 0; textEditor_->setStyleSheet( QStringLiteral( @@ -3190,6 +3269,28 @@ void CaptureEditor::acceptText(bool keepSelected) { annotation.color = textColor_; annotation.size = textSize_; annotation.textFont = textEditFont_; + // Text that wrapped at the current canvas edge freezes that shape on + // commit, as + // tight as its widest line, so moving the layer later never reflows the + // paragraph you just placed. The handle can still re-wrap it. + annotation.textWidth = textEditWrapWidth_; + if (annotation.textWidth <= 0.0) { + const QStringList wrapped = + annotationTextLines(annotation, canvasRect_.right()); + const qsizetype hardLineCount = annotation.text.count('\n') + 1; + if (wrapped.size() > hardLineCount) { + const QFontMetricsF metrics( + annotationTextFont(annotation.size, annotation.textFont)); + qreal widest = 0.0; + for (const QString &line : wrapped) { + QString visible = line; + while (!visible.isEmpty() && visible.back().isSpace()) + visible.chop(1); + widest = std::max(widest, metrics.horizontalAdvance(visible)); + } + annotation.textWidth = widest + 2.0; + } + } annotation.textBackground = editingAnnotation_ >= 0 && editingAnnotation_ < annotations_.size() ? annotations_.at(editingAnnotation_).textBackground @@ -4342,19 +4443,23 @@ void CaptureEditor::mouseMoveEvent(QMouseEvent *event) { annotation.size = std::clamp( QLineF(annotation.start, point).length() / 3.0, 2.0, 30.0); } else if (annotation.kind == Annotation::Kind::Text) { + // The handle sets the wrap width, which is what its horizontal + // cursor has always promised. Size belongs to the wheel, so the two + // are one gesture each rather than both scaling the layer. Its + // painted extent grows the canvas, so the handle remains reachable + // without being clamped back to the source frame. const QRectF originalBounds = annotationBounds(originalAnnotation_); - const qreal ratio = - originalBounds.width() > 0 - ? std::abs(point.x() - originalBounds.left()) / - originalBounds.width() - : 1.0; - annotation.size = - std::clamp(originalAnnotation_.size * ratio, 1.0, 24.0); - annotation.start.setY( - originalBounds.top() + - QFontMetricsF( - annotationTextFont(annotation.size, annotation.textFont)) - .ascent()); + const QFontMetricsF metrics(annotationTextFont( + annotation.size, annotation.textFont)); + const qreal padding = annotation.textBackground == TextBackground::Pill + ? std::max(4.0, metrics.height() * 0.18) + : 0.0; + annotation.textWidth = std::max( + kMinimumTextWrapWidth, + originalAnnotation_.textWidth > 0.0 + ? originalAnnotation_.textWidth + point.x() - dragStart_.x() + : originalBounds.width() - 2.0 * padding + + point.x() - dragStart_.x()); } } } @@ -4611,8 +4716,18 @@ void CaptureEditor::mousePressEvent(QMouseEvent *event) { } // Clicking away keeps whatever was typed; Enter belongs to multiline text. - if (textEditing()) + if (textEditing()) { + const bool committed = !textEditor_->toPlainText().trimmed().isEmpty(); acceptText(); + bool overToolbar = false; + for (const ToolbarButton &button : toolbarButtons()) + overToolbar = overToolbar || button.rect.contains(cursor_); + // Committing is the whole of that click: the text tool stays armed, but + // the click that put one text down must not also open the next where it + // landed. An empty editor committed nothing, so the click simply moves it. + if (committed && !overToolbar) + return; + } for (const ToolbarButton &button : toolbarButtons()) { if (button.rect.contains(cursor_)) { @@ -6570,10 +6685,26 @@ void CaptureEditor::paintEdit(QPainter &painter) { // a caret spanning the glyph box rather than the face's whole line height. const QRectF box = textEditor_->geometry(); if (textEditPill_) { - const qreal radius = std::min(box.height() / 4.0, 6.0); + // The widget keeps typing slack (a 48px floor plus room for the next + // glyph), so its geometry cannot shape the pill. Rebuild the committed + // pill's rect from the draft text instead, so nothing shifts on commit. + Annotation draft; + draft.kind = Annotation::Kind::Text; + draft.size = textSize_; + draft.textFont = textEditFont_; + draft.text = textEditor_->toPlainText(); + draft.textWidth = textEditWrapWidth_; + const QFontMetricsF metrics(annotationTextFont(textSize_, textEditFont_)); + draft.start = textPoint_ + QPointF(0, metrics.ascent()); + const QRectF pill = annotationTextBounds(draft, canvasRect_.right()); + painter.save(); + painter.translate(sourceFrameWidgetRect().topLeft()); + painter.scale(editScale(), editScale()); painter.setPen(Qt::NoPen); painter.setBrush(QColor(248, 245, 235)); - painter.drawRoundedRect(box, radius, radius); + const qreal radius = std::min(pill.height() / 4.0, 6.0); + painter.drawRoundedRect(pill, radius, radius); + painter.restore(); } if (textCaretOn_ && textEditor_->hasFocus()) { const QFontMetricsF metrics(textEditor_->font()); diff --git a/src/editor.hpp b/src/editor.hpp index 9f18fb1d..db51a25d 100644 --- a/src/editor.hpp +++ b/src/editor.hpp @@ -793,6 +793,9 @@ class CaptureEditor final : public QWidget { bool textEditPill_ = false; /// How many lines the current text entry has room for (see beginText). int textLineCapacity_ = 1; + /// Wrap width of the text being typed, in image px; 0 wraps at the canvas + /// edge. Carried onto the layer when the text is committed. + qreal textEditWrapWidth_ = 0.0; bool textCaretOn_ = true; QTimer textCaretTimer_; QElapsedTimer nudgeTimer_; diff --git a/tests/editor-smoke.cpp b/tests/editor-smoke.cpp index 02eb6f78..e35edfe2 100644 --- a/tests/editor-smoke.cpp +++ b/tests/editor-smoke.cpp @@ -20,6 +20,8 @@ #include "eyedropper.hpp" #include +#include +#include #include #include #include @@ -1829,6 +1831,394 @@ bool runNativeCaretHiddenCheck(QApplication &application, QString &error) { return true; } +/** The draft's cream pill sits exactly where the committed pill lands. */ +bool runDraftPillStaysPutCheck(QApplication &application, QString &error) { + CaptureData capture; + capture.monitor.name = QStringLiteral("TEST"); + capture.monitor.geometry = {0, 0, 800, 600}; + capture.monitor.pixelSize = {800, 600}; + capture.monitor.scale = 1.0; + capture.source = QImage(800, 600, QImage::Format_ARGB32_Premultiplied); + capture.source.fill(Qt::white); + capture.previewSize = capture.source.size(); + + CaptureEditor editor(capture); + editor.resize(800, 600); + editor.show(); + application.processEvents(); + QTest::mousePress(&editor, Qt::LeftButton, Qt::NoModifier, QPoint(100, 100)); + QTest::mouseMove(&editor, QPoint(650, 470), 20); + QTest::mouseRelease(&editor, Qt::LeftButton, Qt::NoModifier, QPoint(650, 470)); + application.processEvents(); + QTest::keyClick(&editor, Qt::Key_T); + QTest::mouseClick(&editor, Qt::LeftButton, Qt::NoModifier, QPoint(180, 300)); + application.processEvents(); + auto *draft = qobject_cast(QApplication::focusWidget()); + if (!draft) { + error = QStringLiteral("Text draft did not open for the pill check"); + return false; + } + QTest::keyClicks(draft, QStringLiteral("hug")); + application.processEvents(); + + // The pill is the only cream ink over a white capture, so its bounding box + // before and after the committing click is the whole contract: any drift + // means the background jumps when typing ends. + const auto creamBounds = [](const QImage &frame, const QRect ®ion) { + QRect bounds; + for (int y = region.top(); y <= region.bottom(); ++y) + for (int x = region.left(); x <= region.right(); ++x) { + if (!frame.rect().contains(x, y)) + continue; + const QColor color = frame.pixelColor(x, y); + if (std::abs(color.red() - 248) <= 3 && + std::abs(color.green() - 245) <= 3 && + std::abs(color.blue() - 235) <= 3) + bounds |= QRect(x, y, 1, 1); + } + return bounds; + }; + const QRect wholeCanvas(0, 0, 800, 600); + const QRect draftPill = creamBounds(editor.grab().toImage(), wholeCanvas); + QTest::mouseClick(&editor, Qt::LeftButton, Qt::NoModifier, QPoint(550, 200)); + application.processEvents(); + const QRect committedPill = + creamBounds(editor.grab().toImage(), wholeCanvas); + if (draftPill.isNull() || committedPill.isNull()) { + error = QStringLiteral("Could not find the cream pill in a frame"); + return false; + } + if (std::abs(draftPill.left() - committedPill.left()) > 1 || + std::abs(draftPill.top() - committedPill.top()) > 1 || + std::abs(draftPill.right() - committedPill.right()) > 1 || + std::abs(draftPill.bottom() - committedPill.bottom()) > 1) { + error = QStringLiteral("Pill moved on commit: draft %1,%2 %3x%4 vs %5,%6 %7x%8") + .arg(draftPill.x()).arg(draftPill.y()) + .arg(draftPill.width()).arg(draftPill.height()) + .arg(committedPill.x()).arg(committedPill.y()) + .arg(committedPill.width()).arg(committedPill.height()); + return false; + } + + // A draft typed toward the right edge wraps there while typing, and the + // committed pill lands exactly on the wrapped draft's pill. The scan region + // keeps the first annotation's pill out of the comparison. + QTest::mouseClick(&editor, Qt::LeftButton, Qt::NoModifier, QPoint(350, 355)); + application.processEvents(); + draft = qobject_cast(QApplication::focusWidget()); + if (!draft) { + error = QStringLiteral("Second text draft did not open"); + return false; + } + QTest::keyClicks( + draft, QStringLiteral("wrap wrap wrap wrap wrap wrap wrap wrap wrap")); + application.processEvents(); + const QRect wrapRegion(300, 330, 500, 230); + const QRect wrappedDraftPill = + creamBounds(editor.grab().toImage(), wrapRegion); + const QFontMetricsF wrapMetrics(draft->font()); + if (wrappedDraftPill.height() < wrapMetrics.lineSpacing() * 1.6) { + error = QStringLiteral("Draft did not wrap at the canvas edge while typing"); + return false; + } + QTest::mouseClick(&editor, Qt::LeftButton, Qt::NoModifier, QPoint(550, 150)); + application.processEvents(); + const QRect wrappedCommittedPill = + creamBounds(editor.grab().toImage(), wrapRegion); + if (std::abs(wrappedDraftPill.left() - wrappedCommittedPill.left()) > 1 || + std::abs(wrappedDraftPill.top() - wrappedCommittedPill.top()) > 1 || + std::abs(wrappedDraftPill.right() - wrappedCommittedPill.right()) > 1 || + std::abs(wrappedDraftPill.bottom() - wrappedCommittedPill.bottom()) > 1) { + error = QStringLiteral( + "Wrapped pill moved on commit: draft %1,%2 %3x%4 vs %5,%6 %7x%8") + .arg(wrappedDraftPill.x()).arg(wrappedDraftPill.y()) + .arg(wrappedDraftPill.width()).arg(wrappedDraftPill.height()) + .arg(wrappedCommittedPill.x()).arg(wrappedCommittedPill.y()) + .arg(wrappedCommittedPill.width()) + .arg(wrappedCommittedPill.height()); + return false; + } + return true; +} + +/** A text layer may grow past the source edge, where its real wrap handle + * remains reachable and can be dragged farther into the expanded canvas. */ +bool runTextOverrunHandleCheck(QApplication &application, QString &error) { + CaptureData capture; + capture.monitor.name = QStringLiteral("TEST"); + capture.monitor.geometry = {0, 0, 800, 600}; + capture.monitor.pixelSize = {800, 600}; + capture.monitor.scale = 1.0; + capture.source = QImage(800, 600, QImage::Format_ARGB32_Premultiplied); + capture.source.fill(QColor(QStringLiteral("#182030"))); + capture.previewSize = capture.source.size(); + + for (const int widgetWidth : {1000, 500}) { + CaptureEditor draft(capture, CaptureEditor::CaptureMode::Fullscreen); + draft.setSuppressSnapshots(true); + draft.resize(widgetWidth, 900); + draft.show(); + application.processEvents(); + QTest::keyClick(&draft, Qt::Key_T); + const QPoint at = draft.annotationPointToWidgetForTest(QPointF(775, 180)).toPoint(); + QTest::mouseClick(&draft, Qt::LeftButton, Qt::NoModifier, at); + auto *input = qobject_cast(QApplication::focusWidget()); + if (!input) { + error = QStringLiteral("Near-edge text fixture did not start a draft"); + return false; + } + QTest::keyClicks(input, QStringLiteral("near the right edge")); + application.processEvents(); + if (input->document()->size().height() > 1.0) { + error = QStringLiteral("An unbounded near-edge draft wrapped at a screen-pixel floor"); + return false; + } + QTest::keyClick(input, Qt::Key_Return, Qt::ControlModifier); + if (draft.currentAnnotationsForTest().size() != 1 || + draft.currentAnnotationsForTest().constFirst().textWidth != 0.0) { + error = QStringLiteral("Near-edge text changed its wrap rule on commit"); + return false; + } + } + + const QRectF selection(100, 100, 550, 370); + const qreal ascent = QFontMetricsF(annotationTextFont(5.0)).ascent(); + Annotation text; + text.kind = Annotation::Kind::Text; + text.start = QPointF(500, 145 + ascent); + text.text = QStringLiteral("wrap wrap wrap wrap wrap wrap wrap wrap wrap wrap"); + text.color = QColor(QStringLiteral("#ff375f")); + text.size = 5; + text.textWidth = 180.0; + text.textBackground = TextBackground::Plain; + text.id = 1; + OperationLog log; + Operation cropOp; + cropOp.type = Operation::Type::Crop; + cropOp.crop = selection; + log.ops.push_back(cropOp); + Operation annotate; + annotate.type = Operation::Type::Annotate; + annotate.annotations = {text}; + log.ops.push_back(std::move(annotate)); + log.index = log.ops.size(); + log.nextId = 2; + + for (const int width : {570, 420}) { + Annotation label = text; + label.start = {30, 90 + ascent}; + label.text = QStringLiteral("the quick brown fox jumps over the lazy dog"); + label.textBackground = TextBackground::Pill; + Operation op; + op.type = Operation::Type::Annotate; + op.annotations = {label}; + OperationLog fractionalLog{{op}, 1, 2, 1, capture.previewSize}; + CaptureEditor fractional(capture, CaptureEditor::CaptureMode::File, + QuickOutputMode::None, fractionalLog); + fractional.setSuppressSnapshots(true); + fractional.resize(width, 900); + fractional.show(); + application.processEvents(); + QTest::mouseClick(&fractional, Qt::LeftButton, Qt::NoModifier, + fractional.annotationPointToWidgetForTest( + annotationTextBounds(label).center()).toPoint()); + QTest::keyClick(&fractional, Qt::Key_Return); + auto *input = qobject_cast(QApplication::focusWidget()); + if (!input) { + error = QStringLiteral("Fractional wrapped text did not reopen"); + return false; + } + application.processEvents(); + QStringList draftLines; + for (QTextBlock block = input->document()->begin(); block.isValid(); block = block.next()) { + QTextLayout *layout = block.layout(); + for (int line = 0; line < layout->lineCount(); ++line) { + const QTextLine visual = layout->lineAt(line); + draftLines.push_back(block.text().mid(visual.textStart(), visual.textLength())); + } + } + if (draftLines != annotationTextLines(label) || input->toPlainText() != label.text) { + error = QStringLiteral("Fractional draft wrapping diverged from the logical layout"); + return false; + } + const auto cream = [](const QImage &image) { + QRect bounds; + for (int y = 0; y < image.height(); ++y) + for (int x = 0; x < image.width(); ++x) + if (image.pixelColor(x, y) == QColor(248, 245, 235)) + bounds |= QRect(x, y, 1, 1); + return bounds; + }; + const QRect draftPill = cream(fractional.grab().toImage()); + QTest::keyClick(input, Qt::Key_Return, Qt::ControlModifier); + application.processEvents(); + const QRect committedPill = cream(fractional.grab().toImage()); + if (draftPill.isEmpty() || + (draftPill.topLeft() - committedPill.topLeft()).manhattanLength() > 2 || + (draftPill.bottomRight() - committedPill.bottomRight()).manhattanLength() > 2) { + error = QStringLiteral("Fractional text pill moved on commit"); + return false; + } + } + + CaptureEditor editor(capture, CaptureEditor::CaptureMode::File, + QuickOutputMode::None, log); + editor.setSuppressSnapshots(true); + editor.resize(800, 600); + editor.show(); + application.processEvents(); + + const QRectF sourceFrame(QPointF(), selection.size()); + if (editor.currentCanvasForTest().right() <= sourceFrame.right()) { + error = QStringLiteral("Text past the source edge did not grow the canvas"); + return false; + } + + QTest::keyClick(&editor, Qt::Key_V); + QTest::mouseClick( + &editor, Qt::LeftButton, Qt::NoModifier, + editor.annotationPointToWidgetForTest(annotationTextBounds(text).center()) + .toPoint()); + application.processEvents(); + if (editor.selectedCountForTest() != 1) { + error = QStringLiteral("Text on the grown canvas could not be selected"); + return false; + } + + const QPoint handleSpot = editor + .annotationPointToWidgetForTest( + annotationTextBounds(text).bottomRight()) + .toPoint(); + QTest::mouseMove(&editor, handleSpot, 20); + application.processEvents(); + if (editor.cursor().shape() != Qt::SizeHorCursor) { + error = + QStringLiteral("Expanded text's real wrap handle was not reachable"); + return false; + } + + // A tiny drag adjusts the stored constraint, even when the widest line + // is noticeably narrower than that constraint. + const qreal scaleBefore = editor.editScaleForTest(); + QTest::mousePress(&editor, Qt::LeftButton, Qt::NoModifier, handleSpot); + QTest::mouseMove(&editor, handleSpot + QPoint(3, 0), 20); + QTest::mouseRelease(&editor, Qt::LeftButton, Qt::NoModifier, + handleSpot + QPoint(3, 0)); + const qreal resizedWidth = editor.currentAnnotationsForTest().constFirst().textWidth; + if (std::abs(resizedWidth - (text.textWidth + 3.0 / scaleBefore)) > 0.5) { + error = QStringLiteral("A small text handle drag snapped its wrap width"); + return false; + } + QTest::keyClick(&editor, Qt::Key_Z, Qt::ControlModifier); + const int operationsBefore = editor.operationIndex(); + QTest::mousePress(&editor, Qt::LeftButton, Qt::NoModifier, handleSpot); + const QPoint wayRight(std::min(editor.width() - 2, handleSpot.x() + 70), + handleSpot.y()); + QTest::mouseMove(&editor, wayRight, 20); + QTest::mouseRelease(&editor, Qt::LeftButton, Qt::NoModifier, wayRight); + application.processEvents(); + const QVector &operations = editor.operationLog(); + if (editor.operationIndex() != operationsBefore + 1 || + operations.constLast().type != Operation::Type::Patch || + operations.constLast().annotations.size() != 1 || + operations.constLast().annotations.constFirst().textWidth <= + text.textWidth || + editor.currentCanvasForTest().right() <= sourceFrame.right()) { + error = QStringLiteral("Dragging the off-canvas handle did not widen text " + "on the grown canvas"); + return false; + } + return true; +} + +/** A multiline text layer grows the canvas downward, keeping its real + * bottom-right wrap handle reachable there. */ +bool runTextVerticalOverrunHandleCheck(QApplication &application, + QString &error) { + CaptureData capture; + capture.monitor.name = QStringLiteral("TEST"); + capture.monitor.geometry = {0, 0, 800, 600}; + capture.monitor.pixelSize = {800, 600}; + capture.monitor.scale = 1.0; + capture.source = QImage(800, 600, QImage::Format_ARGB32_Premultiplied); + capture.source.fill(QColor(QStringLiteral("#182030"))); + capture.previewSize = capture.source.size(); + + const QRectF selection(100, 100, 550, 370); + Annotation text; + text.kind = Annotation::Kind::Text; + text.start = {80, 330}; + text.text = QStringLiteral("first line\nsecond line\nthird line"); + text.color = QColor(QStringLiteral("#ff375f")); + text.size = 5; + text.textWidth = 180.0; + text.textBackground = TextBackground::Plain; + text.id = 1; + OperationLog log; + Operation cropOp; + cropOp.type = Operation::Type::Crop; + cropOp.crop = selection; + log.ops.push_back(cropOp); + Operation annotate; + annotate.type = Operation::Type::Annotate; + annotate.annotations = {text}; + log.ops.push_back(std::move(annotate)); + log.index = log.ops.size(); + log.nextId = 2; + + CaptureEditor editor(capture, CaptureEditor::CaptureMode::File, + QuickOutputMode::None, log); + editor.setSuppressSnapshots(true); + editor.resize(800, 600); + editor.show(); + application.processEvents(); + + if (editor.currentCanvasForTest().bottom() <= selection.height()) { + error = QStringLiteral("Multiline text did not grow the canvas downward"); + return false; + } + + QTest::keyClick(&editor, Qt::Key_V); + QTest::mouseClick( + &editor, Qt::LeftButton, Qt::NoModifier, + editor.annotationPointToWidgetForTest(annotationTextBounds(text).center()) + .toPoint()); + application.processEvents(); + if (editor.selectedCountForTest() != 1) { + error = QStringLiteral("Vertically overrun text could not be selected"); + return false; + } + + const QPoint handleSpot = editor + .annotationPointToWidgetForTest( + annotationTextBounds(text).bottomRight()) + .toPoint(); + QTest::mouseMove(&editor, handleSpot, 20); + application.processEvents(); + if (editor.cursor().shape() != Qt::SizeHorCursor) { + error = QStringLiteral("Text below the source lost its real wrap handle"); + return false; + } + + const int operationsBefore = editor.operationLog().size(); + const QPoint inward(std::max(0, handleSpot.x() - 70), handleSpot.y()); + QTest::mousePress(&editor, Qt::LeftButton, Qt::NoModifier, handleSpot); + QTest::mouseMove(&editor, inward, 20); + QTest::mouseRelease(&editor, Qt::LeftButton, Qt::NoModifier, inward); + application.processEvents(); + const QVector &operations = editor.operationLog(); + if (operations.size() != operationsBefore + 1 || + operations.constLast().type != Operation::Type::Patch || + operations.constLast().annotations.size() != 1 || + operations.constLast().annotations.constFirst().textWidth >= + text.textWidth) { + error = QStringLiteral( + "Dragging the rescued vertical handle did not resize text inward"); + return false; + } + return true; +} + /** The view holds still while a text draft is open. */ bool runDraftViewLockCheck(QApplication &application, QString &error) { CaptureData capture; @@ -1994,6 +2384,11 @@ bool runTextClickAwayCommitCheck(QApplication &application, QString &error) { const Annotation toolbar = textAnnotation({325, 180}, QStringLiteral("Toolbar")); + // The committing click above did only that; a second click opens the next + // editor, with the text tool still armed. + QTest::mouseClick(&editor, Qt::LeftButton, Qt::NoModifier, + toScreen({325, 180})); + application.processEvents(); // The arrow button sits in the spaced toolbar above the capture. QTest::keyClicks(QApplication::focusWidget(), toolbar.text); QTest::mouseClick(&editor, Qt::LeftButton, Qt::NoModifier, @@ -5504,6 +5899,133 @@ bool runTextFontSmoke(QApplication &application, QString &error) { } /** Runs the interaction and rendering smoke checks. */ +/** Checks text wrapping: a long line breaks at the wrap width, hard newlines + * survive alongside it, a word too long for the width breaks mid-word rather + * than running off, and a bounds box follows the wrapped shape. */ +bool runTextWrapRenderingCheck(QString &error) { + error = QStringLiteral("Text wrap rendering check failed"); + Annotation text; + text.kind = Annotation::Kind::Text; + text.start = {20, 40}; + text.color = QColor(QStringLiteral("#ff375f")); + text.size = 5; + text.text = QStringLiteral("the quick brown fox jumps over the lazy dog"); + + Annotation indented = text; + indented.text = QStringLiteral("one\n indented paragraph with more words"); + indented.textWidth = 120; + const QStringList indentLines = annotationTextLines(indented); + if (indentLines.join(QString()) != QString(indented.text).remove('\n')) { + error = QStringLiteral("Wrapping discarded whitespace from the text"); + return false; + } + const QTemporaryDir roundTrip; + Operation annotate; + annotate.type = Operation::Type::Annotate; + annotate.annotations = {indented}; + const OperationLog saved{{annotate}, 1, 2, 1, QSize(800, 600)}; + OperationLog loaded; + const QString logPath = roundTrip.filePath(QStringLiteral("wrapped.json")); + if (!saveOperationLog(logPath, saved, error) || + !loadOperationLog(logPath, loaded, error) || loaded != saved) { + error = QStringLiteral("Wrapped text did not survive operation-log reload"); + return false; + } + // Unbounded: one line, however long it is. + if (annotationTextLines(text, 0.0).size() != 1) { + error = QStringLiteral("Unbounded text did not stay on one line"); + return false; + } + + // A dragged width breaks it into several. + text.textWidth = 120.0; + const QStringList wrapped = annotationTextLines(text, 0.0); + if (wrapped.size() < 2) { + error = QStringLiteral("Text did not wrap at its dragged width"); + return false; + } + const QFontMetricsF metrics(annotationTextFont(text.size)); + for (const QString &line : wrapped) { + if (metrics.horizontalAdvance(line.trimmed()) > 120.0) { + error = QStringLiteral("A wrapped line ran past the wrap width"); + return false; + } + } + + // The bounds follow the wrapped shape rather than the unwrapped run. + const QRectF box = annotationTextBounds(text, 0.0); + if (box.width() > 120.0 + 2 * std::max(4.0, metrics.height() * 0.18) + 1.0) { + error = QStringLiteral("Wrapped text bounds kept the unwrapped width"); + return false; + } + + // Hard newlines still split, and wrapping applies within each paragraph. + text.text = QStringLiteral("one\ntwo three four five six seven eight nine"); + const QStringList mixed = annotationTextLines(text, 0.0); + if (mixed.size() < 3 || mixed.first() != QStringLiteral("one")) { + error = QStringLiteral("Hard newlines did not survive wrapping"); + return false; + } + + // A single word wider than the wrap width breaks rather than overflowing. + text.text = QStringLiteral("supercalifragilisticexpialidocious"); + for (const QString &line : annotationTextLines(text, 0.0)) { + if (metrics.horizontalAdvance(line.trimmed()) > 120.0) { + error = QStringLiteral("An over-long word ran past the wrap width"); + return false; + } + } + + // With no width of its own, text wraps at the canvas edge instead. + text.textWidth = 0.0; + text.text = QStringLiteral("the quick brown fox jumps over the lazy dog"); + if (annotationTextLines(text, 200.0).size() < 2) { + error = QStringLiteral("Text did not wrap at the canvas edge"); + return false; + } + const QRectF expanded = + captureCanvasRect(QSizeF(200, 100), {text}, CanvasBoundaryMode::Framed); + if (expanded.right() <= 200.0 || annotationTextLines(text).size() != 1) { + error = QStringLiteral( + "Committed zero-width text did not expand the canvas unbounded"); + return false; + } + + // And the painted pixels wrap, not just the layout: rendered at a dragged + // width, ink appears on a second line and never past the wrap width. The + // layout helpers wrapping while the painter split on newlines is exactly + // the break this would have caught. + CaptureData capture; + capture.monitor.scale = 1.0; + capture.monitor.pixelSize = {400, 200}; + capture.source = QImage(400, 200, QImage::Format_ARGB32_Premultiplied); + capture.source.fill(Qt::transparent); + capture.previewSize = capture.source.size(); + Annotation painted = text; + painted.start = {20, 40}; + painted.textWidth = 120.0; + painted.textBackground = TextBackground::Plain; + const QImage out = renderCapture(capture, QRectF(0, 0, 400, 200), {painted}, + BackgroundStyle::None); + const QFontMetricsF painterMetrics(annotationTextFont(painted.size)); + bool secondLineInk = false; + const int secondBaseline = qRound(40 + painterMetrics.lineSpacing()); + for (int x = 20; x < 140 && !secondLineInk; ++x) + secondLineInk = out.pixelColor(x, secondBaseline - 2).alpha() > 0; + if (!secondLineInk) { + error = QStringLiteral("Rendered text did not paint a wrapped second line"); + return false; + } + for (int x = 170; x < 400; ++x) { + if (out.pixelColor(x, qRound(40 - painterMetrics.ascent() / 2)).alpha() > + 0) { + error = QStringLiteral("Rendered text ran past its wrap width"); + return false; + } + } + return true; +} + bool runTextPillRenderingCheck(QString &error) { error = QStringLiteral("Text pill rendering check failed"); CaptureData capture; @@ -7724,6 +8246,18 @@ int main(int argc, char **argv) { qWarning().noquote() << snapshotError; return 11; } + if (!runDraftPillStaysPutCheck(application, snapshotError)) { + qWarning().noquote() << snapshotError; + return 12; + } + if (!runTextVerticalOverrunHandleCheck(application, snapshotError)) { + qWarning().noquote() << snapshotError; + return 13; + } + if (!runTextOverrunHandleCheck(application, snapshotError)) { + qWarning().noquote() << snapshotError; + return 35; + } if (!runTextClickAwayCommitCheck(application, snapshotError)) { qWarning().noquote() << snapshotError; return 87; @@ -7780,6 +8314,10 @@ int main(int argc, char **argv) { qWarning().noquote() << snapshotError; return 128; } + if (!runTextWrapRenderingCheck(snapshotError)) { + qWarning().noquote() << snapshotError; + return 125; + } if (!runTextPillRenderingCheck(snapshotError)) { qWarning().noquote() << snapshotError; return 102;