Skip to content

feat(editor): wrap text at a dragged width or the canvas edge - #59

Open
jondkinney wants to merge 8 commits into
omacom:mainfrom
jondkinney:feat/text-wrap
Open

feat(editor): wrap text at a dragged width or the canvas edge#59
jondkinney wants to merge 8 commits into
omacom:mainfrom
jondkinney:feat/text-wrap

Conversation

@jondkinney

@jondkinney jondkinney commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

Long text now wraps at the current canvas boundary while it is being typed, keeping the inline editor and rendered output readable. Hard newlines and word wrapping compose: paragraphs split on explicit newlines first, ordinary words wrap at spaces, and a single over-long word breaks only when necessary.

This update also makes wrapping compose with the expandable canvas already on main:

  • a draft still wraps at the current edge by default
  • if that draft actually wraps, commit freezes the resulting width so moving it does not unexpectedly reflow
  • committed text with textWidth = 0 is unbounded, so text that did not need wrapping can be dragged past an edge and grow the canvas
  • the bottom-right handle controls wrap width and may be dragged beyond the current canvas instead of being clamped to it
  • explicit hard newlines alone do not force an unnecessary wrap width

The operation log remains the source of truth; preview, bounds, export, move, resize, undo, and redo all use the same text layout behavior.

Test plan

  • make check
  • smoke coverage for default draft wrapping, dragged widths, hard newlines, over-long words, stable committed layout, and moving/resizing text beyond the canvas edge

@jondkinney

Copy link
Copy Markdown
Collaborator Author

Pushed a round of fixes from testing the draft side of this more heavily:

  • The painter now actually wraps. The original branch wrapped the layout helpers but the glyph painter still split on newlines, so a dragged width changed the bounds without re-breaking the text. The wrap width now threads through to the painter itself, and the rendering check asserts on pixels (ink on the second line, none past the wrap width) instead of trusting the helpers.
  • The draft pill is built from the committed pill's own formula instead of the widget rect, so nothing shifts when you click off, short text is not padded out to the widget's typing slack, and an empty draft shows a small pill with the caret centered in it.
  • The draft soft wraps at the canvas edge while you type, with the same room the commit wrap gets, so the wrapped draft and the committed layer break in the same places. A check compares the cream pixels before and after the committing click for both plain and wrapped text.
  • A layer whose frozen width or height sticks out past the canvas keeps its wrap handle on the canvas edge, so it can always be dragged back in. Dragging outward still stops at the edge.

One deliberate behavior change to flag: the committing click now only commits. It used to also open the next editor at that point, which made clicking off a way to accidentally start new text. A second click opens the next draft. The click-away check has been updated to the two-click sequence, so if you prefer the old contract that hunk is the one to look at.

@jondkinney

Copy link
Copy Markdown
Collaborator Author

Rebased over the text box work. The two compose rather than overlap: the box gives the entry room for lines and decides when Enter commits, and this branch handles horizontal overflow, wrapping long text at the canvas edge or at a width dragged from the committed layer's handle. Both feature's checks pass together in the suite.

@jondkinney
jondkinney force-pushed the feat/text-wrap branch 4 times, most recently from be4f067 to 7e56a65 Compare August 27, 2026 05:08
Long text ran straight off the capture, taking its handle with it, so it
could not be read or dragged back.

Text layers carry a `textWidth` (0 = wrap at the canvas edge).
`annotationTextLines()` splits on hard newlines first, then word-wraps
each paragraph to `annotationTextWrapWidth()`, breaking mid-word only
when a single word cannot fit. Bounds and painting both lay out through
it, so the editor preview and the export agree. The inline editor wraps
as you type: the widget takes the wrap width and reports its own laid-out
height, since wrapped lines are not the newline count.

The bottom-right handle now sets that wrap width rather than scaling the
font. Its cursor has always been SizeHorCursor, and the wheel already
sets a selected layer's size, so size had two controls and width had
none.

Text that wrapped at the canvas edge freezes that shape on commit, as
tight as its widest line, so moving it later never reflows the paragraph
you just placed. The handle can still re-wrap it.

Smoke (exit 125): wrapping at a dragged width, hard newlines surviving
alongside it, an over-long word breaking rather than overflowing, bounds
following the wrapped shape, and the canvas edge wrapping when the layer
has no width of its own.

