diff --git a/lib/features/text_editor/widgets/rounded_background_text/rounded_background_text_field.dart b/lib/features/text_editor/widgets/rounded_background_text/rounded_background_text_field.dart index 96fd7f250..67d79cb92 100644 --- a/lib/features/text_editor/widgets/rounded_background_text/rounded_background_text_field.dart +++ b/lib/features/text_editor/widgets/rounded_background_text/rounded_background_text_field.dart @@ -121,6 +121,16 @@ class _RoundedBackgroundTextFieldState final fontSize = (widget.style.fontSize ?? defaultTextStyle.style.fontSize ?? 16); + // The background rectangle drawn by [RoundedBackgroundTextPainter] always + // extends the text by these paddings (see `paddingHorizontal`/ + // `paddingVertical` there). The finished layer reserves room for them via + // `enableHitBoxCorrection: true`; the editing preview must reserve the same + // room so the rounded background does not visibly grow and shift the moment + // editing completes. + final lineHeight = _preferredLineHeight(fontSize); + final hitBoxHorizontal = lineHeight * 0.3; + final hitBoxVertical = lineHeight * 0.1; + return MediaQuery( data: MediaQuery.of(context).copyWith(textScaler: TextScaler.noScaling), child: Stack( @@ -133,14 +143,61 @@ class _RoundedBackgroundTextFieldState TextAlign.center || _ => Alignment.topCenter, }, children: [ - if (_textController.text.isNotEmpty) _buildBackgroundText(), - _buildEditableText(fontSize: fontSize), + if (_textController.text.isNotEmpty) + _buildBackgroundText(hitBoxHorizontal: hitBoxHorizontal), + _buildEditableText( + fontSize: fontSize, + hitBoxHorizontal: hitBoxHorizontal, + hitBoxVertical: hitBoxVertical, + ), ], ), ); } - Widget _buildBackgroundText() { + double? _cachedLineHeight; + double? _cachedLineHeightFontSize; + TextStyle? _cachedLineHeightStyle; + TextLeadingDistribution? _cachedLineHeightLeading; + TextDirection? _cachedLineHeightDirection; + + /// The preferred line height for [widget.style] at [fontSize], computed the + /// same way [RoundedBackgroundText] lays the text out, so the hit-box padding + /// reserved here matches the rectangle the painter draws. Memoized because + /// [build] runs on every keystroke and scroll tick while none of the inputs + /// change per frame. + double _preferredLineHeight(double fontSize) { + final leading = widget.configs.style.leadingDistribution; + final direction = Directionality.maybeOf(context) ?? TextDirection.ltr; + if (_cachedLineHeight != null && + _cachedLineHeightFontSize == fontSize && + _cachedLineHeightStyle == widget.style && + _cachedLineHeightLeading == leading && + _cachedLineHeightDirection == direction) { + return _cachedLineHeight!; + } + + final painter = TextPainter( + text: TextSpan( + style: TextStyle( + leadingDistribution: leading, + ).merge(widget.style.copyWith(fontSize: fontSize)), + text: 'A', + ), + textDirection: direction, + )..layout(); + final lineHeight = painter.preferredLineHeight; + painter.dispose(); + + _cachedLineHeight = lineHeight; + _cachedLineHeightFontSize = fontSize; + _cachedLineHeightStyle = widget.style; + _cachedLineHeightLeading = leading; + _cachedLineHeightDirection = direction; + return lineHeight; + } + + Widget _buildBackgroundText({required double hitBoxHorizontal}) { final style = widget.style.copyWith( color: Colors.transparent, leadingDistribution: widget.configs.style.leadingDistribution, @@ -157,66 +214,88 @@ class _RoundedBackgroundTextFieldState withComposing: true, style: style, ), - maxTextWidth: widget.maxTextWidth - widget.cursorWidth, + // Wrap at the same column as the editable text: the editable glyphs + // are inset by `hitBoxHorizontal` on each side (see + // `_buildEditableText`), so the background must lay its glyphs out at + // that same reduced width. Otherwise the two disagree on the wrap + // column and the line count changes when editing completes. + maxTextWidth: + widget.maxTextWidth - widget.cursorWidth - 2 * hitBoxHorizontal, cursorWidth: widget.cursorWidth, textAlign: widget.textAlign, backgroundColor: widget.backgroundColor, + // Match the finished layer (LayerWidgetTextItem) so the rounded + // background reserves symmetric padding while editing. + enableHitBoxCorrection: true, ), ), ); } - Widget _buildEditableText({required double fontSize}) { - return Material( - type: MaterialType.transparency, - child: TextField( - onTap: - _textController.text.isEmpty && - View.of(context).viewInsets.bottom <= 0 - ? () { - FocusManager.instance.primaryFocus?.unfocus(); - widget.focusNode.requestFocus(); - } - : null, - autofocus: widget.autofocus, - controller: _textController, - focusNode: widget.focusNode, - scrollPhysics: const NeverScrollableScrollPhysics(), - scrollController: _scrollCtrl, - scrollPadding: EdgeInsets.zero, - style: widget.style.copyWith( - fontSize: fontSize, - leadingDistribution: widget.configs.style.leadingDistribution, - height: widget.configs.style.textHeight, - ), - spellCheckConfiguration: widget.configs.spellCheckConfiguration, - decoration: InputDecoration.collapsed( - hintText: _textController.text.isEmpty ? widget.hint : '', - hintStyle: - (widget.hintStyle ?? - TextStyle(color: Theme.of(context).hintColor)) - .copyWith(fontSize: fontSize), - maintainHintSize: false, + Widget _buildEditableText({ + required double fontSize, + required double hitBoxHorizontal, + required double hitBoxVertical, + }) { + return Padding( + // Inset the editable glyphs by the same hit-box padding the background + // rectangle reserves, so the visible text stays centered inside the box + // and aligns with the finished layer. + padding: EdgeInsets.symmetric( + horizontal: hitBoxHorizontal, + vertical: hitBoxVertical, + ), + child: Material( + type: MaterialType.transparency, + child: TextField( + onTap: + _textController.text.isEmpty && + View.of(context).viewInsets.bottom <= 0 + ? () { + FocusManager.instance.primaryFocus?.unfocus(); + widget.focusNode.requestFocus(); + } + : null, + autofocus: widget.autofocus, + controller: _textController, + focusNode: widget.focusNode, + scrollPhysics: const NeverScrollableScrollPhysics(), + scrollController: _scrollCtrl, + scrollPadding: EdgeInsets.zero, + style: widget.style.copyWith( + fontSize: fontSize, + leadingDistribution: widget.configs.style.leadingDistribution, + height: widget.configs.style.textHeight, + ), + spellCheckConfiguration: widget.configs.spellCheckConfiguration, + decoration: InputDecoration.collapsed( + hintText: _textController.text.isEmpty ? widget.hint : '', + hintStyle: + (widget.hintStyle ?? + TextStyle(color: Theme.of(context).hintColor)) + .copyWith(fontSize: fontSize), + maintainHintSize: false, + ), + textAlign: widget.textAlign, + maxLines: null, + keyboardType: TextInputType.multiline, + textCapitalization: TextCapitalization.sentences, + textInputAction: TextInputAction.newline, + cursorColor: widget.configs.style.inputCursorColor, + cursorWidth: widget.cursorWidth, + cursorHeight: widget.cursorHeight, + cursorRadius: widget.cursorRadius, + enableInteractiveSelection: true, + showCursor: true, + autocorrect: widget.configs.enableAutocorrect, + smartDashesType: SmartDashesType.enabled, + smartQuotesType: SmartQuotesType.enabled, + enableSuggestions: widget.configs.enableSuggestions, + clipBehavior: Clip.hardEdge, + onChanged: widget.onChanged, + onEditingComplete: widget.onEditingComplete, + onSubmitted: widget.onSubmitted, ), - textAlign: widget.textAlign, - maxLines: null, - keyboardType: TextInputType.multiline, - textCapitalization: TextCapitalization.sentences, - textInputAction: TextInputAction.newline, - cursorColor: widget.configs.style.inputCursorColor, - cursorWidth: widget.cursorWidth, - cursorHeight: widget.cursorHeight, - cursorRadius: widget.cursorRadius, - enableInteractiveSelection: true, - showCursor: true, - autocorrect: widget.configs.enableAutocorrect, - smartDashesType: SmartDashesType.enabled, - smartQuotesType: SmartQuotesType.enabled, - enableSuggestions: widget.configs.enableSuggestions, - clipBehavior: Clip.hardEdge, - onChanged: widget.onChanged, - onEditingComplete: widget.onEditingComplete, - onSubmitted: widget.onSubmitted, ), ); } diff --git a/test/features/text_editor/rounded_background_text_field_hitbox_test.dart b/test/features/text_editor/rounded_background_text_field_hitbox_test.dart new file mode 100644 index 000000000..badcb09ac --- /dev/null +++ b/test/features/text_editor/rounded_background_text_field_hitbox_test.dart @@ -0,0 +1,216 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pro_image_editor/core/models/editor_configs/text_editor_configs.dart'; +import 'package:pro_image_editor/features/text_editor/widgets/rounded_background_text/rounded_background_text.dart'; +import 'package:pro_image_editor/features/text_editor/widgets/rounded_background_text/rounded_background_text_field.dart'; + +/// The in-editor text preview ([RoundedBackgroundTextField]) must render its +/// rounded background the same way the placed layer does — the layer renders a +/// [RoundedBackgroundText] with `enableHitBoxCorrection: true`. Before the fix +/// the preview passed `enableHitBoxCorrection: false`, so its background hugged +/// the glyphs and grew (becoming symmetric) the moment editing completed. +/// +/// These tests lock the two halves of the fix: +/// * the background reserves the same hit-box padding (vertical + horizontal); +/// * the editable glyphs are inset by that padding and wrap at the same column +/// as the background, so nothing shifts or re-wraps on done. +void main() { + Widget wrapPreview({ + required TextEditingController controller, + required FocusNode focusNode, + required TextStyle style, + required double width, + }) { + return MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: width, + child: RoundedBackgroundTextField( + controller: controller, + focusNode: focusNode, + configs: const TextEditorConfigs(), + style: style, + backgroundColor: Colors.white, + textAlign: TextAlign.center, + maxTextWidth: width, + ), + ), + ), + ), + ); + } + + double preferredLineHeight(TextStyle style) { + final painter = TextPainter( + text: TextSpan(text: 'A', style: style), + textDirection: TextDirection.ltr, + )..layout(); + final h = painter.preferredLineHeight; + painter.dispose(); + return h; + } + + testWidgets( + 'preview background matches hit-box reference and insets glyphs', + (tester) async { + const text = 'Aaaaa'; + const style = TextStyle(fontSize: 40, color: Colors.black); + const maxWidth = 400.0; + final lineHeight = preferredLineHeight(style); + final hitBoxHorizontal = lineHeight * 0.3; + final hitBoxVertical = lineHeight * 0.1; + + // Reference: a bare RoundedBackgroundText with hit-box correction on, + // i.e. exactly what the placed layer renders. + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: maxWidth, + child: RoundedBackgroundText( + text, + style: style, + backgroundColor: Colors.white, + maxTextWidth: maxWidth, + enableHitBoxCorrection: true, + ), + ), + ), + ), + ), + ); + final referenceHeight = tester + .getSize(find.byType(RoundedBackgroundText)) + .height; + + // Preview. + final controller = TextEditingController(text: text); + final focusNode = FocusNode(); + addTearDown(controller.dispose); + addTearDown(focusNode.dispose); + + await tester.pumpWidget( + wrapPreview( + controller: controller, + focusNode: focusNode, + style: style, + width: maxWidth, + ), + ); + await tester.pump(); + + // 1) The background reserves the same room as the reference. + final previewBgRect = tester.getRect(find.byType(RoundedBackgroundText)); + expect( + previewBgRect.height, + moreOrLessEquals(referenceHeight, epsilon: 0.5), + reason: + 'Preview background height must match the hit-box-corrected ' + 'reference; a mismatch means the padding was not reserved.', + ); + + // 2) The editable glyphs are inset by the hit-box padding, so they stay + // centered inside the box instead of shifting on done. + final editableRect = tester.getRect(find.byType(EditableText)); + expect( + editableRect.left - previewBgRect.left, + moreOrLessEquals(hitBoxHorizontal, epsilon: 1.0), + reason: + 'Editable glyphs must be inset horizontally by the hit-box pad.', + ); + expect( + editableRect.top - previewBgRect.top, + moreOrLessEquals(hitBoxVertical, epsilon: 1.0), + reason: 'Editable glyphs must be inset vertically by the hit-box pad.', + ); + }, + ); + + testWidgets( + 'editable text and background wrap at the same column across widths', + (tester) async { + const text = 'one two three four five six seven eight nine ten'; + const style = TextStyle(fontSize: 30, color: Colors.black); + + final controller = TextEditingController(); + final focusNode = FocusNode(); + addTearDown(controller.dispose); + addTearDown(focusNode.dispose); + + // Returns (editableWrapWidth, backgroundHeight) for the given text/width. + Future<(double, double)> measure(String txt, double width) async { + controller.text = txt; + await tester.pumpWidget( + wrapPreview( + controller: controller, + focusNode: focusNode, + style: style, + width: width, + ), + ); + await tester.pump(); + return ( + tester.getSize(find.byType(EditableText)).width, + tester.getSize(find.byType(RoundedBackgroundText)).height, + ); + } + + // Line count for the editable text: re-lay it at the editable's measured + // wrap width. EditableText's own height is non-linear in line count + // (constant caret/strut padding), so dividing it is unsafe; line breaking + // depends only on width and glyph advances. + int editableLineCount(double wrapWidth) { + final painter = TextPainter( + text: const TextSpan(text: text, style: style), + textDirection: TextDirection.ltr, + textAlign: TextAlign.center, + )..layout(maxWidth: wrapWidth); + final count = painter.computeLineMetrics().length; + painter.dispose(); + return count; + } + + // The background box height is linear in line count; calibrate + // `height = lines * perLine + constant` from explicit 1- and 2-line + // renders (the Positioned background's rect width can't reveal its wrap + // column, so height is the reliable signal here). + final (_, bg1) = await measure('A', 4000); + final (_, bg2) = await measure('A\nA', 4000); + final bgPerLine = bg2 - bg1; + final bgConst = bg1 - bgPerLine; + int backgroundLineCount(double height) => + ((height - bgConst) / bgPerLine).round(); + + var sawWrapping = false; + + // Sweep a band of widths straddling wrap boundaries. The editable glyphs + // and the background box that should hug them must break into the same + // number of lines. The horizontal-jump regression makes the background + // wrap at a different column than the editable text. + for (final width in [140.0, 160.0, 180.0, 200.0, 220.0, 240.0]) { + final (edWidth, bgHeight) = await measure(text, width); + final editableLines = editableLineCount(edWidth); + final backgroundLines = backgroundLineCount(bgHeight); + + if (editableLines > 1) sawWrapping = true; + + expect( + editableLines, + backgroundLines, + reason: + 'At width=$width the editable text wrapped into ' + '$editableLines lines but the background box into ' + '$backgroundLines: the two disagree on the wrap column.', + ); + } + + expect( + sawWrapping, + isTrue, + reason: 'Test is vacuous unless the text actually wraps at some width.', + ); + }, + ); +}