From c88360c7f0636dccdd03d717ec10269bc7678f93 Mon Sep 17 00:00:00 2001 From: Yonathan Arbel Date: Wed, 11 Nov 2026 12:31:21 -0600 Subject: [PATCH 1/3] Improve legal reading and refine Review reveal --- .github/workflows/desktop-ci.yml | 34 + .github/workflows/prepare-0.2.33.yml | 48 - .github/workflows/prepare-0.2.36.yml | 48 - .github/workflows/prepare-0.2.37.yml | 48 - .github/workflows/prepare-0.2.44.yml | 48 - .github/workflows/prepare-0.2.45.yml | 49 - .github/workflows/prepare-release.yml | 39 + .github/workflows/publish-0.2.32.yml | 33 - .github/workflows/publish-0.2.33.yml | 40 - .github/workflows/publish-0.2.36.yml | 40 - .github/workflows/publish-0.2.37.yml | 40 - .github/workflows/publish-0.2.45.yml | 40 - .github/workflows/publish-0.2.48.yml | 40 - .github/workflows/publish-release.yml | 59 + Cargo.lock | 2 +- Cargo.toml | 11 +- docs/OPEN_PIPELINE_BENCHMARK.md | 37 + docs/PRIVACY.md | 2 + docs/PRODUCTION_PLAN.md | 4 +- docs/RELEASING.md | 8 + docs/releases/v0.2.89.md | 23 + release-manifest.json | 2 +- src/app/annotation_digest.rs | 860 +++++ src/app/annotation_list.rs | 728 ++++ src/app/annotation_saving.rs | 218 +- src/app/chat_ui.rs | 26 +- src/app/chrome.rs | 829 +++- src/app/citation_copy.rs | 1055 +++++ src/app/close_ui.rs | 130 +- src/app/comments_ui.rs | 7 +- src/app/document_opening.rs | 309 +- src/app/file_drop.rs | 75 + src/app/find_paint.rs | 44 +- src/app/footnote_peek.rs | 971 +++++ src/app/footnote_sources_ui.rs | 2 +- src/app/foreign_annotations.rs | 193 + src/app/frame_bench.rs | 145 + src/app/jump_history.rs | 530 +++ src/app/mod.rs | 4258 +++++++++++++++------ src/app/motion.rs | 371 ++ src/app/print_ui.rs | 8 +- src/app/print_windows_ui.rs | 54 +- src/app/reading_history.rs | 673 ++++ src/app/reading_navigation.rs | 273 ++ src/app/recovery_ui.rs | 4 +- src/app/review_intro.rs | 99 + src/app/review_layout.rs | 584 +++ src/app/review_margin_ui.rs | 342 +- src/app/review_paragraph.rs | 515 +++ src/app/review_references.rs | 1083 ++++++ src/app/review_reveal.rs | 178 +- src/app/save_as.rs | 56 +- src/app/search_state/interaction_tests.rs | 36 + src/app/selection_state.rs | 573 +++ src/app/settings_ui.rs | 76 +- src/app/speech_ui.rs | 178 +- src/app/theme.rs | 364 ++ src/app/thumbnail_layout.rs | 521 +++ src/app/tts_controller.rs | 10 + src/benchmark.rs | 160 + src/diagnostics.rs | 45 +- src/document_store.rs | 125 +- src/liquid/mod.rs | 8 +- src/liquid2.rs | 10 +- src/main.rs | 32 +- src/model.rs | 47 + src/native_process.rs | 435 ++- src/page_text_codec.rs | 226 ++ src/pdf_backend.rs | 262 +- src/pdf_backend/foreign_annotations.rs | 293 ++ src/pdf_backend/navigation.rs | 617 +++ src/performance_cache.rs | 243 +- src/render_worker.rs | 1013 +++-- src/review_masthead.rs | 113 + src/settings.rs | 624 ++- src/tts.rs | 204 +- tools/prepare_release.py | 89 + tools/test_prepare_release.py | 145 + 78 files changed, 19244 insertions(+), 2520 deletions(-) delete mode 100644 .github/workflows/prepare-0.2.33.yml delete mode 100644 .github/workflows/prepare-0.2.36.yml delete mode 100644 .github/workflows/prepare-0.2.37.yml delete mode 100644 .github/workflows/prepare-0.2.44.yml delete mode 100644 .github/workflows/prepare-0.2.45.yml create mode 100644 .github/workflows/prepare-release.yml delete mode 100644 .github/workflows/publish-0.2.32.yml delete mode 100644 .github/workflows/publish-0.2.33.yml delete mode 100644 .github/workflows/publish-0.2.36.yml delete mode 100644 .github/workflows/publish-0.2.37.yml delete mode 100644 .github/workflows/publish-0.2.45.yml delete mode 100644 .github/workflows/publish-0.2.48.yml create mode 100644 .github/workflows/publish-release.yml create mode 100644 docs/OPEN_PIPELINE_BENCHMARK.md create mode 100644 docs/releases/v0.2.89.md create mode 100644 src/app/annotation_digest.rs create mode 100644 src/app/annotation_list.rs create mode 100644 src/app/citation_copy.rs create mode 100644 src/app/file_drop.rs create mode 100644 src/app/footnote_peek.rs create mode 100644 src/app/foreign_annotations.rs create mode 100644 src/app/frame_bench.rs create mode 100644 src/app/jump_history.rs create mode 100644 src/app/motion.rs create mode 100644 src/app/reading_history.rs create mode 100644 src/app/reading_navigation.rs create mode 100644 src/app/review_intro.rs create mode 100644 src/app/review_layout.rs create mode 100644 src/app/review_paragraph.rs create mode 100644 src/app/review_references.rs create mode 100644 src/app/theme.rs create mode 100644 src/app/thumbnail_layout.rs create mode 100644 src/page_text_codec.rs create mode 100644 src/pdf_backend/foreign_annotations.rs create mode 100644 src/pdf_backend/navigation.rs create mode 100644 tools/prepare_release.py create mode 100644 tools/test_prepare_release.py diff --git a/.github/workflows/desktop-ci.yml b/.github/workflows/desktop-ci.yml index 153bf72..ea99923 100644 --- a/.github/workflows/desktop-ci.yml +++ b/.github/workflows/desktop-ci.yml @@ -44,10 +44,44 @@ jobs: if: runner.os == 'Windows' shell: pwsh run: .\scripts\fetch-catboost-windows.ps1 + - name: Check formatting (advisory) + if: runner.os == 'Linux' + continue-on-error: true + run: cargo fmt --all -- --check + - name: Run Clippy (advisory) + if: runner.os == 'Linux' + continue-on-error: true + run: cargo clippy --locked --all-targets - name: Test production feature set run: cargo test --locked - name: Test development feature set run: cargo test --locked --features devtools + - name: Exercise isolated PDF workers + if: runner.os == 'Linux' + env: + LAWPDF_SMOKE_PDF: ${{ github.workspace }}/tests/fixtures/ocr/scanned-law-review-sample.pdf + XDG_DATA_HOME: ${{ runner.temp }}/lawpdf-worker-smoke + LAWPDF_DISABLE_PERFORMANCE_CACHE: "1" + run: | + cargo build --locked --features devtools + "$CARGO_TARGET_DIR/debug/lawpdf" --smoke-pdf-isolation + - name: Measure opening pipeline on a fixture + if: runner.os == 'Linux' + env: + LAWPDF_BENCH_DATA_DIR: ${{ runner.temp }}/lawpdf-open-benchmark-data + run: | + "$CARGO_TARGET_DIR/debug/lawpdf" --bench-open-pipeline \ + "$GITHUB_WORKSPACE/tests/fixtures/ocr/scanned-law-review-sample.pdf" \ + > "$RUNNER_TEMP/lawpdf-open-benchmark.json" + cat "$RUNNER_TEMP/lawpdf-open-benchmark.json" + - name: Enforce synthetic Review frame median budgets + if: runner.os == 'Linux' + env: + LAWPDF_ENFORCE_REVIEW_BUDGETS: "1" + run: cargo test --release --locked review_frame_cost -- --ignored --nocapture + - name: Test release preparation without network + if: runner.os == 'Linux' + run: python3 -B -m unittest discover -s tools -p test_prepare_release.py - name: Verify public runtime assets run: python tools/verify_release_manifest.py --require-platform-library - name: Verify Markdown exports diff --git a/.github/workflows/prepare-0.2.33.yml b/.github/workflows/prepare-0.2.33.yml deleted file mode 100644 index 6631621..0000000 --- a/.github/workflows/prepare-0.2.33.yml +++ /dev/null @@ -1,48 +0,0 @@ -name: Prepare authorized LawPDF 0.2.33 - -on: - push: - branches: [main] - paths: ['.github/workflows/prepare-0.2.33.yml'] - workflow_dispatch: - -permissions: - contents: read - -concurrency: - group: prepare-lawpdf-0.2.33 - cancel-in-progress: false - -jobs: - prepare: - if: github.repository == 'yonathanarbel/LawPDF' && github.ref == 'refs/heads/main' - runs-on: ubuntu-latest - timeout-minutes: 5 - permissions: - contents: write - actions: write - steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 - with: - persist-credentials: false - - name: Tag the approved source and request the release verification build - env: - GH_TOKEN: ${{ github.token }} - GH_REPO: yonathanarbel/LawPDF - run: | - python3 - <<'PY' - import json, os, subprocess, tomllib - from pathlib import Path - repo = os.environ['GH_REPO'] - sha = os.environ['GITHUB_SHA'] - tag = 'v0.2.33' - assert tomllib.loads(Path('Cargo.toml').read_text())['package']['version'] == tag[1:] - assert json.loads(Path('release-manifest.json').read_text())['product']['version'] == tag[1:] - result = subprocess.run(['gh', 'api', f'repos/{repo}/git/ref/tags/{tag}'], capture_output=True, text=True) - if result.returncode == 0: - assert json.loads(result.stdout)['object']['sha'] == sha, 'Existing tag points to different source' - else: - subprocess.run(['gh', 'api', '--method', 'POST', f'repos/{repo}/git/refs', '--input', '-'], - input=json.dumps({'ref': f'refs/tags/{tag}', 'sha': sha}), text=True, check=True) - subprocess.run(['gh', 'workflow', 'run', 'release.yml', '--repo', repo, '--ref', tag], check=True) - PY diff --git a/.github/workflows/prepare-0.2.36.yml b/.github/workflows/prepare-0.2.36.yml deleted file mode 100644 index 5fbf752..0000000 --- a/.github/workflows/prepare-0.2.36.yml +++ /dev/null @@ -1,48 +0,0 @@ -name: Prepare authorized LawPDF 0.2.36 - -on: - push: - branches: [main] - paths: ['.github/workflows/prepare-0.2.36.yml'] - workflow_dispatch: - -permissions: - contents: read - -concurrency: - group: prepare-lawpdf-0.2.36 - cancel-in-progress: false - -jobs: - prepare: - if: github.repository == 'yonathanarbel/LawPDF' && github.ref == 'refs/heads/main' - runs-on: ubuntu-latest - timeout-minutes: 5 - permissions: - contents: write - actions: write - steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 - with: - persist-credentials: false - - name: Tag the approved source and request the release verification build - env: - GH_TOKEN: ${{ github.token }} - GH_REPO: yonathanarbel/LawPDF - run: | - python3 - <<'PY' - import json, os, subprocess, tomllib - from pathlib import Path - repo = os.environ['GH_REPO'] - sha = os.environ['GITHUB_SHA'] - tag = 'v0.2.36' - assert tomllib.loads(Path('Cargo.toml').read_text())['package']['version'] == tag[1:] - assert json.loads(Path('release-manifest.json').read_text())['product']['version'] == tag[1:] - result = subprocess.run(['gh', 'api', f'repos/{repo}/git/ref/tags/{tag}'], capture_output=True, text=True) - if result.returncode == 0: - assert json.loads(result.stdout)['object']['sha'] == sha, 'Existing tag points to different source' - else: - subprocess.run(['gh', 'api', '--method', 'POST', f'repos/{repo}/git/refs', '--input', '-'], - input=json.dumps({'ref': f'refs/tags/{tag}', 'sha': sha}), text=True, check=True) - subprocess.run(['gh', 'workflow', 'run', 'release.yml', '--repo', repo, '--ref', tag], check=True) - PY diff --git a/.github/workflows/prepare-0.2.37.yml b/.github/workflows/prepare-0.2.37.yml deleted file mode 100644 index 5c167c9..0000000 --- a/.github/workflows/prepare-0.2.37.yml +++ /dev/null @@ -1,48 +0,0 @@ -name: Prepare authorized LawPDF 0.2.37 - -on: - push: - branches: [main] - paths: ['.github/workflows/prepare-0.2.37.yml'] - workflow_dispatch: - -permissions: - contents: read - -concurrency: - group: prepare-lawpdf-0.2.37 - cancel-in-progress: false - -jobs: - prepare: - if: github.repository == 'yonathanarbel/LawPDF' && github.ref == 'refs/heads/main' - runs-on: ubuntu-latest - timeout-minutes: 5 - permissions: - contents: write - actions: write - steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 - with: - persist-credentials: false - - name: Tag the approved source and request the release verification build - env: - GH_TOKEN: ${{ github.token }} - GH_REPO: yonathanarbel/LawPDF - run: | - python3 - <<'PY' - import json, os, subprocess, tomllib - from pathlib import Path - repo = os.environ['GH_REPO'] - sha = os.environ['GITHUB_SHA'] - tag = 'v0.2.37' - assert tomllib.loads(Path('Cargo.toml').read_text())['package']['version'] == tag[1:] - assert json.loads(Path('release-manifest.json').read_text())['product']['version'] == tag[1:] - result = subprocess.run(['gh', 'api', f'repos/{repo}/git/ref/tags/{tag}'], capture_output=True, text=True) - if result.returncode == 0: - assert json.loads(result.stdout)['object']['sha'] == sha, 'Existing tag points to different source' - else: - subprocess.run(['gh', 'api', '--method', 'POST', f'repos/{repo}/git/refs', '--input', '-'], - input=json.dumps({'ref': f'refs/tags/{tag}', 'sha': sha}), text=True, check=True) - subprocess.run(['gh', 'workflow', 'run', 'release.yml', '--repo', repo, '--ref', tag], check=True) - PY diff --git a/.github/workflows/prepare-0.2.44.yml b/.github/workflows/prepare-0.2.44.yml deleted file mode 100644 index 1d30b23..0000000 --- a/.github/workflows/prepare-0.2.44.yml +++ /dev/null @@ -1,48 +0,0 @@ -name: Prepare authorized LawPDF 0.2.44 - -on: - push: - branches: [main] - paths: ['.github/workflows/prepare-0.2.44.yml'] - workflow_dispatch: - -permissions: - contents: read - -concurrency: - group: prepare-lawpdf-0.2.44 - cancel-in-progress: false - -jobs: - prepare: - if: github.repository == 'yonathanarbel/LawPDF' && github.ref == 'refs/heads/main' - runs-on: ubuntu-latest - timeout-minutes: 5 - permissions: - contents: write - actions: write - steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 - with: - persist-credentials: false - - name: Tag the approved source and request the release verification build - env: - GH_TOKEN: ${{ github.token }} - GH_REPO: yonathanarbel/LawPDF - run: | - python3 - <<'PY' - import json, os, subprocess, tomllib - from pathlib import Path - repo = os.environ['GH_REPO'] - sha = os.environ['GITHUB_SHA'] - tag = 'v0.2.44' - assert tomllib.loads(Path('Cargo.toml').read_text())['package']['version'] == tag[1:] - assert json.loads(Path('release-manifest.json').read_text())['product']['version'] == tag[1:] - result = subprocess.run(['gh', 'api', f'repos/{repo}/git/ref/tags/{tag}'], capture_output=True, text=True) - if result.returncode == 0: - assert json.loads(result.stdout)['object']['sha'] == sha, 'Existing tag points to different source' - else: - subprocess.run(['gh', 'api', '--method', 'POST', f'repos/{repo}/git/refs', '--input', '-'], - input=json.dumps({'ref': f'refs/tags/{tag}', 'sha': sha}), text=True, check=True) - subprocess.run(['gh', 'workflow', 'run', 'release.yml', '--repo', repo, '--ref', tag], check=True) - PY diff --git a/.github/workflows/prepare-0.2.45.yml b/.github/workflows/prepare-0.2.45.yml deleted file mode 100644 index f39f9f9..0000000 --- a/.github/workflows/prepare-0.2.45.yml +++ /dev/null @@ -1,49 +0,0 @@ -name: Prepare authorized LawPDF 0.2.45 - -on: - push: - branches: [main] - paths: ['.github/workflows/prepare-0.2.45.yml'] - workflow_dispatch: - -permissions: - contents: read - -concurrency: - group: prepare-lawpdf-0.2.45 - cancel-in-progress: false - -jobs: - prepare: - if: github.repository == 'yonathanarbel/LawPDF' && github.ref == 'refs/heads/main' - runs-on: ubuntu-latest - timeout-minutes: 5 - permissions: - contents: write - actions: write - steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 - with: - persist-credentials: false - - name: Tag the approved source and request the release verification build - env: - GH_TOKEN: ${{ github.token }} - GH_REPO: yonathanarbel/LawPDF - run: | - python3 - <<'PY' - import json, os, subprocess, tomllib - from pathlib import Path - repo = os.environ['GH_REPO'] - sha = os.environ['GITHUB_SHA'] - tag = 'v0.2.45' - assert tomllib.loads(Path('Cargo.toml').read_text())['package']['version'] == tag[1:] - assert json.loads(Path('release-manifest.json').read_text())['product']['version'] == tag[1:] - result = subprocess.run(['gh', 'api', f'repos/{repo}/git/ref/tags/{tag}'], capture_output=True, text=True) - if result.returncode == 0: - assert json.loads(result.stdout)['object']['sha'] == sha, 'Existing tag points to different source' - else: - subprocess.run(['gh', 'api', '--method', 'POST', f'repos/{repo}/git/refs', '--input', '-'], - input=json.dumps({'ref': f'refs/tags/{tag}', 'sha': sha}), text=True, check=True) - subprocess.run(['gh', 'workflow', 'run', 'release.yml', '--repo', repo, '--ref', tag], check=True) - subprocess.run(['gh', 'workflow', 'run', 'microsoft-store.yml', '--repo', repo, '--ref', tag], check=True) - PY diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml new file mode 100644 index 0000000..9043640 --- /dev/null +++ b/.github/workflows/prepare-release.yml @@ -0,0 +1,39 @@ +name: Prepare reviewed LawPDF release + +on: + workflow_dispatch: + inputs: + version: + description: Version matching Cargo.toml and release-manifest.json (without v) + required: true + type: string + expected_commit: + description: Full reviewed main-branch commit SHA + required: true + type: string + +permissions: + contents: read + +concurrency: + group: prepare-lawpdf-${{ inputs.version }} + cancel-in-progress: false + +jobs: + prepare: + if: github.repository == 'yonathanarbel/LawPDF' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: write + actions: write + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + persist-credentials: false + - name: Tag the reviewed commit and request verification builds + env: + GH_TOKEN: ${{ github.token }} + RELEASE_VERSION: ${{ inputs.version }} + EXPECTED_COMMIT: ${{ inputs.expected_commit }} + run: python3 tools/prepare_release.py --version "$RELEASE_VERSION" --expected-commit "$EXPECTED_COMMIT" diff --git a/.github/workflows/publish-0.2.32.yml b/.github/workflows/publish-0.2.32.yml deleted file mode 100644 index 08d58ca..0000000 --- a/.github/workflows/publish-0.2.32.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: Publish authorized LawPDF 0.2.32 -on: - push: - branches: [main] - paths: - - '.github/workflows/publish-0.2.32.yml' - workflow_dispatch: - -permissions: - contents: read - -concurrency: - group: publish-lawpdf-0.2.32 - cancel-in-progress: false - -jobs: - publish: - if: github.repository == 'yonathanarbel/LawPDF' && github.ref == 'refs/heads/main' - runs-on: ubuntu-latest - timeout-minutes: 15 - permissions: - contents: write - actions: read - attestations: read - steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 - with: - persist-credentials: false - - name: Verify exact artifacts and maintainer signature, then publish - env: - GH_TOKEN: ${{ github.token }} - GH_REPO: yonathanarbel/LawPDF - run: python3 tools/publish_0_2_32.py --publish diff --git a/.github/workflows/publish-0.2.33.yml b/.github/workflows/publish-0.2.33.yml deleted file mode 100644 index 7f7174e..0000000 --- a/.github/workflows/publish-0.2.33.yml +++ /dev/null @@ -1,40 +0,0 @@ -name: Verify and publish authorized LawPDF 0.2.33 - -on: - push: - branches: [main] - paths: - - '.github/workflows/publish-0.2.33.yml' - - 'packaging/releases/v0.2.33.sig' - workflow_dispatch: - -permissions: - contents: read - -concurrency: - group: publish-lawpdf-0.2.33 - cancel-in-progress: false - -jobs: - verify-and-publish: - if: github.repository == 'yonathanarbel/LawPDF' && github.ref == 'refs/heads/main' - runs-on: ubuntu-latest - timeout-minutes: 15 - permissions: - contents: write - actions: read - attestations: read - steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 - with: - persist-credentials: false - - name: Verify built packages; publish only with the maintainer signature - env: - GH_TOKEN: ${{ github.token }} - GH_REPO: yonathanarbel/LawPDF - run: | - args=(--version 0.2.33) - if [[ -f packaging/releases/v0.2.33.sig ]]; then - args+=(--publish) - fi - python3 tools/publish_signed_release.py "${args[@]}" diff --git a/.github/workflows/publish-0.2.36.yml b/.github/workflows/publish-0.2.36.yml deleted file mode 100644 index c6f523e..0000000 --- a/.github/workflows/publish-0.2.36.yml +++ /dev/null @@ -1,40 +0,0 @@ -name: Verify and publish authorized LawPDF 0.2.36 - -on: - push: - branches: [main] - paths: - - '.github/workflows/publish-0.2.36.yml' - - 'packaging/releases/v0.2.36.sig' - workflow_dispatch: - -permissions: - contents: read - -concurrency: - group: publish-lawpdf-0.2.36 - cancel-in-progress: false - -jobs: - verify-and-publish: - if: github.repository == 'yonathanarbel/LawPDF' && github.ref == 'refs/heads/main' - runs-on: ubuntu-latest - timeout-minutes: 15 - permissions: - contents: write - actions: read - attestations: read - steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 - with: - persist-credentials: false - - name: Verify built packages; publish only with the maintainer signature - env: - GH_TOKEN: ${{ github.token }} - GH_REPO: yonathanarbel/LawPDF - run: | - args=(--version 0.2.36) - if [[ -f packaging/releases/v0.2.36.sig ]]; then - args+=(--publish) - fi - python3 tools/publish_signed_release.py "${args[@]}" diff --git a/.github/workflows/publish-0.2.37.yml b/.github/workflows/publish-0.2.37.yml deleted file mode 100644 index ccad758..0000000 --- a/.github/workflows/publish-0.2.37.yml +++ /dev/null @@ -1,40 +0,0 @@ -name: Verify and publish authorized LawPDF 0.2.37 - -on: - push: - branches: [main] - paths: - - '.github/workflows/publish-0.2.37.yml' - - 'packaging/releases/v0.2.37.sig' - workflow_dispatch: - -permissions: - contents: read - -concurrency: - group: publish-lawpdf-0.2.37 - cancel-in-progress: false - -jobs: - verify-and-publish: - if: github.repository == 'yonathanarbel/LawPDF' && github.ref == 'refs/heads/main' - runs-on: ubuntu-latest - timeout-minutes: 15 - permissions: - contents: write - actions: read - attestations: read - steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 - with: - persist-credentials: false - - name: Verify built packages; publish only with the maintainer signature - env: - GH_TOKEN: ${{ github.token }} - GH_REPO: yonathanarbel/LawPDF - run: | - args=(--version 0.2.37) - if [[ -f packaging/releases/v0.2.37.sig ]]; then - args+=(--publish) - fi - python3 tools/publish_signed_release.py "${args[@]}" diff --git a/.github/workflows/publish-0.2.45.yml b/.github/workflows/publish-0.2.45.yml deleted file mode 100644 index 968539b..0000000 --- a/.github/workflows/publish-0.2.45.yml +++ /dev/null @@ -1,40 +0,0 @@ -name: Verify and publish authorized LawPDF 0.2.45 - -on: - push: - branches: [main] - paths: - - '.github/workflows/publish-0.2.45.yml' - - 'packaging/releases/v0.2.45.sig' - workflow_dispatch: - -permissions: - contents: read - -concurrency: - group: publish-lawpdf-0.2.45 - cancel-in-progress: false - -jobs: - verify-and-publish: - if: github.repository == 'yonathanarbel/LawPDF' && github.ref == 'refs/heads/main' - runs-on: ubuntu-latest - timeout-minutes: 15 - permissions: - contents: write - actions: read - attestations: read - steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 - with: - persist-credentials: false - - name: Verify built packages; publish only with the maintainer signature - env: - GH_TOKEN: ${{ github.token }} - GH_REPO: yonathanarbel/LawPDF - run: | - args=(--version 0.2.45) - if [[ -f packaging/releases/v0.2.45.sig ]]; then - args+=(--publish) - fi - python3 tools/publish_signed_release.py "${args[@]}" diff --git a/.github/workflows/publish-0.2.48.yml b/.github/workflows/publish-0.2.48.yml deleted file mode 100644 index d04a900..0000000 --- a/.github/workflows/publish-0.2.48.yml +++ /dev/null @@ -1,40 +0,0 @@ -name: Verify and publish authorized LawPDF 0.2.48 - -on: - push: - branches: [main] - paths: - - '.github/workflows/publish-0.2.48.yml' - - 'packaging/releases/v0.2.48.sig' - workflow_dispatch: - -permissions: - contents: read - -concurrency: - group: publish-lawpdf-0.2.48 - cancel-in-progress: false - -jobs: - verify-and-publish: - if: github.repository == 'yonathanarbel/LawPDF' && github.ref == 'refs/heads/main' - runs-on: ubuntu-latest - timeout-minutes: 15 - permissions: - contents: write - actions: read - attestations: read - steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 - with: - persist-credentials: false - - name: Verify built packages; publish only with the maintainer signature - env: - GH_TOKEN: ${{ github.token }} - GH_REPO: yonathanarbel/LawPDF - run: | - args=(--version 0.2.48) - if [[ -f packaging/releases/v0.2.48.sig ]]; then - args+=(--publish) - fi - python3 tools/publish_signed_release.py "${args[@]}" diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml new file mode 100644 index 0000000..935e129 --- /dev/null +++ b/.github/workflows/publish-release.yml @@ -0,0 +1,59 @@ +name: Verify and publish reviewed LawPDF release + +on: + workflow_dispatch: + inputs: + version: + description: Version to verify (without v) + required: true + type: string + publish: + description: Publish after the existing signature and verification gates pass + required: true + type: boolean + default: false + +permissions: + contents: read + +concurrency: + group: publish-lawpdf-${{ inputs.version }} + cancel-in-progress: false + +jobs: + verify: + if: github.repository == 'yonathanarbel/LawPDF' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + actions: read + attestations: read + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + persist-credentials: false + - name: Verify package hashes, provenance, and installed Windows evidence + env: + GH_TOKEN: ${{ github.token }} + RELEASE_VERSION: ${{ inputs.version }} + run: python3 tools/publish_signed_release.py --version "$RELEASE_VERSION" + + publish: + needs: verify + if: inputs.publish && github.repository == 'yonathanarbel/LawPDF' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: write + actions: read + attestations: read + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + persist-credentials: false + - name: Reverify and publish only with the maintainer signature + env: + GH_TOKEN: ${{ github.token }} + RELEASE_VERSION: ${{ inputs.version }} + run: python3 tools/publish_signed_release.py --version "$RELEASE_VERSION" --publish diff --git a/Cargo.lock b/Cargo.lock index ac23543..8649473 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2486,7 +2486,7 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" [[package]] name = "lawpdf" -version = "0.2.48" +version = "0.2.89" dependencies = [ "anyhow", "blake2b_simd", diff --git a/Cargo.toml b/Cargo.toml index b70b13d..2f83d07 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lawpdf" -version = "0.2.48" +version = "0.2.89" edition = "2024" description = "Native PDF reader and annotation editor for legal workflows" license = "MIT" @@ -39,7 +39,7 @@ zip = { version = "9.0.0", default-features = false, features = ["deflate"] } [target.'cfg(target_os = "macos")'.dependencies] objc2 = "0.6.4" objc2-pdf-kit = { version = "0.3.2", default-features = false, features = ["std", "PDFDocument", "objc2-app-kit"] } -objc2-app-kit = { version = "0.3.2", default-features = false, features = ["NSApplication", "NSEvent", "NSMenu", "NSMenuItem", "NSResponder", "std"] } +objc2-app-kit = { version = "0.3.2", default-features = false, features = ["NSAccessibility", "NSApplication", "NSEvent", "NSMenu", "NSMenuItem", "NSResponder", "NSWorkspace", "std"] } objc2-foundation = { version = "0.3.2", default-features = false, features = ["NSArray", "NSAppleEventDescriptor", "NSAppleEventManager", "NSData", "NSDictionary", "NSError", "NSNotification", "NSObject", "NSString", "NSUserDefaults", "NSURL", "objc2-core-services", "std"] } objc2-vision = { version = "0.3.2", default-features = false, features = ["VNObservation", "VNRecognizeTextRequest", "VNRequest", "VNRequestHandler", "objc2-core-foundation", "std"] } @@ -48,3 +48,10 @@ windows = { version = "0.61.3", features = ["Foundation", "Foundation_Collection [build-dependencies] winresource = { version = "0.1.31", default-features = false } + +[profile.release] +lto = "thin" +codegen-units = 1 + +[profile.dev.package."*"] +opt-level = 3 diff --git a/docs/OPEN_PIPELINE_BENCHMARK.md b/docs/OPEN_PIPELINE_BENCHMARK.md new file mode 100644 index 0000000..243a21a --- /dev/null +++ b/docs/OPEN_PIPELINE_BENCHMARK.md @@ -0,0 +1,37 @@ +# Measure PDF opening and first raster + +The development build can time the same recovery snapshot, document opening, +annotation read, first CPU raster, and preparation of pixels for the UI used by the reader. It reports JSON to +standard output, including times for the recovery snapshot, metadata, +annotations, and the final revision check. It does not measure window creation, +GPU upload, or display. + +The optimized opening path reads page dimensions through PDFium's indexed size +query, falling back to loading a page if that query fails or returns invalid +dimensions. This avoids loading page contents merely to measure them. A damaged +page can still fail later when it is rendered or its text is requested. + +Choose a **new, non-existent** data directory outside the checkout. The command +creates it and stores a recovery snapshot and performance cache there, so it can +contain a copy of the input PDF. Keep it private when the PDF is private. The +input file is checked for changes and is not modified. + +On Windows, from the repository root: + +```powershell +$env:CARGO_TARGET_DIR = 'C:\tmp\lawpdf-target' +$env:LAWPDF_BENCH_DATA_DIR = 'C:\tmp\lawpdf-open-bench-run-001' +cargo run --locked --features devtools -- --bench-open-pipeline 'C:\path\to\document.pdf' 1.0 +``` + +The final argument is an optional raster scale from 0.25 through 4.0 (default +1.0). Use a different new data directory for every cold application-cache run. +The operating system's file cache can still be warm; record the machine, file, +scale, cache condition, and multiple trials when comparing results. The test +does not establish real-window first-page latency by itself. + +For an eligible large PDF, `read_only_preview_ready_ms` records when a temporary +first-page CPU raster and prepared pixels arrive while the verified recovery snapshot is still +being prepared. It can be null if a preview could not be produced or the full +open finished first. `first_page_ready_ms` remains the time to a prepared raster after the +document is fully opened and its revision checked. Neither includes GPU upload. diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index b25b234..6d1eae3 100644 --- a/docs/PRIVACY.md +++ b/docs/PRIVACY.md @@ -18,6 +18,8 @@ Cloud providers have their own accounts, charges, retention settings, and privac ## What stays on the device +If you enable “Reopen last session on launch,” LawPDF stores the paths of up to 256 open documents locally when you close the window. Those paths expire under the reading-history retention setting, and “Clear reading history” removes them. An explicitly opened document takes precedence over session restoration. + Desktop preferences, per-document zoom, OCR and reading caches, raster caches, local corrections, backups, and recovery records are stored in LawPDF's application-data folder. Windows raster caches use Local AppData. Document paths and content can appear in these local files. A recovery record includes annotations and an immutable copy of the PDF version they belong to. This allows recovery after the original moves or changes. Unsaved recovery data is preserved until recovered or explicitly discarded. The recovery source store has a 4 GB limit; reaching it produces a visible error rather than dropping edits. API keys are stored in macOS Keychain or Windows Credential Manager. The supported Linux backend uses Secret Service. Legacy settings-file keys are removed only after the native credential store confirms migration. A failed migration leaves the existing file and displays an error. Old settings backups created by earlier versions may still contain keys; remove those backups after confirming that secure migration succeeded. Environment-supplied keys are read from the launching environment and are not written into settings. diff --git a/docs/PRODUCTION_PLAN.md b/docs/PRODUCTION_PLAN.md index d6205b6..30a88a3 100644 --- a/docs/PRODUCTION_PLAN.md +++ b/docs/PRODUCTION_PLAN.md @@ -21,9 +21,11 @@ The September 17 review is being implemented as desktop candidate **0.2.32**. Br 1. **No document loss:** zero lost/corrupted committed marks or changed originals in edit/reopen, close/save overlap, forced termination, external revision conflict, protected/read-only document and write-failure scenarios. Recoverable failures must produce a usable copy and an accurate status. 2. **Exact artifact verification:** both platforms build the curated commit. Install the Windows installer; verify SHA-256, product/file versions, Start Menu target, and successful `--lm2-runtime-status --require-native --require-context`. Mac verification includes the actual bundle, signing status, native child restart and real window shutdown. 3. **Update trust:** reject unsigned, altered, wrong-platform, stale and substituted releases. Check the actual downloaded package and test replacement/restart/recovery using disposable installations. Keep build provenance and the signed manifest with the release. -4. **Accessibility:** complete open, navigate, search, mark, undo, save, recover and close using keyboard plus VoiceOver/NVDA. PDF canvas accessibility and reconstructed reading order require human task evidence. +4. **Accessibility:** complete open, navigate, search, mark, undo, save, recover and close using keyboard plus VoiceOver/NVDA. Exercise Page Up/Down, Space/Shift+Space, arrows, Home/End, Ctrl+Home/End, J/K and Ctrl+G in the original, Review, and focused split panes; test Ctrl+0/1/2 for fit page, actual size and fit width in the original view; confirm text fields retain their normal keys. PDF canvas accessibility and reconstructed reading order require human task evidence. 5. **Reading fidelity:** use a private, held-out corpus spanning court documents, contracts, articles, scans, tables, multilingual pages and unusual footnotes. Record missing/reordered substantive text and citation errors against the original. No missing substantive text or changed citations is acceptable in the audited release sample; uncertain reconstructions must keep the source available. Corpus data and development tooling stay outside the public repository. 6. **Performance and reliability:** before a controlled beta, record reference hardware and document sizes, then measure p95 open/navigation/save latency and peak memory. Suggested starting budgets are 3 s to the first usable page for text PDFs under 20 MB, 150 ms for a cached page, and 2 s for saving a 20 MB PDF; larger and scanned files need separate cohorts. These are proposed budgets, not measured claims. Broad release additionally needs at least 99.9% crash-free sessions in a representative, consented beta sample and zero confirmed unrecoverable annotation loss. + + Desktop CI also enforces the synthetic, headless Review benchmark's median budgets: under 1.5 ms per steady frame and under 8 ms per resizing frame for each of its article sizes. These CPU-only medians do not establish worst-frame latency, real-window or GPU performance, or behavior on the full-layout fallback. To run the same gate on a reference machine, set `LAWPDF_ENFORCE_REVIEW_BUDGETS=1` and run `cargo test --release --locked review_frame_cost -- --ignored --nocapture` with Cargo output outside the Box checkout. 7. **Android qualification:** run real activity recreation and process-death restoration, provider-access loss and export checks. A desktop release does not certify Android. ## Free distribution and external dependencies diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 69f681b..fad4077 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -17,6 +17,14 @@ A release is made from a curated source commit, with private research and traini The initial key was created in macOS Keychain with the utility's `init` operation. Do not export it into a source file or shell argument. Keep an approved secure recovery plan for the signing identity; losing it interrupts automatic-update continuity. 6. Upload the detached signature, verify it against the committed public key, and exercise staging/restart/rollback with disposable app installations. The actual packages remain unchanged after their hashes are signed. Publish only after the gate evidence and accurate signing-status/install notes are attached. +The reusable workflows are manually dispatched from `main`. To prepare a version, run +`gh workflow run prepare-release.yml --ref main -f version= -f expected_commit=`. +Preparation refuses a changed commit, mismatched package, lockfile, or manifest version, or an existing tag pointing elsewhere; it requests both desktop release and Store verification builds. It does not publish. +After the maintainer record and release notes are present, run +`gh workflow run publish-release.yml --ref main -f version= -f publish=false` to verify the draft. +After the detached signature is committed and the release is authorized, use the same command with `publish=true`. +The publish job repeats the existing package, attestation, installation-evidence, checksum, and signature checks. Merely committing a signature no longer publishes automatically. + The source signing policy is separate from platform credentials. Until SignPath approves Windows signing, report its absence. The Mac ZIP remains ad hoc signed and unnotarized under the free-distribution plan. Use Apple's documented user approval flow for installation; do not tell users to disable Gatekeeper globally. The Mac updater checks the declared minimum OS and bundled model runtime before replacement. It keeps `LawPDF.app.update-old` beside the installation until the next update; if a newly launched process subsequently fails, quit it and restore this retained bundle or the previous known-good GitHub package while keeping recovery data. LaunchServices accepting a launch is not proof of a healthy reader session. In-app updates reject downgrades. Android has a separate release gate: lifecycle/process-death/provider-permission instrumentation, local annotation restoration, flattened-export semantics, and an Android signing identity. A desktop release does not certify Android. Do not publish an Android production build based solely on desktop checks. diff --git a/docs/releases/v0.2.89.md b/docs/releases/v0.2.89.md new file mode 100644 index 0000000..51bbfe1 --- /dev/null +++ b/docs/releases/v0.2.89.md @@ -0,0 +1,23 @@ +# LawPDF 0.2.89 — A more readable Review + +Created by Professor Yonathan Arbel. LawPDF is a free, open-source reader for legal scholarship. + +## What's new + +- **A slower Review reveal.** On the first switch to Review, words lift from the original page and settle into reading order over about 3.2 seconds. Visible words leave in sequence and each has time to travel. Click, scroll, or press a key to skip it; Reduce motion turns it off. +- **Read with the keyboard.** Page Up and Down, Space, arrows, J/K, Home and End move through a document. Ctrl/Cmd+G opens the page field; Ctrl/Cmd+0, 1, and 2 choose fit page, actual size, and fit width. Press `?` for shortcuts. +- **Select across pages.** Drag across page boundaries, double-click a word, triple-click a paragraph, or extend a selection with Shift+click. Paragraph boundaries can be approximate when Review has no unambiguous source mapping. +- **Resume reading.** Recent documents remember position, zoom, and view; an undoable notice identifies a restored position. An optional setting reopens a prior tab session. Tabs can be reordered, closed with middle-click, or managed from their context menu. +- **Navigate legal materials.** PDF links and bookmarks work in the original view, with back/forward history for supported jumps. After Review prepares an index, supported footnote markers can show a preview; unambiguous `supra note` and a limited set of `Id.` references can link to earlier notes. +- **Work with quotations and notes.** Copy a selection with a reviewable journal-article citation draft. Review all inferred metadata and supply the journal abbreviation. The Notes panel lists LawPDF annotations and supported read-only marks from other PDF apps; it can export a Markdown digest with reviewable page citations. +- **Make the reader your own.** Choose light or dark window appearance, or follow the system. An optional display tint dims original pages without changing the PDF or print output. System-voice reading can start at a passage and offers speed and follow-along controls. +- **Performance and reliability.** Review layout retains and draws visible content, settled highlights no longer request continuous repaints, page textures have a shared memory budget, and slow page renders can be cached in the background. Large eligible PDFs can show a temporary read-only first-page preview while their editable recovery snapshot is prepared. Failed opening and annotation saves have retry actions. +- This release also includes the previously prepared **Windows print controls** and the newer asynchronous **Find** interface. + +## Beta limits and installation + +LawPDF is a public beta. Review reconstruction, detected citation metadata, paragraph boundaries, footnote references, and annotation extraction can be incomplete; check important quotations and citations against the original. Some animation, selection, accessibility, large-document, and speech behavior still needs broader real-world testing. Imported third-party marks are read-only and unsupported annotation types may be omitted. + +Windows and macOS direct downloads can receive signed in-app updates after publication. Linux updates are manual. The Windows installer is not publisher-signed; the macOS app is ad hoc signed and not notarized. GitHub publication does not update the Microsoft Store edition. See `RELEASE-VERIFICATION.json` for the published packages' hashes and build evidence. + +Distributed under the MIT License, **WITHOUT WARRANTY**. diff --git a/release-manifest.json b/release-manifest.json index e9cdb67..557e930 100644 --- a/release-manifest.json +++ b/release-manifest.json @@ -2,7 +2,7 @@ "schema_version": "lawpdf-public-release-manifest-v1", "product": { "name": "LawPDF", - "version": "0.2.48" + "version": "0.2.89" }, "runtime_assets": { "fasttab_model": { diff --git a/src/app/annotation_digest.rs b/src/app/annotation_digest.rs new file mode 100644 index 0000000..3faa581 --- /dev/null +++ b/src/app/annotation_digest.rs @@ -0,0 +1,860 @@ +//! Export the reader's annotations without guessing missing printed pages. +use super::*; +use crate::review_masthead::{CitationDetails, citation_details, printed_folio}; + +pub(super) struct PendingDigest { + epoch: u64, + path: PathBuf, + annotations: Vec, + foreign: Arc>, + pages: Vec, +} + +struct Entry { + page: usize, + kind: &'static str, + foreign: bool, + text: String, + printed_page: Option, +} + +pub(super) struct DigestDraft { + epoch: u64, + path: PathBuf, + title: String, + annotations: Vec, + foreign: Arc>, + entries: Vec, + printed_pages: std::collections::BTreeMap, + details: CitationDetails, + detected_journal: String, + designation: String, + include_citations: bool, +} + +fn marked_text(annotation: &EditorAnnotation, document: &LoadedDocument) -> Option { + marked_text_in_rect(annotation.page_index, annotation.rect, document) +} + +fn marked_text_in_rect(page: usize, rect: PdfRect, document: &LoadedDocument) -> Option { + let chars = document.text_chars.get(page)?.as_deref()?; + let bounds = expand_pdf_rect(rect, 0.5); + let mut indices = chars.iter().enumerate().filter_map(|(index, ch)| { + let rect = ch.rect?; + let center = ( + (rect.left + rect.right) * 0.5, + (rect.bottom + rect.top) * 0.5, + ); + (!ch.ch.is_control() && point_in_pdf_rect(center, bounds)).then_some(index) + }); + let first = indices.next()?; + let last = indices.last().unwrap_or(first); + let mut text = String::new(); + for ch in &chars[first..=last] { + if let Some(rect) = ch.rect { + let center = ( + (rect.left + rect.right) * 0.5, + (rect.bottom + rect.top) * 0.5, + ); + if !ch.ch.is_whitespace() && !ch.ch.is_control() && !point_in_pdf_rect(center, bounds) { + return None; + } + } + text.push(ch.ch); + } + (!text.trim().is_empty()).then(|| text.trim().to_owned()) +} + +fn digest_entries( + annotations: &[EditorAnnotation], + foreign: &[ForeignAnnotation], + document: &LoadedDocument, + year: Option, +) -> Vec { + let mut order = annotations + .iter() + .enumerate() + .map(|(index, mark)| (mark.page_index, mark.rect.top, false, index)) + .chain(foreign.iter().enumerate().filter_map(|(index, mark)| { + mark.rects + .iter() + .map(|rect| rect.top) + .max_by(f32::total_cmp) + .map(|top| (mark.page_index, top, true, index)) + })) + .collect::>(); + order.sort_by(|left, right| { + left.0 + .cmp(&right.0) + .then_with(|| right.1.total_cmp(&left.1)) + .then_with(|| left.2.cmp(&right.2)) + .then_with(|| left.3.cmp(&right.3)) + }); + let mut folios = HashMap::new(); + order + .into_iter() + .map(|(page, _, is_foreign, index)| { + let (kind, text) = if is_foreign { + let annotation = &foreign[index]; + let kind = match annotation.kind { + ForeignAnnotationKind::Highlight => "Highlight", + ForeignAnnotationKind::Underline => "Underline", + ForeignAnnotationKind::Squiggly => "Squiggly underline", + ForeignAnnotationKind::StrikeOut => "Strikeout", + ForeignAnnotationKind::Comment => "Comment", + ForeignAnnotationKind::TextBox => "Text box", + }; + let mut text = if matches!( + annotation.kind, + ForeignAnnotationKind::Comment | ForeignAnnotationKind::TextBox + ) { + annotation.text.clone() + } else if annotation.precise_text_geometry { + annotation + .rects + .iter() + .map(|rect| marked_text_in_rect(page, *rect, document)) + .collect::>>() + .map(|parts| parts.join("\n")) + .unwrap_or_else(|| "[Marked text unavailable or ambiguous.]".to_owned()) + } else { + "[Marked text unavailable or ambiguous.]".to_owned() + }; + if !matches!( + annotation.kind, + ForeignAnnotationKind::Comment | ForeignAnnotationKind::TextBox + ) && !annotation.text.trim().is_empty() + { + text.push_str("\nNote: "); + text.push_str(&annotation.text); + } + if !annotation.author.trim().is_empty() { + text.push_str("\nAuthor: "); + text.push_str(&annotation.author); + } + (kind, text) + } else { + match &annotations[index].kind { + AnnotationKind::Marker { style, .. } => ( + match style { + MarkerStyle::Highlight => "Highlight", + MarkerStyle::Underline => "Underline", + }, + marked_text(&annotations[index], document).unwrap_or_else(|| { + "[Marked text unavailable or ambiguous.]".to_owned() + }), + ), + AnnotationKind::TextBox { text, .. } => ("Text box", text.clone()), + AnnotationKind::Comment { text, .. } => ("Comment", text.clone()), + AnnotationKind::Signature { signer, .. } => ("Signature", signer.clone()), + } + }; + let printed_page = *folios.entry(page).or_insert_with(|| { + citation_copy::page_text(document, page).and_then(|text| printed_folio(&text, year)) + }); + Entry { + page, + kind, + foreign: is_foreign, + text, + printed_page, + } + }) + .collect() +} + +fn citation_fields_complete(details: &CitationDetails, designation: &str) -> bool { + [ + &details.title, + &details.volume, + &details.journal, + &details.first_page, + &details.year, + ] + .into_iter() + .all(|field| !field.trim().is_empty()) + && (!details.authors.trim().is_empty() || !designation.trim().is_empty()) +} + +fn markdown_citation(details: &CitationDetails, designation: &str, page: &str) -> Option { + if !citation_fields_complete(details, designation) || page.trim().is_empty() { + return None; + } + let clean = |s: &str| { + citation_copy::escape_markdown(&s.split_whitespace().collect::>().join(" ")) + }; + let prefix = [details.authors.as_str(), designation] + .into_iter() + .map(|value| clean(value)) + .filter(|value| !value.is_empty()) + .collect::>() + .join(", "); + let first = clean(&details.first_page); + let pinpoint = if page.trim() == details.first_page.trim() { + String::new() + } else { + format!(", {}", clean(page)) + }; + Some(format!( + "{}, *{}*, {} {} {}{} ({}).", + prefix, + clean(&details.title), + clean(&details.volume), + clean(&details.journal), + first, + pinpoint, + clean(&details.year) + )) +} + +fn digest_markdown(draft: &DigestDraft) -> String { + let mut out = format!( + "# Annotations: {}\n\n", + citation_copy::escape_markdown(&draft.title) + ); + out.push_str("LawPDF marks and supported highlights, underlines, strikeouts, comments, and text boxes from other PDF apps. Other-app marks are read-only; other annotation types may be omitted. Verify printed pages and citation details against the source.\n\n"); + let mut current_page = None; + for entry in &draft.entries { + let printed = draft + .printed_pages + .get(&entry.page) + .map(|page| page.split_whitespace().collect::>().join(" ")) + .filter(|page| !page.is_empty()); + if current_page != Some(entry.page) { + current_page = Some(entry.page); + match printed.as_deref() { + Some(page) => out.push_str(&format!( + "## PDF page {} · printed page {} (check against source)\n\n", + entry.page + 1, + citation_copy::escape_markdown(page) + )), + None => out.push_str(&format!( + "## PDF page {} · printed page not verified\n\n", + entry.page + 1 + )), + } + } + let body = citation_copy::escape_markdown(entry.text.trim()) + .replace('\r', "") + .replace('\n', "\n "); + let origin = if entry.foreign { + " (other app, read-only)" + } else { + "" + }; + out.push_str(&format!("- **{}{}:** {}", entry.kind, origin, body)); + if draft.include_citations { + if let Some(citation) = printed + .as_deref() + .and_then(|page| markdown_citation(&draft.details, &draft.designation, page)) + { + out.push_str(&format!("\n {citation}")); + } else { + out.push_str("\n Printed pinpoint unavailable; check the source."); + } + } + out.push_str("\n\n"); + } + out +} + +fn safe_destination(source: &Path, destination: &Path) -> bool { + let source = std::fs::canonicalize(source).unwrap_or_else(|_| source.to_path_buf()); + let destination = + std::fs::canonicalize(destination).unwrap_or_else(|_| destination.to_path_buf()); + source != destination +} + +impl PdfEditorApp { + pub(super) fn start_annotation_digest(&mut self, ctx: &Context) { + if self.pending_annotation_digest.is_some() || self.annotation_digest_draft.is_some() { + return; + } + let Some(foreign) = self.foreign_annotations() else { + self.ensure_foreign_annotations(); + self.status = + "Wait for other applications' annotations to load before exporting.".to_owned(); + return; + }; + let Some(document) = self.document.as_ref() else { + return; + }; + if self.annotations.is_empty() && foreign.is_empty() { + self.status = "No annotations to export.".to_owned(); + return; + } + let mut pages = self + .annotations + .iter() + .map(|annotation| annotation.page_index) + .chain(foreign.iter().map(|annotation| annotation.page_index)) + .collect::>(); + pages.sort_unstable(); + pages.dedup(); + if pages + .iter() + .any(|page| *page >= document.page_count || *page >= document.text_chars.len()) + { + self.push_error_notice("An annotation refers to a page that is not in the document."); + return; + } + let path = document.path.clone(); + let retry = pages + .iter() + .copied() + .filter(|page| self.text_char_errors.remove(&(self.document_epoch, *page))) + .collect::>(); + if let Some(document) = self.document.as_mut() { + for page in retry { + document.text_chars[page] = None; + } + } + self.pending_annotation_digest = Some(PendingDigest { + epoch: self.document_epoch, + path, + annotations: self.annotations.clone(), + foreign, + pages, + }); + self.poll_annotation_digest(ctx); + } + + pub(super) fn annotation_digest_text_failed(&mut self, epoch: u64, path: &Path, page: usize) { + if self + .pending_annotation_digest + .as_ref() + .is_some_and(|pending| { + pending.epoch == epoch && pending.path == path && pending.pages.contains(&page) + }) + { + self.pending_annotation_digest = None; + self.status = + "Could not prepare the annotation digest. Try again after checking the PDF." + .to_owned(); + } + } + + pub(super) fn poll_annotation_digest(&mut self, ctx: &Context) { + let Some(pending) = self.pending_annotation_digest.take() else { + return; + }; + if !self.is_current_document(pending.epoch, &pending.path) + || self.annotations != pending.annotations + || !self + .foreign_annotations() + .is_some_and(|marks| Arc::ptr_eq(&marks, &pending.foreign)) + { + self.status = "Annotations changed; start the digest again.".to_owned(); + return; + } + let document = self.document.as_ref().expect("current document"); + let missing = pending + .pages + .iter() + .copied() + .filter(|page| document.text_chars.get(*page).is_some_and(Option::is_none)) + .collect::>(); + if !missing.is_empty() { + let mut retry_queue = false; + for page in missing { + if self.pending_text_chars.contains(&page) { + continue; + } + let request = RenderRequest::TextCharsAsync { + document_epoch: pending.epoch, + path: pending.path.clone(), + page_index: page, + }; + match self.render_tx.send(request) { + Ok(()) => { + self.pending_text_chars.insert(page); + } + Err(crossbeam_channel::TrySendError::Full(_)) => { + retry_queue = true; + break; + } + Err(crossbeam_channel::TrySendError::Disconnected(_)) => { + self.push_error_notice( + "The PDF worker stopped while preparing the annotation digest.", + ); + return; + } + } + } + self.status = "Preparing marked pages for the annotation digest…".to_owned(); + self.pending_annotation_digest = Some(pending); + if retry_queue { + ctx.request_repaint_after(Duration::from_millis(100)); + } + return; + } + let pages = document + .native_text + .iter() + .zip(&document.native_text_loaded) + .take(crate::review_masthead::MASTHEAD_PAGES_TO_READ + 1) + .map(|(text, loaded)| if *loaded { text.as_str() } else { "" }) + .collect::>(); + let review = match &self.liquid_mode2_state { + LiquidState::Ready(review) => Some(review.as_ref()), + _ => None, + }; + let mut details = citation_details( + &pages, + review.map(|review| review.title.as_str()), + review + .map(|review| review.blocks.as_slice()) + .unwrap_or_default(), + ); + let detected_journal = std::mem::take(&mut details.journal); + let entries = digest_entries( + &pending.annotations, + &pending.foreign, + document, + details.year.parse().ok(), + ); + let printed_pages = entries + .iter() + .map(|entry| { + ( + entry.page, + entry + .printed_page + .map(|page| page.to_string()) + .unwrap_or_default(), + ) + }) + .collect(); + self.annotation_digest_draft = Some(DigestDraft { + epoch: pending.epoch, + path: pending.path, + title: document.title.clone(), + annotations: pending.annotations, + foreign: pending.foreign, + entries, + printed_pages, + details, + detected_journal, + designation: String::new(), + include_citations: true, + }); + self.status = "Review citation details for the annotation digest.".to_owned(); + } + + pub(super) fn draw_annotation_digest_window(&mut self, ctx: &Context) { + let Some(mut draft) = self.annotation_digest_draft.take() else { + return; + }; + if !self.is_current_document(draft.epoch, &draft.path) + || self.annotations != draft.annotations + || !self + .foreign_annotations() + .is_some_and(|marks| Arc::ptr_eq(&marks, &draft.foreign)) + { + self.status = "Annotations changed; start the digest again.".to_owned(); + return; + } + let mut open = true; + let mut save = false; + egui::Window::new("Export annotation digest").open(&mut open).collapsible(false).default_width(480.0).show(ctx, |ui| { + ui.label(format!("{} supported marks", draft.entries.len())); + ui.label("Includes LawPDF marks and supported highlights, underlines, strikeouts, comments, and text boxes from other apps. Other types may be omitted; other-app marks are read-only. Check printed pages against the PDF; image-only marks may have no selectable text."); + egui::CollapsingHeader::new("Review printed page numbers").default_open(true).show(ui, |ui| { + ui.label("Correct a suggestion, fill a blank, or leave it blank when unknown."); + let pages = draft.printed_pages.keys().copied().collect::>(); + let accesskit = ui.ctx().accesskit_node_builder(ui.id(), |_| ()).is_some(); + let draw_page = |ui: &mut egui::Ui, page: usize, values: &mut std::collections::BTreeMap| { + ui.horizontal(|ui| { + ui.label(format!("PDF page {}", page + 1)); + if let Some(value) = values.get_mut(&page) { ui.text_edit_singleline(value); } + }); + }; + let scroll = egui::ScrollArea::vertical().id_salt("digest-printed-pages").max_height(160.0); + if accesskit || ui.ctx().memory(|memory| memory.options.screen_reader) { + scroll.show(ui, |ui| { for page in pages.iter().copied() { draw_page(ui, page, &mut draft.printed_pages); } }); + } else { + let row_height = ui.spacing().interact_size.y.max(20.0); + scroll.show_rows(ui, row_height, pages.len(), |ui, range| { + for index in range { draw_page(ui, pages[index], &mut draft.printed_pages); } + }); + } + }); + ui.checkbox(&mut draft.include_citations, "Include journal-article citations when a printed page is found"); + if draft.include_citations { + if !draft.detected_journal.is_empty() { ui.label(format!("Journal detected: {}", draft.detected_journal)); } + ui.label("Check the inferred details and enter the journal abbreviation."); + egui::Grid::new("digest-citation-fields").num_columns(2).spacing([12.0, 6.0]).show(ui, |ui| { + for (label, field) in [("Author(s)", &mut draft.details.authors), ("Designation (optional)", &mut draft.designation), + ("Article title", &mut draft.details.title), ("Volume", &mut draft.details.volume), + ("Journal abbreviation", &mut draft.details.journal), ("First page", &mut draft.details.first_page), ("Year", &mut draft.details.year)] { + ui.label(label); ui.text_edit_singleline(field); ui.end_row(); + } + }); + } + let ready = !draft.include_citations || citation_fields_complete(&draft.details, &draft.designation); + if !ready { ui.label("Fill the citation fields or turn off citations."); } + if ui.add_enabled(ready, egui::Button::new("Choose Markdown file…")).clicked() { save = true; } + }); + if save { + let file_name = default_output_name(&draft.path, "annotations", "md"); + if let Some(destination) = + self.pick_save_path("Export annotation digest", &file_name, "Markdown", &["md"]) + { + if !safe_destination(&draft.path, &destination) { + self.push_error_notice("Choose a Markdown file other than the original PDF."); + } else { + match crate::atomic_file::write( + &destination, + digest_markdown(&draft).as_bytes(), + ) { + Ok(()) => { + self.status = + format!("Exported annotation digest to {}", destination.display()); + return; + } + Err(error) => self.push_error_notice(format!( + "Could not export annotation digest: {error}" + )), + } + } + } + } + if open { + self.annotation_digest_draft = Some(draft); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn mark(page: usize, top: f32) -> EditorAnnotation { + EditorAnnotation { + page_index: page, + rect: PdfRect::new(50.0, top - 12.0, 150.0, top), + kind: AnnotationKind::Marker { + color_rgb: [1.0, 0.9, 0.2], + opacity: 0.4, + style: MarkerStyle::Highlight, + }, + } + } + + fn text_chars(text: &str) -> Vec { + text.chars() + .enumerate() + .map(|(index, ch)| PageTextChar { + ch, + rect: Some(PdfRect::new( + 50.0 + index as f32 * 5.0, + 688.0, + 54.0 + index as f32 * 5.0, + 700.0, + )), + font_size: Some(12.0), + bold: false, + italic: false, + }) + .collect() + } + + fn details() -> CitationDetails { + CitationDetails { + authors: "A. Author".into(), + title: "A Title".into(), + journal: "N.Y.U. L. Rev.".into(), + volume: "99".into(), + first_page: "451".into(), + year: "2024".into(), + } + } + + #[test] + fn digest_preserves_mark_text_order_and_identifies_unverified_pages() { + let (mut app, _ctx, _worker) = annotation_saving::tests::fixture(); + let document = app.document.as_mut().unwrap(); + document.page_count = 2; + document.native_text = vec!["453\nText".into(), "No folio".into()]; + document.native_text_loaded = vec![true, true]; + document.text_chars = vec![Some(text_chars("Quoted text")), Some(vec![])]; + let comment = EditorAnnotation { + page_index: 0, + rect: PdfRect::new(20.0, 200.0, 40.0, 210.0), + kind: AnnotationKind::Comment { + id: "comment-1".into(), + text: "A *note*\nsecond line".into(), + author: "Reader".into(), + author_id: "reader-1".into(), + color_rgb: [0.0; 3], + created_at: String::new(), + updated_at: String::new(), + anchor: (50.0, 200.0), + }, + }; + let annotations = vec![mark(1, 700.0), comment, mark(0, 700.0)]; + let entries = digest_entries(&annotations, &[], document, Some(2024)); + assert_eq!( + entries.iter().map(|entry| entry.page).collect::>(), + vec![0, 0, 1] + ); + assert_eq!(entries[0].text, "Quoted text"); + assert!(entries[2].text.contains("unavailable or ambiguous")); + let mut draft = DigestDraft { + epoch: 7, + path: document.path.clone(), + title: "Article".into(), + annotations, + foreign: Arc::new(Vec::new()), + entries, + printed_pages: std::collections::BTreeMap::from([ + (0, "453".into()), + (1, String::new()), + ]), + details: details(), + detected_journal: String::new(), + designation: String::new(), + include_citations: true, + }; + let markdown = digest_markdown(&draft); + assert!(markdown.contains("printed page 453 (check against source)")); + assert!(markdown.contains("A. Author, *A Title*, 99 N.Y.U. L. Rev. 451, 453 (2024).")); + assert!(markdown.contains("A \\*note\\*\n second line")); + assert!(markdown.contains("**Comment:**")); + assert!(markdown.contains("PDF page 2 · printed page not verified")); + assert!(markdown.contains("Printed pinpoint unavailable")); + draft.printed_pages.insert(0, "454".into()); + let corrected = digest_markdown(&draft); + assert!(corrected.contains("printed page 454 (check against source)")); + assert!(corrected.contains("99 N.Y.U. L. Rev. 451, 454 (2024).")); + draft.printed_pages.insert(0, String::new()); + assert!(digest_markdown(&draft).contains("PDF page 1 · printed page not verified")); + } + + #[test] + fn digest_loads_marked_page_text_before_opening_the_export_dialog() { + let (mut app, ctx, requests) = annotation_saving::tests::fixture(); + app.set_foreign_annotations_for_test(Vec::new()); + app.document.as_mut().unwrap().text_chars[0] = None; + app.annotations.push(mark(0, 700.0)); + let path = app.document.as_ref().unwrap().path.clone(); + let _ = ctx.run(Default::default(), |ctx| app.start_annotation_digest(ctx)); + assert!(matches!( + requests.try_recv().unwrap(), + RenderRequest::TextCharsAsync { page_index: 0, .. } + )); + assert!(app.annotation_digest_draft.is_none()); + let (sender, receiver) = crossbeam_channel::unbounded(); + app.render_rx = receiver; + sender + .send(RenderEvent::TextChars { + document_epoch: 7, + path, + page_index: 0, + result: Ok(text_chars("Quoted text")), + }) + .unwrap(); + let _ = ctx.run(Default::default(), |ctx| { + app.poll_render_results(ctx); + app.poll_annotation_digest(ctx); + }); + let draft = app.annotation_digest_draft.as_ref().unwrap(); + assert_eq!(draft.entries[0].text, "Quoted text"); + assert!(app.pending_annotation_digest.is_none()); + } + + #[test] + fn printed_page_can_be_corrected_in_the_export_window() { + fn text_center(shape: &egui::Shape, expected: &str) -> Option { + match shape { + egui::Shape::Text(text) if text.galley.text() == expected => { + Some(text.pos + text.galley.size() * 0.5) + } + egui::Shape::Vec(shapes) => { + shapes.iter().find_map(|shape| text_center(shape, expected)) + } + _ => None, + } + } + let (mut app, ctx, _worker) = annotation_saving::tests::fixture(); + app.set_foreign_annotations_for_test(Vec::new()); + let document = app.document.as_mut().unwrap(); + document.native_text[0] = "453\nText".into(); + document.text_chars[0] = Some(text_chars("Quoted text")); + app.annotations.push(mark(0, 700.0)); + let _ = ctx.run(Default::default(), |ctx| app.start_annotation_digest(ctx)); + app.annotation_digest_draft.as_mut().unwrap().details = details(); + let draw = |app: &mut PdfEditorApp, time, events, modifiers| { + ctx.run( + egui::RawInput { + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, Vec2::new(1000.0, 800.0))), + time: Some(time), + events, + modifiers, + ..Default::default() + }, + |ctx| app.draw_annotation_digest_window(ctx), + ) + }; + let _ = draw(&mut app, 0.0, vec![], egui::Modifiers::NONE); + let output = draw(&mut app, 0.1, vec![], egui::Modifiers::NONE); + let point = output + .shapes + .iter() + .find_map(|shape| text_center(&shape.shape, "453")) + .expect("printed page editor"); + let _ = draw( + &mut app, + 0.2, + vec![egui::Event::PointerMoved(point)], + egui::Modifiers::NONE, + ); + for (time, pressed) in [(0.3, true), (0.4, false)] { + let _ = draw( + &mut app, + time, + vec![egui::Event::PointerButton { + pos: point, + button: egui::PointerButton::Primary, + pressed, + modifiers: egui::Modifiers::NONE, + }], + egui::Modifiers::NONE, + ); + } + let command = egui::Modifiers::COMMAND; + let _ = draw( + &mut app, + 0.5, + vec![ + egui::Event::Key { + key: egui::Key::A, + physical_key: None, + pressed: true, + repeat: false, + modifiers: command, + }, + egui::Event::Text("454".into()), + ], + command, + ); + let draft = app.annotation_digest_draft.as_ref().unwrap(); + assert_eq!(draft.printed_pages[&0], "454"); + assert!(digest_markdown(draft).contains("451, 454 (2024)")); + } + + #[test] + fn digest_cancels_when_annotations_change_or_page_extraction_fails() { + let (mut app, ctx, _requests) = annotation_saving::tests::fixture(); + app.set_foreign_annotations_for_test(Vec::new()); + app.document.as_mut().unwrap().text_chars[0] = None; + app.annotations.push(mark(0, 700.0)); + let path = app.document.as_ref().unwrap().path.clone(); + let _ = ctx.run(Default::default(), |ctx| app.start_annotation_digest(ctx)); + app.annotations.push(mark(0, 650.0)); + let _ = ctx.run(Default::default(), |ctx| app.poll_annotation_digest(ctx)); + assert!(app.pending_annotation_digest.is_none()); + assert!(app.annotation_digest_draft.is_none()); + app.pending_text_chars.clear(); + let _ = ctx.run(Default::default(), |ctx| app.start_annotation_digest(ctx)); + let (sender, receiver) = crossbeam_channel::unbounded(); + app.render_rx = receiver; + sender + .send(RenderEvent::TextChars { + document_epoch: 7, + path: path.clone(), + page_index: 0, + result: Err("test extraction failure".into()), + }) + .unwrap(); + let _ = ctx.run(Default::default(), |ctx| app.poll_render_results(ctx)); + assert!(app.pending_annotation_digest.is_none()); + assert!(app.annotation_digest_draft.is_none()); + assert!(app.text_char_errors.contains(&(7, 0))); + let _ = ctx.run(Default::default(), |ctx| app.start_annotation_digest(ctx)); + assert!(app.pending_annotation_digest.is_some()); + assert!(app.document.as_ref().unwrap().text_chars[0].is_none()); + } + + #[test] + fn digest_does_not_include_interleaved_text_outside_a_mark() { + let (mut app, _ctx, _worker) = annotation_saving::tests::fixture(); + let mut chars = text_chars("abc"); + chars[1].rect = Some(PdfRect::new(300.0, 688.0, 304.0, 700.0)); + app.document.as_mut().unwrap().text_chars[0] = Some(chars); + assert!(marked_text(&mark(0, 700.0), app.document.as_ref().unwrap()).is_none()); + } + + #[test] + fn foreign_only_digest_preserves_each_quad_and_rejects_stale_revision() { + let (mut app, ctx, _worker) = annotation_saving::tests::fixture(); + let mut chars = text_chars("First"); + let mut second = text_chars("Second"); + for ch in &mut second { + let rect = ch.rect.as_mut().unwrap(); + rect.top -= 30.0; + rect.bottom -= 30.0; + } + chars.extend(second); + app.document.as_mut().unwrap().text_chars[0] = Some(chars); + app.set_foreign_annotations_for_test(vec![ForeignAnnotation { + page_index: 0, + rects: vec![ + PdfRect::new(49.0, 687.0, 90.0, 701.0), + PdfRect::new(49.0, 657.0, 90.0, 671.0), + ], + precise_text_geometry: true, + kind: ForeignAnnotationKind::Highlight, + text: "External note".into(), + author: "Reader".into(), + color_rgb: [1.0, 1.0, 0.0], + key: "other".into(), + }]); + let _ = ctx.run(Default::default(), |ctx| app.start_annotation_digest(ctx)); + let draft = app.annotation_digest_draft.as_ref().unwrap(); + assert_eq!( + draft.entries[0].text, + "First\nSecond\nNote: External note\nAuthor: Reader" + ); + assert!(digest_markdown(draft).contains("Highlight (other app, read-only)")); + app.annotation_sessions.get_mut(&7).unwrap().revision.sha256 = "changed".into(); + let _ = ctx.run(Default::default(), |ctx| { + app.draw_annotation_digest_window(ctx) + }); + assert!(app.annotation_digest_draft.is_none()); + assert!(app.status.contains("changed")); + } + + #[test] + fn foreign_bounding_rectangle_does_not_guess_a_quotation() { + let (mut app, _ctx, _worker) = annotation_saving::tests::fixture(); + app.document.as_mut().unwrap().text_chars[0] = Some(text_chars("Unrelated text")); + let mark = ForeignAnnotation { + page_index: 0, + rects: vec![PdfRect::new(49.0, 687.0, 150.0, 701.0)], + precise_text_geometry: false, + kind: ForeignAnnotationKind::Highlight, + text: "Reader note".into(), + author: String::new(), + color_rgb: [1.0; 3], + key: "bbox".into(), + }; + let entries = digest_entries(&[], &[mark], app.document.as_ref().unwrap(), None); + assert_eq!( + entries[0].text, + "[Marked text unavailable or ambiguous.]\nNote: Reader note" + ); + } + + #[test] + fn digest_never_overwrites_the_source_pdf() { + let directory = tempfile::tempdir().unwrap(); + let source = directory.path().join("source.pdf"); + std::fs::write(&source, b"source").unwrap(); + assert!(!safe_destination(&source, &source)); + assert!(safe_destination( + &source, + &directory.path().join("notes.md") + )); + } +} diff --git a/src/app/annotation_list.rs b/src/app/annotation_list.rs new file mode 100644 index 0000000..468add6 --- /dev/null +++ b/src/app/annotation_list.rs @@ -0,0 +1,728 @@ +//! A page-ordered index of editable and read-only PDF annotations. +use super::*; + +struct AnnotationRow { + index: usize, + foreign: bool, + page: usize, + top: f32, + label: String, + color: Color32, +} + +pub(super) struct AnnotationListCache { + epoch: u64, + generation: u64, + annotation_count: usize, + foreign_marks: Option>>, + data: Arc, +} + +#[derive(Clone, Copy)] +enum ListItem { + Page(usize), + Annotation(usize), +} + +struct AnnotationListData { + rows: Vec, + items: Vec, +} + +fn short_text(text: &str) -> String { + short_chars(text.chars()) +} + +fn short_chars(chars: impl Iterator) -> String { + let mut result = String::new(); + let mut count = 0; + let mut pending_space = false; + let mut truncated = false; + for ch in chars { + if ch.is_whitespace() { + pending_space = !result.is_empty(); + continue; + } + if pending_space { + if count == 96 { + truncated = true; + break; + } + result.push(' '); + count += 1; + pending_space = false; + } + if count == 96 { + truncated = true; + break; + } + result.push(ch); + count += 1; + } + if truncated { + if result.ends_with(' ') { + result.pop(); + } + result.push('…'); + } + result +} + +fn marker_excerpt(annotation: &EditorAnnotation, document: Option<&LoadedDocument>) -> String { + let Some(chars) = document + .and_then(|document| document.text_chars.get(annotation.page_index)) + .and_then(Option::as_deref) + else { + return String::new(); + }; + let bounds = expand_pdf_rect(annotation.rect, 2.0); + let mut indices = chars.iter().enumerate().filter_map(|(index, character)| { + let rect = character.rect?; + let center = ( + (rect.left + rect.right) * 0.5, + (rect.bottom + rect.top) * 0.5, + ); + (!character.ch.is_control() && point_in_pdf_rect(center, bounds)).then_some(index) + }); + let Some(first) = indices.next() else { + return String::new(); + }; + let last = indices.last().unwrap_or(first); + short_chars(chars[first..=last].iter().map(|character| character.ch)) +} + +fn annotation_rows( + annotations: &[EditorAnnotation], + document: Option<&LoadedDocument>, +) -> Vec { + let mut rows = annotations + .iter() + .enumerate() + .map(|(index, annotation)| { + let (kind, text, color) = match &annotation.kind { + AnnotationKind::Marker { + style, color_rgb, .. + } => ( + match style { + MarkerStyle::Highlight => "Highlight", + MarkerStyle::Underline => "Underline", + }, + marker_excerpt(annotation, document), + color_from_rgb(*color_rgb, 255), + ), + AnnotationKind::TextBox { + text, color_rgb, .. + } => ( + "Text box", + short_text(text), + color_from_rgb(*color_rgb, 255), + ), + AnnotationKind::Comment { + text, color_rgb, .. + } => ("Comment", short_text(text), color_from_rgb(*color_rgb, 255)), + AnnotationKind::Signature { signer, .. } => ("Signature", short_text(signer), INK), + }; + let label = if text.is_empty() { + kind.to_owned() + } else { + format!("{kind} · {text}") + }; + AnnotationRow { + index, + foreign: false, + page: annotation.page_index, + top: annotation.rect.top, + label, + color, + } + }) + .collect::>(); + rows.sort_by(|a, b| { + a.page + .cmp(&b.page) + .then_with(|| b.top.total_cmp(&a.top)) + .then_with(|| a.index.cmp(&b.index)) + }); + rows +} + +impl PdfEditorApp { + fn annotation_list_rows(&mut self) -> Arc { + let foreign = self.foreign_annotations(); + if let Some(cache) = &self.annotation_list_cache + && cache.epoch == self.document_epoch + && cache.generation == self.annotation_save_generation + && cache.annotation_count == self.annotations.len() + && match (&cache.foreign_marks, &foreign) { + (Some(left), Some(right)) => Arc::ptr_eq(left, right), + (None, None) => true, + _ => false, + } + { + return Arc::clone(&cache.data); + } + let mut rows = annotation_rows(&self.annotations, self.document.as_ref()); + if let Some(marks) = &foreign { + for (index, mark) in marks.iter().enumerate() { + let Some(top) = mark + .rects + .iter() + .map(|rect| rect.top) + .max_by(f32::total_cmp) + else { + continue; + }; + let kind = match mark.kind { + ForeignAnnotationKind::Highlight => "Highlight", + ForeignAnnotationKind::Underline => "Underline", + ForeignAnnotationKind::Squiggly => "Squiggly underline", + ForeignAnnotationKind::StrikeOut => "Strikeout", + ForeignAnnotationKind::Comment => "Comment", + ForeignAnnotationKind::TextBox => "Text box", + }; + let text = short_text(&mark.text); + let label = if text.is_empty() { + format!("{kind} · other app") + } else { + format!("{kind} · other app · {text}") + }; + rows.push(AnnotationRow { + index, + foreign: true, + page: mark.page_index, + top, + label, + color: color_from_rgb(mark.color_rgb, 255), + }); + } + } + rows.sort_by(|a, b| { + a.page + .cmp(&b.page) + .then_with(|| b.top.total_cmp(&a.top)) + .then_with(|| a.foreign.cmp(&b.foreign)) + .then_with(|| a.index.cmp(&b.index)) + }); + let mut items = Vec::with_capacity(rows.len()); + let mut page = None; + for (index, row) in rows.iter().enumerate() { + if page != Some(row.page) { + page = Some(row.page); + items.push(ListItem::Page(row.page)); + } + items.push(ListItem::Annotation(index)); + } + let data = Arc::new(AnnotationListData { rows, items }); + self.annotation_list_cache = Some(AnnotationListCache { + epoch: self.document_epoch, + generation: self.annotation_save_generation, + annotation_count: self.annotations.len(), + foreign_marks: foreign, + data: Arc::clone(&data), + }); + data + } + + pub(super) fn draw_annotation_list(&mut self, ui: &mut egui::Ui) { + self.ensure_foreign_annotations(); + ui.heading("Annotations"); + ui.label( + RichText::new("Other apps' marks are read-only") + .size(11.0) + .color(self.app_palette().muted), + ); + let data = self.annotation_list_rows(); + let foreign_ready = self.foreign_annotations().is_some(); + if self.foreign_annotations_loading() { + ui.label("Loading other applications' marks…"); + } + if self.foreign_annotations_failed() { + ui.label("Other applications' marks could not be loaded."); + if ui.button("Retry loading marks").clicked() { + self.retry_foreign_annotations(); + } + } + if data.rows.is_empty() && foreign_ready { + ui.label(RichText::new("No annotations yet.").color(self.app_palette().muted)); + return; + } + if self.pending_annotation_digest.is_some() { + ui.label("Preparing annotation text…"); + if ui.button("Cancel digest preparation").clicked() { + self.pending_annotation_digest = None; + self.status = "Annotation digest preparation canceled.".to_owned(); + } + } else if ui + .add_enabled( + foreign_ready && !data.rows.is_empty(), + egui::Button::new("Export annotation digest…"), + ) + .clicked() + { + self.start_annotation_digest(ui.ctx()); + } + if self.annotations.iter().any(|annotation| matches!(&annotation.kind, + AnnotationKind::Comment { author_id, .. } if !author_id.is_empty() && *author_id == self.settings.comment_author_id)) + && ui.link("Edit my comment name").clicked() { + self.open_comment_profile(); + } + let mut clicked = None; + let draw_item = + |ui: &mut egui::Ui, item: ListItem, clicked: &mut Option<(bool, usize)>| match item { + ListItem::Page(page) => { + ui.label(RichText::new(format!("Page {}", page + 1)).strong()); + } + ListItem::Annotation(index) => { + let row = &data.rows[index]; + ui.horizontal(|ui| { + let (swatch, _) = ui.allocate_exact_size(Vec2::splat(10.0), Sense::hover()); + ui.painter().rect_filled(swatch, 2, row.color); + ui.painter().rect_stroke(swatch, 2, Stroke::new(1.0_f32, theme::for_ui(ui).muted), egui::StrokeKind::Inside); + if ui + .add(egui::Button::new(&row.label).frame(false).truncate()) + .on_hover_text(&row.label) + .on_hover_cursor(CursorIcon::PointingHand) + .clicked() + { + *clicked = Some((row.foreign, row.index)); + } + }); + } + }; + let accesskit_active = ui.ctx().accesskit_node_builder(ui.id(), |_| ()).is_some(); + let scroll = egui::ScrollArea::vertical() + .id_salt(("annotation-list", self.document_epoch)) + .max_height(360.0); + if accesskit_active || ui.ctx().memory(|memory| memory.options.screen_reader) { + scroll.show(ui, |ui| { + for item in data.items.iter().copied() { + draw_item(ui, item, &mut clicked); + } + }); + } else { + let row_height = ui.spacing().interact_size.y.max(20.0); + scroll.show_rows(ui, row_height, data.items.len(), |ui, range| { + for index in range { + draw_item(ui, data.items[index], &mut clicked); + } + }); + } + if let Some((foreign, index)) = clicked { + if foreign { + self.jump_to_foreign_annotation(index, ui.ctx()); + } else { + self.jump_to_annotation(index, ui.ctx()); + } + } + } + + fn jump_to_foreign_annotation(&mut self, index: usize, ctx: &Context) { + let Some(mark) = self + .foreign_annotations() + .and_then(|marks| marks.get(index).cloned()) + else { + return; + }; + let Some(page_height) = self + .document + .as_ref() + .and_then(|document| document.pages.get(mark.page_index)) + .map(|page| page.height) + else { + return; + }; + let Some(top) = mark + .rects + .iter() + .map(|rect| rect.top) + .max_by(f32::total_cmp) + else { + return; + }; + self.record_jump_origin(); + self.set_view_mode(DocumentViewMode::Pdf, ctx); + self.go_to_page(mark.page_index); + if page_height.is_finite() && page_height > 0.0 && top.is_finite() { + self.set_pdf_destination_fraction( + self.page_index, + Some((1.0 - top / page_height).clamp(0.0, 1.0)), + ); + } + self.status = "Other application's annotation (read-only).".to_owned(); + ctx.request_repaint(); + } + + fn jump_to_annotation(&mut self, index: usize, ctx: &Context) { + let Some(annotation) = self.annotations.get(index).cloned() else { + return; + }; + let Some(page_height) = self + .document + .as_ref() + .and_then(|document| document.pages.get(annotation.page_index)) + .map(|page| page.height) + else { + return; + }; + let top = match &annotation.kind { + AnnotationKind::Comment { anchor, .. } => anchor.1, + _ => annotation.rect.top, + }; + self.record_jump_origin(); + self.set_view_mode(DocumentViewMode::Pdf, ctx); + self.go_to_page(annotation.page_index); + if page_height.is_finite() && page_height > 0.0 && top.is_finite() { + self.set_pdf_destination_fraction( + self.page_index, + Some((1.0 - top / page_height).clamp(0.0, 1.0)), + ); + } + match annotation.kind { + AnnotationKind::TextBox { .. } => self.select_text_box(index), + AnnotationKind::Comment { .. } => self.start_comment_edit(index), + _ => {} + } + ctx.request_repaint(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::PageInfo; + + fn marker(page: usize, top: f32, style: MarkerStyle) -> EditorAnnotation { + EditorAnnotation { + page_index: page, + rect: PdfRect::new(50.0, top - 12.0, 150.0, top), + kind: AnnotationKind::Marker { + color_rgb: [1.0, 0.9, 0.2], + opacity: 0.4, + style, + }, + } + } + + #[test] + fn annotations_are_grouped_by_page_and_sorted_in_reading_order() { + let annotations = vec![ + marker(2, 100.0, MarkerStyle::Underline), + marker(0, 300.0, MarkerStyle::Highlight), + marker(0, 600.0, MarkerStyle::Underline), + ]; + let rows = annotation_rows(&annotations, None); + assert_eq!( + rows.iter().map(|row| row.index).collect::>(), + vec![2, 1, 0] + ); + assert_eq!( + rows.iter() + .map(|row| row.label.as_str()) + .collect::>(), + vec!["Underline", "Highlight", "Underline"] + ); + } + + #[test] + fn highlight_preview_uses_only_characters_inside_the_marker() { + let (mut app, _ctx, _worker) = annotation_saving::tests::fixture(); + let chars = "Read me outside" + .chars() + .enumerate() + .map(|(index, ch)| PageTextChar { + ch, + rect: Some(PdfRect::new( + 50.0 + index as f32 * 6.0, + 688.0, + 55.0 + index as f32 * 6.0, + 700.0, + )), + font_size: Some(12.0), + bold: false, + italic: false, + }) + .collect(); + app.document.as_mut().unwrap().text_chars[0] = Some(chars); + let mut highlight = marker(0, 700.0, MarkerStyle::Highlight); + highlight.rect.right = 90.0; + assert_eq!(marker_excerpt(&highlight, app.document.as_ref()), "Read me"); + } + + #[test] + fn list_cache_refreshes_after_annotation_changes() { + let (mut app, _ctx, _worker) = annotation_saving::tests::fixture(); + app.annotations + .push(marker(0, 700.0, MarkerStyle::Highlight)); + let first = app.annotation_list_rows(); + assert!(Arc::ptr_eq(&first, &app.annotation_list_rows())); + app.annotations[0].kind = AnnotationKind::Marker { + color_rgb: [1.0, 0.9, 0.2], + opacity: 0.4, + style: MarkerStyle::Underline, + }; + app.annotation_save_generation += 1; + let updated = app.annotation_list_rows(); + assert!(!Arc::ptr_eq(&first, &updated)); + assert_eq!(updated.rows[0].label, "Underline"); + } + + #[test] + fn foreign_marks_are_listed_and_navigate_without_becoming_editable() { + let (mut app, ctx, _worker) = annotation_saving::tests::fixture(); + let document = app.document.as_mut().unwrap(); + document.page_count = 2; + document.pages = vec![PageInfo::new(612.0, 792.0); 2]; + document.text_chars = vec![None; 2]; + app.set_foreign_annotations_for_test(vec![ForeignAnnotation { + page_index: 1, + rects: vec![PdfRect::new(50.0, 580.0, 100.0, 600.0)], + precise_text_geometry: false, + kind: ForeignAnnotationKind::Comment, + text: "External note".into(), + author: "Reader".into(), + color_rgb: [1.0, 1.0, 0.0], + key: "foreign".into(), + }]); + let rows = app.annotation_list_rows(); + assert_eq!(rows.rows[0].label, "Comment · other app · External note"); + assert!(rows.rows[0].foreign); + let _ = ctx.run(Default::default(), |ctx| { + app.jump_to_foreign_annotation(0, ctx) + }); + assert_eq!(app.page_index, 1); + assert!(app.annotations.is_empty()); + assert!(app.editing_comment.is_none()); + assert!(app.status.contains("read-only")); + } + + #[test] + fn list_preview_refreshes_when_page_text_arrives() { + let (mut app, ctx, _worker) = annotation_saving::tests::fixture(); + let path = app.document.as_ref().unwrap().path.clone(); + app.annotations + .push(marker(0, 700.0, MarkerStyle::Highlight)); + assert_eq!(app.annotation_list_rows().rows[0].label, "Highlight"); + let chars = "Word" + .chars() + .enumerate() + .map(|(index, ch)| PageTextChar { + ch, + rect: Some(PdfRect::new( + 50.0 + index as f32 * 6.0, + 688.0, + 55.0 + index as f32 * 6.0, + 700.0, + )), + font_size: Some(12.0), + bold: false, + italic: false, + }) + .collect(); + let (sender, receiver) = crossbeam_channel::unbounded(); + app.render_rx = receiver; + sender + .send(RenderEvent::TextChars { + document_epoch: 7, + path, + page_index: 0, + result: Ok(chars), + }) + .unwrap(); + let _ = ctx.run(Default::default(), |ctx| app.poll_render_results(ctx)); + assert_eq!(app.annotation_list_rows().rows[0].label, "Highlight · Word"); + } + + #[test] + fn clicking_an_annotation_jumps_to_its_location_and_records_history() { + fn text_center(shape: &egui::Shape, text: &str) -> Option { + match shape { + egui::Shape::Text(shape) if shape.galley.text() == text => { + Some(shape.pos + shape.galley.size() * 0.5) + } + egui::Shape::Vec(shapes) => { + shapes.iter().find_map(|shape| text_center(shape, text)) + } + _ => None, + } + } + let (mut app, ctx, _worker) = annotation_saving::tests::fixture(); + let document = app.document.as_mut().unwrap(); + document.page_count = 3; + document.pages = vec![PageInfo::new(612.0, 792.0); 3]; + document.text_chars = vec![None; 3]; + app.annotations = vec![marker(2, 600.0, MarkerStyle::Underline)]; + app.scroll_target_page = None; + let draw = |app: &mut PdfEditorApp, time, events| { + let input = egui::RawInput { + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, Vec2::new(400.0, 500.0))), + time: Some(time), + events, + ..Default::default() + }; + ctx.run(input, |ctx| { + egui::CentralPanel::default().show(ctx, |ui| app.draw_annotation_list(ui)); + }) + }; + let output = draw(&mut app, 0.0, vec![]); + let point = output + .shapes + .iter() + .find_map(|shape| text_center(&shape.shape, "Underline")) + .expect("annotation control is visible"); + draw(&mut app, 0.1, vec![egui::Event::PointerMoved(point)]); + for (time, pressed) in [(0.2, true), (0.3, false)] { + draw( + &mut app, + time, + vec![egui::Event::PointerButton { + pos: point, + button: egui::PointerButton::Primary, + pressed, + modifiers: egui::Modifiers::NONE, + }], + ); + } + assert_eq!(app.page_index, 2); + let (page, fraction) = app.pending_resume_fraction.unwrap(); + assert_eq!(page, 2); + assert!((fraction - (1.0 - 600.0 / 792.0)).abs() < 0.001); + let _ = ctx.run(Default::default(), |ctx| { + app.navigate_jump_history(ctx, false) + }); + assert_eq!(app.page_index, 0); + } + + #[test] + fn annotation_jump_positions_the_active_side_by_side_pane() { + let (mut app, ctx, _worker) = annotation_saving::tests::fixture(); + let document = app.document.as_mut().unwrap(); + document.page_count = 4; + document.pages = vec![PageInfo::new(612.0, 792.0); 4]; + document.text_chars = vec![None; 4]; + document.native_text = vec![String::new(); 4]; + document.native_text_loaded = vec![true; 4]; + document.links = vec![vec![]; 4]; + let document = document.clone(); + app.tabs[0] = app.active_tab_snapshot(document.clone()); + let mut other = app.active_tab_snapshot(document); + other.document_epoch = 8; + app.tabs.push(other); + app.sbs_mode = Some(SbsModeState { + left_epoch: 7, + right_epoch: 8, + }); + app.annotations = vec![marker(2, 600.0, MarkerStyle::Underline)]; + let draw = |app: &mut PdfEditorApp, time| { + let input = egui::RawInput { + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, Vec2::new(1200.0, 700.0))), + time: Some(time), + ..Default::default() + }; + let _ = ctx.run(input, |ctx| { + egui::CentralPanel::default().show(ctx, |ui| app.draw_sbs_document(ui, ctx)); + }); + }; + draw(&mut app, 0.0); + let _ = ctx.run(Default::default(), |ctx| app.jump_to_annotation(0, ctx)); + let expected = 1.0 - 600.0 / 792.0; + assert_eq!( + app.sbs_pane_states[&7].destination_fraction, + Some((2, expected)) + ); + assert!(app.pending_resume_fraction.is_none()); + for step in 1..20 { + draw(&mut app, step as f64 * 0.1); + } + let range = app + .visible_page_ranges + .iter() + .find(|range| range.page_index == 2) + .unwrap(); + assert!( + (range.top_fraction - expected).abs() < 0.03, + "landed at {}", + range.top_fraction + ); + let _ = ctx.run(Default::default(), |ctx| { + app.navigate_jump_history(ctx, false) + }); + assert_eq!(app.page_index, 0); + } + + #[test] + fn accessibility_tree_can_activate_an_offscreen_annotation() { + let (mut app, ctx, _worker) = annotation_saving::tests::fixture(); + let document = app.document.as_mut().unwrap(); + document.page_count = 100; + document.pages = vec![PageInfo::new(612.0, 792.0); 100]; + document.text_chars = vec![None; 100]; + app.annotations = (0..100) + .map(|page| EditorAnnotation { + page_index: page, + rect: PdfRect::new(50.0, 500.0, 150.0, 520.0), + kind: AnnotationKind::TextBox { + text: format!("Unique item {page}"), + font_size: 12.0, + color_rgb: [0.0, 0.0, 0.0], + }, + }) + .collect(); + ctx.enable_accesskit(); + let input = egui::RawInput { + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, Vec2::new(400.0, 500.0))), + ..Default::default() + }; + let output = ctx.run(input, |ctx| { + egui::CentralPanel::default().show(ctx, |ui| app.draw_annotation_list(ui)); + }); + let tree = output.platform_output.accesskit_update.unwrap(); + let target = tree + .nodes + .iter() + .find_map(|(id, node)| { + (node.label() == Some("Text box · Unique item 99")).then_some(*id) + }) + .expect("distant annotation control"); + let input = egui::RawInput { + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, Vec2::new(400.0, 500.0))), + events: vec![egui::Event::AccessKitActionRequest( + egui::accesskit::ActionRequest { + action: egui::accesskit::Action::Click, + target, + data: None, + }, + )], + ..Default::default() + }; + let _ = ctx.run(input, |ctx| { + egui::CentralPanel::default().show(ctx, |ui| app.draw_annotation_list(ui)); + }); + assert_eq!(app.page_index, 99); + assert_eq!(app.selected_text_box, Some(99)); + } + + #[test] + fn jumping_to_a_comment_opens_it_without_changing_its_text() { + let (mut app, ctx, _worker) = annotation_saving::tests::fixture(); + let comment = EditorAnnotation { + page_index: 0, + rect: PdfRect::new(510.0, 500.0, 560.0, 540.0), + kind: AnnotationKind::Comment { + id: "comment-1".into(), + text: "A note to keep".into(), + author: "Reader".into(), + author_id: "reader-1".into(), + color_rgb: [1.0, 0.9, 0.2], + created_at: String::new(), + updated_at: String::new(), + anchor: (100.0, 620.0), + }, + }; + app.annotations.push(comment.clone()); + let _ = ctx.run(Default::default(), |ctx| app.jump_to_annotation(0, ctx)); + assert_eq!(app.editing_comment, Some(0)); + assert_eq!(app.annotations[0], comment); + let (_, fraction) = app.pending_resume_fraction.unwrap(); + assert!((fraction - (1.0 - 620.0 / 792.0)).abs() < 0.001); + } +} diff --git a/src/app/annotation_saving.rs b/src/app/annotation_saving.rs index 627ca6a..720c2c1 100644 --- a/src/app/annotation_saving.rs +++ b/src/app/annotation_saving.rs @@ -164,14 +164,15 @@ impl PdfEditorApp { Ok(()) => { self.active_annotation_saves.insert(path, save); self.status = "Saving annotations…".to_owned(); - ctx.request_repaint_after(RENDER_POLL_INTERVAL); + ctx.request_repaint(); } } } if let Some(next) = self .pending_annotation_saves - .values() - .map(|save| save.due_at) + .iter() + .filter(|(path, _)| !self.active_annotation_saves.contains_key(*path)) + .map(|(_, save)| save.due_at) .min() { ctx.request_repaint_after(next.saturating_duration_since(now)); @@ -235,6 +236,7 @@ impl PdfEditorApp { { self.annotations_dirty = false; self.status = "All annotations saved to PDF.".to_owned(); + self.chrome.confirm_save(epoch); } if let Some(warning) = report.recovery_warning { self.push_error_notice(format!( @@ -242,11 +244,44 @@ impl PdfEditorApp { )); } } - Err(error) => self.push_error_notice(format!( - "Could not save annotations to {}: {error}. Your changes are still unsaved.", - path.display() - )), + Err(error) => self.push_retry_save_notice( + format!( + "Could not save annotations to {}: {error}. Your changes are still unsaved.", + path.display() + ), + epoch, + path, + ), + } + ctx.request_repaint(); + } + + /// Retry only the still-open document and its latest edits, never the + /// potentially stale snapshot that failed earlier. + pub(super) fn retry_annotation_save(&mut self, epoch: u64, path: PathBuf, ctx: &Context) { + self.save_active_tab_state(); + let Some(tab) = self + .tabs + .iter() + .find(|tab| tab.document_epoch == epoch && tab.document.path == path) + else { + self.push_warning_notice( + "That PDF is no longer open. Check its recovery copy before trying again.", + ); + return; + }; + if !tab.annotations_dirty { + self.push_info_notice("The current annotations are already saved."); + return; + } + let annotations = tab.annotations.clone(); + if let Err(error) = self.queue_annotation_save(epoch, path, annotations) { + self.push_error_notice(format!( + "Could not retry saving: {error}. Your changes remain unsaved." + )); + return; } + self.start_due_annotation_saves(ctx); ctx.request_repaint(); } @@ -351,6 +386,85 @@ pub(super) mod tests { } } + #[test] + fn pending_save_waits_for_active_save_without_repainting_and_then_runs() { + let (mut app, ctx, requests) = fixture(); + let path = app.document.as_ref().unwrap().path.clone(); + let first = PendingAnnotationSave { + document_epoch: 7, + path: path.clone(), + generation: 1, + annotations: Vec::new(), + due_at: Instant::now(), + }; + let mut second = first.clone(); + second.generation = 2; + app.active_annotation_saves + .insert(path.clone(), first.clone()); + app.pending_annotation_saves.insert(path.clone(), second); + for frame in 0..8 { + let output = ctx.run( + egui::RawInput { + time: Some(frame as f64 * 0.2), + ..Default::default() + }, + |ctx| app.start_due_annotation_saves(ctx), + ); + if frame >= 4 { + assert_eq!( + output.viewport_output[&egui::ViewportId::ROOT].repaint_delay, + Duration::MAX + ); + } + } + assert!(requests.try_recv().is_err()); + assert!(app.pending_annotation_saves.contains_key(&path)); + let _ = ctx.run(Default::default(), |ctx| { + app.finish_annotation_autosave( + 7, + path.clone(), + first.generation, + Ok(SaveReport::default()), + ctx, + ); + app.start_due_annotation_saves(ctx); + }); + assert!(matches!( + requests.try_recv().unwrap(), + RenderRequest::AutosaveAnnotations { generation: 2, .. } + )); + } + + #[test] + fn worker_failure_keeps_unsaved_and_pending_annotations() { + let (mut app, ctx, _worker) = fixture(); + let path = app.document.as_ref().unwrap().path.clone(); + let active = PendingAnnotationSave { + document_epoch: 7, + path: path.clone(), + generation: 1, + annotations: Vec::new(), + due_at: Instant::now(), + }; + let mut pending = active.clone(); + pending.generation = 2; + app.annotations_dirty = true; + app.active_annotation_saves.insert(path.clone(), active); + app.pending_annotation_saves.insert(path.clone(), pending); + let (sender, receiver) = unbounded(); + app.render_rx = receiver; + sender.send(RenderEvent::WorkerStopped).unwrap(); + let _ = ctx.run(Default::default(), |ctx| app.poll_render_results(ctx)); + assert!(app.annotations_dirty); + assert!(app.active_annotation_saves.is_empty()); + assert_eq!(app.pending_annotation_saves[&path].generation, 2); + assert!(app.notices.iter().any(|notice| { + notice + .message + .contains("worker stopped before confirming the save") + })); + } + #[test] fn text_box_creation_edit_and_deletion_all_queue_complete_snapshots() { let (mut app, ctx, rx) = fixture(); @@ -443,6 +557,96 @@ pub(super) mod tests { assert!(!app.tabs[0].annotations_dirty); } + #[test] + fn retry_button_saves_latest_edits_after_a_failed_autosave() { + let (mut app, ctx, rx) = fixture(); + app.add_text_box_annotation(0, PdfRect::new(10.0, 20.0, 200.0, 60.0)); + let failed = start_save(&mut app, &ctx, &rx); + app.add_text_box_annotation(0, PdfRect::new(10.0, 80.0, 200.0, 100.0)); + app.finish_annotation_autosave( + 7, + failed.path.clone(), + failed.generation, + Err("File temporarily unavailable".into()), + &ctx, + ); + assert!(app.annotations_dirty); + ctx.enable_accesskit(); + let screen = Rect::from_min_size(Pos2::ZERO, Vec2::new(900.0, 700.0)); + let output = ctx.run( + egui::RawInput { + screen_rect: Some(screen), + ..Default::default() + }, + |ctx| { + app.draw_notices(ctx); + }, + ); + let tree = output.platform_output.accesskit_update.unwrap(); + let target = tree + .nodes + .iter() + .find_map(|(id, node)| (node.label() == Some("Retry saving")).then_some(*id)) + .expect("accessible retry action"); + let input = egui::RawInput { + screen_rect: Some(screen), + events: vec![egui::Event::AccessKitActionRequest( + egui::accesskit::ActionRequest { + action: egui::accesskit::Action::Click, + target, + data: None, + }, + )], + ..Default::default() + }; + let _ = ctx.run(input, |ctx| app.draw_notices(ctx)); + match rx.try_recv().expect("retried autosave") { + RenderRequest::AutosaveAnnotations { + document_epoch, + path, + annotations, + .. + } => { + assert_eq!(document_epoch, 7); + assert_eq!(path, failed.path); + assert_eq!(annotations.len(), 2); + } + _ => panic!("retry did not request an annotation save"), + } + assert!(app.annotations_dirty); + assert_eq!( + std::fs::read(&failed.path).unwrap(), + b"initial test document" + ); + } + + #[test] + fn stale_retry_does_not_save_a_closed_or_already_saved_document() { + let (mut app, ctx, rx) = fixture(); + let path = app.document.as_ref().unwrap().path.clone(); + app.retry_annotation_save(7, path.clone(), &ctx); + assert!(rx.try_recv().is_err()); + assert!( + app.notices + .back() + .unwrap() + .message + .contains("already saved") + ); + app.tabs.clear(); + app.document = None; + app.active_tab = None; + app.retry_annotation_save(7, path, &ctx); + assert!(rx.try_recv().is_err()); + assert!( + app.notices + .back() + .unwrap() + .message + .contains("no longer open") + ); + } + #[test] fn closing_waits_for_autosave_and_failed_save_keeps_changes_open() { let (mut app, ctx, rx) = fixture(); diff --git a/src/app/chat_ui.rs b/src/app/chat_ui.rs index 347ca27..c60b56b 100644 --- a/src/app/chat_ui.rs +++ b/src/app/chat_ui.rs @@ -139,7 +139,7 @@ impl PdfEditorApp { visible_messages: self.chat_ui.state.messages.clone(), document_context: self.chat_ui.state.document_context.clone(), }, - self.chat_ui.tx.clone(), + worker_waking_sender(self.chat_ui.tx.clone(), ctx.clone()), ); self.status = "Chat request sent.".to_owned(); } @@ -241,12 +241,12 @@ impl PdfEditorApp { pub(super) fn draw_chat_tab(&mut self, ui: &mut egui::Ui) { let has_document = self.document.is_some(); if !has_document { - ui.label(RichText::new("No PDF loaded").color(MUTED_INK)); + ui.label(RichText::new("No PDF loaded").color(self.app_palette().muted)); return; } ui.horizontal(|ui| { - ui.label(RichText::new("Model").color(INK)); + ui.label(RichText::new("Model").color(self.app_palette().ink)); let selected = CHAT_MODELS .get(self.chat_ui.state.model_index) .unwrap_or(&CHAT_MODELS[0]); @@ -275,9 +275,9 @@ impl PdfEditorApp { } else { format!("PDF text available: {context_pages} page(s), ~{context_estimate} tokens") }; - ui.label(RichText::new(context_status).color(MUTED_INK)); + ui.label(RichText::new(context_status).color(self.app_palette().muted)); if let Some(warning) = self.chat_ui.state.context_warning.as_ref() { - ui.label(RichText::new(warning).color(Color32::from_rgb(134, 92, 34))); + ui.label(RichText::new(warning).color(ui.visuals().warn_fg_color)); } ui.horizontal(|ui| { @@ -294,7 +294,7 @@ impl PdfEditorApp { }; } if self.chat_ui.state.in_flight { - ui.spinner(); + motion::busy(ui, self.settings.reduce_motion); } }); @@ -305,16 +305,20 @@ impl PdfEditorApp { .stick_to_bottom(true) .show(ui, |ui| { if self.chat_ui.state.messages.is_empty() { - ui.label(RichText::new("Ask a question about this PDF.").color(MUTED_INK)); + ui.label(RichText::new("Ask a question about this PDF.").color(self.app_palette().muted)); } for message in &self.chat_ui.state.messages { let (label, color) = match message.role { - ChatRole::User => ("You", Color32::from_rgb(93, 68, 37)), - ChatRole::Assistant => ("LawPDF", INK), + ChatRole::User => ("You", self.app_palette().accent), + ChatRole::Assistant => ("LawPDF", self.app_palette().ink), }; ui.label(RichText::new(label).strong().color(color)); egui::Frame::NONE - .fill(if message.role == ChatRole::User { + .fill(if ui.visuals().dark_mode && message.role == ChatRole::User { + self.app_palette().hover + } else if ui.visuals().dark_mode { + self.app_palette().bar + } else if message.role == ChatRole::User { Color32::from_rgb(247, 243, 235) } else { Color32::from_rgb(255, 254, 250) @@ -325,7 +329,7 @@ impl PdfEditorApp { .show(ui, |ui| { ui.add( egui::Label::new( - RichText::new(&message.content).size(14.0).color(INK), + RichText::new(&message.content).size(14.0).color(self.app_palette().ink), ) .wrap(), ); diff --git a/src/app/chrome.rs b/src/app/chrome.rs index d2a9c4b..8483b1f 100644 --- a/src/app/chrome.rs +++ b/src/app/chrome.rs @@ -13,7 +13,6 @@ use crate::review_masthead::{MASTHEAD_PAGES_TO_READ, MastheadIdentity, masthead_ pub(super) const NAVY: Color32 = Color32::from_rgb(28, 38, 56); pub(super) const NAVY_TEXT: Color32 = Color32::from_rgb(233, 226, 210); pub(super) const NAVY_MUTED: Color32 = Color32::from_rgb(207, 199, 182); -pub(super) const IVORY: Color32 = Color32::from_rgb(251, 248, 241); pub(super) const BRASS: Color32 = Color32::from_rgb(199, 165, 116); pub(super) const OXBLOOD: Color32 = Color32::from_rgb(122, 31, 43); pub(super) const RULE: Color32 = Color32::from_rgb(217, 207, 189); @@ -37,6 +36,8 @@ pub(super) struct ChromeState { markup_bar_drag: Option, last_status: String, status_changed_at: Option, + /// The check is animated only after this document's confirmed save. + save_confirmation: Option<(u64, Instant)>, /// Review note shown in full, keyed as in `ReviewMarginEntry::key`. pub(super) expanded_note: Option, /// Note under the pointer last frame, so callout and note light up together. @@ -45,6 +46,37 @@ pub(super) struct ChromeState { pub(super) margin_notes_live: bool, } +impl ChromeState { + pub(super) fn confirm_save(&mut self, epoch: u64) { + self.save_confirmation = Some((epoch, Instant::now())); + } +} + +fn draw_save_check(painter: &egui::Painter, center: Pos2, progress: f32) { + let start = center + Vec2::new(-4.5, 0.0); + let corner = center + Vec2::new(-1.5, 3.5); + let end = center + Vec2::new(5.0, -4.0); + let first = start.distance(corner); + let second = corner.distance(end); + let length = progress.clamp(0.0, 1.0) * (first + second); + let stroke = Stroke::new(1.8_f32, BRASS); + if length > 0.0 { + painter.line_segment( + [start, start.lerp(corner, (length / first).min(1.0))], + stroke, + ); + } + if length > first { + painter.line_segment( + [ + corner, + corner.lerp(end, ((length - first) / second).min(1.0)), + ], + stroke, + ); + } +} + #[derive(Debug, Clone, PartialEq, Eq)] struct IdentityKey { epoch: u64, @@ -103,7 +135,11 @@ fn small_caps_button( let (rect, response) = ui.allocate_exact_size(size, Sense::click()); if response.hovered() && !active { ui.painter() - .rect_filled(rect, 4.0, Color32::from_rgba_unmultiplied(42, 38, 32, 12)); + .rect_filled(rect, 4.0, if ui.visuals().dark_mode { + Color32::from_white_alpha(20) + } else { + Color32::from_rgba_unmultiplied(42, 38, 32, 12) + }); } let text_pos = rect.center() - galley.size() * 0.5; ui.painter().galley(text_pos, galley, color); @@ -119,7 +155,7 @@ fn small_caps_button( } impl PdfEditorApp { - fn masthead_identity_for_view(&mut self) -> MastheadIdentity { + pub(super) fn masthead_identity_for_view(&mut self) -> MastheadIdentity { let Some(document) = self.document.as_ref() else { return MastheadIdentity { title: "LawPDF".to_owned(), @@ -218,7 +254,7 @@ impl PdfEditorApp { self.request_masthead_text(); let identity = self.masthead_identity_for_view(); egui::TopBottomPanel::top("masthead") - .frame(egui::Frame::NONE.fill(IVORY)) + .frame(egui::Frame::NONE.fill(self.app_palette().bar)) .show_separator_line(false) .show(ctx, |ui| { quiet_buttons(ui); @@ -260,7 +296,13 @@ impl PdfEditorApp { let mut switch_to = None; let mut close_tab = None; + let mut close_others = None; + let mut copy_path = None; + let mut reveal_path = None; + let mut drop_tab = None; + let mut tab_rects = Vec::new(); let mut open_new = false; + let tab_width = ((row.width() - 220.0) / self.tabs.len().max(1) as f32).clamp(84.0, 250.0); ui.scope_builder( egui::UiBuilder::new() .max_rect(row.shrink2(Vec2::new(14.0, 0.0))) @@ -284,10 +326,20 @@ impl PdfEditorApp { NAVY_TEXT, ); for index in 0..self.tabs.len() { - let action = self.draw_tab(ui, index); + let epoch = self.tabs[index].document_epoch; + let (action, rect) = ui + .push_id(("document-tab", epoch), |ui| { + self.draw_tab(ui, index, tab_width) + }) + .inner; + tab_rects.push(rect); match action { TabAction::Switch => switch_to = Some(index), TabAction::Close => close_tab = Some(index), + TabAction::CloseOthers => close_others = Some(epoch), + TabAction::CopyPath => copy_path = Some(index), + TabAction::RevealPath => reveal_path = Some(index), + TabAction::Drop => drop_tab = Some(index), TabAction::None => {} } } @@ -323,12 +375,42 @@ impl PdfEditorApp { } if let Some(index) = close_tab { self.close_tab(index, ctx); + } else if let Some(epoch) = close_others { + self.close_other_tabs(epoch, ctx); + } else if let Some(index) = copy_path { + ctx.copy_text(self.tabs[index].document.path.display().to_string()); + self.status = "Copied document path.".to_owned(); + } else if let Some(index) = reveal_path { + if let Err(error) = reveal_document_in_file_manager(&self.tabs[index].document.path) { + self.push_error_notice(error); + } + } else if let Some(index) = drop_tab { + if let Some(target) = ctx + .input(|input| input.pointer.latest_pos()) + .and_then(|pos| tab_rects.iter().position(|rect| rect.contains(pos))) + { + self.reorder_tab(index, target); + } } else if let Some(index) = switch_to { self.switch_to_tab(index, ctx); } } - fn draw_tab(&mut self, ui: &mut egui::Ui, index: usize) -> TabAction { + fn reorder_tab(&mut self, source: usize, target: usize) { + if source >= self.tabs.len() || target >= self.tabs.len() || source == target { + return; + } + let active = self + .active_tab + .and_then(|index| self.tabs.get(index)) + .map(|tab| tab.document_epoch); + let tab = self.tabs.remove(source); + self.tabs.insert(target, tab); + self.active_tab = + active.and_then(|epoch| self.tabs.iter().position(|tab| tab.document_epoch == epoch)); + } + + fn draw_tab(&mut self, ui: &mut egui::Ui, index: usize, max_width: f32) -> (TabAction, Rect) { let tab = &self.tabs[index]; let is_active = self.active_tab == Some(index); let dirty = if is_active { @@ -347,25 +429,31 @@ impl PdfEditorApp { }); let full_title = tab.title(); let path = tab.document.path.display().to_string(); - const MAX_TAB_CHARS: usize = 30; - let mut title = full_title.clone(); - if title.chars().count() > MAX_TAB_CHARS { - title = format!( - "{}…", - title.chars().take(MAX_TAB_CHARS - 1).collect::() - ); - } - if let Some(side) = sbs_side { - title = format!("{title} · {side}"); - } - let text_color = if is_active { INK } else { NAVY_MUTED }; - let galley = ui - .fonts_mut(|fonts| fonts.layout_no_wrap(title, FontId::proportional(14.5), text_color)); + let text_color = if is_active { self.app_palette().ink } else { NAVY_MUTED }; let dot = if dirty || is_active { 13.0 } else { 0.0 }; - let size = Vec2::new(galley.size().x + dot + 16.0 + 26.0, 32.0); - let (rect, response) = ui.allocate_exact_size(size, Sense::click()); + let side_galley = sbs_side.map(|side| { + ui.fonts_mut(|fonts| { + fonts.layout_no_wrap( + format!(" · {side}"), + FontId::proportional(14.5), + text_color, + ) + }) + }); + let side_width = side_galley.as_ref().map_or(0.0, |galley| galley.size().x); + let mut job = LayoutJob::simple_singleline( + full_title.clone(), + FontId::proportional(14.5), + text_color, + ); + job.wrap.max_width = (max_width - dot - side_width - 42.0).max(30.0); + job.wrap.max_rows = 1; + job.wrap.break_anywhere = true; + let galley = ui.fonts_mut(|fonts| fonts.layout_job(job)); + let size = Vec2::new(galley.size().x + side_width + dot + 16.0 + 26.0, 32.0); + let (rect, response) = ui.allocate_exact_size(size, Sense::click_and_drag()); let fill = if is_active { - IVORY + self.app_palette().bar } else if response.hovered() { Color32::from_rgba_unmultiplied(255, 255, 255, 16) } else { @@ -383,16 +471,24 @@ impl PdfEditorApp { ); let mut x = rect.left() + 12.0; if dot > 0.0 { - let dot_color = if dirty { OXBLOOD } else { BRASS }; + let dot_color = if dirty { self.app_palette().accent } else { BRASS }; ui.painter() .circle_filled(Pos2::new(x + 3.5, rect.center().y), 3.5, dot_color); x += dot; } + let title_width = galley.size().x; ui.painter().galley( Pos2::new(x, rect.center().y - galley.size().y * 0.5), galley, text_color, ); + if let Some(side) = side_galley { + ui.painter().galley( + Pos2::new(x + title_width, rect.center().y - side.size().y * 0.5), + side, + text_color, + ); + } let close_rect = Rect::from_center_size( Pos2::new(rect.right() - 15.0, rect.center().y), Vec2::splat(18.0), @@ -402,14 +498,14 @@ impl PdfEditorApp { ui.painter().circle_filled( close_rect.center(), 9.0, - if is_active { + if is_active && !ui.visuals().dark_mode { Color32::from_rgba_unmultiplied(42, 38, 32, 22) } else { Color32::from_rgba_unmultiplied(255, 255, 255, 30) }, ); } - let cross = if is_active { MUTED_INK } else { NAVY_MUTED }; + let cross = if is_active { self.app_palette().muted } else { NAVY_MUTED }; let c = close_rect.center(); for (a, b) in [(-3.5, 3.5), (3.5, -3.5)] { ui.painter().line_segment( @@ -428,6 +524,21 @@ impl PdfEditorApp { response.widget_info(|| { egui::WidgetInfo::selected(egui::WidgetType::Button, true, is_active, &full_title) }); + let mut menu_action = TabAction::None; + response.context_menu(|ui| { + if ui.button("Close other tabs").clicked() { + menu_action = TabAction::CloseOthers; + ui.close(); + } + if ui.button("Show in folder").clicked() { + menu_action = TabAction::RevealPath; + ui.close(); + } + if ui.button("Copy path").clicked() { + menu_action = TabAction::CopyPath; + ui.close(); + } + }); let response = response .on_hover_text(if dirty { format!("{path}\nUnsaved annotations") @@ -435,13 +546,21 @@ impl PdfEditorApp { path }) .on_hover_cursor(CursorIcon::PointingHand); - if close.clicked() { + let action = if menu_action != TabAction::None { + menu_action + } else if close.clicked() + || close.clicked_by(egui::PointerButton::Middle) + || response.clicked_by(egui::PointerButton::Middle) + { TabAction::Close + } else if response.drag_stopped_by(egui::PointerButton::Primary) { + TabAction::Drop } else if response.clicked() { TabAction::Switch } else { TabAction::None - } + }; + (action, rect) } fn draw_identity_row(&mut self, ui: &mut egui::Ui, identity: &MastheadIdentity) { @@ -458,7 +577,7 @@ impl PdfEditorApp { 0.0, TextFormat { font_id: masthead_font(title_size), - color: INK, + color: self.app_palette().ink, extra_letter_spacing: title_size * 0.2, ..Default::default() }, @@ -478,14 +597,14 @@ impl PdfEditorApp { ); let title_rect = Rect::from_min_size(title_pos, title.size()); let title_truncated = title.elided; - ui.painter().galley(title_pos, title, INK); + ui.painter().galley(title_pos, title, self.app_palette().ink); let flank_width = ((inner.width() - title_rect.width()) * 0.5 - 28.0).max(60.0); let authors = ui.fonts_mut(|fonts| { fonts.layout_job(small_caps_job( &identity.authors, 13.0, - MUTED_INK, + self.app_palette().muted, flank_width, )) }); @@ -493,7 +612,7 @@ impl PdfEditorApp { fonts.layout_job(small_caps_job( &identity.citation, 13.0, - MUTED_INK, + self.app_palette().muted, flank_width, )) }); @@ -503,8 +622,8 @@ impl PdfEditorApp { Pos2::new(inner.right() - citation.size().x, flank_y), citation.size(), ); - ui.painter().galley(authors_rect.min, authors, MUTED_INK); - ui.painter().galley(citation_rect.min, citation, MUTED_INK); + ui.painter().galley(authors_rect.min, authors, self.app_palette().muted); + ui.painter().galley(citation_rect.min, citation, self.app_palette().muted); if !identity.authors.is_empty() { ui.interact( authors_rect, @@ -533,9 +652,9 @@ impl PdfEditorApp { // The double rule under the title line. let y = inner.bottom() - 3.0; ui.painter() - .hline(inner.x_range(), y, Stroke::new(1.3_f32, INK)); + .hline(inner.x_range(), y, Stroke::new(1.3_f32, self.app_palette().ink)); ui.painter() - .hline(inner.x_range(), y + 3.0, Stroke::new(0.8_f32, INK)); + .hline(inner.x_range(), y + 3.0, Stroke::new(0.8_f32, self.app_palette().ink)); } fn draw_action_row(&mut self, ui: &mut egui::Ui, ctx: &Context) { @@ -570,14 +689,14 @@ impl PdfEditorApp { ui.label( RichText::new(format!("Find in {scope}")) .size(13.) - .color(MUTED_INK), + .color(self.app_palette().muted), ); let width = (inner.width() * 0.24).clamp(54., 300.); self.draw_find_field(ui, ctx, true, width); let count = self.find_progress(); ui.add_sized( [if inner.width() > 700. { 125. } else { 78. }, 26.], - egui::Label::new(RichText::new(&count).size(13.).color(MUTED_INK)) + egui::Label::new(RichText::new(&count).size(13.).color(self.app_palette().muted)) .truncate(), ) .on_hover_text(&count); @@ -713,7 +832,7 @@ impl PdfEditorApp { .map(|(label, _)| { ui.fonts_mut(|fonts| { fonts - .layout_job(small_caps_job(label, 12.5, INK, f32::INFINITY)) + .layout_job(small_caps_job(label, 12.5, self.app_palette().ink, f32::INFINITY)) .size() .x + 10.0 @@ -722,7 +841,8 @@ impl PdfEditorApp { .sum::() + 2.0 * ui.spacing().item_spacing.x; ui.add_space(((width - item_width) * 0.5).max(0.0)); - let color = |active: bool| if active { OXBLOOD } else { MUTED_INK }; + let palette = self.app_palette(); + let color = |active: bool| if active { palette.accent } else { palette.muted }; if small_caps_button(ui, "Original", color(pdf_active), pdf_active) .on_hover_text("The PDF as printed") .clicked() @@ -772,7 +892,7 @@ impl PdfEditorApp { self.liquid_mode2_state, LiquidState::PreparingText | LiquidState::Preparing ) { - ui.spinner().on_hover_text("Preparing Review Mode"); + motion::busy(ui, self.settings.reduce_motion).on_hover_text("Preparing Review Mode"); } } @@ -788,6 +908,10 @@ impl PdfEditorApp { .as_ref() .map(|_| Arc::clone(&self.review_derived.outline)); let in_review = self.view_mode == DocumentViewMode::LiquidMode2; + let pdf_outline = (!in_review) + .then(|| self.pdf_outlines.get(&self.document_epoch).cloned()) + .flatten() + .filter(|outline| !outline.is_empty()); let current = if in_review { outline.as_ref().and_then(|outline| { let active = self.review_active_heading?; @@ -810,13 +934,13 @@ impl PdfEditorApp { (None, false, 0) => "No document".to_owned(), (None, false, count) => format!("Page {} of {count}", self.page_index + 1), }; - let mut job = small_caps_job("Contents", 12.5, OXBLOOD, f32::INFINITY); + let mut job = small_caps_job("Contents", 12.5, self.app_palette().accent, f32::INFINITY); job.append( " ", 0.0, TextFormat { font_id: FontId::proportional(15.0), - color: INK, + color: self.app_palette().ink, ..Default::default() }, ); @@ -836,68 +960,101 @@ impl PdfEditorApp { 0.0, TextFormat { font_id: FontId::proportional(16.0), - color: INK, + color: self.app_palette().ink, italics: true, ..Default::default() }, ); job.wrap.max_rows = 1; let mut clicked_target = None; + let mut clicked_pdf_target = None; let mut open_review = false; let (response, _) = egui::containers::menu::MenuButton::from_button(egui::Button::new(job).frame(false)) .ui(ui, |ui| { ui.set_max_width(420.0); ui.set_max_height(460.0); - match &outline { - Some(outline) if !outline.is_empty() => { - egui::ScrollArea::vertical() - .id_salt("masthead_contents") - .max_height(440.0) - .show(ui, |ui| { - for item in outline.iter() { - let active = in_review - && self.review_active_heading == Some(item.block_index); - ui.horizontal(|ui| { - ui.add_space( - 14.0 * item.level.saturating_sub(1) as f32, - ); - let text = RichText::new(&item.text) - .size(if item.level == 1 { 15.5 } else { 14.5 }) - .color(if active { - OXBLOOD - } else if item.level == 1 { - INK - } else { - MUTED_INK - }); - if ui - .add(egui::Button::new(text).frame(false).wrap()) - .on_hover_cursor(CursorIcon::PointingHand) - .clicked() - { - clicked_target = Some(item.block_index); - ui.close(); - } - }); - } - }); - } - Some(_) => { - ui.label( - RichText::new("No section headings detected.").color(MUTED_INK), - ); - } - None => { - ui.label( + if let Some(outline) = &pdf_outline { + egui::ScrollArea::vertical() + .id_salt("masthead_pdf_contents") + .max_height(440.0) + .show(ui, |ui| { + for item in outline.iter() { + ui.horizontal(|ui| { + ui.add_space(14.0 * item.level.min(8) as f32); + let button = egui::Button::new( + RichText::new(&item.title).size(14.5), + ) + .frame(false) + .wrap(); + if ui + .add_enabled(item.page_index.is_some(), button) + .clicked() + { + clicked_pdf_target = item + .page_index + .map(|page| (page, item.page_fraction)); + ui.close(); + } + }); + } + }); + } else { + match &outline { + Some(outline) if !outline.is_empty() => { + egui::ScrollArea::vertical() + .id_salt("masthead_contents") + .max_height(440.0) + .show(ui, |ui| { + for item in outline.iter() { + let active = in_review + && self.review_active_heading + == Some(item.block_index); + ui.horizontal(|ui| { + ui.add_space( + 14.0 * item.level.saturating_sub(1) as f32, + ); + let text = RichText::new(&item.text) + .size(if item.level == 1 { 15.5 } else { 14.5 }) + .color(if active { + self.app_palette().accent + } else if item.level == 1 { + self.app_palette().ink + } else { + self.app_palette().muted + }); + if ui + .add( + egui::Button::new(text).frame(false).wrap(), + ) + .on_hover_cursor(CursorIcon::PointingHand) + .clicked() + { + clicked_target = Some(item.block_index); + ui.close(); + } + }); + } + }); + } + Some(_) => { + ui.label( + RichText::new("No section headings detected.").color(self.app_palette().muted), + ); + } + None => { + ui.label( RichText::new( "Review Mode builds the contents from the article's headings.", ) - .color(MUTED_INK), + .color(self.app_palette().muted), ); - if self.document.is_some() && ui.button("Open Review Mode").clicked() { - open_review = true; - ui.close(); + if self.document.is_some() + && ui.button("Open Review Mode").clicked() + { + open_review = true; + ui.close(); + } } } } @@ -910,7 +1067,7 @@ impl PdfEditorApp { ] { ui.painter().line_segment( [chevron + from, chevron + to], - Stroke::new(1.3_f32, MUTED_INK), + Stroke::new(1.3_f32, self.app_palette().muted), ); } response.widget_info(|| { @@ -920,6 +1077,7 @@ impl PdfEditorApp { self.set_view_mode(DocumentViewMode::LiquidMode2, ctx); } if let Some(block_index) = clicked_target { + self.record_jump_origin(); if self.view_mode != DocumentViewMode::LiquidMode2 { self.set_view_mode(DocumentViewMode::LiquidMode2, ctx); } @@ -927,6 +1085,10 @@ impl PdfEditorApp { self.review_active_heading = Some(block_index); ctx.request_repaint(); } + if let Some((page_index, fraction)) = clicked_pdf_target { + self.jump_to_pdf_destination(page_index, fraction); + ctx.request_repaint(); + } } fn draw_find_field( @@ -993,9 +1155,13 @@ impl PdfEditorApp { fn draw_file_actions(&mut self, ui: &mut egui::Ui, ctx: &Context, has_document: bool) { // Right-to-left: the last added sits leftmost. #[cfg(target_os = "windows")] - if ui.add_enabled(has_document && !self.print_ui.busy, - egui::Button::new("Print…").min_size(Vec2::new(64.0, 30.0))) - .on_hover_text("Print this PDF · Ctrl+P").clicked() + if ui + .add_enabled( + has_document && !self.print_ui.busy, + egui::Button::new("Print…").min_size(Vec2::new(64.0, 30.0)), + ) + .on_hover_text("Print this PDF · Ctrl+P") + .clicked() { self.request_print(ctx); } @@ -1061,7 +1227,7 @@ impl PdfEditorApp { self.request_markdown_copy(ctx); } if markdown_busy { - ui.spinner(); + motion::busy(ui, self.settings.reduce_motion); } let review_document = match &self.liquid_mode2_state { LiquidState::Ready(document) if self.view_mode == DocumentViewMode::LiquidMode2 => { @@ -1188,9 +1354,10 @@ impl PdfEditorApp { } ui.horizontal(|ui| { ui.label("Theme"); - ui.selectable_value(&mut self.liquid_theme, LiquidTheme::Paper, "Paper"); - ui.selectable_value(&mut self.liquid_theme, LiquidTheme::Sepia, "Sepia"); - ui.selectable_value(&mut self.liquid_theme, LiquidTheme::Dark, "Dark"); + let paper = ui.selectable_value(&mut self.liquid_theme, LiquidTheme::Paper, "Paper").clicked(); + let sepia = ui.selectable_value(&mut self.liquid_theme, LiquidTheme::Sepia, "Sepia").clicked(); + let dark = ui.selectable_value(&mut self.liquid_theme, LiquidTheme::Dark, "Dark").clicked(); + if paper || sepia || dark { self.liquid_theme_follows_appearance = false; } }); ui.separator(); ui.checkbox( @@ -1203,7 +1370,8 @@ impl PdfEditorApp { if ui.button("Reset reading settings").clicked() { self.liquid_text_scale = 1.0; self.liquid_max_width = REVIEW_DEFAULT_MAX_WIDTH; - self.liquid_theme = LiquidTheme::Paper; + self.liquid_theme_follows_appearance = true; + self.liquid_theme = if ui.ctx().theme() == egui::Theme::Dark { LiquidTheme::Dark } else { LiquidTheme::Paper }; } let Some(document) = review_document.as_deref() else { return; @@ -1332,7 +1500,11 @@ impl PdfEditorApp { self.settings_ui.open = true; ui.close(); } - ui.label(RichText::new(APP_VERSION_LABEL).size(12.0).color(MUTED_INK)); + if ui.button("Keyboard shortcuts… ?").clicked() { + self.shortcuts_open = true; + ui.close(); + } + ui.label(RichText::new(APP_VERSION_LABEL).size(12.0).color(self.app_palette().muted)); }); } @@ -1366,7 +1538,7 @@ impl PdfEditorApp { .interactable(true) .show(ctx, |ui| { egui::Frame::NONE - .fill(IVORY) + .fill(self.app_palette().bar) .stroke(Stroke::new(1.0_f32, BRASS)) .corner_radius(26) .shadow(egui::epaint::Shadow { @@ -1407,9 +1579,9 @@ impl PdfEditorApp { ), 1.4, if grip.hovered() || grip.dragged() { - INK + self.app_palette().ink } else { - MUTED_INK + self.app_palette().muted }, ); } @@ -1490,10 +1662,10 @@ impl PdfEditorApp { } if selected && self.active_tool == Tool::Marker { ui.painter() - .circle_stroke(center, 11.0, Stroke::new(1.5_f32, INK)); + .circle_stroke(center, 11.0, Stroke::new(1.5_f32, self.app_palette().ink)); } else if response.hovered() { ui.painter() - .circle_stroke(center, 11.0, Stroke::new(1.0_f32, RULE)); + .circle_stroke(center, 11.0, Stroke::new(1.0_f32, self.app_palette().rule)); } accessibility::name_color_choice(&response, preset.label, selected); if response.on_hover_text(preset.label).clicked() && annotation_enabled { @@ -1538,7 +1710,8 @@ impl PdfEditorApp { }, ); if self.page_rotation_in_flight { - ui.spinner().on_hover_text("Saving page rotation…"); + motion::busy(ui, self.settings.reduce_motion) + .on_hover_text("Saving page rotation…"); } } if self.view_mode == DocumentViewMode::LiquidMode2 { @@ -1596,7 +1769,7 @@ impl PdfEditorApp { } ui.add_sized( Vec2::new(44.0, 30.0), - egui::Label::new(RichText::new(label).size(14.5).color(INK)), + egui::Label::new(RichText::new(label).size(14.5).color(self.app_palette().ink)), ); if toolbar_icon_button(ui, ToolbarIcon::ZoomIn, false, has_document, zoom_in_tip).clicked() { @@ -1631,7 +1804,7 @@ impl PdfEditorApp { ) .clicked() { - self.go_to_page(self.page_index.saturating_sub(1)); + self.jump_to_page(self.page_index.saturating_sub(1)); } let page_input_id = ui .id() @@ -1654,6 +1827,12 @@ impl PdfEditorApp { .horizontal_align(Align::Center), ) .on_hover_text("Type a page number and press Enter"); + if ui.ctx().data_mut(|data| { + data.remove_temp::(egui::Id::new("focus-page-number")) + .unwrap_or(false) + }) { + page_input_response.request_focus(); + } ui.ctx() .accesskit_node_builder(page_input_response.id, |node| { node.set_label("Page number"); @@ -1670,7 +1849,7 @@ impl PdfEditorApp { if submit_page_number { match page_index_from_input(&page_number_text, page_count) { Ok(page_index) => { - self.go_to_page(page_index); + self.jump_to_page(page_index); page_number_text = (page_index + 1).to_string(); } Err(message) => { @@ -1685,7 +1864,7 @@ impl PdfEditorApp { ui.label( RichText::new(format!("of {page_count}")) .size(14.5) - .color(MUTED_INK), + .color(self.app_palette().muted), ) .on_hover_text("Total pages in this PDF"); if toolbar_icon_button( @@ -1697,7 +1876,7 @@ impl PdfEditorApp { ) .clicked() { - self.go_to_page(self.page_index + 1); + self.jump_to_page(self.page_index + 1); } } @@ -1707,35 +1886,28 @@ impl PdfEditorApp { || self.active_annotation_saves.contains_key(&document.path) }); let (label, color) = if saving { - ("Saving…", MUTED_INK) + ("Saving…", self.app_palette().muted) } else if self.annotations_dirty { - ("Not saved", OXBLOOD) + ("Not saved", self.app_palette().accent) } else { - ("Saved", INK) + ("Saved", self.app_palette().ink) }; let (rect, response) = ui.allocate_exact_size(Vec2::new(78.0, 30.0), Sense::hover()); let mark_center = Pos2::new(rect.left() + 8.0, rect.center().y); if saving { ui.painter() - .circle_stroke(mark_center, 5.0, Stroke::new(1.4_f32, MUTED_INK)); - ui.ctx().request_repaint_after(RENDER_POLL_INTERVAL); + .circle_stroke(mark_center, 5.0, Stroke::new(1.4_f32, self.app_palette().muted)); } else if self.annotations_dirty { - ui.painter().circle_filled(mark_center, 4.0, OXBLOOD); + ui.painter().circle_filled(mark_center, 4.0, self.app_palette().accent); } else { - ui.painter().line_segment( - [ - mark_center + Vec2::new(-4.5, 0.0), - mark_center + Vec2::new(-1.5, 3.5), - ], - Stroke::new(1.8_f32, BRASS), - ); - ui.painter().line_segment( - [ - mark_center + Vec2::new(-1.5, 3.5), - mark_center + Vec2::new(5.0, -4.0), - ], - Stroke::new(1.8_f32, BRASS), - ); + let progress = self + .chrome + .save_confirmation + .filter(|(epoch, _)| *epoch == self.document_epoch) + .map_or(1.0, |(_, started)| { + motion::save_progress(ui.ctx(), started, self.settings.reduce_motion) + }); + draw_save_check(ui.painter(), mark_center, progress); } ui.painter().text( Pos2::new(rect.left() + 19.0, rect.center().y), @@ -1787,7 +1959,7 @@ impl PdfEditorApp { let mut job = LayoutJob::simple( text, FontId::proportional(14.0), - MUTED_INK.gamma_multiply(alpha), + self.app_palette().muted.gamma_multiply(alpha), (bar.width() + 120.0).max(320.0), ); job.wrap.max_rows = 2; @@ -1803,14 +1975,14 @@ impl PdfEditorApp { egui::Order::Foreground, egui::Id::new("markup_bar_caption"), )); - painter.rect_filled(rect, 12.0, IVORY.gamma_multiply(0.94 * alpha)); + painter.rect_filled(rect, 12.0, self.app_palette().bar.gamma_multiply(0.94 * alpha)); painter.rect_stroke( rect, 12.0, - Stroke::new(1.0_f32, RULE.gamma_multiply(alpha)), + Stroke::new(1.0_f32, self.app_palette().rule.gamma_multiply(alpha)), egui::StrokeKind::Inside, ); - painter.galley(rect.min + Vec2::new(12.0, 5.0), galley, MUTED_INK); + painter.galley(rect.min + Vec2::new(12.0, 5.0), galley, self.app_palette().muted); } } @@ -1819,24 +1991,55 @@ enum TabAction { None, Switch, Close, + CloseOthers, + CopyPath, + RevealPath, + Drop, +} + +fn reveal_document_in_file_manager(path: &Path) -> Result<(), String> { + #[cfg(target_os = "windows")] + let mut command = { + let mut command = std::process::Command::new("explorer.exe"); + command.arg("/select,").arg(path); + command + }; + #[cfg(target_os = "macos")] + let mut command = { + let mut command = std::process::Command::new("open"); + command.arg("-R").arg(path); + command + }; + #[cfg(not(any(target_os = "windows", target_os = "macos")))] + let mut command = { + let mut command = std::process::Command::new("xdg-open"); + command.arg(path.parent().unwrap_or(path)); + command + }; + command + .spawn() + .map(|_| ()) + .map_err(|error| format!("Could not show the document in its folder: {error}")) } /// Icon buttons without a resting fill or border: they show themselves on hover. fn quiet_buttons(ui: &mut egui::Ui) { + let dark = ui.visuals().dark_mode; + let accent = theme::for_ui(ui).accent; let visuals = ui.visuals_mut(); visuals.widgets.inactive.weak_bg_fill = Color32::TRANSPARENT; visuals.widgets.inactive.bg_stroke = Stroke::NONE; - visuals.widgets.hovered.weak_bg_fill = Color32::from_rgba_unmultiplied(42, 38, 32, 16); + visuals.widgets.hovered.weak_bg_fill = if dark { Color32::from_white_alpha(24) } else { Color32::from_rgba_unmultiplied(42, 38, 32, 16) }; visuals.widgets.hovered.bg_stroke = Stroke::NONE; - visuals.widgets.active.weak_bg_fill = Color32::from_rgba_unmultiplied(42, 38, 32, 28); - visuals.selection.bg_fill = Color32::from_rgba_unmultiplied(122, 31, 43, 30); - visuals.selection.stroke = Stroke::new(1.0_f32, OXBLOOD); + visuals.widgets.active.weak_bg_fill = if dark { Color32::from_white_alpha(40) } else { Color32::from_rgba_unmultiplied(42, 38, 32, 28) }; + visuals.selection.bg_fill = if dark { Color32::from_rgba_unmultiplied(241, 163, 155, 48) } else { Color32::from_rgba_unmultiplied(122, 31, 43, 30) }; + visuals.selection.stroke = Stroke::new(1.0_f32, accent); } fn bar_separator(ui: &mut egui::Ui) { let (rect, _) = ui.allocate_exact_size(Vec2::new(9.0, 26.0), Sense::hover()); ui.painter() - .vline(rect.center().x, rect.y_range(), Stroke::new(1.0_f32, RULE)); + .vline(rect.center().x, rect.y_range(), Stroke::new(1.0_f32, theme::for_ui(ui).rule)); } /// Keep the bar fully inside the document area, whatever the window does. @@ -1880,6 +2083,354 @@ fn paint_bell_mark(ui: &mut egui::Ui, stroke_color: Color32, letter_color: Color mod tests { use super::*; + #[test] + fn save_check_only_animates_after_the_active_documents_latest_success() { + let (mut app, ctx, _worker) = annotation_saving::tests::fixture(); + let path = app.document.as_ref().unwrap().path.clone(); + let mut save = PendingAnnotationSave { + document_epoch: app.document_epoch, + path: path.clone(), + generation: 1, + annotations: app.annotations.clone(), + due_at: Instant::now(), + }; + app.annotations_dirty = true; + app.active_annotation_saves + .insert(path.clone(), save.clone()); + let mut newer = save.clone(); + newer.generation = 2; + app.pending_annotation_saves + .insert(path.clone(), newer.clone()); + app.finish_annotation_autosave( + save.document_epoch, + path.clone(), + 1, + Ok(crate::pdf_backend::SaveReport::default()), + &ctx, + ); + assert!(app.annotations_dirty); + assert!(app.chrome.save_confirmation.is_none()); + app.pending_annotation_saves.remove(&path); + app.active_annotation_saves.insert(path.clone(), newer); + app.finish_annotation_autosave( + save.document_epoch, + path.clone(), + 2, + Err("test failure".to_owned()), + &ctx, + ); + assert!(app.annotations_dirty); + assert!(app.chrome.save_confirmation.is_none()); + save.generation = 3; + app.active_annotation_saves + .insert(path.clone(), save.clone()); + app.finish_annotation_autosave( + save.document_epoch, + path.clone(), + 3, + Ok(crate::pdf_backend::SaveReport::default()), + &ctx, + ); + assert!(!app.annotations_dirty); + assert_eq!( + app.chrome.save_confirmation.map(|(epoch, _)| epoch), + Some(app.document_epoch) + ); + + // A completion for another tab, or a stale completion, cannot replay + // the active tab's check mark. + app.chrome.save_confirmation = None; + let other_path = PathBuf::from("other.pdf"); + save.document_epoch += 1; + save.path = other_path.clone(); + app.active_annotation_saves + .insert(other_path.clone(), save.clone()); + app.finish_annotation_autosave( + save.document_epoch, + other_path, + 3, + Ok(crate::pdf_backend::SaveReport::default()), + &ctx, + ); + assert!(app.chrome.save_confirmation.is_none()); + } + + #[test] + fn save_check_draws_in_and_reduced_motion_shows_the_complete_mark() { + let (mut app, ctx, _worker) = annotation_saving::tests::fixture(); + let frame = |app: &mut PdfEditorApp| { + ctx.run(Default::default(), |ctx| { + egui::CentralPanel::default().show(ctx, |ui| app.draw_bar_save_state(ui)); + }) + }; + let segments = |output: &egui::FullOutput| { + output + .shapes + .iter() + .filter(|shape| matches!(shape.shape, egui::Shape::LineSegment { .. })) + .count() + }; + app.chrome.save_confirmation = + Some((app.document_epoch, Instant::now() + Duration::from_secs(1))); + app.settings.reduce_motion = false; + assert_eq!(segments(&frame(&mut app)), 0); + app.chrome.save_confirmation = Some(( + app.document_epoch, + Instant::now() - Duration::from_millis(150), + )); + assert_eq!(segments(&frame(&mut app)), 2); + app.settings.reduce_motion = true; + app.chrome.save_confirmation = + Some((app.document_epoch, Instant::now() + Duration::from_secs(1))); + assert_eq!(segments(&frame(&mut app)), 2); + for index in 0..8 { + let output = frame(&mut app); + if index >= 4 { + assert_eq!( + output.viewport_output[&egui::ViewportId::ROOT].repaint_delay, + Duration::MAX + ); + } + } + } + + #[test] + fn static_saving_indicator_does_not_schedule_repaints() { + let (mut app, ctx, _worker) = annotation_saving::tests::fixture(); + let path = app.document.as_ref().unwrap().path.clone(); + app.active_annotation_saves.insert( + path.clone(), + PendingAnnotationSave { + document_epoch: 7, + path, + generation: 1, + annotations: Vec::new(), + due_at: Instant::now(), + }, + ); + for frame in 0..8 { + let output = ctx.run( + egui::RawInput { + time: Some(frame as f64 * 0.2), + ..Default::default() + }, + |ctx| { + egui::CentralPanel::default().show(ctx, |ui| app.draw_bar_save_state(ui)); + }, + ); + if frame >= 4 { + assert_eq!( + output.viewport_output[&egui::ViewportId::ROOT].repaint_delay, + Duration::MAX + ); + } + } + } + + fn tab_label_center(shape: &egui::Shape, title: &str) -> Option { + match shape { + egui::Shape::Text(shape) if shape.galley.text() == title => { + Some(shape.pos + shape.galley.size() * 0.5) + } + egui::Shape::Vec(shapes) => shapes + .iter() + .find_map(|shape| tab_label_center(shape, title)), + _ => None, + } + } + + fn tab_frame( + app: &mut PdfEditorApp, + ctx: &Context, + time: f64, + events: Vec, + ) -> egui::FullOutput { + let input = egui::RawInput { + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, Vec2::new(1000.0, 500.0))), + time: Some(time), + events, + ..Default::default() + }; + ctx.run(input, |ctx| { + egui::CentralPanel::default().show(ctx, |ui| app.draw_tab_row(ui, ctx)); + }) + } + + fn pointer_button(pos: Pos2, button: egui::PointerButton, pressed: bool) -> egui::Event { + egui::Event::PointerButton { + pos, + button, + pressed, + modifiers: egui::Modifiers::NONE, + } + } + + #[test] + fn middle_click_a_dirty_tab_keeps_it_open_for_a_save_decision() { + let (mut app, ctx, _worker) = annotation_saving::tests::fixture(); + app.annotations_dirty = true; + let output = tab_frame(&mut app, &ctx, 0.0, vec![]); + let point = output + .shapes + .iter() + .find_map(|shape| tab_label_center(&shape.shape, "Fixture")) + .unwrap(); + tab_frame(&mut app, &ctx, 0.1, vec![egui::Event::PointerMoved(point)]); + tab_frame( + &mut app, + &ctx, + 0.2, + vec![pointer_button(point, egui::PointerButton::Middle, true)], + ); + tab_frame( + &mut app, + &ctx, + 0.3, + vec![pointer_button(point, egui::PointerButton::Middle, false)], + ); + assert_eq!(app.pending_close, Some(CloseTarget::Tab(7))); + assert_eq!(app.tabs.len(), 1); + assert!(app.annotations_dirty); + } + + #[test] + fn tab_context_menu_copies_the_selected_documents_path() { + let (mut app, ctx, _worker) = annotation_saving::tests::fixture(); + let expected = app.tabs[0].document.path.display().to_string(); + let output = tab_frame(&mut app, &ctx, 0.0, vec![]); + let point = output + .shapes + .iter() + .find_map(|shape| tab_label_center(&shape.shape, "Fixture")) + .unwrap(); + tab_frame(&mut app, &ctx, 0.1, vec![egui::Event::PointerMoved(point)]); + tab_frame( + &mut app, + &ctx, + 0.2, + vec![pointer_button(point, egui::PointerButton::Secondary, true)], + ); + tab_frame( + &mut app, + &ctx, + 0.3, + vec![pointer_button(point, egui::PointerButton::Secondary, false)], + ); + let output = tab_frame(&mut app, &ctx, 0.35, vec![]); + let menu_point = output + .shapes + .iter() + .find_map(|shape| tab_label_center(&shape.shape, "Copy path")) + .expect("context menu opens"); + tab_frame( + &mut app, + &ctx, + 0.4, + vec![egui::Event::PointerMoved(menu_point)], + ); + tab_frame( + &mut app, + &ctx, + 0.5, + vec![pointer_button( + menu_point, + egui::PointerButton::Primary, + true, + )], + ); + let output = tab_frame( + &mut app, + &ctx, + 0.6, + vec![pointer_button( + menu_point, + egui::PointerButton::Primary, + false, + )], + ); + assert!(output.platform_output.commands.iter().any( + |command| matches!(command, egui::OutputCommand::CopyText(text) if *text == expected) + )); + } + + #[test] + fn tab_titles_are_elided_by_rendered_width() { + fn elision(shape: &egui::Shape, text: &str) -> Option { + match shape { + egui::Shape::Text(shape) if shape.galley.text() == text => { + Some(shape.galley.elided) + } + egui::Shape::Vec(shapes) => shapes.iter().find_map(|shape| elision(shape, text)), + _ => None, + } + } + let (mut app, ctx, _worker) = annotation_saving::tests::fixture(); + for (title, expected) in [ + ("iiiiiiiiiiiiiiiiiiiiiiiii", false), + ("WWWWWWWWWWWWWWWWWWWWWWWWW", true), + ] { + app.tabs[0].document.title = title.into(); + let output = ctx.run(egui::RawInput::default(), |ctx| { + egui::CentralPanel::default().show(ctx, |ui| { + app.draw_tab(ui, 0, 190.0); + }); + }); + assert_eq!( + output + .shapes + .iter() + .find_map(|shape| elision(&shape.shape, title)), + Some(expected) + ); + } + } + + #[test] + fn dragging_a_tab_reorders_it_without_changing_the_active_document() { + let (mut app, ctx, _worker) = annotation_saving::tests::fixture(); + app.document.as_mut().unwrap().title = "Alpha".into(); + app.tabs[0].document.title = "Alpha".into(); + for (epoch, title) in [(8, "Bravo"), (9, "Charlie")] { + let mut tab = app.tabs[0].clone(); + tab.document_epoch = epoch; + tab.document.title = title.into(); + app.tabs.push(tab); + } + let output = tab_frame(&mut app, &ctx, 0.0, vec![]); + let center = |title| { + output + .shapes + .iter() + .find_map(|shape| tab_label_center(&shape.shape, title)) + .unwrap() + }; + let from = center("Alpha"); + let to = center("Charlie"); + tab_frame(&mut app, &ctx, 0.1, vec![egui::Event::PointerMoved(from)]); + tab_frame( + &mut app, + &ctx, + 0.2, + vec![pointer_button(from, egui::PointerButton::Primary, true)], + ); + tab_frame(&mut app, &ctx, 0.3, vec![egui::Event::PointerMoved(to)]); + tab_frame( + &mut app, + &ctx, + 0.4, + vec![pointer_button(to, egui::PointerButton::Primary, false)], + ); + assert_eq!( + app.tabs + .iter() + .map(|tab| tab.document_epoch) + .collect::>(), + vec![8, 9, 7] + ); + assert_eq!(app.active_tab, Some(2)); + assert_eq!(app.document_epoch, 7); + } + #[test] fn the_bar_stays_inside_the_document_area() { let area = Rect::from_min_size(Pos2::new(100.0, 50.0), Vec2::new(800.0, 600.0)); diff --git a/src/app/citation_copy.rs b/src/app/citation_copy.rs new file mode 100644 index 0000000..fd94921 --- /dev/null +++ b/src/app/citation_copy.rs @@ -0,0 +1,1055 @@ +//! Copy a selected quotation with reader-verified citation details. +use super::*; +use crate::review_masthead::{CitationDetails, citation_details, printed_folio}; + +#[derive(Clone, Copy, PartialEq, Eq)] +enum CitationStyle { + Plain, + Footnote, + Markdown, +} + +pub(super) struct CitationDraft { + document_epoch: u64, + source: String, + pdf_pages: String, + quote: String, + details: CitationDetails, + detected_journal: String, + designation: String, + pinpoint: String, + style: CitationStyle, +} + +impl CitationDraft { + fn complete(&self) -> bool { + [ + &self.details.title, + &self.details.journal, + &self.details.volume, + &self.details.first_page, + &self.details.year, + &self.pinpoint, + ] + .into_iter() + .all(|field| !field.trim().is_empty()) + && (!self.details.authors.trim().is_empty() || !self.designation.trim().is_empty()) + && !self.quote.trim().is_empty() + } + + fn render(&self) -> Option { + if !self.complete() { + return None; + } + let field = |text: &str| text.split_whitespace().collect::>().join(" "); + let authors = field(&self.details.authors); + let designation = field(&self.designation); + let title = field(&self.details.title); + let journal = field(&self.details.journal); + let volume = field(&self.details.volume); + let first_page = field(&self.details.first_page); + let pinpoint = field(&self.pinpoint); + let year = field(&self.details.year); + let prefix = [&authors, &designation] + .into_iter() + .filter(|part| !part.is_empty()) + .cloned() + .collect::>() + .join(", "); + let pin = if pinpoint == first_page { + String::new() + } else { + format!(", {pinpoint}") + }; + let citation = format!("{prefix}, {title}, {volume} {journal} {first_page}{pin} ({year})."); + let quote = self.quote.trim(); + match self.style { + CitationStyle::Plain => Some(format!("“{quote}”\n\n{citation}")), + CitationStyle::Footnote => Some(format!("“{quote}”¹\n\n1. {citation}")), + CitationStyle::Markdown => { + let quotation = quote + .lines() + .map(|line| format!("> {}", escape_markdown(line))) + .collect::>() + .join("\n"); + let markdown_pin = if pinpoint == first_page { + String::new() + } else { + format!(", {}", escape_markdown(&pinpoint)) + }; + let citation = format!( + "{}, *{}*, {} {} {}{} ({}).", + escape_markdown(&prefix), + escape_markdown(&title), + escape_markdown(&volume), + escape_markdown(&journal), + escape_markdown(&first_page), + markdown_pin, + escape_markdown(&year) + ); + Some(format!("{quotation}[^1]\n\n[^1]: {citation}")) + } + } + } +} + +pub(super) fn escape_markdown(text: &str) -> String { + let mut escaped = String::with_capacity(text.len()); + for ch in text.chars() { + if matches!( + ch, + '\\' | '*' | '_' | '[' | ']' | '<' | '>' | '`' | '#' | '~' + ) { + escaped.push('\\'); + } + escaped.push(ch); + } + escaped +} + +pub(super) fn page_text(document: &LoadedDocument, page: usize) -> Option { + if document + .native_text_loaded + .get(page) + .copied() + .unwrap_or(false) + && let Some(text) = document.native_text.get(page) + && !text.trim().is_empty() + { + return Some(text.clone()); + } + document + .text_chars + .get(page)? + .as_ref() + .map(|chars| chars.iter().map(|ch| ch.ch).collect()) +} + +fn page_pinpoint(document: &LoadedDocument, selection: TextSelection, year: Option) -> String { + let first = + page_text(document, selection.start_page).and_then(|text| printed_folio(&text, year)); + let last = page_text(document, selection.end_page).and_then(|text| printed_folio(&text, year)); + match (first, last) { + (Some(first), Some(last)) if first == last => first.to_string(), + (Some(first), Some(last)) if first < last => format!("{first}–{last}"), + _ => String::new(), + } +} + +pub(super) fn consume_citation_shortcut(ctx: &Context) -> bool { + ctx.input_mut(|input| { + let mut consumed = false; + input.events.retain(|event| { + let is_shortcut = matches!(event, + egui::Event::Key { key: egui::Key::C, modifiers, pressed: true, repeat: false, .. } + if modifiers.alt && !modifiers.shift && (modifiers.ctrl || modifiers.command || modifiers.mac_cmd)); + consumed |= is_shortcut; + !is_shortcut + }); + consumed + }) +} + +// Egui owns label selections. Its built-in selection plugin emits their text as a +// CopyText command at end-of-pass. Snapshot the output just before that plugin +// runs and capture only its new command, leaving the real clipboard untouched. +struct ReviewCaptureRequest { + document_epoch: u64, + view: DocumentViewMode, + review: std::sync::Weak, +} + +#[derive(Default)] +struct ReviewSelectionCapture { + pending: Option, + commands_before_selection: Option, + completed: Option<(ReviewCaptureRequest, Option)>, +} + +impl egui::Plugin for ReviewSelectionCapture { + fn debug_name(&self) -> &'static str { + "ReviewSelectionCapture" + } + + fn on_end_pass(&mut self, ctx: &Context) { + let Some(request) = self.pending.take() else { + return; + }; + let text = self.commands_before_selection.take().and_then(|start| { + ctx.output_mut(|output| { + let position = output.commands.iter().enumerate().skip(start).find_map( + |(index, command)| { + matches!(command, egui::OutputCommand::CopyText(_)).then_some(index) + }, + )?; + match output.commands.remove(position) { + egui::OutputCommand::CopyText(text) => Some(text), + _ => unreachable!(), + } + }) + }); + self.completed = Some((request, text)); + ctx.request_repaint(); + } +} + +fn ensure_review_capture_plugin(ctx: &Context) { + if ctx.plugin_opt::().is_some() { + return; + } + ctx.add_plugin(ReviewSelectionCapture::default()); + // CallbackPlugin is built into egui before LabelSelectionState. This callback + // records the boundary immediately before the latter appends its command. + ctx.on_end_pass( + "review citation selection boundary", + Arc::new(|ctx| { + let plugin = ctx.plugin::(); + let mut capture = plugin.lock(); + if capture.pending.is_some() { + capture.commands_before_selection = + Some(ctx.output_mut(|output| output.commands.len())); + } + }), + ); +} + +pub(super) fn ensure_copy_event(ctx: &Context) { + ctx.input_mut(|input| { + if !input + .events + .iter() + .any(|event| matches!(event, egui::Event::Copy)) + { + input.events.push(egui::Event::Copy); + } + }); +} + +// Mirror the visible rendering of footnote callouts; private-use delimiters are +// internal markup and should never be copied into a quotation. +pub(super) fn review_display_text(source: &str, hide_markers: bool) -> String { + let mut result = String::new(); + let mut marker: Option = None; + for ch in source.chars() { + match ch { + crate::layout_roles::CALLOUT_START => { + if let Some(previous) = marker.take() { + if !hide_markers { + result.push_str(&review_footnote_superscript(&previous)); + } + } + marker = Some(String::new()); + } + crate::layout_roles::CALLOUT_END => { + if let Some(previous) = marker.take() { + if !hide_markers { + result.push_str(&review_footnote_superscript(&previous)); + } + } + } + _ => { + if let Some(current) = marker.as_mut() { + current.push(ch); + } else { + result.push(ch); + } + } + } + } + if let Some(previous) = marker { + if !hide_markers { + result.push_str(&review_footnote_superscript(&previous)); + } + } + result +} + +fn normalized(text: &str) -> String { + text.split_whitespace().collect::>().join(" ") +} + +// Selection state is global to egui. Require the captured text to occur in the +// current Review document, in order, before attaching this document's citation. +fn quote_belongs_to_review(quote: &str, review: &LiquidDocument, hide_markers: bool) -> bool { + let source = std::iter::once(review.title.as_str()) + .chain(review.blocks.iter().map(|block| block.text.as_str())) + .map(|text| normalized(&review_display_text(text, hide_markers))) + .collect::>() + .join(" "); + let mut remaining = source.as_str(); + let mut found_any = false; + for line in quote + .lines() + .map(normalized) + .filter(|line| !line.is_empty()) + { + let Some(start) = remaining.find(&line) else { + return false; + }; + remaining = &remaining[start + line.len()..]; + found_any = true; + } + found_any +} + +/// Infer a PDF page only when one Review block contains the quote and the +/// quote is independently present in that block's source lines and in the +/// extracted text of exactly one source page. Partial or ambiguous mappings +/// intentionally return no page. +fn review_quote_page( + review: &LiquidDocument, + document: &LoadedDocument, + quote: &str, + hide_markers: bool, +) -> Option { + let quote = normalized(quote); + if quote.is_empty() { + return None; + } + let mut blocks = review.blocks.iter().enumerate().filter(|(_, block)| { + normalized(&review_display_text(&block.text, hide_markers)).contains("e) + }); + let (block_index, _) = blocks.next()?; + if blocks.next().is_some() { + return None; + } + let mut entries = review + .block_source_lines + .iter() + .filter(|entry| entry.block_index == block_index); + let source = entries.next()?; + if entries.next().is_some() || source.lines.is_empty() { + return None; + } + let mut pages: std::collections::BTreeMap> = + std::collections::BTreeMap::new(); + for line in &source.lines { + if line.page_index >= document.page_count { + return None; + } + pages + .entry(line.page_index) + .or_default() + .push(review_display_text(&line.text, hide_markers)); + } + // Count source-page candidates before consulting loaded page text: an + // unloaded competing page is still ambiguous, not evidence for a match. + let mut matches = pages.into_iter().filter_map(|(page, lines)| { + normalized(&lines.join(" ")) + .contains("e) + .then_some(page) + }); + let page = matches.next()?; + if matches.next().is_some() { + return None; + } + let original = page_text(document, page)?; + normalized(&review_display_text(&original, hide_markers)) + .contains("e) + .then_some(page) +} + +impl PdfEditorApp { + fn active_review_document(&self) -> Option<&Arc> { + match self.view_mode { + DocumentViewMode::Liquid => match &self.liquid_state { + LiquidState::Ready(doc) => Some(doc), + _ => None, + }, + DocumentViewMode::LiquidMode2 => match &self.liquid_mode2_state { + LiquidState::Ready(doc) => Some(doc), + _ => None, + }, + DocumentViewMode::Pdf => None, + } + } + + pub(super) fn start_review_selection_citation(&mut self, ctx: &Context) { + let has_label_selection = ctx + .plugin::() + .lock() + .has_selection(); + if !has_label_selection { + if self.selection_state.liquid_all { + if let Some(text) = self.current_liquid_copy_text() { + self.start_review_quote_citation(review_display_text( + &text, + self.liquid_hide_footnotes, + )); + return; + } + } + self.status = "Select Review text before copying with a citation.".to_owned(); + return; + } + let Some(review) = self.active_review_document() else { + self.status = "Review text is not ready to copy.".to_owned(); + return; + }; + let request = ReviewCaptureRequest { + document_epoch: self.document_epoch, + view: self.view_mode, + review: Arc::downgrade(review), + }; + ensure_review_capture_plugin(ctx); + let plugin = ctx.plugin::(); + let mut capture = plugin.lock(); + capture.pending = Some(request); + capture.completed = None; + drop(capture); + ensure_copy_event(ctx); + } + + pub(super) fn poll_review_selection_citation(&mut self, ctx: &Context) { + let Some(plugin) = ctx.plugin_opt::() else { + return; + }; + let Some((request, text)) = plugin.lock().completed.take() else { + return; + }; + let valid_document = request.document_epoch == self.document_epoch + && request.view == self.view_mode + && request + .review + .upgrade() + .zip(self.active_review_document()) + .is_some_and(|(previous, current)| Arc::ptr_eq(&previous, current)); + if !valid_document { + return; + } + let Some(text) = text.filter(|text| !text.trim().is_empty()) else { + self.status = "The Review selection is no longer visible. Select it again before copying with a citation.".to_owned(); + return; + }; + if !self.active_review_document().is_some_and(|review| { + quote_belongs_to_review(&text, review, self.liquid_hide_footnotes) + }) { + self.status = + "Select text from the Review document before copying with a citation.".to_owned(); + return; + } + self.start_review_quote_citation(text); + } + + pub(super) fn start_review_quote_citation(&mut self, quote: String) { + let full_review = + self.view_mode != DocumentViewMode::LiquidMode2 || self.liquid_mode2_complete; + let page = full_review + .then(|| self.active_review_document().zip(self.document.as_ref())) + .flatten() + .and_then(|(review, document)| { + review_quote_page(review, document, "e, self.liquid_hide_footnotes) + }); + let label = page.map_or_else( + || "Review selection · printed page required".to_owned(), + |page| format!("Review selection · PDF page {}", page + 1), + ); + self.start_citation_for_quote( + quote, + page.map(|page| TextSelection::new(page, 0, 0)), + label, + ); + } + + pub(super) fn start_copy_with_citation(&mut self) { + let Some((_document, selection, quote)) = self + .document + .as_ref() + .zip(self.selection_state.text) + .zip(self.selected_text()) + .map(|((document, selection), quote)| (document, selection, quote)) + else { + self.status = "Select text in the original PDF and wait for it to load before copying with a citation.".to_owned(); + return; + }; + let pdf_pages = if selection.start_page == selection.end_page { + format!("PDF page {}", selection.start_page + 1) + } else { + format!( + "PDF pages {}–{}", + selection.start_page + 1, + selection.end_page + 1 + ) + }; + self.start_citation_for_quote(quote, Some(selection), pdf_pages); + } + + fn start_citation_for_quote( + &mut self, + quote: String, + selection: Option, + pdf_pages: String, + ) { + let Some(document) = self.document.as_ref() else { + return; + }; + let pages = document + .native_text + .iter() + .zip(&document.native_text_loaded) + .take(crate::review_masthead::MASTHEAD_PAGES_TO_READ + 1) + .map(|(text, loaded)| if *loaded { text.as_str() } else { "" }) + .collect::>(); + let review = match &self.liquid_mode2_state { + LiquidState::Ready(document) => Some(document.as_ref()), + _ => None, + }; + let mut details = citation_details( + &pages, + review.map(|document| document.title.as_str()), + review + .map(|document| document.blocks.as_slice()) + .unwrap_or_default(), + ); + let detected_journal = std::mem::take(&mut details.journal); + let pinpoint = selection + .map(|selection| page_pinpoint(document, selection, details.year.parse().ok())) + .unwrap_or_default(); + let source = document + .path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("PDF") + .to_owned(); + self.citation_draft = Some(CitationDraft { + document_epoch: self.document_epoch, + source, + pdf_pages, + quote, + details, + detected_journal, + designation: String::new(), + pinpoint, + style: CitationStyle::Plain, + }); + } + + pub(super) fn draw_citation_copy_window(&mut self, ctx: &Context) { + let Some(mut draft) = self.citation_draft.take() else { + return; + }; + if self.document.is_none() || draft.document_epoch != self.document_epoch { + return; + } + let mut open = true; + let mut copied = None; + egui::Window::new("Copy with citation") + .open(&mut open).collapsible(false).default_width(480.0) + .show(ctx, |ui| { + ui.label(format!("{} · {}", draft.source, draft.pdf_pages)); + ui.label("Check the inferred details, including the journal abbreviation and printed page. PDF page numbers may differ."); + ui.label("For a published journal article. Plain text may need italics and small caps after pasting."); + if !draft.detected_journal.is_empty() { + ui.label(format!("Journal detected: {}", draft.detected_journal)); + } + ui.collapsing("Selected quotation", |ui| { + egui::ScrollArea::vertical().max_height(140.0).show(ui, |ui| { ui.label(&draft.quote); }); + }); + egui::Grid::new("citation-fields").num_columns(2).spacing([12.0, 6.0]).show(ui, |ui| { + for (label, field) in [ + ("Author(s)", &mut draft.details.authors), + ("Designation (optional)", &mut draft.designation), + ("Article title", &mut draft.details.title), + ("Volume", &mut draft.details.volume), + ("Journal abbreviation", &mut draft.details.journal), + ("First page", &mut draft.details.first_page), + ("Printed pinpoint page(s)", &mut draft.pinpoint), + ("Year", &mut draft.details.year), + ] { + ui.label(label); + ui.text_edit_singleline(field); + ui.end_row(); + } + }); + ui.horizontal(|ui| { + ui.label("Format:"); + ui.selectable_value(&mut draft.style, CitationStyle::Plain, "Plain"); + ui.selectable_value(&mut draft.style, CitationStyle::Footnote, "Footnote text"); + ui.selectable_value(&mut draft.style, CitationStyle::Markdown, "Markdown"); + }); + if !draft.complete() { ui.label("Fill the required fields and provide an author or a designation."); } + if ui.add_enabled(draft.complete(), egui::Button::new("Copy quotation and citation")).clicked() { + copied = draft.render(); + } + }); + if let Some(text) = copied { + self.copy_text_to_clipboard(ctx, text); + self.status = + "Copied quotation and citation. Check the details against the source.".to_owned(); + } else if open { + self.citation_draft = Some(draft); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn draft(style: CitationStyle) -> CitationDraft { + CitationDraft { + document_epoch: 7, + source: "article.pdf".into(), + pdf_pages: "PDF page 3".into(), + quote: "A quotation\ncontinued.".into(), + details: CitationDetails { + authors: "A. Author".into(), + title: "A Title".into(), + journal: "N.Y.U. L. Rev.".into(), + volume: "99".into(), + first_page: "451".into(), + year: "2024".into(), + }, + detected_journal: "New York University Law Review".into(), + designation: String::new(), + pinpoint: "453".into(), + style, + } + } + + #[test] + fn citations_require_real_fields_and_support_three_formats() { + let mut value = draft(CitationStyle::Plain); + assert_eq!( + value.render().unwrap(), + "“A quotation\ncontinued.”\n\nA. Author, A Title, 99 N.Y.U. L. Rev. 451, 453 (2024)." + ); + value.style = CitationStyle::Footnote; + assert!(value.render().unwrap().contains("”¹\n\n1. A. Author")); + value.style = CitationStyle::Markdown; + value.details.title = "A *quoted* title".into(); + assert!( + value + .render() + .unwrap() + .contains("[^1]: A. Author, *A \\*quoted\\* title*, 99") + ); + value.pinpoint.clear(); + assert!(value.render().is_none()); + value.style = CitationStyle::Plain; + value.details.authors.clear(); + value.designation = "Note".into(); + value.details.title = "A Title".into(); + value.pinpoint = "451".into(); + assert!( + value + .render() + .unwrap() + .ends_with("Note, A Title, 99 N.Y.U. L. Rev. 451 (2024).") + ); + } + + #[test] + fn citation_shortcut_does_not_consume_plain_or_shift_copy() { + let ctx = Context::default(); + let key = |modifiers, pressed| egui::Event::Key { + key: egui::Key::C, + physical_key: None, + pressed, + repeat: false, + modifiers, + }; + let input = egui::RawInput { + events: vec![ + key(egui::Modifiers::CTRL, true), + key(egui::Modifiers::CTRL, false), + key(egui::Modifiers::CTRL | egui::Modifiers::SHIFT, true), + key(egui::Modifiers::CTRL | egui::Modifiers::SHIFT, false), + key(egui::Modifiers::CTRL | egui::Modifiers::ALT, true), + key(egui::Modifiers::CTRL | egui::Modifiers::ALT, false), + ], + ..Default::default() + }; + let _ = ctx.run(input, |ctx| { + assert!(consume_citation_shortcut(ctx)); + assert_eq!(ctx.input(|input| input.events.len()), 5); + }); + } + + #[test] + fn review_selection_is_captured_without_touching_clipboard_and_rejects_stale_documents() { + fn find_text(shape: &egui::Shape, wanted: &str) -> Option { + match shape { + egui::Shape::Text(text) if text.galley.text() == wanted => Some(text.pos), + egui::Shape::Vec(shapes) => { + shapes.iter().find_map(|shape| find_text(shape, wanted)) + } + _ => None, + } + } + let (mut app, ctx, _worker) = annotation_saving::tests::fixture(); + let first = "First paragraph has selected words."; + let second = "Second paragraph continues the quotation."; + let blocks = [first, second] + .into_iter() + .map(|text| LiquidBlock { + role: LiquidBlockRole::Paragraph, + text: text.to_owned(), + label: None, + }) + .collect(); + app.view_mode = DocumentViewMode::LiquidMode2; + app.liquid_mode2_state = LiquidState::Ready(Arc::new(LiquidDocument { + title: "Article".into(), + blocks, + article_spans: Vec::new(), + block_source_lines: Vec::new(), + footnote_links: Vec::new(), + footnote_link_integrity: None, + profile: None, + noise_lines_removed: 0, + llm_used: false, + llm_provider: None, + deep_liquid_used: false, + deep_liquid_model: None, + warnings: Vec::new(), + source_signature: "citation-test".into(), + })); + app.liquid_mode2_complete = true; + app.liquid_hide_footnotes = true; + let draw = |time, events, action: u8, app: &mut PdfEditorApp| { + ctx.run( + egui::RawInput { + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, Vec2::new(1100.0, 800.0))), + time: Some(time), + events, + ..Default::default() + }, + |ctx| { + if action == 1 { + app.copy_review_shortcut(ctx); + } + if action == 2 { + app.start_review_selection_citation(ctx); + ctx.copy_text("unrelated existing command".to_owned()); + } + if action == 3 { + app.poll_review_selection_citation(ctx); + } + egui::CentralPanel::default() + .show(ctx, |ui| app.draw_liquid_mode2_document(ui, ctx)); + }, + ) + }; + let _ = draw(0.0, vec![], 0, &mut app); + let output = draw(0.1, vec![], 0, &mut app); + let start = output + .shapes + .iter() + .find_map(|shape| find_text(&shape.shape, first)) + .unwrap() + + Vec2::new(15.0, 8.0); + let end = output + .shapes + .iter() + .find_map(|shape| find_text(&shape.shape, second)) + .unwrap() + + Vec2::new(80.0, 8.0); + let _ = draw(0.2, vec![egui::Event::PointerMoved(start)], 0, &mut app); + let _ = draw( + 0.3, + vec![egui::Event::PointerButton { + pos: start, + button: egui::PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::NONE, + }], + 0, + &mut app, + ); + let _ = draw(0.4, vec![egui::Event::PointerMoved(end)], 0, &mut app); + let _ = draw( + 0.5, + vec![egui::Event::PointerButton { + pos: end, + button: egui::PointerButton::Primary, + pressed: false, + modifiers: egui::Modifiers::NONE, + }], + 0, + &mut app, + ); + assert!( + ctx.plugin::() + .lock() + .has_selection() + ); + let ordinary = draw(0.6, vec![egui::Event::Copy], 1, &mut app); + let selected = ordinary + .platform_output + .commands + .iter() + .find_map(|command| match command { + egui::OutputCommand::CopyText(text) => Some(text.clone()), + _ => None, + }) + .expect("ordinary copy must produce the selected text"); + assert!(selected.contains("paragraph has selected words")); + assert!(selected.contains("Second")); + assert!(!selected.contains("Article")); + let citation = draw(0.7, vec![], 2, &mut app); + let clipboard_commands = citation + .platform_output + .commands + .iter() + .filter_map(|command| match command { + egui::OutputCommand::CopyText(text) => Some(text.as_str()), + _ => None, + }) + .collect::>(); + assert_eq!(clipboard_commands, vec!["unrelated existing command"]); + assert!(app.citation_draft.is_none()); + let _ = draw(0.8, vec![], 3, &mut app); + let draft = app + .citation_draft + .as_ref() + .expect("selection should open a citation draft"); + assert_eq!(draft.quote, selected); + assert!(draft.pinpoint.is_empty()); + assert!(draft.pdf_pages.contains("printed page required")); + app.citation_draft = None; + let _ = draw(0.9, vec![], 2, &mut app); + app.document_epoch = app.document_epoch.wrapping_add(1); + let _ = draw(1.0, vec![], 3, &mut app); + assert!( + app.citation_draft.is_none(), + "a selection from another document must be discarded" + ); + } + + #[test] + fn review_pinpoint_requires_unique_review_source_and_original_page_evidence() { + use crate::liquid::LiquidBlockSourceLines; + let (mut app, _ctx, _worker) = annotation_saving::tests::fixture(); + let pdf = app.document.as_mut().unwrap(); + pdf.page_count = 2; + pdf.pages.push(pdf.pages[0].clone()); + pdf.native_text = vec![ + "451\nSome other page.".into(), + "452\nThe unique quotation appears here.".into(), + ]; + pdf.native_text_loaded = vec![true, true]; + pdf.text_chars = vec![None, None]; + let line = |page, text: &str| LiquidSourceLineRef { + id: None, + page_index: page, + line_index: 0, + text: text.into(), + role: LiquidBlockRole::Paragraph, + note_markers: Vec::new(), + }; + let review = LiquidDocument { + title: "Article".into(), + blocks: vec![LiquidBlock { + role: LiquidBlockRole::Paragraph, + text: "The unique quotation appears here.".into(), + label: None, + }], + article_spans: Vec::new(), + block_source_lines: vec![LiquidBlockSourceLines { + block_index: 0, + lines: vec![line(1, "The unique quotation appears here.")], + }], + footnote_links: Vec::new(), + footnote_link_integrity: None, + profile: None, + noise_lines_removed: 0, + llm_used: false, + llm_provider: None, + deep_liquid_used: false, + deep_liquid_model: None, + warnings: Vec::new(), + source_signature: "citation-test".into(), + }; + let quote = "unique quotation"; + assert_eq!(review_quote_page(&review, pdf, quote, false), Some(1)); + let mut duplicate_block = review.clone(); + duplicate_block + .blocks + .push(duplicate_block.blocks[0].clone()); + assert_eq!(review_quote_page(&duplicate_block, pdf, quote, false), None); + let mut duplicate_mapping = review.clone(); + duplicate_mapping + .block_source_lines + .push(duplicate_mapping.block_source_lines[0].clone()); + assert_eq!( + review_quote_page(&duplicate_mapping, pdf, quote, false), + None + ); + let mut repeated_on_unloaded_page = review.clone(); + repeated_on_unloaded_page.block_source_lines[0] + .lines + .push(line(0, "A unique quotation also appears here.")); + pdf.native_text_loaded[0] = false; + assert_eq!( + review_quote_page(&repeated_on_unloaded_page, pdf, quote, false), + None + ); + pdf.native_text_loaded[1] = false; + assert_eq!(review_quote_page(&review, pdf, quote, false), None); + pdf.native_text_loaded[1] = true; + pdf.native_text[1] = "452\nDifferent source text.".into(); + assert_eq!(review_quote_page(&review, pdf, quote, false), None); + pdf.native_text[1] = "452\nThe unique quotation appears here.".into(); + app.view_mode = DocumentViewMode::LiquidMode2; + app.liquid_mode2_state = LiquidState::Ready(Arc::new(review)); + app.liquid_mode2_complete = false; + app.start_review_quote_citation(quote.into()); + assert!( + app.citation_draft.as_ref().unwrap().pinpoint.is_empty(), + "partial Review preparation must not infer a page" + ); + app.liquid_mode2_complete = true; + app.start_review_quote_citation(quote.into()); + let draft = app.citation_draft.as_ref().unwrap(); + assert_eq!(draft.pinpoint, "452"); + assert!(draft.pdf_pages.contains("PDF page 2")); + app.document.as_mut().unwrap().native_text[1] = + "The unique quotation appears here.\nNo printed folio.".into(); + app.start_review_quote_citation(quote.into()); + assert!( + app.citation_draft.as_ref().unwrap().pinpoint.is_empty(), + "never substitute the PDF index for the printed folio" + ); + } + + #[test] + fn review_quote_matching_handles_superscripts_and_rejects_unrelated_text() { + let (_app, _ctx, _worker) = annotation_saving::tests::fixture(); + let review = LiquidDocument { + title: "Article".into(), + blocks: vec![LiquidBlock { + role: LiquidBlockRole::Paragraph, + text: format!( + "Quoted{}12{} passage.", + crate::layout_roles::CALLOUT_START, + crate::layout_roles::CALLOUT_END + ), + label: None, + }], + article_spans: Vec::new(), + block_source_lines: Vec::new(), + footnote_links: Vec::new(), + footnote_link_integrity: None, + profile: None, + noise_lines_removed: 0, + llm_used: false, + llm_provider: None, + deep_liquid_used: false, + deep_liquid_model: None, + warnings: Vec::new(), + source_signature: "citation-test".into(), + }; + assert!(quote_belongs_to_review("Quoted¹² passage.", &review, false)); + assert!(quote_belongs_to_review("Quoted passage.", &review, true)); + assert!(!quote_belongs_to_review("Unrelated text", &review, false)); + assert!(!quote_belongs_to_review("passage.\nQuoted", &review, false)); + } + + #[test] + fn pdf_index_is_never_used_as_a_printed_pinpoint() { + let (mut app, _ctx, _worker) = annotation_saving::tests::fixture(); + let document = app.document.as_mut().unwrap(); + document.native_text[0] = "No printed page number here.".into(); + assert_eq!( + page_pinpoint(document, TextSelection::new(0, 0, 1), Some(2024)), + "" + ); + document.native_text[0] = "453\nARTICLE\nQuoted text".into(); + assert_eq!( + page_pinpoint(document, TextSelection::new(0, 0, 1), Some(2024)), + "453" + ); + } + + #[test] + fn draft_uses_the_actual_selection_and_can_be_copied_from_the_window() { + fn text_center(shape: &egui::Shape, text: &str) -> Option { + match shape { + egui::Shape::Text(shape) if shape.galley.text() == text => { + Some(shape.pos + shape.galley.size() * 0.5) + } + egui::Shape::Vec(shapes) => { + shapes.iter().find_map(|shape| text_center(shape, text)) + } + _ => None, + } + } + let (mut app, ctx, _worker) = annotation_saving::tests::fixture(); + let document = app.document.as_mut().unwrap(); + document.native_text[0] = "453\nNEW YORK UNIVERSITY\nLAW REVIEW\nVolume 99 May 2024 Number 2\nARTICLES\nGENERATIVE INTERPRETATION\nYonathan Arbel† & David A. Hoffman‡\nA quotation".into(); + document.text_chars[0] = Some( + "A quotation" + .chars() + .map(|ch| PageTextChar { + ch, + rect: None, + font_size: None, + bold: false, + italic: false, + }) + .collect(), + ); + app.selection_state.text = Some(TextSelection::new(0, 0, 10)); + app.start_copy_with_citation(); + let initial = app.citation_draft.as_ref().unwrap(); + assert_eq!(initial.quote, "A quotation"); + assert_eq!(initial.pinpoint, "453"); + assert_eq!(initial.detected_journal, "New York University Law Review"); + assert!(initial.details.journal.is_empty()); + app.citation_draft = Some(draft(CitationStyle::Plain)); + + let draw = |app: &mut PdfEditorApp, time, events| { + ctx.run( + egui::RawInput { + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, Vec2::new(1000.0, 800.0))), + time: Some(time), + events, + ..Default::default() + }, + |ctx| app.draw_citation_copy_window(ctx), + ) + }; + let _ = draw(&mut app, 0.0, vec![]); + let output = draw(&mut app, 0.1, vec![]); + let point = output + .shapes + .iter() + .find_map(|shape| text_center(&shape.shape, "Copy quotation and citation")) + .expect("copy control"); + let _ = draw(&mut app, 0.2, vec![egui::Event::PointerMoved(point)]); + let _ = draw( + &mut app, + 0.3, + vec![egui::Event::PointerButton { + pos: point, + button: egui::PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::NONE, + }], + ); + let output = draw( + &mut app, + 0.4, + vec![egui::Event::PointerButton { + pos: point, + button: egui::PointerButton::Primary, + pressed: false, + modifiers: egui::Modifiers::NONE, + }], + ); + assert!(output.platform_output.commands.iter().any(|command| matches!(command, + egui::OutputCommand::CopyText(text) if text.contains("99 N.Y.U. L. Rev. 451, 453 (2024)")))); + assert!(app.citation_draft.is_none()); + } + + #[test] + fn switching_documents_discards_a_citation_draft() { + let (mut app, ctx, _worker) = annotation_saving::tests::fixture(); + app.citation_draft = Some(draft(CitationStyle::Plain)); + app.document_epoch += 1; + let output = ctx.run(Default::default(), |ctx| app.draw_citation_copy_window(ctx)); + assert!(app.citation_draft.is_none()); + assert!(output.platform_output.commands.is_empty()); + } +} diff --git a/src/app/close_ui.rs b/src/app/close_ui.rs index d428439..eda9d5c 100644 --- a/src/app/close_ui.rs +++ b/src/app/close_ui.rs @@ -25,6 +25,7 @@ impl PdfEditorApp { return; } self.save_active_tab_state(); + self.pending_close_others.clear(); if self.has_unsaved_annotations() || self.close_target_is_saving(CloseTarget::Window) { ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); self.pending_close = Some(CloseTarget::Window); @@ -44,6 +45,47 @@ impl PdfEditorApp { } } + pub(super) fn close_other_tabs(&mut self, keep_epoch: u64, ctx: &Context) { + if self.pending_close.is_some() + || !self.tabs.iter().any(|tab| tab.document_epoch == keep_epoch) + { + return; + } + self.save_active_tab_state(); + let mut targets = self + .tabs + .iter() + .filter(|tab| tab.document_epoch != keep_epoch) + .map(|tab| { + ( + tab.document_epoch, + tab.annotations_dirty + || self.close_target_is_saving(CloseTarget::Tab(tab.document_epoch)), + ) + }) + .collect::>(); + // Ask about unsaved work before closing clean tabs, so Cancel preserves them. + targets.sort_by_key(|(_, requires_decision)| !requires_decision); + self.pending_close_others = targets.into_iter().map(|(epoch, _)| epoch).collect(); + self.continue_closing_other_tabs(ctx); + } + + fn continue_closing_other_tabs(&mut self, ctx: &Context) { + while self.pending_close.is_none() { + let Some(epoch) = self.pending_close_others.pop_front() else { + break; + }; + if let Some(index) = self.tabs.iter().position(|tab| tab.document_epoch == epoch) { + self.close_tab(index, ctx); + } + } + } + + fn cancel_pending_close(&mut self) { + self.pending_close = None; + self.pending_close_others.clear(); + } + fn discard_close_target(&mut self, target: CloseTarget) -> Result<(), String> { let store = crate::document_store::DocumentStore::new()?; for tab in &self.tabs { @@ -80,6 +122,7 @@ impl PdfEditorApp { }); match target { CloseTarget::Window => { + self.remember_open_session(); self.allow_window_close = true; ctx.send_viewport_cmd(egui::ViewportCommand::Close); } @@ -87,6 +130,7 @@ impl PdfEditorApp { if let Some(index) = self.tabs.iter().position(|tab| tab.document_epoch == epoch) { self.close_tab_without_saving(index, ctx); } + self.continue_closing_other_tabs(ctx); } } } @@ -110,19 +154,20 @@ impl PdfEditorApp { self.start_due_annotation_saves(ctx); egui::Modal::new(egui::Id::new("unsaved_changes")).show(ctx, |ui| { ui.heading("Saving annotations before closing…"); - ui.spinner(); + if !self.settings.reduce_motion { + ui.spinner(); + } if ui.button("Cancel close").clicked() { - self.pending_close = None; + self.cancel_pending_close(); } }); - ctx.request_repaint_after(RENDER_POLL_INTERVAL); return; } let title = match target { CloseTarget::Window => "Save changes before quitting?".to_owned(), CloseTarget::Tab(epoch) => { let Some(tab) = self.tabs.iter().find(|tab| tab.document_epoch == epoch) else { - self.pending_close = None; + self.cancel_pending_close(); return; }; format!("Save changes to {}?", tab.title()) @@ -148,12 +193,12 @@ impl PdfEditorApp { } } if ui.button("Cancel").clicked() { - self.pending_close = None; + self.cancel_pending_close(); } }); }); if ctx.input_mut(|input| input.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) { - self.pending_close = None; + self.cancel_pending_close(); } } } @@ -163,6 +208,43 @@ mod tests { use super::*; use crate::model::PageInfo; + #[test] + fn reduced_motion_save_before_close_does_not_poll_the_worker() { + let (mut app, ctx, _worker) = annotation_saving::tests::fixture(); + app.settings.reduce_motion = true; + ctx.style_mut(|style| style.animation_time = 0.0); + app.annotations_dirty = true; + app.pending_close = Some(CloseTarget::Window); + let path = app.document.as_ref().unwrap().path.clone(); + app.active_annotation_saves.insert( + path.clone(), + PendingAnnotationSave { + document_epoch: 7, + path, + generation: 1, + annotations: Vec::new(), + due_at: Instant::now(), + }, + ); + for frame in 0..10 { + let output = ctx.run( + egui::RawInput { + time: Some(frame as f64 * 0.2), + ..Default::default() + }, + |ctx| app.draw_unsaved_close_prompt(ctx), + ); + if frame >= 6 { + assert_eq!( + output.viewport_output[&egui::ViewportId::ROOT].repaint_delay, + Duration::MAX + ); + } + } + assert!(app.pending_close.is_some()); + assert!(app.annotations_dirty); + } + fn app_with_dirty_tab() -> (PdfEditorApp, Context) { let ctx = Context::default(); let mut app = PdfEditorApp::new( @@ -349,4 +431,40 @@ mod tests { assert_eq!(app.tabs[0].document_epoch, 9); assert!(app.pending_close.is_none()); } + + #[test] + fn close_others_asks_about_dirty_tabs_first_and_cancel_preserves_the_rest() { + let (mut app, ctx) = app_with_dirty_tab(); + let mut clean = app.tabs[0].clone(); + clean.document_epoch = 8; + clean.annotations_dirty = false; + app.tabs.push(clean.clone()); + clean.document_epoch = 9; + app.tabs.push(clean); + app.close_other_tabs(9, &ctx); + assert_eq!(app.pending_close, Some(CloseTarget::Tab(7))); + assert_eq!(app.tabs.len(), 3); + let input = egui::RawInput { + events: vec![egui::Event::Key { + key: egui::Key::Escape, + physical_key: None, + pressed: true, + repeat: false, + modifiers: egui::Modifiers::NONE, + }], + ..Default::default() + }; + let _ = ctx.run(input, |ctx| app.draw_unsaved_close_prompt(ctx)); + assert!(app.pending_close.is_none()); + assert!(app.pending_close_others.is_empty()); + assert_eq!(app.tabs.len(), 3); + + app.close_other_tabs(9, &ctx); + // Simulate the user choosing to close the dirty tab after being prompted. + app.finish_close_target(CloseTarget::Tab(7), &ctx); + assert_eq!(app.tabs.len(), 1); + assert_eq!(app.tabs[0].document_epoch, 9); + assert!(app.pending_close.is_none()); + assert!(app.pending_close_others.is_empty()); + } } diff --git a/src/app/comments_ui.rs b/src/app/comments_ui.rs index ad374e9..452ab3d 100644 --- a/src/app/comments_ui.rs +++ b/src/app/comments_ui.rs @@ -236,7 +236,7 @@ impl PdfEditorApp { .char_limit(120), ); if let Some(error) = &self.comments_ui.error { - ui.colored_label(Color32::DARK_RED, error); + ui.colored_label(ui.visuals().error_fg_color, error); } ui.horizontal(|ui| { save = ui.button("Save name").clicked() @@ -561,7 +561,7 @@ impl PdfEditorApp { }; if cache.loading || cache.pending_generation.is_some() { ui.horizontal(|ui| { - ui.spinner(); + motion::busy(ui, self.settings.reduce_motion); ui.label("Loading comments…"); }); } else if let Some(error) = cache.error.clone() { @@ -654,12 +654,13 @@ impl PdfEditorApp { .map(|(&block, _)| block); if let Some(block) = anchor { if ui.small_button("Go to passage").clicked() { - self.liquid_scroll_to_block = Some(block); + self.jump_to_review_block(block); } } else if ui .small_button(format!("Open PDF page {}", thread.root.page_index + 1)) .clicked() { + self.record_jump_origin(); self.page_index = thread.root.page_index; self.scroll_target_page = Some(thread.root.page_index); self.set_view_mode(DocumentViewMode::Pdf, ui.ctx()); diff --git a/src/app/document_opening.rs b/src/app/document_opening.rs index 248d8cb..0d3fe06 100644 --- a/src/app/document_opening.rs +++ b/src/app/document_opening.rs @@ -6,6 +6,218 @@ pub(super) struct OpenOptions { prefetch: bool, } +#[cfg(test)] +mod tests { + use super::*; + + fn empty_app() -> ( + PdfEditorApp, + Context, + crossbeam_channel::Receiver, + ) { + let ctx = Context::default(); + let mut app = PdfEditorApp::new( + &ctx, + Vec::new(), + crossbeam_channel::unbounded().1, + #[cfg(target_os = "macos")] + None, + ); + let (sender, receiver) = crossbeam_channel::unbounded(); + app.render_tx = sender.into(); + (app, ctx, receiver) + } + + fn preview() -> PreparedPage { + PreparedPage::try_from(RenderedPage { + page_index: 0, + width: 2, + height: 2, + rgba: vec![255; 16], + }) + .unwrap() + } + + #[test] + fn opening_preview_is_read_only_and_clears_on_open_failure() { + let source = tempfile::NamedTempFile::new().unwrap(); + let path = source.path().canonicalize().unwrap(); + let (mut app, ctx, requests) = empty_app(); + assert!(app.load_document_with_options(path.clone(), &ctx, true, true, false)); + assert!(matches!( + requests.try_recv().unwrap(), + RenderRequest::OpenDocument { + preview_page: Some(0), + .. + } + )); + let _ = ctx.run(Default::default(), |ctx| { + app.accept_opening_preview(path.clone(), preview(), ctx) + }); + assert!(app.opening_preview.is_some()); + assert!(app.document.is_none()); + assert!(app.tabs.is_empty()); + assert!(app.annotation_sessions.is_empty()); + let preview_texture = app.opening_preview.as_ref().unwrap().texture.id(); + app.settings.dim_original_pages = true; + let preview_output = ctx.run(Default::default(), |ctx| { + egui::CentralPanel::default().show(ctx, |ui| app.draw_opening_preview(ui)); + }); + assert!(preview_output.shapes.iter().any(|shape| matches!( + &shape.shape, + egui::Shape::Mesh(mesh) + if mesh.texture_id == preview_texture + && mesh.vertices[0].color == Color32::from_rgb(190, 180, 162) + ))); + let input = egui::RawInput { + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, Vec2::new(900.0, 700.0))), + ..Default::default() + }; + let output = ctx.run(input, |ctx| app.draw_document(ctx)); + fn has_message(shape: &egui::Shape) -> bool { + match shape { + egui::Shape::Text(text) => { + text.galley.text().contains("Editing available when ready") + } + egui::Shape::Vec(shapes) => shapes.iter().any(has_message), + _ => false, + } + } + assert!(output.shapes.iter().any(|shape| has_message(&shape.shape))); + let _ = ctx.run(Default::default(), |ctx| { + app.finish_document_open(path.clone(), Err("test failure".into()), ctx) + }); + assert!(app.opening_preview.is_none()); + assert!(app.document.is_none()); + assert!(app.tabs.is_empty()); + } + + #[test] + fn open_failure_offers_an_accessible_retry_for_the_same_file() { + let source = tempfile::NamedTempFile::new().unwrap(); + let path = source.path().canonicalize().unwrap(); + let (mut app, ctx, requests) = empty_app(); + assert!(app.load_document_with_options(path.clone(), &ctx, true, true, false)); + assert!(matches!( + requests.try_recv().unwrap(), + RenderRequest::OpenDocument { .. } + )); + let _ = ctx.run(Default::default(), |ctx| { + app.finish_document_open( + path.clone(), + Err("The file is temporarily unavailable.".into()), + ctx, + ); + }); + assert!(app.document.is_none()); + assert!(app.pending_document_opens.is_empty()); + assert!( + app.notices[0] + .message + .starts_with("Could not open this PDF:") + ); + ctx.enable_accesskit(); + let screen = Rect::from_min_size(Pos2::ZERO, Vec2::new(900.0, 700.0)); + let output = ctx.run( + egui::RawInput { + screen_rect: Some(screen), + ..Default::default() + }, + |ctx| { + app.draw_notices(ctx); + }, + ); + let tree = output.platform_output.accesskit_update.unwrap(); + let target = tree + .nodes + .iter() + .find_map(|(id, node)| (node.label() == Some("Retry opening")).then_some(*id)) + .expect("accessible retry action"); + let input = egui::RawInput { + screen_rect: Some(screen), + events: vec![egui::Event::AccessKitActionRequest( + egui::accesskit::ActionRequest { + action: egui::accesskit::Action::Click, + target, + data: None, + }, + )], + ..Default::default() + }; + let _ = ctx.run(input, |ctx| app.draw_notices(ctx)); + match requests.try_recv().expect("retry request") { + RenderRequest::OpenDocument { + path: retried, + preview_page, + .. + } => { + assert_eq!(retried, path); + assert_eq!(preview_page, Some(0)); + } + _ => panic!("retry did not request a document open"), + } + assert!(app.pending_document_opens.contains_key(&path)); + assert!(app.notices.is_empty()); + assert!(app.startup_error.is_none()); + } + + #[test] + fn failed_background_open_keeps_its_retry_options() { + let source = tempfile::NamedTempFile::new().unwrap(); + let path = source.path().canonicalize().unwrap(); + let (mut app, ctx, _requests) = empty_app(); + assert!(app.load_document_with_options(path.clone(), &ctx, false, false, true)); + let _ = ctx.run(Default::default(), |ctx| { + app.finish_document_open(path.clone(), Err("File unavailable.".into()), ctx); + }); + assert!(matches!( + &app.notices[0].action, + Some(NoticeAction::RetryOpen { path: retry_path, activate: false, prefetch: true }) + if retry_path == &path + )); + } + + #[test] + fn stale_invalid_and_background_previews_are_ignored() { + let first = tempfile::NamedTempFile::new().unwrap(); + let second = tempfile::NamedTempFile::new().unwrap(); + let first = first.path().canonicalize().unwrap(); + let second = second.path().canonicalize().unwrap(); + let (mut app, ctx, _requests) = empty_app(); + assert!(app.load_document_with_options(first.clone(), &ctx, true, true, false)); + assert!(app.load_document_with_options(second.clone(), &ctx, true, true, false)); + let _ = ctx.run(Default::default(), |ctx| { + app.accept_opening_preview(first.clone(), preview(), ctx); + assert!( + PreparedPage::try_from(RenderedPage { + page_index: 0, + width: 2, + height: 2, + rgba: vec![0; 3] + }) + .is_err() + ); + }); + assert!(app.opening_preview.is_none()); + let _ = ctx.run(Default::default(), |ctx| { + app.accept_opening_preview(second.clone(), preview(), ctx) + }); + assert!(app.opening_preview.is_some()); + let (with_document, _ctx, _worker) = annotation_saving::tests::fixture(); + app.document = with_document.document; + app.opening_preview = None; + let _ = ctx.run(Default::default(), |ctx| { + app.accept_opening_preview(second.clone(), preview(), ctx) + }); + assert!(app.opening_preview.is_none()); + } +} + +pub(super) struct OpeningPreview { + texture: egui::TextureHandle, + size: Vec2, +} + impl PdfEditorApp { pub(super) fn load_document_with_options( &mut self, @@ -15,9 +227,17 @@ impl PdfEditorApp { _render_first_page: bool, prefetch: bool, ) -> bool { + if activate { + self.first_page_open_started = Some(Instant::now()); + } let path = std::fs::canonicalize(&path).unwrap_or(path); + if activate { + self.opening_preview_target = Some(path.clone()); + self.opening_preview = None; + } if let Some(tab_index) = self.tab_index_for_path(&path) { if activate { + self.opening_preview_target = None; self.switch_to_tab(tab_index, ctx); } return true; @@ -34,6 +254,11 @@ impl PdfEditorApp { let request = RenderRequest::OpenDocument { path: path.clone(), optimize_large_documents: self.settings.optimize_large_documents, + preview_page: (activate && self.document.is_none()).then(|| { + self.settings + .recent_document_for_opened_path(&path) + .map_or(0, |recent| recent.page) + }), }; match self.render_tx.send(request) { Ok(()) => { @@ -49,6 +274,10 @@ impl PdfEditorApp { true } Err(_) => { + if self.opening_preview_target.as_ref() == Some(&path) { + self.opening_preview_target = None; + self.opening_preview = None; + } self.push_error_notice( "The PDF worker is unavailable. Close and reopen LawPDF to restart it.", ); @@ -66,12 +295,19 @@ impl PdfEditorApp { let Some(options) = self.pending_document_opens.remove(&path) else { return; }; + if self.opening_preview_target.as_ref() == Some(&path) { + self.opening_preview_target = None; + // The preview predates the verified snapshot and may depict an + // older source revision. Never reuse it after editing is enabled. + self.opening_preview = None; + } let opened = match result { Ok(opened) => opened, Err(error) => { self.open_into_sbs = false; - self.startup_error = Some(error.clone()); - self.push_error_notice(error); + let message = format!("Could not open this PDF: {error}"); + self.startup_error = Some(message.clone()); + self.push_retry_open_notice(message, path, options.activate, options.prefetch); return; } }; @@ -79,7 +315,12 @@ impl PdfEditorApp { Ok(tab) => tab, Err(error) => { self.open_into_sbs = false; - self.push_error_notice(error); + self.push_retry_open_notice( + format!("Could not open this PDF: {error}"), + path, + options.activate, + options.prefetch, + ); return; } }; @@ -90,7 +331,8 @@ impl PdfEditorApp { } self.startup_error = None; self.tabs.push(tab); - self.render_tx.set_live_documents(self.tabs.iter().map(|tab| tab.document_epoch)); + self.render_tx + .set_live_documents(self.tabs.iter().map(|tab| tab.document_epoch)); if activate { let index = self.tabs.len() - 1; self.active_tab = Some(index); @@ -111,4 +353,63 @@ impl PdfEditorApp { } ctx.request_repaint(); } + + pub(super) fn accept_opening_preview( + &mut self, + path: PathBuf, + page: PreparedPage, + ctx: &Context, + ) { + if self.document.is_some() + || self.opening_preview_target.as_ref() != Some(&path) + || !self + .pending_document_opens + .get(&path) + .is_some_and(|options| options.activate) + { + return; + } + let texture = ctx.load_texture( + "opening-pdf-preview", + page.image, + egui::TextureOptions::LINEAR, + ); + self.opening_preview = Some(OpeningPreview { + texture, + size: Vec2::new(page.width as f32, page.height as f32), + }); + if let Some(started) = self.first_page_open_started { + crate::diagnostics::timing( + "document_first_preview", + "request_to_preview", + started.elapsed(), + ); + } + ctx.request_repaint(); + } + + pub(super) fn draw_opening_preview(&self, ui: &mut egui::Ui) { + let Some(preview) = &self.opening_preview else { + return; + }; + let area = ui.available_rect_before_wrap().shrink(18.0); + let available = Vec2::new(area.width().max(1.0), (area.height() - 36.0).max(1.0)); + let scale = (available.x / preview.size.x).min(available.y / preview.size.y); + let size = preview.size * scale; + let rect = + Rect::from_center_size(Pos2::new(area.center().x, area.top() + size.y * 0.5), size); + ui.painter().image( + preview.texture.id(), + rect, + Rect::from_min_max(Pos2::ZERO, Pos2::new(1.0, 1.0)), + self.original_page_tint(), + ); + ui.painter().text( + Pos2::new(area.center().x, rect.bottom() + 18.0), + Align2::CENTER_CENTER, + "Opening PDF… Editing available when ready", + FontId::proportional(13.0), + self.app_palette().muted, + ); + } } diff --git a/src/app/file_drop.rs b/src/app/file_drop.rs new file mode 100644 index 0000000..6a2c15f --- /dev/null +++ b/src/app/file_drop.rs @@ -0,0 +1,75 @@ +//! Give an open document the same visible drop target as the empty window. +use super::*; + +impl PdfEditorApp { + pub(super) fn draw_file_drop_overlay(&self, ctx: &Context) { + if self.document.is_none() || ctx.input(|input| input.raw.hovered_files.is_empty()) { + return; + } + let bounds = ctx.content_rect(); + let painter = ctx.layer_painter(egui::LayerId::new( + egui::Order::Tooltip, + egui::Id::new("file-drop-overlay"), + )); + painter.rect_filled(bounds, 0, Color32::from_rgba_unmultiplied(24, 30, 38, 200)); + painter.text( + bounds.center(), + Align2::CENTER_CENTER, + "Drop files to open", + FontId::proportional(24.0), + Color32::WHITE, + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn contains_label(shape: &egui::Shape) -> bool { + match shape { + egui::Shape::Text(text) => text.galley.text() == "Drop files to open", + egui::Shape::Vec(shapes) => shapes.iter().any(contains_label), + _ => false, + } + } + + #[test] + fn overlay_follows_file_hover_without_changing_the_open_document() { + let (mut app, ctx, _worker) = annotation_saving::tests::fixture(); + let original = app.document.as_ref().unwrap().path.clone(); + let draw = |app: &PdfEditorApp, hovered_files| { + ctx.run( + egui::RawInput { + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, Vec2::new(800.0, 600.0))), + hovered_files, + ..Default::default() + }, + |ctx| app.draw_file_drop_overlay(ctx), + ) + }; + let output = draw(&app, vec![egui::HoveredFile::default()]); + assert!( + output + .shapes + .iter() + .any(|shape| contains_label(&shape.shape)) + ); + assert_eq!(app.document.as_ref().unwrap().path, original); + let output = draw(&app, vec![]); + assert!( + output + .shapes + .iter() + .all(|shape| !contains_label(&shape.shape)) + ); + app.document = None; + let output = draw(&app, vec![egui::HoveredFile::default()]); + assert!( + output + .shapes + .iter() + .all(|shape| !contains_label(&shape.shape)) + ); + } +} diff --git a/src/app/find_paint.rs b/src/app/find_paint.rs index bcb0343..838e8be 100644 --- a/src/app/find_paint.rs +++ b/src/app/find_paint.rs @@ -90,10 +90,15 @@ impl PdfEditorApp { self.settings.reduce_motion, ); } else if !ui.clip_rect().intersects(placement.rect) { + let clip = ui.clip_rect(); ui.scroll_to_rect_animation( placement.rect, Some(Align::TOP), - egui::style::ScrollAnimation::none(), + motion::jump_animation( + placement.rect.top() - clip.top(), + clip.height(), + self.settings.reduce_motion, + ), ); } self.search_state.landing = false; @@ -159,11 +164,7 @@ fn scroll_to_passage(ui: &egui::Ui, rect: Rect, reduce: bool) { if delta == Vec2::ZERO { return; } - let animation = if reduce || delta.y.abs() > viewport.height() { - egui::style::ScrollAnimation::none() - } else { - ui.style().scroll_animation - }; + let animation = motion::jump_animation(delta.y, viewport.height(), reduce); ui.scroll_with_delta_animation(delta, animation); } pub(super) fn passage_delta(view: Rect, rect: Rect) -> Vec2 { @@ -286,20 +287,26 @@ impl PdfEditorApp { if rendered.is_empty() { continue; } - let Some(offset) = block.text[source_offset..].find(rendered) else { + let Some(source_ranges) = review_paragraph::source_ranges( + &block.text, + source_offset, + rendered, + self.liquid_hide_footnotes, + ) else { + continue; + }; + let Some(base) = source_ranges.first().map(|range| range.start) else { continue; }; - let base = source_offset + offset; - source_offset = base + rendered.len(); + source_offset = source_ranges.last().map_or(base, |range| range.end); if self.search_state.review_anchor == Some(block_index) && visible_byte.is_none() { - let mut byte = base; + let mut character = 0; for row in &galley.rows { if pos.y + row.rect().bottom() >= ui.clip_rect().top() { - visible_byte = Some(byte); + visible_byte = source_ranges.get(character).map(|range| range.start); break; } - byte += row.glyphs.iter().map(|g| g.chr.len_utf8()).sum::() - + usize::from(row.ends_with_newline); + character += row.glyphs.len() + usize::from(row.ends_with_newline); } self.search_state.review_anchor_byte = visible_byte.unwrap_or(source_offset); } @@ -309,22 +316,23 @@ impl PdfEditorApp { continue; } let active = self.search_state.selected_hit == Some(i); - let mut byte = base; + let mut character = 0; for row in &galley.rows { let mut bounds = Rect::NOTHING; for glyph in &row.glyphs { - let next = byte + glyph.chr.len_utf8(); - if next > hit.match_start && byte < hit.match_end { + if source_ranges.get(character).is_some_and(|range| { + range.end > hit.match_start && range.start < hit.match_end + }) { bounds = bounds.union( glyph .logical_rect() .translate(pos.to_vec2() + row.pos.to_vec2()), ); } - byte = next; + character += 1; } if row.ends_with_newline { - byte += 1; + character += 1; } if bounds.is_positive() { if active && active_rect.is_none() { diff --git a/src/app/footnote_peek.rs b/src/app/footnote_peek.rs new file mode 100644 index 0000000..c61b7bf --- /dev/null +++ b/src/app/footnote_peek.rs @@ -0,0 +1,971 @@ +//! Original-page footnote previews where the PDF and Review mapping agree. +use super::*; + +fn digit(ch: char) -> Option { + match ch { + '0'..='9' => Some(ch), + '⁰' => Some('0'), + '¹' => Some('1'), + '²' => Some('2'), + '³' => Some('3'), + '⁴' => Some('4'), + '⁵' => Some('5'), + '⁶' => Some('6'), + '⁷' => Some('7'), + '⁸' => Some('8'), + '⁹' => Some('9'), + _ => None, + } +} + +fn marker_in_link(chars: &[PageTextChar], rect: PdfRect) -> Option { + let mut digits = String::new(); + for ch in chars { + let Some(bounds) = ch.rect else { + continue; + }; + let center = ( + (bounds.left + bounds.right) * 0.5, + (bounds.bottom + bounds.top) * 0.5, + ); + if !point_in_pdf_rect(center, rect) || ch.ch.is_whitespace() { + continue; + } + digits.push(digit(ch.ch)?); + if digits.len() > 3 { + return None; + } + } + digits.parse::().ok().filter(|number| *number > 0) +} + +fn preview_note(review: &LiquidDocument, block_index: usize, number: u16) -> Option { + let block = review.blocks.get(block_index)?; + if !is_review_note_display_block(block) { + return None; + } + let mut matches = split_fused_review_notes(&block.text) + .into_iter() + .filter_map(|(marker, body)| (marker.parse::().ok() == Some(number)).then_some(body)); + let body = matches.next()?; + if matches.next().is_some() || body.trim().is_empty() { + return None; + } + let body = body.trim(); + let mut preview = body.chars().take(800).collect::(); + if body.chars().count() > 800 { + preview.push('…'); + } + Some(preview) +} + +fn resolve_footnote( + review: &LiquidDocument, + chars: &[PageTextChar], + page: usize, + link: &PageLink, +) -> Option<(u16, String)> { + let target = link.destination_page?; + let number = marker_in_link(chars, link.rect)?; + let mut matches = review + .footnote_links + .iter() + .filter(|candidate| { + candidate.marker == number + && candidate.body_page_index == Some(page) + && candidate.note_page_index == Some(target) + }) + .map(|candidate| candidate.note_block_index) + .collect::>(); + matches.sort_unstable(); + matches.dedup(); + if matches.len() != 1 { + return None; + } + Some((number, preview_note(review, matches[0], number)?)) +} + +#[derive(Clone, Copy, Debug)] +struct MarkerGlyphs { + first: usize, + end: usize, + number: u16, + rect: PdfRect, + unicode_superscript: bool, +} + +/// The PDF's character order must contain a contiguous, visually adjacent +/// group of at most three digits. We do not join across whitespace or lines. +fn marker_glyphs(chars: &[PageTextChar], index: usize) -> Option { + let adjacent = |a: &PageTextChar, b: &PageTextChar| { + let (Some(a), Some(b)) = (a.rect, b.rect) else { + return false; + }; + let gap = b.left - a.right; + gap >= -1.0 + && gap <= a.height().max(b.height()).max(1.0) + && a.bottom < b.top + && b.bottom < a.top + }; + digit(chars.get(index)?.ch)?; + let mut first = index; + while first > 0 && digit(chars[first - 1].ch).is_some() { + if index - first >= 3 { + return None; + } + if !adjacent(&chars[first - 1], &chars[first]) { + return None; + } + first -= 1; + } + let mut end = index + 1; + while end < chars.len() && digit(chars[end].ch).is_some() { + if end - first >= 3 { + return None; + } + if !adjacent(&chars[end - 1], &chars[end]) { + return None; + } + end += 1; + } + if end - first > 3 { + return None; + } + let mut digits = String::new(); + let mut rect = None::; + let mut unicode_superscript = true; + for ch in &chars[first..end] { + digits.push(digit(ch.ch)?); + unicode_superscript &= !ch.ch.is_ascii_digit(); + let bounds = ch.rect?; + if ![bounds.left, bounds.bottom, bounds.right, bounds.top] + .into_iter() + .all(f32::is_finite) + || bounds.width() <= 0.0 + || bounds.height() <= 0.0 + { + return None; + } + rect = Some(match rect { + Some(previous) => PdfRect::new( + previous.left.min(bounds.left), + previous.bottom.min(bounds.bottom), + previous.right.max(bounds.right), + previous.top.max(bounds.top), + ), + None => bounds, + }); + } + if digits.starts_with('0') { + return None; + } + Some(MarkerGlyphs { + first, + end, + number: digits.parse::().ok().filter(|n| *n > 0)?, + rect: rect?, + unicode_superscript, + }) +} + +fn center(rect: PdfRect) -> (f32, f32) { + ( + (rect.left + rect.right) * 0.5, + (rect.bottom + rect.top) * 0.5, + ) +} + +/// Ordinary digits need physical evidence of a raised, smaller glyph. Unicode +/// superscript digits already carry that evidence in the extracted text. +fn is_raised_marker(chars: &[PageTextChar], marker: MarkerGlyphs, line: PdfRect) -> bool { + if marker.unicode_superscript { + return true; + } + let nearest = chars + .iter() + .enumerate() + .filter_map(|(index, character)| { + if (marker.first..marker.end).contains(&index) || !character.ch.is_alphabetic() { + return None; + } + let rect = character.rect?; + if !point_in_pdf_rect(center(rect), line) || rect.height() <= 0.0 { + return None; + } + let distance = if rect.right < marker.rect.left { + marker.rect.left - rect.right + } else if marker.rect.right < rect.left { + rect.left - marker.rect.right + } else { + 0.0 + }; + (distance <= rect.height() * 3.0).then_some((distance, rect)) + }) + .min_by(|a, b| a.0.total_cmp(&b.0)); + nearest.is_some_and(|(_, body)| { + marker.rect.height() <= body.height() * 0.85 + && marker.rect.bottom >= body.bottom + body.height() * 0.10 + }) +} + +/// Source lines can retain ordinary digits even when Review has independently +/// mapped the same glyph as a callout in its body block. +fn source_marker_count(text: &str, number: u16) -> usize { + let wanted = number.to_string(); + let mut run = String::new(); + let mut count = 0; + for ch in text.chars().chain(std::iter::once(' ')) { + if let Some(digit) = digit(ch) { + run.push(digit); + } else { + if run == wanted { + count += 1; + } + run.clear(); + } + } + count +} + +fn resolve_unlinked( + review: &LiquidDocument, + chars: &[PageTextChar], + geometry: &HashMap<(usize, usize), PdfRect>, + page: usize, + page_count: usize, + point: (f32, f32), +) -> Option<(u16, String)> { + let mut under_pointer = chars.iter().enumerate().filter(|(_, character)| { + digit(character.ch).is_some() + && character + .rect + .is_some_and(|rect| point_in_pdf_rect(point, rect)) + }); + let index = under_pointer.next()?.0; + if under_pointer.next().is_some() { + return None; + } + let marker = marker_glyphs(chars, index)?; + let mut links = review + .footnote_links + .iter() + .filter(|link| link.marker == marker.number && link.body_page_index == Some(page)); + let link = links.next()?; + if links.next().is_some() + || !link + .note_page_index + .is_some_and(|target| target < page_count) + { + return None; + } + let token = format!( + "{}{}{}", + layout_roles::CALLOUT_START, + marker.number, + layout_roles::CALLOUT_END + ); + let block = review.blocks.get(link.body_block_index)?; + if is_review_note_display_block(block) || block.text.match_indices(&token).count() != 1 { + return None; + } + let mut sources = review + .block_source_lines + .iter() + .filter(|source| source.block_index == link.body_block_index); + let source = sources.next()?; + if sources.next().is_some() { + return None; + } + let mut matching_lines = source.lines.iter().filter(|line| { + line.page_index == page + && source_marker_count(&line.text, marker.number) > 0 + && geometry.get(&(page, line.line_index)).is_some_and(|rect| { + point_in_pdf_rect(center(marker.rect), expand_pdf_rect(*rect, 1.0)) + }) + }); + let line = matching_lines.next()?; + if source_marker_count(&line.text, marker.number) != 1 || matching_lines.next().is_some() { + return None; + } + let line_rect = *geometry.get(&(page, line.line_index))?; + if !point_in_pdf_rect(center(marker.rect), expand_pdf_rect(line_rect, 1.0)) + || !is_raised_marker(chars, marker, line_rect) + { + return None; + } + // Reject another visually plausible copy of the same marker on this line. + let mut count = 0; + let mut candidates = 0; + for index in 0..chars.len() { + let Some(other) = marker_glyphs(chars, index) else { + continue; + }; + if other.first != index + || other.number != marker.number + || !point_in_pdf_rect(center(other.rect), expand_pdf_rect(line_rect, 1.0)) + { + continue; + } + candidates += 1; + if candidates > 8 { + return None; + } + if is_raised_marker(chars, other, line_rect) { + count += 1; + if count > 1 { + return None; + } + } + } + if count != 1 { + return None; + } + Some(( + marker.number, + preview_note(review, link.note_block_index, marker.number)?, + )) +} + +impl PdfEditorApp { + pub(super) fn original_footnote_peek( + &mut self, + page: usize, + link: &PageLink, + ) -> Option<(u16, String)> { + if link.destination_page.is_none() { + return None; + } + let review = match &self.liquid_mode2_state { + LiquidState::Ready(review) => Arc::clone(review), + _ => return None, + }; + let chars = self.ensure_text_chars(page)?; + resolve_footnote(&review, chars, page, link) + } + + pub(super) fn original_unlinked_footnote_peek( + &mut self, + page: usize, + point: (f32, f32), + ) -> Option<(u16, String)> { + let review = match &self.liquid_mode2_state { + LiquidState::Ready(review) => Arc::clone(review), + _ => return None, + }; + // Do not build the source-line index just because the pointer is over a page. + let chars = self.document.as_ref()?.text_chars.get(page)?.as_ref()?; + let index = chars.iter().position(|character| { + digit(character.ch).is_some() + && character + .rect + .is_some_and(|rect| point_in_pdf_rect(point, rect)) + })?; + let marker = marker_glyphs(chars, index)?; + if !review + .footnote_links + .iter() + .any(|link| link.body_page_index == Some(page) && link.marker == marker.number) + { + return None; + } + let geometry = self.review_source_geometry_for_current_document()?; + let document = self.document.as_ref()?; + let chars = document.text_chars.get(page)?.as_ref()?; + resolve_unlinked(&review, chars, &geometry, page, document.page_count, point) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::liquid::{LiquidBlockSourceLines, LiquidFootnoteLink, LiquidSourceLineRef}; + + fn character(ch: char, x: f32) -> PageTextChar { + PageTextChar { + ch, + rect: Some(PdfRect::new(x, 10.0, x + 4.0, 18.0)), + font_size: Some(8.0), + bold: false, + italic: false, + } + } + + fn review() -> LiquidDocument { + LiquidDocument { + title: "Test".into(), + blocks: vec![ + LiquidBlock { + role: LiquidBlockRole::Paragraph, + text: "Text".into(), + label: None, + }, + LiquidBlock { + role: LiquidBlockRole::Footnote, + text: "12. A source in the note.".into(), + label: None, + }, + ], + article_spans: vec![], + block_source_lines: vec![], + footnote_links: vec![LiquidFootnoteLink { + body_block_index: 0, + body_marker_ordinal: 0, + marker: 12, + note_block_index: 1, + body_page_index: Some(2), + note_page_index: Some(3), + }], + footnote_link_integrity: None, + profile: None, + noise_lines_removed: 0, + llm_used: false, + llm_provider: None, + deep_liquid_used: false, + deep_liquid_model: None, + warnings: vec![], + source_signature: String::new(), + } + } + + fn link() -> PageLink { + PageLink { + rect: PdfRect::new(9.0, 9.0, 20.0, 19.0), + url: String::new(), + destination_page: Some(3), + destination_fraction: None, + } + } + + fn unlinked_review(page: usize) -> LiquidDocument { + let mut review = review(); + let text = format!( + "A sentence.{}12{}", + layout_roles::CALLOUT_START, + layout_roles::CALLOUT_END + ); + review.blocks[0].text = text.clone(); + review.footnote_links[0].body_page_index = Some(page); + review.block_source_lines = vec![LiquidBlockSourceLines { + block_index: 0, + lines: vec![LiquidSourceLineRef { + id: None, + page_index: page, + line_index: 7, + text, + role: LiquidBlockRole::Paragraph, + note_markers: vec![], + }], + }]; + review + } + + fn unlinked_characters(unicode: bool) -> Vec { + let mut letter = character('e', 5.0); + letter.rect = Some(PdfRect::new(5.0, 10.0, 10.0, 22.0)); + letter.font_size = Some(12.0); + let mut first = character(if unicode { '¹' } else { '1' }, 10.0); + first.rect = Some(PdfRect::new(10.0, 16.0, 13.0, 22.0)); + let mut second = character(if unicode { '²' } else { '2' }, 13.0); + second.rect = Some(PdfRect::new(13.0, 16.0, 16.0, 22.0)); + vec![letter, first, second] + } + + fn source_geometry(page: usize) -> HashMap<(usize, usize), PdfRect> { + HashMap::from([((page, 7), PdfRect::new(4.0, 9.0, 45.0, 23.0))]) + } + + #[test] + fn unlinked_preview_requires_unambiguous_source_and_superscript_evidence() { + let review = unlinked_review(2); + let geometry = source_geometry(2); + let chars = unlinked_characters(false); + assert_eq!( + resolve_unlinked(&review, &chars, &geometry, 2, 4, (11.0, 19.0)), + Some((12, "A source in the note.".into())) + ); + assert_eq!( + resolve_unlinked( + &review, + &unlinked_characters(true), + &geometry, + 2, + 4, + (11.0, 19.0) + ), + Some((12, "A source in the note.".into())) + ); + let mut plain_source = review.clone(); + plain_source.block_source_lines[0].lines[0].text = "A sentence. 12".into(); + assert_eq!( + resolve_unlinked(&plain_source, &chars, &geometry, 2, 4, (11.0, 19.0)), + Some((12, "A source in the note.".into())) + ); + plain_source.block_source_lines[0].lines[0].text = "A sentence. 112".into(); + assert!(resolve_unlinked(&plain_source, &chars, &geometry, 2, 4, (11.0, 19.0)).is_none()); + let mut ordinary = chars.clone(); + ordinary[1].rect = Some(PdfRect::new(10.0, 10.0, 13.0, 22.0)); + ordinary[2].rect = Some(PdfRect::new(13.0, 10.0, 16.0, 22.0)); + assert!(resolve_unlinked(&review, &ordinary, &geometry, 2, 4, (11.0, 19.0)).is_none()); + assert!(resolve_unlinked(&review, &chars, &HashMap::new(), 2, 4, (11.0, 19.0)).is_none()); + assert!(resolve_unlinked(&review, &chars, &geometry, 1, 4, (11.0, 19.0)).is_none()); + assert!(resolve_unlinked(&review, &chars, &geometry, 2, 3, (11.0, 19.0)).is_none()); + let mut wrong = review.clone(); + wrong.blocks[0].text = "No matching callout".into(); + assert!(resolve_unlinked(&wrong, &chars, &geometry, 2, 4, (11.0, 19.0)).is_none()); + let mut duplicate_link = review.clone(); + duplicate_link + .footnote_links + .push(duplicate_link.footnote_links[0].clone()); + assert!(resolve_unlinked(&duplicate_link, &chars, &geometry, 2, 4, (11.0, 19.0)).is_none()); + duplicate_link.footnote_links[1].note_page_index = None; + assert!(resolve_unlinked(&duplicate_link, &chars, &geometry, 2, 4, (11.0, 19.0)).is_none()); + let mut duplicate_source = review.clone(); + duplicate_source + .block_source_lines + .push(duplicate_source.block_source_lines[0].clone()); + assert!( + resolve_unlinked(&duplicate_source, &chars, &geometry, 2, 4, (11.0, 19.0)).is_none() + ); + let mut duplicate_callout = review.clone(); + let token = format!( + "{}12{}", + layout_roles::CALLOUT_START, + layout_roles::CALLOUT_END + ); + duplicate_callout.block_source_lines[0].lines[0] + .text + .push_str(&token); + assert!( + resolve_unlinked(&duplicate_callout, &chars, &geometry, 2, 4, (11.0, 19.0)).is_none() + ); + let mut duplicate_glyphs = chars.clone(); + duplicate_glyphs.push(character(' ', 20.0)); + let mut other = unlinked_characters(true); + other[1].rect = Some(PdfRect::new(30.0, 16.0, 33.0, 22.0)); + other[2].rect = Some(PdfRect::new(33.0, 16.0, 36.0, 22.0)); + duplicate_glyphs.extend(other.into_iter().skip(1)); + assert!( + resolve_unlinked(&review, &duplicate_glyphs, &geometry, 2, 4, (11.0, 19.0)).is_none() + ); + let mut distant_digit = chars.clone(); + distant_digit[2].rect = Some(PdfRect::new(40.0, 16.0, 43.0, 22.0)); + assert!(resolve_unlinked(&review, &distant_digit, &geometry, 2, 4, (11.0, 19.0)).is_none()); + } + + #[test] + fn unlinked_preview_uses_real_pdfium_character_and_source_geometry() { + use lopdf::{Document, Object, Stream, dictionary}; + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("unlinked-marker.pdf"); + let mut pdf = Document::with_version("1.5"); + let catalog = pdf.new_object_id(); + let pages = pdf.new_object_id(); + let page = pdf.new_object_id(); + let font = pdf.add_object( + dictionary! { "Type" => "Font", "Subtype" => "Type1", "BaseFont" => "Helvetica" }, + ); + let contents = pdf.add_object(Stream::new(dictionary! {}, b"BT /F1 12 Tf 1 0 0 1 72 700 Tm (A sentence about the law.) Tj /F1 7 Tf 5 Ts (12) Tj ET".to_vec())); + pdf.objects.insert( + catalog, + Object::Dictionary(dictionary! { "Type" => "Catalog", "Pages" => pages }), + ); + pdf.objects.insert(pages, Object::Dictionary(dictionary! { "Type" => "Pages", "Kids" => vec![Object::Reference(page)], "Count" => 1 })); + pdf.objects.insert(page, Object::Dictionary(dictionary! { "Type" => "Page", "Parent" => pages, + "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()], + "Resources" => dictionary! { "Font" => dictionary! { "F1" => font } }, "Contents" => contents })); + pdf.trailer.set("Root", catalog); + pdf.save(&path).unwrap(); + let chars = PdfEngine::new() + .unwrap() + .load_page_text_chars(&path, 0) + .unwrap(); + let lines = crate::layout_roles::deep_source_lines_for_pages( + &[crate::model::PageInfo::new(612.0, 792.0)], + &[Some(chars.clone())], + ); + let token = format!( + "{}12{}", + layout_roles::CALLOUT_START, + layout_roles::CALLOUT_END + ); + let line = lines + .iter() + .find(|line| line.text.contains(&token)) + .expect("PDFium did not identify the synthetic raised callout"); + let mut review = unlinked_review(0); + review.blocks[0].text = line.text.clone(); + // Review can retain plain digits in a source line while the block's + // independently mapped callout carries explicit marker delimiters. + review.block_source_lines[0].lines[0].text = line + .text + .replace(layout_roles::CALLOUT_START, "") + .replace(layout_roles::CALLOUT_END, ""); + review.block_source_lines[0].lines[0].line_index = line.line_index; + review.footnote_links[0].note_page_index = Some(0); + let geometry = HashMap::from([( + (0, line.line_index), + PdfRect::new(line.left, line.bottom, line.right, line.top), + )]); + let marker = chars + .iter() + .find(|character| character.ch == '1') + .unwrap() + .rect + .unwrap(); + assert_eq!( + resolve_unlinked(&review, &chars, &geometry, 0, 1, center(marker)), + Some((12, "A source in the note.".into())) + ); + } + + #[cfg(feature = "devtools")] + #[test] + #[ignore = "requires the native Review models"] + fn unlinked_preview_uses_a_real_prepared_review_mapping() { + use lopdf::{Document, Object, Stream, dictionary}; + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("synthetic-article.pdf"); + let mut pdf = Document::with_version("1.5"); + let catalog = pdf.new_object_id(); + let pages_id = pdf.new_object_id(); + let page_id = pdf.new_object_id(); + let regular = pdf.add_object( + dictionary! { "Type" => "Font", "Subtype" => "Type1", "BaseFont" => "Times-Roman" }, + ); + let bold = pdf.add_object( + dictionary! { "Type" => "Font", "Subtype" => "Type1", "BaseFont" => "Times-Bold" }, + ); + let mut content = String::from( + "BT /F2 18 Tf 72 744 Td (A SYNTHETIC STUDY OF CONTRACT INTERPRETATION) Tj ET\nBT /F2 11 Tf 72 690 Td (I. INTRODUCTION) Tj ET\n", + ); + let lines = [ + "Courts often interpret agreements by examining their text, structure, and purpose.", + "This synthetic article illustrates a reading interface and a footnote reference.", + "The choice between ordinary meaning and context shapes the work of interpretation.", + "Readers also compare related provisions to understand how the agreement operates.", + "No sentence in this test document describes a real case or a real legal authority.", + "The purpose of the page is to test geometry, navigation, and source-line matching.", + "A second paragraph adds ordinary text so that the page resembles an article.", + "Written contracts can include definitions, conditions, remedies, and other terms.", + "The reader may select a phrase or consult the note below without leaving the page.", + "These examples are generated solely for local software testing and contain no data.", + "The final line gives the classifier enough surrounding prose to recognize a body.", + ]; + for (index, line) in lines.iter().enumerate() { + content.push_str(&format!( + "BT /F1 12 Tf 72 {} Td ({line}) Tj ", + 665 - 16 * index + )); + if index == 1 { + content.push_str("/F1 7 Tf 5 Ts (1) Tj "); + } + content.push_str("ET\n"); + } + content.push_str("72 125 m 225 125 l S\nBT /F1 9 Tf 72 111 Td (1. This generated note is an example for testing a footnote preview.) Tj ET\nBT /F1 9 Tf 72 100 Td (It has no source and makes no claim about an actual court or publication.) Tj ET\n"); + let contents = pdf.add_object(Stream::new(dictionary! {}, content.into_bytes())); + pdf.objects.insert( + catalog, + Object::Dictionary(dictionary! { "Type" => "Catalog", "Pages" => pages_id }), + ); + pdf.objects.insert(pages_id, Object::Dictionary(dictionary! { "Type" => "Pages", "Kids" => vec![Object::Reference(page_id)], "Count" => 1 })); + pdf.objects.insert(page_id, Object::Dictionary(dictionary! { "Type" => "Page", "Parent" => pages_id, + "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()], + "Resources" => dictionary! { "Font" => dictionary! { "F1" => regular, "F2" => bold } }, "Contents" => contents })); + pdf.trailer.set("Root", catalog); + pdf.save(&path).unwrap(); + let engine = PdfEngine::new().unwrap(); + let document = engine.load_document(&path).unwrap(); + let page_count = document.page_count; + let chars = (0..page_count) + .map(|page| Some(engine.load_page_text_chars(&path, page).unwrap())) + .collect::>(); + let pages = crate::layout_roles::source_pages_from_text_chars(&document.pages, &chars) + .into_iter() + .enumerate() + .map(|(page, text)| text.unwrap_or_else(|| engine.load_page_text(&path, page).unwrap())) + .collect(); + let deep = crate::layout_roles::deep_source_lines_for_pages(&document.pages, &chars); + let geometry = deep + .iter() + .map(|line| { + ( + (line.page_index, line.line_index), + PdfRect::new(line.left, line.bottom, line.right, line.top), + ) + }) + .collect::>(); + let review = + crate::liquid2::prepare_liquid_mode2_document(crate::liquid2::LiquidMode2Request { + document_epoch: 0, + path, + title: document.title, + pages, + deep_source_lines: deep, + use_pymupdf_blocks: false, + use_pp_footnote_regions: false, + external_emissions_path: None, + runtime_choice: crate::liquid2::Lm2RuntimeChoice::Automatic, + preview_only: false, + skip_progressive_preview: false, + }) + .unwrap(); + let previews = chars + .iter() + .enumerate() + .flat_map(|(page, chars)| { + let review = &review; + let geometry = &geometry; + let chars = chars.as_ref().unwrap(); + chars.iter().filter_map(move |character| { + let rect = character.rect?; + digit(character.ch)?; + resolve_unlinked(review, chars, geometry, page, page_count, center(rect)) + }) + }) + .collect::>(); + assert!( + previews + .iter() + .any(|(number, body)| *number == 1 && body.contains("generated note")), + "prepared Review did not expose the synthetic note" + ); + } + + #[test] + fn hovering_unlinked_marker_shows_preview_without_changing_navigation() { + fn contains_text(shape: &egui::Shape, expected: &str) -> bool { + match shape { + egui::Shape::Text(text) => text.galley.text().contains(expected), + egui::Shape::Vec(shapes) => { + shapes.iter().any(|shape| contains_text(shape, expected)) + } + _ => false, + } + } + let (mut app, ctx, _worker) = annotation_saving::tests::fixture(); + let document = app.document.as_mut().unwrap(); + document.page_count = 4; + document.pages = vec![crate::model::PageInfo::new(612.0, 792.0); 4]; + document.links = vec![vec![], vec![], vec![], vec![]]; + document.text_chars = vec![Some(unlinked_characters(false)), None, None, None]; + document.native_text = vec![String::new(); 4]; + document.native_text_loaded = vec![true; 4]; + app.liquid_mode2_state = LiquidState::Ready(Arc::new(unlinked_review(0))); + app.review_source_geometry = Some(ReviewSourceGeometry { + document_epoch: app.document_epoch, + loaded_pages: 1, + rects: Arc::new(source_geometry(0)), + }); + app.settings.sidebar_in_pdf = false; + app.zoom = 0.7; + app.target_zoom = 0.7; + app.scroll_target_page = None; + let texture = ctx.load_texture( + "unlinked-footnote-test", + egui::ColorImage::filled([4, 4], Color32::WHITE), + TextureOptions::LINEAR, + ); + let render_scale = app.page_render_scale(&ctx, 612.0, 792.0); + app.page_textures.insert( + 0, + PageTexture { + _zoom: app.zoom, + render_scale, + last_used: 1, + texture, + fade_started: None, + }, + ); + let draw = |app: &mut PdfEditorApp, time, events| { + ctx.run( + egui::RawInput { + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, Vec2::new(1200.0, 900.0))), + time: Some(time), + events, + ..Default::default() + }, + |ctx| app.draw_document(ctx), + ) + }; + let _ = draw(&mut app, 0.0, vec![]); + let point = app + .pdf_page_placements + .iter() + .find(|(page, _)| *page == 0) + .unwrap() + .1 + .pdf_to_screen((11.0, 19.0)); + let _ = draw(&mut app, 0.1, vec![egui::Event::PointerMoved(point)]); + let mut seen = false; + for time in [0.3, 0.6, 1.0, 1.4, 1.8] { + let output = draw(&mut app, time, vec![]); + seen |= output + .shapes + .iter() + .any(|shape| contains_text(&shape.shape, "A source in the note.")); + } + assert!(seen, "unlinked marker preview did not appear"); + assert_eq!(app.page_index, 0); + let _ = draw( + &mut app, + 1.9, + vec![egui::Event::PointerButton { + pos: point, + button: egui::PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::NONE, + }], + ); + let _ = draw( + &mut app, + 2.0, + vec![egui::Event::PointerButton { + pos: point, + button: egui::PointerButton::Primary, + pressed: false, + modifiers: egui::Modifiers::NONE, + }], + ); + assert_eq!(app.page_index, 0); + } + + #[test] + fn preview_requires_the_marker_destination_and_review_mapping_to_agree() { + let chars = vec![character('¹', 10.0), character('²', 15.0)]; + assert_eq!( + resolve_footnote(&review(), &chars, 2, &link()), + Some((12, "A source in the note.".into())) + ); + assert!(resolve_footnote(&review(), &chars, 1, &link()).is_none()); + let mut wrong = link(); + wrong.destination_page = Some(4); + assert!(resolve_footnote(&review(), &chars, 2, &wrong).is_none()); + assert!(resolve_footnote(&review(), &[character('x', 10.0)], 2, &link()).is_none()); + let mut ambiguous = review(); + ambiguous.blocks.push(ambiguous.blocks[1].clone()); + let mut duplicate = ambiguous.footnote_links[0].clone(); + duplicate.note_block_index = 2; + ambiguous.footnote_links.push(duplicate); + assert!(resolve_footnote(&ambiguous, &chars, 2, &link()).is_none()); + } + + #[test] + fn preview_waits_for_review_and_never_treats_external_links_as_notes() { + let (mut app, _ctx, _worker) = annotation_saving::tests::fixture(); + app.document.as_mut().unwrap().text_chars[0] = + Some(vec![character('¹', 10.0), character('²', 15.0)]); + assert!(app.original_footnote_peek(0, &link()).is_none()); + let mut ready = review(); + ready.footnote_links[0].body_page_index = Some(0); + app.liquid_mode2_state = LiquidState::Ready(Arc::new(ready)); + assert_eq!( + app.original_footnote_peek(0, &link()), + Some((12, "A source in the note.".into())) + ); + let mut external = link(); + external.destination_page = None; + external.url = "https://example.org".into(); + assert!(app.original_footnote_peek(0, &external).is_none()); + } + + #[test] + fn hovering_a_matched_marker_shows_the_note_and_keeps_the_link_clickable() { + fn contains_text(shape: &egui::Shape, expected: &str) -> bool { + match shape { + egui::Shape::Text(text) => text.galley.text().contains(expected), + egui::Shape::Vec(shapes) => { + shapes.iter().any(|shape| contains_text(shape, expected)) + } + _ => false, + } + } + let (mut app, ctx, _worker) = annotation_saving::tests::fixture(); + let document = app.document.as_mut().unwrap(); + document.page_count = 4; + document.pages = vec![crate::model::PageInfo::new(612.0, 792.0); 4]; + document.links = vec![vec![link()], vec![], vec![], vec![]]; + document.text_chars = vec![ + Some(vec![character('¹', 10.0), character('²', 15.0)]), + None, + None, + None, + ]; + document.native_text = vec![String::new(); 4]; + document.native_text_loaded = vec![true; 4]; + let mut ready = review(); + ready.footnote_links[0].body_page_index = Some(0); + app.liquid_mode2_state = LiquidState::Ready(Arc::new(ready)); + app.settings.sidebar_in_pdf = false; + app.zoom = 0.7; + app.target_zoom = 0.7; + app.scroll_target_page = None; + let texture = ctx.load_texture( + "footnote-peek-test", + egui::ColorImage::filled([4, 4], Color32::WHITE), + TextureOptions::LINEAR, + ); + let render_scale = app.page_render_scale(&ctx, 612.0, 792.0); + app.page_textures.insert( + 0, + PageTexture { + _zoom: app.zoom, + render_scale, + last_used: 1, + texture, + fade_started: None, + }, + ); + let bounds = Rect::from_min_size(Pos2::ZERO, Vec2::new(1200.0, 900.0)); + let draw = |app: &mut PdfEditorApp, time, events| { + ctx.run( + egui::RawInput { + screen_rect: Some(bounds), + time: Some(time), + events, + ..Default::default() + }, + |ctx| app.draw_document(ctx), + ) + }; + let _ = draw(&mut app, 0.0, vec![]); + let point = app + .pdf_page_placements + .iter() + .find(|(page, _)| *page == 0) + .unwrap() + .1 + .pdf_to_screen((12.0, 14.0)); + let _ = draw(&mut app, 0.1, vec![egui::Event::PointerMoved(point)]); + let mut seen = false; + for time in [0.3, 0.6, 1.0, 1.4, 1.8] { + let output = draw(&mut app, time, vec![]); + seen |= output + .shapes + .iter() + .any(|shape| contains_text(&shape.shape, "A source in the note.")); + } + assert!(seen, "note preview did not appear"); + assert_eq!(app.page_index, 0); + let _ = draw( + &mut app, + 1.9, + vec![egui::Event::PointerButton { + pos: point, + button: egui::PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::NONE, + }], + ); + let _ = draw( + &mut app, + 2.0, + vec![egui::Event::PointerButton { + pos: point, + button: egui::PointerButton::Primary, + pressed: false, + modifiers: egui::Modifiers::NONE, + }], + ); + assert_eq!(app.page_index, 3); + } +} diff --git a/src/app/footnote_sources_ui.rs b/src/app/footnote_sources_ui.rs index 92cf934..47ec72b 100644 --- a/src/app/footnote_sources_ui.rs +++ b/src/app/footnote_sources_ui.rs @@ -321,7 +321,7 @@ impl PdfEditorApp { Some(LookupState::Ready(result)) => Some(Arc::clone(result)), Some(LookupState::Queued) => { ui.horizontal(|ui| { - ui.spinner(); + motion::busy(ui, self.settings.reduce_motion); ui.label("Finding article sources…"); }); None diff --git a/src/app/foreign_annotations.rs b/src/app/foreign_annotations.rs new file mode 100644 index 0000000..9db6c7e --- /dev/null +++ b/src/app/foreign_annotations.rs @@ -0,0 +1,193 @@ +//! Revision-bound, read-only annotations from other PDF applications. +use super::*; +use crate::document_store::FileRevision; +use crate::model::ForeignAnnotation; + +#[derive(Default)] +pub(super) struct ForeignAnnotationsUi { + documents: HashMap, +} + +struct ForeignCache { + revision: FileRevision, + loading: bool, + error: Option, + marks: Option>>, +} + +impl ForeignAnnotationsUi { + pub(super) fn forget(&mut self, epoch: u64) { + self.documents.remove(&epoch); + } + + pub(super) fn worker_stopped(&mut self) { + for cache in self.documents.values_mut().filter(|cache| cache.loading) { + cache.loading = false; + cache.error = Some("The document worker stopped while loading annotations.".to_owned()); + } + } +} + +impl PdfEditorApp { + #[cfg(test)] + pub(super) fn set_foreign_annotations_for_test(&mut self, marks: Vec) { + let revision = self.annotation_sessions[&self.document_epoch] + .revision + .clone(); + self.foreign_annotations_ui.documents.insert( + self.document_epoch, + ForeignCache { + revision, + loading: false, + error: None, + marks: Some(Arc::new(marks)), + }, + ); + self.annotation_list_cache = None; + } + + pub(super) fn ensure_foreign_annotations(&mut self) { + let epoch = self.document_epoch; + let Some(document) = &self.document else { + return; + }; + let Some(session) = self.annotation_sessions.get(&epoch) else { + return; + }; + let revision = session.revision.clone(); + if self + .foreign_annotations_ui + .documents + .get(&epoch) + .is_some_and(|cache| cache.revision == revision) + { + return; + } + let path = document.path.clone(); + self.foreign_annotations_ui.documents.insert( + epoch, + ForeignCache { + revision: revision.clone(), + loading: true, + error: None, + marks: None, + }, + ); + if let Err(error) = self.render_tx.send(RenderRequest::ForeignAnnotations { + document_epoch: epoch, + path, + expected_revision: revision, + }) { + if let Some(cache) = self.foreign_annotations_ui.documents.get_mut(&epoch) { + cache.loading = false; + cache.error = Some(error.to_string()); + } + } + self.annotation_list_cache = None; + } + + pub(super) fn foreign_annotations(&self) -> Option>> { + let cache = self + .foreign_annotations_ui + .documents + .get(&self.document_epoch)?; + let session = self.annotation_sessions.get(&self.document_epoch)?; + (cache.revision == session.revision) + .then(|| cache.marks.clone()) + .flatten() + } + + pub(super) fn foreign_annotations_loading(&self) -> bool { + self.foreign_annotations_ui + .documents + .get(&self.document_epoch) + .is_some_and(|cache| cache.loading) + } + + pub(super) fn foreign_annotations_failed(&self) -> bool { + self.foreign_annotations_ui + .documents + .get(&self.document_epoch) + .is_some_and(|cache| cache.error.is_some()) + } + + pub(super) fn retry_foreign_annotations(&mut self) { + self.foreign_annotations_ui.forget(self.document_epoch); + self.ensure_foreign_annotations(); + } + + pub(super) fn accept_foreign_annotations( + &mut self, + epoch: u64, + path: PathBuf, + revision: FileRevision, + result: Result, String>, + ) { + let Some(session) = self.annotation_sessions.get(&epoch) else { + return; + }; + let Some(cache) = self.foreign_annotations_ui.documents.get_mut(&epoch) else { + return; + }; + if cache.revision != revision { + return; + } + cache.loading = false; + if session.revision != revision { + self.foreign_annotations_ui.documents.remove(&epoch); + return; + } + let correct_path = self + .document + .as_ref() + .is_some_and(|document| self.document_epoch == epoch && document.path == path) + || self + .tabs + .iter() + .any(|tab| tab.document_epoch == epoch && tab.document.path == path); + if !correct_path { + cache.error = + Some("The annotation result no longer matches the open document.".to_owned()); + return; + } + match result { + Ok(marks) => { + cache.marks = Some(Arc::new(marks)); + cache.error = None; + } + Err(error) => { + cache.error = Some(error); + cache.marks = None; + } + } + self.annotation_list_cache = None; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stale_result_is_discarded_and_worker_failure_can_be_retried() { + let (mut app, _ctx, requests) = annotation_saving::tests::fixture(); + let path = app.document.as_ref().unwrap().path.clone(); + let old_revision = app.annotation_sessions[&7].revision.clone(); + app.ensure_foreign_annotations(); + assert!(matches!( + requests.try_recv().unwrap(), + RenderRequest::ForeignAnnotations { .. } + )); + assert!(app.foreign_annotations_loading()); + app.annotation_sessions.get_mut(&7).unwrap().revision.sha256 = "new revision".into(); + app.accept_foreign_annotations(7, path, old_revision, Ok(Vec::new())); + assert!(app.foreign_annotations().is_none()); + app.ensure_foreign_annotations(); + assert!(app.foreign_annotations_loading()); + app.foreign_annotations_ui.worker_stopped(); + assert!(!app.foreign_annotations_loading()); + assert!(app.foreign_annotations_failed()); + app.retry_foreign_annotations(); + assert!(app.foreign_annotations_loading()); + } +} diff --git a/src/app/frame_bench.rs b/src/app/frame_bench.rs new file mode 100644 index 0000000..3fdcb70 --- /dev/null +++ b/src/app/frame_bench.rs @@ -0,0 +1,145 @@ +//! Reproducible headless Review-frame measurements. The synthetic text is not +//! a research corpus and contains no user documents. +use super::*; + +fn synthetic_article(paragraphs: usize) -> LiquidDocument { + let sentence = "The court considered the agreement and explained that the parties must give practical effect to each relevant term in the instrument. "; + let mut blocks = Vec::new(); + for index in 0..paragraphs { + if index % 12 == 0 { + blocks.push(LiquidBlock { + role: LiquidBlockRole::Heading, + text: format!("Section {}", index / 12 + 1), + label: None, + }); + } + let note = index + 1; + let text = format!( + "{}{}{}{}{} {}{}", + sentence.repeat(2), + crate::layout_roles::CALLOUT_START, + note, + crate::layout_roles::CALLOUT_END, + sentence.repeat(2), + sentence.repeat(2), + sentence + ); + blocks.push(LiquidBlock { + role: LiquidBlockRole::Paragraph, + text, + label: None, + }); + blocks.push(LiquidBlock { + role: LiquidBlockRole::Footnote, + text: format!("{note}. {}", sentence.repeat(2)), + label: None, + }); + } + LiquidDocument { + title: "Synthetic Review benchmark".to_owned(), + blocks, + article_spans: Vec::new(), + block_source_lines: Vec::new(), + footnote_links: Vec::new(), + footnote_link_integrity: None, + profile: None, + noise_lines_removed: 0, + llm_used: false, + llm_provider: None, + deep_liquid_used: false, + deep_liquid_model: None, + warnings: Vec::new(), + source_signature: format!("synthetic-{paragraphs}"), + } +} + +fn draw_frame(app: &mut PdfEditorApp, ctx: &Context, width: f32, time: f64) -> Duration { + let input = egui::RawInput { + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, Vec2::new(width, 900.0))), + time: Some(time), + ..Default::default() + }; + let start = Instant::now(); + let _ = ctx.run(input, |ctx| { + app.draw_masthead(ctx); + app.draw_document(ctx); + app.draw_markup_bar(ctx); + }); + start.elapsed() +} + +fn median(values: &mut [Duration]) -> Duration { + values.sort_unstable(); + values[values.len() / 2] +} + +/// Run with `cargo test --release --locked review_frame_cost -- --ignored --nocapture`. +/// Set LAWPDF_ENFORCE_REVIEW_BUDGETS=1 to enforce the synthetic median budgets. +/// This is a headless CPU check, not a real-window or worst-frame guarantee. +#[test] +#[ignore = "performance baseline; run in release mode on a reference machine"] +fn review_frame_cost() { + for paragraphs in [108, 448, 807] { + let (mut app, ctx, _worker) = annotation_saving::tests::fixture(); + let document = synthetic_article(paragraphs); + let original = serde_json::to_vec(&document).unwrap(); + let count = document.blocks.len(); + app.liquid_mode2_state = LiquidState::Ready(Arc::new(document)); + app.liquid_mode2_complete = true; + app.view_mode = DocumentViewMode::LiquidMode2; + app.liquid_mode2_reveal_at = Some(-100.0); + app.settings.sidebar_in_review = false; + for frame in 0..10 { + draw_frame(&mut app, &ctx, 1440.0, frame as f64 / 60.0); + } + let mut steady = (10..50) + .map(|frame| draw_frame(&mut app, &ctx, 1440.0, frame as f64 / 60.0)) + .collect::>(); + let mut resize = (0..12) + .map(|frame| { + draw_frame( + &mut app, + &ctx, + 1440.0 - 9.0 * frame as f32, + 1.0 + frame as f64 / 60.0, + ) + }) + .collect::>(); + // At wide sizes the body column may already be at its maximum width. + // Also exercise a narrower window where every resize changes wrapping. + let mut resize_reflow = (0..12) + .map(|frame| { + draw_frame( + &mut app, + &ctx, + 1060.0 - 9.0 * frame as f32, + 2.0 + frame as f64 / 60.0, + ) + }) + .collect::>(); + if let LiquidState::Ready(document) = &app.liquid_mode2_state { + assert_eq!(serde_json::to_vec(document.as_ref()).unwrap(), original); + } else { + panic!("benchmark document was unexpectedly replaced"); + } + let steady_ms = median(&mut steady).as_secs_f64() * 1000.0; + let resize_ms = median(&mut resize).as_secs_f64() * 1000.0; + let reflow_ms = median(&mut resize_reflow).as_secs_f64() * 1000.0; + println!( + "paragraphs={paragraphs} blocks={count} steady_ms={:.3} resize_ms={:.3} resize_reflow_ms={:.3}", + steady_ms, resize_ms, reflow_ms + ); + if std::env::var_os("LAWPDF_ENFORCE_REVIEW_BUDGETS").as_deref() + == Some(std::ffi::OsStr::new("1")) + { + assert!( + steady_ms < 1.5, + "{paragraphs} paragraphs: median steady frame {steady_ms:.3} ms exceeds 1.5 ms" + ); + assert!( + resize_ms < 8.0 && reflow_ms < 8.0, + "{paragraphs} paragraphs: median resize frames {resize_ms:.3}/{reflow_ms:.3} ms exceed 8 ms" + ); + } + } +} diff --git a/src/app/jump_history.rs b/src/app/jump_history.rs new file mode 100644 index 0000000..4ac0270 --- /dev/null +++ b/src/app/jump_history.rs @@ -0,0 +1,530 @@ +//! Bounded, per-document history of explicit navigation, not ordinary scrolling. +use super::*; + +const MAX_JUMPS: usize = 100; + +pub(super) fn consume_history_key(ctx: &Context, enabled: bool) -> Option { + if !enabled { + return None; + } + ctx.input_mut(|input| { + if input.consume_key(egui::Modifiers::ALT, egui::Key::ArrowLeft) { + Some(false) + } else if input.consume_key(egui::Modifiers::ALT, egui::Key::ArrowRight) { + Some(true) + } else { + None + } + }) +} + +#[derive(Clone, Copy, Debug, PartialEq)] +struct Location { + view: DocumentViewMode, + page: usize, + fraction: f32, + review_block: Option, +} + +#[derive(Default)] +pub(super) struct JumpHistory { + back: Vec, + forward: Vec, +} + +impl JumpHistory { + fn push(stack: &mut Vec, location: Location) { + if stack.last() == Some(&location) { + return; + } + if stack.len() == MAX_JUMPS { + stack.remove(0); + } + stack.push(location); + } + + fn record(&mut self, origin: Location) { + Self::push(&mut self.back, origin); + self.forward.clear(); + } + + fn navigate(&mut self, current: Location, forward: bool) -> Option { + let (source, destination) = if forward { + (&mut self.forward, &mut self.back) + } else { + (&mut self.back, &mut self.forward) + }; + let target = source.pop()?; + Self::push(destination, current); + Some(target) + } +} + +impl PdfEditorApp { + fn current_jump_location(&self) -> Option { + self.document.as_ref()?; + let visible = self.visible_page_ranges.first(); + let (page, fraction) = if self.view_mode == DocumentViewMode::Pdf { + let pending = if self.sbs_mode.is_some() { + self.sbs_pane_states + .get(&self.document_epoch) + .and_then(|state| state.destination_fraction) + } else { + self.pending_resume_fraction + }; + pending + .or_else(|| self.scroll_target_page.map(|page| (page, 0.0))) + .unwrap_or_else(|| { + visible.map_or((self.page_index, 0.0), |range| { + (range.page_index, range.top_fraction.clamp(0.0, 1.0)) + }) + }) + } else { + (self.page_index, 0.0) + }; + Some(Location { + view: self.view_mode, + page, + fraction, + review_block: (self.view_mode != DocumentViewMode::Pdf) + .then(|| { + self.liquid_scroll_to_block + .or(self.search_state.review_anchor) + }) + .flatten(), + }) + } + + pub(super) fn record_jump_origin(&mut self) { + if let Some(origin) = self.current_jump_location() { + self.jump_histories + .entry(self.document_epoch) + .or_default() + .record(origin); + } + } + + pub(super) fn jump_to_page(&mut self, page: usize) { + self.record_jump_origin(); + self.go_to_page(page); + } + + pub(super) fn jump_to_pdf_destination(&mut self, page: usize, fraction: Option) { + self.jump_to_page(page); + if self.view_mode == DocumentViewMode::Pdf { + self.set_pdf_destination_fraction(self.page_index, fraction); + } + } + + pub(super) fn set_pdf_destination_fraction(&mut self, page: usize, fraction: Option) { + let Some(fraction) = fraction.filter(|fraction| fraction.is_finite()) else { + return; + }; + let destination = Some((page, fraction.clamp(0.0, 1.0))); + if self.sbs_mode.is_some() { + self.seed_sbs_pane_state(self.document_epoch); + if let Some(state) = self.sbs_pane_states.get_mut(&self.document_epoch) { + state.destination_fraction = destination; + } + } else { + self.pending_resume_fraction = destination; + } + } + + pub(super) fn jump_to_review_block(&mut self, block: usize) { + self.record_jump_origin(); + self.liquid_scroll_to_block = Some(block); + } + + pub(super) fn navigate_jump_history(&mut self, ctx: &Context, forward: bool) { + let Some(current) = self.current_jump_location() else { + return; + }; + let Some(target) = self + .jump_histories + .get_mut(&self.document_epoch) + .and_then(|history| history.navigate(current, forward)) + else { + return; + }; + self.pending_reference_note = None; + self.search_state.landing = false; + self.search_state.follow_result = false; + self.pending_resume_fraction = None; + self.pending_resume_review_block = None; + self.set_view_mode(target.view, ctx); + self.go_to_page(target.page); + if target.view == DocumentViewMode::Pdf { + self.set_pdf_destination_fraction(self.page_index, Some(target.fraction)); + } else if let Some(block) = target.review_block { + self.liquid_scroll_to_block = Some(block); + } + ctx.request_repaint(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn page(page: usize) -> Location { + Location { + view: DocumentViewMode::Pdf, + page, + fraction: 0.25, + review_block: None, + } + } + + #[test] + fn back_forward_and_branching_preserve_origins() { + let mut history = JumpHistory::default(); + history.record(page(0)); + history.record(page(4)); + assert_eq!(history.navigate(page(8), false), Some(page(4))); + assert_eq!(history.navigate(page(4), false), Some(page(0))); + assert_eq!(history.navigate(page(0), true), Some(page(4))); + history.record(page(4)); + assert_eq!(history.navigate(page(9), true), None); + assert_eq!(history.navigate(page(9), false), Some(page(4))); + } + + #[test] + fn history_is_bounded() { + let mut history = JumpHistory::default(); + for n in 0..MAX_JUMPS + 5 { + history.record(page(n)); + } + assert_eq!(history.back.len(), MAX_JUMPS); + assert_eq!(history.back.first(), Some(&page(5))); + } + + #[test] + fn new_page_jumps_clear_an_older_or_nonfinite_destination() { + let (mut app, _ctx, _worker) = annotation_saving::tests::fixture(); + app.pending_resume_fraction = Some((0, 0.8)); + app.jump_to_pdf_destination(0, Some(f32::NAN)); + assert_eq!(app.pending_resume_fraction, None); + app.jump_to_pdf_destination(0, Some(2.0)); + assert_eq!(app.pending_resume_fraction, Some((0, 1.0))); + app.jump_to_page(0); + assert_eq!(app.pending_resume_fraction, None); + } + + #[test] + fn clicking_pdf_link_jumps_and_back_restores_origin() { + let (mut app, ctx, _worker) = annotation_saving::tests::fixture(); + let doc = app.document.as_mut().unwrap(); + doc.page_count = 4; + doc.pages = vec![crate::model::PageInfo::new(612.0, 792.0); 4]; + doc.native_text = vec![String::new(); 4]; + doc.native_text_loaded = vec![true; 4]; + doc.text_chars = vec![Some(Vec::new()); 4]; + let rect = PdfRect::new(70.0, 580.0, 180.0, 650.0); + doc.links = vec![ + vec![PageLink { + rect, + url: String::new(), + destination_page: Some(2), + destination_fraction: Some(0.25), + }], + vec![], + vec![], + vec![], + ]; + app.settings.sidebar_in_pdf = false; + app.settings.reduce_motion = true; + app.fit_width_on_first_draw = false; + app.scroll_target_page = None; + let frame = |app: &mut PdfEditorApp, time, events| { + let input = egui::RawInput { + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, Vec2::new(900.0, 700.0))), + time: Some(time), + events, + ..Default::default() + }; + let _ = ctx.run(input, |ctx| app.draw_document(ctx)); + }; + frame(&mut app, 0.0, vec![]); + let placement = app + .pdf_page_placements + .iter() + .find(|(page, _)| *page == 0) + .unwrap() + .1; + let point = placement.pdf_rect_to_screen(rect).center(); + frame(&mut app, 0.1, vec![egui::Event::PointerMoved(point)]); + for (time, pressed) in [(0.2, true), (0.3, false)] { + frame( + &mut app, + time, + vec![egui::Event::PointerButton { + pos: point, + button: egui::PointerButton::Primary, + pressed, + modifiers: egui::Modifiers::NONE, + }], + ); + } + assert_eq!( + app.scroll_target_page, + Some(2), + "the link click must queue the destination" + ); + assert_eq!(app.pending_resume_fraction, Some((2, 0.25))); + frame(&mut app, 0.4, vec![]); + assert_eq!(app.page_index, 2); + let landed = app + .visible_page_ranges + .iter() + .find(|range| range.page_index == 2) + .unwrap(); + assert!( + (landed.top_fraction - 0.25).abs() < 0.03, + "landed at {}", + landed.top_fraction + ); + let _ = ctx.run(Default::default(), |ctx| { + app.navigate_jump_history(ctx, false) + }); + assert_eq!(app.page_index, 0); + assert!(app.pending_resume_fraction.is_some()); + } + + #[test] + fn each_document_has_its_own_jump_stack() { + let (mut app, ctx, _worker) = annotation_saving::tests::fixture(); + app.record_jump_origin(); + app.document_epoch = 8; + app.page_index = 4; + let _ = ctx.run(Default::default(), |ctx| { + app.navigate_jump_history(ctx, false) + }); + assert_eq!(app.page_index, 4); + app.document_epoch = 7; + let _ = ctx.run(Default::default(), |ctx| { + app.navigate_jump_history(ctx, false) + }); + assert_eq!(app.page_index, 0); + } + + #[test] + fn history_shortcuts_do_not_consume_text_editor_input() { + let ctx = Context::default(); + let event = egui::Event::Key { + key: egui::Key::ArrowLeft, + physical_key: None, + pressed: true, + repeat: false, + modifiers: egui::Modifiers::ALT, + }; + let input = egui::RawInput { + events: vec![event], + modifiers: egui::Modifiers::ALT, + ..Default::default() + }; + let _ = ctx.run(input, |ctx| { + assert_eq!(consume_history_key(ctx, false), None); + assert_eq!(consume_history_key(ctx, true), Some(false)); + assert_eq!(consume_history_key(ctx, true), None); + }); + } + + #[test] + fn stale_navigation_result_cannot_replace_newer_document_revision() { + let (mut app, ctx, _worker) = annotation_saving::tests::fixture(); + let path = app.document.as_ref().unwrap().path.clone(); + app.document_navigation_revisions.insert(7, 1); + let navigation = |title: &str| PdfNavigation { + links: vec![vec![]], + outline: vec![PdfOutlineItem { + title: title.into(), + page_index: Some(0), + page_fraction: None, + level: 0, + }], + }; + app.document_links_tx + .send(DocumentLinksEvent { + document_epoch: 7, + path: path.clone(), + revision: 0, + result: Ok(navigation("stale")), + }) + .unwrap(); + app.document_links_tx + .send(DocumentLinksEvent { + document_epoch: 7, + path, + revision: 1, + result: Ok(navigation("fresh")), + }) + .unwrap(); + let _ = ctx.run(Default::default(), |ctx| app.poll_document_links(ctx)); + assert_eq!(app.pdf_outlines[&7][0].title, "fresh"); + assert!(app.document.as_ref().unwrap().links_loaded); + } + + #[test] + fn pdf_bookmark_is_clickable_before_review_is_ready() { + fn text_center(shape: &egui::Shape, text: &str) -> Option { + match shape { + egui::Shape::Text(shape) if shape.galley.text() == text => { + Some(shape.pos + shape.galley.size() * 0.5) + } + egui::Shape::Vec(shapes) => { + shapes.iter().find_map(|shape| text_center(shape, text)) + } + _ => None, + } + } + let (mut app, ctx, _worker) = annotation_saving::tests::fixture(); + let doc = app.document.as_mut().unwrap(); + doc.page_count = 3; + doc.pages = vec![crate::model::PageInfo::new(612.0, 792.0); 3]; + app.pdf_outlines.insert( + 7, + Arc::new(vec![PdfOutlineItem { + title: "Chapter Two".into(), + page_index: Some(2), + page_fraction: Some(0.3), + level: 0, + }]), + ); + let draw = |app: &mut PdfEditorApp, time, events| { + let input = egui::RawInput { + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, Vec2::new(600.0, 500.0))), + time: Some(time), + events, + ..Default::default() + }; + ctx.run(input, |ctx| { + egui::CentralPanel::default().show(ctx, |ui| app.draw_outline_tab(ui)); + }) + }; + let output = draw(&mut app, 0.0, vec![]); + let point = output + .shapes + .iter() + .find_map(|shape| text_center(&shape.shape, "Chapter Two")) + .expect("bookmark is shown in PDF view"); + draw(&mut app, 0.1, vec![egui::Event::PointerMoved(point)]); + for (time, pressed) in [(0.2, true), (0.3, false)] { + draw( + &mut app, + time, + vec![egui::Event::PointerButton { + pos: point, + button: egui::PointerButton::Primary, + pressed, + modifiers: egui::Modifiers::NONE, + }], + ); + } + assert_eq!(app.scroll_target_page, Some(2)); + assert_eq!(app.pending_resume_fraction, Some((2, 0.3))); + } + + #[test] + fn clicking_link_in_other_side_by_side_pane_focuses_and_navigates_it() { + fn page_rect(shape: &egui::Shape) -> Option { + match shape { + egui::Shape::Rect(shape) if shape.fill == PAPER_FILL => Some(shape.rect), + egui::Shape::Vec(shapes) => shapes.iter().find_map(page_rect), + _ => None, + } + } + let (mut app, ctx, _worker) = annotation_saving::tests::fixture(); + app.scroll_target_page = None; + let mut second = app.document.as_ref().unwrap().clone(); + second.page_count = 4; + second.pages = vec![crate::model::PageInfo::new(612.0, 792.0); 4]; + second.native_text = vec![String::new(); 4]; + second.native_text_loaded = vec![true; 4]; + second.text_chars = vec![Some(Vec::new()); 4]; + let link_rect = PdfRect::new(70.0, 580.0, 180.0, 650.0); + second.links = vec![ + vec![PageLink { + rect: link_rect, + url: String::new(), + destination_page: Some(2), + destination_fraction: Some(0.25), + }], + vec![], + vec![], + vec![], + ]; + let mut tab = app.active_tab_snapshot(second); + tab.document_epoch = 8; + tab.scroll_target_page = None; + app.tabs.push(tab); + app.pdf_outlines.insert(8, Arc::new(vec![])); + app.sbs_mode = Some(SbsModeState { + left_epoch: 7, + right_epoch: 8, + }); + app.active_tool = Tool::Select; + let draw = |app: &mut PdfEditorApp, time, events| { + let input = egui::RawInput { + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, Vec2::new(1200.0, 700.0))), + time: Some(time), + events, + ..Default::default() + }; + ctx.run(input, |ctx| { + egui::CentralPanel::default().show(ctx, |ui| app.draw_sbs_document(ui, ctx)); + }) + }; + let output = draw(&mut app, 0.0, vec![]); + let rect = output + .shapes + .iter() + .filter_map(|shape| page_rect(&shape.shape)) + .filter(|rect| rect.center().x > 600.0) + .min_by(|a, b| a.top().total_cmp(&b.top())) + .expect("right pane's page is painted"); + let point = PagePlacement { + rect, + page_width: 612.0, + page_height: 792.0, + } + .pdf_rect_to_screen(link_rect) + .center(); + draw(&mut app, 0.1, vec![egui::Event::PointerMoved(point)]); + for (time, pressed) in [(0.2, true), (0.3, false)] { + draw( + &mut app, + time, + vec![egui::Event::PointerButton { + pos: point, + button: egui::PointerButton::Primary, + pressed, + modifiers: egui::Modifiers::NONE, + }], + ); + } + assert_eq!(app.document_epoch, 8); + assert_eq!(app.scroll_target_page, Some(2)); + assert_eq!( + app.sbs_pane_states[&8].destination_fraction, + Some((2, 0.25)) + ); + for step in 4..20 { + draw(&mut app, step as f64 * 0.1, vec![]); + } + let landed = app + .visible_page_ranges + .iter() + .find(|range| range.page_index == 2) + .unwrap(); + assert!( + (landed.top_fraction - 0.25).abs() < 0.03, + "landed at {}", + landed.top_fraction + ); + let _ = ctx.run(Default::default(), |ctx| { + app.navigate_jump_history(ctx, false) + }); + assert_eq!(app.scroll_target_page, Some(0)); + } +} diff --git a/src/app/mod.rs b/src/app/mod.rs index 84e0337..29ae6ee 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1,12 +1,23 @@ mod accessibility; +mod annotation_digest; +mod annotation_list; mod annotation_saving; mod annotation_session; mod chat_ui; mod chrome; +mod citation_copy; mod close_ui; mod comments_ui; mod document_opening; +mod file_drop; +mod find_paint; +mod footnote_peek; mod footnote_sources_ui; +mod foreign_annotations; +#[cfg(test)] +mod frame_bench; +mod jump_history; +mod motion; #[cfg(target_os = "linux")] mod platform_ui; #[cfg(any(target_os = "macos", target_os = "linux"))] @@ -14,22 +25,30 @@ mod print_ui; #[cfg(target_os = "windows")] #[path = "print_windows_ui.rs"] mod print_ui; +mod reading_history; +mod reading_navigation; mod recovery_ui; +mod review_intro; +mod review_layout; mod review_margin_ui; +mod review_paragraph; +mod review_references; mod review_reveal; mod save_as; -mod find_paint; mod search_state; mod selection_state; mod settings_ui; mod speech_ui; mod support_ui; +mod thumbnail_layout; +mod theme; mod tts_controller; mod update_ui; use chat_ui::{ChatState, ChatUi, estimate_tokens}; use chrome::ChromeState; use close_ui::CloseTarget; +use motion::{ease_in_cubic, ease_out_cubic}; use review_margin_ui::ReviewMarginCollector; use search_state::SearchState; use selection_state::SelectionState; @@ -65,9 +84,12 @@ use crate::liquid2::{ LiquidMode2Event, LiquidMode2Request, Lm2RuntimeChoice, load_fast_cached_liquid_mode2_document, spawn_liquid_mode2_job, }; +#[cfg(test)] +use crate::model::RenderedPage; use crate::model::{ - AnnotationKind, EditorAnnotation, LoadedDocument, MarkerStyle, OcrPageState, PageLink, - PageTextChar, PdfRect, RenderedPage, SearchHit, SearchSource, Tool, + AnnotationKind, EditorAnnotation, ForeignAnnotation, ForeignAnnotationKind, LoadedDocument, + MarkerStyle, OcrPageState, PageLink, PageTextChar, PdfNavigation, PdfOutlineItem, PdfRect, + SearchHit, SearchSource, Tool, }; use crate::ocr::{ OcrEvent, load_ocr_cache, save_ocr_cache, spawn_local_ocr_save_job, spawn_ocr_job, @@ -77,7 +99,8 @@ use crate::pdf_backend::{ LAWPDF_COMMENT_ID_PREFIX, PdfEngine, export_text, sidecar_path_for_export, }; use crate::render_worker::{ - PageRenderKey, RenderEvent, RenderRequest, ThumbnailRenderKey, spawn_render_worker, + PageRenderKey, PreparedPage, RenderEvent, RenderRequest, ThumbnailRenderKey, + spawn_render_worker, }; use crate::review_reading::{ REVIEW_MARGIN_GAP, REVIEW_MARGIN_MIN_WIDTH, REVIEW_PARAGRAPH_GAP, ReviewPrepareAction, @@ -135,6 +158,7 @@ const SBS_PAGE_MARGIN: f32 = 20.0; const SBS_TEXTURE_CACHE_CAP: usize = PAGE_TEXTURE_CACHE_CAP * 2; const PAGE_PREFETCH_RADIUS: usize = 3; const PAGE_TEXTURE_CACHE_CAP: usize = 32; +const TEXTURE_BUDGET_BYTES: usize = 256 * 1024 * 1024; const SMALL_DOCUMENT_PREFETCH_LIMIT: usize = 6; const NOTCHED_WHEEL_IMMEDIATE_SHARE: f32 = 0.60; const NOTCHED_WHEEL_POINT_CHUNK: f32 = 6.0; @@ -143,6 +167,59 @@ const UPDATE_RETRY_INTERVAL: Duration = Duration::from_secs(30 * 60); const UPDATE_SUCCESS_NOTICE_DURATION: Duration = Duration::from_secs(15); const NOTICE_CAPACITY: usize = 5; const INFO_NOTICE_DURATION: Duration = Duration::from_secs(6); + +/// Adapt legacy worker senders so a sleeping UI is notified after each result. +/// The relay exits as soon as the worker drops its last sender. +fn worker_waking_sender(destination: Sender, ctx: Context) -> Sender { + let (tx, rx) = unbounded(); + std::thread::spawn(move || { + while let Ok(event) = rx.recv() { + if destination.send(event).is_err() { + break; + } + ctx.request_repaint(); + } + }); + tx +} + +struct FrameStallTimer { + started: Instant, + phase_started: Instant, + phase: &'static str, + slowest: (&'static str, Duration), +} + +impl FrameStallTimer { + fn new() -> Self { + let now = Instant::now(); + Self { + started: now, + phase_started: now, + phase: "poll", + slowest: ("poll", Duration::ZERO), + } + } + fn phase(&mut self, next: &'static str) { + let now = Instant::now(); + let elapsed = now.saturating_duration_since(self.phase_started); + if elapsed > self.slowest.1 { + self.slowest = (self.phase, elapsed); + } + self.phase = next; + self.phase_started = now; + } +} + +impl Drop for FrameStallTimer { + fn drop(&mut self) { + self.phase("finished"); + let elapsed = self.started.elapsed(); + if elapsed >= Duration::from_millis(100) { + crate::diagnostics::slow_frame(elapsed, self.slowest.0, self.slowest.1); + } + } +} const ANNOTATION_AUTOSAVE_DELAY: Duration = Duration::from_millis(650); const COMMENT_CARD_WIDTH: f32 = 324.0; const COMMENT_CARD_GAP: f32 = 14.0; @@ -159,15 +236,6 @@ const MARKER_WIPE_DURATION: Duration = Duration::from_millis(260); const MARKER_SETTLE_DURATION: Duration = Duration::from_millis(160); const MARKER_STAGGER: Duration = Duration::from_millis(65); const MARKER_CORNER_RADIUS: u8 = 2; -// Settled highlights stay gently alive: a long, low-amplitude opacity drift and -// an occasional faint sheen. Each mark gets its own deterministic phase/cycle, -// so a page never pulses in unison. Disabled under reduced motion. -const MARKER_BREATH_RATE: f32 = 0.66; -const MARKER_BREATH_AMPLITUDE: f32 = 0.045; -const MARKER_SHEEN_CYCLE_BASE: f32 = 12.0; -const MARKER_SHEEN_CYCLE_VARIANCE: f32 = 5.0; -const MARKER_SHEEN_ALPHA: f32 = 0.11; -const MARKER_BREATH_REPAINT: Duration = Duration::from_millis(80); fn accelerate_notched_wheel(events: &mut Vec, line_scroll_speed: f32) { let mut accelerated = Vec::with_capacity(events.len() + 4); @@ -309,7 +377,7 @@ fn eb_garamond_candidates() -> Vec { } fn install_paper_theme(ctx: &Context) { - let mut style = (*ctx.style()).clone(); + let mut style = (*ctx.style_of(egui::Theme::Light)).clone(); style.visuals = egui::Visuals::light(); style.visuals.panel_fill = PANEL_FILL; style.visuals.window_fill = BAR_FILL; @@ -330,10 +398,33 @@ fn install_paper_theme(ctx: &Context) { style.spacing.scroll.interact_handle_opacity = 0.95; style.spacing.item_spacing = Vec2::new(8.0, 8.0); style.spacing.button_padding = Vec2::new(9.0, 5.0); - ctx.set_style(style); + ctx.set_style_of(egui::Theme::Light, style.clone()); + + let palette = theme::DARK; + style.visuals = egui::Visuals::dark(); + style.visuals.panel_fill = palette.panel; + style.visuals.window_fill = palette.bar; + style.visuals.window_stroke = Stroke::new(1.0_f32, palette.rule); + style.visuals.extreme_bg_color = Color32::from_rgb(22, 27, 36); + style.visuals.faint_bg_color = Color32::from_rgb(39, 46, 57); + style.visuals.hyperlink_color = palette.accent; + style.visuals.weak_text_color = Some(palette.muted); + style.visuals.widgets.noninteractive.fg_stroke.color = palette.ink; + style.visuals.widgets.inactive.fg_stroke.color = palette.ink; + style.visuals.widgets.hovered.fg_stroke.color = palette.ink; + style.visuals.widgets.active.fg_stroke.color = palette.ink; + style.visuals.widgets.inactive.bg_fill = Color32::from_rgb(42, 49, 61); + style.visuals.widgets.hovered.bg_fill = palette.hover; + style.visuals.widgets.active.bg_fill = Color32::from_rgb(68, 77, 90); + style.visuals.selection.bg_fill = Color32::from_rgb(104, 77, 80); + style.visuals.selection.stroke = Stroke::new(1.0_f32, palette.ink); + ctx.set_style_of(egui::Theme::Dark, style); + ctx.options_mut(|options| options.fallback_theme = egui::Theme::Light); } pub struct PdfEditorApp { + repaint_context: Context, + appearance_palette: theme::Palette, startup_error: Option, tabs: Vec, active_tab: Option, @@ -367,6 +458,10 @@ pub struct PdfEditorApp { review_reveal_block: std::cell::Cell>, /// Labels measured while drawing this frame, taken by the reveal after. review_reveal_labels: std::cell::RefCell>, + /// Retain shaped Review paragraphs while the document and width stay fixed. + review_paragraph_cache: std::cell::RefCell, + review_layout: Option, + review_margin_rows: Option, /// Scroll Review Mode without egui's scroll animation (the reveal is the /// animation). liquid_scroll_instant: bool, @@ -382,6 +477,7 @@ pub struct PdfEditorApp { liquid_text_scale: f32, liquid_max_width: f32, liquid_theme: LiquidTheme, + liquid_theme_follows_appearance: bool, liquid_tx: Sender, liquid_rx: Receiver, liquid_mode2_tx: Sender, @@ -391,7 +487,10 @@ pub struct PdfEditorApp { notices: VecDeque, zoom: f32, target_zoom: f32, + fit_width_on_first_draw: bool, document_layout_cache: Option, + thumbnail_layout_cache: Option>, + annotation_list_cache: Option, page_textures: HashMap, sbs_mode: Option, sbs_pane_states: HashMap, @@ -400,9 +499,16 @@ pub struct PdfEditorApp { thumbnail_textures: HashMap, render_tx: crate::render_worker::RenderSender, render_rx: Receiver, + deferred_render_events: VecDeque, + texture_install_frame: Option, + texture_installs_this_frame: usize, document_links_tx: Sender, document_links_rx: Receiver, - pending_document_links: HashSet<(u64, PathBuf)>, + pending_document_links: HashSet<(u64, PathBuf, u64)>, + document_navigation_revisions: HashMap, + pdf_outlines: HashMap>>, + failed_pdf_navigation: HashSet, + jump_histories: HashMap, pending_document_enrichments: HashSet<(u64, PathBuf)>, incoming_paths_rx: Receiver>, #[cfg(target_os = "macos")] @@ -413,6 +519,7 @@ pub struct PdfEditorApp { pending_native_text: HashSet, pending_accessible_text: HashSet<(u64, usize)>, pending_text_chars: HashSet, + text_char_errors: HashSet<(u64, usize)>, texture_access_counter: u64, last_zoom_change: Option, annotations: Vec, @@ -421,6 +528,8 @@ pub struct PdfEditorApp { /// #27 footnote popovers: body superscript marker number -> footnote text, /// rebuilt each frame from the rendered document's Footnote/Marginalia blocks. liquid_footnote_index: HashMap, + review_references: review_references::ReferenceIndex, + pending_reference_note: Option<(usize, u16)>, footnote_sources_ui: footnote_sources_ui::FootnoteSourcesUi, /// Pending exact Review Mode block selected from the right-hand contents rail. liquid_scroll_to_block: Option, @@ -437,6 +546,15 @@ pub struct PdfEditorApp { liquid_hide_footnotes: bool, tts_controller: TtsController, marker_animations: Vec, + pending_reading_scroll: Option, + shortcuts_open: bool, + pending_reading_resume: Option, + pending_resume_fraction: Option<(usize, f32)>, + pending_resume_review_block: Option, + resume_notice: Option, + recent_needs_touch: bool, + recent_suppressed_epoch: Option, + session_history_suppressed: bool, active_tool: Tool, sidebar_tab: SidebarTab, selection_state: SelectionState, @@ -462,10 +580,17 @@ pub struct PdfEditorApp { active_annotation_saves: HashMap, annotation_save_generation: u64, comments_ui: comments_ui::CommentsUi, + foreign_annotations_ui: foreign_annotations::ForeignAnnotationsUi, #[cfg(target_os = "linux")] platform_ui: platform_ui::PlatformUi, annotation_sessions: HashMap, pending_document_opens: HashMap, + pending_annotation_digest: Option, + annotation_digest_draft: Option, + citation_draft: Option, + opening_preview_target: Option, + opening_preview: Option, + first_page_open_started: Option, open_into_sbs: bool, recovery_ui: recovery_ui::RecoveryUi, save_as: Option, @@ -490,6 +615,7 @@ pub struct PdfEditorApp { #[cfg(target_os = "macos")] show_default_reader_prompt: bool, pending_close: Option, + pending_close_others: VecDeque, allow_window_close: bool, review_derived: ReviewDerivedCache, review_source_geometry: Option, @@ -531,6 +657,7 @@ struct SbsPaneState { page_index: usize, zoom: f32, scroll_target_page: Option, + destination_fraction: Option<(usize, f32)>, } #[derive(Debug, Clone)] @@ -559,6 +686,7 @@ struct SbsPaneOutput { focus_requested: bool, replacement_epoch: Option, open_solo: bool, + clicked_link: Option<(usize, usize)>, } fn horizontal_offset_after_viewport_resize( @@ -605,7 +733,8 @@ struct DocumentLayoutCache { struct DocumentLinksEvent { document_epoch: u64, path: PathBuf, - result: Result>, String>, + revision: u64, + result: Result, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -709,6 +838,10 @@ struct ReviewDerivedCache { key: Option>, hidden_contents: Arc>, outline: Arc>, + feedback_ids: Arc>, + note_indices: Arc>, + needs_ocr: bool, + low_confidence: Option<&'static str>, } impl ReviewDerivedCache { @@ -718,6 +851,14 @@ impl ReviewDerivedCache { .and_then(std::sync::Weak::upgrade) .is_some_and(|cached| Arc::ptr_eq(&cached, document)) } + + fn feedback_id(&self, document: &LiquidDocument, index: usize) -> Option<&str> { + let key = self.key.as_ref()?; + if !std::ptr::eq(key.as_ptr(), document) { + return None; + } + self.feedback_ids.get(index).map(String::as_str) + } } /// Source-line geometry for the loaded PDF, used to map Review Mode blocks @@ -836,11 +977,27 @@ enum NoticeSeverity { Error, } +#[derive(Debug, Clone)] +enum NoticeAction { + RetryOpen { + path: PathBuf, + activate: bool, + prefetch: bool, + }, + RetrySave { + epoch: u64, + path: PathBuf, + }, +} + #[derive(Debug, Clone)] struct Notice { message: String, severity: NoticeSeverity, created_at: Instant, + hovered_at: Option, + paused: Duration, + action: Option, } impl Notice { @@ -849,12 +1006,71 @@ impl Notice { message: message.into(), severity, created_at: Instant::now(), + hovered_at: None, + paused: Duration::ZERO, + action: None, + } + } + + fn age_at(&self, now: Instant) -> Duration { + self.hovered_at + .unwrap_or(now) + .saturating_duration_since(self.created_at) + .saturating_sub(self.paused) + } + + fn set_hovered_at(&mut self, hovered: bool, now: Instant) { + match (hovered, self.hovered_at) { + (true, None) => self.hovered_at = Some(now), + (false, Some(start)) => { + self.paused += now.saturating_duration_since(start); + self.hovered_at = None; + } + _ => {} } } fn is_expired_at(&self, now: Instant) -> bool { - self.severity == NoticeSeverity::Info - && now.saturating_duration_since(self.created_at) >= INFO_NOTICE_DURATION + self.severity == NoticeSeverity::Info && self.age_at(now) >= INFO_NOTICE_DURATION + } + + fn visibility_at(&self, now: Instant, reduce_motion: bool) -> f32 { + if reduce_motion { + return 1.0; + } + let enter = motion::ease_out_cubic( + now.saturating_duration_since(self.created_at).as_secs_f32() / motion::SURFACE_SECONDS, + ); + let exit = if self.severity == NoticeSeverity::Info && self.hovered_at.is_none() { + let remaining = INFO_NOTICE_DURATION.saturating_sub(self.age_at(now)); + motion::ease_in_cubic(remaining.as_secs_f32() / motion::SURFACE_SECONDS) + } else { + 1.0 + }; + enter.min(exit) + } + + fn repaint_after(&self, now: Instant, reduce_motion: bool) -> Option { + let surface = Duration::from_secs_f32(motion::SURFACE_SECONDS); + let enter_remaining = + surface.saturating_sub(now.saturating_duration_since(self.created_at)); + let entrance = (!reduce_motion && !enter_remaining.is_zero()) + .then(|| Duration::from_millis(16).min(enter_remaining)); + let expiry = + (self.severity == NoticeSeverity::Info && self.hovered_at.is_none()).then(|| { + let remaining = INFO_NOTICE_DURATION.saturating_sub(self.age_at(now)); + let until_fade = remaining.saturating_sub(if reduce_motion { + Duration::ZERO + } else { + surface + }); + if until_fade.is_zero() && !reduce_motion { + Duration::from_millis(16).min(remaining) + } else { + until_fade + } + }); + entrance.into_iter().chain(expiry).min() } } @@ -904,6 +1120,7 @@ struct PageTexture { render_scale: f32, last_used: u64, texture: TextureHandle, + fade_started: Option, } #[derive(Clone)] @@ -920,6 +1137,7 @@ struct ThumbnailTextureView { #[derive(Clone, Copy)] struct PageTextureView { texture_id: TextureId, + fade_started: Option, } #[derive(Debug, Clone, Copy)] @@ -1043,6 +1261,9 @@ struct OcrProgress { struct DocumentTab { document: LoadedDocument, page_index: usize, + pending_reading_resume: Option, + pending_resume_fraction: Option<(usize, f32)>, + pending_resume_review_block: Option, document_epoch: u64, view_mode: DocumentViewMode, liquid_state: LiquidState, @@ -1056,6 +1277,7 @@ struct DocumentTab { liquid_notice_dismissed: bool, zoom: f32, target_zoom: f32, + fit_width_on_first_draw: bool, page_textures: HashMap, thumbnail_textures: HashMap, texture_access_counter: u64, @@ -1066,6 +1288,7 @@ struct DocumentTab { text_selection: Option, liquid_all_selected: bool, selection_anchor: Option<(usize, usize)>, + selection_extend_anchor: Option<(usize, usize)>, selection_toolbar_rect: Option, selected_text_box: Option, editing_text_box: Option, @@ -1104,6 +1327,21 @@ impl DocumentTab { } impl PdfEditorApp { + fn app_palette(&self) -> theme::Palette { + self.appearance_palette + } + + fn sync_review_appearance(&mut self, ctx: &Context) { + self.appearance_palette = theme::for_context(ctx); + if self.liquid_theme_follows_appearance { + self.liquid_theme = if ctx.theme() == egui::Theme::Dark { + LiquidTheme::Dark + } else { + LiquidTheme::Paper + }; + } + } + pub fn new( ctx: &Context, startup_paths: Vec, @@ -1129,7 +1367,7 @@ impl PdfEditorApp { let (update_tx, update_rx) = unbounded(); let tts_controller = TtsController::new(); if !isolated && !updater::managed_by_store() { - updater::spawn_update_check(update_tx.clone()); + updater::spawn_update_check(worker_waking_sender(update_tx.clone(), ctx.clone())); } let (installed_update, update_error) = if isolated { (None, None) @@ -1162,11 +1400,22 @@ impl PdfEditorApp { } else { load_settings() }; + theme::set_preference(ctx, settings.appearance); + if settings.reduce_motion { + ctx.all_styles_mut(|style| style.scroll_animation = egui::style::ScrollAnimation::none()); + } + let initial_liquid_theme = if ctx.theme() == egui::Theme::Dark { + LiquidTheme::Dark + } else { + LiquidTheme::Paper + }; #[cfg(target_os = "macos")] let show_default_reader_prompt = !isolated && should_offer_macos_default_reader(&settings); let initial_zoom = normalized_pdf_zoom(settings.last_pdf_zoom); let settings_ui = SettingsUi::new(&settings); let mut app = Self { + repaint_context: ctx.clone(), + appearance_palette: theme::for_context(ctx), startup_error: None, tabs: Vec::new(), active_tab: None, @@ -1190,6 +1439,11 @@ impl PdfEditorApp { pdf_page_placements: Vec::new(), review_reveal_block: std::cell::Cell::new(None), review_reveal_labels: std::cell::RefCell::new(Vec::new()), + review_paragraph_cache: std::cell::RefCell::new( + review_paragraph::ParagraphCache::default(), + ), + review_layout: None, + review_margin_rows: None, liquid_scroll_instant: false, pending_markdown_copy: false, pending_markdown_request: None, @@ -1202,7 +1456,8 @@ impl PdfEditorApp { review_feedback_rx, liquid_text_scale: 1.0, liquid_max_width: REVIEW_DEFAULT_MAX_WIDTH, - liquid_theme: LiquidTheme::Paper, + liquid_theme: initial_liquid_theme, + liquid_theme_follows_appearance: true, liquid_tx, liquid_rx, liquid_mode2_tx, @@ -1212,7 +1467,10 @@ impl PdfEditorApp { notices: VecDeque::new(), zoom: initial_zoom, target_zoom: initial_zoom, + fit_width_on_first_draw: false, document_layout_cache: None, + thumbnail_layout_cache: None, + annotation_list_cache: None, page_textures: HashMap::new(), sbs_mode: None, sbs_pane_states: HashMap::new(), @@ -1221,9 +1479,16 @@ impl PdfEditorApp { thumbnail_textures: HashMap::new(), render_tx, render_rx, + deferred_render_events: VecDeque::new(), + texture_install_frame: None, + texture_installs_this_frame: 0, document_links_tx, document_links_rx, pending_document_links: HashSet::new(), + document_navigation_revisions: HashMap::new(), + pdf_outlines: HashMap::new(), + failed_pdf_navigation: HashSet::new(), + jump_histories: HashMap::new(), pending_document_enrichments: HashSet::new(), incoming_paths_rx, #[cfg(target_os = "macos")] @@ -1234,12 +1499,15 @@ impl PdfEditorApp { pending_native_text: HashSet::new(), pending_accessible_text: HashSet::new(), pending_text_chars: HashSet::new(), + text_char_errors: HashSet::new(), texture_access_counter: 0, last_zoom_change: None, annotations: Vec::new(), annotations_dirty: false, liquid_feedback: Vec::new(), liquid_footnote_index: HashMap::new(), + review_references: review_references::ReferenceIndex::default(), + pending_reference_note: None, footnote_sources_ui: footnote_sources_ui::FootnoteSourcesUi::default(), liquid_scroll_to_block: None, review_active_heading: None, @@ -1249,6 +1517,15 @@ impl PdfEditorApp { liquid_hide_footnotes: false, tts_controller, marker_animations: Vec::new(), + pending_reading_scroll: None, + shortcuts_open: false, + pending_reading_resume: None, + pending_resume_fraction: None, + pending_resume_review_block: None, + resume_notice: None, + recent_needs_touch: false, + recent_suppressed_epoch: None, + session_history_suppressed: false, active_tool: Tool::Select, sidebar_tab: SidebarTab::Pages, selection_state: SelectionState::default(), @@ -1274,10 +1551,17 @@ impl PdfEditorApp { active_annotation_saves: HashMap::new(), annotation_save_generation: 0, comments_ui: comments_ui::CommentsUi::default(), + foreign_annotations_ui: foreign_annotations::ForeignAnnotationsUi::default(), #[cfg(target_os = "linux")] platform_ui: platform_ui::PlatformUi::default(), annotation_sessions: HashMap::new(), pending_document_opens: HashMap::new(), + pending_annotation_digest: None, + annotation_digest_draft: None, + citation_draft: None, + opening_preview_target: None, + opening_preview: None, + first_page_open_started: None, open_into_sbs: false, recovery_ui: recovery_ui::RecoveryUi::new(ctx, isolated), save_as: None, @@ -1302,6 +1586,7 @@ impl PdfEditorApp { #[cfg(target_os = "macos")] show_default_reader_prompt, pending_close: None, + pending_close_others: VecDeque::new(), allow_window_close: false, review_derived: ReviewDerivedCache::default(), review_source_geometry: None, @@ -1310,6 +1595,7 @@ impl PdfEditorApp { chrome: ChromeState::default(), }; + let startup_paths = app.settings.startup_paths(startup_paths); if !startup_paths.is_empty() { app.open_paths_in_tabs(startup_paths, ctx, true); } @@ -1376,6 +1662,9 @@ impl PdfEditorApp { DocumentTab { document, page_index: self.page_index, + pending_reading_resume: self.pending_reading_resume.clone(), + pending_resume_fraction: self.pending_resume_fraction, + pending_resume_review_block: self.pending_resume_review_block, document_epoch: self.document_epoch, view_mode: self.view_mode, liquid_state: self.liquid_state.clone(), @@ -1389,6 +1678,7 @@ impl PdfEditorApp { liquid_notice_dismissed: self.liquid_notice_dismissed, zoom: self.zoom, target_zoom: self.target_zoom, + fit_width_on_first_draw: self.fit_width_on_first_draw, page_textures: self.page_textures.clone(), thumbnail_textures: self.thumbnail_textures.clone(), texture_access_counter: self.texture_access_counter, @@ -1399,6 +1689,7 @@ impl PdfEditorApp { text_selection: self.selection_state.text, liquid_all_selected: self.selection_state.liquid_all, selection_anchor: self.selection_state.anchor, + selection_extend_anchor: self.selection_state.extend_anchor, selection_toolbar_rect: self.selection_state.toolbar_rect, selected_text_box: self.selected_text_box, editing_text_box: self.editing_text_box, @@ -1438,7 +1729,12 @@ impl PdfEditorApp { let Some(document) = self.document.clone() else { return; }; - let snapshot = self.active_tab_snapshot(document); + let mut snapshot = self.active_tab_snapshot(document); + // A background tab can reconstruct other pages on demand. Keeping one + // page prevents every inactive tab from retaining an entire GPU cache. + snapshot + .page_textures + .retain(|page, _| *page == snapshot.page_index); if let Some(tab) = self.tabs.get_mut(tab_index) { *tab = snapshot; } @@ -1449,7 +1745,14 @@ impl PdfEditorApp { let tab_target_zoom = normalized_pdf_zoom(tab.target_zoom); self.document = Some(tab.document); self.document_layout_cache = None; + self.thumbnail_layout_cache = None; + self.annotation_list_cache = None; self.page_index = tab.page_index; + self.pending_reading_resume = tab.pending_reading_resume; + self.pending_resume_fraction = tab.pending_resume_fraction; + self.pending_resume_review_block = tab.pending_resume_review_block; + self.resume_notice = None; + self.recent_needs_touch = true; self.document_epoch = tab.document_epoch; self.view_mode = tab.view_mode; self.liquid_state = tab.liquid_state; @@ -1463,6 +1766,7 @@ impl PdfEditorApp { self.liquid_notice_dismissed = tab.liquid_notice_dismissed; self.zoom = tab_zoom; self.target_zoom = tab_target_zoom; + self.fit_width_on_first_draw = tab.fit_width_on_first_draw; self.remember_pdf_zoom(tab_target_zoom); self.page_textures = tab.page_textures; self.thumbnail_textures = tab.thumbnail_textures; @@ -1476,6 +1780,7 @@ impl PdfEditorApp { self.annotations_dirty = tab.annotations_dirty; self.liquid_feedback = tab.liquid_feedback; self.selection_state.text = tab.text_selection; + self.selection_state.extend_anchor = tab.selection_extend_anchor; self.selection_state.liquid_all = tab.liquid_all_selected; self.selection_state.anchor = tab.selection_anchor; self.selection_state.toolbar_rect = tab.selection_toolbar_rect; @@ -1494,6 +1799,7 @@ impl PdfEditorApp { self.drag_preview = tab.drag_preview; self.active_signature_stroke = tab.active_signature_stroke; self.selection_state.pending_select_all = tab.pending_select_all_text; + self.selection_state.pending_mark = false; self.invalidate_search_results(); self.search_state.query = tab.search_query; self.search_state.hits = tab.search_hits; @@ -1535,6 +1841,9 @@ impl PdfEditorApp { return; } + self.pending_reference_note = None; + self.clear_speech_follow_scroll(); + let pending_for_current = self .pending_markdown_request .as_ref() @@ -1560,6 +1869,7 @@ impl PdfEditorApp { self.active_tab = Some(tab_index); self.startup_error = None; self.apply_tab_state(tab, ctx); + self.request_document_links(); if let Some(query) = pane_query { self.invalidate_search_results(); self.search_state.query = query; @@ -1665,6 +1975,7 @@ impl PdfEditorApp { page_index: tab.page_index, zoom: normalized_pdf_zoom(tab.target_zoom), scroll_target_page: Some(tab.page_index), + destination_fraction: None, }, ); } @@ -1726,6 +2037,12 @@ impl PdfEditorApp { } let closing_epoch = self.tabs[tab_index].document_epoch; + self.document_navigation_revisions.remove(&closing_epoch); + self.pdf_outlines.remove(&closing_epoch); + self.failed_pdf_navigation.remove(&closing_epoch); + self.jump_histories.remove(&closing_epoch); + self.pending_document_links + .retain(|(epoch, _, _)| *epoch != closing_epoch); self.annotation_sessions.remove(&closing_epoch); let closing_pending_markdown = self .pending_markdown_request @@ -1745,6 +2062,9 @@ impl PdfEditorApp { self.sbs_pane_states.remove(&closing_epoch); self.pending_accessible_text .retain(|(epoch, _)| *epoch != closing_epoch); + self.text_char_errors + .retain(|(epoch, _)| *epoch != closing_epoch); + self.foreign_annotations_ui.forget(closing_epoch); self.sbs_page_textures .retain(|(document_epoch, _), _| *document_epoch != closing_epoch); self.sbs_pending_page_renders @@ -1775,7 +2095,14 @@ impl PdfEditorApp { fn clear_document_state(&mut self) { let zoom = self.default_zoom_for_new_document(); self.document = None; + self.pending_reading_resume = None; + self.pending_resume_fraction = None; + self.pending_resume_review_block = None; + self.resume_notice = None; + self.recent_needs_touch = false; self.document_layout_cache = None; + self.thumbnail_layout_cache = None; + self.annotation_list_cache = None; self.page_index = 0; self.document_epoch = 0; self.view_mode = DocumentViewMode::Pdf; @@ -1814,6 +2141,7 @@ impl PdfEditorApp { self.selection_state.text = None; self.selection_state.liquid_all = false; self.selection_state.anchor = None; + self.selection_state.extend_anchor = None; self.selection_state.toolbar_rect = None; self.clear_text_box_selection(); self.text_box_drag = None; @@ -2118,8 +2446,10 @@ impl PdfEditorApp { { self.update_ui.notice = None; ctx.request_repaint(); - } else if self.update_ui.notice.is_some() { - ctx.request_repaint_after(Duration::from_millis(250)); + } else if let Some(notice) = &self.update_ui.notice + && let Some(duration) = notice.duration + { + ctx.request_repaint_after(duration.saturating_sub(notice.shown_at.elapsed())); } if !self.update_ui.check_in_flight @@ -2132,11 +2462,13 @@ impl PdfEditorApp { { self.update_ui.check_in_flight = true; self.update_ui.next_check = None; - updater::spawn_update_check(self.update_ui.tx.clone()); + updater::spawn_update_check(worker_waking_sender( + self.update_ui.tx.clone(), + ctx.clone(), + )); } - - if matches!(self.update_ui.state, UpdateUiState::Downloading) { - ctx.request_repaint_after(RENDER_POLL_INTERVAL); + if let Some(next_check) = self.update_ui.next_check { + ctx.request_repaint_after(next_check.saturating_duration_since(Instant::now())); } } @@ -2195,10 +2527,19 @@ impl PdfEditorApp { revision, annotations, recovery_pending, + .. } = opened; let page_count = document.page_count; + let pending_reading_resume = self + .settings + .recent_document_for_opened_path(&document.path) + .cloned(); + let initial_page = pending_reading_resume + .as_ref() + .map_or(0, |saved| saved.page.min(page_count.saturating_sub(1))); let title = document.title.clone(); let optimized = document.optimized; + let fit_width_on_first_draw = self.settings.zoom_for_document(&document.path).is_none(); let zoom = self.zoom_for_document(&document.path); let ocr_states = load_ocr_cache(&document.path, page_count) .unwrap_or_else(|| vec![OcrPageState::Idle; page_count]); @@ -2221,7 +2562,10 @@ impl PdfEditorApp { self.annotation_sessions.insert(epoch, session); Ok(DocumentTab { document, - page_index: 0, + page_index: initial_page, + pending_reading_resume, + pending_resume_fraction: None, + pending_resume_review_block: None, document_epoch: epoch, view_mode: DocumentViewMode::Pdf, liquid_state: LiquidState::Idle, @@ -2235,6 +2579,7 @@ impl PdfEditorApp { liquid_notice_dismissed: false, zoom, target_zoom: zoom, + fit_width_on_first_draw, page_textures: HashMap::new(), thumbnail_textures: HashMap::new(), texture_access_counter: 0, @@ -2245,6 +2590,7 @@ impl PdfEditorApp { text_selection: None, liquid_all_selected: false, selection_anchor: None, + selection_extend_anchor: None, selection_toolbar_rect: None, selected_text_box: None, editing_text_box: None, @@ -2268,8 +2614,8 @@ impl PdfEditorApp { ocr_states, ocr_progress: None, chat_state: ChatState::default(), - scroll_target_page: Some(0), - thumbnail_scroll_target: Some(0), + scroll_target_page: Some(initial_page), + thumbnail_scroll_target: Some(initial_page), pending_document_scroll_offset: None, visible_page_ranges: Vec::new(), status: document_opened_status( @@ -2664,7 +3010,7 @@ impl PdfEditorApp { self.document_epoch, pdf_path, page_count, - self.ocr_tx.clone(), + worker_waking_sender(self.ocr_tx.clone(), self.repaint_context.clone()), self.render_tx.clone(), ); self.status = "OCR started in the background.".to_owned(); @@ -2748,7 +3094,7 @@ impl PdfEditorApp { destination.clone(), initial_ocr_text, api_key, - self.ocr_tx.clone(), + worker_waking_sender(self.ocr_tx.clone(), self.repaint_context.clone()), self.render_tx.clone(), ); "Cloud OCR (OpenRouter)" @@ -2761,7 +3107,7 @@ impl PdfEditorApp { page_sizes, destination.clone(), initial_ocr_text, - self.ocr_tx.clone(), + worker_waking_sender(self.ocr_tx.clone(), self.repaint_context.clone()), self.render_tx.clone(), ); "OCR" @@ -2876,39 +3222,78 @@ impl PdfEditorApp { } } + fn restart_render_worker(&mut self, ctx: &Context) { + if !self.pending_document_opens.is_empty() { + self.pending_document_opens.clear(); + self.opening_preview_target = None; + self.opening_preview = None; + self.open_into_sbs = false; + self.push_error_notice( + "The PDF worker stopped while opening a document. Your recovery files were kept.", + ); + } + if !self.active_annotation_saves.is_empty() { + self.active_annotation_saves.clear(); + self.push_error_notice("The PDF worker stopped before confirming the save. Your annotations are still unsaved; keep the document open."); + } + self.page_rotation_in_flight = false; + self.pending_page_renders.clear(); + self.pending_thumbnail_renders.clear(); + self.pending_native_text.clear(); + self.pending_accessible_text.clear(); + self.pending_text_chars.clear(); + self.pending_document_enrichments.clear(); + self.deferred_render_events.clear(); + self.foreign_annotations_ui.worker_stopped(); + // Recovery journals retain any ambiguous save across a restart. + (self.render_tx, self.render_rx) = spawn_render_worker(Some(ctx.clone())); + self.render_tx + .set_live_documents(self.tabs.iter().map(|tab| tab.document_epoch)); + self.push_error_notice("The document worker restarted. If a save or rotation was interrupted, reopen that PDF before editing; recovery copies were kept."); + ctx.request_repaint(); + } + fn poll_render_results(&mut self, ctx: &Context) { + let frame = ctx.cumulative_frame_nr(); + if self.texture_install_frame != Some(frame) { + self.texture_install_frame = Some(frame); + self.texture_installs_this_frame = 0; + } loop { - let event = match self.render_rx.try_recv() { + let event = match self + .deferred_render_events + .pop_front() + .map(Ok) + .unwrap_or_else(|| self.render_rx.try_recv()) + { Ok(event) => event, Err(crossbeam_channel::TryRecvError::Empty) => break, Err(crossbeam_channel::TryRecvError::Disconnected) => { - if !self.pending_document_opens.is_empty() { - self.pending_document_opens.clear(); - self.open_into_sbs = false; - self.push_error_notice("The PDF worker stopped while opening a document. Your recovery files were kept."); - } - if !self.active_annotation_saves.is_empty() { - self.active_annotation_saves.clear(); - self.push_error_notice("The PDF worker stopped before confirming the save. Your annotations are still unsaved; keep the document open."); - } - self.page_rotation_in_flight = false; - self.pending_page_renders.clear(); - self.pending_thumbnail_renders.clear(); - self.pending_native_text.clear(); - self.pending_accessible_text.clear(); - self.pending_text_chars.clear(); - self.pending_document_enrichments.clear(); - // A panicked Rust task must not permanently disable the - // reader. Recovery journals retain any ambiguous save. - (self.render_tx, self.render_rx) = spawn_render_worker(Some(ctx.clone())); - self.render_tx - .set_live_documents(self.tabs.iter().map(|tab| tab.document_epoch)); - self.push_error_notice("The document worker restarted. If a save or rotation was interrupted, reopen that PDF before editing; recovery copies were kept."); - ctx.request_repaint_after(RENDER_POLL_INTERVAL); + self.restart_render_worker(ctx); break; } }; + if matches!( + &event, + RenderEvent::Page { result: Ok(_), .. } + | RenderEvent::Thumbnail { result: Ok(_), .. } + | RenderEvent::OpeningPreview { .. } + ) { + if self.texture_installs_this_frame >= 2 { + self.deferred_render_events.push_front(event); + ctx.request_repaint(); + break; + } + self.texture_installs_this_frame += 1; + } match event { + RenderEvent::WorkerStopped => { + self.restart_render_worker(ctx); + break; + } + RenderEvent::OpeningPreview { path, page } => { + self.accept_opening_preview(path, page, ctx) + } RenderEvent::DocumentOpened { path, result } => { self.finish_document_open(path, result, ctx) } @@ -2952,6 +3337,8 @@ impl PdfEditorApp { match merge_result { Ok(()) => { self.document_layout_cache = None; + self.thumbnail_layout_cache = None; + self.annotation_list_cache = None; match self.view_mode { DocumentViewMode::Pdf => {} DocumentViewMode::Liquid => { @@ -3013,6 +3400,15 @@ impl PdfEditorApp { } } } + RenderEvent::PageSkipped { key } => { + if self.pending_page_renders.get(&key.page_index) == Some(&key) { + self.pending_page_renders.remove(&key.page_index); + } + let sbs_key = (key.document_epoch, key.page_index); + if self.sbs_pending_page_renders.get(&sbs_key) == Some(&key) { + self.sbs_pending_page_renders.remove(&sbs_key); + } + } RenderEvent::Page { key, path, @@ -3115,10 +3511,6 @@ impl PdfEditorApp { ) { continue; } - let image = egui::ColorImage::from_rgba_unmultiplied( - [rendered.width, rendered.height], - &rendered.rgba, - ); let texture = ctx.load_texture( format!( "thumb-e{}-{}-{}", @@ -3126,7 +3518,7 @@ impl PdfEditorApp { rendered.page_index + 1, key.render_scale_key ), - image, + rendered.image, TextureOptions::LINEAR, ); self.thumbnail_textures.insert( @@ -3136,6 +3528,7 @@ impl PdfEditorApp { render_scale: incoming_scale, }, ); + self.prune_page_texture_cache(); ctx.request_repaint(); } Err(error) => { @@ -3154,9 +3547,11 @@ impl PdfEditorApp { } self.pending_text_chars.remove(&page_index); self.search_state.geometry.remove(&page_index); + self.annotation_list_cache = None; match result { Ok(chars) => { + self.text_char_errors.remove(&(document_epoch, page_index)); if let Some(document) = self.document.as_mut() { if let Some(slot) = document.text_chars.get_mut(page_index) { *slot = Some(chars); @@ -3165,6 +3560,8 @@ impl PdfEditorApp { } } Err(error) => { + self.text_char_errors.insert((document_epoch, page_index)); + self.annotation_digest_text_failed(document_epoch, &path, page_index); self.push_error_notice(error); if let Some(document) = self.document.as_mut() { if let Some(slot) = document.text_chars.get_mut(page_index) { @@ -3195,6 +3592,14 @@ impl PdfEditorApp { } => { self.accept_document_comments(document_epoch, path, revision, result); } + RenderEvent::ForeignAnnotations { + document_epoch, + path, + revision, + result, + } => { + self.accept_foreign_annotations(document_epoch, path, revision, result); + } RenderEvent::TextPage { document_epoch, path, @@ -3321,10 +3726,28 @@ impl PdfEditorApp { fn poll_document_links(&mut self, ctx: &Context) { while let Ok(event) = self.document_links_rx.try_recv() { - self.pending_document_links - .remove(&(event.document_epoch, event.path.clone())); + self.pending_document_links.remove(&( + event.document_epoch, + event.path.clone(), + event.revision, + )); + if self + .document_navigation_revisions + .get(&event.document_epoch) + .copied() + .unwrap_or(0) + != event.revision + || !self.tabs.iter().any(|tab| { + tab.document_epoch == event.document_epoch && tab.document.path == event.path + }) + { + continue; + } match event.result { - Ok(links) => { + Ok(navigation) => { + let PdfNavigation { links, outline } = navigation; + self.pdf_outlines + .insert(event.document_epoch, Arc::new(outline)); if let Some(tab) = self.tabs.iter_mut().find(|tab| { tab.document_epoch == event.document_epoch && tab.document.path == event.path @@ -3340,7 +3763,11 @@ impl PdfEditorApp { ctx.request_repaint(); } } - Err(error) => eprintln!("Could not cache PDF hyperlinks: {error}"), + Err(error) => { + eprintln!("Could not load PDF navigation: {error}"); + self.failed_pdf_navigation.insert(event.document_epoch); + ctx.request_repaint(); + } } } } @@ -3354,6 +3781,13 @@ impl PdfEditorApp { rotation: i64, ctx: &Context, ) { + let revision = self + .document_navigation_revisions + .entry(document_epoch) + .or_default(); + *revision = revision.wrapping_add(1); + self.pdf_outlines.remove(&document_epoch); + self.failed_pdf_navigation.remove(&document_epoch); let message = format!( "Page {} rotation saved at {} degrees.", page_index + 1, @@ -3376,6 +3810,8 @@ impl PdfEditorApp { if self.is_current_document(document_epoch, path) { self.document = Some(document); self.document_layout_cache = None; + self.thumbnail_layout_cache = None; + self.annotation_list_cache = None; self.page_textures.clear(); self.thumbnail_textures.clear(); self.pending_page_renders.clear(); @@ -3391,34 +3827,44 @@ impl PdfEditorApp { .retain(|(epoch, _), _| *epoch != document_epoch); self.sbs_pending_page_renders .retain(|(epoch, _), _| *epoch != document_epoch); + if self.is_current_document(document_epoch, path) { + self.request_document_links(); + } ctx.request_repaint(); } fn request_document_links(&mut self) { - let Some(document) = self - .document - .as_ref() - .filter(|document| document.optimized && !document.links_loaded) - else { + let Some(document) = self.document.as_ref() else { return; }; let document_epoch = self.document_epoch; + if self.pdf_outlines.contains_key(&document_epoch) { + return; + } + let revision = self + .document_navigation_revisions + .get(&document_epoch) + .copied() + .unwrap_or(0); let path = document.path.clone(); let page_count = document.page_count; if !self .pending_document_links - .insert((document_epoch, path.clone())) + .insert((document_epoch, path.clone(), revision)) { return; } - let event_tx = self.document_links_tx.clone(); + self.failed_pdf_navigation.remove(&document_epoch); + let event_tx = + worker_waking_sender(self.document_links_tx.clone(), self.repaint_context.clone()); std::thread::spawn(move || { let result = PdfEngine::new() - .and_then(|engine| engine.load_document_links(&path, page_count)) + .and_then(|engine| engine.load_document_navigation(&path, page_count)) .map_err(|error| error.to_string()); let _ = event_tx.send(DocumentLinksEvent { document_epoch, path, + revision, result, }); }); @@ -3433,7 +3879,7 @@ impl PdfEditorApp { &mut self, ctx: &Context, document_epoch: u64, - rendered: RenderedPage, + rendered: PreparedPage, zoom: f32, render_scale: f32, ) { @@ -3446,10 +3892,6 @@ impl PdfEditorApp { return; } - let image = egui::ColorImage::from_rgba_unmultiplied( - [rendered.width, rendered.height], - &rendered.rgba, - ); let texture = ctx.load_texture( format!( "page-e{}-{}-z{:.3}-r{:.3}", @@ -3458,9 +3900,12 @@ impl PdfEditorApp { zoom, render_scale ), - image, + rendered.image, TextureOptions::LINEAR, ); + let fade_started = (!self.page_textures.contains_key(&rendered.page_index) + && self.thumbnail_textures.contains_key(&rendered.page_index)) + .then(Instant::now); let last_used = self.next_texture_access(); self.page_textures.insert( rendered.page_index, @@ -3469,6 +3914,7 @@ impl PdfEditorApp { render_scale, last_used, texture, + fade_started, }, ); self.prune_page_texture_cache(); @@ -3596,6 +4042,8 @@ impl PdfEditorApp { } return; } + self.pending_reference_note = None; + self.clear_speech_follow_scroll(); self.clear_text_selection(); if mode == DocumentViewMode::Pdf && self.view_mode == DocumentViewMode::LiquidMode2 { // Arm the switch back: Review Mode follows the reader to a new page. @@ -3761,7 +4209,7 @@ impl PdfEditorApp { self.liquid_state = LiquidState::Preparing; self.liquid_notice_dismissed = false; self.status = "Preparing Review Mode...".to_owned(); - spawn_liquid_job(request, self.liquid_tx.clone()); + spawn_liquid_job(request, self.liquid_tx.clone(), ctx.clone()); ctx.request_repaint_after(RENDER_POLL_INTERVAL); } @@ -3914,7 +4362,6 @@ impl PdfEditorApp { self.liquid_mode2_state = LiquidState::PreparingText; self.status = "Preparing Review Mode...".to_owned(); } - ctx.request_repaint_after(RENDER_POLL_INTERVAL); } ReviewPrepareAction::SpawnPreview { page_count: pages } => { self.spawn_liquid_mode2_request(ctx, pages, true); @@ -3942,6 +4389,26 @@ impl PdfEditorApp { (native, chars) } + /// Text-extraction attempts, not the later classification stage. + fn review_text_preparation_progress(&self) -> Option<(usize, usize)> { + let document = self.document.as_ref()?; + let total = document.page_count; + if total == 0 { + return None; + } + let checked = (0..total) + .filter(|page| { + document + .native_text_loaded + .get(*page) + .copied() + .unwrap_or(false) + && document.text_chars.get(*page).is_some_and(Option::is_some) + }) + .count(); + Some((checked, total)) + } + fn enqueue_review_extract_pages(&mut self, page_limit: usize) { let Some((path, page_count)) = self .document @@ -4000,7 +4467,7 @@ impl PdfEditorApp { self.liquid_mode2_state = LiquidState::Preparing; } self.liquid_mode2_complete = false; - spawn_liquid_mode2_job(request, self.liquid_mode2_tx.clone()); + spawn_liquid_mode2_job(request, self.liquid_mode2_tx.clone(), ctx.clone()); ctx.request_repaint_after(RENDER_POLL_INTERVAL); } @@ -4300,6 +4767,9 @@ impl PdfEditorApp { let Some(hit) = self.search_state.hits.get(index).cloned() else { return; }; + if self.search_state.selected_hit != Some(index) { + self.record_jump_origin(); + } self.search_state.selected_hit = Some(index); self.search_state.pending_step = None; self.search_state.show_highlights = true; @@ -4437,6 +4907,7 @@ impl PdfEditorApp { texture.last_used = last_used; PageTextureView { texture_id: texture.texture.id(), + fade_started: texture.fade_started, } }) } @@ -4614,7 +5085,6 @@ impl PdfEditorApp { } else { format!("{status} ({waiting} page(s) pending)") }; - ctx.request_repaint_after(RENDER_POLL_INTERVAL); false } else { true @@ -4671,7 +5141,6 @@ impl PdfEditorApp { } else { format!("{status} ({waiting} page(s) pending)") }; - ctx.request_repaint_after(RENDER_POLL_INTERVAL); false } else { true @@ -4830,6 +5299,22 @@ impl PdfEditorApp { } } + fn fit_current_page(&mut self, viewport: Vec2, whole_page: bool) { + let Some(page) = self + .document + .as_ref() + .and_then(|document| document.pages.get(self.page_index)) + else { + return; + }; + let zoom = fit_page_zoom(viewport, page.width, page.height, whole_page); + if self.settings.reduce_motion { + self.set_pointer_zoom(zoom); + } else { + self.set_zoom(zoom); + } + } + /// Pointer-anchored wheel zoom must update geometry atomically. Animating /// between a current zoom and a newer target makes the anchor calculation /// stale on the next wheel event and produces lateral drift. @@ -4845,6 +5330,10 @@ impl PdfEditorApp { } fn advance_zoom_animation(&mut self, ctx: &Context) { + if self.settings.reduce_motion { + self.zoom = self.target_zoom; + return; + } if !self.zoom_is_animating() { self.zoom = self.target_zoom; return; @@ -4868,9 +5357,24 @@ impl PdfEditorApp { return; }; + self.pending_resume_fraction = None; + if let Some(state) = self.sbs_pane_states.get_mut(&self.document_epoch) { + state.destination_fraction = None; + } self.page_index = page_index.min(document.page_count.saturating_sub(1)); self.scroll_target_page = Some(self.page_index); self.thumbnail_scroll_target = Some(self.page_index); + let review = match self.view_mode { + DocumentViewMode::Liquid => &self.liquid_state, + DocumentViewMode::LiquidMode2 => &self.liquid_mode2_state, + DocumentViewMode::Pdf => return, + }; + if let LiquidState::Ready(review) = review + && let Some(block) = review_reveal::first_block_on_page(review, self.page_index) + { + self.liquid_scroll_to_block = Some(block); + self.liquid_scroll_instant = self.settings.reduce_motion; + } } fn visible_range_for_page(&self, page_index: usize) -> Option { @@ -5072,7 +5576,7 @@ impl PdfEditorApp { .title_bar(false) .frame( egui::Frame::window(&ctx.style()) - .fill(BAR_FILL) + .fill(self.app_palette().bar) .corner_radius(8) .inner_margin(Margin::symmetric(14, 12)), ) @@ -5082,7 +5586,7 @@ impl PdfEditorApp { ui.label( RichText::new("You can change this later from the toolbar or Finder.") .small() - .color(MUTED_INK), + .color(self.app_palette().muted), ); ui.add_space(4.0); ui.horizontal(|ui| { @@ -5119,9 +5623,9 @@ impl PdfEditorApp { .width_range(248.0..=430.0) .frame( egui::Frame::NONE - .fill(PANEL_FILL) + .fill(self.app_palette().panel) .inner_margin(Margin::symmetric(10, 10)) - .stroke(Stroke::new(1.0_f32, Color32::from_rgb(220, 216, 207))), + .stroke(Stroke::new(1.0_f32, self.app_palette().rule)), ) .show(ctx, |ui| { if self.sbs_mode.is_some() { @@ -5156,160 +5660,6 @@ impl PdfEditorApp { }); } - fn draw_pages_tab(&mut self, ui: &mut egui::Ui, ctx: &Context) { - let Some(document) = self.document.as_ref() else { - ui.label(RichText::new("No PDF loaded").color(MUTED_INK)); - return; - }; - - let path = document.path.clone(); - let pages = document - .pages - .iter() - .map(|page| (page.width, page.height)) - .collect::>(); - - let requested_thumbnail = self.thumbnail_scroll_target; - egui::ScrollArea::vertical().show(ui, |ui| { - let viewport = ui.clip_rect(); - let render_window = viewport.expand2(Vec2::new(0.0, 700.0)); - - for (page_index, (page_width, page_height)) in pages.iter().copied().enumerate() { - let visible_range = self.visible_range_for_page(page_index); - let target_visible = visible_range - .map(|range| range.coverage.max(0.24)) - .unwrap_or_else(|| { - if self.visible_page_ranges.is_empty() && page_index == self.page_index { - 1.0 - } else { - 0.0 - } - }); - let visible_t = ui.ctx().animate_value_with_time( - ui.id().with(("thumb-visible", page_index)), - target_visible, - 0.14, - ); - let fill = lerp_color( - Color32::from_rgb(250, 248, 242), - Color32::from_rgb(231, 224, 210), - visible_t, - ); - let stroke = Stroke::new( - 1.0 + 1.0 * visible_t, - lerp_color( - Color32::from_rgb(213, 208, 198), - Color32::from_rgb(146, 103, 52), - visible_t, - ), - ); - - let inner = egui::Frame::NONE - .fill(fill) - .stroke(stroke) - .corner_radius(6) - .inner_margin(Margin::same(8)) - .show(ui, |ui| { - ui.vertical_centered(|ui| { - let display_width = 118.0; - let display_height = display_width * page_height / page_width.max(1.0); - let display_size = Vec2::new(display_width, display_height); - let (thumb_rect, response) = - ui.allocate_exact_size(display_size, Sense::click()); - let should_render = visible_t > 0.01 - || requested_thumbnail == Some(page_index) - || thumb_rect.intersects(render_window); - let painter = ui.painter_at(thumb_rect); - - painter.rect_filled(thumb_rect, 2, PAPER_FILL); - painter.rect_stroke( - thumb_rect, - 2, - Stroke::new(1.0_f32, PAPER_STROKE), - egui::StrokeKind::Inside, - ); - - if should_render { - if let Some(thumbnail) = self - .ensure_thumbnail_texture(ctx, &path, page_index, page_width) - { - painter.image( - thumbnail.texture_id, - thumb_rect, - Rect::from_min_max(Pos2::ZERO, Pos2::new(1.0, 1.0)), - Color32::WHITE, - ); - } else { - painter.text( - thumb_rect.center(), - Align2::CENTER_CENTER, - "Rendering", - FontId::proportional(13.0), - MUTED_INK, - ); - } - } else { - painter.text( - thumb_rect.center(), - Align2::CENTER_CENTER, - format!("{}", page_index + 1), - FontId::proportional(18.0), - MUTED_INK, - ); - } - if let Some(range) = visible_range { - let top = thumb_rect.top() - + thumb_rect.height() * range.top_fraction.clamp(0.0, 1.0); - let bottom = thumb_rect.top() - + thumb_rect.height() * range.bottom_fraction.clamp(0.0, 1.0); - let bottom = bottom.max(top + 3.0).min(thumb_rect.bottom()); - let visible_rect = Rect::from_min_max( - Pos2::new(thumb_rect.left(), top), - Pos2::new(thumb_rect.right(), bottom), - ); - let alpha = - (42.0 + 86.0 * range.coverage).round().clamp(0.0, 160.0) as u8; - painter.rect_filled( - visible_rect, - 1, - Color32::from_rgba_unmultiplied(207, 150, 64, alpha), - ); - painter.rect_stroke( - visible_rect, - 1, - Stroke::new( - 1.4_f32, - Color32::from_rgba_unmultiplied(146, 103, 52, 190), - ), - egui::StrokeKind::Inside, - ); - } - response - }) - .inner - }); - - if self.thumbnail_scroll_target == Some(page_index) { - inner.response.scroll_to_me_animation( - Some(Align::Center), - egui::style::ScrollAnimation::duration(THUMBNAIL_SCROLL_SECONDS), - ); - self.thumbnail_scroll_target = None; - } - - ui.horizontal(|ui| { - ui.add_space(6.0); - ui.label(RichText::new(format!("Page {}", page_index + 1)).color(INK)); - }); - - if inner.response.clicked() || inner.inner.clicked() { - self.go_to_page(page_index); - } - ui.add_space(10.0); - } - }); - } - fn update_review_active_heading(&mut self, clip: Rect) { let threshold = clip.top() + 56.0; let mut current = None; @@ -5329,6 +5679,45 @@ impl PdfEditorApp { ui.heading("Contents"); ui.add_space(4.0); + if self.view_mode == DocumentViewMode::Pdf { + if let Some(outline) = self.pdf_outlines.get(&self.document_epoch).cloned() { + if outline.is_empty() { + ui.label( + RichText::new( + "This PDF has no bookmarks. Open Review Mode to build a contents list.", + ) + .color(self.app_palette().muted), + ); + return; + } + let mut target = None; + egui::ScrollArea::vertical().show(ui, |ui| { + for item in outline.iter() { + ui.horizontal_wrapped(|ui| { + ui.add_space(14.0 * item.level.min(8) as f32); + let button = egui::Button::new(RichText::new(&item.title).size(13.0)) + .frame(false) + .wrap(); + if ui.add_enabled(item.page_index.is_some(), button).clicked() { + target = item.page_index.map(|page| (page, item.page_fraction)); + } + }); + } + }); + if let Some((page, fraction)) = target { + self.jump_to_pdf_destination(page, fraction); + } + } else if self.failed_pdf_navigation.contains(&self.document_epoch) { + ui.label(RichText::new("Could not load PDF bookmarks.").color(self.app_palette().muted)); + if ui.button("Retry").clicked() { + self.request_document_links(); + } + } else { + ui.label(RichText::new("Loading PDF bookmarks…").color(self.app_palette().muted)); + } + return; + } + let ready = match self.view_mode { DocumentViewMode::Liquid => match &self.liquid_state { LiquidState::Ready(document) => Some(( @@ -5352,12 +5741,12 @@ impl PdfEditorApp { self.view_mode, DocumentViewMode::Liquid | DocumentViewMode::LiquidMode2 ) { - ui.spinner(); - ui.label(RichText::new("Finding sections...").color(MUTED_INK)); + motion::busy(ui, self.settings.reduce_motion); + ui.label(RichText::new("Finding sections...").color(self.app_palette().muted)); } else { ui.label( RichText::new("Open Review Mode to build a clickable contents list.") - .color(MUTED_INK), + .color(self.app_palette().muted), ); } return; @@ -5366,7 +5755,7 @@ impl PdfEditorApp { ui.label(RichText::new(title).strong()); ui.add_space(7.0); if outline.is_empty() { - ui.label(RichText::new("No section headings detected.").color(MUTED_INK)); + ui.label(RichText::new("No section headings detected.").color(self.app_palette().muted)); return; } @@ -5381,7 +5770,7 @@ impl PdfEditorApp { RichText::new(&item.text) .size(if item.level == 1 { 13.0 } else { 12.0 }) .strong() - .color(if item.level == 1 { INK } else { MUTED_INK }), + .color(if item.level == 1 { self.app_palette().ink } else { self.app_palette().muted }), ) .wrap() .sense(Sense::click()), @@ -5396,7 +5785,7 @@ impl PdfEditorApp { } }); if let Some(block_index) = clicked_target { - self.liquid_scroll_to_block = Some(block_index); + self.jump_to_review_block(block_index); } } @@ -5424,7 +5813,7 @@ impl PdfEditorApp { self.add_search_highlights(ui.ctx()); } }); - ui.label(RichText::new(self.find_progress()).color(MUTED_INK)); + ui.label(RichText::new(self.find_progress()).color(self.app_palette().muted)); let row_height = 52.; let chosen = self.search_state.selected_hit; let mut clicked = None; @@ -5462,10 +5851,12 @@ impl PdfEditorApp { ui.painter().rect_filled( rect, 3., - if active { + if active && ui.visuals().dark_mode { + self.app_palette().hover + } else if active { Color32::from_rgb(234, 222, 199) } else { - BAR_FILL + self.app_palette().bar }, ); } @@ -5473,7 +5864,7 @@ impl PdfEditorApp { ui.painter().vline( rect.left() + 1., rect.y_range(), - Stroke::new(2., Color32::from_rgb(121, 68, 23)), + Stroke::new(2., self.app_palette().accent), ); } let at = rect.left_top() + Vec2::new(7., 2.); @@ -5482,14 +5873,14 @@ impl PdfEditorApp { Align2::LEFT_TOP, format!("PAGE {} · {}", hit.page_index + 1, hit.source.label()), FontId::proportional(10.), - MUTED_INK, + self.app_palette().muted, ); let mut job = LayoutJob::default(); job.wrap.max_width = (rect.width() - 14.).max(12.); job.wrap.max_rows = 2; let fmt = TextFormat { font_id: FontId::proportional(13.), - color: INK, + color: self.app_palette().ink, ..Default::default() }; job.append(&hit.snippet[..hit.snippet_match.start], 0., fmt.clone()); @@ -5505,7 +5896,7 @@ impl PdfEditorApp { let galley = ui.fonts_mut(|fonts| fonts.layout_job(job)); ui.painter() .with_clip_rect(rect.intersect(ui.clip_rect())) - .galley(at + Vec2::new(0., 14.), galley, INK); + .galley(at + Vec2::new(0., 14.), galley, self.app_palette().ink); if response.on_hover_text(&hit.snippet).clicked() { clicked = Some(i); } @@ -5547,189 +5938,63 @@ impl PdfEditorApp { } fn draw_notes_tab(&mut self, ui: &mut egui::Ui) { - ui.heading("Tools"); - ui.horizontal_wrapped(|ui| { - for tool in Tool::ALL { - ui.selectable_value(&mut self.active_tool, tool, tool.label()); - } - }); - + self.draw_annotation_list(ui); ui.add_space(8.0); - ui.label("Marker opacity"); - ui.add(egui::Slider::new(&mut self.marker_opacity, 0.1..=0.9)); - - if ui - .checkbox(&mut self.settings.reduce_motion, "Reduce highlight motion") - .on_hover_text("Highlights appear instantly instead of animating the ink stroke.") - .changed() - { - if let Err(error) = save_settings(&self.settings) { - self.push_error_notice(format!("Could not save motion setting: {error}")); - } - } - if ui - .checkbox( - &mut self.settings.optimize_large_documents, - "Optimize PDF opening", - ) - .on_hover_text("Open documents from lightweight metadata and load detailed structure only when Review Mode needs it. Applies when a document is next opened.") - .changed() - { - if let Err(error) = save_settings(&self.settings) { - self.push_error_notice(format!("Could not save optimization setting: {error}")); - } - } - if ui - .checkbox( - &mut self.settings.liquid_mode2_use_pymupdf_blocks, - "Use PyMuPDF block grouping for Review Mode", - ) - .on_hover_text("Use PyMuPDF paragraph boxes for Review Mode block assembly; detector fallback remains available when the sidecar cannot find text blocks.") - .changed() - { - if let Err(error) = save_settings(&self.settings) { - self.push_error_notice(format!( - "Could not save Review Mode grouping setting: {error}" - )); - } - } - if ui - .checkbox( - &mut self.settings.liquid_mode2_use_pp_footnote_regions, - "Use PP footnote regions for Review Mode", - ) - .on_hover_text("Use high-confidence PP footnote-region membership as a default-off Review Mode marginalia override.") - .changed() - { - if let Err(error) = save_settings(&self.settings) { - self.push_error_notice(format!( - "Could not save Review Mode footnote-region setting: {error}" - )); - } - } - - ui.add_space(8.0); - ui.label("Comment color"); - ui.horizontal(|ui| { - for (index, preset) in COMMENT_COLOR_PRESETS.iter().enumerate() { - let selected = self.comment_color_index == index; - let stroke = if selected { - Stroke::new(2.0_f32, Color32::from_rgb(72, 48, 26)) - } else { - Stroke::new(1.0_f32, Color32::from_rgb(210, 200, 184)) - }; - let response = ui - .add( - egui::Button::new("") - .fill(color_from_rgb(preset.color_rgb, 235)) - .stroke(stroke) - .min_size(Vec2::splat(20.0)), - ) - .on_hover_text(preset.label); - accessibility::name_color_choice(&response, preset.label, selected); - if response.clicked() { - self.comment_color_index = index; - } - } - }); - - ui.add_space(8.0); - ui.label("Text box"); - ui.add( - egui::TextEdit::multiline(&mut self.text_box_text) - .desired_rows(3) - .lock_focus(true), - ); - - ui.add_space(8.0); - ui.label("Signer"); - ui.text_edit_singleline(&mut self.signer_name); - - ui.add_space(12.0); ui.separator(); - ui.add_space(8.0); - ui.heading("Comments"); - let comments = self - .annotations - .iter() - .enumerate() - .filter_map(|(index, annotation)| { - if let AnnotationKind::Comment { - text, - color_rgb, - author, - author_id, - .. - } = &annotation.kind - { - Some(( - index, - annotation.page_index, - text.clone(), - *color_rgb, - self.comment_display_author(author, author_id), - !author_id.is_empty() && *author_id == self.settings.comment_author_id, - )) - } else { - None + egui::CollapsingHeader::new("Tools").show(ui, |ui| { + ui.horizontal_wrapped(|ui| { + for tool in Tool::ALL { + ui.selectable_value(&mut self.active_tool, tool, tool.label()); } - }) - .collect::>(); - if comments.is_empty() { - ui.label(RichText::new("No comments").color(MUTED_INK)); - } else { - egui::ScrollArea::vertical() - .max_height(180.0) - .show(ui, |ui| { - for (index, page_index, text, color_rgb, author, own) in comments { - if own { - if ui - .link(RichText::new(&author).strong()) - .on_hover_text("Edit your name for all comments") - .clicked() - { - self.open_comment_profile(); - } - } else { - ui.label(RichText::new(&author).strong()); - } - ui.horizontal(|ui| { - let (swatch_rect, _) = - ui.allocate_exact_size(Vec2::splat(12.0), Sense::hover()); - ui.painter().rect_filled( - swatch_rect, - 2, - color_from_rgb(color_rgb, 230), - ); - let label = format!( - "p{} {}", - page_index + 1, - comment_preview(&text).unwrap_or_else(|| "Comment".to_owned()) - ); - if ui - .selectable_label(self.selected_comment == Some(index), label) - .clicked() - { - self.go_to_page(page_index); - self.start_comment_edit(index); - } - }); + }); + ui.add_space(8.0); + ui.label("Marker opacity"); + ui.add(egui::Slider::new(&mut self.marker_opacity, 0.1..=0.9)); + ui.add_space(8.0); + ui.label("Comment color"); + ui.horizontal(|ui| { + for (index, preset) in COMMENT_COLOR_PRESETS.iter().enumerate() { + let selected = self.comment_color_index == index; + let stroke = if selected { + Stroke::new(2.0_f32, Color32::from_rgb(72, 48, 26)) + } else { + Stroke::new(1.0_f32, Color32::from_rgb(210, 200, 184)) + }; + let response = ui + .add( + egui::Button::new("") + .fill(color_from_rgb(preset.color_rgb, 235)) + .stroke(stroke) + .min_size(Vec2::splat(20.0)), + ) + .on_hover_text(preset.label); + accessibility::name_color_choice(&response, preset.label, selected); + if response.clicked() { + self.comment_color_index = index; } - }); - } - - ui.add_space(12.0); - ui.separator(); - ui.add_space(8.0); - ui.heading("Document"); + } + }); + ui.add_space(8.0); + ui.label("Text box"); + ui.add( + egui::TextEdit::multiline(&mut self.text_box_text) + .desired_rows(3) + .lock_focus(true), + ); + ui.add_space(8.0); + ui.label("Signer"); + ui.text_edit_singleline(&mut self.signer_name); + }); if let Some(document) = self.document.as_ref() { - ui.label(&document.title); - ui.label(RichText::new(format!("{} page(s)", document.page_count)).color(MUTED_INK)); + ui.add_space(8.0); ui.label( - RichText::new(format!("{} annotation(s)", self.annotations.len())).color(MUTED_INK), + RichText::new(format!( + "{} page(s) · {} annotation(s)", + document.page_count, + self.annotations.len() + )) + .color(self.app_palette().muted), ); - } else { - ui.label(RichText::new("No PDF loaded").color(MUTED_INK)); } } @@ -5792,7 +6057,7 @@ impl PdfEditorApp { RichText::new(format!( "{done} done, {running} running, {queued} queued, {idle} idle, {failed} failed" )) - .color(MUTED_INK), + .color(self.app_palette().muted), ); if let Some(state) = self.ocr_states.get(self.page_index) { @@ -5810,7 +6075,7 @@ impl PdfEditorApp { } ui.add_space(8.0); - ui.label(RichText::new("Recognized text").strong().color(INK)); + ui.label(RichText::new("Recognized text").strong().color(self.app_palette().ink)); egui::ScrollArea::vertical() .max_height(220.0) .show(ui, |ui| { @@ -5820,7 +6085,7 @@ impl PdfEditorApp { |ui| { ui.add( egui::Label::new( - RichText::new(text_preview(text, 900)).size(13.0).color(INK), + RichText::new(text_preview(text, 900)).size(13.0).color(self.app_palette().ink), ) .wrap(), ); @@ -5846,6 +6111,28 @@ impl PdfEditorApp { self.push_notice(message, NoticeSeverity::Warning); } + fn push_retry_open_notice( + &mut self, + message: impl Into, + path: PathBuf, + activate: bool, + prefetch: bool, + ) { + let mut notice = Notice::new(message, NoticeSeverity::Error); + notice.action = Some(NoticeAction::RetryOpen { + path, + activate, + prefetch, + }); + enqueue_notice(&mut self.notices, notice); + } + + fn push_retry_save_notice(&mut self, message: impl Into, epoch: u64, path: PathBuf) { + let mut notice = Notice::new(message, NoticeSeverity::Error); + notice.action = Some(NoticeAction::RetrySave { epoch, path }); + enqueue_notice(&mut self.notices, notice); + } + fn draw_notices(&mut self, ctx: &Context) { let now = Instant::now(); prune_notices_at(&mut self.notices, now); @@ -5856,83 +6143,129 @@ impl PdfEditorApp { let reduce_motion = self.settings.reduce_motion; let notices = self.notices.iter().cloned().collect::>(); let mut dismiss = None; - let mut animating = false; + let mut requested_action = None; + let mut hovered = vec![false; notices.len()]; + let entrance = notices.last().map_or(1.0, |notice| { + if reduce_motion { + 1.0 + } else { + motion::ease_out_cubic( + now.saturating_duration_since(notice.created_at) + .as_secs_f32() + / motion::SURFACE_SECONDS, + ) + } + }); egui::Area::new(egui::Id::new("general_notices")) .order(egui::Order::Foreground) - .anchor(Align2::RIGHT_BOTTOM, Vec2::new(-24.0, -56.0)) + .anchor( + Align2::RIGHT_BOTTOM, + Vec2::new(-24.0, -56.0 + 12.0 * (1.0 - entrance)), + ) .show(ctx, |ui| { ui.vertical(|ui| { for (index, notice) in notices.iter().enumerate().rev() { - let age = now.saturating_duration_since(notice.created_at); - let visibility = if reduce_motion { - 1.0 - } else { - let visibility = (age.as_secs_f32() / 0.18).clamp(0.0, 1.0); - animating |= visibility < 1.0; - visibility + let visibility = notice.visibility_at(now, reduce_motion); + let accent = match (ctx.theme(), notice.severity) { + (egui::Theme::Dark, NoticeSeverity::Info) => Color32::from_rgb(150, 198, 226), + (egui::Theme::Dark, NoticeSeverity::Warning) => Color32::from_rgb(237, 190, 115), + (egui::Theme::Dark, NoticeSeverity::Error) => Color32::from_rgb(245, 156, 150), + (_, NoticeSeverity::Info) => Color32::from_rgb(55, 104, 137), + (_, NoticeSeverity::Warning) => Color32::from_rgb(166, 105, 24), + (_, NoticeSeverity::Error) => Color32::from_rgb(164, 54, 54), }; - let alpha = (246.0 * visibility) as u8; - let accent = match notice.severity { - NoticeSeverity::Info => Color32::from_rgb(55, 104, 137), - NoticeSeverity::Warning => Color32::from_rgb(166, 105, 24), - NoticeSeverity::Error => Color32::from_rgb(164, 54, 54), - }; - egui::Frame::NONE - .fill(Color32::from_rgba_unmultiplied(255, 254, 250, alpha)) - .stroke(Stroke::new( - 1.0_f32, - Color32::from_rgba_unmultiplied( - accent.r(), - accent.g(), - accent.b(), - alpha, - ), - )) - .corner_radius(8) - .inner_margin(Margin::symmetric(14, 10)) - .show(ui, |ui| { - ui.set_width(360.0); - ui.horizontal(|ui| { - ui.label( - RichText::new(match notice.severity { - NoticeSeverity::Info => "i", - NoticeSeverity::Warning => "⚠", - NoticeSeverity::Error => "!", - }) - .strong() - .color(accent), - ); - ui.add( - egui::Label::new( - RichText::new(¬ice.message).color(INK).size(13.5), - ) - .wrap(), - ); - if ui - .small_button(RichText::new("x").color(MUTED_INK)) - .on_hover_text("Dismiss") - .clicked() - { - dismiss = Some(index); - } - }); - }); + let response = ui + .scope(|ui| { + ui.multiply_opacity(visibility); + egui::Frame::NONE + .fill(self.app_palette().bar) + .stroke(Stroke::new(1.0_f32, accent)) + .corner_radius(8) + .inner_margin(Margin::symmetric(14, 10)) + .show(ui, |ui| { + ui.set_width(360.0); + ui.horizontal(|ui| { + ui.label( + RichText::new(match notice.severity { + NoticeSeverity::Info => "i", + NoticeSeverity::Warning => "⚠", + NoticeSeverity::Error => "!", + }) + .strong() + .color(accent), + ); + ui.add( + egui::Label::new( + RichText::new(¬ice.message) + .color(self.app_palette().ink) + .size(13.5), + ) + .wrap(), + ); + if ui + .small_button(RichText::new("x").color(self.app_palette().muted)) + .on_hover_text("Dismiss") + .clicked() + { + dismiss = Some(index); + } + }); + if let Some(action) = ¬ice.action { + ui.add_space(4.0); + let label = match action { + NoticeAction::RetryOpen { .. } => "Retry opening", + NoticeAction::RetrySave { .. } => "Retry saving", + }; + if ui.small_button(label).clicked() { + requested_action = Some((index, action.clone())); + } + } + }) + .response + }) + .inner; + // Egui keeps the last interaction position for a frame + // after PointerGone; only pause for a live pointer in the + // focused window. + hovered[index] = ctx + .input(|input| input.focused && input.pointer.hover_pos().is_some()) + && ui.rect_contains_pointer(response.rect); ui.add_space(6.0); } }); }); - if let Some(index) = dismiss { + for (notice, is_hovered) in self.notices.iter_mut().zip(hovered) { + let hover_changed = is_hovered != notice.hovered_at.is_some(); + notice.set_hovered_at(is_hovered, now); + if hover_changed && notice.severity == NoticeSeverity::Info && !reduce_motion { + ctx.request_repaint(); + } + } + if let Some((index, action)) = requested_action { + self.notices.remove(index); + match action { + NoticeAction::RetryOpen { + path, + activate, + prefetch, + } => { + if activate || self.tabs.is_empty() { + self.startup_error = None; + } + self.load_document_with_options(path, ctx, activate, true, prefetch); + } + NoticeAction::RetrySave { epoch, path } => { + self.retry_annotation_save(epoch, path, ctx); + } + } + } else if let Some(index) = dismiss { self.notices.remove(index); } - if animating { - ctx.request_repaint_after(Duration::from_millis(16)); - } else if self - .notices - .iter() - .any(|notice| notice.severity == NoticeSeverity::Info) - { - ctx.request_repaint_after(Duration::from_millis(250)); + for notice in &self.notices { + if let Some(delay) = notice.repaint_after(now, reduce_motion) { + ctx.request_repaint_after(delay); + } } } @@ -5951,22 +6284,22 @@ impl PdfEditorApp { .anchor(Align2::RIGHT_TOP, Vec2::new(-24.0, 24.0)) .show(ctx, |ui| { egui::Frame::NONE - .fill(Color32::from_rgba_unmultiplied(255, 254, 250, 246)) - .stroke(Stroke::new(1.0_f32, Color32::from_rgb(205, 197, 181))) + .fill(self.app_palette().bar) + .stroke(Stroke::new(1.0_f32, self.app_palette().rule)) .corner_radius(8) .inner_margin(Margin::symmetric(16, 12)) .show(ui, |ui| { ui.set_width(330.0); ui.horizontal(|ui| { if matches!(notice.kind, UpdateNoticeKind::Working) { - ui.spinner(); + motion::busy(ui, self.settings.reduce_motion); } else { let (symbol, color) = match notice.kind { UpdateNoticeKind::Success => { - ("✓", Color32::from_rgb(45, 118, 73)) + ("✓", if ui.visuals().dark_mode { Color32::from_rgb(147, 218, 165) } else { Color32::from_rgb(45, 118, 73) }) } UpdateNoticeKind::Error => { - ("!", Color32::from_rgb(164, 54, 54)) + ("!", ui.visuals().error_fg_color) } UpdateNoticeKind::Working => unreachable!(), }; @@ -5977,14 +6310,14 @@ impl PdfEditorApp { RichText::new(¬ice.message) .strong() .size(15.0) - .color(INK), + .color(self.app_palette().ink), ) .wrap(), ); if !matches!(notice.kind, UpdateNoticeKind::Working) && !matches!(self.update_ui.state, UpdateUiState::Ready) && ui - .small_button(RichText::new("x").color(MUTED_INK)) + .small_button(RichText::new("x").color(self.app_palette().muted)) .on_hover_text("Dismiss") .clicked() { @@ -6024,7 +6357,6 @@ impl PdfEditorApp { if dismiss { self.update_ui.notice = None; } - ctx.request_repaint_after(Duration::from_millis(250)); } fn draw_liquid_status_popover(&mut self, ctx: &Context) { @@ -6048,8 +6380,8 @@ impl PdfEditorApp { .anchor(Align2::RIGHT_TOP, Vec2::new(-24.0, 118.0)) .show(ctx, |ui| { egui::Frame::NONE - .fill(Color32::from_rgba_unmultiplied(255, 254, 250, 238)) - .stroke(Stroke::new(1.0_f32, Color32::from_rgb(205, 197, 181))) + .fill(self.app_palette().bar) + .stroke(Stroke::new(1.0_f32, self.app_palette().rule)) .corner_radius(8) .inner_margin(Margin::symmetric(14, 12)) .show(ui, |ui| { @@ -6057,7 +6389,7 @@ impl PdfEditorApp { match state { LiquidState::PreparingText => { ui.horizontal(|ui| { - ui.spinner(); + motion::busy(ui, self.settings.reduce_motion); ui.label(RichText::new("Preparing PDF Text").strong()); }); ui.add_space(8.0); @@ -6069,13 +6401,13 @@ impl PdfEditorApp { ); ui.label( RichText::new("The LLM request has not been sent yet.") - .color(MUTED_INK) + .color(self.app_palette().muted) .size(13.0), ); } LiquidState::Preparing => { ui.horizontal(|ui| { - ui.spinner(); + motion::busy(ui, self.settings.reduce_motion); ui.label(RichText::new("Preparing Review Mode").strong()); }); ui.add_space(8.0); @@ -6087,7 +6419,7 @@ impl PdfEditorApp { ); ui.label( RichText::new("You can keep reading the PDF.") - .color(MUTED_INK) + .color(self.app_palette().muted) .size(13.0), ); } @@ -6100,16 +6432,16 @@ impl PdfEditorApp { "local fallback" } }); - ui.label(RichText::new("Review Mode Ready").strong().color(INK)); + ui.label(RichText::new("Review Mode Ready").strong().color(self.app_palette().ink)); ui.label( RichText::new(format!("Prepared with {engine}.")) - .color(MUTED_INK) + .color(self.app_palette().muted) .size(13.0), ); if let Some(warning) = document.warnings.first() { ui.label( RichText::new(text_preview(warning, 160)) - .color(Color32::from_rgb(134, 92, 34)) + .color(ui.visuals().warn_fg_color) .size(12.5), ); } @@ -6127,11 +6459,11 @@ impl PdfEditorApp { ui.label( RichText::new("Review Mode Failed") .strong() - .color(Color32::from_rgb(132, 49, 42)), + .color(ui.visuals().error_fg_color), ); ui.label( RichText::new(text_preview(&error, 180)) - .color(MUTED_INK) + .color(self.app_palette().muted) .size(12.5), ); ui.add_space(8.0); @@ -6164,17 +6496,29 @@ impl PdfEditorApp { fn draw_empty_state(&mut self, ui: &mut egui::Ui, ctx: &Context) { let available = ui.available_rect_before_wrap(); - let drop_rect = Rect::from_center_size( - available.center(), - Vec2::new(available.width().min(520.0), 260.0), - ); + let has_recent = !self.settings.recent_documents.is_empty(); + let card_height = if has_recent { 142.0 } else { 260.0 }; + let center = if has_recent { + Pos2::new( + available.center().x, + available.top() + card_height * 0.5 + 14.0, + ) + } else { + available.center() + }; + let drop_rect = + Rect::from_center_size(center, Vec2::new(available.width().min(520.0), card_height)); let response = ui.allocate_rect(drop_rect, Sense::click()); if response.clicked() { self.open_dialog(ctx); } let hovering_drop = ctx.input(|input| !input.raw.hovered_files.is_empty()); - let fill = if hovering_drop { + let fill = if ctx.theme() == egui::Theme::Dark && hovering_drop { + self.app_palette().hover + } else if ctx.theme() == egui::Theme::Dark { + self.app_palette().bar + } else if hovering_drop { Color32::from_rgb(246, 241, 229) } else { Color32::from_rgb(250, 248, 242) @@ -6182,7 +6526,7 @@ impl PdfEditorApp { let stroke = if hovering_drop { Stroke::new(1.8_f32, Color32::from_rgb(164, 119, 63)) } else { - Stroke::new(1.0_f32, Color32::from_rgb(211, 205, 193)) + Stroke::new(1.0_f32, self.app_palette().rule) }; let painter = ui.painter(); @@ -6198,29 +6542,38 @@ impl PdfEditorApp { painter.rect_filled(drop_rect, 8, fill); painter.rect_stroke(drop_rect, 8, stroke, egui::StrokeKind::Inside); painter.text( - drop_rect.center_top() + Vec2::new(0.0, 74.0), + drop_rect.center_top() + Vec2::new(0.0, if has_recent { 44.0 } else { 74.0 }), Align2::CENTER_CENTER, "Drop PDF here", FontId::proportional(30.0), - INK, + self.app_palette().ink, ); painter.text( - drop_rect.center_top() + Vec2::new(0.0, 118.0), + drop_rect.center_top() + Vec2::new(0.0, if has_recent { 82.0 } else { 118.0 }), Align2::CENTER_CENTER, "or click to open", FontId::proportional(18.0), - MUTED_INK, + self.app_palette().muted, ); if let Some(error) = self.startup_error.as_ref() { painter.text( - drop_rect.center_top() + Vec2::new(0.0, 170.0), + drop_rect.center_top() + Vec2::new(0.0, if has_recent { 120.0 } else { 170.0 }), Align2::CENTER_CENTER, error, FontId::proportional(14.0), - Color32::from_rgb(164, 58, 46), + ui.visuals().error_fg_color, ); } + if has_recent { + let list = Rect::from_min_max( + Pos2::new(drop_rect.left(), drop_rect.bottom() + 18.0), + Pos2::new(drop_rect.right(), available.bottom() - 8.0), + ); + if list.height() > 30.0 { + self.draw_recent_documents(ui, ctx, list); + } + } } fn handle_dropped_files(&mut self, ctx: &Context) { @@ -6292,11 +6645,13 @@ impl PdfEditorApp { } fn draw_liquid_document(&mut self, ui: &mut egui::Ui, ctx: &Context) { + ui.set_style(ctx.style_of(if self.liquid_theme == LiquidTheme::Dark { egui::Theme::Dark } else { egui::Theme::Light })); + ui.painter().rect_filled(ui.max_rect(), 0.0, self.review_sheet_colors().0); let state = self.liquid_state.clone(); egui::ScrollArea::vertical() .id_salt("liquid_document") .auto_shrink([false, false]) - .show(ui, |ui| { + .show_viewport(ui, |ui, viewport| { let available = ui.available_width(); let width = available .min(self.liquid_max_width) @@ -6317,7 +6672,7 @@ impl PdfEditorApp { } LiquidState::PreparingText => { ui.add_space(80.0); - ui.spinner(); + motion::busy(ui, self.settings.reduce_motion); ui.label(RichText::new("Preparing PDF text").size(20.0)); ui.label( RichText::new("The LLM request will send after text loads.") @@ -6326,7 +6681,7 @@ impl PdfEditorApp { } LiquidState::Preparing => { ui.add_space(80.0); - ui.spinner(); + motion::busy(ui, self.settings.reduce_motion); ui.label(RichText::new("Preparing Review Mode").size(20.0)); ui.label( RichText::new("Formatting text...") @@ -6339,7 +6694,7 @@ impl PdfEditorApp { RichText::new("Review Mode unavailable") .size(22.0) .strong() - .color(Color32::from_rgb(132, 49, 42)), + .color(ui.visuals().error_fg_color), ); ui.label(RichText::new(error).color(self.liquid_muted_color())); if ui.button("Retry").clicked() { @@ -6360,16 +6715,16 @@ impl PdfEditorApp { } self.draw_liquid_controls(ui, &document); self.draw_liquid_tts_controls(ui, &document); - self.liquid_footnote_index = - build_liquid_footnote_index(&document.blocks); - if liquid_document_needs_ocr(&document) { + self.refresh_review_derived(&document); + if self.review_derived.needs_ocr { self.draw_liquid_ocr_actions(ui, ctx, false); - } else if let Some(hint) = liquid_reflow_low_confidence(&document) { + } else if let Some(hint) = self.review_derived.low_confidence { // #23: confidence-gated fallback affordance. self.draw_liquid_reflow_gate(ui, ctx, hint); } - let notes = liquid_note_blocks(&document.blocks); - let hidden_contents = review_hidden_display_mask(&document.blocks); + let notes = Arc::clone(&self.review_derived.note_indices); + let hidden_contents = + Arc::clone(&self.review_derived.hidden_contents); self.chrome.margin_notes_live = false; self.draw_review_blocks( ui, @@ -6383,21 +6738,42 @@ impl PdfEditorApp { if !self.liquid_mode2_complete { self.draw_liquid_deciphering(ui, ctx); } - self.draw_liquid_notes(ui, ¬es); + self.draw_liquid_notes(ui, &document, ¬es); ui.add_space(40.0); } } }); }); + if let Some(command) = self.pending_reading_scroll.take() { + reading_navigation::apply(ui, viewport, command, self.settings.reduce_motion); + } }); } fn draw_review_source_page(&mut self, ui: &mut egui::Ui, ctx: &Context) { + if self.search_state.split_original + && let Some(command) = self.pending_reading_scroll.take() + && let Some(page_count) = self.document.as_ref().map(|document| document.page_count) + { + let current = self + .liquid_provenance_highlight + .first() + .map_or(self.page_index, |(page, _)| *page); + let target = command.page_target(current, page_count); + self.liquid_provenance_highlight.clear(); + self.go_to_page(target); + } let highlights = self.liquid_provenance_highlight.clone(); let page_index = highlights .first() .map(|(page, _)| *page) .unwrap_or(self.page_index); + self.render_tx.set_viewport( + self.document_epoch, + page_index.saturating_sub(PAGE_PREFETCH_RADIUS), + page_index.saturating_add(PAGE_PREFETCH_RADIUS), + page_index, + ); let path = self.document.as_ref().map(|document| document.path.clone()); let page = self .document @@ -6434,7 +6810,7 @@ impl PdfEditorApp { texture.texture.id(), dest, Rect::from_min_max(Pos2::ZERO, Pos2::new(1.0, 1.0)), - Color32::WHITE, + self.original_page_tint(), ); if let Some(page) = page.as_ref() { if self.search_state.split_original { @@ -6475,11 +6851,12 @@ impl PdfEditorApp { } } } else { - ui.label(RichText::new("Loading source page…").color(self.liquid_muted_color())); + ui.label(RichText::new("Loading source page…").color(self.app_palette().muted)); } } fn draw_liquid_mode2_document(&mut self, ui: &mut egui::Ui, ctx: &Context) { + ui.set_style(ctx.style_of(if self.liquid_theme == LiquidTheme::Dark { egui::Theme::Dark } else { egui::Theme::Light })); let state = self.liquid_mode2_state.clone(); let ready_document = match &state { LiquidState::Ready(document) => Some(Arc::clone(document)), @@ -6490,7 +6867,7 @@ impl PdfEditorApp { let review_scroll = egui::ScrollArea::vertical() .id_salt("liquid_mode2_document") .auto_shrink([false, false]) - .show(ui, |ui| { + .show_viewport(ui, |ui, viewport| { // Keep a gutter of canvas around the sheet. let available = (ui.available_width() - 2.0 * REVIEW_SHEET_GUTTER).max(360.0); // Margin notes are worth a narrower column: give up body width, @@ -6528,7 +6905,7 @@ impl PdfEditorApp { } LiquidState::PreparingText => { ui.add_space(80.0); - ui.spinner(); + motion::busy(ui, self.settings.reduce_motion); ui.label(RichText::new("Preparing PDF layout").size(20.0)); ui.label( RichText::new("Review Mode is extracting line geometry.") @@ -6538,7 +6915,7 @@ impl PdfEditorApp { } LiquidState::Preparing => { ui.add_space(80.0); - ui.spinner(); + motion::busy(ui, self.settings.reduce_motion); ui.label(RichText::new("Preparing Review Mode").size(20.0)); ui.label( RichText::new("Decoding page-level reading flow...") @@ -6552,7 +6929,7 @@ impl PdfEditorApp { RichText::new("Review Mode unavailable") .size(22.0) .strong() - .color(Color32::from_rgb(132, 49, 42)), + .color(ui.visuals().error_fg_color), ); ui.label(RichText::new(error).color(self.liquid_muted_color())); if ui.button("Retry Review Mode").clicked() { @@ -6580,13 +6957,13 @@ impl PdfEditorApp { self.draw_unanchored_comments(ui, &comments); } } - if liquid_document_needs_ocr(&document) { + if self.review_derived.needs_ocr { self.draw_liquid_ocr_actions(ui, ctx, true); - } else if let Some(hint) = liquid_reflow_low_confidence(&document) { + } else if let Some(hint) = self.review_derived.low_confidence { // #23: confidence-gated fallback affordance. self.draw_liquid_reflow_gate(ui, ctx, hint); } - let notes = liquid_note_blocks(&document.blocks); + let notes = Arc::clone(&self.review_derived.note_indices); let hidden_contents = Arc::clone(&self.review_derived.hidden_contents); let margins_live = margin_width > 0.0; @@ -6627,7 +7004,7 @@ impl PdfEditorApp { ui.add_space(column.body_indent); ui.vertical(|ui| { ui.set_width(width); - self.draw_liquid_notes(ui, ¬es); + self.draw_liquid_notes(ui, &document, ¬es); }); }); } @@ -6657,6 +7034,11 @@ impl PdfEditorApp { ); }); }); + if (!self.review_pdf_split || !self.search_state.split_original) + && let Some(command) = self.pending_reading_scroll.take() + { + reading_navigation::apply(ui, viewport, command, self.settings.reduce_motion); + } }); if self.find_scope() == search_state::FindScope::Review { self.find_rail( @@ -6667,6 +7049,7 @@ impl PdfEditorApp { } if let Some(document) = ready_document { self.draw_review_reveal(ui, ctx, &document); + self.draw_review_intro(ctx, ui.max_rect()); } } @@ -6698,11 +7081,146 @@ impl PdfEditorApp { width: f32, mut margin: Option<&mut ReviewMarginCollector>, comments: &comments_ui::ReviewComments, + ) { + let ready = match self.view_mode { + DocumentViewMode::Liquid => Some(&self.liquid_state), + DocumentViewMode::LiquidMode2 => Some(&self.liquid_mode2_state), + DocumentViewMode::Pdf => None, + }; + let document_arc = match ready { + Some(LiquidState::Ready(arc)) if std::ptr::eq(arc.as_ref(), document) => { + Some(Arc::clone(arc)) + } + _ => None, + }; + let selecting = ui + .ctx() + .plugin::() + .lock() + .has_selection() + || ui.input(|input| { + input.pointer.primary_down() + && input + .pointer + .press_origin() + .is_some_and(|point| ui.clip_rect().contains(point)) + }); + let eligible = document_arc.is_some() + && !selecting + && !self.search_state.open + && !self.review_shows_comments() + && !self.liquid_show_hidden_furniture + && self.liquid_scroll_to_block.is_none() + && self.review_reveal.is_none() + && !ui.ctx().memory(|memory| memory.options.screen_reader); + let has_margin = margin.is_some(); + let pixels_per_point = ui.ctx().pixels_per_point(); + let mut cached = self.review_layout.take(); + if let Some(layout) = cached.as_mut() { + layout.active_this_frame = false; + } + let now = ui.input(|input| input.time); + let mut cache_matches = false; + let mut restore_resize_anchor = None; + if eligible + && let Some(layout) = cached.as_mut() + && layout.is_compatible( + document_arc.as_ref().unwrap(), + width, + indent, + self.liquid_text_scale, + pixels_per_point, + self.liquid_theme, + self.liquid_hide_footnotes, + has_margin, + ) + { + let previous_factor = layout.resize_factor; + if layout.width == width.to_bits() { + layout.observed_width = width.to_bits(); + layout.width_changed_at = None; + layout.resize_factor = 1.0; + layout.resize_anchor = None; + cache_matches = true; + } else { + if layout.observed_width != width.to_bits() { + if layout.width_changed_at.is_none() { + layout.resize_anchor = self.search_state.review_anchor; + } + layout.observed_width = width.to_bits(); + layout.width_changed_at = Some(now); + } + if ui.input(|input| input.raw_scroll_delta != Vec2::ZERO) { + layout.resize_anchor = self.search_state.review_anchor; + } + let elapsed = (now - layout.width_changed_at.unwrap_or(now)).max(0.0); + if elapsed < 0.12 { + layout.resize_factor = + (f32::from_bits(layout.width) / width.max(1.0)).clamp(0.5, 2.0); + cache_matches = true; + ui.ctx() + .request_repaint_after(Duration::from_secs_f64(0.12 - elapsed)); + } else { + restore_resize_anchor = layout.resize_anchor; + } + } + layout.previous_resize_factor = previous_factor; + } + let scope_id = ( + "review-body", + self.document_epoch, + self.view_mode == DocumentViewMode::LiquidMode2, + ); + ui.scope_builder(egui::UiBuilder::new().id(scope_id), |ui| { + if cache_matches { + let layout = cached.as_mut().unwrap(); + layout.active_this_frame = true; + self.draw_virtual_review_blocks(ui, document, hidden_contents, indent, width, margin.as_deref_mut(), layout); + return; + } + let origin = ui.cursor().min; + let mut rows = Vec::new(); + self.draw_review_blocks_full(ui, document, hidden_contents, indent, width, margin.as_deref_mut(), comments, origin, &mut rows); + if let Some(anchor) = restore_resize_anchor + && let Some(row) = rows.iter().find(|row| matches!(row.kind, review_layout::RowKind::Block(index) if index == anchor)) + { + let delta = ui.clip_rect().top() - (origin.y + row.top); + if delta.abs() > 0.5 { + ui.scroll_with_delta_animation(Vec2::new(0.0, delta), egui::style::ScrollAnimation::none()); + } + } + if eligible && !document.blocks.iter().any(|block| matches!(block.role, LiquidBlockRole::Table | LiquidBlockRole::Caption)) { + cached = Some(review_layout::ReviewLayout { + id: review_layout::next_layout_id(), active_this_frame: true, + document: Arc::downgrade(document_arc.as_ref().unwrap()), width: width.to_bits(), indent: indent.to_bits(), + observed_width: width.to_bits(), width_changed_at: None, resize_factor: 1.0, previous_resize_factor: 1.0, resize_anchor: None, + scale: self.liquid_text_scale.to_bits(), pixels_per_point: pixels_per_point.to_bits(), theme: self.liquid_theme, + hide_notes: self.liquid_hide_footnotes, has_margin, origin, + height: (ui.cursor().top() - origin.y).max(0.0), rows, + margin: margin.as_deref().cloned(), + }); + } + }); + self.review_layout = cached; + } + + fn draw_review_blocks_full( + &mut self, + ui: &mut egui::Ui, + document: &LiquidDocument, + hidden_contents: &[bool], + indent: f32, + width: f32, + mut margin: Option<&mut ReviewMarginCollector>, + comments: &comments_ui::ReviewComments, + origin: Pos2, + rows: &mut Vec, ) { self.search_state.review_anchor = None; self.search_state.review_anchor_byte = 0; let mut block_index = 0usize; while block_index < document.blocks.len() { + let row_top = ui.cursor().top() - origin.y; let block = &document.blocks[block_index]; if self.review_shows_comments() && let Some(margin) = margin.as_deref_mut() @@ -6780,14 +7298,23 @@ impl PdfEditorApp { .map(|(_, block)| block.clone()) .collect::>(); if !visible_metadata.is_empty() { - in_review_column(ui, indent, width, |ui| { - self.draw_liquid_metadata_group(ui, &visible_metadata); + self.draw_review_metadata_row(ui, indent, width, start, &visible_metadata); + rows.push(review_layout::Row { + top: row_top, + bottom: ui.cursor().top() - origin.y, + kind: review_layout::RowKind::Metadata { + start, + end: block_index, + }, }); } continue; } - let hits = in_review_column(ui, indent, width, |ui| { - self.draw_liquid_feedback_block(ui, document, block_index, block) + let hits = self.draw_review_block_row(ui, document, block_index, indent, width); + rows.push(review_layout::Row { + top: row_top, + bottom: ui.cursor().top() - origin.y, + kind: review_layout::RowKind::Block(block_index), }); if !self.review_shows_comments() && let Some(margin) = margin.as_deref_mut() @@ -6798,6 +7325,167 @@ impl PdfEditorApp { } } + fn review_row_id(&self, index: usize) -> egui::Id { + egui::Id::new(( + "review-row", + self.document_epoch, + self.view_mode == DocumentViewMode::LiquidMode2, + index, + )) + } + + fn draw_review_block_row( + &mut self, + ui: &mut egui::Ui, + document: &LiquidDocument, + index: usize, + indent: f32, + width: f32, + ) -> Vec<(Rect, u16)> { + let id = self.review_row_id(index); + in_review_column(ui, indent, width, |ui| { + ui.scope_builder(egui::UiBuilder::new().id(id), |ui| { + self.draw_liquid_feedback_block(ui, document, index, &document.blocks[index]) + }) + .inner + }) + } + + fn draw_review_metadata_row( + &self, + ui: &mut egui::Ui, + indent: f32, + width: f32, + start: usize, + metadata: &[LiquidBlock], + ) { + let id = self.review_row_id(start); + in_review_column(ui, indent, width, |ui| { + ui.scope_builder(egui::UiBuilder::new().id(id), |ui| { + self.draw_liquid_metadata_group(ui, metadata) + }); + }); + } + + fn draw_virtual_review_blocks( + &mut self, + ui: &mut egui::Ui, + document: &LiquidDocument, + hidden_contents: &[bool], + indent: f32, + width: f32, + margin: Option<&mut ReviewMarginCollector>, + layout: &review_layout::ReviewLayout, + ) { + self.search_state.review_anchor = None; + self.search_state.review_anchor_byte = 0; + let origin = ui.cursor().min; + let factor = layout.resize_factor; + if let (Some(target), Some(saved)) = (margin, layout.margin.as_ref()) { + let mut delta = origin - layout.origin; + delta.x += indent - f32::from_bits(layout.indent); + *target = saved.transformed(delta, factor, layout.origin.y); + } + if layout.height <= 0.0 { + return; + } + let (_, body_rect) = ui.allocate_space(Vec2::new( + indent + width, + (layout.height * factor - ui.spacing().item_spacing.y).max(0.0), + )); + let origin = body_rect.min; + if (factor - layout.previous_resize_factor).abs() > 0.0001 { + let current_offset = (ui.clip_rect().top() - origin.y).max(0.0); + let delta = current_offset * (1.0 - factor / layout.previous_resize_factor.max(0.01)); + if delta.abs() > 0.5 { + ui.scroll_with_delta_animation( + Vec2::new(0.0, delta), + egui::style::ScrollAnimation::none(), + ); + } + } + let clip = ui.clip_rect().expand2(Vec2::new( + 0.0, + if (factor - 1.0).abs() > 0.0001 { + 600.0 + } else { + 200.0 + }, + )); + let first = layout + .rows + .partition_point(|row| origin.y + row.bottom * factor < clip.top()); + let last = layout + .rows + .partition_point(|row| origin.y + row.top * factor <= clip.bottom()); + if let Some(index) = layout.rows[..first] + .iter() + .rev() + .find_map(|row| match row.kind { + review_layout::RowKind::Block(index) + if matches!( + document.blocks[index].role, + LiquidBlockRole::Title + | LiquidBlockRole::Heading + | LiquidBlockRole::Subheading + ) => + { + Some(index) + } + _ => None, + }) + { + self.review_heading_screen_rects.push(( + index, + Rect::from_min_size(Pos2::new(origin.x, clip.top() - 1.0), Vec2::ZERO), + )); + } + let mut next_top = None; + for row in &layout.rows[first..last] { + let index = match row.kind { + review_layout::RowKind::Block(index) => index, + review_layout::RowKind::Metadata { start, .. } => start, + }; + let estimated_top = origin.y + row.top * factor; + let top = if (factor - 1.0).abs() > 0.0001 { + next_top.unwrap_or(estimated_top) + } else { + estimated_top + }; + let rect = Rect::from_min_size( + Pos2::new(origin.x, top), + Vec2::new(indent + width, ((row.bottom - row.top) * factor).max(1.0)), + ); + let mut child = ui.new_child( + egui::UiBuilder::new() + .id(("review-row-container", self.document_epoch, index)) + .max_rect(rect) + .layout(egui::Layout::top_down(Align::LEFT)), + ); + match row.kind { + review_layout::RowKind::Block(index) => { + self.draw_review_block_row(&mut child, document, index, indent, width); + } + review_layout::RowKind::Metadata { start, end } => { + let metadata = document.blocks[start..end] + .iter() + .enumerate() + .filter(|(offset, block)| { + !hidden_contents + .get(start + offset) + .copied() + .unwrap_or(false) + && !should_hide_contents_block_for_display(block) + }) + .map(|(_, block)| block.clone()) + .collect::>(); + self.draw_review_metadata_row(&mut child, indent, width, start, &metadata); + } + } + next_top = Some(child.cursor().top()); + } + } + /// The first time a document opens in Review Mode from the original page, /// measure that page so the reveal can show where its words went, and /// open Review Mode at that page. Later switches keep Review Mode's own @@ -6898,6 +7586,7 @@ impl PdfEditorApp { let captured = std::mem::take(&mut *self.review_reveal_labels.borrow_mut()); let (canvas, sheet_fill, _) = self.review_sheet_colors(); let ink = self.liquid_ink_color(); + let page_tint = self.original_page_tint(); let Some(reveal) = self.review_reveal.as_mut() else { return; }; @@ -6921,7 +7610,8 @@ impl PdfEditorApp { ) }) }); - if interrupted || reveal.finished(now) || !reveal.is_active() { + if self.settings.reduce_motion || interrupted || reveal.finished(now) || !reveal.is_active() + { self.review_reveal = None; ctx.request_repaint(); return; @@ -6973,14 +7663,14 @@ impl PdfEditorApp { painter.rect_filled( reveal.page_rect, 3.0, - review_reveal::with_alpha(Color32::WHITE, page_alpha), + review_reveal::with_alpha(page_tint, page_alpha), ); if let Some(texture) = reveal.texture { painter.image( texture, reveal.page_rect, Rect::from_min_max(Pos2::ZERO, Pos2::new(1.0, 1.0)), - review_reveal::with_alpha(Color32::WHITE, page_alpha), + review_reveal::with_alpha(page_tint, page_alpha), ); } } @@ -6992,13 +7682,15 @@ impl PdfEditorApp { painter.rect_filled( word.from.expand(1.5), 0.0, - review_reveal::with_alpha(Color32::WHITE, page_alpha * f.alpha), + review_reveal::with_alpha(page_tint, page_alpha * f.alpha), ); } } } for (_, _, galley, f) in &flights { - if f.alpha <= 0.01 || f.settled { + // An early word can arrive while the printed page still covers + // its destination. Keep it visible on top until that page fades. + if !review_reveal::show_flight(f, page_alpha) { continue; } let mut shape = egui::epaint::TextShape::new(Pos2::ZERO, Arc::clone(galley), ink) @@ -7012,6 +7704,9 @@ impl PdfEditorApp { /// Eased 0..1 progress of the reveal that plays when the reflow replaces the /// original page. Returns 1.0 when there is nothing to animate. fn liquid_mode2_reveal_progress(&self, now: f64) -> f32 { + if self.settings.reduce_motion { + return 1.0; + } const REVEAL_SECONDS: f64 = 0.55; match self.liquid_mode2_reveal_at { Some(started) => { @@ -7030,6 +7725,19 @@ impl PdfEditorApp { /// simply appears to stop, and a reader cannot tell a short paper from an /// unfinished one. fn draw_liquid_deciphering(&self, ui: &mut egui::Ui, ctx: &Context) { + if self.settings.reduce_motion { + ui.label( + RichText::new("Preparing the rest of the article…") + .color(self.liquid_muted_color()), + ); + if let Some((checked, total)) = self.review_text_preparation_progress() { + ui.label( + RichText::new(format!("Page text checked: {checked} of {total}")) + .color(self.liquid_muted_color()), + ); + } + return; + } // Ragged widths so the block reads as prose rather than a progress bar. const LINE_FILL: [f32; 4] = [1.0, 0.96, 0.99, 0.58]; @@ -7041,13 +7749,18 @@ impl PdfEditorApp { Self::draw_scrambled_rows(ui, time, &LINE_FILL, muted, size); ui.add_space(6.0); ui.horizontal(|ui| { - ui.spinner(); + motion::busy(ui, self.settings.reduce_motion); ui.label( RichText::new("Deciphering the rest of the article\u{2026}") .color(muted) .italics(), ); }); + if let Some((checked, total)) = self.review_text_preparation_progress() { + ui.label( + RichText::new(format!("Page text checked: {checked} of {total}")).color(muted), + ); + } ui.add_space(10.0); // Drive the animation without pinning the UI at full frame rate. ctx.request_repaint_after(std::time::Duration::from_millis(70)); @@ -7059,7 +7772,11 @@ impl PdfEditorApp { /// work is happening and that the switch will be automatic. It is /// non-interactive on purpose so it cannot swallow clicks meant for the page. fn draw_liquid_pending_overlay(&self, ctx: &Context) { - let time = ctx.input(|input| input.time); + let time = if self.settings.reduce_motion { + 0.0 + } else { + ctx.input(|input| input.time) + }; let dark = matches!(self.liquid_theme, LiquidTheme::Dark); let (fill, stroke, ink, muted) = if dark { ( @@ -7096,7 +7813,7 @@ impl PdfEditorApp { .show(ui, |ui| { ui.set_max_width(268.0); ui.horizontal(|ui| { - ui.spinner(); + if !self.settings.reduce_motion { motion::busy(ui, self.settings.reduce_motion); } ui.add_space(2.0); ui.label( RichText::new("Review Mode engaged") @@ -7115,9 +7832,14 @@ impl PdfEditorApp { .size(11.0) .color(muted), ); + if let Some((checked, total)) = self.review_text_preparation_progress() { + ui.label(RichText::new(format!("Page text checked: {checked} of {total}")).size(11.0).color(muted)); + } }); }); - ctx.request_repaint_after(std::time::Duration::from_millis(70)); + if !self.settings.reduce_motion { + ctx.request_repaint_after(std::time::Duration::from_millis(70)); + } } /// Rows of glyphs that reshuffle in place, used by both the overlay and the @@ -7202,7 +7924,7 @@ impl PdfEditorApp { let color = if integrity.ambiguous == 0 && integrity.landing_rate >= 0.95 { self.liquid_muted_color() } else { - Color32::from_rgb(154, 91, 35) + ui.visuals().warn_fg_color }; ui.label( RichText::new(format!( @@ -7214,7 +7936,7 @@ impl PdfEditorApp { } }); for warning in &document.warnings { - ui.label(RichText::new(warning).color(Color32::from_rgb(134, 92, 34))); + ui.label(RichText::new(warning).color(ui.visuals().warn_fg_color)); } } @@ -7225,7 +7947,11 @@ impl PdfEditorApp { block_index: usize, block: &LiquidBlock, ) -> Vec<(Rect, u16)> { - let feedback_id = liquid_feedback_id(&document.source_signature, block_index, block); + let feedback_id = self + .review_derived + .feedback_id(document, block_index) + .map(str::to_owned) + .unwrap_or_else(|| liquid_feedback_id(&document.source_signature, block_index, block)); if matches!( block.role, LiquidBlockRole::Table | LiquidBlockRole::Caption @@ -7240,9 +7966,23 @@ impl PdfEditorApp { ui.ctx() .graphics(|g| g.get(ui.layer_id()).map_or(0, |p| p.next_idx().0)) }); + let speaking = self.speech_follow_matches_current() + && self.tts_controller.speaking_block == Some(block_index); + let speech_underlay = speaking.then(|| ui.painter().add(egui::Shape::Noop)); self.review_reveal_block.set(Some(block_index)); let inner = egui::Frame::NONE.show(ui, |ui| self.draw_liquid_block(ui, block)); self.review_reveal_block.set(None); + if let Some(shape) = speech_underlay { + let accent = self.liquid_footnote_marker_color(); + ui.painter().set( + shape, + egui::Shape::rect_filled( + inner.response.rect.expand2(Vec2::new(5.0, 2.0)), + 3.0, + Color32::from_rgba_unmultiplied(accent.r(), accent.g(), accent.b(), 28), + ), + ); + } if self.search_state.review_anchor.is_none() && ui.clip_rect().intersects(inner.response.rect) { @@ -7260,11 +8000,37 @@ impl PdfEditorApp { .push((block_index, inner.response.rect)); } let response = inner.response; + if let Some((target, number)) = self.pending_reference_note + && target == block_index + && ui.clip_rect().intersects(response.rect) + { + if self.chrome.margin_notes_live { + self.chrome.expanded_note = Some(number.to_string()); + } else { + egui::Popup::open_id(ui.ctx(), liquid_footnote_popup_id(&feedback_id, number)); + } + self.pending_reference_note = None; + } // #27: footnote marker rects (in screen space) collected while drawing the body text. let marker_hits = inner.inner; // #30: right-click a block for a quick correction menu that feeds the label pipeline. let mut pending_correction: Option<(LiquidBlockRole, &'static str, &'static str)> = None; + let mut read_from_here = false; + let mut cite_this_passage = false; response.context_menu(|ui| { + if ui.button("Copy this passage with citation…").clicked() { + cite_this_passage = true; + ui.close(); + } + if ui.button("Read aloud from here").on_hover_text(if self.settings.speech_provider == crate::tts::SpeechProvider::System { + "Read from this passage using the installed voice. Starting mid-document includes linked footnotes only." + } else { + "Send text from this passage to the selected speech provider. Charges apply; starting mid-document includes linked footnotes only." + }).clicked() { + read_from_here = true; + ui.close(); + } + ui.separator(); ui.label( RichText::new("Teach the reader") .size(10.0) @@ -7283,16 +8049,51 @@ impl PdfEditorApp { ui.close(); } }); + if cite_this_passage { + self.start_review_quote_citation(citation_copy::review_display_text( + &block.text, + self.liquid_hide_footnotes, + )); + } + if read_from_here { + self.start_liquid_tts_from(document, block_index); + ui.ctx().request_repaint(); + } if let Some((role, gold_role, action)) = pending_correction { self.apply_reader_correction(document, block_index, block, role, gold_role, action); } // Exact block identity makes repeated headings navigate deterministically. if self.liquid_scroll_to_block == Some(block_index) { - if self.liquid_scroll_instant { - response - .scroll_to_me_animation(Some(Align::TOP), egui::style::ScrollAnimation::none()); + let following_speech = self.speech_follow_matches_current() + && self.tts_controller.follow_target == Some(block_index); + if following_speech { + if !ui.clip_rect().contains_rect(response.rect) { + let clip = ui.clip_rect(); + let distance = if response.rect.top() < clip.top() { + response.rect.top() - clip.top() + } else { + response.rect.bottom() - clip.bottom() + }; + response.scroll_to_me_animation( + None, + motion::jump_animation( + distance, + clip.height(), + self.liquid_scroll_instant || self.settings.reduce_motion, + ), + ); + } + self.tts_controller.follow_target = None; } else { - response.scroll_to_me(Some(Align::TOP)); + let clip = ui.clip_rect(); + response.scroll_to_me_animation( + Some(Align::TOP), + motion::jump_animation( + response.rect.top() - clip.top(), + clip.height(), + self.liquid_scroll_instant || self.settings.reduce_motion, + ), + ); } self.liquid_scroll_to_block = None; self.liquid_scroll_instant = false; @@ -7326,12 +8127,30 @@ impl PdfEditorApp { } else { toggle }; - if toggle.clicked() { + // Give each marker its own persistent click target. The selectable + // paragraph otherwise captures a press that arrives in the same frame + // as the pointer enters the marker. + let mut clicked_marker = None; + for (ordinal, (rect, number)) in marker_hits.iter().enumerate() { + let marker = ui + .interact( + *rect, + ui.id() + .with(("review-marker-action", feedback_id.as_str(), ordinal)), + Sense::click(), + ) + .on_hover_cursor(CursorIcon::PointingHand); + if marker.clicked() { + clicked_marker = Some(*number); + } + } + if toggle.clicked() || clicked_marker.is_some() { // #29: ⌘/Ctrl-click a reflowed block jumps to the fixed-layout page view and // highlights the source bboxes this block was assembled from. if command_active && !self.review_pdf_split { let rects = self.liquid_block_provenance_rects(document, block_index); if let Some((page, _)) = rects.first().copied() { + self.record_jump_origin(); self.liquid_provenance_highlight = rects; self.scroll_target_page = Some(page); self.set_view_mode(DocumentViewMode::Pdf, ui.ctx()); @@ -7339,11 +8158,12 @@ impl PdfEditorApp { } else if self.review_pdf_split || command_active { let rects = self.liquid_block_provenance_rects(document, block_index); if let Some((page, _)) = rects.first().copied() { + self.record_jump_origin(); self.liquid_provenance_highlight = rects; self.page_index = page; self.scroll_target_page = Some(page); } - } else if let Some(number) = hovered_marker { + } else if let Some(number) = clicked_marker.or(hovered_marker) { if self.chrome.margin_notes_live { // The note is already beside the line; the callout opens it in full. let key = number.to_string(); @@ -7486,11 +8306,41 @@ impl PdfEditorApp { if self.review_derived.is_for(document) { return; } + if self.tts_controller.follow_epoch == Some(self.document_epoch) + && self.tts_controller.follow_view == Some(self.view_mode) + && !self.speech_follow_matches_current() + { + self.clear_speech_follow(); + } self.liquid_footnote_index = build_liquid_footnote_index(&document.blocks); + self.review_references = review_references::ReferenceIndex::new(document); + self.pending_reference_note = None; self.review_derived = ReviewDerivedCache { key: Some(Arc::downgrade(document)), hidden_contents: Arc::new(review_hidden_display_mask(&document.blocks)), outline: Arc::new(liquid_outline_items(&document.blocks)), + feedback_ids: Arc::new( + document + .blocks + .iter() + .enumerate() + .map(|(index, block)| { + liquid_feedback_id(&document.source_signature, index, block) + }) + .collect(), + ), + note_indices: Arc::new( + document + .blocks + .iter() + .enumerate() + .filter_map(|(index, block)| { + is_review_note_display_block(block).then_some(index) + }) + .collect(), + ), + needs_ocr: liquid_document_needs_ocr(document), + low_confidence: liquid_reflow_low_confidence(document), }; } @@ -7582,7 +8432,7 @@ impl PdfEditorApp { ); return; }; - let tx = self.review_feedback_tx.clone(); + let tx = worker_waking_sender(self.review_feedback_tx.clone(), ctx.clone()); self.review_feedback_sending = true; std::thread::spawn(move || { let result = send_review_feedback(&submission); @@ -7819,7 +8669,7 @@ impl PdfEditorApp { self.liquid_notice_dismissed = false; } if ocr_active { - ui.spinner(); + motion::busy(ui, self.settings.reduce_motion); ui.label(RichText::new(self.ocr_summary()).color(self.liquid_muted_color())); } }); @@ -7864,70 +8714,52 @@ impl PdfEditorApp { return Vec::new(); } - // Split into (is_callout, run) segments at the sentinels, then flow them inline - // via horizontal_wrapped so each marker owns a rect while body text still wraps. - let mut segments: Vec<(bool, String)> = Vec::new(); - let mut in_callout = false; - let mut buf = String::new(); - for ch in text.chars() { - match ch { - crate::layout_roles::CALLOUT_START => { - if !buf.is_empty() { - segments.push((false, std::mem::take(&mut buf))); - } - in_callout = true; - } - crate::layout_roles::CALLOUT_END => { - if !buf.is_empty() { - segments.push((true, std::mem::take(&mut buf))); - } - in_callout = false; - } - _ => buf.push(ch), - } - } - if !buf.is_empty() { - segments.push((in_callout, buf)); - } - - let muted = self.liquid_muted_color(); + // A single selectable galley preserves normal word wrapping and kerning + // across callouts. Hit rectangles come from its actual laid-out cursors. let marker_color = self.liquid_footnote_marker_color(); - let mut marker_hits = Vec::new(); - ui.horizontal_wrapped(|ui| { - ui.spacing_mut().item_spacing.x = 0.0; - for (is_callout, run) in &segments { - if !is_callout { - self.add_review_label( - ui, - egui::Label::new(body_text(run)).wrap().selectable(true), - size, - ); - continue; - } - let number = run - .trim() - .parse::() - .ok() - .filter(|n| self.liquid_footnote_index.contains_key(n)); - let label = review_footnote_superscript(run); - if let Some(number) = number { - let response = ui - .add( - egui::Label::new( - RichText::new(label).size(size * 0.78).color(marker_color), - ) - .sense(Sense::click()), - ) - .on_hover_cursor(CursorIcon::PointingHand); - marker_hits.push((response.rect, number)); - } else { - ui.add(egui::Label::new( - RichText::new(label).size(size * 0.78).color(muted), - )); - } + let muted_color = self.liquid_muted_color(); + let document = match self.view_mode { + DocumentViewMode::Liquid => Some(&self.liquid_state), + DocumentViewMode::LiquidMode2 => Some(&self.liquid_mode2_state), + DocumentViewMode::Pdf => None, + }; + let cached = match (document, self.review_reveal_block.get()) { + (Some(LiquidState::Ready(document)), Some(block)) => { + self.review_paragraph_cache.borrow_mut().get_or_layout( + document, + block, + text, + size, + color, + marker_color, + muted_color, + strong, + &self.liquid_footnote_index, + ui, + ) } + _ => None, + }; + let (galley, markers) = cached.unwrap_or_else(|| { + let (job, markers) = review_paragraph::layout_job( + text, + size, + color, + marker_color, + muted_color, + strong, + &self.liquid_footnote_index, + ui.style(), + ui.available_width(), + ); + (ui.fonts_mut(|fonts| fonts.layout_job(job)), markers) }); - marker_hits + let response = self.add_review_label( + ui, + egui::Label::new(Arc::clone(&galley)).selectable(true), + size, + ); + review_paragraph::marker_rects(&galley, response.rect.min, &markers) } /// Theme-aware accent for tappable footnote markers in the Liquid view. @@ -7989,9 +8821,13 @@ impl PdfEditorApp { .color(label_color), ); ui.add_space(3.0); - ui.add( - egui::Label::new(RichText::new(body).size(13.0 * scale).color(body_color)) - .wrap(), + self.draw_note_reference_text( + ui, + number, + body, + 13.0 * scale, + body_color, + label_color, ); ui.separator(); self.draw_footnote_sources(ui, &number.to_string(), body); @@ -8170,7 +9006,7 @@ impl PdfEditorApp { RichText::new(&block.text) .size(17.0 * s) .italics() - .color(Color32::from_rgb(69, 63, 55)), + .color(self.liquid_ink_color()), ) .wrap(), ); @@ -8205,7 +9041,7 @@ impl PdfEditorApp { egui::Label::new( RichText::new(&block.text) .size(20.0 * s) - .color(Color32::from_rgb(42, 44, 45)), + .color(self.liquid_ink_color()), ) .wrap() .selectable(true), @@ -8283,7 +9119,7 @@ impl PdfEditorApp { RichText::new(label) .size(11.0 * s) .strong() - .color(Color32::from_rgb(100, 93, 83)), + .color(self.liquid_muted_color()), ); ui.add_space(1.0); ui.add( @@ -8314,7 +9150,7 @@ impl PdfEditorApp { RichText::new(label) .size(12.0 * self.liquid_text_scale) .strong() - .color(Color32::from_rgb(94, 86, 75)), + .color(self.liquid_muted_color()), ) .wrap(), ); @@ -8346,7 +9182,7 @@ impl PdfEditorApp { RichText::new(item.marker) .size(16.0) .strong() - .color(MUTED_INK), + .color(self.liquid_muted_color()), ); }, ); @@ -8374,6 +9210,10 @@ impl PdfEditorApp { stroke_color: Color32, ) { let body_text = callout_body_text(label, text); + let dark = self.liquid_theme == LiquidTheme::Dark; + let fill = if dark { Color32::from_rgb(38, 45, 57) } else { fill }; + let stroke_color = if dark { Color32::from_rgb(103, 117, 137) } else { stroke_color }; + let label_color = if dark { Color32::from_rgb(183, 205, 225) } else { label_color }; egui::Frame::NONE .fill(fill) .stroke(Stroke::new(1.0_f32, stroke_color)) @@ -8432,7 +9272,7 @@ impl PdfEditorApp { ui.add_space(7.0); } - fn draw_liquid_notes(&mut self, ui: &mut egui::Ui, notes: &[&LiquidBlock]) { + fn draw_liquid_notes(&mut self, ui: &mut egui::Ui, document: &LiquidDocument, notes: &[usize]) { if notes.is_empty() { return; } @@ -8446,7 +9286,8 @@ impl PdfEditorApp { .default_open(false) .show(ui, |ui| { ui.add_space(4.0); - for (index, note) in notes.iter().enumerate() { + for (index, &block_index) in notes.iter().enumerate() { + let note = &document.blocks[block_index]; let parts = split_fused_review_notes(¬e.text); let entries = if parts.is_empty() { vec![((index + 1).to_string(), note.text.trim().to_owned())] @@ -8468,15 +9309,26 @@ impl PdfEditorApp { }, ); ui.add_space(8.0); - ui.add( - egui::Label::new( - RichText::new(&body) - .size(14.0) - .color(self.liquid_muted_color()), - ) - .wrap() - .selectable(true), - ); + if let Ok(number) = marker.parse::() { + self.draw_note_reference_text( + ui, + number, + &body, + 14.0, + self.liquid_muted_color(), + self.liquid_footnote_marker_color(), + ); + } else { + ui.add( + egui::Label::new( + RichText::new(&body) + .size(14.0) + .color(self.liquid_muted_color()), + ) + .wrap() + .selectable(true), + ); + } }); self.draw_footnote_sources(ui, &format!("list-{index}-{marker}"), &body); ui.add_space(7.0); @@ -8592,11 +9444,13 @@ impl PdfEditorApp { LiquidTheme::Sepia => LiquidTheme::Dark, LiquidTheme::Dark => LiquidTheme::Paper, }; + self.liquid_theme_follows_appearance = false; } if ui.button(RichText::new("Reset").size(11.0)).clicked() { self.liquid_text_scale = 1.0; self.liquid_max_width = REVIEW_DEFAULT_MAX_WIDTH; - self.liquid_theme = LiquidTheme::Paper; + self.liquid_theme_follows_appearance = true; + self.liquid_theme = if ui.ctx().theme() == egui::Theme::Dark { LiquidTheme::Dark } else { LiquidTheme::Paper }; } let details = ui.menu_button( RichText::new(if document.warnings.is_empty() { @@ -8841,14 +9695,31 @@ impl PdfEditorApp { self.text_box_action_rect = None; self.comment_action_rect = None; egui::CentralPanel::default() - .frame(egui::Frame::NONE.fill(CANVAS_FILL)) + .frame(egui::Frame::NONE.fill(self.app_palette().canvas)) .show(ctx, |ui| { self.chrome.document_rect = Some(ui.max_rect()); if self.document.is_none() { - self.draw_empty_state(ui, ctx); + if self.opening_preview.is_some() { + self.draw_opening_preview(ui); + } else { + self.draw_empty_state(ui, ctx); + } return; } + if self.fit_width_on_first_draw && self.sbs_mode.is_none() { + self.fit_width_on_first_draw = false; + if let Some(page) = self + .document + .as_ref() + .and_then(|document| document.pages.first()) + { + let zoom = + fit_page_zoom(ui.available_size(), page.width, page.height, false); + self.set_pointer_zoom(zoom); + } + } + if self.sbs_mode.is_some() { self.draw_sbs_document(ui, ctx); return; @@ -8873,7 +9744,9 @@ impl PdfEditorApp { { self.search_state.split_original = original; if self.search_state.open { - ctx.memory_mut(|m| m.request_focus(egui::Id::new("lawpdf-find"))); + ctx.memory_mut(|m| { + m.request_focus(egui::Id::new("lawpdf-find")) + }); } } } @@ -8927,7 +9800,20 @@ impl PdfEditorApp { document_scroll_style.dormant_background_opacity = 0.18; document_scroll_style.dormant_handle_opacity = 0.62; - let requested_scroll_offset = self.pending_document_scroll_offset.take(); + let resume_offset = + self.pending_resume_fraction + .take() + .and_then(|(page, fraction)| { + document_layout.pages.get(page).map(|layout| { + self.scroll_target_page = None; + Vec2::new( + 0.0, + layout.top + layout.display_size.y * fraction.clamp(0.0, 1.0), + ) + }) + }); + let requested_scroll_offset = + resume_offset.or_else(|| self.pending_document_scroll_offset.take()); let mut document_scroll_area = egui::ScrollArea::both() .id_salt("document_pages") .auto_shrink([false, false]) @@ -9100,6 +9986,16 @@ impl PdfEditorApp { } } + let target = requested_scroll_target.or_else(|| self.landing_page()); + let first = target.map_or(visible_start, |page| visible_start.min(page)); + let last_visible = visible_end.saturating_sub(1); + let last = target.map_or(last_visible, |page| last_visible.max(page)); + self.render_tx.set_viewport( + self.document_epoch, + first.saturating_sub(PAGE_PREFETCH_RADIUS), + last.saturating_add(PAGE_PREFETCH_RADIUS), + target.unwrap_or(closest_visible_page), + ); self.pdf_page_placements.clear(); for page_index in visible_pages { let page_layout = page_layouts[page_index]; @@ -9126,17 +10022,56 @@ impl PdfEditorApp { &path, page_index, ); - let response = if let Some(url) = - self.hovered_web_link_url(&response, &placement, page_index) - { - response - .on_hover_cursor(CursorIcon::PointingHand) - .on_hover_text(url) + let hovered_link = self + .hovered_web_link(&response, &placement, page_index) + .cloned(); + let response = if let Some(link) = hovered_link { + let peek = self.original_footnote_peek(page_index, &link); + let response = response.on_hover_cursor(CursorIcon::PointingHand); + if let Some((number, body)) = peek { + response.on_hover_ui(|ui| { + ui.set_max_width(420.0); + ui.label( + RichText::new(format!("Footnote {number}")).strong(), + ); + ui.label(body); + }) + } else { + let label = link.destination_page.map_or_else( + || link.url.clone(), + |page| format!("Go to page {}", page + 1), + ); + response.on_hover_text(label) + } } else { - response.on_hover_cursor(tool_cursor(self.active_tool)) + let peek = if self.active_tool == Tool::Select { + response.hover_pos() + .and_then(|pos| placement.screen_to_pdf(pos)) + .and_then(|point| self.original_unlinked_footnote_peek(page_index, point)) + } else { + None + }; + let response = response.on_hover_cursor(tool_cursor(self.active_tool)); + if let Some((number, body)) = peek { + response.on_hover_ui(|ui| { + ui.set_max_width(420.0); + ui.label(RichText::new(format!("Footnote {number}")).strong()); + ui.label(body); + }) + } else { + response + } }; if requested_scroll_target == Some(page_index) { - response.scroll_to_me(Some(Align::Center)); + let clip = ui.clip_rect(); + response.scroll_to_me_animation( + Some(Align::Center), + motion::jump_animation( + response.rect.center().y - clip.center().y, + clip.height(), + self.settings.reduce_motion, + ), + ); } let painter = ui.painter(); @@ -9147,7 +10082,7 @@ impl PdfEditorApp { color: Color32::from_black_alpha(48), }; painter.add(page_shadow.as_shape(rect, 3)); - painter.rect_filled(rect, 3, PAPER_FILL); + painter.rect_filled(rect, 3, self.original_page_fill()); painter.rect_stroke( rect, 3, @@ -9158,11 +10093,22 @@ impl PdfEditorApp { if let Some(page_texture) = self.ensure_page_texture(ctx, &path, page_index) { - painter.image( - page_texture.texture_id, + if rect.intersects(ui.clip_rect()) + && let Some(started) = self.first_page_open_started.take() + { + crate::diagnostics::timing( + "document_first_page", + "open_to_visible_page", + started.elapsed(), + ); + } + self.paint_page_texture( + ctx, + painter, rect, - Rect::from_min_max(Pos2::ZERO, Pos2::new(1.0, 1.0)), - Color32::WHITE, + page_index, + page_texture, + true, ); } else { self.draw_page_loading_placeholder(ctx, painter, rect, page_index); @@ -9185,11 +10131,15 @@ impl PdfEditorApp { self.document_epoch, page_index, )); - let expanded_t = ctx.animate_bool_with_time( - chip_animation_id, - !chip_collapsed, - 0.18, - ); + let expanded_t = if self.settings.reduce_motion { + if chip_collapsed { 0.0 } else { 1.0 } + } else { + ctx.animate_bool_with_time( + chip_animation_id, + !chip_collapsed, + motion::SURFACE_SECONDS, + ) + }; let chip_width = egui::lerp( PAGE_MARKER_COLLAPSED_WIDTH..=PAGE_MARKER_EXPANDED_WIDTH, expanded_t, @@ -9265,19 +10215,32 @@ impl PdfEditorApp { self.draw_selection_action_palette(ctx, &placement, page_index); } + self.update_cross_page_selection(ui, ctx, &path); self.visible_page_ranges = visible_page_ranges; self.prune_marker_animations(); let previous_page = self.page_index; - self.page_index = closest_visible_page; + if self.scroll_target_page == requested_scroll_target { + self.page_index = closest_visible_page; + } if self.page_index != previous_page { self.thumbnail_scroll_target = Some(self.page_index); self.prune_page_texture_cache(); } self.prefetch_neighbor_pages(ctx, &path); - if requested_scroll_target.is_some() { + if requested_scroll_target.is_some() + && self.scroll_target_page == requested_scroll_target + { self.scroll_target_page = None; } + if let Some(command) = self.pending_reading_scroll.take() { + reading_navigation::apply( + ui, + viewport, + command, + self.settings.reduce_motion, + ); + } }); self.find_rail( @@ -9443,6 +10406,28 @@ impl PdfEditorApp { { self.focus_sbs_document(document_epoch, ctx); } + let clicked_link = left_output + .clicked_link + .map(|link| (mode.left_epoch, link)) + .or_else(|| { + right_output + .clicked_link + .map(|link| (mode.right_epoch, link)) + }); + if let Some((epoch, (page, index))) = clicked_link { + if self.document_epoch != epoch { + self.focus_sbs_document(epoch, ctx); + } + let link = self + .document + .as_ref() + .and_then(|document| document.links.get(page)) + .and_then(|links| links.get(index)) + .cloned(); + if let Some(link) = link { + self.activate_page_link(ctx, link); + } + } } fn apply_sbs_pane_output(&mut self, document_epoch: u64, output: SbsPaneOutput) { @@ -9479,13 +10464,14 @@ impl PdfEditorApp { let mut replacement_epoch = None; let mut open_solo = false; let mut page_clicked = false; + let mut clicked_link = None; let pane_stroke = if focused { Stroke::new(2.0_f32, Color32::from_rgb(167, 113, 50)) } else { - Stroke::new(1.0_f32, Color32::from_rgb(202, 197, 187)) + Stroke::new(1.0_f32, self.app_palette().rule) }; let pane = egui::Frame::NONE - .fill(Color32::from_rgb(237, 235, 229)) + .fill(if ctx.theme() == egui::Theme::Dark { self.app_palette().panel } else { Color32::from_rgb(237, 235, 229) }) .stroke(pane_stroke) .corner_radius(8) .inner_margin(Margin::same(5)) @@ -9526,7 +10512,7 @@ impl PdfEditorApp { .min(document.page_count()), document.page_count() )) - .color(MUTED_INK), + .color(self.app_palette().muted), ) .on_hover_text("Current page in this pane"); if focused { @@ -9534,7 +10520,7 @@ impl PdfEditorApp { RichText::new("ACTIVE") .small() .strong() - .color(Color32::from_rgb(138, 88, 32)), + .color(self.app_palette().accent), ) .on_hover_text("Toolbar controls apply to this pane"); } @@ -9547,7 +10533,7 @@ impl PdfEditorApp { } }); ui.separator(); - page_clicked = self.draw_sbs_pages(ui, ctx, document, &mut state); + (page_clicked, clicked_link) = self.draw_sbs_pages(ui, ctx, document, &mut state); }); let pane_pressed = @@ -9557,6 +10543,7 @@ impl PdfEditorApp { focus_requested: page_clicked || pane_pressed || replacement_epoch.is_some(), replacement_epoch, open_solo, + clicked_link, } } @@ -9566,7 +10553,7 @@ impl PdfEditorApp { ctx: &Context, document: &SbsPaneDocument, state: &mut SbsPaneState, - ) -> bool { + ) -> (bool, Option<(usize, usize)>) { let scroll_style = &mut ui.style_mut().spacing.scroll; scroll_style.floating = false; scroll_style.bar_width = 9.0; @@ -9577,6 +10564,7 @@ impl PdfEditorApp { .min(document.page_count().saturating_sub(1)); let mut closest_distance = f32::MAX; let mut page_clicked = false; + let mut clicked_link = None; let mut find_visible = vec![]; let output = egui::ScrollArea::both() @@ -9617,6 +10605,33 @@ impl PdfEditorApp { Sense::hover(), ); + let visible_start = + layouts.partition_point(|(top, size, _, _)| *top + size.y < viewport.top()); + let visible_end = + layouts.partition_point(|(top, _, _, _)| *top <= viewport.bottom()); + let focus = requested_page.unwrap_or_else(|| { + (visible_start..visible_end) + .min_by(|&a, &b| { + let distance = |index: usize| { + (layouts[index].0 + layouts[index].1.y * 0.5 - viewport.center().y) + .abs() + }; + distance(a).total_cmp(&distance(b)) + }) + .unwrap_or(state.page_index) + }); + self.render_tx.set_viewport( + document.document_epoch, + visible_start + .min(focus) + .saturating_sub(PAGE_PREFETCH_RADIUS), + visible_end + .saturating_sub(1) + .max(focus) + .saturating_add(PAGE_PREFETCH_RADIUS), + focus, + ); + for (page_index, (top, display_size, page_width, page_height)) in layouts.iter().copied().enumerate() { @@ -9627,7 +10642,25 @@ impl PdfEditorApp { display_size, ); if requested_page == Some(page_index) { - ui.scroll_to_rect(rect, Some(Align::Center)); + let animation = if self.settings.reduce_motion { + egui::style::ScrollAnimation::none() + } else { + ui.style().scroll_animation + }; + if let Some((_, fraction)) = state + .destination_fraction + .filter(|(page, _)| *page == page_index) + { + let position = + Pos2::new(rect.left(), rect.top() + rect.height() * fraction); + ui.scroll_to_rect_animation( + Rect::from_min_size(position, Vec2::splat(1.0)), + Some(Align::TOP), + animation, + ); + } else { + ui.scroll_to_rect_animation(rect, Some(Align::Center), animation); + } } if rect.intersects(ui.clip_rect()) && document.document_epoch == self.document_epoch @@ -9667,6 +10700,42 @@ impl PdfEditorApp { &document.path, page_index, ); + let placement = PagePlacement { + rect, + page_width, + page_height, + }; + let link_at_pointer = response + .hover_pos() + .and_then(|pos| placement.screen_to_pdf(pos)) + .and_then(|point| { + self.page_link_at_pdf_for_epoch( + document.document_epoch, + page_index, + point, + ) + }) + .map(|(index, link)| { + ( + index, + link.destination_page.map_or_else( + || link.url.clone(), + |page| format!("Go to page {}", page + 1), + ), + ) + }); + if response.clicked() && self.active_tool == Tool::Select { + clicked_link = link_at_pointer + .as_ref() + .map(|(index, _)| (page_index, *index)); + } + let response = if let Some((_, tooltip)) = link_at_pointer { + response + .on_hover_cursor(CursorIcon::PointingHand) + .on_hover_text(tooltip) + } else { + response + }; page_clicked |= response.clicked(); let painter = ui.painter(); let shadow = Shadow { @@ -9676,14 +10745,14 @@ impl PdfEditorApp { color: Color32::from_black_alpha(42), }; painter.add(shadow.as_shape(rect, 2)); - painter.rect_filled(rect, 2, PAPER_FILL); + painter.rect_filled(rect, 2, self.original_page_fill()); painter.rect_stroke( rect, 2, Stroke::new(1.0_f32, PAPER_STROKE), egui::StrokeKind::Inside, ); - if let Some(texture_id) = self.ensure_sbs_page_texture( + if let Some(texture) = self.ensure_sbs_page_texture( ctx, document, page_index, @@ -9691,11 +10760,13 @@ impl PdfEditorApp { page_width, page_height, ) { - painter.image( - texture_id, + self.paint_page_texture( + ctx, + painter, rect, - Rect::from_min_max(Pos2::ZERO, Pos2::new(1.0, 1.0)), - Color32::WHITE, + page_index, + texture, + document.document_epoch == self.document_epoch, ); } else { self.draw_page_loading_placeholder(ctx, painter, rect, page_index); @@ -9713,6 +10784,11 @@ impl PdfEditorApp { ); } } + if document.document_epoch == self.document_epoch + && let Some(command) = self.pending_reading_scroll.take() + { + reading_navigation::apply(ui, viewport, command, self.settings.reduce_motion); + } }); if document.document_epoch == self.document_epoch { @@ -9722,8 +10798,9 @@ impl PdfEditorApp { state.page_index = closest_page; if requested_page.is_some() { state.scroll_target_page = None; + state.destination_fraction = None; } - page_clicked + (page_clicked, clicked_link) } fn ensure_sbs_page_texture( @@ -9734,7 +10811,7 @@ impl PdfEditorApp { display_zoom: f32, page_width: f32, page_height: f32, - ) -> Option { + ) -> Option { let render_scale = page_render_scale_for_zoom(ctx, display_zoom, page_width, page_height); let cache_key = (document.document_epoch, page_index); let is_current = self @@ -9748,7 +10825,10 @@ impl PdfEditorApp { let last_used = self.next_texture_access(); self.sbs_page_textures.get_mut(&cache_key).map(|texture| { texture.last_used = last_used; - texture.texture.id() + PageTextureView { + texture_id: texture.texture.id(), + fade_started: texture.fade_started, + } }) } @@ -9792,7 +10872,7 @@ impl PdfEditorApp { &mut self, ctx: &Context, document_epoch: u64, - rendered: RenderedPage, + rendered: PreparedPage, zoom: f32, render_scale: f32, ) { @@ -9800,10 +10880,6 @@ impl PdfEditorApp { if rendered.width > max_texture_side || rendered.height > max_texture_side { return; } - let image = egui::ColorImage::from_rgba_unmultiplied( - [rendered.width, rendered.height], - &rendered.rgba, - ); let texture = ctx.load_texture( format!( "sbs-page-e{}-{}-z{:.3}-r{:.3}", @@ -9812,17 +10888,23 @@ impl PdfEditorApp { zoom, render_scale ), - image, + rendered.image, TextureOptions::LINEAR, ); + let key = (document_epoch, rendered.page_index); + let fade_started = (document_epoch == self.document_epoch + && !self.sbs_page_textures.contains_key(&key) + && self.thumbnail_textures.contains_key(&rendered.page_index)) + .then(Instant::now); let last_used = self.next_texture_access(); self.sbs_page_textures.insert( - (document_epoch, rendered.page_index), + key, PageTexture { _zoom: zoom, render_scale, last_used, texture, + fade_started, }, ); while self.sbs_page_textures.len() > SBS_TEXTURE_CACHE_CAP { @@ -9836,30 +10918,75 @@ impl PdfEditorApp { }; self.sbs_page_textures.remove(&victim); } + self.prune_page_texture_cache(); + } + + /// Tint only the on-screen image, leaving source and export pixels intact. + fn original_page_tint(&self) -> Color32 { + if self.settings.dim_original_pages { + Color32::from_rgb(190, 180, 162) + } else { + Color32::WHITE + } + } + + fn original_page_fill(&self) -> Color32 { + if self.settings.dim_original_pages { + Color32::from_rgb(190, 179, 158) + } else { + PAPER_FILL + } + } + + fn paint_page_texture( + &self, + ctx: &Context, + painter: &egui::Painter, + rect: Rect, + page_index: usize, + page: PageTextureView, + use_thumbnail: bool, + ) { + let uv = Rect::from_min_max(Pos2::ZERO, Pos2::new(1.0, 1.0)); + let thumbnail = use_thumbnail + .then(|| self.thumbnail_textures.get(&page_index)) + .flatten(); + let opacity = match (page.fade_started, thumbnail) { + (Some(started), Some(thumbnail)) => { + let opacity = motion::page_opacity(ctx, started, self.settings.reduce_motion); + if opacity < 1.0 { + painter.image(thumbnail.texture.id(), rect, uv, self.original_page_tint()); + } + opacity + } + _ => 1.0, + }; + painter.image( + page.texture_id, + rect, + uv, + self.original_page_tint().gamma_multiply(opacity), + ); } fn draw_page_loading_placeholder( &self, - ctx: &Context, + _ctx: &Context, painter: &egui::Painter, rect: Rect, page_index: usize, ) { - let time = ctx.input(|input| input.time) as f32; - let travel = rect.width() + 180.0; - let phase = (time * 150.0 + page_index as f32 * 37.0).rem_euclid(travel); - let stripe_center = rect.left() - 90.0 + phase; - let stripe = Rect::from_center_size( - Pos2::new(stripe_center, rect.center().y), - Vec2::new(72.0, rect.height() * 1.25), - ) - .intersect(rect.shrink(6.0)); - - painter.rect_filled( - stripe, - 2, - Color32::from_rgba_unmultiplied(255, 255, 255, 46), - ); + if self.sbs_mode.is_none() + && let Some(thumbnail) = self.thumbnail_textures.get(&page_index) + { + painter.image( + thumbnail.texture.id(), + rect, + Rect::from_min_max(Pos2::ZERO, Pos2::new(1.0, 1.0)), + self.original_page_tint(), + ); + return; + } painter.text( rect.center(), Align2::CENTER_CENTER, @@ -9867,11 +10994,11 @@ impl PdfEditorApp { FontId::proportional(18.0), MUTED_INK, ); - ctx.request_repaint_after(RENDER_POLL_INTERVAL); } fn prefetch_neighbor_pages(&mut self, ctx: &Context, path: &Path) { - if self.zoom_render_is_debounced() { + if self.zoom_render_is_debounced() || self.texture_bytes() >= TEXTURE_BUDGET_BYTES * 9 / 10 + { return; } @@ -9909,30 +11036,118 @@ impl PdfEditorApp { } } - fn prune_page_texture_cache(&mut self) { - let page_count = self - .document - .as_ref() - .map(|document| document.page_count) - .unwrap_or_default(); - if page_count <= PAGE_TEXTURE_CACHE_CAP - || self.page_textures.len() <= PAGE_TEXTURE_CACHE_CAP - { - return; + fn texture_bytes(&self) -> usize { + let mut seen = HashSet::new(); + let mut total = 0usize; + let mut count = |texture: &TextureHandle| { + if seen.insert(texture.id()) { + total = total.saturating_add(texture.byte_size()); + } + }; + for texture in self.page_textures.values() { + count(&texture.texture); + } + for texture in self.sbs_page_textures.values() { + count(&texture.texture); + } + for texture in self.thumbnail_textures.values() { + count(&texture.texture); + } + for tab in &self.tabs { + for texture in tab.page_textures.values() { + count(&texture.texture); + } + for texture in tab.thumbnail_textures.values() { + count(&texture.texture); + } } + total + } + + fn prune_page_texture_cache(&mut self) { + self.prune_texture_cache_to(TEXTURE_BUDGET_BYTES); + } - let current_page = self.page_index; - while self.page_textures.len() > PAGE_TEXTURE_CACHE_CAP { - let Some(victim_page) = self + fn prune_texture_cache_to(&mut self, budget: usize) { + // The live app owns the active tab's textures. A saved snapshot of + // that same tab would otherwise keep evicted texture handles alive. + if let Some(tab) = self.active_tab.and_then(|index| self.tabs.get_mut(index)) { + tab.page_textures.clear(); + tab.thumbnail_textures.clear(); + } + loop { + let oldest_page = self .page_textures .iter() - .filter(|(page_index, _)| **page_index != current_page) + .filter(|(page, _)| **page != self.page_index) .min_by_key(|(_, texture)| texture.last_used) - .map(|(page_index, _)| *page_index) - else { + .map(|(page, texture)| (*page, texture.last_used)); + if self.page_textures.len() > PAGE_TEXTURE_CACHE_CAP { + if let Some((page, _)) = oldest_page { + self.page_textures.remove(&page); + continue; + } + } + if self.texture_bytes() <= budget { break; - }; - self.page_textures.remove(&victim_page); + } + + let oldest_sbs = self + .sbs_page_textures + .iter() + .filter(|((epoch, page), _)| { + !self.sbs_mode.is_some_and(|mode| mode.contains(*epoch)) + || self + .sbs_pane_states + .get(epoch) + .is_none_or(|state| state.page_index != *page) + }) + .min_by_key(|(_, texture)| texture.last_used) + .map(|(key, texture)| (*key, texture.last_used)); + let oldest_tab = self + .tabs + .iter() + .enumerate() + .filter(|(index, _)| Some(*index) != self.active_tab) + .flat_map(|(index, tab)| { + tab.page_textures + .iter() + .map(move |(page, texture)| (index, *page, texture.last_used)) + }) + .min_by_key(|(_, _, last_used)| *last_used); + let oldest = oldest_page + .map(|(_, used)| used) + .into_iter() + .chain(oldest_sbs.map(|(_, used)| used)) + .chain(oldest_tab.map(|(_, _, used)| used)) + .min(); + if let Some(used) = oldest { + if let Some((page, _)) = oldest_page.filter(|(_, candidate)| *candidate == used) { + self.page_textures.remove(&page); + } else if let Some((key, _)) = + oldest_sbs.filter(|(_, candidate)| *candidate == used) + { + self.sbs_page_textures.remove(&key); + } else if let Some((index, page, _)) = oldest_tab { + self.tabs[index].page_textures.remove(&page); + } + continue; + } + if let Some(page) = self.thumbnail_textures.keys().next().copied() { + self.thumbnail_textures.remove(&page); + continue; + } + if let Some(tab) = self + .tabs + .iter_mut() + .find(|tab| !tab.thumbnail_textures.is_empty()) + { + if let Some(page) = tab.thumbnail_textures.keys().next().copied() { + tab.thumbnail_textures.remove(&page); + continue; + } + } + break; // Keep the page currently being read even if it alone is oversized. } } @@ -9950,41 +11165,13 @@ impl PdfEditorApp { ) { let seed = marker_seed(pdf_rect); - // No live animation for this exact segment => settled. Breathe gently - // unless the user has opted into reduced motion. + // A settled highlight is static and must not keep the window repainting. let Some(anim) = self .marker_animations .iter() .find(|anim| anim.page_index == page_index && anim.rect == pdf_rect) else { - if self.settings.reduce_motion { - paint_marker_stroke(painter, rect, seed, color_rgb, target_alpha); - return; - } - let time = painter.ctx().input(|input| input.time) as f32; - let motion = settled_marker_motion(time, seed, target_alpha); - painter.ctx().request_repaint_after(MARKER_BREATH_REPAINT); - paint_marker_stroke(painter, rect, seed, color_rgb, motion.alpha); - - // A narrow glint passes over the existing textured stroke. It fades - // in and out at the ends of its long cycle, so it never flashes. - if motion.sheen_alpha > 0 && rect.width() > 8.0 { - let band_width = (rect.width() * 0.14).clamp(10.0, 38.0); - let travel = rect.width() + band_width * 2.0; - let center = rect.left() - band_width + travel * motion.sheen_progress; - let band = Rect::from_min_max( - Pos2::new(center - band_width * 0.5, rect.top()), - Pos2::new(center + band_width * 0.5, rect.bottom()), - ); - let sheen = painter.with_clip_rect(band.intersect(painter.clip_rect())); - paint_marker_stroke( - &sheen, - rect, - seed, - marker_sheen_rgb(color_rgb), - motion.sheen_alpha, - ); - } + paint_marker_stroke(painter, rect, seed, color_rgb, target_alpha); return; }; @@ -10469,7 +11656,7 @@ impl PdfEditorApp { .fixed_pos(position) .show(ctx, |ui| { egui::Frame::NONE - .fill(Color32::from_rgba_unmultiplied(255, 253, 247, 248)) + .fill(self.app_palette().bar) .stroke(Stroke::new(1.3_f32, Color32::from_rgb(184, 141, 68))) .corner_radius(7) .inner_margin(Margin::symmetric(10, 9)) @@ -10481,7 +11668,7 @@ impl PdfEditorApp { let grip = ui .add( egui::Label::new( - RichText::new("\u{2059}").strong().color(MUTED_INK), + RichText::new("\u{2059}").strong().color(self.app_palette().muted), ) .sense(Sense::drag()), ) @@ -10525,7 +11712,7 @@ impl PdfEditorApp { self.open_comment_profile(); } } else { - ui.label(RichText::new(&comment_author).strong().color(INK)); + ui.label(RichText::new(&comment_author).strong().color(self.app_palette().ink)); } ui.with_layout(egui::Layout::right_to_left(Align::Center), |ui| { if ui.button("Delete").clicked() { @@ -10637,7 +11824,7 @@ impl PdfEditorApp { .fixed_pos(screen_rect.left_top()) .show(ctx, |ui| { egui::Frame::NONE - .fill(Color32::from_rgba_unmultiplied(255, 254, 250, 245)) + .fill(self.app_palette().bar) .stroke(Stroke::new(1.4_f32, Color32::from_rgb(72, 106, 180))) .corner_radius(2) .inner_margin(Margin::same(5)) @@ -10719,8 +11906,8 @@ impl PdfEditorApp { .fixed_pos(position) .show(ctx, |ui| { egui::Frame::NONE - .fill(Color32::from_rgba_unmultiplied(250, 249, 245, 240)) - .stroke(Stroke::new(1.0_f32, Color32::from_rgb(205, 198, 184))) + .fill(self.app_palette().bar) + .stroke(Stroke::new(1.0_f32, self.app_palette().rule)) .corner_radius(6) .inner_margin(Margin::symmetric(6, 4)) .show(ui, |ui| { @@ -10781,29 +11968,18 @@ impl PdfEditorApp { ); } - fn hovered_web_link_url( - &self, - response: &egui::Response, - placement: &PagePlacement, - page_index: usize, - ) -> Option { - self.hovered_web_link(response, placement, page_index) - .map(|link| link.url.clone()) - } - - fn clicked_web_link_url( + fn clicked_page_link( &self, response: &egui::Response, placement: &PagePlacement, page_index: usize, - ) -> Option { + ) -> Option { if !response.clicked() || self.active_tool != Tool::Select { return None; } let pos = response.interact_pointer_pos().or(response.hover_pos())?; - self.web_link_at_screen(page_index, pos, placement) - .map(|link| link.url.clone()) + self.web_link_at_screen(page_index, pos, placement).cloned() } fn hovered_web_link( @@ -10830,13 +12006,32 @@ impl PdfEditorApp { } fn web_link_at_pdf(&self, page_index: usize, point: (f32, f32)) -> Option<&PageLink> { - let document = self.document.as_ref()?; + self.page_link_at_pdf_for_epoch(self.document_epoch, page_index, point) + .map(|(_, link)| link) + } + + fn page_link_at_pdf_for_epoch( + &self, + epoch: u64, + page_index: usize, + point: (f32, f32), + ) -> Option<(usize, &PageLink)> { + let document = if epoch == self.document_epoch { + self.document.as_ref()? + } else { + &self + .tabs + .iter() + .find(|tab| tab.document_epoch == epoch)? + .document + }; document .links .get(page_index)? .iter() - .filter(|link| point_in_pdf_rect(point, expand_pdf_rect(link.rect, 2.0))) - .min_by(|left, right| { + .enumerate() + .filter(|(_, link)| point_in_pdf_rect(point, expand_pdf_rect(link.rect, 2.0))) + .min_by(|(_, left), (_, right)| { let left_area = left.rect.width() * left.rect.height(); let right_area = right.rect.width() * right.rect.height(); left_area @@ -10845,6 +12040,18 @@ impl PdfEditorApp { }) } + fn activate_page_link(&mut self, ctx: &Context, link: PageLink) { + self.clear_text_selection(); + if let Some(page) = link.destination_page { + self.jump_to_pdf_destination(page, link.destination_fraction); + self.status = format!("Page {}", page + 1); + ctx.request_repaint(); + } else { + ctx.open_url(egui::OpenUrl::new_tab(link.url.clone())); + self.status = format!("Opened {}", link.url); + } + } + fn handle_page_interaction( &mut self, ctx: &Context, @@ -10858,10 +12065,8 @@ impl PdfEditorApp { } if self.active_tool == Tool::Select { - if let Some(url) = self.clicked_web_link_url(response, placement, page_index) { - ctx.open_url(egui::OpenUrl::new_tab(url.clone())); - self.clear_text_selection(); - self.status = format!("Opened {url}"); + if let Some(link) = self.clicked_page_link(response, placement, page_index) { + self.activate_page_link(ctx, link); return; } } @@ -10974,6 +12179,69 @@ impl PdfEditorApp { placement: &PagePlacement, page_index: usize, ) { + if response.triple_clicked() { + if let Some(pos) = response.interact_pointer_pos() + && let Some(pdf) = placement.screen_to_pdf(pos) + && let Some(index) = self.text_char_index_at(page_index, pdf) + { + let mapped = self.mapped_paragraph_selection(page_index, index); + let selection = mapped.or_else(|| { + self.ensure_text_chars(page_index) + .and_then(|chars| selection_state::paragraph_bounds(chars, index)) + .map(|(start, end)| TextSelection::new(page_index, start, end)) + }); + if let Some(selection) = selection { + self.selection_state.pending_mark = false; + self.selection_state.anchor = None; + self.selection_state.extend_anchor = + Some((selection.start_page, selection.start)); + self.selection_state.text = Some(selection); + self.status = if mapped.is_some() { + "Selected paragraph." + } else { + "Selected an estimated paragraph on this page." + } + .to_owned(); + } + } + return; + } + if response.double_clicked() + && let Some(pos) = response.interact_pointer_pos() + && let Some(pdf) = placement.screen_to_pdf(pos) + && let Some(index) = self.text_char_index_at(page_index, pdf) + && let Some((start, end)) = self + .ensure_text_chars(page_index) + .and_then(|chars| selection_state::word_bounds(chars, index)) + { + self.selection_state.pending_mark = false; + self.selection_state.anchor = None; + self.selection_state.extend_anchor = Some((page_index, start)); + self.selection_state.text = Some(TextSelection::new(page_index, start, end)); + return; + } + if response.clicked() + && response.ctx.input(|input| input.modifiers.shift) + && let Some(selection) = self.selection_state.text + && let Some(pos) = response.interact_pointer_pos() + && let Some(pdf) = placement.screen_to_pdf(pos) + && let Some(index) = self.text_char_index_at(page_index, pdf) + { + self.selection_state.pending_mark = false; + let (anchor_page, anchor_index) = self + .selection_state + .extend_anchor + .unwrap_or((selection.start_page, selection.start)); + self.selection_state.extend_anchor = Some((anchor_page, anchor_index)); + let extended = TextSelection::range(anchor_page, anchor_index, page_index, index); + self.selection_state.text = Some(extended); + if let Some(path) = self.document.as_ref().map(|document| document.path.clone()) { + for page in extended.page_range() { + self.request_text_chars(&response.ctx, &path, page); + } + } + return; + } if response.clicked() && !self.click_hits_selection_or_toolbar(response, placement, page_index) { @@ -10982,10 +12250,16 @@ impl PdfEditorApp { } if response.drag_started() { - if let Some(pos) = response.interact_pointer_pos() { + if let Some(pos) = response + .ctx + .input(|input| input.pointer.press_origin()) + .or_else(|| response.interact_pointer_pos()) + { if let Some(pdf) = placement.screen_to_pdf(pos) { if let Some(char_index) = self.text_char_index_at(page_index, pdf) { + self.selection_state.pending_mark = false; self.selection_state.anchor = Some((page_index, char_index)); + self.selection_state.extend_anchor = Some((page_index, char_index)); self.selection_state.text = Some(TextSelection::new(page_index, char_index, char_index)); } else { @@ -10995,30 +12269,83 @@ impl PdfEditorApp { } } } + } - if response.dragged() { - let Some((anchor_page, anchor_index)) = self.selection_state.anchor else { - return; - }; - if anchor_page != page_index { - return; - } - - if let Some(pos) = response.interact_pointer_pos() { - if let Some(pdf) = placement.screen_to_pdf(pos) { - if let Some(char_index) = self.text_char_index_at(page_index, pdf) { - self.selection_state.text = - Some(TextSelection::new(page_index, anchor_index, char_index)); + /// Egui retains the original page's drag response, even after the pointer + /// crosses into another page. Resolve the pointer against all visible page + /// placements instead, including when the original page has scrolled away. + fn update_cross_page_selection(&mut self, ui: &egui::Ui, ctx: &Context, path: &Path) { + let Some((anchor_page, anchor_index)) = self.selection_state.anchor else { + return; + }; + let (down, position) = + ctx.input(|input| (input.pointer.primary_down(), input.pointer.interact_pos())); + if let Some(position) = position { + let candidate = self + .pdf_page_placements + .iter() + .copied() + .min_by(|(_, a), (_, b)| { + let distance = |rect: Rect| { + let dx = (rect.left() - position.x) + .max(0.0) + .max(position.x - rect.right()); + let dy = (rect.top() - position.y) + .max(0.0) + .max(position.y - rect.bottom()); + dx * dx + dy * dy + }; + distance(a.rect).total_cmp(&distance(b.rect)) + }); + if let Some((page, placement)) = candidate { + let point = Pos2::new( + position + .x + .clamp(placement.rect.left(), placement.rect.right()), + position + .y + .clamp(placement.rect.top(), placement.rect.bottom()), + ); + if let Some(pdf) = placement.screen_to_pdf(point) + && let Some(index) = self.text_char_index_for_drag(page, pdf) + { + let selection = TextSelection::range(anchor_page, anchor_index, page, index); + if self.selection_state.text != Some(selection) { + self.selection_state.text = Some(selection); + for selected_page in selection.page_range() { + self.request_text_chars(ctx, path, selected_page); + } + ctx.request_repaint(); } } } + if down { + let clip = ui.clip_rect(); + let edge = 36.0_f32.min(clip.height() * 0.2); + let scroll = if position.y < clip.top() + edge { + 14.0 + } else if position.y > clip.bottom() - edge { + -14.0 + } else { + 0.0 + }; + if scroll != 0.0 { + ui.scroll_with_delta_animation( + Vec2::new(0.0, scroll), + egui::style::ScrollAnimation::none(), + ); + ctx.request_repaint_after(Duration::from_millis(16)); + } + } } - - if response.drag_stopped() { + if !down { self.selection_state.anchor = None; if self.active_tool == Tool::Marker { if self.selected_text().is_some() { self.mark_selection(self.marker_preset()); + } else if self.selection_state.text.is_some() { + self.selection_state.pending_mark = true; + self.status = "Preparing selected text for highlighting…".to_owned(); } } else if let Some(text) = self.selected_text() { self.status = format!("Selected {} character(s)", text.chars().count()); @@ -11026,6 +12353,23 @@ impl PdfEditorApp { } } + fn text_char_index_for_drag(&mut self, page: usize, point: (f32, f32)) -> Option { + if let Some(index) = self.text_char_index_at(page, point) { + return Some(index); + } + self.ensure_text_chars(page)? + .iter() + .enumerate() + .filter(|(_, character)| !character.ch.is_control()) + .filter_map(|(index, character)| { + character + .rect + .map(|rect| (index, distance_to_pdf_rect(point, rect))) + }) + .min_by(|left, right| left.1.total_cmp(&right.1)) + .map(|(index, _)| index) + } + fn draw_page_context_menu( &mut self, ctx: &Context, @@ -11061,6 +12405,13 @@ impl PdfEditorApp { self.copy_selection(ctx); ui.close(); } + if ui + .add_enabled(has_selection, egui::Button::new("Copy with citation…")) + .clicked() + { + self.start_copy_with_citation(); + ui.close(); + } if ui .add_enabled(has_selection, egui::Button::new("Highlight selection")) .clicked() @@ -11115,18 +12466,26 @@ impl PdfEditorApp { return; }; - let mut position = selection_rect.left_top() + Vec2::new(0.0, -34.0); - if position.y < 8.0 { + // Keep the palette clear of selected text, including during a + // double/triple-click gesture, using its last measured height. + let palette_height = self + .selection_state + .toolbar_rect + .map_or(40.0, |rect| rect.height()); + let mut position = selection_rect.left_top() + Vec2::new(0.0, -palette_height - 8.0); + if position.y < ctx.content_rect().top() + 8.0 { position = selection_rect.left_bottom() + Vec2::new(0.0, 8.0); } let inner = egui::Area::new(egui::Id::new(("selection-actions", page_index))) .order(egui::Order::Foreground) .fixed_pos(position) + // Avoid egui's large default hit box on the first sizing pass. + .default_size(Vec2::new(300.0, palette_height)) .show(ctx, |ui| { egui::Frame::NONE - .fill(Color32::from_rgba_unmultiplied(250, 249, 245, 238)) - .stroke(Stroke::new(1.0_f32, Color32::from_rgb(205, 198, 184))) + .fill(self.app_palette().bar) + .stroke(Stroke::new(1.0_f32, self.app_palette().rule)) .corner_radius(6) .inner_margin(Margin::symmetric(6, 4)) .show(ui, |ui| { @@ -11134,6 +12493,9 @@ impl PdfEditorApp { if ui.button("Copy").clicked() { self.copy_selection(ctx); } + if ui.button("Copy with citation…").clicked() { + self.start_copy_with_citation(); + } if ui.button("Highlight").clicked() { self.mark_selection(self.marker_preset()); } @@ -11153,6 +12515,28 @@ impl PdfEditorApp { self.copy_text_to_clipboard(ctx, text); } + fn copy_review_shortcut(&mut self, ctx: &Context) { + let has_selection = ctx + .plugin::() + .lock() + .has_selection(); + if has_selection { + // Egui copies the actual highlighted passage at the end of this pass. + citation_copy::ensure_copy_event(ctx); + } else if should_copy_liquid_selection_on_shortcut( + self.selection_state.liquid_all, + self.current_liquid_copy_text().is_some(), + true, + ) { + consume_copy_shortcut(ctx); + surrender_focused_non_text_edit(ctx); + self.copy_liquid_selection(ctx); + } else { + consume_copy_shortcut(ctx); + self.status = "No readable document text is ready to copy.".to_owned(); + } + } + fn copy_liquid_selection(&mut self, ctx: &Context) { let Some(text) = self.current_liquid_copy_text() else { self.selection_state.liquid_all = false; @@ -11174,6 +12558,9 @@ impl PdfEditorApp { match self.view_mode { DocumentViewMode::Pdf => self.select_all_text(ctx), DocumentViewMode::Liquid | DocumentViewMode::LiquidMode2 => { + ctx.plugin::() + .lock() + .clear_selection(); self.select_all_liquid_text() } } @@ -11227,6 +12614,7 @@ impl PdfEditorApp { self.selection_state.text = None; self.selection_state.liquid_all = false; self.selection_state.anchor = None; + self.selection_state.extend_anchor = None; self.selection_state.toolbar_rect = None; self.selection_state.pending_select_all = true; self.finish_pending_select_all_text(ctx); @@ -11270,7 +12658,6 @@ impl PdfEditorApp { "Preparing selectable text for {} page(s)...", missing_pages.len() ); - ctx.request_repaint_after(RENDER_POLL_INTERVAL); return; } @@ -11367,22 +12754,7 @@ impl PdfEditorApp { fn selected_text(&self) -> Option { let document = self.document.as_ref()?; let selection = self.selection_state.text?; - - let mut text = String::new(); - for page_index in selection.page_range() { - let Some(chars) = document.text_chars.get(page_index).and_then(Option::as_ref) else { - return None; - }; - let Some((start, end)) = selection.bounds_for_page(page_index, chars.len()) else { - continue; - }; - if !text.is_empty() { - text.push('\n'); - } - text.extend(chars[start..=end].iter().map(|char| char.ch)); - } - - (!text.is_empty()).then_some(text) + selection_state::selected_text(&document.text_chars, selection) } fn selection_rects_for_page(&self, page_index: usize) -> Vec { @@ -11514,6 +12886,8 @@ impl eframe::App for PdfEditorApp { } fn update(&mut self, ctx: &Context, _frame: &mut eframe::Frame) { + self.sync_review_appearance(ctx); + let mut frame_timing = FrameStallTimer::new(); if ctx.input(|input| input.viewport().close_requested()) && !self.allow_window_close { self.request_window_close(ctx); } @@ -11542,6 +12916,8 @@ impl eframe::App for PdfEditorApp { self.poll_incoming_paths(ctx); self.poll_queued_open_paths(ctx); self.poll_render_results(ctx); + self.poll_annotation_digest(ctx); + self.poll_review_selection_citation(ctx); self.poll_recovery(ctx); #[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] self.poll_print(ctx); @@ -11553,6 +12929,18 @@ impl eframe::App for PdfEditorApp { self.poll_document_links(ctx); self.start_due_annotation_saves(ctx); self.finish_pending_select_all_text(ctx); + if !self.selection_state.pending_select_all + && let Some(selection) = self.selection_state.text + && let Some(path) = self.document.as_ref().map(|document| document.path.clone()) + { + for page in selection.page_range() { + self.enqueue_text_chars(&path, page); + } + } + if self.selection_state.pending_mark && self.selected_text().is_some() { + self.selection_state.pending_mark = false; + self.mark_selection(self.marker_preset()); + } self.poll_ocr(ctx); self.poll_chat_results(ctx); self.poll_liquid_results(ctx); @@ -11570,7 +12958,10 @@ impl eframe::App for PdfEditorApp { self.poll_paid_tts(ctx); self.poll_update_events(ctx); self.handle_dropped_files(ctx); + self.apply_pending_reading_resume(ctx); + self.finish_pending_review_resume(ctx); self.flush_settings_if_due(ctx); + frame_timing.phase("input"); #[cfg(target_os = "windows")] if consume_command_shortcut(ctx, egui::Key::P) { @@ -11579,190 +12970,256 @@ impl eframe::App for PdfEditorApp { #[cfg(target_os = "windows")] let print_modal_open = self.print_ui.open; if !print_modal_open { - let zoom_delta = ctx.input(|input| input.zoom_delta()); - let pdf_ctrl_wheel_zoom = self.view_mode == DocumentViewMode::Pdf - && ctx.input(|input| input.modifiers.ctrl) - && ctx.input(|input| input.pointer.hover_pos().is_some()); - if (zoom_delta - 1.0).abs() > f32::EPSILON && !pdf_ctrl_wheel_zoom { - self.set_zoom(self.target_zoom * zoom_delta); - self.status = format!("{:.0}% zoom", self.target_zoom * 100.0); - ctx.request_repaint_after(ZOOM_RENDER_DEBOUNCE); - } + let zoom_delta = ctx.input(|input| input.zoom_delta()); + let pdf_ctrl_wheel_zoom = self.view_mode == DocumentViewMode::Pdf + && ctx.input(|input| input.modifiers.ctrl) + && ctx.input(|input| input.pointer.hover_pos().is_some()); + if (zoom_delta - 1.0).abs() > f32::EPSILON && !pdf_ctrl_wheel_zoom { + self.set_zoom(self.target_zoom * zoom_delta); + self.status = format!("{:.0}% zoom", self.target_zoom * 100.0); + ctx.request_repaint_after(ZOOM_RENDER_DEBOUNCE); + } - if consume_command_shortcut(ctx, egui::Key::O) { - self.open_dialog(ctx); - } - #[cfg(target_os = "macos")] - { - let native_print = self + if consume_command_shortcut(ctx, egui::Key::O) { + self.open_dialog(ctx); + } + #[cfg(target_os = "macos")] + { + let native_print = self + ._macos_open_files + .as_ref() + .is_some_and(|registration| registration.take_print_requested()); + let shortcut_print = consume_command_shortcut(ctx, egui::Key::P); + if native_print || shortcut_print { + self.request_print(ctx); + } + if let Some(registration) = &self._macos_open_files { + registration.set_print_enabled(self.document.is_some() && !self.print_ui.busy); + } + } + #[cfg(target_os = "linux")] + if consume_command_shortcut(ctx, egui::Key::P) { + self.request_print(ctx); + } + #[cfg(target_os = "macos")] + let (native_save, native_save_as, native_reveal) = self ._macos_open_files .as_ref() - .is_some_and(|registration| registration.take_print_requested()); - let shortcut_print = consume_command_shortcut(ctx, egui::Key::P); - if native_print || shortcut_print { - self.request_print(ctx); + .map(|registration| { + registration.set_save_enabled( + self.document.is_some(), + !self.recovery_is_busy() && !self.page_rotation_in_flight, + ); + ( + registration.take_save_requested(), + registration.take_save_as_requested(), + registration.take_reveal_requested(), + ) + }) + .unwrap_or_default(); + #[cfg(not(target_os = "macos"))] + let (native_save, native_save_as) = (false, false); + #[cfg(target_os = "macos")] + if native_reveal { + self.reveal_current_pdf(); } - if let Some(registration) = &self._macos_open_files { - registration.set_print_enabled(self.document.is_some() && !self.print_ui.busy); + // Consume the modified shortcut first: egui also matches extra Shift + // modifiers against the plain Command-S shortcut. + let save_as_shortcut = consume_save_as_shortcut(ctx); + let save_shortcut = consume_command_shortcut(ctx, egui::Key::S); + if native_save_as || save_as_shortcut { + self.save_as_dialog(ctx); + } else if (native_save || save_shortcut) + && let Err(error) = self.save_current_annotations() + { + self.push_error_notice(error); } - } - #[cfg(target_os = "linux")] - if consume_command_shortcut(ctx, egui::Key::P) { - self.request_print(ctx); - } - #[cfg(target_os = "macos")] - let (native_save, native_save_as, native_reveal) = self - ._macos_open_files - .as_ref() - .map(|registration| { - registration.set_save_enabled( - self.document.is_some(), - !self.recovery_is_busy() && !self.page_rotation_in_flight, - ); - ( - registration.take_save_requested(), - registration.take_save_as_requested(), - registration.take_reveal_requested(), - ) - }) - .unwrap_or_default(); - #[cfg(not(target_os = "macos"))] - let (native_save, native_save_as) = (false, false); - #[cfg(target_os = "macos")] - if native_reveal { - self.reveal_current_pdf(); - } - // Consume the modified shortcut first: egui also matches extra Shift - // modifiers against the plain Command-S shortcut. - let save_as_shortcut = consume_save_as_shortcut(ctx); - let save_shortcut = consume_command_shortcut(ctx, egui::Key::S); - if native_save_as || save_as_shortcut { - self.save_as_dialog(ctx); - } else if (native_save || save_shortcut) - && let Err(error) = self.save_current_annotations() - { - self.push_error_notice(error); - } - self.handle_find_shortcuts(ctx); - if consume_command_shortcut(ctx, egui::Key::Tab) && self.tabs.len() > 1 { - let active = self.active_tab.unwrap_or(0); - let next = if ctx.input(|input| input.modifiers.shift) { - active.checked_sub(1).unwrap_or(self.tabs.len() - 1) - } else { - (active + 1) % self.tabs.len() - }; - self.switch_to_tab(next, ctx); - } - if consume_command_shortcut(ctx, egui::Key::Plus) - || consume_command_shortcut(ctx, egui::Key::Equals) - { - self.set_zoom(self.target_zoom * 1.15); - } - if consume_command_shortcut(ctx, egui::Key::Minus) { - self.set_zoom(self.target_zoom / 1.15); - } - let focused_text_edit = focused_widget_is_text_edit(ctx); - let document_shortcuts_allowed = document_shortcuts_allowed(focused_text_edit); - if document_shortcuts_allowed { - let shift = ctx.input(|input| input.modifiers.shift); - if consume_command_shortcut_or_key_event(ctx, egui::Key::Z) { - self.restore_annotation_history(shift, ctx); - } else if consume_command_shortcut_or_key_event(ctx, egui::Key::Y) { - self.restore_annotation_history(true, ctx); + self.handle_find_shortcuts(ctx); + if consume_command_shortcut(ctx, egui::Key::Tab) && self.tabs.len() > 1 { + let active = self.active_tab.unwrap_or(0); + let next = if ctx.input(|input| input.modifiers.shift) { + active.checked_sub(1).unwrap_or(self.tabs.len() - 1) + } else { + (active + 1) % self.tabs.len() + }; + self.switch_to_tab(next, ctx); } - } - if document_shortcuts_allowed && consume_command_shortcut_or_key_event(ctx, egui::Key::A) { - surrender_focused_non_text_edit(ctx); - self.select_all_current_view_text(ctx); - } - if document_shortcuts_allowed && consume_markdown_copy_shortcut(ctx) { - surrender_focused_non_text_edit(ctx); - self.request_markdown_copy(ctx); - } - let copy_requested = copy_shortcut_requested(ctx); - if document_shortcuts_allowed && copy_requested { - match self.view_mode { - DocumentViewMode::Pdf => { - if should_copy_pdf_selection_on_shortcut( - self.selection_state.text.is_some(), - copy_requested, - ) { - consume_copy_shortcut(ctx); - surrender_focused_non_text_edit(ctx); - self.copy_selection(ctx); + if consume_command_shortcut(ctx, egui::Key::Plus) + || consume_command_shortcut(ctx, egui::Key::Equals) + { + self.set_zoom(self.target_zoom * 1.15); + } + if consume_command_shortcut(ctx, egui::Key::Minus) { + self.set_zoom(self.target_zoom / 1.15); + } + let focused_text_edit = focused_widget_is_text_edit(ctx); + let document_shortcuts_allowed = document_shortcuts_allowed(focused_text_edit); + if document_shortcuts_allowed + && ctx.input(|input| { + input.key_pressed(egui::Key::Questionmark) + || (input.modifiers.shift && input.key_pressed(egui::Key::Slash)) + }) + { + self.shortcuts_open = true; + } + if let Some(forward) = jump_history::consume_history_key( + ctx, + self.document.is_some() && document_shortcuts_allowed, + ) { + self.navigate_jump_history(ctx, forward); + } + if self.document.is_some() + && document_shortcuts_allowed + && self.sbs_mode.is_none() + && self.view_mode == DocumentViewMode::Pdf + { + let viewport = self + .chrome + .document_rect + .map_or(Vec2::new(900.0, 700.0), |rect| rect.size()); + if consume_command_shortcut(ctx, egui::Key::Num0) { + self.fit_current_page(viewport, true); + } else if consume_command_shortcut(ctx, egui::Key::Num1) { + if self.settings.reduce_motion { + self.set_pointer_zoom(1.0); } else { - consume_copy_shortcut(ctx); - self.status = "Select PDF text before copying.".to_owned(); + self.set_zoom(1.0); + } + } else if consume_command_shortcut(ctx, egui::Key::Num2) { + self.fit_current_page(viewport, false); + } + } + self.pending_reading_scroll = reading_navigation::consume( + ctx, + self.document.is_some() && document_shortcuts_allowed, + ); + if self.document.is_some() + && document_shortcuts_allowed + && consume_command_shortcut(ctx, egui::Key::G) + { + ctx.data_mut(|data| data.insert_temp(egui::Id::new("focus-page-number"), true)); + } + if document_shortcuts_allowed { + let shift = ctx.input(|input| input.modifiers.shift); + if consume_command_shortcut_or_key_event(ctx, egui::Key::Z) { + self.restore_annotation_history(shift, ctx); + } else if consume_command_shortcut_or_key_event(ctx, egui::Key::Y) { + self.restore_annotation_history(true, ctx); + } + } + if document_shortcuts_allowed + && consume_command_shortcut_or_key_event(ctx, egui::Key::A) + { + surrender_focused_non_text_edit(ctx); + self.select_all_current_view_text(ctx); + } + let citation_shortcut = + document_shortcuts_allowed && citation_copy::consume_citation_shortcut(ctx); + if citation_shortcut { + // Some platforms also emit a generic Copy event for this chord. + // Remove it before either opening a draft or capturing a selection. + consume_copy_shortcut(ctx); + surrender_focused_non_text_edit(ctx); + match self.view_mode { + DocumentViewMode::Pdf => self.start_copy_with_citation(), + DocumentViewMode::Liquid | DocumentViewMode::LiquidMode2 => { + self.start_review_selection_citation(ctx) } } - DocumentViewMode::Liquid | DocumentViewMode::LiquidMode2 => { - if should_copy_liquid_selection_on_shortcut( - self.selection_state.liquid_all, - self.current_liquid_copy_text().is_some(), - copy_requested, - ) { - consume_copy_shortcut(ctx); - surrender_focused_non_text_edit(ctx); - self.copy_liquid_selection(ctx); - } else { - consume_copy_shortcut(ctx); - self.status = "No readable document text is ready to copy.".to_owned(); + } + let markdown_shortcut = !citation_shortcut + && document_shortcuts_allowed + && consume_markdown_copy_shortcut(ctx); + if markdown_shortcut { + consume_copy_shortcut(ctx); + surrender_focused_non_text_edit(ctx); + self.request_markdown_copy(ctx); + } + let copy_requested = copy_shortcut_requested(ctx); + if !citation_shortcut + && !markdown_shortcut + && document_shortcuts_allowed + && copy_requested + { + match self.view_mode { + DocumentViewMode::Pdf => { + if should_copy_pdf_selection_on_shortcut( + self.selection_state.text.is_some(), + copy_requested, + ) { + consume_copy_shortcut(ctx); + surrender_focused_non_text_edit(ctx); + self.copy_selection(ctx); + } else { + consume_copy_shortcut(ctx); + self.status = "Select PDF text before copying.".to_owned(); + } + } + DocumentViewMode::Liquid | DocumentViewMode::LiquidMode2 => { + self.copy_review_shortcut(ctx); } } } - } - if self.editing_text_box.is_some() - && ctx.input(|input| input.key_pressed(egui::Key::Escape)) - { - self.finish_text_box_edit(); - } - if self.selected_text_box.is_some() - && self.editing_text_box.is_none() - && !ctx.wants_keyboard_input() - && ctx.input(|input| { - input.key_pressed(egui::Key::Delete) || input.key_pressed(egui::Key::Backspace) - }) - { - self.delete_selected_text_box(); - } - if let Some(comment_index) = self.selected_comment { - if self.editing_comment.is_none() + if self.editing_text_box.is_some() + && ctx.input(|input| input.key_pressed(egui::Key::Escape)) + { + self.finish_text_box_edit(); + } + if self.selected_text_box.is_some() + && self.editing_text_box.is_none() && !ctx.wants_keyboard_input() && ctx.input(|input| { input.key_pressed(egui::Key::Delete) || input.key_pressed(egui::Key::Backspace) }) { - self.delete_comment(ctx, comment_index); - } - } - - if !ctx.wants_keyboard_input() { - if ctx.input(|input| input.key_pressed(egui::Key::V)) { - self.active_tool = Tool::Select; - } - if ctx.input(|input| input.key_pressed(egui::Key::M)) { - self.active_tool = Tool::Marker; - } - if ctx.input(|input| input.key_pressed(egui::Key::T)) { - self.active_tool = Tool::TextBox; + self.delete_selected_text_box(); } - if ctx.input(|input| input.key_pressed(egui::Key::S) && !input.modifiers.ctrl) { - self.active_tool = Tool::Signature; + if let Some(comment_index) = self.selected_comment { + if self.editing_comment.is_none() + && !ctx.wants_keyboard_input() + && ctx.input(|input| { + input.key_pressed(egui::Key::Delete) + || input.key_pressed(egui::Key::Backspace) + }) + { + self.delete_comment(ctx, comment_index); + } } - } + if !ctx.wants_keyboard_input() { + if ctx.input(|input| input.key_pressed(egui::Key::V)) { + self.active_tool = Tool::Select; + } + if ctx.input(|input| input.key_pressed(egui::Key::M)) { + self.active_tool = Tool::Marker; + } + if ctx.input(|input| input.key_pressed(egui::Key::T)) { + self.active_tool = Tool::TextBox; + } + if ctx.input(|input| input.key_pressed(egui::Key::S) && !input.modifiers.ctrl) { + self.active_tool = Tool::Signature; + } + } } self.advance_zoom_animation(ctx); self.poll_find(ctx); + frame_timing.phase("chrome"); self.draw_masthead(ctx); if self.sidebar_visible() { self.draw_side_panel(ctx); } + frame_timing.phase("document"); self.draw_document(ctx); + self.remember_reading_position(ctx); + self.remember_window_placement(ctx); + frame_timing.phase("overlays"); self.draw_markup_bar(ctx); self.draw_review_feedback_ui(ctx); self.draw_settings_window(ctx); + self.draw_citation_copy_window(ctx); + self.draw_annotation_digest_window(ctx); + reading_navigation::draw_shortcuts(ctx, &mut self.shortcuts_open); self.draw_comment_profile(ctx); #[cfg(target_os = "linux")] self.draw_print_dialog(ctx); @@ -11773,36 +13230,15 @@ impl eframe::App for PdfEditorApp { // Transient document toasts must not cover the print dialog's action bar. if !print_modal_open { self.draw_notices(ctx); + self.draw_resume_notice(ctx); } #[cfg(target_os = "windows")] self.draw_print_dialog(ctx); + self.draw_file_drop_overlay(ctx); - if self.zoom_is_animating() - || !self.pending_page_renders.is_empty() - || !self.sbs_pending_page_renders.is_empty() - || !self.pending_thumbnail_renders.is_empty() - || !self.pending_native_text.is_empty() - || !self.pending_text_chars.is_empty() - || !self.pending_document_links.is_empty() - || self.selection_state.pending_select_all - || !self.pending_annotation_saves.is_empty() - || !self.active_annotation_saves.is_empty() - || self.page_rotation_in_flight - || !self.queued_open_paths.is_empty() - || !self.pending_document_opens.is_empty() - || self.update_ui.state.is_busy() - || self.chat_ui.state.in_flight - || self.ocr_is_active() - || self.review_feedback_sending - || matches!( - self.liquid_state, - LiquidState::PreparingText | LiquidState::Preparing - ) - || matches!( - self.liquid_mode2_state, - LiquidState::PreparingText | LiquidState::Preparing - ) - { + // Workers wake the UI when they publish results. Queued opens and + // in-flight animations are the only work that needs another frame here. + if self.zoom_is_animating() || !self.queued_open_paths.is_empty() { ctx.request_repaint_after(RENDER_POLL_INTERVAL); } } @@ -12997,57 +14433,11 @@ fn lerp_color(from: Color32, to: Color32, t: f32) -> Color32 { ) } -/// Decelerating ease (fast start, gentle stop) — the marker tip slowing as the -/// stroke completes. -fn ease_out_cubic(t: f32) -> f32 { - let u = 1.0 - t; - 1.0 - u * u * u -} - -/// Accelerating ease, used so the drying sheen lingers then fades quickly. -fn ease_in_cubic(t: f32) -> f32 { - t * t * t -} - -#[derive(Debug, Clone, Copy)] -struct SettledMarkerMotion { - alpha: u8, - sheen_progress: f32, - sheen_alpha: u8, -} - -fn settled_marker_motion(time: f32, seed: u32, target_alpha: u8) -> SettledMarkerMotion { - let phase = hash01(seed ^ 0xA511_E9B3, 17) * std::f32::consts::TAU; - let breath = (time * MARKER_BREATH_RATE + phase).sin(); - let alpha = ((target_alpha as f32) * (1.0 + MARKER_BREATH_AMPLITUDE * breath)) - .round() - .clamp(0.0, 255.0) as u8; - - let cycle = - MARKER_SHEEN_CYCLE_BASE + MARKER_SHEEN_CYCLE_VARIANCE * hash01(seed ^ 0x63D8_3595, 29); - let offset = cycle * hash01(seed ^ 0xB529_7A4D, 43); - let sheen_progress = (time + offset).rem_euclid(cycle) / cycle; - let fade = (std::f32::consts::PI * sheen_progress).sin().powi(4); - let sheen_alpha = ((target_alpha as f32) * MARKER_SHEEN_ALPHA * fade) - .round() - .clamp(0.0, 255.0) as u8; - - SettledMarkerMotion { - alpha, - sheen_progress, - sheen_alpha, - } -} - -fn marker_sheen_rgb(color_rgb: [f32; 3]) -> [f32; 3] { - color_rgb.map(|channel| channel + (1.0 - channel) * 0.34) -} - /// Paint a highlight as a felt-tip marker stroke rather than a flat box: wavy /// (non-straight) top and bottom edges plus uneven ink density — darker pools /// where the tip lingered, lighter streaks where it skipped. Everything is /// derived deterministically from `seed`, so the texture stays put frame to -/// frame while the opacity is free to animate (wipe / settle / breathing). +/// frame while a newly applied mark animates (wipe / settle). fn paint_marker_stroke( painter: &egui::Painter, rect: Rect, @@ -14204,9 +15594,37 @@ fn strip_liquid_callouts(text: &str) -> String { /// #33: assemble the reading text for TTS in reading order — body blocks first (furniture and /// hidden blocks skipped, marker callouts stripped), then footnotes as a separate pass. fn liquid_tts_text(document: &LiquidDocument, include_notes: bool) -> String { + liquid_tts_text_from(document, include_notes, 0) +} + +fn liquid_tts_text_from(document: &LiquidDocument, include_notes: bool, start: usize) -> String { + liquid_tts_segments_from(document, include_notes, start) + .into_iter() + .map(|segment| segment.text) + .collect::>() + .join("\n\n") +} + +fn liquid_tts_segments_from( + document: &LiquidDocument, + include_notes: bool, + start: usize, +) -> Vec { + use crate::tts::SpeechSegment; let hidden = review_hidden_display_mask(&document.blocks); - let mut body = String::new(); - let mut notes = String::new(); + let mut body = Vec::new(); + let mut notes = Vec::new(); + let mut linked_notes: HashMap> = HashMap::new(); + if include_notes && start > 0 { + for link in &document.footnote_links { + if link.body_block_index >= start && link.body_block_index < document.blocks.len() { + linked_notes + .entry(link.note_block_index) + .or_default() + .insert(link.marker); + } + } + } for (index, block) in document.blocks.iter().enumerate() { if hidden.get(index).copied().unwrap_or(false) || should_hide_contents_block_for_display(block) @@ -14223,22 +15641,40 @@ fn liquid_tts_text(document: &LiquidDocument, include_notes: bool) -> String { block.role, LiquidBlockRole::Marginalia | LiquidBlockRole::Footnote ) { - notes.push_str(cleaned); - notes.push_str("\n\n"); - } else { - body.push_str(cleaned); - body.push_str("\n\n"); + if start == 0 { + notes.push(SpeechSegment { + block_index: None, + text: cleaned.to_owned(), + }); + } else if let Some(markers) = linked_notes.get(&index) { + for (marker, note) in split_fused_review_notes(cleaned) { + if marker + .parse::() + .ok() + .is_some_and(|number| markers.contains(&number)) + { + notes.push(SpeechSegment { + block_index: None, + text: format!("{marker}. {}", note.trim()), + }); + } + } + } + } else if index >= start { + body.push(SpeechSegment { + block_index: Some(index), + text: cleaned.to_owned(), + }); } } - let mut out = body; - if include_notes { - let notes = notes.trim(); - if !notes.is_empty() { - out.push_str("\n\nFootnotes.\n\n"); - out.push_str(notes); - } + if include_notes && !notes.is_empty() { + body.push(SpeechSegment { + block_index: None, + text: "Footnotes.".to_owned(), + }); + body.extend(notes); } - out.trim().to_owned() + body } fn has_usable_ocr_text(states: &[OcrPageState]) -> bool { @@ -14454,6 +15890,12 @@ fn sbs_fit_scale(viewport_width: f32, max_page_width: f32) -> f32 { ((viewport_width - SBS_PAGE_MARGIN * 2.0).max(80.0) / max_page_width.max(1.0)).clamp(0.1, 1.0) } +fn fit_page_zoom(viewport: Vec2, page_width: f32, page_height: f32, whole_page: bool) -> f32 { + let width = (viewport.x - 2.0 * PAGE_MARKER_GUTTER - 12.0).max(1.0) / page_width.max(1.0); + let height = (viewport.y - 2.0 * DOCUMENT_PAGE_GAP - 12.0).max(1.0) / page_height.max(1.0); + normalized_pdf_zoom(if whole_page { width.min(height) } else { width }) +} + fn page_render_scale_for_zoom(ctx: &Context, zoom: f32, page_width: f32, page_height: f32) -> f32 { let device_pixels_per_point = ctx.pixels_per_point().max(1.0); let base_scale = (zoom * device_pixels_per_point * 1.25).clamp(0.25, 3.25); @@ -14521,6 +15963,7 @@ fn in_review_column( .inner } +#[cfg(test)] fn liquid_note_blocks(blocks: &[LiquidBlock]) -> Vec<&LiquidBlock> { blocks .iter() @@ -14734,6 +16177,477 @@ mod app_tests { use super::*; use crate::model::PageInfo; + #[test] + fn review_reveal_remains_skippable_and_stops_repainting() { + fn playing() -> review_reveal::ReviewReveal { + review_reveal::ReviewReveal { + page_index: 0, + page_rect: Rect::from_min_size(Pos2::ZERO, Vec2::new(400.0, 500.0)), + texture: None, + words: Vec::new(), + phase: review_reveal::RevealPhase::Playing { started: 0.0 }, + labels: Vec::new(), + notes: Vec::new(), + last_layout: Vec::new(), + galleys: Vec::new(), + } + } + let (mut app, ctx, _worker) = annotation_saving::tests::fixture(); + let document = test_liquid_document_with_blocks(Vec::new()); + let frame = |app: &mut PdfEditorApp, time: f64, events: Vec| { + ctx.run( + egui::RawInput { + time: Some(time), + events, + ..Default::default() + }, + |ctx| { + egui::CentralPanel::default() + .show(ctx, |ui| app.draw_review_reveal(ui, ctx, &document)); + }, + ) + }; + app.settings.reduce_motion = false; + app.review_reveal = Some(playing()); + frame(&mut app, 1.0, vec![]); + assert!(app.review_reveal.is_some()); + frame( + &mut app, + 1.1, + vec![egui::Event::Key { + key: egui::Key::Space, + physical_key: None, + pressed: true, + repeat: false, + modifiers: egui::Modifiers::NONE, + }], + ); + assert!( + app.review_reveal.is_none(), + "keyboard input should skip the reveal" + ); + + app.review_reveal = Some(playing()); + app.settings.reduce_motion = true; + frame(&mut app, 1.2, vec![]); + assert!( + app.review_reveal.is_none(), + "Reduce motion should end an active reveal" + ); + + app.settings.reduce_motion = false; + app.review_reveal = Some(playing()); + frame(&mut app, 3.3, vec![]); + assert!(app.review_reveal.is_none()); + for index in 0..8 { + let output = frame(&mut app, 4.0 + index as f64 * 0.2, vec![]); + if index >= 4 { + assert_eq!( + output.viewport_output[&egui::ViewportId::ROOT].repaint_delay, + Duration::MAX + ); + } + } + } + + #[test] + fn settled_highlight_does_not_schedule_repaints() { + let (app, ctx, _worker) = annotation_saving::tests::fixture(); + for frame in 0..8 { + let input = egui::RawInput { + time: Some(frame as f64 * 0.2), + ..Default::default() + }; + let output = ctx.run(input, |ctx| { + let painter = ctx.layer_painter(egui::LayerId::background()); + app.draw_highlight_marker( + &painter, + 0, + PdfRect { + left: 10.0, + bottom: 10.0, + right: 120.0, + top: 24.0, + }, + Rect::from_min_size(Pos2::new(10.0, 10.0), Vec2::new(110.0, 14.0)), + [1.0, 0.9, 0.2], + 110, + ); + }); + if frame >= 4 { + assert_eq!( + output.viewport_output[&egui::ViewportId::ROOT].repaint_delay, + Duration::MAX + ); + } + } + } + + #[test] + fn highlighted_document_reaches_idle() { + let (mut app, ctx, _worker) = annotation_saving::tests::fixture(); + app.document.as_mut().unwrap().text_chars[0] = Some(Vec::new()); + app.settings.sidebar_in_pdf = false; + app.annotations.push(EditorAnnotation { + page_index: 0, + rect: PdfRect { + left: 20.0, + bottom: 700.0, + right: 200.0, + top: 720.0, + }, + kind: AnnotationKind::Marker { + color_rgb: [1.0, 0.9, 0.2], + opacity: 0.4, + style: MarkerStyle::Highlight, + }, + }); + let texture = ctx.load_texture( + "idle-test-page", + egui::ColorImage::filled([4, 4], Color32::WHITE), + TextureOptions::LINEAR, + ); + let render_scale = app.page_render_scale(&ctx, 612.0, 792.0); + app.page_textures.insert( + 0, + PageTexture { + _zoom: app.zoom, + render_scale, + last_used: 1, + texture, + fade_started: None, + }, + ); + for frame in 0..10 { + let input = egui::RawInput { + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, Vec2::new(1200.0, 900.0))), + time: Some(frame as f64 * 0.2), + ..Default::default() + }; + let output = ctx.run(input, |ctx| { + app.draw_masthead(ctx); + app.draw_document(ctx); + app.draw_markup_bar(ctx); + app.draw_notices(ctx); + app.draw_update_notice(ctx); + }); + if frame >= 6 { + assert_eq!( + output.viewport_output[&egui::ViewportId::ROOT].repaint_delay, + Duration::MAX + ); + } + } + } + + #[test] + fn dragging_from_one_page_into_the_next_selects_both() { + fn chars(text: &str, bottom: f32) -> Vec { + text.chars() + .enumerate() + .map(|(index, ch)| PageTextChar { + ch, + rect: Some(PdfRect { + left: 72.0 + index as f32 * 9.0, + right: 80.0 + index as f32 * 9.0, + bottom, + top: bottom + 16.0, + }), + font_size: Some(12.0), + bold: false, + italic: false, + }) + .collect() + } + let (mut app, ctx, _worker) = annotation_saving::tests::fixture(); + let document = app.document.as_mut().unwrap(); + document.page_count = 2; + document.pages = vec![PageInfo::new(612.0, 792.0), PageInfo::new(612.0, 792.0)]; + document.native_text = vec!["tail".to_owned(), "head".to_owned()]; + document.native_text_loaded = vec![true, true]; + document.text_chars = vec![Some(chars("tail", 30.0)), Some(chars("head", 742.0))]; + document.links = vec![Vec::new(), Vec::new()]; + app.zoom = 0.4; + app.target_zoom = 0.4; + app.settings.sidebar_in_pdf = false; + app.scroll_target_page = None; + let texture = ctx.load_texture( + "selection-test-page", + egui::ColorImage::filled([4, 4], Color32::WHITE), + TextureOptions::LINEAR, + ); + let render_scale = app.page_render_scale(&ctx, 612.0, 792.0); + for page in 0..2 { + app.page_textures.insert( + page, + PageTexture { + _zoom: app.zoom, + render_scale, + last_used: 1, + texture: texture.clone(), + fade_started: None, + }, + ); + } + let rect = Rect::from_min_size(Pos2::ZERO, Vec2::new(1200.0, 900.0)); + let mut draw = |time: f64, events: Vec, app: &mut PdfEditorApp| { + let _ = ctx.run( + egui::RawInput { + screen_rect: Some(rect), + time: Some(time), + events, + ..Default::default() + }, + |ctx| app.draw_document(ctx), + ); + }; + draw(0.0, vec![], &mut app); + let first = app + .pdf_page_placements + .iter() + .find(|(page, _)| *page == 0) + .unwrap() + .1 + .pdf_to_screen((76.0, 38.0)); + let second = app + .pdf_page_placements + .iter() + .find(|(page, _)| *page == 1) + .unwrap() + .1 + .pdf_to_screen((103.0, 750.0)); + draw( + 0.1, + vec![ + egui::Event::PointerMoved(first), + egui::Event::PointerButton { + pos: first, + button: egui::PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::NONE, + }, + ], + &mut app, + ); + draw(0.2, vec![egui::Event::PointerMoved(second)], &mut app); + draw( + 0.3, + vec![egui::Event::PointerButton { + pos: second, + button: egui::PointerButton::Primary, + pressed: false, + modifiers: egui::Modifiers::NONE, + }], + &mut app, + ); + assert_eq!(app.selected_text().as_deref(), Some("tail\nhead")); + } + + #[test] + fn background_worker_result_wakes_the_interface() { + use std::sync::atomic::{AtomicUsize, Ordering}; + let ctx = Context::default(); + let wakes = Arc::new(AtomicUsize::new(0)); + let observed = Arc::clone(&wakes); + ctx.set_request_repaint_callback(move |_| { + observed.fetch_add(1, Ordering::SeqCst); + }); + let (destination, received) = unbounded(); + let sender = worker_waking_sender(destination, ctx); + sender.send(17).unwrap(); + assert_eq!(received.recv_timeout(Duration::from_secs(2)).unwrap(), 17); + let deadline = Instant::now() + Duration::from_secs(2); + while wakes.load(Ordering::SeqCst) == 0 && Instant::now() < deadline { + std::thread::yield_now(); + } + assert!(wakes.load(Ordering::SeqCst) > 0); + } + + #[test] + fn fit_page_respects_width_height_and_zoom_limits() { + let viewport = Vec2::new(1000.0, 700.0); + let width = fit_page_zoom(viewport, 600.0, 900.0, false); + let page = fit_page_zoom(viewport, 600.0, 900.0, true); + assert!(width > page); + assert!((page - (700.0 - 2.0 * DOCUMENT_PAGE_GAP - 12.0) / 900.0).abs() < 0.001); + assert!( + fit_page_zoom(Vec2::splat(1.0), 600.0, 900.0, true) >= crate::settings::MIN_PDF_ZOOM + ); + } + + #[test] + fn review_text_progress_counts_attempted_pages_not_later_inference() { + let (mut app, _ctx, _worker) = annotation_saving::tests::fixture(); + let document = app.document.as_mut().unwrap(); + document.page_count = 3; + document.native_text_loaded = vec![true, true, false]; + document.text_chars = vec![Some(Vec::new()), None, Some(Vec::new())]; + assert_eq!(app.review_text_preparation_progress(), Some((1, 3))); + app.document.as_mut().unwrap().page_count = 0; + assert_eq!(app.review_text_preparation_progress(), None); + } + + #[test] + fn reduced_motion_applies_zoom_without_an_animation() { + let (mut app, ctx, _worker) = annotation_saving::tests::fixture(); + app.settings.reduce_motion = true; + app.zoom = 1.0; + app.target_zoom = 2.0; + let _ = ctx.run(egui::RawInput::default(), |ctx| { + app.advance_zoom_animation(ctx) + }); + assert_eq!(app.zoom, 2.0); + assert!(!app.zoom_is_animating()); + } + + #[test] + fn page_field_navigation_targets_the_corresponding_review_block() { + let (mut app, _ctx, _worker) = annotation_saving::tests::fixture(); + app.document.as_mut().unwrap().page_count = 4; + let mut review = test_liquid_document_with_blocks(vec![test_liquid_block( + LiquidBlockRole::Paragraph, + "Passage", + )]); + review.block_source_lines = vec![crate::liquid::LiquidBlockSourceLines { + block_index: 0, + lines: vec![LiquidSourceLineRef { + id: None, + page_index: 3, + line_index: 0, + text: "Passage".to_owned(), + role: LiquidBlockRole::Paragraph, + note_markers: Vec::new(), + }], + }]; + app.view_mode = DocumentViewMode::LiquidMode2; + app.liquid_mode2_state = LiquidState::Ready(Arc::new(review)); + app.go_to_page(3); + assert_eq!(app.page_index, 3); + assert_eq!(app.liquid_scroll_to_block, Some(0)); + } + + #[test] + fn texture_budget_evicts_old_pages_but_keeps_the_current_page() { + let (mut app, ctx, _worker) = annotation_saving::tests::fixture(); + for page in 0..3 { + let texture = ctx.load_texture( + format!("budget-{page}"), + egui::ColorImage::filled([8, 8], Color32::WHITE), + TextureOptions::LINEAR, + ); + app.page_textures.insert( + page, + PageTexture { + _zoom: 1.0, + render_scale: 1.0, + last_used: page as u64, + texture, + fade_started: None, + }, + ); + } + let page_bytes = app.page_textures[&0].texture.byte_size(); + app.save_active_tab_state(); + assert_eq!(app.tabs[0].page_textures.len(), 1); + app.prune_texture_cache_to(page_bytes * 2); + assert!(app.texture_bytes() <= page_bytes * 2); + assert!(app.page_textures.contains_key(&0)); + assert!(!app.page_textures.contains_key(&1)); + assert!(app.page_textures.contains_key(&2)); + } + + #[test] + fn page_texture_installs_are_spread_across_frames() { + let (mut app, ctx, _worker) = annotation_saving::tests::fixture(); + let document = app.document.as_mut().unwrap(); + document.page_count = 3; + document.pages = vec![PageInfo::new(612.0, 792.0); 3]; + let path = document.path.clone(); + let (tx, rx) = unbounded(); + app.render_rx = rx; + let scale = app.page_render_scale(&ctx, 612.0, 792.0); + for page in 0..3 { + let key = PageRenderKey::new(app.document_epoch, page, scale); + app.pending_page_renders.insert(page, key); + tx.send(RenderEvent::Page { + key, + path: path.clone(), + _zoom: app.zoom, + render_scale: scale, + result: Ok(PreparedPage::try_from(RenderedPage { + page_index: page, + width: 2, + height: 2, + rgba: vec![255; 16], + }) + .unwrap()), + }) + .unwrap(); + } + let _ = ctx.run(egui::RawInput::default(), |ctx| { + app.poll_render_results(ctx) + }); + assert_eq!(app.page_textures.len(), 2); + assert_eq!(app.deferred_render_events.len(), 1); + let _ = ctx.run(egui::RawInput::default(), |ctx| { + app.poll_render_results(ctx) + }); + assert_eq!(app.page_textures.len(), 3); + assert!(app.deferred_render_events.is_empty()); + } + + #[test] + fn thumbnail_installs_share_the_page_texture_frame_budget() { + let (mut app, ctx, _worker) = annotation_saving::tests::fixture(); + let document = app.document.as_mut().unwrap(); + document.page_count = 3; + document.pages = vec![PageInfo::new(612.0, 792.0); 3]; + let path = document.path.clone(); + let (tx, rx) = unbounded(); + app.render_rx = rx; + let page_scale = app.page_render_scale(&ctx, 612.0, 792.0); + let page_key = PageRenderKey::new(app.document_epoch, 0, page_scale); + app.pending_page_renders.insert(0, page_key); + let raster = |page_index| { + PreparedPage::try_from(RenderedPage { + page_index, + width: 2, + height: 2, + rgba: vec![255; 16], + }) + .unwrap() + }; + tx.send(RenderEvent::Page { + key: page_key, + path: path.clone(), + _zoom: app.zoom, + render_scale: page_scale, + result: Ok(raster(0)), + }) + .unwrap(); + for page in 1..3 { + let key = ThumbnailRenderKey::new(app.document_epoch, page, 0.25); + app.pending_thumbnail_renders.insert(page, key); + tx.send(RenderEvent::Thumbnail { + key, + path: path.clone(), + result: Ok(raster(page)), + }) + .unwrap(); + } + let _ = ctx.run(egui::RawInput::default(), |ctx| { + app.poll_render_results(ctx) + }); + assert_eq!(app.page_textures.len(), 1); + assert_eq!(app.thumbnail_textures.len(), 1); + assert_eq!(app.deferred_render_events.len(), 1); + let _ = ctx.run(egui::RawInput::default(), |ctx| { + app.poll_render_results(ctx) + }); + assert_eq!(app.thumbnail_textures.len(), 2); + assert!(app.deferred_render_events.is_empty()); + } + #[test] fn toolbar_version_label_matches_the_build_version() { assert_eq!( @@ -14813,6 +16727,8 @@ mod app_tests { enriched.links = vec![vec![PageLink { rect: PdfRect::new(72.0, 700.0, 180.0, 720.0), url: "https://example.com".to_owned(), + destination_page: None, + destination_fraction: None, }]]; enriched.links_loaded = true; @@ -15004,16 +16920,25 @@ mod app_tests { message: "old info".to_owned(), severity: NoticeSeverity::Info, created_at: old, + hovered_at: None, + paused: Duration::ZERO, + action: None, }, Notice { message: "old error".to_owned(), severity: NoticeSeverity::Error, created_at: old, + hovered_at: None, + paused: Duration::ZERO, + action: None, }, Notice { message: "new info".to_owned(), severity: NoticeSeverity::Info, created_at: now, + hovered_at: None, + paused: Duration::ZERO, + action: None, }, ]); prune_notices_at(&mut notices, now); @@ -15026,6 +16951,94 @@ mod app_tests { ); } + #[test] + fn hovering_a_notice_pauses_its_remaining_lifetime() { + let start = Instant::now(); + let mut notice = Notice::new("A message", NoticeSeverity::Info); + notice.created_at = start; + notice.set_hovered_at(true, start + Duration::from_secs(2)); + assert_eq!( + notice.age_at(start + Duration::from_secs(20)), + Duration::from_secs(2) + ); + assert!(!notice.is_expired_at(start + Duration::from_secs(20))); + notice.set_hovered_at(false, start + Duration::from_secs(20)); + assert!(!notice.is_expired_at(start + Duration::from_secs(23))); + assert!(notice.is_expired_at(start + Duration::from_secs(24))); + notice.set_hovered_at(true, start + Duration::from_secs(25)); + assert!(notice.is_expired_at(start + Duration::from_secs(100))); + } + + #[test] + fn notice_motion_and_wakeups_stop_when_settled_or_hovered() { + let start = Instant::now(); + let mut notice = Notice::new("A message", NoticeSeverity::Info); + notice.created_at = start; + assert_eq!(notice.visibility_at(start, false), 0.0); + assert_eq!(notice.visibility_at(start, true), 1.0); + assert_eq!( + notice.repaint_after(start, true), + Some(INFO_NOTICE_DURATION) + ); + let near_expiry = start + INFO_NOTICE_DURATION - Duration::from_millis(90); + let faded = notice.visibility_at(near_expiry, false); + assert!(faded > 0.0 && faded < 1.0); + assert_eq!(notice.visibility_at(near_expiry, true), 1.0); + assert!(notice.repaint_after(near_expiry, false).unwrap() <= Duration::from_millis(16)); + notice.set_hovered_at(true, near_expiry); + assert_eq!(notice.visibility_at(near_expiry, false), 1.0); + assert_eq!(notice.repaint_after(near_expiry, false), None); + notice.severity = NoticeSeverity::Error; + notice.set_hovered_at(false, near_expiry); + assert_eq!(notice.repaint_after(near_expiry, false), None); + } + + #[test] + fn pointer_over_a_notice_pauses_and_leaving_resumes_it() { + let (mut app, ctx, _worker) = annotation_saving::tests::fixture(); + app.settings.reduce_motion = true; + let mut notice = Notice::new("Read this message", NoticeSeverity::Info); + notice.created_at = Instant::now() - Duration::from_secs(4); + app.notices = VecDeque::from([notice]); + let screen = Rect::from_min_size(Pos2::ZERO, Vec2::new(1200.0, 900.0)); + for _ in 0..3 { + let _ = ctx.run( + egui::RawInput { + screen_rect: Some(screen), + ..Default::default() + }, + |ctx| { + app.draw_notices(ctx); + }, + ); + } + let rect = ctx + .memory(|memory| memory.area_rect(egui::Id::new("general_notices"))) + .unwrap(); + for _ in 0..3 { + let input = egui::RawInput { + screen_rect: Some(screen), + events: vec![egui::Event::PointerMoved(rect.center())], + ..Default::default() + }; + let _ = ctx.run(input, |ctx| app.draw_notices(ctx)); + } + assert!(app.notices[0].hovered_at.is_some()); + app.notices[0].created_at -= Duration::from_secs(10); + app.notices[0].hovered_at = app.notices[0] + .hovered_at + .map(|time| time - Duration::from_secs(10)); + let input = egui::RawInput { + screen_rect: Some(screen), + events: vec![egui::Event::PointerGone], + ..Default::default() + }; + let _ = ctx.run(input, |ctx| app.draw_notices(ctx)); + assert_eq!(app.notices.len(), 1); + assert!(app.notices[0].hovered_at.is_none()); + assert!(!app.notices[0].is_expired_at(Instant::now())); + } + #[cfg(target_os = "windows")] #[test] fn windows_default_apps_uri_targets_lawpdf_registration() { @@ -16484,28 +18497,87 @@ mod app_tests { } #[test] - fn settled_marker_motion_stays_subtle_and_seeded() { - let target = 110; - let first = settled_marker_motion(3.0, 17, target); - let later = settled_marker_motion(7.0, 17, target); - let other = settled_marker_motion(3.0, 99, target); - - let allowed_delta = (target as f32 * MARKER_BREATH_AMPLITUDE).ceil() as i16 + 1; - for motion in [first, later, other] { - assert!((motion.alpha as i16 - target as i16).abs() <= allowed_delta); - assert!((0.0..1.0).contains(&motion.sheen_progress)); - assert!(motion.sheen_alpha <= 13); - } - assert_ne!(first.sheen_progress, later.sheen_progress); - assert_ne!(first.sheen_progress, other.sheen_progress); + fn read_from_here_uses_later_body_and_only_linked_notes() { + use crate::liquid::LiquidFootnoteLink; + let mut doc = test_liquid_document_with_blocks(vec![ + test_liquid_block(LiquidBlockRole::Paragraph, "Earlier body."), + test_liquid_block(LiquidBlockRole::Footnote, "1. Earlier note."), + test_liquid_block(LiquidBlockRole::Paragraph, "Current body."), + test_liquid_block(LiquidBlockRole::Footnote, "2. Linked later note."), + test_liquid_block(LiquidBlockRole::Footnote, "3. Unlinked note."), + test_liquid_block(LiquidBlockRole::Paragraph, "Later body."), + ]); + doc.footnote_links = vec![ + LiquidFootnoteLink { + body_block_index: 0, + body_marker_ordinal: 0, + marker: 1, + note_block_index: 1, + body_page_index: None, + note_page_index: None, + }, + LiquidFootnoteLink { + body_block_index: 2, + body_marker_ordinal: 0, + marker: 2, + note_block_index: 3, + body_page_index: None, + note_page_index: None, + }, + ]; + let text = liquid_tts_text_from(&doc, true, 2); + assert!(text.starts_with("Current body.")); + assert!(text.contains("Later body.")); + assert!(text.contains("Linked later note.")); + assert!(!text.contains("Earlier body.")); + assert!(!text.contains("Earlier note.")); + assert!(!text.contains("Unlinked note.")); + assert!(!liquid_tts_text_from(&doc, false, 2).contains("Footnotes.")); + assert!(liquid_tts_text_from(&doc, true, 99).is_empty()); + let segments = liquid_tts_segments_from(&doc, true, 2); + assert_eq!( + segments + .iter() + .map(|segment| segment.block_index) + .collect::>(), + vec![Some(2), Some(5), None, None] + ); } #[test] - fn marker_sheen_only_gently_lifts_color() { - let lifted = marker_sheen_rgb([0.68, 0.42, 1.0]); - assert!(lifted[0] > 0.68 && lifted[0] < 0.8); - assert!(lifted[1] > 0.42 && lifted[1] < 0.7); - assert_eq!(lifted[2], 1.0); + fn spoken_review_block_is_highlighted_and_follow_target_is_consumed() { + let (mut app, ctx, _worker) = annotation_saving::tests::fixture(); + let document = Arc::new(test_liquid_document_with_blocks(vec![test_liquid_block( + LiquidBlockRole::Paragraph, + "Currently spoken passage.", + )])); + app.view_mode = DocumentViewMode::LiquidMode2; + app.liquid_mode2_state = LiquidState::Ready(Arc::clone(&document)); + app.refresh_review_derived(&document); + app.tts_controller.follow_epoch = Some(7); + app.tts_controller.follow_view = Some(DocumentViewMode::LiquidMode2); + app.tts_controller.follow_document = Some(Arc::downgrade(&document)); + app.tts_controller.speaking_block = Some(0); + app.tts_controller.follow_target = Some(0); + app.liquid_scroll_to_block = Some(0); + let output = ctx.run( + egui::RawInput { + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, Vec2::new(600.0, 400.0))), + ..Default::default() + }, + |ctx| { + egui::CentralPanel::default().show(ctx, |ui| { + app.draw_liquid_feedback_block(ui, &document, 0, &document.blocks[0]); + }); + }, + ); + assert!( + output.shapes.iter().any( + |shape| matches!(&shape.shape, egui::Shape::Rect(rect) if rect.fill.a() == 28) + ) + ); + assert!(app.liquid_scroll_to_block.is_none()); + assert!(app.tts_controller.follow_target.is_none()); } #[test] diff --git a/src/app/motion.rs b/src/app/motion.rs new file mode 100644 index 0000000..8b7fa11 --- /dev/null +++ b/src/app/motion.rs @@ -0,0 +1,371 @@ +//! Shared timing and easing for reader motion. +use eframe::egui::{self, Context}; +use std::time::{Duration, Instant}; + +pub(super) const SURFACE_SECONDS: f32 = 0.18; +pub(super) const PAGING_SECONDS: f32 = 0.14; +pub(super) const REVEAL_SECONDS: f64 = 3.2; +pub(super) const PAGE_ARRIVAL: Duration = Duration::from_millis(120); +pub(super) const SAVE_CONFIRMATION: Duration = Duration::from_millis(300); +pub(super) const JUMP_SECONDS: f32 = 0.25; +const FRAME: Duration = Duration::from_millis(16); + +pub(super) fn ease_out_cubic(t: f32) -> f32 { + let t = t.clamp(0.0, 1.0); + let remaining = 1.0 - t; + 1.0 - remaining * remaining * remaining +} + +pub(super) fn ease_in_cubic(t: f32) -> f32 { + let t = t.clamp(0.0, 1.0); + t * t * t +} + +pub(super) fn ease_in_out(t: f32) -> f32 { + let t = t.clamp(0.0, 1.0); + if t < 0.5 { + 4.0 * t * t * t + } else { + 1.0 - (-2.0 * t + 2.0).powi(3) / 2.0 + } +} + +pub(super) fn smoothstep(a: f32, b: f32, x: f32) -> f32 { + let t = ((x - a) / (b - a)).clamp(0.0, 1.0); + t * t * (3.0 - 2.0 * t) +} + +/// Request frames only while a page is actually transitioning. +pub(super) fn page_opacity(ctx: &Context, started: Instant, reduce_motion: bool) -> f32 { + if reduce_motion { + return 1.0; + } + let elapsed = started.elapsed(); + if elapsed >= PAGE_ARRIVAL { + return 1.0; + } + ctx.request_repaint_after(FRAME.min(PAGE_ARRIVAL - elapsed)); + ease_out_cubic(elapsed.as_secs_f32() / PAGE_ARRIVAL.as_secs_f32()) +} + +/// Animate nearby jumps for at most a quarter second. Distant jumps and all +/// jumps under Reduce motion land immediately rather than sweeping through the +/// intervening document. `distance` and `viewport_height` are in UI points. +pub(super) fn jump_animation( + distance: f32, + viewport_height: f32, + reduce_motion: bool, +) -> egui::style::ScrollAnimation { + if reduce_motion + || !distance.is_finite() + || !viewport_height.is_finite() + || viewport_height <= 0.0 + || distance.abs() > 3.0 * viewport_height + { + egui::style::ScrollAnimation::none() + } else { + egui::style::ScrollAnimation::new(1200.0, egui::emath::Rangef::new(0.0, JUMP_SECONDS)) + } +} + +/// Progress is tied to the actual successful save, never to a repaint or a +/// tab switch. A completed check mark does not keep the UI awake. +pub(super) fn save_progress(ctx: &Context, started: Instant, reduce_motion: bool) -> f32 { + if reduce_motion { + return 1.0; + } + let elapsed = started.elapsed(); + if elapsed >= SAVE_CONFIRMATION { + return 1.0; + } + ctx.request_repaint_after(FRAME.min(SAVE_CONFIRMATION - elapsed)); + ease_out_cubic(elapsed.as_secs_f32() / SAVE_CONFIRMATION.as_secs_f32()) +} + +/// A static, accessible busy cue when animation is disabled. +pub(super) fn busy(ui: &mut egui::Ui, reduce_motion: bool) -> egui::Response { + if reduce_motion { + ui.label("Working…") + } else { + ui.spinner() + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn page_arrival_uses_the_thumbnail_only_during_motion_and_never_on_replacement() { + use super::super::{PageTextureView, ThumbnailTexture, annotation_saving}; + use crate::render_worker::PreparedPage; + use egui::{Color32, ColorImage, Pos2, Rect, TextureOptions, Vec2}; + let (mut app, ctx, _worker) = annotation_saving::tests::fixture(); + app.settings.dim_original_pages = false; + let image = || ColorImage::new([2, 2], vec![Color32::WHITE; 4]); + let thumbnail = ctx.load_texture("motion-thumb", image(), TextureOptions::LINEAR); + let page = ctx.load_texture("motion-page", image(), TextureOptions::LINEAR); + app.thumbnail_textures.insert( + 0, + ThumbnailTexture { + texture: thumbnail.clone(), + render_scale: 1.0, + }, + ); + let view = PageTextureView { + texture_id: page.id(), + fade_started: Some(Instant::now() + Duration::from_secs(1)), + }; + let draw = |app: &super::super::PdfEditorApp, ctx: &Context, use_thumbnail| { + ctx.run(Default::default(), |ctx| { + egui::CentralPanel::default().show(ctx, |ui| { + let rect = Rect::from_min_size(Pos2::new(20.0, 20.0), Vec2::new(100.0, 100.0)); + app.paint_page_texture(ctx, ui.painter(), rect, 0, view, use_thumbnail); + }); + }) + }; + let images = |output: &egui::FullOutput| { + output + .shapes + .iter() + .filter_map(|shape| match &shape.shape { + egui::Shape::Mesh(mesh) + if mesh.texture_id == thumbnail.id() || mesh.texture_id == page.id() => + { + Some((mesh.texture_id, mesh.vertices[0].color.a())) + } + _ => None, + }) + .collect::>() + }; + let animated = draw(&app, &ctx, true); + assert_eq!( + images(&animated), + vec![(thumbnail.id(), 255), (page.id(), 0)] + ); + app.settings.reduce_motion = true; + assert_eq!(images(&draw(&app, &ctx, true)), vec![(page.id(), 255)]); + app.settings.reduce_motion = false; + assert_eq!(images(&draw(&app, &ctx, false)), vec![(page.id(), 255)]); + let make_page = || PreparedPage { + page_index: 0, + width: 2, + height: 2, + image: image(), + }; + let _ = ctx.run(Default::default(), |ctx| { + app.install_page_texture(ctx, 7, make_page(), 1.0, 1.0) + }); + assert!(app.page_textures[&0].fade_started.is_some()); + let _ = ctx.run(Default::default(), |ctx| { + app.install_page_texture(ctx, 7, make_page(), 1.1, 1.1) + }); + assert!( + app.page_textures[&0].fade_started.is_none(), + "a sharper replacement must not flash a thumbnail" + ); + app.liquid_mode2_reveal_at = Some(10.0); + app.settings.reduce_motion = true; + assert_eq!(app.liquid_mode2_reveal_progress(10.0), 1.0); + } + + #[test] + fn original_page_dimming_only_changes_display_tint_for_page_and_placeholder() { + use super::super::{PageTextureView, ThumbnailTexture, annotation_saving}; + use egui::{Color32, ColorImage, Pos2, Rect, TextureOptions, Vec2}; + let (mut app, ctx, _worker) = annotation_saving::tests::fixture(); + app.settings.dim_original_pages = false; + let image = || ColorImage::new([2, 2], vec![Color32::WHITE; 4]); + let thumbnail = ctx.load_texture("dim-thumb", image(), TextureOptions::LINEAR); + let page = ctx.load_texture("dim-page", image(), TextureOptions::LINEAR); + app.thumbnail_textures.insert( + 0, + ThumbnailTexture { + texture: thumbnail.clone(), + render_scale: 1.0, + }, + ); + let draw = |app: &super::super::PdfEditorApp, placeholder| { + ctx.run(Default::default(), |ctx| { + egui::CentralPanel::default().show(ctx, |ui| { + let rect = Rect::from_min_size(Pos2::new(20.0, 20.0), Vec2::new(100.0, 100.0)); + if placeholder { + app.draw_page_loading_placeholder(ctx, ui.painter(), rect, 0); + } else { + app.paint_page_texture( + ctx, + ui.painter(), + rect, + 0, + PageTextureView { + texture_id: page.id(), + fade_started: None, + }, + true, + ); + } + }); + }) + }; + let mesh_color = |output: &egui::FullOutput, texture| { + output + .shapes + .iter() + .find_map(|shape| match &shape.shape { + egui::Shape::Mesh(mesh) if mesh.texture_id == texture => { + Some(mesh.vertices[0].color) + } + _ => None, + }) + .expect("original page image") + }; + assert_eq!(mesh_color(&draw(&app, false), page.id()), Color32::WHITE); + app.settings.dim_original_pages = true; + let tint = Color32::from_rgb(190, 180, 162); + assert_eq!(mesh_color(&draw(&app, false), page.id()), tint); + assert_eq!(mesh_color(&draw(&app, true), thumbnail.id()), tint); + app.settings.dim_original_pages = false; + assert_eq!(mesh_color(&draw(&app, false), page.id()), Color32::WHITE); + } + + #[test] + fn eased_curves_stop_and_reduced_motion_never_schedules_animation() { + assert_eq!(ease_out_cubic(0.0), 0.0); + assert_eq!(ease_out_cubic(1.0), 1.0); + assert_eq!(ease_in_cubic(1.0), 1.0); + assert_eq!(ease_in_out(0.5), 0.5); + let ctx = Context::default(); + let past = Instant::now() - Duration::from_secs(1); + for frame in 0..8 { + let output = ctx.run(Default::default(), |ctx| { + assert_eq!(page_opacity(ctx, past, false), 1.0); + assert_eq!(page_opacity(ctx, Instant::now(), true), 1.0); + egui::CentralPanel::default().show(ctx, |ui| { + busy(ui, true); + }); + }); + if frame >= 4 { + assert_eq!( + output.viewport_output[&egui::ViewportId::ROOT].repaint_delay, + Duration::MAX + ); + } + } + } + + #[test] + fn jump_duration_is_bounded_and_respects_reduce_motion() { + let none = egui::style::ScrollAnimation::none(); + let nearby = + egui::style::ScrollAnimation::new(1200.0, egui::emath::Rangef::new(0.0, JUMP_SECONDS)); + assert_eq!(jump_animation(140.0, 400.0, false), nearby); + assert_eq!(jump_animation(-1200.0, 400.0, false), nearby); + assert_eq!(jump_animation(1200.1, 400.0, false), none); + assert_eq!(jump_animation(140.0, 400.0, true), none); + assert_eq!(jump_animation(f32::NAN, 400.0, false), none); + assert_eq!(jump_animation(1.0, 0.0, false), none); + } + + #[test] + fn egui_jump_scrolls_then_settles_and_reduce_motion_lands_immediately() { + fn run(reduce: bool, target_y: f32) -> (f32, f32, Duration) { + let ctx = Context::default(); + let frame = |time: f64, jump: bool| { + let mut offset = 0.0; + let output = ctx.run( + egui::RawInput { + time: Some(time), + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(500.0, 400.0), + )), + ..Default::default() + }, + |ctx| { + egui::CentralPanel::default().show(ctx, |ui| { + let result = egui::ScrollArea::vertical() + .id_salt("motion-test") + .show_viewport(ui, |ui, _viewport| { + ui.set_min_height(3000.0); + if jump { + let clip = ui.clip_rect(); + let target = egui::Rect::from_min_size( + egui::pos2( + ui.min_rect().left(), + ui.min_rect().top() + target_y, + ), + egui::vec2(1.0, 1.0), + ); + ui.scroll_to_rect_animation( + target, + Some(egui::Align::TOP), + jump_animation( + target.top() - clip.top(), + clip.height(), + reduce, + ), + ); + } + }); + offset = result.state.offset.y; + }); + }, + ); + ( + offset, + output.viewport_output[&egui::ViewportId::ROOT].repaint_delay, + ) + }; + for i in 0..5 { + frame(i as f64 * 0.1, false); + } + frame(1.0, true); + let early = frame(1.05, false).0; + let end = frame(1.6, false).0; + let mut delay = Duration::ZERO; + for i in 0..8 { + delay = frame(2.0 + i as f64 * 0.2, false).1; + } + (early, end, delay) + } + let (moving, end, delay) = run(false, 650.0); + assert!( + moving > 0.0 && moving < end, + "nearby jump should animate: {moving} -> {end}" + ); + assert!(end > 600.0); + assert_eq!(delay, Duration::MAX); + let (instant, reduced_end, reduced_delay) = run(true, 650.0); + assert!((instant - reduced_end).abs() < 0.1); + assert!(reduced_end > 600.0); + assert_eq!(reduced_delay, Duration::MAX); + let (far, far_end, _) = run(false, 2500.0); + assert!( + (far - far_end).abs() < 0.1, + "distant jump should be immediate" + ); + } + + #[test] + fn save_confirmation_stops_repainting_and_reduce_motion_is_immediate() { + let ctx = Context::default(); + let started = Instant::now(); + let _ = ctx.run(Default::default(), |ctx| { + assert!(save_progress(ctx, started, false) < 1.0); + }); + for frame in 0..8 { + let output = ctx.run(Default::default(), |ctx| { + assert_eq!(save_progress(ctx, started, true), 1.0); + assert_eq!( + save_progress(ctx, Instant::now() - Duration::from_secs(1), false), + 1.0 + ); + }); + if frame >= 4 { + assert_eq!( + output.viewport_output[&egui::ViewportId::ROOT].repaint_delay, + Duration::MAX + ); + } + } + } +} diff --git a/src/app/print_ui.rs b/src/app/print_ui.rs index 43f390a..54d3713 100644 --- a/src/app/print_ui.rs +++ b/src/app/print_ui.rs @@ -181,7 +181,7 @@ impl PdfEditorApp { .title_bar(false) .frame( egui::Frame::window(&ctx.style()) - .fill(BAR_FILL) + .fill(self.app_palette().bar) .corner_radius(8) .inner_margin(Margin::symmetric(16, 14)), ) @@ -194,7 +194,7 @@ impl PdfEditorApp { pending.copy.title() )) .small() - .color(MUTED_INK), + .color(self.app_palette().muted), ); ui.add_space(8.0); @@ -237,12 +237,12 @@ impl PdfEditorApp { ui.label( RichText::new("Leave Pages empty to print everything, or enter 1-4,7.") .small() - .color(MUTED_INK), + .color(self.app_palette().muted), ); if let Some(error) = &pending.error { ui.add_space(4.0); - ui.label(RichText::new(error).small().color(egui::Color32::LIGHT_RED)); + ui.label(RichText::new(error).small().color(ui.visuals().error_fg_color)); } ui.add_space(8.0); diff --git a/src/app/print_windows_ui.rs b/src/app/print_windows_ui.rs index ac3ffe2..b506746 100644 --- a/src/app/print_windows_ui.rs +++ b/src/app/print_windows_ui.rs @@ -396,6 +396,8 @@ impl PdfEditorApp { if !self.print_ui.open { return; } + let reduce_motion = self.settings.reduce_motion; + let palette = self.app_palette(); let print = &mut self.print_ui; let Some(source) = print.source.as_ref() else { return; @@ -411,20 +413,21 @@ impl PdfEditorApp { let mut submit = None; let response = egui::Modal::new(egui::Id::new("lawpdf-print")) .backdrop_color(Color32::from_black_alpha(125)) - .frame(egui::Frame::NONE.fill(BAR_FILL).corner_radius(16) - .inner_margin(22).stroke(Stroke::new(1.0_f32, PAPER_STROKE)) + .frame(egui::Frame::NONE.fill(palette.bar).corner_radius(16) + .inner_margin(22).stroke(Stroke::new(1.0_f32, palette.rule)) .shadow(Shadow { offset: [0, 12], blur: 40, spread: 1, color: Color32::from_black_alpha(60) })) .show(ctx, |ui| { ui.set_width(width); - ui.visuals_mut().selection.bg_fill = Color32::from_rgb(216, 235, 229); - ui.visuals_mut().selection.stroke = Stroke::new(1.0_f32, ACCENT); + let highlight = if ui.visuals().dark_mode { Color32::from_rgb(55, 84, 78) } else { Color32::from_rgb(216, 235, 229) }; + ui.visuals_mut().selection.bg_fill = highlight; + ui.visuals_mut().selection.stroke = Stroke::new(1.0_f32, palette.accent); ui.spacing_mut().item_spacing = Vec2::new(10.0, 9.0); ui.horizontal(|ui| { let (rect, _) = ui.allocate_exact_size(Vec2::splat(44.0), Sense::hover()); - paint_printer(ui.painter(), rect, ACCENT); + paint_printer(ui.painter(), rect, palette.accent); ui.vertical(|ui| { - ui.label(RichText::new("Print document").size(25.0).strong().color(INK)); - ui.add(egui::Label::new(RichText::new(&title).color(SUBTLE)).truncate()); + ui.label(RichText::new("Print document").size(25.0).strong().color(palette.ink)); + ui.add(egui::Label::new(RichText::new(&title).color(palette.muted)).truncate()); }); ui.with_layout(egui::Layout::right_to_left(Align::Center), |ui| { if ui.add_enabled(!print.busy, egui::Button::new("×").frame(false)) @@ -439,7 +442,7 @@ impl PdfEditorApp { ui.horizontal_top(|ui| { let preview_width = width - 335.0; ui.allocate_ui_with_layout(Vec2::new(preview_width, body_height - 10.0), egui::Layout::top_down(Align::Min), |ui| { - preview_panel(ui, print, preview_width, body_height - 65.0, page_count); + preview_panel(ui, print, preview_width, body_height - 65.0, page_count, reduce_motion); }); ui.add_space(8.0); ui.allocate_ui_with_layout(Vec2::new(305.0, body_height - 10.0), egui::Layout::top_down(Align::Min), |ui| { @@ -447,17 +450,17 @@ impl PdfEditorApp { }); }); } else { - preview_panel(ui, print, width, 220.0, page_count); + preview_panel(ui, print, width, 220.0, page_count, reduce_motion); ui.add_space(10.0); ui.add_enabled_ui(!print.busy, |ui| controls(ui, print, page_count, ctx)); } }); ui.separator(); if let Some(error) = &print.error { - ui.colored_label(Color32::from_rgb(167, 49, 41), error); + ui.colored_label(ui.visuals().error_fg_color, error); } if let Some(message) = &print.message { - ui.colored_label(ACCENT, message); + ui.colored_label(palette.accent, message); } if print.busy { let (done, total) = print.progress; @@ -471,7 +474,7 @@ impl PdfEditorApp { let sides = pages.len().div_ceil(print.options.pages_per_sheet); let sheets = if print.duplex == "simplex" { sides } else { sides.div_ceil(2) }; ui.label(RichText::new(format!("{} pages · {} sheets × {} copies", - pages.len(), sheets, print.options.copies)).small().color(SUBTLE)); + pages.len(), sheets, print.options.copies)).small().color(palette.muted)); } ui.with_layout(egui::Layout::right_to_left(Align::Center), |ui| { let ready = !print.busy && !print.discovering @@ -511,8 +514,8 @@ impl PdfEditorApp { fn section(ui: &mut egui::Ui, number: &str, title: &str) { ui.add_space(5.0); ui.horizontal(|ui| { - ui.label(RichText::new(number).size(10.0).color(ACCENT).strong()); - ui.label(RichText::new(title).size(16.0).strong().color(INK)); + ui.label(RichText::new(number).size(10.0).color(theme::for_ui(ui).accent).strong()); + ui.label(RichText::new(title).size(16.0).strong().color(theme::for_ui(ui).ink)); }); } @@ -547,7 +550,7 @@ fn controls(ui: &mut egui::Ui, print: &mut PrintUi, page_count: usize, ctx: &Con ui.label( RichText::new(error) .small() - .color(Color32::from_rgb(150, 71, 40)), + .color(ui.visuals().error_fg_color), ); } ui.horizontal(|ui| { @@ -576,7 +579,7 @@ fn controls(ui: &mut egui::Ui, print: &mut PrintUi, page_count: usize, ctx: &Con ui.label( RichText::new(format!("{page_count} total")) .small() - .color(SUBTLE), + .color(theme::for_ui(ui).muted), ); }); ui.add( @@ -588,7 +591,7 @@ fn controls(ui: &mut egui::Ui, print: &mut PrintUi, page_count: usize, ctx: &Con ui.label( RichText::new(error) .small() - .color(Color32::from_rgb(167, 49, 41)), + .color(ui.visuals().error_fg_color), ); } ui.horizontal(|ui| { @@ -707,7 +710,7 @@ fn controls(ui: &mut egui::Ui, print: &mut PrintUi, page_count: usize, ctx: &Con ui.label( RichText::new("Content beyond the selected margins is cropped.") .small() - .color(SUBTLE), + .color(theme::for_ui(ui).muted), ); } ui.separator(); @@ -717,11 +720,11 @@ fn controls(ui: &mut egui::Ui, print: &mut PrintUi, page_count: usize, ctx: &Con &mut print.options.include_annotations, "Include annotations", ); - ui.label(RichText::new("Prints the original PDF layout, including filled forms. Preview includes your current edits.").small().color(SUBTLE)); + ui.label(RichText::new("Prints the original PDF layout, including filled forms. Preview includes your current edits.").small().color(theme::for_ui(ui).muted)); ui.label( RichText::new("300 DPI · Printer margins may trim edge content.") .small() - .color(SUBTLE), + .color(theme::for_ui(ui).muted), ); } @@ -731,10 +734,13 @@ fn preview_panel( width: f32, height: f32, page_count: usize, + reduce_motion: bool, ) { let sheet_count = print.options.sheet_count(page_count).unwrap_or(0); + let preview_background = if ui.visuals().dark_mode { theme::for_ui(ui).panel } else { Color32::from_rgb(228, 230, 225) }; + let preview_label = if ui.visuals().dark_mode { theme::for_ui(ui).muted } else { SUBTLE }; egui::Frame::NONE - .fill(Color32::from_rgb(228, 230, 225)) + .fill(preview_background) .corner_radius(10) .inner_margin(16) .show(ui, |ui| { @@ -767,7 +773,7 @@ fn preview_panel( Align2::CENTER_CENTER, label, FontId::proportional(16.0), - SUBTLE, + preview_label, ); } }); @@ -790,7 +796,7 @@ fn preview_panel( }, sheet_count )) - .color(SUBTLE), + .color(theme::for_ui(ui).muted), ); if ui .add_enabled( @@ -804,7 +810,7 @@ fn preview_panel( } }); if print.preview_busy || print.preview_due.is_some() { - ui.spinner(); + motion::busy(ui, reduce_motion); } }); } diff --git a/src/app/reading_history.rs b/src/app/reading_history.rs new file mode 100644 index 0000000..5be8aec --- /dev/null +++ b/src/app/reading_history.rs @@ -0,0 +1,673 @@ +//! Local reading position, recent-document identity, and resume controls. + +use super::*; +use crate::settings::{ReadingView, RecentDocument, WindowPlacement}; + +const RESUME_NOTICE_DURATION: Duration = Duration::from_secs(8); + +pub(super) struct ResumeNotice { + shown_at: Instant, + document_epoch: u64, + page: usize, +} + +impl PdfEditorApp { + pub(super) fn clear_reading_history(&mut self) { + self.settings.recent_documents.clear(); + self.settings.last_session_paths.clear(); + self.settings.last_session_saved_unix = 0; + // Closing this same window must not silently recreate what was cleared. + self.session_history_suppressed = true; + self.recent_suppressed_epoch = self.document.as_ref().map(|_| self.document_epoch); + self.queue_settings_save(); + self.status = "Reading history cleared.".to_owned(); + } + + /// Record only after the close/save decision has succeeded. The active tab + /// opens first, followed by the remaining tabs in their existing order. + pub(super) fn remember_open_session(&mut self) { + if !self.settings.reopen_last_session || self.session_history_suppressed { + return; + } + let active = self.active_tab.filter(|index| *index < self.tabs.len()); + let paths = active + .into_iter() + .chain((0..self.tabs.len()).filter(|index| Some(*index) != active)) + .map(|index| { + self.tabs[index] + .document + .path + .to_string_lossy() + .into_owned() + }) + .collect::>(); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + self.settings.remember_session_paths(paths, now); + self.queue_settings_save(); + } + + pub(super) fn remember_window_placement(&mut self, ctx: &Context) { + let viewport = ctx.input(|input| input.viewport().clone()); + if viewport.minimized == Some(true) || viewport.fullscreen == Some(true) { + return; + } + let previous = self.settings.window_placement.clone(); + let Some(inner) = viewport.inner_rect else { + return; + }; + let mut placement = previous.clone().unwrap_or(WindowPlacement { + inner_size: [inner.width(), inner.height()], + outer_position: None, + maximized: false, + }); + placement.maximized = viewport.maximized.unwrap_or(placement.maximized); + if !placement.maximized { + let size = [inner.width().round(), inner.height().round()]; + if !size[0].is_finite() + || !size[1].is_finite() + || !(720.0..=7680.0).contains(&size[0]) + || !(520.0..=4320.0).contains(&size[1]) + { + return; + } + placement.inner_size = size; + if let Some(outer) = viewport.outer_rect { + let position = [outer.min.x.round(), outer.min.y.round()]; + if position + .iter() + .all(|value| value.is_finite() && value.abs() <= 16384.0) + { + placement.outer_position = Some(position); + } + } + } + if previous.as_ref() != Some(&placement) { + self.settings.window_placement = Some(placement); + self.queue_settings_save(); + ctx.request_repaint_after(SETTINGS_SAVE_DEBOUNCE); + } + } + + pub(super) fn draw_recent_documents(&mut self, ui: &mut egui::Ui, ctx: &Context, rect: Rect) { + let recent = self.settings.recent_documents.clone(); + let mut open = None; + ui.scope_builder(egui::UiBuilder::new().max_rect(rect), |ui| { + ui.label(RichText::new("Recent documents").strong().size(15.0)); + egui::ScrollArea::vertical() + .id_salt("recent-documents") + .auto_shrink([false, false]) + .show_rows(ui, 62.0, recent.len(), |ui, rows| { + for index in rows { + let item = &recent[index]; + let fallback = Path::new(&item.path) + .file_stem() + .and_then(|value| value.to_str()) + .unwrap_or("Untitled PDF"); + let title = if item.title.trim().is_empty() { + fallback + } else { + &item.title + }; + let response = ui + .add( + egui::Label::new(RichText::new(title).strong().size(15.0)) + .truncate() + .sense(Sense::click()), + ) + .on_hover_cursor(CursorIcon::PointingHand) + .on_hover_text(&item.path); + if response.clicked() { + open = Some(PathBuf::from(&item.path)); + } + let identity = [item.authors.trim(), item.citation.trim()] + .into_iter() + .filter(|s| !s.is_empty()) + .collect::>() + .join(" · "); + let detail = if identity.is_empty() { + format!("Page {}", item.page.saturating_add(1)) + } else { + format!("{identity} · page {}", item.page.saturating_add(1)) + }; + ui.add( + egui::Label::new(RichText::new(detail).size(12.0).color(self.app_palette().muted)) + .truncate(), + ); + ui.add_space(5.0); + } + }); + }); + if let Some(path) = open { + self.open_paths_in_tabs(vec![path], ctx, true); + } + } + + pub(super) fn apply_pending_reading_resume(&mut self, ctx: &Context) { + let Some(saved) = self.pending_reading_resume.take() else { + return; + }; + let Some(document) = self.document.as_ref() else { + return; + }; + let page = saved.page.min(document.page_count.saturating_sub(1)); + self.page_index = page; + self.scroll_target_page = Some(page); + self.thumbnail_scroll_target = Some(page); + let explicit_start_view = std::env::var_os("LAWPDF_START_VIEW").is_some(); + let view = if explicit_start_view { + self.view_mode + } else { + match saved.view { + ReadingView::Pdf => DocumentViewMode::Pdf, + ReadingView::Review => DocumentViewMode::LiquidMode2, + ReadingView::LegacyReview => DocumentViewMode::Liquid, + } + }; + if view == DocumentViewMode::Pdf { + self.pending_resume_fraction = Some((page, saved.page_fraction)); + } else { + self.set_view_mode(view, ctx); + self.pending_resume_review_block = saved.review_block; + } + if page > 0 || saved.page_fraction > 0.01 || view != DocumentViewMode::Pdf { + self.resume_notice = Some(ResumeNotice { + shown_at: Instant::now(), + document_epoch: self.document_epoch, + page, + }); + } + ctx.request_repaint(); + } + + pub(super) fn finish_pending_review_resume(&mut self, ctx: &Context) { + let Some(block) = self.pending_resume_review_block else { + return; + }; + let (ready, complete) = match self.view_mode { + DocumentViewMode::LiquidMode2 => (&self.liquid_mode2_state, self.liquid_mode2_complete), + DocumentViewMode::Liquid => (&self.liquid_state, true), + DocumentViewMode::Pdf => { + self.pending_resume_review_block = None; + return; + } + }; + let LiquidState::Ready(document) = ready else { + return; + }; + if block >= document.blocks.len() && !complete { + self.request_full_review_prepare(ctx); + return; + } + let mask = review_hidden_display_mask(&document.blocks); + let target = document + .blocks + .iter() + .enumerate() + .filter(|(index, candidate)| { + candidate.role != LiquidBlockRole::Metadata + && !is_review_note_display_block(candidate) + && !review_skips_block_as_furniture( + candidate, + mask.get(*index).copied().unwrap_or(false), + ) + }) + .map(|(index, _)| index) + .min_by_key(|index| index.abs_diff(block)); + self.pending_resume_review_block = None; + if let Some(target) = target { + self.liquid_scroll_to_block = Some(target); + self.liquid_scroll_instant = true; + ctx.request_repaint(); + } + } + + pub(super) fn remember_reading_position(&mut self, ctx: &Context) { + if self.pending_reading_resume.is_some() + || self.pending_resume_fraction.is_some() + || self.pending_resume_review_block.is_some() + || self.liquid_scroll_to_block.is_some() + || self.recent_suppressed_epoch == Some(self.document_epoch) + { + return; + } + let Some(path) = self.document.as_ref().map(|doc| doc.path.clone()) else { + return; + }; + let prior = self + .settings + .recent_document_for_opened_path(&path) + .cloned(); + let (page, fraction, view, review_block) = match self.view_mode { + DocumentViewMode::Pdf => { + let (page, fraction) = if self.sbs_mode.is_some() { + ( + self.page_index, + prior + .as_ref() + .filter(|item| item.page == self.page_index) + .map_or(0.0, |item| item.page_fraction), + ) + } else { + let Some(visible) = self.visible_page_ranges.first() else { + return; + }; + ( + visible.page_index, + (visible.top_fraction.clamp(0.0, 1.0) * 200.0).round() / 200.0, + ) + }; + ( + page, + fraction, + ReadingView::Pdf, + prior.as_ref().and_then(|item| item.review_block), + ) + } + DocumentViewMode::Liquid | DocumentViewMode::LiquidMode2 => { + let state = match self.view_mode { + DocumentViewMode::Liquid => &self.liquid_state, + _ => &self.liquid_mode2_state, + }; + let LiquidState::Ready(review) = state else { + return; + }; + let view = if self.view_mode == DocumentViewMode::Liquid { + ReadingView::LegacyReview + } else { + ReadingView::Review + }; + let anchor = self + .search_state + .review_anchor + .or_else(|| prior.as_ref().and_then(|item| item.review_block)); + let page = anchor + .and_then(|anchor| { + review + .block_source_lines + .iter() + .find(|source| source.block_index == anchor) + .and_then(|source| source.lines.first()) + .map(|line| line.page_index) + }) + .unwrap_or_else(|| prior.as_ref().map_or(self.page_index, |item| item.page)) + .min( + self.document + .as_ref() + .map_or(0, |document| document.page_count.saturating_sub(1)), + ); + ( + page, + prior + .as_ref() + .filter(|item| item.page == page) + .map_or(0.0, |item| item.page_fraction), + view, + anchor, + ) + } + }; + let identity = self.masthead_identity_for_view(); + let mut record = RecentDocument { + path: path.to_string_lossy().into_owned(), + title: identity.title, + authors: identity.authors, + citation: identity.citation, + page, + page_fraction: fraction, + view, + review_block, + last_seen_unix: prior.as_ref().map_or(0, |item| item.last_seen_unix), + }; + let touch = self.recent_needs_touch; + if !touch && prior.as_ref() == Some(&record) { + return; + } + record.last_seen_unix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + self.recent_needs_touch = false; + if self.settings.remember_recent_document(record, touch) { + self.queue_settings_save(); + ctx.request_repaint_after(SETTINGS_SAVE_DEBOUNCE); + } + } + + pub(super) fn draw_resume_notice(&mut self, ctx: &Context) { + let Some(notice) = self.resume_notice.as_ref() else { + return; + }; + let elapsed = notice.shown_at.elapsed(); + if notice.document_epoch != self.document_epoch || elapsed >= RESUME_NOTICE_DURATION { + self.resume_notice = None; + return; + } + let page = notice.page; + let mut undo = false; + egui::Area::new(egui::Id::new("reading-resume-notice")) + .order(egui::Order::Foreground) + .anchor(Align2::CENTER_TOP, Vec2::new(0.0, 76.0)) + .show(ctx, |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(format!("Resumed at page {}", page + 1)); + undo = ui.button("Undo").clicked(); + }); + }); + }); + if undo { + self.pending_resume_fraction = None; + self.pending_resume_review_block = None; + self.set_view_mode(DocumentViewMode::Pdf, ctx); + self.go_to_page(0); + self.resume_notice = None; + } else { + ctx.request_repaint_after(RESUME_NOTICE_DURATION - elapsed); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::PageInfo; + + fn text_center(shape: &egui::Shape, text: &str) -> Option { + match shape { + egui::Shape::Text(shape) if shape.galley.text() == text => { + Some(shape.pos + shape.galley.size() * 0.5) + } + egui::Shape::Vec(shapes) => shapes.iter().find_map(|shape| text_center(shape, text)), + _ => None, + } + } + + fn input(time: f64, events: Vec) -> egui::RawInput { + egui::RawInput { + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, Vec2::new(900.0, 700.0))), + time: Some(time), + events, + ..Default::default() + } + } + + fn click_events(point: Pos2, pressed: bool) -> Vec { + vec![egui::Event::PointerButton { + pos: point, + button: egui::PointerButton::Primary, + pressed, + modifiers: egui::Modifiers::NONE, + }] + } + + #[test] + fn session_saves_active_tab_first_only_on_clean_close_and_respects_clear_history() { + let (mut app, _ctx, _worker) = annotation_saving::tests::fixture(); + app.settings.reopen_last_session = true; + let first = app.tabs[0].document.path.clone(); + let second = first.with_file_name("second-session.pdf"); + let mut tab = app.tabs[0].clone(); + tab.document.path = second.clone(); + tab.document_epoch = 8; + app.tabs.push(tab); + app.active_tab = Some(1); + app.remember_open_session(); + assert_eq!( + app.settings.last_session_paths, + vec![ + second.to_string_lossy().into_owned(), + first.to_string_lossy().into_owned() + ] + ); + assert!(app.settings.last_session_saved_unix > 0); + app.clear_reading_history(); + app.remember_open_session(); + assert!(app.settings.last_session_paths.is_empty()); + assert_eq!(app.settings.last_session_saved_unix, 0); + assert!(app.session_history_suppressed); + + let (mut clean, ctx2, _worker2) = annotation_saving::tests::fixture(); + clean.settings.reopen_last_session = true; + let _ = ctx2.run(Default::default(), |ctx| clean.request_window_close(ctx)); + assert!(clean.allow_window_close); + assert_eq!(clean.settings.last_session_paths.len(), 1); + let (mut dirty, ctx3, _worker3) = annotation_saving::tests::fixture(); + dirty.settings.reopen_last_session = true; + dirty.annotations_dirty = true; + let _ = ctx3.run(Default::default(), |ctx| dirty.request_window_close(ctx)); + assert!(!dirty.allow_window_close); + assert!( + dirty.settings.last_session_paths.is_empty(), + "opening the unsaved prompt must not save a session" + ); + } + + #[test] + fn resume_restores_pdf_page_fraction_and_records_stable_position() { + let (mut app, ctx, _worker) = annotation_saving::tests::fixture(); + let doc = app.document.as_mut().unwrap(); + doc.page_count = 3; + doc.pages = vec![PageInfo::new(612.0, 792.0); 3]; + doc.native_text = vec!["one".into(), "two".into(), "three".into()]; + doc.native_text_loaded = vec![true; 3]; + doc.text_chars = vec![Some(Vec::new()); 3]; + doc.links = vec![Vec::new(); 3]; + let path = doc.path.to_string_lossy().into_owned(); + app.settings.sidebar_in_pdf = false; + app.pending_reading_resume = Some(RecentDocument { + path, + title: "Article".into(), + authors: String::new(), + citation: String::new(), + page: 1, + page_fraction: 0.4, + view: ReadingView::Pdf, + review_block: None, + last_seen_unix: 1, + }); + for frame in 0..4 { + let input = egui::RawInput { + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, Vec2::new(900.0, 600.0))), + time: Some(frame as f64 * 0.2), + ..Default::default() + }; + let _ = ctx.run(input, |ctx| { + app.apply_pending_reading_resume(ctx); + app.draw_document(ctx); + app.remember_reading_position(ctx); + }); + } + let visible = app.visible_page_ranges.first().unwrap(); + assert_eq!(visible.page_index, 1); + assert!( + (visible.top_fraction - 0.4).abs() < 0.02, + "restored fraction was {}", + visible.top_fraction + ); + assert!(app.resume_notice.is_some()); + let saved = app.settings.recent_documents.first().unwrap(); + assert_eq!(saved.page, 1); + assert!((saved.page_fraction - 0.4).abs() < 0.02); + app.settings_save_due = None; + let _ = ctx.run(Default::default(), |ctx| app.remember_reading_position(ctx)); + assert!( + app.settings_save_due.is_none(), + "an unchanged reading position must not schedule writes" + ); + } + + #[test] + fn window_state_ignores_maximized_geometry_and_remembers_normal_size() { + let (mut app, ctx, _worker) = annotation_saving::tests::fixture(); + let mut viewport = egui::ViewportInfo { + inner_rect: Some(Rect::from_min_size( + Pos2::new(100.0, 100.0), + Vec2::new(1000.0, 700.0), + )), + outer_rect: Some(Rect::from_min_size( + Pos2::new(90.0, 70.0), + Vec2::new(1020.0, 740.0), + )), + maximized: Some(false), + ..Default::default() + }; + let mut frame = |viewport: egui::ViewportInfo, app: &mut PdfEditorApp| { + let mut input = egui::RawInput::default(); + input.viewports.insert(egui::ViewportId::ROOT, viewport); + let _ = ctx.run(input, |ctx| app.remember_window_placement(ctx)); + }; + frame(viewport.clone(), &mut app); + viewport.maximized = Some(true); + viewport.inner_rect = Some(Rect::from_min_size(Pos2::ZERO, Vec2::new(1920.0, 1080.0))); + frame(viewport, &mut app); + let saved = app.settings.window_placement.unwrap(); + assert_eq!(saved.inner_size, [1000.0, 700.0]); + assert_eq!(saved.outer_position, Some([90.0, 70.0])); + assert!(saved.maximized); + } + + #[test] + fn clicking_recent_identity_queues_that_document() { + let (mut app, ctx, requests) = annotation_saving::tests::fixture(); + let path = app.document.as_ref().unwrap().path.clone(); + app.tabs.clear(); + app.active_tab = None; + app.clear_document_state(); + app.settings.recent_documents = vec![RecentDocument { + path: path.to_string_lossy().into_owned(), + title: "A Recent Article".into(), + authors: "Jane Author".into(), + citation: "Journal 1 (2026)".into(), + page: 0, + page_fraction: 0.0, + view: ReadingView::Pdf, + review_block: None, + last_seen_unix: 1, + }]; + let draw = |app: &mut PdfEditorApp, time, events| { + ctx.run(input(time, events), |ctx| app.draw_document(ctx)) + }; + draw(&mut app, 0.0, vec![]); + let output = draw(&mut app, 0.05, vec![]); + let point = output + .shapes + .iter() + .find_map(|shape| text_center(&shape.shape, "A Recent Article")) + .expect("recent document identity should be visible"); + draw(&mut app, 0.1, vec![egui::Event::PointerMoved(point)]); + draw(&mut app, 0.2, click_events(point, true)); + draw(&mut app, 0.3, click_events(point, false)); + assert!(requests.try_iter().any(|request| matches!(request, + RenderRequest::PrepareSources { paths, .. } if paths == vec![path.clone()]))); + } + + #[test] + fn resume_notice_undo_returns_to_the_first_page() { + let (mut app, ctx, _worker) = annotation_saving::tests::fixture(); + let doc = app.document.as_mut().unwrap(); + doc.page_count = 3; + doc.pages = vec![PageInfo::new(612.0, 792.0); 3]; + let path = doc.path.to_string_lossy().into_owned(); + app.pending_reading_resume = Some(RecentDocument { + path, + title: "Article".into(), + authors: String::new(), + citation: String::new(), + page: 2, + page_fraction: 0.2, + view: ReadingView::Pdf, + review_block: None, + last_seen_unix: 1, + }); + app.apply_pending_reading_resume(&ctx); + let draw = |app: &mut PdfEditorApp, time, events| { + ctx.run(input(time, events), |ctx| app.draw_resume_notice(ctx)) + }; + draw(&mut app, 0.0, vec![]); + let output = draw(&mut app, 0.05, vec![]); + let point = output + .shapes + .iter() + .find_map(|shape| text_center(&shape.shape, "Undo")) + .expect("resume notice should offer Undo"); + draw(&mut app, 0.1, vec![egui::Event::PointerMoved(point)]); + draw(&mut app, 0.2, click_events(point, true)); + draw(&mut app, 0.3, click_events(point, false)); + assert_eq!(app.page_index, 0); + assert!(app.pending_resume_fraction.is_none()); + assert!(app.resume_notice.is_none()); + } + + #[test] + fn review_resume_scrolls_to_the_remembered_block() { + let (mut app, ctx, _worker) = annotation_saving::tests::fixture(); + let blocks = (0..40) + .map(|index| LiquidBlock { + role: LiquidBlockRole::Paragraph, + text: format!( + "Paragraph {index}: {}", + "A long paragraph of ordinary article text. ".repeat(5) + ), + label: None, + }) + .collect::>(); + let document = LiquidDocument { + title: "Article".into(), + blocks, + article_spans: Vec::new(), + block_source_lines: Vec::new(), + footnote_links: Vec::new(), + footnote_link_integrity: None, + profile: None, + noise_lines_removed: 0, + llm_used: false, + llm_provider: None, + deep_liquid_used: false, + deep_liquid_model: None, + warnings: Vec::new(), + source_signature: "resume-review".into(), + }; + app.view_mode = DocumentViewMode::LiquidMode2; + app.liquid_mode2_state = LiquidState::Ready(Arc::new(document)); + app.liquid_mode2_complete = true; + app.liquid_hide_footnotes = true; + app.pending_resume_review_block = Some(20); + for frame in 0..5 { + let _ = ctx.run(input(frame as f64 * 0.2, vec![]), |ctx| { + app.finish_pending_review_resume(ctx); + egui::CentralPanel::default() + .show(ctx, |ui| app.draw_liquid_mode2_document(ui, ctx)); + }); + } + let anchor = app + .search_state + .review_anchor + .expect("a Review block should be visible"); + assert!( + (18..=21).contains(&anchor), + "Review resumed at block {anchor} rather than block 20" + ); + } + + #[test] + fn review_history_records_the_source_page_of_the_visible_block() { + let (mut app, ctx, _worker) = annotation_saving::tests::fixture(); + app.document.as_mut().unwrap().page_count = 3; + let review: LiquidDocument = serde_json::from_value(serde_json::json!({ + "title":"Article", "blocks":[{"role":"paragraph","text":"A passage"}], + "block_source_lines":[{"block_index":0,"lines":[{"page_index":2,"line_index":0,"text":"A passage","role":"paragraph"}]}], + "llm_used":false,"warnings":[],"source_signature":"source-page" + })).unwrap(); + app.view_mode = DocumentViewMode::LiquidMode2; + app.liquid_mode2_state = LiquidState::Ready(Arc::new(review)); + app.search_state.review_anchor = Some(0); + let _ = ctx.run(input(0.0, vec![]), |ctx| app.remember_reading_position(ctx)); + let saved = app.settings.recent_documents.first().unwrap(); + assert_eq!(saved.page, 2); + assert_eq!(saved.review_block, Some(0)); + assert_eq!(saved.view, ReadingView::Review); + } +} diff --git a/src/app/reading_navigation.rs b/src/app/reading_navigation.rs new file mode 100644 index 0000000..493b6b6 --- /dev/null +++ b/src/app/reading_navigation.rs @@ -0,0 +1,273 @@ +//! Keyboard scrolling shared by the original, Review, and split readers. + +use eframe::egui::{self, Context, Key, Modifiers, Rect, Ui, Vec2}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Direction { + LineUp, + LineDown, + LineLeft, + LineRight, + PageUp, + PageDown, + Start, + End, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) struct ScrollCommand { + direction: Direction, + repeated: bool, +} + +impl ScrollCommand { + /// The Review/source split shows one complete original page at a time. + pub(super) fn page_target(self, current: usize, count: usize) -> usize { + let last = count.saturating_sub(1); + match self.direction { + Direction::Start => 0, + Direction::End => last, + Direction::LineUp | Direction::LineLeft | Direction::PageUp => { + current.saturating_sub(1) + } + Direction::LineDown | Direction::LineRight | Direction::PageDown => { + current.saturating_add(1).min(last) + } + } + } +} + +fn command_for_key(key: Key, modifiers: Modifiers, repeated: bool) -> Option { + if modifiers.alt || modifiers.mac_cmd && !modifiers.command { + return None; + } + let command = modifiers.ctrl || modifiers.command || modifiers.mac_cmd; + let direction = if command { + if modifiers.shift { + return None; + } + match key { + Key::Home => Direction::Start, + Key::End => Direction::End, + _ => return None, + } + } else { + match (key, modifiers.shift) { + (Key::Space, true) => Direction::PageUp, + (Key::Space | Key::PageDown, false) => Direction::PageDown, + (Key::PageUp, false) => Direction::PageUp, + (Key::ArrowDown | Key::J, false) => Direction::LineDown, + (Key::ArrowUp | Key::K, false) => Direction::LineUp, + (Key::ArrowLeft, false) => Direction::LineLeft, + (Key::ArrowRight, false) => Direction::LineRight, + (Key::Home, false) => Direction::Start, + (Key::End, false) => Direction::End, + _ => return None, + } + }; + Some(ScrollCommand { + direction, + repeated, + }) +} + +/// Leave all input untouched when a text editor owns keyboard focus. +pub(super) fn consume(ctx: &Context, enabled: bool) -> Option { + if !enabled { + return None; + } + ctx.input_mut(|input| { + let mut command = None; + input.events.retain(|event| { + let egui::Event::Key { + key, + modifiers, + repeat, + pressed: true, + .. + } = event + else { + return true; + }; + if let Some(next) = command_for_key(*key, *modifiers, *repeat) { + command = Some(next); + false + } else { + true + } + }); + command + }) +} + +/// Called at the end of the target scroll area's contents, after its extent is known. +pub(super) fn apply(ui: &Ui, viewport: Rect, command: ScrollCommand, reduce_motion: bool) { + let line = ui.text_style_height(&egui::TextStyle::Body).max(12.0); + let page = (viewport.height() - 2.0 * line).max(line); + let remaining = (ui.min_rect().height() - viewport.bottom()).max(0.0); + // Egui's delta moves the content, so a negative y moves down the document. + let delta = match command.direction { + Direction::LineUp => Vec2::new(0.0, 3.0 * line), + Direction::LineDown => Vec2::new(0.0, -3.0 * line), + Direction::LineLeft => Vec2::new(3.0 * line, 0.0), + Direction::LineRight => Vec2::new(-3.0 * line, 0.0), + Direction::PageUp => Vec2::new(0.0, page), + Direction::PageDown => Vec2::new(0.0, -page), + Direction::Start => Vec2::new(0.0, viewport.top()), + Direction::End => Vec2::new(0.0, -remaining), + }; + let animation = if reduce_motion || command.repeated { + egui::style::ScrollAnimation::none() + } else { + egui::style::ScrollAnimation::duration(super::motion::PAGING_SECONDS) + }; + ui.scroll_with_delta_animation(delta, animation); +} + +pub(super) fn draw_shortcuts(ctx: &Context, open: &mut bool) { + if !*open { + return; + } + let command = if cfg!(target_os = "macos") { + "⌘" + } else { + "Ctrl+" + }; + egui::Window::new("Keyboard shortcuts") + .open(open) + .collapsible(false) + .resizable(false) + .show(ctx, |ui| { + egui::Grid::new("reading-shortcuts") + .spacing([24.0, 8.0]) + .show(ui, |ui| { + for (action, keys) in [ + ("Scroll down / up", "↓ / ↑ or J / K".to_owned()), + ("Page down / up", "Page Down / Page Up".to_owned()), + ("Page down / up", "Space / Shift+Space".to_owned()), + ("Start / end of document", "Home / End".to_owned()), + ("Go to page number", format!("{command}G")), + ("Back / forward", "Alt+Left / Alt+Right".to_owned()), + ( + "Fit page / actual size / fit width", + format!("{command}0 / {command}1 / {command}2"), + ), + ("Open document", format!("{command}O")), + ("Find", format!("{command}F")), + ("Copy selection", format!("{command}C")), + ("Copy selection with citation", format!("{command}Alt+C")), + ("Copy article as Markdown", format!("{command}Shift+C")), + ("Show this sheet", "?".to_owned()), + ] { + ui.label(action); + ui.label(keys); + ui.end_row(); + } + }); + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reading_keys_map_without_stealing_other_shortcuts() { + let plain = Modifiers::NONE; + let command = |key, mods| command_for_key(key, mods, false).map(|c| c.direction); + assert_eq!(command(Key::PageDown, plain), Some(Direction::PageDown)); + assert_eq!( + command(Key::Space, Modifiers::SHIFT), + Some(Direction::PageUp) + ); + assert_eq!(command(Key::J, plain), Some(Direction::LineDown)); + assert_eq!(command(Key::K, plain), Some(Direction::LineUp)); + assert_eq!(command(Key::Home, Modifiers::CTRL), Some(Direction::Start)); + assert_eq!(command(Key::End, Modifiers::CTRL), Some(Direction::End)); + assert_eq!(command(Key::ArrowLeft, Modifiers::ALT), None); + assert_eq!(command(Key::J, Modifiers::CTRL), None); + } + + #[test] + fn source_pane_page_navigation_stays_in_bounds() { + let start = command_for_key(Key::Home, Modifiers::NONE, false).unwrap(); + let end = command_for_key(Key::End, Modifiers::CTRL, false).unwrap(); + let down = command_for_key(Key::PageDown, Modifiers::NONE, false).unwrap(); + assert_eq!(start.page_target(3, 8), 0); + assert_eq!(end.page_target(3, 8), 7); + assert_eq!(down.page_target(7, 8), 7); + } + + #[test] + fn text_focus_leaves_reading_keys_for_the_editor() { + let ctx = Context::default(); + let input = egui::RawInput { + events: vec![egui::Event::Key { + key: Key::Space, + physical_key: None, + pressed: true, + repeat: false, + modifiers: Modifiers::NONE, + }], + ..Default::default() + }; + let _ = ctx.run(input, |ctx| { + assert!(consume(ctx, false).is_none()); + assert!(ctx.input(|input| !input.events.is_empty())); + assert_eq!(consume(ctx, true).unwrap().direction, Direction::PageDown); + assert!(ctx.input(|input| input.events.is_empty())); + }); + } + + #[test] + fn paging_changes_a_real_scroll_area_and_home_returns_to_start() { + let ctx = Context::default(); + let mut offset = 0.0; + for frame in 0..6 { + let input = egui::RawInput { + screen_rect: Some(Rect::from_min_size( + egui::Pos2::ZERO, + Vec2::new(600.0, 400.0), + )), + time: Some(frame as f64 * 0.2), + ..Default::default() + }; + let _ = ctx.run(input, |ctx| { + egui::CentralPanel::default().show(ctx, |ui| { + let output = egui::ScrollArea::vertical() + .id_salt("test-reader") + .auto_shrink([false, false]) + .show_viewport(ui, |ui, viewport| { + ui.add_space(2000.0); + if frame == 1 { + apply( + ui, + viewport, + command_for_key(Key::PageDown, Modifiers::NONE, false).unwrap(), + true, + ); + } else if frame == 4 { + apply( + ui, + viewport, + command_for_key(Key::Home, Modifiers::CTRL, false).unwrap(), + true, + ); + } + }); + offset = output.state.offset.y; + }); + }); + if frame == 3 { + assert!( + offset > 100.0, + "Page Down did not move the reader: {offset}" + ); + } + } + assert!( + offset < 1.0, + "Ctrl+Home did not return to the start: {offset}" + ); + } +} diff --git a/src/app/recovery_ui.rs b/src/app/recovery_ui.rs index 01eecec..b429194 100644 --- a/src/app/recovery_ui.rs +++ b/src/app/recovery_ui.rs @@ -259,8 +259,8 @@ impl PdfEditorApp { ui.heading("Recover unsaved annotations"); ui.label("LawPDF kept your edits and the PDF they belong to. Save a recovered copy to preserve both versions."); ui.label("Recovered copies may remove password protection or invalidate a digital signature. Your original stays unchanged."); - if let Some(error) = &self.recovery_ui.error { ui.colored_label(Color32::DARK_RED, error); } - if self.recovery_ui.busy { ui.spinner(); } + if let Some(error) = &self.recovery_ui.error { ui.colored_label(ui.visuals().error_fg_color, error); } + if self.recovery_ui.busy { motion::busy(ui, self.settings.reduce_motion); } egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| { for (index, item) in self.recovery_ui.items.iter().enumerate() { ui.group(|ui| { diff --git a/src/app/review_intro.rs b/src/app/review_intro.rs new file mode 100644 index 0000000..a3c9d6f --- /dev/null +++ b/src/app/review_intro.rs @@ -0,0 +1,99 @@ +//! A one-time, non-modal explanation of the Review view. +use super::*; + +impl PdfEditorApp { + pub(super) fn draw_review_intro(&mut self, ctx: &Context, panel: Rect) { + if self.settings.review_intro_dismissed || self.review_reveal.is_some() { + return; + } + let width = (panel.width() - 24.0).min(300.0); + if width < 160.0 { + return; + } + let position = Pos2::new(panel.right() - width - 12.0, panel.top() + 12.0); + egui::Area::new(egui::Id::new("review-introduction")) + .order(egui::Order::Foreground) + .fixed_pos(position) + .show(ctx, |ui| { + egui::Frame::NONE + .fill(theme::for_context(ctx).bar) + .stroke(Stroke::new(1.0_f32, theme::for_context(ctx).rule)) + .corner_radius(6) + .inner_margin(12) + .show(ui, |ui| { + ui.set_width(width - 24.0); + ui.heading("Reading in Review Mode"); + ui.label("Review reflows the text for reading. Choose Original above to return to the printed PDF."); + ui.label("Footnote numbers open notes; notes can also appear in the margin or below the text."); + if ui.button("Got it").clicked() { + self.settings.review_intro_dismissed = true; + self.queue_settings_save(); + } + }); + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn text_center(shape: &egui::Shape, text: &str) -> Option { + match shape { + egui::Shape::Text(shape) if shape.galley.text() == text => { + Some(shape.pos + shape.galley.size() * 0.5) + } + egui::Shape::Vec(shapes) => shapes.iter().find_map(|shape| text_center(shape, text)), + _ => None, + } + } + + #[test] + fn dismissing_intro_hides_it_and_queues_a_preference_save() { + let (mut app, ctx, _worker) = annotation_saving::tests::fixture(); + app.settings.review_intro_dismissed = false; + app.settings_save_due = None; + let panel = Rect::from_min_size(Pos2::ZERO, Vec2::new(800.0, 600.0)); + let draw = |app: &mut PdfEditorApp, time, events| { + ctx.run( + egui::RawInput { + screen_rect: Some(panel), + time: Some(time), + events, + ..Default::default() + }, + |ctx| app.draw_review_intro(ctx, panel), + ) + }; + // A newly created egui Area first measures itself before it paints. + let _ = draw(&mut app, 0.0, vec![]); + let output = draw(&mut app, 0.1, vec![]); + let point = output + .shapes + .iter() + .find_map(|shape| text_center(&shape.shape, "Got it")) + .expect("dismiss button"); + draw(&mut app, 0.2, vec![egui::Event::PointerMoved(point)]); + for (time, pressed) in [(0.3, true), (0.4, false)] { + draw( + &mut app, + time, + vec![egui::Event::PointerButton { + pos: point, + button: egui::PointerButton::Primary, + pressed, + modifiers: egui::Modifiers::NONE, + }], + ); + } + assert!(app.settings.review_intro_dismissed); + assert!(app.settings_save_due.is_some()); + let output = draw(&mut app, 0.5, vec![]); + assert!( + output + .shapes + .iter() + .all(|shape| text_center(&shape.shape, "Got it").is_none()) + ); + } +} diff --git a/src/app/review_layout.rs b/src/app/review_layout.rs new file mode 100644 index 0000000..f82545b --- /dev/null +++ b/src/app/review_layout.rs @@ -0,0 +1,584 @@ +//! Retained geometry for static Review rows. +use super::*; + +static NEXT_LAYOUT_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + +pub(super) fn next_layout_id() -> u64 { + NEXT_LAYOUT_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed) +} + +#[derive(Clone, Copy, Debug)] +pub(super) enum RowKind { + Block(usize), + Metadata { start: usize, end: usize }, +} + +#[derive(Clone, Copy, Debug)] +pub(super) struct Row { + pub(super) top: f32, + pub(super) bottom: f32, + pub(super) kind: RowKind, +} + +pub(super) struct ReviewLayout { + pub(super) id: u64, + pub(super) active_this_frame: bool, + pub(super) document: std::sync::Weak, + pub(super) width: u32, + pub(super) observed_width: u32, + pub(super) width_changed_at: Option, + pub(super) resize_factor: f32, + pub(super) previous_resize_factor: f32, + pub(super) resize_anchor: Option, + pub(super) indent: u32, + pub(super) scale: u32, + pub(super) pixels_per_point: u32, + pub(super) theme: LiquidTheme, + pub(super) hide_notes: bool, + pub(super) has_margin: bool, + pub(super) origin: Pos2, + pub(super) height: f32, + pub(super) rows: Vec, + pub(super) margin: Option, +} + +impl ReviewLayout { + pub(super) fn is_compatible( + &self, + document: &Arc, + _width: f32, + _indent: f32, + scale: f32, + pixels_per_point: f32, + theme: LiquidTheme, + hide_notes: bool, + has_margin: bool, + ) -> bool { + self.document + .upgrade() + .is_some_and(|cached| Arc::ptr_eq(&cached, document)) + && self.scale == scale.to_bits() + && self.pixels_per_point == pixels_per_point.to_bits() + && self.theme == theme + && self.hide_notes == hide_notes + && self.has_margin == has_margin + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn find_text(shape: &egui::Shape, wanted: &str) -> Option { + match shape { + egui::Shape::Text(text) if text.galley.text() == wanted => Some(text.pos), + egui::Shape::Vec(shapes) => shapes.iter().find_map(|shape| find_text(shape, wanted)), + _ => None, + } + } + + fn find_text_containing(shape: &egui::Shape, wanted: &str) -> Option { + match shape { + egui::Shape::Text(text) if text.galley.text().contains(wanted) => Some(text.pos), + egui::Shape::Vec(shapes) => shapes + .iter() + .find_map(|shape| find_text_containing(shape, wanted)), + _ => None, + } + } + + fn document(blocks: Vec) -> LiquidDocument { + LiquidDocument { + title: "Article".into(), + blocks, + article_spans: Vec::new(), + block_source_lines: Vec::new(), + footnote_links: Vec::new(), + footnote_link_integrity: None, + profile: None, + noise_lines_removed: 0, + llm_used: false, + llm_provider: None, + deep_liquid_used: false, + deep_liquid_model: None, + warnings: Vec::new(), + source_signature: "retained-test".into(), + } + } + + fn input(time: f64, events: Vec) -> egui::RawInput { + egui::RawInput { + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, Vec2::new(1100.0, 800.0))), + time: Some(time), + events, + ..Default::default() + } + } + + #[test] + fn retained_rows_keep_visible_text_at_the_measured_positions() { + let (mut app, ctx, _worker) = annotation_saving::tests::fixture(); + let blocks = (0..40) + .map(|index| LiquidBlock { + role: LiquidBlockRole::Paragraph, + text: format!("Paragraph {index}: {}", "Ordinary article text. ".repeat(6)), + label: None, + }) + .collect::>(); + let text = blocks[0].text.clone(); + let document = document(blocks); + app.view_mode = DocumentViewMode::LiquidMode2; + app.liquid_mode2_state = LiquidState::Ready(Arc::new(document)); + app.liquid_mode2_complete = true; + app.liquid_hide_footnotes = true; + let mut draw = |time: f64, app: &mut PdfEditorApp| { + ctx.run( + egui::RawInput { + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, Vec2::new(1100.0, 800.0))), + time: Some(time), + ..Default::default() + }, + |ctx| { + egui::CentralPanel::default() + .show(ctx, |ui| app.draw_liquid_mode2_document(ui, ctx)); + }, + ) + }; + let first = draw(0.0, &mut app); + let measured = app + .review_layout + .as_ref() + .expect("first frame should retain geometry") + .rows + .as_ptr(); + let second = draw(0.2, &mut app); + assert_eq!( + measured, + app.review_layout.as_ref().unwrap().rows.as_ptr(), + "the second frame should reuse the retained layout" + ); + let pos = |output: &egui::FullOutput| { + output + .shapes + .iter() + .find_map(|shape| find_text(&shape.shape, &text)) + }; + let a = pos(&first).expect("first paragraph should be visible"); + let b = pos(&second).expect("retained first paragraph should be visible"); + assert!((a - b).length() < 0.5, "text moved from {a:?} to {b:?}"); + } + + #[test] + fn retained_rows_keep_footnote_positions() { + let (mut app, ctx, _worker) = annotation_saving::tests::fixture(); + let mut blocks = Vec::new(); + for number in 1..=20 { + blocks.push(LiquidBlock { + role: LiquidBlockRole::Paragraph, + text: format!( + "The first paragraph cites{}{}{} an authority.", + crate::layout_roles::CALLOUT_START, + number, + crate::layout_roles::CALLOUT_END + ), + label: None, + }); + blocks.push(LiquidBlock { + role: LiquidBlockRole::Footnote, + text: format!("{number}. Footnote body {number} explains this."), + label: None, + }); + } + app.view_mode = DocumentViewMode::LiquidMode2; + app.liquid_mode2_state = LiquidState::Ready(Arc::new(document(blocks))); + app.liquid_mode2_complete = true; + let draw = |time, app: &mut PdfEditorApp| { + ctx.run(input(time, vec![]), |ctx| { + egui::CentralPanel::default() + .show(ctx, |ui| app.draw_liquid_mode2_document(ui, ctx)); + }) + }; + let first = draw(0.0, &mut app); + let retained = app.review_layout.as_ref().unwrap(); + assert!(retained.margin.is_some()); + let rows = retained.rows.as_ptr(); + let second = draw(0.2, &mut app); + assert_eq!(rows, app.review_layout.as_ref().unwrap().rows.as_ptr()); + let position = |output: &egui::FullOutput| { + output.shapes.iter().find_map(|shape| { + find_text_containing(&shape.shape, "Footnote body 1 explains this.") + }) + }; + let before = position(&first).expect("first footnote should be visible"); + let after = position(&second).expect("retained footnote should be visible"); + assert!( + (before - after).length() < 0.5, + "footnote moved from {before:?} to {after:?}" + ); + } + + #[test] + fn clicking_a_retained_margin_note_opens_it() { + let (mut app, ctx, _worker) = annotation_saving::tests::fixture(); + let blocks = vec![ + LiquidBlock { + role: LiquidBlockRole::Paragraph, + text: format!( + "A paragraph cites{}1{} the note.", + crate::layout_roles::CALLOUT_START, + crate::layout_roles::CALLOUT_END + ), + label: None, + }, + LiquidBlock { + role: LiquidBlockRole::Footnote, + text: "1. Footnote body is here.".into(), + label: None, + }, + ]; + app.view_mode = DocumentViewMode::LiquidMode2; + app.liquid_mode2_state = LiquidState::Ready(Arc::new(document(blocks))); + app.liquid_mode2_complete = true; + let draw = |time, events, app: &mut PdfEditorApp| { + ctx.run(input(time, events), |ctx| { + egui::CentralPanel::default() + .show(ctx, |ui| app.draw_liquid_mode2_document(ui, ctx)); + }) + }; + draw(0.0, vec![], &mut app); + let output = draw(0.1, vec![], &mut app); + let point = output + .shapes + .iter() + .find_map(|shape| find_text_containing(&shape.shape, "Footnote body is here.")) + .expect("footnote should be visible") + + Vec2::new(30.0, 8.0); + draw(0.2, vec![egui::Event::PointerMoved(point)], &mut app); + draw( + 0.3, + vec![egui::Event::PointerButton { + pos: point, + button: egui::PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::NONE, + }], + &mut app, + ); + draw( + 0.4, + vec![egui::Event::PointerButton { + pos: point, + button: egui::PointerButton::Primary, + pressed: false, + modifiers: egui::Modifiers::NONE, + }], + &mut app, + ); + assert_eq!(app.chrome.expanded_note.as_deref(), Some("1")); + } + + #[test] + fn retained_notes_match_full_layout_after_scrolling() { + let (mut app, ctx, _worker) = annotation_saving::tests::fixture(); + let mut blocks = Vec::new(); + for number in 1..=40 { + blocks.push(LiquidBlock { + role: LiquidBlockRole::Paragraph, + text: format!( + "Paragraph {number}: {}{}{} {}", + crate::layout_roles::CALLOUT_START, + number, + crate::layout_roles::CALLOUT_END, + "Some ordinary article text. ".repeat(5) + ), + label: None, + }); + blocks.push(LiquidBlock { + role: LiquidBlockRole::Footnote, + text: format!("{number}. Note {number} provides a citation."), + label: None, + }); + } + app.view_mode = DocumentViewMode::LiquidMode2; + app.liquid_mode2_state = LiquidState::Ready(Arc::new(document(blocks))); + app.liquid_mode2_complete = true; + let draw = |time, events, app: &mut PdfEditorApp| { + ctx.run(input(time, events), |ctx| { + egui::CentralPanel::default() + .show(ctx, |ui| app.draw_liquid_mode2_document(ui, ctx)); + }) + }; + draw(0.0, vec![], &mut app); + draw(0.1, vec![], &mut app); + app.liquid_scroll_to_block = Some(20); + app.liquid_scroll_instant = true; + draw(0.2, vec![], &mut app); + draw(0.4, vec![], &mut app); + let retained = draw(0.6, vec![], &mut app); + let mut visible = None; + for number in 2..=20 { + let text = format!("Note {number} provides a citation."); + if let Some(position) = retained + .shapes + .iter() + .find_map(|shape| find_text_containing(&shape.shape, &text)) + { + visible = Some((text, position)); + break; + } + } + let (text, position) = visible.expect("a later note should be visible after scrolling"); + app.review_layout = None; + app.review_margin_rows = None; + let full = draw(0.8, vec![], &mut app); + let expected = full + .shapes + .iter() + .find_map(|shape| find_text_containing(&shape.shape, &text)) + .expect("the same note should be visible in the full layout"); + assert!( + (position - expected).length() < 0.75, + "retained note moved from {expected:?} to {position:?}" + ); + } + + #[test] + fn retained_notes_follow_a_margin_resize_after_scrolling() { + let (mut app, ctx, _worker) = annotation_saving::tests::fixture(); + let mut blocks = Vec::new(); + for number in 1..=40 { + blocks.push(LiquidBlock { + role: LiquidBlockRole::Paragraph, + text: format!( + "Paragraph {number}: {}{}{} {}", + crate::layout_roles::CALLOUT_START, + number, + crate::layout_roles::CALLOUT_END, + "Some ordinary article text. ".repeat(5) + ), + label: None, + }); + blocks.push(LiquidBlock { + role: LiquidBlockRole::Footnote, + text: format!("{number}. Note {number} provides a citation."), + label: None, + }); + } + app.view_mode = DocumentViewMode::LiquidMode2; + app.liquid_mode2_state = LiquidState::Ready(Arc::new(document(blocks))); + app.liquid_mode2_complete = true; + let draw = |time, events, width, app: &mut PdfEditorApp| { + let mut raw = input(time, events); + raw.screen_rect = Some(Rect::from_min_size(Pos2::ZERO, Vec2::new(width, 800.0))); + ctx.run(raw, |ctx| { + egui::CentralPanel::default() + .show(ctx, |ui| app.draw_liquid_mode2_document(ui, ctx)); + }) + }; + draw(0.0, vec![], 1300.0, &mut app); + draw(0.1, vec![], 1300.0, &mut app); + app.liquid_scroll_to_block = Some(20); + app.liquid_scroll_instant = true; + draw(0.2, vec![], 1300.0, &mut app); + draw(0.4, vec![], 1300.0, &mut app); + draw(0.6, vec![], 1300.0, &mut app); + let cached_id = app.review_layout.as_ref().unwrap().id; + let resized = draw(0.8, vec![], 1250.0, &mut app); + assert_eq!( + cached_id, + app.review_layout.as_ref().unwrap().id, + "body width stays fixed while its margins resize" + ); + let mut visible = None; + for number in 2..=20 { + let text = format!("Note {number} provides a citation."); + if let Some(position) = resized + .shapes + .iter() + .find_map(|shape| find_text_containing(&shape.shape, &text)) + { + visible = Some((text, position)); + break; + } + } + let (text, position) = visible.expect("a later note should be visible after scrolling"); + app.review_layout = None; + app.review_margin_rows = None; + let full = draw(1.0, vec![], 1250.0, &mut app); + let expected = full + .shapes + .iter() + .find_map(|shape| find_text_containing(&shape.shape, &text)) + .expect("the same note should be visible in the full layout"); + assert!( + (position - expected).length() < 0.75, + "resized note moved from {expected:?} to {position:?}" + ); + } + + #[test] + fn live_reflow_reuses_geometry_then_rebuilds_after_resize_settles() { + let (mut app, ctx, _worker) = annotation_saving::tests::fixture(); + let blocks = (0..40) + .map(|index| LiquidBlock { + role: LiquidBlockRole::Paragraph, + text: format!( + "Paragraph {index}: {}", + "Text that wraps with the column width. ".repeat(8) + ), + label: None, + }) + .collect::>(); + app.view_mode = DocumentViewMode::LiquidMode2; + app.liquid_mode2_state = LiquidState::Ready(Arc::new(document(blocks))); + app.liquid_mode2_complete = true; + app.liquid_hide_footnotes = true; + let draw = |time, width, app: &mut PdfEditorApp| { + let mut raw = input(time, vec![]); + raw.screen_rect = Some(Rect::from_min_size(Pos2::ZERO, Vec2::new(width, 800.0))); + ctx.run(raw, |ctx| { + egui::CentralPanel::default() + .show(ctx, |ui| app.draw_liquid_mode2_document(ui, ctx)); + }) + }; + draw(0.0, 760.0, &mut app); + draw(0.1, 760.0, &mut app); + let id = app.review_layout.as_ref().unwrap().id; + for (time, width) in [(0.2, 740.0), (0.3, 720.0), (0.4, 700.0)] { + draw(time, width, &mut app); + assert_eq!( + app.review_layout.as_ref().unwrap().id, + id, + "live resize should reuse the document layout" + ); + } + draw(0.6, 700.0, &mut app); + assert_ne!( + app.review_layout.as_ref().unwrap().id, + id, + "the settled width must get an exact layout" + ); + assert_eq!(app.review_layout.as_ref().unwrap().resize_factor, 1.0); + } + + #[test] + fn resizing_a_scrolled_article_keeps_the_nearby_passage_in_view() { + let (mut app, ctx, _worker) = annotation_saving::tests::fixture(); + let blocks = (0..80) + .map(|index| LiquidBlock { + role: LiquidBlockRole::Paragraph, + text: format!( + "Paragraph {index}: {}", + "Text that wraps with the column width. ".repeat(8) + ), + label: None, + }) + .collect::>(); + app.view_mode = DocumentViewMode::LiquidMode2; + app.liquid_mode2_state = LiquidState::Ready(Arc::new(document(blocks))); + app.liquid_mode2_complete = true; + app.liquid_hide_footnotes = true; + let draw = |time, width, app: &mut PdfEditorApp| { + let mut raw = input(time, vec![]); + raw.screen_rect = Some(Rect::from_min_size(Pos2::ZERO, Vec2::new(width, 800.0))); + ctx.run(raw, |ctx| { + egui::CentralPanel::default() + .show(ctx, |ui| app.draw_liquid_mode2_document(ui, ctx)); + }) + }; + draw(0.0, 760.0, &mut app); + draw(0.1, 760.0, &mut app); + app.liquid_scroll_to_block = Some(40); + app.liquid_scroll_instant = true; + draw(0.2, 760.0, &mut app); + draw(0.4, 760.0, &mut app); + draw(0.6, 760.0, &mut app); + let before = app.search_state.review_anchor.unwrap(); + for (time, width) in [ + (0.8, 740.0), + (0.9, 720.0), + (1.0, 700.0), + (1.2, 700.0), + (1.4, 700.0), + (1.6, 700.0), + ] { + draw(time, width, &mut app); + } + let after = app.search_state.review_anchor.unwrap(); + assert!( + before.abs_diff(after) <= 3, + "resize moved the visible passage from block {before} to {after}" + ); + } + + #[test] + fn selecting_across_visible_paragraphs_survives_culling_fallback() { + let (mut app, ctx, _worker) = annotation_saving::tests::fixture(); + let blocks = (0..25) + .map(|index| LiquidBlock { + role: LiquidBlockRole::Paragraph, + text: format!("Paragraph {index}: a paragraph with several words to select."), + label: None, + }) + .collect::>(); + let first_text = blocks[0].text.clone(); + let second_text = blocks[1].text.clone(); + app.view_mode = DocumentViewMode::LiquidMode2; + app.liquid_mode2_state = LiquidState::Ready(Arc::new(document(blocks))); + app.liquid_mode2_complete = true; + app.liquid_hide_footnotes = true; + let draw = |time, events, app: &mut PdfEditorApp| { + ctx.run(input(time, events), |ctx| { + egui::CentralPanel::default() + .show(ctx, |ui| app.draw_liquid_mode2_document(ui, ctx)); + }) + }; + draw(0.0, vec![], &mut app); + let output = draw(0.1, vec![], &mut app); + let start = output + .shapes + .iter() + .find_map(|shape| find_text(&shape.shape, &first_text)) + .unwrap() + + Vec2::new(15.0, 8.0); + let end = output + .shapes + .iter() + .find_map(|shape| find_text(&shape.shape, &second_text)) + .unwrap() + + Vec2::new(80.0, 8.0); + draw(0.2, vec![egui::Event::PointerMoved(start)], &mut app); + draw( + 0.3, + vec![egui::Event::PointerButton { + pos: start, + button: egui::PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::NONE, + }], + &mut app, + ); + draw(0.4, vec![egui::Event::PointerMoved(end)], &mut app); + draw( + 0.5, + vec![egui::Event::PointerButton { + pos: end, + button: egui::PointerButton::Primary, + pressed: false, + modifiers: egui::Modifiers::NONE, + }], + &mut app, + ); + assert!( + ctx.plugin::() + .lock() + .has_selection() + ); + draw(0.6, vec![], &mut app); + assert!( + ctx.plugin::() + .lock() + .has_selection() + ); + } +} diff --git a/src/app/review_margin_ui.rs b/src/app/review_margin_ui.rs index c7cdc26..795df40 100644 --- a/src/app/review_margin_ui.rs +++ b/src/app/review_margin_ui.rs @@ -21,6 +21,19 @@ const NOTE_MAX_ROWS: usize = 9; const NOTE_MIN_ROWS: usize = 3; const NOTE_OPEN_MAX_WIDTH: f32 = 440.0; +#[derive(Clone)] +pub(super) struct ReviewMarginRows { + layout_id: u64, + width: u32, + entries: Arc>, + placements: Arc>, + sides: Arc>, + by_top: Arc>, + prefix_bottom: Arc>, + bottom: Option, + origin: Vec2, +} + /// One note waiting for a place, in document order. #[derive(Debug, Clone)] pub(super) struct ReviewMarginEntry { @@ -48,13 +61,36 @@ struct ReviewCommentPresentation { authors: Vec, } -#[derive(Debug, Default)] +#[derive(Debug, Clone)] pub(super) struct ReviewMarginCollector { - entries: Vec, - placed: HashSet, + entries: Arc>, + placed: Arc>, + offset: Vec2, + scale_y: f32, + scale_origin_y: f32, +} + +impl Default for ReviewMarginCollector { + fn default() -> Self { + Self { + entries: Arc::default(), + placed: Arc::default(), + offset: Vec2::ZERO, + scale_y: 1.0, + scale_origin_y: 0.0, + } + } } impl ReviewMarginCollector { + pub(super) fn transformed(&self, offset: Vec2, scale_y: f32, scale_origin_y: f32) -> Self { + let mut copy = self.clone(); + copy.offset += offset; + copy.scale_y = scale_y; + copy.scale_origin_y = scale_origin_y; + copy + } + /// Callouts found while drawing a body block. Each note appears once, at /// its first callout. pub(super) fn add_callouts(&mut self, hits: &[(Rect, u16)], index: &HashMap) { @@ -63,10 +99,10 @@ impl ReviewMarginCollector { let Some(body) = index.get(number) else { continue; }; - if !self.placed.insert(key.clone()) { + if !Arc::make_mut(&mut self.placed).insert(key.clone()) { continue; } - self.entries.push(ReviewMarginEntry { + Arc::make_mut(&mut self.entries).push(ReviewMarginEntry { marker: key.clone(), key, body: body.clone(), @@ -102,16 +138,16 @@ impl ReviewMarginCollector { // note before it, split by a page break. Rejoin it. if marker.parse::().is_err() && reads_as_continuation(&body) - && let Some(previous) = self.entries.last_mut() + && let Some(previous) = Arc::make_mut(&mut self.entries).last_mut() { previous.body.push(' '); previous.body.push_str(&body); continue; } - if !self.placed.insert(key.clone()) { + if !Arc::make_mut(&mut self.placed).insert(key.clone()) { continue; } - self.entries.push(ReviewMarginEntry { + Arc::make_mut(&mut self.entries).push(ReviewMarginEntry { key, marker, body, @@ -135,7 +171,7 @@ impl ReviewMarginCollector { colors: &HashMap, ) { let key = format!("comment:{}", thread.root.key); - if !self.placed.insert(key.clone()) { + if !Arc::make_mut(&mut self.placed).insert(key.clone()) { return; } let authors = std::iter::once(&thread.root) @@ -165,7 +201,7 @@ impl ReviewMarginCollector { } }) .collect(); - self.entries.push(ReviewMarginEntry { + Arc::make_mut(&mut self.entries).push(ReviewMarginEntry { key, marker: String::new(), body: String::new(), @@ -224,7 +260,8 @@ impl PdfEditorApp { colors: &NoteColors, width: f32, max_rows: usize, - ) -> LayoutJob { + linkify: bool, + ) -> (LayoutJob, Vec) { let scale = self.liquid_text_scale; let mut job = LayoutJob::default(); if let Some(comment) = &entry.comment { @@ -252,20 +289,36 @@ impl PdfEditorApp { }, ); } - job.append( - entry - .comment - .as_ref() - .map(|comment| comment.thread.root.text.as_str()) - .unwrap_or(&entry.body), - if entry.marker.is_empty() { 0.0 } else { 4.0 }, - TextFormat { - font_id: FontId::proportional(13.5 * scale), - color: colors.body, - line_height: Some(18.0 * scale), - ..Default::default() - }, - ); + let body = entry + .comment + .as_ref() + .map(|comment| comment.thread.root.text.as_str()) + .unwrap_or(&entry.body); + let gap = if entry.marker.is_empty() { 0.0 } else { 4.0 }; + let body_format = TextFormat { + font_id: FontId::proportional(13.5 * scale), + color: colors.body, + line_height: Some(18.0 * scale), + ..Default::default() + }; + let markers = if linkify && entry.comment.is_none() { + if let Ok(number) = entry.marker.parse::() { + self.review_references.append_to_job( + &mut job, + number, + body, + body_format, + colors.number, + gap, + ) + } else { + job.append(body, gap, body_format); + Vec::new() + } + } else { + job.append(body, gap, body_format); + Vec::new() + }; if let Some(comment) = &entry.comment && !comment.thread.replies.is_empty() { @@ -287,7 +340,7 @@ impl PdfEditorApp { break_anywhere: false, overflow_character: Some('…'), }; - job + (job, markers) } /// Place and paint the collected notes: right margin first, left when crowded. @@ -305,22 +358,23 @@ impl PdfEditorApp { let scale = self.liquid_text_scale; let colors = self.review_note_colors(); let text_width = (width - NOTE_TEXT_INSET).max(40.0); - let mut entries = collector.entries; - entries.sort_by(|a, b| a.anchor_top.total_cmp(&b.anchor_top)); - - let full_rows = entries - .iter() - .map(|entry| { - let job = self.review_note_job(entry, &colors, text_width, usize::MAX); - ui.fonts_mut(|fonts| fonts.layout_job(job)) - .rows - .len() - .max(1) - // Footnotes reserve one row for the explicit source lookup. - // Comments keep their existing size and never search citations. - + usize::from(entry.comment.is_none()) + let layout_id = self + .review_layout + .as_ref() + .filter(|layout| layout.active_this_frame) + .map(|layout| layout.id); + let resizing = self.review_layout.as_ref().is_some_and(|layout| { + layout.active_this_frame && (layout.resize_factor - 1.0).abs() > f32::EPSILON + }); + let cached = self + .review_margin_rows + .as_ref() + .filter(|cached| { + Some(cached.layout_id) == layout_id + && (cached.width == text_width.to_bits() || resizing) + && cached.entries.len() == collector.entries.len() }) - .collect::>(); + .cloned(); let row_height = 18.0 * scale; let metrics = MarginNoteMetrics { row_height, @@ -331,39 +385,89 @@ impl PdfEditorApp { min_rows: NOTE_MIN_ROWS, drift_limit: row_height * 14.0, }; - let requests = entries - .iter() - .zip(&full_rows) - .map(|(entry, rows)| MarginNoteRequest { - anchor_top: entry.anchor_top, - total_rows: *rows, - }) - .collect::>(); - let sides = assign_margin_sides(&requests, &metrics, row_height * 3.0); - let mut placements = vec![ - MarginNotePlacement { - top: 0.0, - height: 0.0, - rows: 1, - shortened: false, - }; - requests.len() - ]; - for side in [MarginSide::Right, MarginSide::Left] { - let indices = (0..requests.len()) - .filter(|index| sides[*index] == side) + let layout = cached.unwrap_or_else(|| { + let mut entries = collector.entries.as_ref().clone(); + entries.sort_by(|a, b| a.anchor_top.total_cmp(&b.anchor_top)); + let full_rows = entries + .iter() + .map(|entry| { + let (job, _) = + self.review_note_job(entry, &colors, text_width, usize::MAX, false); + ui.fonts_mut(|fonts| fonts.layout_job(job)).rows.len().max(1) + // Footnotes reserve one row for the explicit source lookup. + + usize::from(entry.comment.is_none()) + }) .collect::>(); - let side_requests = indices + let requests = entries .iter() - .map(|index| requests[*index]) + .zip(&full_rows) + .map(|(entry, rows)| MarginNoteRequest { + anchor_top: entry.anchor_top, + total_rows: *rows, + }) .collect::>(); - for (index, placement) in indices + let sides = assign_margin_sides(&requests, &metrics, row_height * 3.0); + let mut placements = vec![ + MarginNotePlacement { + top: 0.0, + height: 0.0, + rows: 1, + shortened: false, + }; + requests.len() + ]; + for side in [MarginSide::Right, MarginSide::Left] { + let indices = (0..requests.len()) + .filter(|index| sides[*index] == side) + .collect::>(); + let side_requests = indices + .iter() + .map(|index| requests[*index]) + .collect::>(); + for (index, placement) in indices + .iter() + .zip(place_margin_notes(&side_requests, &metrics)) + { + placements[*index] = placement; + } + } + let mut by_top = (0..placements.len()).collect::>(); + by_top.sort_by(|a, b| placements[*a].top.total_cmp(&placements[*b].top)); + let mut max_bottom = f32::NEG_INFINITY; + let prefix_bottom = by_top .iter() - .zip(place_margin_notes(&side_requests, &metrics)) - { - placements[*index] = placement; + .map(|index| { + max_bottom = max_bottom.max(placements[*index].bottom()); + max_bottom + }) + .collect::>(); + let layout = ReviewMarginRows { + layout_id: layout_id.unwrap_or(0), + width: text_width.to_bits(), + bottom: margin_notes_bottom(&placements), + // Entries remain in their original coordinate space; the + // collector carries the current scroll/column translation. + origin: Vec2::ZERO, + entries: Arc::new(entries), + placements: Arc::new(placements), + sides: Arc::new(sides), + by_top: Arc::new(by_top), + prefix_bottom: Arc::new(prefix_bottom), + }; + if layout_id.is_some() { + self.review_margin_rows = Some(layout.clone()); } - } + layout + }); + let delta = collector.offset - layout.origin; + let transform_y = |y: f32| { + collector.scale_origin_y + (y - collector.scale_origin_y) * collector.scale_y + delta.y + }; + let transform_rect = + |rect: Rect| rect.translate(Vec2::new(delta.x, transform_y(rect.top()) - rect.top())); + let entries = &layout.entries; + let placements = &layout.placements; + let sides = &layout.sides; let pointer = ui.input(|input| input.pointer.hover_pos()); let clip = ui.clip_rect(); @@ -372,28 +476,34 @@ impl PdfEditorApp { let mut source_opened = None; let previous_hover = self.chrome.hovered_note.clone(); let expanded = self.chrome.expanded_note.clone(); - let mut note_rects = Vec::with_capacity(entries.len()); - - for ((entry, placement), side) in entries.iter().zip(&placements).zip(&sides) { + let height_slack = + metrics.note_height(NOTE_MAX_ROWS, true) * (1.0 - collector.scale_y).abs(); + let first = layout + .prefix_bottom + .partition_point(|bottom| transform_y(*bottom) + height_slack < clip.top() - 200.0); + let last = layout + .by_top + .partition_point(|index| transform_y(placements[*index].top) <= clip.bottom() + 200.0); + for &index in &layout.by_top[first..last] { + let entry = &entries[index]; + let placement = placements[index]; + let side = sides[index]; let accent = entry .comment .as_ref() .map(|comment| comment.authors[0].color) .unwrap_or(colors.number); - let left_side = *side == MarginSide::Left; + let left_side = side == MarginSide::Left; let left = if left_side { left_left } else { right_left }; let rect = Rect::from_min_size( - Pos2::new(left, placement.top), + Pos2::new(left, transform_y(placement.top)), Vec2::new(width, placement.height), ); - note_rects.push(rect); - if !rect.intersects(clip.expand(200.0)) { - continue; - } let id = ui.id().with(("review-margin-note", &entry.key)); let response = ui.interact(rect, id, Sense::click()); let over_callout = entry .callout + .map(transform_rect) .zip(pointer) .is_some_and(|(callout, pointer)| callout.expand(3.0).contains(pointer)); let lit = response.hovered() @@ -403,7 +513,7 @@ impl PdfEditorApp { if response.hovered() || over_callout { hovered = Some(entry.key.clone()); } - let painter = ui.painter(); + let painter = ui.painter().clone(); if response.hovered() { painter.rect_filled(rect.expand2(Vec2::new(4.0, 3.0)), 4.0, colors.hover_fill); } @@ -427,7 +537,13 @@ impl PdfEditorApp { .rows .saturating_sub(usize::from(entry.comment.is_none())) .max(1); - let job = self.review_note_job(entry, &colors, text_width, content_rows); + let (job, references) = self.review_note_job( + entry, + &colors, + text_width, + content_rows, + !placement.shortened, + ); let galley = ui.fonts_mut(|fonts| fonts.layout_job(job)); let text_left = if left_side { rect.left() @@ -454,7 +570,10 @@ impl PdfEditorApp { ), }); } - painter.galley(text_origin, galley, colors.body); + painter.galley(text_origin, Arc::clone(&galley), colors.body); + let reference_clicked = entry.marker.parse::().ok().is_some_and(|source| { + self.interact_note_references(ui, source, &galley, text_origin, &references) + }); let mut author_clicked = false; if let Some(comment) = &entry.comment && comment.authors[0].own @@ -492,7 +611,7 @@ impl PdfEditorApp { accent, ); } - if lit && let Some(callout) = entry.callout { + if lit && let Some(callout) = entry.callout.map(transform_rect) { painter.rect_stroke( callout.expand2(Vec2::new(2.5, 1.0)), 3.0, @@ -515,7 +634,7 @@ impl PdfEditorApp { ); let label = self.footnote_sources_button_label(&entry.key, &entry.body); let source = ui - .push_id(("review-note-source", &entry.key), |ui| { + .scope_builder(egui::UiBuilder::new().id(("review-note-source", self.document_epoch, &entry.key)), |ui| { ui.put( source_rect, egui::Button::new( @@ -553,13 +672,13 @@ impl PdfEditorApp { } else { response }; - if response.clicked() && !author_clicked && !source_clicked { + if response.clicked() && !author_clicked && !source_clicked && !reference_clicked { toggled = Some(entry.key.clone()); } } - if let Some(bottom) = margin_notes_bottom(&placements) { - let below = bottom - ui.cursor().top(); + if let Some(bottom) = layout.bottom { + let below = transform_y(bottom) + height_slack - ui.cursor().top(); if below > 0.0 { ui.add_space(below + 16.0); } @@ -579,12 +698,22 @@ impl PdfEditorApp { }; } let open = self.chrome.expanded_note.clone().and_then(|key| { - entries - .iter() - .zip(¬e_rects) - .zip(&sides) - .find(|((entry, _), _)| entry.key == key) - .map(|((entry, rect), side)| (entry.clone(), *rect, *side)) + let index = entries.iter().position(|entry| entry.key == key)?; + let side = sides[index]; + let left = if side == MarginSide::Left { + left_left + } else { + right_left + }; + let placement = placements[index]; + let rect = Rect::from_min_size( + Pos2::new(left, transform_y(placement.top)), + Vec2::new(width, placement.height), + ); + let mut entry = entries[index].clone(); + entry.anchor_top = transform_y(entry.anchor_top); + entry.callout = entry.callout.map(transform_rect); + Some((entry, rect, side)) }); if let Some((entry, rect, side)) = open { let just_opened = toggled_now || expanded.as_deref() != Some(entry.key.as_str()); @@ -723,15 +852,26 @@ impl PdfEditorApp { ); } } else { - ui.add( - egui::Label::new( - RichText::new(&entry.body) - .size(15.0 * scale) - .color(colors.body), - ) - .wrap() - .selectable(true), - ); + if let Ok(number) = entry.key.parse::() { + self.draw_note_reference_text( + ui, + number, + &entry.body, + 15.0 * scale, + colors.body, + colors.number, + ); + } else { + ui.add( + egui::Label::new( + RichText::new(&entry.body) + .size(15.0 * scale) + .color(colors.body), + ) + .wrap() + .selectable(true), + ); + } ui.add_space(8.0); ui.separator(); self.draw_footnote_sources(ui, &entry.key, &entry.body); diff --git a/src/app/review_paragraph.rs b/src/app/review_paragraph.rs new file mode 100644 index 0000000..124773b --- /dev/null +++ b/src/app/review_paragraph.rs @@ -0,0 +1,515 @@ +//! Layout of inline note markers inside one selectable paragraph. + +use super::*; + +#[derive(Debug, Clone)] +pub(super) struct MarkerRange { + pub(super) start: usize, + pub(super) end: usize, + pub(super) number: u16, +} + +const PARAGRAPH_CACHE_LIMIT: usize = 96 * 1024 * 1024; + +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +struct ParagraphKey { + block: usize, + offset: usize, + len: usize, + size: u32, + color: [u8; 4], + marker_color: [u8; 4], + muted_color: [u8; 4], + strong: bool, + pixels_per_point: u32, + font_hash: u64, +} + +#[derive(Default)] +pub(super) struct ParagraphCache { + document: Option>, + width: Option, + entries: HashMap, Vec)>, + estimated_bytes: usize, +} + +impl ParagraphCache { + #[allow(clippy::too_many_arguments)] + pub(super) fn get_or_layout( + &mut self, + document: &Arc, + block: usize, + text: &str, + size: f32, + color: Color32, + marker_color: Color32, + muted_color: Color32, + strong: bool, + footnotes: &HashMap, + ui: &mut egui::Ui, + ) -> Option<(Arc, Vec)> { + // The key only uses offsets into an immutable, live document. Refuse + // to cache transient strings whose address could later be reused. + let source = document.blocks.get(block)?.text.as_str(); + let offset = (text.as_ptr() as usize).checked_sub(source.as_ptr() as usize)?; + if offset.checked_add(text.len())? > source.len() { + return None; + } + let width = ui.available_width(); + if !width.is_finite() || width <= 0.0 { + return None; + } + let same_document = self + .document + .as_ref() + .and_then(std::sync::Weak::upgrade) + .is_some_and(|cached| Arc::ptr_eq(&cached, document)); + if !same_document || self.width != Some(width.to_bits()) { + self.entries.clear(); + self.estimated_bytes = 0; + self.document = Some(Arc::downgrade(document)); + self.width = Some(width.to_bits()); + } + let mut hasher = DefaultHasher::new(); + egui::FontSelection::Default + .resolve(ui.style()) + .hash(&mut hasher); + let key = ParagraphKey { + block, + offset, + len: text.len(), + size: size.to_bits(), + color: color.to_array(), + marker_color: marker_color.to_array(), + muted_color: muted_color.to_array(), + strong, + pixels_per_point: ui.ctx().pixels_per_point().to_bits(), + font_hash: hasher.finish(), + }; + if let Some(cached) = self.entries.get(&key) { + return Some(cached.clone()); + } + let (job, markers) = layout_job( + text, + size, + color, + marker_color, + muted_color, + strong, + footnotes, + ui.style(), + width, + ); + let galley = ui.fonts_mut(|fonts| fonts.layout_job(job)); + let estimated = galley + .num_vertices + .saturating_mul(std::mem::size_of::()) + .saturating_add( + galley + .num_indices + .saturating_mul(std::mem::size_of::()), + ) + .saturating_add(text.len().saturating_mul(48)); + if self.estimated_bytes.saturating_add(estimated) <= PARAGRAPH_CACHE_LIMIT { + self.estimated_bytes += estimated; + self.entries + .insert(key, (Arc::clone(&galley), markers.clone())); + } + Some((galley, markers)) + } +} + +/// Assemble one job instead of breaking each sentence around its callouts. +/// Marker positions are character offsets, as required by egui's cursors. +pub(super) fn layout_job( + text: &str, + size: f32, + color: Color32, + marker_color: Color32, + muted_color: Color32, + strong: bool, + footnotes: &HashMap, + style: &egui::Style, + width: f32, +) -> (LayoutJob, Vec) { + let mut job = LayoutJob::default(); + job.wrap.max_width = width; + let mut markers = Vec::new(); + let mut visible_chars = 0; + let mut is_marker = false; + let mut run = String::new(); + + let mut flush = |run: &mut String, is_marker: bool| { + if run.is_empty() { + return; + } + let (display, rich, number) = if is_marker { + let number = run + .trim() + .parse::() + .ok() + .filter(|n| footnotes.contains_key(n)); + let display = review_footnote_superscript(run); + let rich = RichText::new(&display) + .size(size * 0.78) + .color(if number.is_some() { + marker_color + } else { + muted_color + }) + .raised(); + (display, rich, number) + } else { + let display = std::mem::take(run); + let mut rich = RichText::new(&display).size(size).color(color); + if strong { + rich = rich.strong(); + } + (display, rich, None) + }; + let end = visible_chars + display.chars().count(); + if let Some(number) = number { + markers.push(MarkerRange { + start: visible_chars, + end, + number, + }); + } + visible_chars = end; + rich.append_to(&mut job, style, egui::FontSelection::Default, Align::BOTTOM); + run.clear(); + }; + for ch in text.chars() { + match ch { + crate::layout_roles::CALLOUT_START => { + flush(&mut run, is_marker); + is_marker = true; + } + crate::layout_roles::CALLOUT_END => { + flush(&mut run, is_marker); + is_marker = false; + } + _ => run.push(ch), + } + } + flush(&mut run, is_marker); + (job, markers) +} + +/// A marker can wrap between digits; return one hit rectangle per row. +pub(super) fn marker_rects( + galley: &egui::Galley, + origin: Pos2, + ranges: &[MarkerRange], +) -> Vec<(Rect, u16)> { + let mut hits: Vec<(Rect, u16)> = Vec::new(); + for marker in ranges { + for index in marker.start..marker.end { + let left = galley.pos_from_cursor(egui::text::CCursor { + index, + prefer_next_row: true, + }); + let right = galley.pos_from_cursor(egui::text::CCursor { + index: index + 1, + prefer_next_row: false, + }); + let rect = Rect::from_min_max( + Pos2::new(left.left().min(right.left()), left.top()), + Pos2::new(left.left().max(right.left()), left.bottom()), + ) + .translate(origin.to_vec2()); + if let Some((last, number)) = hits.last_mut() + && *number == marker.number + && (last.top() - rect.top()).abs() < 0.5 + && (last.right() - rect.left()).abs() < 1.0 + { + *last = last.union(rect); + } else { + hits.push((rect, marker.number)); + } + } + } + hits +} + +/// Locate rendered characters in the source block, retaining source byte +/// ranges when sentinel-wrapped note numbers become superscript glyphs. +pub(super) fn source_ranges( + source: &str, + source_offset: usize, + rendered: &str, + hide_markers: bool, +) -> Option>> { + if rendered.is_empty() || !source.is_char_boundary(source_offset) { + return None; + } + if let Some(relative) = source.get(source_offset..)?.find(rendered) { + let start = source_offset + relative; + return Some( + rendered + .char_indices() + .map(|(i, c)| start + i..start + i + c.len_utf8()) + .collect(), + ); + } + + let mut visible = String::new(); + let mut ranges = Vec::new(); + let mut marker: Option> = None; + let append = + |ch: char, at: usize, visible: &mut String, ranges: &mut Vec>| { + visible.push(ch); + ranges.push(at..at + ch.len_utf8()); + }; + let append_marker = |items: &[(usize, char)], + visible: &mut String, + ranges: &mut Vec>| { + if hide_markers { + return; + } + let first = items + .iter() + .position(|(_, c)| !c.is_whitespace()) + .unwrap_or(items.len()); + let last = items + .iter() + .rposition(|(_, c)| !c.is_whitespace()) + .map_or(first, |i| i + 1); + for &(at, ch) in &items[first..last] { + let display = if ch.is_whitespace() { + ch.to_string() + } else { + review_footnote_superscript(&ch.to_string()) + }; + for shown in display.chars() { + visible.push(shown); + ranges.push(at..at + ch.len_utf8()); + } + } + }; + for (at, ch) in source.char_indices() { + match ch { + crate::layout_roles::CALLOUT_START => { + if let Some(items) = marker.take() { + append_marker(&items, &mut visible, &mut ranges); + } + marker = Some(Vec::new()); + } + crate::layout_roles::CALLOUT_END => { + if let Some(items) = marker.take() { + append_marker(&items, &mut visible, &mut ranges); + } + } + _ => { + if let Some(items) = marker.as_mut() { + items.push((at, ch)); + } else { + append(ch, at, &mut visible, &mut ranges); + } + } + } + } + if let Some(items) = marker { + append_marker(&items, &mut visible, &mut ranges); + } + let start_char = ranges.partition_point(|range| range.start < source_offset); + let start_byte = visible + .char_indices() + .nth(start_char) + .map_or(visible.len(), |(at, _)| at); + let relative = visible.get(start_byte..)?.find(rendered)?; + let match_byte = start_byte + relative; + let match_char = visible[..match_byte].chars().count(); + let count = rendered.chars().count(); + Some(ranges.get(match_char..match_char + count)?.to_vec()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn paragraph_job_keeps_text_together_and_marker_offsets_use_characters() { + let ctx = Context::default(); + let text = format!( + "Café {}12{} conclusion.", + crate::layout_roles::CALLOUT_START, + crate::layout_roles::CALLOUT_END + ); + let mut output = None; + let _ = ctx.run(Default::default(), |ctx| { + let (job, markers) = layout_job( + &text, + 17.0, + Color32::BLACK, + Color32::RED, + Color32::GRAY, + false, + &HashMap::from([(12, "note".to_owned())]), + &ctx.style(), + 120.0, + ); + assert_eq!(job.text, "Café ¹² conclusion."); + assert_eq!((markers[0].start, markers[0].end), (5, 7)); + let galley = ctx.fonts_mut(|fonts| fonts.layout_job(job)); + let hits = marker_rects(&galley, Pos2::new(10.0, 20.0), &markers); + assert!(!hits.is_empty()); + assert!( + hits.iter() + .all(|(rect, number)| rect.is_positive() && *number == 12) + ); + output = Some(galley.text().to_owned()); + }); + assert_eq!(output.as_deref(), Some("Café ¹² conclusion.")); + } + + #[test] + fn search_maps_superscript_glyphs_back_to_original_source_bytes() { + let source = format!( + "Café {}12{} conclusion.", + crate::layout_roles::CALLOUT_START, + crate::layout_roles::CALLOUT_END + ); + let ranges = source_ranges(&source, 0, "Café ¹² conclusion.", false).unwrap(); + assert_eq!(&source[ranges[3].clone()], "é"); + assert_eq!(&source[ranges[5].clone()], "1"); + assert_eq!(&source[ranges[6].clone()], "2"); + assert_eq!(&source[ranges[8].clone()], "c"); + let hidden = source_ranges(&source, 0, "Café conclusion.", true).unwrap(); + assert_eq!(&source[hidden[6].clone()], "c"); + } + + #[test] + fn retained_paragraph_is_reused_and_tracks_width_and_document_changes() { + let make_document = |text: String| { + Arc::new(LiquidDocument { + title: String::new(), + blocks: vec![LiquidBlock { + role: LiquidBlockRole::Paragraph, + text, + label: None, + }], + article_spans: Vec::new(), + block_source_lines: Vec::new(), + footnote_links: Vec::new(), + footnote_link_integrity: None, + profile: None, + noise_lines_removed: 0, + llm_used: false, + llm_provider: None, + deep_liquid_used: false, + deep_liquid_model: None, + warnings: Vec::new(), + source_signature: String::new(), + }) + }; + let first = make_document(format!( + "body {}1{} end", + crate::layout_roles::CALLOUT_START, + crate::layout_roles::CALLOUT_END + )); + let second = make_document(format!( + "different {}1{} end", + crate::layout_roles::CALLOUT_START, + crate::layout_roles::CALLOUT_END + )); + let ctx = Context::default(); + let mut cache = ParagraphCache::default(); + let mut layouts = Vec::new(); + for (document, width) in [ + (&first, 200.0), + (&first, 200.0), + (&first, 300.0), + (&second, 300.0), + ] { + let input = egui::RawInput { + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, Vec2::new(width, 400.0))), + ..Default::default() + }; + let _ = ctx.run(input, |ctx| { + egui::CentralPanel::default().show(ctx, |ui| { + let (galley, _) = cache + .get_or_layout( + document, + 0, + &document.blocks[0].text, + 17.0, + Color32::BLACK, + Color32::RED, + Color32::GRAY, + false, + &HashMap::from([(1, "note".to_owned())]), + ui, + ) + .unwrap(); + layouts.push(galley); + }); + }); + } + assert!(Arc::ptr_eq(&layouts[0], &layouts[1])); + assert!(!Arc::ptr_eq(&layouts[1], &layouts[2])); + assert_eq!(layouts[3].text(), "different ¹ end"); + } + + #[test] + fn clicking_a_single_galley_marker_opens_its_footnote() { + let (mut app, ctx, _worker) = annotation_saving::tests::fixture(); + let text = format!( + "A sentence{}1{} continues.", + crate::layout_roles::CALLOUT_START, + crate::layout_roles::CALLOUT_END + ); + let document: LiquidDocument = serde_json::from_value(serde_json::json!({ + "title":"Reader", "blocks":[{"role":"paragraph","text":text}], + "llm_used":false,"warnings":[],"source_signature":"marker-click" + })) + .unwrap(); + let document = Arc::new(document); + app.view_mode = DocumentViewMode::LiquidMode2; + app.liquid_mode2_state = LiquidState::Ready(Arc::clone(&document)); + app.liquid_footnote_index + .insert(1, "Footnote body".to_owned()); + let id = liquid_footnote_popup_id( + &liquid_feedback_id(&document.source_signature, 0, &document.blocks[0]), + 1, + ); + let mut draw = |time, events| { + let mut hits = Vec::new(); + let input = egui::RawInput { + time: Some(time), + events, + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, Vec2::new(600.0, 400.0))), + ..Default::default() + }; + let _ = ctx.run(input, |ctx| { + egui::CentralPanel::default().show(ctx, |ui| { + hits = app.draw_liquid_feedback_block(ui, &document, 0, &document.blocks[0]); + }); + }); + hits + }; + let point = draw(0.0, vec![])[0].0.center(); + draw( + 0.1, + vec![ + egui::Event::PointerMoved(point), + egui::Event::PointerButton { + pos: point, + button: egui::PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::NONE, + }, + ], + ); + draw( + 0.2, + vec![egui::Event::PointerButton { + pos: point, + button: egui::PointerButton::Primary, + pressed: false, + modifiers: egui::Modifiers::NONE, + }], + ); + assert!(egui::Popup::is_id_open(&ctx, id)); + } +} diff --git a/src/app/review_references.rs b/src/app/review_references.rs new file mode 100644 index 0000000..aa39493 --- /dev/null +++ b/src/app/review_references.rs @@ -0,0 +1,1083 @@ +//! Conservative links between numbered Review notes. +use super::*; +use std::ops::Range; + +#[derive(Clone, Debug)] +struct Target { + block: usize, + body: String, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum AuthorityKind { + Case, + FederalStatute, + LawReviewArticle, +} + +#[derive(Default)] +pub(super) struct ReferenceIndex { + unique_notes: HashSet, + targets: HashMap, + /// Source note -> (previous note, exact source body). Only populated for + /// narrowly recognizable, single-authority citations. + id_targets: HashMap, +} + +fn has_callout(text: &str, target: u16) -> bool { + let mut rest = text; + while let Some((_, after)) = rest.split_once(layout_roles::CALLOUT_START) { + let Some((marker, after_marker)) = after.split_once(layout_roles::CALLOUT_END) else { + break; + }; + if marker.trim().parse::().ok() == Some(target) { + return true; + } + rest = after_marker; + } + false +} + +impl ReferenceIndex { + pub(super) fn new(document: &LiquidDocument) -> Self { + let mut notes: HashMap> = HashMap::new(); + let mut note_order = Vec::new(); + for (block_index, block) in document + .blocks + .iter() + .enumerate() + .filter(|(_, block)| is_review_note_display_block(block)) + { + for (marker, body) in split_fused_review_notes(&block.text) { + let Ok(number) = marker.parse::() else { + continue; + }; + if number == 0 || body.trim().is_empty() { + continue; + } + note_order.push(number); + notes + .entry(number) + .and_modify(|entry| *entry = None) + .or_insert_with(|| Some((block_index, body.trim().to_owned()))); + } + } + let unique_notes = notes + .iter() + .filter_map(|(&number, note)| note.as_ref().map(|_| number)) + .collect(); + let mut link_counts: HashMap = HashMap::new(); + for link in &document.footnote_links { + *link_counts.entry(link.marker).or_default() += 1; + } + let mut targets = HashMap::new(); + for link in &document.footnote_links { + if link_counts.get(&link.marker) != Some(&1) { + continue; + } + let Some(Some((note_block, body))) = notes.get(&link.marker) else { + continue; + }; + let Some(block) = document.blocks.get(link.body_block_index) else { + continue; + }; + if *note_block != link.note_block_index + || is_review_note_display_block(block) + || !has_callout(&block.text, link.marker) + { + continue; + } + let target = Target { + block: link.body_block_index, + body: body.clone(), + }; + targets.insert(link.marker, target); + } + let mut id_targets = HashMap::new(); + for pair in note_order.windows(2) { + let [previous, source] = [pair[0], pair[1]]; + if previous.checked_add(1) != Some(source) || !targets.contains_key(&previous) { + continue; + } + let (Some(Some((_, previous_body))), Some(Some((_, source_body)))) = + (notes.get(&previous), notes.get(&source)) + else { + continue; + }; + let inherited = id_targets.get(&previous).and_then( + |(_, expected, kind): &(u16, String, AuthorityKind)| { + (expected == previous_body).then_some(*kind) + }, + ); + if let Some(kind) = authority_kind(previous_body).or(inherited) + && standalone_id_range(source_body, kind).is_some() + { + id_targets.insert(source, (previous, source_body.clone(), kind)); + } + } + Self { + unique_notes, + targets, + id_targets, + } + } + + fn references(&self, source: u16, text: &str) -> Vec<(Range, u16)> { + if !self.unique_notes.contains(&source) { + return Vec::new(); + } + let mut references: Vec<_> = reference_ranges(text) + .into_iter() + .filter(|(_, target)| *target < source && self.targets.contains_key(target)) + .collect(); + if let Some((target, expected, kind)) = self.id_targets.get(&source) + && text.trim() == expected.trim() + && let Some(range) = standalone_id_range(text, *kind) + { + references.push((range, *target)); + } + references.sort_by_key(|(range, _)| range.start); + references + } + + pub(super) fn append_to_job( + &self, + job: &mut LayoutJob, + source: u16, + text: &str, + normal: TextFormat, + accent: Color32, + leading_space: f32, + ) -> Vec { + let linked = TextFormat { + color: accent, + underline: Stroke::new(1.0_f32, accent), + ..normal.clone() + }; + let offset = job.text.chars().count(); + let mut gap = leading_space; + let mut last = 0; + let mut markers = Vec::new(); + for (range, number) in self.references(source, text) { + if range.start > last { + job.append(&text[last..range.start], gap, normal.clone()); + gap = 0.0; + } + job.append(&text[range.clone()], gap, linked.clone()); + gap = 0.0; + markers.push(review_paragraph::MarkerRange { + start: offset + text[..range.start].chars().count(), + end: offset + text[..range.end].chars().count(), + number, + }); + last = range.end; + } + if last < text.len() { + job.append(&text[last..], gap, normal); + } + markers + } +} + +fn numeric_pin(text: &str) -> bool { + let text = text.trim(); + if text.is_empty() { + return false; + } + let mut pieces = text.split(['-', '–']); + let first = pieces.next().unwrap_or_default(); + let valid = |piece: &str| !piece.is_empty() && piece.bytes().all(|byte| byte.is_ascii_digit()); + valid(first) && pieces.next().is_none_or(valid) && pieces.next().is_none() +} + +/// Match only an entire note consisting of Id. and a supported pinpoint. +/// A section pinpoint is accepted only for an already-recognized statute. +fn standalone_id_range(text: &str, kind: AuthorityKind) -> Option> { + let trimmed = text.trim(); + let prefix = trimmed.get(..3)?; + if !prefix.eq_ignore_ascii_case("id.") { + return None; + } + let rest = &trimmed[3..]; + if !rest.is_empty() && !rest.chars().next().is_some_and(char::is_whitespace) { + return None; + } + let suffix = rest.trim(); + if !suffix.is_empty() { + let valid_pin = if let Some(section) = suffix.strip_prefix("§ ") { + kind == AuthorityKind::FederalStatute + && section_designator(section.strip_suffix('.').unwrap_or(section)) + } else if suffix + .get(..3) + .is_some_and(|at| at.eq_ignore_ascii_case("at ")) + { + let pin = suffix[3..].strip_suffix('.').unwrap_or(&suffix[3..]); + numeric_pin(pin) + } else { + false + }; + if !valid_pin { + return None; + } + } + let start = text.len() - text.trim_start().len(); + Some(start..start + 3) +} + +/// Require a trailing four-digit year and final period. +fn citation_before_year(text: &str) -> Option<&str> { + let without_period = text.trim().strip_suffix('.')?; + let (citation, parenthetical) = without_period.rsplit_once(" (")?; + let year = parenthetical.strip_suffix(')')?; + (year.len() == 4 && year.bytes().all(|byte| byte.is_ascii_digit())).then_some(citation) +} + +/// Recognize only simple, single-authority cases, federal statutes, or law +/// review articles. Other citation forms are intentionally left unlinked. +fn authority_kind(text: &str) -> Option { + let citation = citation_before_year(text)?; + if single_case_citation(citation) { + Some(AuthorityKind::Case) + } else if single_federal_statute(citation) { + Some(AuthorityKind::FederalStatute) + } else if single_law_review_article(citation) { + Some(AuthorityKind::LawReviewArticle) + } else { + None + } +} + +fn single_case_citation(citation: &str) -> bool { + let mut fields = citation.split(',').map(str::trim); + let (Some(case), Some(reporter)) = (fields.next(), fields.next()) else { + return false; + }; + let pin = fields.next(); + if fields.next().is_some() || pin.is_some_and(|pin| !numeric_pin(pin)) { + return false; + } + let Some((left, right)) = case.split_once(" v. ") else { + return false; + }; + let valid_party = |party: &str| { + !party.trim().is_empty() + && party.chars().all(|ch| { + ch.is_alphanumeric() || ch.is_whitespace() || matches!(ch, '&' | '\'' | '’' | '-') + }) + }; + if !valid_party(left) || !valid_party(right) { + return false; + } + let mut parts = reporter.split_whitespace(); + let (Some(volume), Some(series), Some(page)) = (parts.next(), parts.next(), parts.next()) + else { + return false; + }; + parts.next().is_none() + && matches!(series, "U.S." | "F.2d" | "F.3d" | "F.4th") + && numeric_pin(volume) + && !volume.contains(['-', '–']) + && numeric_pin(page) + && !page.contains(['-', '–']) +} + +fn single_federal_statute(citation: &str) -> bool { + let Some((title, section)) = citation.split_once(" U.S.C. § ") else { + return false; + }; + !title.is_empty() + && title.bytes().all(|byte| byte.is_ascii_digit()) + && section_designator(section) +} + +fn section_designator(section: &str) -> bool { + if !section + .bytes() + .next() + .is_some_and(|byte| byte.is_ascii_digit()) + || section.contains("()") + { + return false; + } + let mut depth = 0u32; + for ch in section.chars() { + match ch { + '(' => depth += 1, + ')' if depth > 0 => depth -= 1, + ch if ch.is_ascii_alphanumeric() || ch == '-' || ch == '.' => {} + _ => return false, + } + } + depth == 0 + && section + .chars() + .last() + .is_some_and(|ch| ch.is_ascii_alphanumeric() || ch == ')') +} + +fn single_law_review_article(citation: &str) -> bool { + let fields = citation.split(',').map(str::trim).collect::>(); + if !(3..=4).contains(&fields.len()) || fields.get(3).is_some_and(|pin| !numeric_pin(pin)) { + return false; + } + let (author, title, reporter) = (fields[0], fields[1], fields[2]); + if author.split_whitespace().count() < 2 + || !author.chars().all(|ch| { + ch.is_alphabetic() || ch.is_whitespace() || matches!(ch, '.' | '\'' | '’' | '-' | '&') + }) + || title.chars().filter(|ch| ch.is_alphabetic()).count() < 4 + || !title.chars().all(|ch| { + ch.is_alphanumeric() + || ch.is_whitespace() + || matches!(ch, ':' | '-' | '–' | '\'' | '’' | '?' | '!') + }) + { + return false; + } + let parts = reporter.split_whitespace().collect::>(); + if parts.len() < 4 { + return false; + } + let volume = parts[0]; + let page = parts[parts.len() - 1]; + let series = parts[1..parts.len() - 1].join(" "); + let digits = |value: &str| !value.is_empty() && value.bytes().all(|byte| byte.is_ascii_digit()); + digits(volume) + && digits(page) + && (series.ends_with(" L. Rev.") || series.ends_with(" L.J.")) + && series + .chars() + .all(|ch| ch.is_alphabetic() || ch.is_whitespace() || matches!(ch, '.' | '-' | '&')) +} + +/// Byte ranges preserve the original Unicode text; only the ASCII legal phrase +/// is matched case-insensitively. Never resolve a plural or malformed number. +fn reference_ranges(text: &str) -> Vec<(Range, u16)> { + let lower = text.to_ascii_lowercase(); + let mut ranges = Vec::new(); + for (start, _) in lower.match_indices("supra") { + if text[..start] + .chars() + .next_back() + .is_some_and(|ch| ch.is_alphanumeric() || ch == '_') + { + continue; + } + let mut at = start + 5; + let before_space = at; + while let Some(ch) = text[at..].chars().next().filter(|ch| ch.is_whitespace()) { + at += ch.len_utf8(); + } + if at == before_space || !lower[at..].starts_with("note") { + continue; + } + at += 4; + let before_space = at; + while let Some(ch) = text[at..].chars().next().filter(|ch| ch.is_whitespace()) { + at += ch.len_utf8(); + } + if at == before_space { + continue; + } + let digits = at; + while at < text.len() && text.as_bytes()[at].is_ascii_digit() { + at += 1; + } + if digits == at + || text[at..] + .chars() + .next() + .is_some_and(|ch| ch.is_alphanumeric() || ch == '_') + { + continue; + } + if text[at..].starts_with(['-', '–', '—']) + || (text[at..].starts_with('.') + && text[at + 1..].starts_with(|ch: char| ch.is_ascii_digit())) + { + continue; + } + if let Ok(number) = text[digits..at].parse::() + && number > 0 + { + ranges.push((start..at, number)); + } + } + ranges +} + +impl PdfEditorApp { + pub(super) fn draw_note_reference_text( + &mut self, + ui: &mut egui::Ui, + source: u16, + text: &str, + size: f32, + color: Color32, + accent: Color32, + ) { + let references = self.review_references.references(source, text); + if references.is_empty() { + ui.add( + egui::Label::new(RichText::new(text).size(size).color(color)) + .wrap() + .selectable(true), + ); + return; + } + let normal = TextFormat { + font_id: FontId::proportional(size), + color, + ..Default::default() + }; + let mut job = LayoutJob::default(); + job.wrap.max_width = ui.available_width(); + let markers = self + .review_references + .append_to_job(&mut job, source, text, normal, accent, 0.0); + let galley = ui.fonts_mut(|fonts| fonts.layout_job(job)); + let response = ui.add(egui::Label::new(Arc::clone(&galley)).selectable(true)); + self.interact_note_references(ui, source, &galley, response.rect.min, &markers); + } + + pub(super) fn interact_note_references( + &mut self, + ui: &mut egui::Ui, + source: u16, + galley: &egui::Galley, + origin: Pos2, + markers: &[review_paragraph::MarkerRange], + ) -> bool { + let mut selected = None; + for (ordinal, (rect, number)) in review_paragraph::marker_rects(galley, origin, markers) + .into_iter() + .enumerate() + { + let rect = rect.intersect(ui.clip_rect()); + if !rect.is_positive() { + continue; + } + let Some(target) = self.review_references.targets.get(&number) else { + continue; + }; + let preview = target.body.chars().take(800).collect::(); + let link = ui + .interact( + rect, + ui.id().with(("note-reference", source, ordinal)), + Sense::click(), + ) + .on_hover_cursor(CursorIcon::PointingHand) + .on_hover_text(format!("Note {number}\n{preview}")); + link.widget_info(|| { + egui::WidgetInfo::labeled( + egui::WidgetType::Button, + true, + format!("Go to note {number}"), + ) + }); + if link.clicked() { + selected = Some((target.block, number)); + } + } + if let Some((block, number)) = selected { + self.jump_to_review_block(block); + self.chrome.expanded_note = None; + self.pending_reference_note = Some((block, number)); + self.status = format!("Jumping to note {number}. Use Alt+Left to return."); + ui.ctx().request_repaint(); + true + } else { + false + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::liquid::LiquidFootnoteLink; + + fn fixture() -> Arc { + let first = format!( + "First passage {}1{}.", + layout_roles::CALLOUT_START, + layout_roles::CALLOUT_END + ); + let second = format!( + "Second passage {}2{}.", + layout_roles::CALLOUT_START, + layout_roles::CALLOUT_END + ); + Arc::new( + serde_json::from_value(serde_json::json!({ + "title":"Article", "blocks":[ + {"role":"paragraph","text":first}, + {"role":"footnote","text":"1. Earlier authority."}, + {"role":"paragraph","text":second}, + {"role":"footnote","text":"2. See supra note 1, at 42."} + ], "footnote_links":[ + {"body_block_index":0,"body_marker_ordinal":0,"marker":1,"note_block_index":1}, + {"body_block_index":2,"body_marker_ordinal":0,"marker":2,"note_block_index":3} + ], "llm_used":false,"warnings":[],"source_signature":"references-test" + })) + .unwrap(), + ) + } + + #[test] + fn matches_singular_references_and_preserves_unicode_offsets() { + let text = "Café SUPRA note 17, supra note 2. asupra note 3; supra notes 4; supra note 9a; supra note 1-2; supra note 1.3."; + let ranges = reference_ranges(text); + assert_eq!( + ranges + .iter() + .map(|(range, number)| (&text[range.clone()], *number)) + .collect::>(), + vec![("SUPRA note 17", 17), ("supra note 2", 2)] + ); + } + + #[test] + fn recognizes_only_standalone_id_and_a_simple_single_case() { + for text in ["Id.", " id. at 42. ", "Id. at 42–45."] { + let range = standalone_id_range(text, AuthorityKind::Case).expect(text); + assert_eq!(text[range].to_ascii_lowercase(), "id."); + } + for text in [ + "Id. at 42; Smith v. Jones.", + "Id.at 42.", + "Id. at 42 n.3.", + "See Id.", + "Id. at ", + ] { + assert!( + standalone_id_range(text, AuthorityKind::Case).is_none(), + "{text}" + ); + } + for text in [ + "Smith v. Jones, 410 U.S. 113 (1973).", + "Smith v. Jones, 410 U.S. 113, 115–16 (1973).", + "Smith v. Jones, 123 F.3d 456 (1999).", + ] { + assert!(authority_kind(text).is_some(), "{text}"); + } + for text in [ + "Smith v. Jones, 410 U.S. 113 (1973); Adams v. Baker, 20 F.3d 12 (1991).", + "Smith v. Jones, 410 U.S. 113 (1973) (quoting another source).", + "Smith v. Jones, 410 U.S. 113 (1973). See also Adams.", + "Smith v. Jones, 410 U.S. 113, 115, 116 (1973).", + "Some discussion without a citation.", + "Id. at 42.", + ] { + assert!(authority_kind(text).is_none(), "{text}"); + } + } + + #[test] + fn recognizes_simple_statutes_and_law_review_articles_without_guessing() { + let accepted = [ + ("42 U.S.C. § 1983 (2018).", AuthorityKind::FederalStatute), + ( + "15 U.S.C. § 78j(b)(1) (2018).", + AuthorityKind::FederalStatute, + ), + ( + "Jane Smith, Contracts and Algorithms, 123 Harv. L. Rev. 456, 460 (2020).", + AuthorityKind::LawReviewArticle, + ), + ( + "Jane Smith, Legal Theory, 35 Yale L.J. 10 (2021).", + AuthorityKind::LawReviewArticle, + ), + ]; + for (citation, expected) in accepted { + assert_eq!(authority_kind(citation), Some(expected), "{citation}"); + let mut document = (*fixture()).clone(); + document.blocks[1].text = format!("1. {citation}"); + document.blocks[3].text = "2. Id.".into(); + assert_eq!( + ReferenceIndex::new(&document).references(2, "Id."), + vec![(0..3, 1)] + ); + } + for citation in [ + "42 U.S.C. § 1983 (2018); 15 U.S.C. § 78j (2018).", + "42 U.S.C. §§ 1983 (2018).", + "42 U.S.C. § 1983(a (2018).", + "Jane Smith, Legal Theory, 123 Harv. L. Rev. 456 (2020) (quoting another source).", + "Jane Smith, Legal Theory, 123 Harv. L. Rev. 456; Doe v. Roe, 1 U.S. 2 (2000).", + "Jane Smith, Legal Theory, 123 Unknown Reporter 456 (2020).", + ] { + assert!(authority_kind(citation).is_none(), "{citation}"); + } + assert_eq!( + standalone_id_range("Id. § 1985(a).", AuthorityKind::FederalStatute), + Some(0..3) + ); + assert!(standalone_id_range("Id. § 1985(a).", AuthorityKind::Case).is_none()); + assert!(standalone_id_range("Id. § 1985(a.", AuthorityKind::FederalStatute).is_none()); + let mut document = (*fixture()).clone(); + document.blocks[1].text = "1. 42 U.S.C. § 1983 (2018).".into(); + document.blocks[3].text = "2. Id. § 1985(a).".into(); + assert_eq!( + ReferenceIndex::new(&document).references(2, "Id. § 1985(a)."), + vec![(0..3, 1)] + ); + let mut body = document.blocks[2].clone(); + body.text = format!( + "Third passage {}3{}.", + layout_roles::CALLOUT_START, + layout_roles::CALLOUT_END + ); + let mut note = document.blocks[3].clone(); + note.text = "3. Id. § 1986.".into(); + document.blocks.push(body); + document.blocks.push(note); + document.footnote_links.push(LiquidFootnoteLink { + body_block_index: 4, + body_marker_ordinal: 0, + marker: 3, + note_block_index: 5, + body_page_index: None, + note_page_index: None, + }); + assert_eq!( + ReferenceIndex::new(&document).references(3, "Id. § 1986."), + vec![(0..3, 2)] + ); + document.blocks[1].text = "1. Smith v. Jones, 410 U.S. 113 (1973).".into(); + assert!( + ReferenceIndex::new(&document) + .references(2, "Id. § 1985(a).") + .is_empty() + ); + assert!( + ReferenceIndex::new(&document) + .references(3, "Id. § 1986.") + .is_empty() + ); + } + + #[test] + fn id_links_only_when_the_adjacent_unique_note_has_one_recognizable_case() { + let mut document = (*fixture()).clone(); + document.blocks[1].text = "1. Smith v. Jones, 410 U.S. 113, 115 (1973).".into(); + document.blocks[3].text = "2. Id. at 116.".into(); + let index = ReferenceIndex::new(&document); + assert_eq!(index.references(2, "Id. at 116."), vec![(0..3, 1)]); + assert!(index.references(2, "Id. at 117.").is_empty()); + assert!(index.references(1, "Id. at 116.").is_empty()); + + let mut ambiguous = document.clone(); + ambiguous.blocks[1].text = + "1. Smith v. Jones, 410 U.S. 113 (1973); Adams v. Baker, 20 F.3d 12 (1991).".into(); + assert!( + ReferenceIndex::new(&ambiguous) + .references(2, "Id. at 116.") + .is_empty() + ); + + let mut mixed = document.clone(); + mixed.blocks[3].text = "2. Id. at 116; see also Adams.".into(); + assert!( + ReferenceIndex::new(&mixed) + .references(2, "Id. at 116.") + .is_empty() + ); + + let mut duplicate = document.clone(); + duplicate.blocks.push(duplicate.blocks[1].clone()); + assert!( + ReferenceIndex::new(&duplicate) + .references(2, "Id. at 116.") + .is_empty() + ); + + let mut duplicate_source = document.clone(); + duplicate_source + .blocks + .push(duplicate_source.blocks[3].clone()); + assert!( + ReferenceIndex::new(&duplicate_source) + .references(2, "Id. at 116.") + .is_empty() + ); + + let mut duplicate_link = document.clone(); + duplicate_link + .footnote_links + .push(duplicate_link.footnote_links[0].clone()); + assert!( + ReferenceIndex::new(&duplicate_link) + .references(2, "Id. at 116.") + .is_empty() + ); + + let mut missing_link = document.clone(); + missing_link.footnote_links.retain(|link| link.marker != 1); + assert!( + ReferenceIndex::new(&missing_link) + .references(2, "Id. at 116.") + .is_empty() + ); + + let mut out_of_order = document.clone(); + let mut unrelated = out_of_order.blocks[1].clone(); + unrelated.text = "3. An intervening note.".into(); + out_of_order.blocks.insert(3, unrelated); + out_of_order.footnote_links[1].note_block_index = 4; + assert!( + ReferenceIndex::new(&out_of_order) + .references(2, "Id. at 116.") + .is_empty() + ); + } + + #[test] + fn consecutive_id_notes_link_to_the_immediate_note_and_keep_its_pinpoint() { + let mut document = (*fixture()).clone(); + document.blocks[1].text = "1. Smith v. Jones, 410 U.S. 113, 115 (1973).".into(); + document.blocks[3].text = "2. Id. at 116.".into(); + let mut third_body = document.blocks[2].clone(); + third_body.text = format!( + "Third passage {}3{}.", + layout_roles::CALLOUT_START, + layout_roles::CALLOUT_END + ); + let mut third_note = document.blocks[3].clone(); + third_note.text = "3. Id.".into(); + document.blocks.push(third_body); + document.blocks.push(third_note); + document.footnote_links.push(LiquidFootnoteLink { + body_block_index: 4, + body_marker_ordinal: 0, + marker: 3, + note_block_index: 5, + body_page_index: None, + note_page_index: None, + }); + let index = ReferenceIndex::new(&document); + assert_eq!(index.references(2, "Id. at 116."), vec![(0..3, 1)]); + assert_eq!(index.references(3, "Id."), vec![(0..3, 2)]); + assert_eq!(index.targets[&2].body, "Id. at 116."); + + let mut ambiguous = document.clone(); + ambiguous.blocks[3].text = "2. Id. at 116; see also Adams.".into(); + assert!( + ReferenceIndex::new(&ambiguous) + .references(3, "Id.") + .is_empty() + ); + let mut duplicate = document.clone(); + duplicate.blocks.push(duplicate.blocks[3].clone()); + assert!( + ReferenceIndex::new(&duplicate) + .references(3, "Id.") + .is_empty() + ); + } + + #[test] + fn id_reference_exposes_an_accessible_jump_to_the_preceding_note() { + let (mut app, ctx, _worker) = annotation_saving::tests::fixture(); + let mut doc = (*fixture()).clone(); + doc.blocks[1].text = "1. Smith v. Jones, 410 U.S. 113 (1973).".into(); + doc.blocks[3].text = "2. Id. at 116.".into(); + let document = Arc::new(doc); + app.view_mode = DocumentViewMode::LiquidMode2; + app.liquid_mode2_state = LiquidState::Ready(Arc::clone(&document)); + app.refresh_review_derived(&document); + ctx.enable_accesskit(); + let input = egui::RawInput { + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, Vec2::new(600.0, 400.0))), + ..Default::default() + }; + let output = ctx.run(input, |ctx| { + egui::CentralPanel::default().show(ctx, |ui| { + app.draw_note_reference_text( + ui, + 2, + "Id. at 116.", + 15.0, + Color32::BLACK, + Color32::DARK_RED, + ) + }); + }); + let tree = output.platform_output.accesskit_update.unwrap(); + let id = tree + .nodes + .iter() + .find_map(|(id, node)| (node.label() == Some("Go to note 1")).then_some(*id)) + .expect("accessible Id. reference"); + let input = egui::RawInput { + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, Vec2::new(600.0, 400.0))), + events: vec![egui::Event::AccessKitActionRequest( + egui::accesskit::ActionRequest { + action: egui::accesskit::Action::Click, + target: id, + data: None, + }, + )], + ..Default::default() + }; + let _ = ctx.run(input, |ctx| { + egui::CentralPanel::default().show(ctx, |ui| { + app.draw_note_reference_text( + ui, + 2, + "Id. at 116.", + 15.0, + Color32::BLACK, + Color32::DARK_RED, + ) + }); + }); + assert_eq!(app.liquid_scroll_to_block, Some(0)); + assert_eq!(app.pending_reference_note, Some((0, 1))); + } + + #[test] + fn refuses_duplicate_notes_duplicate_links_and_forward_references() { + let mut document = (*fixture()).clone(); + let index = ReferenceIndex::new(&document); + assert_eq!(index.references(2, "See supra note 1").len(), 1); + assert!(index.references(1, "See supra note 2").is_empty()); + document.blocks.push(document.blocks[1].clone()); + assert!( + ReferenceIndex::new(&document) + .references(2, "supra note 1") + .is_empty() + ); + document.blocks.pop(); + document.footnote_links.push(LiquidFootnoteLink { + body_block_index: 0, + body_marker_ordinal: 0, + marker: 1, + note_block_index: 1, + body_page_index: None, + note_page_index: None, + }); + assert!( + ReferenceIndex::new(&document) + .references(2, "supra note 1") + .is_empty() + ); + } + + #[test] + fn accessible_reference_click_records_jump_and_opens_the_target_on_arrival() { + let (mut app, ctx, _worker) = annotation_saving::tests::fixture(); + let document = fixture(); + app.view_mode = DocumentViewMode::LiquidMode2; + app.liquid_mode2_state = LiquidState::Ready(Arc::clone(&document)); + app.refresh_review_derived(&document); + app.search_state.review_anchor = Some(2); + ctx.enable_accesskit(); + let input = egui::RawInput { + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, Vec2::new(600.0, 400.0))), + ..Default::default() + }; + let output = ctx.run(input, |ctx| { + egui::CentralPanel::default().show(ctx, |ui| { + app.draw_note_reference_text( + ui, + 2, + "See supra note 1, at 42.", + 15.0, + Color32::BLACK, + Color32::DARK_RED, + ) + }); + }); + let tree = output.platform_output.accesskit_update.unwrap(); + let id = tree + .nodes + .iter() + .find_map(|(id, node)| (node.label() == Some("Go to note 1")).then_some(*id)) + .expect("accessible reference"); + let input = egui::RawInput { + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, Vec2::new(600.0, 400.0))), + events: vec![egui::Event::AccessKitActionRequest( + egui::accesskit::ActionRequest { + action: egui::accesskit::Action::Click, + target: id, + data: None, + }, + )], + ..Default::default() + }; + let _ = ctx.run(input, |ctx| { + egui::CentralPanel::default().show(ctx, |ui| { + app.draw_note_reference_text( + ui, + 2, + "See supra note 1, at 42.", + 15.0, + Color32::BLACK, + Color32::DARK_RED, + ) + }); + }); + assert_eq!(app.liquid_scroll_to_block, Some(0)); + assert_eq!(app.pending_reference_note, Some((0, 1))); + let popup_id = liquid_footnote_popup_id( + &liquid_feedback_id(&document.source_signature, 0, &document.blocks[0]), + 1, + ); + let _ = ctx.run( + egui::RawInput { + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, Vec2::new(600.0, 400.0))), + ..Default::default() + }, + |ctx| { + egui::CentralPanel::default().show(ctx, |ui| { + app.draw_liquid_feedback_block(ui, &document, 0, &document.blocks[0]); + }); + }, + ); + assert!(app.pending_reference_note.is_none()); + assert!(egui::Popup::is_id_open(&ctx, popup_id)); + let _ = ctx.run(Default::default(), |ctx| { + app.navigate_jump_history(ctx, false) + }); + assert_eq!(app.liquid_scroll_to_block, Some(2)); + } + + #[test] + fn pointer_click_on_reference_jumps_to_the_earlier_note() { + let (mut app, ctx, _worker) = annotation_saving::tests::fixture(); + let document = fixture(); + app.view_mode = DocumentViewMode::LiquidMode2; + app.liquid_mode2_state = LiquidState::Ready(Arc::clone(&document)); + app.refresh_review_derived(&document); + let body = "See supra note 1, at 42."; + let draw = |app: &mut PdfEditorApp, time, events| { + ctx.run( + egui::RawInput { + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, Vec2::new(600.0, 400.0))), + time: Some(time), + events, + ..Default::default() + }, + |ctx| { + egui::CentralPanel::default().show(ctx, |ui| { + app.draw_note_reference_text( + ui, + 2, + body, + 15.0, + Color32::BLACK, + Color32::DARK_RED, + ) + }); + }, + ) + }; + let output = draw(&mut app, 0.0, vec![]); + let point = output + .shapes + .iter() + .find_map(|shape| { + if let egui::Shape::Text(text) = &shape.shape { + if text.galley.text() == body { + return review_paragraph::marker_rects( + &text.galley, + text.pos, + &[review_paragraph::MarkerRange { + start: 4, + end: 16, + number: 1, + }], + ) + .first() + .map(|(rect, _)| rect.center()); + } + } + None + }) + .expect("reference geometry"); + let hovered = draw(&mut app, 0.1, vec![egui::Event::PointerMoved(point)]); + assert_eq!( + hovered.platform_output.cursor_icon, + CursorIcon::PointingHand + ); + for (time, pressed) in [(0.2, true), (0.3, false)] { + let _ = draw( + &mut app, + time, + vec![egui::Event::PointerButton { + pos: point, + button: egui::PointerButton::Primary, + pressed, + modifiers: egui::Modifiers::NONE, + }], + ); + } + assert_eq!(app.pending_reference_note, Some((0, 1))); + } + + #[test] + fn unshortened_margin_note_reference_is_clickable() { + let (mut app, ctx, _worker) = annotation_saving::tests::fixture(); + let document = fixture(); + app.view_mode = DocumentViewMode::LiquidMode2; + app.liquid_mode2_state = LiquidState::Ready(Arc::clone(&document)); + app.refresh_review_derived(&document); + let mut collector = ReviewMarginCollector::default(); + collector.add_note_block(3, &document.blocks[3].text, 80.0); + let draw = |app: &mut PdfEditorApp, time, events| { + ctx.run( + egui::RawInput { + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, Vec2::new(800.0, 600.0))), + time: Some(time), + events, + ..Default::default() + }, + |ctx| { + egui::CentralPanel::default().show(ctx, |ui| { + app.draw_review_margin_notes(ui, collector.clone(), 300.0, 0.0, 280.0) + }); + }, + ) + }; + let output = draw(&mut app, 0.0, vec![]); + let point = output + .shapes + .iter() + .find_map(|shape| { + if let egui::Shape::Text(text) = &shape.shape { + let visible = text.galley.text(); + if let Some((range, number)) = reference_ranges(visible).first() { + let marker = review_paragraph::MarkerRange { + start: visible[..range.start].chars().count(), + end: visible[..range.end].chars().count(), + number: *number, + }; + return review_paragraph::marker_rects(&text.galley, text.pos, &[marker]) + .first() + .map(|(rect, _)| rect.center()); + } + } + None + }) + .expect("margin reference geometry"); + let _ = draw(&mut app, 0.1, vec![egui::Event::PointerMoved(point)]); + for (time, pressed) in [(0.2, true), (0.3, false)] { + let _ = draw( + &mut app, + time, + vec![egui::Event::PointerButton { + pos: point, + button: egui::PointerButton::Primary, + pressed, + modifiers: egui::Modifiers::NONE, + }], + ); + } + assert_eq!(app.pending_reference_note, Some((0, 1))); + assert!(app.chrome.expanded_note.is_none()); + } +} diff --git a/src/app/review_reveal.rs b/src/app/review_reveal.rs index d6441ac..1f06cd2 100644 --- a/src/app/review_reveal.rs +++ b/src/app/review_reveal.rs @@ -3,8 +3,8 @@ //! The words of the page the reader was on lift off the printed page and //! settle where they now live: body text into the reading column, footnote //! text into the margin cards beside the lines that cite them. It is a -//! one-time explanation of what Review Mode did to the page, so it is kept -//! short, it plays once per document, it never runs when the reader prefers +//! one-time explanation of what Review Mode did to the page. It plays once +//! per document, it never runs when the reader prefers //! reduced motion, and any click, scroll or key ends it at once. //! //! Sources and destinations are measured, never guessed: the printed @@ -19,14 +19,13 @@ use std::sync::Arc; use egui::{Color32, Galley, Pos2, Rect, TextureId, Vec2}; -use super::PagePlacement; +use super::{PagePlacement, motion}; use crate::liquid::{LiquidBlockRole, LiquidDocument}; use crate::model::{PageTextChar, PdfRect}; use crate::review_reading::{is_review_note_display_block, split_fused_review_notes}; -/// Flight time of the reveal. Short enough to read as a settle, long enough -/// to follow a word with the eye. -pub(super) const REVEAL_SECONDS: f64 = 1.05; +/// Total time for the complete reveal, including its opening and landing. +pub(super) const REVEAL_SECONDS: f64 = motion::REVEAL_SECONDS; /// Source lines of each Review block on the page: (page index, line text). pub(super) type BlockLines = HashMap>; /// Source lines of each footnote shown as a margin note, by note number. @@ -38,8 +37,13 @@ const MIN_MATCHED_WORDS: usize = 16; const MAX_PAGE_WORDS: usize = 1400; /// Frames to wait for Review Mode to scroll to the page before giving up. const MAX_SETTLE_FRAMES: u8 = 8; -/// Reading-order stagger: the last word leaves this far into the flight. -const STAGGER: f32 = 0.32; +/// A brief still moment makes the starting page legible before it lifts. +const START_HOLD: f32 = 0.06; +/// Successive words leave in reading order over this part of the timeline. +const STAGGER: f32 = 0.40; +/// Each word gets the same flight time, including the last word to leave. +const FLIGHT_DURATION: f32 = 0.48; +const PAGE_FADE_END: f32 = 0.74; /// Alignment work is bounded so a pathological page cannot stall a frame. const MAX_ALIGN_TOKENS: usize = 700; @@ -143,22 +147,26 @@ impl ReviewReveal { if page_words.len() < MIN_MATCHED_WORDS || page_words.len() > MAX_PAGE_WORDS { return None; } - let total = page_words.len().max(1) as f32; - let words = page_words + let mut words = page_words .into_iter() - .enumerate() - .map(|(index, (text, rect))| RevealWord { + .map(|(text, rect)| RevealWord { key: key(&text), text, from: placement.pdf_rect_to_screen(rect), to: None, - order: index as f32 / total, + order: 0.0, }) .filter(|word| word.from.intersects(viewport.expand(40.0))) .collect::>(); if words.len() < MIN_MATCHED_WORDS { return None; } + // Stagger the words the reader can actually see across the whole + // sequence, even when most of a long page is outside the viewport. + let last = (words.len() - 1) as f32; + for (index, word) in words.iter_mut().enumerate() { + word.order = index as f32 / last; + } Some(Self { page_index, page_rect: placement.rect, @@ -335,12 +343,13 @@ impl ReviewReveal { matched } - /// Eased 0..1 progress of the flight, or `None` when not playing. + /// Linear 0..1 timeline. Individual words have their own easing and + /// departure times; easing the global clock as well makes the middle rush. pub(super) fn progress(&self, now: f64) -> Option { match self.phase { RevealPhase::Playing { started } => { let linear = ((now - started) / REVEAL_SECONDS).clamp(0.0, 1.0) as f32; - Some(ease_in_out(linear)) + Some(linear) } RevealPhase::Settling { .. } => Some(0.0), RevealPhase::Done => None, @@ -448,9 +457,9 @@ pub(super) struct Flight { /// destination stay on the printed page and fade with it; they get `None`. pub(super) fn flight(word: &RevealWord, end: Pos2, p: f32) -> Option { let to = word.to?; - let depart = STAGGER * word.order; - let local = ((p - depart) / (1.0 - depart)).clamp(0.0, 1.0); - let e = ease_in_out(local); + let depart = START_HOLD + STAGGER * word.order.clamp(0.0, 1.0); + let local = ((p - depart) / FLIGHT_DURATION).clamp(0.0, 1.0); + let e = motion::smoothstep(0.0, 1.0, local); let from_size = (word.from.height() * 0.82).clamp(6.0, 40.0); let lift = if word.from.center().y > to.rect.center().y { 18.0 @@ -465,34 +474,26 @@ pub(super) fn flight(word: &RevealWord, end: Pos2, p: f32) -> Option { scale: size / to.style.size.max(1.0), // Invisible until it lifts, so the printed glyph underneath is the // only one the eye sees at rest. - alpha: smoothstep(0.0, 0.1, local), + alpha: motion::smoothstep(0.0, 0.1, local), departed: local, settled: local >= 0.97, }) } /// How much of the printed page is still on screen at progress `p`. It -/// stays until every word has lifted (the last departs at `STAGGER`). +/// stays until every visible word has started lifting. pub(super) fn page_alpha(p: f32) -> f32 { - 1.0 - smoothstep(STAGGER, 0.62, p) -} - -pub(super) fn with_alpha(color: Color32, alpha: f32) -> Color32 { - color.gamma_multiply(alpha.clamp(0.0, 1.0)) + 1.0 - motion::smoothstep(START_HOLD + STAGGER, PAGE_FADE_END, p) } -fn ease_in_out(t: f32) -> f32 { - let t = t.clamp(0.0, 1.0); - if t < 0.5 { - 4.0 * t * t * t - } else { - 1.0 - (-2.0 * t + 2.0).powi(3) / 2.0 - } +/// A landed word still needs to be drawn over the source page until the +/// page has faded away and its real Review text is fully visible. +pub(super) fn show_flight(flight: &Flight, page_alpha: f32) -> bool { + flight.alpha > 0.01 && (!flight.settled || page_alpha > 0.0) } -fn smoothstep(a: f32, b: f32, x: f32) -> f32 { - let t = ((x - a) / (b - a)).clamp(0.0, 1.0); - t * t * (3.0 - 2.0 * t) +pub(super) fn with_alpha(color: Color32, alpha: f32) -> Color32 { + color.gamma_multiply(alpha.clamp(0.0, 1.0)) } /// Matching key: letters and digits, lowercased. Punctuation and ligature @@ -798,6 +799,37 @@ mod tests { assert_eq!(words.len(), 2); } + #[test] + fn stagger_uses_the_full_range_of_visible_words() { + let mut chars = Vec::new(); + for index in 0..40 { + chars.push(ch('a', index as f32 * 10.0, 100.0)); + chars.push(PageTextChar { + ch: ' ', + rect: None, + font_size: None, + bold: false, + italic: false, + }); + } + let placement = PagePlacement { + rect: Rect::from_min_size(Pos2::ZERO, Vec2::new(400.0, 200.0)), + page_width: 400.0, + page_height: 200.0, + }; + let viewport = Rect::from_min_size(Pos2::ZERO, Vec2::new(160.0, 200.0)); + let reveal = ReviewReveal::plan(0, &placement, None, &chars, viewport).unwrap(); + assert!(reveal.words.len() >= MIN_MATCHED_WORDS && reveal.words.len() < 40); + assert_eq!(reveal.words.first().unwrap().order, 0.0); + assert_eq!(reveal.words.last().unwrap().order, 1.0); + assert!( + reveal + .words + .windows(2) + .all(|pair| pair[0].order < pair[1].order) + ); + } + #[test] fn locate_lines_walks_contiguous_runs_with_tolerance() { let page = [ @@ -816,10 +848,7 @@ mod tests { let page = ["one", "two"].map(key); // The first line reaches the end; the next line starts with the last // word but has no complete two-word match on this page. - assert_eq!( - locate_lines(&page, &["one two", "two missing"]), - vec![0, 1] - ); + assert_eq!(locate_lines(&page, &["one two", "two missing"]), vec![0, 1]); assert!(locate_lines(&[key("two")], &["two missing"]).is_empty()); } @@ -882,7 +911,74 @@ mod tests { #[test] fn page_stays_until_every_word_has_lifted() { assert_eq!(page_alpha(0.0), 1.0); - assert_eq!(page_alpha(STAGGER), 1.0); - assert_eq!(page_alpha(0.62), 0.0); + assert_eq!(page_alpha(START_HOLD + STAGGER), 1.0); + assert!(page_alpha(0.6) > 0.0 && page_alpha(0.6) < 1.0); + assert_eq!(page_alpha(PAGE_FADE_END), 0.0); + } + + #[test] + fn reveal_gives_each_word_time_and_keeps_early_landings_visible() { + let mut word = RevealWord { + text: "held".into(), + key: "held".into(), + from: Rect::from_min_size(Pos2::new(100.0, 600.0), Vec2::new(30.0, 12.0)), + to: Some(Landing { + rect: Rect::from_min_size(Pos2::new(400.0, 200.0), Vec2::new(40.0, 20.0)), + baseline: 215.0, + style: LandingStyle { + size: 17.0, + line_height: None, + }, + }), + order: 0.0, + }; + let end = Pos2::new(400.0, 201.0); + assert_eq!(flight(&word, end, START_HOLD).unwrap().departed, 0.0); + let first_mid = flight(&word, end, START_HOLD + FLIGHT_DURATION / 2.0).unwrap(); + assert!((first_mid.departed - 0.5).abs() < 1e-5); + let first_landed = flight(&word, end, START_HOLD + FLIGHT_DURATION).unwrap(); + assert!(first_landed.settled); + assert!(page_alpha(START_HOLD + FLIGHT_DURATION) > 0.0); + assert!(show_flight( + &first_landed, + page_alpha(START_HOLD + FLIGHT_DURATION) + )); + assert!(!show_flight(&first_landed, 0.0)); + + word.order = 1.0; + assert_eq!( + flight(&word, end, START_HOLD + STAGGER).unwrap().departed, + 0.0 + ); + let last_mid = flight(&word, end, START_HOLD + STAGGER + FLIGHT_DURATION / 2.0).unwrap(); + assert!((last_mid.departed - 0.5).abs() < 1e-5); + assert!( + flight(&word, end, START_HOLD + STAGGER + FLIGHT_DURATION) + .unwrap() + .settled + ); + assert!( + f64::from(START_HOLD + STAGGER + FLIGHT_DURATION) * REVEAL_SECONDS < REVEAL_SECONDS + ); + } + + #[test] + fn reveal_uses_a_linear_clock_and_finishes_after_3_2_seconds() { + let reveal = ReviewReveal { + page_index: 0, + page_rect: Rect::NOTHING, + texture: None, + words: Vec::new(), + phase: RevealPhase::Playing { started: 10.0 }, + labels: Vec::new(), + notes: Vec::new(), + last_layout: Vec::new(), + galleys: Vec::new(), + }; + assert_eq!(reveal.progress(10.0), Some(0.0)); + assert!((reveal.progress(10.8).unwrap() - 0.25).abs() < 1e-5); + assert!((reveal.progress(11.6).unwrap() - 0.5).abs() < 1e-5); + assert!(!reveal.finished(13.19)); + assert!(reveal.finished(13.21)); } } diff --git a/src/app/save_as.rs b/src/app/save_as.rs index cfb67cf..864d970 100644 --- a/src/app/save_as.rs +++ b/src/app/save_as.rs @@ -10,6 +10,14 @@ pub(super) struct SaveAsTask { result: Option>>, } +fn guarded_save_as( + work: impl FnOnce() -> Result, +) -> Result { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(work)).unwrap_or_else(|_| { + Err("The Save As task stopped. Your original and edits were kept.".to_owned()) + }) +} + impl PdfEditorApp { pub(super) fn save_as_dialog(&mut self, ctx: &Context) { if self.save_as.is_some() || self.recovery_is_busy() || self.page_rotation_in_flight { @@ -86,11 +94,11 @@ impl PdfEditorApp { task.result = Some(rx); let ctx = ctx.clone(); std::thread::spawn(move || { - let result = (|| { + let result = guarded_save_as(|| { let store = DocumentStore::new()?; store.export_annotations(&source, &revision, &annotations, &destination)?; store.capture(&destination) - })(); + }); let _ = tx.send(result); ctx.request_repaint(); }); @@ -112,10 +120,11 @@ impl PdfEditorApp { ui.add_space(48.0); ui.heading("Saving PDF…"); ui.label(task.destination.display().to_string()); - ui.spinner(); + if !self.settings.reduce_motion { + ui.spinner(); + } }); }); - ctx.request_repaint_after(RENDER_POLL_INTERVAL); true } @@ -200,6 +209,45 @@ impl PdfEditorApp { mod tests { use super::*; + #[test] + fn reduced_motion_save_as_waits_without_polling_and_reports_failure() { + let (mut app, ctx, _worker) = super::super::annotation_saving::tests::fixture(); + app.settings.reduce_motion = true; + ctx.style_mut(|style| style.animation_time = 0.0); + app.annotations_dirty = true; + let source = app.document.as_ref().unwrap().path.clone(); + let destination = source.with_file_name("save-as-idle.pdf"); + app.begin_save_as(destination.clone()).unwrap(); + let (sender, receiver) = unbounded(); + app.save_as.as_mut().unwrap().result = Some(receiver); + for frame in 0..8 { + let output = ctx.run( + egui::RawInput { + time: Some(frame as f64 * 0.2), + ..Default::default() + }, + |ctx| { + assert!(app.poll_save_as(ctx)); + }, + ); + if frame >= 4 { + assert_eq!( + output.viewport_output[&egui::ViewportId::ROOT].repaint_delay, + Duration::MAX + ); + } + } + sender.send(Err("test failure".to_owned())).unwrap(); + let _ = ctx.run(Default::default(), |ctx| { + assert!(!app.poll_save_as(ctx)); + }); + assert!(app.save_as.is_none()); + assert!(app.annotations_dirty); + assert_eq!(app.document.as_ref().unwrap().path, source); + assert!(!destination.exists()); + assert!(guarded_save_as(|| panic!("test panic")).is_err()); + } + fn edited_fixture() -> (PdfEditorApp, Context, Receiver) { let (mut app, ctx, rx) = super::super::annotation_saving::tests::fixture(); let path = &app.document.as_ref().unwrap().path; diff --git a/src/app/search_state/interaction_tests.rs b/src/app/search_state/interaction_tests.rs index 4ad09b6..7fb3257 100644 --- a/src/app/search_state/interaction_tests.rs +++ b/src/app/search_state/interaction_tests.rs @@ -251,6 +251,42 @@ fn find_review_occurrences_remain_distinct_within_the_same_block() { ); } +#[test] +fn find_lands_on_text_after_a_review_footnote_marker() { + let ctx = Context::default(); + let mut app = reader(&ctx); + let text = format!("Before {}12{} the target passage.", layout_roles::CALLOUT_START, layout_roles::CALLOUT_END); + let review: LiquidDocument = serde_json::from_value(serde_json::json!({ + "title":"Reader","blocks":[{"role":"paragraph","text":text}, {"role":"footnote","text":"12. A note."}], + "llm_used":false,"warnings":[],"source_signature":"find-callout" + })).unwrap(); + app.view_mode = DocumentViewMode::LiquidMode2; + app.liquid_mode2_complete = true; + app.liquid_mode2_state = LiquidState::Ready(Arc::new(review)); + app.search_state.query = "target".into(); + app.search_state.open = true; + let end = Instant::now() + Duration::from_secs(4); + while app.search_state.hits.is_empty() && Instant::now() < end { + frame(&ctx, &mut app, vec![], egui::Modifiers::NONE); + std::thread::yield_now(); + } + assert_eq!(app.search_state.hits.len(), 1); + app.find_step(&ctx, false); + let doc = match &app.liquid_mode2_state { + LiquidState::Ready(d) => Arc::clone(d), + _ => unreachable!(), + }; + app.liquid_footnote_index = build_liquid_footnote_index(&doc.blocks); + let input = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, Vec2::new(960.0, 700.0))), ..Default::default() }; + let _ = ctx.run(input, |ctx| { + egui::CentralPanel::default().show(ctx, |ui| { + app.draw_liquid_feedback_block(ui, &doc, 0, &doc.blocks[0]); + }); + }); + assert!(!app.search_state.landing, "the visible match must land on its actual glyphs"); + assert!(!app.search_state.block_landed, "a normal match must not need a fallback excerpt"); +} + #[test] fn find_scope_follows_the_focused_split_pane() { let ctx = Context::default(); diff --git a/src/app/selection_state.rs b/src/app/selection_state.rs index e6c743c..3d59e94 100644 --- a/src/app/selection_state.rs +++ b/src/app/selection_state.rs @@ -5,6 +5,579 @@ pub(super) struct SelectionState { pub(super) text: Option, pub(super) liquid_all: bool, pub(super) anchor: Option<(usize, usize)>, + /// Fixed end for Shift-click, including when the original drag went up. + pub(super) extend_anchor: Option<(usize, usize)>, pub(super) toolbar_rect: Option, pub(super) pending_select_all: bool, + pub(super) pending_mark: bool, +} + +pub(super) fn selected_text( + pages: &[Option>], + selection: TextSelection, +) -> Option { + let mut text = String::new(); + for page_index in selection.page_range() { + let chars = pages.get(page_index)?.as_ref()?; + let Some((start, end)) = selection.bounds_for_page(page_index, chars.len()) else { + continue; + }; + if !text.is_empty() && !text.ends_with('\n') && chars[start].ch != '\n' { + text.push('\n'); + } + text.extend(chars[start..=end].iter().map(|character| character.ch)); + } + (!text.is_empty()).then_some(text) +} + +pub(super) fn word_bounds(chars: &[PageTextChar], index: usize) -> Option<(usize, usize)> { + let character = chars.get(index)?.ch; + let in_word = |c: char| c.is_alphanumeric() || matches!(c, '_' | '\'' | '’'); + if !in_word(character) { + return (!character.is_whitespace()).then_some((index, index)); + } + let mut start = index; + let mut end = index; + while start > 0 && in_word(chars[start - 1].ch) { + start -= 1; + } + while end + 1 < chars.len() && in_word(chars[end + 1].ch) { + end += 1; + } + Some((start, end)) +} + +#[derive(Clone, Copy)] +struct TextLine { + start: usize, + end: usize, + breaks_before: usize, + rect: Option, +} + +/// Estimate a page-local paragraph when no matching Review block is available. +/// PDF text does not carry reliable paragraph boundaries, so use visible gaps, +/// indentation, and explicit blank lines; leave unlocated lines separate when +/// there is no layout evidence. +pub(super) fn paragraph_bounds(chars: &[PageTextChar], index: usize) -> Option<(usize, usize)> { + let mut lines = Vec::::new(); + let mut at = 0; + let mut breaks = 0; + while at < chars.len() { + while at < chars.len() && chars[at].ch.is_whitespace() { + if chars[at].ch == '\r' { + breaks += 1; + if chars.get(at + 1).is_some_and(|ch| ch.ch == '\n') { + at += 1; + } + } else if chars[at].ch == '\n' { + breaks += 1; + } + at += 1; + } + if at == chars.len() { + break; + } + let start = at; + while at < chars.len() && !matches!(chars[at].ch, '\r' | '\n') { + at += 1; + } + let mut end = at - 1; + while end > start && chars[end].ch.is_whitespace() { + end -= 1; + } + let rect = chars[start..=end] + .iter() + .filter_map(|ch| ch.rect) + .reduce(|a, b| { + PdfRect::new( + a.left.min(b.left), + a.bottom.min(b.bottom), + a.right.max(b.right), + a.top.max(b.top), + ) + }); + lines.push(TextLine { + start, + end, + breaks_before: breaks, + rect, + }); + breaks = 0; + } + let selected = lines + .iter() + .position(|line| line.start <= index && index <= line.end)?; + let mut heights = lines + .iter() + .filter_map(|line| line.rect.map(PdfRect::height)) + .filter(|height| height.is_finite() && *height > 0.0) + .collect::>(); + heights.sort_by(f32::total_cmp); + let height = heights.get(heights.len() / 2).copied().unwrap_or(10.0); + let max_width = lines + .iter() + .filter_map(|line| line.rect.map(PdfRect::width)) + .filter(|width| width.is_finite()) + .fold(0.0_f32, f32::max); + let left = lines + .iter() + .filter_map(|line| line.rect.map(|rect| rect.left)) + .filter(|left| left.is_finite()) + .fold(f32::INFINITY, f32::min); + let explicit_paragraphs = lines.iter().any(|line| line.breaks_before > 1); + let boundary = |previous: TextLine, next: TextLine| { + if next.breaks_before > 1 { + return true; + } + let (Some(a), Some(b)) = (previous.rect, next.rect) else { + return !explicit_paragraphs; + }; + if a.bottom - b.top > (height * 0.65).max(5.0) + || b.top - a.top > height * 0.6 + || (b.left - a.left).abs() > max_width * 0.5 + { + return true; + } + if b.left - left > (height * 0.85).max(7.0) && a.left - left < (height * 0.55).max(4.0) { + return true; + } + let ending = chars[previous.start..=previous.end] + .iter() + .rev() + .map(|ch| ch.ch) + .find(|ch| { + !ch.is_whitespace() && !ch.is_ascii_digit() && !matches!(ch, ')' | ']' | '”' | '’') + }); + max_width > 0.0 + && a.width() < max_width * 0.68 + && ending.is_some_and(|ch| matches!(ch, '.' | '!' | '?' | ':')) + }; + let mut first = selected; + while first > 0 && !boundary(lines[first - 1], lines[first]) { + first -= 1; + } + let mut last = selected; + while last + 1 < lines.len() && !boundary(lines[last], lines[last + 1]) { + last += 1; + } + Some((lines[first].start, lines[last].end)) +} + +/// Use Review's mapped source lines when they identify one unambiguous block +/// and every page of that block already has character geometry. +fn mapped_paragraph( + loaded: &LoadedDocument, + review: &LiquidDocument, + geometry: &HashMap<(usize, usize), PdfRect>, + page: usize, + index: usize, +) -> Option { + let clicked = loaded.text_chars.get(page)?.as_ref()?.get(index)?.rect?; + let center = ( + (clicked.left + clicked.right) * 0.5, + (clicked.bottom + clicked.top) * 0.5, + ); + let mut matching = review.block_source_lines.iter().filter(|source| { + review.blocks.get(source.block_index).is_some_and(|block| { + matches!( + block.role, + LiquidBlockRole::Paragraph + | LiquidBlockRole::Lead + | LiquidBlockRole::Abstract + | LiquidBlockRole::Quote + | LiquidBlockRole::ListItem + | LiquidBlockRole::Clause + | LiquidBlockRole::Footnote + | LiquidBlockRole::Marginalia + ) + }) && source.lines.iter().any(|line| { + line.page_index == page + && geometry + .get(&(line.page_index, line.line_index)) + .is_some_and(|rect| point_in_pdf_rect(center, *rect)) + }) + }); + let source = matching.next()?; + if matching.next().is_some() { + return None; + } + let mut positions = Vec::new(); + let mut pages = source + .lines + .iter() + .map(|line| line.page_index) + .collect::>(); + pages.sort_unstable(); + pages.dedup(); + if pages + .windows(2) + .any(|pair| pair[1] != pair[0].saturating_add(1)) + { + return None; + } + for page_index in pages { + let rects = source + .lines + .iter() + .filter(|line| line.page_index == page_index) + .map(|line| geometry.get(&(page_index, line.line_index)).copied()) + .collect::>>()?; + let chars = loaded.text_chars.get(page_index)?.as_ref()?; + let matched = chars + .iter() + .enumerate() + .filter_map(|(index, ch)| { + let rect = ch.rect?; + let center = ( + (rect.left + rect.right) * 0.5, + (rect.bottom + rect.top) * 0.5, + ); + (!ch.ch.is_control() && rects.iter().any(|rect| point_in_pdf_rect(center, *rect))) + .then_some(index) + }) + .collect::>(); + positions.push((page_index, *matched.first()?)); + positions.push((page_index, *matched.last()?)); + } + let start = *positions.iter().min()?; + let end = *positions.iter().max()?; + ((start..=end).contains(&(page, index))) + .then_some(TextSelection::range(start.0, start.1, end.0, end.1)) +} + +impl PdfEditorApp { + pub(super) fn mapped_paragraph_selection( + &mut self, + page: usize, + index: usize, + ) -> Option { + let review = match &self.liquid_mode2_state { + LiquidState::Ready(document) => Arc::clone(document), + _ => return None, + }; + let geometry = self.review_source_geometry_for_current_document()?; + mapped_paragraph(self.document.as_ref()?, &review, &geometry, page, index) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn chars(text: &str) -> Vec { + text.chars() + .map(|ch| PageTextChar { + ch, + rect: None, + font_size: None, + bold: false, + italic: false, + }) + .collect() + } + + #[test] + fn selection_copies_across_pages_in_both_drag_directions() { + let pages = vec![ + Some(chars("first page ending")), + Some(chars("next page begins")), + ]; + let forward = TextSelection::range(0, 11, 1, 3); + let backward = TextSelection::range(1, 3, 0, 11); + assert_eq!( + selected_text(&pages, forward).as_deref(), + Some("ending\nnext") + ); + assert_eq!( + selected_text(&pages, backward), + selected_text(&pages, forward) + ); + assert!(selected_text(&[pages[0].clone(), None], forward).is_none()); + } + + #[test] + fn double_click_word_bounds_respect_whitespace_and_unicode() { + let text = chars("The court’s ruling."); + let (start, end) = word_bounds(&text, 6).unwrap(); + assert_eq!( + text[start..=end].iter().map(|c| c.ch).collect::(), + "court’s" + ); + assert_eq!(word_bounds(&text, 3), None); + } + + #[test] + fn paragraph_bounds_respect_explicit_blank_lines_without_geometry() { + let text = chars("First line\r\nstill first.\r\n\r\nSecond paragraph."); + let (start, end) = paragraph_bounds(&text, 2).unwrap(); + assert_eq!( + text[start..=end].iter().map(|c| c.ch).collect::(), + "First line\r\nstill first." + ); + let unlocated = chars("one line\nanother line"); + let (start, end) = paragraph_bounds(&unlocated, 2).unwrap(); + assert_eq!( + unlocated[start..=end] + .iter() + .map(|c| c.ch) + .collect::(), + "one line" + ); + } + + #[test] + fn paragraph_bounds_use_indent_and_line_geometry() { + let rows = [ + ( + 72.0, + 700.0, + "First paragraph is long enough to fill the line", + ), + (60.0, 686.0, "and ends here."), + (72.0, 672.0, "The next paragraph also has a full first line"), + (60.0, 658.0, "and finishes here."), + ]; + let mut text = Vec::new(); + for (x, bottom, line) in rows { + if !text.is_empty() { + text.extend(chars("\r\n")); + } + text.extend(line.chars().enumerate().map(|(index, ch)| PageTextChar { + ch, + rect: Some(PdfRect::new( + x + index as f32 * 5.0, + bottom, + x + (index + 1) as f32 * 5.0, + bottom + 10.0, + )), + font_size: Some(10.0), + bold: false, + italic: false, + })); + } + let cursor = text.iter().position(|ch| ch.ch == 'n').unwrap(); + let (start, end) = paragraph_bounds(&text, cursor).unwrap(); + assert_eq!( + text[start..=end].iter().map(|ch| ch.ch).collect::(), + "First paragraph is long enough to fill the line\r\nand ends here." + ); + let cursor = text.iter().rposition(|ch| ch.ch == 'f').unwrap(); + let (start, end) = paragraph_bounds(&text, cursor).unwrap(); + assert_eq!( + text[start..=end].iter().map(|ch| ch.ch).collect::(), + "The next paragraph also has a full first line\r\nand finishes here." + ); + } + + #[test] + fn paragraph_estimate_works_with_pdfium_extracted_text() { + use lopdf::{Document, Object, Stream, dictionary}; + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("paragraphs.pdf"); + let mut pdf = Document::with_version("1.5"); + let catalog = pdf.new_object_id(); + let pages = pdf.new_object_id(); + let page = pdf.new_object_id(); + let font = pdf.add_object( + dictionary! { "Type" => "Font", "Subtype" => "Type1", "BaseFont" => "Helvetica" }, + ); + let contents = pdf.add_object(Stream::new(dictionary! {}, b"BT /F1 12 Tf 1 0 0 1 72 700 Tm (First paragraph has a long opening line for selection.) Tj 1 0 0 1 60 685 Tm (It ends here.) Tj 1 0 0 1 72 670 Tm (Second paragraph begins on its own indented line.) Tj 1 0 0 1 60 655 Tm (It also ends here.) Tj ET".to_vec())); + pdf.objects.insert( + catalog, + Object::Dictionary(dictionary! { "Type" => "Catalog", "Pages" => pages }), + ); + pdf.objects.insert(pages, Object::Dictionary(dictionary! { "Type" => "Pages", "Kids" => vec![Object::Reference(page)], "Count" => 1 })); + pdf.objects.insert(page, Object::Dictionary(dictionary! { "Type" => "Page", "Parent" => pages, + "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()], + "Resources" => dictionary! { "Font" => dictionary! { "F1" => font } }, "Contents" => contents })); + pdf.trailer.set("Root", catalog); + pdf.save(&path).unwrap(); + let chars = PdfEngine::new() + .unwrap() + .load_page_text_chars(&path, 0) + .unwrap(); + let index = chars.iter().position(|ch| ch.ch == 'F').unwrap(); + let (start, end) = paragraph_bounds(&chars, index).unwrap(); + let selected = chars[start..=end] + .iter() + .map(|ch| ch.ch) + .collect::(); + assert!( + selected.contains("First paragraph has a long opening line"), + "{selected:?}" + ); + assert!(selected.contains("It ends here."), "{selected:?}"); + assert!(!selected.contains("Second paragraph"), "{selected:?}"); + } + + #[test] + fn mapped_paragraph_uses_all_available_source_pages_and_rejects_ambiguity() { + use crate::liquid::LiquidBlockSourceLines; + let (mut app, _ctx, _worker) = annotation_saving::tests::fixture(); + let loaded = app.document.as_mut().unwrap(); + loaded.page_count = 2; + loaded.text_chars = vec![Some(chars("abc")), Some(chars("def"))]; + for page in 0..2 { + for (index, ch) in loaded.text_chars[page] + .as_mut() + .unwrap() + .iter_mut() + .enumerate() + { + ch.rect = Some(PdfRect::new( + 70.0 + index as f32 * 6.0, + 600.0, + 75.0 + index as f32 * 6.0, + 610.0, + )); + } + } + let line = |page_index| LiquidSourceLineRef { + id: None, + page_index, + line_index: 0, + text: String::new(), + role: LiquidBlockRole::Paragraph, + note_markers: vec![], + }; + let mut review = LiquidDocument { + title: String::new(), + blocks: vec![LiquidBlock { + role: LiquidBlockRole::Paragraph, + text: "abcdef".into(), + label: None, + }], + article_spans: vec![], + block_source_lines: vec![LiquidBlockSourceLines { + block_index: 0, + lines: vec![line(0), line(1)], + }], + footnote_links: vec![], + footnote_link_integrity: None, + profile: None, + noise_lines_removed: 0, + llm_used: false, + llm_provider: None, + deep_liquid_used: false, + deep_liquid_model: None, + warnings: vec![], + source_signature: String::new(), + }; + let geometry = HashMap::from([ + ((0, 0), PdfRect::new(69.0, 599.0, 90.0, 611.0)), + ((1, 0), PdfRect::new(69.0, 599.0, 90.0, 611.0)), + ]); + assert_eq!( + mapped_paragraph(loaded, &review, &geometry, 0, 1), + Some(TextSelection::range(0, 0, 1, 2)) + ); + loaded.text_chars[1] = None; + assert!(mapped_paragraph(loaded, &review, &geometry, 0, 1).is_none()); + loaded.text_chars[1] = Some(chars("def")); + review.blocks.push(review.blocks[0].clone()); + review.block_source_lines.push(LiquidBlockSourceLines { + block_index: 1, + lines: vec![line(0)], + }); + assert!(mapped_paragraph(loaded, &review, &geometry, 0, 1).is_none()); + } + + #[test] + fn triple_click_selects_the_paragraph_in_the_original_view() { + let (mut app, ctx, _worker) = annotation_saving::tests::fixture(); + let rows = [ + ( + 72.0, + 700.0, + "First paragraph is long enough to fill the line", + ), + (60.0, 686.0, "and ends here."), + (72.0, 672.0, "The next paragraph also has a full first line"), + (60.0, 658.0, "and finishes here."), + ]; + let mut text = Vec::new(); + for (x, bottom, line) in rows { + if !text.is_empty() { + text.extend(chars("\r\n")); + } + text.extend(line.chars().enumerate().map(|(index, ch)| PageTextChar { + ch, + rect: Some(PdfRect::new( + x + index as f32 * 5.0, + bottom, + x + (index + 1) as f32 * 5.0, + bottom + 10.0, + )), + font_size: Some(10.0), + bold: false, + italic: false, + })); + } + app.document.as_mut().unwrap().text_chars[0] = Some(text); + app.settings.sidebar_in_pdf = false; + app.zoom = 0.7; + app.target_zoom = 0.7; + app.scroll_target_page = None; + let texture = ctx.load_texture( + "triple-click-test", + egui::ColorImage::filled([4, 4], Color32::WHITE), + TextureOptions::LINEAR, + ); + let render_scale = app.page_render_scale(&ctx, 612.0, 792.0); + app.page_textures.insert( + 0, + PageTexture { + _zoom: app.zoom, + render_scale, + last_used: 1, + texture, + fade_started: None, + }, + ); + let bounds = Rect::from_min_size(Pos2::ZERO, Vec2::new(1200.0, 900.0)); + let draw = |app: &mut PdfEditorApp, time, events| { + let _ = ctx.run( + egui::RawInput { + screen_rect: Some(bounds), + time: Some(time), + events, + ..Default::default() + }, + |ctx| app.draw_document(ctx), + ); + }; + draw(&mut app, 0.0, vec![]); + let point = app + .pdf_page_placements + .iter() + .find(|(page, _)| *page == 0) + .unwrap() + .1 + .pdf_to_screen((84.0, 705.0)); + draw(&mut app, 0.02, vec![egui::Event::PointerMoved(point)]); + for (time, pressed) in [ + (0.10, true), + (0.12, false), + (0.20, true), + (0.22, false), + (0.30, true), + (0.32, false), + ] { + draw( + &mut app, + time, + vec![egui::Event::PointerButton { + pos: point, + button: egui::PointerButton::Primary, + pressed, + modifiers: egui::Modifiers::NONE, + }], + ); + } + assert_eq!( + app.selected_text().as_deref(), + Some("First paragraph is long enough to fill the line\r\nand ends here.") + ); + } } diff --git a/src/app/settings_ui.rs b/src/app/settings_ui.rs index 3087e0b..34aecfe 100644 --- a/src/app/settings_ui.rs +++ b/src/app/settings_ui.rs @@ -142,10 +142,52 @@ impl PdfEditorApp { .vscroll(true) .show(ctx, |ui| { if let Some(error) = &self.settings.credential_error { - ui.colored_label(Color32::DARK_RED, error); + ui.colored_label(ui.visuals().error_fg_color, error); } if self.settings_ui.saving_credentials { ui.disable(); } - ui.label(RichText::new(APP_VERSION_LABEL).size(12.0).color(MUTED_INK)); + ui.label(RichText::new(APP_VERSION_LABEL).size(12.0).color(self.app_palette().muted)); + ui.add_space(8.0); + let mut appearance = self.settings.appearance; + ui.horizontal(|ui| { + ui.label("Appearance"); + ui.selectable_value(&mut appearance, crate::settings::Appearance::System, "System"); + ui.selectable_value(&mut appearance, crate::settings::Appearance::Light, "Light"); + ui.selectable_value(&mut appearance, crate::settings::Appearance::Dark, "Dark"); + }); + if appearance != self.settings.appearance { + self.settings.appearance = appearance; + theme::set_preference(ctx, appearance); + self.sync_review_appearance(ctx); + self.queue_settings_save(); + ctx.request_repaint(); + } + if ui.checkbox(&mut self.settings.reduce_motion, "Reduce motion") + .on_hover_text("Reduce effects such as highlight animations and keyboard scrolling.").changed() + { + let animation = if self.settings.reduce_motion { + egui::style::ScrollAnimation::none() + } else { + egui::style::ScrollAnimation::duration(THUMBNAIL_SCROLL_SECONDS) + }; + ctx.all_styles_mut(|style| style.scroll_animation = animation); + self.queue_settings_save(); + } + if ui.checkbox(&mut self.settings.dim_original_pages, "Dim original PDF pages") + .on_hover_text("Warm and dim original pages on screen. The PDF, printed pages, and exports are unchanged; colors on screen will look different.").changed() + { + self.queue_settings_save(); + ctx.request_repaint(); + } + if ui.checkbox(&mut self.settings.optimize_large_documents, "Optimize PDF opening") + .on_hover_text("Load detailed structure when Review Mode needs it. Applies to newly opened documents.").changed() + { + self.queue_settings_save(); + } + ui.collapsing("Advanced", |ui| { + let grouping = ui.checkbox(&mut self.settings.liquid_mode2_use_pymupdf_blocks, "Use PyMuPDF block grouping").changed(); + let regions = ui.checkbox(&mut self.settings.liquid_mode2_use_pp_footnote_regions, "Use PP footnote regions").changed(); + if grouping || regions { self.queue_settings_save(); } + }); if updater::managed_by_store() { ui.label("Microsoft Store manages updates for this installation."); } else { @@ -165,10 +207,10 @@ impl PdfEditorApp { "Checking for updates…", UpdateNoticeKind::Working, )); - updater::spawn_update_check(self.update_ui.tx.clone()); + updater::spawn_update_check(worker_waking_sender(self.update_ui.tx.clone(), ctx.clone())); } if self.update_ui.check_in_flight { - ui.spinner(); + motion::busy(ui, self.settings.reduce_motion); } }); if let Some(error) = &self.update_ui.last_check_error { @@ -179,11 +221,27 @@ impl PdfEditorApp { } ui.add_space(12.0); ui.label(RichText::new("Local document data").strong()); - ui.label("Cached text and images and old backup copies expire after the selected period. Unsaved edits and their source PDFs stay until you recover or discard them."); + if ui.checkbox(&mut self.settings.reopen_last_session, "Reopen last session on launch") + .on_hover_text("Reopens tabs after a clean close when no document was explicitly opened. Session paths expire with reading history.").changed() + { + if !self.settings.reopen_last_session { + self.settings.last_session_paths.clear(); + self.settings.last_session_saved_unix = 0; + } else { + self.session_history_suppressed = false; + } + self.queue_settings_save(); + } + ui.label("Reading history, cached text and images, and old backup copies expire after the selected period. Unsaved edits and their source PDFs stay until you recover or discard them."); let retention_changed = ui.add(egui::Slider::new(&mut self.settings.cache_retention_days, 1..=365).text("Days to keep cached data")).changed(); if retention_changed { + let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs(); + self.settings.prune_recent_documents(now); if let Err(error) = save_settings(&self.settings) { self.push_error_notice(error); } } + if ui.add_enabled(!self.settings.recent_documents.is_empty() || !self.settings.last_session_paths.is_empty(), egui::Button::new("Clear reading history")).clicked() { + self.clear_reading_history(); + } if ui.add_enabled(!self.settings_ui.maintenance_busy, egui::Button::new("Clear cached text and images")).clicked() { self.start_storage_maintenance(true, ctx); } @@ -191,7 +249,7 @@ impl PdfEditorApp { if ui.button("Save support diagnostics…").clicked() { self.export_support_diagnostics(); } ui.hyperlink_to("Privacy and data storage", "https://github.com/yonathanarbel/LawPDF/blob/main/docs/PRIVACY.md"); ui.add_space(12.0); - ui.label(RichText::new("Groq").strong().color(INK)); + ui.label(RichText::new("Groq").strong().color(self.app_palette().ink)); ui.add( egui::TextEdit::singleline(&mut self.settings_ui.groq_api_key_edit) .password(true) @@ -199,7 +257,7 @@ impl PdfEditorApp { .desired_width(360.0), ); ui.add_space(8.0); - ui.label(RichText::new("OpenRouter").strong().color(INK)); + ui.label(RichText::new("OpenRouter").strong().color(self.app_palette().ink)); ui.add( egui::TextEdit::singleline(&mut self.settings_ui.api_key_edit) .password(true) @@ -207,7 +265,7 @@ impl PdfEditorApp { .desired_width(360.0), ); ui.add_space(8.0); - ui.label(RichText::new("ElevenLabs (speech)").strong().color(INK)); + ui.label(RichText::new("ElevenLabs (speech)").strong().color(self.app_palette().ink)); ui.add( egui::TextEdit::singleline(&mut self.settings_ui.elevenlabs_api_key_edit) .password(true) @@ -219,7 +277,7 @@ impl PdfEditorApp { ui.label( RichText::new("Keys are stored in your system credential store. Blank a field to remove its key.") .size(10.0) - .color(MUTED_INK), + .color(self.app_palette().muted), ); ui.horizontal(|ui| { if ui.button("Save").clicked() { diff --git a/src/app/speech_ui.rs b/src/app/speech_ui.rs index d3bb181..13a9da0 100644 --- a/src/app/speech_ui.rs +++ b/src/app/speech_ui.rs @@ -1,7 +1,8 @@ use super::*; use crate::settings::effective_elevenlabs_api_key; use crate::tts::{ - ApiSpeechConfig, SpeechEventKind, SpeechProvider, SpeechRequest, spawn_speech_job, + ApiSpeechConfig, SpeechEventKind, SpeechProvider, SpeechRequest, SpeechSegment, + spawn_speech_job, }; impl PdfEditorApp { @@ -45,6 +46,7 @@ impl PdfEditorApp { self.tts_controller.job_id, request, self.tts_controller.tx.clone(), + self.repaint_context.clone(), )); self.status = if exporting { format!( @@ -57,13 +59,50 @@ impl PdfEditorApp { } pub(super) fn start_liquid_tts(&mut self, document: &LiquidDocument) { - let text = liquid_tts_text(document, self.settings.speech_include_notes); + self.start_liquid_tts_from(document, 0); + } + + pub(super) fn start_liquid_tts_from(&mut self, document: &LiquidDocument, start: usize) { + let follow_document = if self.settings.speech_provider == SpeechProvider::System + && self.settings.speech_follow_along + { + let state = match self.view_mode { + DocumentViewMode::Liquid => Some(&self.liquid_state), + DocumentViewMode::LiquidMode2 => Some(&self.liquid_mode2_state), + DocumentViewMode::Pdf => None, + }; + match state { + Some(LiquidState::Ready(current)) if std::ptr::eq(current.as_ref(), document) => { + Some(Arc::downgrade(current)) + } + _ => None, + } + } else { + None + }; + let segments = + liquid_tts_segments_from(document, self.settings.speech_include_notes, start); + let text = segments + .iter() + .map(|segment| segment.text.as_str()) + .collect::>() + .join("\n\n"); if text.trim().is_empty() { self.status = "Nothing to read aloud.".to_owned(); return; } let request = if self.settings.speech_provider == SpeechProvider::System { - SpeechRequest::System(text) + SpeechRequest::System { + segments: if follow_document.is_some() { + segments + } else { + vec![SpeechSegment { + block_index: None, + text, + }] + }, + rate: self.settings.system_speech_rate, + } } else { let Some(config) = self.selected_api_speech() else { return; @@ -75,6 +114,11 @@ impl PdfEditorApp { } }; self.launch_speech(request, false); + if let Some(document) = follow_document { + self.tts_controller.follow_epoch = Some(self.document_epoch); + self.tts_controller.follow_view = Some(self.view_mode); + self.tts_controller.follow_document = Some(document); + } } pub(super) fn stop_liquid_tts(&mut self) { @@ -84,9 +128,67 @@ impl PdfEditorApp { job.cancel(); } self.tts_controller.progress = None; + self.clear_speech_follow(); self.tts_controller.job_id = self.tts_controller.job_id.wrapping_add(1); } + pub(super) fn clear_speech_follow_scroll(&mut self) { + if self.tts_controller.follow_epoch == Some(self.document_epoch) + && self.tts_controller.follow_target.is_some() + && self.liquid_scroll_to_block == self.tts_controller.follow_target + { + self.liquid_scroll_to_block = None; + } + self.tts_controller.follow_target = None; + } + + pub(super) fn clear_speech_follow(&mut self) { + self.clear_speech_follow_scroll(); + self.tts_controller.follow_epoch = None; + self.tts_controller.follow_view = None; + self.tts_controller.follow_document = None; + self.tts_controller.speaking_block = None; + } + + pub(super) fn speech_follow_matches_current(&self) -> bool { + if self.tts_controller.follow_epoch != Some(self.document_epoch) + || self.tts_controller.follow_view != Some(self.view_mode) + { + return false; + } + let state = match self.view_mode { + DocumentViewMode::Liquid => &self.liquid_state, + DocumentViewMode::LiquidMode2 => &self.liquid_mode2_state, + DocumentViewMode::Pdf => return false, + }; + match (state, &self.tts_controller.follow_document) { + (LiquidState::Ready(current), Some(follow)) => { + std::ptr::eq(Arc::as_ptr(current), follow.as_ptr()) + } + _ => false, + } + } + + fn follow_spoken_block(&mut self, block: Option) { + self.tts_controller.speaking_block = block; + if !self.speech_follow_matches_current() { + return; + } + if self.tts_controller.follow_target.is_some() + && self.liquid_scroll_to_block == self.tts_controller.follow_target + { + self.liquid_scroll_to_block = None; + } + self.tts_controller.follow_target = None; + if let Some(block) = block + && self.liquid_scroll_to_block.is_none() + { + self.tts_controller.follow_target = Some(block); + self.liquid_scroll_to_block = Some(block); + self.liquid_scroll_instant = self.settings.reduce_motion; + } + } + pub(super) fn start_paid_liquid_tts(&mut self, document: &LiquidDocument) { if self.tts_controller.is_active() { return; @@ -136,9 +238,11 @@ impl PdfEditorApp { }; } SpeechEventKind::Playing => self.status = "Reading aloud…".to_owned(), + SpeechEventKind::BlockStarted(block) => self.follow_spoken_block(block), SpeechEventKind::Complete(path) => { self.tts_controller.active = None; self.tts_controller.progress = None; + self.clear_speech_follow(); if let Some(path) = path { self.status = format!("Created {}", path.display()); self.push_info_notice(self.status.clone()); @@ -149,14 +253,12 @@ impl PdfEditorApp { SpeechEventKind::Failed(error) => { self.tts_controller.active = None; self.tts_controller.progress = None; + self.clear_speech_follow(); self.push_error_notice(error); } } ctx.request_repaint(); } - if self.tts_controller.is_active() { - ctx.request_repaint_after(std::time::Duration::from_millis(100)); - } } pub(super) fn draw_speech_source(&mut self, ui: &mut egui::Ui) { @@ -184,6 +286,18 @@ impl PdfEditorApp { match self.settings.speech_provider { SpeechProvider::System => { ui.label("Uses your computer's installed speech engine. Document text stays on this computer."); + save |= ui + .add( + egui::Slider::new(&mut self.settings.system_speech_rate, -5..=5) + .text("Voice speed"), + ) + .on_hover_text("Changes take effect on the next narration.") + .changed(); + let follow_changed = ui.checkbox(&mut self.settings.speech_follow_along, "Follow the spoken passage").on_hover_text("Highlight and keep each passage in view. Changes take effect on the next narration.").changed(); + save |= follow_changed; + if follow_changed && !self.settings.speech_follow_along { + self.clear_speech_follow(); + } #[cfg(target_os = "linux")] ui.label( "Requires espeak-ng or espeak. Only LawPDF's speech stops when you click Stop.", @@ -263,7 +377,7 @@ impl PdfEditorApp { if total > 0 { ui.label(format!("{completed}/{total}")); } else { - ui.spinner(); + motion::busy(ui, self.settings.reduce_motion); } } } else { @@ -276,6 +390,15 @@ impl PdfEditorApp { self.start_liquid_tts(document); ui.ctx().request_repaint(); } + if let Some(start) = self.search_state.review_anchor.filter(|start| *start < document.blocks.len()) + && ui.button("Read from here").on_hover_text(if self.settings.speech_provider == SpeechProvider::System { + "Start at the first visible Review passage. Only linked footnotes are included when reading from here." + } else { + "Send text from the first visible Review passage to the selected provider. Charges apply; only linked footnotes are included." + }).clicked() { + self.start_liquid_tts_from(document, start); + ui.ctx().request_repaint(); + } if self.settings.speech_provider != SpeechProvider::System && ui.button("Create MP3…") .on_hover_text("Send article text to the selected speech provider and save an MP3. Provider charges apply.").clicked() { self.start_paid_liquid_tts(document); @@ -306,3 +429,44 @@ impl PdfEditorApp { ui.add_space(6.0); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn speech_follow_uses_current_document_and_preserves_user_jumps() { + let (mut app, _ctx, _worker) = annotation_saving::tests::fixture(); + let document: Arc = Arc::new( + serde_json::from_value(serde_json::json!({ + "title":"Article", "blocks":[{"role":"paragraph","text":"A passage."}], + "llm_used":false,"warnings":[],"source_signature":"speech-follow" + })) + .unwrap(), + ); + app.view_mode = DocumentViewMode::LiquidMode2; + app.liquid_mode2_state = LiquidState::Ready(Arc::clone(&document)); + app.tts_controller.follow_epoch = Some(7); + app.tts_controller.follow_view = Some(DocumentViewMode::LiquidMode2); + app.tts_controller.follow_document = Some(Arc::downgrade(&document)); + app.follow_spoken_block(Some(4)); + assert_eq!(app.tts_controller.speaking_block, Some(4)); + assert_eq!(app.liquid_scroll_to_block, Some(4)); + app.follow_spoken_block(Some(5)); + assert_eq!(app.liquid_scroll_to_block, Some(5)); + app.liquid_scroll_to_block = Some(10); + app.follow_spoken_block(Some(6)); + assert_eq!(app.liquid_scroll_to_block, Some(10)); + app.follow_spoken_block(None); + assert_eq!(app.liquid_scroll_to_block, Some(10)); + assert!(app.tts_controller.speaking_block.is_none()); + app.document_epoch = 8; + app.liquid_scroll_to_block = None; + app.follow_spoken_block(Some(3)); + assert!(app.liquid_scroll_to_block.is_none()); + app.document_epoch = 7; + app.liquid_mode2_state = LiquidState::Ready(Arc::new((*document).clone())); + app.follow_spoken_block(Some(3)); + assert!(app.liquid_scroll_to_block.is_none()); + } +} diff --git a/src/app/theme.rs b/src/app/theme.rs new file mode 100644 index 0000000..8ea513a --- /dev/null +++ b/src/app/theme.rs @@ -0,0 +1,364 @@ +//! Semantic colors for LawPDF's light and dark window surfaces. +use super::*; + +#[derive(Clone, Copy)] +pub(super) struct Palette { + pub canvas: Color32, + pub panel: Color32, + pub bar: Color32, + pub ink: Color32, + pub muted: Color32, + pub rule: Color32, + pub accent: Color32, + pub hover: Color32, +} + +pub(super) const LIGHT: Palette = Palette { + canvas: CANVAS_FILL, + panel: PANEL_FILL, + bar: BAR_FILL, + ink: INK, + muted: MUTED_INK, + rule: chrome::RULE, + accent: chrome::OXBLOOD, + hover: Color32::from_rgb(232, 227, 216), +}; +pub(super) const DARK: Palette = Palette { + canvas: Color32::from_rgb(18, 23, 31), + panel: Color32::from_rgb(30, 36, 47), + bar: Color32::from_rgb(35, 41, 52), + ink: Color32::from_rgb(239, 234, 224), + muted: Color32::from_rgb(190, 185, 174), + rule: Color32::from_rgb(81, 89, 101), + accent: Color32::from_rgb(241, 163, 155), + hover: Color32::from_rgb(55, 63, 76), +}; + +pub(super) fn for_context(ctx: &Context) -> Palette { + if ctx.theme() == egui::Theme::Dark { + DARK + } else { + LIGHT + } +} + +pub(super) fn for_ui(ui: &egui::Ui) -> Palette { + if ui.visuals().dark_mode { DARK } else { LIGHT } +} + +pub(super) fn set_preference(ctx: &Context, choice: crate::settings::Appearance) { + let preference = match choice { + crate::settings::Appearance::System => egui::ThemePreference::System, + crate::settings::Appearance::Light => egui::ThemePreference::Light, + crate::settings::Appearance::Dark => egui::ThemePreference::Dark, + }; + ctx.set_theme(preference); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn luminance(color: Color32) -> f64 { + let linear = |channel: u8| { + let value = f64::from(channel) / 255.0; + if value <= 0.04045 { + value / 12.92 + } else { + ((value + 0.055) / 1.055).powf(2.4) + } + }; + 0.2126 * linear(color.r()) + 0.7152 * linear(color.g()) + 0.0722 * linear(color.b()) + } + fn contrast(a: Color32, b: Color32) -> f64 { + let (a, b) = (luminance(a), luminance(b)); + (a.max(b) + 0.05) / (a.min(b) + 0.05) + } + + #[test] + fn core_text_and_accent_have_readable_contrast_on_their_surfaces() { + for palette in [LIGHT, DARK] { + for surface in [palette.canvas, palette.panel, palette.bar, palette.hover] { + assert!(contrast(palette.ink, surface) >= 4.5); + assert!(contrast(palette.muted, surface) >= 4.5); + assert!(contrast(palette.accent, surface) >= 4.5); + } + } + } + + #[test] + fn system_preference_falls_back_to_light_and_explicit_choice_wins() { + let ctx = Context::default(); + ctx.options_mut(|options| options.fallback_theme = egui::Theme::Light); + set_preference(&ctx, crate::settings::Appearance::System); + assert_eq!(ctx.theme(), egui::Theme::Light); + set_preference(&ctx, crate::settings::Appearance::Dark); + assert_eq!(ctx.theme(), egui::Theme::Dark); + set_preference(&ctx, crate::settings::Appearance::Light); + assert_eq!(ctx.theme(), egui::Theme::Light); + } + + #[test] + fn review_follows_system_until_the_reader_chooses_a_review_theme() { + let (mut app, ctx, _worker) = annotation_saving::tests::fixture(); + set_preference(&ctx, crate::settings::Appearance::System); + let _ = ctx.run( + egui::RawInput { + system_theme: Some(egui::Theme::Dark), + ..Default::default() + }, + |_| {}, + ); + app.sync_review_appearance(&ctx); + assert_eq!(app.liquid_theme, LiquidTheme::Dark); + app.liquid_theme = LiquidTheme::Sepia; + app.liquid_theme_follows_appearance = false; + let _ = ctx.run( + egui::RawInput { + system_theme: Some(egui::Theme::Light), + ..Default::default() + }, + |_| {}, + ); + app.sync_review_appearance(&ctx); + assert_eq!(app.liquid_theme, LiquidTheme::Sepia); + app.liquid_theme_follows_appearance = true; + app.sync_review_appearance(&ctx); + assert_eq!(app.liquid_theme, LiquidTheme::Paper); + } + + #[test] + fn window_appearance_does_not_change_pdf_pixels_without_the_dim_setting() { + let (mut app, ctx, _worker) = annotation_saving::tests::fixture(); + app.settings.dim_original_pages = false; + set_preference(&ctx, crate::settings::Appearance::Dark); + app.sync_review_appearance(&ctx); + assert_eq!(app.original_page_tint(), Color32::WHITE); + app.settings.dim_original_pages = true; + assert_eq!(app.original_page_tint(), Color32::from_rgb(190, 180, 162)); + } + + fn contains_fill(shape: &egui::Shape, color: Color32) -> bool { + match shape { + egui::Shape::Rect(rect) => rect.fill == color, + egui::Shape::Vec(shapes) => shapes.iter().any(|shape| contains_fill(shape, color)), + _ => false, + } + } + + fn text_color(shape: &egui::Shape, wanted: &str) -> Option { + match shape { + egui::Shape::Text(text) if text.galley.text().contains(wanted) => Some( + text.galley + .job + .sections + .first() + .map_or(text.fallback_color, |section| { + if section.format.color == Color32::PLACEHOLDER { + text.fallback_color + } else { + section.format.color + } + }), + ), + egui::Shape::Vec(shapes) => shapes.iter().find_map(|shape| text_color(shape, wanted)), + _ => None, + } + } + + fn text_center(shape: &egui::Shape, wanted: &str) -> Option { + match shape { + egui::Shape::Text(text) if text.galley.text() == wanted => { + Some(text.pos + text.galley.size() * 0.5) + } + egui::Shape::Vec(shapes) => shapes.iter().find_map(|shape| text_center(shape, wanted)), + _ => None, + } + } + + #[test] + fn settings_choice_changes_theme_and_queues_persistence() { + let (mut app, ctx, _worker) = annotation_saving::tests::fixture(); + app.settings_ui.open = true; + app.settings_save_due = None; + let draw = |app: &mut PdfEditorApp, time, events| { + ctx.run( + egui::RawInput { + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, Vec2::new(1100.0, 800.0))), + time: Some(time), + events, + ..Default::default() + }, + |ctx| app.draw_settings_window(ctx), + ) + }; + let _ = draw(&mut app, 0.0, vec![]); + let output = draw(&mut app, 0.1, vec![]); + let point = output + .shapes + .iter() + .find_map(|shape| text_center(&shape.shape, "Dark")) + .expect("dark appearance control"); + let _ = draw(&mut app, 0.2, vec![egui::Event::PointerMoved(point)]); + let _ = draw( + &mut app, + 0.3, + vec![egui::Event::PointerButton { + pos: point, + button: egui::PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::NONE, + }], + ); + let _ = draw( + &mut app, + 0.4, + vec![egui::Event::PointerButton { + pos: point, + button: egui::PointerButton::Primary, + pressed: false, + modifiers: egui::Modifiers::NONE, + }], + ); + assert_eq!(app.settings.appearance, crate::settings::Appearance::Dark); + assert_eq!(ctx.theme(), egui::Theme::Dark); + assert_eq!(app.app_palette().ink, DARK.ink); + assert!(app.settings_save_due.is_some()); + } + + #[test] + fn dark_window_paints_readable_masthead_and_empty_state() { + let (mut app, ctx, _worker) = annotation_saving::tests::fixture(); + app.document = None; + app.settings.recent_documents.clear(); + set_preference(&ctx, crate::settings::Appearance::Dark); + app.sync_review_appearance(&ctx); + let input = egui::RawInput { + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, Vec2::new(1100.0, 800.0))), + ..Default::default() + }; + let output = ctx.run(input, |ctx| { + app.draw_masthead(ctx); + app.draw_document(ctx); + }); + assert!( + output + .shapes + .iter() + .any(|shape| contains_fill(&shape.shape, DARK.bar)) + ); + assert!( + output + .shapes + .iter() + .any(|shape| contains_fill(&shape.shape, DARK.canvas)) + ); + assert_eq!( + output + .shapes + .iter() + .find_map(|shape| text_color(&shape.shape, "Drop PDF here")), + Some(DARK.ink) + ); + } + + #[test] + fn dark_reader_uses_a_dark_sidebar_and_a_light_pdf_placeholder() { + let (mut app, ctx, _worker) = annotation_saving::tests::fixture(); + app.settings.sidebar_in_pdf = true; + set_preference(&ctx, crate::settings::Appearance::Dark); + app.sync_review_appearance(&ctx); + let output = ctx.run( + egui::RawInput { + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, Vec2::new(1100.0, 800.0))), + ..Default::default() + }, + |ctx| { + app.draw_side_panel(ctx); + app.draw_document(ctx); + }, + ); + assert!( + output + .shapes + .iter() + .any(|shape| contains_fill(&shape.shape, DARK.panel)) + ); + assert!( + output + .shapes + .iter() + .any(|shape| contains_fill(&shape.shape, PAPER_FILL)) + ); + } + + #[test] + fn dark_review_callout_and_manually_selected_paper_keep_contrasting_text() { + let (mut app, ctx, _worker) = annotation_saving::tests::fixture(); + let review = LiquidDocument { + title: "Synthetic article".into(), + blocks: vec![LiquidBlock { + role: LiquidBlockRole::Definition, + text: "A definition for the reader.".into(), + label: None, + }], + article_spans: vec![], + block_source_lines: vec![], + footnote_links: vec![], + footnote_link_integrity: None, + profile: None, + noise_lines_removed: 0, + llm_used: false, + llm_provider: None, + deep_liquid_used: false, + deep_liquid_model: None, + warnings: vec![], + source_signature: "theme-test".into(), + }; + app.liquid_mode2_state = LiquidState::Ready(Arc::new(review)); + app.liquid_mode2_complete = true; + app.view_mode = DocumentViewMode::LiquidMode2; + app.settings.review_intro_dismissed = true; + app.liquid_theme = LiquidTheme::Dark; + set_preference(&ctx, crate::settings::Appearance::Dark); + app.sync_review_appearance(&ctx); + let draw = |app: &mut PdfEditorApp| { + ctx.run( + egui::RawInput { + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, Vec2::new(1100.0, 800.0))), + ..Default::default() + }, + |ctx| app.draw_document(ctx), + ) + }; + let output = draw(&mut app); + assert!( + output + .shapes + .iter() + .any(|shape| contains_fill(&shape.shape, Color32::from_rgb(38, 45, 57))) + ); + assert_eq!( + output + .shapes + .iter() + .find_map(|shape| text_color(&shape.shape, "A definition for the reader.")), + Some(app.liquid_ink_color()) + ); + app.liquid_theme = LiquidTheme::Paper; + let output = draw(&mut app); + assert!( + output + .shapes + .iter() + .any(|shape| contains_fill(&shape.shape, PAPER_FILL)) + ); + assert_eq!( + output + .shapes + .iter() + .find_map(|shape| text_color(&shape.shape, "A definition for the reader.")), + Some(INK) + ); + } +} diff --git a/src/app/thumbnail_layout.rs b/src/app/thumbnail_layout.rs new file mode 100644 index 0000000..a418fb9 --- /dev/null +++ b/src/app/thumbnail_layout.rs @@ -0,0 +1,521 @@ +//! Retained thumbnail geometry for documents with many differently sized pages. +use super::*; + +const THUMBNAIL_MAX_WIDTH: f32 = 118.0; +const THUMBNAIL_MAX_HEIGHT: f32 = 360.0; + +#[derive(Clone, Copy, Debug)] +pub(super) struct ThumbnailRow { + pub top: f32, + pub height: f32, + pub page_width: f32, + pub display_size: Vec2, +} + +#[derive(Debug)] +pub(super) struct ThumbnailLayout { + pub document_epoch: u64, + pub overhead: f32, + pub rows: Vec, + pub total_height: f32, +} + +impl ThumbnailLayout { + pub fn new(document_epoch: u64, pages: &[crate::model::PageInfo], overhead: f32) -> Self { + let mut rows = Vec::with_capacity(pages.len()); + let mut top = 0.0; + for page in pages { + let width = if page.width.is_finite() && page.width > 0.0 { + page.width + } else { + 612.0 + }; + let height = if page.height.is_finite() && page.height > 0.0 { + page.height + } else { + 792.0 + }; + let scale = (THUMBNAIL_MAX_WIDTH / width).min(THUMBNAIL_MAX_HEIGHT / height); + let display_size = Vec2::new(width * scale, height * scale); + let row_height = display_size.y + overhead; + rows.push(ThumbnailRow { + top, + height: row_height, + page_width: width, + display_size, + }); + top += row_height; + } + Self { + document_epoch, + overhead, + rows, + total_height: top, + } + } + + pub fn visible_rows(&self, top: f32, bottom: f32) -> std::ops::Range { + let first = self.rows.partition_point(|row| row.top + row.height < top); + let end = self.rows.partition_point(|row| row.top <= bottom); + first.min(end)..end + } +} + +impl PdfEditorApp { + pub(super) fn thumbnail_layout(&mut self, overhead: f32) -> Option> { + let document = self.document.as_ref()?; + if let Some(layout) = &self.thumbnail_layout_cache + && layout.document_epoch == self.document_epoch + && layout.rows.len() == document.pages.len() + && (layout.overhead - overhead).abs() < 0.1 + { + return Some(Arc::clone(layout)); + } + let layout = Arc::new(ThumbnailLayout::new( + self.document_epoch, + &document.pages, + overhead, + )); + self.thumbnail_layout_cache = Some(Arc::clone(&layout)); + Some(layout) + } + + pub(super) fn draw_pages_tab(&mut self, ui: &mut egui::Ui, ctx: &Context) { + let Some(path) = self.document.as_ref().map(|document| document.path.clone()) else { + ui.label(RichText::new("No PDF loaded").color(self.app_palette().muted)); + return; + }; + // Include a little slack so different fonts and item spacing cannot + // make adjacent rows overlap when the sidebar is resized. + let overhead = + 32.0 + ui.text_style_height(&egui::TextStyle::Body) + 3.0 * ui.spacing().item_spacing.y; + let Some(layout) = self.thumbnail_layout(overhead) else { + return; + }; + let requested = self.thumbnail_scroll_target.take(); + egui::ScrollArea::vertical() + .id_salt(("page-thumbnails", self.document_epoch)) + .show_viewport(ui, |ui, viewport| { + let width = ui.available_width().max(1.0); + let (canvas, canvas_response) = ui.allocate_exact_size( + Vec2::new(width, layout.total_height.max(1.0)), + Sense::hover(), + ); + let visible = + layout.visible_rows(viewport.top() - 700.0, viewport.bottom() + 700.0); + // AccessKit needs every page represented in the widget tree. + // Keep raster requests restricted to the nearby range. + let accesskit_active = ctx + .accesskit_node_builder(canvas_response.id, |_| ()) + .is_some(); + let rows_to_draw = + if accesskit_active || ctx.memory(|memory| memory.options.screen_reader) { + 0..layout.rows.len() + } else { + visible.clone() + }; + if let Some(index) = requested + && let Some(row) = layout.rows.get(index) + { + let rect = Rect::from_min_size( + canvas.min + Vec2::new(0.0, row.top), + Vec2::new(width, row.height), + ); + let animation = if self.settings.reduce_motion { + egui::style::ScrollAnimation::none() + } else { + egui::style::ScrollAnimation::duration(THUMBNAIL_SCROLL_SECONDS) + }; + ui.scroll_to_rect_animation(rect, Some(Align::Center), animation); + if !visible.contains(&index) { + self.ensure_thumbnail_texture(ctx, &path, index, row.page_width); + } + ctx.request_repaint(); + } + for page_index in rows_to_draw { + let row = layout.rows[page_index]; + let rect = Rect::from_min_size( + canvas.min + Vec2::new(0.0, row.top), + Vec2::new(width, row.height), + ); + let mut child = ui.new_child( + egui::UiBuilder::new() + .id_salt(("thumbnail-row", self.document_epoch, page_index)) + .max_rect(rect), + ); + { + let ui = &mut child; + let visible_range = self.visible_range_for_page(page_index); + let target_visible = visible_range + .map(|range| range.coverage.max(0.24)) + .unwrap_or_else(|| { + if self.visible_page_ranges.is_empty() + && page_index == self.page_index + { + 1.0 + } else { + 0.0 + } + }); + let visible_t = + if self.settings.reduce_motion || !visible.contains(&page_index) { + target_visible + } else { + ctx.animate_value_with_time( + ui.id().with("thumb-visible"), + target_visible, + 0.14, + ) + }; + let fill = if ui.visuals().dark_mode { + lerp_color(self.app_palette().bar, self.app_palette().hover, visible_t) + } else { + lerp_color(Color32::from_rgb(250, 248, 242), Color32::from_rgb(231, 224, 210), visible_t) + }; + let stroke = Stroke::new( + 1.0 + visible_t, + if ui.visuals().dark_mode { + lerp_color(self.app_palette().rule, self.app_palette().accent, visible_t) + } else { + lerp_color(Color32::from_rgb(213, 208, 198), Color32::from_rgb(146, 103, 52), visible_t) + }, + ); + let inner = egui::Frame::NONE + .fill(fill) + .stroke(stroke) + .corner_radius(6) + .inner_margin(Margin::same(8)) + .show(ui, |ui| { + ui.vertical_centered(|ui| { + let (thumb_rect, response) = + ui.allocate_exact_size(row.display_size, Sense::click()); + response.widget_info(|| { + egui::WidgetInfo::labeled( + egui::WidgetType::Button, + true, + format!("Go to page {}", page_index + 1), + ) + }); + let painter = ui.painter_at(thumb_rect); + painter.rect_filled(thumb_rect, 2, PAPER_FILL); + painter.rect_stroke( + thumb_rect, + 2, + Stroke::new(1.0_f32, PAPER_STROKE), + egui::StrokeKind::Inside, + ); + if let Some(thumbnail) = visible + .contains(&page_index) + .then(|| { + self.ensure_thumbnail_texture( + ctx, + &path, + page_index, + row.page_width, + ) + }) + .flatten() + { + painter.image( + thumbnail.texture_id, + thumb_rect, + Rect::from_min_max(Pos2::ZERO, Pos2::new(1.0, 1.0)), + Color32::WHITE, + ); + } else if visible.contains(&page_index) { + painter.text( + thumb_rect.center(), + Align2::CENTER_CENTER, + "Rendering", + FontId::proportional(13.0), + MUTED_INK, + ); + } else { + painter.text( + thumb_rect.center(), + Align2::CENTER_CENTER, + format!("{}", page_index + 1), + FontId::proportional(18.0), + MUTED_INK, + ); + } + if let Some(range) = visible_range { + let top = thumb_rect.top() + + thumb_rect.height() + * range.top_fraction.clamp(0.0, 1.0); + let bottom = (thumb_rect.top() + + thumb_rect.height() + * range.bottom_fraction.clamp(0.0, 1.0)) + .max(top + 3.0) + .min(thumb_rect.bottom()); + let area = Rect::from_min_max( + Pos2::new(thumb_rect.left(), top), + Pos2::new(thumb_rect.right(), bottom), + ); + let alpha = (42.0 + 86.0 * range.coverage) + .round() + .clamp(0.0, 160.0) + as u8; + painter.rect_filled( + area, + 1, + Color32::from_rgba_unmultiplied(207, 150, 64, alpha), + ); + painter.rect_stroke( + area, + 1, + Stroke::new( + 1.4_f32, + Color32::from_rgba_unmultiplied(146, 103, 52, 190), + ), + egui::StrokeKind::Inside, + ); + } + response + }) + .inner + }); + ui.horizontal(|ui| { + ui.add_space(6.0); + ui.label(RichText::new(format!("Page {}", page_index + 1)).color(self.app_palette().ink)); + }); + if inner.response.clicked() || inner.inner.clicked() { + self.jump_to_page(page_index); + } + } + } + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::PageInfo; + + #[test] + fn variable_page_sizes_produce_correct_visible_ranges() { + let pages = vec![ + PageInfo::new(600.0, 800.0), + PageInfo::new(800.0, 600.0), + PageInfo::new(100.0, 2000.0), + ]; + let layout = ThumbnailLayout::new(7, &pages, 60.0); + assert!(layout.rows[2].display_size.y <= 360.0); + assert!(layout.rows[0].display_size.y > layout.rows[1].display_size.y); + let middle = layout.rows[1]; + assert_eq!( + layout.visible_rows(middle.top + 1.0, middle.top + middle.height - 1.0), + 1..2 + ); + assert_eq!( + layout.visible_rows(layout.total_height + 1.0, layout.total_height + 100.0), + 3..3 + ); + } + + #[test] + fn long_document_only_selects_nearby_rows() { + let layout = ThumbnailLayout::new(7, &vec![PageInfo::new(612.0, 792.0); 10_000], 60.0); + let top = layout.rows[5000].top; + let range = layout.visible_rows(top - 700.0, top + 1400.0); + assert!(range.contains(&5000)); + assert!(range.len() < 20); + } + + #[test] + fn sidebar_scrolls_to_a_distant_page_and_only_requests_nearby_thumbnails() { + let (mut app, ctx, requests) = annotation_saving::tests::fixture(); + let document = app.document.as_mut().unwrap(); + document.page_count = 500; + document.pages = vec![PageInfo::new(612.0, 792.0); 500]; + app.thumbnail_scroll_target = None; + app.settings.reduce_motion = true; + let draw = |app: &mut PdfEditorApp, time| { + let input = egui::RawInput { + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, Vec2::new(300.0, 600.0))), + time: Some(time), + ..Default::default() + }; + ctx.run(input, |ctx| { + egui::CentralPanel::default().show(ctx, |ui| app.draw_pages_tab(ui, ctx)); + }) + }; + draw(&mut app, 0.0); + let first_requests: Vec<_> = requests + .try_iter() + .filter_map(|request| match request { + RenderRequest::Thumbnail { key, .. } => Some(key.page_index), + _ => None, + }) + .collect(); + assert!(!first_requests.is_empty() && first_requests.len() < 20); + assert!(first_requests.iter().all(|page| *page < 20)); + app.thumbnail_scroll_target = Some(450); + for frame in 1..5 { + draw(&mut app, frame as f64 * 0.2); + } + let later_requests: Vec<_> = requests + .try_iter() + .filter_map(|request| match request { + RenderRequest::Thumbnail { key, .. } => Some(key.page_index), + _ => None, + }) + .collect(); + assert!(later_requests.contains(&450)); + assert!( + later_requests + .iter() + .any(|page| *page >= 447 && *page <= 453 && *page != 450), + "the viewport did not reach nearby rows: {later_requests:?}" + ); + assert!(later_requests.len() < 20); + } + + #[test] + fn clicking_a_visible_thumbnail_jumps_to_that_page() { + fn text_center(shape: &egui::Shape, text: &str) -> Option { + match shape { + egui::Shape::Text(shape) if shape.galley.text() == text => { + Some(shape.pos + shape.galley.size() * 0.5) + } + egui::Shape::Vec(shapes) => { + shapes.iter().find_map(|shape| text_center(shape, text)) + } + _ => None, + } + } + let (mut app, ctx, _worker) = annotation_saving::tests::fixture(); + let document = app.document.as_mut().unwrap(); + document.page_count = 3; + document.pages = vec![PageInfo::new(612.0, 792.0); 3]; + app.page_index = 1; + app.thumbnail_scroll_target = None; + app.settings.reduce_motion = true; + let draw = |app: &mut PdfEditorApp, time, events| { + let input = egui::RawInput { + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, Vec2::new(300.0, 600.0))), + time: Some(time), + events, + ..Default::default() + }; + ctx.run(input, |ctx| { + egui::CentralPanel::default().show(ctx, |ui| app.draw_pages_tab(ui, ctx)); + }) + }; + let output = draw(&mut app, 0.0, vec![]); + let point = output + .shapes + .iter() + .find_map(|shape| text_center(&shape.shape, "Rendering")) + .expect("first thumbnail is painted"); + draw(&mut app, 0.1, vec![egui::Event::PointerMoved(point)]); + for (time, pressed) in [(0.2, true), (0.3, false)] { + draw( + &mut app, + time, + vec![egui::Event::PointerButton { + pos: point, + button: egui::PointerButton::Primary, + pressed, + modifiers: egui::Modifiers::NONE, + }], + ); + } + assert_eq!(app.page_index, 0); + assert_eq!(app.scroll_target_page, Some(0)); + } + + #[test] + fn mixed_size_thumbnails_have_space_for_labels_and_keep_aspect_ratio() { + fn collect_paper_rects(shape: &egui::Shape, result: &mut Vec) { + match shape { + egui::Shape::Rect(shape) if shape.fill == PAPER_FILL => result.push(shape.rect), + egui::Shape::Vec(shapes) => shapes + .iter() + .for_each(|shape| collect_paper_rects(shape, result)), + _ => {} + } + } + let (mut app, ctx, _worker) = annotation_saving::tests::fixture(); + let document = app.document.as_mut().unwrap(); + document.page_count = 3; + document.pages = vec![ + PageInfo::new(612.0, 792.0), + PageInfo::new(800.0, 600.0), + PageInfo::new(612.0, 792.0), + ]; + app.thumbnail_scroll_target = None; + app.settings.reduce_motion = true; + let input = egui::RawInput { + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, Vec2::new(300.0, 800.0))), + ..Default::default() + }; + let output = ctx.run(input, |ctx| { + egui::CentralPanel::default().show(ctx, |ui| app.draw_pages_tab(ui, ctx)); + }); + let mut rects = Vec::new(); + for shape in &output.shapes { + collect_paper_rects(&shape.shape, &mut rects); + } + rects.sort_by(|a, b| a.top().total_cmp(&b.top())); + assert_eq!(rects.len(), 3); + assert!((rects[0].width() / rects[0].height() - 612.0 / 792.0).abs() < 0.01); + assert!((rects[1].width() / rects[1].height() - 800.0 / 600.0).abs() < 0.01); + assert!( + rects + .windows(2) + .all(|pair| pair[1].top() - pair[0].bottom() > 25.0) + ); + } + + #[test] + fn screen_reader_sees_distant_pages_without_rendering_every_thumbnail() { + let (mut app, ctx, requests) = annotation_saving::tests::fixture(); + let document = app.document.as_mut().unwrap(); + document.page_count = 100; + document.pages = vec![PageInfo::new(612.0, 792.0); 100]; + app.thumbnail_scroll_target = None; + app.settings.reduce_motion = true; + ctx.enable_accesskit(); + let input = egui::RawInput { + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, Vec2::new(300.0, 600.0))), + ..Default::default() + }; + let output = ctx.run(input, |ctx| { + egui::CentralPanel::default().show(ctx, |ui| app.draw_pages_tab(ui, ctx)); + }); + let tree = output + .platform_output + .accesskit_update + .expect("accessibility tree"); + let page_node = tree + .nodes + .iter() + .find_map(|(id, node)| (node.label() == Some("Go to page 100")).then_some(*id)) + .expect("distant page control"); + let count = requests + .try_iter() + .filter(|request| matches!(request, RenderRequest::Thumbnail { .. })) + .count(); + assert!( + count > 0 && count < 20, + "offscreen pages must not all render: {count}" + ); + let input = egui::RawInput { + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, Vec2::new(300.0, 600.0))), + events: vec![egui::Event::AccessKitActionRequest( + egui::accesskit::ActionRequest { + action: egui::accesskit::Action::Click, + target: page_node, + data: None, + }, + )], + ..Default::default() + }; + let _ = ctx.run(input, |ctx| { + egui::CentralPanel::default().show(ctx, |ui| app.draw_pages_tab(ui, ctx)); + }); + assert_eq!(app.scroll_target_page, Some(99)); + } +} diff --git a/src/app/tts_controller.rs b/src/app/tts_controller.rs index 539b220..8feb288 100644 --- a/src/app/tts_controller.rs +++ b/src/app/tts_controller.rs @@ -8,6 +8,11 @@ pub(super) struct TtsController { pub(super) tx: Sender, pub(super) rx: Receiver, pub(super) progress: Option<(usize, usize)>, + pub(super) follow_epoch: Option, + pub(super) follow_view: Option, + pub(super) follow_document: Option>, + pub(super) speaking_block: Option, + pub(super) follow_target: Option, } impl TtsController { @@ -20,6 +25,11 @@ impl TtsController { tx, rx, progress: None, + follow_epoch: None, + follow_view: None, + follow_document: None, + speaking_block: None, + follow_target: None, } } diff --git a/src/benchmark.rs b/src/benchmark.rs index a92c201..41984f1 100644 --- a/src/benchmark.rs +++ b/src/benchmark.rs @@ -13,6 +13,166 @@ const BENCH_ZOOM: f32 = 1.25; const BENCH_PIXELS_PER_POINT: f32 = 1.0; const BENCH_MAX_TEXTURE_SIDE: u32 = 8192; +/// Time the actual open/recovery pipeline through the first CPU raster. +/// The caller supplies a fresh, absolute LAWPDF_BENCH_DATA_DIR so a benchmark +/// never writes to or prunes the reader's normal recovery and performance data. +pub fn run_open_pipeline_benchmark(args: Vec) -> Result<()> { + use crate::render_worker::{PageRenderKey, RenderEvent, RenderRequest, spawn_render_worker}; + use std::time::Duration; + + let (source, scale) = parse_open_pipeline_args(&args)?; + let source = source + .canonicalize() + .context("Could not resolve the PDF path")?; + let metadata = std::fs::metadata(&source).context("Could not inspect the PDF")?; + anyhow::ensure!(metadata.is_file(), "The benchmark input must be a file"); + let data_dir = std::env::var_os("LAWPDF_BENCH_DATA_DIR") + .map(PathBuf::from) + .filter(|path| path.is_absolute()) + .context("Set LAWPDF_BENCH_DATA_DIR to a fresh absolute directory path")?; + std::fs::create_dir(&data_dir).with_context(|| { + format!( + "Benchmark data directory must not already exist: {}", + data_dir.display() + ) + })?; + + let start = Instant::now(); + let (worker, events) = spawn_render_worker(None); + worker + .send(RenderRequest::OpenDocument { + path: source.clone(), + optimize_large_documents: true, + preview_page: Some(0), + }) + .map_err(|error| anyhow::anyhow!("Could not queue document opening: {error}"))?; + let mut preview_ready_ms = None; + let opened = loop { + let remaining = Duration::from_secs(60).saturating_sub(start.elapsed()); + match events + .recv_timeout(remaining) + .context("Timed out opening the PDF")? + { + RenderEvent::OpeningPreview { .. } => { + preview_ready_ms = Some(start.elapsed().as_secs_f64() * 1000.0); + } + RenderEvent::DocumentOpened { result, .. } => { + break result.map_err(anyhow::Error::msg)?; + } + _ => bail!("The worker returned an unexpected opening event"), + } + }; + let open_ms = start.elapsed().as_secs_f64() * 1000.0; + anyhow::ensure!(opened.document.page_count > 0, "The PDF has no pages"); + worker.set_live_documents([1]); + worker.set_viewport(1, 0, 0, 0); + let key = PageRenderKey::new(1, 0, scale); + let render_started = Instant::now(); + worker + .send(RenderRequest::Page { + key, + path: source.clone(), + zoom: 1.0, + render_scale: scale, + fast: false, + }) + .map_err(|error| anyhow::anyhow!("Could not queue the first page: {error}"))?; + let page = loop { + let remaining = Duration::from_secs(30).saturating_sub(render_started.elapsed()); + match events + .recv_timeout(remaining) + .context("Timed out rendering the first page")? + { + RenderEvent::Page { result, .. } => break result.map_err(anyhow::Error::msg)?, + // A preview arriving after the verified document is no longer useful. + RenderEvent::OpeningPreview { .. } => {} + _ => bail!("The worker returned an unexpected page event"), + } + }; + let render_ms = render_started.elapsed().as_secs_f64() * 1000.0; + let ready_ms = start.elapsed().as_secs_f64() * 1000.0; + opened + .revision + .require_current(&source) + .map_err(anyhow::Error::msg)?; + println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "schema_version": "lawpdf-open-pipeline-benchmark-v1", + "app_version": env!("CARGO_PKG_VERSION"), + "input": source, "input_bytes": metadata.len(), "page_count": opened.document.page_count, + "optimized_open": true, "render_scale": scale, + "open_ms": open_ms, "first_page_render_ms": render_ms, "first_page_ready_ms": ready_ms, + "read_only_preview_ready_ms": preview_ready_ms, + "open_phase_ms": { + "recovery_snapshot": opened.timings.snapshot.as_secs_f64() * 1000.0, + "pdf_metadata": opened.timings.metadata.as_secs_f64() * 1000.0, + "annotations": opened.timings.annotations.as_secs_f64() * 1000.0, + "revision_and_recovery": opened.timings.revision_and_recovery.as_secs_f64() * 1000.0 + }, + "first_page_pixels": [page.width, page.height], "source_unchanged": true, + "data_directory": data_dir, + "scope": "Recovery snapshot, metadata, annotation read, first CPU raster and UI pixel preparation; excludes window, GPU upload and presentation." + }))? + ); + drop(worker); + crate::native_process::shutdown(); + Ok(()) +} + +fn parse_open_pipeline_args(args: &[OsString]) -> Result<(PathBuf, f32)> { + anyhow::ensure!( + args.len() == 2 || args.len() == 3, + "Usage: --bench-open-pipeline PDF [render-scale]" + ); + anyhow::ensure!( + args[0] == OsStr::new("--bench-open-pipeline"), + "Expected --bench-open-pipeline" + ); + let scale = if args.len() == 3 { + args[2] + .to_string_lossy() + .parse::() + .context("Render scale must be a number")? + } else { + 1.0 + }; + anyhow::ensure!( + scale.is_finite() && (0.25..=4.0).contains(&scale), + "Render scale must be between 0.25 and 4.0" + ); + Ok((PathBuf::from(&args[1]), scale)) +} + +#[cfg(test)] +mod open_pipeline_tests { + use super::*; + + #[test] + fn open_pipeline_arguments_require_a_path_and_safe_scale() { + let args = |values: &[&str]| values.iter().map(OsString::from).collect::>(); + assert_eq!( + parse_open_pipeline_args(&args(&["--bench-open-pipeline", "paper.pdf"])) + .unwrap() + .1, + 1.0 + ); + assert_eq!( + parse_open_pipeline_args(&args(&["--bench-open-pipeline", "paper.pdf", "2"])) + .unwrap() + .1, + 2.0 + ); + for invalid in [ + &["--bench-open-pipeline"][..], + &["--bench-open-pipeline", "paper.pdf", "NaN"][..], + &["--bench-open-pipeline", "paper.pdf", "8"][..], + ] { + assert!(parse_open_pipeline_args(&args(invalid)).is_err()); + } + } +} + #[derive(Debug, Clone, Copy)] enum BenchLoadMode { Full, diff --git a/src/diagnostics.rs b/src/diagnostics.rs index 69eacf8..64b8b46 100644 --- a/src/diagnostics.rs +++ b/src/diagnostics.rs @@ -44,7 +44,10 @@ pub fn start_session() { let Some(root) = crate::settings::app_data_dir() else { return; }; - if SESSION.set(initialize_at(&root, now_unix_ms(), std::process::id())).is_ok() { + if SESSION + .set(initialize_at(&root, now_unix_ms(), std::process::id())) + .is_ok() + { #[cfg(target_os = "macos")] install_normal_exit_hook(); } @@ -97,6 +100,46 @@ pub fn event(name: &str) { event_details(name, Value::Null); } +/// Timings contain only an internal phase name and durations, never PDF data. +pub fn timing(name: &str, phase: &'static str, elapsed: std::time::Duration) { + event_details( + name, + json!({ "phase": phase, "duration_ms": elapsed.as_millis() }), + ); +} + +pub fn document_open_phases( + snapshot: std::time::Duration, + metadata: std::time::Duration, + annotations: std::time::Duration, + revision_and_recovery: std::time::Duration, +) { + event_details( + "document_open_phases", + json!({ + "recovery_snapshot_ms": snapshot.as_millis(), + "pdf_metadata_ms": metadata.as_millis(), + "annotations_ms": annotations.as_millis(), + "revision_and_recovery_ms": revision_and_recovery.as_millis(), + }), + ); +} + +pub fn slow_frame( + elapsed: std::time::Duration, + phase: &'static str, + phase_elapsed: std::time::Duration, +) { + event_details( + "slow_frame", + json!({ + "duration_ms": elapsed.as_millis(), + "slowest_phase": phase, + "phase_ms": phase_elapsed.as_millis(), + }), + ); +} + /// Record a bounded error description. Callers must not pass PDF text or keys. pub fn error(name: &str, detail: &str) { event_details(name, Value::String(bounded_detail(detail))); diff --git a/src/document_store.rs b/src/document_store.rs index 9a9179a..c752f83 100644 --- a/src/document_store.rs +++ b/src/document_store.rs @@ -31,6 +31,22 @@ pub struct FileRevision { pub bytes: u64, } +/// A revision computed from this exact immutable byte slice. Passing the pair +/// together avoids rehashing the same in-memory snapshot before writing it. +struct HashedBytes<'a> { + bytes: &'a [u8], + revision: FileRevision, +} + +impl<'a> HashedBytes<'a> { + fn new(bytes: &'a [u8]) -> Self { + Self { + bytes, + revision: FileRevision::from_bytes(bytes), + } + } +} + impl FileRevision { pub fn from_bytes(bytes: &[u8]) -> Self { Self { @@ -178,11 +194,11 @@ impl DocumentStore { /// acts as the compare-before-replace token for all subsequent PDF writes. pub fn capture(&self, source: &Path) -> Result { let bytes = read_limited(source, MAX_DOCUMENT_BYTES)?; - let revision = FileRevision::from_bytes(&bytes); - revision.require_current(source)?; + let hashed = HashedBytes::new(&bytes); + hashed.revision.require_current(source)?; let _access = store_access(); - self.write_snapshot(&revision, &bytes)?; - Ok(revision) + self.write_snapshot(&hashed)?; + Ok(hashed.revision) } /// Reserve and flush the next recovery source before changing the user's @@ -192,10 +208,10 @@ impl DocumentStore { if bytes.len() as u64 > MAX_DOCUMENT_BYTES { return Err("This PDF exceeds the document limit.".to_owned()); } - let revision = FileRevision::from_bytes(bytes); + let hashed = HashedBytes::new(bytes); let _access = store_access(); - self.write_snapshot(&revision, bytes)?; - Ok(revision) + self.write_snapshot(&hashed)?; + Ok(hashed.revision) } fn snapshot_path(&self, revision: &FileRevision) -> Result { @@ -217,10 +233,31 @@ impl DocumentStore { .join(format!("{:x}.json", Sha256::digest(key.as_bytes()))) } - fn write_snapshot(&self, revision: &FileRevision, bytes: &[u8]) -> Result { - if FileRevision::from_bytes(bytes) != *revision { + fn write_snapshot(&self, hashed: &HashedBytes<'_>) -> Result { + self.write_hashed_snapshot_with_limit(hashed, MAX_RECOVERY_BYTES) + } + + #[cfg(test)] + fn write_snapshot_with_limit( + &self, + revision: &FileRevision, + bytes: &[u8], + limit: u64, + ) -> Result { + let hashed = HashedBytes::new(bytes); + if hashed.revision != *revision { return Err("Recovery snapshot did not match the document revision.".to_owned()); } + self.write_hashed_snapshot_with_limit(&hashed, limit) + } + + fn write_hashed_snapshot_with_limit( + &self, + hashed: &HashedBytes<'_>, + limit: u64, + ) -> Result { + let revision = &hashed.revision; + let bytes = hashed.bytes; let path = self.snapshot_path(revision)?; if path.is_file() { if FileRevision::read(&path)? == *revision { @@ -250,7 +287,7 @@ impl DocumentStore { .map_err(|error| error.to_string())?; used = used.saturating_add(metadata.len()); } - if used.saturating_add(bytes.len() as u64) > MAX_RECOVERY_BYTES { + if used.saturating_add(bytes.len() as u64) > limit { return Err("Local recovery storage reached its 4 GB limit. Use Settings to clear unused cached data, then try saving again. Pending recovery edits are preserved.".to_owned()); } crate::atomic_file::write(&path, bytes) @@ -306,20 +343,30 @@ impl DocumentStore { generation: u64, saved_bytes: &[u8], ) -> Result { - let revision = FileRevision::from_bytes(saved_bytes); + let hashed = HashedBytes::new(saved_bytes); + self.acknowledge_hashed(source, generation, &hashed)?; + Ok(hashed.revision) + } + + fn acknowledge_hashed( + &self, + source: &Path, + generation: u64, + hashed: &HashedBytes<'_>, + ) -> Result<(), String> { let _access = store_access(); - self.write_snapshot(&revision, saved_bytes)?; + self.write_snapshot(hashed)?; let path = self.journal_path(source); match self.read_record(&path) { Ok(mut record) if record.generation > generation => { - record.base_revision = revision.clone(); + record.base_revision = hashed.revision.clone(); self.write_record(&record)?; } Ok(_) => remove_durable(&path)?, Err(_) if !path.exists() => {} Err(error) => return Err(error), } - Ok(revision) + Ok(()) } pub fn acknowledge_saved_file( @@ -329,11 +376,11 @@ impl DocumentStore { expected: &FileRevision, ) -> Result<(), String> { let bytes = read_limited(source, MAX_DOCUMENT_BYTES)?; - if FileRevision::from_bytes(&bytes) != *expected { + let hashed = HashedBytes::new(&bytes); + if hashed.revision != *expected { return Err("The PDF changed again before saving was confirmed. Your recovery record was kept; save a separate copy.".to_owned()); } - self.acknowledge(source, generation, &bytes)?; - Ok(()) + self.acknowledge_hashed(source, generation, &hashed) } pub fn discard(&self, source: &Path) -> Result<(), String> { @@ -623,6 +670,39 @@ mod tests { assert_eq!(recovered.record.base_revision, saved); assert_eq!(recovered.record.annotations, vec![annotation("newer")]); } + + #[test] + fn saved_file_acknowledgment_rebases_newer_work_and_rejects_a_later_change() { + let (_directory, store, source, revision) = fixture(); + store + .journal(&source, &revision, 2, &[annotation("keep")]) + .unwrap(); + let saved = b"saved first revision"; + fs::write(&source, saved).unwrap(); + let expected = FileRevision::from_bytes(saved); + store.acknowledge_saved_file(&source, 1, &expected).unwrap(); + let rebased = store.pending_for(&source).unwrap().unwrap(); + assert_eq!(rebased.record.generation, 2); + assert_eq!(rebased.record.base_revision, expected); + assert_eq!(fs::read(&rebased.snapshot_path).unwrap(), saved); + fs::write(&source, b"external revision").unwrap(); + assert!(store.acknowledge_saved_file(&source, 2, &expected).is_err()); + let preserved = store.pending_for(&source).unwrap().unwrap(); + assert_eq!(preserved.record, rebased.record); + assert_eq!(fs::read(source).unwrap(), b"external revision"); + } + + #[test] + fn a_mismatched_snapshot_revision_is_rejected_before_writing() { + let (_directory, store, _source, _revision) = fixture(); + let revision = FileRevision::from_bytes(b"different content"); + assert!( + store + .write_snapshot_with_limit(&revision, b"actual content", 4096) + .is_err() + ); + assert!(!store.snapshot_path(&revision).unwrap().exists()); + } #[test] fn external_changes_are_rejected_and_original_snapshot_survives() { let (_directory, store, source, revision) = fixture(); @@ -680,8 +760,15 @@ mod tests { let (directory, store, source, revision) = fixture(); let allocation = File::create(directory.path().join("recovery/sources/quota-placeholder")).unwrap(); - allocation.set_len(MAX_RECOVERY_BYTES).unwrap(); - assert!(store.preserve_bytes(b"new revision").is_err()); + let test_limit = 4096; + allocation.set_len(test_limit).unwrap(); + let new_bytes = b"new revision"; + let new_revision = FileRevision::from_bytes(new_bytes); + assert!( + store + .write_snapshot_with_limit(&new_revision, new_bytes, test_limit) + .is_err() + ); assert_eq!(FileRevision::read(&source).unwrap(), revision); } #[test] diff --git a/src/liquid/mod.rs b/src/liquid/mod.rs index 73f22bf..9fcb716 100644 --- a/src/liquid/mod.rs +++ b/src/liquid/mod.rs @@ -83,16 +83,18 @@ pub use cleaning::{ split_note_marker, }; -pub fn spawn_liquid_job(request: LiquidRequest, tx: Sender) { +pub fn spawn_liquid_job(request: LiquidRequest, tx: Sender, repaint: egui::Context) { thread::spawn(move || { let document_epoch = request.document_epoch; let path = request.path.clone(); let result = prepare_liquid_document(request); - let _ = tx.send(LiquidEvent { + if tx.send(LiquidEvent { document_epoch, path, result, - }); + }).is_ok() { + repaint.request_repaint(); + } }); } diff --git a/src/liquid2.rs b/src/liquid2.rs index 1310ced..d58e31d 100644 --- a/src/liquid2.rs +++ b/src/liquid2.rs @@ -1080,7 +1080,7 @@ struct Lm2PymupdfGroupingBlock { source_line_ids: Vec, } -pub fn spawn_liquid_mode2_job(request: LiquidMode2Request, tx: Sender) { +pub fn spawn_liquid_mode2_job(request: LiquidMode2Request, tx: Sender, repaint: egui::Context) { thread::Builder::new() .name("lawpdf-review-mode".to_owned()) .spawn(move || { @@ -1091,7 +1091,7 @@ pub fn spawn_liquid_mode2_job(request: LiquidMode2Request, tx: Sender Str } } -fn run_liquid_mode2_job(request: LiquidMode2Request, tx: &Sender) { +fn run_liquid_mode2_job(request: LiquidMode2Request, tx: &Sender, repaint: &egui::Context) { { let document_epoch = request.document_epoch; let path = request.path.clone(); @@ -1142,6 +1143,7 @@ fn run_liquid_mode2_job(request: LiquidMode2Request, tx: &Sender eframe::Result<()> { + let startup_started = std::time::Instant::now(); install_panic_log_hook(); let args = std::env::args_os().skip(1).collect::>(); if args.len() == 2 && args[0] == "--citation-source-probe" { @@ -232,13 +234,22 @@ fn main() -> eframe::Result<()> { } } + let mut viewport = egui::ViewportBuilder::default() + .with_title(APP_TITLE) + .with_icon(std::sync::Arc::new(load_app_icon())) + .with_inner_size([1280.0, 860.0]) + .with_min_inner_size([720.0, 520.0]); + if let Some(saved) = settings::load_window_placement() { + viewport = viewport + .with_inner_size(saved.inner_size) + .with_maximized(saved.maximized); + if let Some(position) = saved.outer_position { + viewport = viewport.with_position(position); + } + } let options = eframe::NativeOptions { run_and_return: native_run_and_return(), - viewport: egui::ViewportBuilder::default() - .with_title(APP_TITLE) - .with_icon(std::sync::Arc::new(load_app_icon())) - .with_inner_size([1280.0, 860.0]) - .with_min_inner_size([720.0, 520.0]), + viewport, ..Default::default() }; @@ -247,6 +258,11 @@ fn main() -> eframe::Result<()> { APP_TITLE, options, Box::new(move |cc| { + diagnostics::timing( + "startup_window", + "process_to_window", + startup_started.elapsed(), + ); #[cfg(target_os = "macos")] { macos_open_files.register(); @@ -347,6 +363,12 @@ const DEV_COMMANDS: &[DevCommand] = &[ flags: &["--bench-scroll"], handler: dev_bench_scroll, }, + DevCommand { + flags: &["--bench-open-pipeline"], + handler: |args| { + benchmark::run_open_pipeline_benchmark(args).map_err(|error| format!("{error:#}")) + }, + }, DevCommand { flags: &["--smoke-liquid", "--smoke-liquid2"], handler: dev_smoke_liquid, diff --git a/src/model.rs b/src/model.rs index 3f08057..333d53e 100644 --- a/src/model.rs +++ b/src/model.rs @@ -116,6 +116,32 @@ pub struct EditorAnnotation { pub kind: AnnotationKind, } +/// A read-only mark made by another PDF application. It is deliberately not +/// an `EditorAnnotation`, so saving LawPDF edits cannot rewrite or claim it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ForeignAnnotationKind { + Highlight, + Underline, + Squiggly, + StrikeOut, + Comment, + TextBox, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct ForeignAnnotation { + pub page_index: usize, + pub rects: Vec, + /// True only when every markup quadrilateral was usable, not when the + /// navigation rectangle was used as a fallback. + pub precise_text_geometry: bool, + pub kind: ForeignAnnotationKind, + pub text: String, + pub author: String, + pub color_rgb: [f32; 3], + pub key: String, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum DocumentCommentSource { Embedded, @@ -284,6 +310,27 @@ pub struct PageTextChar { pub struct PageLink { pub rect: PdfRect, pub url: String, + /// Zero-based page for an internal PDF destination, when present. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub destination_page: Option, + /// Distance from the top of the rotated, cropped page, if supplied. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub destination_fraction: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PdfOutlineItem { + pub title: String, + pub page_index: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub page_fraction: Option, + pub level: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PdfNavigation { + pub links: Vec>, + pub outline: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src/native_process.rs b/src/native_process.rs index ca6abd6..8c711ff 100644 --- a/src/native_process.rs +++ b/src/native_process.rs @@ -3,7 +3,7 @@ //! a fresh one. Protocol limits apply before allocating peer-supplied lengths. // Unit tests exercise the in-process backend; the built binary verifies supervision. #![cfg_attr(test, allow(dead_code))] -use crate::model::{LoadedDocument, PageLink, PageTextChar, RenderedPage}; +use crate::model::{LoadedDocument, PageLink, PageTextChar, PdfNavigation, RenderedPage}; use crate::pdf_backend::{NativePdfEngine, RenderQuality, VisionPage}; use anyhow::{Result, anyhow}; use crossbeam_channel::{Sender, bounded}; @@ -18,11 +18,36 @@ const MAX_REQUEST: usize = 128 * 1024; const MAX_METADATA: usize = 64 * 1024 * 1024; const MAX_PIXELS: usize = 16_000_000; const REQUEST_DEADLINE: Duration = Duration::from_secs(120); -static CLIENT: OnceLock>> = OnceLock::new(); -static CHILD: OnceLock>>>> = OnceLock::new(); +const INTERACTIVE_DEADLINE: Duration = Duration::from_secs(10); +const OPENING_PREVIEW_DEADLINE: Duration = Duration::from_secs(2); +static BATCH_CLIENT: OnceLock>> = OnceLock::new(); +static INTERACTIVE_CLIENT: OnceLock>> = OnceLock::new(); +static BATCH_CHILD: OnceLock>>>> = OnceLock::new(); +static INTERACTIVE_CHILD: OnceLock>>>> = OnceLock::new(); static SHUTTING_DOWN: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); -#[derive(Serialize, Deserialize)] +#[derive(Clone, Copy)] +enum WorkerLane { + Batch, + Interactive, +} + +impl WorkerLane { + fn client(self) -> &'static OnceLock>> { + match self { + Self::Batch => &BATCH_CLIENT, + Self::Interactive => &INTERACTIVE_CLIENT, + } + } + fn child(self) -> &'static OnceLock>>>> { + match self { + Self::Batch => &BATCH_CHILD, + Self::Interactive => &INTERACTIVE_CHILD, + } + } +} + +#[derive(Clone, Serialize, Deserialize)] enum Request { Ready, Open { @@ -33,6 +58,10 @@ enum Request { path: PathBuf, page_count: usize, }, + Navigation { + path: PathBuf, + page_count: usize, + }, Text { path: PathBuf, page: usize, @@ -56,6 +85,10 @@ enum Request { Close { path: PathBuf, }, + #[cfg(feature = "devtools")] + TestDelay { + millis: u64, + }, } #[derive(Serialize, Deserialize)] @@ -63,8 +96,12 @@ enum Response { Ready, Document(LoadedDocument), Links(Vec>), + Navigation(PdfNavigation), Text(String), - Characters(Vec), + Characters { + #[serde(skip)] + chars: Vec, + }, Page(RenderedPage), Vision(VisionPage), } @@ -79,7 +116,7 @@ struct ProcessClient { } impl ProcessClient { - fn start() -> io::Result { + fn start(lane: WorkerLane) -> io::Result { let mut command = Command::new(std::env::current_exe()?); command .arg("--pdf-worker") @@ -113,7 +150,8 @@ impl ProcessClient { .take() .ok_or_else(|| io::Error::other("No PDF worker output"))?; let child = Arc::new(Mutex::new(child)); - *CHILD + *lane + .child() .get_or_init(|| Mutex::new(None)) .lock() .unwrap_or_else(|error| error.into_inner()) = Some(Arc::downgrade(&child)); @@ -125,33 +163,22 @@ impl ProcessClient { return Err(io::Error::other("LawPDF is closing")); } std::thread::Builder::new() - .name("pdf-process-transport".to_owned()) + .name( + match lane { + WorkerLane::Batch => "pdf-batch-transport", + WorkerLane::Interactive => "pdf-interactive-transport", + } + .to_owned(), + ) .spawn(move || { while let Ok(job) = receiver.recv() { - let result = (|| { + let result: io::Result> = (|| { write_json(&mut input, &job.request, MAX_REQUEST)?; input.flush()?; let mut response: Result = read_json(&mut output, MAX_METADATA)?; let blob = read_frame(&mut output, MAX_PIXELS * 4)?; - match &mut response { - Ok(Response::Page(page)) => { - if pixel_bytes(page.width, page.height, 4)? != blob.len() { - return Err(io::Error::other("Invalid PDF raster length")); - } - page.rgba = blob; - } - Ok(Response::Vision(page)) => { - if pixel_bytes(page.width, page.height, 3)? != blob.len() { - return Err(io::Error::other("Invalid analysis raster length")); - } - page.rgb = blob; - } - _ if !blob.is_empty() => { - return Err(io::Error::other("Unexpected PDF worker payload")); - } - _ => {} - } + assign_payload(&mut response, blob)?; Ok(response) })(); let failed = result.is_err(); @@ -167,12 +194,12 @@ impl ProcessClient { Ok(client) } - fn call(&self, request: Request) -> io::Result> { + fn call(&self, request: Request, deadline: Duration) -> io::Result> { let (reply, result) = bounded(1); self.requests .try_send(Job { request, reply }) .map_err(|_| io::Error::other("PDF worker is unavailable"))?; - result.recv_timeout(REQUEST_DEADLINE).map_err(|_| { + result.recv_timeout(deadline).map_err(|_| { io::Error::new(io::ErrorKind::TimedOut, "The PDF worker stopped responding") })? } @@ -189,12 +216,13 @@ impl Drop for ProcessClient { pub fn shutdown() { crate::diagnostics::event("pdf_worker_shutdown_requested"); SHUTTING_DOWN.store(true, std::sync::atomic::Ordering::Release); - terminate_current_worker(); + terminate_worker(WorkerLane::Batch); + terminate_worker(WorkerLane::Interactive); crate::diagnostics::event("pdf_worker_shutdown_complete"); } -fn terminate_current_worker() { - if let Some(registry) = CHILD.get() +fn terminate_worker(lane: WorkerLane) { + if let Some(registry) = lane.child().get() && let Some(child) = registry .lock() .unwrap_or_else(|error| error.into_inner()) @@ -208,27 +236,64 @@ fn terminate_current_worker() { } fn call(request: Request) -> Result { - anyhow::ensure!(!SHUTTING_DOWN.load(std::sync::atomic::Ordering::Acquire), "LawPDF is closing"); - let mut client = CLIENT + call_with_policy(request, WorkerLane::Batch, REQUEST_DEADLINE, 1) +} + +fn call_interactive(request: Request, retry: bool) -> Result { + call_with_policy( + request, + WorkerLane::Interactive, + INTERACTIVE_DEADLINE, + if retry { 2 } else { 1 }, + ) +} + +fn call_with_policy( + request: Request, + lane: WorkerLane, + deadline: Duration, + attempts: usize, +) -> Result { + anyhow::ensure!( + !SHUTTING_DOWN.load(std::sync::atomic::Ordering::Acquire), + "LawPDF is closing" + ); + let mut client = lane + .client() .get_or_init(|| Mutex::new(None)) .lock() .unwrap_or_else(|error| error.into_inner()); - if client.is_none() { - *client = Some(ProcessClient::start().map_err(|error| { - crate::diagnostics::error("pdf_worker_start_error", &error.to_string()); - anyhow!("Could not start the isolated PDF worker. Reinstall LawPDF if this continues.") - })?); - } - match client.as_ref().expect("started worker").call(request) { - Ok(result) => result.map_err(anyhow::Error::msg), - Err(error) => { - crate::diagnostics::error("pdf_worker_call_error", &error.to_string()); - client.take(); - Err(anyhow!( - "The PDF worker stopped or exceeded its two-minute limit. LawPDF kept your recovery edits. Try opening the PDF again; the worker will restart automatically." - )) + for attempt in 0..attempts.max(1) { + anyhow::ensure!( + !SHUTTING_DOWN.load(std::sync::atomic::Ordering::Acquire), + "LawPDF is closing" + ); + if client.is_none() { + *client = Some(ProcessClient::start(lane).map_err(|error| { + crate::diagnostics::error("pdf_worker_start_error", &error.to_string()); + anyhow!( + "Could not start the isolated PDF worker. Reinstall LawPDF if this continues." + ) + })?); + } + match client + .as_ref() + .expect("started worker") + .call(request.clone(), deadline) + { + Ok(result) => return result.map_err(anyhow::Error::msg), + Err(error) => { + crate::diagnostics::error("pdf_worker_call_error", &error.to_string()); + client.take(); + if attempt + 1 >= attempts.max(1) { + return Err(anyhow!( + "The PDF worker stopped or timed out. LawPDF kept your recovery edits. Try again; the worker will restart automatically." + )); + } + } } } + unreachable!() } pub struct PdfEngine; @@ -267,6 +332,19 @@ impl PdfEngine { _ => Err(protocol_error()), } } + pub fn load_document_navigation( + &self, + path: &Path, + page_count: usize, + ) -> Result { + match call(Request::Navigation { + path: path.to_path_buf(), + page_count, + })? { + Response::Navigation(navigation) => Ok(navigation), + _ => Err(protocol_error()), + } + } pub fn load_page_text(&self, path: &Path, page: usize) -> Result { match call(Request::Text { path: path.to_path_buf(), @@ -281,7 +359,7 @@ impl PdfEngine { path: path.to_path_buf(), page, })? { - Response::Characters(chars) => Ok(chars), + Response::Characters { chars } => Ok(chars), _ => Err(protocol_error()), } } @@ -316,6 +394,46 @@ impl PdfEngine { _ => Err(protocol_error()), } } + pub(crate) fn render_page_interactive( + &self, + path: &Path, + page: usize, + zoom: f32, + quality: RenderQuality, + editor: bool, + retry: bool, + ) -> Result { + match call_interactive( + Request::Render { + path: path.to_path_buf(), + page, + zoom, + quality, + editor, + }, + retry, + )? { + Response::Page(page) => Ok(page), + _ => Err(protocol_error()), + } + } + pub(crate) fn render_opening_preview(&self, path: &Path, page: usize) -> Result { + match call_with_policy( + Request::Render { + path: path.to_path_buf(), + page, + zoom: 1.0, + quality: RenderQuality::Fast, + editor: false, + }, + WorkerLane::Interactive, + OPENING_PREVIEW_DEADLINE, + 1, + )? { + Response::Page(page) => Ok(page), + _ => Err(protocol_error()), + } + } pub fn render_page_for_vision( &self, path: &Path, @@ -347,9 +465,18 @@ impl PdfEngine { ) } pub fn close_document(&self, path: &Path) { - let _ = call(Request::Close { + let request = Request::Close { path: path.to_path_buf(), + }; + let _ = call(request.clone()); + let interactive_started = INTERACTIVE_CLIENT.get().is_some_and(|slot| { + slot.lock() + .unwrap_or_else(|error| error.into_inner()) + .is_some() }); + if interactive_started { + let _ = call_with_policy(request, WorkerLane::Interactive, INTERACTIVE_DEADLINE, 1); + } } } @@ -363,8 +490,83 @@ pub fn verify_supervision(path: &Path) -> Result<()> { let engine = PdfEngine::new()?; let document = engine.load_document(path)?; anyhow::ensure!(document.page_count > 0, "The QA PDF has no pages"); - engine.render_page(path, 0, 0.5)?; - terminate_current_worker(); // Simulate a crash while the reader stays alive. + let direct_chars = NativePdfEngine::new()?.load_page_text_chars(path, 0)?; + let worker_chars = engine.load_page_text_chars(path, 0)?; + anyhow::ensure!( + direct_chars.len() == worker_chars.len() + && direct_chars.iter().zip(&worker_chars).all(|(direct, worker)| { + direct.ch == worker.ch + && direct.rect == worker.rect + && direct.font_size == worker.font_size + && direct.bold == worker.bold + && direct.italic == worker.italic + }), + "PDF worker returned different character geometry or formatting" + ); + let batch_page = engine.render_page(path, 0, 0.5)?; + let interactive_page = + engine.render_page_interactive(path, 0, 0.5, RenderQuality::Crisp, false, true)?; + anyhow::ensure!( + batch_page.width == interactive_page.width + && batch_page.height == interactive_page.height + && batch_page.rgba == interactive_page.rgba, + "Separate workers returned different page pixels" + ); + let child_id = |lane: WorkerLane| -> Option { + let child = lane.child().get()?.lock().ok()?.as_ref()?.upgrade()?; + let id = child.lock().ok()?.id(); + Some(id) + }; + anyhow::ensure!( + child_id(WorkerLane::Batch).is_some() + && child_id(WorkerLane::Interactive).is_some() + && child_id(WorkerLane::Batch) != child_id(WorkerLane::Interactive), + "PDF lanes must use separate processes" + ); + + // Hold the batch lane in the child while a visible page is rendered. + let batch = std::thread::spawn(|| { + call_with_policy( + Request::TestDelay { millis: 3000 }, + WorkerLane::Batch, + Duration::from_secs(10), + 1, + ) + }); + let until = std::time::Instant::now() + Duration::from_secs(2); + while BATCH_CLIENT.get().unwrap().try_lock().is_ok() && std::time::Instant::now() < until { + std::thread::sleep(Duration::from_millis(5)); + } + anyhow::ensure!( + BATCH_CLIENT.get().unwrap().try_lock().is_err(), + "Batch lane did not start" + ); + engine.render_page_interactive(path, 0, 0.5, RenderQuality::Crisp, false, true)?; + anyhow::ensure!( + !batch.is_finished(), + "Interactive rendering waited for the batch lane" + ); + batch + .join() + .map_err(|_| anyhow!("Batch QA thread panicked"))??; + + // An interactive crash is retried once on a new child. + terminate_worker(WorkerLane::Interactive); + engine.render_page_interactive(path, 0, 0.5, RenderQuality::Crisp, false, true)?; + // A timed-out transport is killed and a later request starts cleanly. + anyhow::ensure!( + call_with_policy( + Request::TestDelay { millis: 1000 }, + WorkerLane::Interactive, + Duration::from_millis(50), + 1 + ) + .is_err(), + "Interactive timeout was not enforced" + ); + engine.render_page_interactive(path, 0, 0.5, RenderQuality::Crisp, false, true)?; + + terminate_worker(WorkerLane::Batch); // Simulate a batch crash while the reader stays alive. anyhow::ensure!( engine.load_document(path).is_err(), "The killed worker should report failure" @@ -376,10 +578,39 @@ pub fn verify_supervision(path: &Path) -> Result<()> { ); engine.render_page(path, 0, 0.5)?; engine.close_document(path); + // Saving replaces a file, so both workers must release their cached handles. + // Use a disposable copy and verify that annotations survive the rewrite. + let scratch = tempfile::NamedTempFile::new()?.into_temp_path(); + std::fs::copy(path, &scratch)?; + engine.render_page(&scratch, 0, 0.5)?; + engine.render_page_interactive(&scratch, 0, 0.5, RenderQuality::Crisp, false, true)?; + engine.close_document(&scratch); + let mark = crate::model::EditorAnnotation { + page_index: 0, + rect: crate::model::PdfRect::new(20.0, 90.0, 180.0, 110.0), + kind: crate::model::AnnotationKind::Marker { + color_rgb: [1.0, 0.93, 0.45], + opacity: 0.42, + style: crate::model::MarkerStyle::Highlight, + }, + }; + crate::pdf_backend::save_with_annotations(&scratch, &scratch, &[mark])?; + anyhow::ensure!( + crate::pdf_backend::load_lawpdf_annotations(&scratch)?.len() == 1, + "The saved annotation did not survive reopening" + ); + engine.render_page(&scratch, 0, 0.5)?; + engine.render_page_interactive(&scratch, 0, 0.5, RenderQuality::Crisp, false, true)?; + engine.close_document(&scratch); original.require_current(path).map_err(anyhow::Error::msg)?; shutdown(); - anyhow::ensure!(PdfEngine::new().is_err(), "Shutdown must reject new worker requests"); - println!("PDF worker crash recovery passed; source unchanged"); + anyhow::ensure!( + PdfEngine::new().is_err(), + "Shutdown must reject new worker requests" + ); + println!( + "Separate PDF workers, interactive timeout and crash recovery passed; source unchanged" + ); Ok(()) } @@ -394,11 +625,22 @@ pub fn run_worker() -> io::Result<()> { Err(error) if error.kind() == io::ErrorKind::UnexpectedEof => return Ok(()), Err(error) => return Err(error), }; - let response = execute(&engine, request).map_err(|error| format!("{error:#}")); + let mut response = execute(&engine, request).map_err(|error| format!("{error:#}")); + let characters = match &response { + Ok(Response::Characters { chars }) => match crate::page_text_codec::encode(chars) { + Ok(bytes) => Some(bytes), + Err(error) => { + response = Err(format!("Could not encode PDF page text: {error}")); + None + } + }, + _ => None, + }; write_json(&mut output, &response, MAX_METADATA)?; let blob = match &response { Ok(Response::Page(page)) => page.rgba.as_slice(), Ok(Response::Vision(page)) => page.rgb.as_slice(), + Ok(Response::Characters { .. }) => characters.as_deref().unwrap_or_default(), _ => &[], }; write_frame(&mut output, blob, MAX_PIXELS * 4)?; @@ -409,8 +651,11 @@ pub fn run_worker() -> io::Result<()> { fn execute(engine: &NativePdfEngine, request: Request) -> Result { match &request { Request::Ready | Request::Close { .. } => {} + #[cfg(feature = "devtools")] + Request::TestDelay { .. } => {} Request::Open { path, .. } | Request::Links { path, .. } + | Request::Navigation { path, .. } | Request::Text { path, .. } | Request::Characters { path, .. } | Request::Render { path, .. } @@ -429,10 +674,12 @@ fn execute(engine: &NativePdfEngine, request: Request) -> Result { | Request::Vision { page, .. } => { anyhow::ensure!(*page < 20_000, "This page exceeds the 20,000-page limit.") } - Request::Links { page_count, .. } => anyhow::ensure!( - *page_count <= 20_000, - "This PDF exceeds the 20,000-page limit." - ), + Request::Links { page_count, .. } | Request::Navigation { page_count, .. } => { + anyhow::ensure!( + *page_count <= 20_000, + "This PDF exceeds the 20,000-page limit." + ) + } _ => {} } match request { @@ -443,10 +690,13 @@ fn execute(engine: &NativePdfEngine, request: Request) -> Result { Request::Links { path, page_count } => engine .load_document_links(&path, page_count) .map(Response::Links), + Request::Navigation { path, page_count } => engine + .load_document_navigation(&path, page_count) + .map(Response::Navigation), Request::Text { path, page } => engine.load_page_text(&path, page).map(Response::Text), Request::Characters { path, page } => engine .load_page_text_chars(&path, page) - .map(Response::Characters), + .map(|chars| Response::Characters { chars }), Request::Render { path, page, @@ -463,6 +713,12 @@ fn execute(engine: &NativePdfEngine, request: Request) -> Result { engine.close_document(&path); Ok(Response::Ready) } + #[cfg(feature = "devtools")] + Request::TestDelay { millis } => { + anyhow::ensure!(millis <= 5000, "QA delay exceeds the limit"); + std::thread::sleep(Duration::from_millis(millis)); + Ok(Response::Ready) + } } } @@ -473,6 +729,29 @@ fn pixel_bytes(width: usize, height: usize, channels: usize) -> io::Result, blob: Vec) -> io::Result<()> { + match response { + Ok(Response::Page(page)) => { + if pixel_bytes(page.width, page.height, 4)? != blob.len() { + return Err(io::Error::other("Invalid PDF raster length")); + } + page.rgba = blob; + } + Ok(Response::Vision(page)) => { + if pixel_bytes(page.width, page.height, 3)? != blob.len() { + return Err(io::Error::other("Invalid analysis raster length")); + } + page.rgb = blob; + } + Ok(Response::Characters { chars }) => { + *chars = crate::page_text_codec::decode(&blob)?; + } + _ if !blob.is_empty() => return Err(io::Error::other("Unexpected PDF worker payload")), + _ => {} + } + Ok(()) +} fn write_json(output: &mut impl Write, value: &impl Serialize, limit: usize) -> io::Result<()> { let bytes = serde_json::to_vec(value).map_err(io::Error::other)?; write_frame(output, &bytes, limit) @@ -502,3 +781,39 @@ fn read_frame(input: &mut impl Read, limit: usize) -> io::Result> { input.read_exact(&mut bytes)?; Ok(bytes) } + +#[cfg(test)] +mod character_transport_tests { + use super::*; + + #[test] + fn response_metadata_never_contains_character_data() { + let response: Result = Ok(Response::Characters { + chars: vec![PageTextChar { + ch: '§', rect: None, font_size: Some(12.0), bold: false, italic: true, + }], + }); + let metadata = serde_json::to_vec(&response).unwrap(); + assert!(metadata.len() < 64); + assert!(!String::from_utf8_lossy(&metadata).contains('§')); + let decoded: Result = serde_json::from_slice(&metadata).unwrap(); + assert!(matches!(decoded, Ok(Response::Characters { chars }) if chars.is_empty())); + } + + #[test] + fn character_payload_populates_response_and_rejects_corruption() { + let original = vec![PageTextChar { + ch: '𝄞', rect: Some(crate::model::PdfRect::new(1.0, 2.0, 3.0, 4.0)), + font_size: Some(9.5), bold: true, italic: true, + }]; + let encoded = crate::page_text_codec::encode(&original).unwrap(); + let mut response = Ok(Response::Characters { chars: Vec::new() }); + assign_payload(&mut response, encoded.clone()).unwrap(); + let Ok(Response::Characters { chars }) = response else { panic!("wrong response") }; + assert_eq!((chars[0].ch, chars[0].rect, chars[0].font_size, chars[0].bold, chars[0].italic), + (original[0].ch, original[0].rect, original[0].font_size, original[0].bold, original[0].italic)); + let mut response = Ok(Response::Characters { chars: Vec::new() }); + assert!(assign_payload(&mut response, encoded[..encoded.len() - 1].to_vec()).is_err()); + assert!(assign_payload(&mut Ok(Response::Ready), encoded).is_err()); + } +} diff --git a/src/page_text_codec.rs b/src/page_text_codec.rs new file mode 100644 index 0000000..1806734 --- /dev/null +++ b/src/page_text_codec.rs @@ -0,0 +1,226 @@ +//! Compact, bounded representation of a PDF page's selectable characters. +//! +//! This is shared by the isolated PDF worker and the disposable page cache. +//! Floats are stored losslessly as little-endian IEEE 754 values. + +use std::io; + +use crate::model::{PageTextChar, PdfRect}; + +const MAGIC: &[u8; 4] = b"LPC1"; +const HEADER_BYTES: usize = 8; +const MIN_RECORD_BYTES: usize = 5; +pub(crate) const MAX_ENCODED_BYTES: usize = 64_000_000; +const MAX_CHARACTERS: usize = 2_000_000; +const HAS_RECT: u8 = 1; +const HAS_FONT_SIZE: u8 = 2; +const BOLD: u8 = 4; +const ITALIC: u8 = 8; +const KNOWN_FLAGS: u8 = HAS_RECT | HAS_FONT_SIZE | BOLD | ITALIC; + +fn invalid(message: &'static str) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, message) +} + +pub(crate) fn encode(chars: &[PageTextChar]) -> io::Result> { + if chars.len() > MAX_CHARACTERS { + return Err(invalid("Too many PDF page characters")); + } + let mut length = HEADER_BYTES; + for item in chars { + if item.rect.is_some_and(|rect| { + ![rect.left, rect.bottom, rect.right, rect.top] + .into_iter() + .all(f32::is_finite) + }) || item.font_size.is_some_and(|value| !value.is_finite()) + { + return Err(invalid("Invalid PDF character geometry")); + } + length = length + .checked_add( + MIN_RECORD_BYTES + + usize::from(item.rect.is_some()) * 16 + + usize::from(item.font_size.is_some()) * 4, + ) + .filter(|length| *length <= MAX_ENCODED_BYTES) + .ok_or_else(|| invalid("PDF page characters exceed the size limit"))?; + } + + let mut output = Vec::with_capacity(length); + output.extend_from_slice(MAGIC); + output.extend_from_slice(&(chars.len() as u32).to_le_bytes()); + for item in chars { + output.extend_from_slice(&(item.ch as u32).to_le_bytes()); + let flags = if item.rect.is_some() { HAS_RECT } else { 0 } + | if item.font_size.is_some() { + HAS_FONT_SIZE + } else { + 0 + } + | if item.bold { BOLD } else { 0 } + | if item.italic { ITALIC } else { 0 }; + output.push(flags); + if let Some(rect) = item.rect { + for value in [rect.left, rect.bottom, rect.right, rect.top] { + output.extend_from_slice(&value.to_le_bytes()); + } + } + if let Some(value) = item.font_size { + output.extend_from_slice(&value.to_le_bytes()); + } + } + debug_assert_eq!(output.len(), length); + Ok(output) +} + +pub(crate) fn decode(bytes: &[u8]) -> io::Result> { + if bytes.len() < HEADER_BYTES || bytes.len() > MAX_ENCODED_BYTES || &bytes[..4] != MAGIC { + return Err(invalid("Invalid PDF character data header")); + } + let count = u32::from_le_bytes(bytes[4..8].try_into().unwrap()) as usize; + if count > MAX_CHARACTERS || count > (bytes.len() - HEADER_BYTES) / MIN_RECORD_BYTES { + return Err(invalid("Invalid PDF character count")); + } + let mut position = HEADER_BYTES; + let mut chars = Vec::with_capacity(count); + for _ in 0..count { + let scalar = read_u32(bytes, &mut position)?; + let ch = char::from_u32(scalar).ok_or_else(|| invalid("Invalid PDF character"))?; + let flags = *bytes + .get(position) + .ok_or_else(|| invalid("Truncated PDF character"))?; + position += 1; + if flags & !KNOWN_FLAGS != 0 { + return Err(invalid("Invalid PDF character flags")); + } + let rect = if flags & HAS_RECT != 0 { + Some(PdfRect { + left: read_f32(bytes, &mut position)?, + bottom: read_f32(bytes, &mut position)?, + right: read_f32(bytes, &mut position)?, + top: read_f32(bytes, &mut position)?, + }) + } else { + None + }; + let font_size = if flags & HAS_FONT_SIZE != 0 { + Some(read_f32(bytes, &mut position)?) + } else { + None + }; + chars.push(PageTextChar { + ch, + rect, + font_size, + bold: flags & BOLD != 0, + italic: flags & ITALIC != 0, + }); + } + if position != bytes.len() { + return Err(invalid("Trailing PDF character data")); + } + Ok(chars) +} + +fn read_u32(bytes: &[u8], position: &mut usize) -> io::Result { + let end = position + .checked_add(4) + .ok_or_else(|| invalid("Invalid PDF character offset"))?; + let raw: [u8; 4] = bytes + .get(*position..end) + .ok_or_else(|| invalid("Truncated PDF character"))? + .try_into() + .unwrap(); + *position = end; + Ok(u32::from_le_bytes(raw)) +} + +fn read_f32(bytes: &[u8], position: &mut usize) -> io::Result { + let value = f32::from_bits(read_u32(bytes, position)?); + value + .is_finite() + .then_some(value) + .ok_or_else(|| invalid("Invalid PDF character geometry")) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample() -> Vec { + vec![ + PageTextChar { + ch: 'A', + rect: Some(PdfRect::new(-1.25, 2.5, 3.75, 4.5)), + font_size: Some(12.25), + bold: true, + italic: false, + }, + PageTextChar { + ch: '§', + rect: None, + font_size: None, + bold: false, + italic: true, + }, + PageTextChar { + ch: '𝄞', + rect: Some(PdfRect::new(0.0, 1.0, 2.0, 3.0)), + font_size: None, + bold: true, + italic: true, + }, + PageTextChar { + ch: '\n', + rect: None, + font_size: Some(0.0), + bold: false, + italic: false, + }, + ] + } + + #[test] + fn round_trips_geometry_flags_unicode_and_empty() { + let chars = sample(); + let decoded = decode(&encode(&chars).unwrap()).unwrap(); + assert_eq!(chars.len(), decoded.len()); + for (a, b) in chars.iter().zip(decoded.iter()) { + assert_eq!( + (a.ch, a.rect, a.font_size, a.bold, a.italic), + (b.ch, b.rect, b.font_size, b.bold, b.italic) + ); + } + assert!(decode(&encode(&[]).unwrap()).unwrap().is_empty()); + assert!(encode(&chars).unwrap().len() < serde_json::to_vec(&chars).unwrap().len()); + } + + #[test] + fn rejects_malformed_and_truncated_records() { + let valid = encode(&sample()).unwrap(); + for end in 0..valid.len() { + assert!( + decode(&valid[..end]).is_err(), + "accepted truncation at {end}" + ); + } + let mut extra = valid.clone(); + extra.push(0); + assert!(decode(&extra).is_err()); + let mut invalid_count = valid.clone(); + invalid_count[4..8].copy_from_slice(&u32::MAX.to_le_bytes()); + assert!(decode(&invalid_count).is_err()); + let mut invalid_char = valid.clone(); + invalid_char[8..12].copy_from_slice(&0xd800u32.to_le_bytes()); + assert!(decode(&invalid_char).is_err()); + let mut invalid_flags = valid.clone(); + invalid_flags[12] |= 0x80; + assert!(decode(&invalid_flags).is_err()); + let mut invalid_float = valid; + invalid_float[13..17].copy_from_slice(&f32::NAN.to_le_bytes()); + assert!(decode(&invalid_float).is_err()); + let mut invalid_source = sample(); + invalid_source[0].font_size = Some(f32::INFINITY); + assert!(encode(&invalid_source).is_err()); + } +} diff --git a/src/pdf_backend.rs b/src/pdf_backend.rs index a4d634c..11cba41 100644 --- a/src/pdf_backend.rs +++ b/src/pdf_backend.rs @@ -15,10 +15,14 @@ use pdfium_render::prelude::*; use crate::model::{ AnnotationKind, DocumentComment, DocumentCommentSource, EditorAnnotation, LoadedDocument, - MarkerStyle, PageInfo, PageLink, PageTextChar, PdfRect, RenderedPage, + MarkerStyle, PageInfo, PageLink, PageTextChar, PdfNavigation, PdfRect, RenderedPage, }; use crate::performance_cache::{CachedDocumentMetadata, PerformanceCache}; +mod foreign_annotations; +mod navigation; +pub use foreign_annotations::load_foreign_annotations_checked; + #[cfg(not(test))] pub use crate::native_process::PdfEngine; #[cfg(test)] @@ -142,16 +146,31 @@ impl NativePdfEngine { let page_count = document.pages().len() as usize; let mut pages = Vec::with_capacity(page_count); for page_index in 0..page_count { - let page = document + // Optimized metadata contains only the displayed page dimensions: + // vectors and footnote dividers are loaded later. PDFium can read + // these dimensions by index without loading each page's contents. + let dimensions = document .pages() - .get(page_index as u16) - .with_context(|| format!("failed to read page {}", page_index + 1))?; - let geometry = native_page_geometry(&page); - let width = page.width().value; - let height = page.height().value; - let mut page_info = PageInfo::with_footnote_divider_y_from_top(width, height, None); - map_page_geometry(&mut page_info, geometry); - pages.push(page_info); + .page_size(page_index as u16) + .ok() + .map(|size| (size.width().value, size.height().value)) + .filter(|(width, height)| { + width.is_finite() && height.is_finite() && *width > 0.0 && *height > 0.0 + }); + let (width, height) = if let Some(dimensions) = dimensions { + dimensions + } else { + // Preserve the previous loading path for unusual PDFs where + // PDFium cannot provide a valid indexed size. + let page = document + .pages() + .get(page_index as u16) + .with_context(|| format!("failed to read page {}", page_index + 1))?; + (page.width().value, page.height().value) + }; + pages.push(PageInfo::with_footnote_divider_y_from_top( + width, height, None, + )); } Ok(CachedDocumentMetadata { links: vec![Vec::new(); page_count], @@ -187,12 +206,23 @@ impl NativePdfEngine { path: &Path, page_count: usize, ) -> Result>> { - if let Some(links) = self.performance_cache.load_document_links(path) { - return Ok(links); + Ok(self.load_document_navigation(path, page_count)?.links) + } + + pub fn load_document_navigation( + &self, + path: &Path, + page_count: usize, + ) -> Result { + if let Some(navigation) = self.performance_cache.load_document_navigation(path) + && navigation.links.len() == page_count + { + return Ok(navigation); } - let links = load_pdf_web_links(path, page_count)?; - self.performance_cache.save_document_links(path, &links); - Ok(links) + let navigation = navigation::load_pdf_navigation(path, page_count)?; + self.performance_cache + .save_document_navigation(path, &navigation); + Ok(navigation) } pub fn load_document(&self, path: &Path) -> Result { @@ -340,6 +370,24 @@ impl NativePdfEngine { self.render_page_internal(path, page_index, zoom, quality, true) } + #[cfg(test)] + pub(crate) fn render_page_interactive( + &self, + path: &Path, + page_index: usize, + zoom: f32, + quality: RenderQuality, + editor: bool, + _retry: bool, + ) -> Result { + self.render_page_internal(path, page_index, zoom, quality, editor) + } + + #[cfg(test)] + pub(crate) fn render_opening_preview(&self, path: &Path, page: usize) -> Result { + self.render_page_internal(path, page, 1.0, RenderQuality::Fast, false) + } + pub(crate) fn render_page_internal( &self, path: &Path, @@ -354,11 +402,15 @@ impl NativePdfEngine { } else { &self.performance_cache }; - if quality != RenderQuality::Print - && let Some(rendered) = cache.load_rendered_page(path, page_index, zoom, fast) { + let cache_path = (quality != RenderQuality::Print) + .then(|| cache.render_path(path, page_index, zoom, fast)) + .flatten(); + if let Some(cache_path) = &cache_path + && let Some(rendered) = PerformanceCache::load_rendered_page_at(cache_path, page_index) + { return Ok(rendered); } - let rendered = self.with_open_document_data(path, |open| { + let (rendered, render_time) = self.with_open_document_data(path, |open| { let page = open .document .pages() @@ -421,6 +473,7 @@ impl NativePdfEngine { hidden.0.push((annotation, was_hidden)); } } + let render_started = std::time::Instant::now(); let bitmap = page.render_with_config(&config); hidden.restore()?; let bitmap = @@ -440,15 +493,22 @@ impl NativePdfEngine { bitmap.as_rgba_bytes(), ); - Ok(RenderedPage { - page_index, - width: width as usize, - height: height as usize, - rgba, - }) + Ok(( + RenderedPage { + page_index, + width: width as usize, + height: height as usize, + rgba, + }, + render_started.elapsed(), + )) })?; - if quality != RenderQuality::Print { - cache.save_rendered_page(path, &rendered, zoom, fast); + // A scanned sample rendered in roughly 24 ms before channel + // normalization and benefited from cache hits on this machine. + if render_time >= std::time::Duration::from_millis(25) + && let Some(cache_path) = cache_path + { + cache.queue_rendered_page_at(cache_path, &rendered); } Ok(rendered) } @@ -1910,32 +1970,7 @@ fn pdf_page_geometry( } pub fn load_pdf_web_links(source: &Path, page_count: usize) -> Result>> { - let document = Document::load(source) - .with_context(|| format!("failed to load source PDF {}", source.display()))?; - let pages = document.get_pages(); - let mut links = vec![Vec::new(); page_count]; - - for (page_number, page_id) in pages { - let page_index = page_number.saturating_sub(1) as usize; - if page_index >= links.len() { - continue; - } - let annots = { - let page = document.get_object(page_id)?.as_dict()?; - page.get(b"Annots").ok().cloned() - }; - let Some(annots) = annots else { - continue; - }; - - append_pdf_web_links_from_annots(&document, &annots, &mut links[page_index]); - let geometry = pdf_page_geometry(&document, page_id)?; - for link in &mut links[page_index] { - link.rect = geometry.rect(link.rect, false); - } - } - - Ok(links) + Ok(navigation::load_pdf_navigation(source, page_count)?.links) } pub fn load_lawpdf_annotations(source: &Path) -> Result> { @@ -2775,57 +2810,6 @@ fn lawpdf_owned_annotation_from_pdf( }) } -fn append_pdf_web_links_from_annots( - document: &Document, - annots: &Object, - page_links: &mut Vec, -) { - match annots { - Object::Array(annots) => { - for annot in annots { - if let Some(link) = pdf_web_link_from_annotation(document, annot) { - page_links.push(link); - } - } - } - Object::Reference(annots_id) => { - if let Ok(annots) = document.get_object(*annots_id).and_then(Object::as_array) { - for annot in annots { - if let Some(link) = pdf_web_link_from_annotation(document, annot) { - page_links.push(link); - } - } - } - } - annot => { - if let Some(link) = pdf_web_link_from_annotation(document, annot) { - page_links.push(link); - } - } - } -} - -fn pdf_web_link_from_annotation(document: &Document, annotation: &Object) -> Option { - let dict = annotation_dict(document, annotation)?; - let subtype = dict.get(b"Subtype").ok().and_then(pdf_object_text)?; - if !subtype.eq_ignore_ascii_case("Link") { - return None; - } - - let rect = dict.get(b"Rect").ok().and_then(pdf_rect_from_object)?; - if rect.width() <= 0.0 || rect.height() <= 0.0 { - return None; - } - - let uri = dict - .get(b"A") - .ok() - .and_then(|action| pdf_uri_from_action(document, action))?; - let url = normalize_web_url(&uri)?; - - Some(PageLink { rect, url }) -} - fn pdf_uri_from_action(document: &Document, action: &Object) -> Option { let dict = annotation_dict(document, action)?; let action_type = dict.get(b"S").ok().and_then(pdf_object_text)?; @@ -3166,6 +3150,78 @@ mod tests { assert_eq!(win_ansi_byte('A'), b'A'); } + #[test] + fn indexed_optimized_page_sizes_match_loaded_pdfium_geometry() { + let directory = tempfile::tempdir().unwrap(); + let engine = + NativePdfEngine::new().expect("PDFium must be available for the desktop tests"); + let crops = [ + [0, 0, 612, 792], + [20, 40, 600, 760], + [-30, -20, 650, 840], + [100, 150, 400, 600], + ]; + for (crop_number, crop) in crops.into_iter().enumerate() { + for rotation in [0, 90, 180, 270] { + for inherited in [false, true] { + let path = directory + .path() + .join(format!("size-{crop_number}-{rotation}-{inherited}.pdf")); + write_blank_pdf(&path); + let mut pdf = Document::load(&path).unwrap(); + let page_id = pdf.get_pages()[&1]; + let parent_id = pdf + .get_object(page_id) + .unwrap() + .as_dict() + .unwrap() + .get(b"Parent") + .unwrap() + .as_reference() + .unwrap(); + let target = if inherited { parent_id } else { page_id }; + let dictionary = pdf.get_object_mut(target).unwrap().as_dict_mut().unwrap(); + dictionary.set("Rotate", Object::Integer(rotation)); + dictionary.set( + "CropBox", + Object::Array(crop.into_iter().map(Object::Integer).collect()), + ); + if rotation == 180 { + pdf.get_object_mut(page_id) + .unwrap() + .as_dict_mut() + .unwrap() + .set("UserUnit", Object::Integer(2)); + } + save_document_in_place(&mut pdf, &path).unwrap(); + let mut expected = engine + .with_open_document(&path, |document| { + let page = document.pages().get(0)?; + let indexed = document.pages().page_size(0)?; + let (width, height) = (page.width().value, page.height().value); + assert!((indexed.width().value - width).abs() < 0.001); + assert!((indexed.height().value - height).abs() < 0.001); + let mut legacy = + PageInfo::with_footnote_divider_y_from_top(width, height, None); + map_page_geometry(&mut legacy, native_page_geometry(&page)); + Ok(legacy) + }) + .unwrap(); + let opened = engine.load_document_optimized(&path).unwrap(); + assert_eq!(opened.page_count, 1); + assert!((opened.pages[0].width - expected.width).abs() < 0.001); + assert!((opened.pages[0].height - expected.height).abs() < 0.001); + expected.width = opened.pages[0].width; + expected.height = opened.pages[0].height; + assert_eq!( + serde_json::to_value(&opened.pages[0]).unwrap(), + serde_json::to_value(expected).unwrap() + ); + } + } + } + } + #[test] fn marks_round_trip_on_cropped_rotated_pages_and_after_rotation() { let directory = tempfile::tempdir().unwrap(); diff --git a/src/pdf_backend/foreign_annotations.rs b/src/pdf_backend/foreign_annotations.rs new file mode 100644 index 0000000..2b5ae94 --- /dev/null +++ b/src/pdf_backend/foreign_annotations.rs @@ -0,0 +1,293 @@ +//! Read other applications' annotations without adding them to editable state. +use super::*; +use crate::document_store::{FileRevision, MAX_DOCUMENT_BYTES, read_limited}; +use crate::model::{ForeignAnnotation, ForeignAnnotationKind}; + +const MAX_FOREIGN_MARKS: usize = 100_000; +const MAX_QUADS_PER_MARK: usize = 10_000; + +pub fn load_foreign_annotations_checked( + source: &Path, + expected: &FileRevision, +) -> Result> { + let bytes = read_limited(source, MAX_DOCUMENT_BYTES).map_err(anyhow::Error::msg)?; + anyhow::ensure!( + FileRevision::from_bytes(&bytes) == *expected, + "The PDF changed outside LawPDF. Reopen it to load annotations from the new revision." + ); + let document = Document::load_mem(&bytes).context("Could not read PDF annotations")?; + foreign_annotations(&document) +} + +fn foreign_annotations(document: &Document) -> Result> { + let mut result = Vec::new(); + for (number, page_id) in document.get_pages() { + let page_index = number.saturating_sub(1) as usize; + let page = document.get_object(page_id)?.as_dict()?; + let Ok(annots) = page.get(b"Annots") else { + continue; + }; + let annots = match annots { + Object::Reference(id) => document.get_object(*id)?, + value => value, + }; + let items = match annots { + Object::Array(items) => items.as_slice(), + value => std::slice::from_ref(value), + }; + let mut geometry = None; + for (ordinal, object) in items.iter().enumerate() { + let Some(dict) = annotation_dict(document, object) else { + continue; + }; + if is_lawpdf_owned_dict(dict) { + continue; + } + let kind = match dict + .get(b"Subtype") + .ok() + .and_then(pdf_object_text) + .as_deref() + { + Some("Highlight") => ForeignAnnotationKind::Highlight, + Some("Underline") => ForeignAnnotationKind::Underline, + Some("Squiggly") => ForeignAnnotationKind::Squiggly, + Some("StrikeOut") => ForeignAnnotationKind::StrikeOut, + Some("Text") => ForeignAnnotationKind::Comment, + Some("FreeText") => ForeignAnnotationKind::TextBox, + _ => continue, + }; + let mut rects = Vec::new(); + let mut precise_text_geometry = false; + if matches!( + kind, + ForeignAnnotationKind::Highlight + | ForeignAnnotationKind::Underline + | ForeignAnnotationKind::Squiggly + | ForeignAnnotationKind::StrikeOut + ) && let Ok(value) = dict.get(b"QuadPoints") + { + let value = match value { + Object::Reference(id) => document.get_object(*id).ok(), + value => Some(value), + }; + if let Some(points) = value.and_then(|value| value.as_array().ok()) { + anyhow::ensure!( + points.len() <= MAX_QUADS_PER_MARK * 8, + "Too many annotation quadrilaterals to index safely" + ); + if !points.is_empty() && points.len() % 8 == 0 { + let quads = points + .chunks_exact(8) + .map(|quad| { + let values = + quad.iter().map(pdf_number).collect::>>()?; + if !values.iter().all(|value| value.is_finite()) { + return None; + } + let xs = [values[0], values[2], values[4], values[6]]; + let ys = [values[1], values[3], values[5], values[7]]; + let rect = PdfRect::new( + xs.into_iter().fold(f32::INFINITY, f32::min), + ys.into_iter().fold(f32::INFINITY, f32::min), + xs.into_iter().fold(f32::NEG_INFINITY, f32::max), + ys.into_iter().fold(f32::NEG_INFINITY, f32::max), + ); + (rect.width() > 0.0 && rect.height() > 0.0).then_some(rect) + }) + .collect::>>(); + if let Some(quads) = quads { + rects = quads; + precise_text_geometry = true; + } + } + } + } + if rects.is_empty() + && let Some(rect) = dict.get(b"Rect").ok().and_then(pdf_rect_from_object) + && [rect.left, rect.bottom, rect.right, rect.top] + .into_iter() + .all(f32::is_finite) + && rect.width() > 0.0 + && rect.height() > 0.0 + { + rects.push(rect); + } + if rects.is_empty() { + continue; + } + let geometry = match geometry { + Some(geometry) => geometry, + None => { + let value = pdf_page_geometry(document, page_id)?; + geometry = Some(value); + value + } + }; + for rect in &mut rects { + *rect = geometry.rect(*rect, false); + } + let text = dict + .get(b"Contents") + .ok() + .and_then(pdf_object_text) + .unwrap_or_default(); + let author = dict + .get(b"T") + .ok() + .and_then(pdf_object_text) + .unwrap_or_default(); + let color_rgb = dict + .get(b"C") + .ok() + .and_then(pdf_color_from_object) + .unwrap_or([0.42, 0.47, 0.58]); + let key = comment_reference_key(document, object) + .unwrap_or_else(|| format!("doc-inline-{page_index}-{ordinal}")); + anyhow::ensure!( + result.len() < MAX_FOREIGN_MARKS, + "Too many annotations to index safely" + ); + result.push(ForeignAnnotation { + page_index, + rects, + precise_text_geometry, + kind, + text, + author, + color_rgb, + key, + }); + } + } + result.sort_by(|a, b| { + a.page_index + .cmp(&b.page_index) + .then_with(|| b.rects[0].top.total_cmp(&a.rects[0].top)) + .then_with(|| a.key.cmp(&b.key)) + }); + Ok(result) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fixture(path: &Path, rotate: bool) { + let mut document = Document::with_version("1.5"); + let catalog = document.new_object_id(); + let pages = document.new_object_id(); + let page = document.new_object_id(); + let highlight = document.add_object(dictionary! { "Type" => "Annot", "Subtype" => "Highlight", + "Rect" => vec![50.into(), 650.into(), 150.into(), 700.into()], + "QuadPoints" => vec![50.into(), 700.into(), 150.into(), 700.into(), 50.into(), 690.into(), 150.into(), 690.into(), + 50.into(), 670.into(), 120.into(), 670.into(), 50.into(), 660.into(), 120.into(), 660.into()] }); + let comment = document.add_object(dictionary! { "Type" => "Annot", "Subtype" => "Text", + "Rect" => vec![20.into(), 400.into(), 40.into(), 420.into()], "Contents" => Object::string_literal("External comment") }); + let owned = document.add_object( + dictionary! { "Type" => "Annot", "Subtype" => "Underline", "LawPDF" => true, + "Rect" => vec![20.into(), 500.into(), 80.into(), 515.into()] }, + ); + document.objects.insert( + catalog, + Object::Dictionary(dictionary! { "Type" => "Catalog", "Pages" => pages }), + ); + document.objects.insert(pages, Object::Dictionary(dictionary! { "Type" => "Pages", "Kids" => vec![Object::Reference(page)], "Count" => 1 })); + let mut page_dict = dictionary! { "Type" => "Page", "Parent" => pages, "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()], + "CropBox" => vec![20.into(), 40.into(), 600.into(), 760.into()], "Resources" => dictionary! {}, + "Annots" => vec![Object::Reference(highlight), Object::Reference(comment), Object::Reference(owned)] }; + if rotate { + page_dict.set("Rotate", 90); + } + document.objects.insert(page, Object::Dictionary(page_dict)); + document.trailer.set("Root", catalog); + document.save(path).unwrap(); + } + + #[test] + fn imports_foreign_quads_and_comments_without_claiming_owned_marks() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("annotated.pdf"); + fixture(&path, false); + let before = std::fs::read(&path).unwrap(); + let revision = FileRevision::read(&path).unwrap(); + let marks = load_foreign_annotations_checked(&path, &revision).unwrap(); + assert_eq!(marks.len(), 2); + assert_eq!(marks[0].kind, ForeignAnnotationKind::Highlight); + assert_eq!(marks[0].rects.len(), 2); + assert!(marks[0].precise_text_geometry); + assert_eq!(marks[0].rects[0], PdfRect::new(30.0, 650.0, 130.0, 660.0)); + assert_eq!(marks[1].kind, ForeignAnnotationKind::Comment); + assert_eq!(marks[1].text, "External comment"); + assert_eq!(std::fs::read(&path).unwrap(), before); + std::fs::write(&path, b"changed externally").unwrap(); + assert!(load_foreign_annotations_checked(&path, &revision).is_err()); + } + + #[test] + fn foreign_quads_follow_cropped_rotated_page_geometry() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("rotated.pdf"); + fixture(&path, true); + let revision = FileRevision::read(&path).unwrap(); + let marks = load_foreign_annotations_checked(&path, &revision).unwrap(); + let highlight = marks + .iter() + .find(|mark| mark.kind == ForeignAnnotationKind::Highlight) + .unwrap(); + let geometry = crate::page_geometry::PageGeometry { + bounds: PdfRect::new(20.0, 40.0, 600.0, 760.0), + rotation: 90, + }; + assert_eq!( + highlight.rects[0], + geometry.rect(PdfRect::new(50.0, 690.0, 150.0, 700.0), false) + ); + } + + #[test] + fn saving_an_owned_mark_preserves_foreign_marks_even_without_contents() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("annotated.pdf"); + fixture(&path, false); + let before = + load_foreign_annotations_checked(&path, &FileRevision::read(&path).unwrap()).unwrap(); + let own = EditorAnnotation { + page_index: 0, + rect: PdfRect::new(100.0, 300.0, 150.0, 315.0), + kind: AnnotationKind::Marker { + color_rgb: [1.0, 1.0, 0.0], + opacity: 0.4, + style: MarkerStyle::Highlight, + }, + }; + save_with_annotations(&path, &path, &[own]).unwrap(); + let after = + load_foreign_annotations_checked(&path, &FileRevision::read(&path).unwrap()).unwrap(); + assert_eq!(before, after); + } + + #[test] + fn missing_or_incomplete_quads_do_not_claim_precise_text_geometry() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("annotated.pdf"); + fixture(&path, false); + let mut document = Document::load(&path).unwrap(); + let page = document.get_pages()[&1]; + let no_quads = document.add_object(dictionary! { "Type" => "Annot", "Subtype" => "Highlight", + "Rect" => vec![50.into(), 500.into(), 150.into(), 520.into()], "Contents" => Object::string_literal("No quads") }); + let partial = document.add_object(dictionary! { "Type" => "Annot", "Subtype" => "Underline", + "Rect" => vec![50.into(), 450.into(), 150.into(), 470.into()], "Contents" => Object::string_literal("Incomplete quads"), + "QuadPoints" => vec![50.into(), 470.into(), 150.into(), 470.into(), 50.into(), 450.into(), 150.into(), 450.into(), 123.into()] }); + append_annotation(&mut document, page, no_quads).unwrap(); + append_annotation(&mut document, page, partial).unwrap(); + document.save(&path).unwrap(); + let marks = + load_foreign_annotations_checked(&path, &FileRevision::read(&path).unwrap()).unwrap(); + for text in ["No quads", "Incomplete quads"] { + let mark = marks.iter().find(|mark| mark.text == text).unwrap(); + assert!(!mark.precise_text_geometry); + assert_eq!(mark.rects.len(), 1); + } + } +} diff --git a/src/pdf_backend/navigation.rs b/src/pdf_backend/navigation.rs new file mode 100644 index 0000000..c68902c --- /dev/null +++ b/src/pdf_backend/navigation.rs @@ -0,0 +1,617 @@ +//! Bounded parsing of PDF link destinations and the document outline. +use super::*; +use crate::model::{PdfNavigation, PdfOutlineItem}; +use std::cell::RefCell; +use std::collections::HashSet; + +const MAX_TREE_NODES: usize = 10_000; +const MAX_DEPTH: usize = 32; +const MAX_LINKS_PER_PAGE: usize = 10_000; +const MAX_LINKS_PER_DOCUMENT: usize = 100_000; + +pub(super) fn load_pdf_navigation(source: &Path, page_count: usize) -> Result { + let document = Document::load(source) + .with_context(|| format!("failed to load source PDF {}", source.display()))?; + navigation_for_document(&document, page_count) +} + +fn navigation_for_document(document: &Document, page_count: usize) -> Result { + let pages = document.get_pages(); + let destinations = named_destinations(document); + let page_ids: HashMap = pages + .iter() + .filter_map(|(number, id)| { + let index = number.saturating_sub(1) as usize; + (index < page_count).then_some((*id, index)) + }) + .collect(); + let page_objects = page_ids.iter().map(|(id, page)| (*page, *id)).collect(); + let resolver = DestinationResolver { + document, + pages: page_ids, + page_objects, + names: destinations, + geometries: RefCell::new(HashMap::new()), + }; + let mut links = vec![Vec::new(); page_count]; + let mut examined_annotations = 0usize; + for (number, page_id) in pages { + let page_index = number.saturating_sub(1) as usize; + if page_index >= page_count { + continue; + } + let page = document.get_object(page_id)?.as_dict()?; + let Some(annots) = page + .get(b"Annots") + .ok() + .and_then(|object| dereference(document, object)) + else { + continue; + }; + let items: &[Object] = match annots { + Object::Array(items) => items, + item => std::slice::from_ref(item), + }; + for annotation in items.iter().take(MAX_LINKS_PER_PAGE) { + if examined_annotations >= MAX_LINKS_PER_DOCUMENT { + break; + } + examined_annotations += 1; + if let Some(link) = parse_link(&resolver, annotation) { + links[page_index].push(link); + } + } + let geometry = pdf_page_geometry(document, page_id)?; + for link in &mut links[page_index] { + link.rect = geometry.rect(link.rect, false); + } + } + Ok(PdfNavigation { + links, + outline: parse_outline(&resolver), + }) +} + +fn dereference<'a>(document: &'a Document, object: &'a Object) -> Option<&'a Object> { + let mut current = object; + let mut seen = HashSet::new(); + for _ in 0..MAX_DEPTH { + match current { + Object::Reference(id) if seen.insert(*id) => current = document.get_object(*id).ok()?, + Object::Reference(_) => return None, + _ => return Some(current), + } + } + None +} + +fn name_bytes(object: &Object) -> Option<&[u8]> { + match object { + Object::Name(bytes) | Object::String(bytes, _) => Some(bytes), + _ => None, + } +} + +fn named_destinations(document: &Document) -> HashMap, &Object> { + let mut found = HashMap::new(); + let Ok(catalog) = document.catalog() else { + return found; + }; + if let Some(legacy) = catalog + .get(b"Dests") + .ok() + .and_then(|object| dereference(document, object)) + .and_then(|object| object.as_dict().ok()) + { + for (name, destination) in legacy.iter().take(MAX_TREE_NODES) { + if name.len() <= 4096 { + found.insert(name.clone(), destination); + } + } + } + let Some(root) = catalog + .get(b"Names") + .ok() + .and_then(|object| dereference(document, object)) + .and_then(|object| object.as_dict().ok()) + .and_then(|names| names.get(b"Dests").ok()) + else { + return found; + }; + let mut stack = vec![(root, 0usize)]; + let mut visited = HashSet::new(); + let mut count = 0usize; + let mut examined_names = 0usize; + while let Some((node, depth)) = stack.pop() { + if count >= MAX_TREE_NODES { + break; + } + if depth > MAX_DEPTH { + continue; + } + count += 1; + if let Object::Reference(id) = node + && !visited.insert(*id) + { + continue; + } + let Some(dict) = dereference(document, node).and_then(|object| object.as_dict().ok()) + else { + continue; + }; + if let Some(items) = dict + .get(b"Names") + .ok() + .and_then(|object| dereference(document, object)) + .and_then(|object| object.as_array().ok()) + { + for pair in items.chunks_exact(2).take(MAX_TREE_NODES) { + if examined_names >= MAX_TREE_NODES { + return found; + } + examined_names += 1; + if let Some(name) = name_bytes(&pair[0]).filter(|name| name.len() <= 4096) { + found.entry(name.to_vec()).or_insert(&pair[1]); + } + } + } + if let Some(children) = dict + .get(b"Kids") + .ok() + .and_then(|object| dereference(document, object)) + .and_then(|object| object.as_array().ok()) + { + let room = MAX_TREE_NODES.saturating_sub(count.saturating_add(stack.len())); + stack.extend( + children + .iter() + .take(room) + .rev() + .map(|child| (child, depth + 1)), + ); + } + } + found +} + +#[derive(Clone, Copy, Debug, PartialEq)] +struct ResolvedDestination { + page: usize, + fraction: Option, +} + +struct DestinationResolver<'a> { + document: &'a Document, + pages: HashMap, + page_objects: HashMap, + names: HashMap, &'a Object>, + geometries: RefCell>>, +} + +impl DestinationResolver<'_> { + fn geometry(&self, page: usize) -> Option { + if let Some(cached) = self.geometries.borrow().get(&page).copied() { + return cached; + } + let geometry = self + .page_objects + .get(&page) + .and_then(|id| pdf_page_geometry(self.document, *id).ok()); + self.geometries.borrow_mut().insert(page, geometry); + geometry + } + + fn explicit_fraction(&self, page: usize, items: &[Object]) -> Option { + let kind = items + .get(1) + .and_then(|object| dereference(self.document, object)) + .and_then(pdf_object_text)? + .to_ascii_uppercase(); + let geometry = self.geometry(page)?; + let rotation = geometry.rotation.rem_euclid(360); + let number = |index: usize| { + items + .get(index) + .and_then(|object| dereference(self.document, object)) + .and_then(pdf_number) + .filter(|n| n.is_finite()) + }; + let display_top = match kind.as_str() { + "XYZ" => { + let x = if matches!(rotation, 90 | 270) { + number(2)? + } else { + number(2).unwrap_or(geometry.bounds.left) + }; + let y = if matches!(rotation, 0 | 180) { + number(3)? + } else { + number(3).unwrap_or(geometry.bounds.top) + }; + geometry.to_display((x, y)).1 + } + "FITH" | "FITBH" if matches!(rotation, 0 | 180) => { + geometry.to_display((geometry.bounds.left, number(2)?)).1 + } + "FITV" | "FITBV" if matches!(rotation, 90 | 270) => { + geometry.to_display((number(2)?, geometry.bounds.top)).1 + } + "FITR" => { + geometry + .rect( + PdfRect::new(number(2)?, number(3)?, number(4)?, number(5)?), + false, + ) + .top + } + _ => return None, + }; + let height = if matches!(rotation, 90 | 270) { + geometry.bounds.width() + } else { + geometry.bounds.height() + }; + (height.is_finite() && height > 0.0 && display_top.is_finite()) + .then(|| (1.0 - display_top / height).clamp(0.0, 1.0)) + } + + fn resolve(&self, object: &Object, depth: usize) -> Option { + if depth >= MAX_DEPTH { + return None; + } + match object { + Object::Reference(id) => self + .pages + .get(id) + .copied() + .map(|page| ResolvedDestination { + page, + fraction: None, + }) + .or_else(|| { + self.document + .get_object(*id) + .ok() + .and_then(|next| self.resolve(next, depth + 1)) + }), + Object::Array(items) => { + let base = self.resolve(items.first()?, depth + 1)?; + Some(ResolvedDestination { + page: base.page, + fraction: self.explicit_fraction(base.page, items).or(base.fraction), + }) + } + Object::Dictionary(dict) => dict + .get(b"D") + .ok() + .and_then(|dest| self.resolve(dest, depth + 1)), + Object::Name(_) | Object::String(_, _) => name_bytes(object) + .and_then(|name| self.names.get(name).copied()) + .and_then(|dest| self.resolve(dest, depth + 1)), + Object::Integer(index) if *index >= 0 => usize::try_from(*index) + .ok() + .filter(|index| self.page_objects.contains_key(index)) + .map(|page| ResolvedDestination { + page, + fraction: None, + }), + _ => None, + } + } + + fn from_dictionary(&self, dict: &Dictionary) -> Option { + if let Some(destination) = dict + .get(b"Dest") + .ok() + .and_then(|dest| self.resolve(dest, 0)) + { + return Some(destination); + } + let action = dict + .get(b"A") + .ok() + .and_then(|object| dereference(self.document, object))? + .as_dict() + .ok()?; + if !action + .get(b"S") + .ok() + .and_then(pdf_object_text) + .is_some_and(|kind| kind.eq_ignore_ascii_case("GoTo")) + { + return None; + } + action.get(b"D").ok().and_then(|dest| self.resolve(dest, 0)) + } +} + +fn parse_link(resolver: &DestinationResolver<'_>, annotation: &Object) -> Option { + let dict = annotation_dict(resolver.document, annotation)?; + if !dict + .get(b"Subtype") + .ok() + .and_then(pdf_object_text) + .is_some_and(|kind| kind.eq_ignore_ascii_case("Link")) + { + return None; + } + let rect = dict.get(b"Rect").ok().and_then(pdf_rect_from_object)?; + if rect.width() <= 0.0 || rect.height() <= 0.0 { + return None; + } + if let Some(destination) = resolver.from_dictionary(dict) { + return Some(PageLink { + rect, + url: String::new(), + destination_page: Some(destination.page), + destination_fraction: destination.fraction, + }); + } + let uri = dict + .get(b"A") + .ok() + .and_then(|action| pdf_uri_from_action(resolver.document, action))?; + Some(PageLink { + rect, + url: normalize_web_url(&uri)?, + destination_page: None, + destination_fraction: None, + }) +} + +fn parse_outline(resolver: &DestinationResolver<'_>) -> Vec { + let document = resolver.document; + let Some(first) = document + .catalog() + .ok() + .and_then(|catalog| catalog.get(b"Outlines").ok()) + .and_then(|outline| dereference(document, outline)) + .and_then(|outline| outline.as_dict().ok()) + .and_then(|outline| outline.get(b"First").ok()) + else { + return Vec::new(); + }; + let mut stack = vec![(first, 0usize)]; + let mut visited = HashSet::new(); + let mut result = Vec::new(); + let mut count = 0usize; + while let Some((node, depth)) = stack.pop() { + if depth > MAX_DEPTH || count >= MAX_TREE_NODES { + continue; + } + count += 1; + if let Object::Reference(id) = node + && !visited.insert(*id) + { + continue; + } + let Some(dict) = dereference(document, node).and_then(|object| object.as_dict().ok()) + else { + continue; + }; + if let Ok(next) = dict.get(b"Next") { + stack.push((next, depth)); + } + if let Ok(child) = dict.get(b"First") { + stack.push((child, depth + 1)); + } + let Some(title) = dict.get(b"Title").ok().and_then(pdf_object_text) else { + continue; + }; + let title = title.trim().chars().take(1024).collect::(); + if !title.is_empty() { + let destination = resolver.from_dictionary(dict); + result.push(PdfOutlineItem { + title, + page_index: destination.map(|destination| destination.page), + page_fraction: destination.and_then(|destination| destination.fraction), + level: depth, + }); + } + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + + fn document() -> (Document, ObjectId, ObjectId, ObjectId) { + let mut doc = Document::with_version("1.5"); + let pages = doc.new_object_id(); + let first = doc.add_object(dictionary! { "Type" => "Page", "Parent" => pages, + "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()] }); + let second = doc.add_object(dictionary! { "Type" => "Page", "Parent" => pages, + "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()] }); + doc.objects.insert( + pages, + Object::Dictionary(dictionary! { "Type" => "Pages", "Count" => 2, + "Kids" => vec![Object::Reference(first), Object::Reference(second)] }), + ); + let root = doc.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages }); + doc.trailer.set("Root", root); + (doc, root, first, second) + } + + fn destination(page: ObjectId) -> Object { + Object::Array(vec![Object::Reference(page), Object::Name(b"Fit".to_vec())]) + } + + fn link(dest: Object) -> Object { + Object::Dictionary(dictionary! { "Type" => "Annot", "Subtype" => "Link", + "Rect" => vec![10.into(), 20.into(), 100.into(), 40.into()], "Dest" => dest }) + } + + #[test] + fn parses_direct_and_named_links_and_nested_outline() { + let (mut doc, root, first, second) = document(); + let names = doc.add_object( + dictionary! { "Names" => vec![Object::string_literal("chapter"), destination(second)] }, + ); + let top = doc.new_object_id(); + let child = doc.new_object_id(); + doc.objects.insert(top, Object::Dictionary(dictionary! { + "Title" => Object::string_literal("Chapter"), "Dest" => Object::Name(b"chapter".to_vec()), + "First" => child, "Next" => top, + })); + doc.objects.insert(child, Object::Dictionary(dictionary! { + "Title" => Object::string_literal("Subsection"), "A" => dictionary! { "S" => "GoTo", "D" => destination(first) }, + })); + let outline = doc.add_object(dictionary! { "First" => top }); + let catalog = doc.get_object_mut(root).unwrap().as_dict_mut().unwrap(); + catalog.set("Names", dictionary! { "Dests" => names }); + catalog.set("Outlines", outline); + doc.get_object_mut(first).unwrap().as_dict_mut().unwrap().set("Annots", vec![ + link(destination(second)), link(Object::string_literal("chapter")), + Object::Dictionary(dictionary! { "Type" => "Annot", "Subtype" => "Link", + "Rect" => vec![10.into(), 50.into(), 100.into(), 70.into()], + "A" => dictionary! { "S" => "URI", "URI" => Object::string_literal("https://example.com") } }), + ]); + let nav = navigation_for_document(&doc, 2).unwrap(); + assert_eq!(nav.links[0].len(), 3); + assert_eq!(nav.links[0][0].destination_page, Some(1)); + assert_eq!(nav.links[0][1].destination_page, Some(1)); + assert_eq!(nav.links[0][2].url, "https://example.com"); + assert_eq!( + nav.outline + .iter() + .map(|item| (&*item.title, item.page_index, item.level)) + .collect::>(), + vec![("Chapter", Some(1), 0), ("Subsection", Some(0), 1)] + ); + } + + #[test] + fn cyclic_and_invalid_destinations_are_ignored() { + let (mut doc, _root, first, _second) = document(); + let cycle = doc.new_object_id(); + doc.objects.insert(cycle, Object::Reference(cycle)); + doc.get_object_mut(first).unwrap().as_dict_mut().unwrap().set("Annots", vec![ + link(Object::Reference(cycle)), link(Object::Integer(99)), + Object::Dictionary(dictionary! { "Subtype" => "Link", "Rect" => vec![1.into(), 1.into(), 3.into(), 3.into()], + "A" => dictionary! { "S" => "URI", "URI" => Object::string_literal("javascript:alert(1)") } }), + ]); + let nav = navigation_for_document(&doc, 2).unwrap(); + assert!(nav.links[0].is_empty()); + } + + #[test] + fn legacy_names_and_name_tree_children_are_resolved_without_external_actions() { + let (mut doc, root, first, second) = document(); + let leaf = doc.add_object(dictionary! { "Names" => vec![Object::string_literal("from-tree"), destination(second)] }); + let tree = doc.new_object_id(); + doc.objects.insert( + tree, + Object::Dictionary( + dictionary! { "Kids" => vec![Object::Reference(leaf), Object::Reference(tree)] }, + ), + ); + let catalog = doc.get_object_mut(root).unwrap().as_dict_mut().unwrap(); + catalog.set("Dests", dictionary! { "legacy" => destination(first) }); + catalog.set("Names", dictionary! { "Dests" => tree }); + doc.get_object_mut(first).unwrap().as_dict_mut().unwrap().set("Annots", vec![ + link(Object::Name(b"legacy".to_vec())), + link(Object::string_literal("from-tree")), + Object::Dictionary(dictionary! { "Subtype" => "Link", "Rect" => vec![1.into(), 1.into(), 3.into(), 3.into()], + "A" => dictionary! { "S" => "GoToR", "F" => Object::string_literal("other.pdf"), "D" => destination(second) } }), + ]); + let nav = navigation_for_document(&doc, 2).unwrap(); + assert_eq!( + nav.links[0] + .iter() + .map(|link| link.destination_page) + .collect::>(), + vec![Some(0), Some(1)] + ); + } + + #[test] + fn resolves_positions_on_cropped_and_rotated_pages() { + for (rotation, xyz, fit_rect) in [ + (0, 0.25, 0.25), + (90, 1.0 / 6.0, 1.0 / 6.0), + (180, 0.75, 0.25), + (270, 5.0 / 6.0, 0.5), + ] { + let (mut doc, _root, first, second) = document(); + let target = doc.get_object_mut(second).unwrap().as_dict_mut().unwrap(); + target.set("MediaBox", vec![0.into(), 0.into(), 700.into(), 900.into()]); + target.set( + "CropBox", + vec![20.into(), 40.into(), 620.into(), 840.into()], + ); + target.set("Rotate", rotation); + let make = |kind: &[u8], args: Vec| { + let mut items = vec![Object::Reference(second), Object::Name(kind.to_vec())]; + items.extend(args); + link(Object::Array(items)) + }; + let half_fit = if rotation == 0 || rotation == 180 { + make(b"FitH", vec![640.into()]) + } else { + make(b"FitV", vec![120.into()]) + }; + let missing = if rotation == 0 || rotation == 180 { + make(b"XYZ", vec![120.into(), Object::Null, Object::Null]) + } else { + make(b"XYZ", vec![Object::Null, 640.into(), Object::Null]) + }; + doc.get_object_mut(first) + .unwrap() + .as_dict_mut() + .unwrap() + .set( + "Annots", + vec![ + make(b"XYZ", vec![120.into(), 640.into(), Object::Null]), + half_fit, + make( + b"FitR", + vec![120.into(), 240.into(), 320.into(), 640.into()], + ), + missing, + make(b"Fit", vec![]), + ], + ); + let nav = navigation_for_document(&doc, 2).unwrap(); + let fractions: Vec<_> = nav.links[0] + .iter() + .map(|link| link.destination_fraction) + .collect(); + assert_eq!(fractions.len(), 5); + for (actual, expected) in fractions[..3].iter().zip([xyz, xyz, fit_rect]) { + assert!( + (actual.unwrap() - expected).abs() < 0.00001, + "rotation {rotation}: {actual:?} vs {expected}" + ); + } + assert_eq!(fractions[3..], [None, None]); + } + } + + #[test] + fn named_destination_preserves_position_for_links_and_outline() { + let (mut doc, root, first, second) = document(); + let named = Object::Array(vec![ + Object::Reference(second), + Object::Name(b"FitH".to_vec()), + 600.into(), + ]); + let names = doc + .add_object(dictionary! { "Names" => vec![Object::string_literal("position"), named] }); + let item = doc.add_object(dictionary! { "Title" => Object::string_literal("Position"), "Dest" => Object::Name(b"position".to_vec()) }); + let outline = doc.add_object(dictionary! { "First" => item }); + let catalog = doc.get_object_mut(root).unwrap().as_dict_mut().unwrap(); + catalog.set("Names", dictionary! { "Dests" => names }); + catalog.set("Outlines", outline); + doc.get_object_mut(first) + .unwrap() + .as_dict_mut() + .unwrap() + .set("Annots", vec![link(Object::string_literal("position"))]); + let nav = navigation_for_document(&doc, 2).unwrap(); + let expected = 1.0 - 600.0 / 792.0; + assert!((nav.links[0][0].destination_fraction.unwrap() - expected).abs() < 0.00001); + assert!((nav.outline[0].page_fraction.unwrap() - expected).abs() < 0.00001); + } +} diff --git a/src/performance_cache.rs b/src/performance_cache.rs index e7dec13..3397378 100644 --- a/src/performance_cache.rs +++ b/src/performance_cache.rs @@ -1,5 +1,7 @@ use std::fs; +use std::io::Read; use std::path::{Path, PathBuf}; +use std::sync::OnceLock; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::SystemTime; @@ -8,11 +10,40 @@ use image::{ExtendedColorType, ImageEncoder}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; -use crate::model::{PageInfo, PageLink, PageTextChar, RenderedPage}; +use crate::model::{PageInfo, PageLink, PageTextChar, PdfNavigation, RenderedPage}; const CACHE_VERSION: u32 = 5; +const NAVIGATION_CACHE_VERSION: u32 = 2; +// Do not reuse old entries written for every page: this cache is now reserved +// for renders that are slow enough to benefit from it. +const RENDER_CACHE_VERSION: u32 = 6; static TEMP_FILE_NONCE: AtomicU64 = AtomicU64::new(1); static RENDER_WRITES: AtomicU64 = AtomicU64::new(0); +static RENDER_WRITE_QUEUE: OnceLock>> = + OnceLock::new(); + +struct PendingRenderWrite { + path: PathBuf, + root: Option, + page: RenderedPage, +} + +fn render_write_queue() -> Option<&'static crossbeam_channel::Sender> { + RENDER_WRITE_QUEUE + .get_or_init(|| { + let (sender, receiver) = crossbeam_channel::bounded::(2); + std::thread::Builder::new() + .name("pdf-render-cache".into()) + .spawn(move || { + while let Ok(write) = receiver.recv() { + write_rendered_page(&write.path, write.root.as_deref(), &write.page); + } + }) + .ok()?; + Some(sender) + }) + .as_ref() +} #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CachedDocumentMetadata { @@ -84,15 +115,19 @@ impl PerformanceCache { source: &Path, page_index: usize, ) -> Option> { - let bytes = fs::read(self.page_path(source, "chars", page_index, "json")?).ok()?; - serde_json::from_slice(&bytes).ok() + let path = self.page_path(source, "chars-v1", page_index, "bin")?; + let file = fs::File::open(path).ok()?; + let mut bytes = Vec::new(); + file.take((crate::page_text_codec::MAX_ENCODED_BYTES + 1) as u64) + .read_to_end(&mut bytes).ok()?; + crate::page_text_codec::decode(&bytes).ok() } pub fn save_page_text_chars(&self, source: &Path, page_index: usize, chars: &[PageTextChar]) { - let Some(path) = self.page_path(source, "chars", page_index, "json") else { + let Some(path) = self.page_path(source, "chars-v1", page_index, "bin") else { return; }; - if let Ok(bytes) = serde_json::to_vec(chars) { + if let Ok(bytes) = crate::page_text_codec::encode(chars) { let _ = write_atomic(&path, &bytes); } } @@ -107,6 +142,26 @@ impl PerformanceCache { serde_json::from_slice(&fs::read(path).ok()?).ok() } + pub fn load_document_navigation(&self, source: &Path) -> Option { + let key = document_key(source)?; + let path = self.root.as_ref()?.join("navigation").join(format!( + "{key}-nav{NAVIGATION_CACHE_VERSION}-v{CACHE_VERSION}.json" + )); + serde_json::from_slice(&fs::read(path).ok()?).ok() + } + + pub fn save_document_navigation(&self, source: &Path, navigation: &PdfNavigation) { + let (Some(key), Some(root)) = (document_key(source), &self.root) else { + return; + }; + let path = root.join("navigation").join(format!( + "{key}-nav{NAVIGATION_CACHE_VERSION}-v{CACHE_VERSION}.json" + )); + if let Ok(bytes) = serde_json::to_vec(navigation) { + let _ = write_atomic(&path, &bytes); + } + } + pub fn save_document_links(&self, source: &Path, links: &[Vec]) { let (Some(key), Some(root)) = (document_key(source), &self.root) else { return; @@ -119,6 +174,7 @@ impl PerformanceCache { } } + #[cfg(test)] pub fn load_rendered_page( &self, source: &Path, @@ -126,7 +182,14 @@ impl PerformanceCache { render_scale: f32, fast: bool, ) -> Option { - let bytes = fs::read(self.render_path(source, page_index, render_scale, fast)?).ok()?; + Self::load_rendered_page_at( + &self.render_path(source, page_index, render_scale, fast)?, + page_index, + ) + } + + pub(crate) fn load_rendered_page_at(path: &Path, page_index: usize) -> Option { + let bytes = fs::read(path).ok()?; let image = image::load_from_memory_with_format(&bytes, image::ImageFormat::Png) .ok()? .to_rgba8(); @@ -139,6 +202,7 @@ impl PerformanceCache { }) } + #[cfg(test)] pub fn save_rendered_page( &self, source: &Path, @@ -149,26 +213,26 @@ impl PerformanceCache { let Some(path) = self.render_path(source, rendered.page_index, render_scale, fast) else { return; }; - let mut png = Vec::new(); - let result = - PngEncoder::new_with_quality(&mut png, CompressionType::Fast, FilterType::Adaptive) - .write_image( - &rendered.rgba, - rendered.width as u32, - rendered.height as u32, - ExtendedColorType::Rgba8, - ); - if result.is_ok() { - let _ = write_atomic(&path, &png); - if let Some(parent) = path.parent() { - prune_render_directory(parent, 384 * 1024 * 1024); - } - if RENDER_WRITES.fetch_add(1, Ordering::Relaxed) % 16 == 0 - && let Some(root) = &self.root - { - prune_render_tree(&root.join("render"), 1024 * 1024 * 1024); - } + write_rendered_page(&path, self.root.as_deref(), rendered); + } + + /// Queue a slow render without blocking on PNG encoding or directory scans. + /// The destination was derived before rendering, so a later source edit + /// cannot put old pixels under a new revision's key. + pub(crate) fn queue_rendered_page_at(&self, path: PathBuf, rendered: &RenderedPage) -> bool { + let Some(sender) = render_write_queue() else { + return false; + }; + if sender.is_full() { + return false; } + sender + .try_send(PendingRenderWrite { + path, + root: self.root.clone(), + page: rendered.clone(), + }) + .is_ok() } fn page_path( @@ -187,7 +251,7 @@ impl PerformanceCache { ) } - fn render_path( + pub(crate) fn render_path( &self, source: &Path, page_index: usize, @@ -205,13 +269,35 @@ impl PerformanceCache { .join("render") .join(document_key(source)?) .join(format!( - "{}-p{page_index:05}-s{scale_key:05}-v{CACHE_VERSION}.png", + "{}-p{page_index:05}-s{scale_key:05}-v{RENDER_CACHE_VERSION}.png", if fast { "fast" } else { "crisp" } )), ) } } +fn write_rendered_page(path: &Path, root: Option<&Path>, rendered: &RenderedPage) { + let mut png = Vec::new(); + let result = + PngEncoder::new_with_quality(&mut png, CompressionType::Fast, FilterType::Adaptive) + .write_image( + &rendered.rgba, + rendered.width as u32, + rendered.height as u32, + ExtendedColorType::Rgba8, + ); + if result.is_ok() && write_atomic(path, &png).is_ok() { + if let Some(parent) = path.parent() { + prune_render_directory(parent, 384 * 1024 * 1024); + } + if RENDER_WRITES.fetch_add(1, Ordering::Relaxed) % 16 == 0 + && let Some(root) = root + { + prune_render_tree(&root.join("render"), 1024 * 1024 * 1024); + } + } +} + fn cache_root() -> Option { #[cfg(feature = "devtools")] if std::env::var("LAWPDF_DISABLE_PERFORMANCE_CACHE") @@ -226,6 +312,11 @@ fn cache_root() -> Option { return None; } + #[cfg(all(feature = "devtools", not(test)))] + if let Some(path) = std::env::var_os("LAWPDF_BENCH_DATA_DIR") { + return Some(PathBuf::from(path).join("performance-cache")); + } + #[cfg(windows)] if !cfg!(test) { if let Some(local) = std::env::var_os("LOCALAPPDATA") { @@ -241,9 +332,10 @@ fn cache_root() -> Option { fn document_key(source: &Path) -> Option { // A print session owns these snapshots; do not retain unsaved edits in the reader cache. - if source.ancestors().any(|path| path.file_name().is_some_and(|name| { - name.to_string_lossy().starts_with("lawpdf-print-") - })) { + if source.ancestors().any(|path| { + path.file_name() + .is_some_and(|name| name.to_string_lossy().starts_with("lawpdf-print-")) + }) { return None; } let metadata = fs::metadata(source).ok()?; @@ -401,6 +493,8 @@ mod tests { let links = vec![vec![PageLink { rect: PdfRect::new(5.0, 6.0, 7.0, 8.0), url: "https://example.com".to_owned(), + destination_page: None, + destination_fraction: None, }]]; cache.save_document_links(&source, &links); @@ -412,6 +506,61 @@ mod tests { fs::remove_dir_all(root).unwrap(); } + #[test] + fn corrupt_character_cache_is_a_miss_and_legacy_json_is_not_reused() { + let (root, source, cache) = test_cache("characters-version"); + let old = cache.page_path(&source, "chars", 0, "json").unwrap(); + fs::create_dir_all(old.parent().unwrap()).unwrap(); + fs::write(&old, b"[]").unwrap(); + assert!(cache.load_page_text_chars(&source, 0).is_none()); + + let new = cache.page_path(&source, "chars-v1", 0, "bin").unwrap(); + fs::create_dir_all(new.parent().unwrap()).unwrap(); + fs::write(&new, b"LPC1\x01\0\0\0").unwrap(); + assert!(cache.load_page_text_chars(&source, 0).is_none()); + + let chars = [PageTextChar { + ch: '§', rect: None, font_size: Some(9.5), bold: false, italic: true, + }]; + cache.save_page_text_chars(&source, 0, &chars); + let decoded = cache.load_page_text_chars(&source, 0).unwrap(); + assert_eq!((decoded[0].ch, decoded[0].font_size, decoded[0].italic), ('§', Some(9.5), true)); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn navigation_cache_rejects_page_only_revision_and_round_trips_positions() { + let (root, source, cache) = test_cache("navigation-positions"); + let dir = root.join("cache").join("navigation"); + fs::create_dir_all(&dir).unwrap(); + let old_path = dir.join(format!( + "{}-v{CACHE_VERSION}.json", + document_key(&source).unwrap() + )); + let old_json = br#"{"links":[[{"rect":{"left":0.0,"bottom":0.0,"right":1.0,"top":1.0},"url":"","destination_page":1}]],"outline":[]}"#; + let old: PdfNavigation = serde_json::from_slice(old_json).unwrap(); + assert_eq!(old.links[0][0].destination_fraction, None); + fs::write(&old_path, old_json).unwrap(); + assert!(cache.load_document_navigation(&source).is_none()); + let mut updated = old; + updated.links[0][0].destination_fraction = Some(0.25); + cache.save_document_navigation(&source, &updated); + assert_eq!( + cache.load_document_navigation(&source).unwrap().links[0][0].destination_fraction, + Some(0.25) + ); + fs::remove_file(&old_path).unwrap(); + let new_path = dir.join(format!( + "{}-nav{NAVIGATION_CACHE_VERSION}-v{CACHE_VERSION}.json", + document_key(&source).unwrap() + )); + fs::remove_file(new_path).unwrap(); + fs::remove_dir(dir).unwrap(); + fs::remove_dir(root.join("cache")).unwrap(); + fs::remove_file(source).unwrap(); + fs::remove_dir(root).unwrap(); + } + #[test] fn rendered_page_round_trips_through_png_cache() { let (root, source, cache) = test_cache("render"); @@ -427,4 +576,38 @@ mod tests { assert_eq!(restored.rgba, page.rgba); fs::remove_dir_all(root).unwrap(); } + + #[test] + fn background_render_write_keeps_its_original_source_revision() { + let (root, source, cache) = test_cache("async-render"); + let page = RenderedPage { + page_index: 0, + width: 2, + height: 1, + rgba: vec![255, 0, 0, 255, 0, 0, 255, 255], + }; + let old_path = cache.render_path(&source, 0, 2.0, false).unwrap(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + while !cache.queue_rendered_page_at(old_path.clone(), &page) { + assert!( + std::time::Instant::now() < deadline, + "render cache writer remained unavailable" + ); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + fs::write(&source, b"a different and longer PDF revision").unwrap(); + let new_path = cache.render_path(&source, 0, 2.0, false).unwrap(); + assert_ne!(old_path, new_path); + while !old_path.exists() && std::time::Instant::now() < deadline { + std::thread::sleep(std::time::Duration::from_millis(10)); + } + assert_eq!( + PerformanceCache::load_rendered_page_at(&old_path, 0) + .unwrap() + .rgba, + page.rgba + ); + assert!(PerformanceCache::load_rendered_page_at(&new_path, 0).is_none()); + fs::remove_dir_all(root).unwrap(); + } } diff --git a/src/render_worker.rs b/src/render_worker.rs index 2079d3b..43ee0ce 100644 --- a/src/render_worker.rs +++ b/src/render_worker.rs @@ -1,21 +1,36 @@ -use std::collections::{HashSet, VecDeque}; +use std::collections::{HashMap, HashSet, VecDeque}; use std::path::PathBuf; use std::sync::{Arc, Mutex}; use std::thread; +use std::time::{Duration, Instant}; use crossbeam_channel::{Receiver, Sender, TrySendError, bounded, unbounded}; use eframe::egui::Context; use crate::document_store::{DocumentStore, FileRevision}; -use crate::model::{DocumentComment, EditorAnnotation, LoadedDocument, PageTextChar, RenderedPage}; +use crate::model::{ + DocumentComment, EditorAnnotation, ForeignAnnotation, LoadedDocument, PageTextChar, + RenderedPage, +}; use crate::pdf_backend::{PdfEngine, RenderQuality, SaveReport, rotate_pdf_page_checked}; +const OPENING_PREVIEW_MIN_BYTES: u64 = 32 * 1024 * 1024; + #[derive(Debug)] pub struct OpenedDocument { pub document: LoadedDocument, pub revision: FileRevision, pub annotations: Vec, pub recovery_pending: bool, + pub timings: OpenTimings, +} + +#[derive(Debug, Clone, Copy)] +pub struct OpenTimings { + pub snapshot: Duration, + pub metadata: Duration, + pub annotations: Duration, + pub revision_and_recovery: Duration, } /// UI producers never wait for capacity. Saves and explicit document commands @@ -25,16 +40,36 @@ pub struct RenderSender { urgent: Sender, background: Sender, live_documents: Arc>>>, + viewports: Arc>>, +} + +#[derive(Debug, Clone, Copy)] +struct RenderViewport { + first: usize, + last: usize, + focus: usize, } impl RenderSender { /// Cancel speculative work when its tab closes. Writes are deliberately /// excluded: a queued save must still report its actual outcome. pub fn set_live_documents(&self, epochs: impl IntoIterator) { + let epochs: HashSet<_> = epochs.into_iter().collect(); *self .live_documents .lock() - .unwrap_or_else(|error| error.into_inner()) = Some(epochs.into_iter().collect()); + .unwrap_or_else(|error| error.into_inner()) = Some(epochs.clone()); + self.viewports + .lock() + .unwrap_or_else(|error| error.into_inner()) + .retain(|epoch, _| epochs.contains(epoch)); + } + + pub fn set_viewport(&self, epoch: u64, first: usize, last: usize, focus: usize) { + self.viewports + .lock() + .unwrap_or_else(|error| error.into_inner()) + .insert(epoch, RenderViewport { first, last, focus }); } pub fn send(&self, request: RenderRequest) -> Result<(), TrySendError> { @@ -53,6 +88,7 @@ impl From> for RenderSender { urgent: sender.clone(), background: sender, live_documents: Arc::new(Mutex::new(None)), + viewports: Arc::new(Mutex::new(HashMap::new())), } } } @@ -102,6 +138,7 @@ pub enum RenderRequest { OpenDocument { path: PathBuf, optimize_large_documents: bool, + preview_page: Option, }, LoadDocument { path: PathBuf, @@ -117,6 +154,11 @@ pub enum RenderRequest { path: PathBuf, expected_revision: FileRevision, }, + ForeignAnnotations { + document_epoch: u64, + path: PathBuf, + expected_revision: FileRevision, + }, TextCharsAsync { document_epoch: u64, path: PathBuf, @@ -175,8 +217,44 @@ pub enum RenderRequest { }, } +/// A validated raster converted into egui's pixel format off the UI thread. +#[derive(Debug)] +pub struct PreparedPage { + pub page_index: usize, + pub width: usize, + pub height: usize, + pub image: egui::ColorImage, +} + +impl TryFrom for PreparedPage { + type Error = String; + fn try_from(page: RenderedPage) -> Result { + if page.width == 0 + || page.height == 0 + || page + .width + .checked_mul(page.height) + .and_then(|pixels| pixels.checked_mul(4)) + != Some(page.rgba.len()) + { + return Err("The PDF worker returned an invalid page image.".to_owned()); + } + let image = egui::ColorImage::from_rgba_unmultiplied([page.width, page.height], &page.rgba); + Ok(Self { + page_index: page.page_index, + width: page.width, + height: page.height, + image, + }) + } +} + #[derive(Debug)] pub enum RenderEvent { + WorkerStopped, + PageSkipped { + key: PageRenderKey, + }, SourcesPrepared { paths: Vec, converted: usize, @@ -187,6 +265,10 @@ pub enum RenderEvent { path: PathBuf, result: Result, }, + OpeningPreview { + path: PathBuf, + page: PreparedPage, + }, DocumentEnriched { document_epoch: u64, path: PathBuf, @@ -198,17 +280,23 @@ pub enum RenderEvent { revision: FileRevision, result: Result, String>, }, + ForeignAnnotations { + document_epoch: u64, + path: PathBuf, + revision: FileRevision, + result: Result, String>, + }, Page { key: PageRenderKey, path: PathBuf, _zoom: f32, render_scale: f32, - result: Result, + result: Result, }, Thumbnail { key: ThumbnailRenderKey, path: PathBuf, - result: Result, + result: Result, }, TextChars { document_epoch: u64, @@ -257,6 +345,19 @@ pub fn save_annotations( .map_err(|error| format!("PDF worker stopped before confirming the save: {error}"))? } +fn supervise_render_worker( + work: impl FnOnce(), + event_tx: Sender, + repaint: Option, +) { + if std::panic::catch_unwind(std::panic::AssertUnwindSafe(work)).is_err() { + let _ = event_tx.send(RenderEvent::WorkerStopped); + if let Some(ctx) = repaint { + ctx.request_repaint(); + } + } +} + pub fn spawn_render_worker( repaint_context: Option, ) -> (RenderSender, Receiver) { @@ -265,300 +366,462 @@ pub fn spawn_render_worker( let (event_tx, event_rx) = bounded(8); let live_documents = Arc::new(Mutex::new(None::>)); let worker_documents = live_documents.clone(); + let viewports = Arc::new(Mutex::new(HashMap::new())); + let worker_viewports = viewports.clone(); + let panic_sender = event_tx.clone(); + let panic_context = repaint_context.clone(); thread::spawn(move || { - let mut engine = None; - let mut backlog = VecDeque::new(); - loop { - let Some(request) = next_worker_request(&urgent_rx, &request_rx, &mut backlog) else { - break; - }; - let epoch = cancellable_epoch(&request); - let cancelled = || { - epoch.is_some_and(|epoch| { - worker_documents - .lock() - .unwrap_or_else(|error| error.into_inner()) - .as_ref() - .is_some_and(|live| !live.contains(&epoch)) - }) - }; - if cancelled() { - continue; - } - if engine.is_none() { - match PdfEngine::new() { - Ok(ready) => engine = Some(ready), - Err(error) => { - let _ = event_tx.send(error_event(request, error.to_string())); - if let Some(ctx) = &repaint_context { + supervise_render_worker( + || { + let mut engine = None; + let mut backlog = VecDeque::new(); + loop { + let Some(request) = next_worker_request( + &urgent_rx, + &request_rx, + &mut backlog, + &worker_viewports, + ) else { + break; + }; + let epoch = cancellable_epoch(&request); + let cancelled = || { + epoch.is_some_and(|epoch| { + worker_documents + .lock() + .unwrap_or_else(|error| error.into_inner()) + .as_ref() + .is_some_and(|live| !live.contains(&epoch)) + }) + }; + if cancelled() { + continue; + } + if let RenderRequest::Page { key, .. } = &request + && worker_viewports + .lock() + .unwrap_or_else(|error| error.into_inner()) + .get(&key.document_epoch) + .is_some_and(|viewport| { + key.page_index < viewport.first || key.page_index > viewport.last + }) + { + if event_tx + .send(RenderEvent::PageSkipped { key: *key }) + .is_ok() + && let Some(ctx) = &repaint_context + { ctx.request_repaint(); } continue; } - } - } - let engine = engine.as_ref().expect("initialized PDF worker"); - let event = match request { - RenderRequest::PrepareSources { - paths, - defer_background, - } => { - let (paths, converted, errors) = crate::app::prepare_open_paths(paths); - RenderEvent::SourcesPrepared { - paths, - converted, - errors, - defer_background, + if engine.is_none() { + match PdfEngine::new() { + Ok(ready) => engine = Some(ready), + Err(error) => { + let _ = event_tx.send(error_event(request, error.to_string())); + if let Some(ctx) = &repaint_context { + ctx.request_repaint(); + } + continue; + } + } } - } - RenderRequest::OpenDocument { - path, - optimize_large_documents, - } => { - engine.close_document(&path); - let result = (|| { - let revision = DocumentStore::new()?.capture(&path)?; - let document = engine - .load_document_adaptive(&path, optimize_large_documents) - .map_err(|error| format!("{error:#}"))?; - let annotations = crate::pdf_backend::load_lawpdf_annotations(&path) - .map_err(|error| { - format!("Could not read PDF annotations: {error:#}") - })?; - revision.require_current(&path)?; - let recovery_pending = DocumentStore::new()?.pending_for(&path)?.is_some(); - Ok(OpenedDocument { - document, - revision, + let engine = engine.as_ref().expect("initialized PDF worker"); + let event = match request { + RenderRequest::PrepareSources { + paths, + defer_background, + } => { + let (paths, converted, errors) = crate::app::prepare_open_paths(paths); + RenderEvent::SourcesPrepared { + paths, + converted, + errors, + defer_background, + } + } + RenderRequest::OpenDocument { + path, + optimize_large_documents, + preview_page, + } => { + engine.close_document(&path); + if let Some(preview_page) = preview_page + && optimize_large_documents + && std::fs::metadata(&path).is_ok_and(|metadata| { + metadata.is_file() + && metadata.len() >= OPENING_PREVIEW_MIN_BYTES + }) + { + let preview_path = path.clone(); + let sender = event_tx.clone(); + let context = repaint_context.clone(); + // This preview is never editable: the normal open waits for + // snapshot and revision verification before creating a tab. + let _ = thread::Builder::new() + .name("lawpdf-opening-preview".to_owned()) + .spawn(move || { + let result = (|| { + let engine = PdfEngine::new()?; + engine + .render_opening_preview(&preview_path, preview_page) + .or_else(|error| { + if preview_page == 0 { + Err(error) + } else { + engine.render_opening_preview( + &preview_path, + 0, + ) + } + }) + })(); + if let Ok(page) = result + && let Ok(page) = PreparedPage::try_from(page) + && sender + .send(RenderEvent::OpeningPreview { + path: preview_path, + page, + }) + .is_ok() + && let Some(ctx) = context + { + ctx.request_repaint(); + } + }); + } + let result = (|| { + let started = Instant::now(); + let revision = DocumentStore::new()?.capture(&path)?; + let snapshot = started.elapsed(); + let started = Instant::now(); + let document = engine + .load_document_adaptive(&path, optimize_large_documents) + .map_err(|error| format!("{error:#}"))?; + let metadata = started.elapsed(); + let started = Instant::now(); + let annotations = crate::pdf_backend::load_lawpdf_annotations( + &path, + ) + .map_err(|error| { + format!("Could not read PDF annotations: {error:#}") + })?; + let annotation_time = started.elapsed(); + let started = Instant::now(); + revision.require_current(&path)?; + let recovery_pending = + DocumentStore::new()?.pending_for(&path)?.is_some(); + let revision_and_recovery = started.elapsed(); + crate::diagnostics::document_open_phases( + snapshot, + metadata, + annotation_time, + revision_and_recovery, + ); + Ok(OpenedDocument { + document, + revision, + annotations, + recovery_pending, + timings: OpenTimings { + snapshot, + metadata, + annotations: annotation_time, + revision_and_recovery, + }, + }) + })(); + RenderEvent::DocumentOpened { path, result } + } + RenderRequest::LoadDocument { + path, + optimize_large_documents, + reply, + } => { + let _ = reply.send( + engine + .load_document_adaptive(&path, optimize_large_documents) + .map_err(|error| error.to_string()), + ); + continue; + } + RenderRequest::EnrichDocument { + document_epoch, + path, + } => RenderEvent::DocumentEnriched { + document_epoch, + path: path.clone(), + result: engine + .load_document(&path) + .map_err(|error| error.to_string()), + }, + RenderRequest::DocumentComments { + document_epoch, + path, + expected_revision, + } => { + // Comment extraction is opt-in and does not occupy the + // native rendering thread while a long PDF is parsed. + let sender = event_tx.clone(); + let context = repaint_context.clone(); + let source = path.clone(); + let revision = expected_revision.clone(); + let spawned = thread::Builder::new() + .name("lawpdf-comments".to_owned()) + .spawn(move || { + let result = + crate::pdf_backend::load_document_comments_checked( + &source, &revision, + ) + .map_err(|error| format!("{error:#}")); + let _ = sender.send(RenderEvent::DocumentComments { + document_epoch, + path: source, + revision, + result, + }); + if let Some(context) = context { + context.request_repaint(); + } + }); + match spawned { + Ok(_) => continue, + Err(error) => RenderEvent::DocumentComments { + document_epoch, + path, + revision: expected_revision, + result: Err(format!( + "Could not start PDF comment loading: {error}" + )), + }, + } + } + RenderRequest::ForeignAnnotations { + document_epoch, + path, + expected_revision, + } => { + let sender = event_tx.clone(); + let context = repaint_context.clone(); + let source = path.clone(); + let revision = expected_revision.clone(); + let spawned = thread::Builder::new() + .name("lawpdf-foreign-annotations".to_owned()) + .spawn(move || { + let result = std::panic::catch_unwind(|| { + crate::pdf_backend::load_foreign_annotations_checked( + &source, &revision, + ) + }) + .map_err(|_| { + "The annotation reader stopped unexpectedly.".to_owned() + }) + .and_then(|result| { + result.map_err(|error| format!("{error:#}")) + }); + let _ = sender.send(RenderEvent::ForeignAnnotations { + document_epoch, + path: source, + revision, + result, + }); + if let Some(context) = context { + context.request_repaint(); + } + }); + match spawned { + Ok(_) => continue, + Err(error) => RenderEvent::ForeignAnnotations { + document_epoch, + path, + revision: expected_revision, + result: Err(format!( + "Could not start annotation loading: {error}" + )), + }, + } + } + RenderRequest::TextCharsAsync { + document_epoch, + path, + page_index, + } => RenderEvent::TextChars { + document_epoch, + path: path.clone(), + page_index, + result: engine + .load_page_text_chars(&path, page_index) + .map_err(|error| error.to_string()), + }, + RenderRequest::TextPageAsync { + document_epoch, + path, + page_index, + } => RenderEvent::TextPage { + document_epoch, + path: path.clone(), + page_index, + result: engine + .load_page_text(&path, page_index) + .map_err(|error| error.to_string()), + }, + RenderRequest::Page { + key, + path, + zoom, + render_scale, + fast, + } => RenderEvent::Page { + key, + path: path.clone(), + _zoom: zoom, + render_scale, + result: engine + .render_page_interactive( + &path, + key.page_index, + render_scale, + if fast { + RenderQuality::Fast + } else { + RenderQuality::Crisp + }, + key.editor_annotations, + { + let viewports = worker_viewports + .lock() + .unwrap_or_else(|error| error.into_inner()); + viewports + .get(&key.document_epoch) + .is_none_or(|viewport| viewport.focus == key.page_index) + }, + ) + .map_err(|error| error.to_string()) + .and_then(PreparedPage::try_from), + }, + RenderRequest::PageImmediate { + path, + page_index, + render_scale, + fast, + reply, + } => { + let _ = reply.send( + engine + .render_page_interactive( + &path, + page_index, + render_scale, + if fast { + RenderQuality::Fast + } else { + RenderQuality::Crisp + }, + true, + true, + ) + .map_err(|error| error.to_string()), + ); + continue; + } + RenderRequest::Thumbnail { + key, + path, + render_scale, + } => RenderEvent::Thumbnail { + key, + path: path.clone(), + result: engine + .render_page_internal( + &path, + key.page_index, + render_scale, + RenderQuality::Fast, + false, + ) + .map_err(|error| error.to_string()) + .and_then(PreparedPage::try_from), + }, + RenderRequest::ExportPagePng { + path, + page_index, + destination, + scale, + reply, + } => { + let _ = reply.send( + engine + .export_page_png(&path, page_index, &destination, scale) + .map_err(|error| error.to_string()), + ); + continue; + } + RenderRequest::SaveAnnotations { + source, + destination, annotations, - recovery_pending, - }) - })(); - RenderEvent::DocumentOpened { path, result } - } - RenderRequest::LoadDocument { - path, - optimize_large_documents, - reply, - } => { - let _ = reply.send( - engine - .load_document_adaptive(&path, optimize_large_documents) - .map_err(|error| error.to_string()), - ); - continue; - } - RenderRequest::EnrichDocument { - document_epoch, - path, - } => RenderEvent::DocumentEnriched { - document_epoch, - path: path.clone(), - result: engine - .load_document(&path) - .map_err(|error| error.to_string()), - }, - RenderRequest::DocumentComments { - document_epoch, - path, - expected_revision, - } => { - // Comment extraction is opt-in and does not occupy the - // native rendering thread while a long PDF is parsed. - let sender = event_tx.clone(); - let context = repaint_context.clone(); - let source = path.clone(); - let revision = expected_revision.clone(); - let spawned = thread::Builder::new() - .name("lawpdf-comments".to_owned()) - .spawn(move || { - let result = crate::pdf_backend::load_document_comments_checked( - &source, &revision, + reply, + } => { + engine.close_document(&source); + engine.close_document(&destination); + let result = crate::pdf_backend::save_with_annotations( + &source, + &destination, + &annotations, ) .map_err(|error| format!("{error:#}")); - let _ = sender.send(RenderEvent::DocumentComments { + let _ = reply.send(result); + continue; + } + RenderRequest::AutosaveAnnotations { + document_epoch, + path, + generation, + annotations, + expected_revision, + } => { + engine.close_document(&path); + let result = crate::pdf_backend::save_with_annotations_checked( + &path, + &path, + &annotations, + &expected_revision, + ) + .map_err(|error| format!("{error:#}")) + .map(|mut report| { + if let Some(revision) = &report.revision { + report.recovery_warning = DocumentStore::new() + .and_then(|store| { + store + .acknowledge_saved_file(&path, generation, revision) + }) + .err(); + } + report + }); + RenderEvent::AnnotationsSaved { document_epoch, - path: source, - revision, + path: path.clone(), + generation, result, - }); - if let Some(context) = context { - context.request_repaint(); } - }); - match spawned { - Ok(_) => continue, - Err(error) => RenderEvent::DocumentComments { + } + RenderRequest::RotatePage { document_epoch, path, - revision: expected_revision, - result: Err(format!("Could not start PDF comment loading: {error}")), - }, - } - } - RenderRequest::TextCharsAsync { - document_epoch, - path, - page_index, - } => RenderEvent::TextChars { - document_epoch, - path: path.clone(), - page_index, - result: engine - .load_page_text_chars(&path, page_index) - .map_err(|error| error.to_string()), - }, - RenderRequest::TextPageAsync { - document_epoch, - path, - page_index, - } => RenderEvent::TextPage { - document_epoch, - path: path.clone(), - page_index, - result: engine - .load_page_text(&path, page_index) - .map_err(|error| error.to_string()), - }, - RenderRequest::Page { - key, - path, - zoom, - render_scale, - fast, - } => RenderEvent::Page { - key, - path: path.clone(), - _zoom: zoom, - render_scale, - result: engine - .render_page_internal( - &path, - key.page_index, - render_scale, - if fast { - RenderQuality::Fast - } else { - RenderQuality::Crisp - }, - key.editor_annotations, - ) - .map_err(|error| error.to_string()), - }, - RenderRequest::PageImmediate { - path, - page_index, - render_scale, - fast, - reply, - } => { - let _ = reply.send( - engine - .render_page_with_quality( + page_index, + clockwise, + expected_revision, + } => { + engine.close_document(&path); + let result = rotate_pdf_page_checked( &path, page_index, - render_scale, - if fast { - RenderQuality::Fast - } else { - RenderQuality::Crisp - }, + clockwise, + &expected_revision, ) - .map_err(|error| error.to_string()), - ); - continue; - } - RenderRequest::Thumbnail { - key, - path, - render_scale, - } => RenderEvent::Thumbnail { - key, - path: path.clone(), - result: engine - .render_page_internal( - &path, - key.page_index, - render_scale, - RenderQuality::Fast, - false, - ) - .map_err(|error| error.to_string()), - }, - RenderRequest::ExportPagePng { - path, - page_index, - destination, - scale, - reply, - } => { - let _ = reply.send( - engine - .export_page_png(&path, page_index, &destination, scale) - .map_err(|error| error.to_string()), - ); - continue; - } - RenderRequest::SaveAnnotations { - source, - destination, - annotations, - reply, - } => { - engine.close_document(&source); - engine.close_document(&destination); - let result = crate::pdf_backend::save_with_annotations( - &source, - &destination, - &annotations, - ) - .map_err(|error| format!("{error:#}")); - let _ = reply.send(result); - continue; - } - RenderRequest::AutosaveAnnotations { - document_epoch, - path, - generation, - annotations, - expected_revision, - } => { - engine.close_document(&path); - let result = crate::pdf_backend::save_with_annotations_checked( - &path, - &path, - &annotations, - &expected_revision, - ) - .map_err(|error| format!("{error:#}")) - .map(|mut report| { - if let Some(revision) = &report.revision { - report.recovery_warning = DocumentStore::new() - .and_then(|store| { - store.acknowledge_saved_file(&path, generation, revision) - }) - .err(); - } - report - }); - RenderEvent::AnnotationsSaved { - document_epoch, - path: path.clone(), - generation, - result, - } - } - RenderRequest::RotatePage { - document_epoch, - path, - page_index, - clockwise, - expected_revision, - } => { - engine.close_document(&path); - let result = - rotate_pdf_page_checked(&path, page_index, clockwise, &expected_revision) .and_then(|(rotation, revision)| { let annotations = crate::pdf_backend::load_lawpdf_annotations(&path)?; @@ -567,24 +830,28 @@ pub fn spawn_render_worker( .map(|document| (document, rotation, revision, annotations)) }) .map_err(|error| error.to_string()); - RenderEvent::PageRotated { - document_epoch, - path: path.clone(), - page_index, - result, - } - } - }; + RenderEvent::PageRotated { + document_epoch, + path: path.clone(), + page_index, + result, + } + } + }; - if cancelled() { - continue; - } - if event_tx.send(event).is_ok() { - if let Some(ctx) = &repaint_context { - ctx.request_repaint(); + if cancelled() { + continue; + } + if event_tx.send(event).is_ok() { + if let Some(ctx) = &repaint_context { + ctx.request_repaint(); + } + } } - } - } + }, + panic_sender, + panic_context, + ) }); ( @@ -592,6 +859,7 @@ pub fn spawn_render_worker( urgent: urgent_tx, background: request_tx, live_documents, + viewports, }, event_rx, ) @@ -603,6 +871,7 @@ fn cancellable_epoch(request: &RenderRequest) -> Option { RenderRequest::Thumbnail { key, .. } => Some(key.document_epoch), RenderRequest::EnrichDocument { document_epoch, .. } | RenderRequest::DocumentComments { document_epoch, .. } + | RenderRequest::ForeignAnnotations { document_epoch, .. } | RenderRequest::TextCharsAsync { document_epoch, .. } | RenderRequest::TextPageAsync { document_epoch, .. } => Some(*document_epoch), _ => None, @@ -613,6 +882,7 @@ fn next_worker_request( urgent: &Receiver, background: &Receiver, backlog: &mut VecDeque, + viewports: &Mutex>, ) -> Option { if let Ok(request) = urgent.try_recv() { return Some(request); @@ -623,12 +893,14 @@ fn next_worker_request( recv(background) -> request => backlog.push_back(request.ok()?), } } - next_prioritized_request(background, backlog) + let viewports = viewports.lock().unwrap_or_else(|error| error.into_inner()); + next_prioritized_request(background, backlog, &viewports) } fn next_prioritized_request( request_rx: &Receiver, backlog: &mut VecDeque, + viewports: &HashMap, ) -> Option { if backlog.is_empty() { backlog.push_back(request_rx.recv().ok()?); @@ -641,7 +913,15 @@ fn next_prioritized_request( let best = backlog .iter() .enumerate() - .min_by_key(|(index, request)| (request_priority(request), *index)) + .min_by_key(|(index, request)| { + let distance = match request { + RenderRequest::Page { key, .. } => viewports + .get(&key.document_epoch) + .map_or(0, |viewport| key.page_index.abs_diff(viewport.focus)), + _ => 0, + }; + (request_priority(request), distance, *index) + }) .map(|(index, _)| index)?; backlog.remove(best) } @@ -670,6 +950,7 @@ fn request_priority(request: &RenderRequest) -> u8 { RenderRequest::Page { .. } => 1, RenderRequest::EnrichDocument { .. } => 2, RenderRequest::DocumentComments { .. } => 2, + RenderRequest::ForeignAnnotations { .. } => 2, RenderRequest::TextCharsAsync { .. } => 3, RenderRequest::Thumbnail { .. } => 4, RenderRequest::TextPageAsync { .. } => 5, @@ -758,6 +1039,16 @@ fn error_event(request: RenderRequest, message: String) -> RenderEvent { revision: expected_revision, result: Err(message), }, + RenderRequest::ForeignAnnotations { + document_epoch, + path, + expected_revision, + } => RenderEvent::ForeignAnnotations { + document_epoch, + path, + revision: expected_revision, + result: Err(message), + }, RenderRequest::TextCharsAsync { document_epoch, path, @@ -862,6 +1153,83 @@ mod tests { use lopdf::{Document, Object, dictionary}; + #[test] + fn worker_panic_notifies_and_wakes_the_ui() { + let ctx = Context::default(); + let wakes = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let counter = Arc::clone(&wakes); + ctx.set_request_repaint_callback(move |_| { + counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + }); + let (sender, receiver) = unbounded(); + supervise_render_worker(|| panic!("simulated worker failure"), sender, Some(ctx)); + assert!(matches!( + receiver.try_recv(), + Ok(RenderEvent::WorkerStopped) + )); + assert!(wakes.load(std::sync::atomic::Ordering::SeqCst) > 0); + } + + #[test] + fn prepared_page_preserves_alpha_and_rejects_invalid_dimensions() { + let rgba = vec![255, 100, 20, 255, 128, 64, 32, 128]; + let expected = egui::ColorImage::from_rgba_unmultiplied([2, 1], &rgba); + let page = PreparedPage::try_from(RenderedPage { + page_index: 4, + width: 2, + height: 1, + rgba, + }) + .unwrap(); + assert_eq!(page.page_index, 4); + assert_eq!(page.image.size, expected.size); + assert_eq!(page.image.pixels, expected.pixels); + for (width, height, rgba) in [(0, 1, vec![]), (2, 2, vec![0; 3]), (usize::MAX, 2, vec![])] { + assert!( + PreparedPage::try_from(RenderedPage { + page_index: 0, + width, + height, + rgba + }) + .is_err() + ); + } + } + + #[test] + fn render_worker_returns_prepared_pixels_for_a_real_pdf_without_changing_it() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("sample.pdf"); + write_blank_pdf(&path); + let before = fs::read(&path).unwrap(); + let (sender, events) = spawn_render_worker(None); + let key = PageRenderKey::new(7, 0, 1.0); + sender + .send(RenderRequest::Page { + key, + path: path.clone(), + zoom: 1.0, + render_scale: 1.0, + fast: false, + }) + .unwrap(); + match events.recv_timeout(Duration::from_secs(15)).unwrap() { + RenderEvent::Page { + key: actual, + result: Ok(page), + .. + } => { + assert_eq!(actual, key); + assert!(page.width > 0 && page.height > 0); + assert_eq!(page.image.size, [page.width, page.height]); + assert_eq!(page.image.pixels.len(), page.width * page.height); + } + other => panic!("unexpected render event: {other:?}"), + } + assert_eq!(fs::read(path).unwrap(), before); + } + fn write_blank_pdf(path: &Path) { let mut document = Document::with_version("1.5"); let catalog_id = document.new_object_id(); @@ -967,7 +1335,7 @@ mod tests { tx.send(page_request(2, 1.0)).unwrap(); let mut backlog = VecDeque::new(); - let next = next_prioritized_request(&rx, &mut backlog).unwrap(); + let next = next_prioritized_request(&rx, &mut backlog, &HashMap::new()).unwrap(); assert!(matches!( next, @@ -997,7 +1365,7 @@ mod tests { tx.send(page_request(3, 1.0)).unwrap(); let mut backlog = VecDeque::new(); - let next = next_prioritized_request(&rx, &mut backlog).unwrap(); + let next = next_prioritized_request(&rx, &mut backlog, &HashMap::new()).unwrap(); assert!(matches!( next, @@ -1015,6 +1383,49 @@ mod tests { )); } + #[test] + fn page_requests_are_ordered_by_the_current_viewport() { + let (tx, rx) = unbounded(); + tx.send(page_request(10, 1.0)).unwrap(); + tx.send(page_request(99, 1.0)).unwrap(); + tx.send(page_request(100, 1.0)).unwrap(); + let viewports = HashMap::from([( + 7, + RenderViewport { + first: 97, + last: 103, + focus: 100, + }, + )]); + let mut backlog = VecDeque::new(); + let next = next_prioritized_request(&rx, &mut backlog, &viewports).unwrap(); + assert!(matches!( + next, + RenderRequest::Page { + key: PageRenderKey { + page_index: 100, + .. + }, + .. + } + )); + } + + #[test] + fn obsolete_page_requests_are_reported_without_rendering() { + let (sender, events) = spawn_render_worker(None); + sender.set_viewport(7, 97, 103, 100); + sender.send(page_request(10, 1.0)).unwrap(); + assert!(matches!( + events + .recv_timeout(std::time::Duration::from_secs(5)) + .unwrap(), + RenderEvent::PageSkipped { + key: PageRenderKey { page_index: 10, .. } + } + )); + } + #[test] fn annotation_saves_replace_pdfs_after_pdfium_has_cached_both_paths() { use crate::model::{AnnotationKind, MarkerStyle, PdfRect}; diff --git a/src/review_masthead.rs b/src/review_masthead.rs index 092ceac..18978b5 100644 --- a/src/review_masthead.rs +++ b/src/review_masthead.rs @@ -20,6 +20,83 @@ pub struct MastheadIdentity { pub citation: String, } +/// Provisional citation fields read from the PDF. Readers should verify them, +/// especially journal abbreviations and the printed (rather than PDF) page. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct CitationDetails { + pub authors: String, + pub title: String, + pub journal: String, + pub volume: String, + pub first_page: String, + pub year: String, +} + +pub fn citation_details( + page_texts: &[&str], + review_title: Option<&str>, + review_blocks: &[LiquidBlock], +) -> CitationDetails { + let identity = masthead_identity(page_texts, review_title, review_blocks, ""); + let heads = RunningHeads::read(page_texts); + let opening = page_texts.first().map(|text| opening_page(text)); + let journal = heads + .journal + .or_else(|| opening.as_ref().and_then(|page| page.journal.clone())); + let volume = heads + .volume + .or_else(|| opening.as_ref().and_then(|page| page.volume)); + let first_page = heads + .first_page + .or_else(|| opening.as_ref().and_then(|page| page.folio)); + let year = heads + .year + .or_else(|| opening.as_ref().and_then(|page| page.year)); + CitationDetails { + authors: identity.authors, + title: identity.title, + journal: journal + .as_deref() + .map(title_case_journal) + .unwrap_or_default(), + volume: volume.map(|value| value.to_string()).unwrap_or_default(), + first_page: first_page + .map(|value| value.to_string()) + .unwrap_or_default(), + year: year.map(|value| value.to_string()).unwrap_or_default(), + } +} + +/// Find a printed page number at a page edge; never substitute the PDF index. +/// Conflicting candidates are left blank for the reader to check. +pub fn printed_folio(text: &str, year: Option) -> Option { + let lines = text + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .collect::>(); + let mut candidates = Vec::new(); + for line in lines.iter().take(3).chain(lines.iter().rev().take(3)) { + let candidate = if journal_volume_head(line).is_some() { + leading_number(line).map(|(value, _)| value) + } else if dated_title_head(line).is_some() { + line.split_whitespace() + .last() + .and_then(|word| word.parse::().ok()) + } else { + line.parse::() + .ok() + .filter(|value| year.is_some() || !(1800..=2100).contains(value)) + }; + if let Some(value) = candidate.filter(|value| *value > 0 && Some(*value) != year) { + if !candidates.contains(&value) { + candidates.push(value); + } + } + } + (candidates.len() == 1).then(|| candidates[0]) +} + /// How many opening pages to read. Running heads start on the second page. pub const MASTHEAD_PAGES_TO_READ: usize = 5; @@ -546,6 +623,42 @@ mod tests { "1060 HARVARD LAW REVIEW [Vol. 137:1058\r\nno apps or subscriptions required."; const HLR_ODD: &str = "2024] CONTRACT-WRAPPED PROPERTY 1061\r\ntext"; + #[test] + fn citation_fields_are_provisional_and_do_not_fall_back_to_a_filename() { + let details = citation_details(&[NYU_OPENING, NYU_EVEN, NYU_ODD], None, &[]); + assert_eq!(details.authors, "Yonathan Arbel & David A. Hoffman"); + assert_eq!(details.title, "GENERATIVE INTERPRETATION"); + assert_eq!(details.journal, "New York University Law Review"); + assert_eq!( + (&*details.volume, &*details.first_page, &*details.year), + ("99", "451", "2024") + ); + assert_eq!( + citation_details(&["An ordinary document"], None, &[]), + CitationDetails::default() + ); + } + + #[test] + fn printed_folio_requires_an_unambiguous_page_edge() { + assert_eq!(printed_folio(NYU_OPENING, Some(2024)), Some(451)); + assert_eq!(printed_folio(NYU_EVEN, Some(2024)), Some(452)); + assert_eq!(printed_folio(NYU_ODD, Some(2024)), Some(453)); + assert_eq!( + printed_folio("2024\nA date, not a known folio.", Some(2024)), + None + ); + assert_eq!( + printed_folio("2024\nA date, not a known folio.", None), + None + ); + assert_eq!(printed_folio("451\nText\n452", Some(2024)), None); + assert_eq!( + printed_folio("Nothing resembling a page number", None), + None + ); + } + #[test] fn reads_an_nyu_opening_and_running_heads() { let identity = masthead_identity(&[NYU_OPENING, NYU_EVEN, NYU_ODD], None, &[], "file"); diff --git a/src/settings.rs b/src/settings.rs index e756495..7092d83 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -1,5 +1,6 @@ use std::collections::BTreeMap; use std::path::{Path, PathBuf}; +use std::time::SystemTime; use serde::{Deserialize, Serialize}; @@ -8,6 +9,70 @@ use crate::liquid::FootnoteMode; pub const DEFAULT_PDF_ZOOM: f32 = 1.25; pub const MIN_PDF_ZOOM: f32 = 0.35; pub const MAX_PDF_ZOOM: f32 = 5.0; +pub const MAX_RECENT_DOCUMENTS: usize = 500; +pub const MAX_LAST_SESSION_DOCUMENTS: usize = 256; + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Appearance { + #[default] + System, + Light, + Dark, +} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ReadingView { + #[default] + Pdf, + Review, + LegacyReview, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RecentDocument { + pub path: String, + #[serde(default)] + pub title: String, + #[serde(default)] + pub authors: String, + #[serde(default)] + pub citation: String, + #[serde(default)] + pub page: usize, + #[serde(default)] + pub page_fraction: f32, + #[serde(default)] + pub view: ReadingView, + #[serde(default)] + pub review_block: Option, + #[serde(default)] + pub last_seen_unix: u64, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct WindowPlacement { + pub inner_size: [f32; 2], + #[serde(default)] + pub outer_position: Option<[f32; 2]>, + #[serde(default)] + pub maximized: bool, +} + +impl WindowPlacement { + fn is_valid(&self) -> bool { + self.inner_size[0].is_finite() + && (720.0..=7680.0).contains(&self.inner_size[0]) + && self.inner_size[1].is_finite() + && (520.0..=4320.0).contains(&self.inner_size[1]) + && self.outer_position.is_none_or(|position| { + position + .iter() + .all(|value| value.is_finite() && value.abs() <= 16384.0) + }) + } +} #[derive(Clone, Serialize, Deserialize)] pub struct AppSettings { @@ -37,6 +102,11 @@ pub struct AppSettings { pub elevenlabs_speech_voice: String, #[serde(default)] pub speech_include_notes: bool, + /// Offset for the installed system voice; cloud voices use their own defaults. + #[serde(default)] + pub system_speech_rate: i32, + #[serde(default = "default_true")] + pub speech_follow_along: bool, #[serde(skip)] pub credential_error: Option, #[serde(default = "default_retention_days")] @@ -46,10 +116,27 @@ pub struct AppSettings { /// Last zoom selected for each PDF, keyed by its normalized absolute path. #[serde(default)] pub pdf_zoom_by_document: BTreeMap, - /// When true, highlights appear instantly instead of animating the - /// "laying-down" ink stroke. Honors users who prefer reduced motion. + /// Local reading history, newest first; bounded and subject to retention. + #[serde(default)] + pub recent_documents: Vec, + /// Optional local session restore; explicit startup files take precedence. + #[serde(default)] + pub reopen_last_session: bool, #[serde(default)] + pub last_session_paths: Vec, + #[serde(default)] + pub last_session_saved_unix: u64, + #[serde(default)] + pub window_placement: Option, + /// Window appearance follows the operating system until the reader chooses otherwise. + #[serde(default)] + pub appearance: Appearance, + /// User preference for reduced motion, initialized from the system on first use. + #[serde(default = "system_reduce_motion")] pub reduce_motion: bool, + /// Optional display-only dimming for the original PDF pages. + #[serde(default)] + pub dim_original_pages: bool, /// Automatically use the cached, low-latency path when opening PDFs. #[serde(default = "default_true")] pub optimize_large_documents: bool, @@ -63,6 +150,9 @@ pub struct AppSettings { pub markdown_copy_include_tables: bool, #[serde(default)] pub markdown_copy_include_metadata: bool, + /// Show the Review Mode explanation until the reader dismisses it. + #[serde(default)] + pub review_intro_dismissed: bool, /// Suppress the one-time, non-modal macOS default-reader suggestion. #[serde(default)] pub macos_default_reader_prompt_dismissed: bool, @@ -94,17 +184,27 @@ impl Default for AppSettings { elevenlabs_speech_model: default_elevenlabs_speech_model(), elevenlabs_speech_voice: default_elevenlabs_speech_voice(), speech_include_notes: false, + system_speech_rate: 0, + speech_follow_along: true, credential_error: None, cache_retention_days: default_retention_days(), last_pdf_zoom: DEFAULT_PDF_ZOOM, pdf_zoom_by_document: BTreeMap::new(), - reduce_motion: false, + recent_documents: Vec::new(), + reopen_last_session: false, + last_session_paths: Vec::new(), + last_session_saved_unix: 0, + window_placement: None, + appearance: Appearance::System, + reduce_motion: system_reduce_motion(), + dim_original_pages: false, optimize_large_documents: true, liquid_mode2_use_pymupdf_blocks: false, liquid_mode2_use_pp_footnote_regions: false, markdown_copy_footnotes: FootnoteMode::Inline, markdown_copy_include_tables: true, markdown_copy_include_metadata: false, + review_intro_dismissed: false, macos_default_reader_prompt_dismissed: false, sidebar_in_pdf: true, sidebar_in_review: false, @@ -117,6 +217,174 @@ fn default_retention_days() -> u16 { 30 } +/// Read the operating-system preference only when no LawPDF choice exists. +#[cfg(target_os = "windows")] +fn system_reduce_motion() -> bool { + #[link(name = "user32")] + unsafe extern "system" { + fn SystemParametersInfoW( + action: u32, + parameter: u32, + value: *mut std::ffi::c_void, + flags: u32, + ) -> i32; + } + const SPI_GETCLIENTAREAANIMATION: u32 = 4162; + let mut animations_enabled: i32 = 1; + // The documented query writes a BOOL to the supplied pointer; no setting is changed. + let success = unsafe { + SystemParametersInfoW( + SPI_GETCLIENTAREAANIMATION, + 0, + (&mut animations_enabled as *mut i32).cast(), + 0, + ) + }; + success != 0 && animations_enabled == 0 +} + +#[cfg(target_os = "macos")] +fn system_reduce_motion() -> bool { + objc2_app_kit::NSWorkspace::sharedWorkspace().accessibilityDisplayShouldReduceMotion() +} + +/// Parse only the standardized portal value. Zero means no preference, not a +/// request to enable motion, so it may fall back to the desktop's setting. +#[cfg(any(target_os = "linux", test))] +fn portal_reduced_motion(output: &str) -> Option { + let compact: String = output.chars().filter(|ch| !ch.is_whitespace()).collect(); + let marker = "'reduced-motion':') { + return None; + } + match &tail[..digits] { + "1" => Some(true), + _ => None, + } +} + +#[cfg(any(target_os = "linux", test))] +fn gnome_reduced_motion(output: &str) -> Option { + match output.trim() { + "false" => Some(true), + "true" => Some(false), + _ => None, + } +} + +/// Bound optional desktop queries so an unavailable portal cannot hold up +/// startup indefinitely. The child is terminated and reaped on timeout. +#[cfg(any(target_os = "linux", test))] +fn bounded_desktop_query( + program: &str, + args: &[&str], + deadline: std::time::Instant, +) -> Option { + use std::io::Read; + use std::process::{Command, Stdio}; + use std::time::Duration; + + if std::time::Instant::now() >= deadline { + return None; + } + let mut child = Command::new(program) + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .ok()?; + let status = loop { + match child.try_wait() { + Ok(Some(status)) => break status, + Ok(None) if std::time::Instant::now() < deadline => { + std::thread::sleep(Duration::from_millis(10)); + } + _ => { + let _ = child.kill(); + let _ = child.wait(); + return None; + } + } + }; + if !status.success() { + return None; + } + let mut bytes = Vec::new(); + child + .stdout + .take()? + .take(4096) + .read_to_end(&mut bytes) + .ok()?; + String::from_utf8(bytes).ok() +} + +#[cfg(any(target_os = "linux", test))] +fn linux_reduce_motion_from_desktop() -> bool { + use std::sync::OnceLock; + use std::time::{Duration, Instant}; + static SYSTEM_PREFERENCE: OnceLock = OnceLock::new(); + *SYSTEM_PREFERENCE.get_or_init(|| { + let started = Instant::now(); + // Reserve time for GNOME's setting if the optional portal is slow. + let portal_deadline = started + Duration::from_millis(250); + let deadline = started + Duration::from_millis(500); + // ReadAll tolerates portal backends that do not implement this key. + let portal = bounded_desktop_query( + "gdbus", + &[ + "call", + "--session", + "--dest", + "org.freedesktop.portal.Desktop", + "--object-path", + "/org/freedesktop/portal/desktop", + "--method", + "org.freedesktop.portal.Settings.ReadAll", + "--timeout", + "1", + "['org.freedesktop.appearance']", + ], + portal_deadline, + ) + .and_then(|output| portal_reduced_motion(&output)); + if portal == Some(true) { + return true; + } + let is_gnome = std::env::var("XDG_CURRENT_DESKTOP") + .ok() + .is_some_and(|desktop| desktop.to_ascii_lowercase().contains("gnome")); + if is_gnome { + return bounded_desktop_query( + "gsettings", + &["get", "org.gnome.desktop.interface", "enable-animations"], + deadline, + ) + .and_then(|output| gnome_reduced_motion(&output)) + .unwrap_or(false); + } + false + }) +} + +#[cfg(target_os = "linux")] +fn system_reduce_motion() -> bool { + linux_reduce_motion_from_desktop() +} + +#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] +fn system_reduce_motion() -> bool { + false +} + impl std::fmt::Debug for AppSettings { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { formatter @@ -214,6 +482,10 @@ pub fn normalized_pdf_zoom(zoom: f32) -> f32 { pub fn pdf_document_key(path: &Path) -> String { let path = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); + path_key_without_io(&path) +} + +fn path_key_without_io(path: &Path) -> String { let key = path.to_string_lossy().replace('\\', "/"); if cfg!(windows) { key.to_lowercase() @@ -222,7 +494,117 @@ pub fn pdf_document_key(path: &Path) -> String { } } +fn bounded_session_paths(paths: impl IntoIterator) -> Vec { + let mut seen = std::collections::HashSet::new(); + paths + .into_iter() + .filter(|path| { + let parsed = Path::new(path); + !path.trim().is_empty() + && parsed.is_absolute() + && seen.insert(path_key_without_io(parsed)) + }) + .take(MAX_LAST_SESSION_DOCUMENTS) + .collect() +} + impl AppSettings { + pub fn remember_session_paths( + &mut self, + paths: impl IntoIterator, + now_unix: u64, + ) { + self.last_session_paths = bounded_session_paths(paths); + self.last_session_saved_unix = if self.last_session_paths.is_empty() { + 0 + } else { + now_unix + }; + } + + pub fn startup_paths(&self, requested: Vec) -> Vec { + if !requested.is_empty() || !self.reopen_last_session { + return requested; + } + self.last_session_paths.iter().map(PathBuf::from).collect() + } + + pub fn recent_document(&self, path: &Path) -> Option<&RecentDocument> { + let key = pdf_document_key(path); + self.recent_documents + .iter() + .find(|recent| path_key_without_io(Path::new(&recent.path)) == key) + } + + pub fn recent_document_for_opened_path(&self, path: &Path) -> Option<&RecentDocument> { + let key = path_key_without_io(path); + self.recent_documents + .iter() + .find(|recent| path_key_without_io(Path::new(&recent.path)) == key) + } + + /// Update a record without touching the filesystem; opened paths are + /// canonicalized when they enter the document-opening pipeline. + pub fn remember_recent_document(&mut self, mut recent: RecentDocument, touch: bool) -> bool { + if recent.path.trim().is_empty() { + return false; + } + recent.page_fraction = if recent.page_fraction.is_finite() { + recent.page_fraction.clamp(0.0, 1.0) + } else { + 0.0 + }; + let key = path_key_without_io(Path::new(&recent.path)); + let existing = self + .recent_documents + .iter() + .position(|item| path_key_without_io(Path::new(&item.path)) == key); + if let Some(index) = existing { + if !touch && self.recent_documents[index] == recent { + return false; + } + if touch { + self.recent_documents.remove(index); + self.recent_documents.insert(0, recent); + } else { + self.recent_documents[index] = recent; + } + } else { + self.recent_documents.insert(0, recent); + } + self.recent_documents.truncate(MAX_RECENT_DOCUMENTS); + true + } + + pub fn prune_recent_documents(&mut self, now_unix: u64) { + let age = u64::from(self.cache_retention_days.max(1)) * 86_400; + let mut seen = std::collections::HashSet::new(); + self.recent_documents.retain_mut(|recent| { + recent.page_fraction = if recent.page_fraction.is_finite() { + recent.page_fraction.clamp(0.0, 1.0) + } else { + 0.0 + }; + !recent.path.trim().is_empty() + && now_unix.saturating_sub(recent.last_seen_unix) <= age + && seen.insert(path_key_without_io(Path::new(&recent.path))) + }); + self.recent_documents.truncate(MAX_RECENT_DOCUMENTS); + if !self.reopen_last_session + || self.last_session_saved_unix == 0 + || now_unix.saturating_sub(self.last_session_saved_unix) > age + { + self.last_session_paths.clear(); + self.last_session_saved_unix = 0; + } else { + self.last_session_paths = + bounded_session_paths(std::mem::take(&mut self.last_session_paths)); + if self.last_session_paths.is_empty() { + self.last_session_saved_unix = 0; + } + } + } + pub fn zoom_for_document(&self, path: &Path) -> Option { self.pdf_zoom_by_document .get(&pdf_document_key(path)) @@ -280,6 +662,7 @@ pub(crate) fn load_settings_from(path: &Path) -> AppSettings { } }; settings.last_pdf_zoom = normalized_pdf_zoom(settings.last_pdf_zoom); + settings.system_speech_rate = settings.system_speech_rate.clamp(-5, 5); settings.pdf_zoom_by_document.retain(|key, zoom| { if key.trim().is_empty() { return false; @@ -287,9 +670,34 @@ pub(crate) fn load_settings_from(path: &Path) -> AppSettings { *zoom = normalized_pdf_zoom(*zoom); true }); + let now = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + settings.prune_recent_documents(now); + if settings + .window_placement + .as_ref() + .is_some_and(|placement| !placement.is_valid()) + { + settings.window_placement = None; + } settings } +/// Read only window geometry before creating the native window. This avoids +/// loading credentials or migrating any settings during viewport setup. +pub fn load_window_placement() -> Option { + #[derive(Deserialize)] + struct WindowOnly { + #[serde(default)] + window_placement: Option, + } + let bytes = std::fs::read(settings_path()?).ok()?; + let settings: WindowOnly = serde_json::from_slice(&bytes).ok()?; + settings.window_placement.filter(WindowPlacement::is_valid) +} + pub fn save_settings(settings: &AppSettings) -> Result<(), String> { ensure_settings_save_allowed(settings)?; let path = settings_path().ok_or_else(|| "Could not find settings directory.".to_owned())?; @@ -335,6 +743,10 @@ fn corrupt_settings_backup_path(path: &Path) -> PathBuf { } pub fn app_data_dir() -> Option { + #[cfg(all(feature = "devtools", not(test)))] + if let Some(path) = std::env::var_os("LAWPDF_BENCH_DATA_DIR") { + return Some(PathBuf::from(path)); + } #[cfg(feature = "devtools")] if crate::shutdown_smoke::running() { if let Some(path) = std::env::var_os("LAWPDF_SHUTDOWN_SMOKE_DATA_DIR") { @@ -382,8 +794,146 @@ fn settings_path() -> Option { #[cfg(test)] mod tests { use super::*; + + #[test] + fn legacy_appearance_follows_system_and_explicit_choices_round_trip() { + let legacy: AppSettings = serde_json::from_str("{}").unwrap(); + assert_eq!(legacy.appearance, Appearance::System); + for appearance in [Appearance::System, Appearance::Light, Appearance::Dark] { + let mut settings = AppSettings::default(); + settings.appearance = appearance; + let encoded = serde_json::to_vec(&settings).unwrap(); + let restored: AppSettings = serde_json::from_slice(&encoded).unwrap(); + assert_eq!(restored.appearance, appearance); + } + } use std::time::{SystemTime, UNIX_EPOCH}; + #[test] + fn session_restore_is_opt_in_bounded_and_obeys_history_retention() { + let mut settings: AppSettings = serde_json::from_str("{}").unwrap(); + assert!(!settings.reopen_last_session); + let base = std::env::temp_dir().join("lawpdf-session-test"); + let documents = (0..260) + .map(|index| { + base.join(format!("{index}.pdf")) + .to_string_lossy() + .into_owned() + }) + .collect::>(); + let mut candidates = vec![ + "relative.pdf".to_owned(), + documents[0].clone(), + documents[0].clone(), + ]; + candidates.extend(documents.clone()); + settings.remember_session_paths(candidates, 200_000); + assert_eq!( + settings.last_session_paths.len(), + MAX_LAST_SESSION_DOCUMENTS + ); + assert_eq!(settings.last_session_paths[0], documents[0]); + assert!(settings.startup_paths(Vec::new()).is_empty()); + settings.reopen_last_session = true; + let serialized = serde_json::to_vec(&settings).unwrap(); + let round_trip: AppSettings = serde_json::from_slice(&serialized).unwrap(); + assert!(round_trip.reopen_last_session); + assert_eq!(round_trip.last_session_paths, settings.last_session_paths); + assert_eq!(round_trip.last_session_saved_unix, 200_000); + assert_eq!( + settings.startup_paths(Vec::new()).len(), + MAX_LAST_SESSION_DOCUMENTS + ); + let explicit = vec![base.join("explicit.pdf")]; + assert_eq!(settings.startup_paths(explicit.clone()), explicit); + settings.cache_retention_days = 1; + settings.prune_recent_documents(200_000 + 86_400); + assert_eq!( + settings.last_session_paths.len(), + MAX_LAST_SESSION_DOCUMENTS + ); + settings.prune_recent_documents(200_000 + 86_401); + assert!(settings.last_session_paths.is_empty()); + assert_eq!(settings.last_session_saved_unix, 0); + } + + #[test] + fn initial_motion_preference_uses_system_but_saved_choice_wins() { + let initial: AppSettings = serde_json::from_str("{}").unwrap(); + assert_eq!(initial.reduce_motion, system_reduce_motion()); + for choice in [false, true] { + let saved: AppSettings = + serde_json::from_value(serde_json::json!({"reduce_motion": choice})).unwrap(); + assert_eq!(saved.reduce_motion, choice); + } + } + + #[test] + fn desktop_motion_parsers_accept_only_known_unambiguous_values() { + assert_eq!( + portal_reduced_motion( + "({'org.freedesktop.appearance': {'reduced-motion': }},)" + ), + Some(true) + ); + assert_eq!( + portal_reduced_motion("({'reduced-motion': },)"), + None + ); + assert_eq!( + portal_reduced_motion("({'reduced-motion': },)"), + None + ); + assert_eq!( + portal_reduced_motion("({'reduced-motion': },)"), + None + ); + assert_eq!(portal_reduced_motion("({'reduced-motion': <'1'>},)"), None); + assert_eq!(portal_reduced_motion("({'other': },)"), None); + assert_eq!( + portal_reduced_motion( + "({'reduced-motion': , 'reduced-motion': },)" + ), + None + ); + assert_eq!(gnome_reduced_motion("false\n"), Some(true)); + assert_eq!(gnome_reduced_motion("true\n"), Some(false)); + assert_eq!(gnome_reduced_motion("False"), None); + assert_eq!(gnome_reduced_motion(""), None); + } + + #[test] + fn desktop_query_returns_output_and_stops_a_hung_child() { + use std::time::{Duration, Instant}; + #[cfg(windows)] + let (program, immediate, slow): (&str, &[&str], &[&str]) = ( + "powershell.exe", + &[ + "-NoProfile", + "-NonInteractive", + "-Command", + "Write-Output ready", + ], + &[ + "-NoProfile", + "-NonInteractive", + "-Command", + "Start-Sleep -Seconds 4", + ], + ); + #[cfg(not(windows))] + let (program, immediate, slow): (&str, &[&str], &[&str]) = + ("sh", &["-c", "printf ready"], &["-c", "exec sleep 4"]); + let output = + bounded_desktop_query(program, immediate, Instant::now() + Duration::from_secs(3)); + assert_eq!(output.as_deref().map(str::trim), Some("ready")); + let started = Instant::now(); + assert!( + bounded_desktop_query(program, slow, started + Duration::from_millis(300)).is_none() + ); + assert!(started.elapsed() < Duration::from_secs(3)); + } + fn temp_settings_path(test_name: &str) -> PathBuf { let nonce = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -397,6 +947,64 @@ mod tests { .join("settings.json") } + fn recent(path: String, at: u64) -> RecentDocument { + RecentDocument { + path, + title: "Article".into(), + authors: "Author".into(), + citation: "Journal".into(), + page: 3, + page_fraction: 0.4, + view: ReadingView::Review, + review_block: Some(17), + last_seen_unix: at, + } + } + + #[test] + fn recent_history_is_bounded_reordered_and_expires_by_retention() { + let mut settings = AppSettings::default(); + for index in 0..503 { + assert!( + settings + .remember_recent_document(recent(format!("paper-{index}.pdf"), 200_000), true) + ); + } + assert_eq!(settings.recent_documents.len(), MAX_RECENT_DOCUMENTS); + assert_eq!(settings.recent_documents[0].path, "paper-502.pdf"); + let mut selected = settings.recent_documents[100].clone(); + selected.page_fraction = 2.0; + assert!(settings.remember_recent_document(selected, true)); + assert_eq!(settings.recent_documents[0].page_fraction, 1.0); + settings.cache_retention_days = 1; + settings.prune_recent_documents(200_000 + 86_401); + assert!(settings.recent_documents.is_empty()); + } + + #[test] + fn reading_history_round_trips_without_provider_credentials() { + let path = temp_settings_path("reading-history"); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + let mut settings = AppSettings::default(); + settings.remember_recent_document(recent("reading.pdf".into(), now), true); + save_settings_to(&settings, &path).unwrap(); + let restored = load_settings_from(&path); + let record = restored.recent_document(Path::new("reading.pdf")).unwrap(); + assert_eq!( + ( + record.page, + record.page_fraction, + record.view, + record.review_block + ), + (3, 0.4, ReadingView::Review, Some(17)) + ); + std::fs::remove_dir_all(path.parent().unwrap()).unwrap(); + } + #[test] fn corrupt_json_returns_defaults_and_creates_backup() { let path = temp_settings_path("corrupt"); @@ -421,6 +1029,12 @@ mod tests { assert_eq!(normalized_pdf_zoom(2.0), 2.0); } + #[test] + fn older_settings_do_not_dim_original_pages_by_default() { + let settings: AppSettings = serde_json::from_str("{}").unwrap(); + assert!(!settings.dim_original_pages); + } + #[test] fn unavailable_keyring_still_allows_local_preferences_without_secrets() { let path = temp_settings_path("unavailable-keyring-preferences"); @@ -481,12 +1095,14 @@ mod tests { ("c:/docs/second.pdf".to_owned(), 2.0), ]), reduce_motion: true, + dim_original_pages: true, optimize_large_documents: false, liquid_mode2_use_pymupdf_blocks: true, liquid_mode2_use_pp_footnote_regions: true, markdown_copy_footnotes: FootnoteMode::Endnotes, markdown_copy_include_tables: false, markdown_copy_include_metadata: true, + review_intro_dismissed: true, macos_default_reader_prompt_dismissed: true, ..AppSettings::default() }; @@ -508,7 +1124,9 @@ mod tests { assert!(!format!("{expected:?}").contains("router")); assert_eq!(actual.last_pdf_zoom, expected.last_pdf_zoom); assert_eq!(actual.pdf_zoom_by_document, expected.pdf_zoom_by_document); + assert!(actual.review_intro_dismissed); assert_eq!(actual.reduce_motion, expected.reduce_motion); + assert_eq!(actual.dim_original_pages, expected.dim_original_pages); assert_eq!( actual.optimize_large_documents, expected.optimize_large_documents diff --git a/src/tts.rs b/src/tts.rs index 162bf65..23ad5b6 100644 --- a/src/tts.rs +++ b/src/tts.rs @@ -40,8 +40,16 @@ pub struct ApiSpeechConfig { pub model: String, pub voice: String, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SpeechSegment { + pub block_index: Option, + pub text: String, +} pub enum SpeechRequest { - System(String), + System { + segments: Vec, + rate: i32, + }, Api { config: ApiSpeechConfig, text: String, @@ -55,6 +63,7 @@ pub struct SpeechEvent { pub enum SpeechEventKind { Progress { completed: usize, total: usize }, Playing, + BlockStarted(Option), Complete(Option), Failed(String), } @@ -84,6 +93,7 @@ pub fn spawn_speech_job( job_id: u64, request: SpeechRequest, tx: Sender, + repaint: egui::Context, ) -> SpeechCancellation { let cancel = Arc::new(AtomicBool::new(false)); let worker_cancel = Arc::clone(&cancel); @@ -92,10 +102,11 @@ pub fn spawn_speech_job( thread::spawn(move || { let send = |kind| { let _ = tx.send(SpeechEvent { job_id, kind }); + repaint.request_repaint(); }; let result = match request { - SpeechRequest::System(text) => { - run_system_speech(&text, &worker_cancel, &worker_process, &send) + SpeechRequest::System { segments, rate } => { + run_system_speech(&segments, rate, &worker_cancel, &worker_process, &send) } SpeechRequest::Api { config, @@ -181,10 +192,14 @@ fn quiet_command(command: &mut Command) { command.creation_flags(0x0800_0000); } } -fn run_child( +fn run_child(command: Command, cancel: &AtomicBool, process: &ActiveProcess) -> Result<(), String> { + run_child_started(command, cancel, process, || {}) +} +fn run_child_started( mut command: Command, cancel: &AtomicBool, process: &ActiveProcess, + started: impl FnOnce(), ) -> Result<(), String> { { // Cancellation cannot land between spawn and registration. Only this @@ -198,6 +213,8 @@ fn run_child( )?); } let _registered = RegisteredChild(process); + check_cancel(cancel)?; + started(); loop { check_cancel(cancel)?; let status = { @@ -220,51 +237,83 @@ fn run_child( } } fn run_system_speech( - text: &str, + segments: &[SpeechSegment], + rate: i32, cancel: &AtomicBool, process: &ActiveProcess, send: &impl Fn(SpeechEventKind), ) -> Result<(), String> { check_cancel(cancel)?; - if text.trim().is_empty() { + if segments + .iter() + .all(|segment| segment.text.trim().is_empty()) + { return Err("Nothing to read aloud.".to_owned()); } - let file = private_text(text)?; - #[cfg(target_os = "macos")] - let mut command = { - let mut c = Command::new("/usr/bin/say"); - c.arg("-f").arg(file.path()); - c - }; - #[cfg(target_os = "windows")] - let mut command = { - let executable = - std::env::current_exe().map_err(|_| "Could not locate the speech worker.")?; - let mut c = Command::new(executable); - c.arg("--speech-worker") - .env("LAWPDF_TTS_TEXT_PATH", file.path()); - c - }; - #[cfg(target_os = "linux")] - let mut command = { - // A private synthesizer can be stopped without affecting other apps. - // spd-say --cancel would cancel all Speech Dispatcher clients. - let program = find_program(&["espeak-ng", "espeak"]).ok_or("System speech needs espeak-ng or espeak. Install one, or choose ElevenLabs or OpenRouter in Settings.")?; - let mut c = Command::new(program); - c.arg("-f").arg(file.path()); - c - }; - #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] - return Err("System speech is unavailable. Choose ElevenLabs or OpenRouter.".to_owned()); - #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))] + if segments + .iter() + .try_fold(0usize, |sum, segment| sum.checked_add(segment.text.len())) + .is_none_or(|total| total > 16 * 1024 * 1024) + { + return Err("Select less text to read aloud.".to_owned()); + } + let rate = rate.clamp(-5, 5); + for segment in segments + .iter() + .filter(|segment| !segment.text.trim().is_empty()) { - quiet_command(&mut command); check_cancel(cancel)?; - send(SpeechEventKind::Playing); - run_child(command, cancel, process)?; - send(SpeechEventKind::Complete(None)); - Ok(()) + let file = private_text(&segment.text)?; + #[cfg(target_os = "macos")] + let mut command = { + let mut c = Command::new("/usr/bin/say"); + if rate != 0 { + c.arg("-r").arg(system_words_per_minute(rate).to_string()); + } + c.arg("-f").arg(file.path()); + c + }; + #[cfg(target_os = "windows")] + let mut command = { + let executable = + std::env::current_exe().map_err(|_| "Could not locate the speech worker.")?; + let mut c = Command::new(executable); + c.arg("--speech-worker") + .env("LAWPDF_TTS_TEXT_PATH", file.path()) + .env("LAWPDF_TTS_RATE", rate.to_string()); + c + }; + #[cfg(target_os = "linux")] + let mut command = { + // A private synthesizer can be stopped without affecting other apps. + // spd-say --cancel would cancel all Speech Dispatcher clients. + let program = find_program(&["espeak-ng", "espeak"]).ok_or("System speech needs espeak-ng or espeak. Install one, or choose ElevenLabs or OpenRouter in Settings.")?; + let mut c = Command::new(program); + if rate != 0 { + c.arg("-s").arg(system_words_per_minute(rate).to_string()); + } + c.arg("-f").arg(file.path()); + c + }; + #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] + return Err("System speech is unavailable. Choose ElevenLabs or OpenRouter.".to_owned()); + #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))] + { + quiet_command(&mut command); + check_cancel(cancel)?; + run_child_started(command, cancel, process, || { + send(SpeechEventKind::Playing); + send(SpeechEventKind::BlockStarted(segment.block_index)); + })?; + } } + send(SpeechEventKind::Complete(None)); + Ok(()) +} + +#[cfg(any(target_os = "macos", target_os = "linux", test))] +fn system_words_per_minute(rate: i32) -> u32 { + (180.0_f32 * 2.0_f32.powf(rate.clamp(-5, 5) as f32 / 5.0)).round() as u32 } #[cfg(target_os = "linux")] fn find_program(names: &[&str]) -> Option { @@ -643,6 +692,11 @@ pub fn run_windows_speech_worker() -> Result<(), String> { use windows::core::PCWSTR; let path = std::env::var_os("LAWPDF_TTS_TEXT_PATH").ok_or("No speech text was provided.")?; + let rate = std::env::var("LAWPDF_TTS_RATE") + .ok() + .and_then(|value| value.parse::().ok()) + .unwrap_or(0) + .clamp(-5, 5); let bytes = crate::document_store::read_limited(&PathBuf::from(path), 16 * 1024 * 1024) .map_err(|error| format!("Could not read speech text: {error}"))?; let text = String::from_utf8(bytes).map_err(|_| "Speech text is not valid UTF-8.")?; @@ -658,6 +712,9 @@ pub fn run_windows_speech_worker() -> Result<(), String> { .map_err(|error| format!("Could not initialize Windows speech: {error}"))?; let result = (|| -> windows::core::Result<()> { let voice: ISpVoice = unsafe { CoCreateInstance(&SpVoice, None, CLSCTX_INPROC_SERVER)? }; + unsafe { + voice.SetRate(rate)?; + } // Speak synchronously; the parent can stop speech by terminating this // child. Force plain text so PDF content cannot act as SAPI markup. unsafe { voice.Speak(PCWSTR(wide.as_ptr()), SPF_IS_NOT_XML.0 as u32, None) } @@ -712,6 +769,75 @@ mod tests { use super::*; use std::net::TcpListener; + #[test] + fn system_speed_stays_bounded_and_increases_monotonically() { + assert_eq!(system_words_per_minute(-100), 90); + assert_eq!(system_words_per_minute(0), 180); + assert_eq!(system_words_per_minute(100), 360); + assert!(system_words_per_minute(-1) < system_words_per_minute(0)); + assert!(system_words_per_minute(1) > system_words_per_minute(0)); + } + + #[test] + fn speech_worker_wakes_ui_when_it_reports_failure() { + use std::sync::atomic::AtomicUsize; + let ctx = egui::Context::default(); + let wakes = Arc::new(AtomicUsize::new(0)); + let count = Arc::clone(&wakes); + ctx.set_request_repaint_callback(move |_| { + count.fetch_add(1, Ordering::SeqCst); + }); + let (tx, rx) = crossbeam_channel::unbounded(); + let _job = spawn_speech_job( + 42, + SpeechRequest::System { + segments: Vec::new(), + rate: 0, + }, + tx, + ctx, + ); + let event = rx.recv_timeout(Duration::from_secs(3)).unwrap(); + assert_eq!(event.job_id, 42); + assert!(matches!(event.kind, SpeechEventKind::Failed(_))); + // The channel send precedes the wake callback in the worker. + for _ in 0..100 { + if wakes.load(Ordering::SeqCst) > 0 { + break; + } + std::thread::sleep(Duration::from_millis(2)); + } + assert!(wakes.load(Ordering::SeqCst) > 0); + } + + #[cfg(any(unix, windows))] + #[test] + fn spoken_passage_event_starts_only_after_a_child_is_launched() { + let process = Arc::new(Mutex::new(None)); + let canceled = AtomicBool::new(false); + #[cfg(windows)] + let mut command = { + let mut command = Command::new("cmd.exe"); + command.args(["/C", "exit", "0"]); + command + }; + #[cfg(unix)] + let mut command = { + let mut command = Command::new("/bin/sh"); + command.args(["-c", "exit 0"]); + command + }; + quiet_command(&mut command); + let started = std::cell::Cell::new(false); + run_child_started(command, &canceled, &process, || started.set(true)).unwrap(); + assert!(started.get()); + assert!(process.lock().unwrap().is_none()); + let absent = Command::new("lawpdf-test-missing-speech-player-8173"); + started.set(false); + assert!(run_child_started(absent, &canceled, &process, || started.set(true)).is_err()); + assert!(!started.get()); + } + fn config(provider: SpeechProvider) -> ApiSpeechConfig { ApiSpeechConfig { provider, diff --git a/tools/prepare_release.py b/tools/prepare_release.py new file mode 100644 index 0000000..6fca81f --- /dev/null +++ b/tools/prepare_release.py @@ -0,0 +1,89 @@ +"""Create an explicitly identified release tag and request verification builds. + +This release utility only tags the supplied, already reviewed main-branch commit. +It never signs artifacts or publishes a release. +""" +import argparse +import json +import os +from pathlib import Path +import re +import tomllib +from urllib.error import HTTPError +from urllib.request import Request, urlopen + +REPOSITORY = "yonathanarbel/LawPDF" +VERSION = re.compile(r"(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)") +COMMIT = re.compile(r"[0-9a-f]{40}") +ROOT = Path(__file__).resolve().parent.parent + + +def require(condition, message): + if not condition: + raise RuntimeError(message) + + +def github_request(method, path, data, token): + body = None if data is None else json.dumps(data).encode("utf-8") + request = Request( + f"https://api.github.com/repos/{REPOSITORY}/{path}", + data=body, + method=method, + headers={ + "Accept": "application/vnd.github+json", + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + "User-Agent": "LawPDF-Release-Preparation", + "X-GitHub-Api-Version": "2026-03-10", + }, + ) + try: + with urlopen(request, timeout=20) as response: + payload = response.read() + return json.loads(payload) if payload else None + except HTTPError as error: + if method == "GET" and error.code == 404: + return None + raise + + +def prepare(version, expected_commit, actual_commit, package_version, manifest_version, lock_version, request): + require(VERSION.fullmatch(version) is not None, "Invalid release version") + require(COMMIT.fullmatch(expected_commit) is not None, "Expected commit must be a full lowercase SHA-1") + require(expected_commit == actual_commit, "The main-branch commit differs from the reviewed commit") + require(version == package_version == manifest_version == lock_version, + "Package, lockfile, and runtime manifest versions must match") + tag = f"v{version}" + existing = request("GET", f"git/ref/tags/{tag}", None) + if existing is None: + existing = request("POST", "git/refs", {"ref": f"refs/tags/{tag}", "sha": expected_commit}) + obj = existing.get("object", {}) if isinstance(existing, dict) else {} + require(obj.get("type") == "commit" and obj.get("sha") == expected_commit, + "Release tag is not a direct reference to the reviewed commit") + for workflow in ("release.yml", "microsoft-store.yml"): + request("POST", f"actions/workflows/{workflow}/dispatches", {"ref": tag}) + return tag + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--version", required=True) + parser.add_argument("--expected-commit", required=True) + args = parser.parse_args() + require(os.environ.get("GITHUB_REPOSITORY") == REPOSITORY, "Unexpected GitHub repository") + require(os.environ.get("GITHUB_REF") == "refs/heads/main", "Preparation must run from main") + token = os.environ.get("GH_TOKEN") + require(bool(token), "Missing GitHub token") + package = tomllib.loads((ROOT / "Cargo.toml").read_text(encoding="utf-8"))["package"] + lock = tomllib.loads((ROOT / "Cargo.lock").read_text(encoding="utf-8"))["package"] + versions = [item["version"] for item in lock if item["name"] == package["name"]] + require(len(versions) == 1, "Lockfile must contain exactly one application package") + manifest = json.loads((ROOT / "release-manifest.json").read_text(encoding="utf-8"))["product"]["version"] + tag = prepare(args.version, args.expected_commit, os.environ.get("GITHUB_SHA", ""), + package["version"], manifest, versions[0], + lambda method, path, data: github_request(method, path, data, token)) + print(f"Prepared {tag} at {args.expected_commit}; verification builds requested.") + + +if __name__ == "__main__": + main() diff --git a/tools/test_prepare_release.py b/tools/test_prepare_release.py new file mode 100644 index 0000000..2ae121b --- /dev/null +++ b/tools/test_prepare_release.py @@ -0,0 +1,145 @@ +"""Offline tests for reviewed release-tag preparation.""" +import contextlib +import io +import os +from pathlib import Path +import sys +import tempfile +import unittest +from unittest.mock import patch +from urllib.error import HTTPError + +import prepare_release as release + + +class PrepareReleaseTests(unittest.TestCase): + commit = "a" * 40 + + def call(self, request, version="1.2.3", expected=None, actual=None, + package="1.2.3", manifest="1.2.3", lock="1.2.3"): + return release.prepare(version, expected or self.commit, actual or self.commit, + package, manifest, lock, request) + + def test_missing_tag_is_created_and_both_verification_builds_are_requested(self): + calls = [] + def request(method, path, data): + calls.append((method, path, data)) + if method == "GET": + return None + if path == "git/refs": + return {"object": {"type": "commit", "sha": self.commit}} + return None + self.assertEqual(self.call(request), "v1.2.3") + self.assertEqual(calls, [ + ("GET", "git/ref/tags/v1.2.3", None), + ("POST", "git/refs", {"ref": "refs/tags/v1.2.3", "sha": self.commit}), + ("POST", "actions/workflows/release.yml/dispatches", {"ref": "v1.2.3"}), + ("POST", "actions/workflows/microsoft-store.yml/dispatches", {"ref": "v1.2.3"}), + ]) + + def test_existing_correct_tag_is_not_recreated(self): + calls = [] + def request(method, path, data): + calls.append((method, path, data)) + return {"object": {"type": "commit", "sha": self.commit}} if method == "GET" else None + self.call(request) + self.assertFalse(any(path == "git/refs" for _, path, _ in calls)) + self.assertEqual(len(calls), 3) + + def test_wrong_or_annotated_tag_never_dispatches(self): + for obj in ({"type": "commit", "sha": "b" * 40}, + {"type": "tag", "sha": self.commit}, {}, None): + calls = [] + def request(method, path, data): + calls.append((method, path)) + return {"object": obj} if obj is not None else {"object": {}} + with self.assertRaises(RuntimeError): + self.call(request) + self.assertEqual(calls, [("GET", "git/ref/tags/v1.2.3")]) + + def test_invalid_request_fails_before_network(self): + def forbidden(*_): + self.fail("network should not be called") + for kwargs in ({"version": "1.2.3; echo unsafe"}, {"version": "01.2.3"}, + {"expected": "bad"}, {"actual": "b" * 40}, + {"package": "1.2.4"}, {"manifest": "1.2.4"}, {"lock": "1.2.4"}): + with self.subTest(kwargs=kwargs), self.assertRaises(RuntimeError): + self.call(forbidden, **kwargs) + + def test_dispatch_accepts_empty_or_json_success_responses(self): + class Response: + def __init__(self, data): + self.data = data + def __enter__(self): + return self + def __exit__(self, *_): + return False + def read(self): + return self.data + for payload, expected in ((b"", None), (b'{"workflow_run_id":7}', {"workflow_run_id": 7})): + with self.subTest(payload=payload), patch.object(release, "urlopen", return_value=Response(payload)) as network: + actual = release.github_request("POST", "actions/workflows/release.yml/dispatches", + {"ref": "v1.2.3"}, "test-token") + self.assertEqual(actual, expected) + request = network.call_args.args[0] + self.assertEqual(request.method, "POST") + self.assertEqual(request.full_url, + "https://api.github.com/repos/yonathanarbel/LawPDF/actions/workflows/release.yml/dispatches") + self.assertIn(b'"ref": "v1.2.3"', request.data) + self.assertEqual(request.get_header("X-github-api-version"), "2026-03-10") + self.assertEqual(network.call_args.kwargs["timeout"], 20) + + def test_missing_tag_is_the_only_http_error_treated_as_absence(self): + def error(status): + return HTTPError("https://api.github.com", status, "error", {}, io.BytesIO()) + with patch.object(release, "urlopen", side_effect=error(404)): + self.assertIsNone(release.github_request("GET", "git/ref/tags/v1.2.3", None, "token")) + for method, status in (("GET", 403), ("GET", 500), ("POST", 404)): + with self.subTest(method=method, status=status), patch.object(release, "urlopen", side_effect=error(status)): + with self.assertRaises(HTTPError): + release.github_request(method, "git/refs", {}, "token") + + def test_main_checks_repository_ref_and_actual_version_files_before_dispatch(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "Cargo.toml").write_text('[package]\nname="lawpdf"\nversion="1.2.3"\n') + (root / "Cargo.lock").write_text('[[package]]\nname="lawpdf"\nversion="1.2.3"\n') + (root / "release-manifest.json").write_text('{"product":{"version":"1.2.3"}}') + environment = {"GITHUB_REPOSITORY": release.REPOSITORY, "GITHUB_REF": "refs/heads/main", + "GITHUB_SHA": self.commit, "GH_TOKEN": "test-token"} + arguments = ["prepare_release.py", "--version", "1.2.3", "--expected-commit", self.commit] + calls = [] + def request(method, path, data, token): + calls.append((method, path, data, token)) + return {"object": {"type": "commit", "sha": self.commit}} if method == "GET" else None + with patch.object(release, "ROOT", root), patch.object(sys, "argv", arguments), \ + patch.dict(os.environ, environment, clear=True), patch.object(release, "github_request", side_effect=request), \ + contextlib.redirect_stdout(io.StringIO()): + release.main() + self.assertEqual(len(calls), 3) + self.assertTrue(all(call[3] == "test-token" for call in calls)) + (root / "Cargo.lock").write_text('[[package]]\nname="lawpdf"\nversion="1.2.4"\n') + with patch.object(release, "ROOT", root), patch.object(sys, "argv", arguments), \ + patch.dict(os.environ, environment, clear=True), patch.object(release, "github_request") as network: + with self.assertRaises(RuntimeError): + release.main() + network.assert_not_called() + environment["GITHUB_REF"] = "refs/heads/other" + with patch.object(release, "ROOT", root), patch.object(sys, "argv", arguments), \ + patch.dict(os.environ, environment, clear=True), patch.object(release, "github_request") as network: + with self.assertRaises(RuntimeError): + release.main() + network.assert_not_called() + + def test_unexpected_created_tag_stops_before_dispatch(self): + calls = [] + def request(method, path, data): + calls.append((method, path)) + return None if method == "GET" else {"object": {"type": "commit", "sha": "b" * 40}} + with self.assertRaises(RuntimeError): + self.call(request) + self.assertEqual(calls, [("GET", "git/ref/tags/v1.2.3"), ("POST", "git/refs")]) + + +if __name__ == "__main__": + unittest.main() From cc22e7662963f934717d11223108481d8830bf8b Mon Sep 17 00:00:00 2001 From: Yonathan Arbel Date: Wed, 11 Nov 2026 12:36:03 -0600 Subject: [PATCH 2/3] Keep landed words clear while the page fades --- src/app/mod.rs | 2 +- src/app/review_reveal.rs | 13 ++++++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index 29ae6ee..2389f34 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -7654,7 +7654,7 @@ impl PdfEditorApp { // seen taking their places in it); the canvas and the printed page // over everything while the words lift; the words in flight above. for (_, landing, _, f) in &flights { - if !f.settled { + if review_reveal::hide_landing(f, page_alpha) { painter.rect_filled(landing.rect.expand2(Vec2::new(1.0, 0.0)), 0.0, sheet_fill); } } diff --git a/src/app/review_reveal.rs b/src/app/review_reveal.rs index 1f06cd2..9779969 100644 --- a/src/app/review_reveal.rs +++ b/src/app/review_reveal.rs @@ -486,10 +486,16 @@ pub(super) fn page_alpha(p: f32) -> f32 { 1.0 - motion::smoothstep(START_HOLD + STAGGER, PAGE_FADE_END, p) } +/// Keep the destination covered until the moving copy can hand off to it. +/// This also prevents two copies showing through as the source page fades. +pub(super) fn hide_landing(flight: &Flight, page_alpha: f32) -> bool { + !flight.settled || page_alpha > 0.0 +} + /// A landed word still needs to be drawn over the source page until the /// page has faded away and its real Review text is fully visible. pub(super) fn show_flight(flight: &Flight, page_alpha: f32) -> bool { - flight.alpha > 0.01 && (!flight.settled || page_alpha > 0.0) + flight.alpha > 0.01 && hide_landing(flight, page_alpha) } pub(super) fn with_alpha(color: Color32, alpha: f32) -> Color32 { @@ -939,10 +945,15 @@ mod tests { let first_landed = flight(&word, end, START_HOLD + FLIGHT_DURATION).unwrap(); assert!(first_landed.settled); assert!(page_alpha(START_HOLD + FLIGHT_DURATION) > 0.0); + assert!(hide_landing( + &first_landed, + page_alpha(START_HOLD + FLIGHT_DURATION) + )); assert!(show_flight( &first_landed, page_alpha(START_HOLD + FLIGHT_DURATION) )); + assert!(!hide_landing(&first_landed, 0.0)); assert!(!show_flight(&first_landed, 0.0)); word.order = 1.0; From 79d70a9e69f57450433fd6a87e83f0128ede4cf4 Mon Sep 17 00:00:00 2001 From: Yonathan Arbel Date: Wed, 11 Nov 2026 12:56:02 -0600 Subject: [PATCH 3/3] Make motion and timeout tests independent of the host --- src/app/jump_history.rs | 37 ++++++++++++++++++++++++++++++------- src/app/motion.rs | 3 +++ src/settings.rs | 7 +++++-- 3 files changed, 38 insertions(+), 9 deletions(-) diff --git a/src/app/jump_history.rs b/src/app/jump_history.rs index 4ac0270..c01131c 100644 --- a/src/app/jump_history.rs +++ b/src/app/jump_history.rs @@ -427,6 +427,12 @@ mod tests { #[test] fn clicking_link_in_other_side_by_side_pane_focuses_and_navigates_it() { + for reduce_motion in [false, true] { + exercise_side_by_side_link(reduce_motion); + } + } + + fn exercise_side_by_side_link(reduce_motion: bool) { fn page_rect(shape: &egui::Shape) -> Option { match shape { egui::Shape::Rect(shape) if shape.fill == PAPER_FILL => Some(shape.rect), @@ -435,6 +441,14 @@ mod tests { } } let (mut app, ctx, _worker) = annotation_saving::tests::fixture(); + app.settings.reduce_motion = reduce_motion; + ctx.all_styles_mut(|style| { + style.scroll_animation = if reduce_motion { + egui::style::ScrollAnimation::none() + } else { + egui::style::ScrollAnimation::duration(THUMBNAIL_SCROLL_SECONDS) + }; + }); app.scroll_target_page = None; let mut second = app.document.as_ref().unwrap().clone(); second.page_count = 4; @@ -475,7 +489,12 @@ mod tests { egui::CentralPanel::default().show(ctx, |ui| app.draw_sbs_document(ui, ctx)); }) }; - let output = draw(&mut app, 0.0, vec![]); + // The initial page can snap or animate depending on the preference. + // Measure the link only after that positioning has settled. + for step in 0..10 { + draw(&mut app, step as f64 * 0.1, vec![]); + } + let output = draw(&mut app, 1.0, vec![]); let rect = output .shapes .iter() @@ -490,8 +509,8 @@ mod tests { } .pdf_rect_to_screen(link_rect) .center(); - draw(&mut app, 0.1, vec![egui::Event::PointerMoved(point)]); - for (time, pressed) in [(0.2, true), (0.3, false)] { + draw(&mut app, 1.1, vec![egui::Event::PointerMoved(point)]); + for (time, pressed) in [(1.2, true), (1.3, false)] { draw( &mut app, time, @@ -509,7 +528,7 @@ mod tests { app.sbs_pane_states[&8].destination_fraction, Some((2, 0.25)) ); - for step in 4..20 { + for step in 14..30 { draw(&mut app, step as f64 * 0.1, vec![]); } let landed = app @@ -522,9 +541,13 @@ mod tests { "landed at {}", landed.top_fraction ); - let _ = ctx.run(Default::default(), |ctx| { - app.navigate_jump_history(ctx, false) - }); + let _ = ctx.run( + egui::RawInput { + time: Some(3.1), + ..Default::default() + }, + |ctx| app.navigate_jump_history(ctx, false), + ); assert_eq!(app.scroll_target_page, Some(0)); } } diff --git a/src/app/motion.rs b/src/app/motion.rs index 8b7fa11..ae2067d 100644 --- a/src/app/motion.rs +++ b/src/app/motion.rs @@ -100,6 +100,9 @@ mod tests { use crate::render_worker::PreparedPage; use egui::{Color32, ColorImage, Pos2, Rect, TextureOptions, Vec2}; let (mut app, ctx, _worker) = annotation_saving::tests::fixture(); + // The host's accessibility setting may default this to true. This + // test exercises both choices explicitly and starts with motion on. + app.settings.reduce_motion = false; app.settings.dim_original_pages = false; let image = || ColorImage::new([2, 2], vec![Color32::WHITE; 4]); let thumbnail = ctx.load_texture("motion-thumb", image(), TextureOptions::LINEAR); diff --git a/src/settings.rs b/src/settings.rs index 7092d83..a839e79 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -924,14 +924,17 @@ mod tests { #[cfg(not(windows))] let (program, immediate, slow): (&str, &[&str], &[&str]) = ("sh", &["-c", "printf ready"], &["-c", "exec sleep 4"]); + // A busy hosted Windows runner can take several seconds to start + // PowerShell. This success-case allowance is separate from the real + // 250/500 ms desktop-query deadlines and the timeout test below. let output = - bounded_desktop_query(program, immediate, Instant::now() + Duration::from_secs(3)); + bounded_desktop_query(program, immediate, Instant::now() + Duration::from_secs(15)); assert_eq!(output.as_deref().map(str::trim), Some("ready")); let started = Instant::now(); assert!( bounded_desktop_query(program, slow, started + Duration::from_millis(300)).is_none() ); - assert!(started.elapsed() < Duration::from_secs(3)); + assert!(started.elapsed() < Duration::from_secs(10)); } fn temp_settings_path(test_name: &str) -> PathBuf {