diff --git a/.github/workflows/flutter-linux.yml b/.github/workflows/flutter-linux.yml index 4764878d..1b1766ee 100644 --- a/.github/workflows/flutter-linux.yml +++ b/.github/workflows/flutter-linux.yml @@ -73,14 +73,14 @@ jobs: uses: subosito/flutter-action@v2 with: channel: stable - flutter-version: '3.47.0' + flutter-version-file: pubspec.yaml cache: true - name: Enable Linux desktop run: flutter config --enable-linux-desktop - - name: Resolve dependencies - run: flutter pub get + - name: Resolve locked dependencies + run: flutter pub get --enforce-lockfile - name: Generate localizations run: flutter gen-l10n @@ -166,15 +166,32 @@ jobs: - name: Check out repository uses: actions/checkout@v7 - - name: Build strict Snap from a clean core24 environment + - name: Build strict Snap from a clean core24 environment (attempt 1) id: snapcraft uses: snapcore/action-build@v1 + continue-on-error: true + + - name: Retry strict Snap build after an infrastructure failure + id: snapcraft-retry + if: steps.snapcraft.outcome == 'failure' + uses: snapcore/action-build@v1 + + - name: Select built Snap + id: snap-artifact + env: + PRIMARY_SNAP: ${{ steps.snapcraft.outputs.snap }} + RETRY_SNAP: ${{ steps.snapcraft-retry.outputs.snap }} + run: | + snap_path="${PRIMARY_SNAP:-$RETRY_SNAP}" + test -n "$snap_path" + test -f "$snap_path" + echo "snap=$snap_path" >> "$GITHUB_OUTPUT" - name: Upload Snap artifact uses: actions/upload-artifact@v7 with: name: busymark-snap - path: ${{ steps.snapcraft.outputs.snap }} + path: ${{ steps.snap-artifact.outputs.snap }} - name: Install desktop smoke dependencies run: | @@ -184,7 +201,7 @@ jobs: - name: Install strict Snap run: >- sudo snap install --dangerous - "${{ steps.snapcraft.outputs.snap }}" + "${{ steps.snap-artifact.outputs.snap }}" - name: Verify GTK SVG icon loader run: | diff --git a/README.md b/README.md index d97c3471..b1e72fc4 100644 --- a/README.md +++ b/README.md @@ -163,7 +163,8 @@ references. An interactive exercise is available in ## Run From Source -1. [Install Flutter](https://docs.flutter.dev/install) +1. [Install Flutter 3.47.0](https://docs.flutter.dev/install). The exact + project version is declared in `pubspec.yaml` and shared with CI. 2. Run the application: diff --git a/docs/screenshots/busymark-welcome.png b/docs/screenshots/busymark-welcome.png deleted file mode 100644 index d2a01dcb..00000000 Binary files a/docs/screenshots/busymark-welcome.png and /dev/null differ diff --git a/lib/src/app/app_metadata.dart b/lib/src/app/app_metadata.dart index 002a227a..cf7c681b 100644 --- a/lib/src/app/app_metadata.dart +++ b/lib/src/app/app_metadata.dart @@ -1 +1 @@ -const busyMarkAppVersion = '0.3.3'; +const busyMarkAppVersion = '0.3.4'; diff --git a/lib/src/app/busymark_dialogs.dart b/lib/src/app/busymark_dialogs.dart index d6dfc4d5..28f78194 100644 --- a/lib/src/app/busymark_dialogs.dart +++ b/lib/src/app/busymark_dialogs.dart @@ -2657,13 +2657,15 @@ class _AboutVersionTag extends StatelessWidget { @override Widget build(BuildContext context) { - final colors = BusyMarkSurfaceColors.of(context); + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; return Center( child: DecoratedBox( + key: const ValueKey('about-version-tag'), decoration: BoxDecoration( - color: colors.control, + color: colorScheme.primary, borderRadius: BorderRadius.circular(BusyMarkRadius.pill), - border: Border.all(color: colors.subtleBorder), + border: Border.all(color: colorScheme.primary), ), child: Padding( padding: const EdgeInsets.symmetric( @@ -2675,8 +2677,8 @@ class _AboutVersionTag extends StatelessWidget { child: Text( version, textAlign: TextAlign.center, - style: Theme.of(context).textTheme.labelMedium?.copyWith( - color: colors.foreground, + style: theme.textTheme.labelMedium?.copyWith( + color: colorScheme.onPrimary, fontWeight: FontWeight.w600, ), ), diff --git a/lib/src/editor/document_collapsible.dart b/lib/src/editor/document_collapsible.dart index 8916da65..e4fb0bf3 100644 --- a/lib/src/editor/document_collapsible.dart +++ b/lib/src/editor/document_collapsible.dart @@ -12,6 +12,8 @@ class BusyMarkDocumentCollapsible extends StatefulWidget { required this.child, required this.kindLabel, this.initiallyExpanded = false, + this.expanded, + this.onExpansionChanged, this.framed = false, this.toggleOnHeaderTap = true, this.margin = const EdgeInsets.symmetric(vertical: BusyMarkSpacing.xs), @@ -21,6 +23,8 @@ class BusyMarkDocumentCollapsible extends StatefulWidget { final Widget child; final String kindLabel; final bool initiallyExpanded; + final bool? expanded; + final ValueChanged? onExpansionChanged; final bool framed; final bool toggleOnHeaderTap; final EdgeInsetsGeometry margin; @@ -34,29 +38,36 @@ class _BusyMarkDocumentCollapsibleState extends State { late bool _expanded = widget.initiallyExpanded; + bool get _effectiveExpanded => widget.expanded ?? _expanded; + @override void didUpdateWidget(BusyMarkDocumentCollapsible oldWidget) { super.didUpdateWidget(oldWidget); - if (oldWidget.initiallyExpanded != widget.initiallyExpanded) { + if (widget.expanded == null && + oldWidget.initiallyExpanded != widget.initiallyExpanded) { _expanded = widget.initiallyExpanded; } } - void _toggle() => setState(() => _expanded = !_expanded); + void _toggle() { + final next = !_effectiveExpanded; + widget.onExpansionChanged?.call(next); + if (widget.expanded == null) { + setState(() => _expanded = next); + } + } @override Widget build(BuildContext context) { final colors = BusyMarkSurfaceColors.of(context); - final tooltip = _expanded + final expanded = _effectiveExpanded; + final tooltip = expanded ? context.l10n.collapseKind(widget.kindLabel) : context.l10n.expandKind(widget.kindLabel); final content = Column( crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, - children: [ - _header(context, colors, tooltip), - if (_expanded) widget.child, - ], + children: [_header(context, colors, tooltip), if (expanded) widget.child], ); if (!widget.framed) { return Padding(padding: widget.margin, child: content); @@ -80,7 +91,7 @@ class _BusyMarkDocumentCollapsibleState BusyMarkSurfaceColors colors, String tooltip, ) { - final icon = _expanded + final icon = _effectiveExpanded ? BusyMarkGlyphs.downArrow : BusyMarkGlyphs.collapsedTreeArrowFor(Directionality.of(context)); final row = Padding( @@ -118,7 +129,7 @@ class _BusyMarkDocumentCollapsibleState } return Semantics( button: true, - expanded: _expanded, + expanded: _effectiveExpanded, label: tooltip, child: InkWell( onTap: _toggle, diff --git a/lib/src/editor/document_text_geometry.dart b/lib/src/editor/document_text_geometry.dart index ccccf67f..3d3c1515 100644 --- a/lib/src/editor/document_text_geometry.dart +++ b/lib/src/editor/document_text_geometry.dart @@ -12,8 +12,13 @@ abstract final class BusyMarkDocumentTextGeometry { editableCaretGap + editableCursorWidth; /// Uses the complete text strut so selection has balanced breathing room - /// above capitals and below descenders in both Source and Editor views. + /// above capitals and below descenders in rich Editor fields. static const BoxHeightStyle selectionHeightStyle = BoxHeightStyle.strut; + + /// Follows the tallest styled glyph on each Source line. Source syntax can + /// enlarge headings beyond the base monospace strut, so a strut-sized box + /// can clip the lower edge of selected heading text. + static const BoxHeightStyle sourceSelectionHeightStyle = BoxHeightStyle.max; static const BoxWidthStyle selectionWidthStyle = BoxWidthStyle.tight; static const double fallbackSelectionAlpha = 0.40; } diff --git a/lib/src/editor/source/source_commands.dart b/lib/src/editor/source/source_commands.dart index d8c64f89..ba55b8e7 100644 --- a/lib/src/editor/source/source_commands.dart +++ b/lib/src/editor/source/source_commands.dart @@ -26,6 +26,9 @@ abstract final class SourceCommands { TextEditingValue value, { int indentWidth = defaultIndentWidth, }) { + if (!_normalizedSelection(value).isCollapsed) { + return indentSelection(value, indentWidth: indentWidth); + } return _replaceSelection(value, ' ' * indentWidth); } diff --git a/lib/src/editor/source/source_document.dart b/lib/src/editor/source/source_document.dart index 810447be..4bb4490e 100644 --- a/lib/src/editor/source/source_document.dart +++ b/lib/src/editor/source/source_document.dart @@ -36,6 +36,41 @@ class SourceDocument { visibleLineIndex = SourceLineIndex(visibleText); } + SourceDocument._({ + required this.fullText, + required this.hiddenRanges, + required this.lineIndex, + required this.visibleText, + required this.visibleLineIndex, + }); + + factory SourceDocument.afterVisibleEdit({ + required SourceDocument previous, + required String fullText, + required SourceHiddenRanges hiddenRanges, + required SourceVisibleEdit edit, + }) { + final visibleText = hiddenRanges.visibleTextFor(fullText); + final visibleChange = _changedRange(previous.visibleText, visibleText); + return SourceDocument._( + fullText: fullText, + hiddenRanges: hiddenRanges, + lineIndex: SourceLineIndex.updated( + previous: previous.lineIndex, + source: fullText, + oldStart: edit.fullStart, + oldEnd: edit.fullEnd, + ), + visibleText: visibleText, + visibleLineIndex: SourceLineIndex.updated( + previous: previous.visibleLineIndex, + source: visibleText, + oldStart: visibleChange.oldStart, + oldEnd: visibleChange.oldEnd, + ), + ); + } + final String fullText; final SourceHiddenRanges hiddenRanges; final SourceLineIndex lineIndex; @@ -88,15 +123,18 @@ class SourceDocument { affinity: selection.affinity, ); } + final fullStart = visibleOffsetToFullOffset( + selection.start, + affinity: SourceHiddenAffinity.downstream, + ); + final fullEnd = visibleOffsetToFullOffset( + selection.end, + affinity: SourceHiddenAffinity.upstream, + ); + final forward = selection.baseOffset <= selection.extentOffset; return selection.copyWith( - baseOffset: visibleOffsetToFullOffset( - selection.baseOffset, - affinity: SourceHiddenAffinity.downstream, - ), - extentOffset: visibleOffsetToFullOffset( - selection.extentOffset, - affinity: SourceHiddenAffinity.upstream, - ), + baseOffset: forward ? fullStart : fullEnd, + extentOffset: forward ? fullEnd : fullStart, ); } diff --git a/lib/src/editor/source/source_editor.dart b/lib/src/editor/source/source_editor.dart index e0e35a07..c41a7ea1 100644 --- a/lib/src/editor/source/source_editor.dart +++ b/lib/src/editor/source/source_editor.dart @@ -1,7 +1,9 @@ import 'dart:async'; import 'dart:math' as math; +import 'package:flutter/foundation.dart' show setEquals; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:yaru/yaru.dart'; @@ -21,12 +23,23 @@ import 'source_commands.dart'; import 'source_controller.dart'; import 'source_autocomplete.dart'; import 'source_diagnostics.dart'; +import 'source_document.dart'; import 'source_gutter.dart'; +import 'source_intrinsic_width.dart'; import 'source_search.dart'; typedef BusyMarkSourceChanged = void Function(String fullText, String? sourceFilePath); +typedef BusyMarkSourceTransactionalChanged = + void Function( + String fullText, + String? sourceFilePath, + TextSelection previousSelection, + TextSelection selection, + String? undoGroup, + ); + typedef BusyMarkSourceSessionChanged = void Function( TextSelection selection, @@ -34,6 +47,8 @@ typedef BusyMarkSourceSessionChanged = Set foldedRegionKeys, ); +var _sourceEditorUndoSessionSequence = 0; + class BusyMarkSourceEditor extends StatefulWidget { const BusyMarkSourceEditor({ super.key, @@ -50,6 +65,7 @@ class BusyMarkSourceEditor extends StatefulWidget { this.searchReplacement = '', this.onSearchReplacementChanged, required this.onChanged, + this.onTransactionalChanged, this.onUndo, this.onRedo, required this.onOpenSearch, @@ -77,8 +93,9 @@ class BusyMarkSourceEditor extends StatefulWidget { final String searchReplacement; final ValueChanged? onSearchReplacementChanged; final BusyMarkSourceChanged onChanged; - final String? Function()? onUndo; - final String? Function()? onRedo; + final BusyMarkSourceTransactionalChanged? onTransactionalChanged; + final TextEditingValue? Function()? onUndo; + final TextEditingValue? Function()? onRedo; final VoidCallback onOpenSearch; final VoidCallback onCloseSearch; final ValueChanged? onVisibleLineChanged; @@ -98,17 +115,29 @@ class BusyMarkSourceEditorState extends State { late BusyMarkSourceController _controller; late final FocusNode _focusNode; late final ScrollController _scrollController; + late final ScrollController _horizontalScrollController; late UndoHistoryController _undoController; final _sourceEditorKey = GlobalKey(); final _foldedRegionKeys = {}; final _searchController = SourceSearchController(); - final _replacementService = const SearchReplacementService(); + final _searchWorker = SourceSearchWorker(); + final _replacementWorker = SearchReplacementWorker(); + final _intrinsicWidthCache = SourceIntrinsicWidthCache(); final _lineLayoutCache = SourceLineLayoutCache(); final _autocompleteProvider = const SourceAutocompleteProvider(); List _foldRegions = const []; List _autocompleteSuggestions = const []; var _autocompleteSelection = 0; String _lastPath = ''; + bool _horizontalCaretScheduled = false; + bool _contentShrinkCorrectionScheduled = false; + Timer? _searchDebounce; + Timer? _foldRefreshDebounce; + _ContinuousSourceEdit? _continuousSourceEdit; + _SourceSessionSnapshot? _lastPublishedSession; + bool _sessionPublicationScheduled = false; + final _undoSessionId = ++_sourceEditorUndoSessionSequence; + var _undoGroupSequence = 0; @override void initState() { @@ -119,22 +148,31 @@ class BusyMarkSourceEditorState extends State { ); _focusNode = FocusNode(onKeyEvent: _handleKeyEvent); _scrollController = ScrollController(); + _horizontalScrollController = ScrollController(); _undoController = UndoHistoryController(); _lastPath = widget.documentId ?? widget.filePath ?? ''; _recomputeFoldRegions(resetCollapsed: true); _restoreSessionState(); + _lastPublishedSession = _sourceSessionSnapshot(); _syncSearchOptions(); - _controller.addListener(_publishSessionState); - _scrollController.addListener(_publishSessionState); + _controller.addListener(_handleControllerActivity); + _scrollController.addListener(_scheduleSessionPublication); } @override void didUpdateWidget(covariant BusyMarkSourceEditor oldWidget) { super.didUpdateWidget(oldWidget); + if (widget.text != oldWidget.text || + widget.searchOptions != oldWidget.searchOptions || + widget.searchReplacement != oldWidget.searchReplacement) { + _replacementWorker.cancel(); + } final path = widget.documentId ?? widget.filePath ?? ''; final pathChanged = path != _lastPath; final languageChanged = widget.language != oldWidget.language; + var authoritativeDocumentChanged = false; if (pathChanged) { + authoritativeDocumentChanged = true; _lastPath = path; _foldedRegionKeys.clear(); _withoutSessionPublication(() { @@ -142,8 +180,10 @@ class BusyMarkSourceEditorState extends State { _recomputeFoldRegions(resetCollapsed: true); _restoreSessionState(); }); - } else if ((widget.text != oldWidget.text && !_focusNode.hasFocus) || - languageChanged) { + _lastPublishedSession = _sourceSessionSnapshot(); + } else if (widget.text != _controller.fullText || languageChanged) { + authoritativeDocumentChanged = true; + _continuousSourceEdit = null; _withoutSessionPublication(() { _controller.replaceFullTextAndLanguage( text: widget.text, @@ -154,21 +194,81 @@ class BusyMarkSourceEditorState extends State { } if (widget.searchActive != oldWidget.searchActive || widget.searchOptions != oldWidget.searchOptions || - widget.text != oldWidget.text || - pathChanged) { + authoritativeDocumentChanged) { _syncSearchOptions(); } + if (widget.wordWrap && !oldWidget.wordWrap) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_horizontalScrollController.hasClients) { + _horizontalScrollController.jumpTo(0); + } + }); + } } @override void dispose() { + _searchDebounce?.cancel(); + _foldRefreshDebounce?.cancel(); + _searchWorker.dispose(); + _replacementWorker.dispose(); + _controller.removeListener(_handleControllerActivity); _scrollController.dispose(); + _horizontalScrollController.dispose(); _focusNode.dispose(); _undoController.dispose(); _controller.dispose(); super.dispose(); } + void _handleControllerActivity() { + _scheduleSessionPublication(); + if (widget.wordWrap || _horizontalCaretScheduled) { + return; + } + _horizontalCaretScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _horizontalCaretScheduled = false; + _ensureCaretHorizontallyVisible(); + }); + } + + void _ensureCaretHorizontallyVisible() { + if (!mounted || + widget.wordWrap || + !_horizontalScrollController.hasClients || + !_controller.selection.isValid) { + return; + } + final editorRenderObject = _sourceEditorKey.currentContext + ?.findRenderObject(); + if (editorRenderObject is! RenderBox) { + return; + } + final editable = _findSourceRenderEditable(editorRenderObject); + if (editable == null) { + return; + } + final caret = editable.getLocalRectForCaret( + TextPosition(offset: _controller.selection.extentOffset), + ); + final caretX = editable + .localToGlobal(caret.topLeft, ancestor: editorRenderObject) + .dx; + final position = _horizontalScrollController.position; + const margin = BusyMarkSpacing.lg; + var target = position.pixels; + if (caretX < position.pixels + margin) { + target = caretX - margin; + } else if (caretX > position.pixels + position.viewportDimension - margin) { + target = caretX - position.viewportDimension + margin; + } + target = target.clamp(0.0, position.maxScrollExtent).toDouble(); + if ((target - position.pixels).abs() > 0.5) { + position.jumpTo(target); + } + } + void scrollToLine(int line) { _unfoldSourceLine(line); final textOffset = _textOffsetForLine(_controller.fullText, line); @@ -217,6 +317,11 @@ class BusyMarkSourceEditorState extends State { if (event is! KeyDownEvent && event is! KeyRepeatEvent) { return KeyEventResult.ignored; } + if (_hasActiveComposition) { + // Keep Source shortcuts and focus traversal out of an active platform + // composition while leaving the key unhandled for the input method. + return KeyEventResult.skipRemainingHandlers; + } final keyboard = HardwareKeyboard.instance; final key = event.logicalKey; if (_autocompleteSuggestions.isNotEmpty) { @@ -255,9 +360,9 @@ class BusyMarkSourceEditorState extends State { event, keyboard, )) { - final text = widget.onUndo?.call(); - if (text != null) { - _applyOwnedUndoText(text); + final value = widget.onUndo?.call(); + if (value != null) { + _applyOwnedUndoValue(value); return KeyEventResult.handled; } } @@ -266,9 +371,9 @@ class BusyMarkSourceEditorState extends State { event, keyboard, )) { - final text = widget.onRedo?.call(); - if (text != null) { - _applyOwnedUndoText(text); + final value = widget.onRedo?.call(); + if (value != null) { + _applyOwnedUndoValue(value); return KeyEventResult.handled; } } @@ -343,6 +448,8 @@ class BusyMarkSourceEditorState extends State { child: _SourceEditorFrame( controller: _controller, scrollController: _scrollController, + horizontalScrollController: _horizontalScrollController, + wordWrap: widget.wordWrap, lineHeight: sourceLineHeight, textStyle: _sourceTextStyle, strutStyle: sourceStrutStyle, @@ -350,6 +457,7 @@ class BusyMarkSourceEditorState extends State { foldRegions: _foldRegions, diagnosticMarkers: markers, layoutCache: _lineLayoutCache, + intrinsicWidthCache: _intrinsicWidthCache, onToggleFold: _toggleFold, onVisibleLineChanged: widget.onVisibleLineChanged, child: SizedBox( @@ -369,6 +477,7 @@ class BusyMarkSourceEditorState extends State { BusyMarkContextCommandIntent: BusyMarkContextCommandAction( isCommandEnabled: (commandId) => + !_hasActiveComposition && commandId.startsWith('editor.'), onCommand: (commandId) { final name = commandId.substring( @@ -401,9 +510,7 @@ class BusyMarkSourceEditorState extends State { focusNode: _focusNode, scrollController: _scrollController, textDirection: TextDirection.ltr, - keyboardType: widget.wordWrap - ? TextInputType.multiline - : TextInputType.text, + keyboardType: TextInputType.multiline, autocorrect: false, enableSuggestions: false, smartDashesType: SmartDashesType.disabled, @@ -413,8 +520,8 @@ class BusyMarkSourceEditorState extends State { textAlignVertical: TextAlignVertical.top, style: _sourceTextStyle, strutStyle: sourceStrutStyle, - selectionHeightStyle: - BusyMarkDocumentTextGeometry.selectionHeightStyle, + selectionHeightStyle: BusyMarkDocumentTextGeometry + .sourceSelectionHeightStyle, selectionWidthStyle: BusyMarkDocumentTextGeometry.selectionWidthStyle, cursorColor: colors.foreground.withValues( @@ -485,10 +592,10 @@ class BusyMarkSourceEditorState extends State { replacement: widget.searchReplacement, onReplacementChanged: widget.onSearchReplacementChanged ?? (_) {}, - onReplaceCurrent: _replaceCurrentSearchMatch, + onReplaceCurrent: () => unawaited(_replaceCurrentSearchMatch()), onReplaceAndFindNext: () => - _replaceCurrentSearchMatch(findNext: true), - onReplaceAll: _replaceAllSearchMatches, + unawaited(_replaceCurrentSearchMatch(findNext: true)), + onReplaceAll: () => unawaited(_replaceAllSearchMatches()), onClose: widget.onCloseSearch, ), ), @@ -583,26 +690,101 @@ class BusyMarkSourceEditorState extends State { } void _syncSearchOptions() { + _scheduleSearch(); + } + + void _scheduleSearch({ + int? currentIndex, + int? firstMatchIndex, + int? minimumFullOffset, + bool revealCurrentAfterRefresh = false, + bool wrapIfOffsetMissing = true, + }) { + _searchDebounce?.cancel(); + _searchWorker.cancel(); if (!widget.searchActive) { - _searchController.setOptions( - const SourceSearchOptions(), - _controller.document, - ); + _searchController.stageOptions(const SourceSearchOptions()); _controller.setSearchResult(SourceSearchResult.empty); return; } - _searchController.setOptions(widget.searchOptions, _controller.document); + final options = widget.searchOptions; + final previousResult = _searchController.result; + final requestedFirstMatchIndex = + firstMatchIndex ?? + (previousResult.options == options + ? previousResult.firstMatchIndex + : 0); + final invalidRegex = sourceSearchOptionsHaveInvalidRegex(options); + _searchController.stageOptions(options, invalidRegex: invalidRegex); _controller.setSearchResult(_searchController.result); + if (options.query.isEmpty || invalidRegex) { + return; + } + _searchDebounce = Timer(const Duration(milliseconds: 120), () { + final document = _controller.document; + unawaited( + _searchWorker + .search( + document, + options, + currentMatchIndex: currentIndex, + firstMatchIndex: requestedFirstMatchIndex, + minimumFullOffset: minimumFullOffset, + ) + .then((result) { + if (!mounted || + result == null || + !identical(document, _controller.document) || + options != widget.searchOptions || + !widget.searchActive) { + return; + } + if (minimumFullOffset != null && + result.matches.isEmpty && + result.totalMatchCount > 0 && + wrapIfOffsetMissing) { + _scheduleSearch( + currentIndex: 0, + firstMatchIndex: 0, + revealCurrentAfterRefresh: revealCurrentAfterRefresh, + wrapIfOffsetMissing: false, + ); + return; + } + _searchController.acceptResult(result); + if (minimumFullOffset != null && result.matches.isNotEmpty) { + _searchController.setCurrentMatchIndex(result.firstMatchIndex); + } else if (revealCurrentAfterRefresh && + _searchController.result.currentMatch == null && + result.matches.isNotEmpty) { + _searchController.setCurrentMatchIndex(result.firstMatchIndex); + } + _controller.setSearchResult(_searchController.result); + setState(() {}); + if (revealCurrentAfterRefresh) { + _revealSearchMatch(_searchController.result.currentMatch); + } + }), + ); + }); } - void _refreshSearch({int? currentIndex}) { + void _refreshSearch({ + int? currentIndex, + int? firstMatchIndex, + int? minimumFullOffset, + bool revealCurrentAfterRefresh = false, + }) { if (!widget.searchActive) { _controller.setSearchResult(SourceSearchResult.empty); return; } - _searchController.refresh(_controller.document); - _searchController.setCurrentMatchIndex(currentIndex); - _controller.setSearchResult(_searchController.result); + _scheduleSearch( + currentIndex: currentIndex, + firstMatchIndex: firstMatchIndex, + minimumFullOffset: minimumFullOffset, + revealCurrentAfterRefresh: revealCurrentAfterRefresh, + ); } void _updateSearchOptions(SourceSearchOptions options) { @@ -610,36 +792,80 @@ class BusyMarkSourceEditorState extends State { } void _nextSearchMatch() { - final match = _searchController.next(_controller.document); - _revealSearchMatch(match); + final result = _searchController.result; + if (result.totalMatchCount == 0) { + _revealSearchMatch(null); + return; + } + final index = result.currentMatchIndex == null + ? 0 + : (result.currentMatchIndex! + 1) % result.totalMatchCount; + _selectSearchMatchIndex(index, loadPreviousWindow: false); } void _previousSearchMatch() { - final match = _searchController.previous(_controller.document); - _revealSearchMatch(match); + final result = _searchController.result; + if (result.totalMatchCount == 0) { + _revealSearchMatch(null); + return; + } + final index = result.currentMatchIndex == null + ? result.totalMatchCount - 1 + : (result.currentMatchIndex! - 1 + result.totalMatchCount) % + result.totalMatchCount; + _selectSearchMatchIndex(index, loadPreviousWindow: true); + } + + void _selectSearchMatchIndex(int index, {required bool loadPreviousWindow}) { + final result = _searchController.result; + final storedEnd = result.firstMatchIndex + result.matches.length; + if (index >= result.firstMatchIndex && index < storedEnd) { + _searchController.setCurrentMatchIndex(index); + _revealSearchMatch(_searchController.result.currentMatch); + return; + } + final firstMatchIndex = loadPreviousWindow + ? math.max(0, index - sourceInteractiveSearchMatchLimit + 1) + : index; + _scheduleSearch( + currentIndex: index, + firstMatchIndex: firstMatchIndex, + revealCurrentAfterRefresh: true, + ); } - void _replaceCurrentSearchMatch({bool findNext = false}) { + Future _replaceCurrentSearchMatch({bool findNext = false}) async { if (_searchController.result.invalidRegex) { return; } - var currentIndex = _searchController.result.currentMatchIndex; - if (currentIndex == null) { + if (_searchController.result.currentMatchIndex == null) { _searchController.next(_controller.document); - currentIndex = _searchController.result.currentMatchIndex; } - if (currentIndex == null) { + final currentIndex = _searchController.result.currentMatchIndex; + final currentMatch = _searchController.result.currentMatch; + if (currentIndex == null || currentMatch == null) { return; } - final preview = _replacementService.previewText( - source: _controller.fullText, - options: widget.searchOptions, - replacement: widget.searchReplacement, + final document = _controller.document; + final options = widget.searchOptions; + final replacement = widget.searchReplacement; + final preview = await _replacementWorker.previewText( + source: document.fullText, + options: options, + replacement: replacement, + targetStart: currentMatch.fullStart, + targetEnd: currentMatch.fullEnd, ); - if (preview.invalidRegex || currentIndex >= preview.matches.length) { + if (!mounted || + preview == null || + !identical(document, _controller.document) || + options != widget.searchOptions || + replacement != widget.searchReplacement || + preview.invalidRegex || + preview.matches.isEmpty) { return; } - final match = preview.matches[currentIndex]; + final match = preview.matches.single; final nextText = preview.source.replaceRange( match.start, match.end, @@ -654,24 +880,44 @@ class BusyMarkSourceEditorState extends State { ), ), ); - _refreshSearch( - currentIndex: math.min( - currentIndex, - math.max(0, preview.matches.length - 2), - ), - ); + final replacementEnd = match.start + match.replacement.length; if (findNext) { - _nextSearchMatch(); + _refreshSearch( + minimumFullOffset: replacementEnd, + revealCurrentAfterRefresh: true, + ); + } else { + _refreshSearch( + currentIndex: currentIndex, + firstMatchIndex: _searchController.result.firstMatchIndex, + ); } } - void _replaceAllSearchMatches() { - final preview = _replacementService.previewText( - source: _controller.fullText, - options: widget.searchOptions, - replacement: widget.searchReplacement, + Future _replaceAllSearchMatches() async { + final document = _controller.document; + final options = widget.searchOptions; + final replacement = widget.searchReplacement; + final preview = await _replacementWorker.previewText( + source: document.fullText, + options: options, + replacement: replacement, ); - if (preview.invalidRegex || preview.matches.isEmpty) { + if (!mounted || + preview == null || + !identical(document, _controller.document) || + options != widget.searchOptions || + replacement != widget.searchReplacement || + preview.invalidRegex || + preview.matches.isEmpty) { + return; + } + if (preview.truncated) { + BusyMarkToastOverlay.show( + context, + message: context.l10n.workspaceReplaceIssueTruncated, + priority: BusyMarkToastPriority.high, + ); return; } final nextText = preview.apply(); @@ -698,15 +944,7 @@ class BusyMarkSourceEditorState extends State { final currentIndex = _searchController.result.currentMatchIndex; if (match.hidden) { _unfoldSourceRange(match.fullStart, match.fullEnd); - _searchController.refreshCurrent(_controller.document); - _searchController.setCurrentMatchIndex(currentIndex); - match = _searchController.result.currentMatch; - if (match == null) { - setState(() { - _controller.setSearchResult(_searchController.result); - }); - return; - } + _refreshSearch(currentIndex: currentIndex); } final line = _controller.document.lineIndex.lineNumberAtOffset( match.fullStart, @@ -805,17 +1043,78 @@ class BusyMarkSourceEditorState extends State { } void _handleSourceChanged() { - setState(() { - _recomputeFoldRegions(); - _refreshSearch(currentIndex: _searchController.result.currentMatchIndex); - }); - widget.onChanged(_controller.fullText, widget.filePath); + _replacementWorker.cancel(); + final visibleEdit = _controller.lastVisibleEdit; + final selection = _controller.fullSelection; + final previousSelection = + _controller.lastFullSelectionBeforeEdit ?? selection; + final undoGroup = _undoGroupForSourceEdit( + visibleEdit, + previousSelection: previousSelection, + selection: selection, + ); + final currentSearchIndex = _searchController.result.currentMatchIndex; + final firstMatchIndex = _searchController.result.firstMatchIndex; + _scheduleFoldRefresh(); + _refreshSearch( + currentIndex: currentSearchIndex, + firstMatchIndex: firstMatchIndex, + ); + final transactionalCallback = widget.onTransactionalChanged; + if (transactionalCallback == null) { + widget.onChanged(_controller.fullText, widget.filePath); + } else { + transactionalCallback( + _controller.fullText, + widget.filePath, + previousSelection, + selection, + undoGroup, + ); + } if (_autocompleteSuggestions.isNotEmpty) { _refreshAutocomplete(); } + if (mounted) { + setState(() {}); + } + if (visibleEdit != null && visibleEdit.fullDelta < 0) { + _scheduleContentShrinkCorrection(); + } _publishSessionState(); } + void _scheduleContentShrinkCorrection() { + if (_contentShrinkCorrectionScheduled) { + return; + } + _contentShrinkCorrectionScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _contentShrinkCorrectionScheduled = false; + if (!mounted || !_scrollController.hasClients) { + return; + } + final position = _scrollController.position; + final target = position.pixels + .clamp(position.minScrollExtent, position.maxScrollExtent) + .toDouble(); + if ((target - position.pixels).abs() > 0.5) { + position.jumpTo(target); + } + }); + } + + void _scheduleFoldRefresh() { + _foldRefreshDebounce?.cancel(); + _foldRefreshDebounce = Timer(const Duration(milliseconds: 100), () { + if (!mounted) { + return; + } + setState(_recomputeFoldRegions); + _publishSessionState(); + }); + } + void _showAutocomplete() { final suggestions = _autocompleteProvider.suggestions( document: _controller.document, @@ -914,10 +1213,36 @@ class BusyMarkSourceEditorState extends State { if (_suppressSessionPublication) { return; } + final snapshot = _sourceSessionSnapshot(); + if (snapshot.sameAs(_lastPublishedSession)) { + return; + } + _lastPublishedSession = snapshot; widget.onSessionChanged?.call( - _controller.fullSelection, - _scrollController.hasClients ? _scrollController.offset : 0, - Set.unmodifiable(_foldedRegionKeys), + snapshot.selection, + snapshot.scrollOffset, + snapshot.foldedRegionKeys, + ); + } + + void _scheduleSessionPublication() { + if (_suppressSessionPublication || _sessionPublicationScheduled) { + return; + } + _sessionPublicationScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _sessionPublicationScheduled = false; + if (mounted) { + _publishSessionState(); + } + }); + } + + _SourceSessionSnapshot _sourceSessionSnapshot() { + return _SourceSessionSnapshot( + selection: _controller.fullSelection, + scrollOffset: _scrollController.hasClients ? _scrollController.offset : 0, + foldedRegionKeys: Set.unmodifiable(_foldedRegionKeys), ); } @@ -937,25 +1262,108 @@ class BusyMarkSourceEditorState extends State { return TextEditingValue( text: _controller.fullText, selection: _controller.fullSelection, + composing: _controller.fullComposing, ); } + bool get _hasActiveComposition { + final value = _controller.value; + final composing = value.composing; + return composing.isValid && + composing.isNormalized && + !composing.isCollapsed && + composing.end <= value.text.length; + } + void _applyFullEditingValue(TextEditingValue value) { _controller.setFullEditingValue(value); _focusNode.requestFocus(); _handleSourceChanged(); } - void _applyOwnedUndoText(String text) { - _controller.replaceFullTextAndLanguage( - text: text, - language: widget.language, - ); + void _applyOwnedUndoValue(TextEditingValue value) { + _continuousSourceEdit = null; + _controller.setFullEditingValue(value); _recomputeFoldRegions(); _refreshSearch(); setState(() {}); } + String? _undoGroupForSourceEdit( + SourceVisibleEdit? edit, { + required TextSelection previousSelection, + required TextSelection selection, + }) { + if (edit == null || + !previousSelection.isValid || + !previousSelection.isCollapsed || + !selection.isValid || + !selection.isCollapsed) { + _continuousSourceEdit = null; + return null; + } + final insertedLength = edit.replacement.length; + final removedLength = edit.replacedFullText.length; + final kind = switch ((insertedLength, removedLength)) { + (1, 0) => _SourceSimpleEditKind.typing, + (0, 1) => _SourceSimpleEditKind.deletion, + _ => null, + }; + if (kind == null || + !_sourceEditSelectionIsContinuous( + kind, + edit, + previousSelection, + selection, + )) { + _continuousSourceEdit = null; + return null; + } + final currentText = _controller.fullText; + final oldText = currentText.replaceRange( + edit.fullStart, + edit.fullStart + insertedLength, + edit.replacedFullText, + ); + final now = DateTime.now(); + final previous = _continuousSourceEdit; + final continuous = + previous != null && + previous.kind == kind && + previous.newText == oldText && + previous.selection == previousSelection && + now.difference(previous.timestamp) < const Duration(seconds: 2); + final group = continuous + ? previous.group + : 'source-${widget.documentId ?? widget.filePath ?? 'document'}-' + '$_undoSessionId-${++_undoGroupSequence}'; + _continuousSourceEdit = _ContinuousSourceEdit( + kind: kind, + newText: currentText, + selection: selection, + timestamp: now, + group: group, + ); + return group; + } + + bool _sourceEditSelectionIsContinuous( + _SourceSimpleEditKind kind, + SourceVisibleEdit edit, + TextSelection previousSelection, + TextSelection selection, + ) { + final previousCaret = previousSelection.extentOffset; + final caret = selection.extentOffset; + return switch (kind) { + _SourceSimpleEditKind.typing => + previousCaret == edit.fullStart && caret == edit.fullStart + 1, + _SourceSimpleEditKind.deletion => + (previousCaret == edit.fullStart || previousCaret == edit.fullEnd) && + caret == edit.fullStart, + }; + } + void _applyShortcutAction(BusyMarkEditorShortcutAction action) { switch (action) { case BusyMarkEditorShortcutAction.refineWithAi: @@ -1150,9 +1558,10 @@ class BusyMarkSourceEditorState extends State { required String text, required SourceSyntaxLanguage language, }) { + _continuousSourceEdit = null; final previous = _controller; _controller = BusyMarkSourceController(text: text, language: language); - _controller.addListener(_publishSessionState); + _controller.addListener(_handleControllerActivity); _resetUndoHistory(); WidgetsBinding.instance.addPostFrameCallback((_) { previous.dispose(); @@ -1257,6 +1666,8 @@ class _SourceEditorFrame extends StatelessWidget { const _SourceEditorFrame({ required this.controller, required this.scrollController, + required this.horizontalScrollController, + required this.wordWrap, required this.lineHeight, required this.textStyle, required this.strutStyle, @@ -1264,6 +1675,7 @@ class _SourceEditorFrame extends StatelessWidget { required this.collapsedRegionKeys, required this.diagnosticMarkers, required this.layoutCache, + required this.intrinsicWidthCache, required this.onToggleFold, this.onVisibleLineChanged, required this.child, @@ -1278,6 +1690,8 @@ class _SourceEditorFrame extends StatelessWidget { final BusyMarkSourceEditingController controller; final ScrollController scrollController; + final ScrollController horizontalScrollController; + final bool wordWrap; final double lineHeight; final TextStyle textStyle; final StrutStyle? strutStyle; @@ -1285,6 +1699,7 @@ class _SourceEditorFrame extends StatelessWidget { final Set collapsedRegionKeys; final List diagnosticMarkers; final SourceLineLayoutCache layoutCache; + final SourceIntrinsicWidthCache intrinsicWidthCache; final ValueChanged onToggleFold; final ValueChanged? onVisibleLineChanged; final Widget child; @@ -1300,9 +1715,22 @@ class _SourceEditorFrame extends StatelessWidget { constraints.maxWidth - _gutterWidth - BusyMarkStroke.hairline, ) .toDouble(); - final textWidth = math + final viewportTextWidth = math .max(1, editorWidth - editorPaddingLeft - editorPaddingRight) .toDouble(); + final textWidth = wordWrap + ? viewportTextWidth + : math.max( + viewportTextWidth, + intrinsicWidthCache.resolve( + context, + controller: controller, + textStyle: textStyle, + strutStyle: strutStyle, + ), + ); + final editorContentWidth = + textWidth + editorPaddingLeft + editorPaddingRight; int? visibleLineAt(double scrollOffset) { final layouts = layoutCache.resolve( context, @@ -1372,44 +1800,67 @@ class _SourceEditorFrame extends StatelessWidget { color: colors.subtleBorder, ), Expanded( - child: Stack( - children: [ - Positioned.fill( - child: _SourceRenderedTextLayer( - controller: controller, - scrollController: scrollController, - textStyle: textStyle, - strutStyle: strutStyle, - textWidth: textWidth, - ), - ), - if (collapsedRegionKeys.isNotEmpty) - Positioned.fill( - child: _CollapsedSourceLineOverlay( - controller: controller, - scrollController: scrollController, - lineHeight: lineHeight, - textWidth: textWidth, - textStyle: textStyle, - strutStyle: strutStyle, - foldRegions: foldRegions, - collapsedRegionKeys: collapsedRegionKeys, - diagnosticMarkers: diagnosticMarkers, - layoutCache: layoutCache, + child: ClipRect( + child: Scrollbar( + controller: horizontalScrollController, + thumbVisibility: !wordWrap, + notificationPredicate: (notification) => + notification.metrics.axis == Axis.horizontal, + child: SingleChildScrollView( + key: const ValueKey('source-horizontal-scroll-view'), + controller: horizontalScrollController, + scrollDirection: Axis.horizontal, + physics: wordWrap + ? const NeverScrollableScrollPhysics() + : null, + child: SizedBox( + width: editorContentWidth, + height: constraints.maxHeight, + child: Stack( + children: [ + Positioned.fill( + child: _SourceRenderedTextLayer( + controller: controller, + scrollController: scrollController, + textStyle: textStyle, + strutStyle: strutStyle, + textWidth: textWidth, + ), + ), + if (collapsedRegionKeys.isNotEmpty) + Positioned.fill( + child: _CollapsedSourceLineOverlay( + controller: controller, + scrollController: scrollController, + lineHeight: lineHeight, + textWidth: textWidth, + textStyle: textStyle, + strutStyle: strutStyle, + foldRegions: foldRegions, + collapsedRegionKeys: collapsedRegionKeys, + diagnosticMarkers: diagnosticMarkers, + layoutCache: layoutCache, + ), + ), + Positioned.fill( + child: NotificationListener( + onNotification: (notification) { + if (notification.metrics.axis == + Axis.vertical) { + reportVisibleLine( + notification.metrics.pixels, + ); + } + return false; + }, + child: child, + ), + ), + ], ), ), - Positioned.fill( - child: NotificationListener( - onNotification: (notification) { - if (notification.metrics.axis == Axis.vertical) { - reportVisibleLine(notification.metrics.pixels); - } - return false; - }, - child: child, - ), ), - ], + ), ), ), ], @@ -1420,6 +1871,17 @@ class _SourceEditorFrame extends StatelessWidget { } } +RenderEditable? _findSourceRenderEditable(RenderObject root) { + if (root is RenderEditable) { + return root; + } + RenderEditable? result; + root.visitChildren((child) { + result ??= _findSourceRenderEditable(child); + }); + return result; +} + class _SourceRenderedTextLayer extends StatelessWidget { const _SourceRenderedTextLayer({ required this.controller, @@ -1519,25 +1981,30 @@ class _CollapsedSourceLineOverlay extends StatelessWidget { textWidth: textWidth, diagnostics: diagnosticMarkers, ); - final linesByNumber = { - for (final line in sourceLineInfos(controller.fullText)) - line.number: line, - }; final scrollOffset = safeScrollOffset(scrollController); final children = []; - for (final layout in layouts) { + final visibleRange = sourceVisibleLayoutRange( + layouts, + scrollOffset: scrollOffset, + viewportHeight: constraints.maxHeight, + overscan: lineHeight, + ); + for (final layout in layouts.sublist( + visibleRange.start, + visibleRange.end, + )) { final line = layout.gutterLine; if (!line.collapsed) { continue; } final top = layout.top - scrollOffset; - if (top < -layout.height || top > constraints.maxHeight) { - continue; - } - final fullLine = linesByNumber[line.fullLine]; - if (fullLine == null) { + if (line.fullLine < 1 || + line.fullLine > controller.document.lineIndex.lineCount) { continue; } + final fullLine = controller.document.lineIndex.lineAt( + line.fullLine, + ); children.add( Positioned( top: top, @@ -1673,7 +2140,9 @@ class _SourceSearchPanelState extends State<_SourceSearchPanel> { ? context.l10n.sourceSearchInvalidRegex : result.totalMatchCount == 0 ? '0 / 0' - : '${(result.currentMatchIndex ?? 0) + 1} / ${result.totalMatchCount}'; + : result.currentMatchIndex == null + ? '– / ${result.totalMatchCount}' + : '${result.currentMatchIndex! + 1} / ${result.totalMatchCount}'; return BusyMarkSurface( color: colors.panel, child: Padding( @@ -1775,7 +2244,8 @@ class _SourceSearchPanelState extends State<_SourceSearchPanel> { _SearchPanelIconButton( tooltip: context.l10n.sourceSearchReplaceAll, icon: BusyMarkGlyphs.searchUnavailable, - onPressed: result.totalMatchCount == 0 + onPressed: + result.options.query.isEmpty || result.invalidRegex ? null : widget.onReplaceAll, ), @@ -1941,6 +2411,43 @@ class _SourceLargeFileBanner extends StatelessWidget { } } +enum _SourceSimpleEditKind { typing, deletion } + +class _ContinuousSourceEdit { + const _ContinuousSourceEdit({ + required this.kind, + required this.newText, + required this.selection, + required this.timestamp, + required this.group, + }); + + final _SourceSimpleEditKind kind; + final String newText; + final TextSelection selection; + final DateTime timestamp; + final String group; +} + +class _SourceSessionSnapshot { + const _SourceSessionSnapshot({ + required this.selection, + required this.scrollOffset, + required this.foldedRegionKeys, + }); + + final TextSelection selection; + final double scrollOffset; + final Set foldedRegionKeys; + + bool sameAs(_SourceSessionSnapshot? other) { + return other != null && + selection == other.selection && + scrollOffset == other.scrollOffset && + setEquals(foldedRegionKeys, other.foldedRegionKeys); + } +} + class _SourceEditorShortcutIntent extends Intent { const _SourceEditorShortcutIntent(this.action); diff --git a/lib/src/editor/source/source_gutter.dart b/lib/src/editor/source/source_gutter.dart index fe222203..945da13a 100644 --- a/lib/src/editor/source/source_gutter.dart +++ b/lib/src/editor/source/source_gutter.dart @@ -49,8 +49,25 @@ List sourceGutterModel({ } final diagnosticsByLine = >{}; for (final diagnostic in diagnostics) { + var displayLine = diagnostic.fullLine; + if (diagnostic.hidden) { + final visibleCollapsedOwner = foldRegions + .where( + (region) => + collapsedRegionKeys.contains(region.key) && + region.containsLine(diagnostic.fullLine) && + document.visibleLineForFullLine(region.startLine) != null, + ) + .fold(null, (current, candidate) { + if (current == null || candidate.startLine > current.startLine) { + return candidate; + } + return current; + }); + displayLine = visibleCollapsedOwner?.startLine ?? displayLine; + } diagnosticsByLine - .putIfAbsent(diagnostic.fullLine, () => []) + .putIfAbsent(displayLine, () => []) .add(diagnostic); } @@ -127,19 +144,25 @@ class BusyMarkSourceGutter extends StatelessWidget { textWidth: textWidth, diagnostics: diagnosticMarkers, ); - final activeLine = sourceLineNumberForOffset( - controller.fullText, - controller.visibleOffsetToFullOffset( - controller.selection.extentOffset, - ), - ); + final activeLine = controller.document.lineIndex + .lineNumberAtOffset( + controller.visibleOffsetToFullOffset( + controller.selection.extentOffset, + ), + ); final scrollOffset = safeScrollOffset(scrollController); final children = []; - for (final layout in layouts) { + final visibleRange = sourceVisibleLayoutRange( + layouts, + scrollOffset: scrollOffset, + viewportHeight: constraints.maxHeight, + overscan: lineHeight, + ); + for (final layout in layouts.sublist( + visibleRange.start, + visibleRange.end, + )) { final top = layout.top - scrollOffset; - if (top < -lineHeight || top > constraints.maxHeight) { - continue; - } final line = layout.gutterLine; children.add( Positioned( @@ -370,8 +393,7 @@ class SourceLineLayoutCache { _lineHeight == lineHeight && _textWidth == textWidth && _textScaler == textScaler && - _collapsedRegionKeys.isEmpty && - collapsedRegionKeys.isEmpty; + _setEquals(_collapsedRegionKeys, collapsedRegionKeys); if (geometryMatches) { final document = controller.document; if (identical(_document, document)) { @@ -481,6 +503,46 @@ class SourceLineLayoutCache { } } +bool _setEquals(Set left, Set right) { + return left.length == right.length && left.containsAll(right); +} + +({int start, int end}) sourceVisibleLayoutRange( + List layouts, { + required double scrollOffset, + required double viewportHeight, + double overscan = 0, +}) { + if (layouts.isEmpty || viewportHeight <= 0) { + return (start: 0, end: 0); + } + final minimum = math.max(0, scrollOffset - overscan); + final maximum = scrollOffset + viewportHeight + overscan; + var low = 0; + var high = layouts.length; + while (low < high) { + final middle = (low + high) >> 1; + final entry = layouts[middle]; + if (entry.top + entry.height < minimum) { + low = middle + 1; + } else { + high = middle; + } + } + final start = low; + low = start; + high = layouts.length; + while (low < high) { + final middle = (low + high) >> 1; + if (layouts[middle].top <= maximum) { + low = middle + 1; + } else { + high = middle; + } + } + return (start: start, end: low); +} + List _entriesWithCurrentGutterModel( List entries, { required SourceDocument document, @@ -681,9 +743,6 @@ List sourceLineLayoutEntries( collapsedRegionKeys: collapsedRegionKeys, diagnostics: diagnostics, ); - final linesByFullLine = { - for (final line in controller.document.lineIndex.lines) line.number: line, - }; final layouts = []; final painter = sourceTextPainter( context, @@ -725,7 +784,6 @@ List sourceLineLayoutEntries( ); } painter.dispose(); - linesByFullLine.clear(); return layouts; } diff --git a/lib/src/editor/source/source_intrinsic_width.dart b/lib/src/editor/source/source_intrinsic_width.dart new file mode 100644 index 00000000..d2a6928f --- /dev/null +++ b/lib/src/editor/source/source_intrinsic_width.dart @@ -0,0 +1,139 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +import '../../app/busymark_design.dart'; +import '../source_highlighter.dart'; + +/// Resolves the unwrapped Source editor width without laying out the complete +/// document a second time. +class SourceIntrinsicWidthCache { + static const int _largeFileColumnCap = 4096; + + Object? _document; + SourceSyntaxLanguage? _language; + TextStyle? _textStyle; + StrutStyle? _strutStyle; + TextScaler? _textScaler; + Map _lineWidths = const {}; + double? _largeFileEstimatedWidth; + double? _width; + int _lineMeasureCount = 0; + bool _usingLargeFileEstimate = false; + + @visibleForTesting + int get debugLineMeasureCount => _lineMeasureCount; + + @visibleForTesting + bool get debugUsingLargeFileEstimate => _usingLargeFileEstimate; + + double resolve( + BuildContext context, { + required BusyMarkSourceEditingController controller, + required TextStyle textStyle, + required StrutStyle? strutStyle, + }) { + final document = controller.document; + final textScaler = MediaQuery.textScalerOf(context); + final cached = _width; + if (cached != null && + identical(_document, document) && + _language == controller.language && + _textStyle == textStyle && + _strutStyle == strutStyle && + _textScaler == textScaler) { + return cached; + } + + final configurationChanged = + _language != controller.language || + _textStyle != textStyle || + _strutStyle != strutStyle || + _textScaler != textScaler; + if (configurationChanged) { + _lineWidths = const {}; + _largeFileEstimatedWidth = null; + } + + final double width; + if (controller.sourceFeaturesDegraded) { + _usingLargeFileEstimate = true; + _lineWidths = const {}; + width = _largeFileEstimatedWidth ??= _estimateLargeFileWidth( + textStyle: textStyle, + textScaler: textScaler, + ); + } else { + _usingLargeFileEstimate = false; + final retainedWidths = {}; + var maximumLineWidth = 0.0; + for (final line in document.visibleLineIndex.lines) { + final lineWidth = + _lineWidths[line.text] ?? + _measureLine( + context, + source: line.text, + language: controller.language, + textStyle: textStyle, + strutStyle: strutStyle, + textScaler: textScaler, + ); + retainedWidths[line.text] = lineWidth; + maximumLineWidth = math.max(maximumLineWidth, lineWidth); + } + _lineWidths = retainedWidths; + width = math + .max(1, maximumLineWidth + BusyMarkStroke.sourceCursor) + .toDouble(); + } + + _document = document; + _language = controller.language; + _textStyle = textStyle; + _strutStyle = strutStyle; + _textScaler = textScaler; + _width = width; + return width; + } + + double _measureLine( + BuildContext context, { + required String source, + required SourceSyntaxLanguage language, + required TextStyle textStyle, + required StrutStyle? strutStyle, + required TextScaler textScaler, + }) { + _lineMeasureCount++; + final painter = TextPainter( + text: buildBusyMarkReadOnlySourceTextSpan( + context: context, + source: source, + language: language, + style: textStyle, + ), + strutStyle: strutStyle, + textDirection: TextDirection.ltr, + textScaler: textScaler, + )..layout(); + final width = painter.width; + painter.dispose(); + return width; + } + + double _estimateLargeFileWidth({ + required TextStyle textStyle, + required TextScaler textScaler, + }) { + final painter = TextPainter( + text: TextSpan(text: 'M', style: textStyle), + textDirection: TextDirection.ltr, + textScaler: textScaler, + )..layout(); + final columnWidth = painter.width; + painter.dispose(); + return math + .max(1, columnWidth * _largeFileColumnCap + BusyMarkStroke.sourceCursor) + .toDouble(); + } +} diff --git a/lib/src/editor/source/source_line_index.dart b/lib/src/editor/source/source_line_index.dart index 9286a872..597e7957 100644 --- a/lib/src/editor/source/source_line_index.dart +++ b/lib/src/editor/source/source_line_index.dart @@ -25,6 +25,80 @@ class SourceLineIndex { _lines = _buildLines(source); } + SourceLineIndex._({required this.source, required List lines}) + : _lines = lines; + + factory SourceLineIndex.updated({ + required SourceLineIndex previous, + required String source, + required int oldStart, + required int oldEnd, + }) { + if (previous.source == source) { + return previous; + } + final safeStart = oldStart.clamp(0, previous.source.length).toInt(); + final safeEnd = oldEnd.clamp(safeStart, previous.source.length).toInt(); + // Include the neighboring code units so edits at a CRLF boundary cannot + // leave one half of the pair in a reused prefix or suffix line. + final firstChangedIndex = + previous.lineNumberAtOffset(math.max(0, safeStart - 1)) - 1; + final lastChangedIndex = + previous.lineNumberAtOffset( + math.min(previous.source.length, safeEnd + 1), + ) - + 1; + final suffixIndex = lastChangedIndex + 1; + final segmentStart = previous._lines[firstChangedIndex].startOffset; + final oldSuffixStart = suffixIndex < previous._lines.length + ? previous._lines[suffixIndex].startOffset + : previous.source.length; + final delta = source.length - previous.source.length; + final newSuffixStart = (oldSuffixStart + delta) + .clamp(segmentStart, source.length) + .toInt(); + final localLines = _buildLines( + source.substring(segmentStart, newSuffixStart), + ); + if (suffixIndex < previous._lines.length && + localLines.isNotEmpty && + localLines.last.text.isEmpty && + !localLines.last.hasLineBreak) { + localLines.removeLast(); + } + + final result = [...previous._lines.take(firstChangedIndex)]; + for (final line in localLines) { + result.add( + SourceLine( + number: result.length + 1, + startOffset: segmentStart + line.startOffset, + endOffset: segmentStart + line.endOffset, + endOffsetIncludingLineBreak: + segmentStart + line.endOffsetIncludingLineBreak, + text: line.text, + lineBreak: line.lineBreak, + ), + ); + } + for (final line in previous._lines.skip(suffixIndex)) { + result.add( + SourceLine( + number: result.length + 1, + startOffset: line.startOffset + delta, + endOffset: line.endOffset + delta, + endOffsetIncludingLineBreak: line.endOffsetIncludingLineBreak + delta, + text: line.text, + lineBreak: line.lineBreak, + ), + ); + } + if (result.isEmpty) { + return SourceLineIndex(source); + } + return SourceLineIndex._(source: source, lines: result); + } + final String source; late final List _lines; diff --git a/lib/src/editor/source/source_search.dart b/lib/src/editor/source/source_search.dart index 47dd9534..67395748 100644 --- a/lib/src/editor/source/source_search.dart +++ b/lib/src/editor/source/source_search.dart @@ -1,4 +1,14 @@ +import 'dart:async'; +import 'dart:isolate'; + import 'source_document.dart'; +import 'source_hidden_ranges.dart'; + +/// Maximum number of interactive matches transferred back to the UI isolate +/// at once. The worker still reports the complete match count so the search +/// panel can remain accurate and another window can be requested for +/// navigation. +const int sourceInteractiveSearchMatchLimit = 2048; class SourceSearchOptions { const SourceSearchOptions({ @@ -59,31 +69,41 @@ class SourceSearchMatch { } class SourceSearchResult { - const SourceSearchResult({ + SourceSearchResult({ required this.options, required this.matches, this.currentMatchIndex, + int? totalMatchCount, + this.firstMatchIndex = 0, this.invalidRegex = false, - }); + }) : totalMatchCount = totalMatchCount ?? matches.length; - static const empty = SourceSearchResult( + static final empty = SourceSearchResult( options: SourceSearchOptions(), matches: [], ); final SourceSearchOptions options; final List matches; + + /// The current match's index in the complete result set, not just [matches]. final int? currentMatchIndex; - final bool invalidRegex; + final int totalMatchCount; - int get totalMatchCount => matches.length; + /// The complete-result index represented by [matches.first]. + final int firstMatchIndex; + final bool invalidRegex; SourceSearchMatch? get currentMatch { final index = currentMatchIndex; - if (index == null || index < 0 || index >= matches.length) { + if (index == null) { + return null; + } + final localIndex = index - firstMatchIndex; + if (localIndex < 0 || localIndex >= matches.length) { return null; } - return matches[index]; + return matches[localIndex]; } SourceSearchResult copyWith({int? currentMatchIndex}) { @@ -91,6 +111,8 @@ class SourceSearchResult { options: options, matches: matches, currentMatchIndex: currentMatchIndex, + totalMatchCount: totalMatchCount, + firstMatchIndex: firstMatchIndex, invalidRegex: invalidRegex, ); } @@ -100,6 +122,8 @@ class SourceSearchResult { return other is SourceSearchResult && other.options == options && other.currentMatchIndex == currentMatchIndex && + other.totalMatchCount == totalMatchCount && + other.firstMatchIndex == firstMatchIndex && other.invalidRegex == invalidRegex && _matchesEqual(other.matches, matches); } @@ -108,6 +132,8 @@ class SourceSearchResult { int get hashCode => Object.hash( options, currentMatchIndex, + totalMatchCount, + firstMatchIndex, invalidRegex, Object.hashAll(matches.map(_matchHash)), ); @@ -133,6 +159,24 @@ class SourceSearchController { refresh(document); } + void stageOptions(SourceSearchOptions options, {bool invalidRegex = false}) { + _options = options; + _version++; + _result = SourceSearchResult( + options: options, + matches: const [], + invalidRegex: invalidRegex, + ); + } + + void acceptResult(SourceSearchResult result) { + if (result.options != _options) { + return; + } + _version++; + _result = result; + } + void refresh(SourceDocument document) { _version++; _result = searchSourceDocument(document, _options); @@ -150,7 +194,9 @@ class SourceSearchController { void setCurrentMatchIndex(int? index) { final safeIndex = - index != null && index >= 0 && index < _result.matches.length + index != null && + index >= _result.firstMatchIndex && + index < _result.firstMatchIndex + _result.matches.length ? index : null; _result = _result.copyWith(currentMatchIndex: safeIndex); @@ -165,8 +211,12 @@ class SourceSearchController { return null; } final nextIndex = _result.currentMatchIndex == null - ? 0 - : (_result.currentMatchIndex! + 1) % _result.matches.length; + ? _result.firstMatchIndex + : (_result.currentMatchIndex! + 1) % _result.totalMatchCount; + if (nextIndex < _result.firstMatchIndex || + nextIndex >= _result.firstMatchIndex + _result.matches.length) { + return null; + } _result = _result.copyWith(currentMatchIndex: nextIndex); return _result.currentMatch; } @@ -180,9 +230,13 @@ class SourceSearchController { return null; } final previousIndex = _result.currentMatchIndex == null - ? _result.matches.length - 1 - : (_result.currentMatchIndex! - 1 + _result.matches.length) % - _result.matches.length; + ? _result.totalMatchCount - 1 + : (_result.currentMatchIndex! - 1 + _result.totalMatchCount) % + _result.totalMatchCount; + if (previousIndex < _result.firstMatchIndex || + previousIndex >= _result.firstMatchIndex + _result.matches.length) { + return null; + } _result = _result.copyWith(currentMatchIndex: previousIndex); return _result.currentMatch; } @@ -194,6 +248,190 @@ class SourceSearchController { } } +/// Runs potentially expensive regular expressions away from Flutter's UI +/// isolate. Starting a newer request kills the preceding worker immediately. +class SourceSearchWorker { + Isolate? _isolate; + ReceivePort? _receivePort; + Completer? _completer; + var _generation = 0; + + Future search( + SourceDocument document, + SourceSearchOptions options, { + int? currentMatchIndex, + int firstMatchIndex = 0, + int? minimumFullOffset, + int maximumMatches = sourceInteractiveSearchMatchLimit, + }) { + cancel(); + final generation = ++_generation; + final receivePort = ReceivePort(); + final completer = Completer(); + _receivePort = receivePort; + _completer = completer; + receivePort.listen((message) { + if (generation != _generation || completer.isCompleted) { + return; + } + if (message is Map) { + completer.complete(_decodeSearchResult(message, options)); + } else { + completer.complete(null); + } + _releaseWorker(kill: true); + }); + final request = { + 'text': document.fullText, + 'hidden': [ + for (final range in document.hiddenRanges.ranges) + [range.start, range.end, range.key], + ], + 'query': options.query, + 'caseSensitive': options.caseSensitive, + 'wholeWord': options.wholeWord, + 'regex': options.regex, + 'currentMatchIndex': currentMatchIndex, + 'firstMatchIndex': firstMatchIndex, + 'minimumFullOffset': minimumFullOffset, + 'maximumMatches': maximumMatches, + }; + Isolate.spawn>( + _sourceSearchWorkerMain, + [receivePort.sendPort, request], + debugName: 'BusyMark source search', + onError: receivePort.sendPort, + onExit: receivePort.sendPort, + ) + .then((isolate) { + if (generation != _generation || completer.isCompleted) { + isolate.kill(priority: Isolate.immediate); + } else { + _isolate = isolate; + } + }) + .catchError((Object _) { + if (generation == _generation && !completer.isCompleted) { + completer.complete(null); + _releaseWorker(kill: false); + } + }); + return completer.future; + } + + void cancel() { + _generation++; + final completer = _completer; + if (completer != null && !completer.isCompleted) { + completer.complete(null); + } + _releaseWorker(kill: true); + } + + void dispose() => cancel(); + + void _releaseWorker({required bool kill}) { + if (kill) { + _isolate?.kill(priority: Isolate.immediate); + } + _isolate = null; + _receivePort?.close(); + _receivePort = null; + _completer = null; + } +} + +void _sourceSearchWorkerMain(List payload) { + final sendPort = payload[0] as SendPort; + final request = payload[1] as Map; + final text = request['text']! as String; + final hidden = request['hidden']! as List; + final options = SourceSearchOptions( + query: request['query']! as String, + caseSensitive: request['caseSensitive']! as bool, + wholeWord: request['wholeWord']! as bool, + regex: request['regex']! as bool, + ); + final document = SourceDocument( + fullText: text, + hiddenRanges: SourceHiddenRanges( + ranges: [ + for (final item in hidden.cast>()) + SourceHiddenRange( + start: item[0]! as int, + end: item[1]! as int, + key: item[2] as String?, + ), + ], + textLength: text.length, + ), + ); + final result = searchSourceDocument( + document, + options, + currentMatchIndex: request['currentMatchIndex'] as int?, + firstMatchIndex: request['firstMatchIndex']! as int, + minimumFullOffset: request['minimumFullOffset'] as int?, + maximumMatches: request['maximumMatches']! as int, + ); + sendPort.send({ + 'invalidRegex': result.invalidRegex, + 'currentMatchIndex': result.currentMatchIndex, + 'totalMatchCount': result.totalMatchCount, + 'firstMatchIndex': result.firstMatchIndex, + 'matches': [ + for (final match in result.matches) + [ + match.fullStart, + match.fullEnd, + match.visibleStart, + match.visibleEnd, + match.hidden, + ], + ], + }); +} + +SourceSearchResult _decodeSearchResult( + Map payload, + SourceSearchOptions options, +) { + final encodedMatches = payload['matches']! as List; + return SourceSearchResult( + options: options, + matches: List.unmodifiable([ + for (final item in encodedMatches.cast>()) + SourceSearchMatch( + fullStart: item[0]! as int, + fullEnd: item[1]! as int, + visibleStart: item[2]! as int, + visibleEnd: item[3]! as int, + hidden: item[4]! as bool, + ), + ]), + currentMatchIndex: payload['currentMatchIndex'] as int?, + totalMatchCount: payload['totalMatchCount']! as int, + firstMatchIndex: payload['firstMatchIndex']! as int, + invalidRegex: payload['invalidRegex']! as bool, + ); +} + +bool sourceSearchOptionsHaveInvalidRegex(SourceSearchOptions options) { + if (!options.regex || options.query.isEmpty) { + return false; + } + try { + RegExp( + options.query, + caseSensitive: options.caseSensitive, + multiLine: true, + ); + return false; + } on FormatException { + return true; + } +} + class SourceWorkspaceSearchFileResult { const SourceWorkspaceSearchFileResult({ required this.filePath, @@ -234,6 +472,9 @@ SourceSearchResult searchSourceDocument( SourceDocument document, SourceSearchOptions options, { int? currentMatchIndex, + int firstMatchIndex = 0, + int? minimumFullOffset, + int? maximumMatches, }) { final query = options.query; if (query.isEmpty) { @@ -258,7 +499,12 @@ SourceSearchResult searchSourceDocument( rawMatches = _plainMatches(document.fullText, query, options.caseSensitive); } + final requestedFirstMatchIndex = firstMatchIndex < 0 ? 0 : firstMatchIndex; + final matchLimit = maximumMatches?.clamp(0, 0x7fffffff).toInt(); final matches = []; + var totalMatchCount = 0; + var storedFirstMatchIndex = requestedFirstMatchIndex; + var foundOffsetWindow = minimumFullOffset == null; for (final match in rawMatches) { if (match.start == match.end) { continue; @@ -267,6 +513,18 @@ SourceSearchResult searchSourceDocument( !_isWholeWord(document.fullText, match.start, match.end)) { continue; } + final matchIndex = totalMatchCount++; + if (!foundOffsetWindow) { + if (match.start < minimumFullOffset!) { + continue; + } + storedFirstMatchIndex = matchIndex; + foundOffsetWindow = true; + } + if (minimumFullOffset == null && matchIndex < requestedFirstMatchIndex || + (matchLimit != null && matches.length >= matchLimit)) { + continue; + } final visible = document.fullRangeToVisibleRange(match.start, match.end); matches.add( SourceSearchMatch( @@ -280,13 +538,22 @@ SourceSearchResult searchSourceDocument( ), ); } + if (!foundOffsetWindow) { + storedFirstMatchIndex = totalMatchCount; + } + final storedEnd = storedFirstMatchIndex + matches.length; return SourceSearchResult( options: options, matches: List.unmodifiable(matches), currentMatchIndex: - currentMatchIndex != null && currentMatchIndex < matches.length + currentMatchIndex != null && + currentMatchIndex >= storedFirstMatchIndex && + currentMatchIndex < storedEnd && + currentMatchIndex < totalMatchCount ? currentMatchIndex : null, + totalMatchCount: totalMatchCount, + firstMatchIndex: storedFirstMatchIndex, ); } @@ -295,35 +562,61 @@ Iterable<({int start, int end})> _plainMatches( String query, bool caseSensitive, ) sync* { - final haystack = caseSensitive ? source : source.toLowerCase(); - final needle = caseSensitive ? query : query.toLowerCase(); - var index = 0; - while (index <= haystack.length - needle.length) { - final found = haystack.indexOf(needle, index); - if (found < 0) { - break; - } - yield (start: found, end: found + needle.length); - index = found + needle.length; + final expression = RegExp( + RegExp.escape(query), + caseSensitive: caseSensitive, + unicode: true, + ); + for (final match in expression.allMatches(source)) { + yield (start: match.start, end: match.end); } } bool _isWholeWord(String source, int start, int end) { - final before = start <= 0 ? null : source.codeUnitAt(start - 1); - final after = end >= source.length ? null : source.codeUnitAt(end); - return !_isWordUnit(before) && !_isWordUnit(after); + return !_isUnicodeWordCharacter(_characterBefore(source, start)) && + !_isUnicodeWordCharacter(_characterAt(source, end)); } -bool _isWordUnit(int? unit) { - if (unit == null) { +final _unicodeWordCharacter = RegExp(r'^[\p{L}\p{N}\p{M}_]$', unicode: true); + +bool _isUnicodeWordCharacter(String? character) { + if (character == null) { return false; } - return (unit >= 48 && unit <= 57) || - (unit >= 65 && unit <= 90) || - (unit >= 97 && unit <= 122) || - unit == 95; + return _unicodeWordCharacter.hasMatch(character); +} + +String? _characterBefore(String source, int offset) { + if (offset <= 0 || source.isEmpty) { + return null; + } + final end = offset.clamp(0, source.length).toInt(); + var start = end - 1; + if (_isLowSurrogate(source.codeUnitAt(start)) && + start > 0 && + _isHighSurrogate(source.codeUnitAt(start - 1))) { + start--; + } + return source.substring(start, end); } +String? _characterAt(String source, int offset) { + if (offset < 0 || offset >= source.length) { + return null; + } + var end = offset + 1; + if (_isHighSurrogate(source.codeUnitAt(offset)) && + end < source.length && + _isLowSurrogate(source.codeUnitAt(end))) { + end++; + } + return source.substring(offset, end); +} + +bool _isHighSurrogate(int unit) => unit >= 0xD800 && unit <= 0xDBFF; + +bool _isLowSurrogate(int unit) => unit >= 0xDC00 && unit <= 0xDFFF; + bool _isAuthoringContentPath(String path) { final lower = path.toLowerCase(); return lower.endsWith('.md') || diff --git a/lib/src/editor/source_folding.dart b/lib/src/editor/source_folding.dart index 985e8137..9fec01b8 100644 --- a/lib/src/editor/source_folding.dart +++ b/lib/src/editor/source_folding.dart @@ -144,13 +144,7 @@ int visibleSourceLineIndex( int sourceLineNumberForOffset(String source, int offset) { final safeOffset = offset.clamp(0, source.length).toInt(); - final lines = sourceLineInfos(source); - for (final line in lines) { - if (safeOffset <= line.endOffsetIncludingLineBreak) { - return line.number; - } - } - return lines.isEmpty ? 1 : lines.last.number; + return SourceLineIndex(source).lineNumberAtOffset(safeOffset); } List collapsedSourceFoldRegions( @@ -184,27 +178,32 @@ SourceFoldRegion? collapsedRegionContainingLine( List _markdownFoldRegions(List lines) { final regions = []; - _addMarkdownHeadingRegions(lines, regions); - _addMarkdownFenceRegions(lines, regions); - _addMarkdownListRegions(lines, regions); - _addMarkdownBlockquoteRegions(lines, regions); + final fencedLines = _addMarkdownFenceRegions(lines, regions); + _addMarkdownHeadingRegions(lines, regions, fencedLines); + _addMarkdownListRegions(lines, regions, fencedLines); + _addMarkdownBlockquoteRegions(lines, regions, fencedLines); return regions; } void _addMarkdownHeadingRegions( List lines, List regions, + List fencedLines, ) { final headingPattern = RegExp(r'^\s{0,3}(#{1,6})\s+'); for (var index = 0; index < lines.length; index++) { - final heading = headingPattern.firstMatch(lines[index].text); + final heading = fencedLines[index] + ? null + : headingPattern.firstMatch(lines[index].text); if (heading == null) { continue; } final level = heading.group(1)!.length; var endIndex = lines.length - 1; for (var next = index + 1; next < lines.length; next++) { - final nextHeading = headingPattern.firstMatch(lines[next].text); + final nextHeading = fencedLines[next] + ? null + : headingPattern.firstMatch(lines[next].text); if (nextHeading != null && nextHeading.group(1)!.length <= level) { endIndex = next - 1; break; @@ -220,10 +219,11 @@ void _addMarkdownHeadingRegions( } } -void _addMarkdownFenceRegions( +List _addMarkdownFenceRegions( List lines, List regions, ) { + final fencedLines = List.filled(lines.length, false); var index = 0; while (index < lines.length) { final fence = MarkdownFence.parse(lines[index].text); @@ -233,6 +233,7 @@ void _addMarkdownFenceRegions( } var endIndex = index; for (var next = index + 1; next < lines.length; next++) { + fencedLines[next] = true; if (fence.closes(lines[next].text)) { endIndex = next; break; @@ -245,17 +246,25 @@ void _addMarkdownFenceRegions( startIndex: index, endIndex: endIndex, ); + if (endIndex == index) { + for (var next = index + 1; next < lines.length; next++) { + fencedLines[next] = true; + } + break; + } index = endIndex + 1; } + return fencedLines; } void _addMarkdownListRegions( List lines, List regions, + List fencedLines, ) { var index = 0; while (index < lines.length) { - if (!_isMarkdownListLine(lines[index].text)) { + if (fencedLines[index] || !_isMarkdownListLine(lines[index].text)) { index++; continue; } @@ -288,16 +297,19 @@ void _addMarkdownListRegions( void _addMarkdownBlockquoteRegions( List lines, List regions, + List fencedLines, ) { var index = 0; while (index < lines.length) { - if (!RegExp(r'^\s{0,3}>\s?').hasMatch(lines[index].text)) { + if (fencedLines[index] || + !RegExp(r'^\s{0,3}>\s?').hasMatch(lines[index].text)) { index++; continue; } var endIndex = index; for (var next = index + 1; next < lines.length; next++) { - if (!RegExp(r'^\s{0,3}>\s?').hasMatch(lines[next].text)) { + if (fencedLines[next] || + !RegExp(r'^\s{0,3}>\s?').hasMatch(lines[next].text)) { break; } endIndex = next; @@ -316,64 +328,196 @@ void _addMarkdownBlockquoteRegions( List _xmlFoldRegions(List lines) { final regions = []; final stack = <({String tag, int lineIndex})>[]; - for (var index = 0; index < lines.length; index++) { - final text = _xmlLineWithoutComments(lines[index].text); - for (final tag in _xmlTagsInLine(text)) { - if (tag.selfClosing || tag.declaration) { - continue; - } - if (!tag.closing) { - stack.add((tag: tag.name, lineIndex: index)); - continue; - } - if (stack.isEmpty) { - continue; - } - final opening = stack.removeLast(); - if (opening.tag != tag.name) { - stack.clear(); - continue; - } - if (opening.lineIndex < index) { - _addRegion( - regions, - lines, - kind: SourceFoldKind.xml, - startIndex: opening.lineIndex, - endIndex: index, - ); - } + for (final tag in _xmlTags(lines)) { + if (tag.selfClosing) { + continue; + } + if (!tag.closing) { + stack.add((tag: tag.name, lineIndex: tag.startLineIndex)); + continue; + } + if (stack.isEmpty) { + continue; + } + final opening = stack.removeLast(); + if (opening.tag != tag.name) { + stack.clear(); + continue; + } + if (opening.lineIndex < tag.endLineIndex) { + _addRegion( + regions, + lines, + kind: SourceFoldKind.xml, + startIndex: opening.lineIndex, + endIndex: tag.endLineIndex, + ); } } return regions; } -String _xmlLineWithoutComments(String line) { - return line.replaceAll(RegExp(r''), ''); +enum _XmlScanMode { + text, + tag, + comment, + cdata, + processingInstruction, + declaration, + declarationComment, } -Iterable<({String name, bool closing, bool selfClosing, bool declaration})> -_xmlTagsInLine(String line) sync* { - for (final match in RegExp(r'<[^>]+>').allMatches(line)) { - final text = match.group(0)!; - if (text.startsWith('', offset); + if (end < 0) { + offset = line.length; + } else { + mode = _XmlScanMode.text; + offset = end + 3; + } + case _XmlScanMode.cdata: + final end = line.indexOf(']]>', offset); + if (end < 0) { + offset = line.length; + } else { + mode = _XmlScanMode.text; + offset = end + 3; + } + case _XmlScanMode.processingInstruction: + final end = line.indexOf('?>', offset); + if (end < 0) { + offset = line.length; + } else { + mode = _XmlScanMode.text; + offset = end + 2; + } + case _XmlScanMode.declaration: + if (line.startsWith('', offset); + if (end < 0) { + offset = line.length; + } else { + mode = _XmlScanMode.declaration; + offset = end + 3; + } + } } - final declaration = - text.startsWith(''), - declaration: declaration, - ); } } +({String name, bool closing, bool selfClosing})? _parseXmlTag(String text) { + final match = RegExp( + r'^<(/?)\s*([A-Za-z_][A-Za-z0-9_.:-]*)', + ).firstMatch(text); + if (match == null) { + return null; + } + final closing = match.group(1)!.isNotEmpty; + return ( + name: match.group(2)!, + closing: closing, + selfClosing: !closing && RegExp(r'/\s*>$').hasMatch(text), + ); +} + bool _isMarkdownListLine(String text) { return RegExp(r'^\s{0,8}(?:[-*+]|\d+\.)\s+').hasMatch(text); } diff --git a/lib/src/editor/source_highlighter.dart b/lib/src/editor/source_highlighter.dart index 4e9d4654..bfb5ac26 100644 --- a/lib/src/editor/source_highlighter.dart +++ b/lib/src/editor/source_highlighter.dart @@ -101,6 +101,7 @@ class BusyMarkSourceEditingController extends TextEditingController { List _foldedRegions = const []; SourceDocument _document; SourceVisibleEdit? lastVisibleEdit; + TextSelection? lastFullSelectionBeforeEdit; SourceSearchResult _searchResult = SourceSearchResult.empty; void Function(String fullText, SourceVisibleEdit? edit)? onFullTextChanged; bool renderText = true; @@ -143,6 +144,10 @@ class BusyMarkSourceEditingController extends TextEditingController { selection = _document.fullSelectionToVisibleSelection(value); } + TextRange get fullComposing { + return _visibleComposingToFullRange(value.composing); + } + int fullOffsetToVisibleOffset(int offset) { return _document.fullOffsetToVisibleOffset(offset); } @@ -158,13 +163,20 @@ class BusyMarkSourceEditingController extends TextEditingController { final selectionSnapshot = fullSelection ?? this.fullSelection; _document = _createDocument(value, _foldedRegions); lastVisibleEdit = null; + final visibleSelection = _document.fullSelectionToVisibleSelection( + selectionSnapshot, + ); super.value = TextEditingValue( text: _document.visibleText, - selection: _document.fullSelectionToVisibleSelection(selectionSnapshot), + selection: _selectionWithLineEndAffinity( + visibleSelection, + _document.visibleText, + ), ); } void setFullEditingValue(TextEditingValue value) { + lastFullSelectionBeforeEdit = fullSelection; _foldedRegions = _preserveFoldedRegionsForFullReplacement( _foldedRegions, _document.fullText, @@ -172,26 +184,41 @@ class BusyMarkSourceEditingController extends TextEditingController { ); _document = _createDocument(value.text, _foldedRegions); lastVisibleEdit = null; + final visibleSelection = _document.fullSelectionToVisibleSelection( + value.selection, + ); super.value = TextEditingValue( text: _document.visibleText, - selection: _document.fullSelectionToVisibleSelection(value.selection), + selection: _selectionWithLineEndAffinity( + visibleSelection, + _document.visibleText, + ), + composing: _fullComposingToVisibleRange(value.composing), ); onFullTextChanged?.call(_document.fullText, null); } void setFoldedRegions(Iterable regions) { final fullSelectionSnapshot = fullSelection; + final fullComposingSnapshot = _visibleComposingToFullRange( + super.value.composing, + ); final normalized = _normalizedFoldRegions(regions); if (_foldRegionsEqual(_foldedRegions, normalized)) { return; } _foldedRegions = normalized; _document = _createDocument(_document.fullText, _foldedRegions); + final visibleSelection = _document.fullSelectionToVisibleSelection( + fullSelectionSnapshot, + ); super.value = TextEditingValue( text: _document.visibleText, - selection: _document.fullSelectionToVisibleSelection( - fullSelectionSnapshot, + selection: _selectionWithLineEndAffinity( + visibleSelection, + _document.visibleText, ), + composing: _fullComposingToVisibleRange(fullComposingSnapshot), ); } @@ -200,13 +227,47 @@ class BusyMarkSourceEditingController extends TextEditingController { return; } final fullSelectionSnapshot = fullSelection; + final fullComposingSnapshot = _visibleComposingToFullRange( + super.value.composing, + ); _foldedRegions = const []; _document = _createDocument(_document.fullText, _foldedRegions); + final visibleSelection = _document.fullSelectionToVisibleSelection( + fullSelectionSnapshot, + ); super.value = TextEditingValue( text: _document.visibleText, - selection: _document.fullSelectionToVisibleSelection( - fullSelectionSnapshot, + selection: _selectionWithLineEndAffinity( + visibleSelection, + _document.visibleText, ), + composing: _fullComposingToVisibleRange(fullComposingSnapshot), + ); + } + + TextRange _visibleComposingToFullRange(TextRange range) { + if (!range.isValid) { + return TextRange.empty; + } + return TextRange( + start: _document.visibleOffsetToFullOffset( + range.start, + affinity: SourceHiddenAffinity.downstream, + ), + end: _document.visibleOffsetToFullOffset( + range.end, + affinity: SourceHiddenAffinity.upstream, + ), + ); + } + + TextRange _fullComposingToVisibleRange(TextRange range) { + if (!range.isValid) { + return TextRange.empty; + } + return TextRange( + start: _document.fullOffsetToVisibleOffset(range.start), + end: _document.fullOffsetToVisibleOffset(range.end), ); } @@ -222,10 +283,16 @@ class BusyMarkSourceEditingController extends TextEditingController { set value(TextEditingValue newValue) { final hasTextChanged = newValue.text != super.value.text; if (!hasTextChanged) { - super.value = newValue; + super.value = newValue.copyWith( + selection: _selectionWithLineEndAffinity( + newValue.selection, + newValue.text, + ), + ); return; } + lastFullSelectionBeforeEdit = fullSelection; final oldDocument = _document; final edit = oldDocument.describeVisibleEdit(newValue.text); final nextFullText = oldDocument.fullText.replaceRange( @@ -245,26 +312,156 @@ class BusyMarkSourceEditingController extends TextEditingController { edit, affectedKeys, ); - _document = _createDocument(nextFullText, _foldedRegions); + final hiddenRanges = _hiddenRangesFor(nextFullText, _foldedRegions); + _document = SourceDocument.afterVisibleEdit( + previous: oldDocument, + fullText: nextFullText, + hiddenRanges: hiddenRanges, + edit: edit, + ); lastVisibleEdit = edit; - final selection = TextSelection( - baseOffset: newValue.selection.baseOffset - .clamp(0, _document.visibleText.length) - .toInt(), - extentOffset: newValue.selection.extentOffset - .clamp(0, _document.visibleText.length) - .toInt(), - affinity: newValue.selection.affinity, - isDirectional: newValue.selection.isDirectional, + final selection = _projectIncomingSelection( + oldDocument: oldDocument, + nextDocument: _document, + edit: edit, + incomingValue: newValue, + ); + final composing = _projectIncomingComposingRange( + oldDocument: oldDocument, + nextDocument: _document, + edit: edit, + incomingValue: newValue, ); super.value = TextEditingValue( text: _document.visibleText, selection: selection, - composing: TextRange.empty, + composing: composing, ); onFullTextChanged?.call(_document.fullText, edit); } + TextSelection _projectIncomingSelection({ + required SourceDocument oldDocument, + required SourceDocument nextDocument, + required SourceVisibleEdit edit, + required TextEditingValue incomingValue, + }) { + final incoming = incomingValue.selection; + if (!incoming.isValid) { + return TextSelection.collapsed(offset: nextDocument.visibleText.length); + } + if (incomingValue.text == nextDocument.visibleText) { + return _selectionWithLineEndAffinity( + incoming.copyWith( + baseOffset: incoming.baseOffset + .clamp(0, nextDocument.visibleText.length) + .toInt(), + extentOffset: incoming.extentOffset + .clamp(0, nextDocument.visibleText.length) + .toInt(), + ), + nextDocument.visibleText, + ); + } + return _selectionWithLineEndAffinity( + incoming.copyWith( + baseOffset: _projectIncomingVisibleOffset( + oldDocument: oldDocument, + nextDocument: nextDocument, + edit: edit, + incomingOffset: incoming.baseOffset, + affinity: SourceHiddenAffinity.downstream, + ), + extentOffset: _projectIncomingVisibleOffset( + oldDocument: oldDocument, + nextDocument: nextDocument, + edit: edit, + incomingOffset: incoming.extentOffset, + affinity: SourceHiddenAffinity.upstream, + ), + ), + nextDocument.visibleText, + ); + } + + TextRange _projectIncomingComposingRange({ + required SourceDocument oldDocument, + required SourceDocument nextDocument, + required SourceVisibleEdit edit, + required TextEditingValue incomingValue, + }) { + final incoming = incomingValue.composing; + if (!incoming.isValid) { + return TextRange.empty; + } + if (incomingValue.text == nextDocument.visibleText) { + return TextRange( + start: incoming.start.clamp(0, nextDocument.visibleText.length).toInt(), + end: incoming.end.clamp(0, nextDocument.visibleText.length).toInt(), + ); + } + final start = _projectIncomingVisibleOffset( + oldDocument: oldDocument, + nextDocument: nextDocument, + edit: edit, + incomingOffset: incoming.start, + affinity: SourceHiddenAffinity.downstream, + ); + final end = _projectIncomingVisibleOffset( + oldDocument: oldDocument, + nextDocument: nextDocument, + edit: edit, + incomingOffset: incoming.end, + affinity: SourceHiddenAffinity.upstream, + ); + return TextRange(start: math.min(start, end), end: math.max(start, end)); + } + + int _projectIncomingVisibleOffset({ + required SourceDocument oldDocument, + required SourceDocument nextDocument, + required SourceVisibleEdit edit, + required int incomingOffset, + required SourceHiddenAffinity affinity, + }) { + final safeOffset = incomingOffset + .clamp( + 0, + edit.visibleStart + + edit.replacement.length + + (oldDocument.visibleText.length - edit.visibleEnd), + ) + .toInt(); + final replacementEnd = edit.visibleStart + edit.replacement.length; + late final int fullOffset; + if (safeOffset <= edit.visibleStart) { + fullOffset = safeOffset == edit.visibleStart + ? edit.fullStart + : oldDocument.visibleOffsetToFullOffset( + safeOffset, + affinity: affinity, + ); + } else if (safeOffset <= replacementEnd) { + fullOffset = edit.fullStart + safeOffset - edit.visibleStart; + } else { + final oldVisibleOffset = + safeOffset - + edit.replacement.length + + (edit.visibleEnd - edit.visibleStart); + final oldFullOffset = oldDocument.visibleOffsetToFullOffset( + oldVisibleOffset, + affinity: affinity, + ); + fullOffset = oldFullOffset >= edit.fullEnd + ? oldFullOffset + edit.fullDelta + : oldFullOffset; + } + return nextDocument + .fullOffsetToVisibleOffset(fullOffset) + .clamp(0, nextDocument.visibleText.length) + .toInt(); + } + @override TextSpan buildTextSpan({ required BuildContext context, @@ -356,17 +553,24 @@ class BusyMarkSourceEditingController extends TextEditingController { ) { return SourceDocument( fullText: fullText, - hiddenRanges: SourceHiddenRanges( - ranges: [ - for (final region in foldedRegions) - SourceHiddenRange( - start: region.hiddenStartOffset, - end: region.hiddenEndOffset, - key: region.key, - ), - ], - textLength: fullText.length, - ), + hiddenRanges: _hiddenRangesFor(fullText, foldedRegions), + ); + } + + SourceHiddenRanges _hiddenRangesFor( + String fullText, + Iterable foldedRegions, + ) { + return SourceHiddenRanges( + ranges: [ + for (final region in foldedRegions) + SourceHiddenRange( + start: region.hiddenStartOffset, + end: region.hiddenEndOffset, + key: region.key, + ), + ], + textLength: fullText.length, ); } @@ -500,6 +704,24 @@ class _HighlightRange { } } +TextSelection _selectionWithLineEndAffinity( + TextSelection selection, + String text, +) { + if (!selection.isValid || !selection.isCollapsed) { + return selection; + } + final offset = selection.extentOffset; + if (offset < 0 || offset >= text.length) { + return selection; + } + final nextUnit = text.codeUnitAt(offset); + if (nextUnit != 10 && nextUnit != 13) { + return selection; + } + return selection.copyWith(affinity: TextAffinity.upstream); +} + class _HiddenRange { const _HiddenRange(this.start, this.end, {this.preserveLayout = false}); @@ -1682,19 +1904,35 @@ List<_HighlightRange> _searchHighlightRanges( final matchStyle = baseStyle.copyWith(backgroundColor: matchBackground); final currentStyle = baseStyle.copyWith(backgroundColor: currentBackground); final currentIndex = result.currentMatchIndex; - for (final (index, match) in result.matches.indexed) { + void addMatch(int index, SourceSearchMatch match) { if (match.hidden || match.visibleEnd <= match.visibleStart) { - continue; + return; } + final isCurrent = result.firstMatchIndex + index == currentIndex; ranges.add( _HighlightRange( match.visibleStart.clamp(0, source.length).toInt(), match.visibleEnd.clamp(0, source.length).toInt(), - index == currentIndex ? currentStyle : matchStyle, - priority: index == currentIndex ? 100 : 90, + isCurrent ? currentStyle : matchStyle, + priority: isCurrent ? 100 : 90, ), ); } + + final highlightCount = math.min( + result.matches.length, + sourceInteractiveSearchMatchLimit, + ); + for (var index = 0; index < highlightCount; index++) { + addMatch(index, result.matches[index]); + } + final currentLocalIndex = currentIndex == null + ? -1 + : currentIndex - result.firstMatchIndex; + if (currentLocalIndex >= highlightCount && + currentLocalIndex < result.matches.length) { + addMatch(currentLocalIndex, result.matches[currentLocalIndex]); + } return ranges; } @@ -1705,16 +1943,6 @@ List _spansFromRanges( TextStyle baseStyle, { TextStyle Function(TextStyle style)? styleOverride, }) { - final sortedRanges = [...ranges] - ..sort((a, b) { - final start = a.start.compareTo(b.start); - if (start != 0) { - return start; - } - return a.priority.compareTo(b.priority); - }); - final sortedHiddenRanges = [...hiddenRanges] - ..sort((a, b) => a.start.compareTo(b.start)); final hiddenStyle = baseStyle.copyWith( color: BusyMarkLinuxPalette.transparent, fontSize: BusyMarkTypography.hiddenLayoutFontSize, @@ -1723,37 +1951,61 @@ List _spansFromRanges( wordSpacing: 0, ); final boundaries = {0, source.length}; - for (final range in sortedRanges) { - boundaries.add(range.start.clamp(0, source.length).toInt()); - boundaries.add(range.end.clamp(0, source.length).toInt()); - } - for (final range in sortedHiddenRanges) { - boundaries.add(range.start.clamp(0, source.length).toInt()); - boundaries.add(range.end.clamp(0, source.length).toInt()); + final highlightStarts = >{}; + final highlightEnds = >{}; + for (final range in ranges) { + final start = range.start.clamp(0, source.length).toInt(); + final end = range.end.clamp(start, source.length).toInt(); + if (end <= start) { + continue; + } + boundaries + ..add(start) + ..add(end); + (highlightStarts[start] ??= []).add(range); + (highlightEnds[end] ??= []).add(range); + } + final hiddenStarts = >{}; + final hiddenEnds = >{}; + for (final range in hiddenRanges) { + final start = range.start.clamp(0, source.length).toInt(); + final end = range.end.clamp(start, source.length).toInt(); + if (end <= start) { + continue; + } + boundaries + ..add(start) + ..add(end); + (hiddenStarts[start] ??= []).add(range); + (hiddenEnds[end] ??= []).add(range); } final sortedBoundaries = boundaries.toList()..sort(); final spans = []; + final activeHighlights = <_HighlightRange>[]; + final activeHiddenRanges = <_HiddenRange>[]; for (var index = 0; index < sortedBoundaries.length - 1; index++) { final start = sortedBoundaries[index]; final end = sortedBoundaries[index + 1]; if (end <= start) { continue; } - _HiddenRange? hiddenRange; - for (final range in sortedHiddenRanges) { - if (range.contains(start, end)) { - hiddenRange = range; - break; - } + for (final range in highlightEnds[start] ?? const <_HighlightRange>[]) { + activeHighlights.remove(range); } - final highlights = <_HighlightRange>[]; - for (final range in sortedRanges) { - if (range.start <= start && end <= range.end) { - highlights.add(range); - } + for (final range in hiddenEnds[start] ?? const <_HiddenRange>[]) { + activeHiddenRanges.remove(range); } - highlights.sort((a, b) => a.priority.compareTo(b.priority)); - final style = _mergeHighlightStyles(baseStyle, highlights); + activeHighlights.addAll( + highlightStarts[start] ?? const <_HighlightRange>[], + ); + activeHiddenRanges.addAll(hiddenStarts[start] ?? const <_HiddenRange>[]); + activeHighlights.sort((a, b) { + final priority = a.priority.compareTo(b.priority); + return priority != 0 ? priority : a.start.compareTo(b.start); + }); + activeHiddenRanges.sort((a, b) => a.start.compareTo(b.start)); + final hiddenRange = activeHiddenRanges.firstOrNull; + final style = _mergeHighlightStyles(baseStyle, activeHighlights); final visibleStyle = style == null ? baseStyle : styleOverride?.call(style) ?? style; diff --git a/lib/src/editor/wysiwyg/wysiwyg_block_widgets.dart b/lib/src/editor/wysiwyg/wysiwyg_block_widgets.dart index e34241e4..0c838097 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_block_widgets.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_block_widgets.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import '../../app/busymark_design.dart'; import '../../app/busymark_glyphs.dart'; @@ -153,6 +154,7 @@ class BusyMarkWysiwygBlockField extends StatelessWidget { required this.focusNode, required this.onChanged, required this.onTableCellChanged, + required this.onTableCellSourceChanged, required this.onTableRowInserted, required this.onTableRowDeleted, required this.onTableColumnInserted, @@ -170,6 +172,11 @@ class BusyMarkWysiwygBlockField extends StatelessWidget { this.onPointerDown, this.onPointerMove, this.onPointerUp, + this.tableCellController, + this.tableCellUndoController, + this.tableCellFocusNode, + this.tableCellKey, + this.onTableCellFocused, }); final BusyBlock block; @@ -187,6 +194,7 @@ class BusyMarkWysiwygBlockField extends StatelessWidget { final FocusNode focusNode; final ValueChanged onChanged; final void Function(String cellId, String text) onTableCellChanged; + final void Function(String cellId, String source) onTableCellSourceChanged; final void Function(int rowIndex, {required bool after}) onTableRowInserted; final ValueChanged onTableRowDeleted; final void Function(int columnIndex, {required bool after}) @@ -206,6 +214,11 @@ class BusyMarkWysiwygBlockField extends StatelessWidget { final ValueChanged? onPointerDown; final ValueChanged? onPointerMove; final ValueChanged? onPointerUp; + final TextEditingController Function(BusyBlock cell)? tableCellController; + final UndoHistoryController Function(BusyBlock cell)? tableCellUndoController; + final FocusNode Function(BusyBlock cell)? tableCellFocusNode; + final GlobalKey Function(String cellId)? tableCellKey; + final ValueChanged? onTableCellFocused; @override Widget build(BuildContext context) { @@ -356,6 +369,7 @@ class BusyMarkWysiwygBlockField extends StatelessWidget { block: block, onFocused: onFocused, onCellChanged: onTableCellChanged, + onCellSourceChanged: onTableCellSourceChanged, onRowInserted: onTableRowInserted, onRowDeleted: onTableRowDeleted, onColumnInserted: onTableColumnInserted, @@ -364,6 +378,11 @@ class BusyMarkWysiwygBlockField extends StatelessWidget { onTableDeleted: onTableDeleted, editRevision: editRevision, onMathDiagnostic: onMathDiagnostic, + cellController: tableCellController, + cellUndoController: tableCellUndoController, + cellFocusNode: tableCellFocusNode, + cellKey: tableCellKey, + onCellFocused: onTableCellFocused, ), ); } @@ -684,12 +703,14 @@ class _RenderedMathBlock extends StatelessWidget { required this.block, required this.editRevision, required this.style, + this.textAlign = TextAlign.start, this.onMathDiagnostic, }); final BusyBlock block; final int editRevision; final TextStyle style; + final TextAlign textAlign; final BusyMarkWysiwygMathDiagnosticCallback? onMathDiagnostic; @override @@ -717,6 +738,7 @@ class _RenderedMathBlock extends StatelessWidget { _span(inline, style, editRevision, 'i$index'), ], ), + textAlign: textAlign, ); } @@ -1415,6 +1437,7 @@ class _TableBlockEditor extends StatelessWidget { required this.block, required this.onFocused, required this.onCellChanged, + required this.onCellSourceChanged, required this.onRowInserted, required this.onRowDeleted, required this.onColumnInserted, @@ -1423,6 +1446,11 @@ class _TableBlockEditor extends StatelessWidget { required this.onTableDeleted, required this.editRevision, this.onMathDiagnostic, + this.cellController, + this.cellUndoController, + this.cellFocusNode, + this.cellKey, + this.onCellFocused, }); static const double _controlSize = BusyMarkSizes.tableControl; @@ -1430,6 +1458,7 @@ class _TableBlockEditor extends StatelessWidget { final BusyBlock block; final VoidCallback onFocused; final void Function(String cellId, String text) onCellChanged; + final void Function(String cellId, String source) onCellSourceChanged; final void Function(int rowIndex, {required bool after}) onRowInserted; final ValueChanged onRowDeleted; final void Function(int columnIndex, {required bool after}) onColumnInserted; @@ -1439,6 +1468,11 @@ class _TableBlockEditor extends StatelessWidget { final VoidCallback onTableDeleted; final int editRevision; final BusyMarkWysiwygMathDiagnosticCallback? onMathDiagnostic; + final TextEditingController Function(BusyBlock cell)? cellController; + final UndoHistoryController Function(BusyBlock cell)? cellUndoController; + final FocusNode Function(BusyBlock cell)? cellFocusNode; + final GlobalKey Function(String cellId)? cellKey; + final ValueChanged? onCellFocused; @override Widget build(BuildContext context) { @@ -1505,9 +1539,23 @@ class _TableBlockEditor extends StatelessWidget { style: busyMarkDocumentBodyTextStyle(context), onFocused: onFocused, onChanged: onCellChanged, + onSourceChanged: onCellSourceChanged, editRevision: editRevision, sourceSpan: block.sourceSpan, onMathDiagnostic: onMathDiagnostic, + controller: column < row.children.length + ? cellController?.call(row.children[column]) + : null, + undoController: column < row.children.length + ? cellUndoController?.call(row.children[column]) + : null, + focusNode: column < row.children.length + ? cellFocusNode?.call(row.children[column]) + : null, + cellKey: column < row.children.length + ? cellKey?.call(row.children[column].id) + : null, + onCellFocused: onCellFocused, ), ], ), @@ -1743,9 +1791,15 @@ class _TableCellEditor extends StatefulWidget { required this.style, required this.onFocused, required this.onChanged, + required this.onSourceChanged, required this.editRevision, this.sourceSpan, this.onMathDiagnostic, + this.controller, + this.undoController, + this.focusNode, + this.cellKey, + this.onCellFocused, }); final BusyBlock? cell; @@ -1753,9 +1807,15 @@ class _TableCellEditor extends StatefulWidget { final TextStyle style; final VoidCallback onFocused; final void Function(String cellId, String text) onChanged; + final void Function(String cellId, String source) onSourceChanged; final int editRevision; final SourceSpan? sourceSpan; final BusyMarkWysiwygMathDiagnosticCallback? onMathDiagnostic; + final TextEditingController? controller; + final UndoHistoryController? undoController; + final FocusNode? focusNode; + final GlobalKey? cellKey; + final ValueChanged? onCellFocused; @override State<_TableCellEditor> createState() => _TableCellEditorState(); @@ -1764,33 +1824,68 @@ class _TableCellEditor extends StatefulWidget { class _TableCellEditorState extends State<_TableCellEditor> { late final TextEditingController _controller; late final FocusNode _focusNode; - String? _cellId; + late bool _ownsController; + late bool _ownsFocusNode; bool _sourceEditing = false; @override void initState() { super.initState(); - _cellId = widget.cell?.id; - _controller = TextEditingController(text: _editableText(widget.cell)); - _focusNode = FocusNode(debugLabel: 'BusyMark table cell $_cellId'); - _focusNode.addListener(_handleFocusChanged); + _attachInputs(); } @override void didUpdateWidget(covariant _TableCellEditor oldWidget) { super.didUpdateWidget(oldWidget); - final cell = widget.cell; - if (cell?.id != _cellId) { - _cellId = cell?.id; - _controller.text = _editableText(cell); - return; + if (!identical(oldWidget.controller, widget.controller) || + !identical(oldWidget.focusNode, widget.focusNode) || + oldWidget.cell?.id != widget.cell?.id) { + _detachInputs(); + _attachInputs(); + } else { + _reconcileController(); } - final nextText = _editableText(cell); - if (!_focusNode.hasFocus && nextText != _controller.text) { - _controller.text = nextText; + } + + void _attachInputs() { + _ownsController = widget.controller == null; + _ownsFocusNode = widget.focusNode == null; + _controller = + widget.controller ?? + TextEditingController(text: _editableText(widget.cell)); + _focusNode = + widget.focusNode ?? + FocusNode(debugLabel: 'BusyMark table cell ${widget.cell?.id}'); + _focusNode.addListener(_handleFocusChanged); + _reconcileController(); + } + + void _detachInputs() { + _focusNode.removeListener(_handleFocusChanged); + if (_ownsController) { + _controller.dispose(); + } + if (_ownsFocusNode) { + _focusNode.dispose(); } } + void _reconcileController() { + final nextText = _editableText(widget.cell); + if (nextText == _controller.text) { + return; + } + final selection = _controller.selection; + _controller.value = _controller.value.copyWith( + text: nextText, + selection: TextSelection( + baseOffset: selection.baseOffset.clamp(0, nextText.length).toInt(), + extentOffset: selection.extentOffset.clamp(0, nextText.length).toInt(), + ), + composing: TextRange.empty, + ); + } + String _editableText(BusyBlock? cell) { if (cell == null) { return ''; @@ -1802,13 +1897,21 @@ class _TableCellEditorState extends State<_TableCellEditor> { @override void dispose() { - _focusNode.removeListener(_handleFocusChanged); - _controller.dispose(); - _focusNode.dispose(); + _detachInputs(); super.dispose(); } void _handleFocusChanged() { + if (_focusNode.hasFocus && widget.cell != null) { + final onCellFocused = widget.onCellFocused; + if (onCellFocused == null) { + widget.onFocused(); + } else { + onCellFocused(widget.cell!.id); + } + } else if (!_focusNode.hasFocus) { + _reconcileController(); + } if (mounted) { setState(() { if (!_focusNode.hasFocus) { @@ -1822,6 +1925,7 @@ class _TableCellEditorState extends State<_TableCellEditor> { Widget build(BuildContext context) { final colors = BusyMarkSurfaceColors.of(context); final cell = widget.cell; + final textAlign = _tableCellTextAlign(cell?.attributes['align']); final textStyle = widget.style.copyWith( fontWeight: widget.header ? FontWeight.w700 : FontWeight.w400, ); @@ -1849,42 +1953,107 @@ class _TableCellEditorState extends State<_TableCellEditor> { block: cell.copyWith(sourceSpan: widget.sourceSpan), editRevision: widget.editRevision, style: textStyle, + textAlign: textAlign, onMathDiagnostic: widget.onMathDiagnostic, ), ), ); } - return Padding( - padding: BusyMarkInsets.documentTableCell, - child: TextField( - key: ValueKey(cell.id), - controller: _controller, - focusNode: _focusNode, - minLines: 1, - maxLines: null, - style: textStyle, - selectionHeightStyle: BusyMarkDocumentTextGeometry.selectionHeightStyle, - selectionWidthStyle: BusyMarkDocumentTextGeometry.selectionWidthStyle, - decoration: InputDecoration( - isCollapsed: true, - border: InputBorder.none, - enabledBorder: InputBorder.none, - focusedBorder: InputBorder.none, - filled: false, - hoverColor: BusyMarkLinuxPalette.transparent, - hintText: widget.header - ? context.l10n.tableHeaderHint - : context.l10n.tableCellHint, - hintStyle: textStyle.copyWith(color: colors.mutedForeground), - contentPadding: EdgeInsets.zero, + return KeyedSubtree( + key: widget.cellKey, + child: Padding( + padding: BusyMarkInsets.documentTableCell, + child: TextField( + key: ValueKey(cell.id), + controller: _controller, + focusNode: _focusNode, + undoController: widget.undoController, + minLines: 1, + maxLines: 1, + inputFormatters: const [_SingleLineTableCellFormatter()], + style: textStyle, + textAlign: textAlign, + selectionHeightStyle: + BusyMarkDocumentTextGeometry.selectionHeightStyle, + selectionWidthStyle: BusyMarkDocumentTextGeometry.selectionWidthStyle, + decoration: InputDecoration( + isCollapsed: true, + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + filled: false, + hoverColor: BusyMarkLinuxPalette.transparent, + hintText: widget.header + ? context.l10n.tableHeaderHint + : context.l10n.tableCellHint, + hintStyle: textStyle.copyWith(color: colors.mutedForeground), + contentPadding: EdgeInsets.zero, + ), + onTap: () { + final onCellFocused = widget.onCellFocused; + if (onCellFocused == null) { + widget.onFocused(); + } else { + onCellFocused(cell.id); + } + }, + onChanged: (value) { + if (_sourceEditing) { + widget.onSourceChanged(cell.id, value); + } else { + widget.onChanged(cell.id, value); + } + }, ), - onTap: widget.onFocused, - onChanged: (value) => widget.onChanged(cell.id, value), ), ); } } +TextAlign _tableCellTextAlign(String? value) { + return switch (busyTableAlignmentFromAttribute(value)) { + BusyTableAlignment.unspecified => TextAlign.start, + BusyTableAlignment.left => TextAlign.left, + BusyTableAlignment.center => TextAlign.center, + BusyTableAlignment.right => TextAlign.right, + }; +} + +class _SingleLineTableCellFormatter extends TextInputFormatter { + const _SingleLineTableCellFormatter(); + + @override + TextEditingValue formatEditUpdate( + TextEditingValue oldValue, + TextEditingValue newValue, + ) { + final normalized = newValue.text.replaceAll(RegExp(r'\r\n|\r|\n'), ' '); + if (normalized == newValue.text) { + return newValue; + } + int normalizedOffset(int offset) { + if (offset < 0) { + return offset; + } + return newValue.text + .substring(0, offset.clamp(0, newValue.text.length).toInt()) + .replaceAll(RegExp(r'\r\n|\r|\n'), ' ') + .length; + } + + return newValue.copyWith( + text: normalized, + selection: TextSelection( + baseOffset: normalizedOffset(newValue.selection.baseOffset), + extentOffset: normalizedOffset(newValue.selection.extentOffset), + affinity: newValue.selection.affinity, + isDirectional: newValue.selection.isDirectional, + ), + composing: TextRange.empty, + ); + } +} + class _WysiwygSelectionPainter extends CustomPainter { const _WysiwygSelectionPainter({ required this.text, diff --git a/lib/src/editor/wysiwyg/wysiwyg_commands.dart b/lib/src/editor/wysiwyg/wysiwyg_commands.dart index 91c12dff..2edb3cf9 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_commands.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_commands.dart @@ -26,6 +26,97 @@ enum BusyWysiwygInlineCommand { link, } +/// Whether [block] can safely be replaced by a generic block command. +/// +/// Structured and source-preserved blocks need dedicated transformations. A +/// generic conversion only changes the block kind and inline metadata, so +/// applying it to one of those blocks would strand or discard its payload. +bool busyMarkWysiwygCanApplyBlockCommand( + BusyBlock block, + BusyWysiwygBlockCommand command, +) { + if (command == BusyWysiwygBlockCommand.image && + block.kind == BusyBlockKind.image && + !block.isSourceProtected) { + return true; + } + if (block.preserveRaw || + block.isSourceOnly || + block.isGenerated || + block.isSourceProtected) { + return false; + } + if (block.children.isNotEmpty) { + final destinationKind = blockKindForCommand(command); + if (!_hasSafeStructuredConversion(block, destinationKind)) { + return false; + } + } + return switch (block.kind) { + BusyBlockKind.paragraph || + BusyBlockKind.heading || + BusyBlockKind.codeBlock || + BusyBlockKind.unorderedListItem || + BusyBlockKind.orderedListItem || + BusyBlockKind.taskListItem || + BusyBlockKind.blockquote => true, + BusyBlockKind.math || + BusyBlockKind.thematicBreak || + BusyBlockKind.image || + BusyBlockKind.video || + BusyBlockKind.table || + BusyBlockKind.htmlBlock || + BusyBlockKind.writersideAdmonition || + BusyBlockKind.writersideTabs || + BusyBlockKind.writersideProcedure || + BusyBlockKind.writersideRawXml || + BusyBlockKind.frontMatter || + BusyBlockKind.unknown => false, + }; +} + +bool busyMarkWysiwygCanApplyAdmonitionStyle(BusyBlock block) { + if (block.kind == BusyBlockKind.writersideAdmonition && + !block.preserveRaw && + !block.isSourceOnly && + !block.isGenerated && + !block.isSourceProtected) { + return true; + } + return busyMarkWysiwygCanApplyBlockCommand( + block, + BusyWysiwygBlockCommand.blockquote, + ); +} + +bool _hasSafeStructuredConversion( + BusyBlock block, + BusyBlockKind destinationKind, +) { + final sourceIsList = _isListItemKind(block.kind); + final destinationIsList = _isListItemKind(destinationKind); + if (sourceIsList) { + // List-family conversions share the same inline/children representation. + // Converting to a quote is handled by an explicit structural transform. + return destinationIsList || destinationKind == BusyBlockKind.blockquote; + } + if (block.kind == BusyBlockKind.blockquote) { + // The reverse list conversion is also structural: its leading paragraph + // becomes the list item's own inline content. + return destinationKind == BusyBlockKind.blockquote || destinationIsList; + } + return false; +} + +bool _isListItemKind(BusyBlockKind kind) { + return switch (kind) { + BusyBlockKind.unorderedListItem || + BusyBlockKind.orderedListItem || + BusyBlockKind.taskListItem => true, + _ => false, + }; +} + BusyBlockKind blockKindForCommand(BusyWysiwygBlockCommand command) { return switch (command) { BusyWysiwygBlockCommand.paragraph => BusyBlockKind.paragraph, diff --git a/lib/src/editor/wysiwyg/wysiwyg_document_controller.dart b/lib/src/editor/wysiwyg/wysiwyg_document_controller.dart index 4281021a..0cd3d6d1 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_document_controller.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_document_controller.dart @@ -11,6 +11,47 @@ import '../../markdown/raw_html_adapter.dart'; import 'wysiwyg_commands.dart'; import 'wysiwyg_inline_controller.dart'; +/// Markdown table cells are represented by one source line. Normalize all +/// platform newline forms at the model boundary so programmatic callers cannot +/// create content that the table serializer cannot faithfully represent. +String busyMarkNormalizeTableCellText(String text) { + return text.replaceAll(RegExp(r'\r\n|\r|\n'), ' '); +} + +BusyBlock busyMarkWysiwygImmutableBlockSnapshot(BusyBlock block) { + BusyInline snapshotInline(BusyInline inline) { + return BusyInline( + kind: inline.kind, + text: inline.text, + destination: inline.destination, + children: List.unmodifiable([ + for (final child in inline.children) snapshotInline(child), + ]), + attributes: Map.unmodifiable(inline.attributes), + ); + } + + return BusyBlock( + id: block.id, + kind: block.kind, + inlines: List.unmodifiable([ + for (final inline in block.inlines) snapshotInline(inline), + ]), + children: List.unmodifiable([ + for (final child in block.children) + busyMarkWysiwygImmutableBlockSnapshot(child), + ]), + attributes: Map.unmodifiable(block.attributes), + rawSource: block.rawSource, + sourceSpan: block.sourceSpan, + preserveRaw: block.preserveRaw, + isSourceOnly: block.isSourceOnly, + isGenerated: block.isGenerated, + isSourceProtected: block.isSourceProtected, + dirty: block.dirty, + ); +} + class BusyMarkWysiwygDocumentController extends ChangeNotifier { BusyMarkWysiwygDocumentController({ required BusyDocument document, @@ -405,6 +446,34 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { } void updateTableCellText(String tableBlockId, String cellId, String text) { + _updateTableCellText( + tableBlockId, + cellId, + text, + parseMarkdownSource: false, + ); + } + + void updateTableCellMarkdownSource( + String tableBlockId, + String cellId, + String source, + ) { + _updateTableCellText( + tableBlockId, + cellId, + source, + parseMarkdownSource: true, + ); + } + + void _updateTableCellText( + String tableBlockId, + String cellId, + String text, { + required bool parseMarkdownSource, + }) { + final acceptedText = busyMarkNormalizeTableCellText(text); var changed = false; _document = _document.copyWith( blocks: _replaceInBlocks(_document.blocks, tableBlockId, (block) { @@ -417,7 +486,11 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { final cells = []; for (final cell in row.children) { if (cell.id == cellId) { - cells.add(_tableCellWithEditedSource(cell, text)); + cells.add( + parseMarkdownSource + ? _tableCellWithEditedSource(cell, acceptedText) + : _blockWithEditedText(cell, acceptedText), + ); rowChanged = true; changed = true; } else { @@ -440,23 +513,12 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { } BusyBlock _tableCellWithEditedSource(BusyBlock cell, String source) { - final parsed = const MarkdownParser().parse( - filePath: _document.filePath, - source: '$source\n', + final inlines = const MarkdownParser().parseInlineFragment( + source: source, mode: _document.mode, - validateLocalReferences: false, ); - final parsedBlock = parsed.busyDocument.blocks - .where( - (block) => - block.kind != BusyBlockKind.frontMatter && !block.isSourceOnly, - ) - .firstOrNull; - final inlines = parsedBlock?.inlines; return cell.copyWith( - inlines: inlines == null || inlines.isEmpty - ? _textInlines(source) - : inlines, + inlines: inlines.isEmpty ? _textInlines(source) : inlines, preserveRaw: false, dirty: true, ); @@ -467,40 +529,58 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { String text, int cursorOffset, ) { - final normalizedText = text.replaceAll('\r\n', '\n').replaceAll('\r', '\n'); - if (!normalizedText.contains('\n')) { - return null; - } final block = blockById(blockId); if (block == null || !_shouldSplitNewlines(block.kind)) { return null; } + final normalizedText = _normalizeLineEndings(text); + final previousText = _normalizeLineEndings(block.plainText); + final editStart = _commonPrefixLength(previousText, normalizedText); + final editSuffix = _commonSuffixLength( + previousText, + normalizedText, + editStart, + ); + final editEnd = normalizedText.length - editSuffix; + final paragraphBreaks = [ + for (final match in '\n'.allMatches(normalizedText)) + if (match.start >= editStart && match.start < editEnd) match, + ]; + if (paragraphBreaks.isEmpty) { + return null; + } if (_isListItemKind(block.kind) && normalizedText.trim().isEmpty) { _replaceBlockWithParagraph(blockId); return BusyWysiwygTextSplitResult(blockId: blockId, offset: 0); } - final parts = normalizedText.split('\n'); - final safeOffset = cursorOffset.clamp(0, normalizedText.length).toInt(); - final textBeforeCursor = normalizedText.substring(0, safeOffset); - final focusIndex = '\n' - .allMatches(textBeforeCursor) + final rawOffset = cursorOffset.clamp(0, text.length).toInt(); + final safeOffset = _normalizeLineEndings( + text.substring(0, rawOffset), + ).length; + final focusIndex = paragraphBreaks + .where((match) => match.start < safeOffset) .length - .clamp(0, parts.length - 1) + .clamp(0, paragraphBreaks.length) .toInt(); - final lastLineStart = textBeforeCursor.lastIndexOf('\n') + 1; - final focusOffset = safeOffset - lastLineStart; + final focusStart = focusIndex == 0 + ? 0 + : paragraphBreaks[focusIndex - 1].end; + final focusOffset = safeOffset - focusStart; final ranges = _remapRangesForTextEdit( oldText: block.plainText, newText: normalizedText, ranges: busyInlineStyleRanges(block.inlines), ); final replacements = []; - var lineStart = 0; - for (final (index, part) in parts.indexed) { - final lineEnd = lineStart + part.length; - final inlines = _inlinesFromStyleRanges( + var partStart = 0; + for (var index = 0; index <= paragraphBreaks.length; index++) { + final partEnd = index < paragraphBreaks.length + ? paragraphBreaks[index].start + : normalizedText.length; + final part = normalizedText.substring(partStart, partEnd); + final inlines = _inlinesFromStyleRangesWithHardBreaks( part, - _styleRangesForSlice(ranges, lineStart, lineEnd), + _styleRangesForSlice(ranges, partStart, partEnd), ); if (index == 0) { replacements.add( @@ -527,7 +607,9 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { ), ); } - lineStart = lineEnd + 1; + if (index < paragraphBreaks.length) { + partStart = paragraphBreaks[index].end; + } } _document = _document.copyWith( blocks: _replaceBlockWithMany(_document.blocks, blockId, replacements), @@ -542,6 +624,10 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { } void applyBlockCommand(String blockId, BusyWysiwygBlockCommand command) { + final block = blockById(blockId); + if (block == null || !busyMarkWysiwygCanApplyBlockCommand(block, command)) { + return; + } if (command == BusyWysiwygBlockCommand.thematicBreak) { insertThematicBreakAfter(blockId); return; @@ -549,7 +635,11 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { final blocks = _replaceInBlocks( _document.blocks, blockId, - (block) => _blockWithCommand(block, command), + (block) => _blockWithCommand( + block, + command, + nextId: () => _nextGeneratedBlockId('paragraph'), + ), ); _document = _document.copyWith( blocks: command == BusyWysiwygBlockCommand.orderedList @@ -567,13 +657,25 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { if (ids.isEmpty) { return; } + final eligibleIds = ids.where((id) { + final block = blockById(id); + return block != null && + busyMarkWysiwygCanApplyBlockCommand(block, command); + }).toList(); + if (eligibleIds.isEmpty) { + return; + } if (command == BusyWysiwygBlockCommand.thematicBreak) { - insertThematicBreakAfter(ids.last); + insertThematicBreakAfter(eligibleIds.last); return; } - final idSet = ids.toSet(); + final idSet = eligibleIds.toSet(); final replaced = _replaceBlocksByIds(_document.blocks, idSet, (block) { - return _blockWithCommand(block, command); + return _blockWithCommand( + block, + command, + nextId: () => _nextGeneratedBlockId('paragraph'), + ); }); _document = _document.copyWith( blocks: command == BusyWysiwygBlockCommand.orderedList @@ -584,11 +686,19 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { } void applyAdmonitionStyle(String blockId, BusyAdmonitionStyle style) { + final block = blockById(blockId); + if (block == null || !busyMarkWysiwygCanApplyAdmonitionStyle(block)) { + return; + } _document = _document.copyWith( blocks: _replaceInBlocks( _document.blocks, blockId, - (block) => _blockWithAdmonitionStyle(block, style), + (block) => _blockWithAdmonitionStyle( + block, + style, + nextId: () => _nextGeneratedBlockId('paragraph'), + ), ), ); notifyListeners(); @@ -598,7 +708,12 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { Iterable blockIds, BusyAdmonitionStyle style, ) { - final ids = blockIds.toSet(); + final ids = { + for (final id in blockIds) + if (blockById(id) case final block? + when busyMarkWysiwygCanApplyAdmonitionStyle(block)) + id, + }; if (ids.isEmpty) { return; } @@ -606,7 +721,11 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { blocks: _replaceBlocksByIds( _document.blocks, ids, - (block) => _blockWithAdmonitionStyle(block, style), + (block) => _blockWithAdmonitionStyle( + block, + style, + nextId: () => _nextGeneratedBlockId('paragraph'), + ), ), ); notifyListeners(); @@ -617,6 +736,14 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { required String source, required String alt, }) { + final block = blockById(blockId); + if (block == null || + !busyMarkWysiwygCanApplyBlockCommand( + block, + BusyWysiwygBlockCommand.image, + )) { + return; + } final trimmedSource = source.trim(); final trimmedAlt = alt.trim(); if (trimmedSource.isEmpty) { @@ -1312,16 +1439,19 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { final safeOffset = offset.clamp(0, text.length).toInt(); final leftText = text.substring(0, safeOffset); final rightText = text.substring(safeOffset); - final ranges = busyInlineStyleRanges(block.inlines); + final inlinePartition = _partitionInlinesForReplacement( + block.inlines, + safeOffset, + safeOffset, + ); final nextBlockId = _nextGeneratedBlockId(_newBlockPrefixFor(block.kind)); final nextKind = _splitKindFor(block.kind); final currentBlock = BusyBlock( id: block.id, kind: block.kind, - inlines: _inlinesFromStyleRanges( - leftText, - _styleRangesForSlice(ranges, 0, safeOffset), - ), + inlines: inlinePartition.before.isEmpty + ? _textInlines('') + : inlinePartition.before, children: block.children, attributes: _attributesForText(block.attributes, block.kind, leftText), dirty: true, @@ -1329,10 +1459,9 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { final nextBlock = BusyBlock( id: nextBlockId, kind: nextKind, - inlines: _inlinesFromStyleRanges( - rightText, - _styleRangesForSlice(ranges, safeOffset, text.length), - ), + inlines: inlinePartition.after.isEmpty + ? _textInlines('') + : inlinePartition.after, attributes: _attributesForText( _splitAttributesFor(block, nextKind, orderedOffset: 1), nextKind, @@ -1377,10 +1506,21 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { if (!_isMergeableTextBlock(previous) || !_isMergeableTextBlock(current)) { return null; } + if (!_canMergeBlockStructures(previous, current)) { + return null; + } final previousText = previous.plainText; final currentText = current.plainText; + final mergedChildren = [...previous.children, ...current.children]; + final mergedInlines = _mergeAdjacentInlineStyles([ + ...previous.inlines, + ...current.inlines, + ]); if (currentText.isEmpty) { - final updatedPrevious = _withoutSourceSpan(previous, dirty: true); + final updatedPrevious = _withoutSourceSpan( + previous.copyWith(inlines: mergedInlines, children: mergedChildren), + dirty: true, + ); _document = _document.copyWith( blocks: [ ...blocks.take(index - 1), @@ -1394,25 +1534,11 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { offset: previousText.length, ); } - final mergedText = '$previousText$currentText'; - final previousRanges = busyInlineStyleRanges(previous.inlines); - final currentRanges = [ - for (final range in busyInlineStyleRanges(current.inlines)) - BusyInlineStyleRange( - start: range.start + previousText.length, - end: range.end + previousText.length, - kind: range.kind, - destination: range.destination, - ), - ]; final merged = BusyBlock( id: previous.id, kind: previous.kind, - inlines: _inlinesFromStyleRanges(mergedText, [ - ...previousRanges, - ...currentRanges, - ]), - children: previous.children, + inlines: mergedInlines, + children: mergedChildren, attributes: previous.attributes, dirty: true, ); @@ -1438,15 +1564,40 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { if (firstBlock == null || lastBlock == null) { return null; } - if (_isReadOnlySelectionEndpoint(firstBlock) || - _isReadOnlySelectionEndpoint(lastBlock)) { - return null; - } final firstText = firstBlock.plainText; final lastText = lastBlock.plainText; final firstStart = firstStartOffset.clamp(0, firstText.length).toInt(); final lastEnd = lastEndOffset.clamp(0, lastText.length).toInt(); + final removedIds = removedBlockIds.toSet(); + final firstReadOnly = _isReadOnlySelectionEndpoint(firstBlock); + final lastReadOnly = _isReadOnlySelectionEndpoint(lastBlock); if (firstBlockId == lastBlockId) { + if (firstReadOnly && + firstStart == 0 && + lastEnd == firstText.length && + removedIds.contains(firstBlockId) && + !firstBlock.isSourceProtected) { + _document = _document.copyWith( + blocks: _replaceInBlocks( + _document.blocks, + firstBlockId, + (block) => BusyBlock( + id: block.id, + kind: BusyBlockKind.paragraph, + inlines: _textInlines(''), + attributes: const { + busyMarkPreserveEmptyParagraphAttribute: 'true', + }, + dirty: true, + ), + ), + ); + notifyListeners(); + return BusyWysiwygTextSplitResult(blockId: firstBlockId, offset: 0); + } + if (firstReadOnly) { + return null; + } if (lastEnd <= firstStart) { return null; } @@ -1466,15 +1617,42 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { ); } + final completeFirstReadOnly = + firstReadOnly && + firstStart == 0 && + removedIds.contains(firstBlockId) && + !firstBlock.isSourceProtected; + final completeLastReadOnly = + lastReadOnly && + lastEnd == lastText.length && + removedIds.contains(lastBlockId) && + !lastBlock.isSourceProtected; + if ((firstReadOnly && !completeFirstReadOnly) || + (lastReadOnly && !completeLastReadOnly)) { + return null; + } + final mergedText = firstText.substring(0, firstStart) + lastText.substring(lastEnd); - final removeIds = removedBlockIds.where((id) => id != firstBlockId).toSet(); + final removeIds = removedIds.where((id) => id != firstBlockId).toSet(); _document = _document.copyWith( blocks: _removeBlocksByIds( _replaceInBlocks( _document.blocks, firstBlockId, - (block) => _blockWithEditedText(block, mergedText), + (block) => completeFirstReadOnly + ? BusyBlock( + id: block.id, + kind: BusyBlockKind.paragraph, + inlines: _textInlines(mergedText), + attributes: _attributesForText( + const {}, + BusyBlockKind.paragraph, + mergedText, + ), + dirty: true, + ) + : _blockWithEditedText(block, mergedText), ), removeIds, ), @@ -1508,6 +1686,18 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { final beforeRanges = _styleRangesForSlice(oldRanges, 0, start); final afterRanges = _styleRangesForSlice(oldRanges, end, text.length); + if (blocks.any(_requiresCompleteBlockInsertion)) { + return _insertCompleteBlocksAtSelection( + block: block, + blockId: blockId, + beforeText: beforeText, + afterText: afterText, + beforeRanges: beforeRanges, + afterRanges: afterRanges, + blocks: blocks, + ); + } + final replacements = []; if (blocks.length == 1) { final inserted = blocks.single; @@ -1584,6 +1774,103 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { ); } + BusyWysiwygTextSplitResult _insertCompleteBlocksAtSelection({ + required BusyBlock block, + required String blockId, + required String beforeText, + required String afterText, + required List beforeRanges, + required List afterRanges, + required List blocks, + }) { + final replacements = []; + var originalIdAvailable = true; + if (beforeText.isNotEmpty) { + replacements.add( + block.copyWith( + inlines: _inlinesFromStyleRanges(beforeText, beforeRanges), + attributes: _attributesForText( + block.attributes, + block.kind, + beforeText, + ), + preserveRaw: false, + dirty: true, + ), + ); + originalIdAvailable = false; + } + for (final styled in blocks) { + replacements.add( + _styledBlockToBusyBlock( + styled, + rootId: originalIdAvailable ? block.id : null, + ), + ); + originalIdAvailable = false; + } + late final BusyBlock focusBlock; + if (afterText.isNotEmpty) { + focusBlock = BusyBlock( + id: originalIdAvailable ? block.id : _nextGeneratedBlockId('paragraph'), + kind: BusyBlockKind.paragraph, + inlines: _inlinesFromStyleRanges(afterText, afterRanges), + dirty: true, + ); + replacements.add(focusBlock); + } else { + focusBlock = BusyBlock( + id: _nextGeneratedBlockId('paragraph'), + kind: BusyBlockKind.paragraph, + inlines: _textInlines(''), + attributes: const {busyMarkPreserveEmptyParagraphAttribute: 'true'}, + dirty: true, + ); + replacements.add(focusBlock); + } + _document = _document.copyWith( + blocks: _replaceBlockWithMany(_document.blocks, blockId, replacements), + ); + notifyListeners(); + return BusyWysiwygTextSplitResult(blockId: focusBlock.id, offset: 0); + } + + BusyWysiwygTextSplitResult? replaceTextSelectionWithStyledBlocks({ + required String firstBlockId, + required int firstStartOffset, + required String lastBlockId, + required int lastEndOffset, + required Iterable removedBlockIds, + required List blocks, + }) { + final staged = BusyMarkWysiwygDocumentController(document: _document); + final deletion = staged.deleteTextSelection( + firstBlockId: firstBlockId, + firstStartOffset: firstStartOffset, + lastBlockId: lastBlockId, + lastEndOffset: lastEndOffset, + removedBlockIds: removedBlockIds, + ); + if (deletion == null) { + staged.dispose(); + return null; + } + final result = staged.insertStyledBlocksAtSelection( + blockId: deletion.blockId, + selectionStart: deletion.offset, + selectionEnd: deletion.offset, + blocks: blocks, + ); + if (result == null) { + staged.dispose(); + return null; + } + _document = staged.document; + staged.dispose(); + notifyListeners(); + return result; + } + String? insertThematicBreakAfter(String blockId) { if (blockById(blockId) == null) { return null; @@ -1850,11 +2137,16 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { BusyWysiwygStyledBlock styled, { String? text, List? ranges, + String? rootId, }) { + final completeBlock = styled.completeBlock; + if (completeBlock != null && _requiresCompleteBlockInsertion(styled)) { + return _cloneClipboardBlock(completeBlock, rootId: rootId); + } final kind = styled.kind; final blockText = text ?? styled.text; return BusyBlock( - id: _nextGeneratedBlockId(_newBlockPrefixFor(kind)), + id: rootId ?? _nextGeneratedBlockId(_newBlockPrefixFor(kind)), kind: kind, inlines: _inlinesFromStyleRanges(blockText, ranges ?? styled.ranges), attributes: _attributesForText(styled.attributes, kind, blockText), @@ -1862,6 +2154,24 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { dirty: true, ); } + + BusyBlock _cloneClipboardBlock(BusyBlock block, {String? rootId}) { + return BusyBlock( + id: rootId ?? _nextGeneratedBlockId(_newBlockPrefixFor(block.kind)), + kind: block.kind, + inlines: List.unmodifiable(block.inlines), + children: List.unmodifiable([ + for (final child in block.children) _cloneClipboardBlock(child), + ]), + attributes: Map.unmodifiable(block.attributes), + rawSource: block.rawSource, + preserveRaw: block.preserveRaw, + isSourceOnly: block.isSourceOnly, + isGenerated: block.isGenerated, + isSourceProtected: block.isSourceProtected, + dirty: true, + ); + } } class BusyWysiwygTextSplitResult { @@ -1880,12 +2190,23 @@ class BusyWysiwygStyledBlock { required this.text, required this.ranges, this.attributes = const {}, + this.completeBlock, }); final BusyBlockKind kind; final String text; final List ranges; final Map attributes; + final BusyBlock? completeBlock; +} + +bool _requiresCompleteBlockInsertion(BusyWysiwygStyledBlock styled) { + final block = styled.completeBlock; + return block != null && + !busyMarkWysiwygCanApplyBlockCommand( + block, + BusyWysiwygBlockCommand.paragraph, + ); } class _OutdentResult { @@ -2371,6 +2692,17 @@ bool _isMergeableTextBlock(BusyBlock block) { }; } +bool _canMergeBlockStructures(BusyBlock previous, BusyBlock current) { + if (previous.children.isNotEmpty && !_isListItemKind(previous.kind)) { + return false; + } + if (current.children.isNotEmpty && + (!_isListItemKind(previous.kind) || !_isListItemKind(current.kind))) { + return false; + } + return true; +} + BusyBlock _blockWithEditedText( BusyBlock block, String nextText, { @@ -2390,14 +2722,55 @@ BusyBlock _blockWithEditedText( ); } } + final rebuiltInlines = _inlinesFromStyleRangesWithHardBreaks( + nextText, + nextRanges, + ); return block.copyWith( - inlines: _inlinesFromStyleRanges(nextText, nextRanges), + inlines: _restoreSemanticInlineAnchors( + originalInlines: block.inlines, + oldText: oldText, + newText: nextText, + rebuiltInlines: rebuiltInlines, + ), attributes: _attributesForText(block.attributes, block.kind, nextText), preserveRaw: false, dirty: true, ); } +String _normalizeLineEndings(String text) { + return text.replaceAll('\r\n', '\n').replaceAll('\r', '\n'); +} + +List _restoreSemanticInlineAnchors({ + required List originalInlines, + required String oldText, + required String newText, + required List rebuiltInlines, +}) { + final anchors = busyInlineSemanticAnchors(originalInlines); + if (anchors.isEmpty) { + return rebuiltInlines; + } + final prefix = _commonPrefixLength(oldText, newText); + final suffix = _commonSuffixLength(oldText, newText, prefix); + final oldEditEnd = oldText.length - suffix; + final newEditEnd = newText.length - suffix; + final delta = newText.length - oldText.length; + var result = rebuiltInlines; + for (final anchor in anchors) { + final offset = switch (anchor.offset) { + final value when value <= prefix => value, + final value when value >= oldEditEnd => value + delta, + _ => newEditEnd, + }.clamp(0, newText.length).toInt(); + final partition = _partitionInlinesForReplacement(result, offset, offset); + result = [...partition.before, anchor.inline, ...partition.after]; + } + return result; +} + BusyBlock _withoutSourceSpan(BusyBlock block, {required bool dirty}) { return BusyBlock( id: block.id, @@ -2414,8 +2787,13 @@ BusyBlock _withoutSourceSpan(BusyBlock block, {required bool dirty}) { ); } -BusyBlock _blockWithCommand(BusyBlock block, BusyWysiwygBlockCommand command) { +BusyBlock _blockWithCommand( + BusyBlock block, + BusyWysiwygBlockCommand command, { + required String Function() nextId, +}) { final kind = blockKindForCommand(command); + final structured = _blockWithStructureForKind(block, kind, nextId); final attributes = {...block.attributes} ..remove('ordered') ..remove('marker') @@ -2463,14 +2841,18 @@ BusyBlock _blockWithCommand(BusyBlock block, BusyWysiwygBlockCommand command) { attributes['marker'] = '-'; attributes['task'] = block.attributes['task'] ?? 'false'; } - return block.copyWith(kind: kind, attributes: attributes, dirty: true); + return structured.copyWith(kind: kind, attributes: attributes, dirty: true); } BusyBlock _blockWithAdmonitionStyle( BusyBlock block, - BusyAdmonitionStyle style, -) { + BusyAdmonitionStyle style, { + required String Function() nextId, +}) { final semanticElement = block.kind == BusyBlockKind.writersideAdmonition; + final structured = semanticElement + ? block + : _blockWithStructureForKind(block, BusyBlockKind.blockquote, nextId); final attributes = {...block.attributes} ..remove('ordered') ..remove('marker') @@ -2489,7 +2871,7 @@ BusyBlock _blockWithAdmonitionStyle( } else { attributes.remove('element'); } - return block.copyWith( + return structured.copyWith( kind: semanticElement ? BusyBlockKind.writersideAdmonition : BusyBlockKind.blockquote, @@ -2499,6 +2881,48 @@ BusyBlock _blockWithAdmonitionStyle( ); } +BusyBlock _blockWithStructureForKind( + BusyBlock block, + BusyBlockKind destinationKind, + String Function() nextId, +) { + if (_isListItemKind(block.kind) && + destinationKind == BusyBlockKind.blockquote && + block.children.isNotEmpty) { + return block.copyWith( + inlines: const [], + children: [ + if (block.inlines.isNotEmpty) + BusyBlock( + id: nextId(), + kind: BusyBlockKind.paragraph, + inlines: block.inlines, + dirty: true, + ), + ...block.children, + ], + ); + } + if (block.kind == BusyBlockKind.blockquote && + _isListItemKind(destinationKind) && + block.children.isNotEmpty) { + final leading = block.children.first; + if (block.inlines.isEmpty && + leading.kind == BusyBlockKind.paragraph && + leading.children.isEmpty && + !leading.preserveRaw && + !leading.isSourceOnly && + !leading.isGenerated && + !leading.isSourceProtected) { + return block.copyWith( + inlines: leading.inlines, + children: block.children.skip(1).toList(growable: false), + ); + } + } + return block; +} + BusyBlock _numberIndentedListItem( BusyBlock block, List existingSiblings, @@ -2628,8 +3052,14 @@ BusyBlock _blockWithInlineCommand( : null, ), ]; + final rebuiltInlines = _inlinesFromStyleRangesWithHardBreaks(text, ranges); return block.copyWith( - inlines: _inlinesFromStyleRanges(text, ranges), + inlines: _restoreSemanticInlineAnchors( + originalInlines: block.inlines, + oldText: text, + newText: text, + rebuiltInlines: rebuiltInlines, + ), dirty: true, ); } diff --git a/lib/src/editor/wysiwyg/wysiwyg_editor.dart b/lib/src/editor/wysiwyg/wysiwyg_editor.dart index a75169ae..0dc9407f 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_editor.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_editor.dart @@ -45,6 +45,18 @@ typedef BusyMarkWysiwygSourceChanged = void Function(String filePath, String source); typedef BusyMarkWysiwygSessionChanged = void Function(String documentId, WysiwygEditorSessionState state); +typedef BusyMarkWysiwygTransactionalSourceChanged = + void Function(String filePath, String source, String? undoGroup); + +class BusyMarkWysiwygSourceRange { + const BusyMarkWysiwygSourceRange({ + required this.startOffset, + required this.endOffset, + }); + + final int startOffset; + final int endOffset; +} class BusyMarkWysiwygEditor extends StatefulWidget { const BusyMarkWysiwygEditor({ @@ -54,6 +66,7 @@ class BusyMarkWysiwygEditor extends StatefulWidget { this.documentId, this.initialSessionState = const WysiwygEditorSessionState(), this.onSessionChanged, + this.onTransactionalSourceChanged, this.useExternalUndoHistory = false, this.onDocumentChanged, this.workspaceRoot, @@ -72,6 +85,7 @@ class BusyMarkWysiwygEditor extends StatefulWidget { this.scrollToHeadingId, this.scrollToBlockId, this.scrollToSearchQuery, + this.scrollToSourceRange, this.scrollRequest = 0, this.onVisibleHeadingChanged, this.onOpenSearch, @@ -90,6 +104,7 @@ class BusyMarkWysiwygEditor extends StatefulWidget { final String? documentId; final WysiwygEditorSessionState initialSessionState; final BusyMarkWysiwygSessionChanged? onSessionChanged; + final BusyMarkWysiwygTransactionalSourceChanged? onTransactionalSourceChanged; final bool useExternalUndoHistory; final ValueChanged? onDocumentChanged; final String? workspaceRoot; @@ -108,6 +123,7 @@ class BusyMarkWysiwygEditor extends StatefulWidget { final String? scrollToHeadingId; final String? scrollToBlockId; final String? scrollToSearchQuery; + final BusyMarkWysiwygSourceRange? scrollToSourceRange; final int scrollRequest; final ValueChanged? onVisibleHeadingChanged; final VoidCallback? onOpenSearch; @@ -131,6 +147,9 @@ class _BusyMarkWysiwygEditorState extends State { final _textControllers = {}; final _textUndoControllers = {}; final _focusNodes = {}; + final _tableCellControllers = {}; + final _tableCellFocusNodes = {}; + final _tableCellKeys = {}; StreamSubscription>? _assetDropSubscription; final _blockKeys = {}; final _undoStack = []; @@ -144,6 +163,10 @@ class _BusyMarkWysiwygEditorState extends State { bool _hasReportedVisibleHeading = false; late final FocusNode _selectionFocusNode; String? _activeBlockId; + String? _activeCellId; + final _collapsibleExpansion = {}; + int _undoGroupSequence = 0; + _ContinuousTextEdit? _continuousTextEdit; _DocumentTextSelection? _documentSelection; _DocumentTextPosition? _pointerSelectionAnchor; VerticalCaretMovementRun? _verticalCaretMovement; @@ -161,6 +184,12 @@ class _BusyMarkWysiwygEditorState extends State { String get _documentId => widget.documentId ?? widget.document.filePath; + @visibleForTesting + int get debugUndoControllerCount => _textUndoControllers.length; + + @visibleForTesting + int get debugUndoSnapshotCount => _undoStack.length; + @override void initState() { super.initState(); @@ -230,12 +259,18 @@ class _BusyMarkWysiwygEditorState extends State { for (final controller in _textControllers.values) { controller.dispose(); } + for (final controller in _tableCellControllers.values) { + controller.dispose(); + } for (final controller in _textUndoControllers.values) { controller.dispose(); } for (final focusNode in _focusNodes.values) { focusNode.dispose(); } + for (final focusNode in _tableCellFocusNodes.values) { + focusNode.dispose(); + } _selectionFocusNode.dispose(); super.dispose(); } @@ -244,18 +279,30 @@ class _BusyMarkWysiwygEditorState extends State { for (final controller in _textControllers.values) { controller.dispose(); } + for (final controller in _tableCellControllers.values) { + controller.dispose(); + } for (final controller in _textUndoControllers.values) { controller.dispose(); } for (final focusNode in _focusNodes.values) { focusNode.dispose(); } + for (final focusNode in _tableCellFocusNodes.values) { + focusNode.dispose(); + } _textControllers.clear(); + _tableCellControllers.clear(); _textUndoControllers.clear(); _focusNodes.clear(); + _tableCellFocusNodes.clear(); _blockKeys.clear(); + _tableCellKeys.clear(); _pendingInlineKindsByBlockId.clear(); _activeBlockId = null; + _activeCellId = null; + _collapsibleExpansion.clear(); + _continuousTextEdit = null; _documentSelection = null; _pointerSelectionAnchor = null; _resetVerticalCaretMovement(); @@ -512,7 +559,10 @@ class _BusyMarkWysiwygEditorState extends State { widget.toolbarDirection, ), onBlockCommand: _applyBlockCommand, + isBlockCommandEnabled: _canApplyBlockCommand, onAdmonitionCommand: _applyAdmonitionCommand, + admonitionCommandsEnabled: _canApplyAdmonitionCommand(), + inlineCommandsEnabled: _hasInlineCommandTarget, admonitionsEnabled: _documentController.document.mode == MarkdownMode.writersideMarkdown, @@ -660,6 +710,12 @@ class _BusyMarkWysiwygEditorState extends State { return BusyMarkDocumentCollapsible( key: ValueKey('wysiwyg-collapsible-${block.id}'), initiallyExpanded: busyMarkWritersideInitiallyExpanded(block.attributes), + expanded: + _collapsibleExpansion[block.id] ?? + busyMarkWritersideInitiallyExpanded(block.attributes), + onExpansionChanged: (expanded) { + setState(() => _collapsibleExpansion[block.id] = expanded); + }, kindLabel: title, framed: !heading, toggleOnHeaderTap: false, @@ -804,6 +860,18 @@ class _BusyMarkWysiwygEditorState extends State { cellId, value, ), + onTableCellSourceChanged: (cellId, value) => + _handleTableCellSourceChanged( + documentFilePath, + block.id, + cellId, + value, + ), + tableCellController: _tableCellControllerFor, + tableCellUndoController: _textUndoControllerFor, + tableCellFocusNode: (cell) => _tableCellFocusNodeFor(block.id, cell), + tableCellKey: _tableCellKeyFor, + onTableCellFocused: (cellId) => _handleTableCellFocused(block.id, cellId), onTableRowInserted: (rowIndex, {required after}) => _handleTableRowInserted(block.id, rowIndex, after: after), onTableRowDeleted: (rowIndex) => @@ -837,6 +905,11 @@ class _BusyMarkWysiwygEditorState extends State { final blocks = _editableBlocks(_documentController.document.blocks); final ids = {for (final block in blocks) block.id}; final blockById = {for (final block in blocks) block.id: block}; + final tableCells = _tableCellEntries(_documentController.document.blocks); + final cellIds = {for (final entry in tableCells) entry.cell.id}; + final cellById = { + for (final entry in tableCells) entry.cell.id: entry.cell, + }; for (final entry in _textControllers.entries) { final block = blockById[entry.key]; if (block != null) { @@ -848,16 +921,42 @@ class _BusyMarkWysiwygEditorState extends State { _textControllers.remove(id)?.dispose(); } } + for (final entry in _tableCellControllers.entries) { + final cell = cellById[entry.key]; + if (cell != null) { + entry.value.updateFromBlock(cell); + } + } + for (final id in _tableCellControllers.keys.toList()) { + if (!cellIds.contains(id)) { + _tableCellControllers.remove(id)?.dispose(); + } + } for (final id in _focusNodes.keys.toList()) { if (!ids.contains(id)) { _focusNodes.remove(id)?.dispose(); } } + for (final id in _tableCellFocusNodes.keys.toList()) { + if (!cellIds.contains(id)) { + _tableCellFocusNodes.remove(id)?.dispose(); + } + } + for (final id in _textUndoControllers.keys.toList()) { + if (!ids.contains(id) && !cellIds.contains(id)) { + _textUndoControllers.remove(id)?.dispose(); + } + } for (final id in _blockKeys.keys.toList()) { if (!ids.contains(id)) { _blockKeys.remove(id); } } + for (final id in _tableCellKeys.keys.toList()) { + if (!cellIds.contains(id)) { + _tableCellKeys.remove(id); + } + } for (final id in _pendingInlineKindsByBlockId.keys.toList()) { if (!ids.contains(id)) { _pendingInlineKindsByBlockId.remove(id); @@ -871,6 +970,9 @@ class _BusyMarkWysiwygEditorState extends State { } if (_activeBlockId == null || !ids.contains(_activeBlockId)) { _activeBlockId = blocks.isEmpty ? null : blocks.first.id; + _activeCellId = null; + } else if (_activeCellId != null && !cellIds.contains(_activeCellId)) { + _activeCellId = null; } if (mounted) { setState(() {}); @@ -903,7 +1005,9 @@ class _BusyMarkWysiwygEditorState extends State { ? const [] : busyInlineStyleRanges(block.inlines), ); - created.addListener(_scheduleSessionReport); + created.addListener( + () => _handleTextControllerActivity(block.id, created), + ); return created; }); controller.updateFromBlock(block); @@ -917,6 +1021,61 @@ class _BusyMarkWysiwygEditorState extends State { ); } + BusyMarkWysiwygTextController _tableCellControllerFor(BusyBlock cell) { + final controller = _tableCellControllers.putIfAbsent(cell.id, () { + final created = BusyMarkWysiwygTextController( + text: busyMarkWysiwygEditableText(cell), + ranges: busyMarkWysiwygBlockContainsMath(cell) + ? const [] + : busyInlineStyleRanges(cell.inlines), + ); + created.addListener( + () => _handleTextControllerActivity(cell.id, created), + ); + return created; + }); + controller.updateFromBlock(cell); + return controller; + } + + void _handleTextControllerActivity( + String targetId, + TextEditingController controller, + ) { + _scheduleSessionReport(); + final continuous = _continuousTextEdit; + if (continuous == null || continuous.targetId != targetId) { + return; + } + final block = _documentController.blockById(targetId); + if (block == null || + busyMarkWysiwygEditableText(block) != controller.text || + !controller.selection.isValid) { + return; + } + if (controller.selection.extentOffset != continuous.caret) { + _continuousTextEdit = null; + } + } + + FocusNode _tableCellFocusNodeFor(String tableBlockId, BusyBlock cell) { + final focusNode = _tableCellFocusNodes.putIfAbsent( + cell.id, + () => FocusNode( + debugLabel: 'BusyMark WYSIWYG table cell ${cell.id}', + onKeyEvent: (node, event) => + _handleTableCellKeyEvent(tableBlockId, cell.id, event), + ), + ); + focusNode.onKeyEvent = (node, event) => + _handleTableCellKeyEvent(tableBlockId, cell.id, event); + return focusNode; + } + + GlobalKey _tableCellKeyFor(String cellId) { + return _tableCellKeys.putIfAbsent(cellId, GlobalKey.new); + } + FocusNode _focusNodeFor(BusyBlock block) { final focusNode = _focusNodes.putIfAbsent( block.id, @@ -934,6 +1093,32 @@ class _BusyMarkWysiwygEditorState extends State { return [for (final entry in _editableBlockEntries(blocks)) entry.block]; } + List<_TableCellEntry> _tableCellEntries(List blocks) { + final cells = <_TableCellEntry>[]; + void visit(List candidates) { + for (final block in candidates) { + if (block.kind == BusyBlockKind.table) { + for (final row in block.children) { + for (final cell in row.children) { + cells.add(_TableCellEntry(table: block, cell: cell)); + } + } + } else { + visit(block.children); + } + } + } + + visit(blocks); + return cells; + } + + _TableCellEntry? _tableCellEntry(String cellId) { + return _tableCellEntries( + _documentController.document.blocks, + ).where((entry) => entry.cell.id == cellId).firstOrNull; + } + List<_EditableBlockEntry> _editableBlockEntries( List blocks, [ int depth = 0, @@ -1066,10 +1251,31 @@ class _BusyMarkWysiwygEditorState extends State { }; void _setActiveBlock(String blockId) { + final changed = _activeBlockId != blockId || _activeCellId != null; _activeBlockId = blockId; + _activeCellId = null; + if (changed && mounted) { + setState(() {}); + } + _scheduleSessionReport(); + } + + void _setActiveTableCell(String tableBlockId, String cellId) { + final changed = _activeBlockId != tableBlockId || _activeCellId != cellId; + _activeBlockId = tableBlockId; + _activeCellId = cellId; + if (changed && mounted) { + setState(() {}); + } _scheduleSessionReport(); } + void _handleTableCellFocused(String tableBlockId, String cellId) { + _clearBlockSelection(); + _collapseFieldSelections(exceptBlockId: cellId); + _setActiveTableCell(tableBlockId, cellId); + } + KeyEventResult _handleDocumentSelectionKeyEvent( FocusNode node, KeyEvent event, @@ -1110,6 +1316,9 @@ class _BusyMarkWysiwygEditorState extends State { } void _recordUndoSnapshot([BusyDocument? previousDocument]) { + if (widget.useExternalUndoHistory) { + return; + } final snapshot = previousDocument ?? _historySnapshot(); if (_undoStack.isNotEmpty && _undoStack.last.source == snapshot.source) { return; @@ -1196,18 +1405,25 @@ class _BusyMarkWysiwygEditorState extends State { } _clearBlockSelection(); _setActiveBlock(blockId); - if (_documentController.blockText(blockId) == value) { + final oldText = _documentController.blockText(blockId); + if (oldText == value) { return; } + final controller = _textControllers[blockId]; + final undoGroup = _undoGroupForTextEdit( + targetId: blockId, + oldText: oldText, + newText: value, + selection: controller?.selection, + ); _recordUndoSnapshot(); final currentBlock = _documentController.blockById(blockId); if (currentBlock != null && busyMarkWysiwygBlockContainsMath(currentBlock)) { _documentController.updateMathSource(blockId, value); - _emitMarkdown(); + _emitMarkdown(undoGroup: undoGroup); return; } - final controller = _textControllers[blockId]; final offset = controller?.selection.extentOffset.clamp(0, value.length).toInt() ?? value.length; @@ -1220,7 +1436,7 @@ class _BusyMarkWysiwygEditorState extends State { offset, ); if (splitResult != null) { - _emitMarkdown(); + _emitMarkdown(undoGroup: undoGroup); _focusBlockAfterFrame(splitResult.blockId, offset: splitResult.offset); return; } @@ -1232,7 +1448,7 @@ class _BusyMarkWysiwygEditorState extends State { if (value.isNotEmpty) { _pendingInlineKindsByBlockId.remove(blockId); } - _emitMarkdown(); + _emitMarkdown(undoGroup: undoGroup); } String _replacementTextForFieldEdit(String oldText, String newText) { @@ -1260,14 +1476,99 @@ class _BusyMarkWysiwygEditorState extends State { String cellId, String value, ) { + _handleTableCellEdit( + documentFilePath, + tableBlockId, + cellId, + value, + markdownSource: false, + ); + } + + void _handleTableCellSourceChanged( + String documentFilePath, + String tableBlockId, + String cellId, + String value, + ) { + _handleTableCellEdit( + documentFilePath, + tableBlockId, + cellId, + value, + markdownSource: true, + ); + } + + void _handleTableCellEdit( + String documentFilePath, + String tableBlockId, + String cellId, + String value, { + required bool markdownSource, + }) { if (documentFilePath != _documentController.document.filePath) { return; } + final accepted = busyMarkNormalizeTableCellText(value); _clearBlockSelection(); - _setActiveBlock(tableBlockId); + _setActiveTableCell(tableBlockId, cellId); + final controller = _tableCellControllers[cellId]; + if (controller != null && controller.text != accepted) { + final selection = controller.selection; + controller.value = controller.value.copyWith( + text: accepted, + selection: TextSelection.collapsed( + offset: selection.extentOffset.clamp(0, accepted.length).toInt(), + ), + composing: TextRange.empty, + ); + } + final currentCell = _documentController.blockById(cellId); + if (currentCell == null) { + return; + } + final currentText = markdownSource + ? busyMarkWysiwygEditableText(currentCell) + : currentCell.plainText; + if (currentText == accepted) { + return; + } _recordUndoSnapshot(); - _documentController.updateTableCellText(tableBlockId, cellId, value); - _emitMarkdown(); + if (markdownSource) { + _documentController.updateTableCellMarkdownSource( + tableBlockId, + cellId, + accepted, + ); + } else { + _documentController.updateTableCellText(tableBlockId, cellId, accepted); + } + _emitMarkdown( + undoGroup: _undoGroupForTextEdit( + targetId: cellId, + oldText: currentText, + newText: accepted, + selection: controller?.selection, + ), + ); + } + + void _updateTableCellFromControllerText( + String tableBlockId, + String cellId, + String text, + ) { + final cell = _documentController.blockById(cellId); + if (cell != null && busyMarkWysiwygBlockContainsMath(cell)) { + _documentController.updateTableCellMarkdownSource( + tableBlockId, + cellId, + text, + ); + return; + } + _documentController.updateTableCellText(tableBlockId, cellId, text); } void _handleTableRowInserted( @@ -1423,6 +1724,12 @@ class _BusyMarkWysiwygEditorState extends State { ? null : _documentController.blockById(session.activeBlockId!); _activeBlockId = activeBlock?.id ?? _focusableBlocks().firstOrNull?.id; + final restoredCell = session.activeCellId == null + ? null + : _tableCellEntry(session.activeCellId!); + _activeCellId = restoredCell?.table.id == _activeBlockId + ? restoredCell?.cell.id + : null; final anchorBlockId = session.anchorBlockId; final extentBlockId = session.extentBlockId; if (anchorBlockId != null && extentBlockId != null) { @@ -1430,7 +1737,9 @@ class _BusyMarkWysiwygEditorState extends State { final extent = _documentController.blockById(extentBlockId); if (anchor != null && extent != null) { if (anchorBlockId == extentBlockId) { - final controller = _textControllers[anchorBlockId]; + final controller = + _tableCellControllers[anchorBlockId] ?? + _textControllers[anchorBlockId]; if (controller != null) { controller.selection = TextSelection( baseOffset: session.anchorOffset @@ -1466,7 +1775,16 @@ class _BusyMarkWysiwygEditorState extends State { alignment: session.viewportAlignment.clamp(0.0, 1.0), ); } - _focusActiveOrFirstBlock(); + final activeCellId = _activeCellId; + if (activeCellId != null) { + final controller = _tableCellControllers[activeCellId]; + final focusNode = _tableCellFocusNodes[activeCellId]; + if (controller != null && focusNode != null) { + focusNode.requestFocus(); + } + } else { + _focusActiveOrFirstBlock(); + } if (mounted) { setState(() {}); } @@ -1505,11 +1823,14 @@ class _BusyMarkWysiwygEditorState extends State { extentBlockId = documentSelection.extent.blockId; extentOffset = documentSelection.extent.offset; } else if (_activeBlockId case final blockId?) { - final selection = _textControllers[blockId]?.selection; + final selection = _activeCellId == null + ? _textControllers[blockId]?.selection + : _tableCellControllers[_activeCellId]?.selection; if (selection != null && selection.isValid) { - anchorBlockId = blockId; + final selectionTargetId = _activeCellId ?? blockId; + anchorBlockId = selectionTargetId; anchorOffset = selection.baseOffset; - extentBlockId = blockId; + extentBlockId = selectionTargetId; extentOffset = selection.extentOffset; } } @@ -1532,6 +1853,7 @@ class _BusyMarkWysiwygEditorState extends State { documentId ?? _documentId, WysiwygEditorSessionState( activeBlockId: _activeBlockId, + activeCellId: _activeCellId, anchorBlockId: anchorBlockId, anchorOffset: anchorOffset, extentBlockId: extentBlockId, @@ -1563,20 +1885,91 @@ class _BusyMarkWysiwygEditorState extends State { if (heading == null) { return; } - _jumpToBlockAndAlign(heading.id, alignment: 0); + _revealBlockThen(heading.id, () { + _jumpToBlockAndAlign(heading!.id, alignment: 0); + }); + }); + } + + void _revealBlockThen(String blockId, VoidCallback action) { + final ancestors = _collapsibleAncestorsFor(blockId); + final needsRebuild = ancestors.any( + (id) => + !(_collapsibleExpansion[id] ?? + busyMarkWritersideInitiallyExpanded( + _documentController.blockById(id)?.attributes ?? const {}, + )), + ); + if (!needsRebuild) { + action(); + return; + } + setState(() { + for (final id in ancestors) { + _collapsibleExpansion[id] = true; + } }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + action(); + } + }); + } + + List _collapsibleAncestorsFor(String blockId) { + List? find( + List<_EditorRenderEntry> entries, + List ancestors, + ) { + for (final entry in entries) { + final hidesOwnContent = + entry.collapsible && + entry.block.id == blockId && + entry.block.kind != BusyBlockKind.heading; + if (entry.block.id == blockId) { + return [...ancestors, if (hidesOwnContent) entry.block.id]; + } + final children = entry.children; + if (children == null) { + continue; + } + final result = find(children, [ + ...ancestors, + if (entry.collapsible) entry.block.id, + ]); + if (result != null) { + return result; + } + } + return null; + } + + return find( + _editorRenderEntries(_documentController.document.blocks), + const [], + ) ?? + const []; } void _scheduleSearchScroll() { + final sourceRange = widget.scrollToSourceRange; final query = widget.scrollToSearchQuery?.trim(); - if (query == null || query.isEmpty || widget.scrollRequest == 0) { + if (widget.scrollRequest == 0 || + (sourceRange == null && (query == null || query.isEmpty))) { return; } WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) { return; } - final target = _blockForSearchQuery(query); + if (sourceRange != null) { + final target = _targetForSourceRange(sourceRange); + if (target != null) { + _navigateToSourceTarget(target); + } + return; + } + final target = _blockForSearchQuery(query!); if (target == null) { return; } @@ -1592,7 +1985,321 @@ class _BusyMarkWysiwygEditorState extends State { extentOffset: matchStart + query.length, ); } - _jumpToBlockAndAlign(target.id, alignment: 0.04); + _revealBlockThen(target.id, () { + _jumpToBlockAndAlign(target.id, alignment: 0.04); + }); + }); + } + + _WysiwygSourceTarget? _targetForSourceRange( + BusyMarkWysiwygSourceRange requested, + ) { + final source = _documentController.markdown; + final start = requested.startOffset.clamp(0, source.length).toInt(); + final end = requested.endOffset.clamp(start, source.length).toInt(); + final candidates = <_WysiwygSourceTarget>[]; + BusyDocument sourceDocument; + try { + sourceDocument = const MarkdownParser() + .parse( + filePath: _documentController.document.filePath, + source: source, + mode: _documentController.document.mode, + validateLocalReferences: false, + ) + .busyDocument; + } on Object { + sourceDocument = _documentController.document; + } + + void visit( + List liveBlocks, + List sourceBlocks, + SourceSpan? inheritedSpan, + ) { + final length = math.min(liveBlocks.length, sourceBlocks.length); + for (var index = 0; index < length; index++) { + final block = liveBlocks[index]; + final sourceBlock = sourceBlocks[index]; + final span = sourceBlock.sourceSpan ?? inheritedSpan; + if (span == null || + start < span.startOffset || + start > span.endOffset) { + continue; + } + if (block.kind == BusyBlockKind.table && + sourceBlock.kind == BusyBlockKind.table) { + candidates.addAll( + _tableSourceTargets(block, span, source, start, end), + ); + continue; + } + if (block.kind != BusyBlockKind.frontMatter && !block.isSourceOnly) { + final visibleRange = _visibleRangeForSourceRange( + source: source, + block: block, + span: span, + sourceStart: start, + sourceEnd: end, + ); + if (visibleRange != null) { + candidates.add( + _WysiwygSourceTarget( + block: block, + outerBlockId: block.id, + span: span, + visibleStart: visibleRange.start, + visibleEnd: visibleRange.end, + ), + ); + } + } + visit(block.children, sourceBlock.children, span); + } + } + + visit(_documentController.document.blocks, sourceDocument.blocks, null); + final containing = + candidates + .where( + (candidate) => + start >= candidate.span.startOffset && + start <= candidate.span.endOffset, + ) + .toList() + ..sort((left, right) { + final leftLength = left.span.endOffset - left.span.startOffset; + final rightLength = right.span.endOffset - right.span.startOffset; + return leftLength.compareTo(rightLength); + }); + return containing.firstOrNull; + } + + List<_WysiwygSourceTarget> _tableSourceTargets( + BusyBlock table, + SourceSpan tableSpan, + String source, + int sourceStart, + int sourceEnd, + ) { + if (tableSpan.startOffset < 0 || tableSpan.endOffset > source.length) { + return const []; + } + final raw = source.substring(tableSpan.startOffset, tableSpan.endOffset); + final lines = <({String text, int offset})>[]; + var offset = 0; + for (final match in RegExp(r'.*(?:\n|$)').allMatches(raw)) { + var text = match.group(0) ?? ''; + if (text.isEmpty) { + continue; + } + if (text.endsWith('\n')) { + text = text.substring(0, text.length - 1); + } + if (text.endsWith('\r')) { + text = text.substring(0, text.length - 1); + } + lines.add((text: text, offset: offset)); + offset = match.end; + } + final targets = <_WysiwygSourceTarget>[]; + for (final (rowIndex, row) in table.children.indexed) { + final lineIndex = rowIndex == 0 ? 0 : rowIndex + 1; + if (lineIndex >= lines.length) { + break; + } + final line = lines[lineIndex]; + final cellSpans = _markdownTableCellSpans(line.text); + for (final (column, cell) in row.children.indexed) { + if (column >= cellSpans.length) { + break; + } + final local = cellSpans[column]; + final span = SourceSpan.fromOffsets( + filePath: tableSpan.filePath, + source: source, + startOffset: tableSpan.startOffset + line.offset + local.start, + endOffset: tableSpan.startOffset + line.offset + local.end, + ); + final visibleRange = _visibleRangeForSourceRange( + source: source, + block: cell, + span: span, + sourceStart: sourceStart, + sourceEnd: sourceEnd, + ); + targets.add( + _WysiwygSourceTarget( + block: cell, + outerBlockId: table.id, + cellId: cell.id, + span: span, + visibleStart: visibleRange?.start ?? 0, + visibleEnd: visibleRange?.end ?? cell.plainText.length, + ), + ); + } + } + return targets; + } + + List<({int start, int end})> _markdownTableCellSpans(String line) { + final delimiters = []; + var escaped = false; + for (var index = 0; index < line.length; index++) { + final codeUnit = line.codeUnitAt(index); + if (codeUnit == 0x5c && !escaped) { + escaped = true; + continue; + } + if (codeUnit == 0x7c && !escaped) { + delimiters.add(index); + } + escaped = false; + } + final boundaries = [0, ...delimiters, line.length]; + final spans = <({int start, int end})>[]; + for (var index = 0; index < boundaries.length - 1; index++) { + if (index == 0 && delimiters.isNotEmpty && delimiters.first == 0) { + continue; + } + if (index == boundaries.length - 2 && + delimiters.isNotEmpty && + delimiters.last == line.length - 1) { + continue; + } + var start = boundaries[index] + (index == 0 ? 0 : 1); + var end = boundaries[index + 1]; + while (start < end && + (line.codeUnitAt(start) == 0x20 || line.codeUnitAt(start) == 0x09)) { + start++; + } + while (end > start && + (line.codeUnitAt(end - 1) == 0x20 || + line.codeUnitAt(end - 1) == 0x09)) { + end--; + } + spans.add((start: start, end: end)); + } + return spans; + } + + ({int start, int end})? _visibleRangeForSourceRange({ + required String source, + required BusyBlock block, + required SourceSpan span, + required int sourceStart, + required int sourceEnd, + }) { + final text = busyMarkWysiwygEditableText(block); + if (text.isEmpty) { + return (start: 0, end: 0); + } + int? first; + var last = 0; + for (var index = 0; index < text.length; index++) { + final charStart = _visibleOffsetToSource( + source, + block, + index, + sourceSpan: span, + endBoundary: false, + visibleText: text, + ); + final charEnd = _visibleOffsetToSource( + source, + block, + index + 1, + sourceSpan: span, + endBoundary: true, + visibleText: text, + ); + if (charStart == null || charEnd == null) { + continue; + } + if (charEnd > sourceStart && charStart < sourceEnd) { + first ??= index; + last = index + 1; + } + } + if (first != null) { + return (start: first, end: last); + } + final rawOffset = sourceStart.clamp(span.startOffset, span.endOffset); + final nearest = + [ + for (var index = 0; index <= text.length; index++) + ( + index: index, + sourceOffset: + _visibleOffsetToSource( + source, + block, + index, + sourceSpan: span, + endBoundary: false, + visibleText: text, + ) ?? + span.startOffset, + ), + ]..sort( + (left, right) => (left.sourceOffset - rawOffset).abs().compareTo( + (right.sourceOffset - rawOffset).abs(), + ), + ); + final offset = nearest.first.index; + return (start: offset, end: offset); + } + + void _navigateToSourceTarget(_WysiwygSourceTarget target) { + _revealBlockThen(target.outerBlockId, () { + _jumpToBlockAndAlign(target.outerBlockId, alignment: 0.04); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) { + return; + } + final cellId = target.cellId; + if (cellId != null) { + _setActiveTableCell(target.outerBlockId, cellId); + final controller = _tableCellControllers[cellId]; + final focusNode = _tableCellFocusNodes[cellId]; + focusNode?.requestFocus(); + if (controller != null) { + controller.selection = TextSelection( + baseOffset: target.visibleStart + .clamp(0, controller.text.length) + .toInt(), + extentOffset: target.visibleEnd + .clamp(0, controller.text.length) + .toInt(), + ); + } + final context = _tableCellKeys[cellId]?.currentContext; + if (context != null) { + Scrollable.ensureVisible( + context, + duration: BusyMarkMotion.scroll, + curve: Curves.easeOutCubic, + alignment: 0.04, + ); + } + return; + } + final block = target.block; + final controller = _textControllers[block.id]; + if (controller != null && _focusNodes[block.id] != null) { + _setActiveBlock(block.id); + _focusNodes[block.id]!.requestFocus(); + controller.selection = TextSelection( + baseOffset: target.visibleStart + .clamp(0, controller.text.length) + .toInt(), + extentOffset: target.visibleEnd + .clamp(0, controller.text.length) + .toInt(), + ); + } + }); }); } @@ -1687,6 +2394,7 @@ class _BusyMarkWysiwygEditorState extends State { return; } _activeBlockId = blockId; + _activeCellId = null; if (!focusNode.hasFocus) { focusNode.requestFocus(); } @@ -1928,27 +2636,111 @@ class _BusyMarkWysiwygEditorState extends State { _focusBlockAfterFrame(result.blockId, offset: result.offset); return KeyEventResult.handled; } - if (key == LogicalKeyboardKey.backspace && offset == 0) { - _recordUndoSnapshot(); - final result = _documentController.applyBackspaceAtStart(blockId); - if (result == null) { - return KeyEventResult.ignored; + if (key == LogicalKeyboardKey.backspace && offset == 0) { + _recordUndoSnapshot(); + final result = _documentController.applyBackspaceAtStart(blockId); + if (result == null) { + return KeyEventResult.ignored; + } + _emitMarkdown(); + _focusBlockAfterFrame(result.blockId, offset: result.offset); + return KeyEventResult.handled; + } + if (key == LogicalKeyboardKey.arrowUp) { + return _moveCaretVertically(blockId, -1); + } + if (key == LogicalKeyboardKey.arrowDown) { + return _moveCaretVertically(blockId, 1); + } + if (key == previousBlockKey && offset == 0) { + return _focusRelativeBlock(blockId, -1, desiredOffset: _MoveToBlockEnd()); + } + if (key == nextBlockKey && offset == controller.text.length) { + return _focusRelativeBlock(blockId, 1, desiredOffset: 0); + } + return KeyEventResult.ignored; + } + + KeyEventResult _handleTableCellKeyEvent( + String tableBlockId, + String cellId, + KeyEvent event, + ) { + if (event is! KeyDownEvent && event is! KeyRepeatEvent) { + return KeyEventResult.ignored; + } + _setActiveTableCell(tableBlockId, cellId); + final keyboard = HardwareKeyboard.instance; + final commands = + BusyMarkCommandRegistryScope.read(context) ?? + BusyMarkCommandCatalog.metadata; + if (commands.shortcutAccepts( + BusyMarkCommandIds.textSelectAll, + event, + keyboard, + )) { + _selectAllForActiveBlock(); + return KeyEventResult.handled; + } + if (commands.shortcutAccepts( + BusyMarkCommandIds.textCopy, + event, + keyboard, + )) { + return _copyCurrentSelection() + ? KeyEventResult.handled + : KeyEventResult.ignored; + } + if (commands.shortcutAccepts(BusyMarkCommandIds.textCut, event, keyboard)) { + return _cutCurrentSelection() + ? KeyEventResult.handled + : KeyEventResult.ignored; + } + if (commands.shortcutAccepts( + BusyMarkCommandIds.textPaste, + event, + keyboard, + )) { + unawaited(_pasteIntoActiveBlock()); + return KeyEventResult.handled; + } + if (commands.shortcutAccepts( + BusyMarkCommandIds.textUndo, + event, + keyboard, + )) { + if (widget.useExternalUndoHistory || !_undoEditorChange()) { + widget.onUndo?.call(); } - _emitMarkdown(); - _focusBlockAfterFrame(result.blockId, offset: result.offset); return KeyEventResult.handled; } - if (key == LogicalKeyboardKey.arrowUp) { - return _moveCaretVertically(blockId, -1); - } - if (key == LogicalKeyboardKey.arrowDown) { - return _moveCaretVertically(blockId, 1); + if (commands.shortcutAccepts( + BusyMarkCommandIds.textRedo, + event, + keyboard, + )) { + if (widget.useExternalUndoHistory || !_redoEditorChange()) { + widget.onRedo?.call(); + } + return KeyEventResult.handled; } - if (key == previousBlockKey && offset == 0) { - return _focusRelativeBlock(blockId, -1, desiredOffset: _MoveToBlockEnd()); + if (commands.shortcutAccepts(BusyMarkCommandIds.search, event, keyboard)) { + widget.onOpenSearch?.call(); + return KeyEventResult.handled; } - if (key == nextBlockKey && offset == controller.text.length) { - return _focusRelativeBlock(blockId, 1, desiredOffset: 0); + final commandId = commands.matchingCommandId( + event, + keyboard, + scope: BusyMarkCommandScope.editor, + ); + final action = commandId == null + ? null + : BusyMarkEditorShortcutAction.values + .where((candidate) => commandId == 'editor.${candidate.name}') + .firstOrNull; + if (action != null) { + _applyEditorShortcutAction(action); + return KeyEventResult.handled; } return KeyEventResult.ignored; } @@ -2455,6 +3247,30 @@ class _BusyMarkWysiwygEditorState extends State { }); } + void _focusTextTargetAfterFrame(String targetId, {required int offset}) { + final cellEntry = _tableCellEntry(targetId); + if (cellEntry == null) { + _focusBlockAfterFrame(targetId, offset: offset); + return; + } + _activeBlockId = cellEntry.table.id; + _activeCellId = targetId; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) { + return; + } + final controller = _tableCellControllers[targetId]; + final focusNode = _tableCellFocusNodes[targetId]; + if (controller == null || focusNode == null) { + return; + } + focusNode.requestFocus(); + controller.selection = TextSelection.collapsed( + offset: offset.clamp(0, controller.text.length).toInt(), + ); + }); + } + List _focusableBlocks() { return [ for (final block in _editableBlocks(_documentController.document.blocks)) @@ -2468,6 +3284,85 @@ class _BusyMarkWysiwygEditorState extends State { block.kind != BusyBlockKind.table; } + bool get _hasInlineCommandTarget { + if (_activeCellId != null) { + return _documentController.blockById(_activeCellId!) != null; + } + final blockId = _activeBlockId; + final block = blockId == null + ? null + : _documentController.blockById(blockId); + return block != null && _isFocusableTextBlock(block); + } + + ({ + BusyBlock block, + TextEditingController controller, + FocusNode focusNode, + String targetId, + })? + _activeTextTarget() { + final cellId = _activeCellId; + if (cellId != null) { + final cell = _documentController.blockById(cellId); + final controller = _tableCellControllers[cellId]; + final focusNode = _tableCellFocusNodes[cellId]; + if (cell != null && controller != null && focusNode != null) { + return ( + block: cell, + controller: controller, + focusNode: focusNode, + targetId: cellId, + ); + } + } + final blockId = _activeBlockId; + if (blockId == null) { + return null; + } + final block = _documentController.blockById(blockId); + final controller = _textControllers[blockId]; + final focusNode = _focusNodes[blockId]; + if (block == null || + controller == null || + focusNode == null || + !_isFocusableTextBlock(block)) { + return null; + } + return ( + block: block, + controller: controller, + focusNode: focusNode, + targetId: blockId, + ); + } + + bool _canApplyBlockCommand(BusyWysiwygBlockCommand command) { + final selected = _selectedBlocks(); + final active = _activeBlockId == null + ? null + : _documentController.blockById(_activeBlockId!); + final targets = selected.isNotEmpty + ? selected + : [if (active != null) active]; + return targets.isNotEmpty && + targets.every( + (block) => busyMarkWysiwygCanApplyBlockCommand(block, command), + ); + } + + bool _canApplyAdmonitionCommand() { + final selected = _selectedBlocks(); + final active = _activeBlockId == null + ? null + : _documentController.blockById(_activeBlockId!); + final targets = selected.isNotEmpty + ? selected + : [if (active != null) active]; + return targets.isNotEmpty && + targets.every(busyMarkWysiwygCanApplyAdmonitionStyle); + } + void _applyBlockCommand(BusyWysiwygBlockCommand command) { final selectedBlocks = _selectedBlocks(); final activeBlock = _activeBlockId == null @@ -2476,6 +3371,12 @@ class _BusyMarkWysiwygEditorState extends State { final commandTargets = selectedBlocks.isNotEmpty ? selectedBlocks : [if (activeBlock != null) activeBlock]; + if (commandTargets.isEmpty || + !commandTargets.every( + (block) => busyMarkWysiwygCanApplyBlockCommand(block, command), + )) { + return; + } if (command == BusyWysiwygBlockCommand.codeBlock && commandTargets.isNotEmpty && commandTargets.every( @@ -2517,6 +3418,16 @@ class _BusyMarkWysiwygEditorState extends State { return; } final selectedBlocks = _selectedBlocks(); + final activeBlock = _activeBlockId == null + ? null + : _documentController.blockById(_activeBlockId!); + final targets = selectedBlocks.isNotEmpty + ? selectedBlocks + : [if (activeBlock != null) activeBlock]; + if (targets.isEmpty || + !targets.every(busyMarkWysiwygCanApplyAdmonitionStyle)) { + return; + } if (selectedBlocks.isNotEmpty) { _recordUndoSnapshot(); _documentController.applyAdmonitionStyleToBlocks( @@ -2557,15 +3468,13 @@ class _BusyMarkWysiwygEditorState extends State { _emitMarkdown(); return; } - final blockId = _activeBlockId; - if (blockId == null) { - return; - } - final controller = _textControllers[blockId]; - final selection = controller?.selection; - if (controller == null || selection == null) { + final target = _activeTextTarget(); + if (target == null) { return; } + final blockId = target.targetId; + final controller = target.controller; + final selection = controller.selection; if (selection.isCollapsed) { _togglePendingInlineKind(blockId, inlineKindForCommand(command)); return; @@ -2765,7 +3674,10 @@ class _BusyMarkWysiwygEditorState extends State { } final active = _activeInlineKindsAt( blockId, - _textControllers[blockId]?.selection.extentOffset ?? 0, + (_tableCellControllers[blockId] ?? _textControllers[blockId]) + ?.selection + .extentOffset ?? + 0, ); if (active.contains(inlineKind)) { active.remove(inlineKind); @@ -2825,19 +3737,14 @@ class _BusyMarkWysiwygEditorState extends State { _emitMarkdown(); return; } - final blockId = _activeBlockId; - if (blockId == null) { - return; - } - final controller = _textControllers[blockId]; - final selection = controller?.selection; - final block = _documentController.blockById(blockId); - if (controller == null || - selection == null || - !selection.isValid || - block == null) { + final target = _activeTextTarget(); + if (target == null || !target.controller.selection.isValid) { return; } + final blockId = target.targetId; + final controller = target.controller; + final selection = controller.selection; + final block = target.block; final start = math .min(selection.start, selection.end) .clamp(0, controller.text.length) @@ -2862,12 +3769,12 @@ class _BusyMarkWysiwygEditorState extends State { start: existingLink.start, end: existingLink.end, ); - final target = _captureDialogTarget(); + final dialogTarget = _captureDialogTarget(); final destination = await _showLinkDialog( context, initialDestination: existingLink?.destination ?? '', ); - if (!_isDialogTargetCurrent(target) || + if (!_isDialogTargetCurrent(dialogTarget) || destination == null || destination.trim().isEmpty) { return; @@ -2927,11 +3834,12 @@ class _BusyMarkWysiwygEditorState extends State { } void _applyInlineMathCommand() { - final blockId = _activeBlockId; - final controller = blockId == null ? null : _textControllers[blockId]; - if (blockId == null || controller == null) { + final target = _activeTextTarget(); + if (target == null) { return; } + final blockId = target.targetId; + final controller = target.controller; final selection = controller.selection.isValid ? controller.selection : TextSelection.collapsed(offset: controller.text.length); @@ -2953,7 +3861,7 @@ class _BusyMarkWysiwygEditorState extends State { return; } _emitMarkdown(); - _focusBlockAfterFrame(blockId, offset: insertion.selectionEnd); + _focusTextTargetAfterFrame(blockId, offset: insertion.selectionEnd); return; } final insertion = _documentController.buildInlineMathSourceInsertion( @@ -2976,7 +3884,7 @@ class _BusyMarkWysiwygEditorState extends State { ); _documentController.updateMathSource(blockId, insertion.source); _emitMarkdown(); - _focusBlockAfterFrame(blockId, offset: insertion.selectionEnd); + _focusTextTargetAfterFrame(blockId, offset: insertion.selectionEnd); } void _applyDisplayMathCommand() { @@ -2998,6 +3906,14 @@ class _BusyMarkWysiwygEditorState extends State { if (blockId == null) { return; } + final block = _documentController.blockById(blockId); + if (block == null || + !busyMarkWysiwygCanApplyBlockCommand( + block, + BusyWysiwygBlockCommand.image, + )) { + return; + } final target = _captureDialogTarget(); final result = await _showImageDialog( context, @@ -3066,14 +3982,15 @@ class _BusyMarkWysiwygEditorState extends State { if (_hasBlockSelection) { return _replaceDocumentSelectionWithStyledBlocks(clipboard.blocks); } - final blockId = _activeBlockId; - if (blockId == null) { - return false; + if (_activeCellId != null) { + return _replaceActiveTableCellSelection(clipboard.text); } - final controller = _textControllers[blockId]; - if (controller == null) { + final target = _activeTextTarget(); + if (target == null) { return false; } + final blockId = target.targetId; + final controller = target.controller; final currentText = controller.text; final selection = controller.selection.isValid ? controller.selection @@ -3086,7 +4003,7 @@ class _BusyMarkWysiwygEditorState extends State { .max(selection.start, selection.end) .clamp(start, currentText.length) .toInt(); - _recordUndoSnapshot(); + final undoSnapshot = _historySnapshot(); final result = _documentController.insertStyledBlocksAtSelection( blockId: blockId, selectionStart: start, @@ -3096,6 +4013,7 @@ class _BusyMarkWysiwygEditorState extends State { if (result == null) { return false; } + _recordUndoSnapshot(undoSnapshot); _clearBlockSelection(collapseFields: false); _emitMarkdown(); _focusBlockAfterFrame(result.blockId, offset: result.offset); @@ -3103,19 +4021,21 @@ class _BusyMarkWysiwygEditorState extends State { } Future _pastePlainTextIntoActiveBlock({String? textOverride}) async { - final blockId = _activeBlockId; - if (blockId == null) { - return; - } - final controller = _textControllers[blockId]; - if (controller == null) { + final target = _activeTextTarget(); + if (target == null) { return; } + final blockId = target.targetId; + final controller = target.controller; final text = textOverride ?? (await Clipboard.getData(Clipboard.kTextPlain))?.text; if (text == null || text.isEmpty) { return; } + if (_activeCellId != null) { + _replaceActiveTableCellSelection(text); + return; + } if (_hasBlockSelection && _replaceDocumentSelectionWithText(text)) { return; } @@ -3143,6 +4063,44 @@ class _BusyMarkWysiwygEditorState extends State { ); } + bool _replaceActiveTableCellSelection(String replacement) { + final cellId = _activeCellId; + final tableId = _activeBlockId; + if (cellId == null || tableId == null) { + return false; + } + final controller = _tableCellControllers[cellId]; + final cell = _documentController.blockById(cellId); + if (controller == null || cell == null) { + return false; + } + final selection = controller.selection.isValid + ? controller.selection + : TextSelection.collapsed(offset: controller.text.length); + final start = math + .min(selection.start, selection.end) + .clamp(0, controller.text.length) + .toInt(); + final end = math + .max(selection.start, selection.end) + .clamp(start, controller.text.length) + .toInt(); + final acceptedReplacement = busyMarkNormalizeTableCellText(replacement); + final nextText = controller.text.replaceRange( + start, + end, + acceptedReplacement, + ); + _recordUndoSnapshot(); + _updateTableCellFromControllerText(tableId, cellId, nextText); + _emitMarkdown(); + _focusTextTargetAfterFrame( + cellId, + offset: start + acceptedReplacement.length, + ); + return true; + } + bool _insertTabIntoBlock(String blockId) { if (_hasBlockSelection) { return _replaceDocumentSelectionWithText('\t'); @@ -3178,10 +4136,9 @@ class _BusyMarkWysiwygEditorState extends State { Future _applyInlineImageCommand() async { final selectedRanges = _selectedTextRanges(); - final activeBlockId = _activeBlockId; - final activeController = activeBlockId == null - ? null - : _textControllers[activeBlockId]; + final activeTarget = _activeTextTarget(); + final activeBlockId = activeTarget?.targetId; + final activeController = activeTarget?.controller; final activeSelection = activeController?.selection; if (selectedRanges.isEmpty && (activeBlockId == null || @@ -3396,15 +4353,13 @@ class _BusyMarkWysiwygEditorState extends State { } void _applyHardBreakCommand() { - final blockId = _activeBlockId; - if (blockId == null) { - return; - } - final controller = _textControllers[blockId]; - final block = _documentController.blockById(blockId); - if (controller == null || block == null) { + final target = _activeTextTarget(); + if (target == null || _activeCellId != null) { return; } + final blockId = target.targetId; + final controller = target.controller; + final block = target.block; final selection = controller.selection; final offset = selection.isValid ? selection.extentOffset.clamp(0, controller.text.length).toInt() @@ -3776,13 +4731,80 @@ class _BusyMarkWysiwygEditorState extends State { ); } - void _emitMarkdown() { + String? _undoGroupForTextEdit({ + required String targetId, + required String oldText, + required String newText, + required TextSelection? selection, + }) { + var prefix = 0; + final shortest = math.min(oldText.length, newText.length); + while (prefix < shortest && + oldText.codeUnitAt(prefix) == newText.codeUnitAt(prefix)) { + prefix++; + } + var oldEnd = oldText.length; + var newEnd = newText.length; + while (oldEnd > prefix && + newEnd > prefix && + oldText.codeUnitAt(oldEnd - 1) == newText.codeUnitAt(newEnd - 1)) { + oldEnd--; + newEnd--; + } + final removedLength = oldEnd - prefix; + final insertedLength = newEnd - prefix; + final simpleTyping = + (removedLength == 0 && insertedLength == 1) || + (removedLength == 1 && insertedLength == 0); + final caret = selection?.isValid == true + ? selection!.extentOffset.clamp(0, newText.length).toInt() + : newEnd; + if (!simpleTyping) { + _continuousTextEdit = null; + return null; + } + final now = DateTime.now(); + final previous = _continuousTextEdit; + final continuous = + previous != null && + previous.targetId == targetId && + previous.newText == oldText && + now.difference(previous.timestamp) < const Duration(seconds: 2) && + (previous.caret == prefix || + previous.caret == caret || + (insertedLength == 0 && previous.caret == oldEnd)); + final group = continuous + ? previous.group + : 'wysiwyg-$_documentId-${++_undoGroupSequence}'; + _continuousTextEdit = _ContinuousTextEdit( + targetId: targetId, + newText: newText, + caret: caret, + timestamp: now, + group: group, + ); + return group; + } + + void _emitMarkdown({String? undoGroup}) { + if (undoGroup == null) { + _continuousTextEdit = null; + } _internalChange = true; final markdown = _documentController.markdown; widget.onDocumentChanged?.call( _documentController.document.copyWith(source: markdown), ); - widget.onSourceChanged(_documentController.document.filePath, markdown); + final transactionalCallback = widget.onTransactionalSourceChanged; + if (transactionalCallback != null) { + transactionalCallback( + _documentController.document.filePath, + markdown, + undoGroup, + ); + } else { + widget.onSourceChanged(_documentController.document.filePath, markdown); + } WidgetsBinding.instance.addPostFrameCallback((_) { _internalChange = false; }); @@ -4448,7 +5470,10 @@ class _BusyMarkWysiwygEditorState extends State { } void _collapseFieldSelections({String? exceptBlockId}) { - for (final entry in _textControllers.entries) { + for (final entry in { + ..._textControllers, + ..._tableCellControllers, + }.entries) { if (entry.key == exceptBlockId) { continue; } @@ -4477,15 +5502,24 @@ class _BusyMarkWysiwygEditorState extends State { } void _selectAllForActiveBlock() { - final blockId = _activeBlockId; - if (blockId == null) { + final target = _activeTextTarget(); + if (target == null) { final blocks = _focusableBlocks(); if (blocks.isNotEmpty) { _selectAllForBlock(blocks.first.id); } return; } - _selectAllForBlock(blockId); + if (_activeCellId != null) { + _clearBlockSelection(); + target.focusNode.requestFocus(); + target.controller.selection = TextSelection( + baseOffset: 0, + extentOffset: target.controller.text.length, + ); + return; + } + _selectAllForBlock(target.targetId); } void _selectAllForBlock(String blockId) { @@ -4663,26 +5697,19 @@ class _BusyMarkWysiwygEditorState extends State { if (first.block.id == last.block.id && first.start == last.end) { return false; } - _recordUndoSnapshot(); - final deletion = _documentController.deleteTextSelection( + final undoSnapshot = _historySnapshot(); + final result = _documentController.replaceTextSelectionWithStyledBlocks( firstBlockId: first.block.id, firstStartOffset: first.start, lastBlockId: last.block.id, lastEndOffset: last.end, removedBlockIds: ranges.map((range) => range.block.id), - ); - if (deletion == null) { - return false; - } - final result = _documentController.insertStyledBlocksAtSelection( - blockId: deletion.blockId, - selectionStart: deletion.offset, - selectionEnd: deletion.offset, blocks: blocks, ); if (result == null) { return false; } + _recordUndoSnapshot(undoSnapshot); _clearBlockSelection(collapseFields: false); _emitMarkdown(); _focusBlockAfterFrame(result.blockId, offset: result.offset); @@ -4789,20 +5816,15 @@ class _BusyMarkWysiwygEditorState extends State { } _SelectedTextRange? _activeTextSelectionRange() { - final blockId = _activeBlockId; - if (blockId == null) { - return null; - } - final block = _documentController.blockById(blockId); - final controller = _textControllers[blockId]; - final selection = controller?.selection; - if (block == null || - controller == null || - selection == null || - !selection.isValid || - selection.isCollapsed) { + final target = _activeTextTarget(); + if (target == null || + !target.controller.selection.isValid || + target.controller.selection.isCollapsed) { return null; } + final block = target.block; + final controller = target.controller; + final selection = controller.selection; final start = math .min(selection.start, selection.end) .clamp(0, controller.text.length) @@ -4830,12 +5852,16 @@ class _BusyMarkWysiwygEditorState extends State { end, ), attributes: range.coversWholeBlock ? range.block.attributes : const {}, + completeBlock: range.coversWholeBlock + ? busyMarkWysiwygImmutableBlockSnapshot(range.block) + : null, ); } bool _deleteActiveTextSelection(_SelectedTextRange range) { final blockId = range.block.id; - final controller = _textControllers[blockId]; + final controller = + _tableCellControllers[blockId] ?? _textControllers[blockId]; if (controller == null) { return false; } @@ -4846,12 +5872,15 @@ class _BusyMarkWysiwygEditorState extends State { return false; } _recordUndoSnapshot(); - _documentController.updateBlockText( - blockId, - text.replaceRange(start, end, ''), - ); + final nextText = text.replaceRange(start, end, ''); + final cellEntry = _tableCellEntry(blockId); + if (cellEntry == null) { + _documentController.updateBlockText(blockId, nextText); + } else { + _updateTableCellFromControllerText(cellEntry.table.id, blockId, nextText); + } _emitMarkdown(); - _focusBlockAfterFrame(blockId, offset: start); + _focusTextTargetAfterFrame(blockId, offset: start); return true; } @@ -5062,6 +6091,40 @@ class _WysiwygInternalClipboard { final List blocks; } +class _ContinuousTextEdit { + const _ContinuousTextEdit({ + required this.targetId, + required this.newText, + required this.caret, + required this.timestamp, + required this.group, + }); + + final String targetId; + final String newText; + final int caret; + final DateTime timestamp; + final String group; +} + +class _WysiwygSourceTarget { + const _WysiwygSourceTarget({ + required this.block, + required this.outerBlockId, + required this.span, + required this.visibleStart, + required this.visibleEnd, + this.cellId, + }); + + final BusyBlock block; + final String outerBlockId; + final String? cellId; + final SourceSpan span; + final int visibleStart; + final int visibleEnd; +} + class _EditableBlockEntry { const _EditableBlockEntry({required this.block, required this.depth}); @@ -5069,6 +6132,13 @@ class _EditableBlockEntry { final int depth; } +class _TableCellEntry { + const _TableCellEntry({required this.table, required this.cell}); + + final BusyBlock table; + final BusyBlock cell; +} + class _EditorRenderEntry { const _EditorRenderEntry.block({ required this.block, diff --git a/lib/src/editor/wysiwyg/wysiwyg_inline_controller.dart b/lib/src/editor/wysiwyg/wysiwyg_inline_controller.dart index 1d4526bf..e647446a 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_inline_controller.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_inline_controller.dart @@ -53,6 +53,52 @@ class BusyInlineStyleRange { final String? destination; } +class BusyInlineSemanticAnchor { + const BusyInlineSemanticAnchor({required this.offset, required this.inline}); + + final int offset; + final BusyInline inline; +} + +List busyInlineSemanticAnchors( + List inlines, +) { + final anchors = []; + var offset = 0; + + void visit(BusyInline inline, List ancestors) { + if (inline.plainText.isEmpty) { + if (inline.kind == BusyInlineKind.text && + !inline.children.any(_containsNonTextInline)) { + return; + } + var anchored = inline; + for (final ancestor in ancestors.reversed) { + anchored = ancestor.copyWith(text: '', children: [anchored]); + } + anchors.add(BusyInlineSemanticAnchor(offset: offset, inline: anchored)); + return; + } + if (inline.children.isNotEmpty) { + for (final child in inline.children) { + visit(child, [...ancestors, inline]); + } + return; + } + offset += inline.plainText.length; + } + + for (final inline in inlines) { + visit(inline, const []); + } + return anchors; +} + +bool _containsNonTextInline(BusyInline inline) { + return inline.kind != BusyInlineKind.text || + inline.children.any(_containsNonTextInline); +} + class BusyMarkWysiwygTextController extends TextEditingController { BusyMarkWysiwygTextController({ required String text, diff --git a/lib/src/editor/wysiwyg/wysiwyg_session_state.dart b/lib/src/editor/wysiwyg/wysiwyg_session_state.dart index de3d6482..642f4cfc 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_session_state.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_session_state.dart @@ -1,6 +1,7 @@ class WysiwygEditorSessionState { const WysiwygEditorSessionState({ this.activeBlockId, + this.activeCellId, this.anchorBlockId, this.anchorOffset = 0, this.extentBlockId, @@ -10,6 +11,7 @@ class WysiwygEditorSessionState { }); final String? activeBlockId; + final String? activeCellId; final String? anchorBlockId; final int anchorOffset; final String? extentBlockId; @@ -19,6 +21,7 @@ class WysiwygEditorSessionState { Map toJson() => { 'activeBlockId': activeBlockId, + 'activeCellId': activeCellId, 'anchorBlockId': anchorBlockId, 'anchorOffset': anchorOffset, 'extentBlockId': extentBlockId, @@ -30,6 +33,7 @@ class WysiwygEditorSessionState { factory WysiwygEditorSessionState.fromJson(Map json) { return WysiwygEditorSessionState( activeBlockId: json['activeBlockId']?.toString(), + activeCellId: json['activeCellId']?.toString(), anchorBlockId: json['anchorBlockId']?.toString(), anchorOffset: (json['anchorOffset'] as num?)?.toInt() ?? 0, extentBlockId: json['extentBlockId']?.toString(), diff --git a/lib/src/editor/wysiwyg/wysiwyg_toolbar.dart b/lib/src/editor/wysiwyg/wysiwyg_toolbar.dart index 972b6cd2..40698d41 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_toolbar.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_toolbar.dart @@ -24,6 +24,9 @@ class BusyMarkWysiwygToolbar extends StatelessWidget { required this.onOutdentCommand, required this.onToggleTaskCommand, required this.onHardBreakCommand, + this.isBlockCommandEnabled, + this.admonitionCommandsEnabled = true, + this.inlineCommandsEnabled = true, this.admonitionsEnabled = false, this.alignEnd = false, this.axis = Axis.horizontal, @@ -43,6 +46,9 @@ class BusyMarkWysiwygToolbar extends StatelessWidget { final VoidCallback onOutdentCommand; final VoidCallback onToggleTaskCommand; final VoidCallback onHardBreakCommand; + final bool Function(BusyWysiwygBlockCommand command)? isBlockCommandEnabled; + final bool admonitionCommandsEnabled; + final bool inlineCommandsEnabled; final bool admonitionsEnabled; final bool alignEnd; final Axis axis; @@ -76,57 +82,66 @@ class BusyMarkWysiwygToolbar extends StatelessWidget { tooltip: context.l10n.bold, icon: BusyMarkGlyphs.bold, shortcut: BusyMarkEditorShortcutLabels.bold, - onPressed: () => onInlineCommand(BusyWysiwygInlineCommand.bold), + onPressed: inlineCommandsEnabled + ? () => onInlineCommand(BusyWysiwygInlineCommand.bold) + : null, ), _button( context, tooltip: context.l10n.italic, icon: BusyMarkGlyphs.italic, shortcut: BusyMarkEditorShortcutLabels.italic, - onPressed: () => onInlineCommand(BusyWysiwygInlineCommand.italic), + onPressed: inlineCommandsEnabled + ? () => onInlineCommand(BusyWysiwygInlineCommand.italic) + : null, ), _button( context, tooltip: context.l10n.underline, icon: BusyMarkGlyphs.underline, shortcut: BusyMarkEditorShortcutLabels.underline, - onPressed: () => - onInlineCommand(BusyWysiwygInlineCommand.underline), + onPressed: inlineCommandsEnabled + ? () => onInlineCommand(BusyWysiwygInlineCommand.underline) + : null, ), _button( context, tooltip: context.l10n.strikethrough, icon: BusyMarkGlyphs.strikethrough, shortcut: BusyMarkEditorShortcutLabels.strikethrough, - onPressed: () => - onInlineCommand(BusyWysiwygInlineCommand.strikethrough), + onPressed: inlineCommandsEnabled + ? () => + onInlineCommand(BusyWysiwygInlineCommand.strikethrough) + : null, ), _button( context, tooltip: context.l10n.inlineCode, icon: BusyMarkGlyphs.code, shortcut: BusyMarkEditorShortcutLabels.inlineCode, - onPressed: () => onInlineCommand(BusyWysiwygInlineCommand.code), + onPressed: inlineCommandsEnabled + ? () => onInlineCommand(BusyWysiwygInlineCommand.code) + : null, ), _button( context, tooltip: context.l10n.link, icon: BusyMarkGlyphs.link, shortcut: BusyMarkEditorShortcutLabels.link, - onPressed: onLinkCommand, + onPressed: inlineCommandsEnabled ? onLinkCommand : null, ), _button( context, tooltip: context.l10n.inlineMath, icon: BusyMarkGlyphs.math, - onPressed: onInlineMathCommand, + onPressed: inlineCommandsEnabled ? onInlineMathCommand : null, ), _button( context, tooltip: context.l10n.hardLineBreak, icon: BusyMarkGlyphs.hardBreak, shortcut: BusyMarkEditorShortcutLabels.hardLineBreak, - onPressed: onHardBreakCommand, + onPressed: inlineCommandsEnabled ? onHardBreakCommand : null, ), ], [ @@ -137,16 +152,19 @@ class BusyMarkWysiwygToolbar extends StatelessWidget { tooltip: context.l10n.blockquote, icon: BusyMarkGlyphs.blockquote, shortcut: BusyMarkEditorShortcutLabels.blockquote, - onPressed: () => - onBlockCommand(BusyWysiwygBlockCommand.blockquote), + onPressed: + _blockCommandEnabled(BusyWysiwygBlockCommand.blockquote) + ? () => onBlockCommand(BusyWysiwygBlockCommand.blockquote) + : null, ), _button( context, tooltip: context.l10n.codeBlock, icon: BusyMarkGlyphs.codeBlock, shortcut: BusyMarkEditorShortcutLabels.codeBlock, - onPressed: () => - onBlockCommand(BusyWysiwygBlockCommand.codeBlock), + onPressed: _blockCommandEnabled(BusyWysiwygBlockCommand.codeBlock) + ? () => onBlockCommand(BusyWysiwygBlockCommand.codeBlock) + : null, ), _button( context, @@ -164,8 +182,10 @@ class BusyMarkWysiwygToolbar extends StatelessWidget { context, tooltip: context.l10n.thematicBreak, icon: BusyMarkGlyphs.thematicBreak, - onPressed: () => - onBlockCommand(BusyWysiwygBlockCommand.thematicBreak), + onPressed: + _blockCommandEnabled(BusyWysiwygBlockCommand.thematicBreak) + ? () => onBlockCommand(BusyWysiwygBlockCommand.thematicBreak) + : null, ), ], [ @@ -174,23 +194,29 @@ class BusyMarkWysiwygToolbar extends StatelessWidget { tooltip: context.l10n.unorderedList, icon: BusyMarkGlyphs.unorderedList, shortcut: BusyMarkEditorShortcutLabels.unorderedList, - onPressed: () => - onBlockCommand(BusyWysiwygBlockCommand.unorderedList), + onPressed: + _blockCommandEnabled(BusyWysiwygBlockCommand.unorderedList) + ? () => onBlockCommand(BusyWysiwygBlockCommand.unorderedList) + : null, ), _button( context, tooltip: context.l10n.orderedList, icon: BusyMarkGlyphs.orderedList, shortcut: BusyMarkEditorShortcutLabels.orderedList, - onPressed: () => - onBlockCommand(BusyWysiwygBlockCommand.orderedList), + onPressed: + _blockCommandEnabled(BusyWysiwygBlockCommand.orderedList) + ? () => onBlockCommand(BusyWysiwygBlockCommand.orderedList) + : null, ), _button( context, tooltip: context.l10n.taskList, icon: BusyMarkGlyphs.checkedBox, shortcut: BusyMarkEditorShortcutLabels.taskList, - onPressed: () => onBlockCommand(BusyWysiwygBlockCommand.taskList), + onPressed: _blockCommandEnabled(BusyWysiwygBlockCommand.taskList) + ? () => onBlockCommand(BusyWysiwygBlockCommand.taskList) + : null, ), _button( context, @@ -219,13 +245,15 @@ class BusyMarkWysiwygToolbar extends StatelessWidget { tooltip: context.l10n.image, icon: BusyMarkGlyphs.image, shortcut: BusyMarkEditorShortcutLabels.image, - onPressed: onImageCommand, + onPressed: _blockCommandEnabled(BusyWysiwygBlockCommand.image) + ? onImageCommand + : null, ), _button( context, tooltip: context.l10n.inlineImage, icon: BusyMarkGlyphs.inlineImage, - onPressed: onInlineImageCommand, + onPressed: inlineCommandsEnabled ? onInlineImageCommand : null, ), _button( context, @@ -239,6 +267,10 @@ class BusyMarkWysiwygToolbar extends StatelessWidget { ); } + bool _blockCommandEnabled(BusyWysiwygBlockCommand command) { + return isBlockCommandEnabled?.call(command) ?? true; + } + List _groups(Axis axis, List> groups) { final widgets = []; for (final (groupIndex, group) @@ -272,42 +304,49 @@ class BusyMarkWysiwygToolbar extends StatelessWidget { label: context.l10n.paragraph, icon: BusyMarkGlyphs.paragraph, shortcut: BusyMarkEditorShortcutLabels.paragraph, + enabled: _blockCommandEnabled(BusyWysiwygBlockCommand.paragraph), ), BusyMarkPopupMenuItem( value: BusyWysiwygBlockCommand.heading1, label: context.l10n.heading1, icon: BusyMarkGlyphs.heading, shortcut: BusyMarkEditorShortcutLabels.heading1, + enabled: _blockCommandEnabled(BusyWysiwygBlockCommand.heading1), ), BusyMarkPopupMenuItem( value: BusyWysiwygBlockCommand.heading2, label: context.l10n.heading2, icon: BusyMarkGlyphs.heading, shortcut: BusyMarkEditorShortcutLabels.heading2, + enabled: _blockCommandEnabled(BusyWysiwygBlockCommand.heading2), ), BusyMarkPopupMenuItem( value: BusyWysiwygBlockCommand.heading3, label: context.l10n.heading3, icon: BusyMarkGlyphs.heading, shortcut: BusyMarkEditorShortcutLabels.heading3, + enabled: _blockCommandEnabled(BusyWysiwygBlockCommand.heading3), ), BusyMarkPopupMenuItem( value: BusyWysiwygBlockCommand.heading4, label: context.l10n.heading4, icon: BusyMarkGlyphs.heading, shortcut: BusyMarkEditorShortcutLabels.heading4, + enabled: _blockCommandEnabled(BusyWysiwygBlockCommand.heading4), ), BusyMarkPopupMenuItem( value: BusyWysiwygBlockCommand.heading5, label: context.l10n.heading5, icon: BusyMarkGlyphs.heading, shortcut: BusyMarkEditorShortcutLabels.heading5, + enabled: _blockCommandEnabled(BusyWysiwygBlockCommand.heading5), ), BusyMarkPopupMenuItem( value: BusyWysiwygBlockCommand.heading6, label: context.l10n.heading6, icon: BusyMarkGlyphs.heading, shortcut: BusyMarkEditorShortcutLabels.heading6, + enabled: _blockCommandEnabled(BusyWysiwygBlockCommand.heading6), ), ], onSelected: onBlockCommand, @@ -327,21 +366,25 @@ class BusyMarkWysiwygToolbar extends StatelessWidget { value: BusyAdmonitionStyle.tip, label: context.l10n.tip, icon: BusyMarkGlyphs.tip, + enabled: admonitionCommandsEnabled, ), BusyMarkPopupMenuItem( value: BusyAdmonitionStyle.note, label: context.l10n.note, icon: BusyMarkGlyphs.info, + enabled: admonitionCommandsEnabled, ), BusyMarkPopupMenuItem( value: BusyAdmonitionStyle.warning, label: context.l10n.warning, icon: BusyMarkGlyphs.warning, + enabled: admonitionCommandsEnabled, ), BusyMarkPopupMenuItem( value: BusyAdmonitionStyle.quote, label: context.l10n.quote, icon: BusyMarkGlyphs.blockquote, + enabled: admonitionCommandsEnabled, ), ], onSelected: onAdmonitionCommand!, @@ -352,7 +395,7 @@ class BusyMarkWysiwygToolbar extends StatelessWidget { BuildContext context, { required String tooltip, required IconData icon, - required VoidCallback onPressed, + required VoidCallback? onPressed, String? shortcut, }) { return BusyMarkHeaderIconButton( diff --git a/lib/src/markdown/busymark_markdown_serializer.dart b/lib/src/markdown/busymark_markdown_serializer.dart index 9d5df7b5..83006d9d 100644 --- a/lib/src/markdown/busymark_markdown_serializer.dart +++ b/lib/src/markdown/busymark_markdown_serializer.dart @@ -38,7 +38,10 @@ class BusyMarkMarkdownSerializer { } return switch (block.kind) { BusyBlockKind.heading => _heading(block), - BusyBlockKind.paragraph => _inlineMarkdown(block.inlines), + BusyBlockKind.paragraph => _inlineMarkdown( + block.inlines, + atBlockStart: true, + ), BusyBlockKind.math => _mathBlock(block), BusyBlockKind.codeBlock => _codeBlock(block), BusyBlockKind.unorderedListItem => _listItem(block, '-'), @@ -251,7 +254,7 @@ class BusyMarkMarkdownSerializer { } String _listItem(BusyBlock block, String marker, {String? contentPrefix}) { - final text = _inlineMarkdown(block.inlines); + final text = _inlineMarkdown(block.inlines, atBlockStart: true); final content = [ if (contentPrefix != null) contentPrefix, if (text.isNotEmpty) text, @@ -441,18 +444,46 @@ class BusyMarkMarkdownSerializer { .replaceAll('>', '>'); } - String _inlineMarkdown(List inlines, {bool tableCell = false}) { - return inlines - .map((inline) => _inline(inline, tableCell: tableCell)) - .join(); + String _inlineMarkdown( + List inlines, { + bool tableCell = false, + bool atBlockStart = false, + }) { + final buffer = StringBuffer(); + var nextAtBlockStart = atBlockStart; + for (var index = 0; index < inlines.length; index++) { + final inline = inlines[index]; + final source = _inline( + inline, + tableCell: tableCell, + atBlockStart: nextAtBlockStart, + followedByLink: + index + 1 < inlines.length && + inlines[index + 1].kind == BusyInlineKind.link, + ); + buffer.write(source); + if (source.isNotEmpty) { + nextAtBlockStart = source.endsWith('\n'); + } + } + return buffer.toString(); } - String _inline(BusyInline inline, {bool tableCell = false}) { + String _inline( + BusyInline inline, { + bool tableCell = false, + bool atBlockStart = false, + bool followedByLink = false, + }) { final children = inline.children.isEmpty - ? _escapeInlineText(inline.text) + ? _escapeInlineText(inline.text, atBlockStart: atBlockStart) : _inlineMarkdown(inline.children, tableCell: tableCell); return switch (inline.kind) { - BusyInlineKind.text => _escapeInlineText(inline.text), + BusyInlineKind.text => _escapeInlineText( + inline.text, + atBlockStart: atBlockStart, + escapeTrailingBang: followedByLink, + ), BusyInlineKind.math => _mathInline(inline), BusyInlineKind.strong => '**$children**', BusyInlineKind.emphasis => '*$children*', @@ -545,13 +576,91 @@ class BusyMarkMarkdownSerializer { return required < minimum ? minimum : required; } - String _escapeInlineText(String value) { - return value - .replaceAll('\\', r'\\') - .replaceAll(r'$', r'\$') - .replaceAll('[', r'\[') - .replaceAll(']', r'\]'); + String _escapeInlineText( + String value, { + bool atBlockStart = false, + bool escapeTrailingBang = false, + }) { + final blockMarkerOffsets = _blockMarkerEscapeOffsets( + value, + atBlockStart: atBlockStart, + ); + final buffer = StringBuffer(); + for (var index = 0; index < value.length; index++) { + final unit = value.codeUnitAt(index); + if (_inlineSyntaxCharacters.contains(unit) || + (escapeTrailingBang && unit == 0x21 && index == value.length - 1) || + blockMarkerOffsets.contains(index)) { + buffer.writeCharCode(0x5c); + } + buffer.writeCharCode(unit); + } + return buffer.toString(); } + + Set _blockMarkerEscapeOffsets( + String value, { + required bool atBlockStart, + }) { + final offsets = {}; + var lineStart = 0; + var firstLine = true; + while (lineStart <= value.length) { + final newline = value.indexOf('\n', lineStart); + final lineEnd = newline < 0 ? value.length : newline; + if (!firstLine || atBlockStart) { + final markerOffset = _blockMarkerEscapeOffset( + value.substring(lineStart, lineEnd), + ); + if (markerOffset != null) { + offsets.add(lineStart + markerOffset); + } + } + if (newline < 0) { + break; + } + lineStart = newline + 1; + firstLine = false; + } + return offsets; + } + + int? _blockMarkerEscapeOffset(String line) { + final indentation = RegExp(r'^[ \t]{0,3}').firstMatch(line)!.group(0)!; + final content = line.substring(indentation.length); + if (RegExp(r'^#{1,6}(?:[ \t]+|$)').hasMatch(content)) { + return indentation.length; + } + if (content.startsWith('>')) { + return indentation.length; + } + if (RegExp(r'^[-+](?:[ \t]+|$)').hasMatch(content)) { + return indentation.length; + } + final ordered = RegExp(r'^(\d{1,9})([.)])(?:[ \t]+|$)').firstMatch(content); + if (ordered != null) { + return indentation.length + ordered.group(1)!.length; + } + if (RegExp(r'^=+[ \t]*$').hasMatch(content) || + RegExp(r'^-(?:[ \t]*-){2,}[ \t]*$').hasMatch(content)) { + return indentation.length; + } + return null; + } + + static const _inlineSyntaxCharacters = { + 0x24, // $ + 0x25, // % + 0x26, // & + 0x2a, // * + 0x3c, // < + 0x5b, // [ + 0x5c, // backslash + 0x5d, // ] + 0x5f, // _ + 0x60, // ` + 0x7e, // ~ + }; } extension _FirstOrNull on Iterable { diff --git a/lib/src/markdown/markdown_ast_adapter.dart b/lib/src/markdown/markdown_ast_adapter.dart index 09bc2a07..7abe7000 100644 --- a/lib/src/markdown/markdown_ast_adapter.dart +++ b/lib/src/markdown/markdown_ast_adapter.dart @@ -15,6 +15,21 @@ const _rawHtmlAdapter = RawHtmlAdapter(); class MarkdownAstAdapter { const MarkdownAstAdapter(); + /// Parses Markdown in an inline-only context, such as a table cell. + /// + /// Block markers at the beginning of [source] remain literal because the + /// block grammar is deliberately never invoked. + List parseInlineFragment({ + required String source, + required MarkdownMode mode, + }) { + if (source.isEmpty) { + return const []; + } + final document = busyMarkMarkdownDocument(mode); + return _inlinesFromNodes(document.parseInline(source)); + } + BusyDocument parse({ required String filePath, required String source, diff --git a/lib/src/markdown/markdown_parser.dart b/lib/src/markdown/markdown_parser.dart index 70260078..3e609f5b 100644 --- a/lib/src/markdown/markdown_parser.dart +++ b/lib/src/markdown/markdown_parser.dart @@ -26,6 +26,16 @@ const int _backgroundParseThresholdBytes = 64 * 1024; class MarkdownParser { const MarkdownParser(); + List parseInlineFragment({ + required String source, + MarkdownMode mode = MarkdownMode.commonMark, + }) { + return const MarkdownAstAdapter().parseInlineFragment( + source: source, + mode: mode, + ); + } + Future parseAsync({ required String filePath, required String source, diff --git a/lib/src/search/search_replace_service.dart b/lib/src/search/search_replace_service.dart index 09e28fdf..109fd150 100644 --- a/lib/src/search/search_replace_service.dart +++ b/lib/src/search/search_replace_service.dart @@ -1,4 +1,6 @@ +import 'dart:async'; import 'dart:io'; +import 'dart:isolate'; import 'package:path/path.dart' as p; @@ -31,6 +33,7 @@ class TextReplacementPreview { required this.replacement, required this.matches, this.invalidRegex = false, + this.truncated = false, }); final String source; @@ -38,16 +41,28 @@ class TextReplacementPreview { final String replacement; final List matches; final bool invalidRegex; + final bool truncated; String apply({Set? selectedMatchIds}) { - var result = source; - for (final match in matches.reversed) { + if (matches.isEmpty) { + return source; + } + final output = StringBuffer(); + var sourceOffset = 0; + for (final match in matches) { if (selectedMatchIds != null && !selectedMatchIds.contains(match.id)) { continue; } - result = result.replaceRange(match.start, match.end, match.replacement); + output + ..write(source.substring(sourceOffset, match.start)) + ..write(match.replacement); + sourceOffset = match.end; + } + if (sourceOffset == 0) { + return source; } - return result; + output.write(source.substring(sourceOffset)); + return output.toString(); } } @@ -149,9 +164,11 @@ class SearchReplacementService { required String replacement, String idPrefix = 'match', }) { + final matchLimit = maximumMatches.clamp(0, 0x7ffffffe).toInt(); final search = searchSourceDocument( SourceDocument(fullText: source), options, + maximumMatches: matchLimit + 1, ); if (search.invalidRegex) { return TextReplacementPreview( @@ -171,7 +188,7 @@ class SearchReplacementService { ); } final matches = []; - for (final (index, match) in search.matches.indexed) { + for (final (index, match) in search.matches.take(matchLimit).indexed) { final original = source.substring(match.fullStart, match.fullEnd); var renderedReplacement = replacement; if (expression != null) { @@ -198,6 +215,66 @@ class SearchReplacementService { options: options, replacement: replacement, matches: List.unmodifiable(matches), + truncated: search.totalMatchCount > matchLimit, + ); + } + + TextReplacementPreview previewMatch({ + required String source, + required SourceSearchOptions options, + required String replacement, + required int start, + required int end, + }) { + if (start < 0 || end <= start || end > source.length) { + return TextReplacementPreview( + source: source, + options: options, + replacement: replacement, + matches: const [], + ); + } + var renderedReplacement = replacement; + if (options.regex) { + try { + final expression = RegExp( + options.query, + caseSensitive: options.caseSensitive, + multiLine: true, + ); + final match = expression.matchAsPrefix(source, start); + if (match is! RegExpMatch || match.end != end) { + return TextReplacementPreview( + source: source, + options: options, + replacement: replacement, + matches: const [], + ); + } + renderedReplacement = _expandRegexReplacement(replacement, match); + } on FormatException { + return TextReplacementPreview( + source: source, + options: options, + replacement: replacement, + matches: const [], + invalidRegex: true, + ); + } + } + return TextReplacementPreview( + source: source, + options: options, + replacement: replacement, + matches: [ + TextReplacementMatch( + id: 'match:0:$start:$end', + start: start, + end: end, + original: source.substring(start, end), + replacement: renderedReplacement, + ), + ], ); } @@ -573,6 +650,175 @@ class SearchReplacementService { } } +/// Plans Source-view replacements away from Flutter's UI isolate. +/// +/// A newer request immediately invalidates and terminates the preceding one, +/// matching the cancellation contract used by interactive Source search. +class SearchReplacementWorker { + Isolate? _isolate; + ReceivePort? _receivePort; + Completer? _completer; + var _generation = 0; + + Future previewText({ + required String source, + required SourceSearchOptions options, + required String replacement, + int maximumMatches = 5000, + int? targetStart, + int? targetEnd, + }) { + cancel(); + final generation = ++_generation; + final receivePort = ReceivePort(); + final completer = Completer(); + _receivePort = receivePort; + _completer = completer; + receivePort.listen((message) { + if (generation != _generation || completer.isCompleted) { + return; + } + if (message is Map) { + completer.complete( + _decodeReplacementPreview( + message, + source: source, + options: options, + replacement: replacement, + ), + ); + } else { + completer.complete(null); + } + _releaseWorker(kill: true); + }); + final request = { + 'source': source, + 'query': options.query, + 'caseSensitive': options.caseSensitive, + 'wholeWord': options.wholeWord, + 'regex': options.regex, + 'replacement': replacement, + 'maximumMatches': maximumMatches, + 'targetStart': targetStart, + 'targetEnd': targetEnd, + }; + Isolate.spawn>( + _replacementWorkerMain, + [receivePort.sendPort, request], + debugName: 'BusyMark source replacement', + onError: receivePort.sendPort, + onExit: receivePort.sendPort, + ) + .then((isolate) { + if (generation != _generation || completer.isCompleted) { + isolate.kill(priority: Isolate.immediate); + } else { + _isolate = isolate; + } + }) + .catchError((Object _) { + if (generation == _generation && !completer.isCompleted) { + completer.complete(null); + _releaseWorker(kill: false); + } + }); + return completer.future; + } + + void cancel() { + _generation++; + final completer = _completer; + if (completer != null && !completer.isCompleted) { + completer.complete(null); + } + _releaseWorker(kill: true); + } + + void dispose() => cancel(); + + void _releaseWorker({required bool kill}) { + if (kill) { + _isolate?.kill(priority: Isolate.immediate); + } + _isolate = null; + _receivePort?.close(); + _receivePort = null; + _completer = null; + } +} + +void _replacementWorkerMain(List payload) { + final sendPort = payload[0] as SendPort; + final request = payload[1] as Map; + final options = SourceSearchOptions( + query: request['query']! as String, + caseSensitive: request['caseSensitive']! as bool, + wholeWord: request['wholeWord']! as bool, + regex: request['regex']! as bool, + ); + final service = SearchReplacementService( + maximumMatches: request['maximumMatches']! as int, + ); + final source = request['source']! as String; + final replacement = request['replacement']! as String; + final targetStart = request['targetStart'] as int?; + final targetEnd = request['targetEnd'] as int?; + final preview = targetStart != null && targetEnd != null + ? service.previewMatch( + source: source, + options: options, + replacement: replacement, + start: targetStart, + end: targetEnd, + ) + : service.previewText( + source: source, + options: options, + replacement: replacement, + ); + sendPort.send({ + 'invalidRegex': preview.invalidRegex, + 'truncated': preview.truncated, + 'matches': [ + for (final match in preview.matches) + [ + match.id, + match.start, + match.end, + match.original, + match.replacement, + ], + ], + }); +} + +TextReplacementPreview _decodeReplacementPreview( + Map payload, { + required String source, + required SourceSearchOptions options, + required String replacement, +}) { + final encodedMatches = payload['matches']! as List; + return TextReplacementPreview( + source: source, + options: options, + replacement: replacement, + matches: List.unmodifiable([ + for (final item in encodedMatches.cast>()) + TextReplacementMatch( + id: item[0]! as String, + start: item[1]! as int, + end: item[2]! as int, + original: item[3]! as String, + replacement: item[4]! as String, + ), + ]), + invalidRegex: payload['invalidRegex']! as bool, + truncated: payload['truncated']! as bool, + ); +} + class _WorkspaceReplacementOperation { const _WorkspaceReplacementOperation({ required this.file, diff --git a/lib/src/visualization/d2_renderer.dart b/lib/src/visualization/d2_renderer.dart index 4ef78d80..f955aeae 100644 --- a/lib/src/visualization/d2_renderer.dart +++ b/lib/src/visualization/d2_renderer.dart @@ -446,6 +446,7 @@ class D2VisualizationRenderer implements VisualizationRenderer { pngBytes: png, width: rasterSize.pixelWidth, height: rasterSize.pixelHeight, + pixelRatio: rasterSize.scale, ); } return SvgVisualizationResult( diff --git a/lib/src/visualization/visualization_cache.dart b/lib/src/visualization/visualization_cache.dart index 8eccc9db..181ad223 100644 --- a/lib/src/visualization/visualization_cache.dart +++ b/lib/src/visualization/visualization_cache.dart @@ -181,6 +181,7 @@ Map _encodeResult( 'png': base64Encode(result.pngBytes), 'width': result.width, 'height': result.height, + 'pixelRatio': result.pixelRatio, }, OpenApiVisualizationResult() => { ...base, @@ -218,6 +219,7 @@ VisualizationRenderResult? _decodeResult(Map json) { pngBytes: Uint8List.fromList(base64Decode(json['png']! as String)), width: (json['width'] as num?)?.toInt() ?? 1, height: (json['height'] as num?)?.toInt() ?? 1, + pixelRatio: (json['pixelRatio'] as num?)?.toDouble() ?? 1, diagnostics: diagnostics, ), 'openapi' diff --git a/lib/src/visualization/visualization_card.dart b/lib/src/visualization/visualization_card.dart index 64025ff5..217a7382 100644 --- a/lib/src/visualization/visualization_card.dart +++ b/lib/src/visualization/visualization_card.dart @@ -62,6 +62,7 @@ class _BusyMarkVisualizationCardState static const _editDebounce = Duration(milliseconds: 260); final _transformationController = TransformationController(); + final _diagramViewportKey = GlobalKey<_DiagramViewportState>(); late final VisualizationCoordinator _coordinator; Timer? _debounce; VisualizationRenderResult? _successfulResult; @@ -303,8 +304,7 @@ class _BusyMarkVisualizationCardState tooltip: context.l10n.visualizationFitWidth, icon: BusyMarkGlyphs.fitWidth, foregroundColor: colors.mutedForeground, - onPressed: () => - _transformationController.value = Matrix4.identity(), + onPressed: () => _diagramViewportKey.currentState?.fitWidth(), ), BusyMarkHeaderIconButton( tooltip: context.l10n.fullScreen, @@ -369,6 +369,7 @@ class _BusyMarkVisualizationCardState if (result is SvgVisualizationResult || result is RasterVisualizationResult) { return _DiagramViewport( + key: _diagramViewportKey, result: result!, transformationController: _transformationController, ); @@ -697,8 +698,9 @@ class _FullScreenDiagramState extends State<_FullScreenDiagram> { } } -class _DiagramViewport extends StatelessWidget { +class _DiagramViewport extends StatefulWidget { const _DiagramViewport({ + super.key, required this.result, required this.transformationController, this.maximumHeight = 520, @@ -708,14 +710,31 @@ class _DiagramViewport extends StatelessWidget { final TransformationController transformationController; final double maximumHeight; + @override + State<_DiagramViewport> createState() => _DiagramViewportState(); +} + +class _DiagramViewportState extends State<_DiagramViewport> { + var _fitWidthScale = 1.0; + + void fitWidth() { + widget.transformationController.value = Matrix4.diagonal3Values( + _fitWidthScale, + _fitWidthScale, + 1, + ); + } + @override Widget build(BuildContext context) { - final (width, height) = switch (result) { + final (width, height) = switch (widget.result) { SvgVisualizationResult(:final width, :final height) => (width, height), - RasterVisualizationResult(:final width, :final height) => ( - width.toDouble(), - height.toDouble(), - ), + RasterVisualizationResult( + :final width, + :final height, + :final pixelRatio, + ) => + (width / pixelRatio, height / pixelRatio), _ => (1.0, 1.0), }; return LayoutBuilder( @@ -723,35 +742,65 @@ class _DiagramViewport extends StatelessWidget { final availableWidth = constraints.maxWidth.isFinite ? constraints.maxWidth : BusyMarkSizes.documentContentWidth; - final naturalHeight = availableWidth * height / width; - final viewportHeight = maximumHeight.isFinite - ? naturalHeight.clamp(160.0, maximumHeight) - : constraints.maxHeight; + final maximumViewportHeight = math.max( + 1.0, + widget.maximumHeight.isFinite + ? widget.maximumHeight + : constraints.maxHeight.isFinite + ? constraints.maxHeight + : 600.0, + ); + final fitScale = math.min( + availableWidth / width, + maximumViewportHeight / height, + ); + // Preserve at least the renderer's logical 1:1 scale. Wide and tall + // diagrams remain readable and can be panned; compact diagrams still + // grow to make good use of the card. + final displayScale = math.max(1.0, fitScale); + final displayWidth = width * displayScale; + final displayHeight = height * displayScale; + final minimumViewportHeight = math.min(160.0, maximumViewportHeight); + final viewportHeight = displayHeight.clamp( + minimumViewportHeight, + maximumViewportHeight, + ); + final contentWidth = math.max(availableWidth, displayWidth); + final contentHeight = math.max(viewportHeight, displayHeight); + _fitWidthScale = math.min(1.0, availableWidth / contentWidth); return SizedBox( width: availableWidth, - height: viewportHeight.isFinite ? viewportHeight : 600, + height: viewportHeight, child: InteractiveViewer( - transformationController: transformationController, - minScale: 0.5, + transformationController: widget.transformationController, + constrained: false, + alignment: Alignment.topLeft, + minScale: math.min(0.5, _fitWidthScale), maxScale: 8, boundaryMargin: const EdgeInsets.all(BusyMarkSpacing.xxl), - child: Center( - child: FittedBox( - fit: BoxFit.contain, + child: SizedBox( + width: contentWidth, + height: contentHeight, + child: Center( child: SizedBox( - width: width, - height: height, - child: switch (result) { + width: displayWidth, + height: displayHeight, + child: switch (widget.result) { SvgVisualizationResult(:final svg) => SvgPicture.string( svg, fit: BoxFit.contain, semanticsLabel: context.l10n.image, ), - RasterVisualizationResult(:final pngBytes) => Image.memory( - pngBytes, - fit: BoxFit.contain, - filterQuality: FilterQuality.high, - ), + RasterVisualizationResult( + :final pngBytes, + :final pixelRatio, + ) => + Image.memory( + pngBytes, + scale: pixelRatio, + fit: BoxFit.contain, + filterQuality: FilterQuality.high, + ), _ => const SizedBox.shrink(), }, ), diff --git a/lib/src/visualization/visualization_models.dart b/lib/src/visualization/visualization_models.dart index d27da178..0b9883a8 100644 --- a/lib/src/visualization/visualization_models.dart +++ b/lib/src/visualization/visualization_models.dart @@ -4,6 +4,9 @@ import 'package:crypto/crypto.dart'; import 'package:flutter/foundation.dart'; const visualizationSanitizerVersion = '4'; +// Increment whenever BusyMark's rendering pipeline changes cached image bytes +// without changing the bundled third-party engine version. +const visualizationRenderPipelineVersion = '2'; const mermaidEngineVersion = '11.16.1'; const plantUmlEngineVersion = '1.2026.6'; const d2EngineVersion = '0.7.1'; @@ -154,6 +157,7 @@ class VisualizationRenderRequest { 'profile': profile.name, 'options': options.canonicalValues, 'sanitizerVersion': visualizationSanitizerVersion, + 'renderPipelineVersion': visualizationRenderPipelineVersion, 'dependencies': [ for (final dependency in sortedDependencies) {'id': dependency.id, 'hash': dependency.hash}, @@ -279,12 +283,20 @@ class RasterVisualizationResult extends VisualizationRenderResult { required this.pngBytes, required this.width, required this.height, + this.pixelRatio = 1, super.diagnostics, - }); + }) : assert(pixelRatio > 0); final Uint8List pngBytes; final int width; final int height; + + /// Raster pixels per logical diagram unit. + /// + /// Preview rasters are normally generated at 2× so they remain sharp. The + /// viewport uses this value to avoid treating resolution pixels as layout + /// pixels when enforcing a readable initial scale. + final double pixelRatio; } @immutable diff --git a/lib/src/visualization/web_visualization_renderer.dart b/lib/src/visualization/web_visualization_renderer.dart index 85153af4..c664f927 100644 --- a/lib/src/visualization/web_visualization_renderer.dart +++ b/lib/src/visualization/web_visualization_renderer.dart @@ -208,6 +208,7 @@ class WebVisualizationRenderer implements VisualizationRenderer { pngBytes: png, width: rasterSize.pixelWidth, height: rasterSize.pixelHeight, + pixelRatio: rasterSize.scale, diagnostics: diagnostics, ); } diff --git a/lib/src/workspace/document_buffer.dart b/lib/src/workspace/document_buffer.dart index fc2ed8cb..42bab8ec 100644 --- a/lib/src/workspace/document_buffer.dart +++ b/lib/src/workspace/document_buffer.dart @@ -12,34 +12,56 @@ const Object _bufferUnset = Object(); enum DocumentDiskState { present, changed, deleted, conflict } +class DocumentHistoryState { + const DocumentHistoryState({required this.text, required this.selection}); + + final String text; + final TextSelection selection; +} + class DocumentUndoState { - const DocumentUndoState({this.undo = const [], this.redo = const []}); + const DocumentUndoState({ + this.undo = const [], + this.redo = const [], + this.activeGroup, + }); static const historyLimit = 100; - final List undo; - final List redo; + final List undo; + final List redo; + final String? activeGroup; - DocumentUndoState push(String text) => DocumentUndoState( - undo: List.unmodifiable( - [...undo, text].skip(math.max(0, undo.length + 1 - historyLimit)), - ), - redo: const [], - ); + DocumentUndoState push(DocumentHistoryState state, {String? group}) { + if (group != null && group == activeGroup && undo.isNotEmpty) { + return DocumentUndoState(undo: undo, redo: const [], activeGroup: group); + } + return DocumentUndoState( + undo: List.unmodifiable( + [...undo, state].skip(math.max(0, undo.length + 1 - historyLimit)), + ), + redo: const [], + activeGroup: group, + ); + } - DocumentUndoState afterUndo(String currentText) => DocumentUndoState( - undo: List.unmodifiable(undo.take(undo.length - 1)), - redo: List.unmodifiable( - [...redo, currentText].skip(math.max(0, redo.length + 1 - historyLimit)), - ), - ); + DocumentUndoState afterUndo(DocumentHistoryState current) => + DocumentUndoState( + undo: List.unmodifiable(undo.take(undo.length - 1)), + redo: List.unmodifiable( + [...redo, current].skip(math.max(0, redo.length + 1 - historyLimit)), + ), + activeGroup: null, + ); - DocumentUndoState afterRedo(String currentText) => DocumentUndoState( - undo: List.unmodifiable( - [...undo, currentText].skip(math.max(0, undo.length + 1 - historyLimit)), - ), - redo: List.unmodifiable(redo.take(redo.length - 1)), - ); + DocumentUndoState afterRedo(DocumentHistoryState current) => + DocumentUndoState( + undo: List.unmodifiable( + [...undo, current].skip(math.max(0, undo.length + 1 - historyLimit)), + ), + redo: List.unmodifiable(redo.take(redo.length - 1)), + activeGroup: null, + ); } class DocumentEditorState { @@ -50,7 +72,6 @@ class DocumentEditorState { this.foldedRegionKeys = const {}, this.searchOptions = const SourceSearchOptions(), this.searchReplacement = '', - this.searchCurrentMatchIndex, this.undoState = const DocumentUndoState(), this.wysiwygState = const WysiwygEditorSessionState(), }); @@ -61,7 +82,6 @@ class DocumentEditorState { final Set foldedRegionKeys; final SourceSearchOptions searchOptions; final String searchReplacement; - final int? searchCurrentMatchIndex; final DocumentUndoState undoState; final WysiwygEditorSessionState wysiwygState; @@ -72,7 +92,6 @@ class DocumentEditorState { Set? foldedRegionKeys, SourceSearchOptions? searchOptions, String? searchReplacement, - Object? searchCurrentMatchIndex = _bufferUnset, DocumentUndoState? undoState, WysiwygEditorSessionState? wysiwygState, }) { @@ -85,9 +104,6 @@ class DocumentEditorState { ), searchOptions: searchOptions ?? this.searchOptions, searchReplacement: searchReplacement ?? this.searchReplacement, - searchCurrentMatchIndex: identical(searchCurrentMatchIndex, _bufferUnset) - ? this.searchCurrentMatchIndex - : searchCurrentMatchIndex as int?, undoState: undoState ?? this.undoState, wysiwygState: wysiwygState ?? this.wysiwygState, ); @@ -104,7 +120,6 @@ class DocumentEditorState { 'searchWholeWord': searchOptions.wholeWord, 'searchRegex': searchOptions.regex, 'searchReplacement': searchReplacement, - 'searchCurrentMatchIndex': searchCurrentMatchIndex, 'wysiwygState': wysiwygState.toJson(), }; @@ -131,8 +146,6 @@ class DocumentEditorState { regex: json['searchRegex'] as bool? ?? false, ), searchReplacement: json['searchReplacement']?.toString() ?? '', - searchCurrentMatchIndex: (json['searchCurrentMatchIndex'] as num?) - ?.toInt(), wysiwygState: WysiwygEditorSessionState.fromJson( (json['wysiwygState'] as Map?)?.cast() ?? const {}, ), @@ -216,17 +229,34 @@ class DocumentBuffer { String get identity => filePath ?? id; String get displayName => untitledName ?? filePath?.split('/').last ?? id; - DocumentBuffer edited(String nextText) { + DocumentBuffer edited( + String nextText, { + String? undoGroup, + TextSelection? previousSelection, + TextSelection? nextSelection, + }) { if (nextText == text) { return this; } + final historicalSelection = _selectionForText( + previousSelection ?? editorState.selection, + text, + ); + final updatedSelection = _selectionForText( + nextSelection ?? editorState.selection, + nextText, + ); return copyWith( text: nextText, dirty: nextText != lastSavedText || isUntitled, format: format.copyWith(hasFinalNewline: nextText.endsWith('\n')), revision: revision + 1, editorState: editorState.copyWith( - undoState: editorState.undoState.push(text), + selection: updatedSelection, + undoState: editorState.undoState.push( + DocumentHistoryState(text: text, selection: historicalSelection), + group: undoGroup, + ), ), ); } @@ -274,3 +304,13 @@ class DocumentBuffer { ); } } + +TextSelection _selectionForText(TextSelection selection, String text) { + if (!selection.isValid) { + return TextSelection.collapsed(offset: text.length); + } + return selection.copyWith( + baseOffset: selection.baseOffset.clamp(0, text.length).toInt(), + extentOffset: selection.extentOffset.clamp(0, text.length).toInt(), + ); +} diff --git a/lib/src/workspace/presentation/workspace_screen.dart b/lib/src/workspace/presentation/workspace_screen.dart index 2070c837..3cd7f635 100644 --- a/lib/src/workspace/presentation/workspace_screen.dart +++ b/lib/src/workspace/presentation/workspace_screen.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:io'; import 'dart:math' as math; +import 'package:flutter/foundation.dart' show setEquals; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -26,6 +27,7 @@ import '../../app/busymark_toast.dart'; import '../../app/command_registry.dart'; import '../../app/localization.dart'; import '../../app/window_control_service.dart'; +import '../../core/busymark_exception.dart'; import '../../core/diagnostic.dart'; import '../../core/diagnostic_localizations.dart'; import '../../core/path_utils.dart' @@ -159,15 +161,32 @@ class _WorkspaceSearchController extends Notifier<_WorkspaceSearchState> { _WorkspaceSearchState build() { _loadText = ref.read(workspaceServiceProvider).loadText; ref.listen(workspaceControllerProvider, (previous, next) { - if (previous?.activeBufferId != next.activeBufferId) { + final activeBufferChanged = + previous?.activeBufferId != next.activeBufferId; + var shouldRefresh = _workspaceSearchInputsChanged(previous, next); + if (activeBufferChanged) { final options = next.activeBuffer?.editorState.searchOptions; if (options != null) { state = state .withOptions(options) .copyWith(matches: const [], searching: false); } + } else { + final previousOptions = + previous?.activeBuffer?.editorState.searchOptions; + final nextOptions = next.activeBuffer?.editorState.searchOptions; + if (nextOptions != null && + previousOptions != nextOptions && + !_sameSourceSearchOptions(state.options, nextOptions)) { + state = state + .withOptions(nextOptions) + .copyWith(matches: const [], searching: false); + shouldRefresh = true; + } + } + if (shouldRefresh) { + refresh(next); } - refresh(next); }); ref.onDispose(() { _disposed = true; @@ -418,6 +437,56 @@ bool _sameSourceSearchOptions( first.regex == second.regex; } +bool _workspaceSearchInputsChanged( + WorkspaceState? previous, + WorkspaceState next, +) { + if (previous == null || + previous.activeBufferId != next.activeBufferId || + previous.activeText != next.activeText) { + return true; + } + final previousWorkspace = previous.workspace; + final nextWorkspace = next.workspace; + if (identical(previousWorkspace, nextWorkspace)) { + return false; + } + if (previousWorkspace == null || nextWorkspace == null) { + return previousWorkspace != nextWorkspace; + } + final previousActivePath = + previousWorkspace.activeFilePath ?? previousWorkspace.markdown?.filePath; + final nextActivePath = + nextWorkspace.activeFilePath ?? nextWorkspace.markdown?.filePath; + return previousActivePath != nextActivePath || + !_sameWorkspaceSearchFiles(previousWorkspace.files, nextWorkspace.files); +} + +bool _sameWorkspaceSearchFiles( + List first, + List second, +) { + final firstSearchable = first.where(_isOpenableTextDocument).toList() + ..sort((left, right) => left.absolutePath.compareTo(right.absolutePath)); + final secondSearchable = second.where(_isOpenableTextDocument).toList() + ..sort((left, right) => left.absolutePath.compareTo(right.absolutePath)); + if (firstSearchable.length != secondSearchable.length) { + return false; + } + for (var index = 0; index < firstSearchable.length; index++) { + final left = firstSearchable[index]; + final right = secondSearchable[index]; + if (left.absolutePath != right.absolutePath || + left.relativePath != right.relativePath || + left.kind != right.kind || + left.size != right.size || + left.lastModified != right.lastModified) { + return false; + } + } + return true; +} + class _SearchNavigationTarget { const _SearchNavigationTarget({ required this.filePath, @@ -9518,6 +9587,7 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { String? _wysiwygScrollHeadingId; String? _wysiwygScrollBlockId; String? _wysiwygSearchQuery; + BusyMarkWysiwygSourceRange? _wysiwygSearchRange; var _wysiwygScrollRequest = 0; @override @@ -9548,26 +9618,9 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { _wysiwygScrollHeadingId = null; _wysiwygScrollBlockId = null; _wysiwygSearchQuery = null; + _wysiwygSearchRange = null; _wysiwygScrollRequest = 0; } - if (oldWidget.viewMode == DocumentViewModePreference.editor && - widget.viewMode != DocumentViewModePreference.editor && - widget.state.workspace != null && - widget.state.isDirty) { - final workspaceId = widget.state.workspace!.id; - final sourceFilePath = _activeEditorPath(); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted || - widget.viewMode == DocumentViewModePreference.editor || - widget.state.workspace?.id != workspaceId || - _activeEditorPath() != sourceFilePath) { - return; - } - ref - .read(workspaceControllerProvider.notifier) - .refreshActivePreview(sourceFilePath: sourceFilePath); - }); - } } void _handlePreviewVisibleItemsChanged() { @@ -9800,6 +9853,7 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { unawaited(_showRemoteImagesPrompt(context, ref)), onDocumentChanged: _cacheWysiwygDocument, onSourceChanged: _handleWysiwygSourceChanged, + onTransactionalSourceChanged: _handleWysiwygSourceChanged, toolbarPlacement: widget.editorToolbarPlacement, toolbarDirection: widget.editorToolbarDirection, onToolbarPlacementChanged: ref @@ -9811,6 +9865,7 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { scrollToHeadingId: _wysiwygScrollHeadingId, scrollToBlockId: _wysiwygScrollBlockId, scrollToSearchQuery: _wysiwygSearchQuery, + scrollToSourceRange: _wysiwygSearchRange, scrollRequest: _wysiwygScrollRequest, onVisibleHeadingChanged: _handleWysiwygVisibleHeadingChanged, @@ -9913,7 +9968,13 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { candidate.id == activeBuffer.id, ) .firstOrNull; - if (latest == null) { + if (latest == null || + _sameSourceSession( + latest.editorState, + selection, + scrollOffset, + foldedRegionKeys, + )) { return; } ref @@ -9935,21 +9996,40 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { .request(), onVisibleLineChanged: _handleSourceVisibleLineChanged, onChanged: _handleSourceChanged, + onTransactionalChanged: _handleTransactionalSourceChanged, onUndo: () { final controller = ref.read( workspaceControllerProvider.notifier, ); - return controller.undoActiveBuffer() - ? ref.read(workspaceControllerProvider).activeText - : null; + if (!controller.undoActiveBuffer()) { + return null; + } + final buffer = ref + .read(workspaceControllerProvider) + .activeBuffer; + return buffer == null + ? null + : TextEditingValue( + text: buffer.text, + selection: buffer.editorState.selection, + ); }, onRedo: () { final controller = ref.read( workspaceControllerProvider.notifier, ); - return controller.redoActiveBuffer() - ? ref.read(workspaceControllerProvider).activeText - : null; + if (!controller.redoActiveBuffer()) { + return null; + } + final buffer = ref + .read(workspaceControllerProvider) + .activeBuffer; + return buffer == null + ? null + : TextEditingValue( + text: buffer.text, + selection: buffer.editorState.selection, + ); }, editRevision: ref .read(workspaceControllerProvider.notifier) @@ -10119,7 +10199,34 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { .updateActiveText(value, sourceFilePath: sourceFilePath ?? activePath); } - void _handleWysiwygSourceChanged(String filePath, String value) { + void _handleTransactionalSourceChanged( + String value, + String? sourceFilePath, + TextSelection previousSelection, + TextSelection selection, + String? undoGroup, + ) { + final activePath = _activeEditorPath(); + if (sourceFilePath != null && sourceFilePath != activePath) { + return; + } + _clearWysiwygCache(); + ref + .read(workspaceControllerProvider.notifier) + .updateActiveSourceText( + value, + sourceFilePath: sourceFilePath ?? activePath, + previousSelection: previousSelection, + selection: selection, + undoGroup: undoGroup, + ); + } + + void _handleWysiwygSourceChanged( + String filePath, + String value, [ + String? undoGroup, + ]) { if (filePath != _activeEditorPath()) { return; } @@ -10135,6 +10242,7 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { value, document: document, sourceFilePath: filePath, + undoGroup: undoGroup, ); } @@ -10201,6 +10309,7 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { _wysiwygScrollHeadingId = target.headingId; _wysiwygScrollBlockId = target.editorBlockId; _wysiwygSearchQuery = null; + _wysiwygSearchRange = null; _wysiwygScrollRequest += 1; }); if (target.line case final line?) { @@ -10232,7 +10341,11 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { setState(() { _wysiwygScrollHeadingId = null; _wysiwygScrollBlockId = null; - _wysiwygSearchQuery = target.query; + _wysiwygSearchQuery = null; + _wysiwygSearchRange = BusyMarkWysiwygSourceRange( + startOffset: target.startOffset, + endOffset: target.endOffset, + ); _wysiwygScrollRequest += 1; }); } @@ -10568,6 +10681,7 @@ bool _sameWysiwygSession( WysiwygEditorSessionState right, ) { return left.activeBlockId == right.activeBlockId && + left.activeCellId == right.activeCellId && left.anchorBlockId == right.anchorBlockId && left.anchorOffset == right.anchorOffset && left.extentBlockId == right.extentBlockId && @@ -10576,6 +10690,17 @@ bool _sameWysiwygSession( (left.viewportAlignment - right.viewportAlignment).abs() < 0.001; } +bool _sameSourceSession( + DocumentEditorState current, + TextSelection selection, + double scrollOffset, + Set foldedRegionKeys, +) { + return current.selection == selection && + (current.scrollOffset - scrollOffset).abs() < 0.01 && + setEquals(current.foldedRegionKeys, foldedRegionKeys); +} + class _ExternalFileBanner extends StatelessWidget { const _ExternalFileBanner({ required this.buffer, @@ -11492,6 +11617,9 @@ class _PreviewTable extends StatelessWidget { ? _PreviewInlineText( block: row.children[index], editRevision: editRevision, + textAlign: _previewTableCellTextAlign( + row.children[index].attributes['align'], + ), style: row.attributes['header'] == 'true' ? busyMarkDocumentBodyTextStyle( context, @@ -11514,11 +11642,13 @@ class _PreviewInlineText extends ConsumerWidget { super.key, required this.block, this.style, + this.textAlign = TextAlign.start, this.editRevision = 0, }); final PreviewBlock block; final TextStyle? style; + final TextAlign textAlign; final int editRevision; @override @@ -11568,11 +11698,21 @@ class _PreviewInlineText extends ConsumerWidget { ), ], ), + textAlign: textAlign, ), ); } } +TextAlign _previewTableCellTextAlign(String? value) { + return switch (busyTableAlignmentFromAttribute(value)) { + BusyTableAlignment.unspecified => TextAlign.start, + BusyTableAlignment.left => TextAlign.left, + BusyTableAlignment.center => TextAlign.center, + BusyTableAlignment.right => TextAlign.right, + }; +} + String _previewBlockSearchText(PreviewBlock block) { return [ block.text, @@ -11900,6 +12040,10 @@ class _PreviewImageBlock extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final width = busyMarkDocumentImageWidth(block.attributes); final source = _previewImageSource(block); + final linkDestination = _previewImageLinkDestination(block); + final followLink = linkDestination == null + ? null + : () => unawaited(_openPreviewLink(context, ref, linkDestination)); final activeFilePath = workspace?.activeFilePath ?? workspace?.markdown?.filePath; final settings = ref.watch(appSettingsControllerProvider); @@ -11914,19 +12058,24 @@ class _PreviewImageBlock extends ConsumerWidget { ), child: Align( alignment: AlignmentDirectional.centerStart, - child: MarkdownImageView( - source: source, - alt: block.text, - activeFilePath: activeFilePath ?? '', - workspaceRoot: _imageWorkspaceRoot(workspace), - writersideRoot: workspace?.writersideModule?.rootPath, - imagesDir: - workspace?.writersideModule?.effectiveImagesDir ?? 'images', - allowRemoteImages: allowRemoteImages, - onRemoteImageBlocked: () => - unawaited(_showRemoteImagesPrompt(context, ref)), - width: width, - maxWidth: width ?? BusyMarkSizes.documentImageMaxWidth, + child: _previewLinkedImage( + destination: linkDestination, + onTap: followLink, + child: MarkdownImageView( + source: source, + alt: block.text, + activeFilePath: activeFilePath ?? '', + workspaceRoot: _imageWorkspaceRoot(workspace), + writersideRoot: workspace?.writersideModule?.rootPath, + imagesDir: + workspace?.writersideModule?.effectiveImagesDir ?? 'images', + allowRemoteImages: allowRemoteImages, + onRemoteImageBlocked: + followLink ?? + () => unawaited(_showRemoteImagesPrompt(context, ref)), + width: width, + maxWidth: width ?? BusyMarkSizes.documentImageMaxWidth, + ), ), ), ), @@ -12026,6 +12175,61 @@ String? _previewImageSourceFromInline(PreviewInline inline) { return null; } +String? _previewImageLinkDestination(PreviewBlock block) { + for (final inline in block.inlines) { + final destination = _previewImageLinkDestinationFromInline(inline); + if (destination != null) { + return destination; + } + } + return null; +} + +String? _previewImageLinkDestinationFromInline( + PreviewInline inline, { + String? inheritedDestination, +}) { + final destination = inline.kind == PreviewInlineKind.link + ? (inline.destination ?? inline.text).trim() + : inheritedDestination; + if (inline.kind == PreviewInlineKind.image) { + return destination == null || destination.isEmpty ? null : destination; + } + for (final child in inline.children) { + final imageDestination = _previewImageLinkDestinationFromInline( + child, + inheritedDestination: destination, + ); + if (imageDestination != null) { + return imageDestination; + } + } + return null; +} + +Widget _previewLinkedImage({ + required Widget child, + required String? destination, + required VoidCallback? onTap, +}) { + if (destination == null || destination.isEmpty || onTap == null) { + return child; + } + return Semantics( + link: true, + onTap: onTap, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + key: ValueKey('preview-linked-image-$destination'), + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: child, + ), + ), + ); +} + InlineSpan _previewInlineSpan( BuildContext context, PreviewInline inline, { @@ -12201,6 +12405,8 @@ InlineSpan _previewInlineSpan( workspace, allowRemoteImages: allowRemoteImages, onRemoteImageBlocked: onRemoteImageBlocked, + linkDestination: linkDestination, + onLinkTap: onLinkTap, style: mergeStyle( TextStyle(color: colors.mutedForeground, fontStyle: FontStyle.italic), ), @@ -12260,10 +12466,16 @@ InlineSpan _previewInlineImageSpan( Workspace? workspace, { required bool allowRemoteImages, required VoidCallback? onRemoteImageBlocked, + required String? linkDestination, + required Future Function(String destination) onLinkTap, required TextStyle? style, }) { final activeFilePath = workspace?.activeFilePath ?? workspace?.markdown?.filePath; + final destination = linkDestination?.trim(); + final followLink = destination == null || destination.isEmpty + ? null + : () => unawaited(onLinkTap(destination)); return WidgetSpan( alignment: PlaceholderAlignment.middle, style: const TextStyle( @@ -12274,19 +12486,23 @@ InlineSpan _previewInlineImageSpan( padding: const EdgeInsets.symmetric(horizontal: BusyMarkSpacing.xs), child: DefaultTextStyle.merge( style: style, - child: MarkdownImageView( - source: inline.destination ?? '', - alt: inline.text, - activeFilePath: activeFilePath ?? '', - workspaceRoot: _imageWorkspaceRoot(workspace), - writersideRoot: workspace?.writersideModule?.rootPath, - imagesDir: - workspace?.writersideModule?.effectiveImagesDir ?? 'images', - allowRemoteImages: allowRemoteImages, - onRemoteImageBlocked: onRemoteImageBlocked, - maxWidth: BusyMarkSizes.previewMinWidth, - maxHeight: BusyMarkSizes.previewInlineImageMaxHeight, - height: BusyMarkSizes.previewInlineImageHeight, + child: _previewLinkedImage( + destination: destination, + onTap: followLink, + child: MarkdownImageView( + source: inline.destination ?? '', + alt: inline.text, + activeFilePath: activeFilePath ?? '', + workspaceRoot: _imageWorkspaceRoot(workspace), + writersideRoot: workspace?.writersideModule?.rootPath, + imagesDir: + workspace?.writersideModule?.effectiveImagesDir ?? 'images', + allowRemoteImages: allowRemoteImages, + onRemoteImageBlocked: followLink ?? onRemoteImageBlocked, + maxWidth: BusyMarkSizes.previewMinWidth, + maxHeight: BusyMarkSizes.previewInlineImageMaxHeight, + height: BusyMarkSizes.previewInlineImageHeight, + ), ), ), ), @@ -12409,9 +12625,28 @@ Future _openPreviewLink( final resolvedPath = p.normalize( p.join(p.dirname(activeFilePath), targetPath), ); - final file = workspace.files + var file = workspace.files .where((file) => p.normalize(file.absolutePath) == resolvedPath) .firstOrNull; + if (file == null) { + try { + file = await ref + .read(workspaceServiceProvider) + .resolveWorkspaceDocument(workspace, resolvedPath); + } on BusyMarkException { + // Paths outside the workspace safety boundary are unavailable links. + } on FileSystemException { + // Missing and unreadable paths are unavailable links. + } + if (!context.mounted) { + return; + } + final currentWorkspace = ref.read(workspaceControllerProvider).workspace; + if (currentWorkspace?.id != workspace.id || + currentWorkspace?.activeFilePath != activeFilePath) { + return; + } + } if (file == null) { if (context.mounted) { _showPreviewLinkMessage( @@ -12427,10 +12662,13 @@ Future _openPreviewLink( } return; } - if (workspace.activeFilePath != file.absolutePath) { - await ref + if (!p.equals(activeFilePath, file.absolutePath)) { + final opened = await ref .read(workspaceControllerProvider.notifier) .openActiveFile(file.absolutePath); + if (!opened) { + return; + } _clearGitDetailSelection(ref); } if (!context.mounted) { diff --git a/lib/src/workspace/workspace_controller.dart b/lib/src/workspace/workspace_controller.dart index 9ed20143..88d0d1a5 100644 --- a/lib/src/workspace/workspace_controller.dart +++ b/lib/src/workspace/workspace_controller.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:io'; import 'dart:math' as math; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:path/path.dart' as p; @@ -161,6 +162,8 @@ class WorkspaceController extends Notifier { var _derivedRefreshRunning = false; var _derivedRefreshPending = false; var _pendingPreviewRefresh = false; + var _pendingOutlineRefresh = false; + _ActivePreviewRevision? _activePreviewRevision; var _editRevision = 0; var _activeDocumentRevision = 0; var _untitledSequence = 0; @@ -373,6 +376,7 @@ class WorkspaceController extends Notifier { ) : null, ); + _recordActivePreviewRevision(); _editRevision = active.revision; await _startMonitoring(reparsed); await _settingsController.setDocumentViewMode(active.editorState.mode); @@ -677,6 +681,7 @@ class WorkspaceController extends Notifier { workspace: reparsed, preview: _safePreview(reparsed, disk.text), ); + _recordActivePreviewRevision(); } } } on FileSystemException { @@ -763,6 +768,7 @@ class WorkspaceController extends Notifier { ), preview: _safePreview(reparsed, remapped.text), ); + _recordActivePreviewRevision(); } } _schedulePersistence(); @@ -816,6 +822,7 @@ class WorkspaceController extends Notifier { workspace: workspace, preview: _safePreview(workspace, disk.text), ); + _recordActivePreviewRevision(); } return true; } @@ -910,6 +917,7 @@ class WorkspaceController extends Notifier { activeBufferId: buffer.id, isLoading: false, ); + _recordActivePreviewRevision(); await _startMonitoring(workspace); _schedulePersistence(); await viewModeChange; @@ -949,6 +957,7 @@ class WorkspaceController extends Notifier { documentBuffers: buffer == null ? const [] : [buffer], activeBufferId: buffer?.id, ); + _recordActivePreviewRevision(); await _startMonitoring(loadedWorkspace); _schedulePersistence(); _resetSaveTracking(); @@ -1014,6 +1023,7 @@ class WorkspaceController extends Notifier { documentBuffers: buffer == null ? const [] : [buffer], activeBufferId: buffer?.id, ); + _recordActivePreviewRevision(); await _startMonitoring(loadedWorkspace); _schedulePersistence(); _resetSaveTracking(); @@ -1105,6 +1115,7 @@ class WorkspaceController extends Notifier { documentBuffers: buffers, activeBufferId: buffer?.id, ); + _recordActivePreviewRevision(); await _startMonitoring(tabbedWorkspace); _schedulePersistence(); _resetSaveTracking(); @@ -1597,6 +1608,7 @@ class WorkspaceController extends Notifier { activeBufferId: null, clearMessage: true, ); + _recordActivePreviewRevision(); _fileMonitor.updateOpenFilePaths(const []); _schedulePersistence(); } @@ -1651,6 +1663,7 @@ class WorkspaceController extends Notifier { activeBufferId: buffer.id, clearMessage: true, ); + _recordActivePreviewRevision(); _fileMonitor.updateOpenFilePaths( [ ...buffers, @@ -1703,6 +1716,7 @@ class WorkspaceController extends Notifier { activeBufferId: buffer.id, clearMessage: true, ); + _recordActivePreviewRevision(); _fileMonitor.updateOpenFilePaths( documentBuffers .map((candidate) => candidate.filePath) @@ -1769,6 +1783,9 @@ class WorkspaceController extends Notifier { return; } updateActiveEditorState(buffer.editorState.copyWith(mode: mode)); + if (_modeShowsPreview(mode) && _activePreviewIsStale) { + _requestDerivedRefresh(rebuildPreview: true); + } } bool undoActiveBuffer() { @@ -1777,20 +1794,30 @@ class WorkspaceController extends Notifier { return false; } final undo = buffer.editorState.undoState; - final text = undo.undo.last; + final target = undo.undo.last; + final current = DocumentHistoryState( + text: buffer.text, + selection: buffer.editorState.selection, + ); final next = buffer.copyWith( - text: text, - dirty: text != buffer.lastSavedText || buffer.isUntitled, - format: buffer.format.copyWith(hasFinalNewline: text.endsWith('\n')), + text: target.text, + dirty: target.text != buffer.lastSavedText || buffer.isUntitled, + format: buffer.format.copyWith( + hasFinalNewline: target.text.endsWith('\n'), + ), revision: buffer.revision + 1, editorState: buffer.editorState.copyWith( - undoState: undo.afterUndo(buffer.text), + selection: target.selection, + undoState: undo.afterUndo(current), ), ); state = state.copyWith( documentBuffers: _replaceBuffer(state.documentBuffers, next), ); - _requestDerivedRefresh(rebuildPreview: true); + _requestDerivedRefresh( + rebuildPreview: _activeModeShowsPreview, + refreshOutline: !_activeModeShowsPreview, + ); _schedulePersistence(); return true; } @@ -1801,20 +1828,30 @@ class WorkspaceController extends Notifier { return false; } final undo = buffer.editorState.undoState; - final text = undo.redo.last; + final target = undo.redo.last; + final current = DocumentHistoryState( + text: buffer.text, + selection: buffer.editorState.selection, + ); final next = buffer.copyWith( - text: text, - dirty: text != buffer.lastSavedText || buffer.isUntitled, - format: buffer.format.copyWith(hasFinalNewline: text.endsWith('\n')), + text: target.text, + dirty: target.text != buffer.lastSavedText || buffer.isUntitled, + format: buffer.format.copyWith( + hasFinalNewline: target.text.endsWith('\n'), + ), revision: buffer.revision + 1, editorState: buffer.editorState.copyWith( - undoState: undo.afterRedo(buffer.text), + selection: target.selection, + undoState: undo.afterRedo(current), ), ); state = state.copyWith( documentBuffers: _replaceBuffer(state.documentBuffers, next), ); - _requestDerivedRefresh(rebuildPreview: true); + _requestDerivedRefresh( + rebuildPreview: _activeModeShowsPreview, + refreshOutline: !_activeModeShowsPreview, + ); _schedulePersistence(); return true; } @@ -1823,7 +1860,26 @@ class WorkspaceController extends Notifier { _updateActiveText( text, sourceFilePath: sourceFilePath, - rebuildPreview: true, + rebuildPreview: + state.activeBuffer?.editorState.mode != + DocumentViewModePreference.source, + ); + } + + void updateActiveSourceText( + String text, { + String? sourceFilePath, + required TextSelection previousSelection, + required TextSelection selection, + String? undoGroup, + }) { + _updateActiveText( + text, + sourceFilePath: sourceFilePath, + rebuildPreview: _activeModeShowsPreview, + previousSelection: previousSelection, + selection: selection, + undoGroup: undoGroup, ); } @@ -1833,6 +1889,7 @@ class WorkspaceController extends Notifier { String text, { required BusyDocument document, String? sourceFilePath, + String? undoGroup, }) { _updateActiveText( text, @@ -1840,22 +1897,10 @@ class WorkspaceController extends Notifier { rebuildPreview: false, liveOutline: document.outline, preserveFinalNewline: true, + undoGroup: undoGroup, ); } - /// Rebuilds derived preview data after leaving WYSIWYG mode without creating - /// another edit revision for text that is already in state. - void refreshActivePreview({String? sourceFilePath}) { - final workspace = state.workspace; - final activeEditorPath = - workspace?.activeFilePath ?? workspace?.markdown?.filePath; - if (workspace == null || - (sourceFilePath != null && activeEditorPath != sourceFilePath)) { - return; - } - _requestDerivedRefresh(rebuildPreview: true); - } - Future selectWritersideContext({ required String moduleId, String? instanceId, @@ -1883,6 +1928,7 @@ class WorkspaceController extends Notifier { activeBufferId: null, clearMessage: true, ); + _recordActivePreviewRevision(); return true; } final existing = state.documentBuffers @@ -1921,6 +1967,7 @@ class WorkspaceController extends Notifier { activeBufferId: buffer.id, clearMessage: true, ); + _recordActivePreviewRevision(); _fileMonitor.updateOpenFilePaths( buffers.map((candidate) => candidate.filePath).whereType(), ); @@ -1943,6 +1990,9 @@ class WorkspaceController extends Notifier { String? sourceFilePath, List? liveOutline, bool preserveFinalNewline = false, + TextSelection? previousSelection, + TextSelection? selection, + String? undoGroup, }) { final workspace = state.workspace; final activeEditorPath = @@ -1957,7 +2007,12 @@ class WorkspaceController extends Notifier { final effectiveText = preserveFinalNewline ? _withFinalNewlinePolicy(text, activeBuffer.format.hasFinalNewline) : text; - final nextBuffer = activeBuffer.edited(effectiveText); + final nextBuffer = activeBuffer.edited( + effectiveText, + undoGroup: undoGroup, + previousSelection: previousSelection, + nextSelection: selection, + ); if (identical(nextBuffer, activeBuffer)) { return; } @@ -1980,16 +2035,57 @@ class WorkspaceController extends Notifier { ), ); _schedulePersistence(); - _requestDerivedRefresh(rebuildPreview: rebuildPreview); + _requestDerivedRefresh( + rebuildPreview: rebuildPreview, + refreshOutline: !rebuildPreview && liveOutline == null, + ); _scheduleAutoSave(nextBuffer.id); } - void _requestDerivedRefresh({required bool rebuildPreview}) { - if (!_settingsController.state.validateOnEdit && !rebuildPreview) { + bool get _activeModeShowsPreview { + final mode = state.activeBuffer?.editorState.mode; + return mode != null && _modeShowsPreview(mode); + } + + bool get _activePreviewIsStale { + final workspace = state.workspace; + final buffer = state.activeBuffer; + final revision = _activePreviewRevision; + return workspace == null || + buffer == null || + state.preview == null || + revision == null || + revision.workspaceId != workspace.id || + revision.bufferId != buffer.id || + revision.revision != buffer.revision; + } + + void _recordActivePreviewRevision() { + final workspace = state.workspace; + final buffer = state.activeBuffer; + if (workspace == null || buffer == null || state.preview == null) { + _activePreviewRevision = null; + return; + } + _activePreviewRevision = _ActivePreviewRevision( + workspaceId: workspace.id, + bufferId: buffer.id, + revision: buffer.revision, + ); + } + + void _requestDerivedRefresh({ + required bool rebuildPreview, + bool refreshOutline = false, + }) { + if (!_settingsController.state.validateOnEdit && + !rebuildPreview && + !refreshOutline) { return; } _derivedRefreshPending = true; _pendingPreviewRefresh = _pendingPreviewRefresh || rebuildPreview; + _pendingOutlineRefresh = _pendingOutlineRefresh || refreshOutline; if (!_derivedRefreshRunning) { unawaited(_drainDerivedRefreshes()); } @@ -2004,11 +2100,15 @@ class WorkspaceController extends Notifier { while (_derivedRefreshPending) { _derivedRefreshPending = false; final rebuildPreview = _pendingPreviewRefresh; + final refreshOutline = _pendingOutlineRefresh; _pendingPreviewRefresh = false; + _pendingOutlineRefresh = false; if (_settingsController.state.validateOnEdit) { - await validateActive(); + await _validateActive(rebuildPreview: rebuildPreview); } else if (rebuildPreview) { await _refreshActivePreview(); + } else if (refreshOutline) { + await _refreshActiveOutline(); } } } finally { @@ -2022,6 +2122,49 @@ class WorkspaceController extends Notifier { void _cancelPendingDerivedRefresh() { _derivedRefreshPending = false; _pendingPreviewRefresh = false; + _pendingOutlineRefresh = false; + } + + Future _refreshActiveOutline() async { + final workspace = state.workspace; + final buffer = state.activeBuffer; + if (workspace == null || buffer == null) { + return; + } + final workspaceId = workspace.id; + final activeFilePath = workspace.activeFilePath; + final bufferId = buffer.id; + final text = buffer.text; + final editRevision = buffer.revision; + final operationRevision = _activeDocumentRevision; + try { + final reparsed = await _service.reparseActive(workspace, text); + if (!_isCurrentActiveDocument( + operationRevision, + workspaceId: workspaceId, + activeFilePath: activeFilePath, + ) || + state.activeBuffer?.id != bufferId || + state.activeText != text || + state.activeBuffer?.revision != editRevision) { + return; + } + state = state.copyWith( + liveOutline: ActiveDocumentOutline( + workspaceId: workspaceId, + filePath: activeFilePath, + source: text, + headings: _service.activeDocumentOutline(reparsed), + ), + ); + } on Object catch (error, stackTrace) { + busyMarkDebugLogError( + '[BusyMark] Could not refresh Source outline', + error, + stackTrace, + context: {'path': busyMarkLogPath(activeFilePath ?? '')}, + ); + } } Future _refreshActivePreview() async { @@ -2050,6 +2193,7 @@ class WorkspaceController extends Notifier { // workspace (including its diagnostics and persisted document model) // must only advance through explicit validation. state = state.copyWith(preview: preview); + _recordActivePreviewRevision(); } on Object catch (error, stackTrace) { busyMarkDebugLogError( '[BusyMark] Could not refresh preview', @@ -2242,6 +2386,7 @@ class WorkspaceController extends Notifier { workspace: nextWorkspace, preview: _safePreview(nextWorkspace, text), ); + _recordActivePreviewRevision(); } on Object catch (error, stackTrace) { busyMarkDebugLogError( '[BusyMark] Document reparse after disk update failed', @@ -2489,7 +2634,7 @@ class WorkspaceController extends Notifier { if (hasNewerEdits) { if (state.activeBufferId == savedBuffer.id && _settingsController.state.validateOnEdit) { - unawaited(validateActive()); + unawaited(_validateActive(rebuildPreview: _activeModeShowsPreview)); } _scheduleAutoSave(savedBuffer.id); } else if (state.activeBufferId == savedBuffer.id) { @@ -2753,6 +2898,7 @@ class WorkspaceController extends Notifier { documentBuffers: buffers, activeBufferId: activeBuffer?.id, ); + _recordActivePreviewRevision(); _fileMonitor.updateOpenFilePaths(tabPaths); _schedulePersistence(); _resetSaveTracking(); @@ -2851,7 +2997,9 @@ class WorkspaceController extends Notifier { ); } - Future validateActive() async { + Future validateActive() => _validateActive(rebuildPreview: true); + + Future _validateActive({required bool rebuildPreview}) async { final workspace = state.workspace; if (workspace == null) { return; @@ -2875,15 +3023,30 @@ class WorkspaceController extends Notifier { return; } final currentSnapshot = currentWorkspace.activeFileSnapshot; - state = state.copyWith( - workspace: reparsed.copyWith( - activeFileSnapshot: currentSnapshot, - openFilePaths: currentWorkspace.openFilePaths, - files: currentWorkspace.files, - ), - preview: _safePreview(reparsed, text), - clearMessage: true, + final validatedWorkspace = reparsed.copyWith( + activeFileSnapshot: currentSnapshot, + openFilePaths: currentWorkspace.openFilePaths, + files: currentWorkspace.files, ); + if (rebuildPreview) { + state = state.copyWith( + workspace: validatedWorkspace, + preview: _safePreview(reparsed, text), + clearMessage: true, + ); + _recordActivePreviewRevision(); + } else { + state = state.copyWith( + workspace: validatedWorkspace, + liveOutline: ActiveDocumentOutline( + workspaceId: validatedWorkspace.id, + filePath: validatedWorkspace.activeFilePath, + source: text, + headings: _service.activeDocumentOutline(validatedWorkspace), + ), + clearMessage: true, + ); + } } on Object catch (error) { if (_isCurrentActiveDocument( operationRevision, @@ -3168,6 +3331,23 @@ List _replaceBuffer( ]); } +class _ActivePreviewRevision { + const _ActivePreviewRevision({ + required this.workspaceId, + required this.bufferId, + required this.revision, + }); + + final String workspaceId; + final String bufferId; + final int revision; +} + +bool _modeShowsPreview(DocumentViewModePreference mode) { + return mode == DocumentViewModePreference.preview || + mode == DocumentViewModePreference.split; +} + List _mergedDocumentFiles( List current, List refreshed, diff --git a/lib/src/workspace/workspace_service.dart b/lib/src/workspace/workspace_service.dart index 9e252566..4325f18d 100644 --- a/lib/src/workspace/workspace_service.dart +++ b/lib/src/workspace/workspace_service.dart @@ -10,6 +10,8 @@ import '../core/debug_log.dart'; import '../core/diagnostic.dart'; import '../core/linux_atomic_file_api.dart'; import '../core/path_utils.dart'; +import '../markdown/busymark_document.dart'; +import '../markdown/document_outline.dart'; import '../markdown/markdown_model.dart'; import '../markdown/markdown_parser.dart'; import '../markdown/preview_model.dart'; @@ -159,6 +161,31 @@ class WorkspaceService { return _openMarkdownFolder(canonicalPath); } + Future resolveWorkspaceDocument( + Workspace workspace, + String candidatePath, + ) async { + final anchor = await _workspacePathAnchor(workspace); + final resolution = await _resolveWorkspacePath( + anchor, + candidatePath, + allowRoot: false, + ); + if (resolution.type != FileSystemEntityType.file) { + return null; + } + final relativePath = p.relative(resolution.path, from: anchor.rootPath); + if (p + .split(relativePath) + .any(versionControlMetadataDirectoryNames.contains)) { + throw BusyMarkException( + 'workspace.file-operation-outside-root', + args: {'path': resolution.path}, + ); + } + return _documentFile(resolution.path, anchor.rootPath); + } + Future createWritersideProject( WritersideProjectCreateRequest request, ) async { @@ -1507,6 +1534,35 @@ class WorkspaceService { return previewBuilder.build(parsed); } + List activeDocumentOutline(Workspace workspace) { + final active = workspace.activeFilePath ?? workspace.markdown?.filePath; + if (active == null) { + return const []; + } + final markdown = workspace.markdown; + if (markdown != null && p.equals(markdown.filePath, active)) { + return List.unmodifiable([ + for (final heading in markdown.headings) + DocumentOutlineHeading.fromMarkdown(heading), + ]); + } + if (workspace.kind != WorkspaceKind.writersideModule) { + return const []; + } + final topic = workspace.writersideModule?.topics + .where((candidate) => p.equals(candidate.filePath, active)) + .firstOrNull; + if (topic == null) { + return const []; + } + return _buildWritersideDocument( + workspace, + active, + topic.document.source, + )?.outline ?? + const []; + } + /// Builds preview data without running Markdown parsing on Flutter's UI /// isolate. An already-current workspace parse is reused when available. Future buildPreviewAsync( @@ -1540,6 +1596,23 @@ class WorkspaceService { Workspace workspace, String active, String source, + ) { + final document = _buildWritersideDocument(workspace, active, source); + if (document != null) { + return const BusyMarkPreviewBuilder().build(document); + } + return PreviewDocument( + title: p.basename(active), + modeLabel: '', + compatibility: '', + blocks: [PreviewBlock(kind: PreviewBlockKind.code, text: source)], + ); + } + + BusyDocument? _buildWritersideDocument( + Workspace workspace, + String active, + String source, ) { final module = workspace.writersideModule; if (module == null) { @@ -1549,14 +1622,11 @@ class WorkspaceService { .where((item) => item.filePath == active) .firstOrNull; if (originalTopic == null) { - return PreviewDocument( - title: p.basename(active), - modeLabel: '', - compatibility: '', - blocks: [PreviewBlock(kind: PreviewBlockKind.code, text: source)], - ); + return null; } - final topic = originalTopic.format == WritersideTopicFormat.markdown + final topic = originalTopic.document.source == source + ? originalTopic + : originalTopic.format == WritersideTopicFormat.markdown ? writersideService.topicParser.parseMarkdown( filePath: active, source: source, @@ -1591,11 +1661,9 @@ class WorkspaceService { {if (module.config.moduleName case final name?) name: module}, ), ); - return const BusyMarkPreviewBuilder().build( - writersideDocumentRenderer.toBusyDocument( - resolved.document, - title: resolved.title ?? topic.title ?? topic.fileName, - ), + return writersideDocumentRenderer.toBusyDocument( + resolved.document, + title: resolved.title ?? topic.title ?? topic.fileName, ); } diff --git a/linux/io.busystack.busymark.metainfo.xml b/linux/io.busystack.busymark.metainfo.xml index 08579f31..95d6e057 100644 --- a/linux/io.busystack.busymark.metainfo.xml +++ b/linux/io.busystack.busymark.metainfo.xml @@ -87,6 +87,7 @@ https://github.com/busystack/busymark/issues + diff --git a/pubspec.yaml b/pubspec.yaml index 532571a7..024e53e7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,10 +1,11 @@ name: busymark description: Local-first Markdown and Writerside-compatible documentation editor. publish_to: 'none' -version: 0.3.3 +version: 0.3.4 environment: sdk: ^3.12.1 + flutter: 3.47.0 dependencies: flutter: diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index c850509e..a32f800b 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -1,6 +1,6 @@ name: busymark title: BusyMark -version: "0.3.3" +version: "0.3.4" summary: Markdown and Writerside documentation editor # Snap Store listing translations are managed outside this Flutter package. # Update store metadata when approved translated listing text is supplied. @@ -63,6 +63,8 @@ parts: source: . flutter-target: lib/main.dart flutter-channel: stable + build-environment: + - BUSYMARK_FLUTTER_VERSION: "3.47.0" build-snaps: - node/24/stable build-packages: @@ -100,14 +102,20 @@ parts: rm -rf "$CRAFT_PART_BUILD/build" rm -rf "$CRAFT_PART_BUILD/.dart_tool" rm -rf "$CRAFT_PART_BUILD/linux/flutter/ephemeral" - if [ ! -x "$CRAFT_PART_BUILD/flutter-distro/bin/flutter" ]; then - git clone --depth 1 -b stable https://github.com/flutter/flutter.git \ - "$CRAFT_PART_BUILD/flutter-distro" + flutter_sdk="$CRAFT_PART_BUILD/flutter-distro" + flutter_sdk_version="$(git -C "$flutter_sdk" describe --tags --exact-match HEAD 2>/dev/null || true)" + if [ ! -x "$flutter_sdk/bin/flutter" ] || \ + [ "$flutter_sdk_version" != "$BUSYMARK_FLUTTER_VERSION" ]; then + rm -rf "$flutter_sdk" + git clone --depth 1 --branch "$BUSYMARK_FLUTTER_VERSION" \ + https://github.com/flutter/flutter.git "$flutter_sdk" fi + test "$(git -C "$flutter_sdk" describe --tags --exact-match HEAD)" = \ + "$BUSYMARK_FLUTTER_VERSION" export CI=true - flutter --no-version-check precache --linux - flutter --no-version-check pub get - flutter --no-version-check build linux --release --verbose \ + "$flutter_sdk/bin/flutter" --no-version-check precache --linux + "$flutter_sdk/bin/flutter" --no-version-check pub get + "$flutter_sdk/bin/flutter" --no-version-check build linux --release --verbose \ --target lib/main.dart cp -r build/linux/*/release/bundle/* "$CRAFT_PART_INSTALL/" diff --git a/test/fixtures/markdown/basic.md b/test/fixtures/markdown/basic.md index 662fc96d..ae016f41 100644 --- a/test/fixtures/markdown/basic.md +++ b/test/fixtures/markdown/basic.md @@ -1,12 +1,930 @@ -# Basic Markdown +# BusyMark Markdown Demo -Intro with **bold**, *italic*, `code`, [other](other.md), and ![Logo](logo.png). +A comprehensive Markdown document demonstrating common Markdown syntax, GitHub-style extensions, mathematical notation, diagrams, and technical documentation blocks. -## Steps {id="steps"} +--- -1. First. -2. Second. +## Heading 2 + +### Heading 3 + +#### Heading 4 + +##### Heading 5 + +###### Heading 6 + +--- + +# 2. Paragraphs + +This is a normal paragraph. Markdown paragraphs are separated by a blank line. + +This is another paragraph containing a longer sentence to demonstrate normal text wrapping inside the editor and rendered document. + +A line can end with two spaces +to create a hard line break. + +--- + +# 3. Text Formatting + +Normal text + +*Italic text* + +*Italic text* + +**Bold text** + +**Bold text** + +***Bold and italic text*** + +***Bold and italic text*** + +~~Strikethrough text~~ + +`inline code` + +Text with **bold**, *italic*, ~~strikethrough~~, and `inline code` together. + +Escaped Markdown characters: + +*not italic* + +# not a heading + +`not code` + +--- + +# 4. Blockquotes + +> This is a blockquote. + +> A blockquote can contain multiple paragraphs. +> +> This is the second paragraph. + +Nested blockquotes: + +> Level one +> +> > Level two +> > +> > > Level three + +Blockquotes can contain other Markdown: + +> ## Quoted heading +> +> * First item +> * Second item +> +> **Important:** Markdown remains available inside the quote. + +--- + +# 5. Unordered Lists + +* First item +* Second item +* Third item + +Alternative markers: + +* Item using an asterisk +* Another item + +- Item using a plus sign +- Another item + +Nested lists: + +* Operating systems + + * Linux + + * Ubuntu + * Fedora + * Windows + * macOS +* Mobile platforms + + * Android + * iOS + +--- + +# 6. Ordered Lists + +1. First step +2. Second step +3. Third step + +Nested ordered lists: + +1. Prepare + + 1. Install dependencies + 2. Configure the project +2. Build + + 1. Compile + 2. Run tests +3. Release + +Markdown can automatically number items: + +1. Alpha +2. Beta +3. Gamma + +--- + +# 7. Task Lists + +* [x] Create project +* [x] Implement editor +* [x] Implement source view +* [ ] Complete documentation +* [ ] Publish release + +Nested tasks: + +* [x] Rendering + + * [x] Markdown + * [x] Tables + * [x] Code blocks + * [x] Diagrams +* [ ] Release + + * [x] Build + * [ ] Publish + +--- + +# 8. Links + +Inline link: + +[OpenAI](https://openai.com) + +Link with title: + +[Markdown](https://daringfireball.net/projects/markdown/ "Markdown") + +Automatic URL: + +[https://example.com](https://example.com) + +Automatic email: + +[developer@example.com](mailto:developer@example.com) + +Reference-style links: + +[BusyMark][busymark] + +[Markdown specification][commonmark] + +[busymark]: https://github.com/busystack/busymark +[commonmark]: https://spec.commonmark.org/ + +--- + +# 9. Images + +Standard image: + +![Example image](https://picsum.photos/800/300) + +Image with title: + +![Example landscape](https://picsum.photos/800/301 "Example image") + +Linked image: + +[![Example thumbnail](https://picsum.photos/300/150)](https://example.com) + +--- + +# 10. Horizontal Rules + +Three hyphens: + +--- + +Three asterisks: + +--- + +Three underscores: + +--- + +--- + +# 11. Inline Code + +Use `git status` to inspect the working tree. + +A Java variable can be written as `List names`. + +Use backticks inside inline code by using a longer delimiter: + +`` `example` `` + +--- + +# 12. Fenced Code Blocks + +Plain text: + +```text +BusyMark +Markdown editor +Linux desktop +``` + +Java: + +```java +public final class Greeting { + + public static void main(String[] args) { + String message = "Hello, Markdown!"; + System.out.println(message); + } +} +``` + +Dart: ```dart -void main() {} +void main() { + final values = [1, 2, 3, 4]; + + for (final value in values) { + print(value); + } +} +``` + +JavaScript: + +```javascript +const users = [ + { id: 1, name: "Ada" }, + { id: 2, name: "Grace" } +]; + +const names = users.map(user => user.name); +console.log(names); +``` + +Python: + +```python +def fibonacci(n): + a, b = 0, 1 + + for _ in range(n): + yield a + a, b = b, a + b ``` + +JSON: + +```json +{ + "application": "BusyMark", + "platform": "Linux", + "features": [ + "Markdown", + "Git", + "Diagrams" + ] +} +``` + +YAML: + +```yaml +application: + name: BusyMark + platform: Linux + features: + - Markdown + - Git + - Diagrams +``` + +Bash: + +```bash +git status +git add . +git commit -m "Update documentation" +``` + +SQL: + +```sql +SELECT + id, + name, + created_at +FROM document +WHERE archived = false +ORDER BY created_at DESC; +``` + +XML: + +```xml + + BusyMark + Linux + +``` + +HTML: + +```html +
+

Documentation

+

Technical documentation written in Markdown.

+
+``` + +CSS: + +```css +.document { + max-width: 960px; + margin: 0 auto; + line-height: 1.6; +} +``` + +--- + +# 13. Indented Code + +``` +This is an indented code block. +Markdown formatting is not interpreted here. +``` + +--- + +# 14. Tables + +Basic table: + +| Name | Type | Status | +| ------ | ---- | -------- | +| Editor | View | Complete | +| Source | View | Complete | +| Split | View | Complete | +| Read | View | Complete | + +Alignment: + +| Left | Center | Right | +| :------ | :----: | ----: | +| Alpha | Beta | 100 | +| Gamma | Delta | 200 | +| Epsilon | Zeta | 300 | + +Formatting inside tables: + +| Feature | Description | +| --------------------------- | -------------------- | +| **Bold** | Important content | +| *Italic* | Emphasized content | +| `code` | Technical identifier | +| [Link](https://example.com) | External resource | + +--- + +# 15. Footnotes + +Markdown can contain footnotes.[^markdown] + +A second footnote can contain more detailed information.[^details] + +[^markdown]: Markdown is a lightweight markup language. + +[^details]: Footnotes are useful when supplementary information should remain outside the main flow of the document. + +--- + +# 16. Definition-Style Content + +Term +: A word or expression being defined. + +Markdown +: A lightweight markup language used for structured plain-text documents. + +BusyMark +: A desktop Markdown editor. + +--- + +# 17. HTML + +
+ Raw HTML block +

This section uses HTML directly inside Markdown.

+
+ +Inline HTML can also be used, such as Ctrl + S. + +Details element: + +
+Expand details + +This content is initially collapsed when the renderer supports the HTML element. + +
+ +--- + +# 18. Special Characters and Entities + +Copyright: © + +Registered trademark: ® + +Trademark: ™ + +Less than: < + +Greater than: > + +Ampersand: & + +Non-breaking space: `A B` + +Unicode also works directly: + +✓ ★ → ← ↔ ∞ λ π Ω + +--- + +# 19. Mathematical Expressions + +Inline mathematics: + +The quadratic equation is $ax^2 + bx + c = 0$. + +Einstein's mass-energy relation is $E = mc^2$. + +A matrix can be written inline as $A \in \mathbb{R}^{m \times n}$. + +Display mathematics: + +$$ +x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a} +$$ + +Summation: + +$$ +\sum_{i=1}^{n} i = \frac{n(n+1)}{2} +$$ + +Integral: + +$$ +\int_{-\infty}^{\infty} e^{-x^2}\,dx = \sqrt{\pi} +$$ + +Matrix: + +$$ +A = +\begin{bmatrix} +1 & 2 & 3 \\ +4 & 5 & 6 \\ +7 & 8 & 9 +\end{bmatrix} +$$ + +Piecewise function: + +$$ +f(x) = +\begin{cases} +x^2, & x \ge 0 \\ +-x, & x < 0 +\end{cases} +$$ + +--- + +# 20. Mermaid + +Flowchart: + +```mermaid +flowchart LR + A[Markdown Source] --> B[Parser] + B --> C[Document Model] + C --> D[Renderer] + D --> E[Rendered Document] +``` + +Sequence diagram: + +```mermaid +sequenceDiagram + participant User + participant BusyMark + participant FileSystem + + User->>BusyMark: Open document + BusyMark->>FileSystem: Read file + FileSystem-->>BusyMark: Markdown content + BusyMark-->>User: Render document +``` + +Class diagram: + +```mermaid +classDiagram + class Document { + +String path + +String content + +save() + } + + class Editor { + +open(Document) + +edit() + } + + Editor --> Document +``` + +State diagram: + +```mermaid +stateDiagram-v2 + [*] --> Clean + Clean --> Modified: Edit + Modified --> Clean: Save + Modified --> Clean: Revert +``` + +--- + +# 21. PlantUML + +```plantuml +@startuml + +actor User + +participant "BusyMark" as BusyMark +participant "File System" as FS + +User -> BusyMark: Open Markdown file +BusyMark -> FS: Read file +FS --> BusyMark: Content +BusyMark --> User: Display document + +@enduml +``` + +Class diagram: + +```plantuml +@startuml + +class Document { + +path: String + +content: String + +save() +} + +class Editor { + +open(document) + +edit() +} + +Editor --> Document + +@enduml +``` + +--- + +# 22. D2 + +```d2 +user: User +editor: BusyMark +filesystem: File System + +user -> editor: Open document +editor -> filesystem: Read Markdown +filesystem -> editor: Content +editor -> user: Render document +``` + +Architecture example: + +```d2 +BusyMark: { + Editor + Source + Split + Read + + Editor -> Source + Editor -> Split + Editor -> Read +} + +Filesystem -> BusyMark.Editor: Markdown files +``` + +--- + +# 23. OpenAPI + +```openapi +openapi: 3.1.0 + +info: + title: Document API + version: 1.0.0 + description: Example API definition embedded in a Markdown document. + +servers: + - url: https://api.example.com + +paths: + /documents: + get: + summary: List documents + operationId: listDocuments + responses: + "200": + description: Documents returned successfully + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Document" + + post: + summary: Create document + operationId: createDocument + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateDocument" + responses: + "201": + description: Document created + content: + application/json: + schema: + $ref: "#/components/schemas/Document" + + /documents/{id}: + get: + summary: Get document + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: Document returned + content: + application/json: + schema: + $ref: "#/components/schemas/Document" + + "404": + description: Document not found + +components: + schemas: + Document: + type: object + required: + - id + - title + - content + properties: + id: + type: string + title: + type: string + content: + type: string + + CreateDocument: + type: object + required: + - title + - content + properties: + title: + type: string + content: + type: string +``` + +--- + +# 24. Nested Markdown + +> ## Documentation note +> +> This blockquote demonstrates several constructs together. +> +> 1. **Markdown** provides document structure. +> 2. `BusyMark` displays and edits the source. +> 3. Technical documents can contain: +> +> * Tables +> * Source code +> * Mathematics +> * Diagrams +> +> ```java +> record Document(String title, String content) {} +> ``` + +--- + +# 25. Long Content + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer posuere erat a ante venenatis dapibus posuere velit aliquet. Donec sed odio dui. Maecenas faucibus mollis interdum. Vestibulum id ligula porta felis euismod semper. + +This paragraph exists to demonstrate normal wrapping, scrolling, selection, search, rendering, and editing behavior with longer prose. A professional Markdown editor should preserve the underlying source while presenting readable typography in rendered views. + +Another paragraph follows to make document boundaries and paragraph spacing visible. Markdown remains plain text, which makes documents suitable for source control, review, diffing, automation, and long-term archival. + +--- + +# 26. Mixed Technical Documentation + +## System Architecture + +The application processes Markdown documents through several stages: + +1. Load the source file. +2. Parse Markdown syntax. +3. Construct the document representation. +4. Render supported elements. +5. Allow the user to edit the source. +6. Persist modifications. + +### Components + +| Component | Responsibility | +| --------------- | -------------------------------------- | +| Editor | Document editing | +| Source | Raw Markdown editing | +| Split | Simultaneous source and rendered views | +| Read | Rendered document reading | +| Renderer | Markdown and extension rendering | +| Git integration | Version-control operations | + +### Processing Flow + +```mermaid +flowchart TD + File[Markdown File] + Source[Source Model] + Parser[Markdown Parser] + Render[Rendered Document] + + File --> Source + Source --> Parser + Parser --> Render + Source --> File +``` + +### Complexity Example + +For a sequence of $n$ elements, a linear traversal requires: + +$$ +T(n) = O(n) +$$ + +A binary search over sorted data requires: + +$$ +T(n) = O(\log n) +$$ + +### Example Implementation + +```java +public static int binarySearch(int[] values, int target) { + int low = 0; + int high = values.length - 1; + + while (low <= high) { + int middle = low + (high - low) / 2; + + if (values[middle] == target) { + return middle; + } + + if (values[middle] < target) { + low = middle + 1; + } else { + high = middle - 1; + } + } + + return -1; +} +``` + +--- + +# 27. Edge Cases + +Empty emphasis markers should remain understandable in source form. + +Characters commonly occurring in technical documentation: + +`* _ # > < > [ ] ( ) { } \ | ~ ` + +URLs with query parameters: + +[https://example.com/search?q=markdown&sort=desc](https://example.com/search?q=markdown&sort=desc) + +Paths: + +`/home/user/Documents/example.md` + +Windows-style path: + +`C:\Users\Example\Documents\example.md` + +Generic types: + +`Map>` + +Command options: + +`git log --oneline --all --decorate` + +Shell variables: + +`${HOME}` + +Regular expression: + +`^[a-zA-Z0-9_-]+$` + +--- + +# 28. Document Conclusion + +This document exercises the principal content types expected in technical Markdown documents: + +* Text formatting +* Headings +* Lists +* Tasks +* Links +* Images +* Quotes +* Tables +* Code +* HTML +* Footnotes +* Mathematics +* Mermaid +* PlantUML +* D2 +* OpenAPI +* Nested structures +* Long-form technical documentation + +**End of document.** + +If this is specifically for BusyMark regression/demo testing, I can also make a stricter version designed to exercise every parser/rendering edge case rather than serve as a readable showcase. diff --git a/test/src/app_smoke_test.dart b/test/src/app_smoke_test.dart index 8ff9e322..f099bb21 100644 --- a/test/src/app_smoke_test.dart +++ b/test/src/app_smoke_test.dart @@ -36,6 +36,7 @@ import 'package:busymark/src/git/domain/git_models.dart'; import 'package:busymark/src/git/presentation/git_diff_viewer.dart'; import 'package:busymark/src/markdown/preview_model.dart'; import 'package:busymark/src/markdown/markdown_model.dart'; +import 'package:busymark/src/markdown/markdown_parser.dart'; import 'package:busymark/src/platform/linux_header_bar_service.dart'; import 'package:busymark/src/writerside/writerside_model.dart'; import 'package:busymark/src/writerside/writerside_topic_creator.dart'; @@ -1532,6 +1533,16 @@ void main() { expect(find.text(l10n.aboutTagline), findsOneWidget); expect(find.text(busyMarkAppVersion), findsOneWidget); expect(find.textContaining('Version'), findsNothing); + final versionTag = find.byKey(const ValueKey('about-version-tag')); + expect(versionTag, findsOneWidget); + final versionTagTheme = Theme.of(tester.element(versionTag)); + final versionTagDecoration = + tester.widget(versionTag).decoration as BoxDecoration; + expect(versionTagDecoration.color, versionTagTheme.colorScheme.primary); + expect( + tester.widget(find.text(busyMarkAppVersion)).style?.color, + versionTagTheme.colorScheme.onPrimary, + ); expect( find.ancestor( of: find.text(busyMarkAppVersion), @@ -4017,6 +4028,273 @@ void main() { expect(service.savedText, '# Edited Introduction\n'); }); + testWidgets('Source groups typing and deletion undo with historical carets', ( + tester, + ) async { + const source = 'abc'; + const startupPath = '/tmp/source-grouped-undo.md'; + final settingsStore = _MemorySettingsStore() + ..value = AppSettings.defaults() + .copyWith( + autoSave: false, + validateOnEdit: false, + documentViewMode: DocumentViewModePreference.source, + ) + .toJson(); + const service = _SearchWorkspaceService(source); + final container = ProviderContainer( + overrides: [ + linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), + localSettingsStoreProvider.overrideWithValue(settingsStore), + workspaceServiceProvider.overrideWithValue(service), + startupPathProvider.overrideWithValue(startupPath), + ], + ); + addTearDown(container.dispose); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const BusyMarkApp(), + ), + ); + for (var index = 0; index < 30; index += 1) { + await tester.pump(const Duration(milliseconds: 100)); + if (find.byType(BusyMarkSourceEditor).evaluate().isNotEmpty) { + break; + } + } + + final sourceField = find.descendant( + of: find.byType(BusyMarkSourceEditor), + matching: find.byType(TextField), + ); + await tester.tap(sourceField); + await tester.showKeyboard(sourceField); + final textController = tester.widget(sourceField).controller!; + textController.selection = const TextSelection.collapsed(offset: 1); + await tester.pump(); + + for (final (text, caret) in const [ + ('awbc', 2), + ('awobc', 3), + ('aworbc', 4), + ('awordbc', 5), + ]) { + tester.testTextInput.updateEditingValue( + TextEditingValue( + text: text, + selection: TextSelection.collapsed(offset: caret), + ), + ); + await tester.pump(); + } + + final buffer = container.read(workspaceControllerProvider).activeBuffer!; + expect(buffer.editorState.undoState.undo.map((state) => state.text), [ + source, + ]); + expect(buffer.text, 'awordbc'); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyZ); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(); + + expect(textController.text, source); + expect(textController.selection, const TextSelection.collapsed(offset: 1)); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyZ); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(); + + expect(textController.text, 'awordbc'); + expect(textController.selection, const TextSelection.collapsed(offset: 5)); + + for (final (text, caret) in const [('aworbc', 4), ('awobc', 3)]) { + tester.testTextInput.updateEditingValue( + TextEditingValue( + text: text, + selection: TextSelection.collapsed(offset: caret), + ), + ); + await tester.pump(); + } + + expect( + container + .read(workspaceControllerProvider) + .activeBuffer! + .editorState + .undoState + .undo + .map((state) => state.text), + [source, 'awordbc'], + ); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyZ); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(); + + expect(textController.text, 'awordbc'); + expect(textController.selection, const TextSelection.collapsed(offset: 5)); + + textController.selection = const TextSelection.collapsed(offset: 1); + await tester.pump(); + tester.testTextInput.updateEditingValue( + const TextEditingValue( + text: 'aXwordbc', + selection: TextSelection.collapsed(offset: 2), + ), + ); + await tester.pump(); + tester.testTextInput.updateEditingValue( + const TextEditingValue( + text: 'aXYZwordbc', + selection: TextSelection.collapsed(offset: 4), + ), + ); + await tester.pump(); + + expect( + container + .read(workspaceControllerProvider) + .activeBuffer! + .editorState + .undoState + .undo + .map((state) => state.text), + [source, 'awordbc', 'aXwordbc'], + ); + + for (final (text, caret) in const [('aXwordbc', 2), ('awordbc', 1)]) { + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyZ); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(); + expect(textController.text, text); + expect(textController.selection, TextSelection.collapsed(offset: caret)); + } + }); + + testWidgets('Source remount starts a distinct undo group', (tester) async { + const source = 'abc'; + const startupPath = '/tmp/source-remount-undo.md'; + final settingsStore = _MemorySettingsStore() + ..value = AppSettings.defaults() + .copyWith( + autoSave: false, + validateOnEdit: false, + documentViewMode: DocumentViewModePreference.source, + ) + .toJson(); + const service = _SearchWorkspaceService(source); + final container = ProviderContainer( + overrides: [ + linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), + localSettingsStoreProvider.overrideWithValue(settingsStore), + workspaceServiceProvider.overrideWithValue(service), + startupPathProvider.overrideWithValue(startupPath), + ], + ); + addTearDown(container.dispose); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const BusyMarkApp(), + ), + ); + for (var index = 0; index < 30; index += 1) { + await tester.pump(const Duration(milliseconds: 100)); + if (find.byType(BusyMarkSourceEditor).evaluate().isNotEmpty) { + break; + } + } + + Finder sourceField() => find.descendant( + of: find.byType(BusyMarkSourceEditor), + matching: find.byType(TextField), + ); + + await tester.tap(sourceField()); + await tester.showKeyboard(sourceField()); + tester.testTextInput.updateEditingValue( + const TextEditingValue( + text: 'abcd', + selection: TextSelection.collapsed(offset: 4), + ), + ); + await tester.pump(); + final firstGroup = container + .read(workspaceControllerProvider) + .activeBuffer! + .editorState + .undoState + .activeGroup; + expect(firstGroup, isNotNull); + + container + .read(workspaceControllerProvider.notifier) + .updateActiveEditorMode(DocumentViewModePreference.preview); + await container + .read(appSettingsControllerProvider.notifier) + .setDocumentViewMode(DocumentViewModePreference.preview); + await tester.pump(const Duration(milliseconds: 100)); + expect(find.byType(BusyMarkSourceEditor), findsNothing); + + container + .read(workspaceControllerProvider.notifier) + .updateActiveEditorMode(DocumentViewModePreference.source); + await container + .read(appSettingsControllerProvider.notifier) + .setDocumentViewMode(DocumentViewModePreference.source); + for (var index = 0; index < 10; index += 1) { + await tester.pump(const Duration(milliseconds: 100)); + if (find.byType(BusyMarkSourceEditor).evaluate().isNotEmpty) { + break; + } + } + + await tester.tap(sourceField()); + await tester.showKeyboard(sourceField()); + tester.testTextInput.updateEditingValue( + const TextEditingValue( + text: 'abcde', + selection: TextSelection.collapsed(offset: 5), + ), + ); + await tester.pump(); + + final remountedBuffer = container + .read(workspaceControllerProvider) + .activeBuffer!; + expect( + remountedBuffer.editorState.undoState.activeGroup, + isNot(firstGroup), + ); + expect( + remountedBuffer.editorState.undoState.undo.map((state) => state.text), + [source, 'abcd'], + ); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyZ); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(); + + final remountedController = tester + .widget(sourceField()) + .controller!; + expect(remountedController.text, 'abcd'); + expect(remountedController.selection.isCollapsed, isTrue); + expect(remountedController.selection.extentOffset, 4); + }); + testWidgets('source view supports editor formatting shortcuts', ( tester, ) async { @@ -6475,6 +6753,186 @@ Draft paragraph. ); }); + testWidgets('Reading opens an unindexed sibling document link', ( + tester, + ) async { + final binding = TestWidgetsFlutterBinding.ensureInitialized(); + binding.platformDispatcher.defaultRouteNameTestValue = '/workspace'; + addTearDown(() { + binding.platformDispatcher.defaultRouteNameTestValue = '/'; + }); + final directory = Directory.systemTemp.createTempSync( + 'busymark_reading_sibling_link_', + ); + addTearDown(() { + directory.deleteSync(recursive: true); + }); + final readme = File(p.join(directory.path, 'README.md')) + ..writeAsStringSync('[Open guide](guide.md)\n'); + final guide = File(p.join(directory.path, 'guide.md')) + ..writeAsStringSync('# Sibling guide\n'); + final settingsStore = _MemorySettingsStore() + ..value = AppSettings.defaults() + .copyWith(documentViewMode: DocumentViewModePreference.preview) + .toJson(); + final container = ProviderContainer( + overrides: [ + linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), + localSettingsStoreProvider.overrideWithValue(settingsStore), + ], + ); + addTearDown(container.dispose); + await tester.runAsync(() async { + await container + .read(appSettingsControllerProvider.notifier) + .waitUntilLoaded(); + await container + .read(workspaceControllerProvider.notifier) + .openPath(readme.path); + }); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const BusyMarkApp(), + ), + ); + for (var index = 0; index < 30; index += 1) { + await tester.pump(const Duration(milliseconds: 100)); + if (find.text('Open guide').evaluate().isNotEmpty) { + break; + } + } + + final initialWorkspace = container + .read(workspaceControllerProvider) + .workspace!; + expect(initialWorkspace.kind, WorkspaceKind.singleMarkdown); + expect(initialWorkspace.files.map((file) => file.absolutePath), [ + readme.path, + ]); + expect( + find.byKey(const ValueKey('preview-document-scroll')), + findsOneWidget, + ); + + final linkText = tester.widget(find.text('Open guide')); + final linkRecognizer = _firstTapRecognizer(linkText.textSpan!); + expect(linkRecognizer?.onTap, isNotNull); + await tester.runAsync(() async { + linkRecognizer!.onTap!(); + for (var index = 0; index < 100; index += 1) { + if (container + .read(workspaceControllerProvider) + .workspace + ?.activeFilePath == + guide.path) { + break; + } + await Future.delayed(const Duration(milliseconds: 10)); + } + }); + await tester.pump(); + + expect(find.text(l10n.linkTargetNotFound('guide.md')), findsNothing); + expect(find.text(l10n.cannotOpenFileTypeInEditor), findsNothing); + expect(container.read(workspaceControllerProvider).message, isNull); + expect( + container.read(workspaceControllerProvider).workspace?.activeFilePath, + guide.path, + ); + expect( + container.read(workspaceControllerProvider).activeText, + '# Sibling guide\n', + ); + }); + + testWidgets('Reading linked block and inline images open their targets', ( + tester, + ) async { + final binding = TestWidgetsFlutterBinding.ensureInitialized(); + binding.platformDispatcher.defaultRouteNameTestValue = '/workspace'; + addTearDown(() { + binding.platformDispatcher.defaultRouteNameTestValue = '/'; + }); + const rootPath = '/tmp/busymark-linked-images'; + const readmePath = '$rootPath/README.md'; + const blockTargetPath = '$rootPath/block-guide.md'; + const inlineTargetPath = '$rootPath/inline-guide.md'; + const source = ''' +[![Block logo](block-logo.png)](block-guide.md) + +Before [![Inline logo](inline-logo.png)](inline-guide.md) after. +'''; + final parsed = const MarkdownParser().parse( + filePath: readmePath, + source: source, + ); + final workspace = Workspace( + id: readmePath, + rootPath: rootPath, + kind: WorkspaceKind.singleMarkdown, + openedAt: DateTime(2026), + activeFilePath: readmePath, + openFilePaths: const [readmePath], + files: [ + for (final path in const [ + readmePath, + blockTargetPath, + inlineTargetPath, + ]) + DocumentFile( + absolutePath: path, + relativePath: p.basename(path), + kind: DocumentKind.markdown, + size: 1, + lastModified: DateTime(2026), + ), + ], + diagnostics: parsed.diagnostics, + markdown: parsed, + ); + final controller = _MutableWorkspaceController( + WorkspaceState( + workspace: workspace, + activeText: source, + preview: const MarkdownPreviewBuilder().build(parsed), + ), + ); + final settingsStore = _MemorySettingsStore() + ..value = AppSettings.defaults() + .copyWith(documentViewMode: DocumentViewModePreference.preview) + .toJson(); + final container = ProviderContainer( + overrides: [ + linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), + localSettingsStoreProvider.overrideWithValue(settingsStore), + workspaceControllerProvider.overrideWith(() => controller), + ], + ); + addTearDown(container.dispose); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const BusyMarkApp(), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(MarkdownImageView), findsNWidgets(2)); + for (final (destination, expectedPath) in const [ + ('block-guide.md', blockTargetPath), + ('inline-guide.md', inlineTargetPath), + ]) { + await tester.tap( + find.byKey(ValueKey('preview-linked-image-$destination')), + ); + await tester.pump(); + expect(controller.openedFilePath, expectedPath); + } + }); + testWidgets('new empty document leaves remembered preview mode for editor', ( tester, ) async { @@ -7221,7 +7679,10 @@ Draft paragraph. final service = _DeferredWorkspaceSearchService(); final settingsStore = _MemorySettingsStore() ..value = AppSettings.defaults() - .copyWith(documentViewMode: DocumentViewModePreference.preview) + .copyWith( + autoSave: false, + documentViewMode: DocumentViewModePreference.preview, + ) .toJson(); final container = ProviderContainer( overrides: [ @@ -7280,6 +7741,34 @@ Draft paragraph. expect(service.searchReadCount, 1); expect(find.text('Delayed needle result'), findsOneWidget); + + final activeBuffer = container + .read(workspaceControllerProvider) + .activeBuffer!; + container + .read(workspaceControllerProvider.notifier) + .updateDocumentEditorState( + activeBuffer.id, + activeBuffer.editorState.copyWith( + selection: const TextSelection.collapsed(offset: 3), + scrollOffset: 24, + ), + ); + await tester.pump(const Duration(milliseconds: 150)); + + expect(service.searchReadCount, 1); + expect(find.text('Delayed needle result'), findsOneWidget); + + container + .read(workspaceControllerProvider.notifier) + .updateActiveText( + '# Active needle document\n', + sourceFilePath: _DeferredWorkspaceSearchService.activePath, + ); + await tester.pump(const Duration(milliseconds: 150)); + await tester.pump(); + + expect(service.searchReadCount, 2); }, ); @@ -7759,6 +8248,49 @@ Draft paragraph. expect(find.textContaining(''), findsNothing); }); + testWidgets('Reading applies Markdown table column alignment', ( + tester, + ) async { + final settingsStore = _MemorySettingsStore() + ..value = AppSettings.defaults() + .copyWith(documentViewMode: DocumentViewModePreference.preview) + .toJson(); + final service = _SearchWorkspaceService( + '| Center heading | Right heading |\n' + '| :---: | ---: |\n' + '| Center value | Right value |\n', + ); + final container = ProviderContainer( + overrides: [ + linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), + localSettingsStoreProvider.overrideWithValue(settingsStore), + workspaceServiceProvider.overrideWithValue(service), + startupPathProvider.overrideWithValue('/tmp/aligned-table.md'), + ], + ); + addTearDown(container.dispose); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const BusyMarkApp(), + ), + ); + for (var i = 0; i < 20; i += 1) { + await tester.pump(const Duration(milliseconds: 100)); + if (find.text('Center heading').evaluate().isNotEmpty) { + break; + } + } + + for (final text in const ['Center heading', 'Center value']) { + expect(tester.widget(find.text(text)).textAlign, TextAlign.center); + } + for (final text in const ['Right heading', 'Right value']) { + expect(tester.widget(find.text(text)).textAlign, TextAlign.right); + } + }); + testWidgets('preview search result click lands on code block line', ( tester, ) async { @@ -8162,6 +8694,23 @@ String _fsrsReadmeSearchSource() { ].join('\n'); } +TapGestureRecognizer? _firstTapRecognizer(InlineSpan span) { + if (span is! TextSpan) { + return null; + } + final recognizer = span.recognizer; + if (recognizer is TapGestureRecognizer) { + return recognizer; + } + for (final child in span.children ?? const []) { + final nested = _firstTapRecognizer(child); + if (nested != null) { + return nested; + } + } + return null; +} + class _FallbackHeaderBarService extends LinuxHeaderBarService { _FallbackHeaderBarService() : super(channel: const MethodChannel('test.busymark/headerbar')); @@ -8188,6 +8737,7 @@ class _MutableWorkspaceController extends WorkspaceController { WritersideTopicRemovalMode? analyzedRemovalMode; String? selectedWritersideModuleId; String? selectedWritersideInstanceId; + String? openedFilePath; @override WorkspaceState build() => initialState; @@ -8310,7 +8860,10 @@ class _MutableWorkspaceController extends WorkspaceController { } @override - Future openActiveFile(String path) async => true; + Future openActiveFile(String path) async { + openedFilePath = path; + return true; + } void replaceWorkspace(Workspace workspace) { state = state.copyWith(workspace: workspace); diff --git a/test/src/busymark_document_test.dart b/test/src/busymark_document_test.dart index 26cb8c8d..580eed28 100644 --- a/test/src/busymark_document_test.dart +++ b/test/src/busymark_document_test.dart @@ -4315,6 +4315,91 @@ void main() {} expect(controller.markdown, 'Hello **bold!** world\n'); }); + test('WYSIWYG text edits preserve empty-alt inline images', () { + const cases = [ + ( + editedText: 'Earlier after.', + expectedMarkdown: 'Earlier ![](image.png) after.\n', + ), + ( + editedText: 'Before later.', + expectedMarkdown: 'Before ![](image.png) later.\n', + ), + ]; + + for (final item in cases) { + final parsed = parser.parse( + filePath: 'topic.md', + source: 'Before ![](image.png) after.\n', + ); + final block = parsed.busyDocument.blocks.single; + expect(block.plainText, 'Before after.'); + final controller = BusyMarkWysiwygDocumentController( + document: parsed.busyDocument, + ); + + controller.updateBlockText(block.id, item.editedText); + + final image = controller + .blockById(block.id)! + .inlines + .singleWhere((inline) => inline.kind == BusyInlineKind.image); + expect(image.text, isEmpty); + expect(image.destination, 'image.png'); + expect(controller.markdown, item.expectedMarkdown); + } + }); + + test('WYSIWYG edits preserve escaped literal punctuation semantics', () { + const cases = [ + (source: r'\*literal\*', plain: '*literal*', expected: r'\*literal\*!'), + (source: r'\_literal\_', plain: '_literal_', expected: r'\_literal\_!'), + (source: r'\`literal\`', plain: '`literal`', expected: r'\`literal\`!'), + ( + source: r'\~\~literal\~\~', + plain: '~~literal~~', + expected: r'\~\~literal\~\~!', + ), + (source: r'\', plain: '', expected: r'\!'), + (source: r'\©', plain: '©', expected: r'\©!'), + (source: r'\# heading', plain: '# heading', expected: r'\# heading!'), + (source: r'\- item', plain: '- item', expected: r'\- item!'), + (source: r'1\. item', plain: '1. item', expected: r'1\. item!'), + (source: r'\> quote', plain: '> quote', expected: r'\> quote!'), + ]; + + for (final item in cases) { + final parsed = parser.parse( + filePath: 'topic.md', + source: '${item.source}\n', + ); + final block = parsed.busyDocument.blocks.single; + expect(block.kind, BusyBlockKind.paragraph, reason: item.source); + expect(block.plainText, item.plain, reason: item.source); + final controller = BusyMarkWysiwygDocumentController( + document: parsed.busyDocument, + ); + + controller.updateBlockText(block.id, '${block.plainText}!'); + + expect(controller.markdown, '${item.expected}\n', reason: item.source); + final reparsed = parser.parse( + filePath: 'topic.md', + source: controller.markdown, + ); + expect( + reparsed.busyDocument.blocks.single.kind, + BusyBlockKind.paragraph, + reason: item.source, + ); + expect( + reparsed.busyDocument.blocks.single.plainText, + '${item.plain}!', + reason: item.source, + ); + } + }); + testWidgets('WYSIWYG link dialog submits with Enter', (tester) async { final parsed = parser.parse(filePath: 'topic.md', source: 'Linked word\n'); var markdown = parsed.source; @@ -5249,6 +5334,40 @@ void main() {} expect(find.byType(TextField), findsNWidgets(2)); }); + testWidgets('WYSIWYG typing preserves an existing hard break', ( + tester, + ) async { + final parsed = parser.parse( + filePath: 'topic.md', + source: 'Alpha \nBeta\n', + ); + var markdown = parsed.source; + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SizedBox( + width: 900, + height: 640, + child: BusyMarkWysiwygEditor( + document: parsed.busyDocument, + onSourceChanged: (filePath, value) => markdown = value, + ), + ), + ), + ), + ); + await tester.pump(); + + await tester.enterText(find.byType(TextField).first, 'Alpha\nBeta!'); + await tester.pump(); + + expect(markdown, 'Alpha \nBeta!\n'); + expect(find.byType(TextField), findsOneWidget); + }); + testWidgets('WYSIWYG editor lazily builds large documents', (tester) async { final source = List.generate( 500, @@ -5765,6 +5884,48 @@ void main() {} ); }); + testWidgets('WYSIWYG table cells apply Markdown column alignment', ( + tester, + ) async { + final parsed = parser.parse( + filePath: 'topic.md', + source: + '| Center heading | Right heading |\n' + '| :---: | ---: |\n' + '| Center value | Right value |\n', + ); + final rows = parsed.busyDocument.blocks.single.children; + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: BusyMarkWysiwygEditor( + document: parsed.busyDocument, + onSourceChanged: (_, _) {}, + ), + ), + ), + ); + await tester.pump(); + + for (final row in rows) { + expect( + tester + .widget(find.byKey(ValueKey(row.children[0].id))) + .textAlign, + TextAlign.center, + ); + expect( + tester + .widget(find.byKey(ValueKey(row.children[1].id))) + .textAlign, + TextAlign.right, + ); + } + }); + testWidgets('WYSIWYG table cells are formatted and editable', (tester) async { final parsed = parser.parse( filePath: 'topic.md', @@ -5862,9 +6023,17 @@ void main() {} expect(find.text('Alignment: Left'), findsOneWidget); expect(find.text('Alignment: Center'), findsOneWidget); expect(find.text('Alignment: Right'), findsOneWidget); - await tester.tap(find.text('Alignment: Left')); + await tester.tap(find.text('Alignment: Center')); await tester.pumpAndSettle(); - expect(markdown, contains('| :--- | --- |')); + expect(markdown, contains('| :---: | --- |')); + expect( + tester.widget(find.byKey(ValueKey(headerCellId))).textAlign, + TextAlign.center, + ); + expect( + tester.widget(find.byKey(ValueKey(bodyCellId))).textAlign, + TextAlign.center, + ); await tester.tap(deleteTableFinder); await tester.pump(); diff --git a/test/src/document_persistence_test.dart b/test/src/document_persistence_test.dart index 5f2b089e..ac9b9a43 100644 --- a/test/src/document_persistence_test.dart +++ b/test/src/document_persistence_test.dart @@ -96,6 +96,7 @@ void main() { expect(firstTab, isNot(contains('lastKnownText'))); expect(firstTab, isNot(contains('diskSnapshot'))); expect(firstTab, isNot(contains('format'))); + expect(firstTab, isNot(contains('searchCurrentMatchIndex'))); expect( await File(p.join(directory.path, 'session.json')).readAsString(), isNot(contains('# First')), diff --git a/test/src/markdown_parser_test.dart b/test/src/markdown_parser_test.dart index 955b87ae..800ed5a7 100644 --- a/test/src/markdown_parser_test.dart +++ b/test/src/markdown_parser_test.dart @@ -22,15 +22,26 @@ void main() { workspaceRoot: 'test/fixtures/markdown', ); - expect(parsed.title, 'Basic Markdown'); - expect(parsed.headings.map((item) => item.text), contains('Steps')); + expect(parsed.title, 'BusyMark Markdown Demo'); expect( - parsed.headings.singleWhere((item) => item.text == 'Steps').id, - 'steps', + parsed.headings.map((item) => item.text), + contains('12. Fenced Code Blocks'), ); - expect(parsed.links.single.destination, 'other.md'); - expect(parsed.images.single.destination, 'logo.png'); - expect(parsed.codeBlocks.single.language, 'dart'); + expect( + parsed.headings + .singleWhere((item) => item.text == '12. Fenced Code Blocks') + .id, + '12-fenced-code-blocks', + ); + expect( + parsed.links.map((item) => item.destination), + contains('https://openai.com'), + ); + expect( + parsed.images.map((item) => item.destination), + contains('https://picsum.photos/800/300'), + ); + expect(parsed.codeBlocks.map((item) => item.language), contains('dart')); }); test('extracts front matter title', () { diff --git a/test/src/native_headerbar_audit_test.dart b/test/src/native_headerbar_audit_test.dart index 3bc85036..08047d63 100644 --- a/test/src/native_headerbar_audit_test.dart +++ b/test/src/native_headerbar_audit_test.dart @@ -442,8 +442,16 @@ void main() { expect(snapcraft, contains(r'rm -rf "$CRAFT_PART_BUILD/build"')); expect(snapcraft, contains(r'rm -rf "$CRAFT_PART_BUILD/.dart_tool"')); expect(snapcraft, contains('export CI=true')); - expect(snapcraft, contains('flutter --no-version-check precache --linux')); - expect(snapcraft, contains('flutter --no-version-check pub get')); + expect( + snapcraft, + contains( + r'"$flutter_sdk/bin/flutter" --no-version-check precache --linux', + ), + ); + expect( + snapcraft, + contains(r'"$flutter_sdk/bin/flutter" --no-version-check pub get'), + ); expect( snapcraft, contains( @@ -552,6 +560,58 @@ void main() { expect(script, contains('the currently installed snap was not changed')); }); + test('local snap builder uses the project Flutter toolchain', () { + final pubspec = File('pubspec.yaml').readAsStringSync(); + final script = File('tools/build_install_snap_local.sh').readAsStringSync(); + + expect( + RegExp(r'^ flutter: \d+\.\d+\.\d+$', multiLine: true).hasMatch(pubspec), + isTrue, + ); + expect(script, contains('project_flutter_version')); + expect(script, contains('select_project_flutter')); + expect(script, contains('BUSYMARK_FLUTTER_BIN')); + expect(script, contains(r'--branch "$required_version"')); + expect(script, contains(r'"$FLUTTER_BIN" pub get --enforce-lockfile')); + expect(script, contains(r'"$FLUTTER_BIN" analyze --no-pub')); + expect(script, contains(r'"$FLUTTER_BIN" test --no-pub')); + expect(script, contains(r'"$FLUTTER_BIN" build linux --release --no-pub')); + }); + + test('Snapcraft pins the same Flutter release as the project', () { + final pubspec = File('pubspec.yaml').readAsStringSync(); + final snapcraft = File('snap/snapcraft.yaml').readAsStringSync(); + final projectVersion = RegExp( + r'^ flutter: (\d+\.\d+\.\d+)$', + multiLine: true, + ).firstMatch(pubspec)!.group(1)!; + + expect(snapcraft, contains('BUSYMARK_FLUTTER_VERSION: "$projectVersion"')); + expect( + snapcraft, + contains(r'git clone --depth 1 --branch "$BUSYMARK_FLUTTER_VERSION"'), + ); + expect( + snapcraft, + contains(r'git -C "$flutter_sdk" describe --tags --exact-match HEAD'), + ); + expect( + snapcraft, + contains(r'"$flutter_sdk/bin/flutter" --no-version-check build linux'), + ); + expect(snapcraft, isNot(contains('git clone --depth 1 -b stable'))); + }); + + test('local snap builder keeps compiler output off the system tmpfs', () { + final script = File('tools/build_install_snap_local.sh').readAsStringSync(); + + expect(script, contains('BUSYMARK_BUILD_TMP_ROOT')); + expect(script, contains(r'${XDG_CACHE_HOME:-$HOME/.cache}/busymark/tmp')); + expect(script, contains(r'mktemp -d "$build_tmp_root/snap-build.XXXXXX"')); + expect(script, contains(r'export TMPDIR="$BUSYMARK_BUILD_TMP_DIR"')); + expect(script, contains('trap cleanup_build_tmp EXIT')); + }); + test('native headerbar uses the shared tooltip visuals', () { final native = File('linux/runner/my_application.cc').readAsStringSync(); diff --git a/test/src/search_replace_service_test.dart b/test/src/search_replace_service_test.dart index 75f88646..273b0ac2 100644 --- a/test/src/search_replace_service_test.dart +++ b/test/src/search_replace_service_test.dart @@ -23,6 +23,53 @@ void main() { expect(preview.apply(), 'dog scatter dog'); }); + test('text replacement previews enforce their match limit', () { + const limited = SearchReplacementService(maximumMatches: 2); + + final preview = limited.previewText( + source: 'a a a', + options: const SourceSearchOptions(query: 'a'), + replacement: 'x', + ); + + expect(preview.matches, hasLength(2)); + expect(preview.truncated, isTrue); + }); + + test('replacement worker expands one accepted regex match', () async { + final worker = SearchReplacementWorker(); + addTearDown(worker.dispose); + + final preview = await worker.previewText( + source: 'Ada Lovelace', + options: const SourceSearchOptions(query: r'(\w+) (\w+)', regex: true), + replacement: r'$2, $1', + targetStart: 0, + targetEnd: 12, + ); + + expect(preview, isNotNull); + expect(preview!.matches.single.replacement, 'Lovelace, Ada'); + }); + + test('replacement worker cancels stale plans', () async { + final worker = SearchReplacementWorker(); + addTearDown(worker.dispose); + final stale = worker.previewText( + source: List.filled(10000, 'alpha').join(' '), + options: const SourceSearchOptions(query: 'alpha'), + replacement: 'stale', + ); + final current = worker.previewText( + source: 'current', + options: const SourceSearchOptions(query: 'current'), + replacement: 'fresh', + ); + + expect(await stale, isNull); + expect((await current)!.apply(), 'fresh'); + }); + test('expands numbered and named regex capture groups', () { final numbered = replacementService.previewText( source: 'Ada Lovelace; Grace Hopper', diff --git a/test/src/source_audit_test.dart b/test/src/source_audit_test.dart index d1482c4f..b77457d7 100644 --- a/test/src/source_audit_test.dart +++ b/test/src/source_audit_test.dart @@ -866,10 +866,7 @@ void main() { sourceEditor, contains('focusColor: BusyMarkLinuxPalette.transparent'), ); - expect( - sourceEditor, - contains('BusyMarkDocumentTextGeometry.selectionHeightStyle'), - ); + expect(sourceEditor, contains('.sourceSelectionHeightStyle')); expect( sourceEditor, contains('BusyMarkDocumentTextGeometry.selectionWidthStyle'), diff --git a/test/src/source_commands_test.dart b/test/src/source_commands_test.dart index 404c339b..b7c8eeca 100644 --- a/test/src/source_commands_test.dart +++ b/test/src/source_commands_test.dart @@ -14,6 +14,18 @@ void main() { expect(value.text, 'a '); }); + test('tab indents selected lines without replacing their text', () { + final value = SourceCommands.insertTab( + const TextEditingValue( + text: 'one\ntwo\nthree', + selection: TextSelection(baseOffset: 1, extentOffset: 6), + ), + ); + + expect(value.text, ' one\n two\nthree'); + expect(value.selection.textInside(value.text), ' one\n two'); + }); + test('indent and outdent selected lines', () { const value = TextEditingValue( text: 'one\ntwo\n', diff --git a/test/src/source_document_test.dart b/test/src/source_document_test.dart index c0c0edf3..d5789408 100644 --- a/test/src/source_document_test.dart +++ b/test/src/source_document_test.dart @@ -1,5 +1,8 @@ +import 'dart:math' as math; + import 'package:busymark/src/editor/source/source_document.dart'; import 'package:busymark/src/editor/source/source_hidden_ranges.dart'; +import 'package:busymark/src/editor/source/source_line_index.dart'; import 'package:busymark/src/editor/source_highlighter.dart'; import 'package:busymark/src/editor/source_folding.dart'; import 'package:flutter/services.dart'; @@ -57,6 +60,42 @@ void main() { ); }); + test('fold-boundary selection mapping preserves direction, not coverage', () { + const source = '# Title\nIntro.\nMore.\n# Next\n'; + final region = sourceFoldRegions( + source, + SourceSyntaxLanguage.markdown, + ).firstWhere((region) => region.startLine == 1); + final document = SourceDocument( + fullText: source, + hiddenRanges: SourceHiddenRanges( + ranges: [ + SourceHiddenRange( + start: region.hiddenStartOffset, + end: region.hiddenEndOffset, + ), + ], + textLength: source.length, + ), + ); + final boundary = document.fullOffsetToVisibleOffset( + region.hiddenStartOffset, + ); + + final forward = document.visibleSelectionToFullSelection( + TextSelection(baseOffset: 0, extentOffset: boundary), + ); + final backward = document.visibleSelectionToFullSelection( + TextSelection(baseOffset: boundary, extentOffset: 0), + ); + + expect(forward.start, backward.start); + expect(forward.end, backward.end); + expect(forward.end, region.hiddenEndOffset); + expect(forward.baseOffset, backward.extentOffset); + expect(forward.extentOffset, backward.baseOffset); + }); + test('multiple adjacent and overlapping ranges normalize safely', () { final ranges = SourceHiddenRanges( ranges: const [ @@ -136,6 +175,120 @@ void main() { expect(controller.fullText, endsWith('Done.\nTail\n')); }); + test('full editing values preserve composing through folded projection', () { + const source = '# Title\nIntro.\nMore.\n# Next\nDone.\n'; + final region = sourceFoldRegions( + source, + SourceSyntaxLanguage.markdown, + ).firstWhere((region) => region.startLine == 1); + final controller = BusyMarkSourceEditingController( + text: source, + language: SourceSyntaxLanguage.markdown, + )..setFoldedRegions([region]); + addTearDown(controller.dispose); + final fullStart = source.indexOf('Next'); + final fullComposing = TextRange(start: fullStart, end: fullStart + 4); + + controller.setFullEditingValue( + TextEditingValue( + text: source, + selection: TextSelection.collapsed(offset: fullComposing.end), + composing: fullComposing, + ), + ); + + final visibleStart = controller.text.indexOf('Next'); + expect( + controller.value.composing, + TextRange(start: visibleStart, end: visibleStart + 4), + ); + expect(controller.fullComposing, fullComposing); + }); + + test('visible edits incrementally retain unaffected line entries', () { + final controller = BusyMarkSourceEditingController( + text: 'first\nsecond\nthird\nfourth\n', + ); + final previous = controller.document; + + controller.value = const TextEditingValue( + text: 'first\nsecond\nTHIRD\nfourth\n', + selection: TextSelection.collapsed(offset: 18), + ); + + expect( + identical( + previous.lineIndex.lines.first, + controller.document.lineIndex.lines.first, + ), + isTrue, + ); + expect(controller.document.lineIndex.lines.map((line) => line.text), [ + 'first', + 'second', + 'THIRD', + 'fourth', + '', + ]); + expect(controller.document.visibleLineIndex.lineCount, 5); + }); + + test( + 'incremental line indexes match full indexes across line-break edits', + () { + final random = math.Random(149); + const pieces = ['a', 'b', ' ', '\n', '\r', '\r\n']; + for (var iteration = 0; iteration < 300; iteration++) { + final oldText = List.generate( + random.nextInt(30), + (_) => pieces[random.nextInt(pieces.length)], + ).join(); + final start = random.nextInt(oldText.length + 1); + final end = start + random.nextInt(oldText.length - start + 1); + final replacement = List.generate( + random.nextInt(8), + (_) => pieces[random.nextInt(pieces.length)], + ).join(); + final nextText = oldText.replaceRange(start, end, replacement); + final incremental = SourceLineIndex.updated( + previous: SourceLineIndex(oldText), + source: nextText, + oldStart: start, + oldEnd: end, + ); + final rebuilt = SourceLineIndex(nextText); + + expect( + incremental.lines + .map( + (line) => ( + line.number, + line.startOffset, + line.endOffset, + line.endOffsetIncludingLineBreak, + line.text, + line.lineBreak, + ), + ) + .toList(), + rebuilt.lines + .map( + (line) => ( + line.number, + line.startOffset, + line.endOffset, + line.endOffsetIncludingLineBreak, + line.text, + line.lineBreak, + ), + ) + .toList(), + reason: 'iteration $iteration: ${oldText.replaceAll('\n', r'\n')}', + ); + } + }, + ); + test('visible edit intersecting a fold unfolds only affected region', () { const source = '# Title\nIntro.\nMore.\n# Next\nDone.\n'; final region = sourceFoldRegions( diff --git a/test/src/source_editor_widget_test.dart b/test/src/source_editor_widget_test.dart index 6b730d41..12ae9c2b 100644 --- a/test/src/source_editor_widget_test.dart +++ b/test/src/source_editor_widget_test.dart @@ -9,11 +9,14 @@ import 'package:busymark/src/app/busymark_design.dart'; import 'package:busymark/src/core/diagnostic.dart'; import 'package:busymark/src/core/source_span.dart'; import 'package:busymark/src/editor/document_text_geometry.dart'; +import 'package:busymark/src/editor/source_highlighter.dart' + show BusyMarkSourceEditingController; import 'package:busymark/src/editor/source/source_editor.dart'; import 'package:busymark/src/editor/source/source_autocomplete.dart'; import 'package:busymark/src/editor/source/source_gutter.dart' show sourceTextHeightBehavior; import 'package:busymark/src/editor/source/source_search.dart'; +import 'package:busymark/src/editor/source_folding.dart'; import 'package:busymark/src/editor/source_language.dart'; import 'package:busymark/src/platform/native_menu_service.dart'; import 'package:busymark/src/writerside/writerside_project.dart'; @@ -429,6 +432,56 @@ void main() { expect(find.text(de.sourceSearchInvalidRegex), findsOneWidget); }); + testWidgets('source search reports no current match before navigation', ( + tester, + ) async { + final en = AppLocalizationsEn(); + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SizedBox( + width: 900, + height: 600, + child: BusyMarkSourceEditor( + text: 'cat cat', + language: SourceSyntaxLanguage.markdown, + filePath: '/project/topic.md', + diagnostics: const [], + editorFontSize: 14, + wordWrap: true, + searchActive: true, + searchOptions: const SourceSearchOptions(query: 'cat'), + onSearchOptionsChanged: (_) {}, + onChanged: (_, _) {}, + onOpenSearch: () {}, + onCloseSearch: () {}, + ), + ), + ), + ), + ); + + await _pumpUntil(tester, () => find.text('– / 2').evaluate().isNotEmpty); + final sourceField = tester + .widgetList(find.byType(TextField)) + .firstWhere((field) => field.controller?.text == 'cat cat'); + final controller = + sourceField.controller! as BusyMarkSourceEditingController; + expect(controller.searchResult.currentMatch, isNull); + + await tester.tap(find.byTooltip(en.sourceSearchNextMatch)); + await tester.pump(); + + expect(find.text('1 / 2'), findsOneWidget); + expect(controller.searchResult.currentMatchIndex, 0); + expect( + controller.selection, + const TextSelection(baseOffset: 0, extentOffset: 3), + ); + }); + testWidgets('source fold and search options use semantic icon buttons', ( tester, ) async { @@ -532,7 +585,10 @@ void main() { } undoText = null; setState(() => currentText = previous); - return previous; + return TextEditingValue( + text: previous, + selection: const TextSelection.collapsed(offset: 0), + ); }, onOpenSearch: () {}, onCloseSearch: () {}, @@ -546,9 +602,9 @@ void main() { find.byKey(const ValueKey('source-search-replacement')), 'dog', ); - await tester.pump(); + await _pumpUntil(tester, () => find.text('– / 2').evaluate().isNotEmpty); await tester.tap(find.byTooltip(en.sourceSearchReplaceAll)); - await tester.pump(); + await _pumpUntil(tester, () => currentText == 'dog dog'); expect(currentText, 'dog dog'); @@ -560,6 +616,218 @@ void main() { expect(currentText, 'cat cat'); }); + testWidgets('source Replace and Find Next selects the logical next match', ( + tester, + ) async { + final en = AppLocalizationsEn(); + var currentText = 'a a a'; + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: StatefulBuilder( + builder: (context, setState) => SizedBox( + width: 900, + height: 600, + child: BusyMarkSourceEditor( + text: currentText, + language: SourceSyntaxLanguage.markdown, + filePath: '/project/topic.md', + diagnostics: const [], + editorFontSize: 14, + wordWrap: true, + searchActive: true, + searchOptions: const SourceSearchOptions(query: 'a'), + searchReplacement: 'x', + onSearchReplacementChanged: (_) {}, + onSearchOptionsChanged: (_) {}, + onChanged: (text, _) => setState(() => currentText = text), + onOpenSearch: () {}, + onCloseSearch: () {}, + ), + ), + ), + ), + ), + ); + await _pumpUntil(tester, () => find.text('– / 3').evaluate().isNotEmpty); + + await tester.tap(find.byTooltip(en.sourceSearchReplaceAndFindNext)); + await _pumpUntil(tester, () => currentText == 'x a a'); + await _pumpUntil(tester, () => find.text('1 / 2').evaluate().isNotEmpty); + + final sourceField = tester + .widgetList(find.byType(TextField)) + .firstWhere((field) => field.controller?.text == currentText); + expect(currentText, 'x a a'); + expect( + sourceField.controller!.selection, + const TextSelection(baseOffset: 2, extentOffset: 3), + ); + }); + + testWidgets('shifted folded regions are persisted after edit debounce', ( + tester, + ) async { + final en = AppLocalizationsEn(); + var currentText = 'Prelude\n# Section\nHidden\nMore\n'; + var sessionKeys = {}; + + Widget editor({Key? key, Set initialKeys = const {}}) { + return MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SizedBox( + width: 900, + height: 600, + child: BusyMarkSourceEditor( + key: key, + text: currentText, + language: SourceSyntaxLanguage.markdown, + filePath: '/project/topic.md', + diagnostics: const [], + editorFontSize: 14, + wordWrap: true, + searchActive: false, + searchOptions: const SourceSearchOptions(), + onSearchOptionsChanged: (_) {}, + onChanged: (text, _) => currentText = text, + onOpenSearch: () {}, + onCloseSearch: () {}, + initialFoldedRegionKeys: initialKeys, + onSessionChanged: (_, _, keys) => sessionKeys = keys, + ), + ), + ), + ); + } + + await tester.pumpWidget(editor(key: const ValueKey('original'))); + await tester.tap(find.byTooltip(en.collapseKind(en.foldKindSection))); + await tester.pump(); + final sourceField = tester.widget(find.byType(TextField)); + final visibleBeforeEdit = sourceField.controller!.text; + expect(visibleBeforeEdit, isNot(contains('Hidden'))); + await tester.tap(find.byType(TextField)); + await tester.showKeyboard(find.byType(TextField)); + + tester.testTextInput.updateEditingValue( + TextEditingValue( + text: 'Lead\n$visibleBeforeEdit', + selection: const TextSelection.collapsed(offset: 5), + ), + ); + await tester.pump(const Duration(milliseconds: 120)); + await tester.pump(); + + final shiftedRegion = sourceFoldRegions( + currentText, + SourceSyntaxLanguage.markdown, + ).singleWhere((region) => region.startLine == 3); + expect(sessionKeys, contains(shiftedRegion.key)); + + await tester.pumpWidget( + editor(key: const ValueKey('restored'), initialKeys: sessionKeys), + ); + await tester.pump(); + + expect(find.byTooltip(en.expandKind(en.foldKindSection)), findsOneWidget); + expect( + tester.widget(find.byType(TextField)).controller!.text, + isNot(contains('Hidden')), + ); + }); + + testWidgets( + 'source sessions ignore render notifications and coalesce scrolling', + (tester) async { + final sessions = + < + ({ + TextSelection selection, + double scrollOffset, + Set foldedRegionKeys, + }) + >[]; + final source = List.generate(80, (index) => 'Line $index').join('\n'); + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SizedBox( + width: 900, + height: 200, + child: BusyMarkSourceEditor( + text: source, + language: SourceSyntaxLanguage.markdown, + filePath: '/project/topic.md', + diagnostics: const [], + editorFontSize: 14, + wordWrap: true, + searchActive: false, + searchOptions: const SourceSearchOptions(), + onSearchOptionsChanged: (_) {}, + onChanged: (_, _) {}, + onOpenSearch: () {}, + onCloseSearch: () {}, + onSessionChanged: (selection, scrollOffset, foldedKeys) { + sessions.add(( + selection: selection, + scrollOffset: scrollOffset, + foldedRegionKeys: foldedKeys, + )); + }, + ), + ), + ), + ), + ); + await tester.pump(); + sessions.clear(); + + final field = tester.widget(find.byType(TextField)); + final controller = field.controller! as BusyMarkSourceEditingController; + controller.setSearchResult( + SourceSearchResult( + options: const SourceSearchOptions(query: 'Line'), + matches: const [], + ), + ); + await tester.pump(); + expect(sessions, isEmpty); + + controller.selection = const TextSelection.collapsed(offset: 3); + controller.setSearchResult( + SourceSearchResult( + options: const SourceSearchOptions(query: 'Line'), + matches: const [], + invalidRegex: true, + ), + ); + await tester.pump(); + expect(sessions, hasLength(1)); + expect( + sessions.single.selection, + const TextSelection.collapsed(offset: 3), + ); + + sessions.clear(); + final scrollController = field.scrollController!; + expect(scrollController.position.maxScrollExtent, greaterThan(30)); + scrollController + ..jumpTo(10) + ..jumpTo(20) + ..jumpTo(30); + expect(sessions, isEmpty); + await tester.pump(); + expect(sessions, hasLength(1)); + expect(sessions.single.scrollOffset, 30); + }, + ); + testWidgets( 'source editor gives glyphs, caret, and selection breathing room', (tester) async { @@ -605,11 +873,11 @@ void main() { expect(field.style?.height, BusyMarkTypography.sourceEditorLineHeight); expect( field.selectionHeightStyle, - BusyMarkDocumentTextGeometry.selectionHeightStyle, + BusyMarkDocumentTextGeometry.sourceSelectionHeightStyle, ); expect( - BusyMarkDocumentTextGeometry.selectionHeightStyle, - BoxHeightStyle.strut, + BusyMarkDocumentTextGeometry.sourceSelectionHeightStyle, + BoxHeightStyle.max, ); expect( field.cursorHeight, @@ -640,29 +908,19 @@ void main() { expect(selectionBox.bottom, greaterThan(glyphBox.bottom)); expect(caret.top, lessThan(glyphBox.top)); expect(caret.bottom, greaterThan(glyphBox.bottom)); - expect(selectionBox.bottom - glyphBox.bottom, greaterThan(2)); + final selectionTopPadding = glyphBox.top - selectionBox.top; + final selectionBottomPadding = selectionBox.bottom - glyphBox.bottom; + expect(selectionTopPadding, greaterThan(1)); + expect(selectionBottomPadding, greaterThan(1)); + expect(selectionBottomPadding, closeTo(selectionTopPadding, 1)); expect(selectionBox.bottom - caret.bottom, greaterThan(1)); }, ); - testWidgets('Ctrl+Space opens project-aware source completion', ( + testWidgets('source heading selections cover styled glyphs evenly', ( tester, ) async { - const source = '

fea'; - String? changedText; - const index = WritersideProjectIndex( - symbols: [ - WritersideSymbol( - name: 'features', - qualifiedName: 'docs:features', - kind: WritersideSymbolKind.topic, - moduleId: 'docs', - filePath: '/project/topics/features.topic', - ), - ], - references: [], - diagnostics: [], - ); + const source = '# Agjpqy\nBody\n'; await tester.pumpWidget( MaterialApp( localizationsDelegates: AppLocalizations.localizationsDelegates, @@ -677,35 +935,276 @@ void main() { height: 600, child: BusyMarkSourceEditor( text: source, - language: SourceSyntaxLanguage.xml, - filePath: '/project/topics/current.topic', + language: SourceSyntaxLanguage.markdown, + filePath: '/project/topic.md', diagnostics: const [], editorFontSize: 14, wordWrap: true, searchActive: false, searchOptions: const SourceSearchOptions(), onSearchOptionsChanged: (_) {}, - onChanged: (text, _) => changedText = text, + onChanged: (_, _) {}, onOpenSearch: () {}, onCloseSearch: () {}, - initialSelection: const TextSelection.collapsed( - offset: source.length, - ), - autocompleteContext: const SourceAutocompleteContext( - projectIndex: index, - moduleId: 'docs', - ), ), ), ), ), ); - await tester.tap(find.byType(TextField)); - await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); - await tester.sendKeyEvent(LogicalKeyboardKey.space); - await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); await tester.pump(); + final field = tester.widget(find.byType(TextField)); + final renderEditable = _findRenderEditable( + tester.renderObject(find.byType(EditableText)), + )!; + const selection = TextSelection(baseOffset: 2, extentOffset: 8); + final selectionBox = renderEditable + .getBoxesForSelection(selection) + .single + .toRect(); + final textPainter = TextPainter( + text: renderEditable.text, + strutStyle: field.strutStyle, + textDirection: TextDirection.ltr, + textHeightBehavior: sourceTextHeightBehavior, + textScaler: MediaQuery.textScalerOf( + tester.element(find.byType(EditableText)), + ), + )..layout(maxWidth: 800); + final glyphBox = textPainter + .getBoxesForSelection(selection, boxHeightStyle: BoxHeightStyle.tight) + .single + .toRect(); + textPainter.dispose(); + + final topPadding = glyphBox.top - selectionBox.top; + final bottomPadding = selectionBox.bottom - glyphBox.bottom; + expect(topPadding, greaterThan(1)); + expect(bottomPadding, greaterThan(1)); + expect(bottomPadding, closeTo(topPadding, 2)); + }); + + testWidgets('source heading caret advances after a typed space', ( + tester, + ) async { + var source = ''; + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + theme: buildBusyMarkTheme( + brightness: Brightness.dark, + accentColor: BusyMarkLinuxPalette.blueAccent, + ), + home: StatefulBuilder( + builder: (context, setState) { + return Scaffold( + body: SizedBox( + width: 900, + height: 600, + child: BusyMarkSourceEditor( + text: source, + language: SourceSyntaxLanguage.markdown, + filePath: '/project/topic.md', + diagnostics: const [], + editorFontSize: 14, + wordWrap: true, + searchActive: false, + searchOptions: const SourceSearchOptions(), + onSearchOptionsChanged: (_) {}, + onChanged: (text, _) => setState(() => source = text), + onOpenSearch: () {}, + onCloseSearch: () {}, + ), + ), + ); + }, + ), + ), + ); + final fieldFinder = find.byType(TextField); + await tester.tap(fieldFinder); + await tester.showKeyboard(fieldFinder); + + Future enterAndReadCaret(String text) async { + tester.testTextInput.updateEditingValue( + TextEditingValue( + text: text, + selection: TextSelection.collapsed(offset: text.length), + ), + ); + await tester.pump(); + final field = tester.widget(fieldFinder); + expect(field.controller!.selection.extentOffset, text.length); + final editable = _findRenderEditable( + tester.renderObject(find.byType(EditableText)), + )!; + return editable.getLocalRectForCaret(TextPosition(offset: text.length)); + } + + final beforeMarkerSpace = await enterAndReadCaret('#'); + final afterMarkerSpace = await enterAndReadCaret('# '); + final beforeWordSpace = await enterAndReadCaret('# Linguality'); + final afterWordSpace = await enterAndReadCaret('# Linguality '); + await enterAndReadCaret('# Linguality\nBody'); + final field = tester.widget(fieldFinder); + field.controller!.selection = const TextSelection.collapsed(offset: 12); + await tester.pump(); + final editable = _findRenderEditable( + tester.renderObject(find.byType(EditableText)), + )!; + final beforeLineEndSpace = editable.getLocalRectForCaret( + const TextPosition(offset: 12), + ); + tester.testTextInput.updateEditingValue( + const TextEditingValue( + text: '# Linguality \nBody', + selection: TextSelection.collapsed(offset: 13), + ), + ); + await tester.pump(); + final lineEndSelection = tester + .widget(fieldFinder) + .controller! + .selection; + final afterLineEndSpace = editable.getLocalRectForCaret( + TextPosition(offset: 13, affinity: lineEndSelection.affinity), + ); + + expect(afterMarkerSpace.left, greaterThan(beforeMarkerSpace.left)); + expect(afterWordSpace.left, greaterThan(beforeWordSpace.left)); + expect(lineEndSelection.affinity, TextAffinity.upstream); + expect(afterLineEndSpace.left, greaterThan(beforeLineEndSpace.left)); + }); + + testWidgets('source caret follows an immediate end-of-file contraction', ( + tester, + ) async { + var source = List.generate(80, (index) => 'Line $index').join('\n'); + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + theme: buildBusyMarkTheme( + brightness: Brightness.dark, + accentColor: BusyMarkLinuxPalette.blueAccent, + ), + home: StatefulBuilder( + builder: (context, setState) { + return Scaffold( + body: SizedBox( + width: 500, + height: 180, + child: BusyMarkSourceEditor( + text: source, + language: SourceSyntaxLanguage.markdown, + filePath: '/project/topic.md', + diagnostics: const [], + editorFontSize: 14, + wordWrap: true, + searchActive: false, + searchOptions: const SourceSearchOptions(), + onSearchOptionsChanged: (_) {}, + onChanged: (text, _) => setState(() => source = text), + onOpenSearch: () {}, + onCloseSearch: () {}, + ), + ), + ); + }, + ), + ), + ); + final fieldFinder = find.byType(TextField); + await tester.tap(fieldFinder); + await tester.showKeyboard(fieldFinder); + tester.testTextInput.updateEditingValue( + TextEditingValue( + text: source, + selection: TextSelection.collapsed(offset: source.length), + ), + ); + await tester.pumpAndSettle(); + var field = tester.widget(fieldFinder); + expect(field.scrollController!.offset, greaterThan(0)); + + const shortened = 'Remaining text'; + tester.testTextInput.updateEditingValue( + const TextEditingValue( + text: shortened, + selection: TextSelection.collapsed(offset: shortened.length), + ), + ); + await tester.pump(); + + field = tester.widget(fieldFinder); + final editable = _findRenderEditable( + tester.renderObject(find.byType(EditableText)), + )!; + final caret = editable.getLocalRectForCaret( + const TextPosition(offset: shortened.length), + ); + expect(field.scrollController!.offset, 0); + expect(caret.top, lessThan(180)); + }); + + testWidgets('Tab indents selected Source lines without replacing text', ( + tester, + ) async { + var source = 'one\ntwo\nthree'; + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + theme: buildBusyMarkTheme( + brightness: Brightness.dark, + accentColor: BusyMarkLinuxPalette.blueAccent, + ), + home: StatefulBuilder( + builder: (context, setState) { + return Scaffold( + body: BusyMarkSourceEditor( + text: source, + language: SourceSyntaxLanguage.markdown, + filePath: '/project/topic.md', + diagnostics: const [], + editorFontSize: 14, + wordWrap: true, + searchActive: false, + searchOptions: const SourceSearchOptions(), + onSearchOptionsChanged: (_) {}, + onChanged: (text, _) => setState(() => source = text), + onOpenSearch: () {}, + onCloseSearch: () {}, + ), + ); + }, + ), + ), + ); + final fieldFinder = find.byType(TextField); + await tester.tap(fieldFinder); + final controller = tester.widget(fieldFinder).controller!; + controller.selection = const TextSelection(baseOffset: 1, extentOffset: 6); + await tester.pump(); + + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pump(); + + expect(source, ' one\n two\nthree'); + expect(controller.selection.textInside(controller.text), ' one\n two'); + }); + + testWidgets('Ctrl+Space opens project-aware source completion', ( + tester, + ) async { + String? changedText; + await _pumpAutocompleteSourceEditor( + tester, + onChanged: (text, _) => changedText = text, + ); + await _pressControlSpace(tester); + expect(find.byKey(const ValueKey('source-autocomplete-popup')), findsOne); final suggestion = find.byKey( const ValueKey('source-autocomplete-topic-features'), @@ -720,6 +1219,276 @@ void main() { findsNothing, ); }); + + testWidgets('wordWrap false uses one horizontally scrollable layout', ( + tester, + ) async { + final source = List.filled(80, 'long-source-token').join('-'); + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SizedBox( + width: 320, + height: 240, + child: BusyMarkSourceEditor( + text: source, + language: SourceSyntaxLanguage.markdown, + filePath: '/project/topic.md', + diagnostics: const [], + editorFontSize: 14, + wordWrap: false, + searchActive: false, + searchOptions: const SourceSearchOptions(), + onSearchOptionsChanged: (_) {}, + onChanged: (_, _) {}, + onOpenSearch: () {}, + onCloseSearch: () {}, + ), + ), + ), + ), + ); + + final scroller = tester.widget( + find.byKey(const ValueKey('source-horizontal-scroll-view')), + ); + expect(scroller.controller!.position.maxScrollExtent, greaterThan(0)); + expect(tester.getSize(find.byType(TextField)).width, greaterThan(320)); + + final field = tester.widget(find.byType(TextField)); + field.controller!.selection = TextSelection.collapsed( + offset: source.length, + ); + await tester.pump(); + expect(scroller.controller!.offset, greaterThan(0)); + }); + + testWidgets('source input preserves an active IME composing range', ( + tester, + ) async { + String? changedText; + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: BusyMarkSourceEditor( + text: '', + language: SourceSyntaxLanguage.markdown, + filePath: '/project/topic.md', + diagnostics: const [], + editorFontSize: 14, + wordWrap: true, + searchActive: false, + searchOptions: const SourceSearchOptions(), + onSearchOptionsChanged: (_) {}, + onChanged: (text, _) => changedText = text, + onOpenSearch: () {}, + onCloseSearch: () {}, + ), + ), + ), + ); + final fieldFinder = find.byType(TextField); + await tester.tap(fieldFinder); + await tester.showKeyboard(fieldFinder); + tester.testTextInput.updateEditingValue( + const TextEditingValue( + text: 'に', + selection: TextSelection.collapsed(offset: 1), + composing: TextRange(start: 0, end: 1), + ), + ); + await tester.pump(); + + final field = tester.widget(fieldFinder); + expect( + field.controller!.value.composing, + const TextRange(start: 0, end: 1), + ); + expect(changedText, 'に'); + }); + + for (final (label, key) in const [ + ('Enter', LogicalKeyboardKey.enter), + ('Tab', LogicalKeyboardKey.tab), + ('autocomplete Escape', LogicalKeyboardKey.escape), + ]) { + testWidgets('active IME composition defers $label to the input method', ( + tester, + ) async { + final controller = await _pumpAutocompleteSourceEditor(tester); + await _pressControlSpace(tester); + expect( + find.byKey(const ValueKey('source-autocomplete-popup')), + findsOneWidget, + ); + + tester.testTextInput.updateEditingValue( + const TextEditingValue( + text: _autocompleteSource, + selection: TextSelection.collapsed(offset: 13), + composing: TextRange(start: 10, end: 13), + ), + ); + await tester.pump(); + + await tester.sendKeyEvent(key); + await tester.pump(); + + expect(controller.text, _autocompleteSource); + expect(controller.value.composing, const TextRange(start: 10, end: 13)); + expect( + find.byKey(const ValueKey('source-autocomplete-popup')), + findsOneWidget, + ); + }); + } + + testWidgets('active IME composition defers the autocomplete shortcut', ( + tester, + ) async { + final controller = await _pumpAutocompleteSourceEditor(tester); + tester.testTextInput.updateEditingValue( + const TextEditingValue( + text: _autocompleteSource, + selection: TextSelection.collapsed(offset: 13), + composing: TextRange(start: 10, end: 13), + ), + ); + await tester.pump(); + + await _pressControlSpace(tester); + + expect(controller.text, _autocompleteSource); + expect(controller.value.composing, const TextRange(start: 10, end: 13)); + expect( + find.byKey(const ValueKey('source-autocomplete-popup')), + findsNothing, + ); + }); + + testWidgets('focused Source accepts authoritative parent text updates', ( + tester, + ) async { + var source = 'local'; + String? changedText; + late StateSetter updateHost; + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: StatefulBuilder( + builder: (context, setState) { + updateHost = setState; + return BusyMarkSourceEditor( + text: source, + language: SourceSyntaxLanguage.markdown, + filePath: '/project/topic.md', + diagnostics: const [], + editorFontSize: 14, + wordWrap: true, + searchActive: false, + searchOptions: const SourceSearchOptions(), + onSearchOptionsChanged: (_) {}, + onChanged: (text, _) => changedText = text, + onOpenSearch: () {}, + onCloseSearch: () {}, + ); + }, + ), + ), + ), + ); + final fieldFinder = find.byType(TextField); + await tester.tap(fieldFinder); + expect(tester.widget(fieldFinder).focusNode!.hasFocus, isTrue); + + updateHost(() => source = 'authoritative'); + await tester.pump(); + + expect(tester.widget(fieldFinder).controller!.text, source); + tester.testTextInput.updateEditingValue( + const TextEditingValue( + text: 'authoritative!', + selection: TextSelection.collapsed(offset: 14), + ), + ); + await tester.pump(); + expect(changedText, 'authoritative!'); + }); +} + +const _autocompleteSource = '

fea'; + +Future _pumpAutocompleteSourceEditor( + WidgetTester tester, { + BusyMarkSourceChanged? onChanged, +}) async { + const index = WritersideProjectIndex( + symbols: [ + WritersideSymbol( + name: 'features', + qualifiedName: 'docs:features', + kind: WritersideSymbolKind.topic, + moduleId: 'docs', + filePath: '/project/topics/features.topic', + ), + ], + references: [], + diagnostics: [], + ); + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + theme: buildBusyMarkTheme( + brightness: Brightness.dark, + accentColor: BusyMarkLinuxPalette.blueAccent, + ), + home: Scaffold( + body: SizedBox( + width: 900, + height: 600, + child: BusyMarkSourceEditor( + text: _autocompleteSource, + language: SourceSyntaxLanguage.xml, + filePath: '/project/topics/current.topic', + diagnostics: const [], + editorFontSize: 14, + wordWrap: true, + searchActive: false, + searchOptions: const SourceSearchOptions(), + onSearchOptionsChanged: (_) {}, + onChanged: onChanged ?? (_, _) {}, + onOpenSearch: () {}, + onCloseSearch: () {}, + initialSelection: const TextSelection.collapsed( + offset: _autocompleteSource.length, + ), + autocompleteContext: const SourceAutocompleteContext( + projectIndex: index, + moduleId: 'docs', + ), + ), + ), + ), + ), + ); + final fieldFinder = find.byType(TextField); + await tester.tap(fieldFinder); + await tester.showKeyboard(fieldFinder); + return tester.widget(fieldFinder).controller!; +} + +Future _pressControlSpace(WidgetTester tester) async { + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(); } String? _nativeShortcut(List> entries, String label) { @@ -742,3 +1511,16 @@ RenderEditable? _findRenderEditable(RenderObject root) { }); return result; } + +Future _pumpUntil(WidgetTester tester, bool Function() condition) async { + for (var attempt = 0; attempt < 100; attempt++) { + if (condition()) { + return; + } + await tester.pump(const Duration(milliseconds: 20)); + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 5)), + ); + } + fail('Timed out waiting for asynchronous Source editor work.'); +} diff --git a/test/src/source_folding_test.dart b/test/src/source_folding_test.dart index 36f83b97..793559a0 100644 --- a/test/src/source_folding_test.dart +++ b/test/src/source_folding_test.dart @@ -3,6 +3,14 @@ import 'package:busymark/src/editor/source_language.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { + test('line starts map to the following source line', () { + const source = 'one\ntwo\nthree'; + + expect(sourceLineNumberForOffset(source, 3), 1); + expect(sourceLineNumberForOffset(source, 4), 2); + expect(sourceLineNumberForOffset(source, 8), 3); + }); + test('markdown folding detects sections lists quotes and code blocks', () { const source = ''' # Title @@ -80,6 +88,120 @@ Done. expect(codeRegion.endLine, 5); }); + test('markdown structures inside fences do not create fold regions', () { + const source = + '# Outer\n' + 'Before\n' + '```text\n' + '# Not a heading\n' + '- not a list\n' + '- still not a list\n' + '> not a quote\n' + '> still not a quote\n' + '```\n' + 'After\n' + '# Next\n'; + + final regions = sourceFoldRegions(source, SourceSyntaxLanguage.markdown); + + expect( + regions.where((region) => region.kind == SourceFoldKind.section), + hasLength(1), + ); + expect( + regions + .singleWhere((region) => region.kind == SourceFoldKind.section) + .endLine, + 10, + ); + expect( + regions.where((region) => region.kind == SourceFoldKind.list), + isEmpty, + ); + expect( + regions.where((region) => region.kind == SourceFoldKind.blockquote), + isEmpty, + ); + }); + + test('xml folding ignores tags inside multiline comments and CDATA', () { + const source = + '\n' + '\n' + '\n' + '\n' + ']]>\n' + '\n' + 'content\n' + '\n' + '\n'; + + final regions = sourceFoldRegions(source, SourceSyntaxLanguage.xml); + + expect(regions, hasLength(2)); + expect(regions.map((region) => region.startLine), containsAll([1, 10])); + expect(regions.map((region) => region.startLine), isNot(contains(3))); + expect(regions.map((region) => region.startLine), isNot(contains(7))); + }); + + test('xml folding ignores greater-than signs inside quoted attributes', () { + const source = + '\n' + ' a > b\n' + " c > d\n" + '\n'; + + final regions = sourceFoldRegions(source, SourceSyntaxLanguage.xml); + + expect(regions, hasLength(1)); + expect(regions.single.startLine, 1); + expect(regions.single.endLine, 4); + }); + + test('xml folding scans opening tags across source lines', () { + const source = + '\n' + ' \n" + ' Content\n' + ' \n' + '\n'; + + final regions = sourceFoldRegions(source, SourceSyntaxLanguage.xml); + + expect( + regions.any((region) => region.startLine == 1 && region.endLine == 7), + isTrue, + ); + expect( + regions.any((region) => region.startLine == 3 && region.endLine == 6), + isTrue, + ); + }); + + test('xml folding skips processing instructions and declarations', () { + const source = + '\n' + '\n' + ' b">\n' + ']>\n' + '\n' + ' Content\n' + '\n'; + + final regions = sourceFoldRegions(source, SourceSyntaxLanguage.xml); + + expect(regions, hasLength(1)); + expect(regions.single.startLine, 6); + expect(regions.single.endLine, 8); + }); + test('markdown folding does not join separate indented code blocks', () { const source = ' ```\n# Heading\n ```\n'; diff --git a/test/src/source_gutter_diagnostics_test.dart b/test/src/source_gutter_diagnostics_test.dart index d8b0fdac..5cdb8528 100644 --- a/test/src/source_gutter_diagnostics_test.dart +++ b/test/src/source_gutter_diagnostics_test.dart @@ -36,7 +36,7 @@ void main() { }); test( - 'diagnostic inside folded region is safe and gutter skips hidden line', + 'diagnostic inside folded region is aggregated onto its fold header', () { const source = '# Title\nBroken link\n# Next\n'; final fold = sourceFoldRegions( @@ -85,9 +85,54 @@ void main() { expect(gutter.map((line) => line.fullLine), [1, 3, 4]); expect(gutter.first.foldable, isTrue); expect(gutter.first.collapsed, isTrue); + expect(gutter.first.diagnostics, [markers.single]); + expect( + gutter.first.diagnostics.single.diagnostic.severity, + DiagnosticSeverity.warning, + ); }, ); + testWidgets('folded layout geometry is reused for equal collapsed sets', ( + tester, + ) async { + late BuildContext context; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (builderContext) { + context = builderContext; + return const SizedBox(); + }, + ), + ), + ); + const source = '# Heading\nBody\nMore\n# Next\n'; + final folds = sourceFoldRegions(source, SourceSyntaxLanguage.markdown); + final fold = folds.firstWhere((region) => region.startLine == 1); + final controller = BusyMarkSourceEditingController(text: source) + ..setFoldedRegions([fold]); + addTearDown(controller.dispose); + final cache = SourceLineLayoutCache(); + + List resolve(Set keys) => cache.resolve( + context, + controller: controller, + foldRegions: folds, + collapsedRegionKeys: keys, + textStyle: const TextStyle(fontFamily: 'monospace', fontSize: 14), + strutStyle: const StrutStyle(fontFamily: 'monospace', fontSize: 14), + lineHeight: 18, + textWidth: 400, + diagnostics: const [], + ); + + final first = resolve({fold.key}); + final second = resolve({fold.key}); + + expect(identical(first, second), isTrue); + }); + testWidgets('incremental gutter layout matches a full measured layout', ( tester, ) async { diff --git a/test/src/source_highlighter_test.dart b/test/src/source_highlighter_test.dart index 0d2983b8..b57dfc07 100644 --- a/test/src/source_highlighter_test.dart +++ b/test/src/source_highlighter_test.dart @@ -3,10 +3,48 @@ import 'package:busymark/src/app/app_theme.dart'; import 'package:busymark/src/app/busymark_design.dart'; import 'package:busymark/src/editor/source_folding.dart'; import 'package:busymark/src/editor/source_highlighter.dart'; +import 'package:busymark/src/editor/source/source_document.dart'; +import 'package:busymark/src/editor/source/source_search.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { + testWidgets('source highlighting bounds high-match span composition', ( + tester, + ) async { + final source = List.filled( + sourceInteractiveSearchMatchLimit + 500, + 'a', + ).join(); + late TextSpan span; + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) { + final controller = BusyMarkSourceEditingController(text: source) + ..setSearchResult( + searchSourceDocument( + SourceDocument(fullText: source), + const SourceSearchOptions(query: 'a'), + ), + ); + span = controller.buildSourceTextSpan(context: context); + controller.dispose(); + return const SizedBox(); + }, + ), + ), + ); + + expect(span.toPlainText(), source); + expect( + _flattenTextSpans(span), + hasLength(lessThanOrEqualTo(sourceInteractiveSearchMatchLimit + 2)), + ); + }); + testWidgets('markdown source highlighter colors editor syntax', ( tester, ) async { @@ -328,6 +366,18 @@ void main() { closeTo(renderedMetrics[index].width, 0.01), ); } + for (var offset = 0; offset <= source.length; offset++) { + final transparentCaret = transparentPainter.getOffsetForCaret( + TextPosition(offset: offset), + Rect.zero, + ); + final renderedCaret = renderedPainter.getOffsetForCaret( + TextPosition(offset: offset), + Rect.zero, + ); + expect(transparentCaret.dx, closeTo(renderedCaret.dx, 0.01)); + expect(transparentCaret.dy, closeTo(renderedCaret.dy, 0.01)); + } }); testWidgets('folded regions project body lines out of editable text', ( diff --git a/test/src/source_intrinsic_width_test.dart b/test/src/source_intrinsic_width_test.dart new file mode 100644 index 00000000..675bea67 --- /dev/null +++ b/test/src/source_intrinsic_width_test.dart @@ -0,0 +1,88 @@ +import 'package:busymark/src/editor/source/source_controller.dart'; +import 'package:busymark/src/editor/source/source_intrinsic_width.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('intrinsic width remeasures only edited source lines', ( + tester, + ) async { + late BuildContext context; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (builderContext) { + context = builderContext; + return const SizedBox(); + }, + ), + ), + ); + final controller = BusyMarkSourceController( + text: 'short\nlongest source line\ntail', + ); + final cache = SourceIntrinsicWidthCache(); + const style = TextStyle(fontFamily: 'monospace', fontSize: 14); + + final initialWidth = cache.resolve( + context, + controller: controller, + textStyle: style, + strutStyle: null, + ); + expect(cache.debugLineMeasureCount, 3); + + controller.value = const TextEditingValue( + text: 'short\nlongest source line extended\ntail', + selection: TextSelection.collapsed(offset: 39), + ); + final editedWidth = cache.resolve( + context, + controller: controller, + textStyle: style, + strutStyle: null, + ); + + expect(editedWidth, greaterThan(initialWidth)); + expect(cache.debugLineMeasureCount, 4); + + cache.resolve( + context, + controller: controller, + textStyle: style, + strutStyle: null, + ); + expect(cache.debugLineMeasureCount, 4); + controller.dispose(); + }); + + testWidgets('large source width uses a bounded estimate', (tester) async { + late BuildContext context; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (builderContext) { + context = builderContext; + return const SizedBox(); + }, + ), + ), + ); + final controller = BusyMarkSourceController( + text: 'x'.padRight(300001, 'x'), + ); + final cache = SourceIntrinsicWidthCache(); + + final width = cache.resolve( + context, + controller: controller, + textStyle: const TextStyle(fontFamily: 'monospace', fontSize: 14), + strutStyle: null, + ); + + expect(width, greaterThan(1)); + expect(cache.debugUsingLargeFileEstimate, isTrue); + expect(cache.debugLineMeasureCount, 0); + controller.dispose(); + }); +} diff --git a/test/src/source_search_test.dart b/test/src/source_search_test.dart index b3c443e5..c78c99ec 100644 --- a/test/src/source_search_test.dart +++ b/test/src/source_search_test.dart @@ -33,6 +33,25 @@ void main() { ); }); + test('whole-word boundaries recognize Unicode letters and marks', () { + final cyrillic = searchSourceDocument( + SourceDocument(fullText: 'привет рив'), + const SourceSearchOptions(query: 'рив', wholeWord: true), + ); + final combining = searchSourceDocument( + SourceDocument(fullText: 'cafe\u0301 fe'), + const SourceSearchOptions(query: 'fe', wholeWord: true), + ); + final cjk = searchSourceDocument( + SourceDocument(fullText: '中文 文'), + const SourceSearchOptions(query: '文', wholeWord: true), + ); + + expect(cyrillic.matches.map((match) => match.fullStart), [7]); + expect(combining.matches.map((match) => match.fullStart), [6]); + expect(cjk.matches.map((match) => match.fullStart), [3]); + }); + test('regex search and invalid regex are safe', () { final document = SourceDocument(fullText: 'v1 v22 vx'); @@ -101,4 +120,54 @@ void main() { ]); expect(results.expand((result) => result.matches), hasLength(2)); }); + + test('source search worker cancels stale requests', () async { + final worker = SourceSearchWorker(); + addTearDown(worker.dispose); + final stale = worker.search( + SourceDocument(fullText: List.filled(10000, 'alpha').join(' ')), + const SourceSearchOptions(query: 'alpha'), + ); + final current = worker.search( + SourceDocument(fullText: 'current result'), + const SourceSearchOptions(query: 'current'), + ); + + expect(await stale, isNull); + expect((await current)!.totalMatchCount, 1); + }); + + test('source search worker bounds transferred high-match results', () async { + final worker = SourceSearchWorker(); + addTearDown(worker.dispose); + final source = List.filled( + sourceInteractiveSearchMatchLimit + 500, + 'a', + ).join(); + + final firstWindow = await worker.search( + SourceDocument(fullText: source), + const SourceSearchOptions(query: 'a'), + ); + + expect(firstWindow, isNotNull); + expect(firstWindow!.totalMatchCount, source.length); + expect(firstWindow.matches, hasLength(sourceInteractiveSearchMatchLimit)); + expect(firstWindow.firstMatchIndex, 0); + + final nextWindow = await worker.search( + SourceDocument(fullText: source), + const SourceSearchOptions(query: 'a'), + currentMatchIndex: sourceInteractiveSearchMatchLimit, + firstMatchIndex: sourceInteractiveSearchMatchLimit, + ); + expect(nextWindow, isNotNull); + expect(nextWindow!.totalMatchCount, source.length); + expect(nextWindow.firstMatchIndex, sourceInteractiveSearchMatchLimit); + expect( + nextWindow.currentMatch?.fullStart, + sourceInteractiveSearchMatchLimit, + ); + expect(nextWindow.matches, hasLength(500)); + }); } diff --git a/test/src/visualization_card_test.dart b/test/src/visualization_card_test.dart index cbf255b8..5242768b 100644 --- a/test/src/visualization_card_test.dart +++ b/test/src/visualization_card_test.dart @@ -76,6 +76,64 @@ void main() { expect(find.textContaining('```MerMAID'), findsOneWidget); }); + testWidgets('fits a small Mermaid diagram to the preview width', ( + tester, + ) async { + final coordinator = VisualizationCoordinator( + renderers: const [_CardRenderer()], + cache: _MemoryVisualizationCache(cacheDirectory), + ); + addTearDown(coordinator.dispose); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + visualizationCoordinatorProvider.overrideWithValue(coordinator), + ], + child: _App(child: _CardHarness(onDiagnosticSelected: (_) {})), + ), + ); + await _pumpUntilFound(tester, find.byType(SvgPicture)); + + final renderedBounds = tester.getRect(find.byType(SvgPicture)); + expect(renderedBounds.width, greaterThan(700)); + expect(renderedBounds.height, greaterThan(140)); + }); + + testWidgets('keeps a wide Mermaid diagram readable until fit is requested', ( + tester, + ) async { + final coordinator = VisualizationCoordinator( + renderers: const [_CardRenderer()], + cache: _MemoryVisualizationCache(cacheDirectory), + ); + addTearDown(coordinator.dispose); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + visualizationCoordinatorProvider.overrideWithValue(coordinator), + ], + child: _App( + child: _CardHarness( + initialSource: 'wide diagram', + onDiagnosticSelected: (_) {}, + ), + ), + ), + ); + await _pumpUntilFound(tester, find.byType(SvgPicture)); + + expect(tester.getRect(find.byType(SvgPicture)).width, closeTo(1200, 0.1)); + + await tester.tap(find.byTooltip('Fit to width')); + await tester.pump(); + + final fittedBounds = tester.getRect(find.byType(SvgPicture)); + expect(fittedBounds.width, greaterThan(700)); + expect(fittedBounds.width, lessThan(800)); + }); + testWidgets( 'shows searchable OpenAPI operations and opens the native reference', (tester) async { @@ -158,18 +216,29 @@ class _App extends StatelessWidget { } class _CardHarness extends StatefulWidget { - const _CardHarness({super.key, required this.onDiagnosticSelected}); + const _CardHarness({ + super.key, + required this.onDiagnosticSelected, + this.initialSource = 'graph TD; A-->B', + }); final ValueChanged onDiagnosticSelected; + final String initialSource; @override State<_CardHarness> createState() => _CardHarnessState(); } class _CardHarnessState extends State<_CardHarness> { - var source = 'graph TD; A-->B'; + late String source; var revision = 1; + @override + void initState() { + super.initState(); + source = widget.initialSource; + } + void updateSource(String value) { setState(() { source = value; @@ -291,11 +360,19 @@ class _CardRenderer implements VisualizationRenderer { ], ); } + if (request.source.contains('wide')) { + return const SvgVisualizationResult( + svg: + '', + width: 1200, + height: 80, + ); + } return const SvgVisualizationResult( svg: - '', - width: 10, - height: 10, + '', + width: 400, + height: 80, ); } } diff --git a/test/src/visualization_markdown_integration_test.dart b/test/src/visualization_markdown_integration_test.dart index ef3eab33..a1725e71 100644 --- a/test/src/visualization_markdown_integration_test.dart +++ b/test/src/visualization_markdown_integration_test.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:busymark/l10n/generated/app_localizations.dart'; import 'package:busymark/src/app/app_theme.dart'; import 'package:busymark/src/app/busymark_design.dart'; @@ -11,6 +13,29 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { + test('basic fixture uses a renderable OpenAPI fence', () { + final source = File('test/fixtures/markdown/basic.md').readAsStringSync(); + final parsed = const MarkdownParser().parse( + filePath: 'test/fixtures/markdown/basic.md', + source: source, + validateLocalReferences: false, + ); + final openApiBlock = parsed.busyDocument.blocks.singleWhere( + (block) => + block.kind == BusyBlockKind.codeBlock && + block.plainText.contains('title: Document API'), + ); + final preview = const BusyMarkPreviewBuilder().build(parsed.busyDocument); + final openApiPreview = preview.blocks.singleWhere( + (block) => block.visualization?.kind == VisualizationRendererKind.openApi, + ); + + expect(openApiBlock.attributes['language'], 'openapi'); + expect(openApiBlock.attributes['metadata'], isNull); + expect(openApiPreview.language, 'openapi'); + expect(openApiPreview.text, contains('openapi: 3.1.0')); + }); + test( 'visualizer fences remain generic code and round-trip byte-for-byte', () { diff --git a/test/src/visualization_models_test.dart b/test/src/visualization_models_test.dart index 8d158479..5da14c01 100644 --- a/test/src/visualization_models_test.dart +++ b/test/src/visualization_models_test.dart @@ -1,8 +1,10 @@ +import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; import 'package:busymark/src/visualization/visualization_cache.dart'; import 'package:busymark/src/visualization/visualization_models.dart'; +import 'package:crypto/crypto.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { @@ -110,6 +112,28 @@ void main() { expect(keys, hasLength(7)); }, ); + + test('invalidates entries produced by the previous render pipeline', () { + final request = _request(); + final legacyKey = sha256 + .convert( + utf8.encode( + jsonEncode({ + 'renderer': request.kind.name, + 'engineVersion': request.engineVersion, + 'source': request.source, + 'theme': request.theme.name, + 'profile': request.profile.name, + 'options': request.options.canonicalValues, + 'sanitizerVersion': visualizationSanitizerVersion, + 'dependencies': const [], + }), + ), + ) + .toString(); + + expect(request.cacheKey, isNot(legacyKey)); + }); }); group('visualization disk cache', () { @@ -136,6 +160,7 @@ void main() { pngBytes: Uint8List.fromList([137, 80, 78, 71]), width: 2, height: 3, + pixelRatio: 2, ); const openApi = OpenApiVisualizationResult( content: 'openapi: 3.1.0', @@ -168,6 +193,7 @@ void main() { await reader.get('openapi') as OpenApiVisualizationResult; expect(readSvg.svg, svg.svg); expect(readRaster.pngBytes, raster.pngBytes); + expect(readRaster.pixelRatio, 2); expect(readOpenApi.reference.title, 'Demo'); expect(readOpenApi.dependencies.single.id, 'parts.yaml'); }); diff --git a/test/src/visualization_packaging_audit_test.dart b/test/src/visualization_packaging_audit_test.dart index 3dce7c9e..58b47737 100644 --- a/test/src/visualization_packaging_audit_test.dart +++ b/test/src/visualization_packaging_audit_test.dart @@ -11,10 +11,10 @@ void main() { 'linux/io.busystack.busymark.metainfo.xml', ).readAsStringSync(); - expect(pubspec, contains(RegExp(r'^version: 0\.3\.3$', multiLine: true))); + expect(pubspec, contains(RegExp(r'^version: 0\.3\.4$', multiLine: true))); expect( snapcraft, - contains(RegExp(r'^version: "0\.3\.3"$', multiLine: true)), + contains(RegExp(r'^version: "0\.3\.4"$', multiLine: true)), ); expect(snapcraft, contains(RegExp(r'^grade: stable$', multiLine: true))); expect( @@ -39,9 +39,9 @@ void main() { ), ), ); - expect(metainfo, contains('.delayed(const Duration(milliseconds: 50)); + + expect(service.reparseCount, greaterThan(0)); + expect(service.asyncPreviewBuildCount, 0); + expect( + controller.state.liveOutline?.headings.single.text, + 'Source discrete edit', + ); + + service.resetCounts(); + controller.updateActiveEditorMode(DocumentViewModePreference.preview); + await _waitFor( + () => controller.state.preview?.title == 'Source discrete edit', + ); + + expect(service.reparseCount, 1); + expect(controller.state.activeText, '# Source discrete edit\n'); + + service.resetCounts(); + controller.updateActiveEditorMode(DocumentViewModePreference.split); + controller.updateActiveSourceText('# Live split edit\n'); + await _waitFor(() => controller.state.preview?.title == 'Live split edit'); + + expect(service.reparseCount, 1); + + controller.dispose(); + settingsController.dispose(); + }); + + test( + 'Source-only validation still runs when validate-on-edit is enabled', + () async { + final service = _PreviewTrackingWorkspaceService(); + final harness = await _createControllerHarness(service: service); + final settingsController = harness.settingsController; + final controller = harness.controller; + await settingsController.setAutoSave(false); + await settingsController.setValidateOnEdit(true); + await controller.openPath('test/fixtures/markdown/basic.md'); + controller.updateActiveEditorMode(DocumentViewModePreference.source); + service.resetCounts(); + + controller.updateActiveSourceText('# Validate this Source edit\n'); + await _waitFor( + () => + controller.state.workspace?.markdown?.source == + '# Validate this Source edit\n', + ); + + expect(service.reparseCount, 1); + expect(service.synchronousPreviewBuildCount, 0); + expect(service.asyncPreviewBuildCount, 0); + + controller.updateActiveEditorMode(DocumentViewModePreference.preview); + await _waitFor( + () => controller.state.preview?.title == 'Validate this Source edit', + ); + + expect(service.reparseCount, 2); + expect(service.synchronousPreviewBuildCount, 1); + expect(service.asyncPreviewBuildCount, 0); + + controller.dispose(); + settingsController.dispose(); + }, + ); + test( 'stale text update from previous file cannot dirty active tab', () async { @@ -2013,6 +2095,19 @@ class _WorkspaceControllerDriver { _notifier.updateActiveText(text, sourceFilePath: sourceFilePath); } + void updateActiveSourceText(String text) { + final selection = state.activeBuffer!.editorState.selection; + _notifier.updateActiveSourceText( + text, + previousSelection: selection, + selection: selection, + ); + } + + void updateActiveEditorMode(DocumentViewModePreference mode) { + _notifier.updateActiveEditorMode(mode); + } + void updateActiveWysiwygText( String text, { required BusyDocument document, @@ -2158,6 +2253,39 @@ class _DelayedSaveAsWorkspaceService extends WorkspaceService { } } +class _PreviewTrackingWorkspaceService extends WorkspaceService { + int reparseCount = 0; + int synchronousPreviewBuildCount = 0; + int asyncPreviewBuildCount = 0; + + void resetCounts() { + reparseCount = 0; + synchronousPreviewBuildCount = 0; + asyncPreviewBuildCount = 0; + } + + @override + Future reparseActive(Workspace workspace, String source) { + reparseCount++; + return super.reparseActive(workspace, source); + } + + @override + PreviewDocument? buildPreview(Workspace workspace, String source) { + synchronousPreviewBuildCount++; + return super.buildPreview(workspace, source); + } + + @override + Future buildPreviewAsync( + Workspace workspace, + String source, + ) { + asyncPreviewBuildCount++; + return super.buildPreviewAsync(workspace, source); + } +} + class _AutosaveWorkspaceService extends WorkspaceService { _AutosaveWorkspaceService({this.pauseFirstSave = false}); diff --git a/test/src/workspace_service_test.dart b/test/src/workspace_service_test.dart index 1a631626..22ec295c 100644 --- a/test/src/workspace_service_test.dart +++ b/test/src/workspace_service_test.dart @@ -20,17 +20,51 @@ void main() { final workspace = await service.openPath('test/fixtures/markdown/basic.md'); expect(workspace.kind, WorkspaceKind.singleMarkdown); - expect(workspace.markdown?.title, 'Basic Markdown'); + expect(workspace.markdown?.title, 'BusyMark Markdown Demo'); expect(workspace.activeFilePath, isNotNull); }); + test('resolves an unindexed sibling document within the workspace', () async { + final base = await Directory.systemTemp.createTemp( + 'busymark-workspace-sibling-', + ); + addTearDown(() async { + if (await base.exists()) { + await base.delete(recursive: true); + } + }); + final directory = Directory(p.join(base.path, 'workspace')); + await directory.create(); + final readme = File(p.join(directory.path, 'README.md')); + final guide = File(p.join(directory.path, 'guide.md')); + final outside = File(p.join(base.path, 'outside.md')); + await readme.writeAsString('[Guide](guide.md)\n'); + await guide.writeAsString('# Guide\n'); + await outside.writeAsString('# Outside\n'); + final workspace = await service.openPath(readme.path); + + expect(workspace.files.map((file) => file.absolutePath), [readme.path]); + + final resolved = await service.resolveWorkspaceDocument( + workspace, + guide.path, + ); + + expect(resolved?.absolutePath, guide.path); + expect(resolved?.kind, DocumentKind.markdown); + await expectLater( + service.resolveWorkspaceDocument(workspace, outside.path), + throwsA(isA()), + ); + }); + test('opens a Markdown file from a file URI path', () async { final file = File('test/fixtures/markdown/basic.md'); final workspace = await service.openPath(file.absolute.uri.toString()); expect(workspace.kind, WorkspaceKind.singleMarkdown); expect(workspace.activeFilePath, file.absolute.path); - expect(workspace.markdown?.title, 'Basic Markdown'); + expect(workspace.markdown?.title, 'BusyMark Markdown Demo'); }); test('opens a generic Markdown folder workspace', () async { diff --git a/test/src/wysiwyg_math_test.dart b/test/src/wysiwyg_math_test.dart index 94e9de02..27bf9893 100644 --- a/test/src/wysiwyg_math_test.dart +++ b/test/src/wysiwyg_math_test.dart @@ -468,12 +468,16 @@ void main() { final table = controller.document.blocks.single; final cell = table.children[1].children.single; - controller.updateTableCellText(table.id, cell.id, r'before $y^2$ after'); + controller.updateTableCellMarkdownSource( + table.id, + cell.id, + r'before $y^2$ after', + ); var edited = controller.blockById(cell.id)!; expect(busyMarkWysiwygBlockContainsMath(edited), isTrue); expect(controller.markdown, contains(r'before $y^2$ after')); - controller.updateTableCellText(table.id, cell.id, 'plain text'); + controller.updateTableCellMarkdownSource(table.id, cell.id, 'plain text'); edited = controller.blockById(cell.id)!; expect(busyMarkWysiwygBlockContainsMath(edited), isFalse); expect(controller.markdown, contains('| plain text |')); @@ -543,7 +547,7 @@ void main() { final cell = table.children[1].children.single; final editableCell = busyMarkWysiwygEditableText(cell); expect(editableCell, contains(r'\$x\$')); - tableController.updateTableCellText( + tableController.updateTableCellMarkdownSource( table.id, cell.id, '$editableCell updated', @@ -921,7 +925,7 @@ void main() { expect(markdown, contains(r'| before $y^2$ after |')); }); - testWidgets('focused plain table cell stays editable after math is typed', ( + testWidgets('plain table cell treats typed Markdown as rich text', ( tester, ) async { final document = const MarkdownParser() @@ -960,14 +964,15 @@ void main() { expect(tester.widget(field).focusNode?.hasFocus, isTrue); await tester.enterText(field, r'before $x$ after'); await tester.pump(); - expect(markdown, contains(r'| before $x$ after |')); + expect(markdown, contains(r'| before \$x\$ after |')); tester.widget(field).focusNode?.unfocus(); await _pumpMath(tester); expect( find.byKey(ValueKey('wysiwyg-rendered-math-${cell.id}')), - findsOneWidget, + findsNothing, ); + expect(field, findsOneWidget); }); } diff --git a/test/src/wysiwyg_structured_regression_test.dart b/test/src/wysiwyg_structured_regression_test.dart new file mode 100644 index 00000000..1f17d1a8 --- /dev/null +++ b/test/src/wysiwyg_structured_regression_test.dart @@ -0,0 +1,930 @@ +import 'package:busymark/l10n/generated/app_localizations.dart'; +import 'package:busymark/src/app/busymark_glyphs.dart'; +import 'package:busymark/src/app/busymark_design.dart'; +import 'package:busymark/src/editor/wysiwyg/wysiwyg_commands.dart'; +import 'package:busymark/src/editor/wysiwyg/wysiwyg_document_controller.dart'; +import 'package:busymark/src/editor/wysiwyg/wysiwyg_editor.dart'; +import 'package:busymark/src/editor/wysiwyg/wysiwyg_session_state.dart'; +import 'package:busymark/src/markdown/busymark_document.dart'; +import 'package:busymark/src/markdown/markdown_model.dart'; +import 'package:busymark/src/markdown/markdown_parser.dart'; +import 'package:busymark/src/workspace/document_buffer.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + const parser = MarkdownParser(); + + test( + 'generic block commands cannot convert tables or preserved raw blocks', + () { + final parsed = parser.parse( + filePath: 'topic.md', + source: + '| A | B |\n| --- | --- |\n| one | two |\n\n\n', + ); + final table = parsed.busyDocument.blocks.firstWhere( + (block) => block.kind == BusyBlockKind.table, + ); + final raw = parsed.busyDocument.blocks.firstWhere( + (block) => block.preserveRaw, + ); + final controller = BusyMarkWysiwygDocumentController( + document: parsed.busyDocument, + ); + final original = controller.markdown; + + for (final command in BusyWysiwygBlockCommand.values) { + expect(busyMarkWysiwygCanApplyBlockCommand(table, command), isFalse); + expect(busyMarkWysiwygCanApplyBlockCommand(raw, command), isFalse); + controller.applyBlockCommand(table.id, command); + controller.applyBlockCommand(raw.id, command); + expect(controller.markdown, original, reason: command.name); + } + controller.applyAdmonitionStyle(table.id, BusyAdmonitionStyle.warning); + controller.applyAdmonitionStyle(raw.id, BusyAdmonitionStyle.warning); + expect(controller.markdown, original); + }, + ); + + test('nested list-to-blockquote conversion preserves parent text', () { + final document = parser + .parse(filePath: 'topic.md', source: '- parent\n - child\n') + .busyDocument; + final parent = document.blocks.single; + expect(parent.children, isNotEmpty); + final controller = BusyMarkWysiwygDocumentController(document: document); + + expect( + busyMarkWysiwygCanApplyBlockCommand( + parent, + BusyWysiwygBlockCommand.heading1, + ), + isFalse, + ); + expect( + busyMarkWysiwygCanApplyBlockCommand( + parent, + BusyWysiwygBlockCommand.orderedList, + ), + isTrue, + ); + controller.applyBlockCommand(parent.id, BusyWysiwygBlockCommand.blockquote); + + final quote = controller.blockById(parent.id)!; + expect(quote.kind, BusyBlockKind.blockquote); + expect(quote.inlines, isEmpty); + expect(quote.children.first.kind, BusyBlockKind.paragraph); + expect(quote.children.first.plainText, 'parent'); + expect(quote.children.last.plainText, 'child'); + expect(controller.markdown, '> parent\n>\n> - child\n'); + + controller.applyBlockCommand( + parent.id, + BusyWysiwygBlockCommand.unorderedList, + ); + final restoredList = controller.blockById(parent.id)!; + expect(restoredList.plainText, 'parent'); + expect(restoredList.children.single.plainText, 'child'); + expect(controller.markdown, '- parent\n - child\n'); + }); + + test('every admonition style preserves nested list parent text', () { + for (final style in BusyAdmonitionStyle.values) { + final document = parser + .parse(filePath: 'topic.md', source: '- parent\n - child\n') + .busyDocument; + final parent = document.blocks.single; + final controller = BusyMarkWysiwygDocumentController(document: document); + + controller.applyAdmonitionStyle(parent.id, style); + + final admonition = controller.blockById(parent.id)!; + expect(admonition.kind, BusyBlockKind.blockquote, reason: style.name); + expect(admonition.attributes['style'], style.name, reason: style.name); + expect(admonition.children.first.plainText, 'parent', reason: style.name); + expect(admonition.children.last.plainText, 'child', reason: style.name); + expect(controller.markdown, contains('> parent'), reason: style.name); + expect(controller.markdown, contains('child'), reason: style.name); + } + }); + + test('Backspace merge preserves the current list item descendants', () { + final document = parser + .parse(filePath: 'topic.md', source: '- first\n- second\n - child\n') + .busyDocument; + final first = document.blocks.first; + final second = document.blocks.last; + expect(second.children.single.plainText, 'child'); + final controller = BusyMarkWysiwygDocumentController(document: document); + + final result = controller.applyBackspaceAtStart(second.id); + + expect(result?.blockId, first.id); + expect(result?.offset, 'first'.length); + final merged = controller.document.blocks.single; + expect(merged.plainText, 'firstsecond'); + expect(merged.children.single.plainText, 'child'); + expect(controller.markdown, '- firstsecond\n - child\n'); + }); + + test('Backspace merge preserves descendants of an empty list item', () { + final document = parser + .parse(filePath: 'topic.md', source: '- first\n-\n - child\n') + .busyDocument; + final first = document.blocks.first; + final emptyParent = document.blocks.last; + expect(emptyParent.plainText, isEmpty); + expect(emptyParent.children.single.plainText, 'child'); + final controller = BusyMarkWysiwygDocumentController(document: document); + + final result = controller.applyBackspaceAtStart(emptyParent.id); + + expect(result?.blockId, first.id); + expect(result?.offset, 'first'.length); + final merged = controller.document.blocks.single; + expect(merged.plainText, 'first'); + expect(merged.children.single.plainText, 'child'); + expect(controller.markdown, '- first\n - child\n'); + }); + + test('Backspace rejects a merge that cannot represent descendants', () { + final document = parser + .parse(filePath: 'topic.md', source: 'intro\n\n- second\n - child\n') + .busyDocument; + final nestedItem = document.blocks.last; + final controller = BusyMarkWysiwygDocumentController(document: document); + final originalMarkdown = controller.markdown; + + final result = controller.applyBackspaceAtStart(nestedItem.id); + + expect(result, isNull); + expect(controller.markdown, originalMarkdown); + expect( + controller.blockById(nestedItem.id)?.children.single.plainText, + 'child', + ); + }); + + test('complete clipboard snapshots reconstruct a table transactionally', () { + final tableDocument = parser + .parse( + filePath: 'table.md', + source: '| A | B |\n| --- | --- |\n| one | **two** |\n', + ) + .busyDocument; + final table = tableDocument.blocks.single; + final targetDocument = parser + .parse(filePath: 'target.md', source: 'Target\n') + .busyDocument; + final controller = BusyMarkWysiwygDocumentController( + document: targetDocument, + ); + final target = controller.document.blocks.single; + final snapshot = busyMarkWysiwygImmutableBlockSnapshot(table); + final result = controller.insertStyledBlocksAtSelection( + blockId: target.id, + selectionStart: 0, + selectionEnd: target.plainText.length, + blocks: [ + BusyWysiwygStyledBlock( + kind: table.kind, + text: table.plainText, + ranges: const [], + attributes: table.attributes, + completeBlock: snapshot, + ), + ], + ); + + expect(result, isNotNull); + final pasted = controller.document.blocks.first; + expect(pasted.kind, BusyBlockKind.table); + expect(pasted.children, hasLength(2)); + expect(pasted.children.last.children.last.plainText, 'two'); + expect(controller.markdown, contains('| one | **two** |')); + expect(() => snapshot.children.add(table), throwsUnsupportedError); + expect(() => snapshot.attributes['bad'] = 'value', throwsUnsupportedError); + }); + + testWidgets('same-editor cut and paste preserves complete tables', ( + tester, + ) async { + const source = + 'Before\n\n| A | B |\n| --- | --- |\n| one | **two** |\n\nAfter\n'; + final document = parser + .parse(filePath: 'topic.md', source: source) + .busyDocument; + var markdown = source; + String? clipboardText; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (call) async { + if (call.method == 'Clipboard.setData') { + clipboardText = + (call.arguments as Map)['text'] as String?; + } else if (call.method == 'Clipboard.getData') { + return {'text': clipboardText}; + } + return null; + }, + ); + addTearDown(() { + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + null, + ); + }); + await tester.pumpWidget( + _app( + BusyMarkWysiwygEditor( + document: document, + onSourceChanged: (_, value) => markdown = value, + ), + ), + ); + await tester.pump(); + final first = tester.widget(find.byType(TextField).first); + first.focusNode!.requestFocus(); + await tester.pump(); + + Future shortcut(LogicalKeyboardKey key) async { + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(key); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(); + } + + await shortcut(LogicalKeyboardKey.keyA); + await shortcut(LogicalKeyboardKey.keyA); + await shortcut(LogicalKeyboardKey.keyX); + expect(markdown, isNot(contains('| one |'))); + await shortcut(LogicalKeyboardKey.keyV); + await tester.pump(); + + expect(markdown, contains('| A | B |')); + expect(markdown, contains('| one | **two** |')); + expect(markdown, contains('Before')); + expect(markdown, contains('After')); + }); + + test('table cell edits normalize every newline form before updating', () { + final document = parser + .parse(filePath: 'topic.md', source: '| A |\n| --- |\n| value |\n') + .busyDocument; + final controller = BusyMarkWysiwygDocumentController(document: document); + final table = document.blocks.single; + final cell = table.children.last.children.single; + + controller.updateTableCellText(table.id, cell.id, 'A\n\nB\r\nC\rD'); + + expect(controller.blockById(cell.id)?.plainText, 'A B C D'); + expect(controller.markdown, contains('| A B C D |')); + }); + + test('table cell edits preserve block-marker prefixes as literal text', () { + for (final value in const ['# title', '- item', '1. item', '> quote']) { + final document = parser + .parse(filePath: 'topic.md', source: '| A |\n| --- |\n| value |\n') + .busyDocument; + final controller = BusyMarkWysiwygDocumentController(document: document); + final table = document.blocks.single; + final cell = table.children.last.children.single; + + controller.updateTableCellText(table.id, cell.id, value); + + expect(controller.blockById(cell.id)?.plainText, value, reason: value); + expect(controller.markdown, contains('| $value |'), reason: value); + } + }); + + test('ordinary table cell edits preserve formatting and links', () { + final document = parser + .parse( + filePath: 'topic.md', + source: + '| Value |\n' + '| --- |\n' + '| **bold** and [link](https://example.com) tail |\n', + ) + .busyDocument; + final controller = BusyMarkWysiwygDocumentController(document: document); + final table = document.blocks.single; + final cell = table.children.last.children.single; + + controller.updateTableCellText(table.id, cell.id, '${cell.plainText}!'); + + final edited = controller.blockById(cell.id)!; + expect( + edited.inlines.where((inline) => inline.kind == BusyInlineKind.strong), + hasLength(1), + ); + final link = edited.inlines.singleWhere( + (inline) => inline.kind == BusyInlineKind.link, + ); + expect(link.destination, 'https://example.com'); + expect( + controller.markdown, + '| Value |\n' + '| --- |\n' + '| **bold** and [link](https://example.com) tail! |\n', + ); + }); + + test('ordinary cell edits retain toolbar-applied formatting', () { + final document = parser + .parse(filePath: 'topic.md', source: '| Value |\n| --- |\n| cell |\n') + .busyDocument; + final controller = BusyMarkWysiwygDocumentController(document: document); + final table = document.blocks.single; + final cell = table.children.last.children.single; + controller.applyInlineCommand( + cell.id, + BusyWysiwygInlineCommand.bold, + 0, + cell.plainText.length, + ); + + controller.updateTableCellText(table.id, cell.id, 'cell!'); + + expect(controller.markdown, '| Value |\n| --- |\n| **cell!** |\n'); + }); + + test('structural edits preserve empty-alt inline images', () { + BusyMarkWysiwygDocumentController open(String source) { + return BusyMarkWysiwygDocumentController( + document: parser + .parse(filePath: 'topic.md', source: source) + .busyDocument, + ); + } + + final split = open('Before ![](image.png) after.\n'); + final splitBlock = split.document.blocks.single; + split.applyEnterAt(splitBlock.id, splitBlock.plainText.length); + expect(split.markdown, 'Before ![](image.png) after.\n\n'); + expect( + split.document.blocks.first.inlines.where( + (inline) => inline.kind == BusyInlineKind.image, + ), + hasLength(1), + ); + + final formatted = open('Before ![](image.png) after.\n'); + final formattedBlock = formatted.document.blocks.single; + formatted.applyInlineCommand( + formattedBlock.id, + BusyWysiwygInlineCommand.bold, + 0, + 'Before'.length, + ); + expect(formatted.markdown, '**Before** ![](image.png) after.\n'); + + final merged = open('Lead\n\nBefore ![](image.png) after.\n'); + final current = merged.document.blocks.last; + merged.applyBackspaceAtStart(current.id); + expect(merged.markdown, 'LeadBefore ![](image.png) after.\n'); + }); + + test('structural edits preserve existing hard line breaks', () { + BusyMarkWysiwygDocumentController open(String source) { + return BusyMarkWysiwygDocumentController( + document: parser + .parse(filePath: 'topic.md', source: source) + .busyDocument, + ); + } + + final split = open('Alpha \nBeta\n'); + final splitBlock = split.document.blocks.single; + split.applyEnterAt(splitBlock.id, splitBlock.plainText.length); + expect(split.markdown, 'Alpha \nBeta\n\n'); + + final formatted = open('Alpha \nBeta\n'); + final formattedBlock = formatted.document.blocks.single; + formatted.applyInlineCommand( + formattedBlock.id, + BusyWysiwygInlineCommand.bold, + 0, + 'Alpha'.length, + ); + expect(formatted.markdown, '**Alpha** \nBeta\n'); + + final merged = open('Lead\n\nAlpha \nBeta\n'); + merged.applyBackspaceAtStart(merged.document.blocks.last.id); + expect(merged.markdown, 'LeadAlpha \nBeta\n'); + }); + + test('editing preserves a literal bang before an inline link', () { + final document = parser + .parse( + filePath: 'topic.md', + source: + r'\![guide](guide.md) tail' + '\n', + ) + .busyDocument; + final controller = BusyMarkWysiwygDocumentController(document: document); + final block = document.blocks.single; + + controller.updateBlockText(block.id, '${block.plainText} extended'); + + expect( + controller.markdown, + r'\![guide](guide.md) tail extended' + '\n', + ); + final reparsed = parser.parse( + filePath: 'topic.md', + source: controller.markdown, + ); + expect(reparsed.images, isEmpty); + expect(reparsed.links, hasLength(1)); + }); + + test('editing escapes block syntax at the start of a list item', () { + final document = parser + .parse(filePath: 'topic.md', source: '- \\# label\n') + .busyDocument; + final controller = BusyMarkWysiwygDocumentController(document: document); + final item = document.blocks.single; + + controller.updateBlockText(item.id, '# label edited'); + + expect(controller.markdown, '- \\# label edited\n'); + final reparsed = parser.parse( + filePath: 'topic.md', + source: controller.markdown, + ); + expect(reparsed.busyDocument.blocks.single.plainText, '# label edited'); + expect(reparsed.busyDocument.blocks.single.children, isEmpty); + }); + + testWidgets('table field edits preserve rendered formatting and links', ( + tester, + ) async { + final document = parser + .parse( + filePath: 'topic.md', + source: + '| Value |\n' + '| --- |\n' + '| **bold** and [link](https://example.com) tail |\n', + ) + .busyDocument; + final cell = document.blocks.single.children.last.children.single; + var markdown = ''; + await tester.pumpWidget( + _app( + BusyMarkWysiwygEditor( + document: document, + onSourceChanged: (_, value) => markdown = value, + ), + ), + ); + await tester.pump(); + + await tester.enterText(find.byKey(ValueKey(cell.id)), '${cell.plainText}!'); + await tester.pump(); + + expect(markdown, contains('**bold**')); + expect(markdown, contains('[link](https://example.com)')); + }); + + testWidgets('table fields reject multiline input and reconcile the model', ( + tester, + ) async { + final document = parser + .parse(filePath: 'topic.md', source: '| A |\n| --- |\n| value |\n') + .busyDocument; + final table = document.blocks.single; + final cell = table.children.last.children.single; + var markdown = ''; + await tester.pumpWidget( + _app( + BusyMarkWysiwygEditor( + document: document, + onSourceChanged: (_, value) => markdown = value, + ), + ), + ); + await tester.pump(); + + final finder = find.byKey(ValueKey(cell.id)); + await tester.enterText(finder, 'A\n\nB'); + await tester.pump(); + final field = tester.widget(finder); + + expect(field.maxLines, 1); + expect(field.controller?.text, 'AB'); + expect(markdown, contains('| AB |')); + }); + + testWidgets('table fields preserve block-marker-prefixed cell values', ( + tester, + ) async { + final document = parser + .parse(filePath: 'topic.md', source: '| A |\n| --- |\n| value |\n') + .busyDocument; + final table = document.blocks.single; + final cell = table.children.last.children.single; + var markdown = ''; + await tester.pumpWidget( + _app( + BusyMarkWysiwygEditor( + document: document, + onSourceChanged: (_, value) => markdown = value, + ), + ), + ); + await tester.pump(); + + final finder = find.byKey(ValueKey(cell.id)); + for (final value in const ['# title', '- item', '1. item', '> quote']) { + await tester.enterText(finder, value); + await tester.pump(); + + expect( + tester.widget(finder).controller?.text, + value, + reason: value, + ); + expect(markdown, contains('| $value |'), reason: value); + } + }); + + testWidgets('exact source ranges select repeated text and table cells', ( + tester, + ) async { + const source = + 'same same Case case cat scatter cat a1 a222\n\n' + '| A | B |\n| --- | --- |\n| first | needle |\n'; + final document = parser + .parse(filePath: 'topic.md', source: source) + .busyDocument; + final table = document.blocks.last; + final needleCell = table.children.last.children.last; + var request = 1; + + Widget editor(BusyMarkWysiwygSourceRange range) => _app( + BusyMarkWysiwygEditor( + document: document, + scrollRequest: request, + scrollToSourceRange: range, + onSourceChanged: (_, _) {}, + ), + ); + + Future expectParagraphRange(int start, int end) async { + await tester.pumpWidget( + editor(BusyMarkWysiwygSourceRange(startOffset: start, endOffset: end)), + ); + await tester.pump(); + await tester.pump(); + final paragraph = tester.widget(find.byType(TextField).first); + expect( + paragraph.controller?.selection, + TextSelection(baseOffset: start, extentOffset: end), + ); + request++; + } + + // Repeated literal: navigate to the second occurrence. + await expectParagraphRange(5, 9); + // Case-sensitive result: preserve the exact uppercase occurrence. + final uppercaseStart = source.indexOf('Case'); + await expectParagraphRange(uppercaseStart, uppercaseStart + 4); + // Whole-word result: skip "cat" inside "scatter". + final wholeWordStart = source.lastIndexOf('cat', source.indexOf(' a1')); + await expectParagraphRange(wholeWordStart, wholeWordStart + 3); + // Regex result: use the actual match length, not the pattern length. + final regexStart = source.indexOf('a222'); + await expectParagraphRange(regexStart, regexStart + 4); + + final needleStart = source.indexOf('needle'); + await tester.pumpWidget( + editor( + BusyMarkWysiwygSourceRange( + startOffset: needleStart, + endOffset: needleStart + 'needle'.length, + ), + ), + ); + await tester.pump(); + await tester.pump(); + final cellField = tester.widget( + find.byKey(ValueKey(needleCell.id)), + ); + expect( + cellField.controller?.selection, + const TextSelection(baseOffset: 0, extentOffset: 6), + ); + }); + + testWidgets('exact source navigation rebases spans after earlier edits', ( + tester, + ) async { + const source = 'alpha\n\nneedle\n'; + final document = parser + .parse(filePath: 'topic.md', source: source) + .busyDocument; + late StateSetter rebuild; + var markdown = source; + var request = 0; + BusyMarkWysiwygSourceRange? range; + + await tester.pumpWidget( + _app( + StatefulBuilder( + builder: (context, setState) { + rebuild = setState; + return BusyMarkWysiwygEditor( + document: document, + scrollRequest: request, + scrollToSourceRange: range, + onSourceChanged: (_, value) => markdown = value, + ); + }, + ), + ), + ); + await tester.pump(); + await tester.enterText(find.byType(TextField).first, 'alpha expanded'); + await tester.pump(); + + final needleStart = markdown.indexOf('needle'); + rebuild(() { + request++; + range = BusyMarkWysiwygSourceRange( + startOffset: needleStart, + endOffset: needleStart + 6, + ); + }); + await tester.pump(); + await tester.pump(); + + final needle = tester.widget(find.byType(TextField).at(1)); + expect( + needle.controller?.selection, + const TextSelection(baseOffset: 0, extentOffset: 6), + ); + }); + + testWidgets('source navigation expands collapsed Writerside ancestors', ( + tester, + ) async { + const source = '''## Details {collapsible="true"} + +Hidden needle. +'''; + final document = parser + .parse( + filePath: 'topic.md', + source: source, + mode: MarkdownMode.writersideMarkdown, + validateLocalReferences: false, + ) + .busyDocument; + final start = source.indexOf('needle'); + + await tester.pumpWidget( + _app( + BusyMarkWysiwygEditor( + document: document, + scrollRequest: 1, + scrollToSourceRange: BusyMarkWysiwygSourceRange( + startOffset: start, + endOffset: start + 6, + ), + onSourceChanged: (_, _) {}, + ), + ), + ); + await tester.pump(); + await tester.pump(); + await tester.pump(); + + expect(find.text('Hidden needle.'), findsOneWidget); + final hiddenField = tester.widget( + find.widgetWithText(TextField, 'Hidden needle.'), + ); + expect( + hiddenField.controller?.selection, + const TextSelection(baseOffset: 7, extentOffset: 13), + ); + }); + + testWidgets('outline navigation expands a collapsed heading ancestor', ( + tester, + ) async { + const source = '''## Details {collapsible="true"} + +### Hidden heading + +Body. +'''; + final document = parser + .parse( + filePath: 'topic.md', + source: source, + mode: MarkdownMode.writersideMarkdown, + validateLocalReferences: false, + ) + .busyDocument; + final hiddenHeading = document.blocks.firstWhere( + (block) => block.plainText == 'Hidden heading', + ); + + await tester.pumpWidget( + _app( + BusyMarkWysiwygEditor( + document: document, + scrollRequest: 1, + scrollToBlockId: hiddenHeading.id, + onSourceChanged: (_, _) {}, + ), + ), + ); + await tester.pump(); + await tester.pump(); + await tester.pump(); + + expect(find.text('Hidden heading'), findsOneWidget); + }); + + testWidgets('table cells own shortcuts, formatting, and restored selection', ( + tester, + ) async { + final document = parser + .parse(filePath: 'topic.md', source: '| A |\n| --- |\n| cell |\n') + .busyDocument; + final table = document.blocks.single; + final cell = table.children.last.children.single; + var markdown = ''; + WysiwygEditorSessionState? session; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (call) async => + call.method == 'Clipboard.getData' ? {'text': 'new\nline'} : null, + ); + addTearDown(() { + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + null, + ); + }); + + await tester.pumpWidget( + _app( + BusyMarkWysiwygEditor( + document: document, + initialSessionState: WysiwygEditorSessionState( + activeBlockId: table.id, + activeCellId: cell.id, + anchorBlockId: cell.id, + anchorOffset: 0, + extentBlockId: cell.id, + extentOffset: 4, + ), + onSessionChanged: (_, value) => session = value, + onSourceChanged: (_, value) => markdown = value, + ), + ), + ); + await tester.pump(); + await tester.pump(); + + final finder = find.byKey(ValueKey(cell.id)); + var field = tester.widget(finder); + expect( + field.controller?.selection, + const TextSelection(baseOffset: 0, extentOffset: 4), + ); + final blockquoteButton = tester + .widgetList( + find.byType(BusyMarkHeaderIconButton), + ) + .firstWhere((button) => button.icon == BusyMarkGlyphs.blockquote); + expect(blockquoteButton.onPressed, isNull); + await tester.tap(find.byIcon(BusyMarkGlyphs.bold)); + await tester.pump(); + expect(markdown, contains('**cell**')); + + field = tester.widget(finder); + field.focusNode!.requestFocus(); + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyA); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(); + expect( + field.controller?.selection, + const TextSelection(baseOffset: 0, extentOffset: 4), + ); + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyV); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(); + await tester.pump(); + expect(markdown, contains('| **new line** |')); + expect(session?.activeBlockId, table.id); + expect(session?.activeCellId, cell.id); + expect(session?.anchorBlockId, cell.id); + expect(session?.extentBlockId, cell.id); + }); + + test('external undo groups continuous typing but not transactions', () { + final initial = DocumentBuffer.untitled(id: 'one', name: 'one.md'); + final first = initial.edited('a', undoGroup: 'typing-1'); + final second = first.edited('ab', undoGroup: 'typing-1'); + final transaction = second.edited('ab\n', undoGroup: null); + + expect(first.editorState.undoState.undo.map((state) => state.text), ['']); + expect(second.editorState.undoState.undo.map((state) => state.text), ['']); + expect(transaction.editorState.undoState.undo.map((state) => state.text), [ + '', + 'ab', + ]); + }); + + testWidgets('external history coalesces editor typing without local copies', ( + tester, + ) async { + final document = parser + .parse(filePath: 'topic.md', source: 'a\n') + .busyDocument; + final key = GlobalKey(); + var buffer = DocumentBuffer.untitled( + id: 'one', + name: 'one.md', + text: 'a\n', + ); + final groups = []; + + await tester.pumpWidget( + _app( + BusyMarkWysiwygEditor( + key: key, + document: document, + useExternalUndoHistory: true, + onSourceChanged: (_, _) {}, + onTransactionalSourceChanged: (_, value, group) { + groups.add(group); + buffer = buffer.edited(value, undoGroup: group); + }, + ), + ), + ); + await tester.pump(); + await tester.enterText(find.byType(TextField).first, 'ab'); + await tester.pump(); + await tester.enterText(find.byType(TextField).first, 'abc'); + await tester.pump(); + + expect(groups, hasLength(2)); + expect(groups.first, isNotNull); + expect(groups.last, groups.first); + expect(buffer.editorState.undoState.undo.map((state) => state.text), [ + 'a\n', + ]); + final dynamic state = tester.state(find.byType(BusyMarkWysiwygEditor)); + expect(state.debugUndoSnapshotCount, 0); + }); + + testWidgets('undo controllers are disposed when blocks disappear', ( + tester, + ) async { + final key = GlobalKey(); + final first = parser + .parse(filePath: 'topic.md', source: 'One\n\nTwo\n') + .busyDocument; + final second = parser + .parse(filePath: 'topic.md', source: 'One\n') + .busyDocument; + + Widget editor(BusyDocument document) => _app( + BusyMarkWysiwygEditor( + key: key, + document: document, + onSourceChanged: (_, _) {}, + ), + ); + + await tester.pumpWidget(editor(first)); + await tester.pump(); + final dynamic state = tester.state(find.byType(BusyMarkWysiwygEditor)); + expect(state.debugUndoControllerCount, 2); + + await tester.pumpWidget(editor(second)); + await tester.pump(); + expect(state.debugUndoControllerCount, 1); + }); +} + +Widget _app(Widget child) { + return MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold(body: SizedBox(width: 900, height: 640, child: child)), + ); +} diff --git a/test/src/wysiwyg_visualization_diagnostic_test.dart b/test/src/wysiwyg_visualization_diagnostic_test.dart index 98b585df..30d5b9da 100644 --- a/test/src/wysiwyg_visualization_diagnostic_test.dart +++ b/test/src/wysiwyg_visualization_diagnostic_test.dart @@ -117,6 +117,7 @@ void main() { focusNode: focusNode, onChanged: (_) {}, onTableCellChanged: (_, _) {}, + onTableCellSourceChanged: (_, _) {}, onTableRowInserted: (_, {required after}) {}, onTableRowDeleted: (_) {}, onTableColumnInserted: (_, {required after}) {}, diff --git a/tools/build_install_snap_local.sh b/tools/build_install_snap_local.sh index 5732359f..d6bf304f 100755 --- a/tools/build_install_snap_local.sh +++ b/tools/build_install_snap_local.sh @@ -27,7 +27,8 @@ Options: Environment overrides are also supported: VERSION, OUT, SNAP_ROOT, SNAP_SCAFFOLD, SNAP_NAME, BINARY_NAME, APP_ID, INSTALL_AFTER_BUILD=0, RUN_AFTER_INSTALL=0, SKIP_TESTS=1, BUNDLE_GIT=0, - DART_DEFINE_FROM_FILE + DART_DEFINE_FROM_FILE, BUSYMARK_FLUTTER_BIN, BUSYMARK_FLUTTER_CACHE, + BUSYMARK_BOOTSTRAP_FLUTTER=0, BUSYMARK_BUILD_TMP_ROOT EOF } @@ -42,6 +43,121 @@ project_value() { pubspec.yaml | head -n 1 } +project_flutter_version() { + sed -nE \ + "/^environment:/,/^[^[:space:]#]/{s/^[[:space:]]+flutter:[[:space:]]*['\"]?([^'\"[:space:]]+)['\"]?[[:space:]]*$/\\1/p}" \ + pubspec.yaml | head -n 1 +} + +flutter_binary_version() { + local executable + executable="$(readlink -f "$1")" + local sdk_root + sdk_root="$(cd "$(dirname "$executable")/.." && pwd)" + local version="" + + version="$(git -C "$sdk_root" describe --tags --exact-match HEAD 2>/dev/null || true)" + if [[ -n "$version" ]]; then + echo "$version" + return + fi + if [[ -f "$sdk_root/bin/cache/flutter.version.json" ]]; then + python3 -c \ + 'import json,sys; print(json.load(open(sys.argv[1]))["frameworkVersion"])' \ + "$sdk_root/bin/cache/flutter.version.json" + return + fi + "$1" --version --machine | + python3 -c 'import json,sys; print(json.load(sys.stdin)["frameworkVersion"])' +} + +select_project_flutter() { + local required_version="$1" + local explicit_bin="${BUSYMARK_FLUTTER_BIN:-}" + local candidate="" + local actual_version="" + + if [[ -n "$explicit_bin" ]]; then + candidate="$(command -v "$explicit_bin" || true)" + [[ -n "$candidate" && -x "$candidate" ]] || \ + fail "BUSYMARK_FLUTTER_BIN is not executable: $explicit_bin" + actual_version="$(flutter_binary_version "$candidate")" || \ + fail "could not determine Flutter version from $candidate" + [[ "$actual_version" == "$required_version" ]] || \ + fail "BUSYMARK_FLUTTER_BIN provides Flutter $actual_version; project requires $required_version" + FLUTTER_BIN="$candidate" + return + fi + + candidate="$PROJECT_ROOT/.fvm/flutter_sdk/bin/flutter" + if [[ -x "$candidate" ]]; then + actual_version="$(flutter_binary_version "$candidate")" || true + if [[ "$actual_version" == "$required_version" ]]; then + FLUTTER_BIN="$candidate" + return + fi + fi + + candidate="$(command -v flutter || true)" + if [[ -n "$candidate" && -x "$candidate" ]]; then + actual_version="$(flutter_binary_version "$candidate")" || true + if [[ "$actual_version" == "$required_version" ]]; then + FLUTTER_BIN="$candidate" + return + fi + if [[ -n "$actual_version" ]]; then + echo "Flutter $actual_version on PATH does not match required $required_version." + fi + fi + + local cache_root="${BUSYMARK_FLUTTER_CACHE:-${XDG_CACHE_HOME:-$HOME/.cache}/busymark/flutter}" + local cached_sdk="$cache_root/$required_version" + candidate="$cached_sdk/bin/flutter" + if [[ -x "$candidate" ]]; then + actual_version="$(flutter_binary_version "$candidate")" || true + [[ "$actual_version" == "$required_version" ]] || \ + fail "cached Flutter SDK at $cached_sdk reports version ${actual_version:-unknown}" + FLUTTER_BIN="$candidate" + return + fi + + [[ "${BUSYMARK_BOOTSTRAP_FLUTTER:-1}" == "1" ]] || \ + fail "Flutter $required_version is unavailable; install it or set BUSYMARK_FLUTTER_BIN" + [[ ! -e "$cached_sdk" ]] || \ + fail "cached Flutter SDK is incomplete: $cached_sdk" + + local partial_sdk="${cached_sdk}.partial.$$" + mkdir -p "$cache_root" + echo "== Bootstrap Flutter $required_version ==" + echo "Cache: $cached_sdk" + if ! git clone --depth 1 --branch "$required_version" \ + https://github.com/flutter/flutter.git "$partial_sdk"; then + rm -rf "$partial_sdk" + fail "could not download Flutter $required_version" + fi + mv "$partial_sdk" "$cached_sdk" + candidate="$cached_sdk/bin/flutter" + actual_version="$(flutter_binary_version "$candidate")" || \ + fail "could not initialize Flutter at $cached_sdk" + [[ "$actual_version" == "$required_version" ]] || \ + fail "downloaded Flutter reports $actual_version; expected $required_version" + FLUTTER_BIN="$candidate" +} + +prepare_build_tmp() { + local build_tmp_root="${BUSYMARK_BUILD_TMP_ROOT:-${XDG_CACHE_HOME:-$HOME/.cache}/busymark/tmp}" + mkdir -p "$build_tmp_root" + BUSYMARK_BUILD_TMP_DIR="$(mktemp -d "$build_tmp_root/snap-build.XXXXXX")" || \ + fail "could not create build temporary directory under $build_tmp_root" + export TMPDIR="$BUSYMARK_BUILD_TMP_DIR" +} + +cleanup_build_tmp() { + if [[ -n "${BUSYMARK_BUILD_TMP_DIR:-}" ]]; then + rm -rf -- "$BUSYMARK_BUILD_TMP_DIR" || true + fi +} + cmake_value() { local key="$1" sed -nE "s/^[[:space:]]*set\\(${key}[[:space:]]+\"([^\"]+)\"\\).*/\\1/p" \ @@ -254,6 +370,13 @@ PROJECT_NAME="$(project_value name)" VERSION="${VERSION_ARG:-${VERSION:-$(project_value version)}}" [[ -n "$VERSION" ]] || fail "could not read version from pubspec.yaml" +REQUIRED_FLUTTER_VERSION="$(project_flutter_version)" +[[ -n "$REQUIRED_FLUTTER_VERSION" ]] || \ + fail "pubspec.yaml must declare an exact environment.flutter version" +select_project_flutter "$REQUIRED_FLUTTER_VERSION" +prepare_build_tmp +trap cleanup_build_tmp EXIT + SNAP_NAME="${SNAP_NAME:-$PROJECT_NAME}" BINARY_NAME="${BINARY_NAME:-$(cmake_value BINARY_NAME)}" BINARY_NAME="${BINARY_NAME:-$PROJECT_NAME}" @@ -271,22 +394,28 @@ echo "Project: $PROJECT_ROOT" echo "Version: $VERSION" echo "Binary: $BINARY_NAME" echo "App ID: $APP_ID" +echo "Flutter: $REQUIRED_FLUTTER_VERSION ($FLUTTER_BIN)" +echo "Temp: $BUSYMARK_BUILD_TMP_DIR" echo "Scaffold: $SNAP_SCAFFOLD" echo "Root: $SNAP_ROOT" echo "Output: $OUT" echo "Defines: $((${#DART_DEFINE_ARGS[@]} + ${#DART_DEFINE_FILE_ARGS[@]})) build-time entries" +echo "== Resolve locked dependencies ==" +"$FLUTTER_BIN" pub get --enforce-lockfile + if [[ "$SKIP_TESTS" != "1" ]]; then echo "== Validate source ==" - flutter analyze - flutter test --reporter=compact + "$FLUTTER_BIN" analyze --no-pub + "$FLUTTER_BIN" test --no-pub --reporter=compact else echo "== Validate source ==" echo "Skipping tests because SKIP_TESTS=1" fi echo "== Build Flutter Linux release ==" -flutter build linux --release "${DART_DEFINE_ARGS[@]}" "${DART_DEFINE_FILE_ARGS[@]}" +"$FLUTTER_BIN" build linux --release --no-pub \ + "${DART_DEFINE_ARGS[@]}" "${DART_DEFINE_FILE_ARGS[@]}" test -f "$BUNDLE_DIR/$BINARY_NAME" || fail "missing built binary: $BUNDLE_DIR/$BINARY_NAME" diff --git a/tools/visualization/render_engines.js b/tools/visualization/render_engines.js index 52ed16b9..15772b28 100644 --- a/tools/visualization/render_engines.js +++ b/tools/visualization/render_engines.js @@ -543,10 +543,25 @@ async function handleRequest(request) { if (!svg) throw new Error('Raster input is not SVG.') svg.setAttribute('width', String(pixelWidth)) svg.setAttribute('height', String(pixelHeight)) - svg.style.width = `${pixelWidth}px` - svg.style.height = `${pixelHeight}px` + // Mermaid emits width="100%" together with an inline max-width equal to + // its logical viewBox width. When the raster scale is greater than one, + // that max-width otherwise keeps the drawing at logical size inside a + // larger pixel canvas, producing a half-sized, left-aligned preview. + // The raster canvas owns the outer geometry; the SVG viewBox continues + // to preserve the diagram's aspect ratio. + svg.style.setProperty('width', `${pixelWidth}px`, 'important') + svg.style.setProperty('height', `${pixelHeight}px`, 'important') + svg.style.setProperty('max-width', 'none', 'important') + svg.style.setProperty('max-height', 'none', 'important') await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))) - return { rasterReady: true, pixelWidth, pixelHeight } + const renderedBounds = svg.getBoundingClientRect() + return { + rasterReady: true, + pixelWidth, + pixelHeight, + renderedWidth: renderedBounds.width, + renderedHeight: renderedBounds.height, + } } default: throw new Error('Unknown visualization operation.') diff --git a/tools/visualization_smoke.py b/tools/visualization_smoke.py index 7e2ebd92..7fec234e 100755 --- a/tools/visualization_smoke.py +++ b/tools/visualization_smoke.py @@ -216,9 +216,13 @@ def _snapshot_finished( raise AssertionError( f"Raster snapshot was too small: {surface.get_width()}x{surface.get_height()}" ) - if opaque_pixels < 1000 or len(colors) < 8: + if opaque_pixels < 1000: raise AssertionError( - f"Raster snapshot was visually empty: {opaque_pixels} pixels, {len(colors)} colors" + f"Raster snapshot was transparent: {opaque_pixels} opaque pixels" + ) + if len(colors) < 8: + raise AssertionError( + f"Raster snapshot lacked visual variation: {len(colors)} colors" ) print(f"PASS {case['name']} visual snapshot") except Exception as error: # noqa: BLE001 @@ -366,6 +370,11 @@ def expect_raster_ready(response: dict[str, object]) -> None: raise AssertionError(f"WebKit did not prepare the raster image: {response}") if response.get("pixelWidth") != 1200 or response.get("pixelHeight") != 800: raise AssertionError(f"Unexpected raster dimensions: {response}") + if ( + response.get("renderedWidth") != 1200 + or response.get("renderedHeight") != 800 + ): + raise AssertionError(f"SVG did not fill the raster canvas: {response}") def d2_smoke(executable: Path) -> tuple[list[str], dict[str, str]]: @@ -686,6 +695,29 @@ def main() -> int: if "D2 foreignObject" in d2_outputs else [] ), + { + "name": "Responsive SVG raster scaling", + "uri": "busymark-render://app/harness.html", + "request": { + "operation": "rasterizeSvg", + "svg": ( + '' + '' + '' + '' + '' + '' + ), + "width": 600, + "height": 400, + "scale": 2, + }, + "validator": expect_raster_ready, + "snapshot": True, + }, *[ { "name": f"PlantUML {index + 1}/{len(plantuml)}",