diff --git a/docs/development/deferred-focus-restoration.md b/docs/development/deferred-focus-restoration.md new file mode 100644 index 00000000..d65c2304 --- /dev/null +++ b/docs/development/deferred-focus-restoration.md @@ -0,0 +1,423 @@ +# Deferred dialog and document-selection focus + +## Scope and baseline + +This follow-up starts at `b8dd70c` with a clean worktree. It changes focus +coordination only, not the heading/Enter/inline-image repair on this branch. +The parser, document controller, serializers, dependency pins and native UI are +unchanged. Repository Linux and compiled-application verification instructions +were followed. No applicable `AGENTS.md` was present. + +The official SDK is `/home/albert/flutter-sdk`: Flutter 3.47.5, +framework `6a19cca56475dbfba1478ee68d7bd0c2ef891da1`, engine revision +`af7e796e161ae0bb1ff0758c71a7105418bd9ded`, Dart 3.13.4. +`flutter pub get --enforce-lockfile` passed without changing either manifest. + +Evidence below is retained locally under `build/deferred-focus-evidence/` and +the named native output directories. These ignored artifacts are not committed. +The committed tests and probes reproduce the checks without those artifacts. + +## Independently confirmed findings + +| Finding | Reproduction and actual cause | Repair | +| --- | --- | --- | +| Delayed modal release can reclaim an older editor. | Close a production dialog, hold native barrier release, focus another field, then deactivate the application. The old guard accepts *any* `FocusScopeNode`, including the desktop root while the newer owner is suspended. A newer owner followed by unfocus also bypasses it. Capturing `closingFocus` after `pop()` additionally mistakes a newer control for the closing dialog. | Identify the actual closing route. A per-release permission observes focus ownership and lifecycle, permanently invalidating on a newer owner or inactivity. Only the previous owner, its ancestor scopes and that closing route are eligible. Flush pending FocusManager requests before the final check; remove listeners on invalidation or completion. | +| Deferred cross-block selection extent can override another control or inactive desktop focus. | Perform a real production pointer drag across blocks; release it, then focus a search-like control or deactivate before the post-frame handoff. The original callback checks selection and generation but not owner/lifecycle. Table-ended ranges also have a second, unconditional child autofocus callback. | One guarded handoff owns ordinary and table-ended range input. Capture selection/request/document/navigation identities and the actual focus owner. Cancel on a competing owner or newer action; wait without focusing while the desktop suspends the owner. Resume only when FocusManager restores that same ownership. Remove the child's independent autofocus. | +| A canceled toolbar dialog can bypass the shared dialog guard. | Hold release of the real Link toolbar dialog and focus another field. The shared guard can leave it alone, but toolbar `whenComplete` snapshots the new owner and schedules an editor return anyway. | Completion is not a new editing intent: check permission before scheduling toolbar restoration. | +| A newer focus request can be pending, not yet primary. | Register a newer-control request earlier in the same post-frame phase, then queue `restoreEditingFocus()`. Without flushing FocusManager, the old callback sees the old primary and overwrites the newer request. | Apply pending focus changes before the existing request/document/selection/navigation checks. | + +Both supplied findings are confirmed, but not every neighboring path was broken. +The existing resumed newer-owner modal case already passes. Document replacement, +disposal, ordinary completion, intentional keyboard collapse and quick lifecycle +round trips are retained as controls, not claimed as additional original defects. +The existing canceled nested-menu regression is preserved; its ordinary +cancellation is not evidence of a new defect. + +## Why editing and reading remain independent + +No repair moves a caret, fabricates composition, changes read-only state, +unfocuses an editor, restores a viewport after a jump, or uses a timer/fixed +frame-count suppression. The existing operation-scoped caret-reveal protection +is unchanged. A passive handoff does not call its intentional `reveal()` API. +Actual input and purposeful navigation continue through the original editing +and reveal paths. + +Validation of the first repair exposed a necessary lifetime detail: an ordinary +selection extent waiting through prolonged inactivity can leave the virtualized +cache before its handoff. The pending operation now holds a keep-alive lease for +that one target only, released on success, cancellation or disposal. Tests assert +that the rest of the document remains virtualized. This is not whole-document +retention, and the lease neither requests focus nor changes scrolling. + +## Regression evidence + +The final 56 new cases were copied into a separate `git archive b8dd70c` +checkout. Its production editor/dialog blobs were verified unchanged +(`52bb3fda7412e6c1de67d5ee2301a057108c02e1` and +`a855768ad0c11853e1c8c974fa298c627ff0a1c0`, respectively). + +```sh +# Run in the unchanged archive and in the repaired checkout: +/home/albert/flutter-sdk/bin/flutter test --no-pub --reporter expanded \ + test/src/busymark_dialogs_test.dart \ + test/src/wysiwyg_selection_lifecycle_test.dart \ + --name 'deferred modal focus|deferred extent|deferred toolbar' +``` + +The unchanged production code fails **29** cases; **27** controls pass +(`before-definitive.log`, exit 1). The repaired checkout passes all 56 as part +of the complete affected suites (`affected-final.log`, exit 0). + +Coverage includes both range directions, ordinary/table extents, competing +owners before/after close, newer-owner-to-scope transitions, inactivity and +resumption, cancellation, 90 inactive frames, disposal, document replacement, +new caret navigation, and a newer request in the same post-frame phase. +Off-cache reading tests measure actual visible geometry every frame with a +0.1-logical-pixel tolerance. They assert unchanged range endpoints, source and +undo history, deliver platform text input without tapping/refocusing, verify +replacement at the correct target and deliberate reveal, then undo. + +The original lifecycle helper explicitly enables Linux FocusManager lifecycle +listening and asserts inactive primary focus is the desktop root. It is not a +test that merely changes an unrelated lifecycle value. + +An early pending-owner test did not schedule a frame, and early toolbar tests +used an obstructed target/incorrect dialog finder. Those attempts are not +regression evidence; `before-definitive.log` uses the corrected tests. + +One combined run encountered an existing test-harness pointer-ID collision: +an old context-menu test fixes pointer 71, while newly auto-allocated gestures +advanced to 71. The same GestureArena assertion was independently reproduced +on unchanged production code by seeding the test pointer allocator to 71 +(`original-pointer-id-collision.log`). New tests use explicit noncolliding IDs; +the old test and its assertions are unchanged. The final full affected run +passes, as does the isolated old context-menu test. + +## Verification commands + +```sh +TMPDIR=/tmp /home/albert/flutter-sdk/bin/flutter test --no-pub \ + --reporter expanded test/src/busymark_dialogs_test.dart \ + test/src/wysiwyg_selection_lifecycle_test.dart \ + test/src/wysiwyg_session_test.dart test/src/source_editor_widget_test.dart + +TMPDIR=/tmp /home/albert/flutter-sdk/bin/flutter test --no-pub \ + --reporter expanded test/src/app_smoke_test.dart \ + test/src/busymark_design_test.dart test/src/workspace_controller_test.dart \ + test/src/workspace_document_context_test.dart \ + test/src/writerside_toc_menu_test.dart test/src/source_commands_test.dart +TMPDIR=/tmp /home/albert/flutter-sdk/bin/flutter test --no-pub \ + --reporter expanded test/src/busymark_document_test.dart \ + --name 'WYSIWYG link dialog' + +/home/albert/flutter-sdk/bin/cache/dart-sdk/bin/dart format \ + --output=none --set-exit-if-changed lib/src/app/busymark_dialogs.dart \ + lib/src/editor/editor_focus_restoration.dart \ + lib/src/editor/wysiwyg/wysiwyg_editor.dart \ + test/src/busymark_dialogs_test.dart \ + test/src/wysiwyg_selection_lifecycle_test.dart tools/focus_restoration_smoke.dart +TMPDIR=/tmp /home/albert/flutter-sdk/bin/flutter analyze --no-pub +/usr/bin/python3 -m py_compile tools/focus_restoration_native.py \ + tools/deferred_focus_native.py +/usr/bin/python3 -m unittest tools/test_owned_processes.py \ + tools/test_application_close_smoke.py tools/test_run_owned_fixture.py \ + tools/test_official_flutter_workflow.py + +PATH=/home/albert/.cache/JetBrains/Air/node-24.13.0/node-v24.13.0-linux-x64/bin:$PATH \ + TMPDIR=/tmp /home/albert/flutter-sdk/bin/flutter build linux --release \ + --no-pub -t lib/main.dart +``` + +The four affected suites pass **487 tests** in 3m24s (`affected-final.log`). +The six broader integration suites pass **505 tests** in 4m56s +(`integration-suites.log`), including existing menu cancellation, workspace +navigation, search and view transitions. All **3** existing accepted Link-dialog +editing tests pass (`accepted-dialog.log`). No assertions were weakened or new +regressions skipped. The unrelated entire Flutter suite was not rerun. + +Formatting checks all six Dart files with zero changes (`format-final.log`). +Static analysis reports no issues (`analyze-commit.log`, 26.0s). Both Python +drivers compile; the existing process/acceptance/workflow tooling suites pass +**92** tests (`tooling-final.log`). The normal `lib/main.dart` Linux release +build passes (`linux-release-final.log`); its bundle is separately retained in +`build/deferred-focus-shipping-bundle/` while compiling probes. The shipping +engine SHA-256 matches the official SDK artifact: +`d57449a5c2ee39e77b9e3488f0703b165c67e0b3c554089f388db96484ae62f1`. + +## Native Linux acceptance + +The probe uses the actual application and official engine, disposable Markdown, +private GNOME/Mutter headless desktops, a separate GTK companion application, +and real Mutter Alt-Tab/key injection. Profiles are isolated *before* starting +the owned D-Bus broker; activated helpers inherit that isolation. The owned-tree +supervisor checks cleanup and does not signal unrelated processes. + +Four application-switch cycles include a 15-second inactive interval. Every +rendered frame records focus/lifecycle and reading geometry, never document +contents. Native `z` input is delivered without clicking/refocusing the field. +Deferred dialog setup deterministically closes the production Navigator route +and gates its real barrier release; this is explicitly **programmatic dialog +dismissal**, not proof of native Escape handling. Selection setup uses real +native XTest pointer input on XWayland, accounting for GTK frame extents, and +introduces a newer owner before the post-frame handoff. + +```sh +# Build the alternate entry point, retain that bundle, then restore lib/main.dart. +PATH=/home/albert/.cache/JetBrains/Air/node-24.13.0/node-v24.13.0-linux-x64/bin:$PATH \ + TMPDIR=/tmp /home/albert/flutter-sdk/bin/flutter build linux --release \ + --no-pub -t tools/focus_restoration_smoke.dart +/usr/bin/python3 tools/deferred_focus_native.py \ + build/deferred-focus-release-probe/busymark NEW_SHORT_OUTPUT x11 deferred-dialog +# Backends: x11 (XWayland), wayland. Modes: editor, deferred-dialog, +# deferred-selection, deferred-selection-table (selection gesture: x11 only). +``` + +The XWayland Editor control passes in debug (`build/df-xe/desktop/probe/`) and +release (`build/df-xe2/desktop/probe/`): all +passive geometry/focus/source/history checks, correct insertion and intentional +reveal. Its supervisor exits 0 with no unresolved descendants. A too-long +Wayland socket path and two native Escape-dismissal prerequisites failed in +earlier attempts; none is counted as acceptance. Their logs are retained. +The Editor control is explicitly retired by the supervisor after its focus/input +measurements; it is not a successful natural-shutdown claim. The three deferred +release cases below do require natural application exit. + +The final release XWayland delayed-dialog case passes +(`build/df-xd4/desktop/probe/{frames.json,result.json}`): barrier completion while +inactive leaves focus at the root, resumed input edits only the newer control, +and all per-frame viewport, range, bytes/history/dirty/revision assertions pass. +The application exits naturally with status 0; the owned-tree supervisor reports +no unresolved descendants (`build/df-xd4/ownership.json`). + +Release XWayland ordinary and table-ended cross-block selections both pass +(`build/df-xs4/desktop/probe/` and `build/df-xt/desktop/probe/`). The recorded +native pointer-up coordinates equal the requested rendered caret positions; +the production editor reports a cross-block range. Each case preserves the +range and reading geometry through all four native focus cycles and directs +native input only to the newer control. Both applications exit naturally with +status 0 and both ownership reports contain no unresolved descendants. + +The native Wayland dialog case remains **unverified**: even after a real Alt-Tab +activation attempt, GTK does not report the application's window active on +this owned desktop (`build/df-wd3/desktop/probe/error.txt`). Earlier Mutter +relative-pointer attempts also failed to deliver the native selection; those +failed prerequisites are retained separately and not counted as passes. +The final XTest cases do not weaken any production assertion. The native GTK +Escape dismissal path is not established by the deterministic dialog prelude. + +Native acceptance is limited to the recorded owned headless desktops; it is not +a claim about an untested physical desktop, strict Snap, minimize/restore, or +window-chrome reactivation. The entire unrelated Flutter suite is not rerun by +this focused follow-up. + +## Semantic-text cancellation follow-up (P2) + +Starting at `b139f51`, the remaining Writerside semantic-dialog caller was +independently confirmed to bypass the async-toolbar completion preflight. +After the production UI Control or File / Path dialog is canceled, hold its +native-barrier release, focus a newer search-like control, then complete release +while resumed. `_isDialogTargetCurrent()` still succeeds, and the semantic +caller schedules `_restoreEditingFocusAfterFrame()` with that newer control as +the captured owner. Its unchanged ownership at the deferred check is therefore +mistaken for permission to return to editing. + +Only the null/empty-result branch of `_insertSemanticText()` changes: it now +requires `_mayRestoreEditingFocus()` as well as the existing document validity +guard before scheduling restoration, matching async toolbar completion. This +does not change accepted semantic insertion, purposeful focus requests, the +earlier Alt-Tab repair, or structural editing. + +Eight new regressions in `writerside_authoring_ui_test.dart` cover Markdown and +XML topics, UI Control and File / Path, and ordinary versus newer-owner delayed +cancellation. They use the production native-menu dispatch and semantic dialog, +gating only the barrier service. They assert the document generation is still +current, preserve the collapsed caret, source and undo history, check focus and +actual reading geometry on every rendered frame (0.1 logical-pixel tolerance), +and send text through the existing input connection without refocusing. Ordinary +cancellation then edits the original target and exercises one-step undo/redo; +newer-owner input affects only the newer control. Existing immediate semantic +cancellation tests now also assert primary editor focus and an input client. + +```sh +TMPDIR=/tmp /home/albert/flutter-sdk/bin/flutter test --no-pub \ + --reporter expanded test/src/writerside_authoring_ui_test.dart \ + --name 'delayed semantic cancellation|collapsed.*enters text and cancels neutrally' +``` + +The unchanged production implementation fails the four newer-owner cases; the +four ordinary delayed controls and eight existing controls pass +(`build/semantic-dialog-focus-evidence/before-framed.log`: 12 passed, 4 failed). +An initial harness did not schedule a render after barrier release, so its +deferred callback did not run; that attempt is not regression evidence. The +final tests explicitly schedule rendered frames, not merely fake-clock ticks. + +The final focused command passes all **16** tests in 48 seconds +(`after-final-focused.log`). The complete five affected suites pass **556** +tests in 3m53s (`affected-suites.log`): + +```sh +TMPDIR=/tmp /home/albert/flutter-sdk/bin/flutter test --no-pub \ + --reporter expanded test/src/writerside_authoring_ui_test.dart \ + test/src/wysiwyg_selection_lifecycle_test.dart \ + test/src/busymark_dialogs_test.dart test/src/wysiwyg_session_test.dart \ + test/src/source_editor_widget_test.dart +``` + +Follow-up logs are under `build/semantic-dialog-focus-evidence/`. Formatting +checks both changed Dart files with zero changes (`format.log`); pinned +`flutter analyze --no-pub` reports no issues (`analyze-final.log`, 43.7s). +The normal pinned `flutter build linux --release --no-pub -t lib/main.dart` +passes (`linux-release.log`), with the same official release-engine hash recorded +above. Dependency pins and unrelated structural code remain unchanged. + +This follow-up does not repeat native semantic-dialog acceptance. The earlier +native results above cover their recorded generic dialog/selection paths, not +this additional semantic caller. No new native pass is claimed. + +## Toolbar-menu cancellation and passive-caller audit (P2) + +Starting at `8b9aa85` with a clean worktree, same-document cancellation of both +Writerside and Text style menus was independently confirmed to bypass ownership +checks. The production native-menu channel result was held while another text +control acquired focus, then completed with null. Document identity and +generation remained unchanged. `onMenuCanceled` passed its document guard and +the deferred helper treated the newly captured owner as permission to return +to the editor. A different toolbar menu's trigger could also be displaced. + +This asynchronous boundary is present in production, not just in the test +double: `native_menu_deactivate_cb()` in `linux/runner/my_application.cc` +schedules `native_menu_cleanup_idle_cb()` with `G_PRIORITY_DEFAULT_IDLE`; +cleanup returns the result to Dart. GLib documents that idle work yields to +ready higher-priority sources. This establishes the possible ordering, not a +measurement of native race frequency. [GLib idle_add_full](https://docs.gtk.org/glib/func.idle_add_full.html) + +Passive completion is now an explicit policy on +`_restoreEditingFocusAfterFrame(passive: true)`. It checks lifecycle and current +ownership before capturing the request and again in the post-frame callback +after pending FocusManager changes are applied. The originating menu context +travels through `BusyMarkHeaderPopupMenuButton.onCanceled` and the toolbar. +This allows return from **that menu's own trigger**, as well as the editor or +its ordinary scopes, without permitting a different menu trigger or text field. +It adds no widgets, focus nodes, timers, SDK changes, or native UI changes. + +The helper's callers were reviewed together: + +| Caller | Intent and policy | +| --- | --- | +| Writerside/Text style cancellation | Passive; document guard plus shared permission, with the exact closing menu's trigger context. | +| Semantic-text dialog cancellation | Passive; existing document guard retained, previous ownership check moved into the shared policy. | +| Async toolbar completion | Passive; previous preflight moved into the same shared policy, with execution-time revalidation too. | +| Rich `restoreSpellingFocus()` after review | Passive; an additional real workspace-review dialog regression reproduced the same theft during delayed barrier release, and this entry point now uses the shared guard. | +| Synchronous toolbar commands, accepted Writerside/semantic insertions, and explicit inspector-close focus return | Purposeful editing/navigation; retain the existing policy and request/document/selection checks. | + +The spelling finding was reproduced through the actual F7 review dialog and +workspace `finally` path, not by directly calling a private helper. Source-mode +spelling return does not use this helper and is not changed or claimed fixed. +No spelling projection/worker algorithm or unrelated structural-editing code +was changed. + +### Failing-before / passing-after evidence + +Artifacts are retained locally in `build/toolbar-menu-focus-evidence/`. + +- `before-menu-valid.log`: unchanged production fails **16** cases and passes + **16** ordinary controls. The 32 cases cover both menus, Writerside Markdown + and XML, collapsed/reversed local selections, ordinary editor return, return + from the exact trigger, a newer text control, and a different menu trigger. +- `before-spelling-valid.log`: unchanged production fails the newer-owner + workspace-review case; ordinary review cancellation/input/undo passes. +- `after-focused.log`: all **50** focused authoring cases pass, including the + menu matrix and previous semantic-dialog regressions/ordinary controls. +- `after-spelling.log`: both focused workspace-review cases pass. + +The menu regressions explicitly render every deferred frame, measure the real +visible anchor and scroll position within 0.1 logical pixel, and assert unchanged +selection direction, source and undo history. They send two successive input +updates over the newer control's existing connection without tapping/refocusing. +Ordinary return types into the original target, reveals it, and preserves +one-step undo/redo. Workspace-review cases also assert unchanged buffer revision, +dirty state, undo and redo histories. No final-position-only workaround is used. + +An initial test tried to read `Focus.focusNode`, which is null when Flutter owns +the internal node. That invalid trigger setup is not defect evidence. The final +test uses the public `Focus.of()` lookup below the real trigger's Focus widget. + +### Verification + +```sh +TMPDIR=/tmp /home/albert/flutter-sdk/bin/flutter test --no-pub \ + --concurrency=2 --reporter expanded \ + test/src/writerside_authoring_ui_test.dart \ + test/src/wysiwyg_selection_lifecycle_test.dart \ + test/src/busymark_dialogs_test.dart test/src/wysiwyg_session_test.dart \ + test/src/source_editor_widget_test.dart test/src/spelling_workspace_test.dart \ + test/src/busymark_design_test.dart test/src/native_menu_service_test.dart \ + test/src/writerside_toc_menu_test.dart +TMPDIR=/tmp /home/albert/flutter-sdk/bin/flutter test --no-pub \ + --reporter expanded test/src/app_smoke_test.dart \ + --name 'Writerside|inspector|properties|focus|canceled|spelling|menu' +``` + +All **689** tests in the nine full suites pass in 3m51s (`affected-final.log`), +and all **34** selected real-workspace controls pass (`workspace-controls.log`). +The entire unrelated Flutter suite was not rerun. + +The initial loaded run had two failures (`affected-suites.log`): a new fixture's +post-undo spelling debounce outliving its widget-test body, and an unchanged +large Markdown spelling check timing out while analysis/build jobs competed. +The new fixture now retires its session in the real async zone, before fake-clock +timer invariants; native-worker shutdown itself has a real-I/O timeout and must +not be moved onto that fake clock. No production spelling code or assertions +were weakened. The unchanged large fixture and all **36** spelling workspace +tests pass alone (`spelling-suite-verified.log`), and then the complete combined +run also passes with controlled concurrency and no competing builds. + +Pinned `flutter analyze --no-pub` reports no issues (`analyze-final.log`, 35.7s). +`dart format --output=none --set-exit-if-changed` checks all five changed Dart +files with zero changes (`format.log`). The normal +`flutter build linux --release --no-pub -t lib/main.dart` passes +(`linux-release.log`), using the same Node PATH/TMPDIR and official toolchain +commands recorded above. Dependency pins, SDK revision and the official engine +hash remain unchanged. + +```sh +/home/albert/flutter-sdk/bin/cache/dart-sdk/bin/dart format \ + --output=none --set-exit-if-changed lib/src/app/busymark_design.dart \ + lib/src/editor/wysiwyg/wysiwyg_toolbar.dart \ + lib/src/editor/wysiwyg/wysiwyg_editor.dart \ + test/src/writerside_authoring_ui_test.dart test/src/spelling_workspace_test.dart +``` + +The existing release-mode native Editor control passes on an owned GNOME +headless XWayland desktop (`native-editor-control-retry.log`). It performs four +real Alt-Tab cycles, including 15 seconds inactive, then types native `z` without +refocusing. Per-frame passive viewport/reading geometry, focus/selection, +bytes/dirty/revision and undo/redo checks pass; input edits the original target +and intentionally reveals it. Retained measurements and ownership/actions are +under `build/toolbar-menu-focus-evidence/native-control/`. The supervisor exits +0 with no unresolved descendants. Like the earlier Editor control, it is retired +after focus/input measurements, not claimed as a natural-shutdown acceptance. +This control does **not** natively reproduce the gated GTK menu-result race or +spelling-review race; those are production-widget/channel regressions above. +Native Wayland and physical-desktop menu acceptance were not rerun. + +```sh +# Build with the earlier pinned Node PATH and TMPDIR=/tmp, retain the probe +# separately, then restore the normal lib/main.dart bundle. +/home/albert/flutter-sdk/bin/flutter build linux --release --no-pub \ + -t tools/focus_restoration_smoke.dart +/usr/bin/python3 tools/deferred_focus_native.py \ + /tmp/busymark-toolbar-focus-gKNPB3/release-probe/busymark \ + /tmp/NEW_SHORT_FOCUS_OUTPUT x11 editor +``` + +The first native-control launch could not finish creating its isolated profile +because staging duplicate bundles exhausted available workspace space; it did +not reach supervisor launch. Three generated bundle copies were moved, without +deletion, to `/tmp/busymark-toolbar-focus-gKNPB3/`. The successful retry kept its +desktop/profile there too. The normal shipping bundle was restored to +`build/linux/x64/release/bundle/` and compared byte-for-byte with the retained +`/tmp/busymark-toolbar-focus-gKNPB3/shipping/` copy. No source or evidence logs +were removed. The workspace volume remains nearly full (about 700 MiB available +after relocation); the temporary copies can be regenerated from the pinned +toolchain if `/tmp` is cleared. diff --git a/docs/development/editor-focus-restoration.md b/docs/development/editor-focus-restoration.md new file mode 100644 index 00000000..dbd1664e --- /dev/null +++ b/docs/development/editor-focus-restoration.md @@ -0,0 +1,346 @@ +# Editor focus and reading-position restoration + +## Investigation boundary + +The starting checkout was clean at +`aac4d66872b794b7722d0ededf11313ee7eccd15`. Repository/parent instructions, +dependency pins and the existing Linux verification documentation were checked +before production changes. The repair uses the existing official Flutter 3.47.5 +SDK at `/home/albert/flutter-sdk`: framework +`6a19cca56475dbfba1478ee68d7bd0c2ef891da1`, engine +`af7e796e161ae0bb1ff0758c71a7105418bd9ded`, Dart 3.13.4. The SDK checkout, +`pubspec.yaml`, and `pubspec.lock` are unchanged. `flutter pub get +--enforce-lockfile` succeeded; the installed positioned-list package is 0.3.8. + +The supplied review was treated as leads. The installed Flutter and package +sources, rather than archive line numbers, determined the implementation. +Evidence is retained locally in `build/focus-evidence/`; these ignored build +artifacts are not document data and are not published. Early exploratory tests +with an incorrect platform setup, an off-screen tap, or a missing render-host +override were corrected and are not counted as defect evidence. + +## Independently confirmed defects + +Each row identifies a regression that failed before its respective repair. Some +additional paths were investigated after the first focus repair; their logs +therefore isolate those remaining defects, not an entirely unmodified baseline. + +| Defect | Failing evidence | Passing evidence | +| --- | --- | --- | +| Linux desktop focus suspension/restoration schedules an old caret reveal in the rich document's ancestor viewport. The visible field moved from 50 to 431.12 logical pixels in an intervening frame. | `before-core.log`, `before-desktop-valid.log` | Editor lifecycle suite; native frame traces below | +| Source's own vertical controller also scrolls on passive focus: 1800 to 1661.75 pixels. Blocking ancestor implicit scrolling alone cannot fix this. | `before-source.log` | `after-source-composition.log`; Source lifecycle suite | +| A partially clipped/oversized block loses its intra-block position. A code block naturally scrolled 2300 pixels restored from top -2219 to 81. | `before-lifetime-dialog-session.log` | `after-session-layout.log`, `clipboard-and-viewport.log`; tall paragraph/code regressions | +| Recreating a session with an off-screen caret can restore reading position but leave no mounted text-input client. | `expanded-first.log` | `after-session-input.log`, `after-session-detach.log`, `after-session-owner.log` | +| A queued editor focus restoration can override a newer search/inspector-like focus owner. | `before-newer-focus.log` | `after-newer-focus.log` | +| A queued toolbar focus callback can reclaim primary focus while the desktop focus manager has suspended the application. | `before-toolbar-suspension.log` | `session-and-toolbar-verified.log` | +| A document refresh/session restore can take focus from an existing competing owner, including while the focus manager has suspended it. | `before-session-owner.log` (both resumed and inactive cases) | `after-session-owner.log` (4 tests passed, including existing recreation cases) | +| Canceling a dialog replaces a table cell's reversed 7..2 selection with 0..9. | `before-table-math.log` (dialog case only) | `math-and-restores.log`; lifecycle suite | +| A math editable can change to its rendered representation on inactivity, detaching its input client. | `math-and-restores.log` (valid render-host setup) | Lifecycle suite's prolonged math-source case | +| Source's pending session jump can overwrite a newer manual scroll (600 becomes 1200). | `before-source-pending.log` | `after-source-pending.log` | +| Legitimate platform composition cleanup while inactive triggers Source's separate horizontal-caret helper (400 becomes 68.67). | `before-source-composition.log` | `after-source-composition.log` (5 tests passed) | +| Delayed native modal-barrier release can restore an older focus owner over a control selected while release was pending. | `before-modal-owner.log` | `after-modal-owner.log`, `serial-retries-one.log` | +| A retained table outside the package's two-screen layout cache has a zero paint transform. Floating controls attempted to paint at NaN coordinates, and the new session metadata attempted to serialize NaN. | `short-table-outside-cache-before.log`; native `ship-x11-table/application.log` | `short-table-finite-verified.log`; the expanded short-cell recreation case in `focused-cache-complete.log` | + +### Conclusions not supported by reproduction + +- No BusyMark application-reactivation handler explicitly scrolling the document + to the top was found. The reproduced mechanism is Flutter's focus-driven caret + reveal, plus the independently reproduced Source horizontal helper. +- Ordinary table-cell *application resumption* did not reproduce select-all. + The pinned `EditableText._justResumed` path protects that case. Dialog return + is different and did reproduce it; the two are not conflated. +- The existing recreation test is useful and remains present, but its caret in + the visible region does not establish application-reactivation behavior. +- Negative item leading edges are legitimate; negative `jumpTo` alignments are + not the repair. The package's documented alignment range remains respected. +- Menu/dialog/Source/Split paths were not all declared defective merely because + they restore focus. Passing cancellation, competing-owner and navigation + cases are retained as coverage, not additional claimed bugs. +- A later native-probe failure initially suggested that a short table cell did + not reveal on typing. Stronger measurements disproved that diagnosis: the + active viewport moved from 1900 to 129.99 pixels and the target was visible at + y=66. The probe retained an obsolete `ScrollPosition` after a viewport + replacement (`accept-w-table/probe/frames.json`, `samePosition:false`). The + measurement now follows the live viewport and also checks actual rendered + reading/target geometry. No editor scrolling workaround was added for this + false diagnosis. The separate non-finite table/session geometry was genuinely + reproduced and repaired. + +## Repair mechanism + +`BusyMarkEditorFocusRestoration` surrounds only production editor fields. On a +focus-only restoration it intercepts both halves of the pinned framework's +deferred caret reveal: the field controller's direct `animateTo`/`jumpTo`, then +the descendant `showOnScreen` request that would reach ancestor viewports. The +actual `showOnScreen` callback consumes the operation token. There is no timer, +fixed number of ignored frames, delayed jump-back, temporary read-only state, +selection movement, SDK modification, or application-wide visibility policy. + +Text/selection changes and real editing keys/pointer input release suppression; +explicit editor navigation/focus helpers identify purposeful reveal requests. +The framework still performs normal caret scrolling for input, selection changes, +search and explicit navigation. Manual wheel/drag scrolling is not disabled. +The repair does not fabricate or replay text-input/composition events. Source +ignores only composing-only, non-editing lifecycle notifications when deciding +whether to schedule its own horizontal reveal. + +A focus-scoped keep-alive retains only the last editing target for each editor, +including while the desktop focus manager suspends primary focus. Changing to +another editing target releases the previous retained target. Ordinary document +virtualization remains enabled; the whole document is not retained. Math source +presentation also remains stable during inactivity. +For retained children beyond the layout cache, session capture uses the public +viewport's logical reveal coordinate when the paint transform is not invertible. +Unanchored table controls are not painted until their table has a valid paint +transform again. This avoids both non-finite persistence and invalid overlay +painting without widening the virtualization cache. + +Session state adds optional/backward-readable clipped-pixel offset, editing-block +viewport offset and source/layout signature fields. With unchanged layout the +saved editable is mounted and the document's actual scroll position is restored +in the same operation, preserving its off-screen input client. Otherwise the +existing content anchor is used with legal `[0,1]` item alignment followed by a +synchronous pixel offset, including leading scroll padding and nested entries. +Current layout enforces genuine bounds. Old sessions retain their previous +alignment semantics: missing intra-block precision cannot be reconstructed. +JSON, workspace coalescing and Writerside source-binding copies include the new +fields; no state is discarded and no migration framework is introduced. + +Queued operations use existing document generations plus request identity, +selection/navigation and focus-ownership checks. New scrolling or navigation, +replacement content, disposal, and a newer owner invalidate stale operations. +Document refresh does not claim focus while inactive or while another control +owns it. Shared dialogs validate their previous node and ownership again after +asynchronous barrier release. Table fields preserve their own caret/range; +explicit cell/document Select All commands remain in the editor. + +## Automated regression coverage + +`test/support/desktop_focus.dart` explicitly installs the pinned focus manager's +desktop lifecycle listener in the Linux-targeted test binding. It verifies that +inactive focus is the root scope and resumed focus is the original node. Tests +inspect geometry during every inactive/resumed frame and after callbacks and +animations, with 0.1 logical-pixel tolerance. They do not silently refocus the +field before delivering subsequent text input. + +During implementation, an additional cross-cell Tab test caught an overbroad +suppression at an explicit focus handoff (`navigation-boundary-measured-before.log`, +caret x=-1275.67). Explicit navigation markers repair that handoff; the same test +and both pending-scroll/navigation cases pass in +`navigation-boundary-verified.log`. Its intentional horizontal offset is compared +with fresh Tab navigation at 0.1-pixel tolerance: assuming it must be exactly zero +was incorrect for the pinned framework's fractional caret geometry (0.1667). +Passive geometry checks were not relaxed. Existing exact-reparse tests also +caught an overly strict root/view-scope ownership check; root scopes without a +route are now distinguished from a competing dialog/control, retaining both +normal recreation and competing-owner protection. + +The expanded lifecycle, session and Source suites cover caret above/below the +viewport; short/90-frame inactive intervals and repeated cycles; reversed local +and cross-block ranges; paragraphs, lists, code, table internal scrolling and +math representation; off-screen recreation; oversized/plain/nested blocks; +new scrolling/navigation while a callback is pending; canceled nested menus and +dialogs; newer focus owners; document replacement/disposal; committed input, +composition, paste and undo/redo. Existing viewport coverage is retained. +Workspace coverage checks meaningful pixel-only session updates, coalescing +sub-tolerance changes, unchanged bytes/dirty/history, and existing document/view +switching behavior. Native Source/Split coverage complements the widget cases. + +## Native Linux acceptance + +`tools/focus_restoration_smoke.dart` is an alternate entry point into the actual +application and production editors, not a substitute editor. The supervisor +`tools/focus_restoration_native.py` creates an owned GNOME/Mutter headless desktop +with a 1280x960 virtual monitor, isolated profiles, disposable Markdown/Writerside +documents, a separate real GTK application, and real Mutter keyboard input. It +does not operate the user's desktop. Both native Wayland and GTK X11 through +XWayland were exercised; this does not claim a separate standalone Xorg session. + +The successful matrix is Markdown Editor, Source, Split, and table-cell editing +on both backends, plus Writerside Editor on both backends (10 runs). Each run +uses four real Alt-Tab out/back cycles, including a 15-second inactive interval, +then types a native `z` without tapping or refocusing the editor. All recorded +frames preserve the passive viewport. Exact insertion at the original authored +source position and deliberate caret reveal both pass. Passive checks also +verify focus suspension/restoration, original selection, exact bytes, dirty +state, edit revision and undo/redo counts. + +Final frame-level evidence (no private document contents): + +- `build/focus-evidence/safe-x-{editor,source,split,table,ws}/probe/{frames.json,result.json}` (XWayland) +- `build/focus-evidence/safe-w-{editor,source,split,table,ws}/probe/{frames.json,result.json}` (Wayland) +- `build/focus-evidence/native-final-summary.json` and `native-final-lifecycle.jsonl` + +All 10 final runs pass. Each records four acknowledged real inactive/resumed +cycles. Live viewport drift is zero; rendered reading-anchor drift is zero where +the rich editor exposes an anchor. The probe follows the current scroll position +after viewport replacement and checks actual target visibility after input. +The normal release bundle is restored at `build/linux/x64/release/bundle/`; +its application-library hash matches `shipping-app-sha256.txt`. The separately +retained instrumentation bundle is `build/focus-verified-probe/`. + +These frame traces are the equivalent frame-level evidence, not a video. Earlier +native attempts and prerequisite failures are retained separately and are not +counted as passes. Native pre-repair recording was not obtained: the initial +build was blocked by the existing visualization build step's missing Node on +PATH. An already-installed Node 24.13.0 was subsequently located and used only +on the build command's PATH; dependencies and build scripts were not changed. + +Minimize/restore and non-editing window-chrome reactivation remain **unverified**. +The headless `full` and follow-up `minimize` attempts did not establish the native +window-state prerequisite. The final bounded X11 attempt also could not verify +native unmapping after the minimize request (`safe-x-full.log`). Failed +prerequisites and any failed focus/input results are retained, not represented as +successful acceptance. This limitation is not evidence that +the editor repair passes those transitions, nor a diagnosis of another editor +bug. The supervisor forcibly retires its owned processes afterward; it makes no +natural-shutdown acceptance claim. + +### Native harness isolation correction + +Earlier runs gave BusyMark disposable XDG directories but did not update the +private broker's environment for D-Bus-activated GNOME helpers. Some earlier +helper logs show attempted migrations of the host Evolution cache, reporting +file-exists errors. BusyMark itself used only disposable documents/profiles. +The harness now verifies that both its parent and broker belong to the dedicated +`dbus-run-session`, updates activation environment before starting the desktop +and after display discovery, and isolates keyring/session-agent settings too. +Final `safe-*` runs have no host-cache migration warnings. No host-cache cleanup, +deletion or attempted rollback was performed; the earlier logs do not establish +that every incidental helper action was side-effect-free. + +Reproduction commands use a new output directory on every run: + +```sh +PATH=/home/albert/.cache/JetBrains/Air/node-24.13.0/node-v24.13.0-linux-x64/bin:$PATH \ + TMPDIR=/tmp /home/albert/flutter-sdk/bin/flutter build linux --release --no-pub \ + -t tools/focus_restoration_smoke.dart +dbus-run-session -- /usr/bin/python3 tools/focus_restoration_native.py \ + build/linux/x64/release/bundle/busymark NEW_OUTPUT x11 editor alt-tab +dbus-run-session -- /usr/bin/python3 tools/focus_restoration_native.py \ + build/linux/x64/release/bundle/busymark NEW_OUTPUT wayland source alt-tab +# Other modes: split, table. Optional final document format: writerside. +# Final retained-bundle runs used build/focus-verified-probe/busymark instead. +``` + +## Verification record + +The verification logs include unsuccessful runs as well as successful retries; +no assertions or deadlines were relaxed, no new regression was skipped, and no +unrelated production code was changed to make the broader suite pass. + +All Flutter/Dart commands used the pinned SDK. Final formatter checking covered +the 14 changed/new Dart files; `git diff --check` and Python `py_compile` also +passed. `analyze-shipping-final.log` records `flutter analyze --no-pub`: no issues; +`format-shipping-final.log` records all 14 files checked with zero changes. +The bundled official release engine and the SDK artifact have the same SHA-256, +`d57449a5c2ee39e77b9e3488f0703b165c67e0b3c554089f388db96484ae62f1` +(`engine-sha256.txt`); `sdk.json` and the empty `dependency-pin-diff.log` retain +the SDK identity and unchanged pins. + +Commands and completed independent checks: + +```sh +/home/albert/flutter-sdk/bin/flutter pub get --enforce-lockfile +TMPDIR=/tmp /home/albert/flutter-sdk/bin/flutter analyze --no-pub +TMPDIR=/tmp /home/albert/flutter-sdk/bin/flutter test --no-pub --concurrency=1 \ + test/src/app_smoke_test.dart --name 'workspace coalesces pixel viewport' \ + --reporter expanded +TMPDIR=/tmp /home/albert/flutter-sdk/bin/flutter test --no-pub --concurrency=1 \ + test/src/busymark_dialogs_test.dart test/src/spellcheck_projection_test.dart \ + test/src/headless_chrome_support_test.dart --reporter expanded +TMPDIR=/tmp /home/albert/flutter-sdk/bin/flutter test --no-pub --concurrency=1 \ + test/src/nextcloud_notes_interaction_test.dart --reporter expanded +``` + +The workspace check passed 1 test (`workspace-coalescing.log`), the serial +dialog/projection/browser run passed all 111 (`serial-retries-one.log`), and the +unchanged Notes interaction suite passed all 12 (`notes-scan-normal-tmp.log`). +The six targeted exact-reparse/toolbar-suspension/table-Tab/competing-owner cases +passed together (`session-and-toolbar-verified.log`). + +Two broad runs are retained, not presented as all-green results: + +```sh +TMPDIR=/home/albert/busystack/busymark/.tmp/focus-regression \ + /home/albert/flutter-sdk/bin/flutter test --no-pub --concurrency=2 \ + --reporter expanded +TMPDIR=/tmp /home/albert/flutter-sdk/bin/flutter test --no-pub --concurrency=4 \ + --reporter expanded +``` + +`full-suite.log`: 4790 passed, 62 existing skips, 4 failed. It compiled before +the final Source composing-only and rich clipboard build-order corrections. +Those regressions subsequently passed (`after-source-composition.log`, +`clipboard-and-viewport.log`). Its unchanged Notes scan case was still displaying +"Scanning files" at its assertion; the normal-`/tmp` retry passed. Its unchanged +automatic-spelling case passed the serial retry below. The task-created temporary +cache was subsequently moved, recoverably, to `build/focus-temp-retained/`. + +`full-suite-final.log`: 4782 passed, 62 existing skips, 13 failed. This run's +compilation predates the final modal ownership correction; that failure passes +in both later dialog runs. The remaining failures were the unchanged spelling +projection's 5-second performance assertion (5.039 seconds), browser writer +fixture, two asset-process deadlines, automatic spelling's existing deadline, +and seven History fixture cases. Serial projection/browser/dialog retries pass +all 111 as above. The following unchanged serial retry passed the entire asset +and spelling suites; only the seven History cases remained (98 passed, 7 failed): + +```sh +TMPDIR=/tmp /home/albert/flutter-sdk/bin/flutter test --no-pub --concurrency=1 \ + test/src/local_history_store_test.dart test/src/asset_ingestion_service_test.dart \ + test/src/spelling_workspace_test.dart --reporter expanded +``` + +See `serial-retries-two.log`. A direct build of the unchanged History fixture +succeeded (exit 0), but a further isolated suite retry still exceeded its +supervisor's existing 60-second compilation deadline: 52 passed, 7 failed in +`history-warm-retry.log`. The supervisor diagnostics verify retirement of its +owned child tree. The fixture imports History/store/format modules, not the +editor. This remains an unresolved verification limitation on this host; no +History implementation or deadline was changed. + +```sh +TMPDIR=/tmp /home/albert/flutter-sdk/bin/flutter build bundle --debug --no-pub \ + --target-platform=linux-x64 --asset-dir build/focus-history-assets \ + --depfile build/focus-history.d --target test/support/local_history_store_process.dart +TMPDIR=/tmp /home/albert/flutter-sdk/bin/flutter test --no-pub --concurrency=1 \ + test/src/local_history_store_test.dart --reporter expanded +``` + +The superseded `focused-shipping.log` was stopped after it identified the +incorrect zero-offset expectation and root/view-scope integration regression +described above. It is retained for transparency; the corrected complete run +is `focused-complete.log`: all 484 tests passed. The additional beyond-cache +table/finite-JSON/exact-recreation case was then included in the final complete +rerun, `focused-cache-complete.log`: **all 485 tests passed**, with no skipped +new cases. Final analysis also passes in `analyze-final-cache.log`, and +`format-cache-check.log` records 14 files checked with no formatting changes. + +The final normal-entry-point release command succeeded (exit 0) in +`linux-release-complete.log`. The normal and instrumentation bundles are retained +separately during native checking; the normal application remains the shipping +artifact, not the alternate probe entry point. + +```sh +PATH=/home/albert/.cache/JetBrains/Air/node-24.13.0/node-v24.13.0-linux-x64/bin:$PATH \ + TMPDIR=/tmp /home/albert/flutter-sdk/bin/flutter build linux --release --no-pub \ + -t lib/main.dart +``` + +```sh +TMPDIR=/tmp /home/albert/flutter-sdk/bin/flutter test --no-pub --concurrency=2 \ + test/src/wysiwyg_selection_lifecycle_test.dart test/src/wysiwyg_session_test.dart \ + test/src/source_editor_widget_test.dart test/src/busymark_dialogs_test.dart \ + test/src/busymark_design_test.dart --reporter expanded +/home/albert/flutter-sdk/bin/dart format --output=none --set-exit-if-changed \ + lib/src/app/busymark_dialogs.dart lib/src/editor/editor_focus_restoration.dart \ + lib/src/editor/source/source_editor.dart lib/src/editor/wysiwyg/wysiwyg_block_widgets.dart \ + lib/src/editor/wysiwyg/wysiwyg_editor.dart lib/src/editor/wysiwyg/wysiwyg_session_state.dart \ + lib/src/workspace/presentation/workspace_screen.dart test/src/app_smoke_test.dart \ + test/src/busymark_dialogs_test.dart test/src/source_editor_widget_test.dart \ + test/src/wysiwyg_selection_lifecycle_test.dart test/src/wysiwyg_session_test.dart \ + test/support/desktop_focus.dart tools/focus_restoration_smoke.dart +git diff --check +python3 -m py_compile tools/focus_restoration_native.py +``` diff --git a/docs/development/inline-break-anchor-restoration.md b/docs/development/inline-break-anchor-restoration.md new file mode 100644 index 00000000..ef6520ed --- /dev/null +++ b/docs/development/inline-break-anchor-restoration.md @@ -0,0 +1,188 @@ +# Inline images beside existing hard breaks + +## Scope and independent reproduction + +Started from clean `d0844de` on 2026-10-22, following the README and Linux/native +development instructions. No applicable `AGENTS.md` was present. The official +Flutter 3.47.5 SDK at `/home/albert/flutter-sdk` and the enforced dependency +lockfile are unchanged. Heading/Enter splitting, Backspace and focus-restoration +logic are unchanged. The parser, serializer, UI architecture and dependency pins +are not modified by this correction. + +The report is confirmed through three independent production paths: + +1. Parse `a \n![](first.png)b\n` with `MarkdownParser`. Its paragraph text is + `a\nb`, with the empty-alt image at text offset 2. Call `insertHardBreak(id, 1)` + or `insertBlankLine(id, 1)`. Before repair the image stays at offset 2 rather + than advancing to 3 or 4. +2. The production editor's Hard Break and Blank Line toolbar buttons reproduce + the same failure. Real application/workspace tests independently reproduce + Shift+Enter and the Blank Line button before the correction. +3. The compiled official-engine Linux GTK application on an isolated X11/Xvfb + display reproduces the error with an actual XTest Shift+Enter event. The + retained `native-before/probe/failure-source.md` is: + + `"a \n![](first.png) \nb\n"` + + The image has incorrectly moved between the two breaks. The driver reports + failure, exits nonzero, and does not claim native acceptance for this run. + +Evidence is retained locally under ignored `build/hard-break-anchor-evidence/`. +It uses only synthetic fixtures and isolated profiles, not personal documents. + +## Cause and narrowly scoped repair + +`_insertHardBreaks` already knows the clamped insertion offset and exact length. +Its style-range remapping uses those coordinates, but semantic-anchor restoration +previously discarded them and inferred a common prefix/suffix from old/new text. +For `a\nb` → `a\n\nb`, the common prefix includes the *existing* newline. That +plausible text diff places the insertion after the old break instead of at the +actual caret. An anchor at offset 2 is consequently classified as preceding +the insertion and left in the wrong place. Consecutive breaks make the same +ambiguity possible over a longer span. + +The command now passes `(offset: safeOffset, length: count)` into +`_restoreSemanticInlineAnchors`. Known insertions use those coordinates directly; +they do not infer the edit from text. Other callers retain their existing +text-input inference and heading-specific behavior. Wrapper normalization and +the established boundary rule are unchanged: an object exactly at the insertion +point stays before the inserted breaks; an object after it advances by the +inserted length. Objects remain in order, exactly once, with their formatting +and attributes intact. No additional production defect was established in this +scoped investigation. + +## Regression evidence + +- The unmodified existing structured suite passed **226 tests** + (`baseline-structured.log`). +- The final new matrix against an isolated archive of `d0844de` has **38 failing + regressions and 36 passing controls** (`before-final-regressions.log`). Only + the test file was overlaid; the original controller blob is + `35b3fa803e6ccc716472da4442f7c8642e359d73`, matching the archived commit exactly. +- The separate real-workspace regression failed both commands before the fix + (`before-workspace.log`), with image offset 2 instead of 3/4. +- After repair, all **76 focused regressions/controls pass** (`after-focused.log`). + This includes 72 parser/controller cases, two local-editor widget cases and + two real-workspace cases. Coverage includes CommonMark, GFM and Writerside + Markdown; single/consecutive existing breaks; insertion on either side of + breaks; multiple images at a shared offset; images before, at and after the + caret; strong/emphasis contexts; source rebinding and reparsing; single-step + local/external undo/redo; focus/caret; view switching; actual save and reopen. + +Tests inspect each leaf image's actual text offset, destination, attributes and +enclosing inline formatting, not only the image count or a saved session. Two +equivalent adjacent formatting wrappers may be coalesced without changing these +semantics. Preliminary exploratory tests incorrectly equated that wrapper +grouping with object identity, used one invalid emphasis fixture, and assumed +single paragraph-boundary breaks survive reparsing. Those assumptions were +corrected without production changes; the definitive before/after matrix uses +valid parsed formatting and interior insertions. It does not change the existing +blank-paragraph or boundary-break lifecycle. + +Focused commands (from the repository root unless noted): + +```sh +flutter pub get --enforce-lockfile +TMPDIR=/tmp flutter test --no-pub --concurrency=2 \ + test/src/wysiwyg_structured_regression_test.dart test/src/app_smoke_test.dart \ + --name 'explicit line-break anchors|inline break anchors survive workspace' \ + --reporter expanded +# Inside build/hard-break-anchor-baseline (original commit, tests overlaid): +TMPDIR=/tmp flutter test --no-pub --concurrency=1 \ + test/src/wysiwyg_structured_regression_test.dart \ + --name 'explicit line-break anchors' --reporter expanded +``` + +## Native Linux acceptance + +The existing structural acceptance driver has an opt-in `inline-breaks` scenario; +it does not run or alter the prior heading/list/focus scenarios. It uses the real +application, workspace, editor, publication and save/close/reopen workflows. +Both default Shift+Enter and an actual pointer click on Blank Line are tested. +The runner establishes private XDG directories before private D-Bus/Xvfb startup +and injects events only into its own PID-owned window. + +The eight cases cross Markdown/Writerside Markdown, existing/consecutive breaks, +and Hard Break/Blank Line. Each checks the actual document mode, live object +positions/formatting, focus/caret, exactly one undo operation, native undo/redo, +actual saved bytes, and the reopened inline tree. Saved Markdown and screenshots +are retained per case; screenshots are not substituted for structural assertions. +The simple tracked fixture uses the equivalent backslash hard-break spelling to +avoid trailing-space warnings; controller/widget regressions also test the +two-space spelling from the reproduction. + +```sh +TMPDIR=/tmp flutter build linux --debug --no-pub \ + -t tools/structural_editing_smoke.dart +python3 tools/structural_editing_native.py \ + build/hard-break-anchor-native-bundle/busymark \ + build/hard-break-anchor-evidence/native-final inline-breaks +``` + +The bundle was copied from `build/linux/x64/debug/bundle` after the repaired +driver build. Output directories must be new on each run. Both `native-after` +(two-space break fixture) and `native-final` (final tracked backslash fixture) +passed **all 88 checks across eight scenarios**. Each exited naturally with code +0; both ownership reports have `ok: true`, `cleanupRequired: false`, and no +unresolved survivors. The original expected-failure run exited 1 and also left +no unresolved descendants. No forced cleanup is counted as acceptance. + +The retained bundle's debug engine matches the official SDK engine byte for byte +(SHA-256 `7a5698608a3c79ad3f123a74bce8074339ffa1a83c896d73ae853c9f92a43cc3`). +This campaign covers X11/Xvfb with the official debug GTK engine, not Wayland, +Writerside XML native interaction, or a separate uninstrumented release-GUI +campaign. The normal release build uses `lib/main.dart`, not the driver. + +## Final verification + +- `affected.log`: **811 tests passed** in 2m30s, no skips/failures. +- `after-focused.log`: **76 tests passed** in 18s, including both real-file + workspace/save/reopen tests. +- `analyze.log`: `flutter analyze --no-pub` found no issues (105.8s). +- `format.log`: four changed Dart files checked, zero formatting changes. +- Python compilation passed; `tooling-tests.log`: all **19** owned-process + tooling tests passed. The fixture-report rejection message is from an + intentional negative test, not a failed test. +- Dependency pins and the official SDK's tracked tree are unchanged. +- `linux-release.log`: the normal Linux release build passed, using + `lib/main.dart`. Its `libapp.so` SHA-256 is + `623df5ac13a3eeaed4d4129f45be2f898df589cffb81d834fd3f437f9cac1cd4`. + The packaged release engine matches the official SDK release engine (SHA-256 + `d57449a5c2ee39e77b9e3488f0703b165c67e0b3c554089f388db96484ae62f1`). +- `full-suite.log`: **5,109 passed, 62 existing skips, one failure**, in 17m38s. + `general Markdown fixture finishes automatic rich spelling` exceeded its + existing deadline while its status was still `checking` (catalog available). + This exact failure also appears in the retained pre-change + `build/structural-evidence/full-suite.log` and the earlier committed repair + report. No spelling/test-deadline code changed. The unchanged isolated retry + passed in **14s** (`spelling-retry.log`); no assertions were weakened and no new + regressions were skipped. The full run is not reported as an all-green pass. + +All `flutter` / `dart` commands below use `/home/albert/flutter-sdk/bin/`. + +```sh +TMPDIR=/tmp flutter test --no-pub --concurrency=2 \ + test/src/busymark_document_test.dart \ + test/src/wysiwyg_structured_regression_test.dart \ + test/src/wysiwyg_clipboard_test.dart \ + test/src/writerside_authoring_test.dart \ + test/src/writerside_native_list_inspector_test.dart \ + test/src/writerside_inspector_session_test.dart --reporter expanded +dart format --output=none --set-exit-if-changed \ + lib/src/editor/wysiwyg/wysiwyg_document_controller.dart \ + test/src/wysiwyg_structured_regression_test.dart \ + test/src/app_smoke_test.dart tools/structural_editing_smoke.dart +flutter analyze --no-pub +python3 -m py_compile tools/structural_editing_native.py +python3 -m unittest tools/test_owned_processes.py tools/test_run_owned_fixture.py +PATH=/home/albert/.cache/JetBrains/Air/node-24.13.0/node-v24.13.0-linux-x64/bin:$PATH \ + TMPDIR=/tmp flutter build linux --release --no-pub -t lib/main.dart +TMPDIR=/tmp flutter test --no-pub --concurrency=4 --reporter expanded +TMPDIR=/tmp flutter test --no-pub --concurrency=1 \ + test/src/spelling_workspace_test.dart \ + --name 'general Markdown fixture finishes automatic rich spelling' \ + --reporter expanded +git diff --check +``` + +No push or publication is part of this work. diff --git a/docs/development/pr59-merge-integration.md b/docs/development/pr59-merge-integration.md new file mode 100644 index 00000000..9b462991 --- /dev/null +++ b/docs/development/pr59-merge-integration.md @@ -0,0 +1,258 @@ +# PR 59: editor and document-history integration + +The merge combines `fix/editor-view` at `3007fa1` with +`release/v0.7.0` at `77a5b5a` (PR 58). The starting worktree was clean. +The five textual conflicts were in the Source editor, rich editor, and their +Source, selection-lifecycle and session suites. Both branches' test cases are +retained; the three conflicted test files lose no test declarations from either +parent. Dependency manifests, Flutter 3.47.5 and the official engine are unchanged. + +## Integration decisions + +- Document-owned Undo/Redo and monotonic, bounded typing groups remain the + authority. Source editing epochs, publication preconditions, fresh input focus + nodes on document changes, and immediate history synchronization are retained. +- Source's document-keyed input is wrapped in both `DocumentUndoActions` and the + existing editor focus/scroll protection. Composition lifecycle observation uses + the visible editing value; stale-input validation still uses the full value. +- Rich history restores model and historical selection synchronously, including + a traversal followed by input before the next parent frame. Viewport restoration + remains separately deferred and guarded by document/request/selection/navigation + identity and focus ownership. A competing control or inactive application is + not permission to focus an editor. +- The hidden table-range input keeps document Undo/Redo actions and the central + guarded focus handoff. The release branch's independent child autofocus is not + reintroduced. +- Every reconstructed rich session retains `viewportOffset`, + `viewportEditingOffset` and `viewportLayoutSignature`, together with source + bindings. Structural-editing, passive menu/dialog and selection protections are + preserved. +- Automatically merged workspace publication still checks live buffer identity, + source and revision, and caches only an accepted committed projection. Local + History keeps the release branch's independent retained traversal obligations, + retry/recovery, format preservation and retention-safe protection renewal. + +## Independently reproduced integration failures + +Evidence is retained locally in `build/pr59-merge-evidence/` (ignored artifacts). +The following tests first failed on the combined implementation before their +targeted corrections; no assertions were weakened. + +| Failure | Evidence and regression | Correction | +| --- | --- | --- | +| Source loses passive scroll protection after a document switch. | `integration-before.log`: both wrap variants drift from 1800 to 1661.7494 logical pixels after a real Linux FocusManager suspend/resume. The new Source tests assert every frame within 0.1 pixel and deliver input without refocusing. | A retired wrapper clears its shared scroll controller hook only if it still owns that hook. This preserves the fresh document's input client and suppression callback. | +| Rich pre-edit history records the post-edit caret. | `integration-before.log` and `integration-extra-before.log`: expected offset 4, actual 5 in the clipped-code-block test. | Report the known pre-edit selection to shared history without subsequently overwriting it with a live post-input capture. Local snapshots are captured before mutation but recorded only after acceptance. | +| Table-cell history drops the pixel viewport anchor. | `integration-extra-before.log`: expected `viewportOffset` 2242, actual 0. The code/table session tests also assert the signed editing offset and layout signature. | Copy all viewport fields in the pre-edit session constructor. | +| Rejected structural input adds history and destroys Redo. | The existing rejected-newline regression fails in `affected-initial.log`; the new edit/Undo/reject/Redo case fails in `integration-extra-before.log` (expected zero snapshots, actual one). | Keep pre-edit session reporting separate from recording an accepted local undo operation. Rejected newline input remains atomic. | +| Rejected boundary Backspace also destroys Redo. | `backspace-before.log`: attributed sole-list-owner Backspace leaves source unchanged but adds an undo snapshot (expected zero, actual one). | Capture before the command, then record only when the controller accepts the boundary edit, matching Enter's transaction order. | + +`integration-after.log` passes all seven integration checks, including the +existing rejected-newline test. The Source tests verify new document focus/input +ownership, prolonged inactivity, independent vertical/horizontal reading position, +correct insertion and deliberate reveal. The rich tests inspect the authoritative +history entry before a frame. Rejection tests preserve source/publication count, +Undo count and the existing Redo result. + +### Non-editable selection endpoint found by the full suite + +The full clipboard suite exposed another confirmed focus defect: a saved range +ending at a thematic break never settles on mount. The same loop is reachable by +ordinary Select All. `_focusDocumentSelectionExtent()` schedules another guarded +handoff whenever a non-editable endpoint has no auxiliary input, although only a +table mounts that input. A thematic break therefore schedules frames forever. + +This defect predates the merge. The existing clipboard regression fails in an +unchanged `git archive 3007fa1` checkout (`nonfield-parent-editor.log`) and passes +in an unchanged `git archive 77a5b5a` checkout (`nonfield-parent-release.log`). +Both use the pinned SDK and offline, lockfile-enforced dependencies. The merged +implementation independently fails the same regression +(`nonfield-paste-isolated.log` and `full-batch-2-before-nonfield-fix.log`). + +Retries now apply only to tables, matching the actual auxiliary-input lifetime. +Other non-editable endpoints keep document-command focus as on the release +branch. The table handoff retains all lifecycle/owner/request safeguards and has +no independent autofocus. No selection, viewport, source or history is changed +to stop the loop. + +Four new lifecycle cases exercise both range directions and local/shared +history; a fifth uses actual Select All and Paste keys. Before correction, the +two forward restoration cases fail and both reverse controls pass +(`nonfield-new-before.log`); keyboard Select All also fails +(`nonfield-selectall-before.log`). The existing clipboard case plus all five +new cases pass afterward (`nonfield-after.log`, six tests in seven seconds). +They verify stable rendered geometry, range endpoints, neutral source/history, +command ownership, subsequent paste/input without refocusing, and Undo/Redo. +The original clipboard regression remains unchanged. + +## Verification + +The pinned SDK is `/home/albert/flutter-sdk`. Dependency resolution used +`flutter pub get --enforce-lockfile` and changed neither manifest. + +```sh +TMPDIR=/tmp /home/albert/flutter-sdk/bin/flutter test --no-pub \ + --reporter expanded test/src/source_editor_widget_test.dart \ + test/src/wysiwyg_session_test.dart \ + test/src/wysiwyg_structured_regression_test.dart \ + --name 'merged |rejected text-input list exit' + +TMPDIR=/tmp /home/albert/flutter-sdk/bin/flutter test --no-pub \ + --reporter expanded test/src/source_editor_widget_test.dart \ + test/src/wysiwyg_selection_lifecycle_test.dart \ + test/src/wysiwyg_session_test.dart \ + test/src/wysiwyg_structured_regression_test.dart \ + test/src/writerside_authoring_ui_test.dart test/src/busymark_dialogs_test.dart \ + test/src/app_smoke_test.dart test/src/document_edit_history_test.dart \ + test/src/local_history_store_test.dart test/src/local_history_workspace_test.dart \ + test/src/nextcloud_workspace_test.dart + +TMPDIR=/tmp /home/albert/flutter-sdk/bin/flutter test --no-pub \ + --concurrency=1 --reporter expanded test/src/local_history_store_test.dart + +mapfile -t pr59_dart_files < <(git diff --cached --name-only --diff-filter=ACMR -- '*.dart') +TMPDIR=/tmp /home/albert/flutter-sdk/bin/dart format \ + --output=none --set-exit-if-changed "${pr59_dart_files[@]}" +TMPDIR=/tmp /home/albert/flutter-sdk/bin/flutter analyze --no-pub + +PATH=/home/albert/.cache/JetBrains/Air/node-24.13.0/node-v24.13.0-linux-x64/bin:$PATH \ + CMAKE_BUILD_PARALLEL_LEVEL=2 TMPDIR=/tmp \ + /home/albert/flutter-sdk/bin/flutter build linux --release --no-pub -t lib/main.dart +``` + +The focused run passes seven tests in nine seconds. The combined affected run +passes 1,408 tests, but seven process tests share one failed fixture compilation: +its 60-second compiler deadline expires during concurrent AOT/analysis work. +The failure is retained in `affected-after.log`; it is not counted as a passing +run. With no competing builds, the complete store suite passes all 73 tests in +72 seconds (`history-store-isolated.log`), including actual contention, independent +writers, stale identities and failure cleanup. No compiler or operation deadline +was increased, and no test was skipped. + +Formatting checks all 22 Dart files with zero changes (`format-final.log`). +Final-code analysis reports no issues (`analyze-final-code.log`, 47.0 seconds), and the normal +release build passes again after the last correction (`linux-release-final-code.log`). The shipped engine and the +official SDK engine have identical SHA-256: +`d57449a5c2ee39e77b9e3488f0703b165c67e0b3c554089f388db96484ae62f1` +(`engine-final-code-sha256.log`). + +A broader formatting check also passes all 642 maintained Dart files with zero +changes (`format-repository.log`, 36.81 seconds): + +```sh +TMPDIR=/tmp /home/albert/flutter-sdk/bin/dart format \ + --output=none --set-exit-if-changed lib test tools packages +``` + +The same complete formatting check passes again after the non-editable endpoint +repair (`format-final-code.log`, 642 files, zero changes, 23.96 seconds). + +The first unpartitioned full-suite command was: + +```sh +TMPDIR=/tmp /home/albert/flutter-sdk/bin/flutter test --no-pub \ + --concurrency=2 --reporter expanded +``` + +It received SIGTERM (exit 143) after 2,519 passes and 48 existing opt-in skips, +without a reported assertion failure (`full-suite.log`). It is not counted as a +completed verification. No project-owned compiler or test workers survived. +The complete corpus is rerun in four bounded batches: the large application +file alone, and all other files balanced by source size. The retained +`all-test-files.txt` and `test-batch-{0,1,2,3}.txt` manifests were checked to contain +all **229** discovered test files exactly once, with no omissions or duplicates. +Each batch uses the following command, substituting its manifest/log number: + +```sh +set -euo pipefail +mapfile -t pr59_test_files < build/pr59-merge-evidence/test-batch-0.txt +PATH=/home/albert/.cache/JetBrains/Air/node-24.13.0/node-v24.13.0-linux-x64/bin:$PATH \ + BUSYMARK_D2_PATH="$PWD/build/linux/x64/release/bundle/libexec/busymark/d2" \ + BUSYMARK_TYPST_PATH="$PWD/build/linux/x64/release/bundle/libexec/busymark/typst" \ + BUSYMARK_TEST_SPELLING_ROOT="$PWD/build/spelling-test" TMPDIR=/tmp \ + /home/albert/flutter-sdk/bin/flutter test --no-pub --concurrency=2 \ + --reporter expanded "${pr59_test_files[@]}" 2>&1 | \ + tee build/pr59-merge-evidence/full-batch-0.log +``` + +The installed D2/Typst executables, Poppler tools and already prepared spelling +dictionaries enable the optional checks as well. No dependencies are upgraded, +test deadlines changed, or test assertions bypassed. + +The first 76-file clipboard/session batch found the non-editable endpoint failure +described above (1,765 passes, one failure). Its before log is retained. All four +batches are rerun on the corrected production implementation; earlier runs are +not substituted for final-code results. + +The final-code results (`full-batch-0.log` through `full-batch-3.log`, summarized +and coverage-checked in `full-suite-summary.json`) are: + +| Batch | Files | Passed | Conditional skips | Runtime | +| --- | ---: | ---: | ---: | --- | +| 0 | 1 | 259 | 0 | 5m50s | +| 1 | 76 | 1,614 | 1 | 8m35s | +| 2 | 76 | 1,766 | 0 | 3m41s | +| 3 | 76 | 1,736 | 0 | 8m21s | +| Total | 229 | 5,375 | 1 | | + +Every batch exits zero, without an assertion failure. The single conditional +case requires `BUSYMARK_NATIVE_DIALOG_TEST_DISPLAY`. The complete two-test +`native_writerside_dialog_source_test.dart` file is then run under owned +Xvfb/private D-Bus with that variable set to its disposable display: both tests +pass, including the previously skipped case (`native-gtk-dialog-final.log`, +`/tmp/bm59-xDlujt/gtk-dialog-final/tests.log`). Its ownership report exits zero +with no forced cleanup or unresolved descendants. All discovered cases are thus +exercised; none of the new regressions is skipped. + +## Native Linux evidence + +Native artifacts are retained under `/tmp/bm59-xDlujt/`; summaries and build logs +are in `build/pr59-merge-evidence/`. All cases use disposable documents, isolated +XDG profiles established before their private D-Bus broker, the official release +engine, and the repository's owned-process supervisor. No installed application +or host display/session is used. Probe bundles are kept separately; the normal +`lib/main.dart` bundle is restored at `build/linux/x64/release/bundle/`. + +| Case | Result and evidence | +| --- | --- | +| Document history, X11/Xvfb | `history-x11/`: 100 checks pass using XTest keyboard input through GTK. Covers exhausted history, repeated traversal, mode/tab return, autosave, actual saved bytes, Local History restore, conflict reload, and displaced-source recovery in Source and Editor. | +| Editor, XWayland/Mutter | `editor-min-x11/`: four Alt-Tab cycles including 15 seconds inactive, then native minimize/restore. All passive viewport/geometry/selection/source/history checks and subsequent input/reveal checks pass. Recorded frames include the hidden lifecycle. | +| Editor, Wayland/Mutter | `editor-wayland/`: four real Alt-Tab cycles including 15 seconds inactive. All passive state/geometry checks and native insertion/reveal checks pass without refocusing. | +| Source and Split, XWayland/Mutter | `source-x11/` and `split-x11/`: four real application-switch cycles, unchanged reading position and state, and native insertion at the old target without refocusing; all checks pass. | +| Table cell, XWayland/Mutter | `table-x11/`: the same real application-switch/input checks pass. | +| Deferred table-range selection, XWayland/Mutter | `extent-table-x11/`: actual native pointer drag establishes the document range; a newer control acquires focus before the deferred handoff. Four application-switch cycles preserve range/geometry/source/history, and subsequent native input reaches only that newer control. All checks pass. | +| Delayed modal release, XWayland/Mutter | `dialog-x11/`: a production Navigator dialog is dismissed programmatically with its barrier release gated. Release while inactive leaves the newer owner intact; resumed native input reaches it without editing or scrolling the document. All checks pass. This does not claim native Escape handling or the GTK menu-result interleaving. | +| Structural editing, X11/Xvfb | `structural-x11/`: 130 checks pass for Markdown/Writerside headings, immediate Enter/Backspace inverse, descendants in all native list kinds, inline objects/formatting, one-operation history, view transitions and saved/reopened bytes. | +| Inline images beside existing breaks, X11/Xvfb | `inline-breaks-x11/`: 88 checks pass for both one/two-break insertion commands, existing/consecutive newlines and Markdown/Writerside. Verifies exact live object positions, attributes, formatting, undo/redo and reopened bytes/objects. | +| Shipping application, X11/Xvfb | `shipping-close/results/summary.json`: the normal `lib/main.dart` executable starts and exits naturally through WM_DELETE_WINDOW, with clean shutdown and no surviving descendants. The probe verifies the actually mapped official engine through its retained file descriptor; zero failures. | + +Each successful ownership report has exit code zero and no unresolved descendants. +The structural probes close through the production close listener. The document +history probe exits after its explicit clean-shutdown checkpoint. Ordinary focus +controls are retired after their measurements; they are not natural-shutdown +acceptance claims. Both deferred-focus probes exit naturally with status zero. +`frames.json` records every rendered focus-test frame with +0.1-logical-pixel passive tolerances; it does not log private document contents. + +The initial `editor-x11/` **full** probe is not a pass: its native window lookup +cannot establish an unmapped normal window after minimize, before the chrome +reactivation stage. The separate maintained `minimize` scenario passes and +records the real hidden lifecycle. Window-chrome reactivation remains unverified +by that failed full attempt. Neither its prerequisite nor product assertions +were weakened. + +After correcting the non-editable endpoint, the focus entry point is rebuilt +from final source (`build-focus-final-code.log`) and the native table-range race +is repeated in `extent-final-x11/`: every passive/selection/history and newer +control input assertion passes. The application exits naturally with code zero, +and its owned-tree report verifies complete retirement. The shipping bundle is +then rebuilt from `lib/main.dart`; no probe entry point is left in the normal +bundle directory. + +Final-code Wayland acceptance also passes (`native-wayland-final-code.log`, +`editor-final-wayland/`): four application switches including 15 seconds inactive, +unchanged reading geometry/state, and native insertion at the original target +without refocusing. The final shipping executable passes the owned X11 natural +close check (`native-shipping-final-code.log`, `shipping-final-close/`), including +the mapped official engine verification. Both supervisors exit zero with no +unresolved descendants. The earlier window-chrome observation limitation remains +as recorded above. diff --git a/docs/development/structural-editing-restoration.md b/docs/development/structural-editing-restoration.md new file mode 100644 index 00000000..e08fcb29 --- /dev/null +++ b/docs/development/structural-editing-restoration.md @@ -0,0 +1,291 @@ +# Structural Enter, split and merge repair + +## Baseline and execution paths + +Reviewed the clean `0c55c7f` checkout on 2026-10-22. No applicable `AGENTS.md` +was found. The README and Linux/application/Writerside development verification +instructions apply. The official `/home/albert/flutter-sdk` is Flutter 3.47.5, +framework `6a19cca56475dbfba1478ee68d7bd0c2ef891da1`, engine revision +`af7e796e16`. `flutter pub get --enforce-lockfile` succeeded; dependency/runtime +pins are unchanged. No engine, SDK, native UI or source-editor patch is used. + +The supplied attachment itself is not present in this checkout. The tracked +`test/fixtures/structural_editing/header_test_text.md` is the requested minimal +`## About the job` reproduction, not a claimed copy of an unavailable attachment. + +Production `MarkdownParser` / `MarkdownAstAdapter` create the editing document. +`BusyMarkWysiwygEditor` handles physical Enter with `applyEnterAt`; field text +input and plain-text paste reach `replaceBlockTextWithParagraphs`. Document-range +replacement uses `replaceTextSelectionWithText`. The controller's Markdown +serializer publishes source; the editor then calls `rebaseCommittedSource` and +the workspace updates its real buffer/preview. Local editor history stores +semantic snapshots; workspace history stores source and session state. Real-file +workspace tests exercise external history, view changes, saving and reopening. + +Evidence is retained locally under ignored `build/structural-evidence/`. The +separate `build/structural-baseline/` is an archive of the original commit with +only the new tests overlaid. Its production controller blob is exactly +`0159079dd431b301ce7548954ec713d57c2b856f`, matching the original commit. It uses +the same locked dependencies and official SDK. Neither artifacts nor fixtures +contain private document content. + +## Findings and disposition + +| Finding | Independently observed failure | Repair / regression | +| --- | --- | --- | +| Heading-start Enter | The original H1–H6 becomes an empty heading and a new paragraph receives its text/identity. Controller and actual key-event widget failures. | Shared position-aware split ownership; the original heading stays on the right, with its complete inline tree, attributes and editing ID. `structural boundaries … retains heading identity`, workspace/native cases. | +| Leading newline through text input/paste | Same corruption through the independent reconstruction path. | Both routes use the shared fragment policy; clipboard tests exercise real text input and plain-paste handlers, focus, subsequent typing and undo/redo. | +| Prefix versus entire selection | Prefix deletion should leave the remaining heading intact; whole-heading replacement must not preserve an untouched heading. A newline followed by identical text has the same string diff as an insertion. | Staged selection Enter and actual pre-edit field selection distinguish these cases. The accepted-selection cache is updated after ordinary edits, cleared/pruned with its controllers, and is not an IME replay mechanism. `range replacement newline`, `identical suffix`, `stale full-heading selection context`. | +| Empty preceding paragraph / nested owner | Backspace adopts the empty paragraph's kind, dropping heading semantics. Inside a quote it cannot find the sibling at all. | Only genuinely empty, unauthored text paragraphs with no objects/children are removable. Keep the current heading intact; resolve real structural siblings, not the synthetic outline path. Inverse H1–H6, nested quote and authored/semantic-empty controls. | +| Writerside heading attribute leak | `collapsible`, `default-state` and the authored-attribute snapshot reach the new paragraph and serializer. | New paragraphs get fresh paragraph-owned attributes; all heading attributes remain with the retained heading. No blanket removal of metadata from other block kinds. Start/middle/end, serialization/rebase/reparse coverage. | +| Empty list parent loses descendants | Clearing just the parent caption and pressing Enter or supplying a newline replaces the item with an empty paragraph, discarding children and grandchildren. Empty-alt inline content can also disappear. | List exit requires safely discardable content: no subtree, semantic objects or authored item properties. Otherwise split into valid list items, retaining the original subtree exactly once. Existing `allowListExit: false` whitespace-paste protection remains covered. | +| Leading list split misowns children | The new empty left item takes the old subtree while the parent text moves right. | Original parent ID, authored state and children stay together on the right; new left item owns no subtree. Ordered markers advance according to the existing increment rule. CommonMark/GFM-style lists and Writerside native ownership/rebinding regressions. | +| Zero-width objects lost | Newline reconstruction, Insert Hard Break and Insert Blank Line drop embedded empty-alt images. | Rebuild text with semantic anchors once, then partition the actual inline tree. Preserve destinations, attributes, wrapper formatting, ordering, and boundary offsets, including multiple anchors at one offset. Leading heading objects stay with that heading. | +| Non-atomic local Enter | A full selection in the sole member of an attributed native list is deleted before list exit returns null; the field/model diverge and history changes. | Stage deletion plus Enter, publish once on success, and record history only after acceptance. Consume rejected physical Enter without a text-input fallback. | +| Rejected input fallback | The field's newline path returns null for protected list exit, then publishes the same input as an ordinary local edit. | Explicitly distinguish “no structural newline” from rejection; restore the last valid model selection/text, with no publication or undo entry. | +| Empty task serialization | End-of-item Enter emits `- [ ]` without required whitespace. The pinned parser reparses the empty task as an ordinary list item and Writerside rebinding can lose editing IDs. | Emit and preserve the required trailing space for an empty task, without adding visible or semantic filler. Parser/save/rebase and native-list owner tests. No parser/dependency patch. | +| Nested checkbox ownership | Native saved source lost the checked/unchecked markers on children when a task parent was edited. The original AST adapter recursively extracted/removed checkboxes from descendant lists; it could also promote an unmarked parent to a task. | Extract/remove only the owning item's direct/caption-paragraph checkbox, matching the pinned package AST. All 18 tight/loose, unordered/ordered/task-parent cases fail against the original adapter. Descendant kinds and checked states survive publication and reopening. | +| Formatted semantic anchors | Ordinary typing already retained zero-width images but split their strong wrapper into adjacent runs. Serialization emitted invalid repeated delimiters, losing formatting and inserting literal asterisks on reopen. | Rejoin equivalent adjacent wrappers after anchor restoration using the existing normalization helper. A genuinely parsed formatted-image fixture establishes this independently of newline operations. | +| Read-only history mutation | Physical Enter and the hard/blank-line toolbar callbacks record history before source publication rejects the mutation. | Reject these operations before modifying the model or history. Copy/selection and arrow navigation remain available. Independent original-code keyboard and toolbar failures. | +| Protected/generated endpoints | Direct controller calls report structural success for protected content (or change generated/source-only projections). | Validate structural endpoints and owners before mutation; reject table-cell block splitting/merging, which has a dedicated editing path. This is controller-level evidence, not a claim that the UI exposed every direct call. | + +### Leads not adopted as production defects + +- Changing `_splitKindFor()` globally to return headings would break correct + middle/end behavior. It still maps headings to paragraphs. The full matrix + explicitly preserves middle/end splitting and the existing empty-heading + end-split behavior in all three Markdown modes. +- The proposed stale-property-source failure did **not** reproduce through the + normal property publication → `rebaseCommittedSource` → nested structural edit + sequence. `rebaseCommittedSource` clears property-only edit records and binds + current source metadata. The passing quote/property regression is retained; + `_replaceBlockWithMany` was not broadly rewritten to fit that diagnosis. +- A zero visible-text length alone is not proof of an empty block. Semantic-only + paragraph/list and authored-paragraph controls explicitly prevent that shortcut. +- Blank-only paragraphs still intentionally disappear as distinct semantic + paragraphs when reparsed. The existing document tests remain unchanged. + +## Repair boundaries + +Only the existing WYSIWYG split/merge/input handlers, semantic-anchor restoration, +empty-task serializer and Markdown AST checkbox ownership are changed. New +fragment ownership is shared between physical and text-input +splits. The original owner receives its children and authored attributes; a newly +inserted ordinary paragraph does not inherit heading identity, collapsibility, +or a stale authored source record. Source spans/raw fragments are not copied onto +new structural blocks. Required whitespace/session metadata is still generated +through the existing helpers. + +An inserted hard break is still a hard break inside one block, not a paragraph +split. Explicit navigation, typing and clipboard routes remain the existing +production routes. No changes to the focus-restoration repair, editor architecture, +virtualization, native UI, save format, dependency pins or blank-paragraph policy +were introduced. + +Native validation also caught an implementation omission in the new empty- +paragraph removal branch: after Writerside publication the retained heading was +clean, so source patching retained the old blank gap. The final branch reuses +`_withoutSourceSpan(..., dirty: true)` to invalidate that structural boundary. +`repair-inverse-publication-before.log` fails before that correction; +`after-inverse-publication.log` passes 199 tests afterward. This is recorded as +validation of the repair, not as a pre-existing review finding. + +## Automated evidence + +The unmodified baseline's existing document/structured suites passed **203 tests** +(`baseline.log`). Initial new boundary tests failed 84 cases while the original +middle/end controls passed (`before-boundaries.log`). Corrected key-based widget +finders then independently reproduced the physical/selection/rejection failures +(`before-integration.log`); the early bad-finder runs are not keyboard evidence. + +The isolated original checkout ran the completed early regressions: **111 failed, +8 passed** (`before-original-checkout.log`). The complete first/later H1–H6 matrix +plus the identical-suffix cases then yielded **151 failures, 38 passing controls** +(`before-original-full-heading-matrix.log`). Original read-only toolbar commands +failed both cases (`before-original-readonly-toolbar.log`). These are intentional +failing-before results, not skips or relaxed assertions. + +Additional focused before logs include `before-input-rejection.log`, +`before-range-replacement.log`, `before-empty-task-serialization.log`, and +`before-identical-suffix.log`. The 711-test affected run passed in +`affected-final.log` before the final explicit-selection-context refinement; +the refinement's full targeted matrix passed **197 tests** in +`after-final-selection-context.log`. The workspace/reparse control passed in +`workspace-transient-redo.log`; the final full checks are recorded below. + +The nested-checkbox addition fails all 18 cases in the original archive +(`before-original-nested-checkboxes.log`). With that repair the complete affected +set passed **736 tests** (`affected-complete.log`). Native image setup then exposed +an invalid emphasis fixture: the mid-word delimiter was literal, not bold. The +final fixture asserts `plainText == 'abc'` and actual strong ranges before any +edit. Its ordinary-edit/newline/hard-break/blank-line cases fail all 13 original +tests (`before-original-valid-formatted-objects.log`); all 13 pass with wrapper +coalescing (`after-valid-formatted-objects.log`). Earlier passing image runs must +not be used as evidence of genuine enclosing-format preservation. + +Commands use `/home/albert/flutter-sdk/bin/flutter` and its adjacent `dart`: + +```sh +flutter pub get --enforce-lockfile +TMPDIR=/tmp flutter test --no-pub --concurrency=2 \ + test/src/busymark_document_test.dart \ + test/src/wysiwyg_structured_regression_test.dart \ + test/src/wysiwyg_clipboard_test.dart \ + test/src/writerside_authoring_test.dart \ + test/src/writerside_native_list_inspector_test.dart \ + test/src/writerside_inspector_session_test.dart --reporter expanded +TMPDIR=/tmp flutter test --no-pub --concurrency=1 test/src/app_smoke_test.dart \ + --name 'heading boundary survives workspace' --reporter expanded +``` + +The isolated original matrix command was: + +```sh +cd build/structural-baseline +TMPDIR=/tmp /home/albert/flutter-sdk/bin/flutter test --no-pub --concurrency=1 \ + test/src/wysiwyg_structured_regression_test.dart \ + test/src/wysiwyg_clipboard_test.dart \ + --name 'structural boundaries|identical suffix' --reporter expanded +``` + +## Native Linux acceptance + +`tools/structural_editing_smoke.dart` opens the production application and its +workspace, not a substitute editor. `tools/structural_editing_native.py` creates +an owned X11/Xvfb display and private XDG profile *before* starting a private +D-Bus broker. Input uses XTest against only the caller's PID-owned window; +profiles, documents and saved-source captures are disposable. The existing +owned-process supervisor retains shutdown/cleanup observations. This campaign +does not claim Wayland coverage. + +The original native exploratory sequence failed its Backspace inverse check +**after external undo/redo** (`native-x-initial`). A new production-workspace +widget measurement established that external history reparses source and has +already removed the transient blank paragraph (`workspace-transient-redo.log`). +There is no preceding paragraph left to remove. The native check was corrected +to test the direct inverse immediately, and to test source/heading restoration +separately across undo/redo. The failed attempt is retained; it was interrupted +through its owned supervisor, with no unresolved survivors. No production +change was made to force blank paragraphs to survive reparsing. + +Two subsequent attempts correctly rejected their Writerside-mode prerequisite: +the harness was calling `openPath(file)`, which selects a standalone workspace. +The final harness explicitly opens the project directory, then opens its topic +using the production `openActiveFile` workflow. No production mode-routing +change was made. Native close can reject a captured immutable buffer when a +session report replaces it during History flush; the harness records that +observation and retries only if ID, revision, saved bytes and clean state still +agree. `native-x-close/probe/markdown-close.json` records exactly that case, not +a source edit or an ignored protection. + +`native-x-inverse` verified the corrected Writerside inverse and saved heading +properties, then discovered the actual nested-checkbox source loss. After the +adapter repair `native-x-checkbox` passed both heading scenarios and all six +list scenarios, including subtree order, identities and checkbox counts. It +stopped at the invalid image fixture prerequisite; no image success is claimed +for that attempt. Every failed attempt and its ownership outcome is retained. + +### Final native result + +`native-x-formatted/probe/result.json` records **all 130 checks passed** across +nine scenarios: the minimal Markdown heading, the actual Writerside Markdown +project/topic, six unordered/ordered/task parent boundary cases (nonempty and +caption-cleared), and genuinely formatted embedded empty-alt images. + +Real GTK keyboard input exercises Enter, immediate Backspace, typing without +refocusing, Undo/Redo, default Shift+Enter, a configured F8 hard-break shortcut, +and Ctrl+S. A real native pointer click invokes Insert Blank Line. Focus and +caret assertions accompany editing. View transitions, actual saved bytes, +reopened semantic trees, subtree order, checkbox states, and image destinations/ +formatting are checked. The nine `*-saved.md` files are actual saved-source +captures; PNGs show rendered results and do not substitute for those checks. + +The final native app is the pinned official **debug** GTK build of the production +application with an acceptance driver. It exited naturally with status 0; +`native-x-formatted/ownership.json` has `ok: true`, `cleanupRequired: false`, and +no unresolved survivors. No natural-close success is claimed for failed attempts. +The final retained bundle is `build/structural-native-formatted-bundle/`. + +Run from the repository root, with the existing Node installation on PATH: + +```sh +TMPDIR=/tmp flutter build linux --debug --no-pub \ + -t tools/structural_editing_smoke.dart +cp -a build/linux/x64/debug/bundle build/structural-native-formatted-bundle +/usr/bin/python3 tools/structural_editing_native.py \ + build/structural-native-formatted-bundle/busymark NEW_OUTPUT_DIRECTORY +TMPDIR=/tmp flutter build linux --release --no-pub -t lib/main.dart +``` + +This verifies X11/Xvfb, not Wayland, Writerside XML native interaction, or a +separate uninstrumented release-desktop campaign. The normal release is built +separately, not left pointing at the driver. The native executable's RUNPATH is +`$ORIGIN/lib`; no `LD_LIBRARY_PATH`/`LD_PRELOAD` override was set. Its bundled +debug engine matches the SDK artifact byte-for-byte (SHA-256 +`7a5698608a3c79ad3f123a74bce8074339ffa1a83c896d73ae853c9f92a43cc3`). + +## Final verification + +- `affected-shipping.log`: **737 tests passed**, including all final parser, + publication-boundary and genuine-formatting cases, no new skips. +- `analyze-complete.log`: `flutter analyze --no-pub`, **no issues**, 44.7 seconds. +- `format-shipping.log`: formatter check over all **10 changed Dart files**, zero + changes. `python3 -m py_compile tools/structural_editing_native.py` succeeds; + `git diff --check` is clean. +- `owned-tooling-tests.log`: the existing process ownership/supervisor tests pass + **19 tests** without modifications. +- `linux-release-shipping.log`: the normal + `flutter build linux --release --no-pub -t lib/main.dart` succeeds. Node is the + already installed 24.13.0 executable under + `/home/albert/.cache/JetBrains/Air/node-24.13.0/node-v24.13.0-linux-x64/bin`; + no installation or version/pin change was made. + +The shipping `libapp.so` SHA-256 is +`654afa080a96db8445b8777af0a55ed6da22697475b9ece1a1e8af689f615c9b`. +Its official release engine and the pinned SDK artifact both have SHA-256 +`d57449a5c2ee39e77b9e3488f0703b165c67e0b3c554089f388db96484ae62f1`. + +The first broader run (`full-suite.log`, before the last parser/formatting +refinements) completed **5,013 passes, 62 existing skips and one failure**: +`general Markdown fixture finishes automatic rich spelling` exceeded its existing +deadline while still checking. The exact unchanged test passes in isolation +(`spelling-retry.log`, one pass in 15 seconds); no assertion/deadline was weakened. +The complete final-source rerun in `full-suite-shipping.log` passes **5,034 tests +with 62 existing skips and no failures**, in 15 minutes 9 seconds. The earlier +spelling deadline failure did not recur. No new regression was skipped. + +Final-source full-suite command: + +```sh +TMPDIR=/tmp flutter test --no-pub --concurrency=4 --reporter expanded +``` + +Final-source affected suites use the six-file command above. Additional checks: + +```sh +TMPDIR=/tmp flutter analyze --no-pub +python3 -m py_compile tools/structural_editing_native.py +python3 -m unittest tools/test_owned_processes.py tools/test_run_owned_fixture.py +git diff --check +``` + +The formatter command lists the four changed production Dart files, five changed +test files and `tools/structural_editing_smoke.dart`, using +`dart format --output=none --set-exit-if-changed`. SDK tracked files and +`pubspec.yaml`/`pubspec.lock` remain unchanged. Only this repair's files are +committed locally; nothing is pushed or published. + +Exact formatter command: + +```sh +/home/albert/flutter-sdk/bin/dart format --output=none --set-exit-if-changed \ + lib/src/editor/wysiwyg/wysiwyg_document_controller.dart \ + lib/src/editor/wysiwyg/wysiwyg_editor.dart \ + lib/src/markdown/busymark_markdown_serializer.dart \ + lib/src/markdown/markdown_ast_adapter.dart \ + test/src/app_smoke_test.dart test/src/busymark_document_test.dart \ + test/src/writerside_native_list_inspector_test.dart \ + test/src/wysiwyg_clipboard_test.dart \ + test/src/wysiwyg_structured_regression_test.dart \ + tools/structural_editing_smoke.dart +``` diff --git a/lib/src/app/busymark_design.dart b/lib/src/app/busymark_design.dart index d5365454..b447ea70 100644 --- a/lib/src/app/busymark_design.dart +++ b/lib/src/app/busymark_design.dart @@ -1227,7 +1227,9 @@ class BusyMarkHeaderPopupMenuButton extends StatefulWidget { final FutureOr>> Function(BuildContext context) itemBuilder; final ValueChanged onSelected; - final VoidCallback? onCanceled; + + /// Identifies this presentation's trigger subtree for focus-return policy. + final ValueChanged? onCanceled; final bool transparent; /// Uses the theme's physical button elevation. @@ -1325,7 +1327,7 @@ class _BusyMarkHeaderPopupMenuButtonState if (selection != null) { onSelected(selection); } else { - onCanceled?.call(); + onCanceled?.call(context); } } } finally { diff --git a/lib/src/app/busymark_dialogs.dart b/lib/src/app/busymark_dialogs.dart index 854b7e16..99188cd9 100644 --- a/lib/src/app/busymark_dialogs.dart +++ b/lib/src/app/busymark_dialogs.dart @@ -203,7 +203,7 @@ Future showBusyMarkModalDialog( Future _coordinateBusyMarkModal( BuildContext context, { required LinuxHeaderBarService? headerBarService, - required Future Function() showSurface, + required ({Future result, ModalRoute route}) Function() showSurface, }) async { final previousFocus = FocusManager.instance.primaryFocus; await acquireBusyMarkModalBarrier(headerBarService); @@ -211,17 +211,91 @@ Future _coordinateBusyMarkModal( await releaseBusyMarkModalBarrier(headerBarService); return null; } + ModalRoute? closingRoute; try { - return await showSurface(); + final surface = showSurface(); + closingRoute = surface.route; + return await surface.result; } finally { - await releaseBusyMarkModalBarrier(headerBarService); - if (previousFocus?.context != null && previousFocus!.canRequestFocus) { - previousFocus.requestFocus(); + final restoration = _ModalFocusRestoration(previousFocus, closingRoute); + try { + await releaseBusyMarkModalBarrier(headerBarService); + if (context.mounted && restoration.mayRestore) { + previousFocus!.requestFocus(); + } + } finally { + restoration.dispose(); + } + } +} + +/// Permission for this one closing surface, not a snapshot of primaryFocus. +/// A newer owner or lifecycle transition permanently supersedes its release. +class _ModalFocusRestoration with WidgetsBindingObserver { + _ModalFocusRestoration(this.previous, this.closingRoute) + : _previousScopes = {...?previous?.ancestors.whereType()} { + _current = _isResumed && _owns(FocusManager.instance.primaryFocus); + if (_current) { + _listening = true; + FocusManager.instance.addListener(_focusChanged); + WidgetsBinding.instance.addObserver(this); } } + + final FocusNode? previous; + final ModalRoute? closingRoute; + final Set _previousScopes; + bool _current = true; + bool _listening = false; + + bool get _isResumed => + WidgetsBinding.instance.lifecycleState == null || + WidgetsBinding.instance.lifecycleState == AppLifecycleState.resumed; + + bool _owns(FocusNode? node) => + node == null || + node == previous || + _previousScopes.contains(node) || + (closingRoute != null && + node.context?.mounted == true && + ModalRoute.of(node.context!) == closingRoute); + + void _focusChanged() { + if (previous?.context?.mounted != true || + !_owns(FocusManager.instance.primaryFocus)) { + _invalidate(); + } + } + + void _invalidate() { + _current = false; + dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state != AppLifecycleState.resumed) _invalidate(); + } + + bool get mayRestore { + // Honor a newer request even if FocusManager has not notified it yet. + FocusManager.instance.applyFocusChangesIfNeeded(); + return _current && + _isResumed && + previous?.context?.mounted == true && + previous!.canRequestFocus && + _owns(FocusManager.instance.primaryFocus); + } + + void dispose() { + if (!_listening) return; + _listening = false; + FocusManager.instance.removeListener(_focusChanged); + WidgetsBinding.instance.removeObserver(this); + } } -Future _showBusyMarkFlutterDialog( +({Future result, ModalRoute route}) _showBusyMarkFlutterDialog( BuildContext context, { required WidgetBuilder builder, Color? barrierColor, @@ -229,16 +303,15 @@ Future _showBusyMarkFlutterDialog( }) { final navigator = Navigator.of(context, rootNavigator: true); final themes = InheritedTheme.capture(from: context, to: navigator.context); - return navigator.push( - _BusyMarkDialogRoute( - context: context, - builder: builder, - themes: themes, - fixedBarrierColor: barrierColor, - initialBarrierColor: barrierColor ?? busyMarkModalBarrierColor(context), - barrierDismissible: barrierDismissible, - ), + final route = _BusyMarkDialogRoute( + context: context, + builder: builder, + themes: themes, + fixedBarrierColor: barrierColor, + initialBarrierColor: barrierColor ?? busyMarkModalBarrierColor(context), + barrierDismissible: barrierDismissible, ); + return (result: navigator.push(route), route: route); } class _BusyMarkDialogRoute extends DialogRoute { diff --git a/lib/src/editor/editor_focus_restoration.dart b/lib/src/editor/editor_focus_restoration.dart new file mode 100644 index 00000000..9dbd2200 --- /dev/null +++ b/lib/src/editor/editor_focus_restoration.dart @@ -0,0 +1,314 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; + +/// One editor's focus and virtualized editable lifetime, independent of the +/// viewport. Search fields and other editors do not participate in this scope. +class BusyMarkEditorFocusScope extends InheritedWidget { + const BusyMarkEditorFocusScope({ + super.key, + required super.child, + this.documentId, + }); + + final String? documentId; + + @override + bool updateShouldNotify(BusyMarkEditorFocusScope oldWidget) => false; +} + +/// Intercepts both halves of EditableText's deferred focus reveal: its direct +/// controller scroll, then showOnScreen through its ancestors. The latter ends +/// the operation, so no timer or frame count is involved. A newer editing value +/// or explicit input wins even when Flutter coalesces it with the focus callback. +class BusyMarkEditorFocusRestoration extends StatefulWidget { + const BusyMarkEditorFocusRestoration({ + super.key, + required this.focusNode, + required this.controller, + required this.builder, + this.scrollController, + this.readOnly = false, + }); + + final FocusNode focusNode; + final TextEditingController controller; + final BusyMarkCaretScrollController? scrollController; + final bool readOnly; + final Widget Function(BuildContext, ScrollController) builder; + + static final _fields = Expando<_EditorFocusRestorationState>(); + + /// For purposeful commands that request focus without changing the caret. + static void reveal(FocusNode node) => _fields[node]?._allowReveal(); + + /// Keep one pending editing target mounted without requesting focus. A + /// deferred selection handoff may outlive the viewport cache while inactive. + static VoidCallback? retainPendingTarget(FocusNode node) => + _fields[node]?._retainPendingTarget(); + + static bool ownsDocumentFocus(FocusNode node, String documentId) { + final field = _fields[node]; + return field?.mounted == true && + (field?._scope?.widget as BusyMarkEditorFocusScope?)?.documentId == + documentId; + } + + static bool keepsEditingWhileInactive(FocusNode node) => + WidgetsBinding.instance.lifecycleState != null && + WidgetsBinding.instance.lifecycleState != AppLifecycleState.resumed && + (_fields[node]?._retainEditingTarget ?? false); + + @override + State createState() => + _EditorFocusRestorationState(); +} + +class _EditorFocusRestorationState extends State + with AutomaticKeepAliveClientMixin { + late BusyMarkCaretScrollController _scrollController; + late TextEditingValue _lastValue; + bool _pendingFocusReveal = false; + late final bool Function() _suppressFocusReveal = _shouldSuppressFocusReveal; + bool _intentionalFocus = false; + bool _retainEditingTarget = false; + final _pendingTargetRetentions = {}; + InheritedElement? _scope; + + bool _shouldSuppressFocusReveal() => _pendingFocusReveal; + + @override + bool get wantKeepAlive => + _retainEditingTarget || _pendingTargetRetentions.isNotEmpty; + + VoidCallback _retainPendingTarget() { + final request = Object(); + _pendingTargetRetentions.add(request); + updateKeepAlive(); + return () { + if (_pendingTargetRetentions.remove(request) && mounted) { + updateKeepAlive(); + } + }; + } + + @override + void initState() { + super.initState(); + _attach(); + FocusManager.instance.addListener(_focusOwnerChanged); + FocusManager.instance.addEarlyKeyEventHandler(_keyEvent); + } + + void _attach() { + _lastValue = widget.controller.value; + _scrollController = + widget.scrollController ?? BusyMarkCaretScrollController(); + _scrollController.suppressFocusReveal = _suppressFocusReveal; + BusyMarkEditorFocusRestoration._fields[widget.focusNode] = this; + widget.focusNode.addListener(_focusChanged); + widget.controller.addListener(_valueChanged); + if (widget.focusNode.hasFocus) _retainEditingTarget = true; + } + + void _detach(BusyMarkEditorFocusRestoration field) { + field.focusNode.removeListener(_focusChanged); + field.controller.removeListener(_valueChanged); + BusyMarkEditorFocusRestoration._fields[field.focusNode] = null; + // A document-keyed replacement can attach its new field before this old + // state is disposed. Do not retire the new owner's shared-controller hook. + if (identical( + _scrollController.suppressFocusReveal, + _suppressFocusReveal, + )) { + _scrollController.suppressFocusReveal = null; + } + if (field.scrollController == null) _scrollController.dispose(); + _pendingFocusReveal = false; + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _scope = context + .getElementForInheritedWidgetOfExactType(); + } + + @override + void didUpdateWidget(BusyMarkEditorFocusRestoration oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.readOnly) _pendingFocusReveal = false; + if (oldWidget.focusNode != widget.focusNode || + oldWidget.controller != widget.controller || + oldWidget.scrollController != widget.scrollController) { + _detach(oldWidget); + _attach(); + } + } + + void _focusChanged() { + if (widget.focusNode.hasFocus) { + _pendingFocusReveal = !_intentionalFocus && !widget.readOnly; + _intentionalFocus = false; + _retainEditingTarget = true; + updateKeepAlive(); + } else { + _intentionalFocus = false; + } + } + + void _focusOwnerChanged() { + final owner = FocusManager.instance.primaryFocus; + final other = owner == null + ? null + : BusyMarkEditorFocusRestoration._fields[owner]; + if (_retainEditingTarget && + other != null && + other != this && + identical(other._scope, _scope)) { + // Keep only this editor's last editing target alive across application + // suspension, menus and dialogs. Do not retain every visited field. + _retainEditingTarget = false; + updateKeepAlive(); + } + } + + void _allowReveal() { + _pendingFocusReveal = false; + _intentionalFocus = !widget.focusNode.hasFocus; + } + + void _valueChanged() { + final value = widget.controller.value; + if (value.text != _lastValue.text || + value.selection != _lastValue.selection) { + _pendingFocusReveal = false; + } + // Composing-only platform lifecycle changes are not navigation. Never + // restore, fabricate or replay a composing range or a text-input message. + _lastValue = value; + } + + KeyEventResult _keyEvent(KeyEvent event) { + if (widget.focusNode.hasFocus && + (event is KeyDownEvent || event is KeyRepeatEvent) && + !{ + LogicalKeyboardKey.altLeft, + LogicalKeyboardKey.altRight, + LogicalKeyboardKey.controlLeft, + LogicalKeyboardKey.controlRight, + LogicalKeyboardKey.shiftLeft, + LogicalKeyboardKey.shiftRight, + LogicalKeyboardKey.metaLeft, + LogicalKeyboardKey.metaRight, + }.contains(event.logicalKey)) { + _allowReveal(); + } + return KeyEventResult.ignored; + } + + bool _consumeFocusReveal() { + final suppress = _pendingFocusReveal; + _pendingFocusReveal = false; + return suppress; + } + + @override + void dispose() { + FocusManager.instance.removeEarlyKeyEventHandler(_keyEvent); + FocusManager.instance.removeListener(_focusOwnerChanged); + _detach(widget); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + super.build(context); + return Listener( + onPointerDown: (_) => _allowReveal(), + child: _CaretRevealBoundary( + consume: _consumeFocusReveal, + child: widget.builder(context, _scrollController), + ), + ); + } +} + +class BusyMarkCaretScrollController extends ScrollController { + bool Function()? suppressFocusReveal; + + @override + ScrollPosition createScrollPosition( + ScrollPhysics physics, + ScrollContext context, + ScrollPosition? oldPosition, + ) => _CaretScrollPosition( + physics: physics, + context: context, + oldPosition: oldPosition, + initialPixels: initialScrollOffset, + keepScrollOffset: keepScrollOffset, + suppress: () => suppressFocusReveal?.call() ?? false, + ); +} + +class _CaretScrollPosition extends ScrollPositionWithSingleContext { + _CaretScrollPosition({ + required super.physics, + required super.context, + super.oldPosition, + super.initialPixels, + super.keepScrollOffset, + required this.suppress, + }); + final bool Function() suppress; + + @override + Future animateTo( + double to, { + required Duration duration, + required Curve curve, + }) { + if (suppress()) return Future.value(); + return super.animateTo(to, duration: duration, curve: curve); + } + + @override + void jumpTo(double value) { + if (!suppress()) super.jumpTo(value); + } +} + +class _CaretRevealBoundary extends SingleChildRenderObjectWidget { + const _CaretRevealBoundary({required this.consume, required super.child}); + final bool Function() consume; + @override + RenderObject createRenderObject(BuildContext context) => + _RenderCaretRevealBoundary(consume); + @override + void updateRenderObject( + BuildContext context, + _RenderCaretRevealBoundary renderObject, + ) => renderObject.consume = consume; +} + +class _RenderCaretRevealBoundary extends RenderProxyBox { + _RenderCaretRevealBoundary(this.consume); + bool Function() consume; + @override + void showOnScreen({ + RenderObject? descendant, + Rect? rect, + Duration duration = Duration.zero, + Curve curve = Curves.ease, + }) { + if (!consume()) { + super.showOnScreen( + descendant: descendant, + rect: rect, + duration: duration, + curve: curve, + ); + } + } +} diff --git a/lib/src/editor/source/source_editor.dart b/lib/src/editor/source/source_editor.dart index 622d47b4..a16d8832 100644 --- a/lib/src/editor/source/source_editor.dart +++ b/lib/src/editor/source/source_editor.dart @@ -2,6 +2,7 @@ import '../document_edit_history.dart'; import '../../app/command_shortcuts.dart'; import '../../app/busymark_search_field.dart'; import '../link_destination.dart'; +import '../editor_focus_restoration.dart'; import '../wysiwyg/writerside_editing_adapter.dart'; import '../../assets/document_media_context.dart'; import 'dart:async'; @@ -186,7 +187,7 @@ class BusyMarkSourceEditorState extends State { Object _editingEpoch = Object(); late TextEditingValue _lastObservedEditingValue; late FocusNode _focusNode; - late final ScrollController _scrollController; + late final BusyMarkCaretScrollController _scrollController; late final ScrollController _horizontalScrollController; late UndoHistoryController _undoController; final _sourceEditorKey = GlobalKey(); @@ -208,12 +209,14 @@ class BusyMarkSourceEditorState extends State { var _autocompleteSelection = 0; String _lastPath = ''; bool _horizontalCaretScheduled = false; + TextEditingValue _lastControllerValue = TextEditingValue.empty; bool _contentShrinkCorrectionScheduled = false; Timer? _searchDebounce; Timer? _foldRefreshDebounce; _ContinuousSourceEdit? _continuousSourceEdit; _SourceSessionSnapshot? _lastPublishedSession; bool _sessionPublicationScheduled = false; + Object? _sessionRestoreRequest; late final DocumentUndoGrouping _undoGrouping; late DocumentEditBase _editBase; DocumentEditBase get publicationBase => _editBase; @@ -358,7 +361,7 @@ class BusyMarkSourceEditorState extends State { _historyInsertionTarget = _SourceClipboardInsertionTarget(this); widget.clipboardInsertionRegistry?.register(_historyInsertionTarget); _focusNode = FocusNode(onKeyEvent: _handleKeyEvent); - _scrollController = ScrollController(); + _scrollController = BusyMarkCaretScrollController(); _horizontalScrollController = ScrollController(); _undoController = UndoHistoryController(); _lastPath = widget.documentId ?? widget.filePath ?? ''; @@ -366,6 +369,7 @@ class BusyMarkSourceEditorState extends State { _restoreSessionState(); _lastPublishedSession = _sourceSessionSnapshot(); _syncSearchOptions(); + _lastControllerValue = _controller.value; _lastObservedEditingValue = _fullEditingValue(); _controller.addListener(_handleControllerActivity); _scrollController.addListener(_scheduleSessionPublication); @@ -482,6 +486,15 @@ class BusyMarkSourceEditorState extends State { } void _handleControllerActivity() { + final visibleValue = _controller.value; + final lifecycle = WidgetsBinding.instance.lifecycleState; + final passiveCompositionChange = + visibleValue.text == _lastControllerValue.text && + visibleValue.selection == _lastControllerValue.selection && + visibleValue.composing != _lastControllerValue.composing && + (!_focusNode.hasFocus || + (lifecycle != null && lifecycle != AppLifecycleState.resumed)); + _lastControllerValue = visibleValue; final value = _fullEditingValue(); if (value != _lastObservedEditingValue) { _lastObservedEditingValue = value; @@ -502,7 +515,9 @@ class BusyMarkSourceEditorState extends State { _cancelSharedSearchNavigation(); } _scheduleSessionPublication(); - if (widget.wordWrap || _horizontalCaretScheduled) { + if (passiveCompositionChange || + widget.wordWrap || + _horizontalCaretScheduled) { return; } _horizontalCaretScheduled = true; @@ -554,6 +569,7 @@ class BusyMarkSourceEditorState extends State { /// Reveals a full-document offset, including a destination inside a fold. void scrollToOffset(int offset, {bool updateSelection = true}) { + BusyMarkEditorFocusRestoration.reveal(_focusNode); final textOffset = offset.clamp(0, _controller.fullText.length); final line = '\n'.allMatches(_controller.fullText.substring(0, textOffset)).length + @@ -581,6 +597,7 @@ class BusyMarkSourceEditorState extends State { required int startOffset, required int endOffset, }) { + BusyMarkEditorFocusRestoration.reveal(_focusNode); _cancelSharedSearchNavigation(); _unfoldSourceRange(startOffset, endOffset); final start = startOffset.clamp(0, _controller.fullText.length).toInt(); @@ -1074,127 +1091,146 @@ class BusyMarkSourceEditorState extends State { }, child: DefaultTextHeightBehavior( textHeightBehavior: sourceTextHeightBehavior, - child: DocumentUndoActions( - onUndo: widget.onUndo == null - ? null - : () => _performDocumentHistory(redo: false), - onRedo: widget.onRedo == null - ? null - : () => _performDocumentHistory(redo: true), - child: TextField( + child: BusyMarkEditorFocusScope( + documentId: _lastPath, + child: BusyMarkEditorFocusRestoration( readOnly: DocumentReadOnlyScope.of(context), - controller: _controller, - undoController: _undoController, focusNode: _focusNode, + controller: _controller, scrollController: _scrollController, - textDirection: TextDirection.ltr, - keyboardType: TextInputType.multiline, - autocorrect: false, - enableSuggestions: false, - smartDashesType: SmartDashesType.disabled, - smartQuotesType: SmartQuotesType.disabled, - maxLines: null, - expands: true, - textAlignVertical: TextAlignVertical.top, - style: _sourceTextStyle, - strutStyle: sourceStrutStyle, - selectionHeightStyle: BusyMarkDocumentTextGeometry - .sourceSelectionHeightStyle, - selectionWidthStyle: BusyMarkDocumentTextGeometry - .selectionWidthStyle, - cursorColor: colors.foreground.withValues( - alpha: BusyMarkAlpha.sourceCursor, - ), - cursorHeight: - widget.editorFontSize * - BusyMarkTypography.sourceCursorHeightScale, - cursorWidth: BusyMarkStroke.sourceCursor, - decoration: const InputDecoration( - isCollapsed: true, - filled: false, - fillColor: BusyMarkLinuxPalette.transparent, - hoverColor: BusyMarkLinuxPalette.transparent, - focusColor: BusyMarkLinuxPalette.transparent, - border: InputBorder.none, - enabledBorder: InputBorder.none, - focusedBorder: InputBorder.none, - contentPadding: BusyMarkInsets.sourceEditor, - ), - contextMenuBuilder: (context, editableTextState) => - buildBusyMarkEditorTextContextMenu( - context, - editableTextState, - refineWithAiLabel: - context.l10n.aiRefineWithAi, - readSpellingItems: - widget.readSpellingMenuItems == null - ? null - : (visibleOffset) => - widget.readSpellingMenuItems!( + builder: (context, caretScrollController) => DocumentUndoActions( + onUndo: widget.onUndo == null + ? null + : () => _performDocumentHistory(redo: false), + onRedo: widget.onRedo == null + ? null + : () => _performDocumentHistory(redo: true), + child: TextField( + readOnly: DocumentReadOnlyScope.of(context), + controller: _controller, + undoController: _undoController, + focusNode: _focusNode, + scrollController: caretScrollController, + textDirection: TextDirection.ltr, + keyboardType: TextInputType.multiline, + autocorrect: false, + enableSuggestions: false, + smartDashesType: SmartDashesType.disabled, + smartQuotesType: SmartQuotesType.disabled, + maxLines: null, + expands: true, + textAlignVertical: TextAlignVertical.top, + style: _sourceTextStyle, + strutStyle: sourceStrutStyle, + selectionHeightStyle: + BusyMarkDocumentTextGeometry + .sourceSelectionHeightStyle, + selectionWidthStyle: + BusyMarkDocumentTextGeometry + .selectionWidthStyle, + cursorColor: colors.foreground.withValues( + alpha: BusyMarkAlpha.sourceCursor, + ), + cursorHeight: + widget.editorFontSize * + BusyMarkTypography.sourceCursorHeightScale, + cursorWidth: BusyMarkStroke.sourceCursor, + decoration: const InputDecoration( + isCollapsed: true, + filled: false, + fillColor: BusyMarkLinuxPalette.transparent, + hoverColor: BusyMarkLinuxPalette.transparent, + focusColor: BusyMarkLinuxPalette.transparent, + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + contentPadding: BusyMarkInsets.sourceEditor, + ), + contextMenuBuilder: + ( + context, + editableTextState, + ) => buildBusyMarkEditorTextContextMenu( + context, + editableTextState, + refineWithAiLabel: + context.l10n.aiRefineWithAi, + readSpellingItems: + widget.readSpellingMenuItems == null + ? null + : ( + visibleOffset, + ) => widget.readSpellingMenuItems!( _controller .visibleOffsetToFullOffset( visibleOffset, ), ), - onCheckSpelling: widget.onCheckSpelling, - additionalItems: [ - if (widget.onSymbolAction != null) - for (final action - in SourceSymbolAction.values) - BusyMarkPopupMenuItem( - value: () => widget.onSymbolAction!( - action, - _controller - .fullSelection - .extentOffset, - ), - label: switch (action) { - SourceSymbolAction.declaration => - context.l10n.goToDeclaration, - SourceSymbolAction.usages => - context.l10n.findUsages, - SourceSymbolAction.rename => - context.l10n.rename, - }, - shortcut: - (BusyMarkCommandRegistryScope.read( - context, - ) ?? - BusyMarkCommandCatalog - .metadata)[sourceSymbolCommands[action]!] - ?.shortcut - ?.label, - gtkAccelerator: - (BusyMarkCommandRegistryScope.read( - context, - ) ?? - BusyMarkCommandCatalog - .metadata)[sourceSymbolCommands[action]!] - ?.shortcut - ?.gtkAccelerator, + onCheckSpelling: widget.onCheckSpelling, + additionalItems: [ + if (widget.onSymbolAction != null) + for (final action + in SourceSymbolAction.values) + BusyMarkPopupMenuItem( + value: () => + widget.onSymbolAction!( + action, + _controller + .fullSelection + .extentOffset, + ), + label: switch (action) { + SourceSymbolAction + .declaration => + context.l10n.goToDeclaration, + SourceSymbolAction.usages => + context.l10n.findUsages, + SourceSymbolAction.rename => + context.l10n.rename, + }, + shortcut: + (BusyMarkCommandRegistryScope.read( + context, + ) ?? + BusyMarkCommandCatalog + .metadata)[sourceSymbolCommands[action]!] + ?.shortcut + ?.label, + gtkAccelerator: + (BusyMarkCommandRegistryScope.read( + context, + ) ?? + BusyMarkCommandCatalog + .metadata)[sourceSymbolCommands[action]!] + ?.shortcut + ?.gtkAccelerator, + ), + ], + onRefineWithAi: widget.onAiEdit == null + ? null + : () => unawaited(_runAiEdit()), + onCopy: () => unawaited( + _copyOrCutSource(cut: false), + ), + onCut: () => unawaited( + _copyOrCutSource(cut: true), + ), + onPaste: () => unawaited( + _pasteFromSystemClipboard( + BusyMarkPasteMode.normal, ), - ], - onRefineWithAi: widget.onAiEdit == null - ? null - : () => unawaited(_runAiEdit()), - onCopy: () => - unawaited(_copyOrCutSource(cut: false)), - onCut: () => - unawaited(_copyOrCutSource(cut: true)), - onPaste: () => unawaited( - _pasteFromSystemClipboard( - BusyMarkPasteMode.normal, - ), - ), - onPastePlainText: () => unawaited( - _pasteFromSystemClipboard( - BusyMarkPasteMode.plainText, + ), + onPastePlainText: () => unawaited( + _pasteFromSystemClipboard( + BusyMarkPasteMode.plainText, + ), + ), + readPasteAvailability: + _readContextMenuPasteAvailability, ), - ), - readPasteAvailability: - _readContextMenuPasteAvailability, - ), - onChanged: (_) => _handleSourceChanged(), + onChanged: (_) => _handleSourceChanged(), + ), + ), ), ), ), @@ -1750,6 +1786,7 @@ class BusyMarkSourceEditorState extends State { }); return; } + BusyMarkEditorFocusRestoration.reveal(_focusNode); if (match.hidden) { _unfoldSourceRange(match.fullStart, match.fullEnd); } @@ -2135,6 +2172,8 @@ class BusyMarkSourceEditorState extends State { } void _restoreSessionState() { + final request = Object(); + _sessionRestoreRequest = request; final validKeys = {for (final region in _foldRegions) region.key}; _foldedRegionKeys ..clear() @@ -2151,14 +2190,24 @@ class BusyMarkSourceEditorState extends State { .toInt(), ); } + final documentId = _lastPath; + final controller = _controller; + final value = controller.value; + final source = controller.fullText; + final offset = widget.initialScrollOffset; WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted || !_scrollController.hasClients) { + if (!mounted || + !identical(request, _sessionRestoreRequest) || + documentId != _lastPath || + !identical(controller, _controller) || + controller.fullText != source || + controller.selection != value.selection || + !_scrollController.hasClients) { return; } + _sessionRestoreRequest = null; _scrollController.jumpTo( - widget.initialScrollOffset - .clamp(0, _scrollController.position.maxScrollExtent) - .toDouble(), + offset.clamp(0, _scrollController.position.maxScrollExtent).toDouble(), ); }); } @@ -2180,6 +2229,11 @@ class BusyMarkSourceEditorState extends State { } void _scheduleSessionPublication() { + if (_scrollController.hasClients && + _scrollController.position.userScrollDirection != + ScrollDirection.idle) { + _sessionRestoreRequest = null; + } if (_suppressSessionPublication || _sessionPublicationScheduled) { return; } @@ -2233,6 +2287,7 @@ class BusyMarkSourceEditorState extends State { TextEditingValue value, { _SourceEditOrigin origin = _SourceEditOrigin.command, }) { + BusyMarkEditorFocusRestoration.reveal(_focusNode); _controller.setFullEditingValue(value); _focusNode.requestFocus(); _handleSourceChanged(origin: origin); @@ -3199,6 +3254,7 @@ class BusyMarkSourceEditorState extends State { _focusNode = FocusNode(onKeyEvent: _handleKeyEvent); _editingEpoch = Object(); _lastObservedEditingValue = _fullEditingValue(); + _lastControllerValue = _controller.value; _controller.addListener(_handleControllerActivity); _resetUndoHistory(); WidgetsBinding.instance.addPostFrameCallback((_) { diff --git a/lib/src/editor/wysiwyg/wysiwyg_block_widgets.dart b/lib/src/editor/wysiwyg/wysiwyg_block_widgets.dart index 8f46fe69..c136599d 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_block_widgets.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_block_widgets.dart @@ -23,6 +23,7 @@ import '../../visualization/visualization_card.dart'; import '../../visualization/visualization_models.dart'; import '../../writerside/writerside_video.dart'; import '../editor_text_context_menu.dart'; +import '../editor_focus_restoration.dart'; import 'wysiwyg_inline_controller.dart'; import 'wysiwyg_visualization_navigation.dart'; @@ -484,7 +485,9 @@ class BusyMarkWysiwygBlockField extends StatelessWidget { ); } final renderedMath = - busyMarkWysiwygBlockContainsMath(block) && !focusNode.hasFocus + busyMarkWysiwygBlockContainsMath(block) && + !focusNode.hasFocus && + !BusyMarkEditorFocusRestoration.keepsEditingWhileInactive(focusNode) ? Focus( focusNode: focusNode, child: GestureDetector( @@ -540,60 +543,68 @@ class BusyMarkWysiwygBlockField extends StatelessWidget { selectionRange: selectionRange, child: KeyedSubtree( key: spellingEditableKey, - child: TextField( + child: BusyMarkEditorFocusRestoration( readOnly: DocumentReadOnlyScope.of(context), - key: ValueKey( - 'wysiwyg-field-$documentFilePath-${block.id}', - ), - controller: controller, - undoController: undoController, focusNode: focusNode, - enableInteractiveSelection: - !documentSelectionDragging, - maxLines: null, - minLines: 1, - textDirection: textDirection, - style: style, - cursorWidth: BusyMarkDocumentTextGeometry - .editableCursorWidth, - selectionHeightStyle: - BusyMarkDocumentTextGeometry - .selectionHeightStyle, - selectionWidthStyle: - BusyMarkDocumentTextGeometry - .selectionWidthStyle, - decoration: const InputDecoration( - isCollapsed: true, - border: InputBorder.none, - enabledBorder: InputBorder.none, - focusedBorder: InputBorder.none, - filled: false, - hoverColor: BusyMarkLinuxPalette.transparent, - contentPadding: EdgeInsets.zero, + controller: controller, + builder: (context, caretScrollController) => TextField( + readOnly: DocumentReadOnlyScope.of(context), + key: ValueKey( + 'wysiwyg-field-$documentFilePath-${block.id}', + ), + controller: controller, + undoController: undoController, + focusNode: focusNode, + scrollController: caretScrollController, + enableInteractiveSelection: + !documentSelectionDragging, + maxLines: null, + minLines: 1, + textDirection: textDirection, + style: style, + cursorWidth: BusyMarkDocumentTextGeometry + .editableCursorWidth, + selectionHeightStyle: + BusyMarkDocumentTextGeometry + .selectionHeightStyle, + selectionWidthStyle: + BusyMarkDocumentTextGeometry + .selectionWidthStyle, + decoration: const InputDecoration( + isCollapsed: true, + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + filled: false, + hoverColor: + BusyMarkLinuxPalette.transparent, + contentPadding: EdgeInsets.zero, + ), + contextMenuBuilder: documentSelectionActive + ? (context, editableTextState) => + const SizedBox.shrink() + : ( + context, + editableTextState, + ) => buildBusyMarkEditorTextContextMenu( + context, + editableTextState, + refineWithAiLabel: + context.l10n.aiRefineWithAi, + onRefineWithAi: onRefineWithAi, + onCut: onCut, + onCopy: onCopy, + onPaste: onPaste, + onPastePlainText: onPastePlainText, + readPasteAvailability: + readPasteAvailability, + onCopyPlainText: onCopyPlainText, + readSpellingItems: spellingMenuReader, + onCheckSpelling: onCheckSpelling, + ), + onTap: onFocused, + onChanged: onChanged, ), - contextMenuBuilder: documentSelectionActive - ? (context, editableTextState) => - const SizedBox.shrink() - : (context, editableTextState) => - buildBusyMarkEditorTextContextMenu( - context, - editableTextState, - refineWithAiLabel: - context.l10n.aiRefineWithAi, - onRefineWithAi: onRefineWithAi, - onCut: onCut, - onCopy: onCopy, - onPaste: onPaste, - onPastePlainText: onPastePlainText, - readPasteAvailability: - readPasteAvailability, - onCopyPlainText: onCopyPlainText, - readSpellingItems: - spellingMenuReader, - onCheckSpelling: onCheckSpelling, - ), - onTap: onFocused, - onChanged: onChanged, ), ), ), @@ -699,7 +710,12 @@ class BusyMarkWysiwygBlockField extends StatelessWidget { final level = int.tryParse(block.attributes['level'] ?? '') ?? 0; return switch (block.kind) { BusyBlockKind.heading => busyMarkDocumentHeadingTextStyle(context, level), - _ when busyMarkWysiwygBlockContainsMath(block) && focusNode.hasFocus => + _ + when busyMarkWysiwygBlockContainsMath(block) && + (focusNode.hasFocus || + BusyMarkEditorFocusRestoration.keepsEditingWhileInactive( + focusNode, + )) => busyMarkDocumentCodeTextStyle(context), BusyBlockKind.codeBlock => busyMarkDocumentCodeTextStyle(context), _ => busyMarkDocumentBodyTextStyle(context), @@ -1780,6 +1796,10 @@ class _TableBlockEditorState extends State<_TableBlockEditor> { layoutInfo.childPaintTransform, Offset.zero & layoutInfo.childSize, ); + // A retained editable can be outside the sliver's layout cache. Its paint + // transform is then zero: the input remains alive, but it has no overlay + // anchor until the table returns to the painted portion of the viewport. + if (!tableRect.isFinite) return const SizedBox.shrink(); final controlSize = BusyMarkSizes.iconButton; final rowRect = active == null || active.row >= _rowAnchorContexts.length ? null @@ -2245,7 +2265,10 @@ class _TableCellEditorState extends State<_TableCellEditor> { } if (mounted) { setState(() { - if (!_focusNode.hasFocus) { + if (!_focusNode.hasFocus && + !BusyMarkEditorFocusRestoration.keepsEditingWhileInactive( + _focusNode, + )) { _sourceEditing = false; } }); @@ -2265,7 +2288,8 @@ class _TableCellEditorState extends State<_TableCellEditor> { } if (busyMarkWysiwygBlockContainsMath(cell) && !_sourceEditing && - !_focusNode.hasFocus) { + !_focusNode.hasFocus && + !BusyMarkEditorFocusRestoration.keepsEditingWhileInactive(_focusNode)) { return Padding( padding: BusyMarkInsets.documentTableCell, child: GestureDetector( @@ -2307,69 +2331,81 @@ class _TableCellEditorState extends State<_TableCellEditor> { end: _controller.text.length, ) : null, - child: TextField( + child: BusyMarkEditorFocusRestoration( readOnly: DocumentReadOnlyScope.of(context), - 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, + controller: _controller, + builder: (context, caretScrollController) => TextField( + readOnly: DocumentReadOnlyScope.of(context), + key: ValueKey(cell.id), + controller: _controller, + focusNode: _focusNode, + // A table cell owns its caret/range, including when a menu + // or dialog returns focus. Explicit Select All is handled + // by the editor's cell/document keyboard commands. + selectAllOnFocus: false, + scrollController: caretScrollController, + 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, ), - 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); - } - }, ), ), ), diff --git a/lib/src/editor/wysiwyg/wysiwyg_document_controller.dart b/lib/src/editor/wysiwyg/wysiwyg_document_controller.dart index 0159079d..c4606b42 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_document_controller.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_document_controller.dart @@ -2573,36 +2573,43 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { ); } + bool hasParagraphBreakEdit(String blockId, String text) { + final block = blockById(blockId); + return block != null && + _shouldSplitNewlines(block.kind) && + _paragraphBreakEdit( + _normalizeLineEndings(block.plainText), + _normalizeLineEndings(text), + ).breaks.isNotEmpty; + } + BusyWysiwygTextSplitResult? replaceBlockTextWithParagraphs( String blockId, String text, int cursorOffset, { bool preserveTextWhitespace = false, bool allowListExit = true, + bool replaceWholeBlock = false, }) { final block = blockById(blockId); - if (block == null || !_shouldSplitNewlines(block.kind)) { + if (block == null || + !_canStructurallyEdit(blockId) || + !_shouldSplitNewlines(block.kind)) { return null; } final normalizedText = _normalizeLineEndings(text); final previousText = _normalizeLineEndings(block.plainText); - final editStart = _commonPrefixLength(previousText, normalizedText); - final editSuffix = _commonSuffixLength( - previousText, - normalizedText, - editStart, - ); - final editEnd = normalizedText.length - editSuffix; - final paragraphBreaks = [ - for (final match in '\n'.allMatches(normalizedText)) - if (match.start >= editStart && match.start < editEnd) match, - ]; + final edit = _paragraphBreakEdit(previousText, normalizedText); + final paragraphBreaks = edit.breaks; if (paragraphBreaks.isEmpty) { return null; } if (allowListExit && _isListItemKind(block.kind) && - normalizedText.trim().isEmpty) { + normalizedText.trim().isEmpty && + _canExitEmptyListItem( + _blockWithEditedText(block, normalizedText.replaceAll('\n', '')), + )) { if (_wouldDiscardNativeListOwner(blockId)) return null; _replaceBlockWithParagraph(blockId); return BusyWysiwygTextSplitResult(blockId: blockId, offset: 0); @@ -2620,59 +2627,46 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { ? 0 : paragraphBreaks[focusIndex - 1].end; final focusOffset = safeOffset - focusStart; - final ranges = _remapRangesForTextEdit( - oldText: block.plainText, - newText: normalizedText, - ranges: busyInlineStyleRanges(block.inlines), + // Rebuild once, including semantic objects, then partition the actual + // inline tree. Text/style ranges alone cannot represent empty-alt images. + final retainRight = _retainsOriginalAfterLeadingBreak( + block, + paragraphBreaks.first.start, + normalizedText.substring(paragraphBreaks.first.end), ); - final replacements = []; + var remaining = _blockWithEditedText( + block, + normalizedText, + leadingAnchorsAfterEdit: retainRight, + ).inlines; + final parts = >[]; var partStart = 0; - for (var index = 0; index <= paragraphBreaks.length; index++) { - final partEnd = index < paragraphBreaks.length - ? paragraphBreaks[index].start - : normalizedText.length; - final part = normalizedText.substring(partStart, partEnd); - final inlines = _inlinesFromStyleRangesWithHardBreaks( - part, - _styleRangesForSlice(ranges, partStart, partEnd), + for (final lineBreak in paragraphBreaks) { + final length = lineBreak.start - partStart; + final partition = _partitionInlinesForReplacement( + remaining, + length, + length + 1, ); - if (index == 0) { - replacements.add( - block.copyWith( - inlines: inlines, - attributes: _attributesForText( - _sourceBackedSplitAttributes(block.attributes), - block.kind, - part, - preserveTextWhitespace: preserveTextWhitespace, - ), - preserveRaw: false, - dirty: true, - ), - ); - } else { - final splitKind = _splitKindFor(block.kind); - replacements.add( - BusyBlock( - id: _nextGeneratedBlockId(_newBlockPrefixFor(block.kind)), - kind: splitKind, - inlines: inlines, - attributes: _attributesForText( - _sourceBackedSplitAttributes( - _splitAttributesFor(block, splitKind, orderedOffset: index), - ), - splitKind, - part, - preserveTextWhitespace: preserveTextWhitespace, - ), - dirty: true, - ), - ); - } - if (index < paragraphBreaks.length) { - partStart = paragraphBreaks[index].end; - } - } + parts.add(partition.before); + remaining = partition.after; + partStart = lineBreak.end; + } + parts.add(remaining); + // A whole textual heading replaced by newlines is a replacement, not an + // insertion before an untouched heading. Prefix replacements retain it. + final replacedHeading = + block.kind == BusyBlockKind.heading && + (replaceWholeBlock || + previousText.isNotEmpty && edit.prefix == 0 && edit.suffix == 0); + final replacements = _blocksForSplit( + replacedHeading + ? block.copyWith(kind: BusyBlockKind.paragraph, attributes: const {}) + : block, + parts, + retainRight: retainRight && !replacedHeading, + preserveTextWhitespace: preserveTextWhitespace, + ); _document = _document.copyWith( blocks: _replaceBlockWithMany(_document.blocks, blockId, replacements), ); @@ -3026,6 +3020,7 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { } void _insertHardBreaks(String blockId, int offset, {required int count}) { + if (!_canStructurallyEdit(blockId)) return; _replaceBlock(blockId, (block) { final text = block.plainText; final safeOffset = offset.clamp(0, text.length).toInt(); @@ -3037,7 +3032,16 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { replacementLength: count, ); return block.copyWith( - inlines: _inlinesFromStyleRangesWithHardBreaks(nextText, ranges), + inlines: _restoreSemanticInlineAnchors( + originalInlines: block.inlines, + oldText: text, + newText: nextText, + insertion: (offset: safeOffset, length: count), + rebuiltInlines: _inlinesFromStyleRangesWithHardBreaks( + nextText, + ranges, + ), + ), preserveRaw: false, dirty: true, ); @@ -3827,66 +3831,144 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { bool recognizeUrls = false, }) { final block = blockById(blockId); - if (block == null) { + if (block == null || !_canStructurallyEdit(blockId)) { return null; } final text = block.plainText; final safeOffset = offset.clamp(0, text.length).toInt(); - final leftText = text.substring(0, safeOffset); final rightText = text.substring(safeOffset); final inlinePartition = _partitionInlinesForReplacement( block.inlines, safeOffset, safeOffset, ); - final nextBlockId = _nextGeneratedBlockId(_newBlockPrefixFor(block.kind)); - final nextKind = _splitKindFor(block.kind); - final currentBlock = BusyBlock( - id: block.id, - kind: block.kind, - inlines: inlinePartition.before.isEmpty - ? _textInlines('') - : recognizeUrls && _supportsBareUrlRecognition(block) - ? _recognizeBareUrls(inlinePartition.before, mode: _document.mode) - : inlinePartition.before, - children: block.children, - attributes: _attributesForText( - _sourceBackedSplitAttributes(block.attributes), - block.kind, - leftText, - ), - dirty: true, - ); - final nextBlock = BusyBlock( - id: nextBlockId, - kind: nextKind, - inlines: inlinePartition.after.isEmpty - ? _textInlines('') - : inlinePartition.after, - attributes: _attributesForText( - _sourceBackedSplitAttributes( - _splitAttributesFor(block, nextKind, orderedOffset: 1), - ), - nextKind, - rightText, - ), - dirty: true, + final retainRight = _retainsOriginalAfterLeadingBreak( + block, + safeOffset, + rightText, ); + final before = retainRight ? _textInlines('') : inlinePartition.before; + final replacements = _blocksForSplit(block, [ + recognizeUrls && _supportsBareUrlRecognition(block) + ? _recognizeBareUrls(before, mode: _document.mode) + : before, + retainRight ? block.inlines : inlinePartition.after, + ], retainRight: retainRight); _document = _document.copyWith( - blocks: _replaceBlockWithMany(_document.blocks, blockId, [ - currentBlock, - nextBlock, - ]), + blocks: _replaceBlockWithMany(_document.blocks, blockId, replacements), ); notifyListeners(); - return nextBlockId; + return replacements.last.id; } - BusyWysiwygTextSplitResult? applyEnterAt(String blockId, int offset) { + /// The retained fragment owns the original editing identity, authored + /// attributes and subtree. A leading split inserts a sibling *before* an + /// existing heading/list parent; other splits retain the original on the left. + List _blocksForSplit( + BusyBlock block, + List> parts, { + required bool retainRight, + bool preserveTextWhitespace = false, + }) { + var retained = 0; + if (retainRight) { + retained = 1; + while (retained < parts.length - 1 && + parts[retained].every(_isEmptyTextInline)) { + retained++; + } + } + return [ + for (var index = 0; index < parts.length; index++) + BusyBlock( + id: index == retained + ? block.id + : _nextGeneratedBlockId(_newBlockPrefixFor(block.kind)), + kind: index == retained ? block.kind : _splitKindFor(block.kind), + inlines: parts[index].isEmpty ? _textInlines('') : parts[index], + children: index == retained ? block.children : const [], + attributes: _attributesForText( + _sourceBackedSplitAttributes( + index == retained + ? block.kind == BusyBlockKind.orderedListItem + ? { + ...block.attributes, + 'marker': _incrementOrderedMarker( + block.attributes['marker'], + index, + ), + } + : block.attributes + : _splitAttributesFor( + block, + _splitKindFor(block.kind), + orderedOffset: index, + ), + ), + index == retained ? block.kind : _splitKindFor(block.kind), + parts[index].map((inline) => inline.plainText).join(), + preserveTextWhitespace: preserveTextWhitespace, + ), + dirty: true, + ), + ]; + } + + bool _canStructurallyEdit(String blockId) { + final path = _actualElementPath(_document.blocks, blockId); + return path.isNotEmpty && + !path.any( + (b) => + b.isSourceProtected || + b.isSourceOnly || + b.isGenerated || + b.preserveRaw || + b.kind == BusyBlockKind.table, + ) && + !_isReadOnlySelectionEndpoint(path.last); + } + + BusyWysiwygTextSplitResult? applyEnterAt( + String blockId, + int offset, { + int? selectionEnd, + }) { final block = blockById(blockId); - if (block == null) { + if (block == null || !_canStructurallyEdit(blockId)) { return null; } + final start = offset.clamp(0, block.plainText.length).toInt(); + final end = (selectionEnd ?? start) + .clamp(start, block.plainText.length) + .toInt(); + if (end > start) { + // Stage both operations: rejection of list exit must not publish the + // selection deletion, change the field, or create an undo entry. + final staged = BusyMarkWysiwygDocumentController(document: _document); + try { + staged.updateBlockText( + blockId, + block.plainText.replaceRange(start, end, ''), + ); + if (block.kind == BusyBlockKind.heading && + start == 0 && + end == block.plainText.length) { + staged._replaceBlock( + blockId, + (b) => + b.copyWith(kind: BusyBlockKind.paragraph, attributes: const {}), + ); + } + final result = staged.applyEnterAt(blockId, start); + if (result != null) { + _document = staged.document; + notifyListeners(); + } + return result; + } finally { + staged.dispose(); + } + } if (_document.isXmlTopic && busyMarkIsWritersideContainer(block) && WritersideEditingAdapter.titleElements.contains( @@ -3923,7 +4005,7 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { offset: safeOffset + 1, ); } - if (_isListItemKind(block.kind) && block.plainText.trim().isEmpty) { + if (_isListItemKind(block.kind) && _canExitEmptyListItem(block)) { if (_wouldDiscardNativeListOwner(blockId)) return null; _replaceBlockWithParagraph(blockId); return BusyWysiwygTextSplitResult(blockId: blockId, offset: 0); @@ -3976,16 +4058,16 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { } BusyWysiwygTextSplitResult? applyBackspaceAtStart(String blockId) { - final path = elementPath(blockId); + if (!_canStructurallyEdit(blockId)) return null; + final path = _actualElementPath(_document.blocks, blockId); final owner = path.length < 2 ? null : path[path.length - 2]; - final native = owner?.attributes['busymark-list-form'] == 'markdown'; - final blocks = native ? owner!.children : _document.blocks; + final blocks = owner?.children ?? _document.blocks; void publish(List members) { _document = _document.copyWith( - blocks: native + blocks: owner != null ? _replaceInBlocks( _document.blocks, - owner!.id, + owner.id, (b) => b.copyWith(children: members, dirty: true), ) : members, @@ -3998,6 +4080,16 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { } final previous = blocks[index - 1]; final current = blocks[index]; + if (!_canStructurallyEdit(previous.id)) return null; + if (_isSafelyRemovableParagraph(previous)) { + // Removing a sibling invalidates the source gap even when the retained + // heading itself is unchanged. A committed Writerside heading is clean; + // retaining its old span would let source patching keep the deleted gap. + final retained = _withoutSourceSpan(current, dirty: true); + publish([...blocks.take(index - 1), retained, ...blocks.skip(index + 1)]); + notifyListeners(); + return BusyWysiwygTextSplitResult(blockId: current.id, offset: 0); + } if (!_isMergeableTextBlock(previous) || !_isMergeableTextBlock(current)) { return null; } @@ -4676,8 +4768,10 @@ class BusyMarkWysiwygDocumentController extends ChangeNotifier { final firstBlock = blockById(firstBlockId); final selectedIds = removedBlockIds.toSet(); final destinationBlockFullySelected = - replacementScope == BusyWysiwygReplacementScope.documentRange && firstBlock != null && + (replacementScope == BusyWysiwygReplacementScope.documentRange || + firstBlock.kind == BusyBlockKind.heading && + replacementText.contains(RegExp(r'[\r\n]'))) && selectedIds.contains(firstBlockId) && firstStartOffset <= 0 && (firstBlockId != lastBlockId || @@ -5380,6 +5474,11 @@ Map _splitAttributesFor( BusyBlockKind kind, { int orderedOffset = 0, }) { + // New paragraphs do not own any of their heading's authored properties or + // projection/source bindings. The retained heading keeps the complete map. + if (block.kind == BusyBlockKind.heading && kind == BusyBlockKind.paragraph) { + return {}; + } final attributes = {...block.attributes}; if (kind != BusyBlockKind.heading) { attributes.remove('level'); @@ -5943,6 +6042,40 @@ BusyBlockKind _splitKindFor(BusyBlockKind kind) { }; } +bool _isEmptyTextInline(BusyInline inline) => + inline.kind == BusyInlineKind.text && + inline.text.isEmpty && + inline.attributes.isEmpty && + inline.children.every(_isEmptyTextInline); + +bool _retainsOriginalAfterLeadingBreak( + BusyBlock block, + int offset, + String rightText, +) => + offset == 0 && + (block.kind == BusyBlockKind.heading || _isListItemKind(block.kind)) && + (block.plainText.isNotEmpty && rightText.isNotEmpty || + block.children.isNotEmpty || + busyInlineSemanticAnchors(block.inlines).isNotEmpty); + +bool _canExitEmptyListItem(BusyBlock block) => + block.plainText.trim().isEmpty && + block.children.isEmpty && + block.inlines.every( + (inline) => + inline.kind == BusyInlineKind.text && + inline.attributes.isEmpty && + inline.children.isEmpty, + ) && + WritersideEditingAdapter.sourceAttributes(block).isEmpty; + +bool _isSafelyRemovableParagraph(BusyBlock block) => + block.kind == BusyBlockKind.paragraph && + block.children.isEmpty && + block.inlines.every(_isEmptyTextInline) && + WritersideEditingAdapter.sourceAttributes(block).isEmpty; + String _newBlockPrefixFor(BusyBlockKind kind) { return switch (kind) { BusyBlockKind.unorderedListItem => 'unordered-list-item', @@ -6287,6 +6420,7 @@ BusyBlock _blockWithEditedText( Iterable activeInlineKinds = const [], bool preserveTextWhitespace = false, bool normalizePastedFormatting = false, + bool leadingAnchorsAfterEdit = false, }) { final oldText = block.plainText; final oldRanges = busyInlineStyleRanges(block.inlines); @@ -6311,6 +6445,7 @@ BusyBlock _blockWithEditedText( oldText: oldText, newText: nextText, rebuiltInlines: rebuiltInlines, + leadingAnchorsAfterEdit: leadingAnchorsAfterEdit, ); if (normalizePastedFormatting) { final prefix = _commonPrefixLength(oldText, nextText); @@ -6338,24 +6473,56 @@ String _normalizeLineEndings(String text) { return text.replaceAll('\r\n', '\n').replaceAll('\r', '\n'); } +({int prefix, int suffix, List breaks}) _paragraphBreakEdit( + String oldText, + String newText, +) { + final prefix = _commonPrefixLength(oldText, newText); + final suffix = _commonSuffixLength(oldText, newText, prefix); + return ( + prefix: prefix, + suffix: suffix, + breaks: [ + for (final match in '\n'.allMatches(newText)) + if (match.start >= prefix && match.start < newText.length - suffix) + match, + ], + ); +} + List _restoreSemanticInlineAnchors({ required List originalInlines, required String oldText, required String newText, required List rebuiltInlines, + bool leadingAnchorsAfterEdit = false, + ({int offset, int length})? insertion, }) { final anchors = busyInlineSemanticAnchors(originalInlines); if (anchors.isEmpty) { return rebuiltInlines; } - final prefix = _commonPrefixLength(oldText, newText); - final suffix = _commonSuffixLength(oldText, newText, prefix); - final oldEditEnd = oldText.length - suffix; - final newEditEnd = newText.length - suffix; - final delta = newText.length - oldText.length; + final int prefix; + final int oldEditEnd; + final int newEditEnd; + final int delta; + if (insertion != null) { + // Repeated characters (especially existing hard breaks) make a text diff + // ambiguous. Commands with a known insertion must retain that position. + prefix = oldEditEnd = insertion.offset; + newEditEnd = insertion.offset + insertion.length; + delta = insertion.length; + } else { + prefix = _commonPrefixLength(oldText, newText); + final suffix = _commonSuffixLength(oldText, newText, prefix); + oldEditEnd = oldText.length - suffix; + newEditEnd = newText.length - suffix; + delta = newText.length - oldText.length; + } var result = rebuiltInlines; for (final anchor in anchors) { final offset = switch (anchor.offset) { + 0 when leadingAnchorsAfterEdit && prefix == 0 => newEditEnd, final value when value <= prefix => value, final value when value >= oldEditEnd => value + delta, _ => newEditEnd, @@ -6363,7 +6530,10 @@ List _restoreSemanticInlineAnchors({ final partition = _partitionInlinesForReplacement(result, offset, offset); result = [...partition.before, anchor.inline, ...partition.after]; } - return result; + // Reinsertion splits enclosing wrappers. Rejoin equivalent adjacent runs so + // serialization does not emit invalid repeated emphasis delimiters around + // zero-width objects (and thereby lose their formatting on reparse). + return _mergeAdjacentInlineStyles(result); } BusyBlock _withoutSourceSpan(BusyBlock block, {required bool dirty}) { diff --git a/lib/src/editor/wysiwyg/wysiwyg_editor.dart b/lib/src/editor/wysiwyg/wysiwyg_editor.dart index a562cc3e..d80e3cf4 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_editor.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_editor.dart @@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart' show listEquals; import '../../writerside/writerside_effective_properties.dart'; import '../../writerside/writerside_model.dart'; import '../link_destination.dart'; +import '../editor_focus_restoration.dart'; import '../../assets/document_media_context.dart'; import 'dart:async'; import 'dart:convert'; @@ -239,6 +240,7 @@ class BusyMarkWysiwygEditorState extends State { final _textUndoControllers = {}; final _focusNodes = {}; final _tableCellControllers = {}; + final _modelTextSelections = {}; final _tableCellFocusNodes = {}; final _tableCellKeys = {}; final _spellingEditableKeys = {}; @@ -249,6 +251,21 @@ class BusyMarkWysiwygEditorState extends State { final _redoStack = []; final _itemScrollController = ItemScrollController(); final _itemPositionsListener = ItemPositionsListener.create(); + ScrollPosition? get _documentScrollPosition { + for (final key in _blockKeys.values) { + final context = key.currentContext; + if (context == null) continue; + final scrollable = Scrollable.maybeOf(context, axis: Axis.vertical); + if (scrollable != null) return scrollable.position; + } + return null; + } + + String? _initialEditingBlockId; + double? _initialEditingOffset; + double? _viewportWidth; + String? _viewportSource; + String? _viewportSourceHash; List<({int itemIndex, DocumentOutlineHeading heading})> _viewportOutlineStops = const []; List _viewportBlockIds = const []; @@ -330,6 +347,11 @@ class BusyMarkWysiwygEditorState extends State { double? _viewportHeight; late final _WysiwygClipboardInsertionTarget _historyInsertionTarget; _PendingTextFocus? _pendingTextFocus; + Object? _focusRestoreRequest; + Object? _sessionRestoreRequest; + FocusNode? _lastNonRootFocus; + VoidCallback? _retrySelectionExtentFocus; + VoidCallback? _cancelSelectionExtentFocus; String get _documentId => widget.documentId ?? widget.document.filePath; @@ -400,7 +422,7 @@ class BusyMarkWysiwygEditorState extends State { } } - void restoreSpellingFocus() => _restoreEditingFocusAfterFrame(); + void restoreSpellingFocus() => _restoreEditingFocusAfterFrame(passive: true); /// Applies one already-checked occurrence as a single structured edit. /// @@ -604,6 +626,8 @@ class BusyMarkWysiwygEditorState extends State { debugLabel: 'BusyMark WYSIWYG document selection', onKeyEvent: _handleDocumentSelectionKeyEvent, ); + _lastNonRootFocus = FocusManager.instance.primaryFocus; + FocusManager.instance.addListener(_handleSelectionFocusOwnerChanged); _documentController = BusyMarkWysiwygDocumentController( document: widget.document, )..addListener(_handleDocumentControllerChanged); @@ -714,6 +738,7 @@ class BusyMarkWysiwygEditorState extends State { @override void dispose() { + FocusManager.instance.removeListener(_handleSelectionFocusOwnerChanged); _releaseSelectionDrag(rebuild: false); _pendingTextFocus = null; widget.clipboardInsertionRegistry?.unregister(_historyInsertionTarget); @@ -765,6 +790,7 @@ class BusyMarkWysiwygEditorState extends State { focusNode.dispose(); } _textControllers.clear(); + _modelTextSelections.clear(); _tableCellControllers.clear(); _textUndoControllers.clear(); _focusNodes.clear(); @@ -1031,6 +1057,7 @@ class BusyMarkWysiwygEditorState extends State { _viewportHeight = constraints.maxHeight.isFinite ? constraints.maxHeight : null; + _viewportWidth = constraints.maxWidth; final documentLayout = _documentLayout; final showProperties = _documentController.document.isWriterside && @@ -1047,6 +1074,8 @@ class BusyMarkWysiwygEditorState extends State { children: [ Positioned.fill( child: Listener( + onPointerSignal: (_) => _cancelSessionRestore(), + onPointerPanZoomStart: (_) => _cancelSessionRestore(), onPointerDown: _handleDocumentSelectionContextPointerDown, onPointerUp: _handleDocumentSelectionContextPointerUp, onPointerCancel: @@ -1058,22 +1087,40 @@ class BusyMarkWysiwygEditorState extends State { _clearBlockSelection(); _focusActiveOrFirstBlock(); }, - child: Focus( - focusNode: _selectionFocusNode, - child: ScrollablePositionedList.builder( - key: const ValueKey('wysiwyg-document-scroll'), - itemScrollController: _itemScrollController, - itemPositionsListener: _itemPositionsListener, - padding: documentLayout.scrollPadding, - itemCount: renderEntries.length, - itemBuilder: (context, index) => _buildRenderEntry( - context, - renderEntries[index], - documentLayout: documentLayout, - first: index == 0, - selectedBlockIds: selectedBlockIds, - selectionRangesByBlockId: - selectionRangesByBlockId, + child: BusyMarkEditorFocusScope( + documentId: _documentId, + child: Focus( + focusNode: _selectionFocusNode, + child: ScrollablePositionedList.builder( + key: const ValueKey('wysiwyg-document-scroll'), + itemScrollController: _itemScrollController, + itemPositionsListener: _itemPositionsListener, + padding: documentLayout.scrollPadding, + itemCount: renderEntries.length, + initialScrollIndex: math.max( + 0, + _initialEditingOffset != null && + (_initialEditingOffset! < 0 || + _initialEditingOffset! > + constraints.maxHeight) + ? renderEntries.indexWhere( + (entry) => _renderEntryContainsBlock( + entry, + _initialEditingBlockId ?? '', + ), + ) + : 0, + ), + itemBuilder: (context, index) => + _buildRenderEntry( + context, + renderEntries[index], + documentLayout: documentLayout, + first: index == 0, + selectedBlockIds: selectedBlockIds, + selectionRangesByBlockId: + selectionRangesByBlockId, + ), ), ), ), @@ -1098,10 +1145,10 @@ class BusyMarkWysiwygEditorState extends State { onUndo: () => _traverseDocumentHistory(redo: false), onRedo: () => _traverseDocumentHistory(redo: true), child: _DocumentSelectionTextInput( - key: ObjectKey(selection), - isCurrent: _documentSelectionInputGuard( - selection, - ), + key: + GlobalObjectKey< + _DocumentSelectionTextInputState + >(selection), onKeyEvent: _handleDocumentSelectionKeyEvent, onEditingValue: _documentSelectionInputHandler( selection, @@ -1128,9 +1175,12 @@ class BusyMarkWysiwygEditorState extends State { onPlacementChanged: widget.onToolbarPlacementChanged, onDirectionChanged: widget.onToolbarDirectionChanged, child: BusyMarkWysiwygToolbar( - onMenuCanceled: () { + onMenuCanceled: (menuContext) { if (_isDialogTargetCurrent(authoringGuard)) { - _restoreEditingFocusAfterFrame(); + _restoreEditingFocusAfterFrame( + passive: true, + menuContext: menuContext, + ); } }, axis: widget.toolbarDirection._axis, @@ -2334,6 +2384,7 @@ class BusyMarkWysiwygEditorState extends State { if (!ids.contains(id)) { _inputSelections.remove(id); _textControllers.remove(id)?.dispose(); + _modelTextSelections.remove(id); } } for (final entry in _tableCellControllers.entries) { @@ -2472,6 +2523,7 @@ class BusyMarkWysiwygEditorState extends State { String targetId, TextEditingController controller, ) { + _rememberModelTextSelection(targetId, controller); if (_documentController.document.isWriterside && _activeBlockId == targetId && mounted) { @@ -2503,6 +2555,17 @@ class BusyMarkWysiwygEditorState extends State { } } + void _rememberModelTextSelection( + String targetId, + TextEditingController controller, + ) { + if (identical(_textControllers[targetId], controller) && + controller.text == _documentController.blockText(targetId) && + controller.selection.isValid) { + _modelTextSelections[targetId] = controller.selection; + } + } + FocusNode _tableCellFocusNodeFor(String tableBlockId, BusyBlock cell) { final focusNode = _tableCellFocusNodes.putIfAbsent( cell.id, @@ -2999,7 +3062,10 @@ class BusyMarkWysiwygEditorState extends State { return; } final controller = _textControllers[blockId]; - _recordTextUndoSnapshot(blockId, _inputSelections[blockId]); + final undoSnapshot = widget.useExternalUndoHistory + ? null + : _historySnapshot(); + _reportTextHistorySession(blockId, _inputSelections[blockId]); final undoGroup = origin == _WysiwygTextEditOrigin.paste ? null : _undoGroupForTextEdit( @@ -3019,6 +3085,7 @@ class BusyMarkWysiwygEditorState extends State { if (currentBlock != null && busyMarkWysiwygBlockContainsMath(currentBlock)) { _documentController.updateMathSource(blockId, value); + if (undoSnapshot != null) _recordUndoSnapshot(undoSnapshot); _emitMarkdown(undoGroup: undoGroup, reportEditingSession: true); return; } @@ -3028,18 +3095,40 @@ class BusyMarkWysiwygEditorState extends State { final pendingInlineKinds = _pendingInlineKindsByBlockId[blockId]?.toList(growable: false) ?? const []; + final paragraphBreakEdit = _documentController.hasParagraphBreakEdit( + blockId, + value, + ); + final previousSelection = _modelTextSelections[blockId]; final splitResult = _documentController.replaceBlockTextWithParagraphs( blockId, value, offset, preserveTextWhitespace: origin == _WysiwygTextEditOrigin.paste, allowListExit: origin != _WysiwygTextEditOrigin.paste, + replaceWholeBlock: + previousSelection != null && + !previousSelection.isCollapsed && + previousSelection.start == 0 && + previousSelection.end == oldText.length, ); if (splitResult != null) { + if (undoSnapshot != null) _recordUndoSnapshot(undoSnapshot); _emitMarkdown(undoGroup: undoGroup); _focusBlockAfterFrame(splitResult.blockId, offset: splitResult.offset); return; } + if (paragraphBreakEdit) { + // Null means rejection here, not an ordinary single-block edit. Do not + // let the platform text-input fallback bypass structural protection. + if (controller != null) { + controller.value = TextEditingValue( + text: oldText, + selection: previousSelection ?? TextSelection.collapsed(offset: 0), + ); + } + return; + } _documentController.updateBlockText( blockId, value, @@ -3047,6 +3136,8 @@ class BusyMarkWysiwygEditorState extends State { preserveTextWhitespace: origin == _WysiwygTextEditOrigin.paste, normalizePastedFormatting: origin == _WysiwygTextEditOrigin.paste, ); + if (undoSnapshot != null) _recordUndoSnapshot(undoSnapshot); + if (controller != null) _rememberModelTextSelection(blockId, controller); if (value.isNotEmpty) { _pendingInlineKindsByBlockId.remove(blockId); } @@ -3137,7 +3228,10 @@ class BusyMarkWysiwygEditorState extends State { return; } final previousSelection = _inputSelections[cellId]; - _recordTextUndoSnapshot(cellId, previousSelection); + final undoSnapshot = widget.useExternalUndoHistory + ? null + : _historySnapshot(); + _reportTextHistorySession(cellId, previousSelection); if (markdownSource) { _documentController.updateTableCellMarkdownSource( tableBlockId, @@ -3147,6 +3241,7 @@ class BusyMarkWysiwygEditorState extends State { } else { _documentController.updateTableCellText(tableBlockId, cellId, accepted); } + if (undoSnapshot != null) _recordUndoSnapshot(undoSnapshot); _emitMarkdown( reportEditingSession: true, undoGroup: _undoGroupForTextEdit( @@ -3306,14 +3401,17 @@ class BusyMarkWysiwygEditorState extends State { } _initialFocusScheduled = true; final generation = _documentGeneration; + final request = _sessionRestoreRequest; WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted || generation != _documentGeneration) { + if (!mounted || + generation != _documentGeneration || + !identical(request, _sessionRestoreRequest)) { return; } final firstPlainBlock = _focusableBlocks() .where((block) => !busyMarkWysiwygBlockContainsMath(block)) .firstOrNull; - if (firstPlainBlock == null) { + if (firstPlainBlock == null || !_mayRestoreEditingFocus()) { _inspectorRestorePending = false; _scheduleSessionReport(); return; @@ -3325,10 +3423,54 @@ class BusyMarkWysiwygEditorState extends State { }); } + void _cancelSessionRestore() { + _sessionRestoreRequest = null; + _inspectorRestorePending = false; + } + + bool _mayRestoreEditingFocus({BuildContext? menuContext}) { + final lifecycle = WidgetsBinding.instance.lifecycleState; + if (lifecycle != null && lifecycle != AppLifecycleState.resumed) { + return false; + } + final owner = FocusManager.instance.primaryFocus; + final ownerContext = owner?.context; + if (ownerContext == null || !ownerContext.mounted) return true; + final ownerRoute = ModalRoute.of(ownerContext); + // Root/view scopes have no route or competing control; a recreated editor + // may initialize focus there. A dialog's route scope is a different owner. + if (owner is FocusScopeNode && ownerRoute == null) return true; + if (ownerRoute != ModalRoute.of(context)) return false; + if (menuContext?.mounted == true) { + // Returning from this menu's own trigger is ordinary cancellation, not + // permission for a different toolbar control to return focus as well. + var isTrigger = identical(ownerContext, menuContext); + if (!isTrigger) { + ownerContext.visitAncestorElements((element) { + isTrigger = identical(element, menuContext); + return !isTrigger; + }); + } + if (isTrigger) return true; + } + return owner is FocusScopeNode || + owner == _selectionFocusNode || + (owner != null && + BusyMarkEditorFocusRestoration.ownsDocumentFocus( + owner, + _documentId, + )) || + ownerContext + .findAncestorStateOfType<_DocumentSelectionTextInputState>() != + null; + } + void _scheduleSessionRestore( WysiwygEditorSessionState session, { bool restoreSelectionImmediately = false, }) { + final request = Object(); + _sessionRestoreRequest = request; _inspectorRestorePending = true; if (session.activeBlockId == null && session.anchorBlockId == null && @@ -3385,6 +3527,9 @@ class BusyMarkWysiwygEditorState extends State { extentOffset: session.extentOffset, viewportBlockId: locate(session.viewportBlockId), viewportAlignment: session.viewportAlignment, + viewportOffset: session.viewportOffset, + viewportEditingOffset: session.viewportEditingOffset, + viewportLayoutSignature: session.viewportLayoutSignature, ); } bool applySelection() { @@ -3459,9 +3604,10 @@ class BusyMarkWysiwygEditorState extends State { ); if (restoreSelectionImmediately) { final applied = applySelection(); - if (_hasBlockSelection) { + final restoreFocus = _mayRestoreEditingFocus(); + if (restoreFocus && _hasBlockSelection) { _selectionFocusNode.requestFocus(); - } else { + } else if (restoreFocus) { final focus = _activeCellId == null ? _focusNodes[_activeBlockId] : _tableCellFocusNodes[_activeCellId]; @@ -3470,14 +3616,38 @@ class BusyMarkWysiwygEditorState extends State { if (applied) immediatelyRestored = _captureSessionState(); } _initialFocusScheduled = true; + _initialEditingBlockId = session.activeBlockId; + _initialEditingOffset = session.viewportEditingOffset; final generation = _documentGeneration; final documentId = _documentId; + final navigation = widget.scrollRequest; + final gesture = _selectionGestureGeneration; + final focusOwner = FocusManager.instance.primaryFocus; + final selectionAtRequest = _activeTextTarget()?.controller.selection; WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted || + !identical(request, _sessionRestoreRequest) || generation != _documentGeneration || - documentId != _documentId) { + documentId != _documentId || + navigation != widget.scrollRequest || + (!restoreSelectionImmediately && + gesture != _selectionGestureGeneration) || + (!restoreSelectionImmediately && + selectionAtRequest != null && + selectionAtRequest != + _activeTextTarget()?.controller.selection) || + (FocusManager.instance.primaryFocus != focusOwner && + FocusManager.instance.primaryFocus is! FocusScopeNode && + FocusManager.instance.primaryFocus?.context?.mounted == true && + FocusManager.instance.primaryFocus?.context + ?.findAncestorStateOfType() != + this)) { + if (mounted && identical(request, _sessionRestoreRequest)) { + _cancelSessionRestore(); + } return; } + _sessionRestoreRequest = null; if (immediatelyRestored != null && selectionKey(immediatelyRestored) != selectionKey(_captureSessionState())) { @@ -3488,8 +3658,23 @@ class BusyMarkWysiwygEditorState extends State { } if (immediatelyRestored == null) applySelection(); final viewportBlockId = session.viewportBlockId; - if (viewportBlockId != null) { - var alignment = session.viewportAlignment.clamp(0.0, 1.0).toDouble(); + final savedEditingOffset = session.viewportEditingOffset; + final editingOffset = _editingBlockViewportOffset(); + if (savedEditingOffset != null && + savedEditingOffset.isFinite && + editingOffset != null && + session.viewportLayoutSignature == _viewportLayoutSignature()) { + final position = _documentScrollPosition; + if (position != null) { + position.jumpTo(position.pixels + editingOffset - savedEditingOffset); + } + } else if (viewportBlockId != null) { + var alignment = session.viewportAlignment.isFinite + ? session.viewportAlignment.clamp(0.0, 1.0).toDouble() + : 0.0; + var offset = session.viewportOffset.isFinite + ? math.max(0.0, session.viewportOffset) + : 0.0; final firstViewportBlockId = _editorRenderEntries( _documentController.document.blocks, ).firstOrNull?.block.id; @@ -3499,31 +3684,38 @@ class BusyMarkWysiwygEditorState extends State { viewportHeight > 0) { // ItemPosition includes the leading scroll padding for item zero, // while jumpTo applies that padding again during restoration. - alignment = math.max( - 0, - alignment - _documentLayout.scrollPadding.top / viewportHeight, - ); + final leading = alignment * viewportHeight - offset; + final padding = _documentLayout.scrollPadding.top; + alignment = math.max(0, (leading - padding) / viewportHeight); + offset = math.max(0, padding - leading); + } + if (_jumpToBlock(viewportBlockId, alignment: alignment) && offset > 0) { + // jumpTo's alignment stays in its documented range. Apply the pixel + // offset to the actual document ScrollPosition in the same operation, + // before the next layout/paint; never jump then compensate in a later + // frame. Layout clamps against the current document's real bounds. + _documentScrollPosition?.jumpTo(offset); } - _jumpToBlock(viewportBlockId, alignment: alignment); } final activeCellId = _activeCellId; + final restoreFocus = _mayRestoreEditingFocus(); 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(); + if (restoreFocus) _selectionFocusNode.requestFocus(); _activeBlockId = _documentSelection!.extent.blockId; _activeCellId = null; - _scheduleDocumentSelectionExtentFocus(); + if (restoreFocus) _scheduleDocumentSelectionExtentFocus(); _scheduleSessionReport(); - } else if (activeCellId != null) { + } else if (restoreFocus && activeCellId != null) { final controller = _tableCellControllers[activeCellId]; final focusNode = _tableCellFocusNodes[activeCellId]; if (controller != null && focusNode != null) { focusNode.requestFocus(); } - } else { - _focusActiveOrFirstBlock(); + } else if (restoreFocus) { + _focusActiveOrFirstBlock(reveal: false); } if (mounted) { setState(() {}); @@ -3635,9 +3827,8 @@ class BusyMarkWysiwygEditorState extends State { report(targetDocumentId, session); } - void _recordTextUndoSnapshot(String targetId, TextSelection? selection) { + void _reportTextHistorySession(String targetId, TextSelection? selection) { if (!widget.useExternalUndoHistory) { - _recordUndoSnapshot(); return; } if (selection?.isValid != true || @@ -3661,6 +3852,9 @@ class BusyMarkWysiwygEditorState extends State { extentOffset: selection.extentOffset, viewportBlockId: session.viewportBlockId, viewportAlignment: session.viewportAlignment, + viewportOffset: session.viewportOffset, + viewportEditingOffset: session.viewportEditingOffset, + viewportLayoutSignature: session.viewportLayoutSignature, sourceSignature: session.sourceSignature, sourceBindings: session.sourceBindings, ), @@ -3695,6 +3889,82 @@ class BusyMarkWysiwygEditorState extends State { return _sessionSourceSignature; } + double? _editingBlockViewportOffset() { + final block = _blockKeys[_activeBlockId]?.currentContext + ?.findRenderObject(); + final viewport = _propertyHoverViewportKey.currentContext + ?.findRenderObject(); + if (block is! RenderBox || + !block.hasSize || + viewport is! RenderBox || + !viewport.hasSize) { + return null; + } + final painted = block.localToGlobal(Offset.zero, ancestor: viewport).dy; + if (painted.isFinite) return painted; + // Kept-alive sliver children outside the layout cache have a zero paint + // transform. Use the viewport's supported logical reveal coordinates, not + // that non-invertible transform, to retain their content position. + final documentViewport = RenderAbstractViewport.maybeOf(block); + final position = _documentScrollPosition; + final viewportBox = documentViewport is RenderBox + ? documentViewport as RenderBox + : null; + if (documentViewport == null || viewportBox == null || position == null) { + return null; + } + final logical = + viewportBox.localToGlobal(Offset.zero, ancestor: viewport).dy + + documentViewport.getOffsetToReveal(block, 0).offset - + position.pixels; + return logical.isFinite ? logical : null; + } + + String _viewportLayoutSignature() { + final source = + _documentController.document.source ?? _documentController.markdown; + if (_viewportSource != source) { + _viewportSource = source; + _viewportSourceHash = sha256.convert(utf8.encode(source)).toString(); + } + final layout = _documentLayout; + final style = busyMarkDocumentBodyTextStyle(context); + final scaler = MediaQuery.textScalerOf(context); + final fontGeometry = [ + style.fontFamily, + style.fontFamilyFallback, + style.fontSize, + style.fontWeight?.value, + style.fontStyle?.index, + style.letterSpacing, + style.wordSpacing, + style.height, + style.textBaseline?.index, + style.leadingDistribution?.index, + style.locale?.toLanguageTag(), + [ + for (final feature in style.fontFeatures ?? const []) + [feature.feature, feature.value], + ], + [ + for (final variation in style.fontVariations ?? const []) + [variation.axis, variation.value], + ], + scaler.scale(style.fontSize ?? 14), + for (var level = 1; level <= 6; level++) + scaler.scale( + busyMarkDocumentHeadingTextStyle(context, level).fontSize!, + ), + ]; + return sha256 + .convert( + utf8.encode( + '$_viewportSourceHash|$_viewportWidth|$_viewportHeight|${layout.maxContentWidth}|${layout.minimumInsets}|${MediaQuery.devicePixelRatioOf(context)}|${Directionality.of(context)}|${jsonEncode(fontGeometry)}', + ), + ) + .toString(); + } + WysiwygEditorSessionState _captureSessionState() { String? anchorBlockId; String? extentBlockId; @@ -3729,6 +3999,7 @@ class BusyMarkWysiwygEditorState extends State { } String? viewportBlockId; var viewportAlignment = 0.0; + var viewportOffset = 0.0; final positions = _itemPositionsListener.itemPositions.value .where( @@ -3741,6 +4012,10 @@ class BusyMarkWysiwygEditorState extends State { positions.first.index < _viewportBlockIds.length) { viewportBlockId = _viewportBlockIds[positions.first.index]; viewportAlignment = positions.first.itemLeadingEdge.clamp(0.0, 1.0); + viewportOffset = math.max( + 0, + -positions.first.itemLeadingEdge * (_viewportHeight ?? 0), + ); } return WysiwygEditorSessionState( activeBlockId: _activeBlockId, @@ -3751,6 +4026,9 @@ class BusyMarkWysiwygEditorState extends State { extentOffset: extentOffset, viewportBlockId: viewportBlockId, viewportAlignment: viewportAlignment, + viewportOffset: viewportOffset, + viewportEditingOffset: _editingBlockViewportOffset(), + viewportLayoutSignature: _viewportLayoutSignature(), sourceSignature: _writersideSessionSourceSignature(), sourceBindings: { if (_documentController.document.isWriterside) @@ -3888,6 +4166,7 @@ class BusyMarkWysiwygEditorState extends State { ); if (matchStart >= 0) { _activeBlockId = target.id; + BusyMarkEditorFocusRestoration.reveal(_focusNodeFor(target)); _focusNodeFor(target).requestFocus(); controller.selection = TextSelection( baseOffset: matchStart, @@ -4101,6 +4380,9 @@ class BusyMarkWysiwygEditorState extends State { _setActiveTableCell(target.outerBlockId, cellId); final controller = _tableCellControllers[cellId]; final focusNode = _tableCellFocusNodes[cellId]; + if (focusNode != null) { + BusyMarkEditorFocusRestoration.reveal(focusNode); + } focusNode?.requestFocus(); if (controller != null) { controller.selection = TextSelection( @@ -4127,6 +4409,7 @@ class BusyMarkWysiwygEditorState extends State { final controller = _textControllers[block.id]; if (controller != null && _focusNodes[block.id] != null) { _setActiveBlock(block.id); + BusyMarkEditorFocusRestoration.reveal(_focusNodes[block.id]!); _focusNodes[block.id]!.requestFocus(); controller.selection = TextSelection( baseOffset: target.visibleStart @@ -4217,7 +4500,10 @@ class BusyMarkWysiwygEditorState extends State { return null; } - void _focusActiveOrFirstBlock({int? initialSelectionOffset}) { + void _focusActiveOrFirstBlock({ + int? initialSelectionOffset, + bool reveal = true, + }) { final blocks = _focusableBlocks(); final activeBlock = _activeBlockId == null ? null @@ -4261,6 +4547,7 @@ class BusyMarkWysiwygEditorState extends State { _activeBlockId = blockId; _activeCellId = null; if (!focusNode.hasFocus) { + if (reveal) BusyMarkEditorFocusRestoration.reveal(focusNode); focusNode.requestFocus(); } final selection = controller.selection; @@ -4279,6 +4566,15 @@ class BusyMarkWysiwygEditorState extends State { } final keyboard = HardwareKeyboard.instance; final key = event.logicalKey; + if (DocumentReadOnlyScope.of(context) && + { + LogicalKeyboardKey.enter, + LogicalKeyboardKey.backspace, + LogicalKeyboardKey.delete, + LogicalKeyboardKey.tab, + }.contains(key)) { + return KeyEventResult.ignored; + } final commands = BusyMarkCommandRegistryScope.read(context) ?? BusyMarkCommandCatalog.metadata; @@ -4484,16 +4780,16 @@ class BusyMarkWysiwygEditorState extends State { .clamp(start, controller.text.length) .toInt(); final activeInlineKinds = _activeInlineKindsAt(blockId, start); - _recordUndoSnapshot(); - _documentController.updateBlockText( + final undoSnapshot = _historySnapshot(); + final result = _documentController.applyEnterAt( blockId, - controller.text.replaceRange(start, end, ''), - activeInlineKinds: activeInlineKinds, + start, + selectionEnd: end, ); - final result = _documentController.applyEnterAt(blockId, start); if (result == null) { - return KeyEventResult.ignored; + return KeyEventResult.handled; } + _recordUndoSnapshot(undoSnapshot); if (_documentController.blockText(result.blockId).isEmpty) { _setPendingInlineKinds(result.blockId, activeInlineKinds); } @@ -4522,11 +4818,12 @@ class BusyMarkWysiwygEditorState extends State { : LogicalKeyboardKey.arrowRight; if (key == LogicalKeyboardKey.enter) { final activeInlineKinds = _activeInlineKindsAt(blockId, offset); - _recordUndoSnapshot(); + final undoSnapshot = _historySnapshot(); final result = _documentController.applyEnterAt(blockId, offset); if (result == null) { - return KeyEventResult.ignored; + return KeyEventResult.handled; } + _recordUndoSnapshot(undoSnapshot); if (_documentController.blockText(result.blockId).isEmpty) { _setPendingInlineKinds(result.blockId, activeInlineKinds); } @@ -4535,11 +4832,12 @@ class BusyMarkWysiwygEditorState extends State { return KeyEventResult.handled; } if (key == LogicalKeyboardKey.backspace && offset == 0) { - _recordUndoSnapshot(); + final undoSnapshot = _historySnapshot(); final result = _documentController.applyBackspaceAtStart(blockId); if (result == null) { return KeyEventResult.ignored; } + _recordUndoSnapshot(undoSnapshot); _emitMarkdown(); _focusBlockAfterFrame(result.blockId, offset: result.offset); return KeyEventResult.handled; @@ -4696,6 +4994,7 @@ class BusyMarkWysiwygEditorState extends State { controller.selection = TextSelection.collapsed( offset: backwards ? controller.text.length : 0, ); + BusyMarkEditorFocusRestoration.reveal(focusNode); focusNode.requestFocus(); return KeyEventResult.handled; } @@ -4780,6 +5079,7 @@ class BusyMarkWysiwygEditorState extends State { final controller = _textControllerFor(nextBlock); final focusNode = _focusNodeFor(nextBlock); _activeBlockId = nextBlock.id; + BusyMarkEditorFocusRestoration.reveal(focusNode); focusNode.requestFocus(); final offset = desiredOffset is _MoveToBlockEnd ? controller.text.length @@ -5163,6 +5463,7 @@ class BusyMarkWysiwygEditorState extends State { if (_documentSelection != null) { setState(() => _documentSelection = null); } + BusyMarkEditorFocusRestoration.reveal(focusNode); focusNode.requestFocus(); controller.selection = TextSelection( baseOffset: anchor.offset, @@ -5179,6 +5480,7 @@ class BusyMarkWysiwygEditorState extends State { final controller = _textControllerFor(extentBlock); final focusNode = _focusNodeFor(extentBlock); if (!_selectionFocusNode.hasPrimaryFocus) { + BusyMarkEditorFocusRestoration.reveal(focusNode); focusNode.requestFocus(); } controller.selection = TextSelection.collapsed( @@ -5289,6 +5591,7 @@ class BusyMarkWysiwygEditorState extends State { } final focusNode = _focusNodeFor(block); final controller = _textControllerFor(block); + BusyMarkEditorFocusRestoration.reveal(focusNode); focusNode.requestFocus(); controller.selection = TextSelection.collapsed( offset: offset.clamp(0, controller.text.length).toInt(), @@ -5319,6 +5622,7 @@ class BusyMarkWysiwygEditorState extends State { if (controller == null || focusNode == null) { return; } + BusyMarkEditorFocusRestoration.reveal(focusNode); focusNode.requestFocus(); controller.selection = TextSelection.collapsed( offset: offset.clamp(0, controller.text.length).toInt(), @@ -5487,15 +5791,57 @@ class BusyMarkWysiwygEditorState extends State { } void _applyAsyncToolbarCommand(Future Function() command) { - unawaited(command().whenComplete(_restoreEditingFocusAfterFrame)); + unawaited( + command().whenComplete(() { + // The surface may have finished releasing after a different control + // acquired focus. Completion is not a new request to return to editing. + _restoreEditingFocusAfterFrame(passive: true); + }), + ); } - void _restoreEditingFocusAfterFrame() { + void _restoreEditingFocusAfterFrame({ + bool passive = false, + BuildContext? menuContext, + }) { + // Passive completions must not capture a newer owner as authorization. + // Purposeful editing/navigation callers retain their existing behavior. + if (!mounted || + (passive && !_mayRestoreEditingFocus(menuContext: menuContext))) { + return; + } final documentId = _documentId; + final generation = _documentGeneration; + final request = Object(); + _focusRestoreRequest = request; + final gesture = _selectionGestureGeneration; + final selection = _documentSelection; + final target = _activeTextTarget(); + final value = target?.controller.value; + final owner = FocusManager.instance.primaryFocus; + final navigation = widget.scrollRequest; WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted || _documentId != documentId) { + if (!mounted) return; + // A newer post-frame focus request may not be primaryFocus yet. + FocusManager.instance.applyFocusChangesIfNeeded(); + final currentOwner = FocusManager.instance.primaryFocus; + final lifecycle = WidgetsBinding.instance.lifecycleState; + if (!mounted || + (lifecycle != null && lifecycle != AppLifecycleState.resumed) || + _documentId != documentId || + generation != _documentGeneration || + !identical(request, _focusRestoreRequest) || + gesture != _selectionGestureGeneration || + !identical(selection, _documentSelection) || + navigation != widget.scrollRequest || + target?.controller.value != value || + (passive && !_mayRestoreEditingFocus(menuContext: menuContext)) || + (currentOwner != owner && + currentOwner != target?.focusNode && + currentOwner != FocusManager.instance.rootScope)) { return; } + _focusRestoreRequest = null; if (_documentSelection != null) { _selectionFocusNode.requestFocus(); return; @@ -5723,7 +6069,10 @@ class BusyMarkWysiwygEditorState extends State { ), ); if (inserted == null || inserted.trim().isEmpty) { - if (_isDialogTargetCurrent(guard)) _restoreEditingFocusAfterFrame(); + // Canceling the surface is not a new request to return to editing. + if (_isDialogTargetCurrent(guard)) { + _restoreEditingFocusAfterFrame(passive: true); + } return; } if (!mounted) return; @@ -7070,6 +7419,7 @@ class BusyMarkWysiwygEditorState extends State { } void _applyHardBreakCommand() { + if (DocumentReadOnlyScope.of(context)) return; final target = _activeTextTarget(); if (target == null || _activeCellId != null) { return; @@ -7088,6 +7438,7 @@ class BusyMarkWysiwygEditorState extends State { } void _applyBlankLineCommand() { + if (DocumentReadOnlyScope.of(context)) return; final target = _activeTextTarget(); if (target == null || _activeCellId != null) { return; @@ -8357,6 +8708,7 @@ class BusyMarkWysiwygEditorState extends State { } void _handleDocumentSelectionContextPointerDown(PointerDownEvent event) { + _cancelSessionRestore(); _documentSelectionContextPointer = null; _documentSelectionContextSnapshot = null; if (event.buttons != kSecondaryMouseButton || !_hasBlockSelection) { @@ -8574,19 +8926,117 @@ class BusyMarkWysiwygEditorState extends State { } void _scheduleDocumentSelectionExtentFocus() { + _cancelSelectionExtentFocus?.call(); final selection = _documentSelection; if (selection == null) return; + final manager = FocusManager.instance; + manager.applyFocusChangesIfNeeded(); + final inactive = + WidgetsBinding.instance.lifecycleState != null && + WidgetsBinding.instance.lifecycleState != AppLifecycleState.resumed; + final owner = inactive && manager.primaryFocus == manager.rootScope + ? _lastNonRootFocus + : manager.primaryFocus; + final ownerContext = owner?.context; + final editorOwnsFocus = + ownerContext?.mounted == true && + identical( + ownerContext!.findAncestorStateOfType(), + this, + ); + if (!editorOwnsFocus && (inactive || !_mayRestoreEditingFocus())) { + _preserveSelectionFocusCallbacks = 0; + return; + } final gesture = _selectionGestureGeneration; final generation = _documentGeneration; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted && - gesture == _selectionGestureGeneration && - generation == _documentGeneration && - identical(selection, _documentSelection)) { + final documentId = _documentId; + final navigation = widget.scrollRequest; + var canceled = false; + var framePending = false; + final extentNode = _focusNodes[selection.extent.blockId]; + final releaseTarget = extentNode == null + ? null + : BusyMarkEditorFocusRestoration.retainPendingTarget(extentNode); + bool isCurrent() => + mounted && + !canceled && + gesture == _selectionGestureGeneration && + generation == _documentGeneration && + documentId == _documentId && + navigation == widget.scrollRequest && + identical(selection, _documentSelection); + bool owns(FocusNode? current) => + current == owner || + current == _selectionFocusNode || + current == _focusNodes[selection.extent.blockId] || + current == + GlobalObjectKey<_DocumentSelectionTextInputState>( + selection, + ).currentState?._focusNode; + void cancel() { + if (canceled) return; + canceled = true; + _retrySelectionExtentFocus = null; + _cancelSelectionExtentFocus = null; + releaseTarget?.call(); + } + + void schedule() { + if (!isCurrent()) { + cancel(); + return; + } + final lifecycle = WidgetsBinding.instance.lifecycleState; + if (lifecycle != null && lifecycle != AppLifecycleState.resumed) { + // Keep the handoff only while the desktop has suspended its owner. + // FocusManager's real restoration wakes it; no timer/frame retry does. + if (manager.primaryFocus != manager.rootScope && + !owns(manager.primaryFocus)) { + cancel(); + } + return; + } + if (!owns(manager.primaryFocus) || !_mayRestoreEditingFocus()) { + cancel(); + return; + } + if (framePending) return; + framePending = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + framePending = false; + if (!isCurrent()) { + cancel(); + return; + } + final lifecycle = WidgetsBinding.instance.lifecycleState; + if (lifecycle != null && lifecycle != AppLifecycleState.resumed) return; + manager.applyFocusChangesIfNeeded(); + if (!isCurrent() || + !owns(manager.primaryFocus) || + !_mayRestoreEditingFocus()) { + cancel(); + return; + } + cancel(); _focusDocumentSelectionExtent(); _preserveSelectionFocusCallbacks = 0; - } - }); + }); + WidgetsBinding.instance.scheduleFrame(); + } + + _cancelSelectionExtentFocus = cancel; + _retrySelectionExtentFocus = schedule; + schedule(); + } + + void _handleSelectionFocusOwnerChanged() { + final owner = FocusManager.instance.primaryFocus; + if (owner != null && owner != FocusManager.instance.rootScope) { + // Root focus during inactivity is not evidence of editor ownership. + _lastNonRootFocus = owner; + } + _retrySelectionExtentFocus?.call(); } bool _collapseEmptyDocumentSelection() { @@ -8597,6 +9047,7 @@ class BusyMarkWysiwygEditorState extends State { } void _releaseSelectionDrag({bool rebuild = true}) { + _cancelSelectionExtentFocus?.call(); final active = _selectionPointer != null; _selectionPointer = null; _pointerSelectionAnchor = null; @@ -8775,8 +9226,9 @@ class BusyMarkWysiwygEditorState extends State { } void _focusDocumentSelectionExtent() { - final extent = _documentSelection?.extent; - if (extent == null) { + final selection = _documentSelection; + final extent = selection?.extent; + if (extent == null || !_mayRestoreEditingFocus()) { return; } final block = _documentController.blockById(extent.blockId); @@ -8785,7 +9237,21 @@ class BusyMarkWysiwygEditorState extends State { } if (!_isFocusableTextBlock(block)) { _setActiveBlock(block.id); - _selectionFocusNode.requestFocus(); + final input = GlobalObjectKey<_DocumentSelectionTextInputState>( + selection!, + ).currentState; + // The same guarded handoff owns table input too; the child must not + // request focus independently after a newer control/lifecycle change. + if (input != null) { + input._focusNode.requestFocus(); + } else { + _selectionFocusNode.requestFocus(); + // Only a table mounts the auxiliary text input. Other non-editable + // endpoints retain command focus without waiting for a missing field. + if (block.kind == BusyBlockKind.table) { + _scheduleDocumentSelectionExtentFocus(); + } + } _scheduleSessionReport(); return; } @@ -9594,12 +10060,10 @@ class BusyMarkWysiwygEditorState extends State { 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; @@ -9621,9 +10085,6 @@ class _DocumentSelectionTextInputState void initState() { super.initState(); _controller.addListener(_handleEditingValue); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted && widget.isCurrent()) _focusNode.requestFocus(); - }); } void _handleEditingValue() { diff --git a/lib/src/editor/wysiwyg/wysiwyg_session_state.dart b/lib/src/editor/wysiwyg/wysiwyg_session_state.dart index 8a468983..137c88fe 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_session_state.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_session_state.dart @@ -8,6 +8,9 @@ class WysiwygEditorSessionState { this.extentOffset = 0, this.viewportBlockId, this.viewportAlignment = 0, + this.viewportOffset = 0, + this.viewportEditingOffset, + this.viewportLayoutSignature, this.sourceSignature, this.sourceBindings = const {}, }); @@ -21,6 +24,16 @@ class WysiwygEditorSessionState { final String? viewportBlockId; final double viewportAlignment; + /// Logical pixels clipped from the leading edge of [viewportBlockId]. Old + /// sessions without this field retain their original alignment semantics. + final double viewportOffset; + + /// The retained editing block's leading edge relative to the viewport. This + /// lets an unchanged session mount that one editable and scroll naturally, + /// preserving its input client even when the reading anchor is far away. + final double? viewportEditingOffset; + final String? viewportLayoutSignature; + // Writerside authoring IDs can differ after an exact saved source is reparsed. // Bind caret/viewport locations to that representation, never to an ordinal. final String? sourceSignature; @@ -36,6 +49,11 @@ class WysiwygEditorSessionState { 'extentOffset': extentOffset, 'viewportBlockId': viewportBlockId, 'viewportAlignment': viewportAlignment, + if (viewportOffset != 0) 'viewportOffset': viewportOffset, + if (viewportEditingOffset != null) + 'viewportEditingOffset': viewportEditingOffset, + if (viewportLayoutSignature != null) + 'viewportLayoutSignature': viewportLayoutSignature, if (sourceSignature != null) 'sourceSignature': sourceSignature, if (sourceBindings.isNotEmpty) 'sourceBindings': { @@ -59,6 +77,10 @@ class WysiwygEditorSessionState { extentOffset: (json['extentOffset'] as num?)?.toInt() ?? 0, viewportBlockId: json['viewportBlockId']?.toString(), viewportAlignment: (json['viewportAlignment'] as num?)?.toDouble() ?? 0, + viewportOffset: (json['viewportOffset'] as num?)?.toDouble() ?? 0, + viewportEditingOffset: (json['viewportEditingOffset'] as num?) + ?.toDouble(), + viewportLayoutSignature: json['viewportLayoutSignature'] as String?, sourceSignature: json['sourceSignature'] as String?, sourceBindings: { if (json['sourceBindings'] case final Map bindings) diff --git a/lib/src/editor/wysiwyg/wysiwyg_toolbar.dart b/lib/src/editor/wysiwyg/wysiwyg_toolbar.dart index 7bee489a..97a153b7 100644 --- a/lib/src/editor/wysiwyg/wysiwyg_toolbar.dart +++ b/lib/src/editor/wysiwyg/wysiwyg_toolbar.dart @@ -69,7 +69,7 @@ class BusyMarkWysiwygToolbar extends StatelessWidget { final ValueChanged? onAdmonitionCommand; final ValueChanged? onWritersideInsert; final bool Function(BusyWritersideInsertCommand)? isWritersideInsertEnabled; - final VoidCallback? onMenuCanceled; + final ValueChanged? onMenuCanceled; final ValueChanged onInlineCommand; final VoidCallback onLinkCommand; final VoidCallback? onInlineMathCommand; diff --git a/lib/src/markdown/busymark_markdown_serializer.dart b/lib/src/markdown/busymark_markdown_serializer.dart index eeb5bdf4..8834eb6e 100644 --- a/lib/src/markdown/busymark_markdown_serializer.dart +++ b/lib/src/markdown/busymark_markdown_serializer.dart @@ -820,7 +820,13 @@ class BusyMarkMarkdownSerializer { } return source.toString(); } - final line = content.isEmpty ? marker : '$marker $content'; + // The pinned Markdown checkbox syntax requires whitespace after ']'. + // Without it an empty editable task reparses as an ordinary list item. + final line = contentPrefix != null && text.isEmpty + ? '$marker $contentPrefix ' + : content.isEmpty + ? marker + : '$marker $content'; if (children.isEmpty) return line; final source = StringBuffer(line); for (final child in children) { @@ -898,6 +904,13 @@ class BusyMarkMarkdownSerializer { bool _requiresTextWhitespacePreservation(BusyBlock block) { return block.attributes[busyMarkPreserveTextWhitespaceAttribute] == 'true' || + block.kind == BusyBlockKind.taskListItem && + block.inlines.every( + (inline) => + inline.kind == BusyInlineKind.text && + inline.text.isEmpty && + inline.children.isEmpty, + ) || block.children.any(_requiresTextWhitespacePreservation); } diff --git a/lib/src/markdown/markdown_ast_adapter.dart b/lib/src/markdown/markdown_ast_adapter.dart index 16748153..6f6b75d1 100644 --- a/lib/src/markdown/markdown_ast_adapter.dart +++ b/lib/src/markdown/markdown_ast_adapter.dart @@ -1649,7 +1649,9 @@ class MarkdownAstAdapter { if (child is md.Element && child.tag == 'input') { return child.attributes['checked'] == 'true'; } - if (child is md.Element && child.children != null) { + // markdown's task marker belongs directly to this li or its caption p. + // Descendant lists/containers own their own checkboxes. + if (child is md.Element && child.tag == 'p' && child.children != null) { final nested = _taskChecked(child.children!); if (nested != null) { return nested; @@ -1664,7 +1666,9 @@ class MarkdownAstAdapter { for (final child in children) if (child is md.Element && child.tag == 'input') ...const [] - else if (child is md.Element && child.children != null) + else if (child is md.Element && + child.tag == 'p' && + child.children != null) md.Element(child.tag, _withoutTaskCheckbox(child.children!)) ..attributes.addAll(child.attributes) else diff --git a/lib/src/workspace/presentation/workspace_screen.dart b/lib/src/workspace/presentation/workspace_screen.dart index 4894dc00..d50660c5 100644 --- a/lib/src/workspace/presentation/workspace_screen.dart +++ b/lib/src/workspace/presentation/workspace_screen.dart @@ -14880,6 +14880,14 @@ bool _sameWysiwygSession( left.extentBlockId == right.extentBlockId && left.extentOffset == right.extentOffset && left.viewportBlockId == right.viewportBlockId && + left.viewportLayoutSignature == right.viewportLayoutSignature && + (left.viewportEditingOffset == right.viewportEditingOffset || + (left.viewportEditingOffset != null && + right.viewportEditingOffset != null && + (left.viewportEditingOffset! - right.viewportEditingOffset!) + .abs() < + 0.1)) && + (left.viewportOffset - right.viewportOffset).abs() < 0.1 && (left.viewportAlignment - right.viewportAlignment).abs() < 0.001; } diff --git a/test/fixtures/structural_editing/header_test_text.md b/test/fixtures/structural_editing/header_test_text.md new file mode 100644 index 00000000..bb068b64 --- /dev/null +++ b/test/fixtures/structural_editing/header_test_text.md @@ -0,0 +1 @@ +## About the job diff --git a/test/fixtures/structural_editing/heading-properties.md b/test/fixtures/structural_editing/heading-properties.md new file mode 100644 index 00000000..00f62398 --- /dev/null +++ b/test/fixtures/structural_editing/heading-properties.md @@ -0,0 +1,5 @@ +# Topic + +## About the job {id="job" collapsible="true" default-state="expanded"} + +Body. diff --git a/test/fixtures/structural_editing/inline-consecutive-breaks.md b/test/fixtures/structural_editing/inline-consecutive-breaks.md new file mode 100644 index 00000000..afa39718 --- /dev/null +++ b/test/fixtures/structural_editing/inline-consecutive-breaks.md @@ -0,0 +1,4 @@ +**a![](first.png)![](second.png)** +
+
+*![](third.png)b* diff --git a/test/fixtures/structural_editing/inline-existing-break.md b/test/fixtures/structural_editing/inline-existing-break.md new file mode 100644 index 00000000..75b2ff3a --- /dev/null +++ b/test/fixtures/structural_editing/inline-existing-break.md @@ -0,0 +1,2 @@ +a\ +![](first.png)b diff --git a/test/fixtures/structural_editing/inline-objects.md b/test/fixtures/structural_editing/inline-objects.md new file mode 100644 index 00000000..1121301a --- /dev/null +++ b/test/fixtures/structural_editing/inline-objects.md @@ -0,0 +1 @@ +**a![](first.png)![](second.png)b**![](third.png)c diff --git a/test/fixtures/structural_editing/lists.md b/test/fixtures/structural_editing/lists.md new file mode 100644 index 00000000..af5a5d6f --- /dev/null +++ b/test/fixtures/structural_editing/lists.md @@ -0,0 +1,11 @@ +- Parent + - Child + - Grandchild + +1. Ordered parent + 1. Ordered child + 1. Ordered grandchild + +- [ ] Task parent + - [x] Task child + - [ ] Task grandchild diff --git a/test/src/app_smoke_test.dart b/test/src/app_smoke_test.dart index aee4fa68..a0639fd9 100644 --- a/test/src/app_smoke_test.dart +++ b/test/src/app_smoke_test.dart @@ -53,6 +53,7 @@ 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_session_state.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'; @@ -1327,6 +1328,442 @@ void main() { } }); + testWidgets( + 'heading boundary survives workspace undo, view switching, save and reopen', + (tester) async { + final root = Directory.systemTemp.createTempSync( + 'busymark-heading-boundary-', + ); + addTearDown(() => root.deleteSync(recursive: true)); + const original = '## About the job\n'; + final file = File('${root.path}/header_test_text.md') + ..writeAsStringSync(original); + final container = ProviderContainer( + overrides: [ + ..._smokeAccentOverrides, + linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), + localSettingsStoreProvider.overrideWithValue( + _MemorySettingsStore() + ..value = AppSettings.defaults() + .copyWith( + autoSave: false, + documentViewMode: DocumentViewModePreference.editor, + ) + .toJson(), + ), + localHistoryStoreProvider.overrideWithValue( + MemoryLocalHistoryStore(), + ), + documentSessionStoreProvider.overrideWithValue( + MemoryDocumentSessionStore(), + ), + documentRecoveryStoreProvider.overrideWithValue( + MemoryDocumentRecoveryStore(), + ), + startupPathProvider.overrideWithValue(file.path), + ], + ); + addTearDown(container.dispose); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const BusyMarkApp(), + ), + ); + await _waitForSearchCondition( + tester, + () => find.byType(BusyMarkWysiwygEditor).evaluate().isNotEmpty, + ); + await tester.pumpAndSettle(); + final workspace = container.read(workspaceControllerProvider.notifier); + final field = tester.widget( + find.widgetWithText(TextField, 'About the job'), + ); + field.focusNode!.requestFocus(); + field.controller!.selection = const TextSelection.collapsed(offset: 0); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + final edited = container.read(workspaceControllerProvider).activeBuffer!; + final savedSource = edited.text; + expect(edited.editorState.undoState.undo, hasLength(1)); + expect(edited.isDirty, isTrue); + expect(savedSource, contains('## About the job')); + expect(savedSource, isNot(contains('## \n'))); + final kept = tester.widget( + find.widgetWithText(TextField, 'About the job'), + ); + expect(kept.focusNode!.hasFocus, isTrue); + expect( + kept.controller!.selection, + const TextSelection.collapsed(offset: 0), + ); + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyZ); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pumpAndSettle(); + expect(container.read(workspaceControllerProvider).activeText, original); + 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.pumpAndSettle(); + expect( + container.read(workspaceControllerProvider).activeText, + savedSource, + ); + // External history restores source through the production parser. Blank + // paragraphs remain transient; redo must preserve the heading, not invent + // a semantic paragraph that save/reopen intentionally does not retain. + expect( + find.descendant( + of: find.byType(BusyMarkWysiwygEditor), + matching: find.byWidgetPredicate( + (w) => w is TextField && w.controller?.text.isEmpty == true, + ), + ), + findsNothing, + ); + for (final mode in [ + DocumentViewModePreference.source, + DocumentViewModePreference.preview, + DocumentViewModePreference.split, + DocumentViewModePreference.editor, + ]) { + workspace.updateActiveEditorMode(mode); + await container + .read(appSettingsControllerProvider.notifier) + .setDocumentViewMode(mode); + await tester.pumpAndSettle(); + expect( + container.read(workspaceControllerProvider).activeText, + savedSource, + ); + final parsed = const MarkdownParser() + .parse(filePath: file.path, source: savedSource) + .busyDocument; + expect(parsed.blocks.single.kind, BusyBlockKind.heading); + expect(parsed.blocks.single.attributes['level'], '2'); + expect(parsed.blocks.single.plainText, 'About the job'); + } + bool? saved; + unawaited(workspace.saveActive().then((value) => saved = value)); + await _waitForSearchCondition(tester, () => saved != null); + expect(saved, isTrue); + await tester.pumpAndSettle(); + expect(file.readAsStringSync(), savedSource); + bool? closed; + unawaited( + workspace.closeActiveOpenFileTab().then((value) => closed = value), + ); + await _waitForSearchCondition(tester, () => closed != null); + expect(closed, isTrue); + unawaited(workspace.openPath(file.path)); + await _waitForSearchCondition( + tester, + () => find + .widgetWithText(TextField, 'About the job') + .evaluate() + .isNotEmpty, + ); + await tester.pumpAndSettle(); + final reopened = container + .read(workspaceControllerProvider) + .activeBuffer!; + expect(reopened.text, savedSource); + expect(reopened.isDirty, isFalse); + expect( + tester + .widget(find.byType(BusyMarkWysiwygEditor)) + .document + .blocks + .single + .kind, + BusyBlockKind.heading, + ); + await tester.pumpWidget(const SizedBox.shrink()); + }, + ); + + for (final count in [1, 2]) { + testWidgets( + 'inline break anchors survive workspace history, save and reopen count=$count', + (tester) async { + final root = Directory.systemTemp.createTempSync( + 'busymark-inline-break-anchors-', + ); + addTearDown(() => root.deleteSync(recursive: true)); + const original = + '**a![](first.png)![](second.png) \n![](third.png)![](fourth.png)b**\n'; + final file = File('${root.path}/breaks.md') + ..writeAsStringSync(original); + const parser = MarkdownParser(); + BusyBlock parsed(String source) => parser + .parse(filePath: file.path, source: source) + .busyDocument + .blocks + .single; + final before = parsed(original); + expect(before.plainText, 'a\nb'); + final expected = [ + for (final anchor in busyInlineSemanticAnchors(before.inlines)) + [ + anchor.offset <= 1 ? anchor.offset : anchor.offset + count, + _inlineAnchorTree(anchor.inline), + ], + ]; + final container = ProviderContainer( + overrides: [ + ..._smokeAccentOverrides, + linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), + localSettingsStoreProvider.overrideWithValue( + _MemorySettingsStore() + ..value = AppSettings.defaults() + .copyWith( + autoSave: false, + automaticSpelling: false, + documentViewMode: DocumentViewModePreference.editor, + ) + .toJson(), + ), + localHistoryStoreProvider.overrideWithValue( + MemoryLocalHistoryStore(), + ), + documentSessionStoreProvider.overrideWithValue( + MemoryDocumentSessionStore(), + ), + documentRecoveryStoreProvider.overrideWithValue( + MemoryDocumentRecoveryStore(), + ), + startupPathProvider.overrideWithValue(file.path), + ], + ); + addTearDown(container.dispose); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const BusyMarkApp(), + ), + ); + await _waitForSearchCondition( + tester, + () => find.byType(BusyMarkWysiwygEditor).evaluate().isNotEmpty, + ); + await tester.pumpAndSettle(); + final workspace = container.read(workspaceControllerProvider.notifier); + final field = tester.widget( + find.widgetWithText(TextField, 'a\nb'), + ); + field.focusNode!.requestFocus(); + field.controller!.selection = const TextSelection.collapsed(offset: 1); + await tester.pump(); + if (count == 1) { + await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + } else { + final button = find.byWidgetPredicate( + (w) => + w is BusyMarkHeaderIconButton && + w.icon == BusyMarkGlyphs.blankLine, + ); + await tester.ensureVisible(button); + await tester.tap(button); + } + await tester.pumpAndSettle(); + final edited = container + .read(workspaceControllerProvider) + .activeBuffer!; + final savedSource = edited.text; + expect(edited.editorState.undoState.undo, hasLength(1)); + expect(_inlineAnchorPositions(parsed(savedSource)), expected); + expect(field.controller!.text, 'a${'\n' * (count + 1)}b'); + expect(field.focusNode!.hasFocus, isTrue); + expect( + field.controller!.selection, + TextSelection.collapsed(offset: 1 + count), + ); + final dynamic editorState = tester.state( + find.byType(BusyMarkWysiwygEditor), + ); + expect(editorState.debugUndoSnapshotCount, 0); + expect(workspace.undoActiveBuffer(), isTrue); + await tester.pumpAndSettle(); + expect( + container.read(workspaceControllerProvider).activeText, + original, + ); + expect( + _inlineAnchorPositions( + tester + .widget( + find.byType(BusyMarkWysiwygEditor), + ) + .document + .blocks + .single, + ), + _inlineAnchorPositions(before), + ); + expect(workspace.redoActiveBuffer(), isTrue); + await tester.pumpAndSettle(); + expect( + container.read(workspaceControllerProvider).activeText, + savedSource, + ); + expect( + _inlineAnchorPositions( + tester + .widget( + find.byType(BusyMarkWysiwygEditor), + ) + .document + .blocks + .single, + ), + expected, + ); + for (final mode in [ + DocumentViewModePreference.source, + DocumentViewModePreference.preview, + DocumentViewModePreference.split, + DocumentViewModePreference.editor, + ]) { + workspace.updateActiveEditorMode(mode); + await container + .read(appSettingsControllerProvider.notifier) + .setDocumentViewMode(mode); + await tester.pumpAndSettle(); + expect( + container.read(workspaceControllerProvider).activeText, + savedSource, + ); + expect(_inlineAnchorPositions(parsed(savedSource)), expected); + } + bool? saved; + unawaited(workspace.saveActive().then((value) => saved = value)); + await _waitForSearchCondition(tester, () => saved != null); + expect(saved, isTrue); + expect(file.readAsStringSync(), savedSource); + bool? closed; + unawaited( + workspace.closeActiveOpenFileTab().then((value) => closed = value), + ); + await _waitForSearchCondition(tester, () => closed != null); + expect(closed, isTrue); + unawaited(workspace.openPath(file.path)); + await _waitForSearchCondition( + tester, + () => find + .widgetWithText(TextField, 'a${'\n' * (count + 1)}b') + .evaluate() + .isNotEmpty, + ); + await tester.pumpAndSettle(); + final reopened = container + .read(workspaceControllerProvider) + .activeBuffer!; + expect(reopened.text, savedSource); + expect(reopened.isDirty, isFalse); + expect(_inlineAnchorPositions(parsed(reopened.text)), expected); + expect( + _inlineAnchorPositions( + tester + .widget( + find.byType(BusyMarkWysiwygEditor), + ) + .document + .blocks + .single, + ), + expected, + ); + await tester.pumpWidget(const SizedBox.shrink()); + }, + ); + } + + testWidgets('workspace coalesces pixel viewport sessions without edits', ( + tester, + ) async { + final container = ProviderContainer( + overrides: [ + ..._smokeAccentOverrides, + linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), + localSettingsStoreProvider.overrideWithValue( + _MemorySettingsStore() + ..value = AppSettings.defaults() + .copyWith( + autoSave: false, + documentViewMode: DocumentViewModePreference.editor, + ) + .toJson(), + ), + workspaceServiceProvider.overrideWithValue( + const _SearchWorkspaceService('Reading target.'), + ), + startupPathProvider.overrideWithValue('/tmp/viewport-session.md'), + ], + ); + addTearDown(container.dispose); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const BusyMarkApp(), + ), + ); + await _pumpUntilFound(tester, find.byType(BusyMarkWysiwygEditor)); + await tester.pumpAndSettle(); + final editor = tester.widget( + find.byType(BusyMarkWysiwygEditor), + ); + final before = container.read(workspaceControllerProvider).activeBuffer!; + final saved = before.editorState.wysiwygState; + WysiwygEditorSessionState pixels(double offset) => + WysiwygEditorSessionState( + activeBlockId: saved.activeBlockId, + activeCellId: saved.activeCellId, + anchorBlockId: saved.anchorBlockId, + anchorOffset: saved.anchorOffset, + extentBlockId: saved.extentBlockId, + extentOffset: saved.extentOffset, + viewportBlockId: saved.viewportBlockId, + viewportAlignment: saved.viewportAlignment, + viewportOffset: offset, + viewportEditingOffset: -offset, + viewportLayoutSignature: saved.viewportLayoutSignature, + sourceSignature: saved.sourceSignature, + sourceBindings: saved.sourceBindings, + ); + final first = pixels(2300.25); + editor.onSessionChanged!(editor.documentId!, first); + expect( + container + .read(workspaceControllerProvider) + .activeBuffer! + .editorState + .wysiwygState, + same(first), + ); + editor.onSessionChanged!(editor.documentId!, pixels(2300.26)); + expect( + container + .read(workspaceControllerProvider) + .activeBuffer! + .editorState + .wysiwygState, + same(first), + ); + final second = pixels(2300.5); + editor.onSessionChanged!(editor.documentId!, second); + final after = container.read(workspaceControllerProvider).activeBuffer!; + expect(after.editorState.wysiwygState, same(second)); + expect(after.text, before.text); + expect(after.dirty, before.dirty); + expect(after.editorState.undoState, same(before.editorState.undoState)); + }); + testWidgets( 'workspace routes paste shortcuts only to editable document views', (tester) async { @@ -18908,6 +19345,19 @@ void _expectHeaderGeometry(WidgetTester tester) { } } +List _inlineAnchorPositions(BusyBlock block) => [ + for (final anchor in busyInlineSemanticAnchors(block.inlines)) + [anchor.offset, _inlineAnchorTree(anchor.inline)], +]; + +Object _inlineAnchorTree(BusyInline inline) => [ + inline.kind, + inline.text, + inline.destination, + inline.attributes, + [for (final child in inline.children) _inlineAnchorTree(child)], +]; + Future _pumpUntilFound(WidgetTester tester, Finder finder) async { for (var i = 0; i < 20 && finder.evaluate().isEmpty; i += 1) { await tester.runAsync( diff --git a/test/src/busymark_dialogs_test.dart b/test/src/busymark_dialogs_test.dart index e33e9edc..9977ea03 100644 --- a/test/src/busymark_dialogs_test.dart +++ b/test/src/busymark_dialogs_test.dart @@ -13,7 +13,73 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import '../support/desktop_focus.dart'; + void main() { + _delayedModalFocusRegressions(); + testWidgets('delayed modal barrier release respects a newer focus owner', ( + tester, + ) async { + const channel = MethodChannel('com.busymark.test/focus-modal-barrier'); + final release = Completer(); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, ( + call, + ) async { + if (call.method == 'initialize') return true; + if (call.method == 'setModalBarrierDepth' && call.arguments == 0) { + await release.future; + } + return null; + }); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + null, + ), + ); + final service = LinuxHeaderBarService(channel: channel); + await service.initialize(); + final previous = FocusNode(); + final newer = FocusNode(); + addTearDown(previous.dispose); + addTearDown(newer.dispose); + late BuildContext host; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + host = context; + return Scaffold( + body: Column( + children: [ + TextField(focusNode: previous), + TextField(focusNode: newer), + ], + ), + ); + }, + ), + ), + ); + previous.requestFocus(); + await tester.pumpAndSettle(); + final result = showBusyMarkModalDialog( + host, + headerBarService: service, + builder: (_) => const Text('Modal focus test'), + ); + await tester.pumpAndSettle(); + Navigator.of(host, rootNavigator: true).pop(); + await tester.pumpAndSettle(); + newer.requestFocus(); + await tester.pumpAndSettle(); + expect(FocusManager.instance.primaryFocus, same(newer)); + release.complete(); + await result; + await tester.pumpAndSettle(); + expect(FocusManager.instance.primaryFocus, same(newer)); + }); + testWidgets( 'modal text input preserves local editing when an application binding overlaps it', (tester) async { @@ -527,6 +593,146 @@ class _EditorShortcutIntent extends Intent { const _EditorShortcutIntent(); } +void _delayedModalFocusRegressions() { + for (final scenario in [ + 'inactive editor', + 'inactive newer owner', + 'resumed newer owner', + 'newer owner before close', + 'newer owner then scope', + 'disposed host', + 'ordinary return', + ]) { + testWidgets('deferred modal focus respects $scenario', (tester) async { + enableDesktopFocusLifecycle(tester); + final barrier = _GatedFocusBarrierService(); + addTearDown(barrier.dispose); + addTearDown(() { + if (!barrier.release.isCompleted) barrier.release.complete(); + tester.binding.handleAppLifecycleStateChanged( + AppLifecycleState.resumed, + ); + }); + final previous = FocusNode(debugLabel: 'Previous editor'); + final newer = FocusNode(debugLabel: 'Newer search control'); + final text = TextEditingController(text: 'search'); + addTearDown(previous.dispose); + addTearDown(newer.dispose); + addTearDown(text.dispose); + late BuildContext host; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + host = context; + return Scaffold( + body: Column( + children: [ + TextField(focusNode: previous), + TextField(focusNode: newer, controller: text), + ], + ), + ); + }, + ), + ), + ); + previous.requestFocus(); + await tester.pumpAndSettle(); + final dialog = showBusyMarkModalDialog( + host, + headerBarService: barrier, + builder: (_) => const AlertDialog(content: Text('Dismiss focus race')), + ); + await tester.pumpAndSettle(); + Navigator.of(host, rootNavigator: true).pop(); + if (scenario == 'newer owner before close') { + newer.requestFocus(); + FocusManager.instance.applyFocusChangesIfNeeded(); + } + await tester.pumpAndSettle(); + expect(barrier.releaseStarted, isTrue); + if (scenario.contains('newer')) { + newer.requestFocus(); + await tester.pumpAndSettle(); + expect(FocusManager.instance.primaryFocus, same(newer)); + } + final inactive = scenario.startsWith('inactive'); + if (inactive || scenario == 'resumed newer owner') { + tester.binding.handleAppLifecycleStateChanged( + AppLifecycleState.inactive, + ); + expect( + FocusManager.instance.primaryFocus, + same(FocusManager.instance.rootScope), + ); + for (var frame = 0; frame < 30; frame++) { + await tester.pump(const Duration(milliseconds: 16)); + } + if (!inactive) { + tester.binding.handleAppLifecycleStateChanged( + AppLifecycleState.resumed, + ); + await tester.pumpAndSettle(); + expect(FocusManager.instance.primaryFocus, same(newer)); + } + } + if (scenario == 'newer owner then scope') { + newer.unfocus(); + await tester.pumpAndSettle(); + } + if (scenario == 'disposed host') { + await tester.pumpWidget(const SizedBox()); + } + final owner = FocusManager.instance.primaryFocus; + barrier.release.complete(); + await dialog; + for (var frame = 0; frame < 30; frame++) { + await tester.pump(const Duration(milliseconds: 16)); + expect(FocusManager.instance.primaryFocus, same(owner)); + } + if (inactive) { + tester.binding.handleAppLifecycleStateChanged( + AppLifecycleState.resumed, + ); + await tester.pumpAndSettle(); + expect( + FocusManager.instance.primaryFocus, + same(scenario.contains('newer') ? newer : previous), + ); + } + if (scenario.contains('newer') && scenario != 'newer owner then scope') { + expect(tester.testTextInput.hasAnyClients, isTrue); + tester.testTextInput.updateEditingValue( + const TextEditingValue( + text: 'search!', + selection: TextSelection.collapsed(offset: 7), + ), + ); + await tester.pumpAndSettle(); + expect(text.text, 'search!'); + } + expect(tester.takeException(), isNull); + }, variant: TargetPlatformVariant.only(TargetPlatform.linux)); + } +} + +class _GatedFocusBarrierService extends LinuxHeaderBarService { + _GatedFocusBarrierService() + : super(channel: const MethodChannel('com.busymark.test/deferred-focus')); + + final release = Completer(); + bool releaseStarted = false; + + @override + Future setModalBarrierDepth(int depth) async { + if (depth == 0) { + releaseStarted = true; + await release.future; + } + } +} + class _FailingModalBarrierService extends LinuxHeaderBarService { final transitions = []; var _failNextShow = true; diff --git a/test/src/busymark_document_test.dart b/test/src/busymark_document_test.dart index cebc3d7a..8262facd 100644 --- a/test/src/busymark_document_test.dart +++ b/test/src/busymark_document_test.dart @@ -42,6 +42,13 @@ import 'package:yaru/yaru.dart'; import '../support/memory_rich_clipboard.dart'; +Iterable _structuralBlocks(Iterable blocks) sync* { + for (final block in blocks) { + yield block; + yield* _structuralBlocks(block.children); + } +} + void main() { const parser = MarkdownParser(); @@ -750,6 +757,69 @@ void main() {} expect(reparsedParent.children.single.plainText, 'Changed'); }); + for (final mode in MarkdownMode.values) { + for (final marker in ['-', '3.', '- [ ]']) { + for (final loose in [false, true]) { + test( + 'nested task ownership survives parent edits $mode $marker loose=$loose', + () { + final indent = marker == '3.' ? ' ' : ' '; + final source = + '$marker Parent\n${loose ? '\n' : ''}$indent- [x] Child\n$indent - [ ] Grandchild\n'; + final document = parser + .parse(filePath: 'tasks.md', source: source, mode: mode) + .busyDocument; + final controller = BusyMarkWysiwygDocumentController( + document: document, + ); + addTearDown(controller.dispose); + void check(BusyDocument value, String parentText) { + final blocks = _structuralBlocks(value.blocks).toList(); + final parent = blocks.singleWhere( + (b) => b.plainText == parentText, + ); + expect( + parent.kind, + marker == '3.' + ? BusyBlockKind.orderedListItem + : marker == '-' + ? BusyBlockKind.unorderedListItem + : BusyBlockKind.taskListItem, + ); + final child = _structuralBlocks( + parent.children, + ).singleWhere((b) => b.plainText == 'Child'); + final grandchild = _structuralBlocks( + child.children, + ).singleWhere((b) => b.plainText == 'Grandchild'); + expect(child.kind, BusyBlockKind.taskListItem); + expect(child.attributes['task'], 'true'); + expect(grandchild.kind, BusyBlockKind.taskListItem); + expect(grandchild.attributes['task'], 'false'); + } + + check(controller.document, 'Parent'); + final parent = _structuralBlocks( + controller.document.blocks, + ).singleWhere((b) => b.plainText == 'Parent'); + controller.updateBlockText(parent.id, 'Changed'); + final saved = controller.markdown; + expect(saved, contains('[x] Child')); + expect(saved, contains('[ ] Grandchild')); + controller.rebaseCommittedSource(saved); + check(controller.document, 'Changed'); + check( + parser + .parse(filePath: 'tasks.md', source: saved, mode: mode) + .busyDocument, + 'Changed', + ); + }, + ); + } + } + } + test('WYSIWYG nested blockquote edit preserves surrounding source', () { const source = 'Before spacing\n\n' @@ -5710,6 +5780,196 @@ void main() {} }, ); + test( + 'heading leading Enter and Backspace are inverse for generated and explicit H1-H6', + () { + for (var level = 1; level <= 6; level++) { + for (final explicit in [false, true]) { + final source = + 'Intro\n\n${'#' * level} About the job${explicit ? ' {id="job"}' : ''}\n'; + final document = parser + .parse(filePath: 'heading.md', source: source) + .busyDocument; + final heading = document.blocks.last; + final controller = BusyMarkWysiwygDocumentController( + document: document, + ); + addTearDown(controller.dispose); + final inserted = controller.applyEnterAt(heading.id, 0); + expect(inserted?.blockId, heading.id); + expect(controller.document.blocks[1].kind, BusyBlockKind.paragraph); + final removed = controller.applyBackspaceAtStart(heading.id); + expect(removed?.blockId, heading.id); + expect(removed?.offset, 0); + expect( + controller.document.blocks.last.attributes, + heading.attributes, + ); + expect(controller.document.blocks.last.inlines, heading.inlines); + expect(controller.markdown, source); + } + } + }, + ); + + test( + 'empty heading Enter retains the established empty-heading/end behavior', + () { + final document = parser + .parse(filePath: 'heading.md', source: '##\n') + .busyDocument; + final heading = document.blocks.single; + expect(heading.kind, BusyBlockKind.heading); + expect(heading.plainText, isEmpty); + final controller = BusyMarkWysiwygDocumentController(document: document); + addTearDown(controller.dispose); + controller.applyEnterAt(heading.id, 0); + expect(controller.document.blocks.map((b) => b.kind), [ + BusyBlockKind.heading, + BusyBlockKind.paragraph, + ]); + expect(controller.document.blocks.first.id, heading.id); + }, + ); + + test('newline input into an empty heading retains its end-split policy', () { + final controller = BusyMarkWysiwygDocumentController( + document: parser + .parse(filePath: 'heading.md', source: '##\n') + .busyDocument, + ); + addTearDown(controller.dispose); + final heading = controller.document.blocks.single; + controller.replaceBlockTextWithParagraphs(heading.id, '\nAbout', 6); + expect(controller.document.blocks.first.id, heading.id); + expect(controller.document.blocks.first.kind, BusyBlockKind.heading); + expect(controller.document.blocks.first.plainText, isEmpty); + expect(controller.document.blocks.last.kind, BusyBlockKind.paragraph); + expect(controller.document.blocks.last.plainText, 'About'); + }); + + test( + 'leading zero-width heading objects stay in the retained heading for both split paths', + () { + final document = parser + .parse( + filePath: 'heading.md', + source: '## **![](first.png)![](second.png)About**\n', + ) + .busyDocument; + final heading = document.blocks.single; + expect(busyInlineSemanticAnchors(heading.inlines).map((a) => a.offset), [ + 0, + 0, + ], reason: heading.plainText); + for (final input in [false, true]) { + final controller = BusyMarkWysiwygDocumentController( + document: document, + ); + addTearDown(controller.dispose); + if (input) { + controller.replaceBlockTextWithParagraphs(heading.id, '\n\nAbout', 2); + } else { + controller.applyEnterAt(heading.id, 0); + } + final kept = controller.document.blocks.last; + expect(kept.id, heading.id); + expect(kept.kind, BusyBlockKind.heading); + final anchors = busyInlineSemanticAnchors(kept.inlines); + expect(anchors.map((a) => a.offset), [0, 0]); + expect( + anchors.map((a) => a.inline.kind), + everyElement(BusyInlineKind.strong), + ); + expect( + anchors.expand((a) => a.inline.children).map((i) => i.destination), + ['first.png', 'second.png'], + ); + expect( + controller.document.blocks + .take(controller.document.blocks.length - 1) + .every((b) => busyInlineSemanticAnchors(b.inlines).isEmpty), + isTrue, + ); + expect(controller.markdown, contains('## **![](first.png)')); + } + }, + ); + + test( + 'Backspace does not treat a semantic-only paragraph as removable whitespace', + () { + final document = parser + .parse( + filePath: 'heading.md', + source: 'a![](image.png)b\n\n## Heading\n', + ) + .busyDocument; + final paragraph = document.blocks.first; + final heading = document.blocks.last; + final controller = BusyMarkWysiwygDocumentController(document: document); + addTearDown(controller.dispose); + controller.updateBlockText(paragraph.id, ''); + expect(controller.blockById(paragraph.id)!.plainText, isEmpty); + final result = controller.applyBackspaceAtStart(heading.id); + expect(result?.blockId, paragraph.id); + expect(controller.markdown, contains('![](image.png)')); + expect(controller.document.blocks.single.kind, BusyBlockKind.paragraph); + }, + ); + + test('nested Backspace does not cross a structural owner boundary', () { + final document = parser + .parse(filePath: 'heading.md', source: 'Outside\n\n> ## Heading\n') + .busyDocument; + final heading = document.blocks.last.children.single; + final controller = BusyMarkWysiwygDocumentController(document: document); + addTearDown(controller.dispose); + expect(controller.applyBackspaceAtStart(heading.id), isNull); + expect(controller.markdown, 'Outside\n\n> ## Heading\n'); + }); + + test( + 'empty task items retain checkbox semantics after Enter and source rebinding', + () { + for (final mode in MarkdownMode.values) { + final document = parser + .parse(filePath: 'tasks.md', mode: mode, source: '- [ ] Parent\n') + .busyDocument; + final controller = BusyMarkWysiwygDocumentController( + document: document, + ); + addTearDown(controller.dispose); + final parent = controller.document.blocks.firstWhere( + (b) => b.kind == BusyBlockKind.taskListItem, + orElse: () => controller.document.blocks.single.children.single, + ); + final next = controller.applyEnterAt( + parent.id, + parent.plainText.length, + )!; + final saved = controller.markdown; + final reopened = parser + .parse(filePath: 'tasks.md', source: saved, mode: mode) + .busyDocument; + final tasks = _structuralBlocks( + reopened.blocks, + ).where((b) => b.kind == BusyBlockKind.taskListItem).toList(); + expect(tasks, hasLength(2), reason: '$mode $saved'); + expect(tasks.last.plainText, isEmpty); + controller.rebaseCommittedSource(saved); + expect( + controller.blockById(parent.id)?.kind, + BusyBlockKind.taskListItem, + ); + expect( + controller.blockById(next.blockId)?.kind, + BusyBlockKind.taskListItem, + ); + } + }, + ); + 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/source_editor_widget_test.dart b/test/src/source_editor_widget_test.dart index 0373f0fe..30f22718 100644 --- a/test/src/source_editor_widget_test.dart +++ b/test/src/source_editor_widget_test.dart @@ -56,10 +56,233 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:markdown/markdown.dart' as md; import 'package:yaru/yaru.dart'; +import '../support/desktop_focus.dart'; import '../support/document_history_harness.dart'; void main() { + for (final wrap in [false, true]) { + testWidgets( + 'merged Source document swap retains focus scroll protection wrap=$wrap', + (tester) async { + enableDesktopFocusLifecycle(tester); + final key = GlobalKey(); + final source = List.generate( + 180, + (i) => 'line $i ${'reading words ' * 60}', + ).join('\n'); + final edits = <(String, String)>[]; + Widget host(String id) => MaterialApp( + home: Scaffold( + body: BusyMarkSourceEditor( + key: key, + text: source, + documentId: id, + filePath: '$id.md', + language: SourceSyntaxLanguage.markdown, + diagnostics: const [], + editorFontSize: 14, + wordWrap: wrap, + searchActive: false, + searchOptions: const SourceSearchOptions(), + onCloseSearch: () {}, + onChanged: (text, _) => edits.add((id, text)), + ), + ), + ); + await tester.pumpWidget(host('first')); + await tester.pumpAndSettle(); + await tester.showKeyboard(find.byType(TextField)); + final oldFocus = tester + .widget(find.byType(TextField)) + .focusNode; + await tester.pumpWidget(host('second')); + await tester.pumpAndSettle(); + final field = tester.widget(find.byType(TextField)); + expect(field.focusNode, isNot(same(oldFocus))); + field.focusNode!.requestFocus(); + field.controller!.selection = const TextSelection.collapsed(offset: 5); + await tester.pumpAndSettle(); + expect(tester.testTextInput.hasAnyClients, isTrue); + field.scrollController!.jumpTo(1800); + final horizontal = find.byKey( + const ValueKey('source-horizontal-scroll-view'), + ); + ScrollController? horizontalController; + if (!wrap) { + horizontalController = tester + .widget(horizontal) + .controller; + horizontalController!.jumpTo(500); + } + await tester.pumpAndSettle(); + final vertical = field.scrollController!.offset; + final left = horizontalController?.offset; + expect(vertical, greaterThan(1000)); + await pumpDesktopFocusCycle( + tester, + target: field.focusNode!, + inactiveFrames: 90, + verifyViewport: () { + expect(field.scrollController!.offset, closeTo(vertical, 0.1)); + if (left != null) { + expect(horizontalController!.offset, closeTo(left, 0.1)); + } + expect( + field.controller!.selection, + const TextSelection.collapsed(offset: 5), + ); + }, + ); + expect(edits, isEmpty); + tester.testTextInput.updateEditingValue( + TextEditingValue( + text: source.replaceRange(5, 5, 'Z'), + selection: const TextSelection.collapsed(offset: 6), + ), + ); + await tester.pumpAndSettle(); + expect(edits, [('second', source.replaceRange(5, 5, 'Z'))]); + expect(field.scrollController!.offset, lessThan(100)); + if (left != null) expect(horizontalController!.offset, lessThan(100)); + }, + variant: TargetPlatformVariant.only(TargetPlatform.linux), + ); + } + + testWidgets( + 'new Source scrolling takes precedence over pending session restoration', + (tester) async { + double? requested; + tester.binding.addPostFrameCallback((_) { + final position = tester + .widget(find.byType(TextField)) + .scrollController! + .position; + position.pointerScroll(600); + requested = position.pixels; + }); + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: BusyMarkSourceEditor( + text: List.generate(300, (i) => 'Line $i for reading').join('\n'), + filePath: '/source-pending.md', + language: SourceSyntaxLanguage.markdown, + diagnostics: const [], + editorFontSize: 14, + wordWrap: true, + searchActive: false, + searchOptions: const SourceSearchOptions(), + onChanged: (_, _) => fail('Scroll must not edit'), + onCloseSearch: () {}, + initialScrollOffset: 1200, + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(requested, 600); + final field = tester.widget(find.byType(TextField)); + expect(field.scrollController!.offset, closeTo(requested!, 0.1)); + }, + ); + + for (final wrap in [true, false]) { + for (final composing in [false, true]) { + testWidgets( + 'desktop Source return preserves direct scroll wrap=$wrap composing=$composing', + (tester) async { + enableDesktopFocusLifecycle(tester); + final source = List.generate( + 150, + (i) => 'line $i ${'words ' * 100}', + ).join('\n'); + var edits = 0; + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: BusyMarkSourceEditor( + text: source, + filePath: '/disposable-focus.md', + language: SourceSyntaxLanguage.markdown, + diagnostics: const [], + editorFontSize: 14, + wordWrap: wrap, + searchActive: false, + searchOptions: const SourceSearchOptions(), + onChanged: (_, _) => edits++, + onOpenSearch: () {}, + onCloseSearch: () {}, + ), + ), + ), + ); + await tester.pumpAndSettle(); + final field = tester.widget(find.byType(TextField)); + field.focusNode!.requestFocus(); + field.controller!.selection = const TextSelection( + baseOffset: 8, + extentOffset: 3, + ); + await tester.pumpAndSettle(); + if (composing) { + tester.testTextInput.updateEditingValue( + TextEditingValue( + text: source, + selection: const TextSelection.collapsed(offset: 5), + composing: const TextRange(start: 0, end: 9), + ), + ); + await tester.pumpAndSettle(); + } + field.scrollController!.jumpTo(1800); + final horizontal = tester + .widget( + find.byKey(const ValueKey('source-horizontal-scroll-view')), + ) + .controller!; + if (!wrap) horizontal.jumpTo(400); + await tester.pumpAndSettle(); + final verticalOffset = field.scrollController!.offset; + final horizontalOffset = horizontal.offset; + final value = field.controller!.value; + await pumpDesktopFocusCycle( + tester, + target: field.focusNode!, + inactiveFrames: 90, + verifyViewport: () { + expect( + field.scrollController!.offset, + closeTo(verticalOffset, 0.1), + ); + expect(horizontal.offset, closeTo(horizontalOffset, 0.1)); + expect(field.controller!.selection, value.selection); + }, + ); + expect(edits, 0); + expect(field.controller!.text, source); + tester.testTextInput.updateEditingValue( + value.copyWith( + text: source.replaceRange(3, 8, 'Z'), + selection: const TextSelection.collapsed(offset: 4), + composing: TextRange.empty, + ), + ); + await tester.pumpAndSettle(); + expect(edits, 1); + expect(field.scrollController!.offset, lessThan(100)); + expect(horizontal.offset, lessThan(100)); + }, + variant: TargetPlatformVariant.only(TargetPlatform.linux), + ); + } + } + testWidgets( 'Source identity changes keep the focused input client registered', (tester) async { @@ -319,7 +542,6 @@ void main() { await tester.pumpWidget(const SizedBox.shrink()); }, ); - testWidgets( 'Source consumes shared pages and replaces in an unsaved buffer', (tester) async { diff --git a/test/src/spelling_workspace_test.dart b/test/src/spelling_workspace_test.dart index cf4baf83..edaa692a 100644 --- a/test/src/spelling_workspace_test.dart +++ b/test/src/spelling_workspace_test.dart @@ -38,6 +38,7 @@ import 'package:path/path.dart' as p; import '../support/spelling_test_bundle.dart'; import '../support/widget_async.dart'; +import '../support/desktop_focus.dart'; void main() { final l10n = AppLocalizationsEn(); @@ -79,6 +80,145 @@ void main() { } }); + for (final newerOwner in [false, true]) { + testWidgets( + 'delayed spelling review return respects newerOwner=$newerOwner', + (tester) async { + enableDesktopFocusLifecycle(tester); + final barrier = _ReviewFocusBarrier(); + addTearDown(() { + if (!barrier.release.isCompleted) barrier.release.complete(); + barrier.dispose(); + }); + const original = 'helo world\n'; + final harness = await _pumpWorkspace( + tester, + source: original, + headerBarService: barrier, + ); + await _until( + tester, + () => + harness.spelling.state.complete && + harness.spelling.misspellings.isNotEmpty, + ); + final field = find.byWidgetPredicate( + (w) => w is TextField && w.controller?.text == 'helo world', + ); + await tester.tap(field, pointer: 1401); + final body = tester.widget(field); + await tester.sendKeyEvent(LogicalKeyboardKey.f7); + final dialog = find.byType(BusyMarkSpellingReviewDialog); + await _until(tester, () => dialog.evaluate().isNotEmpty); + final selection = body.controller!.selection; + final before = harness.workspace.activeBuffer!; + final newer = FocusNode(debugLabel: 'Search after spelling review'); + final search = TextEditingController(text: 'Search'); + OverlayEntry? entry; + addTearDown(() { + entry?.remove(); + entry?.dispose(); + newer.dispose(); + search.dispose(); + }); + await tester.tap( + find.descendant(of: dialog, matching: find.text(l10n.close)), + pointer: 1402, + ); + await _until( + tester, + () => barrier.releaseStarted && dialog.evaluate().isEmpty, + ); + if (newerOwner) { + entry = OverlayEntry( + builder: (_) => Positioned( + left: 1000, + top: 30, + width: 220, + height: 40, + child: Material( + child: TextField(focusNode: newer, controller: search), + ), + ), + ); + Overlay.of( + tester.element(find.byType(BusyMarkWysiwygEditor)), + ).insert(entry); + await tester.pumpAndSettle(); + newer.requestFocus(); + await tester.pumpAndSettle(); + expect(FocusManager.instance.primaryFocus, same(newer)); + } + barrier.release.complete(); + for (var frame = 0; frame < 30; frame++) { + tester.binding.scheduleFrame(); + await tester.pump(const Duration(milliseconds: 16)); + expect( + FocusManager.instance.primaryFocus, + same(newerOwner ? newer : body.focusNode), + ); + } + final after = harness.workspace.activeBuffer!; + expect(after.text, original); + expect(after.revision, before.revision); + expect(after.dirty, before.dirty); + expect( + after.editorState.undoState.undo, + before.editorState.undoState.undo, + ); + expect( + after.editorState.undoState.redo, + before.editorState.undoState.redo, + ); + expect(body.controller!.selection, selection); + expect(tester.testTextInput.hasAnyClients, isTrue); + final value = body.controller!.value; + final text = newerOwner + ? 'Search!' + : value.text.replaceRange(selection.start, selection.end, 'Z'); + tester.testTextInput.updateEditingValue( + TextEditingValue( + text: text, + selection: TextSelection.collapsed( + offset: newerOwner ? 7 : selection.start + 1, + ), + ), + ); + await tester.pumpAndSettle(); + if (newerOwner) { + expect(search.text, 'Search!'); + expect(harness.workspace.activeText, original); + expect(FocusManager.instance.primaryFocus, same(newer)); + } else { + expect(harness.workspace.activeText, '$text\n'); + harness.controller.undoActiveBuffer(); + await _until(tester, () => harness.workspace.activeText == original); + await _until( + tester, + () => + harness.spelling.state.complete && + harness.spelling.misspellings.any( + (word) => + word.run.snapshot.contentRevision == + harness.workspace.activeBuffer!.revision, + ), + ); + } + if (entry != null) { + entry.remove(); + await tester.pump(); + entry.dispose(); + entry = null; + } + await tester.pumpWidget(const SizedBox.shrink()); + // Cancel post-undo preparation before the fake-clock invariants, but + // retire the native worker in the real async zone like provider teardown. + await tester.runAsync(() async => harness.spelling.dispose()); + }, + variant: TargetPlatformVariant.only(TargetPlatform.linux), + ); + } + testWidgets( 'Writerside list hard break supports underlines and spelling review', (tester) async { @@ -1575,6 +1715,7 @@ Future<_Harness> _pumpWorkspace( bool automaticSpelling = true, SpellingDictionaryFileDownload? downloadFile, SpellingCoordinatorStarter? coordinatorStarter, + LinuxHeaderBarService? headerBarService, }) async { await tester.binding.setSurfaceSize(const Size(1400, 1000)); addTearDown(() => tester.binding.setSurfaceSize(null)); @@ -1629,7 +1770,9 @@ Future<_Harness> _pumpWorkspace( documentRecoveryStoreProvider.overrideWithValue( MemoryDocumentRecoveryStore(), ), - linuxHeaderBarServiceProvider.overrideWithValue(_HeaderBar()), + linuxHeaderBarServiceProvider.overrideWithValue( + headerBarService ?? _HeaderBar(), + ), systemAccentColorProvider.overrideWith((ref) => const Stream.empty()), spellingSessionControllerProvider.overrideWith((ref) => spelling), ], @@ -1763,6 +1906,19 @@ class _HeaderBar extends LinuxHeaderBarService { Stream get actions => const Stream.empty(); } +class _ReviewFocusBarrier extends _HeaderBar { + final release = Completer(); + bool releaseStarted = false; + + @override + Future setModalBarrierDepth(int depth) async { + if (depth == 0) { + releaseStarted = true; + await release.future; + } + } +} + Future _copyDictionaryDownload({ required Uri source, required File destination, diff --git a/test/src/writerside_authoring_ui_test.dart b/test/src/writerside_authoring_ui_test.dart index 88de976a..14fee4c8 100644 --- a/test/src/writerside_authoring_ui_test.dart +++ b/test/src/writerside_authoring_ui_test.dart @@ -14,6 +14,7 @@ import 'package:busymark/src/markdown/busymark_document.dart'; import 'package:busymark/src/markdown/markdown_model.dart'; import 'package:busymark/src/markdown/markdown_parser.dart'; import 'package:busymark/src/platform/native_menu_service.dart'; +import 'package:busymark/src/platform/linux_header_bar_service.dart'; import 'package:busymark/src/writerside/writerside_project.dart'; import 'package:busymark/src/writerside/writerside_effective_properties.dart'; import 'package:busymark/src/writerside/writerside_model.dart'; @@ -24,6 +25,29 @@ import 'package:busymark/src/editor/wysiwyg/wysiwyg_block_widgets.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import '../support/desktop_focus.dart'; + +class _GatedSemanticDialogBarrier extends LinuxHeaderBarService { + _GatedSemanticDialogBarrier() + : super(channel: const MethodChannel('busymark/test/semantic-focus')); + + final release = Completer(); + bool releaseStarted = false; + bool releaseFinished = false; + + @override + bool get isAvailable => true; + + @override + Future setModalBarrierDepth(int depth) async { + if (depth == 0) { + releaseStarted = true; + await release.future; + releaseFinished = true; + } + } +} + BusyDocument _document(bool xml, {String? source}) => xml ? const WritersideEditingAdapter().parseXml( filePath: 'a.topic', @@ -692,6 +716,220 @@ void main() { await tester.pumpAndSettle(); } + for (final xml in [false, true]) { + for (final menuName in ['Writerside', 'Text style']) { + for (final range in [false, true]) { + for (final owner in ['editor', 'trigger', 'newer', 'other trigger']) { + testWidgets( + '${xml ? "XML" : "Markdown"} delayed toolbar menu $menuName range=$range owner=$owner', + (tester) async { + enableDesktopFocusLifecycle(tester); + final result = Completer(); + final newer = FocusNode(debugLabel: 'Search during menu cleanup'); + final search = TextEditingController(text: 'Search'); + OverlayEntry? entry; + addTearDown(() { + if (!result.isCompleted) result.complete(null); + entry?.remove(); + entry?.dispose(); + newer.dispose(); + search.dispose(); + }); + final paragraphs = [ + 'Text', + for (var i = 0; i < 80; i++) + 'Reading $i ${'words ' * 16}'.trimRight(), + ]; + final original = xml + ? '${paragraphs.map((p) => '

$p

').join()}
' + : '# Title\n\n${paragraphs.join('\n\n')}\n'; + final document = _document(xml, source: original); + var saved = original; + final changes = []; + final key = GlobalKey(); + await mount( + tester, + BusyMarkWysiwygEditor( + key: key, + document: document, + onSourceChanged: (_, source) { + saved = source; + changes.add(source); + }, + ), + openProperties: false, + ); + await tester.tap(_textField('Text'), pointer: 1301); + final body = tester.widget(_textField('Text')); + final selection = range + ? const TextSelection(baseOffset: 4, extentOffset: 1) + : const TextSelection.collapsed(offset: 2); + body.controller!.selection = selection; + await tester.pumpAndSettle(); + final position = Scrollable.of( + tester.element(_textField('Text')), + axis: Axis.vertical, + ).position; + position.jumpTo(2500); + await tester.pumpAndSettle(); + final pixels = position.pixels; + final reading = + _contentFields().evaluate().firstWhere((e) { + final text = (e.widget as TextField).controller!.text; + final y = tester.getTopLeft(find.byWidget(e.widget)).dy; + return text.startsWith('Reading ') && + y > 100 && + y < 650; + }).widget + as TextField; + final readingText = reading.controller!.text; + final readingTop = tester.getTopLeft(_textField(readingText)).dy; + final generation = key.currentState!.spellingDocumentGeneration; + Finder menu(String name) => find.byWidgetPredicate( + (w) => w is BusyMarkHeaderPopupMenuButton && w.tooltip == name, + ); + FocusNode trigger(String name) { + final focus = tester.widget( + find + .descendant( + of: menu(name), + matching: find.byWidgetPredicate((w) => w is Focus), + ) + .last, + ); + return Focus.of(tester.element(find.byWidget(focus.child))); + } + + var presented = false; + binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, ( + call, + ) async { + if (call.method != 'show') return true; + presented = true; + return result.future; + }); + await tester.tap(menu(menuName), pointer: 1302); + await tester.pumpAndSettle(); + expect(presented, isTrue); + FocusNode expected = body.focusNode!; + switch (owner) { + case 'editor': + FocusManager.instance.primaryFocus?.unfocus(); + case 'trigger': + trigger(menuName).requestFocus(); + case 'other trigger': + expected = trigger( + menuName == 'Writerside' ? 'Text style' : 'Writerside', + ); + expected.requestFocus(); + case 'newer': + entry = OverlayEntry( + builder: (_) => Positioned( + left: 750, + top: 0, + width: 220, + height: 40, + child: Material( + child: TextField(focusNode: newer, controller: search), + ), + ), + ); + Overlay.of( + tester.element(find.byType(BusyMarkWysiwygEditor)), + ).insert(entry); + await tester.pumpAndSettle(); + expected = newer; + newer.requestFocus(); + } + FocusManager.instance.applyFocusChangesIfNeeded(); + if (owner == 'trigger') { + expect( + FocusManager.instance.primaryFocus, + same(trigger(menuName)), + ); + } else if (owner == 'newer' || owner == 'other trigger') { + expect(FocusManager.instance.primaryFocus, same(expected)); + } + // This is the same document, not the stale-document test above. + expect(key.currentState!.widget.document, same(document)); + expect(key.currentState!.spellingDocumentGeneration, generation); + result.complete(null); + for (var frame = 0; frame < 30; frame++) { + tester.binding.scheduleFrame(); + await tester.pump(const Duration(milliseconds: 16)); + expect(FocusManager.instance.primaryFocus, same(expected)); + expect(position.pixels, closeTo(pixels, 0.1)); + expect( + tester.getTopLeft(_textField(readingText)).dy, + closeTo(readingTop, 0.1), + ); + } + expect(body.controller!.selection, selection); + expect(saved, original); + expect(changes, isEmpty); + expect(key.currentState!.debugUndoSnapshotCount, 0); + if (owner == 'other trigger') return; + expect(tester.testTextInput.hasAnyClients, isTrue); + final text = owner == 'newer' + ? 'Search!' + : 'Text'.replaceRange(selection.start, selection.end, '!'); + tester.testTextInput.updateEditingValue( + TextEditingValue( + text: text, + selection: TextSelection.collapsed( + offset: owner == 'newer' ? 7 : selection.start + 1, + ), + ), + ); + await tester.pumpAndSettle(); + expect(FocusManager.instance.primaryFocus, same(expected)); + if (owner == 'newer') { + expect(search.text, 'Search!'); + tester.testTextInput.updateEditingValue( + const TextEditingValue( + text: 'Search!!', + selection: TextSelection.collapsed(offset: 8), + ), + ); + await tester.pumpAndSettle(); + expect(search.text, 'Search!!'); + expect(FocusManager.instance.primaryFocus, same(newer)); + expect(body.controller!.selection, selection); + expect(saved, original); + expect(changes, isEmpty); + expect(key.currentState!.debugUndoSnapshotCount, 0); + expect(position.pixels, closeTo(pixels, 0.1)); + } else { + expect(saved, original.replaceFirst('Text', text)); + expect(changes, hasLength(1)); + expect(key.currentState!.debugUndoSnapshotCount, 1); + expect( + tester.getTopLeft(_textField(text)).dy, + inInclusiveRange(0, 850), + ); + final edited = saved; + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyZ); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pumpAndSettle(); + expect(saved, original); + 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.pumpAndSettle(); + expect(saved, edited); + } + expect(tester.takeException(), isNull); + }, + variant: TargetPlatformVariant.only(TargetPlatform.linux), + ); + } + } + } + } + for (final xml in [false, true]) { testWidgets( '${xml ? 'XML' : 'Markdown'} canceled native menu restores editing focus and selection', @@ -806,6 +1044,186 @@ void main() { ); } } + for (final xml in [false, true]) { + for (final label in ['UI Control', 'File / Path']) { + for (final newerOwner in [false, true]) { + testWidgets( + '${xml ? "XML" : "Markdown"} delayed semantic cancellation $label newerOwner=$newerOwner', + (tester) async { + enableDesktopFocusLifecycle(tester); + final barrier = _GatedSemanticDialogBarrier(); + final newer = FocusNode(debugLabel: 'Search after semantic dialog'); + final search = TextEditingController(text: 'Search'); + OverlayEntry? entry; + addTearDown(() { + if (!barrier.release.isCompleted) barrier.release.complete(); + entry?.remove(); + entry?.dispose(); + newer.dispose(); + search.dispose(); + barrier.dispose(); + }); + final paragraphs = [ + 'Text', + for (var i = 0; i < 80; i++) + 'Reading $i ${'words ' * 16}'.trimRight(), + ]; + final original = xml + ? '${paragraphs.map((p) => '

$p

').join()}
' + : '# Title\n\n${paragraphs.join('\n\n')}\n'; + var saved = original; + final changes = []; + final key = GlobalKey(); + await mount( + tester, + BusyMarkWysiwygEditor( + key: key, + document: _document(xml, source: original), + headerBarService: barrier, + onSourceChanged: (_, source) { + saved = source; + changes.add(source); + }, + ), + openProperties: false, + ); + await tester.tap(_textField('Text'), pointer: 1201); + final body = tester.widget(_textField('Text')); + const selection = TextSelection.collapsed(offset: 2); + body.controller!.selection = selection; + await tester.pumpAndSettle(); + final position = Scrollable.of( + tester.element(_textField('Text')), + axis: Axis.vertical, + ).position; + position.jumpTo(2500); + await tester.pumpAndSettle(); + final pixels = position.pixels; + final reading = + _contentFields().evaluate().firstWhere((e) { + final text = (e.widget as TextField).controller!.text; + final y = tester.getTopLeft(find.byWidget(e.widget)).dy; + return text.startsWith('Reading ') && y > 100 && y < 650; + }).widget + as TextField; + final readingText = reading.controller!.text; + final readingTop = tester.getTopLeft(_textField(readingText)).dy; + final generation = key.currentState!.spellingDocumentGeneration; + expect(key.currentState!.debugUndoSnapshotCount, 0); + labels.add(label); + await tester.tap( + find.byKey(const ValueKey('wysiwyg-writerside')), + pointer: 1202, + ); + await tester.pumpAndSettle(); + expect( + find.byKey(const ValueKey('wysiwyg-semantic-text')), + findsOneWidget, + ); + await tester.tap(find.text('Cancel'), pointer: 1203); + await tester.pumpAndSettle(); + expect(barrier.releaseStarted, isTrue); + expect( + find.byKey(const ValueKey('wysiwyg-semantic-text')), + findsNothing, + ); + if (newerOwner) { + entry = OverlayEntry( + builder: (_) => Positioned( + left: 750, + top: 0, + width: 220, + height: 40, + child: Material( + child: TextField(focusNode: newer, controller: search), + ), + ), + ); + Overlay.of( + tester.element(find.byType(BusyMarkWysiwygEditor)), + ).insert(entry); + await tester.pumpAndSettle(); + newer.requestFocus(); + await tester.pumpAndSettle(); + expect(FocusManager.instance.primaryFocus, same(newer)); + } + expect(key.currentState!.spellingDocumentGeneration, generation); + barrier.release.complete(); + for (var frame = 0; frame < 30; frame++) { + // A gated release can finish after the route animation has + // stopped. Drive the render phase so post-frame restoration + // really runs, rather than only advancing the fake clock. + tester.binding.scheduleFrame(); + await tester.pump(const Duration(milliseconds: 16)); + expect( + FocusManager.instance.primaryFocus, + same(newerOwner ? newer : body.focusNode), + ); + expect(position.pixels, closeTo(pixels, 0.1)); + expect( + tester.getTopLeft(_textField(readingText)).dy, + closeTo(readingTop, 0.1), + ); + } + expect(barrier.releaseFinished, isTrue); + expect( + FocusManager.instance.primaryFocus, + same(newerOwner ? newer : body.focusNode), + ); + expect(body.controller!.selection, selection); + expect(saved, original); + expect(changes, isEmpty); + expect(key.currentState!.debugUndoSnapshotCount, 0); + expect(tester.testTextInput.hasAnyClients, isTrue); + // Use the existing IME connection; enterText would refocus and + // conceal a cancellation callback that stole the newer owner. + tester.testTextInput.updateEditingValue( + TextEditingValue( + text: newerOwner ? 'Search!' : 'Te!xt', + selection: TextSelection.collapsed(offset: newerOwner ? 7 : 3), + ), + ); + await tester.pumpAndSettle(); + expect( + FocusManager.instance.primaryFocus, + same(newerOwner ? newer : body.focusNode), + ); + if (newerOwner) { + expect(search.text, 'Search!'); + expect(saved, original); + expect(changes, isEmpty); + expect(body.controller!.selection, selection); + expect(key.currentState!.debugUndoSnapshotCount, 0); + expect(position.pixels, closeTo(pixels, 0.1)); + } else { + expect(saved, original.replaceFirst('Text', 'Te!xt')); + expect(changes, hasLength(1)); + expect(key.currentState!.debugUndoSnapshotCount, 1); + expect( + tester.getTopLeft(_textField('Te!xt')).dy, + inInclusiveRange(0, 850), + ); + final edited = saved; + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyZ); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pumpAndSettle(); + expect(saved, original); + 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.pumpAndSettle(); + expect(saved, edited); + } + expect(tester.takeException(), isNull); + }, + variant: TargetPlatformVariant.only(TargetPlatform.linux), + ); + } + } + } for (final xml in [true, false]) { for (final pair in const [ ('UI Control', 'control'), @@ -848,6 +1266,8 @@ void main() { await tester.tap(find.text('Cancel')); await tester.pumpAndSettle(); expect(saved, original); + expect(body.focusNode!.hasPrimaryFocus, isTrue); + expect(tester.testTextInput.hasAnyClients, isTrue); expect( body.controller!.selection, const TextSelection.collapsed(offset: 2), diff --git a/test/src/writerside_native_list_inspector_test.dart b/test/src/writerside_native_list_inspector_test.dart index ebb3d241..a7f828cb 100644 --- a/test/src/writerside_native_list_inspector_test.dart +++ b/test/src/writerside_native_list_inspector_test.dart @@ -29,6 +29,75 @@ List lists(BusyDocument d) => walk(d.blocks).where((b) => b.attributes['element'] == 'list').toList(); void main() { + for (final marker in ['-', '3.', '- [ ]']) { + for (final clear in [false, true]) { + test( + 'leading Enter retains native $marker parent ownership after publication clear=$clear', + () { + final indent = marker == '3.' ? ' ' : ' '; + final c = BusyMarkWysiwygDocumentController( + document: parse( + '$marker Parent\n$indent- Child\n$indent - Grandchild\n\n{id="owner"}\n', + ), + ); + addTearDown(c.dispose); + final parent = walk( + c.document.blocks, + ).singleWhere((b) => b.plainText == 'Parent'); + final childIds = walk(parent.children).map((b) => b.id).toList(); + if (clear) { + c.updateBlockText(parent.id, ''); + c.rebaseCommittedSource(c.markdown); + } + final result = c.applyEnterAt(parent.id, 0); + expect(result?.blockId, parent.id); + final published = c.markdown; + c.rebaseCommittedSource(published); + expect(c.markdown, published); + final retained = c.blockById(parent.id)!; + expect(retained.kind, parent.kind); + if (marker == '3.') expect(retained.attributes['marker'], '4.'); + expect( + walk( + retained.children, + ).where((b) => childIds.contains(b.id)).map((b) => b.id), + childIds, + ); + final owner = c.elementPath(parent.id).reversed.skip(1).first; + expect(owner.attributes['busymark-list-form'], 'markdown'); + expect(owner.attributes['id'], 'owner'); + expect(owner.children.first.id, isNot(parent.id)); + expect(owner.children.first.children, isEmpty); + final reopened = parse(published); + expect( + walk(reopened.blocks).where((b) => b.plainText == 'Child'), + hasLength(1), + ); + expect( + walk(reopened.blocks).where((b) => b.plainText == 'Grandchild'), + hasLength(1), + ); + }, + ); + } + } + + test('an authored empty paragraph is not discarded by heading Backspace', () { + final c = BusyMarkWysiwygDocumentController( + document: parse('# Topic\n\nClear\n{id="authored"}\n\n## Heading\n'), + ); + addTearDown(c.dispose); + final paragraph = c.document.blocks.singleWhere( + (b) => b.kind == BusyBlockKind.paragraph, + ); + final heading = c.document.blocks.last; + c.updateBlockText(paragraph.id, ''); + final result = c.applyBackspaceAtStart(heading.id); + expect(result?.blockId, paragraph.id); + expect(c.blockById(paragraph.id)!.attributes['id'], 'authored'); + expect(c.markdown, contains('id="authored"')); + }); + test( 'block lookup follows edits and replacement revisions with reused IDs', () { diff --git a/test/src/wysiwyg_clipboard_test.dart b/test/src/wysiwyg_clipboard_test.dart index 1c8c2117..2afc1e0e 100644 --- a/test/src/wysiwyg_clipboard_test.dart +++ b/test/src/wysiwyg_clipboard_test.dart @@ -1113,6 +1113,169 @@ void main() { await tester.pump(); } + for (final entryPath in ['plain paste', 'text input']) { + testWidgets( + 'whole-heading $entryPath newline replacement with identical suffix is not a prepend', + (tester) async { + const original = '## About the job\n'; + BusyDocument? live; + var source = original; + await mount( + tester, + 'heading-identical-suffix', + original, + (value) => source = value, + onDocumentChanged: (value) => live = value, + ); + final field = tester.widget( + find.widgetWithText(TextField, 'About the job'), + ); + field.controller!.selection = const TextSelection( + baseOffset: 0, + extentOffset: 13, + ); + await tester.pump(); + if (entryPath == 'plain paste') { + systemData = {'text': '\nAbout the job'}; + await key(tester, LogicalKeyboardKey.keyV, shift: true); + } else { + tester.testTextInput.updateEditingValue( + const TextEditingValue( + text: '\nAbout the job', + selection: TextSelection.collapsed(offset: 14), + ), + ); + } + await tester.pumpAndSettle(); + expect( + live!.blocks.map((b) => b.kind), + everyElement(BusyBlockKind.paragraph), + ); + expect(source, isNot(contains('##'))); + await key(tester, LogicalKeyboardKey.keyZ); + await tester.pumpAndSettle(); + expect(source, original); + }, + ); + + for (final end in [0, 5, 13]) { + testWidgets( + 'heading boundary $entryPath replaces prefix $end without losing structure', + (tester) async { + const original = '## About the job {id="job"}\n'; + var source = original; + BusyDocument? live; + var publications = 0; + await mount(tester, 'heading-boundary', original, (value) { + source = value; + publications++; + }, onDocumentChanged: (value) => live = value); + final field = tester.widget( + find.widgetWithText(TextField, 'About the job'), + ); + field.controller!.selection = TextSelection( + baseOffset: 0, + extentOffset: end, + ); + await tester.pump(); + if (entryPath == 'plain paste') { + systemData = {'text': '\n'}; + await key(tester, LogicalKeyboardKey.keyV, shift: true); + } else { + tester.testTextInput.updateEditingValue( + TextEditingValue( + text: '\n${'About the job'.substring(end)}', + selection: const TextSelection.collapsed(offset: 1), + ), + ); + } + await tester.pumpAndSettle(); + expect(publications, 1); + expect(live!.blocks.first.kind, BusyBlockKind.paragraph); + if (end < 13) { + final heading = live!.blocks.last; + expect(heading.kind, BusyBlockKind.heading); + expect(heading.attributes['id'], 'job'); + expect(heading.plainText, 'About the job'.substring(end)); + final target = tester.widget( + find.widgetWithText(TextField, heading.plainText), + ); + expect(target.focusNode!.hasFocus, isTrue); + expect( + target.controller!.selection, + const TextSelection.collapsed(offset: 0), + ); + tester.testTextInput.updateEditingValue( + TextEditingValue( + text: 'X${heading.plainText}', + selection: const TextSelection.collapsed(offset: 1), + ), + ); + await tester.pumpAndSettle(); + expect(source, contains('## X${heading.plainText}')); + await key(tester, LogicalKeyboardKey.keyZ); + await tester.pumpAndSettle(); + } else { + expect( + live!.blocks.where((b) => b.kind == BusyBlockKind.heading), + isEmpty, + ); + } + await key(tester, LogicalKeyboardKey.keyZ); + await tester.pumpAndSettle(); + expect(source, original); + await key(tester, LogicalKeyboardKey.keyZ, shift: true); + await tester.pumpAndSettle(); + expect(live!.blocks.first.kind, BusyBlockKind.paragraph); + expect( + live!.blocks.last.kind, + end < 13 ? BusyBlockKind.heading : BusyBlockKind.paragraph, + ); + }, + ); + } + } + + testWidgets( + 'accepted replacement does not leave stale full-heading selection context', + (tester) async { + var source = '## About the job\n'; + BusyDocument? live; + await mount( + tester, + 'heading-selection-context', + source, + (value) => source = value, + onDocumentChanged: (value) => live = value, + ); + final field = tester.widget( + find.widgetWithText(TextField, 'About the job'), + ); + field.controller!.selection = const TextSelection( + baseOffset: 0, + extentOffset: 13, + ); + await tester.pump(); + tester.testTextInput.updateEditingValue( + const TextEditingValue( + text: 'Other the job', + selection: TextSelection.collapsed(offset: 13), + ), + ); + await tester.pumpAndSettle(); + tester.testTextInput.updateEditingValue( + const TextEditingValue( + text: 'Other the job\n', + selection: TextSelection.collapsed(offset: 14), + ), + ); + await tester.pumpAndSettle(); + expect(live!.blocks.first.kind, BusyBlockKind.heading); + expect(live!.blocks.first.plainText, 'Other the job'); + expect(source, startsWith('## Other the job\n')); + }, + ); + for (final plainText in [false, true]) { testWidgets( 'text-only URL paste then ordinary Enter recognizes a live link (plainText=$plainText)', diff --git a/test/src/wysiwyg_selection_lifecycle_test.dart b/test/src/wysiwyg_selection_lifecycle_test.dart index 5025f4ac..2aa34250 100644 --- a/test/src/wysiwyg_selection_lifecycle_test.dart +++ b/test/src/wysiwyg_selection_lifecycle_test.dart @@ -4,17 +4,27 @@ 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_editor.dart'; +import 'package:busymark/src/app/busymark_dialogs.dart'; +import 'package:busymark/src/app/busymark_design.dart'; +import 'package:busymark/src/app/busymark_glyphs.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/platform/linux_header_bar_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:flutter_riverpod/flutter_riverpod.dart'; +import 'package:busymark/src/visualization/visualization_providers.dart'; +import 'package:busymark/src/visualization/visualization_renderer.dart'; +import 'package:busymark/src/visualization/web_render_host.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import '../support/memory_rich_clipboard.dart'; +import '../support/desktop_focus.dart'; import '../support/wysiwyg_editable_harness.dart'; const _table = @@ -56,6 +66,28 @@ class _DelayedClipboard extends MemoryRichClipboard { } } +class _LifecycleMathHost implements WebRenderHost { + @override + Future> renderMathBatch({ + required List> expressions, + required VisualizationCancellationToken cancellationToken, + }) async => { + 'results': [ + for (final expression in expressions) + { + 'id': expression['id'], + 'svg': + '', + 'width': 20, + 'height': 14, + 'depth': 2, + }, + ], + }; + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + Finder _field(String text) => find.byWidgetPredicate((w) => w is TextField && w.controller?.text == text); @@ -90,19 +122,29 @@ Future _mount( MemoryRichClipboard clipboard, { WysiwygEditorSessionState session = const WysiwygEditorSessionState(), bool externalHistory = false, + bool renderMath = false, BusyMarkClipboardInsertionRegistry? registry, + LinuxHeaderBarService? headerBarService, }) 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, + ProviderScope( + overrides: [ + if (headerBarService != null) + linuxHeaderBarServiceProvider.overrideWithValue(headerBarService), + if (renderMath) + webRenderHostProvider.overrideWithValue(_LifecycleMathHost()), + ], + child: WysiwygEditableHarness( + key: key, + source: source, + clipboard: clipboard, + session: session, + externalHistory: externalHistory, + insertionRegistry: registry, + ), ), ); await tester.pumpAndSettle(); @@ -250,7 +292,1451 @@ void _expectTableGeometry(WidgetTester tester) { } } +void _deferredExtentFocusRegressions() { + for (final table in [false, true]) { + testWidgets( + 'deferred extent preserves new reading geometry and input table=$table', + (tester) async { + enableDesktopFocusLifecycle(tester); + addTearDown( + () => tester.binding.handleAppLifecycleStateChanged( + AppLifecycleState.resumed, + ), + ); + final source = + '${table ? _surrounded : '$_before\n\nMiddle selected.\n\n$_after\n'}\n${[for (var i = 0; i < 80; i++) 'Reading $i ${'words ' * 12}'.trimRight()].join('\n\n')}\n'; + final state = await _mount(tester, source, MemoryRichClipboard()); + final gesture = await tester.startGesture( + _caret(tester, _before, 2), + pointer: 1101, + kind: PointerDeviceKind.mouse, + ); + await gesture.moveTo( + _caret(tester, table ? 'End!' : _after, table ? 4 : 5), + ); + await tester.pumpAndSettle(); + final saved = state.session; + final position = tester + .state( + find + .descendant( + of: find.byType(ScrollablePositionedList), + matching: find.byType(Scrollable), + ) + .first, + ) + .position; + await gesture.up(); + position.jumpTo(2500); + tester.binding.handleAppLifecycleStateChanged( + AppLifecycleState.inactive, + ); + expect( + FocusManager.instance.primaryFocus, + same(FocusManager.instance.rootScope), + ); + for (var frame = 0; frame < 90; frame++) { + await tester.pump(const Duration(milliseconds: 16)); + expect( + FocusManager.instance.primaryFocus, + same(FocusManager.instance.rootScope), + ); + expect(position.pixels, closeTo(2500, 0.1)); + } + final anchorText = tester + .widgetList(find.byType(TextField)) + .where((w) => w.controller!.text.startsWith('Reading ')) + .map((w) => w.controller!.text) + .firstWhere( + (text) => + tester.getTopLeft(_field(text)).dy > 100 && + tester.getTopLeft(_field(text)).dy < 900, + ); + final anchorTop = tester.getTopLeft(_field(anchorText)).dy; + tester.binding.handleAppLifecycleStateChanged( + AppLifecycleState.resumed, + ); + for (var frame = 0; frame < 30; frame++) { + await tester.pump(const Duration(milliseconds: 16)); + expect(position.pixels, closeTo(2500, 0.1)); + expect( + tester.getTopLeft(_field(anchorText)).dy, + closeTo(anchorTop, 0.1), + ); + } + expect(state.changes, isEmpty); + expect(state.editorKey.currentState!.debugUndoSnapshotCount, 0); + expect( + tester + .widgetList( + find.byType(BusyMarkWysiwygBlockField), + ) + .length, + lessThan(state.document.blocks.length), + ); + 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(tester.testTextInput.hasAnyClients, isTrue); + final value = TextEditingValue.fromJSON( + tester.testTextInput.editingState!, + ); + final offset = value.selection.extentOffset; + tester.testTextInput.updateEditingValue( + TextEditingValue( + text: value.text.replaceRange(offset, offset, 'Z'), + selection: TextSelection.collapsed(offset: offset + 1), + ), + ); + await tester.pumpAndSettle(); + expect(state.changes, hasLength(1)); + expect(state.source, startsWith('BeZ')); + final changedText = state.document.blocks.first.plainText; + expect(_caret(tester, changedText, 3).dy, inInclusiveRange(0, 1100)); + await _command(tester, LogicalKeyboardKey.keyZ); + expect(state.source, source); + }, + variant: TargetPlatformVariant.only(TargetPlatform.linux), + ); + } + testWidgets( + 'deferred toolbar restoration honors a pending newer focus request', + (tester) async { + final state = await _mount( + tester, + 'Original target.\n', + MemoryRichClipboard(), + ); + final newer = FocusNode(debugLabel: 'Pending search focus'); + final entry = OverlayEntry( + builder: (_) => Positioned( + left: 750, + top: 0, + width: 200, + height: 40, + child: Material(child: TextField(focusNode: newer)), + ), + ); + Overlay.of( + tester.element(find.byType(BusyMarkWysiwygEditor)), + ).insert(entry); + await tester.pumpAndSettle(); + tester.binding.addPostFrameCallback((_) => newer.requestFocus()); + state.editorKey.currentState!.restoreEditingFocus(); + tester.binding.scheduleFrame(); + await tester.pumpAndSettle(); + expect(FocusManager.instance.primaryFocus, same(newer)); + expect(state.changes, isEmpty); + entry.remove(); + await tester.pumpAndSettle(); + entry.dispose(); + newer.dispose(); + }, + ); + for (final inactive in [false, true]) { + testWidgets( + 'deferred toolbar dialog completion preserves newer owner inactive=$inactive', + (tester) async { + enableDesktopFocusLifecycle(tester); + final barrier = _DelayedSelectionBarrier(); + addTearDown(barrier.dispose); + addTearDown(() { + if (!barrier.release.isCompleted) barrier.release.complete(); + tester.binding.handleAppLifecycleStateChanged( + AppLifecycleState.resumed, + ); + }); + final paragraphs = [ + 'Original editing target.', + for (var i = 0; i < 80; i++) + 'Reading $i ${'words ' * 12}'.trimRight(), + ]; + final state = await _mount( + tester, + '${paragraphs.join('\n\n')}\n', + MemoryRichClipboard(), + headerBarService: barrier, + ); + final target = tester.widget(_field(paragraphs.first)); + target.controller!.selection = const TextSelection( + baseOffset: 8, + extentOffset: 2, + ); + await tester.pumpAndSettle(); + final position = tester + .state( + find + .descendant( + of: find.byType(ScrollablePositionedList), + matching: find.byType(Scrollable), + ) + .first, + ) + .position; + position.jumpTo(3500); + await tester.pumpAndSettle(); + final pixels = position.pixels; + final other = FocusNode( + debugLabel: 'Search selected during dialog release', + ); + final entry = OverlayEntry( + builder: (_) => Positioned( + left: 750, + top: 0, + width: 200, + height: 40, + child: Material(child: TextField(focusNode: other)), + ), + ); + Overlay.of( + tester.element(find.byType(BusyMarkWysiwygEditor)), + ).insert(entry); + await tester.pumpAndSettle(); + final link = find.byWidgetPredicate( + (w) => w is BusyMarkHeaderIconButton && w.icon == BusyMarkGlyphs.link, + ); + await tester.ensureVisible(link); + await tester.tap(link, pointer: 1102); + await tester.pumpAndSettle(); + expect( + find.byKey(const ValueKey('wysiwyg-link-destination-field')), + findsOneWidget, + ); + Navigator.of( + tester.element(find.byType(BusyMarkWysiwygEditor)), + rootNavigator: true, + ).pop(); + await tester.pumpAndSettle(); + expect(barrier.releaseStarted, isTrue); + other.requestFocus(); + await tester.pumpAndSettle(); + expect(FocusManager.instance.primaryFocus, same(other)); + if (inactive) { + tester.binding.handleAppLifecycleStateChanged( + AppLifecycleState.inactive, + ); + expect( + FocusManager.instance.primaryFocus, + same(FocusManager.instance.rootScope), + ); + } + barrier.release.complete(); + for (var frame = 0; frame < 30; frame++) { + await tester.pump(const Duration(milliseconds: 16)); + expect( + FocusManager.instance.primaryFocus, + same(inactive ? FocusManager.instance.rootScope : other), + ); + expect(position.pixels, closeTo(pixels, 0.1)); + } + if (inactive) { + tester.binding.handleAppLifecycleStateChanged( + AppLifecycleState.resumed, + ); + await tester.pumpAndSettle(); + } + expect(FocusManager.instance.primaryFocus, same(other)); + expect( + target.controller!.selection, + const TextSelection(baseOffset: 8, extentOffset: 2), + ); + expect(state.changes, isEmpty); + expect(state.editorKey.currentState!.debugUndoSnapshotCount, 0); + entry.remove(); + await tester.pumpAndSettle(); + entry.dispose(); + other.dispose(); + }, + variant: TargetPlatformVariant.only(TargetPlatform.linux), + ); + } + for (final (table, reverse) in [ + for (final table in [false, true]) + for (final reverse in [false, true]) (table, reverse), + ]) { + for (final scenario in [ + 'newer owner', + 'inactive', + 'inactive cancel', + 'inactive disposal', + 'newer owner inactive', + 'lifecycle round trip', + 'newer owner then scope', + 'document replacement', + 'disposal', + 'ordinary completion', + 'new caret', + ]) { + testWidgets( + 'deferred extent focus table=$table reverse=$reverse respects $scenario', + (tester) async { + enableDesktopFocusLifecycle(tester); + addTearDown(() { + tester.binding.handleAppLifecycleStateChanged( + AppLifecycleState.resumed, + ); + }); + final original = table + ? _surrounded + : '$_before\n\nMiddle selected.\n\n$_after\n'; + final state = await _mount(tester, original, MemoryRichClipboard()); + final other = FocusNode(debugLabel: 'Newer search owner'); + final text = TextEditingController(text: 'search'); + final overlay = Overlay.of( + tester.element(find.byType(BusyMarkWysiwygEditor)), + ); + final entry = OverlayEntry( + builder: (_) => Positioned( + top: 0, + left: 0, + width: 200, + height: 40, + child: Material( + child: TextField(focusNode: other, controller: text), + ), + ), + ); + overlay.insert(entry); + await tester.pumpAndSettle(); + final gesture = await tester.startGesture( + _caret( + tester, + reverse ? (table ? 'End!' : _after) : _before, + reverse ? (table ? 4 : 5) : 2, + ), + pointer: 1103, + kind: PointerDeviceKind.mouse, + ); + await gesture.moveTo( + _caret( + tester, + reverse ? _before : (table ? 'End!' : _after), + reverse ? 2 : (table ? 4 : 5), + ), + ); + await tester.pumpAndSettle(); + final selection = state.session; + expect( + selection.anchorBlockId, + reverse + ? state.document.blocks[table ? 1 : 2].id + : state.document.blocks.first.id, + ); + expect(selection.extentBlockId, isNot(selection.anchorBlockId)); + if (scenario == 'inactive cancel') { + tester.binding.handleAppLifecycleStateChanged( + AppLifecycleState.inactive, + ); + await gesture.cancel(); + } else { + await gesture.up(); + } + // Deliver a real focus change before the pending post-frame handoff. + if (scenario.contains('newer')) { + other.requestFocus(); + FocusManager.instance.applyFocusChangesIfNeeded(); + expect(FocusManager.instance.primaryFocus, same(other)); + } + final inactive = scenario.contains('inactive'); + if (inactive || scenario == 'lifecycle round trip') { + if (scenario != 'inactive cancel') { + tester.binding.handleAppLifecycleStateChanged( + AppLifecycleState.inactive, + ); + } + expect( + FocusManager.instance.primaryFocus, + same(FocusManager.instance.rootScope), + ); + if (!inactive) { + tester.binding.handleAppLifecycleStateChanged( + AppLifecycleState.resumed, + ); + } + } + if (scenario == 'newer owner then scope') { + other.unfocus(); + FocusManager.instance.applyFocusChangesIfNeeded(); + } + if (scenario == 'document replacement') { + state.replaceSource('Replacement document.\n', id: 'replacement'); + } + if (scenario == 'new caret') { + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + } + if (scenario.contains('disposal')) { + entry.remove(); + await tester.pumpWidget(const SizedBox()); + } + final expectedOwner = FocusManager.instance.primaryFocus; + for ( + var frame = 0; + frame < (scenario == 'inactive cancel' ? 90 : 30); + frame++ + ) { + await tester.pump(const Duration(milliseconds: 16)); + if (inactive || scenario.contains('newer')) { + expect(FocusManager.instance.primaryFocus, same(expectedOwner)); + } + } + if (inactive) { + tester.binding.handleAppLifecycleStateChanged( + AppLifecycleState.resumed, + ); + await tester.pumpAndSettle(); + } + if (scenario.contains('newer') && + scenario != 'newer owner then scope') { + expect(FocusManager.instance.primaryFocus, same(other)); + expect(tester.testTextInput.hasAnyClients, isTrue); + tester.testTextInput.updateEditingValue( + const TextEditingValue( + text: 'search!', + selection: TextSelection.collapsed(offset: 7), + ), + ); + await tester.pumpAndSettle(); + expect(text.text, 'search!'); + } else if (scenario == 'inactive' || + scenario == 'inactive cancel' || + scenario == 'lifecycle round trip' || + scenario == 'ordinary completion') { + expect(state.session.anchorBlockId, selection.anchorBlockId); + expect(state.session.anchorOffset, selection.anchorOffset); + expect(state.session.extentBlockId, selection.extentBlockId); + expect(state.session.extentOffset, selection.extentOffset); + expect(tester.testTextInput.hasAnyClients, isTrue); + final value = TextEditingValue.fromJSON( + tester.testTextInput.editingState!, + ); + final offset = value.selection.extentOffset; + tester.testTextInput.updateEditingValue( + TextEditingValue( + text: value.text.replaceRange(offset, offset, 'Z'), + selection: TextSelection.collapsed(offset: offset + 1), + ), + ); + await tester.pumpAndSettle(); + expect(state.changes, hasLength(1)); + expect(state.source, startsWith('${_before.substring(0, 2)}Z')); + } else if (scenario == 'new caret') { + expect(_selected(tester), isEmpty); + expect(state.changes, isEmpty); + expect(tester.testTextInput.hasAnyClients, isTrue); + final value = TextEditingValue.fromJSON( + tester.testTextInput.editingState!, + ); + final offset = value.selection.extentOffset; + tester.testTextInput.updateEditingValue( + TextEditingValue( + text: value.text.replaceRange(offset, offset, 'Z'), + selection: TextSelection.collapsed(offset: offset + 1), + ), + ); + await tester.pumpAndSettle(); + expect(state.changes, hasLength(1)); + expect(state.document.blocks.first.plainText, _before); + } + if (!scenario.contains('disposal')) entry.remove(); + await tester.pumpAndSettle(); + entry.dispose(); + other.dispose(); + text.dispose(); + expect(tester.takeException(), isNull); + }, + variant: TargetPlatformVariant.only(TargetPlatform.linux), + ); + } + } +} + +class _DelayedSelectionBarrier extends LinuxHeaderBarService { + _DelayedSelectionBarrier() + : super( + channel: const MethodChannel( + 'com.busymark.test/selection-modal-release', + ), + ); + final release = Completer(); + bool releaseStarted = false; + @override + Future setModalBarrierDepth(int depth) async { + if (depth == 0) { + releaseStarted = true; + await release.future; + } + } +} + void main() { + _deferredExtentFocusRegressions(); + for (final externalHistory in [false, true]) { + for (final reverse in [false, true]) { + testWidgets( + 'non-editable selection endpoint settles and accepts paste external=$externalHistory reverse=$reverse', + (tester) async { + const original = 'First\n\n---\n'; + final document = WysiwygEditableHarnessState.parser + .parse(filePath: '/editable.md', source: original) + .busyDocument; + final saved = _documentSession(document, reverse: reverse); + final registry = BusyMarkClipboardInsertionRegistry(); + addTearDown(registry.dispose); + final state = await _mount( + tester, + original, + MemoryRichClipboard(), + session: saved, + externalHistory: externalHistory, + registry: registry, + ); + expect(state.session.anchorBlockId, saved.anchorBlockId); + expect(state.session.anchorOffset, saved.anchorOffset); + expect(state.session.extentBlockId, saved.extentBlockId); + expect(state.session.extentOffset, saved.extentOffset); + if (!reverse) { + expect( + FocusManager.instance.primaryFocus?.debugLabel, + 'BusyMark WYSIWYG document selection', + ); + } + final top = tester.getTopLeft(_field('First')); + for (var frame = 0; frame < 20; frame++) { + await tester.pump(const Duration(milliseconds: 16)); + expect(tester.getTopLeft(_field('First')), top); + } + expect(tester.binding.hasScheduledFrame, isFalse); + expect(state.source, original); + expect(state.changes, isEmpty); + expect(state.undoSources, isEmpty); + expect(state.editorKey.currentState!.debugUndoSnapshotCount, 0); + expect( + await registry.paste( + BusyMarkClipboardPayload( + id: 'non-editable-endpoint', + acquiredAt: DateTime.utc(2026), + kind: BusyMarkClipboardContentKind.text, + text: 'Replacement', + ), + mode: BusyMarkPasteMode.plainText, + ), + ClipboardPasteResult.inserted, + ); + await tester.pumpAndSettle(); + expect(state.source, 'Replacement\n'); + expect(state.changes, hasLength(1)); + expect(tester.testTextInput.hasAnyClients, isTrue); + tester.testTextInput.updateEditingValue( + const TextEditingValue( + text: 'ReplacementX', + selection: TextSelection.collapsed(offset: 12), + ), + ); + await tester.pumpAndSettle(); + expect(state.source, 'ReplacementX\n'); + await _command(tester, LogicalKeyboardKey.keyZ); + expect(state.source, 'Replacement\n'); + await _command(tester, LogicalKeyboardKey.keyZ); + expect(state.source, original); + await _command(tester, LogicalKeyboardKey.keyZ, shift: true); + expect(state.source, 'Replacement\n'); + }, + ); + } + } + testWidgets('Select All ending at a rule settles and accepts command paste', ( + tester, + ) async { + const original = 'First\n\n---\n'; + final clipboard = MemoryRichClipboard() + ..data = const RichClipboardData(text: 'Replacement'); + final state = await _mount(tester, original, clipboard); + await _command(tester, LogicalKeyboardKey.keyA); + await _command(tester, LogicalKeyboardKey.keyA); + expect(state.source, original); + expect(state.changes, isEmpty); + expect(state.session.extentBlockId, state.document.blocks.last.id); + expect( + FocusManager.instance.primaryFocus?.debugLabel, + 'BusyMark WYSIWYG document selection', + ); + await _command(tester, LogicalKeyboardKey.keyV, shift: true); + expect(state.source, 'Replacement\n'); + expect(state.changes, hasLength(1)); + expect(tester.testTextInput.hasAnyClients, isTrue); + await _command(tester, LogicalKeyboardKey.keyZ); + expect(state.source, original); + await _command(tester, LogicalKeyboardKey.keyZ, shift: true); + expect(state.source, 'Replacement\n'); + }); + testWidgets( + 'short table input after desktop return reveals the original cell', + (tester) async { + enableDesktopFocusLifecycle(tester); + final source = + 'Before.\n\n| Header | Other |\n| --- | --- |\n| Focus cell | Body |\n\n${List.generate(100, (i) => 'Reading $i ${'words ' * 20}').join('\n\n')}\n'; + final state = await _mount(tester, source, MemoryRichClipboard()); + final field = tester.widget(_field('Focus cell')); + field.focusNode!.requestFocus(); + field.controller!.selection = const TextSelection.collapsed(offset: 5); + await tester.pumpAndSettle(); + final scrollable = Scrollable.of( + tester.element(_field('Focus cell')), + axis: Axis.vertical, + ); + // Beyond the package's two-viewport cache, not only outside the screen. + scrollable.position.jumpTo(5000); + await tester.pumpAndSettle(); + final reading = scrollable.position.pixels; + for (var cycle = 0; cycle < 4; cycle++) { + await pumpDesktopFocusCycle( + tester, + target: field.focusNode!, + inactiveFrames: cycle == 2 ? 90 : 1, + verifyViewport: () => + expect(scrollable.position.pixels, closeTo(reading, 0.1)), + ); + } + expect(state.session.viewportEditingOffset, lessThan(-4000)); + expect(() => jsonEncode(state.session.toJson()), returnsNormally); + final readingField = find + .byWidgetPredicate( + (w) => + w is TextField && + w.controller?.text.startsWith('Reading ') == true, + ) + .evaluate() + .firstWhere((e) { + final top = tester.getTopLeft(find.byWidget(e.widget)).dy; + return top > 100 && top < 900; + }); + final readingText = (readingField.widget as TextField).controller!.text; + final readingTop = tester.getTopLeft(_field(readingText)).dy; + state.recreate( + WysiwygEditorSessionState.fromJson(state.session.toJson()), + ); + await tester.pumpAndSettle(); + expect( + tester.getTopLeft(_field(readingText)).dy, + closeTo(readingTop, 0.1), + ); + expect(tester.testTextInput.hasAnyClients, isTrue); + tester.testTextInput.updateEditingValue( + const TextEditingValue( + text: 'Focusz cell', + selection: TextSelection.collapsed(offset: 6), + ), + ); + await tester.pumpAndSettle(); + expect(state.source, contains('| Focusz cell |')); + expect(_caret(tester, 'Focusz cell', 6).dy, inInclusiveRange(0, 1100)); + expect( + Scrollable.of( + tester.element(_field('Focusz cell')), + axis: Axis.vertical, + ).position.pixels, + lessThan(reading - 100), + ); + }, + variant: TargetPlatformVariant.only(TargetPlatform.linux), + ); + + testWidgets('queued toolbar restoration respects application suspension', ( + tester, + ) async { + enableDesktopFocusLifecycle(tester); + final state = await _mount( + tester, + 'Original target.\n', + MemoryRichClipboard(), + ); + final field = tester.widget(_field('Original target.')); + state.editorKey.currentState!.restoreEditingFocus(); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive); + await tester.pumpAndSettle(); + expect( + FocusManager.instance.primaryFocus, + same(FocusManager.instance.rootScope), + ); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + await tester.pumpAndSettle(); + expect(FocusManager.instance.primaryFocus, same(field.focusNode)); + expect(tester.testTextInput.hasAnyClients, isTrue); + expect(state.changes, isEmpty); + }, variant: TargetPlatformVariant.only(TargetPlatform.linux)); + + testWidgets( + 'table Tab deliberately reveals the next cell after passive restoration', + (tester) async { + enableDesktopFocusLifecycle(tester); + final cells = List.generate(12, (i) => 'Cell $i ${'wide ' * 20}'.trim()); + final table = + '|${List.generate(12, (i) => 'Head $i').join('|')}|\n' + '|${List.filled(12, '---').join('|')}|\n' + '|${cells.join('|')}|\n'; + final source = + '$table\n${List.generate(100, (i) => 'Reading $i ${'words ' * 30}').join('\n\n')}\n'; + final state = await _mount(tester, source, MemoryRichClipboard()); + final first = tester.widget(_field(cells.first)); + final next = tester.widget(_field(cells[1])); + final nextEditable = _editable(tester, _field(cells[1])); + first.focusNode!.requestFocus(); + first.controller!.selection = const TextSelection.collapsed(offset: 0); + await tester.pumpAndSettle(); + final vertical = Scrollable.of( + tester.element(_field(cells.first)), + axis: Axis.vertical, + ).position; + final horizontal = next.scrollController!.position; + expect(horizontal.maxScrollExtent, greaterThan(100)); + // Compare the intentional reveal with a fresh Tab, rather than assuming + // that the framework's caret/pixel rounding necessarily chooses zero. + horizontal.jumpTo(horizontal.maxScrollExtent); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pumpAndSettle(); + final freshHorizontal = horizontal.pixels; + first.focusNode!.requestFocus(); + await tester.pumpAndSettle(); + horizontal.jumpTo(horizontal.maxScrollExtent); + vertical.jumpTo(2000); + await tester.pumpAndSettle(); + final reading = vertical.pixels; + final cellReading = horizontal.pixels; + await pumpDesktopFocusCycle( + tester, + target: first.focusNode!, + verifyViewport: () { + expect(vertical.pixels, closeTo(reading, 0.1)); + expect(horizontal.pixels, closeTo(cellReading, 0.1)); + }, + ); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pumpAndSettle(); + expect(FocusManager.instance.primaryFocus, same(next.focusNode)); + expect(nextEditable.mounted, isTrue); + final caret = nextEditable.renderEditable.localToGlobal( + nextEditable.renderEditable + .getLocalRectForCaret(const TextPosition(offset: 0)) + .center, + ); + expect(caret.dx, inInclusiveRange(0, 1000)); + expect(caret.dy, inInclusiveRange(0, 1100)); + expect(horizontal.pixels, closeTo(freshHorizontal, 0.1)); + expect(state.changes, isEmpty); + }, + variant: TargetPlatformVariant.only(TargetPlatform.linux), + ); + + for (final inactive in [false, true]) { + testWidgets( + 'document refresh preserves an existing competing focus owner inactive=$inactive', + (tester) async { + enableDesktopFocusLifecycle(tester); + final state = await _mount( + tester, + 'Original target.\n', + MemoryRichClipboard(), + ); + final saved = state.session; + final other = FocusNode( + debugLabel: 'Existing search or inspector input', + ); + final overlay = Overlay.of( + tester.element(find.byType(BusyMarkWysiwygEditor)), + ); + final entry = OverlayEntry( + builder: (_) => Positioned( + left: 0, + top: 0, + width: 200, + height: 40, + child: Material(child: TextField(focusNode: other)), + ), + ); + overlay.insert(entry); + await tester.pumpAndSettle(); + other.requestFocus(); + await tester.pumpAndSettle(); + if (inactive) { + tester.binding.handleAppLifecycleStateChanged( + AppLifecycleState.inactive, + ); + } + state.replaceSource('Replacement target.\n', restoredSession: saved); + await tester.pumpAndSettle(); + if (inactive) { + expect( + FocusManager.instance.primaryFocus, + same(FocusManager.instance.rootScope), + ); + tester.binding.handleAppLifecycleStateChanged( + AppLifecycleState.resumed, + ); + await tester.pumpAndSettle(); + } + expect(FocusManager.instance.primaryFocus, same(other)); + tester.testTextInput.updateEditingValue( + const TextEditingValue( + text: 'search continues', + selection: TextSelection.collapsed(offset: 16), + ), + ); + await tester.pumpAndSettle(); + expect(state.changes, isEmpty); + expect(state.source, 'Replacement target.\n'); + entry.remove(); + await tester.pumpAndSettle(); + entry.dispose(); + other.dispose(); + }, + variant: TargetPlatformVariant.only(TargetPlatform.linux), + ); + } + + for (final navigation in [false, true]) { + testWidgets( + 'new input wins while passive reveal is pending navigation=$navigation', + (tester) async { + enableDesktopFocusLifecycle(tester); + final paragraphs = List.generate( + 100, + (i) => 'Paragraph $i ${'reading words ' * 12}'.trimRight(), + ); + final state = await _mount( + tester, + '${paragraphs.join('\n\n')}\n', + MemoryRichClipboard(), + ); + final field = tester.widget(_field(paragraphs.first)); + field.controller!.selection = const TextSelection.collapsed(offset: 3); + await tester.pumpAndSettle(); + final scrollable = tester.state( + find + .descendant( + of: find.byType(ScrollablePositionedList), + matching: find.byType(Scrollable), + ) + .first, + ); + scrollable.position.jumpTo(5000); + await tester.pumpAndSettle(); + tester.binding.handleAppLifecycleStateChanged( + AppLifecycleState.inactive, + ); + await tester.pump(); + tester.binding.handleAppLifecycleStateChanged( + AppLifecycleState.resumed, + ); + await tester.idle(); + if (navigation) { + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pumpAndSettle(); + expect( + field.controller!.selection, + const TextSelection.collapsed(offset: 4), + ); + expect( + _caret(tester, paragraphs.first, 4).dy, + inInclusiveRange(0, 1100), + ); + } else { + scrollable.position.pointerScroll(200); + final offset = scrollable.position.pixels; + for (var frame = 0; frame < 30; frame++) { + await tester.pump(const Duration(milliseconds: 16)); + expect(scrollable.position.pixels, closeTo(offset, 0.1)); + } + } + expect(state.changes, isEmpty); + expect(state.editorKey.currentState!.debugUndoSnapshotCount, 0); + }, + variant: TargetPlatformVariant.only(TargetPlatform.linux), + ); + } + + for (final nested in [false, true]) { + testWidgets( + 'session retains partial tall paragraph geometry nested=$nested', + (tester) async { + final text = List.generate( + 500, + (i) => 'paragraph words $i for reading', + ).join(' '); + final source = nested ? '> $text\n\nAfter.\n' : '$text\n\nAfter.\n'; + final state = await _mount(tester, source, MemoryRichClipboard()); + final scrollable = tester.state( + find + .descendant( + of: find.byType(ScrollablePositionedList), + matching: find.byType(Scrollable), + ) + .first, + ); + scrollable.position.jumpTo(2300.25); + await tester.pumpAndSettle(); + final top = tester.getTopLeft(_field(text)).dy; + expect(top, lessThan(-2000)); + state.recreate( + WysiwygEditorSessionState.fromJson(state.session.toJson()), + ); + await tester.pumpAndSettle(); + expect(tester.getTopLeft(_field(text)).dy, closeTo(top, 0.1)); + expect(state.changes, isEmpty); + expect(tester.testTextInput.hasAnyClients, isTrue); + }, + ); + } + + for (final reverse in [false, true]) { + testWidgets( + 'desktop cross-block selection retains endpoints reverse=$reverse and replaces with composition', + (tester) async { + enableDesktopFocusLifecycle(tester); + final paragraphs = List.generate( + 100, + (i) => 'Paragraph $i ${'reading words ' * 12}'.trimRight(), + ); + final original = '${paragraphs.join('\n\n')}\n'; + final state = await _mount(tester, original, MemoryRichClipboard()); + final gesture = await tester.startGesture( + _caret(tester, paragraphs[reverse ? 2 : 0], reverse ? 9 : 5), + kind: PointerDeviceKind.mouse, + ); + await gesture.moveTo( + _caret(tester, paragraphs[reverse ? 0 : 2], reverse ? 5 : 9), + ); + await gesture.up(); + await tester.pumpAndSettle(); + final session = state.session; + final targetText = paragraphs[reverse ? 0 : 2]; + final field = tester.widget(_field(targetText)); + final scrollable = tester.state( + find + .descendant( + of: find.byType(ScrollablePositionedList), + matching: find.byType(Scrollable), + ) + .first, + ); + scrollable.position.jumpTo(5000); + await tester.pumpAndSettle(); + final offset = scrollable.position.pixels; + await pumpDesktopFocusCycle( + tester, + target: field.focusNode!, + inactiveFrames: 90, + verifyViewport: () { + expect(scrollable.position.pixels, closeTo(offset, 0.1)); + expect(state.session.anchorBlockId, session.anchorBlockId); + expect(state.session.extentBlockId, session.extentBlockId); + expect(state.session.anchorOffset, session.anchorOffset); + expect(state.session.extentOffset, session.extentOffset); + }, + ); + expect(state.changes, isEmpty); + final caret = field.controller!.selection.extentOffset; + tester.testTextInput.updateEditingValue( + TextEditingValue( + text: targetText.replaceRange(caret, caret, 'ni'), + selection: TextSelection.collapsed(offset: caret + 2), + composing: TextRange(start: caret, end: caret + 2), + ), + ); + await tester.pumpAndSettle(); + final expected = + '${paragraphs.first.substring(0, 5)}你${paragraphs[2].substring(9)}'; + tester.testTextInput.updateEditingValue( + TextEditingValue( + text: expected, + selection: const TextSelection.collapsed(offset: 6), + ), + ); + await tester.pumpAndSettle(); + expect( + state.source, + '${[expected, ...paragraphs.skip(3)].join('\n\n')}\n', + ); + expect(_caret(tester, expected, 6).dy, inInclusiveRange(0, 1100)); + final changed = state.source; + await _command(tester, LogicalKeyboardKey.keyZ); + expect(state.source, isNot(changed)); + await _command(tester, LogicalKeyboardKey.keyZ, shift: true); + expect(state.source, changed); + }, + variant: TargetPlatformVariant.only(TargetPlatform.linux), + ); + } + + testWidgets('dismissed dialog preserves a table cell selection', ( + tester, + ) async { + final state = await _mount(tester, _table, MemoryRichClipboard()); + final field = tester.widget(_field('Body text')); + field.focusNode!.requestFocus(); + await tester.pumpAndSettle(); + field.controller!.selection = const TextSelection( + baseOffset: 7, + extentOffset: 2, + ); + await tester.pumpAndSettle(); + final dialog = showBusyMarkModalDialog( + tester.element(find.byType(BusyMarkWysiwygEditor)), + builder: (_) => const AlertDialog(content: Text('Cancel cell dialog')), + ); + await tester.pumpAndSettle(); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + await dialog; + expect( + field.controller!.selection, + const TextSelection(baseOffset: 7, extentOffset: 2), + ); + expect(field.focusNode!.hasFocus, isTrue); + expect(state.changes, isEmpty); + }, variant: TargetPlatformVariant.only(TargetPlatform.linux)); + + testWidgets('desktop math source remains mounted across prolonged inactivity', ( + tester, + ) async { + enableDesktopFocusLifecycle(tester); + const math = r'Focus $x^2$ target'; + final state = await _mount( + tester, + '$math\n\n${List.generate(100, (i) => 'Reading $i ${'words ' * 20}'.trimRight()).join('\n\n')}\n', + MemoryRichClipboard(), + renderMath: true, + ); + tester + .widget(find.byType(ScrollablePositionedList)) + .itemScrollController! + .jumpTo(index: 0); + await tester.pumpAndSettle(); + await tester.tap( + find.byKey( + ValueKey('wysiwyg-rendered-math-${state.document.blocks.first.id}'), + ), + ); + await tester.pumpAndSettle(); + final field = tester.widget(_field(math)); + field.controller!.selection = const TextSelection.collapsed(offset: 3); + await tester.pumpAndSettle(); + final scrollable = tester.state( + find + .descendant( + of: find.byType(ScrollablePositionedList), + matching: find.byType(Scrollable), + ) + .first, + ); + scrollable.position.jumpTo(5000); + await tester.pumpAndSettle(); + final offset = scrollable.position.pixels; + await pumpDesktopFocusCycle( + tester, + target: field.focusNode!, + inactiveFrames: 90, + verifyViewport: () { + expect(scrollable.position.pixels, closeTo(offset, 0.1)); + expect(field.focusNode!.context?.mounted, isTrue); + expect( + field.controller!.selection, + const TextSelection.collapsed(offset: 3), + ); + }, + ); + expect(state.changes, isEmpty); + }, variant: TargetPlatformVariant.only(TargetPlatform.linux)); + + testWidgets('queued editor focus restoration respects a newer focus owner', ( + tester, + ) async { + final state = await _mount( + tester, + 'Original target.\n', + MemoryRichClipboard(), + ); + final other = FocusNode( + debugLabel: 'Competing search or inspector control', + ); + final overlay = Overlay.of( + tester.element(find.byType(BusyMarkWysiwygEditor)), + ); + final entry = OverlayEntry( + builder: (_) => Positioned( + left: 0, + top: 0, + width: 200, + height: 40, + child: Material(child: TextField(focusNode: other)), + ), + ); + overlay.insert(entry); + await tester.pumpAndSettle(); + state.editorKey.currentState!.restoreEditingFocus(); + other.requestFocus(); + await tester.pumpAndSettle(); + expect(FocusManager.instance.primaryFocus, same(other)); + entry.remove(); + await tester.pumpAndSettle(); + entry.dispose(); + other.dispose(); + expect(state.changes, isEmpty); + }); + + testWidgets( + 'session recreation preserves reading with an offscreen caret and immediate input', + (tester) async { + final paragraphs = List.generate( + 100, + (i) => 'Paragraph $i ${'reading words ' * 12}'.trimRight(), + ); + final state = await _mount( + tester, + '${paragraphs.join('\n\n')}\n', + MemoryRichClipboard(), + ); + final field = tester.widget(_field(paragraphs.first)); + field.controller!.selection = const TextSelection( + baseOffset: 10, + extentOffset: 5, + ); + await tester.pumpAndSettle(); + final list = tester.widget( + find.byType(ScrollablePositionedList), + ); + final scrollable = tester.state( + find + .descendant( + of: find.byType(ScrollablePositionedList), + matching: find.byType(Scrollable), + ) + .first, + ); + scrollable.position.jumpTo(5000); + await tester.pumpAndSettle(); + final index = list.itemPositionsNotifier!.itemPositions.value + .firstWhere((p) => p.itemLeadingEdge > 0 && p.itemLeadingEdge < 0.8) + .index; + final top = tester.getTopLeft(_field(paragraphs[index])).dy; + final saved = WysiwygEditorSessionState.fromJson(state.session.toJson()); + state.recreate(saved); + await tester.pumpAndSettle(); + expect( + tester.getTopLeft(_field(paragraphs[index])).dy, + closeTo(top, 0.1), + ); + expect(state.session.anchorOffset, 10); + expect(state.session.extentOffset, 5); + expect(tester.testTextInput.hasAnyClients, isTrue); + tester.testTextInput.updateEditingValue( + TextEditingValue( + text: paragraphs.first.replaceRange(5, 10, 'Z'), + selection: const TextSelection.collapsed(offset: 6), + ), + ); + await tester.pumpAndSettle(); + expect( + state.source, + startsWith(paragraphs.first.replaceRange(5, 10, 'Z')), + ); + expect(state.changes, hasLength(1)); + }, + ); + + testWidgets( + 'canceled nested menu preserves the offscreen caret and viewport', + (tester) async { + final paragraphs = List.generate( + 100, + (i) => 'Paragraph $i ${'reading words ' * 12}'.trimRight(), + ); + final state = await _mount( + tester, + '${paragraphs.join('\n\n')}\n', + MemoryRichClipboard(), + ); + final field = tester.widget(_field(paragraphs.first)); + field.controller!.selection = const TextSelection.collapsed(offset: 3); + await tester.pumpAndSettle(); + final scrollable = tester.state( + find + .descendant( + of: find.byType(ScrollablePositionedList), + matching: find.byType(Scrollable), + ) + .first, + ); + scrollable.position.jumpTo(5000); + await tester.pumpAndSettle(); + final offset = scrollable.position.pixels; + final menu = showBusyMarkMenu( + context: tester.element(find.byType(BusyMarkWysiwygEditor)), + anchorRect: const Rect.fromLTWH(100, 100, 10, 10), + focusFirst: true, + items: const [ + BusyMarkSubmenuItem( + label: 'Choices', + items: [PopupMenuItem(value: 'edit', child: Text('Edit'))], + ), + ], + ); + await tester.pumpAndSettle(); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + for (var frame = 0; frame < 30; frame++) { + await tester.pump(const Duration(milliseconds: 16)); + expect(scrollable.position.pixels, closeTo(offset, 0.1)); + } + expect(await menu, isNull); + expect(field.focusNode!.hasFocus, isTrue); + expect( + field.controller!.selection, + const TextSelection.collapsed(offset: 3), + ); + expect(state.changes, isEmpty); + }, + ); + + for (final kind in ['list', 'code', 'table']) { + testWidgets( + 'desktop $kind preserves selection and inner and document scroll', + (tester) async { + enableDesktopFocusLifecycle(tester); + final text = 'Focus target ${'long text ' * 60}'.trimRight(); + final first = switch (kind) { + 'list' => '- $text\n', + 'code' => '```text\n$text\n```\n', + _ => '| Header | Other |\n| --- | --- |\n| $text | Body |\n', + }; + final source = + '$first\n${List.generate(100, (i) => 'Reading $i ${'words ' * 20}'.trimRight()).join('\n\n')}\n'; + final state = await _mount(tester, source, MemoryRichClipboard()); + final field = tester.widget(_field(text)); + field.focusNode!.requestFocus(); + await tester.pumpAndSettle(); + field.controller!.selection = const TextSelection( + baseOffset: 10, + extentOffset: 4, + ); + await tester.pumpAndSettle(); + if (kind == 'table') field.scrollController!.jumpTo(150); + final scrollable = tester.state( + find + .descendant( + of: find.byType(ScrollablePositionedList), + matching: find.byType(Scrollable), + ) + .first, + ); + scrollable.position.jumpTo(5000); + await tester.pumpAndSettle(); + final offset = scrollable.position.pixels; + final innerOffset = field.scrollController!.offset; + await pumpDesktopFocusCycle( + tester, + target: field.focusNode!, + inactiveFrames: 90, + verifyViewport: () { + expect(scrollable.position.pixels, closeTo(offset, 0.1)); + expect(field.scrollController!.offset, closeTo(innerOffset, 0.1)); + expect( + field.controller!.selection, + const TextSelection(baseOffset: 10, extentOffset: 4), + ); + }, + ); + expect(state.changes, isEmpty); + tester.testTextInput.updateEditingValue( + TextEditingValue( + text: text.replaceRange(4, 10, 'Z'), + selection: const TextSelection.collapsed(offset: 5), + ), + ); + await tester.pumpAndSettle(); + expect(state.source, contains(text.replaceRange(4, 10, 'Z'))); + expect(state.changes, hasLength(1)); + expect(scrollable.position.pixels, lessThan(offset - 100)); + }, + variant: TargetPlatformVariant.only(TargetPlatform.linux), + ); + } + + for (final below in [false, true]) { + for (final inactiveFrames in [1, 90]) { + testWidgets( + 'desktop return preserves offscreen caret below=$below inactive=$inactiveFrames', + (tester) async { + enableDesktopFocusLifecycle(tester); + final paragraphs = List.generate( + 100, + (i) => 'Paragraph $i ${'reading words ' * 12}'.trimRight(), + ); + final state = await _mount( + tester, + '${paragraphs.join('\n\n')}\n', + MemoryRichClipboard(), + ); + final list = tester.widget( + find.byType(ScrollablePositionedList), + ); + final targetIndex = below ? 70 : 0; + list.itemScrollController!.jumpTo(index: targetIndex); + await tester.pumpAndSettle(); + final field = tester.widget( + _field(paragraphs[targetIndex]), + ); + field.focusNode!.requestFocus(); + field.controller!.selection = const TextSelection.collapsed( + offset: 5, + ); + await tester.pumpAndSettle(); + final scrollable = tester.state( + find + .descendant( + of: find.byType(ScrollablePositionedList), + matching: find.byType(Scrollable), + ) + .first, + ); + scrollable.position.jumpTo( + scrollable.position.pixels + (below ? -5000 : 5000), + ); + await tester.pumpAndSettle(); + final readingIndex = list.itemPositionsNotifier!.itemPositions.value + .firstWhere( + (p) => p.itemLeadingEdge > 0 && p.itemLeadingEdge < 0.8, + ) + .index; + final top = tester.getTopLeft(_field(paragraphs[readingIndex])).dy; + final selection = field.controller!.selection; + final saved = state.session; + for (var cycle = 0; cycle < 3; cycle++) { + await pumpDesktopFocusCycle( + tester, + target: field.focusNode!, + inactiveFrames: inactiveFrames, + verifyViewport: () { + expect(_field(paragraphs[readingIndex]), findsOneWidget); + expect( + tester.getTopLeft(_field(paragraphs[readingIndex])).dy, + closeTo(top, 0.1), + ); + expect(field.controller!.selection, selection); + }, + ); + } + expect(state.session.activeBlockId, saved.activeBlockId); + expect(state.changes, isEmpty); + expect(state.undoSources, isEmpty); + tester.testTextInput.updateEditingValue( + TextEditingValue( + text: paragraphs[targetIndex].replaceRange(5, 5, 'Z'), + selection: const TextSelection.collapsed(offset: 6), + ), + ); + await tester.pumpAndSettle(); + expect(state.changes, hasLength(1)); + expect( + state.source, + contains(paragraphs[targetIndex].replaceRange(5, 5, 'Z')), + ); + final caret = _caret( + tester, + paragraphs[targetIndex].replaceRange(5, 5, 'Z'), + 6, + ); + expect(caret.dy, inInclusiveRange(0, 1100)); + }, + variant: TargetPlatformVariant.only(TargetPlatform.linux), + ); + } + } + + testWidgets( + 'dismissed dialog preserves the offscreen editing target and viewport', + (tester) async { + final paragraphs = List.generate( + 100, + (i) => 'Paragraph $i ${'words ' * 20}'.trimRight(), + ); + final state = await _mount( + tester, + '${paragraphs.join('\n\n')}\n', + MemoryRichClipboard(), + ); + final field = tester.widget(_field(paragraphs.first)); + field.controller!.selection = const TextSelection( + baseOffset: 10, + extentOffset: 4, + ); + await tester.pumpAndSettle(); + final scrollable = tester.state( + find + .descendant( + of: find.byType(ScrollablePositionedList), + matching: find.byType(Scrollable), + ) + .first, + ); + scrollable.position.jumpTo(5000); + await tester.pumpAndSettle(); + final pixels = scrollable.position.pixels; + final result = showBusyMarkModalDialog( + tester.element(find.byType(BusyMarkWysiwygEditor)), + builder: (context) => + const AlertDialog(content: Text('Cancel this dialog')), + ); + await tester.pumpAndSettle(); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + for (var i = 0; i < 30; i++) { + await tester.pump(const Duration(milliseconds: 16)); + expect(scrollable.position.pixels, closeTo(pixels, 0.1)); + } + await result; + expect(field.focusNode!.hasFocus, isTrue); + expect( + field.controller!.selection, + const TextSelection(baseOffset: 10, extentOffset: 4), + ); + expect(state.changes, isEmpty); + expect(tester.testTextInput.hasAnyClients, isTrue); + }, + ); + + testWidgets('session preserves position well inside an oversized block', ( + tester, + ) async { + final text = List.generate( + 180, + (i) => 'line $i has enough text for the viewport', + ).join('\n'); + final state = await _mount( + tester, + '```text\n$text\n```\n\nAfter.\n', + MemoryRichClipboard(), + ); + final field = tester.widget(_field(text)); + field.focusNode!.unfocus(); + await tester.pumpAndSettle(); + final scrollable = tester.state( + find + .descendant( + of: find.byType(ScrollablePositionedList), + matching: find.byType(Scrollable), + ) + .first, + ); + scrollable.position.jumpTo(2300); + await tester.pumpAndSettle(); + final top = tester.getTopLeft(_field(text)).dy; + expect(top, lessThan(-1000)); + final saved = WysiwygEditorSessionState.fromJson(state.session.toJson()); + state.recreate(saved); + await tester.pumpAndSettle(); + expect(tester.getTopLeft(_field(text)).dy, closeTo(top, 0.1)); + expect(state.changes, isEmpty); + }); + testWidgets('table selection IME input delegates platform history actions', ( tester, ) async { diff --git a/test/src/wysiwyg_session_test.dart b/test/src/wysiwyg_session_test.dart index cd7f6885..27428123 100644 --- a/test/src/wysiwyg_session_test.dart +++ b/test/src/wysiwyg_session_test.dart @@ -231,6 +231,113 @@ void main() { const parser = MarkdownParser(); + for (final table in [false, true]) { + testWidgets( + 'merged text history preserves every clipped viewport anchor table=$table', + (tester) async { + final history = await DocumentHistoryHarness.create(); + addTearDown(history.dispose); + final text = table + ? 'Focus cell' + : List.generate(180, (i) => 'line $i for reading').join('\n'); + final source = table + ? '| Header | Other |\n| --- | --- |\n| Focus cell | Body |\n${List.generate(100, (i) => '| row $i | reading |').join('\n')}\n\nAfter.\n' + : '```text\n$text\n```\n\nAfter.\n'; + history.controller.updateActiveText(source); + var document = parser + .parse(filePath: 'viewport.md', source: source) + .busyDocument; + final key = GlobalKey(); + await tester.pumpWidget( + _app( + BusyMarkWysiwygEditor( + key: key, + document: document, + documentId: history.buffer.id, + useExternalUndoHistory: true, + readEditBase: history.readBase, + readSessionState: () => history.buffer.editorState.wysiwygState, + onUndo: history.controller.undoActiveBuffer, + onRedo: history.controller.redoActiveBuffer, + onSessionChanged: (id, session) => + history.controller.updateDocumentEditorState( + id, + history.buffer.editorState.copyWith(wysiwygState: session), + ), + onDocumentChanged: (value) => document = value, + onSourceChanged: (_, _) {}, + onTransactionalSourceChanged: (_, value, group) => + history.controller.updateActiveWysiwygText( + value, + document: document, + undoGroup: group, + ), + ), + ), + ); + await tester.pumpAndSettle(); + final field = tester.widget( + find.byWidgetPredicate( + (w) => w is TextField && w.controller?.text == text, + ), + ); + field.focusNode!.requestFocus(); + field.controller!.selection = const TextSelection.collapsed(offset: 4); + await tester.pumpAndSettle(); + Scrollable.of( + field.focusNode!.context!, + axis: Axis.vertical, + ).position.jumpTo(2300); + await tester.pumpAndSettle(); + final reading = history.buffer.editorState.wysiwygState; + expect(reading.viewportOffset, greaterThan(1000)); + expect(reading.viewportEditingOffset, lessThan(-1000)); + tester.testTextInput.updateEditingValue( + TextEditingValue( + text: text.replaceRange(4, 4, 'Z'), + selection: const TextSelection.collapsed(offset: 5), + ), + ); + // Inspect the authoritative pre-edit entry before any parent/frame restore. + final before = + history.buffer.editorState.undoState.undo.last.wysiwygState; + expect(before.anchorOffset, 4); + expect(before.viewportOffset, reading.viewportOffset); + expect(before.viewportEditingOffset, reading.viewportEditingOffset); + expect(before.viewportLayoutSignature, reading.viewportLayoutSignature); + await tester.pumpWidget(const SizedBox.shrink()); + }, + ); + } + + test( + 'viewport pixel anchors round trip while older sessions remain readable', + () { + final old = WysiwygEditorSessionState.fromJson({ + 'viewportBlockId': 'old', + 'viewportAlignment': 0.2, + }); + expect(old.viewportOffset, 0); + expect(old.viewportEditingOffset, isNull); + const session = WysiwygEditorSessionState( + activeBlockId: 'edit', + viewportBlockId: 'reading', + viewportOffset: 2300.25, + viewportEditingOffset: -5000.5, + viewportLayoutSignature: 'layout', + sourceSignature: 'source', + sourceBindings: { + 'reading': (start: 4, end: 9, kind: 'paragraph', tag: 'p'), + }, + ); + final restored = WysiwygEditorSessionState.fromJson(session.toJson()); + expect(restored.toJson(), session.toJson()); + expect(restored.viewportOffset, 2300.25); + expect(restored.viewportEditingOffset, -5000.5); + expect(restored.sourceBindings, session.sourceBindings); + }, + ); + for (final replacement in ['abc', 'Restored']) { testWidgets( 'authoritative $replacement wins during rich publication frame', diff --git a/test/src/wysiwyg_structured_regression_test.dart b/test/src/wysiwyg_structured_regression_test.dart index 39877a1e..6ae1c69b 100644 --- a/test/src/wysiwyg_structured_regression_test.dart +++ b/test/src/wysiwyg_structured_regression_test.dart @@ -1,4 +1,5 @@ import 'package:busymark/l10n/generated/app_localizations.dart'; +import 'package:busymark/src/assets/document_media_context.dart'; import 'package:busymark/src/app/busymark_glyphs.dart'; import 'package:busymark/src/app/busymark_design.dart'; import 'package:busymark/src/editor/wysiwyg/wysiwyg_commands.dart'; @@ -18,6 +19,8 @@ import '../support/memory_rich_clipboard.dart'; void main() { const parser = MarkdownParser(); + _structuralBoundaryRegressions(parser); + _hardBreakAnchorRegressions(parser); test( 'formatting a URL suffix retains the complete live link and round trips', @@ -1550,6 +1553,1246 @@ Body. }); } +void _hardBreakAnchorRegressions(MarkdownParser parser) { + group('explicit line-break anchors', () { + const fixtures = [ + ( + name: 'existing hard break', + source: 'a \n![](first.png)b\n', + text: 'a\nb', + objects: 1, + ), + ( + name: 'consecutive hard breaks', + source: 'a\n
\n
\n![](first.png)b\n', + text: 'a\n\nb', + objects: 1, + ), + ( + name: 'objects before at and after the caret', + source: + '![](first.png)a![](second.png) \n![](third.png)![](fourth.png)b![](last.png)\n', + text: 'a\nb', + objects: 5, + ), + ( + name: 'formatted objects after existing break', + source: '**a \n![](first.png)![](second.png)b**![](third.png)c\n', + text: 'a\nbc', + objects: 3, + ), + ( + name: 'formatted objects at shared offsets on either side', + source: + '**a![](first.png)![](second.png)** \n*![](third.png)![](fourth.png)b*\n', + text: 'a\nb', + objects: 4, + ), + ]; + for (final mode in [ + MarkdownMode.commonMark, + MarkdownMode.gfm, + MarkdownMode.writersideMarkdown, + ]) { + for (final fixture in fixtures) { + for (final count in [1, 2]) { + for (var offset = 1; offset < fixture.text.length; offset++) { + test( + '${mode.name} ${fixture.name} count=$count offset=$offset', + () { + final document = parser + .parse( + filePath: 'breaks.md', + source: fixture.source, + mode: mode, + ) + .busyDocument; + final block = document.blocks.single; + expect(block.plainText, fixture.text); + final anchors = _individualImageAnchors(block); + expect(anchors, hasLength(fixture.objects)); + final safeOffset = offset.clamp(0, fixture.text.length); + final expected = [ + for (final anchor in anchors) + [ + anchor.offset <= safeOffset + ? anchor.offset + : anchor.offset + count, + _inlineSignatures([anchor.inline]), + ], + ]; + final controller = BusyMarkWysiwygDocumentController( + document: document, + ); + addTearDown(controller.dispose); + if (count == 1) { + controller.insertHardBreak(block.id, offset); + } else { + controller.insertBlankLine(block.id, offset); + } + final expectedText = fixture.text.replaceRange( + safeOffset, + safeOffset, + '\n' * count, + ); + void check(BusyBlock edited) { + expect(edited.plainText, expectedText); + expect(_anchoredInlineSignatures(edited), expected); + } + + check(controller.document.blocks.single); + final saved = controller.markdown; + controller.rebaseCommittedSource(saved); + expect(controller.markdown, saved); + check(controller.document.blocks.single); + final reopened = parser + .parse(filePath: 'breaks.md', source: saved, mode: mode) + .busyDocument; + check(reopened.blocks.single); + }, + ); + } + } + } + } + + for (final count in [1, 2]) { + testWidgets('toolbar count=$count publishes once and restores history', ( + tester, + ) async { + const original = '**a \n![](first.png)![](second.png)b**\n'; + final document = parser + .parse(filePath: 'breaks.md', source: original) + .busyDocument; + final before = document.blocks.single; + final originalAnchors = _anchoredInlineSignatures(before); + final expected = [ + for (final anchor in _individualImageAnchors(before)) + [ + anchor.offset + count, + _inlineSignatures([anchor.inline]), + ], + ]; + BusyDocument live = document; + var source = original; + var publications = 0; + await tester.pumpWidget( + _app( + BusyMarkWysiwygEditor( + document: document, + onDocumentChanged: (value) => live = value, + onSourceChanged: (_, value) { + source = value; + publications++; + }, + ), + ), + ); + await tester.pumpAndSettle(); + final field = tester.widget( + find.widgetWithText(TextField, 'a\nb'), + ); + field.focusNode!.requestFocus(); + field.controller!.selection = const TextSelection.collapsed(offset: 1); + await tester.pump(); + final button = find.byWidgetPredicate( + (w) => + w is BusyMarkHeaderIconButton && + w.icon == + (count == 1 + ? BusyMarkGlyphs.hardBreak + : BusyMarkGlyphs.blankLine), + ); + await tester.ensureVisible(button); + await tester.tap(button); + await tester.pumpAndSettle(); + expect(publications, 1); + final editedSource = source; + expect(_anchoredInlineSignatures(live.blocks.single), expected); + expect(field.controller!.text, 'a${'\n' * (count + 1)}b'); + expect(field.focusNode!.hasFocus, isTrue); + expect( + field.controller!.selection, + TextSelection.collapsed(offset: 1 + count), + ); + expect( + (tester.state(find.byType(BusyMarkWysiwygEditor)) as dynamic) + .debugUndoSnapshotCount, + 1, + ); + expect( + _anchoredInlineSignatures( + parser + .parse(filePath: 'breaks.md', source: editedSource) + .busyDocument + .blocks + .single, + ), + expected, + ); + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyZ); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pumpAndSettle(); + expect(source, original); + expect(_anchoredInlineSignatures(live.blocks.single), originalAnchors); + 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.pumpAndSettle(); + expect(source, editedSource); + expect(_anchoredInlineSignatures(live.blocks.single), expected); + }); + } + }); +} + +List _anchoredInlineSignatures(BusyBlock block) => [ + for (final anchor in _individualImageAnchors(block)) + [ + anchor.offset, + _inlineSignatures([anchor.inline]), + ], +]; + +List _individualImageAnchors(BusyBlock block) { + final anchors = []; + var offset = 0; + void visit(BusyInline inline, List ancestors) { + if (inline.kind == BusyInlineKind.image) { + var wrapped = inline; + for (final ancestor in ancestors.reversed) { + wrapped = ancestor.copyWith(text: '', children: [wrapped]); + } + anchors.add(BusyInlineSemanticAnchor(offset: offset, inline: wrapped)); + offset += inline.plainText.length; + } else if (inline.children.isNotEmpty) { + for (final child in inline.children) { + visit(child, [...ancestors, inline]); + } + } else { + offset += inline.plainText.length; + } + } + + for (final inline in block.inlines) { + visit(inline, const []); + } + return anchors; +} + +void _structuralBoundaryRegressions(MarkdownParser parser) { + group('structural boundaries', () { + test( + 'ordinary edits preserve formatting around zero-width inline objects on reopen', + () { + final document = parser + .parse( + filePath: 'image.md', + source: '**a![](first.png)![](second.png)b**![](third.png)c\n', + ) + .busyDocument; + final block = document.blocks.single; + expect(block.plainText, 'abc'); + final expected = busyInlineSemanticAnchors( + block.inlines, + ).map((a) => _inlineSignatures([a.inline])).toList(); + expect(expected, hasLength(3)); + final controller = BusyMarkWysiwygDocumentController( + document: document, + ); + addTearDown(controller.dispose); + controller.updateBlockText(block.id, 'abcX'); + final saved = controller.markdown; + final reopened = parser + .parse(filePath: 'image.md', source: saved) + .busyDocument + .blocks + .single; + expect(reopened.plainText, 'abcX', reason: saved); + expect( + busyInlineSemanticAnchors( + reopened.inlines, + ).map((a) => _inlineSignatures([a.inline])).toList(), + expected, + reason: saved, + ); + }, + ); + + test( + 'Writerside Backspace inverse after source publication removes the inserted paragraph source', + () { + const source = + '# Topic\n\n## About the job {id="job" collapsible="true" default-state="expanded"}\n\nBody.\n'; + final controller = BusyMarkWysiwygDocumentController( + document: parser + .parse( + filePath: 'topic.md', + source: source, + mode: MarkdownMode.writersideMarkdown, + ) + .busyDocument, + ); + addTearDown(controller.dispose); + final heading = controller.document.blocks.singleWhere( + (b) => b.plainText == 'About the job', + ); + final entered = controller.applyEnterAt(heading.id, 0)!; + expect(entered.blockId, heading.id); + controller.rebaseCommittedSource(controller.markdown); + final result = controller.applyBackspaceAtStart(heading.id); + expect(result?.blockId, heading.id); + expect( + controller.document.blocks.where( + (b) => b.kind == BusyBlockKind.paragraph && b.plainText.isEmpty, + ), + isEmpty, + ); + expect(controller.markdown, source); + controller.rebaseCommittedSource(controller.markdown); + expect(controller.markdown, source); + expect( + controller.blockById(heading.id)!.attributes['collapsible'], + 'true', + ); + }, + ); + + for (final icon in [BusyMarkGlyphs.hardBreak, BusyMarkGlyphs.blankLine]) { + testWidgets('read-only line-break toolbar $icon cannot create history', ( + tester, + ) async { + final document = parser + .parse(filePath: 'heading.md', source: '## About the job\n') + .busyDocument; + var publications = 0; + await tester.pumpWidget( + _app( + DocumentReadOnlyScope( + readOnly: true, + child: BusyMarkWysiwygEditor( + document: document, + onSourceChanged: (_, _) => publications++, + ), + ), + ), + ); + await tester.pumpAndSettle(); + final field = tester.widget( + find.widgetWithText(TextField, 'About the job'), + ); + field.focusNode!.requestFocus(); + field.controller!.selection = const TextSelection.collapsed(offset: 3); + await tester.pump(); + final button = find.byWidgetPredicate( + (w) => w is BusyMarkHeaderIconButton && w.icon == icon, + ); + await tester.ensureVisible(button); + await tester.tap(button); + await tester.pumpAndSettle(); + expect(publications, 0); + expect( + (tester.state(find.byType(BusyMarkWysiwygEditor)) as dynamic) + .debugUndoSnapshotCount, + 0, + ); + expect(field.controller!.text, 'About the job'); + }); + } + + testWidgets( + 'read-only Enter cannot create structural history or disturb focus', + (tester) async { + final document = parser + .parse(filePath: 'heading.md', source: '## About the job\n') + .busyDocument; + var publications = 0; + await tester.pumpWidget( + _app( + DocumentReadOnlyScope( + readOnly: true, + child: BusyMarkWysiwygEditor( + document: document, + onSourceChanged: (_, _) => publications++, + ), + ), + ), + ); + await tester.pumpAndSettle(); + final field = tester.widget( + find.widgetWithText(TextField, 'About the job'), + ); + field.focusNode!.requestFocus(); + field.controller!.selection = const TextSelection.collapsed(offset: 0); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + expect(publications, 0); + expect( + (tester.state(find.byType(BusyMarkWysiwygEditor)) as dynamic) + .debugUndoSnapshotCount, + 0, + ); + expect(field.focusNode!.hasFocus, isTrue); + expect( + field.controller!.selection, + const TextSelection.collapsed(offset: 0), + ); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pumpAndSettle(); + expect( + field.controller!.selection, + const TextSelection.collapsed(offset: 1), + ); + }, + ); + + test('table cells reject document-block splitting and sibling merging', () { + final document = parser + .parse( + filePath: 'table.md', + source: '| A | B |\n| --- | --- |\n| one | two |\n', + ) + .busyDocument; + final cell = document.blocks.single.children.last.children.last; + final controller = BusyMarkWysiwygDocumentController(document: document); + addTearDown(controller.dispose); + final before = controller.markdown; + expect(controller.applyBackspaceAtStart(cell.id), isNull); + expect(controller.applyEnterAt(cell.id, 0), isNull); + expect(controller.markdown, before); + }); + + for (final scope in BusyWysiwygReplacementScope.values) { + for (final end in [5, 13]) { + test( + 'range replacement newline scope=$scope end=$end is position-aware', + () { + final document = parser + .parse( + filePath: 'heading.md', + source: '## About the job {id="job"}\n', + ) + .busyDocument; + final heading = document.blocks.single; + final controller = BusyMarkWysiwygDocumentController( + document: document, + ); + addTearDown(controller.dispose); + final result = controller.replaceTextSelectionWithText( + firstBlockId: heading.id, + firstStartOffset: 0, + lastBlockId: heading.id, + lastEndOffset: end, + removedBlockIds: [heading.id], + replacementText: '\n', + activeInlineKinds: const [], + preserveTextWhitespace: true, + replacementScope: scope, + ); + expect(result, isNotNull); + if (end == 13) { + expect( + controller.document.blocks.map((b) => b.kind), + everyElement(BusyBlockKind.paragraph), + ); + } else { + expect(controller.document.blocks.last.id, heading.id); + expect( + controller.document.blocks.last.kind, + BusyBlockKind.heading, + ); + expect(controller.document.blocks.last.plainText, ' the job'); + } + }, + ); + } + } + + testWidgets( + 'rejected text-input list exit cannot fall back to a local newline', + (tester) async { + final document = parser + .parse( + filePath: 'topic.md', + mode: MarkdownMode.writersideMarkdown, + source: '- Parent\n\n{id="owner"}\n', + ) + .busyDocument; + var publications = 0; + await tester.pumpWidget( + _app( + BusyMarkWysiwygEditor( + document: document, + onSourceChanged: (_, _) => publications++, + ), + ), + ); + await tester.pumpAndSettle(); + final field = tester.widget( + find.widgetWithText(TextField, 'Parent'), + ); + field.focusNode!.requestFocus(); + field.controller!.selection = const TextSelection( + baseOffset: 0, + extentOffset: 6, + ); + await tester.pump(); + tester.testTextInput.updateEditingValue( + const TextEditingValue( + text: '\n', + selection: TextSelection.collapsed(offset: 1), + ), + ); + await tester.pumpAndSettle(); + expect(publications, 0); + expect(field.controller!.text, 'Parent'); + expect( + field.controller!.selection, + const TextSelection(baseOffset: 0, extentOffset: 6), + ); + expect( + (tester.state(find.byType(BusyMarkWysiwygEditor)) as dynamic) + .debugUndoSnapshotCount, + 0, + ); + }, + ); + + for (final input in [true, false]) { + testWidgets( + 'merged rejected structural ${input ? "input" : "backspace"} preserves an existing Redo', + (tester) async { + final document = parser + .parse( + filePath: 'topic.md', + mode: MarkdownMode.writersideMarkdown, + source: '- Parent\n\n{id="owner"}\n', + ) + .busyDocument; + var source = document.source!; + var publications = 0; + final key = GlobalKey(); + await tester.pumpWidget( + _app( + BusyMarkWysiwygEditor( + key: key, + document: document, + onSourceChanged: (_, value) { + source = value; + publications++; + }, + ), + ), + ); + await tester.pumpAndSettle(); + final field = tester.widget( + find.widgetWithText(TextField, 'Parent'), + ); + field.focusNode!.requestFocus(); + field.controller!.selection = const TextSelection.collapsed( + offset: 6, + ); + await tester.pumpAndSettle(); + tester.testTextInput.updateEditingValue( + const TextEditingValue( + text: 'ParentX', + selection: TextSelection.collapsed(offset: 7), + ), + ); + await tester.pumpAndSettle(); + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyZ); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pumpAndSettle(); + expect(field.controller!.text, 'Parent'); + final before = source; + final count = publications; + final undoCount = key.currentState!.debugUndoSnapshotCount; + field.controller!.selection = input + ? const TextSelection(baseOffset: 0, extentOffset: 6) + : const TextSelection.collapsed(offset: 0); + await tester.pump(); + if (input) { + tester.testTextInput.updateEditingValue( + const TextEditingValue( + text: '\n', + selection: TextSelection.collapsed(offset: 1), + ), + ); + } else { + await tester.sendKeyEvent(LogicalKeyboardKey.backspace); + } + await tester.pumpAndSettle(); + expect(source, before); + expect(publications, count); + expect(key.currentState!.debugUndoSnapshotCount, undoCount); + 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.pumpAndSettle(); + expect(source, contains('ParentX')); + expect(field.controller!.text, 'ParentX'); + }, + ); + } + + for (final selectedEnd in [5, 15]) { + testWidgets( + 'local selection Enter at heading prefix $selectedEnd is one edit', + (tester) async { + final document = parser + .parse(filePath: 'heading.md', source: '## About the job\n') + .busyDocument; + final heading = document.blocks.single; + BusyDocument? live; + var publications = 0; + await tester.pumpWidget( + _app( + BusyMarkWysiwygEditor( + document: document, + onDocumentChanged: (value) => live = value, + onSourceChanged: (_, _) => publications++, + ), + ), + ); + await tester.pumpAndSettle(); + final field = tester.widget( + find.byKey(ValueKey('wysiwyg-field-heading.md-${heading.id}')), + ); + field.focusNode!.requestFocus(); + field.controller!.selection = TextSelection( + baseOffset: 0, + extentOffset: selectedEnd.clamp(0, heading.plainText.length), + ); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + expect(publications, 1); + if (selectedEnd < heading.plainText.length) { + expect(live!.blocks.last.id, heading.id); + expect(live!.blocks.last.kind, BusyBlockKind.heading); + expect( + live!.blocks.last.plainText, + heading.plainText.substring(selectedEnd), + ); + } else { + expect( + live!.blocks.where((b) => b.kind == BusyBlockKind.heading), + isEmpty, + ); + } + }, + ); + } + + testWidgets('rejected Enter over attributed sole list member is atomic', ( + tester, + ) async { + final document = parser + .parse( + filePath: 'topic.md', + mode: MarkdownMode.writersideMarkdown, + source: '- Parent\n\n{id="owner"}\n', + ) + .busyDocument; + final member = _blocksDepthFirst( + document.blocks, + ).singleWhere((b) => b.plainText == 'Parent'); + BusyDocument? live; + var publications = 0; + await tester.pumpWidget( + _app( + BusyMarkWysiwygEditor( + document: document, + onDocumentChanged: (value) => live = value, + onSourceChanged: (_, _) => publications++, + ), + ), + ); + await tester.pumpAndSettle(); + final field = tester.widget( + find.byKey(ValueKey('wysiwyg-field-topic.md-${member.id}')), + ); + field.focusNode!.requestFocus(); + field.controller!.selection = const TextSelection( + baseOffset: 0, + extentOffset: 6, + ); + await tester.pump(); + final beforeUndo = + (tester.state(find.byType(BusyMarkWysiwygEditor)) as dynamic) + .debugUndoSnapshotCount; + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + expect(publications, 0); + expect(live, isNull); + expect(field.controller!.text, 'Parent'); + expect( + field.controller!.selection, + const TextSelection(baseOffset: 0, extentOffset: 6), + ); + expect( + (tester.state(find.byType(BusyMarkWysiwygEditor)) as dynamic) + .debugUndoSnapshotCount, + beforeUndo, + ); + // A later edit publishes the model too: do not mask a staged deletion. + tester.testTextInput.updateEditingValue( + const TextEditingValue( + text: 'Parent!', + selection: TextSelection.collapsed(offset: 7), + ), + ); + await tester.pumpAndSettle(); + expect( + _blocksDepthFirst(live!.blocks).where((b) => b.plainText == 'Parent!'), + hasLength(1), + ); + }); + + test( + 'property publication followed by nested split preserves source and edited properties', + () { + final controller = BusyMarkWysiwygDocumentController( + document: parser + .parse( + filePath: 'topic.md', + mode: MarkdownMode.writersideMarkdown, + source: '# Topic\n\n> AlphaBeta\n\n{id="quote"}\n', + ) + .busyDocument, + ); + addTearDown(controller.dispose); + final quote = controller.document.blocks.last; + final paragraph = quote.children.single; + expect( + controller.updateWritersideProperty(quote.id, 'id', 'changed'), + isTrue, + ); + final committed = controller.markdown; + controller.rebaseCommittedSource(committed); + controller.applyEnterAt(paragraph.id, 5); + final saved = controller.markdown; + expect(saved, contains('> Alpha\n>\n> Beta')); + expect(saved, contains('id="changed"')); + controller.rebaseCommittedSource(saved); + expect(controller.markdown, saved); + }, + ); + + test( + 'protected and generated blocks reject direct structural operations', + () { + final parsed = parser + .parse(filePath: 'heading.md', source: '## About\n') + .busyDocument; + for (final block in [ + parsed.blocks.single.copyWith(isSourceProtected: true), + parsed.blocks.single.copyWith(isSourceOnly: true), + parsed.blocks.single.copyWith(isGenerated: true), + parsed.blocks.single.copyWith(preserveRaw: true), + ]) { + final controller = BusyMarkWysiwygDocumentController( + document: parsed.copyWith(blocks: [block]), + ); + addTearDown(controller.dispose); + final before = controller.document; + expect(controller.applyEnterAt(block.id, 0), isNull); + expect( + controller.replaceBlockTextWithParagraphs(block.id, '\nAbout', 1), + isNull, + ); + expect(controller.document, same(before)); + } + }, + ); + + test('semantic-only list item cannot exit and discard its object', () { + final document = parser + .parse(filePath: 'list.md', source: '- ![](image.png)\n') + .busyDocument; + final item = document.blocks.single; + expect(item.plainText, isEmpty); + for (final input in [false, true]) { + final controller = BusyMarkWysiwygDocumentController( + document: document, + ); + addTearDown(controller.dispose); + if (input) { + controller.replaceBlockTextWithParagraphs(item.id, '\n', 1); + } else { + controller.applyEnterAt(item.id, 0); + } + expect(controller.markdown, contains('![](image.png)')); + expect( + busyInlineSemanticAnchors(controller.blockById(item.id)!.inlines), + hasLength(1), + ); + } + }); + + for (final later in [false, true]) { + for (final mode in MarkdownMode.values) { + for (var level = 1; level <= 6; level++) { + for (final path in ['Enter', 'text input', 'paste']) { + test( + '$mode H$level start via $path retains heading identity later=$later', + () { + final document = parser + .parse( + filePath: 'heading.md', + mode: mode, + source: + '${later ? 'Introduction\n\n' : ''}${'#' * level} **About** ![](image.png)the job {id="job"}\n', + ) + .busyDocument; + final heading = document.blocks.last; + final controller = BusyMarkWysiwygDocumentController( + document: document, + ); + addTearDown(controller.dispose); + final result = path == 'Enter' + ? controller.applyEnterAt(heading.id, 0) + : controller.replaceBlockTextWithParagraphs( + heading.id, + '\n${heading.plainText}', + 1, + allowListExit: path != 'paste', + preserveTextWhitespace: path == 'paste', + ); + expect(result?.blockId, heading.id); + expect(result?.offset, 0); + expect( + controller.document.blocks + .skip(later ? 1 : 0) + .map((b) => b.kind), + [BusyBlockKind.paragraph, BusyBlockKind.heading], + ); + final kept = controller.blockById(heading.id)!; + expect(kept.attributes['level'], '$level'); + expect(kept.attributes['id'], 'job'); + expect(kept.attributes['generatedId'], 'false'); + expect( + _inlineSignatures(kept.inlines), + _inlineSignatures(heading.inlines), + ); + final paragraph = controller.document.blocks[later ? 1 : 0]; + expect(paragraph.id, isNot(heading.id)); + expect(paragraph.plainText, isEmpty); + expect(busyInlineSemanticAnchors(paragraph.inlines), isEmpty); + expect(paragraph.attributes, isNot(contains('id'))); + final saved = controller.markdown; + controller.rebaseCommittedSource(saved); + expect(controller.markdown, saved); + final reopened = parser + .parse(filePath: 'heading.md', source: saved, mode: mode) + .busyDocument; + expect(reopened.blocks.last.kind, BusyBlockKind.heading); + expect(reopened.blocks.last.attributes['level'], '$level'); + expect( + _inlineSignatures(reopened.blocks.last.inlines), + _inlineSignatures(heading.inlines), + ); + }, + ); + } + } + } + } + + for (final mode in MarkdownMode.values) { + for (var level = 1; level <= 6; level++) { + for (final offset in [3, 6]) { + test( + '$mode H$level heading middle/end $offset keeps existing split semantics', + () { + final document = parser + .parse( + filePath: 'heading.md', + mode: mode, + source: '${'#' * level} abcdef\n', + ) + .busyDocument; + for (final input in [false, true]) { + final controller = BusyMarkWysiwygDocumentController( + document: document, + ); + addTearDown(controller.dispose); + final heading = document.blocks.single; + final result = input + ? controller.replaceBlockTextWithParagraphs( + heading.id, + 'abcdef'.replaceRange(offset, offset, '\n'), + offset + 1, + ) + : controller.applyEnterAt(heading.id, offset); + expect(controller.document.blocks.first.id, heading.id); + expect( + controller.document.blocks.first.kind, + BusyBlockKind.heading, + ); + expect( + controller.document.blocks.first.plainText, + 'abcdef'.substring(0, offset), + ); + expect( + controller.document.blocks.last.kind, + BusyBlockKind.paragraph, + ); + expect( + controller.document.blocks.last.plainText, + 'abcdef'.substring(offset), + ); + expect(result?.blockId, controller.document.blocks.last.id); + } + }, + ); + } + } + } + + for (final offset in [0, 3, 6]) { + test('Writerside heading attributes stay with heading at $offset', () { + for (final input in [false, true]) { + final document = parser + .parse( + filePath: 'topic.md', + mode: MarkdownMode.writersideMarkdown, + source: + '# Topic\n\n## abcdef {id="section" collapsible="true" default-state="expanded"}\n', + ) + .busyDocument; + final heading = document.blocks.last; + final controller = BusyMarkWysiwygDocumentController( + document: document, + ); + addTearDown(controller.dispose); + if (input) { + controller.replaceBlockTextWithParagraphs( + heading.id, + 'abcdef'.replaceRange(offset, offset, '\n'), + offset + 1, + ); + } else { + controller.applyEnterAt(heading.id, offset); + } + final paragraph = controller.document.blocks.singleWhere( + (b) => b.kind == BusyBlockKind.paragraph, + ); + for (final key in [ + 'id', + 'level', + 'generatedId', + 'collapsible', + 'default-state', + 'busymark-writerside-source-attributes', + 'busymark-property-edits', + ]) { + expect(paragraph.attributes, isNot(contains(key)), reason: key); + } + final saved = controller.markdown; + expect('collapsible='.allMatches(saved), hasLength(1)); + expect('default-state='.allMatches(saved), hasLength(1)); + controller.rebaseCommittedSource(saved); + expect(controller.markdown, saved); + final reopened = parser + .parse( + filePath: 'topic.md', + source: saved, + mode: MarkdownMode.writersideMarkdown, + ) + .busyDocument; + final section = reopened.blocks.singleWhere( + (b) => b.attributes['id'] == 'section', + ); + expect(section.kind, BusyBlockKind.heading); + expect(section.attributes['collapsible'], 'true'); + expect(section.attributes['default-state'], 'expanded'); + } + }); + } + + for (final nested in [false, true]) { + test( + 'Backspace removes only the empty preceding paragraph nested=$nested', + () { + final source = nested + ? '> Clear\n>\n> ## **About** ![](image.png)job {id="job"}\n' + : 'Clear\n\n## **About** ![](image.png)job {id="job"}\n'; + final document = parser + .parse(filePath: 'heading.md', source: source) + .busyDocument; + final siblings = nested + ? document.blocks.single.children + : document.blocks; + final paragraph = siblings.first; + final heading = siblings.last; + final controller = BusyMarkWysiwygDocumentController( + document: document, + ); + addTearDown(controller.dispose); + controller.updateBlockText(paragraph.id, ''); + final result = controller.applyBackspaceAtStart(heading.id); + expect(result?.blockId, heading.id); + expect(result?.offset, 0); + final kept = controller.blockById(heading.id)!; + expect(kept.kind, BusyBlockKind.heading); + expect(kept.attributes['id'], 'job'); + expect( + _inlineSignatures(kept.inlines), + _inlineSignatures(heading.inlines), + ); + expect(controller.blockById(paragraph.id), isNull); + final reopened = parser + .parse(filePath: 'heading.md', source: controller.markdown) + .busyDocument; + expect( + _blocksDepthFirst( + reopened.blocks, + ).where((b) => b.kind == BusyBlockKind.heading), + hasLength(1), + ); + }, + ); + } + + for (final marker in ['-', '1.', '- [ ]']) { + for (final input in [false, true]) { + for (final clear in [false, true]) { + test( + 'list $marker start input=$input clear=$clear retains parent subtree', + () { + final indent = marker == '1.' ? ' ' : ' '; + final document = parser + .parse( + filePath: 'list.md', + source: + '$marker Parent\n$indent- Child\n$indent - Grandchild\n', + ) + .busyDocument; + final parent = document.blocks.single; + final controller = BusyMarkWysiwygDocumentController( + document: document, + ); + addTearDown(controller.dispose); + if (clear) controller.updateBlockText(parent.id, ''); + final result = input + ? controller.replaceBlockTextWithParagraphs( + parent.id, + '\n${clear ? '' : parent.plainText}', + 1, + ) + : controller.applyEnterAt(parent.id, 0); + expect(result?.blockId, parent.id); + expect(controller.document.blocks.last.id, parent.id); + expect(controller.document.blocks.first.children, isEmpty); + final kept = controller.blockById(parent.id)!; + expect(kept.kind, parent.kind); + expect(kept.children.single.id, parent.children.single.id); + expect(_blocksDepthFirst(kept.children).map((b) => b.plainText), [ + 'Child', + 'Grandchild', + ]); + final saved = controller.markdown; + final reopened = parser + .parse(filePath: 'list.md', source: saved) + .busyDocument; + expect( + _blocksDepthFirst( + reopened.blocks, + ).where((b) => b.plainText == 'Child'), + hasLength(1), + reason: saved, + ); + expect( + _blocksDepthFirst( + reopened.blocks, + ).where((b) => b.plainText == 'Grandchild'), + hasLength(1), + reason: saved, + ); + expect( + reopened.blocks.last.children.single.plainText, + 'Child', + reason: saved, + ); + }, + ); + } + } + } + + for (final operation in ['newline', 'hard break', 'blank line']) { + for (final offset in [0, 1, 2, 3]) { + test('$operation preserves ordered inline objects at $offset', () { + final document = parser + .parse( + filePath: 'image.md', + source: '**a![](first.png)![](second.png)b**![](third.png)c\n', + ) + .busyDocument; + final block = document.blocks.single; + expect(block.plainText, 'abc'); + expect( + busyInlineStyleRanges( + block.inlines, + ).where((r) => r.kind == BusyInlineKind.strong), + isNotEmpty, + ); + final expected = busyInlineSemanticAnchors( + block.inlines, + ).map((a) => _inlineSignatures([a.inline])).toList(); + expect(expected, hasLength(3)); + final controller = BusyMarkWysiwygDocumentController( + document: document, + ); + addTearDown(controller.dispose); + switch (operation) { + case 'newline': + controller.replaceBlockTextWithParagraphs( + block.id, + block.plainText.replaceRange(offset, offset, '\n'), + offset + 1, + allowListExit: false, + ); + case 'hard break': + controller.insertHardBreak(block.id, offset); + case 'blank line': + controller.insertBlankLine(block.id, offset); + } + final objects = [ + for (final b in controller.document.blocks) + ...busyInlineSemanticAnchors( + b.inlines, + ).map((a) => _inlineSignatures([a.inline])), + ]; + expect(objects, expected); + var precedingText = 0; + final actualOffsets = []; + for (final fragment in controller.document.blocks) { + actualOffsets.addAll( + busyInlineSemanticAnchors( + fragment.inlines, + ).map((a) => precedingText + a.offset), + ); + precedingText += + fragment.plainText.length + (operation == 'newline' ? 1 : 0); + } + final insertedLength = operation == 'blank line' ? 2 : 1; + expect( + actualOffsets, + busyInlineSemanticAnchors(block.inlines).map( + (a) => a.offset <= offset ? a.offset : a.offset + insertedLength, + ), + ); + final saved = controller.markdown; + final reopened = parser + .parse(filePath: 'image.md', source: saved) + .busyDocument; + expect( + [ + for (final b in reopened.blocks) + ...busyInlineSemanticAnchors( + b.inlines, + ).map((a) => _inlineSignatures([a.inline])), + ], + expected, + reason: saved, + ); + }); + } + } + + testWidgets( + 'physical Enter at heading start keeps focus, publishes once and undoes', + (tester) async { + final document = parser + .parse( + filePath: 'header_test_text.md', + source: '## About the job\n', + ) + .busyDocument; + final heading = document.blocks.single; + var publications = 0; + var source = document.source!; + BusyDocument? live; + await tester.pumpWidget( + _app( + BusyMarkWysiwygEditor( + document: document, + onDocumentChanged: (value) => live = value, + onSourceChanged: (_, value) { + source = value; + publications++; + }, + ), + ), + ); + await tester.pumpAndSettle(); + final field = tester.widget( + find.byKey( + ValueKey('wysiwyg-field-header_test_text.md-${heading.id}'), + ), + ); + field.focusNode!.requestFocus(); + field.controller!.selection = const TextSelection.collapsed(offset: 0); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + expect(publications, 1); + expect(live!.blocks.map((b) => b.kind), [ + BusyBlockKind.paragraph, + BusyBlockKind.heading, + ]); + final kept = tester.widget( + find.byKey( + ValueKey('wysiwyg-field-header_test_text.md-${heading.id}'), + ), + ); + expect(kept.focusNode!.hasFocus, isTrue); + expect( + kept.controller!.selection, + const TextSelection.collapsed(offset: 0), + ); + expect(source, contains('## About the job')); + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyZ); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pumpAndSettle(); + expect(source, '## About the job\n'); + expect(live!.blocks, hasLength(1)); + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyZ); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pumpAndSettle(); + expect(live!.blocks.map((b) => b.kind), [ + BusyBlockKind.paragraph, + BusyBlockKind.heading, + ]); + }, + ); + }); +} + +List _inlineSignatures(List inlines) => [ + for (final inline in inlines) + [ + inline.kind, + inline.text, + inline.destination, + inline.attributes, + _inlineSignatures(inline.children), + ], +]; + Iterable _blocksDepthFirst(Iterable blocks) sync* { for (final block in blocks) { yield block; diff --git a/test/support/desktop_focus.dart b/test/support/desktop_focus.dart new file mode 100644 index 00000000..349a58f7 --- /dev/null +++ b/test/support/desktop_focus.dart @@ -0,0 +1,37 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Exercise the desktop FocusManager, including in bindings created before the +/// test's platform override. Never replace this with an explicit requestFocus. +void enableDesktopFocusLifecycle(WidgetTester tester) { + expect(defaultTargetPlatform, TargetPlatform.linux); + // ignore: invalid_use_of_visible_for_testing_member + FocusManager.instance.listenToApplicationLifecycleChangesIfSupported(); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); +} + +Future pumpDesktopFocusCycle( + WidgetTester tester, { + required FocusNode target, + int inactiveFrames = 1, + required VoidCallback verifyViewport, +}) async { + expect(FocusManager.instance.primaryFocus, same(target)); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive); + expect( + FocusManager.instance.primaryFocus, + same(FocusManager.instance.rootScope), + ); + for (var frame = 0; frame < inactiveFrames; frame++) { + await tester.pump(const Duration(milliseconds: 16)); + verifyViewport(); + } + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + for (var frame = 0; frame < 30; frame++) { + await tester.pump(const Duration(milliseconds: 16)); + verifyViewport(); + } + expect(FocusManager.instance.primaryFocus, same(target)); + expect(tester.testTextInput.hasAnyClients, isTrue); +} diff --git a/tools/deferred_focus_native.py b/tools/deferred_focus_native.py new file mode 100644 index 00000000..8feda7a3 --- /dev/null +++ b/tools/deferred_focus_native.py @@ -0,0 +1,65 @@ +#!/usr/bin/python3 +"""Run focus races on an owned desktop with profiles isolated before D-Bus. + +Usage: python3 tools/deferred_focus_native.py BINARY NEW_OUTPUT BACKEND MODE +MODE: deferred-dialog, deferred-selection, deferred-selection-table, or editor. +The existing compositor driver performs the actual native input/lifecycle work. +""" +import json +import os +from pathlib import Path +import subprocess +import sys + + +def run(binary, output, backend, mode): + if backend not in ["x11", "wayland"] or mode not in [ + "deferred-dialog", "deferred-selection", "deferred-selection-table", "editor" + ]: + raise ValueError("Unsupported native focus scenario") + if mode.startswith("deferred-selection") and backend != "x11": + raise ValueError("Native selection gesture requires the owned X11 window") + output = Path(output).resolve() + if len(os.fsencode(output / "desktop/runtime/focus-acceptance")) >= 108: + raise ValueError("Use a shorter output path for the Wayland Unix socket") + output.mkdir(parents=True, exist_ok=False) + env = dict(os.environ) + for key, name in [("XDG_RUNTIME_DIR", "runtime"), ("XDG_CONFIG_HOME", "config"), + ("XDG_DATA_HOME", "data"), ("XDG_CACHE_HOME", "cache"), + ("XDG_STATE_HOME", "state")]: + directory = output / name + directory.mkdir(mode=0o700) + env[key] = str(directory) + for key in ["DISPLAY", "WAYLAND_DISPLAY", "XAUTHORITY", "SESSION_MANAGER", + "GNOME_KEYRING_PID", "GNOME_KEYRING_CONTROL", "SSH_AUTH_SOCK", "GPG_AGENT_INFO"]: + env.pop(key, None) + command = [ + "/usr/bin/dbus-run-session", "--", "/usr/bin/python3", + "tools/focus_restoration_native.py", str(Path(binary).resolve()), + str(output / "desktop"), backend, mode, "alt-tab", + ] + with (output / "supervisor.log").open("w") as log: + supervisor = subprocess.Popen( + ["/usr/bin/python3", "tools/run_owned_fixture.py", "--report", + str(output / "ownership.json"), "--duration", "240", "--", *command], + env=env, stdin=subprocess.PIPE, stdout=log, stderr=subprocess.STDOUT) + try: + status = supervisor.wait(timeout=260) + finally: + if supervisor.poll() is None: + supervisor.stdin.write(b"stop\n") + supervisor.stdin.flush() + supervisor.wait(timeout=15) + supervisor.stdin.close() + result_file = output / "desktop/probe/result.json" + result = json.loads(result_file.read_text()) if result_file.exists() else None + passed = status == 0 and result is not None and all( + value for group in result.values() for value in group.values()) + print(json.dumps({"backend": backend, "mode": mode, "exitCode": status, + "passed": passed, "result": result})) + if not passed: + raise RuntimeError("Native focus acceptance failed; inspect retained output") + + +if __name__ == "__main__": + run(*sys.argv[1:]) diff --git a/tools/focus_restoration_native.py b/tools/focus_restoration_native.py new file mode 100644 index 00000000..82c056e3 --- /dev/null +++ b/tools/focus_restoration_native.py @@ -0,0 +1,322 @@ +#!/usr/bin/env python3 +"""Run the focus probe on an owned GNOME headless desktop, never the user's. + +Usage: dbus-run-session -- /usr/bin/python3 tools/focus_restoration_native.py + BUNDLE_EXECUTABLE NEW_OUTPUT_DIRECTORY x11|wayland MODE + [full|alt-tab|minimize] [markdown|writerside] +MODE: editor, source, split, table, deferred-dialog, deferred-selection, + deferred-selection-table (selection gestures require XWayland). +Requires GNOME Mutter RemoteDesktop input support and system PyGObject. +""" +import ctypes as C +import json +import os +from pathlib import Path +import re +import signal +import subprocess +import sys +import time + +import gi +gi.require_version("Gio", "2.0") +from gi.repository import Gio, GLib + + +def wait_for(predicate, timeout=45): + end = time.monotonic() + timeout + while not predicate(): + if time.monotonic() >= end: + raise RuntimeError("Native prerequisite timed out") + time.sleep(0.05) + + +def companion(): + gi.require_version("Gtk", "3.0") + from gi.repository import Gtk + window = Gtk.Window(title="Focus acceptance companion") + window.set_default_size(400, 200) + window.add(Gtk.Label(label="Disposable focus acceptance application")) + window.connect("destroy", Gtk.main_quit) + window.show_all() + Gtk.main() + + +def run(binary, output, backend, mode, transitions="full", document_format="markdown"): + parent = os.getppid() + if Path(f"/proc/{parent}/exe").resolve().name != "dbus-run-session": + raise RuntimeError("Run under a dedicated dbus-run-session, never the host session bus") + output = Path(output).resolve() + output.mkdir(parents=True, exist_ok=False) + env = dict(os.environ) + for key, child in [("XDG_RUNTIME_DIR", "runtime"), ("XDG_CONFIG_HOME", "config"), + ("XDG_DATA_HOME", "data"), ("XDG_CACHE_HOME", "cache"), + ("XDG_STATE_HOME", "state")]: + directory = output / child + directory.mkdir(mode=0o700) + env[key] = str(directory) + env["GDK_BACKEND"] = backend + env["WAYLAND_DISPLAY"] = "focus-acceptance" + env["XDG_SESSION_TYPE"] = "wayland" + keyring = output / "runtime/keyring" + keyring.mkdir(mode=0o700) + env["GNOME_KEYRING_CONTROL"] = str(keyring) + for key in ("GNOME_KEYRING_PID", "SSH_AUTH_SOCK", "GPG_AGENT_INFO", "SESSION_MANAGER"): + env.pop(key, None) + env.pop("DISPLAY", None) + env.pop("XAUTHORITY", None) + bus = Gio.bus_get_sync(Gio.BusType.SESSION, None) + broker = bus.call_sync("org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", + "GetConnectionUnixProcessID", GLib.Variant("(s)", ("org.freedesktop.DBus",)), + None, Gio.DBusCallFlags.NONE, 10000, None).unpack()[0] + broker_parent = re.search(r"^PPid:\s+(\d+)$", Path(f"/proc/{broker}/status").read_text(), re.M) + if broker_parent is None or int(broker_parent[1]) != parent: + raise RuntimeError("The D-Bus broker is not owned by this dbus-run-session") + + def update_activation_environment(): + # The private broker predates this Python process's isolated profile. + # D-Bus-activated desktop helpers must inherit it too, not the host's. + values = {key: env.get(key, "") for key in ( + "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME", + "XDG_STATE_HOME", "GDK_BACKEND", "WAYLAND_DISPLAY", "DISPLAY", "XAUTHORITY", + "XDG_SESSION_TYPE", "GNOME_KEYRING_CONTROL", "GNOME_KEYRING_PID", + "SSH_AUTH_SOCK", "GPG_AGENT_INFO", "SESSION_MANAGER")} + bus.call_sync("org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", + "UpdateActivationEnvironment", GLib.Variant("(a{ss})", (values,)), + None, Gio.DBusCallFlags.NONE, 10000, None) + + update_activation_environment() + processes = [] + logs = [] + actions = [] + + def launch(command, name): + log = (output / f"{name}.log").open("w") + logs.append(log) + process = subprocess.Popen(command, env=env, stdout=log, stderr=subprocess.STDOUT, + start_new_session=True) + processes.append(process) + return process + + try: + compositor = launch(["gnome-shell", "--headless", "--wayland", "--virtual-monitor=1280x960", + "--wayland-display=focus-acceptance"], "compositor") + wait_for(lambda: (output / "runtime/focus-acceptance").exists() or compositor.poll() is not None) + if compositor.poll() is not None: + raise RuntimeError("Disposable compositor exited; see compositor.log") + wait_for(lambda: "Using public X11 display" in (output / "compositor.log").read_text()) + env["DISPLAY"] = re.search(r"Using public X11 display (:[0-9]+)", (output / "compositor.log").read_text())[1] + auth = list((output / "runtime").glob(".mutter-Xwaylandauth.*")) + if auth: + env["XAUTHORITY"] = str(auth[0]) + update_activation_environment() + wait_for(lambda: bus.call_sync("org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "NameHasOwner", GLib.Variant("(s)", ("org.gnome.Mutter.RemoteDesktop",)), None, Gio.DBusCallFlags.NONE, 1000, None).unpack()[0], timeout=30) + + def call(path, interface, method, parameters=None): + return bus.call_sync("org.gnome.Mutter.RemoteDesktop", path, interface, method, + parameters, None, Gio.DBusCallFlags.NONE, 10000, None) + + session = call("/org/gnome/Mutter/RemoteDesktop", "org.gnome.Mutter.RemoteDesktop", "CreateSession").unpack()[0] + interface = "org.gnome.Mutter.RemoteDesktop.Session" + call(session, interface, "Start") + xml = bus.call_sync("org.gnome.Mutter.RemoteDesktop", session, "org.freedesktop.DBus.Introspectable", "Introspect", None, None, Gio.DBusCallFlags.NONE, 1000, None).unpack()[0] + (output / "remote-input-interface.xml").write_text(xml) + + def key(code, pressed): + call(session, interface, "NotifyKeyboardKeycode", GLib.Variant("(ub)", (code, pressed))) + + def chord(modifier, code): + key(modifier, True) + key(code, True) + key(code, False) + key(modifier, False) + actions.append({"time": time.monotonic(), "keys": [modifier, code]}) + time.sleep(0.7) + + launch(["/usr/bin/python3", str(Path(__file__).resolve()), "--companion"], "companion") + time.sleep(1) + text = "Focus target is the original insertion location.\n\n" + text += "| Header | Other |\n| --- | --- |\n| Focus cell | Body |\n\n" + text += "\n\n".join((f"Reading paragraph {i} " + "reading words " * 25).rstrip() for i in range(160)) + "\n" + if document_format == "writerside": + text = '

Focus target is the original insertion location.

' + ''.join(f'

Reading paragraph {i} ' + 'reading words ' * 25 + '

' for i in range(160)) + '
' + fixture = output / ("disposable.topic" if document_format == "writerside" else "disposable.md") + fixture.write_text(text) + probe = output / "probe" + probe.mkdir() + app = launch([str(Path(binary).resolve()), str(fixture), str(probe), mode], "application") + wait_for(lambda: any((probe / name).exists() for name in + ["ready", "dialog-ready", "selection-ready", "activation-needed", "done"]) or app.poll() is not None) + if (probe / "activation-needed").exists(): + chord(56, 15) # Wayland may not activate a subprocess on mapping. + wait_for(lambda: any((probe / name).exists() for name in + ["ready", "dialog-ready", "selection-ready", "done"]) or app.poll() is not None) + if (probe / "dialog-ready").exists(): + # Deterministic production Navigator dismissal; focus switching and + # subsequent input still come from the actual native GTK embedder. + (probe / "dismiss-dialog").touch() + actions.append({"time": time.monotonic(), "programmatic-dialog-dismissal": True}) + if (probe / "selection-ready").exists(): + if backend != "x11": + raise RuntimeError("Native selection gesture currently requires the owned X11 window") + os.environ.update({key: env[key] for key in ("DISPLAY", "XAUTHORITY", "GDK_BACKEND") if key in env}) + from application_close_smoke import Desktop + desktop = Desktop() + try: + desktop.Gdk.init([]) + window = desktop.window(app.pid, require_mapped=True, require_normal=True) + if window is None: + raise RuntimeError("Missing owned selection window") + points = json.loads((probe / "selection-gesture.json").read_text()) + start, end = points["start"], points["end"] + if not all(0 < p[0] < window.get_width() and 0 < p[1] < window.get_height() for p in [start, end]): + raise RuntimeError("Selection point outside owned window") + _, origin_x, origin_y = window.get_origin() + # Exclude GTK's invisible CSD shadow as well as any header. + actual, fmt, count, remaining = C.c_ulong(), C.c_int(), C.c_ulong(), C.c_ulong() + data = C.POINTER(C.c_ubyte)() + desktop.x.XGetWindowProperty(desktop.display, window.get_xid(), desktop.atom("_GTK_FRAME_EXTENTS"), 0, 4, 0, 0, C.byref(actual), C.byref(fmt), C.byref(count), C.byref(remaining), C.byref(data)) + try: + if count.value != 4 or fmt.value != 32: + raise RuntimeError("Missing owned GTK frame extents") + left, right, top, bottom = C.cast(data, C.POINTER(C.c_ulong))[:4] + finally: + if data: + desktop.x.XFree(data) + header = window.get_height() - bottom - points["viewHeight"] + if not 0 <= header < 120: + raise RuntimeError(f"Unexpected native header height: {header}") + (output / "native-selection.json").write_text(json.dumps({ + "origin": [origin_x, origin_y], "header": header, + "size": [window.get_width(), window.get_height()], "points": points})) + picture = desktop.Gdk.pixbuf_get_from_window(window, 0, 0, window.get_width(), window.get_height()) + if picture is not None: + picture.savev(str(output / "native-selection.png"), "png", [], []) + xtest = C.CDLL("libXtst.so.6") + xtest.XTestFakeMotionEvent.argtypes = [C.c_void_p, C.c_int, C.c_int, C.c_int, C.c_ulong] + xtest.XTestFakeButtonEvent.argtypes = [C.c_void_p, C.c_uint, C.c_int, C.c_ulong] + def pointer(point=None, pressed=None): + current = desktop.window(app.pid, require_mapped=True, require_normal=True) + if current is None or current.get_xid() != window.get_xid(): + raise RuntimeError("Owned selection window changed") + accepted = xtest.XTestFakeMotionEvent(desktop.display, 0, int(origin_x + left + point[0]), int(origin_y + header + point[1]), 0) if point is not None else xtest.XTestFakeButtonEvent(desktop.display, 1, pressed, 0) + if not accepted: + raise RuntimeError("Owned desktop rejected selection input") + desktop.x.XFlush(desktop.display) + pointer(start) + pointer(pressed=True) + time.sleep(0.2) + pointer(end) + time.sleep(0.2) + pointer(pressed=False) + actions.append({"time": time.monotonic(), "native-selection": mode, "points": points}) + finally: + desktop.dispose() + wait_for(lambda: (probe / "ready").exists() or (probe / "done").exists() or app.poll() is not None) + if not (probe / "ready").exists(): + raise RuntimeError("Application did not reach focus acceptance; see application.log/probe/error.txt") + def lifecycle(expected): + state = probe / "lifecycle" + wait_for(lambda: state.exists() and state.read_text() == expected) + + lifecycle("resumed") + for interval in [0.7, 0.7, 15, 0.7]: + chord(56, 15) # Real Alt-Tab through Mutter, not a framework lifecycle call. + lifecycle("inactive") + time.sleep(interval) + chord(56, 15) + lifecycle("resumed") + if transitions == "minimize": + chord(125, 35) # Real GNOME Super-H minimize. + time.sleep(1) + chord(56, 15) # Real Alt-Tab restores the minimized application. + if transitions == "full": + chord(125, 35) # GNOME Super-H minimize. + time.sleep(1) + if backend == "x11": + os.environ.update({key: env[key] for key in ("DISPLAY", "XAUTHORITY", "GDK_BACKEND") if key in env}) + from application_close_smoke import Desktop + desktop = Desktop() + try: + desktop.Gdk.init([]) + window = desktop.window(app.pid, require_mapped=True, require_normal=True) + if window is not None: + # Some disposable desktops have no Super-H binding. + # Send the native WM iconify request to this owned XID. + window.iconify() + desktop.Gdk.Display.get_default().flush() + actions.append({"time": time.monotonic(), "native-iconify": True}) + # Observe real native unmapping, not merely focus loss. + wait_for(lambda: desktop.window(app.pid, require_mapped=False, require_normal=True) is not None + and desktop.window(app.pid, require_mapped=True, require_normal=True) is None) + actions.append({"time": time.monotonic(), "minimized-unmapped": True}) + finally: + desktop.dispose() + (probe / "restore-window").touch() + wait_for(lambda: (probe / "window-restored").exists() or (probe / "done").exists()) + lifecycle("resumed") + if transitions == "full" and backend == "x11": + # Switch away again, then reactivate only by clicking native chrome. + chord(56, 15) + lifecycle("inactive") + os.environ.update({key: env[key] for key in ("DISPLAY", "XAUTHORITY", "GDK_BACKEND") if key in env}) + from application_close_smoke import Desktop + desktop = Desktop() + try: + desktop.Gdk.init([]) + window = desktop.window(app.pid, require_mapped=True, require_normal=True) + if window is None: + raise RuntimeError("Missing owned application window") + _, origin_x, origin_y = window.get_origin() + call(session, interface, "NotifyPointerMotionRelative", GLib.Variant("(dd)", (-3000., -3000.))) + call(session, interface, "NotifyPointerMotionRelative", GLib.Variant("(dd)", (float(origin_x + window.get_width() / 2), float(origin_y + 16)))) + call(session, interface, "NotifyPointerButton", GLib.Variant("(ib)", (272, True))) + call(session, interface, "NotifyPointerButton", GLib.Variant("(ib)", (272, False))) + actions.append({"time": time.monotonic(), "chrome-reactivation": True}) + lifecycle("resumed") + finally: + desktop.dispose() + elif transitions == "full": + chord(56, 15) + chord(56, 15) + time.sleep(1) + (probe / "returned").touch() + wait_for(lambda: (probe / "type-ready").exists() or (probe / "done").exists()) + key(44, True) # Real native Z key; no tapping/refocusing the editable. + key(44, False) + time.sleep(0.5) + (probe / "typed").touch() + wait_for(lambda: (probe / "done").exists()) + (output / "actions.json").write_text(json.dumps({"backend": backend, "coverage": transitions, "documentFormat": document_format, "actions": actions}, indent=2)) + result = json.loads((probe / "result.json").read_text()) + if transitions == "minimize": + frames = json.loads((probe / "frames.json").read_text()) + if not any(frame["lifecycle"] == "hidden" for frame in frames): + raise RuntimeError("Minimize did not reach the hidden lifecycle state") + print(json.dumps(result)) + if not all(value for group in result.values() for value in group.values()): + raise RuntimeError("Native acceptance assertions failed") + if mode.startswith("deferred-"): + wait_for(lambda: app.poll() is not None, timeout=30) + if app.returncode != 0: + raise RuntimeError(f"Native application exit failed: {app.returncode}") + actions.append({"time": time.monotonic(), "naturalApplicationExit": app.returncode}) + (output / "actions.json").write_text(json.dumps({"backend": backend, "coverage": transitions, "documentFormat": document_format, "actions": actions}, indent=2)) + finally: + for process in reversed(processes): + if process.poll() is None: + os.killpg(process.pid, signal.SIGTERM) + try: + process.wait(timeout=8) + except subprocess.TimeoutExpired: + os.killpg(process.pid, signal.SIGKILL) + process.wait(timeout=8) + for log in logs: + log.close() + + +if __name__ == "__main__": + if sys.argv[1:] == ["--companion"]: + companion() + else: + run(*sys.argv[1:]) diff --git a/tools/focus_restoration_smoke.dart b/tools/focus_restoration_smoke.dart new file mode 100644 index 00000000..ff05e128 --- /dev/null +++ b/tools/focus_restoration_smoke.dart @@ -0,0 +1,524 @@ +// Native focus acceptance entry point. Use only disposable, synthetic files and +// an isolated desktop/profile. All lifecycle events and text input come from +// the real GTK embedder. Measurements contain geometry and counts, never text. +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:busymark/main.dart' as application; +import 'package:busymark/src/app/app_settings.dart'; +import 'package:busymark/src/app/busymark_dialogs.dart'; +import 'package:busymark/src/platform/linux_header_bar_service.dart'; +import 'package:busymark/src/editor/source/source_editor.dart'; +import 'package:busymark/src/editor/wysiwyg/wysiwyg_editor.dart'; +import 'package:busymark/src/workspace/workspace_controller.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/gestures.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) { + throw ArgumentError( + 'DISPOSABLE_DOCUMENT OUTPUT_DIRECTORY ' + 'editor|source|split|table|deferred-dialog|' + 'deferred-selection|deferred-selection-table', + ); + } + final binding = _Binding(); + await application.main([arguments[0]]); + final output = Directory(arguments[1]); + await output.create(recursive: true); + unawaited(_Driver(binding, output, arguments[2]).run()); +} + +class _Binding extends WidgetsFlutterBinding { + final root = GlobalKey(); + @override + Widget wrapWithDefaultView(Widget rootWidget) => + super.wrapWithDefaultView(RepaintBoundary(key: root, child: rootWidget)); +} + +class _Driver with WidgetsBindingObserver { + _Driver(this.binding, this.output, this.mode); + final _Binding binding; + final Directory output; + final String mode; + final frames = >[]; + TextField? target; + TextField? documentTarget; + _DelayedNativeBarrier? barrier; + OverlayEntry? competingEntry; + FocusNode? competingFocus; + TextEditingController? competingController; + PointerRoute? selectionRoute; + bool get deferred => mode.startsWith('deferred-'); + ScrollPosition? position; + bool recording = false; + bool finished = false; + int phase = 0; + Element? readingAnchor; + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + File('${output.path}/lifecycle').writeAsStringSync(state.name); + final pending = barrier; + if (state != AppLifecycleState.resumed && + pending?.releaseStarted == true && + !pending!.release.isCompleted) { + pending.release.complete(); + } + } + + double? topOf(Element? element) { + final render = element?.mounted == true + ? element!.findRenderObject() + : null; + if (render is! RenderBox || !render.hasSize) return null; + final top = render.localToGlobal(Offset.zero).dy; + return top.isFinite ? top : null; + } + + ScrollPosition? get currentPosition { + if (mode == 'source' || mode == 'split') { + return target?.scrollController?.hasClients == true + ? target!.scrollController!.position + : null; + } + final context = (documentTarget ?? target)?.focusNode?.context; + return context == null + ? null + : Scrollable.maybeOf(context, axis: Axis.vertical)?.position; + } + + List elements(bool Function(Widget) predicate, [Element? root]) { + final result = []; + void visit(Element e) { + if (predicate(e.widget)) result.add(e); + e.visitChildren(visit); + } + + final context = binding.root.currentContext; + if (context != null) visit(root ?? context as Element); + return result; + } + + Future waitFor(FutureOr Function() condition) async { + final end = DateTime.now().add(const Duration(seconds: 45)); + while (!await condition()) { + if (DateTime.now().isAfter(end)) { + throw StateError('Native prerequisite timed out in phase $phase'); + } + await Future.delayed(const Duration(milliseconds: 40)); + } + } + + Future signal(String value) => + File('${output.path}/$value').writeAsString('').then((_) {}); + Future proceed(String value) => + waitFor(() => File('${output.path}/$value').existsSync()); + + Map measure(Duration stamp) => { + 'frameUs': stamp.inMicroseconds, + 'phase': phase, + 'lifecycle': binding.lifecycleState?.name, + 'focus': identical(FocusManager.instance.primaryFocus, target?.focusNode), + 'rootFocus': identical( + FocusManager.instance.primaryFocus, + FocusManager.instance.rootScope, + ), + 'attached': target?.focusNode?.context?.mounted == true, + 'pixels': position?.pixels, + 'currentPixels': currentPosition?.pixels, + 'samePosition': identical(position, currentPosition), + 'readingAnchorY': topOf(readingAnchor), + 'targetY': topOf(target?.focusNode?.context as Element?), + 'base': target?.controller?.selection.baseOffset, + 'extent': target?.controller?.selection.extentOffset, + }; + + Future prepareDeferred(ProviderContainer container) async { + if (!await windowManager.isFocused()) await signal('activation-needed'); + await waitFor(windowManager.isFocused); + documentTarget = target; + final focus = competingFocus = FocusNode( + debugLabel: 'Native competing control', + ); + final controller = competingController = TextEditingController(); + final context = elements((w) => w is BusyMarkWysiwygEditor).single; + competingEntry = OverlayEntry( + builder: (_) => Positioned( + left: 700, + top: 30, + width: 220, + height: 40, + child: Material( + child: TextField(focusNode: focus, controller: controller), + ), + ), + ); + Overlay.of(context).insert(competingEntry!); + await waitFor(() => focus.context?.mounted == true); + if (mode == 'deferred-dialog') { + final gate = barrier = _DelayedNativeBarrier(); + unawaited( + showBusyMarkModalDialog( + context, + headerBarService: gate, + builder: (_) => + const AlertDialog(content: Text('Native deferred focus check')), + ), + ); + await waitFor(() => elements((w) => w is AlertDialog).isNotEmpty); + // Building the route does not mean its fallback has focus or that GTK + // has activated the new window. Start the race only after both. + await waitFor(() { + final primary = FocusManager.instance.primaryFocus; + final dialog = elements((w) => w is AlertDialog).single; + return binding.lifecycleState == AppLifecycleState.resumed && + primary is! FocusScopeNode && + primary?.context?.mounted == true && + ModalRoute.of(primary!.context!) == ModalRoute.of(dialog); + }); + await signal('dialog-ready'); + await proceed('dismiss-dialog'); + if (!context.mounted) throw StateError('Dialog host detached'); + Navigator.of(context, rootNavigator: true).pop(); + await waitFor(() => gate.releaseStarted); + focus.requestFocus(); + FocusManager.instance.applyFocusChangesIfNeeded(); + } else { + final first = elements( + (w) => w is TextField && w.controller == documentTarget!.controller, + ).single; + final last = elements( + (w) => + w is TextField && + (mode == 'deferred-selection-table' + ? w.controller?.text == 'Body' + : w.controller?.text.startsWith('Reading paragraph 0 ') == + true), + ).single; + Offset caret(Element element, int offset) { + final editable = + (elements((w) => w is EditableText, element).single + as StatefulElement) + .state + as EditableTextState; + return editable.renderEditable.localToGlobal( + editable.renderEditable + .getLocalRectForCaret(TextPosition(offset: offset)) + .center, + ); + } + + final start = caret(first, 5); + final end = caret(last, mode == 'deferred-selection-table' ? 4 : 5); + await File('${output.path}/selection-gesture.json').writeAsString( + jsonEncode({ + 'start': [start.dx, start.dy], + 'end': [end.dx, end.dy], + 'viewHeight': binding.renderViews.single.size.height, + }), + ); + var completed = false; + void route(PointerEvent event) { + if (event is! PointerUpEvent || completed) return; + final session = container + .read(workspaceControllerProvider) + .activeBuffer! + .editorState + .wysiwygState; + unawaited( + File('${output.path}/pointer-up.json').writeAsString( + jsonEncode({ + 'position': [event.position.dx, event.position.dy], + 'crossBlock': session.anchorBlockId != session.extentBlockId, + }), + ), + ); + if (session.anchorBlockId == null || + session.anchorBlockId == session.extentBlockId) { + return; + } + completed = true; + // A newer control takes focus in the same real native pointer-up turn, + // before the editor's queued post-frame handoff can run. + focus.requestFocus(); + FocusManager.instance.applyFocusChangesIfNeeded(); + unawaited(signal('selection-finished')); + } + + selectionRoute = route; + binding.pointerRouter.addGlobalRoute(route); + await signal('selection-ready'); + await proceed('selection-finished'); + binding.pointerRouter.removeGlobalRoute(route); + selectionRoute = null; + } + await Future.delayed(const Duration(milliseconds: 300)); + if (!identical(FocusManager.instance.primaryFocus, focus)) { + throw StateError( + 'Deferred editor callback stole the newer native control', + ); + } + target = + elements((w) => w is TextField && w.focusNode == focus).single.widget + as TextField; + } + + Future run() async { + try { + await waitFor( + () => elements( + (w) => w is BusyMarkWysiwygEditor || w is BusyMarkSourceEditor, + ).isNotEmpty, + ); + final editor = elements( + (w) => w is BusyMarkWysiwygEditor || w is BusyMarkSourceEditor, + ).first; + final container = ProviderScope.containerOf(editor, listen: false); + final workspace = container.read(workspaceControllerProvider.notifier); + if (mode == 'source' || mode == 'split') { + workspace.updateActiveEditorMode( + mode == 'source' + ? DocumentViewModePreference.source + : DocumentViewModePreference.split, + ); + await waitFor( + () => elements((w) => w is BusyMarkSourceEditor).isNotEmpty, + ); + } + await Future.delayed(const Duration(milliseconds: 600)); + final fieldElements = elements( + (w) => w is TextField && w.controller != null, + ); + final fieldElement = mode == 'table' + ? fieldElements.singleWhere( + (e) => (e.widget as TextField).controller!.text == 'Focus cell', + ) + : (mode == 'source' || mode == 'split') + ? elements( + (w) => w is TextField, + elements((w) => w is BusyMarkSourceEditor).single, + ).single + : fieldElements.firstWhere( + (e) => (e.widget as TextField).controller!.text.startsWith( + 'Focus target', + ), + ); + target = fieldElement.widget as TextField; + target!.focusNode!.requestFocus(); + target!.controller!.selection = const TextSelection.collapsed(offset: 5); + await Future.delayed(const Duration(milliseconds: 500)); + if (mode == 'source' || mode == 'split') { + position = target!.scrollController!.position; + } else { + final list = elements((w) => w is ScrollablePositionedList).single; + position = + (elements((w) => w is Scrollable, list).first as StatefulElement) + .state + is ScrollableState + ? ((elements((w) => w is Scrollable, list).first as StatefulElement) + .state + as ScrollableState) + .position + : null; + } + if (position == null) throw StateError('Missing production viewport'); + if (deferred) await prepareDeferred(container); + position!.jumpTo(position!.pixels + 1900); + await Future.delayed(const Duration(milliseconds: 500)); + if (mode != 'source' && mode != 'split') { + readingAnchor = + elements( + (w) => + w is TextField && + w.controller?.text.startsWith('Reading paragraph') == + true, + ) + .where( + (element) => + topOf(element) != null && + topOf(element)! > 100 && + topOf(element)! < 800, + ) + .firstOrNull; + if (readingAnchor == null) { + throw StateError('Missing visible reading anchor'); + } + } + final readingTop = topOf(readingAnchor); + final original = container + .read(workspaceControllerProvider) + .activeBuffer!; + final source = original.text; + final undoCount = original.editorState.undoState.undo.length; + final redoCount = original.editorState.undoState.redo.length; + final revision = workspace.editRevision; + final selection = target!.controller!.selection; + final documentSelection = documentTarget?.controller!.selection; + final savedRange = original.editorState.wysiwygState; + final insertionOffset = mode == 'source' || mode == 'split' + ? selection.extentOffset + : source.indexOf(target!.controller!.text) + selection.extentOffset; + if (insertionOffset < selection.extentOffset) { + throw StateError( + 'Synthetic target does not have an authored source position', + ); + } + final expectedInput = source.replaceRange( + insertionOffset, + insertionOffset, + 'z', + ); + final offset = position!.pixels; + binding.addPersistentFrameCallback((stamp) { + if (recording && !finished) frames.add(measure(stamp)); + }); + recording = true; + binding.addObserver(this); + didChangeAppLifecycleState(binding.lifecycleState!); + // The supervisor performs real window switches, a prolonged interval, + // minimize/restore, and window-chrome reactivation before signalling. + await signal('ready'); + var restoredWindow = false; + await waitFor(() { + if (!restoredWindow && + File('${output.path}/restore-window').existsSync()) { + restoredWindow = true; + unawaited(() async { + await windowManager.restore(); + await windowManager.show(); + await windowManager.focus(); + await signal('window-restored'); + }()); + } + return File('${output.path}/returned').existsSync(); + }); + await Future.delayed(const Duration(milliseconds: 600)); + final current = container.read(workspaceControllerProvider).activeBuffer!; + final passiveChecks = { + 'viewport': frames.every( + (f) => + f['currentPixels'] is num && + ((f['currentPixels'] as num).toDouble() - offset).abs() < 0.1, + ), + 'readingGeometry': + readingAnchor == null || + frames.every( + (f) => + f['readingAnchorY'] is num && + ((f['readingAnchorY'] as num).toDouble() - readingTop!) + .abs() < + 0.1, + ), + 'suspended': frames.any( + (f) => f['lifecycle'] != 'resumed' && f['focus'] == false, + ), + 'focus': identical( + FocusManager.instance.primaryFocus, + target!.focusNode, + ), + 'selection': target!.controller!.selection == selection, + 'bytes': current.text == source, + 'undo': current.editorState.undoState.undo.length == undoCount, + 'redo': current.editorState.undoState.redo.length == redoCount, + 'dirty': current.dirty == original.dirty, + 'revision': workspace.editRevision == revision, + if (deferred) + 'inactiveRootFocus': frames + .where((f) => f['lifecycle'] != 'resumed') + .every((f) => f['rootFocus'] == true), + if (deferred) + 'originalSelection': + documentTarget!.controller!.selection == documentSelection, + if (deferred) + 'documentRange': + ( + current.editorState.wysiwygState.anchorBlockId, + current.editorState.wysiwygState.anchorOffset, + current.editorState.wysiwygState.extentBlockId, + current.editorState.wysiwygState.extentOffset, + ) == + ( + savedRange.anchorBlockId, + savedRange.anchorOffset, + savedRange.extentBlockId, + savedRange.extentOffset, + ), + }; + phase = 1; + await File( + '${output.path}/passive.json', + ).writeAsString(jsonEncode(passiveChecks)); + await signal('type-ready'); + await proceed('typed'); + await Future.delayed(const Duration(milliseconds: 600)); + final edited = container.read(workspaceControllerProvider).activeBuffer!; + final targetTop = topOf(target?.focusNode?.context as Element?); + final inputChecks = deferred + ? { + 'newControlInput': competingController!.text == 'z', + 'documentUnchanged': edited.text == source, + 'readingUnchanged': + (currentPosition!.pixels - offset).abs() < 0.1, + } + : { + 'inputAtOriginalTarget': edited.text == expectedInput, + 'reveal': + (currentPosition?.pixels ?? double.infinity) < offset - 100, + 'targetVisible': + targetTop != null && targetTop >= 0 && targetTop < 900, + }; + await File('${output.path}/result.json').writeAsString( + jsonEncode({'passive': passiveChecks, 'input': inputChecks}), + ); + await File( + '${output.path}/frames.json', + ).writeAsString(jsonEncode(frames)); + finished = true; + await signal('done'); + } catch (error, stack) { + finished = true; + await File('${output.path}/error.txt').writeAsString('$error\n$stack'); + await signal('done'); + } finally { + binding.removeObserver(this); + if (selectionRoute case final route?) { + binding.pointerRouter.removeGlobalRoute(route); + } + competingEntry?.remove(); + competingEntry?.dispose(); + if (competingEntry != null) { + binding.scheduleFrame(); + await binding.endOfFrame; + } + competingFocus?.dispose(); + competingController?.dispose(); + barrier?.dispose(); + if (deferred && File('${output.path}/result.json').existsSync()) { + await windowManager.close(); + } + } + } +} + +class _DelayedNativeBarrier extends LinuxHeaderBarService { + _DelayedNativeBarrier() + : super( + channel: const MethodChannel('com.busymark.test/deferred-native-focus'), + ); + final release = Completer(); + bool releaseStarted = false; + @override + Future setModalBarrierDepth(int depth) async { + await LinuxHeaderBarService.instance.setModalBarrierDepth(depth); + if (depth == 0) { + releaseStarted = true; + await release.future; + } + } +} diff --git a/tools/structural_editing_native.py b/tools/structural_editing_native.py new file mode 100644 index 00000000..b1da6811 --- /dev/null +++ b/tools/structural_editing_native.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +"""Structural-edit acceptance on an owned Xvfb desktop and private profile. + +Usage: /usr/bin/python3 tools/structural_editing_native.py BINARY NEW_OUTPUT [inline-breaks] +The --key/--click modes are called only by that binary for its own native window. +""" +import ctypes as C +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys + + +def input_event(kind, target, arguments): + if os.environ.get("BUSYMARK_STRUCTURAL_ACCEPTANCE") != "1" or int(target) != os.getppid(): + raise RuntimeError("Only the owning disposable application may inject input") + from application_close_smoke import Desktop + desktop = Desktop() + try: + desktop.Gdk.init([]) + window = desktop.window(int(target), require_normal=True) + if window is None: + raise RuntimeError("No mapped window belonging to the caller") + xid = window.get_xid() + x = desktop.x + x.XSetInputFocus.argtypes = [C.c_void_p, C.c_ulong, C.c_int, C.c_ulong] + x.XSetInputFocus(desktop.display, xid, 1, 0) + xtest = C.CDLL("libXtst.so.6") + if kind == "--key": + x.XStringToKeysym.argtypes = [C.c_char_p] + x.XStringToKeysym.restype = C.c_ulong + x.XKeysymToKeycode.argtypes = [C.c_void_p, C.c_ulong] + x.XKeysymToKeycode.restype = C.c_uint + xtest.XTestFakeKeyEvent.argtypes = [C.c_void_p, C.c_uint, C.c_int, C.c_ulong] + codes = [x.XKeysymToKeycode(desktop.display, x.XStringToKeysym(s.encode())) for s in arguments] + if not codes or not all(codes): + raise RuntimeError("Invalid native key chord") + for code, pressed in [(c, 1) for c in codes] + [(c, 0) for c in reversed(codes)]: + if desktop.window(int(target), require_normal=True).get_xid() != xid: + raise RuntimeError("Owned window changed during input") + if not xtest.XTestFakeKeyEvent(desktop.display, code, pressed, 0): + raise RuntimeError("XTest rejected keyboard input") + else: + px, py = map(float, arguments) + if not 0 < px < window.get_width() or not 0 < py < window.get_height(): + raise RuntimeError("Pointer target is outside the owned window") + _, ox, oy = window.get_origin() + xtest.XTestFakeMotionEvent.argtypes = [C.c_void_p, C.c_int, C.c_int, C.c_int, C.c_ulong] + xtest.XTestFakeButtonEvent.argtypes = [C.c_void_p, C.c_uint, C.c_int, C.c_ulong] + if not xtest.XTestFakeMotionEvent(desktop.display, 0, round(ox + px), round(oy + py), 0): + raise RuntimeError("XTest rejected pointer motion") + for pressed in [1, 0]: + if not xtest.XTestFakeButtonEvent(desktop.display, 1, pressed, 0): + raise RuntimeError("XTest rejected pointer input") + x.XFlush(desktop.display) + finally: + desktop.dispose() + + +def run(binary, output, scenario=None): + if scenario not in [None, "inline-breaks"]: + raise ValueError("Unsupported structural acceptance scenario") + output = Path(output).resolve() + output.mkdir(parents=True, exist_ok=False) + env = dict(os.environ) + for key, name in [("XDG_RUNTIME_DIR", "runtime"), ("XDG_CONFIG_HOME", "config"), + ("XDG_DATA_HOME", "data"), ("XDG_CACHE_HOME", "cache"), + ("XDG_STATE_HOME", "state")]: + directory = output / name + directory.mkdir(mode=0o700) + env[key] = str(directory) + for key in ["DISPLAY", "XAUTHORITY", "WAYLAND_DISPLAY", "GNOME_KEYRING_PID", + "GNOME_KEYRING_CONTROL", "SSH_AUTH_SOCK", "GPG_AGENT_INFO", "SESSION_MANAGER"]: + env.pop(key, None) + env.update(GDK_BACKEND="x11", BUSYMARK_STRUCTURAL_ACCEPTANCE="1") + env.pop("BUSYMARK_STRUCTURAL_SCENARIO", None) + if scenario: + env["BUSYMARK_STRUCTURAL_SCENARIO"] = scenario + fixtures = output / "fixtures" + shutil.copytree("test/fixtures/structural_editing", fixtures / "markdown") + for name in ["first.png", "second.png", "third.png"]: + shutil.copyfile("test/fixtures/writerside/basic_project/images/logo.png", fixtures / "markdown" / name) + shutil.copytree("test/fixtures/writerside/basic_project", fixtures / "writerside") + shutil.copyfile(fixtures / "markdown/heading-properties.md", fixtures / "writerside/topics/structure.md") + probe = output / "probe" + probe.mkdir() + # Isolation is established before creating the broker, so activated helpers + # receive the private profile too. Neither the host display nor bus is used. + command = ["/usr/bin/dbus-run-session", "--", "/usr/bin/xvfb-run", "-a", "-s", + "-screen 0 1440x1000x24", str(Path(binary).resolve()), str(fixtures), str(probe)] + with (output / "application.log").open("w") as log: + supervisor = subprocess.Popen( + ["/usr/bin/python3", "tools/run_owned_fixture.py", "--report", str(output / "ownership.json"), + "--duration", "240", "--", *command], env=env, stdin=subprocess.PIPE, + stdout=log, stderr=subprocess.STDOUT) + try: + exit_code = supervisor.wait(timeout=260) + finally: + if supervisor.poll() is None: + supervisor.stdin.write(b"stop\n") + supervisor.stdin.flush() + supervisor.wait(timeout=15) + supervisor.stdin.close() + result = json.loads((probe / "result.json").read_text()) + print(json.dumps({"backend": "X11/Xvfb", "exitCode": exit_code, **result})) + if exit_code or not result["passed"]: + raise RuntimeError("Native structural acceptance failed; inspect retained output") + + +if __name__ == "__main__": + if sys.argv[1:2] in [["--key"], ["--click"]]: + input_event(sys.argv[1], sys.argv[2], sys.argv[3:]) + else: + run(*sys.argv[1:]) diff --git a/tools/structural_editing_smoke.dart b/tools/structural_editing_smoke.dart new file mode 100644 index 00000000..8d41c560 --- /dev/null +++ b/tools/structural_editing_smoke.dart @@ -0,0 +1,684 @@ +// Native acceptance entry point: production app, editors, source publication, +// history and saving. Only disposable fixtures/profiles may be used. +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:busymark/main.dart' as application; +import 'package:busymark/src/app/app_settings.dart'; +import 'package:busymark/src/app/busymark_design.dart'; +import 'package:busymark/src/app/busymark_glyphs.dart'; +import 'package:busymark/src/app/keymap_controller.dart'; +import 'package:busymark/src/app/shortcut_binding.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_inline_controller.dart'; +import 'package:busymark/src/markdown/busymark_document.dart'; +import 'package:busymark/src/markdown/markdown_model.dart'; +import 'package:busymark/src/markdown/markdown_parser.dart'; +import 'package:busymark/src/workspace/workspace_controller.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:window_manager/window_manager.dart'; + +Future main(List arguments) async { + if (arguments.length != 2 || + Platform.environment['BUSYMARK_STRUCTURAL_ACCEPTANCE'] != '1') { + throw ArgumentError( + 'Use structural_editing_native.py with a disposable desktop', + ); + } + final binding = _Binding(); + final fixtures = Directory(arguments[0]); + final fixture = + Platform.environment['BUSYMARK_STRUCTURAL_SCENARIO'] == 'inline-breaks' + ? 'inline-existing-break.md' + : 'header_test_text.md'; + await application.main(['${fixtures.path}/markdown/$fixture']); + unawaited(_Driver(binding, fixtures, Directory(arguments[1])).run()); +} + +class _Binding extends WidgetsFlutterBinding { + final root = GlobalKey(); + @override + Widget wrapWithDefaultView(Widget rootWidget) => + super.wrapWithDefaultView(RepaintBoundary(key: root, child: rootWidget)); +} + +class _Driver { + _Driver(this.binding, this.fixtures, this.output); + final _Binding binding; + final Directory fixtures; + final Directory output; + final checks = []; + late ProviderContainer container; + WorkspaceController get workspace => + container.read(workspaceControllerProvider.notifier); + String get source => container.read(workspaceControllerProvider).activeText; + + List elements(bool Function(Widget) predicate) { + final found = []; + void visit(Element element) { + if (predicate(element.widget)) found.add(element); + element.visitChildren(visit); + } + + final root = binding.root.currentContext; + if (root != null) visit(root as Element); + return found; + } + + Future waitFor(bool Function() condition, String label) async { + final deadline = DateTime.now().add(const Duration(seconds: 30)); + while (!condition()) { + if (DateTime.now().isAfter(deadline)) { + throw StateError('Timed out: $label'); + } + await Future.delayed(const Duration(milliseconds: 40)); + } + } + + Future settle() async { + await Future.delayed(const Duration(milliseconds: 250)); + binding.scheduleFrame(); + await binding.endOfFrame; + } + + void check(bool value, String label) { + if (!value) throw StateError(label); + checks.add(label); + stdout.writeln('structural acceptance: $label'); + } + + Future native(List chord) async { + final result = await Process.run('/usr/bin/python3', [ + 'tools/structural_editing_native.py', + '--key', + '$pid', + ...chord, + ]); + if (result.exitCode != 0) { + throw StateError('Native input: ${result.stderr}'); + } + await settle(); + } + + TextField field(String text) => elements( + (w) => w is TextField && w.controller?.text == text, + ).map((e) => e.widget as TextField).single; + BusyBlock block(String id) => elements( + (w) => w is BusyMarkWysiwygBlockField && w.block.id == id, + ).map((e) => (e.widget as BusyMarkWysiwygBlockField).block).single; + BusyBlock namedBlock(String text) => elements( + (w) => w is BusyMarkWysiwygBlockField && w.block.plainText == text, + ).map((e) => (e.widget as BusyMarkWysiwygBlockField).block).single; + + Future focus( + String text, { + int offset = 0, + bool selectAll = false, + }) async { + final target = field(text); + target.focusNode!.requestFocus(); + target.controller!.selection = selectAll + ? TextSelection(baseOffset: 0, extentOffset: text.length) + : TextSelection.collapsed(offset: offset); + await settle(); + return target; + } + + Future open(String path) async { + if (!await workspace.openActiveFile(path)) { + throw StateError('Could not open fixture within its workspace: $path'); + } + workspace.updateActiveEditorMode(DocumentViewModePreference.editor); + await waitFor( + () => + elements( + (w) => w is BusyMarkWysiwygEditor && w.document.filePath == path, + ).isNotEmpty && + container.read(workspaceControllerProvider).activeBuffer?.filePath == + path, + 'open $path', + ); + await settle(); + } + + Future capture(String name) async { + final boundary = + binding.root.currentContext!.findRenderObject()! + as RenderRepaintBoundary; + final image = await boundary.toImage(); + final bytes = (await image.toByteData(format: ui.ImageByteFormat.png))!; + await File( + '${output.path}/$name.png', + ).writeAsBytes(bytes.buffer.asUint8List()); + image.dispose(); + } + + Future saveAndReopen(String name) async { + final saved = source; + final file = File( + container.read(workspaceControllerProvider).activeBuffer!.filePath!, + ); + await native(['Control_L', 's']); + await waitFor( + () => + file.readAsStringSync() == saved && + !container.read(workspaceControllerProvider).activeBuffer!.isDirty, + '$name native save', + ); + await File( + '${output.path}/$name-saved${file.path.endsWith('.topic') ? '.topic' : '.md'}', + ).writeAsString(file.readAsStringSync()); + final deadline = DateTime.now().add(const Duration(seconds: 30)); + final closeObservations = >[]; + while (true) { + final captured = container + .read(workspaceControllerProvider) + .activeBuffer!; + if (captured.text != saved || + captured.isDirty || + captured.filePath != file.path) { + throw StateError('$name saved buffer changed before close'); + } + if (await workspace.closeActiveOpenFileTab()) break; + final current = container.read(workspaceControllerProvider).activeBuffer; + closeObservations.add({ + 'sameBufferSnapshot': identical(captured, current), + 'sameId': current?.id == captured.id, + 'sameRevision': current?.revision == captured.revision, + 'sameBytes': current?.text == saved, + 'dirty': current?.isDirty, + }); + // A session report can replace the captured immutable buffer during the + // real History flush. Respect that rejection; retry only unchanged saved + // content after another actual frame, never a changed target or edit. + if (current?.id != captured.id || + current?.text != saved || + current?.isDirty != false || + identical(captured, current) || + DateTime.now().isAfter(deadline)) { + await File( + '${output.path}/$name-close.json', + ).writeAsString(jsonEncode(closeObservations)); + throw StateError( + '$name close rejected without an unchanged replacement snapshot', + ); + } + await settle(); + } + await File( + '${output.path}/$name-close.json', + ).writeAsString(jsonEncode(closeObservations)); + check(true, '$name close saved buffer'); + await open(file.path); + check(source == saved, '$name reopened bytes'); + } + + Future heading(String path, String name) async { + await open(path); + final original = source; + final target = await focus('About the job'); + final before = namedBlock('About the job'); + if (name == 'writerside') { + check( + before.attributes['collapsible'] == 'true' && + before.attributes['default-state'] == 'expanded', + 'Writerside authored fixture mode', + ); + } + final undo = container + .read(workspaceControllerProvider) + .activeBuffer! + .editorState + .undoState + .undo + .length; + await native(['Return']); + final kept = namedBlock('About the job'); + check( + kept.id == before.id && + kept.kind == BusyBlockKind.heading && + kept.attributes['level'] == '2', + '$name heading identity and level', + ); + for (final key in ['id', 'generatedId', 'collapsible', 'default-state']) { + check( + kept.attributes[key] == before.attributes[key], + '$name heading $key', + ); + } + check( + field('About the job').focusNode == target.focusNode && + target.focusNode!.hasFocus && + target.controller!.selection.extentOffset == 0, + '$name caret and focus', + ); + check( + container + .read(workspaceControllerProvider) + .activeBuffer! + .editorState + .undoState + .undo + .length == + undo + 1, + '$name one undo operation', + ); + check( + elements( + (w) => + w is BusyMarkWysiwygBlockField && + w.block.kind == BusyBlockKind.paragraph && + w.block.plainText.isEmpty && + !w.block.attributes.containsKey('collapsible'), + ).isNotEmpty, + '$name ordinary preceding paragraph', + ); + await capture('$name-enter'); + await native(['BackSpace']); + check(source == original, '$name immediate Backspace inverse source'); + check( + namedBlock('About the job').id == before.id, + '$name immediate Backspace inverse identity', + ); + await native(['Return']); + final entered = source; + await native(['Control_L', 'z']); + check(source == original, '$name undo'); + await native(['Control_L', 'Shift_L', 'z']); + check( + source == entered && + namedBlock('About the job').kind == BusyBlockKind.heading, + '$name redo source and heading', + ); + check( + elements( + (w) => + w is BusyMarkWysiwygBlockField && + w.block.kind == BusyBlockKind.paragraph && + w.block.plainText.isEmpty, + ).isEmpty, + '$name redo respects transient blank-paragraph lifecycle', + ); + await native(['Control_L', 'z']); + await native(['Return']); + await native(['z']); + check( + source.contains('## zAbout the job'), + '$name native typing in original heading', + ); + final saved = source; + for (final mode in [ + DocumentViewModePreference.source, + DocumentViewModePreference.preview, + DocumentViewModePreference.split, + DocumentViewModePreference.editor, + ]) { + workspace.updateActiveEditorMode(mode); + await settle(); + check(source == saved, '$name ${mode.name} source'); + } + await saveAndReopen(name); + final reopened = namedBlock('zAbout the job'); + check( + reopened.kind == BusyBlockKind.heading && + reopened.attributes['level'] == '2' && + reopened.attributes['collapsible'] == + before.attributes['collapsible'], + '$name reopened heading properties', + ); + } + + Future lists() async { + var sequence = 0; + for (final label in ['Parent', 'Ordered parent', 'Task parent']) { + for (final clear in [false, true]) { + final name = 'list-${sequence++}'; + final file = await File( + '${fixtures.path}/markdown/lists.md', + ).copy('${fixtures.path}/markdown/$name.md'); + await open(file.path); + final before = namedBlock(label); + final target = await focus(label, selectAll: clear); + if (clear) { + await native(['BackSpace']); + check( + block(before.id).plainText.isEmpty, + '$name cleared only parent caption', + ); + } + await native(['Return']); + final parent = block(before.id); + check( + parent.kind == before.kind && + _descendants(parent).map((b) => b.id).join(',') == + _descendants(before).map((b) => b.id).join(','), + '$name parent/subtree identity', + ); + check( + target.focusNode!.hasFocus && + target.controller!.selection.extentOffset == 0, + '$name focus retained', + ); + await native(['z']); + check( + block(before.id).plainText == 'z${clear ? '' : label}', + '$name original parent receives input', + ); + await saveAndReopen(name); + final reparsed = const MarkdownParser() + .parse(filePath: file.path, source: source) + .busyDocument; + final reopenedParent = _walk( + reparsed.blocks, + ).singleWhere((b) => b.plainText == 'z${clear ? '' : label}'); + check( + _descendants(reopenedParent).map((b) => b.plainText).join('\u0000') == + _descendants(before).map((b) => b.plainText).join('\u0000'), + '$name reopened subtree stays with parent in order', + ); + if (label == 'Task parent') { + check( + _walk( + reparsed.blocks, + ).where((b) => b.kind == BusyBlockKind.taskListItem).length == + 4, + '$name empty task keeps checkbox semantics', + ); + } + for (final child in [ + 'Child', + 'Grandchild', + 'Ordered child', + 'Ordered grandchild', + 'Task child', + 'Task grandchild', + ]) { + check( + _walk(reparsed.blocks).where((b) => b.plainText == child).length == + 1, + '$name $child exactly once after reopening', + ); + } + await capture(name); + } + } + } + + Future images() async { + final file = '${fixtures.path}/markdown/inline-objects.md'; + await open(file); + final before = source; + final expected = _objects(namedBlock('abc')); + await focus('abc', offset: 1); + await native(['Shift_L', 'Return']); + check( + _objects(namedBlock('a\nbc')) == expected, + 'image Shift-Enter preserves objects and formatting', + ); + check( + field('a\nbc').focusNode!.hasFocus && + field('a\nbc').controller!.selection.extentOffset == 2, + 'image hard-break caret and focus', + ); + await native(['Control_L', 'z']); + check(source == before, 'image hard-break undo'); + final current = container + .read(appSettingsControllerProvider) + .shortcutOverrides; + final configured = await container + .read(keymapControllerProvider.notifier) + .update( + 'editor.hardLineBreak', + const BusyMarkKeyBinding(LogicalKeyboardKey.f8), + expected: current, + ); + check( + configured.status == KeymapUpdateStatus.saved, + 'configured hard-break shortcut', + ); + await focus('abc', offset: 1); + await native(['F8']); + check( + _objects(namedBlock('a\nbc')) == expected, + 'configured hard-break preserves objects', + ); + await native(['Control_L', 'z']); + await focus('abc', offset: 1); + final button = elements( + (w) => + w is BusyMarkHeaderIconButton && w.icon == BusyMarkGlyphs.blankLine, + ).single; + final render = button.findRenderObject()! as RenderBox; + final point = render.localToGlobal(render.size.center(Offset.zero)); + final click = await Process.run('/usr/bin/python3', [ + 'tools/structural_editing_native.py', + '--click', + '$pid', + '${point.dx}', + '${point.dy}', + ]); + if (click.exitCode != 0) throw StateError('${click.stderr}'); + await settle(); + check( + _objects(namedBlock('a\n\nbc')) == expected, + 'native blank-line toolbar preserves objects', + ); + check( + field('a\n\nbc').focusNode!.hasFocus && + field('a\n\nbc').controller!.selection.extentOffset == 3, + 'image blank-line caret and focus', + ); + await saveAndReopen('images'); + check( + _objects(namedBlock('a\n\nbc')) == expected, + 'saved/reopened objects retain order, destinations and formatting', + ); + await capture('images'); + } + + Future inlineBreaks() async { + for (final writerside in [false, true]) { + final directory = writerside + ? '${fixtures.path}/writerside/topics' + : '${fixtures.path}/markdown'; + await workspace.openPath( + writerside ? '${fixtures.path}/writerside' : directory, + ); + for (final fixture in [ + 'inline-existing-break', + 'inline-consecutive-breaks', + ]) { + for (final count in [1, 2]) { + final name = + '${writerside ? 'writerside' : 'markdown'}-$fixture-$count'; + final original = await File( + '${fixtures.path}/markdown/$fixture.md', + ).readAsString(); + final file = File('$directory/$name.md'); + await file.writeAsString(original); + await open(file.path); + final mode = writerside + ? MarkdownMode.writersideMarkdown + : MarkdownMode.commonMark; + final before = const MarkdownParser() + .parse(filePath: file.path, source: original, mode: mode) + .busyDocument + .blocks + .single; + check( + (elements((w) => w is BusyMarkWysiwygEditor).single.widget + as BusyMarkWysiwygEditor) + .document + .mode == + mode, + '$name production document mode', + ); + final expectedText = before.plainText.replaceRange( + 1, + 1, + '\n' * count, + ); + final expected = jsonEncode([ + for (final anchor in busyInlineSemanticAnchors(before.inlines)) + [ + anchor.offset <= 1 ? anchor.offset : anchor.offset + count, + _inline(anchor.inline), + ], + ]); + final target = await focus(before.plainText, offset: 1); + final undo = container + .read(workspaceControllerProvider) + .activeBuffer! + .editorState + .undoState + .undo + .length; + if (count == 1) { + await native(['Shift_L', 'Return']); + } else { + final button = elements( + (w) => + w is BusyMarkHeaderIconButton && + w.icon == BusyMarkGlyphs.blankLine, + ).single; + final render = button.findRenderObject()! as RenderBox; + final point = render.localToGlobal(render.size.center(Offset.zero)); + final click = await Process.run('/usr/bin/python3', [ + 'tools/structural_editing_native.py', + '--click', + '$pid', + '${point.dx}', + '${point.dy}', + ]); + if (click.exitCode != 0) throw StateError('${click.stderr}'); + await settle(); + } + check( + _positionedObjects(namedBlock(expectedText)) == expected, + '$name exact live object positions, attributes and formatting', + ); + check( + target.focusNode!.hasFocus && + target.controller!.selection.extentOffset == 1 + count, + '$name caret and focus', + ); + check( + container + .read(workspaceControllerProvider) + .activeBuffer! + .editorState + .undoState + .undo + .length == + undo + 1, + '$name one undo operation', + ); + final edited = source; + await native(['Control_L', 'z']); + check(source == original, '$name undo bytes'); + check( + _positionedObjects(namedBlock(before.plainText)) == + _positionedObjects(before), + '$name undo object positions and formatting', + ); + await native(['Control_L', 'Shift_L', 'z']); + check(source == edited, '$name redo bytes'); + check( + _positionedObjects(namedBlock(expectedText)) == expected, + '$name redo object positions and formatting', + ); + await saveAndReopen(name); + check( + _positionedObjects(namedBlock(expectedText)) == expected, + '$name saved/reopened object positions and formatting', + ); + await capture(name); + } + } + } + } + + Future run() async { + var passed = false; + String? error; + try { + await waitFor( + () => elements((w) => w is BusyMarkWysiwygEditor).isNotEmpty, + 'production app startup', + ); + container = ProviderScope.containerOf( + elements((w) => w is BusyMarkWysiwygEditor).single, + ); + final settings = container.read(appSettingsControllerProvider.notifier); + await settings.setAutoSave(false); + await settings.setAutomaticSpelling(false); + await settings.setDocumentViewMode(DocumentViewModePreference.editor); + if (Platform.environment['BUSYMARK_STRUCTURAL_SCENARIO'] == + 'inline-breaks') { + await inlineBreaks(); + } else { + await heading( + '${fixtures.path}/markdown/header_test_text.md', + 'markdown', + ); + // openPath selects a workspace; openActiveFile opens a topic within it. + // Keep the project context that determines Writerside Markdown mode. + await workspace.openPath('${fixtures.path}/writerside'); + await heading( + '${fixtures.path}/writerside/topics/structure.md', + 'writerside', + ); + await workspace.openPath('${fixtures.path}/markdown'); + await lists(); + await images(); + } + passed = true; + } catch (exception, stack) { + error = '$exception\n$stack'; + } + await File('${output.path}/result.json').writeAsString( + const JsonEncoder.withIndent( + ' ', + ).convert({'passed': passed, 'checks': checks, 'error': error}), + ); + // Exit through the production close listener with all test edits saved. + if (!passed) { + stderr.writeln(error); + if (checks.isNotEmpty) { + await File('${output.path}/failure-source.md').writeAsString(source); + } + exit(1); + } + await windowManager.close(); + } +} + +Iterable _walk(Iterable blocks) sync* { + for (final block in blocks) { + yield block; + yield* _walk(block.children); + } +} + +Iterable _descendants(BusyBlock block) => _walk(block.children); +String _positionedObjects(BusyBlock block) => jsonEncode([ + for (final anchor in busyInlineSemanticAnchors(block.inlines)) + [anchor.offset, _inline(anchor.inline)], +]); +String _objects(BusyBlock block) => jsonEncode([ + for (final anchor in busyInlineSemanticAnchors(block.inlines)) + _inline(anchor.inline), +]); +Object _inline(BusyInline inline) => [ + inline.kind.name, + inline.text, + inline.destination, + inline.attributes, + [for (final child in inline.children) _inline(child)], +];