Skip to content

fix(text-editor): reserve hit-box padding in editing preview so the text box doesn't jump on done#849

Merged
hm21 merged 3 commits into
hm21:stablefrom
legit-games:b3rri/upstream-text-hitbox
Jul 23, 2026
Merged

fix(text-editor): reserve hit-box padding in editing preview so the text box doesn't jump on done#849
hm21 merged 3 commits into
hm21:stablefrom
legit-games:b3rri/upstream-text-hitbox

Conversation

@hiake21cUs

Copy link
Copy Markdown
Contributor

Problem

When adding a text layer, the rounded background box looks different while typing vs after pressing done: during editing the box hugs the glyphs (the trailing/leading side looks clipped and asymmetric), and the moment editing completes the box grows and becomes left/right symmetric.

Root cause

The in-editor preview and the finished layer render the same RoundedBackgroundText but reserve different room for the painter's padding:

  • Finished layer — LayerWidgetTextItem renders RoundedBackgroundText(..., enableHitBoxCorrection: true), so RoundedBackgroundText grows its CustomPaint size by lineHeight * 0.3 (horizontal) / lineHeight * 0.1 (vertical) on each side and offsets the painter, keeping the always-drawn background padding fully inside the box.
  • Editing preview — RoundedBackgroundTextField._buildBackgroundText() builds RoundedBackgroundText.rich(...) without enableHitBoxCorrection (defaults to false). The background is Positioned(left: 0, right: 0), so it is tied to the stack width (the glyph width) and the RoundedBackgroundTextPainter padding (rawHeight * 0.3 / rawHeight * 0.1, always drawn) has no reserved room and gets clipped — asymmetrically.

So the transition from editing → placed layer visibly changes the box geometry.

Fix

Make the editing preview reserve the same padding as the finished layer:

  1. _buildBackgroundText() passes enableHitBoxCorrection: true.
  2. The editable glyphs are inset by the matching amount (lineHeight * 0.3 horizontal, lineHeight * 0.1 vertical) so they stay centered inside the now-symmetric box and align with the finished layer. lineHeight is computed the same way RoundedBackgroundText lays the text out (preferredLineHeight with the configured leadingDistribution).

No public API change; single widget file.

Test

Adds a widget test (test/features/text_editor/rounded_background_text_field_hitbox_test.dart) that locks the preview background box height to the finished layer's height for the same text/style. Without the fix the preview is ~0.2 * lineHeight shorter; with it, they match.

The in-editor text preview (RoundedBackgroundTextField) passes
enableHitBoxCorrection: false, so its rounded background hugs the glyphs,
while the finished layer (LayerWidgetTextItem) renders the same
RoundedBackgroundText with enableHitBoxCorrection: true. The rounded
background box therefore grows and shifts (becoming left/right symmetric)
the moment editing completes — a visible WYSIWYG mismatch between typing
and the placed text layer.

Reserve the same symmetric hit-box padding while editing: enable the
correction on the preview background and inset the editable glyphs by the
matching line-height ratio (0.3 horizontal, 0.1 vertical), so the box is
identical during editing and after completion.

Adds a widget test locking the preview background height to the finished
layer's.

@hm21 hm21 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Thanks for working on that PR. The core fix is correct and well-motivated: the editing preview used enableHitBoxCorrection: false while the finished layer (LayerWidgetTextItem) uses true, so the rounded box grew on Done. Flipping the preview to true and reserving the matching vertical room fixes the vertical jump cleanly, and the new test proves exactly that. flutter analyze is clean apart from two lint infos, and the test passes.

However, one change needs rework before merge. The horizontal room is reserved on the wrong side: the new Padding shrinks the editable TextField's wrap width, while the background keeps the full maxTextWidth. So the editable glyphs wrap at a narrower column than the background box and the finished layer, reintroducing a jump-on-done (line-count change) of the same class this PR targets — only horizontal. I reproduced this with a throwaway widget test (details in the inline comment).

Verdict: needs changes — couple the two wrap widths (inline comment), then the rest are small cleanups: undisposed TextPainter, test not covering the Padding half of the fix, a mislabeled test comment, and two 80-char lint infos.