The painted glyphs lay out through annotationTextLines() like the bounds
do; the pill and export path take the canvas width; the draft pill hugs
the laid-out lines instead of inheriting the widget's scroll slack; and
a click that commits a text does only that, so the next text takes its
own click. The click-away check follows that contract with a second
click, and the wrap check asserts rendered pixels, not just the layout.
The draft pill borrowed the widget's rect, but the widget keeps typing
slack (a 48px floor plus room for the next glyph) and starts at the
glyph top rather than a pad above it. So the pill ran wide of short
text, the caret sat at the left of an empty pill instead of inside a
small one, and the whole background shifted up and in on commit.
Rebuild the rect from the draft text with the committed pill's own
formula so the two coincide, and gate it with a check that compares the
cream pixels before and after the committing click.
The inline editor forbade soft wrapping, so a draft typed toward the
edge ran past it in one long line and only wrapped on commit, with the
background snapping into the wrapped shape. Let the widget wrap the way
the committed text will (word boundaries, anywhere within a word too
long to fit) and give its viewport the same room the commit wrap gets,
so the wrapped draft pill and the committed pill coincide. The pill now
measures the laid-out lines rather than newline-split advances.

A layer whose frozen width sticks out past the canvas edge kept its
wrap handle out there too, sometimes off the screen, with no way to
drag the text back in. The handle now waits at the canvas edge instead.
Multiline text can run below the canvas bottom even when its width fits
inside the right edge, which left the wrap handle underneath the canvas
with the same unreachable-handle problem sideways overrun had. Clamp the
handle inside every edge, and check that text loaded with its bottom
clipped still offers a grabbable handle that resizes it inward.
Copilot AI balanced review requested due to automatic review settings September 5, 2026 16:46

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Wrapped-text persistence, whitespace handling, zoom scaling, and pill resize calculations must be corrected before approval.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Adds canvas-aware, resizable text wrapping throughout annotation editing and rendering.

Changes:

  • Adds automatic and dragged-width text wrapping.
  • Integrates wrapping with canvas expansion and text resizing.
  • Adds smoke tests and documentation.
File summaries
File Summary
tests/editor-smoke.cpp Adds wrapping and expanded-canvas smoke coverage.
src/editor.hpp Tracks draft wrap width.
src/editor.cpp Integrates wrapping into editing and resizing; wrap-width scaling and pill padding calculations need correction.
src/capture.hpp Defines text-width state and layout APIs; JSON persistence is missing.
src/capture.cpp Implements wrapped layout and rendering; whitespace preservation needs correction.
README.md Documents text-wrapping behavior.
Review details
  • Files reviewed: 6/6 changed files
  • Comments generated: 4
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/capture.cpp Outdated
Comment on lines +99 to +100
for (const QString &word : paragraph.split(' ')) {
const QString candidate = line.isEmpty() ? word : line + ' ' + word;
Comment thread src/capture.hpp
Comment on lines +96 to +97
/// Wrap width for text layers in image px; 0 leaves the layer unbounded.
qreal textWidth = 0.0;
Comment thread src/editor.cpp Outdated
// edge rather than running off it, where its handle is unreachable.
const int desiredWidth =
textEditWrapWidth_ > 0.0
? std::max(48, qRound(textEditWrapWidth_) + sidePadding * 2)
Comment thread src/editor.cpp Outdated
Comment on lines +4391 to +4393
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());
annotation.textWidth = std::max<qreal>(
kMinimumTextWrapWidth, point.x() - originalBounds.left());
Copilot AI review requested due to automatic review settings September 6, 2026 23:28

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Two moderate text-resizing and draft-layout inconsistencies remain unresolved.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details
  • Files reviewed: 6/6 changed files
  • Comments generated: 2
  • Review effort level: Balanced

Comment thread src/editor.cpp Outdated
Comment on lines +3100 to +3102
const int availableWidth =
std::max(48, qRound(editImageRect().right() - textEditor_->x()));
const int lineCount = std::max(1, static_cast<int>(lines.size()));
std::max(48, qRound(editImageRect().right()) - textEditor_->x() +
sidePadding);
Comment thread src/editor.cpp Outdated
Comment on lines +4401 to +4403
annotation.textWidth = std::max<qreal>(
kMinimumTextWrapWidth,
point.x() - originalBounds.left() - 2.0 * padding);
Copilot AI review requested due to automatic review settings September 7, 2026 00:34

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Two moderate text-layout and resize defects must be resolved before approval.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details
  • Files reviewed: 6/6 changed files
  • Comments generated: 2
  • Review effort level: Balanced

Comment thread src/editor.cpp
Comment on lines +3101 to +3103
const int desiredWidth = textEditWrapWidth_ > 0.0
? std::max(1, qRound(textEditWrapWidth_ * editScale())) + sidePadding * 2
: std::max(48, widestLine + sidePadding * 2);
Comment thread src/editor.cpp Outdated
Comment on lines +4402 to +4406
annotation.textWidth = std::max<qreal>(
kMinimumTextWrapWidth,
originalAnnotation_.textWidth > 0.0
? originalAnnotation_.textWidth + point.x() - dragStart_.x()
: point.x() - originalBounds.left() - 2.0 * padding);
Copilot AI review requested due to automatic review settings September 7, 2026 00:48

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

The reviewed changes are consistent and have no unresolved approval-blocking issues.

Review details
  • Files reviewed: 6/6 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants