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/formatted_text_view.dart b/lib/widgets/draggable_widgets/text/formatted_text_view.dart new file mode 100644 index 00000000..df9225c0 --- /dev/null +++ b/lib/widgets/draggable_widgets/text/formatted_text_view.dart @@ -0,0 +1,88 @@ +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 fontSize = style.fontSize ?? 14; + final lines = parseMarkup(text); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + for (final line in lines) _buildLine(line, fontSize), + ], + ); + } + + Widget _buildLine(MarkupLine line, double fontSize) { + if (line.plainText.isEmpty) { + return SizedBox( + height: fontSize * (style.height ?? 1.2), + ); + } + final contentStyle = line.kind == MarkupLineKind.heading + ? style.copyWith( + fontWeight: FontWeight.w600, + fontSize: fontSize * 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 = fontSize * 1.6; + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: glyphWidth, + child: Text( + glyph, + textAlign: TextAlign.right, + style: style, + ), + ), + SizedBox(width: fontSize * 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..b2b15260 --- /dev/null +++ b/lib/widgets/draggable_widgets/text/text_format_bar.dart @@ -0,0 +1,131 @@ +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 _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; + 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: _buttonSize, iconSize: 16); + return Container( + width: width, + height: height, + padding: const EdgeInsets.all(_padding), + decoration: BoxDecoration( + color: Settings.tacticalVioletTheme.card, + // 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( + MarkupEditing.toggleInline(value, '**'), + ), + icon: const Icon(LucideIcons.bold200), + ), + EditorToolbarButton( + style: style, + showTooltip: false, + tooltip: 'Italic (Ctrl+I)', + active: MarkupEditing.isInlineActive(value, '*'), + onPressed: () => onApply( + MarkupEditing.toggleInline(value, '*'), + ), + icon: const Icon(LucideIcons.italic200), + ), + EditorToolbarButton( + style: style, + showTooltip: false, + tooltip: 'Bullet list', + active: MarkupEditing.lineKindAt(value) == + MarkupLineKind.bullet, + onPressed: () => onApply( + MarkupEditing.toggleLineKind( + value, MarkupLineKind.bullet), + ), + icon: const Icon(LucideIcons.list200), + ), + EditorToolbarButton( + style: style, + showTooltip: false, + tooltip: 'Numbered list', + active: MarkupEditing.lineKindAt(value) == + MarkupLineKind.numbered, + onPressed: () => onApply( + MarkupEditing.toggleLineKind( + value, + MarkupLineKind.numbered, + ), + ), + icon: const Icon(LucideIcons.listOrdered200), + ), + EditorToolbarButton( + style: style, + showTooltip: false, + 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..c2932375 --- /dev/null +++ b/lib/widgets/draggable_widgets/text/text_markup.dart @@ -0,0 +1,597 @@ +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+'); +final RegExp _whitespace = RegExp(r'\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 && + _whitespace.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 && + _whitespace.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 && _whitespace.hasMatch(text[contentStart])) { + contentStart++; + } + while (contentEnd > contentStart && + _whitespace.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 replacement = + '$leading$marker${text.substring(contentStart, contentEnd)}$marker$trailing'; + final next = text.replaceRange(start, end, replacement); + final newStart = start + leading.length + marker.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, + ) { + 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); + var output = StringBuffer(); + var newSelectionBase = 0; + 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 = !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(); + 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'); + } + 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; + // 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; + 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]; + // 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'); + } + 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 (position >= text.length || !_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..b610be17 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 { @@ -46,12 +51,6 @@ class TextWidget extends ConsumerWidget { } } -const _textFieldDecoration = InputDecoration( - hintText: "Write here...", - hintStyle: TextStyle(color: Colors.grey), - border: InputBorder.none, -); - class _EditableTextWidget extends ConsumerStatefulWidget { const _EditableTextWidget({ required this.id, @@ -73,22 +72,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 +125,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 +148,175 @@ 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 + ? ListenableBuilder( + listenable: _controller, + 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: 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 + 6; + final top = below + TextFormatBar.height + 6 <= overlaySize.height + ? below + : childRect.top - TextFormatBar.height - 6; + final boundedTop = top + .clamp(8.0, overlaySize.height - TextFormatBar.height - 8) + .toDouble(); + return Positioned( + left: left, + top: boundedTop, + 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: 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,92 +330,19 @@ 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, - ), - ), - ); - } -} - -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), + size: size, + tagColorValue: tagColorValue, + child: FormattedTextView( + text: text, + style: style, + hintText: 'Write here...', ), - decoration: _textFieldDecoration, - maxLines: null, - minLines: null, - expands: true, - onChanged: onChanged, - onTapOutside: onTapOutside, ); } } @@ -330,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 db5b06ef..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, @@ -265,6 +268,8 @@ class EditorToolbarButton extends StatelessWidget { required this.icon, required this.onPressed, this.enabled = true, + this.active = false, + this.showTooltip = true, this.foregroundColor, this.semanticsLabel, }); @@ -274,6 +279,11 @@ class EditorToolbarButton extends StatelessWidget { final Widget icon; final VoidCallback? onPressed; 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; @@ -282,29 +292,41 @@ class EditorToolbarButton extends StatelessWidget { @override Widget build(BuildContext context) { const theme = Settings.tacticalVioletTheme; - final resting = foregroundColor ?? Settings.toolbarGlyph; + // 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.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: foregroundColor ?? theme.foreground, - hoverBackgroundColor: theme.accent, - onPressed: onPressed, - icon: icon, - ), - ), - ), + child: showTooltip + ? ShadTooltip(builder: (context) => Text(tooltip), child: button) + : button, ); } } 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; 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..61908add --- /dev/null +++ b/test/text_markup_test.dart @@ -0,0 +1,182 @@ +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('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), + '**', + ); + 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('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), + 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..dee4db71 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'; @@ -69,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: [ @@ -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,57 @@ 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**'); + }); + + 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); + }); + } }