From 87eebfe9847b62752450f33785c386883818ed29 Mon Sep 17 00:00:00 2001 From: albert Date: Tue, 22 Sep 2026 16:14:08 -0700 Subject: [PATCH 01/14] Bump version to v0.5.1 --- lib/src/app/app_metadata.dart | 2 +- linux/io.busystack.busymark.metainfo.xml | 1 + pubspec.yaml | 2 +- snap/snapcraft.yaml | 2 +- test/src/visualization_packaging_audit_test.dart | 10 +++++----- 5 files changed, 9 insertions(+), 8 deletions(-) diff --git a/lib/src/app/app_metadata.dart b/lib/src/app/app_metadata.dart index 228206cc..2a7b207e 100644 --- a/lib/src/app/app_metadata.dart +++ b/lib/src/app/app_metadata.dart @@ -1 +1 @@ -const busyMarkAppVersion = '0.5.0'; +const busyMarkAppVersion = '0.5.1'; diff --git a/linux/io.busystack.busymark.metainfo.xml b/linux/io.busystack.busymark.metainfo.xml index e76cdb5c..cdb5d4e3 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 2c6470c0..23720de5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: busymark description: Local-first Markdown and Writerside-compatible documentation editor. publish_to: 'none' -version: 0.5.0 +version: 0.5.1 environment: sdk: ">=3.12.1 <4.0.0" diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index e0cfc2c9..f0be6b82 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -1,6 +1,6 @@ name: busymark title: BusyMark -version: "0.5.0" +version: "0.5.1" 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. diff --git a/test/src/visualization_packaging_audit_test.dart b/test/src/visualization_packaging_audit_test.dart index 41cdae63..cdd5d256 100644 --- a/test/src/visualization_packaging_audit_test.dart +++ b/test/src/visualization_packaging_audit_test.dart @@ -29,10 +29,10 @@ void main() { 'linux/io.busystack.busymark.metainfo.xml', ).readAsStringSync(); - expect(pubspec, contains(RegExp(r'^version: 0\.5\.0$', multiLine: true))); + expect(pubspec, contains(RegExp(r'^version: 0\.5\.1$', multiLine: true))); expect( snapcraft, - contains(RegExp(r'^version: "0\.5\.0"$', multiLine: true)), + contains(RegExp(r'^version: "0\.5\.1"$', multiLine: true)), ); expect(snapcraft, contains(RegExp(r'^grade: stable$', multiLine: true))); expect( @@ -57,9 +57,9 @@ void main() { ), ), ); - expect(metainfo, contains(' Date: Tue, 29 Sep 2026 20:33:11 -0700 Subject: [PATCH 02/14] Chore/snap security update (#36) * Bump Flutter version to 3.47.5 * Harden Snap artifact selection and add runtime verification in Linux CI workflow * Validate built Snap artifacts by checking build outcomes, workspace path boundaries, build start timestamp, and `snapcraft.yaml` hash integrity. * Add verification checks for Snap shared runtime libraries, WebKit helper processes, packaged Git/SSH tools, GStreamer plugins, symlinked fonts, and Yaru themes. * Optimize Snap packaging and deduplicate primed assets * Remove redundant platform-supplied stage packages from `snapcraft.yaml`. * Introduce `gnome-46-2404-prime-exclusions.amd64` to purge duplicate GNOME and GStreamer runtime files during priming. * Replace duplicate font copies and Yaru themes/icons in the snap prime phase with symlinks to staged system assets. * Optimize Snap packaging audit tests and configuration for GNOME extension runtime * Refactor audit tests to expect standard platform libraries (`libsecret-1-0`, `libhandy-1-0`, `libwebkit2gtk-4.1-0`, GTK/GLib, GStreamer base/good) to be provided by the GNOME extension rather than stage packages. * Update Snapcraft audit assertions to check for font, theme, and icon resource symlinking instead of directory copying. * Add packaging audit test cases verifying `gnome-46-2404-prime-exclusions` runtime library deduplication and retention of required private packages (`gstreamer1.0-libav`, plugins-bad/ugly, Noto fonts). * Enhance audit tests for local snap builder script output and GitHub Actions CI snap build step outcome checks. * Clarify development repack scope in `build_install_snap_local.sh` * Add documentation comments and script runtime notices clarifying that local builds perform a development repack that reuses scaffold dependencies. * Direct users to the clean Snapcraft procedure in `README.md` for release, security, and package change builds. * Add Snap release and security build guidelines to README * Document authoritative Snapcraft build steps for releases and security refreshes. * Clarify that `tools/build_install_snap_local.sh` is strictly for local development and not release artifacts. * Add password-manager-service plug and harden Linux Snap verification * Add `password-manager-service` plug to Snap configuration and update confinement documentation for AI provider key storage. * Enhance Linux CI workflow with strict dynamic library resolution checks (`inspect_dependencies` and `require_resolution`) for Snap packages. * Update local Snap build script to replace scaffold font symlinks with bundled fonts during local repack. * Add audit tests for local font repacking and confined runtime dependency validation. * Stabilize asynchronous Flutter test completion checks * Add headless Chrome test suite for DevTools evaluation --- .github/workflows/flutter-linux.yml | 121 +++- README.md | 27 +- .../writerside-toc-implementation.md | 6 +- docs/snap-confinement.md | 19 +- pubspec.lock | 2 +- pubspec.yaml | 2 +- snap/gnome-46-2404-prime-exclusions.amd64 | 198 +++++++ snap/snapcraft.yaml | 66 ++- test/src/ai_packaging_audit_test.dart | 5 +- test/src/app_smoke_test.dart | 46 +- test/src/headless_chrome_test.dart | 315 +++++++++++ test/src/local_history_controller_test.dart | 14 +- test/src/local_history_workspace_test.dart | 36 +- test/src/markdown_pdf_export_test.dart | 3 + test/src/native_headerbar_audit_test.dart | 518 +++++++++++++++++- test/src/source_editor_widget_test.dart | 36 +- test/src/spelling_workspace_test.dart | 24 +- .../visualization_packaging_audit_test.dart | 18 +- .../src/writerside_template_dialogs_test.dart | 85 ++- test/src/writerside_toc_regressions_test.dart | 22 +- .../writerside_video_player_host_test.dart | 14 +- test/src/wysiwyg_clipboard_test.dart | 260 ++++----- test/support/headless_chrome.dart | 210 +++++-- tools/build_install_snap_local.sh | 19 +- 24 files changed, 1823 insertions(+), 243 deletions(-) create mode 100644 snap/gnome-46-2404-prime-exclusions.amd64 create mode 100644 test/src/headless_chrome_test.dart diff --git a/.github/workflows/flutter-linux.yml b/.github/workflows/flutter-linux.yml index 575b53dd..e68ad663 100644 --- a/.github/workflows/flutter-linux.yml +++ b/.github/workflows/flutter-linux.yml @@ -230,6 +230,11 @@ jobs: - name: Check out repository uses: actions/checkout@v7 + - name: Prepare clean Snap artifact selection + run: | + rm -f ./*.snap + touch "${RUNNER_TEMP}/busymark-snap-build-start" + - name: Build strict Snap from a clean core24 environment (attempt 1) id: snapcraft uses: snapcore/action-build@v1 @@ -244,11 +249,27 @@ jobs: id: snap-artifact env: PRIMARY_SNAP: ${{ steps.snapcraft.outputs.snap }} + PRIMARY_OUTCOME: ${{ steps.snapcraft.outcome }} RETRY_SNAP: ${{ steps.snapcraft-retry.outputs.snap }} + RETRY_OUTCOME: ${{ steps.snapcraft-retry.outcome }} run: | - snap_path="${PRIMARY_SNAP:-$RETRY_SNAP}" + if [ "$PRIMARY_OUTCOME" = success ]; then + snap_path="$PRIMARY_SNAP" + else + test "$PRIMARY_OUTCOME" = failure + test "$RETRY_OUTCOME" = success + snap_path="$RETRY_SNAP" + fi test -n "$snap_path" test -f "$snap_path" + test "$snap_path" -nt "${RUNNER_TEMP}/busymark-snap-build-start" + snap_path="$(realpath "$snap_path")" + case "$snap_path" in + "$GITHUB_WORKSPACE"/*.snap) ;; + *) echo "Refusing Snap outside the clean checkout: $snap_path" >&2; exit 1 ;; + esac + test "$(unsquashfs -cat "$snap_path" snap/snapcraft.yaml | sha256sum | cut -d' ' -f1)" = \ + "$(sha256sum snap/snapcraft.yaml | cut -d' ' -f1)" echo "snap=$snap_path" >> "$GITHUB_OUTPUT" - name: Upload Snap artifact @@ -277,6 +298,104 @@ jobs: "$query" "$loader" | grep -q "\"svg\" 6 \"gdk-pixbuf\"" ' + - name: Verify shared runtimes, packaged tools, media, and resources + run: | + snap run --shell busymark -c ' + set -eu + inspect_dependencies() { + dependency_object="$1" + if dependency_output="$(ldd "$dependency_object" 2>&1)"; then + : + else + printf "Dependency inspection failed: %s\n%s\n" \ + "$dependency_object" "$dependency_output" >&2 + exit 1 + fi + case "$dependency_output" in + *"not found"*) + printf "Unresolved dependencies: %s\n%s\n" \ + "$dependency_object" "$dependency_output" >&2 + exit 1 + ;; + esac + } + require_resolution() { + required_library="$1" + required_path="$2" + resolved_path="$(printf "%s\n" "$dependency_output" | \ + awk -v name="$required_library" \ + "\$1 == name && \$2 == \"=>\" { print \$3; exit }")" + if [ -z "$resolved_path" ] || \ + ! expected_real="$(readlink -e "$required_path")" || \ + ! resolved_real="$(readlink -e "$resolved_path")" || \ + [ "$resolved_real" != "$expected_real" ]; then + printf "Unexpected resolution for %s in %s; expected %s\n%s\n" \ + "$required_library" "$dependency_object" \ + "$required_path" "$dependency_output" >&2 + exit 1 + fi + } + app_lib="$SNAP/usr/lib/$SNAP_LAUNCHER_ARCH_TRIPLET" + inspect_dependencies "$SNAP/busymark" + for library in \ + libhandy-1.so.0 libsecret-1.so.0 libwebkit2gtk-4.1.so.0 \ + libgtk-3.so.0 libglib-2.0.so.0 libpango-1.0.so.0; do + test ! -e "$app_lib/$library" + require_resolution "$library" \ + "$SNAP_DESKTOP_RUNTIME/usr/lib/$SNAP_LAUNCHER_ARCH_TRIPLET/$library" + done + graphics_lib="$SNAP/gpu-2404/usr/lib/$SNAP_LAUNCHER_ARCH_TRIPLET" + for library in \ + libX11.so.6 libXdamage.so.1 libXext.so.6 libXfixes.so.3 \ + libxcb-shm.so.0 libxcb.so.1 libwayland-client.so.0 \ + libwayland-cursor.so.0 libwayland-egl.so.1; do + test ! -e "$app_lib/$library" + test -e "$graphics_lib/$library" + require_resolution "$library" "$graphics_lib/$library" + done + for helper in WebKitWebProcess WebKitNetworkProcess WebKitGPUProcess; do + test -x "$SNAP_DESKTOP_RUNTIME/usr/lib/$SNAP_LAUNCHER_ARCH_TRIPLET/webkit2gtk-4.1/$helper" + done + test -x "$SNAP/usr/bin/git" + test -x "$SNAP/usr/bin/ssh" + test -x "$SNAP/usr/bin/setsid" + inspect_dependencies "$SNAP/usr/lib/git-core/git-remote-http" + require_resolution libcurl-gnutls.so.4 \ + "$app_lib/libcurl-gnutls.so.4" + GIT_EXEC_PATH="$SNAP/usr/lib/git-core" \ + "$SNAP/usr/bin/git" --exec-path | grep -Fx "$SNAP/usr/lib/git-core" + "$SNAP/usr/bin/ssh" -V + provider_plugins="$SNAP_DESKTOP_RUNTIME/usr/lib/$SNAP_LAUNCHER_ARCH_TRIPLET/gstreamer-1.0" + for plugin in libgstavi.so libgstisomp4.so libgstmatroska.so \ + libgstogg.so libgsttheora.so libgstvpx.so; do + test -f "$provider_plugins/$plugin" + inspect_dependencies "$provider_plugins/$plugin" + done + private_plugins="$SNAP/usr/lib/$SNAP_LAUNCHER_ARCH_TRIPLET/gstreamer-1.0" + for plugin in libgstavi.so libgstisomp4.so libgstmatroska.so \ + libgstogg.so libgsttheora.so libgstvpx.so; do + test ! -e "$private_plugins/$plugin" + done + for plugin in libgstlibav.so libgstvideoparsersbad.so \ + libgstmpeg2dec.so; do + test -f "$private_plugins/$plugin" + inspect_dependencies "$private_plugins/$plugin" + done + test -L "$SNAP/share/busymark/fonts" + test "$(readlink "$SNAP/share/busymark/fonts")" = \ + ../../usr/share/fonts/truetype/noto + test -f "$SNAP/share/busymark/fonts/NotoSans-Regular.ttf" + test -f "$SNAP/usr/share/doc/fonts-noto-core/copyright" + test -f "$SNAP/usr/share/doc/fonts-noto-mono/copyright" + for resource_kind in themes icons; do + for resource_name in Yaru Yaru-dark; do + resource="$SNAP/share/$resource_kind/$resource_name" + test -L "$resource" + test -e "$resource" + done + done + ' + - name: Install one strict-Snap dictionary and verify refresh/offline use run: | report="$HOME/snap/busymark/common/spelling-release.json" diff --git a/README.md b/README.md index f07efe3e..e0cd2a22 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ for filesystem and Git-integration details. ## Run from source -BusyMark currently uses Flutter 3.47.4. Install the Linux packages required by +BusyMark currently uses Flutter 3.47.5. Install the Linux packages required by Flutter and BusyMark's CMake configuration: ```bash @@ -89,6 +89,31 @@ PDF, and sandbox verification. Those packages are not required for an ordinary local build; see [the Linux workflow](.github/workflows/flutter-linux.yml) when reproducing release verification. +## Snap release and security builds + +Release, security-refresh, and packaging-change artifacts must be built from +the current `snap/snapcraft.yaml` in a clean Ubuntu 24.04 amd64 Snapcraft build +environment. The authoritative build sequence is: + +```bash +snapcraft --version +snapcraft expand-extensions +snapcraft clean +snapcraft +sha256sum ./busymark_*.snap +``` + +Inspect and test the exact file emitted by the final `snapcraft` command. A +failed build must not fall back to an older `.snap`. The `snap` job in +[the Linux workflow](.github/workflows/flutter-linux.yml) follows this route, +installs that selected artifact, and runs the strict-confinement smoke checks. + +`tools/build_install_snap_local.sh` is intentionally different: it replaces +the Flutter payload in an installed Snap scaffold for quick development and +may retain that scaffold's Ubuntu libraries and bundled tools. Even with +`--no-install`, its output is not a dependency refresh and must not be used for +a release, a security update, or validation of `stage-packages` changes. + ## Screenshots diff --git a/docs/development/writerside-toc-implementation.md b/docs/development/writerside-toc-implementation.md index bf153be8..a18d5efb 100644 --- a/docs/development/writerside-toc-implementation.md +++ b/docs/development/writerside-toc-implementation.md @@ -10,7 +10,7 @@ parity**; retained safety and integration differences are listed below. The receiving checkout was clean at `c3b53ff` (BusyMark 0.4.2). The named handoff implementation points matched the receiving source; no archive was copied over it. -Flutter 3.47.4 and Dart 3.13.2 were used without upgrading dependencies. +Flutter 3.47.5 and Dart 3.13.2 were used without upgrading dependencies. User decisions: retain BusyMark AI, Git, clipboard, and file commands **after** the Writerside actions; use installed Writerside **2026.07.8925** for undocumented @@ -344,7 +344,7 @@ production controller/services. Only desktop host/theme and persistence services are isolated. Their framework menu fallback is test-only; these checks do not replace or claim a new recording of the native GTK evidence above. -Review verification (Flutter 3.47.4 / Dart 3.13.2): +Review verification (Flutter 3.47.5 / Dart 3.13.2): - Full `flutter test --no-pub --concurrency=2 --file-reporter json:/tmp/busymark-toc-review-full.json`: exit 0, **1,921 passed, 58 optional @@ -404,7 +404,7 @@ remain explicitly documented adaptations. Full original-Writerside parity is not claimed. Translations are BusyMark translations, not verified original JetBrains locale strings. -Verification for this follow-up (Flutter 3.47.4 / Dart 3.13.2): +Verification for this follow-up (Flutter 3.47.5 / Dart 3.13.2): - Full `flutter test --no-pub --concurrency=2 --file-reporter json:/tmp/busymark-title-full.json`: exit 0, **1,938 passed, 58 optional integration skips**; 17 new regressions. diff --git a/docs/snap-confinement.md b/docs/snap-confinement.md index fb70b43d..567e6b6b 100644 --- a/docs/snap-confinement.md +++ b/docs/snap-confinement.md @@ -5,10 +5,21 @@ Typst, D2, WebKitGTK, and its other runtime dependencies rather than executing arbitrary host programs. The application plugs the standard desktop, display, audio, network, `home`, -`removable-media`, and `ssh-keys` interfaces. This supports documentation -projects in ordinary home-directory locations and connected removable media. -Interfaces that expose sensitive SSH material are controlled by snapd and may -require an explicit connection on the installed system. +`removable-media`, `ssh-keys`, and `password-manager-service` interfaces. This +supports documentation projects in ordinary home-directory locations and +connected removable media. Interfaces that expose SSH keys or the system +credential store are not automatically connected. Connect only the access you +need on the installed system: + +```bash +sudo snap connect busymark:ssh-keys +sudo snap connect busymark:password-manager-service +``` + +BusyMark's AI provider keys use the system credential store. Saving, reading, +or removing those keys in a strict Snap requires the second connection. It +grants access to the session's password manager, so make that choice only on a +trusted installation. ## Git author identity diff --git a/pubspec.lock b/pubspec.lock index 8bcc1ff1..9dd253ea 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1163,4 +1163,4 @@ packages: version: "0.0.4" sdks: dart: ">=3.12.1 <4.0.0" - flutter: "3.47.4" + flutter: "3.47.5" diff --git a/pubspec.yaml b/pubspec.yaml index 23720de5..92a78ca4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -5,7 +5,7 @@ version: 0.5.1 environment: sdk: ">=3.12.1 <4.0.0" - flutter: 3.47.4 + flutter: 3.47.5 dependencies: flutter: diff --git a/snap/gnome-46-2404-prime-exclusions.amd64 b/snap/gnome-46-2404-prime-exclusions.amd64 new file mode 100644 index 00000000..f2f31694 --- /dev/null +++ b/snap/gnome-46-2404-prime-exclusions.amd64 @@ -0,0 +1,198 @@ +# Private runtime paths supplied by the connected content snaps on amd64: +# gnome-46-2404 revision 153 and mesa-2404 revision 1839. +# +# Retained codec packages pull these GNOME/GStreamer/graphics files transitively. The +# desktop-integration prime hook requires each exact path before removing it, +# so an Ubuntu package layout change fails the clean build for a fresh audit. +# Do not add retained libav, plugins-bad, or plugins-ugly files to this list. +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgst1394.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstaasink.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstadaptivedemux2.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstadder.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstalaw.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstalpha.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstalphacolor.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstapetag.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstapp.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstaudioconvert.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstaudiofx.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstaudiomixer.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstaudioparsers.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstaudiorate.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstaudioresample.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstaudiotestsrc.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstauparse.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstautodetect.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstavi.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstbasedebug.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstcacasink.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstcairo.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstcamerabin.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstcdparanoia.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstcompositor.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstcoreelements.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstcoretracers.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstcutter.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstdebug.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstdeinterlace.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstdsd.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstdtmf.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstdv.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgsteffectv.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstencoding.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstequalizer.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstflac.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstflv.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstflxdec.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstgdkpixbuf.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstgio.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstgoom.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstgoom2k1.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgsticydemux.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstid3demux.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstimagefreeze.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstinterleave.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstisomp4.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstjack.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstjpeg.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstjpegformat.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstlame.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstlevel.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstlibvisual.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstmatroska.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstmonoscope.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstmpg123.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstmulaw.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstmultifile.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstmultipart.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstnavigationtest.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstogg.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstopus.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstoss4.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstossaudio.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstoverlaycomposition.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstpbtypes.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstplayback.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstpng.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstpulseaudio.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstrawparse.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstreplaygain.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstrtp.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstrtpmanager.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstrtsp.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstshapewipe.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstshout2.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstsmpte.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstsoup.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstspectrum.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstspeex.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstsubparse.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgsttaglib.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgsttcp.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgsttheora.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgsttwolame.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgsttypefindfunctions.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstudp.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstvideo4linux2.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstvideobox.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstvideoconvertscale.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstvideocrop.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstvideofilter.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstvideomixer.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstvideorate.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstvideotestsrc.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstvolume.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstvorbis.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstvpx.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstwavenc.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstwavpack.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstwavparse.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstximagesrc.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstxingmux.so +usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgsty4menc.so +usr/lib/x86_64-linux-gnu/gstreamer1.0/gstreamer-1.0/gst-plugin-scanner +usr/lib/x86_64-linux-gnu/gstreamer1.0/gstreamer-1.0/gst-ptp-helper +usr/lib/x86_64-linux-gnu/gtk-3.0/3.0.0/immodules/im-am-et.so +usr/lib/x86_64-linux-gnu/gtk-3.0/3.0.0/immodules/im-broadway.so +usr/lib/x86_64-linux-gnu/gtk-3.0/3.0.0/immodules/im-cedilla.so +usr/lib/x86_64-linux-gnu/gtk-3.0/3.0.0/immodules/im-cyrillic-translit.so +usr/lib/x86_64-linux-gnu/gtk-3.0/3.0.0/immodules/im-inuktitut.so +usr/lib/x86_64-linux-gnu/gtk-3.0/3.0.0/immodules/im-ipa.so +usr/lib/x86_64-linux-gnu/gtk-3.0/3.0.0/immodules/im-multipress.so +usr/lib/x86_64-linux-gnu/gtk-3.0/3.0.0/immodules/im-thai.so +usr/lib/x86_64-linux-gnu/gtk-3.0/3.0.0/immodules/im-ti-er.so +usr/lib/x86_64-linux-gnu/gtk-3.0/3.0.0/immodules/im-ti-et.so +usr/lib/x86_64-linux-gnu/gtk-3.0/3.0.0/immodules/im-viqr.so +usr/lib/x86_64-linux-gnu/gtk-3.0/3.0.0/immodules/im-wayland.so +usr/lib/x86_64-linux-gnu/gtk-3.0/3.0.0/immodules/im-xim.so +usr/lib/x86_64-linux-gnu/gtk-3.0/3.0.0/printbackends/libprintbackend-cups.so +usr/lib/x86_64-linux-gnu/gtk-3.0/3.0.0/printbackends/libprintbackend-file.so +usr/lib/x86_64-linux-gnu/libgdk-3.so.0 +usr/lib/x86_64-linux-gnu/libgdk-3.so.0.2409.32 +usr/lib/x86_64-linux-gnu/libX11.so.6 +usr/lib/x86_64-linux-gnu/libX11.so.6.4.0 +usr/lib/x86_64-linux-gnu/libXdamage.so.1 +usr/lib/x86_64-linux-gnu/libXdamage.so.1.1.0 +usr/lib/x86_64-linux-gnu/libXext.so.6 +usr/lib/x86_64-linux-gnu/libXext.so.6.4.0 +usr/lib/x86_64-linux-gnu/libXfixes.so.3 +usr/lib/x86_64-linux-gnu/libXfixes.so.3.1.0 +usr/lib/x86_64-linux-gnu/libgstallocators-1.0.so.0 +usr/lib/x86_64-linux-gnu/libgstallocators-1.0.so.0.2402.0 +usr/lib/x86_64-linux-gnu/libgstapp-1.0.so.0 +usr/lib/x86_64-linux-gnu/libgstapp-1.0.so.0.2402.0 +usr/lib/x86_64-linux-gnu/libgstaudio-1.0.so.0 +usr/lib/x86_64-linux-gnu/libgstaudio-1.0.so.0.2402.0 +usr/lib/x86_64-linux-gnu/libgstbase-1.0.so.0 +usr/lib/x86_64-linux-gnu/libgstbase-1.0.so.0.2402.0 +usr/lib/x86_64-linux-gnu/libgstbasecamerabinsrc-1.0.so.0 +usr/lib/x86_64-linux-gnu/libgstbasecamerabinsrc-1.0.so.0.2402.0 +usr/lib/x86_64-linux-gnu/libgstcheck-1.0.so.0 +usr/lib/x86_64-linux-gnu/libgstcheck-1.0.so.0.2402.0 +usr/lib/x86_64-linux-gnu/libgstcontroller-1.0.so.0 +usr/lib/x86_64-linux-gnu/libgstcontroller-1.0.so.0.2402.0 +usr/lib/x86_64-linux-gnu/libgstfft-1.0.so.0 +usr/lib/x86_64-linux-gnu/libgstfft-1.0.so.0.2402.0 +usr/lib/x86_64-linux-gnu/libgstnet-1.0.so.0 +usr/lib/x86_64-linux-gnu/libgstnet-1.0.so.0.2402.0 +usr/lib/x86_64-linux-gnu/libgstpbutils-1.0.so.0 +usr/lib/x86_64-linux-gnu/libgstpbutils-1.0.so.0.2402.0 +usr/lib/x86_64-linux-gnu/libgstphotography-1.0.so.0 +usr/lib/x86_64-linux-gnu/libgstphotography-1.0.so.0.2402.0 +usr/lib/x86_64-linux-gnu/libgstreamer-1.0.so.0 +usr/lib/x86_64-linux-gnu/libgstreamer-1.0.so.0.2402.0 +usr/lib/x86_64-linux-gnu/libgstriff-1.0.so.0 +usr/lib/x86_64-linux-gnu/libgstriff-1.0.so.0.2402.0 +usr/lib/x86_64-linux-gnu/libgstrtp-1.0.so.0 +usr/lib/x86_64-linux-gnu/libgstrtp-1.0.so.0.2402.0 +usr/lib/x86_64-linux-gnu/libgstrtsp-1.0.so.0 +usr/lib/x86_64-linux-gnu/libgstrtsp-1.0.so.0.2402.0 +usr/lib/x86_64-linux-gnu/libgstsdp-1.0.so.0 +usr/lib/x86_64-linux-gnu/libgstsdp-1.0.so.0.2402.0 +usr/lib/x86_64-linux-gnu/libgsttag-1.0.so.0 +usr/lib/x86_64-linux-gnu/libgsttag-1.0.so.0.2402.0 +usr/lib/x86_64-linux-gnu/libgstvideo-1.0.so.0 +usr/lib/x86_64-linux-gnu/libgstvideo-1.0.so.0.2402.0 +usr/lib/x86_64-linux-gnu/libgtk-3-0t64/gtk-query-immodules-3.0 +usr/lib/x86_64-linux-gnu/libgtk-3.so.0 +usr/lib/x86_64-linux-gnu/libgtk-3.so.0.2409.32 +usr/lib/x86_64-linux-gnu/libpango-1.0.so.0 +usr/lib/x86_64-linux-gnu/libpango-1.0.so.0.5200.1 +usr/lib/x86_64-linux-gnu/libpangocairo-1.0.so.0 +usr/lib/x86_64-linux-gnu/libpangocairo-1.0.so.0.5200.1 +usr/lib/x86_64-linux-gnu/libpangoft2-1.0.so.0 +usr/lib/x86_64-linux-gnu/libpangoft2-1.0.so.0.5200.1 +usr/lib/x86_64-linux-gnu/libwayland-client.so.0 +usr/lib/x86_64-linux-gnu/libwayland-client.so.0.22.0 +usr/lib/x86_64-linux-gnu/libwayland-cursor.so.0 +usr/lib/x86_64-linux-gnu/libwayland-cursor.so.0.22.0 +usr/lib/x86_64-linux-gnu/libwayland-egl.so.1 +usr/lib/x86_64-linux-gnu/libwayland-egl.so.1.22.0 +usr/lib/x86_64-linux-gnu/libxcb-shm.so.0 +usr/lib/x86_64-linux-gnu/libxcb-shm.so.0.0.0 +usr/lib/x86_64-linux-gnu/libxcb.so.1 +usr/lib/x86_64-linux-gnu/libxcb.so.1.1.0 +usr/share/gstreamer-1.0/presets/GstIirEqualizer10Bands.prs +usr/share/gstreamer-1.0/presets/GstIirEqualizer3Bands.prs +usr/share/gstreamer-1.0/presets/GstQTMux.prs +usr/share/gstreamer-1.0/presets/GstVP8Enc.prs diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index f0be6b82..94486d34 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -48,6 +48,7 @@ apps: - network - removable-media - ssh-keys + - password-manager-service environment: GDK_BACKEND: wayland,x11 TYPST_FONT_PATHS: $SNAP/usr/share/fonts @@ -65,7 +66,7 @@ parts: flutter-target: lib/main.dart flutter-channel: stable build-environment: - - BUSYMARK_FLUTTER_VERSION: "3.47.4" + - BUSYMARK_FLUTTER_VERSION: "3.47.5" build-snaps: - node/24/stable build-packages: @@ -79,26 +80,9 @@ parts: - libwebkit2gtk-4.1-dev - xz-utils stage-packages: - - libhandy-1-0 - - libsecret-1-0 - - libwebkit2gtk-4.1-0 - - libgtk-3-0t64 - - libglib2.0-0t64 - - libpango-1.0-0 - gstreamer1.0-libav - gstreamer1.0-plugins-bad - - gstreamer1.0-plugins-base - - gstreamer1.0-plugins-good - gstreamer1.0-plugins-ugly - - libx11-6 - - libxdamage1 - - libxext6 - - libxfixes3 - - libwayland-client0 - - libwayland-cursor0 - - libwayland-egl1 - - libxcb-shm0 - - libxcb1 - git - fonts-noto-core - fonts-noto-mono @@ -134,8 +118,22 @@ parts: - patchelf override-prime: | craftctl default - # Drop a stale plugin that is not part of BusyMark's dependency graph. - rm -f "$CRAFT_PRIME/lib/libflutter_secure_storage_linux_plugin.so" + # The retained codec packages depend on GNOME/GStreamer packages already + # supplied by the connected platform. Remove only the revision-validated + # package-owned paths; a missing path signals that this audit is stale. + while IFS= read -r runtime_path; do + case "$runtime_path" in + ""|\#*) continue ;; + esac + private_path="$CRAFT_PRIME/$runtime_path" + if [ ! -e "$private_path" ] && [ ! -L "$private_path" ]; then + echo "missing audited GNOME runtime duplicate: $runtime_path" >&2 + exit 1 + fi + rm -f "$private_path" + done < \ + "$CRAFT_PROJECT_DIR/snap/gnome-46-2404-prime-exclusions.amd64" + # Keep the library's own directory alongside its two BLAS siblings. patchelf --set-rpath \ '$ORIGIN/lapack:$ORIGIN/blas:$ORIGIN:/snap/core24/current/lib/x86_64-linux-gnu' \ @@ -149,9 +147,33 @@ parts: # the matching SVG pixbuf loader and a newer librsvg. Let that pair stay # together; otherwise every SVG-backed GTK symbolic icon fails to load. rm -f "$CRAFT_PRIME/usr/lib/x86_64-linux-gnu/librsvg-2.so.2"* + + # CMake installs the deterministic Typst font set at the bundle-relative + # path used by the application. The same files are staged for fontconfig; + # require them to be identical before consolidating the bundle copy. + for bundled_font in "$CRAFT_PRIME/share/busymark/fonts"/Noto*.ttf; do + staged_font="$CRAFT_PRIME/usr/share/fonts/truetype/noto/${bundled_font##*/}" + test -f "$staged_font" + cmp -s "$bundled_font" "$staged_font" + done + rm -rf "$CRAFT_PRIME/share/busymark/fonts" + ln -s ../../usr/share/fonts/truetype/noto \ + "$CRAFT_PRIME/share/busymark/fonts" + + # BusyMark checks both standard XDG roots for user-selected Yaru variants. + # Preserve both lookup paths without storing a second copy of each theme. mkdir -p "$CRAFT_PRIME/share/themes" "$CRAFT_PRIME/share/icons" - cp -a "$CRAFT_PRIME/usr/share/themes"/Yaru* "$CRAFT_PRIME/share/themes/" - cp -a "$CRAFT_PRIME/usr/share/icons"/Yaru* "$CRAFT_PRIME/share/icons/" + for resource_kind in themes icons; do + for staged_resource in \ + "$CRAFT_PRIME/usr/share/$resource_kind"/Yaru*; do + test -e "$staged_resource" + resource_name="${staged_resource##*/}" + linked_resource="$CRAFT_PRIME/share/$resource_kind/$resource_name" + rm -rf "$linked_resource" + ln -s "../../usr/share/$resource_kind/$resource_name" \ + "$linked_resource" + done + done install -Dm644 "$CRAFT_PROJECT_DIR/linux/io.busystack.busymark.desktop" \ "$CRAFT_PRIME/share/applications/io.busystack.busymark.desktop" install -Dm644 "$CRAFT_PROJECT_DIR/assets/branding/busymark_logo.svg" \ diff --git a/test/src/ai_packaging_audit_test.dart b/test/src/ai_packaging_audit_test.dart index dc6aa2ca..cbc758ab 100644 --- a/test/src/ai_packaging_audit_test.dart +++ b/test/src/ai_packaging_audit_test.dart @@ -21,7 +21,10 @@ void main() { expect(credentialHost, isNot(contains('password-manager-service'))); expect(workflow, contains('libsecret-1-dev')); expect(snapcraft, contains('- libsecret-1-dev')); - expect(snapcraft, contains('- libsecret-1-0')); + expect(snapcraft, contains('extensions: [gnome]')); + expect(snapcraft, isNot(contains('- libsecret-1-0'))); + expect(workflow, contains('libsecret-1.so.0')); + expect(workflow, contains(r'$SNAP_DESKTOP_RUNTIME/')); expect(snapcraft, contains('confinement: strict')); }); diff --git a/test/src/app_smoke_test.dart b/test/src/app_smoke_test.dart index 9e94ae2e..52eeae86 100644 --- a/test/src/app_smoke_test.dart +++ b/test/src/app_smoke_test.dart @@ -1725,6 +1725,21 @@ void main() { await _pumpUntilCondition( tester, () => harness.spelling.catalog?.installedById('en-GB') != null, + timeout: const Duration(seconds: 15), + diagnostics: () { + final imported = Directory( + p.join( + harness.temporary.path, + 'dictionary-storage', + 'imported', + 'en-GB', + ), + ); + return 'imported directory=${imported.existsSync()}, ' + 'installed=${harness.spelling.catalog?.installedById('en-GB')}, ' + 'invalid=${harness.spelling.catalog?.invalidInstallations}, ' + 'dialog visible=${dialog.evaluate().isNotEmpty}'; + }, ); expect(dialog, findsNothing); @@ -1790,6 +1805,20 @@ void main() { await _pumpUntilCondition( tester, () => harness.spelling.catalog?.installedById('en-GB') != null, + timeout: const Duration(seconds: 15), + diagnostics: () { + final imported = Directory( + p.join( + harness.temporary.path, + 'dictionary-storage', + 'imported', + 'en-GB', + ), + ); + return 'imported directory=${imported.existsSync()}, ' + 'catalog unavailable=${harness.spelling.catalog == null}, ' + 'installed=${harness.spelling.catalog?.installedById('en-GB')}'; + }, ); expect(find.byType(BusyMarkDialogShell), findsNothing); @@ -1902,6 +1931,12 @@ void main() { await _pumpUntilCondition( tester, () => settingsStore.value['defaultSpellingLanguage'] == 'fr-Test', + timeout: const Duration(seconds: 15), + diagnostics: () => + 'downloads=$downloadCount, ' + 'installed=${harness.spelling.catalog?.installedById('fr-Test')}, ' + 'catalog unavailable=${harness.spelling.catalog == null}, ' + 'persisted=${settingsStore.value['defaultSpellingLanguage']}', ); expect(harness.spelling.catalog?.installedById('fr-Test'), isNotNull); @@ -14287,15 +14322,18 @@ BusyMarkPopupSelector _spellingLanguageSelector( Future _pumpUntilCondition( WidgetTester tester, - bool Function() condition, -) async { - for (var attempt = 0; attempt < 200 && !condition(); attempt += 1) { + bool Function() condition, { + Duration timeout = const Duration(seconds: 5), + String Function()? diagnostics, +}) async { + final deadline = DateTime.now().add(timeout); + while (!condition() && DateTime.now().isBefore(deadline)) { await tester.runAsync( () => Future.delayed(const Duration(milliseconds: 25)), ); await tester.pump(); } - expect(condition(), isTrue); + expect(condition(), isTrue, reason: diagnostics?.call()); await _pumpSettingsUi(tester); } diff --git a/test/src/headless_chrome_test.dart b/test/src/headless_chrome_test.dart new file mode 100644 index 00000000..1b967c3e --- /dev/null +++ b/test/src/headless_chrome_test.dart @@ -0,0 +1,315 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +import '../support/headless_chrome.dart'; + +void main() { + final documentUri = Uri.file('/tmp/busymark-devtools-fixture.html'); + + test('evaluates the requested document after it becomes ready', () async { + final devTools = await _FakeDevTools.start( + documentUri, + onEvaluate: (socket, request) => + socket.add(_response(request, {'ready': true, 'value': 'ready'})), + ); + addTearDown(devTools.close); + + expect( + await _evaluate( + browserEndpoint: devTools.browserEndpoint, + documentUri: documentUri, + expression: 'document.title', + readinessTimeout: const Duration(seconds: 1), + ), + 'ready', + ); + expect(devTools.evaluatedPaths, ['/devtools/page/requested']); + }); + + test('does not evaluate the initial about:blank target', () async { + final devTools = await _FakeDevTools.start( + documentUri, + initialListOnly: true, + onEvaluate: (socket, request) => + socket.add(_response(request, {'ready': true, 'value': 'target'})), + ); + addTearDown(devTools.close); + + expect( + await _evaluate( + browserEndpoint: devTools.browserEndpoint, + documentUri: documentUri, + expression: 'document.title', + readinessTimeout: const Duration(seconds: 1), + ), + 'target', + ); + expect(devTools.listRequests, greaterThanOrEqualTo(2)); + expect(devTools.evaluatedPaths, ['/devtools/page/requested']); + }); + + test('retries when navigation destroys the execution context', () async { + var attempts = 0; + final devTools = await _FakeDevTools.start( + documentUri, + onEvaluate: (socket, request) { + attempts++; + socket.add( + attempts == 1 + ? jsonEncode({ + 'id': request['id'], + 'error': { + 'code': -32000, + 'message': 'Execution context was destroyed.', + }, + }) + : _response(request, { + 'ready': true, + 'value': 'after navigation', + }), + ); + }, + ); + addTearDown(devTools.close); + + expect( + await _evaluate( + browserEndpoint: devTools.browserEndpoint, + documentUri: documentUri, + expression: 'document.title', + readinessTimeout: const Duration(seconds: 1), + ), + 'after navigation', + ); + expect(attempts, 2); + }); + + test('a silent open DevTools socket respects the readiness bound', () async { + final responseWait = Stopwatch(); + final devTools = await _FakeDevTools.start( + documentUri, + onEvaluate: (_, _) => responseWait.start(), + ); + addTearDown(devTools.close); + + await expectLater( + _evaluate( + browserEndpoint: devTools.browserEndpoint, + documentUri: documentUri, + expression: 'document.title', + readinessTimeout: const Duration(milliseconds: 150), + ).timeout(const Duration(seconds: 5)), + throwsA( + isA().having( + (error) => error.toString(), + 'diagnostic', + allOf( + contains('DevTools response timed out'), + contains('$documentUri'), + ), + ), + ), + ); + expect(responseWait.elapsed, lessThan(const Duration(seconds: 2))); + expect(devTools.evaluatedPaths, ['/devtools/page/requested']); + }); + + test('socket closure before a response fails deterministically', () async { + final devTools = await _FakeDevTools.start( + documentUri, + onEvaluate: (socket, _) => socket.close(), + ); + addTearDown(devTools.close); + + await expectLater( + _evaluate( + browserEndpoint: devTools.browserEndpoint, + documentUri: documentUri, + expression: 'document.title', + readinessTimeout: const Duration(milliseconds: 300), + ).timeout(const Duration(seconds: 2)), + throwsA( + isA().having( + (error) => error.toString(), + 'diagnostic', + allOf( + contains('socket closed before response'), + contains('$documentUri'), + ), + ), + ), + ); + }); + + test('a DevTools error is not mistaken for navigation', () async { + final devTools = await _FakeDevTools.start( + documentUri, + onEvaluate: (socket, request) => socket.add( + jsonEncode({ + 'id': request['id'], + 'error': {'code': -32601, 'message': 'Method not found'}, + }), + ), + ); + addTearDown(devTools.close); + + await expectLater( + _evaluate( + browserEndpoint: devTools.browserEndpoint, + documentUri: documentUri, + expression: 'document.title', + readinessTimeout: const Duration(seconds: 1), + ), + throwsA( + isA().having( + (error) => error.toString(), + 'diagnostic', + allOf(contains('evaluation failed'), contains('Method not found')), + ), + ), + ); + }); + + test('a page that never becomes ready reports its last state', () async { + final devTools = await _FakeDevTools.start( + documentUri, + onEvaluate: (socket, request) => socket.add( + _response(request, { + 'ready': false, + 'url': '$documentUri', + 'state': 'loading', + 'styles': 0, + }), + ), + ); + addTearDown(devTools.close); + + await expectLater( + _evaluate( + browserEndpoint: devTools.browserEndpoint, + documentUri: documentUri, + expression: 'document.title', + readinessTimeout: const Duration(milliseconds: 150), + ), + throwsA( + isA().having( + (error) => error.toString(), + 'diagnostic', + allOf(contains('did not finish loading'), contains('loading')), + ), + ), + ); + }); +} + +Future _evaluate({ + required Uri browserEndpoint, + required Uri documentUri, + required String expression, + required Duration readinessTimeout, +}) => HttpOverrides.runWithHttpOverrides( + () => evaluateDevToolsDocument( + browserEndpoint: browserEndpoint, + documentUri: documentUri, + expression: expression, + readinessTimeout: readinessTimeout, + ), + _TestHttpOverrides(), +); + +class _TestHttpOverrides extends HttpOverrides {} + +String _response(Map request, Map state) => + jsonEncode({ + 'id': request['id'], + 'result': { + 'result': {'type': 'object', 'value': state}, + }, + }); + +class _FakeDevTools { + _FakeDevTools( + this.server, + this.documentUri, + this.onEvaluate, + this.initialListOnly, + ); + + final HttpServer server; + final Uri documentUri; + final FutureOr Function(WebSocket, Map) onEvaluate; + final bool initialListOnly; + final sockets = []; + final evaluatedPaths = []; + late final StreamSubscription subscription; + var listRequests = 0; + + Uri get browserEndpoint => Uri.parse( + 'ws://${server.address.address}:${server.port}/devtools/browser', + ); + + static Future<_FakeDevTools> start( + Uri documentUri, { + required FutureOr Function(WebSocket, Map) + onEvaluate, + bool initialListOnly = false, + }) async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + final fixture = _FakeDevTools( + server, + documentUri, + onEvaluate, + initialListOnly, + ); + fixture.subscription = server.listen(fixture._handleRequest); + return fixture; + } + + Future _handleRequest(HttpRequest request) async { + if (request.uri.path == '/json/list') { + listRequests++; + final targets = >[ + { + 'type': 'page', + 'url': 'about:blank', + 'webSocketDebuggerUrl': + 'ws://${server.address.address}:${server.port}/devtools/page/initial', + }, + if (!initialListOnly || listRequests > 1) + { + 'type': 'page', + 'url': '$documentUri', + 'webSocketDebuggerUrl': + 'ws://${server.address.address}:${server.port}/devtools/page/requested', + }, + ]; + request.response.headers.contentType = ContentType.json; + request.response.write(jsonEncode(targets)); + await request.response.close(); + return; + } + if (request.uri.path.startsWith('/devtools/page/')) { + evaluatedPaths.add(request.uri.path); + final socket = await WebSocketTransformer.upgrade(request); + sockets.add(socket); + socket.listen((data) { + final message = jsonDecode(data as String) as Map; + onEvaluate(socket, message); + }); + return; + } + request.response.statusCode = HttpStatus.notFound; + await request.response.close(); + } + + Future close() async { + for (final socket in sockets) { + await socket.close(); + } + await subscription.cancel(); + await server.close(force: true); + } +} diff --git a/test/src/local_history_controller_test.dart b/test/src/local_history_controller_test.dart index 17f63448..8452d00f 100644 --- a/test/src/local_history_controller_test.dart +++ b/test/src/local_history_controller_test.dart @@ -200,6 +200,7 @@ void main() { await _waitForHistory( container, (state) => state.selectedRevisions.length == 2, + timeout: const Duration(seconds: 10), ); final reopened = FileLocalHistoryStore( @@ -1964,12 +1965,17 @@ Future> _revisionSources( Future _waitForHistory( ProviderContainer container, - bool Function(LocalHistoryState state) condition, -) async { - final deadline = DateTime.now().add(const Duration(seconds: 3)); + bool Function(LocalHistoryState state) condition, { + Duration timeout = const Duration(seconds: 3), +}) async { + final deadline = DateTime.now().add(timeout); while (!condition(container.read(localHistoryControllerProvider))) { if (DateTime.now().isAfter(deadline)) { - throw StateError('Timed out waiting for Local History state'); + final state = container.read(localHistoryControllerProvider); + throw StateError( + 'Local History state did not complete: ' + 'selected revisions=${state.selectedRevisions.length}', + ); } await Future.delayed(const Duration(milliseconds: 10)); } diff --git a/test/src/local_history_workspace_test.dart b/test/src/local_history_workspace_test.dart index f000fd72..643b8190 100644 --- a/test/src/local_history_workspace_test.dart +++ b/test/src/local_history_workspace_test.dart @@ -982,7 +982,10 @@ void main() { await settings.setLocalHistoryRecordingEnabled(true); harness.controller.updateActiveText('Only B after cancellation\n'); - await _waitFor(() => timers.any((timer) => timer.isActive)); + await _waitFor( + () => timers.any((timer) => timer.isActive), + operation: 'Save As checkpoint timer', + ); for (final timer in List<_FakeTimer>.of(timers)) { timer.fire(); } @@ -997,6 +1000,16 @@ void main() { LocalHistoryCaptureReason.automaticCheckpoint && revision.historicalPath == destinationPath, ), + timeout: const Duration(seconds: 10), + operation: 'Save As automatic checkpoint', + diagnostics: () { + final snapshot = harness.container + .read(localHistoryControllerProvider) + .snapshot; + return 'active=${harness.state.activeBuffer?.filePath}, ' + 'revision count=${snapshot.revisions.length}, ' + 'active timers=${timers.where((timer) => timer.isActive).length}'; + }, ); final snapshot = await diskStore.load(); final destination = snapshot.documents.singleWhere( @@ -1090,7 +1103,10 @@ void main() { // Establish history through an editor mutation and its real-policy // checkpoint, without manually observing or storing the edit. harness.controller.updateActiveText('Original retained draft\n'); - await _waitFor(() => timers.any((timer) => timer.isActive)); + await _waitFor( + () => timers.any((timer) => timer.isActive), + operation: 'first-save checkpoint timer', + ); final firstTimer = timers.singleWhere((timer) => timer.isActive); expect(firstTimer.duration, const Duration(seconds: 60)); now = now.add(firstTimer.duration); @@ -1099,6 +1115,11 @@ void main() { () => history.documentIdForBuffer(bufferId) != null && history.pendingSnapshotForBuffer(bufferId) == null, + timeout: const Duration(seconds: 10), + operation: 'first-save disk checkpoint', + diagnostics: () => + 'document id=${history.documentIdForBuffer(bufferId)}, ' + 'pending snapshot=${history.pendingSnapshotForBuffer(bufferId) != null}', ); final original = (await diskStore.load()).documents.single; final originalRevisionIds = (await diskStore.load()).revisions @@ -1855,11 +1876,16 @@ Future> _revisionSources( return sources; } -Future _waitFor(bool Function() condition) async { - final deadline = DateTime.now().add(const Duration(seconds: 3)); +Future _waitFor( + bool Function() condition, { + Duration timeout = const Duration(seconds: 3), + String operation = 'workspace state', + String Function()? diagnostics, +}) async { + final deadline = DateTime.now().add(timeout); while (!condition()) { if (DateTime.now().isAfter(deadline)) { - fail('Timed out waiting for workspace state'); + fail('$operation did not complete. ${diagnostics?.call() ?? ''}'); } await Future.delayed(const Duration(milliseconds: 10)); } diff --git a/test/src/markdown_pdf_export_test.dart b/test/src/markdown_pdf_export_test.dart index c10d1452..ab6d1fdb 100644 --- a/test/src/markdown_pdf_export_test.dart +++ b/test/src/markdown_pdf_export_test.dart @@ -325,6 +325,9 @@ while :; do :; done expect(cmake, contains('share/licenses/typst')); expect(snapcraft, contains('TYPST_FONT_PATHS:')); expect(snapcraft, contains('fonts-noto-core')); + expect(snapcraft, contains(r'cmp -s "$bundled_font" "$staged_font"')); + expect(snapcraft, contains('ln -s ../../usr/share/fonts/truetype/noto')); + expect(snapcraft, contains('share/busymark/fonts')); }); } diff --git a/test/src/native_headerbar_audit_test.dart b/test/src/native_headerbar_audit_test.dart index 70170b9f..6fef932c 100644 --- a/test/src/native_headerbar_audit_test.dart +++ b/test/src/native_headerbar_audit_test.dart @@ -477,15 +477,15 @@ void main() { ); expect( snapcraft, - contains( - r'cp -a "$CRAFT_PRIME/usr/share/themes"/Yaru* "$CRAFT_PRIME/share/themes/"', - ), + contains(r'ln -s "../../usr/share/$resource_kind/$resource_name"'), ); expect( snapcraft, - contains( - r'cp -a "$CRAFT_PRIME/usr/share/icons"/Yaru* "$CRAFT_PRIME/share/icons/"', - ), + isNot(contains(r'cp -a "$CRAFT_PRIME/usr/share/themes"/Yaru*')), + ); + expect( + snapcraft, + isNot(contains(r'cp -a "$CRAFT_PRIME/usr/share/icons"/Yaru*')), ); expect( native, @@ -581,8 +581,192 @@ void main() { expect(script, contains('item_indent = match.group(1)')); expect(script, contains('f"{item_indent}- {item}\\n"')); expect(script, contains('the currently installed snap was not changed')); + expect(script, contains('development repack')); + expect(script, contains('does not perform a dependency/security refresh')); + expect(script, contains('For release/security refreshes')); + expect( + script.indexOf('NOTICE: this helper creates a development repack'), + lessThan( + script.indexOf(r'select_project_flutter "$REQUIRED_FLUTTER_VERSION"'), + ), + ); + + final help = Process.runSync('bash', [ + 'tools/build_install_snap_local.sh', + '--help', + ]); + expect(help.exitCode, 0); + expect(help.stdout, contains('This is a development repack')); + expect(help.stdout, contains('does not refresh dependencies')); + expect(help.stdout, contains('clean Snapcraft procedure')); + }); + + test('local repack replaces clean-snap font links with new bundle fonts', () { + for (final linkedScaffold in [true, false]) { + _verifyLocalFontRepack(linkedScaffold: linkedScaffold); + } + }); + + test('confined dependency checks reject missing and wrong resolutions', () { + for (final shell in ['/bin/bash', '/bin/sh']) { + final success = _runSharedRuntimeValidation('ok', shell); + expect( + success.exitCode, + 0, + reason: '${success.stdout}\n${success.stderr}', + ); + + for (final mode in [ + 'missing-zero', + 'inspection-error', + 'wrong-gnome', + 'wrong-mesa', + 'wrong-curl', + ]) { + final failure = _runSharedRuntimeValidation(mode, shell); + expect(failure.exitCode, isNot(0), reason: '$shell / $mode'); + expect(failure.stderr, contains('busymark'), reason: '$shell / $mode'); + expect( + failure.stderr, + anyOf( + contains('Unresolved dependencies'), + contains('Dependency inspection failed'), + contains('Unexpected resolution'), + ), + reason: '$shell / $mode: ${failure.stderr}', + ); + } + } }); + test('Snap reuses verified platform libraries and retains private tools', () { + final snapcraft = File('snap/snapcraft.yaml').readAsStringSync(); + final stagePackages = _snapStagePackages(snapcraft); + + expect(snapcraft, contains('extensions: [gnome]')); + expect(snapcraft, contains(' - password-manager-service')); + expect( + File('docs/snap-confinement.md').readAsStringSync(), + contains('snap connect busymark:password-manager-service'), + ); + for (final sharedPackage in { + 'libhandy-1-0', + 'libsecret-1-0', + 'libwebkit2gtk-4.1-0', + 'libgtk-3-0t64', + 'libglib2.0-0t64', + 'libpango-1.0-0', + 'gstreamer1.0-plugins-base', + 'gstreamer1.0-plugins-good', + 'libx11-6', + 'libxdamage1', + 'libxext6', + 'libxfixes3', + 'libxcb-shm0', + 'libxcb1', + 'libwayland-client0', + 'libwayland-cursor0', + 'libwayland-egl1', + }) { + expect(stagePackages, isNot(contains(sharedPackage))); + } + for (final privatePackage in { + 'gstreamer1.0-libav', + 'gstreamer1.0-plugins-bad', + 'gstreamer1.0-plugins-ugly', + 'git', + 'fonts-noto-core', + 'fonts-noto-mono', + 'openssh-client', + 'util-linux', + 'yaru-theme-gtk', + 'yaru-theme-icon', + }) { + expect(stagePackages, contains(privatePackage)); + } + + for (final buildPackage in { + 'libhandy-1-dev', + 'libsecret-1-dev', + 'libwebkit2gtk-4.1-dev', + 'libglib2.0-dev', + 'libpango1.0-dev', + }) { + expect(snapcraft, contains('- $buildPackage')); + } + }); + + test( + 'Snap packaging hooks consolidate resources and keep required fixes', + () { + final snapcraft = File('snap/snapcraft.yaml').readAsStringSync(); + final exclusions = File( + 'snap/gnome-46-2404-prime-exclusions.amd64', + ).readAsLinesSync(); + + expect(snapcraft, contains('missing audited GNOME runtime duplicate')); + expect(snapcraft, contains('gnome-46-2404-prime-exclusions.amd64')); + expect(exclusions, contains(contains('revision 153'))); + expect( + exclusions, + contains('usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstisomp4.so'), + ); + for (final runtimeLibrary in { + 'libgdk-3.so.0', + 'libgtk-3.so.0', + 'libpango-1.0.so.0', + 'libpangocairo-1.0.so.0', + 'libpangoft2-1.0.so.0', + 'libX11.so.6', + 'libXdamage.so.1', + 'libXext.so.6', + 'libXfixes.so.3', + 'libxcb-shm.so.0', + 'libxcb.so.1', + 'libwayland-client.so.0', + 'libwayland-cursor.so.0', + 'libwayland-egl.so.1', + }) { + expect( + exclusions, + contains('usr/lib/x86_64-linux-gnu/$runtimeLibrary'), + ); + } + expect(exclusions, isNot(contains(contains('libgstlibav.so')))); + expect(exclusions, isNot(contains(contains('libgstvideoparsersbad.so')))); + expect(exclusions.where((line) => line.contains('*')), isEmpty); + expect(snapcraft, contains(r'cmp -s "$bundled_font" "$staged_font"')); + expect(snapcraft, contains('ln -s ../../usr/share/fonts/truetype/noto')); + expect( + snapcraft, + contains(r'ln -s "../../usr/share/$resource_kind/$resource_name"'), + ); + expect( + snapcraft, + contains( + r'"$CRAFT_PRIME/usr/lib/x86_64-linux-gnu/libsphinxbase.so.3.0.0"', + ), + ); + expect(snapcraft, contains('caca/libgl_plugin.so.0.0.0')); + expect( + snapcraft, + contains( + r'rm -f "$CRAFT_PRIME/usr/lib/x86_64-linux-gnu/librsvg-2.so.2"*', + ), + ); + expect( + snapcraft, + isNot(contains('libflutter_secure_storage_linux_plugin.so')), + ); + final workflow = File( + '.github/workflows/flutter-linux.yml', + ).readAsStringSync(); + expect(workflow, contains(r'graphics_lib="$SNAP/gpu-2404/usr/lib/')); + expect(workflow, contains('libwayland-egl.so.1')); + expect(workflow, contains('usr/share/doc/fonts-noto-core/copyright')); + }, + ); + 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(); @@ -1102,7 +1286,7 @@ void main() { ), ); expect(snapcraft, contains('- libhandy-1-dev')); - expect(snapcraft, contains('- libhandy-1-0')); + expect(_snapStagePackages(snapcraft), isNot(contains('libhandy-1-0'))); expect(readme, contains('sudo apt-get install')); expect(readme, contains('libhandy-1-dev')); expect(readme, contains('xz-utils')); @@ -1883,3 +2067,323 @@ void main() { }, ); } + +Set _snapStagePackages(String snapcraft) { + final match = RegExp( + r'^ stage-packages:\n((?:^ - [^\n]+\n)+)', + multiLine: true, + ).firstMatch(snapcraft); + expect(match, isNotNull); + return RegExp( + r'^ - ([^\s]+)$', + multiLine: true, + ).allMatches(match!.group(1)!).map((entry) => entry.group(1)!).toSet(); +} + +void _writeFixtureFile(String path, String contents) { + final file = File(path)..createSync(recursive: true); + file.writeAsStringSync(contents); +} + +void _writeFixtureExecutable(String path, String contents) { + _writeFixtureFile(path, contents); + final chmod = Process.runSync('chmod', ['+x', path]); + expect(chmod.exitCode, 0, reason: '$path: ${chmod.stderr}'); +} + +void _verifyLocalFontRepack({required bool linkedScaffold}) { + final fixture = Directory.systemTemp.createTempSync('busymark-repack-font-'); + try { + final project = '${fixture.path}/project'; + final scaffold = '${fixture.path}/scaffold'; + final root = '${fixture.path}/root'; + final tools = '$project/tools'; + final stubBin = '${fixture.path}/stub-bin'; + final flutterSdk = '${fixture.path}/flutter-sdk'; + final fontDir = '$scaffold/share/busymark/fonts'; + final stagedFont = + '$scaffold/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf'; + Directory(tools).createSync(recursive: true); + File( + 'tools/build_install_snap_local.sh', + ).copySync('$tools/build_install_snap_local.sh'); + _writeFixtureFile( + '$project/pubspec.yaml', + 'name: busymark\nversion: 0.5.1\nenvironment:\n flutter: 3.47.5\n', + ); + _writeFixtureFile( + '$project/snap/snapcraft.yaml', + 'name: busymark\nicon: icon.svg\napps:\n busymark:\n plugs:\n - home\n', + ); + _writeFixtureFile('$project/icon.svg', '\n'); + _writeFixtureFile( + '$project/linux/CMakeLists.txt', + 'set(BINARY_NAME "busymark")\nset(APPLICATION_ID "io.busystack.busymark")\n', + ); + _writeFixtureFile( + '$project/linux/io.busystack.busymark.desktop', + '[Desktop Entry]\nType=Application\nName=BusyMark\nIcon=busymark\n', + ); + _writeFixtureFile( + '$scaffold/meta/snap.yaml', + 'name: busymark\nversion: 0.5.0\napps:\n busymark:\n command: busymark\n', + ); + _writeFixtureFile(stagedFont, 'scaffold font bytes'); + if (linkedScaffold) { + Directory('$scaffold/share/busymark').createSync(recursive: true); + Link(fontDir).createSync('../../usr/share/fonts/truetype/noto'); + } else { + _writeFixtureFile('$fontDir/NotoSans-Regular.ttf', 'old bundle font'); + } + _writeFixtureFile( + '$flutterSdk/bin/cache/flutter.version.json', + '{"frameworkVersion":"3.47.5"}\n', + ); + _writeFixtureExecutable('$flutterSdk/bin/flutter', r'''#!/bin/sh +set -eu +case "$1" in + pub) exit 0 ;; + build) + bundle="$STUB_PROJECT/build/linux/x64/release/bundle" + mkdir -p "$bundle/share/busymark/fonts" + printf 'new bundle font bytes' > "$bundle/share/busymark/fonts/NotoSans-Regular.ttf" + printf '#!/bin/sh\nexit 0\n' > "$bundle/busymark" + exit 0 ;; +esac +exit 1 +'''); + _writeFixtureExecutable('$stubBin/snap', r'''#!/bin/sh +set -eu +test "$1" = pack +touch "${3#--filename=}" +'''); + _writeFixtureExecutable('$stubBin/unsquashfs', r'''#!/bin/sh +set -eu +case "$1" in + -cat) cat "$STUB_SNAP_ROOT/meta/snap.yaml" ;; + -ll) printf 'squashfs-root/busymark\nsquashfs-root/meta/gui/busymark.desktop\n' ;; +esac +'''); + _writeFixtureExecutable('$stubBin/sudo', r'''#!/bin/sh +printf 'unexpected installation\n' > "$STUB_INSTALL_MARKER" +exit 1 +'''); + + final installMarker = '${fixture.path}/install-called'; + final result = Process.runSync( + 'bash', + [ + '$tools/build_install_snap_local.sh', + '--no-install', + '--skip-tests', + '--skip-bundled-git', + '--scaffold', + scaffold, + '--root', + root, + '--output', + '${fixture.path}/local.snap', + ], + environment: { + ...Platform.environment, + 'PATH': '$stubBin:${Platform.environment['PATH']}', + 'BUSYMARK_FLUTTER_BIN': '$flutterSdk/bin/flutter', + 'BUSYMARK_BUILD_TMP_ROOT': '${fixture.path}/build-tmp', + 'STUB_PROJECT': project, + 'STUB_SNAP_ROOT': root, + 'STUB_INSTALL_MARKER': installMarker, + }, + ); + expect( + result.exitCode, + 0, + reason: 'linked=$linkedScaffold\n${result.stdout}\n${result.stderr}', + ); + expect(File('${fixture.path}/local.snap').existsSync(), isTrue); + expect(Link('$root/share/busymark/fonts').existsSync(), isFalse); + expect( + File( + '$root/share/busymark/fonts/NotoSans-Regular.ttf', + ).readAsStringSync(), + 'new bundle font bytes', + ); + expect( + File( + '$root/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf', + ).readAsStringSync(), + 'scaffold font bytes', + ); + expect(File(stagedFont).readAsStringSync(), 'scaffold font bytes'); + expect(File(installMarker).existsSync(), isFalse); + } finally { + fixture.deleteSync(recursive: true); + } +} + +String _sharedRuntimeValidationBody() { + final workflow = File( + '.github/workflows/flutter-linux.yml', + ).readAsStringSync(); + const step = + ' - name: Verify shared runtimes, packaged tools, media, and resources'; + final stepAt = workflow.indexOf(step); + expect(stepAt, greaterThanOrEqualTo(0)); + final bodyAt = + workflow.indexOf(' run: |\n', stepAt) + ' run: |\n'.length; + final nextStepAt = workflow.indexOf('\n - name:', bodyAt); + expect(nextStepAt, greaterThan(bodyAt)); + return workflow + .substring(bodyAt, nextStepAt) + .split('\n') + .map((line) { + return line.startsWith(' ') ? line.substring(10) : line; + }) + .join('\n'); +} + +ProcessResult _runSharedRuntimeValidation(String mode, String shell) { + final fixture = Directory.systemTemp.createTempSync( + 'busymark-runtime-check-', + ); + try { + final snap = '${fixture.path}/snap'; + final gnome = '${fixture.path}/gnome'; + final triplet = 'x86_64-linux-gnu'; + final graphics = '$snap/gpu-2404/usr/lib/$triplet'; + final providerPlugins = '$gnome/usr/lib/$triplet/gstreamer-1.0'; + final privatePlugins = '$snap/usr/lib/$triplet/gstreamer-1.0'; + final stubBin = '${fixture.path}/bin'; + for (final path in [ + '$snap/busymark', + '$snap/usr/lib/git-core/git-remote-http', + '$snap/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf', + '$snap/usr/share/doc/fonts-noto-core/copyright', + '$snap/usr/share/doc/fonts-noto-mono/copyright', + ]) { + _writeFixtureFile(path, 'fixture'); + } + for (final library in [ + 'libX11.so.6', + 'libXdamage.so.1', + 'libXext.so.6', + 'libXfixes.so.3', + 'libxcb-shm.so.0', + 'libxcb.so.1', + 'libwayland-client.so.0', + 'libwayland-cursor.so.0', + 'libwayland-egl.so.1', + ]) { + _writeFixtureFile('$graphics/$library', 'fixture'); + } + for (final library in [ + 'libhandy-1.so.0', + 'libsecret-1.so.0', + 'libwebkit2gtk-4.1.so.0', + 'libgtk-3.so.0', + 'libglib-2.0.so.0', + 'libpango-1.0.so.0', + ]) { + _writeFixtureFile('$gnome/usr/lib/$triplet/$library', 'fixture'); + } + _writeFixtureFile('$snap/usr/lib/$triplet/libcurl-gnutls.so.4', 'fixture'); + for (final helper in [ + 'WebKitWebProcess', + 'WebKitNetworkProcess', + 'WebKitGPUProcess', + ]) { + _writeFixtureExecutable( + '$gnome/usr/lib/$triplet/webkit2gtk-4.1/$helper', + '#!/bin/sh\n', + ); + } + for (final plugin in [ + 'libgstavi.so', + 'libgstisomp4.so', + 'libgstmatroska.so', + 'libgstogg.so', + 'libgsttheora.so', + 'libgstvpx.so', + ]) { + _writeFixtureFile('$providerPlugins/$plugin', 'fixture'); + } + for (final plugin in [ + 'libgstlibav.so', + 'libgstvideoparsersbad.so', + 'libgstmpeg2dec.so', + ]) { + _writeFixtureFile('$privatePlugins/$plugin', 'fixture'); + } + Directory('$snap/share/busymark').createSync(recursive: true); + Link( + '$snap/share/busymark/fonts', + ).createSync('../../usr/share/fonts/truetype/noto'); + for (final kind in ['themes', 'icons']) { + for (final name in ['Yaru', 'Yaru-dark']) { + Directory('$snap/usr/share/$kind/$name').createSync(recursive: true); + Directory('$snap/share/$kind').createSync(recursive: true); + Link( + '$snap/share/$kind/$name', + ).createSync('../../usr/share/$kind/$name'); + } + } + _writeFixtureExecutable('$stubBin/snap', r'''#!/bin/sh +set -eu +test "$1" = run +test "$2" = --shell +test "$4" = -c +exec "$STUB_INSPECTION_SHELL" -c "$5" +'''); + _writeFixtureExecutable('$stubBin/ldd', r'''#!/bin/sh +set -eu +object="$1" +if [ "$STUB_LDD_MODE" = inspection-error ] && [ "$object" = "$SNAP/busymark" ]; then + printf 'inspection crashed\n' + exit 7 +fi +if [ "$object" = "$SNAP/busymark" ]; then + for library in libhandy-1.so.0 libsecret-1.so.0 libwebkit2gtk-4.1.so.0 libgtk-3.so.0 libglib-2.0.so.0 libpango-1.0.so.0; do + path="$SNAP_DESKTOP_RUNTIME/usr/lib/$SNAP_LAUNCHER_ARCH_TRIPLET/$library" + if [ "$STUB_LDD_MODE" = wrong-gnome ] && [ "$library" = libgtk-3.so.0 ]; then path="/unexpected/$library"; fi + printf '%s => %s (0x1234)\n' "$library" "$path" + done + for library in libX11.so.6 libXdamage.so.1 libXext.so.6 libXfixes.so.3 libxcb-shm.so.0 libxcb.so.1 libwayland-client.so.0 libwayland-cursor.so.0 libwayland-egl.so.1; do + path="$SNAP/gpu-2404/usr/lib/$SNAP_LAUNCHER_ARCH_TRIPLET/$library" + if [ "$STUB_LDD_MODE" = wrong-mesa ] && [ "$library" = libX11.so.6 ]; then path="/unexpected/$library"; fi + printf '%s => %s (0x1234)\n' "$library" "$path" + done + if [ "$STUB_LDD_MODE" = missing-zero ]; then printf 'libmissing.so.1 => not found\n'; fi +elif [ "$object" = "$SNAP/usr/lib/git-core/git-remote-http" ]; then + path="$SNAP/usr/lib/git-core/../$SNAP_LAUNCHER_ARCH_TRIPLET/libcurl-gnutls.so.4" + if [ "$STUB_LDD_MODE" = wrong-curl ]; then path=/unexpected/libcurl-gnutls.so.4; fi + printf 'libcurl-gnutls.so.4 => %s (0x1234)\n' "$path" +else + printf 'libgstreamer-1.0.so.0 => %s/usr/lib/%s/libgstreamer-1.0.so.0 (0x1234)\n' "$SNAP_DESKTOP_RUNTIME" "$SNAP_LAUNCHER_ARCH_TRIPLET" +fi +'''); + _writeFixtureExecutable( + '$snap/usr/bin/git', + '#!/bin/sh\nprintf "%s\\n" "\$GIT_EXEC_PATH"\n', + ); + _writeFixtureExecutable( + '$snap/usr/bin/ssh', + '#!/bin/sh\nprintf "OpenSSH fixture\\n" >&2\n', + ); + _writeFixtureExecutable('$snap/usr/bin/setsid', '#!/bin/sh\n'); + final result = Process.runSync( + 'bash', + ['-c', _sharedRuntimeValidationBody()], + environment: { + ...Platform.environment, + 'PATH': '$stubBin:${Platform.environment['PATH']}', + 'SNAP': snap, + 'SNAP_DESKTOP_RUNTIME': gnome, + 'SNAP_LAUNCHER_ARCH_TRIPLET': triplet, + 'STUB_LDD_MODE': mode, + 'STUB_INSPECTION_SHELL': shell, + }, + ); + return result; + } finally { + fixture.deleteSync(recursive: true); + } +} diff --git a/test/src/source_editor_widget_test.dart b/test/src/source_editor_widget_test.dart index 257ec960..1a4e3a27 100644 --- a/test/src/source_editor_widget_test.dart +++ b/test/src/source_editor_widget_test.dart @@ -8869,6 +8869,7 @@ void main() { readData: RichClipboardData(text: sourceFile.path, generation: 42), ); final captures = []; + var published = false; final controller = await _pumpClipboardSourceEditor( tester, source: 'Target', @@ -8876,11 +8877,27 @@ void main() { onCaptured: captures.add, filePath: '${root.path}/target.md', assetWorkspaceKind: AssetWorkspaceKind.standalone, + assetIngestionService: AssetIngestionService( + hooks: AssetIngestionHooks( + afterPublication: (_) async { + published = true; + }, + ), + ), ); controller.selection = const TextSelection.collapsed(offset: 6); await _pressControlKey(tester, LogicalKeyboardKey.keyV); - await _pumpUntil(tester, () => captures.isNotEmpty); + await _pumpUntilRealIo( + tester, + () => published, + operation: 'Source image publication', + ); + await _pumpUntilRealIo( + tester, + () => captures.isNotEmpty, + operation: 'Source clipboard capture after image publication', + ); expect(controller.text, contains('![Image](images/original.png)')); expect(captures, hasLength(1)); @@ -9332,6 +9349,7 @@ Future _pumpClipboardSourceEditor( SourceDocumentFormat documentFormat = SourceDocumentFormat.markdown, MarkdownMode markdownMode = MarkdownMode.commonMark, AssetInputService? assetInputService, + AssetIngestionService assetIngestionService = const AssetIngestionService(), VoidCallback? onAssetSaveRequired, }) async { await tester.pumpWidget( @@ -9356,6 +9374,7 @@ Future _pumpClipboardSourceEditor( workspaceRoot: workspaceRoot, assetWorkspaceKind: assetWorkspaceKind, assetInputService: assetInputService, + assetIngestionService: assetIngestionService, diagnostics: const [], editorFontSize: 14, wordWrap: true, @@ -9504,6 +9523,21 @@ Future _pumpUntil(WidgetTester tester, bool Function() condition) async { fail('Timed out waiting for asynchronous Source editor work.'); } +Future _pumpUntilRealIo( + WidgetTester tester, + bool Function() condition, { + required String operation, +}) async { + final deadline = DateTime.now().add(const Duration(seconds: 10)); + while (!condition() && DateTime.now().isBefore(deadline)) { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 25)), + ); + await tester.pump(); + } + expect(condition(), isTrue, reason: '$operation did not complete.'); +} + SpellingOccurrence _sourceSpellingOccurrence({ required String word, required int sourceStart, diff --git a/test/src/spelling_workspace_test.dart b/test/src/spelling_workspace_test.dart index 715422b1..e003d741 100644 --- a/test/src/spelling_workspace_test.dart +++ b/test/src/spelling_workspace_test.dart @@ -608,6 +608,11 @@ void main() { SpellingPresentationStatus.incomplete, SpellingPresentationStatus.failure, ].contains(harness.spelling.state.status), + timeout: const Duration(seconds: 20), + diagnostics: () => + 'status=${harness.spelling.state.status}, ' + 'message=${harness.spelling.state.message}, ' + 'catalog unavailable=${harness.spelling.catalog == null}', ); expect( harness.spelling.state.status, @@ -1042,6 +1047,12 @@ void main() { .widget(find.byType(BusyMarkBanner)) .revealed && harness.spelling.state.complete, + timeout: const Duration(seconds: 15), + diagnostics: () => + 'downloads=$downloadCount, ' + 'installed=${harness.spelling.catalog?.installedById('en-Test')}, ' + 'catalog unavailable=${harness.spelling.catalog == null}, ' + 'spelling complete=${harness.spelling.state.complete}', ); await tester.pump(BusyMarkMotion.bannerReveal); @@ -1533,11 +1544,18 @@ Finder _spellingUnderlineOverlay() => find.byWidgetPredicate( widget.painter.runtimeType.toString() == '_SpellingUnderlinePainter', ); -Future _until(WidgetTester tester, bool Function() condition) async { +Future _until( + WidgetTester tester, + bool Function() condition, { + Duration timeout = const Duration(seconds: 10), + String Function()? diagnostics, +}) async { final elapsed = Stopwatch()..start(); while (!condition()) { - if (elapsed.elapsed > const Duration(seconds: 10)) { - throw TimeoutException('Spelling workspace did not settle'); + if (elapsed.elapsed > timeout) { + throw TimeoutException( + 'Spelling workspace did not settle. ${diagnostics?.call() ?? ''}', + ); } await tester.pump(const Duration(milliseconds: 50)); await tester.runAsync( diff --git a/test/src/visualization_packaging_audit_test.dart b/test/src/visualization_packaging_audit_test.dart index cdd5d256..7958c9bf 100644 --- a/test/src/visualization_packaging_audit_test.dart +++ b/test/src/visualization_packaging_audit_test.dart @@ -199,7 +199,15 @@ void main() { ); expect(workflow, contains('snapcore/action-build@v1')); expect(workflow, contains("if: steps.snapcraft.outcome == 'failure'")); - expect(workflow, contains(r'${PRIMARY_SNAP:-$RETRY_SNAP}')); + expect(workflow, isNot(contains(r'${PRIMARY_SNAP:-$RETRY_SNAP}'))); + expect(workflow, contains(r'test "$PRIMARY_OUTCOME" = failure')); + expect(workflow, contains(r'test "$RETRY_OUTCOME" = success')); + expect(workflow, contains('busymark-snap-build-start')); + expect(workflow, contains('Refusing Snap outside the clean checkout')); + expect( + workflow, + contains(r'unsquashfs -cat "$snap_path" snap/snapcraft.yaml'), + ); expect(workflow, contains('steps.snap-artifact.outputs.snap')); expect(workflow, contains('sudo snap install --dangerous')); expect(workflow, isNot(contains('--dangerous --classic'))); @@ -362,7 +370,13 @@ void main() { expect(scalar, contains('withDefaultFonts: false')); expect(scalar, contains('Network access is disabled')); expect(snapcraft, contains('libwebkit2gtk-4.1-dev')); - expect(snapcraft, contains('libwebkit2gtk-4.1-0')); + expect( + snapcraft, + isNot( + contains(RegExp(r'^ - libwebkit2gtk-4\.1-0$', multiLine: true)), + ), + ); + expect(snapcraft, contains('extensions: [gnome]')); expect(snapcraft, contains('interface: browser-support')); expect(snapcraft, contains('allow-sandbox: false')); expect(snapcraft, contains('node/24/stable')); diff --git a/test/src/writerside_template_dialogs_test.dart b/test/src/writerside_template_dialogs_test.dart index 8b2bde75..f91fbe36 100644 --- a/test/src/writerside_template_dialogs_test.dart +++ b/test/src/writerside_template_dialogs_test.dart @@ -31,7 +31,7 @@ void main() { (_) async => null, ); root = await Directory.systemTemp.createTemp('busymark-template-dialog-'); - service = WritersideTemplateService( + service = _ObservedTemplateService( storagePath: p.join(root.path, 'templates.json'), loadBundledSource: () => File('assets/writerside/templates.json').readAsString(), @@ -44,6 +44,7 @@ void main() { Widget dialog, { TextDirection direction = TextDirection.ltr, Size size = const Size(1300, 1000), + Finder? ready, }) async { tester.view.physicalSize = size; tester.view.devicePixelRatio = 1; @@ -77,7 +78,22 @@ void main() { ), ); await tester.tap(find.text('Open')); - await settle(tester); + if (ready == null) { + await settle(tester); + } else { + // The editor reads its file-backed template catalog outside FakeAsync. + // Wait for the loaded field before asking Flutter to settle animations; + // a loading spinner otherwise keeps pumpAndSettle active indefinitely. + final deadline = DateTime.now().add(const Duration(seconds: 10)); + while (ready.evaluate().isEmpty && DateTime.now().isBefore(deadline)) { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 25)), + ); + await tester.pump(); + } + expect(ready, findsOneWidget); + await tester.pumpAndSettle(); + } } for (final direction in TextDirection.values) { @@ -366,7 +382,7 @@ void main() { '# \${TITLE}\nCustom body', ); await tester.tap(find.text('OK')); - await settle(tester); + await settleAfterSave(tester, service); final saved = (await tester.runAsync(service.read))!.entries; expect( saved, @@ -586,7 +602,11 @@ void main() { () async => service.save(await service.read(), [override]), ); - await show(tester, WritersideTemplatesEditor(selectedId: builtin.id)); + await show( + tester, + WritersideTemplatesEditor(selectedId: builtin.id), + ready: find.byKey(const ValueKey('template-editor-name')), + ); final nameEntry = tester.widget( find.byKey(const ValueKey('template-editor-name')), ); @@ -617,7 +637,7 @@ void main() { await settle(tester); await tester.tap(find.widgetWithText(BusyMarkDialogButton, 'Reset')); await tester.tap(find.widgetWithText(BusyMarkDialogButton, 'OK')); - await settle(tester); + await settleAfterSave(tester, service); expect((await tester.runAsync(service.read))!.entries, isEmpty); }); @@ -661,7 +681,7 @@ void main() { ); await tester.pumpAndSettle(); await tester.tap(find.text('OK')); - await settle(tester); + await settleAfterSave(tester, service); expect(find.text('File and Code Templates'), findsNothing); final entries = (await tester.runAsync(service.read))!.entries; expect( @@ -685,6 +705,59 @@ class _FailingTemplateService extends WritersideTemplateService { Future> catalog() async => throw StateError('load'); } +class _ObservedTemplateService extends WritersideTemplateService { + _ObservedTemplateService({super.storagePath, super.loadBundledSource}); + + Future? mostRecentSave; + bool saveCompleted = false; + Object? saveError; + + @override + Future save( + WritersideTemplateSnapshot expected, + List entries, + ) { + saveCompleted = false; + saveError = null; + final pending = super.save(expected, entries); + mostRecentSave = pending; + pending.then( + (_) { + saveCompleted = true; + }, + onError: (Object error) { + saveError = error; + saveCompleted = true; + }, + ); + return pending; + } +} + +Future settleAfterSave( + WidgetTester tester, + WritersideTemplateService service, +) async { + final observed = service as _ObservedTemplateService; + expect( + observed.mostRecentSave, + isNotNull, + reason: 'The Save action must reach the store.', + ); + final deadline = DateTime.now().add(const Duration(seconds: 10)); + while (!observed.saveCompleted && DateTime.now().isBefore(deadline)) { + // Filesystem continuations run outside FakeAsync; their UI callbacks need + // a pump before the pending Save future can finish. + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 25)), + ); + await tester.pump(const Duration(milliseconds: 100)); + } + expect(observed.saveCompleted, isTrue, reason: 'Save did not complete.'); + expect(observed.saveError, isNull); + await tester.pumpAndSettle(); +} + Finder editableUnderKey(String key) => find.descendant( of: find.byKey(ValueKey(key)), matching: find.byType(EditableText), diff --git a/test/src/writerside_toc_regressions_test.dart b/test/src/writerside_toc_regressions_test.dart index 57bf980c..caa9f225 100644 --- a/test/src/writerside_toc_regressions_test.dart +++ b/test/src/writerside_toc_regressions_test.dart @@ -409,6 +409,12 @@ void main() { ?.filePath == topicPath('created-topic.$extension') || container.read(workspaceControllerProvider).message != null, + diagnostics: () { + final workspace = container.read(workspaceControllerProvider); + return 'active=${workspace.activeBuffer?.filePath}, ' + 'message=${workspace.message?.code}, ' + 'created file=${File(topicPath('created-topic.$extension')).existsSync()}'; + }, ); final after = container.read(workspaceControllerProvider); expect( @@ -461,8 +467,17 @@ Finder _editableUnderKey(String key) => find.descendant( matching: find.byType(EditableText), ); -Future _settle(WidgetTester tester, {bool Function()? until}) async { - for (var i = 0; i < (until == null ? 30 : 300); i++) { +Future _settle( + WidgetTester tester, { + bool Function()? until, + String Function()? diagnostics, +}) async { + final deadline = DateTime.now().add(const Duration(seconds: 10)); + for ( + var i = 0; + until == null ? i < 30 : DateTime.now().isBefore(deadline); + i++ + ) { await tester.runAsync( () => Future.delayed(const Duration(milliseconds: 15)), ); @@ -473,7 +488,8 @@ Future _settle(WidgetTester tester, {bool Function()? until}) async { expect( until(), isTrue, - reason: 'Timed out waiting for workspace operation', + reason: + 'Workspace operation did not complete. ${diagnostics?.call() ?? ''}', ); } await tester.pumpAndSettle(); diff --git a/test/src/writerside_video_player_host_test.dart b/test/src/writerside_video_player_host_test.dart index ad126458..55481466 100644 --- a/test/src/writerside_video_player_host_test.dart +++ b/test/src/writerside_video_player_host_test.dart @@ -120,7 +120,19 @@ void main() { contains('webkit_settings_set_enable_media(settings, FALSE)'), ); expect(snap, contains('confinement: strict')); - expect(snap, contains('gstreamer1.0-plugins-good')); expect(snap, contains('gstreamer1.0-libav')); + expect(snap, contains('gstreamer1.0-plugins-bad')); + expect(snap, contains('gstreamer1.0-plugins-ugly')); + expect( + snap, + isNot( + contains( + RegExp( + r'^ - gstreamer1\.0-plugins-(?:base|good)$', + multiLine: true, + ), + ), + ), + ); }); } diff --git a/test/src/wysiwyg_clipboard_test.dart b/test/src/wysiwyg_clipboard_test.dart index 4e45703a..addb94fe 100644 --- a/test/src/wysiwyg_clipboard_test.dart +++ b/test/src/wysiwyg_clipboard_test.dart @@ -471,6 +471,28 @@ void main() { await tester.pump(); } + Future waitForImageIo( + WidgetTester tester, + bool Function() complete, { + required String operation, + String Function()? diagnostics, + }) async { + // Image ingestion performs real filesystem I/O outside FakeAsync. Pump + // until the observable stage completes, retaining a bounded failure. + final deadline = DateTime.now().add(const Duration(seconds: 10)); + while (!complete() && DateTime.now().isBefore(deadline)) { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 25)), + ); + await tester.pump(); + } + expect( + complete(), + isTrue, + reason: '$operation did not complete. ${diagnostics?.call() ?? ''}', + ); + } + Future mount( WidgetTester tester, String id, @@ -1903,6 +1925,7 @@ void main() { clipboardHistoryControllerProvider.notifier, ); var changed = ''; + var published = false; await mount( tester, 'image-snapshot', @@ -1911,20 +1934,27 @@ void main() { onCaptured: history.retain, filePath: '${root.path}/target.md', assetWorkspaceKind: AssetWorkspaceKind.standalone, + assetIngestionService: AssetIngestionService( + hooks: AssetIngestionHooks( + afterPublication: (_) async { + published = true; + }, + ), + ), ); await key(tester, LogicalKeyboardKey.keyV); - for ( - var attempt = 0; - attempt < 100 && - find.byKey(BusyMarkImageDialogKeys.submit).evaluate().isEmpty; - attempt++ - ) { - await tester.runAsync( - () => Future.delayed(const Duration(milliseconds: 5)), - ); - await tester.pump(); - } + await waitForImageIo( + tester, + () => + find.byKey(BusyMarkImageDialogKeys.submit).evaluate().isNotEmpty, + operation: 'external image dialog', + diagnostics: () => + 'clipboard reads=$readCalls, published=$published, ' + 'source exists=${sourceFile.existsSync()}, ' + 'clipboard=$systemData, editor focus=' + '${tester.widget(find.byType(TextField).first).focusNode?.hasFocus}', + ); expect(find.byKey(BusyMarkImageDialogKeys.submit), findsOneWidget); await tester.runAsync(() => sourceFile.writeAsBytes(replacement)); await tester.tap(find.byKey(BusyMarkImageDialogKeys.submit)); @@ -1961,17 +1991,12 @@ void main() { assetWorkspaceKind: AssetWorkspaceKind.standalone, ); final replay = registry.paste(payload); - for ( - var attempt = 0; - attempt < 100 && - find.byKey(BusyMarkImageDialogKeys.submit).evaluate().isEmpty; - attempt++ - ) { - await tester.runAsync( - () => Future.delayed(const Duration(milliseconds: 5)), - ); - await tester.pump(); - } + await waitForImageIo( + tester, + () => + find.byKey(BusyMarkImageDialogKeys.submit).evaluate().isNotEmpty, + operation: 'history replay image dialog', + ); expect(find.byKey(BusyMarkImageDialogKeys.submit), findsOneWidget); await tester.tap(find.byKey(BusyMarkImageDialogKeys.submit)); await tester.pumpAndSettle(); @@ -2000,17 +2025,12 @@ void main() { assetWorkspaceKind: AssetWorkspaceKind.standalone, ); final replayAfterDelete = registry.paste(payload); - for ( - var attempt = 0; - attempt < 100 && - find.byKey(BusyMarkImageDialogKeys.submit).evaluate().isEmpty; - attempt++ - ) { - await tester.runAsync( - () => Future.delayed(const Duration(milliseconds: 5)), - ); - await tester.pump(); - } + await waitForImageIo( + tester, + () => + find.byKey(BusyMarkImageDialogKeys.submit).evaluate().isNotEmpty, + operation: 'deleted-source image replay dialog', + ); expect(find.byKey(BusyMarkImageDialogKeys.submit), findsOneWidget); await tester.tap(find.byKey(BusyMarkImageDialogKeys.submit)); await tester.pumpAndSettle(); @@ -2093,6 +2113,7 @@ void main() { clipboardHistoryControllerProvider.notifier, ); var changed = 'Target\n'; + var published = false; final document = _parser .parse( filePath: '${root.path}/target.md', @@ -2117,6 +2138,13 @@ void main() { clipboardInsertionRegistry: registry, onClipboardCaptured: history.retain, assetWorkspaceKind: AssetWorkspaceKind.standalone, + assetIngestionService: AssetIngestionService( + hooks: AssetIngestionHooks( + afterPublication: (_) async { + published = true; + }, + ), + ), ), ), const SizedBox(width: 320, child: ClipboardHistoryPanel()), @@ -2139,17 +2167,18 @@ void main() { await tester.tap(pathRow); await tester.pump(const Duration(milliseconds: 50)); await tester.tap(pathRow); - for ( - var attempt = 0; - attempt < 100 && - find.byKey(BusyMarkImageDialogKeys.submit).evaluate().isEmpty; - attempt++ - ) { - await tester.runAsync( - () => Future.delayed(const Duration(milliseconds: 5)), - ); - await tester.pump(); - } + await waitForImageIo( + tester, + () => find.byKey(BusyMarkImageDialogKeys.submit).evaluate().isNotEmpty, + operation: 'history panel image dialog', + diagnostics: () { + final state = container.read(clipboardHistoryControllerProvider); + final payload = state.currentClipboard; + return 'published=$published, source exists=${sourceFile.existsSync()}, ' + 'history entries=${state.entries.length}, ' + 'registry can paste=${payload == null ? null : registry.canPaste(payload, mode: BusyMarkPasteMode.normal)}'; + }, + ); expect(find.byKey(BusyMarkImageDialogKeys.submit), findsOneWidget); await tester.tap(find.byKey(BusyMarkImageDialogKeys.submit)); await tester.pumpAndSettle(); @@ -2281,17 +2310,12 @@ void main() { ); Future waitForDialog() async { - for ( - var attempt = 0; - attempt < 100 && - find.byKey(BusyMarkImageDialogKeys.cancel).evaluate().isEmpty; - attempt++ - ) { - await tester.runAsync( - () => Future.delayed(const Duration(milliseconds: 5)), - ); - await tester.pump(); - } + await waitForImageIo( + tester, + () => + find.byKey(BusyMarkImageDialogKeys.cancel).evaluate().isNotEmpty, + operation: 'image cancellation dialog', + ); expect(find.byKey(BusyMarkImageDialogKeys.cancel), findsOneWidget); } @@ -2306,16 +2330,11 @@ void main() { } Future waitForRollbackCount(int count) async { - for ( - var attempt = 0; - attempt < 100 && completedRollbacks.length < count; - attempt++ - ) { - await tester.runAsync( - () => Future.delayed(const Duration(milliseconds: 5)), - ); - await tester.pump(); - } + await waitForImageIo( + tester, + () => completedRollbacks.length >= count, + operation: 'image rollback count $count', + ); expect(completedRollbacks, hasLength(count)); } @@ -2401,12 +2420,11 @@ void main() { await waitForDialog(); await tester.tap(find.byKey(BusyMarkImageDialogKeys.cancel)); await tester.pumpAndSettle(); - for (var attempt = 0; attempt < 100 && pasteResult == null; attempt++) { - await tester.runAsync( - () => Future.delayed(const Duration(milliseconds: 5)), - ); - await tester.pump(); - } + await waitForImageIo( + tester, + () => pasteResult != null, + operation: 'cancelled history paste result', + ); expect(pasteResult, ClipboardPasteResult.cancelled); await waitForRollbackCount(3); expect(historyResult, 'Target\n'); @@ -2436,8 +2454,10 @@ void main() { final published = []; final committed = []; final rolledBack = []; + final reserved = []; final ingestion = AssetIngestionService( hooks: AssetIngestionHooks( + afterDestinationReserved: (path) async => reserved.add(path), afterPublication: (asset) async => published.add(asset), beforeCommit: (asset) async => committed.add(asset), afterRollback: (asset) async => rolledBack.add(asset), @@ -2457,31 +2477,31 @@ void main() { ); Future waitForCount(List values, int count) async { - for ( - var attempt = 0; - attempt < 100 && values.length < count; - attempt++ - ) { - await tester.runAsync( - () => Future.delayed(const Duration(milliseconds: 5)), - ); - await tester.pump(); - } + await waitForImageIo( + tester, + () => values.length >= count, + operation: 'image publication/rollback count $count', + diagnostics: () { + final fields = find.byType(TextField); + final focused = fields.evaluate().isEmpty + ? null + : tester.widget(fields.first).focusNode?.hasFocus; + return 'clipboard reads=$readCalls, reserved=${reserved.length}, ' + 'published=${published.length}, committed=${committed.length}, ' + 'rolled back=${rolledBack.length}, editor focus=$focused, ' + 'clipboard=$systemData'; + }, + ); expect(values.length, greaterThanOrEqualTo(count)); } Future waitForDialog() async { - for ( - var attempt = 0; - attempt < 100 && - find.byKey(BusyMarkImageDialogKeys.choose).evaluate().isEmpty; - attempt++ - ) { - await tester.runAsync( - () => Future.delayed(const Duration(milliseconds: 5)), - ); - await tester.pump(); - } + await waitForImageIo( + tester, + () => + find.byKey(BusyMarkImageDialogKeys.choose).evaluate().isNotEmpty, + operation: 'image alternatives dialog', + ); expect(find.byKey(BusyMarkImageDialogKeys.choose), findsOneWidget); } @@ -2626,24 +2646,18 @@ void main() { hostToasts: true, ); await key(tester, LogicalKeyboardKey.keyV); - for ( - var attempt = 0; - attempt < 100 && - find.byKey(BusyMarkImageDialogKeys.submit).evaluate().isEmpty; - attempt++ - ) { - await tester.runAsync( - () => Future.delayed(const Duration(milliseconds: 5)), - ); - await tester.pump(); - } + await waitForImageIo( + tester, + () => + find.byKey(BusyMarkImageDialogKeys.submit).evaluate().isNotEmpty, + operation: 'finalization image dialog', + ); await tester.tap(find.byKey(BusyMarkImageDialogKeys.submit)); - for (var attempt = 0; attempt < 100 && commitAttempts == 0; attempt++) { - await tester.runAsync( - () => Future.delayed(const Duration(milliseconds: 5)), - ); - await tester.pump(); - } + await waitForImageIo( + tester, + () => commitAttempts > 0, + operation: 'failed image commit attempt', + ); expect(commitAttempts, 1); expect(rollbacks, 0); @@ -2832,17 +2846,12 @@ void main() { addTearDown(registry.dispose); Future submitImage() async { - for ( - var attempt = 0; - attempt < 100 && - find.byKey(BusyMarkImageDialogKeys.submit).evaluate().isEmpty; - attempt++ - ) { - await tester.runAsync( - () => Future.delayed(const Duration(milliseconds: 5)), - ); - await tester.pump(); - } + await waitForImageIo( + tester, + () => + find.byKey(BusyMarkImageDialogKeys.submit).evaluate().isNotEmpty, + operation: 'history image dialog', + ); expect(find.byKey(BusyMarkImageDialogKeys.submit), findsOneWidget); await tester.tap(find.byKey(BusyMarkImageDialogKeys.submit)); await tester.pumpAndSettle(); @@ -3114,12 +3123,11 @@ void main() { composing: TextRange(start: 0, end: 1), ); ingestion.release.complete(); - for (var attempt = 0; attempt < 100 && pasteResult == null; attempt++) { - await tester.runAsync( - () => Future.delayed(const Duration(milliseconds: 5)), - ); - await tester.pump(); - } + await waitForImageIo( + tester, + () => pasteResult != null, + operation: 'stale image paste result', + ); expect(pasteResult, ClipboardPasteResult.staleTarget); expect(result, 'Target\n'); diff --git a/test/support/headless_chrome.dart b/test/support/headless_chrome.dart index d748206c..7a7181dd 100644 --- a/test/support/headless_chrome.dart +++ b/test/support/headless_chrome.dart @@ -21,9 +21,8 @@ class _BrowserHttpOverrides extends HttpOverrides {} Future _evaluateInChrome(String htmlPath, String expression) async { final profile = await Directory.systemTemp.createTemp('html-csp-browser-'); Process? browser; - WebSocket? socket; Uri? browserEndpoint; - final client = HttpClient(); + final documentUri = Uri.file(htmlPath); try { final endpoint = Completer(); browser = await Process.start(headlessChromePath!, [ @@ -35,7 +34,7 @@ Future _evaluateInChrome(String htmlPath, String expression) async { '--user-data-dir=${profile.path}', '--proxy-server=http://127.0.0.1:9', '--proxy-bypass-list=127.0.0.1;localhost', - Uri.file(htmlPath).toString(), + documentUri.toString(), ]); unawaited(browser.stdout.drain()); browser.stderr @@ -49,51 +48,12 @@ Future _evaluateInChrome(String htmlPath, String expression) async { }); final uri = await endpoint.future.timeout(const Duration(seconds: 15)); browserEndpoint = uri; - final request = await client.getUrl( - Uri.http('${uri.host}:${uri.port}', '/json/list'), - ); - final response = await request.close(); - final targets = - jsonDecode(await response.transform(utf8.decoder).join()) as List; - final page = targets.cast>().firstWhere( - (t) => t['type'] == 'page', - ); - socket = await WebSocket.connect(page['webSocketDebuggerUrl'] as String); - final messages = StreamIterator(socket); - socket.add( - jsonEncode({ - 'id': 1, - 'method': 'Runtime.evaluate', - 'params': { - 'expression': - '''(async () => { - if (document.readyState !== 'complete') { - await new Promise(resolve => addEventListener('load', resolve, {once:true})); - } - return $expression; - })()''', - 'awaitPromise': true, - 'returnByValue': true, - }, - }), + return await evaluateDevToolsDocument( + browserEndpoint: uri, + documentUri: documentUri, + expression: expression, ); - Future readResult() async { - while (await messages.moveNext()) { - final message = jsonDecode(messages.current as String) as Map; - if (message['id'] != 1) continue; - if (message['error'] != null || - message['result']['exceptionDetails'] != null) { - throw StateError('Browser evaluation failed: $message'); - } - return message['result']['result']['value']; - } - throw StateError('Browser closed before evaluating styles.'); - } - - return await readResult().timeout(const Duration(seconds: 15)); } finally { - await socket?.close(); - client.close(force: true); if (browser != null) { if (browserEndpoint != null) { final control = await WebSocket.connect(browserEndpoint.toString()); @@ -113,3 +73,161 @@ Future _evaluateInChrome(String htmlPath, String expression) async { await profile.delete(recursive: true); } } + +/// The narrow DevTools protocol seam used by the fake-server regression tests. +Future evaluateDevToolsDocument({ + required Uri browserEndpoint, + required Uri documentUri, + required String expression, + Duration targetTimeout = const Duration(seconds: 15), + Duration readinessTimeout = const Duration(seconds: 15), +}) async { + final client = HttpClient(); + WebSocket? socket; + StreamIterator? messages; + try { + // DevTools may appear while Chromium is still displaying its initial + // about:blank page. Evaluating there silently returns default CSS values. + final targetClock = Stopwatch()..start(); + Map? page; + Object? lastTargetState; + Future waitForTarget(Future pending) { + final remaining = targetTimeout - targetClock.elapsed; + StateError timedOut() => StateError( + 'Chromium did not load $documentUri before the DevTools target ' + 'deadline; last targets: $lastTargetState', + ); + if (remaining <= Duration.zero) throw timedOut(); + return pending.timeout(remaining, onTimeout: () => throw timedOut()); + } + + while (page == null && targetClock.elapsed < targetTimeout) { + final request = await waitForTarget( + client.getUrl( + Uri.http( + '${browserEndpoint.host}:${browserEndpoint.port}', + '/json/list', + ), + ), + ); + final response = await waitForTarget(request.close()); + final targets = + jsonDecode( + await waitForTarget(response.transform(utf8.decoder).join()), + ) + as List; + lastTargetState = targets; + for (final target in targets.cast>()) { + if (target['type'] == 'page' && + target['url'] == documentUri.toString()) { + page = target; + break; + } + } + if (page != null) break; + await Future.delayed(const Duration(milliseconds: 25)); + } + if (page == null) { + throw StateError( + 'Chromium did not load $documentUri; last targets: $lastTargetState', + ); + } + final evaluationSocket = await waitForTarget( + WebSocket.connect(page['webSocketDebuggerUrl'] as String), + ); + socket = evaluationSocket; + messages = StreamIterator(evaluationSocket); + final readyClock = Stopwatch()..start(); + var nextId = 0; + Object? lastPageState; + while (readyClock.elapsed < readinessTimeout) { + final id = ++nextId; + try { + evaluationSocket.add( + jsonEncode({ + 'id': id, + 'method': 'Runtime.evaluate', + 'params': { + 'expression': + '''(() => { + const ready = location.href === ${jsonEncode(documentUri.toString())} && + document.readyState === 'complete' && + document.querySelector('style') !== null; + return ready + ? {ready: true, value: ($expression)} + : {ready: false, url: location.href, + state: document.readyState, + styles: document.querySelectorAll('style').length}; + })()''', + 'returnByValue': true, + }, + }), + ); + } on StateError catch (error) { + throw StateError( + 'Chromium DevTools socket closed before response for ' + '$documentUri (request $id): $error; ' + 'last page state: $lastPageState', + ); + } + while (true) { + final remaining = readinessTimeout - readyClock.elapsed; + if (remaining <= Duration.zero) { + throw StateError( + 'Chromium DevTools response timed out for $documentUri ' + '(request $id); last page state: $lastPageState', + ); + } + bool hasMessage; + try { + hasMessage = await messages.moveNext().timeout(remaining); + } on TimeoutException { + throw StateError( + 'Chromium DevTools response timed out for $documentUri ' + '(request $id); last page state: $lastPageState', + ); + } + if (!hasMessage) { + throw StateError( + 'Chromium DevTools socket closed before response for ' + '$documentUri (request $id); last page state: $lastPageState', + ); + } + final message = jsonDecode(messages.current as String) as Map; + if (message['id'] != id) continue; + if (message['error'] != null || + message['result']['exceptionDetails'] != null) { + // Navigation can destroy the initial execution context. The next + // evaluation runs in the document's new context. + if (_isNavigationContextError(message)) { + lastPageState = message; + } else { + throw StateError( + 'Chromium DevTools evaluation failed for $documentUri: ' + '$message; last page state: $lastPageState', + ); + } + } else { + final state = message['result']['result']['value'] as Map; + if (state['ready'] == true) return state['value']; + lastPageState = state; + } + break; + } + await Future.delayed(const Duration(milliseconds: 25)); + } + throw StateError( + 'Chromium did not finish loading $documentUri: $lastPageState', + ); + } finally { + await messages?.cancel(); + await socket?.close(); + client.close(force: true); + } +} + +bool _isNavigationContextError(Map message) { + final details = jsonEncode(message).toLowerCase(); + return details.contains('execution context was destroyed') || + details.contains('cannot find context with specified id'); +} diff --git a/tools/build_install_snap_local.sh b/tools/build_install_snap_local.sh index d6bf304f..73a9ff5b 100755 --- a/tools/build_install_snap_local.sh +++ b/tools/build_install_snap_local.sh @@ -8,6 +8,11 @@ Usage: tools/build_install_snap_local.sh [version] [options] Build the Flutter Linux release, replace the payload in an installed snap scaffold, pack it, install it locally, and optionally run it. +This is a development repack: Ubuntu runtime dependencies and bundled tools +may be reused from the installed scaffold. It does not refresh dependencies or +apply snap/snapcraft.yaml package changes. Release and security builds require +the clean Snapcraft procedure documented in README.md. + Options: --no-install Pack and verify the snap without replacing the installed snap. --no-run Install the snap but do not run it. @@ -370,6 +375,11 @@ PROJECT_NAME="$(project_value name)" VERSION="${VERSION_ARG:-${VERSION:-$(project_value version)}}" [[ -n "$VERSION" ]] || fail "could not read version from pubspec.yaml" +echo "NOTICE: this helper creates a development repack from an installed scaffold." +echo "It may reuse the scaffold's Ubuntu dependencies and bundled tools." +echo "It does not perform a dependency/security refresh or apply Snap package changes." +echo "Use the clean Snapcraft procedure in README.md for release/security artifacts." + REQUIRED_FLUTTER_VERSION="$(project_flutter_version)" [[ -n "$REQUIRED_FLUTTER_VERSION" ]] || \ fail "pubspec.yaml must declare an exact environment.flutter version" @@ -439,6 +449,11 @@ rm -f "$SNAP_ROOT/usr/lib/x86_64-linux-gnu/librsvg-2.so.2"* echo "== Replace Flutter payload ==" rm -rf "$SNAP_ROOT/$BINARY_NAME" "$SNAP_ROOT/data" "$SNAP_ROOT/lib" +# A clean Snap scaffold links this path to its staged Noto packages. Replace +# the link itself so this development repack uses the newly built bundle fonts. +if [[ -d "$BUNDLE_DIR/share/busymark/fonts" ]]; then + rm -rf -- "$SNAP_ROOT/share/busymark/fonts" +fi cp -a "$BUNDLE_DIR/." "$SNAP_ROOT/" test -f "$SNAP_ROOT/$BINARY_NAME" || fail "missing staged binary: $SNAP_ROOT/$BINARY_NAME" @@ -726,7 +741,9 @@ fi ! unsquashfs -ll "$OUT" | grep -q \ 'squashfs-root/usr/lib/x86_64-linux-gnu/librsvg-2.so.2' -echo "Built snap: $OUT" +echo "Built development-repack snap: $OUT" +echo "Dependencies may have been reused from: $SNAP_SCAFFOLD" +echo "For release/security refreshes and packaging changes, use clean Snapcraft." if [[ "$INSTALL_AFTER_BUILD" == "1" ]]; then echo "== Install snap ==" sudo snap remove --purge "$SNAP_NAME" 2>/dev/null || true From 41c59886ff10161ca58fb7c143b649e82c18547d Mon Sep 17 00:00:00 2001 From: Albert G Date: Fri, 2 Oct 2026 02:29:04 -0700 Subject: [PATCH 03/14] Fix/spell check bug (#37) * Add input validation for embedded NUL characters and fix Thai word boundaries * Add test script for spelling dictionary installation process * Add comprehensive tests for Markdown and Writerside XML spelling projections * Improve spelling service robustness with enhanced caching, dictionary installation locking, and personal word handling refinements * Enhance spelling settings loading with error handling, retry mechanism, and state management improvements * Add localized strings for spelling settings error messages and retry actions * Refine spelling preparation logic, enhance Markdown projection accuracy, and expand tests for spelling service, settings, and inline images. * Refine personal store conflict handling, enhance Markdown spelling projection with improved inline syntax and fence parsing, and expand projection and spelling service test coverage. * Expand Markdown spelling projection with tab handling in list fences, improve blank line continuation logic, and add comprehensive tests for fence parsing. * Improve markdown spelling source mapping * Enhance title mapping for destination-only links and images, ensuring no false titles are exposed. Update tests to verify correct behavior. * Enhance spellcheck projection line break handling, HTML parsing, and session recovery * Add `interpretHtmlSyntax` option to `_MarkdownScanner` to disable HTML parsing within attribute and title sub-scanners. * Implement `_scanMappedLabel` and inline line break splitting to properly project multiline link labels and text nodes with newline transformations. * Include `markdownParenthesizedTitle` in WYSIWYG projection context assertions. * Track project word store read failures in `SpellingSessionController` to allow restoring the previous state upon successful read recovery. * Improve multiline Markdown label spellchecking and refine project store error recovery * Expose raw positioned line breaks and container continuation prefixes in `BusyMarkMappedInlineParse`. * Refactor `MarkdownSpellingProjector` label scanning to process multiline link and image labels using exact line break and continuation syntax mappings. * Isolate project store read failure state in `SpellingSessionController` to ensure accurate recovery without clearing unrelated personal storage errors. * Add test cases for positioned line breaks in mapped blocks, multiline label spelling projections, and project store failure recovery. * Support multiline nested inline elements in markdown spelling projection * Include image labels in opaque syntax spans and track internal newlines via `opaqueSemanticBreaks`. * Update inline projection scanning to process nested opaque spans and recognized links before advancing past raw physical line breaks in link labels. * Add unit tests covering source projection and WYSIWYG rich text correction for nested multiline code spans and image destinations within link labels. * Support spellchecking code-styled image alt text and hard breaks in link labels * Add `_parseMappedImageLabel` to translate normalized line break offsets back to raw source coordinates for image label inline parses. * Process hard breaks and code spans within link and image labels during Markdown spelling projection instead of treating code spans in image alt text as opaque. * Add `_scanImageCode` to parse code backticks in image alt text and project spellcheckable semantic content. * Add test coverage for spelling projection and rich document correction of code-styled image alt text and inline breaks in link labels. * Preserve line breaks and source offsets for code spans in Markdown image labels * Update `_parseMappedImageLabel` to process positioned line breaks and translate structural continuation prefixes within image label ranges. * Enhance `_MappedImageCode` to track individual text and line-break units with exact source boundary offsets. * Introduce `_ParsedImageLabel` to return mapped inline parses alongside extracted image code spans. * Add unit and rich-editor tests for spellcheck projection on code-styled image descriptions within lists, blockquotes, and multiline continuations. * Support Markdown image alt-text semantics in source mapping and spelling projection * Add `imageDescriptionText` to `BusyMarkMappedInlineParse` to extract alt-text semantics during inline parsing. * Update `MarkdownSpellingProjector` validation checks to compare against `imageDescriptionText` rather than concatenated inline plain text. * Add test cases for image alt-text extraction and spelling projection involving inline code and hard line breaks. * Preserve Markdown source syntax during rich spelling corrections and harden dictionary catalog publication * Preserve original Markdown syntax, HTML entities, and code spans during rich text spelling corrections by applying replacement plans directly to document source. * Map image alternative text inlines between Markdown source and WYSIWYG editor spelling projections. * Add raw HTML text annotations and code-span validation to prevent invalid replacements inside Markdown code spans. * Acquire publication locks and verify directory state identities during dictionary installation and removal to prevent stale deletion races. * Implement single-flight catalog refreshing in `SpellingSessionController` and trigger re-indexing when native dictionary loads fail. * Ensure single-step undo history recording when applying prepared spelling corrections in the WYSIWYG editor. * Fix spellchecking projections and corrections for nested images and math blocks * Apply spelling replacement plans directly to math editable field text when blocks contain math rather than attempting inline-leaf edits. * Preserve authored math field source for non-dirty blocks containing images during inline text serializations. * Enhance image description tracking and bounds validation across spelling projections to support nested images and math blocks. * Improve mapped image destination matching to account for multiline continuation prefix encoding differences. * Refactor rich-to-source atom projection mapping to handle logical atoms spanning multiple rich segments. * Add tests verifying nested image spellcheck policy, math-adjacent image correction, and undo/redo state restoration. --- lib/l10n/app_ar.arb | 2 + lib/l10n/app_de.arb | 2 + lib/l10n/app_en.arb | 4 + lib/l10n/app_es.arb | 2 + lib/l10n/app_et.arb | 2 + lib/l10n/app_fa.arb | 2 + lib/l10n/app_fr.arb | 2 + lib/l10n/app_hi.arb | 2 + lib/l10n/app_id.arb | 2 + lib/l10n/app_it.arb | 2 + lib/l10n/app_ja.arb | 2 + lib/l10n/app_ko.arb | 2 + lib/l10n/app_nb.arb | 2 + lib/l10n/app_nl.arb | 2 + lib/l10n/app_pl.arb | 2 + lib/l10n/app_pt.arb | 2 + lib/l10n/app_pt_BR.arb | 2 + lib/l10n/app_ru.arb | 2 + lib/l10n/app_tr.arb | 2 + lib/l10n/app_uk.arb | 2 + lib/l10n/app_vi.arb | 2 + lib/l10n/app_zh.arb | 2 + lib/l10n/app_zh_CN.arb | 2 + lib/l10n/generated/app_localizations.dart | 12 + lib/l10n/generated/app_localizations_ar.dart | 8 + lib/l10n/generated/app_localizations_de.dart | 7 + lib/l10n/generated/app_localizations_en.dart | 6 + lib/l10n/generated/app_localizations_es.dart | 8 + lib/l10n/generated/app_localizations_et.dart | 7 + lib/l10n/generated/app_localizations_fa.dart | 6 + lib/l10n/generated/app_localizations_fr.dart | 8 + lib/l10n/generated/app_localizations_hi.dart | 7 + lib/l10n/generated/app_localizations_id.dart | 8 + lib/l10n/generated/app_localizations_it.dart | 8 + lib/l10n/generated/app_localizations_ja.dart | 6 + lib/l10n/generated/app_localizations_ko.dart | 6 + lib/l10n/generated/app_localizations_nb.dart | 8 + lib/l10n/generated/app_localizations_nl.dart | 8 + lib/l10n/generated/app_localizations_pl.dart | 8 + lib/l10n/generated/app_localizations_pt.dart | 16 + lib/l10n/generated/app_localizations_ru.dart | 8 + lib/l10n/generated/app_localizations_tr.dart | 7 + lib/l10n/generated/app_localizations_uk.dart | 8 + lib/l10n/generated/app_localizations_vi.dart | 7 + lib/l10n/generated/app_localizations_zh.dart | 12 + .../wysiwyg/wysiwyg_document_controller.dart | 73 + lib/src/editor/wysiwyg/wysiwyg_editor.dart | 25 +- .../wysiwyg/wysiwyg_inline_controller.dart | 64 + lib/src/markdown/markdown_ast_adapter.dart | 17 +- .../markdown/markdown_source_annotation.dart | 25 + lib/src/markdown/markdown_source_map.dart | 137 +- .../markdown_spelling_projection.dart | 1101 +++++- lib/src/spellcheck/spelling_catalog.dart | 189 +- lib/src/spellcheck/spelling_coordinator.dart | 40 +- .../spelling_dictionary_downloader.dart | 37 +- .../spelling_dictionary_installer.dart | 174 +- .../spelling_inline_projection.dart | 107 +- lib/src/spellcheck/spelling_projection.dart | 22 + lib/src/spellcheck/spelling_replacement.dart | 13 + .../spelling_session_controller.dart | 487 ++- lib/src/spellcheck/spelling_worker.dart | 6 + .../writerside_spelling_projection.dart | 79 +- .../wysiwyg_spelling_projection.dart | 170 +- .../presentation/settings_screen.dart | 55 +- .../presentation/workspace_screen.dart | 4 +- .../lib/src/native_dictionary.dart | 11 + .../src/busymark_spellcheck_native.cpp | 2 +- .../test/busymark_spellcheck_native_test.dart | 46 + test/src/app_smoke_test.dart | 19 + test/src/markdown_source_map_test.dart | 175 + test/src/spellcheck_projection_test.dart | 3258 +++++++++++++++-- .../src/spelling_editor_integration_test.dart | 464 ++- test/src/spelling_service_test.dart | 2447 ++++++++++++- test/src/spelling_workspace_test.dart | 125 +- ...spelling_dictionary_installer_process.dart | 63 + 75 files changed, 8946 insertions(+), 716 deletions(-) create mode 100644 test/support/spelling_dictionary_installer_process.dart diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index 3ac306b7..5d23d3c4 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -104,6 +104,8 @@ "noSpellingSuggestions": "لا توجد اقتراحات إملائية", "spellingWordCheckFailed": "تعذّر التحقق من هذه الكلمة", "spellingCheckFailed": "فشل التدقيق الإملائي", + "spellingSettingsLoadFailed": "تعذّر تحميل إعدادات التدقيق الإملائي", + "retrySpellingSettings": "إعادة محاولة تحميل إعدادات التدقيق الإملائي", "spellingCheckIncomplete": "التدقيق الإملائي غير مكتمل", "spellingDictionaryNotInstalled": "القاموس غير مثبّت", "spellingDictionaryNotInstalledForLanguage": "قاموس ⁨{dictionary}⁩ غير مثبّت", diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index d8e66e47..13d752f8 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -104,6 +104,8 @@ "noSpellingSuggestions": "Keine Rechtschreibvorschläge", "spellingWordCheckFailed": "Dieses Wort konnte nicht geprüft werden", "spellingCheckFailed": "Rechtschreibprüfung fehlgeschlagen", + "spellingSettingsLoadFailed": "Rechtschreibeinstellungen konnten nicht geladen werden", + "retrySpellingSettings": "Rechtschreibeinstellungen erneut laden", "spellingCheckIncomplete": "Rechtschreibprüfung ist unvollständig", "spellingDictionaryNotInstalled": "Wörterbuch nicht installiert", "spellingDictionaryNotInstalledForLanguage": "Das Wörterbuch {dictionary} ist nicht installiert", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 32eb36df..ce6e3e4a 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -2768,6 +2768,10 @@ "noSpellingSuggestions": "No spelling suggestions", "spellingWordCheckFailed": "Could not check this word", "spellingCheckFailed": "Spelling check failed", + "spellingSettingsLoadFailed": "Could not load spelling settings", + "@spellingSettingsLoadFailed": {"description": "Settings failure shown when a spelling word store cannot be read."}, + "retrySpellingSettings": "Retry loading spelling settings", + "@retrySpellingSettings": {"description": "Action that deliberately retries failed spelling settings preparation."}, "spellingCheckIncomplete": "Spelling check is incomplete", "spellingDictionaryNotInstalled": "Dictionary not installed", "spellingDictionaryNotInstalledForLanguage": "{dictionary} dictionary is not installed", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index b93644a7..00d86d98 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -104,6 +104,8 @@ "noSpellingSuggestions": "No hay sugerencias ortográficas", "spellingWordCheckFailed": "No se pudo comprobar esta palabra", "spellingCheckFailed": "La revisión ortográfica falló", + "spellingSettingsLoadFailed": "No se pudo cargar la configuración ortográfica", + "retrySpellingSettings": "Volver a cargar la configuración ortográfica", "spellingCheckIncomplete": "La revisión ortográfica está incompleta", "spellingDictionaryNotInstalled": "Diccionario no instalado", "spellingDictionaryNotInstalledForLanguage": "El diccionario de {dictionary} no está instalado", diff --git a/lib/l10n/app_et.arb b/lib/l10n/app_et.arb index f129aa47..3f36423c 100644 --- a/lib/l10n/app_et.arb +++ b/lib/l10n/app_et.arb @@ -104,6 +104,8 @@ "noSpellingSuggestions": "Õigekirjasoovitusi pole", "spellingWordCheckFailed": "Seda sõna ei saanud kontrollida", "spellingCheckFailed": "Õigekirjakontroll nurjus", + "spellingSettingsLoadFailed": "Õigekirjakontrolli sätteid ei õnnestunud laadida", + "retrySpellingSettings": "Laadi õigekirjakontrolli sätted uuesti", "spellingCheckIncomplete": "Õigekirjakontroll on pooleli", "spellingDictionaryNotInstalled": "Sõnastik pole installitud", "spellingDictionaryNotInstalledForLanguage": "{dictionary} sõnastik pole installitud", diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb index a324bdfc..5e7679cf 100644 --- a/lib/l10n/app_fa.arb +++ b/lib/l10n/app_fa.arb @@ -104,6 +104,8 @@ "noSpellingSuggestions": "پیشنهاد املایی وجود ندارد", "spellingWordCheckFailed": "بررسی این واژه ممکن نشد", "spellingCheckFailed": "بررسی املا ناموفق بود", + "spellingSettingsLoadFailed": "تنظیمات بررسی املا بارگیری نشد", + "retrySpellingSettings": "بارگیری دوبارهٔ تنظیمات بررسی املا", "spellingCheckIncomplete": "بررسی املا کامل نیست", "spellingDictionaryNotInstalled": "واژه‌نامه نصب نشده است", "spellingDictionaryNotInstalledForLanguage": "واژه‌نامهٔ ⁨{dictionary}⁩ نصب نشده است", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index ac279995..39617df6 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -104,6 +104,8 @@ "noSpellingSuggestions": "Aucune suggestion orthographique", "spellingWordCheckFailed": "Impossible de vérifier ce mot", "spellingCheckFailed": "Échec de la vérification orthographique", + "spellingSettingsLoadFailed": "Impossible de charger les paramètres de correction orthographique", + "retrySpellingSettings": "Réessayer de charger les paramètres de correction orthographique", "spellingCheckIncomplete": "La vérification orthographique est incomplète", "spellingDictionaryNotInstalled": "Dictionnaire non installé", "spellingDictionaryNotInstalledForLanguage": "Le dictionnaire {dictionary} n’est pas installé", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 3490fb0a..60c33799 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -104,6 +104,8 @@ "noSpellingSuggestions": "कोई वर्तनी सुझाव नहीं", "spellingWordCheckFailed": "इस शब्द की जाँच नहीं हो सकी", "spellingCheckFailed": "वर्तनी जाँच विफल रही", + "spellingSettingsLoadFailed": "वर्तनी जाँच सेटिंग लोड नहीं हो सकीं", + "retrySpellingSettings": "वर्तनी जाँच सेटिंग फिर से लोड करें", "spellingCheckIncomplete": "वर्तनी जाँच अधूरी है", "spellingDictionaryNotInstalled": "शब्दकोश इंस्टॉल नहीं है", "spellingDictionaryNotInstalledForLanguage": "{dictionary} शब्दकोश इंस्टॉल नहीं है", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index e512d425..977e921b 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -104,6 +104,8 @@ "noSpellingSuggestions": "Tidak ada saran ejaan", "spellingWordCheckFailed": "Kata ini tidak dapat diperiksa", "spellingCheckFailed": "Pemeriksaan ejaan gagal", + "spellingSettingsLoadFailed": "Pengaturan pemeriksaan ejaan tidak dapat dimuat", + "retrySpellingSettings": "Coba muat ulang pengaturan pemeriksaan ejaan", "spellingCheckIncomplete": "Pemeriksaan ejaan belum lengkap", "spellingDictionaryNotInstalled": "Kamus belum terpasang", "spellingDictionaryNotInstalledForLanguage": "Kamus {dictionary} belum terpasang", diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 1dc0d3b0..c48b4efc 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -104,6 +104,8 @@ "noSpellingSuggestions": "Nessun suggerimento ortografico", "spellingWordCheckFailed": "Impossibile controllare questa parola", "spellingCheckFailed": "Controllo ortografico non riuscito", + "spellingSettingsLoadFailed": "Impossibile caricare le impostazioni di controllo ortografico", + "retrySpellingSettings": "Riprova a caricare le impostazioni di controllo ortografico", "spellingCheckIncomplete": "Il controllo ortografico è incompleto", "spellingDictionaryNotInstalled": "Dizionario non installato", "spellingDictionaryNotInstalledForLanguage": "Il dizionario {dictionary} non è installato", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 2902a604..c80b386e 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -104,6 +104,8 @@ "noSpellingSuggestions": "スペル候補はありません", "spellingWordCheckFailed": "この単語を確認できませんでした", "spellingCheckFailed": "スペルチェックに失敗しました", + "spellingSettingsLoadFailed": "スペルチェックの設定を読み込めませんでした", + "retrySpellingSettings": "スペルチェックの設定を再読み込み", "spellingCheckIncomplete": "スペルチェックが完了していません", "spellingDictionaryNotInstalled": "辞書がインストールされていません", "spellingDictionaryNotInstalledForLanguage": "{dictionary} の辞書がインストールされていません", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index bc9f4ada..33cee5e1 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -104,6 +104,8 @@ "noSpellingSuggestions": "맞춤법 제안이 없습니다", "spellingWordCheckFailed": "이 단어를 확인할 수 없습니다", "spellingCheckFailed": "맞춤법 검사에 실패했습니다", + "spellingSettingsLoadFailed": "맞춤법 검사 설정을 불러올 수 없습니다", + "retrySpellingSettings": "맞춤법 검사 설정 다시 불러오기", "spellingCheckIncomplete": "맞춤법 검사가 완료되지 않았습니다", "spellingDictionaryNotInstalled": "사전이 설치되지 않음", "spellingDictionaryNotInstalledForLanguage": "{dictionary} 사전이 설치되지 않았습니다", diff --git a/lib/l10n/app_nb.arb b/lib/l10n/app_nb.arb index b805ea54..e26e9eb1 100644 --- a/lib/l10n/app_nb.arb +++ b/lib/l10n/app_nb.arb @@ -104,6 +104,8 @@ "noSpellingSuggestions": "Ingen staveforslag", "spellingWordCheckFailed": "Kunne ikke kontrollere dette ordet", "spellingCheckFailed": "Stavekontrollen mislyktes", + "spellingSettingsLoadFailed": "Kunne ikke laste inn innstillinger for stavekontroll", + "retrySpellingSettings": "Prøv å laste inn innstillinger for stavekontroll på nytt", "spellingCheckIncomplete": "Stavekontrollen er ufullstendig", "spellingDictionaryNotInstalled": "Ordliste er ikke installert", "spellingDictionaryNotInstalledForLanguage": "Ordlisten for {dictionary} er ikke installert", diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 88ea5ddc..f52d5538 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -104,6 +104,8 @@ "noSpellingSuggestions": "Geen spellingsuggesties", "spellingWordCheckFailed": "Dit woord kon niet worden gecontroleerd", "spellingCheckFailed": "Spellingcontrole mislukt", + "spellingSettingsLoadFailed": "Instellingen voor spellingcontrole konden niet worden geladen", + "retrySpellingSettings": "Instellingen voor spellingcontrole opnieuw laden", "spellingCheckIncomplete": "De spellingcontrole is onvolledig", "spellingDictionaryNotInstalled": "Woordenboek niet geïnstalleerd", "spellingDictionaryNotInstalledForLanguage": "Het woordenboek voor {dictionary} is niet geïnstalleerd", diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index e76da42e..01f347cb 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -104,6 +104,8 @@ "noSpellingSuggestions": "Brak sugestii pisowni", "spellingWordCheckFailed": "Nie można sprawdzić tego słowa", "spellingCheckFailed": "Sprawdzanie pisowni nie powiodło się", + "spellingSettingsLoadFailed": "Nie można wczytać ustawień sprawdzania pisowni", + "retrySpellingSettings": "Ponów wczytywanie ustawień sprawdzania pisowni", "spellingCheckIncomplete": "Sprawdzanie pisowni jest niepełne", "spellingDictionaryNotInstalled": "Słownik nie jest zainstalowany", "spellingDictionaryNotInstalledForLanguage": "Słownik {dictionary} nie jest zainstalowany", diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 0f24d460..018d6bc5 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -104,6 +104,8 @@ "noSpellingSuggestions": "Sem sugestões ortográficas", "spellingWordCheckFailed": "Não foi possível verificar esta palavra", "spellingCheckFailed": "A verificação ortográfica falhou", + "spellingSettingsLoadFailed": "Não foi possível carregar as definições de verificação ortográfica", + "retrySpellingSettings": "Tentar carregar novamente as definições de verificação ortográfica", "spellingCheckIncomplete": "A verificação ortográfica está incompleta", "spellingDictionaryNotInstalled": "Dicionário não instalado", "spellingDictionaryNotInstalledForLanguage": "O dicionário de {dictionary} não está instalado", diff --git a/lib/l10n/app_pt_BR.arb b/lib/l10n/app_pt_BR.arb index b745d481..84333041 100644 --- a/lib/l10n/app_pt_BR.arb +++ b/lib/l10n/app_pt_BR.arb @@ -104,6 +104,8 @@ "noSpellingSuggestions": "Nenhuma sugestão ortográfica", "spellingWordCheckFailed": "Não foi possível verificar esta palavra", "spellingCheckFailed": "A verificação ortográfica falhou", + "spellingSettingsLoadFailed": "Não foi possível carregar as configurações de verificação ortográfica", + "retrySpellingSettings": "Tentar carregar novamente as configurações de verificação ortográfica", "spellingCheckIncomplete": "A verificação ortográfica está incompleta", "spellingDictionaryNotInstalled": "Dicionário não instalado", "spellingDictionaryNotInstalledForLanguage": "O dicionário de {dictionary} não está instalado", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index fc0020b9..63583516 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -104,6 +104,8 @@ "noSpellingSuggestions": "Нет вариантов исправления", "spellingWordCheckFailed": "Не удалось проверить это слово", "spellingCheckFailed": "Не удалось проверить правописание", + "spellingSettingsLoadFailed": "Не удалось загрузить настройки проверки орфографии", + "retrySpellingSettings": "Повторить загрузку настроек проверки орфографии", "spellingCheckIncomplete": "Проверка правописания не завершена", "spellingDictionaryNotInstalled": "Словарь не установлен", "spellingDictionaryNotInstalledForLanguage": "Словарь «{dictionary}» не установлен", diff --git a/lib/l10n/app_tr.arb b/lib/l10n/app_tr.arb index 926e572c..6557a2ec 100644 --- a/lib/l10n/app_tr.arb +++ b/lib/l10n/app_tr.arb @@ -104,6 +104,8 @@ "noSpellingSuggestions": "Yazım önerisi yok", "spellingWordCheckFailed": "Bu sözcük denetlenemedi", "spellingCheckFailed": "Yazım denetimi başarısız oldu", + "spellingSettingsLoadFailed": "Yazım denetimi ayarları yüklenemedi", + "retrySpellingSettings": "Yazım denetimi ayarlarını yeniden yükle", "spellingCheckIncomplete": "Yazım denetimi tamamlanmadı", "spellingDictionaryNotInstalled": "Sözlük yüklü değil", "spellingDictionaryNotInstalledForLanguage": "{dictionary} sözlüğü yüklü değil", diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb index f1a8f944..6d874eaf 100644 --- a/lib/l10n/app_uk.arb +++ b/lib/l10n/app_uk.arb @@ -104,6 +104,8 @@ "noSpellingSuggestions": "Немає варіантів виправлення", "spellingWordCheckFailed": "Не вдалося перевірити це слово", "spellingCheckFailed": "Не вдалося перевірити правопис", + "spellingSettingsLoadFailed": "Не вдалося завантажити налаштування перевірки правопису", + "retrySpellingSettings": "Повторити завантаження налаштувань перевірки правопису", "spellingCheckIncomplete": "Перевірку правопису не завершено", "spellingDictionaryNotInstalled": "Словник не встановлено", "spellingDictionaryNotInstalledForLanguage": "Словник «{dictionary}» не встановлено", diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index b125a60f..d6e8ba81 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -104,6 +104,8 @@ "noSpellingSuggestions": "Không có đề xuất chính tả", "spellingWordCheckFailed": "Không thể kiểm tra từ này", "spellingCheckFailed": "Kiểm tra chính tả thất bại", + "spellingSettingsLoadFailed": "Không thể tải cài đặt kiểm tra chính tả", + "retrySpellingSettings": "Thử tải lại cài đặt kiểm tra chính tả", "spellingCheckIncomplete": "Kiểm tra chính tả chưa hoàn tất", "spellingDictionaryNotInstalled": "Từ điển chưa được cài đặt", "spellingDictionaryNotInstalledForLanguage": "Từ điển {dictionary} chưa được cài đặt", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 208f9870..365914a0 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -104,6 +104,8 @@ "noSpellingSuggestions": "没有拼写建议", "spellingWordCheckFailed": "无法检查此单词", "spellingCheckFailed": "拼写检查失败", + "spellingSettingsLoadFailed": "无法加载拼写检查设置", + "retrySpellingSettings": "重试加载拼写检查设置", "spellingCheckIncomplete": "拼写检查未完成", "spellingDictionaryNotInstalled": "尚未安装词典", "spellingDictionaryNotInstalledForLanguage": "尚未安装 {dictionary} 词典", diff --git a/lib/l10n/app_zh_CN.arb b/lib/l10n/app_zh_CN.arb index ae0bbb1d..2295b5e3 100644 --- a/lib/l10n/app_zh_CN.arb +++ b/lib/l10n/app_zh_CN.arb @@ -104,6 +104,8 @@ "noSpellingSuggestions": "没有拼写建议", "spellingWordCheckFailed": "无法检查此单词", "spellingCheckFailed": "拼写检查失败", + "spellingSettingsLoadFailed": "无法加载拼写检查设置", + "retrySpellingSettings": "重试加载拼写检查设置", "spellingCheckIncomplete": "拼写检查未完成", "spellingDictionaryNotInstalled": "尚未安装词典", "spellingDictionaryNotInstalledForLanguage": "尚未安装 {dictionary} 词典", diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 93b9d9fc..0337857e 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -7752,6 +7752,18 @@ abstract class AppLocalizations { /// **'Spelling check failed'** String get spellingCheckFailed; + /// Settings failure shown when a spelling word store cannot be read. + /// + /// In en, this message translates to: + /// **'Could not load spelling settings'** + String get spellingSettingsLoadFailed; + + /// Action that deliberately retries failed spelling settings preparation. + /// + /// In en, this message translates to: + /// **'Retry loading spelling settings'** + String get retrySpellingSettings; + /// No description provided for @spellingCheckIncomplete. /// /// In en, this message translates to: diff --git a/lib/l10n/generated/app_localizations_ar.dart b/lib/l10n/generated/app_localizations_ar.dart index 35f4eb30..48f62637 100644 --- a/lib/l10n/generated/app_localizations_ar.dart +++ b/lib/l10n/generated/app_localizations_ar.dart @@ -4612,6 +4612,14 @@ class AppLocalizationsAr extends AppLocalizations { @override String get spellingCheckFailed => 'فشل التدقيق الإملائي'; + @override + String get spellingSettingsLoadFailed => + 'تعذّر تحميل إعدادات التدقيق الإملائي'; + + @override + String get retrySpellingSettings => + 'إعادة محاولة تحميل إعدادات التدقيق الإملائي'; + @override String get spellingCheckIncomplete => 'التدقيق الإملائي غير مكتمل'; diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index 0555ff1b..e0bb9e87 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -4622,6 +4622,13 @@ class AppLocalizationsDe extends AppLocalizations { @override String get spellingCheckFailed => 'Rechtschreibprüfung fehlgeschlagen'; + @override + String get spellingSettingsLoadFailed => + 'Rechtschreibeinstellungen konnten nicht geladen werden'; + + @override + String get retrySpellingSettings => 'Rechtschreibeinstellungen erneut laden'; + @override String get spellingCheckIncomplete => 'Rechtschreibprüfung ist unvollständig'; diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index 8b5c64be..a902381e 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -4597,6 +4597,12 @@ class AppLocalizationsEn extends AppLocalizations { @override String get spellingCheckFailed => 'Spelling check failed'; + @override + String get spellingSettingsLoadFailed => 'Could not load spelling settings'; + + @override + String get retrySpellingSettings => 'Retry loading spelling settings'; + @override String get spellingCheckIncomplete => 'Spelling check is incomplete'; diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index f355d87a..961dd62c 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -4623,6 +4623,14 @@ class AppLocalizationsEs extends AppLocalizations { @override String get spellingCheckFailed => 'La revisión ortográfica falló'; + @override + String get spellingSettingsLoadFailed => + 'No se pudo cargar la configuración ortográfica'; + + @override + String get retrySpellingSettings => + 'Volver a cargar la configuración ortográfica'; + @override String get spellingCheckIncomplete => 'La revisión ortográfica está incompleta'; diff --git a/lib/l10n/generated/app_localizations_et.dart b/lib/l10n/generated/app_localizations_et.dart index d313d670..cf36d1ea 100644 --- a/lib/l10n/generated/app_localizations_et.dart +++ b/lib/l10n/generated/app_localizations_et.dart @@ -4579,6 +4579,13 @@ class AppLocalizationsEt extends AppLocalizations { @override String get spellingCheckFailed => 'Õigekirjakontroll nurjus'; + @override + String get spellingSettingsLoadFailed => + 'Õigekirjakontrolli sätteid ei õnnestunud laadida'; + + @override + String get retrySpellingSettings => 'Laadi õigekirjakontrolli sätted uuesti'; + @override String get spellingCheckIncomplete => 'Õigekirjakontroll on pooleli'; diff --git a/lib/l10n/generated/app_localizations_fa.dart b/lib/l10n/generated/app_localizations_fa.dart index cf14e58c..5e5ded3d 100644 --- a/lib/l10n/generated/app_localizations_fa.dart +++ b/lib/l10n/generated/app_localizations_fa.dart @@ -4620,6 +4620,12 @@ class AppLocalizationsFa extends AppLocalizations { @override String get spellingCheckFailed => 'بررسی املا ناموفق بود'; + @override + String get spellingSettingsLoadFailed => 'تنظیمات بررسی املا بارگیری نشد'; + + @override + String get retrySpellingSettings => 'بارگیری دوبارهٔ تنظیمات بررسی املا'; + @override String get spellingCheckIncomplete => 'بررسی املا کامل نیست'; diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart index 8952190b..49a8a141 100644 --- a/lib/l10n/generated/app_localizations_fr.dart +++ b/lib/l10n/generated/app_localizations_fr.dart @@ -4625,6 +4625,14 @@ class AppLocalizationsFr extends AppLocalizations { @override String get spellingCheckFailed => 'Échec de la vérification orthographique'; + @override + String get spellingSettingsLoadFailed => + 'Impossible de charger les paramètres de correction orthographique'; + + @override + String get retrySpellingSettings => + 'Réessayer de charger les paramètres de correction orthographique'; + @override String get spellingCheckIncomplete => 'La vérification orthographique est incomplète'; diff --git a/lib/l10n/generated/app_localizations_hi.dart b/lib/l10n/generated/app_localizations_hi.dart index 27d7b3c9..875cbd2f 100644 --- a/lib/l10n/generated/app_localizations_hi.dart +++ b/lib/l10n/generated/app_localizations_hi.dart @@ -4572,6 +4572,13 @@ class AppLocalizationsHi extends AppLocalizations { @override String get spellingCheckFailed => 'वर्तनी जाँच विफल रही'; + @override + String get spellingSettingsLoadFailed => + 'वर्तनी जाँच सेटिंग लोड नहीं हो सकीं'; + + @override + String get retrySpellingSettings => 'वर्तनी जाँच सेटिंग फिर से लोड करें'; + @override String get spellingCheckIncomplete => 'वर्तनी जाँच अधूरी है'; diff --git a/lib/l10n/generated/app_localizations_id.dart b/lib/l10n/generated/app_localizations_id.dart index d8099504..6e16981b 100644 --- a/lib/l10n/generated/app_localizations_id.dart +++ b/lib/l10n/generated/app_localizations_id.dart @@ -4581,6 +4581,14 @@ class AppLocalizationsId extends AppLocalizations { @override String get spellingCheckFailed => 'Pemeriksaan ejaan gagal'; + @override + String get spellingSettingsLoadFailed => + 'Pengaturan pemeriksaan ejaan tidak dapat dimuat'; + + @override + String get retrySpellingSettings => + 'Coba muat ulang pengaturan pemeriksaan ejaan'; + @override String get spellingCheckIncomplete => 'Pemeriksaan ejaan belum lengkap'; diff --git a/lib/l10n/generated/app_localizations_it.dart b/lib/l10n/generated/app_localizations_it.dart index 9c85d3f8..47f5f1f0 100644 --- a/lib/l10n/generated/app_localizations_it.dart +++ b/lib/l10n/generated/app_localizations_it.dart @@ -4619,6 +4619,14 @@ class AppLocalizationsIt extends AppLocalizations { @override String get spellingCheckFailed => 'Controllo ortografico non riuscito'; + @override + String get spellingSettingsLoadFailed => + 'Impossibile caricare le impostazioni di controllo ortografico'; + + @override + String get retrySpellingSettings => + 'Riprova a caricare le impostazioni di controllo ortografico'; + @override String get spellingCheckIncomplete => 'Il controllo ortografico è incompleto'; diff --git a/lib/l10n/generated/app_localizations_ja.dart b/lib/l10n/generated/app_localizations_ja.dart index 66538f3a..b92ad30e 100644 --- a/lib/l10n/generated/app_localizations_ja.dart +++ b/lib/l10n/generated/app_localizations_ja.dart @@ -4454,6 +4454,12 @@ class AppLocalizationsJa extends AppLocalizations { @override String get spellingCheckFailed => 'スペルチェックに失敗しました'; + @override + String get spellingSettingsLoadFailed => 'スペルチェックの設定を読み込めませんでした'; + + @override + String get retrySpellingSettings => 'スペルチェックの設定を再読み込み'; + @override String get spellingCheckIncomplete => 'スペルチェックが完了していません'; diff --git a/lib/l10n/generated/app_localizations_ko.dart b/lib/l10n/generated/app_localizations_ko.dart index a38007c9..92f385ba 100644 --- a/lib/l10n/generated/app_localizations_ko.dart +++ b/lib/l10n/generated/app_localizations_ko.dart @@ -4448,6 +4448,12 @@ class AppLocalizationsKo extends AppLocalizations { @override String get spellingCheckFailed => '맞춤법 검사에 실패했습니다'; + @override + String get spellingSettingsLoadFailed => '맞춤법 검사 설정을 불러올 수 없습니다'; + + @override + String get retrySpellingSettings => '맞춤법 검사 설정 다시 불러오기'; + @override String get spellingCheckIncomplete => '맞춤법 검사가 완료되지 않았습니다'; diff --git a/lib/l10n/generated/app_localizations_nb.dart b/lib/l10n/generated/app_localizations_nb.dart index 82239d9f..17d9c21f 100644 --- a/lib/l10n/generated/app_localizations_nb.dart +++ b/lib/l10n/generated/app_localizations_nb.dart @@ -4580,6 +4580,14 @@ class AppLocalizationsNb extends AppLocalizations { @override String get spellingCheckFailed => 'Stavekontrollen mislyktes'; + @override + String get spellingSettingsLoadFailed => + 'Kunne ikke laste inn innstillinger for stavekontroll'; + + @override + String get retrySpellingSettings => + 'Prøv å laste inn innstillinger for stavekontroll på nytt'; + @override String get spellingCheckIncomplete => 'Stavekontrollen er ufullstendig'; diff --git a/lib/l10n/generated/app_localizations_nl.dart b/lib/l10n/generated/app_localizations_nl.dart index 71d37bfe..cebd0b2f 100644 --- a/lib/l10n/generated/app_localizations_nl.dart +++ b/lib/l10n/generated/app_localizations_nl.dart @@ -4625,6 +4625,14 @@ class AppLocalizationsNl extends AppLocalizations { @override String get spellingCheckFailed => 'Spellingcontrole mislukt'; + @override + String get spellingSettingsLoadFailed => + 'Instellingen voor spellingcontrole konden niet worden geladen'; + + @override + String get retrySpellingSettings => + 'Instellingen voor spellingcontrole opnieuw laden'; + @override String get spellingCheckIncomplete => 'De spellingcontrole is onvolledig'; diff --git a/lib/l10n/generated/app_localizations_pl.dart b/lib/l10n/generated/app_localizations_pl.dart index 24a9c53d..befe28f5 100644 --- a/lib/l10n/generated/app_localizations_pl.dart +++ b/lib/l10n/generated/app_localizations_pl.dart @@ -4634,6 +4634,14 @@ class AppLocalizationsPl extends AppLocalizations { @override String get spellingCheckFailed => 'Sprawdzanie pisowni nie powiodło się'; + @override + String get spellingSettingsLoadFailed => + 'Nie można wczytać ustawień sprawdzania pisowni'; + + @override + String get retrySpellingSettings => + 'Ponów wczytywanie ustawień sprawdzania pisowni'; + @override String get spellingCheckIncomplete => 'Sprawdzanie pisowni jest niepełne'; diff --git a/lib/l10n/generated/app_localizations_pt.dart b/lib/l10n/generated/app_localizations_pt.dart index 6df76e1d..d7e72774 100644 --- a/lib/l10n/generated/app_localizations_pt.dart +++ b/lib/l10n/generated/app_localizations_pt.dart @@ -4638,6 +4638,14 @@ class AppLocalizationsPt extends AppLocalizations { @override String get spellingCheckFailed => 'A verificação ortográfica falhou'; + @override + String get spellingSettingsLoadFailed => + 'Não foi possível carregar as definições de verificação ortográfica'; + + @override + String get retrySpellingSettings => + 'Tentar carregar novamente as definições de verificação ortográfica'; + @override String get spellingCheckIncomplete => 'A verificação ortográfica está incompleta'; @@ -9336,6 +9344,14 @@ class AppLocalizationsPtBr extends AppLocalizationsPt { @override String get spellingCheckFailed => 'A verificação ortográfica falhou'; + @override + String get spellingSettingsLoadFailed => + 'Não foi possível carregar as configurações de verificação ortográfica'; + + @override + String get retrySpellingSettings => + 'Tentar carregar novamente as configurações de verificação ortográfica'; + @override String get spellingCheckIncomplete => 'A verificação ortográfica está incompleta'; diff --git a/lib/l10n/generated/app_localizations_ru.dart b/lib/l10n/generated/app_localizations_ru.dart index c5313195..ba08a1e3 100644 --- a/lib/l10n/generated/app_localizations_ru.dart +++ b/lib/l10n/generated/app_localizations_ru.dart @@ -4638,6 +4638,14 @@ class AppLocalizationsRu extends AppLocalizations { @override String get spellingCheckFailed => 'Не удалось проверить правописание'; + @override + String get spellingSettingsLoadFailed => + 'Не удалось загрузить настройки проверки орфографии'; + + @override + String get retrySpellingSettings => + 'Повторить загрузку настроек проверки орфографии'; + @override String get spellingCheckIncomplete => 'Проверка правописания не завершена'; diff --git a/lib/l10n/generated/app_localizations_tr.dart b/lib/l10n/generated/app_localizations_tr.dart index 911544eb..5cd6d4a9 100644 --- a/lib/l10n/generated/app_localizations_tr.dart +++ b/lib/l10n/generated/app_localizations_tr.dart @@ -4582,6 +4582,13 @@ class AppLocalizationsTr extends AppLocalizations { @override String get spellingCheckFailed => 'Yazım denetimi başarısız oldu'; + @override + String get spellingSettingsLoadFailed => + 'Yazım denetimi ayarları yüklenemedi'; + + @override + String get retrySpellingSettings => 'Yazım denetimi ayarlarını yeniden yükle'; + @override String get spellingCheckIncomplete => 'Yazım denetimi tamamlanmadı'; diff --git a/lib/l10n/generated/app_localizations_uk.dart b/lib/l10n/generated/app_localizations_uk.dart index fbf67d6f..8df90256 100644 --- a/lib/l10n/generated/app_localizations_uk.dart +++ b/lib/l10n/generated/app_localizations_uk.dart @@ -4645,6 +4645,14 @@ class AppLocalizationsUk extends AppLocalizations { @override String get spellingCheckFailed => 'Не вдалося перевірити правопис'; + @override + String get spellingSettingsLoadFailed => + 'Не вдалося завантажити налаштування перевірки правопису'; + + @override + String get retrySpellingSettings => + 'Повторити завантаження налаштувань перевірки правопису'; + @override String get spellingCheckIncomplete => 'Перевірку правопису не завершено'; diff --git a/lib/l10n/generated/app_localizations_vi.dart b/lib/l10n/generated/app_localizations_vi.dart index c302818b..d01dad95 100644 --- a/lib/l10n/generated/app_localizations_vi.dart +++ b/lib/l10n/generated/app_localizations_vi.dart @@ -4573,6 +4573,13 @@ class AppLocalizationsVi extends AppLocalizations { @override String get spellingCheckFailed => 'Kiểm tra chính tả thất bại'; + @override + String get spellingSettingsLoadFailed => + 'Không thể tải cài đặt kiểm tra chính tả'; + + @override + String get retrySpellingSettings => 'Thử tải lại cài đặt kiểm tra chính tả'; + @override String get spellingCheckIncomplete => 'Kiểm tra chính tả chưa hoàn tất'; diff --git a/lib/l10n/generated/app_localizations_zh.dart b/lib/l10n/generated/app_localizations_zh.dart index aabe1633..1a47f2cb 100644 --- a/lib/l10n/generated/app_localizations_zh.dart +++ b/lib/l10n/generated/app_localizations_zh.dart @@ -4381,6 +4381,12 @@ class AppLocalizationsZh extends AppLocalizations { @override String get spellingCheckFailed => '拼写检查失败'; + @override + String get spellingSettingsLoadFailed => '无法加载拼写检查设置'; + + @override + String get retrySpellingSettings => '重试加载拼写检查设置'; + @override String get spellingCheckIncomplete => '拼写检查未完成'; @@ -8818,6 +8824,12 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get spellingCheckFailed => '拼写检查失败'; + @override + String get spellingSettingsLoadFailed => '无法加载拼写检查设置'; + + @override + String get retrySpellingSettings => '重试加载拼写检查设置'; + @override String get spellingCheckIncomplete => '拼写检查未完成'; diff --git a/lib/src/editor/wysiwyg/wysiwyg_document_controller.dart b/lib/src/editor/wysiwyg/wysiwyg_document_controller.dart index ac6d9f3b..9d4b08c1 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_document_controller.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_document_controller.dart @@ -563,6 +563,7 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { required String expectedFieldText, required SpellingReplacementPlan plan, String? preparedFieldText, + String? preparedSource, }) { final current = blockById(blockId); if (current == null || @@ -570,6 +571,13 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { busyMarkWysiwygEditableText(current) != expectedFieldText) { return false; } + final sourceResult = _replaceSpellingUsingSource( + blockId: blockId, + current: current, + plan: plan, + preparedSource: preparedSource, + ); + if (sourceResult != null) return sourceResult; if (busyMarkWysiwygBlockContainsMath(current)) { late final String updatedSource; if (preparedFieldText != null) { @@ -609,6 +617,7 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { required String expectedFieldText, required SpellingReplacementPlan plan, String? preparedFieldText, + String? preparedSource, }) { final table = blockById(tableBlockId); final current = blockById(cellId); @@ -618,6 +627,13 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { busyMarkWysiwygEditableText(current) != expectedFieldText) { return false; } + final sourceResult = _replaceSpellingUsingSource( + blockId: cellId, + current: current, + plan: plan, + preparedSource: preparedSource, + ); + if (sourceResult != null) return sourceResult; if (busyMarkWysiwygBlockContainsMath(current)) { late final String updatedSource; if (preparedFieldText != null) { @@ -664,6 +680,63 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { return true; } + bool? _replaceSpellingUsingSource({ + required String blockId, + required BusyBlock current, + required SpellingReplacementPlan plan, + required String? preparedSource, + }) { + final source = _document.source; + if (source == null || source != markdown) return null; + if (plan.sourceEdits.isEmpty) return false; + try { + final replacement = plan.applyToSource(source); + if (preparedSource != null && preparedSource != replacement) return false; + late final String expectedField; + if (busyMarkWysiwygBlockContainsMath(current)) { + // Math fields expose Markdown source as their editable field. Their + // spelling atoms have field offsets, not inline-leaf paths. + expectedField = plan.applyToField(busyMarkWysiwygEditableText(current)); + } else { + final updatedInlines = _applySpellingLeafEdits( + current.inlines, + plan.richLeafEdits, + ); + if (updatedInlines == null) return false; + expectedField = busyMarkWysiwygEditableText( + current.copyWith(inlines: updatedInlines), + ); + } + final parsed = const MarkdownParser() + .parse( + filePath: _document.filePath, + source: replacement, + mode: _document.mode, + validateLocalReferences: false, + ) + .busyDocument; + BusyBlock? find(Iterable blocks) { + for (final block in blocks) { + if (block.id == blockId) return block; + final child = find(block.children); + if (child != null) return child; + } + return null; + } + + final updated = find(parsed.blocks); + if (updated == null || + busyMarkWysiwygEditableText(updated) != expectedField || + _serializer.serialize(parsed) != replacement) { + return false; + } + replaceDocument(parsed); + return true; + } on Object { + return false; + } + } + /// A table cell accepts inline formatting; block boundaries become spaces. int? insertStyledInlinesInTableCell({ required String tableBlockId, diff --git a/lib/src/editor/wysiwyg/wysiwyg_editor.dart b/lib/src/editor/wysiwyg/wysiwyg_editor.dart index ad3783e6..1f555946 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_editor.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_editor.dart @@ -372,12 +372,26 @@ class BusyMarkWysiwygEditorState extends State { return expectedFieldText; } + String? spellingSourceSnapshot(SpellingOccurrence occurrence) { + if (spellingFieldSnapshot(occurrence) == null) return null; + final source = _documentController.document.source; + if (source == null || source != _documentController.markdown) return null; + final start = occurrence.sourceStart; + final end = occurrence.sourceEnd; + if (start == null || end == null || start < 0 || end > source.length) { + return null; + } + return source; + } + bool applyPreparedSpellingCorrection({ required SpellingOccurrence occurrence, required String suggestion, required SpellingReplacementPlan plan, required String expectedFieldText, String? preparedFieldText, + String? expectedSource, + String? preparedSource, }) { if (!_isSpellingOccurrenceCurrent(occurrence)) return false; final target = occurrence.run.target; @@ -406,9 +420,15 @@ class BusyMarkWysiwygEditorState extends State { controller.text != expectedFieldText) { return false; } + if (expectedSource != null && + (expectedSource != _documentController.markdown || + _documentController.document.source != expectedSource || + preparedSource == null)) { + return false; + } final beforeSession = _captureSessionState(); _continuousTextEdit = null; - _recordUndoSnapshot(); + final beforeDocument = _historySnapshot(); final changed = switch (target) { SpellingRichBlockTarget(:final blockId) => _documentController.replaceSpellingInBlock( @@ -416,6 +436,7 @@ class BusyMarkWysiwygEditorState extends State { expectedFieldText: expectedFieldText, plan: plan, preparedFieldText: preparedFieldText, + preparedSource: preparedSource, ), SpellingRichTableCellTarget(:final tableBlockId, :final cellId) => _documentController.replaceSpellingInTableCell( @@ -424,10 +445,12 @@ class BusyMarkWysiwygEditorState extends State { expectedFieldText: expectedFieldText, plan: plan, preparedFieldText: preparedFieldText, + preparedSource: preparedSource, ), _ => false, }; if (!changed) return false; + _recordUndoSnapshot(beforeDocument); _clearBlockSelection(); if (target is SpellingRichTableCellTarget) { diff --git a/lib/src/editor/wysiwyg/wysiwyg_inline_controller.dart b/lib/src/editor/wysiwyg/wysiwyg_inline_controller.dart index 0e91fbeb..af937eee 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_inline_controller.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_inline_controller.dart @@ -3,6 +3,8 @@ import 'package:flutter/material.dart'; import '../../app/busymark_design.dart'; import '../../markdown/busymark_document.dart'; import '../../markdown/busymark_markdown_serializer.dart'; +import '../../markdown/markdown_model.dart'; +import '../../markdown/markdown_source_map.dart'; bool busyMarkWysiwygBlockContainsMath(BusyBlock block) { bool contains(List inlines) => inlines.any( @@ -24,6 +26,15 @@ String busyMarkWysiwygEditableText(BusyBlock block) { return block.plainText; } if (block.kind != BusyBlockKind.math && block.inlines.isNotEmpty) { + final raw = block.rawSource; + if (!block.dirty && + block.sourceSpan != null && + raw != null && + _containsImage(block.inlines)) { + if (_authoredMathFieldSource(raw) case final authored?) { + return authored; + } + } return const BusyMarkMarkdownSerializer().serializeBlock( BusyBlock( id: 'wysiwyg-inline-source', @@ -39,6 +50,59 @@ String busyMarkWysiwygEditableText(BusyBlock block) { return source.replaceFirst(RegExp(r'(?:\r\n|\r|\n)$'), ''); } +bool _containsImage(List inlines) => inlines.any( + (inline) => + inline.kind == BusyInlineKind.image || _containsImage(inline.children), +); + +String? _authoredMathFieldSource(String raw) { + final mapped = const MarkdownSourceMapper() + .createInlineParserContext( + documentSource: raw, + mode: MarkdownMode.commonMark, + ) + .parsePositionedBlocks(raw); + if (mapped.length != 1 || !mapped.single.positionRecordsComplete) { + return null; + } + final content = mapped.single; + final start = content.sourceStart; + final end = content.sourceEnd; + if (start == null || + end == null || + start < 0 || + end <= start || + end > raw.length) { + return null; + } + final breaks = [...content.positionedLineBreaks] + ..sort( + (left, right) => + (left.sourceOffset ?? -1).compareTo(right.sourceOffset ?? -1), + ); + final field = StringBuffer(); + var cursor = start; + for (final lineBreak in breaks) { + final offset = lineBreak.sourceOffset; + if (offset == null || offset < cursor || offset >= end) continue; + final prefixEnd = + offset + + lineBreak.lineEnding.length + + lineBreak.continuationPrefix.length; + if (prefixEnd > end || + !raw.startsWith( + '${lineBreak.lineEnding}${lineBreak.continuationPrefix}', + offset, + )) { + return null; + } + field.write(raw.substring(cursor, offset + lineBreak.lineEnding.length)); + cursor = prefixEnd; + } + field.write(raw.substring(cursor, end)); + return field.toString(); +} + /// Maps an inline Markdown source boundary back to the block's semantic text /// coordinate. Math-bearing fields use source coordinates while the document /// insertion APIs and ordinary fields use semantic coordinates. diff --git a/lib/src/markdown/markdown_ast_adapter.dart b/lib/src/markdown/markdown_ast_adapter.dart index 219e98ae..0c01776a 100644 --- a/lib/src/markdown/markdown_ast_adapter.dart +++ b/lib/src/markdown/markdown_ast_adapter.dart @@ -857,6 +857,7 @@ class MarkdownAstAdapter { opening: range.opening, closing: range.closing, isSourceLineBreak: entry.key.kind == BusyInlineKind.hardBreak, + isRawHtmlText: true, sourceLineBreakOffset: range.sourceLineBreakOffset == null ? null : mappingStart + range.sourceLineBreakOffset!, @@ -897,6 +898,12 @@ class MarkdownAstAdapter { final labelEnd = int.tryParse( node.attributes[busyMarkSourceMappingLabelEndAttribute] ?? '', ); + final titleStart = int.tryParse( + node.attributes[busyMarkSourceMappingTitleStartAttribute] ?? '', + ); + final titleEnd = int.tryParse( + node.attributes[busyMarkSourceMappingTitleEndAttribute] ?? '', + ); final opening = node.attributes[busyMarkSourceMappingOpeningAttribute]; final closing = node.attributes[busyMarkSourceMappingClosingAttribute]; final childStart = @@ -971,7 +978,11 @@ class MarkdownAstAdapter { kind: BusyInlineKind.image, text: node.attributes['alt'] ?? '', destination: node.attributes['src'], - attributes: node.attributes, + attributes: { + for (final entry in node.attributes.entries) + if (!entry.key.startsWith('data-busymark-source-')) + entry.key: entry.value, + }, ), ], // Source mappings are keyed by inline identity. Each parser occurrence @@ -1004,6 +1015,10 @@ class MarkdownAstAdapter { closing: node.attributes[busyMarkSourceMappingClosingAttribute], labelStart: labelStart, labelEnd: labelEnd, + titleStart: titleStart, + titleEnd: titleEnd, + titleDelimiter: + node.attributes[busyMarkSourceMappingTitleDelimiterAttribute], isAutolink: node.attributes[busyMarkSourceMappingAutolinkAttribute] == 'true', isReference: diff --git a/lib/src/markdown/markdown_source_annotation.dart b/lib/src/markdown/markdown_source_annotation.dart index 38818ae7..137e6f5c 100644 --- a/lib/src/markdown/markdown_source_annotation.dart +++ b/lib/src/markdown/markdown_source_annotation.dart @@ -7,6 +7,11 @@ const busyMarkSourceMappingClosingAttribute = 'data-busymark-source-closing'; const busyMarkSourceMappingLabelStartAttribute = 'data-busymark-source-label-start'; const busyMarkSourceMappingLabelEndAttribute = 'data-busymark-source-label-end'; +const busyMarkSourceMappingTitleStartAttribute = + 'data-busymark-source-title-start'; +const busyMarkSourceMappingTitleEndAttribute = 'data-busymark-source-title-end'; +const busyMarkSourceMappingTitleDelimiterAttribute = + 'data-busymark-source-title-delimiter'; const busyMarkSourceMappingAutolinkAttribute = 'data-busymark-source-autolink'; const busyMarkSourceMappingReferenceAttribute = 'data-busymark-source-reference'; @@ -23,11 +28,15 @@ class BusyMarkMappedInlineRange { this.closing, this.labelStart, this.labelEnd, + this.titleStart, + this.titleEnd, + this.titleDelimiter, this.lineBreaks = const [], this.originalInline, this.isAutolink = false, this.isReference = false, this.isSourceLineBreak = false, + this.isRawHtmlText = false, this.sourceLineBreakOffset, }); @@ -37,11 +46,17 @@ class BusyMarkMappedInlineRange { final String? closing; final int? labelStart; final int? labelEnd; + final int? titleStart; + final int? titleEnd; + final String? titleDelimiter; final List lineBreaks; final BusyInline? originalInline; final bool isAutolink; final bool isReference; final bool isSourceLineBreak; + + /// This inline came from Markdown's literal raw-HTML text node. + final bool isRawHtmlText; final int? sourceLineBreakOffset; } @@ -63,14 +78,24 @@ class BusyMarkMappedInlineParse { BusyMarkMappedInlineParse({ required this.inlines, required Map ranges, + this.positionedLineBreaks = const [], this.positionRecordsComplete = true, + this.imageDescriptionText, this.sourceStart, this.sourceEnd, }) : ranges = Map.unmodifiable(ranges); final List inlines; final Map ranges; + + /// Raw line boundaries and container continuations of a positioned block. + /// [BusyMarkMappedInlineRange.lineBreaks] remains relative to inline text. + final List positionedLineBreaks; final bool positionRecordsComplete; + + /// Text the Markdown image syntax would assign to an alt attribute when + /// these parsed nodes are used as an image description. + final String? imageDescriptionText; final int? sourceStart; final int? sourceEnd; } diff --git a/lib/src/markdown/markdown_source_map.dart b/lib/src/markdown/markdown_source_map.dart index ebbc6ffc..3a27abd8 100644 --- a/lib/src/markdown/markdown_source_map.dart +++ b/lib/src/markdown/markdown_source_map.dart @@ -18,6 +18,9 @@ void _setSourceMappingAttributes( String? closing, int? labelStart, int? labelEnd, + int? titleStart, + int? titleEnd, + String? titleDelimiter, bool isAutolink = false, bool isReference = false, bool isSourceLineBreak = false, @@ -38,6 +41,13 @@ void _setSourceMappingAttributes( if (labelEnd != null) { element.attributes[busyMarkSourceMappingLabelEndAttribute] = '$labelEnd'; } + if (titleStart != null && titleEnd != null && titleDelimiter != null) { + element.attributes[busyMarkSourceMappingTitleStartAttribute] = + '$titleStart'; + element.attributes[busyMarkSourceMappingTitleEndAttribute] = '$titleEnd'; + element.attributes[busyMarkSourceMappingTitleDelimiterAttribute] = + titleDelimiter; + } if (isAutolink) { element.attributes[busyMarkSourceMappingAutolinkAttribute] = 'true'; } @@ -53,6 +63,51 @@ void _setSourceMappingAttributes( } } +({int start, int end, String delimiter})? _mappedInlineTitle( + String source, + int labelEnd, + int occurrenceEnd, +) { + if (labelEnd + 1 >= occurrenceEnd || source[labelEnd + 1] != '(') { + return null; + } + final insideStart = labelEnd + 2; + var closing = occurrenceEnd - 2; + bool whitespace(int offset) => + source.codeUnitAt(offset) == 0x20 || + source.codeUnitAt(offset) == 0x09 || + source.codeUnitAt(offset) == 0x0a || + source.codeUnitAt(offset) == 0x0d || + source.codeUnitAt(offset) == 0x0c; + while (closing >= insideStart && whitespace(closing)) { + closing--; + } + if (closing < insideStart) return null; + final endDelimiter = source[closing]; + if (endDelimiter != '"' && endDelimiter != "'" && endDelimiter != ')') { + return null; + } + final openingDelimiter = endDelimiter == ')' ? '(' : endDelimiter; + bool escaped(int offset) { + var backslashes = 0; + for ( + var index = offset - 1; + index >= insideStart && source.codeUnitAt(index) == 0x5c; + index-- + ) { + backslashes++; + } + return backslashes.isOdd; + } + + for (var opening = closing - 1; opening >= insideStart; opening--) { + if (source[opening] != openingDelimiter || escaped(opening)) continue; + if (opening == insideStart || !whitespace(opening - 1)) continue; + return (start: opening + 1, end: closing, delimiter: openingDelimiter); + } + return null; +} + class _SourceMappingDelimiterPosition { _SourceMappingDelimiterPosition(this.start, this.end); @@ -184,12 +239,61 @@ class _SourceMappingLinkSyntax extends md.LinkSyntax { parser.source.codeUnitAt(labelEnd + 1) != 0x28; for (final node in nodes.whereType()) { if (node.tag != 'a') continue; + final title = isReference || !node.attributes.containsKey('title') + ? null + : _mappedInlineTitle(parser.source, labelEnd, parser.pos + 1); _setSourceMappingAttributes( node, start: opener.endPos - 1, end: parser.pos + 1, labelStart: opener.endPos, labelEnd: labelEnd, + titleStart: title?.start, + titleEnd: title?.end, + titleDelimiter: title?.delimiter, + isReference: isReference, + ); + } + return nodes; + } +} + +class _SourceMappingImageSyntax extends md.ImageSyntax { + @override + Iterable? close( + md.InlineParser parser, + covariant md.SimpleDelimiter opener, + md.Delimiter? closer, { + String? tag, + required List Function() getChildren, + }) { + final labelEnd = parser.pos; + final result = super.close( + parser, + opener, + closer, + tag: tag, + getChildren: getChildren, + ); + if (result == null) return null; + final nodes = result.toList(growable: false); + final isReference = + labelEnd + 1 >= parser.source.length || + parser.source.codeUnitAt(labelEnd + 1) != 0x28; + for (final node in nodes.whereType()) { + if (node.tag != 'img') continue; + final title = isReference || !node.attributes.containsKey('title') + ? null + : _mappedInlineTitle(parser.source, labelEnd, parser.pos + 1); + _setSourceMappingAttributes( + node, + start: opener.endPos - 2, + end: parser.pos + 1, + labelStart: opener.endPos, + labelEnd: labelEnd, + titleStart: title?.start, + titleEnd: title?.end, + titleDelimiter: title?.delimiter, isReference: isReference, ); } @@ -318,6 +422,7 @@ List _sourceMappingInlineSyntaxes( _SourceMappingCodeSyntax(), emailSyntax, _SourceMappingAutolinkSyntax(), + _SourceMappingImageSyntax(), _SourceMappingLinkSyntax(), _SourceMappingDelimiterSyntax.asterisk(), _SourceMappingDelimiterSyntax.underscore(), @@ -590,8 +695,15 @@ class BusyMarkInlineParserContext { _addReferenceLabelMarkerVariants(_mappingDocument, source, markers); parseInvocations += 1; final ranges = Map.identity(); + final nodes = _mappingDocument.parseInline(source); + final imageDescriptionText = nodes.map((node) { + if (node is md.Element && node.tag == 'img') { + return node.attributes['alt'] ?? ''; + } + return node.textContent; + }).join(); final inlines = const MarkdownAstAdapter().convertInlineNodes( - _mappingDocument.parseInline(source), + nodes, sourceMappings: ranges, mappingSource: source, ignoredPositionMarkers: markers, @@ -600,6 +712,7 @@ class BusyMarkInlineParserContext { return BusyMarkMappedInlineParse( inlines: inlines, ranges: ranges, + imageDescriptionText: imageDescriptionText, sourceStart: 0, sourceEnd: source.length, ); @@ -650,11 +763,15 @@ class BusyMarkInlineParserContext { closing: mappedRange.closing, labelStart: mappedRange.labelStart, labelEnd: mappedRange.labelEnd, + titleStart: mappedRange.titleStart, + titleEnd: mappedRange.titleEnd, + titleDelimiter: mappedRange.titleDelimiter, lineBreaks: mappedRange.lineBreaks, originalInline: semantic, isAutolink: mappedRange.isAutolink, isReference: mappedRange.isReference, isSourceLineBreak: mappedRange.isSourceLineBreak, + isRawHtmlText: mappedRange.isRawHtmlText, sourceLineBreakOffset: mappedRange.sourceLineBreakOffset, ); } @@ -678,17 +795,22 @@ class BusyMarkInlineParserContext { closing: range.closing, labelStart: range.labelStart, labelEnd: range.labelEnd, + titleStart: range.titleStart, + titleEnd: range.titleEnd, + titleDelimiter: range.titleDelimiter, lineBreaks: range.lineBreaks, originalInline: originalSemantics[entry.key], isAutolink: range.isAutolink, isReference: range.isReference, isSourceLineBreak: range.isSourceLineBreak, + isRawHtmlText: range.isRawHtmlText, sourceLineBreakOffset: range.sourceLineBreakOffset, ); } return BusyMarkMappedInlineParse( inlines: original.inlines, ranges: originalRangesWithSemantics, + imageDescriptionText: original.imageDescriptionText, positionRecordsComplete: false, sourceStart: 0, sourceEnd: source.length, @@ -697,6 +819,7 @@ class BusyMarkInlineParserContext { return BusyMarkMappedInlineParse( inlines: reconciled, ranges: reconciledRanges, + imageDescriptionText: imageDescriptionText, sourceStart: 0, sourceEnd: source.length, ); @@ -857,11 +980,19 @@ class BusyMarkInlineParserContext { labelEnd: range.labelEnd == null ? null : projection.rawStartFor(range.labelEnd!), + titleStart: range.titleStart == null + ? null + : projection.rawStartFor(range.titleStart!), + titleEnd: range.titleEnd == null + ? null + : projection.rawStartFor(range.titleEnd!), + titleDelimiter: range.titleDelimiter, lineBreaks: lineBreaks, originalInline: range.originalInline, isAutolink: range.isAutolink, isReference: range.isReference, isSourceLineBreak: range.isSourceLineBreak, + isRawHtmlText: range.isRawHtmlText, sourceLineBreakOffset: range.sourceLineBreakOffset == null ? null : projection.rawStartFor(range.sourceLineBreakOffset!), @@ -870,6 +1001,10 @@ class BusyMarkInlineParserContext { return BusyMarkMappedInlineParse( inlines: mapped.inlines, ranges: ranges, + imageDescriptionText: mapped.imageDescriptionText, + positionedLineBreaks: List.unmodifiable( + projection._lineBreaksByLogicalOffset.values, + ), positionRecordsComplete: mapped.positionRecordsComplete, sourceStart: projection.rawStart, sourceEnd: projection.rawEnd, diff --git a/lib/src/spellcheck/markdown_spelling_projection.dart b/lib/src/spellcheck/markdown_spelling_projection.dart index 7171935d..6d9424d9 100644 --- a/lib/src/spellcheck/markdown_spelling_projection.dart +++ b/lib/src/spellcheck/markdown_spelling_projection.dart @@ -48,7 +48,31 @@ final class MarkdownSpellingProjector { block.sourceSpan != null) block.sourceSpan!, ]; - final fencedCodeSpans = _fencedCodeSpans(source); + final fencedCodeSpans = _fencedCodeSpans( + source, + parsed.busyDocument.blocks, + ); + final recognizedAttributeSpans = <_SourceInterval>[]; + for (final block in _walkBlocks(parsed.busyDocument.blocks)) { + final span = block.sourceSpan; + if (block.kind != BusyBlockKind.heading || span == null) continue; + final raw = source + .substring(span.startOffset, span.endOffset) + .trimRight(); + final trailing = RegExp(r'[ \t]+\{[^{}]*\}$').firstMatch(raw); + if (trailing != null && + _isRecognizedHeadingAttribute( + raw.substring(trailing.start).trim(), + block.attributes, + )) { + recognizedAttributeSpans.add( + _SourceInterval( + span.startOffset + trailing.start, + span.startOffset + trailing.end, + ), + ); + } + } final footnoteLabels = {}; void collectFootnotes(BusyInline inline) { final destination = inline.destination; @@ -77,6 +101,7 @@ final class MarkdownSpellingProjector { block, ]; final runs = []; + final imageDescriptions = []; final multilineHtmlSpans = _multilineHtmlSpans(source, 0, source.length); var complete = true; var sequence = 0; @@ -91,11 +116,98 @@ final class MarkdownSpellingProjector { required SpellingSourceContext context, }) { final multilineHtml = multilineHtmlSpans; - final formattingSyntax = _formattingSyntaxSpans([ - mapped, - ], sourceBase: sourceBase); + final imageLabels = + < + ({ + BusyMarkMappedInlineParse mapped, + int sourceBase, + List<_MappedImageCode> codes, + }) + >[]; + final collectedImages = {}; + void collectImageLabels(BusyMarkMappedInlineParse parent, int base) { + for (final entry in parent.ranges.entries) { + if (entry.key.kind != BusyInlineKind.image || + entry.value.labelStart == null || + entry.value.labelEnd == null) { + continue; + } + final labelStart = base + entry.value.labelStart!; + final labelEnd = base + entry.value.labelEnd!; + final reparsed = _parseMappedImageLabel( + inlineParser, + source.substring(labelStart, labelEnd), + sourceBase: labelStart, + positionedBreaks: [ + for (final lineBreak in parent.positionedLineBreaks) + if (lineBreak.sourceOffset case final offset?) + if (base + offset >= labelStart && + base + + offset + + lineBreak.lineEnding.length + + lineBreak.continuationPrefix.length <= + labelEnd) + BusyMarkMappedSourceLineBreak( + textOffset: lineBreak.textOffset, + lineEnding: lineBreak.lineEnding, + continuationPrefix: lineBreak.continuationPrefix, + sourceOffset: base + offset - labelStart, + ), + ], + ); + if (!reparsed.mapped.positionRecordsComplete) complete = false; + if (reparsed.mapped.imageDescriptionText != entry.key.plainText) { + complete = false; + } + if (collectedImages.add(base + entry.value.start)) { + final semanticInlines = _imageFieldInlines( + reparsed.mapped, + source.substring(labelStart, labelEnd), + ); + if (semanticInlines.map((inline) => inline.plainText).join() != + entry.key.plainText) { + complete = false; + } else { + imageDescriptions.add( + SpellingMappedImageDescription( + sourceStart: base + entry.value.start, + sourceEnd: base + entry.value.end, + alternativeText: entry.key.plainText, + destination: entry.key.destination, + inlines: semanticInlines, + ), + ); + } + } + final label = ( + mapped: reparsed.mapped, + sourceBase: labelStart, + codes: reparsed.codes, + ); + imageLabels.add(label); + collectImageLabels(label.mapped, label.sourceBase); + } + } + + collectImageLabels(mapped, sourceBase); + final formattingSyntax = <_SourceInterval>[ + ..._formattingSyntaxSpans([mapped], sourceBase: sourceBase), + for (final label in imageLabels) + ..._formattingSyntaxSpans([ + label.mapped, + ], sourceBase: label.sourceBase), + ]..sort((left, right) => left.start.compareTo(right.start)); final opaqueSyntax = <_SourceInterval>[ ..._opaqueSyntaxSpans([mapped], sourceBase: sourceBase), + for (final label in imageLabels) + ..._opaqueSyntaxSpans( + [label.mapped], + sourceBase: label.sourceBase, + excludedKinds: { + BusyInlineKind.math, + BusyInlineKind.writersideVariable, + }, + ), for (final span in excludedBlockSpans) if (span.startOffset < sourceLimit && span.endOffset > sourceBase) _SourceInterval(span.startOffset, span.endOffset), @@ -112,6 +224,49 @@ final class MarkdownSpellingProjector { for (final html in multilineHtml) _SourceInterval(html.opaqueStart, html.opaqueEnd), ]..sort((left, right) => left.start.compareTo(right.start)); + final opaqueSemanticBreaks = { + for (final entry in mapped.ranges.entries) + if (_opaqueInlineKinds.contains(entry.key.kind)) + sourceBase + entry.value.start: '\n' + .allMatches(entry.key.plainText) + .length, + for (final label in imageLabels) + for (final entry in label.mapped.ranges.entries) + if (entry.key.kind == BusyInlineKind.math || + entry.key.kind == BusyInlineKind.writersideVariable) + label.sourceBase + entry.value.start: '\n' + .allMatches(entry.key.plainText) + .length, + }; + final imageCodeSyntax = <_MappedImageCode>[ + for (final label in imageLabels) ...label.codes, + ]; + final positionedLineBreaks = [ + for (final lineBreak in mapped.positionedLineBreaks) + if (lineBreak.sourceOffset != null) + BusyMarkMappedSourceLineBreak( + textOffset: lineBreak.textOffset, + lineEnding: lineBreak.lineEnding, + continuationPrefix: lineBreak.continuationPrefix, + sourceOffset: sourceBase + lineBreak.sourceOffset!, + ), + ]; + final hardBreakSyntax = <_SourceInterval>[ + for (final entry in mapped.ranges.entries) + if (entry.key.kind == BusyInlineKind.hardBreak) + _SourceInterval( + sourceBase + entry.value.start, + sourceBase + entry.value.end, + ), + for (final label in imageLabels) + for (final entry in label.mapped.ranges.entries) + if (entry.key.kind == BusyInlineKind.hardBreak && + !entry.value.isRawHtmlText) + _SourceInterval( + label.sourceBase + entry.value.start, + label.sourceBase + entry.value.end, + ), + ]; final scanner = _MarkdownProseScanner( source: source, start: mappedStart, @@ -120,10 +275,61 @@ final class MarkdownSpellingProjector { stripBlockSyntax: stripBlockSyntax, footnoteLabels: footnoteLabels, formattingSyntax: formattingSyntax, - formattingWrappers: _formattingWrappers([ - mapped, - ], sourceBase: sourceBase), + formattingWrappers: [ + ..._formattingWrappers([mapped], sourceBase: sourceBase), + for (final label in imageLabels) + ..._formattingWrappers([ + label.mapped, + ], sourceBase: label.sourceBase), + ], opaqueSyntax: opaqueSyntax, + opaqueSemanticBreaks: opaqueSemanticBreaks, + imageCodeSyntax: imageCodeSyntax, + positionedLineBreaks: positionedLineBreaks, + hardBreakSyntax: hardBreakSyntax, + recognizedLinks: { + for (final entry in mapped.ranges.entries) + if (entry.key.kind == BusyInlineKind.link || + entry.key.kind == BusyInlineKind.image) + if (entry.value.labelStart != null && + entry.value.labelEnd != null) + sourceBase + entry.value.start: _RecognizedLinkOccurrence( + isImage: entry.key.kind == BusyInlineKind.image, + semanticLabelText: entry.key.plainText, + end: sourceBase + entry.value.end, + labelStart: sourceBase + entry.value.labelStart!, + labelEnd: sourceBase + entry.value.labelEnd!, + titleStart: entry.value.titleStart == null + ? null + : sourceBase + entry.value.titleStart!, + titleEnd: entry.value.titleEnd == null + ? null + : sourceBase + entry.value.titleEnd!, + titleDelimiter: entry.value.titleDelimiter, + ), + for (final label in imageLabels) + for (final entry in label.mapped.ranges.entries) + if (entry.key.kind == BusyInlineKind.link || + entry.key.kind == BusyInlineKind.image) + if (entry.value.labelStart != null && + entry.value.labelEnd != null) + label.sourceBase + + entry.value.start: _RecognizedLinkOccurrence( + isImage: entry.key.kind == BusyInlineKind.image, + semanticLabelText: entry.key.plainText, + end: label.sourceBase + entry.value.end, + labelStart: label.sourceBase + entry.value.labelStart!, + labelEnd: label.sourceBase + entry.value.labelEnd!, + titleStart: entry.value.titleStart == null + ? null + : label.sourceBase + entry.value.titleStart!, + titleEnd: entry.value.titleEnd == null + ? null + : label.sourceBase + entry.value.titleEnd!, + titleDelimiter: entry.value.titleDelimiter, + ), + }, + recognizedAttributeSpans: recognizedAttributeSpans, ); final groups = <_EmissionGroup>[ @@ -137,6 +343,7 @@ final class MarkdownSpellingProjector { end: attribute.end, context: attribute.context, stripBlockSyntax: false, + interpretHtmlSyntax: false, ).scan(), ]..sort((left, right) { final leftStart = left.atoms.firstOrNull?.sourceStart ?? 0; @@ -293,10 +500,42 @@ final class MarkdownSpellingProjector { runs: List.unmodifiable(runs), complete: complete, message: complete ? null : 'Some Markdown regions could not be mapped.', + imageDescriptions: List.unmodifiable( + imageDescriptions..sort( + (left, right) => left.sourceStart.compareTo(right.sourceStart), + ), + ), ); } } +List _imageFieldInlines( + BusyMarkMappedInlineParse parsed, + String raw, +) { + BusyInline convert(BusyInline inline) { + final range = parsed.ranges[inline]; + if (range?.isRawHtmlText ?? false) { + return BusyInline( + kind: BusyInlineKind.text, + text: raw.substring(range!.start, range.end), + ); + } + if (inline.kind == BusyInlineKind.hardBreak) { + return const BusyInline(kind: BusyInlineKind.text, text: ''); + } + if (inline.kind == BusyInlineKind.code) return inline; + if (inline.children.isEmpty) return inline; + return inline.copyWith( + children: [for (final child in inline.children) convert(child)], + ); + } + + return List.unmodifiable([ + for (final inline in parsed.inlines) convert(inline), + ]); +} + Iterable _walkBlocks(Iterable blocks) sync* { for (final block in blocks) { yield block; @@ -311,47 +550,333 @@ bool _contains(SourceSpan outer, SourceSpan inner) => bool _sameSpan(SourceSpan left, SourceSpan right) => left.startOffset == right.startOffset && left.endOffset == right.endOffset; +bool _isRecognizedHeadingAttribute( + String rawAttribute, + Map parsedAttributes, +) { + // The heading parser records recognized values in the block attributes. + // Tie that evidence to this exact trailing source expression: an attribute + // elsewhere in the heading must not hide a later literal brace expression. + for (final match in RegExp( + r'([A-Za-z_:][-A-Za-z0-9_:.]*)\s*=\s*"([^"]*)"', + ).allMatches(rawAttribute)) { + final name = match.group(1)!; + final value = html.parseFragment(match.group(2)!).text ?? ''; + if (name == 'id') { + if (parsedAttributes['generatedId'] == 'false' && + parsedAttributes['id'] == value) { + return true; + } + } else if (name != 'level' && + name != 'generatedId' && + parsedAttributes[name] == value) { + return true; + } + } + return false; +} + bool _startsExcludedBlock(String raw) { final trimmed = raw.trimLeft().replaceFirst(RegExp(r'^(?:>[ \t]*)+'), ''); - return RegExp(r'^(?:```|~~~)').hasMatch(trimmed) || - RegExp( - r'^(?:', cursor + 4); _barrier(); _opaqueEnd = close < 0 || close >= end ? end : close + 3; @@ -574,7 +1166,7 @@ final class _MarkdownProseScanner { continue; } } - if (unit == 0x3c) { + if (interpretHtmlSyntax && !_inImageLabel && unit == 0x3c) { final tagCandidate = _isHtmlTagCandidate(cursor); final autolinkCandidate = _isAutolinkCandidate(cursor); if (tagCandidate || autolinkCandidate) { @@ -636,6 +1228,93 @@ final class _MarkdownProseScanner { } } + _MappedImageCode? _imageCodeAt(int offset) { + for (final code in imageCodeSyntax) { + if (code.start == offset) return code; + } + return null; + } + + void _scanImageCode(_MappedImageCode code) { + var contentStart = code.start; + while (contentStart < code.end && source.codeUnitAt(contentStart) == 0x60) { + contentStart++; + } + final markerLength = contentStart - code.start; + final contentEnd = code.end - markerLength; + if (markerLength == 0 || + contentEnd < contentStart || + source.substring(contentEnd, code.end) != + source.substring(code.start, contentStart)) { + complete = false; + return; + } + + final units = code.units; + var first = markerLength; + var last = units.length - markerLength; + if (last <= first || + first >= units.length || + units.first.start != code.start || + units.last.end != code.end || + units[first].start != contentStart || + units[last - 1].end != contentEnd) { + complete = false; + return; + } + String logical() => units + .skip(first) + .take(last - first) + .map((unit) => unit.lineBreak ? ' ' : unit.text) + .join(); + if (logical() != code.semanticText && + last - first >= 2 && + (units[first].lineBreak || units[first].text == ' ') && + (units[last - 1].lineBreak || units[last - 1].text == ' ')) { + first++; + last--; + } + if (logical() != code.semanticText) { + complete = false; + return; + } + + var textStart = -1; + var textEnd = -1; + final text = StringBuffer(); + void flushText() { + if (text.isEmpty) return; + _emit( + text.toString(), + textStart, + textEnd, + SpellingTransformationKind.identity, + sourceContext: SpellingSourceContext.markdownCodeSpan, + ); + text.clear(); + } + + for (var index = first; index < last; index++) { + final unit = units[index]; + if (unit.lineBreak) { + flushText(); + _emit( + ' ', + unit.start, + unit.end, + SpellingTransformationKind.lineBreak, + tokenizationLogical: ' ', + sourceContext: SpellingSourceContext.markdownCodeSpan, + ); + } else { + if (text.isEmpty) textStart = unit.start; + textEnd = unit.end; + text.write(unit.text); + } + } + flushText(); + } + bool _isHtmlTagCandidate(int start) { if (start + 1 >= end) return false; final tail = source.substring(start, math.min(end, start + 128)); @@ -666,62 +1345,273 @@ final class _MarkdownProseScanner { } int? _scanLinkOrImage(int cursor, int rangeEnd, {required bool image}) { + final recognized = recognizedLinks[cursor]; final opening = cursor + (image ? 1 : 0); - final labelEnd = _matchingBracket(opening, rangeEnd, 0x5b, 0x5d); - if (labelEnd == null) return null; - final afterLabel = labelEnd + 1; - final label = source.substring(opening + 1, labelEnd); - if (!image && - label.startsWith('^') && - footnoteLabels.contains(label.substring(1).toLowerCase())) { - _barrier(); - return afterLabel; - } - var syntaxEnd = afterLabel; - int? titleStart; - int? titleEnd; - SpellingSourceContext? titleContext; - if (afterLabel < rangeEnd && source.codeUnitAt(afterLabel) == 0x28) { - final destinationEnd = _matchingBracket(afterLabel, rangeEnd, 0x28, 0x29); - if (destinationEnd == null) return null; - syntaxEnd = destinationEnd + 1; - final inside = source.substring(afterLabel + 1, destinationEnd); - final title = _trailingLinkTitle(inside); - if (title != null) { - titleContext = title.quote == "'" - ? SpellingSourceContext.markdownSingleQuotedTitle - : SpellingSourceContext.markdownDoubleQuotedTitle; - // The value ends immediately before the closing quote. Derive its - // range from those parsed boundaries rather than searching for its - // contents, which may also occur in the destination. - titleStart = afterLabel + 1 + title.start; - titleEnd = afterLabel + 1 + title.end; + if (recognized == null) { + final labelEnd = _matchingBracket(opening, rangeEnd, 0x5b, 0x5d); + if (labelEnd != null && !image) { + final label = source.substring(opening + 1, labelEnd); + if (label.startsWith('^') && + footnoteLabels.contains(label.substring(1).toLowerCase())) { + _barrier(); + return labelEnd + 1; + } } - } else if (afterLabel < rangeEnd && source.codeUnitAt(afterLabel) == 0x5b) { - final referenceEnd = source.indexOf(']', afterLabel + 1); - if (referenceEnd < 0 || referenceEnd >= rangeEnd) return null; - syntaxEnd = referenceEnd + 1; - } else if (image) { return null; } - + if (recognized.end > end || + recognized.labelStart < opening + 1 || + recognized.labelEnd < recognized.labelStart || + recognized.labelEnd > recognized.end) { + complete = false; + return null; + } if (image) _barrier(); - _scanInline(opening + 1, labelEnd); + final wasInImageLabel = _inImageLabel; + _inImageLabel = _inImageLabel || image; + try { + _scanMappedLabel(recognized); + } finally { + _inImageLabel = wasInImageLabel; + } if (image) _barrier(); + final titleStart = recognized.titleStart; + final titleEnd = recognized.titleEnd; if (titleStart != null && titleEnd != null && titleEnd > titleStart) { - _barrier(); + final titleContext = switch (recognized.titleDelimiter) { + "'" => SpellingSourceContext.markdownSingleQuotedTitle, + '(' => SpellingSourceContext.markdownParenthesizedTitle, + _ => SpellingSourceContext.markdownDoubleQuotedTitle, + }; final scanner = _MarkdownProseScanner( source: source, start: titleStart, end: titleEnd, - context: titleContext!, + context: titleContext, stripBlockSyntax: false, + interpretHtmlSyntax: false, ); _groups.addAll(scanner.scan()); complete = complete && scanner.complete; - _barrier(); } - return syntaxEnd; + _consumedLinkEnd = recognized.end; + return recognized.end; + } + + void _scanMappedLabel(_RecognizedLinkOccurrence recognized) { + final labelStart = recognized.labelStart; + final labelEnd = recognized.labelEnd; + var cursor = labelStart; + var semanticCursor = 0; + final breaks = positionedLineBreaks.where((lineBreak) { + final offset = lineBreak.sourceOffset; + return offset != null && offset >= labelStart && offset < labelEnd; + }).toList()..sort((a, b) => a.sourceOffset!.compareTo(b.sourceOffset!)); + var breakIndex = 0; + while (cursor < labelEnd) { + // Mapped children own their internal line boundaries. Traverse them + // before interpreting the next physical boundary as label prose. + while (breakIndex < breaks.length && + breaks[breakIndex].sourceOffset! < cursor) { + breakIndex++; + } + final nextBreak = breakIndex < breaks.length + ? breaks[breakIndex].sourceOffset + : null; + ({ + int start, + int end, + int semanticBreaks, + bool? image, + _MappedImageCode? imageCode, + bool inlineBreak, + })? + owner; + for (final span in opaqueSyntax) { + if (span.start < cursor || + span.end > labelEnd || + span.start >= labelEnd || + (nextBreak != null && span.start > nextBreak)) { + continue; + } + if (owner == null || span.start < owner.start) { + owner = ( + start: span.start, + end: span.end, + semanticBreaks: opaqueSemanticBreaks[span.start] ?? 0, + image: null, + imageCode: null, + inlineBreak: false, + ); + } + } + for (final code in imageCodeSyntax) { + if (code.start < cursor || + code.end > labelEnd || + (nextBreak != null && code.start > nextBreak)) { + continue; + } + if (owner == null || code.start < owner.start) { + owner = ( + start: code.start, + end: code.end, + semanticBreaks: 0, + image: null, + imageCode: code, + inlineBreak: false, + ); + } + } + for (final span in hardBreakSyntax) { + if (span.start < cursor || + span.end > labelEnd || + (nextBreak != null && span.start > nextBreak) || + RegExp(r'\r|\n').hasMatch(source.substring(span.start, span.end))) { + continue; + } + if (owner == null || span.start < owner.start) { + owner = ( + start: span.start, + end: span.end, + semanticBreaks: 1, + image: null, + imageCode: null, + inlineBreak: true, + ); + } + } + for (final entry in recognizedLinks.entries) { + final nested = entry.value; + if (entry.key < cursor || + nested.end > labelEnd || + entry.key >= labelEnd || + (nextBreak != null && entry.key > nextBreak)) { + continue; + } + if (owner == null || entry.key < owner.start) { + owner = ( + start: entry.key, + end: nested.end, + semanticBreaks: '\n'.allMatches(nested.semanticLabelText).length, + image: nested.isImage, + imageCode: null, + inlineBreak: false, + ); + } + } + if (owner != null) { + if (cursor < owner.start) _scanInline(cursor, owner.start); + if (owner.imageCode case final code?) { + _scanImageCode(code); + } else if (owner.inlineBreak) { + _emit( + ' ', + owner.start, + owner.end, + SpellingTransformationKind.lineBreak, + tokenizationLogical: '\n', + ); + } else if (owner.image case final image?) { + if (_scanLinkOrImage(owner.start, owner.end, image: image) != + owner.end) { + complete = false; + return; + } + } else { + _scanInline(owner.start, owner.end); + } + for (var index = 0; index < owner.semanticBreaks; index++) { + final semanticBreak = recognized.semanticLabelText.indexOf( + '\n', + semanticCursor, + ); + if (semanticBreak < 0) { + complete = false; + return; + } + semanticCursor = semanticBreak + 1; + } + cursor = owner.end; + continue; + } + if (breakIndex >= breaks.length) { + // Unmapped raw line boundaries must not silently enter logical prose. + if (RegExp(r'\r|\n').hasMatch(source.substring(cursor, labelEnd))) { + complete = false; + return; + } + _scanInline(cursor, labelEnd); + cursor = labelEnd; + break; + } + final lineBreak = breaks[breakIndex++]; + final breakStart = lineBreak.sourceOffset!; + final nextStart = + breakStart + + lineBreak.lineEnding.length + + lineBreak.continuationPrefix.length; + _SourceInterval? hardBreak; + for (final span in hardBreakSyntax) { + if (span.start >= cursor && + span.start <= breakStart && + span.end >= breakStart + lineBreak.lineEnding.length) { + hardBreak = span; + break; + } + } + if (nextStart > labelEnd || breakStart < cursor) { + complete = false; + return; + } + final semanticBreak = recognized.isImage && hardBreak != null + ? -1 + : recognized.semanticLabelText.indexOf('\n', semanticCursor); + if (semanticBreak < 0 && (!recognized.isImage || hardBreak == null)) { + complete = false; + return; + } + var contentEnd = hardBreak?.start ?? breakStart; + if (hardBreak == null) { + // The parser can omit whitespace immediately before a soft break. + // Retain exactly the authored suffix that remains in its label text. + var rawWhitespaceStart = contentEnd; + while (rawWhitespaceStart > cursor && + (source.codeUnitAt(rawWhitespaceStart - 1) == 0x20 || + source.codeUnitAt(rawWhitespaceStart - 1) == 0x09)) { + rawWhitespaceStart--; + } + var semanticWhitespaceStart = semanticBreak; + while (semanticWhitespaceStart > semanticCursor && + (recognized.semanticLabelText.codeUnitAt( + semanticWhitespaceStart - 1, + ) == + 0x20 || + recognized.semanticLabelText.codeUnitAt( + semanticWhitespaceStart - 1, + ) == + 0x09)) { + semanticWhitespaceStart--; + } + final rawCount = contentEnd - rawWhitespaceStart; + final semanticCount = semanticBreak - semanticWhitespaceStart; + contentEnd -= math.max(0, rawCount - semanticCount); + } + if (cursor < contentEnd) _scanInline(cursor, contentEnd); + if (!recognized.isImage || hardBreak == null) { + _emit( + ' ', + contentEnd, + nextStart, + SpellingTransformationKind.lineBreak, + tokenizationLogical: '\n', + ); + } + if (semanticBreak >= 0) semanticCursor = semanticBreak + 1; + cursor = nextStart; + } + if (recognized.semanticLabelText.indexOf('\n', semanticCursor) >= 0) { + complete = false; + } } void _scanHumanReadableAttributes(int tagStart, int tagEnd) { @@ -738,17 +1628,16 @@ final class _MarkdownProseScanner { final savedContext = quote == "'" ? SpellingSourceContext.xmlSingleQuotedAttribute : SpellingSourceContext.xmlDoubleQuotedAttribute; - _barrier(); final scanner = _MarkdownProseScanner( source: source, start: valueStart, end: valueStart + value.length, context: savedContext, stripBlockSyntax: false, + interpretHtmlSyntax: false, ); _groups.addAll(scanner.scan()); complete = complete && scanner.complete; - _barrier(); } } @@ -758,6 +1647,7 @@ final class _MarkdownProseScanner { int sourceEnd, SpellingTransformationKind transformation, { String? tokenizationLogical, + SpellingSourceContext? sourceContext, }) { if (logical.isEmpty) return; _tokenizationContextStart ??= _tokenizationContext.length; @@ -772,7 +1662,7 @@ final class _MarkdownProseScanner { sourceStart: sourceStart, sourceEnd: sourceEnd, transformation: transformation, - context: context, + context: sourceContext ?? context, ), ); } @@ -837,21 +1727,22 @@ final class _MarkdownProseScanner { _tokenizationContextStart = null; } - List<({int start, int contentEnd})> _lines() { + List<({int start, int contentEnd})> _lines({int? from, int? until}) { final result = <({int start, int contentEnd})>[]; - var cursor = start; - while (cursor < end) { + var cursor = from ?? start; + final rangeEnd = until ?? end; + while (cursor < rangeEnd) { var contentEnd = cursor; - while (contentEnd < end && + while (contentEnd < rangeEnd && source.codeUnitAt(contentEnd) != 0x0a && source.codeUnitAt(contentEnd) != 0x0d) { contentEnd++; } result.add((start: cursor, contentEnd: contentEnd)); - if (contentEnd >= end) break; + if (contentEnd >= rangeEnd) break; cursor = contentEnd + 1; if (source.codeUnitAt(contentEnd) == 0x0d && - cursor < end && + cursor < rangeEnd && source.codeUnitAt(cursor) == 0x0a) { cursor++; } @@ -934,6 +1825,18 @@ List<_MultilineHtmlSpan> _multilineHtmlSpans( while (cursor < end) { final opening = source.indexOf('<', cursor); if (opening < 0 || opening >= end) break; + var precedingBackslashes = 0; + for ( + var index = opening - 1; + index >= start && source.codeUnitAt(index) == 0x5c; + index-- + ) { + precedingBackslashes++; + } + if (precedingBackslashes.isOdd) { + cursor = opening + 1; + continue; + } final candidate = RegExp( r'^|$)', ).hasMatch(source.substring(opening, math.min(end, opening + 128))); @@ -1008,37 +1911,6 @@ List<_MultilineHtmlSpan> _multilineHtmlSpans( return List.unmodifiable(result); } -({int start, int end, String quote})? _trailingLinkTitle(String value) { - var closing = value.length - 1; - while (closing >= 0 && _horizontalWhitespace(value.codeUnitAt(closing))) { - closing--; - } - if (closing < 0 || (value[closing] != '"' && value[closing] != "'")) { - return null; - } - final quote = value[closing]; - bool escaped(int offset) { - var slashes = 0; - for ( - var cursor = offset - 1; - cursor >= 0 && value.codeUnitAt(cursor) == 0x5c; - cursor-- - ) { - slashes++; - } - return slashes.isOdd; - } - - for (var opening = closing - 1; opening >= 0; opening--) { - if (value[opening] != quote || escaped(opening)) continue; - if (opening > 0 && !_horizontalWhitespace(value.codeUnitAt(opening - 1))) { - continue; - } - return (start: opening + 1, end: closing, quote: quote); - } - return null; -} - final class _RawFormattingWrapper { const _RawFormattingWrapper({ required this.kind, @@ -1130,11 +2002,12 @@ List<_RawFormattingWrapper> _formattingWrappers( List<_SourceInterval> _opaqueSyntaxSpans( Iterable parses, { required int sourceBase, + Set excludedKinds = _opaqueInlineKinds, }) { final spans = <_SourceInterval>[]; for (final parse in parses) { for (final entry in parse.ranges.entries) { - if (!_opaqueInlineKinds.contains(entry.key.kind)) continue; + if (!excludedKinds.contains(entry.key.kind)) continue; final range = entry.value; spans.add( _SourceInterval(sourceBase + range.start, sourceBase + range.end), diff --git a/lib/src/spellcheck/spelling_catalog.dart b/lib/src/spellcheck/spelling_catalog.dart index cf1f059b..0ef44636 100644 --- a/lib/src/spellcheck/spelling_catalog.dart +++ b/lib/src/spellcheck/spelling_catalog.dart @@ -4,6 +4,8 @@ import 'dart:io'; import 'package:crypto/crypto.dart'; import 'package:path/path.dart' as p; +import 'spelling_dictionary_installer.dart'; + enum SpellingDictionaryInstallationKind { downloaded, imported } final class SpellingInvalidDictionaryInstallation { @@ -13,6 +15,7 @@ final class SpellingInvalidDictionaryInstallation { required this.error, this.resourceId, this.id, + this.sourceIdentity, }); final String directoryPath; @@ -21,6 +24,9 @@ final class SpellingInvalidDictionaryInstallation { final String? resourceId; final String? id; + /// Snapshot of the invalid directory when this record was discovered. + final String? sourceIdentity; + bool matches(String value) => resourceId == value || id == value || p.basename(directoryPath) == value; } @@ -300,6 +306,7 @@ final class SpellingDictionaryCatalog { String? downloadedRoot, String? importedRoot, bool verifyChecksums = true, + void Function(String rootPath, List entries)? onInstallationListed, }) async { final available = []; final installations = []; @@ -318,6 +325,7 @@ final class SpellingDictionaryCatalog { entries: installations, unavailable: unavailable, invalidEntries: invalidInstallations, + onInstallationListed: onInstallationListed, ); await _loadInstallations( importedRoot, @@ -327,6 +335,7 @@ final class SpellingDictionaryCatalog { entries: installations, unavailable: unavailable, invalidEntries: invalidInstallations, + onInstallationListed: onInstallationListed, ); available.sort((left, right) => left.label.compareTo(right.label)); installations.sort((left, right) => left.label.compareTo(right.label)); @@ -389,79 +398,139 @@ final class SpellingDictionaryCatalog { required List entries, required Map unavailable, required List invalidEntries, + void Function(String rootPath, List entries)? onInstallationListed, }) async { if (rootPath == null) return; final root = Directory(rootPath); if (!await root.exists()) return; - await for (final entity in root.list()) { + var visible = await root.list(followLinks: false).toList(); + onInstallationListed?.call(root.path, [ + for (final entity in visible) p.basename(entity.path), + ]); + // A replacement briefly moves its previous directory to a hidden backup. + // A listing taken in that interval must wait for the same destination + // lock and then list again, rather than publish a missing installation. + for (var attempt = 0; attempt < 3; attempt++) { + final backups = visible.where( + (entity) => + entity is Directory && + p.basename(entity.path).startsWith('.busymark-replaced-'), + ); + if (backups.isEmpty) break; + for (final backup in backups) { + final name = p.basename(backup.path); + final match = RegExp( + r'^\.busymark-replaced-(.+)-[^-]+$', + ).firstMatch(name); + if (match == null) continue; + final destination = p.join(root.path, match.group(1)!); + await const SpellingDictionaryPairInstaller().withPublicationLock( + destination, + () async {}, + ); + } + visible = await root.list(followLinks: false).toList(); + } + for (final entity in visible) { if (entity is! Directory || p.basename(entity.path).startsWith('.')) { continue; } - final manifest = File(p.join(entity.path, 'manifest.json')); - String? resourceId; - String? id; - try { - if (!await manifest.exists()) { - throw const FormatException('Installation manifest is missing.'); - } - final decoded = jsonDecode(await manifest.readAsString()); - if (decoded is! Map || decoded['schemaVersion'] != 1) { - throw const FormatException('Unsupported installation manifest.'); - } - resourceId = decoded['resourceId']?.toString(); - id = decoded['id']?.toString(); - final installation = SpellingDictionaryInstallation.fromJson( - decoded.cast(), - rootPath: entity.path, - ); - resourceId = installation.resourceId; - id = installation.id; - if (installation.kind != expectedKind) { - throw const FormatException('Dictionary installation kind mismatch.'); - } - if (expectedKind == SpellingDictionaryInstallationKind.downloaded) { - final resource = available - .where( - (candidate) => candidate.resourceId == installation.resourceId, - ) - .firstOrNull; - if (resource == null || - resource.affSha256 != installation.affSha256 || - resource.dicSha256 != installation.dicSha256 || - resource.sourceRevision != installation.sourceRevision) { - throw const FormatException( - 'Downloaded dictionary does not match the shipped catalog.', + await const SpellingDictionaryPairInstaller().withPublicationLock( + entity.path, + () async { + if (!await entity.exists()) return; + final manifest = File(p.join(entity.path, 'manifest.json')); + String? resourceId; + String? id; + try { + if (!await manifest.exists()) { + throw const FormatException('Installation manifest is missing.'); + } + final decoded = jsonDecode(await manifest.readAsString()); + if (decoded is! Map || decoded['schemaVersion'] != 1) { + throw const FormatException('Unsupported installation manifest.'); + } + resourceId = decoded['resourceId']?.toString(); + id = decoded['id']?.toString(); + final installation = SpellingDictionaryInstallation.fromJson( + decoded.cast(), + rootPath: entity.path, + ); + resourceId = installation.resourceId; + id = installation.id; + if (installation.kind != expectedKind) { + throw const FormatException( + 'Dictionary installation kind mismatch.', + ); + } + if (expectedKind == SpellingDictionaryInstallationKind.downloaded) { + final resource = available + .where( + (candidate) => + candidate.resourceId == installation.resourceId, + ) + .firstOrNull; + if (resource == null || + resource.affSha256 != installation.affSha256 || + resource.dicSha256 != installation.dicSha256 || + resource.sourceRevision != installation.sourceRevision) { + throw const FormatException( + 'Downloaded dictionary does not match the shipped catalog.', + ); + } + } + if (entries.any( + (candidate) => candidate.resourceId == installation.resourceId, + )) { + throw FormatException( + 'Duplicate installed resource ${installation.resourceId}.', + ); + } + await _validateInstallationFiles( + installation, + verifyChecksums: verifyChecksums, + ); + entries.add(installation); + } on Object catch (error) { + unavailable[entity.path] = error.toString(); + invalidEntries.add( + SpellingInvalidDictionaryInstallation( + directoryPath: p.normalize(p.absolute(entity.path)), + kind: expectedKind, + error: error.toString(), + resourceId: resourceId, + id: id, + sourceIdentity: await spellingInstallationDirectoryIdentity( + entity, + ), + ), ); } - } - if (entries.any( - (candidate) => candidate.resourceId == installation.resourceId, - )) { - throw FormatException( - 'Duplicate installed resource ${installation.resourceId}.', - ); - } - await _validateInstallationFiles( - installation, - verifyChecksums: verifyChecksums, - ); - entries.add(installation); - } on Object catch (error) { - unavailable[entity.path] = error.toString(); - invalidEntries.add( - SpellingInvalidDictionaryInstallation( - directoryPath: p.normalize(p.absolute(entity.path)), - kind: expectedKind, - error: error.toString(), - resourceId: resourceId, - id: id, - ), - ); - } + }, + ); } } } +/// A directory signature for rejecting a stale invalid-installation record. +/// It is observed again while holding that destination's publication lock. +Future spellingInstallationDirectoryIdentity( + Directory directory, +) async { + final entries = []; + final directoryStat = await directory.stat(); + entries.add('${directoryStat.changed.microsecondsSinceEpoch}'); + await for (final entity in directory.list(followLinks: false)) { + final stat = await entity.stat(); + entries.add( + '${p.basename(entity.path)}:${stat.type}:${stat.size}:' + '${stat.changed.microsecondsSinceEpoch}', + ); + } + entries.sort(); + return sha256.convert(utf8.encode(entries.join('\n'))).toString(); +} + String safeSpellingCatalogResourceName(String value) => value.replaceAll(RegExp(r'[^A-Za-z0-9_.-]'), '_'); diff --git a/lib/src/spellcheck/spelling_coordinator.dart b/lib/src/spellcheck/spelling_coordinator.dart index 02554f31..67b3d518 100644 --- a/lib/src/spellcheck/spelling_coordinator.dart +++ b/lib/src/spellcheck/spelling_coordinator.dart @@ -85,6 +85,7 @@ final class SpellingCoordinator extends ChangeNotifier { Timer? _debounce; _PendingSpellingCheck? _pending; int _presentationGeneration = 0; + bool _dictionaryLoadFailed = false; bool _running = false; bool _closed = false; final List<_IgnoredOccurrence> _ignoredOccurrences = []; @@ -95,6 +96,7 @@ final class SpellingCoordinator extends ChangeNotifier { SpellingPresentationState _state = const SpellingPresentationState.languageRequired(); SpellingPresentationState get state => _state; + bool get dictionaryLoadFailed => _dictionaryLoadFailed; List get misspellings => _state.occurrences .where((item) => item.outcome == SpellingCheckOutcome.rejected) @@ -129,6 +131,7 @@ final class SpellingCoordinator extends ChangeNotifier { _pending?.complete(); _pending = _PendingSpellingCheck(request); _presentationGeneration++; + _dictionaryLoadFailed = false; _worker.cancelChecks(); _debounce?.cancel(); _setState( @@ -148,6 +151,7 @@ final class SpellingCoordinator extends ChangeNotifier { final pending = _PendingSpellingCheck(request, waitForCompletion: true); _pending = pending; _presentationGeneration++; + _dictionaryLoadFailed = false; _worker.cancelChecks(); _debounce?.cancel(); _setState( @@ -341,11 +345,7 @@ final class SpellingCoordinator extends ChangeNotifier { if (cached == null) { uncheckedRuns.add(run); } else { - occurrences.addAll( - cached - .map((item) => item.bind(run)) - .whereType(), - ); + occurrences.addAll(cached); } } if (_closed || generation != _presentationGeneration) continue; @@ -354,6 +354,7 @@ final class SpellingCoordinator extends ChangeNotifier { } var nativeComplete = true; String? nativeError; + var dictionaryLoadFailed = false; const batchSize = 12; for ( var start = 0; @@ -375,6 +376,8 @@ final class SpellingCoordinator extends ChangeNotifier { } occurrences.addAll(result.occurrences); nativeComplete = nativeComplete && result.complete; + dictionaryLoadFailed = + dictionaryLoadFailed || result.dictionaryLoadFailed; nativeError ??= result.error; if (result.complete) { for (final run in batch) { @@ -392,6 +395,7 @@ final class SpellingCoordinator extends ChangeNotifier { if (_closed || generation != _presentationGeneration) continue; final rejected = _orderedRejected(occurrences, projection.runs); final complete = projection.complete && nativeComplete; + _dictionaryLoadFailed = dictionaryLoadFailed; _setState( SpellingPresentationState( status: complete @@ -465,15 +469,21 @@ final class SpellingCoordinator extends ChangeNotifier { return List.unmodifiable(rejected); } - List<_CachedRunOccurrence>? _takeCachedRun( + List? _takeCachedRun( SpellingEngineContext context, SpellingProseRun run, ) { final key = _runCacheKey(context, run); final cached = _runResultCache.remove(key); if (cached == null) return null; + final rebound = []; + for (final item in cached) { + final occurrence = item.bind(run); + if (occurrence == null) return null; + rebound.add(occurrence); + } _runResultCache[key] = cached; - return cached; + return rebound; } void _cacheRunResult( @@ -535,7 +545,8 @@ String _temporaryWordKey(String word) => unicode.nfc(word).toLowerCase(); String _runCacheKey(SpellingEngineContext context, SpellingProseRun run) => '${context.identity}\u0000${run.languageId}\u0000' - '${run.tokenizationContextStart}\u0000${run.tokenizationContext}'; + '${run.tokenizationContextStart}\u0000${run.tokenizationContext}\u0000' + '${run.text.length}:${run.text}'; final class _CachedRunOccurrence { const _CachedRunOccurrence({ @@ -558,11 +569,12 @@ final class _CachedRunOccurrence { SpellingOccurrence? bind(SpellingProseRun run) { if (logicalStart < 0 || logicalEnd <= logicalStart || - logicalEnd > run.text.length) { + logicalEnd > run.text.length || + (run.atoms.isNotEmpty && !run.hasValidMapping)) { return null; } final word = run.text.substring(logicalStart, logicalEnd); - return SpellingOccurrence( + final occurrence = SpellingOccurrence( id: '${run.snapshot.bufferId}:${run.snapshot.contentRevision}:' '${run.snapshot.documentKind.name}:' @@ -574,6 +586,14 @@ final class _CachedRunOccurrence { word: word, outcome: outcome, ); + if (run.atoms.isNotEmpty && + ((run.target is SpellingSourceTarget && + occurrence.sourceIntervals.isEmpty) || + (run.target is! SpellingSourceTarget && + occurrence.fieldIntervals.isEmpty))) { + return null; + } + return occurrence; } } diff --git a/lib/src/spellcheck/spelling_dictionary_downloader.dart b/lib/src/spellcheck/spelling_dictionary_downloader.dart index 3cc946f2..2b9f7005 100644 --- a/lib/src/spellcheck/spelling_dictionary_downloader.dart +++ b/lib/src/spellcheck/spelling_dictionary_downloader.dart @@ -87,6 +87,7 @@ final class SpellingDictionaryDownloader { final dic = File(p.join(downloadStage.path, 'download.dic')); var affReceived = 0; var dicReceived = 0; + var failed = false; void publishProgress() => onProgress(affReceived + dicReceived, resource.downloadSize); try { @@ -121,9 +122,16 @@ final class SpellingDictionaryDownloader { cancellationGuard: cancellation.throwIfCancelled, replaceExisting: replaceExisting, ); + } on Object { + failed = true; + rethrow; } finally { - if (await downloadStage.exists()) { - await downloadStage.delete(recursive: true); + try { + if (await downloadStage.exists()) { + await downloadStage.delete(recursive: true); + } + } on Object { + if (!failed) rethrow; } } } @@ -191,8 +199,17 @@ Future receiveSpellingDictionaryBody({ Duration inactivityTimeout = const Duration(seconds: 15), }) async { IOSink? sink; + Object? failure; + StackTrace? failureStack; try { sink = destination.openWrite(); + // Start the sink's consumer immediately so its asynchronous file-open + // failure is connected to done before a delayed response body arrives. + sink.add(const []); + final done = sink.done; + // IOSink reports asynchronous open/write failures on done, potentially + // before the response stream ends. Observe it in the originating zone. + unawaited(done.then((_) {}, onError: (Object _, StackTrace _) {})); var received = 0; await for (final chunk in bytes.timeout(inactivityTimeout)) { cancellation.throwIfCancelled(); @@ -203,14 +220,26 @@ Future receiveSpellingDictionaryBody({ sink.add(chunk); onProgress(received); } - await sink.flush(); + await Future.any([sink.flush(), done]); await sink.close(); sink = null; + await done; cancellation.throwIfCancelled(); if (received != expectedBytes) { throw const FormatException('Dictionary download was incomplete.'); } + } on Object catch (error, stackTrace) { + failure = error; + failureStack = stackTrace; } finally { - await sink?.close(); + try { + await sink?.close(); + } on Object catch (error, stackTrace) { + failure ??= error; + failureStack ??= stackTrace; + } + } + if (failure != null) { + Error.throwWithStackTrace(failure, failureStack!); } } diff --git a/lib/src/spellcheck/spelling_dictionary_installer.dart b/lib/src/spellcheck/spelling_dictionary_installer.dart index 6c11c908..5209cbca 100644 --- a/lib/src/spellcheck/spelling_dictionary_installer.dart +++ b/lib/src/spellcheck/spelling_dictionary_installer.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:io'; @@ -59,12 +60,20 @@ final class SpellingDictionaryInstallSpec { /// directory is always complete and native-loadable; staging directories are /// ignored by catalog discovery and removed after failure or cancellation. final class SpellingDictionaryPairInstaller { - const SpellingDictionaryPairInstaller({this.onCommitted}); + const SpellingDictionaryPairInstaller({ + this.onCommitted, + this.onReplacementMoved, + }); + + static final Map> _publicationTails = {}; /// Observes the commit point after the staged directory becomes the /// published installation. The callback must not throw. final void Function(SpellingDictionaryInstallation installation)? onCommitted; + /// Optional transaction observer used to gate publication races in tests. + final Future Function()? onReplacementMoved; + Future install({ required File affSource, required File dicSource, @@ -105,6 +114,7 @@ final class SpellingDictionaryPairInstaller { final staging = await root.createTemp('.busymark-dictionary-'); final stagedAff = File(p.join(staging.path, 'dictionary.aff')); final stagedDic = File(p.join(staging.path, 'dictionary.dic')); + var failed = false; try { await affSource.copy(stagedAff.path); await dicSource.copy(stagedDic.path); @@ -144,46 +154,104 @@ final class SpellingDictionaryPairInstaller { flush: true, ); cancellationGuard?.call(); - Directory? replaced; - if (await destination.exists()) { - replaced = Directory( - p.join( - root.path, - '.busymark-replaced-${safeSpellingResourceName(spec.resourceId)}-' - '${DateTime.now().microsecondsSinceEpoch}', - ), + return await withPublicationLock(destination.path, () async { + cancellationGuard?.call(); + if (await destination.exists() && !replaceExisting) { + throw FileSystemException( + 'This dictionary resource is already installed', + destination.path, + ); + } + Directory? replaced; + try { + if (await destination.exists()) { + replaced = await root.createTemp( + '.busymark-replaced-${safeSpellingResourceName(spec.resourceId)}-', + ); + await replaced.delete(); + await destination.rename(replaced.path); + await onReplacementMoved?.call(); + } + await staging.rename(destination.path); + } on Object { + if (replaced != null && + await replaced.exists() && + !await destination.exists()) { + await replaced.rename(destination.path); + } + rethrow; + } + final installation = SpellingDictionaryInstallation( + resourceId: spec.resourceId, + id: spec.id, + locales: List.unmodifiable(spec.locales), + label: spec.label, + affPath: p.join(destination.path, 'dictionary.aff'), + dicPath: p.join(destination.path, 'dictionary.dic'), + affSha256: spec.affSha256, + dicSha256: spec.dicSha256, + kind: spec.kind, + directoryPath: destination.path, + sourceRevision: spec.sourceRevision, ); - await destination.rename(replaced.path); - } - try { - await staging.rename(destination.path); - } on Object { + onCommitted?.call(installation); if (replaced != null && await replaced.exists()) { - await replaced.rename(destination.path); + await replaced.delete(recursive: true); + } + return installation; + }); + } on Object { + failed = true; + rethrow; + } finally { + try { + if (await staging.exists()) { + await staging.delete(recursive: true); } - rethrow; + } on Object { + if (!failed) rethrow; } - final installation = SpellingDictionaryInstallation( - resourceId: spec.resourceId, - id: spec.id, - locales: List.unmodifiable(spec.locales), - label: spec.label, - affPath: p.join(destination.path, 'dictionary.aff'), - dicPath: p.join(destination.path, 'dictionary.dic'), - affSha256: spec.affSha256, - dicSha256: spec.dicSha256, - kind: spec.kind, - directoryPath: destination.path, - sourceRevision: spec.sourceRevision, - ); - onCommitted?.call(installation); - if (replaced != null && await replaced.exists()) { - await replaced.delete(recursive: true); + } + } + + Future withPublicationLock( + String destinationPath, + Future Function() publish, + ) async { + final previous = _publicationTails[destinationPath] ?? Future.value(); + final completed = Completer(); + _publicationTails[destinationPath] = completed.future; + try { + await previous; + final lock = await File( + '$destinationPath.lock', + ).open(mode: FileMode.append); + try { + final stopwatch = Stopwatch()..start(); + while (true) { + try { + await lock.lock(FileLock.exclusive); + break; + } on FileSystemException catch (error) { + if (error.osError?.errorCode != 11 || + stopwatch.elapsed >= const Duration(seconds: 30)) { + rethrow; + } + await Future.delayed(const Duration(milliseconds: 10)); + } + } + try { + return await publish(); + } finally { + await lock.unlock(); + } + } finally { + await lock.close(); } - return installation; } finally { - if (await staging.exists()) { - await staging.delete(recursive: true); + completed.complete(); + if (identical(_publicationTails[destinationPath], completed.future)) { + _publicationTails.remove(destinationPath); } } } @@ -198,7 +266,31 @@ final class SpellingDictionaryPairInstaller { throw StateError('Dictionary removal escaped its storage root.'); } final directory = Directory(target); - if (await directory.exists()) await directory.delete(recursive: true); + await withPublicationLock(target, () async { + if (!await directory.exists()) return; + final manifest = File(p.join(target, 'manifest.json')); + if (!await manifest.exists()) { + throw StateError('Dictionary installation changed before removal.'); + } + late final SpellingDictionaryInstallation current; + try { + current = SpellingDictionaryInstallation.fromJson( + (jsonDecode(await manifest.readAsString()) as Map) + .cast(), + rootPath: target, + ); + } on Object { + throw StateError('Dictionary installation changed before removal.'); + } + if (current.resourceId != installation.resourceId || + current.id != installation.id || + current.kind != installation.kind || + current.fingerprint != installation.fingerprint || + current.sourceRevision != installation.sourceRevision) { + throw StateError('Dictionary installation changed before removal.'); + } + await directory.delete(recursive: true); + }); } Future removeInvalid({ @@ -211,7 +303,15 @@ final class SpellingDictionaryPairInstaller { throw StateError('Dictionary removal escaped its storage root.'); } final directory = Directory(target); - if (await directory.exists()) await directory.delete(recursive: true); + await withPublicationLock(target, () async { + if (!await directory.exists()) return; + if (installation.sourceIdentity == null || + await spellingInstallationDirectoryIdentity(directory) != + installation.sourceIdentity) { + throw StateError('Dictionary installation changed before removal.'); + } + await directory.delete(recursive: true); + }); } } diff --git a/lib/src/spellcheck/spelling_inline_projection.dart b/lib/src/spellcheck/spelling_inline_projection.dart index bc2f63d6..9d6a2fe6 100644 --- a/lib/src/spellcheck/spelling_inline_projection.dart +++ b/lib/src/spellcheck/spelling_inline_projection.dart @@ -36,6 +36,11 @@ List projectSpellingInlineRuns({ required List inlines, required int sourceBase, SpellingSourceContext context = SpellingSourceContext.markdownProse, + SpellingMappedImageDescription? Function( + BusyInline image, + SpellingMappedImageDescription? parent, + )? + imageDescriptionFor, }) { final pendingRuns = <_PendingInlineProjection>[]; var text = StringBuffer(); @@ -74,8 +79,41 @@ List projectSpellingInlineRuns({ String? tokenizationText, SpellingTransformationKind transformation = SpellingTransformationKind.identity, + SpellingSourceContext? sourceContext, }) { final fieldValue = inline.text; + if (logicalText == null && + (fieldValue.contains('\n') || fieldValue.contains('\r'))) { + var cursor = 0; + for (final breakMatch in RegExp(r'\r\n|\r|\n').allMatches(fieldValue)) { + if (breakMatch.start > cursor) { + emitLeaf( + inline.copyWith( + text: fieldValue.substring(cursor, breakMatch.start), + ), + path, + sourceContext: sourceContext, + ); + } + emitLeaf( + inline.copyWith(text: breakMatch.group(0)!), + path, + logicalText: ' ', + tokenizationText: '\n', + transformation: SpellingTransformationKind.lineBreak, + sourceContext: sourceContext, + ); + cursor = breakMatch.end; + } + if (cursor < fieldValue.length) { + emitLeaf( + inline.copyWith(text: fieldValue.substring(cursor)), + path, + sourceContext: sourceContext, + ); + } + return; + } final value = logicalText ?? fieldValue; if (value.isEmpty) return; tokenizationContextStart ??= tokenizationContext.length; @@ -97,13 +135,18 @@ List projectSpellingInlineRuns({ fieldEnd: fieldOffset + fieldValue.length, richLeafPath: List.unmodifiable(path), transformation: transformation, - context: context, + context: sourceContext ?? context, ), ); fieldOffset += fieldValue.length; } - void visit(BusyInline inline, List path) { + void visit( + BusyInline inline, + List path, { + List? imageFieldPath, + SpellingMappedImageDescription? imageOwner, + }) { if (inline.kind == BusyInlineKind.link && (inline.attributes['id']?.startsWith('fnref-') ?? false) && (inline.destination?.startsWith('#fn-') ?? false)) { @@ -113,23 +156,66 @@ List projectSpellingInlineRuns({ } switch (inline.kind) { case BusyInlineKind.math: - case BusyInlineKind.code: case BusyInlineKind.html: case BusyInlineKind.writersideVariable: case BusyInlineKind.unknown: barrier(); fieldOffset += inline.plainText.length; return; + case BusyInlineKind.code: + if (imageFieldPath != null) { + emitLeaf( + inline, + imageFieldPath, + sourceContext: SpellingSourceContext.markdownCodeSpan, + ); + } else { + barrier(); + fieldOffset += inline.plainText.length; + } + return; case BusyInlineKind.image: barrier(); - emitLeaf(inline, path); + final description = imageDescriptionFor?.call(inline, imageOwner); + if (description != null) { + for (final child in description.inlines) { + visit( + child, + path, + imageFieldPath: imageFieldPath ?? path, + imageOwner: description, + ); + } + barrier(); + return; + } + var cursor = 0; + for (final address in spellingPlainAddress.allMatches(inline.text)) { + if (address.start > cursor) { + emitLeaf( + inline.copyWith( + text: inline.text.substring(cursor, address.start), + ), + imageFieldPath ?? path, + ); + } + barrier(); + fieldOffset += address.end - address.start; + cursor = address.end; + } + if (cursor < inline.text.length) { + emitLeaf( + inline.copyWith(text: inline.text.substring(cursor)), + imageFieldPath ?? path, + ); + } barrier(); return; case BusyInlineKind.softBreak: case BusyInlineKind.hardBreak: emitLeaf( inline, - path, + imageFieldPath ?? path, logicalText: ' ', tokenizationText: '\n', transformation: SpellingTransformationKind.lineBreak, @@ -149,7 +235,7 @@ List projectSpellingInlineRuns({ inline.copyWith( text: inline.text.substring(cursor, address.start), ), - path, + imageFieldPath ?? path, ); } barrier(); @@ -159,13 +245,18 @@ List projectSpellingInlineRuns({ if (cursor < inline.text.length) { emitLeaf( inline.copyWith(text: inline.text.substring(cursor)), - path, + imageFieldPath ?? path, ); } return; } for (final (index, child) in inline.children.indexed) { - visit(child, [...path, index]); + visit( + child, + [...path, index], + imageFieldPath: imageFieldPath, + imageOwner: imageOwner, + ); } } } diff --git a/lib/src/spellcheck/spelling_projection.dart b/lib/src/spellcheck/spelling_projection.dart index 4d80b6e1..b5a3f276 100644 --- a/lib/src/spellcheck/spelling_projection.dart +++ b/lib/src/spellcheck/spelling_projection.dart @@ -1,3 +1,4 @@ +import '../markdown/busymark_document.dart'; import '../workspace/workspace_model.dart'; enum SpellingTransformationKind { @@ -12,8 +13,10 @@ enum SpellingTransformationKind { enum SpellingSourceContext { markdownProse, markdownTableCell, + markdownCodeSpan, markdownSingleQuotedTitle, markdownDoubleQuotedTitle, + markdownParenthesizedTitle, xmlText, xmlSingleQuotedAttribute, xmlDoubleQuotedAttribute, @@ -358,9 +361,28 @@ final class SpellingProjectionResult { required this.runs, required this.complete, this.message, + this.imageDescriptions = const [], }); final List runs; final bool complete; final String? message; + final List imageDescriptions; +} + +/// Parser-derived alternative text structure shared by source and rich checks. +final class SpellingMappedImageDescription { + const SpellingMappedImageDescription({ + required this.sourceStart, + required this.sourceEnd, + required this.alternativeText, + required this.destination, + required this.inlines, + }); + + final int sourceStart; + final int sourceEnd; + final String alternativeText; + final String? destination; + final List inlines; } diff --git a/lib/src/spellcheck/spelling_replacement.dart b/lib/src/spellcheck/spelling_replacement.dart index de1c4b07..ddd6eb48 100644 --- a/lib/src/spellcheck/spelling_replacement.dart +++ b/lib/src/spellcheck/spelling_replacement.dart @@ -647,10 +647,16 @@ String _encode(String value, SpellingSourceContext context) { RegExp(r'([\\`*_{}\[\]()<>#+.!|])'), (match) => '\\${match.group(1)}', ), + SpellingSourceContext.markdownCodeSpan => _encodeMarkdownCode(value), SpellingSourceContext.markdownSingleQuotedTitle => value.replaceAll(r'\', r'\\').replaceAll("'", r"\'"), SpellingSourceContext.markdownDoubleQuotedTitle => value.replaceAll(r'\', r'\\').replaceAll('"', r'\"'), + SpellingSourceContext.markdownParenthesizedTitle => + value + .replaceAll(r'\', r'\\') + .replaceAll('(', r'\(') + .replaceAll(')', r'\)'), SpellingSourceContext.xmlText => value .replaceAll('&', '&') @@ -673,6 +679,13 @@ String _encode(String value, SpellingSourceContext context) { }; } +String _encodeMarkdownCode(String value) { + if (value.contains('`') || value.contains('\n') || value.contains('\r')) { + throw StateError('The suggestion cannot be written inside this code span.'); + } + return value; +} + List _coalesceSourceEdits(List edits) { if (edits.length < 2) return List.unmodifiable(edits); final sorted = [...edits] diff --git a/lib/src/spellcheck/spelling_session_controller.dart b/lib/src/spellcheck/spelling_session_controller.dart index b331722e..dd853d92 100644 --- a/lib/src/spellcheck/spelling_session_controller.dart +++ b/lib/src/spellcheck/spelling_session_controller.dart @@ -30,6 +30,13 @@ typedef SpellingWordStoreFactory = }); typedef SpellingWordStoreReader = Future Function(SpellingWordStore store); +typedef SpellingCatalogLoader = + Future Function({ + required String bundledRoot, + required String downloadedRoot, + required String importedRoot, + required bool verifyChecksums, + }); SpellingWordStore _createSpellingWordStore({ required String filePath, @@ -85,6 +92,7 @@ final class SpellingSessionController extends ChangeNotifier { this.dictionaryDownloader = const SpellingDictionaryDownloader(), this.dictionaryInstaller = const SpellingDictionaryPairInstaller(), SpellingCoordinatorStarter? coordinatorStarter, + this.catalogLoader = SpellingDictionaryCatalog.load, SpellingWordStoreFactory? wordStoreFactory, SpellingWordStoreReader? wordStoreReader, }) : coordinatorStarter = coordinatorStarter ?? SpellingCoordinator.start, @@ -99,12 +107,16 @@ final class SpellingSessionController extends ChangeNotifier { final SpellingDictionaryDownloader dictionaryDownloader; final SpellingDictionaryPairInstaller dictionaryInstaller; final SpellingCoordinatorStarter coordinatorStarter; + final SpellingCatalogLoader catalogLoader; final SpellingWordStoreFactory wordStoreFactory; final SpellingWordStoreReader wordStoreReader; SpellingCoordinator? _coordinator; Future? _coordinatorStartup; SpellingDictionaryCatalog? _catalog; + Future? _catalogRefresh; + int _catalogGeneration = 0; + bool _catalogNeedsRefresh = false; SpellingWordStore? _personalStore; SpellingWordStore? _projectStore; SpellingWordStoreSnapshot _personalWords = const SpellingWordStoreSnapshot( @@ -117,6 +129,13 @@ final class SpellingSessionController extends ChangeNotifier { ); SpellingPresentationState _localState = const SpellingPresentationState.languageRequired(); + ({ + SpellingWordStore store, + SpellingPresentationState previous, + bool previousUsesLocalState, + })? + _projectReadFailure; + SpellingWordStore? _localProjectReadFailureStore; SpellingEngineContext? _engineContext; SpellingSessionInput? _latestInput; String? _projectRoot; @@ -127,8 +146,11 @@ final class SpellingSessionController extends ChangeNotifier { bool _projectStorageInitialized = false; Future<({SpellingWordStore store, SpellingWordStoreSnapshot snapshot})>? _personalStorageLoad; + int _personalMutationGeneration = 0; + int _personalRefreshGeneration = 0; String? _requestedProjectRoot; int _projectStorageGeneration = 0; + int _projectPublicationGeneration = 0; String _scheduledIdentity = ''; String _contextIdentity = ''; int _contextGeneration = 0; @@ -164,7 +186,15 @@ final class SpellingSessionController extends ChangeNotifier { Future prepareSettings(Workspace? workspace) async { _settingsWorkspace = workspace; + final wasInitialized = _personalStorageInitialized; + final hadCatalog = _catalog != null; await _initializeStorage(workspace); + if (wasInitialized && await _refreshPersonalWords()) { + await _refreshAfterPersistentChange(); + } + if (hadCatalog && await _refreshCatalog()) { + await _refreshAfterPersistentChange(); + } if (!_disposed) notifyListeners(); } @@ -257,6 +287,7 @@ final class SpellingSessionController extends ChangeNotifier { _requireCurrent(occurrence); final store = _personalStore; if (store == null) throw StateError('Personal dictionary is unavailable.'); + _personalMutationGeneration++; late final SpellingWordStoreSnapshot updated; try { updated = await store.addWord(occurrence.run.languageId, occurrence.word); @@ -266,6 +297,7 @@ final class SpellingSessionController extends ChangeNotifier { } if (!identical(store, _personalStore)) return; _personalWords = updated; + _personalMutationGeneration++; await _refreshAfterPersistentChange(); } @@ -276,6 +308,9 @@ final class SpellingSessionController extends ChangeNotifier { throw StateError('This document is not associated with a project.'); } final projectRoot = _projectRoot; + if (projectRoot == _requestedProjectRoot) { + _projectPublicationGeneration++; + } late final SpellingWordStoreSnapshot updated; try { updated = await store.addWord(occurrence.run.languageId, occurrence.word); @@ -283,7 +318,12 @@ final class SpellingSessionController extends ChangeNotifier { await _reconcileProjectStoreConflict(store, projectRoot); Error.throwWithStackTrace(error, stackTrace); } - if (!identical(store, _projectStore) || projectRoot != _projectRoot) return; + if (!identical(store, _projectStore) || + projectRoot != _projectRoot || + projectRoot != _requestedProjectRoot) { + return; + } + _projectPublicationGeneration++; _projectWords = updated; await _refreshAfterPersistentChange(); } @@ -291,6 +331,7 @@ final class SpellingSessionController extends ChangeNotifier { Future removePersonalWord(String languageId, String word) async { final store = _personalStore; if (store == null) throw StateError('Personal dictionary is unavailable.'); + _personalMutationGeneration++; late final SpellingWordStoreSnapshot updated; try { updated = await store.removeWord(languageId, word); @@ -300,6 +341,7 @@ final class SpellingSessionController extends ChangeNotifier { } if (!identical(store, _personalStore)) return; _personalWords = updated; + _personalMutationGeneration++; await _refreshAfterPersistentChange(); } @@ -307,6 +349,9 @@ final class SpellingSessionController extends ChangeNotifier { final store = _projectStore; if (store == null) throw StateError('Project dictionary is unavailable.'); final projectRoot = _projectRoot; + if (projectRoot == _requestedProjectRoot) { + _projectPublicationGeneration++; + } late final SpellingWordStoreSnapshot updated; try { updated = await store.removeWord(languageId, word); @@ -314,7 +359,12 @@ final class SpellingSessionController extends ChangeNotifier { await _reconcileProjectStoreConflict(store, projectRoot); Error.throwWithStackTrace(error, stackTrace); } - if (!identical(store, _projectStore) || projectRoot != _projectRoot) return; + if (!identical(store, _projectStore) || + projectRoot != _projectRoot || + projectRoot != _requestedProjectRoot) { + return; + } + _projectPublicationGeneration++; _projectWords = updated; await _refreshAfterPersistentChange(); } @@ -327,6 +377,7 @@ final class SpellingSessionController extends ChangeNotifier { }) async { await _ensureStorageRoot(); await _initializeStorage(_latestInput?.workspace ?? _settingsWorkspace); + await _refreshCatalog(); final normalizedLanguage = normalizeSpellingLanguageId(languageId); if (normalizedLanguage == null) { throw const FormatException('Choose an explicit valid language tag.'); @@ -372,6 +423,7 @@ final class SpellingSessionController extends ChangeNotifier { Future removeImportedDictionary(String languageId) async { await _initializeStorage(_latestInput?.workspace ?? _settingsWorkspace); + await _refreshCatalog(); final installation = _catalog?.installedById(languageId); if (installation != null && installation.imported) { await _releaseAndRemoveInstallation(installation); @@ -388,6 +440,7 @@ final class SpellingSessionController extends ChangeNotifier { SpellingInvalidDictionaryInstallation installation, ) async { await _initializeStorage(_latestInput?.workspace ?? _settingsWorkspace); + await _refreshCatalog(); final current = _catalog?.invalidInstallations.any( (candidate) => @@ -400,48 +453,54 @@ final class SpellingSessionController extends ChangeNotifier { } Future installDictionary(String languageId) async { - await _initializeStorage(_latestInput?.workspace ?? _settingsWorkspace); - final catalog = _catalog; - final resource = catalog?.availableById(languageId); - if (resource == null) { - throw StateError('Dictionary resource is not in the shipped catalog.'); - } - final existing = catalog!.installationForResource(resource.resourceId); - if (existing != null) { - if (existing.kind == SpellingDictionaryInstallationKind.downloaded) { - return; - } - throw StateError( - 'Remove the local import before installing the catalog dictionary.', - ); - } - final invalidInstallation = catalog.invalidInstallations - .where( - (candidate) => - candidate.kind == SpellingDictionaryInstallationKind.downloaded && - (candidate.resourceId == resource.resourceId || - candidate.matches(resource.resourceId)), - ) - .firstOrNull; if (_dictionaryDownloadCancellation != null) { throw StateError('Another dictionary installation is in progress.'); } - final dictionaryRoot = await _ensureDictionaryStorageRoot(); - await _ensureCoordinator(); - final coordinator = _coordinator; - if (coordinator == null) { - throw StateError('The spelling service is unavailable.'); - } final cancellation = SpellingDictionaryDownloadCancellation(); _dictionaryDownloadCancellation = cancellation; - _dictionaryInstallStatus = SpellingDictionaryInstallStatus( - resourceId: resource.resourceId, - phase: SpellingDictionaryInstallPhase.downloading, - receivedBytes: 0, - totalBytes: resource.downloadSize, - ); - notifyListeners(); + SpellingDictionaryResource? resource; try { + await _initializeStorage(_latestInput?.workspace ?? _settingsWorkspace); + await _refreshCatalog(); + cancellation.throwIfCancelled(); + final catalog = _catalog; + resource = catalog?.availableById(languageId); + if (resource == null) { + throw StateError('Dictionary resource is not in the shipped catalog.'); + } + final existing = catalog!.installationForResource(resource.resourceId); + if (existing != null) { + if (existing.kind == SpellingDictionaryInstallationKind.downloaded) { + return; + } + throw StateError( + 'Remove the local import before installing the catalog dictionary.', + ); + } + final invalidInstallation = catalog.invalidInstallations + .where( + (candidate) => + candidate.kind == + SpellingDictionaryInstallationKind.downloaded && + (candidate.resourceId == resource!.resourceId || + candidate.matches(resource.resourceId)), + ) + .firstOrNull; + final dictionaryRoot = await _ensureDictionaryStorageRoot(); + cancellation.throwIfCancelled(); + await _ensureCoordinator(); + cancellation.throwIfCancelled(); + final coordinator = _coordinator; + if (coordinator == null) { + throw StateError('The spelling service is unavailable.'); + } + _dictionaryInstallStatus = SpellingDictionaryInstallStatus( + resourceId: resource.resourceId, + phase: SpellingDictionaryInstallPhase.downloading, + receivedBytes: 0, + totalBytes: resource.downloadSize, + ); + if (!_disposed) notifyListeners(); await dictionaryDownloader.install( resource: resource, downloadedRoot: p.join(dictionaryRoot, 'downloaded'), @@ -457,7 +516,7 @@ final class SpellingSessionController extends ChangeNotifier { return; } _dictionaryInstallStatus = SpellingDictionaryInstallStatus( - resourceId: resource.resourceId, + resourceId: resource!.resourceId, phase: received >= total ? SpellingDictionaryInstallPhase.validating : SpellingDictionaryInstallPhase.downloading, @@ -468,20 +527,27 @@ final class SpellingSessionController extends ChangeNotifier { }, replaceExisting: invalidInstallation != null, ); - _dictionaryInstallStatus = null; + if (identical(_dictionaryDownloadCancellation, cancellation)) { + _dictionaryInstallStatus = null; + } await _reloadCatalogAndRefresh(); } on SpellingDictionaryDownloadCancelled { - _dictionaryInstallStatus = null; - if (!_disposed) notifyListeners(); + if (identical(_dictionaryDownloadCancellation, cancellation)) { + _dictionaryInstallStatus = null; + if (!_disposed) notifyListeners(); + } } on Object catch (error) { - _dictionaryInstallStatus = SpellingDictionaryInstallStatus( - resourceId: resource.resourceId, - phase: SpellingDictionaryInstallPhase.failed, - receivedBytes: _dictionaryInstallStatus?.receivedBytes ?? 0, - totalBytes: resource.downloadSize, - error: error.toString(), - ); - if (!_disposed) notifyListeners(); + if (resource != null && + identical(_dictionaryDownloadCancellation, cancellation)) { + _dictionaryInstallStatus = SpellingDictionaryInstallStatus( + resourceId: resource.resourceId, + phase: SpellingDictionaryInstallPhase.failed, + receivedBytes: _dictionaryInstallStatus?.receivedBytes ?? 0, + totalBytes: resource.downloadSize, + error: error.toString(), + ); + if (!_disposed) notifyListeners(); + } rethrow; } finally { if (identical(_dictionaryDownloadCancellation, cancellation)) { @@ -495,6 +561,7 @@ final class SpellingSessionController extends ChangeNotifier { } Future retryDictionaryInstallation() async { + if (_dictionaryDownloadCancellation != null) return; final status = _dictionaryInstallStatus; if (status == null || status.phase != SpellingDictionaryInstallPhase.failed) { @@ -510,6 +577,7 @@ final class SpellingSessionController extends ChangeNotifier { Future removeDownloadedDictionary(String languageId) async { await _initializeStorage(_latestInput?.workspace ?? _settingsWorkspace); + await _refreshCatalog(); final catalog = _catalog; final resource = catalog?.availableById(languageId); final installation = resource == null @@ -551,6 +619,9 @@ final class SpellingSessionController extends ChangeNotifier { throw StateError('This workspace has no project spelling scope.'); } final projectRoot = _projectRoot; + if (projectRoot == _requestedProjectRoot) { + _projectPublicationGeneration++; + } late final SpellingWordStoreSnapshot updated; try { updated = await store.setProjectLanguage(languageId); @@ -558,17 +629,30 @@ final class SpellingSessionController extends ChangeNotifier { await _reconcileProjectStoreConflict(store, projectRoot); Error.throwWithStackTrace(error, stackTrace); } - if (!identical(store, _projectStore) || projectRoot != _projectRoot) return; + if (!identical(store, _projectStore) || + projectRoot != _projectRoot || + projectRoot != _requestedProjectRoot) { + return; + } + _projectPublicationGeneration++; _projectWords = updated; await _refreshAfterPersistentChange(); } Future _reconcilePersonalStoreConflict(SpellingWordStore store) async { if (!identical(store, _personalStore)) return; + final generation = ++_personalMutationGeneration; + final refreshGeneration = ++_personalRefreshGeneration; try { final actual = await wordStoreReader(store); - if (!identical(store, _personalStore)) return; + if (_disposed || + !identical(store, _personalStore) || + generation != _personalMutationGeneration || + refreshGeneration != _personalRefreshGeneration) { + return; + } _personalWords = actual; + _personalMutationGeneration++; await _refreshAfterPersistentChange(); } on Object { // Reconciliation is best effort; the publication conflict remains the @@ -580,10 +664,19 @@ final class SpellingSessionController extends ChangeNotifier { SpellingWordStore store, String? projectRoot, ) async { - if (!identical(store, _projectStore) || projectRoot != _projectRoot) return; + if (!identical(store, _projectStore) || + projectRoot != _projectRoot || + projectRoot != _requestedProjectRoot) { + return; + } + final generation = ++_projectPublicationGeneration; try { final actual = await wordStoreReader(store); - if (!identical(store, _projectStore) || projectRoot != _projectRoot) { + if (_disposed || + !identical(store, _projectStore) || + projectRoot != _projectRoot || + projectRoot != _requestedProjectRoot || + generation != _projectPublicationGeneration) { return; } _projectWords = actual; @@ -638,9 +731,24 @@ final class SpellingSessionController extends ChangeNotifier { _showDisabled(); return; } + SpellingWordStore? failedProjectReadStore; try { - await _initializeStorage(input.workspace); + final wasInitialized = _personalStorageInitialized; + final hadCatalog = _catalog != null; + await _initializeStorage( + input.workspace, + onProjectReadFailure: (store) => failedProjectReadStore = store, + scheduleOnProjectRecovery: false, + ); if (!_isOperationCurrent(operation, input)) return; + if (manual && hadCatalog) { + await _refreshCatalog(); + if (!_isOperationCurrent(operation, input)) return; + } + if (manual && wasInitialized) { + await _refreshPersonalWords(); + if (!_isOperationCurrent(operation, input)) return; + } final languageId = resolveSpellingLanguageId( override: override, projectLanguage: _projectWords.projectLanguage, @@ -709,12 +817,38 @@ final class SpellingSessionController extends ChangeNotifier { } } on Object catch (error) { if (!_isOperationCurrent(operation, input)) return; - _localState = SpellingPresentationState( + final previousFailure = _projectReadFailure; + final projectReadFailed = + failedProjectReadStore != null && + identical(failedProjectReadStore, _projectStore) && + _projectRoot == _requestedProjectRoot; + final previousState = + projectReadFailed && + identical(previousFailure?.store, failedProjectReadStore) + ? previousFailure!.previous + : state; + final previousUsesLocalState = + projectReadFailed && + identical(previousFailure?.store, failedProjectReadStore) + ? previousFailure!.previousUsesLocalState + : _presentationUsesLocalState; + final failure = SpellingPresentationState( status: SpellingPresentationStatus.failure, occurrences: const [], complete: false, message: error.toString(), ); + if (projectReadFailed) { + _projectReadFailure = ( + store: failedProjectReadStore!, + previous: previousState, + previousUsesLocalState: previousUsesLocalState, + ); + _localProjectReadFailureStore = failedProjectReadStore; + } else { + _localProjectReadFailureStore = null; + } + _localState = failure; _presentationUsesLocalState = true; notifyListeners(); } @@ -743,7 +877,11 @@ final class SpellingSessionController extends ChangeNotifier { ); } - Future _initializeStorage(Workspace? workspace) async { + Future _initializeStorage( + Workspace? workspace, { + void Function(SpellingWordStore store)? onProjectReadFailure, + bool scheduleOnProjectRecovery = true, + }) async { final supportRoot = await _ensureStorageRoot(); if (!_personalStorageInitialized) { final load = _personalStorageLoad ??= () async { @@ -753,7 +891,16 @@ final class SpellingSessionController extends ChangeNotifier { ); return (store: store, snapshot: await wordStoreReader(store)); }(); - final loaded = await load; + late final ({SpellingWordStore store, SpellingWordStoreSnapshot snapshot}) + loaded; + try { + loaded = await load; + } on Object { + if (identical(_personalStorageLoad, load)) { + _personalStorageLoad = null; + } + rethrow; + } if (!_disposed && !_personalStorageInitialized) { _personalStore = loaded.store; _personalWords = loaded.snapshot; @@ -764,6 +911,7 @@ final class SpellingSessionController extends ChangeNotifier { final nextProjectRoot = _projectScopeRoot(workspace); _requestedProjectRoot = nextProjectRoot; final projectGeneration = ++_projectStorageGeneration; + final publicationGeneration = ++_projectPublicationGeneration; final nextProjectStore = nextProjectRoot == null ? null : _projectStorageInitialized && _projectRoot == nextProjectRoot @@ -772,44 +920,130 @@ final class SpellingSessionController extends ChangeNotifier { filePath: p.join(nextProjectRoot, '.busymark', 'spelling.json'), projectStore: true, ); - final nextProjectWords = - (nextProjectStore == null - ? null - : await wordStoreReader(nextProjectStore)) ?? - const SpellingWordStoreSnapshot(revision: 0, wordsByLanguage: {}); + var nextProjectWords = const SpellingWordStoreSnapshot( + revision: 0, + wordsByLanguage: {}, + ); + if (nextProjectStore != null) { + try { + nextProjectWords = await wordStoreReader(nextProjectStore); + } on Object { + if (_disposed || + projectGeneration != _projectStorageGeneration || + publicationGeneration != _projectPublicationGeneration || + _requestedProjectRoot != nextProjectRoot) { + return; + } + onProjectReadFailure?.call(nextProjectStore); + rethrow; + } + } if (!_disposed && projectGeneration == _projectStorageGeneration && + publicationGeneration == _projectPublicationGeneration && _requestedProjectRoot == nextProjectRoot) { _projectRoot = nextProjectRoot; _projectStore = nextProjectStore; _projectWords = nextProjectWords; _projectStorageInitialized = true; _watchProjectStore(nextProjectRoot, nextProjectStore?.filePath); + if (nextProjectStore != null && + _recoverProjectReadFailure(nextProjectStore) && + scheduleOnProjectRecovery) { + await _refreshAfterPersistentChange(); + } } // Available metadata is shipped with BusyMark. Only application-managed // installations are opened and checksum-verified. - if (_catalog == null) { - final resourceRoot = - bundledRoot ?? const SpellingResourceLocator().locate(); - if (resourceRoot == null) { - _catalog = const SpellingDictionaryCatalog( - availableEntries: [], - installations: [], - unavailableEntries: { - 'bundle': 'Dictionary catalog metadata is unavailable.', - }, - ); - } else { - final dictionaryRoot = await _ensureDictionaryStorageRoot(); - _catalog = await SpellingDictionaryCatalog.load( - bundledRoot: resourceRoot, - downloadedRoot: p.join(dictionaryRoot, 'downloaded'), - importedRoot: p.join(dictionaryRoot, 'imported'), - verifyChecksums: verifyDictionaryChecksums, - ); + if (_catalog == null || _catalogNeedsRefresh) await _refreshCatalog(); + } + + Future _refreshCatalog({bool forceNew = false}) async { + final pending = _catalogRefresh; + if (pending != null && !forceNew) return pending; + if (pending != null) { + // A local mutation owns the next inventory publication. Invalidate the + // older read, then wait for its worker-release transaction to finish. + ++_catalogGeneration; + try { + await pending; + } on Object { + // The new read is the retry and reports its own failure. + } + } + final generation = ++_catalogGeneration; + final operation = _loadAndPublishCatalog(generation); + _catalogRefresh = operation; + try { + return await operation; + } finally { + if (identical(_catalogRefresh, operation)) _catalogRefresh = null; + } + } + + Future _loadAndPublishCatalog(int generation) async { + final resourceRoot = + bundledRoot ?? const SpellingResourceLocator().locate(); + final next = resourceRoot == null + ? const SpellingDictionaryCatalog( + availableEntries: [], + installations: [], + unavailableEntries: { + 'bundle': 'Dictionary catalog metadata is unavailable.', + }, + ) + : await _loadManagedCatalog(resourceRoot); + if (_disposed || generation != _catalogGeneration) return false; + _catalogNeedsRefresh = false; + final previous = _catalog; + final changed = + previous == null || + _catalogInventoryIdentity(previous) != _catalogInventoryIdentity(next); + _catalog = next; + final context = _engineContext; + if (changed && context != null) { + final oldInstallation = previous?.installedById(context.languageId); + final newInstallation = next.installedById(context.languageId); + if (oldInstallation?.fingerprint != newInstallation?.fingerprint || + oldInstallation?.directoryPath != newInstallation?.directoryPath) { + _coordinator?.disablePresentation(); + await _coordinator?.releaseDictionary(); + _engineContext = null; + _contextIdentity = ''; } } + if (changed && !_disposed) notifyListeners(); + return changed; + } + + Future _loadManagedCatalog( + String resourceRoot, + ) async { + final dictionaryRoot = await _ensureDictionaryStorageRoot(); + return catalogLoader( + bundledRoot: resourceRoot, + downloadedRoot: p.join(dictionaryRoot, 'downloaded'), + importedRoot: p.join(dictionaryRoot, 'imported'), + verifyChecksums: verifyDictionaryChecksums, + ); + } + + Future _refreshPersonalWords() async { + final store = _personalStore; + if (_disposed || store == null) return false; + final mutationGeneration = _personalMutationGeneration; + final refreshGeneration = ++_personalRefreshGeneration; + final snapshot = await wordStoreReader(store); + if (_disposed || + !identical(store, _personalStore) || + mutationGeneration != _personalMutationGeneration || + refreshGeneration != _personalRefreshGeneration || + _sameWordStoreSnapshot(snapshot, _personalWords)) { + return false; + } + _personalWords = snapshot; + return true; } Future _ensureStorageRoot() async { @@ -879,8 +1113,7 @@ final class SpellingSessionController extends ChangeNotifier { } Future _reloadCatalogAndRefresh() async { - _catalog = null; - await _initializeStorage(_latestInput?.workspace ?? _settingsWorkspace); + await _refreshCatalog(forceNew: true); if (!_disposed) notifyListeners(); await _refreshAfterPersistentChange(); } @@ -1008,35 +1241,95 @@ final class SpellingSessionController extends ChangeNotifier { Future _reloadProjectWordsFromDisk() async { final store = _projectStore; final root = _projectRoot; - if (_disposed || store == null || root == null) return; + if (_disposed || + store == null || + root == null || + root != _requestedProjectRoot) { + return; + } + final generation = ++_projectPublicationGeneration; + var readCompleted = false; try { final snapshot = await wordStoreReader(store); + readCompleted = true; if (_disposed || !identical(store, _projectStore) || - root != _projectRoot) { + root != _projectRoot || + root != _requestedProjectRoot || + generation != _projectPublicationGeneration) { + return; + } + final recovering = _recoverProjectReadFailure(store); + if (_sameWordStoreSnapshot(snapshot, _projectWords) && !recovering) { return; } - if (_sameWordStoreSnapshot(snapshot, _projectWords)) return; _projectWords = snapshot; await _refreshAfterPersistentChange(); } on Object catch (error) { if (_disposed || !identical(store, _projectStore) || - root != _projectRoot) { + root != _projectRoot || + root != _requestedProjectRoot || + generation != _projectPublicationGeneration) { return; } - _localState = SpellingPresentationState( + final previousFailure = _projectReadFailure; + final repeatedFailure = + previousFailure != null && + identical(previousFailure.store, store) && + _presentationUsesLocalState && + identical(_localProjectReadFailureStore, store) && + _localState.status == SpellingPresentationStatus.failure; + final previousState = repeatedFailure ? previousFailure.previous : state; + final previousUsesLocalState = repeatedFailure + ? previousFailure.previousUsesLocalState + : _presentationUsesLocalState; + final failure = SpellingPresentationState( status: SpellingPresentationStatus.failure, occurrences: const [], complete: false, message: error.toString(), ); + _projectReadFailure = readCompleted + ? null + : ( + store: store, + previous: previousState, + previousUsesLocalState: previousUsesLocalState, + ); + _localState = failure; + _localProjectReadFailureStore = readCompleted ? null : store; _presentationUsesLocalState = true; notifyListeners(); } } - void _forwardCoordinatorChange() => notifyListeners(); + bool _recoverProjectReadFailure(SpellingWordStore store) { + final failedRead = _projectReadFailure; + if (failedRead == null || !identical(failedRead.store, store)) { + return false; + } + _projectReadFailure = null; + final recovering = + _presentationUsesLocalState && + identical(_localProjectReadFailureStore, store) && + _localState.status == SpellingPresentationStatus.failure; + if (recovering) { + _localState = failedRead.previous; + _presentationUsesLocalState = failedRead.previousUsesLocalState; + _localProjectReadFailureStore = null; + } + return recovering; + } + + void _forwardCoordinatorChange() { + if (_coordinator?.dictionaryLoadFailed ?? false) { + // Retry inventory discovery on the next automatic check. The worker + // could not open the installation that the cached catalog described. + _catalogNeedsRefresh = true; + } + notifyListeners(); + } void _showLanguageRequired() { if (_coordinator case final coordinator?) { @@ -1094,6 +1387,7 @@ final class SpellingSessionController extends ChangeNotifier { void _invalidatePresentation() { _engineContext = null; _presentationUsesLocalState = true; + _localProjectReadFailureStore = null; _localState = const SpellingPresentationState( status: SpellingPresentationStatus.checking, occurrences: [], @@ -1237,3 +1531,16 @@ String? _projectScopeRoot(Workspace? workspace) => switch (workspace?.kind) { WorkspaceKind.writersideModule => workspace!.rootPath, _ => null, }; + +String _catalogInventoryIdentity(SpellingDictionaryCatalog catalog) { + final entries = [ + for (final installation in catalog.installations) + 'installed:${installation.directoryPath}:${installation.kind.name}:' + '${installation.resourceId}:${installation.id}:' + '${installation.sourceRevision}:${installation.fingerprint}', + for (final invalid in catalog.invalidInstallations) + 'invalid:${invalid.directoryPath}:${invalid.kind.name}:' + '${invalid.sourceIdentity ?? ''}', + ]..sort(); + return entries.join('\n'); +} diff --git a/lib/src/spellcheck/spelling_worker.dart b/lib/src/spellcheck/spelling_worker.dart index 19cbf059..2481a8dd 100644 --- a/lib/src/spellcheck/spelling_worker.dart +++ b/lib/src/spellcheck/spelling_worker.dart @@ -99,6 +99,7 @@ final class SpellingWorkerResult { required this.cancelled, required this.complete, this.error, + this.dictionaryLoadFailed = false, }); final int requestId; @@ -106,6 +107,7 @@ final class SpellingWorkerResult { final bool cancelled; final bool complete; final String? error; + final bool dictionaryLoadFailed; } /// One reusable spelling isolate. It owns the only active native handle and @@ -200,6 +202,7 @@ final class SpellingWorker { cancelled: message['cancelled'] == true, complete: message['complete'] == true, error: message['error']?.toString(), + dictionaryLoadFailed: message['dictionaryLoadFailed'] == true, ); } @@ -436,9 +439,11 @@ final class _WorkerRuntime { final occurrences = >[]; var complete = true; String? firstError; + var dictionaryOpened = false; try { final context = (request['context'] as Map).cast(); final dictionary = _ensureDictionary(context); + dictionaryOpened = true; final language = context['languageId'].toString(); final runs = request['runs'] as List; var wordsSinceYield = 0; @@ -608,6 +613,7 @@ final class _WorkerRuntime { 'cancelled': false, 'complete': false, 'error': error.toString(), + if (!dictionaryOpened) 'dictionaryLoadFailed': true, }); } } diff --git a/lib/src/spellcheck/writerside_spelling_projection.dart b/lib/src/spellcheck/writerside_spelling_projection.dart index 557a79d3..1dc20a92 100644 --- a/lib/src/spellcheck/writerside_spelling_projection.dart +++ b/lib/src/spellcheck/writerside_spelling_projection.dart @@ -4,6 +4,7 @@ import '../writerside/writerside_document.dart'; import '../writerside/writerside_document_parser.dart'; import '../writerside/writerside_schema.dart'; import 'spelling_projection.dart'; +import 'spelling_text_patterns.dart'; final class WritersideXmlSpellingProjector { const WritersideXmlSpellingProjector({ @@ -37,7 +38,13 @@ final class WritersideXmlSpellingProjector { } builder.flush(); return SpellingProjectionResult( - runs: List.unmodifiable(builder.runs), + runs: List.unmodifiable( + builder.runs..sort( + (left, right) => left.atoms.first.sourceStart.compareTo( + right.atoms.first.sourceStart, + ), + ), + ), complete: builder.complete, message: builder.complete ? null @@ -134,10 +141,15 @@ final class _XmlProjectionBuilder { final value = element.attributes[name]; final span = element.attributeSpans[name]; if (value == null || span == null || value.trim().isEmpty) continue; - flush(); final quoteOffset = span.startOffset - 1; final quote = quoteOffset >= 0 ? source[quoteOffset] : '"'; - _appendEncoded( + final attribute = _XmlProjectionBuilder( + filePath: filePath, + source: source, + languageId: languageId, + snapshot: snapshot, + ); + attribute._appendEncoded( raw: source.substring(span.startOffset, span.endOffset), sourceStart: span.startOffset, expected: value, @@ -145,7 +157,22 @@ final class _XmlProjectionBuilder { ? SpellingSourceContext.xmlSingleQuotedAttribute : SpellingSourceContext.xmlDoubleQuotedAttribute, ); - flush(); + attribute.flush(); + complete = complete && attribute.complete; + for (final run in attribute.runs) { + runs.add( + SpellingProseRun( + id: 'writerside-xml:${_sequence++}', + text: run.text, + languageId: languageId, + atoms: run.atoms, + target: run.target, + snapshot: snapshot, + tokenizationContext: run.tokenizationContext, + tokenizationContextStart: run.tokenizationContextStart, + ), + ); + } } } @@ -204,18 +231,13 @@ final class _XmlProjectionBuilder { ); cursor += width; } - final variables = [ - for (final match in _writersideVariable.allMatches( - decodedText.toString(), - )) - if (match.group(1) == null) (start: match.start, end: match.end), - ]; + final excluded = _excludedTextIntervals(decodedText.toString()); for (final unit in units) { - final variable = variables.any( + final skip = excluded.any( (range) => range.start < unit.logicalEnd && range.end > unit.logicalStart, ); - if (variable) { + if (skip) { _barrier(); continue; } @@ -231,19 +253,18 @@ final class _XmlProjectionBuilder { void _appendCdata(String content, int sourceStart) { var cursor = 0; - for (final variable in _writersideVariable.allMatches(content)) { - if (variable.group(1) != null) continue; - if (variable.start > cursor) { + for (final excluded in _excludedTextIntervals(content)) { + if (excluded.start > cursor) { _emit( - content.substring(cursor, variable.start), + content.substring(cursor, excluded.start), sourceStart + cursor, - sourceStart + variable.start, + sourceStart + excluded.start, SpellingTransformationKind.xmlCdata, SpellingSourceContext.xmlCdata, ); } _barrier(); - cursor = variable.end; + cursor = excluded.end; } if (cursor < content.length) { _emit( @@ -256,6 +277,28 @@ final class _XmlProjectionBuilder { } } + List<({int start, int end})> _excludedTextIntervals(String text) { + final intervals = <({int start, int end})>[ + for (final match in _writersideVariable.allMatches(text)) + if (match.group(1) == null) (start: match.start, end: match.end), + for (final match in spellingPlainAddress.allMatches(text)) + (start: match.start, end: match.end), + ]..sort((left, right) => left.start.compareTo(right.start)); + final merged = <({int start, int end})>[]; + for (final interval in intervals) { + if (merged.isNotEmpty && interval.start <= merged.last.end) { + final previous = merged.removeLast(); + merged.add(( + start: previous.start, + end: interval.end > previous.end ? interval.end : previous.end, + )); + } else { + merged.add(interval); + } + } + return merged; + } + void _emit( String logical, int sourceStart, diff --git a/lib/src/spellcheck/wysiwyg_spelling_projection.dart b/lib/src/spellcheck/wysiwyg_spelling_projection.dart index ded1eada..eb34e664 100644 --- a/lib/src/spellcheck/wysiwyg_spelling_projection.dart +++ b/lib/src/spellcheck/wysiwyg_spelling_projection.dart @@ -19,6 +19,42 @@ final class WysiwygSpellingProjector { final fieldKeysByTarget = {}; var sequence = 0; var complete = true; + final source = document.source; + final sourceProjection = source == null + ? null + : const MarkdownSpellingProjector().project( + filePath: document.filePath, + source: source, + mode: document.mode, + languageId: languageId, + snapshot: snapshot, + ); + var imageIndex = 0; + + SpellingMappedImageDescription? imageDescription( + BusyInline image, + SpellingMappedImageDescription? parent, + ) { + if (sourceProjection == null) return null; + if (imageIndex >= sourceProjection.imageDescriptions.length) { + complete = false; + return null; + } + final description = sourceProjection.imageDescriptions[imageIndex++]; + if (description.alternativeText != image.text || + !_sameMappedImageDestination( + source, + description, + image.destination, + ) || + (parent != null && + (description.sourceStart <= parent.sourceStart || + description.sourceEnd >= parent.sourceEnd))) { + complete = false; + return null; + } + return description; + } void addOrdinary(BusyBlock block, SpellingEditorTarget target) { for (final projection in projectSpellingInlineRuns( @@ -27,6 +63,7 @@ final class WysiwygSpellingProjector { context: target is SpellingRichTableCellTarget ? SpellingSourceContext.markdownTableCell : SpellingSourceContext.markdownProse, + imageDescriptionFor: imageDescription, )) { final run = SpellingProseRun( id: 'wysiwyg:${sequence++}:${block.id}', @@ -59,7 +96,31 @@ final class WysiwygSpellingProjector { : SpellingSourceContext.markdownProse, ); complete = complete && projected.complete; + if (sourceProjection != null) { + for (final fieldImage in projected.imageDescriptions) { + if (imageIndex >= sourceProjection.imageDescriptions.length) { + complete = false; + break; + } + final authoredImage = + sourceProjection.imageDescriptions[imageIndex++]; + if (authoredImage.alternativeText != fieldImage.alternativeText || + !_sameMappedImageDestination( + source, + authoredImage, + fieldImage.destination, + ) || + (block.sourceSpan != null && + (authoredImage.sourceStart < block.sourceSpan!.startOffset || + authoredImage.sourceEnd > block.sourceSpan!.endOffset))) { + complete = false; + } + } + } for (final sourceRun in projected.runs) { + // Link/image titles belong to authored source, not the editable math + // field, even when that field displays the surrounding Markdown. + if (_sourceOnlyMetadataRun(sourceRun)) continue; richRuns.add( SpellingProseRun( id: 'wysiwyg-math:${sequence++}:${block.id}', @@ -153,7 +214,6 @@ final class WysiwygSpellingProjector { for (final (index, block) in document.blocks.indexed) { visit(block, [index]); } - final source = document.source; if (source == null) { return SpellingProjectionResult( runs: List.unmodifiable(richRuns), @@ -162,13 +222,6 @@ final class WysiwygSpellingProjector { ); } - final sourceProjection = const MarkdownSpellingProjector().project( - filePath: document.filePath, - source: source, - mode: document.mode, - languageId: languageId, - snapshot: snapshot, - ); final currentDocument = const MarkdownParser() .parse( filePath: document.filePath, @@ -179,7 +232,7 @@ final class WysiwygSpellingProjector { .busyDocument; var merged = _mergeCurrentSourceMappings( richRuns: richRuns, - sourceRuns: sourceProjection.runs, + sourceRuns: sourceProjection!.runs, fieldKeysByTarget: fieldKeysByTarget, sourceRegions: _sourceFieldRegions(source, currentDocument.blocks), ); @@ -213,6 +266,34 @@ final class WysiwygSpellingProjector { } } +bool _sameMappedImageDestination( + String? source, + SpellingMappedImageDescription description, + String? richDestination, +) { + final mappedDestination = description.destination; + if (mappedDestination == richDestination) return true; + if (source == null || + mappedDestination == null || + richDestination == null || + description.sourceStart < 0 || + description.sourceEnd > source.length || + !source + .substring(description.sourceStart, description.sourceEnd) + .contains(RegExp(r'\r|\n')) || + !richDestination.endsWith(mappedDestination)) { + return false; + } + // The block AST can retain a continuation's indentation as URI-encoded + // leading whitespace; the positioned inline parse removes those structural + // bytes. Permit only that precise prefix discrepancy on multiline images. + final encodedPrefix = richDestination.substring( + 0, + richDestination.length - mappedDestination.length, + ); + return RegExp(r'^(?:%20|%09)+$').hasMatch(encodedPrefix); +} + final class _MergedRichProjection { const _MergedRichProjection({ required this.runs, @@ -470,6 +551,7 @@ bool _sourceOnlyMetadataRun(SpellingProseRun run) { (atom) => switch (atom.context) { SpellingSourceContext.markdownSingleQuotedTitle || SpellingSourceContext.markdownDoubleQuotedTitle || + SpellingSourceContext.markdownParenthesizedTitle || SpellingSourceContext.xmlSingleQuotedAttribute || SpellingSourceContext.xmlDoubleQuotedAttribute => true, _ => false, @@ -494,21 +576,48 @@ List? _mergeRunAtoms( final result = []; var richCursor = 0; for (final sourceAtom in source.atoms) { - while (richCursor < rich.atoms.length && - rich.atoms[richCursor].logicalEnd <= sourceAtom.logicalStart) { - richCursor++; - } - if (richCursor >= rich.atoms.length) return null; - final richAtom = rich.atoms[richCursor]; - if (richAtom.logicalStart > sourceAtom.logicalStart || - richAtom.logicalEnd < sourceAtom.logicalEnd) { - return null; + int? fieldStart; + int? fieldEnd; + List? richLeafPath; + var pathInitialized = false; + var logicalCursor = sourceAtom.logicalStart; + while (logicalCursor < sourceAtom.logicalEnd) { + while (richCursor < rich.atoms.length && + rich.atoms[richCursor].logicalEnd <= logicalCursor) { + richCursor++; + } + if (richCursor >= rich.atoms.length) return null; + final richAtom = rich.atoms[richCursor]; + if (richAtom.logicalStart > logicalCursor) return null; + final segmentEnd = richAtom.logicalEnd < sourceAtom.logicalEnd + ? richAtom.logicalEnd + : sourceAtom.logicalEnd; + if (sourceAtom.logicalText.substring( + logicalCursor - sourceAtom.logicalStart, + segmentEnd - sourceAtom.logicalStart, + ) != + richAtom.logicalText.substring( + logicalCursor - richAtom.logicalStart, + segmentEnd - richAtom.logicalStart, + )) { + return null; + } + final segment = richAtom.fieldIntervalFor(logicalCursor, segmentEnd); + if (segment == null || + (fieldEnd != null && fieldEnd != segment.start) || + (pathInitialized && + !_sameLeafPath(richLeafPath, richAtom.richLeafPath))) { + return null; + } + fieldStart ??= segment.start; + fieldEnd = segment.end; + if (!pathInitialized) { + richLeafPath = richAtom.richLeafPath; + pathInitialized = true; + } + logicalCursor = segmentEnd; } - final fieldRange = richAtom.fieldIntervalFor( - sourceAtom.logicalStart, - sourceAtom.logicalEnd, - ); - if (fieldRange == null) return null; + if (fieldStart == null || fieldEnd == null) return null; result.add( SpellingSourceAtom( logicalText: sourceAtom.logicalText, @@ -516,9 +625,9 @@ List? _mergeRunAtoms( logicalEnd: sourceAtom.logicalEnd, sourceStart: sourceAtom.sourceStart, sourceEnd: sourceAtom.sourceEnd, - fieldStart: fieldRange.start, - fieldEnd: fieldRange.end, - richLeafPath: richAtom.richLeafPath, + fieldStart: fieldStart, + fieldEnd: fieldEnd, + richLeafPath: richLeafPath, transformation: sourceAtom.transformation, context: sourceAtom.context, ), @@ -527,6 +636,15 @@ List? _mergeRunAtoms( return List.unmodifiable(result); } +bool _sameLeafPath(List? left, List? right) { + if (left == null || right == null) return left == null && right == null; + if (left.length != right.length) return false; + for (var index = 0; index < left.length; index++) { + if (left[index] != right[index]) return false; + } + return true; +} + List _mergeFormattingWrappers( List source, List field, diff --git a/lib/src/workspace/presentation/settings_screen.dart b/lib/src/workspace/presentation/settings_screen.dart index 1fea4ab9..a933b353 100644 --- a/lib/src/workspace/presentation/settings_screen.dart +++ b/lib/src/workspace/presentation/settings_screen.dart @@ -48,7 +48,12 @@ class SettingsScreen extends ConsumerStatefulWidget { class _SettingsScreenState extends ConsumerState { late SettingsPage _page = widget.initialPage; String? _preparedSpellingWorkspaceId; + bool _spellingPreparationSucceeded = false; + String? _preparationWorkspaceId; + SpellingSessionController? _preparationController; bool _preparingSpelling = false; + Object? _spellingPreparationError; + int _spellingPreparationGeneration = 0; @override void didUpdateWidget(covariant SettingsScreen oldWidget) { @@ -116,6 +121,8 @@ class _SettingsScreenState extends ConsumerState { title: l10n.editor, filled: true, children: [ + if (_spellingPreparationError != null) + _spellingPreparationFailureRow(l10n), BusyMarkSwitchRow( title: l10n.autoSave, subtitle: l10n.autoSaveDescription, @@ -224,6 +231,8 @@ class _SettingsScreenState extends ConsumerState { title: l10n.availableSpellingDictionaries, filled: true, children: [ + if (_spellingPreparationError != null) + _spellingPreparationFailureRow(l10n), for (final resource in spellingResources) _SpellingDictionaryResourceRow( resource: resource, @@ -528,25 +537,63 @@ class _SettingsScreenState extends ConsumerState { SpellingSessionController spelling, String? workspaceId, ) { + if (_preparationWorkspaceId != workspaceId || + !identical(_preparationController, spelling)) { + _preparationWorkspaceId = workspaceId; + _preparationController = spelling; + _preparedSpellingWorkspaceId = null; + _spellingPreparationSucceeded = false; + _spellingPreparationError = null; + _preparingSpelling = false; + _spellingPreparationGeneration++; + } if (_preparingSpelling || - (_preparedSpellingWorkspaceId == workspaceId && + _spellingPreparationError != null || + (_spellingPreparationSucceeded && + _preparedSpellingWorkspaceId == workspaceId && spelling.catalog != null)) { return; } _preparingSpelling = true; + final generation = ++_spellingPreparationGeneration; WidgetsBinding.instance.addPostFrameCallback((_) async { + if (!mounted || generation != _spellingPreparationGeneration) return; try { await spelling.prepareSettings( ref.read(workspaceControllerProvider).workspace, ); - _preparedSpellingWorkspaceId = workspaceId; + if (mounted && generation == _spellingPreparationGeneration) { + _preparedSpellingWorkspaceId = workspaceId; + _spellingPreparationSucceeded = true; + } + } on Object catch (error) { + if (mounted && generation == _spellingPreparationGeneration) { + _spellingPreparationError = error; + } } finally { - _preparingSpelling = false; - if (mounted) setState(() {}); + if (mounted && generation == _spellingPreparationGeneration) { + _preparingSpelling = false; + setState(() {}); + } } }); } + Widget _spellingPreparationFailureRow(AppLocalizations l10n) => + BusyMarkActionRow( + key: const ValueKey('retry-spelling-settings'), + title: l10n.spellingSettingsLoadFailed, + subtitle: _spellingPreparationError.toString(), + leading: const Icon(BusyMarkGlyphs.warning), + trailing: const Icon(BusyMarkGlyphs.refresh), + tooltip: l10n.retrySpellingSettings, + onTap: () => setState(() { + _spellingPreparationError = null; + _preparedSpellingWorkspaceId = null; + _spellingPreparationSucceeded = false; + }), + ); + void _goBack() { if (_page == SettingsPage.spellingDictionaries) { _selectPage(SettingsPage.editor); diff --git a/lib/src/workspace/presentation/workspace_screen.dart b/lib/src/workspace/presentation/workspace_screen.dart index 7efa3de5..bcc4f6e8 100644 --- a/lib/src/workspace/presentation/workspace_screen.dart +++ b/lib/src/workspace/presentation/workspace_screen.dart @@ -12907,7 +12907,7 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { final richEditor = _wysiwygEditorKey.currentState; final source = occurrence.run.target is SpellingSourceTarget ? sourceEditor?.spellingSourceSnapshot(occurrence) - : null; + : richEditor?.spellingSourceSnapshot(occurrence); final field = occurrence.run.target is SpellingSourceTarget ? null : richEditor?.spellingFieldSnapshot(occurrence); @@ -12941,6 +12941,8 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { plan: plan, expectedFieldText: field!, preparedFieldText: prepared.replacementField, + expectedSource: source, + preparedSource: prepared.replacementSource, ) ?? false; if (!applied) return null; diff --git a/packages/busymark_spellcheck_native/lib/src/native_dictionary.dart b/packages/busymark_spellcheck_native/lib/src/native_dictionary.dart index a979ea7d..cc252c53 100644 --- a/packages/busymark_spellcheck_native/lib/src/native_dictionary.dart +++ b/packages/busymark_spellcheck_native/lib/src/native_dictionary.dart @@ -95,6 +95,7 @@ final class NativeSpellDictionary { NativeSpellResult check(String word) { _requireOpen(); + _rejectEmbeddedNul(word); final nativeWord = word.toNativeUtf8(); final outError = calloc>(); try { @@ -119,6 +120,7 @@ final class NativeSpellDictionary { List suggest(String word) { _requireOpen(); + _rejectEmbeddedNul(word); final nativeWord = word.toNativeUtf8(); final outItems = calloc>>(); final outCount = calloc(); @@ -153,6 +155,7 @@ final class NativeSpellDictionary { void add(String word) { _requireOpen(); + _rejectEmbeddedNul(word); final nativeWord = word.toNativeUtf8(); final outError = calloc>(); try { @@ -173,6 +176,8 @@ final class NativeSpellDictionary { List tokenize(String prose, {required String language}) { _requireOpen(); + _rejectEmbeddedNul(prose); + _rejectEmbeddedNul(language); final nativeProse = prose.toNativeUtf8(); final nativeLanguage = language.toNativeUtf8(); final outRanges = calloc>(); @@ -239,6 +244,12 @@ final class NativeSpellDictionary { } } +void _rejectEmbeddedNul(String value) { + if (value.contains('\u0000')) { + throw const NativeSpellException('Input contains an embedded NUL.'); + } +} + /// Explicit UTF-8 byte-to-Dart UTF-16 mapping for offsets returned by Pango. List nativeUtf8BoundaryToUtf16(String text) { final mapping = List.filled(utf8.encode(text).length + 1, -1); diff --git a/packages/busymark_spellcheck_native/src/busymark_spellcheck_native.cpp b/packages/busymark_spellcheck_native/src/busymark_spellcheck_native.cpp index dfc7d240..6f0b87b9 100644 --- a/packages/busymark_spellcheck_native/src/busymark_spellcheck_native.cpp +++ b/packages/busymark_spellcheck_native/src/busymark_spellcheck_native.cpp @@ -597,7 +597,7 @@ int busy_spell_tokenize(BusySpellHandle* handle, std::vector merged; for (const auto candidate : candidates) { if (!merged.empty() && - (candidate.start <= merged.back().end || + (candidate.start < merged.back().end || separator_can_join(handle, prose_utf8, offsets, merged.back().end, candidate.start))) { merged.back().end = std::max(merged.back().end, candidate.end); diff --git a/packages/busymark_spellcheck_native/test/busymark_spellcheck_native_test.dart b/packages/busymark_spellcheck_native/test/busymark_spellcheck_native_test.dart index b2a37bb8..955aa630 100644 --- a/packages/busymark_spellcheck_native/test/busymark_spellcheck_native_test.dart +++ b/packages/busymark_spellcheck_native/test/busymark_spellcheck_native_test.dart @@ -55,6 +55,52 @@ void main() { expect(words, ['helo', 'café', 'don’t', 'state-of-the-art']); }); + test('keeps touching Thai word boundaries separate', () { + const prose = 'ภาษาไทยภาษาไทย'; + final ranges = dictionary.tokenize(prose, language: 'th-TH'); + final utf16 = nativeUtf8BoundaryToUtf16(prose); + expect( + [ + for (final range in ranges) + prose.substring(utf16[range.utf8Start], utf16[range.utf8End]), + ], + ['ภาษา', 'ไทย', 'ภาษา', 'ไทย'], + ); + dictionary.add('ภาษา'); + dictionary.add('ไทย'); + for (final range in ranges) { + expect( + dictionary.check( + prose.substring(utf16[range.utf8Start], utf16[range.utf8End]), + ), + NativeSpellResult.accepted, + ); + } + }); + + test('rejects initial and embedded NUL at every string boundary', () { + for (final value in ['\u0000hello', 'hello\u0000wrld']) { + expect( + () => dictionary.check(value), + throwsA(isA()), + ); + expect( + () => dictionary.suggest(value), + throwsA(isA()), + ); + expect(() => dictionary.add(value), throwsA(isA())); + expect( + () => dictionary.tokenize(value, language: 'en-US'), + throwsA(isA()), + ); + expect( + () => dictionary.tokenize('hello', language: value), + throwsA(isA()), + ); + } + expect(dictionary.check('hello'), NativeSpellResult.accepted); + }); + test('dictionary word characters do not absorb quotation punctuation', () { const prose = "C++ C# +lead trail# -dash dash- 'quoted' ‘curly’ -hyphen- " diff --git a/test/src/app_smoke_test.dart b/test/src/app_smoke_test.dart index 52eeae86..4a6b949f 100644 --- a/test/src/app_smoke_test.dart +++ b/test/src/app_smoke_test.dart @@ -14254,6 +14254,19 @@ Future<_SettingsSpellingHarness> _pumpSettingsSpellingHarness( startupPathProvider.overrideWithValue(workspace!.rootPath), ], ); + final preparedWorkspace = workspace; + if (preparedWorkspace != null) { + await tester.runAsync( + () => container + .read(workspaceControllerProvider.notifier) + .openPath(preparedWorkspace.rootPath), + ); + expect( + container.read(workspaceControllerProvider).workspace?.rootPath, + preparedWorkspace.rootPath, + ); + } + container.read(appRouterProvider).go('/settings?page=editor'); await tester.pumpWidget( UncontrolledProviderScope(container: container, child: const BusyMarkApp()), ); @@ -14268,6 +14281,12 @@ Future<_SettingsSpellingHarness> _pumpSettingsSpellingHarness( } final l10n = AppLocalizationsEn(); await _pumpUntilFound(tester, find.byTooltip(l10n.defaultSpellingLanguage)); + if (preparedWorkspace != null) { + expect( + container.read(workspaceControllerProvider).workspace?.rootPath, + preparedWorkspace.rootPath, + ); + } return _SettingsSpellingHarness( temporary: temporary, diff --git a/test/src/markdown_source_map_test.dart b/test/src/markdown_source_map_test.dart index c68adb52..872eb86a 100644 --- a/test/src/markdown_source_map_test.dart +++ b/test/src/markdown_source_map_test.dart @@ -9,6 +9,37 @@ import 'package:markdown/markdown.dart' as md; void main() { const mapper = MarkdownSourceMapper(); + test( + 'mapped image-description text follows Markdown image alt semantics', + () { + for (final (source, expectedAlt, expectedInline) in [ + ('hello `wrld`\\\nagain', 'hello wrldagain', 'hello wrld\nagain'), + ('hello `wrld` \nagain', 'hello wrldagain', 'hello wrld\nagain'), + ('hello `wrld`\nagain', 'hello wrld\nagain', 'hello wrld\nagain'), + ('hello `wrld\nagain`', 'hello wrld again', 'hello wrld again'), + ('hello `wrld`
again', 'hello wrld
again', 'hello wrld\nagain'), + ( + 'hello **wrld** ![alt](inner.png)', + 'hello wrld alt', + 'hello wrld alt', + ), + ]) { + final mapped = mapper + .createInlineParserContext( + documentSource: source, + mode: MarkdownMode.commonMark, + ) + .parseMapped(source); + expect(mapped.imageDescriptionText, expectedAlt, reason: source); + expect( + mapped.inlines.map((inline) => inline.plainText).join(), + expectedInline, + reason: source, + ); + } + }, + ); + test('annotated parsing has ordinary semantics across supported corpus', () { const fixtures = [ 'plain text and whitespace', @@ -50,6 +81,150 @@ void main() { } }); + test('image ranges identify only resolved source occurrences', () { + for (final source in [ + '![helo][wrld] ![**helo**](image.png)', + '![**helo**](image.png) ![helo][wrld]', + ]) { + final context = mapper.createInlineParserContext( + documentSource: source, + mode: MarkdownMode.commonMark, + ); + final mapped = context.parsePositionedBlocks(source).single; + final images = mapped.ranges.entries + .where((entry) => entry.key.kind == BusyInlineKind.image) + .toList(); + expect(images, hasLength(1)); + final imageStart = source.indexOf('![**helo**]'); + final range = images.single.value; + expect(range.start, imageStart); + expect(range.end, imageStart + '![**helo**](image.png)'.length); + expect(range.labelStart, imageStart + 2); + expect(range.labelEnd, imageStart + 10); + } + }); + + test('positioned label breaks retain raw endings and container prefixes', () { + for (final source in [ + '> - [helo\r\n> wrld](target)\r\n', + '> - ![helo\r\n> wrld](target)\r\n', + ]) { + final mapped = mapper + .createInlineParserContext( + documentSource: source, + mode: MarkdownMode.commonMark, + ) + .parsePositionedBlocks(source) + .single; + final label = mapped.ranges.entries + .singleWhere( + (entry) => + entry.key.kind == BusyInlineKind.link || + entry.key.kind == BusyInlineKind.image, + ) + .value; + final lineBreak = mapped.positionedLineBreaks.singleWhere( + (entry) => + entry.sourceOffset! > label.labelStart! && + entry.sourceOffset! < label.labelEnd!, + ); + expect(lineBreak.sourceOffset, source.indexOf('\r\n')); + expect(lineBreak.lineEnding, '\r\n'); + expect(lineBreak.continuationPrefix, '> '); + expect( + source.substring( + lineBreak.sourceOffset!, + lineBreak.sourceOffset! + + lineBreak.lineEnding.length + + lineBreak.continuationPrefix.length, + ), + '\r\n> ', + ); + } + }); + + test('mapped link and image titles retain exact raw boundaries', () { + for (final (source, kind, delimiter) in [ + ('[hello](diagrm.md (wrld))', BusyInlineKind.link, '('), + ('[hello](diagrm.md "wrld)")', BusyInlineKind.link, '"'), + ('![hello](diagrm.png (wrld))', BusyInlineKind.image, '('), + ( + '> - [hello](\n> diagrm.md\n> "wrld"\n> )\n', + BusyInlineKind.link, + '"', + ), + ]) { + final context = mapper.createInlineParserContext( + documentSource: source, + mode: MarkdownMode.commonMark, + ); + final ranges = [ + for (final mapped in context.parsePositionedBlocks(source)) + for (final entry in mapped.ranges.entries) + if (entry.key.kind == kind) entry.value, + ]; + expect(ranges, hasLength(1), reason: source); + final range = ranges.single; + expect( + source + .substring(range.start, range.end) + .startsWith(kind == BusyInlineKind.image ? '![hello]' : '[hello]'), + isTrue, + reason: source, + ); + expect(source.substring(range.labelStart!, range.labelEnd!), 'hello'); + expect( + source.substring(range.titleStart!, range.titleEnd!), + source.contains('"wrld)"') ? 'wrld)' : 'wrld', + ); + expect(range.titleDelimiter, delimiter); + expect(range.end, source.lastIndexOf(')') + 1); + } + }); + + test('destination-only links and images have no mapped title', () { + for (final (source, kind, destination) in [ + ('[hello]( (wrld))', BusyInlineKind.link, '(wrld)'), + ('![hello]( (wrld))', BusyInlineKind.image, '(wrld)'), + ('[hello]( (wrld) )', BusyInlineKind.link, '(wrld)'), + ('[hello](\n(wrld)\n)', BusyInlineKind.link, '(wrld)'), + ('[hello](\r\n(wrld)\r\n)', BusyInlineKind.link, '(wrld)'), + ('[hello]( ("wrld"))', BusyInlineKind.link, '(%22wrld%22)'), + ("![hello]( ('wrld'))", BusyInlineKind.image, "('wrld')"), + ('[hello]( (wrld) "")', BusyInlineKind.link, '(wrld)'), + ]) { + final context = mapper.createInlineParserContext( + documentSource: source, + mode: MarkdownMode.commonMark, + ); + final parsed = context + .parse(source) + .where((inline) => inline.kind == kind); + expect(parsed, hasLength(1), reason: source); + expect(parsed.single.destination, destination, reason: source); + expect( + parsed.single.attributes.containsKey('title'), + isFalse, + reason: source, + ); + final matches = [ + for (final mapped in context.parsePositionedBlocks(source)) + for (final entry in mapped.ranges.entries) + if (entry.key.kind == kind) entry, + ]; + expect(matches, hasLength(1), reason: source); + final inline = matches.single.key; + final range = matches.single.value; + expect(inline.destination, destination, reason: source); + expect(inline.attributes.containsKey('title'), isFalse, reason: source); + expect(source.substring(range.start, range.end), source); + expect(source.substring(range.labelStart!, range.labelEnd!), 'hello'); + expect(range.titleStart, isNull, reason: source); + expect(range.titleEnd, isNull, reason: source); + expect(range.titleDelimiter, isNull, reason: source); + } + }); + test('position records do not change HTML or link semantics', () { const marker = '\ue001'; const source = diff --git a/test/src/spellcheck_projection_test.dart b/test/src/spellcheck_projection_test.dart index 4cf6f840..cae539d0 100644 --- a/test/src/spellcheck_projection_test.dart +++ b/test/src/spellcheck_projection_test.dart @@ -13,6 +13,7 @@ import 'package:busymark/src/spellcheck/writerside_spelling_projection.dart'; import 'package:busymark/src/spellcheck/wysiwyg_spelling_projection.dart'; import 'package:busymark/src/workspace/workspace_model.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:markdown/markdown.dart' as md; const _snapshot = SpellingSnapshotIdentity( bufferId: 'buffer-1', @@ -34,6 +35,191 @@ SpellingOccurrence _rejected(SpellingProseRun run, String word) { ); } +bool _parserHasInline(String source, BusyInlineKind kind) { + final parsed = const MarkdownParser().parse( + filePath: '/tmp/recognized-inline.md', + source: source, + mode: MarkdownMode.commonMark, + ); + bool inInline(BusyInline inline) => + inline.kind == kind || inline.children.any(inInline); + bool inBlock(BusyBlock block) => + block.inlines.any(inInline) || block.children.any(inBlock); + return parsed.busyDocument.blocks.any(inBlock); +} + +void _expectNestedMultilineLabel({ + required String source, + required BusyInlineKind nestedKind, + required List bodyRuns, + String? titleText, +}) { + const filePath = '/tmp/nested-multiline-label.md'; + final parsed = const MarkdownParser().parse( + filePath: filePath, + source: source, + mode: MarkdownMode.commonMark, + ); + Iterable inlines(Iterable roots) sync* { + for (final inline in roots) { + yield inline; + yield* inlines(inline.children); + } + } + + Iterable blocks(Iterable roots) sync* { + for (final block in roots) { + yield block; + yield* blocks(block.children); + } + } + + final allInlines = [ + for (final block in blocks(parsed.busyDocument.blocks)) + ...inlines(block.inlines), + ]; + final outerLink = allInlines.singleWhere( + (inline) => + inline.kind == BusyInlineKind.link && inline.destination == 'target', + ); + expect( + inlines(outerLink.children).any((inline) => inline.kind == nestedKind), + isTrue, + reason: source, + ); + if (nestedKind == BusyInlineKind.image) { + expect( + inlines(outerLink.children) + .where((inline) => inline.kind == BusyInlineKind.image) + .single + .destination, + contains('image.png'), + reason: source, + ); + } + final projected = const MarkdownSpellingProjector().project( + filePath: filePath, + source: source, + mode: MarkdownMode.commonMark, + languageId: 'en-Test', + snapshot: _snapshot, + ); + final rich = const WysiwygSpellingProjector().project( + document: parsed.busyDocument, + languageId: 'en-Test', + snapshot: _snapshot, + documentGeneration: 1, + ); + expect(projected.complete, isTrue, reason: source); + expect(rich.complete, isTrue, reason: '$source: ${rich.message}'); + expect(projected.runs.every((run) => run.hasValidMapping), isTrue); + expect(rich.runs.every((run) => run.hasValidMapping), isTrue); + + List body(List runs) => [ + for (final run in runs) + if (run.atoms.first.context == SpellingSourceContext.markdownProse) + run.text.trim(), + ]; + expect(body(projected.runs), bodyRuns, reason: source); + expect(body(rich.runs), bodyRuns, reason: source); + final sourceText = projected.runs.map((run) => run.text).join(' '); + expect(sourceText, isNot(contains('image.png')), reason: source); + expect(sourceText, isNot(contains('target')), reason: source); + if (nestedKind == BusyInlineKind.code) { + expect(sourceText, isNot(contains('code')), reason: source); + expect(sourceText, isNot(contains('more')), reason: source); + } + if (titleText != null) { + final sourceTitle = projected.runs.singleWhere( + (run) => run.text == titleText, + ); + final richTitle = rich.runs.singleWhere((run) => run.text == titleText); + expect(sourceTitle.target, isA()); + expect(richTitle.target, isA()); + } + for (final runs in [projected.runs, rich.runs]) { + final run = runs.singleWhere( + (candidate) => candidate.text.contains('wrld'), + ); + final word = _rejected(run, 'wrld'); + expect(word.sourceStart, source.indexOf('wrld')); + expect(word.sourceEnd, source.indexOf('wrld') + 'wrld'.length); + if (identical(runs, rich.runs)) { + expect(run.target, isA()); + } + expect( + const SpellingReplacementPlanner() + .build(occurrence: word, suggestion: 'world') + .applyToSource(source), + source.replaceFirst('wrld', 'world'), + reason: source, + ); + } +} + +void _expectCodeFenceBeforeWrld(String source, List codeWords) { + final parsed = const MarkdownParser().parse( + filePath: '/tmp/fence-continuation.md', + source: source, + mode: MarkdownMode.commonMark, + ); + Iterable blocks(Iterable roots) sync* { + for (final block in roots) { + yield block; + yield* blocks(block.children); + } + } + + final structured = blocks(parsed.busyDocument.blocks).toList(); + expect( + structured.any( + (block) => + block.kind == BusyBlockKind.codeBlock && + codeWords.every(block.plainText.contains), + ), + isTrue, + reason: source, + ); + expect( + structured.any( + (block) => + block.kind == BusyBlockKind.paragraph && + block.plainText.contains('wrld'), + ), + isTrue, + reason: source, + ); + + final projected = const MarkdownSpellingProjector().project( + filePath: '/tmp/fence-continuation.md', + source: source, + mode: MarkdownMode.commonMark, + languageId: 'en-Test', + snapshot: _snapshot, + ); + expect(projected.complete, isTrue, reason: source); + expect(projected.runs.every((run) => run.hasValidMapping), isTrue); + final visible = projected.runs.map((run) => run.text).join(' '); + expect(visible, contains('wrld'), reason: source); + for (final word in codeWords) { + expect(visible, isNot(contains(word)), reason: source); + } + final occurrence = _rejected( + projected.runs.firstWhere((run) => run.text.contains('wrld')), + 'wrld', + ); + expect( + source.substring(occurrence.sourceStart!, occurrence.sourceEnd!), + 'wrld', + ); + expect( + const SpellingReplacementPlanner() + .build(occurrence: occurrence, suggestion: 'world') + .applyToSource(source), + source.replaceFirst('wrld', 'world'), + ); +} + void main() { test( 'different live prose still reports incomplete instead of guessed mapping', @@ -246,6 +432,425 @@ void main() { }); group('Markdown spelling projection', () { + test('keeps literal unresolved and invalid apparent links checkable', () { + for (final source in [ + '[hello][wrld]', + '[hello](wrld text)', + 'before [hello][wrld] after', + ]) { + final result = const MarkdownSpellingProjector().project( + filePath: '/tmp/literal-links.md', + source: source, + mode: MarkdownMode.commonMark, + languageId: 'en-Test', + snapshot: _snapshot, + ); + expect(result.complete, isTrue, reason: source); + expect(result.runs.every((run) => run.hasValidMapping), isTrue); + final text = result.runs.map((run) => run.text).join(' '); + expect(text, contains('hello'), reason: source); + expect(text, contains('wrld'), reason: source); + final run = result.runs.firstWhere((run) => run.text.contains('wrld')); + final occurrence = _rejected(run, 'wrld'); + expect( + source.substring(occurrence.sourceStart!, occurrence.sourceEnd!), + 'wrld', + ); + expect( + const SpellingReplacementPlanner() + .build(occurrence: occurrence, suggestion: 'world') + .applyToSource(source), + source.replaceFirst('wrld', 'world'), + ); + } + }); + + test( + 'resolves only parser-recognized links and images across revisions', + () { + final cases = <({String source, bool literalReference, bool hasTitle})>[ + ( + source: '[helo](target "titl")', + literalReference: false, + hasTitle: true, + ), + (source: '[helo][wrld]', literalReference: true, hasTitle: false), + ( + source: '[helo][wrld]\n\n[wrld]: /target', + literalReference: false, + hasTitle: false, + ), + (source: '[helo][]', literalReference: true, hasTitle: false), + ( + source: '[helo][]\n\n[helo]: /target', + literalReference: false, + hasTitle: false, + ), + (source: '[helo]', literalReference: true, hasTitle: false), + ( + source: '[helo]\n\n[helo]: /target', + literalReference: false, + hasTitle: false, + ), + ( + source: '![alttern](path "titl")', + literalReference: false, + hasTitle: true, + ), + ( + source: '![alttern][id]\n\n[id]: /path', + literalReference: false, + hasTitle: false, + ), + (source: '![alttern][id]', literalReference: true, hasTitle: false), + (source: r'\[helo\][wrld]', literalReference: true, hasTitle: false), + ]; + for (final (index, fixture) in cases.indexed) { + final result = const MarkdownSpellingProjector().project( + filePath: '/tmp/reference-revision.md', + source: fixture.source, + mode: MarkdownMode.commonMark, + languageId: 'en-Test', + snapshot: SpellingSnapshotIdentity( + bufferId: 'reference-revision', + contentRevision: index, + documentKind: DocumentKind.markdown, + contextGeneration: 1, + ), + ); + expect(result.complete, isTrue, reason: fixture.source); + expect(result.runs.every((run) => run.hasValidMapping), isTrue); + final all = result.runs.map((run) => run.text).join(' '); + expect( + all, + contains(fixture.source.startsWith('!') ? 'alttern' : 'helo'), + reason: fixture.source, + ); + if (fixture.source.contains('[wrld]')) { + expect( + all.contains('wrld'), + fixture.literalReference, + reason: fixture.source, + ); + } + if (fixture.hasTitle) { + expect(all, contains('titl'), reason: fixture.source); + } + if (fixture.source.contains('/target')) { + expect(all, isNot(contains('/target'))); + } + if (fixture.source.contains('/path')) { + expect(all, isNot(contains('/path'))); + } + if (fixture.source.contains('![alttern](path')) { + expect(all, isNot(contains('path'))); + } + if (fixture.source.startsWith('![alttern][id]')) { + expect( + all.contains('id'), + fixture.literalReference, + reason: fixture.source, + ); + } + } + }, + ); + + test('keeps literal trailing braces in paragraph and heading prose', () { + for (final mode in MarkdownMode.values) { + for (final source in [ + 'Use {wrld}', + '# Use {wrld}', + '# Use {*wrld*}', + '# Use {**wrld**}', + '# Use {wrld}', + '# Use {id="anchor"} {wrld}', + ]) { + final result = const MarkdownSpellingProjector().project( + filePath: '/tmp/braces.md', + source: source, + mode: mode, + languageId: 'en-Test', + snapshot: _snapshot, + ); + expect(result.complete, isTrue, reason: '$mode: $source'); + expect(result.runs.every((run) => run.hasValidMapping), isTrue); + final run = result.runs.firstWhere( + (run) => run.text.contains('wrld'), + ); + final occurrence = _rejected(run, 'wrld'); + expect( + source.substring(occurrence.sourceStart!, occurrence.sourceEnd!), + source.contains('r') ? 'wrld' : 'wrld', + ); + expect( + const SpellingReplacementPlanner() + .build(occurrence: occurrence, suggestion: 'world') + .applyToSource(source), + source.contains('r') + ? '# Use {world}' + : source.replaceFirst('wrld', 'world'), + ); + } + } + }); + + test('binds recognized images to their exact source occurrence', () { + for (final (revision, source) in [ + '![helo][wrld] ![helo](image.png)', + '![helo](image.png) ![helo][wrld]', + '![**helo**](diagrm.png)', + '![wrld](diagrm.png)', + '![helo][wrld] ![helo](image.png)\n\n[wrld]: /diagram.png', + '![helo][wrld] ![helo](image.png)', + ].indexed) { + final result = const MarkdownSpellingProjector().project( + filePath: '/tmp/images.md', + source: source, + mode: MarkdownMode.commonMark, + languageId: 'en-Test', + snapshot: SpellingSnapshotIdentity( + bufferId: 'image-revisions', + contentRevision: revision, + documentKind: DocumentKind.markdown, + contextGeneration: 1, + ), + ); + expect(result.complete, isTrue, reason: source); + expect(result.runs.every((run) => run.hasValidMapping), isTrue); + final all = result.runs.map((run) => run.text).join(' '); + expect(all, isNot(contains('diagrm.png')), reason: source); + expect(all, isNot(contains('image.png')), reason: source); + if (source.contains('[wrld]') && !source.contains('[wrld]:')) { + expect(all, contains('wrld'), reason: source); + final occurrence = _rejected( + result.runs.firstWhere((run) => run.text.contains('wrld')), + 'wrld', + ); + expect( + const SpellingReplacementPlanner() + .build(occurrence: occurrence, suggestion: 'world') + .applyToSource(source), + source.replaceFirst('wrld', 'world'), + ); + } + if (source.contains('[wrld]:')) { + expect(all, isNot(contains('wrld')), reason: source); + } + if (source.contains('**helo**')) { + expect(all, contains('helo')); + final occurrence = _rejected( + result.runs.firstWhere((run) => run.text.contains('helo')), + 'helo', + ); + expect( + const SpellingReplacementPlanner() + .build(occurrence: occurrence, suggestion: 'hello') + .applyToSource(source), + '![**hello**](diagrm.png)', + ); + } + if (source.contains('r')) { + expect(all, contains('wrld')); + final occurrence = _rejected( + result.runs.firstWhere((run) => run.text.contains('wrld')), + 'wrld', + ); + expect( + const SpellingReplacementPlanner() + .build(occurrence: occurrence, suggestion: 'world') + .applyToSource(source), + '![world](diagrm.png)', + ); + } + } + }); + + test('orders formatting from image labels and surrounding body', () { + for (final (source, expectedImage) in [ + ('![hel**l**o](image.png) **text**', 'hello'), + ('**text** ![hel**l**o](image.png)', 'hello'), + ('![hel**l**o](one.png) **text** ![w**r**ld](two.png)', 'wrld'), + ]) { + final result = const MarkdownSpellingProjector().project( + filePath: '/tmp/formatted-images.md', + source: source, + mode: MarkdownMode.commonMark, + languageId: 'en-Test', + snapshot: _snapshot, + ); + expect(result.complete, isTrue, reason: source); + expect(result.runs.every((run) => run.hasValidMapping), isTrue); + final all = result.runs.map((run) => run.text).join(' '); + expect( + result.runs.map((run) => run.text), + contains(expectedImage), + reason: source, + ); + expect(result.runs.map((run) => run.text.trim()), contains('text')); + expect(all, isNot(contains('**')), reason: source); + expect(all, isNot(contains('.png')), reason: source); + } + const misspelled = '![w**r**ld](image.png) **text**'; + final result = const MarkdownSpellingProjector().project( + filePath: '/tmp/formatted-images.md', + source: misspelled, + mode: MarkdownMode.commonMark, + languageId: 'en-Test', + snapshot: _snapshot, + ); + final image = result.runs.firstWhere((run) => run.text == 'wrld'); + expect( + const SpellingReplacementPlanner() + .build(occurrence: _rejected(image, 'wrld'), suggestion: 'world') + .applyToSource(misspelled), + '![wo**r**ld](image.png) **text**', + ); + }); + + test('recognizes nested syntax inside exact image descriptions', () { + for (final (source, visible, hidden) in [ + ('![a [wrld](diagrm.md)](image.png)', 'a wrld', 'diagrm.md'), + ('![a ![wrld](diagrm.png)](image.png)', 'a wrld', 'diagrm.png'), + ('![a [wrld][missing]](image.png)', 'wrld', 'missing'), + ]) { + final result = const MarkdownSpellingProjector().project( + filePath: '/tmp/nested-image.md', + source: source, + mode: MarkdownMode.commonMark, + languageId: 'en-Test', + snapshot: _snapshot, + ); + expect(result.complete, isTrue, reason: source); + expect(result.runs.every((run) => run.hasValidMapping), isTrue); + final all = result.runs.map((run) => run.text).join(' '); + if (source.contains('![wrld]')) { + expect(all, contains('a'), reason: source); + expect(all, contains('wrld'), reason: source); + } else { + expect(all, contains(visible), reason: source); + } + if (!source.contains('[missing]')) { + expect(all, isNot(contains(hidden)), reason: source); + } else { + expect(all, contains(hidden), reason: source); + } + expect(all, isNot(contains('image.png')), reason: source); + final occurrence = _rejected( + result.runs.firstWhere((run) => run.text.contains('wrld')), + 'wrld', + ); + expect( + source.substring(occurrence.sourceStart!, occurrence.sourceEnd!), + 'wrld', + ); + expect( + const SpellingReplacementPlanner() + .build(occurrence: occurrence, suggestion: 'world') + .applyToSource(source), + source.replaceFirst('wrld', 'world'), + ); + } + }); + + test( + 'excludes only trailing attributes recognized by the current mode', + () { + for (final mode in MarkdownMode.values) { + for (final source in [ + 'Use {id="wrld"}', + '# Use {id="wrld"', + '# Use {class="wrld"}', + ]) { + final result = const MarkdownSpellingProjector().project( + filePath: '/tmp/attribute-mode.md', + source: source, + mode: mode, + languageId: 'en-Test', + snapshot: _snapshot, + ); + expect(result.complete, isTrue, reason: '$mode: $source'); + expect(result.runs.every((run) => run.hasValidMapping), isTrue); + final containsWord = result.runs.any( + (run) => run.text.contains('wrld'), + ); + final recognized = + source.startsWith('# Use {class=') && + mode == MarkdownMode.writersideMarkdown; + expect(containsWord, !recognized, reason: '$mode: $source'); + } + final recognizedId = const MarkdownSpellingProjector().project( + filePath: '/tmp/attribute-mode.md', + source: '# Use {id="technical"}', + mode: mode, + languageId: 'en-Test', + snapshot: _snapshot, + ); + expect(recognizedId.complete, isTrue); + expect(recognizedId.runs.every((run) => run.hasValidMapping), isTrue); + expect( + recognizedId.runs.map((run) => run.text).join(' '), + isNot(contains('technical')), + reason: '$mode', + ); + } + }, + ); + + test('metadata does not split a surrounding Markdown word', () { + for (final source in [ + 'docu[men](target "titl")tation', + 'documentation', + ]) { + final result = const MarkdownSpellingProjector().project( + filePath: '/tmp/metadata.md', + source: source, + mode: MarkdownMode.commonMark, + languageId: 'en-Test', + snapshot: _snapshot, + ); + expect(result.complete, isTrue, reason: source); + expect(result.runs.every((run) => run.hasValidMapping), isTrue); + expect(result.runs.map((run) => run.text), contains('documentation')); + expect(result.runs.map((run) => run.text), contains('titl')); + } + }); + + test('corrects a Markdown body word across independent metadata', () { + for (final source in [ + 'docu[men](target "titl")taton', + 'documentaton', + ]) { + final result = const MarkdownSpellingProjector().project( + filePath: '/tmp/metadata-correction.md', + source: source, + mode: MarkdownMode.commonMark, + languageId: 'en-Test', + snapshot: _snapshot, + ); + expect(result.complete, isTrue); + expect(result.runs.every((run) => run.hasValidMapping), isTrue); + final body = result.runs.singleWhere( + (run) => run.text == 'documentaton', + ); + final plan = const SpellingReplacementPlanner().build( + occurrence: _rejected(body, 'documentaton'), + suggestion: 'documentation', + ); + expect( + plan.applyToSource(source), + source.replaceFirst('taton', 'tation'), + ); + final title = result.runs.singleWhere((run) => run.text == 'titl'); + expect( + const SpellingReplacementPlanner() + .build(occurrence: _rejected(title, 'titl'), suggestion: 'title') + .applyToSource(source), + source.replaceFirst('"titl"', '"title"'), + ); + } + }); + test('maps repeated and formatted prose without searching globally', () { const source = 'mispelled\n\n**mispel**led and `hiddenbad` tail\n'; final result = const MarkdownSpellingProjector().project( @@ -409,60 +1014,244 @@ Visiblee prose. expect(result.complete, isTrue, reason: result.message); expect(all, contains('Visiblee prose.')); expect(all, isNot(contains('record Document'))); - }); - - test('keeps unmatched delimiters and removes only parsed formatting', () { - const source = - 'Matched **mispel**led and literal foo_bar plus *unclosed.\n'; - final result = const MarkdownSpellingProjector().project( - filePath: '/tmp/delimiters.md', - source: source, - mode: MarkdownMode.commonMark, - languageId: 'en-US', - snapshot: _snapshot, - ); - - expect(result.complete, isTrue); - expect( - result.runs.single.text, - 'Matched mispelled and literal foo_bar plus *unclosed.', - ); - expect( - result.runs.single.atoms.map((atom) => atom.logicalText).join(), - result.runs.single.text, - ); - }); - - test('maps quoted HTML attribute values from quote boundaries', () { - for (final fixture in <({String source, String expected})>[ - ( - source: 'A x.\n', - expected: 'A x.\n', - ), - ( - source: 'A x.\n', - expected: 'A x.\n', - ), - ( - source: "A x.\n", - expected: "A x.\n", - ), - ( - source: 'A x.\n', - expected: - 'A x.\n', - ), + for (final quoted in [ + '> ```\n> code\n> ```\n>\n> wrld\n', + '> > ```\n> > code\n>\n> wrld\n', ]) { - final result = const MarkdownSpellingProjector().project( - filePath: '/tmp/attributes.md', - source: fixture.source, + final parsed = const MarkdownParser().parse( + filePath: '/tmp/quoted-code.md', + source: quoted, mode: MarkdownMode.commonMark, - languageId: 'en-US', - snapshot: _snapshot, - ); - final run = result.runs.firstWhere( - (candidate) => - candidate.text == 'titl' || candidate.text.startsWith('titl '), + ); + expect(parsed.busyDocument.blocks.first.kind, BusyBlockKind.blockquote); + final outer = parsed.busyDocument.blocks.first; + if (quoted.startsWith('> >')) { + expect(outer.children.first.kind, BusyBlockKind.blockquote); + expect( + outer.children.first.children.any( + (child) => child.kind == BusyBlockKind.codeBlock, + ), + isTrue, + ); + } else { + expect( + outer.children.any( + (child) => child.kind == BusyBlockKind.codeBlock, + ), + isTrue, + ); + } + expect( + outer.children.any( + (child) => + child.kind == BusyBlockKind.paragraph && + child.plainText == 'wrld', + ), + isTrue, + ); + final projected = const MarkdownSpellingProjector().project( + filePath: '/tmp/quoted-code.md', + source: quoted, + mode: MarkdownMode.commonMark, + languageId: 'en-Test', + snapshot: _snapshot, + ); + expect(projected.complete, isTrue, reason: quoted); + expect(projected.runs.every((run) => run.hasValidMapping), isTrue); + final visible = projected.runs.map((run) => run.text).join(' '); + expect(visible, contains('wrld'), reason: quoted); + expect(visible, isNot(contains('code')), reason: quoted); + final occurrence = _rejected( + projected.runs.firstWhere((run) => run.text.contains('wrld')), + 'wrld', + ); + expect( + const SpellingReplacementPlanner() + .build(occurrence: occurrence, suggestion: 'world') + .applyToSource(quoted), + quoted.replaceFirst('wrld', 'world'), + ); + } + for (final unclosed in [ + '> ```\n> code\n\nwrld\n', + '> > ```\n> > code\n\nwrld\n', + '> - ```\n> code\n>\n> wrld\n', + '- ```\n code\n\nwrld\n', + '- > ```\n > code\n\nwrld\n', + ]) { + final projected = const MarkdownSpellingProjector().project( + filePath: '/tmp/unclosed-code.md', + source: unclosed, + mode: MarkdownMode.commonMark, + languageId: 'en-Test', + snapshot: _snapshot, + ); + expect(projected.complete, isTrue, reason: unclosed); + expect(projected.runs.every((run) => run.hasValidMapping), isTrue); + final visible = projected.runs.map((run) => run.text).join(' '); + expect(visible, contains('wrld'), reason: unclosed); + expect(visible, isNot(contains('code')), reason: unclosed); + final run = projected.runs.firstWhere( + (run) => run.text.contains('wrld'), + ); + final occurrence = _rejected(run, 'wrld'); + expect( + unclosed.substring(occurrence.sourceStart!, occurrence.sourceEnd!), + 'wrld', + ); + expect( + const SpellingReplacementPlanner() + .build(occurrence: occurrence, suggestion: 'world') + .applyToSource(unclosed), + unclosed.replaceFirst('wrld', 'world'), + ); + } + for (final mixed in [ + '- > ```\n > code\n > ```\n >\n > wrld\n', + '> ```\n> > ```\n> code\n> ```\n>\n> wrld\n', + ]) { + final parsed = const MarkdownParser().parse( + filePath: '/tmp/mixed-fence.md', + source: mixed, + mode: MarkdownMode.commonMark, + ); + bool containsKind(BusyBlock block, BusyBlockKind kind) => + block.kind == kind || + block.children.any((child) => containsKind(child, kind)); + bool containsProse(BusyBlock block) => + block.kind == BusyBlockKind.paragraph && + block.plainText.contains('wrld') || + block.children.any(containsProse); + expect( + parsed.busyDocument.blocks.any( + (block) => containsKind(block, BusyBlockKind.codeBlock), + ), + isTrue, + reason: mixed, + ); + expect( + parsed.busyDocument.blocks.any(containsProse), + isTrue, + reason: mixed, + ); + final projected = const MarkdownSpellingProjector().project( + filePath: '/tmp/mixed-fence.md', + source: mixed, + mode: MarkdownMode.commonMark, + languageId: 'en-Test', + snapshot: _snapshot, + ); + expect(projected.complete, isTrue, reason: mixed); + expect(projected.runs.every((run) => run.hasValidMapping), isTrue); + final visible = projected.runs.map((run) => run.text).join(' '); + expect(visible, contains('wrld'), reason: mixed); + expect(visible, isNot(contains('code')), reason: mixed); + final occurrence = _rejected( + projected.runs.firstWhere((run) => run.text.contains('wrld')), + 'wrld', + ); + expect( + const SpellingReplacementPlanner() + .build(occurrence: occurrence, suggestion: 'world') + .applyToSource(mixed), + mixed.replaceFirst('wrld', 'world'), + ); + } + }); + + test('keeps quoted list fences across blank continuations', () { + for (final blank in ['>', '> ']) { + final source = + '> - ```\n> code\n$blank\n> more\n> ```\n>\n> wrld\n'; + _expectCodeFenceBeforeWrld(source, ['code', 'more']); + } + }); + + test('counts tab columns in list fence continuations', () { + const tabbed = '- ```\n\tcode\n ```\n\n wrld\n'; + const spaces = '- ```\n code\n ```\n\n wrld\n'; + const quoted = '> - ```\n> \tcode\n> ```\n>\n> wrld\n'; + const indentedFence = '- ```\n\t\t```\n more\n ```\n\n wrld\n'; + expect(tabbed.codeUnitAt(tabbed.indexOf('\n') + 1), 0x09); + expect(quoted.codeUnitAt(quoted.indexOf('\n') + 3), 0x09); + for (final source in [tabbed, spaces, quoted]) { + _expectCodeFenceBeforeWrld(source, ['code']); + } + _expectCodeFenceBeforeWrld(indentedFence, ['more']); + const insufficient = '- > ```\n code\n\nwrld\n'; + final parsed = const MarkdownParser().parse( + filePath: '/tmp/insufficient-list-indent.md', + source: insufficient, + mode: MarkdownMode.commonMark, + ); + expect(parsed.busyDocument.blocks.first.plainText.trim(), 'code'); + expect(parsed.busyDocument.blocks.last.plainText, 'wrld'); + final projected = const MarkdownSpellingProjector().project( + filePath: '/tmp/insufficient-list-indent.md', + source: insufficient, + mode: MarkdownMode.commonMark, + languageId: 'en-Test', + snapshot: _snapshot, + ); + expect(projected.complete, isTrue); + expect(projected.runs.every((run) => run.hasValidMapping), isTrue); + final visible = projected.runs.map((run) => run.text).join(' '); + expect(visible, contains('code')); + expect(visible, contains('wrld')); + }); + + test('keeps unmatched delimiters and removes only parsed formatting', () { + const source = + 'Matched **mispel**led and literal foo_bar plus *unclosed.\n'; + final result = const MarkdownSpellingProjector().project( + filePath: '/tmp/delimiters.md', + source: source, + mode: MarkdownMode.commonMark, + languageId: 'en-US', + snapshot: _snapshot, + ); + + expect(result.complete, isTrue); + expect( + result.runs.single.text, + 'Matched mispelled and literal foo_bar plus *unclosed.', + ); + expect( + result.runs.single.atoms.map((atom) => atom.logicalText).join(), + result.runs.single.text, + ); + }); + + test('maps quoted HTML attribute values from quote boundaries', () { + for (final fixture in <({String source, String expected})>[ + ( + source: 'A x.\n', + expected: 'A x.\n', + ), + ( + source: 'A x.\n', + expected: 'A x.\n', + ), + ( + source: "A x.\n", + expected: "A x.\n", + ), + ( + source: 'A x.\n', + expected: + 'A x.\n', + ), + ]) { + final result = const MarkdownSpellingProjector().project( + filePath: '/tmp/attributes.md', + source: fixture.source, + mode: MarkdownMode.commonMark, + languageId: 'en-US', + snapshot: _snapshot, + ); + final run = result.runs.firstWhere( + (candidate) => + candidate.text == 'titl' || candidate.text.startsWith('titl '), ); final corrected = const SpellingReplacementPlanner() .build(occurrence: _rejected(run, 'titl'), suggestion: 'title') @@ -552,6 +1341,61 @@ hidden style prose expect(corrected, source.replaceFirst('mispelled', 'misspelled')); }); + test('keeps escaped multiline tag-like prose checkable', () { + for (final source in [ + r'\\n', + r'\\\\n', + r'\body\n', + ]) { + expect(md.markdownToHtml(source), contains('<span')); + final result = const MarkdownSpellingProjector().project( + filePath: '/tmp/escaped-multiline-tag.md', + source: source, + mode: MarkdownMode.commonMark, + languageId: 'en-Test', + snapshot: _snapshot, + ); + expect(result.complete, isTrue, reason: source); + expect(result.runs.every((run) => run.hasValidMapping), isTrue); + expect(result.runs.map((run) => run.text).join(' '), contains('wrld')); + expect( + result.runs.where((run) => run.text.contains('wrld')), + hasLength(1), + reason: source, + ); + final occurrence = _rejected( + result.runs.firstWhere((run) => run.text.contains('wrld')), + 'wrld', + ); + expect( + const SpellingReplacementPlanner() + .build(occurrence: occurrence, suggestion: 'world') + .applyToSource(source), + source.replaceFirst('wrld', 'world'), + ); + } + const real = + r'\\body\n'; + expect(md.markdownToHtml(real), contains(' run.hasValidMapping), isTrue); + final all = result.runs.map((run) => run.text).join(' '); + expect(all, contains('wrld')); + expect(all, contains('body')); + expect(all, isNot(contains('hidden'))); + }); + test('removes delimiters when a correction empties formatting', () { for (final fixture in <({String source, MarkdownMode mode})>[ (source: 'he**x**llo\n', mode: MarkdownMode.commonMark), @@ -766,280 +1610,2072 @@ hidden style prose }, ); - test('semantic leaf blocks define independent complete prose runs', () { - const source = r''' hiddenindent - -- parentt - - nestedd - -> firstt -> -> secondd - -- visiblee - - hiddencontainercode - -before $$ mispelled $$ afterr - -before $hiddenmath$ aftermath -'''; - final projected = const MarkdownSpellingProjector().project( - filePath: '/tmp/leaves.md', - source: source, - mode: MarkdownMode.commonMark, - languageId: 'en-US', - snapshot: _snapshot, - ); - final texts = projected.runs.map((run) => run.text).toList(); - - expect(projected.complete, isTrue, reason: projected.message); - expect(texts, contains('parentt')); - expect(texts, contains('nestedd')); - expect(texts, contains('firstt')); - expect(texts, contains('secondd')); - expect(texts, contains(r'before $$ mispelled $$ afterr')); - expect(texts.join(' '), isNot(contains('hiddenindent'))); - expect(texts.join(' '), isNot(contains('hiddencontainercode'))); - expect(texts.join(' '), isNot(contains('hiddenmath'))); - expect(texts.every((text) => text != 'parentt nestedd'), isTrue); + test('recognized link and image titles use their authored boundaries', () { + for (final (source, kind) in [ + ('[hello](diagrm.md (wrld))', BusyInlineKind.link), + ('[hello](diagrm.md "wrld)")', BusyInlineKind.link), + ('![hello](diagrm.png (wrld))', BusyInlineKind.image), + ('[hello](wrld.md "wrld")', BusyInlineKind.link), + ('[he`[`llo](diagrm.md "wrld")', BusyInlineKind.link), + ]) { + expect(_parserHasInline(source, kind), isTrue, reason: source); + final result = const MarkdownSpellingProjector().project( + filePath: '/tmp/recognized-title.md', + source: source, + mode: MarkdownMode.commonMark, + languageId: 'en-Test', + snapshot: _snapshot, + ); + expect(result.complete, isTrue, reason: source); + expect(result.runs.every((run) => run.hasValidMapping), isTrue); + final all = result.runs.map((run) => run.text).join(' '); + if (source.contains('`[`')) { + expect(all, contains('he'), reason: source); + expect(all, contains('llo'), reason: source); + } else { + expect(all, contains('hello'), reason: source); + } + expect(all, contains('wrld'), reason: source); + expect(all, isNot(contains('diagrm')), reason: source); + expect(all, isNot(contains('wrld.md')), reason: source); + final occurrence = _rejected( + result.runs.firstWhere((run) => run.text.contains('wrld')), + 'wrld', + ); + final titleOffset = source.lastIndexOf('wrld'); + expect(occurrence.sourceStart, titleOffset, reason: source); + expect( + const SpellingReplacementPlanner() + .build(occurrence: occurrence, suggestion: 'world') + .applyToSource(source), + source.replaceRange(titleOffset, titleOffset + 4, 'world'), + ); + } }); - test('projects semantic HTML prose and trusts parser math nodes', () { - const source = - '''

mispelled

hiddenbad
- -Price \$ 5, mispelled \$ 6 and actual \$hiddenmath\$ tail. -'''; - final projected = const MarkdownSpellingProjector().project( - filePath: '/tmp/html.md', - source: source, - mode: MarkdownMode.commonMark, - languageId: 'en-US', - snapshot: _snapshot, - ); - final text = projected.runs.map((run) => run.text).join('\n'); + test('destination-only links and images do not expose a false title', () { + for (final (source, kind) in [ + ('[helo]( (wrld))', BusyInlineKind.link), + ('![helo]( (wrld))', BusyInlineKind.image), + ('[helo]( ("wrld"))', BusyInlineKind.link), + ("![helo]( ('wrld'))", BusyInlineKind.image), + ]) { + expect(_parserHasInline(source, kind), isTrue, reason: source); + final result = const MarkdownSpellingProjector().project( + filePath: '/tmp/destination-only.md', + source: source, + mode: MarkdownMode.commonMark, + languageId: 'en-Test', + snapshot: _snapshot, + ); + expect(result.complete, isTrue, reason: source); + expect(result.runs.every((run) => run.hasValidMapping), isTrue); + final all = result.runs.map((run) => run.text).join(' '); + expect(all, contains('helo'), reason: source); + expect(all, isNot(contains('wrld')), reason: source); + final occurrence = _rejected( + result.runs.firstWhere((run) => run.text.contains('helo')), + 'helo', + ); + expect(occurrence.sourceStart, source.indexOf('helo')); + expect( + const SpellingReplacementPlanner() + .build(occurrence: occurrence, suggestion: 'hello') + .applyToSource(source), + source.replaceFirst('helo', 'hello'), + ); + } + }); - expect(projected.complete, isTrue, reason: projected.message); - expect(text, contains('Readablee')); - expect(text, contains('mispelled')); - expect(text, contains(r'Price $ 5, mispelled $ 6')); - expect(text, isNot(contains('hiddenbad'))); - expect(text, isNot(contains('hiddenmath'))); + test('recognized multiline links consume one mapped occurrence', () { + for (final (source, kind) in [ + ('[hello](\ndiagrm.md\n"wrld"\n)\n', BusyInlineKind.link), + ('[hello](\r\ndiagrm.md\r\n"wrld"\r\n)\r\n', BusyInlineKind.link), + ( + '> - [hello](\n> diagrm.md\n> "wrld"\n> )\n', + BusyInlineKind.link, + ), + ('![hello](\ndiagrm.png\n"wrld"\n)\n', BusyInlineKind.image), + ]) { + expect(_parserHasInline(source, kind), isTrue); + final result = const MarkdownSpellingProjector().project( + filePath: '/tmp/multiline-link.md', + source: source, + mode: MarkdownMode.commonMark, + languageId: 'en-Test', + snapshot: _snapshot, + ); + expect(result.complete, isTrue, reason: source); + expect(result.runs.every((run) => run.hasValidMapping), isTrue); + final all = result.runs.map((run) => run.text).join(' '); + expect(all, contains('hello'), reason: source); + expect(all, contains('wrld'), reason: source); + expect(all, isNot(contains('diagrm')), reason: source); + final occurrence = _rejected( + result.runs.firstWhere((run) => run.text.contains('wrld')), + 'wrld', + ); + expect( + const SpellingReplacementPlanner() + .build(occurrence: occurrence, suggestion: 'world') + .applyToSource(source), + source.replaceFirst('wrld', 'world'), + ); + } }); - test('large formatting-heavy projection and correction stay bounded', () { - final source = List.generate( - 600, - (index) => - '**mispel**led paragraph $index with [hello](target) and `code` ' - r'$x$', - ).join('\n\n'); - final watch = Stopwatch()..start(); - final projected = const MarkdownSpellingProjector().project( - filePath: '/tmp/large-correction.md', - source: source, - mode: MarkdownMode.commonMark, - languageId: 'en-US', - snapshot: _snapshot, - ); + test('multiline link labels keep container prefixes out of prose', () { + for (final source in [ + '> [helo\n> wrld](target)\n', + '> [helo\r\n> wrld](target)\r\n', + '- [helo\n wrld](target)\n', + ]) { + final parsed = const MarkdownParser().parse( + filePath: '/tmp/multiline-label.md', + source: source, + mode: MarkdownMode.commonMark, + ); + final projected = const MarkdownSpellingProjector().project( + filePath: '/tmp/multiline-label.md', + source: source, + mode: MarkdownMode.commonMark, + languageId: 'en-Test', + snapshot: _snapshot, + ); + final rich = const WysiwygSpellingProjector().project( + document: parsed.busyDocument, + languageId: 'en-Test', + snapshot: _snapshot, + documentGeneration: 1, + ); + expect(projected.complete, isTrue, reason: source); + expect(rich.complete, isTrue, reason: source); + expect(projected.runs.every((run) => run.hasValidMapping), isTrue); + expect(rich.runs.every((run) => run.hasValidMapping), isTrue); + final prose = projected.runs.map((run) => run.text).join(' '); + expect(prose, contains('helo')); + expect(prose, contains('wrld')); + expect(prose, isNot(contains('>'))); + expect(prose, isNot(contains('target'))); + expect(rich.runs.map((run) => run.text).join(' '), prose); + final word = _rejected( + projected.runs.singleWhere((run) => run.text.contains('wrld')), + 'wrld', + ); + expect(word.sourceStart, source.indexOf('wrld')); + expect( + const SpellingReplacementPlanner() + .build(occurrence: word, suggestion: 'world') + .applyToSource(source), + source.replaceFirst('wrld', 'world'), + ); + final richWord = _rejected( + rich.runs.singleWhere((run) => run.text.contains('wrld')), + 'wrld', + ); + expect(richWord.run.target, isA()); + expect(richWord.sourceStart, source.indexOf('wrld')); + expect( + const SpellingReplacementPlanner() + .build(occurrence: richWord, suggestion: 'world') + .applyToSource(source), + source.replaceFirst('wrld', 'world'), + ); + } + }); + + test('multiline mapped labels agree with rich prose and corrections', () { + for (final (source, expected) in [ + ('> - [helo\n> wrld](target)\n', 'helo wrld'), + ('> - [helo\r\n> wrld](target)\r\n', 'helo wrld'), + ('> [helo\\\n> wrld](target)\n', 'helo wrld'), + ('> [helo \n> wrld](target)\n', 'helo wrld'), + ('> [helo\\\r\n> wrld](target)\r\n', 'helo wrld'), + ('> [helo\n> wrld](target)\n', 'helo wrld'), + ('> [helo \n> wrld](target)\n', 'helo wrld'), + ('> [helo\\\n> middle \n> wrld](target)\n', 'helo middle wrld'), + ('- > [helo\n > wrld](target)\n', 'helo wrld'), + ('> - [**helo**\n> wrld](target)\n', 'helo wrld'), + ('> [helo there\n> wrld](target)\n', 'helo there wrld'), + ('> ![helo\n> wrld](target)\n', 'helo wrld'), + ('> - ![helo\n> wrld](target)\n', 'helo wrld'), + ('> ![helo\\\n> wrld](target)\n', 'helowrld'), + ('> - ![helo\\\n> wrld](target)\n', 'helowrld'), + ('> ![helo \n> wrld](target)\n', 'helowrld'), + ('> ![helo there\n> wrld](target)\n', 'helo there wrld'), + ('> ![helo\\\n> middle \n> wrld](target)\n', 'helomiddle wrld'), + ]) { + final kind = source.contains('![') + ? BusyInlineKind.image + : BusyInlineKind.link; + expect(_parserHasInline(source, kind), isTrue, reason: source); + final parsed = const MarkdownParser().parse( + filePath: '/tmp/multiline-label-invariant.md', + source: source, + mode: MarkdownMode.commonMark, + ); + final projected = const MarkdownSpellingProjector().project( + filePath: '/tmp/multiline-label-invariant.md', + source: source, + mode: MarkdownMode.commonMark, + languageId: 'en-Test', + snapshot: _snapshot, + ); + final rich = const WysiwygSpellingProjector().project( + document: parsed.busyDocument, + languageId: 'en-Test', + snapshot: _snapshot, + documentGeneration: 1, + ); + final sourceText = projected.runs.map((run) => run.text).join(' '); + final richText = rich.runs.map((run) => run.text).join(' '); + expect(projected.complete, isTrue, reason: source); + expect(rich.complete, isTrue, reason: source); + expect(projected.runs.every((run) => run.hasValidMapping), isTrue); + expect(rich.runs.every((run) => run.hasValidMapping), isTrue); + expect(sourceText, expected, reason: source); + expect(richText, sourceText, reason: source); + expect(sourceText, isNot(contains('target')), reason: source); + for (final run in [projected.runs.single, rich.runs.single]) { + final word = _rejected(run, 'wrld'); + expect(word.sourceStart, source.indexOf('wrld')); + expect(word.sourceEnd, source.indexOf('wrld') + 4); + expect( + const SpellingReplacementPlanner() + .build(occurrence: word, suggestion: 'world') + .applyToSource(source), + source.replaceFirst('wrld', 'world'), + reason: source, + ); + } + } + }); + + test('inline HTML breaks in link labels agree with rich prose', () { + for (final source in [ + '[hello
wrld](target)', + '[hello
text\nwrld](target)', + '> - [hello
text\r\n> wrld](target)', + ]) { + final parsed = const MarkdownParser().parse( + filePath: '/tmp/link-inline-break.md', + source: source, + mode: MarkdownMode.commonMark, + ); + expect( + _parserHasInline(source, BusyInlineKind.hardBreak), + isTrue, + reason: source, + ); + final projected = const MarkdownSpellingProjector().project( + filePath: '/tmp/link-inline-break.md', + source: source, + mode: MarkdownMode.commonMark, + languageId: 'en-Test', + snapshot: _snapshot, + ); + final rich = const WysiwygSpellingProjector().project( + document: parsed.busyDocument, + languageId: 'en-Test', + snapshot: _snapshot, + documentGeneration: 1, + ); + expect(projected.complete, isTrue, reason: source); + expect(rich.complete, isTrue, reason: source); + expect(projected.runs.every((run) => run.hasValidMapping), isTrue); + expect(rich.runs.every((run) => run.hasValidMapping), isTrue); + final sourceText = projected.runs.map((run) => run.text).join(' '); + expect( + sourceText, + source.contains('text') ? 'hello text wrld' : 'hello wrld', + reason: source, + ); + expect( + rich.runs.map((run) => run.text).join(' '), + sourceText, + reason: source, + ); + final tagAtoms = [ + for (final run in projected.runs) + for (final atom in run.atoms) + if (source + .substring(atom.sourceStart, atom.sourceEnd) + .startsWith(' run.text.contains('wrld')), + rich.runs.singleWhere((run) => run.text.contains('wrld')), + ]) { + final word = _rejected(run, 'wrld'); + expect(word.sourceStart, source.indexOf('wrld')); + expect( + const SpellingReplacementPlanner() + .build(occurrence: word, suggestion: 'world') + .applyToSource(source), + source.replaceFirst('wrld', 'world'), + ); + } + } + }); + + test('code-styled image alternative text agrees with rich image field', () { + for (final source in [ + '![hello `wrld`](image.png)', + '![hello `wrld\nagain`](image.png)', + '![hello `wrld\r\nagain`](image.png)', + '![hello ` wrld `](image.png)', + '[before ![hello `wrld`](image.png) after](target)', + ]) { + final parsed = const MarkdownParser().parse( + filePath: '/tmp/image-code-alt.md', + source: source, + mode: MarkdownMode.commonMark, + ); + expect( + _parserHasInline(source, BusyInlineKind.image), + isTrue, + reason: source, + ); + Iterable descendants(Iterable roots) sync* { + for (final inline in roots) { + yield inline; + yield* descendants(inline.children); + } + } + + final image = [ + for (final block in parsed.busyDocument.blocks) + ...descendants(block.inlines), + ].singleWhere((inline) => inline.kind == BusyInlineKind.image); + expect( + image.text, + source.contains('again') ? 'hello wrld again' : 'hello wrld', + reason: source, + ); + final projected = const MarkdownSpellingProjector().project( + filePath: '/tmp/image-code-alt.md', + source: source, + mode: MarkdownMode.commonMark, + languageId: 'en-Test', + snapshot: _snapshot, + ); + final rich = const WysiwygSpellingProjector().project( + document: parsed.busyDocument, + languageId: 'en-Test', + snapshot: _snapshot, + documentGeneration: 1, + ); + expect(projected.complete, isTrue, reason: source); + expect(rich.complete, isTrue, reason: source); + expect(projected.runs.every((run) => run.hasValidMapping), isTrue); + expect(rich.runs.every((run) => run.hasValidMapping), isTrue); + final sourceText = projected.runs.map((run) => run.text).join(' '); + expect( + sourceText, + rich.runs.map((run) => run.text).join(' '), + reason: source, + ); + expect( + sourceText, + source.contains('before') + ? 'before hello wrld after' + : source.contains('again') + ? 'hello wrld again' + : 'hello wrld', + reason: source, + ); + expect(sourceText, isNot(contains('image.png'))); + expect(sourceText, isNot(contains('`'))); + expect(sourceText, contains(image.text), reason: source); + for (final run in [ + projected.runs.singleWhere((run) => run.text.contains('wrld')), + rich.runs.singleWhere((run) => run.text.contains('wrld')), + ]) { + final word = _rejected(run, 'wrld'); + expect(word.sourceStart, source.indexOf('wrld')); + expect(word.sourceEnd, source.indexOf('wrld') + 4); + expect( + const SpellingReplacementPlanner() + .build(occurrence: word, suggestion: 'world') + .applyToSource(source), + source.replaceFirst('wrld', 'world'), + ); + } + } + const ordinary = '[hello `wrld`](target)'; + final ordinaryProjection = const MarkdownSpellingProjector().project( + filePath: '/tmp/link-code-control.md', + source: ordinary, + mode: MarkdownMode.commonMark, + languageId: 'en-Test', + snapshot: _snapshot, + ); + expect(ordinaryProjection.complete, isTrue); + expect(ordinaryProjection.runs.map((run) => run.text), ['hello ']); + }); + + test('image code and Markdown hard breaks retain image alt semantics', () { + for (final (name, source, expectedAlt) in [ + ( + 'backslash-after-code', + '![hello `wrld`\\\nagain](image.png)', + 'hello wrldagain', + ), + ( + 'spaces-after-code', + '![hello `wrld` \nagain](image.png)', + 'hello wrldagain', + ), + ( + 'backslash-before-code', + '![hello\\\n`wrld` again](image.png)', + 'hellowrld again', + ), + ( + 'combined-crlf', + '> - ![hello `wrld`\\\r\n> again](image.png)', + 'hello wrldagain', + ), + ( + 'soft-break', + '![hello `wrld`\nagain](image.png)', + 'hello wrld\nagain', + ), + ( + 'newline-in-code', + '![hello `wrld\nagain`](image.png)', + 'hello wrld again', + ), + ]) { + final parsed = const MarkdownParser().parse( + filePath: '/tmp/image-code-break.md', + source: source, + mode: MarkdownMode.commonMark, + ); + Iterable descendants(Iterable roots) sync* { + for (final inline in roots) { + yield inline; + yield* descendants(inline.children); + } + } + + Iterable blocks(Iterable roots) sync* { + for (final block in roots) { + yield block; + yield* blocks(block.children); + } + } + + final image = [ + for (final block in blocks(parsed.busyDocument.blocks)) + ...descendants(block.inlines), + ].singleWhere((inline) => inline.kind == BusyInlineKind.image); + expect(image.text, expectedAlt, reason: name); + final projected = const MarkdownSpellingProjector().project( + filePath: '/tmp/image-code-break.md', + source: source, + mode: MarkdownMode.commonMark, + languageId: 'en-Test', + snapshot: _snapshot, + ); + final rich = const WysiwygSpellingProjector().project( + document: parsed.busyDocument, + languageId: 'en-Test', + snapshot: _snapshot, + documentGeneration: 1, + ); + expect(projected.complete, isTrue, reason: name); + expect(rich.complete, isTrue, reason: name); + expect( + projected.runs.every((run) => run.hasValidMapping), + isTrue, + reason: name, + ); + expect( + rich.runs.every((run) => run.hasValidMapping), + isTrue, + reason: name, + ); + final expectedSpelling = expectedAlt.replaceAll('\n', ' '); + expect(projected.runs.single.text, expectedSpelling, reason: name); + expect(rich.runs.single.text, expectedSpelling, reason: name); + expect( + projected.runs.single.text, + isNot(contains('image.png')), + reason: name, + ); + expect(projected.runs.single.text, isNot(contains('`')), reason: name); + } + }); + + test('image code hard-break corrections retain authored source', () { + for (final source in [ + '![helo `code`\\\nagain wrld](image.png)', + '![helo `code` \nagain wrld](image.png)', + '> - ![helo `code`\\\r\n> again wrld](image.png)', + ]) { + final parsed = const MarkdownParser().parse( + filePath: '/tmp/image-code-break-correction.md', + source: source, + mode: MarkdownMode.commonMark, + ); + Iterable descendants(Iterable roots) sync* { + for (final inline in roots) { + yield inline; + yield* descendants(inline.children); + } + } + + Iterable blocks(Iterable roots) sync* { + for (final block in roots) { + yield block; + yield* blocks(block.children); + } + } + + final image = [ + for (final block in blocks(parsed.busyDocument.blocks)) + ...descendants(block.inlines), + ].singleWhere((inline) => inline.kind == BusyInlineKind.image); + expect(image.text, 'helo codeagain wrld', reason: source); + final projected = const MarkdownSpellingProjector().project( + filePath: '/tmp/image-code-break-correction.md', + source: source, + mode: MarkdownMode.commonMark, + languageId: 'en-Test', + snapshot: _snapshot, + ); + final rich = const WysiwygSpellingProjector().project( + document: parsed.busyDocument, + languageId: 'en-Test', + snapshot: _snapshot, + documentGeneration: 1, + ); + expect(projected.complete, isTrue, reason: source); + expect(rich.complete, isTrue, reason: source); + expect( + projected.runs.every((run) => run.hasValidMapping), + isTrue, + reason: source, + ); + expect( + rich.runs.every((run) => run.hasValidMapping), + isTrue, + reason: source, + ); + expect(projected.runs.single.text, image.text, reason: source); + expect(rich.runs.single.text, image.text, reason: source); + for (final (word, suggestion) in [ + ('helo', 'hello'), + ('wrld', 'world'), + ]) { + for (final run in [projected.runs.single, rich.runs.single]) { + final occurrence = _rejected(run, word); + expect( + occurrence.sourceStart, + source.indexOf(word), + reason: source, + ); + expect( + occurrence.sourceEnd, + source.indexOf(word) + word.length, + reason: source, + ); + expect( + const SpellingReplacementPlanner() + .build(occurrence: occurrence, suggestion: suggestion) + .applyToSource(source), + source.replaceFirst(word, suggestion), + reason: source, + ); + } + } + } + }); + + for (final (name, source, expectedImage) in [ + ('list', '- ![hello `wrld\n again`](image.png)', 'hello wrld again'), + ('quote', '> ![hello `wrld\n> again`](image.png)', 'hello wrld again'), + ( + 'crlf-after-content', + 'Intro.\r\n\r\n- ![hello `wrld\r\n againn`](image.png)', + 'hello wrld againn', + ), + ( + 'combined-quote-list', + '> - ![hello `wrld\n> again`](image.png)', + 'hello wrld again', + ), + ( + 'nested-image', + '> - [before ![hello `wrld\n> again`](image.png) after](target)', + 'hello wrld again', + ), + ( + 'authored-inner-spaces', + '- ![hello `wrld kept\n again`](image.png)', + 'hello wrld kept again', + ), + ( + 'authored-continuation-space', + '- ![hello `wrld\n again`](image.png)', + 'hello wrld again', + ), + ( + 'literal-code-syntax', + '- ![hello `wrld\\* &\n again`](image.png)', + r'hello wrld\* & again', + ), + ]) { + test( + 'container image code matches the block-parsed alternative: $name', + () { + final parsed = const MarkdownParser().parse( + filePath: '/tmp/container-image-code.md', + source: source, + mode: MarkdownMode.commonMark, + ); + Iterable blocks(Iterable roots) sync* { + for (final block in roots) { + yield block; + yield* blocks(block.children); + } + } + + Iterable inlines(Iterable roots) sync* { + for (final inline in roots) { + yield inline; + yield* inlines(inline.children); + } + } + + final image = [ + for (final block in blocks(parsed.busyDocument.blocks)) + ...inlines(block.inlines), + ].singleWhere((inline) => inline.kind == BusyInlineKind.image); + expect(image.text, expectedImage, reason: source); + final projected = const MarkdownSpellingProjector().project( + filePath: '/tmp/container-image-code.md', + source: source, + mode: MarkdownMode.commonMark, + languageId: 'en-Test', + snapshot: _snapshot, + ); + final rich = const WysiwygSpellingProjector().project( + document: parsed.busyDocument, + languageId: 'en-Test', + snapshot: _snapshot, + documentGeneration: 1, + ); + final sourceImage = projected.runs.singleWhere( + (run) => run.text.contains('wrld'), + ); + expect(sourceImage.text, image.text, reason: source); + final richImage = rich.runs.singleWhere( + (run) => run.text == image.text, + ); + expect(richImage.text, image.text, reason: source); + expect(projected.complete, isTrue, reason: source); + expect(rich.complete, isTrue, reason: source); + expect( + projected.runs.every((run) => run.hasValidMapping), + isTrue, + reason: source, + ); + expect( + rich.runs.every((run) => run.hasValidMapping), + isTrue, + reason: source, + ); + final codeBreak = sourceImage.atoms.singleWhere( + (atom) => + atom.transformation == SpellingTransformationKind.lineBreak && + source + .substring(atom.sourceStart, atom.sourceEnd) + .contains('\n'), + ); + final expectedBreak = switch (name) { + 'quote' => '\n> ', + 'combined-quote-list' || 'nested-image' => '\n> ', + 'crlf-after-content' => '\r\n ', + _ => '\n ', + }; + expect( + source.substring(codeBreak.sourceStart, codeBreak.sourceEnd), + expectedBreak, + reason: source, + ); + expect(codeBreak.logicalText, ' '); + expect( + projected.runs.map((run) => run.text).join(' '), + isNot(contains('image.png')), + reason: source, + ); + if (source.contains('(target)')) { + expect( + projected.runs.map((run) => run.text).join(' '), + isNot(contains('target')), + reason: source, + ); + expect( + projected.runs.where((run) => run.text.contains('before')), + hasLength(1), + reason: source, + ); + expect( + projected.runs.where((run) => run.text.contains('after')), + hasLength(1), + reason: source, + ); + } + for (final word in [ + 'wrld', + if (source.contains('againn')) 'againn', + ]) { + final suggestion = word == 'wrld' ? 'world' : 'again'; + for (final run in [sourceImage, richImage]) { + final occurrence = _rejected(run, word); + expect( + occurrence.sourceStart, + source.indexOf(word), + reason: source, + ); + expect( + occurrence.sourceEnd, + source.indexOf(word) + word.length, + reason: source, + ); + expect( + const SpellingReplacementPlanner() + .build(occurrence: occurrence, suggestion: suggestion) + .applyToSource(source), + source.replaceFirst(word, suggestion), + reason: source, + ); + } + } + }, + ); + } + + test('nested image code inherits the enclosing container mapping', () { + const source = + '> - ![before ![hello `wrld\n> again`](inner.png) after](outer.png)'; + final parsed = const MarkdownParser().parse( + filePath: '/tmp/recursive-container-image-code.md', + source: source, + mode: MarkdownMode.commonMark, + ); + Iterable blocks(Iterable roots) sync* { + for (final block in roots) { + yield block; + yield* blocks(block.children); + } + } + + final image = [ + for (final block in blocks(parsed.busyDocument.blocks)) + ...block.inlines, + ].singleWhere((inline) => inline.kind == BusyInlineKind.image); + expect(image.text, 'before hello wrld again after'); + final projected = const MarkdownSpellingProjector().project( + filePath: '/tmp/recursive-container-image-code.md', + source: source, + mode: MarkdownMode.commonMark, + languageId: 'en-Test', + snapshot: _snapshot, + ); + expect(projected.complete, isTrue); + expect(projected.runs.every((run) => run.hasValidMapping), isTrue); + expect(projected.runs.map((run) => run.text), [ + 'before ', + 'hello wrld again', + ' after', + ]); + final word = _rejected( + projected.runs.singleWhere((run) => run.text.contains('wrld')), + 'again', + ); + expect(word.sourceStart, source.indexOf('again')); + expect( + const SpellingReplacementPlanner() + .build(occurrence: word, suggestion: 'world') + .applyToSource(source), + source.replaceFirst('again', 'world'), + ); + }); + + test('link title keeps literal inline-break markup as scalar text', () { + const source = '[hello](target "Use
wrld")'; + final result = const MarkdownSpellingProjector().project( + filePath: '/tmp/literal-title-break.md', + source: source, + mode: MarkdownMode.commonMark, + languageId: 'en-Test', + snapshot: _snapshot, + ); + expect(result.complete, isTrue); + expect(result.runs.every((run) => run.hasValidMapping), isTrue); + expect(result.runs.map((run) => run.text), ['hello', 'Use
wrld']); + final title = result.runs.singleWhere((run) => run.text.contains('wrld')); + expect( + const SpellingReplacementPlanner() + .build(occurrence: _rejected(title, 'wrld'), suggestion: 'world') + .applyToSource(source), + '[hello](target "Use
world")', + ); + }); + + test('multiline code inside a link label stays opaque', () { + _expectNestedMultilineLabel( + source: '[helo `code\nmore` wrld](target)', + nestedKind: BusyInlineKind.code, + bodyRuns: ['helo', 'wrld'], + ); + }); + + test('multiline nested-image destination stays outside outer prose', () { + _expectNestedMultilineLabel( + source: '[helo ![alt](\nimage.png) wrld](target)', + nestedKind: BusyInlineKind.image, + bodyRuns: ['helo', 'alt', 'wrld'], + ); + }); + + test('nested label syntax owns its breaks before outer prose resumes', () { + for (final fixture in [ + ( + source: '[helo `code\nmore` prose\nwrld](target)', + kind: BusyInlineKind.code, + body: ['helo', 'prose wrld'], + title: null, + ), + ( + source: '[helo `code\nmore` prose\\\nwrld](target)', + kind: BusyInlineKind.code, + body: ['helo', 'prose wrld'], + title: null, + ), + ( + source: '[helo ![alt](\nimage.png) prose\nwrld](target)', + kind: BusyInlineKind.image, + body: ['helo', 'alt', 'prose wrld'], + title: null, + ), + ( + source: '[helo ![alt\ntext](image.png) wrld](target)', + kind: BusyInlineKind.image, + body: ['helo', 'alt text', 'wrld'], + title: null, + ), + ( + source: '[helo ![alt\ntext](image.png) prose\nwrld](target)', + kind: BusyInlineKind.image, + body: ['helo', 'alt text', 'prose wrld'], + title: null, + ), + ( + source: '[helo ![alt](image.png "tit\nle") wrld](target)', + kind: BusyInlineKind.image, + body: ['helo', 'alt', 'wrld'], + title: 'tit le', + ), + ( + source: '[helo ![alt](image.png "tit\nle") prose\nwrld](target)', + kind: BusyInlineKind.image, + body: ['helo', 'alt', 'prose wrld'], + title: 'tit le', + ), + ( + source: '> - [helo `code\n> more` wrld](target)', + kind: BusyInlineKind.code, + body: ['helo', 'wrld'], + title: null, + ), + ( + source: '> - [helo ![alt](\r\n> image.png) wrld](target)', + kind: BusyInlineKind.image, + body: ['helo', 'alt', 'wrld'], + title: null, + ), + ]) { + _expectNestedMultilineLabel( + source: fixture.source, + nestedKind: fixture.kind, + bodyRuns: fixture.body, + titleText: fixture.title, + ); + } + }); + + test('metadata scalar tag-like text remains checkable', () { + for (final source in [ + '[hello](target "Use wrld & more")', + 'hello', + ]) { + final result = const MarkdownSpellingProjector().project( + filePath: '/tmp/metadata-scalar.md', + source: source, + mode: MarkdownMode.commonMark, + languageId: 'en-Test', + snapshot: _snapshot, + ); + expect(result.complete, isTrue, reason: source); + expect(result.runs.every((run) => run.hasValidMapping), isTrue); + final all = result.runs.map((run) => run.text).join(' '); + expect(all, contains('wrld'), reason: source); + expect(all, contains('&'), reason: source); + final word = _rejected( + result.runs.singleWhere((run) => run.text.contains('wrld')), + 'wrld', + ); + expect(word.sourceStart, source.indexOf('wrld')); + expect( + const SpellingReplacementPlanner() + .build(occurrence: word, suggestion: 'world') + .applyToSource(source), + source.replaceFirst('wrld', 'world'), + ); + } + const bodyCode = 'hello wrld again'; + final body = const MarkdownSpellingProjector().project( + filePath: '/tmp/body-code.md', + source: bodyCode, + mode: MarkdownMode.commonMark, + languageId: 'en-Test', + snapshot: _snapshot, + ); + expect(body.complete, isTrue); + expect( + body.runs.map((run) => run.text).join(' '), + isNot(contains('wrld')), + ); + }); + + test('parenthesized title replacement escapes its own delimiter', () { + const source = '[hello](target (wrld))'; + final result = const MarkdownSpellingProjector().project( + filePath: '/tmp/parenthesized-title.md', + source: source, + mode: MarkdownMode.commonMark, + languageId: 'en-Test', + snapshot: _snapshot, + ); + final occurrence = _rejected( + result.runs.singleWhere((run) => run.text == 'wrld'), + 'wrld', + ); + for (final (suggestion, expected) in [ + ('world)', r'[hello](target (world\)))'), + ('world(', r'[hello](target (world\())'), + ('world()', r'[hello](target (world\(\)))'), + (r'world\done', r'[hello](target (world\\done))'), + (r'world\(x)', r'[hello](target (world\\\(x\)))'), + ]) { + final corrected = const SpellingReplacementPlanner() + .build(occurrence: occurrence, suggestion: suggestion) + .applyToSource(source); + expect(corrected, expected); + final parsed = const MarkdownParser().parse( + filePath: '/tmp/parenthesized-title.md', + source: corrected, + mode: MarkdownMode.commonMark, + ); + final link = parsed.busyDocument.blocks.single.inlines.singleWhere( + (inline) => inline.kind == BusyInlineKind.link, + ); + expect(link.destination, 'target'); + expect(link.attributes['title'], suggestion); + expect(md.markdownToHtml(corrected), contains('title="$suggestion"')); + } + }); + + test('semantic leaf blocks define independent complete prose runs', () { + const source = r''' hiddenindent + +- parentt + - nestedd + +> firstt +> +> secondd + +- visiblee + + hiddencontainercode + +before $$ mispelled $$ afterr + +before $hiddenmath$ aftermath +'''; + final projected = const MarkdownSpellingProjector().project( + filePath: '/tmp/leaves.md', + source: source, + mode: MarkdownMode.commonMark, + languageId: 'en-US', + snapshot: _snapshot, + ); + final texts = projected.runs.map((run) => run.text).toList(); + + expect(projected.complete, isTrue, reason: projected.message); + expect(texts, contains('parentt')); + expect(texts, contains('nestedd')); + expect(texts, contains('firstt')); + expect(texts, contains('secondd')); + expect(texts, contains(r'before $$ mispelled $$ afterr')); + expect(texts.join(' '), isNot(contains('hiddenindent'))); + expect(texts.join(' '), isNot(contains('hiddencontainercode'))); + expect(texts.join(' '), isNot(contains('hiddenmath'))); + expect(texts.every((text) => text != 'parentt nestedd'), isTrue); + }); + + test('projects semantic HTML prose and trusts parser math nodes', () { + const source = + '''

mispelled

hiddenbad
+ +Price \$ 5, mispelled \$ 6 and actual \$hiddenmath\$ tail. +'''; + final projected = const MarkdownSpellingProjector().project( + filePath: '/tmp/html.md', + source: source, + mode: MarkdownMode.commonMark, + languageId: 'en-US', + snapshot: _snapshot, + ); + final text = projected.runs.map((run) => run.text).join('\n'); + + expect(projected.complete, isTrue, reason: projected.message); + expect(text, contains('Readablee')); + expect(text, contains('mispelled')); + expect(text, contains(r'Price $ 5, mispelled $ 6')); + expect(text, isNot(contains('hiddenbad'))); + expect(text, isNot(contains('hiddenmath'))); + }); + + test('large formatting-heavy projection and correction stay bounded', () { + final source = List.generate( + 600, + (index) => + '**mispel**led paragraph $index with [hello](target) and `code` ' + r'$x$', + ).join('\n\n'); + final watch = Stopwatch()..start(); + final projected = const MarkdownSpellingProjector().project( + filePath: '/tmp/large-correction.md', + source: source, + mode: MarkdownMode.commonMark, + languageId: 'en-US', + snapshot: _snapshot, + ); final first = projected.runs.firstWhere( (run) => run.text.contains('mispelled'), ); - final corrected = const SpellingReplacementPlanner() - .build( - occurrence: _rejected(first, 'mispelled'), - suggestion: 'misspelled', - ) - .applyToSource(source); - watch.stop(); + final corrected = const SpellingReplacementPlanner() + .build( + occurrence: _rejected(first, 'mispelled'), + suggestion: 'misspelled', + ) + .applyToSource(source); + watch.stop(); + + expect(projected.complete, isTrue, reason: projected.message); + expect(corrected, startsWith('**misspel**led paragraph 0')); + expect(corrected, contains('**mispel**led paragraph 599')); + expect(watch.elapsed, lessThan(const Duration(seconds: 5))); + }); + }); + + group('Writerside XML spelling projection', () { + test('keeps inline body words intact across readable attributes', () { + const source = + '

documentation

'; + final result = const WritersideXmlSpellingProjector().project( + filePath: '/tmp/inline.topic', + source: source, + languageId: 'en-Test', + snapshot: const SpellingSnapshotIdentity( + bufferId: 'xml-inline', + contentRevision: 1, + documentKind: DocumentKind.writersideXmlTopic, + contextGeneration: 1, + ), + ); + expect(result.complete, isTrue); + expect(result.runs.every((run) => run.hasValidMapping), isTrue); + expect(result.runs.map((run) => run.text), contains('documentation')); + expect(result.runs.map((run) => run.text), contains('titl')); + }); + + test('corrects XML body and encoded metadata independently', () { + const source = + '

documentaton

'; + final result = const WritersideXmlSpellingProjector().project( + filePath: '/tmp/metadata.topic', + source: source, + languageId: 'en-Test', + snapshot: const SpellingSnapshotIdentity( + bufferId: 'xml-metadata', + contentRevision: 1, + documentKind: DocumentKind.writersideXmlTopic, + contextGeneration: 1, + ), + ); + expect(result.complete, isTrue); + expect(result.runs.every((run) => run.hasValidMapping), isTrue); + final body = result.runs.singleWhere((run) => run.text == 'documentaton'); + expect( + const SpellingReplacementPlanner() + .build( + occurrence: _rejected(body, 'documentaton'), + suggestion: 'documentation', + ) + .applyToSource(source), + source.replaceFirst('taton', 'tation'), + ); + final attribute = result.runs.singleWhere( + (run) => run.text == 'titl & titl', + ); + expect( + const SpellingReplacementPlanner() + .build( + occurrence: _rejected(attribute, 'titl'), + suggestion: 'title', + ) + .applyToSource(source), + source.replaceFirst('"titl &', '"title &'), + ); + }); + + test('excludes visible addresses in encoded text and CDATA', () { + for (final body in [ + 'helo https://example.invalid/path wrld', + 'helo https://examp&le.invalid/path wrld', + '', + ]) { + final source = '

$body

'; + final result = const WritersideXmlSpellingProjector().project( + filePath: '/tmp/addresses.topic', + source: source, + languageId: 'en-Test', + snapshot: const SpellingSnapshotIdentity( + bufferId: 'xml-address', + contentRevision: 1, + documentKind: DocumentKind.writersideXmlTopic, + contextGeneration: 1, + ), + ); + expect(result.complete, isTrue, reason: body); + expect(result.runs.every((run) => run.hasValidMapping), isTrue); + final all = result.runs.map((run) => run.text).join(' '); + expect(all, contains('helo'), reason: body); + expect(all, contains('wrld'), reason: body); + expect(all, isNot(contains('example.invalid')), reason: body); + expect(all, isNot(contains('examp&le.invalid')), reason: body); + final run = result.runs.firstWhere((run) => run.text.contains('wrld')); + final occurrence = _rejected(run, 'wrld'); + expect( + source.substring(occurrence.sourceStart!, occurrence.sourceEnd!), + 'wrld', + ); + expect( + const SpellingReplacementPlanner() + .build(occurrence: occurrence, suggestion: 'world') + .applyToSource(source), + source.replaceFirst('wrld', 'world'), + ); + } + }); + + test('maps text, entities, CDATA, and positive attributes', () { + const source = ''' +

Repeeted & formmatted.

+

+ hiddenbad + + Altternativ text +
'''; + final result = const WritersideXmlSpellingProjector().project( + filePath: '/tmp/topic.topic', + source: source, + languageId: 'en-US', + snapshot: const SpellingSnapshotIdentity( + bufferId: 'xml', + contentRevision: 1, + documentKind: DocumentKind.writersideXmlTopic, + contextGeneration: 1, + ), + ); + final text = result.runs.map((run) => run.text).join('\n'); + expect(result.complete, isTrue); + expect(text, contains('Titlle')); + expect(text, contains('Repeeted & formmatted')); + expect(text, contains('Cdataa prose')); + expect(text, contains('Altternativ text')); + expect(text, isNot(contains('hiddenbad'))); + expect(text, isNot(contains('other.topic'))); + expect(text, isNot(contains('path/teh.png'))); + }); + + test('reports malformed XML as incomplete without guessed targets', () { + final result = const WritersideXmlSpellingProjector().project( + filePath: '/tmp/topic.topic', + source: '

broken', + languageId: 'en-US', + snapshot: const SpellingSnapshotIdentity( + bufferId: 'xml', + contentRevision: 1, + documentKind: DocumentKind.writersideXmlTopic, + contextGeneration: 1, + ), + ); + expect(result.complete, isFalse); + expect(result.runs, isEmpty); + }); + + test( + 'clips CDATA occurrences while preserving repeated surrounding text', + () { + const source = + '

plain helo

'; + final projected = const WritersideXmlSpellingProjector().project( + filePath: '/tmp/cdata.topic', + source: source, + languageId: 'en-US', + snapshot: const SpellingSnapshotIdentity( + bufferId: 'xml', + contentRevision: 1, + documentKind: DocumentKind.writersideXmlTopic, + contextGeneration: 1, + ), + ); + final run = projected.runs.firstWhere( + (candidate) => candidate.text.contains('hello helo helo world'), + ); + final first = _rejected(run, 'helo'); + + expect(source.substring(first.sourceStart!, first.sourceEnd!), 'helo'); + expect(first.sourceIntervals, hasLength(1)); + expect( + const SpellingReplacementPlanner() + .build(occurrence: first, suggestion: 'hello') + .applyToSource(source), + '

plain helo

', + ); + + final transformed = SpellingSourceAtom( + logicalText: 'xy', + logicalStart: 0, + logicalEnd: 2, + sourceStart: 10, + sourceEnd: 18, + transformation: SpellingTransformationKind.entity, + context: SpellingSourceContext.xmlText, + ); + expect(transformed.sourceIntervalFor(1, 2)?.start, 10); + expect(transformed.sourceIntervalFor(1, 2)?.end, 18); + }, + ); + + test('maps line breaks, excludes variables, and includes controls', () { + const source = r''' +

hello
world %projectname% prose %\escaped%

+

tail

+ Setttings +
'''; + final projected = const WritersideXmlSpellingProjector().project( + filePath: '/tmp/variables.topic', + source: source, + languageId: 'en-US', + snapshot: const SpellingSnapshotIdentity( + bufferId: 'xml', + contentRevision: 1, + documentKind: DocumentKind.writersideXmlTopic, + contextGeneration: 1, + ), + ); + final text = projected.runs.map((run) => run.text).join('\n'); + + expect(projected.complete, isTrue, reason: projected.message); + expect(text, contains('hello world')); + expect(text, isNot(contains('helloworld'))); + expect(text, isNot(contains('projectname'))); + expect(text, contains(r'%\escaped%')); + expect(text, contains('Titlle')); + expect(text, contains('Setttings')); + }); + + test('excludes entity-encoded variables after XML decoding', () { + const source = r''' +

before %projectname% after %other_name% literal %\escaped%

+

tail

+
'''; + final projected = const WritersideXmlSpellingProjector().project( + filePath: '/tmp/encoded-variables.topic', + source: source, + languageId: 'en-US', + snapshot: const SpellingSnapshotIdentity( + bufferId: 'xml', + contentRevision: 1, + documentKind: DocumentKind.writersideXmlTopic, + contextGeneration: 1, + ), + ); + final text = projected.runs.map((run) => run.text).join('\n'); + + expect(projected.complete, isTrue, reason: projected.message); + expect(text, contains('before ')); + expect(text, contains(' after ')); + expect(text, contains(r'literal %\escaped%')); + expect(text, isNot(contains('projectname'))); + expect(text, isNot(contains('other_name'))); + expect(text, isNot(contains('project'))); + }); + + test('keeps decomposed Unicode in XML and CDATA corrections', () { + for (final source in [ + '

cafe\u0301x

', + '

', + ]) { + final projected = const WritersideXmlSpellingProjector().project( + filePath: '/tmp/unicode.topic', + source: source, + languageId: 'en-US', + snapshot: const SpellingSnapshotIdentity( + bufferId: 'xml', + contentRevision: 1, + documentKind: DocumentKind.writersideXmlTopic, + contextGeneration: 1, + ), + ); + final run = projected.runs.singleWhere( + (candidate) => candidate.text.contains('cafe\u0301x'), + ); + expect( + const SpellingReplacementPlanner() + .build( + occurrence: _rejected(run, 'cafe\u0301x'), + suggestion: 'café', + ) + .applyToSource(source), + source.replaceFirst('cafe\u0301x', 'cafe\u0301'), + ); + } + }); + }); + + group('exact rich correction', () { + test('nested image descriptions retain recursive spelling policy', () { + for (final (source, expectedRuns) in <(String, List)>[ + ( + '![outer ![visit https://example.invalid wrld](inner.png) tail](outer.png)', + ['outer ', 'visit ', ' wrld', ' tail'], + ), + ( + '![outer ![mail test@example.invalid wrld](inner.png) tail](outer.png)', + ['outer ', 'mail ', ' wrld', ' tail'], + ), + ( + r'![outer ![hello $x$ wrld](inner.png) tail](outer.png)', + ['outer ', 'hello ', ' wrld', ' tail'], + ), + ( + '![outer ![hello `wrld`](inner.png) tail](outer.png)', + ['outer ', 'hello wrld', ' tail'], + ), + ( + '![outer ![hello
wrld](inner.png) tail](outer.png)', + ['outer ', 'hello
wrld', ' tail'], + ), + ( + '![outer ![hello wrld](inner.png) tail](outer.png)', + ['outer ', 'hello wrld', ' tail'], + ), + ( + '![outer ![café wrld](inner.png) tail](outer.png)', + ['outer ', 'café wrld', ' tail'], + ), + ( + '![outer ![hello\nwrld](inner.png) tail](outer.png)', + ['outer ', 'hello wrld', ' tail'], + ), + ( + '> - ![outer ![hello\n> wrld](inner.png) tail](outer.png)', + ['outer ', 'hello wrld', ' tail'], + ), + ( + '![outer ![wrld](one.png) middle ![wrld](two.png) tail](outer.png)', + ['outer ', 'wrld', ' middle ', 'wrld', ' tail'], + ), + ]) { + final document = const MarkdownParser() + .parse( + filePath: '/tmp/nested-image-policy.md', + source: source, + mode: MarkdownMode.commonMark, + ) + .busyDocument; + Iterable blockInlines(Iterable blocks) sync* { + for (final block in blocks) { + yield* block.inlines; + yield* blockInlines(block.children); + } + } - expect(projected.complete, isTrue, reason: projected.message); - expect(corrected, startsWith('**misspel**led paragraph 0')); - expect(corrected, contains('**mispel**led paragraph 599')); - expect(watch.elapsed, lessThan(const Duration(seconds: 5))); + final outer = blockInlines( + document.blocks, + ).singleWhere((inline) => inline.kind == BusyInlineKind.image); + expect(outer.text, contains('wrld'), reason: source); + final sourceResult = const MarkdownSpellingProjector().project( + filePath: document.filePath, + source: source, + mode: document.mode, + languageId: 'en-Test', + snapshot: _snapshot, + ); + final richResult = const WysiwygSpellingProjector().project( + document: document, + languageId: 'en-Test', + snapshot: _snapshot, + documentGeneration: 1, + ); + expect(sourceResult.complete, isTrue, reason: source); + final nestedCount = source.contains('one.png') ? 2 : 1; + expect( + sourceResult.imageDescriptions, + hasLength(nestedCount + 1), + reason: source, + ); + expect( + sourceResult.imageDescriptions.first.inlines.where( + (inline) => inline.kind == BusyInlineKind.image, + ), + hasLength(nestedCount), + reason: source, + ); + expect( + richResult.complete, + isTrue, + reason: '$source: ${richResult.message}', + ); + expect(sourceResult.runs.every((run) => run.hasValidMapping), isTrue); + expect(richResult.runs.every((run) => run.hasValidMapping), isTrue); + expect( + sourceResult.runs.map((run) => run.text).toList(), + expectedRuns, + reason: source, + ); + expect( + richResult.runs.map((run) => run.text).toList(), + expectedRuns, + reason: source, + ); + final richText = richResult.runs.map((run) => run.text).join(' '); + expect(richText, isNot(contains('example.invalid'))); + expect(richText, isNot(contains('test@example.invalid'))); + expect(richText, isNot(contains('inner.png'))); + expect(richText, isNot(contains('one.png'))); + expect(richText, isNot(contains('two.png'))); + expect(richText, isNot(contains('outer.png'))); + final runsWithWord = richResult.runs + .where((run) => run.text.contains('wrld')) + .toList(); + expect(runsWithWord, hasLength(nestedCount), reason: source); + final run = runsWithWord.first; + final occurrence = _rejected(run, 'wrld'); + expect(occurrence.sourceStart, source.indexOf('wrld')); + expect( + occurrence.fieldStart, + outer.text.indexOf('wrld'), + reason: source, + ); + final plan = const SpellingReplacementPlanner().build( + occurrence: occurrence, + suggestion: 'world', + ); + expect( + plan.applyToSource(source), + source.replaceFirst('wrld', 'world'), + ); + final target = run.target as SpellingRichBlockTarget; + final controller = BusyMarkWysiwygDocumentController( + document: document, + ); + addTearDown(controller.dispose); + final correctionPlan = nestedCount == 2 + ? const SpellingReplacementPlanner().build( + occurrence: _rejected(runsWithWord.last, 'wrld'), + suggestion: 'world', + ) + : plan; + final correctionStart = nestedCount == 2 + ? source.lastIndexOf('wrld') + : source.indexOf('wrld'); + final expected = source.replaceRange( + correctionStart, + correctionStart + 4, + 'world', + ); + expect( + controller.replaceSpellingInBlock( + blockId: target.blockId, + expectedFieldText: busyMarkWysiwygEditableText( + controller.blockById(target.blockId)!, + ), + plan: correctionPlan, + preparedSource: expected, + ), + isTrue, + reason: source, + ); + expect(controller.markdown, expected, reason: source); + if (nestedCount == 2) { + final second = _rejected(runsWithWord.last, 'wrld'); + expect(second.sourceStart, source.lastIndexOf('wrld')); + expect(second.fieldStart, outer.text.lastIndexOf('wrld')); + expect( + const SpellingReplacementPlanner() + .build(occurrence: second, suggestion: 'world') + .applyToSource(source), + source.replaceRange( + source.lastIndexOf('wrld'), + source.lastIndexOf('wrld') + 4, + 'world', + ), + ); + } + } }); - }); - group('Writerside XML spelling projection', () { - test('maps text, entities, CDATA, and positive attributes', () { - const source = ''' -

Repeeted & formmatted.

-

- hiddenbad - - Altternativ text -
'''; - final result = const WritersideXmlSpellingProjector().project( - filePath: '/tmp/topic.topic', - source: source, - languageId: 'en-US', - snapshot: const SpellingSnapshotIdentity( - bufferId: 'xml', - contentRevision: 1, - documentKind: DocumentKind.writersideXmlTopic, - contextGeneration: 1, + test('source-backed rich correction preserves images beside math', () { + for (final source in [ + r'Before ![hello `wrld`](image.png) $x$ after', + r'Before ![hello *wrld*](image.png "title") $x$ after', + r'Before ![café wrld](image.png) $x$ after', + 'Before ![hello `wrld\nagain`](image.png) \$x\$ after', + '> Before ![hello `wrld`](image.png) \$x\$ after', + '- Before ![hello `wrld`](image.png) \$x\$ after', + r'Before ![outer ![hello `wrld`](inner.png) tail](outer.png) $x$ after', + r'Before ![outer ![visit https://example.invalid wrld](inner.png) tail](outer.png) $x$ after', + ]) { + final document = const MarkdownParser() + .parse( + filePath: '/tmp/image-math-spelling.md', + source: source, + mode: MarkdownMode.commonMark, + ) + .busyDocument; + final projection = const WysiwygSpellingProjector().project( + document: document, + languageId: 'en-Test', + snapshot: _snapshot, + documentGeneration: 1, + ); + expect( + projection.complete, + isTrue, + reason: '$source: ${projection.message}', + ); + final run = projection.runs.singleWhere( + (run) => run.text.contains('wrld'), + ); + final occurrence = _rejected(run, 'wrld'); + final plan = const SpellingReplacementPlanner().build( + occurrence: occurrence, + suggestion: 'world', + ); + final expected = source.replaceFirst('wrld', 'world'); + expect(plan.applyToSource(source), expected); + final target = run.target as SpellingRichBlockTarget; + final controller = BusyMarkWysiwygDocumentController( + document: document, + ); + addTearDown(controller.dispose); + final fieldText = busyMarkWysiwygEditableText( + controller.blockById(target.blockId)!, + ); + if (source.startsWith('> ') || source.startsWith('- ')) { + expect(fieldText, startsWith('Before '), reason: source); + } + expect( + controller.replaceSpellingInBlock( + blockId: target.blockId, + expectedFieldText: fieldText, + plan: plan, + preparedSource: expected, + ), + isTrue, + reason: source, + ); + expect(controller.markdown, expected, reason: source); + } + }); + + test( + 'source-backed table image and math correction preserves cell source', + () { + const source = + '| Image |\n| --- |\n| ![hello `wrld`](image.png) \$x\$ |\n'; + final document = const MarkdownParser() + .parse( + filePath: '/tmp/table-image-math.md', + source: source, + mode: MarkdownMode.gfm, + ) + .busyDocument; + final projection = const WysiwygSpellingProjector().project( + document: document, + languageId: 'en-Test', + snapshot: _snapshot, + documentGeneration: 1, + ); + expect(projection.complete, isTrue, reason: projection.message); + final run = projection.runs.singleWhere( + (run) => run.text.contains('wrld'), + ); + final occurrence = _rejected(run, 'wrld'); + expect(occurrence.sourceStart, source.indexOf('wrld')); + final plan = const SpellingReplacementPlanner().build( + occurrence: occurrence, + suggestion: 'world', + ); + final expected = source.replaceFirst('wrld', 'world'); + expect(plan.applyToSource(source), expected); + final target = run.target as SpellingRichTableCellTarget; + final controller = BusyMarkWysiwygDocumentController( + document: document, + ); + addTearDown(controller.dispose); + expect( + controller.replaceSpellingInTableCell( + tableBlockId: target.tableBlockId, + cellId: target.cellId, + expectedFieldText: busyMarkWysiwygEditableText( + controller.blockById(target.cellId)!, + ), + plan: plan, + preparedSource: expected, + ), + isTrue, + ); + expect(controller.markdown, expected); + }, + ); + + test('neighboring prose in a math image field keeps exact source', () { + const source = 'hello ![hello `wrld`](image.png) \$x\$ aftr'; + final document = const MarkdownParser() + .parse( + filePath: '/tmp/image-math-neighbor.md', + source: source, + mode: MarkdownMode.commonMark, + ) + .busyDocument; + final projection = const WysiwygSpellingProjector().project( + document: document, + languageId: 'en-Test', + snapshot: _snapshot, + documentGeneration: 1, + ); + expect(projection.complete, isTrue, reason: projection.message); + final run = projection.runs.singleWhere( + (run) => run.text.contains('aftr'), + ); + final occurrence = _rejected(run, 'aftr'); + expect(occurrence.sourceStart, source.indexOf('aftr')); + final plan = const SpellingReplacementPlanner().build( + occurrence: occurrence, + suggestion: 'after', + ); + const expected = 'hello ![hello `wrld`](image.png) \$x\$ after'; + expect(plan.applyToSource(source), expected); + final target = run.target as SpellingRichBlockTarget; + final controller = BusyMarkWysiwygDocumentController(document: document); + addTearDown(controller.dispose); + expect( + controller.replaceSpellingInBlock( + blockId: target.blockId, + expectedFieldText: busyMarkWysiwygEditableText( + controller.blockById(target.blockId)!, + ), + plan: plan, + preparedSource: expected, ), + isTrue, ); - final text = result.runs.map((run) => run.text).join('\n'); - expect(result.complete, isTrue); - expect(text, contains('Titlle')); - expect(text, contains('Repeeted & formmatted')); - expect(text, contains('Cdataa prose')); - expect(text, contains('Altternativ text')); - expect(text, isNot(contains('hiddenbad'))); - expect(text, isNot(contains('other.topic'))); - expect(text, isNot(contains('path/teh.png'))); + expect(controller.markdown, expected); }); - test('reports malformed XML as incomplete without guessed targets', () { - final result = const WritersideXmlSpellingProjector().project( - filePath: '/tmp/topic.topic', - source: '

broken', - languageId: 'en-US', - snapshot: const SpellingSnapshotIdentity( - bufferId: 'xml', - contentRevision: 1, - documentKind: DocumentKind.writersideXmlTopic, - contextGeneration: 1, - ), + test('image ownership continues across ordinary and math fields', () { + const source = + '![hello wrld](one.png)\n\nhello \$x\$ ![hello wrld](two.png)\n\n![hello wrld](three.png)'; + final document = const MarkdownParser() + .parse( + filePath: '/tmp/image-math-order.md', + source: source, + mode: MarkdownMode.commonMark, + ) + .busyDocument; + final projected = const WysiwygSpellingProjector().project( + document: document, + languageId: 'en-Test', + snapshot: _snapshot, + documentGeneration: 1, + ); + expect(projected.complete, isTrue, reason: projected.message); + final occurrences = [ + for (final run in projected.runs) + if (run.text.contains('wrld')) _rejected(run, 'wrld'), + ]; + expect(occurrences, hasLength(3)); + expect( + occurrences.map((item) => item.sourceStart).toList(), + RegExp('wrld').allMatches(source).map((match) => match.start).toList(), ); - expect(result.complete, isFalse); - expect(result.runs, isEmpty); }); - test( - 'clips CDATA occurrences while preserving repeated surrounding text', - () { - const source = - '

plain helo

'; - final projected = const WritersideXmlSpellingProjector().project( - filePath: '/tmp/cdata.topic', + test('image description spelling agrees across source and rich fields', () { + for (final (source, expectedAlt) in [ + ('![hello wrld](image.png)', 'hello wrld'), + ('![hello *wrld*](image.png)', 'hello wrld'), + ('![hello `wrld`](image.png)', 'hello wrld'), + ('![hello
wrld](image.png)', 'hello
wrld'), + ('![hello wrld](image.png)', 'hello wrld'), + ( + '![hello again](image.png)', + 'hello again', + ), + ('![hello & wrld](image.png)', 'hello & wrld'), + ( + '![visit https://example.invalid wrld](image.png)', + 'visit https://example.invalid wrld', + ), + ( + '![mail test@example.invalid wrld](image.png)', + 'mail test@example.invalid wrld', + ), + (r'![hello $x$ wrld](image.png)', 'hello x wrld'), + ]) { + final document = const MarkdownParser() + .parse( + filePath: '/tmp/image-contract.md', + source: source, + mode: MarkdownMode.commonMark, + ) + .busyDocument; + Iterable inlines(Iterable roots) sync* { + for (final inline in roots) { + yield inline; + yield* inlines(inline.children); + } + } + + final image = [ + for (final block in document.blocks) ...inlines(block.inlines), + ].singleWhere((inline) => inline.kind == BusyInlineKind.image); + expect(image.text, expectedAlt, reason: source); + final sourceResult = const MarkdownSpellingProjector().project( + filePath: document.filePath, source: source, - languageId: 'en-US', - snapshot: const SpellingSnapshotIdentity( - bufferId: 'xml', - contentRevision: 1, - documentKind: DocumentKind.writersideXmlTopic, - contextGeneration: 1, - ), + mode: document.mode, + languageId: 'en-Test', + snapshot: _snapshot, ); - final run = projected.runs.firstWhere( - (candidate) => candidate.text.contains('hello helo helo world'), + final richResult = const WysiwygSpellingProjector().project( + document: document, + languageId: 'en-Test', + snapshot: _snapshot, + documentGeneration: 1, ); - final first = _rejected(run, 'helo'); + expect( + sourceResult.complete, + isTrue, + reason: + '$source: ${sourceResult.message}: ' + '${sourceResult.runs.map((run) => run.text).toList()}', + ); + expect(richResult.complete, isTrue, reason: source); + expect( + sourceResult.runs.every((run) => run.hasValidMapping), + isTrue, + reason: source, + ); + expect( + richResult.runs.every((run) => run.hasValidMapping), + isTrue, + reason: source, + ); + expect( + richResult.runs.map((run) => run.text).toList(), + sourceResult.runs.map((run) => run.text).toList(), + reason: source, + ); + if (source.contains('https://')) { + expect( + sourceResult.runs.map((run) => run.text).join(' '), + isNot(contains('example.invalid')), + ); + } + } + }); - expect(source.substring(first.sourceStart!, first.sourceEnd!), 'helo'); - expect(first.sourceIntervals, hasLength(1)); + test('rich image spelling changes only the mapped source word', () { + for (final source in [ + 'Before ![hello *wrld*](image.png "title") after', + 'Before ![hello `wrld`](image.png) after', + 'Before ![hello wrld](image.png) after', + 'Before ![hello
wrld](image.png) after', + 'Before ![visit https://example.invalid wrld](image.png) after', + 'Before ![hello & wrld](image.png) after', + r'Before ![hello $x$ wrld](image.png) after', + 'Before ![hello `wrld\n again`](image.png) after', + '> - Before ![hello `wrld\r\n> again`](image.png) after', + ]) { + final document = const MarkdownParser() + .parse( + filePath: '/tmp/image-source-preservation.md', + source: source, + mode: MarkdownMode.commonMark, + ) + .busyDocument; + final projection = const WysiwygSpellingProjector().project( + document: document, + languageId: 'en-Test', + snapshot: _snapshot, + documentGeneration: 1, + ); expect( - const SpellingReplacementPlanner() - .build(occurrence: first, suggestion: 'hello') - .applyToSource(source), - '

plain helo

', + projection.complete, + isTrue, + reason: '$source: ${projection.message}', ); - - final transformed = SpellingSourceAtom( - logicalText: 'xy', - logicalStart: 0, - logicalEnd: 2, - sourceStart: 10, - sourceEnd: 18, - transformation: SpellingTransformationKind.entity, - context: SpellingSourceContext.xmlText, + final occurrence = _rejected( + projection.runs.singleWhere((run) => run.text.contains('wrld')), + 'wrld', ); - expect(transformed.sourceIntervalFor(1, 2)?.start, 10); - expect(transformed.sourceIntervalFor(1, 2)?.end, 18); - }, - ); + expect(occurrence.sourceStart, source.indexOf('wrld'), reason: source); + final plan = const SpellingReplacementPlanner().build( + occurrence: occurrence, + suggestion: 'world', + ); + final expected = source.replaceFirst('wrld', 'world'); + expect(plan.applyToSource(source), expected, reason: source); + final target = occurrence.run.target as SpellingRichBlockTarget; + final controller = BusyMarkWysiwygDocumentController( + document: document, + ); + addTearDown(controller.dispose); + expect( + controller.replaceSpellingInBlock( + blockId: target.blockId, + expectedFieldText: busyMarkWysiwygEditableText( + controller.blockById(target.blockId)!, + ), + plan: plan, + preparedSource: expected, + ), + isTrue, + reason: source, + ); + expect(controller.markdown, expected, reason: source); + } + }); - test('maps line breaks, excludes variables, and includes controls', () { - const source = r''' -

hello
world %projectname% prose %\escaped%

-

tail

- Setttings -
'''; - final projected = const WritersideXmlSpellingProjector().project( - filePath: '/tmp/variables.topic', - source: source, - languageId: 'en-US', - snapshot: const SpellingSnapshotIdentity( - bufferId: 'xml', - contentRevision: 1, - documentKind: DocumentKind.writersideXmlTopic, - contextGeneration: 1, + test('container image code corrects after continuation in rich editor', () { + const source = + 'Before paragraph.\n\n- ![hello `wrld\n againn`](image.png)\n\nAfter paragraph.'; + final document = const MarkdownParser() + .parse( + filePath: '/tmp/container-image-code-rich.md', + source: source, + mode: MarkdownMode.commonMark, + ) + .busyDocument; + final projection = const WysiwygSpellingProjector().project( + document: document, + languageId: 'en-Test', + snapshot: _snapshot, + documentGeneration: 1, + ); + expect(projection.complete, isTrue, reason: projection.message); + final run = projection.runs.singleWhere( + (candidate) => candidate.text == 'hello wrld againn', + ); + final occurrence = _rejected(run, 'againn'); + expect(occurrence.sourceStart, source.indexOf('againn')); + final plan = const SpellingReplacementPlanner().build( + occurrence: occurrence, + suggestion: 'again', + ); + expect( + plan.applyToSource(source), + source.replaceFirst('againn', 'again'), + ); + final target = run.target as SpellingRichBlockTarget; + final controller = BusyMarkWysiwygDocumentController(document: document); + addTearDown(controller.dispose); + expect( + controller.replaceSpellingInBlock( + blockId: target.blockId, + expectedFieldText: busyMarkWysiwygEditableText( + controller.blockById(target.blockId)!, + ), + plan: plan, ), + isTrue, ); - final text = projected.runs.map((run) => run.text).join('\n'); + final corrected = controller.markdown; + expect(corrected, source.replaceFirst('againn', 'again')); + final reparsed = const MarkdownParser().parse( + filePath: '/tmp/container-image-code-rich.md', + source: corrected, + mode: MarkdownMode.commonMark, + ); + Iterable blocks(Iterable roots) sync* { + for (final block in roots) { + yield block; + yield* blocks(block.children); + } + } - expect(projected.complete, isTrue, reason: projected.message); - expect(text, contains('hello world')); - expect(text, isNot(contains('helloworld'))); - expect(text, isNot(contains('projectname'))); - expect(text, contains(r'%\escaped%')); - expect(text, contains('Titlle')); - expect(text, contains('Setttings')); + final image = [ + for (final block in blocks(reparsed.busyDocument.blocks)) + ...block.inlines, + ].singleWhere((inline) => inline.kind == BusyInlineKind.image); + expect(image.text, 'hello wrld again'); + expect(image.destination, 'image.png'); }); - test('excludes entity-encoded variables after XML decoding', () { - const source = r''' -

before %projectname% after %other_name% literal %\escaped%

-

tail

-
'''; - final projected = const WritersideXmlSpellingProjector().project( - filePath: '/tmp/encoded-variables.topic', - source: source, - languageId: 'en-US', - snapshot: const SpellingSnapshotIdentity( - bufferId: 'xml', - contentRevision: 1, - documentKind: DocumentKind.writersideXmlTopic, - contextGeneration: 1, + test('code-styled image alternative corrects through rich controller', () { + const source = 'Before ![hello `wrld`](image.png) after'; + final document = const MarkdownParser() + .parse( + filePath: '/tmp/image-code-rich.md', + source: source, + mode: MarkdownMode.commonMark, + ) + .busyDocument; + final projection = const WysiwygSpellingProjector().project( + document: document, + languageId: 'en-Test', + snapshot: _snapshot, + documentGeneration: 1, + ); + expect(projection.complete, isTrue, reason: projection.message); + final run = projection.runs.singleWhere( + (candidate) => candidate.text.contains('wrld'), + ); + expect(run.hasValidMapping, isTrue); + final target = run.target as SpellingRichBlockTarget; + final controller = BusyMarkWysiwygDocumentController(document: document); + addTearDown(controller.dispose); + final plan = const SpellingReplacementPlanner().build( + occurrence: _rejected(run, 'wrld'), + suggestion: 'world', + ); + expect( + controller.replaceSpellingInBlock( + blockId: target.blockId, + expectedFieldText: busyMarkWysiwygEditableText( + controller.blockById(target.blockId)!, + ), + plan: plan, ), + isTrue, ); - final text = projected.runs.map((run) => run.text).join('\n'); + expect(controller.markdown, 'Before ![hello `world`](image.png) after'); + final reparsed = const MarkdownParser().parse( + filePath: '/tmp/image-code-rich.md', + source: controller.markdown, + mode: MarkdownMode.commonMark, + ); + expect( + reparsed.busyDocument.blocks.first.inlines + .singleWhere((inline) => inline.kind == BusyInlineKind.image) + .text, + 'hello world', + ); + }); + test('image code punctuation correction retains literal code meaning', () { + const source = '![hello `wrld`](image.png)'; + final document = const MarkdownParser() + .parse( + filePath: '/tmp/image-code-punctuation.md', + source: source, + mode: MarkdownMode.commonMark, + ) + .busyDocument; + final projected = const WysiwygSpellingProjector().project( + document: document, + languageId: 'en-Test', + snapshot: _snapshot, + documentGeneration: 1, + ); expect(projected.complete, isTrue, reason: projected.message); - expect(text, contains('before ')); - expect(text, contains(' after ')); - expect(text, contains(r'literal %\escaped%')); - expect(text, isNot(contains('projectname'))); - expect(text, isNot(contains('other_name'))); - expect(text, isNot(contains('project'))); + final run = projected.runs.single; + final occurrence = _rejected(run, 'wrld'); + final plan = const SpellingReplacementPlanner().build( + occurrence: occurrence, + suggestion: 'world(', + ); + const expected = '![hello `world(`](image.png)'; + expect(plan.applyToSource(source), expected); + final corrected = const MarkdownParser() + .parse( + filePath: '/tmp/image-code-punctuation.md', + source: expected, + mode: MarkdownMode.commonMark, + ) + .busyDocument; + final image = corrected.blocks.single.inlines.single; + expect(image.text, 'hello world('); }); - test('keeps decomposed Unicode in XML and CDATA corrections', () { - for (final source in [ - '

cafe\u0301x

', - '

', - ]) { - final projected = const WritersideXmlSpellingProjector().project( - filePath: '/tmp/unicode.topic', - source: source, - languageId: 'en-US', - snapshot: const SpellingSnapshotIdentity( - bufferId: 'xml', - contentRevision: 1, - documentKind: DocumentKind.writersideXmlTopic, - contextGeneration: 1, + test('multiline link label correction uses rich controller mapping', () { + const source = '[helo ![alt\ntext](image.png) wrld](target)'; + final document = const MarkdownParser() + .parse( + filePath: '/tmp/nested-multiline-rich.md', + source: source, + mode: MarkdownMode.commonMark, + ) + .busyDocument; + final projection = const WysiwygSpellingProjector().project( + document: document, + languageId: 'en-Test', + snapshot: _snapshot, + documentGeneration: 1, + ); + expect(projection.complete, isTrue, reason: projection.message); + final run = projection.runs.singleWhere( + (candidate) => candidate.text.contains('wrld'), + ); + final target = run.target as SpellingRichBlockTarget; + final controller = BusyMarkWysiwygDocumentController(document: document); + addTearDown(controller.dispose); + final plan = const SpellingReplacementPlanner().build( + occurrence: _rejected(run, 'wrld'), + suggestion: 'world', + ); + expect( + controller.replaceSpellingInBlock( + blockId: target.blockId, + expectedFieldText: busyMarkWysiwygEditableText( + controller.blockById(target.blockId)!, ), - ); - final run = projected.runs.singleWhere( - (candidate) => candidate.text.contains('cafe\u0301x'), - ); - expect( - const SpellingReplacementPlanner() - .build( - occurrence: _rejected(run, 'cafe\u0301x'), - suggestion: 'café', - ) - .applyToSource(source), - source.replaceFirst('cafe\u0301x', 'cafe\u0301'), - ); - } + plan: plan, + ), + isTrue, + ); + expect(controller.markdown, source.replaceFirst('wrld', 'world')); }); - }); - - group('exact rich correction', () { for (final source in [ '# Introduction\n\n- [Overview](#overview)\n - [Motivation](#motivation)\n- [Discussion](#discussion)\n\n\n## Overview\n\nhelo **world**\n\nhelo world\n', '- helo\n - helo\n\nhelo\n', @@ -1255,6 +3891,54 @@ Price \$ 5, mispelled \$ 6 and actual \$hiddenmath\$ tail. ); }); + test('rich links keep all title delimiters source-only', () { + for (final title in ['(wrld)', '"wrld"', "'wrld'"]) { + final source = '[helo](target $title)\n\nafter\n'; + final parsed = const MarkdownParser().parse( + filePath: '/tmp/rich-title.md', + source: source, + mode: MarkdownMode.commonMark, + ); + final sourceProjection = const MarkdownSpellingProjector().project( + filePath: '/tmp/rich-title.md', + source: source, + mode: MarkdownMode.commonMark, + languageId: 'en-Test', + snapshot: _snapshot, + ); + final rich = const WysiwygSpellingProjector().project( + document: parsed.busyDocument, + languageId: 'en-Test', + snapshot: _snapshot, + documentGeneration: 1, + ); + expect(sourceProjection.complete, isTrue, reason: source); + expect(rich.complete, isTrue, reason: source); + expect(rich.runs.every((run) => run.hasValidMapping), isTrue); + final label = rich.runs.singleWhere((run) => run.text == 'helo'); + final metadata = rich.runs.singleWhere((run) => run.text == 'wrld'); + final following = rich.runs.singleWhere((run) => run.text == 'after'); + expect(label.target, isA()); + expect(metadata.target, isA()); + expect(following.target, isA()); + expect( + const SpellingReplacementPlanner() + .build( + occurrence: _rejected(metadata, 'wrld'), + suggestion: 'world', + ) + .applyToSource(source), + source.replaceFirst('wrld', 'world'), + ); + expect( + const SpellingReplacementPlanner() + .build(occurrence: _rejected(label, 'helo'), suggestion: 'hello') + .applyToSource(source), + source.replaceFirst('helo', 'hello'), + ); + } + }); + test('preserves formatting ownership across leaves', () { final document = BusyDocument( filePath: '/tmp/test.md', diff --git a/test/src/spelling_editor_integration_test.dart b/test/src/spelling_editor_integration_test.dart index 026d83ee..e5ebb955 100644 --- a/test/src/spelling_editor_integration_test.dart +++ b/test/src/spelling_editor_integration_test.dart @@ -17,7 +17,9 @@ import 'package:busymark/src/spellcheck/wysiwyg_spelling_projection.dart'; import 'package:busymark/src/workspace/presentation/workspace_screen.dart'; import 'package:busymark/src/workspace/workspace_model.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; const _sourceSnapshot = SpellingSnapshotIdentity( bufferId: 'source-buffer', @@ -213,8 +215,456 @@ void main() { }, ); + testWidgets('rich image spelling preserves source and one undo step', ( + tester, + ) async { + const source = 'Before ![hello `wrld`](image.png) after\n'; + final document = const MarkdownParser() + .parse( + filePath: '/tmp/image-spelling-undo.md', + source: source, + mode: MarkdownMode.commonMark, + ) + .busyDocument; + const snapshot = SpellingSnapshotIdentity( + bufferId: 'image-spelling-undo', + contentRevision: 3, + documentKind: DocumentKind.markdown, + contextGeneration: 2, + ); + final projection = const WysiwygSpellingProjector().project( + document: document, + languageId: 'en-Test', + snapshot: snapshot, + documentGeneration: 0, + ); + expect(projection.complete, isTrue, reason: projection.message); + final occurrence = _occurrence( + projection.runs.singleWhere((run) => run.text.contains('wrld')), + word: 'wrld', + logicalStart: projection.runs + .singleWhere((run) => run.text.contains('wrld')) + .text + .indexOf('wrld'), + ); + final key = GlobalKey(); + final sources = []; + await tester.pumpWidget( + _testApp( + BusyMarkWysiwygEditor( + key: key, + document: document, + documentId: snapshot.bufferId, + contentRevision: snapshot.contentRevision, + spellingAnnotations: [_annotation(occurrence)], + onDocumentChanged: (_) {}, + onSourceChanged: (_, text) => sources.add(text), + onSpellingSourceChanged: (_, text, _, _) => sources.add(text), + ), + ), + ); + await tester.pump(); + final editor = key.currentState!; + final field = editor.spellingFieldSnapshot(occurrence)!; + final originalSource = editor.spellingSourceSnapshot(occurrence)!; + final prepared = await tester.runAsync( + () => prepareSpellingCorrection( + occurrence: occurrence, + suggestion: 'world', + source: originalSource, + field: field, + ), + ); + expect( + editor.applyPreparedSpellingCorrection( + occurrence: occurrence, + suggestion: 'world', + plan: prepared!.plan, + expectedFieldText: field, + preparedFieldText: prepared.replacementField, + expectedSource: originalSource, + preparedSource: prepared.replacementSource, + ), + isTrue, + ); + await tester.pump(); + expect(sources, ['Before ![hello `world`](image.png) after\n']); + expect(editor.debugUndoSnapshotCount, 1); + + final editable = find.byType(EditableText).first; + await tester.tap(editable); + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyZ); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(); + expect(sources.last, source); + 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(sources.last, 'Before ![hello `world`](image.png) after\n'); + }); + + for (final fixture in [ + ( + name: 'nested code and math', + source: + r'Before ![outer ![hello `wrld`](inner.png) tail](outer.png) $x$ after' + '\n', + ), + ( + name: 'nested address and math', + source: + r'Before ![outer ![visit https://example.invalid wrld](inner.png) tail](outer.png) $x$ after' + '\n', + ), + ]) { + testWidgets('rich ${fixture.name} spelling preserves source and undo', ( + tester, + ) async { + final source = fixture.source; + final expected = source.replaceFirst('wrld', 'world'); + final document = const MarkdownParser() + .parse( + filePath: '/tmp/nested-image-math-undo.md', + source: source, + mode: MarkdownMode.commonMark, + ) + .busyDocument; + const snapshot = SpellingSnapshotIdentity( + bufferId: 'nested-image-math-undo', + contentRevision: 3, + documentKind: DocumentKind.markdown, + contextGeneration: 2, + ); + final projection = const WysiwygSpellingProjector().project( + document: document, + languageId: 'en-Test', + snapshot: snapshot, + documentGeneration: 0, + ); + expect(projection.complete, isTrue, reason: projection.message); + final run = projection.runs.singleWhere( + (run) => run.text.contains('wrld'), + ); + final occurrence = _occurrence( + run, + word: 'wrld', + logicalStart: run.text.indexOf('wrld'), + ); + expect(occurrence.sourceStart, source.indexOf('wrld')); + final key = GlobalKey(); + final sources = []; + await tester.pumpWidget( + ProviderScope( + child: _testApp( + BusyMarkWysiwygEditor( + key: key, + document: document, + documentId: snapshot.bufferId, + contentRevision: snapshot.contentRevision, + spellingAnnotations: [_annotation(occurrence)], + onDocumentChanged: (_) {}, + onSourceChanged: (_, text) => sources.add(text), + onSpellingSourceChanged: (_, text, _, _) => sources.add(text), + ), + ), + ), + ); + await tester.pump(); + final editor = key.currentState!; + final field = editor.spellingFieldSnapshot(occurrence)!; + final originalSource = editor.spellingSourceSnapshot(occurrence)!; + final prepared = await tester.runAsync( + () => prepareSpellingCorrection( + occurrence: occurrence, + suggestion: 'world', + source: originalSource, + field: field, + ), + ); + expect( + editor.applyPreparedSpellingCorrection( + occurrence: occurrence, + suggestion: 'world', + plan: prepared!.plan, + expectedFieldText: field, + preparedFieldText: prepared.replacementField, + expectedSource: originalSource, + preparedSource: prepared.replacementSource, + ), + isTrue, + ); + await tester.pump(); + expect(sources, [expected]); + expect(editor.debugUndoSnapshotCount, 1); + final target = occurrence.run.target as SpellingRichBlockTarget; + await tester.tap( + find.byKey(ValueKey('wysiwyg-rendered-math-${target.blockId}')), + ); + await tester.pump(); + final editable = find.byType(EditableText).first; + await tester.tap(editable); + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyZ); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(); + expect(sources.last, source); + 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(sources.last, expected); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(const Duration(seconds: 11)); + }); + } + + testWidgets('rich table image spelling preserves authored cell syntax', ( + tester, + ) async { + const source = '| Image |\n| --- |\n| ![hello `wrld`](image.png) |\n'; + final document = const MarkdownParser() + .parse( + filePath: '/tmp/table-image-spelling.md', + source: source, + mode: MarkdownMode.gfm, + ) + .busyDocument; + const snapshot = SpellingSnapshotIdentity( + bufferId: 'table-image-spelling', + contentRevision: 4, + documentKind: DocumentKind.markdown, + contextGeneration: 2, + ); + final projection = const WysiwygSpellingProjector().project( + document: document, + languageId: 'en-Test', + snapshot: snapshot, + documentGeneration: 0, + ); + expect(projection.complete, isTrue, reason: projection.message); + final run = projection.runs.singleWhere((run) => run.text.contains('wrld')); + final occurrence = _occurrence( + run, + word: 'wrld', + logicalStart: run.text.indexOf('wrld'), + ); + expect(run.target, isA()); + final key = GlobalKey(); + String? committed; + await tester.pumpWidget( + _testApp( + BusyMarkWysiwygEditor( + key: key, + document: document, + documentId: snapshot.bufferId, + contentRevision: snapshot.contentRevision, + spellingAnnotations: [_annotation(occurrence)], + onDocumentChanged: (_) {}, + onSourceChanged: (_, _) {}, + onSpellingSourceChanged: (_, text, _, _) => committed = text, + ), + ), + ); + await tester.pump(); + final editor = key.currentState!; + final expectedSource = editor.spellingSourceSnapshot(occurrence)!; + final field = editor.spellingFieldSnapshot(occurrence)!; + final prepared = await tester.runAsync( + () => prepareSpellingCorrection( + occurrence: occurrence, + suggestion: 'world', + source: expectedSource, + field: field, + ), + ); + expect( + editor.applyPreparedSpellingCorrection( + occurrence: occurrence, + suggestion: 'world', + plan: prepared!.plan, + expectedFieldText: field, + preparedFieldText: prepared.replacementField, + expectedSource: expectedSource, + preparedSource: prepared.replacementSource, + ), + isTrue, + ); + await tester.pump(); + expect(committed, '| Image |\n| --- |\n| ![hello `world`](image.png) |\n'); + }); + + testWidgets('delayed rich image preparation refuses a newer revision', ( + tester, + ) async { + const source = 'hello ![hello `wrld`](image.png)\n'; + final document = const MarkdownParser() + .parse( + filePath: '/tmp/stale-image-spelling.md', + source: source, + mode: MarkdownMode.commonMark, + ) + .busyDocument; + const snapshot = SpellingSnapshotIdentity( + bufferId: 'stale-image-spelling', + contentRevision: 5, + documentKind: DocumentKind.markdown, + contextGeneration: 2, + ); + final projection = const WysiwygSpellingProjector().project( + document: document, + languageId: 'en-Test', + snapshot: snapshot, + documentGeneration: 0, + ); + final run = projection.runs.singleWhere((run) => run.text.contains('wrld')); + final occurrence = _occurrence( + run, + word: 'wrld', + logicalStart: run.text.indexOf('wrld'), + ); + final key = GlobalKey(); + final changes = []; + BusyMarkWysiwygEditor editor(BusyDocument value, int revision) => + BusyMarkWysiwygEditor( + key: key, + document: value, + documentId: snapshot.bufferId, + contentRevision: revision, + spellingAnnotations: [_annotation(occurrence)], + onDocumentChanged: (_) {}, + onSourceChanged: (_, text) => changes.add(text), + onSpellingSourceChanged: (_, text, _, _) => changes.add(text), + ); + await tester.pumpWidget(_testApp(editor(document, 5))); + await tester.pump(); + final field = key.currentState!.spellingFieldSnapshot(occurrence)!; + final expectedSource = key.currentState!.spellingSourceSnapshot( + occurrence, + )!; + final prepared = await tester.runAsync( + () => prepareSpellingCorrection( + occurrence: occurrence, + suggestion: 'world', + source: expectedSource, + field: field, + ), + ); + final newer = const MarkdownParser() + .parse( + filePath: '/tmp/stale-image-spelling.md', + source: 'hello ![hello `wrld`](image.png) again\n', + mode: MarkdownMode.commonMark, + ) + .busyDocument; + await tester.pumpWidget(_testApp(editor(newer, 6))); + await tester.pump(); + expect( + key.currentState!.applyPreparedSpellingCorrection( + occurrence: occurrence, + suggestion: 'world', + plan: prepared!.plan, + expectedFieldText: field, + preparedFieldText: prepared.replacementField, + expectedSource: expectedSource, + preparedSource: prepared.replacementSource, + ), + isFalse, + ); + expect(changes, isEmpty); + expect(key.currentState!.debugUndoSnapshotCount, 0); + }); + + testWidgets('delayed math image preparation refuses a newer revision', ( + tester, + ) async { + const source = 'hello ![hello `wrld`](image.png) \$x\$\n'; + const newerSource = 'hello ![hello `wrld`](image.png) \$x\$ again\n'; + BusyDocument parse(String value) => const MarkdownParser() + .parse( + filePath: '/tmp/stale-math-image-spelling.md', + source: value, + mode: MarkdownMode.commonMark, + ) + .busyDocument; + final document = parse(source); + const snapshot = SpellingSnapshotIdentity( + bufferId: 'stale-math-image-spelling', + contentRevision: 5, + documentKind: DocumentKind.markdown, + contextGeneration: 2, + ); + final projection = const WysiwygSpellingProjector().project( + document: document, + languageId: 'en-Test', + snapshot: snapshot, + documentGeneration: 0, + ); + expect(projection.complete, isTrue, reason: projection.message); + final run = projection.runs.singleWhere((run) => run.text.contains('wrld')); + final occurrence = _occurrence( + run, + word: 'wrld', + logicalStart: run.text.indexOf('wrld'), + ); + final key = GlobalKey(); + final changes = []; + BusyMarkWysiwygEditor editor(BusyDocument value, int revision) => + BusyMarkWysiwygEditor( + key: key, + document: value, + documentId: snapshot.bufferId, + contentRevision: revision, + spellingAnnotations: [_annotation(occurrence)], + onDocumentChanged: (_) {}, + onSourceChanged: (_, text) => changes.add(text), + onSpellingSourceChanged: (_, text, _, _) => changes.add(text), + ); + await tester.pumpWidget( + ProviderScope(child: _testApp(editor(document, 5))), + ); + await tester.pump(); + final field = key.currentState!.spellingFieldSnapshot(occurrence)!; + final expectedSource = key.currentState!.spellingSourceSnapshot( + occurrence, + )!; + final prepared = await tester.runAsync( + () => prepareSpellingCorrection( + occurrence: occurrence, + suggestion: 'world', + source: expectedSource, + field: field, + ), + ); + await tester.pumpWidget( + ProviderScope(child: _testApp(editor(parse(newerSource), 6))), + ); + await tester.pump(); + expect( + key.currentState!.applyPreparedSpellingCorrection( + occurrence: occurrence, + suggestion: 'world', + plan: prepared!.plan, + expectedFieldText: field, + preparedFieldText: prepared.replacementField, + expectedSource: expectedSource, + preparedSource: prepared.replacementSource, + ), + isFalse, + ); + expect(changes, isEmpty); + expect(key.currentState!.debugUndoSnapshotCount, 0); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(const Duration(seconds: 11)); + }); + testWidgets( - 'rich correction translates later anchors from serialized source', + 'rich correction preserves entities and translates later anchors', (tester) async { const source = 'helo café\n\nwrld\n'; final document = const MarkdownParser() @@ -276,16 +726,12 @@ void main() { ); await tester.pump(); - expect(committedSource, 'hello café\n\nwrld\n'); + expect(committedSource, 'hello café\n\nwrld\n'); final actual = busyMarkMinimalSourceEdit(source, committedSource!); final oldAnchor = source.indexOf('wrld'); final translatedAnchor = oldAnchor + (actual.newEnd - actual.oldEnd); expect(translatedAnchor, committedSource!.indexOf('wrld')); - expect( - planned.translateSourceOffset(oldAnchor), - isNot(translatedAnchor), - reason: 'The planned word delta cannot represent entity normalization.', - ); + expect(planned.translateSourceOffset(oldAnchor), translatedAnchor); }, ); @@ -356,7 +802,7 @@ void main() { ); }); - testWidgets('rich table correction uses serialized table source delta', ( + testWidgets('rich table correction preserves encoded cell source', ( tester, ) async { const source = @@ -418,7 +864,7 @@ void main() { ); await tester.pump(); - expect(committedSource, contains('| hello café |')); + expect(committedSource, contains('| hello café |')); final actual = busyMarkMinimalSourceEdit(source, committedSource!); final oldAnchor = source.indexOf('wrld'); expect( diff --git a/test/src/spelling_service_test.dart b/test/src/spelling_service_test.dart index 9bb3ed89..4f621b8c 100644 --- a/test/src/spelling_service_test.dart +++ b/test/src/spelling_service_test.dart @@ -5,8 +5,11 @@ import 'dart:io'; import 'package:busymark/l10n/generated/app_localizations.dart'; import 'package:busymark/l10n/generated/app_localizations_en.dart'; import 'package:busymark/src/app/app_settings.dart'; +import 'package:busymark/src/app/app_router.dart'; +import 'package:busymark/src/app/busymark_design.dart'; import 'package:busymark/src/app/busymark_toast.dart'; import 'package:busymark/src/core/atomic_file_writer.dart'; +import 'package:busymark/src/platform/header_bar_configuration.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'; @@ -26,9 +29,11 @@ import 'package:busymark/src/spellcheck/wysiwyg_spelling_projection.dart'; import 'package:busymark/src/spellcheck/writerside_spelling_projection.dart'; import 'package:busymark/src/workspace/document_buffer.dart'; import 'package:busymark/src/workspace/presentation/workspace_screen.dart'; +import 'package:busymark/src/workspace/presentation/settings_screen.dart'; import 'package:busymark/src/workspace/workspace_model.dart'; import 'package:crypto/crypto.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:path/path.dart' as p; @@ -744,6 +749,295 @@ void main() { expect(await _publishedDirectories(afterRoot), hasLength(1)); }); + test( + 'a stale invalid record cannot remove a repaired installation', + () async { + final root = p.join(temporary.path, 'stale-invalid'); + final destination = Directory(p.join(root, 'en-Test')); + await destination.create(recursive: true); + await File( + p.join(destination.path, 'manifest.json'), + ).writeAsString('malformed'); + final catalogRoot = Directory(p.join(temporary.path, 'stale-catalog')); + await catalogRoot.create(); + await File(p.join(catalogRoot.path, 'dictionaries.json')).writeAsString( + jsonEncode({ + 'schemaVersion': 2, + 'dictionaries': [_resourceJson(resource)], + }), + ); + final invalid = (await SpellingDictionaryCatalog.load( + bundledRoot: catalogRoot.path, + downloadedRoot: root, + )).invalidInstallations.single; + final repaired = await const SpellingDictionaryPairInstaller().install( + affSource: fixtureAff, + dicSource: fixtureDic, + spec: SpellingDictionaryInstallSpec.downloaded(resource), + destinationRoot: root, + validateNativePair: (_, _, _) async => 'UTF-8', + replaceExisting: true, + ); + await expectLater( + const SpellingDictionaryPairInstaller().removeInvalid( + installation: invalid, + installationRoot: root, + ), + throwsStateError, + ); + expect(await File(repaired.affPath).exists(), isTrue); + expect(await File(repaired.dicPath).exists(), isTrue); + }, + ); + + test( + 'a stale valid descriptor cannot remove a replacement in another process', + () async { + final root = p.join(temporary.path, 'process-removal'); + final installer = const SpellingDictionaryPairInstaller(); + final old = await installer.install( + affSource: fixtureAff, + dicSource: fixtureDic, + spec: SpellingDictionaryInstallSpec.downloaded(resource), + destinationRoot: root, + validateNativePair: (_, _, _) async => 'UTF-8', + ); + final flutterRoot = Platform.environment['FLUTTER_ROOT']; + final dartExecutable = flutterRoot == null + ? 'dart' + : p.join(flutterRoot, 'bin', 'cache', 'dart-sdk', 'bin', 'dart'); + final process = await Process.start(dartExecutable, [ + '--packages=.dart_tool/package_config.json', + p.join( + Directory.current.path, + 'test', + 'support', + 'spelling_dictionary_installer_process.dart', + ), + fixtureAff.path, + fixtureDic.path, + root, + 'remove', + ], workingDirectory: Directory.current.path); + addTearDown(() => process.kill()); + final errors = process.stderr.transform(utf8.decoder).join(); + expect( + await process.stdout + .transform(utf8.decoder) + .transform(const LineSplitter()) + .first, + 'ready', + ); + final replacement = await installer.install( + affSource: fixtureAff, + dicSource: fixtureDic, + spec: SpellingDictionaryInstallSpec( + resourceId: resource.resourceId, + id: resource.id, + locales: resource.locales, + label: resource.label, + sourceRevision: 'repaired-fixture', + affSha256: resource.affSha256, + dicSha256: resource.dicSha256, + affSize: resource.affSize, + dicSize: resource.dicSize, + kind: SpellingDictionaryInstallationKind.downloaded, + ), + destinationRoot: root, + validateNativePair: (_, _, _) async => 'UTF-8', + replaceExisting: true, + ); + process.stdin.writeln('go'); + await process.stdin.close(); + expect(await process.exitCode, 3, reason: await errors); + expect(old.directoryPath, replacement.directoryPath); + expect(File(replacement.affPath).existsSync(), isTrue); + expect(File(replacement.dicPath).existsSync(), isTrue); + final manifest = + jsonDecode( + await File( + p.join(replacement.directoryPath, 'manifest.json'), + ).readAsString(), + ) + as Map; + expect(manifest['sourceRevision'], 'repaired-fixture'); + }, + ); + + test('catalog waits through a replacement publication gap', () async { + final root = p.join(temporary.path, 'catalog-publication-gap'); + final catalogRoot = Directory(p.join(temporary.path, 'gap-catalog')); + await catalogRoot.create(); + await File(p.join(catalogRoot.path, 'dictionaries.json')).writeAsString( + jsonEncode({ + 'schemaVersion': 2, + 'dictionaries': [_resourceJson(resource)], + }), + ); + await const SpellingDictionaryPairInstaller().install( + affSource: fixtureAff, + dicSource: fixtureDic, + spec: SpellingDictionaryInstallSpec.downloaded(resource), + destinationRoot: root, + validateNativePair: (_, _, _) async => 'UTF-8', + ); + final moved = Completer(); + final release = Completer(); + final replacement = + SpellingDictionaryPairInstaller( + onReplacementMoved: () async { + moved.complete(); + await release.future; + }, + ).install( + affSource: fixtureAff, + dicSource: fixtureDic, + spec: SpellingDictionaryInstallSpec( + resourceId: resource.resourceId, + id: resource.id, + locales: resource.locales, + label: 'Replaced Test English', + sourceRevision: resource.sourceRevision, + affSha256: resource.affSha256, + dicSha256: resource.dicSha256, + affSize: resource.affSize, + dicSize: resource.dicSize, + kind: SpellingDictionaryInstallationKind.downloaded, + ), + destinationRoot: root, + validateNativePair: (_, _, _) async => 'UTF-8', + replaceExisting: true, + ); + await moved.future; + final listed = Completer(); + final catalogFuture = SpellingDictionaryCatalog.load( + bundledRoot: catalogRoot.path, + downloadedRoot: root, + onInstallationListed: (path, entries) { + if (path == root && !listed.isCompleted) { + expect( + entries.any( + (name) => name.startsWith('.busymark-replaced-en-Test-'), + ), + isTrue, + ); + listed.complete(); + } + }, + ); + await listed.future; + release.complete(); + await replacement; + final catalog = await catalogFuture; + expect(catalog.invalidInstallations, isEmpty); + expect(catalog.installedById('en-Test')?.label, 'Replaced Test English'); + }); + + test( + 'a late no-replace publisher cannot replace a completed installation', + () async { + final root = p.join(temporary.path, 'publisher-race'); + final validating = Completer(); + final release = Completer(); + var commits = 0; + final installer = SpellingDictionaryPairInstaller( + onCommitted: (_) => commits++, + ); + final first = installer.install( + affSource: fixtureAff, + dicSource: fixtureDic, + spec: SpellingDictionaryInstallSpec.downloaded(resource), + destinationRoot: root, + validateNativePair: (_, _, _) async { + validating.complete(); + await release.future; + return 'UTF-8'; + }, + ); + await validating.future; + final second = await installer.install( + affSource: fixtureAff, + dicSource: fixtureDic, + spec: SpellingDictionaryInstallSpec.downloaded(resource), + destinationRoot: root, + validateNativePair: (_, _, _) async => 'UTF-8', + ); + final before = await File( + p.join(second.directoryPath, 'manifest.json'), + ).readAsBytes(); + release.complete(); + await expectLater(first, throwsA(isA())); + expect(commits, 1); + expect( + await File( + p.join(second.directoryPath, 'manifest.json'), + ).readAsBytes(), + before, + ); + expect( + await File(second.affPath).readAsBytes(), + await fixtureAff.readAsBytes(), + ); + expect( + await File(second.dicPath).readAsBytes(), + await fixtureDic.readAsBytes(), + ); + }, + ); + + test( + 'independent process observes the published no-replace destination', + () async { + final root = p.join(temporary.path, 'process-publisher'); + final flutterRoot = Platform.environment['FLUTTER_ROOT']; + final dartExecutable = flutterRoot == null + ? 'dart' + : p.join(flutterRoot, 'bin', 'cache', 'dart-sdk', 'bin', 'dart'); + final helper = p.join( + Directory.current.path, + 'test', + 'support', + 'spelling_dictionary_installer_process.dart', + ); + final process = await Process.start(dartExecutable, [ + '--packages=.dart_tool/package_config.json', + helper, + fixtureAff.path, + fixtureDic.path, + root, + ], workingDirectory: Directory.current.path); + addTearDown(() => process.kill()); + final errors = process.stderr.transform(utf8.decoder).join(); + expect( + await process.stdout + .transform(utf8.decoder) + .transform(const LineSplitter()) + .first, + 'validating', + ); + final installed = await const SpellingDictionaryPairInstaller().install( + affSource: fixtureAff, + dicSource: fixtureDic, + spec: SpellingDictionaryInstallSpec.downloaded(resource), + destinationRoot: root, + validateNativePair: (_, _, _) async => 'UTF-8', + ); + final manifest = await File( + p.join(installed.directoryPath, 'manifest.json'), + ).readAsBytes(); + process.stdin.writeln('go'); + await process.stdin.close(); + expect(await process.exitCode, 2, reason: await errors); + expect( + await File( + p.join(installed.directoryPath, 'manifest.json'), + ).readAsBytes(), + manifest, + ); + expect(await _publishedDirectories(root), hasLength(1)); + }, + ); + test('stalled response times out, cancels, and permits retry', () async { final body = StreamController>(); final cancelled = Completer(); @@ -780,6 +1074,39 @@ void main() { expect(await destination.readAsBytes(), [1, 2]); }); + test( + 'asynchronous output sink errors fail the receiver without zone leaks', + () async { + final destination = Directory( + p.join(temporary.path, 'output-is-directory'), + ); + await destination.create(); + final uncaught = []; + final failure = await runZonedGuarded>(() async { + try { + await receiveSpellingDictionaryBody( + bytes: Stream.fromFuture( + Future.delayed( + const Duration(milliseconds: 20), + () => [1], + ), + ), + destination: File(destination.path), + expectedBytes: 1, + cancellation: SpellingDictionaryDownloadCancellation(), + onProgress: (_) {}, + ).timeout(const Duration(seconds: 2)); + return null; + } on Object catch (error) { + return error; + } + }, (error, _) => uncaught.add(error)); + expect(failure, isA()); + expect(uncaught, isEmpty); + expect(await destination.exists(), isTrue); + }, + ); + test('a failed replacement cannot damage a working installation', () async { final root = p.join(temporary.path, 'working'); final installed = @@ -887,6 +1214,176 @@ void main() { hasLength(1), ); }); + + test( + 'controller reserves an installation before coordinator startup', + () async { + final catalogRoot = Directory( + p.join(temporary.path, 'admission-catalog'), + ); + await catalogRoot.create(); + await File(p.join(catalogRoot.path, 'dictionaries.json')).writeAsString( + jsonEncode({ + 'schemaVersion': 2, + 'dictionaries': [_resourceJson(resource)], + }), + ); + final entered = Completer(); + final release = Completer(); + var downloads = 0; + final controller = SpellingSessionController( + bundledRoot: catalogRoot.path, + applicationSupportRoot: p.join(temporary.path, 'admission-support'), + dictionaryStorageRoot: p.join(temporary.path, 'admission-managed'), + coordinatorStarter: () async { + entered.complete(); + await release.future; + return SpellingCoordinator.start(); + }, + dictionaryDownloader: SpellingDictionaryDownloader( + downloadFile: + ({ + required source, + required destination, + required expectedBytes, + required cancellation, + required onProgress, + }) async { + downloads++; + await copyDownload( + source: source, + destination: destination, + expectedBytes: expectedBytes, + cancellation: cancellation, + onProgress: onProgress, + ); + }, + ), + ); + addTearDown(controller.dispose); + await controller.prepareSettings(null); + final first = controller.installDictionary(resource.id); + final firstAssertion = expectLater(first, completes); + await entered.future; + final second = controller.installDictionary(resource.id); + release.complete(); + await expectLater(second, throwsA(isA())); + await firstAssertion; + expect(downloads, 2); + expect(controller.dictionaryInstallStatus, isNull); + expect(controller.catalog?.installedById(resource.id), isNotNull); + }, + ); + + test( + 'controller releases installation ownership after setup failure', + () async { + final catalogRoot = Directory(p.join(temporary.path, 'setup-catalog')); + await catalogRoot.create(); + await File(p.join(catalogRoot.path, 'dictionaries.json')).writeAsString( + jsonEncode({ + 'schemaVersion': 2, + 'dictionaries': [_resourceJson(resource)], + }), + ); + var starts = 0; + final controller = SpellingSessionController( + bundledRoot: catalogRoot.path, + applicationSupportRoot: p.join(temporary.path, 'setup-support'), + dictionaryStorageRoot: p.join(temporary.path, 'setup-managed'), + coordinatorStarter: () { + starts++; + if (starts == 1) return Future.error(StateError('startup failed')); + return SpellingCoordinator.start(); + }, + dictionaryDownloader: SpellingDictionaryDownloader( + downloadFile: copyDownload, + ), + ); + addTearDown(controller.dispose); + await controller.prepareSettings(null); + await expectLater( + controller.installDictionary(resource.id), + throwsA(isA()), + ); + expect( + controller.dictionaryInstallStatus?.phase, + SpellingDictionaryInstallPhase.failed, + ); + await controller.retryDictionaryInstallation(); + expect(starts, 2); + expect(controller.dictionaryInstallStatus, isNull); + expect(controller.catalog?.installedById(resource.id), isNotNull); + }, + ); + + test( + 'controller cancellation remains with the reserved startup operation', + () async { + final catalogRoot = Directory( + p.join(temporary.path, 'cancel-startup-catalog'), + ); + await catalogRoot.create(); + await File(p.join(catalogRoot.path, 'dictionaries.json')).writeAsString( + jsonEncode({ + 'schemaVersion': 2, + 'dictionaries': [_resourceJson(resource)], + }), + ); + final entered = Completer(); + final release = Completer(); + var downloads = 0; + final controller = SpellingSessionController( + bundledRoot: catalogRoot.path, + applicationSupportRoot: p.join( + temporary.path, + 'cancel-startup-support', + ), + dictionaryStorageRoot: p.join( + temporary.path, + 'cancel-startup-managed', + ), + coordinatorStarter: () async { + entered.complete(); + await release.future; + return SpellingCoordinator.start(); + }, + dictionaryDownloader: SpellingDictionaryDownloader( + downloadFile: + ({ + required source, + required destination, + required expectedBytes, + required cancellation, + required onProgress, + }) async { + downloads++; + await copyDownload( + source: source, + destination: destination, + expectedBytes: expectedBytes, + cancellation: cancellation, + onProgress: onProgress, + ); + }, + ), + ); + addTearDown(controller.dispose); + await controller.prepareSettings(null); + final first = controller.installDictionary(resource.id); + await entered.future; + controller.cancelDictionaryInstallation(); + await expectLater( + controller.installDictionary(resource.id), + throwsA(isA()), + ); + release.complete(); + await first; + expect(downloads, 0); + expect(controller.dictionaryInstallStatus, isNull); + expect(controller.catalog?.installedById(resource.id), isNull); + }, + ); }); test( @@ -1657,6 +2154,93 @@ void main() { ); }); + test('cached runs cover the complete checked text across edits', () async { + final coordinator = await SpellingCoordinator.start(); + addTearDown(coordinator.dispose); + final context = _fixtureContext(project: 'cache-coverage', revision: 0); + + Future check( + String source, + int revision, + List expected, + ) async { + final snapshot = SpellingSnapshotIdentity( + bufferId: 'cache-coverage', + contentRevision: revision, + documentKind: DocumentKind.markdown, + contextGeneration: 1, + ); + await coordinator.checkNow( + SpellingCheckRequest( + snapshot: snapshot, + engineContext: context, + automatic: false, + project: () async => const MarkdownSpellingProjector().project( + filePath: '/tmp/cache-coverage.md', + source: source, + mode: MarkdownMode.commonMark, + languageId: 'en-Test', + snapshot: snapshot, + ), + ), + ); + expect(coordinator.state.complete, isTrue); + expect( + coordinator.misspellings.map((item) => item.word), + expected, + reason: source, + ); + for (final occurrence in coordinator.misspellings) { + expect( + source.substring(occurrence.sourceStart!, occurrence.sourceEnd!), + occurrence.word, + ); + } + } + + await check('hello `code`wrld', 1, ['wrld']); + await check('hello wrld', 2, ['wrld']); + await check('hello `code`wrld', 3, ['wrld']); + await check('hello wrld', 4, ['wrld']); + }); + + test( + 'a NUL suffix cannot complete checking and recovers after correction', + () async { + final coordinator = await SpellingCoordinator.start(); + addTearDown(coordinator.dispose); + final context = _fixtureContext(project: 'nul-document', revision: 0); + Future check(String text, int revision) { + final snapshot = SpellingSnapshotIdentity( + bufferId: 'nul-document', + contentRevision: revision, + documentKind: DocumentKind.markdown, + contextGeneration: 1, + ); + return coordinator.checkNow( + SpellingCheckRequest( + snapshot: snapshot, + engineContext: context, + automatic: false, + project: () async => SpellingProjectionResult( + runs: [_runFor(text, snapshot: snapshot)], + complete: true, + ), + ), + ); + } + + await check('hello\u0000wrld', 1); + expect(coordinator.state.complete, isFalse); + expect(coordinator.state.status, SpellingPresentationStatus.incomplete); + await check('hello wrld', 2); + expect(coordinator.state.complete, isTrue); + expect(coordinator.misspellings.single.word, 'wrld'); + await check('hello helo', 3); + expect(coordinator.misspellings.single.word, 'helo'); + }, + ); + test('project initialization publishes only the latest owner', () async { for (final completeLatestFirst in [true, false]) { final temporary = await Directory.systemTemp.createTemp( @@ -1685,53 +2269,1283 @@ void main() { firstStarted = true; return firstRead.future; } - secondStarted = true; - return secondRead.future; + secondStarted = true; + return secondRead.future; + }, + ); + addTearDown(controller.dispose); + final settings = AppSettings.defaults().copyWith( + defaultSpellingLanguage: 'en-Test', + ); + + controller.update( + _sessionInput( + id: 'first-buffer', + root: firstRoot, + text: 'FirstProjectWord', + settings: settings, + ), + ); + await _waitFor(() => firstStarted); + controller.update( + _sessionInput( + id: 'second-buffer', + root: secondRoot, + text: 'SecondProjectWord', + settings: settings, + ), + ); + await _waitFor(() => secondStarted); + final firstSnapshot = _wordSnapshot('FirstProjectWord'); + final secondSnapshot = _wordSnapshot('SecondProjectWord'); + if (completeLatestFirst) { + secondRead.complete(secondSnapshot); + firstRead.complete(firstSnapshot); + } else { + firstRead.complete(firstSnapshot); + secondRead.complete(secondSnapshot); + } + await _waitFor( + () => controller.state.status == SpellingPresentationStatus.ready, + ); + + expect(controller.projectWords.wordsFor('en-Test'), [ + 'SecondProjectWord', + ]); + expect(controller.misspellings, isEmpty); + controller.dispose(); + } + }); + + test( + 'older same-project Settings read cannot undo a newer snapshot', + () async { + final temporary = await Directory.systemTemp.createTemp( + 'busymark-project-refresh-order-', + ); + addTearDown(() => temporary.delete(recursive: true)); + final bundle = await _createFixtureBundle(temporary); + final root = p.join(temporary.path, 'project'); + await Directory(root).create(); + final input = _sessionInput( + id: 'project-refresh-order', + root: root, + text: 'BusyBrand', + settings: AppSettings.defaults().copyWith( + defaultSpellingLanguage: 'en-Test', + ), + ); + final readEntered = Completer(); + final readRelease = Completer(); + var delayNext = false; + final controller = SpellingSessionController( + bundledRoot: bundle, + applicationSupportRoot: p.join(temporary.path, 'support'), + dictionaryStorageRoot: p.join(temporary.path, 'dictionary-storage'), + verifyDictionaryChecksums: false, + wordStoreReader: (store) async { + final snapshot = await store.read(); + if (store.projectStore && delayNext) { + delayNext = false; + readEntered.complete(); + await readRelease.future; + } + return snapshot; + }, + ); + addTearDown(controller.dispose); + await controller.prepareSettings(input.workspace); + delayNext = true; + final older = controller.prepareSettings(input.workspace); + await readEntered.future; + final store = SpellingWordStore( + filePath: p.join(root, '.busymark', 'spelling.json'), + projectStore: true, + ); + await store.addWord('en-Test', 'BusyBrand'); + await store.setProjectLanguage('en-Test'); + await controller.prepareSettings(input.workspace); + expect(controller.projectWords.wordsFor('en-Test'), ['BusyBrand']); + expect(controller.projectWords.projectLanguage, 'en-Test'); + readRelease.complete(); + await older; + expect(controller.projectWords.wordsFor('en-Test'), ['BusyBrand']); + expect(controller.projectWords.projectLanguage, 'en-Test'); + expect((await store.read()).wordsFor('en-Test'), ['BusyBrand']); + await controller.checkNow(input); + expect(controller.misspellings, isEmpty); + }, + ); + + test('older project watcher read cannot undo Settings publication', () async { + final temporary = await Directory.systemTemp.createTemp( + 'busymark-project-watcher-order-', + ); + addTearDown(() => temporary.delete(recursive: true)); + final bundle = await _createFixtureBundle(temporary); + final root = p.join(temporary.path, 'project'); + await Directory(root).create(); + final input = _sessionInput( + id: 'project-watcher-order', + root: root, + text: 'BusyBrand', + settings: AppSettings.defaults().copyWith( + defaultSpellingLanguage: 'en-Test', + ), + ); + final readEntered = Completer(); + final readRelease = Completer(); + var delayNext = false; + final controller = SpellingSessionController( + bundledRoot: bundle, + applicationSupportRoot: p.join(temporary.path, 'support'), + dictionaryStorageRoot: p.join(temporary.path, 'dictionary-storage'), + verifyDictionaryChecksums: false, + wordStoreReader: (store) async { + final snapshot = await store.read(); + if (store.projectStore && delayNext) { + delayNext = false; + readEntered.complete(); + await readRelease.future; + } + return snapshot; + }, + ); + addTearDown(controller.dispose); + await controller.prepareSettings(input.workspace); + final store = SpellingWordStore( + filePath: p.join(root, '.busymark', 'spelling.json'), + projectStore: true, + ); + delayNext = true; + await store.addWord('en-Test', 'OldWord'); + await readEntered.future; + await const AtomicFileWriter().writeBytes( + store.filePath, + utf8.encode( + '${jsonEncode({ + 'schemaVersion': 1, + 'revision': 1, + 'projectLanguage': 'en-Test', + 'words': { + 'en-Test': [ + {'key': 'busybrand', 'display': 'BusyBrand'}, + ], + }, + })}\n', + ), + overwrite: true, + ); + await controller.prepareSettings(input.workspace); + expect(controller.projectWords.wordsFor('en-Test'), ['BusyBrand']); + readRelease.complete(); + await Future.delayed(Duration.zero); + expect(controller.projectWords.wordsFor('en-Test'), ['BusyBrand']); + expect(controller.projectWords.projectLanguage, 'en-Test'); + final persisted = await store.read(); + expect(persisted.wordsFor('en-Test'), ['BusyBrand']); + expect(persisted.projectLanguage, 'en-Test'); + await controller.checkNow(input); + expect(controller.misspellings, isEmpty); + }); + + test('project read begun during a mutation cannot undo its result', () async { + for (final changeLanguage in [false, true]) { + final temporary = await Directory.systemTemp.createTemp( + 'busymark-project-mutation-refresh-', + ); + addTearDown(() => temporary.delete(recursive: true)); + final bundle = await _createFixtureBundle(temporary); + final root = p.join(temporary.path, 'project'); + await Directory(root).create(); + final filePath = p.join(root, '.busymark', 'spelling.json'); + final external = SpellingWordStore( + filePath: filePath, + projectStore: true, + ); + await external.addWord('en-Test', 'BusyBrand'); + final writer = _GatedSpellingAtomicFileWriter(); + final readEntered = Completer(); + final readRelease = Completer(); + var holdRead = false; + final controller = SpellingSessionController( + bundledRoot: bundle, + applicationSupportRoot: p.join(temporary.path, 'support'), + dictionaryStorageRoot: p.join(temporary.path, 'dictionary-storage'), + verifyDictionaryChecksums: false, + wordStoreFactory: ({required filePath, required projectStore}) => + SpellingWordStore( + filePath: filePath, + projectStore: projectStore, + writer: projectStore ? writer : const AtomicFileWriter(), + ), + wordStoreReader: (store) async { + final snapshot = await store.read(); + if (store.projectStore && holdRead) { + holdRead = false; + readEntered.complete(); + await readRelease.future; + } + return snapshot; + }, + ); + addTearDown(controller.dispose); + final input = _sessionInput( + id: 'project-mutation-refresh', + root: root, + text: 'BusyBrand', + settings: AppSettings.defaults().copyWith( + defaultSpellingLanguage: changeLanguage ? 'fr-CA' : 'en-Test', + ), + ); + await controller.prepareSettings(input.workspace); + writer.holdNextWrite(); + final mutation = changeLanguage + ? controller.setProjectLanguage('en-Test') + : controller.removeProjectWord('en-Test', 'BusyBrand'); + await writer.entered.future; + holdRead = true; + final refresh = controller.prepareSettings(input.workspace); + await readEntered.future; + writer.release.complete(); + await mutation; + readRelease.complete(); + await refresh; + final words = controller.projectWords; + expect(words.wordsFor('en-Test').isEmpty, !changeLanguage); + expect(words.projectLanguage, changeLanguage ? 'en-Test' : null); + final persisted = await external.read(); + expect(persisted.wordsFor('en-Test').isEmpty, !changeLanguage); + expect(persisted.projectLanguage, changeLanguage ? 'en-Test' : null); + await controller.checkNow(input); + expect(controller.misspellings.isEmpty, changeLanguage); + expect(controller.effectiveLanguage, 'en-Test'); + } + }); + + test( + 'superseded project conflict read preserves newer state and error', + () async { + final temporary = await Directory.systemTemp.createTemp( + 'busymark-project-conflict-refresh-', + ); + addTearDown(() => temporary.delete(recursive: true)); + final bundle = await _createFixtureBundle(temporary); + final root = p.join(temporary.path, 'project'); + await Directory(root).create(); + final filePath = p.join(root, '.busymark', 'spelling.json'); + final recoveryPath = p.join(temporary.path, 'preserved-project.json'); + final writer = _ToggleFailAtomicFileWriter(); + final readEntered = Completer(); + final readRelease = Completer(); + var holdNextRead = false; + final controller = SpellingSessionController( + bundledRoot: bundle, + applicationSupportRoot: p.join(temporary.path, 'support'), + dictionaryStorageRoot: p.join(temporary.path, 'dictionary-storage'), + verifyDictionaryChecksums: false, + wordStoreFactory: ({required filePath, required projectStore}) => + SpellingWordStore( + filePath: filePath, + projectStore: projectStore, + writer: projectStore ? writer : const AtomicFileWriter(), + ), + wordStoreReader: (store) async { + final snapshot = await store.read(); + if (store.projectStore && holdNextRead) { + holdNextRead = false; + readEntered.complete(); + await readRelease.future; + } + return snapshot; + }, + ); + addTearDown(controller.dispose); + final input = _sessionInput( + id: 'project-conflict-refresh', + root: root, + text: 'BusyBrand', + settings: AppSettings.defaults().copyWith( + defaultSpellingLanguage: 'en-Test', + ), + ); + await controller.prepareSettings(input.workspace); + writer + ..conflictRecoveryPath = recoveryPath + ..conflictDestinationBytes = utf8.encode( + '${jsonEncode({ + 'schemaVersion': 1, + 'revision': 77, + 'projectLanguage': 'en-Test', + 'words': { + 'en-Test': [ + {'key': 'external', 'display': 'external'}, + ], + }, + })}\n', + ); + holdNextRead = true; + final mutation = controller.removeProjectWord('en-Test', 'BusyBrand'); + final conflict = expectLater( + mutation, + throwsA( + isA().having( + (error) => error.recoveryPath, + 'recoveryPath', + recoveryPath, + ), + ), + ); + await readEntered.future; + final external = SpellingWordStore( + filePath: filePath, + projectStore: true, + ); + await external.addWord('en-Test', 'BusyBrand'); + await controller.prepareSettings(input.workspace); + expect(controller.projectWords.wordsFor('en-Test'), [ + 'BusyBrand', + 'external', + ]); + readRelease.complete(); + await conflict; + expect(controller.projectWords.wordsFor('en-Test'), [ + 'BusyBrand', + 'external', + ]); + expect(controller.projectWords.projectLanguage, 'en-Test'); + expect((await external.read()).wordsFor('en-Test'), [ + 'BusyBrand', + 'external', + ]); + }, + ); + + test('superseded project watcher error cannot replace valid state', () async { + final temporary = await Directory.systemTemp.createTemp( + 'busymark-project-watcher-error-', + ); + addTearDown(() => temporary.delete(recursive: true)); + final bundle = await _createFixtureBundle(temporary); + final root = p.join(temporary.path, 'project'); + await Directory(root).create(); + final input = _sessionInput( + id: 'project-watcher-error', + root: root, + text: 'BusyBrand', + settings: AppSettings.defaults().copyWith( + defaultSpellingLanguage: 'en-Test', + ), + ); + final entered = Completer(); + final release = Completer(); + var staleErrorNext = false; + var currentErrorNext = false; + final controller = SpellingSessionController( + bundledRoot: bundle, + applicationSupportRoot: p.join(temporary.path, 'support'), + dictionaryStorageRoot: p.join(temporary.path, 'dictionary-storage'), + verifyDictionaryChecksums: false, + wordStoreReader: (store) async { + final snapshot = await store.read(); + if (store.projectStore && staleErrorNext) { + staleErrorNext = false; + entered.complete(); + await release.future; + throw StateError('superseded project read'); + } + if (store.projectStore && currentErrorNext) { + currentErrorNext = false; + throw StateError('current project read'); + } + return snapshot; + }, + ); + addTearDown(controller.dispose); + await controller.prepareSettings(input.workspace); + final external = SpellingWordStore( + filePath: p.join(root, '.busymark', 'spelling.json'), + projectStore: true, + ); + staleErrorNext = true; + await external.addWord('en-Test', 'OldWord'); + await entered.future; + await external.addWord('en-Test', 'BusyBrand'); + await controller.prepareSettings(input.workspace); + expect(controller.projectWords.wordsFor('en-Test'), [ + 'BusyBrand', + 'OldWord', + ]); + release.complete(); + await Future.delayed(Duration.zero); + expect(controller.state.status, isNot(SpellingPresentationStatus.failure)); + expect(controller.projectWords.wordsFor('en-Test'), [ + 'BusyBrand', + 'OldWord', + ]); + currentErrorNext = true; + await external.addWord('en-Test', 'AnotherWord'); + await _waitFor( + () => controller.state.status == SpellingPresentationStatus.failure, + ); + expect(controller.state.message, contains('current project read')); + }); + + test('project watcher recovers after identical dictionary repair', () async { + final temporary = await Directory.systemTemp.createTemp( + 'busymark-project-repair-', + ); + addTearDown(() => temporary.delete(recursive: true)); + final bundle = await _createFixtureBundle(temporary); + final root = p.join(temporary.path, 'project'); + await Directory(root).create(); + final filePath = p.join(root, '.busymark', 'spelling.json'); + final external = SpellingWordStore(filePath: filePath, projectStore: true); + await external.addWord('en-Test', 'BusyBrand'); + final validBytes = await File(filePath).readAsString(); + final controller = SpellingSessionController( + bundledRoot: bundle, + applicationSupportRoot: p.join(temporary.path, 'support'), + dictionaryStorageRoot: p.join(temporary.path, 'dictionary-storage'), + verifyDictionaryChecksums: false, + ); + addTearDown(controller.dispose); + final input = _sessionInput( + id: 'project-repair', + root: root, + text: 'BusyBrand', + settings: AppSettings.defaults().copyWith( + defaultSpellingLanguage: 'en-Test', + ), + ); + controller.update(input); + await _waitFor( + () => controller.state.status == SpellingPresentationStatus.ready, + ); + expect(controller.misspellings, isEmpty); + await File(filePath).writeAsString('{ malformed', flush: true); + await _waitFor( + () => controller.state.status == SpellingPresentationStatus.failure, + ); + expect(controller.state.message, contains('FormatException')); + await File(filePath).writeAsString(validBytes, flush: true); + await _waitFor( + () => controller.state.status == SpellingPresentationStatus.ready, + ); + expect(controller.state.complete, isTrue); + expect(controller.projectWords.wordsFor('en-Test'), ['BusyBrand']); + expect(controller.misspellings, isEmpty); + expect(await File(filePath).readAsString(), validBytes); + }); + + test( + 'project repair recovers a later failed preparation at latest revision', + () async { + final temporary = await Directory.systemTemp.createTemp( + 'busymark-project-preparation-repair-', + ); + addTearDown(() => temporary.delete(recursive: true)); + final bundle = await _createFixtureBundle(temporary); + final root = p.join(temporary.path, 'project'); + await Directory(root).create(); + final filePath = p.join(root, '.busymark', 'spelling.json'); + final external = SpellingWordStore( + filePath: filePath, + projectStore: true, + ); + await external.addWord('en-Test', 'BusyBrand'); + await external.setProjectLanguage('en-Test'); + final validBytes = await File(filePath).readAsString(); + var failedProjectReads = 0; + final controller = SpellingSessionController( + bundledRoot: bundle, + applicationSupportRoot: p.join(temporary.path, 'support'), + dictionaryStorageRoot: p.join(temporary.path, 'dictionary-storage'), + verifyDictionaryChecksums: false, + wordStoreReader: (store) async { + try { + return await store.read(); + } on FormatException { + if (store.projectStore) failedProjectReads++; + rethrow; + } + }, + ); + addTearDown(controller.dispose); + SpellingSessionInput input(String text, int revision) => _sessionInput( + id: 'project-preparation-repair', + root: root, + text: text, + revision: revision, + settings: AppSettings.defaults(), + ); + controller.update(input('BusyBrand', 0)); + await _waitFor( + () => controller.state.status == SpellingPresentationStatus.ready, + ); + await File(filePath).writeAsString('{ malformed', flush: true); + await _waitFor( + () => controller.state.status == SpellingPresentationStatus.failure, + ); + expect(failedProjectReads, greaterThanOrEqualTo(1)); + final watcherFailure = controller.state; + controller.update(input('BusyBrand wrld', 1)); + await _waitFor( + () => + controller.state.status == SpellingPresentationStatus.failure && + !identical(controller.state, watcherFailure), + ); + expect(failedProjectReads, greaterThanOrEqualTo(2)); + expect(controller.state.message, contains('FormatException')); + await File(filePath).writeAsString(validBytes, flush: true); + await _waitFor( + () => controller.state.status == SpellingPresentationStatus.ready, + ); + expect(controller.state.complete, isTrue); + expect(controller.projectWords.wordsFor('en-Test'), ['BusyBrand']); + expect(controller.projectWords.projectLanguage, 'en-Test'); + expect(controller.effectiveLanguage, 'en-Test'); + expect(controller.misspellings.map((entry) => entry.word), ['wrld']); + expect(controller.misspellings.single.run.snapshot.contentRevision, 1); + expect(await File(filePath).readAsString(), validBytes); + }, + ); + + test( + 'project reread does not clear an unrelated personal read failure', + () async { + final temporary = await Directory.systemTemp.createTemp( + 'busymark-unrelated-project-repair-', + ); + addTearDown(() => temporary.delete(recursive: true)); + final bundle = await _createFixtureBundle(temporary); + final root = p.join(temporary.path, 'project'); + await Directory(root).create(); + var failPersonalRead = false; + final controller = SpellingSessionController( + bundledRoot: bundle, + applicationSupportRoot: p.join(temporary.path, 'support'), + dictionaryStorageRoot: p.join(temporary.path, 'dictionary-storage'), + verifyDictionaryChecksums: false, + wordStoreReader: (store) { + if (!store.projectStore && failPersonalRead) { + throw StateError('unrelated personal read failure'); + } + return store.read(); + }, + ); + addTearDown(controller.dispose); + final input = _sessionInput( + id: 'unrelated-project-repair', + root: root, + text: 'wrld', + settings: AppSettings.defaults().copyWith( + defaultSpellingLanguage: 'en-Test', + ), + ); + controller.update(input); + await _waitFor( + () => controller.state.status == SpellingPresentationStatus.ready, + ); + failPersonalRead = true; + await controller.checkNow(input); + expect(controller.state.status, SpellingPresentationStatus.failure); + expect( + controller.state.message, + contains('unrelated personal read failure'), + ); + failPersonalRead = false; + await controller.prepareSettings(input.workspace); + expect(controller.state.status, SpellingPresentationStatus.failure); + expect( + controller.state.message, + contains('unrelated personal read failure'), + ); + }, + ); + + test( + 'failed personal initialization retries after repair and shares first load', + () async { + final temporary = await Directory.systemTemp.createTemp( + 'busymark-personal-retry-', + ); + addTearDown(() => temporary.delete(recursive: true)); + final bundle = await _createFixtureBundle(temporary); + final support = p.join(temporary.path, 'support'); + final gate = Completer(); + var reads = 0; + var fail = true; + final controller = SpellingSessionController( + bundledRoot: bundle, + applicationSupportRoot: support, + dictionaryStorageRoot: p.join(temporary.path, 'dictionary-storage'), + verifyDictionaryChecksums: false, + wordStoreReader: (store) async { + if (!store.projectStore) { + reads++; + if (reads == 1) await gate.future; + if (fail) throw const FormatException('Malformed personal words'); + } + return store.read(); + }, + ); + addTearDown(controller.dispose); + final first = controller.prepareSettings(null); + final concurrent = controller.prepareSettings(null); + await _waitFor(() => reads == 1); + gate.complete(); + await expectLater(first, throwsA(isA())); + await expectLater(concurrent, throwsA(isA())); + expect(reads, 1); + + fail = false; + await SpellingWordStore( + filePath: p.join(support, 'spelling', 'personal.json'), + ).addWord('en-Test', 'RepairedWord'); + await controller.prepareSettings(null); + expect(reads, 2); + expect(controller.personalWords.wordsFor('en-Test'), ['RepairedWord']); + }, + ); + + test('explicit checks and Settings refresh external personal words', () async { + final temporary = await Directory.systemTemp.createTemp( + 'busymark-personal-fresh-', + ); + addTearDown(() => temporary.delete(recursive: true)); + final bundle = await _createFixtureBundle(temporary); + final support = p.join(temporary.path, 'support'); + final personalPath = p.join(support, 'spelling', 'personal.json'); + SpellingSessionController controller() => SpellingSessionController( + bundledRoot: bundle, + applicationSupportRoot: support, + dictionaryStorageRoot: p.join(temporary.path, 'dictionary-storage'), + verifyDictionaryChecksums: false, + ); + final first = controller(); + final second = controller(); + addTearDown(first.dispose); + addTearDown(second.dispose); + final input = _sessionInput( + id: 'shared-personal', + root: temporary.path, + text: 'BusyBrand', + settings: AppSettings.defaults().copyWith( + defaultSpellingLanguage: 'en-Test', + ), + ); + await first.checkNow(input); + await second.checkNow(input); + expect( + first.state.status, + SpellingPresentationStatus.ready, + reason: first.state.message, + ); + expect( + second.state.status, + SpellingPresentationStatus.ready, + reason: second.state.message, + ); + expect(first.misspellings.single.word, 'BusyBrand'); + await first.addPersonalWord(first.misspellings.single); + await second.checkNow(input); + expect(second.misspellings, isEmpty); + expect(second.personalWords.wordsFor('en-Test'), ['BusyBrand']); + await first.removePersonalWord('en-Test', 'BusyBrand'); + await second.checkNow(input); + expect(second.misspellings.single.word, 'BusyBrand'); + + await SpellingWordStore( + filePath: personalPath, + ).addWord('en-Test', 'BusyBrand'); + await second.prepareSettings(input.workspace); + expect(second.personalWords.wordsFor('en-Test'), ['BusyBrand']); + await const AtomicFileWriter().writeBytes( + personalPath, + utf8.encode( + '${jsonEncode({'schemaVersion': 1, 'revision': second.personalWords.revision, 'words': {}})}\n', + ), + overwrite: true, + ); + await second.checkNow(input); + expect(second.misspellings.single.word, 'BusyBrand'); + }); + + test('late personal refresh cannot undo a newer local mutation', () async { + final temporary = await Directory.systemTemp.createTemp( + 'busymark-personal-race-', + ); + addTearDown(() => temporary.delete(recursive: true)); + final bundle = await _createFixtureBundle(temporary); + final support = p.join(temporary.path, 'support'); + final store = SpellingWordStore( + filePath: p.join(support, 'spelling', 'personal.json'), + ); + await store.addWord('en-Test', 'BusyBrand'); + final gate = Completer(); + var reads = 0; + final controller = SpellingSessionController( + bundledRoot: bundle, + applicationSupportRoot: support, + dictionaryStorageRoot: p.join(temporary.path, 'dictionary-storage'), + verifyDictionaryChecksums: false, + wordStoreReader: (candidate) { + if (!candidate.projectStore && ++reads == 2) return gate.future; + return candidate.read(); + }, + ); + addTearDown(controller.dispose); + await controller.prepareSettings(null); + final stale = controller.personalWords; + final refreshing = controller.prepareSettings(null); + await _waitFor(() => reads == 2); + await controller.removePersonalWord('en-Test', 'BusyBrand'); + gate.complete(stale); + await refreshing; + expect(controller.personalWords.wordsFor('en-Test'), isEmpty); + expect((await store.read()).wordsFor('en-Test'), isEmpty); + }); + + test( + 'refresh begun during a personal mutation cannot undo its result', + () async { + for (final removing in [true, false]) { + final temporary = await Directory.systemTemp.createTemp( + 'busymark-personal-overlap-', + ); + addTearDown(() => temporary.delete(recursive: true)); + final bundle = await _createFixtureBundle(temporary); + final support = p.join(temporary.path, 'support'); + final store = SpellingWordStore( + filePath: p.join(support, 'spelling', 'personal.json'), + ); + if (removing) await store.addWord('en-Test', 'BusyBrand'); + final writer = _GatedSpellingAtomicFileWriter(); + final readRelease = Completer(); + final readEntered = Completer(); + var holdRead = false; + final controller = SpellingSessionController( + bundledRoot: bundle, + applicationSupportRoot: support, + dictionaryStorageRoot: p.join(temporary.path, 'dictionary-storage'), + verifyDictionaryChecksums: false, + wordStoreFactory: ({required filePath, required projectStore}) => + SpellingWordStore( + filePath: filePath, + projectStore: projectStore, + writer: writer, + ), + wordStoreReader: (candidate) async { + final snapshot = await candidate.read(); + if (holdRead && !candidate.projectStore) { + readEntered.complete(); + await readRelease.future; + } + return snapshot; + }, + ); + addTearDown(controller.dispose); + final input = _sessionInput( + id: 'overlap', + root: temporary.path, + text: 'BusyBrand', + settings: AppSettings.defaults().copyWith( + defaultSpellingLanguage: 'en-Test', + ), + ); + await controller.checkNow(input); + expect(controller.misspellings.isEmpty, removing); + writer.holdNextWrite(); + final mutation = removing + ? controller.removePersonalWord('en-Test', 'BusyBrand') + : controller.addPersonalWord(controller.misspellings.single); + await writer.entered.future; + holdRead = true; + final refresh = controller.prepareSettings(null); + await readEntered.future; + holdRead = false; + writer.release.complete(); + await mutation; + readRelease.complete(); + await refresh; + expect(controller.personalWords.wordsFor('en-Test').isEmpty, removing); + expect((await store.read()).wordsFor('en-Test').isEmpty, removing); + await controller.checkNow(input); + expect(controller.misspellings.isEmpty, !removing); + } + }, + ); + + test( + 'an older personal refresh cannot replace a newer publication', + () async { + final temporary = await Directory.systemTemp.createTemp( + 'busymark-personal-refresh-order-', + ); + addTearDown(() => temporary.delete(recursive: true)); + final bundle = await _createFixtureBundle(temporary); + final support = p.join(temporary.path, 'support'); + final store = SpellingWordStore( + filePath: p.join(support, 'spelling', 'personal.json'), + ); + final firstRelease = Completer(); + final firstEntered = Completer(); + var refreshReads = 0; + var trackRefresh = false; + final controller = SpellingSessionController( + bundledRoot: bundle, + applicationSupportRoot: support, + dictionaryStorageRoot: p.join(temporary.path, 'dictionary-storage'), + verifyDictionaryChecksums: false, + wordStoreReader: (candidate) async { + final snapshot = await candidate.read(); + if (trackRefresh && !candidate.projectStore && ++refreshReads == 1) { + firstEntered.complete(); + await firstRelease.future; + } + return snapshot; + }, + ); + addTearDown(controller.dispose); + final input = _sessionInput( + id: 'refresh-order', + root: temporary.path, + text: 'BusyBrand', + settings: AppSettings.defaults().copyWith( + defaultSpellingLanguage: 'en-Test', + ), + ); + await controller.prepareSettings(null); + trackRefresh = true; + final older = controller.prepareSettings(null); + await firstEntered.future; + await store.addWord('en-Test', 'BusyBrand'); + await controller.prepareSettings(null); + expect(controller.personalWords.wordsFor('en-Test'), ['BusyBrand']); + firstRelease.complete(); + await older; + expect(controller.personalWords.wordsFor('en-Test'), ['BusyBrand']); + expect((await store.read()).wordsFor('en-Test'), ['BusyBrand']); + await controller.checkNow(input); + expect(controller.misspellings, isEmpty); + }, + ); + + test( + 'a superseded conflict read cannot replace a newer personal refresh', + () async { + final temporary = await Directory.systemTemp.createTemp( + 'busymark-personal-conflict-refresh-', + ); + addTearDown(() => temporary.delete(recursive: true)); + final bundle = await _createFixtureBundle(temporary); + final support = p.join(temporary.path, 'support'); + final personalPath = p.join(support, 'spelling', 'personal.json'); + final recoveryPath = p.join(temporary.path, 'preserved-personal.json'); + final writer = _ToggleFailAtomicFileWriter(); + final readEntered = Completer(); + final readRelease = Completer(); + var holdNextRead = false; + final controller = SpellingSessionController( + bundledRoot: bundle, + applicationSupportRoot: support, + dictionaryStorageRoot: p.join(temporary.path, 'dictionary-storage'), + verifyDictionaryChecksums: false, + wordStoreFactory: ({required filePath, required projectStore}) => + SpellingWordStore( + filePath: filePath, + projectStore: projectStore, + writer: writer, + ), + wordStoreReader: (candidate) async { + final snapshot = await candidate.read(); + if (holdNextRead && !candidate.projectStore) { + holdNextRead = false; + readEntered.complete(); + await readRelease.future; + } + return snapshot; + }, + ); + addTearDown(controller.dispose); + await controller.prepareSettings(null); + writer + ..conflictRecoveryPath = recoveryPath + ..conflictDestinationBytes = utf8.encode( + '${jsonEncode({ + 'schemaVersion': 1, + 'revision': 77, + 'words': { + 'en-Test': [ + {'key': 'external', 'display': 'external'}, + ], + }, + })}\n', + ); + holdNextRead = true; + final mutation = controller.removePersonalWord('en-Test', 'BusyBrand'); + final conflict = expectLater( + mutation, + throwsA( + isA().having( + (error) => error.recoveryPath, + 'recoveryPath', + recoveryPath, + ), + ), + ); + await readEntered.future; + final external = SpellingWordStore(filePath: personalPath); + await external.addWord('en-Test', 'newer'); + await controller.prepareSettings(null); + expect(controller.personalWords.wordsFor('en-Test'), [ + 'external', + 'newer', + ]); + readRelease.complete(); + await conflict; + expect(controller.personalWords.wordsFor('en-Test'), [ + 'external', + 'newer', + ]); + expect((await external.read()).wordsFor('en-Test'), [ + 'external', + 'newer', + ]); + final input = _sessionInput( + id: 'conflict-refresh', + root: temporary.path, + text: 'newer', + settings: AppSettings.defaults().copyWith( + defaultSpellingLanguage: 'en-Test', + ), + ); + await controller.checkNow(input); + expect(controller.misspellings, isEmpty); + }, + ); + + testWidgets('Settings holds a failed preparation until deliberate retry', ( + tester, + ) async { + late Directory temporary; + late String bundle; + await tester.runAsync(() async { + temporary = await Directory.systemTemp.createTemp( + 'busymark-settings-retry-', + ); + bundle = await _createFixtureBundle(temporary); + }); + addTearDown(() => temporary.delete(recursive: true)); + var reads = 0; + var fail = true; + final controller = SpellingSessionController( + bundledRoot: bundle, + applicationSupportRoot: p.join(temporary.path, 'support'), + dictionaryStorageRoot: p.join(temporary.path, 'dictionary-storage'), + wordStoreReader: (store) async { + if (!store.projectStore) { + reads++; + if (fail) throw const FormatException('malformed personal.json'); + } + return store.read(); + }, + ); + addTearDown(controller.dispose); + await tester.pumpWidget( + ProviderScope( + overrides: [ + spellingSessionControllerProvider.overrideWith((ref) => controller), + localSettingsStoreProvider.overrideWithValue( + _SpellingMemorySettingsStore(), + ), + ], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + builder: (context, child) => + _spellingSettingsHeaderDefaults(context, child!), + home: const SettingsScreen( + returnTarget: SettingsReturnTarget.welcome, + initialPage: SettingsPage.editor, + ), + ), + ), + ); + await _pumpWidgetUntil( + tester, + () => find + .byKey(const ValueKey('retry-spelling-settings')) + .evaluate() + .isNotEmpty, + ); + expect(reads, 1); + for (var frame = 0; frame < 6; frame++) { + await tester.pump(const Duration(milliseconds: 20)); + } + expect(reads, 1); + expect(tester.takeException(), isNull); + expect(find.text('Could not load spelling settings'), findsOneWidget); + fail = false; + await tester.tap(find.byKey(const ValueKey('retry-spelling-settings'))); + await _pumpWidgetUntil( + tester, + () => reads == 2 && controller.catalog != null, + ); + expect(find.byKey(const ValueKey('retry-spelling-settings')), findsNothing); + expect(tester.takeException(), isNull); + }); + + testWidgets('warm no-workspace Settings refreshes personal words once', ( + tester, + ) async { + late Directory temporary; + late String bundle; + await tester.runAsync(() async { + temporary = await Directory.systemTemp.createTemp( + 'busymark-settings-warm-', + ); + bundle = await _createFixtureBundle(temporary); + }); + addTearDown(() => temporary.delete(recursive: true)); + final support = p.join(temporary.path, 'support'); + var reads = 0; + final controller = SpellingSessionController( + bundledRoot: bundle, + applicationSupportRoot: support, + dictionaryStorageRoot: p.join(temporary.path, 'dictionary-storage'), + verifyDictionaryChecksums: false, + wordStoreReader: (store) { + if (!store.projectStore) reads++; + return store.read(); + }, + ); + addTearDown(controller.dispose); + SpellingSessionController activeController = controller; + await tester.runAsync(() async { + await controller.prepareSettings(null); + await SpellingWordStore( + filePath: p.join(support, 'spelling', 'personal.json'), + ).addWord('en-Test', 'BusyBrand'); + }); + expect(reads, 1); + await tester.pumpWidget( + ProviderScope( + overrides: [ + spellingSessionControllerProvider.overrideWith( + (ref) => activeController, + ), + localSettingsStoreProvider.overrideWithValue( + _SpellingMemorySettingsStore(), + ), + ], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + builder: (context, child) => + _spellingSettingsHeaderDefaults(context, child!), + home: const SettingsScreen( + returnTarget: SettingsReturnTarget.welcome, + initialPage: SettingsPage.editor, + ), + ), + ), + ); + for (var frame = 0; frame < 8; frame++) { + await tester.pump(const Duration(milliseconds: 20)); + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 5)), + ); + } + expect(reads, 2); + expect(controller.personalWords.wordsFor('en-Test'), ['BusyBrand']); + BusyMarkActionRow personalRow() => tester.widget( + find.ancestor( + of: find.text('Personal Dictionary'), + matching: find.byType(BusyMarkActionRow), + ), + ); + expect(personalRow().subtitle, '1'); + var nextReads = 0; + final nextController = SpellingSessionController( + bundledRoot: bundle, + applicationSupportRoot: support, + dictionaryStorageRoot: p.join(temporary.path, 'dictionary-storage'), + verifyDictionaryChecksums: false, + wordStoreReader: (store) { + if (!store.projectStore) nextReads++; + return store.read(); + }, + ); + addTearDown(nextController.dispose); + await tester.runAsync(() async { + await nextController.prepareSettings(null); + await SpellingWordStore( + filePath: p.join(support, 'spelling', 'personal.json'), + ).addWord('en-Test', 'OtherBrand'); + }); + expect(nextReads, 1); + activeController = nextController; + ProviderScope.containerOf( + tester.element(find.byType(SettingsScreen)), + ).invalidate(spellingSessionControllerProvider); + await _pumpWidgetUntil( + tester, + () => + nextReads == 2 && + nextController.personalWords + .wordsFor('en-Test') + .contains('OtherBrand'), + ); + expect(nextController.personalWords.wordsFor('en-Test'), [ + 'BusyBrand', + 'OtherBrand', + ]); + expect(personalRow().subtitle, '2'); + for (var frame = 0; frame < 6; frame++) { + await tester.pump(const Duration(milliseconds: 20)); + } + expect(nextReads, 2); + await tester.pumpWidget(const MaterialApp(home: SizedBox())); + expect(tester.takeException(), isNull); + }); + + testWidgets('warm Settings observes an external dictionary removal once', ( + tester, + ) async { + late Directory temporary; + late String bundle; + await tester.runAsync(() async { + temporary = await Directory.systemTemp.createTemp( + 'busymark-settings-catalog-', + ); + bundle = await _createFixtureBundle(temporary); + }); + addTearDown(() => temporary.delete(recursive: true)); + final storage = p.join(temporary.path, 'dictionary-storage'); + var loads = 0; + final first = SpellingSessionController( + bundledRoot: bundle, + applicationSupportRoot: p.join(temporary.path, 'first-support'), + dictionaryStorageRoot: storage, + ); + final second = SpellingSessionController( + bundledRoot: bundle, + applicationSupportRoot: p.join(temporary.path, 'second-support'), + dictionaryStorageRoot: storage, + catalogLoader: + ({ + required bundledRoot, + required downloadedRoot, + required importedRoot, + required verifyChecksums, + }) { + loads++; + return SpellingDictionaryCatalog.load( + bundledRoot: bundledRoot, + downloadedRoot: downloadedRoot, + importedRoot: importedRoot, + verifyChecksums: verifyChecksums, + ); + }, + ); + addTearDown(first.dispose); + addTearDown(second.dispose); + await tester.runAsync(() async { + await first.prepareSettings(null); + await second.prepareSettings(null); + await first.removeDownloadedDictionary('en-Test'); + }); + expect(loads, 1); + expect(second.catalog!.installedById('en-Test'), isNotNull); + await tester.pumpWidget( + ProviderScope( + overrides: [ + spellingSessionControllerProvider.overrideWith((ref) => second), + localSettingsStoreProvider.overrideWithValue( + _SpellingMemorySettingsStore(), + ), + ], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + builder: (context, child) => + _spellingSettingsHeaderDefaults(context, child!), + home: const SettingsScreen( + returnTarget: SettingsReturnTarget.welcome, + initialPage: SettingsPage.spellingDictionaries, + ), + ), + ), + ); + await _pumpWidgetUntil( + tester, + () => loads == 2 && second.catalog!.installedById('en-Test') == null, + ); + expect(find.textContaining('Not installed'), findsWidgets); + for (var frame = 0; frame < 6; frame++) { + await tester.pump(const Duration(milliseconds: 20)); + } + expect(loads, 2); + await tester.pumpWidget(const MaterialApp(home: SizedBox())); + expect(tester.takeException(), isNull); + }); + + testWidgets( + 'leaving Settings during preparation ignores its late completion', + (tester) async { + late Directory temporary; + late String bundle; + await tester.runAsync(() async { + temporary = await Directory.systemTemp.createTemp( + 'busymark-settings-leave-', + ); + bundle = await _createFixtureBundle(temporary); + }); + addTearDown(() => temporary.delete(recursive: true)); + final entered = Completer(); + final release = Completer(); + final controller = SpellingSessionController( + bundledRoot: bundle, + applicationSupportRoot: p.join(temporary.path, 'support'), + dictionaryStorageRoot: p.join(temporary.path, 'dictionary-storage'), + wordStoreReader: (store) async { + if (!store.projectStore) { + entered.complete(); + await release.future; + } + return store.read(); }, ); addTearDown(controller.dispose); - final settings = AppSettings.defaults().copyWith( - defaultSpellingLanguage: 'en-Test', - ); - - controller.update( - _sessionInput( - id: 'first-buffer', - root: firstRoot, - text: 'FirstProjectWord', - settings: settings, - ), - ); - await _waitFor(() => firstStarted); - controller.update( - _sessionInput( - id: 'second-buffer', - root: secondRoot, - text: 'SecondProjectWord', - settings: settings, + await tester.pumpWidget( + ProviderScope( + overrides: [ + spellingSessionControllerProvider.overrideWith((ref) => controller), + localSettingsStoreProvider.overrideWithValue( + _SpellingMemorySettingsStore(), + ), + ], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + builder: (context, child) => + _spellingSettingsHeaderDefaults(context, child!), + home: const SettingsScreen( + returnTarget: SettingsReturnTarget.welcome, + initialPage: SettingsPage.editor, + ), + ), ), ); - await _waitFor(() => secondStarted); - final firstSnapshot = _wordSnapshot('FirstProjectWord'); - final secondSnapshot = _wordSnapshot('SecondProjectWord'); - if (completeLatestFirst) { - secondRead.complete(secondSnapshot); - firstRead.complete(firstSnapshot); - } else { - firstRead.complete(firstSnapshot); - secondRead.complete(secondSnapshot); - } - await _waitFor( - () => controller.state.status == SpellingPresentationStatus.ready, - ); - - expect(controller.projectWords.wordsFor('en-Test'), [ - 'SecondProjectWord', - ]); - expect(controller.misspellings, isEmpty); - controller.dispose(); - } - }); + await _pumpWidgetUntil(tester, () => entered.isCompleted); + await tester.pumpWidget(const MaterialApp(home: SizedBox())); + release.complete(); + await tester.pump(const Duration(milliseconds: 20)); + expect(tester.takeException(), isNull); + }, + ); test('concurrent first requests start exactly one worker', () async { final temporary = await Directory.systemTemp.createTemp( @@ -2829,6 +4643,448 @@ void main() { }, ); + test( + 'warm instance refreshes external installation at explicit boundaries', + () async { + final temporary = await Directory.systemTemp.createTemp( + 'busymark-external-inventory-', + ); + addTearDown(() async { + if (await temporary.exists()) await temporary.delete(recursive: true); + }); + final bundle = await _createFixtureBundle(temporary); + final storage = p.join(temporary.path, 'dictionary-storage'); + SpellingSessionController controller() => SpellingSessionController( + bundledRoot: bundle, + applicationSupportRoot: p.join(temporary.path, 'support'), + dictionaryStorageRoot: storage, + ); + final first = controller(); + final second = controller(); + addTearDown(first.dispose); + addTearDown(second.dispose); + await first.prepareSettings(null); + await second.prepareSettings(null); + expect(second.catalog!.installedById('en-Test'), isNotNull); + await first.removeDownloadedDictionary('en-Test'); + await second.prepareSettings(null); + expect(second.catalog!.installedById('en-Test'), isNull); + final input = SpellingSessionInput( + buffer: DocumentBuffer.untitled( + id: 'external-inventory', + name: 'external-inventory.md', + text: 'helo', + ), + workspace: null, + settings: AppSettings.defaults().copyWith( + defaultSpellingLanguage: 'en-Test', + ), + documentKind: DocumentKind.markdown, + markdownMode: MarkdownMode.commonMark, + ); + await second.checkNow(input); + expect( + second.state.status, + SpellingPresentationStatus.dictionaryNotInstalled, + ); + final fixture = p.join( + Directory.current.path, + 'packages', + 'busymark_spellcheck_native', + 'test', + 'fixtures', + ); + final catalogResource = second.catalog!.availableById('en-Test')!; + await const SpellingDictionaryPairInstaller().install( + affSource: File(p.join(fixture, 'test.aff')), + dicSource: File(p.join(fixture, 'test.dic')), + spec: SpellingDictionaryInstallSpec.downloaded(catalogResource), + destinationRoot: p.join(storage, 'downloaded'), + validateNativePair: (_, _, _) async => 'UTF-8', + ); + await second.checkNow(input); + expect(second.catalog!.installedById('en-Test'), isNotNull); + expect(second.state.status, SpellingPresentationStatus.ready); + expect(second.misspellings.single.word, 'helo'); + final installation = second.catalog!.installedById('en-Test')!; + await File( + p.join(installation.directoryPath, 'manifest.json'), + ).writeAsString('malformed'); + await second.prepareSettings(null); + expect(second.catalog!.installedById('en-Test'), isNull); + expect(second.catalog!.invalidById('en-Test'), isNotNull); + await const SpellingDictionaryPairInstaller().install( + affSource: File(p.join(fixture, 'test.aff')), + dicSource: File(p.join(fixture, 'test.dic')), + spec: SpellingDictionaryInstallSpec.downloaded(catalogResource), + destinationRoot: p.join(storage, 'downloaded'), + validateNativePair: (_, _, _) async => 'UTF-8', + replaceExisting: true, + ); + await second.checkNow(input); + expect(second.catalog!.invalidById('en-Test'), isNull); + expect(second.catalog!.installedById('en-Test'), isNotNull); + expect(second.state.status, SpellingPresentationStatus.ready); + }, + ); + + test( + 'warm instance replaces an active imported dictionary fingerprint', + () async { + final temporary = await Directory.systemTemp.createTemp( + 'busymark-external-import-', + ); + addTearDown(() async { + if (await temporary.exists()) await temporary.delete(recursive: true); + }); + final bundle = await _createFixtureBundle(temporary); + final storage = p.join(temporary.path, 'dictionary-storage'); + final fixture = p.join( + Directory.current.path, + 'packages', + 'busymark_spellcheck_native', + 'test', + 'fixtures', + ); + SpellingSessionController controller(String name) => + SpellingSessionController( + bundledRoot: bundle, + applicationSupportRoot: p.join(temporary.path, '$name-support'), + dictionaryStorageRoot: storage, + ); + final first = controller('first'); + final second = controller('second'); + addTearDown(first.dispose); + addTearDown(second.dispose); + await first.prepareSettings(null); + await second.prepareSettings(null); + await first.removeDownloadedDictionary('en-Test'); + await first.importDictionary( + affPath: p.join(fixture, 'test.aff'), + dicPath: p.join(fixture, 'test.dic'), + languageId: 'en-Test', + displayLabel: 'Imported English', + ); + final input = SpellingSessionInput( + buffer: DocumentBuffer.untitled( + id: 'external-import', + name: 'external-import.md', + text: 'newword helo', + ), + workspace: null, + settings: AppSettings.defaults().copyWith( + defaultSpellingLanguage: 'en-Test', + ), + documentKind: DocumentKind.markdown, + markdownMode: MarkdownMode.commonMark, + ); + await second.checkNow(input); + expect(second.catalog!.installedById('en-Test')!.imported, isTrue); + expect( + second.misspellings.map((word) => word.word), + containsAll(['newword', 'helo']), + ); + final originalFingerprint = second.catalog! + .installedById('en-Test')! + .fingerprint; + final revised = File(p.join(temporary.path, 'revised.dic')); + final originalDic = await File( + p.join(fixture, 'test.dic'), + ).readAsString(); + await revised.writeAsString( + '${originalDic.replaceFirst('11\n', '12\n')}newword\n', + ); + final aff = File(p.join(fixture, 'test.aff')); + final spec = SpellingDictionaryInstallSpec( + resourceId: 'en-Test', + id: 'en-Test', + locales: const ['en-Test'], + label: 'Imported English', + sourceRevision: 'local-import', + affSha256: (await sha256.bind(aff.openRead()).first).toString(), + dicSha256: (await sha256.bind(revised.openRead()).first).toString(), + affSize: await aff.length(), + dicSize: await revised.length(), + kind: SpellingDictionaryInstallationKind.imported, + ); + await const SpellingDictionaryPairInstaller().install( + affSource: aff, + dicSource: revised, + spec: spec, + destinationRoot: p.join(storage, 'imported'), + validateNativePair: (_, _, _) async => 'UTF-8', + replaceExisting: true, + ); + await second.checkNow(input); + expect( + second.catalog!.installedById('en-Test')!.fingerprint, + isNot(originalFingerprint), + ); + expect(second.state.status, SpellingPresentationStatus.ready); + expect(second.misspellings.map((word) => word.word), ['helo']); + await first.prepareSettings(null); + await first.removeImportedDictionary('en-Test'); + await second.checkNow(input); + expect(second.catalog!.installedById('en-Test'), isNull); + expect( + second.state.status, + SpellingPresentationStatus.dictionaryNotInstalled, + ); + }, + ); + + test('concurrent warm catalog preparations share one owned read', () async { + final temporary = await Directory.systemTemp.createTemp( + 'busymark-catalog-single-flight-', + ); + addTearDown(() async { + if (await temporary.exists()) await temporary.delete(recursive: true); + }); + final bundle = await _createFixtureBundle(temporary); + final entered = Completer(); + final release = Completer(); + final secondPersonalRefresh = Completer(); + var loads = 0; + var personalReads = 0; + final controller = SpellingSessionController( + bundledRoot: bundle, + applicationSupportRoot: p.join(temporary.path, 'support'), + dictionaryStorageRoot: p.join(temporary.path, 'dictionary-storage'), + wordStoreReader: (store) async { + if (!store.projectStore && ++personalReads == 3) { + secondPersonalRefresh.complete(); + } + return store.read(); + }, + catalogLoader: + ({ + required bundledRoot, + required downloadedRoot, + required importedRoot, + required verifyChecksums, + }) async { + loads++; + final snapshot = await SpellingDictionaryCatalog.load( + bundledRoot: bundledRoot, + downloadedRoot: downloadedRoot, + importedRoot: importedRoot, + verifyChecksums: verifyChecksums, + ); + if (loads == 2) { + entered.complete(); + await release.future; + } + return snapshot; + }, + ); + addTearDown(controller.dispose); + await controller.prepareSettings(null); + final first = controller.prepareSettings(null); + await entered.future; + final second = controller.prepareSettings(null); + await secondPersonalRefresh.future; + await Future.delayed(Duration.zero); + expect(loads, 2); + release.complete(); + await Future.wait([first, second]); + expect(loads, 2); + expect(controller.catalog!.installedById('en-Test'), isNotNull); + }); + + test('a delayed catalog read cannot undo a local installation', () async { + final temporary = await Directory.systemTemp.createTemp( + 'busymark-catalog-install-overlap-', + ); + addTearDown(() async { + if (await temporary.exists()) await temporary.delete(recursive: true); + }); + final bundle = await _createFixtureBundle(temporary); + final fixture = p.join( + Directory.current.path, + 'packages', + 'busymark_spellcheck_native', + 'test', + 'fixtures', + ); + final downloadEntered = Completer(); + final releaseDownload = Completer(); + final readEntered = Completer(); + final releaseRead = Completer(); + final published = Completer(); + var gateNextRead = false; + final controller = SpellingSessionController( + bundledRoot: bundle, + applicationSupportRoot: p.join(temporary.path, 'support'), + dictionaryStorageRoot: p.join(temporary.path, 'dictionary-storage'), + dictionaryDownloader: SpellingDictionaryDownloader( + installer: SpellingDictionaryPairInstaller( + onCommitted: (_) => published.complete(), + ), + downloadFile: + ({ + required source, + required destination, + required expectedBytes, + required cancellation, + required onProgress, + }) async { + if (source.path.endsWith('.aff')) { + downloadEntered.complete(); + await releaseDownload.future; + } + cancellation.throwIfCancelled(); + final input = File( + p.join( + fixture, + source.path.endsWith('.aff') ? 'test.aff' : 'test.dic', + ), + ); + final bytes = await input.readAsBytes(); + expect(bytes, hasLength(expectedBytes)); + await destination.writeAsBytes(bytes, flush: true); + onProgress(bytes.length); + }, + ), + catalogLoader: + ({ + required bundledRoot, + required downloadedRoot, + required importedRoot, + required verifyChecksums, + }) async { + final snapshot = await SpellingDictionaryCatalog.load( + bundledRoot: bundledRoot, + downloadedRoot: downloadedRoot, + importedRoot: importedRoot, + verifyChecksums: verifyChecksums, + ); + if (gateNextRead) { + gateNextRead = false; + readEntered.complete(); + await releaseRead.future; + } + return snapshot; + }, + ); + addTearDown(controller.dispose); + await controller.prepareSettings(null); + await controller.removeDownloadedDictionary('en-Test'); + final installation = controller.installDictionary('en-Test'); + await downloadEntered.future; + gateNextRead = true; + final oldRead = controller.prepareSettings(null); + await readEntered.future; + expect(controller.catalog!.installedById('en-Test'), isNull); + releaseDownload.complete(); + await published.future; + releaseRead.complete(); + await Future.wait([oldRead, installation]); + expect(controller.catalog!.installedById('en-Test'), isNotNull); + expect(controller.dictionaryInstallStatus, isNull); + }); + + test('a failed warm catalog refresh remains retryable', () async { + final temporary = await Directory.systemTemp.createTemp( + 'busymark-catalog-refresh-retry-', + ); + addTearDown(() async { + if (await temporary.exists()) await temporary.delete(recursive: true); + }); + final bundle = await _createFixtureBundle(temporary); + var fail = false; + var loads = 0; + final controller = SpellingSessionController( + bundledRoot: bundle, + applicationSupportRoot: p.join(temporary.path, 'support'), + dictionaryStorageRoot: p.join(temporary.path, 'dictionary-storage'), + catalogLoader: + ({ + required bundledRoot, + required downloadedRoot, + required importedRoot, + required verifyChecksums, + }) { + loads++; + if (fail) throw StateError('injected catalog read failure'); + return SpellingDictionaryCatalog.load( + bundledRoot: bundledRoot, + downloadedRoot: downloadedRoot, + importedRoot: importedRoot, + verifyChecksums: verifyChecksums, + ); + }, + ); + addTearDown(controller.dispose); + await controller.prepareSettings(null); + final original = controller.catalog; + fail = true; + await expectLater(controller.prepareSettings(null), throwsStateError); + expect(controller.catalog, same(original)); + fail = false; + await controller.prepareSettings(null); + expect(loads, 3); + expect(controller.catalog!.installedById('en-Test'), isNotNull); + }); + + test( + 'automatic checking rereads inventory after dictionary load fails', + () async { + final temporary = await Directory.systemTemp.createTemp( + 'busymark-catalog-native-open-', + ); + addTearDown(() async { + if (await temporary.exists()) await temporary.delete(recursive: true); + }); + final bundle = await _createFixtureBundle(temporary); + final startupEntered = Completer(); + final releaseStartup = Completer(); + final controller = SpellingSessionController( + bundledRoot: bundle, + applicationSupportRoot: p.join(temporary.path, 'support'), + dictionaryStorageRoot: p.join(temporary.path, 'dictionary-storage'), + coordinatorStarter: () async { + startupEntered.complete(); + await releaseStartup.future; + return SpellingCoordinator.start(); + }, + ); + addTearDown(controller.dispose); + final buffer = DocumentBuffer.untitled( + id: 'native-open-race', + name: 'native-open-race.md', + text: 'helo', + ); + SpellingSessionInput input(DocumentBuffer value) => SpellingSessionInput( + buffer: value, + workspace: null, + settings: AppSettings.defaults().copyWith( + defaultSpellingLanguage: 'en-Test', + ), + documentKind: DocumentKind.markdown, + markdownMode: MarkdownMode.commonMark, + ); + controller.update(input(buffer)); + await startupEntered.future; + final installed = controller.catalog!.installedById('en-Test')!; + await Directory(installed.directoryPath).delete(recursive: true); + releaseStartup.complete(); + await _waitFor( + () => + controller.state.status == SpellingPresentationStatus.failure || + controller.state.status == SpellingPresentationStatus.incomplete, + ); + controller.update(input(buffer.copyWith(text: 'wrld', revision: 1))); + await _waitFor( + () => controller.state.status != SpellingPresentationStatus.checking, + ); + expect( + controller.state.status, + SpellingPresentationStatus.dictionaryNotInstalled, + ); + expect(controller.catalog!.installedById('en-Test'), isNull); + }, + ); + group('long-lived spelling worker', () { late SpellingWorker worker; @@ -3644,6 +5900,79 @@ Future _pumpWidgetUntil( } } +final class _SpellingMemorySettingsStore implements LocalSettingsStore { + @override + Future> load() async => {}; + + @override + Future save(Map json) async {} +} + +Widget _spellingSettingsHeaderDefaults(BuildContext context, Widget child) => + HeaderBarConfigurationDefaults( + configuration: HeaderBarConfiguration( + title: 'Settings', + viewMode: AppViewMode.editor, + searchQuery: '', + textDirection: TextDirection.ltr, + canRefresh: false, + documentControlsVisible: false, + searchActive: false, + searchVisible: false, + sidebarVisible: false, + sidebarToggleVisible: false, + backVisible: true, + fullScreen: false, + modalBarrierDepth: 0, + sidebarWidth: 200, + labels: _spellingSettingsHeaderLabels, + theme: HeaderBarTheme.fromContext(context), + ), + child: child, + ); + +const _spellingSettingsHeaderLabels = HeaderBarLabels( + editor: 'Editor', + source: 'Source', + preview: 'Preview', + split: 'Split', + viewMode: 'View mode', + editorShortcut: '', + editorGtkAccelerator: '', + sourceShortcut: '', + sourceGtkAccelerator: '', + previewShortcut: '', + previewGtkAccelerator: '', + splitShortcut: '', + splitGtkAccelerator: '', + search: 'Search', + searchShortcut: '', + refresh: 'Refresh', + menu: 'Menu', + sidebar: 'Sidebar', + sidebarShortcut: '', + back: 'Back', + backShortcut: '', + save: 'Save', + export: 'Export', + exportShortcut: '', + exportGtkAccelerator: '', + fullScreen: 'Full screen', + fullScreenShortcut: '', + fullScreenGtkAccelerator: '', + settings: 'Settings', + settingsShortcut: '', + settingsGtkAccelerator: '', + keyboardShortcuts: 'Keyboard shortcuts', + keyboardShortcutsShortcut: '', + keyboardShortcutsGtkAccelerator: '', + syntaxReference: 'Syntax reference', + syntaxReferenceShortcut: '', + syntaxReferenceGtkAccelerator: '', + reportIssue: 'Report issue', + aboutBusyMark: 'About', +); + final class _DelayedAtomicFileWriter extends AtomicFileWriter { _DelayedAtomicFileWriter(); @@ -3715,6 +6044,40 @@ final class _ToggleFailAtomicFileWriter extends AtomicFileWriter { } } +final class _GatedSpellingAtomicFileWriter extends AtomicFileWriter { + Completer entered = Completer(); + Completer release = Completer(); + bool _hold = false; + + void holdNextWrite() { + entered = Completer(); + release = Completer(); + _hold = true; + } + + @override + Future writeBytes( + String targetPath, + List bytes, { + required bool overwrite, + FutureOr Function()? beforePublish, + FutureOr Function(String replacedPath)? acceptReplaced, + }) async { + if (_hold) { + _hold = false; + entered.complete(); + await release.future; + } + await super.writeBytes( + targetPath, + bytes, + overwrite: overwrite, + beforePublish: beforePublish, + acceptReplaced: acceptReplaced, + ); + } +} + final class _PostExchangeAtomicFileWriter extends AtomicFileWriter { final Completer exchanged = Completer(); final Completer _release = Completer(); diff --git a/test/src/spelling_workspace_test.dart b/test/src/spelling_workspace_test.dart index e003d741..5fc4c886 100644 --- a/test/src/spelling_workspace_test.dart +++ b/test/src/spelling_workspace_test.dart @@ -125,6 +125,126 @@ void main() { }, ); + testWidgets('rich image review preserves authored source through undo and redo', ( + tester, + ) async { + const source = 'hello ![hello `wrld`](image.png)\n'; + const corrected = 'hello ![hello `world`](image.png)\n'; + final harness = await _pumpWorkspace(tester, source: source); + await _until( + tester, + () => + harness.spelling.state.complete && + harness.spelling.misspellings.any((item) => item.word == 'wrld'), + ); + expect(harness.workspace.activeText, source); + await tester.tap(find.byType(EditableText).first); + await tester.sendKeyEvent(LogicalKeyboardKey.f7); + final dialog = find.byType(BusyMarkSpellingReviewDialog); + await _until( + tester, + () => find + .descendant(of: dialog, matching: find.text('world')) + .evaluate() + .isNotEmpty, + diagnostics: () => + 'dialog=${dialog.evaluate().length}; ' + 'texts=${tester.widgetList(find.byType(Text)).map((text) => text.data).toList()}; ' + 'words=${harness.spelling.misspellings.map((word) => word.word).toList()}', + ); + await tester.tap(find.descendant(of: dialog, matching: find.text('world'))); + await _until( + tester, + () => + harness.workspace.activeText == corrected && + dialog.evaluate().isEmpty, + ); + expect(harness.workspace.activeBuffer!.revision, greaterThan(0)); + harness.controller.undoActiveBuffer(); + await _until(tester, () => harness.workspace.activeText == source); + harness.controller.redoActiveBuffer(); + await _until(tester, () => harness.workspace.activeText == corrected); + await _until( + tester, + () => + harness.spelling.state.complete && + harness.spelling.misspellings.every((item) => item.word != 'wrld'), + ); + await tester.pumpWidget(const SizedBox.shrink()); + }); + + for (final fixture in [ + ( + name: 'nested code image beside math', + source: + r'hello ![hello ![hello `wrld`](inner.png) hello](outer.png) $x$ hello' + '\n', + ), + ( + name: 'nested address image beside math', + source: + r'hello ![hello ![hello https://example.invalid wrld](inner.png) hello](outer.png) $x$ hello' + '\n', + ), + ]) { + testWidgets('rich review preserves ${fixture.name} through undo and redo', ( + tester, + ) async { + final source = fixture.source; + final corrected = source.replaceFirst('wrld', 'world'); + final harness = await _pumpWorkspace(tester, source: source); + await _until( + tester, + () => + harness.spelling.state.complete && + harness.spelling.misspellings.any((item) => item.word == 'wrld'), + ); + final initialRevision = harness.workspace.activeBuffer!.revision; + final target = + harness.spelling.misspellings + .singleWhere((item) => item.word == 'wrld') + .run + .target + as SpellingRichBlockTarget; + await tester.tap( + find.byKey(ValueKey('wysiwyg-rendered-math-${target.blockId}')), + ); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.f7); + final dialog = find.byType(BusyMarkSpellingReviewDialog); + await _until( + tester, + () => find + .descendant(of: dialog, matching: find.text('world')) + .evaluate() + .isNotEmpty, + diagnostics: () => + 'dialog=${dialog.evaluate().length}; words=${harness.spelling.misspellings.map((item) => item.word).toList()}; texts=${tester.widgetList(find.byType(Text)).map((item) => item.data).toList()}', + ); + await tester.tap( + find.descendant(of: dialog, matching: find.text('world')), + ); + await _until( + tester, + () => + harness.workspace.activeText == corrected && + dialog.evaluate().isEmpty, + ); + expect(harness.workspace.activeBuffer!.revision, initialRevision + 1); + harness.controller.undoActiveBuffer(); + await _until(tester, () => harness.workspace.activeText == source); + harness.controller.redoActiveBuffer(); + await _until(tester, () => harness.workspace.activeText == corrected); + await _until( + tester, + () => + harness.spelling.state.complete && + harness.spelling.misspellings.every((item) => item.word != 'wrld'), + ); + await tester.pumpWidget(const SizedBox.shrink()); + }); + } + testWidgets( 'Ctrl+N typing paints live spelling targets and corrects with undo', (tester) async { @@ -223,6 +343,8 @@ void main() { () => harness.workspace.activeText == 'hello' && find.byType(BusyMarkSpellingReviewDialog).evaluate().isEmpty, + diagnostics: () => + 'text=${harness.workspace.activeText}; status=${harness.spelling.state.status}; dialog=${find.byType(BusyMarkSpellingReviewDialog).evaluate().length}', ); harness.controller.undoActiveBuffer(); await _until( @@ -425,8 +547,7 @@ void main() { () => !harness.workspace.activeText.contains('helo'), ); if (source.contains('é')) { - expect(harness.workspace.activeText, contains('café')); - expect(harness.workspace.activeText, isNot(contains('é'))); + expect(harness.workspace.activeText, contains('café')); } // The fixture deliberately does not contain café. Ignore it if present; // the previously visited wrld must not become the next review item. diff --git a/test/support/spelling_dictionary_installer_process.dart b/test/support/spelling_dictionary_installer_process.dart new file mode 100644 index 00000000..66986402 --- /dev/null +++ b/test/support/spelling_dictionary_installer_process.dart @@ -0,0 +1,63 @@ +import 'dart:io'; +import 'dart:convert'; +import 'package:path/path.dart' as p; + +import 'package:busymark/src/spellcheck/spelling_catalog.dart'; +import 'package:busymark/src/spellcheck/spelling_dictionary_installer.dart'; +import 'package:crypto/crypto.dart'; + +Future main(List arguments) async { + final aff = File(arguments[0]); + final dic = File(arguments[1]); + final destinationRoot = arguments[2]; + if (arguments.length > 3 && arguments[3] == 'remove') { + final destination = p.join(destinationRoot, 'en-Test'); + final manifest = File(p.join(destination, 'manifest.json')); + final installation = SpellingDictionaryInstallation.fromJson( + (jsonDecode(await manifest.readAsString()) as Map) + .cast(), + rootPath: destination, + ); + stdout.writeln('ready'); + await stdin.transform(utf8.decoder).transform(const LineSplitter()).first; + try { + await const SpellingDictionaryPairInstaller().remove( + installation: installation, + installationRoot: destinationRoot, + ); + } on StateError { + exitCode = 3; + } + return; + } + final spec = SpellingDictionaryInstallSpec( + resourceId: 'en-Test', + id: 'en-Test', + locales: const ['en-Test'], + label: 'Test English', + sourceRevision: 'fixture', + affSha256: (await sha256.bind(aff.openRead()).first).toString(), + dicSha256: (await sha256.bind(dic.openRead()).first).toString(), + affSize: await aff.length(), + dicSize: await dic.length(), + kind: SpellingDictionaryInstallationKind.downloaded, + ); + try { + await const SpellingDictionaryPairInstaller().install( + affSource: aff, + dicSource: dic, + spec: spec, + destinationRoot: destinationRoot, + validateNativePair: (_, _, _) async { + stdout.writeln('validating'); + await stdin + .transform(utf8.decoder) + .transform(const LineSplitter()) + .first; + return 'UTF-8'; + }, + ); + } on FileSystemException { + exitCode = 2; + } +} From 6cdfe82aa89d42740b590a6aad7438d065b504ac Mon Sep 17 00:00:00 2001 From: Albert G Date: Sat, 3 Oct 2026 01:14:33 -0700 Subject: [PATCH 04/14] Writerside slowdown fix (#38) * Offload Writerside model loading to background workers and validate input provenance * Introduce `WritersideExecution` to offload Writerside module and project parsing, indexing, and resolution to background worker isolates. * Add `InputObserver` and `WritersideInputSnapshot` to record filesystem reads, directory listings, and file types to verify model freshness against disk state. * Refactor `WorkspaceController` to queue document preparations, reconcile file monitor events, and prevent stale background jobs from publishing obsolete workspace states. * Enhance `WorkspaceFileMonitor` event classification to ignore internal topic staging and quarantine paths during file moves and deletions. * Update `WritersideTopicRemovalService` and topic creation workflows to verify input snapshots and disk consistency before committing file modifications. * Add unit and responsiveness tests covering isolate execution, input snapshot validation, and workspace state reconciliation. * Harden Writerside async state reconciliation * Add unit tests for Writerside input snapshot consistency * Add unit tests in `writerside_input_snapshot_test.dart` to verify `WritersideInputRecorder` observation behavior. * Test partial, complete, and repeated directory scanning scenarios for snapshot state consistency. * Validate handling of directory listing failures, recovery, rescans, and entry modifications. --- lib/src/core/anchored_path_guard.dart | 4 + lib/src/core/busymark_temporary_path.dart | 10 + lib/src/core/input_observer.dart | 23 + lib/src/core/path_utils.dart | 17 + .../openapi_dependency_resolver.dart | 22 +- lib/src/workspace/workspace_controller.dart | 827 +++++++- lib/src/workspace/workspace_file_monitor.dart | 105 +- lib/src/workspace/workspace_service.dart | 136 +- lib/src/writerside/writerside_execution.dart | 14 + .../writerside/writerside_input_snapshot.dart | 389 ++++ lib/src/writerside/writerside_model.dart | 5 + .../writerside/writerside_module_service.dart | 145 +- lib/src/writerside/writerside_project.dart | 214 ++- .../writerside/writerside_source_loader.dart | 25 +- .../writerside_topic_removal_service.dart | 68 +- test/src/app_smoke_test.dart | 18 +- test/src/workspace_controller_test.dart | 1701 ++++++++++++++++- .../src/workspace_monitor_endpoints_test.dart | 63 + test/src/workspace_service_test.dart | 27 +- test/src/writerside_api_test.dart | 28 + test/src/writerside_input_snapshot_test.dart | 200 ++ test/src/writerside_responsiveness_test.dart | 679 +++++++ test/support/writerside_responsiveness.dart | 315 +++ 23 files changed, 4820 insertions(+), 215 deletions(-) create mode 100644 lib/src/core/busymark_temporary_path.dart create mode 100644 lib/src/core/input_observer.dart create mode 100644 lib/src/writerside/writerside_execution.dart create mode 100644 lib/src/writerside/writerside_input_snapshot.dart create mode 100644 test/src/workspace_monitor_endpoints_test.dart create mode 100644 test/src/writerside_input_snapshot_test.dart create mode 100644 test/src/writerside_responsiveness_test.dart create mode 100644 test/support/writerside_responsiveness.dart diff --git a/lib/src/core/anchored_path_guard.dart b/lib/src/core/anchored_path_guard.dart index 0e75bb01..19cab00e 100644 --- a/lib/src/core/anchored_path_guard.dart +++ b/lib/src/core/anchored_path_guard.dart @@ -1,5 +1,7 @@ import 'dart:io'; +import 'input_observer.dart'; + import 'package:path/path.dart' as p; /// A canonical directory boundary captured before path validation. @@ -128,6 +130,7 @@ Future resolveAnchoredPath( ); } if (relative == '.') { + InputObserver.current?.path(resolved, rootType); return AnchoredPathResolution( anchor: anchor, path: resolved, @@ -166,6 +169,7 @@ Future resolveAnchoredPath( ); } } + InputObserver.current?.path(resolved, currentType); return AnchoredPathResolution( anchor: anchor, path: resolved, diff --git a/lib/src/core/busymark_temporary_path.dart b/lib/src/core/busymark_temporary_path.dart new file mode 100644 index 00000000..0cff4313 --- /dev/null +++ b/lib/src/core/busymark_temporary_path.dart @@ -0,0 +1,10 @@ +import 'package:path/path.dart' as p; + +// Only names emitted by the topic writers. A user file merely containing +// "busymark" is not an internal path. Moves must classify both endpoints. +final _topicStagingName = RegExp( + r'^\..+\.busymark-(?:topic-create|safe-delete(?:-quarantine)?)-\d+-\d+-\d+$', +); + +bool isBusyMarkTopicStagingPath(String path) => + _topicStagingName.hasMatch(p.basename(path)); diff --git a/lib/src/core/input_observer.dart b/lib/src/core/input_observer.dart new file mode 100644 index 00000000..e21e1e60 --- /dev/null +++ b/lib/src/core/input_observer.dart @@ -0,0 +1,23 @@ +import 'dart:async'; +import 'dart:io'; + +/// Scoped observations of inputs actually consumed by a read-only model job. +/// No observer is installed for ordinary filesystem operations or mutations. +abstract class InputObserver { + static final _key = Object(); + static InputObserver? get current => Zone.current[_key] as InputObserver?; + + T observe(T Function() body) => runZoned(body, zoneValues: {_key: this}); + + void path(String path, FileSystemEntityType type); + void read(String path, List bytes, {bool override = false}); + void limitedRead(String path, int bytes, {bool atLeast = false}); + void readFailed(String path); + void directory( + String path, + Iterable entries, { + bool complete = true, + bool failed = false, + }); + void incomplete(); +} diff --git a/lib/src/core/path_utils.dart b/lib/src/core/path_utils.dart index 170bbf5e..52c35237 100644 --- a/lib/src/core/path_utils.dart +++ b/lib/src/core/path_utils.dart @@ -1,4 +1,5 @@ import 'dart:io'; +import 'input_observer.dart'; import 'package:path/path.dart' as p; @@ -155,6 +156,10 @@ Future scanWorkspaceEntities( }) async { final directory = Directory(rootPath); if (!await directory.exists()) { + InputObserver.current?.path( + normalizePath(rootPath), + FileSystemEntityType.notFound, + ); return const WorkspaceScanResult( entities: [], diagnostics: [], @@ -188,6 +193,12 @@ Future scanWorkspaceEntities( } } on Object catch (error) { traversalComplete = false; + InputObserver.current?.directory( + current.path, + listing, + complete: false, + failed: true, + ); diagnostics.add( _scanWarning( current.path, @@ -197,6 +208,11 @@ Future scanWorkspaceEntities( ); continue; } + InputObserver.current?.directory( + current.path, + listing, + complete: !reachedTreeEntryLimit, + ); listing.sort((a, b) => a.path.compareTo(b.path)); for (final entity in listing) { @@ -253,6 +269,7 @@ Future scanWorkspaceEntities( traversalComplete = false; diagnostics.add(_scanWarning(rootPath, 'workspace.scan.skipped')); } + if (!traversalComplete) InputObserver.current?.incomplete(); entities.sort((a, b) => a.path.compareTo(b.path)); return WorkspaceScanResult( entities: entities, diff --git a/lib/src/visualization/openapi_dependency_resolver.dart b/lib/src/visualization/openapi_dependency_resolver.dart index 8c22c7c4..d7e4b1ca 100644 --- a/lib/src/visualization/openapi_dependency_resolver.dart +++ b/lib/src/visualization/openapi_dependency_resolver.dart @@ -5,6 +5,7 @@ import 'package:crypto/crypto.dart'; import 'package:path/path.dart' as p; import '../core/anchored_path_guard.dart'; +import '../core/input_observer.dart'; import 'visualization_models.dart'; import 'visualization_renderer.dart'; import 'web_render_host.dart'; @@ -143,6 +144,7 @@ class OpenApiDependencyResolver { ? await file.length() : utf8.encode(override).length; } on FileSystemException { + InputObserver.current?.readFailed(resolution.path); throw _referenceError( 'visualization.openapiReferenceUnavailable', 'OpenAPI reference could not be read: $referencePath', @@ -150,6 +152,15 @@ class OpenApiDependencyResolver { ); } if (size > maximumFileBytes || totalBytes + size > maximumTotalBytes) { + if (override == null) { + InputObserver.current?.limitedRead(resolution.path, size); + } else { + InputObserver.current?.read( + resolution.path, + utf8.encode(override), + override: true, + ); + } throw _referenceError( 'visualization.openapiReferenceTooLarge', 'OpenAPI local references exceed the size limit.', @@ -158,7 +169,15 @@ class OpenApiDependencyResolver { } late String source; try { - source = override ?? utf8.decode(await file.readAsBytes()); + final bytes = override == null + ? await file.readAsBytes() + : utf8.encode(override); + InputObserver.current?.read( + resolution.path, + bytes, + override: override != null, + ); + source = utf8.decode(bytes); } on FormatException { throw _referenceError( 'visualization.openapiReferenceEncoding', @@ -166,6 +185,7 @@ class OpenApiDependencyResolver { reference, ); } on FileSystemException { + InputObserver.current?.readFailed(resolution.path); throw _referenceError( 'visualization.openapiReferenceUnavailable', 'OpenAPI reference could not be read: $referencePath', diff --git a/lib/src/workspace/workspace_controller.dart b/lib/src/workspace/workspace_controller.dart index 58850970..596cfc0f 100644 --- a/lib/src/workspace/workspace_controller.dart +++ b/lib/src/workspace/workspace_controller.dart @@ -9,6 +9,8 @@ import 'package:path/path.dart' as p; import '../app/app_settings.dart'; import '../comparison/source_comparison.dart'; import '../core/debug_log.dart'; +import '../core/anchored_path_guard.dart'; +import '../core/busymark_temporary_path.dart'; import '../core/busymark_exception.dart'; import '../core/diagnostic.dart'; import '../core/source_span.dart'; @@ -21,6 +23,8 @@ import '../local_history/local_history_controller.dart'; import '../local_history/local_history_models.dart'; import '../writerside/writerside_project_creator.dart'; import '../writerside/writerside_project.dart'; +import '../writerside/writerside_model.dart'; +import '../writerside/writerside_input_snapshot.dart'; import '../writerside/writerside_instance_service.dart'; import '../writerside/writerside_topic_removal_service.dart'; import '../writerside/writerside_topic_creator.dart'; @@ -217,11 +221,21 @@ class WorkspaceController extends Notifier { var _validationSequence = 0; var _editRevision = 0; var _activeDocumentRevision = 0; + // Removal follows workspace/request lifetime, not active-tab navigation. + var _removalOperationRevision = 0; var _workspaceRefreshRevision = 0; + var _workspaceDiskRevision = 0; + _ReconciledFileNotifications? _reconciledNotifications; + int _preparationsRunning = 0; + _PendingPreparation? _pendingPreparation; + int? _acceptedRefreshRevision; + final _preparedInputs = Expando<_PreparedDocumentInputs>(); var _workspaceFileOperationDepth = 0; + int? _fileOperationNavigationRevision; final _deferredFileMonitorEvents = []; var _untitledSequence = 0; - final _intentionallyRemovedPaths = {}; + final _removalAuthorizations = {}; + late Future _recoveryStart; Future _persistenceWrites = Future.value(); @@ -288,6 +302,7 @@ class WorkspaceController extends Notifier { } }); ref.onDispose(() { + _removalOperationRevision++; _cancelPendingDerivedRefresh(); _cancelAllAutoSaves(); _persistenceDebounce?.cancel(); @@ -436,6 +451,9 @@ class WorkspaceController extends Notifier { active, buffers: buffers, ); + if (!ref.mounted || !_preparedInputsCurrent(reparsed, buffers)) { + return false; + } state = WorkspaceState( workspace: reparsed, preview: _safePreview(reparsed, active), @@ -713,6 +731,10 @@ class WorkspaceController extends Notifier { _deferredFileMonitorEvents.add(event); return; } + if (await _acknowledgeReconciledNotification(event)) return; + if (!ref.mounted) return; + _workspaceDiskRevision++; + _reconciledNotifications = null; final matching = state.documentBuffers.where((buffer) { final path = buffer.filePath; return path != null && @@ -726,13 +748,205 @@ class WorkspaceController extends Notifier { } final workspaceRoot = state.workspace?.rootPath; if (workspaceRoot == null || - (!p.equals(workspaceRoot, event.path) && - !p.isWithin(workspaceRoot, event.path))) { + ![ + event.path, + if (event.destinationPath != null) event.destinationPath!, + ].any( + (path) => + p.equals(workspaceRoot, path) || p.isWithin(workspaceRoot, path), + )) { return; } _scheduleMonitoredWorkspaceRefresh(); } + Map _incorporatedDiskStates( + Workspace workspace, + Iterable buffers, + ) { + final result = {}; + for (final module + in workspace.writersideProject?.modules ?? const []) { + final snapshot = module.inputSnapshot; + if (snapshot == null || + !snapshot.consistent || + !snapshot.discoveryComplete) { + continue; + } + for (final input in snapshot.reads.entries) { + if (!input.value.override) { + result[input.key] = (exists: true, hash: input.value.hash); + } + } + for (final input in snapshot.types.entries) { + if (input.value == FileSystemEntityType.notFound) { + result[input.key] = (exists: false, hash: null); + } + } + } + for (final buffer in buffers) { + final path = buffer.filePath; + if (path != null && + !buffer.isDirty && + buffer.diskState == DocumentDiskState.present && + buffer.diskSnapshot != null) { + result[path] = (exists: true, hash: buffer.diskSnapshot!.contentHash); + } + } + return result; + } + + void _recordReconciledNotifications( + Workspace original, + Map before, + ) { + final workspace = state.workspace; + final project = workspace?.writersideProject; + if (workspace == null || + project == null || + !project.moduleDiscoveryComplete || + project.inputSnapshot?.discoveryComplete != true || + project.modules.any( + (module) => + !module.topicDiscoveryComplete || + module.inputSnapshot?.discoveryComplete != true, + ) || + workspace.id != original.id || + state.isLoading || + !_preparedInputsCurrent(workspace, state.documentBuffers) || + _acceptedRefreshRevision != _workspaceRefreshRevision) { + return; + } + final after = _incorporatedDiskStates(workspace, state.documentBuffers); + for (final path in before.keys.where((path) => !after.containsKey(path))) { + // Absence is evidence only when a completed loader inventory contains + // the parent directory and did not find this path. + final inventories = project.modules.expand( + (m) => + m.inputSnapshot?.directories.entries ?? + const >>[], + ); + if (inventories.any( + (dir) => + p.equals(dir.key, p.dirname(path)) && + !dir.value.any((entry) => entry.startsWith('${p.basename(path)}:')), + )) { + after[path] = (exists: false, hash: null); + } + } + final changed = { + for (final entry in after.entries) + if (before[entry.key] != entry.value) entry.key: entry.value, + }; + if (changed.isEmpty || changed.length > 2048) return; + _reconciledNotifications = _ReconciledFileNotifications( + workspace.id, + workspace.rootPath, + project.modules, + _workspaceRefreshRevision, + changed, + DateTime.now().add(const Duration(seconds: 5)), + ); + } + + Future _acknowledgeReconciledNotification( + WorkspaceFileMonitorEvent event, + ) async { + final coverage = _reconciledNotifications; + final workspace = state.workspace; + if (coverage == null || workspace == null) return false; + if (coverage.workspaceId != workspace.id || + coverage.rootPath != workspace.rootPath || + coverage.refreshRevision != _workspaceRefreshRevision || + !identical(coverage.modules, workspace.writersideProject?.modules) || + DateTime.now().isAfter(coverage.expires)) { + _reconciledNotifications = null; + return false; + } + final paths = [ + event.path, + if (event.destinationPath != null) event.destinationPath!, + ].where((path) => !isBusyMarkTopicStagingPath(path)).toSet(); + if (paths.isEmpty || + event.isDirectory || + !_preparedInputsCurrent(workspace, state.documentBuffers)) { + return false; + } + final CanonicalPathAnchor anchor; + try { + anchor = await captureCanonicalDirectoryAnchor(coverage.rootPath); + } on Object { + return false; + } + if (!ref.mounted) { + return false; + } + for (final path in paths) { + final incorporated = coverage.states[path]; + if (incorporated == null) return false; + final matching = state.documentBuffers.where((b) => b.filePath == path); + // A model overlay alone cannot prove that dirty/conflicted editor state + // or history has handled an external notification. + if (matching.any( + (b) => + b.isDirty || + (incorporated.exists && + (b.diskState != DocumentDiskState.present || + b.diskSnapshot?.contentHash != incorporated.hash)) || + (!incorporated.exists && b.diskState != DocumentDiskState.deleted), + )) { + return false; + } + try { + final resolution = await resolveAnchoredPath( + anchor, + path, + allowRoot: false, + allowMissingAncestors: true, + ); + if (!incorporated.exists) { + if (resolution.type != FileSystemEntityType.notFound) return false; + } else { + if (resolution.type != FileSystemEntityType.file) return false; + final disk = await _service.fileSnapshot(resolution.path); + if (disk.contentHash != incorporated.hash || + await _service.fileChangedSince(resolution.path, disk)) { + return false; + } + } + } on Object { + return false; + } + if (!ref.mounted || + !identical(coverage, _reconciledNotifications) || + state.workspace?.id != coverage.workspaceId || + !_preparedInputsCurrent(state.workspace!, state.documentBuffers) || + !identical( + coverage.modules, + state.workspace?.writersideProject?.modules, + )) { + return false; + } + } + for (final path in paths) { + final incorporated = coverage.states[path]!; + if (state.documentBuffers + .where((b) => b.filePath == path) + .any( + (b) => + b.isDirty || + (incorporated.exists && + (b.diskState != DocumentDiskState.present || + b.diskSnapshot?.contentHash != incorporated.hash)) || + (!incorporated.exists && + b.diskState != DocumentDiskState.deleted), + )) { + return false; + } + } + return true; + } + void _scheduleMonitoredWorkspaceRefresh() { _workspaceRefreshDebounce?.cancel(); _workspaceRefreshDebounce = Timer(const Duration(milliseconds: 250), () { @@ -830,7 +1044,8 @@ class WorkspaceController extends Notifier { ); final reparsed = await _reparseWithDocumentBuffers(workspace, reloaded); if (state.activeBufferId == reloaded.id && - state.activeBuffer?.revision == reloaded.revision) { + state.activeBuffer?.revision == reloaded.revision && + _preparedInputsCurrent(reparsed, state.documentBuffers)) { state = state.copyWith( workspace: reparsed, preview: _safePreview(reparsed, reloaded), @@ -968,14 +1183,15 @@ class WorkspaceController extends Notifier { derivedWorkspace, remapped, ); - if (_canPublishActiveDerivedContent( - operationRevision: derivedOperationRevision, - workspaceId: derivedWorkspace.id, - bufferId: remapped.id, - path: destinationPath, - revision: remapped.revision, - source: remapped.text, - )) { + if (_preparedInputsCurrent(reparsed, state.documentBuffers) && + _canPublishActiveDerivedContent( + operationRevision: derivedOperationRevision, + workspaceId: derivedWorkspace.id, + bufferId: remapped.id, + path: destinationPath, + revision: remapped.revision, + source: remapped.text, + )) { state = state.copyWith( workspace: reparsed.copyWith( activeFileSnapshot: remapped.diskSnapshot, @@ -1070,14 +1286,15 @@ class WorkspaceController extends Notifier { derivedWorkspace.copyWith(activeFileSnapshot: disk.snapshot), reloaded, ); - if (_canPublishActiveDerivedContent( - operationRevision: derivedOperationRevision, - workspaceId: derivedWorkspace.id, - bufferId: reloaded.id, - path: path, - revision: reloaded.revision, - source: reloaded.text, - )) { + if (_preparedInputsCurrent(workspace, state.documentBuffers) && + _canPublishActiveDerivedContent( + operationRevision: derivedOperationRevision, + workspaceId: derivedWorkspace.id, + bufferId: reloaded.id, + path: path, + revision: reloaded.revision, + source: reloaded.text, + )) { state = state.copyWith( workspace: workspace, preview: _safePreview(workspace, reloaded), @@ -1180,6 +1397,7 @@ class WorkspaceController extends Notifier { _cancelPendingDerivedRefresh(); _cancelAllAutoSaves(); _invalidateActiveDocumentOperations(); + _removalOperationRevision++; state = const WorkspaceState(); for (final buffer in previous.documentBuffers) { _localHistory.handleBufferClosed( @@ -1232,14 +1450,15 @@ class WorkspaceController extends Notifier { buffer, buffers: buffers, ); - if (_canPublishActiveDerivedContent( - operationRevision: operationRevision, - workspaceId: workspace.id, - bufferId: buffer.id, - path: null, - revision: buffer.revision, - source: buffer.text, - )) { + if (_preparedInputsCurrent(reparsed, state.documentBuffers) && + _canPublishActiveDerivedContent( + operationRevision: operationRevision, + workspaceId: workspace.id, + bufferId: buffer.id, + path: null, + revision: buffer.revision, + source: buffer.text, + )) { state = state.copyWith( workspace: reparsed, preview: _safePreview(reparsed, buffer), @@ -1250,6 +1469,7 @@ class WorkspaceController extends Notifier { } Future openPath(String path) async { + _removalOperationRevision++; await _localHistory.flushAll(state.documentBuffers); _cancelPendingDerivedRefresh(); _cancelAllAutoSaves(); @@ -1288,6 +1508,10 @@ class WorkspaceController extends Notifier { ); if (buffer != null) unawaited(_localHistory.observeOpened(buffer)); _recordActivePreviewRevision(); + if (buffer != null && + _writersideSourceMismatch(loadedWorkspace, buffer)) { + _requestDerivedRefresh(rebuildPreview: true); + } await _startMonitoring(loadedWorkspace); _schedulePersistence(); _resetSaveTracking(); @@ -1320,6 +1544,7 @@ class WorkspaceController extends Notifier { Future createWritersideProject( WritersideProjectCreateRequest request, ) async { + _removalOperationRevision++; await _localHistory.flushAll(state.documentBuffers); _cancelPendingDerivedRefresh(); _cancelAllAutoSaves(); @@ -1358,6 +1583,10 @@ class WorkspaceController extends Notifier { ); if (buffer != null) unawaited(_localHistory.observeOpened(buffer)); _recordActivePreviewRevision(); + if (buffer != null && + _writersideSourceMismatch(loadedWorkspace, buffer)) { + _requestDerivedRefresh(rebuildPreview: true); + } await _startMonitoring(loadedWorkspace); _schedulePersistence(); _resetSaveTracking(); @@ -1408,7 +1637,7 @@ class WorkspaceController extends Notifier { validateBeforePublish: () async => _requireCleanAffectedFiles(workspace, paths), ); - return created.activeFilePath; + return created.topicPath; }, openForEditing: true); Future?> @@ -1537,7 +1766,7 @@ class WorkspaceController extends Notifier { )) { return false; } - _clearOpenFileTabs(workspace); + await _clearOpenFileTabs(workspace); for (final buffer in closingBuffers) { _localHistory.handleBufferClosed( buffer.id, @@ -1609,10 +1838,11 @@ class WorkspaceController extends Notifier { Future deleteWorkspaceEntity(String path) async { if (!await _protectWorkspaceEntityBeforeDelete(path)) return false; - _intentionallyRemovedPaths.add(p.normalize(path)); try { final deleted = await _runWorkspaceFileOperation((workspace) async { + _authorizeRemovedBuffers(p.normalize(path), workspace); await _service.deleteEntity(workspace, path); + _removalAuthorizations[p.normalize(path)]?.committed = true; return null; }); if (deleted) { @@ -1620,7 +1850,7 @@ class WorkspaceController extends Notifier { } return deleted; } finally { - _intentionallyRemovedPaths.remove(p.normalize(path)); + _removalAuthorizations.remove(p.normalize(path)); } } @@ -1711,7 +1941,8 @@ class WorkspaceController extends Notifier { } void _requireCleanWritersideProject(Workspace workspace) { - if (state.workspace?.id != workspace.id || + if (!ref.mounted || + state.workspace?.id != workspace.id || state.dirtyBuffers.any( (buffer) => buffer.filePath != null && @@ -2093,6 +2324,7 @@ class WorkspaceController extends Notifier { Future applyWritersideTopicRemoval( WritersideTopicRemovalRequest request, ) async { + final removalRevision = ++_removalOperationRevision; final deleting = request.analysis.mode == WritersideTopicRemovalMode.safeDeleteFile; final deletedPath = p.normalize(request.analysis.topicPath); @@ -2100,11 +2332,18 @@ class WorkspaceController extends Notifier { return null; } WritersideTopicRemovalResult? result; - if (deleting) _intentionallyRemovedPaths.add(deletedPath); try { final success = await _runWorkspaceFileOperation((workspace) async { - void validate(Iterable _) => - _requireCleanWritersideProject(workspace); + void validate(Iterable _) { + if (!ref.mounted || removalRevision != _removalOperationRevision) { + throw const BusyMarkException( + 'writerside.topic-file.project-buffers-dirty', + ); + } + _requireCleanWritersideProject(workspace); + if (deleting) _authorizeRemovedBuffers(deletedPath, workspace); + } + validate(const []); result = await _service.applyWritersideTopicRemoval( workspace, @@ -2112,13 +2351,16 @@ class WorkspaceController extends Notifier { validateBeforeCommit: validate, ); if (result?.deletedFile == true) { + _removalAuthorizations[deletedPath]?.committed = true; await _localHistory.markDeleted(deletedPath, recursive: false); } return null; }); return success ? result : null; } finally { - if (deleting) _intentionallyRemovedPaths.remove(deletedPath); + if (deleting) { + _removalAuthorizations.remove(deletedPath); + } } } @@ -2172,7 +2414,7 @@ class WorkspaceController extends Notifier { if (nextOpenFilePaths.isEmpty) { final nextUntitled = remainingBuffers.firstOrNull; if (nextUntitled == null) { - _clearOpenFileTabs(closingWorkspace); + await _clearOpenFileTabs(closingWorkspace); if (closingBuffer != null) { _localHistory.handleBufferClosed( closingBuffer.id, @@ -2208,6 +2450,7 @@ class WorkspaceController extends Notifier { historySettled: historySettled, ); } + await _validateActive(rebuildPreview: _activeModeShowsPreview); return true; } final nextIndex = closedIndex <= 0 @@ -2305,6 +2548,7 @@ class WorkspaceController extends Notifier { if (candidate.id != bufferId) candidate, ]; if (remaining.isEmpty) { + _removalOperationRevision++; state = const WorkspaceState(); _fileMonitor.updateOpenFilePaths(const []); _schedulePersistence(); @@ -2348,11 +2592,12 @@ class WorkspaceController extends Notifier { return activateDocumentBuffer(state.documentBuffers[normalizedIndex].id); } - void _clearOpenFileTabs(Workspace workspace) { + Future _clearOpenFileTabs(Workspace workspace) async { if (!_supportsOpenFileTabs(workspace)) { return; } _cancelPendingDerivedRefresh(); + _removalOperationRevision++; _cancelAllAutoSaves(); _invalidateActiveDocumentOperations(); _resetSaveTracking(); @@ -2369,6 +2614,7 @@ class WorkspaceController extends Notifier { activeFileModifiedAt: null, activeFileSnapshot: null, openFilePaths: const [], + sourceOverrides: const {}, diagnostics: diagnostics, markdown: null, ), @@ -2381,6 +2627,30 @@ class WorkspaceController extends Notifier { _recordActivePreviewRevision(); _fileMonitor.updateOpenFilePaths(const []); _schedulePersistence(); + final cleared = state.workspace!; + final operation = _activeDocumentRevision; + try { + final prepared = await _service.withDocumentSources(cleared, const {}); + if (prepared.writersideProject != null && + !await prepared.writersideProject!.observedInputsMatchDisk()) { + throw WritersideInputsChanged(prepared.rootPath); + } + if (ref.mounted && + operation == _activeDocumentRevision && + state.workspace?.id == cleared.id && + state.documentBuffers.isEmpty) { + state = state.copyWith(workspace: prepared); + } + } on Object catch (error, stackTrace) { + busyMarkDebugLogError( + '[BusyMark] Closed-buffer reconciliation failed', + error, + stackTrace, + ); + if (ref.mounted && state.workspace?.id == cleared.id) { + _scheduleMonitoredWorkspaceRefresh(); + } + } } Future _openActiveFile( @@ -2437,11 +2707,22 @@ class WorkspaceController extends Notifier { load, mode: _settingsController.state.documentViewMode, ); - final reparsed = await _reparseWithDocumentBuffers( + var reparsed = await _reparseWithDocumentBuffers( nextWorkspace, buffer, buffers: buffers, ); + if (_isCurrentActiveDocumentOperation(operationRevision) && + !_preparedInputsCurrent(reparsed, [ + ...state.documentBuffers, + buffer, + ])) { + reparsed = await _reparseWithDocumentBuffers( + state.workspace ?? nextWorkspace, + buffer, + buffers: state.documentBuffers, + ); + } if (!_isCurrentActiveDocumentOperation(operationRevision) || state.workspace?.id != workspaceId) { return false; @@ -2460,17 +2741,38 @@ class WorkspaceController extends Notifier { for (final addedPath in addedOpenPaths) if (!liveOpenPaths.contains(addedPath)) addedPath, ]; - final publishedWorkspace = reparsed.copyWith( - openFilePaths: reconciledOpenPaths, - ); + final sourcesCurrent = _preparedInputsCurrent( + reparsed, + reconciledBuffers, + ); + final publishedWorkspace = + (sourcesCurrent + ? reparsed + : (state.workspace ?? nextWorkspace).copyWith(markdown: null)) + .copyWith( + activeFilePath: path, + activeFileSnapshot: load.snapshot, + openFilePaths: reconciledOpenPaths, + ); + if (sourcesCurrent) { + _preparedInputs[publishedWorkspace] = _preparedInputs[reparsed]; + } state = state.copyWith( workspace: publishedWorkspace, activeText: load.text, - preview: _safePreview(publishedWorkspace, buffer), + preview: sourcesCurrent + ? _safePreview(publishedWorkspace, buffer) + : null, documentBuffers: reconciledBuffers, activeBufferId: buffer.id, clearMessage: true, ); + if (!sourcesCurrent) { + _requestDerivedRefresh( + rebuildPreview: _activeModeShowsPreview, + refreshOutline: !_activeModeShowsPreview, + ); + } unawaited(_localHistory.observeOpened(buffer)); _recordActivePreviewRevision(); _fileMonitor.updateOpenFilePaths( @@ -2541,6 +2843,56 @@ class WorkspaceController extends Notifier { openFilePaths: openFilePaths, markdown: null, ); + final displayInputs = _preparedInputs[workspace]; + if (_workspaceFileOperationDepth > 0 && + initialBufferIds.contains(targetBufferId) && + displayInputs != null && + displayInputs.workspaceId == state.workspace?.id && + displayInputs.diskRevision == _workspaceDiskRevision && + !buffer.isDirty && + _sameBufferInputs( + displayInputs.buffers, + _sourceBufferInputs(documentBuffers), + ) && + _sameBufferInputs( + displayInputs.buffers, + _sourceBufferInputs(state.documentBuffers), + ) && + resolveWorkspaceDocumentContext(workspace, buffer).writersideTopic != + null && + !_writersideSourceMismatch(workspace, buffer)) { + // A mutation can temporarily quarantine an input. Navigate the accepted + // display snapshot; the operation still owns authoritative reconciliation. + // Carry its original provenance without marking it freshly prepared for + // this navigation revision or acknowledging any filesystem notification. + final selected = _service.selectPreparedWritersideDocument( + nextWorkspace, + buffer, + ); + if (selected != null) { + _preparedInputs[selected] = displayInputs; + _fileOperationNavigationRevision = _removalOperationRevision; + state = state.copyWith( + workspace: selected, + preview: _safePreview(selected, buffer), + activeBufferId: buffer.id, + clearMessage: true, + ); + _recordActivePreviewRevision(); + _schedulePersistence(); + _editRevision = buffer.revision; + unawaited( + _settingsController.setDocumentViewMode(buffer.editorState.mode), + ); + return true; + } + } + List currentRequestedBuffers() => [ + for (final live in state.documentBuffers) + if (!intentionallyRemovedBufferIds.contains(live.id)) live, + for (final added in intentionallyAddedBuffers) + if (!state.documentBuffers.any((live) => live.id == added.id)) added, + ]; var parsedBuffer = buffer; var reparsed = await _reparseWithDocumentBuffers( nextWorkspace, @@ -2556,12 +2908,13 @@ class WorkspaceController extends Notifier { .firstOrNull; if (liveBuffer == null) return false; if (liveBuffer.revision != parsedBuffer.revision || - liveBuffer.text != parsedBuffer.text) { + liveBuffer.text != parsedBuffer.text || + !_preparedInputsCurrent(reparsed, currentRequestedBuffers())) { parsedBuffer = liveBuffer; reparsed = await _reparseWithDocumentBuffers( nextWorkspace, parsedBuffer, - buffers: documentBuffers, + buffers: currentRequestedBuffers(), ); if (!_isCurrentActiveDocumentOperation(operationRevision) || state.workspace?.id != workspaceId) { @@ -2572,7 +2925,7 @@ class WorkspaceController extends Notifier { .firstOrNull; if (liveBuffer == null) return false; } - final sourceStayedCurrent = + var sourceStayedCurrent = liveBuffer.revision == parsedBuffer.revision && liveBuffer.text == parsedBuffer.text; final liveBuffers = state.documentBuffers; @@ -2597,12 +2950,21 @@ class WorkspaceController extends Notifier { for (final path in intentionallyAddedOpenPaths) if (!(state.workspace?.openFilePaths.contains(path) ?? false)) path, ]; - final publishedWorkspace = (sourceStayedCurrent ? reparsed : nextWorkspace) - .copyWith( - activeFilePath: liveBuffer.filePath, - activeFileSnapshot: liveBuffer.diskSnapshot, - openFilePaths: reconciledOpenPaths, - ); + sourceStayedCurrent = + sourceStayedCurrent && + _preparedInputsCurrent(reparsed, reconciledBuffers); + final publishedWorkspace = + (sourceStayedCurrent + ? reparsed + : (state.workspace ?? nextWorkspace).copyWith(markdown: null)) + .copyWith( + activeFilePath: liveBuffer.filePath, + activeFileSnapshot: liveBuffer.diskSnapshot, + openFilePaths: reconciledOpenPaths, + ); + if (sourceStayedCurrent) { + _preparedInputs[publishedWorkspace] = _preparedInputs[reparsed]; + } state = state.copyWith( workspace: publishedWorkspace, preview: sourceStayedCurrent @@ -2988,11 +3350,25 @@ class WorkspaceController extends Notifier { load, mode: _settingsController.state.documentViewMode, ); - final reparsed = await _reparseWithDocumentBuffers(nextWorkspace, buffer); + var reparsed = await _reparseWithDocumentBuffers(nextWorkspace, buffer); if (!_isCurrentActiveDocumentOperation(operationRevision)) { return false; } final buffers = [...state.documentBuffers, buffer]; + if (!_preparedInputsCurrent(reparsed, buffers)) { + reparsed = await _reparseWithDocumentBuffers( + nextWorkspace, + buffer, + buffers: buffers, + ); + if (!_isCurrentActiveDocumentOperation(operationRevision)) return false; + if (!_preparedInputsCurrent(reparsed, [ + ...state.documentBuffers, + buffer, + ])) { + return false; + } + } state = state.copyWith( workspace: reparsed, preview: _safePreview(reparsed, buffer), @@ -3181,14 +3557,15 @@ class WorkspaceController extends Notifier { final operationRevision = _activeDocumentRevision; try { final reparsed = await _reparseWithDocumentBuffers(workspace, buffer); - if (!_canPublishActiveDerivedContent( - operationRevision: operationRevision, - workspaceId: workspaceId, - bufferId: bufferId, - path: activeFilePath, - revision: editRevision, - source: text, - )) { + if (!_preparedInputsCurrent(reparsed, state.documentBuffers) || + !_canPublishActiveDerivedContent( + operationRevision: operationRevision, + workspaceId: workspaceId, + bufferId: bufferId, + path: activeFilePath, + revision: editRevision, + source: text, + )) { return; } state = state.copyWith( @@ -3228,14 +3605,15 @@ class WorkspaceController extends Notifier { reparsed, buffer, ); - if (!_canPublishActiveDerivedContent( - operationRevision: operationRevision, - workspaceId: workspaceId, - bufferId: bufferId, - path: activeFilePath, - revision: editRevision, - source: text, - )) { + if (!_preparedInputsCurrent(reparsed, state.documentBuffers) || + !_canPublishActiveDerivedContent( + operationRevision: operationRevision, + workspaceId: workspaceId, + bufferId: bufferId, + path: activeFilePath, + revision: editRevision, + source: text, + )) { return; } // Preview remains live when validate-on-edit is disabled, but the parsed @@ -3443,6 +3821,7 @@ class WorkspaceController extends Notifier { currentBuffer == null || currentBuffer.revision != editRevision || currentBuffer.text != text || + !_preparedInputsCurrent(reparsed, state.documentBuffers) || !_sameFileSnapshot(currentBuffer.diskSnapshot, snapshot)) { return; } @@ -3451,6 +3830,7 @@ class WorkspaceController extends Notifier { openFilePaths: currentWorkspace.openFilePaths, files: currentWorkspace.files, ); + _preparedInputs[nextWorkspace] = _preparedInputs[reparsed]; state = state.copyWith( workspace: nextWorkspace, preview: _safePreview(nextWorkspace, currentBuffer), @@ -4386,6 +4766,7 @@ class WorkspaceController extends Notifier { return false; } final refreshRevision = ++_workspaceRefreshRevision; + _acceptedRefreshRevision = null; _invalidateActiveDocumentOperations(); _cancelPendingDerivedRefresh(); _cancelAllAutoSaves(); @@ -4418,9 +4799,7 @@ class WorkspaceController extends Notifier { if (path == null) { continue; } - if (_intentionallyRemovedPaths.any( - (removed) => p.equals(path, removed) || p.isWithin(removed, path), - )) { + if (_authorizedRemovedBuffer(requested)) { continue; } final exists = @@ -4475,6 +4854,7 @@ class WorkspaceController extends Notifier { if (activeBuffer == null && requestedBuffers.isEmpty && state.documentBuffers.isEmpty && + workspace.activeFilePath != null && refreshed.activeFilePath != null) { final load = await _service.loadTextWithSnapshot( refreshed.activeFilePath!, @@ -4495,6 +4875,7 @@ class WorkspaceController extends Notifier { path: activeBuffer?.filePath, revision: activeBuffer?.revision, text: activeBuffer?.text ?? '', + sources: _sourceBufferInputs(buffers), ); var reparsed = await _reparseWorkspaceRefresh( refreshedWorkspace, @@ -4522,8 +4903,10 @@ class WorkspaceController extends Notifier { path: activeBuffer?.filePath, revision: activeBuffer?.revision, text: activeBuffer?.text ?? '', + sources: _sourceBufferInputs(buffers), ); - if (finalTarget != parseTarget) { + if (finalTarget != parseTarget || + !_preparedInputsCurrent(reparsed, buffers)) { parseTarget = finalTarget; reparsed = await _reparseWorkspaceRefresh( refreshedWorkspace, @@ -4551,15 +4934,19 @@ class WorkspaceController extends Notifier { path: activeBuffer?.filePath, revision: activeBuffer?.revision, text: activeBuffer?.text ?? '', + sources: _sourceBufferInputs(buffers), ); } final tabPaths = _refreshTabPaths(buffers); - if (finalTarget == parseTarget) { + if (finalTarget == parseTarget && + _preparedInputsCurrent(reparsed, buffers)) { + _acceptedRefreshRevision = refreshRevision; final publishedWorkspace = reparsed.copyWith( activeFilePath: activeBuffer?.filePath, activeFileSnapshot: activeBuffer?.diskSnapshot, openFilePaths: tabPaths, ); + _preparedInputs[publishedWorkspace] = _preparedInputs[reparsed]; state = state.copyWith( workspace: publishedWorkspace, preview: activeBuffer == null @@ -4617,15 +5004,44 @@ class WorkspaceController extends Notifier { state.workspace?.id == workspaceId; } + void _authorizeRemovedBuffers(String path, Workspace workspace) { + _removalAuthorizations[path] = _RemovalBufferAuthorization(workspace.id, [ + for (final b in state.documentBuffers) + if (b.filePath != null && + (p.equals(b.filePath!, path) || p.isWithin(path, b.filePath!))) + b, + ]); + } + + bool _authorizedRemovedBuffer(DocumentBuffer buffer) => + _removalAuthorizations.values.any( + (authorization) => + authorization.committed && + authorization.workspaceId == state.workspace?.id && + authorization.buffers.any( + (authorized) => + !buffer.isDirty && + _workspaceRefreshRequestStillMatches(buffer, authorized), + ), + ); + List _workspaceRefreshLiveBuffers() => [ for (final buffer in state.documentBuffers) - if (buffer.filePath == null || - !_intentionallyRemovedPaths.any( - (removed) => - p.equals(buffer.filePath!, removed) || - p.isWithin(removed, buffer.filePath!), - )) - buffer, + if (!_authorizedRemovedBuffer(buffer)) + // A committed removal authorizes only the captured clean revision. + // Edits accepted afterwards remain recoverable, including edits made + // while reconciliation is awaiting model work. + if (buffer.filePath != null && + _removalAuthorizations.entries.any( + (entry) => + entry.value.committed && + entry.value.workspaceId == state.workspace?.id && + (p.equals(buffer.filePath!, entry.key) || + p.isWithin(entry.key, buffer.filePath!)), + )) + buffer.copyWith(diskState: DocumentDiskState.deleted) + else + buffer, ]; Future _discardWorkspaceRefreshReplacementsWithStaleDisk( @@ -4650,19 +5066,44 @@ class WorkspaceController extends Notifier { Workspace refreshedWorkspace, List buffers, DocumentBuffer? activeBuffer, - ) { + ) async { + // Use the live selection: another context can be selected while the disk + // model is loading. Existing input/revision guards still govern publication. + final live = state.workspace; + if (live != null && live.id == refreshedWorkspace.id) { + refreshedWorkspace = _service.preserveWritersideContext( + refreshedWorkspace, + live, + ); + } final nextWorkspace = refreshedWorkspace.copyWith( activeFilePath: activeBuffer?.filePath, activeFileSnapshot: activeBuffer?.diskSnapshot, openFilePaths: _refreshTabPaths(buffers), ); - return activeBuffer == null - ? Future.value(nextWorkspace.copyWith(markdown: null)) - : _reparseWithDocumentBuffers( - nextWorkspace, - activeBuffer, - buffers: buffers, - ); + if (activeBuffer != null) { + return _reparseWithDocumentBuffers( + nextWorkspace, + activeBuffer, + buffers: buffers, + ); + } + final inputs = _PreparedDocumentInputs( + _sourceBufferInputs(buffers), + _workspaceDiskRevision, + _activeDocumentRevision, + state.workspace?.id, + ); + final prepared = await _service.withDocumentSources(nextWorkspace, { + for (final buffer in buffers) + if (buffer.filePath != null) buffer.filePath!: buffer.text, + }); + if (prepared.writersideProject != null && + !await prepared.writersideProject!.observedInputsMatchDisk()) { + return nextWorkspace.copyWith(); + } + _preparedInputs[prepared] = inputs; + return prepared; } Future _runWorkspaceFileOperation( @@ -4673,9 +5114,15 @@ class WorkspaceController extends Notifier { if (workspace == null) { return false; } + final before = _incorporatedDiskStates(workspace, state.documentBuffers); + _reconciledNotifications = null; _workspaceFileOperationDepth++; + var reconciled = false; try { final preferredActivePath = await operation(workspace); + if (!ref.mounted || state.workspace?.id != workspace.id) { + return false; + } final refreshed = await refreshWorkspaceFromDiskPreservingOpenTabs(); if (!refreshed) { return false; @@ -4691,8 +5138,12 @@ class WorkspaceController extends Notifier { // preference or the view modes of any other open buffers. updateActiveEditorMode(DocumentViewModePreference.source); } + reconciled = opened; + if (opened) _recordReconciledNotifications(workspace, before); return opened; } + reconciled = true; + _recordReconciledNotifications(workspace, before); return true; } on Object catch (error, stackTrace) { busyMarkDebugLogError( @@ -4701,6 +5152,9 @@ class WorkspaceController extends Notifier { stackTrace, context: {'root': busyMarkLogPath(workspace.rootPath)}, ); + if (!ref.mounted) { + return false; + } state = state.copyWith( isLoading: false, message: WorkspaceMessage( @@ -4710,8 +5164,19 @@ class WorkspaceController extends Notifier { ); return false; } finally { + if (!reconciled) _reconciledNotifications = null; _workspaceFileOperationDepth--; if (_workspaceFileOperationDepth == 0 && ref.mounted) { + final navigationRevision = _fileOperationNavigationRevision; + _fileOperationNavigationRevision = null; + final current = state.workspace; + if (navigationRevision == _removalOperationRevision && + current != null && + !_preparedInputsCurrent(current, state.documentBuffers)) { + // Failed/rolled-back operations still finish any deferred navigation + // preparation through the existing bounded derived-work scheduler. + _requestDerivedRefresh(rebuildPreview: true); + } final pending = List.of( _deferredFileMonitorEvents, ); @@ -4803,15 +5268,92 @@ class WorkspaceController extends Notifier { Workspace workspace, DocumentBuffer target, { Iterable? buffers, + bool priority = false, }) async { - final sourceBuffers = buffers ?? state.documentBuffers; - final overlaid = await _service.withDocumentSources(workspace, { - for (final buffer in sourceBuffers) - if (buffer.filePath != null) buffer.filePath!: buffer.text, - // Disk reloads and newly opened/saved files may not be in the list yet. - if (target.filePath case final path?) path: target.text, - }); - return _service.reparseDocument(overlaid, target); + final sourceBuffers = List.of( + buffers ?? state.documentBuffers, + ); + sourceBuffers.removeWhere( + (b) => + b.id == target.id || + target.filePath != null && b.filePath == target.filePath, + ); + sourceBuffers.add(target); + final inputs = _PreparedDocumentInputs( + _sourceBufferInputs(sourceBuffers), + _workspaceDiskRevision, + _activeDocumentRevision, + state.workspace?.id, + ); + final baseline = _sourceBufferInputs(state.documentBuffers); + final baselineWorkspaceId = state.workspace?.id; + final revision = _activeDocumentRevision; + Future prepare() async { + // Publication permission and model freshness are separate. Obsolete + // queued jobs do not start work; running jobs finish without publishing. + if (!ref.mounted || + state.workspace?.id != baselineWorkspaceId || + revision != _activeDocumentRevision || + !_sameBufferInputs( + baseline, + _sourceBufferInputs(state.documentBuffers), + )) { + return workspace.copyWith(); + } + try { + final prepared = await _service.prepareDocument(workspace, target, { + for (final buffer in sourceBuffers) + if (buffer.filePath != null) buffer.filePath!: buffer.text, + }); + if (prepared.writersideProject != null && + !await prepared.writersideProject!.observedInputsMatchDisk()) { + return workspace.copyWith(); + } + _preparedInputs[prepared] = inputs; + return prepared; + } on WritersideInputsChanged { + return workspace.copyWith(); + } + } + + final pending = _PendingPreparation(prepare, workspace); + if (_preparationsRunning < 2 || priority) { + _startPreparation(pending); + } else { + _pendingPreparation?.completion.complete( + _pendingPreparation!.fallback.copyWith(), + ); + _pendingPreparation = pending; + } + return pending.completion.future; + } + + void _startPreparation(_PendingPreparation pending) { + _preparationsRunning++; + unawaited(() async { + try { + pending.completion.complete(await pending.run()); + } on Object catch (error, stack) { + pending.completion.completeError(error, stack); + } finally { + _preparationsRunning--; + final next = _pendingPreparation; + _pendingPreparation = null; + if (next != null) _startPreparation(next); + } + }()); + } + + bool _preparedInputsCurrent( + Workspace workspace, + Iterable buffers, + ) { + final prepared = _preparedInputs[workspace]; + return prepared != null && + prepared.diskRevision == _workspaceDiskRevision && + prepared.operationRevision == _activeDocumentRevision && + prepared.workspaceId == state.workspace?.id && + _sameBufferInputs(prepared.buffers, _sourceBufferInputs(buffers)); } Future validateActive() async { @@ -4823,7 +5365,7 @@ class WorkspaceController extends Notifier { // work becomes stale; later edits can queue a refresh after this request. _cancelPendingDerivedRefresh(); try { - return await _validateActive(rebuildPreview: true); + return await _validateActive(rebuildPreview: true, priority: true); } finally { _manualValidationRunning = false; if (ref.mounted && _derivedRefreshPending) { @@ -4842,6 +5384,7 @@ class WorkspaceController extends Notifier { Future _validateActive({ required bool rebuildPreview, + bool priority = false, }) async { final workspace = state.workspace; final targetBuffer = state.activeBuffer; @@ -4890,9 +5433,12 @@ class WorkspaceController extends Notifier { workspace, targetBuffer, buffers: buffers, + priority: priority, ); final currentWorkspace = state.workspace; - if (!current() || currentWorkspace == null) { + if (!current() || + currentWorkspace == null || + !_preparedInputsCurrent(reparsed, state.documentBuffers)) { return outcome(ValidationStatus.stale); } final currentSnapshot = currentWorkspace.activeFileSnapshot; @@ -4902,6 +5448,7 @@ class WorkspaceController extends Notifier { files: currentWorkspace.files, runtimeDiagnostics: currentWorkspace.runtimeDiagnostics, ); + _preparedInputs[validatedWorkspace] = _preparedInputs[reparsed]; if (rebuildPreview) { state = state.copyWith( workspace: validatedWorkspace, @@ -4940,8 +5487,17 @@ class WorkspaceController extends Notifier { } } + bool _writersideSourceMismatch(Workspace workspace, DocumentBuffer buffer) { + final context = resolveWorkspaceDocumentContext(workspace, buffer); + final topic = context.writersideModule?.topics + .where((topic) => topic.filePath == context.diskPath) + .firstOrNull; + return topic != null && topic.document.source != buffer.text; + } + PreviewDocument? _safePreview(Workspace workspace, DocumentBuffer buffer) { try { + if (_writersideSourceMismatch(workspace, buffer)) return null; return _service.buildDocumentPreview(workspace, buffer); } on Object { return PreviewDocument( @@ -5280,12 +5836,14 @@ class _WorkspaceRefreshParseTarget { required this.path, required this.revision, required this.text, + this.sources = const [], }); final String? bufferId; final String? path; final int? revision; final String text; + final List<_BufferSourceInput> sources; @override bool operator ==(Object other) => @@ -5293,7 +5851,8 @@ class _WorkspaceRefreshParseTarget { other.bufferId == bufferId && other.path == path && other.revision == revision && - other.text == text; + other.text == text && + _sameBufferInputs(sources, other.sources); @override int get hashCode => Object.hash(bufferId, path, revision, text); @@ -5461,3 +6020,69 @@ bool _samePathLists(List first, List second) { } return true; } + +typedef _BufferSourceInput = ({ + String id, + String? path, + int revision, + String text, +}); +List<_BufferSourceInput> _sourceBufferInputs( + Iterable buffers, +) => [ + for (final b in buffers) + (id: b.id, path: b.filePath, revision: b.revision, text: b.text), +]..sort((a, b) => a.id.compareTo(b.id)); + +bool _sameBufferInputs( + List<_BufferSourceInput> a, + List<_BufferSourceInput> b, +) => + a.length == b.length && + List.generate(a.length, (i) => a[i] == b[i]).every((v) => v); + +class _PreparedDocumentInputs { + const _PreparedDocumentInputs( + this.buffers, + this.diskRevision, + this.operationRevision, + this.workspaceId, + ); + final List<_BufferSourceInput> buffers; + final int diskRevision; + final int operationRevision; + final String? workspaceId; +} + +typedef _IncorporatedDiskState = ({bool exists, String? hash}); + +class _ReconciledFileNotifications { + const _ReconciledFileNotifications( + this.workspaceId, + this.rootPath, + this.modules, + this.refreshRevision, + this.states, + this.expires, + ); + final String workspaceId; + final String rootPath; + final List modules; + final int refreshRevision; + final Map states; + final DateTime expires; +} + +class _PendingPreparation { + _PendingPreparation(this.run, this.fallback); + final Future Function() run; + final Workspace fallback; + final completion = Completer(); +} + +class _RemovalBufferAuthorization { + _RemovalBufferAuthorization(this.workspaceId, this.buffers); + final String workspaceId; + final List buffers; + bool committed = false; +} diff --git a/lib/src/workspace/workspace_file_monitor.dart b/lib/src/workspace/workspace_file_monitor.dart index 17faf53d..a8c22cd1 100644 --- a/lib/src/workspace/workspace_file_monitor.dart +++ b/lib/src/workspace/workspace_file_monitor.dart @@ -3,6 +3,8 @@ import 'dart:io'; import 'package:path/path.dart' as p; +import '../core/busymark_temporary_path.dart'; + enum WorkspaceFileEventKind { changed, deleted, moved, workspaceChanged } class WorkspaceFileMonitorEvent { @@ -108,33 +110,13 @@ class WorkspaceFileMonitor { } void _receive(FileSystemEvent event, {required bool workspaceRoot}) { - final path = p.normalize(p.absolute(event.path)); - if (_isBusyMarkTemporaryPath(path)) { - return; - } - final destination = - event is FileSystemMoveEvent && event.destination != null - ? p.normalize(p.absolute(event.destination!)) - : null; - final openPath = - _openFilePaths.contains(path) || - (destination != null && _openFilePaths.contains(destination)); - if (!workspaceRoot && !openPath) { - return; - } - final kind = event is FileSystemDeleteEvent - ? WorkspaceFileEventKind.deleted - : event is FileSystemMoveEvent - ? WorkspaceFileEventKind.moved - : openPath - ? WorkspaceFileEventKind.changed - : WorkspaceFileEventKind.workspaceChanged; - _pending[path] = WorkspaceFileMonitorEvent( - kind: kind, - path: path, - destinationPath: destination, - isDirectory: event.isDirectory, + final classified = classifyWorkspaceFileMonitorEvent( + event, + openFilePaths: _openFilePaths, + workspaceRoot: workspaceRoot, ); + if (classified == null) return; + _pending[classified.path] = classified; _debounceTimer?.cancel(); _debounceTimer = Timer(debounce, _flush); } @@ -148,17 +130,66 @@ class WorkspaceFileMonitor { } } } +} - bool _isBusyMarkTemporaryPath(String path) { - final basename = p.basename(path); - return basename.contains('.busymark-save-') || - path - .split(p.separator) - .any( - (part) => - part.startsWith('.busymark-state-') || - part.startsWith('.busymark-settings-') || - part.startsWith('.busymark-export-'), - ); +WorkspaceFileMonitorEvent? classifyWorkspaceFileMonitorEvent( + FileSystemEvent event, { + required Set openFilePaths, + required bool workspaceRoot, +}) { + var path = p.normalize(p.absolute(event.path)); + var destination = event is FileSystemMoveEvent && event.destination != null + ? p.normalize(p.absolute(event.destination!)) + : null; + final sourceTemporary = _isBusyMarkTemporaryPath(path); + final destinationTemporary = + destination != null && _isBusyMarkTemporaryPath(destination); + WorkspaceFileEventKind? endpointKind; + if (sourceTemporary) { + if (destination == null || destinationTemporary) return null; + // The real destination was published by an internal staging rename. + path = destination; + destination = null; + endpointKind = openFilePaths.contains(path) + ? WorkspaceFileEventKind.changed + : WorkspaceFileEventKind.workspaceChanged; + } else if (destinationTemporary) { + // Quarantine removes the real source. Do not expose the private name as + // an external rename target for an editor buffer. + destination = null; + endpointKind = WorkspaceFileEventKind.deleted; } + final openPath = + openFilePaths.contains(path) || + (destination != null && openFilePaths.contains(destination)); + if (!workspaceRoot && !openPath) return null; + final kind = + endpointKind ?? + (event is FileSystemDeleteEvent + ? WorkspaceFileEventKind.deleted + : event is FileSystemMoveEvent + ? WorkspaceFileEventKind.moved + : openPath + ? WorkspaceFileEventKind.changed + : WorkspaceFileEventKind.workspaceChanged); + return WorkspaceFileMonitorEvent( + kind: kind, + path: path, + destinationPath: destination, + isDirectory: event.isDirectory, + ); +} + +bool _isBusyMarkTemporaryPath(String path) { + final basename = p.basename(path); + return isBusyMarkTopicStagingPath(path) || + basename.contains('.busymark-save-') || + path + .split(p.separator) + .any( + (part) => + part.startsWith('.busymark-state-') || + part.startsWith('.busymark-settings-') || + part.startsWith('.busymark-export-'), + ); } diff --git a/lib/src/workspace/workspace_service.dart b/lib/src/workspace/workspace_service.dart index e6c4bce8..daac0c44 100644 --- a/lib/src/workspace/workspace_service.dart +++ b/lib/src/workspace/workspace_service.dart @@ -22,6 +22,7 @@ import '../writerside/writerside_instance_service.dart'; import '../writerside/writerside_model.dart'; import '../writerside/writerside_project_creator.dart'; import '../writerside/writerside_project.dart'; +import '../writerside/writerside_input_snapshot.dart'; import '../writerside/writerside_toc_editor.dart'; import '../writerside/writerside_topic_creator.dart'; import '../writerside/writerside_topic_file_editor.dart'; @@ -229,7 +230,7 @@ class WorkspaceService { ); } - Future createWritersideTopic( + Future createWritersideTopic( Workspace workspace, WritersideTopicCreateRequest request, { String? instanceTreePath, @@ -272,7 +273,7 @@ class WorkspaceService { validateTopicIdentityBeforePublish: (validation) => _validateCreatedTopicIdentity(module.rootPath, validation), ); - return _openWriterside(module.rootPath, activeFilePath: result.topicPath); + return result; } Future> @@ -1019,6 +1020,7 @@ class WorkspaceService { } return writersideTopicRemovalService.apply( request, + project: project, validateBeforeCommit: validateBeforeCommit, ); } @@ -1949,8 +1951,18 @@ class WorkspaceService { if (project == null) { return workspace.copyWith(sourceOverrides: Map.unmodifiable(sources)); } + // A discovery change can introduce another owning module or file symbol. + // Reopen only when the recorded discovery inputs changed, never merely + // because the active tab changed. Buffer overlays are reapplied below. var updated = project; - for (final module in project.modules) { + if (!await _canPresentWritersideInputs(project.inputSnapshot)) { + updated = await _writersideProjectService.load( + project.rootPath, + preferredModuleRoot: project.activeModule?.rootPath, + ); + updated = _preserveProjectSelection(updated, project); + } + for (final module in List.of(updated.modules)) { final overrides = { for (final entry in sources.entries) if (p.isWithin(module.rootPath, entry.key)) entry.key: entry.value, @@ -1958,15 +1970,22 @@ class WorkspaceService { if (overrides.length == module.sourceOverrides.length && overrides.entries.every( (entry) => module.sourceOverrides[entry.key] == entry.value, - )) { + ) && + await _canPresentWritersideInputs(module.inputSnapshot)) { continue; } - updated = updated.withModule( - await writersideService.load( - module.rootPath, - options: _useWorkspaceScanOptionsForWriterside ? scanOptions : null, - sourceOverrides: overrides, - ), + final loaded = await writersideService.load( + module.rootPath, + options: _useWorkspaceScanOptionsForWriterside ? scanOptions : null, + sourceOverrides: overrides, + ); + final configChanged = + module.inputSnapshot?.reads[module.config.filePath] != + loaded.inputSnapshot?.reads[loaded.config.filePath]; + updated = await _writersideProjectService.replaceModule( + updated, + loaded, + rediscoverFileSymbols: configChanged, ); } return workspace.copyWith( @@ -1981,6 +2000,103 @@ class WorkspaceService { ); } + /// A disk reconciliation is not a user context selection. Keep a surviving + /// module/instance without changing the active document or reading it again. + Workspace preserveWritersideContext(Workspace workspace, Workspace previous) { + final project = workspace.writersideProject; + final before = previous.writersideProject; + if (project == null || + before == null || + !p.equals(project.rootPath, before.rootPath)) { + return workspace; + } + final selected = _preserveProjectSelection(project, before); + return workspace.copyWith( + writersideProject: selected, + writersideModule: selected.activeModule, + ); + } + + WritersideProject _preserveProjectSelection( + WritersideProject next, + WritersideProject previous, + ) { + final root = previous.activeModule?.rootPath; + final owner = next.modulesByOrigin.entries + .where((entry) => p.equals(entry.value.rootPath, root ?? '')) + .firstOrNull; + if (owner == null) return next; + final instance = + owner.value.instances.any( + (instance) => instance.id == previous.activeInstanceId, + ) + ? previous.activeInstanceId + : owner.value.instances + .where((instance) => !instance.isLibrary) + .firstOrNull + ?.id; + return next.withSelection(moduleId: owner.key, instanceId: instance); + } + + Future _canPresentWritersideInputs( + WritersideInputSnapshot? inputs, + ) async { + if (inputs == null) return false; + if (inputs.discoveryComplete) return inputs.isCurrent(); + final options = _useWorkspaceScanOptionsForWriterside + ? scanOptions + : writersideService.scanOptions; + // A stable limit can present the observed portion without reparsing on + // every tab switch. A changed limit must rediscover, including recovery + // to a complete inventory. Full freshness remains false for this result. + return inputs.treeEntryLimit == options.maxTreeEntries && + await inputs.observedInputsCurrent(); + } + + /// Applies the complete editor snapshot once and selects its already-built + /// document. Ownership, config-driven symbols and cross-module references + /// are prepared by withDocumentSources, rather than a second module load. + Future prepareDocument( + Workspace workspace, + DocumentBuffer buffer, + Map sources, + ) async { + final prepared = await withDocumentSources(workspace, sources); + return selectPreparedWritersideDocument(prepared, buffer) ?? + await reparseDocument(prepared, buffer); + } + + /// Selects a document from a previously prepared display model. This performs + /// no freshness check and cannot authorize publication or a filesystem write. + Workspace? selectPreparedWritersideDocument( + Workspace prepared, + DocumentBuffer buffer, + ) { + final context = resolveWorkspaceDocumentContext(prepared, buffer); + final module = context.writersideModule; + if (context.diskPath == null || + module == null || + !context.isWritersideOwned) { + return null; + } + var project = prepared.writersideProject!; + final owner = project.modulesByOrigin.entries.firstWhere( + (entry) => p.equals(entry.value.rootPath, module.rootPath), + ); + if (!p.equals(project.activeModule?.rootPath ?? '', module.rootPath)) { + project = project.withSelection(moduleId: owner.key); + } + final selected = project.activeModule!; + return prepared.copyWith( + writersideProject: project, + writersideModule: selected, + markdown: selected.topics + .where((topic) => p.equals(topic.filePath, context.diskPath!)) + .firstOrNull + ?.markdown, + ); + } + Future reparseDocument( Workspace workspace, DocumentBuffer buffer, diff --git a/lib/src/writerside/writerside_execution.dart b/lib/src/writerside/writerside_execution.dart new file mode 100644 index 00000000..04dbff7a --- /dev/null +++ b/lib/src/writerside/writerside_execution.dart @@ -0,0 +1,14 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +/// Coarse jobs carry explicit sendable input and a top-level entry point. +/// Platform services and controller graphs never travel with a job. +class WritersideExecution { + const WritersideExecution({this.useWorker = true}); + final bool useWorker; + + Future run(ComputeCallback entry, Q input) => useWorker + ? compute(entry, input, debugLabel: 'Writerside model') + : Future.sync(() => entry(input)); +} diff --git a/lib/src/writerside/writerside_input_snapshot.dart b/lib/src/writerside/writerside_input_snapshot.dart new file mode 100644 index 00000000..3e3813fc --- /dev/null +++ b/lib/src/writerside/writerside_input_snapshot.dart @@ -0,0 +1,389 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:crypto/crypto.dart'; +import 'package:path/path.dart' as p; + +import '../core/anchored_path_guard.dart'; +import '../core/busymark_exception.dart'; +import '../core/busymark_temporary_path.dart'; +import '../core/input_observer.dart'; +import 'writerside_execution.dart'; + +/// Transient provenance, not a parse cache. Digests are recorded at the reads +/// that feed parsers, rather than inferred from a later filesystem snapshot. +class WritersideInputSnapshot { + const WritersideInputSnapshot({ + required this.rootPath, + required this.types, + required this.reads, + required this.directories, + required this.consistent, + this.limitedReads = const {}, + this.failedReads = const {}, + this.discoveryComplete = true, + this.incompleteDirectories = const {}, + this.treeEntryLimit, + }); + + final String rootPath; + final Map types; + final Map + reads; + final Map> directories; + // A size rejection consumed a stat length or a bounded prefix, rather than + // document bytes. Recheck that rejection without reading an oversized file. + final Map limitedReads; + final Set failedReads; + final bool consistent; + final bool discoveryComplete; + final Map incompleteDirectories; + final int? treeEntryLimit; + + WritersideInputSnapshot withDiscovery(WritersideInputSnapshot next) => + WritersideInputSnapshot( + rootPath: rootPath, + types: {...types, ...next.types}, + reads: {...reads, ...next.reads}, + directories: {...directories, ...next.directories}, + limitedReads: {...limitedReads, ...next.limitedReads}, + failedReads: {...failedReads, ...next.failedReads}, + consistent: consistent && next.consistent, + discoveryComplete: discoveryComplete && next.discoveryComplete, + incompleteDirectories: { + for (final entry in incompleteDirectories.entries) + if (!next.directories.containsKey(entry.key)) + entry.key: entry.value, + ...next.incompleteDirectories, + }, + treeEntryLimit: treeEntryLimit ?? next.treeEntryLimit, + ); + + Future isCurrent() => + const WritersideExecution().run(_matchSnapshot, this); + + /// Verifies consumed readable inputs for presentation only. This cannot + /// establish an exhaustive inventory, authorize removal, acknowledge monitor + /// events, or turn a limited result into a complete model. + Future observedInputsCurrent() => + const WritersideExecution().run(_matchObservedSnapshot, this); + + Future matchesDisk({ + bool requireDiskSources = false, + Set ignoredPaths = const {}, + }) => _matchesInputs( + requireDiskSources: requireDiskSources, + ignoredPaths: ignoredPaths, + ); + + Future matchesObservedInputs() => + _matchesInputs(allowIncompleteDiscovery: true); + + Future _matchesInputs({ + bool requireDiskSources = false, + Set ignoredPaths = const {}, + bool allowIncompleteDiscovery = false, + }) async { + if (!consistent || (!discoveryComplete && !allowIncompleteDiscovery)) { + return false; + } + try { + final anchor = await captureCanonicalDirectoryAnchor(rootPath); + if (!p.equals(anchor.rootPath, rootPath)) return false; + for (final entry in types.entries) { + if (ignoredPaths.contains(entry.key)) continue; + final resolution = await resolveAnchoredPath( + anchor, + entry.key, + allowRoot: true, + allowMissingAncestors: true, + ); + if (resolution.type != entry.value) return false; + } + for (final entry in reads.entries) { + if (ignoredPaths.contains(entry.key)) continue; + if (entry.value.override && !requireDiskSources) continue; + final resolution = await resolveAnchoredPath( + anchor, + entry.key, + allowRoot: false, + ); + if (resolution.type != FileSystemEntityType.file) return false; + final bytes = await File(resolution.path) + .openRead(0, entry.value.bytes + 1) + .fold>([], (all, chunk) => all..addAll(chunk)); + if (bytes.length != entry.value.bytes || + sha256.convert(bytes).toString() != entry.value.hash) { + return false; + } + // A symlink replacement during the read must not validate provenance. + await resolveAnchoredPath(anchor, entry.key, allowRoot: false); + } + for (final entry in limitedReads.entries) { + if (ignoredPaths.contains(entry.key)) continue; + final resolution = await resolveAnchoredPath( + anchor, + entry.key, + allowRoot: false, + ); + if (resolution.type != FileSystemEntityType.file) return false; + final size = await File(resolution.path).length(); + if (entry.value.atLeast + ? size < entry.value.bytes + : size != entry.value.bytes) { + return false; + } + await resolveAnchoredPath(anchor, entry.key, allowRoot: false); + } + for (final path in failedReads) { + if (ignoredPaths.contains(path)) continue; + final resolution = await resolveAnchoredPath( + anchor, + path, + allowRoot: false, + ); + if (resolution.type != FileSystemEntityType.file) return false; + try { + // A successful bounded probe means the earlier failure is no longer + // authoritative. Never keep reusing a missing/unreadable-source model + // after access recovers, nor read a formerly unavailable large file. + await File(resolution.path).openRead(0, 1).drain(); + return false; + } on FileSystemException { + await resolveAnchoredPath(anchor, path, allowRoot: false); + } + } + for (final entry in directories.entries) { + final resolution = await resolveAnchoredPath( + anchor, + entry.key, + allowRoot: true, + ); + if (resolution.type != FileSystemEntityType.directory) return false; + final partial = incompleteDirectories[entry.key]; + if (partial?.failed == true) { + // A failed listing is authoritative only while the bounded attempt + // still fails. Successful access must trigger rediscovery. + try { + await Directory( + resolution.path, + ).list(followLinks: false).take(partial!.count + 1).toList(); + return false; + } on FileSystemException { + continue; + } + } + final entries = await Directory(resolution.path) + .list(followLinks: false) + .take(partial?.count ?? entry.value.length + 128) + .toList(); + if (entries.length >= entry.value.length + 128) return false; + final ignoredNames = { + for (final path in ignoredPaths) + if (p.equals(p.dirname(path), entry.key)) p.basename(path), + }; + final current = inputDirectoryEntries(entries) + .where( + (e) => !ignoredNames.contains(e.substring(0, e.lastIndexOf(':'))), + ) + .toList(); + final expected = entry.value + .where( + (e) => !ignoredNames.contains(e.substring(0, e.lastIndexOf(':'))), + ) + .toList(); + if (current.length != expected.length || + !List.generate( + current.length, + (i) => current[i] == expected[i], + ).every((same) => same)) { + return false; + } + } + return true; + } on Object { + return false; + } + } + + /// Exact content incorporated for a real file; overrides cannot acknowledge + /// a disk notification. Buffer snapshot handling is checked separately. + String? diskHash(String path) { + final input = reads[path]; + return input == null || input.override ? null : input.hash; + } +} + +List inputDirectoryEntries(Iterable entries) => [ + for (final entry in entries) + if (!isBusyMarkTopicStagingPath(entry.path) && + !{'.git', '.hg', '.svn'}.contains(p.basename(entry.path))) + '${p.basename(entry.path)}:${entry is Directory + ? 'directory' + : entry is Link + ? 'link' + : 'file'}', +]..sort(); + +class WritersideInputRecorder extends InputObserver { + WritersideInputRecorder(this.rootPath, {this.treeEntryLimit}); + WritersideInputRecorder.fromSnapshot(WritersideInputSnapshot snapshot) + : rootPath = snapshot.rootPath, + _consistent = snapshot.consistent, + _discoveryComplete = snapshot.discoveryComplete, + treeEntryLimit = snapshot.treeEntryLimit { + _incompleteDirectories.addAll(snapshot.incompleteDirectories); + _types.addAll(snapshot.types); + _reads.addAll(snapshot.reads); + _directories.addAll(snapshot.directories); + _limitedReads.addAll(snapshot.limitedReads); + _failedReads.addAll(snapshot.failedReads); + } + + String rootPath; + final int? treeEntryLimit; + bool _discoveryComplete = true; + final _incompleteDirectories = {}; + final _types = {}; + final _reads = + {}; + final _directories = >{}; + final _limitedReads = {}; + final _failedReads = {}; + bool _consistent = true; + + @override + void path(String path, FileSystemEntityType type) { + if (_types.containsKey(path) && _types[path] != type) _consistent = false; + _types[path] = type; + } + + @override + void read(String path, List bytes, {bool override = false}) { + String? textHash; + try { + textHash = sha256.convert(utf8.encode(utf8.decode(bytes))).toString(); + } on FormatException { + textHash = null; + } + final value = ( + hash: sha256.convert(bytes).toString(), + bytes: bytes.length, + override: override, + textHash: textHash, + ); + if (_reads.containsKey(path) && _reads[path] != value) _consistent = false; + _reads[path] = value; + } + + void source(String path, String source, {bool override = false}) => + read(path, utf8.encode(source), override: override); + + @override + void limitedRead(String path, int bytes, {bool atLeast = false}) { + final value = (bytes: bytes, atLeast: atLeast); + if (_limitedReads.containsKey(path) && _limitedReads[path] != value) { + _consistent = false; + } + _limitedReads[path] = value; + } + + @override + void readFailed(String path) => _failedReads.add(path); + + @override + void directory( + String path, + Iterable entries, { + bool complete = true, + bool failed = false, + }) { + final listing = entries.toList(); + final value = inputDirectoryEntries(listing); + final before = _directories[path]; + final previous = _incompleteDirectories[path]; + if (!complete || failed) _discoveryComplete = false; + if (before != null) { + final retainsBefore = before.every(value.toSet().contains); + final retainedByBefore = value.every(before.toSet().contains); + if (previous?.failed == true) { + // Recovery needs a complete successful listing that accounts for the + // actual prefix consumed before the failure. An empty/limited retry + // cannot establish that the earlier failed input was unchanged. + if (!failed && (!complete || before.isEmpty || !retainsBefore)) { + _consistent = false; + return; + } + } else if (failed) { + // Losing access after successfully consuming the inventory supersedes + // that input state, even if the failing stream emitted the same names. + _consistent = false; + } + if (previous == null && !failed) { + if (complete + ? !retainsBefore || !retainedByBefore + : !retainedByBefore) { + _consistent = false; + return; + } + // A bounded retry cannot weaken an already complete inventory proof. + if (!complete) return; + } else if (!failed && complete) { + if (!retainsBefore) { + _consistent = false; + return; + } + } else { + // Independent scan budgets can expose nested subsets of one listing. + // Incomparable observations prove neither a stable inventory nor safe + // supersession. Retain the strongest compatible observed portion. + if (!retainsBefore && !retainedByBefore) { + _consistent = false; + return; + } + if ((!failed || previous?.failed == true) && + retainedByBefore && + (!retainsBefore || listing.length <= previous!.count)) { + return; + } + } + } + _directories[path] = value; + if (complete && !failed) { + _incompleteDirectories.remove(path); + } else { + _incompleteDirectories[path] = (count: listing.length, failed: failed); + } + } + + @override + void incomplete() => _discoveryComplete = false; + + WritersideInputSnapshot get snapshot => WritersideInputSnapshot( + rootPath: rootPath, + types: Map.unmodifiable(_types), + reads: Map.unmodifiable(_reads), + directories: Map.unmodifiable({ + for (final e in _directories.entries) + e.key: List.unmodifiable(e.value), + }), + limitedReads: Map.unmodifiable(_limitedReads), + failedReads: Set.unmodifiable(_failedReads), + consistent: _consistent, + discoveryComplete: _discoveryComplete, + incompleteDirectories: Map.unmodifiable(_incompleteDirectories), + treeEntryLimit: treeEntryLimit, + ); +} + +Future _matchSnapshot(WritersideInputSnapshot snapshot) => + snapshot.matchesDisk(); + +class WritersideInputsChanged extends BusyMarkException { + WritersideInputsChanged(this.rootPath) + : super('writerside.topic-file.tree-changed', args: {'path': rootPath}); + final String rootPath; +} + +Future _matchObservedSnapshot(WritersideInputSnapshot snapshot) => + snapshot.matchesObservedInputs(); diff --git a/lib/src/writerside/writerside_model.dart b/lib/src/writerside/writerside_model.dart index d3576b19..081600bd 100644 --- a/lib/src/writerside/writerside_model.dart +++ b/lib/src/writerside/writerside_model.dart @@ -1,3 +1,4 @@ +import 'writerside_input_snapshot.dart'; import '../core/diagnostic.dart'; import '../core/source_span.dart'; import '../markdown/markdown_model.dart'; @@ -538,6 +539,7 @@ class WritersideModule { this.buildProfiles, this.instanceGroups, this.sourceOverrides = const {}, + this.inputSnapshot, this.sourceFiles = const {}, this.referenceData = const WritersideReferenceData(), this.semanticDiagnostics = const [], @@ -579,6 +581,7 @@ class WritersideModule { /// Unsaved project sources that must win over their on-disk counterparts /// when the module is recomputed after another project-file edit. final Map sourceOverrides; + final WritersideInputSnapshot? inputSnapshot; final Map sourceFiles; final WritersideReferenceData referenceData; @@ -587,6 +590,7 @@ class WritersideModule { List? diagnostics, List? semanticDiagnostics, Map? sourceOverrides, + WritersideInputSnapshot? inputSnapshot, Map? sourceFiles, WritersideReferenceData? referenceData, }) { @@ -606,6 +610,7 @@ class WritersideModule { buildProfiles: buildProfiles, instanceGroups: instanceGroups, sourceOverrides: sourceOverrides ?? this.sourceOverrides, + inputSnapshot: inputSnapshot ?? this.inputSnapshot, sourceFiles: sourceFiles ?? this.sourceFiles, referenceData: referenceData ?? this.referenceData, ); diff --git a/lib/src/writerside/writerside_module_service.dart b/lib/src/writerside/writerside_module_service.dart index 137fef87..c463df89 100644 --- a/lib/src/writerside/writerside_module_service.dart +++ b/lib/src/writerside/writerside_module_service.dart @@ -5,6 +5,11 @@ import 'dart:typed_data'; import 'package:path/path.dart' as p; import '../core/anchored_path_guard.dart'; +import '../core/input_observer.dart'; +import 'writerside_execution.dart'; +import 'writerside_input_snapshot.dart'; +import 'writerside_document.dart'; +import '../markdown/busymark_document.dart'; import '../core/diagnostic.dart'; import '../core/local_image_resolver.dart'; import '../core/path_utils.dart'; @@ -30,6 +35,7 @@ class WritersideModuleService { this.categoriesParser = const WritersideCategoriesParser(), this.scanOptions = const WorkspaceScanOptions(), this.topicDirectoryLister, + this.execution = const WritersideExecution(), }); final WritersideConfigParser configParser; @@ -42,11 +48,87 @@ class WritersideModuleService { final WritersideCategoriesParser categoriesParser; final WorkspaceScanOptions scanOptions; final WorkspaceDirectoryLister? topicDirectoryLister; + final WritersideExecution execution; + + // Injected parsers/listers stay on their supplied test execution path; the + // normal application always dispatches explicit inputs to the worker. + bool get _hasInjectedStages => + topicDirectoryLister != null || + !identical(configParser, const WritersideConfigParser()) || + !identical(buildProfilesParser, const WritersideBuildProfilesParser()) || + !identical( + instanceGroupsParser, + const WritersideInstanceGroupsParser(), + ) || + !identical(treeParser, const WritersideTreeParser()) || + !identical(treeResolver, const WritersideTreeResolver()) || + !identical(topicParser, const WritersideTopicParser()) || + !identical(variablesParser, const WritersideVariablesParser()) || + !identical(categoriesParser, const WritersideCategoriesParser()); Future load( String rootPath, { WorkspaceScanOptions? options, Map sourceOverrides = const {}, + }) async { + final input = _ModuleLoadInput( + normalizePath(rootPath), + options ?? scanOptions, + Map.unmodifiable(sourceOverrides), + ); + if (_hasInjectedStages || !execution.useWorker) { + return _loadObserved(input, includePlatformSources: true); + } + final module = await execution.run(_loadModuleOnWorker, input); + if (!_needsPlatformSources(module)) return module; + // MethodChannel/OpenAPI host operations are never called from the worker. + // Reuse the exact textual sources consumed there, then resolve the enriched + // semantic graph in a second coarse pure job. + final recorder = WritersideInputRecorder.fromSnapshot( + module.inputSnapshot!, + ); + final sources = await recorder.observe( + () => const WritersideSourceLoader().loadModule(module), + ); + return execution.run( + _finishModuleOnWorker, + module.copyWith(sourceFiles: sources, inputSnapshot: recorder.snapshot), + ); + } + + Future _loadObserved( + _ModuleLoadInput input, { + required bool includePlatformSources, + }) async { + final recorder = WritersideInputRecorder( + input.rootPath, + treeEntryLimit: input.options.maxTreeEntries, + ); + final loaded = await recorder.observe( + () => _loadHere( + input.rootPath, + options: input.options, + sourceOverrides: input.sourceOverrides, + includePlatformSources: includePlatformSources, + ), + ); + recorder.rootPath = loaded.rootPath; + final snapshot = recorder.snapshot; + if (loaded.topicDiscoveryComplete && + loaded.unparsedTopicReferences.isEmpty && + loaded.variablesAvailable && + loaded.topics.every((t) => t.document.isWellFormed) && + (!snapshot.consistent || !await snapshot.matchesDisk())) { + throw WritersideInputsChanged(loaded.rootPath); + } + return loaded.copyWith(inputSnapshot: snapshot); + } + + Future _loadHere( + String rootPath, { + WorkspaceScanOptions? options, + Map sourceOverrides = const {}, + bool includePlatformSources = true, }) async { final effectiveScanOptions = options ?? scanOptions; final normalizedOverrides = Map.unmodifiable({ @@ -515,9 +597,13 @@ class WritersideModuleService { ); final loaded = module.copyWith( diagnostics: sortDiagnostics(diagnostics), - sourceFiles: await const WritersideSourceLoader().loadModule(module), + sourceFiles: await const WritersideSourceLoader().loadModule( + module, + parseApis: includePlatformSources, + ), referenceData: await WritersideReferenceData.load(module), ); + if (!includePlatformSources && _needsPlatformSources(loaded)) return loaded; return loaded.copyWith( diagnostics: sortDiagnostics([ ...loaded.structuralDiagnostics, @@ -533,6 +619,7 @@ class WritersideModuleService { configPath, followLinks: false, ); + InputObserver.current?.path(configPath, configType); if (configType == FileSystemEntityType.file || configType == FileSystemEntityType.link) { return configPath; @@ -603,7 +690,9 @@ class WritersideModuleService { } final override = sourceOverrides[normalizePath(file.path)]; if (override != null) { - if (utf8.encode(override).length > maxBytes) { + final bytes = utf8.encode(override); + InputObserver.current?.read(file.path, bytes, override: true); + if (bytes.length > maxBytes) { diagnostics.add(_fileTooLargeDiagnostic(file.path)); return null; } @@ -615,11 +704,21 @@ class WritersideModuleService { bytes.add(chunk); } if (bytes.length > maxBytes) { + InputObserver.current?.limitedRead( + file.path, + bytes.length, + atLeast: true, + ); diagnostics.add(_fileTooLargeDiagnostic(file.path)); return null; } - return utf8.decode(bytes.takeBytes()); + final consumed = bytes.takeBytes(); + InputObserver.current?.read(file.path, consumed); + return utf8.decode(consumed); } on Object catch (error) { + if (error is FileSystemException) { + InputObserver.current?.readFailed(file.path); + } diagnostics.add( Diagnostic( code: readFailureCode, @@ -1280,3 +1379,43 @@ class _UnparsedTopicIndex { return _baseNames.contains(p.basename(normalized)); } } + +class _ModuleLoadInput { + const _ModuleLoadInput(this.rootPath, this.options, this.sourceOverrides); + final String rootPath; + final WorkspaceScanOptions options; + final Map sourceOverrides; +} + +Future _loadModuleOnWorker(_ModuleLoadInput input) => + const WritersideModuleService( + execution: WritersideExecution(useWorker: false), + )._loadObserved(input, includePlatformSources: false); + +Future _finishModuleOnWorker(WritersideModule module) async { + final finished = module.copyWith( + diagnostics: sortDiagnostics([ + ...module.structuralDiagnostics, + ...module.referenceData.diagnostics, + ]), + semanticDiagnostics: resolveWritersideModuleDiagnostics(module), + ); + if (!await finished.inputSnapshot!.matchesDisk()) { + throw WritersideInputsChanged(finished.rootPath); + } + return finished; +} + +bool _needsPlatformSources(WritersideModule module) { + bool blockHasApi(BusyBlock block) => + block.attributes.containsKey('openapi-path') || + block.children.any(blockHasApi); + return module.topics.any( + (topic) => topic.document.walk().any( + (node) => + node is WritersideElementNode && + node.attributes.containsKey('openapi-path') || + node is WritersideMarkdownBlockNode && blockHasApi(node.block), + ), + ); +} diff --git a/lib/src/writerside/writerside_project.dart b/lib/src/writerside/writerside_project.dart index cebf24d5..4afc4a3f 100644 --- a/lib/src/writerside/writerside_project.dart +++ b/lib/src/writerside/writerside_project.dart @@ -1,3 +1,5 @@ +import 'writerside_execution.dart'; +import 'writerside_input_snapshot.dart'; import 'dart:io'; import 'package:path/path.dart' as p; @@ -863,6 +865,7 @@ class WritersideProject { required this.diagnostics, this.moduleDiscoveryComplete = true, this.moduleDiscoveryDiagnostics = const [], + this.inputSnapshot, }); final String rootPath; @@ -873,6 +876,33 @@ class WritersideProject { final List diagnostics; final bool moduleDiscoveryComplete; final List moduleDiscoveryDiagnostics; + final WritersideInputSnapshot? inputSnapshot; + + Future inputsMatchDisk({ + bool requireDiskSources = false, + Set ignoredPaths = const {}, + }) => const WritersideExecution().run( + _checkProjectInputs, + _ProjectInputs( + inputSnapshot, + [for (final m in modules) m.inputSnapshot], + requireDiskSources, + ignoredPaths, + ), + ); + + /// Consumed-input checks for presenting a limited, diagnostic model. Full + /// freshness and mutation checks continue to use inputsMatchDisk. + Future observedInputsMatchDisk() => const WritersideExecution().run( + _checkProjectInputs, + _ProjectInputs( + inputSnapshot, + [for (final m in modules) m.inputSnapshot], + false, + const {}, + allowIncompleteDiscovery: true, + ), + ); WritersideModule? get activeModule { for (final module in modules) { @@ -969,6 +999,7 @@ class WritersideProject { diagnostics: diagnostics, moduleDiscoveryComplete: moduleDiscoveryComplete, moduleDiscoveryDiagnostics: moduleDiscoveryDiagnostics, + inputSnapshot: inputSnapshot, ); } @@ -1030,6 +1061,7 @@ class WritersideProject { ]), moduleDiscoveryComplete: moduleDiscoveryComplete, moduleDiscoveryDiagnostics: moduleDiscoveryDiagnostics, + inputSnapshot: inputSnapshot, ); } } @@ -1108,41 +1140,47 @@ class WritersideProjectService { Future load( String projectRoot, { String? preferredModuleRoot, + }) async { + final recorder = WritersideInputRecorder( + normalizePath(projectRoot), + treeEntryLimit: scanOptions.maxTreeEntries, + ); + final project = await recorder.observe( + () => _loadHere(projectRoot, preferredModuleRoot: preferredModuleRoot), + ); + final observed = _projectWithInputs(project, recorder.snapshot); + if (observed.moduleDiscoveryComplete && + observed.modules.every( + (m) => + m.topicDiscoveryComplete && + m.unparsedTopicReferences.isEmpty && + m.variablesAvailable && + m.topics.every((t) => t.document.isWellFormed), + ) && + !await observed.inputsMatchDisk()) { + throw WritersideInputsChanged(project.rootPath); + } + return observed; + } + + Future _loadHere( + String projectRoot, { + String? preferredModuleRoot, }) async { final discovery = await discoverModuleRoots(projectRoot); var modules = []; for (final root in discovery.roots) { modules.add(await moduleService.load(root, options: scanOptions)); } - modules = _resolveProjectModules(modules); - final index = WritersideProjectIndex.build( - modules, - fileSymbols: await _discoverFileSymbols(modules), - ); - WritersideModule? active; - if (preferredModuleRoot != null) { - active = modules - .where((module) => p.equals(module.rootPath, preferredModuleRoot)) - .firstOrNull; - } - active ??= modules.firstOrNull; - final instance = active?.instances - .where((candidate) => !candidate.isLibrary) - .firstOrNull; - return WritersideProject( - rootPath: normalizePath(projectRoot), - modules: List.unmodifiable(modules), - activeModuleId: active == null ? null : _moduleId(active), - activeInstanceId: instance?.id, - index: index, - diagnostics: sortDiagnostics([ - for (final module in modules) ...module.diagnostics, - ...index.diagnostics, - ...writersideWebFileNameDiagnostics(modules), - ...discovery.diagnostics, - ]), - moduleDiscoveryComplete: discovery.complete, - moduleDiscoveryDiagnostics: discovery.diagnostics, + return moduleService.execution.run( + _buildProjectOnWorker, + _ProjectBuildInput( + normalizePath(projectRoot), + modules, + await _discoverFileSymbols(modules), + discovery, + preferredModuleRoot, + ), ); } @@ -1154,14 +1192,21 @@ class WritersideProjectService { WritersideModule module, { bool rediscoverFileSymbols = false, }) async { - final replaced = project.withModule(module); + final replaced = await moduleService.execution.run(_replaceModuleOnWorker, ( + project: project, + module: module, + )); if (!rediscoverFileSymbols || identical(replaced, project)) { return replaced; } - final index = WritersideProjectIndex.build( - replaced.modules, - fileSymbols: await _discoverFileSymbols(replaced.modules), + final recorder = WritersideInputRecorder(project.rootPath); + final symbols = await recorder.observe( + () => _discoverFileSymbols(replaced.modules), ); + final index = await moduleService.execution.run(_indexProjectOnWorker, ( + modules: replaced.modules, + symbols: symbols, + )); return WritersideProject( rootPath: replaced.rootPath, modules: replaced.modules, @@ -1176,6 +1221,7 @@ class WritersideProjectService { ]), moduleDiscoveryComplete: replaced.moduleDiscoveryComplete, moduleDiscoveryDiagnostics: replaced.moduleDiscoveryDiagnostics, + inputSnapshot: replaced.inputSnapshot?.withDiscovery(recorder.snapshot), ); } @@ -1540,3 +1586,105 @@ List _resolveProjectModules(List modules) { ), ]; } + +class _ProjectBuildInput { + const _ProjectBuildInput( + this.rootPath, + this.modules, + this.fileSymbols, + this.discovery, + this.preferredModuleRoot, + ); + final String rootPath; + final List modules; + final List fileSymbols; + final WritersideModuleDiscoveryResult discovery; + final String? preferredModuleRoot; +} + +WritersideProject _buildProjectOnWorker(_ProjectBuildInput input) { + final modules = _resolveProjectModules(input.modules); + final index = WritersideProjectIndex.build( + modules, + fileSymbols: input.fileSymbols, + ); + WritersideModule? active; + if (input.preferredModuleRoot != null) { + active = modules + .where( + (module) => p.equals(module.rootPath, input.preferredModuleRoot!), + ) + .firstOrNull; + } + active ??= modules.firstOrNull; + final instance = active?.instances + .where((candidate) => !candidate.isLibrary) + .firstOrNull; + return WritersideProject( + rootPath: input.rootPath, + modules: List.unmodifiable(modules), + activeModuleId: active == null ? null : _moduleId(active), + activeInstanceId: instance?.id, + index: index, + diagnostics: sortDiagnostics([ + for (final module in modules) ...module.diagnostics, + ...index.diagnostics, + ...writersideWebFileNameDiagnostics(modules), + ...input.discovery.diagnostics, + ]), + moduleDiscoveryComplete: input.discovery.complete, + moduleDiscoveryDiagnostics: input.discovery.diagnostics, + ); +} + +WritersideProject _replaceModuleOnWorker( + ({WritersideProject project, WritersideModule module}) input, +) => input.project.withModule(input.module); + +WritersideProjectIndex _indexProjectOnWorker( + ({List modules, List symbols}) input, +) => WritersideProjectIndex.build(input.modules, fileSymbols: input.symbols); + +WritersideProject _projectWithInputs( + WritersideProject project, + WritersideInputSnapshot snapshot, +) => WritersideProject( + rootPath: project.rootPath, + modules: project.modules, + activeModuleId: project.activeModuleId, + activeInstanceId: project.activeInstanceId, + index: project.index, + diagnostics: project.diagnostics, + moduleDiscoveryComplete: project.moduleDiscoveryComplete, + moduleDiscoveryDiagnostics: project.moduleDiscoveryDiagnostics, + inputSnapshot: snapshot, +); + +class _ProjectInputs { + const _ProjectInputs( + this.project, + this.modules, + this.requireDiskSources, + this.ignoredPaths, { + this.allowIncompleteDiscovery = false, + }); + final bool allowIncompleteDiscovery; + final WritersideInputSnapshot? project; + final List modules; + final bool requireDiskSources; + final Set ignoredPaths; +} + +Future _checkProjectInputs(_ProjectInputs input) async { + for (final snapshot in [input.project, ...input.modules]) { + if (snapshot == null) return false; + final matches = input.allowIncompleteDiscovery + ? await snapshot.matchesObservedInputs() + : await snapshot.matchesDisk( + requireDiskSources: input.requireDiskSources, + ignoredPaths: input.ignoredPaths, + ); + if (!matches) return false; + } + return true; +} diff --git a/lib/src/writerside/writerside_source_loader.dart b/lib/src/writerside/writerside_source_loader.dart index 4ed548ba..07360964 100644 --- a/lib/src/writerside/writerside_source_loader.dart +++ b/lib/src/writerside/writerside_source_loader.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'package:path/path.dart' as p; import '../core/anchored_path_guard.dart'; +import '../core/input_observer.dart'; import '../markdown/busymark_document.dart'; import '../visualization/visualization_models.dart'; import '../visualization/visualization_renderer.dart'; @@ -57,6 +58,7 @@ class WritersideSourceLoader { for (final directory in directories) p.normalize(p.join(workspaceRoot, directory, value)), }) { + String? readPath; try { final resolved = await resolveAnchoredPath( anchor, @@ -68,17 +70,26 @@ class WritersideSourceLoader { continue; } if (!readText) return WritersideSourceFile(path: resolved.path); + readPath = resolved.path; if (override != null) { + InputObserver.current?.read( + resolved.path, + utf8.encode(override), + override: true, + ); if (utf8.encode(override).length > maximumBytes) { return const WritersideSourceFile(failure: 'too-large'); } return WritersideSourceFile(path: resolved.path, text: override); } final file = File(resolved.path); - if (await file.length() > maximumBytes) { + final size = await file.length(); + if (size > maximumBytes) { + InputObserver.current?.limitedRead(resolved.path, size); return const WritersideSourceFile(failure: 'too-large'); } final bytes = await file.readAsBytes(); + InputObserver.current?.read(resolved.path, bytes); if (bytes.length > maximumBytes) { return const WritersideSourceFile(failure: 'too-large'); } @@ -92,6 +103,7 @@ class WritersideSourceLoader { } return const WritersideSourceFile(failure: 'outside-workspace'); } on FileSystemException { + if (readPath != null) InputObserver.current?.readFailed(readPath); continue; } on FormatException { return const WritersideSourceFile(failure: 'invalid-utf8'); @@ -106,9 +118,10 @@ class WritersideSourceLoader { } Future> loadModule( - WritersideModule module, - ) async { - final result = {}; + WritersideModule module, { + bool parseApis = true, + }) async { + final result = {...module.sourceFiles}; Future add(String path, Map attributes) async { final reference = attributes['openapi-path'] ?? attributes['src']; if (reference == null || reference.isEmpty) return; @@ -129,7 +142,9 @@ class WritersideSourceLoader { overrides: module.sourceOverrides, ); final loaded = result[identity]!; - if (attributes.containsKey('openapi-path') && loaded.text != null) { + if (parseApis && + attributes.containsKey('openapi-path') && + loaded.text != null) { try { final token = VisualizationCancellationToken(); final request = diff --git a/lib/src/writerside/writerside_topic_removal_service.dart b/lib/src/writerside/writerside_topic_removal_service.dart index 688c883f..85ce73a8 100644 --- a/lib/src/writerside/writerside_topic_removal_service.dart +++ b/lib/src/writerside/writerside_topic_removal_service.dart @@ -476,6 +476,7 @@ class WritersideTopicRemovalService { Future apply( WritersideTopicRemovalRequest request, { + WritersideProject? project, void Function(Iterable)? validateBeforeCommit, }) async { final analysis = request.analysis; @@ -485,7 +486,11 @@ class WritersideTopicRemovalService { for (final usage in analysis.usages) usage.filePath, }; validateBeforeCommit?.call(analyzedPaths); - final snapshot = await _snapshot(analysis.projectRoot, analysis.topicPath); + final snapshot = await _snapshot( + analysis.projectRoot, + analysis.topicPath, + loadedProject: project, + ); if (!_sameStringList(snapshot.moduleRoots, analysis.projectModuleRoots) || snapshot.fingerprint != analysis.fingerprint) { throw BusyMarkException( @@ -806,6 +811,7 @@ class WritersideTopicRemovalService { edit.updated, expected: edit.original, validateBeforeCommit: validate, + verifyBeforeCommit: () => _ensureExpectedState(snapshot, applied), ); applied.add(edit); } @@ -817,6 +823,12 @@ class WritersideTopicRemovalService { snapshot.topic.filePath, expected: snapshot.sources[snapshot.topic.filePath]!, validateBeforeCommit: validate, + verifyBeforeCommit: () => _ensureExpectedState(snapshot, applied), + verifyBeforeDelete: () => _ensureExpectedState( + snapshot, + applied, + ignoredPaths: {snapshot.topic.filePath}, + ), ); } } on Object catch (error, stackTrace) { @@ -907,6 +919,13 @@ class WritersideTopicRemovalService { ); } + if (!await project.inputsMatchDisk(requireDiskSources: true)) { + throw BusyMarkException( + 'writerside.topic-file.tree-changed', + args: {'path': project.rootPath}, + ); + } + final sources = {}; final redirectRules = >>{}; for (final candidate in project.modules) { @@ -1047,6 +1066,27 @@ class WritersideTopicRemovalService { } } } + // The reread fingerprint must describe the inputs consumed by the parsed + // model, not just a second self-consistent but different filesystem state. + for (final candidate in project.modules) { + for (final input in candidate.inputSnapshot!.reads.entries) { + final source = sources[input.key]; + if (source != null && + crypto.sha256.convert(utf8.encode(source)).toString() != + input.value.textHash) { + throw BusyMarkException( + 'writerside.topic-file.tree-changed', + args: {'path': input.key}, + ); + } + } + } + if (!await project.inputsMatchDisk(requireDiskSources: true)) { + throw BusyMarkException( + 'writerside.topic-file.tree-changed', + args: {'path': project.rootPath}, + ); + } return _RemovalSnapshot( anchor: anchor, project: project, @@ -2293,10 +2333,21 @@ class WritersideTopicRemovalService { Future _ensureExpectedState( _RemovalSnapshot snapshot, - List<_SourceEdit> edits, - ) async { + List<_SourceEdit> edits, { + Set ignoredPaths = const {}, + }) async { final edited = {for (final edit in edits) edit.path: edit.updated}; + if (!await snapshot.project.inputsMatchDisk( + requireDiskSources: true, + ignoredPaths: {...edited.keys, ...ignoredPaths}, + )) { + throw BusyMarkException( + 'writerside.topic-file.tree-changed', + args: {'path': snapshot.project.rootPath}, + ); + } for (final entry in snapshot.sources.entries) { + if (ignoredPaths.contains(entry.key)) continue; final expected = edited[entry.key] ?? entry.value; final resolution = await resolveAnchoredPath( snapshot.anchor, @@ -2319,6 +2370,7 @@ class WritersideTopicRemovalService { String source, { required String expected, void Function()? validateBeforeCommit, + Future Function()? verifyBeforeCommit, }) async { final resolution = await resolveAnchoredPath( anchor, @@ -2373,6 +2425,10 @@ class WritersideTopicRemovalService { ); } validateBeforeCommit?.call(); + await verifyBeforeCommit?.call(); + // Disk verification awaits; editor/workspace authorization must be live + // at the mutation boundary as well as at the inexpensive early check. + validateBeforeCommit?.call(); final atomicApi = LinuxAtomicFileApi.instance; if (atomicApi.isAvailable) { final exchangeError = atomicApi.exchange(temporary.path, checked.path); @@ -2437,6 +2493,8 @@ class WritersideTopicRemovalService { String path, { required String expected, void Function()? validateBeforeCommit, + Future Function()? verifyBeforeCommit, + Future Function()? verifyBeforeDelete, }) async { final resolution = await resolveAnchoredPath( anchor, @@ -2464,6 +2522,8 @@ class WritersideTopicRemovalService { continue; } try { + validateBeforeCommit?.call(); + await verifyBeforeCommit?.call(); validateBeforeCommit?.call(); quarantined = await File(resolution.path).rename(candidate.path); break; @@ -2495,6 +2555,8 @@ class WritersideTopicRemovalService { ); } validateBeforeCommit?.call(); + await verifyBeforeDelete?.call(); + validateBeforeCommit?.call(); await quarantined.delete(); quarantined = null; } on Object catch (error, stackTrace) { diff --git a/test/src/app_smoke_test.dart b/test/src/app_smoke_test.dart index 4a6b949f..120e82f4 100644 --- a/test/src/app_smoke_test.dart +++ b/test/src/app_smoke_test.dart @@ -3257,10 +3257,12 @@ void main() { await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); await tester.pump(); await tester.pump(const Duration(seconds: 1)); - await tester.tap( - find.descendant( - of: find.byType(BusyMarkDialogShell), - matching: find.text(l10n.createMarkdownFile), + await tester.runAsync( + () => tester.tap( + find.descendant( + of: find.byType(BusyMarkDialogShell), + matching: find.text(l10n.createMarkdownFile), + ), ), ); await tester.pump(); @@ -3277,7 +3279,9 @@ Paragraph with *emphasis*. code ``` '''; - controller.updateActiveText(source); + await tester.runAsync(() async { + controller.updateActiveText(source); + }); await tester.runAsync(() => controller.validateActive()); for (var index = 0; index < 30; index += 1) { await tester.pump(const Duration(milliseconds: 100)); @@ -3364,7 +3368,9 @@ code ); await tester.runAsync(controller.createMarkdownFile); - controller.updateActiveText(source); + await tester.runAsync(() async { + controller.updateActiveText(source); + }); await tester.runAsync(() => controller.validateActive()); await tester.pump(const Duration(milliseconds: 500)); final secondState = container.read(workspaceControllerProvider); diff --git a/test/src/workspace_controller_test.dart b/test/src/workspace_controller_test.dart index 4a45f370..3d0a6cf6 100644 --- a/test/src/workspace_controller_test.dart +++ b/test/src/workspace_controller_test.dart @@ -1,6 +1,9 @@ import 'dart:async'; import 'dart:io'; +import 'package:busymark/src/core/path_utils.dart'; +import 'package:busymark/src/writerside/writerside_project.dart'; + import 'package:busymark/src/markdown/markdown_parser.dart'; import 'package:busymark/src/markdown/markdown_model.dart'; @@ -24,6 +27,7 @@ import 'package:busymark/src/workspace/workspace_service.dart'; import 'package:busymark/src/writerside/writerside_project_creator.dart'; import 'package:busymark/src/writerside/writerside_instance_service.dart'; import 'package:busymark/src/writerside/writerside_model.dart'; +import '../support/writerside_responsiveness.dart' as responsiveness; import 'package:busymark/src/writerside/writerside_topic_creator.dart'; import 'package:busymark/src/writerside/writerside_topic_file_editor.dart'; import 'package:busymark/src/writerside/writerside_topic_removal_service.dart'; @@ -34,6 +38,8 @@ import 'package:path/path.dart' as p; import 'package:xml/xml.dart'; void main() { + _registerResponsivenessTests(); + _registerCorrectionBoundaryTests(); test( 'Welcome creation cannot overwrite an edit made during history flush', () async { @@ -4642,6 +4648,7 @@ class _ControlledFileMonitor extends WorkspaceFileMonitor { final _events = StreamController.broadcast(); @override Stream get events => _events.stream; + void emitEvent(WorkspaceFileMonitorEvent event) => _events.add(event); void emit(String path) => _events.add( WorkspaceFileMonitorEvent( kind: WorkspaceFileEventKind.workspaceChanged, @@ -4663,7 +4670,7 @@ class _TocCreationMonitorService extends WorkspaceService { _TocCreationMonitorService(this.monitor); final _ControlledFileMonitor monitor; @override - Future createWritersideTopic( + Future createWritersideTopic( Workspace workspace, WritersideTopicCreateRequest request, { String? instanceTreePath, @@ -4752,3 +4759,1695 @@ class _BlockingRefreshWorkspaceService extends WorkspaceService { return reparsed; } } + +Future _waitForPublishedWorkspace( + ProviderContainer container, + bool Function(WorkspaceState) predicate, +) async { + final completed = Completer(); + final subscription = container.listen( + workspaceControllerProvider, + (previous, next) { + if (predicate(next) && !completed.isCompleted) { + completed.complete(); + } + }, + fireImmediately: true, + ); + try { + await completed.future; + } finally { + subscription.close(); + } +} + +void _registerResponsivenessTests() { + test( + 'Writerside tab preparation loads once and current activation loads zero', + () async { + final root = await responsiveness.syntheticProject(); + final loader = responsiveness.CountingModule(); + final harness = await _createControllerHarness( + service: WorkspaceService(writersideService: loader), + fileMonitor: _ControlledFileMonitor(), + ); + await harness.settingsController.setAutoSave(false); + await harness.settingsController.setValidateOnEdit(false); + final controller = harness.controller._notifier; + await controller.openPath(root.path); + loader.loads = 0; + expect( + await controller.openActiveFile(p.join(root.path, 'topics/other.md')), + isTrue, + ); + expect(loader.loads, 1); + final other = controller.state.activeBuffer!; + loader.loads = 0; + expect( + await controller.openActiveFile(p.join(root.path, 'topics/home.md')), + isTrue, + ); + expect(loader.loads, 0); + expect(controller.state.workspace!.markdown!.title, 'Home'); + expect(controller.state.preview, isNotNull); + await controller.closeDocumentBuffer(other.id); + expect( + controller.state.workspace!.sourceOverrides, + isNot(contains(other.filePath)), + ); + }, + ); + test( + 'configuration preview does not reparse in a derived loop and closing all buffers relinquishes overlays', + () async { + final root = await responsiveness.syntheticProject(); + final loader = responsiveness.CountingModule(); + final harness = await _createControllerHarness( + service: WorkspaceService(writersideService: loader), + fileMonitor: _ControlledFileMonitor(), + ); + await harness.settingsController.setAutoSave(false); + await harness.settingsController.setValidateOnEdit(false); + final controller = harness.controller._notifier; + await controller.openPath(root.path); + final homeId = controller.state.activeBuffer!.id; + await controller.openActiveFile(p.join(root.path, 'writerside.cfg')); + final configId = controller.state.activeBuffer!.id; + expect(controller.state.preview, isNotNull); + await controller.closeDocumentBuffer(homeId); + await controller.closeDocumentBuffer(configId); + expect(controller.state.documentBuffers, isEmpty); + expect(controller.state.workspace!.sourceOverrides, isEmpty); + expect( + controller.state.workspace!.writersideModule!.sourceOverrides, + isEmpty, + ); + }, + ); + test( + 'closing all Writerside tabs completes override relinquishment', + () async { + final root = await responsiveness.syntheticProject(); + final loader = responsiveness.CountingModule(); + final harness = await _createControllerHarness( + service: WorkspaceService(writersideService: loader), + fileMonitor: _ControlledFileMonitor(), + ); + final controller = harness.controller._notifier; + await controller.openPath(root.path); + await controller.openActiveFile(p.join(root.path, 'topics/other.md')); + loader.loads = 0; + expect(await controller.closeAllOpenFileTabs(), isTrue); + expect(controller.state.documentBuffers, isEmpty); + expect(controller.state.workspace!.sourceOverrides, isEmpty); + expect( + controller.state.workspace!.writersideModule!.sourceOverrides, + isEmpty, + ); + expect(loader.loads, 1); + }, + ); + test( + 'closed-tab reconciliation rejects obsolete disk models and keeps tabs closed', + () async { + final root = await responsiveness.syntheticProject(); + final loader = responsiveness.CountingModule(); + final service = _GatedClosedSourcesService(loader); + final harness = await _createControllerHarness( + service: service, + fileMonitor: _ControlledFileMonitor(), + ); + final controller = harness.controller._notifier; + await controller.openPath(root.path); + await controller.openActiveFile(p.join(root.path, 'topics/other.md')); + final originalModel = controller.state.workspace!.writersideModule; + service.pause(); + final close = controller.closeAllOpenFileTabs(); + await service.started.future; + await File( + p.join(root.path, 'topics/other.md'), + ).writeAsString('# Latest dependency\n'); + service.release(); + expect(await close, isTrue); + // A stale closed-buffer completion must not replace the live model. + expect(controller.state.workspace!.writersideModule, same(originalModel)); + expect( + controller.state.workspace!.writersideModule! + .topicByReference('other.md')! + .title, + 'Other', + ); + await controller.refreshWorkspaceFromDiskPreservingOpenTabs(); + expect(controller.state.documentBuffers, isEmpty); + expect(controller.state.activeBuffer, isNull); + expect(controller.state.workspace!.activeFilePath, isNull); + expect( + controller.state.workspace!.writersideModule! + .topicByReference('other.md')! + .title, + 'Latest dependency', + ); + service.pause(); + final refresh = controller.refreshWorkspaceFromDiskPreservingOpenTabs(); + await service.started.future; + await File( + p.join(root.path, 'topics/other.md'), + ).writeAsString('# Current dependency\n'); + service.release(); + expect(await refresh, isTrue); + expect(controller.state.documentBuffers, isEmpty); + expect(controller.state.workspace!.activeFilePath, isNull); + expect( + controller.state.workspace!.writersideModule! + .topicByReference('other.md')! + .title, + 'Current dependency', + ); + expect(controller.state.workspace!.sourceOverrides, isEmpty); + }, + ); + for (final change in [ + 'active', + 'inactive', + 'closure', + 'workspace', + 'external', + 'disposal', + ]) { + test( + 'Writerside preparation rejects obsolete full input state: $change', + () async { + final root = await responsiveness.syntheticProject(); + final monitor = _ControlledFileMonitor(); + final service = _GatedPreparationService( + responsiveness.CountingModule(), + ); + final harness = await _createControllerHarness( + service: service, + fileMonitor: monitor, + ); + await harness.settingsController.setAutoSave(false); + await harness.settingsController.setValidateOnEdit(false); + final controller = harness.controller._notifier; + final home = p.join(root.path, 'topics/home.md'); + final other = p.join(root.path, 'topics/other.md'); + await controller.openPath(root.path); + await controller.openActiveFile(other); + final otherId = controller.state.activeBuffer!.id; + await controller.openActiveFile(home); + service.pause(); + final pending = controller.openActiveFile(other); + await service.started.future; + switch (change) { + case 'active': + harness.controller.updateActiveSourceText('# Live active\n'); + case 'inactive': + controller.updateDocumentText(otherId, '# Live inactive\n'); + case 'closure': + await controller.closeDocumentBuffer(otherId); + case 'workspace': + await controller.openPath('test/fixtures/markdown/basic.md'); + case 'external': + await File(home).writeAsString('# External\n'); + monitor.emit(home); + await Future.delayed(const Duration(milliseconds: 300)); + case 'disposal': + harness._container.dispose(); + } + service.release(); + await pending; + if (change == 'disposal') return; + if (change == 'workspace') { + expect(controller.state.workspace!.rootPath, isNot(root.path)); + } else if (change == 'closure') { + expect( + controller.state.documentBuffers.map((b) => b.id), + isNot(contains(otherId)), + ); + } else if (change == 'active' || change == 'inactive') { + final changedPath = change == 'active' ? home : other; + final expected = change == 'active' + ? '# Live active\n' + : '# Live inactive\n'; + expect( + controller.state.documentBuffers + .singleWhere((b) => b.filePath == changedPath) + .text, + expected, + ); + final topic = controller.state.workspace!.writersideModule!.topics + .singleWhere((t) => t.filePath == changedPath); + expect(topic.document.source, expected); + } else { + await Future.delayed(const Duration(milliseconds: 700)); + expect( + controller.state.documentBuffers + .singleWhere((b) => b.filePath == home) + .text, + '# External\n', + ); + } + }, + ); + } + test( + 'rapid Writerside activation keeps derived work bounded and publishes the latest request', + () async { + final root = await responsiveness.syntheticProject(); + final loader = responsiveness.CountingModule(); + final service = _GatedPreparationService(loader); + final harness = await _createControllerHarness( + service: service, + fileMonitor: _ControlledFileMonitor(), + ); + await harness.settingsController.setAutoSave(false); + await harness.settingsController.setValidateOnEdit(false); + final controller = harness.controller._notifier; + final home = p.join(root.path, 'topics/home.md'); + final other = p.join(root.path, 'topics/other.md'); + await controller.openPath(root.path); + await controller.openActiveFile(other); + await controller.openActiveFile(home); + service.maxRunning = 0; + loader.loads = 0; + service.pause(); + final first = controller.openActiveFile(other); + await service.started.future; + final requests = [ + for (var i = 0; i < 20; i++) + controller.openActiveFile(i.isEven ? home : other), + ]; + service.release(); + await Future.wait([first, ...requests]); + expect(service.maxRunning, lessThanOrEqualTo(2)); + expect(loader.loads, 0); + expect(controller.state.activeBuffer!.filePath, other); + expect(controller.state.workspace!.markdown!.filePath, other); + expect(controller.state.documentBuffers, hasLength(2)); + }, + ); + test( + 'covered creation notifications are acknowledged, external same-size writes remain visible', + () async { + final root = await responsiveness.syntheticProject(); + final loader = responsiveness.CountingModule(); + final monitor = _ControlledFileMonitor(); + final service = _CoveredCreationService(loader, monitor); + final harness = await _createControllerHarness( + service: service, + fileMonitor: monitor, + ); + await harness.settingsController.setAutoSave(false); + await harness.settingsController.setValidateOnEdit(false); + final controller = harness.controller._notifier; + await controller.openPath(root.path); + expect( + await controller.createWritersideTopic( + const WritersideTopicCreateRequest( + title: 'Created', + fileName: 'created.md', + ), + ), + isTrue, + ); + final count = loader.loads; + final topic = p.join(root.path, 'topics/created.md'); + final tree = p.join(root.path, 'guide.tree'); + monitor.emit(topic); + monitor.emit(tree); + monitor.emit(tree); + await Future.delayed(const Duration(milliseconds: 700)); + expect(loader.loads, count); + expect(controller.state.activeBuffer!.filePath, topic); + final source = await File(topic).readAsString(); + await File( + topic, + ).writeAsString(source.replaceFirst('Created', 'Changed')); + monitor.emit(topic); + await _waitForPublishedWorkspace( + harness._container, + (state) => + !state.isLoading && + (state.activeBuffer?.text.contains('Changed') == true && + state.workspace?.writersideModule + ?.topicByReference('created.md') + ?.title == + 'Changed'), + ); + expect(loader.loads, greaterThan(count)); + expect(controller.state.activeBuffer!.text, contains('Changed')); + expect( + controller.state.workspace!.writersideModule! + .topicByReference('created.md')! + .title, + 'Changed', + ); + }, + ); + test( + 'a failed mutation cannot acknowledge its write notifications', + () async { + final root = await responsiveness.syntheticProject(); + final loader = responsiveness.CountingModule(); + final monitor = _ControlledFileMonitor(); + final harness = await _createControllerHarness( + service: _FailedPublicationService(loader, monitor), + fileMonitor: monitor, + ); + await harness.settingsController.setAutoSave(false); + await harness.settingsController.setValidateOnEdit(false); + final controller = harness.controller._notifier; + await controller.openPath(root.path); + final before = loader.loads; + expect( + await controller.createWritersideTopic( + const WritersideTopicCreateRequest( + title: 'Failed', + fileName: 'failed.md', + ), + ), + isFalse, + ); + await _waitForPublishedWorkspace( + harness._container, + (state) => + !state.isLoading && + (state.workspace?.writersideModule?.instances.first.name == + 'GUIDE'), + ); + expect(loader.loads, greaterThan(before)); + expect( + await File(p.join(root.path, 'topics/failed.md')).exists(), + isFalse, + ); + expect( + controller.state.workspace!.writersideModule!.instances.first.name, + 'GUIDE', + ); + }, + ); + test( + 'quarantine and duplicate delayed deletion events acknowledge incorporated absence', + () async { + final root = await responsiveness.syntheticProject(); + final loader = responsiveness.CountingModule(); + final monitor = _ControlledFileMonitor(); + final harness = await _createControllerHarness( + service: WorkspaceService(writersideService: loader), + fileMonitor: monitor, + ); + await harness.settingsController.setAutoSave(false); + await harness.settingsController.setValidateOnEdit(false); + final controller = harness.controller._notifier; + await controller.openPath(root.path); + final topic = p.join(root.path, 'topics/other.md'); + await controller.openActiveFile(topic); + final analysis = await controller.analyzeWritersideTopicRemoval( + topicPath: topic, + mode: WritersideTopicRemovalMode.safeDeleteFile, + ); + expect( + await controller.applyWritersideTopicRemoval( + WritersideTopicRemovalRequest( + analysis: analysis!, + updateUsagesAutomatically: true, + ), + ), + isNotNull, + ); + final count = loader.loads; + final event = classifyWorkspaceFileMonitorEvent( + FileSystemMoveEvent( + topic, + false, + p.join( + root.path, + 'topics/.other.md.busymark-safe-delete-quarantine-1-2-0', + ), + ), + openFilePaths: {topic}, + workspaceRoot: true, + )!; + monitor.emitEvent(event); + monitor.emitEvent(event); + await Future.delayed(const Duration(milliseconds: 700)); + expect(loader.loads, count); + expect( + controller.state.documentBuffers.map((b) => b.filePath), + isNot(contains(topic)), + ); + await File(topic).writeAsString('# External recreation\n'); + monitor.emit(topic); + await _waitForPublishedWorkspace( + harness._container, + (state) => + !state.isLoading && + (state.workspace?.writersideModule + ?.topicByReference('other.md') + ?.title == + 'External recreation'), + ); + expect(loader.loads, greaterThan(count)); + expect( + controller.state.workspace!.writersideModule! + .topicByReference('other.md')! + .title, + 'External recreation', + ); + }, + ); + test( + 'an external same-size write during acknowledgement invalidates the consumed fingerprint', + () async { + final root = await responsiveness.syntheticProject(); + final loader = responsiveness.CountingModule(); + final monitor = _ControlledFileMonitor(); + final service = _AcknowledgementGateService(loader); + final harness = await _createControllerHarness( + service: service, + fileMonitor: monitor, + ); + await harness.settingsController.setAutoSave(false); + await harness.settingsController.setValidateOnEdit(false); + final controller = harness.controller._notifier; + await controller.openPath(root.path); + await controller.createWritersideTopic( + const WritersideTopicCreateRequest( + title: 'Created', + fileName: 'created.md', + ), + ); + final count = loader.loads; + final tree = File(p.join(root.path, 'guide.tree')); + service.gatePath = tree.path; + monitor.emit(tree.path); + await service.started.future; + final previous = await tree.readAsString(); + await tree.writeAsString(previous.replaceFirst('Guide', 'GUIDE')); + service.release.complete(); + await _waitForPublishedWorkspace( + harness._container, + (state) => + !state.isLoading && + (state.workspace?.writersideModule?.instances.first.name == + 'GUIDE'), + ); + expect(loader.loads, greaterThan(count)); + expect( + controller.state.workspace!.writersideModule!.instances.first.name, + 'GUIDE', + ); + }, + ); + for (final phase in ['anchor', 'disk']) { + test( + 'disposal during covered notification verification is safe: $phase', + () async { + final root = await responsiveness.syntheticProject(); + final loader = responsiveness.CountingModule(); + final monitor = _ControlledFileMonitor(); + final service = _AcknowledgementGateService(loader); + final harness = await _createControllerHarness( + service: service, + fileMonitor: monitor, + ); + final controller = harness.controller._notifier; + await controller.openPath(root.path); + await controller.createWritersideTopic( + const WritersideTopicCreateRequest( + title: 'Created', + fileName: 'created.md', + ), + ); + final count = loader.loads; + final tree = p.join(root.path, 'guide.tree'); + if (phase == 'disk') service.gatePath = tree; + monitor.emit(tree); + if (phase == 'disk') { + await service.started.future; + harness._container.dispose(); + service.release.complete(); + } else { + // Delivery starts anchor IO in the first microtask. Dispose before its + // completion rather than relying on a wall-clock race. + final disposed = Completer(); + scheduleMicrotask(() { + harness._container.dispose(); + disposed.complete(); + }); + await disposed.future; + } + await Future.delayed(const Duration(milliseconds: 30)); + expect(loader.loads, count); + }, + ); + } + test( + 'covered TOC removal notifications require no subsequent module processing', + () async { + final root = await responsiveness.syntheticProject(); + final loader = responsiveness.CountingModule(); + final monitor = _ControlledFileMonitor(); + final harness = await _createControllerHarness( + service: WorkspaceService(writersideService: loader), + fileMonitor: monitor, + ); + await harness.settingsController.setAutoSave(false); + await harness.settingsController.setValidateOnEdit(false); + final controller = harness.controller._notifier; + await controller.openPath(root.path); + final tree = p.join(root.path, 'guide.tree'); + final analysis = await controller.analyzeWritersideTopicRemoval( + topicPath: p.join(root.path, 'topics/other.md'), + mode: WritersideTopicRemovalMode.removeFromInstance, + treePath: tree, + nodePath: [1], + ); + expect( + await controller.applyWritersideTopicRemoval( + WritersideTopicRemovalRequest(analysis: analysis!), + ), + isNotNull, + ); + final count = loader.loads; + monitor.emit(tree); + await Future.delayed(const Duration(milliseconds: 700)); + expect(loader.loads, count); + expect( + controller + .state + .workspace! + .writersideModule! + .instances + .first + .topicFileSet, + isNot(contains('other.md')), + ); + }, + ); +} + +class _GatedClosedSourcesService extends WorkspaceService { + _GatedClosedSourcesService(responsiveness.CountingModule loader) + : super(writersideService: loader); + Completer started = Completer(); + Completer? _gate; + void pause() { + started = Completer(); + _gate = Completer(); + } + + void release() => _waiting!.complete(); + Completer? _waiting; + @override + Future withDocumentSources( + Workspace workspace, + Map sources, + ) async { + final result = await super.withDocumentSources(workspace, sources); + if (sources.isEmpty && _gate != null) { + _waiting = _gate; + _gate = null; + started.complete(); + await _waiting!.future; + } + return result; + } +} + +class _GatedPreparationService extends WorkspaceService { + _GatedPreparationService(responsiveness.CountingModule loader) + : super(writersideService: loader); + Completer started = Completer(); + Completer? _gate; + void pause() { + started = Completer(); + _gate = Completer(); + } + + void release() => _waiting?.complete(); + Completer? _waiting; + int running = 0; + int maxRunning = 0; + @override + Future prepareDocument( + Workspace workspace, + DocumentBuffer buffer, + Map sources, + ) async { + final gate = _gate; + _gate = null; + running++; + if (running > maxRunning) maxRunning = running; + try { + final result = await super.prepareDocument(workspace, buffer, sources); + if (gate != null) { + _waiting = gate; + started.complete(); + await gate.future; + } + return result; + } finally { + running--; + } + } +} + +class _CoveredCreationService extends WorkspaceService { + _CoveredCreationService(responsiveness.CountingModule loader, this.monitor) + : super(writersideService: loader); + final _ControlledFileMonitor monitor; + @override + Future createWritersideTopic( + Workspace workspace, + WritersideTopicCreateRequest request, { + String? instanceTreePath, + String? initialSource, + Future Function()? validateBeforePublish, + }) async { + final result = await super.createWritersideTopic( + workspace, + request, + instanceTreePath: instanceTreePath, + initialSource: initialSource, + validateBeforePublish: validateBeforePublish, + ); + monitor.emit(result.topicPath); + monitor.emit(p.join(workspace.rootPath, 'guide.tree')); + return result; + } +} + +class _FailedPublicationService extends WorkspaceService { + _FailedPublicationService(responsiveness.CountingModule loader, this.monitor) + : super(writersideService: loader); + final _ControlledFileMonitor monitor; + @override + Future createWritersideTopic( + Workspace workspace, + WritersideTopicCreateRequest request, { + String? instanceTreePath, + String? initialSource, + Future Function()? validateBeforePublish, + }) async { + final tree = File(p.join(workspace.rootPath, 'guide.tree')); + await tree.writeAsString( + (await tree.readAsString()).replaceFirst('Guide', 'GUIDE'), + ); + monitor.emit(tree.path); + throw StateError('Simulated failed mutation'); + } +} + +class _AcknowledgementGateService extends WorkspaceService { + _AcknowledgementGateService(responsiveness.CountingModule loader) + : super(writersideService: loader); + String? gatePath; + final started = Completer(); + final release = Completer(); + @override + Future fileSnapshot(String path) async { + final result = await super.fileSnapshot(path); + if (gatePath == path) { + gatePath = null; + started.complete(); + await release.future; + } + return result; + } +} + +void _registerCorrectionBoundaryTests() { + for (final phase in ['replacement', 'quarantine', 'final deletion']) { + for (final returnToOriginal in [false, true]) { + test( + 'remaining removal $phase accepts clean navigation return=$returnToOriginal', + () async { + final root = await responsiveness.syntheticProject(); + final target = p.join(root.path, 'topics/other.md'); + final third = p.join(root.path, 'topics/third.md'); + await File(third).writeAsString('# Third\n'); + final tree = File(p.join(root.path, 'guide.tree')); + if (phase != 'replacement') { + await tree.writeAsString( + '', + ); + } + final beforeTree = await tree.readAsString(); + final gate = _RemovalVerificationGate( + phase, + target, + tree.path, + beforeTree, + ); + final loader = responsiveness.CountingModule(); + final harness = await _createControllerHarness( + service: WorkspaceService( + writersideService: loader, + writersideTopicRemovalService: _GatedRemovalVerificationService( + gate, + ), + ), + fileMonitor: _ControlledFileMonitor(), + ); + await harness.settingsController.setAutoSave(false); + await harness.settingsController.setValidateOnEdit(false); + final controller = harness.controller._notifier; + await controller.openPath(root.path); + await controller.openActiveFile(target); + await controller.openActiveFile(third); + controller.updateActiveEditorMode(DocumentViewModePreference.source); + await controller.createMarkdownFile(); + controller.updateActiveText('# Unrelated unsaved note\n'); + final untitled = controller.state.activeBuffer!; + await controller.openActiveFile(p.join(root.path, 'topics/home.md')); + final original = controller.state.activeBuffer!; + final before = List.of( + controller.state.documentBuffers, + ); + final analysis = await controller.analyzeWritersideTopicRemoval( + topicPath: target, + mode: phase == 'replacement' + ? WritersideTopicRemovalMode.removeFromInstance + : WritersideTopicRemovalMode.safeDeleteFile, + treePath: phase == 'replacement' ? tree.path : null, + nodePath: phase == 'replacement' ? [1] : null, + ); + expect(analysis, isNotNull); + gate.armed = true; + final pending = controller.applyWritersideTopicRemoval( + WritersideTopicRemovalRequest(analysis: analysis!), + ); + await gate.started.future; + final loads = loader.loads; + expect(await controller.openActiveFile(third), isTrue); + if (returnToOriginal) { + expect( + await controller.activateDocumentBuffer(original.id), + isTrue, + ); + } + final navigationLoads = loader.loads; + gate.release.complete(); + expect(await pending, isNotNull); + expect( + navigationLoads, + loads, + reason: 'clean navigation adds no parse', + ); + expect(await File(target).exists(), phase == 'replacement'); + if (phase == 'replacement') { + expect(await tree.readAsString(), isNot(contains('other.md'))); + expect(controller.state.bufferForPath(target)!.text, '# Other\n'); + } else { + expect(await tree.readAsString(), beforeTree); + expect(controller.state.bufferForPath(target), isNull); + } + for (final saved in before.where((b) => b.filePath != target)) { + final live = controller.state.documentBuffers.singleWhere( + (b) => b.id == saved.id, + ); + expect(live.text, saved.text); + expect(live.revision, saved.revision); + expect(live.lastSavedText, saved.lastSavedText); + expect(live.isDirty, saved.isDirty); + expect(live.diskState, saved.diskState); + expect(live.editorState.mode, saved.editorState.mode); + expect(live.editorState.selection, saved.editorState.selection); + expect( + live.editorState.undoState.undo, + saved.editorState.undoState.undo, + ); + expect( + live.editorState.undoState.redo, + saved.editorState.undoState.redo, + ); + } + expect( + controller.state.activeBuffer!.filePath, + returnToOriginal ? original.filePath : third, + ); + expect( + controller.state.documentBuffers + .singleWhere((b) => b.id == untitled.id) + .text, + '# Unrelated unsaved note\n', + ); + }, + ); + } + } + for (final phase in ['replacement', 'quarantine', 'final deletion']) { + for (final change in [ + 'inactive edit', + 'reopen', + 'close all', + 'dispose', + 'disk input', + ]) { + test( + 'remaining removal $phase rejects $change after clean navigation', + () async { + final root = await responsiveness.syntheticProject(); + final target = p.join(root.path, 'topics/other.md'); + final home = p.join(root.path, 'topics/home.md'); + final third = p.join(root.path, 'topics/third.md'); + await File(third).writeAsString('# Third\n'); + final tree = File(p.join(root.path, 'guide.tree')); + if (phase != 'replacement') { + await tree.writeAsString( + '', + ); + } + final beforeTree = await tree.readAsString(); + final gate = _RemovalVerificationGate( + phase, + target, + tree.path, + beforeTree, + ); + final loader = responsiveness.CountingModule(); + final service = _RemovalNavigationWorkspaceService(loader, gate); + final harness = await _createControllerHarness( + service: service, + fileMonitor: _ControlledFileMonitor(), + ); + await harness.settingsController.setAutoSave(false); + await harness.settingsController.setValidateOnEdit(false); + final controller = harness.controller._notifier; + await controller.openPath(root.path); + await controller.openActiveFile(target); + final targetBuffer = controller.state.activeBuffer!; + await controller.openActiveFile(third); + await controller.openActiveFile(home); + final analysis = await controller.analyzeWritersideTopicRemoval( + topicPath: target, + mode: phase == 'replacement' + ? WritersideTopicRemovalMode.removeFromInstance + : WritersideTopicRemovalMode.safeDeleteFile, + treePath: phase == 'replacement' ? tree.path : null, + nodePath: phase == 'replacement' ? [1] : null, + ); + gate.armed = true; + final pending = controller.applyWritersideTopicRemoval( + WritersideTopicRemovalRequest(analysis: analysis!), + ); + await gate.started.future; + expect(await controller.openActiveFile(third), isTrue); + if (change == 'inactive edit') { + controller.updateDocumentText( + targetBuffer.id, + '# Keep this unsaved edit\n', + ); + } else if (change == 'reopen') { + await controller.openPath(root.path); + } else if (change == 'close all') { + for (final buffer in List.of( + controller.state.documentBuffers, + )) { + expect(await controller.closeDocumentBuffer(buffer.id), isTrue); + } + } else if (change == 'dispose') { + harness._container.dispose(); + } else { + await File(home).writeAsString('# External disk edit\n'); + } + final preparations = service.preparations; + gate.release.complete(); + expect(await pending, isNull); + expect(await File(target).readAsString(), '# Other\n'); + expect(await tree.readAsString(), beforeTree); + expect( + await root + .list(recursive: true) + .where((e) => e.path.contains('safe-delete')) + .toList(), + isEmpty, + ); + if (change == 'inactive edit') { + final live = controller.state.documentBuffers.singleWhere( + (b) => b.id == targetBuffer.id, + ); + expect(live.text, '# Keep this unsaved edit\n'); + expect(live.isDirty, isTrue); + } else if (change == 'reopen') { + expect(controller.state.activeBuffer!.filePath, home); + expect( + service.preparations, + preparations, + reason: 'old navigation must not prepare a reopened workspace', + ); + } else if (change == 'close all') { + expect(controller.state.documentBuffers, isEmpty); + } else if (change == 'disk input') { + expect(await File(home).readAsString(), '# External disk edit\n'); + } + }, + ); + } + } + test('remaining resource discovery presents stable limited inputs', () async { + final root = await responsiveness.resourceLimitedProject(); + final loader = _ResourceLimitCountingModule(); + final service = WorkspaceService(writersideService: loader); + final harness = await _createControllerHarness( + service: service, + fileMonitor: _ControlledFileMonitor(), + ); + await harness.settingsController.setAutoSave(false); + await harness.settingsController.setValidateOnEdit(false); + final controller = harness.controller._notifier; + await controller.openPath(root.path); + var project = controller.state.workspace!.writersideProject!; + expect(project.moduleDiscoveryComplete, isFalse); + expect(project.inputSnapshot!.consistent, isTrue); + expect( + project.inputSnapshot!.incompleteDirectories, + isNot(contains(p.join(root.path, 'images'))), + ); + expect(await project.observedInputsMatchDisk(), isTrue); + expect(await project.inputsMatchDisk(), isFalse); + expect(controller.state.activeBuffer!.text, '# Home\n'); + expect(controller.state.workspace!.markdown!.source, '# Home\n'); + expect(controller.state.preview!.title, 'Home'); + expect( + controller.state.workspace!.diagnostics.any( + (d) => d.code == 'workspace.scan.skipped', + ), + isTrue, + ); + for (final mode in WritersideTopicRemovalMode.values) { + expect( + await controller.analyzeWritersideTopicRemoval( + topicPath: p.join(root.path, 'topics/other.md'), + mode: mode, + treePath: mode == WritersideTopicRemovalMode.removeFromInstance + ? p.join(root.path, 'guide.tree') + : null, + nodePath: mode == WritersideTopicRemovalMode.removeFromInstance + ? [1] + : null, + ), + isNull, + ); + } + controller.updateActiveText('# Current readable content\n'); + expect((await controller.validateActive()).published, isTrue); + expect(controller.state.preview!.title, 'Current readable content'); + expect( + controller.state.workspace!.markdown!.source, + '# Current readable content\n', + ); + await controller.openActiveFile(p.join(root.path, 'topics/other.md')); + final loads = loader.loads; + for (var i = 0; i < 3; i++) { + expect( + await controller.openActiveFile(p.join(root.path, 'topics/home.md')), + isTrue, + ); + expect((await controller.validateActive()).published, isTrue); + expect( + await controller.openActiveFile(p.join(root.path, 'topics/other.md')), + isTrue, + ); + } + expect(loader.loads, loads); + project = controller.state.workspace!.writersideProject!; + expect(await project.observedInputsMatchDisk(), isTrue); + expect(await project.inputsMatchDisk(), isFalse); + await File(p.join(root.path, 'images/new.png')).writeAsString('new'); + expect(await project.observedInputsMatchDisk(), isFalse); + loader.limit = 10000; + expect((await controller.validateActive()).published, isTrue); + final complete = controller.state.workspace!.writersideProject!; + expect(complete.moduleDiscoveryComplete, isTrue); + expect(complete.inputSnapshot!.discoveryComplete, isTrue); + expect(await complete.inputsMatchDisk(), isTrue); + expect( + await controller.refreshWorkspaceFromDiskPreservingOpenTabs(), + isTrue, + ); + expect( + controller.state.workspace!.diagnostics.any( + (d) => d.code == 'workspace.scan.skipped', + ), + isFalse, + ); + expect( + controller.state.bufferForPath(p.join(root.path, 'topics/home.md'))!.text, + '# Current readable content\n', + ); + expect(await File(p.join(root.path, 'topics/other.md')).exists(), isTrue); + }); + for (final phase in ['replacement', 'quarantine', 'final deletion']) { + for (final change in [ + 'edit', + 'inactive edit', + 'workspace replacement', + 'same-project reopening', + ]) { + test( + 'correction removal $phase rejects $change during verification', + () async { + final root = await responsiveness.syntheticProject(); + final path = p.join(root.path, 'topics/other.md'); + final tree = File(p.join(root.path, 'guide.tree')); + if (phase != 'replacement') { + await tree.writeAsString( + '', + ); + } + final before = await tree.readAsString(); + final gate = _RemovalVerificationGate(phase, path, tree.path, before); + final harness = await _createControllerHarness( + service: WorkspaceService( + writersideTopicRemovalService: _GatedRemovalVerificationService( + gate, + ), + ), + fileMonitor: _ControlledFileMonitor(), + ); + await harness.settingsController.setAutoSave(false); + await harness.settingsController.setValidateOnEdit(false); + final controller = harness.controller._notifier; + await controller.openPath(root.path); + await controller.openActiveFile(path); + final buffer = controller.state.activeBuffer!; + if (change == 'inactive edit') { + await controller.openActiveFile( + p.join(root.path, 'topics/home.md'), + ); + } + final analysis = await controller.analyzeWritersideTopicRemoval( + topicPath: path, + mode: phase == 'replacement' + ? WritersideTopicRemovalMode.removeFromInstance + : WritersideTopicRemovalMode.safeDeleteFile, + treePath: phase == 'replacement' ? tree.path : null, + nodePath: phase == 'replacement' ? [1] : null, + ); + expect(analysis, isNotNull); + gate.armed = true; + final pending = controller.applyWritersideTopicRemoval( + WritersideTopicRemovalRequest( + analysis: analysis!, + updateUsagesAutomatically: true, + ), + ); + await gate.started.future.timeout(const Duration(seconds: 10)); + if (change == 'edit' || change == 'inactive edit') { + controller.updateDocumentText( + buffer.id, + '# Unsaved during verification\n', + ); + expect( + controller.state.documentBuffers + .singleWhere((b) => b.id == buffer.id) + .revision, + greaterThan(buffer.revision), + ); + } else { + await controller.openPath( + change == 'same-project reopening' + ? root.path + : 'test/fixtures/markdown/basic.md', + ); + } + gate.release.complete(); + final result = await pending; + expect(result, isNull); + expect(gate.unauthorizedMutations, isEmpty); + expect(await File(path).readAsString(), '# Other\n'); + expect(await tree.readAsString(), before); + expect( + await root + .list(recursive: true) + .where((e) => e.path.contains('safe-delete')) + .toList(), + isEmpty, + ); + if (change == 'edit' || change == 'inactive edit') { + final live = controller.state.documentBuffers.singleWhere( + (b) => b.id == buffer.id, + ); + expect(live.text, '# Unsaved during verification\n'); + expect(live.isDirty, isTrue); + expect(live.revision, greaterThan(buffer.revision)); + } else { + expect( + controller.state.workspace!.kind, + change == 'same-project reopening' + ? WorkspaceKind.writersideModule + : WorkspaceKind.singleMarkdown, + ); + } + }, + ); + } + } + for (final phase in ['disk before refresh', 'preparation after disk scan']) { + for (final inactive in [false, true]) { + test( + 'correction deletion reconciliation preserves a newer accepted edit $phase inactive=$inactive', + () async { + final root = await responsiveness.syntheticProject(); + final path = p.join(root.path, 'topics/other.md'); + final service = _PostDeleteRefreshGateService(path, phase); + final harness = await _createControllerHarness( + service: service, + fileMonitor: _ControlledFileMonitor(), + ); + await harness.settingsController.setAutoSave(false); + await harness.settingsController.setValidateOnEdit(false); + final controller = harness.controller._notifier; + await controller.openPath(root.path); + await controller.openActiveFile(path); + final buffer = controller.state.activeBuffer!; + if (inactive) { + await controller.openActiveFile( + p.join(root.path, 'topics/home.md'), + ); + } + final analysis = await controller.analyzeWritersideTopicRemoval( + topicPath: path, + mode: WritersideTopicRemovalMode.safeDeleteFile, + ); + service.armed = true; + final pending = controller.applyWritersideTopicRemoval( + WritersideTopicRemovalRequest( + analysis: analysis!, + updateUsagesAutomatically: true, + ), + ); + await service.started.future.timeout(const Duration(seconds: 10)); + expect(await File(path).exists(), isFalse); + controller.updateDocumentText( + buffer.id, + '# Recover this newer content\n', + ); + final edited = controller.state.documentBuffers.singleWhere( + (b) => b.id == buffer.id, + ); + service.release.complete(); + expect(await pending, isNotNull); + expect(await File(path).exists(), isFalse); + final live = controller.state.documentBuffers.singleWhere( + (b) => b.id == buffer.id, + ); + expect(live.text, edited.text); + expect(live.revision, edited.revision); + expect(live.editorState.selection, edited.editorState.selection); + expect( + live.editorState.undoState.undo, + edited.editorState.undoState.undo, + ); + expect( + live.editorState.undoState.redo, + edited.editorState.undoState.redo, + ); + expect(live.editorState.mode, edited.editorState.mode); + expect(live.lastSavedText, edited.lastSavedText); + expect(live.isDirty, isTrue); + expect(live.diskState, DocumentDiskState.deleted); + }, + ); + } + } + for (final creating in [false, true]) { + for (final automatic in [false, true]) { + test( + 'correction initial ${creating ? "creation" : "open"} recovers model/read mismatch automatic=$automatic', + () async { + final root = await responsiveness.syntheticProject(); + final service = _InitialReadGateService(); + final harness = await _createControllerHarness( + service: service, + fileMonitor: _ControlledFileMonitor(), + ); + await harness.settingsController.setAutoSave(false); + await harness.settingsController.setValidateOnEdit(automatic); + final controller = harness.controller._notifier; + service.armed = true; + final pending = creating + ? controller.createWritersideProject( + WritersideProjectCreateRequest( + parentDirectoryPath: root.path, + directoryName: 'created', + projectName: 'Created', + instanceName: 'Guide', + topicTitle: 'Initial', + ), + ) + : controller.openPath(root.path); + await service.started.future.timeout(const Duration(seconds: 10)); + await File( + service.path!, + ).writeAsString('# Changed after model load\n'); + service.release.complete(); + await pending; + expect( + controller.state.activeBuffer!.text, + '# Changed after model load\n', + ); + await _waitForPublishedWorkspace( + harness._container, + (s) => s.preview?.title == 'Changed after model load', + ).timeout(const Duration(seconds: 5)); + expect( + controller.state.activeBuffer!.text, + '# Changed after model load\n', + ); + expect(service.preparations, 1); + if (automatic) { + expect( + controller.state.workspace!.markdown!.source, + '# Changed after model load\n', + ); + expect( + controller.state.workspace!.writersideModule!.topics + .singleWhere((t) => t.filePath == service.path) + .document + .source, + '# Changed after model load\n', + ); + } else { + expect( + controller.state.workspace!.markdown!.source, + isNot('# Changed after model load\n'), + ); + final outcome = await controller.validateActive(); + expect(outcome.published, isTrue); + expect( + controller.state.workspace!.markdown!.source, + '# Changed after model load\n', + ); + } + }, + ); + } + } + + for (final change in ['inactive edit', 'configuration write']) { + test('correction limited preparation rejects superseded $change', () async { + final root = await responsiveness.syntheticProject(); + final loader = _LimitCountingModule(); + final service = _GatedPreparationService(loader); + final harness = await _createControllerHarness( + service: service, + fileMonitor: _ControlledFileMonitor(), + ); + await harness.settingsController.setAutoSave(false); + await harness.settingsController.setValidateOnEdit(false); + final controller = harness.controller._notifier; + await controller.openPath(root.path); + final home = controller.state.activeBuffer!; + await controller.openActiveFile(p.join(root.path, 'topics/other.md')); + service.pause(); + final pending = controller.validateActive(); + await service.started.future; + if (change == 'inactive edit') { + controller.updateDocumentText( + home.id, + '# New inactive readable input\n', + ); + } else { + final config = File(p.join(root.path, 'writerside.cfg')); + await config.writeAsString('${await config.readAsString()}\n'); + } + service.release(); + expect((await pending).status, ValidationStatus.stale); + if (change == 'inactive edit') { + expect( + controller.state.documentBuffers + .singleWhere((b) => b.id == home.id) + .text, + '# New inactive readable input\n', + ); + } + expect((await controller.validateActive()).published, isTrue); + expect( + controller.state.workspace!.writersideProject!.moduleDiscoveryComplete, + isFalse, + ); + }); + } + + test( + 'correction discovery refresh keeps the live selected instance through reconciliation', + () async { + final root = await responsiveness.syntheticProject(); + await File(p.join(root.path, 'writerside.cfg')).writeAsString( + '', + ); + await File(p.join(root.path, 'second.tree')).writeAsString( + '', + ); + await File(p.join(root.path, 'topics/other.md')).writeAsString( + '# Other\n\nSecond-only content\n', + ); + final harness = await _createControllerHarness( + fileMonitor: _ControlledFileMonitor(), + ); + await harness.settingsController.setAutoSave(false); + final controller = harness.controller._notifier; + await controller.openPath(root.path); + expect( + await controller.selectWritersideContext( + moduleId: + controller.state.workspace!.writersideProject!.activeModuleId!, + instanceId: 'second', + ), + isTrue, + ); + await File( + p.join(root.path, 'topics/added.md'), + ).writeAsString('# Added\n'); + expect( + await controller.refreshWorkspaceFromDiskPreservingOpenTabs(), + isTrue, + ); + expect( + controller.state.workspace!.writersideProject!.activeInstanceId, + 'second', + ); + expect( + controller + .state + .workspace! + .writersideProject! + .activeInstance! + .topicFileSet, + {'other.md'}, + ); + expect( + controller.state.preview!.blocks.map((b) => b.text).join('\n'), + contains('Second-only content'), + ); + }, + ); + for (final limit in [4, 2]) { + test( + 'correction incomplete tree traversal publishes readable edited content and settles limit=$limit', + () async { + final root = await responsiveness.syntheticProject(); + // The topic scan sees both readable topics, but the enclosing project + // discovery is capped. This exercises incomplete(), not maxParsedDocuments. + final service = _LimitedDiscoveryService()..loader.limit = limit; + final harness = await _createControllerHarness( + service: service, + fileMonitor: _ControlledFileMonitor(), + ); + await harness.settingsController.setAutoSave(false); + await harness.settingsController.setValidateOnEdit(false); + final controller = harness.controller._notifier; + await controller.openPath(root.path); + expect( + controller + .state + .workspace! + .writersideProject! + .moduleDiscoveryComplete, + isFalse, + ); + expect( + controller.state.workspace!.writersideModule!.topics, + isNotEmpty, + ); + controller.updateActiveText('# Readable current edit\n'); + final outcome = await controller.validateActive(); + expect(outcome.published, isTrue); + expect(controller.state.preview!.title, 'Readable current edit'); + expect( + controller.state.workspace!.markdown!.source, + '# Readable current edit\n', + ); + expect( + controller.state.workspace!.diagnostics.any( + (d) => d.code == 'workspace.scan.skipped', + ), + isTrue, + ); + final loads = service.loader.loads; + await controller.openActiveFile(p.join(root.path, 'topics/other.md')); + await controller.openActiveFile(p.join(root.path, 'topics/home.md')); + expect( + controller.state.activeBuffer!.text, + '# Readable current edit\n', + ); + expect(controller.state.preview!.title, 'Readable current edit'); + await Future.delayed(const Duration(milliseconds: 700)); + expect(service.loader.loads, loads + 1); + expect( + await controller.analyzeWritersideTopicRemoval( + topicPath: p.join(root.path, 'topics/other.md'), + mode: WritersideTopicRemovalMode.safeDeleteFile, + ), + isNull, + ); + service.complete = true; + expect((await controller.validateActive()).published, isTrue); + expect( + controller + .state + .workspace! + .writersideProject! + .moduleDiscoveryComplete, + isTrue, + ); + expect( + controller.state.workspace!.writersideProject!.diagnostics.any( + (d) => d.code == 'workspace.scan.skipped', + ), + isFalse, + ); + expect( + await controller.refreshWorkspaceFromDiskPreservingOpenTabs(), + isTrue, + ); + expect( + controller.state.workspace!.diagnostics.any( + (d) => d.code == 'workspace.scan.skipped', + ), + isFalse, + ); + }, + ); + } +} + +class _RemovalVerificationGate { + _RemovalVerificationGate(this.phase, this.topic, this.tree, this.treeBefore); + final String phase; + final String topic; + final String tree; + final String treeBefore; + final unauthorizedMutations = []; + bool armed = false; + final started = Completer(); + final release = Completer(); +} + +class _GatedRemovalVerificationService extends WritersideTopicRemovalService { + _GatedRemovalVerificationService(this.gate); + final _RemovalVerificationGate gate; + @override + Future apply( + WritersideTopicRemovalRequest request, { + WritersideProject? project, + void Function(Iterable)? validateBeforeCommit, + }) => super.apply( + request, + project: _VerificationProject(project!, gate), + validateBeforeCommit: (paths) { + if (gate.phase != 'final deletion' && + gate.release.isCompleted && + (!File(gate.topic).existsSync() || + File(gate.tree).readAsStringSync() != gate.treeBefore)) { + gate.unauthorizedMutations.add('changed disk before later live check'); + } + validateBeforeCommit?.call(paths); + }, + ); +} + +class _VerificationProject extends WritersideProject { + _VerificationProject(WritersideProject project, this.gate) + : super( + rootPath: project.rootPath, + modules: project.modules, + activeModuleId: project.activeModuleId, + activeInstanceId: project.activeInstanceId, + index: project.index, + diagnostics: project.diagnostics, + moduleDiscoveryComplete: project.moduleDiscoveryComplete, + moduleDiscoveryDiagnostics: project.moduleDiscoveryDiagnostics, + inputSnapshot: project.inputSnapshot, + ); + final _RemovalVerificationGate gate; + int checks = 0; + @override + Future inputsMatchDisk({ + bool requireDiskSources = false, + Set ignoredPaths = const {}, + }) async { + checks++; + if (gate.phase != 'final deletion' && + gate.release.isCompleted && + (!await File(gate.topic).exists() || + await File(gate.tree).readAsString() != gate.treeBefore)) { + gate.unauthorizedMutations.add('changed disk at verification $checks'); + } + final result = await super.inputsMatchDisk( + requireDiskSources: requireDiskSources, + ignoredPaths: ignoredPaths, + ); + final shouldGate = switch (gate.phase) { + 'replacement' => checks == 3, + 'quarantine' => checks == 4, + 'final deletion' => ignoredPaths.any((p) => p.endsWith('other.md')), + _ => false, + }; + if (gate.armed && shouldGate) { + gate.armed = false; + gate.started.complete(); + await gate.release.future; + } + return result; + } +} + +class _PostDeleteRefreshGateService extends WorkspaceService { + _PostDeleteRefreshGateService(this.target, this.phase); + final String phase; + final String target; + bool armed = false; + final started = Completer(); + final release = Completer(); + @override + Future openPath(String path) async { + if (phase == 'disk before refresh' && + armed && + !await File(target).exists()) { + armed = false; + started.complete(); + await release.future; + } + return super.openPath(path); + } + + @override + Future prepareDocument( + Workspace workspace, + DocumentBuffer buffer, + Map sources, + ) async { + final result = await super.prepareDocument(workspace, buffer, sources); + if (phase == 'preparation after disk scan' && + armed && + !await File(target).exists()) { + armed = false; + started.complete(); + await release.future; + } + return result; + } +} + +class _InitialReadGateService extends WorkspaceService { + bool armed = false; + String? path; + int preparations = 0; + final started = Completer(); + final release = Completer(); + @override + Future loadTextWithSnapshot(String path) async { + if (armed) { + armed = false; + this.path = path; + started.complete(); + await release.future; + } + return super.loadTextWithSnapshot(path); + } + + @override + Future prepareDocument( + Workspace workspace, + DocumentBuffer buffer, + Map sources, + ) { + preparations++; + return super.prepareDocument(workspace, buffer, sources); + } +} + +class _LimitedDiscoveryService extends WorkspaceService { + final loader = _LimitCountingModule(); + bool get complete => loader.complete; + set complete(bool value) => loader.complete = value; + WorkspaceService get delegate => WorkspaceService( + scanOptions: WorkspaceScanOptions(maxTreeEntries: complete ? 10000 : 4), + writersideService: loader, + ); + @override + Future openPath(String path) => delegate.openPath(path); + @override + Future prepareDocument( + Workspace workspace, + DocumentBuffer buffer, + Map sources, + ) async { + final prepared = await delegate.prepareDocument(workspace, buffer, sources); + return prepared; + } + + @override + Future analyzeWritersideTopicRemoval( + Workspace workspace, { + required String topicPath, + required WritersideTopicRemovalMode mode, + String? treePath, + List? nodePath, + }) => delegate.analyzeWritersideTopicRemoval( + workspace, + topicPath: topicPath, + mode: mode, + treePath: treePath, + nodePath: nodePath, + ); +} + +class _LimitCountingModule extends responsiveness.CountingModule { + bool complete = false; + int limit = 4; + @override + WorkspaceScanOptions get scanOptions => + WorkspaceScanOptions(maxTreeEntries: complete ? 10000 : limit); +} + +class _ResourceLimitCountingModule extends responsiveness.CountingModule { + int limit = 8; + @override + WorkspaceScanOptions get scanOptions => + WorkspaceScanOptions(maxTreeEntries: limit); +} + +class _RemovalNavigationWorkspaceService extends WorkspaceService { + _RemovalNavigationWorkspaceService( + responsiveness.CountingModule loader, + _RemovalVerificationGate gate, + ) : super( + writersideService: loader, + writersideTopicRemovalService: _GatedRemovalVerificationService(gate), + ); + int preparations = 0; + @override + Future prepareDocument( + Workspace workspace, + DocumentBuffer buffer, + Map sources, + ) { + preparations++; + return super.prepareDocument(workspace, buffer, sources); + } +} diff --git a/test/src/workspace_monitor_endpoints_test.dart b/test/src/workspace_monitor_endpoints_test.dart new file mode 100644 index 00000000..322dd22b --- /dev/null +++ b/test/src/workspace_monitor_endpoints_test.dart @@ -0,0 +1,63 @@ +import 'dart:io'; +import 'package:busymark/src/workspace/workspace_file_monitor.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + final names = [ + '/tmp/.guide.tree.busymark-topic-create-10-100-0', + '/tmp/.guide.tree.busymark-safe-delete-10-100-0', + '/tmp/.topic.md.busymark-safe-delete-quarantine-10-100-0', + ]; + WorkspaceFileMonitorEvent? classify(FileSystemEvent event) => + classifyWorkspaceFileMonitorEvent( + event, + openFilePaths: {'/tmp/guide.tree'}, + workspaceRoot: true, + ); + for (final temporary in names) { + test('temporary-to-real move retains publication: $temporary', () { + final event = classify( + FileSystemMoveEvent(temporary, false, '/tmp/guide.tree'), + )!; + expect(event.path, '/tmp/guide.tree'); + expect(event.kind, WorkspaceFileEventKind.changed); + expect(event.destinationPath, isNull); + }); + test('real-to-temporary move retains deletion: $temporary', () { + final event = classify( + FileSystemMoveEvent('/tmp/guide.tree', false, temporary), + )!; + expect(event.path, '/tmp/guide.tree'); + expect(event.kind, WorkspaceFileEventKind.deleted); + expect(event.destinationPath, isNull); + expect(classify(FileSystemMoveEvent(temporary, false, null)), isNull); + expect( + classify(FileSystemMoveEvent(temporary, false, names.first)), + isNull, + ); + expect(classify(FileSystemCreateEvent(temporary, false)), isNull); + }); + } + test( + 'external moves, missing destinations and authored hidden names stay visible', + () { + expect( + classify( + FileSystemMoveEvent('/tmp/guide.tree', false, '/tmp/other.tree'), + )!.destinationPath, + '/tmp/other.tree', + ); + expect( + classify(FileSystemMoveEvent('/tmp/guide.tree', false, null))!.kind, + WorkspaceFileEventKind.moved, + ); + for (final path in [ + '/tmp/.hidden.md', + '/tmp/busymark.md', + '/tmp/.x.busymark-topic-create-custom', + ]) { + expect(classify(FileSystemModifyEvent(path, false, false))!.path, path); + } + }, + ); +} diff --git a/test/src/workspace_service_test.dart b/test/src/workspace_service_test.dart index c938bbbb..0cbcfd8d 100644 --- a/test/src/workspace_service_test.dart +++ b/test/src/workspace_service_test.dart @@ -380,21 +380,24 @@ final answer = 42; ), ); - final topicPath = p.join(updated.rootPath, 'topics', 'install.md'); - final treeSource = File( - p.join(updated.rootPath, 'user-guide.tree'), - ).readAsStringSync(); + final topicPath = p.join(workspace.rootPath, 'topics', 'install.md'); + final treeSource = File(updated.treePath).readAsStringSync(); - expect(updated.activeFilePath, topicPath); + expect(updated.topicPath, topicPath); expect(File(topicPath).existsSync(), isTrue); expect( File(topicPath).readAsStringSync(), startsWith('# Install BusyMark'), ); expect(treeSource, contains('topic="install.md"')); - expect(updated.markdown?.title, 'Install BusyMark'); + expect(updated.topicFileName, 'install.md'); + final reopened = await service.openPath(workspace.rootPath); + expect( + reopened.writersideModule!.topicByReference('install.md')!.title, + 'Install BusyMark', + ); expect( - updated.diagnostics.where((item) => item.severity.name == 'error'), + reopened.diagnostics.where((item) => item.severity.name == 'error'), isEmpty, ); }, @@ -623,10 +626,7 @@ final answer = 42; File(p.join(root.path, 'first.tree')).readAsStringSync(), firstTreeBefore, ); - expect( - updated.activeFilePath, - p.join(root.path, 'topics', 'second-child.md'), - ); + expect(updated.topicPath, p.join(root.path, 'topics', 'second-child.md')); }, ); @@ -668,10 +668,7 @@ final answer = 42; fileName: 'different.md', ), ); - expect( - File(p.join(updated.rootPath, 'topics', 'different.md')).existsSync(), - isTrue, - ); + expect(File(updated.topicPath).existsSync(), isTrue); }, ); diff --git a/test/src/writerside_api_test.dart b/test/src/writerside_api_test.dart index 412cdab2..155f8ddc 100644 --- a/test/src/writerside_api_test.dart +++ b/test/src/writerside_api_test.dart @@ -1,5 +1,8 @@ import 'dart:convert'; import 'dart:io'; +import '../support/writerside_responsiveness.dart'; +import 'package:busymark/src/writerside/writerside_execution.dart'; +import 'package:busymark/src/writerside/writerside_project.dart'; import 'package:busymark/src/markdown/busymark_document.dart'; import 'package:busymark/src/writerside/writerside_document_renderer.dart'; import 'package:busymark/src/writerside/writerside_document_resolver.dart'; @@ -135,6 +138,31 @@ void main() { return (text(busy.blocks), resolved); } + test( + 'platform-backed API model remains on the host path and transfers complete semantics', + () async { + await File(p.join(root.path, 'topics/main.topic')).writeAsString( + '', + ); + final foreground = await const WritersideProjectService( + moduleService: WritersideModuleService( + execution: WritersideExecution(useWorker: false), + ), + ).load(root.path); + final worker = await const WritersideProjectService().load(root.path); + expect(parsedSources, [jsonEncode(spec), jsonEncode(spec)]); + expect( + projectSemanticSnapshot(worker), + projectSemanticSnapshot(foreground), + ); + expect(await worker.inputsMatchDisk(), isTrue); + await File( + p.join(root.path, 'specifications/api.json'), + ).writeAsString('{}'); + expect(await worker.inputsMatchDisk(), isFalse); + }, + ); + test( 'API tags select operations through the existing OpenAPI parser', () async { diff --git a/test/src/writerside_input_snapshot_test.dart b/test/src/writerside_input_snapshot_test.dart new file mode 100644 index 00000000..650e985c --- /dev/null +++ b/test/src/writerside_input_snapshot_test.dart @@ -0,0 +1,200 @@ +import 'dart:io'; + +import 'package:busymark/src/writerside/writerside_input_snapshot.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; + +void main() { + late Directory root; + late List files; + late WritersideInputRecorder recorder; + setUp(() async { + root = await Directory.systemTemp.createTemp( + 'busymark-directory-observations-', + ); + files = [ + for (final name in ['a', 'b', 'c']) File(p.join(root.path, name)), + ]; + for (final file in files) { + await file.writeAsString('fixture'); + } + recorder = WritersideInputRecorder(root.path, treeEntryLimit: 3); + }); + tearDown(() => root.delete(recursive: true)); + + test('remaining observations partial prefix grows compatibly', () async { + recorder.directory(root.path, files.take(1), complete: false); + recorder.incomplete(); + recorder.directory(root.path, files.take(2), complete: false); + expect(recorder.snapshot.consistent, isTrue); + expect(recorder.snapshot.incompleteDirectories[root.path]!.count, 2); + expect( + recorder.snapshot.directories[root.path], + inputDirectoryEntries(files.take(2)), + ); + }); + test( + 'remaining observations partial becomes complete and clears marker', + () async { + recorder.directory(root.path, files.take(1), complete: false); + recorder.incomplete(); + recorder.directory(root.path, files); + expect(recorder.snapshot.consistent, isTrue); + expect(recorder.snapshot.incompleteDirectories, isEmpty); + expect(await recorder.snapshot.observedInputsCurrent(), isTrue); + expect(await recorder.snapshot.isCurrent(), isFalse); + }, + ); + test( + 'remaining observations identical complete listings remain consistent', + () async { + recorder.directory(root.path, files); + recorder.directory(root.path, files.reversed); + expect(recorder.snapshot.consistent, isTrue); + expect(await recorder.snapshot.isCurrent(), isTrue); + }, + ); + test( + 'remaining observations incompatible repeated listing rejects', + () async { + recorder.directory(root.path, files.take(2), complete: false); + recorder.directory(root.path, [files.first, files.last], complete: false); + expect(recorder.snapshot.consistent, isFalse); + expect(await recorder.snapshot.observedInputsCurrent(), isFalse); + }, + ); + test( + 'remaining observations known failed prefix recovers completely', + () async { + recorder.directory( + root.path, + files.take(1), + complete: false, + failed: true, + ); + recorder.incomplete(); + recorder.directory(root.path, files); + expect(recorder.snapshot.consistent, isTrue); + expect(recorder.snapshot.incompleteDirectories, isEmpty); + expect(await recorder.snapshot.observedInputsCurrent(), isTrue); + expect(await recorder.snapshot.isCurrent(), isFalse); + }, + ); + test( + 'remaining observations failure without a known prefix cannot prove recovery', + () async { + recorder.directory(root.path, [], complete: false, failed: true); + recorder.directory(root.path, []); + expect(recorder.snapshot.consistent, isFalse); + expect(await recorder.snapshot.observedInputsCurrent(), isFalse); + }, + ); + test( + 'remaining observations successful listing followed by failure rejects', + () async { + recorder.directory(root.path, files); + recorder.directory(root.path, files, complete: false, failed: true); + recorder.incomplete(); + expect(recorder.snapshot.consistent, isFalse); + expect(await recorder.snapshot.observedInputsCurrent(), isFalse); + }, + ); + for (final change in ['replacement', 'removal']) { + test( + 'remaining observations real entry $change between complete reads rejects', + () async { + recorder.directory(root.path, files); + await files.last.delete(); + if (change == 'replacement') { + await Directory(files.last.path).create(); + } + recorder.directory( + root.path, + await root.list(followLinks: false).toList(), + ); + expect(recorder.snapshot.consistent, isFalse); + expect(await recorder.snapshot.observedInputsCurrent(), isFalse); + }, + ); + } + test( + 'remaining observations shorter compatible scan keeps stronger proof', + () async { + recorder.directory(root.path, files, complete: false); + recorder.incomplete(); + recorder.directory(root.path, files.take(1), complete: false); + expect(recorder.snapshot.consistent, isTrue); + expect(recorder.snapshot.incompleteDirectories[root.path]!.count, 3); + await files.last.delete(); + expect(await recorder.snapshot.observedInputsCurrent(), isFalse); + }, + ); + test( + 'remaining observations complete proof survives compatible limited rescan', + () async { + recorder.directory(root.path, files); + recorder.directory(root.path, files.take(1), complete: false); + recorder.incomplete(); + expect(recorder.snapshot.consistent, isTrue); + expect(recorder.snapshot.incompleteDirectories, isEmpty); + expect(await recorder.snapshot.observedInputsCurrent(), isTrue); + expect(await recorder.snapshot.isCurrent(), isFalse); + }, + ); + test( + 'remaining observations partial rescan cannot erase a previous failure', + () async { + recorder.directory( + root.path, + files.take(1), + complete: false, + failed: true, + ); + recorder.incomplete(); + recorder.directory(root.path, files.take(2), complete: false); + expect(recorder.snapshot.consistent, isFalse); + expect( + recorder.snapshot.incompleteDirectories[root.path]!.failed, + isTrue, + ); + expect(await recorder.snapshot.observedInputsCurrent(), isFalse); + }, + ); + test( + 'remaining observations complete recovery must retain failed-prefix entries', + () async { + recorder.directory( + root.path, + files.take(1), + complete: false, + failed: true, + ); + recorder.incomplete(); + recorder.directory(root.path, files.skip(1)); + expect(recorder.snapshot.consistent, isFalse); + expect(await recorder.snapshot.observedInputsCurrent(), isFalse); + }, + ); + test( + 'remaining observations shorter failed retry retains its consumed prefix', + () async { + recorder.directory( + root.path, + files.take(2), + complete: false, + failed: true, + ); + recorder.directory( + root.path, + files.take(1), + complete: false, + failed: true, + ); + expect(recorder.snapshot.consistent, isTrue); + expect(recorder.snapshot.incompleteDirectories[root.path]!.count, 2); + recorder.directory(root.path, files.take(1)); + expect(recorder.snapshot.consistent, isFalse); + expect(await recorder.snapshot.observedInputsCurrent(), isFalse); + }, + ); +} diff --git a/test/src/writerside_responsiveness_test.dart b/test/src/writerside_responsiveness_test.dart new file mode 100644 index 00000000..0b9fb43e --- /dev/null +++ b/test/src/writerside_responsiveness_test.dart @@ -0,0 +1,679 @@ +import '../support/writerside_responsiveness.dart'; +import 'dart:io'; +import 'dart:isolate'; + +import 'package:busymark/src/core/busymark_exception.dart'; +import 'package:busymark/src/core/path_utils.dart'; +import 'package:busymark/src/workspace/workspace_service.dart'; +import 'package:busymark/src/writerside/writerside_execution.dart'; +import 'package:busymark/src/writerside/writerside_input_snapshot.dart'; +import 'package:busymark/src/writerside/writerside_module_service.dart'; +import 'package:busymark/src/writerside/writerside_project.dart'; +import 'package:busymark/src/writerside/writerside_source_loader.dart'; +import 'package:busymark/src/writerside/writerside_topic_creator.dart'; +import 'package:busymark/src/writerside/writerside_topic_removal_service.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + test('correction discovery refresh preserves a valid second instance', () async { + final root = await syntheticProject(); + await File(p.join(root.path, 'writerside.cfg')).writeAsString( + '', + ); + await File(p.join(root.path, 'second.tree')).writeAsString( + '', + ); + await File(p.join(root.path, 'topics/other.md')).writeAsString( + '# Other\n\nSecond-only content\n', + ); + const service = WorkspaceService(); + var workspace = await service.openPath(root.path); + workspace = await service.selectWritersideContext( + workspace, + moduleId: workspace.writersideProject!.activeModuleId!, + instanceId: 'second', + ); + final buffer = await responsivenessBuffer( + service, + p.join(root.path, 'topics/other.md'), + ); + await File(p.join(root.path, 'topics/added.md')).writeAsString('# Added\n'); + final prepared = await service.prepareDocument(workspace, buffer, { + buffer.filePath!: buffer.text, + }); + expect(prepared.writersideProject!.activeInstanceId, 'second'); + expect(prepared.writersideProject!.activeInstance!.topicFileSet, { + 'other.md', + }); + expect( + service + .buildDocumentPreview(prepared, buffer)! + .blocks + .map((b) => b.text) + .join('\n'), + contains('Second-only content'), + ); + final first = await service.selectWritersideContext( + prepared, + moduleId: prepared.writersideProject!.activeModuleId!, + instanceId: 'guide', + ); + expect( + service + .buildDocumentPreview(first, buffer)! + .blocks + .map((b) => b.text) + .join('\n'), + isNot(contains('Second-only content')), + ); + }); + + for (final change in [ + 'selected instance removed', + 'intentional selection', + 'another module', + ]) { + test('correction discovery selection fallback: $change', () async { + final root = await syntheticProject(); + final config = File(p.join(root.path, 'writerside.cfg')); + await config.writeAsString( + '', + ); + final second = File(p.join(root.path, 'second.tree')); + await second.writeAsString( + '', + ); + const service = WorkspaceService(); + var workspace = await service.openPath(root.path); + workspace = await service.selectWritersideContext( + workspace, + moduleId: workspace.writersideProject!.activeModuleId!, + instanceId: 'second', + ); + var path = p.join(root.path, 'topics/other.md'); + switch (change) { + case 'selected instance removed': + await config.writeAsString( + '', + ); + await second.delete(); + case 'intentional selection': + workspace = await service.selectWritersideContext( + workspace, + moduleId: workspace.writersideProject!.activeModuleId!, + instanceId: 'guide', + ); + await File( + p.join(root.path, 'topics/added.md'), + ).writeAsString('# Added\n'); + case 'another module': + final nested = Directory(p.join(root.path, 'nested')); + await nested.create(); + await Directory(p.join(nested.path, 'topics')).create(); + await File(p.join(nested.path, 'writerside.cfg')).writeAsString( + '', + ); + await File(p.join(nested.path, 'nested.tree')).writeAsString( + '', + ); + path = p.join(nested.path, 'topics/nested.md'); + await File(path).writeAsString('# Nested\n'); + } + final buffer = await responsivenessBuffer(service, path); + final prepared = await service.prepareDocument(workspace, buffer, { + path: buffer.text, + }); + expect( + prepared.writersideProject!.activeInstance!.id, + change == 'another module' ? 'nested' : 'guide', + ); + expect( + prepared.writersideProject!.activeModule!.rootPath, + change == 'another module' ? p.join(root.path, 'nested') : root.path, + ); + expect(prepared.markdown!.filePath, path); + }); + } + test( + 'correction incomplete observations are presentation only and reject changed reads', + () async { + final root = await syntheticProject(); + final loader = WritersideModuleService( + scanOptions: const WorkspaceScanOptions(maxTreeEntries: 2), + ); + final module = await loader.load(root.path); + expect(module.topicDiscoveryComplete, isFalse); + expect(module.inputSnapshot!.discoveryComplete, isFalse); + expect(module.inputSnapshot!.consistent, isTrue); + expect(await module.inputSnapshot!.isCurrent(), isFalse); + expect(await module.inputSnapshot!.observedInputsCurrent(), isTrue); + final file = File(p.join(root.path, 'topics/home.md')); + await file.writeAsString('# HOME\n'); + expect(await module.inputSnapshot!.observedInputsCurrent(), isFalse); + final recorder = WritersideInputRecorder(root.path); + recorder.source(file.path, 'first'); + recorder.incomplete(); + recorder.source(file.path, 'later'); + expect(recorder.snapshot.consistent, isFalse); + expect(await recorder.snapshot.observedInputsCurrent(), isFalse); + expect(await recorder.snapshot.isCurrent(), isFalse); + }, + ); + + test( + 'correction limited inventory retains raw counts for excluded Git entries', + () async { + final root = await syntheticProject(); + await Directory(p.join(root.path, '.git')).create(); + final listing = await root.list(followLinks: false).toList(); + final recorder = WritersideInputRecorder( + root.path, + treeEntryLimit: listing.length, + ); + recorder.directory(root.path, listing, complete: false); + recorder.incomplete(); + expect( + recorder.snapshot.directories[root.path]!.length, + listing.length - 1, + ); + expect( + recorder.snapshot.incompleteDirectories[root.path]!.count, + listing.length, + ); + expect(await recorder.snapshot.observedInputsCurrent(), isTrue); + expect(await recorder.snapshot.isCurrent(), isFalse); + }, + ); + test('rejected source size remains an input to freshness checking', () async { + final root = await syntheticProject(); + final source = File(p.join(root.path, 'topics/example.txt')); + await source.writeAsString('this is over the limit'); + final recorder = WritersideInputRecorder(root.path); + final rejected = await recorder.observe( + () => const WritersideSourceLoader(maximumBytes: 8).load( + reference: 'example.txt', + documentPath: p.join(root.path, 'topics/home.md'), + workspaceRoot: root.path, + ), + ); + expect(rejected.failure, 'too-large'); + expect(await recorder.snapshot.isCurrent(), isTrue); + await source.writeAsString('valid'); + expect(await recorder.snapshot.isCurrent(), isFalse); + final fresh = await const WritersideSourceLoader(maximumBytes: 8).load( + reference: 'example.txt', + documentPath: p.join(root.path, 'topics/home.md'), + workspaceRoot: root.path, + ); + expect(fresh.text, 'valid'); + }); + test('bounded topic rejection detects recovery on the real worker', () async { + final root = await syntheticProject(); + final source = File(p.join(root.path, 'topics/other.md')); + await source.writeAsString('x' * 513); + const loader = WritersideModuleService( + scanOptions: WorkspaceScanOptions(maxParsedFileBytes: 256), + ); + final rejected = await loader.load(root.path); + expect(rejected.unparsedTopicReferences, contains('other.md')); + expect(await rejected.inputSnapshot!.isCurrent(), isTrue); + await source.writeAsString('# Recovered\n'); + expect(await rejected.inputSnapshot!.isCurrent(), isFalse); + final fresh = await loader.load(root.path); + expect(fresh.topicByReference('other.md')!.title, 'Recovered'); + }); + test('oversized buffer overrides retain their consumed-input hash', () async { + final root = await syntheticProject(); + final path = p.join(root.path, 'topics/other.md'); + const loader = WritersideModuleService( + scanOptions: WorkspaceScanOptions(maxParsedFileBytes: 256), + ); + final rejected = await loader.load( + root.path, + sourceOverrides: {path: 'x' * 513}, + ); + expect(rejected.unparsedTopicReferences, contains('other.md')); + expect(rejected.inputSnapshot!.reads[path]!.override, isTrue); + expect(rejected.inputSnapshot!.reads[path]!.bytes, 513); + expect(await rejected.inputSnapshot!.isCurrent(), isTrue); + expect( + await rejected.inputSnapshot!.matchesDisk(requireDiskSources: true), + isFalse, + ); + }); + test( + 'unreadable sources keep error diagnostics and invalidate on recovery', + () async { + final root = await syntheticProject(); + final source = File(p.join(root.path, 'topics/example.txt')); + await source.writeAsString('readable after recovery'); + final chmod = await Process.run('chmod', ['000', source.path]); + expect(chmod.exitCode, 0); + addTearDown(() async { + await Process.run('chmod', ['600', source.path]); + }); + final recorder = WritersideInputRecorder(root.path); + Future load() => + const WritersideSourceLoader().load( + reference: 'example.txt', + documentPath: p.join(root.path, 'topics/home.md'), + workspaceRoot: root.path, + ); + final rejected = await recorder.observe(load); + expect(rejected.failure, 'missing'); + expect(recorder.snapshot.failedReads, contains(source.path)); + expect(await recorder.snapshot.isCurrent(), isTrue); + final extended = WritersideInputRecorder.fromSnapshot(recorder.snapshot); + expect(await extended.snapshot.isCurrent(), isTrue); + await Process.run('chmod', ['600', source.path]); + expect(await recorder.snapshot.isCurrent(), isFalse); + expect(await extended.snapshot.isCurrent(), isFalse); + expect((await load()).text, 'readable after recovery'); + }, + skip: !Platform.isLinux, + ); + test( + 'real execution transfers results and propagates worker failures', + () async { + const execution = WritersideExecution(); + expect( + await execution.run(_isolateName, 0), + isNot(Isolate.current.debugName), + ); + await expectLater(execution.run(_fail, 0), throwsStateError); + }, + ); + for (final fixture in [ + 'basic_project', + 'conformance_project', + 'markdown_export_compatibility', + ]) { + test( + 'worker preserves full parsed and resolved models: $fixture', + () async { + final root = p.absolute('test/fixtures/writerside/$fixture'); + final foreground = await const WritersideProjectService( + moduleService: WritersideModuleService( + execution: WritersideExecution(useWorker: false), + ), + ).load(root); + final worker = await const WritersideProjectService().load(root); + expect( + projectSemanticSnapshot(worker), + projectSemanticSnapshot(foreground), + ); + expect(await worker.inputsMatchDisk(), isTrue); + }, + ); + } + for (final fault in [ + 'malformed XML', + 'missing configured root', + 'unsafe include', + 'scan limit', + ]) { + test('worker preserves failure and safety diagnostics: $fault', () async { + final root = await syntheticProject(); + switch (fault) { + case 'malformed XML': + await File( + p.join(root.path, 'topics/broken.topic'), + ).writeAsString('

'); + case 'missing configured root': + await File(p.join(root.path, 'writerside.cfg')).writeAsString( + '', + ); + case 'unsafe include': + await File( + p.join(root.path, 'topics/other.md'), + ).writeAsString('# Other\n\n'); + case 'scan limit': + break; + } + final options = fault == 'scan limit' + ? const WorkspaceScanOptions(maxParsedDocuments: 1) + : const WorkspaceScanOptions(); + final foreground = await WritersideModuleService( + execution: const WritersideExecution(useWorker: false), + scanOptions: options, + ).load(root.path); + final worker = await WritersideModuleService( + scanOptions: options, + ).load(root.path); + expect( + worker.diagnostics.map(diagnosticSnapshot).toList(), + foreground.diagnostics.map(diagnosticSnapshot).toList(), + ); + expect( + worker.topics.map((t) => documentSemanticSnapshot(t.document)).toList(), + foreground.topics + .map((t) => documentSemanticSnapshot(t.document)) + .toList(), + ); + expect( + worker.unparsedTopicReferences, + foreground.unparsedTopicReferences, + ); + expect(worker.topicDiscoveryComplete, foreground.topicDiscoveryComplete); + expect(worker.diagnostics, isNotEmpty); + }); + } + test( + 'preparation consumes complete overlays once, then selects with zero loads', + () async { + final root = await syntheticProject(); + final loader = CountingModule(); + final service = WorkspaceService(writersideService: loader); + var workspace = await service.openPath(root.path); + final a = await responsivenessBuffer( + service, + p.join(root.path, 'topics/home.md'), + ); + final b = await responsivenessBuffer( + service, + p.join(root.path, 'topics/other.md'), + ); + final sources = {a.filePath!: a.text, b.filePath!: b.text}; + loader.loads = 0; + workspace = await service.prepareDocument(workspace, b, sources); + expect(loader.loads, 1); + expect(workspace.markdown?.source, b.text); + expect(workspace.writersideProject!.activeInstanceId, 'guide'); + expect(service.documentOutline(workspace, b).first.text, 'Other'); + expect(service.buildDocumentPreview(workspace, b), isNotNull); + loader.loads = 0; + workspace = await service.prepareDocument(workspace, a, sources); + expect(loader.loads, 0); + expect(workspace.markdown?.filePath, a.filePath); + final edited = b.copyWith( + text: '# Dependency edited\n', + dirty: true, + revision: b.revision + 1, + ); + workspace = await service.prepareDocument(workspace, a, { + a.filePath!: a.text, + b.filePath!: edited.text, + }); + expect(loader.loads, 1); + expect( + workspace.writersideModule!.topicByReference('other.md')!.title, + 'Dependency edited', + ); + loader.loads = 0; + workspace = await service.prepareDocument(workspace, a, { + a.filePath!: a.text, + }); + expect(loader.loads, 1); + expect(workspace.sourceOverrides, {a.filePath!: a.text}); + expect( + workspace.writersideModule!.topicByReference('other.md')!.title, + 'Other', + ); + }, + ); + test('reuse checks equal-size disk dependencies and added inputs', () async { + final root = await syntheticProject(); + final loader = CountingModule(); + final service = WorkspaceService(writersideService: loader); + var workspace = await service.openPath(root.path); + final a = await responsivenessBuffer( + service, + p.join(root.path, 'topics/home.md'), + ); + workspace = await service.prepareDocument(workspace, a, { + a.filePath!: a.text, + }); + loader.loads = 0; + await File(p.join(root.path, 'topics/other.md')).writeAsString('# OTHER\n'); + workspace = await service.prepareDocument(workspace, a, { + a.filePath!: a.text, + }); + expect(loader.loads, 1); + expect( + workspace.writersideModule!.topicByReference('other.md')!.title, + 'OTHER', + ); + await File(p.join(root.path, 'topics/added.md')).writeAsString('# Added\n'); + workspace = await service.prepareDocument(workspace, a, { + a.filePath!: a.text, + }); + expect(workspace.writersideModule!.topicByReference('added.md'), isNotNull); + }); + test( + 'planned path exclusions cannot hide a new colon-named dependency', + () async { + final root = await syntheticProject(); + final project = await const WritersideProjectService().load(root.path); + final ignored = p.join(root.path, 'topics/other.md'); + expect(await project.inputsMatchDisk(ignoredPaths: {ignored}), isTrue); + await File( + p.join(root.path, 'topics/other.md:dependent.md'), + ).writeAsString('# Added dependency\n'); + expect(await project.inputsMatchDisk(ignoredPaths: {ignored}), isFalse); + }, + ); + test( + 'configuration overlay rediscovery retains symbols and diagnostics', + () async { + final root = await syntheticProject(); + await Directory(p.join(root.path, 'alternate')).create(); + await File(p.join(root.path, 'alternate/logo.png')).writeAsBytes([0]); + final service = WorkspaceService(writersideService: CountingModule()); + final workspace = await service.openPath(root.path); + final a = await responsivenessBuffer( + service, + p.join(root.path, 'topics/home.md'), + ); + final config = File(p.join(root.path, 'writerside.cfg')); + final text = (await config.readAsString()).replaceFirst( + ' s.kind == WritersideSymbolKind.image) + .map((s) => s.filePath), + contains(p.join(root.path, 'alternate/logo.png')), + ); + final selected = await service.prepareDocument(prepared, a, { + a.filePath!: a.text, + config.path: text, + }); + expect( + selected.writersideProject!.index.symbols.map((s) => s.filePath), + contains(p.join(root.path, 'alternate/logo.png')), + ); + }, + ); + test( + 'creation returns its typed publication result without reopening', + () async { + final root = await syntheticProject(); + final loader = CountingModule(); + final service = WorkspaceService(writersideService: loader); + final workspace = await service.openPath(root.path); + loader.loads = 0; + final result = await service.createWritersideTopic( + workspace, + const WritersideTopicCreateRequest(title: 'New', fileName: 'new.md'), + ); + expect(result, isA()); + expect(result.topicPath, p.join(root.path, 'topics/new.md')); + expect( + loader.loads, + 2, + ); // Current module and pre-publication identity only. + expect(await File(result.topicPath).readAsString(), contains('# New')); + expect( + await File(p.join(root.path, 'guide.tree')).readAsString(), + contains('new.md'), + ); + }, + ); + test( + 'removal application shares one ownership-checked fresh project', + () async { + final root = await syntheticProject(); + final loader = CountingModule(); + final service = WorkspaceService(writersideService: loader); + final workspace = await service.openPath(root.path); + final analysis = await service.analyzeWritersideTopicRemoval( + workspace, + topicPath: p.join(root.path, 'topics/other.md'), + mode: WritersideTopicRemovalMode.safeDeleteFile, + ); + loader.loads = 0; + await service.applyWritersideTopicRemoval( + workspace, + WritersideTopicRemovalRequest( + analysis: analysis, + updateUsagesAutomatically: true, + ), + ); + expect(loader.loads, 1); + expect(await File(analysis.topicPath).exists(), isFalse); + }, + ); + for (final change in [ + 'changed', + 'added', + 'removed', + 'tree', + 'configuration', + ]) { + test( + 'supplied removal model rejects $change input before fingerprint collection', + () async { + final root = await syntheticProject(); + const service = WritersideTopicRemovalService(); + final project = await const WritersideProjectService().load(root.path); + final path = p.join(root.path, 'topics/other.md'); + final analysis = await service.analyze( + project: project, + topicPath: path, + mode: WritersideTopicRemovalMode.safeDeleteFile, + ); + final treeBefore = await File( + p.join(root.path, 'guide.tree'), + ).readAsString(); + switch (change) { + case 'changed': + await File( + p.join(root.path, 'topics/home.md'), + ).writeAsString('# HOME\n'); + case 'added': + await File( + p.join(root.path, 'topics/added.md'), + ).writeAsString('# Added\n[Other](other.md)\n'); + case 'removed': + await File(p.join(root.path, 'topics/home.md')).delete(); + case 'tree': + await File( + p.join(root.path, 'guide.tree'), + ).writeAsString('$treeBefore\n'); + case 'configuration': + await File( + p.join(root.path, 'writerside.cfg'), + ).writeAsString(''); + } + await expectLater( + service.apply( + WritersideTopicRemovalRequest( + analysis: analysis, + updateUsagesAutomatically: true, + ), + project: project, + ), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'writerside.topic-file.tree-changed', + ), + ), + ); + expect(await File(path).exists(), isTrue); + if (change != 'tree') { + expect( + await File(p.join(root.path, 'guide.tree')).readAsString(), + treeBefore, + ); + } + }, + ); + } + for (final change in ['disk dependency', 'dirty affected buffer']) { + test( + 'removal rejects $change after fingerprint verification and before commit', + () async { + final root = await syntheticProject(); + const service = WritersideTopicRemovalService(); + final path = p.join(root.path, 'topics/other.md'); + final tree = File(p.join(root.path, 'guide.tree')); + final before = await tree.readAsString(); + final analysis = await service.analyze( + projectRoot: root.path, + topicPath: path, + mode: WritersideTopicRemovalMode.safeDeleteFile, + ); + var calls = 0; + await expectLater( + service.apply( + WritersideTopicRemovalRequest( + analysis: analysis, + updateUsagesAutomatically: true, + ), + validateBeforeCommit: (paths) { + calls++; + if (calls != 2) return; + if (change == 'dirty affected buffer') { + throw const BusyMarkException( + 'workspace.file-operation-unsaved-changes', + ); + } + File( + p.join(root.path, 'topics/home.md'), + ).writeAsStringSync('# HOME\n'); + }, + ), + throwsA(isA()), + ); + expect(calls, 2); + expect(await File(path).exists(), isTrue); + expect(await tree.readAsString(), before); + }, + ); + } + test('analysis is never reused across confirmation', () async { + final root = await syntheticProject(); + const service = WritersideTopicRemovalService(); + final path = p.join(root.path, 'topics/other.md'); + final analysis = await service.analyze( + projectRoot: root.path, + topicPath: path, + mode: WritersideTopicRemovalMode.safeDeleteFile, + ); + await File( + p.join(root.path, 'topics/home.md'), + ).writeAsString('# Home\n[New usage](other.md)\n'); + await expectLater( + service.apply( + WritersideTopicRemovalRequest( + analysis: analysis, + updateUsagesAutomatically: true, + ), + ), + throwsA(isA()), + ); + expect(await File(path).exists(), isTrue); + }); +} + +String? _isolateName(int _) => Isolate.current.debugName; +Never _fail(int _) => throw StateError('Worker failure'); diff --git a/test/support/writerside_responsiveness.dart b/test/support/writerside_responsiveness.dart new file mode 100644 index 00000000..30158be3 --- /dev/null +++ b/test/support/writerside_responsiveness.dart @@ -0,0 +1,315 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:busymark/src/core/diagnostic.dart'; +import 'package:busymark/src/core/path_utils.dart'; +import 'package:busymark/src/markdown/busymark_document.dart'; +import 'package:busymark/src/workspace/document_buffer.dart'; +import 'package:busymark/src/workspace/workspace_service.dart'; +import 'package:busymark/src/writerside/writerside_document.dart'; +import 'package:busymark/src/writerside/writerside_document_resolver.dart'; +import 'package:busymark/src/writerside/writerside_model.dart'; +import 'package:busymark/src/writerside/writerside_module_service.dart'; +import 'package:busymark/src/writerside/writerside_project.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; + +Future syntheticProject() async { + final root = await Directory.systemTemp.createTemp( + 'busymark-responsiveness-', + ); + addTearDown(() => root.delete(recursive: true)); + await Directory(p.join(root.path, 'topics')).create(); + await File(p.join(root.path, 'writerside.cfg')).writeAsString( + '', + ); + await File(p.join(root.path, 'guide.tree')).writeAsString( + '', + ); + await File(p.join(root.path, 'topics/home.md')).writeAsString('# Home\n'); + await File(p.join(root.path, 'topics/other.md')).writeAsString('# Other\n'); + return root; +} + +/// Root discovery observes a limited part of images; symbol discovery revisits +/// the same directory with a fresh budget and can observe the whole listing. +Future resourceLimitedProject() async { + final root = await syntheticProject(); + await File(p.join(root.path, 'writerside.cfg')).writeAsString( + '' + '', + ); + for (final directory in ['images', 'resources']) { + await Directory(p.join(root.path, directory)).create(); + for (var i = 0; i < 6; i++) { + await File( + p.join(root.path, directory, '$i.png'), + ).writeAsString('fixture'); + } + } + return root; +} + +class CountingModule extends WritersideModuleService { + int loads = 0; + @override + Future load( + String rootPath, { + WorkspaceScanOptions? options, + Map sourceOverrides = const {}, + }) { + loads++; + return super.load( + rootPath, + options: options, + sourceOverrides: sourceOverrides, + ); + } +} + +Future responsivenessBuffer( + WorkspaceService service, + String path, +) async { + final load = await service.loadTextWithSnapshot(path); + return DocumentBuffer.file( + id: path, + filePath: path, + text: load.text, + snapshot: load.snapshot, + format: load.format, + ); +} + +Object diagnosticSnapshot(Diagnostic d) => [ + d.toJson(), + d.sourceSpan?.toJson(), + d.relatedSpans.map((s) => s.toJson()).toList(), +]; +Object _inline(BusyInline n) => [ + n.kind.name, + n.text, + n.destination, + n.attributes, + n.children.map(_inline).toList(), +]; +Object _block(BusyBlock n) => [ + n.id, + n.kind.name, + n.attributes, + n.rawSource, + n.sourceSpan?.toJson(), + n.preserveRaw, + n.isSourceOnly, + n.isGenerated, + n.isSourceProtected, + n.dirty, + n.inlines.map(_inline).toList(), + n.children.map(_block).toList(), +]; +Object _node(WritersideDocumentNode n) => [ + n.runtimeType.toString(), + n.span.toJson(), + n.rawSource, + n.isModified, + n.plainText, + if (n.provenance case final v?) [v.moduleRoot, v.topicPath, v.occurrence], + if (n is WritersideElementNode) + [ + n.name, + n.qualifiedName, + n.attributes, + n.qualifiedAttributes + .map((a) => [a.name, a.qualifiedName, a.value]) + .toList(), + n.attributeSpans.map((k, v) => MapEntry(k, v.toJson())), + n.semanticKind?.name, + n.schemaKnown, + n.children.map(_node).toList(), + ], + if (n is WritersideMarkdownBlockNode) _block(n.block), +]; +Object documentSemanticSnapshot(WritersideDocument d) => [ + d.filePath, + d.source, + d.format.name, + d.isWellFormed, + d.nodes.map(_node).toList(), +]; +Object _toc(TocNode n) => [ + n.topicFileName, + n.referenceTopicFileName, + n.referenceInstanceId, + n.href, + n.tocTitle, + n.id, + n.acceptsWebFileNames, + n.acceptsWebFileNamesRef, + n.targetForAcceptWebFileNames, + n.instanceCondition, + n.customFilter, + n.origin, + n.hidden, + n.workInProgress, + n.span.toJson(), + n.sourceTreePath, + n.sourceTocPath, + n.sourceXmlPath, + n.included, + n.includeFrom, + n.includeElementId, + n.includeResolutionError, + n.children.map(_toc).toList(), +]; +Object projectSemanticSnapshot(WritersideProject project) => jsonDecode( + jsonEncode([ + project.rootPath, + project.activeModuleId, + project.activeInstanceId, + project.moduleDiscoveryComplete, + project.diagnostics.map(diagnosticSnapshot).toList(), + project.index.symbols + .map( + (s) => [ + s.name, + s.qualifiedName, + s.kind.name, + s.moduleId, + s.filePath, + s.span?.toJson(), + s.instanceCondition, + s.scopeSpan?.toJson(), + ], + ) + .toList(), + project.index.references + .map( + (s) => [ + s.value, + s.kind.name, + s.moduleId, + s.filePath, + s.span.toJson(), + s.origin, + s.sourceValue, + s.scopeReference, + s.nullable, + ], + ) + .toList(), + project.modules + .map( + (m) => [ + m.rootPath, + m.sourceOverrides, + m.validatedImageDirs, + m.topicDiscoveryComplete, + m.variablesAvailable, + m.unparsedTopicReferences.toList(), + m.config.topicsDirs, + m.config.imagesDirs, + m.config.apiSpecificationsDir, + m.config.buildConfigDir, + m.config.varsFile, + m.config.categoriesFile, + m.variables + .map( + (v) => [ + v.name, + v.value, + v.instanceCondition, + v.span.toJson(), + ], + ) + .toList(), + m.categories + .map((v) => [v.id, v.name, v.order, v.span.toJson()]) + .toList(), + m.diagnostics.map(diagnosticSnapshot).toList(), + m.instances + .map( + (i) => [ + i.id, + i.name, + i.startPage, + i.status, + i.isLibrary, + i.sourceTreePath, + i.version, + i.globalVersion, + i.webPath, + i.keymapsMode, + i.allowSearchEngineIndexing, + i.offlineArtifact, + i.diagnostics.map(diagnosticSnapshot).toList(), + i.tocRoots.map(_toc).toList(), + i.navigationTocRoots.map(_toc).toList(), + ], + ) + .toList(), + m.sourceFiles.map( + (k, v) => + MapEntry(k, [v.path, v.text, v.failure, v.api?.toJson()]), + ), + m.referenceData.glossary, + m.referenceData.shortcuts, + m.referenceData.layouts, + m.topics.map((t) { + final resolved = const WritersideDocumentResolver().resolve( + t.document, + WritersideResolveContext( + module: m, + topic: t, + instance: m.instances.firstOrNull, + modulesByOrigin: project.modulesByOrigin, + ), + ); + return [ + t.fileName, + t.id, + t.title, + t.webFileName, + t.topicRoot, + t.format.name, + t.semanticElementNames, + t.elementIds.map((e) => [e.id, e.span.toJson()]).toList(), + documentSemanticSnapshot(t.document), + t.markdown?.busyDocument.blocks.map(_block).toList(), + t.markdown?.mode.name, + t.markdown?.headings + .map( + (h) => [ + h.level, + h.text, + h.id, + h.generatedId, + h.span.toJson(), + ], + ) + .toList(), + t.markdown?.links + .map((v) => [v.text, v.destination, v.span.toJson()]) + .toList(), + t.markdown?.images + .map((v) => [v.alt, v.destination, v.span.toJson()]) + .toList(), + t.markdown?.codeBlocks + .map((v) => [v.language, v.content, v.span.toJson()]) + .toList(), + t.markdown?.xmlBlocks + .map((v) => [v.rawXml, v.elementName, v.span.toJson()]) + .toList(), + t.markdown?.variables + .map((v) => [v.name, v.escaped, v.span.toJson()]) + .toList(), + t.markdown?.diagnostics.map(diagnosticSnapshot).toList(), + t.diagnostics.map(diagnosticSnapshot).toList(), + documentSemanticSnapshot(resolved.document), + resolved.title, + resolved.diagnostics.map(diagnosticSnapshot).toList(), + ]; + }).toList(), + ], + ) + .toList(), + ]), +); From 6efe142768a2e9d742bef73fd0af0d0b278a2524 Mon Sep 17 00:00:00 2001 From: Albert G Date: Sat, 3 Oct 2026 23:09:14 -0700 Subject: [PATCH 05/14] Fix/editor view select all missing parts (#39) * Fix WYSIWYG multi-block selection * Complete editor selection lifecycle * Add comprehensive widget tests for WYSIWYG editor session restoration and selection lifecycle * Add test cases for session recreation and reuse across paragraphs, lists, and tables with reverse selections and external history. * Verify ordinary composition matching, immediate Unicode input acceptance, and focus handoff supersession. * Validate scrolled viewport retention and session offset clamping during state restoration. --- .../editor/wysiwyg/wysiwyg_block_widgets.dart | 370 ++- lib/src/editor/wysiwyg/wysiwyg_editor.dart | 615 ++++- test/fixtures/wysiwyg/selection_boundaries.md | 18 + test/src/busymark_document_test.dart | 75 +- .../src/wysiwyg_selection_lifecycle_test.dart | 2294 +++++++++++++++++ test/src/wysiwyg_selection_test.dart | 861 +++++++ test/support/wysiwyg_editable_harness.dart | 131 + tools/wysiwyg_selection_smoke.dart | 1695 ++++++++++++ 8 files changed, 5776 insertions(+), 283 deletions(-) create mode 100644 test/fixtures/wysiwyg/selection_boundaries.md create mode 100644 test/src/wysiwyg_selection_lifecycle_test.dart create mode 100644 test/src/wysiwyg_selection_test.dart create mode 100644 test/support/wysiwyg_editable_harness.dart create mode 100644 tools/wysiwyg_selection_smoke.dart diff --git a/lib/src/editor/wysiwyg/wysiwyg_block_widgets.dart b/lib/src/editor/wysiwyg/wysiwyg_block_widgets.dart index a12447f5..96ac7f8f 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_block_widgets.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_block_widgets.dart @@ -177,9 +177,11 @@ class BusyMarkWysiwygBlockField extends StatelessWidget { this.selected = false, this.selectionRange, this.documentSelectionActive = false, + this.documentSelectionDragging = false, this.onPointerDown, this.onPointerMove, this.onPointerUp, + this.onPointerCancel, this.tableCellController, this.tableCellUndoController, this.tableCellFocusNode, @@ -233,9 +235,11 @@ class BusyMarkWysiwygBlockField extends StatelessWidget { final bool selected; final BusyMarkWysiwygSelectionRange? selectionRange; final bool documentSelectionActive; + final bool documentSelectionDragging; final ValueChanged? onPointerDown; final ValueChanged? onPointerMove; final ValueChanged? onPointerUp; + final ValueChanged? onPointerCancel; final TextEditingController Function(BusyBlock cell)? tableCellController; final UndoHistoryController Function(BusyBlock cell)? tableCellUndoController; final FocusNode Function(BusyBlock cell)? tableCellFocusNode; @@ -287,6 +291,7 @@ class BusyMarkWysiwygBlockField extends StatelessWidget { onPointerDown: onPointerDown, onPointerMove: onPointerMove, onPointerUp: onPointerUp, + onPointerCancel: onPointerCancel, child: visualization != null ? BusyMarkVisualizationCard( key: ValueKey('wysiwyg-visualization-${block.id}'), @@ -397,6 +402,8 @@ class BusyMarkWysiwygBlockField extends StatelessWidget { textDirection: textDirection, child: _TableBlockEditor( block: block, + documentSelected: selectionRange != null, + documentSelectionDragging: documentSelectionDragging, onFocused: onFocused, onCellChanged: onTableCellChanged, onCellSourceChanged: onTableCellSourceChanged, @@ -513,39 +520,9 @@ class BusyMarkWysiwygBlockField extends StatelessWidget { ) : Stack( children: [ - Positioned.fill( - child: IgnorePointer( - child: CustomPaint( - painter: _WysiwygSelectionPainter( - text: controller.text, - style: style, - selectionRange: selectionRange, - color: - DefaultSelectionStyle.of( - context, - ).selectionColor ?? - Theme.of( - context, - ).colorScheme.primary.withValues( - alpha: BusyMarkDocumentTextGeometry - .fallbackSelectionAlpha, - ), - textDirection: textDirection, - textScaler: MediaQuery.textScalerOf(context), - locale: Localizations.maybeLocaleOf(context), - layoutWidthInset: BusyMarkDocumentTextGeometry - .editableLayoutInset, - ), - ), - ), - ), - TextSelectionTheme( - data: selectionRange == null - ? Theme.of(context).textSelectionTheme - : Theme.of(context).textSelectionTheme.copyWith( - selectionColor: - BusyMarkLinuxPalette.transparent, - ), + _WysiwygSelectionOverlay( + controller: controller, + selectionRange: selectionRange, child: KeyedSubtree( key: spellingEditableKey, child: TextField( @@ -555,6 +532,8 @@ class BusyMarkWysiwygBlockField extends StatelessWidget { controller: controller, undoController: undoController, focusNode: focusNode, + enableInteractiveSelection: + !documentSelectionDragging, maxLines: null, minLines: 1, textDirection: textDirection, @@ -1502,6 +1481,8 @@ String _directionalText(BusyBlock block) { class _TableBlockEditor extends StatefulWidget { const _TableBlockEditor({ required this.block, + required this.documentSelected, + required this.documentSelectionDragging, required this.onFocused, required this.onCellChanged, required this.onCellSourceChanged, @@ -1532,6 +1513,8 @@ class _TableBlockEditor extends StatefulWidget { }); final BusyBlock block; + final bool documentSelected; + final bool documentSelectionDragging; final VoidCallback onFocused; final void Function(String cellId, String text) onCellChanged; final void Function(String cellId, String source) onCellSourceChanged; @@ -1704,6 +1687,8 @@ class _TableBlockEditorState extends State<_TableBlockEditor> { onEnter: (_) => _activateCell(rowIndex, column), child: _TableCellEditor( cell: cell, + documentSelected: widget.documentSelected, + documentSelectionDragging: widget.documentSelectionDragging, header: _isHeaderRow(row, rowIndex), style: busyMarkDocumentBodyTextStyle(context), onFocused: widget.onFocused, @@ -2091,6 +2076,8 @@ class _TableControlMenuButton extends StatelessWidget { class _TableCellEditor extends StatefulWidget { const _TableCellEditor({ required this.cell, + required this.documentSelected, + required this.documentSelectionDragging, required this.header, required this.style, required this.onFocused, @@ -2118,6 +2105,8 @@ class _TableCellEditor extends StatefulWidget { }); final BusyBlock? cell; + final bool documentSelected; + final bool documentSelectionDragging; final bool header; final TextStyle style; final VoidCallback onFocused; @@ -2294,64 +2283,77 @@ class _TableCellEditorState extends State<_TableCellEditor> { children: [ KeyedSubtree( key: widget.spellingEditableKey, - child: TextField( - key: ValueKey(cell.id), + child: _WysiwygSelectionOverlay( 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, + selectionRange: widget.documentSelected + ? BusyMarkWysiwygSelectionRange( + start: 0, + end: _controller.text.length, + ) + : null, + child: TextField( + key: ValueKey(cell.id), + controller: _controller, + focusNode: _focusNode, + enableInteractiveSelection: !widget.documentSelectionDragging, + 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, + ), + contextMenuBuilder: widget.suppressContextMenu + ? (context, editableTextState) => const SizedBox.shrink() + : (context, editableTextState) => + buildBusyMarkEditorTextContextMenu( + context, + editableTextState, + refineWithAiLabel: context.l10n.aiRefineWithAi, + onCut: widget.onCut, + onCopy: widget.onCopy, + onPaste: widget.onPaste, + onPastePlainText: widget.onPastePlainText, + readPasteAvailability: + widget.readPasteAvailability, + onCopyPlainText: widget.onCopyPlainText, + readSpellingItems: widget.spellingMenuReader, + onCheckSpelling: widget.onCheckSpelling, + ), + 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); + } + }, ), - contextMenuBuilder: widget.suppressContextMenu - ? (context, editableTextState) => const SizedBox.shrink() - : (context, editableTextState) => - buildBusyMarkEditorTextContextMenu( - context, - editableTextState, - refineWithAiLabel: context.l10n.aiRefineWithAi, - onCut: widget.onCut, - onCopy: widget.onCopy, - onPaste: widget.onPaste, - onPastePlainText: widget.onPastePlainText, - readPasteAvailability: widget.readPasteAvailability, - onCopyPlainText: widget.onCopyPlainText, - readSpellingItems: widget.spellingMenuReader, - onCheckSpelling: widget.onCheckSpelling, - ), - 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); - } - }, ), ), if (widget.spellingRanges.isNotEmpty && @@ -2605,72 +2607,166 @@ class _SingleLineTableCellFormatter extends TextInputFormatter { } } +/// Paints document selections using the field's actual rich layout. Keeping a +/// second plain-text layout here loses styled-run widths, wrapping and scroll +/// offsets. The foreground layer also keeps opaque inline-code backgrounds +/// from hiding selected text. +class _WysiwygSelectionOverlay extends StatefulWidget { + const _WysiwygSelectionOverlay({ + required this.controller, + required this.selectionRange, + required this.child, + }); + + final TextEditingController controller; + final BusyMarkWysiwygSelectionRange? selectionRange; + final Widget child; + + @override + State<_WysiwygSelectionOverlay> createState() => + _WysiwygSelectionOverlayState(); +} + +class _WysiwygSelectionOverlayState extends State<_WysiwygSelectionOverlay> { + final _editableKey = GlobalKey(); + final _overlayKey = GlobalKey(); + final _repaint = ValueNotifier(0); + ViewportOffset? _editableOffset; + + @override + void initState() { + super.initState(); + widget.controller.addListener(_requestRepaint); + _scheduleOffsetBinding(); + } + + @override + void didUpdateWidget(covariant _WysiwygSelectionOverlay oldWidget) { + super.didUpdateWidget(oldWidget); + if (!identical(oldWidget.controller, widget.controller)) { + oldWidget.controller.removeListener(_requestRepaint); + widget.controller.addListener(_requestRepaint); + } + _scheduleOffsetBinding(); + } + + void _scheduleOffsetBinding() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + final offset = _findRenderEditable( + _editableKey.currentContext?.findRenderObject(), + )?.offset; + if (identical(offset, _editableOffset)) return; + _editableOffset?.removeListener(_requestRepaint); + _editableOffset = offset; + _editableOffset?.addListener(_requestRepaint); + _requestRepaint(); + }); + } + + void _requestRepaint() => _repaint.value++; + + @override + void dispose() { + widget.controller.removeListener(_requestRepaint); + _editableOffset?.removeListener(_requestRepaint); + _repaint.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context).textSelectionTheme; + return Stack( + children: [ + TextSelectionTheme( + data: widget.selectionRange == null + ? theme + : theme.copyWith( + selectionColor: BusyMarkLinuxPalette.transparent, + ), + child: KeyedSubtree(key: _editableKey, child: widget.child), + ), + Positioned.fill( + child: IgnorePointer( + child: CustomPaint( + key: _overlayKey, + painter: _WysiwygSelectionPainter( + editableKey: _editableKey, + overlayKey: _overlayKey, + selectionRange: widget.selectionRange, + color: + DefaultSelectionStyle.of(context).selectionColor ?? + Theme.of(context).colorScheme.primary.withValues( + alpha: + BusyMarkDocumentTextGeometry.fallbackSelectionAlpha, + ), + repaint: _repaint, + ), + ), + ), + ), + ], + ); + } +} + class _WysiwygSelectionPainter extends CustomPainter { - const _WysiwygSelectionPainter({ - required this.text, - required this.style, + _WysiwygSelectionPainter({ + required this.editableKey, + required this.overlayKey, required this.selectionRange, required this.color, - required this.textDirection, - required this.textScaler, - required this.locale, - required this.layoutWidthInset, - }); + required Listenable repaint, + }) : super(repaint: repaint); - final String text; - final TextStyle style; + final GlobalKey editableKey; + final GlobalKey overlayKey; final BusyMarkWysiwygSelectionRange? selectionRange; final Color color; - final TextDirection textDirection; - final TextScaler textScaler; - final Locale? locale; - final double layoutWidthInset; @override void paint(Canvas canvas, Size size) { final range = selectionRange; - if (range == null || text.isEmpty || size.width <= 0) { - return; - } - final start = range.start.clamp(0, text.length).toInt(); - final end = range.end.clamp(0, text.length).toInt(); - if (end <= start) { - return; - } - final maxWidth = size.width - layoutWidthInset; - if (maxWidth <= 0) { - return; - } - final textPainter = TextPainter( - text: TextSpan(text: text, style: style), - strutStyle: StrutStyle.fromTextStyle(style), - textDirection: textDirection, - textScaler: textScaler, - locale: locale, - )..layout(maxWidth: maxWidth); - final boxes = textPainter.getBoxesForSelection( + if (range == null) return; + final editable = _findRenderEditable( + editableKey.currentContext?.findRenderObject(), + ); + final overlay = overlayKey.currentContext?.findRenderObject(); + if (editable == null || overlay is! RenderBox || !overlay.hasSize) return; + final length = editable.text?.toPlainText().length ?? 0; + final start = range.start.clamp(0, length).toInt(); + final end = range.end.clamp(0, length).toInt(); + if (end <= start) return; + final boxes = editable.getBoxesForSelection( TextSelection(baseOffset: start, extentOffset: end), - boxHeightStyle: BusyMarkDocumentTextGeometry.selectionHeightStyle, - boxWidthStyle: BusyMarkDocumentTextGeometry.selectionWidthStyle, ); final paint = Paint()..color = color; + canvas.save(); + canvas.clipRect(Offset.zero & size); for (final box in boxes) { - canvas.drawRect(box.toRect(), paint); + canvas.drawRect( + Rect.fromPoints( + overlay.globalToLocal( + editable.localToGlobal(Offset(box.left, box.top)), + ), + overlay.globalToLocal( + editable.localToGlobal(Offset(box.right, box.bottom)), + ), + ), + paint, + ); } + canvas.restore(); } @override - bool shouldRepaint(covariant _WysiwygSelectionPainter oldDelegate) { - return oldDelegate.text != text || - oldDelegate.style != style || - oldDelegate.selectionRange?.start != selectionRange?.start || - oldDelegate.selectionRange?.end != selectionRange?.end || - oldDelegate.color != color || - oldDelegate.textDirection != textDirection || - oldDelegate.textScaler != textScaler || - oldDelegate.locale != locale || - oldDelegate.layoutWidthInset != layoutWidthInset; - } + bool shouldRepaint(covariant _WysiwygSelectionPainter oldDelegate) => + oldDelegate.editableKey != editableKey || + oldDelegate.overlayKey != overlayKey || + oldDelegate.selectionRange?.start != selectionRange?.start || + oldDelegate.selectionRange?.end != selectionRange?.end || + oldDelegate.color != color; } class _ImageBlockEditor extends StatelessWidget { diff --git a/lib/src/editor/wysiwyg/wysiwyg_editor.dart b/lib/src/editor/wysiwyg/wysiwyg_editor.dart index 1f555946..fe0b365c 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_editor.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_editor.dart @@ -214,7 +214,13 @@ class BusyMarkWysiwygEditorState extends State { int _undoGroupSequence = 0; _ContinuousTextEdit? _continuousTextEdit; _DocumentTextSelection? _documentSelection; + _DocumentTextSelection? _documentInputSelection; + TextEditingValue _documentInputValue = TextEditingValue.empty; _DocumentTextPosition? _pointerSelectionAnchor; + String? _pointerSelectionCellId; + int? _selectionPointer; + int _selectionGestureGeneration = 0; + String? _cellSelectAllTarget; int? _documentSelectionContextPointer; _DocumentTextSelection? _documentSelectionContextSnapshot; VerticalCaretMovementRun? _verticalCaretMovement; @@ -533,6 +539,8 @@ class BusyMarkWysiwygEditorState extends State { final fileChanged = oldDocumentId != _documentId; final sourceChanged = oldWidget.document.source != widget.document.source; if (fileChanged || (sourceChanged && !_internalChange)) { + _releaseSelectionDrag(rebuild: false); + _pendingTextFocus = null; _hasReportedVisibleHeading = false; if (fileChanged) { _reportSessionNow( @@ -544,6 +552,7 @@ class BusyMarkWysiwygEditorState extends State { _internalChange = false; _resetPerDocumentState(); } + _documentSelection = null; _documentController.replaceDocument(widget.document); _initialFocusScheduled = false; _scheduleSessionRestore(widget.initialSessionState); @@ -561,6 +570,8 @@ class BusyMarkWysiwygEditorState extends State { @override void dispose() { + _releaseSelectionDrag(rebuild: false); + _pendingTextFocus = null; widget.clipboardInsertionRegistry?.unregister(_historyInsertionTarget); unawaited(_assetDropSubscription?.cancel()); _itemPositionsListener.itemPositions.removeListener( @@ -618,7 +629,8 @@ class BusyMarkWysiwygEditorState extends State { _collapsibleExpansion.clear(); _continuousTextEdit = null; _documentSelection = null; - _pointerSelectionAnchor = null; + _releaseSelectionDrag(rebuild: false); + _cellSelectAllTarget = null; _resetVerticalCaretMovement(); } @@ -840,6 +852,7 @@ class BusyMarkWysiwygEditorState extends State { child: GestureDetector( behavior: HitTestBehavior.translucent, onTap: () { + if (_selectionPointer != null) return; _clearBlockSelection(); _focusActiveOrFirstBlock(); }, @@ -865,6 +878,30 @@ class BusyMarkWysiwygEditorState extends State { ), ), ), + if (_documentSelection case final selection? + when _selectionPointer == null && + _documentController + .blockById(selection.extent.blockId) + ?.kind == + BusyBlockKind.table) + Positioned( + left: 0, + top: 0, + width: 1, + height: 1, + child: ExcludeSemantics( + child: IgnorePointer( + child: _DocumentSelectionTextInput( + key: ObjectKey(selection), + isCurrent: _documentSelectionInputGuard(selection), + onKeyEvent: _handleDocumentSelectionKeyEvent, + onEditingValue: _documentSelectionInputHandler( + selection, + ), + ), + ), + ), + ), _FloatingWysiwygToolbar( placement: widget.toolbarPlacement, direction: widget.toolbarDirection, @@ -1192,9 +1229,14 @@ class BusyMarkWysiwygEditorState extends State { selected: selectedBlockIds.contains(block.id), selectionRange: selectionRangesByBlockId[block.id], documentSelectionActive: _hasBlockSelection, + // The originating field must stop its local drag once the document owns + // it; otherwise EditableText refocuses and scrolls that field's caret. + documentSelectionDragging: + _hasBlockSelection && _pointerSelectionAnchor != null, onPointerDown: (event) => _handleBlockPointerDown(block.id, event), onPointerMove: _handleBlockPointerMove, onPointerUp: _handleBlockPointerUp, + onPointerCancel: _handleBlockPointerCancel, onFocused: () => _handleBlockFocused(block.id), onCut: _cutCurrentSelection, onCopy: _copyCurrentSelection, @@ -1432,6 +1474,7 @@ class BusyMarkWysiwygEditorState extends State { String targetId, TextEditingController controller, ) { + if (_cellSelectAllTarget == targetId) _cellSelectAllTarget = null; _scheduleSessionReport(); final continuous = _continuousTextEdit; if (continuous == null || continuous.targetId != targetId) { @@ -1677,6 +1720,16 @@ class BusyMarkWysiwygEditorState extends State { } void _handleTableCellFocused(String tableBlockId, String cellId) { + if (_selectionPointer != null && _hasBlockSelection) { + _selectionFocusNode.requestFocus(); + return; + } + if (_preserveSelectionFocusCallbacks > 0 && _hasBlockSelection) { + _preserveSelectionFocusCallbacks--; + _setActiveBlock(tableBlockId); + return; + } + _cellSelectAllTarget = null; _clearBlockSelection(); _collapseFieldSelections(exceptBlockId: cellId); _setActiveTableCell(tableBlockId, cellId); @@ -1686,12 +1739,55 @@ class BusyMarkWysiwygEditorState extends State { FocusNode node, KeyEvent event, ) { + final key = event.logicalKey; + if (_hasDocumentInputComposition && + (key == LogicalKeyboardKey.backspace || + key == LogicalKeyboardKey.delete || + key == LogicalKeyboardKey.enter || + key == LogicalKeyboardKey.arrowLeft || + key == LogicalKeyboardKey.arrowRight || + key == LogicalKeyboardKey.arrowUp || + key == LogicalKeyboardKey.arrowDown || + key == LogicalKeyboardKey.home || + key == LogicalKeyboardKey.end)) { + // Leave composition editing to the platform IME, including its commit + // key. Neither document commands nor Flutter shortcuts may replace it. + return KeyEventResult.skipRemainingHandlers; + } final blockId = _documentSelection?.extent.blockId ?? _activeBlockId; return blockId == null ? KeyEventResult.ignored : _handleBlockKeyEvent(blockId, event); } + bool Function(TextEditingValue) _documentSelectionInputHandler( + _DocumentTextSelection selection, + ) { + final isCurrent = _documentSelectionInputGuard(selection); + return (value) { + if (!isCurrent()) return false; + _documentInputSelection = selection; + _documentInputValue = value; + if (_hasComposingValue(value) || value.text.isEmpty) return true; + final accepted = _replaceDocumentSelectionWithText(value.text); + if (!accepted) _documentInputValue = TextEditingValue.empty; + return accepted; + }; + } + + bool Function() _documentSelectionInputGuard( + _DocumentTextSelection selection, + ) { + final generation = _documentGeneration; + final documentId = _documentId; + return () => + mounted && + generation == _documentGeneration && + documentId == _documentId && + identical(selection, _documentSelection) && + _selectionPointer == null; + } + BusyDocument _historySnapshot() { final markdown = _documentController.markdown; return _documentController.document.copyWith( @@ -1782,6 +1878,10 @@ class BusyMarkWysiwygEditorState extends State { } void _handleBlockFocused(String blockId) { + if (_selectionPointer != null && _hasBlockSelection) { + _selectionFocusNode.requestFocus(); + return; + } if (_preserveSelectionFocusCallbacks > 0 && _hasBlockSelection) { _preserveSelectionFocusCallbacks--; _setActiveBlock(blockId); @@ -1806,9 +1906,13 @@ class BusyMarkWysiwygEditorState extends State { selectedDocumentText.extent.blockId == blockId) { final oldText = _documentController.blockText(blockId); final replacement = _replacementTextForFieldEdit(oldText, value); - if (_replaceDocumentSelectionWithText(replacement)) { - return; + if (!_replaceDocumentSelectionWithText(replacement)) { + // A rejected document edit must not become a local field edit. + _textControllers[blockId]?.updateFromBlock( + _documentController.blockById(blockId)!, + ); } + return; } _clearBlockSelection(); _setActiveBlock(blockId); @@ -2115,8 +2219,9 @@ class BusyMarkWysiwygEditorState extends State { return; } _initialFocusScheduled = true; + final generation = _documentGeneration; WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) { + if (!mounted || generation != _documentGeneration) { return; } final firstPlainBlock = _focusableBlocks() @@ -2138,10 +2243,15 @@ class BusyMarkWysiwygEditorState extends State { return; } _initialFocusScheduled = true; + final generation = _documentGeneration; + final documentId = _documentId; WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) { + if (!mounted || + generation != _documentGeneration || + documentId != _documentId) { return; } + _documentSelection = null; final activeBlock = session.activeBlockId == null ? null : _documentController.blockById(session.activeBlockId!); @@ -2158,7 +2268,8 @@ class BusyMarkWysiwygEditorState extends State { final anchor = _documentController.blockById(anchorBlockId); final extent = _documentController.blockById(extentBlockId); if (anchor != null && extent != null) { - if (anchorBlockId == extentBlockId) { + if (anchorBlockId == extentBlockId && + anchor.kind != BusyBlockKind.table) { final controller = _tableCellControllers[anchorBlockId] ?? _textControllers[anchorBlockId]; @@ -2173,20 +2284,24 @@ class BusyMarkWysiwygEditorState extends State { ); } } else { - _documentSelection = _DocumentTextSelection( - anchor: _DocumentTextPosition( + final start = _clampDocumentPosition( + _DocumentTextPosition( blockId: anchorBlockId, - offset: session.anchorOffset - .clamp(0, anchor.plainText.length) - .toInt(), + offset: session.anchorOffset, ), - extent: _DocumentTextPosition( + ); + final end = _clampDocumentPosition( + _DocumentTextPosition( blockId: extentBlockId, - offset: session.extentOffset - .clamp(0, extent.plainText.length) - .toInt(), + offset: session.extentOffset, ), ); + if (start != null && end != null) { + _documentSelection = _DocumentTextSelection( + anchor: start, + extent: end, + ); + } } } } @@ -2210,7 +2325,16 @@ class BusyMarkWysiwygEditorState extends State { _jumpToBlock(viewportBlockId, alignment: alignment); } final activeCellId = _activeCellId; - if (activeCellId != null) { + if (_hasBlockSelection) { + _collapseFieldSelections(); + // Keep command focus until the restored extent's editable field or + // table input client is mounted, preserving the document range. + _selectionFocusNode.requestFocus(); + _activeBlockId = _documentSelection!.extent.blockId; + _activeCellId = null; + _scheduleDocumentSelectionExtentFocus(); + _scheduleSessionReport(); + } else if (activeCellId != null) { final controller = _tableCellControllers[activeCellId]; final focusNode = _tableCellFocusNodes[activeCellId]; if (controller != null && focusNode != null) { @@ -2751,11 +2875,32 @@ class BusyMarkWysiwygEditorState extends State { void _focusActiveOrFirstBlock({int? initialSelectionOffset}) { final blocks = _focusableBlocks(); + final activeBlock = _activeBlockId == null + ? null + : _documentController.blockById(_activeBlockId!); + if (activeBlock?.kind == BusyBlockKind.table || blocks.isEmpty) { + final cells = _tableCellEntries(_documentController.document.blocks); + final cell = + cells.where((entry) => entry.cell.id == _activeCellId).firstOrNull ?? + cells + .where((entry) => entry.table.id == activeBlock?.id) + .firstOrNull ?? + (blocks.isEmpty ? cells.firstOrNull : null); + if (cell != null) { + final selection = _tableCellControllers[cell.cell.id]?.selection; + _focusTextTargetAfterFrame( + cell.cell.id, + offset: + initialSelectionOffset ?? + (selection?.isValid == true ? selection!.extentOffset : 0), + ); + return; + } + } if (blocks.isEmpty) { return; } - final blockId = - _activeBlockId != null && _focusNodes[_activeBlockId] != null + final blockId = blocks.any((block) => block.id == _activeBlockId) ? _activeBlockId! : blocks.first.id; final focusNode = _focusNodes[blockId]; @@ -2928,6 +3073,9 @@ class BusyMarkWysiwygEditorState extends State { !keyboard.isMetaPressed && (key == LogicalKeyboardKey.arrowLeft || key == LogicalKeyboardKey.arrowRight)) { + if (_hasBlockSelection && !keyboard.isShiftPressed) { + return _collapseDocumentSelectionForArrow(key); + } final boundaryResult = keyboard.isShiftPressed ? _extendKeyboardSelectionByWord(blockId, key) : _moveWordCaretAcrossBlockBoundary(blockId, key); @@ -2940,19 +3088,14 @@ class BusyMarkWysiwygEditorState extends State { } if ((key == LogicalKeyboardKey.backspace || key == LogicalKeyboardKey.delete) && - _deleteBlockSelection()) { + _hasBlockSelection) { + _deleteBlockSelection(); return KeyEventResult.handled; } - if (key == LogicalKeyboardKey.enter && - _hasBlockSelection && - _replaceDocumentSelectionWithText('\n')) { + if (key == LogicalKeyboardKey.enter && _hasBlockSelection) { + _replaceDocumentSelectionWithText('\n'); return KeyEventResult.handled; } - final controller = _textControllers[blockId]; - if (controller == null || !controller.selection.isValid) { - return KeyEventResult.ignored; - } - final selection = controller.selection; final shiftPressed = HardwareKeyboard.instance.isShiftPressed; final isArrowKey = key == LogicalKeyboardKey.arrowUp || @@ -2971,6 +3114,11 @@ class BusyMarkWysiwygEditorState extends State { if (!shiftPressed && isArrowKey && _hasBlockSelection) { return _collapseDocumentSelectionForArrow(key); } + final controller = _textControllers[blockId]; + if (controller == null || !controller.selection.isValid) { + return KeyEventResult.ignored; + } + final selection = controller.selection; if (!selection.isCollapsed) { if (key == LogicalKeyboardKey.enter && !shiftPressed) { final start = math @@ -3257,6 +3405,8 @@ class BusyMarkWysiwygEditorState extends State { _resetVerticalCaretMovement(); _applyKeyboardSelection( _DocumentTextSelection(anchor: target, extent: target), + collapse: true, + towardStart: target.offset == 0, ); return KeyEventResult.handled; } @@ -3298,12 +3448,12 @@ class BusyMarkWysiwygEditorState extends State { final extent = selection.extent; final _DocumentTextPosition? target; if (key == LogicalKeyboardKey.arrowUp) { - target = _verticalCaretTarget(extent, -1); + target = _verticalCaretTarget(extent, -1, selecting: true); } else if (key == LogicalKeyboardKey.arrowDown) { - target = _verticalCaretTarget(extent, 1); + target = _verticalCaretTarget(extent, 1, selecting: true); } else if (key == LogicalKeyboardKey.arrowLeft || key == LogicalKeyboardKey.arrowRight) { - target = _horizontalCaretTarget(extent, key); + target = _horizontalCaretTarget(extent, key, selecting: true); } else { target = null; } @@ -3360,8 +3510,9 @@ class BusyMarkWysiwygEditorState extends State { _DocumentTextPosition? _horizontalCaretTarget( _DocumentTextPosition position, - LogicalKeyboardKey key, - ) { + LogicalKeyboardKey key, { + bool selecting = false, + }) { final block = _documentController.blockById(position.blockId); final controller = _textControllers[position.blockId]; if (block == null || controller == null) { @@ -3391,17 +3542,17 @@ class BusyMarkWysiwygEditorState extends State { offset: boundary.getLeadingTextBoundaryAt(offset - 1) ?? 0, ); } - final nextBlock = _relativeFocusableBlock( - position.blockId, - forward ? 1 : -1, - ); + final nextBlock = selecting + ? _relativeSelectionBlock(position.blockId, forward ? 1 : -1) + : _relativeFocusableBlock(position.blockId, forward ? 1 : -1); if (nextBlock == null) { return position; } - final nextController = _textControllerFor(nextBlock); return _DocumentTextPosition( blockId: nextBlock.id, - offset: forward ? 0 : nextController.text.length, + offset: forward || nextBlock.kind == BusyBlockKind.table + ? 0 + : _textControllerFor(nextBlock).text.length, ); } @@ -3439,17 +3590,18 @@ class BusyMarkWysiwygEditorState extends State { : wordBoundary.getLeadingTextBoundaryAt(offset - 1) ?? 0, ); } - final nextBlock = _relativeFocusableBlock( + final nextBlock = _relativeSelectionBlock( position.blockId, forward ? 1 : -1, ); if (nextBlock == null) { return position; } - final nextController = _textControllerFor(nextBlock); return _DocumentTextPosition( blockId: nextBlock.id, - offset: forward ? 0 : nextController.text.length, + offset: forward || nextBlock.kind == BusyBlockKind.table + ? 0 + : _textControllerFor(nextBlock).text.length, ); } @@ -3478,8 +3630,19 @@ class BusyMarkWysiwygEditorState extends State { _DocumentTextPosition? _verticalCaretTarget( _DocumentTextPosition position, - int direction, - ) { + int direction, { + bool selecting = false, + }) { + final block = _documentController.blockById(position.blockId); + if (block?.kind == BusyBlockKind.table) { + final next = _relativeSelectionBlock(position.blockId, direction); + return next == null + ? position + : _DocumentTextPosition( + blockId: next.id, + offset: direction < 0 ? next.plainText.length : 0, + ); + } final controller = _textControllers[position.blockId]; if (controller == null) { return null; @@ -3518,7 +3681,9 @@ class BusyMarkWysiwygEditorState extends State { } } - final nextBlock = _relativeFocusableBlock(position.blockId, direction); + final nextBlock = selecting + ? _relativeSelectionBlock(position.blockId, direction) + : _relativeFocusableBlock(position.blockId, direction); if (nextBlock == null) { final boundaryOffset = direction < 0 ? 0 : controller.text.length; final target = _DocumentTextPosition( @@ -3528,6 +3693,10 @@ class BusyMarkWysiwygEditorState extends State { _verticalCaretMovementPosition = TextPosition(offset: boundaryOffset); return target; } + if (nextBlock.kind == BusyBlockKind.table) { + _resetVerticalCaretMovement(); + return _DocumentTextPosition(blockId: nextBlock.id, offset: 0); + } final nextController = _textControllerFor(nextBlock); final nextRenderEditable = _renderEditableForBlock(nextBlock.id); var targetPosition = TextPosition( @@ -3574,6 +3743,20 @@ class BusyMarkWysiwygEditorState extends State { : blocks[targetIndex]; } + BusyBlock? _relativeSelectionBlock(String blockId, int direction) { + final blocks = _editableBlocks(_documentController.document.blocks) + .where( + (block) => + _isFocusableTextBlock(block) || block.kind == BusyBlockKind.table, + ) + .toList(); + final index = blocks.indexWhere((block) => block.id == blockId); + final target = index + direction; + return index < 0 || target < 0 || target >= blocks.length + ? null + : blocks[target]; + } + RenderEditable? _renderEditableForBlock(String blockId) { final renderObject = _blockKeys[blockId]?.currentContext ?.findRenderObject(); @@ -3591,7 +3774,11 @@ class BusyMarkWysiwygEditorState extends State { return result; } - void _applyKeyboardSelection(_DocumentTextSelection selection) { + void _applyKeyboardSelection( + _DocumentTextSelection selection, { + bool collapse = false, + bool towardStart = true, + }) { final anchor = _clampDocumentPosition(selection.anchor); final extent = _clampDocumentPosition(selection.extent); if (anchor == null || extent == null) { @@ -3605,10 +3792,23 @@ class BusyMarkWysiwygEditorState extends State { if (extentBlock == null) { return; } - final controller = _textControllerFor(extentBlock); - final focusNode = _focusNodeFor(extentBlock); _activeBlockId = extent.blockId; - if (anchor.blockId == extent.blockId) { + if (collapse && extentBlock.kind == BusyBlockKind.table) { + _clearBlockSelection(); + final cells = [for (final row in extentBlock.children) ...row.children]; + final cell = towardStart ? cells.firstOrNull : cells.lastOrNull; + if (cell != null) { + _focusTextTargetAfterFrame( + cell.id, + offset: towardStart ? 0 : cell.plainText.length, + ); + } + return; + } + if (anchor.blockId == extent.blockId && + extentBlock.kind != BusyBlockKind.table) { + final controller = _textControllerFor(extentBlock); + final focusNode = _focusNodeFor(extentBlock); if (_documentSelection != null) { setState(() => _documentSelection = null); } @@ -3622,26 +3822,38 @@ class BusyMarkWysiwygEditorState extends State { return; } setState(() => _documentSelection = nextSelection); - if (!_selectionFocusNode.hasPrimaryFocus) { - focusNode.requestFocus(); + if (extentBlock.kind == BusyBlockKind.table) { + _focusDocumentSelectionExtent(); + } else { + final controller = _textControllerFor(extentBlock); + final focusNode = _focusNodeFor(extentBlock); + if (!_selectionFocusNode.hasPrimaryFocus) { + focusNode.requestFocus(); + } + controller.selection = TextSelection.collapsed( + offset: extent.offset, + affinity: extent.affinity, + ); + _collapseInactiveFieldSelections(extent.blockId); } - controller.selection = TextSelection.collapsed( - offset: extent.offset, - affinity: extent.affinity, - ); - _collapseInactiveFieldSelections(extent.blockId); } _DocumentTextPosition? _clampDocumentPosition( _DocumentTextPosition position, ) { - final controller = _textControllers[position.blockId]; - if (controller == null) { + final block = _editableBlocks( + _documentController.document.blocks, + ).where((block) => block.id == position.blockId).firstOrNull; + if (block == null) { return null; } return _DocumentTextPosition( blockId: position.blockId, - offset: position.offset.clamp(0, controller.text.length).toInt(), + offset: block.kind == BusyBlockKind.table + ? 0 + : position.offset + .clamp(0, _textControllerFor(block).text.length) + .toInt(), affinity: position.affinity, ); } @@ -3672,6 +3884,8 @@ class BusyMarkWysiwygEditorState extends State { _resetVerticalCaretMovement(); _applyKeyboardSelection( _DocumentTextSelection(anchor: target, extent: target), + collapse: true, + towardStart: towardStart, ); return KeyEventResult.handled; } @@ -3679,7 +3893,7 @@ class BusyMarkWysiwygEditorState extends State { _OrderedDocumentSelection? _orderedDocumentSelection( _DocumentTextSelection selection, ) { - final blocks = _focusableBlocks(); + final blocks = _editableBlocks(_documentController.document.blocks); final anchorIndex = blocks.indexWhere( (block) => block.id == selection.anchor.blockId, ); @@ -3710,13 +3924,14 @@ class BusyMarkWysiwygEditorState extends State { _activeBlockId = blockId; final pending = _PendingTextFocus(targetId: blockId, offset: offset); _pendingTextFocus = pending; + final generation = _documentGeneration; WidgetsBinding.instance.addPostFrameCallback((_) { - if (identical(_pendingTextFocus, pending)) { - _pendingTextFocus = null; - } - if (!mounted) { + if (!mounted || + !identical(_pendingTextFocus, pending) || + generation != _documentGeneration) { return; } + _pendingTextFocus = null; final block = _documentController.blockById(blockId); if (block == null) { return; @@ -3740,13 +3955,14 @@ class BusyMarkWysiwygEditorState extends State { _activeCellId = targetId; final pending = _PendingTextFocus(targetId: targetId, offset: offset); _pendingTextFocus = pending; + final generation = _documentGeneration; WidgetsBinding.instance.addPostFrameCallback((_) { - if (identical(_pendingTextFocus, pending)) { - _pendingTextFocus = null; - } - if (!mounted) { + if (!mounted || + !identical(_pendingTextFocus, pending) || + generation != _documentGeneration) { return; } + _pendingTextFocus = null; final controller = _tableCellControllers[targetId]; final focusNode = _tableCellFocusNodes[targetId]; if (controller == null || focusNode == null) { @@ -4902,12 +5118,11 @@ class BusyMarkWysiwygEditorState extends State { if (!_isClipboardTargetCurrent(captured) || text.isEmpty) { return false; } - if (_hasBlockSelection && - _replaceDocumentSelectionWithText( - text, - origin: _WysiwygTextEditOrigin.paste, - )) { - return true; + if (_hasBlockSelection) { + return _replaceDocumentSelectionWithText( + text, + origin: _WysiwygTextEditOrigin.paste, + ); } if (_activeCellId != null) { return _replaceActiveTableCellSelection( @@ -6329,7 +6544,7 @@ class BusyMarkWysiwygEditorState extends State { endOffset: selection.extent.offset.clamp(0, textLength).toInt(), forward: forward, ); - if (range != null && (includeEmptyRanges || range.end > range.start)) { + if (range != null && (includeEmptyRanges || !range.isEmpty)) { ranges.add(range); } } @@ -6416,10 +6631,14 @@ class BusyMarkWysiwygEditorState extends State { } void _handleBlockPointerDown(String blockId, PointerDownEvent event) { - if (event.buttons != kPrimaryMouseButton) { + if (event.buttons != kPrimaryMouseButton || _selectionPointer != null) { return; } _resetVerticalCaretMovement(); + _selectionGestureGeneration++; + _selectionPointer = event.pointer; + _pendingTextFocus = null; + _pointerSelectionCellId = _tableCellIdAtGlobalPosition(event.position); final offset = _textOffsetAtGlobalPosition(blockId, event.position); if (HardwareKeyboard.instance.isShiftPressed) { final anchor = _selectionAnchorForBlock(_activeBlockId ?? blockId); @@ -6438,7 +6657,7 @@ class BusyMarkWysiwygEditorState extends State { return; } } - _clearBlockSelection(); + _clearBlockSelection(releaseDrag: false); _collapseInactiveFieldSelections(blockId); _pointerSelectionAnchor = _DocumentTextPosition( blockId: blockId, @@ -6474,6 +6693,7 @@ class BusyMarkWysiwygEditorState extends State { } void _handleDocumentSelectionContextPointerCancel(PointerCancelEvent event) { + _handleBlockPointerCancel(event); if (_documentSelectionContextPointer == event.pointer) { _documentSelectionContextPointer = null; _documentSelectionContextSnapshot = null; @@ -6484,12 +6704,17 @@ class BusyMarkWysiwygEditorState extends State { Offset position, _DocumentTextSelection snapshot, ) async { + final target = _captureDialogTarget(); _preserveSelectionFocusCallbacks = math.max( _preserveSelectionFocusCallbacks, 4, ); final pasteAvailability = await _readContextMenuPasteAvailability(); - if (!mounted || snapshot != _documentSelection) return; + if (!mounted || + !_isDialogTargetCurrent(target) || + snapshot != _documentSelection) { + return; + } final commands = BusyMarkCommandRegistryScope.maybeOf(context) ?? BusyMarkCommandCatalog.metadata; @@ -6563,7 +6788,9 @@ class BusyMarkWysiwygEditorState extends State { ), ], ); - if (!mounted || action == null || snapshot != _documentSelection) { + if (!_isDialogTargetCurrent(target) || + action == null || + snapshot != _documentSelection) { return; } switch (action) { @@ -6589,7 +6816,8 @@ class BusyMarkWysiwygEditorState extends State { } void _handleBlockPointerMove(PointerMoveEvent event) { - if (_pointerSelectionAnchor == null || + if (event.pointer != _selectionPointer || + _pointerSelectionAnchor == null || event.buttons != kPrimaryMouseButton) { return; } @@ -6605,7 +6833,12 @@ class BusyMarkWysiwygEditorState extends State { if (targetBlockId == null) { return false; } - if (targetBlockId == anchor.blockId && _documentSelection == null) { + final crossesTableCells = + _pointerSelectionCellId != null && + _tableCellIdAtGlobalPosition(position) != _pointerSelectionCellId; + if (targetBlockId == anchor.blockId && + _documentSelection == null && + !crossesTableCells) { return false; } final endOffset = _textOffsetAtGlobalPosition(targetBlockId, position); @@ -6625,22 +6858,62 @@ class BusyMarkWysiwygEditorState extends State { } void _handleBlockPointerUp(PointerUpEvent event) { + if (event.pointer != _selectionPointer) return; _updateBlockSelectionDrag(event.position); - _pointerSelectionAnchor = null; + final focusCallbacks = _preserveSelectionFocusCallbacks; + _releaseSelectionDrag(); + if (_collapseEmptyDocumentSelection()) return; if (_hasBlockSelection) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - _focusDocumentSelectionExtent(); - } - }); + // The field's tap callback is dispatched after pointer-up. Preserve a + // Shift-click range through that callback, then release the tap guard. + _preserveSelectionFocusCallbacks = focusCallbacks; + _scheduleDocumentSelectionExtentFocus(); } - if (_preserveSelectionFocusCallbacks > 0) { - WidgetsBinding.instance.addPostFrameCallback((_) { - _preserveSelectionFocusCallbacks = 0; - }); + } + + void _handleBlockPointerCancel(PointerCancelEvent event) { + if (event.pointer != _selectionPointer) return; + // Cancellation preserves the last valid endpoint; its position is not a + // selection update. + _releaseSelectionDrag(); + if (!_collapseEmptyDocumentSelection()) { + _scheduleDocumentSelectionExtentFocus(); } } + void _scheduleDocumentSelectionExtentFocus() { + final selection = _documentSelection; + if (selection == null) return; + final gesture = _selectionGestureGeneration; + final generation = _documentGeneration; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && + gesture == _selectionGestureGeneration && + generation == _documentGeneration && + identical(selection, _documentSelection)) { + _focusDocumentSelectionExtent(); + _preserveSelectionFocusCallbacks = 0; + } + }); + } + + bool _collapseEmptyDocumentSelection() { + final ranges = _selectedTextRanges(null, true); + if (ranges.length != 1 || !ranges.single.isEmpty) return false; + _applyKeyboardSelection(_documentSelection!); + return true; + } + + void _releaseSelectionDrag({bool rebuild = true}) { + final active = _selectionPointer != null; + _selectionPointer = null; + _pointerSelectionAnchor = null; + _pointerSelectionCellId = null; + _preserveSelectionFocusCallbacks = 0; + _selectionGestureGeneration++; + if (active && rebuild && mounted) setState(() {}); + } + String? _blockIdAtGlobalPosition(Offset position) { String? nearestBlockId; var nearestDistance = double.infinity; @@ -6666,6 +6939,20 @@ class BusyMarkWysiwygEditorState extends State { return nearestDistance <= 80 ? nearestBlockId : null; } + String? _tableCellIdAtGlobalPosition(Offset position) { + for (final entry in _tableCellKeys.entries) { + final render = entry.value.currentContext?.findRenderObject(); + if (render is RenderBox && + render.hasSize && + (render.localToGlobal(Offset.zero) & render.size).contains( + position, + )) { + return entry.key; + } + } + return null; + } + void _collapseInactiveFieldSelections(String activeBlockId) { _collapseFieldSelections(exceptBlockId: activeBlockId); } @@ -6689,11 +6976,15 @@ class BusyMarkWysiwygEditorState extends State { } } - void _clearBlockSelection({bool collapseFields = true}) { + void _clearBlockSelection({ + bool collapseFields = true, + bool releaseDrag = true, + }) { if (!_hasBlockSelection) { _documentSelection = null; return; } + if (releaseDrag) _releaseSelectionDrag(rebuild: false); setState(() { _documentSelection = null; }); @@ -6703,6 +6994,10 @@ class BusyMarkWysiwygEditorState extends State { } void _selectAllForActiveBlock() { + if (_hasBlockSelection) { + _selectWholeDocumentText(); + return; + } final target = _activeTextTarget(); if (target == null) { final blocks = _focusableBlocks(); @@ -6712,12 +7007,21 @@ class BusyMarkWysiwygEditorState extends State { return; } if (_activeCellId != null) { + final selection = target.controller.selection; + if (_cellSelectAllTarget == _activeCellId && + selection.isValid && + selection.start == 0 && + selection.end == target.controller.text.length) { + _selectWholeDocumentText(); + return; + } _clearBlockSelection(); target.focusNode.requestFocus(); target.controller.selection = TextSelection( baseOffset: 0, extentOffset: target.controller.text.length, ); + _cellSelectAllTarget = _activeCellId; return; } _selectAllForBlock(target.targetId); @@ -6730,7 +7034,7 @@ class BusyMarkWysiwygEditorState extends State { return; } _activeBlockId = blockId; - if (_isWholeBlockSelected(blockId)) { + if (_hasBlockSelection || _isWholeBlockSelected(blockId)) { _selectWholeDocumentText(); return; } @@ -6759,7 +7063,7 @@ class BusyMarkWysiwygEditorState extends State { } void _selectWholeDocumentText() { - final blocks = _focusableBlocks(); + final blocks = _editableBlocks(_documentController.document.blocks); if (blocks.isEmpty) { return; } @@ -6787,6 +7091,12 @@ class BusyMarkWysiwygEditorState extends State { if (block == null) { return; } + if (!_isFocusableTextBlock(block)) { + _setActiveBlock(block.id); + _selectionFocusNode.requestFocus(); + _scheduleSessionReport(); + return; + } final controller = _textControllerFor(block); _activeBlockId = extent.blockId; _focusNodeFor(block).requestFocus(); @@ -6804,10 +7114,10 @@ class BusyMarkWysiwygEditorState extends State { } final first = ranges.first; final last = ranges.last; - if (first.block.id == last.block.id && first.start == last.end) { + if (ranges.length == 1 && first.isEmpty) { return false; } - _recordUndoSnapshot(); + final undoSnapshot = _historySnapshot(); final result = _documentController.deleteTextSelection( firstBlockId: first.block.id, firstStartOffset: first.start, @@ -6818,6 +7128,7 @@ class BusyMarkWysiwygEditorState extends State { if (result == null) { return false; } + _recordUndoSnapshot(undoSnapshot); _clearBlockSelection(collapseFields: false); _emitMarkdown(); _focusBlockAfterFrame(result.blockId, offset: result.offset); @@ -6834,7 +7145,7 @@ class BusyMarkWysiwygEditorState extends State { } final first = ranges.first; final last = ranges.last; - if (first.block.id == last.block.id && first.start == last.end) { + if (ranges.length == 1 && first.isEmpty) { return false; } final normalizedReplacement = replacementText @@ -6881,7 +7192,7 @@ class BusyMarkWysiwygEditorState extends State { } final first = ranges.first; final last = ranges.last; - if (first.block.id == last.block.id && first.start == last.end) { + if (ranges.length == 1 && first.isEmpty) { return false; } final undoSnapshot = _historySnapshot(); @@ -7279,14 +7590,26 @@ class BusyMarkWysiwygEditorState extends State { } bool _hasActiveComposition(TextEditingController controller) { - final composing = controller.value.composing; + return _hasComposingValue(controller.value); + } + + bool _hasComposingValue(TextEditingValue value) { + final composing = value.composing; return composing.isValid && composing.isNormalized && !composing.isCollapsed && - composing.end <= controller.text.length; + composing.end <= value.text.length; } + bool get _hasDocumentInputComposition => + _documentSelection != null && + identical(_documentInputSelection, _documentSelection) && + _hasComposingValue(_documentInputValue); + bool get _hasActiveClipboardComposition { + if (_hasDocumentInputComposition) { + return true; + } final targetIds = { if (_activeBlockId case final id?) id, if (_activeCellId case final id?) id, @@ -7330,10 +7653,12 @@ class BusyMarkWysiwygEditorState extends State { ) { var start = 0; var end = ranges.length; - while (start < end && ranges[start].start == ranges[start].end) { + // Tables have no block-local inline text: their document range denotes + // the complete structured block, including all child rows and cells. + while (start < end && ranges[start].isEmpty) { start++; } - while (end > start && ranges[end - 1].start == ranges[end - 1].end) { + while (end > start && ranges[end - 1].isEmpty) { end--; } return ranges.sublist(start, end); @@ -7484,6 +7809,14 @@ class BusyMarkWysiwygEditorState extends State { if (block == null || controller == null || controller.text.isEmpty) { return 0; } + final editable = _renderEditableForBlock(blockId); + if (editable != null && editable.hasSize) { + return editable + .getPositionForPoint(globalPosition) + .offset + .clamp(0, controller.text.length) + .toInt(); + } final keyContext = _blockKeys[blockId]?.currentContext; final renderObject = keyContext?.findRenderObject(); if (renderObject is! RenderBox || !renderObject.hasSize) { @@ -7526,7 +7859,11 @@ class BusyMarkWysiwygEditorState extends State { .clamp(0.0, renderObject.size.height) .toDouble(); final textPainter = TextPainter( - text: TextSpan(text: controller.text, style: _textStyleForBlock(block)), + text: controller.buildTextSpan( + context: context, + style: _textStyleForBlock(block), + withComposing: false, + ), textDirection: textDirection, textScaler: MediaQuery.textScalerOf(context), locale: Localizations.maybeLocaleOf(context), @@ -7555,6 +7892,76 @@ class BusyMarkWysiwygEditorState extends State { } } +/// Supplies a real IME client while a complete table is the document extent. +/// Its preedit stays outside the document and only committed text replaces the +/// captured range. A new selection gets a new client and closes the old one. +class _DocumentSelectionTextInput extends StatefulWidget { + const _DocumentSelectionTextInput({ + super.key, + required this.isCurrent, + required this.onEditingValue, + required this.onKeyEvent, + }); + + final bool Function() isCurrent; + final bool Function(TextEditingValue) onEditingValue; + final KeyEventResult Function(FocusNode, KeyEvent) onKeyEvent; + + @override + State<_DocumentSelectionTextInput> createState() => + _DocumentSelectionTextInputState(); +} + +class _DocumentSelectionTextInputState + extends State<_DocumentSelectionTextInput> { + final _controller = TextEditingController(); + late final _focusNode = FocusNode( + debugLabel: 'BusyMark WYSIWYG document selection input', + onKeyEvent: (node, event) => widget.onKeyEvent(node, event), + ); + bool _resetting = false; + + @override + void initState() { + super.initState(); + _controller.addListener(_handleEditingValue); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && widget.isCurrent()) _focusNode.requestFocus(); + }); + } + + void _handleEditingValue() { + if (_resetting || widget.onEditingValue(_controller.value)) return; + _resetting = true; + _controller.value = TextEditingValue.empty; + _resetting = false; + } + + @override + void dispose() { + _controller.removeListener(_handleEditingValue); + _controller.dispose(); + _focusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => EditableText( + controller: _controller, + focusNode: _focusNode, + style: const TextStyle( + fontSize: 1, + color: BusyMarkLinuxPalette.transparent, + ), + cursorColor: BusyMarkLinuxPalette.transparent, + backgroundCursorColor: BusyMarkLinuxPalette.transparent, + showCursor: false, + enableInteractiveSelection: false, + maxLines: null, + keyboardType: TextInputType.multiline, + ); +} + class _SelectedTextRange { const _SelectedTextRange({ required this.block, @@ -7566,6 +7973,10 @@ class _SelectedTextRange { final int start; final int end; + // A document table range owns its rows/cells, even though the container has + // no inline text. Cell-local and ordinary collapsed ranges remain empty. + bool get isEmpty => start == end && block.kind != BusyBlockKind.table; + bool get coversWholeBlock => start <= 0 && end >= block.plainText.length; } diff --git a/test/fixtures/wysiwyg/selection_boundaries.md b/test/fixtures/wysiwyg/selection_boundaries.md new file mode 100644 index 00000000..a540256a --- /dev/null +++ b/test/fixtures/wysiwyg/selection_boundaries.md @@ -0,0 +1,18 @@ +Previous paragraph ends here. + +**The client must work before corpus population finishes.** Use synthetic published test books. A successful build of a mock-only UI is not the final integration result. + +Following paragraph ends here. + +- `/mnt/Media/Linguality/gateway` +- `/mnt/Media/Linguality/learner_client` +- Ordinary list text ends here. + +**Bold** then plain; plain then **bold**; `code` then plain; plain then `code`; [link](https://example.test); *emphasis* ends here! + +| Header | Longer header | +| --- | --- | +| Short | A longer body cell | +| Last row | End! | + +Final paragraph ends here. diff --git a/test/src/busymark_document_test.dart b/test/src/busymark_document_test.dart index a56336e6..06866100 100644 --- a/test/src/busymark_document_test.dart +++ b/test/src/busymark_document_test.dart @@ -1,7 +1,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; -import 'dart:ui' show BoxHeightStyle, CheckedState; +import 'dart:ui' show CheckedState; import 'package:busymark/l10n/generated/app_localizations.dart'; import 'package:busymark/l10n/generated/app_localizations_de.dart'; @@ -2918,52 +2918,39 @@ void main() {} ); final paintBox = tester.renderObject(painterFinder.at(1)); - final painterText = - TextPainter( - text: TextSpan( - text: firstParagraph, - style: painter.style as TextStyle, - ), - strutStyle: StrutStyle.fromTextStyle(painter.style as TextStyle), - textDirection: painter.textDirection as TextDirection, - textScaler: painter.textScaler as TextScaler, - locale: painter.locale as Locale?, - )..layout( - maxWidth: paintBox.size.width - (painter.layoutWidthInset as double), - ); - final highlightBoxes = painterText.getBoxesForSelection( - TextSelection( - baseOffset: exampleStart, - extentOffset: exampleStart + 'example.'.length, - ), - boxHeightStyle: BusyMarkDocumentTextGeometry.selectionHeightStyle, - boxWidthStyle: BusyMarkDocumentTextGeometry.selectionWidthStyle, - ); - final tightHighlightBoxes = painterText.getBoxesForSelection( - TextSelection( - baseOffset: exampleStart, - extentOffset: exampleStart + 'example.'.length, - ), - boxHeightStyle: BoxHeightStyle.tight, - boxWidthStyle: BusyMarkDocumentTextGeometry.selectionWidthStyle, - ); - painterText.dispose(); - - expect(highlightBoxes, hasLength(exampleBoxes.length)); - expect(tightHighlightBoxes, hasLength(highlightBoxes.length)); - for (var index = 0; index < exampleBoxes.length; index += 1) { - expect(highlightBoxes[index].top, closeTo(exampleBoxes[index].top, 0.01)); - expect( - highlightBoxes[index].bottom, - closeTo(exampleBoxes[index].bottom, 0.01), + final canvas = TestRecordingCanvas(); + customPaint.painter!.paint(canvas, paintBox.size); + final highlightRects = [ + for (final call in canvas.invocations) + if (call.invocation.memberName == #drawRect) + call.invocation.positionalArguments.first as Rect, + ]; + final expectedBoxes = renderEditable.getBoxesForSelection( + const TextSelection(baseOffset: 0, extentOffset: firstParagraph.length), + ); + expect(highlightRects, hasLength(expectedBoxes.length)); + for (var index = 0; index < expectedBoxes.length; index += 1) { + final box = expectedBoxes[index]; + final expectedRect = Rect.fromPoints( + paintBox.globalToLocal( + renderEditable.localToGlobal(Offset(box.left, box.top)), + ), + paintBox.globalToLocal( + renderEditable.localToGlobal(Offset(box.right, box.bottom)), + ), ); - expect( - highlightBoxes[index].top, - lessThan(tightHighlightBoxes[index].top), + expect(highlightRects[index].left, closeTo(expectedRect.left, 0.01)); + expect(highlightRects[index].right, closeTo(expectedRect.right, 0.01)); + expect(highlightRects[index].top, closeTo(expectedRect.top, 0.01)); + expect(highlightRects[index].bottom, closeTo(expectedRect.bottom, 0.01)); + } + for (final box in exampleBoxes) { + final center = paintBox.globalToLocal( + renderEditable.localToGlobal(box.toRect().center), ); expect( - highlightBoxes[index].bottom, - greaterThan(tightHighlightBoxes[index].bottom), + highlightRects.any((rect) => rect.inflate(0.01).contains(center)), + isTrue, ); } }); diff --git a/test/src/wysiwyg_selection_lifecycle_test.dart b/test/src/wysiwyg_selection_lifecycle_test.dart new file mode 100644 index 00000000..cb7a6cef --- /dev/null +++ b/test/src/wysiwyg_selection_lifecycle_test.dart @@ -0,0 +1,2294 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:busymark/src/clipboard/clipboard_insertion.dart'; +import 'package:busymark/src/clipboard/clipboard_models.dart'; +import 'package:busymark/src/editor/wysiwyg/wysiwyg_block_widgets.dart'; +import 'package:busymark/src/editor/wysiwyg/wysiwyg_session_state.dart'; +import 'package:busymark/src/markdown/busymark_document.dart'; +import 'package:busymark/src/platform/rich_clipboard_service.dart'; +import 'package:busymark/src/workspace/document_buffer.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; + +import '../support/memory_rich_clipboard.dart'; +import '../support/wysiwyg_editable_harness.dart'; + +const _table = + '| Header | Longer header |\n| --- | --- |\n| Short | Body text |\n| Last | End! |\n'; +const _before = 'Before stays.'; +const _after = 'After stays.'; +const _surrounded = '$_before\n\n$_table\n$_after\n'; +const _protected = '> [guide]: docs.md\n>\n> Original\n\nOutside\n'; +const _secondTable = + '| Second | Other header |\n| --- | --- |\n| Two | Another body |\n| Final | Done! |\n'; +const _emptyTable = '| | |\n| --- | --- |\n| | |\n'; +const _fixtures = [ + (name: 'table-only', source: _table), + (name: 'leading table', source: '$_table\n$_after\n'), + (name: 'trailing table', source: '$_before\n\n$_table'), + (name: 'consecutive tables', source: '$_table\n$_secondTable'), + (name: 'text control', source: 'First.\n\nMiddle.\n\nLast.\n'), +]; + +class _DelayedClipboard extends MemoryRichClipboard { + Completer? readGate; + Completer? writeGate; + bool readFails = false; + int reads = 0; + int writes = 0; + + @override + Future read() async { + reads++; + await readGate?.future; + return readFails ? const RichClipboardData() : data; + } + + @override + Future write(RichClipboardData value) async { + writes++; + await writeGate?.future; + return super.write(value); + } +} + +Finder _field(String text) => + find.byWidgetPredicate((w) => w is TextField && w.controller?.text == text); + +EditableTextState _editable(WidgetTester tester, Finder field) => + tester.state( + find.descendant(of: field, matching: find.byType(EditableText)), + ); + +Offset _caret(WidgetTester tester, String text, int offset) { + final render = _editable(tester, _field(text)).renderEditable; + return render.localToGlobal( + render.getLocalRectForCaret(TextPosition(offset: offset)).center, + ); +} + +Future _command( + WidgetTester tester, + LogicalKeyboardKey key, { + bool shift = false, +}) async { + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + if (shift) await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyEvent(key); + if (shift) await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pumpAndSettle(); +} + +Future _mount( + WidgetTester tester, + String source, + MemoryRichClipboard clipboard, { + WysiwygEditorSessionState session = const WysiwygEditorSessionState(), + bool externalHistory = false, + BusyMarkClipboardInsertionRegistry? registry, +}) async { + await tester.binding.setSurfaceSize(const Size(1000, 1100)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + final key = GlobalKey(); + await tester.pumpWidget( + WysiwygEditableHarness( + key: key, + source: source, + clipboard: clipboard, + session: session, + externalHistory: externalHistory, + insertionRegistry: registry, + ), + ); + await tester.pumpAndSettle(); + return key.currentState!; +} + +WysiwygEditorSessionState _documentSession( + BusyDocument document, { + bool reverse = false, +}) { + final first = document.blocks.first; + final last = document.blocks.last; + return WysiwygEditorSessionState( + activeBlockId: reverse ? first.id : last.id, + anchorBlockId: reverse ? last.id : first.id, + anchorOffset: reverse ? last.plainText.length : 0, + extentBlockId: reverse ? first.id : last.id, + extentOffset: reverse ? 0 : last.plainText.length, + viewportBlockId: first.id, + ); +} + +Future _arrow( + WidgetTester tester, + LogicalKeyboardKey key, { + bool shift = false, + bool control = false, +}) async { + if (control) await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + if (shift) await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyEvent(key); + if (shift) await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + if (control) await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pumpAndSettle(); +} + +Future _input( + WidgetTester tester, + String text, { + TextRange composing = TextRange.empty, +}) async { + expect(tester.testTextInput.hasAnyClients, isTrue); + tester.testTextInput.updateEditingValue( + TextEditingValue( + text: text, + selection: TextSelection.collapsed(offset: text.length), + composing: composing, + ), + ); + await tester.pumpAndSettle(); +} + +Future _oldInput(WidgetTester tester, int client, String text) async { + // Deliver a real platform text-input message tagged with the old client ID. + // ignore: deprecated_member_use + tester.binding.defaultBinaryMessenger.handlePlatformMessage( + SystemChannels.textInput.name, + SystemChannels.textInput.codec.encodeMethodCall( + MethodCall('TextInputClient.updateEditingState', [ + client, + TextEditingValue( + text: text, + selection: TextSelection.collapsed(offset: text.length), + ).toJSON(), + ]), + ), + (_) {}, + ); + await tester.pumpAndSettle(); +} + +List _selected(WidgetTester tester) => [ + for (final block in tester.widgetList( + find.byType(BusyMarkWysiwygBlockField), + )) + if (block.selectionRange != null) block, +]; + +Future _startDrag( + WidgetTester tester, { + bool table = true, + int pointer = 71, +}) async { + final gesture = await tester.startGesture( + _caret(tester, table ? 'Header' : _before, 0), + pointer: pointer, + kind: PointerDeviceKind.mouse, + ); + await gesture.moveTo(_caret(tester, table ? 'End!' : _after, table ? 4 : 5)); + await tester.pumpAndSettle(); + return gesture; +} + +Future _selectTable(WidgetTester tester, {bool selectAll = false}) async { + if (selectAll) { + await tester.tap(_field('Header')); + await _command(tester, LogicalKeyboardKey.keyA); + await _command(tester, LogicalKeyboardKey.keyA); + } else { + final gesture = await _startDrag(tester); + await gesture.up(); + await tester.pumpAndSettle(); + } + final selected = _selected(tester); + expect(selected, hasLength(1)); + expect(selected.single.block.kind, BusyBlockKind.table); + expect(selected.single.selectionRange!.start, 0); + expect(selected.single.selectionRange!.end, 0); +} + +void _expectTableGeometry(WidgetTester tester) { + for (final text in [ + 'Header', + 'Longer header', + 'Short', + 'Body text', + 'Last', + 'End!', + ]) { + final field = _field(text); + final stack = find.ancestor(of: field, matching: find.byType(Stack)).first; + final overlay = find.descendant( + of: stack, + matching: find.byWidgetPredicate( + (w) => + w is CustomPaint && + w.painter.runtimeType.toString().contains('SelectionPainter'), + ), + ); + final canvas = TestRecordingCanvas(); + tester + .widget(overlay) + .painter! + .paint(canvas, tester.getSize(overlay)); + final rectangles = [ + for (final call in canvas.invocations) + if (call.invocation.memberName == #drawRect) + call.invocation.positionalArguments.first as Rect, + ]; + final expected = _editable(tester, field).renderEditable + .getBoxesForSelection( + TextSelection(baseOffset: 0, extentOffset: text.length), + ); + expect(rectangles, [for (final box in expected) box.toRect()]); + } +} + +void main() { + _registerLifecycleMatrix(); + _registerRestorationAndTerminalTests(); + setUp( + () => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, (_) async => null), + ); + tearDown( + () => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, null), + ); + + for (final forward in [false, true]) { + for (final control in [false, true]) { + testWidgets( + 'ordinary caret navigation preserves table traversal forward=$forward control=$control', + (tester) async { + final state = await _mount( + tester, + _surrounded, + MemoryRichClipboard(), + ); + final origin = forward ? _before : _after; + final destination = forward ? _after : _before; + await tester.tap(_field(origin)); + _editable(tester, _field(origin)).widget.controller.selection = + TextSelection.collapsed(offset: forward ? origin.length : 0); + await _arrow( + tester, + forward + ? LogicalKeyboardKey.arrowRight + : LogicalKeyboardKey.arrowLeft, + control: control, + ); + final focused = tester + .widgetList(find.byType(TextField)) + .where((field) => field.focusNode!.hasFocus) + .single; + expect(focused.controller!.text, destination); + expect( + focused.controller!.selection, + TextSelection.collapsed(offset: forward ? 0 : destination.length), + ); + expect(_selected(tester), isEmpty); + expect(state.source, _surrounded); + expect(state.changes, isEmpty); + expect(state.editorKey.currentState!.debugUndoSnapshotCount, 0); + }, + ); + } + } + + for (final fixture in ['table drag', 'table Select All', 'surrounded drag']) { + for (final action in [ + 'Delete', + 'Backspace', + 'Cut', + 'Enter', + 'plain paste', + 'structured paste', + ]) { + testWidgets( + '$fixture: $action replaces the selected structured block and undoes', + (tester) async { + final clipboard = MemoryRichClipboard(); + final original = fixture.startsWith('surrounded') + ? _surrounded + : _table; + final state = await _mount(tester, original, clipboard); + await _selectTable(tester, selectAll: fixture.endsWith('Select All')); + _expectTableGeometry(tester); + final parses = state.parses; + await _command(tester, LogicalKeyboardKey.keyC); + expect(clipboard.data.sourceText, contains('| Short | Body text |')); + expect( + state.parses, + parses, + reason: 'selection/copy must not reparse source', + ); + switch (action) { + case 'Delete': + await tester.sendKeyEvent(LogicalKeyboardKey.delete); + case 'Backspace': + await tester.sendKeyEvent(LogicalKeyboardKey.backspace); + case 'Cut': + await _command(tester, LogicalKeyboardKey.keyX); + case 'Enter': + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + case 'plain paste': + clipboard.data = const RichClipboardData(text: 'Replacement 🧭'); + await _command(tester, LogicalKeyboardKey.keyV, shift: true); + case 'structured paste': + clipboard.data = const RichClipboardData( + text: 'Replacement', + html: '

Replacement

', + ); + await _command(tester, LogicalKeyboardKey.keyV); + } + await tester.pumpAndSettle(); + expect( + state.changes, + hasLength(1), + reason: '$action must be one accepted document edit', + ); + expect( + state.document.blocks.any((b) => b.kind == BusyBlockKind.table), + isFalse, + ); + expect(_selected(tester), isEmpty); + if (fixture.startsWith('surrounded')) { + expect(state.document.blocks.first.plainText, _before); + expect(state.document.blocks.last.plainText, _after); + } + if (action == 'plain paste') { + expect(state.source, contains('Replacement 🧭')); + } + if (action == 'structured paste') { + expect(state.source, contains('**Replacement**')); + } + final insertedSource = switch (action) { + 'plain paste' => 'Replacement 🧭', + 'structured paste' => '**Replacement**', + _ => '', + }; + final expectedSource = fixture.startsWith('surrounded') + ? insertedSource.isNotEmpty + ? '$_before\n\n$insertedSource\n\n$_after\n' + : '$_before${action == 'Enter' ? '\n\n\n\n' : '\n\n\n'}$_after\n' + : insertedSource.isNotEmpty + ? '$insertedSource\n' + : action == 'Enter' + ? '\n' + : ''; + expect(state.source, expectedSource); + expect( + state.document.blocks.every( + (b) => b.kind == BusyBlockKind.paragraph, + ), + isTrue, + ); + final focused = tester + .widgetList(find.byType(TextField)) + .where((f) => f.focusNode!.hasFocus) + .single; + expect(focused.controller!.selection.isValid, isTrue); + expect(focused.controller!.selection.isCollapsed, isTrue); + final insertedText = action == 'plain paste' + ? 'Replacement 🧭' + : action == 'structured paste' + ? 'Replacement' + : ''; + expect(focused.controller!.text, insertedText); + expect( + focused.controller!.selection.extentOffset, + insertedText.length, + ); + final changed = state.source; + await _command(tester, LogicalKeyboardKey.keyZ); + expect(state.source, original); + expect( + state.document.blocks.any((b) => b.kind == BusyBlockKind.table), + isTrue, + ); + await _command(tester, LogicalKeyboardKey.keyZ, shift: true); + expect(state.source, changed); + }, + ); + } + } + + for (final source in [_table, '$_table\n$_after\n', '$_before\n\n$_table']) { + for (final arrow in [ + LogicalKeyboardKey.arrowLeft, + LogicalKeyboardKey.arrowRight, + ]) { + testWidgets( + 'table endpoint arrow collapse: ${source.startsWith('|')} ${source.endsWith('stays.\n')} $arrow', + (tester) async { + final state = await _mount(tester, source, MemoryRichClipboard()); + await tester.tap(_field('Header')); + await _command(tester, LogicalKeyboardKey.keyA); + await _command(tester, LogicalKeyboardKey.keyA); + expect(_selected(tester), isNotEmpty); + await tester.sendKeyEvent(arrow); + await tester.pumpAndSettle(); + expect(_selected(tester), isEmpty); + expect( + tester + .widgetList(find.byType(TextField)) + .where((f) => f.focusNode!.hasFocus), + hasLength(1), + ); + expect(state.source, source); + expect(state.changes, isEmpty); + expect(state.editorKey.currentState!.debugUndoSnapshotCount, 0); + }, + ); + } + } + + testWidgets( + 'table-ended selection accepts actual committed Unicode input once', + (tester) async { + final state = await _mount(tester, _table, MemoryRichClipboard()); + await _selectTable(tester, selectAll: true); + expect( + tester.testTextInput.isRegistered, + isTrue, + reason: 'a table document selection needs a real text input client', + ); + tester.testTextInput.updateEditingValue( + const TextEditingValue( + text: '🧭', + selection: TextSelection.collapsed(offset: 2), + ), + ); + await tester.pumpAndSettle(); + expect(state.source, '🧭\n'); + expect(state.changes, hasLength(1)); + tester.testTextInput.updateEditingValue( + const TextEditingValue( + text: '🧭 next', + selection: TextSelection.collapsed(offset: 7), + ), + ); + await tester.pumpAndSettle(); + expect(state.source, '🧭 next\n'); + await _command(tester, LogicalKeyboardKey.keyZ); + await _command(tester, LogicalKeyboardKey.keyZ); + expect(state.source, _table); + }, + ); + + testWidgets( + 'captured table selection restores highlighting copy and mutation', + (tester) async { + final clipboard = MemoryRichClipboard(); + final state = await _mount(tester, _surrounded, clipboard); + await _selectTable(tester); + final saved = WysiwygEditorSessionState.fromJson( + Map.from( + jsonDecode(jsonEncode(state.session.toJson())) as Map, + ), + ); + state.recreate(saved); + await tester.pumpAndSettle(); + expect(_selected(tester), hasLength(1)); + _expectTableGeometry(tester); + await _command(tester, LogicalKeyboardKey.keyC); + expect(clipboard.data.sourceText, contains('| Short | Body text |')); + await tester.sendKeyEvent(LogicalKeyboardKey.delete); + await tester.pumpAndSettle(); + expect( + state.document.blocks.any((b) => b.kind == BusyBlockKind.table), + isFalse, + ); + expect(state.document.blocks.first.plainText, _before); + expect(state.document.blocks.last.plainText, _after); + }, + ); + + for (final table in [false, true]) { + testWidgets( + 'cancel releases ${table ? 'cross-cell' : 'cross-block'} drag without changing its endpoint', + (tester) async { + final state = await _mount(tester, _surrounded, MemoryRichClipboard()); + final gesture = await _startDrag(tester, table: table); + expect( + tester + .widgetList( + find.byType(BusyMarkWysiwygBlockField), + ) + .any((b) => b.documentSelectionDragging), + isTrue, + ); + final saved = state.session.toJson(); + await gesture.cancel(); + await tester.pumpAndSettle(); + expect( + tester + .widgetList( + find.byType(BusyMarkWysiwygBlockField), + ) + .every((b) => !b.documentSelectionDragging), + isTrue, + ); + expect(state.session.anchorBlockId, saved['anchorBlockId']); + expect(state.session.extentBlockId, saved['extentBlockId']); + expect(state.session.extentOffset, saved['extentOffset']); + expect(state.changes, isEmpty); + await tester.tap(_field(_after)); + await tester.pumpAndSettle(); + expect( + tester.widget(_field(_after)).enableInteractiveSelection, + isTrue, + ); + expect(_selected(tester), isEmpty); + }, + ); + } + + testWidgets('unrelated pointer cannot complete an owned document drag', ( + tester, + ) async { + final state = await _mount(tester, _surrounded, MemoryRichClipboard()); + final gesture = await _startDrag(tester, table: false, pointer: 91); + final other = await tester.startGesture( + _caret(tester, _after, 2), + pointer: 92, + kind: PointerDeviceKind.mouse, + ); + await other.up(); + await tester.pumpAndSettle(); + expect( + tester + .widgetList( + find.byType(BusyMarkWysiwygBlockField), + ) + .any((b) => b.documentSelectionDragging), + isTrue, + ); + await gesture.up(); + await tester.pumpAndSettle(); + expect( + tester + .widgetList( + find.byType(BusyMarkWysiwygBlockField), + ) + .every((b) => !b.documentSelectionDragging), + isTrue, + ); + expect(state.changes, isEmpty); + }); + + for (final action in [ + 'Delete', + 'Enter', + 'plain paste', + 'structured paste', + 'typing', + ]) { + testWidgets( + 'rejected protected document $action cannot edit a local fallback', + (tester) async { + final clipboard = MemoryRichClipboard(); + final state = await _mount(tester, _protected, clipboard); + await tester.tap(_field('Outside')); + await _command(tester, LogicalKeyboardKey.keyA); + await _command(tester, LogicalKeyboardKey.keyA); + expect(_selected(tester), hasLength(2)); + await _command(tester, LogicalKeyboardKey.keyC); + expect(clipboard.data.text, contains('Outside')); + switch (action) { + case 'Delete': + await tester.sendKeyEvent(LogicalKeyboardKey.delete); + case 'Enter': + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + case 'plain paste': + clipboard.data = const RichClipboardData(text: 'Wrong target'); + await _command(tester, LogicalKeyboardKey.keyV, shift: true); + case 'structured paste': + clipboard.data = const RichClipboardData( + text: 'Wrong target', + html: '

Wrong target

', + ); + await _command(tester, LogicalKeyboardKey.keyV); + case 'typing': + tester.testTextInput.updateEditingValue( + const TextEditingValue( + text: 'OutsideX', + selection: TextSelection.collapsed(offset: 8), + ), + ); + } + await tester.pumpAndSettle(); + expect(state.source, _protected); + expect(state.changes, isEmpty); + expect(_selected(tester), hasLength(2)); + expect(state.editorKey.currentState!.debugUndoSnapshotCount, 0); + }, + ); + } + + testWidgets('failed document deletion preserves redo history', ( + tester, + ) async { + final state = await _mount(tester, _protected, MemoryRichClipboard()); + await tester.enterText(_field('Outside'), 'Outside!'); + await tester.pumpAndSettle(); + final changed = state.source; + await _command(tester, LogicalKeyboardKey.keyZ); + expect(state.source, _protected); + await _command(tester, LogicalKeyboardKey.keyA); + await _command(tester, LogicalKeyboardKey.keyA); + await tester.sendKeyEvent(LogicalKeyboardKey.delete); + await tester.pumpAndSettle(); + expect(state.editorKey.currentState!.debugUndoSnapshotCount, 0); + await _command(tester, LogicalKeyboardKey.keyZ, shift: true); + expect(state.source, changed); + }); + + for (final cut in [false, true]) { + for (final interruption in [ + 'failure', + 'selection', + 'document', + 'disposal', + ]) { + testWidgets( + 'delayed table ${cut ? 'cut' : 'paste'} is safe after $interruption', + (tester) async { + final clipboard = _DelayedClipboard(); + final state = await _mount(tester, _surrounded, clipboard); + await _selectTable(tester); + clipboard.data = const RichClipboardData(text: 'Replacement'); + final gate = Completer(); + if (cut) { + clipboard.writeGate = gate; + } else { + clipboard.readGate = gate; + } + await _command( + tester, + cut ? LogicalKeyboardKey.keyX : LogicalKeyboardKey.keyV, + ); + expect(cut ? clipboard.writes : clipboard.reads, 1); + switch (interruption) { + case 'failure': + clipboard.writeSucceeds = false; + clipboard.readFails = true; + case 'selection': + await tester.tap(_field(_after)); + await tester.pumpAndSettle(); + case 'document': + state.replaceSource('New document\n', id: 'new-document'); + await tester.pumpAndSettle(); + case 'disposal': + await tester.pumpWidget(const SizedBox()); + } + gate.complete(); + await tester.pumpAndSettle(); + expect(state.changes, isEmpty); + expect( + state.source, + interruption == 'document' ? 'New document\n' : _surrounded, + ); + expect(tester.takeException(), isNull); + }, + ); + } + } +} + +void _registerRestorationAndTerminalTests() { + for (final key in [ + LogicalKeyboardKey.backspace, + LogicalKeyboardKey.delete, + LogicalKeyboardKey.enter, + LogicalKeyboardKey.arrowLeft, + LogicalKeyboardKey.arrowRight, + LogicalKeyboardKey.arrowUp, + LogicalKeyboardKey.arrowDown, + LogicalKeyboardKey.home, + LogicalKeyboardKey.end, + ]) { + testWidgets('table preedit owns ${key.keyLabel} until committed input', ( + tester, + ) async { + final clipboard = MemoryRichClipboard() + ..data = const RichClipboardData(text: 'Must not paste'); + final state = await _mount(tester, _table, clipboard); + await _selectTable(tester, selectAll: true); + await _input(tester, 'ni', composing: const TextRange(start: 0, end: 2)); + await _command(tester, LogicalKeyboardKey.keyV); + await tester.sendKeyEvent(key); + await tester.pumpAndSettle(); + expect(state.source, _table); + expect(state.changes, isEmpty); + expect(_selected(tester), hasLength(1)); + await _input(tester, '你'); + expect(state.source, '你\n'); + expect(state.changes, hasLength(1)); + expect(state.document.blocks.single.kind, BusyBlockKind.paragraph); + await _command(tester, LogicalKeyboardKey.keyZ); + expect(state.source, _table); + }); + } + + for (final start in [false, true]) { + testWidgets( + 'a new pointer gesture supersedes deferred table caret start=$start', + (tester) async { + final state = await _mount(tester, _table, MemoryRichClipboard()); + await _selectTable(tester, selectAll: true); + await tester.sendKeyEvent( + start ? LogicalKeyboardKey.arrowLeft : LogicalKeyboardKey.arrowRight, + ); + final target = start ? 'End!' : 'Header'; + final gesture = await tester.startGesture( + _caret(tester, target, 2), + pointer: 311, + kind: PointerDeviceKind.mouse, + ); + await gesture.up(); + await tester.pumpAndSettle(); + final focused = tester + .widgetList(find.byType(TextField)) + .where((field) => field.focusNode!.hasFocus) + .single; + expect(focused.controller!.text, target); + expect( + focused.controller!.selection, + const TextSelection.collapsed(offset: 2), + ); + expect(_selected(tester), isEmpty); + expect(state.source, _table); + expect(state.changes, isEmpty); + expect(state.editorKey.currentState!.debugUndoSnapshotCount, 0); + }, + ); + } + + for (final cancel in [false, true]) { + testWidgets( + 'paragraph-ended drag ${cancel ? 'cancel' : 'up'} retains committed text input', + (tester) async { + final state = await _mount(tester, _surrounded, MemoryRichClipboard()); + final gesture = await _startDrag(tester, table: false, pointer: 301); + final saved = state.session; + if (cancel) { + await gesture.cancel(); + } else { + await gesture.up(); + } + await tester.pumpAndSettle(); + expect(state.session.anchorBlockId, saved.anchorBlockId); + expect(state.session.anchorOffset, saved.anchorOffset); + expect(state.session.extentBlockId, saved.extentBlockId); + expect(state.session.extentOffset, saved.extentOffset); + expect(state.source, _surrounded); + expect(_selected(tester), hasLength(3)); + expect(tester.testTextInput.isRegistered, isTrue); + tester.testTextInput.updateEditingValue( + const TextEditingValue( + text: 'After🧭 stays.', + selection: TextSelection.collapsed(offset: 7), + ), + ); + await tester.pumpAndSettle(); + expect(state.source, '🧭 stays.\n'); + expect(state.document.blocks, hasLength(1)); + expect(state.document.blocks.single.kind, BusyBlockKind.paragraph); + expect(_selected(tester), isEmpty); + expect( + tester.widget(_field('🧭 stays.')).controller!.selection, + const TextSelection.collapsed(offset: 2), + ); + expect(state.editorKey.currentState!.debugUndoSnapshotCount, 1); + await _command(tester, LogicalKeyboardKey.keyZ); + expect(state.source, _surrounded); + await _command(tester, LogicalKeyboardKey.keyZ, shift: true); + expect(state.source, '🧭 stays.\n'); + }, + ); + } + + for (final phase in ['clipboard preparation', 'open menu']) { + testWidgets( + 'a table context menu cannot follow a changed document during $phase', + (tester) async { + final clipboard = _DelayedClipboard() + ..data = const RichClipboardData(text: 'Pasted'); + final state = await _mount(tester, _surrounded, clipboard); + await _selectTable(tester); + if (phase == 'clipboard preparation') { + clipboard.readGate = Completer(); + } + await tester.tap(_field('Body text'), buttons: kSecondaryMouseButton); + await tester.pumpAndSettle(); + final next = _surrounded.replaceFirst('Before', 'Changed'); + state.replaceSource(next, id: 'new-document'); + await tester.pumpAndSettle(); + expect(_selected(tester), hasLength(1)); + if (phase == 'clipboard preparation') { + clipboard.readGate!.complete(); + await tester.pumpAndSettle(); + expect(find.text('Cut'), findsNothing); + } else { + await tester.tap(find.text('Paste as Plain Text')); + await tester.pumpAndSettle(); + } + expect(state.source, next); + expect(state.changes, isEmpty); + expect(tester.takeException(), isNull); + }, + ); + } + for (final source in [_table, _surrounded]) { + testWidgets( + 'structured table fragment replaces selected table surrounded=${source == _surrounded}', + (tester) async { + final clipboard = MemoryRichClipboard(); + await _mount(tester, _secondTable, clipboard); + await tester.tap(_field('Second')); + await _command(tester, LogicalKeyboardKey.keyA); + await _command(tester, LogicalKeyboardKey.keyA); + await _command(tester, LogicalKeyboardKey.keyC); + final state = await _mount(tester, source, clipboard); + await _selectTable(tester); + await _command(tester, LogicalKeyboardKey.keyV); + expect( + state.source, + source == _table + ? '$_secondTable\n' + : '$_before\n\n$_secondTable\n\n$_after\n', + ); + final table = state.document.blocks.singleWhere( + (b) => b.kind == BusyBlockKind.table, + ); + expect( + table.children + .map((row) => row.children.map((cell) => cell.plainText).toList()) + .toList(), + [ + ['Second', 'Other header'], + ['Two', 'Another body'], + ['Final', 'Done!'], + ], + ); + expect(state.changes, hasLength(1)); + expect(_selected(tester), isEmpty); + final focused = tester + .widgetList(find.byType(TextField)) + .singleWhere((field) => field.focusNode!.hasFocus); + expect(focused.controller!.text, ''); + expect( + focused.controller!.selection, + const TextSelection.collapsed(offset: 0), + ); + final replacement = state.source; + await _command(tester, LogicalKeyboardKey.keyZ); + expect(state.source, source); + await _command(tester, LogicalKeyboardKey.keyZ, shift: true); + expect(state.source, replacement); + }, + ); + } + for (final fixture in [ + ..._fixtures.take(4), + (name: 'surrounded', source: _surrounded), + ]) { + for (final reverse in [false, true]) { + testWidgets( + '${fixture.name} reverse=$reverse switching a reused editor captures outgoing document selection', + (tester) async { + final state = await _mount( + tester, + fixture.source, + MemoryRichClipboard(), + ); + state.recreate(_documentSession(state.document, reverse: reverse)); + await tester.pumpAndSettle(); + final saved = state.session; + final selectedIds = _selected(tester).map((b) => b.block.id).toList(); + state.replaceSource('Other tab\n', id: 'other'); + await tester.pumpAndSettle(); + final captured = state.sessions['editable']!; + expect(captured.anchorBlockId, saved.anchorBlockId); + expect(captured.anchorOffset, saved.anchorOffset); + expect(captured.extentBlockId, saved.extentBlockId); + expect(captured.extentOffset, saved.extentOffset); + state.replaceSource( + fixture.source, + id: 'editable', + restoredSession: captured, + ); + await tester.pumpAndSettle(); + expect(_selected(tester).map((b) => b.block.id), selectedIds); + expect(state.session.anchorBlockId, saved.anchorBlockId); + expect(state.session.extentBlockId, saved.extentBlockId); + expect(state.changes, isEmpty); + }, + ); + } + } + for (final action in ['Cut', 'Paste', 'Paste as Plain Text']) { + testWidgets('document context menu $action edits only the selected table', ( + tester, + ) async { + final clipboard = MemoryRichClipboard() + ..data = const RichClipboardData( + text: 'Replacement', + html: '

Replacement

', + ); + final state = await _mount(tester, _surrounded, clipboard); + await _selectTable(tester); + await tester.tap(_field('Body text'), buttons: kSecondaryMouseButton); + await tester.pumpAndSettle(); + expect( + _selected(tester), + hasLength(1), + reason: 'Right-click must preserve the selected table', + ); + await tester.tap(find.text(action)); + await tester.pumpAndSettle(); + final inserted = switch (action) { + 'Paste' => '**Replacement**', + 'Paste as Plain Text' => 'Replacement', + _ => '', + }; + expect( + state.source, + action == 'Cut' + ? '$_before\n\n\n$_after\n' + : '$_before\n\n$inserted\n\n$_after\n', + ); + expect(state.changes, hasLength(1)); + expect( + state.document.blocks.every((b) => b.kind == BusyBlockKind.paragraph), + isTrue, + ); + expect(_selected(tester), isEmpty); + final focused = tester + .widgetList(find.byType(TextField)) + .singleWhere((field) => field.focusNode!.hasFocus); + expect(focused.controller!.text, action == 'Cut' ? '' : 'Replacement'); + if (action == 'Cut') { + expect(clipboard.data.sourceText, contains('| Short | Body text |')); + } + await _command(tester, LogicalKeyboardKey.keyZ); + expect(state.source, _surrounded); + }); + } + for (final source in [_table, _surrounded]) { + testWidgets( + 'undo table deletion restores a real cell caret surrounded=${source == _surrounded}', + (tester) async { + final state = await _mount(tester, source, MemoryRichClipboard()); + await _selectTable(tester); + await tester.sendKeyEvent(LogicalKeyboardKey.delete); + await tester.pumpAndSettle(); + await _command(tester, LogicalKeyboardKey.keyZ); + expect(state.source, source); + final fields = tester + .widgetList(find.byType(TextField)) + .where((f) => f.focusNode!.hasFocus); + expect(fields, hasLength(1)); + expect(fields.single.controller!.text, 'Header'); + expect( + fields.single.controller!.selection, + const TextSelection.collapsed(offset: 0), + ); + await _input(tester, 'Edited header'); + expect(state.source, contains('| Edited header | Longer header |')); + await _command(tester, LogicalKeyboardKey.keyZ); + expect(state.source, source); + }, + ); + } + for (final cancel in [false, true]) { + testWidgets( + 'returning to an ordinary collapsed endpoint ${cancel ? 'cancel' : 'up'} restores real typing', + (tester) async { + final state = await _mount(tester, _surrounded, MemoryRichClipboard()); + final start = _caret(tester, _before, 3); + final gesture = await tester.startGesture( + start, + pointer: 176, + kind: PointerDeviceKind.mouse, + ); + await gesture.moveTo(_caret(tester, _after, 5)); + await tester.pumpAndSettle(); + await gesture.moveTo(start); + await tester.pumpAndSettle(); + expect(state.session.anchorOffset, 3); + expect(state.session.extentOffset, 3); + if (cancel) { + await gesture.cancel(); + } else { + await gesture.up(); + } + await tester.pumpAndSettle(); + expect(_selected(tester), isEmpty); + expect(state.changes, isEmpty); + expect(tester.testTextInput.hasAnyClients, isTrue); + tester.testTextInput.updateEditingValue( + const TextEditingValue( + text: 'BefXore stays.', + selection: TextSelection.collapsed(offset: 4), + ), + ); + await tester.pumpAndSettle(); + expect( + state.source, + _surrounded.replaceFirst(_before, 'BefXore stays.'), + ); + expect(state.changes, hasLength(1)); + expect( + tester + .widget(_field('BefXore stays.')) + .controller! + .selection + .extentOffset, + 4, + ); + }, + ); + } + for (final fixture in _fixtures.where((f) => f.name != 'text control')) { + for (final reverse in [false, true]) { + testWidgets( + '${fixture.name} real ${reverse ? 'reverse drag' : 'Select All'} survives workspace session JSON and editor recreation', + (tester) async { + final clipboard = MemoryRichClipboard(); + final state = await _mount(tester, fixture.source, clipboard); + if (reverse && fixture.name != 'table-only') { + final first = state.document.blocks.first; + final last = state.document.blocks.last; + final from = last.kind == BusyBlockKind.table + ? last.children.last.children.last + : last; + final to = first.kind == BusyBlockKind.table + ? first.children.first.children.first + : first; + final gesture = await tester.startGesture( + _caret(tester, from.plainText, from.plainText.length), + pointer: 180, + kind: PointerDeviceKind.mouse, + ); + await gesture.moveTo(_caret(tester, to.plainText, 0)); + await gesture.up(); + await tester.pumpAndSettle(); + } else { + await tester.tap(_field('Header')); + await _command(tester, LogicalKeyboardKey.keyA); + await _command(tester, LogicalKeyboardKey.keyA); + } + final ranges = [ + for (final b in _selected(tester)) + (b.block.id, b.selectionRange!.start, b.selectionRange!.end), + ]; + expect(ranges, isNotEmpty); + await _command(tester, LogicalKeyboardKey.keyC); + final copy = clipboard.data.sourceText; + final serialized = + jsonDecode( + jsonEncode( + DocumentEditorState(wysiwygState: state.session).toJson(), + ), + ) + as Map; + final saved = DocumentEditorState.fromJson( + Map.from(serialized), + ).wysiwygState; + final before = state.session; + state.recreate(saved); + await tester.pumpAndSettle(); + expect([ + for (final b in _selected(tester)) + (b.block.id, b.selectionRange!.start, b.selectionRange!.end), + ], ranges); + expect(state.session.anchorBlockId, before.anchorBlockId); + expect(state.session.extentBlockId, before.extentBlockId); + _expectTableGeometry(tester); + await _command(tester, LogicalKeyboardKey.keyC); + expect(clipboard.data.sourceText, copy); + expect(state.changes, isEmpty); + expect(state.editorKey.currentState!.debugUndoSnapshotCount, 0); + clipboard.data = const RichClipboardData( + text: 'Restored replacement', + ); + await _command(tester, LogicalKeyboardKey.keyV, shift: true); + expect(state.source, 'Restored replacement\n'); + expect(state.document.blocks.single.kind, BusyBlockKind.paragraph); + await _command(tester, LogicalKeyboardKey.keyZ); + expect(state.source, fixture.source); + }, + ); + } + } + + for (final cellLocal in [false, true]) { + testWidgets( + '${cellLocal ? 'cell' : 'paragraph'} local reverse selection restores locally', + (tester) async { + final clipboard = MemoryRichClipboard(); + final state = await _mount(tester, _surrounded, clipboard); + final text = cellLocal ? 'Body text' : _before; + await tester.tap(_field(text)); + final field = tester.widget(_field(text)); + field.controller!.selection = const TextSelection( + baseOffset: 5, + extentOffset: 1, + ); + await tester.pumpAndSettle(); + final saved = WysiwygEditorSessionState.fromJson( + Map.from( + jsonDecode(jsonEncode(state.session.toJson())) as Map, + ), + ); + state.recreate(saved); + await tester.pumpAndSettle(); + expect(_selected(tester), isEmpty); + final restored = tester.widget(_field(text)); + expect(restored.focusNode!.hasFocus, isTrue); + expect( + restored.controller!.selection, + const TextSelection(baseOffset: 5, extentOffset: 1), + ); + await _command(tester, LogicalKeyboardKey.keyC); + expect(clipboard.data.text, text.substring(1, 5)); + clipboard.data = const RichClipboardData(text: 'X'); + await _command(tester, LogicalKeyboardKey.keyV, shift: true); + expect( + state.document.blocks.where((b) => b.kind == BusyBlockKind.table), + hasLength(1), + ); + expect(state.source, contains(text.replaceRange(1, 5, 'X'))); + await _command(tester, LogicalKeyboardKey.keyZ); + expect(state.source, _surrounded); + }, + ); + } + + for (final changed in ['removed', 'changed kind', 'changed text']) { + testWidgets('saved table target $changed uses current-document fallback', ( + tester, + ) async { + final state = await _mount(tester, _surrounded, MemoryRichClipboard()); + await _selectTable(tester); + final saved = state.session; + final next = switch (changed) { + 'removed' => 'Survivor\n', + 'changed kind' => '$_before\n\nNew paragraph\n\n$_after\n', + _ => _surrounded.replaceFirst('Body text', 'Current body'), + }; + state.replaceSource(next); + await tester.pumpAndSettle(); + state.recreate(saved); + await tester.pumpAndSettle(); + expect(state.source, next); + expect(state.changes, isEmpty); + expect(state.editorKey.currentState!.debugUndoSnapshotCount, 0); + if (changed == 'changed text') { + expect(_selected(tester), hasLength(1)); + expect( + tester + .widget(_field('Current body')) + .enableInteractiveSelection, + isTrue, + ); + } else { + expect(_selected(tester), isEmpty); + expect( + tester + .widgetList(find.byType(TextField)) + .where((f) => f.focusNode!.hasFocus), + hasLength(1), + ); + } + expect(tester.takeException(), isNull); + }); + } + + for (final table in [false, true]) { + testWidgets( + 'unrelated move and cancel preserve owned ${table ? 'cell' : 'block'} drag', + (tester) async { + final state = await _mount(tester, _surrounded, MemoryRichClipboard()); + final gesture = await _startDrag(tester, table: table, pointer: 191); + final saved = state.session; + final position = _caret(tester, _after, 0); + tester.binding.handlePointerEvent( + PointerMoveEvent( + pointer: 192, + position: position, + buttons: kPrimaryMouseButton, + kind: PointerDeviceKind.mouse, + ), + ); + tester.binding.handlePointerEvent( + PointerCancelEvent( + pointer: 192, + position: position, + kind: PointerDeviceKind.mouse, + ), + ); + await tester.pumpAndSettle(); + expect(state.session.anchorBlockId, saved.anchorBlockId); + expect(state.session.extentBlockId, saved.extentBlockId); + expect(state.session.extentOffset, saved.extentOffset); + expect( + tester + .widgetList( + find.byType(BusyMarkWysiwygBlockField), + ) + .any((b) => b.documentSelectionDragging), + isTrue, + ); + await gesture.cancel(); + await tester.pumpAndSettle(); + expect( + tester + .widgetList(find.byType(TextField)) + .every((f) => f.enableInteractiveSelection), + isTrue, + ); + final local = await tester.startGesture( + _caret(tester, _after, 1), + pointer: 193, + kind: PointerDeviceKind.mouse, + ); + await local.moveTo(_caret(tester, _after, 5)); + await local.up(); + await tester.pumpAndSettle(); + expect(_selected(tester), isEmpty); + expect( + tester.widget(_field(_after)).controller!.selection, + const TextSelection(baseOffset: 1, extentOffset: 5), + ); + expect(state.changes, isEmpty); + }, + ); + } +} + +void _registerLifecycleMatrix() { + for (final fixture in [ + (name: 'table-only', source: _table, reverse: false), + (name: 'leading table', source: '$_table\n$_after\n', reverse: true), + (name: 'trailing table', source: '$_before\n\n$_table', reverse: false), + ( + name: 'consecutive tables', + source: '$_table\n$_secondTable', + reverse: false, + ), + ]) { + testWidgets( + '${fixture.name} committed newline and continued typing replace a table-ended selection', + (tester) async { + final state = await _mount( + tester, + fixture.source, + MemoryRichClipboard(), + ); + state.recreate( + _documentSession(state.document, reverse: fixture.reverse), + ); + await tester.pumpAndSettle(); + await _input(tester, '\n'); + expect(state.source, '\n'); + expect(state.changes, hasLength(1)); + expect( + state.document.blocks.where((b) => b.kind == BusyBlockKind.table), + isEmpty, + ); + expect(_selected(tester), isEmpty); + final focused = tester + .widgetList(find.byType(TextField)) + .singleWhere((field) => field.focusNode!.hasFocus); + expect( + focused.controller!.selection, + const TextSelection.collapsed(offset: 0), + ); + await _input(tester, 'Next'); + expect(state.source, '\nNext\n'); + await _command(tester, LogicalKeyboardKey.keyZ); + expect(state.source, '\n'); + await _command(tester, LogicalKeyboardKey.keyZ); + expect(state.source, fixture.source); + }, + ); + } + for (final fixture in _fixtures) { + for (final reverse in [false, true]) { + for (final movement in [ + (key: LogicalKeyboardKey.arrowLeft, control: false, start: true), + (key: LogicalKeyboardKey.arrowUp, control: false, start: true), + (key: LogicalKeyboardKey.arrowRight, control: false, start: false), + (key: LogicalKeyboardKey.arrowDown, control: false, start: false), + (key: LogicalKeyboardKey.arrowLeft, control: true, start: true), + (key: LogicalKeyboardKey.arrowRight, control: true, start: false), + ]) { + testWidgets( + '${fixture.name} reverse=$reverse collapse ${movement.key.keyLabel} control=${movement.control}', + (tester) async { + final state = await _mount( + tester, + fixture.source, + MemoryRichClipboard(), + ); + state.recreate(_documentSession(state.document, reverse: reverse)); + await tester.pumpAndSettle(); + expect(_selected(tester), isNotEmpty); + await _arrow(tester, movement.key, control: movement.control); + expect(_selected(tester), isEmpty); + final edge = movement.start + ? state.document.blocks.first + : state.document.blocks.last; + final target = edge.kind == BusyBlockKind.table + ? (movement.start + ? edge.children.first.children.first + : edge.children.last.children.last) + : edge; + final focused = tester + .widgetList(find.byType(TextField)) + .where((f) => f.focusNode!.hasFocus) + .single; + expect(focused.controller!.text, target.plainText); + expect( + focused.controller!.selection, + TextSelection.collapsed( + offset: movement.start ? 0 : target.plainText.length, + ), + ); + expect(state.source, fixture.source); + expect(state.changes, isEmpty); + expect(state.editorKey.currentState!.debugUndoSnapshotCount, 0); + }, + ); + } + final tableExtent = + fixture.name == 'table-only' || + fixture.name == 'consecutive tables' || + (fixture.name == 'leading table' && reverse) || + (fixture.name == 'trailing table' && !reverse); + if (!tableExtent) continue; + for (final cancel in [false, true]) { + testWidgets( + '${fixture.name} reverse=$reverse composing ${cancel ? 'cancel' : 'commit'} uses real input', + (tester) async { + final clipboard = _DelayedClipboard(); + final state = await _mount(tester, fixture.source, clipboard); + state.recreate(_documentSession(state.document, reverse: reverse)); + await tester.pumpAndSettle(); + await _input( + tester, + 'n', + composing: const TextRange(start: 0, end: 1), + ); + await _input( + tester, + 'ni', + composing: const TextRange(start: 0, end: 2), + ); + await _input( + tester, + '你', + composing: const TextRange(start: 0, end: 1), + ); + expect(state.source, fixture.source); + expect(state.changes, isEmpty); + expect(state.editorKey.currentState!.debugUndoSnapshotCount, 0); + clipboard.data = const RichClipboardData(text: 'Must not paste'); + await _command(tester, LogicalKeyboardKey.keyV); + expect(clipboard.reads, 0); + if (cancel) { + await _input(tester, ''); + expect(state.source, fixture.source); + expect(_selected(tester), isNotEmpty); + expect(state.changes, isEmpty); + await _command(tester, LogicalKeyboardKey.keyC); + expect( + clipboard.data.sourceText, + contains('| Short | Body text |'), + ); + } else { + await _input(tester, '你🧭'); + expect(state.source, '你🧭\n'); + expect(state.changes, hasLength(1)); + expect( + state.document.blocks.single.kind, + BusyBlockKind.paragraph, + ); + expect(_selected(tester), isEmpty); + await _input(tester, '你🧭'); + expect( + state.changes, + hasLength(1), + reason: 'repeated input must not duplicate replacement', + ); + await _input(tester, '你🧭 next'); + expect(state.source, '你🧭 next\n'); + final field = tester.widget(_field('你🧭 next')); + expect(field.focusNode!.hasFocus, isTrue); + expect( + field.controller!.selection.extentOffset, + '你🧭 next'.length, + ); + await _command(tester, LogicalKeyboardKey.keyZ); + expect(state.source, '你🧭\n'); + await _command(tester, LogicalKeyboardKey.keyZ); + expect(state.source, fixture.source); + await _command(tester, LogicalKeyboardKey.keyZ, shift: true); + expect(state.source, '你🧭\n'); + } + }, + ); + } + } + } + + for (final movement in [ + ( + next: LogicalKeyboardKey.arrowRight, + previous: LogicalKeyboardKey.arrowLeft, + word: false, + ), + ( + next: LogicalKeyboardKey.arrowRight, + previous: LogicalKeyboardKey.arrowLeft, + word: true, + ), + ( + next: LogicalKeyboardKey.arrowDown, + previous: LogicalKeyboardKey.arrowUp, + word: false, + ), + ]) { + testWidgets( + 'keyboard extension includes consecutive tables and returns to a selected table: $movement', + (tester) async { + final clipboard = MemoryRichClipboard(); + final state = await _mount( + tester, + '$_table\n$_secondTable\n$_after\n', + clipboard, + ); + await _selectTable(tester); + await _arrow( + tester, + movement.next, + shift: true, + control: movement.word, + ); + expect(_selected(tester).map((b) => b.block.kind), [ + BusyBlockKind.table, + BusyBlockKind.table, + ]); + await _command(tester, LogicalKeyboardKey.keyC); + expect( + clipboard.data.sourceText, + contains('| Second | Other header |'), + ); + await _arrow( + tester, + movement.previous, + shift: true, + control: movement.word, + ); + expect(_selected(tester), hasLength(1)); + expect(_selected(tester).single.block.kind, BusyBlockKind.table); + await _arrow( + tester, + movement.next, + shift: true, + control: movement.word, + ); + await _arrow( + tester, + movement.next, + shift: true, + control: movement.word, + ); + expect(state.session.extentBlockId, state.document.blocks.last.id); + expect(state.session.extentOffset, 0); + expect( + _selected(tester).where((b) => b.block.kind == BusyBlockKind.table), + hasLength(2), + ); + expect(state.changes, isEmpty); + }, + ); + } + + for (final interruption in [ + 'selection', + 'document', + 'same document source', + 'disposal', + ]) { + testWidgets('old table input client is rejected after $interruption', ( + tester, + ) async { + final state = await _mount(tester, _surrounded, MemoryRichClipboard()); + await _selectTable(tester); + final client = + (tester.testTextInput.log + .lastWhere( + (call) => call.method == 'TextInput.setClient', + ) + .arguments + as List) + .first + as int; + await _input( + tester, + 'preedit', + composing: const TextRange(start: 0, end: 7), + ); + switch (interruption) { + case 'selection': + await tester.tap(_field(_after)); + await tester.pumpAndSettle(); + case 'document': + state.replaceSource('Another\n', id: 'another'); + await tester.pumpAndSettle(); + case 'same document source': + state.replaceSource('Another\n'); + await tester.pumpAndSettle(); + case 'disposal': + await tester.pumpWidget(const SizedBox()); + } + await _oldInput(tester, client, 'Wrong target'); + expect( + state.source, + interruption.contains('document') ? 'Another\n' : _surrounded, + ); + expect(state.changes, isEmpty); + expect(tester.takeException(), isNull); + }); + } + + for (final external in [false, true]) { + testWidgets('empty table copy deletion undo externalHistory=$external', ( + tester, + ) async { + final clipboard = MemoryRichClipboard(); + final state = await _mount( + tester, + _emptyTable, + clipboard, + externalHistory: external, + ); + await tester.tap(find.byType(TextField).first); + await _command(tester, LogicalKeyboardKey.keyA); + await _command(tester, LogicalKeyboardKey.keyA); + expect(_selected(tester), hasLength(1)); + await _command(tester, LogicalKeyboardKey.keyC); + expect(clipboard.data.sourceText, contains('| --- | --- |')); + await tester.sendKeyEvent(LogicalKeyboardKey.delete); + await tester.pumpAndSettle(); + expect(state.source, ''); + expect( + state.document.blocks.where((b) => b.kind == BusyBlockKind.table), + isEmpty, + ); + expect(state.changes, hasLength(1)); + await _command(tester, LogicalKeyboardKey.keyZ); + expect(state.source, _emptyTable); + expect(state.document.blocks.single.kind, BusyBlockKind.table); + if (external) { + expect(state.undoCalls, 1); + expect(state.editorKey.currentState!.debugUndoSnapshotCount, 0); + } + await _command(tester, LogicalKeyboardKey.keyZ, shift: true); + expect(state.source, ''); + }); + } + + testWidgets( + 'clipboard history replaces selected table through document range', + (tester) async { + final registry = BusyMarkClipboardInsertionRegistry(); + addTearDown(registry.dispose); + final state = await _mount( + tester, + _surrounded, + MemoryRichClipboard(), + registry: registry, + ); + await _selectTable(tester); + expect( + await registry.paste( + BusyMarkClipboardPayload( + id: 'history', + acquiredAt: DateTime.utc(2026), + kind: BusyMarkClipboardContentKind.text, + text: 'History replacement', + ), + mode: BusyMarkPasteMode.plainText, + ), + ClipboardPasteResult.inserted, + ); + await tester.pumpAndSettle(); + expect(state.source, '$_before\n\nHistory replacement\n\n$_after\n'); + expect(state.document.blocks.map((b) => b.kind), [ + BusyBlockKind.paragraph, + BusyBlockKind.paragraph, + BusyBlockKind.paragraph, + ]); + expect(_selected(tester), isEmpty); + await _command(tester, LogicalKeyboardKey.keyZ); + expect(state.source, _surrounded); + }, + ); + + testWidgets( + 'partial paragraph to table to paragraph replacement preserves outside text and undo', + (tester) async { + final clipboard = MemoryRichClipboard(); + final state = await _mount(tester, _surrounded, clipboard); + final gesture = await tester.startGesture( + _caret(tester, _before, 6), + pointer: 115, + kind: PointerDeviceKind.mouse, + ); + await gesture.moveTo(_caret(tester, _after, 5)); + await gesture.up(); + await tester.pumpAndSettle(); + await _command(tester, LogicalKeyboardKey.keyC); + expect(clipboard.data.text, startsWith(' stays.')); + expect(clipboard.data.text, endsWith('After')); + expect(clipboard.data.text, contains('Body text')); + clipboard.data = const RichClipboardData(text: 'X'); + await _command(tester, LogicalKeyboardKey.keyV, shift: true); + expect(state.source, 'BeforeX stays.\n'); + expect(state.document.blocks.single.plainText, 'BeforeX stays.'); + expect( + tester + .widget(_field('BeforeX stays.')) + .controller! + .selection + .extentOffset, + 7, + ); + await _command(tester, LogicalKeyboardKey.keyZ); + expect(state.source, _surrounded); + }, + ); + + testWidgets('cell-local input cut paste and Tab remain local', ( + tester, + ) async { + final clipboard = MemoryRichClipboard(); + final state = await _mount(tester, _surrounded, clipboard); + await tester.tap(_field('Body text')); + final field = tester.widget(_field('Body text')); + field.controller!.selection = const TextSelection( + baseOffset: 0, + extentOffset: 4, + ); + await _command(tester, LogicalKeyboardKey.keyX); + expect(state.source, contains('| Short | text |')); + expect( + state.document.blocks.where((b) => b.kind == BusyBlockKind.table), + hasLength(1), + ); + clipboard.data = const RichClipboardData(text: 'Cell'); + await _command(tester, LogicalKeyboardKey.keyV, shift: true); + expect(state.source, contains('| Short | Cell text |')); + await _input(tester, 'Local 🧭'); + expect(state.source, contains('| Short | Local 🧭 |')); + expect(state.document.blocks.first.plainText, _before); + expect(state.document.blocks.last.plainText, _after); + expect(_selected(tester), isEmpty); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pumpAndSettle(); + expect( + tester.widget(_field('Last')).focusNode!.hasFocus, + isTrue, + ); + await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + await tester.pumpAndSettle(); + expect( + tester.widget(_field('Local 🧭')).focusNode!.hasFocus, + isTrue, + ); + }); + + for (final terminal in ['up', 'cancel', 'document', 'disposal']) { + testWidgets('scrolling owned drag terminates safely by $terminal', ( + tester, + ) async { + final source = + '${[for (var i = 0; i < 50; i++) 'Paragraph $i with enough text.'].join('\n\n')}\n'; + final state = await _mount(tester, source, MemoryRichClipboard()); + final list = tester.widget( + find.byType(ScrollablePositionedList), + ); + final gesture = await tester.startGesture( + _caret(tester, 'Paragraph 0 with enough text.', 0), + pointer: 130, + kind: PointerDeviceKind.mouse, + ); + await gesture.moveTo(_caret(tester, 'Paragraph 2 with enough text.', 5)); + await tester.pumpAndSettle(); + list.itemScrollController!.jumpTo(index: 35, alignment: 0.1); + await tester.pumpAndSettle(); + await gesture.moveTo(_caret(tester, 'Paragraph 37 with enough text.', 9)); + await tester.pumpAndSettle(); + expect(state.session.extentBlockId, state.document.blocks[37].id); + final saved = state.session; + switch (terminal) { + case 'up': + await gesture.up(); + case 'cancel': + await gesture.cancel(); + case 'document': + state.replaceSource('Replacement document\n', id: 'replacement'); + await tester.pumpAndSettle(); + await gesture.up(); + case 'disposal': + await tester.pumpWidget(const SizedBox()); + await gesture.cancel(); + } + await tester.pumpAndSettle(); + expect( + tester + .widgetList( + find.byType(BusyMarkWysiwygBlockField), + ) + .every((b) => !b.documentSelectionDragging), + isTrue, + ); + expect(state.changes, isEmpty); + if (terminal == 'cancel') { + expect(state.session.extentBlockId, saved.extentBlockId); + expect(state.session.extentOffset, 9); + expect( + state.session.viewportBlockId, + isNot(state.document.blocks.first.id), + ); + } + expect(tester.takeException(), isNull); + }, variant: TargetPlatformVariant.only(TargetPlatform.linux)); + } + + for (final fixture in [ + (name: 'paragraphs', source: '$_before\n\nMiddle selected.\n\n$_after\n'), + (name: 'mixed paragraph', source: _surrounded), + ( + name: 'list extent', + source: '$_before\n\n- Middle selected.\n- $_after\n', + ), + (name: 'table extent control', source: '$_before\n\n$_table'), + ]) { + for (final reverse in [false, true]) { + for (final lifecycle in ['recreate', 'reuse']) { + for (final externalHistory in [false, true]) { + testWidgets( + 'restored direct input ${fixture.name} reverse=$reverse lifecycle=$lifecycle external=$externalHistory', + (tester) async { + final state = await _mount( + tester, + fixture.source, + MemoryRichClipboard(), + externalHistory: externalHistory, + ); + final tableEdge = fixture.name == 'table extent control'; + final lastText = tableEdge ? 'End!' : _after; + final lastOffset = tableEdge ? 4 : 5; + final gesture = await tester.startGesture( + _caret( + tester, + reverse ? lastText : _before, + reverse ? lastOffset : 6, + ), + pointer: 301, + kind: PointerDeviceKind.mouse, + ); + await gesture.moveTo( + _caret( + tester, + reverse ? _before : lastText, + reverse ? 6 : lastOffset, + ), + ); + await gesture.up(); + await tester.pumpAndSettle(); + final saved = DocumentEditorState.fromJson( + Map.from( + jsonDecode( + jsonEncode( + DocumentEditorState( + wysiwygState: state.session, + ).toJson(), + ), + ) + as Map, + ), + ).wysiwygState; + final ranges = [ + for (final b in _selected(tester)) + (b.block.id, b.selectionRange!.start, b.selectionRange!.end), + ]; + if (lifecycle == 'recreate') { + state.recreate(saved); + } else { + state.replaceSource( + 'Other document remains.\n', + id: 'other', + restoredSession: const WysiwygEditorSessionState(), + ); + await tester.pumpAndSettle(); + state.replaceSource( + fixture.source, + id: 'editable', + restoredSession: saved, + ); + } + await tester.pumpAndSettle(); + expect(state.session.anchorBlockId, saved.anchorBlockId); + expect(state.session.extentBlockId, saved.extentBlockId); + expect(state.session.anchorOffset, saved.anchorOffset); + expect(state.session.extentOffset, saved.extentOffset); + expect([ + for (final b in _selected(tester)) + (b.block.id, b.selectionRange!.start, b.selectionRange!.end), + ], ranges); + expect(state.changes, isEmpty); + expect(state.parses, lifecycle == 'recreate' ? 1 : 3); + expect(state.editorKey.currentState!.debugUndoSnapshotCount, 0); + final tableInput = tableEdge && !reverse; + final extentText = reverse ? _before : _after; + final extentOffset = reverse ? 6 : 5; + if (!tableInput) { + final field = tester.widget(_field(extentText)); + expect( + field.focusNode!.hasFocus, + isTrue, + reason: 'restored extent must own ordinary input', + ); + expect( + tester.testTextInput.editingState, + field.controller!.value.toJSON(), + ); + } + expect(tester.testTextInput.hasAnyClients, isTrue); + const committed = '你🧭'; + tester.testTextInput.updateEditingValue( + TextEditingValue( + text: tableInput + ? committed + : extentText.replaceRange( + extentOffset, + extentOffset, + committed, + ), + selection: TextSelection.collapsed( + offset: tableInput + ? committed.length + : extentOffset + committed.length, + ), + ), + ); + await tester.pumpAndSettle(); + final suffix = tableEdge ? '' : ' stays.'; + final replaced = 'Before$committed$suffix'; + expect(state.source, '$replaced\n'); + expect( + state.document.blocks.single.kind, + BusyBlockKind.paragraph, + ); + expect(state.document.blocks.single.plainText, replaced); + expect(state.changes, hasLength(1)); + expect(_selected(tester), isEmpty); + final field = tester.widget(_field(replaced)); + expect(field.focusNode!.hasFocus, isTrue); + expect( + field.controller!.selection.extentOffset, + 6 + committed.length, + ); + final continued = 'Before$committed next$suffix'; + tester.testTextInput.updateEditingValue( + TextEditingValue( + text: continued, + selection: TextSelection.collapsed( + offset: 6 + committed.length + 5, + ), + ), + ); + await tester.pumpAndSettle(); + expect(state.source, '$continued\n'); + expect(state.changes, hasLength(2)); + await _command(tester, LogicalKeyboardKey.keyZ); + expect(state.source, '$replaced\n'); + await _command(tester, LogicalKeyboardKey.keyZ); + expect(state.source, fixture.source); + await _command(tester, LogicalKeyboardKey.keyZ, shift: true); + expect(state.source, '$replaced\n'); + await _command(tester, LogicalKeyboardKey.keyZ, shift: true); + expect(state.source, '$continued\n'); + }, + ); + } + } + } + } + + for (final mixed in [false, true]) { + for (final reverse in [false, true]) { + for (final cancel in [false, true]) { + testWidgets( + 'restored ordinary composition matches fresh mixed=$mixed reverse=$reverse cancel=$cancel', + (tester) async { + final source = mixed + ? _surrounded + : '$_before\n\nMiddle selected.\n\n$_after\n'; + final traces = >[]; + for (final restored in [false, true]) { + await tester.pumpWidget(const SizedBox()); + final state = await _mount(tester, source, MemoryRichClipboard()); + final gesture = await tester.startGesture( + _caret(tester, reverse ? _after : _before, reverse ? 5 : 6), + pointer: 302, + kind: PointerDeviceKind.mouse, + ); + await gesture.moveTo( + _caret(tester, reverse ? _before : _after, reverse ? 6 : 5), + ); + await gesture.up(); + await tester.pumpAndSettle(); + if (restored) { + state.recreate( + WysiwygEditorSessionState.fromJson( + Map.from( + jsonDecode(jsonEncode(state.session.toJson())) as Map, + ), + ), + ); + await tester.pumpAndSettle(); + } + expect(state.changes, isEmpty); + final extent = reverse ? _before : _after; + final offset = reverse ? 6 : 5; + expect( + tester.widget(_field(extent)).focusNode!.hasFocus, + isTrue, + ); + expect(tester.testTextInput.hasAnyClients, isTrue); + final trace = []; + for (final preedit in ['n', 'ni', cancel ? '' : '你🧭']) { + final initial = state.changes.isEmpty; + final text = initial + ? extent.replaceRange(offset, offset, preedit) + : 'Before$preedit stays.'; + final caret = initial + ? offset + preedit.length + : 6 + preedit.length; + tester.testTextInput.updateEditingValue( + TextEditingValue( + text: text, + selection: TextSelection.collapsed(offset: caret), + composing: preedit == 'n' || preedit == 'ni' + ? TextRange(start: initial ? offset : 6, end: caret) + : TextRange.empty, + ), + ); + await tester.pumpAndSettle(); + final focused = tester + .widgetList(find.byType(TextField)) + .singleWhere((f) => f.focusNode!.hasFocus); + trace.add(( + state.source, + state.changes.length, + _selected(tester).length, + focused.controller!.value, + state.editorKey.currentState!.debugUndoSnapshotCount, + )); + } + final finalText = cancel ? 'Before stays.' : 'Before你🧭 stays.'; + expect(state.source, '$finalText\n'); + expect(state.document.blocks.single.plainText, finalText); + expect(_selected(tester), isEmpty); + // Ordinary fields accept preedit immediately. Cancellation removes + // that preedit locally; it does not resurrect the selected blocks. + expect(state.changes.first, 'Beforen stays.\n'); + final accepted = state.changes.length; + final focused = tester.widget(_field(finalText)); + tester.testTextInput.updateEditingValue( + focused.controller!.value, + ); + await tester.pumpAndSettle(); + expect(state.changes, hasLength(accepted)); + await _command(tester, LogicalKeyboardKey.keyZ); + trace.add(state.source); + await _command(tester, LogicalKeyboardKey.keyZ, shift: true); + expect(state.source, '$finalText\n'); + traces.add(trace); + } + expect( + traces.last, + traces.first, + reason: + 'restoration must preserve the ordinary-field composition contract', + ); + }, + ); + } + } + } + + for (final cell in [false, true]) { + testWidgets( + 'restored local ${cell ? 'cell' : 'paragraph'} accepts immediate Unicode input', + (tester) async { + final state = await _mount(tester, _surrounded, MemoryRichClipboard()); + final text = cell ? 'Body text' : _before; + await tester.tap(_field(text)); + tester.widget(_field(text)).controller!.selection = + const TextSelection(baseOffset: 5, extentOffset: 1); + await tester.pumpAndSettle(); + state.recreate( + WysiwygEditorSessionState.fromJson( + Map.from( + jsonDecode(jsonEncode(state.session.toJson())) as Map, + ), + ), + ); + await tester.pumpAndSettle(); + expect(_selected(tester), isEmpty); + final field = tester.widget(_field(text)); + expect(field.focusNode!.hasFocus, isTrue); + expect(tester.testTextInput.hasAnyClients, isTrue); + expect( + tester.testTextInput.editingState, + field.controller!.value.toJSON(), + ); + final next = text.replaceRange(1, 5, '你🧭'); + tester.testTextInput.updateEditingValue( + TextEditingValue( + text: next, + selection: const TextSelection.collapsed(offset: 4), + ), + ); + await tester.pumpAndSettle(); + expect(state.source, _surrounded.replaceFirst(text, next)); + expect( + state.document.blocks.where((b) => b.kind == BusyBlockKind.table), + hasLength(1), + ); + expect(state.changes, hasLength(1)); + expect( + tester + .widget(_field(next)) + .controller! + .selection + .extentOffset, + 4, + ); + await _command(tester, LogicalKeyboardKey.keyZ); + expect(state.source, _surrounded); + await _command(tester, LogicalKeyboardKey.keyZ, shift: true); + expect(state.source, _surrounded.replaceFirst(text, next)); + }, + ); + } + + for (final superseded in [ + 'caret', + 'range', + 'document', + 'tab replacement', + 'disposal', + ]) { + testWidgets( + 'restored input focus is superseded by $superseded before handoff', + (tester) async { + const source = 'Before stays.\n\nMiddle selected.\n\nAfter stays.\n'; + final state = await _mount(tester, source, MemoryRichClipboard()); + final gesture = await tester.startGesture( + _caret(tester, _before, 6), + pointer: 303, + kind: PointerDeviceKind.mouse, + ); + await gesture.moveTo(_caret(tester, _after, 5)); + await gesture.up(); + await tester.pumpAndSettle(); + final saved = state.session; + state.recreate(saved); + // This frame installs the restored range and queues its guarded handoff. + // Supersede it before the next frame runs that handoff. + await tester.pump(); + switch (superseded) { + case 'caret': + await tester.tapAt(_caret(tester, 'Middle selected.', 7)); + case 'range': + final newer = await tester.startGesture( + _caret(tester, 'Middle selected.', 2), + pointer: 304, + kind: PointerDeviceKind.mouse, + ); + await newer.moveTo(_caret(tester, _before, 3)); + await newer.up(); + case 'document': + state.replaceSource( + 'Current document.\n', + id: 'current', + restoredSession: const WysiwygEditorSessionState(), + ); + case 'tab replacement': + state.replaceSource( + 'Current document.\n', + id: 'current', + restoredSession: const WysiwygEditorSessionState(), + ); + state.recreate(const WysiwygEditorSessionState()); + case 'disposal': + await tester.pumpWidget(const SizedBox()); + } + await tester.pumpAndSettle(); + expect(state.changes, isEmpty); + expect(tester.takeException(), isNull); + if (superseded == 'disposal') { + expect(tester.testTextInput.hasAnyClients, isFalse); + return; + } + final target = superseded == 'caret' + ? 'Middle selected.' + : superseded == 'range' + ? _before + : 'Current document.'; + final field = tester.widget(_field(target)); + expect(field.focusNode!.hasFocus, isTrue); + expect(tester.testTextInput.hasAnyClients, isTrue); + expect( + tester.testTextInput.editingState, + field.controller!.value.toJSON(), + ); + if (superseded == 'range') { + expect(state.session.anchorBlockId, state.document.blocks[1].id); + expect(state.session.extentBlockId, state.document.blocks.first.id); + expect(state.session.extentOffset, 3); + } else { + expect(_selected(tester), isEmpty); + } + final offset = field.controller!.selection.extentOffset; + tester.testTextInput.updateEditingValue( + TextEditingValue( + text: target.replaceRange(offset, offset, 'X'), + selection: TextSelection.collapsed(offset: offset + 1), + ), + ); + await tester.pumpAndSettle(); + expect( + state.source, + superseded == 'range' + ? 'BefXddle selected.\n\nAfter stays.\n' + : (superseded == 'caret' + ? source.replaceFirst( + target, + target.replaceRange(offset, offset, 'X'), + ) + : '${target.replaceRange(offset, offset, 'X')}\n'), + ); + expect(state.changes, hasLength(1)); + }, + ); + } + + testWidgets('restored ordinary input retains a scrolled viewport', ( + tester, + ) async { + final paragraphs = [ + for (var i = 0; i < 50; i++) 'Paragraph $i with enough text.', + ]; + final source = '${paragraphs.join('\n\n')}\n'; + final state = await _mount(tester, source, MemoryRichClipboard()); + final list = tester.widget( + find.byType(ScrollablePositionedList), + ); + final gesture = await tester.startGesture( + _caret(tester, paragraphs.first, 5), + pointer: 305, + kind: PointerDeviceKind.mouse, + ); + await gesture.moveTo(_caret(tester, paragraphs[2], 5)); + await tester.pumpAndSettle(); + list.itemScrollController!.jumpTo(index: 35, alignment: 0.1); + await tester.pumpAndSettle(); + await gesture.moveTo(_caret(tester, paragraphs[37], 9)); + await gesture.up(); + await tester.pumpAndSettle(); + final saved = WysiwygEditorSessionState.fromJson( + Map.from( + jsonDecode(jsonEncode(state.session.toJson())) as Map, + ), + ); + final parses = state.parses; + state.recreate(saved); + await tester.pumpAndSettle(); + expect(state.session.viewportBlockId, saved.viewportBlockId); + expect( + state.session.viewportAlignment, + closeTo(saved.viewportAlignment, 0.01), + ); + expect(state.session.anchorBlockId, saved.anchorBlockId); + expect(state.session.extentBlockId, saved.extentBlockId); + final field = tester.widget(_field(paragraphs[37])); + expect(field.focusNode!.hasFocus, isTrue); + expect(tester.testTextInput.hasAnyClients, isTrue); + expect(tester.testTextInput.editingState, field.controller!.value.toJSON()); + expect(state.changes, isEmpty); + expect(state.parses, parses); + tester.testTextInput.updateEditingValue( + TextEditingValue( + text: paragraphs[37].replaceRange(9, 9, '你🧭'), + selection: const TextSelection.collapsed(offset: 12), + ), + ); + await tester.pumpAndSettle(); + final replacement = + '${paragraphs.first.substring(0, 5)}你🧭${paragraphs[37].substring(9)}'; + expect( + state.source, + '${[replacement, ...paragraphs.skip(38)].join('\n\n')}\n', + ); + expect(state.changes, hasLength(1)); + }); + + testWidgets('restored ordinary input uses clamped session offsets', ( + tester, + ) async { + final state = await _mount( + tester, + '$_before\n\n$_after\n', + MemoryRichClipboard(), + ); + await tester.tap(_field(_before)); + await _command(tester, LogicalKeyboardKey.keyA); + await _command(tester, LogicalKeyboardKey.keyA); + final json = Map.from( + jsonDecode(jsonEncode(state.session.toJson())) as Map, + ); + json['anchorOffset'] = -10; + json['extentOffset'] = 10000; + state.recreate(WysiwygEditorSessionState.fromJson(json)); + await tester.pumpAndSettle(); + expect(state.session.anchorOffset, 0); + expect(state.session.extentOffset, _after.length); + final field = tester.widget(_field(_after)); + expect(field.focusNode!.hasFocus, isTrue); + expect(tester.testTextInput.editingState, field.controller!.value.toJSON()); + expect(state.changes, isEmpty); + await _input(tester, '$_after你🧭'); + expect(state.source, '你🧭\n'); + expect(state.changes, hasLength(1)); + }); +} diff --git a/test/src/wysiwyg_selection_test.dart b/test/src/wysiwyg_selection_test.dart new file mode 100644 index 00000000..b029e591 --- /dev/null +++ b/test/src/wysiwyg_selection_test.dart @@ -0,0 +1,861 @@ +import 'dart:io'; + +import 'package:busymark/l10n/generated/app_localizations.dart'; +import 'package:busymark/src/editor/wysiwyg/wysiwyg_block_widgets.dart'; +import 'package:busymark/src/editor/wysiwyg/wysiwyg_editor.dart'; +import 'package:busymark/src/editor/wysiwyg/wysiwyg_document_controller.dart'; +import 'package:busymark/src/editor/wysiwyg/wysiwyg_inline_controller.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_parser.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; + +import '../support/memory_rich_clipboard.dart'; + +const _fixture = 'test/fixtures/wysiwyg/selection_boundaries.md'; + +Future _key(WidgetTester tester, LogicalKeyboardKey key) async { + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(key); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pumpAndSettle(); +} + +Future _drag( + WidgetTester tester, + String startText, + int startOffset, + String endText, + int endOffset, +) async { + Offset point(String text, int offset) { + final editable = _editable(tester, _field(text)); + return editable.localToGlobal( + editable.getLocalRectForCaret(TextPosition(offset: offset)).center, + ); + } + + final start = point(startText, startOffset); + final end = point(endText, endOffset); + final gesture = await tester.startGesture( + start, + kind: PointerDeviceKind.mouse, + ); + await gesture.moveTo(end); + await gesture.up(); + await tester.pumpAndSettle(); +} + +Finder _field(String text) => find.byWidgetPredicate( + (widget) => widget is TextField && widget.controller?.text == text, +); + +RenderEditable _editable(WidgetTester tester, Finder field) => tester + .state( + find.descendant(of: field, matching: find.byType(EditableText)), + ) + .renderEditable; + +List _paintedRects(WidgetTester tester, Finder field) { + // Record the real selection painter's draw calls, not screenshot pixels. + final stack = find.ancestor(of: field, matching: find.byType(Stack)).first; + final overlay = find.descendant( + of: stack, + matching: find.byWidgetPredicate( + (widget) => + widget is CustomPaint && + widget.painter.runtimeType.toString().contains('SelectionPainter'), + ), + ); + if (overlay.evaluate().isEmpty) return []; + final widget = tester.widget(overlay); + final canvas = TestRecordingCanvas(); + widget.painter!.paint(canvas, tester.getSize(overlay)); + return [ + for (final call in canvas.invocations) + if (call.invocation.memberName == #drawRect) + call.invocation.positionalArguments.first as Rect, + ]; +} + +void _expectGeometry(WidgetTester tester, Finder field, int start, int end) { + final editable = _editable(tester, field); + final expected = editable.getBoxesForSelection( + TextSelection(baseOffset: start, extentOffset: end), + ); + final painted = _paintedRects(tester, field); + expect(painted, hasLength(expected.length)); + for (var index = 0; index < expected.length; index++) { + final box = expected[index].toRect(); + expect(painted[index].left, closeTo(box.left, 0.01)); + expect(painted[index].right, closeTo(box.right, 0.01)); + expect(painted[index].top, closeTo(box.top, 0.01)); + expect(painted[index].bottom, closeTo(box.bottom, 0.01)); + } +} + +Future _mount( + WidgetTester tester, + BusyDocument document, + MemoryRichClipboard clipboard, { + WysiwygEditorSessionState session = const WysiwygEditorSessionState(), + BusyMarkWysiwygSessionChanged? onSession, + double width = 1000, + double height = 1400, +}) async { + await tester.binding.setSurfaceSize(Size(width, height)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: BusyMarkWysiwygEditor( + key: UniqueKey(), + document: document, + clipboardService: clipboard, + initialSessionState: session, + onSessionChanged: onSession, + onSourceChanged: (_, _) => fail('Selection must not edit the source'), + ), + ), + ), + ); + await tester.pumpAndSettle(); +} + +void main() { + setUp(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, (_) async => null); + }); + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, null); + }); + final source = File(_fixture).readAsStringSync(); + final document = const MarkdownParser() + .parse(filePath: 'selection.md', source: source) + .busyDocument; + + test( + 'reduced fixture uses rendered offsets without Markdown delimiters or block separators', + () { + final controller = BusyMarkWysiwygDocumentController(document: document); + addTearDown(controller.dispose); + for (final block in document.blocks.where((b) => b.inlines.isNotEmpty)) { + expect(controller.blockText(block.id), block.plainText); + expect(controller.blockText(block.id), isNot(endsWith('\n'))); + for (final range in busyInlineStyleRanges(block.inlines)) { + expect(range.start, greaterThanOrEqualTo(0)); + expect(range.end, lessThanOrEqualTo(block.plainText.length)); + } + } + for (final block + in document.blocks + .where((b) => b.kind == BusyBlockKind.unorderedListItem) + .take(2)) { + expect(block.plainText, isNot(contains('`'))); + final code = busyInlineStyleRanges(block.inlines).single; + expect(code.kind, BusyInlineKind.code); + expect(code.start, 0); + expect(code.end, block.plainText.length); + } + }, + ); + + testWidgets( + 'Select All: logical range and copy include styled suffixes and table', + (tester) async { + final clipboard = MemoryRichClipboard(); + WysiwygEditorSessionState? session; + await _mount( + tester, + document, + clipboard, + onSession: (_, s) => session = s, + ); + final first = tester.widget( + _field(document.blocks.first.plainText), + ); + first.focusNode!.requestFocus(); + first.controller!.selection = const TextSelection.collapsed(offset: 0); + await tester.pump(); + await _key(tester, LogicalKeyboardKey.keyA); + expect(first.controller!.selection.end, first.controller!.text.length); + await _key(tester, LogicalKeyboardKey.keyA); + expect(session?.anchorBlockId, document.blocks.first.id); + expect(session?.anchorOffset, 0); + expect(session?.extentBlockId, document.blocks.last.id); + expect(session?.extentOffset, document.blocks.last.plainText.length); + await _key(tester, LogicalKeyboardKey.keyC); + expect( + clipboard.data.text, + contains('not the final integration result.'), + ); + expect(clipboard.data.text, contains('/mnt/Media/Linguality/gateway')); + expect( + clipboard.data.text, + contains('/mnt/Media/Linguality/learner_client'), + ); + expect(clipboard.data.text, contains('A longer body cell')); + expect(clipboard.data.text, contains('End!')); + expect( + clipboard.data.sourceText, + contains('`/mnt/Media/Linguality/gateway`'), + ); + expect( + clipboard.data.sourceText, + contains('`/mnt/Media/Linguality/learner_client`'), + ); + expect( + clipboard.data.sourceText, + contains('**The client must work before corpus population finishes.**'), + ); + for (final block in tester.widgetList( + find.byType(BusyMarkWysiwygBlockField), + )) { + expect(block.selectionRange?.start, 0); + expect(block.selectionRange?.end, block.block.plainText.length); + } + }, + ); + + for (final block in document.blocks.where((b) => b.inlines.isNotEmpty)) { + testWidgets( + 'multi-block paint matches rendered runs: ${block.id} ${block.kind}', + (tester) async { + await _mount( + tester, + document, + MemoryRichClipboard(), + session: WysiwygEditorSessionState( + activeBlockId: document.blocks.last.id, + anchorBlockId: document.blocks.first.id, + extentBlockId: document.blocks.last.id, + extentOffset: document.blocks.last.plainText.length, + ), + ); + _expectGeometry( + tester, + _field(block.plainText), + 0, + block.plainText.length, + ); + }, + ); + } + + testWidgets('table cells paint the same selected text as document copy', ( + tester, + ) async { + final clipboard = MemoryRichClipboard(); + await _mount( + tester, + document, + clipboard, + session: WysiwygEditorSessionState( + activeBlockId: document.blocks.last.id, + anchorBlockId: document.blocks.first.id, + extentBlockId: document.blocks.last.id, + extentOffset: document.blocks.last.plainText.length, + ), + ); + await _key(tester, LogicalKeyboardKey.keyC); + final table = document.blocks.firstWhere( + (b) => b.kind == BusyBlockKind.table, + ); + for (final cell in table.children.expand((row) => row.children)) { + expect(clipboard.data.text, contains(cell.plainText)); + _expectGeometry(tester, _field(cell.plainText), 0, cell.plainText.length); + } + }); + + testWidgets('repeated Select All keeps the entire document selected', ( + tester, + ) async { + final clipboard = MemoryRichClipboard(); + WysiwygEditorSessionState? session; + await _mount(tester, document, clipboard, onSession: (_, s) => session = s); + final first = tester.widget( + _field(document.blocks.first.plainText), + ); + first.focusNode!.requestFocus(); + first.controller!.selection = const TextSelection.collapsed(offset: 0); + await tester.pump(); + for (var invocation = 0; invocation < 5; invocation++) { + await _key(tester, LogicalKeyboardKey.keyA); + if (invocation == 0) continue; + expect(session?.anchorBlockId, document.blocks.first.id); + expect(session?.extentBlockId, document.blocks.last.id); + await _key(tester, LogicalKeyboardKey.keyC); + expect(clipboard.data.text, contains('Previous paragraph')); + expect(clipboard.data.text, contains('End!')); + } + }); + + for (final text in [ + 'Before.\n\n| H |\n| --- |\n| Body! |\n', + '| H |\n| --- |\n| Body! |\n\nAfter.\n', + ]) { + testWidgets('Select All includes a table at the document edge: $text', ( + tester, + ) async { + final edgeDocument = const MarkdownParser() + .parse(filePath: 'edge.md', source: text) + .busyDocument; + final clipboard = MemoryRichClipboard(); + WysiwygEditorSessionState? session; + await _mount( + tester, + edgeDocument, + clipboard, + onSession: (_, s) => session = s, + ); + final paragraph = edgeDocument.blocks.firstWhere( + (b) => b.kind == BusyBlockKind.paragraph, + ); + final field = tester.widget(_field(paragraph.plainText)); + field.focusNode!.requestFocus(); + field.controller!.selection = const TextSelection.collapsed(offset: 0); + await tester.pump(); + await _key(tester, LogicalKeyboardKey.keyA); + await _key(tester, LogicalKeyboardKey.keyA); + await _key(tester, LogicalKeyboardKey.keyC); + expect(session?.anchorBlockId, edgeDocument.blocks.first.id); + expect(session?.extentBlockId, edgeDocument.blocks.last.id); + expect(clipboard.data.text, contains('Body!')); + }); + } + + testWidgets('drag endpoints use the styled rendered coordinate system', ( + tester, + ) async { + final clipboard = MemoryRichClipboard(); + WysiwygEditorSessionState? session; + await _mount(tester, document, clipboard, onSession: (_, s) => session = s); + final styled = document.blocks[1]; + await _drag(tester, styled.plainText, 20, document.blocks[2].plainText, 8); + expect(session?.anchorOffset, 20); + expect(session?.extentOffset, 8); + await _key(tester, LogicalKeyboardKey.keyC); + expect( + clipboard.data.text, + '${styled.plainText.substring(20)}\n\n${document.blocks[2].plainText.substring(0, 8)}', + ); + }); + + testWidgets( + 'drag from a paragraph into a table copies and selects its cells', + (tester) async { + final clipboard = MemoryRichClipboard(); + await _mount(tester, document, clipboard); + final before = document.blocks[6]; + await _drag(tester, before.plainText, 0, 'A longer body cell', 8); + await _key(tester, LogicalKeyboardKey.keyC); + expect(clipboard.data.text, contains('A longer body cell')); + _expectGeometry(tester, _field('End!'), 0, 4); + }, + ); + + testWidgets( + 'drag across table cells selects the existing complete-table document block', + (tester) async { + final clipboard = MemoryRichClipboard(); + await _mount(tester, document, clipboard); + await _drag(tester, 'Header', 0, 'End!', 4); + await _key(tester, LogicalKeyboardKey.keyC); + expect(clipboard.data.text, contains('Header')); + expect(clipboard.data.text, contains('A longer body cell')); + expect(clipboard.data.sourceText, contains('| Last row | End! |')); + _expectGeometry(tester, _field('End!'), 0, 4); + }, + ); + + for (final width in [460.0, 1000.0]) { + for (final endpoints in [ + (1, 0, 1, document.blocks[1].plainText.length), + (0, 4, 1, document.blocks[1].plainText.length), + (1, 0, 2, 8), + (0, 0, 2, document.blocks[2].plainText.length), + (2, document.blocks[2].plainText.length, 0, 0), + ]) { + testWidgets('paragraph boundary $endpoints at width $width', ( + tester, + ) async { + final (anchorIndex, anchorOffset, extentIndex, extentOffset) = + endpoints; + final clipboard = MemoryRichClipboard(); + await _mount( + tester, + document, + clipboard, + width: width, + session: WysiwygEditorSessionState( + activeBlockId: document.blocks[extentIndex].id, + anchorBlockId: document.blocks[anchorIndex].id, + anchorOffset: anchorOffset, + extentBlockId: document.blocks[extentIndex].id, + extentOffset: extentOffset, + ), + ); + final middle = document.blocks[1]; + final field = _field(middle.plainText); + if (anchorIndex == extentIndex) { + expect( + tester.widget(field).controller!.selection, + TextSelection(baseOffset: 0, extentOffset: middle.plainText.length), + ); + } else { + final model = tester + .widgetList( + find.byType(BusyMarkWysiwygBlockField), + ) + .firstWhere((b) => b.block.id == middle.id); + expect(model.selectionRange?.start, 0); + expect(model.selectionRange?.end, middle.plainText.length); + _expectGeometry(tester, field, 0, middle.plainText.length); + final suffix = _editable(tester, field).getBoxesForSelection( + TextSelection( + baseOffset: middle.plainText.length - 1, + extentOffset: middle.plainText.length, + ), + ); + expect(suffix, isNotEmpty); + for (final glyph in suffix) { + expect( + _paintedRects( + tester, + field, + ).any((r) => r.inflate(0.01).contains(glyph.toRect().center)), + isTrue, + ); + } + } + await _key(tester, LogicalKeyboardKey.keyC); + expect(clipboard.data.text, contains(middle.plainText)); + expect( + clipboard.data.text, + contains('not the final integration result.'), + ); + }); + } + + testWidgets( + 'all styled runs and list endings repaint after resizing to $width', + (tester) async { + await _mount( + tester, + document, + MemoryRichClipboard(), + session: WysiwygEditorSessionState( + activeBlockId: document.blocks.last.id, + anchorBlockId: document.blocks.first.id, + extentBlockId: document.blocks.last.id, + extentOffset: document.blocks.last.plainText.length, + ), + ); + await tester.binding.setSurfaceSize(Size(width, 1400)); + await tester.pumpAndSettle(); + for (final block in document.blocks.where( + (b) => b.inlines.isNotEmpty, + )) { + _expectGeometry( + tester, + _field(block.plainText), + 0, + block.plainText.length, + ); + } + }, + ); + } + + for (final inline in [ + '**Bold** plain!', + 'plain **bold**!', + '`code` plain!', + 'plain `code`!', + '[link](https://example.test) plain!', + '*emphasis* plain!', + ]) { + final styled = const MarkdownParser() + .parse(filePath: 'inline.md', source: 'Before.\n\n$inline\n\nAfter.\n') + .busyDocument; + for (final end in [ + 2, + styled.blocks[1].plainText.length - 1, + styled.blocks[1].plainText.length, + ]) { + testWidgets('partial multi-block styled span: $inline end=$end', ( + tester, + ) async { + final clipboard = MemoryRichClipboard(); + await _mount( + tester, + styled, + clipboard, + session: WysiwygEditorSessionState( + activeBlockId: styled.blocks[1].id, + anchorBlockId: styled.blocks.first.id, + anchorOffset: styled.blocks.first.plainText.length, + extentBlockId: styled.blocks[1].id, + extentOffset: end, + ), + ); + _expectGeometry(tester, _field(styled.blocks[1].plainText), 0, end); + await _key(tester, LogicalKeyboardKey.keyC); + expect( + clipboard.data.text, + styled.blocks[1].plainText.substring(0, end), + ); + }); + } + } + + for (final endpoints in [ + (3, 0, 3, document.blocks[3].plainText.length), + (4, 0, 4, document.blocks[4].plainText.length), + (3, 0, 4, document.blocks[4].plainText.length), + (2, 4, 4, document.blocks[4].plainText.length), + (3, 0, 6, 9), + (2, 0, 6, document.blocks[6].plainText.length), + (5, 0, 5, document.blocks[5].plainText.length), + ]) { + testWidgets( + 'list boundary $endpoints includes the final rendered character', + (tester) async { + final (anchor, anchorOffset, extent, extentOffset) = endpoints; + final clipboard = MemoryRichClipboard(); + await _mount( + tester, + document, + clipboard, + session: WysiwygEditorSessionState( + activeBlockId: document.blocks[extent].id, + anchorBlockId: document.blocks[anchor].id, + anchorOffset: anchorOffset, + extentBlockId: document.blocks[extent].id, + extentOffset: extentOffset, + ), + ); + final expectedText = []; + for (var index = anchor; index <= extent; index++) { + final block = document.blocks[index]; + final start = index == anchor ? anchorOffset : 0; + final end = index == extent ? extentOffset : block.plainText.length; + final whole = start == 0 && end == block.plainText.length; + expectedText.add( + '${whole && block.kind == BusyBlockKind.unorderedListItem ? '• ' : ''}${block.plainText.substring(start, end)}', + ); + if (anchor != extent) { + _expectGeometry(tester, _field(block.plainText), start, end); + } else { + expect( + tester + .widget(_field(block.plainText)) + .controller! + .selection, + TextSelection(baseOffset: start, extentOffset: end), + ); + } + } + await _key(tester, LogicalKeyboardKey.keyC); + expect(clipboard.data.text, expectedText.join('\n\n')); + for (var index = 3; index <= 4; index++) { + if (index >= anchor && index <= extent) { + expect( + clipboard.data.sourceText, + contains('`${document.blocks[index].plainText}`'), + ); + } + } + }, + ); + } + + testWidgets( + 'mouse range across paragraph, list, table and paragraph equals Select All', + (tester) async { + final clipboard = MemoryRichClipboard(); + await _mount(tester, document, clipboard); + final first = tester.widget( + _field(document.blocks.first.plainText), + ); + first.focusNode!.requestFocus(); + first.controller!.selection = const TextSelection.collapsed(offset: 0); + await tester.pump(); + await _key(tester, LogicalKeyboardKey.keyA); + await _key(tester, LogicalKeyboardKey.keyA); + await _key(tester, LogicalKeyboardKey.keyC); + final selectAllText = clipboard.data.text; + final selectAllMarkdown = clipboard.data.sourceText; + await _drag( + tester, + document.blocks.first.plainText, + 0, + document.blocks.last.plainText, + document.blocks.last.plainText.length, + ); + await _key(tester, LogicalKeyboardKey.keyC); + expect(clipboard.data.text, selectAllText); + expect(clipboard.data.sourceText, selectAllMarkdown); + for (final block in document.blocks.where((b) => b.inlines.isNotEmpty)) { + _expectGeometry( + tester, + _field(block.plainText), + 0, + block.plainText.length, + ); + } + _expectGeometry(tester, _field('End!'), 0, 4); + }, + ); + + testWidgets( + 'Select All, mouse and keyboard ranges produce equivalent local selection geometry', + (tester) async { + final clipboard = MemoryRichClipboard(); + final tiny = const MarkdownParser() + .parse( + filePath: 'tiny.md', + source: 'Before.\n\n**Bold** with `code` and plain!\n\nAfter.\n', + ) + .busyDocument; + await _mount(tester, tiny, clipboard); + final field = tester.widget( + _field(tiny.blocks.first.plainText), + ); + field.focusNode!.requestFocus(); + field.controller!.selection = const TextSelection.collapsed(offset: 0); + await tester.pump(); + await _key(tester, LogicalKeyboardKey.keyA); + await _key(tester, LogicalKeyboardKey.keyA); + List<(String, int?, int?)> ranges() => [ + for (final b in tester.widgetList( + find.byType(BusyMarkWysiwygBlockField), + )) + (b.block.id, b.selectionRange?.start, b.selectionRange?.end), + ]; + final allRanges = ranges(); + final allGeometry = _paintedRects( + tester, + _field(tiny.blocks[1].plainText), + ); + await _key(tester, LogicalKeyboardKey.keyC); + final allCopy = clipboard.data.text; + await _drag( + tester, + tiny.blocks.first.plainText, + 0, + tiny.blocks.last.plainText, + tiny.blocks.last.plainText.length, + ); + expect(ranges(), allRanges); + expect( + _paintedRects(tester, _field(tiny.blocks[1].plainText)), + allGeometry, + ); + await _key(tester, LogicalKeyboardKey.keyC); + expect(clipboard.data.text, allCopy); + field.focusNode!.requestFocus(); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + field.controller!.selection = const TextSelection.collapsed(offset: 0); + await tester.pump(); + await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + for ( + var i = 0; + i < tiny.blocks.fold(0, (n, b) => n + b.plainText.length) + 2; + i++ + ) { + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + } + await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + await tester.pumpAndSettle(); + expect(ranges(), allRanges); + expect( + _paintedRects(tester, _field(tiny.blocks[1].plainText)), + allGeometry, + ); + await _key(tester, LogicalKeyboardKey.keyC); + expect(clipboard.data.text, allCopy); + }, + ); + + testWidgets( + 'local table cell selection stays local; subsequent Select All expands to document', + (tester) async { + final clipboard = MemoryRichClipboard(); + await _mount(tester, document, clipboard); + await tester.tap(_field('A longer body cell')); + final field = tester.widget(_field('A longer body cell')); + field.controller!.selection = const TextSelection( + baseOffset: 2, + extentOffset: 8, + ); + await _key(tester, LogicalKeyboardKey.keyC); + expect(clipboard.data.text, 'longer'); + expect(_paintedRects(tester, _field('Header')), isEmpty); + await _key(tester, LogicalKeyboardKey.keyA); + expect(field.controller!.selection.start, 0); + expect(field.controller!.selection.end, field.controller!.text.length); + await _key(tester, LogicalKeyboardKey.keyA); + await _key(tester, LogicalKeyboardKey.keyC); + expect(clipboard.data.text, contains('Previous paragraph')); + expect(clipboard.data.text, contains('End!')); + _expectGeometry(tester, _field('A longer body cell'), 0, 18); + }, + ); + + testWidgets('table to following paragraph retains complete selected rows', ( + tester, + ) async { + final clipboard = MemoryRichClipboard(); + await _mount(tester, document, clipboard); + await _drag(tester, 'Short', 1, document.blocks.last.plainText, 5); + await _key(tester, LogicalKeyboardKey.keyC); + expect(clipboard.data.text, endsWith('\n\nFinal')); + expect(clipboard.data.text, contains('End!')); + expect(clipboard.data.text, isNot(contains('Previous paragraph'))); + for (final text in [ + 'Header', + 'Longer header', + 'Short', + 'A longer body cell', + 'Last row', + 'End!', + ]) { + _expectGeometry(tester, _field(text), 0, text.length); + } + }); + + testWidgets( + 'document selection survives viewport scrolling and block recreation', + (tester) async { + final clipboard = MemoryRichClipboard(); + WysiwygEditorSessionState? session; + await _mount( + tester, + document, + clipboard, + height: 400, + onSession: (_, s) => session = s, + session: WysiwygEditorSessionState( + activeBlockId: document.blocks.first.id, + anchorBlockId: document.blocks.first.id, + extentBlockId: document.blocks.last.id, + extentOffset: document.blocks.last.plainText.length, + ), + ); + final list = tester.widget( + find.byType(ScrollablePositionedList), + ); + for (final index in [0, 3, 6, 8, 1]) { + list.itemScrollController!.jumpTo(index: index, alignment: 0.1); + await tester.pumpAndSettle(); + expect(session?.anchorBlockId, document.blocks.first.id); + expect(session?.extentBlockId, document.blocks.last.id); + final rendered = tester + .widgetList( + find.byType(BusyMarkWysiwygBlockField), + ) + .toList(); + for (final block in rendered.where((b) => b.block.inlines.isNotEmpty)) { + expect(block.selectionRange?.end, block.block.plainText.length); + _expectGeometry( + tester, + _field(block.block.plainText), + 0, + block.block.plainText.length, + ); + } + await _key(tester, LogicalKeyboardKey.keyC); + expect( + clipboard.data.text, + contains('not the final integration result.'), + ); + expect(clipboard.data.text, contains('End!')); + } + }, + ); + + testWidgets('selected table text follows horizontal cell scrolling', ( + tester, + ) async { + final longText = 'long cell content ' * 12; + final scrollDocument = const MarkdownParser() + .parse( + filePath: 'scroll.md', + source: 'Before.\n\n| H |\n| --- |\n| $longText |\n\nAfter.\n', + ) + .busyDocument; + await _mount( + tester, + scrollDocument, + MemoryRichClipboard(), + width: 460, + session: WysiwygEditorSessionState( + activeBlockId: scrollDocument.blocks.last.id, + anchorBlockId: scrollDocument.blocks.first.id, + extentBlockId: scrollDocument.blocks.last.id, + extentOffset: scrollDocument.blocks.last.plainText.length, + ), + ); + final field = _field(longText.trim()); + final editable = _editable(tester, field); + final position = editable.offset as ScrollPosition; + expect(position.maxScrollExtent, greaterThan(0)); + position.jumpTo(position.maxScrollExtent); + await tester.pumpAndSettle(); + _expectGeometry(tester, field, 0, longText.trim().length); + }); + + testWidgets('drag across a viewport jump resolves the visible extent block', ( + tester, + ) async { + WysiwygEditorSessionState? session; + await _mount( + tester, + document, + MemoryRichClipboard(), + height: 600, + onSession: (_, s) => session = s, + ); + final list = tester.widget( + find.byType(ScrollablePositionedList), + ); + list.itemScrollController!.jumpTo(index: 3, alignment: 0.1); + await tester.pumpAndSettle(); + final start = _editable(tester, _field(document.blocks[3].plainText)); + final startPoint = start.localToGlobal( + start.getLocalRectForCaret(const TextPosition(offset: 0)).center, + ); + final gesture = await tester.startGesture( + startPoint, + kind: PointerDeviceKind.mouse, + ); + await gesture.moveTo(startPoint + const Offset(0, 40)); + await tester.pumpAndSettle(); + list.itemScrollController!.jumpTo(index: 6, alignment: 0.1); + await tester.pumpAndSettle(); + final end = _editable(tester, _field(document.blocks.last.plainText)); + final endPoint = end.localToGlobal( + end.getLocalRectForCaret(const TextPosition(offset: 5)).center, + ); + for (var step = 1; step <= 10; step++) { + await gesture.moveTo(Offset.lerp(startPoint, endPoint, step / 10)!); + await tester.pump(const Duration(milliseconds: 180)); + } + await gesture.up(); + await tester.pumpAndSettle(); + expect(session?.anchorBlockId, document.blocks[3].id); + expect(session?.extentBlockId, document.blocks.last.id); + expect(session?.extentOffset, 5); + }, variant: TargetPlatformVariant.only(TargetPlatform.linux)); +} diff --git a/test/support/wysiwyg_editable_harness.dart b/test/support/wysiwyg_editable_harness.dart new file mode 100644 index 00000000..4c2777c4 --- /dev/null +++ b/test/support/wysiwyg_editable_harness.dart @@ -0,0 +1,131 @@ +import 'package:busymark/l10n/generated/app_localizations.dart'; +import 'package:busymark/src/clipboard/clipboard_insertion.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_parser.dart'; +import 'package:busymark/src/platform/rich_clipboard_service.dart'; +import 'package:flutter/material.dart'; + +/// Accepts editor output and rebuilds from the accepted source, as the workspace +/// does. Selection-only tests retain their separate, non-editable harness. +class WysiwygEditableHarness extends StatefulWidget { + const WysiwygEditableHarness({ + super.key, + required this.source, + required this.clipboard, + this.session = const WysiwygEditorSessionState(), + this.externalHistory = false, + this.insertionRegistry, + }); + + final String source; + final RichClipboardService clipboard; + final WysiwygEditorSessionState session; + final bool externalHistory; + final BusyMarkClipboardInsertionRegistry? insertionRegistry; + + @override + State createState() => WysiwygEditableHarnessState(); +} + +class WysiwygEditableHarnessState extends State { + static const parser = MarkdownParser(); + var editorKey = GlobalKey(); + final changes = []; + final undoSources = []; + final redoSources = []; + late String source; + late BusyDocument document; + late WysiwygEditorSessionState session; + final sessions = {}; + String documentId = 'editable'; + var parses = 0; + var undoCalls = 0; + var redoCalls = 0; + + @override + void initState() { + super.initState(); + source = widget.source; + session = widget.session; + _parse(); + } + + void _parse() { + parses++; + document = parser + .parse(filePath: '/$documentId.md', source: source) + .busyDocument; + } + + void _accept(String next) { + if (widget.externalHistory) { + undoSources.add(source); + redoSources.clear(); + } + changes.add(next); + setState(() { + source = next; + _parse(); + }); + } + + void replaceSource( + String next, { + String? id, + WysiwygEditorSessionState? restoredSession, + }) { + setState(() { + documentId = id ?? documentId; + if (restoredSession != null) session = restoredSession; + source = next; + _parse(); + }); + } + + void recreate(WysiwygEditorSessionState saved) { + setState(() { + session = saved; + editorKey = GlobalKey(); + }); + } + + void _undo() { + undoCalls++; + if (undoSources.isEmpty) return; + redoSources.add(source); + replaceSource(undoSources.removeLast()); + } + + void _redo() { + redoCalls++; + if (redoSources.isEmpty) return; + undoSources.add(source); + replaceSource(redoSources.removeLast()); + } + + @override + Widget build(BuildContext context) => MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: BusyMarkWysiwygEditor( + key: editorKey, + document: document, + documentId: documentId, + clipboardService: widget.clipboard, + clipboardInsertionRegistry: widget.insertionRegistry, + initialSessionState: session, + useExternalUndoHistory: widget.externalHistory, + onUndo: _undo, + onRedo: _redo, + onSessionChanged: (id, value) { + sessions[id] = value; + if (id == documentId) session = value; + }, + onSourceChanged: (_, value) => _accept(value), + ), + ), + ); +} diff --git a/tools/wysiwyg_selection_smoke.dart b/tools/wysiwyg_selection_smoke.dart new file mode 100644 index 00000000..623fe2e0 --- /dev/null +++ b/tools/wysiwyg_selection_smoke.dart @@ -0,0 +1,1695 @@ +// Native Linux selection acceptance probe. Reads a disposable document copy, +// drives production key/pointer handlers, and uses the real Linux clipboard. +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:busymark/main.dart' as application; +import 'package:busymark/l10n/generated/app_localizations.dart'; +import 'package:busymark/src/editor/wysiwyg/wysiwyg_block_widgets.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_parser.dart'; +import 'package:busymark/src/platform/rich_clipboard_service.dart'; +import 'package:busymark/src/workspace/workspace_controller.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; +import 'package:window_manager/window_manager.dart'; + +Future main(List arguments) async { + if (arguments.length == 3 && arguments.first == '--application') { + final binding = _ApplicationProbeBinding(); + await application.main([arguments[1]]); + unawaited( + Future.delayed(const Duration(seconds: 2), () async { + await _ApplicationProbeDriver( + boundary: binding.boundary, + fixture: File(arguments[1]), + output: await Directory(arguments[2]).create(recursive: true), + ).run(); + }), + ); + return; + } + _ProbeBinding(); + if (arguments.length != 2) { + throw ArgumentError('INPUT_MARKDOWN OUTPUT_DIRECTORY'); + } + final root = await Directory.systemTemp.createTemp('busymark-selection-'); + final copy = await File(arguments[0]).copy('${root.path}/selection.md'); + final output = await Directory(arguments[1]).create(recursive: true); + final document = const MarkdownParser() + .parse(filePath: copy.path, source: await copy.readAsString()) + .busyDocument; + await windowManager.ensureInitialized(); + await windowManager.setSize(const Size(1100, 950)); + runApp( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: _Probe(document: document, output: output), + ), + ); +} + +// Observe the real client ID while forwarding every platform request. Text +// updates below are delivered on the engine's incoming text-input channel; +// clipboard requests still reach the Linux plugin unchanged. +class _ProbeBinding extends WidgetsFlutterBinding { + @override + BinaryMessenger createBinaryMessenger() => + _ProbeMessenger(super.createBinaryMessenger()); +} + +class _ApplicationProbeBinding extends _ProbeBinding { + final boundary = GlobalKey(); + + @override + Widget wrapWithDefaultView(Widget rootWidget) => super.wrapWithDefaultView( + RepaintBoundary(key: boundary, child: rootWidget), + ); +} + +class _ProbeMessenger extends BinaryMessenger { + _ProbeMessenger(this.delegate); + final BinaryMessenger delegate; + static int? clientId; + + @override + Future? send(String channel, ByteData? message) { + if (channel == SystemChannels.textInput.name && message != null) { + final call = SystemChannels.textInput.codec.decodeMethodCall(message); + if (call.method == 'TextInput.setClient') { + clientId = (call.arguments as List).first as int; + } + if (call.method == 'TextInput.clearClient') clientId = null; + } + return delegate.send(channel, message); + } + + @override + void setMessageHandler(String channel, MessageHandler? handler) => + delegate.setMessageHandler(channel, handler); + + @override + Future handlePlatformMessage( + String channel, + ByteData? data, + ui.PlatformMessageResponseCallback? callback, + ) async { + ServicesBinding.instance.channelBuffers.push( + channel, + data, + callback ?? (_) {}, + ); + } +} + +class _Probe extends StatefulWidget { + const _Probe({required this.document, required this.output}); + final BusyDocument document; + final Directory output; + @override + State<_Probe> createState() => _ProbeState(); +} + +class _ProbeState extends State<_Probe> { + final _boundary = GlobalKey(); + final _clipboard = RichClipboardService(); + final _reports = >[]; + WysiwygEditorSessionState _session = const WysiwygEditorSessionState(); + int _pointer = 100; + int _edits = 0; + late BusyDocument _liveDocument = widget.document; + late String _liveSource = widget.document.source ?? ''; + var _editorKey = UniqueKey(); + bool _editableMode = false; + int _acceptedEdits = 0; + int _syntheticParses = 0; + WysiwygEditorSessionState _initialSession = const WysiwygEditorSessionState(); + + @override + void initState() { + super.initState(); + unawaited(Future.delayed(const Duration(seconds: 2), _run)); + } + + @override + Widget build(BuildContext context) => Scaffold( + body: RepaintBoundary( + key: _boundary, + child: BusyMarkWysiwygEditor( + key: _editorKey, + document: _liveDocument, + clipboardService: _clipboard, + initialSessionState: _initialSession, + onSessionChanged: (_, session) => _session = session, + onSourceChanged: (_, value) { + if (!_editableMode) { + _edits++; + return; + } + _acceptedEdits++; + setState(() { + _liveSource = value; + _syntheticParses++; + _liveDocument = const MarkdownParser() + .parse(filePath: _liveDocument.filePath, source: value) + .busyDocument; + }); + }, + ), + ), + ); + + List _elements(bool Function(Widget) predicate, [Element? root]) { + final result = []; + void visit(Element element) { + if (predicate(element.widget)) result.add(element); + element.visitChildren(visit); + } + + visit(root ?? _boundary.currentContext! as Element); + return result; + } + + Element _field(String text) => + _elements((w) => w is TextField && w.controller?.text == text).single; + + RenderEditable _editable(Element field) => + ((_elements((w) => w is EditableText, field).single as StatefulElement) + .state + as EditableTextState) + .renderEditable; + + Future _pause() => _ProbeInput.pause(); + + void _keyData( + PhysicalKeyboardKey physical, + LogicalKeyboardKey logical, + ui.KeyEventType type, + ) { + _ProbeInput.keyData(physical, logical, type); + } + + Future _key( + PhysicalKeyboardKey physical, + LogicalKeyboardKey logical, { + bool shift = false, + }) => _ProbeInput.command(physical, logical, shift: shift); + + Future _jump(int index) async { + final list = + _elements((w) => w is ScrollablePositionedList).single.widget + as ScrollablePositionedList; + list.itemScrollController!.jumpTo(index: index, alignment: 0.1); + await _pause(); + } + + Future _capture(String label) async { + await _pause(); + final image = + await (_boundary.currentContext!.findRenderObject()! + as RenderRepaintBoundary) + .toImage(); + final data = await image.toByteData(format: ui.ImageByteFormat.png); + image.dispose(); + await File( + '${widget.output.path}/$label.png', + ).writeAsBytes(data!.buffer.asUint8List()); + } + + Future _record( + String label, { + ({String anchorId, int anchorOffset, String extentId, int extentOffset})? + expectedRange, + String? expectedCopy, + List needles = const [], + String? localFieldText, + TextSelection? localSelection, + }) async { + await _key(PhysicalKeyboardKey.keyC, LogicalKeyboardKey.keyC); + final copied = await _clipboard.read(); + await File( + '${widget.output.path}/$label-clipboard.txt', + ).writeAsString(copied.text ?? ''); + await File( + '${widget.output.path}/$label-clipboard.md', + ).writeAsString(copied.sourceText ?? ''); + final geometry = >[]; + for (final blockElement in _elements( + (w) => w is BusyMarkWysiwygBlockField, + )) { + final block = blockElement.widget as BusyMarkWysiwygBlockField; + final range = block.selectionRange; + if (range == null) continue; + for (final fieldElement in _elements( + (w) => w is TextField, + blockElement, + )) { + final field = fieldElement.widget as TextField; + final editable = _editable(fieldElement); + Element? stack; + fieldElement.visitAncestorElements((element) { + if (element.widget is Stack) { + stack = element; + return false; + } + return true; + }); + final overlays = _elements( + (w) => + w is CustomPaint && + w.painter.runtimeType.toString().contains('SelectionPainter'), + stack, + ); + final canvas = _RectCanvas(); + if (overlays.isNotEmpty) { + final overlay = overlays.single; + (overlay.widget as CustomPaint).painter!.paint( + canvas, + (overlay.findRenderObject()! as RenderBox).size, + ); + } + final start = block.block.kind == BusyBlockKind.table ? 0 : range.start; + final end = block.block.kind == BusyBlockKind.table + ? field.controller!.text.length + : range.end; + final expected = editable.getBoxesForSelection( + TextSelection(baseOffset: start, extentOffset: end), + ); + final match = + expected.length == canvas.rects.length && + [ + for ( + var i = 0; + i < expected.length && i < canvas.rects.length; + i++ + ) + (expected[i].toRect().left - canvas.rects[i].left).abs() < + 0.1 && + (expected[i].toRect().right - canvas.rects[i].right).abs() < + 0.1 && + (expected[i].toRect().top - canvas.rects[i].top).abs() < + 0.1 && + (expected[i].toRect().bottom - canvas.rects[i].bottom) + .abs() < + 0.1, + ].every((value) => value); + geometry.add({ + 'block': block.block.id, + 'kind': block.block.kind.name, + 'localStart': start, + 'localEnd': end, + 'textLength': field.controller!.text.length, + 'sourceLength': block.block.rawSource?.length, + 'blockTextLength': block.block.plainText.length, + 'renderedLength': editable.text?.toPlainText().length, + 'actualRects': canvas.rects + .map((r) => [r.left, r.top, r.right, r.bottom]) + .toList(), + 'expectedRects': expected + .map((b) => [b.left, b.top, b.right, b.bottom]) + .toList(), + 'geometryMatches': match, + 'copiedCompleteText': copied.text?.contains(field.controller!.text), + }); + } + } + final local = localFieldText == null + ? null + : (_field(localFieldText).widget as TextField).controller!.selection; + final checks = { + 'logicalRange': + expectedRange == null || + (_session.anchorBlockId == expectedRange.anchorId && + _session.anchorOffset == expectedRange.anchorOffset && + _session.extentBlockId == expectedRange.extentId && + _session.extentOffset == expectedRange.extentOffset), + 'localRange': localSelection == null || local == localSelection, + 'clipboard': + (expectedCopy == null || copied.text == expectedCopy) && + needles.every((needle) => copied.text?.contains(needle) == true), + 'geometry': geometry.every((entry) => entry['geometryMatches'] == true), + 'documentHighlightPresent': localFieldText != null || geometry.isNotEmpty, + }; + _reports.add({ + 'case': label, + 'logicalSelection': _session.toJson(), + 'clipboardLength': copied.text?.length, + 'localFieldSelection': local == null ? null : [local.start, local.end], + 'checks': checks, + 'geometry': geometry, + }); + await _capture(label); + stdout.writeln('$label: $checks'); + return copied.text; + } + + Offset _caret(String text, int offset) { + final editable = _editable(_field(text)); + return editable.localToGlobal( + editable.getLocalRectForCaret(TextPosition(offset: offset)).center, + ); + } + + Future _drag( + String startText, + int startOffset, + String endText, + int endOffset, { + int? scrollTo, + bool cancel = false, + }) async { + final start = _caret(startText, startOffset); + final pointer = ++_pointer; + final viewId = View.of(context).viewId; + GestureBinding.instance.handlePointerEvent( + PointerDownEvent( + viewId: viewId, + pointer: pointer, + position: start, + buttons: kPrimaryMouseButton, + kind: PointerDeviceKind.mouse, + ), + ); + // Start the local drag and allow its normal focus/caret reveal to settle + // before locating the destination in the current viewport. + var previous = start + const Offset(24, 0); + GestureBinding.instance.handlePointerEvent( + PointerMoveEvent( + viewId: viewId, + pointer: pointer, + position: previous, + delta: const Offset(24, 0), + buttons: kPrimaryMouseButton, + kind: PointerDeviceKind.mouse, + ), + ); + await _pause(); + if (scrollTo != null) { + await _pause(); + // Establish a document selection before scrolling, as in a continued + // drag through adjacent blocks followed by a wheel/viewport change. + GestureBinding.instance.handlePointerEvent( + PointerMoveEvent( + viewId: viewId, + pointer: pointer, + position: start + const Offset(0, 70), + delta: const Offset(0, 70), + buttons: kPrimaryMouseButton, + kind: PointerDeviceKind.mouse, + ), + ); + await _pause(); + await _jump(scrollTo); + } + final end = _caret(endText, endOffset); + for (var step = 1; step <= 10; step++) { + final position = Offset.lerp(start, end, step / 10)!; + GestureBinding.instance.handlePointerEvent( + PointerMoveEvent( + viewId: viewId, + pointer: pointer, + position: position, + delta: position - previous, + buttons: kPrimaryMouseButton, + kind: PointerDeviceKind.mouse, + ), + ); + previous = position; + await _pause(); + } + GestureBinding.instance.handlePointerEvent( + cancel + ? PointerCancelEvent( + viewId: viewId, + pointer: pointer, + position: start, + kind: PointerDeviceKind.mouse, + ) + : PointerUpEvent( + viewId: viewId, + pointer: pointer, + position: end, + kind: PointerDeviceKind.mouse, + ), + ); + await _pause(); + } + + Future _run() async { + Object? failure; + try { + final blocks = widget.document.blocks; + ({String anchorId, int anchorOffset, String extentId, int extentOffset}) + range( + BusyBlock anchor, + int anchorOffset, + BusyBlock extent, + int extentOffset, + ) => ( + anchorId: anchor.id, + anchorOffset: anchorOffset, + extentId: extent.id, + extentOffset: extentOffset, + ); + final first = blocks.firstWhere((b) => b.inlines.isNotEmpty); + final firstField = _field(first.plainText).widget as TextField; + firstField.focusNode!.requestFocus(); + firstField.controller!.selection = const TextSelection.collapsed( + offset: 0, + ); + await _pause(); + await _key(PhysicalKeyboardKey.keyA, LogicalKeyboardKey.keyA); + await _record( + '01-local-select-all', + expectedCopy: first.plainText, + localFieldText: first.plainText, + localSelection: TextSelection( + baseOffset: 0, + extentOffset: first.plainText.length, + ), + ); + await _key(PhysicalKeyboardKey.keyA, LogicalKeyboardKey.keyA); + final globalRange = range( + blocks.first, + 0, + blocks.last, + blocks.last.plainText.length, + ); + final globalCopy = await _record( + '02-document-select-all', + expectedRange: globalRange, + needles: const [ + '/mnt/Media/Linguality/gateway', + '/mnt/Media/Linguality/learner_client', + 'not the final integration result.', + ], + ); + await _key(PhysicalKeyboardKey.keyA, LogicalKeyboardKey.keyA); + await _record( + '03-repeat-select-all', + expectedRange: globalRange, + expectedCopy: globalCopy, + ); + await _key(PhysicalKeyboardKey.keyA, LogicalKeyboardKey.keyA); + final paragraphIndex = blocks.indexWhere( + (b) => b.plainText.contains( + 'The client must work before corpus population finishes.', + ), + ); + await _jump(paragraphIndex - 1); + final paragraph = blocks[paragraphIndex]; + await _record( + '04-scroll-selection', + expectedRange: globalRange, + expectedCopy: globalCopy, + ); + final preceding = blocks[paragraphIndex - 1]; + final following = blocks[paragraphIndex + 1]; + await _drag( + preceding.plainText, + 0, + following.plainText, + following.plainText.length, + ); + await _record( + '05-paragraph-drag', + expectedRange: range( + preceding, + 0, + following, + following.plainText.length, + ), + expectedCopy: + '${preceding.plainText}\n\n${paragraph.plainText}\n\n${following.plainText}', + ); + await _drag( + paragraph.plainText, + 0, + paragraph.plainText, + paragraph.plainText.length, + ); + await _record( + '06-paragraph-alone', + expectedCopy: paragraph.plainText, + localFieldText: paragraph.plainText, + localSelection: TextSelection( + baseOffset: 0, + extentOffset: paragraph.plainText.length, + ), + ); + final listIndex = blocks.indexWhere( + (b) => b.plainText == '/mnt/Media/Linguality/gateway', + ); + await _jump(listIndex - 1); + await _drag( + blocks[listIndex - 1].plainText, + 0, + blocks[listIndex + 2].plainText, + blocks[listIndex + 2].plainText.length, + ); + await _record( + '07-list-drag', + expectedRange: range( + blocks[listIndex - 1], + 0, + blocks[listIndex + 2], + blocks[listIndex + 2].plainText.length, + ), + needles: [ + '/mnt/Media/Linguality/gateway', + '/mnt/Media/Linguality/learner_client', + blocks[listIndex + 2].plainText, + ], + ); + final tableIndex = blocks.indexWhere( + (b) => b.kind == BusyBlockKind.table, + ); + await _jump(tableIndex - 1); + await _drag( + blocks[tableIndex - 1].plainText, + 0, + blocks[tableIndex + 1].plainText, + blocks[tableIndex + 1].plainText.length, + ); + final table = blocks[tableIndex]; + final cells = table.children.expand((row) => row.children).toList(); + final tableRange = range( + blocks[tableIndex - 1], + 0, + blocks[tableIndex + 1], + blocks[tableIndex + 1].plainText.length, + ); + final tableCopy = await _record( + '08-table-drag', + expectedRange: tableRange, + needles: [for (final cell in cells) cell.plainText], + ); + await windowManager.setSize(const Size(760, 950)); + await _pause(); + await _record( + '09-rewrapped-selection', + expectedRange: tableRange, + expectedCopy: tableCopy, + ); + await _jump(listIndex - 1); + await _drag( + blocks[listIndex - 1].plainText, + 0, + following.plainText, + following.plainText.length, + scrollTo: paragraphIndex, + ); + await _record( + '10-drag-with-scroll', + expectedRange: range( + blocks[listIndex - 1], + 0, + following, + following.plainText.length, + ), + needles: [ + paragraph.plainText, + '/mnt/Media/Linguality/gateway', + '/mnt/Media/Linguality/learner_client', + for (final cell in cells) cell.plainText, + ], + ); + await _jump(paragraphIndex); + await _drag(paragraph.plainText, 55, following.plainText, 17); + await _record( + '11-styled-drag-endpoints', + expectedRange: range(paragraph, 55, following, 17), + expectedCopy: + '${paragraph.plainText.substring(55)}\n\n${following.plainText.substring(0, 17)}', + ); + await _jump(tableIndex); + await _drag(cells.first.plainText, 1, cells.first.plainText, 3); + await _record( + '12-cell-local-drag', + expectedCopy: cells.first.plainText.substring(1, 3), + localFieldText: cells.first.plainText, + localSelection: const TextSelection(baseOffset: 1, extentOffset: 3), + ); + await _drag( + cells.first.plainText, + 0, + cells.last.plainText, + cells.last.plainText.length, + ); + await _record( + '13-cross-cell-drag', + expectedRange: range(table, 0, table, 0), + needles: [for (final cell in cells) cell.plainText], + ); + await _runEditableCases(); + } catch (error, stack) { + failure = '$error\n$stack'; + stderr.writeln(failure); + } + await File('${widget.output.path}/report.json').writeAsString( + const JsonEncoder.withIndent(' ').convert({ + 'failure': failure?.toString(), + 'edits': _edits, + 'acceptedSyntheticEdits': _acceptedEdits, + 'syntheticParses': _syntheticParses, + 'inputMethod': + 'framework-dispatched key/pointer events and engine-channel text-input editing updates; real Linux clipboard', + 'cases': _reports, + }), + ); + final checksPass = _reports.every( + (report) => (report['checks'] as Map).values.every( + (value) => value, + ), + ); + exit(failure == null && _edits == 0 && checksPass ? 0 : 1); + } + + static const _syntheticTable = + '| Header | Longer header |\n| --- | --- |\n| Short | Body text |\n| Last | End! |\n'; + + Future _setSynthetic( + String source, { + WysiwygEditorSessionState session = const WysiwygEditorSessionState(), + }) async { + setState(() { + _editableMode = true; + _liveSource = source; + _syntheticParses++; + _liveDocument = const MarkdownParser() + .parse(filePath: '${widget.output.path}/synthetic.md', source: source) + .busyDocument; + _editorKey = UniqueKey(); + _initialSession = session; + _session = const WysiwygEditorSessionState(); + }); + await _pause(); + await _pause(); + } + + Future _selectSyntheticTable() async { + final field = _field('Header').widget as TextField; + field.focusNode!.requestFocus(); + field.controller!.selection = const TextSelection.collapsed(offset: 0); + await _pause(); + await _key(PhysicalKeyboardKey.keyA, LogicalKeyboardKey.keyA); + await _key(PhysicalKeyboardKey.keyA, LogicalKeyboardKey.keyA); + } + + Future _press( + PhysicalKeyboardKey physical, + LogicalKeyboardKey logical, + ) async { + _keyData(physical, logical, ui.KeyEventType.down); + _keyData(physical, logical, ui.KeyEventType.up); + await _pause(); + } + + Future _deliverText( + String text, { + TextRange composing = TextRange.empty, + int? caretOffset, + }) => _ProbeInput.deliverText( + text, + composing: composing, + caretOffset: caretOffset, + ); + + Future _recordEdit( + String label, + String expectedSource, + int expectedEdits, { + String? caretText, + int? caretOffset, + bool selectionCleared = true, + }) async { + final focused = _elements( + (w) => w is TextField && w.focusNode?.hasFocus == true, + ); + final field = focused.singleOrNull?.widget as TextField?; + final checks = { + 'source': _liveSource == expectedSource, + 'editCount': _acceptedEdits == expectedEdits, + 'selection': + selectionCleared == + _elements( + (w) => w is BusyMarkWysiwygBlockField && w.selectionRange != null, + ).isEmpty, + 'caret': + caretText == null || + (field?.controller?.text == caretText && + field?.controller?.selection.extentOffset == caretOffset), + 'parsedStructure': + caretText == null || + _liveDocument.blocks.every((b) => b.kind == BusyBlockKind.paragraph), + }; + _reports.add({ + 'case': label, + 'checks': checks, + 'acceptedSource': _liveSource, + 'blocks': [ + for (final b in _liveDocument.blocks) + {'kind': b.kind.name, 'text': b.plainText, 'rows': b.children.length}, + ], + 'logicalSelection': _session.toJson(), + 'acceptedEdits': _acceptedEdits, + 'caretText': field?.controller?.text, + 'caretOffset': field?.controller?.selection.extentOffset, + }); + await _capture(label); + stdout.writeln('$label: $checks'); + if (checks.values.any((v) => !v)) { + throw StateError('$label failed: $checks'); + } + } + + Future _undoSynthetic(String label, String source) async { + final expectedEdits = _acceptedEdits + 1; + await _key(PhysicalKeyboardKey.keyZ, LogicalKeyboardKey.keyZ); + await _recordEdit('$label-undo', source, expectedEdits); + } + + Future _runEditableCases() async { + await windowManager.setSize(const Size(1100, 950)); + await _setSynthetic(_syntheticTable); + await _selectSyntheticTable(); + final table = _liveDocument.blocks.single; + final range = ( + anchorId: table.id, + anchorOffset: 0, + extentId: table.id, + extentOffset: 0, + ); + await _record( + '14-synthetic-table-select-all', + expectedRange: range, + needles: ['Header', 'Body text', 'End!'], + ); + final parses = _syntheticParses; + await _drag('Header', 0, 'End!', 4, cancel: true); + if (_elements( + (w) => w is BusyMarkWysiwygBlockField && w.documentSelectionDragging, + ).isNotEmpty) { + throw StateError('Cancelled table drag retained ownership'); + } + if (_syntheticParses != parses) { + throw StateError('Selection reparsed the synthetic source'); + } + await _record( + '15-synthetic-table-cancel', + expectedRange: range, + needles: ['Body text', 'End!'], + ); + final cancelled = _session; + setState(() { + _initialSession = WysiwygEditorSessionState.fromJson( + Map.from( + jsonDecode(jsonEncode(cancelled.toJson())) as Map, + ), + ); + _editorKey = UniqueKey(); + }); + await _pause(); + await _pause(); + await _record( + '16-restored-table-selection', + expectedRange: range, + needles: ['Header', 'Body text', 'End!'], + ); + + for (final action in [ + 'cut', + 'delete', + 'backspace', + 'enter', + 'plain-paste', + 'structured-paste', + ]) { + await _selectSyntheticTable(); + final edits = _acceptedEdits + 1; + var expectedSource = ''; + var caret = ''; + switch (action) { + case 'cut': + await _key(PhysicalKeyboardKey.keyX, LogicalKeyboardKey.keyX); + case 'delete': + await _press(PhysicalKeyboardKey.delete, LogicalKeyboardKey.delete); + case 'backspace': + await _press( + PhysicalKeyboardKey.backspace, + LogicalKeyboardKey.backspace, + ); + case 'enter': + expectedSource = '\n'; + await _press(PhysicalKeyboardKey.enter, LogicalKeyboardKey.enter); + case 'plain-paste': + caret = 'Pasted 🧭'; + expectedSource = '$caret\n'; + if (!await _clipboard.write(RichClipboardData(text: caret))) { + throw StateError('Linux clipboard write failed'); + } + await _key( + PhysicalKeyboardKey.keyV, + LogicalKeyboardKey.keyV, + shift: true, + ); + case 'structured-paste': + caret = 'Structured'; + expectedSource = '**Structured**\n'; + if (!await _clipboard.write( + const RichClipboardData( + text: 'Structured', + html: '

Structured

', + ), + )) { + throw StateError('Linux rich clipboard write failed'); + } + await _key(PhysicalKeyboardKey.keyV, LogicalKeyboardKey.keyV); + } + if (action == 'cut') { + final data = await _clipboard.read(); + if (data.sourceText?.contains('| Short | Body text |') != true) { + throw StateError('Linux cut omitted table structure'); + } + } + await _recordEdit( + '17-table-$action', + expectedSource, + edits, + caretText: caret, + caretOffset: caret.length, + ); + await _undoSynthetic('17-table-$action', _syntheticTable); + } + + await _selectSyntheticTable(); + final preeditEdits = _acceptedEdits; + await _deliverText('n', composing: const TextRange(start: 0, end: 1)); + await _deliverText('ni', composing: const TextRange(start: 0, end: 2)); + await _recordEdit( + '18-table-input-preedit', + _syntheticTable, + preeditEdits, + selectionCleared: false, + ); + await _press(PhysicalKeyboardKey.backspace, LogicalKeyboardKey.backspace); + await _press(PhysicalKeyboardKey.delete, LogicalKeyboardKey.delete); + await _press(PhysicalKeyboardKey.enter, LogicalKeyboardKey.enter); + await _recordEdit( + '18-table-composition-keys', + _syntheticTable, + preeditEdits, + selectionCleared: false, + ); + await _deliverText(''); + await _recordEdit( + '19-table-input-cancel', + _syntheticTable, + preeditEdits, + selectionCleared: false, + ); + await _deliverText('你', composing: const TextRange(start: 0, end: 1)); + await _deliverText('你🧭'); + await _recordEdit( + '20-table-input-commit', + '你🧭\n', + preeditEdits + 1, + caretText: '你🧭', + caretOffset: 3, + ); + await _deliverText('你🧭 next'); + await _recordEdit( + '21-table-input-continued', + '你🧭 next\n', + preeditEdits + 2, + caretText: '你🧭 next', + caretOffset: 8, + ); + await _undoSynthetic('21-table-input-continued', '你🧭\n'); + await _undoSynthetic('20-table-input-commit', _syntheticTable); + + const surrounded = 'Before stays.\n\n$_syntheticTable\nAfter stays.\n'; + await _setSynthetic(surrounded); + await _drag('Before stays.', 6, 'After stays.', 5, cancel: true); + if (_elements( + (w) => w is BusyMarkWysiwygBlockField && w.documentSelectionDragging, + ).isNotEmpty) { + throw StateError('Cancelled cross-block drag retained ownership'); + } + await _record( + '22-paragraph-table-paragraph-cancel', + expectedRange: ( + anchorId: _liveDocument.blocks.first.id, + anchorOffset: 6, + extentId: _liveDocument.blocks.last.id, + extentOffset: 5, + ), + needles: ['Body text'], + ); + final cancelInputEdits = _acceptedEdits + 1; + await _deliverText('After🧭 stays.', caretOffset: 7); + await _recordEdit( + '22-cancel-committed-input', + 'Before🧭 stays.\n', + cancelInputEdits, + caretText: 'Before🧭 stays.', + caretOffset: 8, + ); + await _undoSynthetic('22-cancel-committed-input', surrounded); + await _drag('Before stays.', 6, 'After stays.', 5, cancel: true); + final edits = _acceptedEdits + 1; + if (!await _clipboard.write(const RichClipboardData(text: 'X'))) { + throw StateError('Linux clipboard write failed'); + } + await _key(PhysicalKeyboardKey.keyV, LogicalKeyboardKey.keyV, shift: true); + await _recordEdit( + '23-partial-table-range-paste', + 'BeforeX stays.\n', + edits, + caretText: 'BeforeX stays.', + caretOffset: 7, + ); + await _undoSynthetic('23-partial-table-range-paste', surrounded); + await _runRestoredOrdinaryInput(); + } + + Future _runRestoredOrdinaryInput() async { + for (final fixture in [ + ( + name: 'paragraphs', + source: 'Before stays.\n\nMiddle selected.\n\nAfter stays.\n', + table: false, + ), + ( + name: 'mixed', + source: 'Before stays.\n\n$_syntheticTable\nAfter stays.\n', + table: false, + ), + ( + name: 'table-control', + source: 'Before stays.\n\n$_syntheticTable', + table: true, + ), + ]) { + for (final reverse in [false, true]) { + await _setSynthetic(fixture.source); + final lastText = fixture.table ? 'End!' : 'After stays.'; + final lastOffset = fixture.table ? 4 : 5; + await _drag( + reverse ? lastText : 'Before stays.', + reverse ? lastOffset : 6, + reverse ? 'Before stays.' : lastText, + reverse ? 6 : lastOffset, + ); + final saved = WysiwygEditorSessionState.fromJson( + Map.from( + jsonDecode(jsonEncode(_session.toJson())) as Map, + ), + ); + final edits = _acceptedEdits; + await _setSynthetic('Other document stays unchanged.\n'); + await _setSynthetic(fixture.source, session: saved); + // No focus/selection command intervenes between restoration and input. + final tableInput = fixture.table && !reverse; + final extentText = reverse ? 'Before stays.' : 'After stays.'; + final extentOffset = reverse ? 6 : 5; + final focused = _elements( + (w) => w is TextField && w.focusNode?.hasFocus == true, + ); + final checks = { + 'source': _liveSource == fixture.source, + 'noRestorationEdits': _acceptedEdits == edits, + 'logicalRange': + _session.anchorBlockId == saved.anchorBlockId && + _session.anchorOffset == saved.anchorOffset && + _session.extentBlockId == saved.extentBlockId && + _session.extentOffset == saved.extentOffset, + 'inputClient': _ProbeMessenger.clientId != null, + 'extentFocus': + tableInput || + (focused.singleOrNull?.widget as TextField?)?.controller?.text == + extentText, + }; + final label = '24-restored-${fixture.name}-reverse-$reverse'; + _reports.add({ + 'case': '$label-focus', + 'checks': checks, + 'logicalSelection': _session.toJson(), + 'client': _ProbeMessenger.clientId, + }); + if (checks.values.any((v) => !v)) throw StateError('$label: $checks'); + await _deliverText( + tableInput + ? '你🧭' + : extentText.replaceRange(extentOffset, extentOffset, '你🧭'), + caretOffset: tableInput ? 3 : extentOffset + 3, + ); + final suffix = fixture.table ? '' : ' stays.'; + final replaced = 'Before你🧭$suffix'; + final continued = 'Before你🧭 next$suffix'; + await _recordEdit( + '$label-commit', + '$replaced\n', + edits + 1, + caretText: replaced, + caretOffset: 9, + ); + await _deliverText(continued, caretOffset: 14); + await _recordEdit( + '$label-continued', + '$continued\n', + edits + 2, + caretText: continued, + caretOffset: 14, + ); + await _undoSynthetic('$label-continued', '$replaced\n'); + await _undoSynthetic('$label-commit', fixture.source); + await _key( + PhysicalKeyboardKey.keyZ, + LogicalKeyboardKey.keyZ, + shift: true, + ); + await _recordEdit('$label-commit-redo', '$replaced\n', edits + 5); + await _key( + PhysicalKeyboardKey.keyZ, + LogicalKeyboardKey.keyZ, + shift: true, + ); + await _recordEdit('$label-continued-redo', '$continued\n', edits + 6); + } + } + } +} + +// Runs the actual main entry point and workspace buffer/session/history path. +// Only the documented disposable synthetic table fixture may be edited. +class _ApplicationProbeDriver { + _ApplicationProbeDriver({ + required this.boundary, + required this.fixture, + required this.output, + }); + final GlobalKey boundary; + final File fixture; + final Directory output; + final clipboard = busyMarkRichClipboardService; + final reports = >[]; + + List elements(bool Function(Widget) predicate, [Element? root]) { + final found = []; + void visit(Element element) { + if (predicate(element.widget)) found.add(element); + element.visitChildren(visit); + } + + visit(root ?? boundary.currentContext! as Element); + return found; + } + + Element get editorElement => + elements((w) => w is BusyMarkWysiwygEditor).single; + BusyMarkWysiwygEditor get editor => + editorElement.widget as BusyMarkWysiwygEditor; + ProviderContainer get container => + ProviderScope.containerOf(editorElement, listen: false); + WorkspaceController get controller => + container.read(workspaceControllerProvider.notifier); + String get source => + container.read(workspaceControllerProvider).activeBuffer!.text; + WysiwygEditorSessionState get session => container + .read(workspaceControllerProvider) + .activeBuffer! + .editorState + .wysiwygState; + List get selected => elements( + (w) => w is BusyMarkWysiwygBlockField && w.selectionRange != null, + ).map((e) => e.widget as BusyMarkWysiwygBlockField).toList(); + + Future settle() async { + await Future.delayed(const Duration(milliseconds: 400)); + await _ProbeInput.pause(); + } + + Future record(String label, Map checks) async { + reports.add({ + 'case': label, + 'checks': checks, + 'source': source, + 'session': session.toJson(), + 'undoEntries': container + .read(workspaceControllerProvider) + .activeBuffer! + .editorState + .undoState + .undo + .length, + }); + final image = + await (boundary.currentContext!.findRenderObject()! + as RenderRepaintBoundary) + .toImage(); + final bytes = await image.toByteData(format: ui.ImageByteFormat.png); + image.dispose(); + await File( + '${output.path}/$label.png', + ).writeAsBytes(bytes!.buffer.asUint8List()); + stdout.writeln('$label: $checks'); + if (checks.values.any((v) => !v)) { + throw StateError('$label failed: $checks'); + } + } + + Future command( + PhysicalKeyboardKey physical, + LogicalKeyboardKey logical, { + bool shift = false, + }) async { + await _ProbeInput.command(physical, logical, shift: shift); + await settle(); + } + + Future selectTable() async { + final field = + elements( + (w) => w is TextField && w.controller?.text == 'Header', + ).single.widget + as TextField; + field.focusNode!.requestFocus(); + field.controller!.selection = const TextSelection.collapsed(offset: 0); + await settle(); + await command(PhysicalKeyboardKey.keyA, LogicalKeyboardKey.keyA); + await command(PhysicalKeyboardKey.keyA, LogicalKeyboardKey.keyA); + } + + bool get tableSelected => + selected.length == 1 && + selected.single.block.kind == BusyBlockKind.table && + selected.single.selectionRange!.start == 0 && + selected.single.selectionRange!.end == 0; + + Future checkTableCopy(String label) async { + await command(PhysicalKeyboardKey.keyC, LogicalKeyboardKey.keyC); + final data = await clipboard.read(); + await File('${output.path}/$label-clipboard-summary.json').writeAsString( + jsonEncode({ + 'textLength': data.text?.length, + 'sourceTextLength': data.sourceText?.length, + 'htmlLength': data.html?.length, + 'textContainsHeader': data.text?.contains('Header'), + 'textContainsEnd': data.text?.contains('End!'), + 'sourceContainsTable': data.sourceText?.contains( + '| Short | Body text |', + ), + }), + ); + await record(label, { + 'source': source == _ProbeState._syntheticTable, + 'selection': tableSelected, + 'clipboard': + data.sourceText?.contains('| Short | Body text |') == true && + data.text?.contains('End!') == true, + }); + } + + Future run() async { + Object? failure; + try { + if (await fixture.readAsString() != _ProbeState._syntheticTable) { + throw ArgumentError( + 'Application probe requires the disposable synthetic table fixture', + ); + } + await settle(); + await selectTable(); + await checkTableCopy('01-normal-select-all'); + await command(PhysicalKeyboardKey.keyA, LogicalKeyboardKey.keyA); + await checkTableCopy('02-normal-repeat-select-all'); + final revision = controller.editRevision; + final frameBeforeIdle = WidgetsBinding.instance.hasScheduledFrame; + await Future.delayed(const Duration(milliseconds: 600)); + await record('03-normal-idle-selection', { + 'sourceUnchanged': source == _ProbeState._syntheticTable, + 'revisionUnchanged': controller.editRevision == revision, + 'noPerpetualFrame': + !frameBeforeIdle && !WidgetsBinding.instance.hasScheduledFrame, + }); + + final tableBuffer = container + .read(workspaceControllerProvider) + .activeBuffer! + .id; + final saved = jsonEncode(session.toJson()); + if (!await controller.openActiveFile('${fixture.parent.path}/other.md')) { + throw StateError('Could not open the other disposable tab'); + } + await settle(); + await command(PhysicalKeyboardKey.tab, LogicalKeyboardKey.tab); + await record('04-normal-tab-restoration', { + 'activeTab': + container.read(workspaceControllerProvider).activeBuffer!.id == + tableBuffer, + 'tableSelection': tableSelected, + 'endpoints': + session.anchorBlockId == + (jsonDecode(saved) as Map)['anchorBlockId'] && + session.extentBlockId == + (jsonDecode(saved) as Map)['extentBlockId'], + }); + await checkTableCopy('05-normal-restored-copy'); + + _ProbeInput.keyData( + PhysicalKeyboardKey.delete, + LogicalKeyboardKey.delete, + ui.KeyEventType.down, + ); + _ProbeInput.keyData( + PhysicalKeyboardKey.delete, + LogicalKeyboardKey.delete, + ui.KeyEventType.up, + ); + await settle(); + await record('06-normal-table-delete', { + // Workspace buffers retain the file's existing final-newline policy. + 'source': source == '\n', + 'structure': editor.document.blocks.every( + (b) => b.kind == BusyBlockKind.paragraph, + ), + 'selectionCleared': selected.isEmpty, + 'history': + container + .read(workspaceControllerProvider) + .activeBuffer! + .editorState + .undoState + .undo + .length == + 1, + }); + await command(PhysicalKeyboardKey.keyZ, LogicalKeyboardKey.keyZ); + await checkTableCopy('07-normal-delete-undo'); + await command( + PhysicalKeyboardKey.keyZ, + LogicalKeyboardKey.keyZ, + shift: true, + ); + await record('08-normal-delete-redo', {'source': source == '\n'}); + await command(PhysicalKeyboardKey.keyZ, LogicalKeyboardKey.keyZ); + await checkTableCopy('09-normal-redo-undo'); + + await _ProbeInput.deliverText( + 'ni', + composing: const TextRange(start: 0, end: 2), + ); + await settle(); + await record('10-normal-preedit', { + 'source': source == _ProbeState._syntheticTable, + 'selection': tableSelected, + }); + await _ProbeInput.deliverText(''); + await settle(); + await record('11-normal-composition-cancel', { + 'source': source == _ProbeState._syntheticTable, + 'selection': tableSelected, + }); + await _ProbeInput.deliverText('你🧭'); + await settle(); + await record('12-normal-committed-input', { + 'source': source == '你🧭\n', + 'selectionCleared': selected.isEmpty, + 'history': + container + .read(workspaceControllerProvider) + .activeBuffer! + .editorState + .undoState + .undo + .length == + 1, + }); + await _ProbeInput.deliverText('你🧭 next'); + await settle(); + await record('13-normal-continued-input', { + 'source': source == '你🧭 next\n', + 'caret': + (elements( + (w) => w is TextField && w.focusNode?.hasFocus == true, + ).single.widget + as TextField) + .controller! + .selection + .extentOffset == + 8, + }); + await command(PhysicalKeyboardKey.keyZ, LogicalKeyboardKey.keyZ); + await record('14-normal-continued-undo', {'source': source == '你🧭\n'}); + await command(PhysicalKeyboardKey.keyZ, LogicalKeyboardKey.keyZ); + await checkTableCopy('15-normal-input-undo'); + + await command(PhysicalKeyboardKey.keyX, LogicalKeyboardKey.keyX); + final cut = await clipboard.read(); + await record('16-normal-cut', { + 'source': source == '\n', + 'clipboard': cut.sourceText?.contains('| Last | End! |') == true, + }); + await command(PhysicalKeyboardKey.keyZ, LogicalKeyboardKey.keyZ); + await checkTableCopy('17-normal-cut-undo'); + await clipboard.write(const RichClipboardData(text: 'Pasted 🧭')); + await command( + PhysicalKeyboardKey.keyV, + LogicalKeyboardKey.keyV, + shift: true, + ); + await record('18-normal-paste', { + 'source': source == 'Pasted 🧭\n', + 'selectionCleared': selected.isEmpty, + }); + await command(PhysicalKeyboardKey.keyZ, LogicalKeyboardKey.keyZ); + await checkTableCopy('19-normal-paste-undo'); + await record('20-normal-fixture-integrity', { + 'diskUnchanged': + await fixture.readAsString() == _ProbeState._syntheticTable, + }); + await runRestoredOrdinaryInput(); + } catch (error, stack) { + failure = '$error\n$stack'; + stderr.writeln(failure); + } + await File('${output.path}/report.json').writeAsString( + const JsonEncoder.withIndent(' ').convert({ + 'failure': failure?.toString(), + 'entryPoint': 'lib/main.dart', + 'inputMethod': + 'framework-dispatched keys and engine-channel editing updates; native Linux clipboard; actual workspace buffers, tab recreation, and external history', + 'cases': reports, + }), + ); + exit(failure == null ? 0 : 1); + } + + Future runRestoredOrdinaryInput() async { + for (final name in ['paragraphs', 'mixed']) { + final path = '${fixture.parent.path}/$name.md'; + final original = await File(path).readAsString(); + final expected = name == 'paragraphs' + ? 'Before stays.\n\nMiddle selected.\n\nAfter stays.\n' + : 'Before stays.\n\n${_ProbeState._syntheticTable}\nAfter stays.\n'; + if (original != expected) { + throw ArgumentError('Unexpected synthetic fixture: $path'); + } + if (!await controller.openActiveFile(path)) { + throw StateError('Could not open $name fixture'); + } + await settle(); + for (final reverse in [false, true]) { + final fromText = reverse ? 'After stays.' : 'Before stays.'; + final toText = reverse ? 'Before stays.' : 'After stays.'; + Offset caret(String text, int offset) { + final field = elements( + (w) => w is TextField && w.controller?.text == text, + ).single; + final editable = + (elements((w) => w is EditableText, field).single + as StatefulElement) + .state + as EditableTextState; + return editable.renderEditable.localToGlobal( + editable.renderEditable + .getLocalRectForCaret(TextPosition(offset: offset)) + .center, + ); + } + + final start = caret(fromText, reverse ? 5 : 6); + final end = caret(toText, reverse ? 6 : 5); + final viewId = View.of(editorElement).viewId; + final pointer = 500 + (name == 'mixed' ? 2 : 0) + (reverse ? 1 : 0); + GestureBinding.instance.handlePointerEvent( + PointerDownEvent( + viewId: viewId, + pointer: pointer, + position: start, + buttons: kPrimaryMouseButton, + kind: PointerDeviceKind.mouse, + ), + ); + GestureBinding.instance.handlePointerEvent( + PointerMoveEvent( + viewId: viewId, + pointer: pointer, + position: end, + delta: end - start, + buttons: kPrimaryMouseButton, + kind: PointerDeviceKind.mouse, + ), + ); + GestureBinding.instance.handlePointerEvent( + PointerUpEvent( + viewId: viewId, + pointer: pointer, + position: end, + kind: PointerDeviceKind.mouse, + ), + ); + await settle(); + final saved = session; + final bufferId = container + .read(workspaceControllerProvider) + .activeBuffer! + .id; + final revision = controller.editRevision; + final undoBefore = container + .read(workspaceControllerProvider) + .activeBuffer! + .editorState + .undoState + .undo + .length; + if (!await controller.openActiveFile( + '${fixture.parent.path}/other.md', + )) { + throw StateError('Could not open other tab'); + } + await settle(); + final openCount = container + .read(workspaceControllerProvider) + .documentBuffers + .length; + for (var attempt = 0; attempt < openCount; attempt++) { + await command(PhysicalKeyboardKey.tab, LogicalKeyboardKey.tab); + if (container.read(workspaceControllerProvider).activeBuffer!.id == + bufferId) { + break; + } + } + final label = '21-normal-restored-$name-reverse-$reverse'; + final focused = + elements( + (w) => w is TextField && w.focusNode?.hasFocus == true, + ).singleOrNull?.widget + as TextField?; + await record('$label-focus', { + 'activeBuffer': + container.read(workspaceControllerProvider).activeBuffer!.id == + bufferId, + 'logicalRange': + session.anchorBlockId == saved.anchorBlockId && + session.anchorOffset == saved.anchorOffset && + session.extentBlockId == saved.extentBlockId && + session.extentOffset == saved.extentOffset, + 'sourceUnchanged': source == original, + 'revisionUnchanged': controller.editRevision == revision, + 'historyUnchanged': + container + .read(workspaceControllerProvider) + .activeBuffer! + .editorState + .undoState + .undo + .length == + undoBefore, + 'extentFocus': focused?.controller?.text == toText, + 'inputClient': _ProbeMessenger.clientId != null, + 'selectedBlocks': selected.length == 3, + }); + // Deliver immediately to the restored client; no focus command intervenes. + final offset = reverse ? 6 : 5; + await _ProbeInput.deliverText( + toText.replaceRange(offset, offset, '你🧭'), + caretOffset: offset + 3, + ); + await settle(); + await record('$label-commit', { + 'source': source == 'Before你🧭 stays.\n', + 'structure': + editor.document.blocks.length == 1 && + editor.document.blocks.single.kind == BusyBlockKind.paragraph, + 'selectionCleared': selected.isEmpty, + 'caret': + (elements( + (w) => w is TextField && w.focusNode?.hasFocus == true, + ).single.widget + as TextField) + .controller! + .selection + .extentOffset == + 9, + 'history': + container + .read(workspaceControllerProvider) + .activeBuffer! + .editorState + .undoState + .undo + .length == + undoBefore + 1, + }); + await _ProbeInput.deliverText('Before你🧭 next stays.', caretOffset: 14); + await settle(); + await record('$label-continued', { + 'source': source == 'Before你🧭 next stays.\n', + 'caret': + (elements( + (w) => w is TextField && w.focusNode?.hasFocus == true, + ).single.widget + as TextField) + .controller! + .selection + .extentOffset == + 14, + }); + await command(PhysicalKeyboardKey.keyZ, LogicalKeyboardKey.keyZ); + await record('$label-continued-undo', { + 'source': source == 'Before你🧭 stays.\n', + }); + await command(PhysicalKeyboardKey.keyZ, LogicalKeyboardKey.keyZ); + await record('$label-commit-undo', {'source': source == original}); + await command( + PhysicalKeyboardKey.keyZ, + LogicalKeyboardKey.keyZ, + shift: true, + ); + await record('$label-commit-redo', { + 'source': source == 'Before你🧭 stays.\n', + }); + await command( + PhysicalKeyboardKey.keyZ, + LogicalKeyboardKey.keyZ, + shift: true, + ); + await record('$label-continued-redo', { + 'source': source == 'Before你🧭 next stays.\n', + }); + await command(PhysicalKeyboardKey.keyZ, LogicalKeyboardKey.keyZ); + await command(PhysicalKeyboardKey.keyZ, LogicalKeyboardKey.keyZ); + await record('$label-integrity', { + 'source': source == original, + 'diskUnchanged': await File(path).readAsString() == original, + }); + } + } + } +} + +class _ProbeInput { + static Future pause() async { + await Future.delayed(const Duration(milliseconds: 180)); + await WidgetsBinding.instance.endOfFrame; + } + + static void keyData( + PhysicalKeyboardKey physical, + LogicalKeyboardKey logical, + ui.KeyEventType type, + ) { + // Synthesized framework messages also update HardwareKeyboard. These are + // intentionally distinct from physical input or X11-generated events. + // ignore: deprecated_member_use + ServicesBinding.instance.keyEventManager.handleKeyData( + ui.KeyData( + timeStamp: Duration.zero, + type: type, + physical: physical.usbHidUsage, + logical: logical.keyId, + character: null, + synthesized: true, + ), + ); + } + + static Future command( + PhysicalKeyboardKey physical, + LogicalKeyboardKey logical, { + bool shift = false, + }) async { + keyData( + PhysicalKeyboardKey.controlLeft, + LogicalKeyboardKey.controlLeft, + ui.KeyEventType.down, + ); + if (shift) { + keyData( + PhysicalKeyboardKey.shiftLeft, + LogicalKeyboardKey.shiftLeft, + ui.KeyEventType.down, + ); + } + keyData(physical, logical, ui.KeyEventType.down); + keyData(physical, logical, ui.KeyEventType.up); + if (shift) { + keyData( + PhysicalKeyboardKey.shiftLeft, + LogicalKeyboardKey.shiftLeft, + ui.KeyEventType.up, + ); + } + keyData( + PhysicalKeyboardKey.controlLeft, + LogicalKeyboardKey.controlLeft, + ui.KeyEventType.up, + ); + await pause(); + } + + static Future deliverText( + String text, { + TextRange composing = TextRange.empty, + int? caretOffset, + }) async { + final client = _ProbeMessenger.clientId; + if (client == null) throw StateError('No active Linux text-input client'); + ServicesBinding.instance.channelBuffers.push( + SystemChannels.textInput.name, + SystemChannels.textInput.codec.encodeMethodCall( + MethodCall('TextInputClient.updateEditingState', [ + client, + TextEditingValue( + text: text, + selection: TextSelection.collapsed( + offset: caretOffset ?? text.length, + ), + composing: composing, + ).toJSON(), + ]), + ), + (_) {}, + ); + await pause(); + } +} + +class _RectCanvas implements Canvas { + final rects = []; + @override + void drawRect(Rect rect, Paint paint) => rects.add(rect); + @override + dynamic noSuchMethod(Invocation invocation) => null; +} From 7b638aa79bfb2518a34ddea927de3f7c48a220ce Mon Sep 17 00:00:00 2001 From: albert Date: Sun, 4 Oct 2026 00:50:32 -0700 Subject: [PATCH 06/14] Prioritize GTK system accent and coordinate Linux accent sources * Prioritize native GTK style properties (`accent_bg_color` and `theme_selected_bg_color`) over D-Bus portal properties for Linux system accents. * Implement `GtkAccentObserver` and a `com.busymark.app/gtk_accent` platform channel to observe and stream GTK theme invalidations to Dart. * Introduce `LinuxAccentCoordinator` to manage `LinuxAccentSource` subscriptions and enforce priority fallback from GTK to Gnome/Freedesktop portals. * Refactor `LinuxPortalAppearance` to implement `LinuxAccentSource` with a testable `LinuxPortalAccentIO` D-Bus boundary. * Add native C++ probing tool (`test_gtk_accent.sh` and `gtk_accent_probe.cc`) and Dart test suites to validate accent resolution and platform channels. --- lib/main.dart | 22 +- lib/src/app/system_accent.dart | 338 ++++++++++++------ .../platform/linux_gtk_accent_service.dart | 140 ++++++++ linux/runner/CMakeLists.txt | 2 + linux/runner/gtk_accent.cc | 112 ++++++ linux/runner/gtk_accent.h | 42 +++ linux/runner/gtk_accent_host.cc | 112 ++++++ linux/runner/gtk_accent_host.h | 9 + linux/runner/my_application.cc | 7 + test/src/linux_accent_provider_test.dart | 277 ++++++++++++++ test/src/linux_gtk_accent_service_test.dart | 203 +++++++++++ test/src/system_accent_test.dart | 264 ++++++++++++++ test/support/linux_accent_fakes.dart | 62 ++++ tools/gtk_accent_probe.cc | 148 ++++++++ tools/test_gtk_accent.sh | 13 + 15 files changed, 1639 insertions(+), 112 deletions(-) create mode 100644 lib/src/platform/linux_gtk_accent_service.dart create mode 100644 linux/runner/gtk_accent.cc create mode 100644 linux/runner/gtk_accent.h create mode 100644 linux/runner/gtk_accent_host.cc create mode 100644 linux/runner/gtk_accent_host.h create mode 100644 test/src/linux_accent_provider_test.dart create mode 100644 test/src/linux_gtk_accent_service_test.dart create mode 100644 test/support/linux_accent_fakes.dart create mode 100644 tools/gtk_accent_probe.cc create mode 100755 tools/test_gtk_accent.sh diff --git a/lib/main.dart b/lib/main.dart index bc682a30..cb1551fa 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; @@ -10,6 +11,7 @@ import 'src/app/system_accent.dart'; import 'src/git/application/git_controller.dart'; import 'src/git/data/git_cli_gateway.dart'; import 'src/platform/linux_header_bar_service.dart'; +import 'src/platform/linux_gtk_accent_service.dart'; import 'src/spellcheck/spelling_release_smoke.dart'; import 'src/visualization/visualization_release_smoke.dart'; @@ -23,17 +25,23 @@ Future main(List args) async { if (spellingReleaseSmokeReportPath(args) case final reportPath?) { exit(await runSpellingReleaseSmoke(reportPath)); } - var initialAccent = busyMarkDefaultAccentColor; - if (Platform.isLinux) { - initialAccent = - await const LinuxPortalAppearance().readAccentColor() ?? - busyMarkDefaultAccentColor; - } + final accentCoordinator = LinuxAccentCoordinator( + sources: Platform.isLinux + ? [const LinuxGtkAccentService(), const LinuxPortalAppearance()] + : [], + ); + await accentCoordinator.initialize(); runApp( ProviderScope( overrides: [ startupPathProvider.overrideWithValue(args.isEmpty ? null : args.first), - initialSystemAccentColorProvider.overrideWithValue(initialAccent), + initialSystemAccentColorProvider.overrideWithValue( + accentCoordinator.color, + ), + linuxAccentCoordinatorProvider.overrideWith((ref) { + ref.onDispose(() => unawaited(accentCoordinator.dispose())); + return accentCoordinator; + }), gitRepositoryGatewayProvider.overrideWithValue(const GitCliGateway()), ], child: const BusyMarkApp(), diff --git a/lib/src/app/system_accent.dart b/lib/src/app/system_accent.dart index 47bf7eae..faca9d68 100644 --- a/lib/src/app/system_accent.dart +++ b/lib/src/app/system_accent.dart @@ -6,136 +6,265 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:yaru/yaru.dart'; +import '../platform/linux_gtk_accent_service.dart'; + final busyMarkDefaultAccentColor = YaruVariant.orange.color; +final linuxAccentPlatformProvider = Provider((ref) => Platform.isLinux); +final linuxGtkAccentSourceProvider = Provider( + (ref) => const LinuxGtkAccentService(), +); +final linuxPortalAccentSourceProvider = Provider( + (ref) => const LinuxPortalAppearance(), +); + +final linuxAccentCoordinatorProvider = Provider((ref) { + final coordinator = LinuxAccentCoordinator( + sources: ref.watch(linuxAccentPlatformProvider) + ? [ + ref.watch(linuxGtkAccentSourceProvider), + ref.watch(linuxPortalAccentSourceProvider), + ] + : [], + ); + ref.onDispose(() => unawaited(coordinator.dispose())); + unawaited(coordinator.initialize()); + return coordinator; +}); final initialSystemAccentColorProvider = Provider( (ref) => busyMarkDefaultAccentColor, ); -final systemAccentColorProvider = StreamProvider((ref) async* { - final fallback = ref.watch(initialSystemAccentColorProvider); - var current = fallback; - yield current; - - if (!Platform.isLinux) { - return; +final systemAccentColorProvider = StreamProvider((ref) { + if (!ref.watch(linuxAccentPlatformProvider)) { + return Stream.value(ref.watch(initialSystemAccentColorProvider)); } + return ref.watch(linuxAccentCoordinatorProvider).colors; +}); + +/// Sources stay separate throughout startup and live updates. The list is in +/// priority order: GTK, then the portal's named-Yaru / generic-RGB fallback. +class LinuxAccentCoordinator { + LinuxAccentCoordinator({required List sources}) + : _sources = sources, + _values = List.filled(sources.length, null); + + final List _sources; + final List _values; + final _changes = StreamController.broadcast(sync: true); + final _subscriptions = >[]; + final _ready = >[]; + Future? _initialization; + Future? _disposal; + bool _disposed = false; + Color _color = busyMarkDefaultAccentColor; + Color get color => _color; + + Stream get colors => Stream.multi((listener) { + final subscription = _changes.stream.listen( + listener.add, + onDone: listener.close, + ); + listener.add(_color); + listener.onCancel = subscription.cancel; + }); + + Future initialize() => _initialization ??= _initialize(); + + Future _initialize() async { + if (_disposed) return; + final ready = >[]; + for (var index = 0; index < _sources.length; index++) { + final first = Completer(); + _ready.add(first); + ready.add(first.future); + void update(Color? value) { + if (!first.isCompleted) first.complete(); + if (_disposed) return; + _values[index] = value; + final resolved = + _values.whereType().firstOrNull ?? + busyMarkDefaultAccentColor; + if (resolved != _color) { + _color = resolved; + _changes.add(resolved); + } + } - final appearance = LinuxPortalAppearance(); - final initial = await appearance.readAccentColor(); - if (initial != null && initial != current) { - current = initial; - yield initial; + try { + _subscriptions.add( + _sources[index].watchAccentColor().listen( + update, + onError: (Object _, StackTrace _) => update(null), + onDone: () { + if (!first.isCompleted) update(null); + }, + ), + ); + } on Object { + update(null); + } + } + await Future.wait(ready); } - await for (final color in appearance.accentColorChanges()) { - if (color == current) { - continue; + + Future dispose() => _disposal ??= _dispose(); + + Future _dispose() async { + _disposed = true; + for (final first in _ready) { + if (!first.isCompleted) first.complete(); } - current = color; - yield color; + await Future.wait( + _subscriptions.map((subscription) => subscription.cancel()), + ); + await _changes.close(); } -}); +} -class LinuxPortalAppearance { - const LinuxPortalAppearance(); +/// Injectable D-Bus boundary. A watch owns one session and closes it on cancel. +abstract interface class LinuxPortalAccentIO { + Future readNamed(); + Future readRgb(); + Stream<({String namespace, String key, DBusValue value})> get changes; + Future close(); +} - static const _portalDestination = 'org.freedesktop.portal.Desktop'; - static const _portalPath = '/org/freedesktop/portal/desktop'; - static const _settingsInterface = 'org.freedesktop.portal.Settings'; - static const _freedesktopAppearance = 'org.freedesktop.appearance'; - static const _gnomeInterface = 'org.gnome.desktop.interface'; - static const _accentColor = 'accent-color'; +class LinuxPortalAppearance implements LinuxAccentSource { + const LinuxPortalAppearance({LinuxPortalAccentIO Function()? open}) + : _open = open ?? _openPortal; + final LinuxPortalAccentIO Function() _open; + + static const freedesktopAppearance = 'org.freedesktop.appearance'; + static const gnomeInterface = 'org.gnome.desktop.interface'; Future readAccentColor() async { - final client = DBusClient.session(); + final io = _open(); try { - final object = _portalObject(client); return await readPreferredLinuxAccentColor( - readGnome: () => _readGnomeAccentName(object), - readFreedesktop: () => _readFreedesktopAccent(object), + readGnome: io.readNamed, + readFreedesktop: io.readRgb, ); } finally { - await client.close(); + await io.close(); } } - Stream accentColorChanges() async* { - final client = DBusClient.session(); - try { - final object = _portalObject(client); - final gnomeAccent = await _readAccentSafely( - () => _readGnomeAccentName(object), - ); - final resolver = LinuxAccentChangeResolver( - gnomeAuthoritative: gnomeAccent != null, - ); - if (gnomeAccent != null) { - yield gnomeAccent; - } else { - final freedesktopAccent = await _readAccentSafely( - () => _readFreedesktopAccent(object), + @override + Stream watchAccentColor() => accentColorChanges(); + + Stream accentColorChanges() { + late StreamController controller; + LinuxPortalAccentIO? io; + StreamSubscription<({String namespace, String key, DBusValue value})>? + subscription; + Color? named; + Color? rgb; + var namedGeneration = 0; + var rgbGeneration = 0; + var cancelled = false; + void publish() { + if (!cancelled) controller.add(named ?? rgb); + } + + Future initialize() async { + try { + final session = io = _open(); + // D-Bus installs the signal match before these method calls. Each key + // has its own generation so a change cannot be undone by either read. + subscription = session.changes.listen( + (change) { + if (change.key != 'accent-color') return; + if (change.namespace == gnomeInterface) { + namedGeneration++; + named = colorFromUbuntuAccentNameValue(change.value); + } else if (change.namespace == freedesktopAppearance) { + rgbGeneration++; + rgb = colorFromPortalAccentValue(change.value); + } else { + return; + } + publish(); + }, + onError: (Object _, StackTrace _) { + // A bad signal does not terminate the subscription or native source. + }, ); - if (freedesktopAccent != null) { - yield freedesktopAccent; - } - } - final signals = DBusRemoteObjectSignalStream( - object: object, - interface: _settingsInterface, - name: 'SettingChanged', - signature: DBusSignature('ssv'), - ); - await for (final signal in signals) { - final namespace = signal.values[0].asString(); - final key = signal.values[1].asString(); - if (key != _accentColor) { - continue; - } - final color = resolver.resolve(namespace, signal.values[2].asVariant()); - if (color != null) { - yield color; - } + final initialNamedGeneration = namedGeneration; + final initialRgbGeneration = rgbGeneration; + await Future.wait([ + _readAccentSafely(session.readNamed).then((value) { + if (namedGeneration == initialNamedGeneration) named = value; + }), + _readAccentSafely(session.readRgb).then((value) { + if (rgbGeneration == initialRgbGeneration) rgb = value; + }), + ]); + publish(); + } on Object { + publish(); } - } on Object { - return; - } finally { - await client.close(); } - } - DBusRemoteObject _portalObject(DBusClient client) { - return DBusRemoteObject( - client, - name: _portalDestination, - path: DBusObjectPath(_portalPath), + controller = StreamController( + onListen: () => unawaited(initialize()), + onCancel: () async { + cancelled = true; + try { + await subscription?.cancel(); + } finally { + await io?.close(); + } + }, ); + return controller.stream.distinct(); } +} - Future _readFreedesktopAccent(DBusRemoteObject object) async { - final value = await _readSetting( - object, - _freedesktopAppearance, - _accentColor, - ); - return value == null ? null : colorFromPortalAccentValue(value); - } +LinuxPortalAccentIO _openPortal() => _DBusPortalAccentIO(); - Future _readGnomeAccentName(DBusRemoteObject object) async { - final value = await _readSetting(object, _gnomeInterface, _accentColor); - return value == null ? null : colorFromUbuntuAccentNameValue(value); - } +class _DBusPortalAccentIO implements LinuxPortalAccentIO { + final _client = DBusClient.session(); + late final _object = DBusRemoteObject( + _client, + name: 'org.freedesktop.portal.Desktop', + path: DBusObjectPath('/org/freedesktop/portal/desktop'), + ); + static const _settingsInterface = 'org.freedesktop.portal.Settings'; - Future _readSetting( - DBusRemoteObject object, - String namespace, - String key, - ) async { - final response = await object.callMethod(_settingsInterface, 'Read', [ + @override + Stream<({String namespace, String key, DBusValue value})> get changes => + DBusRemoteObjectSignalStream( + object: _object, + interface: _settingsInterface, + name: 'SettingChanged', + signature: DBusSignature('ssv'), + ).map( + (signal) => ( + namespace: signal.values[0].asString(), + key: signal.values[1].asString(), + value: signal.values[2].asVariant(), + ), + ); + + Future _read(String namespace) async { + final response = await _object.callMethod(_settingsInterface, 'Read', [ DBusString(namespace), - DBusString(key), + const DBusString('accent-color'), ], replySignature: DBusSignature('v')); return response.returnValues.single.asVariant(); } + + @override + Future readNamed() async => colorFromUbuntuAccentNameValue( + await _read(LinuxPortalAppearance.gnomeInterface), + ); + @override + Future readRgb() async => colorFromPortalAccentValue( + await _read(LinuxPortalAppearance.freedesktopAppearance), + ); + @override + Future close() => _client.close(); } /// Resolves the Yaru accent selected by Ubuntu before consulting the generic @@ -164,26 +293,24 @@ Future _readAccentSafely(Future Function() read) async { } } -/// Resolves portal changes without allowing the generic freedesktop palette to -/// replace the Yaru variant that owns native GTK controls. +/// Retains both portal states so a missing named accent releases precedence. @visibleForTesting class LinuxAccentChangeResolver { LinuxAccentChangeResolver({bool gnomeAuthoritative = false}) : _gnomeAuthoritative = gnomeAuthoritative; bool _gnomeAuthoritative; + Color? _generic; Color? resolve(String namespace, DBusValue value) { - if (namespace == LinuxPortalAppearance._gnomeInterface) { + if (namespace == LinuxPortalAppearance.gnomeInterface) { final color = colorFromUbuntuAccentNameValue(value); - if (color != null) { - _gnomeAuthoritative = true; - } - return color; + _gnomeAuthoritative = color != null; + return color ?? _generic; } - if (namespace == LinuxPortalAppearance._freedesktopAppearance && - !_gnomeAuthoritative) { - return colorFromPortalAccentValue(value); + if (namespace == LinuxPortalAppearance.freedesktopAppearance) { + _generic = colorFromPortalAccentValue(value); + return _gnomeAuthoritative ? null : _generic; } return null; } @@ -200,6 +327,7 @@ Color? colorFromPortalAccentValue(DBusValue value) { if (channels.length != 3) { return null; } + if (channels.any((channel) => !channel.asDouble().isFinite)) return null; final red = _colorChannel(channels[0].asDouble()); final green = _colorChannel(channels[1].asDouble()); final blue = _colorChannel(channels[2].asDouble()); diff --git a/lib/src/platform/linux_gtk_accent_service.dart b/lib/src/platform/linux_gtk_accent_service.dart new file mode 100644 index 00000000..b6dab0ce --- /dev/null +++ b/lib/src/platform/linux_gtk_accent_service.dart @@ -0,0 +1,140 @@ +import 'dart:async'; + +import 'package:flutter/services.dart'; + +/// Each subscription owns its initialization and platform listener. Null means +/// the source currently has no usable accent, rather than a default color. +abstract interface class LinuxAccentSource { + Stream watchAccentColor(); +} + +class LinuxGtkAccentService implements LinuxAccentSource { + const LinuxGtkAccentService({ + MethodChannel channel = const MethodChannel('com.busymark.app/gtk_accent'), + EventChannel events = const EventChannel( + 'com.busymark.app/gtk_accent/events', + ), + }) : _channel = channel, + _events = events; + + final MethodChannel _channel; + final EventChannel _events; + + Future readAccentColor() async { + try { + return colorFromGtkAccentPayload( + await _channel.invokeMethod('getAccent'), + ); + } on Object { + return null; + } + } + + @override + Stream watchAccentColor() => + watchInitializedAccent(read: readAccentColor, changes: _watchEvents); + + Stream _watchEvents() { + // EventChannel.receiveBroadcastStream reports listen/cancel failures to + // FlutterError instead of its stream. Own this small event-channel protocol + // so an absent Linux runner remains an ordinary unavailable source. + final messenger = _events.binaryMessenger; + final methods = MethodChannel(_events.name, _events.codec, messenger); + late StreamController controller; + var cancelled = false; + controller = StreamController( + onListen: () async { + messenger.setMessageHandler(_events.name, (message) async { + if (cancelled) return null; + try { + controller.add( + message == null + ? null + : colorFromGtkAccentPayload( + _events.codec.decodeEnvelope(message), + ), + ); + } on Object catch (error, stack) { + controller.addError(error, stack); + } + return null; + }); + try { + await methods.invokeMethod('listen'); + } on Object { + if (!cancelled) controller.add(null); + } + }, + onCancel: () async { + cancelled = true; + messenger.setMessageHandler(_events.name, null); + try { + await methods.invokeMethod('cancel'); + } on Object { + // A missing channel also has no listener to cancel. + } + }, + ); + return controller.stream; + } +} + +Color? colorFromGtkAccentPayload(Object? payload) { + if (payload is! Map || payload['available'] != true) return null; + final rgb = payload['rgb']; + if (rgb is! List || rgb.length != 3) return null; + final channels = []; + for (final value in rgb) { + if (value is! num || !value.isFinite || value < 0 || value > 1) { + return null; + } + channels.add((value * 255).round()); + } + return Color.fromARGB(255, channels[0], channels[1], channels[2]); +} + +/// Attach before reading and reject a snapshot superseded by a change, even +/// when that change explicitly makes the source unavailable. +Stream watchInitializedAccent({ + required Future Function() read, + required Stream Function() changes, +}) { + late StreamController controller; + StreamSubscription? subscription; + var generation = 0; + var cancelled = false; + void changed(Color? color) { + generation++; + if (!cancelled) controller.add(color); + } + + Future initialize() async { + try { + subscription = changes().listen( + changed, + onError: (Object _, StackTrace _) => changed(null), + ); + } on Object { + changed(null); + } + final snapshotGeneration = generation; + Color? snapshot; + try { + snapshot = await read(); + } on Object { + snapshot = null; + } + if (!cancelled && generation == snapshotGeneration) { + controller.add(snapshot); + } + } + + controller = StreamController( + onListen: () => unawaited(initialize()), + onCancel: () async { + cancelled = true; + await subscription?.cancel(); + }, + ); + return controller.stream.distinct(); +} diff --git a/linux/runner/CMakeLists.txt b/linux/runner/CMakeLists.txt index 20b36776..c795a13d 100644 --- a/linux/runner/CMakeLists.txt +++ b/linux/runner/CMakeLists.txt @@ -9,6 +9,8 @@ project(runner LANGUAGES CXX) add_executable(${BINARY_NAME} "main.cc" "my_application.cc" + "gtk_accent.cc" + "gtk_accent_host.cc" "rich_clipboard_host.cc" "secure_credential_host.cc" "video_player_host.cc" diff --git a/linux/runner/gtk_accent.cc b/linux/runner/gtk_accent.cc new file mode 100644 index 00000000..9f6b6652 --- /dev/null +++ b/linux/runner/gtk_accent.cc @@ -0,0 +1,112 @@ +#include "gtk_accent.h" + +#include +#include + +namespace busymark { +namespace { +bool ValidChannel(double value) { + return std::isfinite(value) && value >= 0 && value <= 1; +} + +bool Lookup(GtkStyleContext* context, const char* name, GdkRGBA* color) { + *color = {0, 0, 0, 0}; + return context != nullptr && + gtk_style_context_lookup_color(context, name, color) && + ValidChannel(color->red) && ValidChannel(color->green) && + ValidChannel(color->blue) && ValidChannel(color->alpha) && + color->alpha > 0; +} + +int EffectiveChannel(double value) { + return static_cast(std::round(value * 255)); +} +} // namespace + +GtkAccent LookupGtkAccent(GtkStyleContext* context) { + GtkAccent accent; + accent.available = Lookup(context, "accent_bg_color", &accent.rgba) || + Lookup(context, "theme_selected_bg_color", &accent.rgba); + if (!accent.available) accent.rgba = {0, 0, 0, 0}; + return accent; +} + +bool SameEffectiveAccent(const GtkAccent& a, const GtkAccent& b) { + return a.available == b.available && + (!a.available || + (EffectiveChannel(a.rgba.red) == EffectiveChannel(b.rgba.red) && + EffectiveChannel(a.rgba.green) == EffectiveChannel(b.rgba.green) && + EffectiveChannel(a.rgba.blue) == EffectiveChannel(b.rgba.blue))); +} + +GtkAccentObserver::GtkAccentObserver(GtkWidget* widget) + : context_(GTK_STYLE_CONTEXT( + g_object_ref(gtk_widget_get_style_context(widget)))), + settings_(GTK_SETTINGS(g_object_ref(gtk_widget_get_settings(widget)))) { + theme_signal_ = g_signal_connect(settings_, "notify::gtk-theme-name", + G_CALLBACK(SettingsChanged), this); + dark_signal_ = g_signal_connect( + settings_, "notify::gtk-application-prefer-dark-theme", + G_CALLBACK(SettingsChanged), this); + style_signal_ = g_signal_connect(context_, "changed", + G_CALLBACK(StyleChanged), this); +} + +GtkAccentObserver::~GtkAccentObserver() { + Cancel(); + g_signal_handler_disconnect(settings_, theme_signal_); + g_signal_handler_disconnect(settings_, dark_signal_); + g_signal_handler_disconnect(context_, style_signal_); + g_clear_object(&settings_); + g_clear_object(&context_); +} + +GtkAccent GtkAccentObserver::Read() const { + return LookupGtkAccent(context_); +} + +void GtkAccentObserver::Listen(std::function send) { + send_ = std::move(send); + // Replay the current state on every attachment, including after cancellation. + Publish(true); +} + +void GtkAccentObserver::Cancel() { + send_ = nullptr; + if (refresh_id_ != 0) { + g_source_remove(refresh_id_); + refresh_id_ = 0; + } +} + +void GtkAccentObserver::SettingsChanged(GObject*, GParamSpec*, gpointer data) { + static_cast(data)->Schedule(); +} + +void GtkAccentObserver::StyleChanged(GtkStyleContext*, gpointer data) { + static_cast(data)->Schedule(); +} + +void GtkAccentObserver::Schedule() { + // GTK reloads its theme and invalidates styles during settings notification. + // Sample at idle after that work, and also observe style invalidation itself. + if (send_ && refresh_id_ == 0) { + refresh_id_ = g_idle_add_full(G_PRIORITY_DEFAULT_IDLE, Refresh, this, nullptr); + } +} + +gboolean GtkAccentObserver::Refresh(gpointer data) { + auto* observer = static_cast(data); + observer->refresh_id_ = 0; + observer->Publish(false); + return G_SOURCE_REMOVE; +} + +void GtkAccentObserver::Publish(bool force) { + const auto accent = Read(); + if (send_ && (force || !SameEffectiveAccent(last_, accent))) { + last_ = accent; + send_(accent); + } +} +} // namespace busymark diff --git a/linux/runner/gtk_accent.h b/linux/runner/gtk_accent.h new file mode 100644 index 00000000..e0902a3b --- /dev/null +++ b/linux/runner/gtk_accent.h @@ -0,0 +1,42 @@ +#ifndef BUSYMARK_GTK_ACCENT_H_ +#define BUSYMARK_GTK_ACCENT_H_ + +#include +#include + +namespace busymark { +struct GtkAccent { + bool available = false; + GdkRGBA rgba = {0, 0, 0, 0}; +}; + +// The production lookup is shared with the GTK-backed regression probe. +GtkAccent LookupGtkAccent(GtkStyleContext* context); +bool SameEffectiveAccent(const GtkAccent& a, const GtkAccent& b); + +class GtkAccentObserver { + public: + explicit GtkAccentObserver(GtkWidget* widget); + ~GtkAccentObserver(); + GtkAccent Read() const; + void Listen(std::function send); + void Cancel(); + + private: + static void SettingsChanged(GObject*, GParamSpec*, gpointer data); + static void StyleChanged(GtkStyleContext*, gpointer data); + static gboolean Refresh(gpointer data); + void Schedule(); + void Publish(bool force); + + GtkStyleContext* context_; + GtkSettings* settings_; + gulong theme_signal_ = 0; + gulong dark_signal_ = 0; + gulong style_signal_ = 0; + guint refresh_id_ = 0; + GtkAccent last_; + std::function send_; +}; +} // namespace busymark +#endif // BUSYMARK_GTK_ACCENT_H_ diff --git a/linux/runner/gtk_accent_host.cc b/linux/runner/gtk_accent_host.cc new file mode 100644 index 00000000..df94c838 --- /dev/null +++ b/linux/runner/gtk_accent_host.cc @@ -0,0 +1,112 @@ +#include "gtk_accent_host.h" +#include "gtk_accent.h" + +#include +#include + +struct BusyMarkGtkAccentHost { + std::unique_ptr observer; + FlMethodChannel* methods = nullptr; + FlEventChannel* events = nullptr; + GtkWidget* window = nullptr; + GtkWidget* view = nullptr; + gulong window_destroy = 0; + gulong view_destroy = 0; +}; + +static FlValue* accent_payload(const busymark::GtkAccent& accent) { + FlValue* result = fl_value_new_map(); + fl_value_set_string_take(result, "available", + fl_value_new_bool(accent.available)); + if (accent.available) { + FlValue* rgb = fl_value_new_list(); + fl_value_append_take(rgb, fl_value_new_float(accent.rgba.red)); + fl_value_append_take(rgb, fl_value_new_float(accent.rgba.green)); + fl_value_append_take(rgb, fl_value_new_float(accent.rgba.blue)); + fl_value_set_string_take(result, "rgb", rgb); + } + return result; +} + +static void accent_method(FlMethodChannel*, FlMethodCall* call, gpointer data) { + auto* host = static_cast(data); + if (std::strcmp(fl_method_call_get_name(call), "getAccent") != 0) { + fl_method_call_respond_not_implemented(call, nullptr); + return; + } + g_autoptr(FlValue) payload = accent_payload(host->observer->Read()); + fl_method_call_respond_success(call, payload, nullptr); +} + +static FlMethodErrorResponse* accent_listen(FlEventChannel*, FlValue*, + gpointer data) { + auto* host = static_cast(data); + host->observer->Listen([host](const busymark::GtkAccent& accent) { + g_autoptr(FlValue) payload = accent_payload(accent); + fl_event_channel_send(host->events, payload, nullptr, nullptr); + }); + return nullptr; +} + +static FlMethodErrorResponse* accent_cancel(FlEventChannel*, FlValue*, + gpointer data) { + static_cast(data)->observer->Cancel(); + return nullptr; +} + +static void shutdown_accent_host(BusyMarkGtkAccentHost* host) { + // Stop queued GTK work before either the window or Flutter engine disappears. + host->observer.reset(); + if (host->methods != nullptr) { + fl_method_channel_set_method_call_handler(host->methods, nullptr, nullptr, + nullptr); + } + if (host->events != nullptr) { + fl_event_channel_set_stream_handlers(host->events, nullptr, nullptr, nullptr, + nullptr); + } + g_clear_object(&host->methods); + g_clear_object(&host->events); + if (host->window != nullptr) { + g_signal_handler_disconnect(host->window, host->window_destroy); + host->window = nullptr; + } + if (host->view != nullptr) { + g_signal_handler_disconnect(host->view, host->view_destroy); + host->view = nullptr; + } +} + +static void widget_destroyed(GtkWidget*, gpointer data) { + shutdown_accent_host(static_cast(data)); +} + +BusyMarkGtkAccentHost* busymark_gtk_accent_host_new(FlView* view, + GtkWidget* window) { + auto* host = new BusyMarkGtkAccentHost(); + host->observer = std::make_unique(window); + host->window = window; + host->view = GTK_WIDGET(view); + host->window_destroy = g_signal_connect(window, "destroy", + G_CALLBACK(widget_destroyed), host); + host->view_destroy = g_signal_connect(view, "destroy", + G_CALLBACK(widget_destroyed), host); + FlBinaryMessenger* messenger = + fl_engine_get_binary_messenger(fl_view_get_engine(view)); + g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new(); + host->methods = fl_method_channel_new(messenger, "com.busymark.app/gtk_accent", + FL_METHOD_CODEC(codec)); + fl_method_channel_set_method_call_handler(host->methods, accent_method, host, + nullptr); + host->events = fl_event_channel_new( + messenger, "com.busymark.app/gtk_accent/events", FL_METHOD_CODEC(codec)); + fl_event_channel_set_stream_handlers(host->events, accent_listen, accent_cancel, + host, nullptr); + return host; +} + +void busymark_gtk_accent_host_free(BusyMarkGtkAccentHost* host) { + if (host == nullptr) return; + shutdown_accent_host(host); + delete host; +} diff --git a/linux/runner/gtk_accent_host.h b/linux/runner/gtk_accent_host.h new file mode 100644 index 00000000..c58b23d4 --- /dev/null +++ b/linux/runner/gtk_accent_host.h @@ -0,0 +1,9 @@ +#ifndef BUSYMARK_GTK_ACCENT_HOST_H_ +#define BUSYMARK_GTK_ACCENT_HOST_H_ +#include + +struct BusyMarkGtkAccentHost; +BusyMarkGtkAccentHost* busymark_gtk_accent_host_new(FlView* view, + GtkWidget* window); +void busymark_gtk_accent_host_free(BusyMarkGtkAccentHost* host); +#endif // BUSYMARK_GTK_ACCENT_HOST_H_ diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index 9f5a6b80..e703b108 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -10,6 +10,7 @@ #include #include "flutter/generated_plugin_registrant.h" +#include "gtk_accent_host.h" #include "rich_clipboard_host.h" #include "secure_credential_host.h" #include "video_player_host.h" @@ -84,6 +85,7 @@ constexpr char kHeaderApplicationBackdropStyleClass[] = struct _MyApplication { GtkApplication parent_instance; char** dart_entrypoint_arguments; + BusyMarkGtkAccentHost* gtk_accent_host; FlMethodChannel* header_bar_channel; FlMethodChannel* native_menu_channel; FlMethodChannel* writerside_dialog_channel; @@ -3155,6 +3157,8 @@ static void my_application_activate(GApplication* application) { g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb), self); + // Register before realizing the view starts Dart execution. + self->gtk_accent_host = busymark_gtk_accent_host_new(view, GTK_WIDGET(window)); gtk_widget_realize(GTK_WIDGET(view)); fl_register_plugins(FL_PLUGIN_REGISTRY(view)); @@ -3218,6 +3222,8 @@ static void my_application_shutdown(GApplication* application) { // Implements GObject::dispose. static void my_application_dispose(GObject* object) { MyApplication* self = MY_APPLICATION(object); + busymark_gtk_accent_host_free(self->gtk_accent_host); + self->gtk_accent_host = nullptr; GdkScreen* screen = gdk_screen_get_default(); if (screen != nullptr) { if (self->header_bar_css_provider != nullptr) { @@ -3271,6 +3277,7 @@ static void my_application_class_init(MyApplicationClass* klass) { static void my_application_init(MyApplication* self) { self->dart_entrypoint_arguments = nullptr; + self->gtk_accent_host = nullptr; self->header_bar_channel = nullptr; self->native_menu_channel = nullptr; self->writerside_dialog_channel = nullptr; diff --git a/test/src/linux_accent_provider_test.dart b/test/src/linux_accent_provider_test.dart new file mode 100644 index 00000000..2fbedb64 --- /dev/null +++ b/test/src/linux_accent_provider_test.dart @@ -0,0 +1,277 @@ +import 'dart:async'; + +import 'package:busymark/src/app/app_router.dart'; +import 'package:busymark/src/app/app_settings.dart'; +import 'package:busymark/src/app/busymark_app.dart'; +import 'package:busymark/src/app/system_accent.dart'; +import 'package:busymark/src/platform/linux_header_bar_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:yaru/yaru.dart'; + +import '../support/linux_accent_fakes.dart'; + +void main() { + final binding = TestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets( + 'production provider consumes an older GTK accent without portals', + (tester) async { + const channel = MethodChannel('com.busymark.app/gtk_accent'); + const events = MethodChannel('com.busymark.app/gtk_accent/events'); + binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + (call) async => { + 'available': true, + 'rgb': [0.2, 0.4, 0.6], + }, + ); + binding.defaultBinaryMessenger.setMockMethodCallHandler( + events, + (_) async => null, + ); + final io = + FakePortalIO(); // Ubuntu 24.04: neither portal key is available. + final container = ProviderContainer( + overrides: [ + linuxAccentPlatformProvider.overrideWithValue(true), + linuxPortalAccentSourceProvider.overrideWithValue( + LinuxPortalAppearance(open: () => io), + ), + ], + ); + final subscription = container.listen( + systemAccentColorProvider, + (_, _) {}, + ); + await tester.pump(); + expect( + container.read(systemAccentColorProvider).value, + const Color(0xff336699), + ); + await binding.defaultBinaryMessenger.handlePlatformMessage( + events.name, + const StandardMethodCodec().encodeSuccessEnvelope({ + 'available': true, + 'rgb': [0.0, 0.6, 0.0], + }), + (_) {}, + ); + await tester.pump(); + expect( + container.read(systemAccentColorProvider).value, + const Color(0xff009900), + ); + final coordinator = container.read(linuxAccentCoordinatorProvider); + subscription.close(); + await tester.runAsync(() async { + container.dispose(); + await coordinator.dispose(); + }); + await tester.pump(); + expect(io.closes, 1); + binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, null); + binding.defaultBinaryMessenger.setMockMethodCallHandler(events, null); + await io.signals.close(); + }, + ); + + testWidgets('non-Linux never creates platform listeners', (tester) async { + final gtk = FakeAccentSource(() => throw StateError('must not read GTK')); + final portal = FakeAccentSource( + () => throw StateError('must not read portal'), + ); + final container = ProviderContainer( + overrides: [ + linuxAccentPlatformProvider.overrideWithValue(false), + linuxGtkAccentSourceProvider.overrideWithValue(gtk), + linuxPortalAccentSourceProvider.overrideWithValue(portal), + ], + ); + final subscription = container.listen(systemAccentColorProvider, (_, _) {}); + await tester.pump(); + expect( + container.read(systemAccentColorProvider).value, + busyMarkDefaultAccentColor, + ); + expect(gtk.listens, 0); + expect(portal.listens, 0); + subscription.close(); + container.dispose(); + }); + + testWidgets( + 'missing native snapshot uses portal, then native events regain priority', + (tester) async { + const channel = MethodChannel('com.busymark.app/gtk_accent'); + const events = MethodChannel('com.busymark.app/gtk_accent/events'); + binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + (_) async => throw MissingPluginException(), + ); + binding.defaultBinaryMessenger.setMockMethodCallHandler( + events, + (_) async => null, + ); + final io = FakePortalIO( + named: YaruVariant.purple.color, + rgb: const Color(0xff112233), + ); + final container = ProviderContainer( + overrides: [ + linuxAccentPlatformProvider.overrideWithValue(true), + linuxPortalAccentSourceProvider.overrideWithValue( + LinuxPortalAppearance(open: () => io), + ), + ], + ); + final subscription = container.listen( + systemAccentColorProvider, + (_, _) {}, + ); + await tester.pump(); + expect( + container.read(systemAccentColorProvider).value, + YaruVariant.purple.color, + ); + Future native(Object payload) => + binding.defaultBinaryMessenger.handlePlatformMessage( + events.name, + const StandardMethodCodec().encodeSuccessEnvelope(payload), + (_) {}, + ); + await native({ + 'available': true, + 'rgb': [0.2, 0.4, 0.6], + }); + await tester.pump(); + expect( + container.read(systemAccentColorProvider).value, + const Color(0xff336699), + ); + await native({'available': false}); + await tester.pump(); + expect( + container.read(systemAccentColorProvider).value, + YaruVariant.purple.color, + ); + await native({ + 'available': true, + 'rgb': [0.0, 0.6, 0.0], + }); + await tester.pump(); + expect( + container.read(systemAccentColorProvider).value, + const Color(0xff009900), + ); + final coordinator = container.read(linuxAccentCoordinatorProvider); + subscription.close(); + await tester.runAsync(() async { + container.dispose(); + await coordinator.dispose(); + }); + expect(io.closes, 1); + binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, null); + binding.defaultBinaryMessenger.setMockMethodCallHandler(events, null); + await io.signals.close(); + }, + ); + + testWidgets( + 'startup and updated production light/dark themes consume GTK accent', + (tester) async { + const first = Color(0xff336699); + const second = Color(0xff7764d8); + final gtk = FakeAccentSource(() async => first); + final io = FakePortalIO(); + final coordinator = LinuxAccentCoordinator( + sources: [ + gtk, + LinuxPortalAppearance(open: () => io), + ], + ); + final initialized = coordinator.initialize(); + await tester.pump(); + await initialized; + final header = LinuxHeaderBarService( + channel: const MethodChannel('test.busymark/headerbar'), + ); + final router = GoRouter( + routes: [ + GoRoute( + path: '/', + builder: (_, _) => const Scaffold(body: Text('Accent test')), + ), + ], + ); + final container = ProviderContainer( + overrides: [ + linuxAccentPlatformProvider.overrideWithValue(true), + // The same pre-frame handoff as main; the production stream provider runs. + linuxAccentCoordinatorProvider.overrideWith((ref) { + ref.onDispose(() => unawaited(coordinator.dispose())); + return coordinator; + }), + initialSystemAccentColorProvider.overrideWithValue(coordinator.color), + localSettingsStoreProvider.overrideWithValue(_MemorySettings()), + linuxHeaderBarServiceProvider.overrideWithValue(header), + appRouterProvider.overrideWithValue(router), + ], + ); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const BusyMarkApp(), + ), + ); + void expectThemes(Color accent) { + final app = tester.widget(find.byType(MaterialApp)); + expect(app.theme!.colorScheme.primary, accent); + expect(app.darkTheme!.colorScheme.primary, accent); + } + + expectThemes( + first, + ); // Includes the first frame, before StreamProvider data. + gtk.events.add(second); + await tester.pump(); + await tester.pump(); + expectThemes(second); + // Rebuilds must retain one native and one portal listener. + container + .read(appSettingsControllerProvider.notifier) + .setThemeModePreference(BusyMarkThemeModePreference.dark); + await tester.pump(); + expectThemes(second); + container + .read(appSettingsControllerProvider.notifier) + .setThemeModePreference(BusyMarkThemeModePreference.light); + await tester.pump(); + expectThemes(second); + expect(gtk.listens, 1); + expect(io.signals.hasListener, isTrue); + await tester.pumpWidget(const SizedBox()); + await tester.runAsync(() async { + container.dispose(); + await coordinator.dispose(); + }); + await tester.pump(); + expect(gtk.cancels, 1); + expect(io.closes, 1); + router.dispose(); + header.dispose(); + await gtk.events.close(); + await io.signals.close(); + }, + ); +} + +class _MemorySettings implements LocalSettingsStore { + @override + Future> load() async => {}; + @override + Future save(Map json) async {} +} diff --git a/test/src/linux_gtk_accent_service_test.dart b/test/src/linux_gtk_accent_service_test.dart new file mode 100644 index 00000000..cd077aff --- /dev/null +++ b/test/src/linux_gtk_accent_service_test.dart @@ -0,0 +1,203 @@ +import 'dart:async'; + +import 'package:busymark/src/platform/linux_gtk_accent_service.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + final binding = TestWidgetsFlutterBinding.ensureInitialized(); + const method = MethodChannel('test.busymark/gtk_accent'); + const events = EventChannel('test.busymark/gtk_accent/events'); + const eventMethod = MethodChannel('test.busymark/gtk_accent/events'); + const service = LinuxGtkAccentService(channel: method, events: events); + const blue = Color(0xff336699); + final bluePayload = { + 'available': true, + 'rgb': [0.2, 0.4, 0.6], + }; + final greenPayload = { + 'available': true, + 'rgb': [0.0, 0.6, 0.0], + }; + + Future send(Object? payload) async { + await binding.defaultBinaryMessenger.handlePlatformMessage( + events.name, + const StandardMethodCodec().encodeSuccessEnvelope(payload), + (_) {}, + ); + } + + tearDown(() { + binding.defaultBinaryMessenger.setMockMethodCallHandler(method, null); + binding.defaultBinaryMessenger.setMockMethodCallHandler(eventMethod, null); + }); + + test('validates availability, finite normalized RGB and payload shape', () { + expect(colorFromGtkAccentPayload(bluePayload), blue); + expect( + colorFromGtkAccentPayload({ + 'available': true, + 'rgb': [0.7843137383460999, 0.5333333611488342, 0.0], + }), + const Color(0xffc88800), + ); + expect( + colorFromGtkAccentPayload({ + 'available': true, + 'rgb': [0, 0, 0], + }), + const Color(0xff000000), + ); // Explicit valid black is not lookup failure. + for (final payload in [ + null, + false, + {}, + {'available': false}, + {'available': true}, + { + 'available': true, + 'rgb': [0, 0], + }, + { + 'available': true, + 'rgb': [double.nan, 0, 0], + }, + { + 'available': true, + 'rgb': [double.infinity, 0, 0], + }, + { + 'available': true, + 'rgb': [-0.1, 0, 0], + }, + { + 'available': true, + 'rgb': [0, 1.1, 0], + }, + { + 'available': true, + 'rgb': ['blue', 0, 0], + }, + { + 'available': 'true', + 'rgb': [0, 0, 0], + }, + ]) { + expect(colorFromGtkAccentPayload(payload), isNull, reason: '$payload'); + } + }); + + testWidgets('attaches before snapshot and rejects a superseded snapshot', ( + tester, + ) async { + final snapshot = Completer(); + var listening = false; + binding.defaultBinaryMessenger.setMockMethodCallHandler(eventMethod, ( + call, + ) async { + if (call.method == 'listen') listening = true; + return null; + }); + binding.defaultBinaryMessenger.setMockMethodCallHandler(method, (call) { + expect(listening, isTrue); + return snapshot.future; + }); + final values = []; + final subscription = service.watchAccentColor().listen(values.add); + await tester.pump(); + await send(greenPayload); + await tester.pump(); + snapshot.complete(bluePayload); + await tester.pump(); + expect(values, [const Color(0xff009900)]); + await subscription.cancel(); + }); + + testWidgets('replays current value, deduplicates and safely resubscribes', ( + tester, + ) async { + var current = bluePayload; + var listens = 0; + var cancels = 0; + binding.defaultBinaryMessenger.setMockMethodCallHandler(eventMethod, ( + call, + ) async { + if (call.method == 'listen') { + listens++; + await send(current); + } + if (call.method == 'cancel') cancels++; + return null; + }); + binding.defaultBinaryMessenger.setMockMethodCallHandler( + method, + (_) async => current, + ); + final values = []; + var subscription = service.watchAccentColor().listen(values.add); + await tester.pump(); + await send(bluePayload); + await send({'available': false}); + await send({'available': false}); + await send({ + 'available': true, + 'rgb': ['bad'], + }); + await send(greenPayload); + await tester.pump(); + expect(values, [blue, null, const Color(0xff009900)]); + await subscription.cancel(); + current = greenPayload; + subscription = service.watchAccentColor().listen(values.add); + await tester.pump(); + expect(values.last, const Color(0xff009900)); + expect(listens, 2); + await subscription.cancel(); + expect(cancels, 2); + }); + + testWidgets('missing channels are unavailable and cancellable', ( + tester, + ) async { + binding.defaultBinaryMessenger.setMockMethodCallHandler( + method, + (_) async => throw MissingPluginException(), + ); + binding.defaultBinaryMessenger.setMockMethodCallHandler( + eventMethod, + (_) async => throw MissingPluginException(), + ); + final values = []; + final subscription = service.watchAccentColor().listen(values.add); + await tester.pump(); + expect(values, [null]); + expect(await service.readAccentColor(), isNull); + await subscription.cancel(); + }); + + testWidgets('cancellation while snapshot is pending discards its result', ( + tester, + ) async { + final snapshot = Completer(); + var cancels = 0; + binding.defaultBinaryMessenger.setMockMethodCallHandler(eventMethod, ( + call, + ) async { + if (call.method == 'cancel') cancels++; + return null; + }); + binding.defaultBinaryMessenger.setMockMethodCallHandler( + method, + (_) => snapshot.future, + ); + final values = []; + final subscription = service.watchAccentColor().listen(values.add); + await tester.pump(); + await subscription.cancel(); + snapshot.complete(bluePayload); + await tester.pump(); + expect(values, isEmpty); + expect(cancels, 1); + }); +} diff --git a/test/src/system_accent_test.dart b/test/src/system_accent_test.dart index 88d6f017..e699e7a3 100644 --- a/test/src/system_accent_test.dart +++ b/test/src/system_accent_test.dart @@ -1,9 +1,13 @@ +import 'dart:async'; + import 'package:busymark/src/app/system_accent.dart'; import 'package:dbus/dbus.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:yaru/yaru.dart'; +import '../support/linux_accent_fakes.dart'; + void main() { test('uses the native Yaru orange accent as the offline fallback', () { expect(busyMarkDefaultAccentColor, YaruVariant.orange.color); @@ -105,4 +109,264 @@ void main() { ); expect(resolver.resolve('org.freedesktop.appearance', exactRgb), isNull); }); + test('an unavailable named accent releases the current generic RGB', () { + final resolver = LinuxAccentChangeResolver(); + resolver.resolve( + LinuxPortalAppearance.gnomeInterface, + const DBusString('purple'), + ); + resolver.resolve( + LinuxPortalAppearance.freedesktopAppearance, + DBusStruct([ + const DBusDouble(0.2), + const DBusDouble(0.4), + const DBusDouble(0.6), + ]), + ); + expect( + resolver.resolve( + LinuxPortalAppearance.gnomeInterface, + const DBusString('unknown'), + ), + const Color(0xff336699), + ); + }); + + test( + 'portal subscribes before both reads and retains changes during reads', + () async { + final io = FakePortalIO() + ..namedRead = Completer() + ..rgbRead = Completer(); + final values = []; + final subscription = LinuxPortalAppearance( + open: () => io, + ).accentColorChanges().listen(values.add); + expect(io.subscribedBeforeReads, isTrue); + io.change( + LinuxPortalAppearance.gnomeInterface, + const DBusString('purple'), + ); + io.change( + LinuxPortalAppearance.freedesktopAppearance, + DBusStruct([ + const DBusDouble(0.2), + const DBusDouble(0.4), + const DBusDouble(0.6), + ]), + ); + io.namedRead!.complete(YaruVariant.orange.color); + io.rgbRead!.complete(const Color(0xff000000)); + await Future.delayed(Duration.zero); + expect(values, [YaruVariant.purple.color]); + io.change( + LinuxPortalAppearance.gnomeInterface, + const DBusString('unknown'), + ); + await Future.delayed(Duration.zero); + expect(values.last, const Color(0xff336699)); + await subscription.cancel(); + expect(io.closes, 1); + expect(io.signals.hasListener, isFalse); + await io.signals.close(); + }, + ); + + test( + 'missing portal keys, malformed signals, cancellation and resubscription', + () async { + final sessions = []; + final portal = LinuxPortalAppearance( + open: () { + final io = FakePortalIO(); + sessions.add(io); + return io; + }, + ); + final values = []; + var subscription = portal.accentColorChanges().listen(values.add); + await Future.delayed(Duration.zero); + expect(values, [null]); + sessions.single.signals.addError(StateError('malformed signal')); + sessions.single.change( + LinuxPortalAppearance.gnomeInterface, + const DBusString('blue'), + key: 'color-scheme', + ); + sessions.single.change('unknown.namespace', const DBusString('blue')); + sessions.single.change( + LinuxPortalAppearance.gnomeInterface, + const DBusString('green'), + ); + await Future.delayed(Duration.zero); + expect(values, [null, YaruVariant.adwaitaGreen.color]); + await subscription.cancel(); + subscription = portal.accentColorChanges().listen(values.add); + await Future.delayed(Duration.zero); + expect(sessions, hasLength(2)); + await subscription.cancel(); + for (final io in sessions) { + expect(io.closes, 1); + expect(io.signals.hasListener, isFalse); + await io.signals.close(); + } + }, + ); + + test( + 'generic portal initialization cannot overwrite a newer generic change', + () async { + final io = FakePortalIO()..rgbRead = Completer(); + final values = []; + final subscription = LinuxPortalAppearance( + open: () => io, + ).accentColorChanges().listen(values.add); + io.change( + LinuxPortalAppearance.freedesktopAppearance, + DBusStruct([ + const DBusDouble(0.2), + const DBusDouble(0.4), + const DBusDouble(0.6), + ]), + ); + io.rgbRead!.complete(YaruVariant.orange.color); + await Future.delayed(Duration.zero); + expect(values, [const Color(0xff336699)]); + await subscription.cancel(); + await io.signals.close(); + }, + ); + + for (final gtkFirst in [true, false]) { + test( + 'GTK wins with gtkFirst=$gtkFirst; unavailability selects current portal', + () async { + const gtkColor = Color(0xff287b9c); + final gtkRead = Completer(); + final gtk = FakeAccentSource(() => gtkRead.future); + final io = FakePortalIO() + ..namedRead = Completer() + ..rgbRead = Completer(); + final coordinator = LinuxAccentCoordinator( + sources: [ + gtk, + LinuxPortalAppearance(open: () => io), + ], + ); + final initialization = coordinator.initialize(); + final values = []; + final subscription = coordinator.colors.listen(values.add); + await Future.delayed(Duration.zero); + if (gtkFirst) gtkRead.complete(gtkColor); + io.rgbRead!.complete(const Color(0xff112233)); + io.namedRead!.complete(YaruVariant.purple.color); + await Future.delayed(Duration.zero); + if (!gtkFirst) gtkRead.complete(gtkColor); + await initialization; + expect(coordinator.color, gtkColor); + io.change( + LinuxPortalAppearance.freedesktopAppearance, + DBusStruct([ + const DBusDouble(0), + const DBusDouble(0.4), + const DBusDouble(0), + ]), + ); + io.change( + LinuxPortalAppearance.gnomeInterface, + const DBusString('blue'), + ); + await Future.delayed(Duration.zero); + expect(coordinator.color, gtkColor); + gtk.events.add(null); + await Future.delayed(Duration.zero); + expect(coordinator.color, YaruVariant.blue.color); + io.change( + LinuxPortalAppearance.gnomeInterface, + const DBusString('unavailable'), + ); + await Future.delayed(Duration.zero); + expect(coordinator.color, const Color(0xff006600)); + gtk.events.add(gtkColor); + gtk.events.add(gtkColor); + await Future.delayed(Duration.zero); + expect(coordinator.color, gtkColor); + expect(values.last, gtkColor); + expect(values.length, gtkFirst ? 5 : 6); + await subscription.cancel(); + await coordinator.dispose(); + expect(gtk.listens, 1); + expect(gtk.cancels, 1); + expect(io.closes, 1); + await gtk.events.close(); + await io.signals.close(); + }, + ); + } + + test( + 'source errors remain independent and healthy native updates continue', + () async { + final gtk = FakeAccentSource(() async => const Color(0xff336699)); + final portal = FakeAccentSource( + () => Future.error(StateError('no portal')), + ); + final coordinator = LinuxAccentCoordinator(sources: [gtk, portal]); + await coordinator.initialize(); + expect(coordinator.color, const Color(0xff336699)); + portal.events.addError(StateError('portal disconnected')); + gtk.events.add(const Color(0xff7764d8)); + await Future.delayed(Duration.zero); + expect(coordinator.color, const Color(0xff7764d8)); + gtk.events.addError(StateError('native disconnected')); + portal.events.add(const Color(0xff123456)); + await Future.delayed(Duration.zero); + expect(coordinator.color, const Color(0xff123456)); + await coordinator.dispose(); + await gtk.events.close(); + await portal.events.close(); + }, + ); + + test( + 'startup changes supersede native snapshots, including unavailable changes', + () async { + final snapshot = Completer(); + final gtk = FakeAccentSource(() => snapshot.future); + final portal = FakeAccentSource(() async => const Color(0xff112233)); + final coordinator = LinuxAccentCoordinator(sources: [gtk, portal]); + final initialized = coordinator.initialize(); + gtk.events.add(const Color(0xff287b9c)); + await initialized; + expect(coordinator.color, const Color(0xff287b9c)); + gtk.events.add(null); + await Future.delayed(Duration.zero); + expect(coordinator.color, const Color(0xff112233)); + snapshot.complete(const Color(0xff336699)); + await Future.delayed(Duration.zero); + expect(coordinator.color, const Color(0xff112233)); + await coordinator.dispose(); + await gtk.events.close(); + await portal.events.close(); + }, + ); + + test( + 'disposal completes pending startup and cancels each owned source once', + () async { + final snapshot = Completer(); + final gtk = FakeAccentSource(() => snapshot.future); + final coordinator = LinuxAccentCoordinator(sources: [gtk]); + final initialized = coordinator.initialize(); + await Future.wait([coordinator.dispose(), coordinator.dispose()]); + await initialized; + expect(gtk.listens, 1); + expect(gtk.cancels, 1); + expect(gtk.events.hasListener, isFalse); + snapshot.complete(const Color(0xff336699)); + await Future.delayed(Duration.zero); + expect(coordinator.color, busyMarkDefaultAccentColor); + await gtk.events.close(); + }, + ); } diff --git a/test/support/linux_accent_fakes.dart b/test/support/linux_accent_fakes.dart new file mode 100644 index 00000000..b3851ec9 --- /dev/null +++ b/test/support/linux_accent_fakes.dart @@ -0,0 +1,62 @@ +import 'dart:async'; + +import 'package:busymark/src/app/system_accent.dart'; +import 'package:busymark/src/platform/linux_gtk_accent_service.dart'; +import 'package:dbus/dbus.dart'; +import 'package:flutter/material.dart'; + +class FakeAccentSource implements LinuxAccentSource { + FakeAccentSource(this.snapshot); + final Future Function() snapshot; + int listens = 0; + int cancels = 0; + late final events = StreamController.broadcast( + sync: true, + onListen: () => listens++, + onCancel: () => cancels++, + ); + @override + Stream watchAccentColor() => + watchInitializedAccent(read: snapshot, changes: () => events.stream); +} + +class FakePortalIO implements LinuxPortalAccentIO { + FakePortalIO({this.named, this.rgb}); + Color? named; + Color? rgb; + Completer? namedRead; + Completer? rgbRead; + bool subscribedBeforeReads = true; + int closes = 0; + late final signals = + StreamController< + ({String namespace, String key, DBusValue value}) + >.broadcast(sync: true); + @override + Stream<({String namespace, String key, DBusValue value})> get changes => + signals.stream; + @override + Future readNamed() async { + subscribedBeforeReads &= signals.hasListener; + return namedRead == null ? named : await namedRead!.future; + } + + @override + Future readRgb() async { + subscribedBeforeReads &= signals.hasListener; + return rgbRead == null ? rgb : await rgbRead!.future; + } + + void change( + String namespace, + DBusValue value, { + String key = 'accent-color', + }) { + signals.add((namespace: namespace, key: key, value: value)); + } + + @override + Future close() async { + closes++; + } +} diff --git a/tools/gtk_accent_probe.cc b/tools/gtk_accent_probe.cc new file mode 100644 index 00000000..1fe48de4 --- /dev/null +++ b/tools/gtk_accent_probe.cc @@ -0,0 +1,148 @@ +// Exercises the production GTK lookup and notification observer with disposable +// themes. No desktop settings or BusyMark application data are read or written. +#include "gtk_accent.h" + +#include +#include +#include + +static void theme(const char* root, const char* name, const char* light, + const char* dark = nullptr) { + const std::string dir = std::string(root) + "/themes/" + name + "/gtk-3.0"; + g_assert_cmpint(g_mkdir_with_parents(dir.c_str(), 0700), ==, 0); + g_assert_true(g_file_set_contents((dir + "/gtk.css").c_str(), light, -1, nullptr)); + if (dark != nullptr) { + g_assert_true(g_file_set_contents((dir + "/gtk-dark.css").c_str(), dark, -1, + nullptr)); + } +} + +static void drain() { + // GTK settings notification invalidates CSS synchronously but observers + // deliberately sample on the next idle, after the new style is installed. + int iterations = 0; + while (g_main_context_pending(nullptr)) { + g_main_context_iteration(nullptr, FALSE); + g_assert_cmpint(++iterations, <, 10000); + } +} + +static void color(const busymark::GtkAccent& accent, int r, int g, int b) { + g_assert_true(accent.available); + g_assert_cmpint(std::lround(accent.rgba.red * 255), ==, r); + g_assert_cmpint(std::lround(accent.rgba.green * 255), ==, g); + g_assert_cmpint(std::lround(accent.rgba.blue * 255), ==, b); +} + +int main(int argc, char** argv) { + g_assert_cmpint(argc, ==, 2); + const char* root = argv[1]; + theme(root, "BusyMarkOld", "@define-color theme_selected_bg_color #336699;", + "@define-color theme_selected_bg_color #7764d8;"); + theme(root, "BusyMarkModern", + "@define-color accent_bg_color #006600;" + "@define-color theme_selected_bg_color #336699;"); + theme(root, "BusyMarkTransparent", + "@define-color accent_bg_color transparent;" + "@define-color theme_selected_bg_color #336699;"); + theme(root, "BusyMarkUnavailable", + "@define-color accent_bg_color transparent;" + "@define-color theme_selected_bg_color transparent;"); + theme(root, "BusyMarkMissing", "window { color: #abcdef; }"); + g_setenv("XDG_DATA_HOME", root, TRUE); + g_setenv("GTK_THEME", "BusyMarkOld", TRUE); + g_setenv("NO_AT_BRIDGE", "1", TRUE); + gtk_init(&argc, &argv); + // GTK_THEME forces a theme even across notifications; release that fixture + // startup override before changing per-process GtkSettings below. + g_unsetenv("GTK_THEME"); + GtkSettings* settings = gtk_settings_get_default(); + g_object_set(settings, "gtk-theme-name", "BusyMarkOld", + "gtk-application-prefer-dark-theme", FALSE, nullptr); + GtkWidget* window = gtk_window_new(GTK_WINDOW_TOPLEVEL); + gtk_widget_realize(window); + auto* context = gtk_widget_get_style_context(window); + GdkRGBA ignored = {0, 0, 0, 0}; + g_assert_false(gtk_style_context_lookup_color(context, "accent_bg_color", &ignored)); + color(busymark::LookupGtkAccent(context), 51, 102, 153); + g_print("PASS older-only lookup: #336699 (modern symbol absent)\n"); + + std::vector events; + { + busymark::GtkAccentObserver observer(window); + color(observer.Read(), 51, 102, 153); // Initial snapshot. + // A change between snapshot and subscription must be replayed on listen. + g_object_set(settings, "gtk-theme-name", "BusyMarkModern", nullptr); + drain(); + observer.Listen([&events](const busymark::GtkAccent& accent) { + events.push_back(accent); + }); + color(events.back(), 0, 102, 0); + g_assert_cmpuint(events.size(), ==, 1); + g_print("PASS listener attachment replays current modern color\n"); + + g_object_set(settings, "gtk-theme-name", "BusyMarkOld", nullptr); + drain(); + color(events.back(), 51, 102, 153); + g_assert_cmpuint(events.size(), ==, 2); + g_object_set(settings, "gtk-application-prefer-dark-theme", TRUE, nullptr); + drain(); + color(events.back(), 119, 100, 216); + g_assert_cmpuint(events.size(), ==, 3); + g_object_set(settings, "gtk-application-prefer-dark-theme", FALSE, nullptr); + drain(); + color(events.back(), 51, 102, 153); + g_assert_cmpuint(events.size(), ==, 4); + g_print("PASS runtime theme-name and dark-preference notifications read refreshed CSS\n"); + + g_object_set(settings, "gtk-theme-name", "BusyMarkTransparent", nullptr); + drain(); + color(observer.Read(), 51, 102, 153); + g_assert_cmpuint(events.size(), ==, 4); // Same effective fallback. + g_object_set(settings, "gtk-theme-name", "BusyMarkUnavailable", nullptr); + drain(); + g_assert_false(observer.Read().available); + g_assert_false(events.back().available); + g_assert_cmpuint(events.size(), ==, 5); + g_object_set(settings, "gtk-theme-name", "BusyMarkMissing", nullptr); + drain(); + g_assert_false(observer.Read().available); + g_assert_cmpuint(events.size(), ==, 5); + g_print("PASS transparent and missing lookups remain unavailable; duplicates suppressed\n"); + + // Simulate Flutter's native header CSS refresh: ordinary CSS must not + // generate accent changes or redefine accent symbols. + g_autoptr(GtkCssProvider) header = gtk_css_provider_new(); + gtk_css_provider_load_from_data(header, "window { background-color: #f0f0f0; }", + -1, nullptr); + gtk_style_context_add_provider_for_screen(gtk_widget_get_screen(window), + GTK_STYLE_PROVIDER(header), GTK_STYLE_PROVIDER_PRIORITY_APPLICATION); + drain(); + g_assert_cmpuint(events.size(), ==, 5); + gtk_style_context_remove_provider_for_screen(gtk_widget_get_screen(window), + GTK_STYLE_PROVIDER(header)); + drain(); + g_assert_cmpuint(events.size(), ==, 5); + g_print("PASS header CSS refresh produces no accent feedback\n"); + + g_object_set(settings, "gtk-theme-name", "BusyMarkModern", nullptr); + observer.Cancel(); // Cancel with a refresh queued. + drain(); + g_assert_cmpuint(events.size(), ==, 5); + observer.Listen([&events](const busymark::GtkAccent& accent) { + events.push_back(accent); + }); + color(events.back(), 0, 102, 0); + g_assert_cmpuint(events.size(), ==, 6); + g_object_set(settings, "gtk-theme-name", "BusyMarkOld", nullptr); + // Destroy the observer with a refresh queued, exactly as host shutdown does. + } + drain(); + g_assert_cmpuint(events.size(), ==, 6); + gtk_widget_destroy(window); + g_print("PASS cancellation, resubscription and queued-callback cleanup\n"); + g_print("GTK %u.%u.%u; backend=%s; fixture package=unpackaged native probe\n", + gtk_get_major_version(), gtk_get_minor_version(), gtk_get_micro_version(), + G_OBJECT_TYPE_NAME(gdk_display_get_default())); + return 0; +} diff --git a/tools/test_gtk_accent.sh b/tools/test_gtk_accent.sh new file mode 100755 index 00000000..939eff65 --- /dev/null +++ b/tools/test_gtk_accent.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail +project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +fixture_root="$(mktemp -d /tmp/busymark-gtk-accent.XXXXXX)" +trap 'rm -rf "$fixture_root"' EXIT +"${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror \ + "$project_root/tools/gtk_accent_probe.cc" \ + "$project_root/linux/runner/gtk_accent.cc" \ + -I"$project_root/linux/runner" $(pkg-config --cflags --libs gtk+-3.0) \ + -o "$fixture_root/probe" +# Use the caller's isolated display, or an existing display for a hidden probe. +# All GTK setting changes affect only this process; fixtures live under /tmp. +"$fixture_root/probe" "$fixture_root/data" From 25a5c6f23cc03aded59e315331697fcf7ffb2063 Mon Sep 17 00:00:00 2001 From: albert Date: Sun, 4 Oct 2026 02:03:45 -0700 Subject: [PATCH 07/14] Fix priority-aware Linux accent startup readiness --- .github/workflows/flutter-linux.yml | 6 + lib/src/app/system_accent.dart | 191 +++++++--- .../platform/linux_gtk_accent_service.dart | 35 +- test/src/app_smoke_test.dart | 112 ++++++ test/src/linux_accent_provider_test.dart | 146 +++++++- test/src/linux_gtk_accent_service_test.dart | 92 +++++ test/src/system_accent_test.dart | 276 +++++++++++++- test/support/linux_accent_fakes.dart | 28 +- tools/gtk_accent_host_probe.cc | 345 ++++++++++++++++++ tools/test_gtk_accent_host.sh | 19 + 10 files changed, 1175 insertions(+), 75 deletions(-) create mode 100644 tools/gtk_accent_host_probe.cc create mode 100755 tools/test_gtk_accent_host.sh diff --git a/.github/workflows/flutter-linux.yml b/.github/workflows/flutter-linux.yml index e68ad663..01c2aaf2 100644 --- a/.github/workflows/flutter-linux.yml +++ b/.github/workflows/flutter-linux.yml @@ -89,6 +89,9 @@ jobs: - name: Resolve locked dependencies run: flutter pub get --enforce-lockfile + - name: Test production GTK accent lookup and observer + run: xvfb-run -a env GDK_BACKEND=x11 bash tools/test_gtk_accent.sh + - name: Generate localizations run: flutter gen-l10n @@ -121,6 +124,9 @@ jobs: - name: Build Linux release run: flutter build linux --release + - name: Test production GTK accent channel host and lifecycle + run: xvfb-run -a env GDK_BACKEND=x11 bash tools/test_gtk_accent_host.sh + - name: Verify release ships catalog and notices without language pairs run: | spelling_root="build/linux/x64/release/bundle/share/busymark/spelling" diff --git a/lib/src/app/system_accent.dart b/lib/src/app/system_accent.dart index faca9d68..bbafa83a 100644 --- a/lib/src/app/system_accent.dart +++ b/lib/src/app/system_accent.dart @@ -45,17 +45,24 @@ final systemAccentColorProvider = StreamProvider((ref) { /// Sources stay separate throughout startup and live updates. The list is in /// priority order: GTK, then the portal's named-Yaru / generic-RGB fallback. class LinuxAccentCoordinator { - LinuxAccentCoordinator({required List sources}) - : _sources = sources, - _values = List.filled(sources.length, null); - + LinuxAccentCoordinator({ + required List sources, + this.initializationDeadline = linuxAccentInitializationDeadline, + }) : _sources = sources, + _values = List.filled(sources.length, null), + _resolved = List.filled(sources.length, false), + _deadlines = List.filled(sources.length, null); + + final Duration initializationDeadline; final List _sources; final List _values; + final List _resolved; + final List _deadlines; final _changes = StreamController.broadcast(sync: true); final _subscriptions = >[]; - final _ready = >[]; - Future? _initialization; + final _ready = Completer(); Future? _disposal; + bool _started = false; bool _disposed = false; Color _color = busyMarkDefaultAccentColor; Color get color => _color; @@ -69,52 +76,73 @@ class LinuxAccentCoordinator { listener.onCancel = subscription.cancel; }); - Future initialize() => _initialization ??= _initialize(); + Future initialize() { + if (!_started && !_disposed) { + _started = true; + for (var index = 0; index < _sources.length; index++) { + void update(Color? value) { + if (_disposed) return; + _resolved[index] = true; + _values[index] = value; + _deadlines[index]?.cancel(); + _recompute(); + } - Future _initialize() async { - if (_disposed) return; - final ready = >[]; - for (var index = 0; index < _sources.length; index++) { - final first = Completer(); - _ready.add(first); - ready.add(first.future); - void update(Color? value) { - if (!first.isCompleted) first.complete(); - if (_disposed) return; - _values[index] = value; - final resolved = - _values.whereType().firstOrNull ?? - busyMarkDefaultAccentColor; - if (resolved != _color) { - _color = resolved; - _changes.add(resolved); + try { + _subscriptions.add( + _sources[index].watchAccentColor().listen( + update, + onError: (Object _, StackTrace _) => update(null), + onDone: () { + if (!_resolved[index]) update(null); + }, + ), + ); + // Subscribe first: a source's own per-setting deadline must have an + // opportunity to publish its retained fallback before this safety net. + if (!_resolved[index]) { + _deadlines[index] = Timer(initializationDeadline, () { + if (!_disposed && !_resolved[index]) update(null); + }); + } + } on Object { + update(null); } } + _recompute(); + } + return _ready.future; + } - try { - _subscriptions.add( - _sources[index].watchAccentColor().listen( - update, - onError: (Object _, StackTrace _) => update(null), - onDone: () { - if (!first.isCompleted) update(null); - }, - ), - ); - } on Object { - update(null); + void _recompute() { + var ready = true; + Color? selected; + for (var index = 0; index < _sources.length; index++) { + if (!_resolved[index]) { + ready = false; + break; } + if (_values[index] != null) { + selected = _values[index]; + break; + } + } + final color = selected ?? busyMarkDefaultAccentColor; + if (color != _color) { + _color = color; + _changes.add(color); } - await Future.wait(ready); + if (ready && !_ready.isCompleted) _ready.complete(); } Future dispose() => _disposal ??= _dispose(); Future _dispose() async { _disposed = true; - for (final first in _ready) { - if (!first.isCompleted) first.complete(); + for (final deadline in _deadlines) { + deadline?.cancel(); } + if (!_ready.isCompleted) _ready.complete(); await Future.wait( _subscriptions.map((subscription) => subscription.cancel()), ); @@ -131,9 +159,12 @@ abstract interface class LinuxPortalAccentIO { } class LinuxPortalAppearance implements LinuxAccentSource { - const LinuxPortalAppearance({LinuxPortalAccentIO Function()? open}) - : _open = open ?? _openPortal; + const LinuxPortalAppearance({ + LinuxPortalAccentIO Function()? open, + this.initializationDeadline = linuxAccentInitializationDeadline, + }) : _open = open ?? _openPortal; final LinuxPortalAccentIO Function() _open; + final Duration initializationDeadline; static const freedesktopAppearance = 'org.freedesktop.appearance'; static const gnomeInterface = 'org.gnome.desktop.interface'; @@ -160,56 +191,98 @@ class LinuxPortalAppearance implements LinuxAccentSource { subscription; Color? named; Color? rgb; + var namedResolved = false; + var rgbResolved = false; var namedGeneration = 0; var rgbGeneration = 0; + Timer? namedDeadline; + Timer? rgbDeadline; var cancelled = false; void publish() { - if (!cancelled) controller.add(named ?? rgb); + if (cancelled || !namedResolved) return; + if (named != null || rgbResolved) controller.add(named ?? rgb); } - Future initialize() async { + void resolveNamed(Color? value) { + if (cancelled) return; + named = value; + namedResolved = true; + namedDeadline?.cancel(); + publish(); + } + + void resolveRgb(Color? value) { + if (cancelled) return; + rgb = value; + rgbResolved = true; + rgbDeadline?.cancel(); + publish(); + } + + void initialize() { try { final session = io = _open(); - // D-Bus installs the signal match before these method calls. Each key - // has its own generation so a change cannot be undone by either read. + final initialNamedGeneration = namedGeneration; + final initialRgbGeneration = rgbGeneration; subscription = session.changes.listen( (change) { if (change.key != 'accent-color') return; if (change.namespace == gnomeInterface) { namedGeneration++; - named = colorFromUbuntuAccentNameValue(change.value); + resolveNamed(colorFromUbuntuAccentNameValue(change.value)); } else if (change.namespace == freedesktopAppearance) { rgbGeneration++; - rgb = colorFromPortalAccentValue(change.value); - } else { - return; + resolveRgb(colorFromPortalAccentValue(change.value)); } - publish(); }, onError: (Object _, StackTrace _) { - // A bad signal does not terminate the subscription or native source. + // A malformed signal cannot resolve either setting or end native updates. }, ); - final initialNamedGeneration = namedGeneration; - final initialRgbGeneration = rgbGeneration; - await Future.wait([ + namedDeadline = Timer(initializationDeadline, () { + if (!cancelled && + !namedResolved && + namedGeneration == initialNamedGeneration) { + resolveNamed(null); + } + }); + rgbDeadline = Timer(initializationDeadline, () { + if (!cancelled && + !rgbResolved && + rgbGeneration == initialRgbGeneration) { + resolveRgb(null); + } + }); + // Resolve each read independently. A valid named result is ready even + // when RGB never responds; generic data waits for named unavailability. + unawaited( _readAccentSafely(session.readNamed).then((value) { - if (namedGeneration == initialNamedGeneration) named = value; + if (!cancelled && namedGeneration == initialNamedGeneration) { + resolveNamed(value); + } }), + ); + unawaited( _readAccentSafely(session.readRgb).then((value) { - if (rgbGeneration == initialRgbGeneration) rgb = value; + if (!cancelled && rgbGeneration == initialRgbGeneration) { + resolveRgb(value); + } }), - ]); - publish(); + ); } on Object { + namedDeadline?.cancel(); + rgbDeadline?.cancel(); + namedResolved = rgbResolved = true; publish(); } } controller = StreamController( - onListen: () => unawaited(initialize()), + onListen: initialize, onCancel: () async { cancelled = true; + namedDeadline?.cancel(); + rgbDeadline?.cancel(); try { await subscription?.cancel(); } finally { diff --git a/lib/src/platform/linux_gtk_accent_service.dart b/lib/src/platform/linux_gtk_accent_service.dart index b6dab0ce..20d158b6 100644 --- a/lib/src/platform/linux_gtk_accent_service.dart +++ b/lib/src/platform/linux_gtk_accent_service.dart @@ -2,6 +2,12 @@ import 'dart:async'; import 'package:flutter/services.dart'; +/// All Linux accent reads start concurrently. Unresolved sources/settings get +/// five seconds, matching rich_clipboard_service.dart's platform I/O timeout. +/// Expiry makes them temporarily unavailable; watchers and unsuperseded late +/// reads remain active and can restore their normal precedence. +const linuxAccentInitializationDeadline = Duration(seconds: 5); + /// Each subscription owns its initialization and platform listener. Null means /// the source currently has no usable accent, rather than a default color. abstract interface class LinuxAccentSource { @@ -14,9 +20,11 @@ class LinuxGtkAccentService implements LinuxAccentSource { EventChannel events = const EventChannel( 'com.busymark.app/gtk_accent/events', ), + this.initializationDeadline = linuxAccentInitializationDeadline, }) : _channel = channel, _events = events; + final Duration initializationDeadline; final MethodChannel _channel; final EventChannel _events; @@ -31,8 +39,11 @@ class LinuxGtkAccentService implements LinuxAccentSource { } @override - Stream watchAccentColor() => - watchInitializedAccent(read: readAccentColor, changes: _watchEvents); + Stream watchAccentColor() => watchInitializedAccent( + read: readAccentColor, + changes: _watchEvents, + initializationDeadline: initializationDeadline, + ); Stream _watchEvents() { // EventChannel.receiveBroadcastStream reports listen/cancel failures to @@ -98,17 +109,23 @@ Color? colorFromGtkAccentPayload(Object? payload) { Stream watchInitializedAccent({ required Future Function() read, required Stream Function() changes, + Duration initializationDeadline = linuxAccentInitializationDeadline, }) { late StreamController controller; StreamSubscription? subscription; + Timer? deadline; + var resolved = false; var generation = 0; var cancelled = false; void changed(Color? color) { generation++; + resolved = true; + deadline?.cancel(); if (!cancelled) controller.add(color); } Future initialize() async { + final snapshotGeneration = generation; try { subscription = changes().listen( changed, @@ -117,7 +134,16 @@ Stream watchInitializedAccent({ } on Object { changed(null); } - final snapshotGeneration = generation; + if (!resolved) { + deadline = Timer(initializationDeadline, () { + if (!cancelled && !resolved && generation == snapshotGeneration) { + // Do not advance the event generation: an unsuperseded late snapshot + // can still recover the source after startup's deadline. + resolved = true; + controller.add(null); + } + }); + } Color? snapshot; try { snapshot = await read(); @@ -125,6 +151,8 @@ Stream watchInitializedAccent({ snapshot = null; } if (!cancelled && generation == snapshotGeneration) { + resolved = true; + deadline?.cancel(); controller.add(snapshot); } } @@ -133,6 +161,7 @@ Stream watchInitializedAccent({ onListen: () => unawaited(initialize()), onCancel: () async { cancelled = true; + deadline?.cancel(); await subscription?.cancel(); }, ); diff --git a/test/src/app_smoke_test.dart b/test/src/app_smoke_test.dart index 120e82f4..7ddcd75d 100644 --- a/test/src/app_smoke_test.dart +++ b/test/src/app_smoke_test.dart @@ -52,6 +52,7 @@ import 'package:busymark/src/local_history/local_history_store.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_gtk_accent_service.dart'; import 'package:busymark/src/platform/linux_header_bar_service.dart'; import 'package:busymark/src/platform/rich_clipboard_service.dart'; import 'package:busymark/src/spellcheck/spelling_dictionary_downloader.dart'; @@ -221,6 +222,7 @@ void main() { const service = _SearchWorkspaceService('start'); final container = ProviderContainer( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), localSettingsStoreProvider.overrideWithValue(settingsStore), workspaceServiceProvider.overrideWithValue(service), @@ -461,6 +463,7 @@ void main() { await tester.pumpWidget( ProviderScope( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), ], child: const BusyMarkApp(), @@ -508,6 +511,7 @@ void main() { await tester.pumpWidget( ProviderScope( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), ], child: const BusyMarkApp(), @@ -571,6 +575,7 @@ void main() { ); final container = ProviderContainer( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), localSettingsStoreProvider.overrideWithValue( _MemorySettingsStore(), @@ -643,6 +648,7 @@ void main() { (tester) async { final container = ProviderContainer( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), localSettingsStoreProvider.overrideWithValue( _MemorySettingsStore(), @@ -693,6 +699,7 @@ void main() { await tester.pumpWidget( ProviderScope( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), nativeWindowControllerProvider.overrideWithValue(nativeWindow), ], @@ -760,6 +767,7 @@ void main() { await tester.pumpWidget( ProviderScope( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), ], child: const BusyMarkApp(), @@ -789,6 +797,7 @@ void main() { await tester.pumpWidget( ProviderScope( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), ], child: const BusyMarkApp(), @@ -849,6 +858,7 @@ void main() { await tester.pumpWidget( ProviderScope( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), ], child: const BusyMarkApp(), @@ -889,6 +899,7 @@ void main() { await tester.pumpWidget( ProviderScope( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), ], child: const BusyMarkApp(), @@ -955,6 +966,7 @@ void main() { await tester.pumpWidget( ProviderScope( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), localSettingsStoreProvider.overrideWithValue(settingsStore), nativeWindowControllerProvider.overrideWithValue(nativeWindow), @@ -1324,6 +1336,7 @@ void main() { await tester.pumpWidget( ProviderScope( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), ], child: const BusyMarkApp(), @@ -1481,6 +1494,7 @@ void main() { }); final container = ProviderContainer( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), localSettingsStoreProvider.overrideWithValue(_MemorySettingsStore()), spellingSessionControllerProvider.overrideWith((ref) => spelling), @@ -2072,6 +2086,7 @@ void main() { await tester.pumpWidget( ProviderScope( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), localSettingsStoreProvider.overrideWithValue(settingsStore), ], @@ -2118,6 +2133,7 @@ void main() { await tester.pumpWidget( ProviderScope( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), localSettingsStoreProvider.overrideWithValue(settingsStore), ], @@ -2176,6 +2192,7 @@ void main() { await tester.pumpWidget( ProviderScope( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), localSettingsStoreProvider.overrideWithValue(settingsStore), ], @@ -2225,6 +2242,7 @@ void main() { await tester.pumpWidget( ProviderScope( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), ], child: const BusyMarkApp(), @@ -2372,6 +2390,7 @@ void main() { await tester.pumpWidget( ProviderScope( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), ], child: const BusyMarkApp(), @@ -2526,6 +2545,7 @@ void main() { await tester.pumpWidget( ProviderScope( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), ], child: const BusyMarkApp(), @@ -2830,6 +2850,7 @@ void main() { await tester.pumpWidget( ProviderScope( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), ], child: const BusyMarkApp(), @@ -2870,6 +2891,7 @@ void main() { await tester.pumpWidget( ProviderScope( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), localSettingsStoreProvider.overrideWithValue(settingsStore), ], @@ -2936,6 +2958,7 @@ void main() { await tester.pumpWidget( ProviderScope( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), localSettingsStoreProvider.overrideWithValue(settingsStore), ], @@ -2960,6 +2983,7 @@ void main() { await tester.pumpWidget( ProviderScope( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), ], child: const BusyMarkApp(), @@ -3029,6 +3053,7 @@ void main() { await tester.pumpWidget( ProviderScope( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), localSettingsStoreProvider.overrideWithValue(settingsStore), workspaceServiceProvider.overrideWithValue(service), @@ -3097,6 +3122,7 @@ void main() { final service = _StartupWorkspaceService(); final container = ProviderContainer( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), localSettingsStoreProvider.overrideWithValue( _MemorySettingsStore() @@ -3219,6 +3245,7 @@ void main() { final root = Directory('test/fixtures/writerside/basic_project').absolute; final container = ProviderContainer( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), localSettingsStoreProvider.overrideWithValue( _MemorySettingsStore() @@ -3481,6 +3508,7 @@ code final controller = _MutableWorkspaceController(workspaceState); final container = ProviderContainer( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), localSettingsStoreProvider.overrideWithValue(_MemorySettingsStore()), workspaceControllerProvider.overrideWith(() => controller), @@ -4398,6 +4426,7 @@ code ..simulateTopicRename = true; final container = ProviderContainer( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), localSettingsStoreProvider.overrideWithValue(_MemorySettingsStore()), workspaceControllerProvider.overrideWith(() => controller), @@ -4714,6 +4743,7 @@ code ..simulateTopicRename = true; final container = ProviderContainer( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), localSettingsStoreProvider.overrideWithValue(_MemorySettingsStore()), workspaceControllerProvider.overrideWith(() => controller), @@ -4797,6 +4827,7 @@ code ).._sources[second.path] = '# B\r\n'; final container = ProviderContainer( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), localSettingsStoreProvider.overrideWithValue(_MemorySettingsStore()), workspaceServiceProvider.overrideWithValue(service), @@ -4978,6 +5009,7 @@ code final container = ProviderContainer( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), localSettingsStoreProvider.overrideWithValue(_MemorySettingsStore()), workspaceServiceProvider.overrideWithValue(service), @@ -5444,6 +5476,7 @@ code .toJson(); final container = ProviderContainer( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), localSettingsStoreProvider.overrideWithValue(settingsStore), workspaceServiceProvider.overrideWithValue(service), @@ -5525,6 +5558,7 @@ code ); final container = ProviderContainer( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), localSettingsStoreProvider.overrideWithValue(_MemorySettingsStore()), workspaceServiceProvider.overrideWithValue(service), @@ -5605,6 +5639,7 @@ code .toJson(); final container = ProviderContainer( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), localSettingsStoreProvider.overrideWithValue(settingsStore), workspaceServiceProvider.overrideWithValue(service), @@ -5917,6 +5952,7 @@ code final historyStore = MemoryLocalHistoryStore(); final container = ProviderContainer( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), localSettingsStoreProvider.overrideWithValue(settingsStore), workspaceServiceProvider.overrideWithValue( @@ -6159,6 +6195,7 @@ code final historyStore = MemoryLocalHistoryStore(); final container = ProviderContainer( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), localSettingsStoreProvider.overrideWithValue(settingsStore), workspaceServiceProvider.overrideWithValue( @@ -6304,6 +6341,7 @@ code ); final container = ProviderContainer( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), localSettingsStoreProvider.overrideWithValue(_MemorySettingsStore()), workspaceControllerProvider.overrideWith(() => controller), @@ -6523,6 +6561,7 @@ code ); final container = ProviderContainer( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), localSettingsStoreProvider.overrideWithValue(_MemorySettingsStore()), workspaceServiceProvider.overrideWithValue(service), @@ -6615,6 +6654,7 @@ code ); final container = ProviderContainer( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), localSettingsStoreProvider.overrideWithValue(_MemorySettingsStore()), workspaceControllerProvider.overrideWith(() => controller), @@ -6691,6 +6731,7 @@ code .toJson(); final container = ProviderContainer( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), localSettingsStoreProvider.overrideWithValue(settingsStore), workspaceServiceProvider.overrideWithValue(service), @@ -6755,6 +6796,7 @@ code .toJson(); final container = ProviderContainer( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), localSettingsStoreProvider.overrideWithValue(settingsStore), workspaceServiceProvider.overrideWithValue(service), @@ -6838,6 +6880,7 @@ code .toJson(); final container = ProviderContainer( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), localSettingsStoreProvider.overrideWithValue(settingsStore), workspaceControllerProvider.overrideWith(() => controller), @@ -6941,6 +6984,7 @@ code ..value = AppSettings.defaults().copyWith(localeTag: 'ar').toJson(); final container = ProviderContainer( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), localSettingsStoreProvider.overrideWithValue(settingsStore), workspaceControllerProvider.overrideWith(() => controller), @@ -7079,6 +7123,7 @@ code ); final container = ProviderContainer( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), workspaceControllerProvider.overrideWith(() => controller), ], @@ -7159,6 +7204,7 @@ code ); final container = ProviderContainer( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), workspaceControllerProvider.overrideWith(() => controller), ], @@ -7216,6 +7262,7 @@ code .toJson(); final container = ProviderContainer( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), localSettingsStoreProvider.overrideWithValue(settingsStore), workspaceServiceProvider.overrideWithValue(service), @@ -7319,6 +7366,7 @@ code const service = _SearchWorkspaceService(source); final container = ProviderContainer( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), localSettingsStoreProvider.overrideWithValue(settingsStore), workspaceServiceProvider.overrideWithValue(service), @@ -7470,6 +7518,7 @@ code const service = _SearchWorkspaceService(source); final container = ProviderContainer( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), localSettingsStoreProvider.overrideWithValue(settingsStore), workspaceServiceProvider.overrideWithValue(service), @@ -7592,6 +7641,7 @@ code .toJson(); final container = ProviderContainer( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), localSettingsStoreProvider.overrideWithValue(settingsStore), workspaceServiceProvider.overrideWithValue(service), @@ -7709,6 +7759,7 @@ code .toJson(); final container = ProviderContainer( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), localSettingsStoreProvider.overrideWithValue(settingsStore), workspaceServiceProvider.overrideWithValue(service), @@ -7786,6 +7837,7 @@ code const service = _SearchWorkspaceService('# Editing toolbar\n'); final container = ProviderContainer( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), localSettingsStoreProvider.overrideWithValue(settingsStore), workspaceServiceProvider.overrideWithValue(service), @@ -7979,6 +8031,7 @@ code ); final container = ProviderContainer( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), localSettingsStoreProvider.overrideWithValue(settingsStore), workspaceServiceProvider.overrideWithValue(service), @@ -8179,6 +8232,7 @@ code const service = _SearchWorkspaceService(source); final container = ProviderContainer( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), localSettingsStoreProvider.overrideWithValue(settingsStore), workspaceServiceProvider.overrideWithValue(service), @@ -8304,6 +8358,7 @@ code const service = _SearchWorkspaceService(source); final container = ProviderContainer( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), localSettingsStoreProvider.overrideWithValue(settingsStore), workspaceServiceProvider.overrideWithValue(service), @@ -8400,6 +8455,7 @@ code ); final container = ProviderContainer( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), localSettingsStoreProvider.overrideWithValue(settingsStore), workspaceServiceProvider.overrideWithValue(service), @@ -8514,6 +8570,7 @@ code const service = _SearchWorkspaceService('> Shared quote.\n'); final container = ProviderContainer( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), localSettingsStoreProvider.overrideWithValue(settingsStore), workspaceServiceProvider.overrideWithValue(service), @@ -8619,6 +8676,7 @@ code const service = _SearchWorkspaceService('```dart\n$code\n```\n\nمرحبا\n'); final container = ProviderContainer( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), localSettingsStoreProvider.overrideWithValue(settingsStore), workspaceServiceProvider.overrideWithValue(service), @@ -8760,6 +8818,7 @@ After break. '''); final container = ProviderContainer( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), localSettingsStoreProvider.overrideWithValue(settingsStore), workspaceServiceProvider.overrideWithValue(service), @@ -8979,6 +9038,7 @@ After break. }); final container = ProviderContainer( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), localSettingsStoreProvider.overrideWithValue(settingsStore), workspaceControllerProvider.overrideWith(() => controller), @@ -9070,6 +9130,7 @@ After break. '''); final container = ProviderContainer( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), localSettingsStoreProvider.overrideWithValue(settingsStore), workspaceServiceProvider.overrideWithValue(service), @@ -9175,6 +9236,7 @@ After break. .toJson(); final container = ProviderContainer( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), localSettingsStoreProvider.overrideWithValue(settingsStore), workspaceServiceProvider.overrideWithValue(service), @@ -9271,6 +9333,7 @@ After break. final service = _SearchWorkspaceService('Paragraph\n'); final container = ProviderContainer( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), localSettingsStoreProvider.overrideWithValue(settingsStore), workspaceServiceProvider.overrideWithValue(service), @@ -9324,6 +9387,7 @@ After break. await tester.pumpWidget( ProviderScope( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), localSettingsStoreProvider.overrideWithValue(_MemorySettingsStore()), nativeWindowControllerProvider.overrideWithValue(nativeWindow), @@ -9363,6 +9427,7 @@ After break. final service = _StartupWorkspaceService(); final container = ProviderContainer( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), localSettingsStoreProvider.overrideWithValue(_MemorySettingsStore()), nativeWindowControllerProvider.overrideWithValue(nativeWindow), @@ -9409,6 +9474,7 @@ After break. final service = _StartupWorkspaceService(); final container = ProviderContainer( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), localSettingsStoreProvider.overrideWithValue(_MemorySettingsStore()), workspaceServiceProvider.overrideWithValue(service), @@ -9504,6 +9570,7 @@ Body. .toJson(); final container = ProviderContainer( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), localSettingsStoreProvider.overrideWithValue(settingsStore), workspaceServiceProvider.overrideWithValue(service), @@ -9645,6 +9712,7 @@ Gamma body. const service = _SearchWorkspaceService(source); final container = ProviderContainer( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), localSettingsStoreProvider.overrideWithValue(settingsStore), workspaceServiceProvider.overrideWithValue(service), @@ -9854,6 +9922,7 @@ Gamma body. .toJson(); final container = ProviderContainer( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), localSettingsStoreProvider.overrideWithValue(settingsStore), workspaceServiceProvider.overrideWithValue( @@ -10075,6 +10144,7 @@ Gamma body. .toJson(); final container = ProviderContainer( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), localSettingsStoreProvider.overrideWithValue(settingsStore), workspaceServiceProvider.overrideWithValue(service), @@ -10191,6 +10261,7 @@ Draft paragraph. .toJson(); final container = ProviderContainer( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), localSettingsStoreProvider.overrideWithValue(settingsStore), workspaceServiceProvider.overrideWithValue(service), @@ -10258,6 +10329,7 @@ Draft paragraph. .toJson(); final container = ProviderContainer( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), localSettingsStoreProvider.overrideWithValue(settingsStore), workspaceServiceProvider.overrideWithValue(service), @@ -10333,6 +10405,7 @@ Draft paragraph. .toJson(); final container = ProviderContainer( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), localSettingsStoreProvider.overrideWithValue(settingsStore), ], @@ -10461,6 +10534,7 @@ Before [![Inline logo](inline-logo.png)](inline-guide.md) after. .toJson(); final container = ProviderContainer( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), localSettingsStoreProvider.overrideWithValue(settingsStore), workspaceControllerProvider.overrideWith(() => controller), @@ -10498,6 +10572,7 @@ Before [![Inline logo](inline-logo.png)](inline-guide.md) after. .toJson(); final container = ProviderContainer( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), localSettingsStoreProvider.overrideWithValue(settingsStore), workspaceServiceProvider.overrideWithValue(_StartupWorkspaceService()), @@ -10540,6 +10615,7 @@ Before [![Inline logo](inline-logo.png)](inline-guide.md) after. .toJson(); final container = ProviderContainer( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), localSettingsStoreProvider.overrideWithValue(settingsStore), workspaceServiceProvider.overrideWithValue(service), @@ -10596,6 +10672,7 @@ Before [![Inline logo](inline-logo.png)](inline-guide.md) after. .toJson(); final container = ProviderContainer( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), localSettingsStoreProvider.overrideWithValue(settingsStore), workspaceServiceProvider.overrideWithValue(service), @@ -10670,6 +10747,7 @@ Before [![Inline logo](inline-logo.png)](inline-guide.md) after. .toJson(); final container = ProviderContainer( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), localSettingsStoreProvider.overrideWithValue(settingsStore), startupPathProvider.overrideWithValue(null), @@ -11185,6 +11263,7 @@ Before [![Inline logo](inline-logo.png)](inline-guide.md) after. final service = _StartupWorkspaceService(); final container = ProviderContainer( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), localSettingsStoreProvider.overrideWithValue(_MemorySettingsStore()), workspaceServiceProvider.overrideWithValue(service), @@ -11286,6 +11365,7 @@ Before [![Inline logo](inline-logo.png)](inline-guide.md) after. final container = ProviderContainer( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue( nativeHeaderBarService, ), @@ -11356,6 +11436,7 @@ Before [![Inline logo](inline-logo.png)](inline-guide.md) after. .toJson(); final container = ProviderContainer( overrides: [ + ..._smokeAccentOverrides, systemAccentColorProvider.overrideWith((ref) => const Stream.empty()), workspaceFileMonitorProvider.overrideWith( (ref) => _SearchTestFileMonitor(), @@ -11871,6 +11952,7 @@ Before [![Inline logo](inline-logo.png)](inline-guide.md) after. ); final container = ProviderContainer( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), localSettingsStoreProvider.overrideWithValue(settingsStore), workspaceServiceProvider.overrideWithValue(service), @@ -11955,6 +12037,7 @@ Before [![Inline logo](inline-logo.png)](inline-guide.md) after. final service = _SearchWorkspaceService(source); final container = ProviderContainer( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), localSettingsStoreProvider.overrideWithValue(settingsStore), workspaceServiceProvider.overrideWithValue(service), @@ -12032,6 +12115,7 @@ Before [![Inline logo](inline-logo.png)](inline-guide.md) after. ); final container = ProviderContainer( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), localSettingsStoreProvider.overrideWithValue(settingsStore), workspaceServiceProvider.overrideWithValue(service), @@ -12103,6 +12187,7 @@ Before [![Inline logo](inline-logo.png)](inline-guide.md) after. final service = _SearchWorkspaceService(_fsrsReadmeSearchSource()); final container = ProviderContainer( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), localSettingsStoreProvider.overrideWithValue(settingsStore), workspaceServiceProvider.overrideWithValue(service), @@ -12171,6 +12256,7 @@ Before [![Inline logo](inline-logo.png)](inline-guide.md) after. ); final container = ProviderContainer( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), localSettingsStoreProvider.overrideWithValue(settingsStore), workspaceServiceProvider.overrideWithValue(service), @@ -12255,6 +12341,7 @@ Before [![Inline logo](inline-logo.png)](inline-guide.md) after. ); final container = ProviderContainer( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), localSettingsStoreProvider.overrideWithValue(settingsStore), workspaceServiceProvider.overrideWithValue(service), @@ -12299,6 +12386,7 @@ Before [![Inline logo](inline-logo.png)](inline-guide.md) after. ); final container = ProviderContainer( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), localSettingsStoreProvider.overrideWithValue(settingsStore), workspaceServiceProvider.overrideWithValue(service), @@ -12340,6 +12428,7 @@ Before [![Inline logo](inline-logo.png)](inline-guide.md) after. ); final container = ProviderContainer( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), localSettingsStoreProvider.overrideWithValue(settingsStore), workspaceServiceProvider.overrideWithValue(service), @@ -12387,6 +12476,7 @@ Before [![Inline logo](inline-logo.png)](inline-guide.md) after. final service = _SearchWorkspaceService(_fsrsReadmeSearchSource()); final container = ProviderContainer( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), localSettingsStoreProvider.overrideWithValue(settingsStore), workspaceServiceProvider.overrideWithValue(service), @@ -12434,6 +12524,7 @@ Before [![Inline logo](inline-logo.png)](inline-guide.md) after. await tester.pumpWidget( ProviderScope( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), localSettingsStoreProvider.overrideWithValue(_MemorySettingsStore()), workspaceServiceProvider.overrideWithValue(service), @@ -12495,6 +12586,7 @@ Before [![Inline logo](inline-logo.png)](inline-guide.md) after. .toJson(); final container = ProviderContainer( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), localSettingsStoreProvider.overrideWithValue(settingsStore), workspaceServiceProvider.overrideWithValue(service), @@ -13527,6 +13619,7 @@ _pumpSearchRegression( .toJson(); final container = ProviderContainer( overrides: [ + ..._smokeAccentOverrides, systemAccentColorProvider.overrideWith((ref) => const Stream.empty()), workspaceFileMonitorProvider.overrideWith( (ref) => _SearchTestFileMonitor(), @@ -14252,6 +14345,7 @@ Future<_SettingsSpellingHarness> _pumpSettingsSpellingHarness( final container = ProviderContainer( overrides: [ + ..._smokeAccentOverrides, linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), localSettingsStoreProvider.overrideWithValue(settingsStore), systemAccentColorProvider.overrideWith((ref) => const Stream.empty()), @@ -14444,3 +14538,21 @@ class _FakeNativeWindowController implements NativeWindowController { listeners.remove(listener); } } + +// Smoke tests exercise application behavior with deterministic external accent +// I/O. The production coordinator/provider still run; readiness, deadline and +// GTK/portal event behavior are covered by the focused Linux accent tests. +final _smokeAccentOverrides = [ + linuxGtkAccentSourceProvider.overrideWithValue( + const _UnavailableAccentSource(), + ), + linuxPortalAccentSourceProvider.overrideWithValue( + const _UnavailableAccentSource(), + ), +]; + +class _UnavailableAccentSource implements LinuxAccentSource { + const _UnavailableAccentSource(); + @override + Stream watchAccentColor() => Stream.value(null); +} diff --git a/test/src/linux_accent_provider_test.dart b/test/src/linux_accent_provider_test.dart index 2fbedb64..f5d1bd43 100644 --- a/test/src/linux_accent_provider_test.dart +++ b/test/src/linux_accent_provider_test.dart @@ -5,6 +5,7 @@ import 'package:busymark/src/app/app_settings.dart'; import 'package:busymark/src/app/busymark_app.dart'; import 'package:busymark/src/app/system_accent.dart'; import 'package:busymark/src/platform/linux_header_bar_service.dart'; +import 'package:dbus/dbus.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -180,21 +181,92 @@ void main() { }, ); - testWidgets( - 'startup and updated production light/dark themes consume GTK accent', - (tester) async { - const first = Color(0xff336699); + for (final scenario in [ + 'GTK with silent portals', + 'named with silent RGB', + 'pending GTK before deadline', + 'pending GTK at deadline', + 'pending named becomes valid', + 'pending named becomes unavailable', + 'pending named at deadline', + 'all silent at deadline', + ]) { + testWidgets('first application frame and live light/dark themes: $scenario', ( + tester, + ) async { + const gtkColor = Color(0xff336699); const second = Color(0xff7764d8); - final gtk = FakeAccentSource(() async => first); - final io = FakePortalIO(); + const deadline = Duration(milliseconds: 100); + final snapshot = Completer(); + final gtk = FakeAccentSource( + () => snapshot.future, + initializationDeadline: deadline, + ); + final io = FakePortalIO() + ..namedRead = Completer() + ..rgbRead = Completer(); + Color first; + switch (scenario) { + case 'GTK with silent portals': + snapshot.complete(gtkColor); + first = gtkColor; + case 'named with silent RGB': + snapshot.complete(null); + io.namedRead!.complete(YaruVariant.purple.color); + first = YaruVariant.purple.color; + case 'pending GTK before deadline': + io.namedRead!.complete(YaruVariant.purple.color); + first = gtkColor; + case 'pending GTK at deadline': + io.namedRead!.complete(YaruVariant.purple.color); + first = YaruVariant.purple.color; + case 'pending named becomes valid': + snapshot.complete(null); + first = YaruVariant.purple.color; + case 'pending named becomes unavailable': + case 'pending named at deadline': + snapshot.complete(null); + first = gtkColor; // RGB signal is retained until named is resolved. + case 'all silent at deadline': + first = busyMarkDefaultAccentColor; + default: + throw StateError(scenario); + } final coordinator = LinuxAccentCoordinator( + initializationDeadline: deadline, sources: [ gtk, - LinuxPortalAppearance(open: () => io), + LinuxPortalAppearance( + open: () => io, + initializationDeadline: deadline, + ), ], ); - final initialized = coordinator.initialize(); + var ready = false; + final initialized = coordinator.initialize().then((_) => ready = true); + if (scenario.startsWith('pending named')) { + io.change( + LinuxPortalAppearance.freedesktopAppearance, + portalRgb(0.2, 0.4, 0.6), + ); + } + await tester.pump(); + if (scenario.startsWith('pending') || scenario.startsWith('all silent')) { + expect(ready, isFalse); + } + if (scenario == 'pending GTK before deadline') { + await tester.pump(deadline - const Duration(milliseconds: 1)); + expect(ready, isFalse); + snapshot.complete(gtkColor); + } else if (scenario == 'pending named becomes valid') { + io.namedRead!.complete(YaruVariant.purple.color); + } else if (scenario == 'pending named becomes unavailable') { + io.namedRead!.complete(null); + } else if (scenario.endsWith('at deadline')) { + await tester.pump(deadline); + } await tester.pump(); + expect(ready, isTrue); await initialized; final header = LinuxHeaderBarService( channel: const MethodChannel('test.busymark/headerbar'), @@ -240,6 +312,13 @@ void main() { await tester.pump(); await tester.pump(); expectThemes(second); + // Lower-priority activity remains owned and cannot overwrite live GTK. + io.change( + LinuxPortalAppearance.gnomeInterface, + const DBusString('green'), + ); + await tester.pump(const Duration(milliseconds: 200)); + expectThemes(second); // Rebuilds must retain one native and one portal listener. container .read(appSettingsControllerProvider.notifier) @@ -251,6 +330,14 @@ void main() { .setThemeModePreference(BusyMarkThemeModePreference.light); await tester.pump(); expectThemes(second); + gtk.events.add(null); + await tester.pump(); + await tester.pump(); + expectThemes(YaruVariant.adwaitaGreen.color); + gtk.events.add(second); + await tester.pump(); + await tester.pump(); + expectThemes(second); expect(gtk.listens, 1); expect(io.signals.hasListener, isTrue); await tester.pumpWidget(const SizedBox()); @@ -265,6 +352,49 @@ void main() { header.dispose(); await gtk.events.close(); await io.signals.close(); + }); + } + testWidgets( + 'application scope teardown cancels unresolved startup deadlines', + (tester) async { + final gtk = FakeAccentSource(() => Completer().future); + final io = FakePortalIO() + ..namedRead = Completer() + ..rgbRead = Completer(); + late LinuxAccentCoordinator coordinator; + await tester.pumpWidget( + ProviderScope( + overrides: [ + linuxAccentPlatformProvider.overrideWithValue(true), + linuxGtkAccentSourceProvider.overrideWithValue(gtk), + linuxPortalAccentSourceProvider.overrideWithValue( + LinuxPortalAppearance(open: () => io), + ), + ], + child: Consumer( + builder: (context, ref, child) { + ref.watch(systemAccentColorProvider); + coordinator = ref.read(linuxAccentCoordinatorProvider); + return const SizedBox(); + }, + ), + ), + ); + expect(gtk.listens, 1); + expect(io.listens, 1); + await tester.pumpWidget(const SizedBox()); + expect(gtk.cancels, 1); + expect(io.cancels, 1); + // Flush the provider owner's root-zone cleanup and virtual-clock events. + final disposed = coordinator.dispose(); + await tester.runAsync(() async {}); + await tester.pump(); + await tester.runAsync(() async {}); + await tester.pump(); + await disposed; + expect(io.closes, 1); + await gtk.events.close(); + await io.signals.close(); }, ); } diff --git a/test/src/linux_gtk_accent_service_test.dart b/test/src/linux_gtk_accent_service_test.dart index cd077aff..f022bb28 100644 --- a/test/src/linux_gtk_accent_service_test.dart +++ b/test/src/linux_gtk_accent_service_test.dart @@ -200,4 +200,96 @@ void main() { expect(values, isEmpty); expect(cancels, 1); }); + testWidgets( + 'silent channels expire locally and unsuperseded late snapshot recovers', + (tester) async { + const deadline = Duration(milliseconds: 100); + const bounded = LinuxGtkAccentService( + channel: method, + events: events, + initializationDeadline: deadline, + ); + final snapshot = Completer(); + final listen = Completer(); + var cancels = 0; + binding.defaultBinaryMessenger.setMockMethodCallHandler( + method, + (_) => snapshot.future, + ); + binding.defaultBinaryMessenger.setMockMethodCallHandler(eventMethod, ( + call, + ) { + if (call.method == 'listen') return listen.future; + cancels++; + return Future.value(null); + }); + final values = []; + final subscription = bounded.watchAccentColor().listen(values.add); + await tester.pump(deadline - const Duration(milliseconds: 1)); + expect(values, isEmpty); + await tester.pump(const Duration(milliseconds: 1)); + expect(values, [null]); + snapshot.complete(bluePayload); + await tester.pump(); + expect(values, [null, blue]); + listen.complete(null); + await subscription.cancel(); + expect(cancels, 1); + }, + ); + + testWidgets('new unavailable event invalidates older read and deadline', ( + tester, + ) async { + final snapshot = Completer(); + binding.defaultBinaryMessenger.setMockMethodCallHandler( + method, + (_) => snapshot.future, + ); + binding.defaultBinaryMessenger.setMockMethodCallHandler( + eventMethod, + (_) async => null, + ); + final values = []; + final subscription = service.watchAccentColor().listen(values.add); + await tester.pump(); + await send(greenPayload); + await send({'available': false}); + snapshot.complete(bluePayload); + await tester.pump(linuxAccentInitializationDeadline); + expect(values, [const Color(0xff009900), null]); + await send(bluePayload); + await tester.pump(); + expect(values.last, blue); + await subscription.cancel(); + }); + + testWidgets( + 'cancellation while both native calls pending rejects late errors', + (tester) async { + final snapshot = Completer(); + final listen = Completer(); + var cancels = 0; + binding.defaultBinaryMessenger.setMockMethodCallHandler( + method, + (_) => snapshot.future, + ); + binding.defaultBinaryMessenger.setMockMethodCallHandler(eventMethod, ( + call, + ) { + if (call.method == 'listen') return listen.future; + cancels++; + return Future.value(null); + }); + final values = []; + final subscription = service.watchAccentColor().listen(values.add); + await tester.pump(); + await subscription.cancel(); + snapshot.completeError(StateError('late snapshot failure')); + listen.completeError(StateError('late listen failure')); + await tester.pump(linuxAccentInitializationDeadline); + expect(values, isEmpty); + expect(cancels, 1); + }, + ); } diff --git a/test/src/system_accent_test.dart b/test/src/system_accent_test.dart index e699e7a3..910538a1 100644 --- a/test/src/system_accent_test.dart +++ b/test/src/system_accent_test.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:busymark/src/app/system_accent.dart'; +import 'package:busymark/src/platform/linux_gtk_accent_service.dart'; import 'package:dbus/dbus.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -292,7 +293,13 @@ void main() { await Future.delayed(Duration.zero); expect(coordinator.color, gtkColor); expect(values.last, gtkColor); - expect(values.length, gtkFirst ? 5 : 6); + expect(values, [ + busyMarkDefaultAccentColor, + gtkColor, + YaruVariant.blue.color, + const Color(0xff006600), + gtkColor, + ]); await subscription.cancel(); await coordinator.dispose(); expect(gtk.listens, 1); @@ -369,4 +376,271 @@ void main() { await gtk.events.close(); }, ); + testWidgets('valid GTK releases startup while portal reads stay pending', ( + tester, + ) async { + final gtk = FakeAccentSource(() async => const Color(0xff336699)); + final io = FakePortalIO() + ..namedRead = Completer() + ..rgbRead = Completer(); + final coordinator = LinuxAccentCoordinator( + sources: [ + gtk, + LinuxPortalAppearance(open: () => io), + ], + ); + var initialized = false; + coordinator.initialize().then((_) => initialized = true); + await tester.pump(); + expect(initialized, isTrue); + expect(coordinator.color, const Color(0xff336699)); + await tester.runAsync(coordinator.dispose); + await gtk.events.close(); + await io.signals.close(); + }); + + testWidgets( + 'generic signal cannot resolve startup before pending named read', + (tester) async { + final gtk = FakeAccentSource(() async => null); + final io = FakePortalIO() + ..namedRead = Completer() + ..rgbRead = Completer(); + final coordinator = LinuxAccentCoordinator( + sources: [ + gtk, + LinuxPortalAppearance(open: () => io), + ], + ); + var initialized = false; + coordinator.initialize().then((_) => initialized = true); + io.change( + LinuxPortalAppearance.freedesktopAppearance, + DBusStruct([ + const DBusDouble(0.2), + const DBusDouble(0.4), + const DBusDouble(0.6), + ]), + ); + await tester.pump(); + expect(initialized, isFalse); + io.namedRead!.complete(YaruVariant.purple.color); + await tester.pump(); + expect(initialized, isTrue); + expect(coordinator.color, YaruVariant.purple.color); + await tester.runAsync(coordinator.dispose); + await gtk.events.close(); + await io.signals.close(); + }, + ); + + testWidgets('valid named portal releases startup without pending RGB', ( + tester, + ) async { + final gtk = FakeAccentSource(() async => null); + final io = FakePortalIO(named: YaruVariant.purple.color) + ..rgbRead = Completer(); + final coordinator = LinuxAccentCoordinator( + sources: [ + gtk, + LinuxPortalAppearance(open: () => io), + ], + ); + var initialized = false; + coordinator.initialize().then((_) => initialized = true); + await tester.pump(); + expect(initialized, isTrue); + expect(coordinator.color, YaruVariant.purple.color); + expect(io.rgbReads, 1); + await tester.runAsync(coordinator.dispose); + await gtk.events.close(); + await io.signals.close(); + }); + + testWidgets( + 'pending GTK blocks ready portal until GTK arrives before deadline', + (tester) async { + final snapshot = Completer(); + final gtk = FakeAccentSource(() => snapshot.future); + final io = FakePortalIO(named: YaruVariant.purple.color); + final coordinator = LinuxAccentCoordinator( + sources: [ + gtk, + LinuxPortalAppearance(open: () => io), + ], + ); + var initialized = false; + coordinator.initialize().then((_) => initialized = true); + await tester.pump( + linuxAccentInitializationDeadline - const Duration(milliseconds: 1), + ); + expect(initialized, isFalse); + expect(coordinator.color, busyMarkDefaultAccentColor); + snapshot.complete(const Color(0xff336699)); + await tester.pump(); + expect(initialized, isTrue); + expect(coordinator.color, const Color(0xff336699)); + await tester.pump(const Duration(seconds: 10)); + expect(coordinator.color, const Color(0xff336699)); + await tester.runAsync(coordinator.dispose); + await gtk.events.close(); + await io.signals.close(); + }, + ); + + for (final namedResolution in ['unavailable', 'failure', 'deadline']) { + testWidgets('pending named releases retained RGB on $namedResolution', ( + tester, + ) async { + final gtk = FakeAccentSource(() async => null); + final io = FakePortalIO() + ..namedRead = Completer() + ..rgbRead = Completer(); + final coordinator = LinuxAccentCoordinator( + sources: [ + gtk, + LinuxPortalAppearance(open: () => io), + ], + ); + var initialized = false; + coordinator.initialize().then((_) => initialized = true); + io.change( + LinuxPortalAppearance.freedesktopAppearance, + portalRgb(0.2, 0.4, 0.6), + ); + io.change( + LinuxPortalAppearance.gnomeInterface, + const DBusString('purple'), + key: 'color-scheme', + ); + io.change('unrelated.namespace', const DBusString('purple')); + io.signals.addError(StateError('unrelated delivery failure')); + await tester.pump(); + expect(initialized, isFalse); + if (namedResolution == 'deadline') { + await tester.pump(linuxAccentInitializationDeadline); + } else { + if (namedResolution == 'failure') { + io.namedRead!.completeError(StateError('missing named setting')); + } else { + io.namedRead!.complete(null); + } + await tester.pump(); + } + expect(initialized, isTrue); + expect(coordinator.color, const Color(0xff336699)); + io.change( + LinuxPortalAppearance.gnomeInterface, + const DBusString('purple'), + ); + await tester.pump(); + expect(coordinator.color, YaruVariant.purple.color); + // Both reads were superseded independently, including explicit unavailability. + io.change( + LinuxPortalAppearance.gnomeInterface, + const DBusString('unknown'), + ); + io.rgbRead!.complete(const Color(0xff000000)); + if (namedResolution == 'deadline') { + io.namedRead!.complete(YaruVariant.orange.color); + } + await tester.pump(const Duration(seconds: 10)); + expect(coordinator.color, const Color(0xff336699)); + await tester.runAsync(coordinator.dispose); + expect(io.closes, 1); + expect(io.cancels, 1); + await gtk.events.close(); + await io.signals.close(); + }); + } + + testWidgets( + 'all silent sources time out to orange and late sources recover', + (tester) async { + final snapshot = Completer(); + final io = FakePortalIO() + ..namedRead = Completer() + ..rgbRead = Completer(); + const deadline = Duration(milliseconds: 100); + final gtk = FakeAccentSource( + () => snapshot.future, + initializationDeadline: deadline, + ); + final coordinator = LinuxAccentCoordinator( + initializationDeadline: deadline, + sources: [ + gtk, + LinuxPortalAppearance( + open: () => io, + initializationDeadline: deadline, + ), + ], + ); + var initialized = false; + coordinator.initialize().then((_) => initialized = true); + await tester.pump(deadline - const Duration(milliseconds: 1)); + expect(initialized, isFalse); + await tester.pump(const Duration(milliseconds: 1)); + expect(initialized, isTrue); + expect(coordinator.color, busyMarkDefaultAccentColor); + io.rgbRead!.complete(const Color(0xff112233)); + await tester.pump(); + expect(coordinator.color, const Color(0xff112233)); + io.namedRead!.complete(YaruVariant.purple.color); + await tester.pump(); + expect(coordinator.color, YaruVariant.purple.color); + snapshot.complete(const Color(0xff336699)); + await tester.pump(); + expect(coordinator.color, const Color(0xff336699)); + await tester.runAsync(coordinator.dispose); + await gtk.events.close(); + await io.signals.close(); + }, + ); + + testWidgets( + 'pending initialization disposal cancels timers and portal session once', + (tester) async { + final snapshot = Completer(); + final gtk = FakeAccentSource(() => snapshot.future); + final io = FakePortalIO() + ..namedRead = Completer() + ..rgbRead = Completer(); + final coordinator = LinuxAccentCoordinator( + sources: [ + gtk, + LinuxPortalAppearance(open: () => io), + ], + ); + final initialization = coordinator.initialize(); + expect(identical(initialization, coordinator.initialize()), isTrue); + var initialized = false; + initialization.then((_) => initialized = true); + await tester.pump(); + expect(initialized, isFalse); + await tester.runAsync(() async { + await coordinator.dispose(); + await coordinator.dispose(); + }); + await tester.pump(); + expect(initialized, isTrue); + expect(io.closes, 1); + expect(io.cancels, 1); + expect(io.listens, 1); + expect(io.namedReads, 1); + expect(io.rgbReads, 1); + expect(gtk.listens, 1); + expect(gtk.cancels, 1); + expect(io.signals.hasListener, isFalse); + expect(gtk.events.hasListener, isFalse); + snapshot.completeError(StateError('late native failure')); + io.namedRead!.completeError(StateError('late portal failure')); + io.rgbRead!.complete(const Color(0xff112233)); + // Do not advance time: testWidgets also rejects any uncancelled deadline. + await tester.pump(); + expect(coordinator.color, busyMarkDefaultAccentColor); + await gtk.events.close(); + await io.signals.close(); + }, + ); } diff --git a/test/support/linux_accent_fakes.dart b/test/support/linux_accent_fakes.dart index b3851ec9..0c0bbece 100644 --- a/test/support/linux_accent_fakes.dart +++ b/test/support/linux_accent_fakes.dart @@ -6,8 +6,12 @@ import 'package:dbus/dbus.dart'; import 'package:flutter/material.dart'; class FakeAccentSource implements LinuxAccentSource { - FakeAccentSource(this.snapshot); + FakeAccentSource( + this.snapshot, { + this.initializationDeadline = linuxAccentInitializationDeadline, + }); final Future Function() snapshot; + final Duration initializationDeadline; int listens = 0; int cancels = 0; late final events = StreamController.broadcast( @@ -16,8 +20,11 @@ class FakeAccentSource implements LinuxAccentSource { onCancel: () => cancels++, ); @override - Stream watchAccentColor() => - watchInitializedAccent(read: snapshot, changes: () => events.stream); + Stream watchAccentColor() => watchInitializedAccent( + read: snapshot, + changes: () => events.stream, + initializationDeadline: initializationDeadline, + ); } class FakePortalIO implements LinuxPortalAccentIO { @@ -28,21 +35,31 @@ class FakePortalIO implements LinuxPortalAccentIO { Completer? rgbRead; bool subscribedBeforeReads = true; int closes = 0; + int namedReads = 0; + int rgbReads = 0; + int listens = 0; + int cancels = 0; late final signals = StreamController< ({String namespace, String key, DBusValue value}) - >.broadcast(sync: true); + >.broadcast( + sync: true, + onListen: () => listens++, + onCancel: () => cancels++, + ); @override Stream<({String namespace, String key, DBusValue value})> get changes => signals.stream; @override Future readNamed() async { + namedReads++; subscribedBeforeReads &= signals.hasListener; return namedRead == null ? named : await namedRead!.future; } @override Future readRgb() async { + rgbReads++; subscribedBeforeReads &= signals.hasListener; return rgbRead == null ? rgb : await rgbRead!.future; } @@ -60,3 +77,6 @@ class FakePortalIO implements LinuxPortalAccentIO { closes++; } } + +DBusStruct portalRgb(double red, double green, double blue) => + DBusStruct([DBusDouble(red), DBusDouble(green), DBusDouble(blue)]); diff --git a/tools/gtk_accent_host_probe.cc b/tools/gtk_accent_host_probe.cc new file mode 100644 index 00000000..619b5670 --- /dev/null +++ b/tools/gtk_accent_host_probe.cc @@ -0,0 +1,345 @@ +// Executes the production host, GTK observer, Flutter channels and codecs. +// Only the engine's binary transport is injected; a real FlView/FlEngine owns +// the widget lifecycle. The view is never realized, so no Dart app is launched. +#include "gtk_accent_host.h" + +#include +#include + +constexpr char kMethods[] = "com.busymark.app/gtk_accent"; +constexpr char kEvents[] = "com.busymark.app/gtk_accent/events"; + +struct Handler { + FlBinaryMessengerMessageHandler callback; + gpointer data; + GDestroyNotify destroy; +}; + +struct TestMessenger { + GObject parent; + GHashTable* handlers; + GPtrArray* events; +}; +struct TestMessengerClass { GObjectClass parent; }; +static void messenger_interface_init(FlBinaryMessengerInterface* iface); +G_DEFINE_TYPE_WITH_CODE(TestMessenger, test_messenger, G_TYPE_OBJECT, + G_IMPLEMENT_INTERFACE(fl_binary_messenger_get_type(), + messenger_interface_init)) + +struct TestResponse { + FlBinaryMessengerResponseHandle parent; + GBytes* bytes; + bool responded; +}; +struct TestResponseClass { FlBinaryMessengerResponseHandleClass parent; }; +G_DEFINE_TYPE(TestResponse, test_response, + fl_binary_messenger_response_handle_get_type()) + +static void test_response_finalize(GObject* object) { + g_clear_pointer(&reinterpret_cast(object)->bytes, g_bytes_unref); + G_OBJECT_CLASS(test_response_parent_class)->finalize(object); +} +static void test_response_class_init(TestResponseClass* klass) { + G_OBJECT_CLASS(klass)->finalize = test_response_finalize; +} +static void test_response_init(TestResponse*) {} + +static void free_handler(gpointer data) { + auto* handler = static_cast(data); + if (handler->destroy != nullptr) handler->destroy(handler->data); + delete handler; +} +static void set_handler(FlBinaryMessenger* messenger, const gchar* channel, + FlBinaryMessengerMessageHandler callback, gpointer data, + GDestroyNotify destroy) { + auto* self = reinterpret_cast(messenger); + g_hash_table_remove(self->handlers, channel); + if (callback != nullptr) { + g_hash_table_insert(self->handlers, g_strdup(channel), + new Handler{callback, data, destroy}); + } else if (destroy != nullptr) { + destroy(data); + } +} +static gboolean send_response(FlBinaryMessenger*, + FlBinaryMessengerResponseHandle* handle, + GBytes* bytes, GError**) { + auto* response = reinterpret_cast(handle); + g_assert_false(response->responded); + response->responded = true; + response->bytes = bytes != nullptr ? g_bytes_ref(bytes) : g_bytes_new(nullptr, 0); + return TRUE; +} +static void send_event(FlBinaryMessenger* messenger, const gchar* channel, + GBytes* message, GCancellable*, GAsyncReadyCallback callback, + gpointer) { + g_assert_cmpstr(channel, ==, kEvents); + g_assert_null(callback); // FlEventChannel sends unacknowledged envelopes. + g_assert_nonnull(message); + g_ptr_array_add(reinterpret_cast(messenger)->events, + g_bytes_ref(message)); +} +static void shutdown_messenger(FlBinaryMessenger* messenger) { + g_hash_table_remove_all(reinterpret_cast(messenger)->handlers); +} +static void messenger_interface_init(FlBinaryMessengerInterface* iface) { + iface->set_message_handler_on_channel = set_handler; + iface->send_response = send_response; + iface->send_on_channel = send_event; + iface->shutdown = shutdown_messenger; +} +static void test_messenger_finalize(GObject* object) { + auto* self = reinterpret_cast(object); + g_hash_table_unref(self->handlers); + g_ptr_array_unref(self->events); + G_OBJECT_CLASS(test_messenger_parent_class)->finalize(object); +} +static void test_messenger_class_init(TestMessengerClass* klass) { + G_OBJECT_CLASS(klass)->finalize = test_messenger_finalize; +} +static void test_messenger_init(TestMessenger* self) { + self->handlers = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, free_handler); + self->events = g_ptr_array_new_with_free_func( + reinterpret_cast(g_bytes_unref)); +} + +static TestMessenger* active_messenger = nullptr; +// --wrap affects the production host's call, not calls inside Flutter's shared +// library. This is the sole injected I/O boundary; channel handlers are real. +extern "C" FlBinaryMessenger* __wrap_fl_engine_get_binary_messenger(FlEngine* engine) { + g_assert_true(FL_IS_ENGINE(engine)); + g_assert_nonnull(active_messenger); + return FL_BINARY_MESSENGER(active_messenger); +} + +static void drain() { + int iterations = 0; + while (g_main_context_pending(nullptr)) { + g_main_context_iteration(nullptr, FALSE); + g_assert_cmpint(++iterations, <, 10000); + } +} +static void theme(const char* root, const char* name, const char* css, + const char* dark = nullptr) { + const std::string directory = std::string(root) + "/themes/" + name + "/gtk-3.0"; + g_assert_cmpint(g_mkdir_with_parents(directory.c_str(), 0700), ==, 0); + g_assert_true(g_file_set_contents((directory + "/gtk.css").c_str(), css, -1, nullptr)); + if (dark != nullptr) { + g_assert_true(g_file_set_contents((directory + "/gtk-dark.css").c_str(), + dark, -1, nullptr)); + } +} +static FlMethodResponse* decode(FlMethodCodec* codec, GBytes* bytes) { + if (g_bytes_get_size(bytes) == 0) { + return FL_METHOD_RESPONSE(fl_method_not_implemented_response_new()); + } + g_autoptr(GError) error = nullptr; + // The codec's public virtual interface avoids Flutter-private test headers. + auto* response = FL_METHOD_CODEC_GET_CLASS(codec)->decode_response(codec, bytes, &error); + g_assert_no_error(error); + g_assert_nonnull(response); + return response; +} +static void payload(FlMethodResponse* response, bool available, + double r = 0, double g = 0, double b = 0) { + g_assert_true(FL_IS_METHOD_SUCCESS_RESPONSE(response)); + FlValue* value = fl_method_success_response_get_result(FL_METHOD_SUCCESS_RESPONSE(response)); + g_assert_cmpint(fl_value_get_type(value), ==, FL_VALUE_TYPE_MAP); + auto* flag = fl_value_lookup_string(value, "available"); + g_assert_nonnull(flag); + g_assert_cmpint(fl_value_get_type(flag), ==, FL_VALUE_TYPE_BOOL); + g_assert_cmpint(fl_value_get_bool(flag), ==, available); + auto* rgb = fl_value_lookup_string(value, "rgb"); + if (!available) { + g_assert_null(rgb); + g_assert_cmpuint(fl_value_get_length(value), ==, 1); + return; + } + g_assert_cmpuint(fl_value_get_length(value), ==, 2); + g_assert_nonnull(rgb); + g_assert_cmpint(fl_value_get_type(rgb), ==, FL_VALUE_TYPE_LIST); + g_assert_cmpuint(fl_value_get_length(rgb), ==, 3); + const double expected[] = {r, g, b}; + for (size_t i = 0; i < 3; ++i) { + auto* channel = fl_value_get_list_value(rgb, i); + g_assert_cmpint(fl_value_get_type(channel), ==, FL_VALUE_TYPE_FLOAT); + g_assert_cmpfloat(std::abs(fl_value_get_float(channel) - expected[i]), <, 0.000001); + } +} + +struct Harness { + TestMessenger* messenger; + FlStandardMethodCodec* codec; + GtkWidget* window; + FlView* view; + BusyMarkGtkAccentHost* host; + guint disposed_channels = 0; + + Harness() { + messenger = static_cast(g_object_new(test_messenger_get_type(), nullptr)); + active_messenger = messenger; + codec = fl_standard_method_codec_new(); + window = gtk_window_new(GTK_WINDOW_TOPLEVEL); + g_object_ref_sink(window); + gtk_widget_realize(window); + g_autoptr(FlDartProject) project = fl_dart_project_new(); + view = fl_view_new(project); + g_object_ref_sink(view); + host = busymark_gtk_accent_host_new(view, window); + g_assert_cmpuint(g_hash_table_size(messenger->handlers), ==, 2); + for (const auto* name : {kMethods, kEvents}) { + auto* handler = static_cast(g_hash_table_lookup(messenger->handlers, name)); + g_object_weak_ref(G_OBJECT(handler->data), [](gpointer data, GObject*) { + ++*static_cast(data); + }, &disposed_channels); + } + } + ~Harness() { + stop(); + // Flutter channels are messenger-owned until channel/engine shutdown. Their + // host callbacks must already be detached, and shutdown releases both refs. + shutdown_messenger(FL_BINARY_MESSENGER(messenger)); + g_assert_cmpuint(g_hash_table_size(messenger->handlers), ==, 0); + g_assert_cmpuint(disposed_channels, ==, 2); + gtk_widget_destroy(GTK_WIDGET(view)); + gtk_widget_destroy(window); + g_object_unref(view); + g_object_unref(window); + g_object_unref(codec); + g_object_unref(messenger); + active_messenger = nullptr; + drain(); + } + void stop() { + busymark_gtk_accent_host_free(host); + host = nullptr; + } + FlMethodResponse* call(const char* channel, const char* method, + bool require_response = true) { + auto* handler = static_cast(g_hash_table_lookup(messenger->handlers, channel)); + g_assert_nonnull(handler); + g_autoptr(GError) error = nullptr; + auto* method_codec = FL_METHOD_CODEC(codec); + g_autoptr(GBytes) bytes = FL_METHOD_CODEC_GET_CLASS(method_codec)->encode_method_call( + method_codec, method, nullptr, &error); + g_assert_no_error(error); + auto* response = static_cast(g_object_new(test_response_get_type(), nullptr)); + handler->callback(FL_BINARY_MESSENGER(messenger), channel, bytes, + FL_BINARY_MESSENGER_RESPONSE_HANDLE(response), handler->data); + if (!require_response) { + g_assert_false(response->responded); + g_object_unref(response); + return nullptr; + } + g_assert_true(response->responded); + auto* result = decode(method_codec, response->bytes); + g_object_unref(response); + return result; + } + void event(bool available, double r = 0, double g = 0, double b = 0) { + g_assert_cmpuint(messenger->events->len, >, 0); + auto* bytes = static_cast(g_ptr_array_index( + messenger->events, messenger->events->len - 1)); + g_autoptr(FlMethodResponse) response = decode(FL_METHOD_CODEC(codec), bytes); + payload(response, available, r, g, b); + } + void listen() { + g_autoptr(FlMethodResponse) response = call(kEvents, "listen"); + g_assert_true(FL_IS_METHOD_SUCCESS_RESPONSE(response)); + } + void cancel() { + g_autoptr(FlMethodResponse) response = call(kEvents, "cancel"); + g_assert_true(FL_IS_METHOD_SUCCESS_RESPONSE(response)); + } + void detached() { + // Exercise the real channel after host destruction: no dangling host data. + g_autoptr(FlMethodResponse) method = call(kMethods, "getAccent", false); + g_assert_null(method); // Flutter drops messages when the method handler is cleared. + g_autoptr(FlMethodResponse) listen = call(kEvents, "listen"); + g_assert_true(FL_IS_METHOD_SUCCESS_RESPONSE(listen)); // No handler, no replay. + } +}; + +int main(int argc, char** argv) { + g_assert_cmpint(argc, ==, 2); + theme(argv[1], "BusyMarkHostOld", "@define-color theme_selected_bg_color #336699;", + "@define-color theme_selected_bg_color #7764d8;"); + theme(argv[1], "BusyMarkHostModern", "@define-color accent_bg_color #006600;" + "@define-color theme_selected_bg_color #336699;"); + theme(argv[1], "BusyMarkHostMissing", "window { color: #abcdef; }"); + g_setenv("XDG_DATA_HOME", argv[1], TRUE); + g_setenv("GTK_THEME", "BusyMarkHostOld", TRUE); + g_unsetenv("NO_AT_BRIDGE"); + g_setenv("GTK_MODULES", "gail:atk-bridge", TRUE); + gtk_init(&argc, &argv); + g_unsetenv("GTK_THEME"); + auto* settings = gtk_settings_get_default(); + g_object_set(settings, "gtk-theme-name", "BusyMarkHostOld", + "gtk-application-prefer-dark-theme", FALSE, nullptr); + { + Harness test; + g_autoptr(FlMethodResponse) snapshot = test.call(kMethods, "getAccent"); + payload(snapshot, true, 0.2, 0.4, 0.6); + g_autoptr(FlMethodResponse) unknown = test.call(kMethods, "unknown"); + g_assert_true(FL_IS_METHOD_NOT_IMPLEMENTED_RESPONSE(unknown)); + g_print("PASS production getAccent method and exact standard-codec payload\n"); + + g_object_set(settings, "gtk-theme-name", "BusyMarkHostModern", nullptr); + drain(); + test.listen(); + test.event(true, 0, 0.4, 0); + g_assert_cmpuint(test.messenger->events->len, ==, 1); + g_object_set(settings, "gtk-theme-name", "BusyMarkHostOld", nullptr); + drain(); + test.event(true, 0.2, 0.4, 0.6); + g_object_set(settings, "gtk-application-prefer-dark-theme", TRUE, nullptr); + drain(); + test.event(true, 119.0 / 255, 100.0 / 255, 216.0 / 255); + g_assert_cmpuint(test.messenger->events->len, ==, 3); + g_object_set(settings, "gtk-application-prefer-dark-theme", TRUE, nullptr); + drain(); + g_assert_cmpuint(test.messenger->events->len, ==, 3); + g_object_set(settings, "gtk-theme-name", "BusyMarkHostMissing", nullptr); + drain(); + test.event(false); + g_autoptr(FlMethodResponse) unavailable = test.call(kMethods, "getAccent"); + payload(unavailable, false); + g_assert_cmpuint(test.messenger->events->len, ==, 4); + g_print("PASS actual event channel replay, live GTK/dark changes and unavailability\n"); + + g_object_set(settings, "gtk-theme-name", "BusyMarkHostModern", nullptr); + test.cancel(); // A refresh is queued but must not reach the channel. + drain(); + g_assert_cmpuint(test.messenger->events->len, ==, 4); + test.listen(); + test.event(true, 0, 0.4, 0); + g_assert_cmpuint(test.messenger->events->len, ==, 5); + g_print("PASS channel cancellation and current-state reattachment\n"); + + g_object_set(settings, "gtk-theme-name", "BusyMarkHostOld", nullptr); + test.stop(); + drain(); + test.detached(); + g_assert_cmpuint(test.messenger->events->len, ==, 5); + g_print("PASS host free cancels queued refresh and detaches channel callbacks\n"); + } + for (bool window_first : {true, false}) { + g_object_set(settings, "gtk-theme-name", "BusyMarkHostOld", nullptr); + Harness test; + test.listen(); + g_object_set(settings, "gtk-theme-name", "BusyMarkHostModern", nullptr); + gtk_widget_destroy(window_first ? test.window : GTK_WIDGET(test.view)); + drain(); + test.detached(); + g_assert_cmpuint(test.messenger->events->len, ==, 1); + gtk_widget_destroy(window_first ? GTK_WIDGET(test.view) : test.window); + test.stop(); // Cleanup after both widget destroy signals is safe. + drain(); + g_assert_cmpuint(test.messenger->events->len, ==, 1); + g_print("PASS %s destruction, subsequent cleanup and no events after shutdown\n", + window_first ? "window-first" : "view-first"); + } + busymark_gtk_accent_host_free(nullptr); + g_print("PASS Flutter messenger shutdown releases both channel objects\n"); + return 0; +} diff --git a/tools/test_gtk_accent_host.sh b/tools/test_gtk_accent_host.sh new file mode 100755 index 00000000..5096041e --- /dev/null +++ b/tools/test_gtk_accent_host.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -euo pipefail +project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +engine_root="$project_root/linux/flutter/ephemeral" +# Build the application first with the pinned Flutter SDK. A missing engine is +# a failure, never a successful skip; CI runs this target after the release build. +test -f "$engine_root/libflutter_linux_gtk.so" +fixture_root="$(mktemp -d /tmp/busymark-gtk-accent-host.XXXXXX)" +trap 'rm -rf "$fixture_root"' EXIT +"${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror \ + "$project_root/tools/gtk_accent_host_probe.cc" \ + "$project_root/linux/runner/gtk_accent.cc" \ + "$project_root/linux/runner/gtk_accent_host.cc" \ + -I"$project_root/linux/runner" -I"$engine_root" \ + $(pkg-config --cflags --libs gtk+-3.0) \ + -L"$engine_root" -lflutter_linux_gtk -Wl,-rpath,"$engine_root" \ + -Wl,--wrap=fl_engine_get_binary_messenger \ + -o "$fixture_root/probe" +G_DEBUG=fatal-warnings "$fixture_root/probe" "$fixture_root/data" From 80a84bd100324a25df568f986bde50e6401ccd02 Mon Sep 17 00:00:00 2001 From: albert Date: Sun, 4 Oct 2026 04:42:38 -0700 Subject: [PATCH 08/14] Implement native GTK dialogs for Writerside topic operations * Introduce interactive native dialog support in `NativeWritersideDialogService` (`showCreateTopic`, `showRenameTopic`, `showTocText`, `showExistingTopicPicker`) using session-based method channel callbacks. * Implement GTK interactive form components in `writerside_dialog_host.cc` to handle user input, surface validation errors, and manage modal lifetime. * Extract shared file name and title validation logic into `WritersideTopicCreationForm` for consistency across native GTK and Flutter fallback interfaces. * Coordinate application focus and Linux header bar state during native modal interactions via `showBusyMarkNativeDialog`. * Upgrade Flutter fallback dialogs (TOC operations, local history, settings, spelling, find usages) to use modern components like `BusyMarkDialogShell`, `BusyMarkDialogButton`, and `BusyMarkGroupedTextEntry`. * Update smoke, source audit, and interaction tests to validate GTK dialog method channel integration, fallback UI components, and shared validation behavior. --- lib/src/app/busymark_dialogs.dart | 10 + .../local_history/local_history_panel.dart | 33 +- .../native_writerside_dialog_service.dart | 303 +++ lib/src/visualization/visualization_card.dart | 44 +- .../presentation/settings_screen.dart | 84 +- .../presentation/workspace_screen.dart | 798 ++++--- .../presentation/writerside_toc_dialogs.dart | 165 +- .../writerside_topic_creation_form.dart | 64 + linux/runner/writerside_dialog_host.cc | 490 +++- test/src/app_smoke_test.dart | 2037 ++++++++++------- test/src/busymark_dialogs_test.dart | 66 + test/src/local_history_controller_test.dart | 4 +- ...native_writerside_dialog_service_test.dart | 468 ++++ .../native_writerside_dialog_source_test.dart | 41 + test/src/source_audit_test.dart | 14 +- test/src/spelling_service_test.dart | 28 +- test/src/visualization_card_test.dart | 80 +- test/src/writerside_toc_menu_test.dart | 172 ++ test/src/writerside_toc_regressions_test.dart | 5 +- .../native_writerside_dialog_host.dart | 57 + 20 files changed, 3600 insertions(+), 1363 deletions(-) create mode 100644 lib/src/workspace/presentation/writerside_topic_creation_form.dart create mode 100644 test/support/native_writerside_dialog_host.dart diff --git a/lib/src/app/busymark_dialogs.dart b/lib/src/app/busymark_dialogs.dart index 5a22af42..6222de46 100644 --- a/lib/src/app/busymark_dialogs.dart +++ b/lib/src/app/busymark_dialogs.dart @@ -218,6 +218,16 @@ Future _coordinateBusyMarkModal( } } +/// Coordinates focus and the header bar while the Linux toolkit owns a modal. +Future showBusyMarkNativeDialog( + BuildContext context, { + required Future Function() showDialog, +}) => _coordinateBusyMarkModal( + context, + headerBarService: _busyMarkHeaderBarServiceFrom(context), + showSurface: showDialog, +); + Future _showBusyMarkFlutterDialog( BuildContext context, { required WidgetBuilder builder, diff --git a/lib/src/local_history/local_history_panel.dart b/lib/src/local_history/local_history_panel.dart index 86f53f2f..5692322a 100644 --- a/lib/src/local_history/local_history_panel.dart +++ b/lib/src/local_history/local_history_panel.dart @@ -350,26 +350,27 @@ class _LocalHistoryPanelState extends ConsumerState { final confirmed = await showBusyMarkModalDialog( context, barrierDismissible: false, - builder: (dialogContext) => AlertDialog( - title: Text( - action == _HistoryAction.clearDocument - ? dialogContext.l10n.localHistoryClearDocumentTitle - : dialogContext.l10n.localHistoryClearAllTitle, - ), - content: Text( - action == _HistoryAction.clearAll - ? '${dialogContext.l10n.localHistoryClearAll}.\n\n' - '${dialogContext.l10n.localHistoryClearConfirmation}' - : dialogContext.l10n.localHistoryClearConfirmation, - ), + builder: (dialogContext) => BusyMarkDialogShell( + title: action == _HistoryAction.clearDocument + ? dialogContext.l10n.localHistoryClearDocumentTitle + : dialogContext.l10n.localHistoryClearAllTitle, actions: [ - TextButton( + BusyMarkDialogButton( onPressed: () => Navigator.pop(dialogContext, false), - child: Text(dialogContext.l10n.cancel), + label: dialogContext.l10n.cancel, ), - FilledButton( + BusyMarkDialogButton( + destructive: true, onPressed: () => Navigator.pop(dialogContext, true), - child: Text(dialogContext.l10n.delete), + label: dialogContext.l10n.delete, + ), + ], + children: [ + Text( + action == _HistoryAction.clearAll + ? '${dialogContext.l10n.localHistoryClearAll}.\n\n' + '${dialogContext.l10n.localHistoryClearConfirmation}' + : dialogContext.l10n.localHistoryClearConfirmation, ), ], ), diff --git a/lib/src/platform/native_writerside_dialog_service.dart b/lib/src/platform/native_writerside_dialog_service.dart index 3d7c5d07..7efebee6 100644 --- a/lib/src/platform/native_writerside_dialog_service.dart +++ b/lib/src/platform/native_writerside_dialog_service.dart @@ -1,6 +1,31 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; +@immutable +final class NativeWritersideCreateValidation { + const NativeWritersideCreateValidation({ + required this.fileName, + this.titleError, + this.fileNameError, + }); + final String fileName; + final String? titleError; + final String? fileNameError; + + Map toMap() => { + 'fileName': fileName, + 'titleError': titleError, + 'fileNameError': fileNameError, + }; +} + +@immutable +final class NativeWritersideRenameValues { + const NativeWritersideRenameValues(this.fileName, {required this.preview}); + final String fileName; + final bool preview; +} + @visibleForTesting const nativeWritersideDialogChannelName = 'busymark/native_writerside_dialogs'; @@ -44,6 +69,284 @@ class NativeWritersideDialogService { final MethodChannel _channel; + // New interactive forms share this channel with the existing operations. + // A session owns only its own callbacks; destroying one cannot replace a + // newer dialog's handler or leave a submission callback installed. + static int _nextSession = 0; + static final _sessions = + Function(MethodCall)>>{}; + + Future> _showInteractive( + String method, + Map arguments, + Future Function(MethodCall) onEvent, + ) async { + final session = ++_nextSession; + var receivedEvent = false; + final sessions = _sessions.putIfAbsent(_channel.name, () => {}); + sessions[session] = (call) { + receivedEvent = true; + return onEvent(call); + }; + if (sessions.length == 1) { + _channel.setMethodCallHandler((call) async { + if (call.method != 'writersideDialogEvent' || call.arguments is! Map) { + throw PlatformException(code: 'invalid-event'); + } + final handler = sessions[(call.arguments as Map)['session']]; + if (handler == null) throw PlatformException(code: 'expired-dialog'); + return handler(call); + }); + } + try { + final value = await _channel.invokeMethod(method, { + ...arguments, + 'session': session, + }); + return NativeWritersideDialogResult.available(value); + } on MissingPluginException { + if (receivedEvent) { + throw PlatformException(code: 'dialog-host-disconnected'); + } + return const NativeWritersideDialogResult.unavailable(); + } on PlatformException catch (error) { + if (error.code == 'unavailable' && !receivedEvent) { + return const NativeWritersideDialogResult.unavailable(); + } + rethrow; + } finally { + sessions.remove(session); + if (sessions.isEmpty) { + _sessions.remove(_channel.name); + _channel.setMethodCallHandler(null); + } + } + } + + /// Keeps the GTK form alive until cancellation or a successful [submit]. + /// + /// The host asks Dart to [validate] each edit and to submit a valid form. + /// A null submission error closes the form; a localized error leaves its + /// values intact for retry. The pending guard also protects against repeated + /// native submission events. Session callbacks are removed on destruction. + Future> showCreateTopic({ + required String title, + required String titleLabel, + required String fileNameLabel, + required String initialTitle, + required String initialFileName, + required String cancelLabel, + required String okLabel, + required NativeWritersideCreateValidation Function( + String title, + String fileName, + bool fileNameEdited, + ) + validate, + required Future Function(String title, String fileName) submit, + required TextDirection textDirection, + }) async { + var submitting = false; + var completed = false; + var active = true; + try { + final result = await _showInteractive( + 'showCreateTopic', + { + 'title': title, + 'titleLabel': titleLabel, + 'fileNameLabel': fileNameLabel, + 'initialTitle': initialTitle, + 'initialFileName': initialFileName, + 'cancelLabel': cancelLabel, + 'okLabel': okLabel, + 'textDirection': textDirection.name, + }, + (call) async { + final args = call.arguments; + if (!active || completed) { + throw PlatformException(code: 'expired-dialog'); + } + if (args case { + 'event': final String event, + 'title': final String title, + 'fileName': final String fileName, + 'fileNameEdited': final bool edited, + }) { + if (event != 'changed' && event != 'submit') { + throw PlatformException(code: 'invalid-event'); + } + if (submitting) return {'pending': true}; + final validation = validate(title, fileName, edited); + if (event == 'changed' || + validation.titleError != null || + validation.fileNameError != null) { + return {...validation.toMap(), 'created': false}; + } + submitting = true; + try { + final error = await submit(title, validation.fileName); + completed = error == null; + return { + ...validation.toMap(), + 'created': completed, + 'error': error, + }; + } finally { + submitting = false; + } + } + throw PlatformException(code: 'invalid-event'); + }, + ); + if (!result.available) { + return const NativeWritersideDialogResult.unavailable(); + } + if (result.value == null) { + return const NativeWritersideDialogResult.available(null); + } + if (result.value != true || !completed) { + throw PlatformException(code: 'invalid-result'); + } + return const NativeWritersideDialogResult.available(true); + } finally { + active = false; + } + } + + Future> + showRenameTopic({ + required String title, + required String fileNameLabel, + required String initialValue, + required String cancelLabel, + required String previewLabel, + required String refactorLabel, + required String? Function(String) validate, + required TextDirection textDirection, + }) async { + final result = await _showInteractive( + 'showRenameTopic', + { + 'title': title, + 'fileNameLabel': fileNameLabel, + 'initialValue': initialValue, + 'cancelLabel': cancelLabel, + 'previewLabel': previewLabel, + 'okLabel': refactorLabel, + 'textDirection': textDirection.name, + }, + (call) async { + if (call.arguments case { + 'event': 'changed', + 'value': final String value, + }) { + return {'error': validate(value)}; + } + throw PlatformException(code: 'invalid-event'); + }, + ); + if (!result.available) { + return const NativeWritersideDialogResult.unavailable(); + } + if (result.value == null) { + return const NativeWritersideDialogResult.available(null); + } + if (result.value case { + 'value': final String value, + 'preview': final bool preview, + }) { + if (validate(value) == null) { + return NativeWritersideDialogResult.available( + NativeWritersideRenameValues(value.trim(), preview: preview), + ); + } + } + throw PlatformException(code: 'invalid-result'); + } + + Future> showTocText({ + required String title, + required String label, + required String cancelLabel, + required String okLabel, + required String requiredError, + bool enterOnly = false, + required TextDirection textDirection, + }) async { + final result = await _showInteractive( + 'showTocText', + { + 'title': title, + 'fileNameLabel': label, + 'initialValue': '', + 'cancelLabel': cancelLabel, + 'okLabel': okLabel, + 'enterOnly': enterOnly, + 'textDirection': textDirection.name, + }, + (call) async { + if (call.arguments case { + 'event': 'changed', + 'value': final String value, + }) { + return {'error': value.trim().isEmpty ? requiredError : null}; + } + throw PlatformException(code: 'invalid-event'); + }, + ); + if (!result.available) { + return const NativeWritersideDialogResult.unavailable(); + } + if (result.value == null) { + return const NativeWritersideDialogResult.available(null); + } + if (result.value case final String value when value.trim().isNotEmpty) { + return NativeWritersideDialogResult.available(value); + } + throw PlatformException(code: 'invalid-result'); + } + + Future> showExistingTopicPicker({ + required String title, + required String searchLabel, + required List fileNames, + required TextDirection textDirection, + }) async { + final result = await _showInteractive( + 'showExistingTopicPicker', + { + 'title': title, + 'searchLabel': searchLabel, + 'fileNames': fileNames, + 'textDirection': textDirection.name, + }, + (call) async { + if (call.arguments case { + 'event': 'filter', + 'value': final String query, + }) { + return [ + for (var i = 0; i < fileNames.length; i++) + if (fileNames[i].toLowerCase().contains(query.toLowerCase())) i, + ]; + } + throw PlatformException(code: 'invalid-event'); + }, + ); + if (!result.available) { + return const NativeWritersideDialogResult.unavailable(); + } + if (result.value == null) { + return const NativeWritersideDialogResult.available(null); + } + if (result.value case final int index + when index >= 0 && index < fileNames.length) { + return NativeWritersideDialogResult.available(index); + } + throw PlatformException(code: 'invalid-result'); + } + Future> showDuplicateTopic({ required String title, required String fileNameLabel, diff --git a/lib/src/visualization/visualization_card.dart b/lib/src/visualization/visualization_card.dart index 217a7382..2d7d10b2 100644 --- a/lib/src/visualization/visualization_card.dart +++ b/lib/src/visualization/visualization_card.dart @@ -11,6 +11,7 @@ import 'package:flutter_svg/flutter_svg.dart'; import 'package:path/path.dart' as p; import '../app/busymark_design.dart'; +import '../app/busymark_dialogs.dart'; import '../app/busymark_glyphs.dart'; import '../app/busymark_toast.dart'; import '../app/localization.dart'; @@ -643,9 +644,10 @@ class _BusyMarkVisualizationCardState BuildContext context, VisualizationRenderResult result, ) { - return showDialog( - context: context, - builder: (_) => Dialog.fullscreen( + return showBusyMarkModalDialog( + context, + builder: (_) => BusyMarkSurfaceScope( + role: BusyMarkSurfaceRole.dialog, child: _FullScreenDiagram( title: widget.descriptor.kind.displayName, result: result, @@ -676,23 +678,27 @@ class _FullScreenDiagramState extends State<_FullScreenDiagram> { @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: Text(widget.title), - leading: IconButton( - icon: const Icon(BusyMarkGlyphs.windowClose), - onPressed: () => Navigator.pop(context), - ), - ), - body: Padding( - padding: const EdgeInsets.all(BusyMarkSpacing.lg), - child: Center( - child: _DiagramViewport( - result: widget.result, - transformationController: _transformationController, - maximumHeight: double.infinity, + return Material( + color: busyMarkDialogSurfaceColor(context), + child: Column( + children: [ + BusyMarkDialogTitleBar( + title: Text(widget.title), + closeSemanticLabel: context.l10n.close, ), - ), + Expanded( + child: Padding( + padding: const EdgeInsets.all(BusyMarkSpacing.lg), + child: Center( + child: _DiagramViewport( + result: widget.result, + transformationController: _transformationController, + maximumHeight: double.infinity, + ), + ), + ), + ), + ], ), ); } diff --git a/lib/src/workspace/presentation/settings_screen.dart b/lib/src/workspace/presentation/settings_screen.dart index a933b353..10a078e9 100644 --- a/lib/src/workspace/presentation/settings_screen.dart +++ b/lib/src/workspace/presentation/settings_screen.dart @@ -1241,41 +1241,67 @@ Future _editHistoryExcludedPaths( List current, Future Function(Iterable) save, ) async { - final controller = TextEditingController(text: current.join('\n')); final result = await showBusyMarkModalDialog( context, barrierDismissible: false, - builder: (dialogContext) => AlertDialog( - title: Text(dialogContext.l10n.settingsHistoryExcludedPaths), - content: SizedBox( - width: BusyMarkSizes.settingsWidth, - child: TextField( - controller: controller, - autofocus: true, - minLines: 6, - maxLines: 12, - decoration: InputDecoration( - hintText: dialogContext.l10n.settingsHistoryExcludedPathsHint, - border: const OutlineInputBorder(), + builder: (_) => _HistoryExcludedPathsDialog(current: current), + ); + if (result != null) await save(result.split(RegExp(r'\r?\n'))); +} + +class _HistoryExcludedPathsDialog extends StatefulWidget { + const _HistoryExcludedPathsDialog({required this.current}); + final List current; + @override + State<_HistoryExcludedPathsDialog> createState() => + _HistoryExcludedPathsDialogState(); +} + +class _HistoryExcludedPathsDialogState + extends State<_HistoryExcludedPathsDialog> { + late final _controller = TextEditingController( + text: widget.current.join('\n'), + ); + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => BusyMarkDialogShell( + title: context.l10n.settingsHistoryExcludedPaths, + maxWidth: BusyMarkSizes.settingsWidth, + actions: [ + BusyMarkDialogButton( + label: context.l10n.cancel, + onPressed: () => Navigator.pop(context), + ), + BusyMarkDialogButton( + label: context.l10n.save, + suggested: true, + onPressed: () => Navigator.pop(context, _controller.text), + ), + ], + children: [ + BusyMarkGroupedList( + filled: true, + children: [ + BusyMarkGroupedTextEntry( + label: context.l10n.settingsHistoryExcludedPaths, + hintText: context.l10n.settingsHistoryExcludedPathsHint, + controller: _controller, + autofocus: true, + minLines: 6, + maxLines: 12, + alignLabelWithHint: true, + keyboardType: TextInputType.multiline, + textInputAction: TextInputAction.newline, ), - ), + ], ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(dialogContext), - child: Text(dialogContext.l10n.cancel), - ), - FilledButton( - onPressed: () => Navigator.pop(dialogContext, controller.text), - child: Text(dialogContext.l10n.save), - ), - ], - ), + ], ); - controller.dispose(); - if (result != null) { - await save(result.split(RegExp(r'\r?\n'))); - } } enum SettingsPage { diff --git a/lib/src/workspace/presentation/workspace_screen.dart b/lib/src/workspace/presentation/workspace_screen.dart index bcc4f6e8..e3fcc9ff 100644 --- a/lib/src/workspace/presentation/workspace_screen.dart +++ b/lib/src/workspace/presentation/workspace_screen.dart @@ -112,6 +112,7 @@ import '../workspace_tabs.dart'; import 'welcome_screen.dart'; import 'writerside_instance_dialog.dart'; import 'writerside_markdown_import_dialog.dart'; +import 'writerside_topic_creation_form.dart'; import 'writerside_toc_dialogs.dart'; import 'writerside_template_dialogs.dart'; import '../../writerside/writerside_template_service.dart'; @@ -2394,14 +2395,10 @@ class _SidebarState extends ConsumerState<_Sidebar> { ) .firstOrNull; if (owner == null) return false; - final decision = - await showBusyMarkModalDialog( - context, - barrierDismissible: false, - builder: (_) => WritersideTopicRenameDialog( - currentFileName: p.basename(topicPath), - ), - ); + final decision = await showWritersideTopicRenameDialog( + this.context, + currentFileName: p.basename(topicPath), + ); if (decision == null || !mounted) return false; // Use the sidebar State's context rather than the row/menu context that // launched the workflow; transient menu entries may already be unmounted. @@ -3810,7 +3807,7 @@ class _WritersideTopicRemovalDialogState if (removeFromInstance) Row( children: [ - Checkbox( + BusyMarkCheckbox( value: _redirectTarget != null, onChanged: _analysis.redirectTargets.isEmpty ? null @@ -3841,16 +3838,14 @@ class _WritersideTopicRemovalDialogState ], ), if (!removeFromInstance) - CheckboxListTile( - contentPadding: EdgeInsets.zero, - controlAffinity: ListTileControlAffinity.leading, - title: Text(context.l10n.tocSafeDelete), - value: true, - onChanged: null, + BusyMarkActionRow( + title: context.l10n.tocSafeDelete, + enabled: false, + leading: const BusyMarkCheckbox(value: true, onChanged: null), ), Row( children: [ - Checkbox( + BusyMarkCheckbox( value: _updateUsagesAutomatically, onChanged: _analysis.canUpdateUsagesAutomatically ? (value) => setState( @@ -6853,13 +6848,11 @@ class _TocTabState extends ConsumerState<_TocTab> { ), ); } - final title = await showBusyMarkModalDialog( + final title = await showWritersideTocTextDialog( context, - builder: (_) => WritersideTocTextDialog( - title: context.l10n.tocNewGroup, - label: context.l10n.tocGroupName, - enterOnly: true, - ), + title: context.l10n.tocNewGroup, + label: context.l10n.tocGroupName, + enterOnly: true, ); if (title == null || !mounted || !context.mounted) return; if (!await confirmSafeToChangeWorkspaceFiles(context, ref, [ @@ -7280,23 +7273,75 @@ class _TocTabState extends ConsumerState<_TocTab> { if (referenceEntry != null && rawReference == null) { return; } + final referenceIdentity = rawReference == null + ? null + : WritersideTocNodeIdentity.fromNode(rawReference); + final form = WritersideTopicCreationForm( + format: format, + existingIds: widget.workspace.writersideModule?.reservedTopicIds ?? {}, + l10n: context.l10n, + ); + final initialTitle = context.l10n.defaultNewTopicTitle; + final controller = ref.read(workspaceControllerProvider.notifier); + Future submit(String title, String fileName) async { + if (!mounted) return form.l10n.createWritersideTopicFailed; + final created = await controller.createWritersideTopic( + WritersideTopicCreateRequest( + title: title.trim(), + fileName: form.effectiveFileName(fileName), + format: format, + placement: placement, + referenceTocPath: placement == WritersideTopicCreatePlacement.root + ? null + : referenceEntry?.editPath, + referenceTopic: placement == WritersideTopicCreatePlacement.root + ? null + : referenceEntry?.node.topicFileName, + referenceTocIdentity: placement == WritersideTopicCreatePlacement.root + ? null + : referenceIdentity, + ), + instanceTreePath: instanceTreePath, + ); + if (created) return null; + if (!mounted) return form.l10n.createWritersideTopicFailed; + final message = ref.read(workspaceControllerProvider).message; + return message == null + ? form.l10n.createWritersideTopicFailed + : localizeWorkspaceMessage(this.context, message); + } + + final native = await showBusyMarkNativeDialog( + context, + showDialog: () => const NativeWritersideDialogService().showCreateTopic( + title: context.l10n.newTopic, + titleLabel: context.l10n.tocTopicTitleField, + fileNameLabel: context.l10n.tocDuplicateFilename, + initialTitle: initialTitle, + initialFileName: form.fileNameForTitle(initialTitle), + cancelLabel: context.l10n.cancel, + okLabel: context.l10n.tocOk, + textDirection: Directionality.of(context), + validate: (title, fileName, edited) { + final effective = edited ? fileName : form.fileNameForTitle(title); + return NativeWritersideCreateValidation( + fileName: effective, + titleError: form.titleError(title), + fileNameError: form.fileNameError(effective), + ); + }, + submit: submit, + ), + ); + if (!mounted || !context.mounted || native?.available != false) return; final headerBar = ref.read(linuxHeaderBarServiceProvider); await showBusyMarkModalDialog( context, headerBarService: headerBar.isAvailable ? headerBar : null, - builder: (dialogContext) => _CreateWritersideTopicDialog( + builder: (_) => _CreateWritersideTopicDialog( workspace: widget.workspace, - instanceTreePath: instanceTreePath, - placement: placement, initialFormat: format, - referencePath: referenceEntry?.editPath, - referenceTopic: referenceEntry?.node.topicFileName, - referenceIdentity: rawReference == null - ? null - : WritersideTocNodeIdentity.fromNode(rawReference), - referenceLabel: referenceEntry == null - ? null - : _tocNodeDisplayLabel(dialogContext, referenceEntry.node), + submit: submit, ), ); } @@ -7652,12 +7697,10 @@ class _TocTabState extends ConsumerState<_TocTab> { String? title; WritersideTopic? topic; if (choice == _TocCreationChoice.emptyGroup) { - title = await showBusyMarkModalDialog( + title = await showWritersideTocTextDialog( context, - builder: (_) => WritersideTocTextDialog( - title: context.l10n.tocNewEmptyGroup, - label: context.l10n.tocTitleField, - ), + title: context.l10n.tocNewEmptyGroup, + label: context.l10n.tocTitleField, ); if (title == null) return; } else { @@ -7674,14 +7717,12 @@ class _TocTabState extends ConsumerState<_TocTab> { .map((node) => presenter.present(node).topic?.filePath) .nonNulls .toSet(); - topic = await showBusyMarkModalDialog( + topic = await showWritersideExistingTopicPicker( context, - builder: (_) => WritersideExistingTopicPicker( - topics: [ - for (final topic in module.topics) - if (!members.contains(topic.filePath)) topic, - ], - ), + topics: [ + for (final topic in module.topics) + if (!members.contains(topic.filePath)) topic, + ], ); if (topic == null) return; } @@ -8703,37 +8744,26 @@ WritersideInstanceIconColor _effectiveInstanceIconColor( return automaticPalette[index % automaticPalette.length]; } -class _CreateWritersideTopicDialog extends ConsumerStatefulWidget { +class _CreateWritersideTopicDialog extends StatefulWidget { const _CreateWritersideTopicDialog({ required this.workspace, - required this.instanceTreePath, - required this.placement, - required this.referencePath, - required this.referenceTopic, - required this.referenceIdentity, - required this.referenceLabel, + required this.submit, this.initialFormat = WritersideTopicFormat.markdown, }); final Workspace workspace; - final String instanceTreePath; - final WritersideTopicCreatePlacement placement; - final List? referencePath; - final String? referenceTopic; - final WritersideTocNodeIdentity? referenceIdentity; - final String? referenceLabel; + final Future Function(String title, String fileName) submit; final WritersideTopicFormat initialFormat; @override - ConsumerState<_CreateWritersideTopicDialog> createState() => + State<_CreateWritersideTopicDialog> createState() => _CreateWritersideTopicDialogState(); } class _CreateWritersideTopicDialogState - extends ConsumerState<_CreateWritersideTopicDialog> { + extends State<_CreateWritersideTopicDialog> { late final TextEditingController _titleController; late final TextEditingController _fileNameController; - late WritersideTopicCreatePlacement _placement; var _format = WritersideTopicFormat.markdown; var _fileNameEdited = false; var _syncingFileName = false; @@ -8744,7 +8774,6 @@ class _CreateWritersideTopicDialogState @override void initState() { super.initState(); - _placement = widget.placement; _format = widget.initialFormat; _titleController = TextEditingController() ..addListener(_handleTitleChanged); @@ -8779,6 +8808,7 @@ class _CreateWritersideTopicDialogState canPop: !_creating, child: BusyMarkDialogShell( title: context.l10n.newTopic, + closable: !_creating, maxWidth: BusyMarkSizes.dialog, actions: [ BusyMarkDialogButton( @@ -8791,27 +8821,29 @@ class _CreateWritersideTopicDialogState ), ], children: [ - TextField( - controller: _titleController, - autofocus: true, - textInputAction: TextInputAction.next, - decoration: InputDecoration( - labelText: context.l10n.tocTopicTitleField, - errorText: titleError, - ), - ), - const SizedBox(height: BusyMarkSpacing.md), - TextField( - controller: _fileNameController, - textDirection: TextDirection.ltr, - textInputAction: TextInputAction.done, - decoration: InputDecoration( - labelText: context.l10n.tocDuplicateFilename, - errorText: fileNameError, - ), - onSubmitted: (_) { - if (canCreate) _submit(); - }, + BusyMarkGroupedList( + filled: true, + children: [ + BusyMarkGroupedTextEntry( + controller: _titleController, + autofocus: true, + textInputAction: TextInputAction.next, + label: context.l10n.tocTopicTitleField, + errorText: titleError, + enabled: !_creating, + ), + BusyMarkGroupedTextEntry( + controller: _fileNameController, + textDirection: TextDirection.ltr, + textInputAction: TextInputAction.done, + label: context.l10n.tocDuplicateFilename, + errorText: fileNameError, + enabled: !_creating, + onSubmitted: (_) { + if (canCreate) _submit(); + }, + ), + ], ), if (_creationError != null) ...[ const SizedBox(height: BusyMarkSpacing.md), @@ -8825,62 +8857,26 @@ class _CreateWritersideTopicDialogState ); } - String? _titleError(BuildContext context) { - if (_titleController.text.trim().isEmpty) { - return context.l10n.topicTitleRequired; - } - return null; - } + WritersideTopicCreationForm get _form => WritersideTopicCreationForm( + format: _format, + existingIds: widget.workspace.writersideModule?.reservedTopicIds ?? {}, + l10n: context.l10n, + ); - String? _fileNameError(BuildContext context) { - final value = _fileNameController.text.trim(); - if (value.isEmpty) { - return context.l10n.fileNameRequired; - } - final expectedExtension = _extensionFor(_format); - final extension = p.extension(value).toLowerCase(); - if (extension.isNotEmpty && extension != expectedExtension) { - return context.l10n.useExpectedExtension(expectedExtension); - } - final effective = extension.isEmpty ? '$value$expectedExtension' : value; - try { - validateWritersideTopicFileName( - effective, - requiredExtension: expectedExtension, - ); - } on Object { - if (value == '.' || - value == '..' || - p.isAbsolute(value) || - value.contains('/') || - value.contains(r'\') || - value.contains('\u0000')) { - return context.l10n.useSingleSafeFileName; - } - return context.l10n.useIdentifierCharacters; - } - final id = p.basenameWithoutExtension(effective); - final existingIds = widget.workspace.writersideModule?.reservedTopicIds; - if (existingIds?.contains(id) ?? false) { - return context.l10n.topicIdAlreadyExists; - } - return null; - } + String? _titleError(BuildContext context) => + _form.titleError(_titleController.text); - String get _effectiveFileName { - final value = _fileNameController.text.trim(); - if (p.extension(value).isEmpty) { - return '$value${_extensionFor(_format)}'; - } - return value; - } + String? _fileNameError(BuildContext context) => + _form.fileNameError(_fileNameController.text); + + String get _effectiveFileName => + _form.effectiveFileName(_fileNameController.text); void _handleTitleChanged() { _creationError = null; if (!_fileNameEdited) { _syncingFileName = true; - _fileNameController.text = - '${_slugTopicName(_titleController.text)}${_extensionFor(_format)}'; + _fileNameController.text = _form.fileNameForTitle(_titleController.text); _syncingFileName = false; } setState(() {}); @@ -8904,48 +8900,21 @@ class _CreateWritersideTopicDialogState _creating = true; _creationError = null; }); - final created = await ref - .read(workspaceControllerProvider.notifier) - .createWritersideTopic( - WritersideTopicCreateRequest( - title: _titleController.text.trim(), - fileName: _effectiveFileName, - format: _format, - placement: _placement, - referenceTocPath: _placement == WritersideTopicCreatePlacement.root - ? null - : widget.referencePath, - referenceTopic: _placement == WritersideTopicCreatePlacement.root - ? null - : widget.referenceTopic, - referenceTocIdentity: - _placement == WritersideTopicCreatePlacement.root - ? null - : widget.referenceIdentity, - ), - instanceTreePath: widget.instanceTreePath, - ); - if (!mounted) { - return; - } - if (created) { + final error = await widget.submit( + _titleController.text, + _effectiveFileName, + ); + if (!mounted) return; + if (error == null) { Navigator.pop(context); return; } setState(() { _creating = false; - final message = ref.read(workspaceControllerProvider).message; - _creationError = message == null - ? context.l10n.createWritersideTopicFailed - : localizeWorkspaceMessage(context, message); + _creationError = error; }); } - String _slugTopicName(String value) { - final slug = slugForHeading(value); - return slug.isEmpty ? 'new-topic' : slug; - } - String _extensionFor(WritersideTopicFormat format) { return switch (format) { WritersideTopicFormat.markdown => '.md', @@ -13493,14 +13462,15 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { if (symbol == null) { await showBusyMarkModalDialog( context, - builder: (context) => AlertDialog( - content: Text(context.l10n.noResults), + builder: (context) => BusyMarkDialogShell( + title: context.l10n.findUsages, actions: [ - TextButton( + BusyMarkDialogButton( onPressed: () => Navigator.pop(context), - child: Text(context.l10n.close), + label: context.l10n.close, ), ], + children: [Text(context.l10n.noResults)], ), ); return; @@ -13519,35 +13489,35 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { final usages = index.findUsages(symbol).toList(); final selected = await showBusyMarkModalDialog( context, - builder: (context) => AlertDialog( - title: Text('${context.l10n.findUsages}: ${symbol.name}'), - content: SizedBox( - width: 600, - height: 360, - child: usages.isEmpty - ? Text(context.l10n.noResults) - : ListView( - children: [ - for (final usage in usages) - ListTile( - title: Text( - p.relative( + builder: (context) => BusyMarkDialogShell( + title: '${context.l10n.findUsages}: ${symbol.name}', + maxWidth: 650, + actions: [ + BusyMarkDialogButton( + onPressed: () => Navigator.pop(context), + label: context.l10n.close, + ), + ], + children: [ + SizedBox( + width: 600, + height: 360, + child: usages.isEmpty + ? Text(context.l10n.noResults) + : ListView( + children: [ + for (final usage in usages) + BusyMarkActionRow( + title: p.relative( usage.filePath, from: widget.state.workspace?.rootPath, ), + subtitle: + '${usage.span.startLine}:${usage.span.startColumn} ${usage.value}', + onTap: () => Navigator.pop(context, usage), ), - subtitle: Text( - '${usage.span.startLine}:${usage.span.startColumn} ${usage.value}', - ), - onTap: () => Navigator.pop(context, usage), - ), - ], - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: Text(context.l10n.close), + ], + ), ), ], ), @@ -13557,14 +13527,15 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { if (symbol.kind == WritersideSymbolKind.topic) { await showBusyMarkModalDialog( context, - builder: (context) => AlertDialog( - content: Text(context.l10n.renameTopicFileInstead), + builder: (context) => BusyMarkDialogShell( + title: context.l10n.rename, actions: [ - TextButton( + BusyMarkDialogButton( onPressed: () => Navigator.pop(context), - child: Text(context.l10n.close), + label: context.l10n.close, ), ], + children: [Text(context.l10n.renameTopicFileInstead)], ), ); return; @@ -13572,22 +13543,24 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { var pendingName = symbol.name; final newName = await showBusyMarkModalEditorDialog( context, - builder: (context) => AlertDialog( - title: Text('${context.l10n.rename}: ${symbol.name}'), - content: TextFormField( - initialValue: pendingName, - onChanged: (value) => pendingName = value, - autofocus: true, - onFieldSubmitted: (value) => Navigator.pop(context, value), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: Text(context.l10n.cancel), - ), - TextButton( - onPressed: () => Navigator.pop(context, pendingName), - child: Text(context.l10n.rename), + builder: (context) => BusyMarkModalEditorScaffold( + title: '${context.l10n.rename}: ${symbol.name}', + cancelLabel: context.l10n.cancel, + saveLabel: context.l10n.rename, + onCancel: () => Navigator.pop(context), + onSave: () => Navigator.pop(context, pendingName), + children: [ + BusyMarkGroupedList( + filled: true, + children: [ + BusyMarkGroupedTextEntry( + label: symbol.name, + initialValue: pendingName, + onChanged: (value) => pendingName = value, + autofocus: true, + onSubmitted: (value) => Navigator.pop(context, value), + ), + ], ), ], ), @@ -13599,14 +13572,15 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { if (!applied && mounted) { await showBusyMarkModalDialog( context, - builder: (context) => AlertDialog( - content: Text(context.l10n.cannotRenameSymbol), + builder: (context) => BusyMarkDialogShell( + title: context.l10n.rename, actions: [ - TextButton( + BusyMarkDialogButton( onPressed: () => Navigator.pop(context), - child: Text(context.l10n.close), + label: context.l10n.close, ), ], + children: [Text(context.l10n.cannotRenameSymbol)], ), ); } @@ -16886,157 +16860,13 @@ class _SpellingReviewDialogState extends State { } return KeyEventResult.ignored; }, - child: AlertDialog( - title: Text(context.l10n.checkSpelling), - content: SizedBox( - width: 480, - child: occurrence == null - ? Padding( - padding: const EdgeInsets.all(BusyMarkSpacing.lg), - child: state.status == SpellingPresentationStatus.checking - ? Center( - child: Semantics( - label: context.l10n.spellingChecking, - child: const CircularProgressIndicator(), - ), - ) - : Text(_spellingEmptyStateMessage(context, state)), - ) - : Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Semantics( - liveRegion: true, - child: Text( - occurrence.word, - style: Theme.of(context).textTheme.headlineSmall, - ), - ), - const SizedBox(height: BusyMarkSpacing.xs), - Text( - _spellingContext(occurrence), - maxLines: 3, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: BusyMarkSpacing.md), - FutureBuilder>( - future: _suggestionsFor(occurrence), - builder: (context, snapshot) { - if (snapshot.connectionState != ConnectionState.done) { - return const Center( - child: CircularProgressIndicator(), - ); - } - if (snapshot.hasError) { - return Text(context.l10n.spellingWordCheckFailed); - } - final suggestions = snapshot.data ?? const []; - if (suggestions.isEmpty) { - return Text(context.l10n.noSpellingSuggestions); - } - return Wrap( - spacing: BusyMarkSpacing.sm, - runSpacing: BusyMarkSpacing.sm, - children: [ - for (final suggestion in suggestions.take(8)) - BusyMarkPushButton.standard( - onPressed: _actionInProgress || _closing - ? null - : () async { - setState( - () => _actionInProgress = true, - ); - final correction = await widget - .onCorrect(occurrence, suggestion); - if (!mounted || _closing) return; - _actionInProgress = false; - if (correction != null) { - _recordVisited(occurrence); - _translateReviewState(correction); - _suggestionOccurrenceId = null; - _suggestions = null; - final occurrences = _occurrences; - if (occurrences.isEmpty) { - final state = widget.spelling.state; - if (state.status == - SpellingPresentationStatus - .ready && - state.complete) { - _requestClose(); - return; - } - } else if (!_selectNextUnvisited( - occurrences, - )) { - _requestClose(); - return; - } - } - setState(() {}); - }, - child: Text(suggestion), - ), - ], - ); - }, - ), - const SizedBox(height: BusyMarkSpacing.md), - Wrap( - spacing: BusyMarkSpacing.sm, - runSpacing: BusyMarkSpacing.sm, - children: [ - TextButton( - onPressed: _actionInProgress || _closing - ? null - : () => _ignore( - occurrence, - widget.spelling.ignoreOnce, - ), - child: Text(context.l10n.ignoreSpellingOnce), - ), - TextButton( - onPressed: _actionInProgress || _closing - ? null - : () => _ignore( - occurrence, - widget.spelling.ignoreAllInDocument, - ), - child: Text(context.l10n.ignoreSpellingDocument), - ), - TextButton( - onPressed: _actionInProgress || _closing - ? null - : () => unawaited( - _persist( - widget.spelling.addPersonalWord, - occurrence, - ), - ), - child: Text(context.l10n.addPersonalSpellingWord), - ), - TextButton( - onPressed: - widget.spelling.hasProjectScope && - !_actionInProgress && - !_closing - ? () => unawaited( - _persist( - widget.spelling.addProjectWord, - occurrence, - ), - ) - : null, - child: Text(context.l10n.addProjectSpellingWord), - ), - ], - ), - ], - ), - ), + child: BusyMarkDialogShell( + title: context.l10n.checkSpelling, + closable: false, + maxWidth: 560, actions: [ if (occurrence != null) ...[ - TextButton( + BusyMarkDialogButton( onPressed: _actionInProgress || _closing ? null : () async { @@ -17050,21 +16880,173 @@ class _SpellingReviewDialogState extends State { _handleSpellingChanged(); } }, - child: Text(context.l10n.chooseSpellingLanguage), + label: context.l10n.chooseSpellingLanguage, ), - TextButton( + BusyMarkDialogButton( onPressed: _actionInProgress || _closing ? null : () => _move(-1), - child: Text(context.l10n.sourceSearchPreviousMatch), + label: context.l10n.sourceSearchPreviousMatch, ), - TextButton( + BusyMarkDialogButton( onPressed: _actionInProgress || _closing ? null : () => _move(1), - child: Text(context.l10n.sourceSearchNextMatch), + label: context.l10n.sourceSearchNextMatch, ), ], - TextButton( - autofocus: true, + BusyMarkDialogButton( onPressed: _closing ? null : _requestClose, - child: Text(context.l10n.close), + label: context.l10n.close, + ), + ], + children: [ + SizedBox( + width: 480, + child: occurrence == null + ? Padding( + padding: const EdgeInsets.all(BusyMarkSpacing.lg), + child: state.status == SpellingPresentationStatus.checking + ? Center( + child: Semantics( + label: context.l10n.spellingChecking, + child: const YaruCircularProgressIndicator(), + ), + ) + : Text(_spellingEmptyStateMessage(context, state)), + ) + : Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Semantics( + liveRegion: true, + child: Text( + occurrence.word, + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + const SizedBox(height: BusyMarkSpacing.xs), + Text( + _spellingContext(occurrence), + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: BusyMarkSpacing.md), + FutureBuilder>( + future: _suggestionsFor(occurrence), + builder: (context, snapshot) { + if (snapshot.connectionState != + ConnectionState.done) { + return const Center( + child: YaruCircularProgressIndicator(), + ); + } + if (snapshot.hasError) { + return Text(context.l10n.spellingWordCheckFailed); + } + final suggestions = snapshot.data ?? const []; + if (suggestions.isEmpty) { + return Text(context.l10n.noSpellingSuggestions); + } + return Wrap( + spacing: BusyMarkSpacing.sm, + runSpacing: BusyMarkSpacing.sm, + children: [ + for (final suggestion in suggestions.take(8)) + BusyMarkPushButton.standard( + onPressed: _actionInProgress || _closing + ? null + : () async { + setState( + () => _actionInProgress = true, + ); + final correction = await widget + .onCorrect( + occurrence, + suggestion, + ); + if (!mounted || _closing) return; + _actionInProgress = false; + if (correction != null) { + _recordVisited(occurrence); + _translateReviewState(correction); + _suggestionOccurrenceId = null; + _suggestions = null; + final occurrences = _occurrences; + if (occurrences.isEmpty) { + final state = + widget.spelling.state; + if (state.status == + SpellingPresentationStatus + .ready && + state.complete) { + _requestClose(); + return; + } + } else if (!_selectNextUnvisited( + occurrences, + )) { + _requestClose(); + return; + } + } + setState(() {}); + }, + child: Text(suggestion), + ), + ], + ); + }, + ), + const SizedBox(height: BusyMarkSpacing.md), + Wrap( + spacing: BusyMarkSpacing.sm, + runSpacing: BusyMarkSpacing.sm, + children: [ + BusyMarkDialogButton( + onPressed: _actionInProgress || _closing + ? null + : () => _ignore( + occurrence, + widget.spelling.ignoreOnce, + ), + label: context.l10n.ignoreSpellingOnce, + ), + BusyMarkDialogButton( + onPressed: _actionInProgress || _closing + ? null + : () => _ignore( + occurrence, + widget.spelling.ignoreAllInDocument, + ), + label: context.l10n.ignoreSpellingDocument, + ), + BusyMarkDialogButton( + onPressed: _actionInProgress || _closing + ? null + : () => unawaited( + _persist( + widget.spelling.addPersonalWord, + occurrence, + ), + ), + label: context.l10n.addPersonalSpellingWord, + ), + BusyMarkDialogButton( + onPressed: + widget.spelling.hasProjectScope && + !_actionInProgress && + !_closing + ? () => unawaited( + _persist( + widget.spelling.addProjectWord, + occurrence, + ), + ) + : null, + label: context.l10n.addProjectSpellingWord, + ), + ], + ), + ], + ), ), ], ), @@ -17266,6 +17248,25 @@ class _WorkspaceReplacementReviewDialogState ) { final ids = file.matches.map((match) => match.id).toSet(); final selectedCount = ids.intersection(_selected).length; + final bool? allSelected = selectedCount == 0 + ? false + : selectedCount == ids.length + ? true + : null; + void selectFile(bool? selected) => setState(() { + if (selected == true) { + _selected.addAll(ids); + } else { + _selected.removeAll(ids); + } + }); + void selectMatch(String id, bool? selected) => setState(() { + if (selected == true) { + _selected.add(id); + } else { + _selected.remove(id); + } + }); return BusyMarkGroupedList( title: busyMarkLtrIsolateFor(context, file.relativePath), description: file.sourceKind == WorkspaceReplacementSourceKind.dirtyBuffer @@ -17273,41 +17274,28 @@ class _WorkspaceReplacementReviewDialogState : context.l10n.workspaceReplaceDiskContent, filled: true, children: [ - CheckboxListTile( - value: selectedCount == 0 - ? false - : selectedCount == ids.length - ? true - : null, - tristate: true, - title: Text(context.l10n.selectFileMatches(file.matches.length)), - onChanged: (selected) { - setState(() { - if (selected == true) { - _selected.addAll(ids); - } else { - _selected.removeAll(ids); - } - }); - }, + BusyMarkActionRow( + title: context.l10n.selectFileMatches(file.matches.length), + leading: BusyMarkCheckbox( + value: allSelected, + tristate: true, + onChanged: selectFile, + ), + onTap: () => selectFile(allSelected == false ? true : false), ), for (final match in file.matches) - CheckboxListTile( - value: _selected.contains(match.id), - title: Text( + BusyMarkActionRow( + title: '${match.original} → ${match.replacement}', + titleWidget: Text( '${match.original} → ${match.replacement}', maxLines: 2, overflow: TextOverflow.ellipsis, ), - onChanged: (selected) { - setState(() { - if (selected == true) { - _selected.add(match.id); - } else { - _selected.remove(match.id); - } - }); - }, + leading: BusyMarkCheckbox( + value: _selected.contains(match.id), + onChanged: (value) => selectMatch(match.id, value), + ), + onTap: () => selectMatch(match.id, !_selected.contains(match.id)), ), ], ); diff --git a/lib/src/workspace/presentation/writerside_toc_dialogs.dart b/lib/src/workspace/presentation/writerside_toc_dialogs.dart index e7f7eff2..432b5f50 100644 --- a/lib/src/workspace/presentation/writerside_toc_dialogs.dart +++ b/lib/src/workspace/presentation/writerside_toc_dialogs.dart @@ -4,6 +4,9 @@ import 'package:path/path.dart' as p; import 'package:url_launcher/url_launcher.dart'; import '../../app/busymark_design.dart'; +import '../../app/busymark_dialogs.dart'; +import '../../app/busymark_search_field.dart'; +import '../../platform/native_writerside_dialog_service.dart'; import '../../app/busymark_glyphs.dart'; import '../../app/localization.dart'; import '../../writerside/writerside_model.dart'; @@ -11,6 +14,110 @@ import '../../writerside/writerside_topic_file_name.dart'; import '../../writerside/writerside_title_editor.dart'; import '../workspace_service.dart'; +Future showWritersideTopicRenameDialog( + BuildContext context, { + required String currentFileName, +}) async { + String? validate(String value) { + try { + validateWritersideTopicFileName( + value, + requiredExtension: p.extension(currentFileName).toLowerCase(), + ); + return null; + } on Object { + return context.l10n.errorTopicFileNameInvalid; + } + } + + final native = await showBusyMarkNativeDialog( + context, + showDialog: () => const NativeWritersideDialogService().showRenameTopic( + title: context.l10n.rename, + fileNameLabel: context.l10n.fileName, + initialValue: currentFileName, + cancelLabel: context.l10n.cancel, + previewLabel: context.l10n.preview, + refactorLabel: context.l10n.tocRefactorMenu, + textDirection: Directionality.of(context), + validate: validate, + ), + ); + if (native?.available != false) { + final value = native?.value; + return value == null + ? null + : WritersideTopicRenameDialogResult( + fileName: value.fileName, + action: value.preview + ? WritersideTopicRenameDialogAction.preview + : WritersideTopicRenameDialogAction.refactor, + ); + } + if (!context.mounted) return null; + return showBusyMarkModalDialog( + context, + barrierDismissible: false, + builder: (_) => + WritersideTopicRenameDialog(currentFileName: currentFileName), + ); +} + +Future showWritersideTocTextDialog( + BuildContext context, { + required String title, + required String label, + bool enterOnly = false, +}) async { + final native = await showBusyMarkNativeDialog( + context, + showDialog: () => const NativeWritersideDialogService().showTocText( + title: title, + label: label, + enterOnly: enterOnly, + cancelLabel: context.l10n.cancel, + okLabel: context.l10n.tocOk, + requiredError: context.l10n.topicTitleRequired, + textDirection: Directionality.of(context), + ), + ); + if (native?.available != false) return native?.value; + if (!context.mounted) return null; + return showBusyMarkModalDialog( + context, + builder: (_) => WritersideTocTextDialog( + title: title, + label: label, + enterOnly: enterOnly, + ), + ); +} + +Future showWritersideExistingTopicPicker( + BuildContext context, { + required List topics, +}) async { + final native = await showBusyMarkNativeDialog( + context, + showDialog: () => + const NativeWritersideDialogService().showExistingTopicPicker( + title: context.l10n.tocSelectExistingTopic, + searchLabel: context.l10n.search, + fileNames: [for (final topic in topics) topic.fileName], + textDirection: Directionality.of(context), + ), + ); + if (native?.available != false) { + final index = native?.value; + return index == null ? null : topics[index]; + } + if (!context.mounted) return null; + return showBusyMarkModalDialog( + context, + builder: (_) => WritersideExistingTopicPicker(topics: topics), + ); +} + enum WritersideTopicRenameDialogAction { preview, refactor } class WritersideTopicRenameDialogResult { @@ -94,15 +201,20 @@ class _WritersideTopicRenameDialogState children: [ Text(widget.currentFileName), const SizedBox(height: BusyMarkSpacing.sm), - TextField( - controller: _controller, - autofocus: true, - decoration: InputDecoration( - labelText: context.l10n.fileName, - errorText: _error, - ), - onChanged: (_) => setState(() {}), - onSubmitted: (_) => _submit(WritersideTopicRenameDialogAction.refactor), + BusyMarkGroupedList( + filled: true, + children: [ + BusyMarkGroupedTextEntry( + controller: _controller, + autofocus: true, + label: context.l10n.fileName, + errorText: _error, + textDirection: TextDirection.ltr, + onChanged: (_) => setState(() {}), + onSubmitted: (_) => + _submit(WritersideTopicRenameDialogAction.refactor), + ), + ], ), ], ); @@ -321,12 +433,18 @@ class _WritersideTocTextDialogState extends State { ), ], children: [ - TextField( - controller: _controller, - autofocus: true, - decoration: InputDecoration(labelText: widget.label, errorText: _error), - onChanged: (_) => setState(() {}), - onSubmitted: (_) => _submit(), + BusyMarkGroupedList( + filled: true, + children: [ + BusyMarkGroupedTextEntry( + controller: _controller, + autofocus: true, + label: widget.label, + errorText: _error, + onChanged: (_) => setState(() {}), + onSubmitted: (_) => _submit(), + ), + ], ), ], ); @@ -390,9 +508,10 @@ class _WritersideExistingTopicPickerState } return KeyEventResult.handled; }, - child: TextField( + child: BusyMarkSearchField( autofocus: true, - decoration: InputDecoration(hintText: context.l10n.search), + hintText: context.l10n.search, + onEscape: () => Navigator.pop(context), onChanged: (value) => setState(() { _query = value; _selected = 0; @@ -409,10 +528,14 @@ class _WritersideExistingTopicPickerState itemExtent: 48, children: [ for (var index = 0; index < topics.length; index++) - ListTile( - selected: index == _selected, - title: Text(topics[index].fileName), - onTap: () => Navigator.pop(context, topics[index]), + ColoredBox( + color: index == _selected + ? Theme.of(context).colorScheme.secondaryContainer + : BusyMarkLinuxPalette.transparent, + child: BusyMarkActionRow( + title: topics[index].fileName, + onTap: () => Navigator.pop(context, topics[index]), + ), ), ], ), diff --git a/lib/src/workspace/presentation/writerside_topic_creation_form.dart b/lib/src/workspace/presentation/writerside_topic_creation_form.dart new file mode 100644 index 00000000..d1782ad2 --- /dev/null +++ b/lib/src/workspace/presentation/writerside_topic_creation_form.dart @@ -0,0 +1,64 @@ +import 'package:path/path.dart' as p; + +import '../../../l10n/generated/app_localizations.dart'; +import '../../core/path_utils.dart'; +import '../../writerside/writerside_model.dart'; +import '../../writerside/writerside_topic_file_name.dart'; + +/// The filename and validation rules shared by the GTK form and its fallback. +final class WritersideTopicCreationForm { + const WritersideTopicCreationForm({ + required this.format, + required this.existingIds, + required this.l10n, + }); + + final WritersideTopicFormat format; + final Set existingIds; + final AppLocalizations l10n; + + String get extension => switch (format) { + WritersideTopicFormat.markdown => '.md', + WritersideTopicFormat.xml => '.topic', + }; + + String fileNameForTitle(String title) { + final slug = slugForHeading(title); + return '${slug.isEmpty ? 'new-topic' : slug}$extension'; + } + + String? titleError(String title) => + title.trim().isEmpty ? l10n.topicTitleRequired : null; + + String effectiveFileName(String fileName) { + final value = fileName.trim(); + return p.extension(value).isEmpty ? '$value$extension' : value; + } + + String? fileNameError(String fileName) { + final value = fileName.trim(); + if (value.isEmpty) return l10n.fileNameRequired; + final suppliedExtension = p.extension(value).toLowerCase(); + if (suppliedExtension.isNotEmpty && suppliedExtension != extension) { + return l10n.useExpectedExtension(extension); + } + final effective = effectiveFileName(value); + try { + validateWritersideTopicFileName(effective, requiredExtension: extension); + } on Object { + if (value == '.' || + value == '..' || + p.isAbsolute(value) || + value.contains('/') || + value.contains(r'\') || + value.contains('\u0000')) { + return l10n.useSingleSafeFileName; + } + return l10n.useIdentifierCharacters; + } + if (existingIds.contains(p.basenameWithoutExtension(effective))) { + return l10n.topicIdAlreadyExists; + } + return null; + } +} diff --git a/linux/runner/writerside_dialog_host.cc b/linux/runner/writerside_dialog_host.cc index 6c949723..724e2a17 100644 --- a/linux/runner/writerside_dialog_host.cc +++ b/linux/runner/writerside_dialog_host.cc @@ -1,6 +1,9 @@ #include "writerside_dialog_host.h" #include +#include +#include +#include namespace { @@ -376,7 +379,487 @@ void show_edit_title(DialogHandlerData* data, fl_method_call_respond_success(call, result, nullptr); } -void method_call_cb(FlMethodChannel*, +// Interactive forms leave the show call pending until cancellation or success. +// GTK owns the widgets; Dart owns validation, slugging and workspace mutation. +struct InteractiveDialog { + FlMethodChannel* channel; + FlMethodCall* call; + GWeakRef view; + GtkWidget* dialog = nullptr; + GtkWidget* entry = nullptr; + GtkWidget* title_entry = nullptr; + GtkWidget* error_label = nullptr; + GtkWidget* title_error = nullptr; + GtkWidget* ok_button = nullptr; + GtkWidget* preview_button = nullptr; + GtkWidget* cancel_button = nullptr; + GtkWidget* list = nullptr; + GtkWidget* scroll = nullptr; + std::vector file_names; + gint64 session; + guint revision = 0; + bool create = false; + bool rename = false; + bool picker = false; + bool applying = false; + bool file_name_edited = false; + bool pending = false; + bool valid = false; + bool finished = false; + + InteractiveDialog(FlMethodChannel* channel, FlMethodCall* call, + GtkWidget* view, gint64 session) + : channel(FL_METHOD_CHANNEL(g_object_ref(channel))), + call(FL_METHOD_CALL(g_object_ref(call))), + session(session) { + g_weak_ref_init(&this->view, G_OBJECT(view)); + } + ~InteractiveDialog() { + g_weak_ref_clear(&view); + g_object_unref(call); + g_object_unref(channel); + } +}; +using InteractivePtr = std::shared_ptr; + +InteractivePtr interactive_ref(InteractiveDialog* state) { + return *static_cast( + g_object_get_data(G_OBJECT(state->dialog), "writerside-state")); +} + +void finish_interactive(const InteractivePtr& state, FlValue* value, + const char* error = nullptr) { + if (state->finished) return; + state->finished = true; + if (error != nullptr) { + fl_method_call_respond_error(state->call, error, + "Native dialog interaction failed.", nullptr, + nullptr); + } else { + fl_method_call_respond_success(state->call, value, nullptr); + } + GtkWidget* dialog = state->dialog; + state->dialog = nullptr; + if (dialog != nullptr) gtk_widget_destroy(dialog); + g_autoptr(GObject) view = G_OBJECT(g_weak_ref_get(&state->view)); + if (view != nullptr && gtk_widget_get_realized(GTK_WIDGET(view))) { + gtk_widget_grab_focus(GTK_WIDGET(view)); + } +} + +void set_interactive_sensitive(InteractiveDialog* state) { + gtk_widget_set_sensitive(state->entry, !state->pending); + if (state->title_entry != nullptr) { + gtk_widget_set_sensitive(state->title_entry, !state->pending); + } + if (state->ok_button != nullptr) { + gtk_widget_set_sensitive(state->ok_button, state->valid && !state->pending); + } + if (state->preview_button != nullptr) { + gtk_widget_set_sensitive(state->preview_button, + state->valid && !state->pending); + } + if (state->cancel_button != nullptr) { + gtk_widget_set_sensitive(state->cancel_button, !state->pending); + } + gtk_window_set_deletable(GTK_WINDOW(state->dialog), !state->pending); +} + +void set_error_label(GtkWidget* label, const gchar* error) { + gtk_label_set_text(GTK_LABEL(label), error == nullptr ? "" : error); + gtk_widget_set_visible(label, error != nullptr && error[0] != '\0'); +} + +struct InteractiveReply { + InteractivePtr state; + guint revision; + bool submit; +}; + +void interactive_reply_cb(GObject* object, GAsyncResult* result, + gpointer user_data) { + std::unique_ptr reply( + static_cast(user_data)); + const auto state = reply->state; + g_autoptr(GError) error = nullptr; + g_autoptr(FlMethodResponse) response = fl_method_channel_invoke_method_finish( + FL_METHOD_CHANNEL(object), result, &error); + if (state->finished) return; + if (reply->revision != state->revision) return; + FlValue* value = response == nullptr + ? nullptr + : fl_method_response_get_result(response, &error); + if (value == nullptr || error != nullptr) { + finish_interactive(state, nullptr, "dialog-callback-failed"); + return; + } + if (state->picker) { + if (fl_value_get_type(value) != FL_VALUE_TYPE_LIST) { + finish_interactive(state, nullptr, "invalid-result"); + return; + } + GList* children = gtk_container_get_children(GTK_CONTAINER(state->list)); + for (GList* item = children; item != nullptr; item = item->next) { + gtk_widget_destroy(GTK_WIDGET(item->data)); + } + g_list_free(children); + for (size_t i = 0; i < fl_value_get_length(value); ++i) { + FlValue* index = fl_value_get_list_value(value, i); + if (fl_value_get_type(index) != FL_VALUE_TYPE_INT || + fl_value_get_int(index) < 0 || + static_cast(fl_value_get_int(index)) >= + state->file_names.size()) { + finish_interactive(state, nullptr, "invalid-result"); + return; + } + const gint original = static_cast(fl_value_get_int(index)); + GtkWidget* row = gtk_list_box_row_new(); + GtkWidget* label = + left_aligned_label(state->file_names[original].c_str()); + gtk_widget_set_margin_start(label, 8); + gtk_widget_set_margin_end(label, 8); + gtk_widget_set_size_request(row, -1, 48); + gtk_container_add(GTK_CONTAINER(row), label); + g_object_set_data(G_OBJECT(row), "topic-index", + GINT_TO_POINTER(original)); + gtk_container_add(GTK_CONTAINER(state->list), row); + } + gtk_widget_show_all(state->list); + gtk_list_box_select_row( + GTK_LIST_BOX(state->list), + gtk_list_box_get_row_at_index(GTK_LIST_BOX(state->list), 0)); + gtk_adjustment_set_value( + gtk_scrolled_window_get_vadjustment(GTK_SCROLLED_WINDOW(state->scroll)), + 0); + state->valid = true; + return; + } + if (fl_value_get_type(value) != FL_VALUE_TYPE_MAP) { + finish_interactive(state, nullptr, "invalid-result"); + return; + } + if (state->create) { + const gchar* file_name = lookup_string(value, "fileName"); + FlValue* created = fl_value_lookup_string(value, "created"); + if (file_name == nullptr || created == nullptr || + fl_value_get_type(created) != FL_VALUE_TYPE_BOOL) { + finish_interactive(state, nullptr, "invalid-result"); + return; + } + if (reply->submit && fl_value_get_bool(created)) { + g_autoptr(FlValue) success = fl_value_new_bool(TRUE); + finish_interactive(state, success); + return; + } + state->applying = true; + if (g_strcmp0(gtk_entry_get_text(GTK_ENTRY(state->entry)), file_name) != + 0) { + gtk_entry_set_text(GTK_ENTRY(state->entry), file_name); + } + state->applying = false; + const gchar* title_error = lookup_string(value, "titleError"); + const gchar* file_error = lookup_string(value, "fileNameError"); + const gchar* creation_error = lookup_string(value, "error"); + set_error_label(state->title_error, title_error); + set_error_label(state->error_label, + creation_error != nullptr ? creation_error : file_error); + state->valid = title_error == nullptr && file_error == nullptr; + } else { + const gchar* validation_error = lookup_string(value, "error"); + set_error_label(state->error_label, validation_error); + state->valid = validation_error == nullptr; + } + state->pending = false; + set_interactive_sensitive(state.get()); + if (reply->submit) gtk_widget_grab_focus(state->entry); +} + +void send_interactive_event(InteractiveDialog* state, bool submit = false) { + if (state->finished || state->applying || state->pending) return; + if (submit) state->pending = true; + state->valid = false; + set_interactive_sensitive(state); + ++state->revision; + g_autoptr(FlValue) args = fl_value_new_map(); + fl_value_set_string_take(args, "session", fl_value_new_int(state->session)); + fl_value_set_string_take(args, "event", + fl_value_new_string(state->picker ? "filter" + : submit ? "submit" + : "changed")); + const gchar* entry = gtk_entry_get_text(GTK_ENTRY(state->entry)); + if (state->create) { + fl_value_set_string_take( + args, "title", + fl_value_new_string(gtk_entry_get_text(GTK_ENTRY(state->title_entry)))); + fl_value_set_string_take(args, "fileName", fl_value_new_string(entry)); + fl_value_set_string_take(args, "fileNameEdited", + fl_value_new_bool(state->file_name_edited)); + } else { + fl_value_set_string_take(args, "value", fl_value_new_string(entry)); + } + auto* reply = + new InteractiveReply{interactive_ref(state), state->revision, submit}; + fl_method_channel_invoke_method(state->channel, "writersideDialogEvent", args, + nullptr, interactive_reply_cb, reply); +} + +void interactive_changed(GtkEditable* entry, gpointer user_data) { + auto* state = static_cast(user_data); + if (state->applying || state->pending) return; + if (state->create && GTK_WIDGET(entry) == state->entry) { + state->file_name_edited = true; + } + send_interactive_event(state); +} + +void interactive_response(GtkDialog*, gint response, gpointer user_data) { + auto* state = static_cast(user_data); + if (state->pending || state->finished) return; + if (response != GTK_RESPONSE_OK && response != GTK_RESPONSE_APPLY) { + g_autoptr(FlValue) cancelled = fl_value_new_null(); + finish_interactive(interactive_ref(state), cancelled); + return; + } + if (!state->valid) return; + if (state->create) { + send_interactive_event(state, true); + } else if (state->picker) { + GtkListBoxRow* row = + gtk_list_box_get_selected_row(GTK_LIST_BOX(state->list)); + if (row == nullptr) return; + g_autoptr(FlValue) selected = fl_value_new_int( + GPOINTER_TO_INT(g_object_get_data(G_OBJECT(row), "topic-index"))); + finish_interactive(interactive_ref(state), selected); + } else { + const gchar* text = gtk_entry_get_text(GTK_ENTRY(state->entry)); + g_autoptr(FlValue) selected = + state->rename ? fl_value_new_map() : fl_value_new_string(text); + if (state->rename) { + fl_value_set_string_take(selected, "value", fl_value_new_string(text)); + fl_value_set_string_take( + selected, "preview", + fl_value_new_bool(response == GTK_RESPONSE_APPLY)); + } + finish_interactive(interactive_ref(state), selected); + } +} + +gboolean interactive_delete(GtkWidget*, GdkEvent*, gpointer user_data) { + auto* state = static_cast(user_data); + if (!state->pending) { + interactive_response(nullptr, GTK_RESPONSE_CANCEL, state); + } + return TRUE; +} + +gboolean interactive_key(GtkWidget*, GdkEventKey* event, gpointer user_data) { + auto* state = static_cast(user_data); + if (event->keyval == GDK_KEY_Escape) { + interactive_response(nullptr, GTK_RESPONSE_CANCEL, state); + return TRUE; + } + if (!state->picker || + (event->keyval != GDK_KEY_Down && event->keyval != GDK_KEY_Up)) + return FALSE; + if (!state->valid) return TRUE; + GtkListBox* list = GTK_LIST_BOX(state->list); + GtkListBoxRow* current = gtk_list_box_get_selected_row(list); + gint index = current == nullptr ? 0 : gtk_list_box_row_get_index(current); + gint next = MAX(0, index + (event->keyval == GDK_KEY_Down ? 1 : -1)); + GtkListBoxRow* row = gtk_list_box_get_row_at_index(list, next); + if (row == nullptr) row = current; + if (row != nullptr) { + gtk_list_box_select_row(list, row); + GtkAllocation allocation; + gtk_widget_get_allocation(GTK_WIDGET(row), &allocation); + GtkAdjustment* adjustment = + gtk_scrolled_window_get_vadjustment(GTK_SCROLLED_WINDOW(state->scroll)); + gtk_adjustment_clamp_page(adjustment, allocation.y, + allocation.y + allocation.height); + } + return TRUE; +} + +void show_interactive(DialogHandlerData* data, FlMethodChannel* channel, + FlMethodCall* call, FlValue* args, const gchar* method) { + FlValue* session = + args == nullptr ? nullptr : fl_value_lookup_string(args, "session"); + const bool picker = std::strcmp(method, "showExistingTopicPicker") == 0; + const bool create = std::strcmp(method, "showCreateTopic") == 0; + const bool rename = std::strcmp(method, "showRenameTopic") == 0; + const gchar* title = lookup_string(args, "title"); + if (session == nullptr || fl_value_get_type(session) != FL_VALUE_TYPE_INT || + title == nullptr) { + respond_invalid_arguments(call, + "Interactive dialog arguments are invalid."); + return; + } + const gchar* keys[] = {"fileNameLabel", "cancelLabel", "okLabel"}; + if (!picker) { + for (const gchar* key : keys) { + if (lookup_string(args, key) == nullptr) { + respond_invalid_arguments(call, + "Interactive dialog labels are invalid."); + return; + } + } + } + if ((create && (lookup_string(args, "titleLabel") == nullptr || + lookup_string(args, "initialTitle") == nullptr || + lookup_string(args, "initialFileName") == nullptr)) || + (!create && !picker && lookup_string(args, "initialValue") == nullptr) || + (rename && lookup_string(args, "previewLabel") == nullptr) || + (picker && lookup_string(args, "searchLabel") == nullptr)) { + respond_invalid_arguments(call, "Interactive dialog fields are invalid."); + return; + } + FlValue* names = picker ? fl_value_lookup_string(args, "fileNames") : nullptr; + if (picker && + (names == nullptr || fl_value_get_type(names) != FL_VALUE_TYPE_LIST)) { + respond_invalid_arguments(call, "Topic filenames are invalid."); + return; + } + auto state = std::make_shared(channel, call, data->view, + fl_value_get_int(session)); + state->create = create; + state->rename = rename; + state->picker = picker; + if (picker) { + for (size_t i = 0; i < fl_value_get_length(names); ++i) { + FlValue* name = fl_value_get_list_value(names, i); + if (fl_value_get_type(name) != FL_VALUE_TYPE_STRING) { + respond_invalid_arguments(call, "Topic filenames must be strings."); + return; + } + state->file_names.emplace_back(fl_value_get_string(name)); + } + } + state->dialog = gtk_dialog_new_with_buttons( + title, data->parent, + static_cast(GTK_DIALOG_MODAL | + GTK_DIALOG_DESTROY_WITH_PARENT), + nullptr, nullptr); + configure_dialog(state->dialog, requested_direction(args, data->view), 470); + FlValue* enter_only_value = + picker ? nullptr : fl_value_lookup_string(args, "enterOnly"); + const bool enter_only = + enter_only_value != nullptr && + fl_value_get_type(enter_only_value) == FL_VALUE_TYPE_BOOL && + fl_value_get_bool(enter_only_value); + if (!picker) { + state->cancel_button = gtk_dialog_add_button( + GTK_DIALOG(state->dialog), lookup_string(args, "cancelLabel"), + GTK_RESPONSE_CANCEL); + if (rename) { + state->preview_button = gtk_dialog_add_button( + GTK_DIALOG(state->dialog), lookup_string(args, "previewLabel"), + GTK_RESPONSE_APPLY); + } + state->ok_button = + gtk_dialog_add_button(GTK_DIALOG(state->dialog), + lookup_string(args, "okLabel"), GTK_RESPONSE_OK); + if (enter_only) { + gtk_widget_set_no_show_all(state->cancel_button, TRUE); + gtk_widget_set_no_show_all(state->ok_button, TRUE); + } + // configure_dialog runs before these actions exist. Assign the default + // after installing the OK action. + gtk_dialog_set_default_response(GTK_DIALOG(state->dialog), GTK_RESPONSE_OK); + } + GtkWidget* content = gtk_dialog_get_content_area(GTK_DIALOG(state->dialog)); + if (create) { + state->title_entry = gtk_entry_new(); + gtk_entry_set_text(GTK_ENTRY(state->title_entry), + lookup_string(args, "initialTitle")); + GtkWidget* label = left_aligned_label(lookup_string(args, "titleLabel")); + gtk_label_set_mnemonic_widget(GTK_LABEL(label), state->title_entry); + gtk_box_pack_start(GTK_BOX(content), label, FALSE, FALSE, 0); + gtk_box_pack_start(GTK_BOX(content), state->title_entry, FALSE, FALSE, 0); + state->title_error = left_aligned_label(""); + gtk_style_context_add_class( + gtk_widget_get_style_context(state->title_error), "error"); + gtk_box_pack_start(GTK_BOX(content), state->title_error, FALSE, FALSE, 0); + } + state->entry = picker ? gtk_search_entry_new() : gtk_entry_new(); + if (create || rename) + gtk_widget_set_direction(state->entry, GTK_TEXT_DIR_LTR); + if (picker) { + const gchar* search_label = lookup_string(args, "searchLabel"); + gtk_entry_set_placeholder_text(GTK_ENTRY(state->entry), search_label); + } else { + gtk_entry_set_text( + GTK_ENTRY(state->entry), + lookup_string(args, create ? "initialFileName" : "initialValue")); + GtkWidget* label = left_aligned_label(lookup_string(args, "fileNameLabel")); + gtk_label_set_mnemonic_widget(GTK_LABEL(label), state->entry); + gtk_box_pack_start(GTK_BOX(content), label, FALSE, FALSE, 0); + } + gtk_box_pack_start(GTK_BOX(content), state->entry, FALSE, FALSE, 0); + state->error_label = left_aligned_label(""); + gtk_style_context_add_class(gtk_widget_get_style_context(state->error_label), + "error"); + gtk_box_pack_start(GTK_BOX(content), state->error_label, FALSE, FALSE, 0); + if (picker) { + state->scroll = gtk_scrolled_window_new(nullptr, nullptr); + gtk_widget_set_size_request(state->scroll, -1, 300); + state->list = gtk_list_box_new(); + gtk_list_box_set_activate_on_single_click(GTK_LIST_BOX(state->list), TRUE); + gtk_container_add(GTK_CONTAINER(state->scroll), state->list); + gtk_box_pack_start(GTK_BOX(content), state->scroll, TRUE, TRUE, 0); + g_signal_connect( + state->list, "row-activated", + G_CALLBACK( + +[](GtkListBox* list, GtkListBoxRow* row, gpointer user_data) { + gtk_list_box_select_row(list, row); + interactive_response(nullptr, GTK_RESPONSE_OK, user_data); + }), + state.get()); + } + // Explicit activation also supports the Group form without visible actions. + g_signal_connect(state->entry, "activate", + G_CALLBACK(+[](GtkEntry*, gpointer user_data) { + interactive_response(nullptr, GTK_RESPONSE_OK, user_data); + }), + state.get()); + if (create) { + g_signal_connect(state->title_entry, "activate", + G_CALLBACK(+[](GtkEntry*, gpointer user_data) { + interactive_response(nullptr, GTK_RESPONSE_OK, + user_data); + }), + state.get()); + g_signal_connect(state->title_entry, "changed", + G_CALLBACK(interactive_changed), state.get()); + } + g_signal_connect(state->entry, "changed", G_CALLBACK(interactive_changed), + state.get()); + g_signal_connect(state->dialog, "response", G_CALLBACK(interactive_response), + state.get()); + g_signal_connect(state->dialog, "key-press-event", + G_CALLBACK(interactive_key), state.get()); + g_signal_connect(state->dialog, "delete-event", + G_CALLBACK(interactive_delete), state.get()); + g_object_set_data_full( + G_OBJECT(state->dialog), "writerside-state", new InteractivePtr(state), + +[](gpointer ptr) { delete static_cast(ptr); }); + g_signal_connect(state->dialog, "destroy", + G_CALLBACK(+[](GtkWidget*, gpointer user_data) { + auto* state = static_cast(user_data); + if (state->finished) return; + auto retained = interactive_ref(state); + state->dialog = nullptr; + g_autoptr(FlValue) cancelled = fl_value_new_null(); + finish_interactive(retained, cancelled); + }), + state.get()); + gtk_widget_show_all(state->dialog); + set_error_label(state->error_label, nullptr); + if (create) set_error_label(state->title_error, nullptr); + gtk_widget_grab_focus(create ? state->title_entry : state->entry); + send_interactive_event(state.get()); +} + +void method_call_cb(FlMethodChannel* channel, FlMethodCall* call, gpointer user_data) { auto* data = static_cast(user_data); @@ -392,6 +875,11 @@ void method_call_cb(FlMethodChannel*, show_duplicate_topic(data, call, fl_method_call_get_args(call)); } else if (std::strcmp(method, "showEditTitle") == 0) { show_edit_title(data, call, fl_method_call_get_args(call)); + } else if (std::strcmp(method, "showCreateTopic") == 0 || + std::strcmp(method, "showRenameTopic") == 0 || + std::strcmp(method, "showTocText") == 0 || + std::strcmp(method, "showExistingTopicPicker") == 0) { + show_interactive(data, channel, call, fl_method_call_get_args(call), method); } else { fl_method_call_respond_not_implemented(call, nullptr); } diff --git a/test/src/app_smoke_test.dart b/test/src/app_smoke_test.dart index 7ddcd75d..89305833 100644 --- a/test/src/app_smoke_test.dart +++ b/test/src/app_smoke_test.dart @@ -1,3 +1,5 @@ +import 'package:busymark/src/platform/native_writerside_dialog_service.dart'; +import '../support/native_writerside_dialog_host.dart'; import 'dart:async'; import 'dart:convert'; import 'dart:io'; @@ -99,6 +101,10 @@ void main() { (call) async => call.method == 'state' ? {} : null, ); } + binding.defaultBinaryMessenger.setMockMethodCallHandler( + const MethodChannel(nativeWritersideDialogChannelName), + (_) async => throw MissingPluginException(), + ); headerBarService = _FallbackHeaderBarService(); }); @@ -1247,6 +1253,57 @@ void main() { expect(settingsStore.value['localHistoryMaximumStorageMiB'], 1024); expect(historyRow(l10n.settingsHistoryStorage).selected, 1024); + Future openExcludedPaths() async { + final row = find.byWidgetPredicate( + (widget) => + widget is BusyMarkActionRow && + widget.title == l10n.settingsHistoryExcludedPaths, + ); + await tester.ensureVisible(row); + await tester.tap(row); + await tester.pumpAndSettle(); + expect(find.byType(BusyMarkDialogShell), findsOneWidget); + expect(find.byType(AlertDialog), findsNothing); + } + + await openExcludedPaths(); + final excludedEntry = find.byType(BusyMarkGroupedTextEntry); + final excluded = tester.widget(excludedEntry); + expect(excluded.controller!.text, ''); + expect(excluded.maxLines, 12); + expect(excluded.onSubmitted, isNull); + await tester.enterText(excludedEntry, '/tmp/build'); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.testTextInput.receiveAction(TextInputAction.newline); + tester.testTextInput.updateEditingValue( + const TextEditingValue(text: '/tmp/build\n'), + ); + await tester.pump(); + expect(find.byType(BusyMarkDialogShell), findsOneWidget); + expect(excluded.controller!.text, contains('\n')); + await tester.enterText(excludedEntry, '/tmp/build\r\n/tmp/cache'); + await tester.tap(find.text(l10n.save)); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + expect(settingsStore.value['localHistoryExcludedPaths'], [ + '/tmp/build', + '/tmp/cache', + ]); + await openExcludedPaths(); + expect( + tester.widget(excludedEntry).controller!.text, + '/tmp/build\n/tmp/cache', + ); + await tester.enterText(excludedEntry, 'discard'); + await tester.tap(find.text(l10n.cancel)); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + expect(settingsStore.value['localHistoryExcludedPaths'], [ + '/tmp/build', + '/tmp/cache', + ]); + + await tester.ensureVisible(find.text(l10n.settingsLocalHistoryTitle)); await tester.tap(find.text(l10n.settingsLocalHistoryTitle)); await tester.pumpAndSettle(); expect(settingsStore.value['localHistoryRecordingEnabled'], isFalse); @@ -3413,47 +3470,51 @@ code expect(container.read(workspaceControllerProvider).activeText, source); }); - testWidgets( - 'Table of Contents creates from selection and exposes nested Writerside actions', - (tester) async { - const yaruWindowChannel = MethodChannel('yaru_window'); - const yaruWindowEventsChannel = MethodChannel('yaru_window/events'); - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(yaruWindowChannel, (call) async { - if (call.method == 'state') { - return {}; - } - return null; - }); - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(yaruWindowEventsChannel, (_) async => null); - addTearDown(() { + for (final nativeCreation in [false, true]) { + testWidgets( + 'Table of Contents creates from selection and exposes nested Writerside actions${nativeCreation ? ' via native dialogs' : ''}', + (tester) async { + const yaruWindowChannel = MethodChannel('yaru_window'); + const yaruWindowEventsChannel = MethodChannel('yaru_window/events'); TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - ..setMockMethodCallHandler(yaruWindowChannel, null) - ..setMockMethodCallHandler(yaruWindowEventsChannel, null); - }); - final binding = TestWidgetsFlutterBinding.ensureInitialized(); - binding.platformDispatcher.defaultRouteNameTestValue = '/workspace'; - addTearDown(() { - binding.platformDispatcher.defaultRouteNameTestValue = '/'; - }); - final root = Directory.systemTemp.createTempSync( - 'busymark-topics-sidebar-', - ); - addTearDown(() { - if (root.existsSync()) { - root.deleteSync(recursive: true); - } - }); - Directory(p.join(root.path, 'topics')).createSync(); - File(p.join(root.path, 'writerside.cfg')).writeAsStringSync(''' + .setMockMethodCallHandler(yaruWindowChannel, (call) async { + if (call.method == 'state') { + return {}; + } + return null; + }); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + yaruWindowEventsChannel, + (_) async => null, + ); + addTearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + ..setMockMethodCallHandler(yaruWindowChannel, null) + ..setMockMethodCallHandler(yaruWindowEventsChannel, null); + }); + final binding = TestWidgetsFlutterBinding.ensureInitialized(); + binding.platformDispatcher.defaultRouteNameTestValue = '/workspace'; + addTearDown(() { + binding.platformDispatcher.defaultRouteNameTestValue = '/'; + }); + final root = Directory.systemTemp.createTempSync( + 'busymark-topics-sidebar-', + ); + addTearDown(() { + if (root.existsSync()) { + root.deleteSync(recursive: true); + } + }); + Directory(p.join(root.path, 'topics')).createSync(); + File(p.join(root.path, 'writerside.cfg')).writeAsStringSync(''' '''); - File(p.join(root.path, 'guide.tree')).writeAsStringSync(''' + File(p.join(root.path, 'guide.tree')).writeAsStringSync(''' @@ -3464,465 +3525,593 @@ code '''); - File(p.join(root.path, 'api.tree')).writeAsStringSync(''' + File(p.join(root.path, 'api.tree')).writeAsStringSync(''' '''); - File( - p.join(root.path, 'topics', 'parent.md'), - ).writeAsStringSync('# Parent\n'); - File( - p.join(root.path, 'topics', 'nested.md'), - ).writeAsStringSync('# Nested\n'); - File( - p.join(root.path, 'topics', 'loose.md'), - ).writeAsStringSync('# Loose\n'); - File( - p.join(root.path, 'topics', 'target.md'), - ).writeAsStringSync('# Target\n'); - File(p.join(root.path, 'topics', 'api.md')).writeAsStringSync('# API\n'); - final sharedModule = Directory(p.join(root.path, 'shared'))..createSync(); - File(p.join(sharedModule.path, 'writerside.cfg')).writeAsStringSync(''' + File( + p.join(root.path, 'topics', 'parent.md'), + ).writeAsStringSync('# Parent\n'); + File( + p.join(root.path, 'topics', 'nested.md'), + ).writeAsStringSync('# Nested\n'); + File( + p.join(root.path, 'topics', 'loose.md'), + ).writeAsStringSync('# Loose\n'); + File( + p.join(root.path, 'topics', 'target.md'), + ).writeAsStringSync('# Target\n'); + File( + p.join(root.path, 'topics', 'api.md'), + ).writeAsStringSync('# API\n'); + final sharedModule = Directory(p.join(root.path, 'shared')) + ..createSync(); + File(p.join(sharedModule.path, 'writerside.cfg')).writeAsStringSync(''' '''); - File(p.join(sharedModule.path, 'shared.tree')).writeAsStringSync(''' + File(p.join(sharedModule.path, 'shared.tree')).writeAsStringSync(''' '''); - File( - p.join(sharedModule.path, 'shared.md'), - ).writeAsStringSync('# Shared\n'); - final workspace = (await tester.runAsync( - () => const WorkspaceService().openPath(root.path), - ))!; - final workspaceState = WorkspaceState( - workspace: workspace, - activeText: '# Nested\n', - ); - final controller = _MutableWorkspaceController(workspaceState); - final container = ProviderContainer( - overrides: [ - ..._smokeAccentOverrides, - linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), - localSettingsStoreProvider.overrideWithValue(_MemorySettingsStore()), - workspaceControllerProvider.overrideWith(() => controller), - ], - ); - addTearDown(container.dispose); + File( + p.join(sharedModule.path, 'shared.md'), + ).writeAsStringSync('# Shared\n'); + final workspace = (await tester.runAsync( + () => const WorkspaceService().openPath(root.path), + ))!; + final workspaceState = WorkspaceState( + workspace: workspace, + activeText: '# Nested\n', + ); + final controller = _MutableWorkspaceController(workspaceState); + var cancelNativeCreation = false; + final nativeCalls = []; + if (nativeCreation) { + const channel = MethodChannel(nativeWritersideDialogChannelName); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + (call) async { + if (call.method != 'showCreateTopic') { + throw MissingPluginException(); + } + nativeCalls.add(call); + if (cancelNativeCreation) { + cancelNativeCreation = false; + return null; + } + final host = TestNativeWritersideDialogHost() + ..arguments = call.arguments as Map; + final result = + await host.event({ + 'event': 'submit', + 'title': host.arguments['initialTitle'], + 'fileName': host.arguments['initialFileName'], + 'fileNameEdited': false, + }) + as Map; + expect(result['created'], isTrue); + return true; + }, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null), + ); + } + Future acceptCreation() async { + if (nativeCreation) { + expect(find.byType(BusyMarkDialogShell), findsNothing); + } else { + await tester.tap(find.text(l10n.tocOk)); + } + await tester.pumpAndSettle(); + } - await tester.pumpWidget( - UncontrolledProviderScope( - container: container, - child: const BusyMarkApp(), - ), - ); - await tester.pump(); - for (var index = 0; index < 10; index += 1) { - await tester.pump(const Duration(milliseconds: 100)); - } + final container = ProviderContainer( + overrides: [ + ..._smokeAccentOverrides, + linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), + localSettingsStoreProvider.overrideWithValue( + _MemorySettingsStore(), + ), + workspaceControllerProvider.overrideWith(() => controller), + ], + ); + addTearDown(container.dispose); - Future openPopup( - Finder anchor, { - int buttons = kPrimaryButton, - }) async { - await tester.tap(anchor, buttons: buttons); - await tester.pumpAndSettle(); - } + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const BusyMarkApp(), + ), + ); + await tester.pump(); + for (var index = 0; index < 10; index += 1) { + await tester.pump(const Duration(milliseconds: 100)); + } - Finder popupMenuItem(String label) => find.byWidgetPredicate( - (widget) => - (widget is BusyMarkPopupMenuItem && - widget.label == label) || - (widget is MenuItemButton && - widget.child is Text && - (widget.child! as Text).data == label) || - (widget is SubmenuButton && - widget.child is Text && - (widget.child! as Text).data == label), - ); + Future openPopup( + Finder anchor, { + int buttons = kPrimaryButton, + }) async { + await tester.tap(anchor, buttons: buttons); + await tester.pumpAndSettle(); + } - await openPopup(find.byTooltip(l10n.sidebarViewMenu)); - await tester.tap(find.text(l10n.files)); - await tester.pumpAndSettle(); - await tester.tap(find.text('target.md')); - await tester.pump(const Duration(milliseconds: 200)); - final filesDeleteHandled = await tester.sendKeyDownEvent( - LogicalKeyboardKey.delete, - ); - expect(filesDeleteHandled, isTrue); - await tester.pumpAndSettle(); - await tester.sendKeyUpEvent(LogicalKeyboardKey.delete); - expect( - controller.analyzedRemovalMode, - WritersideTopicRemovalMode.safeDeleteFile, - ); - expect(find.text(l10n.delete), findsOneWidget); - expect(find.text(l10n.tocSafeDelete), findsOneWidget); - final mandatorySafeDelete = tester.widget( - find.widgetWithText(CheckboxListTile, l10n.tocSafeDelete), - ); - expect(mandatorySafeDelete.value, isTrue); - expect(mandatorySafeDelete.onChanged, isNull); - expect(find.text(l10n.updateUsagesAutomatically), findsOneWidget); - expect(find.text(l10n.reviewUsages), findsOneWidget); - await tester.tap(find.text(l10n.cancel)); - await tester.pump(const Duration(milliseconds: 200)); + Finder popupMenuItem(String label) => find.byWidgetPredicate( + (widget) => + (widget is BusyMarkPopupMenuItem && + widget.label == label) || + (widget is MenuItemButton && + widget.child is Text && + (widget.child! as Text).data == label) || + (widget is SubmenuButton && + widget.child is Text && + (widget.child! as Text).data == label), + ); - await openPopup(find.text('target.md'), buttons: kSecondaryButton); - expect(popupMenuItem(l10n.tocRefactorMenu), findsOneWidget); - await tester.tap(popupMenuItem(l10n.tocRefactorMenu)); - await tester.pumpAndSettle(); - expect(popupMenuItem(l10n.tocSafeDelete), findsOneWidget); - await tester.sendKeyEvent(LogicalKeyboardKey.escape); - await tester.pumpAndSettle(); + await openPopup(find.byTooltip(l10n.sidebarViewMenu)); + await tester.tap(find.text(l10n.files)); + await tester.pumpAndSettle(); + await tester.tap(find.text('target.md')); + await tester.pump(const Duration(milliseconds: 200)); + final filesDeleteHandled = await tester.sendKeyDownEvent( + LogicalKeyboardKey.delete, + ); + expect(filesDeleteHandled, isTrue); + await tester.pumpAndSettle(); + await tester.sendKeyUpEvent(LogicalKeyboardKey.delete); + expect( + controller.analyzedRemovalMode, + WritersideTopicRemovalMode.safeDeleteFile, + ); + expect(find.text(l10n.delete), findsOneWidget); + expect(find.text(l10n.tocSafeDelete), findsOneWidget); + final mandatorySafeDelete = tester.widget( + find.descendant( + of: find.widgetWithText(BusyMarkActionRow, l10n.tocSafeDelete), + matching: find.byType(BusyMarkCheckbox), + ), + ); + expect(mandatorySafeDelete.value, isTrue); + expect(mandatorySafeDelete.onChanged, isNull); + expect(find.text(l10n.updateUsagesAutomatically), findsOneWidget); + expect(find.text(l10n.reviewUsages), findsOneWidget); + await tester.tap(find.text(l10n.cancel)); + await tester.pump(const Duration(milliseconds: 200)); - await openPopup(find.byTooltip(l10n.sidebarViewMenu)); - await tester.tap(find.text(l10n.toc)); - await tester.pumpAndSettle(); + await openPopup(find.text('target.md'), buttons: kSecondaryButton); + expect(popupMenuItem(l10n.tocRefactorMenu), findsOneWidget); + await tester.tap(popupMenuItem(l10n.tocRefactorMenu)); + await tester.pumpAndSettle(); + expect(popupMenuItem(l10n.tocSafeDelete), findsOneWidget); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); - final instanceSelector = find.byKey( - const ValueKey('writerside-instance-selector'), - ); - expect(instanceSelector, findsOneWidget); - final instanceSelectorTrigger = find.byKey( - const ValueKey('writerside-instance-selector-trigger'), - ); - expect(instanceSelectorTrigger, findsOneWidget); - expect( - find.descendant( - of: instanceSelector, - matching: find.byType(FilledButton), - ), - findsNothing, - ); - final instanceSelectorSurface = find.descendant( - of: instanceSelectorTrigger, - matching: find.byType(Material), - ); - final instanceSelectorInkWell = find.descendant( - of: instanceSelectorTrigger, - matching: find.byType(InkWell), - ); - expect(instanceSelectorSurface, findsOneWidget); - expect(instanceSelectorInkWell, findsOneWidget); - expect( - tester.widget(instanceSelectorSurface).color, - BusyMarkLinuxPalette.transparent, - ); - final instanceSelectorRect = tester.getRect(instanceSelectorTrigger); - final tocHeaderRowRect = tester.getRect( - find.byKey(const ValueKey('workspace-sidebar-first-content')), - ); - final firstTocRow = find.byKey( - const ValueKey('workspace-sidebar-toc-row-0'), - ); - final firstTocRowRect = tester.getRect(firstTocRow); - final firstTocRowSurface = find.descendant( - of: firstTocRow, - matching: find.byType(Material), - ); - final firstTocRowInkWell = find.descendant( - of: firstTocRow, - matching: find.byType(InkWell), - ); - expect(firstTocRowSurface, findsOneWidget); - expect(firstTocRowInkWell, findsOneWidget); - expect( - tester.widget(instanceSelectorSurface).borderRadius, - tester.widget(firstTocRowSurface).borderRadius, - ); - expect( - tester.widget(instanceSelectorInkWell).hoverColor, - tester.widget(firstTocRowInkWell).hoverColor, - ); - final primaryIconRect = tester.getRect( - find.descendant( - of: find.byKey(const ValueKey('workspace-sidebar-primary-label')), - matching: find.byType(Icon), - ), - ); - final instanceIconRect = tester.getRect( - find.descendant( - of: instanceSelector, - matching: find.byIcon(BusyMarkGlyphs.tree), - ), - ); - expect( - find.descendant( - of: find.byKey(const ValueKey('workspace-sidebar-toc-row-0')), - matching: find.byIcon(BusyMarkGlyphs.document), - ), - findsNothing, - ); - expect(instanceIconRect.left, primaryIconRect.left); - expect(tocHeaderRowRect.left, firstTocRowRect.left); - expect(tocHeaderRowRect.right, firstTocRowRect.right); - expect(instanceSelectorRect.left, tocHeaderRowRect.left); - expect(instanceSelectorRect.right, lessThan(tocHeaderRowRect.right)); - final primaryMenuRect = tester.getRect( - find.byTooltip(l10n.sidebarViewMenu), - ); - final tocMenuRect = tester.getRect( - find.descendant( + await openPopup(find.byTooltip(l10n.sidebarViewMenu)); + await tester.tap(find.text(l10n.toc)); + await tester.pumpAndSettle(); + + final instanceSelector = find.byKey( + const ValueKey('writerside-instance-selector'), + ); + expect(instanceSelector, findsOneWidget); + final instanceSelectorTrigger = find.byKey( + const ValueKey('writerside-instance-selector-trigger'), + ); + expect(instanceSelectorTrigger, findsOneWidget); + expect( + find.descendant( + of: instanceSelector, + matching: find.byType(FilledButton), + ), + findsNothing, + ); + final instanceSelectorSurface = find.descendant( + of: instanceSelectorTrigger, + matching: find.byType(Material), + ); + final instanceSelectorInkWell = find.descendant( + of: instanceSelectorTrigger, + matching: find.byType(InkWell), + ); + expect(instanceSelectorSurface, findsOneWidget); + expect(instanceSelectorInkWell, findsOneWidget); + expect( + tester.widget(instanceSelectorSurface).color, + BusyMarkLinuxPalette.transparent, + ); + final instanceSelectorRect = tester.getRect(instanceSelectorTrigger); + final tocHeaderRowRect = tester.getRect( + find.byKey(const ValueKey('workspace-sidebar-first-content')), + ); + final firstTocRow = find.byKey( + const ValueKey('workspace-sidebar-toc-row-0'), + ); + final firstTocRowRect = tester.getRect(firstTocRow); + final firstTocRowSurface = find.descendant( + of: firstTocRow, + matching: find.byType(Material), + ); + final firstTocRowInkWell = find.descendant( + of: firstTocRow, + matching: find.byType(InkWell), + ); + expect(firstTocRowSurface, findsOneWidget); + expect(firstTocRowInkWell, findsOneWidget); + expect( + tester.widget(instanceSelectorSurface).borderRadius, + tester.widget(firstTocRowSurface).borderRadius, + ); + expect( + tester.widget(instanceSelectorInkWell).hoverColor, + tester.widget(firstTocRowInkWell).hoverColor, + ); + final primaryIconRect = tester.getRect( + find.descendant( + of: find.byKey(const ValueKey('workspace-sidebar-primary-label')), + matching: find.byType(Icon), + ), + ); + final instanceIconRect = tester.getRect( + find.descendant( + of: instanceSelector, + matching: find.byIcon(BusyMarkGlyphs.tree), + ), + ); + expect( + find.descendant( + of: find.byKey(const ValueKey('workspace-sidebar-toc-row-0')), + matching: find.byIcon(BusyMarkGlyphs.document), + ), + findsNothing, + ); + expect(instanceIconRect.left, primaryIconRect.left); + expect(tocHeaderRowRect.left, firstTocRowRect.left); + expect(tocHeaderRowRect.right, firstTocRowRect.right); + expect(instanceSelectorRect.left, tocHeaderRowRect.left); + expect(instanceSelectorRect.right, lessThan(tocHeaderRowRect.right)); + final primaryMenuRect = tester.getRect( + find.byTooltip(l10n.sidebarViewMenu), + ); + final tocMenuRect = tester.getRect( + find.descendant( + of: find.byKey(const ValueKey('workspace-sidebar-toc-menu')), + matching: find.byType(IconButton), + ), + ); + expect(instanceSelectorRect.height, tocMenuRect.height); + expect(instanceSelectorRect.center.dy, tocMenuRect.center.dy); + expect(instanceSelectorRect.height, firstTocRowRect.height); + final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); + addTearDown(mouse.removePointer); + await mouse.addPointer(location: Offset.zero); + await mouse.moveTo( + tester.getCenter( + find.byKey(const ValueKey('workspace-sidebar-toc-row-0')), + ), + ); + await tester.pump(); + final tocRowMenuRect = tester.getRect( + find.descendant( + of: find.byKey(const ValueKey('workspace-sidebar-toc-row-0')), + matching: find.byType(IconButton), + ), + ); + expect(tocMenuRect.center.dx, primaryMenuRect.center.dx); + expect(tocRowMenuRect.center.dx, primaryMenuRect.center.dx); + await mouse.moveTo(Offset.zero); + await tester.pump(); + expect(find.text(l10n.instances), findsNothing); + expect( + find.byKey(const ValueKey('writerside-instance-guide')), + findsNothing, + ); + expect( + find.byKey(const ValueKey('writerside-instance-api')), + findsNothing, + ); + expect( + find.byKey(const ValueKey('writerside-module-selector')), + findsOneWidget, + ); + await openPopup( + find.byKey(const ValueKey('writerside-module-selector')), + ); + await tester.tap(find.text('shared-docs')); + await tester.pump(const Duration(milliseconds: 200)); + expect(controller.selectedWritersideModuleId, 'shared-docs'); + final guideIcon = tester.widget( + find.descendant( + of: instanceSelector, + matching: find.byIcon(BusyMarkGlyphs.tree), + ), + ); + await openPopup(instanceSelector); + final apiInstance = find.byWidgetPredicate( + (widget) => + widget is BusyMarkPopupMenuItem && + widget.label.contains('API Reference'), + ); + final guideInstance = find.byWidgetPredicate( + (widget) => + widget is BusyMarkPopupMenuItem && + widget.label.contains('Guide'), + ); + expect(apiInstance, findsOneWidget); + expect(guideInstance, findsOneWidget); + final guideOptionIcon = tester.widget( + find.descendant( + of: guideInstance, + matching: find.byIcon(BusyMarkGlyphs.tree), + ), + ); + final apiOptionIcon = tester.widget( + find.descendant( + of: apiInstance, + matching: find.byIcon(BusyMarkGlyphs.tree), + ), + ); + expect(guideOptionIcon.color, guideIcon.color); + expect(apiOptionIcon.color, isNotNull); + expect(apiOptionIcon.color, isNot(guideOptionIcon.color)); + await tester.tap(apiInstance); + await tester.pump(const Duration(milliseconds: 200)); + expect(controller.selectedWritersideInstanceId, 'api'); + final apiIcon = tester.widget( + find.descendant( + of: instanceSelector, + matching: find.byIcon(BusyMarkGlyphs.tree), + ), + ); + expect(guideIcon.color, isNotNull); + expect(apiIcon.color, isNotNull); + expect(guideIcon.color, isNot(apiIcon.color)); + await openPopup(instanceSelector); + expect(guideInstance, findsOneWidget); + await tester.tap(guideInstance); + await tester.pump(const Duration(milliseconds: 200)); + + expect(find.text('Nested entry'), findsOneWidget); + expect(find.byTooltip(l10n.newTopic), findsOneWidget); + expect(find.byTooltip(l10n.tocActions), findsOneWidget); + expect(find.byTooltip(l10n.tocSynchronize), findsNothing); + expect(find.byTooltip(l10n.newChildTopic), findsNothing); + final tocMenuButton = find.descendant( of: find.byKey(const ValueKey('workspace-sidebar-toc-menu')), matching: find.byType(IconButton), - ), - ); - expect(instanceSelectorRect.height, tocMenuRect.height); - expect(instanceSelectorRect.center.dy, tocMenuRect.center.dy); - expect(instanceSelectorRect.height, firstTocRowRect.height); - final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); - addTearDown(mouse.removePointer); - await mouse.addPointer(location: Offset.zero); - await mouse.moveTo( - tester.getCenter( - find.byKey(const ValueKey('workspace-sidebar-toc-row-0')), - ), - ); - await tester.pump(); - final tocRowMenuRect = tester.getRect( - find.descendant( - of: find.byKey(const ValueKey('workspace-sidebar-toc-row-0')), - matching: find.byType(IconButton), - ), - ); - expect(tocMenuRect.center.dx, primaryMenuRect.center.dx); - expect(tocRowMenuRect.center.dx, primaryMenuRect.center.dx); - await mouse.moveTo(Offset.zero); - await tester.pump(); - expect(find.text(l10n.instances), findsNothing); - expect( - find.byKey(const ValueKey('writerside-instance-guide')), - findsNothing, - ); - expect( - find.byKey(const ValueKey('writerside-instance-api')), - findsNothing, - ); - expect( - find.byKey(const ValueKey('writerside-module-selector')), - findsOneWidget, - ); - await openPopup(find.byKey(const ValueKey('writerside-module-selector'))); - await tester.tap(find.text('shared-docs')); - await tester.pump(const Duration(milliseconds: 200)); - expect(controller.selectedWritersideModuleId, 'shared-docs'); - final guideIcon = tester.widget( - find.descendant( - of: instanceSelector, - matching: find.byIcon(BusyMarkGlyphs.tree), - ), - ); - await openPopup(instanceSelector); - final apiInstance = find.byWidgetPredicate( - (widget) => - widget is BusyMarkPopupMenuItem && - widget.label.contains('API Reference'), - ); - final guideInstance = find.byWidgetPredicate( - (widget) => - widget is BusyMarkPopupMenuItem && - widget.label.contains('Guide'), - ); - expect(apiInstance, findsOneWidget); - expect(guideInstance, findsOneWidget); - final guideOptionIcon = tester.widget( - find.descendant( - of: guideInstance, - matching: find.byIcon(BusyMarkGlyphs.tree), - ), - ); - final apiOptionIcon = tester.widget( - find.descendant( - of: apiInstance, - matching: find.byIcon(BusyMarkGlyphs.tree), - ), - ); - expect(guideOptionIcon.color, guideIcon.color); - expect(apiOptionIcon.color, isNotNull); - expect(apiOptionIcon.color, isNot(guideOptionIcon.color)); - await tester.tap(apiInstance); - await tester.pump(const Duration(milliseconds: 200)); - expect(controller.selectedWritersideInstanceId, 'api'); - final apiIcon = tester.widget( - find.descendant( - of: instanceSelector, - matching: find.byIcon(BusyMarkGlyphs.tree), - ), - ); - expect(guideIcon.color, isNotNull); - expect(apiIcon.color, isNotNull); - expect(guideIcon.color, isNot(apiIcon.color)); - await openPopup(instanceSelector); - expect(guideInstance, findsOneWidget); - await tester.tap(guideInstance); - await tester.pump(const Duration(milliseconds: 200)); - - expect(find.text('Nested entry'), findsOneWidget); - expect(find.byTooltip(l10n.newTopic), findsOneWidget); - expect(find.byTooltip(l10n.tocActions), findsOneWidget); - expect(find.byTooltip(l10n.tocSynchronize), findsNothing); - expect(find.byTooltip(l10n.newChildTopic), findsNothing); - final tocMenuButton = find.descendant( - of: find.byKey(const ValueKey('workspace-sidebar-toc-menu')), - matching: find.byType(IconButton), - ); - expect(tester.widget(tocMenuButton).isSelected, isFalse); - await openPopup(find.byTooltip(l10n.tocActions)); - expect(tester.widget(tocMenuButton).isSelected, isFalse); - expect(find.text(l10n.newTopic), findsNothing); - final synchronizeItem = find.byWidgetPredicate( - (widget) => - widget is BusyMarkPopupMenuItem && - widget.label == l10n.tocSynchronize, - ); - expect(synchronizeItem, findsOneWidget); - expect( - tester.widget>(synchronizeItem).enabled, - isTrue, - ); - expect(find.text(l10n.newInstance), findsOneWidget); - expect(find.text(l10n.newTocLibrary), findsOneWidget); - expect(find.text(l10n.editInstance), findsOneWidget); - expect(find.text(l10n.openTocFile), findsOneWidget); - expect(find.text(l10n.export), findsNothing); - final tocActionSequence = find - .byWidgetPredicate( - (widget) => - widget is BusyMarkPopupMenuItem || - widget is PopupMenuDivider, - ) - .evaluate() - .map((element) { - final widget = element.widget; - return widget is BusyMarkPopupMenuItem - ? widget.label - : ''; - }) - .toList(); - expect(tocActionSequence, [ - l10n.tocSynchronize, - '', - l10n.newInstance, - l10n.newTocLibrary, - '', - l10n.editInstance, - l10n.openTocFile, - ]); - await tester.tap(find.text(l10n.editInstance)); - await tester.pumpAndSettle(); - expect(find.text(l10n.instanceOutputSettings), findsOneWidget); - expect( - find.byKey(const ValueKey('writerside-instance-name')), - findsOneWidget, - ); - expect( - find.byKey(const ValueKey('writerside-instance-id')), - findsOneWidget, - ); - expect( - find.byKey(const ValueKey('writerside-instance-version')), - findsOneWidget, - ); - expect( - find.byKey(const ValueKey('writerside-instance-web-path')), - findsOneWidget, - ); - expect(find.text(l10n.allowSearchEngineIndexing), findsOneWidget); - expect(find.text(l10n.offlineArtifact), findsOneWidget); - expect(find.text(l10n.instanceAppearance), findsOneWidget); - await tester.tap(find.text(l10n.cancel)); - await tester.pumpAndSettle(); + ); + expect(tester.widget(tocMenuButton).isSelected, isFalse); + await openPopup(find.byTooltip(l10n.tocActions)); + expect(tester.widget(tocMenuButton).isSelected, isFalse); + expect(find.text(l10n.newTopic), findsNothing); + final synchronizeItem = find.byWidgetPredicate( + (widget) => + widget is BusyMarkPopupMenuItem && + widget.label == l10n.tocSynchronize, + ); + expect(synchronizeItem, findsOneWidget); + expect( + tester + .widget>(synchronizeItem) + .enabled, + isTrue, + ); + expect(find.text(l10n.newInstance), findsOneWidget); + expect(find.text(l10n.newTocLibrary), findsOneWidget); + expect(find.text(l10n.editInstance), findsOneWidget); + expect(find.text(l10n.openTocFile), findsOneWidget); + expect(find.text(l10n.export), findsNothing); + final tocActionSequence = find + .byWidgetPredicate( + (widget) => + widget is BusyMarkPopupMenuItem || + widget is PopupMenuDivider, + ) + .evaluate() + .map((element) { + final widget = element.widget; + return widget is BusyMarkPopupMenuItem + ? widget.label + : ''; + }) + .toList(); + expect(tocActionSequence, [ + l10n.tocSynchronize, + '', + l10n.newInstance, + l10n.newTocLibrary, + '', + l10n.editInstance, + l10n.openTocFile, + ]); + await tester.tap(find.text(l10n.editInstance)); + await tester.pumpAndSettle(); + expect(find.text(l10n.instanceOutputSettings), findsOneWidget); + expect( + find.byKey(const ValueKey('writerside-instance-name')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('writerside-instance-id')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('writerside-instance-version')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('writerside-instance-web-path')), + findsOneWidget, + ); + expect(find.text(l10n.allowSearchEngineIndexing), findsOneWidget); + expect(find.text(l10n.offlineArtifact), findsOneWidget); + expect(find.text(l10n.instanceAppearance), findsOneWidget); + await tester.tap(find.text(l10n.cancel)); + await tester.pumpAndSettle(); - await openPopup(find.byTooltip(l10n.tocActions)); - await tester.tap(find.text(l10n.newInstance)); - await tester.pumpAndSettle(); - expect(find.text(l10n.createInstance), findsOneWidget); - expect(find.text(l10n.emptyInstance), findsOneWidget); - await tester.tap(find.text(l10n.emptyInstance)); - await tester.pumpAndSettle(); - expect(find.text(l10n.markdownFiles), findsOneWidget); - await tester.sendKeyEvent(LogicalKeyboardKey.escape); - await tester.pumpAndSettle(); - expect(find.text(l10n.instanceAppearance), findsOneWidget); - await tester.tap(find.text(l10n.cancel)); - await tester.pumpAndSettle(); + await openPopup(find.byTooltip(l10n.tocActions)); + await tester.tap(find.text(l10n.newInstance)); + await tester.pumpAndSettle(); + expect(find.text(l10n.createInstance), findsOneWidget); + expect(find.text(l10n.emptyInstance), findsOneWidget); + await tester.tap(find.text(l10n.emptyInstance)); + await tester.pumpAndSettle(); + expect(find.text(l10n.markdownFiles), findsOneWidget); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + expect(find.text(l10n.instanceAppearance), findsOneWidget); + await tester.tap(find.text(l10n.cancel)); + await tester.pumpAndSettle(); - await openPopup(find.byTooltip(l10n.newTopic)); - expect(find.text(l10n.addLocalMarkdownFiles), findsOneWidget); - expect(find.text(l10n.tocLinkTopicFiles), findsOneWidget); - await tester.tap(find.text(l10n.tocEmptyMdTopic)); - await tester.pumpAndSettle(); + cancelNativeCreation = nativeCreation; + await openPopup(find.byTooltip(l10n.newTopic)); + await tester.tap(find.text(l10n.tocEmptyMdTopic)); + await tester.pumpAndSettle(); + if (!nativeCreation) { + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + } + expect(find.byType(BusyMarkDialogShell), findsNothing); + expect(controller.createdTopicRequest, isNull); - expect(find.text(l10n.newTopic), findsOneWidget); - expect(find.byType(BusyMarkDialogShell), findsOneWidget); - expect( - find.descendant( - of: find.byType(BusyMarkDialogShell), - matching: find.byType(TextField), - ), - findsNWidgets(2), - ); - expect(find.text(l10n.topicPlacement), findsNothing); - await tester.tap(find.text(l10n.tocOk)); - await tester.pumpAndSettle(); + await openPopup(find.byTooltip(l10n.newTopic)); + expect(find.text(l10n.addLocalMarkdownFiles), findsOneWidget); + expect(find.text(l10n.tocLinkTopicFiles), findsOneWidget); + await tester.tap(find.text(l10n.tocEmptyMdTopic)); + await tester.pumpAndSettle(); - expect(controller.createdTopicRequest, isNotNull); - expect( - controller.createdTopicRequest!.placement, - WritersideTopicCreatePlacement.root, - ); - expect(controller.createdTopicRequest!.referenceTocPath, isNull); - expect(controller.createdTopicRequest!.referenceTopic, isNull); - expect(controller.createdTopicTreePath, p.join(root.path, 'guide.tree')); + if (!nativeCreation) { + expect(find.text(l10n.newTopic), findsOneWidget); + expect(find.byType(BusyMarkDialogShell), findsOneWidget); + expect( + find.descendant( + of: find.byType(BusyMarkDialogShell), + matching: find.byType(BusyMarkGroupedTextEntry), + ), + findsNWidgets(2), + ); + expect(find.text(l10n.topicPlacement), findsNothing); + } + if (!nativeCreation) { + final entries = find.byType(BusyMarkGroupedTextEntry); + final title = entries.at(0); + final name = entries.at(1); + expect( + tester.widget(title).controller!.text, + l10n.defaultNewTopicTitle, + ); + await tester.enterText(title, 'Hello World'); + await tester.pump(); + expect( + tester.widget(name).controller!.text, + 'hello-world.md', + ); + await tester.enterText(name, 'manual'); + await tester.enterText(title, 'Changed title'); + await tester.pump(); + expect( + tester.widget(name).controller!.text, + 'manual', + ); + for (final invalid in [ + '', + '../unsafe.md', + 'CON.md', + 'parent.md', + 'wrong.topic', + ]) { + await tester.enterText(name, invalid); + await tester.pump(); + expect( + tester + .widget( + find.byWidgetPredicate( + (widget) => + widget is BusyMarkDialogButton && + widget.label == l10n.tocOk, + ), + ) + .onPressed, + isNull, + ); + } + await tester.enterText(name, 'root-topic'); + await tester.enterText(title, ' '); + await tester.pump(); + expect(find.text(l10n.topicTitleRequired), findsOneWidget); + await tester.enterText(title, 'Created Root'); + await tester.pump(); + final pendingCreation = Completer(); + controller.createTopic = (_) => pendingCreation.future; + await tester.tap(find.text(l10n.tocOk)); + await tester.pump(); + expect( + tester + .widget(find.byType(BusyMarkDialogShell)) + .closable, + isFalse, + ); + expect( + tester.widget(title).enabled, + isFalse, + ); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pump(); + expect(find.byType(BusyMarkDialogShell), findsOneWidget); + final count = controller.createTopicCount; + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pump(); + expect(controller.createTopicCount, count); + pendingCreation.complete(false); + await tester.pumpAndSettle(); + expect(find.text(l10n.createWritersideTopicFailed), findsOneWidget); + expect( + tester.widget(title).controller!.text, + 'Created Root', + ); + expect( + tester.widget(name).controller!.text, + 'root-topic', + ); + controller.createTopic = null; + } + await acceptCreation(); - await openPopup(instanceSelector); - await tester.tap(apiInstance); - await tester.pumpAndSettle(); - expect(find.text('API'), findsOneWidget); + expect(controller.createdTopicRequest, isNotNull); + expect( + controller.createdTopicRequest!.placement, + WritersideTopicCreatePlacement.root, + ); + expect(controller.createdTopicRequest!.referenceTocPath, isNull); + expect(controller.createdTopicRequest!.referenceTopic, isNull); + expect( + controller.createdTopicTreePath, + p.join(root.path, 'guide.tree'), + ); - await openPopup(find.byTooltip(l10n.newTopic)); - await tester.tap(find.text(l10n.tocEmptyMdTopic)); - await tester.pumpAndSettle(); - await tester.tap(find.text(l10n.tocOk)); - await tester.pumpAndSettle(); - expect(controller.createdTopicTreePath, p.join(root.path, 'api.tree')); + await openPopup(instanceSelector); + await tester.tap(apiInstance); + await tester.pumpAndSettle(); + expect(find.text('API'), findsOneWidget); - await openPopup(instanceSelector); - await tester.tap(guideInstance); - await tester.pumpAndSettle(); + await openPopup(find.byTooltip(l10n.newTopic)); + await tester.tap(find.text(l10n.tocEmptyMdTopic)); + await tester.pumpAndSettle(); + await acceptCreation(); + expect(controller.createdTopicTreePath, p.join(root.path, 'api.tree')); - await tester.tap(find.text('Nested entry')); - await tester.pump(const Duration(milliseconds: 200)); - await tester.sendKeyDownEvent(LogicalKeyboardKey.delete); - await tester.pumpAndSettle(); - await tester.sendKeyUpEvent(LogicalKeyboardKey.delete); - expect( - controller.analyzedRemovalMode, - WritersideTopicRemovalMode.removeFromInstance, - ); - expect(find.text(l10n.removeTocElement), findsOneWidget); - expect(find.text(l10n.setRedirectTo), findsOneWidget); - expect(find.text(l10n.updateUsagesAutomatically), findsOneWidget); - expect(find.text(l10n.topicUsagesCount(1)), findsOneWidget); - expect(find.text(l10n.reviewUsages), findsOneWidget); - expect(find.text(l10n.removeAction), findsOneWidget); - await tester.tap(find.text(l10n.reviewUsages)); - await tester.pumpAndSettle(); - expect(find.text(l10n.doRefactor), findsOneWidget); - await tester.tap(find.text(l10n.doRefactor)); - await tester.pumpAndSettle(); - expect(controller.appliedTopicRemovalCount, 1); - expect(find.text(l10n.removeTocElement), findsNothing); + await openPopup(instanceSelector); + await tester.tap(guideInstance); + await tester.pumpAndSettle(); - for (final label in ['Ref entry', 'Origin entry']) { - await tester.tap(find.text(label)); + await tester.tap(find.text('Nested entry')); await tester.pump(const Duration(milliseconds: 200)); await tester.sendKeyDownEvent(LogicalKeyboardKey.delete); await tester.pumpAndSettle(); @@ -3932,235 +4121,283 @@ code WritersideTopicRemovalMode.removeFromInstance, ); expect(find.text(l10n.removeTocElement), findsOneWidget); - await tester.tap(find.text(l10n.cancel)); + expect(find.text(l10n.setRedirectTo), findsOneWidget); + expect(find.text(l10n.updateUsagesAutomatically), findsOneWidget); + expect(find.text(l10n.topicUsagesCount(1)), findsOneWidget); + expect(find.byType(Checkbox), findsNothing); + expect(find.byType(CheckboxListTile), findsNothing); + final removalControls = tester + .widgetList(find.byType(BusyMarkCheckbox)) + .toList(); + expect(removalControls, hasLength(2)); + removalControls.last.onChanged!(false); + await tester.pump(); + expect( + tester + .widgetList(find.byType(BusyMarkCheckbox)) + .last + .value, + isFalse, + ); + tester + .widgetList(find.byType(BusyMarkCheckbox)) + .last + .onChanged!(true); + await tester.pump(); + expect(find.text(l10n.reviewUsages), findsOneWidget); + expect(find.text(l10n.removeAction), findsOneWidget); + await tester.tap(find.text(l10n.reviewUsages)); await tester.pumpAndSettle(); - } + expect(find.text(l10n.doRefactor), findsOneWidget); + await tester.tap(find.text(l10n.doRefactor)); + await tester.pumpAndSettle(); + expect(controller.appliedTopicRemovalCount, 1); + expect(find.text(l10n.removeTocElement), findsNothing); - await tester.pumpAndSettle(); - await openPopup(find.text('Nested entry'), buttons: kSecondaryButton); + for (final label in ['Ref entry', 'Origin entry']) { + await tester.tap(find.text(label)); + await tester.pump(const Duration(milliseconds: 200)); + await tester.sendKeyDownEvent(LogicalKeyboardKey.delete); + await tester.pumpAndSettle(); + await tester.sendKeyUpEvent(LogicalKeyboardKey.delete); + expect( + controller.analyzedRemovalMode, + WritersideTopicRemovalMode.removeFromInstance, + ); + expect(find.text(l10n.removeTocElement), findsOneWidget); + await tester.tap(find.text(l10n.cancel)); + await tester.pumpAndSettle(); + } - for (final label in [ - l10n.copy, - l10n.aiRefineWithAi, - l10n.newSiblingTopic, - l10n.newChildTopic, - l10n.tocDuplicate, - l10n.tocCopySpecial, - l10n.tocEditTitleAction, - l10n.renameTopicFile, - l10n.cut, - l10n.pasteAfterTopic, - l10n.pasteAsChildTopic, - l10n.tocRemoveElementAction, - l10n.tocSetHomePage, - l10n.tocGoToElement('guide.tree'), - l10n.copyName, - l10n.copyPath, - l10n.openInFiles, - l10n.fileHistory, - ]) { - expect(popupMenuItem(label), findsOneWidget); - } - expect(popupMenuItem(l10n.addToGit), findsNothing); - expect(popupMenuItem(l10n.safeDeleteTopicFile), findsNothing); - expect(popupMenuItem(l10n.delete), findsNothing); - expect(popupMenuItem('Preview Topic'), findsNothing); - await tester.sendKeyEvent(LogicalKeyboardKey.escape); - await tester.pumpAndSettle(); + await tester.pumpAndSettle(); + await openPopup(find.text('Nested entry'), buttons: kSecondaryButton); + + for (final label in [ + l10n.copy, + l10n.aiRefineWithAi, + l10n.newSiblingTopic, + l10n.newChildTopic, + l10n.tocDuplicate, + l10n.tocCopySpecial, + l10n.tocEditTitleAction, + l10n.renameTopicFile, + l10n.cut, + l10n.pasteAfterTopic, + l10n.pasteAsChildTopic, + l10n.tocRemoveElementAction, + l10n.tocSetHomePage, + l10n.tocGoToElement('guide.tree'), + l10n.copyName, + l10n.copyPath, + l10n.openInFiles, + l10n.fileHistory, + ]) { + expect(popupMenuItem(label), findsOneWidget); + } + expect(popupMenuItem(l10n.addToGit), findsNothing); + expect(popupMenuItem(l10n.safeDeleteTopicFile), findsNothing); + expect(popupMenuItem(l10n.delete), findsNothing); + expect(popupMenuItem('Preview Topic'), findsNothing); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); - await tester.sendKeyEvent(LogicalKeyboardKey.contextMenu); - await tester.pumpAndSettle(); - expect(popupMenuItem(l10n.openInFiles), findsOneWidget); - expect(popupMenuItem(l10n.addToGit), findsNothing); - expect(popupMenuItem(l10n.fileHistory), findsOneWidget); - await tester.sendKeyEvent(LogicalKeyboardKey.escape); - await tester.pumpAndSettle(); + await tester.sendKeyEvent(LogicalKeyboardKey.contextMenu); + await tester.pumpAndSettle(); + expect(popupMenuItem(l10n.openInFiles), findsOneWidget); + expect(popupMenuItem(l10n.addToGit), findsNothing); + expect(popupMenuItem(l10n.fileHistory), findsOneWidget); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); - await openPopup(find.text('Nested entry'), buttons: kSecondaryButton); - await tester.tap(find.text(l10n.newChildTopic)); - await tester.pumpAndSettle(); - await tester.tap(find.text(l10n.tocEmptyXmlTopic)); - await tester.pumpAndSettle(); - expect(find.text(l10n.newTopic), findsOneWidget); - await tester.tap(find.text(l10n.tocOk)); - await tester.pumpAndSettle(); - expect( - controller.createdTopicRequest!.placement, - WritersideTopicCreatePlacement.child, - ); - expect(controller.createdTopicRequest!.referenceTocPath, [0, 0]); - expect(controller.createdTopicRequest!.referenceTopic, 'nested.md'); - expect(controller.createdTopicRequest!.referenceTocIdentity, isNotNull); + await openPopup(find.text('Nested entry'), buttons: kSecondaryButton); + await tester.tap(find.text(l10n.newChildTopic)); + await tester.pumpAndSettle(); + await tester.tap(find.text(l10n.tocEmptyXmlTopic)); + await tester.pumpAndSettle(); + if (!nativeCreation) expect(find.text(l10n.newTopic), findsOneWidget); + await acceptCreation(); + expect( + controller.createdTopicRequest!.placement, + WritersideTopicCreatePlacement.child, + ); + expect(controller.createdTopicRequest!.referenceTocPath, [0, 0]); + expect(controller.createdTopicRequest!.referenceTopic, 'nested.md'); + expect(controller.createdTopicRequest!.referenceTocIdentity, isNotNull); - await openPopup(find.text('Nested entry'), buttons: kSecondaryButton); - await tester.tap(find.text(l10n.newSiblingTopic)); - await tester.pumpAndSettle(); - await tester.tap(find.text(l10n.tocEmptyMdTopic)); - await tester.pumpAndSettle(); - expect(find.text(l10n.newTopic), findsOneWidget); - await tester.tap(find.text(l10n.tocOk)); - await tester.pumpAndSettle(); - expect( - controller.createdTopicRequest!.placement, - WritersideTopicCreatePlacement.sibling, - ); - expect(controller.createdTopicRequest!.referenceTocPath, [0, 0]); + await openPopup(find.text('Nested entry'), buttons: kSecondaryButton); + await tester.tap(find.text(l10n.newSiblingTopic)); + await tester.pumpAndSettle(); + await tester.tap(find.text(l10n.tocEmptyMdTopic)); + await tester.pumpAndSettle(); + if (!nativeCreation) expect(find.text(l10n.newTopic), findsOneWidget); + await acceptCreation(); + expect( + controller.createdTopicRequest!.placement, + WritersideTopicCreatePlacement.sibling, + ); + expect(controller.createdTopicRequest!.referenceTocPath, [0, 0]); + + // Exercise actual row drag recognizers, including the pointer-relative + // child/before zones and the selection captured for a multi-item drag. + Future dragBetween(Finder source, Offset destination) async { + final gesture = await tester.startGesture( + tester.getCenter(source), + kind: PointerDeviceKind.mouse, + ); + await gesture.moveBy(const Offset(22, 0)); + await tester.pump(); + await gesture.moveTo(destination); + await tester.pump(const Duration(milliseconds: 700)); + await gesture.up(); + await tester.pumpAndSettle(); + } - // Exercise actual row drag recognizers, including the pointer-relative - // child/before zones and the selection captured for a multi-item drag. - Future dragBetween(Finder source, Offset destination) async { - final gesture = await tester.startGesture( - tester.getCenter(source), - kind: PointerDeviceKind.mouse, + await dragBetween( + find.text('Loose'), + tester.getCenter(find.text('Target')), + ); + expect(controller.dragRequest!.sources.single.sourcePath, [1]); + expect(controller.dragRequest!.referencePath, [2]); + expect( + controller.dragRequest!.placement, + WritersideTopicCreatePlacement.child, + ); + controller.dragRequest = null; + await dragBetween( + find.text('Parent'), + tester.getCenter(find.text('Nested entry')), + ); + expect( + controller.dragRequest, + isNull, + reason: 'Ancestor cannot enter its descendant', + ); + await openPopup(find.text('Target'), buttons: kSecondaryButton); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.tap(find.text('Loose')); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(kDoubleTapTimeout); + await tester.pumpAndSettle(); + final parentRow = find.byKey( + const ValueKey('workspace-sidebar-toc-row-0'), + ); + await tester.tap( + find.descendant( + of: parentRow, + matching: find.byType(AnimatedRotation), + ), + ); + await tester.pump(kDoubleTapTimeout); + await tester.pumpAndSettle(); + expect(find.text('Nested entry'), findsNothing); + await dragBetween(find.text('Loose'), tester.getCenter(parentRow)); + expect( + find.text('Nested entry'), + findsOneWidget, + reason: 'Hover expands a collapsed destination during the drag', + ); + expect( + controller.dragRequest!.placement, + WritersideTopicCreatePlacement.child, + ); + await openPopup(find.text('Target'), buttons: kSecondaryButton); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.tap(find.text('Loose')); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(kDoubleTapTimeout); + await tester.pumpAndSettle(); + await dragBetween( + find.text('Loose'), + tester.getTopLeft(parentRow) + const Offset(80, 3), + ); + expect( + controller.dragRequest!.sources.map((entry) => entry.sourcePath), + [ + [1], + [2], + ], + ); + expect(controller.dragRequest!.referencePath, [0]); + expect(controller.dragRequest!.beforeReference, isTrue); + await openPopup(find.text('Nested entry'), buttons: kSecondaryButton); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.tap( + find.byKey(const ValueKey('workspace-sidebar-toc-row-1')), + ); + await tester.tap( + find.byKey(const ValueKey('workspace-sidebar-toc-row-2')), ); - await gesture.moveBy(const Offset(22, 0)); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); await tester.pump(); - await gesture.moveTo(destination); - await tester.pump(const Duration(milliseconds: 700)); - await gesture.up(); + await openPopup(find.text('Target'), buttons: kSecondaryButton); + for (final label in [ + l10n.copy, + l10n.cut, + l10n.aiRefineWithAi, + l10n.removeTocElements, + ]) { + expect(popupMenuItem(label), findsOneWidget); + } + expect(popupMenuItem(l10n.delete), findsNothing); + expect(popupMenuItem(l10n.safeDeleteTopicFile), findsNothing); + expect(find.text(l10n.newSiblingTopic), findsNothing); + expect(find.text(l10n.copyName), findsNothing); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); await tester.pumpAndSettle(); - } - - await dragBetween( - find.text('Loose'), - tester.getCenter(find.text('Target')), - ); - expect(controller.dragRequest!.sources.single.sourcePath, [1]); - expect(controller.dragRequest!.referencePath, [2]); - expect( - controller.dragRequest!.placement, - WritersideTopicCreatePlacement.child, - ); - controller.dragRequest = null; - await dragBetween( - find.text('Parent'), - tester.getCenter(find.text('Nested entry')), - ); - expect( - controller.dragRequest, - isNull, - reason: 'Ancestor cannot enter its descendant', - ); - await openPopup(find.text('Target'), buttons: kSecondaryButton); - await tester.sendKeyEvent(LogicalKeyboardKey.escape); - await tester.pumpAndSettle(); - await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); - await tester.tap(find.text('Loose')); - await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); - await tester.pump(kDoubleTapTimeout); - await tester.pumpAndSettle(); - final parentRow = find.byKey( - const ValueKey('workspace-sidebar-toc-row-0'), - ); - await tester.tap( - find.descendant(of: parentRow, matching: find.byType(AnimatedRotation)), - ); - await tester.pump(kDoubleTapTimeout); - await tester.pumpAndSettle(); - expect(find.text('Nested entry'), findsNothing); - await dragBetween(find.text('Loose'), tester.getCenter(parentRow)); - expect( - find.text('Nested entry'), - findsOneWidget, - reason: 'Hover expands a collapsed destination during the drag', - ); - expect( - controller.dragRequest!.placement, - WritersideTopicCreatePlacement.child, - ); - await openPopup(find.text('Target'), buttons: kSecondaryButton); - await tester.sendKeyEvent(LogicalKeyboardKey.escape); - await tester.pumpAndSettle(); - await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); - await tester.tap(find.text('Loose')); - await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); - await tester.pump(kDoubleTapTimeout); - await tester.pumpAndSettle(); - await dragBetween( - find.text('Loose'), - tester.getTopLeft(parentRow) + const Offset(80, 3), - ); - expect(controller.dragRequest!.sources.map((entry) => entry.sourcePath), [ - [1], - [2], - ]); - expect(controller.dragRequest!.referencePath, [0]); - expect(controller.dragRequest!.beforeReference, isTrue); - await openPopup(find.text('Nested entry'), buttons: kSecondaryButton); - await tester.sendKeyEvent(LogicalKeyboardKey.escape); - await tester.pumpAndSettle(); - - await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); - await tester.tap( - find.byKey(const ValueKey('workspace-sidebar-toc-row-1')), - ); - await tester.tap( - find.byKey(const ValueKey('workspace-sidebar-toc-row-2')), - ); - await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); - await tester.pump(); - await openPopup(find.text('Target'), buttons: kSecondaryButton); - for (final label in [ - l10n.copy, - l10n.cut, - l10n.aiRefineWithAi, - l10n.removeTocElements, - ]) { - expect(popupMenuItem(label), findsOneWidget); - } - expect(popupMenuItem(l10n.delete), findsNothing); - expect(popupMenuItem(l10n.safeDeleteTopicFile), findsNothing); - expect(find.text(l10n.newSiblingTopic), findsNothing); - expect(find.text(l10n.copyName), findsNothing); - await tester.sendKeyEvent(LogicalKeyboardKey.escape); - await tester.pumpAndSettle(); - await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); - await tester.tap( - find.byKey(const ValueKey('workspace-sidebar-toc-row-1')), - ); - await tester.tap( - find.byKey(const ValueKey('workspace-sidebar-toc-row-1')), - ); - await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); - await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); - await tester.tap( - find.byKey(const ValueKey('workspace-sidebar-toc-row-2')), - ); - await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); - await tester.pump(); - await openPopup(find.text('Target'), buttons: kSecondaryButton); - expect(find.text(l10n.aiRefineWithAi), findsOneWidget); - expect(find.text(l10n.newSiblingTopic), findsNothing); - await tester.sendKeyEvent(LogicalKeyboardKey.escape); - await tester.pumpAndSettle(); + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.tap( + find.byKey(const ValueKey('workspace-sidebar-toc-row-1')), + ); + await tester.tap( + find.byKey(const ValueKey('workspace-sidebar-toc-row-1')), + ); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + await tester.tap( + find.byKey(const ValueKey('workspace-sidebar-toc-row-2')), + ); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + await tester.pump(); + await openPopup(find.text('Target'), buttons: kSecondaryButton); + expect(find.text(l10n.aiRefineWithAi), findsOneWidget); + expect(find.text(l10n.newSiblingTopic), findsNothing); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); - await openPopup(find.text('Nested entry'), buttons: kSecondaryButton); + await openPopup(find.text('Nested entry'), buttons: kSecondaryButton); - await tester.ensureVisible(find.text(l10n.cut)); - await tester.pumpAndSettle(); - await tester.ensureVisible(find.text(l10n.cut)); - await tester.pumpAndSettle(); - await tester.tap(find.text(l10n.cut)); - await tester.pumpAndSettle(); - await openPopup(find.text('Parent'), buttons: kSecondaryButton); - expect( - find.byWidgetPredicate( - (widget) => - widget is MenuItemButton && - widget.child is Text && - (widget.child! as Text).data == l10n.pasteAfterTopic && - widget.onPressed != null, - ), - findsOneWidget, - ); + await tester.ensureVisible(find.text(l10n.cut)); + await tester.pumpAndSettle(); + await tester.ensureVisible(find.text(l10n.cut)); + await tester.pumpAndSettle(); + await tester.tap(find.text(l10n.cut)); + await tester.pumpAndSettle(); + await openPopup(find.text('Parent'), buttons: kSecondaryButton); + expect( + find.byWidgetPredicate( + (widget) => + widget is MenuItemButton && + widget.child is Text && + (widget.child! as Text).data == l10n.pasteAfterTopic && + widget.onPressed != null, + ), + findsOneWidget, + ); - await tester.sendKeyEvent(LogicalKeyboardKey.escape); - await tester.pumpAndSettle(); - File( - p.join(root.path, 'topics', 'inserted.md'), - ).writeAsStringSync('# Inserted\n'); - File(p.join(root.path, 'guide.tree')).writeAsStringSync(''' + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + File( + p.join(root.path, 'topics', 'inserted.md'), + ).writeAsStringSync('# Inserted\n'); + File(p.join(root.path, 'guide.tree')).writeAsStringSync(''' @@ -4170,192 +4407,196 @@ code '''); - final refreshedWorkspace = (await tester.runAsync( - () => const WorkspaceService().openPath(root.path), - ))!; - controller.replaceWorkspace(refreshedWorkspace); - await tester.pumpAndSettle(); + final refreshedWorkspace = (await tester.runAsync( + () => const WorkspaceService().openPath(root.path), + ))!; + controller.replaceWorkspace(refreshedWorkspace); + await tester.pumpAndSettle(); - await openPopup(find.text('Parent'), buttons: kSecondaryButton); - expect( - find.byWidgetPredicate( + await openPopup(find.text('Parent'), buttons: kSecondaryButton); + expect( + find.byWidgetPredicate( + (widget) => + widget is MenuItemButton && + widget.child is Text && + (widget.child! as Text).data == l10n.pasteAfterTopic && + widget.onPressed == null, + ), + findsOneWidget, + ); + + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + await openPopup(find.text('Loose'), buttons: kSecondaryButton); + await tester.ensureVisible(find.text(l10n.cut)); + await tester.pumpAndSettle(); + await tester.tap(find.text(l10n.cut)); + await tester.pumpAndSettle(); + await openPopup(find.text('Target'), buttons: kSecondaryButton); + expect( + find.byWidgetPredicate( + (widget) => + widget is MenuItemButton && + widget.child is Text && + (widget.child! as Text).data == l10n.pasteAsChildTopic && + widget.onPressed != null, + ), + findsOneWidget, + ); + await tester.ensureVisible(find.text(l10n.pasteAsChildTopic)); + await tester.pumpAndSettle(); + await tester.tap(find.text(l10n.pasteAsChildTopic)); + await tester.pumpAndSettle(); + + expect( + controller.movedTopicPlacement, + WritersideTopicCreatePlacement.child, + ); + expect(controller.movedTopicSourcePath, [1]); + expect(controller.movedTopicReferencePath, [2]); + expect(find.text('Loose'), findsOneWidget); + final movedRoots = controller + .state + .workspace! + .writersideModule! + .instances + .first + .tocRoots; + final targetNode = movedRoots.singleWhere( + (node) => node.topicFileName == 'target.md', + ); + expect(targetNode.children.single.topicFileName, 'loose.md'); + + await openPopup(find.byTooltip(l10n.mainMenu)); + final mainMenuExportItem = find.byWidgetPredicate( (widget) => - widget is MenuItemButton && - widget.child is Text && - (widget.child! as Text).data == l10n.pasteAfterTopic && - widget.onPressed == null, - ), - findsOneWidget, - ); + widget is BusyMarkPopupMenuItem && + widget.label == l10n.export, + ); + expect(mainMenuExportItem, findsOneWidget); + expect( + tester + .widget>(mainMenuExportItem) + .enabled, + isTrue, + ); + await tester.tap(mainMenuExportItem); + for (var index = 0; index < 20; index++) { + await tester.pump(const Duration(milliseconds: 100)); + if (find.byType(BusyMarkModalEditorSurface).evaluate().isNotEmpty) { + break; + } + } + expect(find.byType(BusyMarkModalEditorSurface), findsOneWidget); + expect(find.byType(BusyMarkModalEditorScaffold), findsOneWidget); + expect(find.byType(BusyMarkComboRow), findsOneWidget); + expect(find.byType(SegmentedButton), findsNothing); + expect(find.text(l10n.pdfPageSize), findsOneWidget); + expect(find.text(l10n.pdfIncludePageNumbers), findsOneWidget); + await tester.tap(find.text(l10n.cancel)); + await tester.pumpAndSettle(); - await tester.sendKeyEvent(LogicalKeyboardKey.escape); - await tester.pumpAndSettle(); - await openPopup(find.text('Loose'), buttons: kSecondaryButton); - await tester.ensureVisible(find.text(l10n.cut)); - await tester.pumpAndSettle(); - await tester.tap(find.text(l10n.cut)); - await tester.pumpAndSettle(); - await openPopup(find.text('Target'), buttons: kSecondaryButton); - expect( - find.byWidgetPredicate( + await openPopup(find.byTooltip(l10n.tocActions)); + final exportItem = find.byWidgetPredicate( (widget) => - widget is MenuItemButton && - widget.child is Text && - (widget.child! as Text).data == l10n.pasteAsChildTopic && - widget.onPressed != null, - ), - findsOneWidget, - ); - await tester.ensureVisible(find.text(l10n.pasteAsChildTopic)); - await tester.pumpAndSettle(); - await tester.tap(find.text(l10n.pasteAsChildTopic)); - await tester.pumpAndSettle(); + widget is BusyMarkPopupMenuItem && + widget.label == l10n.export, + ); + expect(find.text(l10n.tocSynchronize), findsOneWidget); + expect(find.text(l10n.newInstance), findsOneWidget); + expect(find.text(l10n.newTocLibrary), findsOneWidget); + expect(find.text(l10n.editInstance), findsOneWidget); + expect(find.text(l10n.openTocFile), findsOneWidget); + expect(exportItem, findsNothing); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); - expect( - controller.movedTopicPlacement, - WritersideTopicCreatePlacement.child, - ); - expect(controller.movedTopicSourcePath, [1]); - expect(controller.movedTopicReferencePath, [2]); - expect(find.text('Loose'), findsOneWidget); - final movedRoots = controller - .state - .workspace! - .writersideModule! - .instances - .first - .tocRoots; - final targetNode = movedRoots.singleWhere( - (node) => node.topicFileName == 'target.md', - ); - expect(targetNode.children.single.topicFileName, 'loose.md'); - - await openPopup(find.byTooltip(l10n.mainMenu)); - final mainMenuExportItem = find.byWidgetPredicate( - (widget) => - widget is BusyMarkPopupMenuItem && - widget.label == l10n.export, - ); - expect(mainMenuExportItem, findsOneWidget); - expect( - tester - .widget>(mainMenuExportItem) - .enabled, - isTrue, - ); - await tester.tap(mainMenuExportItem); - for (var index = 0; index < 20; index++) { - await tester.pump(const Duration(milliseconds: 100)); - if (find.byType(BusyMarkModalEditorSurface).evaluate().isNotEmpty) { - break; + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyE); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + for (var index = 0; index < 20; index++) { + await tester.pump(const Duration(milliseconds: 100)); + if (find.byType(BusyMarkModalEditorSurface).evaluate().isNotEmpty) { + break; + } } - } - expect(find.byType(BusyMarkModalEditorSurface), findsOneWidget); - expect(find.byType(BusyMarkModalEditorScaffold), findsOneWidget); - expect(find.byType(BusyMarkComboRow), findsOneWidget); - expect(find.byType(SegmentedButton), findsNothing); - expect(find.text(l10n.pdfPageSize), findsOneWidget); - expect(find.text(l10n.pdfIncludePageNumbers), findsOneWidget); - await tester.tap(find.text(l10n.cancel)); - await tester.pumpAndSettle(); - - await openPopup(find.byTooltip(l10n.tocActions)); - final exportItem = find.byWidgetPredicate( - (widget) => - widget is BusyMarkPopupMenuItem && - widget.label == l10n.export, - ); - expect(find.text(l10n.tocSynchronize), findsOneWidget); - expect(find.text(l10n.newInstance), findsOneWidget); - expect(find.text(l10n.newTocLibrary), findsOneWidget); - expect(find.text(l10n.editInstance), findsOneWidget); - expect(find.text(l10n.openTocFile), findsOneWidget); - expect(exportItem, findsNothing); - await tester.sendKeyEvent(LogicalKeyboardKey.escape); - await tester.pumpAndSettle(); + expect(find.byType(BusyMarkModalEditorSurface), findsOneWidget); + final formatRow = tester.widget>( + find.byType(BusyMarkComboRow), + ); + expect(formatRow.title, l10n.exportFormat); + expect(formatRow.values, ExportFormat.values); + expect(formatRow.selected, ExportFormat.pdf); + await tester.tap(find.text(l10n.cancel)); + await tester.pumpAndSettle(); + await openPopup(instanceSelectorTrigger, buttons: kSecondaryButton); + await tester.tap(find.text(l10n.openTocFile)); + await tester.pumpAndSettle(); + expect(controller.openedFilePath, p.join(root.path, 'guide.tree')); + expect( + controller.requestedEditorMode, + DocumentViewModePreference.source, + ); - await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); - await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); - await tester.sendKeyEvent(LogicalKeyboardKey.keyE); - await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); - await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); - for (var index = 0; index < 20; index++) { + File(p.join(root.path, 'guide.tree')).writeAsStringSync( + '' + '' + '${[for (var i = 0; i < 40; i++) ''].join()}' + '' + '', + ); + controller.replaceWorkspace( + (await tester.runAsync( + () => const WorkspaceService().openPath(root.path), + ))!, + ); + await tester.pumpAndSettle(); + controller.openedFilePath = null; + await tester.tap(find.text('Drag source')); await tester.pump(const Duration(milliseconds: 100)); - if (find.byType(BusyMarkModalEditorSurface).evaluate().isNotEmpty) { - break; + await tester.tap(find.text('Drag source')); + await tester.pumpAndSettle(); + expect( + controller.openedFilePath, + p.join(root.path, 'guide.tree'), + reason: 'An empty-group double-click opens its source tree', + ); + final viewport = tester.getRect( + find.ancestor( + of: find.text('Drag source'), + matching: find.byType(ScrollablePositionedList), + ), + ); + final edge = Offset(viewport.center.dx, viewport.bottom - 16); + final distant = find.text('Distant destination').hitTestable(); + expect(distant, findsNothing); + final scrollDrag = await tester.startGesture( + tester.getCenter(find.text('Drag source')), + kind: PointerDeviceKind.mouse, + ); + await scrollDrag.moveBy(const Offset(22, 0)); + await tester.pump(); + for (var step = 0; step < 60 && distant.evaluate().isEmpty; step++) { + await scrollDrag.moveTo(edge + Offset(step.isEven ? 0 : 1, 0)); + await tester.pump(const Duration(milliseconds: 140)); } - } - expect(find.byType(BusyMarkModalEditorSurface), findsOneWidget); - final formatRow = tester.widget>( - find.byType(BusyMarkComboRow), - ); - expect(formatRow.title, l10n.exportFormat); - expect(formatRow.values, ExportFormat.values); - expect(formatRow.selected, ExportFormat.pdf); - await tester.tap(find.text(l10n.cancel)); - await tester.pumpAndSettle(); - await openPopup(instanceSelectorTrigger, buttons: kSecondaryButton); - await tester.tap(find.text(l10n.openTocFile)); - await tester.pumpAndSettle(); - expect(controller.openedFilePath, p.join(root.path, 'guide.tree')); - expect(controller.requestedEditorMode, DocumentViewModePreference.source); - - File(p.join(root.path, 'guide.tree')).writeAsStringSync( - '' - '' - '${[for (var i = 0; i < 40; i++) ''].join()}' - '' - '', - ); - controller.replaceWorkspace( - (await tester.runAsync( - () => const WorkspaceService().openPath(root.path), - ))!, - ); - await tester.pumpAndSettle(); - controller.openedFilePath = null; - await tester.tap(find.text('Drag source')); - await tester.pump(const Duration(milliseconds: 100)); - await tester.tap(find.text('Drag source')); - await tester.pumpAndSettle(); - expect( - controller.openedFilePath, - p.join(root.path, 'guide.tree'), - reason: 'An empty-group double-click opens its source tree', - ); - final viewport = tester.getRect( - find.ancestor( - of: find.text('Drag source'), - matching: find.byType(ScrollablePositionedList), - ), - ); - final edge = Offset(viewport.center.dx, viewport.bottom - 16); - final distant = find.text('Distant destination').hitTestable(); - expect(distant, findsNothing); - final scrollDrag = await tester.startGesture( - tester.getCenter(find.text('Drag source')), - kind: PointerDeviceKind.mouse, - ); - await scrollDrag.moveBy(const Offset(22, 0)); - await tester.pump(); - for (var step = 0; step < 60 && distant.evaluate().isEmpty; step++) { - await scrollDrag.moveTo(edge + Offset(step.isEven ? 0 : 1, 0)); - await tester.pump(const Duration(milliseconds: 140)); - } - expect( - distant, - findsOneWidget, - reason: - 'Edge autoscroll reaches a destination outside the initial viewport', - ); - await scrollDrag.moveTo(tester.getCenter(distant)); - await tester.pump(const Duration(milliseconds: 20)); - await scrollDrag.up(); - await tester.pumpAndSettle(); - expect(controller.dragRequest!.sources.single.sourcePath, [0]); - expect(controller.dragRequest!.referencePath, [41]); - }, - ); + expect( + distant, + findsOneWidget, + reason: + 'Edge autoscroll reaches a destination outside the initial viewport', + ); + await scrollDrag.moveTo(tester.getCenter(distant)); + await tester.pump(const Duration(milliseconds: 20)); + await scrollDrag.up(); + await tester.pumpAndSettle(); + expect(controller.dragRequest!.sources.single.sourcePath, [0]); + expect(controller.dragRequest!.referencePath, [41]); + }, + ); + } testWidgets( 'Writerside topic rename uses Preview, Do Refactor, and shared entry points', @@ -4460,7 +4701,7 @@ code Future enterRename(String fileName) async { final field = find.descendant( of: find.byType(BusyMarkDialogShell), - matching: find.byType(TextField), + matching: find.byType(EditableText), ); expect(field, findsOneWidget); await tester.enterText(field, fileName); @@ -4583,6 +4824,53 @@ code final sourceController = tester .widget(sourceField) .controller!; + final sourceEditor = tester.widget( + find.byType(BusyMarkSourceEditor), + ); + controller.editorIndexOverride = const WritersideProjectIndex( + symbols: [], + references: [], + diagnostics: [], + ); + sourceEditor.onSymbolAction!( + SourceSymbolAction.usages, + topicSource.length - 1, + ); + await tester.pumpAndSettle(); + expect(find.byType(BusyMarkDialogShell), findsOneWidget); + expect(find.text(l10n.noResults), findsOneWidget); + expect(find.byType(AlertDialog), findsNothing); + await tester.tap(find.text(l10n.close)); + await tester.pumpAndSettle(); + controller.editorIndexOverride = null; + + sourceEditor.onSymbolAction!( + SourceSymbolAction.usages, + topicSource.indexOf('guide') + 1, + ); + await tester.pumpAndSettle(); + expect(find.text('${l10n.findUsages}: guide'), findsOneWidget); + expect(find.byType(BusyMarkDialogShell), findsOneWidget); + expect(find.byType(BusyMarkActionRow), findsWidgets); + expect(find.text('links.md'), findsWidgets); + await tester.tap(find.text(l10n.close)); + await tester.pumpAndSettle(); + + sourceEditor.onSymbolAction!( + SourceSymbolAction.rename, + topicSource.indexOf('install') + 1, + ); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextFormField), 'invalid!'); + await tester.tap(find.text(l10n.rename)); + await tester.pumpAndSettle(); + expect(find.byType(BusyMarkDialogShell), findsOneWidget); + expect(find.text(l10n.cannotRenameSymbol), findsOneWidget); + expect(find.byType(AlertDialog), findsNothing); + expect(controller.state.activeText, topicSource); + await tester.tap(find.text(l10n.close)); + await tester.pumpAndSettle(); + sourceController.selection = TextSelection.collapsed( offset: topicSource.indexOf('guide') + 1, ); @@ -4599,6 +4887,12 @@ code ); await pressShiftF6(); expect(find.text('${l10n.rename}: install'), findsOneWidget); + expect(find.byType(BusyMarkModalEditorScaffold), findsOneWidget); + expect(find.byType(Dialog), findsOneWidget); + expect(find.byType(AlertDialog), findsNothing); + await tester.tapAt(const Offset(1, 1)); + await tester.pump(); + expect(find.byType(BusyMarkModalEditorScaffold), findsOneWidget); await tester.enterText(find.byType(TextFormField), 'renamed-install'); await tester.tap(find.text(l10n.rename)); await tester.pumpAndSettle(); @@ -4775,7 +5069,7 @@ code await tester.pumpAndSettle(); final renameField = find.descendant( of: find.byType(BusyMarkDialogShell), - matching: find.byType(TextField), + matching: find.byType(EditableText), ); await tester.enterText(renameField, 'setup.md'); await tester.tap(find.text(l10n.tocRefactorMenu)); @@ -11847,6 +12141,30 @@ Before [![Inline logo](inline-logo.png)](inline-guide.md) after. () => find.text(l10n.applyReplacements).evaluate().isNotEmpty, ); expect(find.text('cat → dog'), findsNWidgets(3)); + expect(find.byType(CheckboxListTile), findsNothing); + final controls = find.byType(BusyMarkCheckbox); + expect(controls, findsNWidgets(5)); + tester.widget(controls.at(1)).onChanged!(false); + await tester.pump(); + expect(tester.widget(controls.first).value, isNull); + tester.widget(controls.first).onChanged!(false); + tester.widget(controls.at(3)).onChanged!(false); + await tester.pump(); + final apply = find.byWidgetPredicate( + (widget) => + widget is BusyMarkModalEditorScaffold && + widget.saveLabel == l10n.applyReplacements, + ); + expect(tester.widget(apply).onSave, isNull); + tester.widget(controls.at(2)).onChanged!(true); + await tester.pump(); + expect(tester.widget(controls.first).value, isNull); + expect(tester.widget(controls.at(1)).value, isFalse); + expect(tester.widget(controls.at(2)).value, isTrue); + expect( + tester.widget(apply).onSave, + isNotNull, + ); await tester.pumpAndSettle(); await tester.tap(find.text(l10n.cancel)); await tester.pumpAndSettle(); @@ -12922,6 +13240,8 @@ class _MutableWorkspaceController extends WorkspaceController { _MutableWorkspaceController(this.initialState); final WorkspaceState initialState; + Future Function(WritersideTopicCreateRequest)? createTopic; + int createTopicCount = 0; WritersideTopicCreateRequest? createdTopicRequest; String? createdTopicTreePath; WritersideTopicCreatePlacement? movedTopicPlacement; @@ -13035,9 +13355,11 @@ class _MutableWorkspaceController extends WorkspaceController { @override Future flushPersistence() async {} + WritersideProjectIndex? editorIndexOverride; + @override Future writersideEditorIndex() async => - state.workspace?.writersideProject?.index; + editorIndexOverride ?? state.workspace?.writersideProject?.index; @override Future applyWritersideRename(List edits) async { @@ -13097,9 +13419,10 @@ class _MutableWorkspaceController extends WorkspaceController { String? instanceTreePath, String? initialSource, }) async { + createTopicCount++; createdTopicRequest = request; createdTopicTreePath = instanceTreePath; - return true; + return createTopic?.call(request) ?? true; } @override diff --git a/test/src/busymark_dialogs_test.dart b/test/src/busymark_dialogs_test.dart index 0f40e993..6895b3dc 100644 --- a/test/src/busymark_dialogs_test.dart +++ b/test/src/busymark_dialogs_test.dart @@ -11,6 +11,72 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { + testWidgets('native modal coordinates its lifetime and restores focus', ( + tester, + ) async { + const channel = MethodChannel('com.busymark.test/native-modal'); + final depths = []; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, ( + call, + ) async { + if (call.method == 'setModalBarrierDepth') { + depths.add(call.arguments as int); + } + return call.method == 'initialize' ? true : null; + }); + addTearDown(() { + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + null, + ); + }); + final headerBar = LinuxHeaderBarService(channel: channel); + await headerBar.initialize(); + final previousFocus = FocusNode(); + addTearDown(previousFocus.dispose); + late BuildContext dialogContext; + await tester.pumpWidget( + ProviderScope( + overrides: [linuxHeaderBarServiceProvider.overrideWithValue(headerBar)], + child: MaterialApp( + home: Builder( + builder: (context) { + dialogContext = context; + return Focus(focusNode: previousFocus, child: const SizedBox()); + }, + ), + ), + ), + ); + previousFocus.requestFocus(); + await tester.pump(); + final completion = Completer(); + final result = showBusyMarkNativeDialog( + dialogContext, + showDialog: () => completion.future, + ); + await tester.pumpAndSettle(); + expect(depths, [1]); + expect(find.byType(Dialog), findsNothing); + previousFocus.unfocus(); + await tester.pump(); + completion.complete(true); + await tester.pumpAndSettle(); + expect(await result, isTrue); + expect(depths, [1, 0]); + expect(previousFocus.hasFocus, isTrue); + + final failure = showBusyMarkNativeDialog( + dialogContext, + showDialog: () async => throw PlatformException(code: 'host-failed'), + ); + final checked = expectLater(failure, throwsA(isA())); + await tester.pumpAndSettle(); + await checked; + expect(depths, [1, 0, 1, 0]); + expect(previousFocus.hasFocus, isTrue); + }); + testWidgets('modal dialogs stop app and editor shortcuts', (tester) async { const channel = MethodChannel('com.busymark.test/modal-shortcuts'); tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, ( diff --git a/test/src/local_history_controller_test.dart b/test/src/local_history_controller_test.dart index 8452d00f..44a5295b 100644 --- a/test/src/local_history_controller_test.dart +++ b/test/src/local_history_controller_test.dart @@ -1201,10 +1201,10 @@ void main() { ), ); await tester.pump(); - final dialogContext = tester.element(find.byType(AlertDialog)); + final dialogContext = tester.element(find.byType(BusyMarkDialogShell)); await tester.tap( find.descendant( - of: find.byType(AlertDialog), + of: find.byType(BusyMarkDialogShell), matching: find.text(AppLocalizations.of(dialogContext).delete), ), ); diff --git a/test/src/native_writerside_dialog_service_test.dart b/test/src/native_writerside_dialog_service_test.dart index a08cdc30..80736c69 100644 --- a/test/src/native_writerside_dialog_service_test.dart +++ b/test/src/native_writerside_dialog_service_test.dart @@ -1,3 +1,10 @@ +import 'package:busymark/l10n/generated/app_localizations_en.dart'; +import 'package:busymark/src/core/path_utils.dart'; +import 'dart:async'; +import 'package:busymark/l10n/generated/app_localizations_ar.dart'; +import 'package:busymark/src/workspace/presentation/writerside_topic_creation_form.dart'; +import 'package:busymark/src/writerside/writerside_model.dart'; +import '../support/native_writerside_dialog_host.dart'; import 'package:busymark/src/platform/native_writerside_dialog_service.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -5,6 +12,154 @@ import 'package:flutter_test/flutter_test.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); + for (final format in WritersideTopicFormat.values) { + test( + 'New Topic $format preserves filename, slug, case and Unicode rules', + () { + final form = WritersideTopicCreationForm( + format: format, + existingIds: {'taken', 'Case'}, + l10n: AppLocalizationsEn(), + ); + final extension = format == WritersideTopicFormat.markdown + ? '.md' + : '.topic'; + expect(form.titleError(' '), form.l10n.topicTitleRequired); + expect(form.titleError(' Title '), isNull); + expect(form.fileNameForTitle('Hello World'), 'hello-world$extension'); + expect(form.fileNameForTitle('!!!'), 'new-topic$extension'); + expect( + form.fileNameForTitle('Résumé 世界'), + '${slugForHeading('Résumé 世界')}$extension', + ); + expect(form.effectiveFileName(' Manual '), 'Manual$extension'); + expect( + form.effectiveFileName('Manual${extension.toUpperCase()}'), + 'Manual${extension.toUpperCase()}', + ); + expect(form.fileNameError('Manual${extension.toUpperCase()}'), isNull); + expect(form.fileNameError(''), form.l10n.fileNameRequired); + expect( + form.fileNameError('taken$extension'), + form.l10n.topicIdAlreadyExists, + ); + expect( + form.fileNameError('Case$extension'), + form.l10n.topicIdAlreadyExists, + ); + expect(form.fileNameError('case$extension'), isNull); + expect(form.fileNameError('Résumé世界$extension'), isNull); + expect( + form.fileNameError('name.txt'), + form.l10n.useExpectedExtension(extension), + ); + for (final name in [ + '../unsafe$extension', + '/unsafe$extension', + 'unsafe\\name$extension', + 'nul\u0000$extension', + ]) { + expect(form.fileNameError(name), form.l10n.useSingleSafeFileName); + } + for (final name in [ + 'CON$extension', + 'nul$extension', + 'with spaces$extension', + 'with.dot$extension', + ]) { + expect(form.fileNameError(name), form.l10n.useIdentifierCharacters); + } + }, + ); + } + test( + 'native success without a completed submission is a protocol error', + () async { + const channel = MethodChannel('busymark/test/early-success'); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (_) async => true); + addTearDown( + () => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null), + ); + await expectLater( + _create(channel), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'invalid-result', + ), + ), + ); + }, + ); + test('host unavailability after an event cannot open a fallback', () async { + final host = TestNativeWritersideDialogHost(); + host.install(); + addTearDown(host.dispose); + final result = _create(host.channel); + await host.opened.future; + await host.event({ + 'event': 'changed', + 'title': 'Title', + 'fileName': 'title.md', + 'fileNameEdited': true, + }); + final assertion = expectLater( + result, + throwsA( + isA().having( + (error) => error.code, + 'code', + 'unavailable', + ), + ), + ); + host.closed.completeError(PlatformException(code: 'unavailable')); + await assertion; + }); + test('retiring a session leaves other native operations intact', () async { + const channel = MethodChannel('busymark/test/concurrent-forms'); + final hosts = [ + TestNativeWritersideDialogHost(channel: channel), + TestNativeWritersideDialogHost(channel: channel), + ]; + var index = 0; + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler(channel, (call) { + if (call.method == 'showDuplicateTopic') return Future.value('copy'); + final host = hosts[index++]; + host.arguments = call.arguments as Map; + host.opened.complete(host.arguments); + return host.closed.future; + }); + addTearDown(() => messenger.setMockMethodCallHandler(channel, null)); + final first = _text(channel); + await hosts[0].opened.future; + final second = _rename(channel); + await hosts[1].opened.future; + hosts[0].closed.complete(null); + expect((await first).value, isNull); + await expectLater( + hosts[0].event({'event': 'changed', 'value': 'late'}), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'expired-dialog', + ), + ), + ); + expect(await hosts[1].event({'event': 'changed', 'value': 'new.topic'}), { + 'error': null, + }); + final duplicate = await _operation('duplicate', channel); + expect(duplicate.value, 'copy'); + hosts[1].closed.complete({'value': 'new.topic', 'preview': false}); + expect((await second).value?.fileName, 'new.topic'); + }); test('Duplicate Topic is serialized to the native dialog host', () async { const channel = MethodChannel('busymark/test/native-writerside-duplicate'); MethodCall? call; @@ -142,4 +297,317 @@ void main() { expect(unavailable.value, isNull); }, ); + test( + 'New Topic validates, stays pending, retains failure and retries', + () async { + final host = TestNativeWritersideDialogHost(); + host.install(); + addTearDown(host.dispose); + final form = WritersideTopicCreationForm( + format: WritersideTopicFormat.xml, + existingIds: {'taken'}, + l10n: AppLocalizationsAr(), + ); + var submissions = 0; + var creation = Completer(); + final resultFuture = NativeWritersideDialogService(channel: host.channel) + .showCreateTopic( + title: form.l10n.newTopic, + titleLabel: form.l10n.tocTopicTitleField, + fileNameLabel: form.l10n.tocDuplicateFilename, + initialTitle: form.l10n.defaultNewTopicTitle, + initialFileName: form.fileNameForTitle( + form.l10n.defaultNewTopicTitle, + ), + cancelLabel: form.l10n.cancel, + okLabel: form.l10n.tocOk, + textDirection: TextDirection.rtl, + validate: (title, name, edited) { + final fileName = edited ? name : form.fileNameForTitle(title); + return NativeWritersideCreateValidation( + fileName: fileName, + titleError: form.titleError(title), + fileNameError: form.fileNameError(fileName), + ); + }, + submit: (title, name) { + submissions++; + expect(title, 'New title'); + expect(name, 'manual.topic'); + return creation.future; + }, + ); + final request = await host.opened.future; + expect(request['textDirection'], 'rtl'); + expect(request['titleLabel'], form.l10n.tocTopicTitleField); + expect(request['initialTitle'], form.l10n.defaultNewTopicTitle); + expect(request['initialFileName'], endsWith('.topic')); + Future event( + String type, + String title, + String name, { + bool edited = true, + }) => host.event({ + 'event': type, + 'title': title, + 'fileName': name, + 'fileNameEdited': edited, + }); + final synchronized = + await event('changed', 'New title', 'old.topic', edited: false) + as Map; + expect(synchronized['fileName'], 'new-title.topic'); + final invalid = await event('submit', '', 'taken.topic') as Map; + expect(invalid['titleError'], form.l10n.topicTitleRequired); + expect(invalid['fileNameError'], form.l10n.topicIdAlreadyExists); + expect(submissions, 0); + final pending = event('submit', 'New title', 'manual.topic'); + await Future.delayed(Duration.zero); + expect(submissions, 1); + expect(await event('submit', 'New title', 'manual.topic'), { + 'pending': true, + }); + expect(await event('changed', 'Other', 'different.topic'), { + 'pending': true, + }); + var finished = false; + unawaited(resultFuture.then((_) => finished = true)); + expect(finished, isFalse); + creation.complete(form.l10n.createWritersideTopicFailed); + final failure = await pending as Map; + expect(failure['created'], isFalse); + expect(failure['error'], form.l10n.createWritersideTopicFailed); + expect(failure['fileName'], 'manual.topic'); + expect(finished, isFalse); + creation = Completer(); + final retry = event('submit', 'New title', 'manual.topic'); + await Future.delayed(Duration.zero); + expect(submissions, 2); + creation.complete(null); + expect((await retry as Map)['created'], isTrue); + host.closed.complete(true); + final result = await resultFuture; + expect(result.available, isTrue); + expect(result.value, isTrue); + await expectLater( + event('changed', 'Late', 'late.topic'), + throwsA(isA()), + ); + }, + ); + + test( + 'New Topic destruction during creation removes the session safely', + () async { + final host = TestNativeWritersideDialogHost(); + host.install(); + addTearDown(host.dispose); + final creation = Completer(); + final future = _create(host.channel, submit: (_, _) => creation.future); + await host.opened.future; + final pending = host.event({ + 'event': 'submit', + 'title': 'Title', + 'fileName': 'title.md', + 'fileNameEdited': true, + }); + await Future.delayed(Duration.zero); + host.closed.complete(null); + expect((await future).value, isNull); + creation.complete(null); + expect((await pending as Map)['created'], isTrue); + await expectLater( + host.event({'event': 'changed'}), + throwsA(isA()), + ); + }, + ); + + for (final operation in [ + 'create', + 'rename', + 'text', + 'picker', + 'duplicate', + 'title', + ]) { + for (final outcome in [ + 'cancel', + 'missing', + 'unavailable', + 'malformed', + 'unexpected', + ]) { + test('$operation preserves the $outcome result contract', () async { + final channel = MethodChannel('busymark/test/$operation/$outcome'); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler(channel, (_) async { + switch (outcome) { + case 'cancel': + return null; + case 'missing': + throw MissingPluginException(); + case 'unavailable': + throw PlatformException(code: 'unavailable'); + case 'unexpected': + throw PlatformException(code: 'host-failed'); + default: + return ['malformed']; + } + }); + addTearDown(() => messenger.setMockMethodCallHandler(channel, null)); + final future = _operation(operation, channel); + if (outcome == 'malformed' || outcome == 'unexpected') { + await expectLater( + future, + throwsA( + isA().having( + (error) => error.code, + 'code', + outcome == 'malformed' ? 'invalid-result' : 'host-failed', + ), + ), + ); + } else { + final result = await future; + expect(result.available, outcome == 'cancel'); + expect(result.value, isNull); + } + }); + } + } + + test('rename uses Dart extension validation and distinct actions', () async { + for (final preview in [true, false]) { + final host = TestNativeWritersideDialogHost(); + host.install(); + final resultFuture = _rename(host.channel); + await host.opened.future; + expect(host.calls.single.method, 'showRenameTopic'); + expect(host.arguments['textDirection'], 'rtl'); + expect(host.arguments['previewLabel'], 'Preview'); + expect(await host.event({'event': 'changed', 'value': 'wrong.md'}), { + 'error': 'extension', + }); + host.closed.complete({'value': ' renamed.topic ', 'preview': preview}); + final result = await resultFuture; + expect(result.value?.fileName, 'renamed.topic'); + expect(result.value?.preview, preview); + host.dispose(); + } + }); + + test( + 'Group returns untrimmed text and keeps Enter-only interaction', + () async { + final host = TestNativeWritersideDialogHost(); + host.install(); + addTearDown(host.dispose); + final future = _text(host.channel); + await host.opened.future; + expect(host.arguments['enterOnly'], isTrue); + expect(await host.event({'event': 'changed', 'value': ' '}), { + 'error': 'required', + }); + host.closed.complete(' Group '); + expect((await future).value, ' Group '); + }, + ); + + test('picker filters in Dart and returns the original index', () async { + final host = TestNativeWritersideDialogHost(); + host.install(); + addTearDown(host.dispose); + final future = _picker(host.channel); + await host.opened.future; + expect(host.arguments['searchLabel'], 'Search'); + expect(await host.event({'event': 'filter', 'value': 'BETA'}), [1, 2]); + host.closed.complete(2); + expect((await future).value, 2); + }); +} + +Future> _create( + MethodChannel channel, { + Future Function(String, String)? submit, +}) => NativeWritersideDialogService(channel: channel).showCreateTopic( + title: 'New Topic', + titleLabel: 'Title', + fileNameLabel: 'Filename', + initialTitle: 'Title', + initialFileName: 'title.md', + cancelLabel: 'Cancel', + okLabel: 'OK', + textDirection: TextDirection.ltr, + validate: (_, name, _) => NativeWritersideCreateValidation(fileName: name), + submit: submit ?? (_, _) async => null, +); +Future> _rename( + MethodChannel channel, +) => NativeWritersideDialogService(channel: channel).showRenameTopic( + title: 'Rename', + fileNameLabel: 'Filename', + initialValue: 'old.topic', + cancelLabel: 'Cancel', + previewLabel: 'Preview', + refactorLabel: 'Refactor', + validate: (value) => value.trim().endsWith('.topic') ? null : 'extension', + textDirection: TextDirection.rtl, +); +Future> _text(MethodChannel channel) => + NativeWritersideDialogService(channel: channel).showTocText( + title: 'Group', + label: 'Title', + cancelLabel: 'Cancel', + okLabel: 'OK', + requiredError: 'required', + enterOnly: true, + textDirection: TextDirection.rtl, + ); +Future> _picker(MethodChannel channel) => + NativeWritersideDialogService(channel: channel).showExistingTopicPicker( + title: 'Select', + searchLabel: 'Search', + fileNames: ['alpha.md', 'beta.md', 'BETA.md'], + textDirection: TextDirection.rtl, + ); +Future> _operation( + String operation, + MethodChannel channel, +) { + final service = NativeWritersideDialogService(channel: channel); + return switch (operation) { + 'create' => _create(channel), + 'rename' => _rename(channel), + 'text' => _text(channel), + 'picker' => _picker(channel), + 'duplicate' => service.showDuplicateTopic( + title: 'Duplicate', + fileNameLabel: 'Filename', + initialValue: 'old', + cancelLabel: 'Cancel', + okLabel: 'OK', + requiredError: 'required', + invalidCharactersError: 'invalid', + duplicateError: 'duplicate', + existingTopicIds: [], + ), + _ => service.showEditTitle( + dialogTitle: 'Edit Title', + topicTitleLabel: 'Title', + advancedLabel: 'Advanced', + instanceTitleLabel: 'Instance', + tocTitleLabel: 'TOC', + instanceExplanation: 'Instance help', + tocExplanation: 'TOC help', + documentationLabel: 'Docs', + documentationUrl: 'https://example.test', + initialTitle: 'Title', + initialInstanceTitle: '', + initialTocTitle: '', + cancelLabel: 'Cancel', + okLabel: 'OK', + ), + }; } diff --git a/test/src/native_writerside_dialog_source_test.dart b/test/src/native_writerside_dialog_source_test.dart index d55844e2..5ec763fe 100644 --- a/test/src/native_writerside_dialog_source_test.dart +++ b/test/src/native_writerside_dialog_source_test.dart @@ -20,4 +20,45 @@ void main() { expect(workspace, contains('.showEditTitle(')); expect(workspace, contains('.showDuplicateTopic(')); }); + test( + 'interactive Writerside dialogs reuse the GTK host and Dart callbacks', + () { + final host = File( + 'linux/runner/writerside_dialog_host.cc', + ).readAsStringSync(); + final dialogs = File( + 'lib/src/workspace/presentation/writerside_toc_dialogs.dart', + ).readAsStringSync(); + for (final method in [ + 'showCreateTopic', + 'showRenameTopic', + 'showTocText', + 'showExistingTopicPicker', + ]) { + expect(host, contains('"$method"')); + } + expect( + host, + matches( + RegExp(r'GTK_DIALOG_MODAL\s*\|\s*GTK_DIALOG_DESTROY_WITH_PARENT'), + ), + ); + expect(host, contains('GTK_TEXT_DIR_LTR')); + expect(host, contains('writersideDialogEvent')); + expect(host, contains('state->pending')); + expect(host, contains('gtk_window_set_deletable')); + expect(host, contains('G_CALLBACK(interactive_key)')); + expect( + host, + contains('interactive_response(nullptr, GTK_RESPONSE_CANCEL, state)'), + ); + expect(host, contains('g_weak_ref_get')); + expect(host, contains('if (state->finished) return;')); + expect(host, contains('reply->revision != state->revision')); + expect(dialogs, contains('.showRenameTopic(')); + expect(dialogs, contains('.showTocText(')); + expect(dialogs, contains('.showExistingTopicPicker(')); + expect(dialogs, contains('return index == null ? null : topics[index]')); + }, + ); } diff --git a/test/src/source_audit_test.dart b/test/src/source_audit_test.dart index f600db56..be6776e3 100644 --- a/test/src/source_audit_test.dart +++ b/test/src/source_audit_test.dart @@ -257,7 +257,7 @@ void main() { expect(dialogButton, isNot(contains('minWidth:'))); }); - test('Writerside topic controls use shared semantic adapters', () { + test('Writerside topic Flutter fallback uses shared semantic adapters', () { final workspace = File( 'lib/src/workspace/presentation/workspace_screen.dart', ).readAsStringSync(); @@ -269,7 +269,13 @@ void main() { expect(workspace, contains('BusyMarkPushButton.standardIcon(')); expect(workspace, isNot(contains('FilledButton.icon('))); expect(createTopicDialog, contains('BusyMarkDialogShell(')); - expect(RegExp(r'TextField\(').allMatches(createTopicDialog).length, 2); + expect( + RegExp( + r'BusyMarkGroupedTextEntry\(', + ).allMatches(createTopicDialog).length, + 2, + ); + expect(workspace, contains('.showCreateTopic(')); expect( RegExp(r'BusyMarkComboRow<').allMatches(createTopicDialog).length, 0, @@ -277,7 +283,9 @@ void main() { expect(createTopicDialog, contains('context.l10n.newTopic')); expect(createTopicDialog, isNot(contains('SegmentedButton<'))); expect(createTopicDialog, isNot(contains('BusyMarkFloatingTextEntry'))); - expect(createTopicDialog, contains('InputDecoration(')); + expect(createTopicDialog, contains('BusyMarkGroupedList(')); + expect(createTopicDialog, isNot(contains('InputDecoration('))); + expect(createTopicDialog, isNot(contains('TextField('))); }); test('shared grouped surfaces use native card shadow layers', () { diff --git a/test/src/spelling_service_test.dart b/test/src/spelling_service_test.dart index 4f621b8c..c62577f9 100644 --- a/test/src/spelling_service_test.dart +++ b/test/src/spelling_service_test.dart @@ -46,6 +46,17 @@ const _snapshot = SpellingSnapshotIdentity( ); void main() { + setUp(() { + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + for (final name in ['yaru_window', 'yaru_window/events']) { + messenger.setMockMethodCallHandler( + MethodChannel(name), + (call) async => call.method == 'state' ? {} : null, + ); + } + }); + test('document kinds expose one authoritative spelling capability', () { expect(DocumentKind.markdown.supportsSpelling, isTrue); expect(DocumentKind.writersideMarkdownTopic.supportsSpelling, isTrue); @@ -4011,18 +4022,19 @@ void main() { ); expect(controller.isCurrent(erorOccurrence), isTrue); final addPersonal = find.widgetWithText( - TextButton, + BusyMarkDialogButton, l10n.addPersonalSpellingWord, ); await tester.ensureVisible(addPersonal); personalWriter.failWrites = true; - tester.widget(addPersonal).onPressed!.call(); + tester.widget(addPersonal).onPressed!.call(); await tester.runAsync( () => Future.delayed(const Duration(milliseconds: 500)), ); await _pumpWidgetUntil( tester, - () => tester.widget(addPersonal).onPressed != null, + () => + tester.widget(addPersonal).onPressed != null, ); expect(find.text('eror'), findsOneWidget); expect( @@ -4031,7 +4043,7 @@ void main() { ); personalWriter.failWrites = false; - tester.widget(addPersonal).onPressed!.call(); + tester.widget(addPersonal).onPressed!.call(); await _pumpWidgetUntil( tester, () => @@ -4196,7 +4208,9 @@ void main() { () => find.text(action).evaluate().isNotEmpty, ); final callback = tester - .widget(find.widgetWithText(TextButton, action)) + .widget( + find.widgetWithText(BusyMarkDialogButton, action), + ) .onPressed!; callback(); callback(); @@ -4364,11 +4378,11 @@ void main() { expect(controller.isCurrent(occurrence), isTrue); final writesBefore = writer.writeCount; final addPersonal = find.widgetWithText( - TextButton, + BusyMarkDialogButton, l10n.addPersonalSpellingWord, ); await tester.ensureVisible(addPersonal); - tester.widget(addPersonal).onPressed!.call(); + tester.widget(addPersonal).onPressed!.call(); await _pumpWidgetUntil(tester, () => writer.writeCount > writesBefore); await _pumpWidgetUntil( tester, diff --git a/test/src/visualization_card_test.dart b/test/src/visualization_card_test.dart index 5242768b..d62accd0 100644 --- a/test/src/visualization_card_test.dart +++ b/test/src/visualization_card_test.dart @@ -1,5 +1,8 @@ +import 'package:busymark/src/app/busymark_design.dart'; +import 'package:busymark/src/app/busymark_dialogs.dart'; +import 'package:busymark/src/platform/linux_header_bar_service.dart'; +import 'package:flutter/services.dart'; import 'dart:io'; -import 'dart:typed_data'; import 'package:busymark/l10n/generated/app_localizations.dart'; import 'package:busymark/src/visualization/visualization_cache.dart'; @@ -27,6 +30,75 @@ void main() { } }); + testWidgets( + 'full-screen diagram coordinates its modal and preserves pan/zoom', + (tester) async { + final coordinator = VisualizationCoordinator( + renderers: const [_CardRenderer()], + cache: _MemoryVisualizationCache(cacheDirectory), + ); + addTearDown(coordinator.dispose); + final barrier = _VisualizationModalBarrier(); + final previousFocus = FocusNode(); + addTearDown(previousFocus.dispose); + await tester.pumpWidget( + ProviderScope( + overrides: [ + visualizationCoordinatorProvider.overrideWithValue(coordinator), + linuxHeaderBarServiceProvider.overrideWithValue(barrier), + ], + child: _App( + child: Focus( + focusNode: previousFocus, + child: _CardHarness(onDiagnosticSelected: (_) {}), + ), + ), + ), + ); + await _pumpUntilFound(tester, find.byType(SvgPicture)); + previousFocus.requestFocus(); + await tester.pump(); + final preview = tester.widget( + find.byType(InteractiveViewer), + ); + final previewTransform = preview.transformationController!.value.clone(); + await tester.tap(find.byTooltip('Full Screen')); + await tester.pumpAndSettle(); + expect(barrier.depths, [1]); + expect(find.byType(BusyMarkModalShortcutBoundary), findsOneWidget); + expect(find.byType(BusyMarkDialogTitleBar), findsOneWidget); + final viewer = tester.widget( + find.byType(InteractiveViewer).last, + ); + expect(viewer.panEnabled, isTrue); + expect(viewer.scaleEnabled, isTrue); + expect( + tester.getSize(find.byType(BusyMarkDialogTitleBar)).width, + tester.view.physicalSize.width / tester.view.devicePixelRatio, + ); + expect( + tester.getSize(find.byType(InteractiveViewer).last).height, + greaterThan(140), + ); + viewer.transformationController!.value = Matrix4.diagonal3Values(2, 2, 1); + await tester.pump(); + expect(viewer.transformationController!.value.getMaxScaleOnAxis(), 2); + await tester.tap(find.bySemanticsLabel('Close').last); + await tester.pumpAndSettle(); + expect(barrier.depths, [1, 0]); + expect(find.byType(BusyMarkDialogTitleBar), findsNothing); + expect(preview.transformationController!.value, previewTransform); + expect(previousFocus.hasFocus, isTrue); + expect(tester.takeException(), isNull); + await tester.tap(find.byTooltip('Full Screen')); + await tester.pumpAndSettle(); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + expect(barrier.depths, [1, 0, 1, 0]); + expect(find.byType(InteractiveViewer), findsOneWidget); + expect(previousFocus.hasFocus, isTrue); + }, + ); testWidgets('retains the last valid diagram and navigates new diagnostics', ( tester, ) async { @@ -446,3 +518,9 @@ class _OpenReferenceHost implements WebRenderHost { required VisualizationCancellationToken cancellationToken, }) => throw UnimplementedError(); } + +class _VisualizationModalBarrier extends LinuxHeaderBarService { + final depths = []; + @override + Future setModalBarrierDepth(int value) async => depths.add(value); +} diff --git a/test/src/writerside_toc_menu_test.dart b/test/src/writerside_toc_menu_test.dart index b30ebbc9..1812a57c 100644 --- a/test/src/writerside_toc_menu_test.dart +++ b/test/src/writerside_toc_menu_test.dart @@ -1,3 +1,4 @@ +import 'package:busymark/src/platform/native_writerside_dialog_service.dart'; import 'package:busymark/src/app/busymark_design.dart'; import 'package:busymark/src/platform/native_menu_service.dart'; import 'package:busymark/l10n/generated/app_localizations.dart'; @@ -55,11 +56,182 @@ void main() { expect(chosen?.fileName, 'gamma.md'); await tester.tap(find.text('Link')); await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField), 'GAM'); + await tester.pump(); await tester.sendKeyEvent(LogicalKeyboardKey.escape); await tester.pumpAndSettle(); expect(chosen, isNull); }); + testWidgets( + 'native picker returns the supplied topic identity without a fallback', + (tester) async { + final topics = [ + for (final root in ['/first', '/second']) + const WritersideTopicParser().parseMarkdown( + filePath: '$root/same.md', + source: '# Same\n', + topicsRoot: root, + ), + ]; + Object? response = 1; + const channel = MethodChannel(nativeWritersideDialogChannelName); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, ( + call, + ) async { + expect(call.method, 'showExistingTopicPicker'); + expect((call.arguments as Map)['fileNames'], ['same.md', 'same.md']); + return response; + }); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + null, + ), + ); + WritersideTopic? chosen; + Object? failure; + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) => TextButton( + child: const Text('Link'), + onPressed: () async { + try { + chosen = await showWritersideExistingTopicPicker( + context, + topics: topics, + ); + } catch (error) { + failure = error; + } + }, + ), + ), + ), + ); + await tester.tap(find.text('Link')); + await tester.pumpAndSettle(); + expect(chosen, same(topics[1])); + expect(find.byType(WritersideExistingTopicPicker), findsNothing); + response = null; + await tester.tap(find.text('Link')); + await tester.pumpAndSettle(); + expect(chosen, isNull); + expect(find.byType(WritersideExistingTopicPicker), findsNothing); + response = 20; + await tester.tap(find.text('Link')); + await tester.pumpAndSettle(); + expect(failure, isA()); + expect(find.byType(WritersideExistingTopicPicker), findsNothing); + }, + ); + + testWidgets('Group fallback keeps raw input and Enter-only acceptance', ( + tester, + ) async { + const channel = MethodChannel(nativeWritersideDialogChannelName); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + (_) async => throw PlatformException(code: 'unavailable'), + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + null, + ), + ); + String? result; + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) => TextButton( + child: const Text('Launch'), + onPressed: () async => result = await showWritersideTocTextDialog( + context, + title: 'Group', + label: 'Title', + enterOnly: true, + ), + ), + ), + ), + ); + await tester.tap(find.text('Launch')); + await tester.pumpAndSettle(); + expect(find.byType(WritersideTocTextDialog), findsOneWidget); + expect(find.byType(BusyMarkDialogButton), findsNothing); + await tester.enterText(find.byType(BusyMarkGroupedTextEntry), ' '); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + expect(find.byType(WritersideTocTextDialog), findsOneWidget); + await tester.enterText( + find.byType(BusyMarkGroupedTextEntry), + ' Group name ', + ); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + expect(result, ' Group name '); + }); + + testWidgets('native rename preserves Preview and Refactor results', ( + tester, + ) async { + const channel = MethodChannel(nativeWritersideDialogChannelName); + Object? response; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, ( + call, + ) async { + expect(call.method, 'showRenameTopic'); + expect((call.arguments as Map)['initialValue'], 'old.topic'); + return response; + }); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + null, + ), + ); + WritersideTopicRenameDialogResult? result; + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) => TextButton( + child: const Text('Launch'), + onPressed: () async => + result = await showWritersideTopicRenameDialog( + context, + currentFileName: 'old.topic', + ), + ), + ), + ), + ); + for (final preview in [true, false]) { + response = {'value': ' new.topic ', 'preview': preview}; + await tester.tap(find.text('Launch')); + await tester.pumpAndSettle(); + expect(result?.fileName, 'new.topic'); + expect( + result?.action, + preview + ? WritersideTopicRenameDialogAction.preview + : WritersideTopicRenameDialogAction.refactor, + ); + expect(find.byType(WritersideTopicRenameDialog), findsNothing); + } + response = null; + await tester.tap(find.text('Launch')); + await tester.pumpAndSettle(); + expect(result, isNull); + expect(find.byType(WritersideTopicRenameDialog), findsNothing); + }); for (final direction in TextDirection.values) { testWidgets('nested menus use the native host in $direction', ( tester, diff --git a/test/src/writerside_toc_regressions_test.dart b/test/src/writerside_toc_regressions_test.dart index caa9f225..72f60043 100644 --- a/test/src/writerside_toc_regressions_test.dart +++ b/test/src/writerside_toc_regressions_test.dart @@ -1,3 +1,4 @@ +import 'package:busymark/src/app/busymark_design.dart'; import 'dart:io'; import 'package:busymark/src/app/app_settings.dart'; @@ -387,11 +388,11 @@ void main() { ); } else { await tester.enterText( - find.widgetWithText(TextField, 'Topic title:'), + find.widgetWithText(BusyMarkGroupedTextEntry, 'Topic title:'), 'Created topic', ); await tester.enterText( - find.widgetWithText(TextField, 'Topic Filename:'), + find.widgetWithText(BusyMarkGroupedTextEntry, 'Topic Filename:'), 'created-topic', ); } diff --git a/test/support/native_writerside_dialog_host.dart b/test/support/native_writerside_dialog_host.dart new file mode 100644 index 00000000..def0be9b --- /dev/null +++ b/test/support/native_writerside_dialog_host.dart @@ -0,0 +1,57 @@ +import 'dart:async'; + +import 'package:busymark/src/platform/native_writerside_dialog_service.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Exercises both directions of the production dialog protocol. +class TestNativeWritersideDialogHost { + TestNativeWritersideDialogHost({ + this.channel = const MethodChannel(nativeWritersideDialogChannelName), + }); + + final MethodChannel channel; + final opened = Completer>(); + final closed = Completer(); + final calls = []; + late Map arguments; + + void install() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) { + calls.add(call); + arguments = call.arguments as Map; + if (!opened.isCompleted) opened.complete(arguments); + return closed.future; + }); + } + + void dispose() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + } + + Future event(Map values) async { + const codec = StandardMethodCodec(); + final reply = Completer(); + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage( + channel.name, + codec.encodeMethodCall( + MethodCall('writersideDialogEvent', { + 'session': arguments['session'], + ...values, + }), + ), + (data) { + try { + if (data == null) throw MissingPluginException(); + reply.complete(codec.decodeEnvelope(data)); + } catch (error, stack) { + reply.completeError(error, stack); + } + }, + ); + return reply.future; + } +} From 4b7bc1bd1563044d633d4386bb7b0e9969c22acb Mon Sep 17 00:00:00 2001 From: albert Date: Sun, 4 Oct 2026 06:24:18 -0700 Subject: [PATCH 09/14] Migrate interactive Writerside dialogs to Flutter modals * Replace native GTK dialogs for interactive Writerside operations (New Topic, Rename, TOC Group, Topic Picker) with `BusyMarkModalEditorScaffold` and standard Flutter modal dialogs. * Remove `showBusyMarkNativeDialog`, `NativeWritersideDialogService` interactive methods, and `WritersideTopicCreationForm` as their logic is now fully handled in Flutter. * Remove C++ `InteractiveDialog` session management, GTK widget creation, and asynchronous callbacks from `linux/runner/writerside_dialog_host.cc`. * Add `set_dialog_direction` to the Linux runner to ensure surviving native dialogs (Edit Title, Duplicate Topic) recursively apply the application text direction to internal GTK widgets. * Update widget tests to remove native host mock channels and verify the new Flutter-based modal interactions. * Add `native_writerside_dialog_host_test.cc` to explicitly test GTK text direction application on simulated desktops. --- lib/src/app/busymark_dialogs.dart | 10 - .../native_writerside_dialog_service.dart | 303 ----------- .../presentation/workspace_screen.dart | 335 +++++++----- .../presentation/writerside_toc_dialogs.dart | 106 ---- .../writerside_topic_creation_form.dart | 64 --- linux/runner/writerside_dialog_host.cc | 507 +----------------- test/src/app_smoke_test.dart | 266 ++++----- test/src/busymark_dialogs_test.dart | 66 --- ...native_writerside_dialog_service_test.dart | 468 ---------------- .../native_writerside_dialog_source_test.dart | 81 +-- test/src/source_audit_test.dart | 8 +- test/src/writerside_toc_menu_test.dart | 132 ++--- .../native_writerside_dialog_host.dart | 57 -- .../native_writerside_dialog_host_test.cc | 169 ++++++ 14 files changed, 622 insertions(+), 1950 deletions(-) delete mode 100644 lib/src/workspace/presentation/writerside_topic_creation_form.dart delete mode 100644 test/support/native_writerside_dialog_host.dart create mode 100644 test/support/native_writerside_dialog_host_test.cc diff --git a/lib/src/app/busymark_dialogs.dart b/lib/src/app/busymark_dialogs.dart index 6222de46..5a22af42 100644 --- a/lib/src/app/busymark_dialogs.dart +++ b/lib/src/app/busymark_dialogs.dart @@ -218,16 +218,6 @@ Future _coordinateBusyMarkModal( } } -/// Coordinates focus and the header bar while the Linux toolkit owns a modal. -Future showBusyMarkNativeDialog( - BuildContext context, { - required Future Function() showDialog, -}) => _coordinateBusyMarkModal( - context, - headerBarService: _busyMarkHeaderBarServiceFrom(context), - showSurface: showDialog, -); - Future _showBusyMarkFlutterDialog( BuildContext context, { required WidgetBuilder builder, diff --git a/lib/src/platform/native_writerside_dialog_service.dart b/lib/src/platform/native_writerside_dialog_service.dart index 7efebee6..3d7c5d07 100644 --- a/lib/src/platform/native_writerside_dialog_service.dart +++ b/lib/src/platform/native_writerside_dialog_service.dart @@ -1,31 +1,6 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; -@immutable -final class NativeWritersideCreateValidation { - const NativeWritersideCreateValidation({ - required this.fileName, - this.titleError, - this.fileNameError, - }); - final String fileName; - final String? titleError; - final String? fileNameError; - - Map toMap() => { - 'fileName': fileName, - 'titleError': titleError, - 'fileNameError': fileNameError, - }; -} - -@immutable -final class NativeWritersideRenameValues { - const NativeWritersideRenameValues(this.fileName, {required this.preview}); - final String fileName; - final bool preview; -} - @visibleForTesting const nativeWritersideDialogChannelName = 'busymark/native_writerside_dialogs'; @@ -69,284 +44,6 @@ class NativeWritersideDialogService { final MethodChannel _channel; - // New interactive forms share this channel with the existing operations. - // A session owns only its own callbacks; destroying one cannot replace a - // newer dialog's handler or leave a submission callback installed. - static int _nextSession = 0; - static final _sessions = - Function(MethodCall)>>{}; - - Future> _showInteractive( - String method, - Map arguments, - Future Function(MethodCall) onEvent, - ) async { - final session = ++_nextSession; - var receivedEvent = false; - final sessions = _sessions.putIfAbsent(_channel.name, () => {}); - sessions[session] = (call) { - receivedEvent = true; - return onEvent(call); - }; - if (sessions.length == 1) { - _channel.setMethodCallHandler((call) async { - if (call.method != 'writersideDialogEvent' || call.arguments is! Map) { - throw PlatformException(code: 'invalid-event'); - } - final handler = sessions[(call.arguments as Map)['session']]; - if (handler == null) throw PlatformException(code: 'expired-dialog'); - return handler(call); - }); - } - try { - final value = await _channel.invokeMethod(method, { - ...arguments, - 'session': session, - }); - return NativeWritersideDialogResult.available(value); - } on MissingPluginException { - if (receivedEvent) { - throw PlatformException(code: 'dialog-host-disconnected'); - } - return const NativeWritersideDialogResult.unavailable(); - } on PlatformException catch (error) { - if (error.code == 'unavailable' && !receivedEvent) { - return const NativeWritersideDialogResult.unavailable(); - } - rethrow; - } finally { - sessions.remove(session); - if (sessions.isEmpty) { - _sessions.remove(_channel.name); - _channel.setMethodCallHandler(null); - } - } - } - - /// Keeps the GTK form alive until cancellation or a successful [submit]. - /// - /// The host asks Dart to [validate] each edit and to submit a valid form. - /// A null submission error closes the form; a localized error leaves its - /// values intact for retry. The pending guard also protects against repeated - /// native submission events. Session callbacks are removed on destruction. - Future> showCreateTopic({ - required String title, - required String titleLabel, - required String fileNameLabel, - required String initialTitle, - required String initialFileName, - required String cancelLabel, - required String okLabel, - required NativeWritersideCreateValidation Function( - String title, - String fileName, - bool fileNameEdited, - ) - validate, - required Future Function(String title, String fileName) submit, - required TextDirection textDirection, - }) async { - var submitting = false; - var completed = false; - var active = true; - try { - final result = await _showInteractive( - 'showCreateTopic', - { - 'title': title, - 'titleLabel': titleLabel, - 'fileNameLabel': fileNameLabel, - 'initialTitle': initialTitle, - 'initialFileName': initialFileName, - 'cancelLabel': cancelLabel, - 'okLabel': okLabel, - 'textDirection': textDirection.name, - }, - (call) async { - final args = call.arguments; - if (!active || completed) { - throw PlatformException(code: 'expired-dialog'); - } - if (args case { - 'event': final String event, - 'title': final String title, - 'fileName': final String fileName, - 'fileNameEdited': final bool edited, - }) { - if (event != 'changed' && event != 'submit') { - throw PlatformException(code: 'invalid-event'); - } - if (submitting) return {'pending': true}; - final validation = validate(title, fileName, edited); - if (event == 'changed' || - validation.titleError != null || - validation.fileNameError != null) { - return {...validation.toMap(), 'created': false}; - } - submitting = true; - try { - final error = await submit(title, validation.fileName); - completed = error == null; - return { - ...validation.toMap(), - 'created': completed, - 'error': error, - }; - } finally { - submitting = false; - } - } - throw PlatformException(code: 'invalid-event'); - }, - ); - if (!result.available) { - return const NativeWritersideDialogResult.unavailable(); - } - if (result.value == null) { - return const NativeWritersideDialogResult.available(null); - } - if (result.value != true || !completed) { - throw PlatformException(code: 'invalid-result'); - } - return const NativeWritersideDialogResult.available(true); - } finally { - active = false; - } - } - - Future> - showRenameTopic({ - required String title, - required String fileNameLabel, - required String initialValue, - required String cancelLabel, - required String previewLabel, - required String refactorLabel, - required String? Function(String) validate, - required TextDirection textDirection, - }) async { - final result = await _showInteractive( - 'showRenameTopic', - { - 'title': title, - 'fileNameLabel': fileNameLabel, - 'initialValue': initialValue, - 'cancelLabel': cancelLabel, - 'previewLabel': previewLabel, - 'okLabel': refactorLabel, - 'textDirection': textDirection.name, - }, - (call) async { - if (call.arguments case { - 'event': 'changed', - 'value': final String value, - }) { - return {'error': validate(value)}; - } - throw PlatformException(code: 'invalid-event'); - }, - ); - if (!result.available) { - return const NativeWritersideDialogResult.unavailable(); - } - if (result.value == null) { - return const NativeWritersideDialogResult.available(null); - } - if (result.value case { - 'value': final String value, - 'preview': final bool preview, - }) { - if (validate(value) == null) { - return NativeWritersideDialogResult.available( - NativeWritersideRenameValues(value.trim(), preview: preview), - ); - } - } - throw PlatformException(code: 'invalid-result'); - } - - Future> showTocText({ - required String title, - required String label, - required String cancelLabel, - required String okLabel, - required String requiredError, - bool enterOnly = false, - required TextDirection textDirection, - }) async { - final result = await _showInteractive( - 'showTocText', - { - 'title': title, - 'fileNameLabel': label, - 'initialValue': '', - 'cancelLabel': cancelLabel, - 'okLabel': okLabel, - 'enterOnly': enterOnly, - 'textDirection': textDirection.name, - }, - (call) async { - if (call.arguments case { - 'event': 'changed', - 'value': final String value, - }) { - return {'error': value.trim().isEmpty ? requiredError : null}; - } - throw PlatformException(code: 'invalid-event'); - }, - ); - if (!result.available) { - return const NativeWritersideDialogResult.unavailable(); - } - if (result.value == null) { - return const NativeWritersideDialogResult.available(null); - } - if (result.value case final String value when value.trim().isNotEmpty) { - return NativeWritersideDialogResult.available(value); - } - throw PlatformException(code: 'invalid-result'); - } - - Future> showExistingTopicPicker({ - required String title, - required String searchLabel, - required List fileNames, - required TextDirection textDirection, - }) async { - final result = await _showInteractive( - 'showExistingTopicPicker', - { - 'title': title, - 'searchLabel': searchLabel, - 'fileNames': fileNames, - 'textDirection': textDirection.name, - }, - (call) async { - if (call.arguments case { - 'event': 'filter', - 'value': final String query, - }) { - return [ - for (var i = 0; i < fileNames.length; i++) - if (fileNames[i].toLowerCase().contains(query.toLowerCase())) i, - ]; - } - throw PlatformException(code: 'invalid-event'); - }, - ); - if (!result.available) { - return const NativeWritersideDialogResult.unavailable(); - } - if (result.value == null) { - return const NativeWritersideDialogResult.available(null); - } - if (result.value case final int index - when index >= 0 && index < fileNames.length) { - return NativeWritersideDialogResult.available(index); - } - throw PlatformException(code: 'invalid-result'); - } - Future> showDuplicateTopic({ required String title, required String fileNameLabel, diff --git a/lib/src/workspace/presentation/workspace_screen.dart b/lib/src/workspace/presentation/workspace_screen.dart index e3fcc9ff..2e70e781 100644 --- a/lib/src/workspace/presentation/workspace_screen.dart +++ b/lib/src/workspace/presentation/workspace_screen.dart @@ -112,7 +112,6 @@ import '../workspace_tabs.dart'; import 'welcome_screen.dart'; import 'writerside_instance_dialog.dart'; import 'writerside_markdown_import_dialog.dart'; -import 'writerside_topic_creation_form.dart'; import 'writerside_toc_dialogs.dart'; import 'writerside_template_dialogs.dart'; import '../../writerside/writerside_template_service.dart'; @@ -2395,10 +2394,14 @@ class _SidebarState extends ConsumerState<_Sidebar> { ) .firstOrNull; if (owner == null) return false; - final decision = await showWritersideTopicRenameDialog( - this.context, - currentFileName: p.basename(topicPath), - ); + final decision = + await showBusyMarkModalDialog( + this.context, + barrierDismissible: false, + builder: (_) => WritersideTopicRenameDialog( + currentFileName: p.basename(topicPath), + ), + ); if (decision == null || !mounted) return false; // Use the sidebar State's context rather than the row/menu context that // launched the workflow; transient menu entries may already be unmounted. @@ -6848,11 +6851,13 @@ class _TocTabState extends ConsumerState<_TocTab> { ), ); } - final title = await showWritersideTocTextDialog( + final title = await showBusyMarkModalDialog( context, - title: context.l10n.tocNewGroup, - label: context.l10n.tocGroupName, - enterOnly: true, + builder: (_) => WritersideTocTextDialog( + title: context.l10n.tocNewGroup, + label: context.l10n.tocGroupName, + enterOnly: true, + ), ); if (title == null || !mounted || !context.mounted) return; if (!await confirmSafeToChangeWorkspaceFiles(context, ref, [ @@ -7276,72 +7281,22 @@ class _TocTabState extends ConsumerState<_TocTab> { final referenceIdentity = rawReference == null ? null : WritersideTocNodeIdentity.fromNode(rawReference); - final form = WritersideTopicCreationForm( - format: format, - existingIds: widget.workspace.writersideModule?.reservedTopicIds ?? {}, - l10n: context.l10n, - ); - final initialTitle = context.l10n.defaultNewTopicTitle; - final controller = ref.read(workspaceControllerProvider.notifier); - Future submit(String title, String fileName) async { - if (!mounted) return form.l10n.createWritersideTopicFailed; - final created = await controller.createWritersideTopic( - WritersideTopicCreateRequest( - title: title.trim(), - fileName: form.effectiveFileName(fileName), - format: format, - placement: placement, - referenceTocPath: placement == WritersideTopicCreatePlacement.root - ? null - : referenceEntry?.editPath, - referenceTopic: placement == WritersideTopicCreatePlacement.root - ? null - : referenceEntry?.node.topicFileName, - referenceTocIdentity: placement == WritersideTopicCreatePlacement.root - ? null - : referenceIdentity, - ), - instanceTreePath: instanceTreePath, - ); - if (created) return null; - if (!mounted) return form.l10n.createWritersideTopicFailed; - final message = ref.read(workspaceControllerProvider).message; - return message == null - ? form.l10n.createWritersideTopicFailed - : localizeWorkspaceMessage(this.context, message); - } - - final native = await showBusyMarkNativeDialog( - context, - showDialog: () => const NativeWritersideDialogService().showCreateTopic( - title: context.l10n.newTopic, - titleLabel: context.l10n.tocTopicTitleField, - fileNameLabel: context.l10n.tocDuplicateFilename, - initialTitle: initialTitle, - initialFileName: form.fileNameForTitle(initialTitle), - cancelLabel: context.l10n.cancel, - okLabel: context.l10n.tocOk, - textDirection: Directionality.of(context), - validate: (title, fileName, edited) { - final effective = edited ? fileName : form.fileNameForTitle(title); - return NativeWritersideCreateValidation( - fileName: effective, - titleError: form.titleError(title), - fileNameError: form.fileNameError(effective), - ); - }, - submit: submit, - ), - ); - if (!mounted || !context.mounted || native?.available != false) return; final headerBar = ref.read(linuxHeaderBarServiceProvider); - await showBusyMarkModalDialog( + await showBusyMarkModalEditorDialog( context, headerBarService: headerBar.isAvailable ? headerBar : null, - builder: (_) => _CreateWritersideTopicDialog( + maxWidth: 680, + builder: (dialogContext) => _CreateWritersideTopicDialog( workspace: widget.workspace, + instanceTreePath: instanceTreePath, + placement: placement, initialFormat: format, - submit: submit, + referencePath: referenceEntry?.editPath, + referenceTopic: referenceEntry?.node.topicFileName, + referenceIdentity: referenceIdentity, + referenceLabel: referenceEntry == null + ? null + : _tocNodeDisplayLabel(dialogContext, referenceEntry.node), ), ); } @@ -7697,10 +7652,12 @@ class _TocTabState extends ConsumerState<_TocTab> { String? title; WritersideTopic? topic; if (choice == _TocCreationChoice.emptyGroup) { - title = await showWritersideTocTextDialog( + title = await showBusyMarkModalDialog( context, - title: context.l10n.tocNewEmptyGroup, - label: context.l10n.tocTitleField, + builder: (_) => WritersideTocTextDialog( + title: context.l10n.tocNewEmptyGroup, + label: context.l10n.tocTitleField, + ), ); if (title == null) return; } else { @@ -7717,12 +7674,14 @@ class _TocTabState extends ConsumerState<_TocTab> { .map((node) => presenter.present(node).topic?.filePath) .nonNulls .toSet(); - topic = await showWritersideExistingTopicPicker( + topic = await showBusyMarkModalDialog( context, - topics: [ - for (final topic in module.topics) - if (!members.contains(topic.filePath)) topic, - ], + builder: (_) => WritersideExistingTopicPicker( + topics: [ + for (final topic in module.topics) + if (!members.contains(topic.filePath)) topic, + ], + ), ); if (topic == null) return; } @@ -8744,26 +8703,38 @@ WritersideInstanceIconColor _effectiveInstanceIconColor( return automaticPalette[index % automaticPalette.length]; } -class _CreateWritersideTopicDialog extends StatefulWidget { +class _CreateWritersideTopicDialog extends ConsumerStatefulWidget { const _CreateWritersideTopicDialog({ required this.workspace, - required this.submit, + required this.instanceTreePath, + required this.placement, + required this.referencePath, + required this.referenceTopic, + required this.referenceIdentity, + required this.referenceLabel, this.initialFormat = WritersideTopicFormat.markdown, }); final Workspace workspace; - final Future Function(String title, String fileName) submit; + final String instanceTreePath; + final WritersideTopicCreatePlacement placement; + final List? referencePath; + final String? referenceTopic; + final WritersideTocNodeIdentity? referenceIdentity; + final String? referenceLabel; final WritersideTopicFormat initialFormat; @override - State<_CreateWritersideTopicDialog> createState() => + ConsumerState<_CreateWritersideTopicDialog> createState() => _CreateWritersideTopicDialogState(); } class _CreateWritersideTopicDialogState - extends State<_CreateWritersideTopicDialog> { + extends ConsumerState<_CreateWritersideTopicDialog> { late final TextEditingController _titleController; late final TextEditingController _fileNameController; + final _fileNameFocusNode = FocusNode(); + late WritersideTopicCreatePlacement _placement; var _format = WritersideTopicFormat.markdown; var _fileNameEdited = false; var _syncingFileName = false; @@ -8774,6 +8745,7 @@ class _CreateWritersideTopicDialogState @override void initState() { super.initState(); + _placement = widget.placement; _format = widget.initialFormat; _titleController = TextEditingController() ..addListener(_handleTitleChanged); @@ -8786,6 +8758,7 @@ class _CreateWritersideTopicDialogState void dispose() { _titleController.dispose(); _fileNameController.dispose(); + _fileNameFocusNode.dispose(); super.dispose(); } @@ -8806,77 +8779,122 @@ class _CreateWritersideTopicDialogState final canCreate = !_creating && titleError == null && fileNameError == null; return PopScope( canPop: !_creating, - child: BusyMarkDialogShell( - title: context.l10n.newTopic, - closable: !_creating, - maxWidth: BusyMarkSizes.dialog, - actions: [ - BusyMarkDialogButton( - label: context.l10n.cancel, - onPressed: _creating ? null : () => Navigator.pop(context), - ), - BusyMarkDialogButton( - label: context.l10n.tocOk, - onPressed: canCreate ? _submit : null, - ), - ], - children: [ - BusyMarkGroupedList( - filled: true, + child: CallbackShortcuts( + bindings: {const SingleActivator(LogicalKeyboardKey.escape): _cancel}, + child: Focus( + autofocus: true, + child: BusyMarkModalEditorScaffold( + title: context.l10n.newTopic, + cancelLabel: context.l10n.cancel, + saveLabel: context.l10n.tocOk, + onCancel: _cancel, + cancelEnabled: !_creating, + onSave: canCreate ? _submit : null, + saving: _creating, children: [ - BusyMarkGroupedTextEntry( - controller: _titleController, - autofocus: true, - textInputAction: TextInputAction.next, - label: context.l10n.tocTopicTitleField, - errorText: titleError, - enabled: !_creating, - ), - BusyMarkGroupedTextEntry( - controller: _fileNameController, - textDirection: TextDirection.ltr, - textInputAction: TextInputAction.done, - label: context.l10n.tocDuplicateFilename, - errorText: fileNameError, - enabled: !_creating, - onSubmitted: (_) { - if (canCreate) _submit(); - }, + BusyMarkGroupedList( + filled: true, + children: [ + BusyMarkGroupedTextEntry( + controller: _titleController, + autofocus: true, + textInputAction: TextInputAction.next, + label: context.l10n.tocTopicTitleField, + errorText: titleError, + enabled: !_creating, + onSubmitted: (_) { + if (!_creating) _fileNameFocusNode.requestFocus(); + }, + ), + BusyMarkGroupedTextEntry( + controller: _fileNameController, + focusNode: _fileNameFocusNode, + textDirection: TextDirection.ltr, + textInputAction: TextInputAction.done, + label: context.l10n.tocDuplicateFilename, + errorText: fileNameError, + enabled: !_creating, + onSubmitted: (_) { + if (canCreate) _submit(); + }, + ), + ], ), + if (_creationError != null) ...[ + const SizedBox(height: BusyMarkSpacing.md), + BusyMarkStatusBox( + message: _creationError!, + kind: BusyMarkStatusKind.error, + ), + ], + const SizedBox(height: BusyMarkSpacing.lg), ], ), - if (_creationError != null) ...[ - const SizedBox(height: BusyMarkSpacing.md), - BusyMarkStatusBox( - message: _creationError!, - kind: BusyMarkStatusKind.error, - ), - ], - ], + ), ), ); } - WritersideTopicCreationForm get _form => WritersideTopicCreationForm( - format: _format, - existingIds: widget.workspace.writersideModule?.reservedTopicIds ?? {}, - l10n: context.l10n, - ); + void _cancel() { + if (!_creating) Navigator.pop(context); + } - String? _titleError(BuildContext context) => - _form.titleError(_titleController.text); + String? _titleError(BuildContext context) { + if (_titleController.text.trim().isEmpty) { + return context.l10n.topicTitleRequired; + } + return null; + } - String? _fileNameError(BuildContext context) => - _form.fileNameError(_fileNameController.text); + String? _fileNameError(BuildContext context) { + final value = _fileNameController.text.trim(); + if (value.isEmpty) { + return context.l10n.fileNameRequired; + } + final expectedExtension = _extensionFor(_format); + final extension = p.extension(value).toLowerCase(); + if (extension.isNotEmpty && extension != expectedExtension) { + return context.l10n.useExpectedExtension(expectedExtension); + } + final effective = extension.isEmpty ? '$value$expectedExtension' : value; + try { + validateWritersideTopicFileName( + effective, + requiredExtension: expectedExtension, + ); + } on Object { + if (value == '.' || + value == '..' || + p.isAbsolute(value) || + value.contains('/') || + value.contains(r'\') || + value.contains('\u0000')) { + return context.l10n.useSingleSafeFileName; + } + return context.l10n.useIdentifierCharacters; + } + final id = p.basenameWithoutExtension(effective); + final existingIds = widget.workspace.writersideModule?.reservedTopicIds; + if (existingIds?.contains(id) ?? false) { + return context.l10n.topicIdAlreadyExists; + } + return null; + } - String get _effectiveFileName => - _form.effectiveFileName(_fileNameController.text); + String get _effectiveFileName { + final value = _fileNameController.text.trim(); + if (p.extension(value).isEmpty) { + return '$value${_extensionFor(_format)}'; + } + return value; + } void _handleTitleChanged() { _creationError = null; if (!_fileNameEdited) { _syncingFileName = true; - _fileNameController.text = _form.fileNameForTitle(_titleController.text); + _fileNameController.text = + '${_slugTopicName(_titleController.text)}${_extensionFor(_format)}'; _syncingFileName = false; } setState(() {}); @@ -8900,21 +8918,48 @@ class _CreateWritersideTopicDialogState _creating = true; _creationError = null; }); - final error = await widget.submit( - _titleController.text, - _effectiveFileName, - ); - if (!mounted) return; - if (error == null) { + final created = await ref + .read(workspaceControllerProvider.notifier) + .createWritersideTopic( + WritersideTopicCreateRequest( + title: _titleController.text.trim(), + fileName: _effectiveFileName, + format: _format, + placement: _placement, + referenceTocPath: _placement == WritersideTopicCreatePlacement.root + ? null + : widget.referencePath, + referenceTopic: _placement == WritersideTopicCreatePlacement.root + ? null + : widget.referenceTopic, + referenceTocIdentity: + _placement == WritersideTopicCreatePlacement.root + ? null + : widget.referenceIdentity, + ), + instanceTreePath: widget.instanceTreePath, + ); + if (!mounted) { + return; + } + if (created) { Navigator.pop(context); return; } setState(() { _creating = false; - _creationError = error; + final message = ref.read(workspaceControllerProvider).message; + _creationError = message == null + ? context.l10n.createWritersideTopicFailed + : localizeWorkspaceMessage(context, message); }); } + String _slugTopicName(String value) { + final slug = slugForHeading(value); + return slug.isEmpty ? 'new-topic' : slug; + } + String _extensionFor(WritersideTopicFormat format) { return switch (format) { WritersideTopicFormat.markdown => '.md', diff --git a/lib/src/workspace/presentation/writerside_toc_dialogs.dart b/lib/src/workspace/presentation/writerside_toc_dialogs.dart index 432b5f50..4466e5c6 100644 --- a/lib/src/workspace/presentation/writerside_toc_dialogs.dart +++ b/lib/src/workspace/presentation/writerside_toc_dialogs.dart @@ -4,9 +4,7 @@ import 'package:path/path.dart' as p; import 'package:url_launcher/url_launcher.dart'; import '../../app/busymark_design.dart'; -import '../../app/busymark_dialogs.dart'; import '../../app/busymark_search_field.dart'; -import '../../platform/native_writerside_dialog_service.dart'; import '../../app/busymark_glyphs.dart'; import '../../app/localization.dart'; import '../../writerside/writerside_model.dart'; @@ -14,110 +12,6 @@ import '../../writerside/writerside_topic_file_name.dart'; import '../../writerside/writerside_title_editor.dart'; import '../workspace_service.dart'; -Future showWritersideTopicRenameDialog( - BuildContext context, { - required String currentFileName, -}) async { - String? validate(String value) { - try { - validateWritersideTopicFileName( - value, - requiredExtension: p.extension(currentFileName).toLowerCase(), - ); - return null; - } on Object { - return context.l10n.errorTopicFileNameInvalid; - } - } - - final native = await showBusyMarkNativeDialog( - context, - showDialog: () => const NativeWritersideDialogService().showRenameTopic( - title: context.l10n.rename, - fileNameLabel: context.l10n.fileName, - initialValue: currentFileName, - cancelLabel: context.l10n.cancel, - previewLabel: context.l10n.preview, - refactorLabel: context.l10n.tocRefactorMenu, - textDirection: Directionality.of(context), - validate: validate, - ), - ); - if (native?.available != false) { - final value = native?.value; - return value == null - ? null - : WritersideTopicRenameDialogResult( - fileName: value.fileName, - action: value.preview - ? WritersideTopicRenameDialogAction.preview - : WritersideTopicRenameDialogAction.refactor, - ); - } - if (!context.mounted) return null; - return showBusyMarkModalDialog( - context, - barrierDismissible: false, - builder: (_) => - WritersideTopicRenameDialog(currentFileName: currentFileName), - ); -} - -Future showWritersideTocTextDialog( - BuildContext context, { - required String title, - required String label, - bool enterOnly = false, -}) async { - final native = await showBusyMarkNativeDialog( - context, - showDialog: () => const NativeWritersideDialogService().showTocText( - title: title, - label: label, - enterOnly: enterOnly, - cancelLabel: context.l10n.cancel, - okLabel: context.l10n.tocOk, - requiredError: context.l10n.topicTitleRequired, - textDirection: Directionality.of(context), - ), - ); - if (native?.available != false) return native?.value; - if (!context.mounted) return null; - return showBusyMarkModalDialog( - context, - builder: (_) => WritersideTocTextDialog( - title: title, - label: label, - enterOnly: enterOnly, - ), - ); -} - -Future showWritersideExistingTopicPicker( - BuildContext context, { - required List topics, -}) async { - final native = await showBusyMarkNativeDialog( - context, - showDialog: () => - const NativeWritersideDialogService().showExistingTopicPicker( - title: context.l10n.tocSelectExistingTopic, - searchLabel: context.l10n.search, - fileNames: [for (final topic in topics) topic.fileName], - textDirection: Directionality.of(context), - ), - ); - if (native?.available != false) { - final index = native?.value; - return index == null ? null : topics[index]; - } - if (!context.mounted) return null; - return showBusyMarkModalDialog( - context, - builder: (_) => WritersideExistingTopicPicker(topics: topics), - ); -} - enum WritersideTopicRenameDialogAction { preview, refactor } class WritersideTopicRenameDialogResult { diff --git a/lib/src/workspace/presentation/writerside_topic_creation_form.dart b/lib/src/workspace/presentation/writerside_topic_creation_form.dart deleted file mode 100644 index d1782ad2..00000000 --- a/lib/src/workspace/presentation/writerside_topic_creation_form.dart +++ /dev/null @@ -1,64 +0,0 @@ -import 'package:path/path.dart' as p; - -import '../../../l10n/generated/app_localizations.dart'; -import '../../core/path_utils.dart'; -import '../../writerside/writerside_model.dart'; -import '../../writerside/writerside_topic_file_name.dart'; - -/// The filename and validation rules shared by the GTK form and its fallback. -final class WritersideTopicCreationForm { - const WritersideTopicCreationForm({ - required this.format, - required this.existingIds, - required this.l10n, - }); - - final WritersideTopicFormat format; - final Set existingIds; - final AppLocalizations l10n; - - String get extension => switch (format) { - WritersideTopicFormat.markdown => '.md', - WritersideTopicFormat.xml => '.topic', - }; - - String fileNameForTitle(String title) { - final slug = slugForHeading(title); - return '${slug.isEmpty ? 'new-topic' : slug}$extension'; - } - - String? titleError(String title) => - title.trim().isEmpty ? l10n.topicTitleRequired : null; - - String effectiveFileName(String fileName) { - final value = fileName.trim(); - return p.extension(value).isEmpty ? '$value$extension' : value; - } - - String? fileNameError(String fileName) { - final value = fileName.trim(); - if (value.isEmpty) return l10n.fileNameRequired; - final suppliedExtension = p.extension(value).toLowerCase(); - if (suppliedExtension.isNotEmpty && suppliedExtension != extension) { - return l10n.useExpectedExtension(extension); - } - final effective = effectiveFileName(value); - try { - validateWritersideTopicFileName(effective, requiredExtension: extension); - } on Object { - if (value == '.' || - value == '..' || - p.isAbsolute(value) || - value.contains('/') || - value.contains(r'\') || - value.contains('\u0000')) { - return l10n.useSingleSafeFileName; - } - return l10n.useIdentifierCharacters; - } - if (existingIds.contains(p.basenameWithoutExtension(effective))) { - return l10n.topicIdAlreadyExists; - } - return null; - } -} diff --git a/linux/runner/writerside_dialog_host.cc b/linux/runner/writerside_dialog_host.cc index 724e2a17..d8fa6e43 100644 --- a/linux/runner/writerside_dialog_host.cc +++ b/linux/runner/writerside_dialog_host.cc @@ -1,9 +1,6 @@ #include "writerside_dialog_host.h" #include -#include -#include -#include namespace { @@ -40,10 +37,20 @@ GtkTextDirection requested_direction(FlValue* args, GtkWidget* fallback) { return gtk_widget_get_direction(fallback); } +void set_dialog_direction(GtkWidget* widget, gpointer user_data) { + const auto direction = *static_cast(user_data); + gtk_widget_set_direction(widget, direction); + if (GTK_IS_CONTAINER(widget)) { + gtk_container_forall(GTK_CONTAINER(widget), set_dialog_direction, user_data); + } +} + void configure_dialog(GtkWidget* dialog, GtkTextDirection direction, gint width) { - gtk_widget_set_direction(dialog, direction); + // GTK children otherwise keep the desktop's default direction. Configure + // the completed tree, including internal dialog/action widgets. + set_dialog_direction(dialog, &direction); gtk_window_set_resizable(GTK_WINDOW(dialog), FALSE); gtk_window_set_default_size(GTK_WINDOW(dialog), width, -1); gtk_dialog_set_default_response(GTK_DIALOG(dialog), GTK_RESPONSE_OK); @@ -170,7 +177,6 @@ void show_duplicate_topic(DialogHandlerData* data, static_cast(GTK_DIALOG_MODAL | GTK_DIALOG_DESTROY_WITH_PARENT), cancel_label, GTK_RESPONSE_CANCEL, ok_label, GTK_RESPONSE_OK, nullptr); - configure_dialog(dialog, requested_direction(args, data->view), 470); GtkWidget* ok_button = gtk_dialog_get_widget_for_response(GTK_DIALOG(dialog), GTK_RESPONSE_OK); @@ -197,6 +203,8 @@ void show_duplicate_topic(DialogHandlerData* data, g_signal_connect(entry, "changed", G_CALLBACK(update_duplicate_validation), &validation); update_duplicate_validation(GTK_EDITABLE(entry), &validation); + configure_dialog(dialog, requested_direction(args, data->view), 470); + gtk_widget_set_direction(entry, GTK_TEXT_DIR_LTR); gtk_widget_show_all(dialog); gtk_widget_set_visible(error_label, gtk_label_get_text(GTK_LABEL(error_label))[0] != '\0'); @@ -284,7 +292,6 @@ void show_edit_title(DialogHandlerData* data, GTK_DIALOG_DESTROY_WITH_PARENT), lookup_string(args, "cancelLabel"), GTK_RESPONSE_CANCEL, lookup_string(args, "okLabel"), GTK_RESPONSE_OK, nullptr); - configure_dialog(dialog, requested_direction(args, data->view), 560); GtkWidget* ok_button = gtk_dialog_get_widget_for_response(GTK_DIALOG(dialog), GTK_RESPONSE_OK); GtkWidget* content = gtk_dialog_get_content_area(GTK_DIALOG(dialog)); @@ -345,6 +352,7 @@ void show_edit_title(DialogHandlerData* data, ok_button); update_title_inheritance(GTK_EDITABLE(topic_entry), &inheritance); update_title_validity(GTK_EDITABLE(topic_entry), ok_button); + configure_dialog(dialog, requested_direction(args, data->view), 560); gtk_widget_show_all(dialog); gtk_widget_grab_focus(topic_entry); gtk_editable_select_region(GTK_EDITABLE(topic_entry), 0, -1); @@ -379,487 +387,7 @@ void show_edit_title(DialogHandlerData* data, fl_method_call_respond_success(call, result, nullptr); } -// Interactive forms leave the show call pending until cancellation or success. -// GTK owns the widgets; Dart owns validation, slugging and workspace mutation. -struct InteractiveDialog { - FlMethodChannel* channel; - FlMethodCall* call; - GWeakRef view; - GtkWidget* dialog = nullptr; - GtkWidget* entry = nullptr; - GtkWidget* title_entry = nullptr; - GtkWidget* error_label = nullptr; - GtkWidget* title_error = nullptr; - GtkWidget* ok_button = nullptr; - GtkWidget* preview_button = nullptr; - GtkWidget* cancel_button = nullptr; - GtkWidget* list = nullptr; - GtkWidget* scroll = nullptr; - std::vector file_names; - gint64 session; - guint revision = 0; - bool create = false; - bool rename = false; - bool picker = false; - bool applying = false; - bool file_name_edited = false; - bool pending = false; - bool valid = false; - bool finished = false; - - InteractiveDialog(FlMethodChannel* channel, FlMethodCall* call, - GtkWidget* view, gint64 session) - : channel(FL_METHOD_CHANNEL(g_object_ref(channel))), - call(FL_METHOD_CALL(g_object_ref(call))), - session(session) { - g_weak_ref_init(&this->view, G_OBJECT(view)); - } - ~InteractiveDialog() { - g_weak_ref_clear(&view); - g_object_unref(call); - g_object_unref(channel); - } -}; -using InteractivePtr = std::shared_ptr; - -InteractivePtr interactive_ref(InteractiveDialog* state) { - return *static_cast( - g_object_get_data(G_OBJECT(state->dialog), "writerside-state")); -} - -void finish_interactive(const InteractivePtr& state, FlValue* value, - const char* error = nullptr) { - if (state->finished) return; - state->finished = true; - if (error != nullptr) { - fl_method_call_respond_error(state->call, error, - "Native dialog interaction failed.", nullptr, - nullptr); - } else { - fl_method_call_respond_success(state->call, value, nullptr); - } - GtkWidget* dialog = state->dialog; - state->dialog = nullptr; - if (dialog != nullptr) gtk_widget_destroy(dialog); - g_autoptr(GObject) view = G_OBJECT(g_weak_ref_get(&state->view)); - if (view != nullptr && gtk_widget_get_realized(GTK_WIDGET(view))) { - gtk_widget_grab_focus(GTK_WIDGET(view)); - } -} - -void set_interactive_sensitive(InteractiveDialog* state) { - gtk_widget_set_sensitive(state->entry, !state->pending); - if (state->title_entry != nullptr) { - gtk_widget_set_sensitive(state->title_entry, !state->pending); - } - if (state->ok_button != nullptr) { - gtk_widget_set_sensitive(state->ok_button, state->valid && !state->pending); - } - if (state->preview_button != nullptr) { - gtk_widget_set_sensitive(state->preview_button, - state->valid && !state->pending); - } - if (state->cancel_button != nullptr) { - gtk_widget_set_sensitive(state->cancel_button, !state->pending); - } - gtk_window_set_deletable(GTK_WINDOW(state->dialog), !state->pending); -} - -void set_error_label(GtkWidget* label, const gchar* error) { - gtk_label_set_text(GTK_LABEL(label), error == nullptr ? "" : error); - gtk_widget_set_visible(label, error != nullptr && error[0] != '\0'); -} - -struct InteractiveReply { - InteractivePtr state; - guint revision; - bool submit; -}; - -void interactive_reply_cb(GObject* object, GAsyncResult* result, - gpointer user_data) { - std::unique_ptr reply( - static_cast(user_data)); - const auto state = reply->state; - g_autoptr(GError) error = nullptr; - g_autoptr(FlMethodResponse) response = fl_method_channel_invoke_method_finish( - FL_METHOD_CHANNEL(object), result, &error); - if (state->finished) return; - if (reply->revision != state->revision) return; - FlValue* value = response == nullptr - ? nullptr - : fl_method_response_get_result(response, &error); - if (value == nullptr || error != nullptr) { - finish_interactive(state, nullptr, "dialog-callback-failed"); - return; - } - if (state->picker) { - if (fl_value_get_type(value) != FL_VALUE_TYPE_LIST) { - finish_interactive(state, nullptr, "invalid-result"); - return; - } - GList* children = gtk_container_get_children(GTK_CONTAINER(state->list)); - for (GList* item = children; item != nullptr; item = item->next) { - gtk_widget_destroy(GTK_WIDGET(item->data)); - } - g_list_free(children); - for (size_t i = 0; i < fl_value_get_length(value); ++i) { - FlValue* index = fl_value_get_list_value(value, i); - if (fl_value_get_type(index) != FL_VALUE_TYPE_INT || - fl_value_get_int(index) < 0 || - static_cast(fl_value_get_int(index)) >= - state->file_names.size()) { - finish_interactive(state, nullptr, "invalid-result"); - return; - } - const gint original = static_cast(fl_value_get_int(index)); - GtkWidget* row = gtk_list_box_row_new(); - GtkWidget* label = - left_aligned_label(state->file_names[original].c_str()); - gtk_widget_set_margin_start(label, 8); - gtk_widget_set_margin_end(label, 8); - gtk_widget_set_size_request(row, -1, 48); - gtk_container_add(GTK_CONTAINER(row), label); - g_object_set_data(G_OBJECT(row), "topic-index", - GINT_TO_POINTER(original)); - gtk_container_add(GTK_CONTAINER(state->list), row); - } - gtk_widget_show_all(state->list); - gtk_list_box_select_row( - GTK_LIST_BOX(state->list), - gtk_list_box_get_row_at_index(GTK_LIST_BOX(state->list), 0)); - gtk_adjustment_set_value( - gtk_scrolled_window_get_vadjustment(GTK_SCROLLED_WINDOW(state->scroll)), - 0); - state->valid = true; - return; - } - if (fl_value_get_type(value) != FL_VALUE_TYPE_MAP) { - finish_interactive(state, nullptr, "invalid-result"); - return; - } - if (state->create) { - const gchar* file_name = lookup_string(value, "fileName"); - FlValue* created = fl_value_lookup_string(value, "created"); - if (file_name == nullptr || created == nullptr || - fl_value_get_type(created) != FL_VALUE_TYPE_BOOL) { - finish_interactive(state, nullptr, "invalid-result"); - return; - } - if (reply->submit && fl_value_get_bool(created)) { - g_autoptr(FlValue) success = fl_value_new_bool(TRUE); - finish_interactive(state, success); - return; - } - state->applying = true; - if (g_strcmp0(gtk_entry_get_text(GTK_ENTRY(state->entry)), file_name) != - 0) { - gtk_entry_set_text(GTK_ENTRY(state->entry), file_name); - } - state->applying = false; - const gchar* title_error = lookup_string(value, "titleError"); - const gchar* file_error = lookup_string(value, "fileNameError"); - const gchar* creation_error = lookup_string(value, "error"); - set_error_label(state->title_error, title_error); - set_error_label(state->error_label, - creation_error != nullptr ? creation_error : file_error); - state->valid = title_error == nullptr && file_error == nullptr; - } else { - const gchar* validation_error = lookup_string(value, "error"); - set_error_label(state->error_label, validation_error); - state->valid = validation_error == nullptr; - } - state->pending = false; - set_interactive_sensitive(state.get()); - if (reply->submit) gtk_widget_grab_focus(state->entry); -} - -void send_interactive_event(InteractiveDialog* state, bool submit = false) { - if (state->finished || state->applying || state->pending) return; - if (submit) state->pending = true; - state->valid = false; - set_interactive_sensitive(state); - ++state->revision; - g_autoptr(FlValue) args = fl_value_new_map(); - fl_value_set_string_take(args, "session", fl_value_new_int(state->session)); - fl_value_set_string_take(args, "event", - fl_value_new_string(state->picker ? "filter" - : submit ? "submit" - : "changed")); - const gchar* entry = gtk_entry_get_text(GTK_ENTRY(state->entry)); - if (state->create) { - fl_value_set_string_take( - args, "title", - fl_value_new_string(gtk_entry_get_text(GTK_ENTRY(state->title_entry)))); - fl_value_set_string_take(args, "fileName", fl_value_new_string(entry)); - fl_value_set_string_take(args, "fileNameEdited", - fl_value_new_bool(state->file_name_edited)); - } else { - fl_value_set_string_take(args, "value", fl_value_new_string(entry)); - } - auto* reply = - new InteractiveReply{interactive_ref(state), state->revision, submit}; - fl_method_channel_invoke_method(state->channel, "writersideDialogEvent", args, - nullptr, interactive_reply_cb, reply); -} - -void interactive_changed(GtkEditable* entry, gpointer user_data) { - auto* state = static_cast(user_data); - if (state->applying || state->pending) return; - if (state->create && GTK_WIDGET(entry) == state->entry) { - state->file_name_edited = true; - } - send_interactive_event(state); -} - -void interactive_response(GtkDialog*, gint response, gpointer user_data) { - auto* state = static_cast(user_data); - if (state->pending || state->finished) return; - if (response != GTK_RESPONSE_OK && response != GTK_RESPONSE_APPLY) { - g_autoptr(FlValue) cancelled = fl_value_new_null(); - finish_interactive(interactive_ref(state), cancelled); - return; - } - if (!state->valid) return; - if (state->create) { - send_interactive_event(state, true); - } else if (state->picker) { - GtkListBoxRow* row = - gtk_list_box_get_selected_row(GTK_LIST_BOX(state->list)); - if (row == nullptr) return; - g_autoptr(FlValue) selected = fl_value_new_int( - GPOINTER_TO_INT(g_object_get_data(G_OBJECT(row), "topic-index"))); - finish_interactive(interactive_ref(state), selected); - } else { - const gchar* text = gtk_entry_get_text(GTK_ENTRY(state->entry)); - g_autoptr(FlValue) selected = - state->rename ? fl_value_new_map() : fl_value_new_string(text); - if (state->rename) { - fl_value_set_string_take(selected, "value", fl_value_new_string(text)); - fl_value_set_string_take( - selected, "preview", - fl_value_new_bool(response == GTK_RESPONSE_APPLY)); - } - finish_interactive(interactive_ref(state), selected); - } -} - -gboolean interactive_delete(GtkWidget*, GdkEvent*, gpointer user_data) { - auto* state = static_cast(user_data); - if (!state->pending) { - interactive_response(nullptr, GTK_RESPONSE_CANCEL, state); - } - return TRUE; -} - -gboolean interactive_key(GtkWidget*, GdkEventKey* event, gpointer user_data) { - auto* state = static_cast(user_data); - if (event->keyval == GDK_KEY_Escape) { - interactive_response(nullptr, GTK_RESPONSE_CANCEL, state); - return TRUE; - } - if (!state->picker || - (event->keyval != GDK_KEY_Down && event->keyval != GDK_KEY_Up)) - return FALSE; - if (!state->valid) return TRUE; - GtkListBox* list = GTK_LIST_BOX(state->list); - GtkListBoxRow* current = gtk_list_box_get_selected_row(list); - gint index = current == nullptr ? 0 : gtk_list_box_row_get_index(current); - gint next = MAX(0, index + (event->keyval == GDK_KEY_Down ? 1 : -1)); - GtkListBoxRow* row = gtk_list_box_get_row_at_index(list, next); - if (row == nullptr) row = current; - if (row != nullptr) { - gtk_list_box_select_row(list, row); - GtkAllocation allocation; - gtk_widget_get_allocation(GTK_WIDGET(row), &allocation); - GtkAdjustment* adjustment = - gtk_scrolled_window_get_vadjustment(GTK_SCROLLED_WINDOW(state->scroll)); - gtk_adjustment_clamp_page(adjustment, allocation.y, - allocation.y + allocation.height); - } - return TRUE; -} - -void show_interactive(DialogHandlerData* data, FlMethodChannel* channel, - FlMethodCall* call, FlValue* args, const gchar* method) { - FlValue* session = - args == nullptr ? nullptr : fl_value_lookup_string(args, "session"); - const bool picker = std::strcmp(method, "showExistingTopicPicker") == 0; - const bool create = std::strcmp(method, "showCreateTopic") == 0; - const bool rename = std::strcmp(method, "showRenameTopic") == 0; - const gchar* title = lookup_string(args, "title"); - if (session == nullptr || fl_value_get_type(session) != FL_VALUE_TYPE_INT || - title == nullptr) { - respond_invalid_arguments(call, - "Interactive dialog arguments are invalid."); - return; - } - const gchar* keys[] = {"fileNameLabel", "cancelLabel", "okLabel"}; - if (!picker) { - for (const gchar* key : keys) { - if (lookup_string(args, key) == nullptr) { - respond_invalid_arguments(call, - "Interactive dialog labels are invalid."); - return; - } - } - } - if ((create && (lookup_string(args, "titleLabel") == nullptr || - lookup_string(args, "initialTitle") == nullptr || - lookup_string(args, "initialFileName") == nullptr)) || - (!create && !picker && lookup_string(args, "initialValue") == nullptr) || - (rename && lookup_string(args, "previewLabel") == nullptr) || - (picker && lookup_string(args, "searchLabel") == nullptr)) { - respond_invalid_arguments(call, "Interactive dialog fields are invalid."); - return; - } - FlValue* names = picker ? fl_value_lookup_string(args, "fileNames") : nullptr; - if (picker && - (names == nullptr || fl_value_get_type(names) != FL_VALUE_TYPE_LIST)) { - respond_invalid_arguments(call, "Topic filenames are invalid."); - return; - } - auto state = std::make_shared(channel, call, data->view, - fl_value_get_int(session)); - state->create = create; - state->rename = rename; - state->picker = picker; - if (picker) { - for (size_t i = 0; i < fl_value_get_length(names); ++i) { - FlValue* name = fl_value_get_list_value(names, i); - if (fl_value_get_type(name) != FL_VALUE_TYPE_STRING) { - respond_invalid_arguments(call, "Topic filenames must be strings."); - return; - } - state->file_names.emplace_back(fl_value_get_string(name)); - } - } - state->dialog = gtk_dialog_new_with_buttons( - title, data->parent, - static_cast(GTK_DIALOG_MODAL | - GTK_DIALOG_DESTROY_WITH_PARENT), - nullptr, nullptr); - configure_dialog(state->dialog, requested_direction(args, data->view), 470); - FlValue* enter_only_value = - picker ? nullptr : fl_value_lookup_string(args, "enterOnly"); - const bool enter_only = - enter_only_value != nullptr && - fl_value_get_type(enter_only_value) == FL_VALUE_TYPE_BOOL && - fl_value_get_bool(enter_only_value); - if (!picker) { - state->cancel_button = gtk_dialog_add_button( - GTK_DIALOG(state->dialog), lookup_string(args, "cancelLabel"), - GTK_RESPONSE_CANCEL); - if (rename) { - state->preview_button = gtk_dialog_add_button( - GTK_DIALOG(state->dialog), lookup_string(args, "previewLabel"), - GTK_RESPONSE_APPLY); - } - state->ok_button = - gtk_dialog_add_button(GTK_DIALOG(state->dialog), - lookup_string(args, "okLabel"), GTK_RESPONSE_OK); - if (enter_only) { - gtk_widget_set_no_show_all(state->cancel_button, TRUE); - gtk_widget_set_no_show_all(state->ok_button, TRUE); - } - // configure_dialog runs before these actions exist. Assign the default - // after installing the OK action. - gtk_dialog_set_default_response(GTK_DIALOG(state->dialog), GTK_RESPONSE_OK); - } - GtkWidget* content = gtk_dialog_get_content_area(GTK_DIALOG(state->dialog)); - if (create) { - state->title_entry = gtk_entry_new(); - gtk_entry_set_text(GTK_ENTRY(state->title_entry), - lookup_string(args, "initialTitle")); - GtkWidget* label = left_aligned_label(lookup_string(args, "titleLabel")); - gtk_label_set_mnemonic_widget(GTK_LABEL(label), state->title_entry); - gtk_box_pack_start(GTK_BOX(content), label, FALSE, FALSE, 0); - gtk_box_pack_start(GTK_BOX(content), state->title_entry, FALSE, FALSE, 0); - state->title_error = left_aligned_label(""); - gtk_style_context_add_class( - gtk_widget_get_style_context(state->title_error), "error"); - gtk_box_pack_start(GTK_BOX(content), state->title_error, FALSE, FALSE, 0); - } - state->entry = picker ? gtk_search_entry_new() : gtk_entry_new(); - if (create || rename) - gtk_widget_set_direction(state->entry, GTK_TEXT_DIR_LTR); - if (picker) { - const gchar* search_label = lookup_string(args, "searchLabel"); - gtk_entry_set_placeholder_text(GTK_ENTRY(state->entry), search_label); - } else { - gtk_entry_set_text( - GTK_ENTRY(state->entry), - lookup_string(args, create ? "initialFileName" : "initialValue")); - GtkWidget* label = left_aligned_label(lookup_string(args, "fileNameLabel")); - gtk_label_set_mnemonic_widget(GTK_LABEL(label), state->entry); - gtk_box_pack_start(GTK_BOX(content), label, FALSE, FALSE, 0); - } - gtk_box_pack_start(GTK_BOX(content), state->entry, FALSE, FALSE, 0); - state->error_label = left_aligned_label(""); - gtk_style_context_add_class(gtk_widget_get_style_context(state->error_label), - "error"); - gtk_box_pack_start(GTK_BOX(content), state->error_label, FALSE, FALSE, 0); - if (picker) { - state->scroll = gtk_scrolled_window_new(nullptr, nullptr); - gtk_widget_set_size_request(state->scroll, -1, 300); - state->list = gtk_list_box_new(); - gtk_list_box_set_activate_on_single_click(GTK_LIST_BOX(state->list), TRUE); - gtk_container_add(GTK_CONTAINER(state->scroll), state->list); - gtk_box_pack_start(GTK_BOX(content), state->scroll, TRUE, TRUE, 0); - g_signal_connect( - state->list, "row-activated", - G_CALLBACK( - +[](GtkListBox* list, GtkListBoxRow* row, gpointer user_data) { - gtk_list_box_select_row(list, row); - interactive_response(nullptr, GTK_RESPONSE_OK, user_data); - }), - state.get()); - } - // Explicit activation also supports the Group form without visible actions. - g_signal_connect(state->entry, "activate", - G_CALLBACK(+[](GtkEntry*, gpointer user_data) { - interactive_response(nullptr, GTK_RESPONSE_OK, user_data); - }), - state.get()); - if (create) { - g_signal_connect(state->title_entry, "activate", - G_CALLBACK(+[](GtkEntry*, gpointer user_data) { - interactive_response(nullptr, GTK_RESPONSE_OK, - user_data); - }), - state.get()); - g_signal_connect(state->title_entry, "changed", - G_CALLBACK(interactive_changed), state.get()); - } - g_signal_connect(state->entry, "changed", G_CALLBACK(interactive_changed), - state.get()); - g_signal_connect(state->dialog, "response", G_CALLBACK(interactive_response), - state.get()); - g_signal_connect(state->dialog, "key-press-event", - G_CALLBACK(interactive_key), state.get()); - g_signal_connect(state->dialog, "delete-event", - G_CALLBACK(interactive_delete), state.get()); - g_object_set_data_full( - G_OBJECT(state->dialog), "writerside-state", new InteractivePtr(state), - +[](gpointer ptr) { delete static_cast(ptr); }); - g_signal_connect(state->dialog, "destroy", - G_CALLBACK(+[](GtkWidget*, gpointer user_data) { - auto* state = static_cast(user_data); - if (state->finished) return; - auto retained = interactive_ref(state); - state->dialog = nullptr; - g_autoptr(FlValue) cancelled = fl_value_new_null(); - finish_interactive(retained, cancelled); - }), - state.get()); - gtk_widget_show_all(state->dialog); - set_error_label(state->error_label, nullptr); - if (create) set_error_label(state->title_error, nullptr); - gtk_widget_grab_focus(create ? state->title_entry : state->entry); - send_interactive_event(state.get()); -} - -void method_call_cb(FlMethodChannel* channel, +void method_call_cb(FlMethodChannel*, FlMethodCall* call, gpointer user_data) { auto* data = static_cast(user_data); @@ -875,11 +403,6 @@ void method_call_cb(FlMethodChannel* channel, show_duplicate_topic(data, call, fl_method_call_get_args(call)); } else if (std::strcmp(method, "showEditTitle") == 0) { show_edit_title(data, call, fl_method_call_get_args(call)); - } else if (std::strcmp(method, "showCreateTopic") == 0 || - std::strcmp(method, "showRenameTopic") == 0 || - std::strcmp(method, "showTocText") == 0 || - std::strcmp(method, "showExistingTopicPicker") == 0) { - show_interactive(data, channel, call, fl_method_call_get_args(call), method); } else { fl_method_call_respond_not_implemented(call, nullptr); } diff --git a/test/src/app_smoke_test.dart b/test/src/app_smoke_test.dart index 89305833..944d1958 100644 --- a/test/src/app_smoke_test.dart +++ b/test/src/app_smoke_test.dart @@ -1,5 +1,5 @@ +import 'package:busymark/src/core/path_utils.dart'; import 'package:busymark/src/platform/native_writerside_dialog_service.dart'; -import '../support/native_writerside_dialog_host.dart'; import 'dart:async'; import 'dart:convert'; import 'dart:io'; @@ -3470,9 +3470,9 @@ code expect(container.read(workspaceControllerProvider).activeText, source); }); - for (final nativeCreation in [false, true]) { + for (final nativeHostAvailable in [false, true]) { testWidgets( - 'Table of Contents creates from selection and exposes nested Writerside actions${nativeCreation ? ' via native dialogs' : ''}', + 'Table of Contents creates from selection and exposes nested Writerside actions${nativeHostAvailable ? ' with an available native host' : ''}', (tester) async { const yaruWindowChannel = MethodChannel('yaru_window'); const yaruWindowEventsChannel = MethodChannel('yaru_window/events'); @@ -3570,9 +3570,8 @@ code activeText: '# Nested\n', ); final controller = _MutableWorkspaceController(workspaceState); - var cancelNativeCreation = false; final nativeCalls = []; - if (nativeCreation) { + if (nativeHostAvailable) { const channel = MethodChannel(nativeWritersideDialogChannelName); tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( channel, @@ -3581,22 +3580,7 @@ code throw MissingPluginException(); } nativeCalls.add(call); - if (cancelNativeCreation) { - cancelNativeCreation = false; - return null; - } - final host = TestNativeWritersideDialogHost() - ..arguments = call.arguments as Map; - final result = - await host.event({ - 'event': 'submit', - 'title': host.arguments['initialTitle'], - 'fileName': host.arguments['initialFileName'], - 'fileNameEdited': false, - }) - as Map; - expect(result['created'], isTrue); - return true; + return null; }, ); addTearDown( @@ -3605,11 +3589,10 @@ code ); } Future acceptCreation() async { - if (nativeCreation) { - expect(find.byType(BusyMarkDialogShell), findsNothing); - } else { - await tester.tap(find.text(l10n.tocOk)); - } + expect(find.byType(BusyMarkModalEditorScaffold), findsOneWidget); + expect(find.byType(Dialog), findsOneWidget); + expect(nativeCalls, isEmpty); + await tester.tap(find.text(l10n.tocOk)); await tester.pumpAndSettle(); } @@ -3970,15 +3953,13 @@ code await tester.tap(find.text(l10n.cancel)); await tester.pumpAndSettle(); - cancelNativeCreation = nativeCreation; await openPopup(find.byTooltip(l10n.newTopic)); await tester.tap(find.text(l10n.tocEmptyMdTopic)); await tester.pumpAndSettle(); - if (!nativeCreation) { - await tester.sendKeyEvent(LogicalKeyboardKey.escape); - await tester.pumpAndSettle(); - } - expect(find.byType(BusyMarkDialogShell), findsNothing); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + + expect(find.byType(BusyMarkModalEditorScaffold), findsNothing); expect(controller.createdTopicRequest, isNull); await openPopup(find.byTooltip(l10n.newTopic)); @@ -3987,101 +3968,138 @@ code await tester.tap(find.text(l10n.tocEmptyMdTopic)); await tester.pumpAndSettle(); - if (!nativeCreation) { - expect(find.text(l10n.newTopic), findsOneWidget); - expect(find.byType(BusyMarkDialogShell), findsOneWidget); - expect( - find.descendant( - of: find.byType(BusyMarkDialogShell), - matching: find.byType(BusyMarkGroupedTextEntry), - ), - findsNWidgets(2), - ); - expect(find.text(l10n.topicPlacement), findsNothing); - } - if (!nativeCreation) { - final entries = find.byType(BusyMarkGroupedTextEntry); - final title = entries.at(0); - final name = entries.at(1); - expect( - tester.widget(title).controller!.text, - l10n.defaultNewTopicTitle, - ); - await tester.enterText(title, 'Hello World'); - await tester.pump(); - expect( - tester.widget(name).controller!.text, - 'hello-world.md', - ); - await tester.enterText(name, 'manual'); - await tester.enterText(title, 'Changed title'); - await tester.pump(); - expect( - tester.widget(name).controller!.text, - 'manual', - ); - for (final invalid in [ - '', - '../unsafe.md', - 'CON.md', - 'parent.md', - 'wrong.topic', - ]) { - await tester.enterText(name, invalid); - await tester.pump(); - expect( - tester - .widget( - find.byWidgetPredicate( - (widget) => - widget is BusyMarkDialogButton && - widget.label == l10n.tocOk, - ), - ) - .onPressed, - isNull, - ); - } - await tester.enterText(name, 'root-topic'); - await tester.enterText(title, ' '); - await tester.pump(); - expect(find.text(l10n.topicTitleRequired), findsOneWidget); - await tester.enterText(title, 'Created Root'); - await tester.pump(); - final pendingCreation = Completer(); - controller.createTopic = (_) => pendingCreation.future; - await tester.tap(find.text(l10n.tocOk)); + expect(find.text(l10n.newTopic), findsOneWidget); + expect(find.byType(BusyMarkModalEditorScaffold), findsOneWidget); + expect(find.byType(BusyMarkModalEditorSurface), findsOneWidget); + expect( + tester.getRect(find.byType(Dialog)).bottom - + tester.getRect(find.byType(BusyMarkGroupedList)).bottom, + greaterThanOrEqualTo(BusyMarkSpacing.lg), + ); + expect(find.byType(BusyMarkEditorHeader), findsOneWidget); + expect(find.byType(Dialog), findsOneWidget); + expect(find.byType(BusyMarkDialogShell), findsNothing); + expect( + tester + .widget(find.byType(BusyMarkGroupedList)) + .filled, + isTrue, + ); + expect(nativeCalls, isEmpty); + expect( + find.descendant( + of: find.byType(BusyMarkModalEditorScaffold), + matching: find.byType(BusyMarkGroupedTextEntry), + ), + findsNWidgets(2), + ); + expect(find.text(l10n.topicPlacement), findsNothing); + + final entries = find.byType(BusyMarkGroupedTextEntry); + final title = entries.at(0); + final name = entries.at(1); + expect( + tester.widget(title).controller!.text, + l10n.defaultNewTopicTitle, + ); + await tester.enterText(title, 'Résumé 世界'); + await tester.pump(); + expect( + tester.widget(name).controller!.text, + '${slugForHeading('Résumé 世界')}.md', + ); + await tester.enterText(title, 'Hello World'); + await tester.pump(); + expect( + tester.widget(name).controller!.text, + 'hello-world.md', + ); + await tester.enterText(name, 'manual'); + await tester.enterText(title, 'Changed title'); + await tester.pump(); + expect( + tester.widget(name).controller!.text, + 'manual', + ); + for (final invalid in [ + '', + '../unsafe.md', + 'CON.md', + 'parent.md', + 'wrong.topic', + ]) { + await tester.enterText(name, invalid); await tester.pump(); expect( tester - .widget(find.byType(BusyMarkDialogShell)) - .closable, - isFalse, - ); - expect( - tester.widget(title).enabled, - isFalse, - ); - await tester.sendKeyEvent(LogicalKeyboardKey.escape); - await tester.pump(); - expect(find.byType(BusyMarkDialogShell), findsOneWidget); - final count = controller.createTopicCount; - await tester.testTextInput.receiveAction(TextInputAction.done); - await tester.pump(); - expect(controller.createTopicCount, count); - pendingCreation.complete(false); - await tester.pumpAndSettle(); - expect(find.text(l10n.createWritersideTopicFailed), findsOneWidget); - expect( - tester.widget(title).controller!.text, - 'Created Root', - ); - expect( - tester.widget(name).controller!.text, - 'root-topic', + .widget( + find.byWidgetPredicate( + (widget) => + widget is ElevatedButton && + widget.child is Text && + (widget.child! as Text).data == l10n.tocOk, + ), + ) + .onPressed, + isNull, ); - controller.createTopic = null; } + await tester.enterText(name, 'root-topic'); + await tester.enterText(title, ' '); + await tester.pump(); + expect(find.text(l10n.topicTitleRequired), findsOneWidget); + await tester.enterText(title, 'Created Root'); + await tester.pump(); + final pendingCreation = Completer(); + controller.createTopic = (_) => pendingCreation.future; + final submissionsBeforeEnter = controller.createTopicCount; + await tester.tap(title); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(); + expect(controller.createTopicCount, submissionsBeforeEnter); + final filenameEditor = tester.widget( + find.descendant(of: name, matching: find.byType(EditableText)), + ); + expect(filenameEditor.focusNode.hasFocus, isTrue); + await tester.tap(title); + await tester.pump(); + await tester.testTextInput.receiveAction(TextInputAction.next); + await tester.pump(); + expect(controller.createTopicCount, submissionsBeforeEnter); + expect(filenameEditor.focusNode.hasFocus, isTrue); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(); + expect(controller.createTopicCount, submissionsBeforeEnter + 1); + expect( + tester + .widget( + find.byType(BusyMarkModalEditorScaffold), + ) + .cancelEnabled, + isFalse, + ); + expect(tester.widget(title).enabled, isFalse); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pump(); + expect(find.byType(BusyMarkModalEditorScaffold), findsOneWidget); + final count = controller.createTopicCount; + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pump(); + expect(controller.createTopicCount, count); + pendingCreation.complete(false); + await tester.pumpAndSettle(); + expect(find.text(l10n.createWritersideTopicFailed), findsOneWidget); + expect( + tester.widget(title).controller!.text, + 'Created Root', + ); + expect( + tester.widget(name).controller!.text, + 'root-topic', + ); + controller.createTopic = null; + await acceptCreation(); expect(controller.createdTopicRequest, isNotNull); @@ -4214,7 +4232,7 @@ code await tester.pumpAndSettle(); await tester.tap(find.text(l10n.tocEmptyXmlTopic)); await tester.pumpAndSettle(); - if (!nativeCreation) expect(find.text(l10n.newTopic), findsOneWidget); + expect(find.text(l10n.newTopic), findsOneWidget); await acceptCreation(); expect( controller.createdTopicRequest!.placement, @@ -4229,7 +4247,7 @@ code await tester.pumpAndSettle(); await tester.tap(find.text(l10n.tocEmptyMdTopic)); await tester.pumpAndSettle(); - if (!nativeCreation) expect(find.text(l10n.newTopic), findsOneWidget); + expect(find.text(l10n.newTopic), findsOneWidget); await acceptCreation(); expect( controller.createdTopicRequest!.placement, diff --git a/test/src/busymark_dialogs_test.dart b/test/src/busymark_dialogs_test.dart index 6895b3dc..0f40e993 100644 --- a/test/src/busymark_dialogs_test.dart +++ b/test/src/busymark_dialogs_test.dart @@ -11,72 +11,6 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { - testWidgets('native modal coordinates its lifetime and restores focus', ( - tester, - ) async { - const channel = MethodChannel('com.busymark.test/native-modal'); - final depths = []; - tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, ( - call, - ) async { - if (call.method == 'setModalBarrierDepth') { - depths.add(call.arguments as int); - } - return call.method == 'initialize' ? true : null; - }); - addTearDown(() { - tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - channel, - null, - ); - }); - final headerBar = LinuxHeaderBarService(channel: channel); - await headerBar.initialize(); - final previousFocus = FocusNode(); - addTearDown(previousFocus.dispose); - late BuildContext dialogContext; - await tester.pumpWidget( - ProviderScope( - overrides: [linuxHeaderBarServiceProvider.overrideWithValue(headerBar)], - child: MaterialApp( - home: Builder( - builder: (context) { - dialogContext = context; - return Focus(focusNode: previousFocus, child: const SizedBox()); - }, - ), - ), - ), - ); - previousFocus.requestFocus(); - await tester.pump(); - final completion = Completer(); - final result = showBusyMarkNativeDialog( - dialogContext, - showDialog: () => completion.future, - ); - await tester.pumpAndSettle(); - expect(depths, [1]); - expect(find.byType(Dialog), findsNothing); - previousFocus.unfocus(); - await tester.pump(); - completion.complete(true); - await tester.pumpAndSettle(); - expect(await result, isTrue); - expect(depths, [1, 0]); - expect(previousFocus.hasFocus, isTrue); - - final failure = showBusyMarkNativeDialog( - dialogContext, - showDialog: () async => throw PlatformException(code: 'host-failed'), - ); - final checked = expectLater(failure, throwsA(isA())); - await tester.pumpAndSettle(); - await checked; - expect(depths, [1, 0, 1, 0]); - expect(previousFocus.hasFocus, isTrue); - }); - testWidgets('modal dialogs stop app and editor shortcuts', (tester) async { const channel = MethodChannel('com.busymark.test/modal-shortcuts'); tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, ( diff --git a/test/src/native_writerside_dialog_service_test.dart b/test/src/native_writerside_dialog_service_test.dart index 80736c69..a08cdc30 100644 --- a/test/src/native_writerside_dialog_service_test.dart +++ b/test/src/native_writerside_dialog_service_test.dart @@ -1,10 +1,3 @@ -import 'package:busymark/l10n/generated/app_localizations_en.dart'; -import 'package:busymark/src/core/path_utils.dart'; -import 'dart:async'; -import 'package:busymark/l10n/generated/app_localizations_ar.dart'; -import 'package:busymark/src/workspace/presentation/writerside_topic_creation_form.dart'; -import 'package:busymark/src/writerside/writerside_model.dart'; -import '../support/native_writerside_dialog_host.dart'; import 'package:busymark/src/platform/native_writerside_dialog_service.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -12,154 +5,6 @@ import 'package:flutter_test/flutter_test.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); - for (final format in WritersideTopicFormat.values) { - test( - 'New Topic $format preserves filename, slug, case and Unicode rules', - () { - final form = WritersideTopicCreationForm( - format: format, - existingIds: {'taken', 'Case'}, - l10n: AppLocalizationsEn(), - ); - final extension = format == WritersideTopicFormat.markdown - ? '.md' - : '.topic'; - expect(form.titleError(' '), form.l10n.topicTitleRequired); - expect(form.titleError(' Title '), isNull); - expect(form.fileNameForTitle('Hello World'), 'hello-world$extension'); - expect(form.fileNameForTitle('!!!'), 'new-topic$extension'); - expect( - form.fileNameForTitle('Résumé 世界'), - '${slugForHeading('Résumé 世界')}$extension', - ); - expect(form.effectiveFileName(' Manual '), 'Manual$extension'); - expect( - form.effectiveFileName('Manual${extension.toUpperCase()}'), - 'Manual${extension.toUpperCase()}', - ); - expect(form.fileNameError('Manual${extension.toUpperCase()}'), isNull); - expect(form.fileNameError(''), form.l10n.fileNameRequired); - expect( - form.fileNameError('taken$extension'), - form.l10n.topicIdAlreadyExists, - ); - expect( - form.fileNameError('Case$extension'), - form.l10n.topicIdAlreadyExists, - ); - expect(form.fileNameError('case$extension'), isNull); - expect(form.fileNameError('Résumé世界$extension'), isNull); - expect( - form.fileNameError('name.txt'), - form.l10n.useExpectedExtension(extension), - ); - for (final name in [ - '../unsafe$extension', - '/unsafe$extension', - 'unsafe\\name$extension', - 'nul\u0000$extension', - ]) { - expect(form.fileNameError(name), form.l10n.useSingleSafeFileName); - } - for (final name in [ - 'CON$extension', - 'nul$extension', - 'with spaces$extension', - 'with.dot$extension', - ]) { - expect(form.fileNameError(name), form.l10n.useIdentifierCharacters); - } - }, - ); - } - test( - 'native success without a completed submission is a protocol error', - () async { - const channel = MethodChannel('busymark/test/early-success'); - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (_) async => true); - addTearDown( - () => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, null), - ); - await expectLater( - _create(channel), - throwsA( - isA().having( - (error) => error.code, - 'code', - 'invalid-result', - ), - ), - ); - }, - ); - test('host unavailability after an event cannot open a fallback', () async { - final host = TestNativeWritersideDialogHost(); - host.install(); - addTearDown(host.dispose); - final result = _create(host.channel); - await host.opened.future; - await host.event({ - 'event': 'changed', - 'title': 'Title', - 'fileName': 'title.md', - 'fileNameEdited': true, - }); - final assertion = expectLater( - result, - throwsA( - isA().having( - (error) => error.code, - 'code', - 'unavailable', - ), - ), - ); - host.closed.completeError(PlatformException(code: 'unavailable')); - await assertion; - }); - test('retiring a session leaves other native operations intact', () async { - const channel = MethodChannel('busymark/test/concurrent-forms'); - final hosts = [ - TestNativeWritersideDialogHost(channel: channel), - TestNativeWritersideDialogHost(channel: channel), - ]; - var index = 0; - final messenger = - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; - messenger.setMockMethodCallHandler(channel, (call) { - if (call.method == 'showDuplicateTopic') return Future.value('copy'); - final host = hosts[index++]; - host.arguments = call.arguments as Map; - host.opened.complete(host.arguments); - return host.closed.future; - }); - addTearDown(() => messenger.setMockMethodCallHandler(channel, null)); - final first = _text(channel); - await hosts[0].opened.future; - final second = _rename(channel); - await hosts[1].opened.future; - hosts[0].closed.complete(null); - expect((await first).value, isNull); - await expectLater( - hosts[0].event({'event': 'changed', 'value': 'late'}), - throwsA( - isA().having( - (error) => error.code, - 'code', - 'expired-dialog', - ), - ), - ); - expect(await hosts[1].event({'event': 'changed', 'value': 'new.topic'}), { - 'error': null, - }); - final duplicate = await _operation('duplicate', channel); - expect(duplicate.value, 'copy'); - hosts[1].closed.complete({'value': 'new.topic', 'preview': false}); - expect((await second).value?.fileName, 'new.topic'); - }); test('Duplicate Topic is serialized to the native dialog host', () async { const channel = MethodChannel('busymark/test/native-writerside-duplicate'); MethodCall? call; @@ -297,317 +142,4 @@ void main() { expect(unavailable.value, isNull); }, ); - test( - 'New Topic validates, stays pending, retains failure and retries', - () async { - final host = TestNativeWritersideDialogHost(); - host.install(); - addTearDown(host.dispose); - final form = WritersideTopicCreationForm( - format: WritersideTopicFormat.xml, - existingIds: {'taken'}, - l10n: AppLocalizationsAr(), - ); - var submissions = 0; - var creation = Completer(); - final resultFuture = NativeWritersideDialogService(channel: host.channel) - .showCreateTopic( - title: form.l10n.newTopic, - titleLabel: form.l10n.tocTopicTitleField, - fileNameLabel: form.l10n.tocDuplicateFilename, - initialTitle: form.l10n.defaultNewTopicTitle, - initialFileName: form.fileNameForTitle( - form.l10n.defaultNewTopicTitle, - ), - cancelLabel: form.l10n.cancel, - okLabel: form.l10n.tocOk, - textDirection: TextDirection.rtl, - validate: (title, name, edited) { - final fileName = edited ? name : form.fileNameForTitle(title); - return NativeWritersideCreateValidation( - fileName: fileName, - titleError: form.titleError(title), - fileNameError: form.fileNameError(fileName), - ); - }, - submit: (title, name) { - submissions++; - expect(title, 'New title'); - expect(name, 'manual.topic'); - return creation.future; - }, - ); - final request = await host.opened.future; - expect(request['textDirection'], 'rtl'); - expect(request['titleLabel'], form.l10n.tocTopicTitleField); - expect(request['initialTitle'], form.l10n.defaultNewTopicTitle); - expect(request['initialFileName'], endsWith('.topic')); - Future event( - String type, - String title, - String name, { - bool edited = true, - }) => host.event({ - 'event': type, - 'title': title, - 'fileName': name, - 'fileNameEdited': edited, - }); - final synchronized = - await event('changed', 'New title', 'old.topic', edited: false) - as Map; - expect(synchronized['fileName'], 'new-title.topic'); - final invalid = await event('submit', '', 'taken.topic') as Map; - expect(invalid['titleError'], form.l10n.topicTitleRequired); - expect(invalid['fileNameError'], form.l10n.topicIdAlreadyExists); - expect(submissions, 0); - final pending = event('submit', 'New title', 'manual.topic'); - await Future.delayed(Duration.zero); - expect(submissions, 1); - expect(await event('submit', 'New title', 'manual.topic'), { - 'pending': true, - }); - expect(await event('changed', 'Other', 'different.topic'), { - 'pending': true, - }); - var finished = false; - unawaited(resultFuture.then((_) => finished = true)); - expect(finished, isFalse); - creation.complete(form.l10n.createWritersideTopicFailed); - final failure = await pending as Map; - expect(failure['created'], isFalse); - expect(failure['error'], form.l10n.createWritersideTopicFailed); - expect(failure['fileName'], 'manual.topic'); - expect(finished, isFalse); - creation = Completer(); - final retry = event('submit', 'New title', 'manual.topic'); - await Future.delayed(Duration.zero); - expect(submissions, 2); - creation.complete(null); - expect((await retry as Map)['created'], isTrue); - host.closed.complete(true); - final result = await resultFuture; - expect(result.available, isTrue); - expect(result.value, isTrue); - await expectLater( - event('changed', 'Late', 'late.topic'), - throwsA(isA()), - ); - }, - ); - - test( - 'New Topic destruction during creation removes the session safely', - () async { - final host = TestNativeWritersideDialogHost(); - host.install(); - addTearDown(host.dispose); - final creation = Completer(); - final future = _create(host.channel, submit: (_, _) => creation.future); - await host.opened.future; - final pending = host.event({ - 'event': 'submit', - 'title': 'Title', - 'fileName': 'title.md', - 'fileNameEdited': true, - }); - await Future.delayed(Duration.zero); - host.closed.complete(null); - expect((await future).value, isNull); - creation.complete(null); - expect((await pending as Map)['created'], isTrue); - await expectLater( - host.event({'event': 'changed'}), - throwsA(isA()), - ); - }, - ); - - for (final operation in [ - 'create', - 'rename', - 'text', - 'picker', - 'duplicate', - 'title', - ]) { - for (final outcome in [ - 'cancel', - 'missing', - 'unavailable', - 'malformed', - 'unexpected', - ]) { - test('$operation preserves the $outcome result contract', () async { - final channel = MethodChannel('busymark/test/$operation/$outcome'); - final messenger = - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; - messenger.setMockMethodCallHandler(channel, (_) async { - switch (outcome) { - case 'cancel': - return null; - case 'missing': - throw MissingPluginException(); - case 'unavailable': - throw PlatformException(code: 'unavailable'); - case 'unexpected': - throw PlatformException(code: 'host-failed'); - default: - return ['malformed']; - } - }); - addTearDown(() => messenger.setMockMethodCallHandler(channel, null)); - final future = _operation(operation, channel); - if (outcome == 'malformed' || outcome == 'unexpected') { - await expectLater( - future, - throwsA( - isA().having( - (error) => error.code, - 'code', - outcome == 'malformed' ? 'invalid-result' : 'host-failed', - ), - ), - ); - } else { - final result = await future; - expect(result.available, outcome == 'cancel'); - expect(result.value, isNull); - } - }); - } - } - - test('rename uses Dart extension validation and distinct actions', () async { - for (final preview in [true, false]) { - final host = TestNativeWritersideDialogHost(); - host.install(); - final resultFuture = _rename(host.channel); - await host.opened.future; - expect(host.calls.single.method, 'showRenameTopic'); - expect(host.arguments['textDirection'], 'rtl'); - expect(host.arguments['previewLabel'], 'Preview'); - expect(await host.event({'event': 'changed', 'value': 'wrong.md'}), { - 'error': 'extension', - }); - host.closed.complete({'value': ' renamed.topic ', 'preview': preview}); - final result = await resultFuture; - expect(result.value?.fileName, 'renamed.topic'); - expect(result.value?.preview, preview); - host.dispose(); - } - }); - - test( - 'Group returns untrimmed text and keeps Enter-only interaction', - () async { - final host = TestNativeWritersideDialogHost(); - host.install(); - addTearDown(host.dispose); - final future = _text(host.channel); - await host.opened.future; - expect(host.arguments['enterOnly'], isTrue); - expect(await host.event({'event': 'changed', 'value': ' '}), { - 'error': 'required', - }); - host.closed.complete(' Group '); - expect((await future).value, ' Group '); - }, - ); - - test('picker filters in Dart and returns the original index', () async { - final host = TestNativeWritersideDialogHost(); - host.install(); - addTearDown(host.dispose); - final future = _picker(host.channel); - await host.opened.future; - expect(host.arguments['searchLabel'], 'Search'); - expect(await host.event({'event': 'filter', 'value': 'BETA'}), [1, 2]); - host.closed.complete(2); - expect((await future).value, 2); - }); -} - -Future> _create( - MethodChannel channel, { - Future Function(String, String)? submit, -}) => NativeWritersideDialogService(channel: channel).showCreateTopic( - title: 'New Topic', - titleLabel: 'Title', - fileNameLabel: 'Filename', - initialTitle: 'Title', - initialFileName: 'title.md', - cancelLabel: 'Cancel', - okLabel: 'OK', - textDirection: TextDirection.ltr, - validate: (_, name, _) => NativeWritersideCreateValidation(fileName: name), - submit: submit ?? (_, _) async => null, -); -Future> _rename( - MethodChannel channel, -) => NativeWritersideDialogService(channel: channel).showRenameTopic( - title: 'Rename', - fileNameLabel: 'Filename', - initialValue: 'old.topic', - cancelLabel: 'Cancel', - previewLabel: 'Preview', - refactorLabel: 'Refactor', - validate: (value) => value.trim().endsWith('.topic') ? null : 'extension', - textDirection: TextDirection.rtl, -); -Future> _text(MethodChannel channel) => - NativeWritersideDialogService(channel: channel).showTocText( - title: 'Group', - label: 'Title', - cancelLabel: 'Cancel', - okLabel: 'OK', - requiredError: 'required', - enterOnly: true, - textDirection: TextDirection.rtl, - ); -Future> _picker(MethodChannel channel) => - NativeWritersideDialogService(channel: channel).showExistingTopicPicker( - title: 'Select', - searchLabel: 'Search', - fileNames: ['alpha.md', 'beta.md', 'BETA.md'], - textDirection: TextDirection.rtl, - ); -Future> _operation( - String operation, - MethodChannel channel, -) { - final service = NativeWritersideDialogService(channel: channel); - return switch (operation) { - 'create' => _create(channel), - 'rename' => _rename(channel), - 'text' => _text(channel), - 'picker' => _picker(channel), - 'duplicate' => service.showDuplicateTopic( - title: 'Duplicate', - fileNameLabel: 'Filename', - initialValue: 'old', - cancelLabel: 'Cancel', - okLabel: 'OK', - requiredError: 'required', - invalidCharactersError: 'invalid', - duplicateError: 'duplicate', - existingTopicIds: [], - ), - _ => service.showEditTitle( - dialogTitle: 'Edit Title', - topicTitleLabel: 'Title', - advancedLabel: 'Advanced', - instanceTitleLabel: 'Instance', - tocTitleLabel: 'TOC', - instanceExplanation: 'Instance help', - tocExplanation: 'TOC help', - documentationLabel: 'Docs', - documentationUrl: 'https://example.test', - initialTitle: 'Title', - initialInstanceTitle: '', - initialTocTitle: '', - cancelLabel: 'Cancel', - okLabel: 'OK', - ), - }; } diff --git a/test/src/native_writerside_dialog_source_test.dart b/test/src/native_writerside_dialog_source_test.dart index 5ec763fe..0b96f9e0 100644 --- a/test/src/native_writerside_dialog_source_test.dart +++ b/test/src/native_writerside_dialog_source_test.dart @@ -20,45 +20,54 @@ void main() { expect(workspace, contains('.showEditTitle(')); expect(workspace, contains('.showDuplicateTopic(')); }); + + final nativeDisplay = + Platform.environment['BUSYMARK_NATIVE_DIALOG_TEST_DISPLAY']; test( - 'interactive Writerside dialogs reuse the GTK host and Dart callbacks', - () { - final host = File( - 'linux/runner/writerside_dialog_host.cc', - ).readAsStringSync(); - final dialogs = File( - 'lib/src/workspace/presentation/writerside_toc_dialogs.dart', - ).readAsStringSync(); - for (final method in [ - 'showCreateTopic', - 'showRenameTopic', - 'showTocText', - 'showExistingTopicPicker', - ]) { - expect(host, contains('"$method"')); - } - expect( - host, - matches( - RegExp(r'GTK_DIALOG_MODAL\s*\|\s*GTK_DIALOG_DESTROY_WITH_PARENT'), - ), + 'GTK Writerside contents follow application direction, filenames stay LTR', + () async { + final temporary = Directory.systemTemp.createTempSync( + 'busymark-native-dialog-test-', ); - expect(host, contains('GTK_TEXT_DIR_LTR')); - expect(host, contains('writersideDialogEvent')); - expect(host, contains('state->pending')); - expect(host, contains('gtk_window_set_deletable')); - expect(host, contains('G_CALLBACK(interactive_key)')); - expect( - host, - contains('interactive_response(nullptr, GTK_RESPONSE_CANCEL, state)'), + addTearDown(() => temporary.deleteSync(recursive: true)); + final engine = Directory('linux/flutter/ephemeral').absolute.path; + final flags = await Process.run('pkg-config', [ + '--cflags', + '--libs', + 'gtk+-3.0', + ]); + expect(flags.exitCode, 0, reason: '${flags.stderr}'); + final binary = '${temporary.path}/writerside-dialog-test'; + final compiled = await Process.run(Platform.environment['CXX'] ?? 'c++', [ + '-std=c++14', + '-Wall', + '-Werror', + '-I$engine', + 'test/support/native_writerside_dialog_host_test.cc', + '-L$engine', + '-lflutter_linux_gtk', + '-Wl,-rpath,$engine', + '-Wl,--wrap=fl_method_call_respond_success', + ...'${flags.stdout}'.trim().split(RegExp(r'\s+')), + '-o', + binary, + ]); + expect(compiled.exitCode, 0, reason: '${compiled.stderr}'); + final result = await Process.run( + binary, + const [], + environment: { + 'DISPLAY': nativeDisplay!, + 'GDK_BACKEND': 'x11', + 'NO_AT_BRIDGE': '1', + }, ); - expect(host, contains('g_weak_ref_get')); - expect(host, contains('if (state->finished) return;')); - expect(host, contains('reply->revision != state->revision')); - expect(dialogs, contains('.showRenameTopic(')); - expect(dialogs, contains('.showTocText(')); - expect(dialogs, contains('.showExistingTopicPicker(')); - expect(dialogs, contains('return index == null ? null : topics[index]')); + expect(result.exitCode, 0, reason: '${result.stdout}\n${result.stderr}'); }, + skip: !Platform.isLinux || nativeDisplay == null + ? 'Requires a Linux build and an isolated ' + 'BUSYMARK_NATIVE_DIALOG_TEST_DISPLAY.' + : false, + timeout: const Timeout(Duration(minutes: 2)), ); } diff --git a/test/src/source_audit_test.dart b/test/src/source_audit_test.dart index be6776e3..17fa1aa0 100644 --- a/test/src/source_audit_test.dart +++ b/test/src/source_audit_test.dart @@ -257,7 +257,7 @@ void main() { expect(dialogButton, isNot(contains('minWidth:'))); }); - test('Writerside topic Flutter fallback uses shared semantic adapters', () { + test('Writerside New Topic uses the established modal editor components', () { final workspace = File( 'lib/src/workspace/presentation/workspace_screen.dart', ).readAsStringSync(); @@ -268,14 +268,16 @@ void main() { expect(workspace, contains('BusyMarkPushButton.standardIcon(')); expect(workspace, isNot(contains('FilledButton.icon('))); - expect(createTopicDialog, contains('BusyMarkDialogShell(')); + expect(createTopicDialog, contains('BusyMarkModalEditorScaffold(')); + expect(createTopicDialog, isNot(contains('BusyMarkDialogShell('))); + expect(workspace, contains('showBusyMarkModalEditorDialog(')); expect( RegExp( r'BusyMarkGroupedTextEntry\(', ).allMatches(createTopicDialog).length, 2, ); - expect(workspace, contains('.showCreateTopic(')); + expect(workspace, isNot(contains('.showCreateTopic('))); expect( RegExp(r'BusyMarkComboRow<').allMatches(createTopicDialog).length, 0, diff --git a/test/src/writerside_toc_menu_test.dart b/test/src/writerside_toc_menu_test.dart index 1812a57c..6e7b6c4e 100644 --- a/test/src/writerside_toc_menu_test.dart +++ b/test/src/writerside_toc_menu_test.dart @@ -1,4 +1,4 @@ -import 'package:busymark/src/platform/native_writerside_dialog_service.dart'; +import 'package:busymark/src/app/busymark_dialogs.dart'; import 'package:busymark/src/app/busymark_design.dart'; import 'package:busymark/src/platform/native_menu_service.dart'; import 'package:busymark/l10n/generated/app_localizations.dart'; @@ -64,7 +64,7 @@ void main() { }); testWidgets( - 'native picker returns the supplied topic identity without a fallback', + 'picker returns the supplied topic identity for duplicate names', (tester) async { final topics = [ for (final root in ['/first', '/second']) @@ -74,23 +74,7 @@ void main() { topicsRoot: root, ), ]; - Object? response = 1; - const channel = MethodChannel(nativeWritersideDialogChannelName); - tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, ( - call, - ) async { - expect(call.method, 'showExistingTopicPicker'); - expect((call.arguments as Map)['fileNames'], ['same.md', 'same.md']); - return response; - }); - addTearDown( - () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - channel, - null, - ), - ); WritersideTopic? chosen; - Object? failure; await tester.pumpWidget( MaterialApp( localizationsDelegates: AppLocalizations.localizationsDelegates, @@ -98,51 +82,34 @@ void main() { home: Builder( builder: (context) => TextButton( child: const Text('Link'), - onPressed: () async { - try { - chosen = await showWritersideExistingTopicPicker( + onPressed: () async => + chosen = await showBusyMarkModalDialog( context, - topics: topics, - ); - } catch (error) { - failure = error; - } - }, + builder: (_) => + WritersideExistingTopicPicker(topics: topics), + ), ), ), ), ); await tester.tap(find.text('Link')); await tester.pumpAndSettle(); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); expect(chosen, same(topics[1])); - expect(find.byType(WritersideExistingTopicPicker), findsNothing); - response = null; await tester.tap(find.text('Link')); await tester.pumpAndSettle(); - expect(chosen, isNull); - expect(find.byType(WritersideExistingTopicPicker), findsNothing); - response = 20; - await tester.tap(find.text('Link')); + await tester.enterText(find.byType(TextField), 'SAME'); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); await tester.pumpAndSettle(); - expect(failure, isA()); - expect(find.byType(WritersideExistingTopicPicker), findsNothing); + expect(chosen, isNull); }, ); - testWidgets('Group fallback keeps raw input and Enter-only acceptance', ( + testWidgets('Group keeps raw input and Enter-only acceptance', ( tester, ) async { - const channel = MethodChannel(nativeWritersideDialogChannelName); - tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - channel, - (_) async => throw PlatformException(code: 'unavailable'), - ); - addTearDown( - () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - channel, - null, - ), - ); String? result; await tester.pumpWidget( MaterialApp( @@ -151,12 +118,15 @@ void main() { home: Builder( builder: (context) => TextButton( child: const Text('Launch'), - onPressed: () async => result = await showWritersideTocTextDialog( - context, - title: 'Group', - label: 'Title', - enterOnly: true, - ), + onPressed: () async => + result = await showBusyMarkModalDialog( + context, + builder: (_) => const WritersideTocTextDialog( + title: 'Group', + label: 'Title', + enterOnly: true, + ), + ), ), ), ), @@ -178,24 +148,9 @@ void main() { expect(result, ' Group name '); }); - testWidgets('native rename preserves Preview and Refactor results', ( + testWidgets('rename preserves validation and Preview/Refactor results', ( tester, ) async { - const channel = MethodChannel(nativeWritersideDialogChannelName); - Object? response; - tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, ( - call, - ) async { - expect(call.method, 'showRenameTopic'); - expect((call.arguments as Map)['initialValue'], 'old.topic'); - return response; - }); - addTearDown( - () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - channel, - null, - ), - ); WritersideTopicRenameDialogResult? result; await tester.pumpWidget( MaterialApp( @@ -204,19 +159,45 @@ void main() { home: Builder( builder: (context) => TextButton( child: const Text('Launch'), - onPressed: () async => - result = await showWritersideTopicRenameDialog( + onPressed: () async => result = + await showBusyMarkModalDialog< + WritersideTopicRenameDialogResult + >( context, - currentFileName: 'old.topic', + barrierDismissible: false, + builder: (_) => const WritersideTopicRenameDialog( + currentFileName: 'old.topic', + ), ), ), ), ), ); for (final preview in [true, false]) { - response = {'value': ' new.topic ', 'preview': preview}; await tester.tap(find.text('Launch')); await tester.pumpAndSettle(); + await tester.enterText(find.byType(BusyMarkGroupedTextEntry), 'wrong.md'); + await tester.pump(); + final buttons = tester + .widgetList(find.byType(BusyMarkDialogButton)) + .toList(); + expect( + buttons + .where((button) => button.label != 'Cancel') + .every((button) => button.onPressed == null), + isTrue, + ); + await tester.enterText( + find.byType(BusyMarkGroupedTextEntry), + ' new.topic ', + ); + await tester.pump(); + if (preview) { + await tester.tap(find.text('Preview')); + } else { + await tester.testTextInput.receiveAction(TextInputAction.done); + } + await tester.pumpAndSettle(); expect(result?.fileName, 'new.topic'); expect( result?.action, @@ -224,13 +205,12 @@ void main() { ? WritersideTopicRenameDialogAction.preview : WritersideTopicRenameDialogAction.refactor, ); - expect(find.byType(WritersideTopicRenameDialog), findsNothing); } - response = null; await tester.tap(find.text('Launch')); await tester.pumpAndSettle(); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); expect(result, isNull); - expect(find.byType(WritersideTopicRenameDialog), findsNothing); }); for (final direction in TextDirection.values) { testWidgets('nested menus use the native host in $direction', ( diff --git a/test/support/native_writerside_dialog_host.dart b/test/support/native_writerside_dialog_host.dart deleted file mode 100644 index def0be9b..00000000 --- a/test/support/native_writerside_dialog_host.dart +++ /dev/null @@ -1,57 +0,0 @@ -import 'dart:async'; - -import 'package:busymark/src/platform/native_writerside_dialog_service.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; - -/// Exercises both directions of the production dialog protocol. -class TestNativeWritersideDialogHost { - TestNativeWritersideDialogHost({ - this.channel = const MethodChannel(nativeWritersideDialogChannelName), - }); - - final MethodChannel channel; - final opened = Completer>(); - final closed = Completer(); - final calls = []; - late Map arguments; - - void install() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) { - calls.add(call); - arguments = call.arguments as Map; - if (!opened.isCompleted) opened.complete(arguments); - return closed.future; - }); - } - - void dispose() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, null); - } - - Future event(Map values) async { - const codec = StandardMethodCodec(); - final reply = Completer(); - await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .handlePlatformMessage( - channel.name, - codec.encodeMethodCall( - MethodCall('writersideDialogEvent', { - 'session': arguments['session'], - ...values, - }), - ), - (data) { - try { - if (data == null) throw MissingPluginException(); - reply.complete(codec.decodeEnvelope(data)); - } catch (error, stack) { - reply.completeError(error, stack); - } - }, - ); - return reply.future; - } -} diff --git a/test/support/native_writerside_dialog_host_test.cc b/test/support/native_writerside_dialog_host_test.cc new file mode 100644 index 00000000..5e5baa3e --- /dev/null +++ b/test/support/native_writerside_dialog_host_test.cc @@ -0,0 +1,169 @@ +#include +#include + +// Exercise the production GTK builders. Only the final channel response is +// intercepted; the widget tree, dialog loop and button handling are real GTK. +#include "../../linux/runner/writerside_dialog_host.cc" + +namespace { + +FlValue* received_result = nullptr; +guint response_count = 0; + +struct DirectionCase { + GtkTextDirection desktop_direction; + GtkTextDirection application_direction; + bool duplicate; +}; + +struct DialogCheck { + const DirectionCase* test_case; + GtkWindow* parent; + GtkWidget* filename_entry; + guint entries; + guint labels; + bool checked; +}; + +void assert_widget_direction(GtkWidget* widget, gpointer user_data) { + auto* check = static_cast(user_data); + const GtkTextDirection expected = widget == check->filename_entry + ? GTK_TEXT_DIR_LTR + : check->test_case->application_direction; + g_assert_cmpint(gtk_widget_get_direction(widget), ==, expected); + if (GTK_IS_ENTRY(widget)) ++check->entries; + if (GTK_IS_LABEL(widget)) ++check->labels; + if (GTK_IS_CONTAINER(widget)) { + gtk_container_forall(GTK_CONTAINER(widget), assert_widget_direction, check); + } +} + +gboolean inspect_and_accept_dialog(gpointer user_data) { + auto* check = static_cast(user_data); + GList* windows = gtk_window_list_toplevels(); + GtkWidget* dialog = nullptr; + for (GList* node = windows; node != nullptr; node = node->next) { + if (GTK_IS_DIALOG(node->data)) dialog = GTK_WIDGET(node->data); + } + g_list_free(windows); + g_assert_nonnull(dialog); + g_assert_true(gtk_window_get_modal(GTK_WINDOW(dialog))); + g_assert_true(gtk_window_get_transient_for(GTK_WINDOW(dialog)) == check->parent); + g_assert_cmpint(gtk_widget_get_default_direction(), ==, + check->test_case->desktop_direction); + if (check->test_case->duplicate) { + check->filename_entry = gtk_window_get_focus(GTK_WINDOW(dialog)); + g_assert_true(GTK_IS_ENTRY(check->filename_entry)); + } + assert_widget_direction(dialog, check); + g_assert_cmpuint(check->entries, ==, check->test_case->duplicate ? 1 : 3); + g_assert_cmpuint(check->labels, >=, 4); + check->checked = true; + GtkWidget* ok = + gtk_dialog_get_widget_for_response(GTK_DIALOG(dialog), GTK_RESPONSE_OK); + gtk_button_clicked(GTK_BUTTON(ok)); + return G_SOURCE_REMOVE; +} + +void set_string(FlValue* args, const gchar* key, const gchar* value) { + fl_value_set_string_take(args, key, fl_value_new_string(value)); +} + +void test_dialog_direction(gconstpointer user_data) { + const auto* test_case = static_cast(user_data); + const GtkTextDirection original_direction = gtk_widget_get_default_direction(); + // Simulate a desktop language opposite to the application language. The + // production dialog must not change this process-wide setting. + gtk_widget_set_default_direction(test_case->desktop_direction); + GtkWidget* parent = gtk_window_new(GTK_WINDOW_TOPLEVEL); + GtkWidget* view = gtk_drawing_area_new(); + gtk_container_add(GTK_CONTAINER(parent), view); + gtk_widget_show_all(parent); + DialogHandlerData handler = {view, GTK_WINDOW(parent)}; + g_autoptr(FlValue) args = fl_value_new_map(); + set_string(args, "textDirection", + test_case->application_direction == GTK_TEXT_DIR_RTL ? "rtl" : "ltr"); + set_string(args, "title", "Localized dialog title"); + set_string(args, "cancelLabel", "Localized cancel"); + set_string(args, "okLabel", "Localized accept"); + if (test_case->duplicate) { + set_string(args, "fileNameLabel", "Localized filename label"); + set_string(args, "initialValue", "original-copy"); + set_string(args, "requiredError", "Localized required error"); + set_string(args, "invalidCharactersError", "Localized identifier error"); + set_string(args, "duplicateError", "Localized duplicate error"); + fl_value_set_string_take(args, "existingTopicIds", fl_value_new_list()); + } else { + set_string(args, "topicTitleLabel", "Localized topic label"); + set_string(args, "advancedLabel", "Localized advanced label"); + set_string(args, "instanceTitleLabel", "Localized instance label"); + set_string(args, "tocTitleLabel", "Localized TOC label"); + set_string(args, "instanceExplanation", "Localized instance explanation"); + set_string(args, "tocExplanation", "Localized TOC explanation"); + set_string(args, "documentationLabel", "Localized documentation link"); + set_string(args, "documentationUrl", "https://example.test/titles"); + set_string(args, "initialTitle", "Original title"); + set_string(args, "initialInstanceTitle", "Instance title"); + set_string(args, "initialTocTitle", "TOC title"); + } + DialogCheck check = {test_case, GTK_WINDOW(parent), nullptr, 0, 0, false}; + response_count = 0; + g_idle_add(inspect_and_accept_dialog, &check); + if (test_case->duplicate) { + show_duplicate_topic(&handler, nullptr, args); + } else { + show_edit_title(&handler, nullptr, args); + } + g_assert_true(check.checked); + g_assert_cmpuint(response_count, ==, 1); + if (test_case->duplicate) { + g_assert_cmpstr(fl_value_get_string(received_result), ==, "original-copy"); + } else { + g_assert_cmpstr(fl_value_get_string( + fl_value_lookup_string(received_result, "title")), + ==, "Original title"); + g_assert_cmpstr(fl_value_get_string( + fl_value_lookup_string(received_result, "instanceTitle")), + ==, "Instance title"); + g_assert_cmpstr(fl_value_get_string( + fl_value_lookup_string(received_result, "tocTitle")), + ==, "TOC title"); + } + fl_value_unref(received_result); + received_result = nullptr; + g_assert_cmpint(gtk_widget_get_default_direction(), ==, + test_case->desktop_direction); + gtk_widget_destroy(parent); + gtk_widget_set_default_direction(original_direction); +} + +} // namespace + +extern "C" gboolean __wrap_fl_method_call_respond_success(FlMethodCall*, + FlValue* result, + GError**) { + g_assert_null(received_result); + received_result = fl_value_ref(result); + ++response_count; + return TRUE; +} + +int main(int argc, char** argv) { + gtk_init(&argc, &argv); + g_test_init(&argc, &argv, nullptr); + const DirectionCase cases[] = { + {GTK_TEXT_DIR_LTR, GTK_TEXT_DIR_RTL, false}, + {GTK_TEXT_DIR_RTL, GTK_TEXT_DIR_LTR, false}, + {GTK_TEXT_DIR_LTR, GTK_TEXT_DIR_RTL, true}, + {GTK_TEXT_DIR_RTL, GTK_TEXT_DIR_LTR, true}, + }; + g_test_add_data_func("/writerside/edit-title/rtl-on-ltr-desktop", &cases[0], + test_dialog_direction); + g_test_add_data_func("/writerside/edit-title/ltr-on-rtl-desktop", &cases[1], + test_dialog_direction); + g_test_add_data_func("/writerside/duplicate/rtl-on-ltr-desktop", &cases[2], + test_dialog_direction); + g_test_add_data_func("/writerside/duplicate/ltr-on-rtl-desktop", &cases[3], + test_dialog_direction); + return g_test_run(); +} From 6869a215f3414a4007a4fed425a64f799af1dc73 Mon Sep 17 00:00:00 2001 From: albert Date: Sun, 4 Oct 2026 07:31:40 -0700 Subject: [PATCH 10/14] Add bare URL recognition to WYSIWYG editor on Enter and paste * Update `BusyInlineStyleRange` and clipboard operations to serialize and retain inline attributes. * Introduce `busyMarkBareUrlAttribute` to distinguish structural autolinks and serialize them as plain text instead of delimited Markdown links. * Modify block split and text replacement logic to automatically recognize and refresh URLs during Enter and paste operations in eligible text blocks. * Implement URL recognition to preserve surrounding inline formatting while avoiding unintended parsing of other Markdown syntaxes. * Add comprehensive tests for bare URL destination updating, clipboard paste completion, explicit link retention, and exact text offset tracking. --- .../wysiwyg/wysiwyg_clipboard_fragment.dart | 5 + .../wysiwyg/wysiwyg_document_controller.dart | 228 +++++++++++++- lib/src/editor/wysiwyg/wysiwyg_editor.dart | 1 + .../wysiwyg/wysiwyg_inline_controller.dart | 13 +- lib/src/markdown/busymark_document.dart | 4 + .../busymark_markdown_serializer.dart | 65 ++-- lib/src/markdown/math_syntax.dart | 66 ++++ test/src/busymark_document_test.dart | 296 ++++++++++++++++++ .../markdown_serializer_metadata_test.dart | 156 +++++++++ test/src/wysiwyg_clipboard_test.dart | 88 ++++++ .../wysiwyg_structured_regression_test.dart | 174 ++++++++++ 11 files changed, 1058 insertions(+), 38 deletions(-) diff --git a/lib/src/editor/wysiwyg/wysiwyg_clipboard_fragment.dart b/lib/src/editor/wysiwyg/wysiwyg_clipboard_fragment.dart index 6167f374..53e788f2 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_clipboard_fragment.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_clipboard_fragment.dart @@ -123,6 +123,7 @@ class WysiwygClipboardFragment { 'end': range.end, 'kind': range.kind.name, if (range.destination != null) 'destination': range.destination, + if (range.attributes.isNotEmpty) 'attributes': range.attributes, }, ], if (block.completeBlock != null) @@ -171,6 +172,9 @@ class WysiwygClipboardFragment { end: end, kind: BusyInlineKind.values.byName(reader.string(range['kind'])), destination: reader.optionalString(range['destination']), + attributes: range['attributes'] == null + ? const {} + : reader.attributes(range['attributes']), ), ); } @@ -299,6 +303,7 @@ class WysiwygClipboardFragment { start: range.start, end: range.end, kind: range.kind, + attributes: range.attributes, destination: destination( range.destination, media: range.kind == BusyInlineKind.image, diff --git a/lib/src/editor/wysiwyg/wysiwyg_document_controller.dart b/lib/src/editor/wysiwyg/wysiwyg_document_controller.dart index 9d4b08c1..0a7700e2 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_document_controller.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_document_controller.dart @@ -1762,7 +1762,14 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { return true; } - String? splitBlockAt(String blockId, int offset) { + String? splitBlockAt(String blockId, int offset) => + _splitBlockAt(blockId, offset); + + String? _splitBlockAt( + String blockId, + int offset, { + bool recognizeUrls = false, + }) { final block = blockById(blockId); if (block == null) { return null; @@ -1783,6 +1790,8 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { kind: block.kind, inlines: inlinePartition.before.isEmpty ? _textInlines('') + : recognizeUrls && _supportsBareUrlRecognition(block) + ? _recognizeBareUrls(inlinePartition.before, mode: _document.mode) : inlinePartition.before, children: block.children, attributes: _attributesForText( @@ -1850,7 +1859,7 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { _replaceBlockWithParagraph(blockId); return BusyWysiwygTextSplitResult(blockId: blockId, offset: 0); } - final nextBlockId = splitBlockAt(blockId, offset); + final nextBlockId = _splitBlockAt(blockId, offset, recognizeUrls: true); if (nextBlockId == null) { return null; } @@ -2104,16 +2113,22 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { final end = selectionEnd.clamp(start, text.length).toInt(); final beforeText = text.substring(0, start); final afterText = text.substring(end); + final completeInsertion = blocks.any( + busyMarkClipboardRequiresCompleteBlockInsertion, + ); final partition = _partitionInlinesForReplacement( block.inlines, start, end, + // Compose an inline replacement before deriving its updated URL. Repair + // individual slices immediately for structural block insertion. + refreshUrls: blocks.length != 1 || completeInsertion, ); final ownsDestinationStructure = replacementScope == BusyWysiwygReplacementScope.documentRange && destinationBlockFullySelected; - if (blocks.any(busyMarkClipboardRequiresCompleteBlockInsertion)) { + if (completeInsertion) { return _insertCompleteBlocksAtSelection( block: block, blockId: blockId, @@ -2136,11 +2151,13 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { clipboardBlock.inlines, _inlineContextForReplacement(block.inlines, start, end), ); - final mergedInlines = _mergeAdjacentInlineStyles([ - ...partition.before, - ...insertedInlines, - ...partition.after, - ]); + final mergedInlines = _refreshBareUrlLinks( + _mergeAdjacentInlineStyles([ + ...partition.before, + ...insertedInlines, + ...partition.after, + ]), + ); if (ownsDestinationStructure) { final structural = _styledBlockToBusyBlock(inserted, rootId: block.id); final retainsDescendants = _isBlockContentContainer(structural.kind); @@ -3136,6 +3153,7 @@ List _styleRangesForReplacement({ end: range.end + delta, kind: range.kind, destination: range.destination, + attributes: range.attributes, ) else ...[ if (range.start < selectionStart) @@ -3144,6 +3162,7 @@ List _styleRangesForReplacement({ end: selectionStart, kind: range.kind, destination: range.destination, + attributes: range.attributes, ), if (range.end > selectionEnd) BusyInlineStyleRange( @@ -3151,6 +3170,7 @@ List _styleRangesForReplacement({ end: range.end + delta, kind: range.kind, destination: range.destination, + attributes: range.attributes, ), ], ], @@ -3248,7 +3268,12 @@ Map _attributesAfterInlineMathEdit( } ({List before, List after}) -_partitionInlinesForReplacement(List inlines, int start, int end) { +_partitionInlinesForReplacement( + List inlines, + int start, + int end, { + bool refreshUrls = true, +}) { final before = []; final after = []; var offset = 0; @@ -3271,12 +3296,21 @@ _partitionInlinesForReplacement(List inlines, int start, int end) { inline, localStart, localEnd, + refreshUrls: refreshUrls, ); if (partition.before != null) { - before.add(partition.before!); + before.addAll( + refreshUrls + ? _refreshBareUrlLinks([partition.before!]) + : [partition.before!], + ); } if (partition.after != null) { - after.add(partition.after!); + after.addAll( + refreshUrls + ? _refreshBareUrlLinks([partition.after!]) + : [partition.after!], + ); } } offset = inlineEnd; @@ -3287,14 +3321,16 @@ _partitionInlinesForReplacement(List inlines, int start, int end) { ({BusyInline? before, BusyInline? after}) _partitionInlineForReplacement( BusyInline inline, int start, - int end, -) { + int end, { + required bool refreshUrls, +}) { final length = inline.plainText.length; if (inline.children.isNotEmpty) { final partition = _partitionInlinesForReplacement( inline.children, start, end, + refreshUrls: refreshUrls, ); return ( before: partition.before.isEmpty @@ -3630,6 +3666,149 @@ bool _canMergeBlockStructures(BusyBlock previous, BusyBlock current) { return true; } +bool _supportsBareUrlRecognition(BusyBlock block) => + !block.isSourceOnly && + !block.isSourceProtected && + !busyMarkWysiwygBlockContainsMath(block) && + switch (block.kind) { + BusyBlockKind.paragraph || + BusyBlockKind.heading || + BusyBlockKind.unorderedListItem || + BusyBlockKind.orderedListItem || + BusyBlockKind.taskListItem => true, + _ => false, + }; + +/// Wrap only parser-recognized URL slices, preserving the inline tree itself. +List _recognizeBareUrls( + List inlines, { + MarkdownMode mode = MarkdownMode.commonMark, + Map attributes = const {}, +}) { + final text = inlines.map((inline) => inline.plainText).join(); + final excluded = <({int start, int end})>[]; + var offset = 0; + void visit(BusyInline inline) { + final start = offset; + if (switch (inline.kind) { + BusyInlineKind.code || + BusyInlineKind.link || + BusyInlineKind.image || + BusyInlineKind.math || + BusyInlineKind.html || + BusyInlineKind.unknown || + BusyInlineKind.writersideVariable => true, + _ => false, + }) { + offset += inline.plainText.length; + excluded.add((start: start, end: offset)); + } else if (inline.children.isNotEmpty) { + for (final child in inline.children) { + visit(child); + } + } else { + offset += inline.plainText.length; + } + } + + for (final inline in inlines) { + visit(inline); + } + final matches = busyMarkBareUrlRanges(text, mode: mode).where( + (match) => !excluded.any( + (range) => range.start == range.end + ? range.start > match.start && range.start <= match.end + : range.start < match.end && range.end > match.start, + ), + ); + var result = inlines; + // Work backwards so earlier slices retain their original coordinates. + for (final match in matches.toList().reversed) { + result = _wrapBareUrlRange( + result, + match.start, + match.end, + match.destination, + attributes, + ); + } + return result; +} + +List _wrapBareUrlRange( + List inlines, + int start, + int end, + String destination, + Map attributes, +) { + var offset = 0; + for (var index = 0; index < inlines.length; index++) { + final inline = inlines[index]; + final inlineEnd = offset + inline.plainText.length; + if (inline.children.isNotEmpty && start >= offset && end <= inlineEnd) { + return [ + ...inlines.take(index), + inline.copyWith( + children: _wrapBareUrlRange( + inline.children, + start - offset, + end - offset, + destination, + attributes, + ), + ), + ...inlines.skip(index + 1), + ]; + } + offset = inlineEnd; + } + final prefix = _partitionInlinesForReplacement(inlines, start, start); + final url = _partitionInlinesForReplacement( + prefix.after, + end - start, + end - start, + ); + return [ + ...prefix.before, + BusyInline( + kind: BusyInlineKind.link, + text: url.before.map((inline) => inline.plainText).join(), + destination: destination, + children: url.before, + attributes: { + ...attributes, + busyMarkBareUrlAttribute: 'true', + if (attributes.containsKey('href')) 'href': destination, + }, + ), + ...url.after, + ]; +} + +/// Existing bare links derive their destination from their current text. Edits +/// and splits may shorten a URL or leave trailing punctuation outside it. +/// Explicit links keep their independently authored destinations. +List _refreshBareUrlLinks(List inlines) => [ + for (final inline in inlines) + if (inline.kind == BusyInlineKind.link && + inline.attributes[busyMarkBareUrlAttribute] == 'true') + ..._recognizeBareUrls( + inline.children.isEmpty ? _textInlines(inline.text) : inline.children, + attributes: inline.attributes, + ) + else if (inline.children.isNotEmpty) + _refreshBareUrlChildren(inline) + else + inline, +]; + +BusyInline _refreshBareUrlChildren(BusyInline inline) { + final children = _refreshBareUrlLinks(inline.children); + if (listEquals(children, inline.children)) return inline; + return inline.copyWith(children: children); +} + BusyBlock _blockWithEditedText( BusyBlock block, String nextText, { @@ -4026,6 +4205,7 @@ List _remapRangesForTextEdit({ end: range.end + delta, kind: range.kind, destination: range.destination, + attributes: range.attributes, ), ); continue; @@ -4049,6 +4229,7 @@ List _remapRangesForTextEdit({ end: range.end + delta, kind: range.kind, destination: range.destination, + attributes: range.attributes, ), ); } @@ -4064,6 +4245,7 @@ List _remapRangesForTextEdit({ end: end, kind: range.kind, destination: range.destination, + attributes: range.attributes, ), ); } @@ -4245,6 +4427,7 @@ List _preservedRangeParts( end: selectionStart, kind: range.kind, destination: range.destination, + attributes: range.attributes, ), if (range.end > selectionEnd) BusyInlineStyleRange( @@ -4252,6 +4435,7 @@ List _preservedRangeParts( end: range.end, kind: range.kind, destination: range.destination, + attributes: range.attributes, ), ]; } @@ -4283,7 +4467,7 @@ List _inlinesFromStyleRanges( ), ), ]; - return _mergeAdjacentInlineStyles(segments); + return _refreshBareUrlLinks(_mergeAdjacentInlineStyles(segments)); } List _mergeAdjacentInlineStyles(List inlines) { @@ -4318,7 +4502,9 @@ List _mergeAdjacentInlineStyles(List inlines) { } bool _canMergeInlineStyles(BusyInline left, BusyInline right) { - if (left.kind != right.kind || left.destination != right.destination) { + if (left.kind != right.kind || + left.destination != right.destination || + !mapEquals(left.attributes, right.attributes)) { return false; } if (left.kind == BusyInlineKind.text) { @@ -4352,6 +4538,7 @@ List _styleRangesForSlice( end: (range.end > end ? end : range.end) - start, kind: range.kind, destination: range.destination, + attributes: range.attributes, ), ]; } @@ -4368,6 +4555,7 @@ List _normalizedStyleRanges( end: range.end.clamp(0, textLength).toInt(), kind: range.kind, destination: range.destination, + attributes: range.attributes, ), ].where((range) => range.end > range.start).toList(); } @@ -4393,6 +4581,7 @@ BusyInline _inlineForSegment(String text, List styles) { kind: style.kind, text: inline.plainText, destination: style.destination, + attributes: style.attributes, children: style.kind == BusyInlineKind.code || style.kind == BusyInlineKind.image @@ -4404,7 +4593,14 @@ BusyInline _inlineForSegment(String text, List styles) { } int _compareStyleRanges(BusyInlineStyleRange a, BusyInlineStyleRange b) { - return _stylePriority(a.kind).compareTo(_stylePriority(b.kind)); + // Bare URLs stay inside surrounding formatting, letting reconstruction + // merge those wrappers without introducing adjacent Markdown delimiters. + int priority(BusyInlineStyleRange range) => + range.kind == BusyInlineKind.link && + range.attributes[busyMarkBareUrlAttribute] == 'true' + ? 6 + : _stylePriority(range.kind); + return priority(a).compareTo(priority(b)); } int _stylePriority(BusyInlineKind kind) { diff --git a/lib/src/editor/wysiwyg/wysiwyg_editor.dart b/lib/src/editor/wysiwyg/wysiwyg_editor.dart index fe0b365c..f4ba5e2f 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_editor.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_editor.dart @@ -7799,6 +7799,7 @@ class BusyMarkWysiwygEditorState extends State { end: (range.end > end ? end : range.end) - start, kind: range.kind, destination: range.destination, + attributes: range.attributes, ), ]; } diff --git a/lib/src/editor/wysiwyg/wysiwyg_inline_controller.dart b/lib/src/editor/wysiwyg/wysiwyg_inline_controller.dart index af937eee..e297ec9c 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_inline_controller.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_inline_controller.dart @@ -149,12 +149,14 @@ class BusyInlineStyleRange { required this.end, required this.kind, this.destination, + this.attributes = const {}, }); final int start; final int end; final BusyInlineKind kind; final String? destination; + final Map attributes; } class BusyInlineSemanticAnchor { @@ -339,7 +341,11 @@ List busyInlineStyleRanges(List inlines) { final start = offset; final children = inline.children; final ownStyle = _styledKind(inline.kind) - ? _InheritedInlineStyle(inline.kind, inline.destination) + ? _InheritedInlineStyle( + inline.kind, + inline.destination, + inline.attributes, + ) : null; final effectiveStyles = ownStyle == null ? inheritedStyles @@ -364,6 +370,7 @@ List busyInlineStyleRanges(List inlines) { end: end, kind: style.kind, destination: style.destination, + attributes: style.attributes, ), ); } @@ -388,6 +395,7 @@ List _normalizedRanges( end: range.end.clamp(0, textLength).toInt(), kind: range.kind, destination: range.destination, + attributes: range.attributes, ), ].where((range) => range.end > range.start).toList()..sort((a, b) { final byStart = a.start.compareTo(b.start); @@ -430,10 +438,11 @@ int _stylePriority(BusyInlineKind kind) { } class _InheritedInlineStyle { - const _InheritedInlineStyle(this.kind, this.destination); + const _InheritedInlineStyle(this.kind, this.destination, this.attributes); final BusyInlineKind kind; final String? destination; + final Map attributes; } bool _styledKind(BusyInlineKind kind) { diff --git a/lib/src/markdown/busymark_document.dart b/lib/src/markdown/busymark_document.dart index 0827e790..209ba72e 100644 --- a/lib/src/markdown/busymark_document.dart +++ b/lib/src/markdown/busymark_document.dart @@ -9,6 +9,10 @@ const busyMarkPreserveEmptyParagraphAttribute = 'preserveEmptyParagraph'; /// intentional edit and must not be removed by document-level canonicalization. const busyMarkPreserveTextWhitespaceAttribute = 'preserveTextWhitespace'; +/// A URL recognized by the Markdown autolink extension, emitted without link +/// delimiters. Its destination is derived from its visible URL text. +const busyMarkBareUrlAttribute = 'bareUrl'; + /// Marks the editor-only paragraph after a terminal non-paragraph block. /// /// The Markdown serializer ignores this block while it is empty. The marker is diff --git a/lib/src/markdown/busymark_markdown_serializer.dart b/lib/src/markdown/busymark_markdown_serializer.dart index f63c3e84..118fd099 100644 --- a/lib/src/markdown/busymark_markdown_serializer.dart +++ b/lib/src/markdown/busymark_markdown_serializer.dart @@ -690,6 +690,7 @@ class BusyMarkMarkdownSerializer { required _InlineTraversalMetrics metrics, List pathPrefix = const [], int textBase = 0, + bool bareUrlText = false, }) { final totalTextLength = inlines.fold( 0, @@ -796,11 +797,13 @@ class BusyMarkMarkdownSerializer { readableHardBreakRuns: readableHardBreakRuns, followedByLink: index + 1 < inlines.length && - inlines[index + 1].kind == BusyInlineKind.link, + inlines[index + 1].kind == BusyInlineKind.link && + inlines[index + 1].attributes[busyMarkBareUrlAttribute] != 'true', delimiterOverrides: delimiterOverrides, metrics: metrics, inlinePath: [...pathPrefix, index], textBase: textBase + consumedText, + bareUrlText: bareUrlText, ); for (final entry in result.sourceOffsets.entries) { sourceOffsets.putIfAbsent( @@ -853,7 +856,13 @@ class BusyMarkMarkdownSerializer { required _InlineTraversalMetrics metrics, required List inlinePath, required int textBase, + required bool bareUrlText, }) { + final bareUrl = + inline.kind == BusyInlineKind.link && + inline.attributes[busyMarkBareUrlAttribute] == 'true'; + final rawText = bareUrlText || bareUrl; + final linkOpeningLength = bareUrl ? 0 : 1; final length = inline.plainText.length; final targets = { for (final offset in textOffsets) offset.clamp(0, length).toInt(), @@ -873,17 +882,23 @@ class BusyMarkMarkdownSerializer { metrics: metrics, pathPrefix: inlinePath, textBase: textBase, + bareUrlText: rawText, ); final children = inline.children.isEmpty - ? _escapeInlineText(inline.text, atBlockStart: atBlockStart) + ? rawText + ? inline.text + : _escapeInlineText(inline.text, atBlockStart: atBlockStart) : childResult!.source; final delimiter = delimiterOverrides[inline.kind]; final source = switch (inline.kind) { - BusyInlineKind.text => _escapeInlineText( - inline.text, - atBlockStart: atBlockStart, - escapeTrailingBang: followedByLink, - ), + BusyInlineKind.text => + rawText + ? inline.text + : _escapeInlineText( + inline.text, + atBlockStart: atBlockStart, + escapeTrailingBang: followedByLink, + ), BusyInlineKind.math => _mathInline(inline), BusyInlineKind.strong => '${delimiter?.opening ?? '**'}$children${delimiter?.closing ?? '**'}', @@ -898,7 +913,9 @@ class BusyMarkMarkdownSerializer { ? _htmlCodeSpan(inline.text) : _codeSpan(inline.text), BusyInlineKind.link => - '[${children.isEmpty ? inline.text : children}](${_linkTarget(inline)})', + bareUrl + ? children + : '[${children.isEmpty ? inline.text : children}](${_linkTarget(inline)})', BusyInlineKind.image => '![${_escapeInlineText(inline.text)}](${inline.destination ?? ''})', BusyInlineKind.softBreak => ' ', @@ -906,7 +923,9 @@ class BusyMarkMarkdownSerializer { BusyInlineKind.writersideVariable => '%${inline.text}%', BusyInlineKind.html || BusyInlineKind.unknown => inline.text, }; - final escapedTextOffsets = inline.kind == BusyInlineKind.text + final escapedTextOffsets = rawText + ? {for (final target in targets) target: target} + : inline.kind == BusyInlineKind.text ? _escapedInlineTextOffsets( inline.text, targets, @@ -936,7 +955,9 @@ class BusyMarkMarkdownSerializer { BusyInlineKind.strikethrough => openingLength + (childResult?.sourceOffsets[target] ?? 0), BusyInlineKind.link => - 1 + (childResult?.sourceOffsets[target] ?? 0), + linkOpeningLength + + (childResult?.sourceOffsets[target] ?? + (bareUrl ? target : 0)), _ => source.length, }; } @@ -949,7 +970,7 @@ class BusyMarkMarkdownSerializer { } } else if (childResult != null) { final childOpeningLength = inline.kind == BusyInlineKind.link - ? 1 + ? linkOpeningLength : openingLength; for (final entry in childResult.lineBreakSourceOffsets.entries) { lineBreakSourceOffsets[entry.key] = childOpeningLength + entry.value; @@ -961,17 +982,19 @@ class BusyMarkMarkdownSerializer { inline.kind == BusyInlineKind.image) { final value = inline.text; final prefix = switch (inline.kind) { - BusyInlineKind.link => 1, + BusyInlineKind.link => linkOpeningLength, BusyInlineKind.image => 2, _ => 0, }; - final escapedOffsets = _escapedInlineTextOffsets( - value, - {for (var index = 0; index <= value.length; index++) index}, - atBlockStart: inline.kind == BusyInlineKind.text && atBlockStart, - escapeTrailingBang: - inline.kind == BusyInlineKind.text && followedByLink, - ); + final escapedOffsets = rawText + ? {for (var index = 0; index <= value.length; index++) index: index} + : _escapedInlineTextOffsets( + value, + {for (var index = 0; index <= value.length; index++) index}, + atBlockStart: inline.kind == BusyInlineKind.text && atBlockStart, + escapeTrailingBang: + inline.kind == BusyInlineKind.text && followedByLink, + ); var offset = 0; for (final rune in value.runes) { final width = rune > 0xffff ? 2 : 1; @@ -991,7 +1014,9 @@ class BusyMarkMarkdownSerializer { offset += width; } } else if (childResult != null) { - final prefix = inline.kind == BusyInlineKind.link ? 1 : openingLength; + final prefix = inline.kind == BusyInlineKind.link + ? linkOpeningLength + : openingLength; for (final atom in childResult.textAtoms) { textAtoms.add( BusyMarkSerializedTextAtom( diff --git a/lib/src/markdown/math_syntax.dart b/lib/src/markdown/math_syntax.dart index 7d5ae86c..39876e88 100644 --- a/lib/src/markdown/math_syntax.dart +++ b/lib/src/markdown/math_syntax.dart @@ -1,5 +1,6 @@ import 'package:markdown/markdown.dart' as md; +import 'busymark_document.dart'; import 'markdown_model.dart'; import 'markdown_source_annotation.dart'; import 'writerside_variable_syntax.dart'; @@ -40,12 +41,77 @@ md.Document busyMarkMarkdownDocument( WritersideLiteralPercentSyntax(), BusyDollarMathSyntax(), if (mode == MarkdownMode.writersideMarkdown) BusyWritersideMathSyntax(), + _BareUrlSyntax(), ], extensionSet: md.ExtensionSet.gitHubWeb, encodeHtml: false, ); } +/// Runs only the configured parser's extended autolink grammar. Everything +/// else remains literal, so recognition in displayed prose cannot interpret +/// Markdown delimiters, escapes, HTML, or math as newly authored syntax. +List<({int start, int end, String destination})> busyMarkBareUrlRanges( + String text, { + MarkdownMode mode = MarkdownMode.commonMark, +}) { + final configured = busyMarkMarkdownDocument(mode); + final document = md.Document( + inlineSyntaxes: configured.inlineSyntaxes + .whereType(), + withDefaultInlineSyntaxes: false, + encodeHtml: false, + ); + final ranges = <({int start, int end, String destination})>[]; + var offset = 0; + for (final node in document.parseInline(text)) { + final end = offset + node.textContent.length; + if (node is md.Element && + node.attributes[busyMarkBareUrlAttribute] == 'true') { + ranges.add(( + start: offset, + end: end, + destination: node.attributes['href']!, + )); + } + offset = end; + } + return ranges; +} + +// Delegate matching, punctuation trimming, and destination encoding to the +// locked Markdown implementation; attach source form only to bare URLs. +class _BareUrlSyntax extends md.AutolinkExtensionSyntax { + @override + bool onMatch(md.InlineParser parser, Match match) { + final capture = _AutolinkCaptureParser(parser); + super.onMatch(capture, match); + final node = capture.node!; + if (match[1] != null) { + node.attributes[busyMarkBareUrlAttribute] = 'true'; + } + parser + ..addNode(node) + ..consume(capture.pos - parser.pos); + return true; + } +} + +class _AutolinkCaptureParser extends md.InlineParser { + _AutolinkCaptureParser(md.InlineParser parser) + : super(parser.source, parser.document) { + pos = parser.pos; + start = parser.pos; + } + + md.Element? node; + + @override + void addNode(md.Node value) { + node = value as md.Element; + } +} + /// Returns the exact key the pinned Markdown parser registers for [label]. /// This deliberately uses the parser rather than maintaining a second /// whitespace or Unicode case-folding implementation in BusyMark. diff --git a/test/src/busymark_document_test.dart b/test/src/busymark_document_test.dart index 06866100..5b661129 100644 --- a/test/src/busymark_document_test.dart +++ b/test/src/busymark_document_test.dart @@ -5284,6 +5284,302 @@ void main() {} } }); + for (final mode in MarkdownMode.values) { + test('WYSIWYG Enter recognizes a pasted bare URL in ${mode.name}', () { + const url = 'https://example.com'; + final parsed = parser.parseInlineFragment(source: url, mode: mode); + expect(parsed.single.kind, BusyInlineKind.link); + expect(parsed.single.plainText, url); + expect(parsed.single.destination, url); + + final controller = BusyMarkWysiwygDocumentController( + document: parser + .parse(filePath: 'topic.md', source: '', mode: mode) + .busyDocument, + ); + addTearDown(controller.dispose); + final id = controller.document.blocks.single.id; + controller.updateBlockText(id, url); + expect( + busyInlineStyleRanges(controller.document.blocks.single.inlines), + isEmpty, + ); + + final result = controller.applyEnterAt(id, url.length)!; + final completed = controller.document.blocks.first; + final link = completed.inlines.single; + expect(link.kind, BusyInlineKind.link); + expect(link.plainText, url); + expect(link.destination, url); + final range = busyInlineStyleRanges(completed.inlines).single; + expect(range.kind, BusyInlineKind.link); + expect((range.start, range.end), (0, url.length)); + final next = controller.document.blocks.last; + expect(next.kind, BusyBlockKind.paragraph); + expect(next.plainText, isEmpty); + expect(busyInlineStyleRanges(next.inlines), isEmpty); + expect(result.blockId, next.id); + expect(result.offset, 0); + expect(controller.markdown, '$url\n\n'); + }); + } + + test( + 'WYSIWYG Enter uses parser URL boundaries without parsing other syntax', + () { + for (final mode in [ + MarkdownMode.commonMark, + MarkdownMode.writersideMarkdown, + ]) { + for (final text in [ + 'https://example.com.', + '(https://example.com/a_(b)).', + 'https://example.com/a_b?q=x&next=y#part', + 'https://example.com/a%20b?x=1&y=two+words', + 'https://example.com/a*b?q=_value_&next=ok', + 'www.example.com/path,', + 'ftp://example.com/path!', + 'prefixhttps://example.com', + ]) { + final expected = busyInlineStyleRanges( + parser.parseInlineFragment(source: text, mode: mode), + ).where((range) => range.kind == BusyInlineKind.link).toList(); + final controller = BusyMarkWysiwygDocumentController( + document: parser + .parse(filePath: 'topic.md', source: '', mode: mode) + .busyDocument, + ); + addTearDown(controller.dispose); + final id = controller.document.blocks.single.id; + controller.updateBlockText(id, text); + controller.applyEnterAt(id, text.length); + final actual = busyInlineStyleRanges( + controller.document.blocks.first.inlines, + ); + expect(controller.document.blocks.first.plainText, text); + expect( + actual.map((range) => (range.start, range.end, range.destination)), + expected.map( + (range) => (range.start, range.end, range.destination), + ), + reason: text, + ); + } + } + }, + ); + + test( + 'WYSIWYG Enter excludes code and keeps explicit equal-label links explicit', + () { + const url = 'https://example.com'; + const source = '`$url` [$url]($url) [other](https://other.example)'; + final controller = BusyMarkWysiwygDocumentController( + document: parser + .parse(filePath: 'topic.md', source: '$source\n') + .busyDocument, + ); + addTearDown(controller.dispose); + final block = controller.document.blocks.single; + controller.applyEnterAt(block.id, block.plainText.length); + final inlines = controller.document.blocks.first.inlines; + expect(inlines.first.kind, BusyInlineKind.code); + final links = inlines + .where((inline) => inline.kind == BusyInlineKind.link) + .toList(); + expect(links, hasLength(2)); + expect( + busyInlineStyleRanges( + inlines, + ).where((range) => range.kind == BusyInlineKind.link), + hasLength(2), + ); + expect(links.first.plainText, url); + expect(links.first.destination, url); + expect( + links.every( + (link) => !link.attributes.containsKey(busyMarkBareUrlAttribute), + ), + isTrue, + ); + expect(controller.markdown, '$source\n\n'); + final anchored = BusyMarkWysiwygDocumentController( + document: const BusyDocument( + filePath: 'anchor.md', + mode: MarkdownMode.commonMark, + blocks: [ + BusyBlock( + id: 'anchor', + kind: BusyBlockKind.paragraph, + inlines: [ + BusyInline(kind: BusyInlineKind.text, text: url), + BusyInline( + kind: BusyInlineKind.link, + text: '', + destination: 'docs.md', + ), + ], + ), + ], + ), + ); + addTearDown(anchored.dispose); + anchored.applyEnterAt('anchor', url.length); + final anchoredInlines = anchored.document.blocks.first.inlines; + expect(anchoredInlines.first.kind, BusyInlineKind.text); + expect(anchoredInlines.last.kind, BusyInlineKind.link); + expect(anchoredInlines.last.destination, 'docs.md'); + + controller.updateBlockText(block.id, '${block.plainText}!'); + expect(controller.markdown, contains('[$url]($url)')); + expect( + controller.document.blocks.first.inlines + .where((inline) => inline.kind == BusyInlineKind.link) + .first + .destination, + url, + ); + + final code = BusyMarkWysiwygDocumentController( + document: parser + .parse(filePath: 'code.md', source: '```text\n$url\n```\n') + .busyDocument, + ); + addTearDown(code.dispose); + final codeBlock = code.document.blocks.first; + final result = code.applyEnterAt(codeBlock.id, url.length)!; + expect(result.blockId, codeBlock.id); + expect(result.offset, url.length + 1); + expect(code.document.blocks.first.plainText, '$url\n'); + expect( + busyInlineStyleRanges(code.document.blocks.first.inlines), + isEmpty, + ); + }, + ); + + test( + 'bare URL destinations follow edits and splits while explicit destinations stay authored', + () { + const url = 'https://example.com'; + final controller = BusyMarkWysiwygDocumentController( + document: parser.parse(filePath: 'topic.md', source: '').busyDocument, + ); + addTearDown(controller.dispose); + final id = controller.document.blocks.single.id; + controller.updateBlockText(id, url); + controller.applyEnterAt(id, url.length); + const edited = '$url/path?q=one&next=two#part'; + controller.updateBlockText(id, edited); + var link = controller.document.blocks.first.inlines.single; + expect(link.destination, edited); + expect(link.attributes[busyMarkBareUrlAttribute], 'true'); + expect(controller.markdown, '$edited\n\n'); + controller.updateBlockText(id, '$edited.'); + link = controller.document.blocks.first.inlines.first; + expect(link.plainText, edited); + expect(link.destination, edited); + expect(controller.document.blocks.first.inlines.last.plainText, '.'); + expect(controller.markdown, '$edited.\n\n'); + controller.updateBlockText(id, edited); + final nextId = controller.splitBlockAt(id, url.length)!; + expect(controller.blockById(id)!.inlines.single.destination, url); + expect( + controller.blockById(nextId)!.plainText, + '/path?q=one&next=two#part', + ); + expect( + busyInlineStyleRanges(controller.blockById(nextId)!.inlines), + isEmpty, + ); + final splitAgain = controller.splitBlockAt(id, 'https://exa'.length)!; + expect(busyInlineStyleRanges(controller.blockById(id)!.inlines), isEmpty); + expect( + busyInlineStyleRanges(controller.blockById(splitAgain)!.inlines), + isEmpty, + ); + + final explicit = BusyMarkWysiwygDocumentController( + document: parser + .parse(filePath: 'explicit.md', source: '[$url]($url)\n') + .busyDocument, + ); + addTearDown(explicit.dispose); + explicit.updateBlockText(explicit.document.blocks.first.id, '$url/path'); + expect(explicit.document.blocks.first.inlines.single.destination, url); + expect(explicit.markdown, '[$url/path]($url)\n'); + }, + ); + + test( + 'structural URL slices update derived destinations and link editing authors an explicit destination', + () { + const url = 'https://example.com'; + final controller = BusyMarkWysiwygDocumentController( + document: parser + .parse(filePath: 'topic.md', source: '$url/path\n') + .busyDocument, + ); + addTearDown(controller.dispose); + final id = controller.document.blocks.single.id; + controller.insertStyledBlocksAtSelection( + blockId: id, + selectionStart: url.length, + selectionEnd: '$url/path'.length, + blocks: const [ + BusyWysiwygStyledBlock( + kind: BusyBlockKind.paragraph, + text: ' suffix', + ranges: [], + ), + ], + ); + final link = controller.document.blocks.single.inlines.first; + expect(link.kind, BusyInlineKind.link); + expect(link.plainText, url); + expect(link.destination, url); + expect(controller.markdown, '$url suffix\n'); + expect( + busyInlineStyleRanges(controller.document.blocks.single.inlines), + hasLength(1), + ); + controller.applyInlineCommand( + id, + BusyWysiwygInlineCommand.link, + 0, + url.length, + destination: 'https://edited.example', + ); + final authored = controller.document.blocks.single.inlines.first; + expect(authored.destination, 'https://edited.example'); + expect(authored.attributes, isNot(contains(busyMarkBareUrlAttribute))); + expect(controller.markdown, '[$url](https://edited.example) suffix\n'); + final rich = BusyMarkWysiwygDocumentController( + document: parser + .parse(filePath: 'rich.md', source: '$url/path\n') + .busyDocument, + ); + addTearDown(rich.dispose); + rich.insertStyledBlocksAtSelection( + blockId: rich.document.blocks.single.id, + selectionStart: url.length, + selectionEnd: '$url/path'.length, + blocks: const [ + BusyWysiwygStyledBlock( + kind: BusyBlockKind.paragraph, + text: '/new?q=x&next=y', + ranges: [], + ), + ], + ); + expect( + rich.document.blocks.single.inlines.single.destination, + '$url/new?q=x&next=y', + ); + expect(rich.markdown, '$url/new?q=x&next=y\n'); + }, + ); + test('WYSIWYG Enter splits one block into separate Markdown paragraphs', () { final parsed = parser.parse(filePath: 'topic.md', source: 'FirstSecond\n'); final controller = BusyMarkWysiwygDocumentController( diff --git a/test/src/markdown_serializer_metadata_test.dart b/test/src/markdown_serializer_metadata_test.dart index b62b9aa7..3b1eaf39 100644 --- a/test/src/markdown_serializer_metadata_test.dart +++ b/test/src/markdown_serializer_metadata_test.dart @@ -1,6 +1,8 @@ import 'package:busymark/src/markdown/busymark_document.dart'; import 'package:busymark/src/markdown/busymark_markdown_serializer.dart'; import 'package:busymark/src/markdown/markdown_model.dart'; +import 'package:busymark/src/markdown/markdown_parser.dart'; +import 'package:busymark/src/editor/wysiwyg/wysiwyg_document_controller.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { @@ -61,6 +63,38 @@ void main() { bool readableBreaks, }) >[ + ( + inlines: const [ + BusyInline( + kind: BusyInlineKind.link, + text: 'https://example.com/a_b?q=x&next=y', + destination: 'https://example.com/a_b?q=x&next=y', + attributes: {busyMarkBareUrlAttribute: 'true'}, + children: [ + BusyInline( + kind: BusyInlineKind.text, + text: 'https://example.com/a_b?q=x&next=y', + ), + ], + ), + ], + tableCell: false, + atBlockStart: true, + readableBreaks: true, + ), + ( + inlines: const [ + BusyInline( + kind: BusyInlineKind.link, + text: 'https://example.com/a_b?q=x&next=y', + destination: 'https://example.com/a_b?q=x&next=y', + attributes: {busyMarkBareUrlAttribute: 'true'}, + ), + ], + tableCell: true, + atBlockStart: false, + readableBreaks: true, + ), ( inlines: const [ BusyInline(kind: BusyInlineKind.text, text: 'plain'), @@ -189,6 +223,128 @@ void main() { } }); + test( + 'bare URL offsets and text atoms stay exact through Enter, edit and save/reparse', + () { + const parser = MarkdownParser(); + for (final mode in [ + MarkdownMode.commonMark, + MarkdownMode.writersideMarkdown, + ]) { + const url = 'https://example.com'; + final controller = BusyMarkWysiwygDocumentController( + document: parser + .parse(filePath: 'topic.md', source: '', mode: mode) + .busyDocument, + ); + addTearDown(controller.dispose); + final id = controller.document.blocks.single.id; + controller.updateBlockText(id, url); + controller.applyEnterAt(id, url.length); + void check(BusyBlock block, String expected) { + expect(block.inlines.single.kind, BusyInlineKind.link); + expect( + block.inlines.single.attributes[busyMarkBareUrlAttribute], + 'true', + ); + expect(block.inlines.single.destination, expected); + expect(serializer.serializeInlineFragment(block.inlines), expected); + for (var offset = 0; offset <= expected.length; offset++) { + final result = serializer.serializeInlineFragmentWithOffsets( + block.inlines, + textOffset: offset, + ); + expect(result.source, expected); + expect(result.sourceOffset, offset); + expect(result.textAtoms, hasLength(expected.length)); + for (var index = 0; index < result.textAtoms.length; index++) { + final atom = result.textAtoms[index]; + expect((atom.textStart, atom.textEnd), (index, index + 1)); + expect((atom.sourceStart, atom.sourceEnd), (index, index + 1)); + expect(atom.text, expected[index]); + expect(atom.inlinePath, [0, 0]); + expect(atom.escaped, isFalse); + } + } + } + + check(controller.document.blocks.first, url); + expect(controller.markdown, '$url\n\n'); + const edited = '$url/a_b?q=x&next=y#part'; + controller.updateBlockText(id, edited); + check(controller.document.blocks.first, edited); + expect(controller.markdown, '$edited\n\n'); + final saved = parser + .parse( + filePath: 'topic.md', + source: controller.markdown, + mode: mode, + ) + .busyDocument; + check(saved.blocks.single, edited); + final reopened = BusyMarkWysiwygDocumentController(document: saved); + addTearDown(reopened.dispose); + reopened.updateBlockText(saved.blocks.single.id, '$edited/new'); + check(reopened.document.blocks.single, '$edited/new'); + expect(reopened.markdown, '$edited/new\n\n'); + // A second Enter is idempotent and preserves the imported source form. + reopened.applyEnterAt(saved.blocks.single.id, '$edited/new'.length); + check(reopened.document.blocks.first, '$edited/new'); + expect(reopened.markdown, '$edited/new\n\n'); + } + }, + ); + + test( + 'bare leaf links use raw URL positions and explicit equal-label links retain delimiters', + () { + const url = 'https://example.com/a_b?q=x&next=y'; + for (final bare in [false, true]) { + for (final withChildren in [false, true]) { + final inlines = [ + BusyInline( + kind: BusyInlineKind.link, + text: url, + destination: url, + attributes: bare + ? const {busyMarkBareUrlAttribute: 'true'} + : const {}, + children: withChildren + ? const [BusyInline(kind: BusyInlineKind.text, text: url)] + : const [], + ), + ]; + final expected = bare + ? url + : r'[https://example.com/a\_b?q=x\&next=y](https://example.com/a_b?q=x&next=y)'; + final ordinary = serializer.serializeInlineFragment(inlines); + expect(ordinary, expected); + for (final offset in [0, 1, 8, url.length]) { + final result = serializer.serializeInlineFragmentWithOffsets( + inlines, + textOffset: offset, + ); + expect(result.source, ordinary); + if (bare) { + expect(result.sourceOffset, offset); + } + if (!bare && withChildren && offset == 8) { + expect(result.sourceOffset, 9); + } + expect(result.textAtoms.first.sourceStart, bare ? 0 : 1); + expect( + result.textAtoms.last.sourceEnd, + bare ? url.length : url.length + 3, + ); + if (bare) { + expect(result.textAtoms.every((atom) => !atom.escaped), isTrue); + } + } + } + } + }, + ); + test('grouped breaks retain distinct source provenance in one traversal', () { const first = BusyMarkInlineLineBreakOffset(textOffset: 1); const second = BusyMarkInlineLineBreakOffset(textOffset: 2); diff --git a/test/src/wysiwyg_clipboard_test.dart b/test/src/wysiwyg_clipboard_test.dart index addb94fe..0ecf0d42 100644 --- a/test/src/wysiwyg_clipboard_test.dart +++ b/test/src/wysiwyg_clipboard_test.dart @@ -500,6 +500,7 @@ void main() { ValueChanged changed, { BusyMarkClipboardInsertionRegistry? registry, ValueChanged? onCaptured, + ValueChanged? onDocumentChanged, String? filePath, String? workspaceRoot, AssetWorkspaceKind? assetWorkspaceKind, @@ -543,6 +544,7 @@ void main() { initialSessionFor?.call(document) ?? const WysiwygEditorSessionState(), document: document, + onDocumentChanged: onDocumentChanged, onSourceChanged: (_, value) => changed(value), ), ), @@ -565,6 +567,92 @@ void main() { await tester.pump(); } + for (final plainText in [false, true]) { + testWidgets( + 'text-only URL paste then ordinary Enter recognizes a live link (plainText=$plainText)', + (tester) async { + const url = 'https://example.com'; + systemData = {'text': url}; + var source = ''; + BusyDocument? live; + await mount( + tester, + 'bare-url-$plainText', + source, + (value) => source = value, + onDocumentChanged: (value) => live = value, + ); + await key(tester, LogicalKeyboardKey.keyV, shift: plainText); + await tester.pumpAndSettle(); + expect(live!.blocks.single.plainText, url); + expect(busyInlineStyleRanges(live!.blocks.single.inlines), isEmpty); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + final link = live!.blocks.first.inlines.single; + expect(link.kind, BusyInlineKind.link); + expect(link.plainText, url); + expect(link.destination, url); + expect(source, '$url\n\n'); + final fields = tester + .widgetList(find.byType(TextField)) + .toList(); + expect(fields, hasLength(2)); + expect(fields.last.controller!.text, ''); + expect(fields.last.focusNode!.hasFocus, isTrue); + expect( + fields.last.controller!.selection, + const TextSelection.collapsed(offset: 0), + ); + final span = fields.first.controller!.buildTextSpan( + context: tester.element(find.byType(TextField).first), + withComposing: false, + ); + expect( + (span.children!.single as TextSpan).style!.decoration, + TextDecoration.underline, + ); + await key(tester, LogicalKeyboardKey.keyZ); + expect(live!.blocks, hasLength(1)); + expect(live!.blocks.single.plainText, url); + expect(busyInlineStyleRanges(live!.blocks.single.inlines), isEmpty); + await key(tester, LogicalKeyboardKey.keyZ, shift: true); + expect(live!.blocks.first.inlines.single.kind, BusyInlineKind.link); + expect(source, '$url\n\n'); + final completedField = tester.widget( + find.byType(TextField).first, + ); + completedField.focusNode!.requestFocus(); + completedField.controller!.selection = const TextSelection.collapsed( + offset: 8, + ); + await tester.pump(); + await key(tester, LogicalKeyboardKey.keyK); + await tester.pumpAndSettle(); + final destination = find.byKey( + const ValueKey('wysiwyg-link-destination-field'), + ); + expect(destination, findsOneWidget); + expect( + tester + .widget( + find.descendant( + of: destination, + matching: find.byType(EditableText), + ), + ) + .controller + .text, + url, + ); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + await key(tester, LogicalKeyboardKey.keyZ); + await key(tester, LogicalKeyboardKey.keyZ); + expect(live!.blocks.single.plainText, ''); + }, + ); + } + Future copyAll(WidgetTester tester, {String source = _source}) async { await mount(tester, 'origin', source, (_) {}); await key(tester, LogicalKeyboardKey.keyA); diff --git a/test/src/wysiwyg_structured_regression_test.dart b/test/src/wysiwyg_structured_regression_test.dart index 88e7b17c..18db312a 100644 --- a/test/src/wysiwyg_structured_regression_test.dart +++ b/test/src/wysiwyg_structured_regression_test.dart @@ -19,6 +19,180 @@ import '../support/memory_rich_clipboard.dart'; void main() { const parser = MarkdownParser(); + test( + 'Enter URL recognition preserves formatting, literal syntax, anchors, breaks and descendants', + () { + const url = 'https://example.com/a_b?q=one&next=two'; + const literal = '*literal* _literal_ `literal` ©'; + const strong = BusyInline( + kind: BusyInlineKind.strong, + text: 'bold', + attributes: {'retained': 'strong'}, + children: [BusyInline(kind: BusyInlineKind.text, text: 'bold')], + ); + const image = BusyInline( + kind: BusyInlineKind.image, + text: '', + destination: 'image.png', + attributes: {'retained': 'image'}, + ); + const child = BusyBlock( + id: 'child', + kind: BusyBlockKind.paragraph, + inlines: [BusyInline(kind: BusyInlineKind.text, text: 'Child')], + ); + const block = BusyBlock( + id: 'paragraph', + kind: BusyBlockKind.paragraph, + attributes: {'retained': 'block'}, + children: [child], + inlines: [ + strong, + image, + BusyInline( + kind: BusyInlineKind.text, + text: ' $literal', + attributes: {'retained': 'text'}, + ), + BusyInline( + kind: BusyInlineKind.hardBreak, + text: '\n', + attributes: {'retained': 'break'}, + ), + BusyInline(kind: BusyInlineKind.text, text: url), + ], + ); + final controller = BusyMarkWysiwygDocumentController( + document: const BusyDocument( + filePath: 'topic.md', + mode: MarkdownMode.commonMark, + blocks: [block], + ), + ); + addTearDown(controller.dispose); + controller.applyEnterAt(block.id, block.plainText.length); + final completed = controller.document.blocks.first; + expect(completed.plainText, block.plainText); + expect(completed.children.single, same(child)); + expect(completed.attributes['retained'], 'block'); + expect(completed.inlines.first, same(strong)); + expect(completed.inlines[1], same(image)); + expect(completed.inlines[2].attributes['retained'], 'text'); + expect(completed.inlines[3].kind, BusyInlineKind.hardBreak); + expect(completed.inlines[3].attributes['retained'], 'break'); + expect(completed.inlines.last.kind, BusyInlineKind.link); + expect(completed.inlines.last.destination, url); + final ranges = busyInlineStyleRanges(completed.inlines); + expect( + ranges.where((range) => range.kind == BusyInlineKind.link).single.start, + block.plainText.length - url.length, + ); + expect( + ranges.where((range) => range.kind == BusyInlineKind.strong).single.end, + 4, + ); + final reparsed = parser + .parse(filePath: 'topic.md', source: controller.markdown) + .busyDocument; + expect(reparsed.blocks.first.plainText, block.plainText); + expect( + busyInlineStyleRanges(reparsed.blocks.first.inlines) + .where((range) => range.kind == BusyInlineKind.link) + .single + .destination, + url, + ); + expect(controller.markdown, contains('![](image.png)')); + expect(controller.markdown, contains(' \n$url')); + }, + ); + + test( + 'Enter recognition preserves formatting within a URL and does not enter math fields', + () { + const url = 'https://example.com/path'; + const formatted = BusyBlock( + id: 'formatted', + kind: BusyBlockKind.paragraph, + inlines: [ + BusyInline( + kind: BusyInlineKind.strong, + text: 'prefix $url suffix', + attributes: {'retained': 'value'}, + children: [ + BusyInline(kind: BusyInlineKind.text, text: 'prefix $url suffix'), + ], + ), + ], + ); + final controller = BusyMarkWysiwygDocumentController( + document: const BusyDocument( + filePath: 'topic.md', + mode: MarkdownMode.commonMark, + blocks: [formatted], + ), + ); + addTearDown(controller.dispose); + controller.applyEnterAt(formatted.id, formatted.plainText.length); + var block = controller.document.blocks.first; + expect(block.plainText, formatted.plainText); + final link = block.inlines.single.children.singleWhere( + (inline) => inline.kind == BusyInlineKind.link, + ); + expect(link.destination, url); + expect(block.inlines.single.kind, BusyInlineKind.strong); + expect(block.inlines.single.attributes['retained'], 'value'); + controller.updateBlockText(block.id, 'prefix $url/new suffix'); + block = controller.document.blocks.first; + expect( + busyInlineStyleRanges(block.inlines) + .where((range) => range.kind == BusyInlineKind.link) + .single + .destination, + '$url/new', + ); + final reloaded = parser + .parse(filePath: 'topic.md', source: controller.markdown) + .busyDocument + .blocks + .first; + expect(reloaded.plainText, block.plainText); + expect( + busyInlineStyleRanges(reloaded.inlines) + .where((range) => range.kind == BusyInlineKind.link) + .single + .destination, + '$url/new', + ); + + const math = BusyBlock( + id: 'math', + kind: BusyBlockKind.paragraph, + inlines: [ + BusyInline(kind: BusyInlineKind.math, text: 'x'), + BusyInline(kind: BusyInlineKind.text, text: ' $url'), + ], + ); + final mathController = BusyMarkWysiwygDocumentController( + document: const BusyDocument( + filePath: 'math.md', + mode: MarkdownMode.commonMark, + blocks: [math], + ), + ); + addTearDown(mathController.dispose); + mathController.applyEnterAt(math.id, math.plainText.length); + expect( + busyInlineStyleRanges(mathController.document.blocks.first.inlines), + isEmpty, + ); + expect( + mathController.document.blocks.first.inlines.first.kind, + BusyInlineKind.math, + ); + }, + ); + test('text whitespace preservation metadata follows pasted whitespace', () { final document = parser .parse(filePath: 'topic.md', source: 'Target\n') From 8f3273faa6f8585132e296541a461fdaa21925af Mon Sep 17 00:00:00 2001 From: albert Date: Sun, 4 Oct 2026 09:21:13 -0700 Subject: [PATCH 11/14] Enhance bare URL rejoining and interior formatting retention * Implement bare URL reconstruction across block join operations (e.g., Backspace) to reconnect divided link components without disrupting existing semantics. * Retain complete URL destinations when applying interior inline formatting by saving them with explicit Markdown labels and entity references to prevent delimiter parsing issues. * Update `BusyMarkMarkdownSerializer` to properly track literal prefix lengths and entity offsets for partially formatted links and surrounding punctuation. * Refactor inline style merging to keep links intact across interior formatting boundaries instead of fragmenting them based on style priority. * Add comprehensive tests verifying URL rejoining, clipboard paste persistence, and round-trip serialization of formatted URL suffixes. --- .../wysiwyg/wysiwyg_document_controller.dart | 140 ++++++++-- .../busymark_markdown_serializer.dart | 150 ++++++++++- test/src/busymark_document_test.dart | 90 ++++++- .../markdown_serializer_metadata_test.dart | 245 ++++++++++++++++++ test/src/wysiwyg_clipboard_test.dart | 82 ++++++ .../wysiwyg_structured_regression_test.dart | 84 ++++++ 6 files changed, 763 insertions(+), 28 deletions(-) diff --git a/lib/src/editor/wysiwyg/wysiwyg_document_controller.dart b/lib/src/editor/wysiwyg/wysiwyg_document_controller.dart index 0a7700e2..47ba2e69 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_document_controller.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_document_controller.dart @@ -1923,10 +1923,18 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { final previousText = previous.plainText; final currentText = current.plainText; final mergedChildren = [...previous.children, ...current.children]; - final mergedInlines = _mergeAdjacentInlineStyles([ + var mergedInlines = _mergeAdjacentInlineStyles([ ...previous.inlines, ...current.inlines, ]); + if (_supportsBareUrlRecognition(previous) && + _supportsBareUrlRecognition(current)) { + mergedInlines = _rejoinBareUrls( + mergedInlines, + previousText.length, + mode: _document.mode, + ); + } if (currentText.isEmpty) { final updatedPrevious = _withoutSourceSpan( previous.copyWith(inlines: mergedInlines, children: mergedChildren), @@ -3684,6 +3692,7 @@ List _recognizeBareUrls( List inlines, { MarkdownMode mode = MarkdownMode.commonMark, Map attributes = const {}, + List? existingUrls, }) { final text = inlines.map((inline) => inline.plainText).join(); final excluded = <({int start, int end})>[]; @@ -3715,11 +3724,16 @@ List _recognizeBareUrls( visit(inline); } final matches = busyMarkBareUrlRanges(text, mode: mode).where( - (match) => !excluded.any( - (range) => range.start == range.end - ? range.start > match.start && range.start <= match.end - : range.start < match.end && range.end > match.start, - ), + (match) => + (existingUrls == null || + existingUrls.any( + (range) => range.start < match.end && range.end > match.start, + )) && + !excluded.any( + (range) => range.start == range.end + ? range.start > match.start && range.start <= match.end + : range.start < match.end && range.end > match.start, + ), ); var result = inlines; // Work backwards so earlier slices retain their original coordinates. @@ -3729,12 +3743,98 @@ List _recognizeBareUrls( match.start, match.end, match.destination, - attributes, + existingUrls + ?.firstWhere( + (range) => range.start < match.end && range.end > match.start, + ) + .attributes ?? + attributes, ); } return result; } +/// Joining a paragraph can extend an already recognized URL into its former +/// text sibling. Only links touching the join seed recognition; other text +/// retains its existing semantics. +List _rejoinBareUrls( + List inlines, + int joinOffset, { + required MarkdownMode mode, +}) { + final text = inlines.map((inline) => inline.plainText).join(); + bool protected(BusyInline inline) => switch (inline.kind) { + BusyInlineKind.link => + inline.attributes[busyMarkBareUrlAttribute] != 'true', + BusyInlineKind.code || + BusyInlineKind.image || + BusyInlineKind.math || + BusyInlineKind.html || + BusyInlineKind.unknown || + BusyInlineKind.writersideVariable => true, + _ => inline.children.any(protected), + }; + final candidates = busyMarkBareUrlRanges(text, mode: mode).where((range) { + if (range.start >= joinOffset || range.end <= joinOffset) return false; + final prefix = _partitionInlinesForReplacement( + inlines, + range.start, + range.start, + refreshUrls: false, + ); + final slice = _partitionInlinesForReplacement( + prefix.after, + range.end - range.start, + range.end - range.start, + refreshUrls: false, + ); + return !slice.before.any(protected); + }).toList(); + if (candidates.isEmpty) return inlines; + final existing = []; + var offset = 0; + List visit(BusyInline inline) { + final start = offset; + final end = start + inline.plainText.length; + if (inline.kind == BusyInlineKind.link) { + offset = end; + if (inline.attributes[busyMarkBareUrlAttribute] == 'true' && + start <= joinOffset && + end >= joinOffset && + candidates.any((range) => range.start < end && range.end > start)) { + existing.add( + BusyInlineStyleRange( + start: start, + end: end, + kind: BusyInlineKind.link, + destination: inline.destination, + attributes: inline.attributes, + ), + ); + return inline.children.isEmpty + ? _textInlines(inline.text) + : inline.children; + } + return [inline]; + } + if (inline.children.isNotEmpty) { + return [ + inline.copyWith( + children: [for (final child in inline.children) ...visit(child)], + ), + ]; + } + offset = end; + return [inline]; + } + + final unwrapped = [for (final inline in inlines) ...visit(inline)]; + if (existing.isEmpty) return inlines; + return _mergeAdjacentInlineStyles( + _recognizeBareUrls(unwrapped, mode: mode, existingUrls: existing), + ); +} + List _wrapBareUrlRange( List inlines, int start, @@ -3769,6 +3869,12 @@ List _wrapBareUrlRange( end - start, end - start, ); + // Interior formatting requires an explicit Markdown label. Commit that + // source form in the live model too, so later label edits have the same + // destination semantics before and after saving/reopening. + bool plainUrlText(BusyInline inline) => + inline.kind == BusyInlineKind.text && inline.children.every(plainUrlText); + final bare = url.before.every(plainUrlText); return [ ...prefix.before, BusyInline( @@ -3777,8 +3883,9 @@ List _wrapBareUrlRange( destination: destination, children: url.before, attributes: { - ...attributes, - busyMarkBareUrlAttribute: 'true', + for (final entry in attributes.entries) + if (entry.key != busyMarkBareUrlAttribute) entry.key: entry.value, + if (bare) busyMarkBareUrlAttribute: 'true', if (attributes.containsKey('href')) 'href': destination, }, ), @@ -4467,7 +4574,9 @@ List _inlinesFromStyleRanges( ), ), ]; - return _refreshBareUrlLinks(_mergeAdjacentInlineStyles(segments)); + return _mergeAdjacentInlineStyles( + _refreshBareUrlLinks(_mergeAdjacentInlineStyles(segments)), + ); } List _mergeAdjacentInlineStyles(List inlines) { @@ -4593,14 +4702,9 @@ BusyInline _inlineForSegment(String text, List styles) { } int _compareStyleRanges(BusyInlineStyleRange a, BusyInlineStyleRange b) { - // Bare URLs stay inside surrounding formatting, letting reconstruction - // merge those wrappers without introducing adjacent Markdown delimiters. - int priority(BusyInlineStyleRange range) => - range.kind == BusyInlineKind.link && - range.attributes[busyMarkBareUrlAttribute] == 'true' - ? 6 - : _stylePriority(range.kind); - return priority(a).compareTo(priority(b)); + // Keep links whole across interior formatting boundaries. Refreshing a + // bare URL subsequently restores any formatting enclosing its entire text. + return _stylePriority(a.kind).compareTo(_stylePriority(b.kind)); } int _stylePriority(BusyInlineKind kind) { diff --git a/lib/src/markdown/busymark_markdown_serializer.dart b/lib/src/markdown/busymark_markdown_serializer.dart index 118fd099..526072ed 100644 --- a/lib/src/markdown/busymark_markdown_serializer.dart +++ b/lib/src/markdown/busymark_markdown_serializer.dart @@ -691,6 +691,8 @@ class BusyMarkMarkdownSerializer { List pathPrefix = const [], int textBase = 0, bool bareUrlText = false, + bool formattedUrlLabel = false, + bool insideFormatting = false, }) { final totalTextLength = inlines.fold( 0, @@ -798,12 +800,38 @@ class BusyMarkMarkdownSerializer { followedByLink: index + 1 < inlines.length && inlines[index + 1].kind == BusyInlineKind.link && - inlines[index + 1].attributes[busyMarkBareUrlAttribute] != 'true', + !_canEmitBareUrl(inlines[index + 1]), delimiterOverrides: delimiterOverrides, metrics: metrics, inlinePath: [...pathPrefix, index], textBase: textBase + consumedText, bareUrlText: bareUrlText, + literalPrefixLength: + !insideFormatting && inline.kind == BusyInlineKind.text && index > 0 + ? _bareUrlLiteralSuffixLength(inlines[index - 1], inline.text) + : 0, + forceExplicitBareUrl: + insideFormatting && + index + 1 < inlines.length && + inlines[index + 1].kind == BusyInlineKind.text && + _bareUrlLiteralSuffixLength(inline, inlines[index + 1].text) > 0, + formattedUrlLabel: formattedUrlLabel, + insideFormatting: insideFormatting, + entityOffsets: + // A character reference at a formatting boundary makes it a + // punctuation boundary to Markdown while retaining the URL text. + // This keeps, for example, com**/path** from being literal text. + formattedUrlLabel && + inline.kind == BusyInlineKind.text && + inline.text.isNotEmpty + ? { + if (index > 0 && _urlFormattingKind(inlines[index - 1].kind)) 0, + if (index + 1 < inlines.length && + _urlFormattingKind(inlines[index + 1].kind)) + inline.text.length - + (inline.text.runes.last > 0xffff ? 2 : 1), + } + : const {}, ); for (final entry in result.sourceOffsets.entries) { sourceOffsets.putIfAbsent( @@ -857,10 +885,21 @@ class BusyMarkMarkdownSerializer { required List inlinePath, required int textBase, required bool bareUrlText, + required int literalPrefixLength, + required bool forceExplicitBareUrl, + required bool formattedUrlLabel, + required bool insideFormatting, + required Set entityOffsets, }) { - final bareUrl = + final bareUrl = _canEmitBareUrl(inline) && !forceExplicitBareUrl; + final formattedLinkLabel = inline.kind == BusyInlineKind.link && - inline.attributes[busyMarkBareUrlAttribute] == 'true'; + !bareUrl && + !inline.children.every(_isPlainUrlText) && + (inline.attributes[busyMarkBareUrlAttribute] == 'true' || + busyMarkBareUrlRanges( + inline.plainText, + ).any((range) => range.start == 0)); final rawText = bareUrlText || bareUrl; final linkOpeningLength = bareUrl ? 0 : 1; final length = inline.plainText.length; @@ -883,6 +922,9 @@ class BusyMarkMarkdownSerializer { pathPrefix: inlinePath, textBase: textBase, bareUrlText: rawText, + formattedUrlLabel: formattedUrlLabel || formattedLinkLabel, + insideFormatting: + insideFormatting || _urlFormattingKind(inline.kind), ); final children = inline.children.isEmpty ? rawText @@ -898,6 +940,8 @@ class BusyMarkMarkdownSerializer { inline.text, atBlockStart: atBlockStart, escapeTrailingBang: followedByLink, + literalPrefixLength: literalPrefixLength, + entityOffsets: entityOffsets, ), BusyInlineKind.math => _mathInline(inline), BusyInlineKind.strong => @@ -931,6 +975,8 @@ class BusyMarkMarkdownSerializer { targets, atBlockStart: atBlockStart, escapeTrailingBang: followedByLink, + literalPrefixLength: literalPrefixLength, + entityOffsets: entityOffsets, ) : const {}; final openingLength = @@ -994,6 +1040,12 @@ class BusyMarkMarkdownSerializer { atBlockStart: inline.kind == BusyInlineKind.text && atBlockStart, escapeTrailingBang: inline.kind == BusyInlineKind.text && followedByLink, + literalPrefixLength: inline.kind == BusyInlineKind.text + ? literalPrefixLength + : 0, + entityOffsets: inline.kind == BusyInlineKind.text + ? entityOffsets + : const {}, ); var offset = 0; for (final rune in value.runes) { @@ -1107,6 +1159,8 @@ class BusyMarkMarkdownSerializer { Set offsets, { required bool atBlockStart, required bool escapeTrailingBang, + int literalPrefixLength = 0, + Set entityOffsets = const {}, }) { final blockMarkerOffsets = _blockMarkerEscapeOffsets( value, @@ -1122,10 +1176,24 @@ class BusyMarkMarkdownSerializer { targetIndex += 1; } if (index == value.length) break; + if (entityOffsets.contains(index)) { + final rune = value.substring(index).runes.first; + sourceOffset += '&#$rune;'.length; + if (rune > 0xffff) { + index++; + if (targetIndex < targets.length && targets[targetIndex] == index) { + result[targets[targetIndex++]] = sourceOffset; + } + } + continue; + } final unit = value.codeUnitAt(index); - if (_inlineSyntaxCharacters.contains(unit) || - (escapeTrailingBang && unit == 0x21 && index == value.length - 1) || - blockMarkerOffsets.contains(index)) { + if (index >= literalPrefixLength && + (_inlineSyntaxCharacters.contains(unit) || + (escapeTrailingBang && + unit == 0x21 && + index == value.length - 1) || + blockMarkerOffsets.contains(index))) { sourceOffset += 1; } sourceOffset += 1; @@ -1239,6 +1307,8 @@ class BusyMarkMarkdownSerializer { String value, { bool atBlockStart = false, bool escapeTrailingBang = false, + int literalPrefixLength = 0, + Set entityOffsets = const {}, }) { final blockMarkerOffsets = _blockMarkerEscapeOffsets( value, @@ -1246,10 +1316,19 @@ class BusyMarkMarkdownSerializer { ); final buffer = StringBuffer(); for (var index = 0; index < value.length; index++) { + if (entityOffsets.contains(index)) { + final rune = value.substring(index).runes.first; + buffer.write('&#$rune;'); + if (rune > 0xffff) index++; + continue; + } final unit = value.codeUnitAt(index); - if (_inlineSyntaxCharacters.contains(unit) || - (escapeTrailingBang && unit == 0x21 && index == value.length - 1) || - blockMarkerOffsets.contains(index)) { + if (index >= literalPrefixLength && + (_inlineSyntaxCharacters.contains(unit) || + (escapeTrailingBang && + unit == 0x21 && + index == value.length - 1) || + blockMarkerOffsets.contains(index))) { buffer.writeCharCode(0x5c); } buffer.writeCharCode(unit); @@ -1257,6 +1336,59 @@ class BusyMarkMarkdownSerializer { return buffer.toString(); } + bool _canEmitBareUrl(BusyInline inline) => + inline.kind == BusyInlineKind.link && + inline.attributes[busyMarkBareUrlAttribute] == 'true' && + inline.children.every(_isPlainUrlText); + + bool _urlFormattingKind(BusyInlineKind kind) => switch (kind) { + BusyInlineKind.strong || + BusyInlineKind.emphasis || + BusyInlineKind.strikethrough || + BusyInlineKind.underline => true, + _ => false, + }; + + bool _isPlainUrlText(BusyInline inline) => + inline.kind == BusyInlineKind.text && + inline.children.every(_isPlainUrlText); + + int _bareUrlLiteralSuffixLength(BusyInline previous, String text) { + if (_urlFormattingKind(previous.kind) && previous.children.isNotEmpty) { + return _bareUrlLiteralSuffixLength(previous.children.last, text); + } + if (!_canEmitBareUrl(previous)) return 0; + final whitespace = text.indexOf(RegExp(r'\s')); + final length = whitespace < 0 ? text.length : whitespace; + if (length == 0) return 0; + // Only delimiter punctuation gets this escape exception. Parser-excluded + // HTML or entity text must retain its ordinary literal escaping. + if (text + .substring(0, length) + .codeUnits + .any( + (unit) => + _inlineSyntaxCharacters.contains(unit) && + unit != 0x2a && + unit != 0x5f && + unit != 0x7e, + )) { + return 0; + } + // Escaping a parser-excluded suffix inserts a backslash which the URL + // grammar can consume. Keep that suffix literal, using the same grammar + // to establish the boundary instead of a separate punctuation rule. + final ranges = busyMarkBareUrlRanges( + '${previous.plainText}${text.substring(0, length)}', + ); + return ranges.length == 1 && + ranges.single.start == 0 && + ranges.single.end == previous.plainText.length && + ranges.single.destination == previous.destination + ? length + : 0; + } + Set _blockMarkerEscapeOffsets( String value, { required bool atBlockStart, diff --git a/test/src/busymark_document_test.dart b/test/src/busymark_document_test.dart index 5b661129..f1487bf5 100644 --- a/test/src/busymark_document_test.dart +++ b/test/src/busymark_document_test.dart @@ -5482,7 +5482,7 @@ void main() {} expect(controller.document.blocks.first.inlines.last.plainText, '.'); expect(controller.markdown, '$edited.\n\n'); controller.updateBlockText(id, edited); - final nextId = controller.splitBlockAt(id, url.length)!; + final nextId = controller.applyEnterAt(id, url.length)!.blockId; expect(controller.blockById(id)!.inlines.single.destination, url); expect( controller.blockById(nextId)!.plainText, @@ -5492,6 +5492,17 @@ void main() {} busyInlineStyleRanges(controller.blockById(nextId)!.inlines), isEmpty, ); + final joined = controller.applyBackspaceAtStart(nextId)!; + expect((joined.blockId, joined.offset), (id, url.length)); + expect(controller.blockById(id)!.plainText, edited); + final joinedLink = busyInlineStyleRanges( + controller.blockById(id)!.inlines, + ).where((range) => range.kind == BusyInlineKind.link).single; + expect( + (joinedLink.start, joinedLink.end, joinedLink.destination), + (0, edited.length, edited), + ); + expect(controller.markdown, '$edited\n\n'); final splitAgain = controller.splitBlockAt(id, 'https://exa'.length)!; expect(busyInlineStyleRanges(controller.blockById(id)!.inlines), isEmpty); expect( @@ -5511,6 +5522,83 @@ void main() {} }, ); + test( + 'rejoining only extends existing bare URLs and preserves code and explicit links', + () { + const url = 'https://example.com'; + final plain = BusyMarkWysiwygDocumentController( + document: parser.parse(filePath: 'topic.md', source: '').busyDocument, + ); + addTearDown(plain.dispose); + final id = plain.document.blocks.single.id; + plain.updateBlockText(id, '$url/path'); + final second = plain.splitBlockAt(id, url.length)!; + plain.applyBackspaceAtStart(second); + expect(plain.document.blocks.single.plainText, '$url/path'); + expect( + busyInlineStyleRanges(plain.document.blocks.single.inlines), + isEmpty, + ); + for (final code in [false, true]) { + final document = code + ? const BusyDocument( + filePath: 'topic.md', + mode: MarkdownMode.commonMark, + blocks: [ + BusyBlock( + id: 'first', + kind: BusyBlockKind.paragraph, + inlines: [ + BusyInline( + kind: BusyInlineKind.link, + text: url, + destination: url, + attributes: {busyMarkBareUrlAttribute: 'true'}, + ), + ], + ), + BusyBlock( + id: 'second', + kind: BusyBlockKind.paragraph, + inlines: [ + BusyInline(kind: BusyInlineKind.code, text: '/path'), + ], + ), + ], + ) + : parser + .parse( + filePath: 'topic.md', + source: '[$url]($url)\n\n/path\n', + ) + .busyDocument; + final controller = BusyMarkWysiwygDocumentController( + document: document, + ); + addTearDown(controller.dispose); + controller.applyBackspaceAtStart(document.blocks.last.id); + final block = controller.document.blocks.single; + final ranges = busyInlineStyleRanges(block.inlines); + final link = ranges + .where((range) => range.kind == BusyInlineKind.link) + .single; + expect((link.start, link.end, link.destination), (0, url.length, url)); + expect(block.plainText, '$url/path'); + if (code) { + expect( + ranges + .where((range) => range.kind == BusyInlineKind.code) + .single + .start, + url.length, + ); + } else { + expect(controller.markdown, '[$url]($url)/path\n'); + } + } + }, + ); + test( 'structural URL slices update derived destinations and link editing authors an explicit destination', () { diff --git a/test/src/markdown_serializer_metadata_test.dart b/test/src/markdown_serializer_metadata_test.dart index 3b1eaf39..ecabc771 100644 --- a/test/src/markdown_serializer_metadata_test.dart +++ b/test/src/markdown_serializer_metadata_test.dart @@ -3,11 +3,256 @@ import 'package:busymark/src/markdown/busymark_markdown_serializer.dart'; import 'package:busymark/src/markdown/markdown_model.dart'; import 'package:busymark/src/markdown/markdown_parser.dart'; import 'package:busymark/src/editor/wysiwyg/wysiwyg_document_controller.dart'; +import 'package:busymark/src/editor/wysiwyg/wysiwyg_commands.dart'; +import 'package:busymark/src/editor/wysiwyg/wysiwyg_inline_controller.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { const serializer = BusyMarkMarkdownSerializer(); + test( + 'partially formatted URL source offsets and text atoms survive save and edit', + () { + const parser = MarkdownParser(); + const url = 'https://example.com/path'; + const prefix = 'https://example.com'; + const source = '[https://example.com**/path**]($url)'; + final controller = BusyMarkWysiwygDocumentController( + document: parser.parse(filePath: 'topic.md', source: '').busyDocument, + ); + addTearDown(controller.dispose); + final id = controller.document.blocks.single.id; + controller.updateBlockText(id, url); + controller.applyEnterAt(id, url.length); + controller.applyInlineCommand( + id, + BusyWysiwygInlineCommand.bold, + prefix.length, + url.length, + ); + void check(BusyBlock block) { + expect(serializer.serializeInlineFragment(block.inlines), source); + for (var offset = 0; offset <= url.length; offset++) { + final result = serializer.serializeInlineFragmentWithOffsets( + block.inlines, + textOffset: offset, + ); + expect(result.source, source); + final expectedOffset = offset == 0 + ? 0 + : offset == url.length + ? source.length + : offset < prefix.length + ? offset + 1 + : offset == prefix.length + ? offset + 6 + : offset + 8; + expect(result.sourceOffset, expectedOffset, reason: 'offset $offset'); + expect(result.textAtoms, hasLength(url.length)); + for (var index = 0; index < url.length; index++) { + final atom = result.textAtoms[index]; + final start = index < prefix.length - 1 + ? index + 1 + : index == prefix.length - 1 + ? index + 1 + : index + 8; + final end = index == prefix.length - 1 ? start + 6 : start + 1; + expect((atom.textStart, atom.textEnd), (index, index + 1)); + expect((atom.sourceStart, atom.sourceEnd), (start, end)); + expect(atom.text, url[index]); + expect(atom.escaped, index == prefix.length - 1); + expect( + source.substring(start, end), + atom.escaped ? 'm' : atom.text, + ); + } + } + } + + check(controller.document.blocks.first); + final saved = parser + .parse(filePath: 'topic.md', source: controller.markdown) + .busyDocument; + check(saved.blocks.single); + final reopened = BusyMarkWysiwygDocumentController(document: saved); + addTearDown(reopened.dispose); + reopened.updateBlockText(saved.blocks.single.id, '$url!'); + final reparsed = parser + .parse(filePath: 'topic.md', source: reopened.markdown) + .busyDocument + .blocks + .single; + expect(reparsed.plainText, '$url!'); + expect( + busyInlineStyleRanges(reparsed.inlines) + .where((range) => range.kind == BusyInlineKind.link) + .every((range) => range.destination == url), + isTrue, + ); + expect( + busyInlineStyleRanges( + reparsed.inlines, + ).where((range) => range.kind == BusyInlineKind.strong).isNotEmpty, + isTrue, + ); + }, + ); + + test( + 'URL punctuation beside surrounding formatting retains literal and style boundaries', + () { + const parser = MarkdownParser(); + const url = 'https://example.com'; + const link = BusyInline( + kind: BusyInlineKind.link, + text: url, + destination: url, + attributes: {busyMarkBareUrlAttribute: 'true'}, + children: [BusyInline(kind: BusyInlineKind.text, text: url)], + ); + for (final suffix in ['*', '_', '~', '**', '__', '~~', '*_~']) { + final literal = BusyInline(kind: BusyInlineKind.text, text: suffix); + for (final inside in [false, true]) { + final inlines = [ + BusyInline( + kind: BusyInlineKind.strong, + text: '', + children: [link, if (inside) literal], + ), + if (!inside) literal, + ]; + final source = serializer.serializeInlineFragment(inlines); + final parsed = parser.parseInlineFragment(source: source); + expect( + parsed.map((inline) => inline.plainText).join(), + '$url$suffix', + reason: source, + ); + final ranges = busyInlineStyleRanges(parsed); + final links = ranges.where( + (range) => range.kind == BusyInlineKind.link, + ); + expect(links.single.destination, url, reason: source); + final bold = ranges + .where((range) => range.kind == BusyInlineKind.strong) + .toList(); + expect(bold.first.start, 0); + expect( + bold.last.end, + inside ? url.length + suffix.length : url.length, + reason: source, + ); + for (var offset = 0; offset <= url.length + suffix.length; offset++) { + final result = serializer.serializeInlineFragmentWithOffsets( + inlines, + textOffset: offset, + ); + expect(result.source, source); + expect( + result.textAtoms.map((atom) => atom.text).join(), + '$url$suffix', + ); + if (offset == 0) expect(result.sourceOffset, 0); + if (offset == url.length + suffix.length) { + expect(result.sourceOffset, source.length); + } + for (final atom in result.textAtoms) { + expect( + source.substring(atom.sourceStart, atom.sourceEnd), + atom.escaped ? '\\${atom.text}' : atom.text, + ); + } + } + } + } + }, + ); + + test( + 'bare URL trailing literal punctuation survives serialization and reparse', + () { + const parser = MarkdownParser(); + const url = 'https://example.com'; + for (final mode in [ + MarkdownMode.commonMark, + MarkdownMode.writersideMarkdown, + ]) { + for (final suffix in ['*', '_', '~', '**', '__', '~~', '*_~']) { + final text = '$url$suffix'; + final controller = BusyMarkWysiwygDocumentController( + document: parser + .parse(filePath: 'topic.md', source: '', mode: mode) + .busyDocument, + ); + addTearDown(controller.dispose); + final id = controller.document.blocks.single.id; + controller.updateBlockText(id, text); + controller.applyEnterAt(id, text.length); + final block = controller.document.blocks.first; + expect(block.inlines.first.destination, url); + final saved = parser + .parse( + filePath: 'topic.md', + source: controller.markdown, + mode: mode, + ) + .busyDocument + .blocks + .single; + expect( + saved.plainText, + text, + reason: '$mode $suffix: ${controller.markdown}', + ); + expect(saved.inlines.first.destination, url); + final ordinary = serializer.serializeInlineFragment(block.inlines); + expect(ordinary, text); + for (var offset = 0; offset <= text.length; offset++) { + final result = serializer.serializeInlineFragmentWithOffsets( + block.inlines, + textOffset: offset, + ); + expect(result.source, ordinary); + expect(result.sourceOffset, offset); + expect(result.textAtoms, hasLength(text.length)); + for (var index = 0; index < text.length; index++) { + final atom = result.textAtoms[index]; + expect((atom.textStart, atom.textEnd), (index, index + 1)); + expect((atom.sourceStart, atom.sourceEnd), (index, index + 1)); + expect(atom.text, text[index]); + expect(atom.escaped, isFalse); + } + } + final reopened = BusyMarkWysiwygDocumentController( + document: parser + .parse( + filePath: 'topic.md', + source: controller.markdown, + mode: mode, + ) + .busyDocument, + ); + addTearDown(reopened.dispose); + reopened.updateBlockText( + reopened.document.blocks.single.id, + '$url/path$suffix', + ); + final edited = parser + .parse( + filePath: 'topic.md', + source: reopened.markdown, + mode: mode, + ) + .busyDocument + .blocks + .single; + expect(edited.plainText, '$url/path$suffix'); + expect(edited.inlines.first.destination, '$url/path'); + } + } + }, + ); + test('descendant whitespace preservation prevents ancestor trimming', () { const preserved = {busyMarkPreserveTextWhitespaceAttribute: 'true'}; const whitespace = BusyBlock( diff --git a/test/src/wysiwyg_clipboard_test.dart b/test/src/wysiwyg_clipboard_test.dart index 0ecf0d42..068bd743 100644 --- a/test/src/wysiwyg_clipboard_test.dart +++ b/test/src/wysiwyg_clipboard_test.dart @@ -653,6 +653,88 @@ void main() { ); } + testWidgets( + 'pasted URL retains live link coverage through split, Backspace and suffix formatting', + (tester) async { + const url = 'https://example.com/path'; + const prefix = 'https://example.com'; + systemData = {'text': url}; + var source = ''; + BusyDocument? live; + await mount( + tester, + 'url-rejoin-format', + source, + (value) => source = value, + onDocumentChanged: (value) => live = value, + ); + await key(tester, LogicalKeyboardKey.keyV); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + var first = tester.widget(find.byType(TextField).first); + first.focusNode!.requestFocus(); + first.controller!.selection = const TextSelection.collapsed( + offset: prefix.length, + ); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + expect(live!.blocks.first.inlines.single.destination, prefix); + expect(live!.blocks[1].plainText, '/path'); + await tester.sendKeyEvent(LogicalKeyboardKey.backspace); + await tester.pumpAndSettle(); + expect(live!.blocks.first.inlines.single.destination, url); + expect(source, '$url\n\n'); + first = tester.widget(find.byType(TextField).first); + expect( + first.controller!.selection, + const TextSelection.collapsed(offset: prefix.length), + ); + expect(first.focusNode!.hasFocus, isTrue); + first.controller!.selection = const TextSelection( + baseOffset: prefix.length, + extentOffset: url.length, + ); + await tester.pump(); + await key(tester, LogicalKeyboardKey.keyB); + void checkFormatted() { + final block = live!.blocks.first; + expect(block.inlines.single.kind, BusyInlineKind.link); + expect(block.inlines.single.plainText, url); + expect(block.inlines.single.destination, url); + final bold = busyInlineStyleRanges( + block.inlines, + ).where((range) => range.kind == BusyInlineKind.strong).single; + expect((bold.start, bold.end), (prefix.length, url.length)); + expect(source, '[https://example.com**/path**]($url)\n\n'); + } + + checkFormatted(); + final formattedField = tester.widget( + find.byType(TextField).first, + ); + final spans = formattedField.controller! + .buildTextSpan( + context: tester.element(find.byType(TextField).first), + withComposing: false, + ) + .children! + .cast(); + expect( + spans.every( + (span) => span.style!.decoration == TextDecoration.underline, + ), + isTrue, + ); + expect(spans.last.style!.fontWeight, FontWeight.w700); + await key(tester, LogicalKeyboardKey.keyZ); + expect(source, '$url\n\n'); + expect(live!.blocks.first.inlines.single.destination, url); + await key(tester, LogicalKeyboardKey.keyZ, shift: true); + checkFormatted(); + }, + ); + Future copyAll(WidgetTester tester, {String source = _source}) async { await mount(tester, 'origin', source, (_) {}); await key(tester, LogicalKeyboardKey.keyA); diff --git a/test/src/wysiwyg_structured_regression_test.dart b/test/src/wysiwyg_structured_regression_test.dart index 18db312a..6a6366dd 100644 --- a/test/src/wysiwyg_structured_regression_test.dart +++ b/test/src/wysiwyg_structured_regression_test.dart @@ -19,6 +19,90 @@ import '../support/memory_rich_clipboard.dart'; void main() { const parser = MarkdownParser(); + test( + 'formatting a URL suffix retains the complete live link and round trips', + () { + const url = 'https://example.com/path'; + const prefix = 'https://example.com'; + for (final mode in [ + MarkdownMode.commonMark, + MarkdownMode.writersideMarkdown, + ]) { + final controller = BusyMarkWysiwygDocumentController( + document: parser + .parse(filePath: 'topic.md', source: '', mode: mode) + .busyDocument, + ); + addTearDown(controller.dispose); + final id = controller.document.blocks.single.id; + controller.updateBlockText(id, url); + controller.applyEnterAt(id, url.length); + controller.applyInlineCommand( + id, + BusyWysiwygInlineCommand.bold, + prefix.length, + url.length, + ); + void check(BusyBlock block) { + expect(block.plainText, url); + final ranges = busyInlineStyleRanges(block.inlines); + final links = ranges + .where((range) => range.kind == BusyInlineKind.link) + .toList(); + expect(links, isNotEmpty); + expect(links.first.start, 0); + expect(links.last.end, url.length); + expect(links.every((range) => range.destination == url), isTrue); + final bold = ranges + .where((range) => range.kind == BusyInlineKind.strong) + .single; + expect((bold.start, bold.end), (prefix.length, url.length)); + } + + check(controller.document.blocks.first); + expect( + controller.document.blocks.first.inlines.single.attributes + .containsKey(busyMarkBareUrlAttribute), + isFalse, + ); + expect( + controller.markdown, + '[https://example.com**/path**]($url)\n\n', + ); + check( + parser + .parse( + filePath: 'topic.md', + source: controller.markdown, + mode: mode, + ) + .busyDocument + .blocks + .first, + ); + final saved = parser + .parse( + filePath: 'topic.md', + source: controller.markdown, + mode: mode, + ) + .busyDocument; + final reopened = BusyMarkWysiwygDocumentController(document: saved); + addTearDown(reopened.dispose); + controller.updateBlockText(id, '$url/new'); + reopened.updateBlockText(saved.blocks.single.id, '$url/new'); + expect(controller.markdown.trim(), reopened.markdown.trim()); + for (final edited in [ + controller.document.blocks.first, + reopened.document.blocks.first, + ]) { + expect(edited.plainText, '$url/new'); + expect(edited.inlines.single.destination, url); + } + } + }, + ); + test( 'Enter URL recognition preserves formatting, literal syntax, anchors, breaks and descendants', () { From 997e1044afe21736e7b510e8528c359eecccc83b Mon Sep 17 00:00:00 2001 From: albert Date: Sun, 4 Oct 2026 10:13:15 -0700 Subject: [PATCH 12/14] Preserve bare URL semantics across source round trips --- .../wysiwyg/wysiwyg_document_controller.dart | 71 ++++ .../busymark_markdown_serializer.dart | 115 ++++-- lib/src/markdown/markdown_ast_adapter.dart | 3 + lib/src/markdown/math_syntax.dart | 20 + .../markdown_serializer_metadata_test.dart | 360 ++++++++++++++++-- test/src/wysiwyg_clipboard_test.dart | 87 +++++ 6 files changed, 589 insertions(+), 67 deletions(-) diff --git a/lib/src/editor/wysiwyg/wysiwyg_document_controller.dart b/lib/src/editor/wysiwyg/wysiwyg_document_controller.dart index 47ba2e69..61ec123c 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_document_controller.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_document_controller.dart @@ -3893,6 +3893,58 @@ List _wrapBareUrlRange( ]; } +/// Commit explicit source forms required by surrounding formatting, after +/// reconstruction has merged wrappers. Serialization itself stays read-only. +List _commitBareUrlSourceForms( + List inlines, { + bool insideFormatting = false, +}) { + const serializer = BusyMarkMarkdownSerializer(); + return [ + for (var index = 0; index < inlines.length; index++) + _commitBareUrlSourceForm( + inlines[index], + explicit: serializer.bareUrlNeedsExplicitSource( + inlines[index], + index + 1 < inlines.length ? inlines[index + 1] : null, + insideFormatting: insideFormatting, + ), + insideFormatting: insideFormatting, + ), + ]; +} + +BusyInline _commitBareUrlSourceForm( + BusyInline inline, { + required bool explicit, + required bool insideFormatting, +}) { + final children = inline.children.isEmpty + ? inline.children + : _commitBareUrlSourceForms( + inline.children, + insideFormatting: + insideFormatting || + switch (inline.kind) { + BusyInlineKind.strong || + BusyInlineKind.emphasis || + BusyInlineKind.strikethrough || + BusyInlineKind.underline => true, + _ => false, + }, + ); + if (!explicit && listEquals(children, inline.children)) return inline; + return inline.copyWith( + children: children, + attributes: explicit + ? { + for (final entry in inline.attributes.entries) + if (entry.key != busyMarkBareUrlAttribute) entry.key: entry.value, + } + : inline.attributes, + ); +} + /// Existing bare links derive their destination from their current text. Edits /// and splits may shorten a URL or leave trailing punctuation outside it. /// Explicit links keep their independently authored destinations. @@ -4411,6 +4463,25 @@ String _incrementOrderedMarker(String? marker, int offset) { } BusyDocument _ensureEditableDocument(BusyDocument document) { + // Commit source-form changes before notifying listeners, within the edit's + // existing transaction. Include dirty imported fragments as well as newly + // reconstructed prose; untouched source-backed blocks retain their source. + BusyBlock visit(BusyBlock block) { + final children = block.children.map(visit).toList(); + final inlines = block.dirty && _supportsBareUrlRecognition(block) + ? _commitBareUrlSourceForms(block.inlines) + : block.inlines; + if (listEquals(children, block.children) && + listEquals(inlines, block.inlines)) { + return block; + } + return block.copyWith(children: children, inlines: inlines, dirty: true); + } + + final blocks = document.blocks.map(visit).toList(); + if (!listEquals(blocks, document.blocks)) { + document = document.copyWith(blocks: blocks); + } // Source whitespace remains in BusyDocument.source. The Markdown parser, // rather than the rich editor, owns whether that whitespace creates blocks. final hasEditableBlock = document.blocks.any( diff --git a/lib/src/markdown/busymark_markdown_serializer.dart b/lib/src/markdown/busymark_markdown_serializer.dart index 526072ed..cf5be694 100644 --- a/lib/src/markdown/busymark_markdown_serializer.dart +++ b/lib/src/markdown/busymark_markdown_serializer.dart @@ -59,6 +59,19 @@ class BusyMarkInlineDelimiter { class BusyMarkMarkdownSerializer { const BusyMarkMarkdownSerializer(); + /// Formatting delimiters beside literal URL punctuation require an explicit + /// label. Editing operations must commit that source form in the model so + /// subsequent label edits agree with saving and reopening. + bool bareUrlNeedsExplicitSource( + BusyInline inline, + BusyInline? following, { + required bool insideFormatting, + }) => + insideFormatting && + _canEmitBareUrl(inline) && + following?.kind == BusyInlineKind.text && + _bareUrlLiteralSuffixLength(inline, following!.text) > 0; + /// Serializes an inline fragment without document-level trimming or a final /// newline. This is also the single entry point for context-sensitive inline /// escaping used by clipboard insertion. @@ -710,10 +723,14 @@ class BusyMarkMarkdownSerializer { var nextAtBlockStart = atBlockStart; final sortedTargets = targets.toList()..sort(); var targetCursor = 0; + BusyInline? literalUrl; + var literalSuffix = ''; for (var index = 0; index < inlines.length; index++) { final inline = inlines[index]; metrics.visitedNodes += 1; if (inline.kind == BusyInlineKind.hardBreak) { + literalUrl = null; + literalSuffix = ''; var runEnd = index + 1; while (runEnd < inlines.length && inlines[runEnd].kind == BusyInlineKind.hardBreak) { @@ -791,6 +808,24 @@ class BusyMarkMarkdownSerializer { sortedTargets[targetCursor] <= consumedText + length) { localOffsets.add(sortedTargets[targetCursor++] - consumedText); } + // Literal escapes may parse into several adjacent text nodes. Carry + // the URL boundary across them so reopening does not change escaping. + final suffix = inline.kind == BusyInlineKind.text + ? '$literalSuffix${inline.text}' + : ''; + final suffixPrefixLength = + !insideFormatting && + inline.kind == BusyInlineKind.text && + literalUrl != null + ? _bareUrlLiteralSuffixLength(literalUrl, suffix) + : 0; + final literalPrefixLength = (suffixPrefixLength - literalSuffix.length) + .clamp(0, inline.text.length) + .toInt(); + final angleEntityOffset = + suffixPrefixLength > 0 && suffix[suffixPrefixLength - 1] == '<' + ? suffixPrefixLength - literalSuffix.length + : -1; final result = _inlineAtTextOffsets( inline, textOffsets: localOffsets, @@ -806,33 +841,49 @@ class BusyMarkMarkdownSerializer { inlinePath: [...pathPrefix, index], textBase: textBase + consumedText, bareUrlText: bareUrlText, - literalPrefixLength: - !insideFormatting && inline.kind == BusyInlineKind.text && index > 0 - ? _bareUrlLiteralSuffixLength(inlines[index - 1], inline.text) - : 0, - forceExplicitBareUrl: - insideFormatting && - index + 1 < inlines.length && - inlines[index + 1].kind == BusyInlineKind.text && - _bareUrlLiteralSuffixLength(inline, inlines[index + 1].text) > 0, + literalPrefixLength: literalPrefixLength, + forceExplicitBareUrl: bareUrlNeedsExplicitSource( + inline, + index + 1 < inlines.length ? inlines[index + 1] : null, + insideFormatting: insideFormatting, + ), formattedUrlLabel: formattedUrlLabel, insideFormatting: insideFormatting, - entityOffsets: - // A character reference at a formatting boundary makes it a - // punctuation boundary to Markdown while retaining the URL text. - // This keeps, for example, com**/path** from being literal text. - formattedUrlLabel && - inline.kind == BusyInlineKind.text && - inline.text.isNotEmpty - ? { - if (index > 0 && _urlFormattingKind(inlines[index - 1].kind)) 0, - if (index + 1 < inlines.length && - _urlFormattingKind(inlines[index + 1].kind)) - inline.text.length - - (inline.text.runes.last > 0xffff ? 2 : 1), - } - : const {}, + entityOffsets: { + // Keep the boundary '<' literal without forming HTML or an angle + // autolink. The following reference also keeps this literal slice + // distinct from decoded raw HTML in the AST adapter. + if (angleEntityOffset >= 0 && angleEntityOffset < inline.text.length) + angleEntityOffset, + // A reference beside formatting makes it a punctuation boundary + // while retaining the displayed URL character. + if (formattedUrlLabel && + inline.kind == BusyInlineKind.text && + inline.text.isNotEmpty) ...{ + // A URL inside label formatting would start after its opening + // delimiter. Encode its first character so the autolink grammar + // cannot consume the label's closing delimiters and link target. + if (insideFormatting) + for (final range in busyMarkBareUrlRanges(inline.text)) + range.start, + if (index > 0 && _urlFormattingKind(inlines[index - 1].kind)) 0, + if (index + 1 < inlines.length && + _urlFormattingKind(inlines[index + 1].kind)) + inline.text.length - (inline.text.runes.last > 0xffff ? 2 : 1), + }, + }, ); + if (inline.kind == BusyInlineKind.text) { + if (suffixPrefixLength > 0 && !suffix.contains(RegExp(r'\s'))) { + literalSuffix = suffix; + } else { + literalUrl = null; + literalSuffix = ''; + } + } else { + literalUrl = inline; + literalSuffix = ''; + } for (final entry in result.sourceOffsets.entries) { sourceOffsets.putIfAbsent( consumedText + entry.key, @@ -1361,17 +1412,23 @@ class BusyMarkMarkdownSerializer { final whitespace = text.indexOf(RegExp(r'\s')); final length = whitespace < 0 ? text.length : whitespace; if (length == 0) return 0; - // Only delimiter punctuation gets this escape exception. Parser-excluded - // HTML or entity text must retain its ordinary literal escaping. + // Preserve a parser-excluded '<' boundary as well as delimiter + // punctuation. Everything after '<' keeps normal literal escaping, with + // its first character encoded to prevent this boundary from opening HTML. + final lessThan = text.indexOf('<'); + final prefixLength = lessThan >= 0 && lessThan < length + ? lessThan + 1 + : length; if (text - .substring(0, length) + .substring(0, prefixLength) .codeUnits .any( (unit) => _inlineSyntaxCharacters.contains(unit) && unit != 0x2a && unit != 0x5f && - unit != 0x7e, + unit != 0x7e && + unit != 0x3c, )) { return 0; } @@ -1385,7 +1442,7 @@ class BusyMarkMarkdownSerializer { ranges.single.start == 0 && ranges.single.end == previous.plainText.length && ranges.single.destination == previous.destination - ? length + ? prefixLength : 0; } diff --git a/lib/src/markdown/markdown_ast_adapter.dart b/lib/src/markdown/markdown_ast_adapter.dart index 0c01776a..339d780e 100644 --- a/lib/src/markdown/markdown_ast_adapter.dart +++ b/lib/src/markdown/markdown_ast_adapter.dart @@ -821,6 +821,9 @@ class MarkdownAstAdapter { int? mappingEnd, Iterable ignoredPositionMarkers = const [], }) { + if (node is md.Element && node.tag == busyMarkLiteralLessThanTag) { + return [BusyInline(kind: BusyInlineKind.text, text: node.textContent)]; + } if (node is md.Element && node.attributes[writersideLiteralPercentAttribute] == 'true') { return const [ diff --git a/lib/src/markdown/math_syntax.dart b/lib/src/markdown/math_syntax.dart index 39876e88..2c71e493 100644 --- a/lib/src/markdown/math_syntax.dart +++ b/lib/src/markdown/math_syntax.dart @@ -5,6 +5,8 @@ import 'markdown_model.dart'; import 'markdown_source_annotation.dart'; import 'writerside_variable_syntax.dart'; +const busyMarkLiteralLessThanTag = 'busymark-literal-less-than'; + const busyMarkMathInlineTag = 'busymark-math-inline'; const busyMarkMathBlockTag = 'busymark-math-block'; const busyMarkMathExpressionAttribute = 'mathExpression'; @@ -37,6 +39,7 @@ md.Document busyMarkMarkdownDocument( blockSyntaxes: const [BusyDisplayMathSyntax()], inlineSyntaxes: [ ...leadingInlineSyntaxes, + _LiteralLessThanSyntax(), if (mode == MarkdownMode.writersideMarkdown) WritersideLiteralPercentSyntax(), BusyDollarMathSyntax(), @@ -79,6 +82,23 @@ List<({int start, int end, String destination})> busyMarkBareUrlRanges( return ranges; } +// An escaped '<', or '<' followed by a character reference, is literal +// Markdown text. Keep it in a distinct node so the HTML adapter cannot turn +// the decoded text back into markup. The latter form preserves a bare URL's +// boundary without inserting a backslash that its grammar would consume. +class _LiteralLessThanSyntax extends md.InlineSyntax { + _LiteralLessThanSyntax() : super(r'\\<|<&#[0-9]{1,7};'); + + @override + bool onMatch(md.InlineParser parser, Match match) { + final text = match[0] == r'\<' + ? '<' + : '<${parser.document.parseInline(match[0]!.substring(1)).map((node) => node.textContent).join()}'; + parser.addNode(md.Element(busyMarkLiteralLessThanTag, [md.Text(text)])); + return true; + } +} + // Delegate matching, punctuation trimming, and destination encoding to the // locked Markdown implementation; attach source form only to bare URLs. class _BareUrlSyntax extends md.AutolinkExtensionSyntax { diff --git a/test/src/markdown_serializer_metadata_test.dart b/test/src/markdown_serializer_metadata_test.dart index ecabc771..30c8a386 100644 --- a/test/src/markdown_serializer_metadata_test.dart +++ b/test/src/markdown_serializer_metadata_test.dart @@ -163,6 +163,189 @@ void main() { ); } } + for (final mode in [ + MarkdownMode.commonMark, + MarkdownMode.writersideMarkdown, + ]) { + final original = BusyMarkWysiwygDocumentController( + document: BusyDocument( + filePath: 'topic.md', + mode: mode, + blocks: [ + BusyBlock( + id: 'paragraph', + kind: BusyBlockKind.paragraph, + inlines: inlines, + dirty: true, + ), + ], + ), + ); + final reopened = BusyMarkWysiwygDocumentController( + document: parser + .parse( + filePath: 'topic.md', + source: original.markdown, + mode: mode, + ) + .busyDocument, + ); + addTearDown(original.dispose); + addTearDown(reopened.dispose); + const changed = 'https://other.com'; + for (final controller in [original, reopened]) { + controller.updateBlockText( + controller.document.blocks.single.id, + '$changed$suffix', + ); + final links = busyInlineStyleRanges( + controller.document.blocks.single.inlines, + ).where((range) => range.kind == BusyInlineKind.link).toList(); + expect((links.first.start, links.last.end), (0, changed.length)); + expect( + links.every( + (range) => range.destination == (inside ? url : changed), + ), + isTrue, + reason: '$mode $suffix inside=$inside: $source', + ); + } + expect(original.markdown, reopened.markdown); + } + } + } + }, + ); + + test( + 'surrounding formatting commits the same URL editing semantics as reopening', + () { + const parser = MarkdownParser(); + const url = 'https://example.com'; + const changed = 'https://other.com'; + for (final mode in [ + MarkdownMode.commonMark, + MarkdownMode.writersideMarkdown, + ]) { + for (final suffix in ['*', '_', '~', '*_~', '<', '*<']) { + for (final formatBeforeEnter in [false, true]) { + final original = BusyMarkWysiwygDocumentController( + document: parser + .parse(filePath: 'topic.md', source: '', mode: mode) + .busyDocument, + ); + addTearDown(original.dispose); + final id = original.document.blocks.single.id; + original.updateBlockText(id, '$url$suffix'); + void format() => original.applyInlineCommand( + id, + BusyWysiwygInlineCommand.bold, + 0, + url.length + suffix.length, + ); + if (formatBeforeEnter) format(); + original.applyEnterAt(id, url.length + suffix.length); + if (!formatBeforeEnter) format(); + final source = original.markdown; + final reopened = BusyMarkWysiwygDocumentController( + document: parser + .parse(filePath: 'topic.md', source: source, mode: mode) + .busyDocument, + ); + addTearDown(reopened.dispose); + original.updateBlockText(id, '$changed$suffix'); + reopened.updateBlockText( + reopened.document.blocks.single.id, + '$changed$suffix', + ); + BusyInlineStyleRange link( + BusyMarkWysiwygDocumentController controller, + ) => busyInlineStyleRanges( + controller.document.blocks.first.inlines, + ).where((range) => range.kind == BusyInlineKind.link).single; + expect( + link(original).destination, + link(reopened).destination, + reason: + '$mode $suffix formatBeforeEnter=$formatBeforeEnter: $source', + ); + expect(original.markdown.trim(), reopened.markdown.trim()); + expect(original.document.blocks.first.plainText, '$changed$suffix'); + expect(link(original).destination, url); + expect( + link(original).attributes.containsKey(busyMarkBareUrlAttribute), + isFalse, + ); + expect( + link(reopened).attributes.containsKey(busyMarkBareUrlAttribute), + isFalse, + ); + for (final controller in [original, reopened]) { + final block = controller.document.blocks.first; + final bold = busyInlineStyleRanges( + block.inlines, + ).where((range) => range.kind == BusyInlineKind.strong).toList(); + expect( + (bold.first.start, bold.last.end), + (0, changed.length + suffix.length), + ); + for (var offset = 0; offset < block.plainText.length; offset++) { + expect( + bold.any( + (range) => range.start <= offset && range.end > offset, + ), + isTrue, + ); + } + final reparsed = parser + .parse( + filePath: 'topic.md', + source: controller.markdown, + mode: mode, + ) + .busyDocument + .blocks + .single; + expect(reparsed.plainText, '$changed$suffix'); + final links = busyInlineStyleRanges( + reparsed.inlines, + ).where((range) => range.kind == BusyInlineKind.link).toList(); + expect((links.first.start, links.last.end), (0, changed.length)); + expect(links.every((range) => range.destination == url), isTrue); + final ordinary = serializer.serializeInlineFragment( + block.inlines, + ); + for (var offset = 0; offset <= block.plainText.length; offset++) { + final result = serializer.serializeInlineFragmentWithOffsets( + block.inlines, + textOffset: offset, + ); + expect(result.source, ordinary); + if (offset == 0) expect(result.sourceOffset, 0); + if (offset == block.plainText.length) { + expect(result.sourceOffset, ordinary.length); + } + expect( + result.textAtoms.map((atom) => atom.text).join(), + block.plainText, + ); + for (final atom in result.textAtoms) { + final unit = ordinary.substring( + atom.sourceStart, + atom.sourceEnd, + ); + expect( + unit, + atom.escaped + ? atom.text == 'h' + ? 'h' + : '\\${atom.text}' + : atom.text, + ); + } + } + } + } } } }, @@ -173,12 +356,89 @@ void main() { () { const parser = MarkdownParser(); const url = 'https://example.com'; + // Source units specify the authored span of each visible suffix + // character, including references and escaped literal HTML delimiters. + const suffixes = >{ + '*': ['*'], + '_': ['_'], + '~': ['~'], + '**': ['*', '*'], + '__': ['_', '_'], + '~~': ['~', '~'], + '*_~': ['*', '_', '~'], + '<': ['<'], + '*<': ['*', '<'], + '': ['<', 't', 'a', 'g', '>'], + '*literal': [ + '*', + '<', + 'b', + '>', + 'l', + 'i', + 't', + 'e', + 'r', + 'a', + 'l', + r'\<', + '/', + 'b', + '>', + ], + 'ab': [ + '<', + 'b', + '>', + 'a', + r'\<', + '/', + 'b', + '>', + r'\<', + 'i', + '>', + 'b', + r'\<', + '/', + 'i', + '>', + ], + '': [ + '<', + 'h', + 't', + 't', + 'p', + 's', + ':', + '/', + '/', + 'o', + 't', + 'h', + 'e', + 'r', + '.', + 'c', + 'o', + 'm', + '>', + ], + }; for (final mode in [ MarkdownMode.commonMark, MarkdownMode.writersideMarkdown, ]) { - for (final suffix in ['*', '_', '~', '**', '__', '~~', '*_~']) { + for (final entry in suffixes.entries) { + final suffix = entry.key; final text = '$url$suffix'; + final units = [...url.split(''), ...entry.value]; + final source = units.join(); + final boundaries = [0]; + for (final unit in units) { + boundaries.add(boundaries.last + unit.length); + } final controller = BusyMarkWysiwygDocumentController( document: parser .parse(filePath: 'topic.md', source: '', mode: mode) @@ -189,49 +449,67 @@ void main() { controller.updateBlockText(id, text); controller.applyEnterAt(id, text.length); final block = controller.document.blocks.first; - expect(block.inlines.first.destination, url); + void check(BusyBlock block) { + expect(block.plainText, text, reason: '$mode $suffix'); + final ranges = busyInlineStyleRanges(block.inlines); + final link = ranges + .where((range) => range.kind == BusyInlineKind.link) + .single; + expect((link.start, link.end), (0, url.length)); + expect(link.destination, url); + expect(link.attributes[busyMarkBareUrlAttribute], 'true'); + expect( + ranges.where((range) => range.kind != BusyInlineKind.link), + isEmpty, + ); + expect(serializer.serializeInlineFragment(block.inlines), source); + for (var offset = 0; offset <= text.length; offset++) { + final result = serializer.serializeInlineFragmentWithOffsets( + block.inlines, + textOffset: offset, + ); + expect(result.source, source); + expect( + result.sourceOffset, + boundaries[offset], + reason: '$mode $suffix offset $offset', + ); + expect(result.textAtoms, hasLength(text.length)); + for (var index = 0; index < text.length; index++) { + final atom = result.textAtoms[index]; + expect((atom.textStart, atom.textEnd), (index, index + 1)); + expect( + (atom.sourceStart, atom.sourceEnd), + (boundaries[index], boundaries[index + 1]), + ); + expect(atom.text, text[index]); + expect(atom.escaped, units[index].length != 1); + expect( + source.substring(atom.sourceStart, atom.sourceEnd), + units[index], + ); + } + } + } + + check(block); + expect(controller.markdown, '$source\n\n'); final saved = parser .parse( filePath: 'topic.md', source: controller.markdown, mode: mode, ) - .busyDocument - .blocks - .single; - expect( - saved.plainText, - text, - reason: '$mode $suffix: ${controller.markdown}', - ); - expect(saved.inlines.first.destination, url); - final ordinary = serializer.serializeInlineFragment(block.inlines); - expect(ordinary, text); - for (var offset = 0; offset <= text.length; offset++) { - final result = serializer.serializeInlineFragmentWithOffsets( - block.inlines, - textOffset: offset, - ); - expect(result.source, ordinary); - expect(result.sourceOffset, offset); - expect(result.textAtoms, hasLength(text.length)); - for (var index = 0; index < text.length; index++) { - final atom = result.textAtoms[index]; - expect((atom.textStart, atom.textEnd), (index, index + 1)); - expect((atom.sourceStart, atom.sourceEnd), (index, index + 1)); - expect(atom.text, text[index]); - expect(atom.escaped, isFalse); - } - } - final reopened = BusyMarkWysiwygDocumentController( - document: parser - .parse( - filePath: 'topic.md', - source: controller.markdown, - mode: mode, - ) - .busyDocument, + .busyDocument; + check(saved.blocks.single); + // Inline parsing and another save cycle must also retain the literal + // HTML-looking text rather than introduce formatting or another link. + check( + block.copyWith( + inlines: parser.parseInlineFragment(source: source, mode: mode), + ), ); + final reopened = BusyMarkWysiwygDocumentController(document: saved); addTearDown(reopened.dispose); reopened.updateBlockText( reopened.document.blocks.single.id, @@ -247,7 +525,13 @@ void main() { .blocks .single; expect(edited.plainText, '$url/path$suffix'); - expect(edited.inlines.first.destination, '$url/path'); + expect( + busyInlineStyleRanges(edited.inlines) + .where((range) => range.kind == BusyInlineKind.link) + .single + .destination, + '$url/path', + ); } } }, diff --git a/test/src/wysiwyg_clipboard_test.dart b/test/src/wysiwyg_clipboard_test.dart index 068bd743..d86c00bf 100644 --- a/test/src/wysiwyg_clipboard_test.dart +++ b/test/src/wysiwyg_clipboard_test.dart @@ -653,6 +653,93 @@ void main() { ); } + for (final suffix in ['<', '*<', '*']) { + testWidgets( + 'text-only URL suffix $suffix survives Enter, source reload and label edit', + (tester) async { + const url = 'https://example.com'; + const changed = 'https://other.com'; + final text = '$url$suffix'; + systemData = {'text': text}; + var source = ''; + BusyDocument? live; + await mount( + tester, + 'url-boundary-$suffix', + source, + (value) => source = value, + onDocumentChanged: (value) => live = value, + ); + await key(tester, LogicalKeyboardKey.keyV); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + expect(live!.blocks.first.plainText, text); + final next = tester.widget(find.byType(TextField).last); + expect(next.focusNode!.hasFocus, isTrue); + expect( + next.controller!.selection, + const TextSelection.collapsed(offset: 0), + ); + expect(source, '$text\n\n'); + BusyInlineStyleRange link() => busyInlineStyleRanges( + live!.blocks.first.inlines, + ).where((range) => range.kind == BusyInlineKind.link).single; + expect( + (link().start, link().end, link().destination), + (0, url.length, url), + ); + var first = tester.widget(find.byType(TextField).first); + final spans = first.controller! + .buildTextSpan( + context: tester.element(find.byType(TextField).first), + withComposing: false, + ) + .children! + .cast(); + expect(spans.first.style!.decoration, TextDecoration.underline); + expect(spans.last.style!.decoration, isNot(TextDecoration.underline)); + if (suffix == '*') { + first.focusNode!.requestFocus(); + first.controller!.selection = TextSelection( + baseOffset: 0, + extentOffset: text.length, + ); + await tester.pump(); + await key(tester, LogicalKeyboardKey.keyB); + expect(source, '**[$url]($url)\\***\n\n'); + expect( + link().attributes.containsKey(busyMarkBareUrlAttribute), + isFalse, + ); + } + final saved = source; + await tester.enterText( + find.byType(TextField).first, + '$changed$suffix', + ); + await tester.pumpAndSettle(); + final liveDestination = link().destination; + final liveSource = source; + expect(liveDestination, suffix == '*' ? url : changed); + await mount( + tester, + 'url-boundary-reopened-$suffix', + saved, + (value) => source = value, + onDocumentChanged: (value) => live = value, + ); + await tester.enterText( + find.byType(TextField).first, + '$changed$suffix', + ); + await tester.pumpAndSettle(); + expect(link().destination, liveDestination); + expect(source.trim(), liveSource.trim()); + expect(live!.blocks.first.plainText, '$changed$suffix'); + }, + ); + } + testWidgets( 'pasted URL retains live link coverage through split, Backspace and suffix formatting', (tester) async { From c5dfd35657ec45e83b485d852dafdc135a76fc84 Mon Sep 17 00:00:00 2001 From: Albert G Date: Sun, 4 Oct 2026 18:34:44 -0700 Subject: [PATCH 13/14] Migrate Linux application chrome to Flutter-owned header layout and GTK integration bridges (#40) * Replace native GTK headerbar implementation with a Flutter-owned Linux window host, header layout (`BusyMarkLinuxHeaderLayout`), and canonical page frame supporting custom control clusters and native titlebar gestures. * Introduce native C++ bridges for GTK window preferences, active icon theme loading, dark theme preference, and animation setting notifications. * Standardize Workspace, Welcome, and Settings screens on `LinuxPageFrame` with retained crossfade view transitions. * Add localizations for window minimize, maximize, and restore controls across all supported languages. * Enhance Markdown serializer and parser to correctly handle bare URLs with trailing character-reference suffixes and formatting boundaries. --- lib/l10n/app_ar.arb | 5 +- lib/l10n/app_de.arb | 5 +- lib/l10n/app_en.arb | 8 +- lib/l10n/app_es.arb | 5 +- lib/l10n/app_et.arb | 5 +- lib/l10n/app_fa.arb | 5 +- lib/l10n/app_fr.arb | 5 +- lib/l10n/app_hi.arb | 5 +- lib/l10n/app_id.arb | 5 +- lib/l10n/app_it.arb | 5 +- lib/l10n/app_ja.arb | 5 +- lib/l10n/app_ko.arb | 5 +- lib/l10n/app_nb.arb | 5 +- lib/l10n/app_nl.arb | 5 +- lib/l10n/app_pl.arb | 5 +- lib/l10n/app_pt.arb | 5 +- lib/l10n/app_pt_BR.arb | 5 +- lib/l10n/app_ru.arb | 5 +- lib/l10n/app_tr.arb | 5 +- lib/l10n/app_uk.arb | 5 +- lib/l10n/app_vi.arb | 5 +- lib/l10n/app_zh.arb | 5 +- lib/l10n/app_zh_CN.arb | 5 +- lib/l10n/generated/app_localizations.dart | 18 + lib/l10n/generated/app_localizations_ar.dart | 9 + lib/l10n/generated/app_localizations_de.dart | 9 + lib/l10n/generated/app_localizations_en.dart | 9 + lib/l10n/generated/app_localizations_es.dart | 9 + lib/l10n/generated/app_localizations_et.dart | 9 + lib/l10n/generated/app_localizations_fa.dart | 9 + lib/l10n/generated/app_localizations_fr.dart | 9 + lib/l10n/generated/app_localizations_hi.dart | 9 + lib/l10n/generated/app_localizations_id.dart | 9 + lib/l10n/generated/app_localizations_it.dart | 9 + lib/l10n/generated/app_localizations_ja.dart | 9 + lib/l10n/generated/app_localizations_ko.dart | 9 + lib/l10n/generated/app_localizations_nb.dart | 9 + lib/l10n/generated/app_localizations_nl.dart | 9 + lib/l10n/generated/app_localizations_pl.dart | 9 + lib/l10n/generated/app_localizations_pt.dart | 18 + lib/l10n/generated/app_localizations_ru.dart | 9 + lib/l10n/generated/app_localizations_tr.dart | 9 + lib/l10n/generated/app_localizations_uk.dart | 9 + lib/l10n/generated/app_localizations_vi.dart | 9 + lib/l10n/generated/app_localizations_zh.dart | 18 + lib/main.dart | 23 +- lib/src/app/app_settings.dart | 14 + lib/src/app/busymark_app.dart | 35 +- lib/src/app/busymark_design.dart | 32 +- lib/src/app/busymark_motion_widgets.dart | 293 +++ lib/src/app/busymark_search_field.dart | 4 +- lib/src/app/busymark_toast.dart | 8 +- lib/src/app/linux/linux_header_style.dart | 252 ++ lib/src/app/linux/linux_page_frame.dart | 254 ++ lib/src/app/linux/linux_window_host.dart | 506 ++++ lib/src/app/window_control_service.dart | 18 +- .../busymark_markdown_serializer.dart | 41 +- .../gtk_animation_settings_service.dart | 60 + lib/src/platform/gtk_header_icon_service.dart | 352 +++ .../gtk_window_preferences_service.dart | 198 ++ .../presentation/settings_screen.dart | 86 +- .../presentation/welcome_screen.dart | 194 +- .../presentation/workspace_screen.dart | 1680 ++++++------- linux/runner/CMakeLists.txt | 4 + linux/runner/gtk_header_icons.cc | 222 ++ linux/runner/gtk_header_icons.h | 73 + linux/runner/gtk_window_preferences.cc | 111 + linux/runner/gtk_window_preferences.h | 48 + linux/runner/linux_chrome_host.cc | 300 +++ linux/runner/linux_chrome_host.h | 12 + linux/runner/my_application.cc | 2091 +---------------- test/src/app_settings_test.dart | 22 + test/src/app_smoke_test.dart | 323 ++- test/src/busymark_motion_widgets_test.dart | 289 +++ test/src/busymark_search_field_test.dart | 23 + .../gtk_animation_settings_service_test.dart | 103 + test/src/gtk_header_icon_service_test.dart | 230 ++ .../gtk_window_preferences_service_test.dart | 116 + test/src/html_export_ui_test.dart | 16 +- test/src/linux_page_frame_test.dart | 561 +++++ test/src/linux_window_host_test.dart | 790 +++++++ .../markdown_serializer_metadata_test.dart | 195 ++ test/src/modal_barrier_test.dart | 49 - test/src/native_headerbar_audit_test.dart | 1866 +++------------ test/src/window_control_service_test.dart | 29 + test/src/wysiwyg_clipboard_test.dart | 181 ++ 86 files changed, 7264 insertions(+), 4783 deletions(-) create mode 100644 lib/src/app/busymark_motion_widgets.dart create mode 100644 lib/src/app/linux/linux_header_style.dart create mode 100644 lib/src/app/linux/linux_page_frame.dart create mode 100644 lib/src/app/linux/linux_window_host.dart create mode 100644 lib/src/platform/gtk_animation_settings_service.dart create mode 100644 lib/src/platform/gtk_header_icon_service.dart create mode 100644 lib/src/platform/gtk_window_preferences_service.dart create mode 100644 linux/runner/gtk_header_icons.cc create mode 100644 linux/runner/gtk_header_icons.h create mode 100644 linux/runner/gtk_window_preferences.cc create mode 100644 linux/runner/gtk_window_preferences.h create mode 100644 linux/runner/linux_chrome_host.cc create mode 100644 linux/runner/linux_chrome_host.h create mode 100644 test/src/busymark_motion_widgets_test.dart create mode 100644 test/src/gtk_animation_settings_service_test.dart create mode 100644 test/src/gtk_header_icon_service_test.dart create mode 100644 test/src/gtk_window_preferences_service_test.dart create mode 100644 test/src/linux_page_frame_test.dart create mode 100644 test/src/linux_window_host_test.dart diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index 5d23d3c4..e228187e 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -3376,5 +3376,8 @@ "renameTopicFileInstead": "تُشتق معرّفات الموضوعات من أسماء ملفاتها. استخدم «إعادة تسمية ملف الموضوع» في جدول المحتويات أو الشريط الجانبي للملفات.", "diagnosticWritersideWebFileNameInvalid": "ينشر المثيل \"{instanceId}\" اسم ملف ويب غير صالح \"{webFileName}\" للموضوع \"{topic}\".", "diagnosticWritersideWebFileNameCollision": "ينشر المثيل \"{instanceId}\" الموضوعين \"{firstTopic}\" و\"{secondTopic}\" باسم \"{webFileName}\".", - "spellingDictionaryRecoveryConflict": "تم تغيير قاموس التدقيق الإملائي خارجيًا. تم الاحتفاظ بنسخة استرداد." + "spellingDictionaryRecoveryConflict": "تم تغيير قاموس التدقيق الإملائي خارجيًا. تم الاحتفاظ بنسخة استرداد.", + "windowMinimize": "تصغير", + "windowMaximize": "تكبير", + "windowRestore": "استعادة" } diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 13d752f8..68fa4274 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -3376,5 +3376,8 @@ "renameTopicFileInstead": "Themen-IDs werden aus ihren Dateinamen abgeleitet. Verwenden Sie „Themendatei umbenennen“ im Inhaltsverzeichnis oder in der Seitenleiste „Dateien“.", "diagnosticWritersideWebFileNameInvalid": "Instanz \"{instanceId}\" veröffentlicht den ungültigen Webdateinamen \"{webFileName}\" für das Thema \"{topic}\".", "diagnosticWritersideWebFileNameCollision": "Instanz \"{instanceId}\" veröffentlicht sowohl \"{firstTopic}\" als auch \"{secondTopic}\" unter \"{webFileName}\".", - "spellingDictionaryRecoveryConflict": "Das Rechtschreibwörterbuch wurde extern geändert. Eine Wiederherstellungskopie wurde aufbewahrt." + "spellingDictionaryRecoveryConflict": "Das Rechtschreibwörterbuch wurde extern geändert. Eine Wiederherstellungskopie wurde aufbewahrt.", + "windowMinimize": "Minimieren", + "windowMaximize": "Maximieren", + "windowRestore": "Wiederherstellen" } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index ce6e3e4a..3b6bf08c 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -2806,5 +2806,11 @@ } } }, - "spellingDictionaryRecoveryConflict": "The spelling dictionary changed externally. A recovery copy was preserved." + "spellingDictionaryRecoveryConflict": "The spelling dictionary changed externally. A recovery copy was preserved.", + "windowMinimize": "Minimize", + "@windowMinimize": {"description": "Tooltip and accessibility label for minimizing the application window."}, + "windowMaximize": "Maximize", + "@windowMaximize": {"description": "Tooltip and accessibility label for maximizing the application window."}, + "windowRestore": "Restore", + "@windowRestore": {"description": "Tooltip and accessibility label for restoring a maximized or fullscreen window to its normal size."} } diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 00d86d98..c45668c2 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -3376,5 +3376,8 @@ "renameTopicFileInstead": "Los ID de los temas proceden de sus nombres de archivo. Use «Cambiar nombre del archivo de tema» en la tabla de contenido o en la barra lateral Archivos.", "diagnosticWritersideWebFileNameInvalid": "La instancia \"{instanceId}\" publica el nombre de archivo web no válido \"{webFileName}\" para el tema \"{topic}\".", "diagnosticWritersideWebFileNameCollision": "La instancia \"{instanceId}\" publica \"{firstTopic}\" y \"{secondTopic}\" como \"{webFileName}\".", - "spellingDictionaryRecoveryConflict": "El diccionario ortográfico se modificó externamente. Se conservó una copia de recuperación." + "spellingDictionaryRecoveryConflict": "El diccionario ortográfico se modificó externamente. Se conservó una copia de recuperación.", + "windowMinimize": "Minimizar", + "windowMaximize": "Maximizar", + "windowRestore": "Restaurar" } diff --git a/lib/l10n/app_et.arb b/lib/l10n/app_et.arb index 3f36423c..a5d62879 100644 --- a/lib/l10n/app_et.arb +++ b/lib/l10n/app_et.arb @@ -3836,5 +3836,8 @@ "renameTopicFileInstead": "Teema ID-d tuletatakse failinimedest. Kasuta sisukorras või failide külgribal käsku „Nimeta teemafail ümber“.", "diagnosticWritersideWebFileNameInvalid": "Eksemplar \"{instanceId}\" avaldab teema \"{topic}\" jaoks sobimatu veebifailinime \"{webFileName}\".", "diagnosticWritersideWebFileNameCollision": "Eksemplar \"{instanceId}\" avaldab nii \"{firstTopic}\" kui ka \"{secondTopic}\" nimega \"{webFileName}\".", - "spellingDictionaryRecoveryConflict": "Õigekirjasõnastikku muudeti väliselt. Taastekoopia säilitati." + "spellingDictionaryRecoveryConflict": "Õigekirjasõnastikku muudeti väliselt. Taastekoopia säilitati.", + "windowMinimize": "Minimeeri", + "windowMaximize": "Maksimeeri", + "windowRestore": "Taasta" } diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb index 5e7679cf..942b4b97 100644 --- a/lib/l10n/app_fa.arb +++ b/lib/l10n/app_fa.arb @@ -3418,5 +3418,8 @@ "renameTopicFileInstead": "شناسه‌های موضوع از نام فایل‌هایشان گرفته می‌شوند. از «تغییر نام فایل موضوع» در فهرست مطالب یا نوار کناری فایل‌ها استفاده کنید.", "diagnosticWritersideWebFileNameInvalid": "نمونهٔ \"{instanceId}\" نام فایل وب نامعتبر \"{webFileName}\" را برای موضوع \"{topic}\" منتشر می‌کند.", "diagnosticWritersideWebFileNameCollision": "نمونهٔ \"{instanceId}\" هر دو موضوع \"{firstTopic}\" و \"{secondTopic}\" را با نام \"{webFileName}\" منتشر می‌کند.", - "spellingDictionaryRecoveryConflict": "فرهنگ لغت املایی به‌صورت خارجی تغییر کرد. یک نسخهٔ بازیابی حفظ شد." + "spellingDictionaryRecoveryConflict": "فرهنگ لغت املایی به‌صورت خارجی تغییر کرد. یک نسخهٔ بازیابی حفظ شد.", + "windowMinimize": "کوچک کردن", + "windowMaximize": "بزرگ کردن", + "windowRestore": "بازیابی" } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 39617df6..a762505a 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -3376,5 +3376,8 @@ "renameTopicFileInstead": "Les ID de sujet proviennent de leur nom de fichier. Utilisez « Renommer le fichier de sujet » dans la table des matières ou la barre latérale Fichiers.", "diagnosticWritersideWebFileNameInvalid": "L’instance \"{instanceId}\" publie le nom de fichier web non valide \"{webFileName}\" pour le sujet \"{topic}\".", "diagnosticWritersideWebFileNameCollision": "L’instance \"{instanceId}\" publie \"{firstTopic}\" et \"{secondTopic}\" sous le nom \"{webFileName}\".", - "spellingDictionaryRecoveryConflict": "Le dictionnaire orthographique a été modifié en externe. Une copie de récupération a été conservée." + "spellingDictionaryRecoveryConflict": "Le dictionnaire orthographique a été modifié en externe. Une copie de récupération a été conservée.", + "windowMinimize": "Réduire", + "windowMaximize": "Agrandir", + "windowRestore": "Restaurer" } diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 60c33799..df43a635 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -3376,5 +3376,8 @@ "renameTopicFileInstead": "विषय ID उनके फ़ाइल नामों से बनते हैं। विषय-सूची या फ़ाइलें साइडबार में ‘विषय फ़ाइल का नाम बदलें’ का उपयोग करें।", "diagnosticWritersideWebFileNameInvalid": "इंस्टेंस \"{instanceId}\" विषय \"{topic}\" के लिए अमान्य वेब फ़ाइल नाम \"{webFileName}\" प्रकाशित करता है।", "diagnosticWritersideWebFileNameCollision": "इंस्टेंस \"{instanceId}\" \"{firstTopic}\" और \"{secondTopic}\" दोनों को \"{webFileName}\" के रूप में प्रकाशित करता है।", - "spellingDictionaryRecoveryConflict": "वर्तनी शब्दकोश को बाहरी रूप से बदला गया। पुनर्प्राप्ति प्रति सुरक्षित रखी गई।" + "spellingDictionaryRecoveryConflict": "वर्तनी शब्दकोश को बाहरी रूप से बदला गया। पुनर्प्राप्ति प्रति सुरक्षित रखी गई।", + "windowMinimize": "छोटा करें", + "windowMaximize": "अधिकतम करें", + "windowRestore": "पुनर्स्थापित करें" } diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 977e921b..0c5de50d 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -4915,5 +4915,8 @@ "renameTopicFileInstead": "ID topik berasal dari nama filenya. Gunakan Ganti Nama File Topik di daftar isi atau bilah samping File.", "diagnosticWritersideWebFileNameInvalid": "Instans \"{instanceId}\" menerbitkan nama file web tidak valid \"{webFileName}\" untuk topik \"{topic}\".", "diagnosticWritersideWebFileNameCollision": "Instans \"{instanceId}\" menerbitkan \"{firstTopic}\" dan \"{secondTopic}\" sebagai \"{webFileName}\".", - "spellingDictionaryRecoveryConflict": "Kamus ejaan diubah secara eksternal. Salinan pemulihan telah disimpan." + "spellingDictionaryRecoveryConflict": "Kamus ejaan diubah secara eksternal. Salinan pemulihan telah disimpan.", + "windowMinimize": "Minimalkan", + "windowMaximize": "Maksimalkan", + "windowRestore": "Pulihkan" } diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index c48b4efc..510e6b53 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -3376,5 +3376,8 @@ "renameTopicFileInstead": "Gli ID degli argomenti derivano dai nomi dei file. Usa Rinomina file argomento nel sommario o nella barra laterale File.", "diagnosticWritersideWebFileNameInvalid": "L’istanza \"{instanceId}\" pubblica il nome file web non valido \"{webFileName}\" per l’argomento \"{topic}\".", "diagnosticWritersideWebFileNameCollision": "L’istanza \"{instanceId}\" pubblica sia \"{firstTopic}\" sia \"{secondTopic}\" come \"{webFileName}\".", - "spellingDictionaryRecoveryConflict": "Il dizionario ortografico è stato modificato esternamente. È stata conservata una copia di ripristino." + "spellingDictionaryRecoveryConflict": "Il dizionario ortografico è stato modificato esternamente. È stata conservata una copia di ripristino.", + "windowMinimize": "Riduci", + "windowMaximize": "Ingrandisci", + "windowRestore": "Ripristina" } diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index c80b386e..16e412cd 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -4915,5 +4915,8 @@ "renameTopicFileInstead": "トピック ID はファイル名から生成されます。目次またはファイルサイドバーの「トピックファイルの名前を変更」を使用してください。", "diagnosticWritersideWebFileNameInvalid": "インスタンス \"{instanceId}\" はトピック \"{topic}\" に無効な Web ファイル名 \"{webFileName}\" を使用しています。", "diagnosticWritersideWebFileNameCollision": "インスタンス \"{instanceId}\" は \"{firstTopic}\" と \"{secondTopic}\" の両方を \"{webFileName}\" として公開します。", - "spellingDictionaryRecoveryConflict": "スペルチェック辞書が外部で変更されました。復旧用コピーを保持しました。" + "spellingDictionaryRecoveryConflict": "スペルチェック辞書が外部で変更されました。復旧用コピーを保持しました。", + "windowMinimize": "最小化", + "windowMaximize": "最大化", + "windowRestore": "元に戻す" } diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 33cee5e1..d7a6c25e 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -4915,5 +4915,8 @@ "renameTopicFileInstead": "토픽 ID는 파일 이름에서 생성됩니다. 목차 또는 파일 사이드바에서 토픽 파일 이름 바꾸기를 사용하세요.", "diagnosticWritersideWebFileNameInvalid": "인스턴스 \"{instanceId}\"에서 토픽 \"{topic}\"의 웹 파일 이름 \"{webFileName}\"이 올바르지 않습니다.", "diagnosticWritersideWebFileNameCollision": "인스턴스 \"{instanceId}\"에서 \"{firstTopic}\"과 \"{secondTopic}\"이 모두 \"{webFileName}\"으로 게시됩니다.", - "spellingDictionaryRecoveryConflict": "맞춤법 사전이 외부에서 변경되었습니다. 복구 사본을 보존했습니다." + "spellingDictionaryRecoveryConflict": "맞춤법 사전이 외부에서 변경되었습니다. 복구 사본을 보존했습니다.", + "windowMinimize": "최소화", + "windowMaximize": "최대화", + "windowRestore": "복원" } diff --git a/lib/l10n/app_nb.arb b/lib/l10n/app_nb.arb index e26e9eb1..c62892c6 100644 --- a/lib/l10n/app_nb.arb +++ b/lib/l10n/app_nb.arb @@ -3376,5 +3376,8 @@ "renameTopicFileInstead": "Emne-ID-er kommer fra filnavnene. Bruk Gi nytt navn til emnefil i innholdsfortegnelsen eller sidepanelet Filer.", "diagnosticWritersideWebFileNameInvalid": "Instansen \"{instanceId}\" publiserer det ugyldige nettfilnavnet \"{webFileName}\" for emnet \"{topic}\".", "diagnosticWritersideWebFileNameCollision": "Instansen \"{instanceId}\" publiserer både \"{firstTopic}\" og \"{secondTopic}\" som \"{webFileName}\".", - "spellingDictionaryRecoveryConflict": "Staveordboken ble endret eksternt. En gjenopprettingskopi ble bevart." + "spellingDictionaryRecoveryConflict": "Staveordboken ble endret eksternt. En gjenopprettingskopi ble bevart.", + "windowMinimize": "Minimer", + "windowMaximize": "Maksimer", + "windowRestore": "Gjenopprett" } diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index f52d5538..34d32ec0 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -4915,5 +4915,8 @@ "renameTopicFileInstead": "Onderwerp-ID's zijn afgeleid van de bestandsnamen. Gebruik Onderwerpbestand hernoemen in de inhoudsopgave of de zijbalk Bestanden.", "diagnosticWritersideWebFileNameInvalid": "Instantie \"{instanceId}\" publiceert de ongeldige webbestandsnaam \"{webFileName}\" voor onderwerp \"{topic}\".", "diagnosticWritersideWebFileNameCollision": "Instantie \"{instanceId}\" publiceert zowel \"{firstTopic}\" als \"{secondTopic}\" als \"{webFileName}\".", - "spellingDictionaryRecoveryConflict": "Het spellingwoordenboek is extern gewijzigd. Er is een herstelkopie bewaard." + "spellingDictionaryRecoveryConflict": "Het spellingwoordenboek is extern gewijzigd. Er is een herstelkopie bewaard.", + "windowMinimize": "Minimaliseren", + "windowMaximize": "Maximaliseren", + "windowRestore": "Herstellen" } diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index 01f347cb..b26ec680 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -3402,5 +3402,8 @@ "renameTopicFileInstead": "Identyfikatory tematów pochodzą z nazw ich plików. Użyj polecenia Zmień nazwę pliku tematu w spisie treści lub na pasku bocznym Pliki.", "diagnosticWritersideWebFileNameInvalid": "Instancja \"{instanceId}\" publikuje nieprawidłową nazwę pliku internetowego \"{webFileName}\" dla tematu \"{topic}\".", "diagnosticWritersideWebFileNameCollision": "Instancja \"{instanceId}\" publikuje zarówno \"{firstTopic}\", jak i \"{secondTopic}\" jako \"{webFileName}\".", - "spellingDictionaryRecoveryConflict": "Słownik pisowni został zmieniony zewnętrznie. Zachowano kopię odzyskiwania." + "spellingDictionaryRecoveryConflict": "Słownik pisowni został zmieniony zewnętrznie. Zachowano kopię odzyskiwania.", + "windowMinimize": "Minimalizuj", + "windowMaximize": "Maksymalizuj", + "windowRestore": "Przywróć" } diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 018d6bc5..e3fde3e5 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -3376,5 +3376,8 @@ "renameTopicFileInstead": "Os IDs dos tópicos provêm dos nomes dos ficheiros. Utilize Mudar o nome do ficheiro de tópico no índice ou na barra lateral Ficheiros.", "diagnosticWritersideWebFileNameInvalid": "A instância \"{instanceId}\" publica o nome de ficheiro Web inválido \"{webFileName}\" para o tópico \"{topic}\".", "diagnosticWritersideWebFileNameCollision": "A instância \"{instanceId}\" publica \"{firstTopic}\" e \"{secondTopic}\" como \"{webFileName}\".", - "spellingDictionaryRecoveryConflict": "O dicionário ortográfico foi alterado externamente. Foi preservada uma cópia de recuperação." + "spellingDictionaryRecoveryConflict": "O dicionário ortográfico foi alterado externamente. Foi preservada uma cópia de recuperação.", + "windowMinimize": "Minimizar", + "windowMaximize": "Maximizar", + "windowRestore": "Restaurar" } diff --git a/lib/l10n/app_pt_BR.arb b/lib/l10n/app_pt_BR.arb index 84333041..0cf716a9 100644 --- a/lib/l10n/app_pt_BR.arb +++ b/lib/l10n/app_pt_BR.arb @@ -3376,5 +3376,8 @@ "renameTopicFileInstead": "Os IDs dos tópicos vêm dos nomes dos arquivos. Use Renomear arquivo do tópico no sumário ou na barra lateral Arquivos.", "diagnosticWritersideWebFileNameInvalid": "A instância \"{instanceId}\" publica o nome de arquivo da Web inválido \"{webFileName}\" para o tópico \"{topic}\".", "diagnosticWritersideWebFileNameCollision": "A instância \"{instanceId}\" publica \"{firstTopic}\" e \"{secondTopic}\" como \"{webFileName}\".", - "spellingDictionaryRecoveryConflict": "O dicionário ortográfico foi alterado externamente. Uma cópia de recuperação foi preservada." + "spellingDictionaryRecoveryConflict": "O dicionário ortográfico foi alterado externamente. Uma cópia de recuperação foi preservada.", + "windowMinimize": "Minimizar", + "windowMaximize": "Maximizar", + "windowRestore": "Restaurar" } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 63583516..66a76827 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -3402,5 +3402,8 @@ "renameTopicFileInstead": "ID тем определяются именами их файлов. Используйте «Переименовать файл темы» в оглавлении или на боковой панели «Файлы».", "diagnosticWritersideWebFileNameInvalid": "Экземпляр \"{instanceId}\" публикует недопустимое имя веб-файла \"{webFileName}\" для темы \"{topic}\".", "diagnosticWritersideWebFileNameCollision": "Экземпляр \"{instanceId}\" публикует темы \"{firstTopic}\" и \"{secondTopic}\" под одним именем \"{webFileName}\".", - "spellingDictionaryRecoveryConflict": "Орфографический словарь был изменён извне. Резервная копия для восстановления сохранена." + "spellingDictionaryRecoveryConflict": "Орфографический словарь был изменён извне. Резервная копия для восстановления сохранена.", + "windowMinimize": "Свернуть", + "windowMaximize": "Развернуть", + "windowRestore": "Восстановить" } diff --git a/lib/l10n/app_tr.arb b/lib/l10n/app_tr.arb index 6557a2ec..ebb10c7c 100644 --- a/lib/l10n/app_tr.arb +++ b/lib/l10n/app_tr.arb @@ -4915,5 +4915,8 @@ "renameTopicFileInstead": "Konu kimlikleri dosya adlarından gelir. İçindekiler tablosunda veya Dosyalar kenar çubuğunda Konu Dosyasını Yeniden Adlandır'ı kullanın.", "diagnosticWritersideWebFileNameInvalid": "\"{instanceId}\" örneği, \"{topic}\" konusu için geçersiz \"{webFileName}\" web dosyası adını yayımlıyor.", "diagnosticWritersideWebFileNameCollision": "\"{instanceId}\" örneği hem \"{firstTopic}\" hem de \"{secondTopic}\" konusunu \"{webFileName}\" olarak yayımlıyor.", - "spellingDictionaryRecoveryConflict": "Yazım sözlüğü harici olarak değiştirildi. Bir kurtarma kopyası korundu." + "spellingDictionaryRecoveryConflict": "Yazım sözlüğü harici olarak değiştirildi. Bir kurtarma kopyası korundu.", + "windowMinimize": "Simge durumuna küçült", + "windowMaximize": "Ekranı kapla", + "windowRestore": "Geri yükle" } diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb index 6d874eaf..e7911df4 100644 --- a/lib/l10n/app_uk.arb +++ b/lib/l10n/app_uk.arb @@ -3402,5 +3402,8 @@ "renameTopicFileInstead": "ID тем визначаються іменами їхніх файлів. Скористайтеся командою «Перейменувати файл теми» у змісті або на бічній панелі «Файли».", "diagnosticWritersideWebFileNameInvalid": "Екземпляр \"{instanceId}\" публікує неприпустиме ім’я вебфайлу \"{webFileName}\" для теми \"{topic}\".", "diagnosticWritersideWebFileNameCollision": "Екземпляр \"{instanceId}\" публікує теми \"{firstTopic}\" і \"{secondTopic}\" під одним іменем \"{webFileName}\".", - "spellingDictionaryRecoveryConflict": "Орфографічний словник було змінено ззовні. Копію для відновлення збережено." + "spellingDictionaryRecoveryConflict": "Орфографічний словник було змінено ззовні. Копію для відновлення збережено.", + "windowMinimize": "Згорнути", + "windowMaximize": "Розгорнути", + "windowRestore": "Відновити" } diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index d6e8ba81..2b1a081c 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -4915,5 +4915,8 @@ "renameTopicFileInstead": "ID chủ đề được tạo từ tên tệp. Hãy dùng Đổi tên tệp chủ đề trong mục lục hoặc thanh bên Tệp.", "diagnosticWritersideWebFileNameInvalid": "Phiên bản \"{instanceId}\" xuất bản tên tệp web không hợp lệ \"{webFileName}\" cho chủ đề \"{topic}\".", "diagnosticWritersideWebFileNameCollision": "Phiên bản \"{instanceId}\" xuất bản cả \"{firstTopic}\" và \"{secondTopic}\" dưới tên \"{webFileName}\".", - "spellingDictionaryRecoveryConflict": "Từ điển chính tả đã được thay đổi từ bên ngoài. Một bản sao khôi phục đã được giữ lại." + "spellingDictionaryRecoveryConflict": "Từ điển chính tả đã được thay đổi từ bên ngoài. Một bản sao khôi phục đã được giữ lại.", + "windowMinimize": "Thu nhỏ", + "windowMaximize": "Phóng to", + "windowRestore": "Khôi phục" } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 365914a0..23411f24 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -4915,5 +4915,8 @@ "renameTopicFileInstead": "主题 ID 来自其文件名。请在目录或“文件”侧边栏中使用“重命名主题文件”。", "diagnosticWritersideWebFileNameInvalid": "实例 \"{instanceId}\" 为主题 \"{topic}\" 发布了无效的 Web 文件名 \"{webFileName}\"。", "diagnosticWritersideWebFileNameCollision": "实例 \"{instanceId}\" 将 \"{firstTopic}\" 和 \"{secondTopic}\" 都发布为 \"{webFileName}\"。", - "spellingDictionaryRecoveryConflict": "拼写词典已被外部更改。已保留恢复副本。" + "spellingDictionaryRecoveryConflict": "拼写词典已被外部更改。已保留恢复副本。", + "windowMinimize": "最小化", + "windowMaximize": "最大化", + "windowRestore": "还原" } diff --git a/lib/l10n/app_zh_CN.arb b/lib/l10n/app_zh_CN.arb index 2295b5e3..c3450a62 100644 --- a/lib/l10n/app_zh_CN.arb +++ b/lib/l10n/app_zh_CN.arb @@ -4915,5 +4915,8 @@ "renameTopicFileInstead": "主题 ID 来自其文件名。请在目录或“文件”侧边栏中使用“重命名主题文件”。", "diagnosticWritersideWebFileNameInvalid": "实例 \"{instanceId}\" 为主题 \"{topic}\" 发布了无效的 Web 文件名 \"{webFileName}\"。", "diagnosticWritersideWebFileNameCollision": "实例 \"{instanceId}\" 将 \"{firstTopic}\" 和 \"{secondTopic}\" 都发布为 \"{webFileName}\"。", - "spellingDictionaryRecoveryConflict": "拼写词典已被外部更改。已保留恢复副本。" + "spellingDictionaryRecoveryConflict": "拼写词典已被外部更改。已保留恢复副本。", + "windowMinimize": "最小化", + "windowMaximize": "最大化", + "windowRestore": "还原" } diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 0337857e..3f19c907 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -7865,6 +7865,24 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'The spelling dictionary changed externally. A recovery copy was preserved.'** String get spellingDictionaryRecoveryConflict; + + /// Tooltip and accessibility label for minimizing the application window. + /// + /// In en, this message translates to: + /// **'Minimize'** + String get windowMinimize; + + /// Tooltip and accessibility label for maximizing the application window. + /// + /// In en, this message translates to: + /// **'Maximize'** + String get windowMaximize; + + /// Tooltip and accessibility label for restoring a maximized or fullscreen window to its normal size. + /// + /// In en, this message translates to: + /// **'Restore'** + String get windowRestore; } class _AppLocalizationsDelegate diff --git a/lib/l10n/generated/app_localizations_ar.dart b/lib/l10n/generated/app_localizations_ar.dart index 48f62637..19f6a8a4 100644 --- a/lib/l10n/generated/app_localizations_ar.dart +++ b/lib/l10n/generated/app_localizations_ar.dart @@ -4682,4 +4682,13 @@ class AppLocalizationsAr extends AppLocalizations { @override String get spellingDictionaryRecoveryConflict => 'تم تغيير قاموس التدقيق الإملائي خارجيًا. تم الاحتفاظ بنسخة استرداد.'; + + @override + String get windowMinimize => 'تصغير'; + + @override + String get windowMaximize => 'تكبير'; + + @override + String get windowRestore => 'استعادة'; } diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index e0bb9e87..afa42a8e 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -4692,4 +4692,13 @@ class AppLocalizationsDe extends AppLocalizations { @override String get spellingDictionaryRecoveryConflict => 'Das Rechtschreibwörterbuch wurde extern geändert. Eine Wiederherstellungskopie wurde aufbewahrt.'; + + @override + String get windowMinimize => 'Minimieren'; + + @override + String get windowMaximize => 'Maximieren'; + + @override + String get windowRestore => 'Wiederherstellen'; } diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index a902381e..eb19725f 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -4665,4 +4665,13 @@ class AppLocalizationsEn extends AppLocalizations { @override String get spellingDictionaryRecoveryConflict => 'The spelling dictionary changed externally. A recovery copy was preserved.'; + + @override + String get windowMinimize => 'Minimize'; + + @override + String get windowMaximize => 'Maximize'; + + @override + String get windowRestore => 'Restore'; } diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index 961dd62c..cd5fdbb2 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -4696,4 +4696,13 @@ class AppLocalizationsEs extends AppLocalizations { @override String get spellingDictionaryRecoveryConflict => 'El diccionario ortográfico se modificó externamente. Se conservó una copia de recuperación.'; + + @override + String get windowMinimize => 'Minimizar'; + + @override + String get windowMaximize => 'Maximizar'; + + @override + String get windowRestore => 'Restaurar'; } diff --git a/lib/l10n/generated/app_localizations_et.dart b/lib/l10n/generated/app_localizations_et.dart index cf36d1ea..4941ceba 100644 --- a/lib/l10n/generated/app_localizations_et.dart +++ b/lib/l10n/generated/app_localizations_et.dart @@ -4650,4 +4650,13 @@ class AppLocalizationsEt extends AppLocalizations { @override String get spellingDictionaryRecoveryConflict => 'Õigekirjasõnastikku muudeti väliselt. Taastekoopia säilitati.'; + + @override + String get windowMinimize => 'Minimeeri'; + + @override + String get windowMaximize => 'Maksimeeri'; + + @override + String get windowRestore => 'Taasta'; } diff --git a/lib/l10n/generated/app_localizations_fa.dart b/lib/l10n/generated/app_localizations_fa.dart index 5e5ded3d..00b197f5 100644 --- a/lib/l10n/generated/app_localizations_fa.dart +++ b/lib/l10n/generated/app_localizations_fa.dart @@ -4688,4 +4688,13 @@ class AppLocalizationsFa extends AppLocalizations { @override String get spellingDictionaryRecoveryConflict => 'فرهنگ لغت املایی به‌صورت خارجی تغییر کرد. یک نسخهٔ بازیابی حفظ شد.'; + + @override + String get windowMinimize => 'کوچک کردن'; + + @override + String get windowMaximize => 'بزرگ کردن'; + + @override + String get windowRestore => 'بازیابی'; } diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart index 49a8a141..3e3336a3 100644 --- a/lib/l10n/generated/app_localizations_fr.dart +++ b/lib/l10n/generated/app_localizations_fr.dart @@ -4698,4 +4698,13 @@ class AppLocalizationsFr extends AppLocalizations { @override String get spellingDictionaryRecoveryConflict => 'Le dictionnaire orthographique a été modifié en externe. Une copie de récupération a été conservée.'; + + @override + String get windowMinimize => 'Réduire'; + + @override + String get windowMaximize => 'Agrandir'; + + @override + String get windowRestore => 'Restaurer'; } diff --git a/lib/l10n/generated/app_localizations_hi.dart b/lib/l10n/generated/app_localizations_hi.dart index 875cbd2f..daafa607 100644 --- a/lib/l10n/generated/app_localizations_hi.dart +++ b/lib/l10n/generated/app_localizations_hi.dart @@ -4641,4 +4641,13 @@ class AppLocalizationsHi extends AppLocalizations { @override String get spellingDictionaryRecoveryConflict => 'वर्तनी शब्दकोश को बाहरी रूप से बदला गया। पुनर्प्राप्ति प्रति सुरक्षित रखी गई।'; + + @override + String get windowMinimize => 'छोटा करें'; + + @override + String get windowMaximize => 'अधिकतम करें'; + + @override + String get windowRestore => 'पुनर्स्थापित करें'; } diff --git a/lib/l10n/generated/app_localizations_id.dart b/lib/l10n/generated/app_localizations_id.dart index 6e16981b..76234651 100644 --- a/lib/l10n/generated/app_localizations_id.dart +++ b/lib/l10n/generated/app_localizations_id.dart @@ -4651,4 +4651,13 @@ class AppLocalizationsId extends AppLocalizations { @override String get spellingDictionaryRecoveryConflict => 'Kamus ejaan diubah secara eksternal. Salinan pemulihan telah disimpan.'; + + @override + String get windowMinimize => 'Minimalkan'; + + @override + String get windowMaximize => 'Maksimalkan'; + + @override + String get windowRestore => 'Pulihkan'; } diff --git a/lib/l10n/generated/app_localizations_it.dart b/lib/l10n/generated/app_localizations_it.dart index 47f5f1f0..4371bf1f 100644 --- a/lib/l10n/generated/app_localizations_it.dart +++ b/lib/l10n/generated/app_localizations_it.dart @@ -4691,4 +4691,13 @@ class AppLocalizationsIt extends AppLocalizations { @override String get spellingDictionaryRecoveryConflict => 'Il dizionario ortografico è stato modificato esternamente. È stata conservata una copia di ripristino.'; + + @override + String get windowMinimize => 'Riduci'; + + @override + String get windowMaximize => 'Ingrandisci'; + + @override + String get windowRestore => 'Ripristina'; } diff --git a/lib/l10n/generated/app_localizations_ja.dart b/lib/l10n/generated/app_localizations_ja.dart index b92ad30e..593ced6d 100644 --- a/lib/l10n/generated/app_localizations_ja.dart +++ b/lib/l10n/generated/app_localizations_ja.dart @@ -4521,4 +4521,13 @@ class AppLocalizationsJa extends AppLocalizations { @override String get spellingDictionaryRecoveryConflict => 'スペルチェック辞書が外部で変更されました。復旧用コピーを保持しました。'; + + @override + String get windowMinimize => '最小化'; + + @override + String get windowMaximize => '最大化'; + + @override + String get windowRestore => '元に戻す'; } diff --git a/lib/l10n/generated/app_localizations_ko.dart b/lib/l10n/generated/app_localizations_ko.dart index 92f385ba..9d449e02 100644 --- a/lib/l10n/generated/app_localizations_ko.dart +++ b/lib/l10n/generated/app_localizations_ko.dart @@ -4515,4 +4515,13 @@ class AppLocalizationsKo extends AppLocalizations { @override String get spellingDictionaryRecoveryConflict => '맞춤법 사전이 외부에서 변경되었습니다. 복구 사본을 보존했습니다.'; + + @override + String get windowMinimize => '최소화'; + + @override + String get windowMaximize => '최대화'; + + @override + String get windowRestore => '복원'; } diff --git a/lib/l10n/generated/app_localizations_nb.dart b/lib/l10n/generated/app_localizations_nb.dart index 17d9c21f..66f86e5b 100644 --- a/lib/l10n/generated/app_localizations_nb.dart +++ b/lib/l10n/generated/app_localizations_nb.dart @@ -4651,4 +4651,13 @@ class AppLocalizationsNb extends AppLocalizations { @override String get spellingDictionaryRecoveryConflict => 'Staveordboken ble endret eksternt. En gjenopprettingskopi ble bevart.'; + + @override + String get windowMinimize => 'Minimer'; + + @override + String get windowMaximize => 'Maksimer'; + + @override + String get windowRestore => 'Gjenopprett'; } diff --git a/lib/l10n/generated/app_localizations_nl.dart b/lib/l10n/generated/app_localizations_nl.dart index cebd0b2f..46609f1d 100644 --- a/lib/l10n/generated/app_localizations_nl.dart +++ b/lib/l10n/generated/app_localizations_nl.dart @@ -4697,4 +4697,13 @@ class AppLocalizationsNl extends AppLocalizations { @override String get spellingDictionaryRecoveryConflict => 'Het spellingwoordenboek is extern gewijzigd. Er is een herstelkopie bewaard.'; + + @override + String get windowMinimize => 'Minimaliseren'; + + @override + String get windowMaximize => 'Maximaliseren'; + + @override + String get windowRestore => 'Herstellen'; } diff --git a/lib/l10n/generated/app_localizations_pl.dart b/lib/l10n/generated/app_localizations_pl.dart index befe28f5..da40313f 100644 --- a/lib/l10n/generated/app_localizations_pl.dart +++ b/lib/l10n/generated/app_localizations_pl.dart @@ -4704,4 +4704,13 @@ class AppLocalizationsPl extends AppLocalizations { @override String get spellingDictionaryRecoveryConflict => 'Słownik pisowni został zmieniony zewnętrznie. Zachowano kopię odzyskiwania.'; + + @override + String get windowMinimize => 'Minimalizuj'; + + @override + String get windowMaximize => 'Maksymalizuj'; + + @override + String get windowRestore => 'Przywróć'; } diff --git a/lib/l10n/generated/app_localizations_pt.dart b/lib/l10n/generated/app_localizations_pt.dart index d7e72774..20dc1425 100644 --- a/lib/l10n/generated/app_localizations_pt.dart +++ b/lib/l10n/generated/app_localizations_pt.dart @@ -4711,6 +4711,15 @@ class AppLocalizationsPt extends AppLocalizations { @override String get spellingDictionaryRecoveryConflict => 'O dicionário ortográfico foi alterado externamente. Foi preservada uma cópia de recuperação.'; + + @override + String get windowMinimize => 'Minimizar'; + + @override + String get windowMaximize => 'Maximizar'; + + @override + String get windowRestore => 'Restaurar'; } /// The translations for Portuguese, as used in Brazil (`pt_BR`). @@ -9417,4 +9426,13 @@ class AppLocalizationsPtBr extends AppLocalizationsPt { @override String get spellingDictionaryRecoveryConflict => 'O dicionário ortográfico foi alterado externamente. Uma cópia de recuperação foi preservada.'; + + @override + String get windowMinimize => 'Minimizar'; + + @override + String get windowMaximize => 'Maximizar'; + + @override + String get windowRestore => 'Restaurar'; } diff --git a/lib/l10n/generated/app_localizations_ru.dart b/lib/l10n/generated/app_localizations_ru.dart index ba08a1e3..60229af7 100644 --- a/lib/l10n/generated/app_localizations_ru.dart +++ b/lib/l10n/generated/app_localizations_ru.dart @@ -4708,4 +4708,13 @@ class AppLocalizationsRu extends AppLocalizations { @override String get spellingDictionaryRecoveryConflict => 'Орфографический словарь был изменён извне. Резервная копия для восстановления сохранена.'; + + @override + String get windowMinimize => 'Свернуть'; + + @override + String get windowMaximize => 'Развернуть'; + + @override + String get windowRestore => 'Восстановить'; } diff --git a/lib/l10n/generated/app_localizations_tr.dart b/lib/l10n/generated/app_localizations_tr.dart index 5cd6d4a9..5449e4b7 100644 --- a/lib/l10n/generated/app_localizations_tr.dart +++ b/lib/l10n/generated/app_localizations_tr.dart @@ -4652,4 +4652,13 @@ class AppLocalizationsTr extends AppLocalizations { @override String get spellingDictionaryRecoveryConflict => 'Yazım sözlüğü harici olarak değiştirildi. Bir kurtarma kopyası korundu.'; + + @override + String get windowMinimize => 'Simge durumuna küçült'; + + @override + String get windowMaximize => 'Ekranı kapla'; + + @override + String get windowRestore => 'Geri yükle'; } diff --git a/lib/l10n/generated/app_localizations_uk.dart b/lib/l10n/generated/app_localizations_uk.dart index 8df90256..17dd1e25 100644 --- a/lib/l10n/generated/app_localizations_uk.dart +++ b/lib/l10n/generated/app_localizations_uk.dart @@ -4717,4 +4717,13 @@ class AppLocalizationsUk extends AppLocalizations { @override String get spellingDictionaryRecoveryConflict => 'Орфографічний словник було змінено ззовні. Копію для відновлення збережено.'; + + @override + String get windowMinimize => 'Згорнути'; + + @override + String get windowMaximize => 'Розгорнути'; + + @override + String get windowRestore => 'Відновити'; } diff --git a/lib/l10n/generated/app_localizations_vi.dart b/lib/l10n/generated/app_localizations_vi.dart index d01dad95..cbcc944c 100644 --- a/lib/l10n/generated/app_localizations_vi.dart +++ b/lib/l10n/generated/app_localizations_vi.dart @@ -4642,4 +4642,13 @@ class AppLocalizationsVi extends AppLocalizations { @override String get spellingDictionaryRecoveryConflict => 'Từ điển chính tả đã được thay đổi từ bên ngoài. Một bản sao khôi phục đã được giữ lại.'; + + @override + String get windowMinimize => 'Thu nhỏ'; + + @override + String get windowMaximize => 'Phóng to'; + + @override + String get windowRestore => 'Khôi phục'; } diff --git a/lib/l10n/generated/app_localizations_zh.dart b/lib/l10n/generated/app_localizations_zh.dart index 1a47f2cb..9d7276da 100644 --- a/lib/l10n/generated/app_localizations_zh.dart +++ b/lib/l10n/generated/app_localizations_zh.dart @@ -4447,6 +4447,15 @@ class AppLocalizationsZh extends AppLocalizations { @override String get spellingDictionaryRecoveryConflict => '拼写词典已被外部更改。已保留恢复副本。'; + + @override + String get windowMinimize => '最小化'; + + @override + String get windowMaximize => '最大化'; + + @override + String get windowRestore => '还原'; } /// The translations for Chinese, as used in China (`zh_CN`). @@ -8890,4 +8899,13 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get spellingDictionaryRecoveryConflict => '拼写词典已被外部更改。已保留恢复副本。'; + + @override + String get windowMinimize => '最小化'; + + @override + String get windowMaximize => '最大化'; + + @override + String get windowRestore => '还原'; } diff --git a/lib/main.dart b/lib/main.dart index cb1551fa..5d2a8fcd 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -4,13 +4,16 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:window_manager/window_manager.dart'; +import 'package:yaru/yaru.dart'; import 'src/app/busymark_app.dart'; import 'src/app/startup_path.dart'; import 'src/app/system_accent.dart'; import 'src/git/application/git_controller.dart'; import 'src/git/data/git_cli_gateway.dart'; -import 'src/platform/linux_header_bar_service.dart'; +import 'src/platform/gtk_animation_settings_service.dart'; +import 'src/platform/gtk_window_preferences_service.dart'; +import 'src/platform/gtk_header_icon_service.dart'; import 'src/platform/linux_gtk_accent_service.dart'; import 'src/spellcheck/spelling_release_smoke.dart'; import 'src/visualization/visualization_release_smoke.dart'; @@ -18,7 +21,7 @@ import 'src/visualization/visualization_release_smoke.dart'; Future main(List args) async { WidgetsFlutterBinding.ensureInitialized(); await windowManager.ensureInitialized(); - await LinuxHeaderBarService.instance.initialize(); + if (Platform.isLinux) await YaruWindow.ensureInitialized(); if (visualizationReleaseSmokeReportPath(args) case final reportPath?) { exit(await runVisualizationReleaseSmoke(reportPath)); } @@ -31,9 +34,25 @@ Future main(List args) async { : [], ); await accentCoordinator.initialize(); + final chrome = await Future.wait([ + const GtkAnimationSettingsService().getAnimationsEnabled(), + const GtkWindowPreferencesService().load(), + ]); + final headerIcons = GtkHeaderIconService(); + await headerIcons.initialize(); runApp( ProviderScope( overrides: [ + initialGtkAnimationsEnabledProvider.overrideWithValue( + chrome[0] as bool?, + ), + initialGtkWindowPreferencesProvider.overrideWithValue( + chrome[1] as GtkWindowPreferences?, + ), + gtkHeaderIconServiceProvider.overrideWith((ref) { + ref.onDispose(headerIcons.dispose); + return headerIcons; + }), startupPathProvider.overrideWithValue(args.isEmpty ? null : args.first), initialSystemAccentColorProvider.overrideWithValue( accentCoordinator.color, diff --git a/lib/src/app/app_settings.dart b/lib/src/app/app_settings.dart index 686415c4..95436a29 100644 --- a/lib/src/app/app_settings.dart +++ b/lib/src/app/app_settings.dart @@ -662,6 +662,9 @@ class AppSettingsController extends Notifier { } Future setSidebarVisible(bool enabled) { + if (state.sidebarVisible != enabled) { + ref.read(sidebarTransitionGenerationProvider.notifier).increment(); + } return _mutate((settings) => settings.copyWith(sidebarVisible: enabled)); } @@ -981,6 +984,17 @@ final localSettingsStoreProvider = Provider( (ref) => const JsonFileLocalSettingsStore(), ); +final sidebarTransitionGenerationProvider = + NotifierProvider( + SidebarTransitionGeneration.new, + ); + +class SidebarTransitionGeneration extends Notifier { + @override + int build() => 0; + void increment() => state++; +} + final appSettingsControllerProvider = NotifierProvider( AppSettingsController.new, diff --git a/lib/src/app/busymark_app.dart b/lib/src/app/busymark_app.dart index bdd0628a..3dea0920 100644 --- a/lib/src/app/busymark_app.dart +++ b/lib/src/app/busymark_app.dart @@ -14,6 +14,8 @@ import '../export/workspace_export_ui.dart'; import '../clipboard/clipboard_history_controller.dart'; import '../git/application/git_controller.dart'; import '../platform/linux_header_bar_service.dart'; +import '../platform/gtk_animation_settings_service.dart'; +import '../platform/gtk_header_icon_service.dart'; import '../local_history/local_history_controller.dart'; import '../workspace/workspace_controller.dart'; import '../workspace/workspace_model.dart'; @@ -33,6 +35,7 @@ import 'busymark_toast.dart'; import 'localization.dart'; import 'system_accent.dart'; import 'window_control_service.dart'; +import 'linux/linux_window_host.dart'; final busyMarkCommandRegistryProvider = Provider(( ref, @@ -111,6 +114,10 @@ class BusyMarkApp extends ConsumerWidget { final router = ref.watch(appRouterProvider); final settings = ref.watch(appSettingsControllerProvider); final windowControls = ref.watch(windowControlServiceProvider); + final gtkAnimationsEnabled = + ref.watch(gtkAnimationsEnabledProvider).asData?.value ?? + ref.watch(initialGtkAnimationsEnabledProvider) ?? + true; ref.listen(headerBarActionsProvider, (previous, next) { next.whenData((event) { if (event.action == HeaderBarAction.fullScreen) { @@ -416,7 +423,19 @@ class BusyMarkApp extends ConsumerWidget { ); return BusyMarkCommandRegistryScope( registry: commandRegistry, - child: BusyMarkToastOverlay(child: appContent), + child: MediaQuery( + data: MediaQuery.of(context).copyWith( + disableAnimations: + MediaQuery.disableAnimationsOf(context) || + !gtkAnimationsEnabled, + ), + child: GtkHeaderIconScope( + service: ref.watch(gtkHeaderIconServiceProvider), + child: LinuxWindowHost( + child: BusyMarkToastOverlay(child: appContent), + ), + ), + ), ); }, routerConfig: router, @@ -826,9 +845,6 @@ class BusyMarkApp extends ConsumerWidget { } final settings = ref.read(appSettingsControllerProvider); final visible = !settings.sidebarVisible; - if (!visible) { - _clearGitDetailSelection(ref); - } unawaited( ref .read(appSettingsControllerProvider.notifier) @@ -845,17 +861,6 @@ class BusyMarkApp extends ConsumerWidget { }; } - void _clearGitDetailSelection(WidgetRef ref) { - final gitState = ref.read(gitControllerProvider); - if (gitState.selectedDiff != null || - gitState.selectedFilePath != null || - gitState.selectedCommitHash != null || - gitState.selectedCommitFilePath != null || - gitState.openDiffFilePaths.isNotEmpty) { - ref.read(gitControllerProvider.notifier).clearSelection(); - } - } - HeaderBarConfiguration _nativeHeaderBarDefaults( BuildContext context, AppSettings settings, diff --git a/lib/src/app/busymark_design.dart b/lib/src/app/busymark_design.dart index 4894c553..1981f22f 100644 --- a/lib/src/app/busymark_design.dart +++ b/lib/src/app/busymark_design.dart @@ -8,6 +8,7 @@ import 'package:flutter/services.dart'; import 'package:yaru/yaru.dart'; import '../platform/native_menu_service.dart'; +import '../platform/gtk_header_icon_service.dart'; import 'busymark_glyphs.dart'; /// Resolve an explicit six-digit RGB color chosen in a document/export control. @@ -51,10 +52,13 @@ abstract final class BusyMarkSizes { static const double contentWidth = 760; static const double documentContentWidth = contentWidth; static const double sidebarWidth = 300; + // The workspace header also needs room for document actions and window controls. + static const double workspaceSidebarBreakpoint = sidebarWidth + 480; static const double sidebarRowHeight = 36; static const double settingsWidth = 760; static const double settingsSidebarBreakpoint = sidebarWidth + 520; static const double toolbarHeight = kYaruTitleBarHeight; + static const double headerTitleMinWidth = 120; static const double paneHeaderHeight = 38; static const double documentStatusBarHeight = 28; static const double iconButton = kYaruTitleBarItemHeight; @@ -261,6 +265,11 @@ String busyMarkBidiIsolateFor(BuildContext context, Object value) { } abstract final class BusyMarkMotion { + static const Duration fast = Duration(milliseconds: 100); + static const Duration sidebar = Duration(milliseconds: 200); + static const Duration search = Duration(milliseconds: 160); + static const Duration crossfade = Duration(milliseconds: 140); + static const Curve presentationCurve = Curves.easeOutCubic; static const Duration dialogInsets = Duration(milliseconds: 160); static const Duration bannerReveal = Duration(milliseconds: 250); static const Duration sidebarExpand = Duration(milliseconds: 120); @@ -1078,7 +1087,7 @@ class BusyMarkHeaderIconButton extends StatelessWidget { final button = IconButton( isSelected: selected, tooltip: shortcut == null ? tooltip : '$tooltip ($shortcut)', - icon: Icon(icon, size: BusyMarkSizes.iconSm), + icon: BusyMarkGtkHeaderIcon(icon), padding: EdgeInsets.zero, style: style.merge(yaruDefaults), onPressed: onPressed, @@ -2575,9 +2584,14 @@ class BusyMarkGroupedSurface extends StatelessWidget { /// Shared split-view sidebar surface and reading-direction boundary. class BusyMarkSidebarSurface extends StatelessWidget { - const BusyMarkSidebarSurface({super.key, required this.child}); + const BusyMarkSidebarSurface({ + super.key, + required this.child, + this.showEndBorder = true, + }); final Widget child; + final bool showEndBorder; @override Widget build(BuildContext context) { @@ -2587,12 +2601,14 @@ class BusyMarkSidebarSurface extends StatelessWidget { child: DecoratedBox( position: DecorationPosition.foreground, decoration: BoxDecoration( - border: BorderDirectional( - end: BorderSide( - color: colors.sidebarBorder, - width: BusyMarkStroke.hairline, - ), - ), + border: !showEndBorder + ? null + : BorderDirectional( + end: BorderSide( + color: colors.sidebarBorder, + width: BusyMarkStroke.hairline, + ), + ), ), child: child, ), diff --git a/lib/src/app/busymark_motion_widgets.dart b/lib/src/app/busymark_motion_widgets.dart new file mode 100644 index 00000000..0578619b --- /dev/null +++ b/lib/src/app/busymark_motion_widgets.dart @@ -0,0 +1,293 @@ +import 'package:flutter/widgets.dart'; + +import 'busymark_design.dart'; + +/// A semantic view change fades the single live editor surface into place. +/// The editing tree, keys, controllers and input remain owned by the caller. +class BusyMarkSemanticFade extends StatefulWidget { + const BusyMarkSemanticFade({ + super.key, + required this.transitionKey, + required this.child, + }); + final Object transitionKey; + final Widget child; + @override + State createState() => _BusyMarkSemanticFadeState(); +} + +class _BusyMarkSemanticFadeState extends State + with SingleTickerProviderStateMixin { + late final _controller = AnimationController( + vsync: this, + value: 1, + animationBehavior: AnimationBehavior.preserve, + ); + bool _disabled = false; + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _disabled = MediaQuery.disableAnimationsOf(context); + if (_disabled) _controller.value = 1; + } + + @override + void didUpdateWidget(covariant BusyMarkSemanticFade oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.transitionKey == oldWidget.transitionKey) return; + if (_disabled) { + _controller.value = 1; + } else { + _controller.forward(from: 0); + } + } + + @override + void initState() { + super.initState(); + _controller.duration = BusyMarkMotion.crossfade; + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => FadeTransition( + opacity: _controller.drive( + CurveTween(curve: BusyMarkMotion.presentationCurve), + ), + child: widget.child, + ); +} + +/// Crossfades a fixed set of live pages without reconstructing destinations. +class BusyMarkRetainedCrossfade extends StatefulWidget { + const BusyMarkRetainedCrossfade({ + super.key, + required this.index, + required this.children, + this.duration = BusyMarkMotion.crossfade, + }) : assert(children.length > 0), + assert(index >= 0 && index < children.length); + + final int index; + final List children; + final Duration duration; + + @override + State createState() => + _BusyMarkRetainedCrossfadeState(); +} + +/// A semantic destination host: data rebuilds update the active destination, +/// while every previously visited destination keeps its live element state. +/// Only a changed [transitionKey] starts a crossfade. +class BusyMarkKeyedCrossfade extends StatefulWidget { + const BusyMarkKeyedCrossfade({ + super.key, + required this.transitionKey, + required this.child, + this.duration = BusyMarkMotion.crossfade, + }); + + final Object transitionKey; + final Widget child; + final Duration duration; + + @override + State createState() => _BusyMarkKeyedCrossfadeState(); +} + +class _BusyMarkKeyedCrossfadeState extends State { + late final List _keys; + late final List _destinationKeys; + late final List _destinations; + var _index = 0; + + @override + void initState() { + super.initState(); + final key = GlobalKey( + debugLabel: 'busymark-destination-${widget.transitionKey}', + ); + _keys = [widget.transitionKey]; + _destinationKeys = [key]; + _destinations = [KeyedSubtree(key: key, child: widget.child)]; + } + + @override + void didUpdateWidget(covariant BusyMarkKeyedCrossfade oldWidget) { + super.didUpdateWidget(oldWidget); + final destination = _keys.indexOf(widget.transitionKey); + if (destination < 0) { + _keys.add(widget.transitionKey); + final key = GlobalKey( + debugLabel: 'busymark-destination-${widget.transitionKey}', + ); + _destinationKeys.add(key); + _destinations.add(KeyedSubtree(key: key, child: widget.child)); + _index = _keys.length - 1; + } else { + _index = destination; + _destinations[destination] = KeyedSubtree( + key: _destinationKeys[destination], + child: widget.child, + ); + } + } + + @override + Widget build(BuildContext context) => BusyMarkRetainedCrossfade( + index: _index, + duration: widget.duration, + // Widgets are immutable. Do not let an older retained host observe later + // in-place destination-list edits during element reconciliation. + children: List.of(_destinations, growable: false), + ); +} + +class _BusyMarkRetainedCrossfadeState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller = AnimationController( + vsync: this, + animationBehavior: AnimationBehavior.preserve, + value: 1, + ); + late int _current = widget.index; + int? _outgoing; + bool _disableAnimations = false; + + @override + void initState() { + super.initState(); + _controller.addStatusListener(_handleStatus); + } + + void _handleStatus(AnimationStatus status) { + if (status == AnimationStatus.completed && _outgoing != null && mounted) { + setState(() => _outgoing = null); + } + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final disabled = MediaQuery.disableAnimationsOf(context); + if (disabled != _disableAnimations) { + _disableAnimations = disabled; + if (disabled) { + _controller.stop(); + _outgoing = null; + _controller.value = 1; + } + } + } + + @override + void didUpdateWidget(covariant BusyMarkRetainedCrossfade oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.index == _current) return; + if (widget.index == _outgoing && _controller.isAnimating) { + // A -> B -> A reverses from the presentation currently on screen. + // Swapping the roles and complementing progress preserves both page + // opacities instead of replaying a complete transition from an endpoint. + final previousCurrent = _current; + _current = widget.index; + _outgoing = previousCurrent; + _controller.value = 1 - _controller.value; + } else { + _outgoing = _current; + _current = widget.index; + _controller.value = 0; + } + if (_disableAnimations || widget.duration == Duration.zero) { + _finish(); + return; + } + final distance = 1 - _controller.value; + if (distance == 0) { + _finish(); + return; + } + _controller.animateTo( + 1, + duration: widget.duration * distance, + curve: BusyMarkMotion.presentationCurve, + ); + } + + void _finish() { + if (!mounted) return; + _outgoing = null; + _controller.value = 1; + } + + @override + void dispose() { + _controller.removeStatusListener(_handleStatus); + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _controller, + builder: (context, _) => Stack( + fit: StackFit.loose, + children: [ + for (var index = 0; index < widget.children.length; index++) + _RetainedPage( + key: ValueKey(('busymark-retained-page', index)), + active: index == _current, + outgoing: index == _outgoing, + opacity: index == _current + ? _controller.value + : index == _outgoing + ? 1 - _controller.value + : 0, + child: widget.children[index], + ), + ], + ), + ); + } +} + +class _RetainedPage extends StatelessWidget { + const _RetainedPage({ + super.key, + required this.active, + required this.outgoing, + required this.opacity, + required this.child, + }); + + final bool active; + final bool outgoing; + final double opacity; + final Widget child; + + @override + Widget build(BuildContext context) { + return Offstage( + offstage: !active && !outgoing, + child: IgnorePointer( + ignoring: !active, + child: ExcludeFocus( + excluding: !active, + child: ExcludeSemantics( + excluding: !active, + child: TickerMode( + enabled: active, + child: Opacity(opacity: opacity, child: child), + ), + ), + ), + ), + ); + } +} diff --git a/lib/src/app/busymark_search_field.dart b/lib/src/app/busymark_search_field.dart index 97970af3..6909182a 100644 --- a/lib/src/app/busymark_search_field.dart +++ b/lib/src/app/busymark_search_field.dart @@ -20,6 +20,7 @@ class BusyMarkSearchField extends StatefulWidget { this.onClear, this.onEscape, this.clearButtonSemanticLabel, + this.clearOnEscape = true, }); final TextEditingController? controller; @@ -34,6 +35,7 @@ class BusyMarkSearchField extends StatefulWidget { final VoidCallback? onClear; final VoidCallback? onEscape; final String? clearButtonSemanticLabel; + final bool clearOnEscape; @override State createState() => _BusyMarkSearchFieldState(); @@ -120,7 +122,7 @@ class _BusyMarkSearchFieldState extends State { onKeyEvent: (_, event) { if (event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.escape) { - _clear(); + if (widget.clearOnEscape) _clear(); widget.onEscape?.call(); return KeyEventResult.handled; } diff --git a/lib/src/app/busymark_toast.dart b/lib/src/app/busymark_toast.dart index 8245b0df..1914d7e0 100644 --- a/lib/src/app/busymark_toast.dart +++ b/lib/src/app/busymark_toast.dart @@ -174,8 +174,12 @@ class _BusyMarkToastOverlayState extends State { alignment: Alignment.bottomCenter, heightFactor: 1, child: AnimatedSwitcher( - duration: _transitionDuration, - reverseDuration: _transitionDuration, + duration: MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : _transitionDuration, + reverseDuration: MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : _transitionDuration, switchInCurve: Curves.easeOutCubic, switchOutCurve: Curves.easeInCubic, transitionBuilder: (child, animation) => FadeTransition( diff --git a/lib/src/app/linux/linux_header_style.dart b/lib/src/app/linux/linux_header_style.dart new file mode 100644 index 00000000..0a20da6c --- /dev/null +++ b/lib/src/app/linux/linux_header_style.dart @@ -0,0 +1,252 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +import '../busymark_design.dart'; +import 'linux_window_host.dart'; + +class LinuxPageHeaderInsetsScope extends InheritedWidget { + const LinuxPageHeaderInsetsScope({ + super.key, + required this.leftObstruction, + required this.rightObstruction, + required super.child, + }); + + final double leftObstruction; + final double rightObstruction; + + static const _fallback = LinuxPageHeaderInsetsScope( + leftObstruction: 0, + rightObstruction: 0, + child: SizedBox.shrink(), + ); + + static LinuxPageHeaderInsetsScope of(BuildContext context) => + context + .dependOnInheritedWidgetOfExactType() ?? + _fallback; + + @override + bool updateShouldNotify(LinuxPageHeaderInsetsScope oldWidget) => + leftObstruction != oldWidget.leftObstruction || + rightObstruction != oldWidget.rightObstruction; +} + +TextStyle busyMarkLinuxHeaderTitleStyle(BuildContext context) { + final colors = BusyMarkSurfaceColors.of(context); + final active = LinuxWindowMetricsScope.of(context).windowActive; + return Theme.of(context).textTheme.titleSmall!.copyWith( + fontWeight: FontWeight.w600, + color: colors.foreground.withValues(alpha: active ? 1 : .5), + ); +} + +TextStyle busyMarkLinuxHeaderBrandStyle(BuildContext context) => + busyMarkLinuxHeaderTitleStyle( + context, + ).copyWith(fontWeight: FontWeight.w700); + +enum _BusyMarkLinuxHeaderSlot { leading, title, trailing } + +enum BusyMarkLinuxHeaderCenterAllocation { centered, fillBetweenControls } + +/// GTK-style application header geometry with an explicit center allocation. +class BusyMarkLinuxHeaderLayout extends StatelessWidget { + const BusyMarkLinuxHeaderLayout({ + super.key, + required this.leading, + required this.title, + required this.trailing, + this.maxContentWidth, + this.centerAllocation = BusyMarkLinuxHeaderCenterAllocation.centered, + }); + + final Widget leading; + final Widget title; + final Widget trailing; + final double? maxContentWidth; + final BusyMarkLinuxHeaderCenterAllocation centerAllocation; + + @override + Widget build(BuildContext context) { + final insets = LinuxPageHeaderInsetsScope.of(context); + final direction = Directionality.of(context); + return SizedBox( + height: BusyMarkSizes.toolbarHeight, + child: Stack( + fit: StackFit.expand, + children: [ + const LinuxTitlebarGestureRegion(child: SizedBox.expand()), + CustomMultiChildLayout( + delegate: _BusyMarkLinuxHeaderLayoutDelegate( + direction: direction, + leftObstruction: insets.leftObstruction, + rightObstruction: insets.rightObstruction, + maxContentWidth: maxContentWidth, + centerAllocation: centerAllocation, + ), + children: [ + LayoutId(id: _BusyMarkLinuxHeaderSlot.leading, child: leading), + LayoutId(id: _BusyMarkLinuxHeaderSlot.title, child: title), + LayoutId(id: _BusyMarkLinuxHeaderSlot.trailing, child: trailing), + ], + ), + ], + ), + ); + } +} + +class _BusyMarkLinuxHeaderLayoutDelegate extends MultiChildLayoutDelegate { + _BusyMarkLinuxHeaderLayoutDelegate({ + required this.direction, + required this.leftObstruction, + required this.rightObstruction, + required this.maxContentWidth, + required this.centerAllocation, + }); + + final TextDirection direction; + final double leftObstruction; + final double rightObstruction; + final double? maxContentWidth; + final BusyMarkLinuxHeaderCenterAllocation centerAllocation; + + @override + void performLayout(Size size) { + final centeredInset = maxContentWidth == null + ? 0.0 + : math.max(0.0, (size.width - maxContentWidth!) / 2); + final leftInset = math.max( + leftObstruction + BusyMarkSpacing.headerInset, + centeredInset, + ); + final rightInset = math.max( + rightObstruction + BusyMarkSpacing.headerInset, + centeredInset, + ); + final sideConstraints = BoxConstraints.loose( + Size(math.max(0, size.width - leftInset - rightInset), size.height), + ); + final leadingSize = layoutChild( + _BusyMarkLinuxHeaderSlot.leading, + sideConstraints, + ); + final trailingSize = layoutChild( + _BusyMarkLinuxHeaderSlot.trailing, + sideConstraints, + ); + + late final Offset leadingOffset; + late final Offset trailingOffset; + late final double leftOccupiedEdge; + late final double rightOccupiedEdge; + if (direction == TextDirection.ltr) { + leadingOffset = Offset(leftInset, (size.height - leadingSize.height) / 2); + trailingOffset = Offset( + size.width - rightInset - trailingSize.width, + (size.height - trailingSize.height) / 2, + ); + leftOccupiedEdge = leadingOffset.dx + leadingSize.width; + rightOccupiedEdge = trailingOffset.dx; + } else { + leadingOffset = Offset( + size.width - rightInset - leadingSize.width, + (size.height - leadingSize.height) / 2, + ); + trailingOffset = Offset( + leftInset, + (size.height - trailingSize.height) / 2, + ); + leftOccupiedEdge = trailingOffset.dx + trailingSize.width; + rightOccupiedEdge = leadingOffset.dx; + } + positionChild(_BusyMarkLinuxHeaderSlot.leading, leadingOffset); + positionChild(_BusyMarkLinuxHeaderSlot.trailing, trailingOffset); + + final safeLeftEdge = leftOccupiedEdge + BusyMarkSpacing.headerInset; + final safeRightEdge = rightOccupiedEdge - BusyMarkSpacing.headerInset; + switch (centerAllocation) { + case BusyMarkLinuxHeaderCenterAllocation.centered: + final centerX = size.width / 2; + final availableWidth = math.max(0.0, safeRightEdge - safeLeftEdge); + final minimumHalfWidth = + math.min(BusyMarkSizes.headerTitleMinWidth, availableWidth) / 2; + // Clamp continuously into the safe control gap as the sidebar moves. + // Wide headers keep absolute centering; narrow headers keep a readable + // title without jumping between two allocation policies. + final titleCenter = availableWidth == 0 + ? (safeLeftEdge + safeRightEdge) / 2 + : centerX + .clamp( + safeLeftEdge + minimumHalfWidth, + safeRightEdge - minimumHalfWidth, + ) + .toDouble(); + final safeHalfWidth = math.max( + 0.0, + math.min(titleCenter - safeLeftEdge, safeRightEdge - titleCenter), + ); + final titleSize = layoutChild( + _BusyMarkLinuxHeaderSlot.title, + BoxConstraints.loose(Size(safeHalfWidth * 2, size.height)), + ); + positionChild( + _BusyMarkLinuxHeaderSlot.title, + Offset( + titleCenter - titleSize.width / 2, + (size.height - titleSize.height) / 2, + ), + ); + case BusyMarkLinuxHeaderCenterAllocation.fillBetweenControls: + final availableWidth = math.max(0.0, safeRightEdge - safeLeftEdge); + final titleSize = layoutChild( + _BusyMarkLinuxHeaderSlot.title, + BoxConstraints( + minWidth: availableWidth, + maxWidth: availableWidth, + minHeight: 0, + maxHeight: size.height, + ), + ); + positionChild( + _BusyMarkLinuxHeaderSlot.title, + Offset(safeLeftEdge, (size.height - titleSize.height) / 2), + ); + } + } + + @override + bool shouldRelayout(_BusyMarkLinuxHeaderLayoutDelegate oldDelegate) => + direction != oldDelegate.direction || + leftObstruction != oldDelegate.leftObstruction || + rightObstruction != oldDelegate.rightObstruction || + maxContentWidth != oldDelegate.maxContentWidth || + centerAllocation != oldDelegate.centerAllocation; +} + +class BusyMarkLinuxHeaderTitle extends StatelessWidget { + const BusyMarkLinuxHeaderTitle(this.text, {super.key, this.brand = false}); + + final String text; + final bool brand; + + @override + Widget build(BuildContext context) { + return LinuxTitlebarGestureRegion( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: BusyMarkSpacing.md), + child: Text( + text, + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: brand + ? busyMarkLinuxHeaderBrandStyle(context) + : busyMarkLinuxHeaderTitleStyle(context), + ), + ), + ); + } +} diff --git a/lib/src/app/linux/linux_page_frame.dart b/lib/src/app/linux/linux_page_frame.dart new file mode 100644 index 00000000..7cd74deb --- /dev/null +++ b/lib/src/app/linux/linux_page_frame.dart @@ -0,0 +1,254 @@ +import 'package:flutter/material.dart'; + +import '../busymark_design.dart'; +import '../localization.dart'; +import 'linux_header_style.dart'; +import 'linux_window_host.dart'; + +/// The canonical Linux page composition. +/// +/// The sidebar header and body are a single full-height child of one clipped +/// allocation, so their visible edge is physically identical at every frame. +class LinuxPageFrame extends StatefulWidget { + const LinuxPageFrame({ + super.key, + required this.header, + required this.body, + this.sidebarHeader, + this.sidebarBody, + this.sidebarAvailable = false, + this.sidebarExpanded = false, + this.sidebarTransitionGeneration = 0, + }); + + final Widget header; + final Widget body; + final Widget? sidebarHeader; + final Widget? sidebarBody; + final bool sidebarAvailable; + final bool sidebarExpanded; + final int sidebarTransitionGeneration; + + @override + State createState() => _LinuxPageFrameState(); +} + +class _LinuxPageFrameState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller = AnimationController( + vsync: this, + animationBehavior: AnimationBehavior.preserve, + value: _targetVisible ? 1 : 0, + ); + late int _generation; + bool _disableAnimations = false; + + bool get _hasSidebar => + widget.sidebarHeader != null && widget.sidebarBody != null; + bool get _targetVisible => + _hasSidebar && widget.sidebarAvailable && widget.sidebarExpanded; + + @override + void initState() { + super.initState(); + _generation = widget.sidebarTransitionGeneration; + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final disabled = MediaQuery.disableAnimationsOf(context); + if (disabled != _disableAnimations) { + _disableAnimations = disabled; + if (disabled && _controller.isAnimating) { + _controller.value = _targetVisible ? 1 : 0; + } + } + } + + @override + void didUpdateWidget(covariant LinuxPageFrame oldWidget) { + super.didUpdateWidget(oldWidget); + final target = _targetVisible ? 1.0 : 0.0; + if (widget.sidebarTransitionGeneration == _generation) { + final oldTarget = + oldWidget.sidebarHeader != null && + oldWidget.sidebarBody != null && + oldWidget.sidebarAvailable && + oldWidget.sidebarExpanded; + if (oldTarget != _targetVisible) { + _controller + ..stop() + ..value = target; + } + return; + } + _generation = widget.sidebarTransitionGeneration; + if (_disableAnimations) { + _controller.value = target; + return; + } + final distance = (target - _controller.value).abs(); + if (distance == 0) { + _controller + ..stop() + ..value = target; + return; + } + _controller.animateTo( + target, + duration: BusyMarkMotion.sidebar * distance, + curve: BusyMarkMotion.presentationCurve, + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final colors = BusyMarkSurfaceColors.of(context); + final metrics = LinuxWindowMetricsScope.of(context); + final textDirection = Directionality.of(context); + final sidebarAtLeft = textDirection == TextDirection.ltr; + final sidebarControlInset = sidebarAtLeft + ? metrics.leftControlInset + : metrics.rightControlInset; + final oppositeControlInset = sidebarAtLeft + ? metrics.rightControlInset + : metrics.leftControlInset; + final sidebar = _hasSidebar + ? SizedBox( + width: BusyMarkSizes.sidebarWidth, + child: ColoredBox( + color: colors.sidebar, + child: Column( + children: [ + SizedBox( + height: BusyMarkLinuxWindowMetrics.headerHeight, + child: Padding( + padding: sidebarAtLeft + ? EdgeInsets.only(left: sidebarControlInset) + : EdgeInsets.only(right: sidebarControlInset), + child: widget.sidebarHeader!, + ), + ), + Expanded(child: widget.sidebarBody!), + ], + ), + ), + ) + : null; + final main = _LinuxFrameMain( + header: ColoredBox(color: colors.headerbarFlat, child: widget.header), + body: widget.body, + ); + return AnimatedBuilder( + animation: _controller, + child: main, + builder: (context, mainChild) { + final devicePixelRatio = MediaQuery.devicePixelRatioOf(context); + final rawWidth = BusyMarkSizes.sidebarWidth * _controller.value; + final presentedWidth = + (rawWidth * devicePixelRatio).round() / devicePixelRatio; + final widthFactor = presentedWidth / BusyMarkSizes.sidebarWidth; + final sidebarSideOverlap = (sidebarControlInset - presentedWidth) + .clamp(0.0, double.infinity) + .toDouble(); + final leftObstruction = sidebarAtLeft + ? sidebarSideOverlap + : oppositeControlInset; + final rightObstruction = sidebarAtLeft + ? oppositeControlInset + : sidebarSideOverlap; + final sidebarInteractive = _targetVisible && _controller.value == 1; + return Row( + children: [ + if (sidebar != null) + DecoratedBox( + key: const ValueKey('linux-sidebar-viewport'), + position: DecorationPosition.foreground, + decoration: BoxDecoration( + border: BorderDirectional( + end: BorderSide( + color: colors.sidebarBorder.withValues( + alpha: presentedWidth == 0 ? 0 : colors.sidebarBorder.a, + ), + ), + ), + ), + child: IgnorePointer( + ignoring: !sidebarInteractive, + child: ExcludeFocus( + excluding: !sidebarInteractive, + child: ExcludeSemantics( + excluding: !sidebarInteractive, + child: TickerMode( + enabled: sidebarInteractive || _controller.value > 0, + child: ClipRect( + child: Align( + alignment: AlignmentDirectional.centerStart, + widthFactor: widthFactor, + child: Offstage( + offstage: presentedWidth == 0, + child: sidebar, + ), + ), + ), + ), + ), + ), + ), + ), + Expanded( + child: Column( + children: [ + SizedBox( + height: BusyMarkLinuxWindowMetrics.headerHeight, + child: LinuxPageHeaderInsetsScope( + leftObstruction: leftObstruction, + rightObstruction: rightObstruction, + child: (mainChild! as _LinuxFrameMain).header, + ), + ), + Expanded(child: (mainChild as _LinuxFrameMain).body), + ], + ), + ), + ], + ); + }, + ); + } +} + +class _LinuxFrameMain extends StatelessWidget { + const _LinuxFrameMain({required this.header, required this.body}); + + final Widget header; + final Widget body; + + @override + Widget build(BuildContext context) => const SizedBox.shrink(); +} + +class BusyMarkLinuxBrandHeader extends StatelessWidget { + const BusyMarkLinuxBrandHeader({super.key}); + + @override + Widget build(BuildContext context) { + return LinuxTitlebarGestureRegion( + child: Center( + child: Text( + context.l10n.appTitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: busyMarkLinuxHeaderBrandStyle(context), + ), + ), + ); + } +} diff --git a/lib/src/app/linux/linux_window_host.dart b/lib/src/app/linux/linux_window_host.dart new file mode 100644 index 00000000..a2dcf52b --- /dev/null +++ b/lib/src/app/linux/linux_window_host.dart @@ -0,0 +1,506 @@ +import 'dart:async'; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:yaru/yaru.dart'; + +import '../localization.dart'; +import '../../platform/gtk_window_preferences_service.dart'; +import '../busymark_design.dart'; +import '../window_control_service.dart'; + +/// Shared dimensions for Flutter-owned Linux chrome. +abstract final class BusyMarkLinuxWindowMetrics { + static const double headerHeight = BusyMarkSizes.toolbarHeight; + static const double controlSize = kYaruWindowControlSize; + static const double controlSpacing = 14; + static const double controlHorizontalPadding = 10; + + static double decorationWidth(GtkWindowDecorationElement decoration) => + switch (decoration) { + GtkWindowDecorationElement.fallbackApplicationMenu => 0, + GtkWindowDecorationElement.windowIcon || + GtkWindowDecorationElement.minimize || + GtkWindowDecorationElement.maximize || + GtkWindowDecorationElement.close => controlSize, + }; + + static double clusterWidth(List decorations) => + decorations.isEmpty + ? 0 + : controlHorizontalPadding * 2 + + decorations.fold(0, (width, item) { + return width + decorationWidth(item); + }) + + controlSpacing * (decorations.length - 1); +} + +class LinuxWindowMetricsScope extends InheritedWidget { + const LinuxWindowMetricsScope({ + super.key, + required this.leftControlInset, + required this.rightControlInset, + required this.windowActive, + required this.preferences, + required super.child, + }); + + final double leftControlInset; + final double rightControlInset; + final bool windowActive; + final GtkWindowPreferences preferences; + + static final LinuxWindowMetricsScope _fallback = LinuxWindowMetricsScope( + leftControlInset: 0, + rightControlInset: 0, + windowActive: true, + preferences: GtkWindowPreferences.defaults(), + child: const SizedBox.shrink(), + ); + + static LinuxWindowMetricsScope of(BuildContext context) => + context.dependOnInheritedWidgetOfExactType() ?? + _fallback; + + @override + bool updateShouldNotify(LinuxWindowMetricsScope oldWidget) => + leftControlInset != oldWidget.leftControlInset || + rightControlInset != oldWidget.rightControlInset || + windowActive != oldWidget.windowActive || + preferences != oldWidget.preferences; +} + +/// Owns the stable, window-level system controls above the route navigator. +/// +/// The overlay contains only the configured control clusters. Application +/// header content remains inside page routes and is therefore covered by +/// ordinary dialog barriers. +class LinuxWindowHost extends ConsumerStatefulWidget { + const LinuxWindowHost({super.key, required this.child}); + + final Widget child; + + @override + ConsumerState createState() => _LinuxWindowHostState(); +} + +class _LinuxWindowHostState extends ConsumerState { + YaruWindowInstance? _window; + Stream? _windowStates; + Brightness? _nativeBrightness; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final brightness = Theme.of(context).brightness; + if (_nativeBrightness != brightness) { + _nativeBrightness = brightness; + unawaited( + const GtkWindowPreferencesService().setPreferDark( + brightness == Brightness.dark, + ), + ); + } + final window = YaruWindow.of(context); + if (identical(window, _window)) return; + _window = window; + _windowStates = window.states(); + } + + @override + Widget build(BuildContext context) { + final preferences = + ref.watch(gtkWindowPreferencesProvider).asData?.value ?? + ref.watch(initialGtkWindowPreferencesProvider) ?? + GtkWindowPreferences.defaults(); + return StreamBuilder( + stream: _windowStates, + builder: (context, snapshot) { + final state = snapshot.data ?? const YaruWindowState(); + final leftDecorations = _meaningfulDecorations( + preferences.decorationLayout.left, + state, + ); + final rightDecorations = _meaningfulDecorations( + preferences.decorationLayout.right, + state, + ); + final leftInset = BusyMarkLinuxWindowMetrics.clusterWidth( + leftDecorations, + ); + final rightInset = BusyMarkLinuxWindowMetrics.clusterWidth( + rightDecorations, + ); + return LinuxWindowMetricsScope( + leftControlInset: leftInset, + rightControlInset: rightInset, + windowActive: state.isActive != false, + preferences: preferences, + child: _LinuxWindowOverlay( + leftDecorations: leftDecorations, + rightDecorations: rightDecorations, + state: state, + child: widget.child, + ), + ); + }, + ); + } +} + +List _meaningfulDecorations( + List decorations, + YaruWindowState state, +) => decorations + .where((decoration) { + return switch (decoration) { + // BusyMark does not register GTK's fallback application-menu model. + GtkWindowDecorationElement.fallbackApplicationMenu => false, + GtkWindowDecorationElement.windowIcon => true, + GtkWindowDecorationElement.minimize => state.isMinimizable != false, + GtkWindowDecorationElement.maximize => + state.isMaximizable != false || + state.isRestorable == true || + state.isMaximized == true || + state.isFullscreen == true, + GtkWindowDecorationElement.close => state.isClosable != false, + }; + }) + .toList(growable: false); + +class _LinuxWindowOverlay extends StatefulWidget { + const _LinuxWindowOverlay({ + required this.leftDecorations, + required this.rightDecorations, + required this.state, + required this.child, + }); + + final List leftDecorations; + final List rightDecorations; + final YaruWindowState state; + final Widget child; + + @override + State<_LinuxWindowOverlay> createState() => _LinuxWindowOverlayState(); +} + +class _LinuxWindowOverlayState extends State<_LinuxWindowOverlay> { + late final OverlayEntry _entry = OverlayEntry(builder: _buildOverlay); + + @override + void didUpdateWidget(covariant _LinuxWindowOverlay oldWidget) { + super.didUpdateWidget(oldWidget); + _entry.markNeedsBuild(); + } + + @override + void dispose() { + _entry.remove(); + _entry.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => Overlay(initialEntries: [_entry]); + + Widget _buildOverlay(BuildContext context) { + return Stack( + children: [ + Positioned.fill(child: widget.child), + if (widget.leftDecorations.isNotEmpty) + Positioned( + key: const ValueKey('linux-window-controls-left'), + left: 0, + top: 0, + height: BusyMarkLinuxWindowMetrics.headerHeight, + child: _LinuxWindowControlCluster( + decorations: widget.leftDecorations, + state: widget.state, + ), + ), + if (widget.rightDecorations.isNotEmpty) + Positioned( + key: const ValueKey('linux-window-controls-right'), + right: 0, + top: 0, + height: BusyMarkLinuxWindowMetrics.headerHeight, + child: _LinuxWindowControlCluster( + decorations: widget.rightDecorations, + state: widget.state, + ), + ), + ], + ); + } +} + +class _LinuxWindowControlCluster extends ConsumerWidget { + const _LinuxWindowControlCluster({ + required this.decorations, + required this.state, + }); + + final List decorations; + final YaruWindowState state; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final foreground = BusyMarkSurfaceColors.of(context).foreground; + return Material( + type: MaterialType.transparency, + child: _LinuxWindowActivationOpacity( + opacity: state.isActive == false ? .5 : 1, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: BusyMarkLinuxWindowMetrics.controlHorizontalPadding, + ), + child: Row( + textDirection: TextDirection.ltr, + mainAxisSize: MainAxisSize.min, + children: [ + for (var index = 0; index < decorations.length; index++) ...[ + if (index > 0) + const SizedBox( + width: BusyMarkLinuxWindowMetrics.controlSpacing, + ), + KeyedSubtree( + key: ValueKey(decorations[index]), + child: _buildDecoration( + context, + ref, + decorations[index], + foreground, + ), + ), + ], + ], + ), + ), + ), + ); + } + + Widget _buildDecoration( + BuildContext context, + WidgetRef ref, + GtkWindowDecorationElement decoration, + Color foreground, + ) { + final window = YaruWindow.of(context); + final material = MaterialLocalizations.of(context); + final iconColor = WidgetStatePropertyAll(foreground); + final restorePresentation = + state.isMaximized == true || state.isFullscreen == true; + return switch (decoration) { + GtkWindowDecorationElement.fallbackApplicationMenu => + const SizedBox.shrink(), + GtkWindowDecorationElement.windowIcon => SizedBox.square( + dimension: BusyMarkLinuxWindowMetrics.controlSize, + child: Center( + child: SvgPicture.asset( + 'assets/branding/busymark_logo.svg', + width: BusyMarkSizes.iconSm, + height: BusyMarkSizes.iconSm, + excludeFromSemantics: true, + ), + ), + ), + GtkWindowDecorationElement.minimize => Tooltip( + message: context.l10n.windowMinimize, + child: _LinuxWindowControl( + iconColor: iconColor, + semanticLabel: context.l10n.windowMinimize, + type: YaruWindowControlType.minimize, + onTap: state.isMinimizable == false ? null : window.minimize, + ), + ), + GtkWindowDecorationElement.maximize => Tooltip( + message: restorePresentation + ? context.l10n.windowRestore + : context.l10n.windowMaximize, + child: _LinuxWindowControl( + iconColor: iconColor, + semanticLabel: restorePresentation + ? context.l10n.windowRestore + : context.l10n.windowMaximize, + type: restorePresentation + ? YaruWindowControlType.restore + : YaruWindowControlType.maximize, + onTap: restorePresentation + ? window.restore + : state.isMaximizable == false + ? null + : window.maximize, + ), + ), + GtkWindowDecorationElement.close => Tooltip( + message: material.closeButtonTooltip, + child: _LinuxWindowControl( + iconColor: iconColor, + semanticLabel: material.closeButtonLabel, + type: YaruWindowControlType.close, + onTap: state.isClosable == false + ? null + : ref.read(windowControlServiceProvider).requestClose, + ), + ), + }; + } +} + +/// Yaru's controls do not consume MediaQuery.disableAnimations. Under reduced +/// motion, remount each icon at its final type and render feedback outside its +/// animated background, keeping the native control's artwork and interaction. +class _LinuxWindowControl extends StatefulWidget { + const _LinuxWindowControl({ + required this.type, + required this.iconColor, + required this.semanticLabel, + required this.onTap, + }); + + final YaruWindowControlType type; + final WidgetStateProperty iconColor; + final String semanticLabel; + final VoidCallback? onTap; + + @override + State<_LinuxWindowControl> createState() => _LinuxWindowControlState(); +} + +class _LinuxWindowControlState extends State<_LinuxWindowControl> { + var _hovered = false; + var _pressed = false; + + @override + Widget build(BuildContext context) { + final reducedMotion = MediaQuery.disableAnimationsOf(context); + final control = YaruWindowControl( + key: reducedMotion ? ValueKey(widget.type) : null, + type: widget.type, + iconColor: widget.iconColor, + semanticLabel: widget.semanticLabel, + onTap: widget.onTap, + backgroundColor: reducedMotion + ? const WidgetStatePropertyAll(BusyMarkLinuxPalette.transparent) + : null, + ); + if (!reducedMotion) return control; + final alpha = widget.onTap == null + ? .05 + : _pressed + ? .2 + : _hovered + ? .15 + : .1; + return MouseRegion( + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() { + _hovered = false; + _pressed = false; + }), + child: Listener( + onPointerDown: (_) => setState(() => _pressed = true), + onPointerUp: (_) => setState(() => _pressed = false), + onPointerCancel: (_) => setState(() => _pressed = false), + child: DecoratedBox( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: alpha), + ), + child: control, + ), + ), + ); + } +} + +class _LinuxWindowActivationOpacity extends StatelessWidget { + const _LinuxWindowActivationOpacity({ + required this.opacity, + required this.child, + }); + + final double opacity; + final Widget child; + + @override + Widget build(BuildContext context) { + const key = ValueKey('linux-window-controls-opacity'); + if (MediaQuery.disableAnimationsOf(context)) { + return Opacity(key: key, opacity: opacity, child: child); + } + return AnimatedOpacity( + key: key, + opacity: opacity, + duration: BusyMarkMotion.fast, + child: child, + ); + } +} + +/// Adds native titlebar gestures only to a page-selected empty header region. +class LinuxTitlebarGestureRegion extends StatelessWidget { + const LinuxTitlebarGestureRegion({super.key, required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + final preferences = LinuxWindowMetricsScope.of(context).preferences; + final window = YaruWindow.of(context); + return Listener( + behavior: HitTestBehavior.translucent, + onPointerDown: (event) { + if (event.kind == PointerDeviceKind.mouse && + event.buttons == kMiddleMouseButton) { + unawaited( + _performTitlebarAction(context, preferences.middleClick, window), + ); + } + }, + child: GestureDetector( + excludeFromSemantics: true, + behavior: HitTestBehavior.translucent, + onPanStart: (_) => unawaited(window.drag()), + onDoubleTap: () => unawaited( + _performTitlebarAction(context, preferences.doubleClick, window), + ), + onSecondaryTap: () => unawaited( + _performTitlebarAction(context, preferences.rightClick, window), + ), + child: child, + ), + ); + } +} + +Future _performTitlebarAction( + BuildContext context, + GtkTitlebarAction action, + YaruWindowInstance window, +) async { + switch (action) { + case GtkTitlebarAction.none: + return; + case GtkTitlebarAction.minimize: + await window.minimize(); + case GtkTitlebarAction.toggleMaximize: + final state = await window.state(); + if (!context.mounted) return; + if (state.isMaximized == true || state.isFullscreen == true) { + await window.restore(); + } else if (state.isMaximizable != false) { + await window.maximize(); + } + case GtkTitlebarAction.menu: + await window.showMenu(); + case GtkTitlebarAction.lower: + await const GtkWindowPreferencesService().lowerWindow(); + } +} diff --git a/lib/src/app/window_control_service.dart b/lib/src/app/window_control_service.dart index 924aa0c5..7350aa30 100644 --- a/lib/src/app/window_control_service.dart +++ b/lib/src/app/window_control_service.dart @@ -75,6 +75,7 @@ class WindowControlService extends ChangeNotifier { final NativeWindowController _nativeWindow; WindowListener? _listener; Future Function()? _onCloseRequest; + Future? _pendingCloseRequest; var _closeInProgress = false; var _initialized = false; var _disposed = false; @@ -169,13 +170,22 @@ class WindowControlService extends ChangeNotifier { await _nativeWindow.close(); } - void _handleNativeCloseRequest() { + Future requestClose() { + if (_disposed) return Future.value(); + final pending = _pendingCloseRequest; + if (pending != null) return pending; final handler = _onCloseRequest; - if (handler != null) { - unawaited(handler()); - } + if (handler == null) return Future.value(); + late final Future request; + request = Future.sync(handler).whenComplete(() { + if (identical(_pendingCloseRequest, request)) _pendingCloseRequest = null; + }); + _pendingCloseRequest = request; + return request; } + void _handleNativeCloseRequest() => unawaited(requestClose()); + void _setFullScreen(bool value) { if (_disposed || _fullScreen == value) { return; diff --git a/lib/src/markdown/busymark_markdown_serializer.dart b/lib/src/markdown/busymark_markdown_serializer.dart index cf5be694..f20b55f2 100644 --- a/lib/src/markdown/busymark_markdown_serializer.dart +++ b/lib/src/markdown/busymark_markdown_serializer.dart @@ -66,11 +66,30 @@ class BusyMarkMarkdownSerializer { BusyInline inline, BusyInline? following, { required bool insideFormatting, - }) => - insideFormatting && - _canEmitBareUrl(inline) && - following?.kind == BusyInlineKind.text && - _bareUrlLiteralSuffixLength(inline, following!.text) > 0; + }) { + if (!_canEmitBareUrl(inline) || following == null) return false; + // A following formatting subtree emits its delimiters before the visible + // suffix. Those delimiters and the suffix's escapes can enter the URL. + // Use its effective content, including nested wrappers, for the boundary. + final text = following.plainText; + if (_urlFormattingKind(following.kind)) { + return text.isNotEmpty && !RegExp(r'^\s').hasMatch(text); + } + if (following.kind != BusyInlineKind.text || text.isEmpty) return false; + if (_bareUrlLiteralSuffixLength(inline, text) > 0) { + return insideFormatting; + } + // Normal literal escaping is still required (especially for '©'). + // If that source extends the autolink, protect the semantic boundary with + // an explicit label rather than expose an escape or decode an entity. + final ranges = busyMarkBareUrlRanges( + '${inline.plainText}${_escapeInlineText(text)}', + ); + return ranges.isEmpty || + ranges.first.start != 0 || + ranges.first.end != inline.plainText.length || + ranges.first.destination != inline.destination; + } /// Serializes an inline fragment without document-level trimming or a final /// newline. This is also the single entry point for context-sensitive inline @@ -706,6 +725,7 @@ class BusyMarkMarkdownSerializer { bool bareUrlText = false, bool formattedUrlLabel = false, bool insideFormatting = false, + bool insideLinkLabel = false, }) { final totalTextLength = inlines.fold( 0, @@ -849,12 +869,20 @@ class BusyMarkMarkdownSerializer { ), formattedUrlLabel: formattedUrlLabel, insideFormatting: insideFormatting, + insideLinkLabel: insideLinkLabel, entityOffsets: { // Keep the boundary '<' literal without forming HTML or an angle // autolink. The following reference also keeps this literal slice // distinct from decoded raw HTML in the AST adapter. if (angleEntityOffset >= 0 && angleEntityOffset < inline.text.length) angleEntityOffset, + // A plain URL has not acquired link semantics yet. Source history + // reparses Markdown, so encode its initial character to prevent an + // autolink from consuming literal escapes or recognizing it early. + if (!bareUrlText && + !insideLinkLabel && + inline.kind == BusyInlineKind.text) + for (final range in busyMarkBareUrlRanges(inline.text)) range.start, // A reference beside formatting makes it a punctuation boundary // while retaining the displayed URL character. if (formattedUrlLabel && @@ -940,6 +968,7 @@ class BusyMarkMarkdownSerializer { required bool forceExplicitBareUrl, required bool formattedUrlLabel, required bool insideFormatting, + required bool insideLinkLabel, required Set entityOffsets, }) { final bareUrl = _canEmitBareUrl(inline) && !forceExplicitBareUrl; @@ -976,6 +1005,8 @@ class BusyMarkMarkdownSerializer { formattedUrlLabel: formattedUrlLabel || formattedLinkLabel, insideFormatting: insideFormatting || _urlFormattingKind(inline.kind), + insideLinkLabel: + insideLinkLabel || inline.kind == BusyInlineKind.link, ); final children = inline.children.isEmpty ? rawText diff --git a/lib/src/platform/gtk_animation_settings_service.dart b/lib/src/platform/gtk_animation_settings_service.dart new file mode 100644 index 00000000..204830ea --- /dev/null +++ b/lib/src/platform/gtk_animation_settings_service.dart @@ -0,0 +1,60 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// Bridges GTK's `gtk-enable-animations` setting into Flutter. +class GtkAnimationSettingsService { + const GtkAnimationSettingsService({ + MethodChannel channel = _settingsChannel, + EventChannel events = _eventsChannel, + }) : _channel = channel, + _events = events; + + static const _settingsChannel = MethodChannel( + 'com.busymark.app/linux_chrome', + ); + static const _eventsChannel = EventChannel( + 'com.busymark.app/gtk_animation_settings', + ); + + final MethodChannel _channel; + final EventChannel _events; + + Future getAnimationsEnabled() async { + try { + return await _channel.invokeMethod('getGtkAnimationsEnabled'); + } on MissingPluginException { + return null; + } on PlatformException { + return null; + } + } + + Stream watchAnimationsEnabled() async* { + try { + await for (final value in _events.receiveBroadcastStream()) { + yield value is bool ? value : null; + } + } on MissingPluginException { + yield null; + } on PlatformException { + yield null; + } + } +} + +final initialGtkAnimationsEnabledProvider = Provider((ref) => null); + +final gtkAnimationsEnabledProvider = StreamProvider((ref) async* { + var previous = ref.watch(initialGtkAnimationsEnabledProvider); + yield previous; + // Linux bootstrap supplies a concrete initial value only when the native + // bridge exists. Avoid activating an EventChannel on widget-test and + // non-Linux engines where no plugin is registered. + if (previous == null) return; + await for (final value + in const GtkAnimationSettingsService().watchAnimationsEnabled()) { + if (value == previous) continue; + previous = value; + yield value; + } +}); diff --git a/lib/src/platform/gtk_header_icon_service.dart b/lib/src/platform/gtk_header_icon_service.dart new file mode 100644 index 00000000..6984c24d --- /dev/null +++ b/lib/src/platform/gtk_header_icon_service.dart @@ -0,0 +1,352 @@ +import 'dart:async'; +import 'dart:collection'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +import '../app/busymark_glyphs.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +const gtkHeaderIconsChannelName = 'com.busymark.app/gtk_header_icons'; +const gtkHeaderIconsChangedChannelName = + 'com.busymark.app/gtk_header_icons_changed'; + +@immutable +final class GtkHeaderIconAsset { + const GtkHeaderIconAsset({ + required this.bytes, + required this.resolvedName, + required this.scale, + required this.pixelWidth, + required this.pixelHeight, + }); + + final Uint8List bytes; + final String resolvedName; + final int scale; + final int pixelWidth; + final int pixelHeight; +} + +enum BusyMarkLinuxHeaderIcon { + back, + sidebar, + search, + searchEntryFind, + searchClear, + mainMenu, + validate, + viewEditor, + viewSource, + viewPreview, + viewSplit, + viewMenuArrow, +} + +extension BusyMarkLinuxHeaderIconNames on BusyMarkLinuxHeaderIcon { + List get gtkNames => switch (this) { + BusyMarkLinuxHeaderIcon.back => const ['go-previous-symbolic'], + BusyMarkLinuxHeaderIcon.sidebar => const ['sidebar-show-symbolic'], + BusyMarkLinuxHeaderIcon.search => const ['system-search-symbolic'], + BusyMarkLinuxHeaderIcon.searchEntryFind => const ['edit-find-symbolic'], + BusyMarkLinuxHeaderIcon.searchClear => const ['edit-clear-symbolic'], + BusyMarkLinuxHeaderIcon.mainMenu => const ['open-menu-symbolic'], + BusyMarkLinuxHeaderIcon.validate => const ['tools-check-spelling-symbolic'], + BusyMarkLinuxHeaderIcon.viewEditor => const ['document-edit-symbolic'], + BusyMarkLinuxHeaderIcon.viewSource => const ['text-x-generic-symbolic'], + BusyMarkLinuxHeaderIcon.viewPreview => const [ + 'view-reader-symbolic', + 'document-open-symbolic', + ], + BusyMarkLinuxHeaderIcon.viewSplit => const ['view-dual-symbolic'], + BusyMarkLinuxHeaderIcon.viewMenuArrow => const ['pan-down-symbolic'], + }; + bool get directionSensitive => + this == BusyMarkLinuxHeaderIcon.back || + this == BusyMarkLinuxHeaderIcon.sidebar; + bool get allowMissing => true; +} + +BusyMarkLinuxHeaderIcon? _nativeHeaderIcon(IconData glyph) => switch (glyph) { + BusyMarkGlyphs.home => BusyMarkLinuxHeaderIcon.back, + BusyMarkGlyphs.sidebar => BusyMarkLinuxHeaderIcon.sidebar, + BusyMarkGlyphs.search => BusyMarkLinuxHeaderIcon.search, + BusyMarkGlyphs.clear => BusyMarkLinuxHeaderIcon.searchClear, + BusyMarkGlyphs.menuVertical => BusyMarkLinuxHeaderIcon.mainMenu, + BusyMarkGlyphs.diagnostics => BusyMarkLinuxHeaderIcon.validate, + BusyMarkGlyphs.editorView => BusyMarkLinuxHeaderIcon.viewEditor, + BusyMarkGlyphs.sourceView => BusyMarkLinuxHeaderIcon.viewSource, + BusyMarkGlyphs.previewView => BusyMarkLinuxHeaderIcon.viewPreview, + BusyMarkGlyphs.splitView => BusyMarkLinuxHeaderIcon.viewSplit, + _ => null, +}; + +/// Native symbolic artwork, with the existing Yaru glyph as a test/platform fallback. +class BusyMarkGtkHeaderIcon extends StatelessWidget { + const BusyMarkGtkHeaderIcon(this.glyph, {super.key}); + final IconData glyph; + @override + Widget build(BuildContext context) { + final icon = _nativeHeaderIcon(glyph); + final asset = icon == null + ? null + : GtkHeaderIconScope.of( + context, + ).catalog.assetFor(icon, Directionality.of(context)); + if (asset == null) return Icon(glyph, size: 16); + return ExcludeSemantics( + child: Image.memory( + asset.bytes, + width: 16, + height: 16, + scale: asset.scale.toDouble(), + color: IconTheme.of(context).color, + colorBlendMode: BlendMode.srcIn, + filterQuality: FilterQuality.none, + gaplessPlayback: true, + ), + ); + } +} + +final class GtkHeaderIconCatalog { + GtkHeaderIconCatalog( + Map assets, { + required this.revision, + }) : assets = UnmodifiableMapView(assets); + + GtkHeaderIconCatalog.empty() : assets = const {}, revision = 0; + + final Map assets; + final int revision; + + int? get scale => assets.values.firstOrNull?.scale; + + GtkHeaderIconAsset? assetFor( + BusyMarkLinuxHeaderIcon icon, + TextDirection direction, + ) => assets[_catalogKey(icon, direction)]; +} + +typedef GtkHeaderIconBatchLoader = + Future Function(List> requests); + +/// Cached Linux application-header artwork supplied by GTK's active icon +/// theme. A replacement catalog is published only after its full batch loads. +class GtkHeaderIconService extends ChangeNotifier { + GtkHeaderIconService({ + MethodChannel methodChannel = const MethodChannel( + gtkHeaderIconsChannelName, + ), + EventChannel changedEvents = const EventChannel( + gtkHeaderIconsChangedChannelName, + ), + }) : _loadBatch = ((requests) => + methodChannel.invokeMethod('loadIcons', requests)), + _changedEvents = changedEvents.receiveBroadcastStream(); + + @visibleForTesting + GtkHeaderIconService.testing({ + GtkHeaderIconCatalog? initialCatalog, + GtkHeaderIconBatchLoader? loadBatch, + Stream? changedEvents, + }) : _catalog = initialCatalog ?? GtkHeaderIconCatalog.empty(), + _loadBatch = loadBatch, + _changedEvents = changedEvents; + + GtkHeaderIconService.fallback() : _loadBatch = null, _changedEvents = null; + + static final fallbackInstance = GtkHeaderIconService.fallback(); + + final GtkHeaderIconBatchLoader? _loadBatch; + final Stream? _changedEvents; + GtkHeaderIconCatalog _catalog = GtkHeaderIconCatalog.empty(); + StreamSubscription? _subscription; + bool _reloadRunning = false; + int? _queuedRevision; + int _highestSeenRevision = 0; + int _reloadCount = 0; + bool _disposed = false; + + GtkHeaderIconCatalog get catalog => _catalog; + + @visibleForTesting + int get reloadCount => _reloadCount; + + Future initialize({ + Duration timeout = const Duration(seconds: 5), + }) async { + final loadBatch = _loadBatch; + if (loadBatch == null) return false; + _subscription ??= _changedEvents?.listen( + _handleInvalidation, + onError: (Object error, StackTrace stackTrace) { + debugPrint('GTK header-icon invalidation stream failed: $error'); + }, + ); + _reloadRunning = true; + try { + var targetRevision = 0; + while (true) { + final loaded = await _loadCatalog(targetRevision).timeout(timeout); + if (_disposed) return false; + _catalog = loaded; + notifyListeners(); + final queued = _queuedRevision; + _queuedRevision = null; + if (queued == null || queued <= targetRevision) { + return loaded.assets.isNotEmpty; + } + targetRevision = queued; + } + } on Object catch (error) { + debugPrint('GTK header-icon catalog preload failed: $error'); + return false; + } finally { + _reloadRunning = false; + } + } + + void _handleInvalidation(Object? event) { + final revision = switch (event) { + {'revision': final int value} => value, + _ => _highestSeenRevision + 1, + }; + if (revision <= _highestSeenRevision) return; + _highestSeenRevision = revision; + if (_reloadRunning) { + _queuedRevision = revision; + return; + } + unawaited(_reloadFromInvalidation(revision)); + } + + Future _reloadFromInvalidation(int revision) async { + _reloadRunning = true; + var targetRevision = revision; + try { + while (!_disposed) { + try { + final replacement = await _loadCatalog(targetRevision); + if (_disposed) return; + _catalog = replacement; + notifyListeners(); + } on Object catch (error) { + debugPrint('GTK header-icon catalog refresh failed: $error'); + } + final queued = _queuedRevision; + _queuedRevision = null; + if (queued == null || queued <= targetRevision) break; + targetRevision = queued; + } + } finally { + _reloadRunning = false; + } + } + + Future _loadCatalog(int revision) async { + final loadBatch = _loadBatch; + if (loadBatch == null) return GtkHeaderIconCatalog.empty(); + _reloadCount += 1; + final response = await loadBatch(_requests()); + if (response is! Map) { + throw const FormatException('GTK header-icon response was not a map.'); + } + final assets = {}; + for (final entry in response.entries) { + final key = entry.key; + final value = entry.value; + if (key is! String || value is! Map) continue; + final bytes = value['bytes']; + final resolvedName = value['resolvedName']; + final scale = value['scale']; + final pixelWidth = value['pixelWidth']; + final pixelHeight = value['pixelHeight']; + if (bytes is! Uint8List || + resolvedName is! String || + scale is! int || + pixelWidth is! int || + pixelHeight is! int || + bytes.isEmpty || + scale < 1 || + pixelWidth < 1 || + pixelHeight < 1) { + continue; + } + assets[key] = GtkHeaderIconAsset( + bytes: bytes, + resolvedName: resolvedName, + scale: scale, + pixelWidth: pixelWidth, + pixelHeight: pixelHeight, + ); + } + if (assets.isEmpty) { + throw const FormatException('GTK returned no usable header icons.'); + } + return GtkHeaderIconCatalog(assets, revision: revision); + } + + static List> _requests() { + return [ + for (final icon in BusyMarkLinuxHeaderIcon.values) + for (final direction + in icon.directionSensitive + ? TextDirection.values + : const [TextDirection.ltr]) + { + 'key': _catalogKey(icon, direction), + 'names': icon.gtkNames, + 'direction': direction == TextDirection.rtl ? 'rtl' : 'ltr', + 'allowMissing': icon.allowMissing, + }, + ]; + } + + @override + void dispose() { + _disposed = true; + unawaited(_subscription?.cancel()); + _subscription = null; + super.dispose(); + } +} + +String _catalogKey(BusyMarkLinuxHeaderIcon icon, TextDirection direction) { + final effectiveDirection = icon.directionSensitive + ? direction + : TextDirection.ltr; + return '${icon.name}.${effectiveDirection.name}'; +} + +final gtkHeaderIconServiceProvider = Provider((ref) { + final service = GtkHeaderIconService.fallback(); + ref.onDispose(service.dispose); + return service; +}); + +class GtkHeaderIconScope extends InheritedNotifier { + const GtkHeaderIconScope({ + super.key, + required GtkHeaderIconService service, + required super.child, + }) : super(notifier: service); + + static GtkHeaderIconService of(BuildContext context) { + return context + .dependOnInheritedWidgetOfExactType() + ?.notifier ?? + GtkHeaderIconService.fallbackInstance; + } +} + +String resolvedNativeHeaderIconName( + BuildContext context, + BusyMarkLinuxHeaderIcon icon, +) { + final asset = GtkHeaderIconScope.of( + context, + ).catalog.assetFor(icon, Directionality.of(context)); + return asset?.resolvedName ?? icon.gtkNames.first; +} diff --git a/lib/src/platform/gtk_window_preferences_service.dart b/lib/src/platform/gtk_window_preferences_service.dart new file mode 100644 index 00000000..fbf8f4fc --- /dev/null +++ b/lib/src/platform/gtk_window_preferences_service.dart @@ -0,0 +1,198 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +enum GtkTitlebarAction { none, minimize, toggleMaximize, menu, lower } + +@immutable +final class GtkDecorationLayout { + const GtkDecorationLayout({required this.left, required this.right}); + + factory GtkDecorationLayout.parse(String? value) { + final sides = (value ?? 'menu:minimize,maximize,close').split(':'); + final left = sides.isEmpty ? '' : sides.first; + final right = sides.length < 2 ? '' : sides[1]; + return GtkDecorationLayout( + left: _parseControls(left), + right: _parseControls(right), + ); + } + + final List left; + final List right; + + @override + bool operator ==(Object other) => + other is GtkDecorationLayout && + listEquals(other.left, left) && + listEquals(other.right, right); + + @override + int get hashCode => Object.hash(Object.hashAll(left), Object.hashAll(right)); + + static List _parseControls(String source) { + return List.unmodifiable( + source + .split(',') + .map( + (token) => switch (token.trim()) { + 'menu' => GtkWindowDecorationElement.fallbackApplicationMenu, + 'icon' => GtkWindowDecorationElement.windowIcon, + 'minimize' => GtkWindowDecorationElement.minimize, + 'maximize' => GtkWindowDecorationElement.maximize, + 'close' => GtkWindowDecorationElement.close, + _ => null, + }, + ) + .whereType(), + ); + } +} + +/// An element from GTK 3's physical `gtk-decoration-layout` setting. +/// +/// The fallback application menu is intentionally distinct from +/// [GtkTitlebarAction.menu], which requests the window-manager menu. +enum GtkWindowDecorationElement { + fallbackApplicationMenu, + windowIcon, + minimize, + maximize, + close, +} + +@immutable +final class GtkWindowPreferences { + const GtkWindowPreferences({ + required this.decorationLayout, + required this.doubleClick, + required this.middleClick, + required this.rightClick, + }); + + factory GtkWindowPreferences.fromMessage(Map message) { + return GtkWindowPreferences( + decorationLayout: GtkDecorationLayout.parse( + message['decorationLayout'] as String?, + ), + doubleClick: _parseAction(message['doubleClick'] as String?), + middleClick: _parseAction(message['middleClick'] as String?), + rightClick: _parseAction(message['rightClick'] as String?), + ); + } + + factory GtkWindowPreferences.defaults() => GtkWindowPreferences( + decorationLayout: GtkDecorationLayout.parse(null), + doubleClick: GtkTitlebarAction.toggleMaximize, + middleClick: GtkTitlebarAction.none, + rightClick: GtkTitlebarAction.menu, + ); + + final GtkDecorationLayout decorationLayout; + final GtkTitlebarAction doubleClick; + final GtkTitlebarAction middleClick; + final GtkTitlebarAction rightClick; + + @override + bool operator ==(Object other) => + other is GtkWindowPreferences && + other.decorationLayout == decorationLayout && + other.doubleClick == doubleClick && + other.middleClick == middleClick && + other.rightClick == rightClick; + + @override + int get hashCode => + Object.hash(decorationLayout, doubleClick, middleClick, rightClick); + + static GtkTitlebarAction _parseAction(String? value) => switch (value) { + 'minimize' => GtkTitlebarAction.minimize, + 'toggle-maximize' => GtkTitlebarAction.toggleMaximize, + 'menu' => GtkTitlebarAction.menu, + 'lower' => GtkTitlebarAction.lower, + _ => GtkTitlebarAction.none, + }; +} + +class GtkWindowPreferencesService { + const GtkWindowPreferencesService({ + MethodChannel channel = const MethodChannel( + 'com.busymark.app/linux_chrome', + ), + EventChannel events = const EventChannel( + 'com.busymark.app/gtk_window_preferences', + ), + }) : _channel = channel, + _events = events; + + final MethodChannel _channel; + final EventChannel _events; + + Future load() async { + try { + final value = await _channel.invokeMapMethod( + 'getGtkWindowPreferences', + ); + return value == null ? null : GtkWindowPreferences.fromMessage(value); + } on MissingPluginException { + return null; + } on PlatformException { + return null; + } + } + + Stream watch() async* { + try { + await for (final value in _events.receiveBroadcastStream()) { + if (value is Map) { + yield GtkWindowPreferences.fromMessage(value); + } + } + } on MissingPluginException { + yield null; + } on PlatformException { + yield null; + } + } + + Future lowerWindow() async { + try { + await _channel.invokeMethod('lowerWindow'); + } on MissingPluginException { + // Tests and non-Linux compositions do not expose the GTK bridge. + } on PlatformException { + // The compositor may refuse lowering; leave the window unchanged. + } + } + + Future setPreferDark(bool dark) async { + try { + await _channel.invokeMethod('setPreferDark', dark); + } on MissingPluginException { + // Widget tests and other platforms have no GTK surface. + } on PlatformException { + // Keep the Flutter theme available if the compositor rejects a setting. + } + } +} + +final initialGtkWindowPreferencesProvider = Provider( + (ref) => null, +); + +final gtkWindowPreferencesProvider = StreamProvider(( + ref, +) async* { + final initial = ref.watch(initialGtkWindowPreferencesProvider); + var previous = initial ?? GtkWindowPreferences.defaults(); + yield previous; + // Linux bootstrap supplies a concrete initial value only when the native + // bridge exists. Avoid activating an EventChannel on widget-test and + // non-Linux engines where no plugin is registered. + if (initial == null) return; + await for (final update in const GtkWindowPreferencesService().watch()) { + if (update == null || update == previous) continue; + previous = update; + yield update; + } +}); diff --git a/lib/src/workspace/presentation/settings_screen.dart b/lib/src/workspace/presentation/settings_screen.dart index 10a078e9..ff69d426 100644 --- a/lib/src/workspace/presentation/settings_screen.dart +++ b/lib/src/workspace/presentation/settings_screen.dart @@ -17,6 +17,9 @@ import '../../app/app_settings.dart'; import '../../app/app_locale.dart'; import '../../app/busymark_dialogs.dart'; import '../../app/busymark_design.dart'; +import '../../app/busymark_motion_widgets.dart'; +import '../../app/linux/linux_page_frame.dart'; +import '../../app/linux/linux_header_style.dart'; import '../../app/busymark_glyphs.dart'; import '../../app/busymark_main_menu.dart'; import '../../app/busymark_shortcuts.dart'; @@ -92,7 +95,6 @@ class _SettingsScreenState extends ConsumerState { _prepareSpellingSettings(spelling, workspace?.id); final colors = BusyMarkSurfaceColors.of(context); final headerBar = ref.watch(linuxHeaderBarServiceProvider); - final useNativeHeaderBar = headerBar.usesNativeHeaderBar; final title = _settingsPageLabel(context, _page); ref.listen(headerBarActionsProvider, (previous, next) { next.whenData((event) { @@ -470,13 +472,6 @@ class _SettingsScreenState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - if (!useNativeHeaderBar) - _SettingsFallbackHeader( - title: title, - onBack: _goBack, - onMenuSelected: (action) => - _handleMainMenuAction(context, headerBar, action), - ), if (!showSidebar) Padding( padding: const EdgeInsets.fromLTRB( @@ -495,27 +490,31 @@ class _SettingsScreenState extends ConsumerState { maxWidth: BusyMarkSizes.settingsWidth, margin: EdgeInsets.zero, padding: BusyMarkInsets.settingsPage, - child: pageBody, + child: BusyMarkKeyedCrossfade( + transitionKey: _page, + child: pageBody, + ), ), ), ], ), ); - final body = showSidebar - ? Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - SizedBox( - width: BusyMarkSizes.sidebarWidth, - child: _SettingsSidebar( - selected: _settingsNavigationSelection(_page), - onSelected: _selectPage, - ), - ), - Expanded(child: content), - ], - ) - : content; + final body = LinuxPageFrame( + header: _SettingsFallbackHeader( + title: title, + onBack: _goBack, + onMenuSelected: (action) => + _handleMainMenuAction(context, headerBar, action), + ), + sidebarHeader: const BusyMarkLinuxBrandHeader(), + sidebarBody: _SettingsSidebar( + selected: _settingsNavigationSelection(_page), + onSelected: _selectPage, + ), + sidebarAvailable: showSidebar, + sidebarExpanded: true, + body: content, + ); return PopScope( canPop: false, @@ -1382,6 +1381,7 @@ class _SettingsSidebar extends StatelessWidget { @override Widget build(BuildContext context) { return BusyMarkSidebarSurface( + showEndBorder: false, child: BusyMarkSidebarNavigation( children: [ for (final page in _primarySettingsPages) @@ -1472,37 +1472,15 @@ class _SettingsFallbackHeader extends StatelessWidget { @override Widget build(BuildContext context) { - final colors = BusyMarkSurfaceColors.of(context); - return Material( - color: colors.window, - child: SizedBox( - height: BusyMarkSizes.toolbarHeight, - child: Row( - children: [ - const SizedBox(width: BusyMarkSpacing.sm), - BusyMarkHeaderIconButton( - tooltip: context.l10n.back, - icon: BusyMarkGlyphs.backFor(Directionality.of(context)), - shortcut: BusyMarkAppShortcutLabels.back, - onPressed: onBack, - ), - const SizedBox(width: BusyMarkSpacing.sm), - Expanded( - child: Text( - title, - textAlign: TextAlign.center, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of( - context, - ).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w700), - ), - ), - BusyMarkMainMenuButton(onSelected: onMenuSelected), - const SizedBox(width: BusyMarkSpacing.sm), - ], - ), + return BusyMarkLinuxHeaderLayout( + leading: BusyMarkHeaderIconButton( + tooltip: context.l10n.back, + icon: BusyMarkGlyphs.backFor(Directionality.of(context)), + shortcut: BusyMarkAppShortcutLabels.back, + onPressed: onBack, ), + title: BusyMarkLinuxHeaderTitle(title), + trailing: BusyMarkMainMenuButton(onSelected: onMenuSelected), ); } } diff --git a/lib/src/workspace/presentation/welcome_screen.dart b/lib/src/workspace/presentation/welcome_screen.dart index 32496f44..ece437a7 100644 --- a/lib/src/workspace/presentation/welcome_screen.dart +++ b/lib/src/workspace/presentation/welcome_screen.dart @@ -15,6 +15,8 @@ import '../../app/app_settings.dart'; import '../../app/app_router.dart'; import '../../app/busymark_dialogs.dart'; import '../../app/busymark_design.dart'; +import '../../app/linux/linux_page_frame.dart'; +import '../../app/linux/linux_header_style.dart'; import '../../app/busymark_glyphs.dart'; import '../../app/busymark_main_menu.dart'; import '../../app/busymark_shortcuts.dart'; @@ -50,7 +52,6 @@ class _WelcomeScreenState extends ConsumerState { final settings = ref.watch(appSettingsControllerProvider); final headerBar = ref.watch(linuxHeaderBarServiceProvider); final colors = BusyMarkSurfaceColors.of(context); - final useNativeHeaderBar = headerBar.usesNativeHeaderBar; final sidebarVisible = settings.sidebarVisible; ref.listen(headerBarActionsProvider, (previous, next) { next.whenData((event) { @@ -98,86 +99,79 @@ class _WelcomeScreenState extends ConsumerState { onRecentAction: _performRecentAction, ), ); - final welcomeContent = Expanded( - child: ColoredBox( - color: welcomeMainColor, - child: BusyMarkClamp( - maxWidth: BusyMarkSizes.contentWidth, - margin: EdgeInsets.zero, - padding: BusyMarkInsets.welcomePage, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - BusyMarkGroupedList( - title: context.l10n.create, - filled: true, - children: [ - BusyMarkActionRow( - title: context.l10n.createMarkdownFile, - subtitle: context.l10n.createMarkdownFileDescription, - leading: const Icon(BusyMarkGlyphs.newDocument), - trailing: Icon( - BusyMarkGlyphs.forwardFor(Directionality.of(context)), - ), - onTap: _createMarkdownFile, + final welcomeContent = ColoredBox( + color: welcomeMainColor, + child: BusyMarkClamp( + maxWidth: BusyMarkSizes.contentWidth, + margin: EdgeInsets.zero, + padding: BusyMarkInsets.welcomePage, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + BusyMarkGroupedList( + title: context.l10n.create, + filled: true, + children: [ + BusyMarkActionRow( + title: context.l10n.createMarkdownFile, + subtitle: context.l10n.createMarkdownFileDescription, + leading: const Icon(BusyMarkGlyphs.newDocument), + trailing: Icon( + BusyMarkGlyphs.forwardFor(Directionality.of(context)), ), - BusyMarkActionRow( - title: context.l10n.createWritersideProject, - subtitle: context.l10n.createWritersideProjectDescription, - leading: const Icon(BusyMarkGlyphs.writersideProject), - trailing: Icon( - BusyMarkGlyphs.forwardFor(Directionality.of(context)), - ), - onTap: _createWritersideProject, + onTap: _createMarkdownFile, + ), + BusyMarkActionRow( + title: context.l10n.createWritersideProject, + subtitle: context.l10n.createWritersideProjectDescription, + leading: const Icon(BusyMarkGlyphs.writersideProject), + trailing: Icon( + BusyMarkGlyphs.forwardFor(Directionality.of(context)), ), - ], - ), - BusyMarkGroupedList( - title: context.l10n.open, - filled: true, - children: [ - BusyMarkActionRow( - title: context.l10n.openMarkdownFile, - subtitle: context.l10n.markdownFileExtensions, - leading: const Icon(BusyMarkGlyphs.markdownFile), - trailing: Icon( - BusyMarkGlyphs.forwardFor(Directionality.of(context)), - ), - onTap: _chooseMarkdownFile, + onTap: _createWritersideProject, + ), + ], + ), + BusyMarkGroupedList( + title: context.l10n.open, + filled: true, + children: [ + BusyMarkActionRow( + title: context.l10n.openMarkdownFile, + subtitle: context.l10n.markdownFileExtensions, + leading: const Icon(BusyMarkGlyphs.markdownFile), + trailing: Icon( + BusyMarkGlyphs.forwardFor(Directionality.of(context)), ), - BusyMarkActionRow( - title: context.l10n.openFolderOrWritersideProject, - subtitle: context.l10n.markdownFolderOrWritersideProject, - leading: const Icon(BusyMarkGlyphs.folder), - trailing: Icon( - BusyMarkGlyphs.forwardFor(Directionality.of(context)), - ), - onTap: () => _chooseDirectory(context.l10n.open), + onTap: _chooseMarkdownFile, + ), + BusyMarkActionRow( + title: context.l10n.openFolderOrWritersideProject, + subtitle: context.l10n.markdownFolderOrWritersideProject, + leading: const Icon(BusyMarkGlyphs.folder), + trailing: Icon( + BusyMarkGlyphs.forwardFor(Directionality.of(context)), ), - ], - ), - if (state.isLoading) ...[ - const SizedBox(height: BusyMarkSpacing.lg), - const LinearProgressIndicator(), - ], - if (state.message != null) ...[ - const SizedBox(height: BusyMarkSpacing.lg), - BusyMarkStatusBox( - message: localizeWorkspaceMessage(context, state.message!), - kind: busyMarkWorkspaceMessageStatusKind(state.message!.code), + onTap: () => _chooseDirectory(context.l10n.open), ), ], + ), + if (state.isLoading) ...[ + const SizedBox(height: BusyMarkSpacing.lg), + const LinearProgressIndicator(), ], - ), + if (state.message != null) ...[ + const SizedBox(height: BusyMarkSpacing.lg), + BusyMarkStatusBox( + message: localizeWorkspaceMessage(context, state.message!), + kind: busyMarkWorkspaceMessageStatusKind(state.message!.code), + ), + ], + ], ), ), ); - final sidebarOnRight = Directionality.of(context) == TextDirection.rtl; - final bodyChildren = [ - if (!sidebarOnRight && sidebarVisible) welcomeSidebar, - welcomeContent, - if (sidebarOnRight && sidebarVisible) welcomeSidebar, - ]; + final headerConfiguration = HeaderBarConfigurationDefaults.of(context) .copyWith( title: context.l10n.appTitle, @@ -198,36 +192,35 @@ class _WelcomeScreenState extends ConsumerState { enabled: headerBar.isAvailable, child: Scaffold( backgroundColor: welcomeMainColor, - appBar: useNativeHeaderBar - ? null - : AppBar( - title: Text( - context.l10n.appTitle, - style: Theme.of( - context, - ).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w700), - ), - actions: [ - BusyMarkHeaderIconButton( - tooltip: sidebarVisible - ? context.l10n.hideSidebar - : context.l10n.showSidebar, - icon: BusyMarkGlyphs.sidebar, - selected: sidebarVisible, - shortcut: BusyMarkSidebarShortcutLabels.toggleSidebar, - onPressed: _toggleSidebar, - ), - BusyMarkMainMenuButton( - onSelected: (action) => - _handleMainMenuAction(context, headerBar, action), - ), - const SizedBox(width: BusyMarkSpacing.sm), - ], + body: LayoutBuilder( + builder: (context, constraints) => LinuxPageFrame( + sidebarHeader: const BusyMarkLinuxBrandHeader(), + sidebarBody: welcomeSidebar, + sidebarAvailable: + constraints.maxWidth >= + BusyMarkSizes.sidebarWidth + BusyMarkSizes.previewMinWidth, + sidebarExpanded: sidebarVisible, + sidebarTransitionGeneration: ref.watch( + sidebarTransitionGenerationProvider, + ), + header: BusyMarkLinuxHeaderLayout( + leading: BusyMarkHeaderIconButton( + tooltip: sidebarVisible + ? context.l10n.hideSidebar + : context.l10n.showSidebar, + icon: BusyMarkGlyphs.sidebar, + selected: sidebarVisible, + shortcut: BusyMarkSidebarShortcutLabels.toggleSidebar, + onPressed: _toggleSidebar, ), - body: Row( - textDirection: TextDirection.ltr, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: bodyChildren, + title: BusyMarkLinuxHeaderTitle(context.l10n.appTitle), + trailing: BusyMarkMainMenuButton( + onSelected: (action) => + _handleMainMenuAction(context, headerBar, action), + ), + ), + body: welcomeContent, + ), ), ), ); @@ -506,6 +499,7 @@ class _WelcomeSidebar extends StatelessWidget { @override Widget build(BuildContext context) { return BusyMarkSidebarSurface( + showEndBorder: false, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ diff --git a/lib/src/workspace/presentation/workspace_screen.dart b/lib/src/workspace/presentation/workspace_screen.dart index 2e70e781..d1001710 100644 --- a/lib/src/workspace/presentation/workspace_screen.dart +++ b/lib/src/workspace/presentation/workspace_screen.dart @@ -27,6 +27,9 @@ import '../../app/app_settings.dart'; import '../../app/app_router.dart'; import '../../app/busymark_dialogs.dart'; import '../../app/busymark_design.dart'; +import '../../app/busymark_motion_widgets.dart'; +import '../../app/linux/linux_page_frame.dart'; +import '../../app/linux/linux_header_style.dart'; import '../../app/busymark_glyphs.dart'; import '../../app/busymark_main_menu.dart'; import '../../app/busymark_search_field.dart'; @@ -642,6 +645,32 @@ class _SelectSidebarTabIntent extends Intent { final _SidebarTab tab; } +final _headerSearchFocusRequestProvider = + NotifierProvider<_HeaderSearchFocusRequest, int>( + _HeaderSearchFocusRequest.new, + ); + +class _HeaderSearchFocusRequest extends Notifier { + @override + int build() => 0; + void request() => state++; +} + +final _searchReturnFocusProvider = Provider<_SearchReturnFocus>( + (ref) => _SearchReturnFocus(), +); + +class _SearchReturnFocus { + FocusNode? node; + void restore() { + final target = node; + node = null; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (target?.context?.mounted == true) target!.requestFocus(); + }); + } +} + class WorkspaceScreen extends ConsumerWidget { const WorkspaceScreen({super.key}); @@ -662,7 +691,6 @@ class WorkspaceScreen extends ConsumerWidget { final colors = BusyMarkSurfaceColors.of(context); final headerBar = ref.watch(linuxHeaderBarServiceProvider); - final useNativeHeaderBar = headerBar.usesNativeHeaderBar; final settingsController = ref.read(appSettingsControllerProvider.notifier); final sidebarVisible = settings.sidebarVisible && _hasWorkspaceSidebar(workspace); @@ -672,6 +700,7 @@ class WorkspaceScreen extends ConsumerWidget { final sidebar = SizedBox( width: BusyMarkSizes.sidebarWidth, child: _Sidebar( + key: ValueKey(workspace.id), workspace: workspace, outline: documentOutline, searchState: searchState, @@ -679,59 +708,51 @@ class WorkspaceScreen extends ConsumerWidget { onOpenSearchResult: (result) => _openSearchResult(context, ref, result), ), ); - final workspaceContent = Expanded( - child: Column( - children: [ - if (_shouldShowEditorTabs(state, gitState, localHistoryState)) - _EditorTabStrip( - state: state, - gitState: gitState, - localHistoryState: localHistoryState, - ), - Expanded( - child: localHistoryState.selectedRevision != null - ? const LocalHistoryComparisonView() - : gitState.selectedDiffForDisplay == null - ? _EditorPreviewSplit( - state: state, - outline: documentOutline, - viewMode: documentViewMode, - editorFontSize: settings.editorFontSize, - editorToolbarPlacement: settings.editorToolbarPlacement, - editorToolbarDirection: settings.editorToolbarDirection, - wordWrap: settings.wordWrap, - ) - : _GitDiffDocumentView( - diff: gitState.selectedDiffForDisplay, - comparisonLabel: _gitDiffComparisonLabel(context, gitState), - comparisonType: gitState.selectedView == GitView.fileHistory - ? gitState.fileHistory.comparisonType - : null, - comparisonEnabled: !gitState.isRunningOperation, - onComparisonTypeChanged: - gitState.selectedView == GitView.fileHistory - ? (comparison) => unawaited( - _selectFileHistoryComparison(ref, comparison), - ) - : null, - openFilePath: gitState.selectedDiffOpenFilePath, - workspace: workspace, - viewMode: documentViewMode, - hasUnsavedEditorChanges: state.isDirty, - editorFontSize: settings.editorFontSize, - onOpenFile: (relativePath) => - _openGitDiffFile(context, ref, relativePath), - ), + final workspaceContent = Column( + children: [ + if (_shouldShowEditorTabs(state, gitState, localHistoryState)) + _EditorTabStrip( + state: state, + gitState: gitState, + localHistoryState: localHistoryState, ), - ], - ), + Expanded( + child: localHistoryState.selectedRevision != null + ? const LocalHistoryComparisonView() + : gitState.selectedDiffForDisplay == null + ? _EditorPreviewSplit( + state: state, + outline: documentOutline, + viewMode: documentViewMode, + editorFontSize: settings.editorFontSize, + editorToolbarPlacement: settings.editorToolbarPlacement, + editorToolbarDirection: settings.editorToolbarDirection, + wordWrap: settings.wordWrap, + ) + : _GitDiffDocumentView( + diff: gitState.selectedDiffForDisplay, + comparisonLabel: _gitDiffComparisonLabel(context, gitState), + comparisonType: gitState.selectedView == GitView.fileHistory + ? gitState.fileHistory.comparisonType + : null, + comparisonEnabled: !gitState.isRunningOperation, + onComparisonTypeChanged: + gitState.selectedView == GitView.fileHistory + ? (comparison) => unawaited( + _selectFileHistoryComparison(ref, comparison), + ) + : null, + openFilePath: gitState.selectedDiffOpenFilePath, + workspace: workspace, + viewMode: documentViewMode, + hasUnsavedEditorChanges: state.isDirty, + editorFontSize: settings.editorFontSize, + onOpenFile: (relativePath) => + _openGitDiffFile(context, ref, relativePath), + ), + ), + ], ); - final sidebarOnRight = Directionality.of(context) == TextDirection.rtl; - final workspaceChildren = [ - if (!sidebarOnRight && sidebarVisible) sidebar, - workspaceContent, - if (sidebarOnRight && sidebarVisible) sidebar, - ]; ref.listen(headerBarActionsProvider, (previous, next) { next.whenData((event) { _handleHeaderBarAction(context, ref, event.action); @@ -821,7 +842,7 @@ class WorkspaceScreen extends ConsumerWidget { final headerConfiguration = HeaderBarConfigurationDefaults.of(context) .copyWith( title: busyMarkBidiIsolateFor(context, title), - viewMode: _headerBarViewMode(settings.documentViewMode), + viewMode: _headerBarViewMode(documentViewMode), searchQuery: searchState.query, canRefresh: true, canExportPdf: canExportPdf, @@ -901,167 +922,185 @@ class WorkspaceScreen extends ConsumerWidget { autofocus: true, child: Scaffold( backgroundColor: colors.window, - appBar: useNativeHeaderBar - ? null - : AppBar( - automaticallyImplyLeading: false, - leading: searchState.active - ? null - : Center( - child: BusyMarkHeaderIconButton( - tooltip: context.l10n.welcome, - icon: BusyMarkGlyphs.home, - shortcut: - commandRegistry[BusyMarkCommandIds.back] - ?.shortcut - ?.label, - onPressed: () async { - final router = GoRouter.of(context); - if (await confirmSafeToContinue( - context, - ref, - )) { - router.go('/'); - } - }, - ), + body: LayoutBuilder( + builder: (context, constraints) { + final sidebarAvailable = + constraints.maxWidth >= + BusyMarkSizes.workspaceSidebarBreakpoint; + return LinuxPageFrame( + key: const ValueKey('workspace-page-frame'), + sidebarHeader: const BusyMarkLinuxBrandHeader(), + sidebarBody: sidebar, + sidebarAvailable: sidebarAvailable, + sidebarExpanded: sidebarVisible, + sidebarTransitionGeneration: ref.watch( + sidebarTransitionGenerationProvider, + ), + header: BusyMarkLinuxHeaderLayout( + centerAllocation: searchState.active + ? BusyMarkLinuxHeaderCenterAllocation + .fillBetweenControls + : BusyMarkLinuxHeaderCenterAllocation.centered, + leading: Row( + mainAxisSize: MainAxisSize.min, + children: [ + BusyMarkHeaderIconButton( + tooltip: sidebarAvailable && settings.sidebarVisible + ? context.l10n.hideSidebar + : context.l10n.showSidebar, + icon: BusyMarkGlyphs.sidebar, + selected: + sidebarAvailable && settings.sidebarVisible, + shortcut: + commandRegistry[BusyMarkCommandIds + .toggleSidebar] + ?.shortcut + ?.label, + onPressed: !sidebarAvailable + ? null + : () { + final visible = !settings.sidebarVisible; + unawaited( + settingsController.setSidebarVisible( + visible, + ), + ); + }, + ), + if (!searchState.active) + BusyMarkHeaderIconButton( + tooltip: context.l10n.welcome, + icon: BusyMarkGlyphs.home, + shortcut: commandRegistry[BusyMarkCommandIds.back] + ?.shortcut + ?.label, + onPressed: () async { + if (await confirmSafeToContinue(context, ref) && + context.mounted) { + context.go('/'); + } + }, ), - title: searchState.active - ? _HeaderSearchField( - query: searchState.query, - onChanged: (query) => _setSearchQuery(ref, query), - onClear: () => _clearSearchQuery(ref), - onSubmitted: () => - unawaited(_submitSearch(context, ref)), - onEscape: () => _closeSearch(ref), - ) - : _HeaderTitle( - title: _activeFileName( - context, - workspace, - state.activeBuffer, - ), - subtitle: _workspaceKindLabel( - context, - workspace.kind, + ], + ), + title: BusyMarkRetainedCrossfade( + index: searchState.active ? 1 : 0, + duration: BusyMarkMotion.search, + children: [ + BusyMarkLinuxHeaderTitle(title), + _HeaderSearchField( + active: searchState.active, + focusRequest: ref.watch( + _headerSearchFocusRequestProvider, + ), + query: searchState.query, + onChanged: (query) => _setSearchQuery(ref, query), + onClear: () => _clearSearchQuery(ref), + onSubmitted: () => + unawaited(_submitSearch(context, ref)), + onEscape: () => _closeSearch(ref), + ), + ], + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (!searchState.active) ...[ + const SizedBox(width: BusyMarkSpacing.sm), + BusyMarkHeaderIconButton( + tooltip: context.l10n.validate, + icon: BusyMarkGlyphs.diagnostics, + onPressed: () => unawaited( + _validateActiveAndShowProblems(context, ref), ), - dirty: state.isDirty, ), - actions: [ - if (!searchState.active) ...[ - const SizedBox(width: BusyMarkSpacing.sm), + const _HeaderSeparator(), + ], BusyMarkHeaderIconButton( - tooltip: context.l10n.validate, - icon: BusyMarkGlyphs.diagnostics, - onPressed: () => unawaited( - _validateActiveAndShowProblems(context, ref), + tooltip: context.l10n.search, + icon: BusyMarkGlyphs.search, + selected: searchState.active, + shortcut: commandRegistry[BusyMarkCommandIds.search] + ?.shortcut + ?.label, + onPressed: () => _toggleSearch(ref), + ), + if (!searchState.active) + BusyMarkHeaderPopupMenuButton< + DocumentViewModePreference + >( + tooltip: context.l10n.viewMode, + icon: _documentViewModeIcon(documentViewMode), + shortcut: _documentViewModeShortcut( + documentViewMode, + commandRegistry, + ), + itemBuilder: (context) => [ + for (final mode + in DocumentViewModePreference.values) + BusyMarkPopupMenuItem( + value: mode, + label: _documentViewModeLabel( + context, + mode, + ), + icon: _documentViewModeIcon(mode), + shortcut: _documentViewModeShortcut( + mode, + commandRegistry, + ), + checked: mode == documentViewMode, + trailingCheck: true, + ), + ], + onSelected: (mode) { + ref + .read(workspaceControllerProvider.notifier) + .updateActiveEditorMode(mode); + unawaited( + settingsController.setDocumentViewMode(mode), + ); + }, ), + BusyMarkMainMenuButton( + canExport: canExportPdf || canExportHtml, + onSelected: (action) => + _handleMainMenuAction(context, ref, action), ), - const _HeaderSeparator(), + const SizedBox(width: BusyMarkSpacing.sm), ], - BusyMarkHeaderIconButton( - tooltip: settings.sidebarVisible - ? context.l10n.hideSidebar - : context.l10n.showSidebar, - icon: BusyMarkGlyphs.sidebar, - selected: settings.sidebarVisible, - shortcut: - commandRegistry[BusyMarkCommandIds.toggleSidebar] - ?.shortcut - ?.label, - onPressed: () { - final visible = !settings.sidebarVisible; - if (!visible) { - _clearGitDetailSelection(ref); - } - unawaited( - settingsController.setSidebarVisible(visible), - ); - }, - ), - BusyMarkHeaderIconButton( - tooltip: context.l10n.search, - icon: BusyMarkGlyphs.search, - selected: searchState.active, - shortcut: commandRegistry[BusyMarkCommandIds.search] - ?.shortcut - ?.label, - onPressed: () => _toggleSearch(ref), - ), - if (!searchState.active) - BusyMarkHeaderPopupMenuButton< - DocumentViewModePreference - >( - tooltip: context.l10n.viewMode, - icon: _documentViewModeIcon(documentViewMode), - shortcut: _documentViewModeShortcut( - documentViewMode, - commandRegistry, + ), + ), + body: Column( + children: [ + // The recovered document already has a review banner with + // actions. Keep other messages, including recovery damage. + if (state.message != null && + !(state.message!.code == + WorkspaceMessageCode.recoveryRestored && + state.activeBuffer?.recovered == true)) + BusyMarkStatusBox( + message: localizeWorkspaceMessage( + context, + state.message!, + ), + kind: busyMarkWorkspaceMessageStatusKind( + state.message!.code, ), - itemBuilder: (context) => [ - for (final mode - in DocumentViewModePreference.values) - BusyMarkPopupMenuItem( - value: mode, - label: _documentViewModeLabel(context, mode), - icon: _documentViewModeIcon(mode), - shortcut: _documentViewModeShortcut( - mode, - commandRegistry, - ), - checked: mode == documentViewMode, - trailingCheck: true, - ), - ], - onSelected: (mode) { - ref - .read(workspaceControllerProvider.notifier) - .updateActiveEditorMode(mode); - unawaited( - settingsController.setDocumentViewMode(mode), - ); - }, ), - BusyMarkMainMenuButton( - canExport: canExportPdf || canExportHtml, - onSelected: (action) => - _handleMainMenuAction(context, ref, action), - ), - const SizedBox(width: BusyMarkSpacing.sm), + if (localHistoryState.warning != null) + BusyMarkStatusBox( + message: localizeLocalHistoryWarning( + context, + localHistoryState.warning!, + ), + kind: BusyMarkStatusKind.warning, + ), + Expanded(child: workspaceContent), ], ), - body: Column( - children: [ - // The recovered document already has a review banner with - // actions. Keep other messages, including recovery damage. - if (state.message != null && - !(state.message!.code == - WorkspaceMessageCode.recoveryRestored && - state.activeBuffer?.recovered == true)) - BusyMarkStatusBox( - message: localizeWorkspaceMessage( - context, - state.message!, - ), - kind: busyMarkWorkspaceMessageStatusKind( - state.message!.code, - ), - ), - if (localHistoryState.warning != null) - BusyMarkStatusBox( - message: localizeLocalHistoryWarning( - context, - localHistoryState.warning!, - ), - kind: BusyMarkStatusKind.warning, - ), - Expanded( - child: Row( - textDirection: TextDirection.ltr, - children: workspaceChildren, - ), - ), - ], + ); + }, ), ), ), @@ -1081,13 +1120,17 @@ class WorkspaceScreen extends ConsumerWidget { void _openSearch(WidgetRef ref) { final search = ref.read(_workspaceSearchProvider); + ref.read(_headerSearchFocusRequestProvider.notifier).request(); + if (!search.active) { + ref.read(_searchReturnFocusProvider).node = + FocusManager.instance.primaryFocus; + } if (search.active) { unawaited( ref .read(appSettingsControllerProvider.notifier) .setSidebarVisible(true), ); - unawaited(ref.read(linuxHeaderBarServiceProvider).focusSearch()); return; } _clearGitDetailSelection(ref); @@ -1107,6 +1150,7 @@ class WorkspaceScreen extends ConsumerWidget { ref .read(_workspaceSearchProvider.notifier) .set(search.copyWith(active: false)); + ref.read(_searchReturnFocusProvider).restore(); } void _selectSidebarShortcut(WidgetRef ref, _SidebarTab tab) { @@ -1255,17 +1299,6 @@ class WorkspaceScreen extends ConsumerWidget { return p.basename(path); } - String _workspaceKindLabel(BuildContext context, WorkspaceKind kind) { - return switch (kind) { - WorkspaceKind.untitledMarkdown => - context.l10n.workspaceKindUnsavedMarkdown, - WorkspaceKind.singleMarkdown => context.l10n.workspaceKindSingleMarkdown, - WorkspaceKind.markdownFolder => context.l10n.workspaceKindMarkdownFolder, - WorkspaceKind.writersideModule => - context.l10n.workspaceKindWritersideModule, - }; - } - AppViewMode _headerBarViewMode(DocumentViewModePreference mode) { return switch (mode) { DocumentViewModePreference.editor => AppViewMode.editor, @@ -2001,50 +2034,6 @@ class _GitFileList extends StatelessWidget { } } -class _HeaderTitle extends StatelessWidget { - const _HeaderTitle({ - required this.title, - required this.subtitle, - required this.dirty, - }); - - final String title; - final String subtitle; - final bool dirty; - - @override - Widget build(BuildContext context) { - final colors = BusyMarkSurfaceColors.of(context); - return Row( - children: [ - Flexible( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - dirty ? '*$title' : title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - color: colors.foreground, - fontWeight: FontWeight.w700, - ), - ), - Text( - subtitle, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.labelSmall, - ), - ], - ), - ), - ], - ); - } -} - class _HeaderSearchField extends StatefulWidget { const _HeaderSearchField({ required this.query, @@ -2052,8 +2041,12 @@ class _HeaderSearchField extends StatefulWidget { required this.onClear, required this.onSubmitted, required this.onEscape, + this.active = true, + this.focusRequest = 0, }); + final bool active; + final int focusRequest; final String query; final ValueChanged onChanged; final VoidCallback onClear; @@ -2066,6 +2059,7 @@ class _HeaderSearchField extends StatefulWidget { class _HeaderSearchFieldState extends State<_HeaderSearchField> { late final TextEditingController _controller; + var _focusRequest = 0; @override void initState() { @@ -2076,6 +2070,10 @@ class _HeaderSearchFieldState extends State<_HeaderSearchField> { @override void didUpdateWidget(covariant _HeaderSearchField oldWidget) { super.didUpdateWidget(oldWidget); + if (widget.active && + (!oldWidget.active || widget.focusRequest != oldWidget.focusRequest)) { + _focusRequest++; + } if (widget.query != _controller.text) { _controller.value = TextEditingValue( text: widget.query, @@ -2095,7 +2093,9 @@ class _HeaderSearchFieldState extends State<_HeaderSearchField> { return BusyMarkSearchField( controller: _controller, hintText: context.l10n.search, - autofocus: true, + autofocus: widget.active, + clearOnEscape: false, + focusRequest: _focusRequest, onChanged: widget.onChanged, onSubmitted: (_) => widget.onSubmitted(), onClear: widget.onClear, @@ -2120,6 +2120,7 @@ class _HeaderSeparator extends StatelessWidget { class _Sidebar extends ConsumerStatefulWidget { const _Sidebar({ + super.key, required this.workspace, required this.outline, required this.searchState, @@ -2213,6 +2214,7 @@ class _SidebarState extends ConsumerState<_Sidebar> { : _tab.clamp(0, tabs.length - 1).toInt(); final selectedTab = tabs.isEmpty ? null : tabs[selectedIndex]; return BusyMarkSidebarSurface( + showEndBorder: false, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -2231,81 +2233,92 @@ class _SidebarState extends ConsumerState<_Sidebar> { unawaited(_refineActiveDocumentWithAi(context)), ), Expanded( - child: widget.searchState.active - ? _SearchSidebar( - query: widget.searchState.query, - results: widget.searchResults, - searching: widget.searchState.searching, - state: widget.searchState, - onShowMore: () => - ref.read(_workspaceSearchProvider.notifier).showMore(), - onOpenResult: widget.onOpenSearchResult, - onReviewReplacement: _reviewWorkspaceReplacement, - ) - : _topicRenameReview != null - ? _WritersideTopicRenameSidebar( - plan: _topicRenameReview!, - onBack: () => setState(() => _topicRenameReview = null), - onDoRefactor: () => _applyReviewedTopicRename(context), - ) - : _topicUsageReview != null - ? _WritersideTopicUsagesSidebar( - review: _topicUsageReview!, - onBack: _closeTopicUsageReview, - onOpenUsage: (usage) => - _openWritersideTopicUsage(context, usage), - onDoRefactor: () => _resumeWritersideTopicRemoval(context), - ) - : switch (selectedTab) { - _SidebarTab.files => _FilesTab( - workspace: widget.workspace, - onShowFileHistory: _showFileHistory, - onRenameTopic: (path) => - _startWritersideTopicRename(context, path), - onRequestTopicRemoval: (target) => - _runWritersideTopicRemoval(context, target), - ), - _SidebarTab.toc => _TocTab( - workspace: widget.workspace, - onShowFileHistory: _showFileHistory, - onRenameTopic: (path) => - _startWritersideTopicRename(context, path), - onRequestTopicRemoval: (target) => - _runWritersideTopicRemoval(context, target), - ), - _SidebarTab.outline => _OutlineTab( - workspace: widget.workspace, - bufferId: ref - .watch(workspaceControllerProvider) - .activeBufferId, - headings: widget.outline, - ), - _SidebarTab.git => GitSidebarTab( - workspace: widget.workspace, - onOpenFile: (relativePath) => - _openGitDiffFile(context, ref, relativePath), - onConfirmDiscard: (files) => - _confirmDiscardGitFiles(context, ref, files), - onAfterWorkspaceFilesChanged: () => - _refreshWorkspaceAfterGitFileChanges(ref), - onConfirmSwitchBranch: (branchName) => - _confirmSwitchGitBranch(context, ref, branchName), - onConfirmPushSetUpstream: () => - _confirmGitPushSetUpstream(context, ref), - ), - _SidebarTab.clipboard => ClipboardHistoryPanel( - onEscape: () => ref - .read(clipboardInsertionRegistryProvider) - .target - ?.requestEditorFocus(), - ), - _SidebarTab.localHistory => LocalHistoryPanel( - focusSearchRequest: ref.watch( - localHistoryFindRequestProvider, + child: BusyMarkKeyedCrossfade( + transitionKey: widget.searchState.active + ? 'search' + : _topicRenameReview != null + ? 'rename' + : _topicUsageReview != null + ? 'usages' + : selectedTab ?? 'empty', + child: widget.searchState.active + ? _SearchSidebar( + query: widget.searchState.query, + results: widget.searchResults, + searching: widget.searchState.searching, + state: widget.searchState, + onShowMore: () => ref + .read(_workspaceSearchProvider.notifier) + .showMore(), + onOpenResult: widget.onOpenSearchResult, + onReviewReplacement: _reviewWorkspaceReplacement, + ) + : _topicRenameReview != null + ? _WritersideTopicRenameSidebar( + plan: _topicRenameReview!, + onBack: () => setState(() => _topicRenameReview = null), + onDoRefactor: () => _applyReviewedTopicRename(context), + ) + : _topicUsageReview != null + ? _WritersideTopicUsagesSidebar( + review: _topicUsageReview!, + onBack: _closeTopicUsageReview, + onOpenUsage: (usage) => + _openWritersideTopicUsage(context, usage), + onDoRefactor: () => + _resumeWritersideTopicRemoval(context), + ) + : switch (selectedTab) { + _SidebarTab.files => _FilesTab( + workspace: widget.workspace, + onShowFileHistory: _showFileHistory, + onRenameTopic: (path) => + _startWritersideTopicRename(context, path), + onRequestTopicRemoval: (target) => + _runWritersideTopicRemoval(context, target), ), - ), - null => const SizedBox.shrink(), - }, + _SidebarTab.toc => _TocTab( + workspace: widget.workspace, + onShowFileHistory: _showFileHistory, + onRenameTopic: (path) => + _startWritersideTopicRename(context, path), + onRequestTopicRemoval: (target) => + _runWritersideTopicRemoval(context, target), + ), + _SidebarTab.outline => _OutlineTab( + workspace: widget.workspace, + bufferId: ref + .watch(workspaceControllerProvider) + .activeBufferId, + headings: widget.outline, + ), + _SidebarTab.git => GitSidebarTab( + workspace: widget.workspace, + onOpenFile: (relativePath) => + _openGitDiffFile(context, ref, relativePath), + onConfirmDiscard: (files) => + _confirmDiscardGitFiles(context, ref, files), + onAfterWorkspaceFilesChanged: () => + _refreshWorkspaceAfterGitFileChanges(ref), + onConfirmSwitchBranch: (branchName) => + _confirmSwitchGitBranch(context, ref, branchName), + onConfirmPushSetUpstream: () => + _confirmGitPushSetUpstream(context, ref), + ), + _SidebarTab.clipboard => ClipboardHistoryPanel( + onEscape: () => ref + .read(clipboardInsertionRegistryProvider) + .target + ?.requestEditorFocus(), + ), + _SidebarTab.localHistory => LocalHistoryPanel( + focusSearchRequest: ref.watch( + localHistoryFindRequestProvider, + ), + ), + null => const SizedBox.shrink(), + }, + ), ), ], ), @@ -5207,7 +5220,9 @@ class _SidebarTreeRowState extends State<_SidebarTreeRow> { ? -0.25 : 0.25 : 0, - duration: BusyMarkMotion.sidebarExpand, + duration: MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : BusyMarkMotion.sidebarExpand, child: Icon( BusyMarkGlyphs.collapsedTreeArrowFor(direction), size: BusyMarkSizes.sidebarTreeArrow, @@ -12042,321 +12057,385 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { final spellingLabel = activeBuffer != null && spellingSupported ? _spellingStatusLabel(context, activeBuffer, settings) : null; - return DecoratedBox( - decoration: BoxDecoration(color: colors.view), - child: Column( - children: [ - if (activeBuffer?.recovered == true) - _RecoveredDocumentBanner( - buffer: activeBuffer!, - onSave: () => unawaited( - (BusyMarkCommandRegistryScope.read(context) ?? - BusyMarkCommandCatalog.metadata) - .execute(BusyMarkCommandIds.save), + return BusyMarkSemanticFade( + transitionKey: widget.viewMode, + child: DecoratedBox( + decoration: BoxDecoration(color: colors.view), + child: Column( + children: [ + if (activeBuffer?.recovered == true) + _RecoveredDocumentBanner( + buffer: activeBuffer!, + onSave: () => unawaited( + (BusyMarkCommandRegistryScope.read(context) ?? + BusyMarkCommandCatalog.metadata) + .execute(BusyMarkCommandIds.save), + ), + onSaveAs: () => + unawaited(saveActiveToNewLocation(context, ref)), + onDiscard: () => unawaited( + activeBuffer.deletedOnDisk + ? ref + .read(workspaceControllerProvider.notifier) + .closeDocumentBuffer(activeBuffer.id, discard: true) + : ref + .read(workspaceControllerProvider.notifier) + .discardActiveChanges(), + ), ), - onSaveAs: () => unawaited(saveActiveToNewLocation(context, ref)), - onDiscard: () => unawaited( - activeBuffer.deletedOnDisk - ? ref - .read(workspaceControllerProvider.notifier) - .closeDocumentBuffer(activeBuffer.id, discard: true) - : ref - .read(workspaceControllerProvider.notifier) - .discardActiveChanges(), + if (activeBuffer != null && + activeBuffer.diskState != DocumentDiskState.present && + activeBuffer.diskState != DocumentDiskState.changed) + _ExternalFileBanner( + buffer: activeBuffer, + onCompare: activeBuffer.diskVersionText == null + ? null + : () => _showExternalFileComparison(context, activeBuffer), + onReload: activeBuffer.filePath == null + ? null + : () => unawaited( + ref + .read(workspaceControllerProvider.notifier) + .reloadBufferFromDisk(activeBuffer.id), + ), + onKeepMine: () => ref + .read(workspaceControllerProvider.notifier) + .keepBufferVersion(activeBuffer.id), + onSave: () => unawaited( + (BusyMarkCommandRegistryScope.read(context) ?? + BusyMarkCommandCatalog.metadata) + .execute(BusyMarkCommandIds.save), + ), + onSaveAs: () => + unawaited(saveActiveToNewLocation(context, ref)), ), - ), - if (activeBuffer != null && - activeBuffer.diskState != DocumentDiskState.present && - activeBuffer.diskState != DocumentDiskState.changed) - _ExternalFileBanner( - buffer: activeBuffer, - onCompare: activeBuffer.diskVersionText == null - ? null - : () => _showExternalFileComparison(context, activeBuffer), - onReload: activeBuffer.filePath == null - ? null - : () => unawaited( - ref - .read(workspaceControllerProvider.notifier) - .reloadBufferFromDisk(activeBuffer.id), - ), - onKeepMine: () => ref - .read(workspaceControllerProvider.notifier) - .keepBufferVersion(activeBuffer.id), - onSave: () => unawaited( - (BusyMarkCommandRegistryScope.read(context) ?? - BusyMarkCommandCatalog.metadata) - .execute(BusyMarkCommandIds.save), + if (activeBuffer != null) + BusyMarkBanner( + key: const ValueKey('spelling-banner'), + title: spellingBannerPresentation?.title ?? '', + actionLabel: spellingBannerPresentation?.actionLabel, + onAction: spellingBannerPresentation?.onAction, + revealed: spellingBannerPresentation != null, ), - onSaveAs: () => unawaited(saveActiveToNewLocation(context, ref)), - ), - if (activeBuffer != null) - BusyMarkBanner( - key: const ValueKey('spelling-banner'), - title: spellingBannerPresentation?.title ?? '', - actionLabel: spellingBannerPresentation?.actionLabel, - onAction: spellingBannerPresentation?.onAction, - revealed: spellingBannerPresentation != null, - ), - Expanded( - child: Stack( - children: [ - Positioned.fill( - child: Row( - children: [ - if (wysiwygVisible) - Expanded( - key: const ValueKey('document-wysiwyg-pane'), - child: BusyMarkWysiwygEditor( - key: _wysiwygEditorKey, - document: wysiwygDocument, - documentId: activeBuffer?.id, - contentRevision: activeBuffer?.revision, - spellingAnnotations: _spelling.annotations, - onCheckSpelling: () => - unawaited(_openSpellingReview()), - readSpellingMenuItems: _prepareRichSpellingMenu, - clipboardInsertionRegistry: ref.read( - clipboardInsertionRegistryProvider, - ), - onClipboardCaptured: ref - .read( - clipboardHistoryControllerProvider.notifier, - ) - .retain, - initialSessionState: - activeBuffer?.editorState.wysiwygState ?? - const WysiwygEditorSessionState(), - useExternalUndoHistory: true, - onSessionChanged: (documentId, session) { - final latest = ref - .read(workspaceControllerProvider) - .documentBuffers - .where((buffer) => buffer.id == documentId) - .firstOrNull; - if (latest == null || - _sameWysiwygSession( - latest.editorState.wysiwygState, - session, - )) { - return; - } - ref - .read(workspaceControllerProvider.notifier) - .updateDocumentEditorState( - documentId, - latest.editorState.copyWith( - wysiwygState: session, - ), - ); - }, - headerBarService: headerBar, - workspaceRoot: _imageWorkspaceRoot( - widget.state.workspace, - ), - writersideRoot: widget - .state - .workspace - ?.writersideModule - ?.rootPath, - imagesDir: - widget - .state - .workspace - ?.writersideModule - ?.effectiveImagesDir ?? - 'images', - assetWorkspaceKind: - switch (widget.state.workspace?.kind) { - WorkspaceKind.writersideModule => - AssetWorkspaceKind.writerside, - WorkspaceKind.markdownFolder => - AssetWorkspaceKind.markdownWorkspace, - WorkspaceKind.singleMarkdown => - AssetWorkspaceKind.standalone, - WorkspaceKind.untitledMarkdown || - null => AssetWorkspaceKind.standalone, - }, - onAssetSaveRequired: () => unawaited( - saveActiveToNewLocation(context, ref), - ), - allowRemoteImages: allowRemoteImages, - onRemoteImageBlocked: () => unawaited( - _showRemoteImagesPrompt(context, ref), - ), - onDocumentChanged: (document) => - _cacheWysiwygDocument( - activeBuffer?.id, - document, - ), - onSourceChanged: (filePath, value) => - _handleWysiwygSourceChanged( - activeBuffer?.id, - filePath, - value, - ), - onTransactionalSourceChanged: - (filePath, value, [undoGroup]) => - _handleWysiwygSourceChanged( - activeBuffer?.id, - filePath, - value, - undoGroup, - ), - onSpellingSourceChanged: - (filePath, value, before, after) => - _handleWysiwygSpellingSourceChanged( - activeBuffer?.id, - filePath, - value, - before, - after, - ), - toolbarPlacement: widget.editorToolbarPlacement, - toolbarDirection: widget.editorToolbarDirection, - onToolbarPlacementChanged: ref - .read(appSettingsControllerProvider.notifier) - .setEditorToolbarPlacement, - onToolbarDirectionChanged: ref - .read(appSettingsControllerProvider.notifier) - .setEditorToolbarDirection, - scrollToHeadingId: _wysiwygScrollHeadingId, - scrollToBlockId: _wysiwygScrollBlockId, - scrollToSearchQuery: _wysiwygSearchQuery, - scrollToSourceRange: _wysiwygSearchRange, - scrollRequest: _wysiwygScrollRequest, - onVisibleHeadingChanged: - _handleWysiwygVisibleHeadingChanged, - documentLayout: standaloneDocumentLayout, - visualizationRevision: ref - .read(workspaceControllerProvider.notifier) - .editRevision, - onOpenSearch: () => ref - .read( - workspaceSearchOpenRequestProvider.notifier, - ) - .request(), - onCloseSearch: () => ref - .read( - workspaceSearchCloseRequestProvider.notifier, - ) - .request(), - onUndo: () => ref - .read(workspaceControllerProvider.notifier) - .undoActiveBuffer(), - onRedo: () => ref - .read(workspaceControllerProvider.notifier) - .redoActiveBuffer(), - onAiEdit: - (_activeDocumentKind( - widget.state.workspace, - )?.supportsAiMarkdownEditing ?? - false) - ? (snapshot) => - showBusyMarkAiEdit(context, ref, snapshot) - : null, - onMathDiagnostic: - (expressionId, code, sourceSpan) => ref + Expanded( + child: Stack( + children: [ + Positioned.fill( + child: Row( + children: [ + if (wysiwygVisible) + Expanded( + key: const ValueKey('document-wysiwyg-pane'), + child: BusyMarkWysiwygEditor( + key: _wysiwygEditorKey, + document: wysiwygDocument, + documentId: activeBuffer?.id, + contentRevision: activeBuffer?.revision, + spellingAnnotations: _spelling.annotations, + onCheckSpelling: () => + unawaited(_openSpellingReview()), + readSpellingMenuItems: _prepareRichSpellingMenu, + clipboardInsertionRegistry: ref.read( + clipboardInsertionRegistryProvider, + ), + onClipboardCaptured: ref + .read( + clipboardHistoryControllerProvider.notifier, + ) + .retain, + initialSessionState: + activeBuffer?.editorState.wysiwygState ?? + const WysiwygEditorSessionState(), + useExternalUndoHistory: true, + onSessionChanged: (documentId, session) { + final latest = ref + .read(workspaceControllerProvider) + .documentBuffers + .where((buffer) => buffer.id == documentId) + .firstOrNull; + if (latest == null || + _sameWysiwygSession( + latest.editorState.wysiwygState, + session, + )) { + return; + } + ref .read(workspaceControllerProvider.notifier) - .updateMathRenderDiagnostic( - expressionId: expressionId, - code: code, - sourceSpan: sourceSpan, - expectedRevision: activeBuffer?.revision, - expectedFilePath: activeBuffer?.filePath, - ), - ), - ), - if (sourceVisible) - Expanded( - key: const ValueKey('document-source-pane'), - child: BusyMarkSourceEditor( - key: _sourceEditorKey, - text: widget.state.activeText, - language: _sourceSyntaxLanguage( - widget.state.workspace, - ), - documentFormat: _sourceDocumentFormat( - widget.state.workspace, - ), - markdownMode: _sourceMarkdownMode( - widget.state.workspace, - ), - filePath: activeEditorPath, - documentId: activeBuffer?.id, - clipboardInsertionRegistry: ref.read( - clipboardInsertionRegistryProvider, - ), - onClipboardCaptured: ref - .read( - clipboardHistoryControllerProvider.notifier, - ) - .retain, - workspaceRoot: _imageWorkspaceRoot( - widget.state.workspace, - ), - writersideRoot: widget - .state - .workspace - ?.writersideModule - ?.rootPath, - imagesDir: - widget - .state - .workspace - ?.writersideModule - ?.effectiveImagesDir ?? - 'images', - assetWorkspaceKind: - switch (widget.state.workspace?.kind) { - WorkspaceKind.writersideModule => - AssetWorkspaceKind.writerside, - WorkspaceKind.markdownFolder => - AssetWorkspaceKind.markdownWorkspace, - WorkspaceKind.singleMarkdown => - AssetWorkspaceKind.standalone, - WorkspaceKind.untitledMarkdown || - null => AssetWorkspaceKind.standalone, - }, - onAssetSaveRequired: () => unawaited( - saveActiveToNewLocation(context, ref), - ), - spellingAnnotations: _spelling.annotations, - onCheckSpelling: () => - unawaited(_openSpellingReview()), - readSpellingMenuItems: _prepareSourceSpellingMenu, - diagnostics: - widget.state.workspace?.allDiagnostics ?? - const [], - onSymbolAction: - widget.state.workspace?.writersideProject == - null - ? null - : (action, offset) => unawaited( - _handleSymbolAction(action, offset), + .updateDocumentEditorState( + documentId, + latest.editorState.copyWith( + wysiwygState: session, + ), + ); + }, + headerBarService: headerBar, + workspaceRoot: _imageWorkspaceRoot( + widget.state.workspace, + ), + writersideRoot: widget + .state + .workspace + ?.writersideModule + ?.rootPath, + imagesDir: + widget + .state + .workspace + ?.writersideModule + ?.effectiveImagesDir ?? + 'images', + assetWorkspaceKind: + switch (widget.state.workspace?.kind) { + WorkspaceKind.writersideModule => + AssetWorkspaceKind.writerside, + WorkspaceKind.markdownFolder => + AssetWorkspaceKind.markdownWorkspace, + WorkspaceKind.singleMarkdown => + AssetWorkspaceKind.standalone, + WorkspaceKind.untitledMarkdown || + null => AssetWorkspaceKind.standalone, + }, + onAssetSaveRequired: () => unawaited( + saveActiveToNewLocation(context, ref), + ), + allowRemoteImages: allowRemoteImages, + onRemoteImageBlocked: () => unawaited( + _showRemoteImagesPrompt(context, ref), + ), + onDocumentChanged: (document) => + _cacheWysiwygDocument( + activeBuffer?.id, + document, + ), + onSourceChanged: (filePath, value) => + _handleWysiwygSourceChanged( + activeBuffer?.id, + filePath, + value, ), - autocompleteContext: _sourceAutocompleteContext( - widget.state.workspace, + onTransactionalSourceChanged: + (filePath, value, [undoGroup]) => + _handleWysiwygSourceChanged( + activeBuffer?.id, + filePath, + value, + undoGroup, + ), + onSpellingSourceChanged: + (filePath, value, before, after) => + _handleWysiwygSpellingSourceChanged( + activeBuffer?.id, + filePath, + value, + before, + after, + ), + toolbarPlacement: widget.editorToolbarPlacement, + toolbarDirection: widget.editorToolbarDirection, + onToolbarPlacementChanged: ref + .read(appSettingsControllerProvider.notifier) + .setEditorToolbarPlacement, + onToolbarDirectionChanged: ref + .read(appSettingsControllerProvider.notifier) + .setEditorToolbarDirection, + scrollToHeadingId: _wysiwygScrollHeadingId, + scrollToBlockId: _wysiwygScrollBlockId, + scrollToSearchQuery: _wysiwygSearchQuery, + scrollToSourceRange: _wysiwygSearchRange, + scrollRequest: _wysiwygScrollRequest, + onVisibleHeadingChanged: + _handleWysiwygVisibleHeadingChanged, + documentLayout: standaloneDocumentLayout, + visualizationRevision: ref + .read(workspaceControllerProvider.notifier) + .editRevision, + onOpenSearch: () => ref + .read( + workspaceSearchOpenRequestProvider.notifier, + ) + .request(), + onCloseSearch: () => ref + .read( + workspaceSearchCloseRequestProvider + .notifier, + ) + .request(), + onUndo: () => ref + .read(workspaceControllerProvider.notifier) + .undoActiveBuffer(), + onRedo: () => ref + .read(workspaceControllerProvider.notifier) + .redoActiveBuffer(), + onAiEdit: + (_activeDocumentKind( + widget.state.workspace, + )?.supportsAiMarkdownEditing ?? + false) + ? (snapshot) => showBusyMarkAiEdit( + context, + ref, + snapshot, + ) + : null, + onMathDiagnostic: + (expressionId, code, sourceSpan) => ref + .read( + workspaceControllerProvider.notifier, + ) + .updateMathRenderDiagnostic( + expressionId: expressionId, + code: code, + sourceSpan: sourceSpan, + expectedRevision: + activeBuffer?.revision, + expectedFilePath: + activeBuffer?.filePath, + ), ), - editorFontSize: widget.editorFontSize, - wordWrap: widget.wordWrap, - searchActive: searchState.active, - searchOptions: searchState.options, - searchReplacement: - activeBuffer?.editorState.searchReplacement ?? - '', - onSearchReplacementChanged: activeBuffer == null - ? null - : (replacement) { - final latest = ref - .read(workspaceControllerProvider) - .documentBuffers - .where( - (candidate) => - candidate.id == activeBuffer.id, - ) - .firstOrNull; - if (latest != null) { + ), + if (sourceVisible) + Expanded( + key: const ValueKey('document-source-pane'), + child: BusyMarkSourceEditor( + key: _sourceEditorKey, + text: widget.state.activeText, + language: _sourceSyntaxLanguage( + widget.state.workspace, + ), + documentFormat: _sourceDocumentFormat( + widget.state.workspace, + ), + markdownMode: _sourceMarkdownMode( + widget.state.workspace, + ), + filePath: activeEditorPath, + documentId: activeBuffer?.id, + clipboardInsertionRegistry: ref.read( + clipboardInsertionRegistryProvider, + ), + onClipboardCaptured: ref + .read( + clipboardHistoryControllerProvider.notifier, + ) + .retain, + workspaceRoot: _imageWorkspaceRoot( + widget.state.workspace, + ), + writersideRoot: widget + .state + .workspace + ?.writersideModule + ?.rootPath, + imagesDir: + widget + .state + .workspace + ?.writersideModule + ?.effectiveImagesDir ?? + 'images', + assetWorkspaceKind: + switch (widget.state.workspace?.kind) { + WorkspaceKind.writersideModule => + AssetWorkspaceKind.writerside, + WorkspaceKind.markdownFolder => + AssetWorkspaceKind.markdownWorkspace, + WorkspaceKind.singleMarkdown => + AssetWorkspaceKind.standalone, + WorkspaceKind.untitledMarkdown || + null => AssetWorkspaceKind.standalone, + }, + onAssetSaveRequired: () => unawaited( + saveActiveToNewLocation(context, ref), + ), + spellingAnnotations: _spelling.annotations, + onCheckSpelling: () => + unawaited(_openSpellingReview()), + readSpellingMenuItems: _prepareSourceSpellingMenu, + diagnostics: + widget.state.workspace?.allDiagnostics ?? + const [], + onSymbolAction: + widget.state.workspace?.writersideProject == + null + ? null + : (action, offset) => unawaited( + _handleSymbolAction(action, offset), + ), + autocompleteContext: _sourceAutocompleteContext( + widget.state.workspace, + ), + editorFontSize: widget.editorFontSize, + wordWrap: widget.wordWrap, + searchActive: searchState.active, + searchOptions: searchState.options, + searchReplacement: + activeBuffer?.editorState.searchReplacement ?? + '', + onSearchReplacementChanged: activeBuffer == null + ? null + : (replacement) { + final latest = ref + .read(workspaceControllerProvider) + .documentBuffers + .where( + (candidate) => + candidate.id == activeBuffer.id, + ) + .firstOrNull; + if (latest != null) { + ref + .read( + workspaceControllerProvider + .notifier, + ) + .updateDocumentEditorState( + latest.id, + latest.editorState.copyWith( + searchReplacement: replacement, + ), + ); + } + }, + onSearchOptionsChanged: (options) { + final current = ref.read( + _workspaceSearchProvider, + ); + ref + .read(_workspaceSearchProvider.notifier) + .set(current.withOptions(options)); + }, + initialSelection: + activeBuffer?.editorState.selection, + initialScrollOffset: + activeBuffer?.editorState.scrollOffset ?? 0, + initialFoldedRegionKeys: + activeBuffer?.editorState.foldedRegionKeys ?? + const {}, + onSessionChanged: activeBuffer == null + ? null + : ( + selection, + scrollOffset, + foldedRegionKeys, + ) { + final latest = ref + .read(workspaceControllerProvider) + .documentBuffers + .where( + (candidate) => + candidate.id == activeBuffer.id, + ) + .firstOrNull; + if (latest == null || + _sameSourceSession( + latest.editorState, + selection, + scrollOffset, + foldedRegionKeys, + )) { + return; + } ref .read( workspaceControllerProvider @@ -12365,189 +12444,148 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { .updateDocumentEditorState( latest.id, latest.editorState.copyWith( - searchReplacement: replacement, + selection: selection, + scrollOffset: scrollOffset, + foldedRegionKeys: + foldedRegionKeys, ), ); - } - }, - onSearchOptionsChanged: (options) { - final current = ref.read( - _workspaceSearchProvider, - ); - ref - .read(_workspaceSearchProvider.notifier) - .set(current.withOptions(options)); - }, - initialSelection: - activeBuffer?.editorState.selection, - initialScrollOffset: - activeBuffer?.editorState.scrollOffset ?? 0, - initialFoldedRegionKeys: - activeBuffer?.editorState.foldedRegionKeys ?? - const {}, - onSessionChanged: activeBuffer == null - ? null - : (selection, scrollOffset, foldedRegionKeys) { - final latest = ref - .read(workspaceControllerProvider) - .documentBuffers - .where( - (candidate) => - candidate.id == activeBuffer.id, - ) - .firstOrNull; - if (latest == null || - _sameSourceSession( - latest.editorState, - selection, - scrollOffset, - foldedRegionKeys, - )) { - return; - } - ref - .read( - workspaceControllerProvider.notifier, - ) - .updateDocumentEditorState( - latest.id, - latest.editorState.copyWith( - selection: selection, - scrollOffset: scrollOffset, - foldedRegionKeys: foldedRegionKeys, - ), - ); - }, - onOpenSearch: () => ref - .read( - workspaceSearchOpenRequestProvider.notifier, - ) - .request(), - onCloseSearch: () => ref - .read( - workspaceSearchCloseRequestProvider.notifier, - ) - .request(), - onVisibleLineChanged: - _handleSourceVisibleLineChanged, - onChanged: (value, sourceFilePath) => - _handleSourceChanged( - activeBuffer?.id, - value, - sourceFilePath, - ), - onTransactionalChanged: - ( - value, - sourceFilePath, - previousSelection, - selection, - undoGroup, - ) => _handleTransactionalSourceChanged( - activeBuffer?.id, - value, - sourceFilePath, - previousSelection, - selection, - undoGroup, - ), - onUndo: () { - final controller = ref.read( - workspaceControllerProvider.notifier, - ); - 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, - ); - 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) - .editRevision, - onAiEdit: - (_activeDocumentKind( - widget.state.workspace, - )?.supportsAiMarkdownEditing ?? - false) - ? (snapshot) => - showBusyMarkAiEdit(context, ref, snapshot) - : null, + }, + onOpenSearch: () => ref + .read( + workspaceSearchOpenRequestProvider.notifier, + ) + .request(), + onCloseSearch: () => ref + .read( + workspaceSearchCloseRequestProvider + .notifier, + ) + .request(), + onVisibleLineChanged: + _handleSourceVisibleLineChanged, + onChanged: (value, sourceFilePath) => + _handleSourceChanged( + activeBuffer?.id, + value, + sourceFilePath, + ), + onTransactionalChanged: + ( + value, + sourceFilePath, + previousSelection, + selection, + undoGroup, + ) => _handleTransactionalSourceChanged( + activeBuffer?.id, + value, + sourceFilePath, + previousSelection, + selection, + undoGroup, + ), + onUndo: () { + final controller = ref.read( + workspaceControllerProvider.notifier, + ); + 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, + ); + 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) + .editRevision, + onAiEdit: + (_activeDocumentKind( + widget.state.workspace, + )?.supportsAiMarkdownEditing ?? + false) + ? (snapshot) => showBusyMarkAiEdit( + context, + ref, + snapshot, + ) + : null, + ), ), - ), - if (sourceVisible && previewVisible) - VerticalDivider( - width: BusyMarkStroke.hairline, - color: colors.subtleBorder, - ), - if (previewVisible) - Expanded( - key: const ValueKey('document-preview-pane'), - child: _PreviewPane( - preview: widget.state.preview, - workspace: widget.state.workspace, - activeSource: widget.state.activeText, - editRevision: ref - .read(workspaceControllerProvider.notifier) - .editRevision, - visualizationsEnabled: true, - onVisualizationDiagnostic: - _openVisualizationSourceLine, - onEditVisualizationSource: - _openVisualizationSourceLine, - controller: _previewScrollController, - itemPositionsListener: - _previewItemPositionsListener, - onBlockContextAvailable: - _rememberPreviewBlockContext, - onBlockContextUnavailable: - _forgetPreviewBlockContext, - documentLayout: sourceVisible - ? BusyMarkDocumentLayoutSpec.splitPreview - : standaloneDocumentLayout, + if (sourceVisible && previewVisible) + VerticalDivider( + width: BusyMarkStroke.hairline, + color: colors.subtleBorder, ), - ), - ], + if (previewVisible) + Expanded( + key: const ValueKey('document-preview-pane'), + child: _PreviewPane( + preview: widget.state.preview, + workspace: widget.state.workspace, + activeSource: widget.state.activeText, + editRevision: ref + .read(workspaceControllerProvider.notifier) + .editRevision, + visualizationsEnabled: true, + onVisualizationDiagnostic: + _openVisualizationSourceLine, + onEditVisualizationSource: + _openVisualizationSourceLine, + controller: _previewScrollController, + itemPositionsListener: + _previewItemPositionsListener, + onBlockContextAvailable: + _rememberPreviewBlockContext, + onBlockContextUnavailable: + _forgetPreviewBlockContext, + documentLayout: sourceVisible + ? BusyMarkDocumentLayoutSpec.splitPreview + : standaloneDocumentLayout, + ), + ), + ], + ), ), - ), - ], - ), - ), - if (activeBuffer != null) - BusyMarkDocumentStatusBar( - format: activeBuffer.format, - spellingLabel: spellingLabel, - spellingTooltip: spellingSupported - ? context.l10n.chooseSpellingLanguage - : null, - onSpellingPressed: spellingSupported - ? () => unawaited(_chooseDocumentSpellingLanguage()) - : null, + ], + ), ), - ], + if (activeBuffer != null) + BusyMarkDocumentStatusBar( + format: activeBuffer.format, + spellingLabel: spellingLabel, + spellingTooltip: spellingSupported + ? context.l10n.chooseSpellingLanguage + : null, + onSpellingPressed: spellingSupported + ? () => unawaited(_chooseDocumentSpellingLanguage()) + : null, + ), + ], + ), ), ); } diff --git a/linux/runner/CMakeLists.txt b/linux/runner/CMakeLists.txt index c795a13d..841fe6dc 100644 --- a/linux/runner/CMakeLists.txt +++ b/linux/runner/CMakeLists.txt @@ -11,6 +11,9 @@ add_executable(${BINARY_NAME} "my_application.cc" "gtk_accent.cc" "gtk_accent_host.cc" + "gtk_header_icons.cc" + "gtk_window_preferences.cc" + "linux_chrome_host.cc" "rich_clipboard_host.cc" "secure_credential_host.cc" "video_player_host.cc" @@ -22,6 +25,7 @@ add_executable(${BINARY_NAME} # Apply the standard set of build settings. This can be removed for applications # that need different build settings. apply_standard_settings(${BINARY_NAME}) +target_compile_features(${BINARY_NAME} PRIVATE cxx_std_17) # Add preprocessor definitions for the application ID. add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") diff --git a/linux/runner/gtk_header_icons.cc b/linux/runner/gtk_header_icons.cc new file mode 100644 index 00000000..b7c68ebc --- /dev/null +++ b/linux/runner/gtk_header_icons.cc @@ -0,0 +1,222 @@ +#include "gtk_header_icons.h" + +#include +#include + +namespace { + +GtkIconLookupFlags LookupFlags(BusyMarkGtkIconDirection direction) { + const GtkIconLookupFlags directional = + direction == BusyMarkGtkIconDirection::kRtl ? GTK_ICON_LOOKUP_DIR_RTL + : GTK_ICON_LOOKUP_DIR_LTR; + return static_cast(directional | + GTK_ICON_LOOKUP_FORCE_SYMBOLIC); +} + +std::string FirstAvailableName(GtkIconTheme* theme, + const std::vector& names) { + const auto available = std::find_if( + names.begin(), names.end(), + [theme](const std::string& name) { + return gtk_icon_theme_has_icon(theme, name.c_str()); + }); + return available == names.end() ? std::string() : *available; +} + +} // namespace + +BusyMarkGtkHeaderIcons::BusyMarkGtkHeaderIcons(GtkWidget* flutter_view, + GtkIconTheme* icon_theme) + : flutter_view_(flutter_view), injected_theme_(icon_theme) { + if (flutter_view_ != nullptr) g_object_ref(flutter_view_); + if (injected_theme_ != nullptr) g_object_ref(injected_theme_); +} + +BusyMarkGtkHeaderIcons::~BusyMarkGtkHeaderIcons() { + Stop(); + g_clear_object(&injected_theme_); + g_clear_object(&flutter_view_); +} + +std::optional BusyMarkGtkHeaderIcons::Load( + const std::vector& candidate_names, + BusyMarkGtkIconDirection direction, + int scale_override, + bool allow_missing) const { + if (candidate_names.empty()) return std::nullopt; + GtkIconTheme* theme = icon_theme_ != nullptr ? icon_theme_ : ResolveTheme(); + if (theme == nullptr) { + if (!allow_missing) { + g_warning("GTK could not resolve BusyMark header icon '%s'", + candidate_names.front().c_str()); + } + return std::nullopt; + } + + const int requested_scale = + scale_override > 0 ? scale_override : scale(); + GtkIconInfo* info = nullptr; + if (candidate_names.size() == 1) { + info = gtk_icon_theme_lookup_icon_for_scale( + theme, candidate_names.front().c_str(), + kBusyMarkGtkHeaderIconLogicalSize, requested_scale, + LookupFlags(direction)); + } else { + std::vector names; + names.reserve(candidate_names.size() + 1); + for (const std::string& name : candidate_names) { + names.push_back(name.c_str()); + } + names.push_back(nullptr); + info = gtk_icon_theme_choose_icon_for_scale( + theme, names.data(), kBusyMarkGtkHeaderIconLogicalSize, + requested_scale, LookupFlags(direction)); + } + if (info == nullptr) { + if (!allow_missing) { + g_warning("GTK could not resolve BusyMark header icon '%s'", + candidate_names.front().c_str()); + } + return std::nullopt; + } + g_autoptr(GtkIconInfo) owned_info = info; + + const GdkRGBA white = {1.0, 1.0, 1.0, 1.0}; + gboolean was_symbolic = FALSE; + g_autoptr(GError) load_error = nullptr; + g_autoptr(GdkPixbuf) pixbuf = gtk_icon_info_load_symbolic( + owned_info, &white, &white, &white, &white, &was_symbolic, + &load_error); + if (pixbuf == nullptr) { + if (load_error != nullptr) { + g_warning("Failed to load GTK header icon: %s", load_error->message); + } + return std::nullopt; + } + + gchar* encoded = nullptr; + gsize encoded_length = 0; + g_autoptr(GError) encode_error = nullptr; + if (!gdk_pixbuf_save_to_buffer(pixbuf, &encoded, &encoded_length, "png", + &encode_error, nullptr)) { + if (encode_error != nullptr) { + g_warning("Failed to encode GTK header icon: %s", + encode_error->message); + } + return std::nullopt; + } + std::vector png_bytes( + reinterpret_cast(encoded), + reinterpret_cast(encoded) + encoded_length); + g_free(encoded); + + std::string resolved_name = FirstAvailableName(theme, candidate_names); + if (resolved_name.empty()) resolved_name = candidate_names.front(); + return BusyMarkGtkHeaderIconAsset{ + std::move(png_bytes), + std::move(resolved_name), + requested_scale, + gdk_pixbuf_get_width(pixbuf), + gdk_pixbuf_get_height(pixbuf), + }; +} + +bool BusyMarkGtkHeaderIcons::Start(InvalidatedCallback callback) { + callback_ = std::move(callback); + if (flutter_view_ != nullptr && scale_changed_signal_id_ == 0) { + scale_changed_signal_id_ = g_signal_connect( + flutter_view_, "notify::scale-factor", G_CALLBACK(OnScaleChanged), + this); + } + if (flutter_view_ != nullptr && screen_changed_signal_id_ == 0) { + screen_changed_signal_id_ = g_signal_connect( + flutter_view_, "screen-changed", G_CALLBACK(OnScreenChanged), this); + } + RebindTheme(); + return icon_theme_ != nullptr; +} + +void BusyMarkGtkHeaderIcons::Stop() { + if (icon_theme_ != nullptr && theme_changed_signal_id_ != 0 && + g_signal_handler_is_connected(icon_theme_, theme_changed_signal_id_)) { + g_signal_handler_disconnect(icon_theme_, theme_changed_signal_id_); + } + theme_changed_signal_id_ = 0; + g_clear_object(&icon_theme_); + + if (flutter_view_ != nullptr && scale_changed_signal_id_ != 0 && + g_signal_handler_is_connected(flutter_view_, + scale_changed_signal_id_)) { + g_signal_handler_disconnect(flutter_view_, scale_changed_signal_id_); + } + if (flutter_view_ != nullptr && screen_changed_signal_id_ != 0 && + g_signal_handler_is_connected(flutter_view_, + screen_changed_signal_id_)) { + g_signal_handler_disconnect(flutter_view_, screen_changed_signal_id_); + } + scale_changed_signal_id_ = 0; + screen_changed_signal_id_ = 0; + callback_ = nullptr; +} + +int BusyMarkGtkHeaderIcons::scale() const { + return flutter_view_ != nullptr + ? std::max(1, gtk_widget_get_scale_factor(flutter_view_)) + : 1; +} + +gulong BusyMarkGtkHeaderIcons::theme_changed_signal_id() const { + return theme_changed_signal_id_; +} + +gulong BusyMarkGtkHeaderIcons::scale_changed_signal_id() const { + return scale_changed_signal_id_; +} + +gulong BusyMarkGtkHeaderIcons::screen_changed_signal_id() const { + return screen_changed_signal_id_; +} + +void BusyMarkGtkHeaderIcons::OnThemeChanged(GtkIconTheme*, gpointer user_data) { + static_cast(user_data)->Invalidate(); +} + +void BusyMarkGtkHeaderIcons::OnScaleChanged(GObject*, GParamSpec*, + gpointer user_data) { + static_cast(user_data)->Invalidate(); +} + +void BusyMarkGtkHeaderIcons::OnScreenChanged(GtkWidget*, GdkScreen*, + gpointer user_data) { + auto* icons = static_cast(user_data); + icons->RebindTheme(); + icons->Invalidate(); +} + +GtkIconTheme* BusyMarkGtkHeaderIcons::ResolveTheme() const { + if (injected_theme_ != nullptr) return injected_theme_; + if (flutter_view_ != nullptr) { + GdkScreen* screen = gtk_widget_get_screen(flutter_view_); + if (screen != nullptr) return gtk_icon_theme_get_for_screen(screen); + } + return gtk_icon_theme_get_default(); +} + +void BusyMarkGtkHeaderIcons::RebindTheme() { + GtkIconTheme* next_theme = ResolveTheme(); + if (next_theme == icon_theme_) return; + if (icon_theme_ != nullptr && theme_changed_signal_id_ != 0 && + g_signal_handler_is_connected(icon_theme_, theme_changed_signal_id_)) { + g_signal_handler_disconnect(icon_theme_, theme_changed_signal_id_); + } + theme_changed_signal_id_ = 0; + g_clear_object(&icon_theme_); + if (next_theme == nullptr) return; + icon_theme_ = GTK_ICON_THEME(g_object_ref(next_theme)); + theme_changed_signal_id_ = g_signal_connect( + icon_theme_, "changed", G_CALLBACK(OnThemeChanged), this); +} + +void BusyMarkGtkHeaderIcons::Invalidate() { + if (callback_) callback_(); +} diff --git a/linux/runner/gtk_header_icons.h b/linux/runner/gtk_header_icons.h new file mode 100644 index 00000000..c2585f99 --- /dev/null +++ b/linux/runner/gtk_header_icons.h @@ -0,0 +1,73 @@ +#ifndef RUNNER_GTK_HEADER_ICONS_H_ +#define RUNNER_GTK_HEADER_ICONS_H_ + +#include + +#include +#include +#include +#include +#include + +constexpr int kBusyMarkGtkHeaderIconLogicalSize = 16; + +enum class BusyMarkGtkIconDirection { kLtr, kRtl }; + +struct BusyMarkGtkHeaderIconAsset { + std::vector png_bytes; + std::string resolved_name; + int scale; + int pixel_width; + int pixel_height; +}; + +/// Resolves GTK icon-theme artwork and watches the configuration that makes +/// those raster results stale. Header geometry and interaction remain in Dart. +class BusyMarkGtkHeaderIcons { + public: + using InvalidatedCallback = std::function; + + explicit BusyMarkGtkHeaderIcons(GtkWidget* flutter_view, + GtkIconTheme* icon_theme = nullptr); + ~BusyMarkGtkHeaderIcons(); + + BusyMarkGtkHeaderIcons(const BusyMarkGtkHeaderIcons&) = delete; + BusyMarkGtkHeaderIcons& operator=(const BusyMarkGtkHeaderIcons&) = delete; + + std::optional Load( + const std::vector& candidate_names, + BusyMarkGtkIconDirection direction, + int scale_override = 0, + bool allow_missing = false) const; + + bool Start(InvalidatedCallback callback); + void Stop(); + + int scale() const; + gulong theme_changed_signal_id() const; + gulong scale_changed_signal_id() const; + gulong screen_changed_signal_id() const; + + private: + static void OnThemeChanged(GtkIconTheme* theme, gpointer user_data); + static void OnScaleChanged(GObject* object, + GParamSpec* specification, + gpointer user_data); + static void OnScreenChanged(GtkWidget* widget, + GdkScreen* previous_screen, + gpointer user_data); + + GtkIconTheme* ResolveTheme() const; + void RebindTheme(); + void Invalidate(); + + GtkWidget* flutter_view_; + GtkIconTheme* injected_theme_; + GtkIconTheme* icon_theme_ = nullptr; + InvalidatedCallback callback_; + gulong theme_changed_signal_id_ = 0; + gulong scale_changed_signal_id_ = 0; + gulong screen_changed_signal_id_ = 0; +}; + +#endif // RUNNER_GTK_HEADER_ICONS_H_ diff --git a/linux/runner/gtk_window_preferences.cc b/linux/runner/gtk_window_preferences.cc new file mode 100644 index 00000000..d5ec8903 --- /dev/null +++ b/linux/runner/gtk_window_preferences.cc @@ -0,0 +1,111 @@ +#include "gtk_window_preferences.h" + +#include + +namespace { + +constexpr char kDefaultDecorationLayout[] = + "menu:minimize,maximize,close"; +constexpr char kDefaultDoubleClick[] = "toggle-maximize"; +constexpr char kDefaultMiddleClick[] = "none"; +constexpr char kDefaultRightClick[] = "menu"; + +std::string ValueOrDefault(const gchar* value, const char* fallback) { + return value != nullptr ? value : fallback; +} + +} // namespace + +BusyMarkGtkWindowPreferencesWatcher::BusyMarkGtkWindowPreferencesWatcher( + GtkSettings* settings) + : settings_(settings) {} + +BusyMarkGtkWindowPreferencesWatcher::~BusyMarkGtkWindowPreferencesWatcher() { + Stop(); +} + +BusyMarkGtkWindowPreferences BusyMarkGtkWindowPreferencesWatcher::Read() const { + GtkSettings* settings = ResolveSettings(); + if (settings == nullptr) return Defaults(); + + gchar* decoration_layout = nullptr; + gchar* double_click = nullptr; + gchar* middle_click = nullptr; + gchar* right_click = nullptr; + g_object_get(settings, + "gtk-decoration-layout", &decoration_layout, + "gtk-titlebar-double-click", &double_click, + "gtk-titlebar-middle-click", &middle_click, + "gtk-titlebar-right-click", &right_click, + nullptr); + BusyMarkGtkWindowPreferences preferences = { + ValueOrDefault(decoration_layout, kDefaultDecorationLayout), + ValueOrDefault(double_click, kDefaultDoubleClick), + ValueOrDefault(middle_click, kDefaultMiddleClick), + ValueOrDefault(right_click, kDefaultRightClick), + }; + g_free(decoration_layout); + g_free(double_click); + g_free(middle_click); + g_free(right_click); + return preferences; +} + +BusyMarkGtkWindowPreferences +BusyMarkGtkWindowPreferencesWatcher::Defaults() { + return { + kDefaultDecorationLayout, + kDefaultDoubleClick, + kDefaultMiddleClick, + kDefaultRightClick, + }; +} + +bool BusyMarkGtkWindowPreferencesWatcher::Start(ChangedCallback callback) { + callback_ = std::move(callback); + if (signal_ids_[0] != 0) return true; + + settings_ = ResolveSettings(); + if (settings_ == nullptr) return false; + + constexpr const char* kSignals[] = { + "notify::gtk-decoration-layout", + "notify::gtk-titlebar-double-click", + "notify::gtk-titlebar-middle-click", + "notify::gtk-titlebar-right-click", + }; + for (std::size_t index = 0; index < signal_ids_.size(); index++) { + signal_ids_[index] = + g_signal_connect(settings_, kSignals[index], + G_CALLBACK(OnSettingChanged), this); + } + return true; +} + +void BusyMarkGtkWindowPreferencesWatcher::Stop() { + if (settings_ != nullptr) { + for (gulong signal : signal_ids_) { + if (signal != 0 && g_signal_handler_is_connected(settings_, signal)) { + g_signal_handler_disconnect(settings_, signal); + } + } + } + signal_ids_ = {0, 0, 0, 0}; + callback_ = nullptr; +} + +std::array +BusyMarkGtkWindowPreferencesWatcher::signal_ids() const { + return signal_ids_; +} + +void BusyMarkGtkWindowPreferencesWatcher::OnSettingChanged( + GObject*, GParamSpec*, gpointer user_data) { + auto* watcher = + static_cast(user_data); + if (watcher->callback_) watcher->callback_(watcher->Read()); +} + +GtkSettings* BusyMarkGtkWindowPreferencesWatcher::ResolveSettings() const { + return settings_ != nullptr ? settings_ : gtk_settings_get_default(); +} diff --git a/linux/runner/gtk_window_preferences.h b/linux/runner/gtk_window_preferences.h new file mode 100644 index 00000000..89aaa775 --- /dev/null +++ b/linux/runner/gtk_window_preferences.h @@ -0,0 +1,48 @@ +#ifndef RUNNER_GTK_WINDOW_PREFERENCES_H_ +#define RUNNER_GTK_WINDOW_PREFERENCES_H_ + +#include + +#include +#include +#include + +struct BusyMarkGtkWindowPreferences { + std::string decoration_layout; + std::string double_click; + std::string middle_click; + std::string right_click; +}; + +class BusyMarkGtkWindowPreferencesWatcher { + public: + using ChangedCallback = + std::function; + + explicit BusyMarkGtkWindowPreferencesWatcher(GtkSettings* settings = nullptr); + ~BusyMarkGtkWindowPreferencesWatcher(); + + BusyMarkGtkWindowPreferencesWatcher( + const BusyMarkGtkWindowPreferencesWatcher&) = delete; + BusyMarkGtkWindowPreferencesWatcher& operator=( + const BusyMarkGtkWindowPreferencesWatcher&) = delete; + + BusyMarkGtkWindowPreferences Read() const; + static BusyMarkGtkWindowPreferences Defaults(); + bool Start(ChangedCallback callback); + void Stop(); + + std::array signal_ids() const; + + private: + static void OnSettingChanged(GObject* object, + GParamSpec* specification, + gpointer user_data); + GtkSettings* ResolveSettings() const; + + GtkSettings* settings_; + ChangedCallback callback_; + std::array signal_ids_ = {0, 0, 0, 0}; +}; + +#endif // RUNNER_GTK_WINDOW_PREFERENCES_H_ diff --git a/linux/runner/linux_chrome_host.cc b/linux/runner/linux_chrome_host.cc new file mode 100644 index 00000000..e61264c7 --- /dev/null +++ b/linux/runner/linux_chrome_host.cc @@ -0,0 +1,300 @@ +#include "linux_chrome_host.h" + +#include "gtk_header_icons.h" +#include "gtk_window_preferences.h" + +#include + +struct BusyMarkLinuxChromeHost { + GtkWindow* window; + GtkSettings* settings; + void (*set_theme)(gboolean); + FlMethodChannel* settings_channel; + FlMethodChannel* icons_channel; + FlEventChannel* preferences_channel; + FlEventChannel* animations_channel; + FlEventChannel* icons_changed_channel; + BusyMarkGtkWindowPreferencesWatcher* preferences; + BusyMarkGtkHeaderIcons* icons; + gulong animation_signal = 0; + bool preferences_listening = false; + bool animations_listening = false; + bool icons_listening = false; + gint64 icons_revision = 0; +}; + +static FlValue* preferences_value(BusyMarkLinuxChromeHost* host) { + const auto preferences = host->preferences->Read(); + FlValue* value = fl_value_new_map(); + fl_value_set_string_take(value, "decorationLayout", + fl_value_new_string(preferences.decoration_layout.c_str())); + fl_value_set_string_take(value, "doubleClick", + fl_value_new_string(preferences.double_click.c_str())); + fl_value_set_string_take(value, "middleClick", + fl_value_new_string(preferences.middle_click.c_str())); + fl_value_set_string_take(value, "rightClick", + fl_value_new_string(preferences.right_click.c_str())); + return value; +} + +static FlValue* animations_value(BusyMarkLinuxChromeHost* host) { + gboolean enabled = TRUE; + if (host->settings != nullptr) { + g_object_get(host->settings, "gtk-enable-animations", &enabled, nullptr); + } + return fl_value_new_bool(enabled); +} + +static void send_preferences(BusyMarkLinuxChromeHost* host) { + if (!host->preferences_listening) return; + g_autoptr(FlValue) value = preferences_value(host); + fl_event_channel_send(host->preferences_channel, value, nullptr, nullptr); +} + +static void send_animations(BusyMarkLinuxChromeHost* host) { + if (!host->animations_listening) return; + g_autoptr(FlValue) value = animations_value(host); + fl_event_channel_send(host->animations_channel, value, nullptr, nullptr); +} + +static void animation_changed(GObject*, GParamSpec*, gpointer data) { + send_animations(static_cast(data)); +} + +static FlMethodErrorResponse* preferences_listen(FlEventChannel*, FlValue*, gpointer data) { + auto* host = static_cast(data); + host->preferences_listening = true; + send_preferences(host); + return nullptr; +} +static FlMethodErrorResponse* preferences_cancel(FlEventChannel*, FlValue*, gpointer data) { + static_cast(data)->preferences_listening = false; + return nullptr; +} +static FlMethodErrorResponse* animations_listen(FlEventChannel*, FlValue*, gpointer data) { + auto* host = static_cast(data); + host->animations_listening = true; + send_animations(host); + return nullptr; +} +static FlMethodErrorResponse* animations_cancel(FlEventChannel*, FlValue*, gpointer data) { + static_cast(data)->animations_listening = false; + return nullptr; +} + +static void settings_call(FlMethodChannel*, FlMethodCall* call, gpointer data) { + auto* host = static_cast(data); + const gchar* method = fl_method_call_get_name(call); + g_autoptr(FlValue) result = nullptr; + if (strcmp(method, "getGtkWindowPreferences") == 0) { + result = preferences_value(host); + } else if (strcmp(method, "getGtkAnimationsEnabled") == 0) { + result = animations_value(host); + } else if (strcmp(method, "lowerWindow") == 0) { + GdkWindow* window = gtk_widget_get_window(GTK_WIDGET(host->window)); + if (window != nullptr) gdk_window_lower(window); + } else if (strcmp(method, "setPreferDark") == 0) { + FlValue* value = fl_method_call_get_args(call); + if (value == nullptr || fl_value_get_type(value) != FL_VALUE_TYPE_BOOL) { + fl_method_call_respond_error(call, "invalid-arguments", "Expected a boolean theme preference.", nullptr, nullptr); + return; + } + host->set_theme(fl_value_get_bool(value)); + } else { + fl_method_call_respond_not_implemented(call, nullptr); + return; + } + fl_method_call_respond_success(call, result, nullptr); +} + +// The icon method/event handlers below resolve native artwork; they never +// receive application-header or sidebar layout coordinates. +static void send_icons_changed_event(BusyMarkLinuxChromeHost* self) { + if (!self->icons_listening || + self->icons_changed_channel == nullptr || + self->icons == nullptr) { + return; + } + g_autoptr(FlValue) event = fl_value_new_map(); + fl_value_set_string_take( + event, "revision", + fl_value_new_int(self->icons_revision)); + fl_value_set_string_take(event, "scale", + fl_value_new_int(self->icons->scale())); + g_autoptr(GError) error = nullptr; + if (!fl_event_channel_send(self->icons_changed_channel, + event, nullptr, &error)) { + const gchar* message = error != nullptr ? error->message : "unknown error"; + g_warning("Failed to send GTK header-icon invalidation: %s", message); + } +} + +static FlMethodErrorResponse* icons_listen_cb( + FlEventChannel*, FlValue*, gpointer user_data) { + auto* self = static_cast(user_data); + self->icons_listening = TRUE; + if (self->icons_revision > 0) { + send_icons_changed_event(self); + } + return nullptr; +} + +static FlMethodErrorResponse* icons_cancel_cb( + FlEventChannel*, FlValue*, gpointer user_data) { + auto* self = static_cast(user_data); + self->icons_listening = FALSE; + return nullptr; +} + +static gboolean parse_gtk_header_icon_request( + FlValue* request, + const gchar** key_out, + std::vector* names_out, + BusyMarkGtkIconDirection* direction_out, + gboolean* allow_missing_out) { + if (request == nullptr || fl_value_get_type(request) != FL_VALUE_TYPE_MAP) { + return FALSE; + } + FlValue* key = fl_value_lookup_string(request, "key"); + FlValue* names = fl_value_lookup_string(request, "names"); + FlValue* direction = fl_value_lookup_string(request, "direction"); + FlValue* allow_missing = fl_value_lookup_string(request, "allowMissing"); + if (key == nullptr || fl_value_get_type(key) != FL_VALUE_TYPE_STRING || + names == nullptr || fl_value_get_type(names) != FL_VALUE_TYPE_LIST || + fl_value_get_length(names) == 0 || direction == nullptr || + fl_value_get_type(direction) != FL_VALUE_TYPE_STRING || + (allow_missing != nullptr && + fl_value_get_type(allow_missing) != FL_VALUE_TYPE_BOOL)) { + return FALSE; + } + names_out->clear(); + for (size_t index = 0; index < fl_value_get_length(names); index++) { + FlValue* name = fl_value_get_list_value(names, index); + if (name == nullptr || fl_value_get_type(name) != FL_VALUE_TYPE_STRING) { + return FALSE; + } + names_out->emplace_back(fl_value_get_string(name)); + } + const gchar* direction_value = fl_value_get_string(direction); + if (g_strcmp0(direction_value, "ltr") == 0) { + *direction_out = BusyMarkGtkIconDirection::kLtr; + } else if (g_strcmp0(direction_value, "rtl") == 0) { + *direction_out = BusyMarkGtkIconDirection::kRtl; + } else { + return FALSE; + } + *allow_missing_out = + allow_missing == nullptr ? FALSE : fl_value_get_bool(allow_missing); + *key_out = fl_value_get_string(key); + return TRUE; +} + +static FlValue* gtk_header_icon_asset_to_fl_value( + const BusyMarkGtkHeaderIconAsset& asset) { + FlValue* value = fl_value_new_map(); + fl_value_set_string_take( + value, "bytes", + fl_value_new_uint8_list(asset.png_bytes.data(), asset.png_bytes.size())); + fl_value_set_string_take( + value, "resolvedName", + fl_value_new_string(asset.resolved_name.c_str())); + fl_value_set_string_take(value, "scale", fl_value_new_int(asset.scale)); + fl_value_set_string_take(value, "pixelWidth", + fl_value_new_int(asset.pixel_width)); + fl_value_set_string_take(value, "pixelHeight", + fl_value_new_int(asset.pixel_height)); + return value; +} + +static void icons_method_call_cb(FlMethodChannel*, + FlMethodCall* method_call, + gpointer user_data) { + auto* self = static_cast(user_data); + if (g_strcmp0(fl_method_call_get_name(method_call), "loadIcons") != 0) { + fl_method_call_respond_not_implemented(method_call, nullptr); + return; + } + FlValue* requests = fl_method_call_get_args(method_call); + if (requests == nullptr || + fl_value_get_type(requests) != FL_VALUE_TYPE_LIST) { + fl_method_call_respond_error( + method_call, "invalid-arguments", + "loadIcons requires a list of keyed icon requests.", nullptr, + nullptr); + return; + } + + g_autoptr(FlValue) result = fl_value_new_map(); + for (size_t index = 0; index < fl_value_get_length(requests); index++) { + const gchar* key = nullptr; + std::vector names; + BusyMarkGtkIconDirection direction = BusyMarkGtkIconDirection::kLtr; + gboolean allow_missing = FALSE; + if (!parse_gtk_header_icon_request( + fl_value_get_list_value(requests, index), &key, &names, + &direction, &allow_missing)) { + fl_method_call_respond_error( + method_call, "invalid-arguments", + "Each icon request requires key, non-empty names, ltr/rtl " + "direction, and an optional boolean allowMissing.", + nullptr, nullptr); + return; + } + const auto asset = self->icons->Load( + names, direction, 0, allow_missing); + if (asset) { + fl_value_set_string_take( + result, key, gtk_header_icon_asset_to_fl_value(*asset)); + } else { + fl_value_set_string_take(result, key, fl_value_new_null()); + } + } + fl_method_call_respond_success(method_call, result, nullptr); +} + + +BusyMarkLinuxChromeHost* busymark_linux_chrome_host_new( + FlView* view, GtkWindow* window, void (*set_theme)(gboolean)) { + auto* host = new BusyMarkLinuxChromeHost{}; + host->window = window; + host->settings = gtk_settings_get_default(); + host->set_theme = set_theme; + host->preferences = new BusyMarkGtkWindowPreferencesWatcher(host->settings); + host->icons = new BusyMarkGtkHeaderIcons(GTK_WIDGET(view)); + g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new(); + FlBinaryMessenger* messenger = fl_engine_get_binary_messenger(fl_view_get_engine(view)); + host->settings_channel = fl_method_channel_new(messenger, "com.busymark.app/linux_chrome", FL_METHOD_CODEC(codec)); + fl_method_channel_set_method_call_handler(host->settings_channel, settings_call, host, nullptr); + host->icons_channel = fl_method_channel_new(messenger, "com.busymark.app/gtk_header_icons", FL_METHOD_CODEC(codec)); + fl_method_channel_set_method_call_handler(host->icons_channel, icons_method_call_cb, host, nullptr); + host->preferences_channel = fl_event_channel_new(messenger, "com.busymark.app/gtk_window_preferences", FL_METHOD_CODEC(codec)); + fl_event_channel_set_stream_handlers(host->preferences_channel, preferences_listen, preferences_cancel, host, nullptr); + host->animations_channel = fl_event_channel_new(messenger, "com.busymark.app/gtk_animation_settings", FL_METHOD_CODEC(codec)); + fl_event_channel_set_stream_handlers(host->animations_channel, animations_listen, animations_cancel, host, nullptr); + host->icons_changed_channel = fl_event_channel_new(messenger, "com.busymark.app/gtk_header_icons_changed", FL_METHOD_CODEC(codec)); + fl_event_channel_set_stream_handlers(host->icons_changed_channel, icons_listen_cb, icons_cancel_cb, host, nullptr); + host->preferences->Start([host](const auto&) { send_preferences(host); }); + host->icons->Start([host]() { + host->icons_revision++; + send_icons_changed_event(host); + }); + if (host->settings != nullptr) { + host->animation_signal = g_signal_connect(host->settings, "notify::gtk-enable-animations", G_CALLBACK(animation_changed), host); + } + return host; +} + +void busymark_linux_chrome_host_free(BusyMarkLinuxChromeHost* host) { + if (host == nullptr) return; + if (host->animation_signal != 0 && host->settings != nullptr) { + g_signal_handler_disconnect(host->settings, host->animation_signal); + } + delete host->preferences; + delete host->icons; + g_clear_object(&host->settings_channel); + g_clear_object(&host->icons_channel); + g_clear_object(&host->preferences_channel); + g_clear_object(&host->animations_channel); + g_clear_object(&host->icons_changed_channel); + delete host; +} diff --git a/linux/runner/linux_chrome_host.h b/linux/runner/linux_chrome_host.h new file mode 100644 index 00000000..421e11d5 --- /dev/null +++ b/linux/runner/linux_chrome_host.h @@ -0,0 +1,12 @@ +#ifndef RUNNER_LINUX_CHROME_HOST_H_ +#define RUNNER_LINUX_CHROME_HOST_H_ + +#include +#include + +struct BusyMarkLinuxChromeHost; +BusyMarkLinuxChromeHost* busymark_linux_chrome_host_new( + FlView* view, GtkWindow* window, void (*set_theme)(gboolean)); +void busymark_linux_chrome_host_free(BusyMarkLinuxChromeHost* host); + +#endif diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index e703b108..a48642c2 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -11,6 +11,7 @@ #include "flutter/generated_plugin_registrant.h" #include "gtk_accent_host.h" +#include "linux_chrome_host.h" #include "rich_clipboard_host.h" #include "secure_credential_host.h" #include "video_player_host.h" @@ -18,38 +19,12 @@ #include "writerside_dialog_host.h" constexpr char kApplicationDisplayName[] = "BusyMark"; -constexpr char kHeaderBarChannel[] = "com.busymark.app/headerbar"; constexpr char kNativeMenuChannel[] = "busymark/native_menus"; constexpr char kGitBranchMenuIcon[] = "busymark-git-branch-symbolic"; constexpr char kAssetInputChannel[] = "com.busymark.app/asset_input"; -constexpr gint kHeaderButtonHeight = 32; -constexpr gint kHeaderButtonSpacing = 8; -constexpr gint kHeaderSidebarInset = 8; -constexpr gdouble kHeaderBackdropForegroundOpacity = 0.50; -constexpr gdouble kHeaderDisabledForegroundOpacity = 0.38; -constexpr gdouble kHeaderDisabledBackdropForegroundOpacity = - kHeaderDisabledForegroundOpacity * kHeaderBackdropForegroundOpacity; -constexpr char kDefaultHeaderbarBackground[] = "#272727"; -constexpr char kDefaultSidebarBackground[] = "#393939"; -constexpr char kDefaultSidebarBorder[] = "rgba(16,16,16,0.35)"; -constexpr char kDefaultForeground[] = "#F7F7F7"; -constexpr char kDefaultModalBarrierColor[] = "rgba(0,0,0,0.25)"; -constexpr char kDefaultTooltipBackground[] = "rgba(0,0,0,0.8)"; -constexpr char kDefaultTooltipForeground[] = "#FFFFFF"; -constexpr char kDefaultTooltipBorder[] = "rgba(255,255,255,0.1)"; -constexpr gdouble kDefaultTooltipRadius = 8.0; -constexpr gdouble kDefaultTooltipFontSize = 14.0; -constexpr gdouble kDefaultTooltipHorizontalPadding = 10.0; -constexpr gdouble kDefaultTooltipVerticalPadding = 6.0; -constexpr gdouble kDefaultTooltipMinimumHeight = 30.0; -constexpr gdouble kTooltipBorderWidth = 1.0; -// GtkTooltipWindow applies a private GtkContainer border-width of 6 px around -// its content. Compensate for it so native header hints have the same visible -// border-to-text padding as Flutter tooltips. -constexpr gdouble kGtkTooltipContainerInset = 6.0; -// Yaru GTK 3 adds a zero-blur 23%/75% black ring around CSD windows. Current -// Ubuntu apps retain the diffuse shadow without that legacy hard edge. Reuse -// Yaru's geometry here; Handy continues to own clipping, radii, and states. +constexpr char kMenuAccelAttribute[] = "accel"; +constexpr char kNativeMenuActionNamespace[] = "busymark-native-menu"; +constexpr char kNativeMenuActionIndexKey[] = "busymark-native-menu-index"; constexpr char kLegacyYaruWindowShadowCompatibilityCss[] = "window#busymark-window:not(.solid-csd):not(.maximized):" "not(.fullscreen):not(.tiled):not(.tiled-top):not(.tiled-right):" @@ -74,19 +49,12 @@ constexpr char kLegacyYaruWindowShadowCompatibilityCss[] = "not(.fullscreen) > decoration {" "box-shadow: 0 0 0 20px transparent;" "}"; -constexpr char kLtrIsolateStart[] = "\xE2\x81\xA6"; -constexpr char kBidiIsolateEnd[] = "\xE2\x81\xA9"; -constexpr char kMenuAccelAttribute[] = "accel"; -constexpr char kHeaderApplicationActiveStyleClass[] = - "busymark-focus-active"; -constexpr char kHeaderApplicationBackdropStyleClass[] = - "busymark-focus-backdrop"; struct _MyApplication { GtkApplication parent_instance; char** dart_entrypoint_arguments; BusyMarkGtkAccentHost* gtk_accent_host; - FlMethodChannel* header_bar_channel; + BusyMarkLinuxChromeHost* chrome_host; FlMethodChannel* native_menu_channel; FlMethodChannel* writerside_dialog_channel; FlMethodChannel* asset_input_channel; @@ -94,96 +62,13 @@ struct _MyApplication { FlMethodChannel* rich_clipboard_channel; BusyMarkWebRenderHost* visualization_host; BusyMarkVideoPlayerHost* video_player_host; - GtkCssProvider* header_bar_css_provider; + GtkCssProvider* native_surface_css_provider; GtkWindow* main_window; GtkWidget* flutter_view; GtkWidget* flutter_overlay; - GtkWidget* titlebar_handle; - GtkWidget* titlebar_overlay; - GtkWidget* modal_scrim; - GtkWidget* titlebar_box; - GtkHeaderBar* header_bar; - GtkWidget* sidebar_header_box; - GtkWidget* sidebar_title_label; - GMenu* main_menu_model; - GtkWidget* header_start_box; - GtkWidget* back_button; - GtkWidget* sidebar_toggle_button; - GtkWidget* title_stack; - GtkWidget* title_label; - GtkWidget* search_entry; - gboolean document_controls_visible; - gboolean search_visible; - gboolean can_export_pdf; - gboolean can_export_html; - GtkWidget* view_mode_box; - GtkWidget* view_mode_button; - GtkWidget* view_mode_icon; - GtkWidget* view_mode_menu; - GMenu* view_mode_menu_model; - GtkWidget* refresh_button; - GtkWidget* search_button; - GtkWidget* main_menu_button; - GtkWidget* main_menu; - GSimpleActionGroup* header_action_group; - GSimpleAction* view_mode_action; - GSimpleAction* full_screen_action; - gchar* view_mode; - gchar* search_query; - gchar* background_color; - gchar* sidebar_background_color; - gchar* foreground_color; - gchar* sidebar_border_color; - gchar* modal_barrier_color; - gchar* tooltip_background_color; - gchar* tooltip_foreground_color; - gchar* tooltip_border_color; - gdouble tooltip_radius; - gdouble tooltip_font_size; - gdouble tooltip_horizontal_padding; - gdouble tooltip_vertical_padding; - gdouble tooltip_minimum_height; - gint sidebar_width; - gboolean sidebar_visible; - gboolean text_direction_rtl; - gboolean back_visible; - gboolean full_screen; - gboolean search_active; - gboolean modal_barrier_visible; - gint modal_barrier_depth; - gboolean suppress_header_actions; - gchar* header_configuration_session_id; - gint64 header_configuration_revision; }; - -struct HeaderBarConfiguration { - const gchar* session_id; - gint64 revision; - const gchar* title; - const gchar* view_mode; - gboolean can_refresh; - gboolean can_export_pdf; - gboolean can_export_html; - gboolean document_controls_visible; - gboolean search_active; - gboolean search_visible; - gboolean sidebar_visible; - gboolean sidebar_toggle_visible; - gboolean back_visible; - gboolean full_screen; - gboolean modal_barrier_visible; - gint64 modal_barrier_depth; - const gchar* search_query; - const gchar* text_direction; - gdouble sidebar_width; - FlValue* labels; - FlValue* theme; -}; - G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) -static void schedule_header_bar_focus_state_refresh(MyApplication* self); - static gchar* gtk_accelerator_from_shortcut_label(const gchar* shortcut) { if (shortcut == nullptr || shortcut[0] == '\0') { return nullptr; @@ -372,59 +257,11 @@ static gboolean uses_legacy_yaru_window_shadow() { strstr(normalized_theme, "high-contrast") == nullptr; } -static void respond_success(FlMethodCall* method_call) { - g_autoptr(FlValue) result = fl_value_new_null(); - fl_method_call_respond_success(method_call, result, nullptr); -} - static void respond_bool(FlMethodCall* method_call, gboolean value) { g_autoptr(FlValue) result = fl_value_new_bool(value); fl_method_call_respond_success(method_call, result, nullptr); } -static void respond_int64(FlMethodCall* method_call, gint64 value) { - g_autoptr(FlValue) result = fl_value_new_int(value); - fl_method_call_respond_success(method_call, result, nullptr); -} - -static void respond_invalid_configuration(FlMethodCall* method_call, - const gchar* message) { - fl_method_call_respond_error(method_call, "invalid-header-configuration", - message, nullptr, nullptr); -} - -static const gchar* fl_method_string_arg(FlValue* args) { - return args != nullptr && fl_value_get_type(args) == FL_VALUE_TYPE_STRING - ? fl_value_get_string(args) - : nullptr; -} - -static gboolean fl_method_bool_arg(FlValue* args) { - return args != nullptr && fl_value_get_type(args) == FL_VALUE_TYPE_BOOL - ? fl_value_get_bool(args) - : FALSE; -} - -static gint64 fl_method_int_arg(FlValue* args, gint64 fallback) { - return args != nullptr && fl_value_get_type(args) == FL_VALUE_TYPE_INT - ? fl_value_get_int(args) - : fallback; -} - -static gdouble fl_method_double_arg(FlValue* args, gdouble fallback) { - if (args == nullptr) { - return fallback; - } - switch (fl_value_get_type(args)) { - case FL_VALUE_TYPE_FLOAT: - return fl_value_get_float(args); - case FL_VALUE_TYPE_INT: - return static_cast(fl_value_get_int(args)); - default: - return fallback; - } -} - static const gchar* fl_lookup_string_arg(FlValue* args, const gchar* key) { if (args == nullptr || fl_value_get_type(args) != FL_VALUE_TYPE_MAP) { return nullptr; @@ -436,20 +273,6 @@ static const gchar* fl_lookup_string_arg(FlValue* args, const gchar* key) { return fl_value_get_string(value); } -static gboolean fl_lookup_optional_bool_arg(FlValue* args, - const gchar* key, - gboolean* value_out) { - if (args == nullptr || fl_value_get_type(args) != FL_VALUE_TYPE_MAP) { - return FALSE; - } - FlValue* value = fl_value_lookup_string(args, key); - if (value == nullptr || fl_value_get_type(value) != FL_VALUE_TYPE_BOOL) { - return FALSE; - } - *value_out = fl_value_get_bool(value); - return TRUE; -} - static gboolean fl_lookup_int64_arg(FlValue* args, const gchar* key, gint64* value_out) { @@ -485,18 +308,6 @@ static gboolean fl_lookup_double_arg(FlValue* args, return FALSE; } -static void update_bounded_double_arg(FlValue* args, - const gchar* key, - gdouble minimum, - gdouble maximum, - gdouble* target) { - gdouble value = 0; - if (fl_lookup_double_arg(args, key, &value) && value >= minimum && - value <= maximum) { - *target = value; - } -} - static FlValue* fl_lookup_map_arg(FlValue* args, const gchar* key) { if (args == nullptr || fl_value_get_type(args) != FL_VALUE_TYPE_MAP) { return nullptr; @@ -506,1745 +317,6 @@ static FlValue* fl_lookup_map_arg(FlValue* args, const gchar* key) { ? value : nullptr; } - -static gboolean has_header_bar(MyApplication* self) { - return self->header_bar != nullptr && GTK_IS_HEADER_BAR(self->header_bar); -} - -static gboolean is_css_hex_color(const gchar* value) { - if (value == nullptr || strlen(value) != 7 || value[0] != '#') { - return FALSE; - } - for (int i = 1; i < 7; i++) { - if (!g_ascii_isxdigit(value[i])) { - return FALSE; - } - } - return TRUE; -} - -static gboolean is_css_rgba_color(const gchar* value) { - if (value == nullptr || !g_str_has_prefix(value, "rgba(")) { - return FALSE; - } - gint red = -1; - gint green = -1; - gint blue = -1; - gdouble alpha = -1; - gchar extra = 0; - if (sscanf(value, "rgba(%d,%d,%d,%lf)%c", &red, &green, &blue, &alpha, - &extra) != 4) { - return FALSE; - } - return red >= 0 && red <= 255 && green >= 0 && green <= 255 && blue >= 0 && - blue <= 255 && alpha >= 0 && alpha <= 1; -} - -static gboolean is_css_color_token(const gchar* value) { - return is_css_hex_color(value) || is_css_rgba_color(value); -} - -static const gchar* css_color_or(const gchar* value, const gchar* fallback) { - return is_css_color_token(value) ? value : fallback; -} - -static gchar* modal_barrier_color_for_depth(const gchar* color, gint depth) { - GdkRGBA barrier; - if (!gdk_rgba_parse(&barrier, color)) { - return g_strdup(color); - } - const gint effective_depth = std::max(0, depth); - barrier.alpha = - 1.0 - std::pow(1.0 - barrier.alpha, effective_depth); - return gdk_rgba_to_string(&barrier); -} - -static void replace_css_color_field(gchar** target, const gchar* value) { - g_free(*target); - *target = is_css_color_token(value) ? g_strdup(value) : nullptr; -} - -static void set_widget_visible(GtkWidget* widget, gboolean visible) { - if (widget != nullptr && GTK_IS_WIDGET(widget)) { - gtk_widget_set_no_show_all(widget, !visible); - if (visible) { - // Some header controls are nested in containers that start hidden. - // Showing only the container leaves children skipped by the initial - // gtk_widget_show_all() invisible, producing an empty header slot. - gtk_widget_show_all(widget); - } else { - gtk_widget_hide(widget); - } - } -} - -static void set_widget_sensitive(GtkWidget* widget, gboolean sensitive) { - if (widget != nullptr && GTK_IS_WIDGET(widget)) { - gtk_widget_set_sensitive(widget, sensitive); - } -} - -static gboolean stop_modal_scrim_event(GtkWidget*, GdkEvent*, gpointer) { - return GDK_EVENT_STOP; -} - -static void close_header_menu_button(GtkWidget* menu_button) { - if (menu_button == nullptr || !GTK_IS_MENU_BUTTON(menu_button)) { - return; - } - GtkMenu* menu = gtk_menu_button_get_popup(GTK_MENU_BUTTON(menu_button)); - if (menu != nullptr && GTK_IS_MENU(menu) && - gtk_widget_get_visible(GTK_WIDGET(menu))) { - gtk_menu_shell_deactivate(GTK_MENU_SHELL(menu)); - } - if (GTK_IS_TOGGLE_BUTTON(menu_button)) { - gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(menu_button), FALSE); - } -} - -static GtkTextDirection app_text_direction(MyApplication* self) { - return self->text_direction_rtl ? GTK_TEXT_DIR_RTL : GTK_TEXT_DIR_LTR; -} - -static void set_widget_direction(GtkWidget* widget, GtkTextDirection direction) { - if (widget != nullptr && GTK_IS_WIDGET(widget)) { - gtk_widget_set_direction(widget, direction); - } -} - -static void set_widget_horizontal_margins(GtkWidget* widget, - gint margin_start, - gint margin_end) { - if (widget != nullptr && GTK_IS_WIDGET(widget)) { - gtk_widget_set_margin_start(widget, margin_start); - gtk_widget_set_margin_end(widget, margin_end); - } -} - -static void set_toggle_button_active(MyApplication* self, - GtkWidget* widget, - gboolean active) { - if (widget == nullptr || !GTK_IS_TOGGLE_BUTTON(widget)) { - return; - } - const gboolean previous = self->suppress_header_actions; - self->suppress_header_actions = TRUE; - gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(widget), active); - self->suppress_header_actions = previous; -} - -static void update_sidebar_header_geometry(MyApplication* self) { - if (self->sidebar_header_box == nullptr || - !GTK_IS_WIDGET(self->sidebar_header_box)) { - return; - } - const gint width = self->sidebar_visible ? self->sidebar_width : 0; - gtk_widget_set_size_request(self->sidebar_header_box, width, -1); - set_widget_visible(self->sidebar_header_box, width > 0); -} - -static void update_titlebar_direction(MyApplication* self) { - const GtkTextDirection direction = app_text_direction(self); - if (self->titlebar_box != nullptr && GTK_IS_BOX(self->titlebar_box) && - has_header_bar(self) && self->sidebar_header_box != nullptr && - GTK_IS_WIDGET(self->sidebar_header_box)) { - gtk_widget_set_direction(self->titlebar_box, GTK_TEXT_DIR_LTR); - if (self->text_direction_rtl) { - gtk_box_reorder_child(GTK_BOX(self->titlebar_box), - GTK_WIDGET(self->header_bar), 0); - gtk_box_reorder_child(GTK_BOX(self->titlebar_box), - self->sidebar_header_box, 1); - } else { - gtk_box_reorder_child(GTK_BOX(self->titlebar_box), - self->sidebar_header_box, 0); - gtk_box_reorder_child(GTK_BOX(self->titlebar_box), - GTK_WIDGET(self->header_bar), 1); - } - } - - set_widget_direction(self->sidebar_header_box, direction); - set_widget_direction(GTK_WIDGET(self->header_bar), direction); - set_widget_direction(self->header_start_box, direction); - set_widget_direction(self->sidebar_toggle_button, direction); - set_widget_direction(self->back_button, direction); - set_widget_direction(self->title_stack, direction); - set_widget_direction(self->title_label, direction); - set_widget_direction(self->search_entry, direction); - set_widget_direction(self->view_mode_box, direction); - set_widget_direction(self->view_mode_button, direction); - set_widget_direction(self->view_mode_icon, direction); - set_widget_direction(self->refresh_button, direction); - set_widget_direction(self->search_button, direction); - set_widget_direction(self->main_menu_button, direction); - set_widget_direction(self->main_menu, direction); - set_widget_direction(self->view_mode_menu, direction); - - // GTK 3 resolves logical margins against the widget direction at setter - // time, so reapply both sides after a live LTR/RTL direction change. - set_widget_horizontal_margins(self->header_start_box, kHeaderSidebarInset, - 0); -} - -static void refresh_header_bar_css(MyApplication* self) { - if (!has_header_bar(self)) { - return; - } - - const gchar* background = - css_color_or(self->background_color, kDefaultHeaderbarBackground); - const gchar* sidebar_background = - css_color_or(self->sidebar_background_color, kDefaultSidebarBackground); - const gchar* foreground = - css_color_or(self->foreground_color, kDefaultForeground); - const gchar* sidebar_border = - css_color_or(self->sidebar_border_color, kDefaultSidebarBorder); - const gboolean use_legacy_yaru_compatibility = - uses_legacy_yaru_window_shadow(); - const gchar* tooltip_background = css_color_or( - self->tooltip_background_color, kDefaultTooltipBackground); - const gchar* tooltip_foreground = css_color_or( - self->tooltip_foreground_color, kDefaultTooltipForeground); - const gchar* tooltip_border = - css_color_or(self->tooltip_border_color, kDefaultTooltipBorder); - const gdouble tooltip_label_horizontal_padding = std::max( - 0.0, self->tooltip_horizontal_padding - - (kGtkTooltipContainerInset - kTooltipBorderWidth)); - const gdouble tooltip_label_vertical_padding = std::max( - 0.0, self->tooltip_vertical_padding - - (kGtkTooltipContainerInset - kTooltipBorderWidth)); - const gdouble tooltip_label_minimum_height = std::max( - 0.0, self->tooltip_minimum_height - - kGtkTooltipContainerInset * 2 - - tooltip_label_vertical_padding * 2); - g_autofree gchar* tooltip_css = g_strdup_printf( - "tooltip," - "tooltip.background," - "tooltip box," - "tooltip.background box {" - "margin: 0;" - "padding: 0;" - "min-width: 0;" - "min-height: 0;" - "}" - "tooltip.background {" - "background-color: %s;" - "background-image: none;" - "background-clip: padding-box;" - "border: %.2fpx solid %s;" - "border-radius: %.2fpx;" - "}" - "tooltip decoration," - "tooltip.csd decoration {" - "background-color: transparent;" - "border-radius: %.2fpx;" - "box-shadow: none;" - "}" - "tooltip * {" - "background-color: transparent;" - "color: %s;" - "}" - "tooltip label," - "tooltip.background label {" - "margin: 0;" - "padding: %.2fpx %.2fpx;" - "min-width: 0;" - "min-height: %.2fpx;" - "font-family: Ubuntu;" - "font-size: %.2fpx;" - "font-weight: 400;" - "}", - tooltip_background, kTooltipBorderWidth, tooltip_border, - self->tooltip_radius, self->tooltip_radius, tooltip_foreground, - tooltip_label_vertical_padding, tooltip_label_horizontal_padding, - tooltip_label_minimum_height, self->tooltip_font_size); - g_autofree gchar* header_focus_css = g_strdup_printf( - ".busymark-titlebar.%s .busymark-sidebar-header label," - ".busymark-titlebar.%s .busymark-header-title {" - "color: %s;" - "}" - ".busymark-titlebar.%s .busymark-sidebar-header label," - ".busymark-titlebar.%s .busymark-header-title {" - "color: alpha(%s, %.2f);" - "}" - ".busymark-titlebar.%s " - ".busymark-header-control:not(:disabled)," - ".busymark-titlebar.%s " - "headerbar button.titlebutton:not(:disabled) {" - "color: %s;" - "-gtk-icon-effect: none;" - "}" - ".busymark-titlebar.%s " - ".busymark-header-control:not(:disabled)," - ".busymark-titlebar.%s " - "headerbar button.titlebutton:not(:disabled) {" - "color: alpha(%s, %.2f);" - "-gtk-icon-effect: none;" - "}" - ".busymark-titlebar.%s .busymark-header-control:disabled," - ".busymark-titlebar.%s headerbar button.titlebutton:disabled {" - "color: alpha(%s, %.2f);" - "-gtk-icon-effect: none;" - "}" - ".busymark-titlebar.%s .busymark-header-control:disabled," - ".busymark-titlebar.%s headerbar button.titlebutton:disabled {" - "color: alpha(%s, %.2f);" - "-gtk-icon-effect: none;" - "}", - kHeaderApplicationActiveStyleClass, - kHeaderApplicationActiveStyleClass, foreground, - kHeaderApplicationBackdropStyleClass, - kHeaderApplicationBackdropStyleClass, foreground, - kHeaderBackdropForegroundOpacity, - kHeaderApplicationActiveStyleClass, - kHeaderApplicationActiveStyleClass, foreground, - kHeaderApplicationBackdropStyleClass, - kHeaderApplicationBackdropStyleClass, foreground, - kHeaderBackdropForegroundOpacity, - kHeaderApplicationActiveStyleClass, - kHeaderApplicationActiveStyleClass, foreground, - kHeaderDisabledForegroundOpacity, - kHeaderApplicationBackdropStyleClass, - kHeaderApplicationBackdropStyleClass, foreground, - kHeaderDisabledBackdropForegroundOpacity); - g_autofree gchar* modal = modal_barrier_color_for_depth( - css_color_or(self->modal_barrier_color, kDefaultModalBarrierColor), - self->modal_barrier_depth); - const gchar* window_shadow_css = use_legacy_yaru_compatibility - ? kLegacyYaruWindowShadowCompatibilityCss - : ""; - - gtk_style_context_add_class(gtk_widget_get_style_context(self->titlebar_box), - "busymark-titlebar"); - gtk_style_context_add_class( - gtk_widget_get_style_context(GTK_WIDGET(self->header_bar)), - "busymark-headerbar"); - - g_autofree gchar* css = g_strdup_printf( - "window#busymark-window," - "window#busymark-window:backdrop {" - "background-color: %s;" - "background-image: none;" - "}" - "%s" - "%s" - ".busymark-titlebar," - ".busymark-titlebar:backdrop {" - "background-color: %s;" - "background-image: none;" - "color: %s;" - "border: none;" - "box-shadow: none;" - "}" - "headerbar.busymark-headerbar," - "headerbar.busymark-headerbar:backdrop {" - "background-color: %s;" - "background-image: none;" - "color: %s;" - "border: none;" - "box-shadow: none;" - "}" - "headerbar.busymark-headerbar:dir(ltr) {" - "padding-left: 0;" - "}" - "headerbar.busymark-headerbar:dir(rtl) {" - "padding-right: 0;" - "}" - ".busymark-sidebar-header," - ".busymark-sidebar-header:backdrop {" - "background-color: %s;" - "background-image: none;" - "color: %s;" - "border: none;" - "box-shadow: none;" - "}" - ".busymark-sidebar-header label {" - "color: %s;" - "font-weight: 800;" - "}" - ".busymark-sidebar-header label:backdrop {" - "color: alpha(%s, %.2f);" - "}" - ".busymark-sidebar-header:dir(ltr) {" - "border-right: 1px solid %s;" - "}" - ".busymark-sidebar-header:dir(rtl) {" - "border-left: 1px solid %s;" - "}" - "%s" - // Legacy Yaru GTK 3 uses an absolute near-black image for active and - // checked buttons. BusyMark-owned controls use neutral current-color - // layers while GTK continues to own geometry, focus, and motion. - ".busymark-titlebar " - ".busymark-header-control:not(.suggested-action):not(:disabled) {" - "background-color: transparent;" - "background-image: none;" - "border-color: transparent;" - "box-shadow: none;" - "}" - ".busymark-titlebar " - ".busymark-header-control:not(.suggested-action):not(:disabled):hover {" - "background-color: alpha(currentColor, 0.07);" - "background-image: none;" - "}" - ".busymark-titlebar " - ".busymark-header-control:not(.suggested-action):not(:disabled):active {" - "background-color: alpha(currentColor, 0.16);" - "background-image: none;" - "}" - ".busymark-titlebar " - ".busymark-header-control:not(.suggested-action):not(:disabled):checked {" - "background-color: alpha(currentColor, 0.10);" - "background-image: none;" - "}" - ".busymark-titlebar " - ".busymark-header-control:not(.suggested-action):" - "not(:disabled):checked:hover {" - "background-color: alpha(currentColor, 0.13);" - "background-image: none;" - "}" - ".busymark-titlebar " - ".busymark-header-control:not(.suggested-action):" - "not(:disabled):checked:active {" - "background-color: alpha(currentColor, 0.19);" - "background-image: none;" - "}" - // Keep native controls visually enabled beneath the modal tint while - // removing transient hover and checked surfaces. The overlay below owns - // interaction blocking. - ".busymark-titlebar.busymark-modal-open " - ".busymark-header-control," - ".busymark-titlebar.busymark-modal-open " - ".busymark-header-control:hover," - ".busymark-titlebar.busymark-modal-open " - ".busymark-header-control:active," - ".busymark-titlebar.busymark-modal-open " - ".busymark-header-control:checked," - ".busymark-titlebar.busymark-modal-open " - ".busymark-header-control:checked:hover," - ".busymark-titlebar.busymark-modal-open " - ".busymark-header-control:checked:active {" - "background-color: transparent;" - "background-image: none;" - "border-color: transparent;" - "box-shadow: none;" - "}" - ".busymark-modal-scrim {" - "background-color: %s;" - "background-image: none;" - "}", - background, window_shadow_css, tooltip_css, - background, foreground, background, foreground, sidebar_background, - foreground, foreground, foreground, kHeaderBackdropForegroundOpacity, - sidebar_border, sidebar_border, header_focus_css, modal); - - g_autoptr(GError) error = nullptr; - GtkCssProvider* provider = gtk_css_provider_new(); - gtk_css_provider_load_from_data(provider, css, -1, &error); - if (error != nullptr) { - g_warning("Failed to load BusyMark headerbar CSS: %s", error->message); - g_object_unref(provider); - return; - } - - GdkScreen* screen = gtk_widget_get_screen(GTK_WIDGET(self->header_bar)); - if (self->header_bar_css_provider != nullptr) { - gtk_style_context_remove_provider_for_screen( - screen, GTK_STYLE_PROVIDER(self->header_bar_css_provider)); - g_clear_object(&self->header_bar_css_provider); - } - self->header_bar_css_provider = provider; - gtk_style_context_add_provider_for_screen( - screen, GTK_STYLE_PROVIDER(self->header_bar_css_provider), - GTK_STYLE_PROVIDER_PRIORITY_APPLICATION); -} - -static gboolean refresh_header_bar_focus_state_cb(gpointer user_data) { - MyApplication* self = MY_APPLICATION(user_data); - if (self->titlebar_handle == nullptr || - !GTK_IS_WIDGET(self->titlebar_handle)) { - return G_SOURCE_REMOVE; - } - - const gboolean application_active = - self->main_window != nullptr && - gtk_window_is_active(self->main_window); - GtkStyleContext* context = - gtk_widget_get_style_context(self->titlebar_handle); - gtk_style_context_remove_class(context, - kHeaderApplicationActiveStyleClass); - gtk_style_context_remove_class(context, - kHeaderApplicationBackdropStyleClass); - gtk_style_context_add_class( - context, - application_active ? kHeaderApplicationActiveStyleClass - : kHeaderApplicationBackdropStyleClass); - - // The headerbar is embedded above Flutter rather than installed as - // GtkWindow's titlebar. Reset its subtree after the compositor's focus - // transfer settles so :backdrop declarations cannot remain one event late. - gtk_widget_reset_style(self->titlebar_handle); - gtk_widget_queue_draw(self->titlebar_handle); - return G_SOURCE_REMOVE; -} - -static void schedule_header_bar_focus_state_refresh(MyApplication* self) { - g_idle_add_full(G_PRIORITY_DEFAULT_IDLE, - refresh_header_bar_focus_state_cb, g_object_ref(self), - g_object_unref); -} - -static void header_focus_window_is_active_notify_cb( - GtkWindow*, - GParamSpec*, - gpointer user_data) { - schedule_header_bar_focus_state_refresh(MY_APPLICATION(user_data)); -} - -static void gtk_theme_name_changed_cb(GtkSettings*, - GParamSpec*, - gpointer user_data) { - refresh_header_bar_css(MY_APPLICATION(user_data)); -} - -static void set_header_bar_theme(MyApplication* self, FlValue* args) { - if (args == nullptr || fl_value_get_type(args) != FL_VALUE_TYPE_MAP) { - return; - } - gboolean prefer_dark = FALSE; - if (fl_lookup_optional_bool_arg(args, "preferDark", &prefer_dark)) { - set_gtk_theme_preference(prefer_dark); - } - replace_css_color_field(&self->background_color, - fl_lookup_string_arg(args, "backgroundColor")); - replace_css_color_field( - &self->sidebar_background_color, - fl_lookup_string_arg(args, "sidebarBackgroundColor")); - replace_css_color_field(&self->foreground_color, - fl_lookup_string_arg(args, "foregroundColor")); - replace_css_color_field( - &self->sidebar_border_color, - fl_lookup_string_arg(args, "sidebarBorderColor")); - replace_css_color_field(&self->modal_barrier_color, - fl_lookup_string_arg(args, "modalBarrierColor")); - FlValue* tooltip = fl_lookup_map_arg(args, "tooltip"); - if (tooltip != nullptr) { - replace_css_color_field( - &self->tooltip_background_color, - fl_lookup_string_arg(tooltip, "backgroundColor")); - replace_css_color_field( - &self->tooltip_foreground_color, - fl_lookup_string_arg(tooltip, "foregroundColor")); - replace_css_color_field(&self->tooltip_border_color, - fl_lookup_string_arg(tooltip, "borderColor")); - update_bounded_double_arg(tooltip, "borderRadius", 0, 64, - &self->tooltip_radius); - update_bounded_double_arg(tooltip, "fontSize", 1, 64, - &self->tooltip_font_size); - update_bounded_double_arg(tooltip, "horizontalPadding", 0, 64, - &self->tooltip_horizontal_padding); - update_bounded_double_arg(tooltip, "verticalPadding", 0, 64, - &self->tooltip_vertical_padding); - update_bounded_double_arg(tooltip, "minimumHeight", 1, 128, - &self->tooltip_minimum_height); - } - refresh_header_bar_css(self); -} - -static void focus_flutter_view(MyApplication* self) { - if (self->flutter_view != nullptr && GTK_IS_WIDGET(self->flutter_view)) { - gtk_widget_grab_focus(self->flutter_view); - } -} - -static void invoke_header_bar_action(MyApplication* self, - const gchar* action) { - if (self->modal_barrier_visible || - self->header_bar_channel == nullptr || action == nullptr) { - return; - } - fl_method_channel_invoke_method(self->header_bar_channel, action, nullptr, - nullptr, nullptr, nullptr); -} - -static void invoke_header_bar_string_action(MyApplication* self, - const gchar* action, - const gchar* value) { - if (self->modal_barrier_visible || - self->header_bar_channel == nullptr || action == nullptr) { - return; - } - g_autoptr(FlValue) args = fl_value_new_string(value == nullptr ? "" : value); - fl_method_channel_invoke_method(self->header_bar_channel, action, args, - nullptr, nullptr, nullptr); -} - -static void invoke_header_bar_bool_action(MyApplication* self, - const gchar* action, - gboolean value) { - if (self->modal_barrier_visible || - self->header_bar_channel == nullptr || action == nullptr) { - return; - } - g_autoptr(FlValue) args = fl_value_new_bool(value); - fl_method_channel_invoke_method(self->header_bar_channel, action, args, - nullptr, nullptr, nullptr); -} - -static const gchar* sidebar_shortcut_action_for_key(guint keyval) { - switch (keyval) { - case GDK_KEY_1: - case GDK_KEY_KP_1: - return "sidebarFiles"; - case GDK_KEY_2: - case GDK_KEY_KP_2: - return "sidebarToc"; - case GDK_KEY_3: - case GDK_KEY_KP_3: - return "sidebarOutline"; - case GDK_KEY_4: - case GDK_KEY_KP_4: - return "sidebarGit"; - case GDK_KEY_5: - case GDK_KEY_KP_5: - return "sidebarLocalHistory"; - case GDK_KEY_6: - case GDK_KEY_KP_6: - return "sidebarClipboardHistory"; - default: - return nullptr; - } -} - -// GtkSearchEntry owns the native keyboard focus while search is active, so -// these workspace shortcuts cannot reach Flutter's Shortcuts widget. Bridge -// them through the same header-bar channel used by the native controls. -static gboolean search_entry_key_press_cb(GtkWidget*, - GdkEventKey* event, - gpointer user_data) { - MyApplication* self = MY_APPLICATION(user_data); - const guint modifiers = - event->state & gtk_accelerator_get_default_mod_mask(); - if (self->suppress_header_actions || self->modal_barrier_visible) { - return FALSE; - } - if (modifiers == 0 && event->keyval == GDK_KEY_F11) { - invoke_header_bar_action(self, "fullScreen"); - return TRUE; - } - if (self->back_visible && modifiers == GDK_MOD1_MASK && - (event->keyval == GDK_KEY_Left || - event->keyval == GDK_KEY_KP_Left)) { - invoke_header_bar_action(self, "back"); - return TRUE; - } - if (modifiers != GDK_CONTROL_MASK) { - return FALSE; - } - const gchar* action = sidebar_shortcut_action_for_key(event->keyval); - if (action == nullptr) { - return FALSE; - } - focus_flutter_view(self); - invoke_header_bar_action(self, action); - return TRUE; -} - -enum class SearchQueryUpdateDisposition { - kAlreadyCurrent, - kPreserveNativeText, - kApplyDartSnapshot, -}; - -constexpr SearchQueryUpdateDisposition resolve_search_query_update( - bool queries_match, - bool native_entry_has_authority) { - if (queries_match) { - return SearchQueryUpdateDisposition::kAlreadyCurrent; - } - return native_entry_has_authority - ? SearchQueryUpdateDisposition::kPreserveNativeText - : SearchQueryUpdateDisposition::kApplyDartSnapshot; -} - -static_assert( - resolve_search_query_update(false, true) == - SearchQueryUpdateDisposition::kPreserveNativeText, - "A newer focused native edit must survive a delayed Dart snapshot"); -static_assert( - resolve_search_query_update(false, false) == - SearchQueryUpdateDisposition::kApplyDartSnapshot, - "Dart owns search text while the native entry is not being edited"); - -static void cache_search_query(MyApplication* self, const gchar* query) { - const gchar* normalized_query = query == nullptr ? "" : query; - if (g_strcmp0(self->search_query, normalized_query) == 0) { - return; - } - g_free(self->search_query); - self->search_query = g_strdup(normalized_query); -} - -static void set_search_query(MyApplication* self, const gchar* query) { - const gchar* normalized_query = query == nullptr ? "" : query; - if (self->search_entry == nullptr || !GTK_IS_ENTRY(self->search_entry)) { - cache_search_query(self, normalized_query); - return; - } - const gchar* current_query = - gtk_entry_get_text(GTK_ENTRY(self->search_entry)); - const bool native_entry_has_authority = - self->search_active && gtk_widget_has_focus(self->search_entry); - switch (resolve_search_query_update( - g_strcmp0(current_query, normalized_query) == 0, - native_entry_has_authority)) { - case SearchQueryUpdateDisposition::kAlreadyCurrent: - cache_search_query(self, normalized_query); - return; - case SearchQueryUpdateDisposition::kPreserveNativeText: - // GtkSearchEntry emits search-changed after a short delay. Dart can - // therefore publish an older mirrored snapshot after the user has - // already typed more text. While the active entry has focus, native - // text is authoritative. Do not update the cache here: a pending - // search-changed signal still needs to publish the newer native text. - return; - case SearchQueryUpdateDisposition::kApplyDartSnapshot: - break; - } - - cache_search_query(self, normalized_query); - const gboolean previous_suppression = self->suppress_header_actions; - self->suppress_header_actions = TRUE; - gtk_entry_set_text(GTK_ENTRY(self->search_entry), normalized_query); - self->suppress_header_actions = previous_suppression; -} - -static void header_button_clicked_cb(GtkWidget* widget, gpointer user_data) { - MyApplication* self = MY_APPLICATION(user_data); - if (self->suppress_header_actions || self->modal_barrier_visible) { - return; - } - const gchar* action = static_cast( - g_object_get_data(G_OBJECT(widget), "busymark-action")); - focus_flutter_view(self); - invoke_header_bar_action(self, action); -} - -static void connect_header_action(MyApplication* self, - GtkWidget* widget, - const gchar* action) { - g_object_set_data(G_OBJECT(widget), "busymark-action", - const_cast(action)); - g_signal_connect(widget, "clicked", G_CALLBACK(header_button_clicked_cb), - self); -} - -static void search_entry_changed_cb(GtkSearchEntry* entry, - gpointer user_data) { - MyApplication* self = MY_APPLICATION(user_data); - if (self->suppress_header_actions || self->modal_barrier_visible || - !self->search_active) { - return; - } - const gchar* query = gtk_entry_get_text(GTK_ENTRY(entry)); - if (g_strcmp0(self->search_query, query) == 0) { - return; - } - cache_search_query(self, query); - invoke_header_bar_string_action(self, "searchQueryChanged", query); -} - -static void search_entry_activate_cb(GtkEntry* entry, gpointer user_data) { - MyApplication* self = MY_APPLICATION(user_data); - if (self->suppress_header_actions || self->modal_barrier_visible || - !self->search_active) { - return; - } - invoke_header_bar_string_action(self, "searchSubmitted", - gtk_entry_get_text(entry)); - focus_flutter_view(self); -} - -static gboolean search_entry_focus_in_cb(GtkWidget*, - GdkEventFocus*, - gpointer user_data) { - MyApplication* self = MY_APPLICATION(user_data); - if (!self->modal_barrier_visible) { - invoke_header_bar_bool_action(self, "searchFocusChanged", TRUE); - } - return FALSE; -} - -static gboolean search_entry_focus_out_cb(GtkWidget*, - GdkEventFocus*, - gpointer user_data) { - MyApplication* self = MY_APPLICATION(user_data); - if (!self->modal_barrier_visible) { - invoke_header_bar_bool_action(self, "searchFocusChanged", FALSE); - } - return FALSE; -} - -static void search_entry_icon_release_cb(GtkEntry* entry, - GtkEntryIconPosition icon_position, - GdkEvent*, - gpointer user_data) { - MyApplication* self = MY_APPLICATION(user_data); - if (self->suppress_header_actions || self->modal_barrier_visible || - !self->search_active || - icon_position != GTK_ENTRY_ICON_SECONDARY || - gtk_entry_get_text(entry)[0] == '\0') { - return; - } - - // GtkSearchEntry clears the entry after this signal. Cache the semantic - // result now so its subsequent search-changed signal is deduplicated. - cache_search_query(self, ""); - invoke_header_bar_action(self, "searchCleared"); -} - -static void search_entry_stop_search_cb(GtkSearchEntry*, gpointer user_data) { - MyApplication* self = MY_APPLICATION(user_data); - if (self->suppress_header_actions || self->modal_barrier_visible || - !self->search_active) { - return; - } - invoke_header_bar_action(self, "searchEscapePressed"); -} - -static void make_icon_button_square(GtkWidget* button) { - gtk_widget_set_size_request(button, kHeaderButtonHeight, - kHeaderButtonHeight); - gtk_widget_set_valign(button, GTK_ALIGN_CENTER); -} - -static GtkWidget* create_header_icon_button(const gchar* icon_name) { - GtkWidget* button = gtk_button_new(); - gtk_style_context_add_class(gtk_widget_get_style_context(button), - "busymark-header-control"); - GtkWidget* image = gtk_image_new_from_icon_name(icon_name, GTK_ICON_SIZE_MENU); - gtk_button_set_image(GTK_BUTTON(button), image); - make_icon_button_square(button); - return button; -} - -static GtkWidget* create_header_toggle_button(const gchar* icon_name) { - GtkWidget* button = gtk_toggle_button_new(); - gtk_style_context_add_class(gtk_widget_get_style_context(button), - "busymark-header-control"); - GtkWidget* image = gtk_image_new_from_icon_name(icon_name, GTK_ICON_SIZE_MENU); - gtk_button_set_image(GTK_BUTTON(button), image); - make_icon_button_square(button); - return button; -} - -static const gchar* main_menu_icon_name(const gchar* action) { - if (g_strcmp0(action, "export") == 0) { - return "document-save-as-symbolic"; - } - if (g_strcmp0(action, "settings") == 0) { - return "preferences-system-symbolic"; - } - if (g_strcmp0(action, "keyboardShortcuts") == 0) { - return "input-keyboard-symbolic"; - } - if (g_strcmp0(action, "fullScreen") == 0) { - return "view-fullscreen-symbolic"; - } - if (g_strcmp0(action, "syntaxReference") == 0) { - return "text-x-generic-symbolic"; - } - if (g_strcmp0(action, "reportIssue") == 0) { - return "dialog-warning-symbolic"; - } - if (g_strcmp0(action, "aboutBusyMark") == 0) { - return "help-about-symbolic"; - } - return "open-menu-symbolic"; -} - -static const gchar* localized_label_or(FlValue* labels, - const gchar* key, - const gchar* fallback) { - const gchar* value = fl_lookup_string_arg(labels, key); - return value != nullptr ? value : fallback; -} - -static void append_action_menu_item(GMenu* menu, - const gchar* label, - const gchar* action, - const gchar* icon_name, - const gchar* accelerator) { - GMenuItem* item = g_menu_item_new(label, action); - if (icon_name != nullptr) { - GIcon* icon = g_themed_icon_new(icon_name); - g_menu_item_set_icon(item, icon); - g_object_unref(icon); - } - if (accelerator != nullptr && accelerator[0] != '\0') { - set_menu_item_accelerator(item, accelerator); - } - g_menu_append_item(menu, item); - g_object_unref(item); -} - -static void rebuild_main_menu_model(MyApplication* self, FlValue* labels) { - if (self->main_menu_model == nullptr) { - return; - } - g_menu_remove_all(self->main_menu_model); - append_action_menu_item( - self->main_menu_model, - localized_label_or(labels, "export", ""), "header.export", - main_menu_icon_name("export"), - fl_lookup_string_arg(labels, "exportGtkAccelerator")); - append_action_menu_item( - self->main_menu_model, - localized_label_or(labels, "fullScreen", ""), "header.full-screen", - main_menu_icon_name("fullScreen"), - fl_lookup_string_arg(labels, "fullScreenGtkAccelerator")); - append_action_menu_item( - self->main_menu_model, - localized_label_or(labels, "settings", ""), "header.settings", - main_menu_icon_name("settings"), - fl_lookup_string_arg(labels, "settingsGtkAccelerator")); - append_action_menu_item( - self->main_menu_model, - localized_label_or(labels, "keyboardShortcuts", ""), - "header.keyboard-shortcuts", - main_menu_icon_name("keyboardShortcuts"), - fl_lookup_string_arg(labels, "keyboardShortcutsGtkAccelerator")); - append_action_menu_item( - self->main_menu_model, - localized_label_or(labels, "syntaxReference", ""), - "header.syntax-reference", main_menu_icon_name("syntaxReference"), - fl_lookup_string_arg(labels, "syntaxReferenceGtkAccelerator")); - append_action_menu_item( - self->main_menu_model, - localized_label_or(labels, "reportIssue", ""), - "header.report-issue", main_menu_icon_name("reportIssue"), nullptr); - append_action_menu_item( - self->main_menu_model, - localized_label_or(labels, "aboutBusyMark", ""), - "header.about", main_menu_icon_name("aboutBusyMark"), nullptr); -} - -static const gchar* view_mode_dart_action(const gchar* mode) { - if (g_strcmp0(mode, "editor") == 0) { - return "viewModeEditor"; - } - if (g_strcmp0(mode, "source") == 0) { - return "viewModeSource"; - } - if (g_strcmp0(mode, "preview") == 0) { - return "viewModePreview"; - } - if (g_strcmp0(mode, "split") == 0) { - return "viewModeSplit"; - } - return nullptr; -} - -static const gchar* view_mode_icon_name(const gchar* mode) { - if (g_strcmp0(mode, "editor") == 0) { - return "accessories-text-editor-symbolic"; - } - if (g_strcmp0(mode, "source") == 0) { - return "text-x-generic-symbolic"; - } - if (g_strcmp0(mode, "preview") == 0) { - return "document-print-preview-symbolic"; - } - if (g_strcmp0(mode, "split") == 0) { - return "view-dual-symbolic"; - } - return "view-dual-symbolic"; -} - -static void update_view_mode_icon(MyApplication* self) { - if (self->view_mode_icon == nullptr || - !GTK_IS_IMAGE(self->view_mode_icon)) { - return; - } - const gchar* mode = self->view_mode != nullptr ? self->view_mode : "split"; - gtk_image_set_from_icon_name(GTK_IMAGE(self->view_mode_icon), - view_mode_icon_name(mode), GTK_ICON_SIZE_MENU); -} - -static void set_view_mode(MyApplication* self, const gchar* mode) { - if (view_mode_dart_action(mode) == nullptr) { - return; - } - g_free(self->view_mode); - self->view_mode = g_strdup(mode); - if (self->view_mode_action != nullptr) { - g_simple_action_set_state(self->view_mode_action, - g_variant_new_string(mode)); - } - update_view_mode_icon(self); -} - -static void set_full_screen(MyApplication* self, gboolean full_screen) { - self->full_screen = full_screen; - if (self->full_screen_action != nullptr) { - g_simple_action_set_state(self->full_screen_action, - g_variant_new_boolean(full_screen)); - } -} - -static void header_gaction_activated_cb(GSimpleAction* action, - GVariant* parameter, - gpointer user_data) { - MyApplication* self = MY_APPLICATION(user_data); - if (self->suppress_header_actions || self->modal_barrier_visible) { - return; - } - const gchar* dart_action = static_cast( - g_object_get_data(G_OBJECT(action), "busymark-dart-action")); - if (dart_action == nullptr) { - return; - } - focus_flutter_view(self); - invoke_header_bar_action(self, dart_action); -} - -static void view_mode_gaction_activated_cb(GSimpleAction* action, - GVariant* parameter, - gpointer user_data) { - MyApplication* self = MY_APPLICATION(user_data); - if (self->suppress_header_actions || self->modal_barrier_visible) { - return; - } - if (parameter == nullptr || - !g_variant_is_of_type(parameter, G_VARIANT_TYPE_STRING)) { - return; - } - const gchar* mode = g_variant_get_string(parameter, nullptr); - const gchar* dart_action = view_mode_dart_action(mode); - if (dart_action == nullptr) { - return; - } - set_view_mode(self, mode); - focus_flutter_view(self); - invoke_header_bar_action(self, dart_action); -} - -static void full_screen_gaction_activated_cb(GSimpleAction* action, - GVariant* parameter, - gpointer user_data) { - MyApplication* self = MY_APPLICATION(user_data); - if (self->suppress_header_actions || self->modal_barrier_visible) { - return; - } - set_full_screen(self, !self->full_screen); - focus_flutter_view(self); - invoke_header_bar_action(self, "fullScreen"); -} - -static void add_header_gaction(MyApplication* self, - const gchar* action_name, - const gchar* dart_action) { - GSimpleAction* action = g_simple_action_new(action_name, nullptr); - g_object_set_data_full(G_OBJECT(action), "busymark-dart-action", - g_strdup(dart_action), g_free); - g_signal_connect(action, "activate", G_CALLBACK(header_gaction_activated_cb), - self); - g_action_map_add_action(G_ACTION_MAP(self->header_action_group), - G_ACTION(action)); - g_object_unref(action); -} - -static void set_header_action_enabled(MyApplication* self, - const gchar* action_name, - gboolean enabled) { - if (self->header_action_group == nullptr) { - return; - } - GAction* action = g_action_map_lookup_action( - G_ACTION_MAP(self->header_action_group), action_name); - if (action != nullptr && G_IS_SIMPLE_ACTION(action)) { - g_simple_action_set_enabled(G_SIMPLE_ACTION(action), enabled); - } -} - -static void setup_header_actions(MyApplication* self) { - self->header_action_group = g_simple_action_group_new(); - add_header_gaction(self, "export", "export"); - set_header_action_enabled(self, "export", FALSE); - add_header_gaction(self, "settings", "settings"); - add_header_gaction(self, "keyboard-shortcuts", "keyboardShortcuts"); - add_header_gaction(self, "syntax-reference", "syntaxReference"); - add_header_gaction(self, "report-issue", "reportIssue"); - add_header_gaction(self, "about", "aboutBusyMark"); - - self->full_screen_action = g_simple_action_new_stateful( - "full-screen", nullptr, g_variant_new_boolean(FALSE)); - g_signal_connect(self->full_screen_action, "activate", - G_CALLBACK(full_screen_gaction_activated_cb), self); - g_action_map_add_action(G_ACTION_MAP(self->header_action_group), - G_ACTION(self->full_screen_action)); - - self->view_mode_action = g_simple_action_new_stateful( - "view-mode", G_VARIANT_TYPE_STRING, g_variant_new_string("split")); - g_signal_connect(self->view_mode_action, "activate", - G_CALLBACK(view_mode_gaction_activated_cb), self); - g_action_map_add_action(G_ACTION_MAP(self->header_action_group), - G_ACTION(self->view_mode_action)); - gtk_widget_insert_action_group(self->titlebar_box, "header", - G_ACTION_GROUP(self->header_action_group)); -} - -static void append_view_mode_menu_item(GMenu* menu, - const gchar* label, - const gchar* mode, - const gchar* accelerator) { - GMenuItem* item = g_menu_item_new(label, nullptr); - g_menu_item_set_action_and_target(item, "header.view-mode", "s", mode); - GIcon* icon = g_themed_icon_new(view_mode_icon_name(mode)); - g_menu_item_set_icon(item, icon); - g_object_unref(icon); - if (accelerator != nullptr && accelerator[0] != '\0') { - set_menu_item_accelerator(item, accelerator); - } - g_menu_append_item(menu, item); - g_object_unref(item); -} - -static void rebuild_view_mode_menu_model(MyApplication* self, - FlValue* labels) { - if (self->view_mode_menu_model == nullptr) { - return; - } - g_menu_remove_all(self->view_mode_menu_model); - append_view_mode_menu_item( - self->view_mode_menu_model, - localized_label_or(labels, "editor", ""), "editor", - fl_lookup_string_arg(labels, "editorGtkAccelerator")); - append_view_mode_menu_item( - self->view_mode_menu_model, - localized_label_or(labels, "source", ""), "source", - fl_lookup_string_arg(labels, "sourceGtkAccelerator")); - append_view_mode_menu_item( - self->view_mode_menu_model, - localized_label_or(labels, "preview", ""), "preview", - fl_lookup_string_arg(labels, "previewGtkAccelerator")); - append_view_mode_menu_item( - self->view_mode_menu_model, - localized_label_or(labels, "split", ""), "split", - fl_lookup_string_arg(labels, "splitGtkAccelerator")); -} - -static GtkWidget* create_model_menu_button(GMenuModel* model, - const gchar* icon_name, - GtkWidget** menu_out) { - GtkWidget* button = gtk_menu_button_new(); - gtk_style_context_add_class(gtk_widget_get_style_context(button), - "busymark-header-control"); - gtk_button_set_image(GTK_BUTTON(button), - gtk_image_new_from_icon_name(icon_name, - GTK_ICON_SIZE_MENU)); - // Pointer-opened model menus should not paint a keyboard focus ring around - // their first row. Keyboard traversal can still focus the trigger. - gtk_widget_set_focus_on_click(button, FALSE); - // GtkMenu maps as an independent xdg_popup on Wayland. GtkPopover maps as a - // child subsurface whose GDK redraw clock can stall while Flutter is idle. - gtk_menu_button_set_use_popover(GTK_MENU_BUTTON(button), FALSE); - gtk_menu_button_set_menu_model(GTK_MENU_BUTTON(button), model); - make_icon_button_square(button); - GtkMenu* menu = gtk_menu_button_get_popup(GTK_MENU_BUTTON(button)); - if (menu != nullptr && GTK_IS_MENU(menu) && menu_out != nullptr) { - *menu_out = GTK_WIDGET(menu); - } - return button; -} - -static void set_widget_tooltip(GtkWidget* widget, const gchar* tooltip) { - if (widget != nullptr && GTK_IS_WIDGET(widget) && tooltip != nullptr) { - gtk_widget_set_tooltip_text(widget, tooltip); - } -} - -static void set_widget_tooltip_with_shortcut(GtkWidget* widget, - const gchar* tooltip, - const gchar* shortcut) { - if (widget == nullptr || !GTK_IS_WIDGET(widget) || tooltip == nullptr) { - return; - } - if (shortcut == nullptr || shortcut[0] == '\0') { - gtk_widget_set_tooltip_text(widget, tooltip); - return; - } - gchar* value = g_strdup_printf("%s (%s%s%s)", tooltip, kLtrIsolateStart, - shortcut, kBidiIsolateEnd); - gtk_widget_set_tooltip_text(widget, value); - g_free(value); -} - -static const gchar* view_mode_shortcut_label(MyApplication* self, - FlValue* labels) { - const gchar* mode = self->view_mode != nullptr ? self->view_mode : "split"; - if (g_strcmp0(mode, "editor") == 0) { - return fl_lookup_string_arg(labels, "editorShortcut"); - } - if (g_strcmp0(mode, "source") == 0) { - return fl_lookup_string_arg(labels, "sourceShortcut"); - } - if (g_strcmp0(mode, "preview") == 0) { - return fl_lookup_string_arg(labels, "previewShortcut"); - } - return fl_lookup_string_arg(labels, "splitShortcut"); -} - -static void set_localized_labels(MyApplication* self, FlValue* args) { - const gchar* view_mode = fl_lookup_string_arg(args, "viewMode"); - const gchar* search = fl_lookup_string_arg(args, "search"); - const gchar* search_shortcut = - fl_lookup_string_arg(args, "searchShortcut"); - const gchar* refresh = fl_lookup_string_arg(args, "refresh"); - const gchar* menu = fl_lookup_string_arg(args, "menu"); - const gchar* sidebar = fl_lookup_string_arg(args, "sidebar"); - const gchar* sidebar_shortcut = - fl_lookup_string_arg(args, "sidebarShortcut"); - const gchar* back = fl_lookup_string_arg(args, "back"); - const gchar* back_shortcut = - fl_lookup_string_arg(args, "backShortcut"); - - set_widget_tooltip_with_shortcut(self->back_button, back, back_shortcut); - set_widget_tooltip_with_shortcut(self->sidebar_toggle_button, sidebar, - sidebar_shortcut); - set_widget_tooltip_with_shortcut(self->search_button, search, - search_shortcut); - if (self->search_entry != nullptr && GTK_IS_ENTRY(self->search_entry) && - search != nullptr) { - gtk_entry_set_placeholder_text(GTK_ENTRY(self->search_entry), search); - } - set_widget_tooltip(self->main_menu_button, menu); - set_widget_tooltip(self->refresh_button, refresh); - set_widget_tooltip_with_shortcut( - self->view_mode_button, view_mode, - view_mode_shortcut_label(self, args)); - rebuild_main_menu_model(self, args); - rebuild_view_mode_menu_model(self, args); - update_view_mode_icon(self); -} - -static void set_modal_barrier_depth(MyApplication* self, gint64 depth) { - const gint effective_depth = - depth <= 0 ? 0 - : depth > G_MAXINT ? G_MAXINT : static_cast(depth); - const gboolean visible = effective_depth > 0; - self->modal_barrier_depth = effective_depth; - self->modal_barrier_visible = visible; - refresh_header_bar_css(self); - if (self->titlebar_box != nullptr && - GTK_IS_WIDGET(self->titlebar_box)) { - GtkStyleContext* context = - gtk_widget_get_style_context(self->titlebar_box); - if (visible) { - gtk_style_context_add_class(context, "busymark-modal-open"); - } else { - gtk_style_context_remove_class(context, "busymark-modal-open"); - } - } - set_widget_visible(self->modal_scrim, visible); - if (visible) { - close_header_menu_button(self->main_menu_button); - close_header_menu_button(self->view_mode_button); - focus_flutter_view(self); - } -} - -static void set_modal_barrier_visible(MyApplication* self, gboolean visible) { - set_modal_barrier_depth(self, visible ? 1 : 0); -} - -static void set_sidebar_visible(MyApplication* self, gboolean visible) { - self->sidebar_visible = visible; - set_toggle_button_active(self, self->sidebar_toggle_button, visible); - update_sidebar_header_geometry(self); - refresh_header_bar_css(self); -} - -static void set_sidebar_toggle_visible(MyApplication* self, gboolean visible) { - set_widget_visible(self->sidebar_toggle_button, visible); -} - -static void set_sidebar_width(MyApplication* self, gdouble width) { - if (width <= 0) { - return; - } - self->sidebar_width = static_cast(width); - update_sidebar_header_geometry(self); -} - -static void set_text_direction(MyApplication* self, const gchar* value) { - self->text_direction_rtl = g_strcmp0(value, "rtl") == 0; - update_titlebar_direction(self); - refresh_header_bar_css(self); -} - -static void set_back_visible(MyApplication* self, gboolean visible) { - self->back_visible = visible; - set_widget_visible(self->back_button, visible); -} - -static void set_document_controls_visible(MyApplication* self, - gboolean visible) { - self->document_controls_visible = visible; - const gboolean effective_visible = visible && !self->search_active; - set_widget_visible(self->refresh_button, effective_visible); - set_widget_visible(self->view_mode_box, effective_visible); -} - -static void set_search_active(MyApplication* self, gboolean active) { - const gboolean changed = self->search_active != active; - self->search_active = active; - set_toggle_button_active(self, self->search_button, active); - if (self->title_stack != nullptr && GTK_IS_STACK(self->title_stack)) { - GtkWidget* visible_child = active ? self->search_entry : self->title_label; - if (visible_child != nullptr && GTK_IS_WIDGET(visible_child)) { - gtk_stack_set_visible_child(GTK_STACK(self->title_stack), visible_child); - } - } - const gboolean document_controls_visible = - self->document_controls_visible && !active; - set_widget_visible(self->refresh_button, document_controls_visible); - set_widget_visible(self->view_mode_box, document_controls_visible); - if (changed && active && self->search_entry != nullptr && - GTK_IS_ENTRY(self->search_entry)) { - gtk_widget_grab_focus(self->search_entry); - gtk_editable_select_region(GTK_EDITABLE(self->search_entry), 0, -1); - } else if (changed && !active) { - focus_flutter_view(self); - } -} - -static gboolean focus_search_entry(MyApplication* self) { - if (self->modal_barrier_visible || !self->search_active || - self->search_entry == nullptr || !GTK_IS_ENTRY(self->search_entry) || - !gtk_widget_get_visible(self->search_entry) || - !gtk_widget_get_child_visible(self->search_entry) || - !gtk_widget_get_sensitive(self->search_entry)) { - return FALSE; - } - - gtk_widget_grab_focus(self->search_entry); - gtk_editable_select_region(GTK_EDITABLE(self->search_entry), 0, -1); - return gtk_widget_has_focus(self->search_entry); -} - -static void set_search_visible(MyApplication* self, gboolean visible) { - self->search_visible = visible; - set_widget_visible(self->search_button, visible); - if (!visible && self->search_active) { - set_search_active(self, FALSE); - } -} - -static gboolean begin_header_configuration_session(MyApplication* self, - FlValue* args) { - const gchar* session_id = fl_lookup_string_arg(args, "sessionId"); - if (session_id == nullptr || session_id[0] == '\0') { - return FALSE; - } - if (g_strcmp0(session_id, self->header_configuration_session_id) != 0) { - g_free(self->header_configuration_session_id); - self->header_configuration_session_id = g_strdup(session_id); - self->header_configuration_revision = -1; - } - return TRUE; -} - -static gboolean decode_header_bar_configuration( - FlValue* args, - HeaderBarConfiguration* configuration) { - if (args == nullptr || fl_value_get_type(args) != FL_VALUE_TYPE_MAP || - (configuration->session_id = - fl_lookup_string_arg(args, "sessionId")) == nullptr || - !fl_lookup_int64_arg(args, "revision", &configuration->revision)) { - return FALSE; - } - - configuration->title = fl_lookup_string_arg(args, "title"); - configuration->view_mode = fl_lookup_string_arg(args, "viewMode"); - configuration->search_query = fl_lookup_string_arg(args, "searchQuery"); - configuration->text_direction = - fl_lookup_string_arg(args, "textDirection"); - configuration->labels = fl_lookup_map_arg(args, "labels"); - configuration->theme = fl_lookup_map_arg(args, "theme"); - - if (configuration->session_id[0] == '\0' || configuration->revision < 0 || - configuration->title == nullptr || - configuration->view_mode == nullptr || - view_mode_dart_action(configuration->view_mode) == nullptr || - configuration->search_query == nullptr || - configuration->text_direction == nullptr || - (g_strcmp0(configuration->text_direction, "ltr") != 0 && - g_strcmp0(configuration->text_direction, "rtl") != 0) || - configuration->labels == nullptr || configuration->theme == nullptr || - !fl_lookup_double_arg(args, "sidebarWidth", - &configuration->sidebar_width) || - configuration->sidebar_width <= 0 || - !fl_lookup_optional_bool_arg(args, "canRefresh", - &configuration->can_refresh) || - !fl_lookup_optional_bool_arg(args, "canExportPdf", - &configuration->can_export_pdf) || - !fl_lookup_optional_bool_arg(args, "canExportHtml", - &configuration->can_export_html) || - !fl_lookup_optional_bool_arg( - args, "documentControlsVisible", - &configuration->document_controls_visible) || - !fl_lookup_optional_bool_arg(args, "searchActive", - &configuration->search_active) || - !fl_lookup_optional_bool_arg(args, "searchVisible", - &configuration->search_visible) || - !fl_lookup_optional_bool_arg(args, "sidebarVisible", - &configuration->sidebar_visible) || - !fl_lookup_optional_bool_arg(args, "sidebarToggleVisible", - &configuration->sidebar_toggle_visible) || - !fl_lookup_optional_bool_arg(args, "backVisible", - &configuration->back_visible) || - !fl_lookup_optional_bool_arg(args, "fullScreen", - &configuration->full_screen) || - !fl_lookup_optional_bool_arg(args, "modalBarrierVisible", - &configuration->modal_barrier_visible) || - !fl_lookup_int64_arg(args, "modalBarrierDepth", - &configuration->modal_barrier_depth) || - configuration->modal_barrier_depth < 0 || - configuration->modal_barrier_depth > G_MAXINT || - configuration->modal_barrier_visible != - (configuration->modal_barrier_depth > 0)) { - return FALSE; - } - - return configuration->search_visible || !configuration->search_active; -} - -static void apply_header_bar_configuration( - MyApplication* self, - const HeaderBarConfiguration& configuration) { - const gboolean previous_suppression = self->suppress_header_actions; - self->suppress_header_actions = TRUE; - if (self->titlebar_box != nullptr) { - g_object_freeze_notify(G_OBJECT(self->titlebar_box)); - } - - set_header_bar_theme(self, configuration.theme); - set_localized_labels(self, configuration.labels); - if (self->title_label != nullptr && GTK_IS_LABEL(self->title_label)) { - gtk_label_set_text(GTK_LABEL(self->title_label), configuration.title); - } - set_widget_sensitive(self->refresh_button, configuration.can_refresh); - self->can_export_pdf = configuration.can_export_pdf; - self->can_export_html = configuration.can_export_html; - set_header_action_enabled( - self, "export", self->can_export_pdf || self->can_export_html); - set_sidebar_width(self, configuration.sidebar_width); - set_text_direction(self, configuration.text_direction); - set_sidebar_visible(self, configuration.sidebar_visible); - set_sidebar_toggle_visible(self, configuration.sidebar_toggle_visible); - set_search_visible(self, configuration.search_visible); - set_back_visible(self, configuration.back_visible); - set_full_screen(self, configuration.full_screen); - set_document_controls_visible( - self, configuration.document_controls_visible); - set_search_query(self, configuration.search_query); - set_search_active(self, configuration.search_active); - set_view_mode(self, configuration.view_mode); - set_modal_barrier_depth(self, configuration.modal_barrier_depth); - self->header_configuration_revision = configuration.revision; - - if (self->titlebar_box != nullptr) { - g_object_thaw_notify(G_OBJECT(self->titlebar_box)); - gtk_widget_queue_draw(self->titlebar_box); - } - self->suppress_header_actions = previous_suppression; -} - -static GtkWidget* create_busymark_titlebar(MyApplication* self) { - self->titlebar_box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0); - gtk_widget_set_halign(self->titlebar_box, GTK_ALIGN_FILL); - gtk_widget_set_hexpand(self->titlebar_box, TRUE); - gtk_style_context_add_class(gtk_widget_get_style_context(self->titlebar_box), - "busymark-titlebar"); - setup_header_actions(self); - self->main_menu_model = g_menu_new(); - self->view_mode_menu_model = g_menu_new(); - rebuild_main_menu_model(self, nullptr); - rebuild_view_mode_menu_model(self, nullptr); - - // An ordinary GtkBox owns the complete painted sidebar-brand allocation. - // GtkOverlay can leave its own background node transparent under some GTK - // 3/Yaru combinations, which exposed the main headerbar color even though - // Dart supplied the sidebar color. - self->sidebar_header_box = - gtk_box_new(GTK_ORIENTATION_HORIZONTAL, kHeaderButtonSpacing); - gtk_widget_set_halign(self->sidebar_header_box, GTK_ALIGN_FILL); - gtk_widget_set_hexpand(self->sidebar_header_box, FALSE); - gtk_style_context_add_class(gtk_widget_get_style_context(self->sidebar_header_box), - "busymark-sidebar-header"); - - GtkWidget* sidebar_title_box = - gtk_box_new(GTK_ORIENTATION_HORIZONTAL, kHeaderButtonSpacing); - gtk_widget_set_halign(sidebar_title_box, GTK_ALIGN_CENTER); - gtk_widget_set_valign(sidebar_title_box, GTK_ALIGN_CENTER); - gtk_widget_set_hexpand(sidebar_title_box, TRUE); - self->sidebar_title_label = gtk_label_new(kApplicationDisplayName); - gtk_widget_set_halign(self->sidebar_title_label, GTK_ALIGN_CENTER); - gtk_widget_set_valign(self->sidebar_title_label, GTK_ALIGN_CENTER); - gtk_label_set_ellipsize(GTK_LABEL(self->sidebar_title_label), - PANGO_ELLIPSIZE_END); - gtk_style_context_add_class(gtk_widget_get_style_context(self->sidebar_title_label), - GTK_STYLE_CLASS_TITLE); - gtk_box_pack_start(GTK_BOX(sidebar_title_box), self->sidebar_title_label, - FALSE, FALSE, 0); - gtk_box_pack_start(GTK_BOX(self->sidebar_header_box), sidebar_title_box, - TRUE, TRUE, 0); - - gtk_box_pack_start(GTK_BOX(self->titlebar_box), self->sidebar_header_box, - FALSE, FALSE, 0); - - self->header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); - gtk_header_bar_set_show_close_button(self->header_bar, TRUE); - gtk_widget_set_hexpand(GTK_WIDGET(self->header_bar), TRUE); - gtk_style_context_add_class(gtk_widget_get_style_context(GTK_WIDGET(self->header_bar)), - "busymark-headerbar"); - - self->header_start_box = - gtk_box_new(GTK_ORIENTATION_HORIZONTAL, kHeaderButtonSpacing); - self->back_button = create_header_icon_button("go-previous-symbolic"); - self->sidebar_toggle_button = - create_header_toggle_button("sidebar-show-symbolic"); - connect_header_action(self, self->back_button, "back"); - connect_header_action(self, self->sidebar_toggle_button, "sidebarToggle"); - gtk_box_pack_start(GTK_BOX(self->header_start_box), self->sidebar_toggle_button, - FALSE, FALSE, 0); - gtk_box_pack_start(GTK_BOX(self->header_start_box), self->back_button, - FALSE, FALSE, 0); - gtk_header_bar_pack_start(self->header_bar, self->header_start_box); - - self->title_stack = gtk_stack_new(); - gtk_widget_set_hexpand(self->title_stack, TRUE); - gtk_stack_set_transition_type(GTK_STACK(self->title_stack), - GTK_STACK_TRANSITION_TYPE_NONE); - self->title_label = gtk_label_new(""); - gtk_label_set_ellipsize(GTK_LABEL(self->title_label), PANGO_ELLIPSIZE_END); - gtk_label_set_max_width_chars(GTK_LABEL(self->title_label), 48); - gtk_style_context_add_class(gtk_widget_get_style_context(self->title_label), - "busymark-header-title"); - self->search_entry = gtk_search_entry_new(); - gtk_entry_set_placeholder_text(GTK_ENTRY(self->search_entry), ""); - gtk_widget_set_hexpand(self->search_entry, TRUE); - gtk_style_context_add_class(gtk_widget_get_style_context(self->search_entry), - "busymark-search-entry"); - g_signal_connect(self->search_entry, "search-changed", - G_CALLBACK(search_entry_changed_cb), self); - g_signal_connect(self->search_entry, "activate", - G_CALLBACK(search_entry_activate_cb), self); - g_signal_connect(self->search_entry, "key-press-event", - G_CALLBACK(search_entry_key_press_cb), self); - g_signal_connect(self->search_entry, "focus-in-event", - G_CALLBACK(search_entry_focus_in_cb), self); - g_signal_connect(self->search_entry, "focus-out-event", - G_CALLBACK(search_entry_focus_out_cb), self); - g_signal_connect(self->search_entry, "icon-release", - G_CALLBACK(search_entry_icon_release_cb), self); - g_signal_connect(self->search_entry, "stop-search", - G_CALLBACK(search_entry_stop_search_cb), self); - gtk_stack_add_named(GTK_STACK(self->title_stack), self->title_label, "title"); - gtk_stack_add_named(GTK_STACK(self->title_stack), self->search_entry, - "search"); - gtk_stack_set_visible_child(GTK_STACK(self->title_stack), self->title_label); - gtk_header_bar_set_custom_title(self->header_bar, self->title_stack); - - GtkWidget* end_box = - gtk_box_new(GTK_ORIENTATION_HORIZONTAL, kHeaderButtonSpacing); - self->view_mode_box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0); - self->view_mode_button = create_model_menu_button( - G_MENU_MODEL(self->view_mode_menu_model), view_mode_icon_name("split"), - &self->view_mode_menu); - gtk_style_context_add_class(gtk_widget_get_style_context(self->view_mode_button), - "busymark-view-mode-button"); - self->view_mode_icon = - gtk_button_get_image(GTK_BUTTON(self->view_mode_button)); - gtk_box_pack_start(GTK_BOX(self->view_mode_box), self->view_mode_button, - FALSE, FALSE, 0); - gtk_box_pack_start(GTK_BOX(end_box), self->view_mode_box, FALSE, FALSE, 0); - - self->refresh_button = create_header_icon_button("tools-check-spelling-symbolic"); - connect_header_action(self, self->refresh_button, "refresh"); - gtk_box_pack_start(GTK_BOX(end_box), self->refresh_button, FALSE, FALSE, 0); - self->search_button = - create_header_toggle_button("system-search-symbolic"); - connect_header_action(self, self->search_button, "search"); - gtk_box_pack_start(GTK_BOX(end_box), self->search_button, FALSE, FALSE, 0); - self->main_menu_button = create_model_menu_button( - G_MENU_MODEL(self->main_menu_model), "open-menu-symbolic", - &self->main_menu); - gtk_box_pack_start(GTK_BOX(end_box), self->main_menu_button, FALSE, FALSE, - 0); - gtk_header_bar_pack_end(self->header_bar, end_box); - - gtk_box_pack_start(GTK_BOX(self->titlebar_box), GTK_WIDGET(self->header_bar), - TRUE, TRUE, 0); - update_titlebar_direction(self); - set_view_mode(self, "split"); - set_document_controls_visible(self, FALSE); - set_sidebar_visible(self, TRUE); - set_back_visible(self, FALSE); - refresh_header_bar_css(self); - return self->titlebar_box; -} - -static GtkWidget* create_busymark_titlebar_overlay(MyApplication* self) { - self->titlebar_overlay = gtk_overlay_new(); - gtk_widget_set_halign(self->titlebar_overlay, GTK_ALIGN_FILL); - gtk_widget_set_valign(self->titlebar_overlay, GTK_ALIGN_FILL); - gtk_widget_set_hexpand(self->titlebar_overlay, TRUE); - // The titlebar is packed as a fixed-height row above the Flutter view. - // Prevent expansion requests from overlay children from turning it into a - // second vertically expanding application surface. - gtk_widget_set_vexpand(self->titlebar_overlay, FALSE); - gtk_container_add(GTK_CONTAINER(self->titlebar_overlay), - create_busymark_titlebar(self)); - - // A real overlay is the GTK equivalent of Flutter's modal barrier. Painting - // only the header backgrounds leaves descendant icons and button surfaces - // above the scrim. - self->modal_scrim = gtk_event_box_new(); - gtk_event_box_set_visible_window(GTK_EVENT_BOX(self->modal_scrim), TRUE); - gtk_widget_set_halign(self->modal_scrim, GTK_ALIGN_FILL); - gtk_widget_set_valign(self->modal_scrim, GTK_ALIGN_FILL); - gtk_widget_set_hexpand(self->modal_scrim, TRUE); - // GTK_ALIGN_FILL gives the scrim the overlay's full allocation. Asking it - // to expand vertically instead propagates through GtkOverlay and - // HdyWindowHandle when the scrim becomes visible, making the header consume - // the window's spare height. - gtk_widget_set_vexpand(self->modal_scrim, FALSE); - gtk_widget_add_events(self->modal_scrim, GDK_ALL_EVENTS_MASK); - gtk_style_context_add_class(gtk_widget_get_style_context(self->modal_scrim), - "busymark-modal-scrim"); - // Keep the header subtree visually enabled beneath the modal tint. The - // overlay owns pointer input while visible and consumes it here so events - // cannot bubble into HdyWindowHandle's drag and window-menu handlers. - g_signal_connect(self->modal_scrim, "event", - G_CALLBACK(stop_modal_scrim_event), nullptr); - set_widget_visible(self->modal_scrim, FALSE); - gtk_overlay_add_overlay(GTK_OVERLAY(self->titlebar_overlay), - self->modal_scrim); - gtk_overlay_set_overlay_pass_through(GTK_OVERLAY(self->titlebar_overlay), - self->modal_scrim, FALSE); - return self->titlebar_overlay; -} - -static void header_bar_method_call_cb(FlMethodChannel* channel, - FlMethodCall* method_call, - gpointer user_data) { - MyApplication* self = MY_APPLICATION(user_data); - const gchar* method = fl_method_call_get_name(method_call); - FlValue* args = fl_method_call_get_args(method_call); - if (strcmp(method, "initialize") == 0) { - respond_bool(method_call, - begin_header_configuration_session(self, args) && - has_header_bar(self)); - } else if (strcmp(method, "applyConfiguration") == 0) { - HeaderBarConfiguration configuration = {}; - if (!decode_header_bar_configuration(args, &configuration)) { - respond_invalid_configuration( - method_call, - "applyConfiguration requires a complete, typed header snapshot"); - } else if (g_strcmp0(configuration.session_id, - self->header_configuration_session_id) != 0) { - respond_invalid_configuration( - method_call, - "applyConfiguration requires the active Dart session"); - } else if (configuration.revision <= - self->header_configuration_revision) { - respond_int64(method_call, self->header_configuration_revision); - } else { - apply_header_bar_configuration(self, configuration); - respond_int64(method_call, self->header_configuration_revision); - } - } else if (strcmp(method, "setTitleRange") == 0) { - const gchar* value = fl_method_string_arg(args); - if (self->title_label != nullptr && GTK_IS_LABEL(self->title_label) && - value != nullptr) { - gtk_label_set_text(GTK_LABEL(self->title_label), value); - } - respond_success(method_call); - } else if (strcmp(method, "setViewMode") == 0) { - set_view_mode(self, fl_method_string_arg(args)); - respond_success(method_call); - } else if (strcmp(method, "setCanRefresh") == 0) { - set_widget_sensitive(self->refresh_button, fl_method_bool_arg(args)); - respond_success(method_call); - } else if (strcmp(method, "setCanExportPdf") == 0) { - self->can_export_pdf = fl_method_bool_arg(args); - set_header_action_enabled( - self, "export", self->can_export_pdf || self->can_export_html); - respond_success(method_call); - } else if (strcmp(method, "setCanExportHtml") == 0) { - self->can_export_html = fl_method_bool_arg(args); - set_header_action_enabled( - self, "export", self->can_export_pdf || self->can_export_html); - respond_success(method_call); - } else if (strcmp(method, "setDocumentControlsVisible") == 0) { - set_document_controls_visible(self, fl_method_bool_arg(args)); - respond_success(method_call); - } else if (strcmp(method, "setSearchActive") == 0) { - set_search_active(self, fl_method_bool_arg(args)); - respond_success(method_call); - } else if (strcmp(method, "focusSearch") == 0) { - respond_bool(method_call, focus_search_entry(self)); - } else if (strcmp(method, "setSearchQuery") == 0) { - set_search_query(self, fl_method_string_arg(args)); - respond_success(method_call); - } else if (strcmp(method, "setSidebarVisible") == 0) { - set_sidebar_visible(self, fl_method_bool_arg(args)); - respond_success(method_call); - } else if (strcmp(method, "setSidebarToggleVisible") == 0) { - set_sidebar_toggle_visible(self, fl_method_bool_arg(args)); - respond_success(method_call); - } else if (strcmp(method, "setSearchVisible") == 0) { - set_search_visible(self, fl_method_bool_arg(args)); - respond_success(method_call); - } else if (strcmp(method, "setSidebarWidth") == 0) { - set_sidebar_width(self, fl_method_double_arg(args, 300)); - respond_success(method_call); - } else if (strcmp(method, "setTextDirection") == 0) { - set_text_direction(self, fl_method_string_arg(args)); - respond_success(method_call); - } else if (strcmp(method, "setBackVisible") == 0) { - set_back_visible(self, fl_method_bool_arg(args)); - respond_success(method_call); - } else if (strcmp(method, "setLocalizedLabels") == 0) { - set_localized_labels(self, args); - respond_success(method_call); - } else if (strcmp(method, "setTheme") == 0) { - set_header_bar_theme(self, args); - respond_success(method_call); - } else if (strcmp(method, "setModalBarrierVisible") == 0) { - set_modal_barrier_visible(self, fl_method_bool_arg(args)); - respond_success(method_call); - } else if (strcmp(method, "setModalBarrierDepth") == 0) { - set_modal_barrier_depth(self, fl_method_int_arg(args, 0)); - respond_success(method_call); - } else { - fl_method_call_respond_not_implemented(method_call, nullptr); - } -} - -static void register_header_bar_channel(MyApplication* self, FlView* view) { - g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new(); - self->header_bar_channel = fl_method_channel_new( - fl_engine_get_binary_messenger(fl_view_get_engine(view)), - kHeaderBarChannel, FL_METHOD_CODEC(codec)); - fl_method_channel_set_method_call_handler( - self->header_bar_channel, header_bar_method_call_cb, self, nullptr); -} - -constexpr char kNativeMenuActionNamespace[] = "busymark-native-menu"; -constexpr char kNativeMenuActionIndexKey[] = "busymark-native-menu-index"; - struct NativeMenuHandlerData; struct NativeMenuSession { @@ -3090,7 +1162,7 @@ static void register_asset_input_channel(MyApplication* self, FlView* view) { } // Called when first Flutter frame received. -static void first_frame_cb(MyApplication* self, FlView* view) { +static void first_frame_cb(MyApplication*, FlView* view) { gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view))); } @@ -3114,22 +1186,14 @@ static void my_application_activate(GApplication* application) { gtk_window_set_icon_name(window, APPLICATION_ID); } - self->titlebar_handle = hdy_window_handle_new(); - gtk_widget_set_hexpand(self->titlebar_handle, TRUE); - gtk_widget_set_vexpand(self->titlebar_handle, FALSE); - // Keep the style scope on the outer native owner. This also makes - // focused/backdrop selectors independent of the inner box hierarchy. - gtk_style_context_add_class( - gtk_widget_get_style_context(self->titlebar_handle), - "busymark-titlebar"); - gtk_container_add(GTK_CONTAINER(self->titlebar_handle), - create_busymark_titlebar_overlay(self)); - gtk_widget_show_all(self->titlebar_handle); - gtk_window_set_default_size(window, 1280, 720); - g_signal_connect( - window, "notify::is-active", - G_CALLBACK(header_focus_window_is_active_notify_cb), self); + if (uses_legacy_yaru_window_shadow()) { + self->native_surface_css_provider = gtk_css_provider_new(); + gtk_css_provider_load_from_data(self->native_surface_css_provider, + kLegacyYaruWindowShadowCompatibilityCss, -1, nullptr); + gtk_style_context_add_provider_for_screen(gtk_widget_get_screen(GTK_WIDGET(window)), + GTK_STYLE_PROVIDER(self->native_surface_css_provider), GTK_STYLE_PROVIDER_PRIORITY_APPLICATION); + } g_autoptr(FlDartProject) project = fl_dart_project_new(); fl_dart_project_set_dart_entrypoint_arguments( @@ -3142,18 +1206,12 @@ static void my_application_activate(GApplication* application) { fl_view_set_background_color(view, &background_color); gtk_widget_show(GTK_WIDGET(view)); - GtkWidget* window_content = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); - gtk_box_pack_start(GTK_BOX(window_content), self->titlebar_handle, FALSE, - FALSE, 0); self->flutter_overlay = gtk_overlay_new(); gtk_widget_set_hexpand(self->flutter_overlay, TRUE); gtk_widget_set_vexpand(self->flutter_overlay, TRUE); gtk_container_add(GTK_CONTAINER(self->flutter_overlay), GTK_WIDGET(view)); gtk_widget_show(self->flutter_overlay); - gtk_box_pack_start(GTK_BOX(window_content), self->flutter_overlay, TRUE, - TRUE, 0); - gtk_widget_show(window_content); - gtk_container_add(GTK_CONTAINER(window), window_content); + gtk_container_add(GTK_CONTAINER(window), self->flutter_overlay); g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb), self); @@ -3162,7 +1220,7 @@ static void my_application_activate(GApplication* application) { gtk_widget_realize(GTK_WIDGET(view)); fl_register_plugins(FL_PLUGIN_REGISTRY(view)); - register_header_bar_channel(self, view); + self->chrome_host = busymark_linux_chrome_host_new(view, window, set_gtk_theme_preference); register_native_menu_channel(self, view); self->writerside_dialog_channel = busymark_writerside_dialog_channel_new(view, window); @@ -3178,7 +1236,6 @@ static void my_application_activate(GApplication* application) { busymark_video_player_host_register_channel(self->video_player_host, view); gtk_widget_grab_focus(GTK_WIDGET(view)); - schedule_header_bar_focus_state_refresh(self); } // Implements GApplication::local_command_line. @@ -3206,12 +1263,6 @@ static void my_application_startup(GApplication* application) { G_APPLICATION_CLASS(my_application_parent_class)->startup(application); hdy_init(); - GtkSettings* settings = gtk_settings_get_default(); - if (settings != nullptr) { - g_signal_connect_object(settings, "notify::gtk-theme-name", - G_CALLBACK(gtk_theme_name_changed_cb), application, - G_CONNECT_DEFAULT); - } } // Implements GApplication::shutdown. @@ -3219,49 +1270,26 @@ static void my_application_shutdown(GApplication* application) { G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); } -// Implements GObject::dispose. static void my_application_dispose(GObject* object) { MyApplication* self = MY_APPLICATION(object); + busymark_linux_chrome_host_free(self->chrome_host); + self->chrome_host = nullptr; busymark_gtk_accent_host_free(self->gtk_accent_host); self->gtk_accent_host = nullptr; - GdkScreen* screen = gdk_screen_get_default(); - if (screen != nullptr) { - if (self->header_bar_css_provider != nullptr) { - gtk_style_context_remove_provider_for_screen( - screen, GTK_STYLE_PROVIDER(self->header_bar_css_provider)); - } + if (self->native_surface_css_provider != nullptr) { + GdkScreen* screen = gdk_screen_get_default(); + if (screen != nullptr) gtk_style_context_remove_provider_for_screen(screen, GTK_STYLE_PROVIDER(self->native_surface_css_provider)); } - g_clear_object(&self->header_bar_css_provider); - g_clear_object(&self->header_bar_channel); + g_clear_object(&self->native_surface_css_provider); g_clear_object(&self->native_menu_channel); g_clear_object(&self->writerside_dialog_channel); g_clear_object(&self->asset_input_channel); g_clear_object(&self->rich_clipboard_channel); g_clear_object(&self->secure_credential_channel); - if (self->visualization_host != nullptr) { - busymark_web_render_host_shutdown(self->visualization_host); - } + if (self->visualization_host != nullptr) busymark_web_render_host_shutdown(self->visualization_host); g_clear_object(&self->visualization_host); - if (self->video_player_host != nullptr) { - busymark_video_player_host_shutdown(self->video_player_host); - } + if (self->video_player_host != nullptr) busymark_video_player_host_shutdown(self->video_player_host); g_clear_object(&self->video_player_host); - g_clear_object(&self->main_menu_model); - g_clear_object(&self->view_mode_menu_model); - g_clear_object(&self->view_mode_action); - g_clear_object(&self->full_screen_action); - g_clear_object(&self->header_action_group); - g_clear_pointer(&self->background_color, g_free); - g_clear_pointer(&self->sidebar_background_color, g_free); - g_clear_pointer(&self->sidebar_border_color, g_free); - g_clear_pointer(&self->modal_barrier_color, g_free); - g_clear_pointer(&self->tooltip_background_color, g_free); - g_clear_pointer(&self->tooltip_foreground_color, g_free); - g_clear_pointer(&self->tooltip_border_color, g_free); - g_clear_pointer(&self->view_mode, g_free); - g_clear_pointer(&self->search_query, g_free); - g_clear_pointer(&self->foreground_color, g_free); - g_clear_pointer(&self->header_configuration_session_id, g_free); g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); G_OBJECT_CLASS(my_application_parent_class)->dispose(object); } @@ -3274,76 +1302,7 @@ static void my_application_class_init(MyApplicationClass* klass) { G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; G_OBJECT_CLASS(klass)->dispose = my_application_dispose; } - -static void my_application_init(MyApplication* self) { - self->dart_entrypoint_arguments = nullptr; - self->gtk_accent_host = nullptr; - self->header_bar_channel = nullptr; - self->native_menu_channel = nullptr; - self->writerside_dialog_channel = nullptr; - self->asset_input_channel = nullptr; - self->secure_credential_channel = nullptr; - self->visualization_host = nullptr; - self->video_player_host = nullptr; - self->header_bar_css_provider = nullptr; - self->main_window = nullptr; - self->flutter_view = nullptr; - self->flutter_overlay = nullptr; - self->titlebar_handle = nullptr; - self->titlebar_box = nullptr; - self->titlebar_overlay = nullptr; - self->modal_scrim = nullptr; - self->header_bar = nullptr; - self->sidebar_header_box = nullptr; - self->sidebar_title_label = nullptr; - self->main_menu_model = nullptr; - self->header_start_box = nullptr; - self->back_button = nullptr; - self->sidebar_toggle_button = nullptr; - self->title_stack = nullptr; - self->title_label = nullptr; - self->search_entry = nullptr; - self->document_controls_visible = FALSE; - self->search_visible = TRUE; - self->view_mode_box = nullptr; - self->view_mode_button = nullptr; - self->view_mode_icon = nullptr; - self->view_mode_menu = nullptr; - self->view_mode_menu_model = nullptr; - self->refresh_button = nullptr; - self->search_button = nullptr; - self->main_menu_button = nullptr; - self->main_menu = nullptr; - self->header_action_group = nullptr; - self->view_mode_action = nullptr; - self->full_screen_action = nullptr; - self->view_mode = nullptr; - self->search_query = g_strdup(""); - self->background_color = g_strdup(kDefaultHeaderbarBackground); - self->sidebar_background_color = g_strdup(kDefaultSidebarBackground); - self->foreground_color = g_strdup(kDefaultForeground); - self->sidebar_border_color = nullptr; - self->modal_barrier_color = nullptr; - self->tooltip_background_color = nullptr; - self->tooltip_foreground_color = nullptr; - self->tooltip_border_color = nullptr; - self->tooltip_radius = kDefaultTooltipRadius; - self->tooltip_font_size = kDefaultTooltipFontSize; - self->tooltip_horizontal_padding = kDefaultTooltipHorizontalPadding; - self->tooltip_vertical_padding = kDefaultTooltipVerticalPadding; - self->tooltip_minimum_height = kDefaultTooltipMinimumHeight; - self->sidebar_width = 300; - self->sidebar_visible = TRUE; - self->text_direction_rtl = FALSE; - self->back_visible = FALSE; - self->full_screen = FALSE; - self->search_active = FALSE; - self->modal_barrier_visible = FALSE; - self->modal_barrier_depth = 0; - self->suppress_header_actions = FALSE; - self->header_configuration_session_id = nullptr; - self->header_configuration_revision = -1; -} +static void my_application_init(MyApplication*) {} MyApplication* my_application_new() { g_set_prgname(APPLICATION_ID); diff --git a/test/src/app_settings_test.dart b/test/src/app_settings_test.dart index 8d1beb2b..22478ec8 100644 --- a/test/src/app_settings_test.dart +++ b/test/src/app_settings_test.dart @@ -9,6 +9,28 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:path/path.dart' as p; void main() { + test( + 'hydration and redundant sidebar requests do not count as user transitions', + () async { + final store = _MemorySettingsStore(); + store.value['sidebarVisible'] = false; + final container = ProviderContainer( + overrides: [localSettingsStoreProvider.overrideWithValue(store)], + ); + addTearDown(container.dispose); + final controller = container.read(appSettingsControllerProvider.notifier); + await controller.waitUntilLoaded(); + expect(container.read(sidebarTransitionGenerationProvider), 0); + await controller.setSidebarVisible(false); + expect(container.read(sidebarTransitionGenerationProvider), 0); + await controller.setSidebarVisible(true); + expect(container.read(sidebarTransitionGenerationProvider), 1); + await controller.setSidebarVisible(true); + expect(container.read(sidebarTransitionGenerationProvider), 1); + await controller.setSidebarVisible(false); + expect(container.read(sidebarTransitionGenerationProvider), 2); + }, + ); test('window behavior defaults protect unsaved changes', () { final settings = AppSettings.defaults(); diff --git a/test/src/app_smoke_test.dart b/test/src/app_smoke_test.dart index 944d1958..bdf61431 100644 --- a/test/src/app_smoke_test.dart +++ b/test/src/app_smoke_test.dart @@ -43,6 +43,8 @@ import 'package:busymark/src/editor/source_highlighter.dart' show BusyMarkSourceEditingController; import 'package:busymark/src/editor/source/source_read_only_view.dart'; import 'package:busymark/src/editor/wysiwyg/wysiwyg_editor.dart'; +import 'package:busymark/src/editor/wysiwyg/wysiwyg_inline_controller.dart'; +import 'package:busymark/src/markdown/busymark_document.dart'; import 'package:busymark/src/feedback/presentation/feedback_dialog.dart'; import 'package:busymark/src/export/export_options_editor.dart'; import 'package:busymark/src/git/application/git_controller.dart'; @@ -157,6 +159,66 @@ void main() { ); }); + testWidgets( + 'workspace resize retains the sidebar and leaves room for header controls', + (tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(1280, 720); + addTearDown(tester.view.reset); + final container = ProviderContainer( + overrides: [ + ..._smokeAccentOverrides, + linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), + localSettingsStoreProvider.overrideWithValue(_MemorySettingsStore()), + workspaceServiceProvider.overrideWithValue( + _StartupWorkspaceService(), + ), + startupPathProvider.overrideWithValue( + 'test/fixtures/markdown/basic.md', + ), + ], + ); + addTearDown(container.dispose); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const BusyMarkApp(), + ), + ); + await _pumpUntilFound( + tester, + find.byKey(const ValueKey('workspace-page-frame')), + ); + await tester.pumpAndSettle(); + final sidebarKey = ValueKey( + container.read(workspaceControllerProvider).workspace!.id, + ); + final sidebarState = tester.state(find.byKey(sidebarKey)); + final generation = container.read(sidebarTransitionGenerationProvider); + final viewport = find.byKey(const ValueKey('linux-sidebar-viewport')); + expect(tester.getSize(viewport).width, BusyMarkSizes.sidebarWidth); + + tester.view.physicalSize = const Size(700, 720); + await tester.pump(); + expect(tester.getSize(viewport).width, 0); + expect( + tester.state(find.byKey(sidebarKey, skipOffstage: false)), + same(sidebarState), + ); + expect( + container.read(appSettingsControllerProvider).sidebarVisible, + isTrue, + ); + expect(container.read(sidebarTransitionGenerationProvider), generation); + + tester.view.physicalSize = const Size(1280, 720); + await tester.pump(); + expect(tester.getSize(viewport).width, BusyMarkSizes.sidebarWidth); + expect(tester.state(find.byKey(sidebarKey)), same(sidebarState)); + expect(container.read(sidebarTransitionGenerationProvider), generation); + }, + ); + test('document view shortcuts are distinct from existing commands', () { expect( { @@ -463,6 +525,148 @@ void main() { }, ); + testWidgets( + 'workspace source history restores a pasted URL suffix through undo and redo', + (tester) async { + const url = 'https://example.com'; + const text = '$url*'; + const clipboardChannel = MethodChannel(richClipboardChannelName); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + clipboardChannel, + (call) async => call.method == 'read' + ? {'text': text, 'generation': 1} + : null, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + clipboardChannel, + null, + ), + ); + final settings = _MemorySettingsStore() + ..value = AppSettings.defaults() + .copyWith( + autoSave: false, + documentViewMode: DocumentViewModePreference.editor, + ) + .toJson(); + final container = ProviderContainer( + overrides: [ + ..._smokeAccentOverrides, + linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), + localSettingsStoreProvider.overrideWithValue(settings), + workspaceServiceProvider.overrideWithValue( + const _SearchWorkspaceService(''), + ), + startupPathProvider.overrideWithValue('/tmp/workspace-url-undo.md'), + ], + ); + addTearDown(container.dispose); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const BusyMarkApp(), + ), + ); + for (var index = 0; index < 30; index++) { + await tester.pump(const Duration(milliseconds: 100)); + if (find.byType(BusyMarkWysiwygEditor).evaluate().isNotEmpty) break; + } + Future shortcut( + LogicalKeyboardKey key, { + bool shift = false, + }) async { + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + if (shift) await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyEvent(key); + if (shift) await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(const Duration(milliseconds: 100)); + } + + BusyDocument live() => tester + .widget(find.byType(BusyMarkWysiwygEditor)) + .document; + TextField first() => tester.widget( + find + .descendant( + of: find.byType(BusyMarkWysiwygEditor), + matching: find.byType(TextField), + ) + .first, + ); + String source() => container.read(workspaceControllerProvider).activeText; + void check({required bool recognized}) { + expect(first().controller!.text, text); + expect(first().controller!.text, isNot(contains(r'\'))); + expect(live().blocks.first.plainText, text); + final ranges = busyInlineStyleRanges(live().blocks.first.inlines); + if (recognized) { + final link = ranges.single; + expect( + (link.kind, link.start, link.end, link.destination), + (BusyInlineKind.link, 0, url.length, url), + ); + expect(link.attributes[busyMarkBareUrlAttribute], 'true'); + } else { + expect(ranges, isEmpty); + } + } + + first().focusNode!.requestFocus(); + first().controller!.selection = const TextSelection.collapsed(offset: 0); + await tester.pump(); + await shortcut(LogicalKeyboardKey.keyV); + check(recognized: false); + final beforeEnter = source(); + expect(beforeEnter, r'https://example.com\*'); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(const Duration(milliseconds: 100)); + check(recognized: true); + final afterEnter = source(); + // This workspace fixture has no final newline, so the buffer strips + // trailing document separators while retaining the live new paragraph. + expect(afterEnter, text); + final fields = tester + .widgetList( + find.descendant( + of: find.byType(BusyMarkWysiwygEditor), + matching: find.byType(TextField), + ), + ) + .toList(); + expect(fields.last.focusNode!.hasFocus, isTrue); + expect( + fields.last.controller!.selection, + const TextSelection.collapsed(offset: 0), + ); + expect( + tester + .state( + find.byType(BusyMarkWysiwygEditor), + ) + .debugUndoSnapshotCount, + 0, + ); + await shortcut(LogicalKeyboardKey.keyZ); + expect(source(), beforeEnter); + check(recognized: false); + await shortcut(LogicalKeyboardKey.keyZ, shift: true); + expect(source(), afterEnter); + check(recognized: true); + await shortcut(LogicalKeyboardKey.keyZ); + check(recognized: false); + await shortcut(LogicalKeyboardKey.keyZ); + expect(first().controller!.text, ''); + await shortcut(LogicalKeyboardKey.keyZ, shift: true); + expect(source(), beforeEnter); + check(recognized: false); + await shortcut(LogicalKeyboardKey.keyZ, shift: true); + expect(source(), afterEnter); + check(recognized: true); + }, + ); + testWidgets('app wires generated localization delegates and locales', ( tester, ) async { @@ -3159,7 +3363,7 @@ void main() { expect(service.untitledCount, 1); expect(find.text(l10n.createMarkdownFile), findsNothing); - expect(find.text(l10n.workspaceKindUnsavedMarkdown), findsWidgets); + expect(find.text(l10n.untitledMarkdownFileName), findsWidgets); expect(find.byKey(const ValueKey('editor-tab-strip')), findsNothing); expect(find.byKey(const ValueKey('document-status-bar')), findsOneWidget); expect(find.text('LF'), findsOneWidget); @@ -5363,7 +5567,32 @@ code findsWidgets, ); - await tester.tap(find.text('current.md').at(1)); + final selectedDiff = container + .read(gitControllerProvider) + .selectedCommitFilePath; + await tester.sendKeyEvent(LogicalKeyboardKey.f9); + await tester.pumpAndSettle(); + expect( + container.read(gitControllerProvider).selectedCommitFilePath, + selectedDiff, + ); + expect( + find.textContaining('Guide change', findRichText: true), + findsWidgets, + ); + await tester.sendKeyEvent(LogicalKeyboardKey.f9); + await tester.pumpAndSettle(); + expect( + container.read(gitControllerProvider).selectedCommitFilePath, + selectedDiff, + ); + + await tester.tap( + find.descendant( + of: find.byKey(const ValueKey('editor-tab-strip')), + matching: find.text('current.md'), + ), + ); await tester.pump(); expect(find.text('README.md'), findsWidgets); @@ -5966,8 +6195,8 @@ code await tester.sendKeyDownEvent(key); await tester.sendKeyUpEvent(key); await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); - await tester.pump(const Duration(milliseconds: 100)); - await tester.pump(const Duration(milliseconds: 100)); + await tester.pumpAndSettle(); + await tester.pumpAndSettle(); } Future setDocumentViewMode( @@ -5976,8 +6205,8 @@ code await container .read(appSettingsControllerProvider.notifier) .setDocumentViewMode(expectedMode); - await tester.pump(const Duration(milliseconds: 100)); - await tester.pump(const Duration(milliseconds: 100)); + await tester.pumpAndSettle(); + await tester.pumpAndSettle(); expect( container.read(appSettingsControllerProvider).documentViewMode, expectedMode, @@ -5995,12 +6224,17 @@ code if (container.read(workspaceControllerProvider).workspace != null) { break; } - await tester.pump(const Duration(milliseconds: 100)); + await tester.pumpAndSettle(); } await tester.pumpAndSettle(); expect(find.text('Api.md'), findsOneWidget); + final sidebarKey = ValueKey( + container.read(workspaceControllerProvider).workspace!.id, + ); + final sidebarState = tester.state(find.byKey(sidebarKey)); + expect( find.byTooltip( '${l10n.hideSidebar} (${BusyMarkSidebarShortcutLabels.toggleSidebar})', @@ -6008,8 +6242,12 @@ code findsOneWidget, ); await tester.sendKeyEvent(LogicalKeyboardKey.f9); - await tester.pump(const Duration(milliseconds: 100)); + await tester.pumpAndSettle(); expect(find.byTooltip(l10n.sidebarViewMenu), findsNothing); + expect( + tester.state(find.byKey(sidebarKey, skipOffstage: false)), + same(sidebarState), + ); expect( find.byTooltip( '${l10n.showSidebar} (${BusyMarkSidebarShortcutLabels.toggleSidebar})', @@ -6018,8 +6256,9 @@ code ); await tester.sendKeyEvent(LogicalKeyboardKey.f9); - await tester.pump(const Duration(milliseconds: 100)); + await tester.pumpAndSettle(); expect(find.byTooltip(l10n.sidebarViewMenu), findsOneWidget); + expect(tester.state(find.byKey(sidebarKey)), same(sidebarState)); final activeEditorField = find.byWidgetPredicate( (widget) => @@ -11594,14 +11833,22 @@ Before [![Inline logo](inline-logo.png)](inline-guide.md) after. ); await tester.pump(); for (var i = 0; i < 20; i += 1) { - await tester.pump(const Duration(milliseconds: 100)); - if (find.text(l10n.workspaceKindSingleMarkdown).evaluate().isNotEmpty) { + await tester.pumpAndSettle(); + if (find + .byKey(const ValueKey('workspace-page-frame')) + .evaluate() + .isNotEmpty) { break; } } - expect(find.text(l10n.workspaceKindSingleMarkdown), findsWidgets); + expect(find.byKey(const ValueKey('workspace-page-frame')), findsWidgets); final initialTextFields = find.byType(TextField).evaluate().length; + final editorFocus = tester + .widget(find.byType(EditableText).first) + .focusNode; + editorFocus.requestFocus(); + await tester.pump(); Finder headerButton(String tooltip) => find.byWidgetPredicate( (widget) => widget is IconButton && @@ -11613,33 +11860,35 @@ Before [![Inline logo](inline-logo.png)](inline-guide.md) after. await tester.sendKeyDownEvent(LogicalKeyboardKey.keyF); await tester.sendKeyUpEvent(LogicalKeyboardKey.keyF); await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); - await tester.pump(const Duration(milliseconds: 100)); + await tester.pumpAndSettle(); expect(find.byType(TextField).evaluate().length, initialTextFields + 1); expect(headerButton(l10n.welcome), findsNothing); expect(headerButton(l10n.validate), findsNothing); expect(headerButton(l10n.viewMode), findsNothing); await tester.tap(headerButton(l10n.search)); - await tester.pump(const Duration(milliseconds: 100)); + await tester.pumpAndSettle(); expect(find.byType(TextField).evaluate().length, initialTextFields); expect(headerButton(l10n.welcome), findsOneWidget); + expect(editorFocus.hasFocus, isTrue); expect(headerButton(l10n.validate), findsOneWidget); expect(headerButton(l10n.viewMode), findsOneWidget); await tester.tap(headerButton(l10n.search)); - await tester.pump(const Duration(milliseconds: 100)); + await tester.pumpAndSettle(); expect(find.byType(TextField).evaluate().length, initialTextFields + 1); await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); await tester.sendKeyDownEvent(LogicalKeyboardKey.keyF); await tester.sendKeyUpEvent(LogicalKeyboardKey.keyF); await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); - await tester.pump(const Duration(milliseconds: 100)); + await tester.pumpAndSettle(); expect(find.byType(TextField).evaluate().length, initialTextFields + 1); await tester.sendKeyEvent(LogicalKeyboardKey.escape); - await tester.pump(const Duration(milliseconds: 100)); + await tester.pumpAndSettle(); expect(find.byType(TextField).evaluate().length, initialTextFields); + expect(editorFocus.hasFocus, isTrue); }); testWidgets( @@ -11701,7 +11950,10 @@ Before [![Inline logo](inline-logo.png)](inline-guide.md) after. await tester.pump(); for (var i = 0; i < 20; i += 1) { await tester.pump(const Duration(milliseconds: 100)); - if (find.text(l10n.workspaceKindSingleMarkdown).evaluate().isNotEmpty) { + if (find + .byKey(const ValueKey('workspace-page-frame')) + .evaluate() + .isNotEmpty) { break; } } @@ -11771,7 +12023,10 @@ Before [![Inline logo](inline-logo.png)](inline-guide.md) after. ); for (var i = 0; i < 20; i += 1) { await tester.pump(const Duration(milliseconds: 100)); - if (find.text(l10n.workspaceKindSingleMarkdown).evaluate().isNotEmpty) { + if (find + .byKey(const ValueKey('workspace-page-frame')) + .evaluate() + .isNotEmpty) { break; } } @@ -12305,7 +12560,10 @@ Before [![Inline logo](inline-logo.png)](inline-guide.md) after. ); for (var i = 0; i < 20; i += 1) { await tester.pump(const Duration(milliseconds: 100)); - if (find.text(l10n.workspaceKindSingleMarkdown).evaluate().isNotEmpty) { + if (find + .byKey(const ValueKey('workspace-page-frame')) + .evaluate() + .isNotEmpty) { break; } } @@ -12468,7 +12726,10 @@ Before [![Inline logo](inline-logo.png)](inline-guide.md) after. ); for (var i = 0; i < 20; i += 1) { await tester.pump(const Duration(milliseconds: 100)); - if (find.text(l10n.workspaceKindSingleMarkdown).evaluate().isNotEmpty) { + if (find + .byKey(const ValueKey('workspace-page-frame')) + .evaluate() + .isNotEmpty) { break; } } @@ -12540,7 +12801,10 @@ Before [![Inline logo](inline-logo.png)](inline-guide.md) after. ); for (var i = 0; i < 20; i += 1) { await tester.pump(const Duration(milliseconds: 100)); - if (find.text(l10n.workspaceKindSingleMarkdown).evaluate().isNotEmpty) { + if (find + .byKey(const ValueKey('workspace-page-frame')) + .evaluate() + .isNotEmpty) { break; } } @@ -12694,7 +12958,10 @@ Before [![Inline logo](inline-logo.png)](inline-guide.md) after. ); for (var i = 0; i < 20; i += 1) { await tester.pump(const Duration(milliseconds: 100)); - if (find.text(l10n.workspaceKindSingleMarkdown).evaluate().isNotEmpty) { + if (find + .byKey(const ValueKey('workspace-page-frame')) + .evaluate() + .isNotEmpty) { break; } } @@ -12739,7 +13006,10 @@ Before [![Inline logo](inline-logo.png)](inline-guide.md) after. ); for (var i = 0; i < 20; i += 1) { await tester.pump(const Duration(milliseconds: 100)); - if (find.text(l10n.workspaceKindSingleMarkdown).evaluate().isNotEmpty) { + if (find + .byKey(const ValueKey('workspace-page-frame')) + .evaluate() + .isNotEmpty) { break; } } @@ -12829,7 +13099,10 @@ Before [![Inline logo](inline-logo.png)](inline-guide.md) after. ); for (var i = 0; i < 20; i += 1) { await tester.pump(const Duration(milliseconds: 100)); - if (find.text(l10n.workspaceKindSingleMarkdown).evaluate().isNotEmpty) { + if (find + .byKey(const ValueKey('workspace-page-frame')) + .evaluate() + .isNotEmpty) { break; } } diff --git a/test/src/busymark_motion_widgets_test.dart b/test/src/busymark_motion_widgets_test.dart new file mode 100644 index 00000000..98e2889f --- /dev/null +++ b/test/src/busymark_motion_widgets_test.dart @@ -0,0 +1,289 @@ +import 'package:busymark/src/app/busymark_motion_widgets.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets( + 'view changes fade one live editor while ordinary data changes do not', + (tester) async { + final counts = {}; + var view = 'editor'; + var revision = 0; + var disabled = false; + late StateSetter update; + await tester.pumpWidget( + MaterialApp( + home: StatefulBuilder( + builder: (context, setState) { + update = setState; + return MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(disableAnimations: disabled), + child: Scaffold( + body: BusyMarkSemanticFade( + transitionKey: view, + child: Column( + children: [ + Text('$revision'), + _TrackedPage( + key: const ValueKey('editor'), + name: 'Editor', + counts: counts, + ), + ], + ), + ), + ), + ); + }, + ), + ), + ); + await tester.enterText( + find.byKey(const ValueKey('field-Editor')), + 'draft', + ); + final editor = tester.state(find.byKey(const ValueKey('editor'))); + Finder fade() => find + .descendant( + of: find.byType(BusyMarkSemanticFade), + matching: find.byType(FadeTransition), + ) + .first; + update(() => revision++); + await tester.pump(); + expect(tester.widget(fade()).opacity.value, 1); + + update(() => view = 'source'); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + expect( + tester.widget(fade()).opacity.value, + inExclusiveRange(0, 1), + ); + expect(tester.state(find.byKey(const ValueKey('editor'))), same(editor)); + expect(find.text('draft'), findsOneWidget); + expect(counts, {'Editor': 1}); + + update(() => disabled = true); + await tester.pump(); + expect(tester.widget(fade()).opacity.value, 1); + update(() => view = 'preview'); + await tester.pump(); + expect(tester.widget(fade()).opacity.value, 1); + }, + ); + + testWidgets('retained destinations settle immediately under reduced motion', ( + tester, + ) async { + var destination = 'Files'; + late StateSetter update; + await tester.pumpWidget( + MaterialApp( + home: StatefulBuilder( + builder: (context, setState) { + update = setState; + return MediaQuery( + data: MediaQuery.of(context).copyWith(disableAnimations: true), + child: BusyMarkKeyedCrossfade( + transitionKey: destination, + child: Text(destination), + ), + ); + }, + ), + ), + ); + update(() => destination = 'Search'); + await tester.pump(); + expect(find.text('Files'), findsNothing); + expect(find.text('Search'), findsOneWidget); + final opacity = tester.widget( + find + .ancestor(of: find.text('Search'), matching: find.byType(Opacity)) + .first, + ); + expect(opacity.opacity, 1); + }); + testWidgets('retained crossfade keeps two live pages and hides outgoing UI', ( + tester, + ) async { + final counts = {}; + Widget build(int index) => MaterialApp( + home: Scaffold( + body: SizedBox( + width: 300, + height: 200, + child: BusyMarkRetainedCrossfade( + index: index, + children: [ + _TrackedPage( + key: const ValueKey('page-a'), + name: 'A', + counts: counts, + ), + _TrackedPage( + key: const ValueKey('page-b'), + name: 'B', + counts: counts, + ), + ], + ), + ), + ), + ); + + await tester.pumpWidget(build(0)); + expect(counts, {'A': 1, 'B': 1}); + await tester.enterText(find.byKey(const ValueKey('field-A')), 'draft'); + + await tester.pumpWidget(build(1)); + await tester.pump(const Duration(milliseconds: 50)); + expect(find.text('draft'), findsOneWidget); + final opacityBeforeReversal = _pageOpacity(tester, 'page-a'); + expect( + tester + .widget( + find + .ancestor( + of: find.byKey(const ValueKey('field-A')), + matching: find.byType(IgnorePointer), + ) + .first, + ) + .ignoring, + isTrue, + ); + + await tester.pumpWidget(build(0)); + await tester.pump(); + expect( + _pageOpacity(tester, 'page-a'), + closeTo(opacityBeforeReversal, .001), + ); + await tester.pumpAndSettle(); + expect(find.text('draft'), findsOneWidget); + expect(counts, {'A': 1, 'B': 1}); + }); + + testWidgets('keyed crossfade retains three semantic destinations', ( + tester, + ) async { + final counts = {}; + var destination = 'Files'; + late StateSetter update; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: StatefulBuilder( + builder: (context, setState) { + update = setState; + return SizedBox( + width: 300, + height: 200, + child: BusyMarkKeyedCrossfade( + transitionKey: destination, + child: _TrackedPage( + key: ValueKey('page-$destination'), + name: destination, + counts: counts, + ), + ), + ); + }, + ), + ), + ), + ); + await tester.enterText( + find.byKey(const ValueKey('field-Files')), + 'retained sidebar state', + ); + final weekState = tester.state( + find.byKey(const ValueKey('page-Files'), skipOffstage: false), + ); + + update(() => destination = 'Search'); + await tester.pump(); + expect(counts, {'Files': 1, 'Search': 1}); + expect( + tester.state( + find.byKey(const ValueKey('page-Files'), skipOffstage: false), + ), + same(weekState), + ); + await tester.pump(const Duration(milliseconds: 30)); + update(() => destination = 'History'); + await tester.pump(); + expect(counts, {'Files': 1, 'Search': 1, 'History': 1}); + expect( + tester.state( + find.byKey(const ValueKey('page-Files'), skipOffstage: false), + ), + same(weekState), + ); + await tester.pump(const Duration(milliseconds: 30)); + update(() => destination = 'Files'); + await tester.pump(); + await tester.pumpAndSettle(); + + expect( + find.byKey(const ValueKey('field-Files'), skipOffstage: false), + findsOneWidget, + ); + expect( + tester.state(find.byKey(const ValueKey('page-Files'))), + same(weekState), + ); + expect(counts, {'Files': 1, 'Search': 1, 'History': 1}); + expect( + tester + .widget( + find.descendant( + of: find.byKey(const ValueKey('field-Files')), + matching: find.byType(EditableText), + ), + ) + .controller + .text, + 'retained sidebar state', + ); + }); +} + +double _pageOpacity(WidgetTester tester, String key) => tester + .widget( + find + .ancestor( + of: find.byKey(ValueKey(key)), + matching: find.byType(Opacity), + ) + .first, + ) + .opacity; + +class _TrackedPage extends StatefulWidget { + const _TrackedPage({super.key, required this.name, required this.counts}); + + final String name; + final Map counts; + + @override + State<_TrackedPage> createState() => _TrackedPageState(); +} + +class _TrackedPageState extends State<_TrackedPage> { + @override + void initState() { + super.initState(); + widget.counts.update(widget.name, (value) => value + 1, ifAbsent: () => 1); + } + + @override + Widget build(BuildContext context) => TextField( + key: ValueKey('field-${widget.name}'), + decoration: InputDecoration(labelText: widget.name), + ); +} diff --git a/test/src/busymark_search_field_test.dart b/test/src/busymark_search_field_test.dart index 6ef6f064..072cc7e7 100644 --- a/test/src/busymark_search_field_test.dart +++ b/test/src/busymark_search_field_test.dart @@ -6,6 +6,29 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:yaru/yaru.dart'; void main() { + testWidgets('header Escape preserves the query for the next search', ( + tester, + ) async { + final controller = TextEditingController(text: 'unfinished search'); + addTearDown(controller.dispose); + var escaped = false; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: BusyMarkSearchField( + controller: controller, + autofocus: true, + clearOnEscape: false, + onEscape: () => escaped = true, + ), + ), + ), + ); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + expect(escaped, isTrue); + expect(controller.text, 'unfinished search'); + }); testWidgets('search fallback uses the normal Yaru-themed entry geometry', ( tester, ) async { diff --git a/test/src/gtk_animation_settings_service_test.dart b/test/src/gtk_animation_settings_service_test.dart new file mode 100644 index 00000000..89af401b --- /dev/null +++ b/test/src/gtk_animation_settings_service_test.dart @@ -0,0 +1,103 @@ +import 'dart:io'; + +import 'package:busymark/src/platform/gtk_animation_settings_service.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('reads the initial GTK animation setting', () async { + const channel = MethodChannel('busymark_test/gtk_animations'); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler(channel, (call) async { + expect(call.method, 'getGtkAnimationsEnabled'); + return false; + }); + addTearDown(() => messenger.setMockMethodCallHandler(channel, null)); + + expect( + await const GtkAnimationSettingsService( + channel: channel, + ).getAnimationsEnabled(), + isFalse, + ); + }); + + test('animation stream follows GTK setting changes', () async { + const events = EventChannel('busymark_test/gtk_animation_events'); + MockStreamHandlerEventSink? sink; + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockStreamHandler( + events, + MockStreamHandler.inline( + onListen: (_, eventSink) { + sink = eventSink; + eventSink.success(true); + }, + ), + ); + addTearDown(() => messenger.setMockStreamHandler(events, null)); + + final values = const GtkAnimationSettingsService( + events: events, + ).watchAnimationsEnabled().take(2).toList(); + await pumpEventQueue(); + sink!.success(false); + + expect(await values, [isTrue, isFalse]); + }); + + test('provider exposes the preloaded value before native events', () async { + final container = ProviderContainer( + overrides: [initialGtkAnimationsEnabledProvider.overrideWithValue(false)], + ); + addTearDown(container.dispose); + final subscription = container.listen( + gtkAnimationsEnabledProvider, + (_, _) {}, + ); + addTearDown(subscription.close); + + expect(await container.read(gtkAnimationsEnabledProvider.future), isFalse); + }); + + test('missing GTK integration falls back without disabling motion', () async { + expect( + await const GtkAnimationSettingsService( + channel: MethodChannel('busymark_test/gtk_animations_missing'), + ).getAnimationsEnabled(), + isNull, + ); + }); + + test( + 'native GTK bridge observes and tears down animation preference state', + () { + final source = File( + 'linux/runner/linux_chrome_host.cc', + ).readAsStringSync(); + final frame = File( + 'lib/src/app/linux/linux_page_frame.dart', + ).readAsStringSync(); + + expect(source, contains('"notify::gtk-enable-animations"')); + expect(source, contains('send_animations(host);')); + expect( + source, + contains( + 'g_signal_handler_disconnect(host->settings, host->animation_signal);', + ), + ); + expect(source, contains('g_clear_object(&host->animations_channel);')); + expect(source, isNot(contains('header_sidebar_brand_box'))); + expect(source, isNot(contains('gtk_widget_add_tick_callback('))); + expect(frame, contains('MediaQuery.disableAnimationsOf(context)')); + expect(frame, contains('_controller.value = _targetVisible ? 1 : 0')); + expect(frame, contains('BusyMarkMotion.sidebar * distance')); + }, + ); +} diff --git a/test/src/gtk_header_icon_service_test.dart b/test/src/gtk_header_icon_service_test.dart new file mode 100644 index 00000000..f927819f --- /dev/null +++ b/test/src/gtk_header_icon_service_test.dart @@ -0,0 +1,230 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:busymark/src/app/busymark_glyphs.dart'; +import 'package:busymark/src/platform/gtk_header_icon_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('semantic catalog uses the exact native GTK icon names', () { + expect(BusyMarkLinuxHeaderIcon.back.gtkNames, const [ + 'go-previous-symbolic', + ]); + expect(BusyMarkLinuxHeaderIcon.sidebar.gtkNames, const [ + 'sidebar-show-symbolic', + ]); + expect(BusyMarkLinuxHeaderIcon.search.gtkNames, const [ + 'system-search-symbolic', + ]); + expect(BusyMarkLinuxHeaderIcon.searchEntryFind.gtkNames, const [ + 'edit-find-symbolic', + ]); + expect(BusyMarkLinuxHeaderIcon.searchClear.gtkNames, const [ + 'edit-clear-symbolic', + ]); + expect(BusyMarkLinuxHeaderIcon.mainMenu.gtkNames, const [ + 'open-menu-symbolic', + ]); + expect(BusyMarkLinuxHeaderIcon.viewEditor.gtkNames, const [ + 'document-edit-symbolic', + ]); + expect(BusyMarkLinuxHeaderIcon.viewSource.gtkNames, const [ + 'text-x-generic-symbolic', + ]); + expect(BusyMarkLinuxHeaderIcon.viewPreview.gtkNames, const [ + 'view-reader-symbolic', + 'document-open-symbolic', + ]); + expect(BusyMarkLinuxHeaderIcon.viewSplit.gtkNames, const [ + 'view-dual-symbolic', + ]); + expect(BusyMarkLinuxHeaderIcon.viewMenuArrow.gtkNames, const [ + 'pan-down-symbolic', + ]); + expect(BusyMarkLinuxHeaderIcon.validate.gtkNames, const [ + 'tools-check-spelling-symbolic', + ]); + }); + + test( + 'initial load batches direction variants and preserves candidate order', + () async { + late List> observedRequests; + final service = GtkHeaderIconService.testing( + loadBatch: (requests) async { + observedRequests = requests; + return _responseFor(requests, _pngA, scale: 1); + }, + ); + addTearDown(service.dispose); + + expect(await service.initialize(), isTrue); + expect(service.reloadCount, 1); + expect(service.catalog.scale, 1); + + for (final role in const { + BusyMarkLinuxHeaderIcon.search: 'system-search-symbolic', + BusyMarkLinuxHeaderIcon.searchEntryFind: 'edit-find-symbolic', + BusyMarkLinuxHeaderIcon.searchClear: 'edit-clear-symbolic', + }.entries) { + final request = observedRequests.singleWhere( + (request) => request['key'] == '${role.key.name}.ltr', + ); + expect(request['names'], [role.value]); + final asset = service.catalog.assetFor(role.key, TextDirection.ltr); + expect(asset?.resolvedName, role.value); + expect(asset?.scale, 1); + expect(asset?.pixelWidth, 16); + expect(asset?.pixelHeight, 16); + } + + final preview = observedRequests.singleWhere( + (request) => request['key'] == 'viewPreview.ltr', + ); + expect(preview['names'], const [ + 'view-reader-symbolic', + 'document-open-symbolic', + ]); + expect(preview['direction'], 'ltr'); + expect( + observedRequests.every((request) => request['allowMissing'] == true), + isTrue, + ); + + for (final icon in const [ + BusyMarkLinuxHeaderIcon.back, + BusyMarkLinuxHeaderIcon.sidebar, + ]) { + expect( + observedRequests.where( + (request) => (request['key'] as String).startsWith('${icon.name}.'), + ), + hasLength(2), + ); + } + expect( + observedRequests.where( + (request) => (request['key'] as String).startsWith('viewPreview.'), + ), + hasLength(1), + ); + }, + ); + + testWidgets( + 'theme and scale invalidation atomically replaces one cached catalog', + (tester) async { + final invalidations = StreamController.broadcast(sync: true); + final replacement = Completer(); + var calls = 0; + final service = GtkHeaderIconService.testing( + changedEvents: invalidations.stream, + loadBatch: (requests) { + calls += 1; + if (calls == 1) { + return Future.value(_responseFor(requests, _pngA, scale: 1)); + } + return replacement.future; + }, + ); + addTearDown(() async { + service.dispose(); + await invalidations.close(); + }); + expect(await service.initialize(), isTrue); + final originalCatalog = service.catalog; + + await tester.pumpWidget( + GtkHeaderIconScope( + service: service, + child: const MaterialApp( + home: Center(child: BusyMarkGtkHeaderIcon(BusyMarkGlyphs.search)), + ), + ), + ); + expect( + tester.getSize(find.byType(BusyMarkGtkHeaderIcon)), + const Size.square(16), + ); + expect(_renderedBytes(tester), _pngA); + expect(_fallbackPaint(), findsNothing); + + invalidations.add(const {'revision': 1, 'scale': 2}); + invalidations.add(const {'revision': 1, 'scale': 2}); + await tester.pump(); + expect(service.reloadCount, 2); + expect(identical(service.catalog, originalCatalog), isTrue); + expect(_renderedBytes(tester), _pngA); + expect(_fallbackPaint(), findsNothing); + + replacement.complete( + _responseFor(_allRequestsFrom(originalCatalog), _pngB, scale: 2), + ); + await tester.pump(); + await tester.pump(); + + expect(service.reloadCount, 2); + expect(identical(service.catalog, originalCatalog), isFalse); + expect(service.catalog.revision, 1); + expect(service.catalog.scale, 2); + for (final icon in const [ + BusyMarkLinuxHeaderIcon.search, + BusyMarkLinuxHeaderIcon.searchEntryFind, + BusyMarkLinuxHeaderIcon.searchClear, + ]) { + final asset = service.catalog.assetFor(icon, TextDirection.ltr); + expect(asset?.scale, 2); + expect(asset?.pixelWidth, 32); + expect(asset?.pixelHeight, 32); + } + expect(_renderedBytes(tester), _pngB); + expect( + tester.getSize(find.byType(BusyMarkGtkHeaderIcon)), + const Size.square(16), + ); + expect(_fallbackPaint(), findsNothing); + }, + ); +} + +List> _lastRequests = const []; + +Map _responseFor( + List> requests, + Uint8List bytes, { + required int scale, +}) { + _lastRequests = requests; + return { + for (final request in requests) + request['key']! as String: { + 'bytes': bytes, + 'resolvedName': (request['names']! as List).first, + 'scale': scale, + 'pixelWidth': 16 * scale, + 'pixelHeight': 16 * scale, + }, + }; +} + +List> _allRequestsFrom(GtkHeaderIconCatalog _) => + _lastRequests; + +Uint8List _renderedBytes(WidgetTester tester) { + final image = tester.widget(find.byType(Image)); + return (image.image as MemoryImage).bytes; +} + +Finder _fallbackPaint() => find.descendant( + of: find.byType(BusyMarkGtkHeaderIcon), + matching: find.byType(CustomPaint), +); + +final _pngA = base64Decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4z8DwHwAFgAI/ScL1WQAAAABJRU5ErkJggg==', +); +final _pngB = base64Decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4////fwAJ+wP+4Xz8WQAAAABJRU5ErkJggg==', +); diff --git a/test/src/gtk_window_preferences_service_test.dart b/test/src/gtk_window_preferences_service_test.dart new file mode 100644 index 00000000..18ac8e07 --- /dev/null +++ b/test/src/gtk_window_preferences_service_test.dart @@ -0,0 +1,116 @@ +import 'package:busymark/src/platform/gtk_window_preferences_service.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('parses every GTK 3 decoration token in physical order', () { + final preferences = GtkWindowPreferences.fromMessage(const { + 'decorationLayout': 'close,icon,menu:minimize,maximize', + 'doubleClick': 'toggle-maximize', + 'middleClick': 'lower', + 'rightClick': 'menu', + }); + + expect(preferences.decorationLayout.left, [ + GtkWindowDecorationElement.close, + GtkWindowDecorationElement.windowIcon, + GtkWindowDecorationElement.fallbackApplicationMenu, + ]); + expect(preferences.decorationLayout.right, [ + GtkWindowDecorationElement.minimize, + GtkWindowDecorationElement.maximize, + ]); + expect(preferences.doubleClick, GtkTitlebarAction.toggleMaximize); + expect(preferences.middleClick, GtkTitlebarAction.lower); + expect(preferences.rightClick, GtkTitlebarAction.menu); + }); + + test('ignores unknown controls and actions safely', () { + final preferences = GtkWindowPreferences.fromMessage(const { + 'decorationLayout': 'unknown,close:bogus,minimize', + 'doubleClick': 'unsupported', + 'middleClick': 'none', + 'rightClick': 'minimize', + }); + + expect(preferences.decorationLayout.left, [ + GtkWindowDecorationElement.close, + ]); + expect(preferences.decorationLayout.right, [ + GtkWindowDecorationElement.minimize, + ]); + expect(preferences.doubleClick, GtkTitlebarAction.none); + expect(preferences.middleClick, GtkTitlebarAction.none); + expect(preferences.rightClick, GtkTitlebarAction.minimize); + }); + + test( + 'loads preferences and delegates the narrowly scoped lower action', + () async { + const channel = MethodChannel('busymark_test/gtk_preferences'); + final calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls.add(call); + if (call.method == 'getGtkWindowPreferences') { + return { + 'decorationLayout': 'menu:close', + 'doubleClick': 'minimize', + 'middleClick': 'none', + 'rightClick': 'menu', + }; + } + return null; + }); + addTearDown( + () => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null), + ); + const service = GtkWindowPreferencesService(channel: channel); + + final value = await service.load(); + await service.lowerWindow(); + + expect(value?.decorationLayout.left, [ + GtkWindowDecorationElement.fallbackApplicationMenu, + ]); + expect(value?.decorationLayout.right, [GtkWindowDecorationElement.close]); + expect(value?.doubleClick, GtkTitlebarAction.minimize); + expect(calls.map((call) => call.method), [ + 'getGtkWindowPreferences', + 'lowerWindow', + ]); + }, + ); + + test('window preference values have stable equality', () { + final first = GtkWindowPreferences.fromMessage(const { + 'decorationLayout': ':minimize,maximize,close', + 'doubleClick': 'toggle-maximize', + 'middleClick': 'none', + 'rightClick': 'menu', + }); + final second = GtkWindowPreferences.fromMessage(const { + 'decorationLayout': ':minimize,maximize,close', + 'doubleClick': 'toggle-maximize', + 'middleClick': 'none', + 'rightClick': 'menu', + }); + + expect(first, second); + expect(first.hashCode, second.hashCode); + }); + + test('null decoration layout uses the documented GTK 3 default', () { + final layout = GtkDecorationLayout.parse(null); + + expect(layout.left, [GtkWindowDecorationElement.fallbackApplicationMenu]); + expect(layout.right, [ + GtkWindowDecorationElement.minimize, + GtkWindowDecorationElement.maximize, + GtkWindowDecorationElement.close, + ]); + }); +} diff --git a/test/src/html_export_ui_test.dart b/test/src/html_export_ui_test.dart index b5392c2f..804261bb 100644 --- a/test/src/html_export_ui_test.dart +++ b/test/src/html_export_ui_test.dart @@ -33,14 +33,14 @@ void main() { enabled = true; expect(await registry.execute(BusyMarkCommandIds.export), isTrue); expect(calls, 1); - final native = File('linux/runner/my_application.cc').readAsStringSync(); - expect(native, contains('"header.export"')); - expect(native, isNot(contains('"header.export-html"'))); - expect(native, isNot(contains('"header.export-pdf"'))); - expect(native, contains('configuration.can_export_pdf')); - expect(native, contains('configuration.can_export_html')); - expect(native, contains('"setCanExportPdf"')); - expect(native, contains('"setCanExportHtml"')); + // The Flutter-owned header uses the shared command registry and menu; + // GTK now transports popups rather than defining Export header actions. + expect( + registry.commands + .where((command) => command.id.startsWith(BusyMarkCommandIds.export)) + .map((command) => command.id), + [BusyMarkCommandIds.export], + ); }); testWidgets('Flutter Main menu exposes document export', (tester) async { diff --git a/test/src/linux_page_frame_test.dart b/test/src/linux_page_frame_test.dart new file mode 100644 index 00000000..cf8bbb02 --- /dev/null +++ b/test/src/linux_page_frame_test.dart @@ -0,0 +1,561 @@ +import 'dart:ui' as ui; + +import 'package:busymark/src/app/busymark_design.dart'; +import 'package:busymark/src/app/linux/linux_header_style.dart'; +import 'package:busymark/src/app/linux/linux_page_frame.dart'; +import 'package:busymark/src/app/linux/linux_window_host.dart'; +import 'package:busymark/src/platform/gtk_window_preferences_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets( + 'narrow title allocation stays continuous while the sidebar changes width', + (tester) async { + Widget build(double width) => MaterialApp( + home: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: width, + height: BusyMarkSizes.toolbarHeight, + child: const LinuxPageHeaderInsetsScope( + leftObstruction: 0, + rightObstruction: 120, + child: BusyMarkLinuxHeaderLayout( + leading: SizedBox(width: 68, height: 34), + title: SizedBox( + key: ValueKey('continuous-title'), + width: 300, + height: 16, + ), + trailing: SizedBox(width: 160, height: 34), + ), + ), + ), + ), + ); + final title = find.byKey(const ValueKey('continuous-title')); + await tester.pumpWidget(build(600)); + final before = tester.getCenter(title).dx; + await tester.pumpWidget(build(590)); + expect( + (tester.getCenter(title).dx - before).abs(), + lessThanOrEqualTo(10), + ); + expect( + tester.getSize(title).width, + greaterThanOrEqualTo(BusyMarkSizes.headerTitleMinWidth), + ); + }, + ); + for (final width in [500.0, 800.0]) { + for (final direction in TextDirection.values) { + testWidgets( + 'header title fits asymmetric controls at width $width in $direction', + (tester) async { + final rtl = direction == TextDirection.rtl; + await tester.pumpWidget( + MaterialApp( + home: Align( + alignment: Alignment.topLeft, + child: Directionality( + textDirection: direction, + child: SizedBox( + key: const ValueKey('header-layout'), + width: width, + height: BusyMarkSizes.toolbarHeight, + child: LinuxPageHeaderInsetsScope( + leftObstruction: rtl ? 120 : 0, + rightObstruction: rtl ? 0 : 120, + child: const BusyMarkLinuxHeaderLayout( + leading: SizedBox( + key: ValueKey('leading-controls'), + width: 68, + height: 34, + ), + title: SizedBox( + key: ValueKey('header-title'), + width: 300, + height: 16, + ), + trailing: SizedBox( + key: ValueKey('trailing-controls'), + width: 160, + height: 34, + ), + ), + ), + ), + ), + ), + ), + ); + final title = tester.getRect( + find.byKey(const ValueKey('header-title')), + ); + expect(title.width, greaterThan(0)); + expect( + title.overlaps( + tester.getRect(find.byKey(const ValueKey('leading-controls'))), + ), + isFalse, + ); + expect( + title.overlaps( + tester.getRect(find.byKey(const ValueKey('trailing-controls'))), + ), + isFalse, + ); + if (width == 800) { + expect( + title.center.dx, + tester + .getRect(find.byKey(const ValueKey('header-layout'))) + .center + .dx, + ); + } + expect(tester.takeException(), isNull); + }, + ); + } + } + for (final brightness in Brightness.values) { + testWidgets( + '$brightness seam paints the same subtle color across header and body', + (tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(1000, 700); + addTearDown(tester.view.reset); + final harnessKey = GlobalKey<_FrameHarnessState>(); + final boundaryKey = GlobalKey(); + await tester.pumpWidget( + RepaintBoundary( + key: boundaryKey, + child: _testApp( + _FrameHarness(key: harnessKey), + brightness: brightness, + ), + ), + ); + harnessKey.currentState!.toggle(); + await tester.pumpAndSettle(); + final colors = BusyMarkSurfaceColors.of( + tester.element(find.byType(LinuxPageFrame)), + ); + final expected = Color.alphaBlend( + colors.sidebarBorder, + colors.sidebar, + ).toARGB32(); + final x = + tester + .getRect(find.byKey(const ValueKey('linux-sidebar-viewport'))) + .right + .floor() - + 1; + final boundary = + boundaryKey.currentContext!.findRenderObject() + as RenderRepaintBoundary; + await tester.runAsync(() async { + final picture = await boundary.toImage(pixelRatio: 1); + try { + final pixels = (await picture.toByteData( + format: ui.ImageByteFormat.rawRgba, + ))!; + for (final y in [10, 200, 680]) { + final offset = (y * picture.width + x) * 4; + for (var component = 0; component < 3; component++) { + final expectedByte = (expected >> ((2 - component) * 8)) & 0xff; + expect( + pixels.getUint8(offset + component), + closeTo(expectedByte, 1), + reason: 'seam at y=$y', + ); + } + } + } finally { + picture.dispose(); + } + }); + }, + ); + } + testWidgets('one viewport owns the full-height sidebar edge at every frame', ( + tester, + ) async { + final harnessKey = GlobalKey<_FrameHarnessState>(); + await tester.pumpWidget(_testApp(_FrameHarness(key: harnessKey))); + + _expectSharedEdge(tester, expectedWidth: 0); + harnessKey.currentState!.toggle(); + await tester.pump(); + for (final elapsed in const [ + Duration(milliseconds: 35), + Duration(milliseconds: 45), + Duration(milliseconds: 55), + ]) { + await tester.pump(elapsed); + _expectSharedEdge(tester); + } + await tester.pumpAndSettle(); + _expectSharedEdge(tester, expectedWidth: BusyMarkSizes.sidebarWidth); + + harnessKey.currentState!.toggle(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 45)); + final closingWidth = _sidebarWidth(tester); + harnessKey.currentState!.toggle(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 20)); + expect(_sidebarWidth(tester), greaterThan(closingWidth)); + _expectSharedEdge(tester); + }); + + testWidgets('unrelated rebuild preserves partial progress and body state', ( + tester, + ) async { + final harnessKey = GlobalKey<_FrameHarnessState>(); + await tester.pumpWidget(_testApp(_FrameHarness(key: harnessKey))); + expect( + MediaQuery.disableAnimationsOf( + tester.element(find.byType(LinuxPageFrame)), + ), + isFalse, + ); + expect(_sidebarWidth(tester), 0); + harnessKey.currentState!.toggle(); + await tester.pump(const Duration(milliseconds: 80)); + final before = _sidebarWidth(tester); + final bodyState = tester.state(find.byType(_RetainedBody)); + + harnessKey.currentState!.rebuild(); + await tester.pump(); + + expect(_sidebarWidth(tester), closeTo(before, 0.01)); + expect(tester.state(find.byType(_RetainedBody)), same(bodyState)); + _expectSharedEdge(tester); + }); + + testWidgets( + 'breakpoint settlement is immediate and retained state survives', + (tester) async { + final harnessKey = GlobalKey<_FrameHarnessState>(); + await tester.pumpWidget(_testApp(_FrameHarness(key: harnessKey))); + harnessKey.currentState!.toggle(); + await tester.pumpAndSettle(); + final sidebarState = tester.state(find.byType(_RetainedSidebar)); + + harnessKey.currentState!.setAvailable(false); + await tester.pump(); + _expectSharedEdge(tester, expectedWidth: 0); + harnessKey.currentState!.setAvailable(true); + await tester.pump(); + _expectSharedEdge(tester, expectedWidth: BusyMarkSizes.sidebarWidth); + expect(tester.state(find.byType(_RetainedSidebar)), same(sidebarState)); + }, + ); + + testWidgets('RTL and fractional device pixels share one snapped edge', ( + tester, + ) async { + tester.view.devicePixelRatio = 1.25; + addTearDown(tester.view.reset); + final harnessKey = GlobalKey<_FrameHarnessState>(); + await tester.pumpWidget( + _testApp( + _FrameHarness(key: harnessKey), + direction: TextDirection.rtl, + devicePixelRatio: 1.25, + ), + ); + harnessKey.currentState!.toggle(); + await tester.pump(const Duration(milliseconds: 73)); + + final width = _sidebarWidth(tester); + expect(width * 1.25, closeTo((width * 1.25).roundToDouble(), 0.0001)); + _expectSharedEdge(tester, rtl: true); + }); + + testWidgets( + 'divider follows the snapped physical viewport while opening and closing', + (tester) async { + tester.view.devicePixelRatio = .75; + addTearDown(tester.view.reset); + final harnessKey = GlobalKey<_FrameHarnessState>(); + await tester.pumpWidget( + _testApp(_FrameHarness(key: harnessKey), devicePixelRatio: .75), + ); + + harnessKey.currentState!.toggle(); + await tester.pump(); + await tester.pump(const Duration(microseconds: 1)); + expect(_sidebarWidth(tester), 0); + expect(_sidebarTicker(tester).enabled, isTrue); + expect(_dividerAlpha(tester), 0); + + await tester.pump(const Duration(milliseconds: 10)); + expect(_sidebarWidth(tester), greaterThanOrEqualTo(1 / .75)); + expect(_dividerAlpha(tester), _themedDividerAlpha(tester)); + _expectSharedEdge(tester); + await tester.pumpAndSettle(); + + harnessKey.currentState!.toggle(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 199)); + expect(_sidebarWidth(tester), 0); + expect(_sidebarTicker(tester).enabled, isTrue); + expect(_dividerAlpha(tester), 0); + _expectSharedEdge(tester, expectedWidth: 0); + + await tester.pumpAndSettle(); + expect(_sidebarTicker(tester).enabled, isFalse); + expect(_dividerAlpha(tester), 0); + }, + ); + + testWidgets( + 'animation setting settles a running reveal to its logical target', + (tester) async { + final harnessKey = GlobalKey<_FrameHarnessState>(); + await tester.pumpWidget(_testApp(_FrameHarness(key: harnessKey))); + expect( + MediaQuery.disableAnimationsOf( + tester.element(find.byType(LinuxPageFrame)), + ), + isFalse, + ); + expect(_sidebarWidth(tester), 0); + harnessKey.currentState!.toggle(); + await tester.pump(); + expect(_sidebarWidth(tester), 0); + await tester.pump(const Duration(milliseconds: 60)); + expect(_sidebarWidth(tester), inExclusiveRange(0, 300)); + + harnessKey.currentState!.setDisableAnimations(true); + await tester.pump(); + _expectSharedEdge(tester, expectedWidth: BusyMarkSizes.sidebarWidth); + }, + ); + + testWidgets('header receives only the presented system-control obstruction', ( + tester, + ) async { + final harnessKey = GlobalKey<_FrameHarnessState>(); + await tester.pumpWidget( + _testApp( + _FrameHarness(key: harnessKey), + leftControlInset: 240, + rightControlInset: 100, + ), + ); + + LinuxPageHeaderInsetsScope insets() => LinuxPageHeaderInsetsScope.of( + tester.element(find.byKey(const ValueKey('frame-header-probe'))), + ); + expect(insets().leftObstruction, 240); + expect(insets().rightObstruction, 100); + + harnessKey.currentState!.toggle(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 80)); + final presentedWidth = _sidebarWidth(tester); + expect(presentedWidth, inExclusiveRange(0, 240)); + expect(insets().leftObstruction, closeTo(240 - presentedWidth, .001)); + expect(insets().rightObstruction, 100); + + await tester.pumpAndSettle(); + expect(insets().leftObstruction, 0); + expect(insets().rightObstruction, 100); + }); + + testWidgets('frame is the single owner of the full-height sidebar seam', ( + tester, + ) async { + final harnessKey = GlobalKey<_FrameHarnessState>(); + await tester.pumpWidget(_testApp(_FrameHarness(key: harnessKey))); + + final surface = tester.widget( + find.byKey(const ValueKey('frame-sidebar-surface'), skipOffstage: false), + ); + expect(surface.showEndBorder, isFalse); + expect(_dividerAlpha(tester), 0); + expect(_seamDecorations(tester), hasLength(1)); + + harnessKey.currentState!.toggle(); + await tester.pumpAndSettle(); + expect(_dividerAlpha(tester), _themedDividerAlpha(tester)); + expect(_seamDecorations(tester), hasLength(1)); + _expectSharedEdge(tester, expectedWidth: BusyMarkSizes.sidebarWidth); + }); +} + +Widget _testApp( + Widget child, { + Brightness brightness = Brightness.light, + TextDirection direction = TextDirection.ltr, + bool disableAnimations = false, + double devicePixelRatio = 1, + double leftControlInset = 0, + double rightControlInset = 0, +}) => MaterialApp( + theme: ThemeData(brightness: brightness), + home: LinuxWindowMetricsScope( + leftControlInset: leftControlInset, + rightControlInset: rightControlInset, + windowActive: true, + preferences: GtkWindowPreferences.defaults(), + child: MediaQuery( + data: MediaQueryData( + size: const Size(1000, 700), + devicePixelRatio: devicePixelRatio, + disableAnimations: disableAnimations, + ), + child: Directionality(textDirection: direction, child: child), + ), + ), +); + +double _sidebarWidth(WidgetTester tester) => + tester.getSize(find.byKey(const ValueKey('linux-sidebar-viewport'))).width; + +TickerMode _sidebarTicker(WidgetTester tester) => tester.widget( + find.descendant( + of: find.byKey(const ValueKey('linux-sidebar-viewport')), + matching: find.byType(TickerMode), + ), +); + +double _dividerAlpha(WidgetTester tester) { + final box = tester.widget( + find.byKey(const ValueKey('linux-sidebar-viewport')), + ); + final decoration = box.decoration as BoxDecoration; + final border = decoration.border! as BorderDirectional; + return border.end.color.a; +} + +double _themedDividerAlpha(WidgetTester tester) => BusyMarkSurfaceColors.of( + tester.element(find.byType(LinuxPageFrame)), +).sidebarBorder.a; + +List _seamDecorations(WidgetTester tester) => tester + .widgetList( + find.descendant( + of: find.byType(LinuxPageFrame), + matching: find.byType(DecoratedBox), + ), + ) + .where( + (box) => + box.decoration is BoxDecoration && + (box.decoration as BoxDecoration).border is BorderDirectional, + ) + .toList(); + +void _expectSharedEdge( + WidgetTester tester, { + double? expectedWidth, + bool rtl = false, +}) { + final viewport = tester.getRect( + find.byKey(const ValueKey('linux-sidebar-viewport')), + ); + final main = tester.getRect(find.byKey(const ValueKey('frame-main'))); + if (rtl) { + expect(viewport.left, closeTo(main.right, 0.001)); + } else { + expect(viewport.right, closeTo(main.left, 0.001)); + } + if (expectedWidth != null) { + expect(viewport.width, closeTo(expectedWidth, 0.001)); + } + expect( + tester + .getSize( + find.byKey(const ValueKey('full-sidebar-child'), skipOffstage: false), + ) + .width, + BusyMarkSizes.sidebarWidth, + ); + expect(find.byType(ClipRect), findsOneWidget); +} + +class _FrameHarness extends StatefulWidget { + const _FrameHarness({super.key}); + + @override + State<_FrameHarness> createState() => _FrameHarnessState(); +} + +class _FrameHarnessState extends State<_FrameHarness> { + var expanded = false; + var available = true; + var generation = 0; + var rebuildCount = 0; + var disableAnimations = false; + + void toggle() => setState(() { + expanded = !expanded; + generation += 1; + }); + + void setAvailable(bool value) => setState(() => available = value); + void setDisableAnimations(bool value) => + setState(() => disableAnimations = value); + void rebuild() => setState(() => rebuildCount += 1); + + @override + Widget build(BuildContext context) { + return MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(disableAnimations: disableAnimations), + child: LinuxPageFrame( + header: Builder( + builder: (context) => SizedBox( + key: const ValueKey('frame-header-probe'), + child: Text('$rebuildCount'), + ), + ), + body: const _RetainedBody(key: ValueKey('frame-main')), + sidebarHeader: const SizedBox(height: 46), + sidebarBody: const BusyMarkSidebarSurface( + key: ValueKey('frame-sidebar-surface'), + showEndBorder: false, + child: _RetainedSidebar(), + ), + sidebarAvailable: available, + sidebarExpanded: expanded, + sidebarTransitionGeneration: generation, + ), + ); + } +} + +class _RetainedBody extends StatefulWidget { + const _RetainedBody({super.key}); + + @override + State<_RetainedBody> createState() => _RetainedBodyState(); +} + +class _RetainedBodyState extends State<_RetainedBody> { + @override + Widget build(BuildContext context) => const SizedBox.expand(); +} + +class _RetainedSidebar extends StatefulWidget { + const _RetainedSidebar(); + + @override + State<_RetainedSidebar> createState() => _RetainedSidebarState(); +} + +class _RetainedSidebarState extends State<_RetainedSidebar> { + @override + Widget build(BuildContext context) => const SizedBox( + key: ValueKey('full-sidebar-child'), + width: BusyMarkSizes.sidebarWidth, + ); +} diff --git a/test/src/linux_window_host_test.dart b/test/src/linux_window_host_test.dart new file mode 100644 index 00000000..833b16be --- /dev/null +++ b/test/src/linux_window_host_test.dart @@ -0,0 +1,790 @@ +import 'dart:async'; + +import 'package:busymark/l10n/generated/app_localizations.dart'; +import 'package:busymark/src/app/busymark_design.dart'; +import 'package:busymark/src/app/busymark_glyphs.dart'; +import 'package:busymark/src/app/busymark_dialogs.dart'; +import 'package:busymark/src/app/window_control_service.dart'; +import 'package:busymark/src/app/linux/linux_header_style.dart'; +import 'package:busymark/src/app/linux/linux_window_host.dart'; +import 'package:busymark/src/platform/gtk_window_preferences_service.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:yaru/yaru.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const yaruWindowChannel = MethodChannel('yaru_window'); + const yaruEventsChannel = EventChannel('yaru_window/events'); + late List windowCalls; + late Map nativeState; + late MockStreamHandlerEventSink eventSink; + late int eventListenCount; + late int eventCancelCount; + + setUp(() { + windowCalls = []; + nativeState = {}; + eventListenCount = 0; + eventCancelCount = 0; + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler(yaruWindowChannel, (call) async { + windowCalls.add(call); + return call.method == 'state' ? nativeState : null; + }); + messenger.setMockStreamHandler( + yaruEventsChannel, + MockStreamHandler.inline( + onListen: (_, sink) { + eventListenCount += 1; + eventSink = sink; + }, + onCancel: (_) { + eventCancelCount += 1; + }, + ), + ); + }); + + tearDown(() { + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler(yaruWindowChannel, null); + messenger.setMockStreamHandler(yaruEventsChannel, null); + }); + + testWidgets( + 'dialogs cover application header actions while system Close uses the guarded service', + (tester) async { + var headerActions = 0; + final controls = _RecordingWindowControls(); + addTearDown(controls.dispose); + await tester.pumpWidget( + ProviderScope( + overrides: [ + gtkWindowPreferencesProvider.overrideWith( + (ref) => Stream.value(_closePreferences), + ), + windowControlServiceProvider.overrideWith((ref) => controls), + ], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + builder: (context, child) => LinuxWindowHost(child: child!), + home: Builder( + builder: (context) => Scaffold( + body: Column( + children: [ + BusyMarkLinuxHeaderLayout( + leading: TextButton( + key: const ValueKey('header-action'), + onPressed: () => headerActions++, + child: const Text('Header action'), + ), + title: const BusyMarkLinuxHeaderTitle('Workspace'), + trailing: const SizedBox.shrink(), + ), + Expanded( + child: Center( + child: TextButton( + onPressed: () => unawaited( + showBusyMarkModalDialog( + context, + barrierDismissible: false, + builder: (_) => const AlertDialog( + content: Text('Window modal'), + ), + ), + ), + child: const Text('Open modal'), + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + final headerPoint = tester.getCenter( + find.byKey(const ValueKey('header-action')), + ); + await tester.tap(find.text('Open modal')); + await tester.pumpAndSettle(); + await tester.tapAt(headerPoint); + await tester.pump(); + expect(headerActions, 0); + expect(find.text('Window modal'), findsOneWidget); + await tester.tap(find.byType(YaruWindowControl)); + await tester.pump(); + expect(controls.closeRequests, 1); + expect(windowCalls.where((call) => call.method == 'close'), isEmpty); + }, + ); + + testWidgets('icon uses its physical GTK side and contributes to the inset', ( + tester, + ) async { + const preferences = GtkWindowPreferences( + decorationLayout: GtkDecorationLayout( + left: [ + GtkWindowDecorationElement.windowIcon, + GtkWindowDecorationElement.minimize, + ], + right: [GtkWindowDecorationElement.close], + ), + doubleClick: GtkTitlebarAction.toggleMaximize, + middleClick: GtkTitlebarAction.none, + rightClick: GtkTitlebarAction.menu, + ); + + await _pumpHost( + tester, + preferences: preferences, + locale: const Locale('ar'), + ); + + final left = find.byKey(const ValueKey('linux-window-controls-left')); + final right = find.byKey(const ValueKey('linux-window-controls-right')); + expect(left, findsOneWidget); + expect(right, findsOneWidget); + expect( + find.descendant(of: left, matching: find.byType(SvgPicture)), + findsOneWidget, + ); + expect( + find.descendant(of: right, matching: find.byType(SvgPicture)), + findsNothing, + ); + expect(tester.getRect(left).left, 0); + expect(tester.getRect(right).right, 800); + + final metrics = LinuxWindowMetricsScope.of( + tester.element(find.byKey(const ValueKey('metrics-probe'))), + ); + expect( + metrics.leftControlInset, + BusyMarkLinuxWindowMetrics.clusterWidth( + preferences.decorationLayout.left, + ), + ); + expect( + metrics.rightControlInset, + BusyMarkLinuxWindowMetrics.clusterWidth( + preferences.decorationLayout.right, + ), + ); + + final leftControls = find.descendant( + of: left, + matching: find.byType(YaruWindowControl), + ); + expect(leftControls, findsOneWidget); + expect( + tester.getCenter(find.byType(SvgPicture)).dx, + lessThan(tester.getCenter(leftControls).dx), + ); + }); + + testWidgets( + 'fallback application menu is absent while titlebar menu still works', + (tester) async { + const preferences = GtkWindowPreferences( + decorationLayout: GtkDecorationLayout( + left: [GtkWindowDecorationElement.fallbackApplicationMenu], + right: [GtkWindowDecorationElement.close], + ), + doubleClick: GtkTitlebarAction.toggleMaximize, + middleClick: GtkTitlebarAction.none, + rightClick: GtkTitlebarAction.menu, + ); + await _pumpHost( + tester, + preferences: preferences, + child: const LinuxTitlebarGestureRegion( + child: SizedBox( + key: ValueKey('titlebar-drag-region'), + width: 180, + height: 46, + ), + ), + ); + + expect(find.byIcon(BusyMarkGlyphs.menuVertical), findsNothing); + expect(windowCalls.where((call) => call.method == 'showMenu'), isEmpty); + final metrics = LinuxWindowMetricsScope.of( + tester.element(find.byKey(const ValueKey('metrics-probe'))), + ); + expect(metrics.leftControlInset, 0); + + await tester.tapAt( + tester.getCenter(find.byKey(const ValueKey('titlebar-drag-region'))), + buttons: kSecondaryMouseButton, + ); + await tester.pump(); + + expect( + windowCalls.where((call) => call.method == 'showMenu'), + hasLength(1), + ); + }, + ); + + testWidgets('window-state stream survives unrelated host rebuilds', ( + tester, + ) async { + final preferences = StreamController(); + addTearDown(preferences.close); + final harnessKey = GlobalKey<_HostHarnessState>(); + + await _pumpHost( + tester, + preferencesStream: preferences.stream, + harnessKey: harnessKey, + ); + final initialStateCalls = _callCount(windowCalls, 'state'); + expect(initialStateCalls, 1); + expect(eventListenCount, 1); + + preferences.add( + const GtkWindowPreferences( + decorationLayout: GtkDecorationLayout( + left: [GtkWindowDecorationElement.windowIcon], + right: [GtkWindowDecorationElement.maximize], + ), + doubleClick: GtkTitlebarAction.minimize, + middleClick: GtkTitlebarAction.none, + rightClick: GtkTitlebarAction.menu, + ), + ); + await tester.pump(); + await tester.pump(); + harnessKey.currentState! + ..rebuildParent() + ..toggleTheme() + ..toggleLocale(); + await tester.pump(); + + expect(_callCount(windowCalls, 'state'), initialStateCalls); + expect(eventListenCount, 1); + expect(eventCancelCount, 0); + + await _sendState(tester, eventSink, { + 'active': true, + 'maximized': true, + 'restorable': true, + }); + + final control = tester.widget( + find.byType(YaruWindowControl), + ); + expect(control.type, YaruWindowControlType.restore); + expect(_callCount(windowCalls, 'state'), initialStateCalls); + }); + + for (final scenario + in <(String, Map, YaruWindowControlType, String)>[ + ( + 'normal', + {'maximizable': true}, + YaruWindowControlType.maximize, + 'maximize', + ), + ( + 'maximized', + {'maximized': true, 'restorable': true}, + YaruWindowControlType.restore, + 'restore', + ), + ( + 'fullscreen', + {'fullscreen': true, 'restorable': true}, + YaruWindowControlType.restore, + 'restore', + ), + ]) { + testWidgets('${scenario.$1} maximize decoration has matching behavior', ( + tester, + ) async { + nativeState = scenario.$2; + await _pumpHost(tester, preferences: _maximizePreferences); + + final controlFinder = find.byType(YaruWindowControl); + final control = tester.widget(controlFinder); + expect(control.type, scenario.$3); + expect( + find.bySemanticsLabel( + scenario.$3 == YaruWindowControlType.restore ? 'Restore' : 'Maximize', + ), + findsWidgets, + ); + + await tester.tap(controlFinder); + await tester.pump(); + + expect(_callCount(windowCalls, scenario.$4), 1); + if (scenario.$1 == 'fullscreen') { + expect(_callCount(windowCalls, 'maximize'), 0); + } + }); + } + + testWidgets('fullscreen titlebar double-click still restores', ( + tester, + ) async { + nativeState = {'fullscreen': true, 'restorable': true}; + await _pumpHost( + tester, + preferences: _maximizePreferences, + child: const LinuxTitlebarGestureRegion( + child: SizedBox( + key: ValueKey('titlebar-double-click-region'), + width: 180, + height: 46, + ), + ), + ); + + final region = find.byKey(const ValueKey('titlebar-double-click-region')); + await tester.tapAt(tester.getCenter(region)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(tester.getCenter(region)); + await tester.pump(const Duration(milliseconds: 50)); + + expect(_callCount(windowCalls, 'restore'), 1); + expect(_callCount(windowCalls, 'maximize'), 0); + }); + + testWidgets('the document title itself handles titlebar double-click', ( + tester, + ) async { + await _pumpHost( + tester, + preferences: _maximizePreferences, + child: const BusyMarkLinuxHeaderTitle('document.md'), + ); + final title = find.text('document.md'); + await tester.tap(title); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tap(title); + await tester.pump(const Duration(milliseconds: 50)); + expect(_callCount(windowCalls, 'maximize'), 1); + }); + + testWidgets('live GTK layout removal retains each decoration identity', ( + tester, + ) async { + final preferences = StreamController(); + addTearDown(preferences.close); + await _pumpHost(tester, preferencesStream: preferences.stream); + preferences.add( + GtkWindowPreferences.fromMessage(const { + 'decorationLayout': ':minimize,maximize,close', + }), + ); + await tester.pump(); + await tester.pumpAndSettle(); + Finder maximize() => find.byWidgetPredicate( + (widget) => + widget is YaruWindowControl && + widget.type == YaruWindowControlType.maximize, + ); + final originalState = tester.state(maximize()); + double progress() { + final paint = tester.widget( + find.descendant(of: maximize(), matching: find.byType(CustomPaint)), + ); + final dynamic painter = paint.painter; + return painter.progress as double; + } + + expect(progress(), 0); + preferences.add( + GtkWindowPreferences.fromMessage(const { + 'decorationLayout': ':maximize,close', + }), + ); + await tester.pump(); + await tester.pumpAndSettle(); + expect(progress(), 0); + expect(tester.state(maximize()), same(originalState)); + await tester.tap(maximize()); + expect(_callCount(windowCalls, 'maximize'), 1); + }); + + for (final gtkPolicy in [false, true]) { + testWidgets( + 'reduced motion settles maximize and restore icons (GTK=$gtkPolicy)', + (tester) async { + await _pumpHost( + tester, + preferences: _maximizePreferences, + disableAnimations: !gtkPolicy, + gtkAnimationsEnabled: !gtkPolicy, + ); + await tester.pumpAndSettle(); + await _sendState(tester, eventSink, { + 'maximized': true, + 'restorable': true, + }); + await tester.pump(); + expect(tester.hasRunningAnimations, isFalse); + await tester.tap(find.bySemanticsLabel('Restore').first); + expect(_callCount(windowCalls, 'restore'), 1); + await _sendState(tester, eventSink, { + 'maximized': false, + 'maximizable': true, + }); + await tester.pump(); + expect(tester.hasRunningAnimations, isFalse); + await tester.tap(find.bySemanticsLabel('Maximize').first); + expect(_callCount(windowCalls, 'maximize'), 1); + }, + ); + } + + testWidgets('GTK reduced motion settles window-control hover immediately', ( + tester, + ) async { + await _pumpHost( + tester, + preferences: _closePreferences, + gtkAnimationsEnabled: false, + ); + await tester.pumpAndSettle(); + final pointer = await tester.createGesture(kind: PointerDeviceKind.mouse); + await pointer.addPointer(location: Offset.zero); + addTearDown(pointer.removePointer); + final onSurface = Theme.of( + tester.element(find.byType(YaruWindowControl)), + ).colorScheme.onSurface; + Color background() => tester + .widgetList( + find.descendant( + of: find.byKey(const ValueKey('linux-window-controls-right')), + matching: find.byType(DecoratedBox), + ), + ) + .map((widget) => widget.decoration) + .whereType() + .where( + (decoration) => + decoration.shape == BoxShape.circle && + decoration.color != Colors.transparent, + ) + .single + .color!; + expect(background(), onSurface.withValues(alpha: .1)); + await pointer.moveTo( + tester.getCenter(find.bySemanticsLabel('Close').first), + ); + await tester.pump(); + // Check the painted control feedback; the separate Tooltip may fade. + expect(background(), onSurface.withValues(alpha: .15)); + await pointer.moveTo(const Offset(200, 200)); + await tester.pump(); + expect(background(), onSurface.withValues(alpha: .1)); + }); + + testWidgets( + 'enabling reduced motion settles an in-flight window icon morph', + (tester) async { + final harness = GlobalKey<_HostHarnessState>(); + await _pumpHost( + tester, + preferences: _maximizePreferences, + harnessKey: harness, + ); + await tester.pumpAndSettle(); + await _sendState(tester, eventSink, { + 'maximized': true, + 'restorable': true, + }); + await tester.pump(const Duration(milliseconds: 375)); + expect(tester.hasRunningAnimations, isTrue); + harness.currentState!.setDisableAnimations(true); + await tester.pump(); + expect(tester.hasRunningAnimations, isFalse); + final paint = tester.widget( + find.descendant( + of: find.byType(YaruWindowControl), + matching: find.byType(CustomPaint), + ), + ); + final dynamic painter = paint.painter; + expect(painter.progress, 1); + }, + ); + + testWidgets('enabled motion retains the normal active-window fade', ( + tester, + ) async { + nativeState = {'active': true}; + await _pumpHost(tester, preferences: _closePreferences); + expect(_controlAnimatedOpacity(tester).duration, BusyMarkMotion.fast); + + await _sendState(tester, eventSink, {'active': false}); + await tester.pump(_half(BusyMarkMotion.fast)); + + expect(_renderedControlOpacity(tester), inExclusiveRange(.5, 1)); + await tester.pumpAndSettle(); + expect(_renderedControlOpacity(tester), .5); + }); + + testWidgets('Flutter reduced motion settles the host fade immediately', ( + tester, + ) async { + nativeState = {'active': true}; + await _pumpHost( + tester, + preferences: _closePreferences, + disableAnimations: true, + ); + + await _sendState(tester, eventSink, {'active': false}); + + expect(_controlAnimatedOpacityFinder(), findsNothing); + expect(_renderedControlOpacity(tester), .5); + }); + + testWidgets('GTK animation policy reaches the actual host overlay', ( + tester, + ) async { + nativeState = {'active': true}; + await _pumpHost( + tester, + preferences: _closePreferences, + gtkAnimationsEnabled: false, + ); + + final hostContext = tester.element(find.byType(LinuxWindowHost)); + expect(MediaQuery.disableAnimationsOf(hostContext), isTrue); + expect(MediaQuery.alwaysUse24HourFormatOf(hostContext), isTrue); + expect(_controlAnimatedOpacityFinder(), findsNothing); + }); + + testWidgets('enabling reduced motion stops a fade and later fades can run', ( + tester, + ) async { + nativeState = {'active': true}; + final harnessKey = GlobalKey<_HostHarnessState>(); + await _pumpHost( + tester, + preferences: _closePreferences, + harnessKey: harnessKey, + ); + + await _sendState(tester, eventSink, {'active': false}); + await tester.pump(_half(BusyMarkMotion.fast)); + expect(_renderedControlOpacity(tester), inExclusiveRange(.5, 1)); + + harnessKey.currentState!.setDisableAnimations(true); + await tester.pump(); + expect(_controlAnimatedOpacityFinder(), findsNothing); + expect(_renderedControlOpacity(tester), .5); + + harnessKey.currentState!.setDisableAnimations(false); + await tester.pump(); + expect(_controlAnimatedOpacity(tester).duration, BusyMarkMotion.fast); + expect(_renderedControlOpacity(tester), .5); + + await _sendState(tester, eventSink, {'active': true}); + await tester.pump(_half(BusyMarkMotion.fast)); + expect(_renderedControlOpacity(tester), inExclusiveRange(.5, 1)); + await tester.pumpAndSettle(); + expect(_renderedControlOpacity(tester), 1); + }); +} + +class _RecordingWindowControls extends WindowControlService { + _RecordingWindowControls() + : super(nativeWindow: const WindowManagerNativeWindowController()); + var closeRequests = 0; + @override + Future requestClose() async => closeRequests++; +} + +const _maximizePreferences = GtkWindowPreferences( + decorationLayout: GtkDecorationLayout( + left: [], + right: [GtkWindowDecorationElement.maximize], + ), + doubleClick: GtkTitlebarAction.toggleMaximize, + middleClick: GtkTitlebarAction.none, + rightClick: GtkTitlebarAction.menu, +); + +const _closePreferences = GtkWindowPreferences( + decorationLayout: GtkDecorationLayout( + left: [], + right: [GtkWindowDecorationElement.close], + ), + doubleClick: GtkTitlebarAction.toggleMaximize, + middleClick: GtkTitlebarAction.none, + rightClick: GtkTitlebarAction.menu, +); + +Future _pumpHost( + WidgetTester tester, { + GtkWindowPreferences? preferences, + Stream? preferencesStream, + GlobalKey<_HostHarnessState>? harnessKey, + Widget? child, + Locale locale = const Locale('en'), + bool disableAnimations = false, + bool gtkAnimationsEnabled = true, +}) async { + final stream = + preferencesStream ?? Stream.value(preferences ?? _closePreferences); + await tester.pumpWidget( + ProviderScope( + overrides: [gtkWindowPreferencesProvider.overrideWith((ref) => stream)], + child: _HostHarness( + key: harnessKey, + initialLocale: locale, + initialDisableAnimations: disableAnimations, + initialGtkAnimationsEnabled: gtkAnimationsEnabled, + child: child, + ), + ), + ); + await tester.pump(); + await tester.pump(); +} + +void _emitState(MockStreamHandlerEventSink sink, Map state) { + sink.success({'id': 0, 'type': 'state', ...state}); +} + +Future _sendState( + WidgetTester tester, + MockStreamHandlerEventSink sink, + Map state, +) async { + _emitState(sink, state); + await tester.runAsync(pumpEventQueue); + await tester.pump(); +} + +int _callCount(List calls, String method) => + calls.where((call) => call.method == method).length; + +Duration _half(Duration duration) => + Duration(microseconds: duration.inMicroseconds ~/ 2); + +Finder _controlAnimatedOpacityFinder() => find.byWidgetPredicate( + (widget) => + widget is AnimatedOpacity && + widget.key == const ValueKey('linux-window-controls-opacity'), +); + +AnimatedOpacity _controlAnimatedOpacity(WidgetTester tester) => + tester.widget( + find.byWidgetPredicate( + (widget) => + widget is AnimatedOpacity && + widget.key == const ValueKey('linux-window-controls-opacity'), + ), + ); + +double _renderedControlOpacity(WidgetTester tester) { + final opacity = find.byWidgetPredicate( + (widget) => + widget is Opacity && + widget.key == const ValueKey('linux-window-controls-opacity'), + ); + if (opacity.evaluate().isNotEmpty) { + return tester.widget(opacity).opacity; + } + return tester + .widget( + find.descendant( + of: find.byKey(const ValueKey('linux-window-controls-opacity')), + matching: find.byType(FadeTransition), + ), + ) + .opacity + .value; +} + +class _HostHarness extends StatefulWidget { + const _HostHarness({ + super.key, + required this.initialLocale, + required this.initialDisableAnimations, + required this.initialGtkAnimationsEnabled, + this.child, + }); + + final Locale initialLocale; + final bool initialDisableAnimations; + final bool initialGtkAnimationsEnabled; + final Widget? child; + + @override + State<_HostHarness> createState() => _HostHarnessState(); +} + +class _HostHarnessState extends State<_HostHarness> { + late Locale locale = widget.initialLocale; + late bool disableAnimations = widget.initialDisableAnimations; + late bool gtkAnimationsEnabled = widget.initialGtkAnimationsEnabled; + var dark = false; + var rebuilds = 0; + + void rebuildParent() => setState(() => rebuilds += 1); + void toggleTheme() => setState(() => dark = !dark); + void toggleLocale() => setState(() { + locale = locale.languageCode == 'en' + ? const Locale('ar') + : const Locale('en'); + }); + void setDisableAnimations(bool value) => + setState(() => disableAnimations = value); + + @override + Widget build(BuildContext context) { + return MaterialApp( + locale: locale, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + theme: ThemeData(brightness: dark ? Brightness.dark : Brightness.light), + home: Builder( + builder: (context) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(disableAnimations: disableAnimations), + child: MediaQuery( + data: MediaQuery.of(context).copyWith( + alwaysUse24HourFormat: true, + disableAnimations: disableAnimations || !gtkAnimationsEnabled, + ), + child: LinuxWindowHost( + child: Scaffold( + body: Stack( + children: [ + Center(child: Text('$rebuilds')), + const SizedBox( + key: ValueKey('metrics-probe'), + width: 1, + height: 1, + ), + if (widget.child case final child?) child, + ], + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/test/src/markdown_serializer_metadata_test.dart b/test/src/markdown_serializer_metadata_test.dart index 30c8a386..554457a7 100644 --- a/test/src/markdown_serializer_metadata_test.dart +++ b/test/src/markdown_serializer_metadata_test.dart @@ -537,6 +537,201 @@ void main() { }, ); + for (final mode in [ + MarkdownMode.commonMark, + MarkdownMode.writersideMarkdown, + ]) { + for (final suffix in ['©', '&', '&*']) { + test( + 'bare URL character-reference suffix round-trips in $mode: $suffix', + () { + const parser = MarkdownParser(); + const url = 'https://example.com'; + const linkSource = '[$url]($url)'; + final suffixUnits = [ + for (final character in suffix.split('')) + if ('&*<'.contains(character)) '\\$character' else character, + ]; + final source = '$linkSource${suffixUnits.join()}'; + final text = '$url$suffix'; + final controller = BusyMarkWysiwygDocumentController( + document: parser + .parse(filePath: 'topic.md', source: '', mode: mode) + .busyDocument, + ); + addTearDown(controller.dispose); + final id = controller.document.blocks.single.id; + controller.updateBlockText(id, text); + final next = controller.applyEnterAt(id, text.length)!; + expect(next.offset, 0); + + void check(BusyBlock block) { + expect(block.plainText, text); + final ranges = busyInlineStyleRanges(block.inlines); + final link = ranges + .where((range) => range.kind == BusyInlineKind.link) + .single; + expect( + (link.start, link.end, link.destination), + (0, url.length, url), + ); + expect( + ranges.where((range) => range.kind != BusyInlineKind.link), + isEmpty, + ); + expect(block.plainText.substring(link.end), suffix); + expect(serializer.serializeInlineFragment(block.inlines), source); + final suffixBoundaries = [linkSource.length]; + for (final unit in suffixUnits) { + suffixBoundaries.add(suffixBoundaries.last + unit.length); + } + for (var offset = 0; offset <= text.length; offset++) { + final result = serializer.serializeInlineFragmentWithOffsets( + block.inlines, + textOffset: offset, + ); + expect(result.source, source); + expect( + result.sourceOffset, + offset == 0 + ? 0 + : offset < url.length + ? offset + 1 + : suffixBoundaries[offset - url.length], + ); + expect(result.textAtoms, hasLength(text.length)); + for (var index = 0; index < text.length; index++) { + final atom = result.textAtoms[index]; + final start = index < url.length + ? index + 1 + : suffixBoundaries[index - url.length]; + final end = index < url.length + ? start + 1 + : suffixBoundaries[index - url.length + 1]; + expect((atom.textStart, atom.textEnd), (index, index + 1)); + expect((atom.sourceStart, atom.sourceEnd), (start, end)); + expect(atom.text, text[index]); + expect(atom.escaped, end - start > 1); + expect( + source.substring(start, end), + index < url.length + ? text[index] + : suffixUnits[index - url.length], + ); + } + } + } + + // Check the live extent before demonstrating the saved-source failure. + final live = controller.document.blocks.first; + final liveLink = busyInlineStyleRanges( + live.inlines, + ).where((range) => range.kind == BusyInlineKind.link).single; + expect( + (liveLink.start, liveLink.end, liveLink.destination), + (0, url.length, url), + ); + expect(live.plainText, text); + final saved = parser + .parse( + filePath: 'topic.md', + source: controller.markdown, + mode: mode, + ) + .busyDocument; + check(saved.blocks.single); + check(live); + expect(controller.markdown, '$source\n\n'); + check( + live.copyWith( + inlines: parser.parseInlineFragment(source: source, mode: mode), + ), + ); + final reopened = BusyMarkWysiwygDocumentController(document: saved); + addTearDown(reopened.dispose); + expect(reopened.markdown, controller.markdown); + }, + ); + } + } + + for (final mode in [ + MarkdownMode.commonMark, + MarkdownMode.writersideMarkdown, + ]) { + test( + 'unrecognized URL source preserves literal text and metadata in $mode', + () { + const parser = MarkdownParser(); + const text = 'https://example.com*'; + const source = r'https://example.com\*'; + final units = [ + 'h', + ...text.substring(1, text.length - 1).split(''), + r'\*', + ]; + final boundaries = [0]; + for (final unit in units) { + boundaries.add(boundaries.last + unit.length); + } + final controller = BusyMarkWysiwygDocumentController( + document: parser + .parse(filePath: 'topic.md', source: '', mode: mode) + .busyDocument, + ); + addTearDown(controller.dispose); + final id = controller.document.blocks.single.id; + controller.updateBlockText(id, text); + void check(BusyBlock block) { + expect(block.plainText, text); + expect(busyInlineStyleRanges(block.inlines), isEmpty); + for (var offset = 0; offset <= text.length; offset++) { + final result = serializer.serializeInlineFragmentWithOffsets( + block.inlines, + textOffset: offset, + ); + expect(result.source, source); + expect(result.sourceOffset, boundaries[offset]); + expect(result.textAtoms, hasLength(text.length)); + for (var index = 0; index < text.length; index++) { + final atom = result.textAtoms[index]; + expect((atom.textStart, atom.textEnd), (index, index + 1)); + expect( + (atom.sourceStart, atom.sourceEnd), + (boundaries[index], boundaries[index + 1]), + ); + expect(atom.text, text[index]); + expect(atom.escaped, units[index].length > 1); + expect( + source.substring(atom.sourceStart, atom.sourceEnd), + units[index], + ); + } + } + } + + check(controller.document.blocks.single); + expect(controller.markdown, '$source\n'); + check( + parser + .parse( + filePath: 'topic.md', + source: controller.markdown, + mode: mode, + ) + .busyDocument + .blocks + .single, + ); + check( + controller.document.blocks.single.copyWith( + inlines: parser.parseInlineFragment(source: source, mode: mode), + ), + ); + }, + ); + } + test('descendant whitespace preservation prevents ancestor trimming', () { const preserved = {busyMarkPreserveTextWhitespaceAttribute: 'true'}; const whitespace = BusyBlock( diff --git a/test/src/modal_barrier_test.dart b/test/src/modal_barrier_test.dart index f02d4b4a..2c54531b 100644 --- a/test/src/modal_barrier_test.dart +++ b/test/src/modal_barrier_test.dart @@ -1,5 +1,3 @@ -import 'dart:io'; - import 'package:busymark/src/app/app_theme.dart'; import 'package:busymark/src/app/busymark_design.dart'; import 'package:busymark/src/app/busymark_dialogs.dart'; @@ -8,53 +6,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { - test('native dark startup fallback matches the semantic shade', () { - final source = File('linux/runner/my_application.cc').readAsStringSync(); - final match = RegExp( - r'kDefaultModalBarrierColor\[\] = ' - r'"rgba\(0,0,0,([0-9.]+)\)"', - ).firstMatch(source); - - expect(match, isNotNull); - expect(double.parse(match!.group(1)!), 0.25); - expect(source, contains('modal_barrier_color_for_depth(')); - }); - - test('native headerbar becomes inert while a modal route is active', () { - final source = File('linux/runner/my_application.cc').readAsStringSync(); - - expect( - source, - contains( - 'if (self->modal_barrier_visible ||\n' - ' self->header_bar_channel == nullptr || action == nullptr)', - ), - ); - expect( - source, - contains('close_header_menu_button(self->main_menu_button);'), - ); - expect( - source, - contains('close_header_menu_button(self->view_mode_button);'), - ); - expect(source, contains('focus_flutter_view(self);')); - expect( - source, - contains( - 'gtk_event_box_set_visible_window(GTK_EVENT_BOX(self->modal_scrim), ' - 'TRUE);', - ), - ); - expect( - source, - contains( - 'gtk_widget_add_events(self->modal_scrim, GDK_ALL_EVENTS_MASK);', - ), - ); - expect(source, contains('busymark-modal-open')); - }); - for (final (brightness, expectedAlpha) in [ (Brightness.light, 0.07), (Brightness.dark, 0.25), diff --git a/test/src/native_headerbar_audit_test.dart b/test/src/native_headerbar_audit_test.dart index 6fef932c..6feb3f91 100644 --- a/test/src/native_headerbar_audit_test.dart +++ b/test/src/native_headerbar_audit_test.dart @@ -3,114 +3,57 @@ import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; void main() { - test('Linux runner owns one native GTK headerbar', () { - final source = File('linux/runner/my_application.cc').readAsStringSync(); - - expect(source, contains('#include ')); - expect(source, contains('hdy_init()')); - expect(source, contains('hdy_application_window_new()')); - expect(source, contains('hdy_window_handle_new()')); - expect(source, contains('gtk_header_bar_new()')); - expect(source, contains('gtk_header_bar_set_show_close_button')); - expect(source, isNot(contains('gtk_window_set_titlebar'))); - expect(source, contains('gtk_menu_button_set_menu_model')); - expect(source, contains('kLegacyYaruWindowShadowCompatibilityCss')); - expect(source, contains('fl_view_set_background_color')); - expect(source, contains('"#00000000"')); - expect(source, contains('kHeaderBarChannel')); - expect(source, contains('setModalBarrierVisible')); - expect(source, contains('setModalBarrierDepth')); - expect(source, contains('modal_barrier_color_for_depth')); - expect( - source, - contains('1.0 - std::pow(1.0 - barrier.alpha, effective_depth)'), - ); - expect(source, contains('gtk_overlay_new()')); - expect(source, contains('gtk_overlay_add_overlay')); - expect(source, contains('gtk_event_box_new()')); - expect(source, contains('"busymark-modal-scrim"')); - expect(source, contains('set_widget_visible(self->modal_scrim, visible)')); - expect(source, contains('set_widget_visible(self->modal_scrim, FALSE)')); - expect(source, contains('gtk_widget_show_all(widget)')); - expect(source, isNot(contains('gtk_widget_set_visible(widget, visible)'))); - expect(source, contains('G_CALLBACK(stop_modal_scrim_event)')); - expect( - source, - isNot( - contains('gtk_widget_set_sensitive(self->titlebar_handle, !visible)'), - ), - ); - expect( - source, - contains('gtk_widget_set_vexpand(self->modal_scrim, FALSE)'), - ); - expect( - source, - isNot(contains('gtk_widget_set_vexpand(self->modal_scrim, TRUE)')), - ); - expect(source, contains('setSidebarWidth')); - expect(source, contains('setSidebarToggleVisible')); - expect(source, contains('setTextDirection')); - expect(source, contains('setDocumentControlsVisible')); - expect(source, contains('setLocalizedLabels')); - expect(source, contains('setTheme')); - expect(source, contains('applyConfiguration')); - expect(source, contains('header_configuration_session_id')); - expect(source, contains('header_configuration_revision')); - expect(source, contains('busymark-sidebar-header')); - expect(source, contains('self->sidebar_width')); - expect(source, isNot(contains('self->save_button'))); - expect( - source, - isNot(contains('connect_header_action(self, self->save_button, "save")')), - ); - expect(source, isNot(contains('window-close-symbolic'))); - expect(source, isNot(contains('window-minimize-symbolic'))); - expect(source, isNot(contains('window-maximize-symbolic'))); - }); - - test('native header configuration is atomic and latest-wins', () { - final source = File('linux/runner/my_application.cc').readAsStringSync(); + test( + 'Flutter owns application chrome; native bridge carries no split geometry', + () { + final native = File('linux/runner/my_application.cc').readAsStringSync(); + final chrome = File( + 'linux/runner/linux_chrome_host.cc', + ).readAsStringSync(); + expect(native, isNot(contains('hdy_header_bar_new'))); + expect(native, isNot(contains('gtk_header_bar_new'))); + expect(native, isNot(contains('create_header_bar'))); + expect(native, isNot(contains('busymark/headerbar'))); + expect(chrome, isNot(contains('sidebarWidth'))); + expect(chrome, isNot(contains('sidebarVisible'))); + expect(native, contains('busymark_linux_chrome_host_new')); + expect(chrome, contains('getGtkWindowPreferences')); + expect(chrome, contains('getGtkAnimationsEnabled')); + expect(chrome, contains('loadIcons')); + }, + ); - expect(source, contains('struct HeaderBarConfiguration')); - expect(source, contains('decode_header_bar_configuration')); - expect(source, contains('apply_header_bar_configuration')); - expect(source, contains('begin_header_configuration_session')); - expect(source, contains('strcmp(method, "applyConfiguration")')); - expect(source, contains('fl_lookup_string_arg(args, "sessionId")')); - expect(source, contains('fl_lookup_int64_arg(args, "revision"')); - expect(source, contains('active Dart session')); - expect(source, contains('configuration.revision <=')); - expect(source, contains('self->header_configuration_revision')); - expect(source, contains('g_object_freeze_notify')); - expect(source, contains('g_object_thaw_notify')); - expect(source, contains('self->suppress_header_actions = TRUE')); - for (final key in [ - 'sessionId', - 'title', - 'viewMode', - 'canRefresh', - 'canExportPdf', - 'canExportHtml', - 'documentControlsVisible', - 'searchActive', - 'searchVisible', - 'searchQuery', - 'sidebarVisible', - 'sidebarToggleVisible', - 'sidebarWidth', - 'textDirection', - 'backVisible', - 'fullScreen', - 'modalBarrierVisible', - 'modalBarrierDepth', - 'labels', - 'theme', + test('Workspace, Welcome and Settings use the shared full-height frame', () { + for (final page in [ + 'workspace_screen', + 'welcome_screen', + 'settings_screen', ]) { - expect(source, contains('"$key"'), reason: key); + final source = File( + 'lib/src/workspace/presentation/$page.dart', + ).readAsStringSync(); + expect(source, contains('LinuxPageFrame('), reason: page); + expect(source, contains('sidebarBody:'), reason: page); + expect(source, isNot(contains('useNativeHeaderBar')), reason: page); + expect(source, isNot(contains('showEndBorder: true')), reason: page); } }); + test( + 'native clipboard, menus, Writerside media and rendering hosts remain registered', + () { + final native = File('linux/runner/my_application.cc').readAsStringSync(); + for (final registration in [ + 'busymark_rich_clipboard_channel_new', + 'busymark_writerside_dialog_channel_new', + 'busymark_video_player_host_register_channel', + 'busymark_web_render_host_register_channel', + ]) { + expect(native, contains(registration)); + } + expect(native, contains('gtk_menu_popup_at_rect(')); + }, + ); test('Linux desktop identity uses the standard Snap launcher mapping', () { final native = File('linux/runner/my_application.cc').readAsStringSync(); final cmake = File('linux/CMakeLists.txt').readAsStringSync(); @@ -168,351 +111,6 @@ void main() { expect(localSnapBuilder, isNot(contains('ensure_desktop_file_id'))); }); - test('native labels are supplied by Dart rather than hardcoded in C++', () { - final source = File('linux/runner/my_application.cc').readAsStringSync(); - - expect(source, isNot(contains('"Today"'))); - expect(source, isNot(contains('"Day"'))); - expect(source, isNot(contains('"Week"'))); - expect(source, isNot(contains('"Month"'))); - expect(source, isNot(contains('"Agenda"'))); - expect(source, isNot(contains('"Source"'))); - expect(source, isNot(contains('"Preview"'))); - expect(source, isNot(contains('"Split"'))); - expect(source, isNot(contains('"Print"'))); - expect(source, isNot(contains('"Settings"'))); - expect(source, isNot(contains('"Keyboard Shortcuts"'))); - expect(source, isNot(contains('"Markdown and HTML"'))); - expect(source, isNot(contains('markdownAndHtml'))); - expect(source, isNot(contains('"About BusyMark"'))); - }); - - test('native main menu exposes shared application actions', () { - final service = File( - 'lib/src/platform/linux_header_bar_service.dart', - ).readAsStringSync(); - final app = File('lib/src/app/busymark_app.dart').readAsStringSync(); - final dialogs = File( - 'lib/src/app/busymark_dialogs.dart', - ).readAsStringSync(); - final shortcuts = File( - 'lib/src/app/busymark_shortcuts.dart', - ).readAsStringSync(); - final workspace = File( - 'lib/src/workspace/presentation/workspace_screen.dart', - ).readAsStringSync(); - final settings = File( - 'lib/src/workspace/presentation/settings_screen.dart', - ).readAsStringSync(); - final welcome = File( - 'lib/src/workspace/presentation/welcome_screen.dart', - ).readAsStringSync(); - final mainMenu = File( - 'lib/src/app/busymark_main_menu.dart', - ).readAsStringSync(); - final native = File('linux/runner/my_application.cc').readAsStringSync(); - - expect(service, contains('keyboardShortcuts')); - expect(service, contains('syntaxReference')); - expect(service, contains('reportIssue')); - expect(service, contains('fullScreen')); - expect(service, contains("'export' => HeaderBarAction.export")); - expect(app, contains('menu: l10n.mainMenu')); - expect(app, contains('export: label(BusyMarkCommandIds.export)')); - expect(app, contains('settings: label(BusyMarkCommandIds.settings)')); - expect( - app, - contains( - 'keyboardShortcuts: label(BusyMarkCommandIds.keyboardShortcuts)', - ), - ); - expect( - app, - contains('syntaxReference: label(BusyMarkCommandIds.syntaxReference)'), - ); - expect(app, contains('reportIssue: l10n.reportIssue')); - expect(app, contains('fullScreen: label(BusyMarkCommandIds.fullScreen)')); - expect(app, contains('aboutBusyMark: l10n.aboutBusyMark')); - expect(dialogs, contains('showBusyMarkKeyboardShortcutsDialog')); - expect(dialogs, contains('showBusyMarkSyntaxReferenceDialog')); - expect(dialogs, contains('BusyMarkAppShortcutLabels.newDocument')); - expect(dialogs, contains('BusyMarkAppShortcutLabels.save')); - expect(dialogs, contains('BusyMarkTextEditingShortcutLabels.undo')); - expect(dialogs, contains('BusyMarkTextEditingShortcutLabels.redo')); - expect(shortcuts, contains("newDocumentLabel = 'Ctrl+N'")); - expect(shortcuts, contains("saveLabel = 'Ctrl+S'")); - expect(shortcuts, contains("fullScreenLabel = 'F11'")); - expect(shortcuts, contains("backLabel = 'Alt+Left'")); - expect(shortcuts, contains("undoLabel = 'Ctrl+Z'")); - expect(shortcuts, contains("redoLabel = 'Ctrl+Shift+Z'")); - expect(workspace, contains('case HeaderBarAction.keyboardShortcuts:')); - expect(workspace, contains('case HeaderBarAction.syntaxReference:')); - expect(workspace, contains('case HeaderBarAction.reportIssue:')); - expect(workspace, contains('case HeaderBarAction.export:')); - expect(settings, contains('case HeaderBarAction.keyboardShortcuts:')); - expect(settings, contains('case HeaderBarAction.syntaxReference:')); - expect(settings, contains('case HeaderBarAction.reportIssue:')); - expect(welcome, contains('case HeaderBarAction.keyboardShortcuts:')); - expect(welcome, contains('case HeaderBarAction.syntaxReference:')); - expect(welcome, contains('case HeaderBarAction.reportIssue:')); - expect(welcome, contains('BusyMarkMainMenuButton(')); - expect(mainMenu, contains('BusyMarkHeaderPopupMenuButton')); - expect(mainMenu, isNot(contains('BusyMarkPopupSubmenu'))); - expect(mainMenu, contains('tooltip: l10n.mainMenu')); - expect(mainMenu, contains('label: l10n.reportIssue')); - expect( - mainMenu, - contains('label: command(BusyMarkCommandIds.fullScreen).label(context)'), - ); - expect( - mainMenu, - contains('label: command(BusyMarkCommandIds.export).label(context)'), - ); - expect(mainMenu, contains('enabled: canExport')); - for (final removed in [ - 'BusyMarkCommandIds.clipboardHistory', - 'BusyMarkCommandIds.localHistory', - 'BusyMarkCommandIds.findLocalHistory', - ]) { - expect(mainMenu, isNot(contains(removed)), reason: removed); - } - expect(native, contains('GtkWidget* main_menu_button;')); - expect(native, contains('GMenu* main_menu_model;')); - expect(native, contains('GSimpleActionGroup* header_action_group;')); - expect(native, contains('rebuild_main_menu_model')); - final mainMenuBuilder = RegExp( - r'static void rebuild_main_menu_model[\s\S]*?(?=\nstatic )', - ).firstMatch(native)!.group(0)!; - expect(mainMenuBuilder, isNot(contains('g_menu_item_new_submenu'))); - expect(native, contains('localized_label_or(labels, "export", "")')); - expect(native, contains('"header.keyboard-shortcuts"')); - expect(native, contains('"header.export"')); - expect(native, isNot(contains('"header.export-pdf"'))); - expect(native, isNot(contains('"header.export-html"'))); - expect(native, contains('"header.full-screen"')); - expect(native, contains('full_screen_gaction_activated_cb')); - expect(native, contains('configuration.full_screen')); - expect(native, contains('configuration.can_export_pdf')); - expect(native, contains('configuration.can_export_html')); - expect(native, isNot(contains('"header.clipboard-history"'))); - expect(native, isNot(contains('"header.local-history"'))); - expect(native, isNot(contains('"header.find-local-history"'))); - expect(native, contains('"header.syntax-reference"')); - expect(native, isNot(contains('"header.markdown-and-html"'))); - expect(native, contains('"header.report-issue"')); - expect(native, contains('static const gchar* main_menu_icon_name')); - expect(native, contains('"preferences-system-symbolic"')); - expect(native, contains('"input-keyboard-symbolic"')); - expect(native, contains('"text-x-generic-symbolic"')); - expect(native, contains('"dialog-warning-symbolic"')); - expect(native, contains('"help-about-symbolic"')); - final reportIssuePack = native.indexOf( - 'localized_label_or(labels, "reportIssue", "")', - ); - final aboutPack = native.indexOf( - 'localized_label_or(labels, "aboutBusyMark", "")', - ); - expect(reportIssuePack, isNonNegative); - expect(aboutPack, isNonNegative); - expect(reportIssuePack, lessThan(aboutPack)); - expect(native, contains('g_menu_item_set_icon(item, icon)')); - expect(native, contains('kMenuAccelAttribute')); - expect(native, contains('set_menu_item_accelerator')); - expect(native, isNot(contains('gtk_accelerator_get_label'))); - expect(native, contains('g_action_map_add_action')); - expect(native, contains('gtk_widget_insert_action_group')); - expect(native, isNot(contains('static GtkWidget* create_menu_item'))); - expect(native, isNot(contains('"busymark-menu-row"'))); - }); - - test( - 'Flutter top bars are fallback-only when native headerbar is available', - () { - final files = [ - File('lib/src/workspace/presentation/workspace_screen.dart'), - File('lib/src/workspace/presentation/welcome_screen.dart'), - File('lib/src/workspace/presentation/settings_screen.dart'), - ]; - - for (final file in files) { - final source = file.readAsStringSync(); - expect(source, contains('useNativeHeaderBar')); - expect(source, contains('usesNativeHeaderBar')); - expect(source, contains('linuxHeaderBarServiceProvider')); - expect(source, contains('headerBarActionsProvider')); - } - for (final file in files.take(2)) { - expect(file.readAsStringSync(), contains('appBar: useNativeHeaderBar')); - } - final settings = files.last.readAsStringSync(); - expect(settings, contains('if (!useNativeHeaderBar)')); - expect(settings, contains('_SettingsFallbackHeader(')); - }, - ); - - test('native modal barrier and semantic theme are centralized', () { - final configuration = File( - 'lib/src/platform/header_bar_configuration.dart', - ).readAsStringSync(); - final service = File( - 'lib/src/platform/linux_header_bar_service.dart', - ).readAsStringSync(); - final dialogs = File( - 'lib/src/app/busymark_dialogs.dart', - ).readAsStringSync(); - - expect(configuration, contains('class HeaderBarTheme')); - expect(configuration, contains('HeaderBarTheme.fromContext')); - expect(configuration, contains('BusyMarkSurfaceColors.of(context)')); - expect(configuration, contains('modalBarrierDepth: _modalBarrierDepth')); - expect(configuration, contains('setModalBarrierDepth(int depth)')); - expect(configuration, contains('setModalBarrierVisible(bool visible)')); - expect(service, contains('setModalBarrierDepth')); - expect(service, contains('setModalBarrierVisible')); - expect( - service, - contains('configurationSynchronizer.setModalBarrierDepth(depth)'), - ); - expect(dialogs, contains('showBusyMarkModalDialog')); - expect(dialogs, contains('busyMarkModalBarrierColor')); - expect(dialogs, contains('BusyMarkModalEditorSurface')); - }); - - test('native GTK theme follows brightness without replacing a valid user theme', () { - final configuration = File( - 'lib/src/platform/header_bar_configuration.dart', - ).readAsStringSync(); - final native = File('linux/runner/my_application.cc').readAsStringSync(); - final snapcraft = File('snap/snapcraft.yaml').readAsStringSync(); - - expect(configuration, contains('preferDark: theme.brightness')); - expect(configuration, contains("'preferDark': preferDark")); - expect(native, contains('static void set_gtk_theme_preference')); - expect(native, contains('gtk_settings_get_default()')); - expect( - native, - contains('"gtk-application-prefer-dark-theme", prefer_dark'), - ); - expect(native, contains('"gtk-theme-name"')); - expect(native, contains('gtk_theme_exists')); - expect(native, contains('available_gtk_theme_fallback')); - expect( - native, - contains( - 'const gchar* fallback = available_gtk_theme_fallback(prefer_dark);', - ), - ); - expect( - native, - contains('fallback != nullptr && !gtk_theme_exists(theme_name)'), - ); - expect(native, isNot(contains('g_strcmp0(theme_name, fallback) != 0'))); - expect( - native, - contains('g_object_set(settings, "gtk-theme-name", fallback, nullptr);'), - ); - expect(native, contains('"Yaru-dark"')); - expect(native, contains('"Adwaita-dark"')); - expect(native, contains('"gtk-icon-theme-name"')); - expect(native, contains('icon_theme_exists')); - expect(native, contains('available_icon_theme_fallback')); - expect( - native, - contains( - 'const gchar* icon_fallback = available_icon_theme_fallback(prefer_dark);', - ), - ); - expect( - native, - contains( - 'icon_fallback != nullptr && !icon_theme_exists(icon_theme_name)', - ), - ); - expect( - native, - isNot(contains('g_strcmp0(icon_theme_name, icon_fallback) != 0')), - ); - expect( - native, - contains( - 'g_object_set(settings, "gtk-icon-theme-name", icon_fallback, nullptr);', - ), - ); - expect(native, isNot(contains('gtk_icon_theme_set_custom_theme'))); - expect(native, isNot(contains('gtk_accent_css_provider'))); - expect(native, isNot(contains('@define-color theme_selected_bg_color'))); - expect(native, isNot(contains('@define-color accent_bg_color'))); - expect(native, isNot(contains('treeview.view:selected'))); - expect(native, isNot(contains('button.suggested-action'))); - expect( - native, - isNot(contains('GTK_STYLE_PROVIDER_PRIORITY_APPLICATION + 1')), - ); - expect(native, isNot(contains('gtk_theme_name_for_preference'))); - expect(native, isNot(contains('icon_theme_name_for_preference'))); - expect(native, contains('fl_lookup_optional_bool_arg')); - expect(native, contains('fl_lookup_optional_bool_arg(args, "preferDark"')); - expect(native, contains('set_gtk_theme_preference(prefer_dark);')); - expect(native, isNot(contains('prefer_dark_gtk_theme'))); - expect(native, isNot(contains('set_gtk_theme_preference(TRUE)'))); - expect( - native, - isNot(contains('fl_lookup_bool_arg(args, "preferDark", TRUE)')), - ); - expect(snapcraft, contains('yaru-theme-gtk')); - expect(snapcraft, contains('yaru-theme-icon')); - expect(snapcraft, contains('override-build:')); - 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( - 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(r'ln -s "../../usr/share/$resource_kind/$resource_name"'), - ); - expect( - snapcraft, - isNot(contains(r'cp -a "$CRAFT_PRIME/usr/share/themes"/Yaru*')), - ); - expect( - snapcraft, - isNot(contains(r'cp -a "$CRAFT_PRIME/usr/share/icons"/Yaru*')), - ); - expect( - native, - isNot( - matches( - RegExp( - r'static void my_application_startup[\s\S]*' - r'G_APPLICATION_CLASS\(my_application_parent_class\)->startup\(application\);[\s\S]*' - r'set_gtk_theme_preference', - ), - ), - ), - ); - expect( - native, - isNot( - matches( - RegExp( - r'static void my_application_activate\(GApplication\* application\) \{[\s\S]*' - r'set_gtk_theme_preference[\s\S]*' - r'gtk_application_window_new', - ), - ), - ), - ); - }); - test('strict Snap keeps the GTK SVG loader ABI-aligned with GNOME', () { final snapcraft = File('snap/snapcraft.yaml').readAsStringSync(); final localSnapBuilder = File( @@ -696,1043 +294,56 @@ void main() { } }); - test( - 'Snap packaging hooks consolidate resources and keep required fixes', - () { - final snapcraft = File('snap/snapcraft.yaml').readAsStringSync(); - final exclusions = File( - 'snap/gnome-46-2404-prime-exclusions.amd64', - ).readAsLinesSync(); + 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(snapcraft, contains('missing audited GNOME runtime duplicate')); - expect(snapcraft, contains('gnome-46-2404-prime-exclusions.amd64')); - expect(exclusions, contains(contains('revision 153'))); - expect( - exclusions, - contains('usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstisomp4.so'), - ); - for (final runtimeLibrary in { - 'libgdk-3.so.0', - 'libgtk-3.so.0', - 'libpango-1.0.so.0', - 'libpangocairo-1.0.so.0', - 'libpangoft2-1.0.so.0', - 'libX11.so.6', - 'libXdamage.so.1', - 'libXext.so.6', - 'libXfixes.so.3', - 'libxcb-shm.so.0', - 'libxcb.so.1', - 'libwayland-client.so.0', - 'libwayland-cursor.so.0', - 'libwayland-egl.so.1', - }) { - expect( - exclusions, - contains('usr/lib/x86_64-linux-gnu/$runtimeLibrary'), - ); - } - expect(exclusions, isNot(contains(contains('libgstlibav.so')))); - expect(exclusions, isNot(contains(contains('libgstvideoparsersbad.so')))); - expect(exclusions.where((line) => line.contains('*')), isEmpty); - expect(snapcraft, contains(r'cmp -s "$bundled_font" "$staged_font"')); - expect(snapcraft, contains('ln -s ../../usr/share/fonts/truetype/noto')); - expect( - snapcraft, - contains(r'ln -s "../../usr/share/$resource_kind/$resource_name"'), - ); - expect( - snapcraft, - contains( - r'"$CRAFT_PRIME/usr/lib/x86_64-linux-gnu/libsphinxbase.so.3.0.0"', - ), - ); - expect(snapcraft, contains('caca/libgl_plugin.so.0.0.0')); - expect( - snapcraft, - contains( - r'rm -f "$CRAFT_PRIME/usr/lib/x86_64-linux-gnu/librsvg-2.so.2"*', - ), - ); - expect( - snapcraft, - isNot(contains('libflutter_secure_storage_linux_plugin.so')), - ); - final workflow = File( - '.github/workflows/flutter-linux.yml', - ).readAsStringSync(); - expect(workflow, contains(r'graphics_lib="$SNAP/gpu-2404/usr/lib/')); - expect(workflow, contains('libwayland-egl.so.1')); - expect(workflow, contains('usr/share/doc/fonts-noto-core/copyright')); - }, - ); - - 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(); - - expect(native, contains('gtk_widget_set_tooltip_text')); - expect(native, contains('kDefaultTooltipBackground')); - expect(native, contains('kDefaultTooltipForeground')); - expect(native, contains('kDefaultTooltipBorder')); - expect(native, contains('kDefaultTooltipRadius')); - expect(native, contains('kTooltipBorderWidth')); - expect(native, contains('kGtkTooltipContainerInset')); - expect(native, contains('"tooltip,"')); - expect(native, contains('"tooltip.background {"')); - expect(native, contains('"tooltip decoration,"')); - expect(native, contains('"tooltip.csd decoration {"')); - expect(native, contains('"tooltip box,"')); - expect(native, contains('"tooltip.background box {"')); - expect(native, contains('"tooltip label,"')); - expect(native, contains('"tooltip.background label {"')); - expect(native, contains('"min-width: 0;"')); - expect(native, contains('"border-radius: %.2fpx;"')); - expect(native, contains('tooltip_label_horizontal_padding')); - expect(native, contains('tooltip_label_vertical_padding')); - expect(native, contains('tooltip_label_minimum_height')); - expect(native, contains('self->tooltip_minimum_height -')); - expect(native, contains('kGtkTooltipContainerInset * 2')); - expect(native, contains('tooltip_label_vertical_padding * 2')); - expect(native, contains('fl_lookup_map_arg(args, "tooltip")')); - expect(native, contains('"backgroundColor"')); - expect(native, contains('"foregroundColor"')); - expect(native, contains('"borderColor"')); - expect(native, contains('"borderRadius"')); - expect(native, contains('"fontSize"')); - expect(native, contains('"horizontalPadding"')); - expect(native, contains('"verticalPadding"')); - expect(native, contains('"minimumHeight"')); - expect(native, contains('"font-family: Ubuntu;"')); - expect(native, contains('"font-weight: 400;"')); - }); - - test( - 'native headerbar refreshes focus state when window activation changes', - () { - final native = File('linux/runner/my_application.cc').readAsStringSync(); - - expect(native, contains('kHeaderApplicationActiveStyleClass')); - expect(native, contains('kHeaderApplicationBackdropStyleClass')); - expect(native, contains('"notify::is-active"')); - expect( - native, - contains('G_CALLBACK(header_focus_window_is_active_notify_cb), self'), - ); - expect( - native, - contains('schedule_header_bar_focus_state_refresh(self);'), - ); - expect(native, contains('gtk_window_is_active(self->main_window)')); - expect( - native, - contains('gtk_widget_reset_style(self->titlebar_handle);'), - ); - expect(native, contains('gtk_widget_queue_draw(self->titlebar_handle);')); - expect(native, contains('G_PRIORITY_DEFAULT_IDLE')); - expect(native, contains('g_object_ref(self)')); - expect(native, contains('g_object_unref')); - }, - ); - - test( - 'native sidebar header uses the same semantic surface as the sidebar', - () { - final native = File('linux/runner/my_application.cc').readAsStringSync(); - - expect( - native, - contains( - '".busymark-sidebar-header,"' - '\n ' - '".busymark-sidebar-header:backdrop {"', - ), - ); - expect( - native, - contains( - 'css_color_or(self->sidebar_background_color, ' - 'kDefaultSidebarBackground)', - ), - ); - }, - ); - - test('native header CSS is balanced and narrowly semantic', () { - final native = File('linux/runner/my_application.cc').readAsStringSync(); - final refreshFunction = RegExp( - r'static void refresh_header_bar_css[\s\S]*?' - r'(?=static void set_header_bar_theme)', - ).firstMatch(native)?.group(0); - expect(refreshFunction, isNotNull); - - final formatArguments = RegExp( - r'g_strdup_printf\(([\s\S]*?)\);\s*' - r'g_autoptr\(GError\)', - ).firstMatch(refreshFunction!)?.group(1); - expect(formatArguments, isNotNull); - - final css = RegExp( - r'"((?:\\.|[^"\\])*)"', - ).allMatches(formatArguments!).map((match) => match.group(1)!).join(); - var braceDepth = 0; - for (final codeUnit in css.codeUnits) { - if (codeUnit == 0x7B) { - braceDepth++; - } else if (codeUnit == 0x7D) { - braceDepth--; - expect( - braceDepth, - isNonNegative, - reason: 'premature CSS closing brace', - ); - } - } - expect(braceDepth, 0, reason: 'unbalanced structural CSS blocks'); - expect(css, contains('.busymark-modal-scrim')); - expect(css, contains('.busymark-header-control')); - expect(css, contains('background-color: alpha(currentColor, 0.07)')); - expect(css, contains('background-color: alpha(currentColor, 0.16)')); - expect(css, contains('background-color: alpha(currentColor, 0.10)')); - expect(css, isNot(contains('popover.background.'))); - expect(css, isNot(contains('modelbutton:hover:not(:disabled)'))); - expect(css, isNot(contains('row:hover:not(:disabled)'))); - expect(css, isNot(contains('.%s:hover:not(:disabled)'))); - expect(css, isNot(contains('.%s:focus:not(:disabled)'))); - expect(css, contains('border-color: transparent')); - expect(css, isNot(contains('outline-width: 0'))); - expect(css, isNot(contains('font-size: 0.92em'))); - expect(css, isNot(contains('padding: 2px 6px'))); - expect(css, isNot(contains('modelbutton.%s.%s:not(:disabled)'))); - expect(css, isNot(contains('box-shadow: 0 1px 3px'))); - expect(css, contains('tooltip.background')); - expect(css, contains('tooltip decoration')); - for (final interactionSelector in ['@define-color']) { - expect(css, isNot(contains(interactionSelector))); - } - }); - - test('native headerbar uses split sidebar and main content surfaces', () { - final configuration = File( - 'lib/src/platform/header_bar_configuration.dart', - ).readAsStringSync(); - final native = File('linux/runner/my_application.cc').readAsStringSync(); - final workspace = File( - 'lib/src/workspace/presentation/workspace_screen.dart', - ).readAsStringSync(); - - expect(configuration, contains('backgroundColor: colors.view')); - expect(configuration, contains('sidebarBackgroundColor: colors.sidebar')); - expect(configuration, contains('foregroundColor: colors.foreground')); - expect(configuration, isNot(contains('popoverBackgroundColor:'))); - expect(configuration, isNot(contains('menuHoverColor:'))); - expect(configuration, isNot(contains('popoverShadowColor:'))); - expect(configuration, isNot(contains('borderColor: colors.'))); - expect(configuration, isNot(contains('floatingBorderColor'))); - expect(native, contains('kDefaultHeaderbarBackground[] = "#272727"')); - expect(native, contains('kDefaultSidebarBackground[] = "#393939"')); - expect(native, contains('kDefaultSidebarBorder[] = "rgba(16,16,16,0.35)"')); - expect( - native, - contains('fl_lookup_string_arg(args, "sidebarBorderColor")'), - ); - expect(native, isNot(contains('"popoverBackgroundColor"'))); - expect(native, isNot(contains('"menuHoverColor"'))); - expect(native, isNot(contains('"popoverShadowColor"'))); - expect(native, isNot(contains('"floatingBorderColor"'))); - expect( - native, - contains( - 'css_color_or(self->sidebar_border_color, kDefaultSidebarBorder)', - ), - ); - expect(native, isNot(contains('kNativePopoverStyleClass'))); - expect(native, isNot(contains('kHeaderMenuDepthStyleClass'))); - expect(native, isNot(contains('style_header_menu_popover'))); - expect(native, isNot(contains('style_native_popover'))); - expect(native, isNot(contains('gtk_popover_'))); - expect( - native, - contains( - 'gtk_menu_button_set_use_popover(GTK_MENU_BUTTON(button), FALSE)', - ), - ); - expect(native, contains('gtk_menu_button_get_popup')); - expect(native, contains('GTK_IS_MENU(menu)')); - expect(native, contains('gtk_menu_shell_deactivate')); - expect(native, contains('".busymark-sidebar-header:backdrop {"')); - expect(native, contains('background-color: %s;')); - expect(native, contains('".busymark-sidebar-header label {"')); - expect(native, contains('"font-weight: 800;"')); - expect(native, contains('".busymark-sidebar-header label:backdrop {"')); - expect(native, contains('kHeaderBackdropForegroundOpacity = 0.50')); - expect( - native, - contains( - 'self->sidebar_header_box =\n' - ' gtk_box_new(GTK_ORIENTATION_HORIZONTAL, kHeaderButtonSpacing)', - ), - ); - expect(native, contains('GtkWidget* sidebar_title_box =')); - expect( - native, - contains( - 'gtk_box_pack_start(GTK_BOX(self->sidebar_header_box), ' - 'sidebar_title_box,', - ), - ); - expect( - native, - isNot(contains('self->sidebar_header_box = gtk_overlay_new()')), - ); - expect( - native, - contains( - 'gtk_widget_get_style_context(self->titlebar_handle),\n' - ' "busymark-titlebar"', - ), - ); - final headerbarBlock = RegExp( - r'"headerbar\.busymark-headerbar,"(.*?)"\}', - dotAll: true, - ).firstMatch(native)!.group(1)!; - expect(headerbarBlock, contains('"background-color: %s;"')); - expect(headerbarBlock, contains('"background-image: none;"')); - expect(headerbarBlock, contains('"border: none;"')); - expect(headerbarBlock, contains('"box-shadow: none;"')); - expect(headerbarBlock, isNot(contains('border-radius'))); - expect(headerbarBlock, isNot(contains('"padding-left: 0;"'))); - expect(headerbarBlock, isNot(contains('"padding-right: 0;"'))); - expect(native, contains('".busymark-sidebar-header:dir(ltr) {"')); - expect(native, contains('"border-right: 1px solid %s;"')); - expect(native, contains('".busymark-sidebar-header:dir(rtl) {"')); - expect(native, contains('"border-left: 1px solid %s;"')); - expect(native, contains('".busymark-modal-scrim {"')); - expect(native, contains('create_busymark_titlebar_overlay')); - expect(native, contains('gtk_overlay_set_overlay_pass_through')); - expect(native, isNot(contains('busymark-titlebar.busymark-modal-barrier'))); - expect(native, isNot(contains('modal_sidebar_border_css_color'))); - expect(native, isNot(contains('composite_rgba'))); - expect(native, isNot(contains('"border-right-color: %s;"'))); - expect(native, isNot(contains('"border-left-color: %s;"'))); - expect(native, contains('G_CALLBACK(stop_modal_scrim_event)')); - expect( - native, - isNot( - contains('gtk_widget_set_sensitive(self->titlebar_handle, !visible)'), - ), - ); - expect(workspace, isNot(contains('Border(right:'))); - }); - - test('native headerbar preserves the themed outer window inset', () { - final native = File('linux/runner/my_application.cc').readAsStringSync(); - final ltrPaddingBlock = RegExp( - r'"headerbar\.busymark-headerbar:dir\(ltr\) \{"(.*?)"\}', - dotAll: true, - ).firstMatch(native)?.group(1); - final rtlPaddingBlock = RegExp( - r'"headerbar\.busymark-headerbar:dir\(rtl\) \{"(.*?)"\}', - dotAll: true, - ).firstMatch(native)?.group(1); - - expect(ltrPaddingBlock, isNotNull); - expect(ltrPaddingBlock, contains('"padding-left: 0;"')); - expect(ltrPaddingBlock, isNot(contains('"padding-right: 0;"'))); - expect(rtlPaddingBlock, isNotNull); - expect(rtlPaddingBlock, contains('"padding-right: 0;"')); - expect(rtlPaddingBlock, isNot(contains('"padding-left: 0;"'))); - }); - - test('native headerbar mirrors sidebar surface for text direction', () { - final configuration = File( - 'lib/src/platform/header_bar_configuration.dart', - ).readAsStringSync(); - final app = File('lib/src/app/busymark_app.dart').readAsStringSync(); - final settings = File( - 'lib/src/workspace/presentation/settings_screen.dart', - ).readAsStringSync(); - final welcome = File( - 'lib/src/workspace/presentation/welcome_screen.dart', - ).readAsStringSync(); - final workspace = File( - 'lib/src/workspace/presentation/workspace_screen.dart', - ).readAsStringSync(); - final native = File('linux/runner/my_application.cc').readAsStringSync(); - - expect(configuration, contains('final TextDirection textDirection;')); - expect( - configuration, - contains( - "'textDirection': textDirection == TextDirection.rtl ? 'rtl' : 'ltr'", - ), - ); - expect(app, contains('Directionality.maybeOf(context)')); - expect(app, contains('textDirection: textDirection')); - for (final screen in [settings, welcome, workspace]) { - expect(screen, contains('HeaderBarConfigurationDefaults.of(context)')); - expect(screen, contains('HeaderBarConfigurationPublisher(')); - } - expect(native, contains('gboolean text_direction_rtl;')); - expect(native, contains('static void update_titlebar_direction')); - expect(native, contains('set_widget_direction(self->main_menu')); - expect(native, contains('set_widget_direction(self->view_mode_menu')); - expect(native, contains('set_widget_direction(self->search_button')); - expect(native, contains('kLtrIsolateStart')); - expect(native, contains('kBidiIsolateEnd')); - expect(native, contains('gtk_box_reorder_child')); - expect(native, contains('static void set_text_direction')); - expect( - native, - contains('set_text_direction(self, configuration.text_direction)'), - ); - expect(native, contains('g_strcmp0(value, "rtl") == 0')); - }); - - test('native headerbar reapplies owned insets after direction changes', () { - final native = File('linux/runner/my_application.cc').readAsStringSync(); - final marginHelper = RegExp( - r'static void set_widget_horizontal_margins[\s\S]*?^}', - multiLine: true, - ).firstMatch(native)?.group(0); - final directionUpdate = RegExp( - r'static void update_titlebar_direction[\s\S]*?(?=^static void refresh_header_bar_css)', - multiLine: true, - ).firstMatch(native)?.group(0); - - expect(marginHelper, isNotNull); - expect(marginHelper, contains('gtk_widget_set_margin_start')); - expect(marginHelper, contains('gtk_widget_set_margin_end')); - expect(native, isNot(contains('kHeaderWindowControlsBalanceWidth'))); - expect(native, isNot(contains('update_title_stack_alignment'))); - expect(directionUpdate, isNotNull); - expect( - directionUpdate, - matches( - RegExp( - r'set_widget_horizontal_margins\(\s*self->header_start_box,\s*' - r'kHeaderSidebarInset,\s*0\);', - ), - ), - ); - - for (final widget in ['header_start_box']) { - final directionOffset = directionUpdate!.indexOf( - 'set_widget_direction(self->$widget, direction)', - ); - final marginOffset = directionUpdate.indexOf( - 'set_widget_horizontal_margins(self->$widget,', - ); - expect(directionOffset, isNonNegative, reason: widget); - expect(marginOffset, greaterThan(directionOffset), reason: widget); - } - - expect( - native, - isNot(contains('gtk_widget_set_margin_start(self->header_start_box')), - ); - }); - - test( - 'sidebar visibility updates split geometry without corner emulation', - () { - final native = File('linux/runner/my_application.cc').readAsStringSync(); - - expect(native, contains('sidebar_background')); - expect(native, contains('update_sidebar_header_geometry(self);')); - expect(native, contains('refresh_header_bar_css(self);')); - expect(native, isNot(contains('headerbar_left_radius'))); - expect(native, isNot(contains('headerbar_right_radius'))); - expect(native, isNot(contains('sidebar_left_radius'))); - expect(native, isNot(contains('sidebar_right_radius'))); - expect(native, isNot(contains('"border-top-left-radius:'))); - expect(native, isNot(contains('"border-top-right-radius:'))); - }, - ); - - test( - 'Handy owns window shape while Yaru receives a scoped frame adapter', - () { - final native = File('linux/runner/my_application.cc').readAsStringSync(); - final linuxCmake = File('linux/CMakeLists.txt').readAsStringSync(); - final runnerCmake = File( - 'linux/runner/CMakeLists.txt', - ).readAsStringSync(); - final snapcraft = File('snap/snapcraft.yaml').readAsStringSync(); - final readme = File('README.md').readAsStringSync(); - final compatibilityCss = RegExp( - r'constexpr char kLegacyYaruWindowShadowCompatibilityCss\[\][\s\S]*?' - r'constexpr char kLtrIsolateStart', - ).firstMatch(native)!.group(0)!; - - expect(native, contains('#include ')); - expect(native, contains('hdy_application_window_new()')); - expect(native, contains('hdy_window_handle_new()')); - expect(native, contains('GtkWidget* titlebar_handle;')); - expect(native, contains('GtkWidget* titlebar_overlay;')); - expect(native, contains('GtkWidget* modal_scrim;')); - expect( - native, - contains('gtk_widget_set_vexpand(self->titlebar_handle, FALSE)'), - ); - expect( - native, - contains('gtk_widget_set_vexpand(self->titlebar_overlay, FALSE)'), - ); - expect(native, contains('G_CALLBACK(stop_modal_scrim_event)')); - expect( - native, - isNot( - contains('gtk_widget_set_sensitive(self->titlebar_handle, !visible)'), - ), - ); - expect(native, contains('uses_legacy_yaru_window_shadow()')); - expect(native, contains('g_strcmp0(normalized_theme, "yaru")')); - expect(native, contains('g_str_has_prefix(normalized_theme, "yaru-")')); - expect(native, contains('strstr(normalized_theme, "highcontrast")')); - expect(native, contains('"notify::gtk-theme-name"')); - expect(native, contains('G_CALLBACK(gtk_theme_name_changed_cb)')); - expect(native, contains('g_signal_connect_object(')); - expect(native, isNot(contains('gtk_window_set_titlebar('))); - expect(native, isNot(contains('gtk_application_window_new('))); - expect( - compatibilityCss, - contains('box-shadow: 0 3px 9px 1px rgba(0,0,0,0.5)'), - ); - expect( - compatibilityCss, - contains( - '0 3px 9px 1px transparent,' - '"\n "0 2px 6px 2px rgba(0,0,0,0.2)', - ), - ); - expect(compatibilityCss, contains(':not(.solid-csd)')); - expect(compatibilityCss, contains(':not(.maximized)')); - expect(compatibilityCss, contains('not(.fullscreen)')); - expect( - RegExp(r'not\(\.maximized\)').allMatches(compatibilityCss), - hasLength(7), - ); - expect( - RegExp(r'not\(\.fullscreen\)').allMatches(compatibilityCss), - hasLength(7), - ); - expect(compatibilityCss, contains('0 0 0 20px transparent')); - expect(compatibilityCss, isNot(contains('0 0 0 1px'))); - expect(compatibilityCss, isNot(contains('border-radius'))); - expect(compatibilityCss, isNot(contains('border-color'))); - expect(native, isNot(contains('"window#busymark-window decoration,"'))); - expect( - linuxCmake, - contains( - 'pkg_check_modules(HANDY REQUIRED IMPORTED_TARGET libhandy-1)', - ), - ); - expect( - runnerCmake, - contains( - r'target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::HANDY)', - ), - ); - expect(snapcraft, contains('- libhandy-1-dev')); - expect(_snapStagePackages(snapcraft), isNot(contains('libhandy-1-0'))); - expect(readme, contains('sudo apt-get install')); - expect(readme, contains('libhandy-1-dev')); - expect(readme, contains('xz-utils')); - expect(native, isNot(contains('kHeaderWindowRadius'))); - expect(native, isNot(contains('create_rounded_window_region'))); - expect(native, isNot(contains('gdk_window_shape_combine_region'))); - expect(native, isNot(contains('rounded_window_configure_event_cb'))); - expect(native, isNot(contains('configure_transparent_window_backing'))); - expect(native, isNot(contains('gtk_widget_set_app_paintable'))); - expect(native, isNot(contains('CAIRO_OPERATOR_CLEAR'))); - expect(native, isNot(contains('#include '))); - expect( - RegExp(r'"border-radius:').allMatches(native), - hasLength(2), - reason: 'Only the tooltip surface and native window clip own a radius', - ); - }, - ); - - test('native header controls preserve GTK geometry with neutral states', () { - final native = File('linux/runner/my_application.cc').readAsStringSync(); - final contentMenuStart = native.indexOf( - 'constexpr char kNativeMenuActionNamespace', - ); - expect(contentMenuStart, isNonNegative); - final headerOnly = native.substring(0, contentMenuStart); - - expect(native, contains('kHeaderButtonHeight = 32')); - expect(native, contains('kHeaderButtonSpacing = 8')); - expect(native, contains('self->search_entry = gtk_search_entry_new()')); - expect( - native, - contains('gtk_widget_set_hexpand(self->search_entry, TRUE)'), - ); - expect( - native, - isNot(contains('gtk_widget_set_size_request(self->search_entry')), - ); - expect( - native, - isNot( - contains('".busymark-titlebar entry.busymark-search-entry:focus {"'), - ), - ); - expect(native, isNot(contains('button.busymark-header-button:hover'))); - expect(native, isNot(contains('button.busymark-header-button:checked'))); - expect(native, isNot(contains('button.busymark-header-button:focus'))); - expect(headerOnly, isNot(contains('GTK_RELIEF_NONE'))); - expect(headerOnly, isNot(contains('GTK_STYLE_CLASS_FLAT'))); - expect(native, contains('"busymark-header-control"')); - expect(native, isNot(contains('"busymark-header-icon-button"'))); - expect(native, contains('alpha(currentColor, 0.07)')); - expect(native, contains('alpha(currentColor, 0.16)')); - expect(native, contains('alpha(currentColor, 0.10)')); - expect(native, contains('alpha(currentColor, 0.13)')); - expect(native, contains('alpha(currentColor, 0.19)')); - expect(native, isNot(contains('"outline-width: 2px;"'))); - expect(native, isNot(contains('"outline-width: 0;"'))); - expect(native, contains('"searchSubmitted"')); - expect( - native, - contains('invoke_header_bar_string_action(self, "searchSubmitted"'), - ); - expect(native, contains('gtk_widget_set_focus_on_click(button, FALSE)')); - expect(native, contains('"stop-search"')); - expect(native, contains('"searchFocusChanged"')); - expect(native, contains('"searchCleared"')); - expect(native, contains('"searchEscapePressed"')); - expect(native, contains('sidebar_shortcut_action_for_key')); - expect(native, contains('GDK_KEY_KP_1')); - expect(native, contains('GDK_KEY_KP_4')); - expect(native, contains('GDK_KEY_KP_5')); - expect(native, contains('GDK_KEY_KP_6')); - expect(native, contains('"sidebarFiles"')); - expect(native, contains('"sidebarGit"')); - expect(native, contains('"sidebarLocalHistory"')); - expect(native, contains('"sidebarClipboardHistory"')); - expect(native, contains('modifiers != GDK_CONTROL_MASK')); - expect( - native, - contains('g_signal_connect(self->search_entry, "key-press-event",'), - ); - expect(native, contains('strcmp(method, "focusSearch") == 0')); - expect(native, contains('enum class SearchQueryUpdateDisposition')); - expect(native, contains('resolve_search_query_update(false, true)')); - expect( - native, - contains('SearchQueryUpdateDisposition::kPreserveNativeText'), - ); - expect( - native, - contains( - 'A newer focused native edit must survive a delayed Dart snapshot', - ), - ); - expect(native, contains('native_entry_has_authority')); - expect(native, isNot(contains('echoes_last_native_query'))); - expect( - RegExp(r'"key-press-event"').allMatches(native), - hasLength(1), - reason: 'Only the native-focus shortcut bridge handles raw keys.', - ); - }); - - test( - 'native window controls retain GTK geometry with synchronized focus color', - () { - final native = File('linux/runner/my_application.cc').readAsStringSync(); - - expect(native, contains('headerbar button.titlebutton:not(:disabled)')); - expect(native, contains('headerbar button.titlebutton:disabled')); - expect(native, isNot(contains('const gchar* title_button ='))); - expect( - native, - isNot(contains('css_color_or(self->title_button_color, control)')), - ); - expect(native, isNot(contains('button.titlebutton {background'))); - expect(native, isNot(contains('-gtk-gradient'))); - }, - ); - - test('native sidebar header contains branding but no action buttons', () { - final native = File('linux/runner/my_application.cc').readAsStringSync(); - - expect(native, contains('self->sidebar_title_label = gtk_label_new(')); - expect(native, isNot(contains('sidebar_search_button'))); - expect(native, isNot(contains('sidebar_menu_button'))); - expect(native, isNot(contains('busymark-sidebar-action-button'))); - }); - - test( - 'native document controls stay hidden on welcome and settings screens', - () { - final native = File('linux/runner/my_application.cc').readAsStringSync(); - - expect(native, contains('self->document_controls_visible = visible')); - expect(native, contains('visible && !self->search_active')); - expect(native, contains('self->document_controls_visible && !active')); - expect(native, contains('set_document_controls_visible(self, FALSE)')); - }, - ); - - test('search and main menu stay in the main header', () { - final native = File('linux/runner/my_application.cc').readAsStringSync(); - - expect(native, contains('GtkWidget* search_button;')); - expect(native, contains('GtkWidget* main_menu_button;')); - expect(native, isNot(contains('adaptive_search_button'))); - expect(native, isNot(contains('adaptive_menu_button'))); - expect( - native, - contains('set_toggle_button_active(self, self->search_button, active)'), - ); - expect( - native, - contains( - 'gtk_box_pack_start(GTK_BOX(end_box), self->search_button, FALSE, FALSE, 0)', - ), - ); - expect( - native, - contains( - 'gtk_box_pack_start(GTK_BOX(end_box), self->main_menu_button, FALSE, FALSE', - ), - ); - expect( - native, - contains('G_MENU_MODEL(self->main_menu_model), "open-menu-symbolic"'), - ); - }); - - test( - 'native fallback surfaces are neutral grays, not blue-tinted colors', - () { - final native = File('linux/runner/my_application.cc').readAsStringSync(); - - int fallbackValue(String constantName) { - final match = RegExp( - 'constexpr char $constantName\\[\\] = "#([0-9A-Fa-f]{6})";', - ).firstMatch(native); - expect(match, isNotNull, reason: constantName); - return int.parse(match!.group(1)!, radix: 16); - } - - void expectNeutral(String constantName, int value) { - final red = (value >> 16) & 0xFF; - final green = (value >> 8) & 0xFF; - final blue = value & 0xFF; - expect(red, green, reason: constantName); - expect(green, blue, reason: constantName); - } - - final header = fallbackValue('kDefaultHeaderbarBackground'); - final sidebar = fallbackValue('kDefaultSidebarBackground'); - expectNeutral('kDefaultHeaderbarBackground', header); - expectNeutral('kDefaultSidebarBackground', sidebar); - expect(sidebar, greaterThan(header)); - expect(native, isNot(contains('"#1D1D20"'))); - expect(native, isNot(contains('"#2E2E32"'))); - }, - ); - - test('native document commands are explicit workspace actions', () { - final service = File( - 'lib/src/platform/linux_header_bar_service.dart', - ).readAsStringSync(); - final workspace = File( - 'lib/src/workspace/presentation/workspace_screen.dart', - ).readAsStringSync(); - final native = File('linux/runner/my_application.cc').readAsStringSync(); - - expect(service, contains('save,')); - expect(service, contains("'save' => HeaderBarAction.save")); - expect(service, isNot(contains('problems,'))); - expect(service, isNot(contains("'problems' => HeaderBarAction.problems"))); - expect(workspace, contains('case HeaderBarAction.save:')); - expect(workspace, isNot(contains('case HeaderBarAction.problems:'))); - expect(workspace, isNot(contains('saveActiveWithOverwriteConfirmation'))); - expect(workspace, contains('_showProblemsDialog(context, ref)')); - expect(workspace, contains('_validateActiveAndShowProblems')); - expect(workspace, contains('HeaderBarConfigurationPublisher(')); - expect(workspace, isNot(contains('headerBar.setCanSave'))); - expect(workspace, isNot(contains('accented: state.isDirty'))); - expect(native, isNot(contains('gboolean can_save;'))); - expect(native, isNot(contains('gboolean can_undo;'))); - expect(native, isNot(contains('gboolean can_redo;'))); - expect(native, isNot(contains('strcmp(method, "setCanSave")'))); - expect(native, isNot(contains('strcmp(method, "setCanUndo")'))); - expect(native, isNot(contains('strcmp(method, "setCanRedo")'))); - expect( - native, - isNot(contains('create_header_icon_button("emblem-ok-symbolic")')), - ); - expect(native, isNot(contains('busymark-save-button'))); - expect(native, isNot(contains('busymark-save-dirty'))); - expect( - native, - isNot(contains('set_save_dirty(self, fl_method_bool_arg(args))')), - ); - expect( - native, - contains('create_header_icon_button("tools-check-spelling-symbolic")'), - ); - expect( - native, - isNot(contains('create_header_icon_button("dialog-warning-symbolic")')), - ); - expect( - native, - isNot(contains('create_header_icon_button("view-refresh-symbolic")')), - ); - expect( - native, - isNot( - contains( - 'connect_header_action(self, self->problems_button, "problems")', - ), - ), - ); - expect(native, isNot(contains('fl_lookup_string_arg(args, "problems")'))); - }); - - test('native headerbar starts with sidebar toggle before back', () { - final native = File('linux/runner/my_application.cc').readAsStringSync(); - - expect( - native, - matches( - RegExp( - r'gtk_box_pack_start\(GTK_BOX\(self->header_start_box\), self->sidebar_toggle_button[\s\S]*' - r'gtk_box_pack_start\(GTK_BOX\(self->header_start_box\), self->back_button', - ), - ), - ); - }); - - test('native document view mode dropdown uses checked rows', () { - final service = File( - 'lib/src/platform/linux_header_bar_service.dart', - ).readAsStringSync(); - final configuration = File( - 'lib/src/platform/header_bar_configuration.dart', - ).readAsStringSync(); - final app = File('lib/src/app/busymark_app.dart').readAsStringSync(); - final workspace = File( - 'lib/src/workspace/presentation/workspace_screen.dart', - ).readAsStringSync(); - final native = File('linux/runner/my_application.cc').readAsStringSync(); - - expect( - configuration, - contains('enum AppViewMode { editor, source, preview, split }'), - ); - expect(service, contains('viewModeEditor')); - expect(service, contains('viewModeSource')); - expect(service, contains('viewModePreview')); - expect(service, contains('viewModeSplit')); - expect(app, contains('editor: label(BusyMarkCommandIds.viewEditor)')); - expect(app, contains('source: label(BusyMarkCommandIds.viewSource)')); - expect(app, contains('preview: label(BusyMarkCommandIds.viewReading)')); - expect(app, contains('split: label(BusyMarkCommandIds.viewSplit)')); - expect( - app, - contains('editorShortcut: shortcut(BusyMarkCommandIds.viewEditor)'), - ); - expect(app, contains('editorGtkAccelerator:')); - expect(app, contains('accelerator(BusyMarkCommandIds.viewEditor)')); - expect( - app, - contains('sourceShortcut: shortcut(BusyMarkCommandIds.viewSource)'), - ); - expect( - app, - contains('previewShortcut: shortcut(BusyMarkCommandIds.viewReading)'), - ); - expect( - app, - contains('splitShortcut: shortcut(BusyMarkCommandIds.viewSplit)'), - ); - expect( - app, - contains('sidebarShortcut: shortcut(BusyMarkCommandIds.toggleSidebar)'), - ); - expect( - app, - contains('searchShortcut: shortcut(BusyMarkCommandIds.search)'), - ); - expect(workspace, contains('case HeaderBarAction.viewModeEditor:')); - expect(workspace, contains('case HeaderBarAction.viewModeSource:')); - expect(workspace, contains('case HeaderBarAction.viewModePreview:')); - expect(workspace, contains('case HeaderBarAction.viewModeSplit:')); - expect(workspace, contains('setDocumentViewMode(')); - expect(workspace, contains('HeaderBarConfigurationPublisher(')); - expect( - workspace, - contains('viewMode: _headerBarViewMode(settings.documentViewMode)'), - ); - expect(workspace, isNot(contains('headerBar.setViewMode'))); - expect(service, contains("('setViewMode', configuration.viewMode.name)")); - expect(native, contains('GMenu* view_mode_menu_model;')); - expect(native, contains('GSimpleAction* view_mode_action;')); - expect(native, contains('g_simple_action_new_stateful(')); - expect(native, contains('"view-mode", G_VARIANT_TYPE_STRING')); - expect(native, contains('g_menu_item_set_action_and_target(')); - expect(native, contains('"header.view-mode"')); - expect(native, contains('g_simple_action_set_state(')); - expect(native, contains('append_view_mode_menu_item(')); - for (final mode in ['editor', 'source', 'preview', 'split']) { - expect( - native, - contains('localized_label_or(labels, "$mode", "")'), - reason: mode, - ); - } - expect(native, contains('view_mode_icon_name(mode)')); - expect(native, contains('view_mode_icon_name("split")')); - expect(native, isNot(contains('modelbutton:hover:not(:disabled)'))); - expect(native, isNot(contains('modelbutton.%s:hover:not(:disabled)'))); - expect(native, isNot(contains('kNativeMenuItemStyleClass'))); - expect(native, isNot(contains('style_native_menu_item(widget)'))); - expect(native, isNot(contains('kNativeMenuItemHoverStyleClass'))); - expect(native, isNot(contains('native_menu_item_enter_cb'))); - expect(native, isNot(contains('native_menu_item_motion_cb'))); - expect(native, isNot(contains('native_menu_item_leave_cb'))); - expect( - native, - isNot(contains('native_menu_popover_hidden_reset_hover_cb')), - ); - expect(native, isNot(contains('modelbutton:focus'))); - expect(native, isNot(contains('modelbutton:active'))); - expect(native, isNot(contains('outline-width: 0;'))); - expect(native, contains('static GtkWidget* create_model_menu_button')); - expect( - native, - contains( - 'gtk_menu_button_set_use_popover(GTK_MENU_BUTTON(button), FALSE)', - ), - ); - expect(native, contains('gtk_menu_button_set_menu_model')); - expect(native, contains('gtk_menu_button_get_popup')); - expect(native, contains('GTK_IS_MENU(menu)')); - expect( - native, - contains('gtk_image_set_from_icon_name(GTK_IMAGE(self->view_mode_icon)'), - ); - expect( - native, - contains('gtk_button_get_image(GTK_BUTTON(self->view_mode_button))'), - ); - expect(native, isNot(contains('create_view_mode_item'))); - expect(native, isNot(contains('set_menu_item_checked'))); - expect(native, isNot(contains('self->view_mode_label'))); - expect( - native, - contains( - 'set_widget_tooltip_with_shortcut(self->sidebar_toggle_button, sidebar', - ), - ); - expect(native, contains('view_mode_shortcut_label(self, args)')); - expect( - native, - contains( - 'set_widget_tooltip_with_shortcut(\n' - ' self->view_mode_button', - ), - ); - expect(native, contains('rebuild_view_mode_menu_model(self, args)')); expect( - native, - contains('set_widget_visible(self->view_mode_box, effective_visible)'), + RegExp(r'^ flutter: \d+\.\d+\.\d+$', multiLine: true).hasMatch(pubspec), + isTrue, ); - expect(native, isNot(contains('viewModeDay'))); - expect(native, isNot(contains('viewModeWeek'))); - expect(native, isNot(contains('viewModeMonth'))); - expect(native, isNot(contains('viewModeAgenda'))); + 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('native GTK menus use standard icon and accelerator attributes', () { - final native = File('linux/runner/my_application.cc').readAsStringSync(); + 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(native, contains('kMenuAccelAttribute')); - expect(native, contains('constexpr char kMenuAccelAttribute[] = "accel"')); - expect(native, contains('gtk_accelerator_from_shortcut_label')); - expect(native, contains('set_menu_item_accelerator')); - expect(native, contains('gtk_accelerator_parse(accelerator')); + expect(snapcraft, contains('BUSYMARK_FLUTTER_VERSION: "$projectVersion"')); expect( - native, - contains( - 'g_menu_item_set_attribute(item, kMenuAccelAttribute, "s", accelerator)', - ), + snapcraft, + contains(r'git clone --depth 1 --branch "$BUSYMARK_FLUTTER_VERSION"'), ); - expect(native, contains('g_menu_item_set_icon(item, icon)')); - expect(native, contains('gtk_menu_button_set_menu_model')); - expect(native, isNot(contains('GTK_IS_MODEL_BUTTON'))); - expect(native, isNot(contains('gtk_accelerator_get_label'))); - expect(native, isNot(contains('gtk_label_new(shortcut)'))); - expect(native, isNot(contains('decorate_model_menu_accelerators'))); - expect(native, isNot(contains('busymark-shortcut-widget'))); - expect(native, isNot(contains('busymark-menu-row'))); expect( - native, - contains( - 'set_widget_tooltip_with_shortcut(\n' - ' self->view_mode_button', - ), + 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('Flutter content menus use native GTK popup menu semantics', () { @@ -1750,7 +361,7 @@ void main() { ); final nativeMenuEnd = native.indexOf( 'static void my_application_activate', - nativeMenuStart, + native.indexOf('struct NativeMenuSession', nativeMenuStart), ); expect(nativeMenuStart, isNonNegative); expect(nativeMenuEnd, greaterThan(nativeMenuStart)); @@ -1872,37 +483,6 @@ void main() { } }); - test('content menu consolidation leaves GTK headerbar menus intact', () { - final native = File('linux/runner/my_application.cc').readAsStringSync(); - final nativeMenuStart = native.indexOf( - 'constexpr char kNativeMenuActionNamespace', - ); - final nativeMenuEnd = native.indexOf( - 'static void my_application_activate', - nativeMenuStart, - ); - expect(nativeMenuStart, isNonNegative); - expect(nativeMenuEnd, greaterThan(nativeMenuStart)); - - final headerbar = native.substring(0, nativeMenuStart); - final contentMenu = native.substring(nativeMenuStart, nativeMenuEnd); - expect(headerbar, contains('static GtkWidget* create_model_menu_button')); - expect( - headerbar, - contains( - 'gtk_menu_button_set_use_popover(GTK_MENU_BUTTON(button), FALSE)', - ), - ); - expect(headerbar, contains('gtk_menu_button_set_menu_model(')); - expect(headerbar, contains('gtk_menu_button_get_popup(')); - expect(headerbar, isNot(contains('GTK_IS_MODEL_BUTTON'))); - expect(contentMenu, contains('gtk_menu_new_from_model(')); - expect(contentMenu, contains('gtk_menu_popup_at_rect(')); - expect(contentMenu, isNot(contains('gtk_menu_button_'))); - expect(contentMenu, isNot(contains('GTK_IS_MODEL_BUTTON'))); - expect(contentMenu, isNot(contains('create_model_menu_button('))); - }); - test('native content menus clean up direct GTK popup sessions', () { final native = File('linux/runner/my_application.cc').readAsStringSync(); @@ -1948,124 +528,210 @@ void main() { expect(respondIndex, greaterThan(detachIndex)); expect(freeIndex, greaterThan(respondIndex)); }); + test( + 'Snap packaging hooks consolidate resources and keep required fixes', + () { + final snapcraft = File('snap/snapcraft.yaml').readAsStringSync(); + final exclusions = File( + 'snap/gnome-46-2404-prime-exclusions.amd64', + ).readAsLinesSync(); - test('welcome page has a sidebar but no document controls', () { - final welcome = File( - 'lib/src/workspace/presentation/welcome_screen.dart', - ).readAsStringSync(); - final workspace = File( - 'lib/src/workspace/presentation/workspace_screen.dart', - ).readAsStringSync(); + expect(snapcraft, contains('missing audited GNOME runtime duplicate')); + expect(snapcraft, contains('gnome-46-2404-prime-exclusions.amd64')); + expect(exclusions, contains(contains('revision 153'))); + expect( + exclusions, + contains('usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstisomp4.so'), + ); + for (final runtimeLibrary in { + 'libgdk-3.so.0', + 'libgtk-3.so.0', + 'libpango-1.0.so.0', + 'libpangocairo-1.0.so.0', + 'libpangoft2-1.0.so.0', + 'libX11.so.6', + 'libXdamage.so.1', + 'libXext.so.6', + 'libXfixes.so.3', + 'libxcb-shm.so.0', + 'libxcb.so.1', + 'libwayland-client.so.0', + 'libwayland-cursor.so.0', + 'libwayland-egl.so.1', + }) { + expect( + exclusions, + contains('usr/lib/x86_64-linux-gnu/$runtimeLibrary'), + ); + } + expect(exclusions, isNot(contains(contains('libgstlibav.so')))); + expect(exclusions, isNot(contains(contains('libgstvideoparsersbad.so')))); + expect(exclusions.where((line) => line.contains('*')), isEmpty); + expect(snapcraft, contains(r'cmp -s "$bundled_font" "$staged_font"')); + expect(snapcraft, contains('ln -s ../../usr/share/fonts/truetype/noto')); + expect( + snapcraft, + contains(r'ln -s "../../usr/share/$resource_kind/$resource_name"'), + ); + expect( + snapcraft, + contains( + r'"$CRAFT_PRIME/usr/lib/x86_64-linux-gnu/libsphinxbase.so.3.0.0"', + ), + ); + expect(snapcraft, contains('caca/libgl_plugin.so.0.0.0')); + expect( + snapcraft, + contains( + r'rm -f "$CRAFT_PRIME/usr/lib/x86_64-linux-gnu/librsvg-2.so.2"*', + ), + ); + expect( + snapcraft, + isNot(contains('libflutter_secure_storage_linux_plugin.so')), + ); + final workflow = File( + '.github/workflows/flutter-linux.yml', + ).readAsStringSync(); + expect(workflow, contains(r'graphics_lib="$SNAP/gpu-2404/usr/lib/')); + expect(workflow, contains('libwayland-egl.so.1')); + expect(workflow, contains('usr/share/doc/fonts-noto-core/copyright')); + }, + ); + test('native GTK theme follows brightness without replacing a valid user theme', () { final configuration = File( - 'lib/src/platform/header_bar_configuration.dart', + 'lib/src/app/linux/linux_window_host.dart', ).readAsStringSync(); final native = File('linux/runner/my_application.cc').readAsStringSync(); - final design = File('lib/src/app/busymark_design.dart').readAsStringSync(); + final snapcraft = File('snap/snapcraft.yaml').readAsStringSync(); - expect(welcome, contains('_WelcomeSidebar')); + expect(configuration, contains('setPreferDark(')); + expect(configuration, contains('brightness == Brightness.dark')); + final chrome = File('linux/runner/linux_chrome_host.cc').readAsStringSync(); + expect(chrome, contains('setPreferDark')); + expect(chrome, contains('host->set_theme(')); + expect(native, contains('static void set_gtk_theme_preference')); + expect(native, contains('gtk_settings_get_default()')); + expect( + native, + contains('"gtk-application-prefer-dark-theme", prefer_dark'), + ); + expect(native, contains('"gtk-theme-name"')); + expect(native, contains('gtk_theme_exists')); + expect(native, contains('available_gtk_theme_fallback')); expect( - welcome, - contains('BusyMarkSidebarRecordRow<_RecentWorkspaceAction>'), + native, + contains( + 'const gchar* fallback = available_gtk_theme_fallback(prefer_dark);', + ), ); - expect(welcome, contains('WorkspaceGlyphs.forRecent(recent)')); - expect(welcome, contains('BusyMarkGlyphs.markdownFile')); - expect(welcome, contains('BusyMarkGlyphs.folder')); - expect(welcome, contains('BusyMarkGlyphs.writersideProject')); - expect(design, contains('class BusyMarkSidebarRecordRow')); - expect(design, contains('BorderRadius.circular(BusyMarkRadius.md)')); - expect(welcome, contains('if (!sidebarOnRight && sidebarVisible)')); - expect(welcome, contains('if (sidebarOnRight && sidebarVisible)')); - expect(welcome, contains('welcomeMainColor = colors.view')); - expect(welcome, contains('backgroundColor: welcomeMainColor')); - expect(welcome, contains('crossAxisAlignment: CrossAxisAlignment.stretch')); - expect(welcome, contains('HeaderBarConfigurationPublisher(')); - expect(welcome, contains('documentControlsVisible: false')); - expect(welcome, contains('searchVisible: false')); - expect(welcome, contains('sidebarVisible: sidebarVisible')); - expect(welcome, contains('sidebarToggleVisible: true')); - expect(welcome, contains('case HeaderBarAction.sidebarToggle:')); - expect(welcome, contains('setSidebarVisible(!settings.sidebarVisible)')); - expect(workspace, contains('HeaderBarConfigurationPublisher(')); - expect(workspace, contains('sidebarVisible: sidebarVisible')); - expect(workspace, contains('sidebarToggleVisible: hasSidebar')); - expect(workspace, contains('searchVisible: true')); - expect(workspace, contains('documentControlsVisible: true')); - expect(configuration, contains('final bool documentControlsVisible;')); - expect(configuration, contains('final bool sidebarToggleVisible;')); - expect(configuration, contains('final bool searchVisible;')); - expect(native, contains('set_document_controls_visible')); - expect(native, contains('set_sidebar_toggle_visible')); - expect(native, contains('set_search_visible')); expect( native, - contains('set_widget_visible(self->sidebar_toggle_button, visible)'), + contains('fallback != nullptr && !gtk_theme_exists(theme_name)'), ); + expect(native, isNot(contains('g_strcmp0(theme_name, fallback) != 0'))); expect( native, - isNot( - contains('set_widget_visible(self->save_button, effective_visible)'), + contains('g_object_set(settings, "gtk-theme-name", fallback, nullptr);'), + ); + expect(native, contains('"Yaru-dark"')); + expect(native, contains('"Adwaita-dark"')); + expect(native, contains('"gtk-icon-theme-name"')); + expect(native, contains('icon_theme_exists')); + expect(native, contains('available_icon_theme_fallback')); + expect( + native, + contains( + 'const gchar* icon_fallback = available_icon_theme_fallback(prefer_dark);', ), ); expect( native, - contains('set_widget_visible(self->refresh_button, effective_visible)'), + contains( + 'icon_fallback != nullptr && !icon_theme_exists(icon_theme_name)', + ), ); expect( native, - isNot(contains('set_widget_visible(self->problems_button, visible)')), + isNot(contains('g_strcmp0(icon_theme_name, icon_fallback) != 0')), ); - }); - - test('settings page matches the native header surface and split shell', () { - final settings = File( - 'lib/src/workspace/presentation/settings_screen.dart', - ).readAsStringSync(); - - expect(settings, contains('backgroundColor: colors.view')); - expect(settings, contains('color: colors.view')); - expect(settings, contains('BusyMarkSidebarSurface(')); - expect(settings, contains('BusyMarkSidebarNavigation(')); - expect(settings, contains('sidebarVisible: showSidebar')); - expect(settings, contains('sidebarToggleVisible: false')); expect( - settings, + native, + contains( + 'g_object_set(settings, "gtk-icon-theme-name", icon_fallback, nullptr);', + ), + ); + expect(native, isNot(contains('gtk_icon_theme_set_custom_theme'))); + expect(native, isNot(contains('gtk_accent_css_provider'))); + expect(native, isNot(contains('@define-color theme_selected_bg_color'))); + expect(native, isNot(contains('@define-color accent_bg_color'))); + expect(native, isNot(contains('treeview.view:selected'))); + expect(native, isNot(contains('button.suggested-action'))); + expect( + native, + isNot(contains('GTK_STYLE_PROVIDER_PRIORITY_APPLICATION + 1')), + ); + expect(native, isNot(contains('gtk_theme_name_for_preference'))); + expect(native, isNot(contains('icon_theme_name_for_preference'))); + expect(native, isNot(contains('prefer_dark_gtk_theme'))); + expect(native, isNot(contains('set_gtk_theme_preference(TRUE)'))); + expect( + native, + isNot(contains('fl_lookup_bool_arg(args, "preferDark", TRUE)')), + ); + expect(snapcraft, contains('yaru-theme-gtk')); + expect(snapcraft, contains('yaru-theme-icon')); + expect(snapcraft, contains('override-build:')); + 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( - 'constraints.maxWidth >= BusyMarkSizes.settingsSidebarBreakpoint', + 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(r'ln -s "../../usr/share/$resource_kind/$resource_name"'), + ); + expect( + snapcraft, + isNot(contains(r'cp -a "$CRAFT_PRIME/usr/share/themes"/Yaru*')), + ); + expect( + snapcraft, + isNot(contains(r'cp -a "$CRAFT_PRIME/usr/share/icons"/Yaru*')), + ); + expect( + native, + isNot( + matches( + RegExp( + r'static void my_application_startup[\s\S]*' + r'G_APPLICATION_CLASS\(my_application_parent_class\)->startup\(application\);[\s\S]*' + r'set_gtk_theme_preference', + ), + ), + ), + ); + expect( + native, + isNot( + matches( + RegExp( + r'static void my_application_activate\(GApplication\* application\) \{[\s\S]*' + r'set_gtk_theme_preference[\s\S]*' + r'gtk_application_window_new', + ), + ), ), ); }); - - test( - 'native main header title is contextual, not duplicate app branding', - () { - final welcome = File( - 'lib/src/workspace/presentation/welcome_screen.dart', - ).readAsStringSync(); - final settings = File( - 'lib/src/workspace/presentation/settings_screen.dart', - ).readAsStringSync(); - final workspace = File( - 'lib/src/workspace/presentation/workspace_screen.dart', - ).readAsStringSync(); - final native = File('linux/runner/my_application.cc').readAsStringSync(); - - expect(welcome, contains('HeaderBarConfigurationPublisher(')); - expect(welcome, contains('title: context.l10n.appTitle')); - expect(settings, contains('HeaderBarConfigurationPublisher(')); - expect(settings, contains('title: title')); - expect( - workspace, - contains('title: busyMarkBidiIsolateFor(context, title)'), - ); - expect(settings, isNot(contains("setTitleRange('BusyMark Settings')"))); - expect( - native, - contains('gtk_header_bar_set_custom_title(self->header_bar'), - ); - expect(native, isNot(contains('kHeaderWindowControlsBalanceWidth'))); - expect(native, isNot(contains('update_title_stack_alignment'))); - }, - ); } Set _snapStagePackages(String snapcraft) { diff --git a/test/src/window_control_service_test.dart b/test/src/window_control_service_test.dart index 49e33dd7..4c7ccc8c 100644 --- a/test/src/window_control_service_test.dart +++ b/test/src/window_control_service_test.dart @@ -1,8 +1,37 @@ +import 'dart:async'; + import 'package:busymark/src/app/window_control_service.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:window_manager/window_manager.dart'; void main() { + test( + 'button and native close requests share one pending confirmation', + () async { + final native = _FakeNativeWindowController(); + final service = WindowControlService(nativeWindow: native); + addTearDown(service.dispose); + await service.initialize(); + final confirmation = Completer(); + var requests = 0; + service.registerCloseHandler(() { + requests++; + return confirmation.future; + }); + + final first = service.requestClose(); + final second = service.requestClose(); + native.listeners.single.onWindowClose(); + expect(second, same(first)); + expect(requests, 1); + + confirmation.complete(); + await first; + await service.requestClose(); + expect(requests, 2); + expect(native.closeCount, 0); + }, + ); test('initializes prevent-close interception', () async { final native = _FakeNativeWindowController(); final service = WindowControlService(nativeWindow: native); diff --git a/test/src/wysiwyg_clipboard_test.dart b/test/src/wysiwyg_clipboard_test.dart index d86c00bf..fdeadab8 100644 --- a/test/src/wysiwyg_clipboard_test.dart +++ b/test/src/wysiwyg_clipboard_test.dart @@ -740,6 +740,187 @@ void main() { ); } + for (final suffix in ['©', '&', '&*']) { + testWidgets( + 'text-only URL character-reference suffix survives Enter and reopening: $suffix', + (tester) async { + const url = 'https://example.com'; + final text = '$url$suffix'; + systemData = {'text': text}; + var source = ''; + BusyDocument? live; + await mount( + tester, + 'url-reference-$suffix', + source, + (value) => source = value, + onDocumentChanged: (value) => live = value, + ); + await key(tester, LogicalKeyboardKey.keyV); + expect(live!.blocks.single.plainText, text); + expect(busyInlineStyleRanges(live!.blocks.single.inlines), isEmpty); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + void check(BusyDocument document) { + expect(document.blocks.first.plainText, text); + final link = busyInlineStyleRanges( + document.blocks.first.inlines, + ).single; + expect( + (link.kind, link.start, link.end, link.destination), + (BusyInlineKind.link, 0, url.length, url), + ); + expect(document.blocks.first.plainText.substring(link.end), suffix); + } + + check(live!); + final next = tester.widget(find.byType(TextField).last); + expect(next.focusNode!.hasFocus, isTrue); + expect( + next.controller!.selection, + const TextSelection.collapsed(offset: 0), + ); + final saved = source; + check( + _parser + .parse( + filePath: 'topic.md', + source: saved, + mode: MarkdownMode.writersideMarkdown, + ) + .busyDocument, + ); + await mount( + tester, + 'url-reference-reopened-$suffix', + saved, + (value) => source = value, + onDocumentChanged: (value) => live = value, + ); + expect( + tester + .widget(find.byType(TextField).first) + .controller! + .text, + text, + ); + }, + ); + } + + for (final nested in [false, true]) { + testWidgets( + 'formatting only a pasted URL suffix preserves the boundary (nested=$nested)', + (tester) async { + const url = 'https://example.com'; + const text = '$url*'; + systemData = {'text': text}; + var source = ''; + BusyDocument? live; + await mount( + tester, + 'formatted-url-suffix-$nested', + source, + (value) => source = value, + onDocumentChanged: (value) => live = value, + ); + await key(tester, LogicalKeyboardKey.keyV); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + final first = tester.widget(find.byType(TextField).first); + first.focusNode!.requestFocus(); + first.controller!.selection = const TextSelection( + baseOffset: url.length, + extentOffset: text.length, + ); + await tester.pump(); + await key(tester, LogicalKeyboardKey.keyB); + if (nested) await key(tester, LogicalKeyboardKey.keyI); + await tester.pumpAndSettle(); + + void check(BusyDocument document) { + final block = document.blocks.first; + expect(block.plainText, text); + final ranges = busyInlineStyleRanges(block.inlines); + final link = ranges + .where((range) => range.kind == BusyInlineKind.link) + .single; + expect( + (link.start, link.end, link.destination), + (0, url.length, url), + ); + final bold = ranges + .where((range) => range.kind == BusyInlineKind.strong) + .single; + expect((bold.start, bold.end), (url.length, text.length)); + final italic = ranges.where( + (range) => range.kind == BusyInlineKind.emphasis, + ); + if (nested) { + expect( + (italic.single.start, italic.single.end), + (url.length, text.length), + ); + } else { + expect(italic, isEmpty); + } + } + + check(live!); + final saved = source; + final reparsed = _parser + .parse( + filePath: 'formatted.md', + source: saved, + mode: MarkdownMode.writersideMarkdown, + ) + .busyDocument; + check(reparsed); + final liveLink = busyInlineStyleRanges( + live!.blocks.first.inlines, + ).where((range) => range.kind == BusyInlineKind.link).single; + expect( + liveLink.attributes.containsKey(busyMarkBareUrlAttribute), + isFalse, + ); + expect( + saved, + nested ? '[$url]($url)***\\****\n\n' : '[$url]($url)**\\***\n\n', + ); + await tester.enterText( + find.byType(TextField).first, + 'https://other.com*', + ); + await tester.pumpAndSettle(); + final liveDestination = + busyInlineStyleRanges(live!.blocks.first.inlines) + .where((range) => range.kind == BusyInlineKind.link) + .single + .destination; + expect(liveDestination, url); + await mount( + tester, + 'formatted-url-suffix-reopened-$nested', + saved, + (value) => source = value, + onDocumentChanged: (value) => live = value, + ); + await tester.enterText( + find.byType(TextField).first, + 'https://other.com*', + ); + await tester.pumpAndSettle(); + expect( + busyInlineStyleRanges(live!.blocks.first.inlines) + .where((range) => range.kind == BusyInlineKind.link) + .single + .destination, + liveDestination, + ); + }, + ); + } + testWidgets( 'pasted URL retains live link coverage through split, Backspace and suffix formatting', (tester) async { From 185d99bad0047d008efa1090bdc085f8fb9e3bce Mon Sep 17 00:00:00 2001 From: albert Date: Mon, 5 Oct 2026 00:33:07 -0700 Subject: [PATCH 14/14] Fix accessibility bus setup for GTK host CI test --- .github/workflows/flutter-linux.yml | 2 ++ tools/test_gtk_accent_host.sh | 5 ++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/flutter-linux.yml b/.github/workflows/flutter-linux.yml index 01c2aaf2..c4943ff0 100644 --- a/.github/workflows/flutter-linux.yml +++ b/.github/workflows/flutter-linux.yml @@ -31,9 +31,11 @@ jobs: sudo apt-get update sudo apt-get install -y \ apparmor-profiles \ + at-spi2-core \ bubblewrap \ clang \ cmake \ + dbus-daemon \ fonts-noto-core \ fonts-noto-cjk \ fonts-noto-mono \ diff --git a/tools/test_gtk_accent_host.sh b/tools/test_gtk_accent_host.sh index 5096041e..045e8e98 100755 --- a/tools/test_gtk_accent_host.sh +++ b/tools/test_gtk_accent_host.sh @@ -16,4 +16,7 @@ trap 'rm -rf "$fixture_root"' EXIT -L"$engine_root" -lflutter_linux_gtk -Wl,-rpath,"$engine_root" \ -Wl,--wrap=fl_engine_get_binary_messenger \ -o "$fixture_root/probe" -G_DEBUG=fatal-warnings "$fixture_root/probe" "$fixture_root/data" +# The probe enables the AT-SPI bridge and keeps GTK warnings fatal. Give its +# accessibility service (at-spi2-core) a private bus, including on headless CI. +dbus-run-session -- env G_DEBUG=fatal-warnings \ + "$fixture_root/probe" "$fixture_root/data"