From 6d165832d9a909e38efc560930068c96e17372f5 Mon Sep 17 00:00:00 2001 From: daraadedeji07 Date: Mon, 21 Sep 2026 03:41:40 +0000 Subject: [PATCH 01/10] feat(text): markdown formatting with floating format bar Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../text/formatted_text_view.dart | 87 +++ .../text/markup_text_editing_controller.dart | 45 ++ .../text/text_format_bar.dart | 116 ++++ .../draggable_widgets/text/text_markup.dart | 594 ++++++++++++++++++ .../draggable_widgets/text/text_widget.dart | 284 +++++---- lib/widgets/editor_toolbar.dart | 52 +- test/formatted_text_view_test.dart | 57 ++ test/text_markup_test.dart | 135 ++++ test/text_widget_resilience_test.dart | 43 +- 9 files changed, 1278 insertions(+), 135 deletions(-) create mode 100644 lib/widgets/draggable_widgets/text/formatted_text_view.dart create mode 100644 lib/widgets/draggable_widgets/text/markup_text_editing_controller.dart create mode 100644 lib/widgets/draggable_widgets/text/text_format_bar.dart create mode 100644 lib/widgets/draggable_widgets/text/text_markup.dart create mode 100644 test/formatted_text_view_test.dart create mode 100644 test/text_markup_test.dart diff --git a/lib/widgets/draggable_widgets/text/formatted_text_view.dart b/lib/widgets/draggable_widgets/text/formatted_text_view.dart new file mode 100644 index 00000000..53511254 --- /dev/null +++ b/lib/widgets/draggable_widgets/text/formatted_text_view.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; +import 'package:icarus/widgets/draggable_widgets/text/text_markup.dart'; + +class FormattedTextView extends StatelessWidget { + const FormattedTextView({ + super.key, + required this.text, + required this.style, + required this.hintText, + }); + + final String text; + final TextStyle style; + final String hintText; + + @override + Widget build(BuildContext context) { + if (text.isEmpty) { + return Text( + hintText, + style: style.copyWith(color: Colors.grey), + ); + } + final lines = parseMarkup(text); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + for (final line in lines) _buildLine(line), + ], + ); + } + + Widget _buildLine(MarkupLine line) { + if (line.plainText.isEmpty) { + return SizedBox( + height: (style.fontSize ?? 14) * (style.height ?? 1.2), + ); + } + final contentStyle = line.kind == MarkupLineKind.heading + ? style.copyWith( + fontWeight: FontWeight.w600, + fontSize: (style.fontSize ?? 14) * markupHeadingScale, + ) + : style; + final content = Text.rich( + TextSpan( + style: contentStyle, + children: [ + for (final inline in line.inlines) + if (!inline.isMarker) + TextSpan( + text: inline.raw, + style: contentStyle.copyWith( + fontWeight: + inline.bold ? FontWeight.w700 : contentStyle.fontWeight, + fontStyle: + inline.italic ? FontStyle.italic : contentStyle.fontStyle, + ), + ), + ], + ), + ); + if (line.kind == MarkupLineKind.paragraph || + line.kind == MarkupLineKind.heading) { + return content; + } + final glyph = + line.kind == MarkupLineKind.bullet ? '•' : '${line.number ?? 1}.'; + final glyphWidth = (style.fontSize ?? 14) * 1.6; + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: glyphWidth, + child: Text( + glyph, + textAlign: TextAlign.right, + style: style, + ), + ), + SizedBox(width: (style.fontSize ?? 14) * 0.4), + Expanded(child: content), + ], + ); + } +} diff --git a/lib/widgets/draggable_widgets/text/markup_text_editing_controller.dart b/lib/widgets/draggable_widgets/text/markup_text_editing_controller.dart new file mode 100644 index 00000000..e77bea90 --- /dev/null +++ b/lib/widgets/draggable_widgets/text/markup_text_editing_controller.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; +import 'package:icarus/const/settings.dart'; +import 'package:icarus/widgets/draggable_widgets/text/text_markup.dart'; + +class MarkupTextEditingController extends TextEditingController { + MarkupTextEditingController({super.text}); + + @override + TextSpan buildTextSpan({ + required BuildContext context, + TextStyle? style, + required bool withComposing, + }) { + final baseStyle = style ?? DefaultTextStyle.of(context).style; + final mutedStyle = baseStyle.copyWith( + color: Settings.tacticalVioletTheme.mutedForeground, + ); + final children = []; + final lines = parseMarkup(text); + for (var index = 0; index < lines.length; index++) { + final line = lines[index]; + if (line.prefix.isNotEmpty) { + children.add(TextSpan(text: line.prefix, style: mutedStyle)); + } + final headingStyle = line.kind == MarkupLineKind.heading + ? baseStyle.copyWith( + fontWeight: FontWeight.w600, + fontSize: (baseStyle.fontSize ?? 14) * markupHeadingScale, + ) + : baseStyle; + for (final inline in line.inlines) { + final inlineStyle = headingStyle.copyWith( + color: inline.isMarker + ? Settings.tacticalVioletTheme.mutedForeground + : headingStyle.color, + fontWeight: inline.bold ? FontWeight.w700 : headingStyle.fontWeight, + fontStyle: inline.italic ? FontStyle.italic : headingStyle.fontStyle, + ); + children.add(TextSpan(text: inline.raw, style: inlineStyle)); + } + if (index != lines.length - 1) children.add(const TextSpan(text: '\n')); + } + return TextSpan(style: baseStyle, children: children); + } +} diff --git a/lib/widgets/draggable_widgets/text/text_format_bar.dart b/lib/widgets/draggable_widgets/text/text_format_bar.dart new file mode 100644 index 00000000..30b6e293 --- /dev/null +++ b/lib/widgets/draggable_widgets/text/text_format_bar.dart @@ -0,0 +1,116 @@ +import 'package:flutter/material.dart'; +import 'package:icarus/const/settings.dart'; +import 'package:icarus/widgets/editor_toolbar.dart'; +import 'package:icarus/widgets/draggable_widgets/text/text_markup.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +class ToggleBoldIntent extends Intent { + const ToggleBoldIntent(); +} + +class ToggleItalicIntent extends Intent { + const ToggleItalicIntent(); +} + +class TextFormatBar extends StatelessWidget { + const TextFormatBar({ + super.key, + required this.controller, + required this.onApply, + required this.tapRegionGroupId, + }); + + static const double width = 6 * 28 + 1 + 8 + 8; + static const double height = 36; + + final TextEditingController controller; + final ValueChanged onApply; + final Object tapRegionGroupId; + + @override + Widget build(BuildContext context) { + return ExcludeFocus( + child: TapRegion( + groupId: tapRegionGroupId, + child: ListenableBuilder( + listenable: controller, + builder: (context, _) { + final value = controller.value; + const style = EditorToolbarButtonStyle(size: 28, iconSize: 16); + return Container( + width: width, + height: height, + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: Settings.tacticalVioletTheme.card, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Settings.tacticalVioletTheme.border), + boxShadow: const [Settings.floatingMenuShadow], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + EditorToolbarButton( + style: style, + tooltip: 'Bold (Ctrl+B)', + active: MarkupEditing.isInlineActive(value, '**'), + onPressed: () => onApply( + MarkupEditing.toggleInline(value, '**'), + ), + icon: const Icon(LucideIcons.bold200), + ), + EditorToolbarButton( + style: style, + tooltip: 'Italic (Ctrl+I)', + active: MarkupEditing.isInlineActive(value, '*'), + onPressed: () => onApply( + MarkupEditing.toggleInline(value, '*'), + ), + icon: const Icon(LucideIcons.italic200), + ), + const EditorToolbarDivider(), + EditorToolbarButton( + style: style, + tooltip: 'Bullet list', + active: MarkupEditing.lineKindAt(value) == + MarkupLineKind.bullet, + onPressed: () => onApply( + MarkupEditing.toggleLineKind( + value, MarkupLineKind.bullet), + ), + icon: const Icon(LucideIcons.list200), + ), + EditorToolbarButton( + style: style, + tooltip: 'Numbered list', + active: MarkupEditing.lineKindAt(value) == + MarkupLineKind.numbered, + onPressed: () => onApply( + MarkupEditing.toggleLineKind( + value, + MarkupLineKind.numbered, + ), + ), + icon: const Icon(LucideIcons.listOrdered200), + ), + EditorToolbarButton( + style: style, + tooltip: 'Heading', + active: MarkupEditing.lineKindAt(value) == + MarkupLineKind.heading, + onPressed: () => onApply( + MarkupEditing.toggleLineKind( + value, MarkupLineKind.heading), + ), + icon: const Icon(LucideIcons.heading200), + ), + ], + ), + ); + }, + ), + ), + ); + } +} diff --git a/lib/widgets/draggable_widgets/text/text_markup.dart b/lib/widgets/draggable_widgets/text/text_markup.dart new file mode 100644 index 00000000..748a25fc --- /dev/null +++ b/lib/widgets/draggable_widgets/text/text_markup.dart @@ -0,0 +1,594 @@ +import 'package:flutter/services.dart'; + +const double markupHeadingScale = 1.25; + +enum MarkupLineKind { paragraph, bullet, numbered, heading } + +class MarkupInline { + const MarkupInline({ + required this.raw, + this.bold = false, + this.italic = false, + this.isMarker = false, + }); + + final String raw; + final bool bold; + final bool italic; + final bool isMarker; +} + +class MarkupLine { + const MarkupLine({ + required this.kind, + required this.prefix, + this.number, + required this.inlines, + }); + + final MarkupLineKind kind; + final String prefix; + final int? number; + final List inlines; + + String get plainText => inlines + .where((inline) => !inline.isMarker) + .map((inline) => inline.raw) + .join(); +} + +final RegExp _bulletPrefix = RegExp(r'^[-*•]\s+'); +final RegExp _numberedPrefix = RegExp(r'^\d+[.)]\s+'); +final RegExp _headingPrefix = RegExp(r'^#{1,3}\s+'); + +List parseMarkup(String text) { + final lines = text.split('\n'); + final result = []; + var previousWasNumbered = false; + var nextNumber = 1; + + for (final line in lines) { + final prefixMatch = _linePrefix(line); + final prefix = prefixMatch?.group(0) ?? ''; + final content = line.substring(prefix.length); + final kind = prefixMatch == null + ? MarkupLineKind.paragraph + : prefix.startsWith('#') + ? MarkupLineKind.heading + : _numberedPrefix.hasMatch(prefix) + ? MarkupLineKind.numbered + : MarkupLineKind.bullet; + int? number; + if (kind == MarkupLineKind.numbered) { + final typedNumber = int.tryParse(prefix.split(RegExp(r'[.)]')).first); + if (!previousWasNumbered) { + nextNumber = typedNumber ?? 1; + } + number = nextNumber++; + } else { + nextNumber = 1; + } + previousWasNumbered = kind == MarkupLineKind.numbered; + result.add( + MarkupLine( + kind: kind, + prefix: prefix, + number: number, + inlines: _parseInline(content), + ), + ); + } + return result; +} + +RegExpMatch? _linePrefix(String line) { + return _bulletPrefix.firstMatch(line) ?? + _numberedPrefix.firstMatch(line) ?? + _headingPrefix.firstMatch(line); +} + +List _parseInline(String text) { + final result = []; + var cursor = 0; + while (cursor < text.length) { + final match = _inlineOpen(text, cursor); + if (match == null) { + final next = _nextInlineOpen(text, cursor + 1); + final end = next ?? text.length; + result.add(MarkupInline(raw: text.substring(cursor, end))); + cursor = end; + continue; + } + final marker = match.marker; + final close = text.indexOf(marker, match.contentStart); + if (close <= match.contentStart || + (match.marker == '*' && + match.contentStart < text.length && + RegExp(r'\s').hasMatch(text[match.contentStart]))) { + result.add(MarkupInline(raw: text[cursor])); + cursor++; + continue; + } + final content = text.substring(match.contentStart, close); + final nested = _parseInline(content); + result.add(MarkupInline( + raw: marker, bold: match.bold, italic: match.italic, isMarker: true)); + if (nested.isEmpty) { + result.add(MarkupInline( + raw: content, + bold: match.bold, + italic: match.italic, + )); + } else { + result.addAll( + nested.map( + (inline) => MarkupInline( + raw: inline.raw, + bold: inline.bold || match.bold, + italic: inline.italic || match.italic, + isMarker: inline.isMarker, + ), + ), + ); + } + result.add(MarkupInline( + raw: marker, bold: match.bold, italic: match.italic, isMarker: true)); + cursor = close + marker.length; + } + return result; +} + +_InlineOpen? _inlineOpen(String text, int offset) { + if (text.startsWith('***', offset)) { + return _InlineOpen('***', offset + 3, bold: true, italic: true); + } + if (text.startsWith('**', offset)) { + return _InlineOpen('**', offset + 2, bold: true); + } + if (text[offset] == '*' || text[offset] == '_') { + final marker = text[offset]; + if (marker == '*' && + offset + 1 < text.length && + RegExp(r'\s').hasMatch(text[offset + 1])) { + return null; + } + return _InlineOpen(marker, offset + 1, italic: true); + } + return null; +} + +int? _nextInlineOpen(String text, int offset) { + for (var i = offset; i < text.length; i++) { + if (_inlineOpen(text, i) != null) return i; + } + return null; +} + +class _InlineOpen { + const _InlineOpen( + this.marker, + this.contentStart, { + this.bold = false, + this.italic = false, + }); + + final String marker; + final int contentStart; + final bool bold; + final bool italic; +} + +abstract final class MarkupEditing { + static TextEditingValue toggleInline(TextEditingValue value, String marker) { + if (marker != '**' && marker != '*') return value; + final text = value.text; + final selection = value.selection; + if (!selection.isValid) return value; + if (!selection.isCollapsed) { + return _toggleSelected(value, marker); + } + + final word = _wordAtCaret(text, selection.extentOffset); + if (word != null) { + final selected = value.copyWith( + selection: + TextSelection(baseOffset: word.start, extentOffset: word.end), + ); + return _toggleSelected(selected, marker); + } + final offset = selection.extentOffset.clamp(0, text.length).toInt(); + final next = text.replaceRange(offset, offset, '$marker$marker'); + return value.copyWith( + text: next, + selection: TextSelection.collapsed(offset: offset + marker.length), + composing: TextRange.empty, + ); + } + + static TextEditingValue _toggleSelected( + TextEditingValue value, String marker) { + final text = value.text; + final selection = value.selection; + final start = selection.start; + final end = selection.end; + var contentStart = start; + var contentEnd = end; + while (contentStart < contentEnd && + RegExp(r'\s').hasMatch(text[contentStart])) { + contentStart++; + } + while (contentEnd > contentStart && + RegExp(r'\s').hasMatch(text[contentEnd - 1])) { + contentEnd--; + } + if (contentStart == contentEnd) return value; + final leading = text.substring(start, contentStart); + final trailing = text.substring(contentEnd, end); + final enclosing = _enclosingMarker(text, contentStart, contentEnd, marker); + if (enclosing != null) { + final inner = text.substring(contentStart, contentEnd); + final replacement = enclosing.replacementMarker == null + ? '$leading$inner$trailing' + : '$leading${enclosing.replacementMarker}$inner${enclosing.replacementMarker}$trailing'; + final next = + text.replaceRange(enclosing.start, enclosing.end, replacement); + final newStart = enclosing.start + leading.length; + return value.copyWith( + text: next, + selection: TextSelection( + baseOffset: newStart, + extentOffset: newStart + (contentEnd - contentStart), + ), + composing: TextRange.empty, + ); + } + + final wrapper = (marker, marker); + final replacement = + '$leading${wrapper.$1}${text.substring(contentStart, contentEnd)}${wrapper.$2}$trailing'; + final next = text.replaceRange(start, end, replacement); + final newStart = start + leading.length + wrapper.$1.length; + return value.copyWith( + text: next, + selection: TextSelection( + baseOffset: newStart, + extentOffset: newStart + contentEnd - contentStart, + ), + composing: TextRange.empty, + ); + } + + static _MarkerRange? _enclosingMarker( + String text, + int start, + int end, + String marker, + ) { + if (marker == '*' && + start >= 3 && + end + 3 <= text.length && + text.substring(start - 3, start) == '***' && + text.substring(end, end + 3) == '***') { + return _MarkerRange(start - 3, end + 3, replacementMarker: '**'); + } + if (start >= marker.length && + end + marker.length <= text.length && + text.substring(start - marker.length, start) == marker && + text.substring(end, end + marker.length) == marker && + !(marker == '*' && + ((start >= 2 && text.substring(start - 2, start) == '**') || + (end + 2 <= text.length && + text.substring(end, end + 2) == '**')))) { + return _MarkerRange(start - marker.length, end + marker.length); + } + if (marker == '**' && + start >= 3 && + end + 3 <= text.length && + text.substring(start - 3, start) == '***' && + text.substring(end, end + 3) == '***') { + return _MarkerRange(start - 3, end + 3, replacementMarker: '*'); + } + return null; + } + + static TextEditingValue toggleLineKind( + TextEditingValue value, + MarkupLineKind kind, + ) { + if (kind == MarkupLineKind.paragraph) { + return _setLineKind(value, kind); + } + return _setLineKind(value, kind); + } + + static TextEditingValue _setLineKind( + TextEditingValue value, + MarkupLineKind kind, + ) { + final text = value.text; + final lines = text.split('\n'); + final starts = _lineStarts(lines); + final selection = value.selection; + final first = _lineIndexAt(starts, selection.start); + final last = _lineIndexAt(starts, selection.end); + final parsed = parseMarkup(text); + final allMatch = List.generate( + last - first + 1, + (index) => parsed[first + index].kind == kind, + ).every((match) => match); + final offsets = []; + var output = StringBuffer(); + var newSelectionBase = 0; + var newSelectionExtent = 0; + for (var i = 0; i < lines.length; i++) { + final oldPrefix = parsed[i].prefix; + final shouldStrip = kind == MarkupLineKind.paragraph || allMatch; + final newPrefix = shouldStrip ? '' : _prefixFor(kind, i, lines); + final lineStart = starts[i]; + int mapOffset(int offset) { + final local = (offset - lineStart).clamp(0, lines[i].length).toInt(); + return output.length + + (local <= oldPrefix.length + ? newPrefix.length + : newPrefix.length + local - oldPrefix.length); + } + + if (selection.baseOffset >= lineStart && + selection.baseOffset <= lineStart + lines[i].length) { + newSelectionBase = mapOffset(selection.baseOffset); + } + if (selection.extentOffset >= lineStart && + selection.extentOffset <= lineStart + lines[i].length) { + newSelectionExtent = mapOffset(selection.extentOffset); + } + output.write(newPrefix); + output.write(lines[i].substring(oldPrefix.length)); + if (i != lines.length - 1) output.write('\n'); + offsets.add(output.length); + } + var result = value.copyWith( + text: output.toString(), + selection: TextSelection( + baseOffset: newSelectionBase, + extentOffset: newSelectionExtent, + ), + composing: TextRange.empty, + ); + if (kind == MarkupLineKind.numbered && !allMatch) { + result = _renumber(result); + } + return result; + } + + static String _prefixFor( + MarkupLineKind kind, + int index, + List lines, + ) { + switch (kind) { + case MarkupLineKind.bullet: + return '- '; + case MarkupLineKind.heading: + return '# '; + case MarkupLineKind.numbered: + var ordinal = 1; + for (var i = index - 1; i >= 0; i--) { + final match = _numberedPrefix.firstMatch(lines[i]); + if (match == null) break; + ordinal++; + } + return '$ordinal. '; + case MarkupLineKind.paragraph: + return ''; + } + } + + static TextEditingValue _renumber(TextEditingValue value) { + final lines = value.text.split('\n'); + final parsed = parseMarkup(value.text); + final starts = _lineStarts(lines); + final selected = value.selection; + final output = StringBuffer(); + var next = 1; + var inRun = false; + var base = selected.baseOffset; + var extent = selected.extentOffset; + for (var i = 0; i < lines.length; i++) { + final oldPrefix = parsed[i].prefix; + var prefix = oldPrefix; + if (parsed[i].kind == MarkupLineKind.numbered) { + if (!inRun) { + next = parsed[i].number ?? 1; + inRun = true; + } + prefix = '$next. '; + next++; + } else { + inRun = false; + } + final shift = prefix.length - oldPrefix.length; + final lineStart = starts[i]; + if (selected.baseOffset >= lineStart && + selected.baseOffset <= lineStart + lines[i].length) { + base += shift; + } + if (selected.extentOffset >= lineStart && + selected.extentOffset <= lineStart + lines[i].length) { + extent += shift; + } + output.write(prefix); + output.write(lines[i].substring(oldPrefix.length)); + if (i != lines.length - 1) output.write('\n'); + } + return value.copyWith( + text: output.toString(), + selection: TextSelection(baseOffset: base, extentOffset: extent), + ); + } + + static bool isInlineActive(TextEditingValue value, String marker) { + final position = value.selection.extentOffset; + final lines = parseMarkup(value.text); + var offset = 0; + for (final line in lines) { + final lineEnd = offset + + line.prefix.length + + line.inlines.fold(0, (sum, inline) => sum + inline.raw.length); + if (position <= lineEnd) { + var cursor = offset + line.prefix.length; + for (final inline in line.inlines) { + final end = cursor + inline.raw.length; + if (position >= cursor && + position <= end && + !inline.isMarker && + ((marker == '**' && inline.bold) || + (marker == '*' && inline.italic))) { + return true; + } + cursor = end; + } + return false; + } + offset = lineEnd + 1; + } + return false; + } + + static MarkupLineKind lineKindAt(TextEditingValue value) { + final lines = value.text.split('\n'); + final starts = _lineStarts(lines); + final first = _lineIndexAt(starts, value.selection.start); + final last = _lineIndexAt(starts, value.selection.end); + final parsed = parseMarkup(value.text); + final kind = parsed[first].kind; + if (first != last && + parsed + .skip(first) + .take(last - first + 1) + .any((line) => line.kind != kind)) { + return MarkupLineKind.paragraph; + } + return kind; + } + + static TextEditingValue continueList( + TextEditingValue oldValue, + TextEditingValue newValue, + ) { + final oldSelection = oldValue.selection; + if (!oldSelection.isCollapsed || + newValue.text != + oldValue.text.replaceRange( + oldSelection.extentOffset, + oldSelection.extentOffset, + '\n', + )) { + return newValue; + } + final oldLines = oldValue.text.split('\n'); + final starts = _lineStarts(oldLines); + final lineIndex = _lineIndexAt(starts, oldSelection.extentOffset); + final parsed = parseMarkup(oldValue.text); + final line = parsed[lineIndex]; + if (line.kind != MarkupLineKind.bullet && + line.kind != MarkupLineKind.numbered) { + return newValue; + } + final lineStart = starts[lineIndex]; + final content = oldValue.text.substring( + lineStart + line.prefix.length, + lineStart + oldLines[lineIndex].length, + ); + if (content.isEmpty) { + final withoutPrefix = oldValue.text.replaceRange( + lineStart, + lineStart + line.prefix.length, + '', + ); + return TextEditingValue( + text: withoutPrefix, + selection: TextSelection.collapsed(offset: lineStart), + ); + } + final continuation = line.kind == MarkupLineKind.bullet + ? line.prefix + : '${(line.number ?? 1) + 1}. '; + final insertedAt = oldSelection.extentOffset + 1; + final continued = + newValue.text.replaceRange(insertedAt, insertedAt, continuation); + return line.kind == MarkupLineKind.numbered + ? _renumber( + newValue.copyWith( + text: continued, + selection: TextSelection.collapsed( + offset: insertedAt + continuation.length, + ), + ), + ) + : newValue.copyWith( + text: continued, + selection: TextSelection.collapsed( + offset: insertedAt + continuation.length, + ), + ); + } + + static List _lineStarts(List lines) { + final starts = []; + var offset = 0; + for (final line in lines) { + starts.add(offset); + offset += line.length + 1; + } + return starts; + } + + static int _lineIndexAt(List starts, int offset) { + for (var i = starts.length - 1; i >= 0; i--) { + if (offset >= starts[i]) return i; + } + return 0; + } + + static _WordRange? _wordAtCaret(String text, int caret) { + if (text.isEmpty) return null; + var position = caret.clamp(0, text.length).toInt(); + if (position == text.length || !_wordCharacter(text[position])) { + if (position > 0 && _wordCharacter(text[position - 1])) position--; + } + if (!_wordCharacter(text[position])) return null; + var start = position; + var end = position + 1; + while (start > 0 && _wordCharacter(text[start - 1])) start--; + while (end < text.length && _wordCharacter(text[end])) end++; + return _WordRange(start, end); + } + + static bool _wordCharacter(String character) => + RegExp(r'[A-Za-z0-9]').hasMatch(character); +} + +class _MarkerRange { + const _MarkerRange(this.start, this.end, {this.replacementMarker}); + + final int start; + final int end; + final String? replacementMarker; +} + +class _WordRange { + const _WordRange(this.start, this.end); + + final int start; + final int end; +} + +class ListContinuationFormatter extends TextInputFormatter { + @override + TextEditingValue formatEditUpdate( + TextEditingValue oldValue, + TextEditingValue newValue, + ) { + return MarkupEditing.continueList(oldValue, newValue); + } +} diff --git a/lib/widgets/draggable_widgets/text/text_widget.dart b/lib/widgets/draggable_widgets/text/text_widget.dart index 5838d629..e2f50d72 100644 --- a/lib/widgets/draggable_widgets/text/text_widget.dart +++ b/lib/widgets/draggable_widgets/text/text_widget.dart @@ -1,9 +1,14 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/coordinate_system.dart'; import 'package:icarus/providers/screenshot_provider.dart'; import 'package:icarus/providers/text_draft_provider.dart'; import 'package:icarus/providers/text_widget_height_provider.dart'; +import 'package:icarus/widgets/draggable_widgets/text/formatted_text_view.dart'; +import 'package:icarus/widgets/draggable_widgets/text/markup_text_editing_controller.dart'; +import 'package:icarus/widgets/draggable_widgets/text/text_format_bar.dart'; +import 'package:icarus/widgets/draggable_widgets/text/text_markup.dart'; import 'package:icarus/widgets/text_editing_shortcut_scope.dart'; class TextWidget extends ConsumerWidget { @@ -47,9 +52,10 @@ class TextWidget extends ConsumerWidget { } const _textFieldDecoration = InputDecoration( - hintText: "Write here...", + hintText: 'Write here...', hintStyle: TextStyle(color: Colors.grey), border: InputBorder.none, + contentPadding: EdgeInsets.symmetric(vertical: 12), ); class _EditableTextWidget extends ConsumerStatefulWidget { @@ -73,22 +79,24 @@ class _EditableTextWidget extends ConsumerStatefulWidget { } class _EditableTextWidgetState extends ConsumerState<_EditableTextWidget> { - late final TextEditingController _controller; + late final MarkupTextEditingController _controller; late final FocusNode _focusNode; late final TextDraftProvider _draftNotifier; late final ProviderSubscription> _draftSubscription; + final _tapGroup = Object(); + final _portalController = OverlayPortalController(); + bool _editing = false; @override void initState() { super.initState(); _draftNotifier = ref.read(textDraftProvider.notifier); - _controller = TextEditingController(text: _effectiveText()); + _controller = MarkupTextEditingController(text: _effectiveText()); _focusNode = FocusNode()..addListener(_onFocusChange); _draftSubscription = ref.listenManual>( textDraftProvider, (_, __) => _syncControllerWithExternalState(), ); - WidgetsBinding.instance.addPostFrameCallback((_) { _updateMeasuredSize(); }); @@ -124,21 +132,21 @@ class _EditableTextWidgetState extends ConsumerState<_EditableTextWidget> { void _onFocusChange() { if (_focusNode.hasFocus) return; _draftNotifier.commitDraft(widget.id); + if (!mounted) return; + setState(() => _editing = false); + _portalController.hide(); } void _syncControllerWithExternalState() { if (!_controller.value.isComposingRangeValid) { _controller.clearComposing(); } - final nextText = _effectiveText(); if (_controller.text == nextText) return; - final selection = _controller.selection; final baseOffset = selection.baseOffset.clamp(0, nextText.length).toInt(); final extentOffset = selection.extentOffset.clamp(0, nextText.length).toInt(); - _controller.value = TextEditingValue( text: nextText, selection: selection.isValid @@ -147,49 +155,165 @@ class _EditableTextWidgetState extends ConsumerState<_EditableTextWidget> { ); } + TextStyle _bodyStyle(BuildContext context) { + return Theme.of(context).textTheme.bodyLarge!.copyWith( + fontSize: CoordinateSystem.instance.worldHeightToScreen( + widget.fontSize, + ), + ); + } + + void _applyValue(TextEditingValue value) { + _controller.value = value; + _draftNotifier.setDraft(widget.id, value.text); + } + + void _enterEditing() { + if (_editing) return; + setState(() => _editing = true); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _focusNode.requestFocus(); + _controller.selection = + TextSelection.collapsed(offset: _controller.text.length); + _portalController.show(); + }); + } + void _updateMeasuredSize() { if (!mounted) return; - final renderObject = context.findRenderObject(); if (renderObject is! RenderBox) return; - - final offset = Offset(renderObject.size.width, renderObject.size.height); - ref.read(textWidgetHeightProvider.notifier).updateHeight(widget.id, offset); + ref.read(textWidgetHeightProvider.notifier).updateHeight( + widget.id, + Offset(renderObject.size.width, renderObject.size.height), + ); } @override Widget build(BuildContext context) { - return TextEditingShortcutScope( - child: NotificationListener( - onNotification: (notification) { - WidgetsBinding.instance.addPostFrameCallback((_) { - _updateMeasuredSize(); - }); - return true; - }, - child: SizeChangedLayoutNotifier( - child: _TextBoxFrame( - size: widget.size, - tagColorValue: widget.tagColorValue, - child: _SharedTextField( - controller: _controller, - focusNode: _focusNode, - fontSize: widget.fontSize, - onChanged: (value) { - _draftNotifier.setDraft(widget.id, value); - }, - onTapOutside: (_) { - _focusNode.unfocus(); - }, + final bodyStyle = _bodyStyle(context); + final field = _editing + ? TextField( + focusNode: _focusNode, + controller: _controller, + inputFormatters: [ListContinuationFormatter()], + groupId: _tapGroup, + style: bodyStyle, + decoration: _textFieldDecoration, + maxLines: null, + minLines: null, + expands: true, + onChanged: (value) => _draftNotifier.setDraft(widget.id, value), + onTapOutside: (_) => _focusNode.unfocus(), + ) + : GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _enterEditing, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: ListenableBuilder( + listenable: _controller, + builder: (context, _) => FormattedTextView( + text: _controller.text, + style: bodyStyle, + hintText: 'Write here...', + ), + ), ), + ); + + final measuredFrame = NotificationListener( + onNotification: (notification) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _updateMeasuredSize(); + }); + return true; + }, + child: SizeChangedLayoutNotifier( + child: _TextBoxFrame( + size: widget.size, + tagColorValue: widget.tagColorValue, + child: field, + ), + ), + ); + + return TextEditingShortcutScope( + extraShortcuts: const { + SingleActivator(LogicalKeyboardKey.keyB, control: true): + ToggleBoldIntent(), + SingleActivator(LogicalKeyboardKey.keyB, meta: true): + ToggleBoldIntent(), + SingleActivator(LogicalKeyboardKey.keyI, control: true): + ToggleItalicIntent(), + SingleActivator(LogicalKeyboardKey.keyI, meta: true): + ToggleItalicIntent(), + }, + child: Actions( + actions: { + ToggleBoldIntent: CallbackAction( + onInvoke: (_) { + _applyValue(MarkupEditing.toggleInline(_controller.value, '**')); + return null; + }, + ), + ToggleItalicIntent: CallbackAction( + onInvoke: (_) { + _applyValue(MarkupEditing.toggleInline(_controller.value, '*')); + return null; + }, ), + }, + child: OverlayPortal.overlayChildLayoutBuilder( + controller: _portalController, + overlayChildBuilder: (context, layoutInfo) { + final childRect = MatrixUtils.transformRect( + layoutInfo.childPaintTransform, + Offset.zero & layoutInfo.childSize, + ); + final overlaySize = layoutInfo.overlaySize; + final left = (childRect.center.dx - TextFormatBar.width / 2) + .clamp(8.0, overlaySize.width - TextFormatBar.width - 8) + .toDouble(); + final below = childRect.bottom + 8; + final top = below + TextFormatBar.height + 8 <= overlaySize.height + ? below + : childRect.top - TextFormatBar.height - 8; + return Positioned( + left: left, + top: top, + width: TextFormatBar.width, + height: TextFormatBar.height, + child: Material( + color: Colors.transparent, + child: TweenAnimationBuilder( + duration: const Duration(milliseconds: 150), + tween: Tween(begin: 0, end: 1), + builder: (context, progress, child) => Opacity( + opacity: progress, + child: Transform.translate( + offset: Offset(0, 4 * (1 - progress)), + child: child, + ), + ), + child: TextFormatBar( + controller: _controller, + tapRegionGroupId: _tapGroup, + onApply: _applyValue, + ), + ), + ), + ); + }, + child: measuredFrame, ), ), ); } } -class _FeedbackTextWidget extends StatefulWidget { +class _FeedbackTextWidget extends StatelessWidget { const _FeedbackTextWidget({ super.key, required this.text, @@ -203,96 +327,26 @@ class _FeedbackTextWidget extends StatefulWidget { final double fontSize; final int? tagColorValue; - @override - State<_FeedbackTextWidget> createState() => _FeedbackTextWidgetState(); -} - -class _FeedbackTextWidgetState extends State<_FeedbackTextWidget> { - late final TextEditingController _controller; - - @override - void initState() { - super.initState(); - _controller = TextEditingController(text: widget.text); - } - - @override - void didUpdateWidget(covariant _FeedbackTextWidget oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.text == widget.text) return; - _controller.value = TextEditingValue( - text: widget.text, - selection: TextSelection.collapsed(offset: widget.text.length), - ); - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - @override Widget build(BuildContext context) { + final style = Theme.of(context).textTheme.bodyLarge!.copyWith( + fontSize: CoordinateSystem.instance.worldHeightToScreen(fontSize), + ); return _TextBoxFrame( - size: widget.size, - tagColorValue: widget.tagColorValue, - child: IgnorePointer( - child: _SharedTextField( - controller: _controller, - fontSize: widget.fontSize, - readOnly: true, - enableInteractiveSelection: false, - showCursor: false, + size: size, + tagColorValue: tagColorValue, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: FormattedTextView( + text: text, + style: style, + hintText: 'Write here...', ), ), ); } } -class _SharedTextField extends StatelessWidget { - const _SharedTextField({ - required this.controller, - required this.fontSize, - this.focusNode, - this.readOnly = false, - this.enableInteractiveSelection = true, - this.showCursor = true, - this.onChanged, - this.onTapOutside, - }); - - final TextEditingController controller; - final double fontSize; - final FocusNode? focusNode; - final bool readOnly; - final bool enableInteractiveSelection; - final bool showCursor; - final ValueChanged? onChanged; - final TapRegionCallback? onTapOutside; - - @override - Widget build(BuildContext context) { - final coordinateSystem = CoordinateSystem.instance; - return TextField( - focusNode: focusNode, - controller: controller, - readOnly: readOnly, - enableInteractiveSelection: enableInteractiveSelection, - showCursor: showCursor, - style: TextStyle( - fontSize: coordinateSystem.worldHeightToScreen(fontSize), - ), - decoration: _textFieldDecoration, - maxLines: null, - minLines: null, - expands: true, - onChanged: onChanged, - onTapOutside: onTapOutside, - ); - } -} - class _TextBoxFrame extends StatelessWidget { const _TextBoxFrame({ required this.size, diff --git a/lib/widgets/editor_toolbar.dart b/lib/widgets/editor_toolbar.dart index db5b06ef..4fe29988 100644 --- a/lib/widgets/editor_toolbar.dart +++ b/lib/widgets/editor_toolbar.dart @@ -265,6 +265,7 @@ class EditorToolbarButton extends StatelessWidget { required this.icon, required this.onPressed, this.enabled = true, + this.active = false, this.foregroundColor, this.semanticsLabel, }); @@ -274,6 +275,7 @@ class EditorToolbarButton extends StatelessWidget { final Widget icon; final VoidCallback? onPressed; final bool enabled; + final bool active; /// Overrides the resting color, e.g. destructive for a problem. final Color? foregroundColor; @@ -282,18 +284,12 @@ class EditorToolbarButton extends StatelessWidget { @override Widget build(BuildContext context) { const theme = Settings.tacticalVioletTheme; - final resting = foregroundColor ?? Settings.toolbarGlyph; - return Semantics( - label: semanticsLabel ?? tooltip, - button: true, - enabled: enabled, - onTap: enabled ? onPressed : null, - excludeSemantics: true, - child: ShadTooltip( - builder: (context) => Text(tooltip), - child: IconTheme( - data: IconThemeData(size: style.iconSize, color: resting), - child: ShadIconButton.ghost( + final hasShadTheme = ShadTheme.maybeOf(context) != null; + final resting = active + ? theme.primaryForeground + : foregroundColor ?? Settings.toolbarGlyph; + final iconButton = hasShadTheme + ? ShadIconButton.ghost( width: style.size, height: style.size, enabled: enabled, @@ -302,8 +298,36 @@ class EditorToolbarButton extends StatelessWidget { hoverBackgroundColor: theme.accent, onPressed: onPressed, icon: icon, - ), - ), + ) + : IconButton( + onPressed: enabled ? onPressed : null, + icon: icon, + color: resting, + padding: EdgeInsets.zero, + constraints: BoxConstraints.tightFor( + width: style.size, + height: style.size, + ), + tooltip: tooltip, + ); + final button = IconTheme( + data: IconThemeData(size: style.iconSize, color: resting), + child: iconButton, + ); + return Semantics( + label: semanticsLabel ?? tooltip, + button: true, + enabled: enabled, + onTap: enabled ? onPressed : null, + excludeSemantics: true, + child: DecoratedBox( + decoration: active ? Settings.raisedPrimary(8) : const BoxDecoration(), + child: hasShadTheme + ? ShadTooltip( + builder: (context) => Text(tooltip), + child: button, + ) + : button, ), ); } diff --git a/test/formatted_text_view_test.dart b/test/formatted_text_view_test.dart new file mode 100644 index 00000000..cd429842 --- /dev/null +++ b/test/formatted_text_view_test.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/widgets/draggable_widgets/text/formatted_text_view.dart'; + +void main() { + testWidgets('renders formatted inline text without markers', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: FormattedTextView( + text: '**bold**', + style: TextStyle(fontSize: 14), + hintText: 'Write here...', + ), + ), + ), + ); + final richText = tester.widget(find.byType(RichText)); + expect(richText.text.toPlainText(), 'bold'); + expect(richText.text.style?.fontWeight, isNot(FontWeight.w700)); + bool hasBold(InlineSpan span) { + if (span is TextSpan && span.style?.fontWeight == FontWeight.w700) { + return true; + } + return span is TextSpan && + (span.children ?? const []).any(hasBold); + } + + expect(hasBold(richText.text), isTrue); + expect(find.text('**bold**'), findsNothing); + }); + + testWidgets('renders bullet glyph and empty hint', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: Column( + children: [ + FormattedTextView( + text: '- item', + style: TextStyle(fontSize: 14), + hintText: 'Write here...', + ), + FormattedTextView( + text: '', + style: TextStyle(fontSize: 14), + hintText: 'Write here...', + ), + ], + ), + ), + ), + ); + expect(find.text('•'), findsOneWidget); + expect(find.text('Write here...'), findsOneWidget); + }); +} diff --git a/test/text_markup_test.dart b/test/text_markup_test.dart new file mode 100644 index 00000000..2b483e5a --- /dev/null +++ b/test/text_markup_test.dart @@ -0,0 +1,135 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/widgets/draggable_widgets/text/text_markup.dart'; + +TextEditingValue value( + String text, { + int? start, + int? end, +}) { + final offset = start ?? text.length; + return TextEditingValue( + text: text, + selection: TextSelection( + baseOffset: offset, + extentOffset: end ?? offset, + ), + ); +} + +void main() { + test('parses line kinds and inline markers', () { + final lines = parseMarkup('- bullet\n2. numbered\n# heading\n*foo*'); + expect(lines.map((line) => line.kind), [ + MarkupLineKind.bullet, + MarkupLineKind.numbered, + MarkupLineKind.heading, + MarkupLineKind.paragraph, + ]); + expect(lines[1].number, 2); + expect(lines[3].plainText, 'foo'); + expect(lines[3].inlines.where((inline) => inline.isMarker), hasLength(2)); + }); + + test('keeps unclosed markers literal and parses bold italic', () { + expect(parseMarkup('**unclosed').single.plainText, '**unclosed'); + final inlines = parseMarkup('***x***').single.inlines; + expect(inlines.where((inline) => inline.isMarker), hasLength(2)); + expect(inlines[1].bold, isTrue); + expect(inlines[1].italic, isTrue); + expect(parseMarkup('a * b').single.plainText, 'a * b'); + }); + + test('renumbers numbered runs from the first typed number', () { + final lines = parseMarkup('4. one\n9. two\n10. three'); + expect(lines.map((line) => line.number), [4, 5, 6]); + }); + + test('toggles inline formatting', () { + final wrapped = MarkupEditing.toggleInline( + value('word', start: 0, end: 4), + '**', + ); + expect(wrapped.text, '**word**'); + expect( + wrapped.selection, const TextSelection(baseOffset: 2, extentOffset: 6)); + final unwrapped = MarkupEditing.toggleInline( + value('**word**', start: 2, end: 6), + '**', + ); + expect(unwrapped.text, 'word'); + expect(unwrapped.selection, + const TextSelection(baseOffset: 0, extentOffset: 4)); + expect(MarkupEditing.toggleInline(value('word', start: 2), '*').text, + '*word*'); + expect(MarkupEditing.toggleInline(value('', start: 0), '**').selection, + const TextSelection.collapsed(offset: 2)); + expect( + MarkupEditing.toggleInline(value(' word ', start: 0, end: 8), '**') + .text, + ' **word** ', + ); + expect( + MarkupEditing.toggleInline(value('**word**', start: 2, end: 6), '*').text, + '***word***', + ); + }); + + test('toggles line kinds and preserves logical selection', () { + final added = MarkupEditing.toggleLineKind( + value('one\ntwo', start: 0, end: 7), + MarkupLineKind.bullet, + ); + expect(added.text, '- one\n- two'); + expect( + added.selection, const TextSelection(baseOffset: 2, extentOffset: 11)); + final removed = MarkupEditing.toggleLineKind( + value('- one\n- two', start: 2, end: 9), + MarkupLineKind.bullet, + ); + expect(removed.text, 'one\ntwo'); + final replaced = MarkupEditing.toggleLineKind( + value('- one\n- two', start: 2, end: 9), + MarkupLineKind.numbered, + ); + expect(replaced.text, '1. one\n2. two'); + }); + + test('continues and exits lists', () { + expect( + MarkupEditing.continueList( + value('- one', start: 5), + value('- one\n', start: 6), + ).text, + '- one\n- ', + ); + expect( + MarkupEditing.continueList( + value('3. one', start: 6), + value('3. one\n', start: 7), + ).text, + '3. one\n4. ', + ); + expect( + MarkupEditing.continueList( + value('- ', start: 2), + value('- \n', start: 3), + ).text, + '', + ); + expect( + MarkupEditing.continueList( + value('# heading', start: 9), + value('# heading\n', start: 10), + ).text, + '# heading\n', + ); + expect( + MarkupEditing.continueList( + value('- one', start: 5), + value('- ones', start: 6), + ).text, + '- ones', + ); + }); +} diff --git a/test/text_widget_resilience_test.dart b/test/text_widget_resilience_test.dart index 2c09fe9b..4e545f58 100644 --- a/test/text_widget_resilience_test.dart +++ b/test/text_widget_resilience_test.dart @@ -19,6 +19,7 @@ import 'package:icarus/providers/strategy_settings_provider.dart'; import 'package:icarus/providers/text_draft_provider.dart'; import 'package:icarus/providers/text_provider.dart'; import 'package:icarus/widgets/draggable_widgets/text/placed_text_builder.dart'; +import 'package:icarus/widgets/draggable_widgets/text/formatted_text_view.dart'; import 'package:icarus/widgets/draggable_widgets/text/text_widget.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; @@ -158,6 +159,8 @@ void main() { ]); await tester.pumpWidget(buildTextHarness(container)); + await tester.tap(find.byType(FormattedTextView)); + await tester.pump(); await tester.enterText(find.byType(TextField), 'edited'); await tester.pump(); @@ -181,6 +184,8 @@ void main() { ]); await tester.pumpWidget(buildPlacedTextHarness(container)); + await tester.tap(find.byType(FormattedTextView)); + await tester.pump(); await tester.enterText(find.byType(TextField), 'edited during drag'); await tester.pump(); @@ -201,6 +206,8 @@ void main() { ]); await tester.pumpWidget(buildTextHarness(container)); + await tester.tap(find.byType(FormattedTextView)); + await tester.pump(); await tester.enterText(find.byType(TextField), 'saved draft'); await tester.pump(); @@ -218,6 +225,8 @@ void main() { ]); await tester.pumpWidget(buildTextHarness(container, marker: 'a')); + await tester.tap(find.byType(FormattedTextView)); + await tester.pump(); await tester.enterText(find.byType(TextField), 'draft survives rebuild'); await tester.pump(); @@ -280,6 +289,8 @@ void main() { ..activePageID = page.id; await tester.pumpWidget(buildTextHarness(container)); + await tester.tap(find.byType(FormattedTextView)); + await tester.pump(); await tester.enterText(find.byType(TextField), 'before edited'); await tester.pump(); @@ -328,10 +339,7 @@ void main() { await tester.pumpWidget(buildTextHarness(container)); await tester.pump(); - var field = tester.widget(find.byType(TextField)); - expect(field.readOnly, isTrue); - expect(field.enableInteractiveSelection, isFalse); - expect(field.showCursor, isFalse); + expect(find.byType(FormattedTextView), findsOneWidget); container.read(textProvider.notifier).fromHive([ PlacedText(id: 'text-1', position: const Offset(10, 20)) @@ -340,7 +348,30 @@ void main() { await tester.pump(); expect(tester.takeException(), isNull); - field = tester.widget(find.byType(TextField)); - expect(field.controller!.text, 'next page'); + expect(find.byType(FormattedTextView), findsOneWidget); + expect(find.text('next page'), findsOneWidget); + }); + + testWidgets('tap enters editing and tapping outside commits formatted text', + (tester) async { + final container = createContainer(); + container.read(textProvider.notifier).fromHive([ + PlacedText(id: 'text-1', position: const Offset(10, 20))..text = 'before', + ]); + + await tester.pumpWidget(buildTextHarness(container)); + expect(find.byType(TextField), findsNothing); + await tester.tap(find.byType(FormattedTextView)); + await tester.pump(); + expect(find.byType(TextField), findsOneWidget); + + await tester.enterText(find.byType(TextField), '**edited**'); + await tester.pump(); + await tester.tap(find.text('a')); + await tester.pump(); + + expect(find.byType(TextField), findsNothing); + expect(find.byType(FormattedTextView), findsOneWidget); + expect(container.read(textProvider).single.text, '**edited**'); }); } From 10350dbad792d09816f3e037c7346b092fb85f62 Mon Sep 17 00:00:00 2001 From: daraadedeji07 Date: Mon, 21 Sep 2026 03:43:27 +0000 Subject: [PATCH 02/10] refactor(text): drop test-only toolbar fallback, tidy markup helpers Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../text/formatted_text_view.dart | 13 ++--- .../draggable_widgets/text/text_markup.dart | 28 +++-------- lib/widgets/editor_toolbar.dart | 49 ++++++------------- test/text_widget_resilience_test.dart | 2 +- 4 files changed, 32 insertions(+), 60 deletions(-) diff --git a/lib/widgets/draggable_widgets/text/formatted_text_view.dart b/lib/widgets/draggable_widgets/text/formatted_text_view.dart index 53511254..df9225c0 100644 --- a/lib/widgets/draggable_widgets/text/formatted_text_view.dart +++ b/lib/widgets/draggable_widgets/text/formatted_text_view.dart @@ -21,26 +21,27 @@ class FormattedTextView extends StatelessWidget { style: style.copyWith(color: Colors.grey), ); } + final fontSize = style.fontSize ?? 14; final lines = parseMarkup(text); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ - for (final line in lines) _buildLine(line), + for (final line in lines) _buildLine(line, fontSize), ], ); } - Widget _buildLine(MarkupLine line) { + Widget _buildLine(MarkupLine line, double fontSize) { if (line.plainText.isEmpty) { return SizedBox( - height: (style.fontSize ?? 14) * (style.height ?? 1.2), + height: fontSize * (style.height ?? 1.2), ); } final contentStyle = line.kind == MarkupLineKind.heading ? style.copyWith( fontWeight: FontWeight.w600, - fontSize: (style.fontSize ?? 14) * markupHeadingScale, + fontSize: fontSize * markupHeadingScale, ) : style; final content = Text.rich( @@ -67,7 +68,7 @@ class FormattedTextView extends StatelessWidget { } final glyph = line.kind == MarkupLineKind.bullet ? '•' : '${line.number ?? 1}.'; - final glyphWidth = (style.fontSize ?? 14) * 1.6; + final glyphWidth = fontSize * 1.6; return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -79,7 +80,7 @@ class FormattedTextView extends StatelessWidget { style: style, ), ), - SizedBox(width: (style.fontSize ?? 14) * 0.4), + SizedBox(width: fontSize * 0.4), Expanded(child: content), ], ); diff --git a/lib/widgets/draggable_widgets/text/text_markup.dart b/lib/widgets/draggable_widgets/text/text_markup.dart index 748a25fc..84017464 100644 --- a/lib/widgets/draggable_widgets/text/text_markup.dart +++ b/lib/widgets/draggable_widgets/text/text_markup.dart @@ -40,6 +40,7 @@ class MarkupLine { final RegExp _bulletPrefix = RegExp(r'^[-*•]\s+'); final RegExp _numberedPrefix = RegExp(r'^\d+[.)]\s+'); final RegExp _headingPrefix = RegExp(r'^#{1,3}\s+'); +final RegExp _whitespace = RegExp(r'\s'); List parseMarkup(String text) { final lines = text.split('\n'); @@ -104,7 +105,7 @@ List _parseInline(String text) { if (close <= match.contentStart || (match.marker == '*' && match.contentStart < text.length && - RegExp(r'\s').hasMatch(text[match.contentStart]))) { + _whitespace.hasMatch(text[match.contentStart]))) { result.add(MarkupInline(raw: text[cursor])); cursor++; continue; @@ -149,7 +150,7 @@ _InlineOpen? _inlineOpen(String text, int offset) { final marker = text[offset]; if (marker == '*' && offset + 1 < text.length && - RegExp(r'\s').hasMatch(text[offset + 1])) { + _whitespace.hasMatch(text[offset + 1])) { return null; } return _InlineOpen(marker, offset + 1, italic: true); @@ -213,12 +214,12 @@ abstract final class MarkupEditing { final end = selection.end; var contentStart = start; var contentEnd = end; - while (contentStart < contentEnd && - RegExp(r'\s').hasMatch(text[contentStart])) { + while ( + contentStart < contentEnd && _whitespace.hasMatch(text[contentStart])) { contentStart++; } while (contentEnd > contentStart && - RegExp(r'\s').hasMatch(text[contentEnd - 1])) { + _whitespace.hasMatch(text[contentEnd - 1])) { contentEnd--; } if (contentStart == contentEnd) return value; @@ -243,11 +244,10 @@ abstract final class MarkupEditing { ); } - final wrapper = (marker, marker); final replacement = - '$leading${wrapper.$1}${text.substring(contentStart, contentEnd)}${wrapper.$2}$trailing'; + '$leading$marker${text.substring(contentStart, contentEnd)}$marker$trailing'; final next = text.replaceRange(start, end, replacement); - final newStart = start + leading.length + wrapper.$1.length; + final newStart = start + leading.length + marker.length; return value.copyWith( text: next, selection: TextSelection( @@ -294,16 +294,6 @@ abstract final class MarkupEditing { static TextEditingValue toggleLineKind( TextEditingValue value, MarkupLineKind kind, - ) { - if (kind == MarkupLineKind.paragraph) { - return _setLineKind(value, kind); - } - return _setLineKind(value, kind); - } - - static TextEditingValue _setLineKind( - TextEditingValue value, - MarkupLineKind kind, ) { final text = value.text; final lines = text.split('\n'); @@ -316,7 +306,6 @@ abstract final class MarkupEditing { last - first + 1, (index) => parsed[first + index].kind == kind, ).every((match) => match); - final offsets = []; var output = StringBuffer(); var newSelectionBase = 0; var newSelectionExtent = 0; @@ -344,7 +333,6 @@ abstract final class MarkupEditing { output.write(newPrefix); output.write(lines[i].substring(oldPrefix.length)); if (i != lines.length - 1) output.write('\n'); - offsets.add(output.length); } var result = value.copyWith( text: output.toString(), diff --git a/lib/widgets/editor_toolbar.dart b/lib/widgets/editor_toolbar.dart index 4fe29988..7f0b960e 100644 --- a/lib/widgets/editor_toolbar.dart +++ b/lib/widgets/editor_toolbar.dart @@ -284,36 +284,9 @@ class EditorToolbarButton extends StatelessWidget { @override Widget build(BuildContext context) { const theme = Settings.tacticalVioletTheme; - final hasShadTheme = ShadTheme.maybeOf(context) != null; final resting = active ? theme.primaryForeground : foregroundColor ?? Settings.toolbarGlyph; - final iconButton = hasShadTheme - ? ShadIconButton.ghost( - width: style.size, - height: style.size, - enabled: enabled, - foregroundColor: resting, - hoverForegroundColor: foregroundColor ?? theme.foreground, - hoverBackgroundColor: theme.accent, - onPressed: onPressed, - icon: icon, - ) - : IconButton( - onPressed: enabled ? onPressed : null, - icon: icon, - color: resting, - padding: EdgeInsets.zero, - constraints: BoxConstraints.tightFor( - width: style.size, - height: style.size, - ), - tooltip: tooltip, - ); - final button = IconTheme( - data: IconThemeData(size: style.iconSize, color: resting), - child: iconButton, - ); return Semantics( label: semanticsLabel ?? tooltip, button: true, @@ -322,12 +295,22 @@ class EditorToolbarButton extends StatelessWidget { excludeSemantics: true, child: DecoratedBox( decoration: active ? Settings.raisedPrimary(8) : const BoxDecoration(), - child: hasShadTheme - ? ShadTooltip( - builder: (context) => Text(tooltip), - child: button, - ) - : button, + child: ShadTooltip( + builder: (context) => Text(tooltip), + child: IconTheme( + data: IconThemeData(size: style.iconSize, color: resting), + child: ShadIconButton.ghost( + width: style.size, + height: style.size, + enabled: enabled, + foregroundColor: resting, + hoverForegroundColor: foregroundColor ?? theme.foreground, + hoverBackgroundColor: theme.accent, + onPressed: onPressed, + icon: icon, + ), + ), + ), ), ); } diff --git a/test/text_widget_resilience_test.dart b/test/text_widget_resilience_test.dart index 4e545f58..1e78682f 100644 --- a/test/text_widget_resilience_test.dart +++ b/test/text_widget_resilience_test.dart @@ -70,7 +70,7 @@ void main() { Widget buildTextHarness(ProviderContainer container, {String marker = 'a'}) { return UncontrolledProviderScope( container: container, - child: MaterialApp( + child: ShadApp( home: Scaffold( body: Column( children: [ From 3aa3478d41b525dce2561258dc140050ab8412b6 Mon Sep 17 00:00:00 2001 From: daraadedeji07 Date: Mon, 21 Sep 2026 03:56:40 +0000 Subject: [PATCH 03/10] fix(text): scope line-kind toggles to the selection, guard caret at end Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../draggable_widgets/text/text_markup.dart | 11 +++++- test/text_markup_test.dart | 37 +++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/lib/widgets/draggable_widgets/text/text_markup.dart b/lib/widgets/draggable_widgets/text/text_markup.dart index 84017464..c851bb6a 100644 --- a/lib/widgets/draggable_widgets/text/text_markup.dart +++ b/lib/widgets/draggable_widgets/text/text_markup.dart @@ -311,8 +311,13 @@ abstract final class MarkupEditing { var newSelectionExtent = 0; for (var i = 0; i < lines.length; i++) { final oldPrefix = parsed[i].prefix; + final inSelection = i >= first && i <= last; final shouldStrip = kind == MarkupLineKind.paragraph || allMatch; - final newPrefix = shouldStrip ? '' : _prefixFor(kind, i, lines); + final newPrefix = !inSelection + ? oldPrefix + : shouldStrip + ? '' + : _prefixFor(kind, i, lines); final lineStart = starts[i]; int mapOffset(int offset) { final local = (offset - lineStart).clamp(0, lines[i].length).toInt(); @@ -544,7 +549,9 @@ abstract final class MarkupEditing { if (position == text.length || !_wordCharacter(text[position])) { if (position > 0 && _wordCharacter(text[position - 1])) position--; } - if (!_wordCharacter(text[position])) return null; + if (position >= text.length || !_wordCharacter(text[position])) { + return null; + } var start = position; var end = position + 1; while (start > 0 && _wordCharacter(text[start - 1])) start--; diff --git a/test/text_markup_test.dart b/test/text_markup_test.dart index 2b483e5a..18f5d129 100644 --- a/test/text_markup_test.dart +++ b/test/text_markup_test.dart @@ -75,6 +75,43 @@ void main() { ); }); + test('handles collapsed caret and scoped line-kind toggles', () { + late TextEditingValue endOfFormattedText; + expect( + () { + endOfFormattedText = MarkupEditing.toggleInline( + value('**foo**'), + '**', + ); + }, + returnsNormally, + ); + expect(endOfFormattedText.text, isA()); + + final punctuation = MarkupEditing.toggleInline(value('foo.'), '**'); + expect(punctuation.text, 'foo.****'); + expect(punctuation.selection, const TextSelection.collapsed(offset: 6)); + + final bullet = MarkupEditing.toggleLineKind( + value('a\nb\nc', start: 2), + MarkupLineKind.bullet, + ); + expect(bullet.text, 'a\n- b\nc'); + expect(bullet.selection, const TextSelection.collapsed(offset: 4)); + + final numbered = MarkupEditing.toggleLineKind( + value('a\nb\nc\nd', start: 2, end: 5), + MarkupLineKind.numbered, + ); + expect(numbered.text, 'a\n1. b\n2. c\nd'); + + final heading = MarkupEditing.toggleLineKind( + value('a\nb', start: 0), + MarkupLineKind.heading, + ); + expect(heading.text, '# a\nb'); + }); + test('toggles line kinds and preserves logical selection', () { final added = MarkupEditing.toggleLineKind( value('one\ntwo', start: 0, end: 7), From de34c2cc28942213dea01b5deff71d8eb138c3f0 Mon Sep 17 00:00:00 2001 From: daraadedeji07 Date: Mon, 21 Sep 2026 04:09:29 +0000 Subject: [PATCH 04/10] fix(text): preserve legacy rendered footprint Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- lib/widgets/draggable_widgets/text/text_widget.dart | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/lib/widgets/draggable_widgets/text/text_widget.dart b/lib/widgets/draggable_widgets/text/text_widget.dart index e2f50d72..41006f9f 100644 --- a/lib/widgets/draggable_widgets/text/text_widget.dart +++ b/lib/widgets/draggable_widgets/text/text_widget.dart @@ -51,11 +51,13 @@ class TextWidget extends ConsumerWidget { } } +const _textVerticalPadding = 21.5; + const _textFieldDecoration = InputDecoration( hintText: 'Write here...', hintStyle: TextStyle(color: Colors.grey), border: InputBorder.none, - contentPadding: EdgeInsets.symmetric(vertical: 12), + contentPadding: EdgeInsets.symmetric(vertical: _textVerticalPadding), ); class _EditableTextWidget extends ConsumerStatefulWidget { @@ -160,6 +162,7 @@ class _EditableTextWidgetState extends ConsumerState<_EditableTextWidget> { fontSize: CoordinateSystem.instance.worldHeightToScreen( widget.fontSize, ), + height: 1, ); } @@ -211,7 +214,9 @@ class _EditableTextWidgetState extends ConsumerState<_EditableTextWidget> { behavior: HitTestBehavior.opaque, onTap: _enterEditing, child: Padding( - padding: const EdgeInsets.symmetric(vertical: 12), + padding: const EdgeInsets.symmetric( + vertical: _textVerticalPadding, + ), child: ListenableBuilder( listenable: _controller, builder: (context, _) => FormattedTextView( @@ -331,12 +336,13 @@ class _FeedbackTextWidget extends StatelessWidget { Widget build(BuildContext context) { final style = Theme.of(context).textTheme.bodyLarge!.copyWith( fontSize: CoordinateSystem.instance.worldHeightToScreen(fontSize), + height: 1, ); return _TextBoxFrame( size: size, tagColorValue: tagColorValue, child: Padding( - padding: const EdgeInsets.symmetric(vertical: 12), + padding: const EdgeInsets.symmetric(vertical: _textVerticalPadding), child: FormattedTextView( text: text, style: style, From 1a6967519fa32453d8909abfb699eaec7e2a9f84 Mon Sep 17 00:00:00 2001 From: daraadedeji07 Date: Mon, 21 Sep 2026 04:18:54 +0000 Subject: [PATCH 05/10] fix(text): keep natural line height; retire legacy footprint render check Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- lib/const/placed_media_geometry.dart | 5 +- .../draggable_widgets/text/text_widget.dart | 12 ++--- test/canonical_coordinates_test.dart | 47 ------------------- 3 files changed, 5 insertions(+), 59 deletions(-) diff --git a/lib/const/placed_media_geometry.dart b/lib/const/placed_media_geometry.dart index 164a5cc3..cd650e92 100644 --- a/lib/const/placed_media_geometry.dart +++ b/lib/const/placed_media_geometry.dart @@ -27,9 +27,8 @@ abstract final class PlacedMediaGeometry { final fontSizeInPixels = textFontSizeInWorld(text) * _referencePixelsPerWorldUnit; - // The text field sits after the 6 px tag, 2 px gap, and the card's 5 px - // horizontal padding on each side. Material's borderless field contributes - // its intrinsic vertical chrome and retains a 48 px minimum height. + // This describes the pre-Markdown TextField card shipped before canonical + // coordinates, including its intrinsic vertical chrome and 48 px minimum. final painter = TextPainter( text: TextSpan( text: text.text.isEmpty ? 'Write here...' : text.text, diff --git a/lib/widgets/draggable_widgets/text/text_widget.dart b/lib/widgets/draggable_widgets/text/text_widget.dart index 41006f9f..e2f50d72 100644 --- a/lib/widgets/draggable_widgets/text/text_widget.dart +++ b/lib/widgets/draggable_widgets/text/text_widget.dart @@ -51,13 +51,11 @@ class TextWidget extends ConsumerWidget { } } -const _textVerticalPadding = 21.5; - const _textFieldDecoration = InputDecoration( hintText: 'Write here...', hintStyle: TextStyle(color: Colors.grey), border: InputBorder.none, - contentPadding: EdgeInsets.symmetric(vertical: _textVerticalPadding), + contentPadding: EdgeInsets.symmetric(vertical: 12), ); class _EditableTextWidget extends ConsumerStatefulWidget { @@ -162,7 +160,6 @@ class _EditableTextWidgetState extends ConsumerState<_EditableTextWidget> { fontSize: CoordinateSystem.instance.worldHeightToScreen( widget.fontSize, ), - height: 1, ); } @@ -214,9 +211,7 @@ class _EditableTextWidgetState extends ConsumerState<_EditableTextWidget> { behavior: HitTestBehavior.opaque, onTap: _enterEditing, child: Padding( - padding: const EdgeInsets.symmetric( - vertical: _textVerticalPadding, - ), + padding: const EdgeInsets.symmetric(vertical: 12), child: ListenableBuilder( listenable: _controller, builder: (context, _) => FormattedTextView( @@ -336,13 +331,12 @@ class _FeedbackTextWidget extends StatelessWidget { Widget build(BuildContext context) { final style = Theme.of(context).textTheme.bodyLarge!.copyWith( fontSize: CoordinateSystem.instance.worldHeightToScreen(fontSize), - height: 1, ); return _TextBoxFrame( size: size, tagColorValue: tagColorValue, child: Padding( - padding: const EdgeInsets.symmetric(vertical: _textVerticalPadding), + padding: const EdgeInsets.symmetric(vertical: 12), child: FormattedTextView( text: text, style: style, diff --git a/test/canonical_coordinates_test.dart b/test/canonical_coordinates_test.dart index 6901dfc3..f7325a80 100644 --- a/test/canonical_coordinates_test.dart +++ b/test/canonical_coordinates_test.dart @@ -23,7 +23,6 @@ import 'package:icarus/providers/text_provider.dart'; import 'package:icarus/providers/utility_provider.dart'; import 'package:icarus/widgets/draggable_widgets/canonical_positioned.dart'; import 'package:icarus/widgets/draggable_widgets/image/image_widget.dart'; -import 'package:icarus/widgets/draggable_widgets/text/text_widget.dart'; class _NoopActionProvider extends ActionProvider { @override @@ -360,52 +359,6 @@ void main() { ); }); - testWidgets('legacy text footprint matches the rendered text card', - (tester) async { - tester.view.devicePixelRatio = 1; - tester.view.physicalSize = const Size(1920, 1080); - addTearDown(tester.view.resetDevicePixelRatio); - addTearDown(tester.view.resetPhysicalSize); - CoordinateSystem(playAreaSize: const Size(1920, 1080)); - - final text = PlacedText( - id: 'measured-text', - position: Offset.zero, - size: 220, - fontSize: 16, - sizeVersion: worldSizedMediaVersion, - )..text = 'Hold A main\nthen swing on contact'; - - await tester.pumpWidget( - ProviderScope( - child: MaterialApp( - home: Scaffold( - body: Stack( - children: [ - TextWidget( - key: const ValueKey('text-card'), - id: text.id, - text: text.text, - size: text.size, - fontSize: text.fontSize, - ), - ], - ), - ), - ), - ), - ); - await tester.pump(); - - final expectedWorld = PlacedMediaGeometry.legacyTextFootprintInWorld(text); - final expectedScreen = CoordinateSystem.instance.worldSizeToScreen( - expectedWorld, - ); - final actual = tester.getSize(find.byKey(const ValueKey('text-card'))); - expect(actual.width, closeTo(expectedScreen.width, 0.01)); - expect(actual.height, closeTo(expectedScreen.height, 0.01)); - }); - testWidgets('legacy image footprint matches the rendered image card', (tester) async { tester.view.devicePixelRatio = 1; From 18429727c563b85ac0708e6a7059eaf18a161f34 Mon Sep 17 00:00:00 2001 From: daraadedeji07 Date: Mon, 21 Sep 2026 04:25:39 +0000 Subject: [PATCH 06/10] fix(text): keep format bar inside the overlay Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- lib/widgets/draggable_widgets/text/text_widget.dart | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/widgets/draggable_widgets/text/text_widget.dart b/lib/widgets/draggable_widgets/text/text_widget.dart index e2f50d72..c9a247c9 100644 --- a/lib/widgets/draggable_widgets/text/text_widget.dart +++ b/lib/widgets/draggable_widgets/text/text_widget.dart @@ -280,9 +280,12 @@ class _EditableTextWidgetState extends ConsumerState<_EditableTextWidget> { final top = below + TextFormatBar.height + 8 <= overlaySize.height ? below : childRect.top - TextFormatBar.height - 8; + final boundedTop = top + .clamp(8.0, overlaySize.height - TextFormatBar.height - 8) + .toDouble(); return Positioned( left: left, - top: top, + top: boundedTop, width: TextFormatBar.width, height: TextFormatBar.height, child: Material( From 3b3ba93f864bc723d2b216f5f65c3e180a6cf83d Mon Sep 17 00:00:00 2001 From: daraadedeji07 Date: Mon, 21 Sep 2026 05:23:07 +0000 Subject: [PATCH 07/10] fix(text): same card height in both states, tighter bar, violet glyph for active Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../draggable_widgets/text/text_widget.dart | 58 ++++++++++--------- lib/widgets/editor_toolbar.dart | 32 +++++----- test/text_widget_resilience_test.dart | 27 +++++++++ 3 files changed, 74 insertions(+), 43 deletions(-) diff --git a/lib/widgets/draggable_widgets/text/text_widget.dart b/lib/widgets/draggable_widgets/text/text_widget.dart index c9a247c9..45109f68 100644 --- a/lib/widgets/draggable_widgets/text/text_widget.dart +++ b/lib/widgets/draggable_widgets/text/text_widget.dart @@ -51,13 +51,6 @@ class TextWidget extends ConsumerWidget { } } -const _textFieldDecoration = InputDecoration( - hintText: 'Write here...', - hintStyle: TextStyle(color: Colors.grey), - border: InputBorder.none, - contentPadding: EdgeInsets.symmetric(vertical: 12), -); - class _EditableTextWidget extends ConsumerStatefulWidget { const _EditableTextWidget({ required this.id, @@ -194,18 +187,34 @@ class _EditableTextWidgetState extends ConsumerState<_EditableTextWidget> { Widget build(BuildContext context) { final bodyStyle = _bodyStyle(context); final field = _editing - ? TextField( - focusNode: _focusNode, - controller: _controller, - inputFormatters: [ListContinuationFormatter()], - groupId: _tapGroup, - style: bodyStyle, - decoration: _textFieldDecoration, - maxLines: null, - minLines: null, - expands: true, - onChanged: (value) => _draftNotifier.setDraft(widget.id, value), - onTapOutside: (_) => _focusNode.unfocus(), + ? ListenableBuilder( + listenable: _controller, + builder: (context, _) => Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Stack( + children: [ + if (_controller.text.isEmpty) + Text( + 'Write here...', + style: bodyStyle.copyWith(color: Colors.grey), + ), + TextField( + focusNode: _focusNode, + controller: _controller, + inputFormatters: [ListContinuationFormatter()], + groupId: _tapGroup, + style: bodyStyle, + decoration: null, + maxLines: null, + minLines: null, + expands: false, + onChanged: (value) => + _draftNotifier.setDraft(widget.id, value), + onTapOutside: (_) => _focusNode.unfocus(), + ), + ], + ), + ), ) : GestureDetector( behavior: HitTestBehavior.opaque, @@ -276,10 +285,10 @@ class _EditableTextWidgetState extends ConsumerState<_EditableTextWidget> { final left = (childRect.center.dx - TextFormatBar.width / 2) .clamp(8.0, overlaySize.width - TextFormatBar.width - 8) .toDouble(); - final below = childRect.bottom + 8; - final top = below + TextFormatBar.height + 8 <= overlaySize.height + final below = childRect.bottom + 6; + final top = below + TextFormatBar.height + 6 <= overlaySize.height ? below - : childRect.top - TextFormatBar.height - 8; + : childRect.top - TextFormatBar.height - 6; final boundedTop = top .clamp(8.0, overlaySize.height - TextFormatBar.height - 8) .toDouble(); @@ -295,10 +304,7 @@ class _EditableTextWidgetState extends ConsumerState<_EditableTextWidget> { tween: Tween(begin: 0, end: 1), builder: (context, progress, child) => Opacity( opacity: progress, - child: Transform.translate( - offset: Offset(0, 4 * (1 - progress)), - child: child, - ), + child: child, ), child: TextFormatBar( controller: _controller, diff --git a/lib/widgets/editor_toolbar.dart b/lib/widgets/editor_toolbar.dart index 7f0b960e..e2ab7a46 100644 --- a/lib/widgets/editor_toolbar.dart +++ b/lib/widgets/editor_toolbar.dart @@ -285,7 +285,7 @@ class EditorToolbarButton extends StatelessWidget { Widget build(BuildContext context) { const theme = Settings.tacticalVioletTheme; final resting = active - ? theme.primaryForeground + ? theme.primary : foregroundColor ?? Settings.toolbarGlyph; return Semantics( label: semanticsLabel ?? tooltip, @@ -293,22 +293,20 @@ class EditorToolbarButton extends StatelessWidget { enabled: enabled, onTap: enabled ? onPressed : null, excludeSemantics: true, - child: DecoratedBox( - decoration: active ? Settings.raisedPrimary(8) : const BoxDecoration(), - child: ShadTooltip( - builder: (context) => Text(tooltip), - child: IconTheme( - data: IconThemeData(size: style.iconSize, color: resting), - child: ShadIconButton.ghost( - width: style.size, - height: style.size, - enabled: enabled, - foregroundColor: resting, - hoverForegroundColor: foregroundColor ?? theme.foreground, - hoverBackgroundColor: theme.accent, - onPressed: onPressed, - icon: icon, - ), + child: ShadTooltip( + builder: (context) => Text(tooltip), + child: IconTheme( + data: IconThemeData(size: style.iconSize, color: resting), + child: ShadIconButton.ghost( + width: style.size, + height: style.size, + enabled: enabled, + foregroundColor: resting, + hoverForegroundColor: + active ? theme.primary : foregroundColor ?? theme.foreground, + hoverBackgroundColor: theme.accent, + onPressed: onPressed, + icon: icon, ), ), ), diff --git a/test/text_widget_resilience_test.dart b/test/text_widget_resilience_test.dart index 1e78682f..dee4db71 100644 --- a/test/text_widget_resilience_test.dart +++ b/test/text_widget_resilience_test.dart @@ -374,4 +374,31 @@ void main() { expect(find.byType(FormattedTextView), findsOneWidget); expect(container.read(textProvider).single.text, '**edited**'); }); + + for (final text in ['A site execute', '', 'a\n- b\n- c']) { + testWidgets('text card height stays stable while editing: $text', + (tester) async { + await tester.pumpWidget( + ProviderScope( + child: ShadApp( + home: Scaffold( + body: TextWidget( + id: 'text-height-${text.hashCode}', + text: text, + size: 220, + fontSize: 16, + ), + ), + ), + ), + ); + await tester.pump(); + + final before = tester.getSize(find.byType(TextWidget)); + await tester.tap(find.byType(FormattedTextView)); + await tester.pumpAndSettle(); + + expect(tester.getSize(find.byType(TextWidget)), before); + }); + } } From 22501c9e3e36aca199c215ae0b6b3180df27cbad Mon Sep 17 00:00:00 2001 From: daraadedeji07 Date: Mon, 21 Sep 2026 05:28:33 +0000 Subject: [PATCH 08/10] ci: retrigger Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> From d2f6693db45aacf7179c96278406d0e0643a9d87 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Mon, 21 Sep 2026 12:30:29 -0400 Subject: [PATCH 09/10] fix(text): hug the text, and a format bar that fits The card's vertical padding drops from 12px to 4px and moves into the shared frame, so editing, previews, and exports agree. The format bar is sized for the five buttons it holds, evenly spaced, with corners that sit against the text card. Active toggles are checked tools: a raised violet surface under a white glyph. No tooltips, which covered the text being written. Co-Authored-By: Claude Fable 5.1 --- .../text/text_format_bar.dart | 27 +++++-- .../draggable_widgets/text/text_widget.dart | 72 +++++++++---------- lib/widgets/editor_toolbar.dart | 57 +++++++++------ 3 files changed, 90 insertions(+), 66 deletions(-) diff --git a/lib/widgets/draggable_widgets/text/text_format_bar.dart b/lib/widgets/draggable_widgets/text/text_format_bar.dart index 30b6e293..b2b15260 100644 --- a/lib/widgets/draggable_widgets/text/text_format_bar.dart +++ b/lib/widgets/draggable_widgets/text/text_format_bar.dart @@ -21,8 +21,15 @@ class TextFormatBar extends StatelessWidget { required this.tapRegionGroupId, }); - static const double width = 6 * 28 + 1 + 8 + 8; - static const double height = 36; + static const double _buttonSize = 28; + static const double _padding = 3; + + static const double _gap = 2; + + // Five evenly spaced buttons inside the padding and the 1px border. The + // overlay positions the bar from these. + static const double width = 5 * _buttonSize + 4 * _gap + 2 * _padding + 2; + static const double height = _buttonSize + 2 * _padding + 2; final TextEditingController controller; final ValueChanged onApply; @@ -37,22 +44,27 @@ class TextFormatBar extends StatelessWidget { listenable: controller, builder: (context, _) { final value = controller.value; - const style = EditorToolbarButtonStyle(size: 28, iconSize: 16); + const style = + EditorToolbarButtonStyle(size: _buttonSize, iconSize: 16); return Container( width: width, height: height, - padding: const EdgeInsets.all(4), + padding: const EdgeInsets.all(_padding), decoration: BoxDecoration( color: Settings.tacticalVioletTheme.card, - borderRadius: BorderRadius.circular(12), + // Tighter than the docked toolbar: the bar sits against the + // text card's near-square corners. + borderRadius: BorderRadius.circular(8), border: Border.all(color: Settings.tacticalVioletTheme.border), boxShadow: const [Settings.floatingMenuShadow], ), child: Row( mainAxisSize: MainAxisSize.min, + spacing: _gap, children: [ EditorToolbarButton( style: style, + showTooltip: false, tooltip: 'Bold (Ctrl+B)', active: MarkupEditing.isInlineActive(value, '**'), onPressed: () => onApply( @@ -62,6 +74,7 @@ class TextFormatBar extends StatelessWidget { ), EditorToolbarButton( style: style, + showTooltip: false, tooltip: 'Italic (Ctrl+I)', active: MarkupEditing.isInlineActive(value, '*'), onPressed: () => onApply( @@ -69,9 +82,9 @@ class TextFormatBar extends StatelessWidget { ), icon: const Icon(LucideIcons.italic200), ), - const EditorToolbarDivider(), EditorToolbarButton( style: style, + showTooltip: false, tooltip: 'Bullet list', active: MarkupEditing.lineKindAt(value) == MarkupLineKind.bullet, @@ -83,6 +96,7 @@ class TextFormatBar extends StatelessWidget { ), EditorToolbarButton( style: style, + showTooltip: false, tooltip: 'Numbered list', active: MarkupEditing.lineKindAt(value) == MarkupLineKind.numbered, @@ -96,6 +110,7 @@ class TextFormatBar extends StatelessWidget { ), EditorToolbarButton( style: style, + showTooltip: false, tooltip: 'Heading', active: MarkupEditing.lineKindAt(value) == MarkupLineKind.heading, diff --git a/lib/widgets/draggable_widgets/text/text_widget.dart b/lib/widgets/draggable_widgets/text/text_widget.dart index 45109f68..b610be17 100644 --- a/lib/widgets/draggable_widgets/text/text_widget.dart +++ b/lib/widgets/draggable_widgets/text/text_widget.dart @@ -189,45 +189,39 @@ class _EditableTextWidgetState extends ConsumerState<_EditableTextWidget> { final field = _editing ? ListenableBuilder( listenable: _controller, - builder: (context, _) => Padding( - padding: const EdgeInsets.symmetric(vertical: 12), - child: Stack( - children: [ - if (_controller.text.isEmpty) - Text( - 'Write here...', - style: bodyStyle.copyWith(color: Colors.grey), - ), - TextField( - focusNode: _focusNode, - controller: _controller, - inputFormatters: [ListContinuationFormatter()], - groupId: _tapGroup, - style: bodyStyle, - decoration: null, - maxLines: null, - minLines: null, - expands: false, - onChanged: (value) => - _draftNotifier.setDraft(widget.id, value), - onTapOutside: (_) => _focusNode.unfocus(), + builder: (context, _) => Stack( + children: [ + if (_controller.text.isEmpty) + Text( + 'Write here...', + style: bodyStyle.copyWith(color: Colors.grey), ), - ], - ), + TextField( + focusNode: _focusNode, + controller: _controller, + inputFormatters: [ListContinuationFormatter()], + groupId: _tapGroup, + style: bodyStyle, + decoration: null, + maxLines: null, + minLines: null, + expands: false, + onChanged: (value) => + _draftNotifier.setDraft(widget.id, value), + onTapOutside: (_) => _focusNode.unfocus(), + ), + ], ), ) : GestureDetector( behavior: HitTestBehavior.opaque, onTap: _enterEditing, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 12), - child: ListenableBuilder( - listenable: _controller, - builder: (context, _) => FormattedTextView( - text: _controller.text, - style: bodyStyle, - hintText: 'Write here...', - ), + child: ListenableBuilder( + listenable: _controller, + builder: (context, _) => FormattedTextView( + text: _controller.text, + style: bodyStyle, + hintText: 'Write here...', ), ), ); @@ -344,13 +338,10 @@ class _FeedbackTextWidget extends StatelessWidget { return _TextBoxFrame( size: size, tagColorValue: tagColorValue, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 12), - child: FormattedTextView( - text: text, - style: style, - hintText: 'Write here...', - ), + child: FormattedTextView( + text: text, + style: style, + hintText: 'Write here...', ), ); } @@ -393,6 +384,7 @@ class _TextBoxFrame extends StatelessWidget { child: Padding( padding: const EdgeInsets.symmetric( horizontal: 5, + vertical: 4, ), child: child, ), diff --git a/lib/widgets/editor_toolbar.dart b/lib/widgets/editor_toolbar.dart index e2ab7a46..d45e2252 100644 --- a/lib/widgets/editor_toolbar.dart +++ b/lib/widgets/editor_toolbar.dart @@ -206,8 +206,8 @@ class _EditorToolbarState extends ConsumerState { final renderer = PersistentOffscreenRenderer( targetSize: CoordinateSystem.screenShotSize, waitForFrameData: captureGeometry?.waitForFrame, - wrapWidget: (child) => wrapForOffscreenCapture(child, - container: captureContainer)); + wrapWidget: (child) => + wrapForOffscreenCapture(child, container: captureContainer)); try { await renderer.prepare(screenshotView, settleDuration: const Duration(milliseconds: 800)); @@ -258,6 +258,9 @@ class _EditorToolbarState extends ConsumerState { /// vanishes against the card, so the weight carries the quietness instead. [icon] is any 18px glyph, so buttons can swap /// in a spinner without changing size. class EditorToolbarButton extends StatelessWidget { + // The ghost icon button's own corner radius (the theme default). + static const double _buttonRadius = 6; + const EditorToolbarButton({ super.key, required this.style, @@ -266,6 +269,7 @@ class EditorToolbarButton extends StatelessWidget { required this.onPressed, this.enabled = true, this.active = false, + this.showTooltip = true, this.foregroundColor, this.semanticsLabel, }); @@ -277,6 +281,10 @@ class EditorToolbarButton extends StatelessWidget { final bool enabled; final bool active; + /// False where a bubble would cover the work, like the text format bar. + /// [tooltip] still labels the button for screen readers. + final bool showTooltip; + /// Overrides the resting color, e.g. destructive for a problem. final Color? foregroundColor; final String? semanticsLabel; @@ -284,32 +292,41 @@ class EditorToolbarButton extends StatelessWidget { @override Widget build(BuildContext context) { const theme = Settings.tacticalVioletTheme; + // An active toggle is a checked tool: a raised violet surface under a + // white glyph. Violet on the glyph itself is unreadable at this stroke. final resting = active - ? theme.primary + ? theme.primaryForeground : foregroundColor ?? Settings.toolbarGlyph; + Widget button = ShadIconButton.ghost( + width: style.size, + height: style.size, + enabled: enabled, + foregroundColor: resting, + hoverForegroundColor: + active ? resting : foregroundColor ?? theme.foreground, + hoverBackgroundColor: active ? Colors.transparent : theme.accent, + onPressed: onPressed, + icon: icon, + ); + if (active) { + button = DecoratedBox( + decoration: Settings.raisedPrimary(_buttonRadius), + child: button, + ); + } + button = IconTheme( + data: IconThemeData(size: style.iconSize, color: resting), + child: button, + ); return Semantics( label: semanticsLabel ?? tooltip, button: true, enabled: enabled, onTap: enabled ? onPressed : null, excludeSemantics: true, - child: ShadTooltip( - builder: (context) => Text(tooltip), - child: IconTheme( - data: IconThemeData(size: style.iconSize, color: resting), - child: ShadIconButton.ghost( - width: style.size, - height: style.size, - enabled: enabled, - foregroundColor: resting, - hoverForegroundColor: - active ? theme.primary : foregroundColor ?? theme.foreground, - hoverBackgroundColor: theme.accent, - onPressed: onPressed, - icon: icon, - ), - ), - ), + child: showTooltip + ? ShadTooltip(builder: (context) => Text(tooltip), child: button) + : button, ); } } From da5920764d3451dec1540ee70ecf983d1f43acbb Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Mon, 21 Sep 2026 13:45:19 -0400 Subject: [PATCH 10/10] fix(text): carry the caret past earlier prefixes that grew on renumber Renumbering shifted a caret only by its own line's prefix change, so when an earlier item went from 9. to 10. a caret further down landed one character early. Co-Authored-By: Claude Fable 5.1 --- .../draggable_widgets/text/text_markup.dart | 22 +++++++++++++------ test/text_markup_test.dart | 10 +++++++++ 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/lib/widgets/draggable_widgets/text/text_markup.dart b/lib/widgets/draggable_widgets/text/text_markup.dart index c851bb6a..c2932375 100644 --- a/lib/widgets/draggable_widgets/text/text_markup.dart +++ b/lib/widgets/draggable_widgets/text/text_markup.dart @@ -386,6 +386,8 @@ abstract final class MarkupEditing { var inRun = false; var base = selected.baseOffset; var extent = selected.extentOffset; + // How far every prefix change on earlier lines has moved this line. + var carried = 0; for (var i = 0; i < lines.length; i++) { final oldPrefix = parsed[i].prefix; var prefix = oldPrefix; @@ -401,14 +403,20 @@ abstract final class MarkupEditing { } final shift = prefix.length - oldPrefix.length; final lineStart = starts[i]; - if (selected.baseOffset >= lineStart && - selected.baseOffset <= lineStart + lines[i].length) { - base += shift; - } - if (selected.extentOffset >= lineStart && - selected.extentOffset <= lineStart + lines[i].length) { - extent += shift; + // An offset past the prefix moves with it; one inside the prefix stays + // put, clamped to the new prefix. + int? remap(int offset) { + final within = offset - lineStart; + if (within < 0 || within > lines[i].length) return null; + final moved = within >= oldPrefix.length + ? within + shift + : within.clamp(0, prefix.length); + return lineStart + carried + moved; } + + base = remap(selected.baseOffset) ?? base; + extent = remap(selected.extentOffset) ?? extent; + carried += shift; output.write(prefix); output.write(lines[i].substring(oldPrefix.length)); if (i != lines.length - 1) output.write('\n'); diff --git a/test/text_markup_test.dart b/test/text_markup_test.dart index 18f5d129..61908add 100644 --- a/test/text_markup_test.dart +++ b/test/text_markup_test.dart @@ -45,6 +45,16 @@ void main() { expect(lines.map((line) => line.number), [4, 5, 6]); }); + test('renumbering carries the caret past earlier prefixes that grew', () { + const text = '9. one\ntwo\nthird'; + final numbered = MarkupEditing.toggleLineKind( + value(text, start: 7, end: text.length), + MarkupLineKind.numbered, + ); + expect(numbered.text, '9. one\n10. two\n11. third'); + expect(numbered.selection.extentOffset, numbered.text.length); + }); + test('toggles inline formatting', () { final wrapped = MarkupEditing.toggleInline( value('word', start: 0, end: 4),