required double hitBoxHorizontal,
required double hitBoxVertical,
}) {
return Padding(

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Reintroduces a jump-on-done — this time horizontal.

This Padding(horizontal: hitBoxHorizontal) shrinks the TextField's layout width by 2*hitBoxHorizontal (~0.6·lineHeight, ~28px at fontSize 40), but the background RoundedBackgroundText.rich still lays its glyphs out at the full maxTextWidth (L190) and only grows the CustomPaint outward. The two sides no longer agree on the wrap column: the editable text is reserved by shrinking inward, the background by growing outward at an unchanged wrap width.

I verified it with a widget test sweeping maxWidth (default TextEditorConfigs, wrapping on), measuring the EditableText vs the finished RoundedBackgroundText:

maxWidth=200  editableH=240  finishedH=128   <- editable ~4 lines, box/finished ~2 lines
maxWidth=220  editableH=240  finishedH=128
maxWidth=240  editableH=120  finishedH=128   <- back in sync

For a band of widths the visible text wraps into more lines than the box that should hug it (a mismatch while editing), and the line count changes on Done. This was not present before the PR, since the editable text used the full width.

Fix: make both layouts wrap at the same column — e.g. subtract 2*hitBoxHorizontal from the background's maxTextWidth (L190), or give the TextField an explicit width of maxTextWidth - 2*hitBoxHorizontal instead of an outer Padding. Please add a near-boundary wrapping test as well, not just the single-line height case.

/// same way [RoundedBackgroundText] lays the text out, so the hit-box padding
/// reserved here matches the rectangle the painter draws.
double _preferredLineHeight(double fontSize) {
final painter = TextPainter(

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

_preferredLineHeight creates this TextPainter, calls layout(), and returns without dispose(). It runs from build() on every keystroke and scroll tick (both listeners call setState), leaking one native ui.Paragraph per rebuild until GC.

final h = painter.preferredLineHeight;
painter.dispose();
return h;

Optionally memoize on (fontSize, style, leadingDistribution, textDirection) — none of them change per keystroke, so recomputing the layout every frame is redundant.

Comment thread test/features/text_editor/rounded_background_text_field_hitbox_test.dart Outdated
Comment thread test/features/text_editor/rounded_background_text_field_hitbox_test.dart Outdated
Comment thread test/features/text_editor/rounded_background_text_field_hitbox_test.dart Outdated
… line height

Addresses review feedback:
- Horizontal room was reserved on the wrong side: the editable Padding shrank
  the TextField's wrap width by 2*hitBox while the background kept the full
  maxTextWidth, so they broke into different line counts (a horizontal
  jump-on-done). Subtract 2*hitBox from the background maxTextWidth so both
  wrap at the same column.
- Dispose the TextPainter in _preferredLineHeight and memoize it (build runs
  every keystroke/scroll tick; inputs are unchanged per frame).
- Tests: assert the editable glyph inset (the Padding half of the fix); add a
  wrap-column sweep across widths (editable line count via TextPainter vs the
  background box line count); reword the reference-render comment; keep lines
  within 80 chars.
@hiake21cUs

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review — the horizontal-wrap catch was spot on. Pushed a follow-up addressing every point:

Horizontal jump (blocking). You're right: the Padding shrank the editable's wrap width by 2*hitBoxHorizontal while the background still laid out at the full maxTextWidth, so they disagreed on the wrap column. Fixed by subtracting 2*hitBoxHorizontal from the background's maxTextWidth as well, so both wrap at the same column. Verified with the new sweep test below (editable and background now break into the same number of lines across the whole width band; reverting just this line reproduces your 12-vs-10 mismatch).

Undisposed TextPainter. _preferredLineHeight now calls painter.dispose(), and additionally memoizes on (fontSize, style, leadingDistribution, textDirection) so the layout isn't recomputed every keystroke/scroll tick.

Test didn't cover the Padding half. The single-line test now also asserts the editable glyph inset — EditableText.rect - background.rect ≈ (hitBoxHorizontal, hitBoxVertical) — so deleting the Padding fails it. Added a second test that sweeps a band of widths across wrap boundaries and asserts the editable line count (measured with a TextPainter at the editable's wrap width — its own height is non-linear in line count) equals the background box's line count.

Mislabeled comment. Reworded: the reference tree is described as a bare RoundedBackgroundText with hit-box correction enabled, not the finished LayerWidgetTextItem.

80-char infos. Reflowed; flutter analyze is now clean on both files.

@hiake21cUs
hiake21cUs requested a review from hm21 July 16, 2026 15:39
@hm21

hm21 commented Jul 23, 2026

Copy link
Copy Markdown
Owner

LGTM thanks for the thorough writeup 🙏

@hm21
hm21 merged commit 1be3fc4 into hm21:stable Jul 23, 2026
1 check passed
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.

3 participants