From 8ff07ad65c0a9fa5a9063d1af0d666c3cc297ef9 Mon Sep 17 00:00:00 2001 From: Ed Wei Date: Tue, 17 Mar 2026 04:28:27 -0700 Subject: [PATCH 1/7] Add local PR review scripts and code review docs Scripts (review-pr.sh, review-check.sh) run Claude Code reviews locally against the branch diff, with --local/--post/--no-post flags for non-interactive agent use. Uses printf-based prompt passing to avoid shell injection from diff content. Includes iterative review-fix-verify cycle in CLAUDE.md PR workflow. Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 26 ++++++++++ docs/code-review.md | 50 +++++++++++++++++++ scripts/review-check.sh | 95 +++++++++++++++++++++++++++++++++++ scripts/review-pr.sh | 108 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 279 insertions(+) create mode 100644 docs/code-review.md create mode 100644 scripts/review-check.sh create mode 100644 scripts/review-pr.sh diff --git a/CLAUDE.md b/CLAUDE.md index 085ed50..c8cd651 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,3 +16,29 @@ JAVA_HOME="/c/Program Files/Android/Android Studio/jbr" ./gradlew assembleDebug ```bash "/c/Users/Durham/AppData/Local/Android/Sdk/platform-tools/adb.exe" install -r app/build/outputs/apk/debug/app-debug.apk ``` + +### Tests + +```bash +# Unit tests only (no device required) +./gradlew testDebugUnitTest + +# All tests (unit + instrumented, requires connected device) +./gradlew allTests +``` + +## PR Workflow + +Before creating a PR, run the self-review cycle locally: + +1. **Review local changes**: `REVIEW=$(./scripts/review-pr.sh --local --no-post)` — reviews the branch diff against master, saves to `.claude/reviews/`. +2. **Read the review file** (`cat "$REVIEW"`) and address every actionable item by editing the code and committing. +3. **Verify fixes**: `./scripts/review-check.sh --local --no-post "$REVIEW"` — confirm all items are addressed. +4. If any items remain open, go back to step 2. +5. Once all items are resolved, **create the PR** and post the review: + - `gh pr create --draft` (or `gh pr create` if ready) + - `./scripts/review-pr.sh --post` (runs a fresh review and posts to the PR) + - `./scripts/review-check.sh --post "$REVIEW"` (posts the resolution checklist) + - `gh pr ready` (if created as draft) + +The `--local` flag works without a remote or PR. The `--no-post` / `--post` flags make scripts non-interactive for agent use. diff --git a/docs/code-review.md b/docs/code-review.md new file mode 100644 index 0000000..1ff9934 --- /dev/null +++ b/docs/code-review.md @@ -0,0 +1,50 @@ +# Local Code Review + +Run Claude Code reviews locally using your already-authenticated `claude` CLI, then optionally post results to a PR. No CI secrets or OAuth tokens needed. + +## Prerequisites + +- `claude` CLI installed and logged in +- `gh` CLI authenticated with your GitHub account + +## Interactive use + +```bash +# Review local changes (prompts to post if a PR exists) +./scripts/review-pr.sh + +# Review a specific PR +./scripts/review-pr.sh 42 + +# Check addressed items +./scripts/review-check.sh [pr-number] +``` + +## Non-interactive / agent use + +Both scripts accept `--local`, `--post`, and `--no-post` flags: + +```bash +# Review local branch diff, no remote needed +REVIEW=$(./scripts/review-pr.sh --local --no-post) + +# After fixing issues, verify locally +./scripts/review-check.sh --local --no-post "$REVIEW" + +# Once ready, create PR and post results +gh pr create --draft +./scripts/review-pr.sh --post +./scripts/review-check.sh --post "$REVIEW" +``` + +## Automated PR cycle (used by Claude agent) + +The full self-review cycle is documented in `CLAUDE.md` under **PR Workflow**. +Claude will automatically: + +1. Review local changes with `--local --no-post` (no remote needed) +2. Address all actionable items found +3. Verify fixes with `review-check.sh --local --no-post` +4. Iterate until all items are resolved +5. Create the PR and post the final review + checklist +6. Mark the PR as ready diff --git a/scripts/review-check.sh b/scripts/review-check.sh new file mode 100644 index 0000000..3a4270a --- /dev/null +++ b/scripts/review-check.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# Check which review items have been addressed by subsequent changes. +# Usage: ./scripts/review-check.sh [--post] [--no-post] [--local] [pr-number] +# +# Options: +# --local Compare against local diff (no PR needed) +# --post Post update to PR without prompting (non-interactive) +# --no-post Save update locally without posting (non-interactive) +# (default) Prompt whether to post + +set -euo pipefail + +POST_MODE="" +LOCAL_MODE="" +POSITIONAL=() + +for arg in "$@"; do + case "$arg" in + --post) POST_MODE="yes" ;; + --no-post) POST_MODE="no" ;; + --local) LOCAL_MODE="yes" ;; + *) POSITIONAL+=("$arg") ;; + esac +done + +REVIEW_FILE="${POSITIONAL[0]:?Usage: review-check.sh [options] [pr-number]}" +PR="${POSITIONAL[1]:-}" + +if [ ! -f "$REVIEW_FILE" ]; then + echo "Error: review file not found: ${REVIEW_FILE}" >&2 + exit 1 +fi + +BASE_BRANCH="master" + +# Determine if we're working locally or with a PR +if [ "$LOCAL_MODE" = "yes" ]; then + PR="" +elif [ -z "$PR" ]; then + PR=$(gh pr view --json number -q .number 2>/dev/null) || { + LOCAL_MODE="yes" + } +fi + +# Get the current diff +if [ "$LOCAL_MODE" = "yes" ] || [ -z "$PR" ]; then + # Include committed + staged + unstaged changes vs base branch + DIFF=$(git diff "${BASE_BRANCH}") +else + DIFF=$(gh pr diff "$PR") +fi + +REVIEW=$(cat "$REVIEW_FILE") +REPO_NAME=$(basename "$(git rev-parse --show-toplevel)") + +echo "Checking which review items have been addressed..." >&2 + +# Use printf to avoid shell interpretation of diff/review content +UPDATE=$({ + printf 'You were given this code review for %s:\n\n' "$REPO_NAME" + printf '%s\n' "$REVIEW" + printf '\nHere is the current diff after the author made changes:\n\n```diff\n' + printf '%s\n' "$DIFF" + printf '```\n\n' + printf '%s\n' \ + "For each item in the original review, determine whether it has been addressed" \ + "by the current changes. Output a checklist in markdown:" \ + "" \ + "- [x] Item — addressed (brief explanation)" \ + "- [ ] Item — still open (brief explanation)" \ + "" \ + "Be concise. Only list items that were actual action items from the review." +} | claude --print --output-format text) + +echo "$UPDATE" >&2 + +# Post to PR if applicable +if [ -n "$PR" ]; then + if [ -z "$POST_MODE" ]; then + read -r -p "Post update to PR #${PR}? [y/N] " POST_MODE + [[ "$POST_MODE" =~ ^[Yy] ]] && POST_MODE="yes" || POST_MODE="no" + fi + + if [ "$POST_MODE" = "yes" ]; then + gh pr comment "$PR" --body "## Review Update + +${UPDATE} + +--- +*Checked via \`scripts/review-check.sh\`*" + echo "Posted update to PR #${PR}." >&2 + fi +elif [ "$POST_MODE" = "yes" ]; then + echo "Warning: --post ignored, no PR exists for this branch." >&2 +fi diff --git a/scripts/review-pr.sh b/scripts/review-pr.sh new file mode 100644 index 0000000..e79dae5 --- /dev/null +++ b/scripts/review-pr.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +# Run Claude Code review on the current branch's changes. +# Usage: ./scripts/review-pr.sh [--post] [--no-post] [--local] [pr-number] +# +# Options: +# --local Review local diff against master (no PR or remote needed) +# --post Post review to PR without prompting (non-interactive) +# --no-post Save review locally without posting (non-interactive) +# (default) --local if no PR exists, prompts to post if PR exists +# +# Review output is saved to .claude/reviews/-.md +# Prints the review file path as the last line of stdout. + +set -euo pipefail + +POST_MODE="" +LOCAL_MODE="" +PR="" + +for arg in "$@"; do + case "$arg" in + --post) POST_MODE="yes" ;; + --no-post) POST_MODE="no" ;; + --local) LOCAL_MODE="yes" ;; + *) PR="$arg" ;; + esac +done + +BRANCH=$(git rev-parse --abbrev-ref HEAD) +BASE_BRANCH="master" + +# Determine if we're working locally or with a PR +if [ "$LOCAL_MODE" = "yes" ]; then + PR="" +elif [ -z "$PR" ]; then + PR=$(gh pr view --json number -q .number 2>/dev/null) || { + LOCAL_MODE="yes" + } +fi + +REVIEW_DIR=".claude/reviews" +mkdir -p "$REVIEW_DIR" +TIMESTAMP=$(date +%Y%m%d-%H%M%S) +SAFE_BRANCH=$(echo "$BRANCH" | tr '/' '-') +REVIEW_FILE="$REVIEW_DIR/${SAFE_BRANCH}-${TIMESTAMP}.md" + +# Get the diff +if [ "$LOCAL_MODE" = "yes" ]; then + echo "Reviewing local branch '${BRANCH}' against '${BASE_BRANCH}'..." >&2 + # Include committed + staged + unstaged changes vs base branch + DIFF=$(git diff "${BASE_BRANCH}") + CONTEXT="Local branch: ${BRANCH} (vs ${BASE_BRANCH})" +else + echo "Reviewing PR #${PR} on branch '${BRANCH}'..." >&2 + DIFF=$(gh pr diff "$PR") + CONTEXT="PR #${PR} on branch: ${BRANCH}" +fi + +if [ -z "$DIFF" ]; then + echo "No changes found to review." >&2 + exit 0 +fi + +REPO_NAME=$(basename "$(git rev-parse --show-toplevel)") + +# Use printf to avoid shell interpretation of diff content +{ + printf 'PROJECT: %s\n%s\n\nHere is the diff:\n\n```diff\n' "$REPO_NAME" "$CONTEXT" + printf '%s\n' "$DIFF" + printf '```\n\n' + printf '%s\n' \ + "Please review these changes and provide feedback on:" \ + "- Code quality and best practices" \ + "- Potential bugs or issues" \ + "- Performance considerations" \ + "- Security concerns" \ + "- Test coverage" \ + "" \ + "Format your review as a markdown document with sections for each concern found." \ + "If everything looks good, say so briefly." \ + "Do NOT post any GitHub comments — just output the review text." +} | claude --print --output-format text > "$REVIEW_FILE" + +echo "Review saved to ${REVIEW_FILE}" >&2 + +# Post to PR if applicable and requested +if [ -n "$PR" ]; then + if [ -z "$POST_MODE" ]; then + read -r -p "Post review to PR #${PR}? [y/N] " POST_MODE + [[ "$POST_MODE" =~ ^[Yy] ]] && POST_MODE="yes" || POST_MODE="no" + fi + + if [ "$POST_MODE" = "yes" ]; then + BODY=$(cat "$REVIEW_FILE") + gh pr comment "$PR" --body "## Claude Code Review + +${BODY} + +--- +*Local review via \`scripts/review-pr.sh\`*" + echo "Posted review to PR #${PR}." >&2 + fi +elif [ "$POST_MODE" = "yes" ]; then + echo "Warning: --post ignored, no PR exists for this branch." >&2 +fi + +# Output the review file path (for piping to review-check.sh) +echo "$REVIEW_FILE" From 3ad9134d5cd55dfd0bd2a4aac1aad35757760082 Mon Sep 17 00:00:00 2001 From: Ed Wei Date: Tue, 17 Mar 2026 04:41:28 -0700 Subject: [PATCH 2/7] Refactor ScreenMetrics init and fix scaledDensity deprecation Use TypedValue.applyDimension for SP to pixel conversions instead of the deprecated DisplayMetrics.scaledDensity field. Extract initLayout() and computeTextSizes() for clearer separation of layout vs text sizing. Add type-safe TestLogEvent, replace KotlinClosure2 with TestListener, and add allTests task with test ordering. Co-Authored-By: Claude Opus 4.5 Co-Authored-By: Claude Opus 4.6 (1M context) --- app/build.gradle.kts | 30 +++++++ .../java/com/writer/view/ScreenMetrics.kt | 83 ++++++++++++++----- .../java/com/writer/view/ScreenMetricsTest.kt | 13 ++- 3 files changed, 100 insertions(+), 26 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6e1454f..57d7381 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -86,4 +86,34 @@ dependencies { implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7") implementation("androidx.recyclerview:recyclerview:1.3.2") implementation("com.google.android.material:material:1.12.0") + + // Testing + testImplementation("junit:junit:4.13.2") +} + +tasks.withType { + testLogging { + events(org.gradle.api.tasks.testing.logging.TestLogEvent.FAILED) + showStandardStreams = false + } + addTestListener(object : TestListener { + override fun beforeSuite(suite: TestDescriptor) {} + override fun afterSuite(suite: TestDescriptor, result: TestResult) { + if (suite.parent == null) { + println("${result.resultType}: ${result.successfulTestCount} passed, ${result.failedTestCount} failed, ${result.skippedTestCount} skipped") + } + } + override fun beforeTest(testDescriptor: TestDescriptor) {} + override fun afterTest(testDescriptor: TestDescriptor, result: TestResult) {} + }) +} + +tasks.register("allTests") { + description = "Runs all tests: unit tests and instrumented tests on connected device" + group = "verification" + dependsOn("testDebugUnitTest", "connectedDebugAndroidTest") +} +// Run unit tests first — fail fast before slower device tests +tasks.matching { it.name == "connectedDebugAndroidTest" }.configureEach { + mustRunAfter("testDebugUnitTest") } diff --git a/app/src/main/java/com/writer/view/ScreenMetrics.kt b/app/src/main/java/com/writer/view/ScreenMetrics.kt index 5e17cde..ea48ff9 100644 --- a/app/src/main/java/com/writer/view/ScreenMetrics.kt +++ b/app/src/main/java/com/writer/view/ScreenMetrics.kt @@ -1,23 +1,26 @@ package com.writer.view +import android.util.TypedValue import kotlin.math.roundToInt /** * Converts dp/sp design constants to device pixels using Android's standard - * density system ([DisplayMetrics.density] and [DisplayMetrics.scaledDensity]). + * density system ([DisplayMetrics.density] and [TypedValue.applyDimension]). * * This is the Android platform best practice: * - **dp** (density-independent pixels) for all spatial measurements. * 1 dp = 1 px at 160 ppi; scaled by [DisplayMetrics.density]. * - **sp** (scale-independent pixels) for text sizes. * Same as dp but additionally respects the user's system font-size preference - * via [DisplayMetrics.scaledDensity]. + * via [TypedValue.applyDimension] with [TypedValue.COMPLEX_UNIT_SP]. * * The compact/standard breakpoint uses [Configuration.smallestScreenWidthDp] — * the same mechanism Android resource qualifiers (e.g. `values-sw600dp/`) use — * rather than computing a physical diagonal. * - * Call [init] once in Application.onCreate() before any view is inflated. + * Call [init] once in `Application.onCreate()` before any view is inflated, + * and re-call it in `onConfigurationChanged` if the user changes font scale + * or display density at runtime. * Tests use the plain-value overload to avoid an Android framework dependency. */ object ScreenMetrics { @@ -55,10 +58,14 @@ object ScreenMetrics { // ── Computed pixel values (set by init) ─────────────────────────────────── var density: Float = 1f; private set - var scaledDensity: Float = 1f; private set /** True when the device's smallestScreenWidthDp is below [COMPACT_SW_DP]. */ var isCompact: Boolean = false; private set + // DisplayMetrics reference for proper SP conversion (null in tests) + private var displayMetrics: android.util.DisplayMetrics? = null + // Font scale for test init (1.0 = no scaling) + private var fontScale: Float = 1f + var lineSpacing: Float = 100f; private set var topMargin: Float = 30f; private set var gutterWidth: Float = 110f; private set @@ -83,34 +90,46 @@ object ScreenMetrics { displayMetrics: android.util.DisplayMetrics, configuration: android.content.res.Configuration ) { - init( - density = displayMetrics.density, - scaledDensity = displayMetrics.scaledDensity, - smallestWidthDp = configuration.smallestScreenWidthDp, - widthPixels = displayMetrics.widthPixels, - heightPixels = displayMetrics.heightPixels + this.displayMetrics = displayMetrics + initLayout( + density = displayMetrics.density, + smallestWidthDp = configuration.smallestScreenWidthDp, + widthPixels = displayMetrics.widthPixels, + heightPixels = displayMetrics.heightPixels ) + computeTextSizes() } /** * Plain-value overload for unit tests — no Android framework dependency. * * @param density [DisplayMetrics.density] (= densityDpi / 160) - * @param scaledDensity [DisplayMetrics.scaledDensity] (density × fontScale) + * @param fontScale User font scale preference (1.0 = default, >1.0 = larger text) * @param smallestWidthDp [Configuration.smallestScreenWidthDp] * @param widthPixels screen width in pixels (used for gutter cap) * @param heightPixels screen height in pixels */ fun init( density: Float, - scaledDensity: Float, + fontScale: Float = 1f, + smallestWidthDp: Int, + widthPixels: Int, + heightPixels: Int + ) { + this.displayMetrics = null + this.fontScale = fontScale.coerceAtLeast(0.5f) + initLayout(density, smallestWidthDp, widthPixels, heightPixels) + computeTextSizes() + } + + private fun initLayout( + density: Float, smallestWidthDp: Int, widthPixels: Int, heightPixels: Int ) { - this.density = density.coerceAtLeast(0.5f) - this.scaledDensity = scaledDensity.coerceAtLeast(0.5f) - isCompact = smallestWidthDp < COMPACT_SW_DP + this.density = density.coerceAtLeast(0.5f) + isCompact = smallestWidthDp < COMPACT_SW_DP val lineSpacingDp = if (isCompact) LINE_SPACING_COMPACT_DP else LINE_SPACING_DP val gutterTargetDp = if (isCompact) GUTTER_TARGET_COMPACT_DP else GUTTER_TARGET_DP @@ -125,13 +144,15 @@ object ScreenMetrics { .coerceAtMost(widthPixels * gutterMaxFrac) .coerceAtLeast(gutterMinDp * this.density) .roundToInt().toFloat() + } - textBody = TEXT_BODY_SP * this.scaledDensity - textLogo = TEXT_LOGO_SP * this.scaledDensity - textStatus = TEXT_STATUS_SP * this.scaledDensity - textSubtext = TEXT_SUBTEXT_SP * this.scaledDensity - textCloseBtn = TEXT_CLOSE_BTN_SP * this.scaledDensity - textTutorial = TEXT_TUTORIAL_SP * this.scaledDensity + private fun computeTextSizes() { + textBody = spToPx(TEXT_BODY_SP) + textLogo = spToPx(TEXT_LOGO_SP) + textStatus = spToPx(TEXT_STATUS_SP) + textSubtext = spToPx(TEXT_SUBTEXT_SP) + textCloseBtn = spToPx(TEXT_CLOSE_BTN_SP) + textTutorial = spToPx(TEXT_TUTORIAL_SP) } // ── Conversion helpers ──────────────────────────────────────────────────── @@ -139,8 +160,24 @@ object ScreenMetrics { /** Convert dp to pixels at the current display density. */ fun dp(value: Float): Float = value * density - /** Convert sp to pixels, respecting the user's font-size preference. */ - fun sp(value: Float): Float = value * scaledDensity + /** + * Convert sp to pixels, respecting the user's font-size preference. + * Uses [TypedValue.applyDimension] for proper adaptive font scaling on API 34+. + */ + fun sp(value: Float): Float = spToPx(value) + + /** + * Internal SP to pixel conversion using [TypedValue.applyDimension] when available. + * Falls back to manual calculation for tests without Android framework. + */ + private fun spToPx(value: Float): Float { + val dm = displayMetrics + return if (dm != null) { + TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, value, dm) + } else { + value * density * fontScale + } + } // ── Layout helpers ──────────────────────────────────────────────────────── diff --git a/app/src/test/java/com/writer/view/ScreenMetricsTest.kt b/app/src/test/java/com/writer/view/ScreenMetricsTest.kt index fbabe13..ccea113 100644 --- a/app/src/test/java/com/writer/view/ScreenMetricsTest.kt +++ b/app/src/test/java/com/writer/view/ScreenMetricsTest.kt @@ -49,13 +49,13 @@ class ScreenMetricsTest { // Re-initialise before each test so state from previous tests doesn't leak. @Before fun resetToDefault() { - ScreenMetrics.init(DENSITY, DENSITY, SW_GO_7, W_GO_7, H_GO_7) + ScreenMetrics.init(DENSITY, smallestWidthDp = SW_GO_7, widthPixels = W_GO_7, heightPixels = H_GO_7) } // ── helpers ────────────────────────────────────────────────────────────── private fun init(sw: Int, w: Int, h: Int) = - ScreenMetrics.init(DENSITY, DENSITY, sw, w, h) + ScreenMetrics.init(DENSITY, smallestWidthDp = sw, widthPixels = w, heightPixels = h) /** Convert pixels back to mm at 300 PPI. */ private fun toMm(px: Float) = px / (DENSITY * 160f) * 25.4f @@ -264,11 +264,18 @@ class ScreenMetricsTest { @Test fun extremelyLowDensity_doesNotCrash() { // Clamps to minimum 0.5 internally - ScreenMetrics.init(0.3f, 0.3f, 400, 800, 600) + ScreenMetrics.init(0.3f, fontScale = 0.3f, smallestWidthDp = 400, widthPixels = 800, heightPixels = 600) assertTrue(ScreenMetrics.lineSpacing > 0f) assertTrue(ScreenMetrics.gutterWidth > 0f) } + @Test fun fontScale_2x_doublesTextSizes() { + ScreenMetrics.init(DENSITY, fontScale = 1f, smallestWidthDp = SW_GO_7, widthPixels = W_GO_7, heightPixels = H_GO_7) + val baseTextBody = ScreenMetrics.textBody + ScreenMetrics.init(DENSITY, fontScale = 2f, smallestWidthDp = SW_GO_7, widthPixels = W_GO_7, heightPixels = H_GO_7) + assertEquals("textBody at fontScale=2 should be ~2x default", baseTextBody * 2f, ScreenMetrics.textBody, 0.1f) + } + // ── compact-mode classification ─────────────────────────────────────────── @Test fun isCompact_palma2Pro_isTrue() { From f7f8e956de783c9c3a742f321d13279e7a950a58 Mon Sep 17 00:00:00 2001 From: Ed Wei Date: Tue, 17 Mar 2026 05:32:50 -0700 Subject: [PATCH 3/7] Add Boox MyScript handwriting recognition with ML Kit fallback Co-Authored-By: Claude Opus 4.6 (1M context) --- app/build.gradle.kts | 56 +++ .../assets/fixtures/hello_test.json | 245 ++++++++++++ .../java/com/writer/recognition/DevTool.kt | 4 + .../com/writer/recognition/FixtureLoader.kt | 55 +++ .../recognition/OnyxHwrTextRecognizerTest.kt | 79 ++++ .../recognition/StrokeFixtureCapture.kt | 92 +++++ app/src/main/AndroidManifest.xml | 6 +- .../sdk/hwr/service/HWRCommandArgs.aidl | 3 + .../android/sdk/hwr/service/HWRInputArgs.aidl | 3 + .../sdk/hwr/service/HWROutputArgs.aidl | 3 + .../sdk/hwr/service/HWROutputCallback.aidl | 7 + .../android/sdk/hwr/service/IHWRService.aidl | 16 + .../android/sdk/hwr/service/HWRCommandArgs.kt | 17 + .../android/sdk/hwr/service/HWRInputArgs.kt | 76 ++++ .../android/sdk/hwr/service/HWROutputArgs.kt | 47 +++ ...gnizer.kt => GoogleMLKitTextRecognizer.kt} | 14 +- .../com/writer/recognition/HwrProtobuf.kt | 139 +++++++ .../recognition/OnyxHwrTextRecognizer.kt | 226 +++++++++++ .../writer/recognition/StrokeDownsampler.kt | 89 +++++ .../com/writer/recognition/TextRecognizer.kt | 35 ++ .../recognition/TextRecognizerFactory.kt | 31 ++ .../com/writer/ui/writing/SaveAsActivity.kt | 35 +- .../com/writer/ui/writing/WritingActivity.kt | 12 +- .../writer/ui/writing/WritingCoordinator.kt | 27 +- .../sdk/hwr/service/HWRInputArgsTest.kt | 79 ++++ .../com/writer/recognition/HwrProtobufTest.kt | 355 ++++++++++++++++++ .../recognition/OnyxHwrTextRecognizerTest.kt | 79 ++++ .../recognition/StrokeDownsamplerTest.kt | 90 +++++ .../recognition/TextRecognizerFactoryTest.kt | 59 +++ 29 files changed, 1930 insertions(+), 49 deletions(-) create mode 100644 app/src/androidTest/assets/fixtures/hello_test.json create mode 100644 app/src/androidTest/java/com/writer/recognition/DevTool.kt create mode 100644 app/src/androidTest/java/com/writer/recognition/FixtureLoader.kt create mode 100644 app/src/androidTest/java/com/writer/recognition/OnyxHwrTextRecognizerTest.kt create mode 100644 app/src/androidTest/java/com/writer/recognition/StrokeFixtureCapture.kt create mode 100644 app/src/main/aidl/com/onyx/android/sdk/hwr/service/HWRCommandArgs.aidl create mode 100644 app/src/main/aidl/com/onyx/android/sdk/hwr/service/HWRInputArgs.aidl create mode 100644 app/src/main/aidl/com/onyx/android/sdk/hwr/service/HWROutputArgs.aidl create mode 100644 app/src/main/aidl/com/onyx/android/sdk/hwr/service/HWROutputCallback.aidl create mode 100644 app/src/main/aidl/com/onyx/android/sdk/hwr/service/IHWRService.aidl create mode 100644 app/src/main/java/com/onyx/android/sdk/hwr/service/HWRCommandArgs.kt create mode 100644 app/src/main/java/com/onyx/android/sdk/hwr/service/HWRInputArgs.kt create mode 100644 app/src/main/java/com/onyx/android/sdk/hwr/service/HWROutputArgs.kt rename app/src/main/java/com/writer/recognition/{HandwritingRecognizer.kt => GoogleMLKitTextRecognizer.kt} (83%) create mode 100644 app/src/main/java/com/writer/recognition/HwrProtobuf.kt create mode 100644 app/src/main/java/com/writer/recognition/OnyxHwrTextRecognizer.kt create mode 100644 app/src/main/java/com/writer/recognition/StrokeDownsampler.kt create mode 100644 app/src/main/java/com/writer/recognition/TextRecognizer.kt create mode 100644 app/src/main/java/com/writer/recognition/TextRecognizerFactory.kt create mode 100644 app/src/test/java/com/onyx/android/sdk/hwr/service/HWRInputArgsTest.kt create mode 100644 app/src/test/java/com/writer/recognition/HwrProtobufTest.kt create mode 100644 app/src/test/java/com/writer/recognition/OnyxHwrTextRecognizerTest.kt create mode 100644 app/src/test/java/com/writer/recognition/StrokeDownsamplerTest.kt create mode 100644 app/src/test/java/com/writer/recognition/TextRecognizerFactoryTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 57d7381..8cf7804 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -14,6 +14,8 @@ android { targetSdk = 34 versionCode = 1 versionName = "0.1.0" + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + testInstrumentationRunnerArguments["notAnnotation"] = "com.writer.recognition.DevTool" } buildTypes { @@ -39,8 +41,21 @@ android { jvmTarget = "17" } + testOptions { + unitTests { + isReturnDefaultValues = true + isIncludeAndroidResources = true + all { + it.jvmArgs( + "--add-opens", "java.base/jdk.internal.access=ALL-UNNAMED", + ) + } + } + } + buildFeatures { viewBinding = true + aidl = true } packaging { @@ -50,6 +65,40 @@ android { } } +tasks.register("captureFixture") { + description = "Capture handwriting fixture from device: -PfixtureName=hello -PexpectedText=\"hello\"" + dependsOn("installDebug", "installDebugAndroidTest") + doLast { + val name = project.property("fixtureName") as String + val text = project.property("expectedText") as String + val lang = project.findProperty("language") as? String ?: "en-US" + val line = project.findProperty("lineIndex") as? String ?: "0" + val adb = android.adbExecutable.absolutePath + val appId = "com.writer.dev" + + exec { + commandLine(adb, "shell", "am", "instrument", "-w", + "-e", "class", "com.writer.recognition.StrokeFixtureCapture", + "-e", "fixtureName", name, + "-e", "expectedText", text, + "-e", "language", lang, + "-e", "lineIndex", line, + "$appId.test/androidx.test.runner.AndroidJUnitRunner") + } + + exec { + commandLine(adb, "pull", + "/sdcard/Download/inkup-fixtures/$name.json", + "app/src/androidTest/assets/fixtures/$name.json") + } + + exec { + commandLine(adb, "shell", "rm", + "/sdcard/Download/inkup-fixtures/$name.json") + } + } +} + configurations.all { // Onyx SDK pulls in old pre-AndroidX support libraries that clash with AndroidX exclude(group = "com.android.support", module = "support-compat") @@ -89,6 +138,13 @@ dependencies { // Testing testImplementation("junit:junit:4.13.2") + testImplementation("org.json:json:20231013") + testImplementation("org.robolectric:robolectric:4.14.1") + + androidTestImplementation("androidx.test:runner:1.6.2") + androidTestImplementation("androidx.test:rules:1.6.1") + androidTestImplementation("androidx.test.ext:junit:1.2.1") + androidTestImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1") } tasks.withType { diff --git a/app/src/androidTest/assets/fixtures/hello_test.json b/app/src/androidTest/assets/fixtures/hello_test.json new file mode 100644 index 0000000..8743dae --- /dev/null +++ b/app/src/androidTest/assets/fixtures/hello_test.json @@ -0,0 +1,245 @@ +{ + "expectedText": "hello test", + "language": "en-US", + "strokes": [ + { + "strokeId": "s0", + "points": [ + { "x": 59.63, "y": 30.94, "pressure": 1071.0, "timestamp": 1773472267298 }, + { "x": 59.63, "y": 30.94, "pressure": 2865.0, "timestamp": 1773472267366 }, + { "x": 59.83, "y": 34.91, "pressure": 3167.0, "timestamp": 1773472267396 }, + { "x": 58.84, "y": 46.20, "pressure": 3248.0, "timestamp": 1773472267405 }, + { "x": 57.26, "y": 56.10, "pressure": 3284.0, "timestamp": 1773472267416 }, + { "x": 55.28, "y": 65.81, "pressure": 3289.0, "timestamp": 1773472267427 }, + { "x": 53.30, "y": 74.53, "pressure": 3374.0, "timestamp": 1773472267435 }, + { "x": 51.91, "y": 81.46, "pressure": 3351.0, "timestamp": 1773472267447 }, + { "x": 51.31, "y": 87.80, "pressure": 3345.0, "timestamp": 1773472267455 }, + { "x": 51.31, "y": 91.96, "pressure": 3325.0, "timestamp": 1773472267466 }, + { "x": 51.31, "y": 93.54, "pressure": 3298.0, "timestamp": 1773472267484 }, + { "x": 51.31, "y": 93.54, "pressure": 3257.0, "timestamp": 1773472267557 }, + { "x": 54.68, "y": 90.77, "pressure": 3273.0, "timestamp": 1773472267566 }, + { "x": 58.45, "y": 86.41, "pressure": 3279.0, "timestamp": 1773472267576 }, + { "x": 62.41, "y": 82.45, "pressure": 3280.0, "timestamp": 1773472267587 }, + { "x": 65.58, "y": 78.49, "pressure": 3273.0, "timestamp": 1773472267596 }, + { "x": 68.55, "y": 75.52, "pressure": 3262.0, "timestamp": 1773472267609 }, + { "x": 70.53, "y": 74.33, "pressure": 3249.0, "timestamp": 1773472267623 }, + { "x": 72.12, "y": 73.34, "pressure": 3234.0, "timestamp": 1773472267639 }, + { "x": 73.70, "y": 72.74, "pressure": 3221.0, "timestamp": 1773472267655 }, + { "x": 75.29, "y": 72.35, "pressure": 3231.0, "timestamp": 1773472267669 }, + { "x": 77.27, "y": 73.14, "pressure": 3244.0, "timestamp": 1773472267678 }, + { "x": 79.05, "y": 75.52, "pressure": 3446.0, "timestamp": 1773472267690 }, + { "x": 80.44, "y": 79.48, "pressure": 3508.0, "timestamp": 1773472267701 }, + { "x": 81.43, "y": 84.83, "pressure": 3556.0, "timestamp": 1773472267710 }, + { "x": 81.43, "y": 89.19, "pressure": 3568.0, "timestamp": 1773472267721 }, + { "x": 81.43, "y": 93.15, "pressure": 3571.0, "timestamp": 1773472267732 }, + { "x": 81.03, "y": 96.32, "pressure": 3554.0, "timestamp": 1773472267744 }, + { "x": 81.03, "y": 99.09, "pressure": 3288.0, "timestamp": 1773472267755 }, + { "x": 81.23, "y": 101.07, "pressure": 3010.0, "timestamp": 1773472267765 }, + { "x": 81.23, "y": 101.86, "pressure": 2756.0, "timestamp": 1773472267774 }, + { "x": 81.63, "y": 103.05, "pressure": 1398.0, "timestamp": 1773472267785 }, + { "x": 82.62, "y": 104.24, "pressure": 1211.0, "timestamp": 1773472267796 }, + { "x": 82.62, "y": 104.24, "pressure": 1211.0, "timestamp": 1773472267798 } + ] + }, + { + "strokeId": "s1", + "points": [ + { "x": 94.11, "y": 84.83, "pressure": 374.0, "timestamp": 1773472268057 }, + { "x": 94.11, "y": 84.83, "pressure": 2510.0, "timestamp": 1773472268149 }, + { "x": 94.11, "y": 84.83, "pressure": 2659.0, "timestamp": 1773472268157 }, + { "x": 101.64, "y": 85.03, "pressure": 2744.0, "timestamp": 1773472268169 }, + { "x": 106.59, "y": 83.84, "pressure": 2770.0, "timestamp": 1773472268177 }, + { "x": 108.57, "y": 83.04, "pressure": 2792.0, "timestamp": 1773472268191 }, + { "x": 110.16, "y": 81.86, "pressure": 2851.0, "timestamp": 1773472268202 }, + { "x": 111.15, "y": 80.27, "pressure": 2979.0, "timestamp": 1773472268215 }, + { "x": 111.54, "y": 78.69, "pressure": 3094.0, "timestamp": 1773472268223 }, + { "x": 111.54, "y": 78.69, "pressure": 3256.0, "timestamp": 1773472268236 }, + { "x": 110.36, "y": 78.09, "pressure": 3288.0, "timestamp": 1773472268250 }, + { "x": 107.19, "y": 77.70, "pressure": 3294.0, "timestamp": 1773472268261 }, + { "x": 104.02, "y": 78.88, "pressure": 3279.0, "timestamp": 1773472268273 }, + { "x": 102.63, "y": 80.27, "pressure": 3235.0, "timestamp": 1773472268284 }, + { "x": 100.25, "y": 84.23, "pressure": 3209.0, "timestamp": 1773472268295 }, + { "x": 99.85, "y": 87.80, "pressure": 3185.0, "timestamp": 1773472268303 }, + { "x": 100.85, "y": 91.17, "pressure": 3145.0, "timestamp": 1773472268316 }, + { "x": 104.41, "y": 94.53, "pressure": 3126.0, "timestamp": 1773472268332 }, + { "x": 108.37, "y": 96.52, "pressure": 3117.0, "timestamp": 1773472268343 }, + { "x": 112.34, "y": 97.70, "pressure": 3111.0, "timestamp": 1773472268352 }, + { "x": 116.30, "y": 97.51, "pressure": 2270.0, "timestamp": 1773472268363 }, + { "x": 120.46, "y": 95.92, "pressure": 2153.0, "timestamp": 1773472268373 }, + { "x": 120.46, "y": 95.92, "pressure": 2153.0, "timestamp": 1773472268375 } + ] + }, + { + "strokeId": "s2", + "points": [ + { "x": 139.48, "y": 38.87, "pressure": 581.0, "timestamp": 1773472268650 }, + { "x": 139.48, "y": 38.87, "pressure": 2850.0, "timestamp": 1773472268737 }, + { "x": 136.51, "y": 46.79, "pressure": 2999.0, "timestamp": 1773472268751 }, + { "x": 132.94, "y": 57.29, "pressure": 3049.0, "timestamp": 1773472268760 }, + { "x": 130.76, "y": 67.79, "pressure": 3088.0, "timestamp": 1773472268769 }, + { "x": 128.98, "y": 77.10, "pressure": 3095.0, "timestamp": 1773472268780 }, + { "x": 128.78, "y": 84.63, "pressure": 3096.0, "timestamp": 1773472268790 }, + { "x": 129.18, "y": 90.57, "pressure": 3078.0, "timestamp": 1773472268799 }, + { "x": 129.97, "y": 93.54, "pressure": 2807.0, "timestamp": 1773472268810 }, + { "x": 130.76, "y": 96.91, "pressure": 2719.0, "timestamp": 1773472268819 }, + { "x": 130.76, "y": 96.91, "pressure": 1535.0, "timestamp": 1773472268831 }, + { "x": 132.74, "y": 98.30, "pressure": 1501.0, "timestamp": 1773472268840 } + ] + }, + { + "strokeId": "s3", + "points": [ + { "x": 155.73, "y": 41.84, "pressure": 705.0, "timestamp": 1773472269067 }, + { "x": 155.73, "y": 41.84, "pressure": 2923.0, "timestamp": 1773472269131 }, + { "x": 154.74, "y": 45.21, "pressure": 2990.0, "timestamp": 1773472269145 }, + { "x": 151.96, "y": 56.30, "pressure": 3030.0, "timestamp": 1773472269154 }, + { "x": 149.58, "y": 65.81, "pressure": 3043.0, "timestamp": 1773472269165 }, + { "x": 147.60, "y": 74.72, "pressure": 3046.0, "timestamp": 1773472269175 }, + { "x": 145.62, "y": 82.05, "pressure": 3043.0, "timestamp": 1773472269184 }, + { "x": 144.83, "y": 88.19, "pressure": 3023.0, "timestamp": 1773472269195 }, + { "x": 144.43, "y": 92.95, "pressure": 3018.0, "timestamp": 1773472269204 }, + { "x": 144.63, "y": 94.14, "pressure": 2258.0, "timestamp": 1773472269215 }, + { "x": 144.83, "y": 94.53, "pressure": 1438.0, "timestamp": 1773472269226 }, + { "x": 146.61, "y": 95.33, "pressure": 689.0, "timestamp": 1773472269234 }, + { "x": 148.40, "y": 95.33, "pressure": 671.0, "timestamp": 1773472269241 } + ] + }, + { + "strokeId": "s4", + "points": [ + { "x": 167.02, "y": 82.65, "pressure": 402.0, "timestamp": 1773472269373 }, + { "x": 167.02, "y": 82.65, "pressure": 1254.0, "timestamp": 1773472269417 }, + { "x": 168.21, "y": 88.99, "pressure": 1406.0, "timestamp": 1773472269425 }, + { "x": 169.40, "y": 93.54, "pressure": 1595.0, "timestamp": 1773472269436 }, + { "x": 170.19, "y": 94.73, "pressure": 1656.0, "timestamp": 1773472269448 }, + { "x": 172.57, "y": 95.92, "pressure": 1993.0, "timestamp": 1773472269457 }, + { "x": 175.34, "y": 95.72, "pressure": 2261.0, "timestamp": 1773472269468 }, + { "x": 177.72, "y": 94.93, "pressure": 2345.0, "timestamp": 1773472269476 }, + { "x": 179.30, "y": 93.74, "pressure": 2638.0, "timestamp": 1773472269489 }, + { "x": 180.89, "y": 91.36, "pressure": 2936.0, "timestamp": 1773472269500 }, + { "x": 181.48, "y": 88.59, "pressure": 3029.0, "timestamp": 1773472269509 }, + { "x": 181.28, "y": 86.21, "pressure": 3240.0, "timestamp": 1773472269520 }, + { "x": 180.69, "y": 85.03, "pressure": 3355.0, "timestamp": 1773472269532 }, + { "x": 177.12, "y": 83.04, "pressure": 3388.0, "timestamp": 1773472269541 }, + { "x": 174.15, "y": 82.25, "pressure": 3395.0, "timestamp": 1773472269553 }, + { "x": 171.77, "y": 82.05, "pressure": 3341.0, "timestamp": 1773472269566 }, + { "x": 169.40, "y": 82.85, "pressure": 2959.0, "timestamp": 1773472269579 }, + { "x": 169.40, "y": 82.85, "pressure": 2585.0, "timestamp": 1773472269594 } + ] + }, + { + "strokeId": "s5", + "points": [ + { "x": 231.01, "y": 75.71, "pressure": 48.0, "timestamp": 1773472271839 }, + { "x": 231.01, "y": 75.71, "pressure": 3000.0, "timestamp": 1773472271988 }, + { "x": 232.00, "y": 75.52, "pressure": 3064.0, "timestamp": 1773472272003 }, + { "x": 239.53, "y": 73.14, "pressure": 3080.0, "timestamp": 1773472272011 }, + { "x": 244.68, "y": 72.15, "pressure": 3085.0, "timestamp": 1773472272022 }, + { "x": 249.44, "y": 71.55, "pressure": 3089.0, "timestamp": 1773472272032 }, + { "x": 253.40, "y": 71.16, "pressure": 3092.0, "timestamp": 1773472272041 }, + { "x": 258.16, "y": 70.17, "pressure": 3096.0, "timestamp": 1773472272052 }, + { "x": 263.11, "y": 69.18, "pressure": 3094.0, "timestamp": 1773472272063 }, + { "x": 268.06, "y": 68.19, "pressure": 2333.0, "timestamp": 1773472272071 }, + { "x": 271.03, "y": 67.59, "pressure": 2312.0, "timestamp": 1773472272081 } + ] + }, + { + "strokeId": "s6", + "points": [ + { "x": 256.77, "y": 52.54, "pressure": 903.0, "timestamp": 1773472272259 }, + { "x": 256.77, "y": 52.54, "pressure": 2766.0, "timestamp": 1773472272316 }, + { "x": 254.59, "y": 58.08, "pressure": 2860.0, "timestamp": 1773472272328 }, + { "x": 251.62, "y": 67.39, "pressure": 2912.0, "timestamp": 1773472272340 }, + { "x": 249.64, "y": 75.32, "pressure": 2929.0, "timestamp": 1773472272348 }, + { "x": 248.05, "y": 81.86, "pressure": 2927.0, "timestamp": 1773472272359 }, + { "x": 247.85, "y": 87.80, "pressure": 2922.0, "timestamp": 1773472272370 }, + { "x": 248.05, "y": 92.55, "pressure": 2916.0, "timestamp": 1773472272378 }, + { "x": 248.65, "y": 94.93, "pressure": 2539.0, "timestamp": 1773472272389 }, + { "x": 249.24, "y": 97.11, "pressure": 1827.0, "timestamp": 1773472272401 }, + { "x": 252.81, "y": 99.88, "pressure": 1188.0, "timestamp": 1773472272410 }, + { "x": 254.59, "y": 100.68, "pressure": 1173.0, "timestamp": 1773472272419 } + ] + }, + { + "strokeId": "s7", + "points": [ + { "x": 278.17, "y": 92.16, "pressure": 601.0, "timestamp": 1773472272581 }, + { "x": 278.17, "y": 92.16, "pressure": 2719.0, "timestamp": 1773472272658 }, + { "x": 284.11, "y": 88.00, "pressure": 2794.0, "timestamp": 1773472272667 }, + { "x": 288.27, "y": 84.63, "pressure": 2888.0, "timestamp": 1773472272678 }, + { "x": 289.66, "y": 83.24, "pressure": 2941.0, "timestamp": 1773472272690 }, + { "x": 290.05, "y": 82.05, "pressure": 2977.0, "timestamp": 1773472272700 }, + { "x": 290.65, "y": 79.48, "pressure": 3037.0, "timestamp": 1773472272710 }, + { "x": 290.45, "y": 77.10, "pressure": 3095.0, "timestamp": 1773472272725 }, + { "x": 290.25, "y": 76.71, "pressure": 3142.0, "timestamp": 1773472272739 }, + { "x": 288.87, "y": 75.91, "pressure": 3171.0, "timestamp": 1773472272747 }, + { "x": 284.51, "y": 75.32, "pressure": 3128.0, "timestamp": 1773472272758 }, + { "x": 280.54, "y": 77.70, "pressure": 3123.0, "timestamp": 1773472272769 }, + { "x": 277.18, "y": 81.66, "pressure": 3101.0, "timestamp": 1773472272778 }, + { "x": 274.80, "y": 85.82, "pressure": 3074.0, "timestamp": 1773472272789 }, + { "x": 273.02, "y": 90.37, "pressure": 3068.0, "timestamp": 1773472272797 }, + { "x": 273.02, "y": 93.74, "pressure": 3019.0, "timestamp": 1773472272808 }, + { "x": 274.40, "y": 97.11, "pressure": 2985.0, "timestamp": 1773472272821 }, + { "x": 276.58, "y": 99.49, "pressure": 2859.0, "timestamp": 1773472272836 }, + { "x": 280.54, "y": 101.67, "pressure": 2746.0, "timestamp": 1773472272843 }, + { "x": 284.51, "y": 102.06, "pressure": 1545.0, "timestamp": 1773472272854 }, + { "x": 289.46, "y": 100.87, "pressure": 1379.0, "timestamp": 1773472272866 }, + { "x": 289.46, "y": 100.87, "pressure": 1379.0, "timestamp": 1773472272867 } + ] + }, + { + "strokeId": "s8", + "points": [ + { "x": 320.96, "y": 74.13, "pressure": 836.0, "timestamp": 1773472273014 }, + { "x": 320.96, "y": 74.13, "pressure": 2774.0, "timestamp": 1773472273079 }, + { "x": 316.01, "y": 75.71, "pressure": 2805.0, "timestamp": 1773472273111 }, + { "x": 311.06, "y": 77.89, "pressure": 2811.0, "timestamp": 1773472273123 }, + { "x": 309.67, "y": 79.28, "pressure": 2785.0, "timestamp": 1773472273132 }, + { "x": 308.88, "y": 81.26, "pressure": 2733.0, "timestamp": 1773472273145 }, + { "x": 309.27, "y": 84.03, "pressure": 2694.0, "timestamp": 1773472273159 }, + { "x": 310.46, "y": 86.41, "pressure": 2687.0, "timestamp": 1773472273172 }, + { "x": 314.03, "y": 89.58, "pressure": 2686.0, "timestamp": 1773472273186 }, + { "x": 317.99, "y": 91.96, "pressure": 2866.0, "timestamp": 1773472273195 }, + { "x": 319.97, "y": 92.95, "pressure": 2889.0, "timestamp": 1773472273206 }, + { "x": 322.35, "y": 94.14, "pressure": 2901.0, "timestamp": 1773472273220 }, + { "x": 322.35, "y": 94.14, "pressure": 3233.0, "timestamp": 1773472273252 }, + { "x": 320.76, "y": 96.91, "pressure": 3306.0, "timestamp": 1773472273260 }, + { "x": 315.02, "y": 99.88, "pressure": 3308.0, "timestamp": 1773472273271 }, + { "x": 308.08, "y": 101.86, "pressure": 3309.0, "timestamp": 1773472273283 }, + { "x": 301.15, "y": 103.65, "pressure": 3193.0, "timestamp": 1773472273291 }, + { "x": 295.60, "y": 104.04, "pressure": 2909.0, "timestamp": 1773472273302 }, + { "x": 293.62, "y": 104.04, "pressure": 2817.0, "timestamp": 1773472273310 }, + { "x": 293.62, "y": 104.04, "pressure": 1446.0, "timestamp": 1773472273332 } + ] + }, + { + "strokeId": "s9", + "points": [ + { "x": 331.26, "y": 74.72, "pressure": 633.0, "timestamp": 1773472273559 }, + { "x": 331.26, "y": 74.72, "pressure": 2992.0, "timestamp": 1773472273646 }, + { "x": 339.59, "y": 75.91, "pressure": 2999.0, "timestamp": 1773472273657 }, + { "x": 348.10, "y": 75.91, "pressure": 3000.0, "timestamp": 1773472273667 }, + { "x": 355.83, "y": 75.32, "pressure": 2993.0, "timestamp": 1773472273676 }, + { "x": 361.97, "y": 74.33, "pressure": 2072.0, "timestamp": 1773472273687 }, + { "x": 366.93, "y": 73.34, "pressure": 1774.0, "timestamp": 1773472273695 }, + { "x": 367.72, "y": 73.14, "pressure": 1769.0, "timestamp": 1773472273701 } + ] + }, + { + "strokeId": "s10", + "points": [ + { "x": 354.64, "y": 53.73, "pressure": 1142.0, "timestamp": 1773472273832 }, + { "x": 354.64, "y": 53.73, "pressure": 3160.0, "timestamp": 1773472273885 }, + { "x": 351.87, "y": 61.65, "pressure": 3318.0, "timestamp": 1773472273897 }, + { "x": 349.29, "y": 70.17, "pressure": 3339.0, "timestamp": 1773472273908 }, + { "x": 347.51, "y": 78.88, "pressure": 3356.0, "timestamp": 1773472273917 }, + { "x": 346.92, "y": 85.03, "pressure": 3335.0, "timestamp": 1773472273928 }, + { "x": 347.11, "y": 90.57, "pressure": 3330.0, "timestamp": 1773472273936 }, + { "x": 347.71, "y": 94.53, "pressure": 2685.0, "timestamp": 1773472273947 }, + { "x": 348.70, "y": 96.12, "pressure": 1491.0, "timestamp": 1773472273958 }, + { "x": 350.88, "y": 98.30, "pressure": 374.0, "timestamp": 1773472273966 }, + { "x": 351.27, "y": 98.50, "pressure": 347.0, "timestamp": 1773472273974 } + ] + } + ] +} diff --git a/app/src/androidTest/java/com/writer/recognition/DevTool.kt b/app/src/androidTest/java/com/writer/recognition/DevTool.kt new file mode 100644 index 0000000..5af8fd0 --- /dev/null +++ b/app/src/androidTest/java/com/writer/recognition/DevTool.kt @@ -0,0 +1,4 @@ +package com.writer.recognition + +/** Marker for dev-tool "tests" that should only run when explicitly invoked. */ +annotation class DevTool diff --git a/app/src/androidTest/java/com/writer/recognition/FixtureLoader.kt b/app/src/androidTest/java/com/writer/recognition/FixtureLoader.kt new file mode 100644 index 0000000..f95fca0 --- /dev/null +++ b/app/src/androidTest/java/com/writer/recognition/FixtureLoader.kt @@ -0,0 +1,55 @@ +package com.writer.recognition + +import androidx.test.platform.app.InstrumentationRegistry +import com.writer.model.InkLine +import com.writer.model.InkStroke +import com.writer.model.StrokePoint +import org.json.JSONObject + +object FixtureLoader { + + data class Fixture( + val expectedText: String, + val language: String, + val inkLine: InkLine + ) + + fun load(name: String): Fixture { + val context = InstrumentationRegistry.getInstrumentation().context + val jsonText = context.assets.open("fixtures/$name.json") + .bufferedReader().use { it.readText() } + val json = JSONObject(jsonText) + + val expectedText = json.getString("expectedText") + val language = json.optString("language", "en-US") + + val strokes = mutableListOf() + val strokesArr = json.getJSONArray("strokes") + for (i in 0 until strokesArr.length()) { + val strokeObj = strokesArr.getJSONObject(i) + val strokeId = strokeObj.getString("strokeId") + + val pointsArr = strokeObj.getJSONArray("points") + val points = mutableListOf() + for (j in 0 until pointsArr.length()) { + val ptObj = pointsArr.getJSONObject(j) + points.add( + StrokePoint( + x = ptObj.getDouble("x").toFloat(), + y = ptObj.getDouble("y").toFloat(), + pressure = ptObj.getDouble("pressure").toFloat(), + timestamp = ptObj.getLong("timestamp") + ) + ) + } + + strokes.add(InkStroke(strokeId = strokeId, points = points)) + } + + return Fixture( + expectedText = expectedText, + language = language, + inkLine = InkLine.build(strokes) + ) + } +} diff --git a/app/src/androidTest/java/com/writer/recognition/OnyxHwrTextRecognizerTest.kt b/app/src/androidTest/java/com/writer/recognition/OnyxHwrTextRecognizerTest.kt new file mode 100644 index 0000000..9ce839f --- /dev/null +++ b/app/src/androidTest/java/com/writer/recognition/OnyxHwrTextRecognizerTest.kt @@ -0,0 +1,79 @@ +package com.writer.recognition + +import android.content.ComponentName +import android.content.Intent +import android.content.pm.PackageManager +import android.graphics.RectF +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.writer.model.InkLine +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Connected test that exercises the full text recognition pipeline on a Boox device: + * service binding → initialization → protobuf encoding → SharedMemory IPC → result parsing. + * + * Skipped on non-Boox devices where KHwrService is unavailable. + */ +@RunWith(AndroidJUnit4::class) +class OnyxHwrTextRecognizerTest { + + private lateinit var recognizer: OnyxHwrTextRecognizer + private var hwrAvailable = false + + @Before + fun setUp() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + hwrAvailable = isHwrServiceAvailable(context) + assumeTrue("KHwrService not available — skipping on non-Boox device", hwrAvailable) + recognizer = OnyxHwrTextRecognizer(context) + } + + @After + fun tearDown() { + if (hwrAvailable) { + recognizer.close() + } + } + + @Test + fun initialize_bindsAndActivates() = runBlocking { + recognizer.initialize("en-US") + } + + @Test + fun recognizeLine_emptyStrokes_returnsEmpty() = runBlocking { + recognizer.initialize("en-US") + val line = InkLine(emptyList(), RectF()) + val result = recognizer.recognizeLine(line, "") + assertTrue("Empty strokes should return empty string", result.isEmpty()) + } + + @Test + fun recognizeLine_capturedHelloTest_recognizesCorrectly() = runBlocking { + recognizer.initialize("en-US") + val fixture = FixtureLoader.load("hello_test") + assertEquals(fixture.expectedText, recognizer.recognizeLine(fixture.inkLine, "")) + } + + // --- Helpers --- + + private fun isHwrServiceAvailable(context: android.content.Context): Boolean { + val intent = Intent().apply { + component = ComponentName( + "com.onyx.android.ksync", + "com.onyx.android.ksync.service.KHwrService" + ) + } + return context.packageManager.resolveService( + intent, PackageManager.ResolveInfoFlags.of(0) + ) != null + } +} diff --git a/app/src/androidTest/java/com/writer/recognition/StrokeFixtureCapture.kt b/app/src/androidTest/java/com/writer/recognition/StrokeFixtureCapture.kt new file mode 100644 index 0000000..f84bbc2 --- /dev/null +++ b/app/src/androidTest/java/com/writer/recognition/StrokeFixtureCapture.kt @@ -0,0 +1,92 @@ +package com.writer.recognition + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.writer.model.StrokePoint +import com.writer.storage.DocumentStorage +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assume.assumeTrue +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File + +/** + * Instrumented test that captures handwriting from a document on device + * and writes a downsampled JSON fixture to /sdcard/Download/inkup-fixtures/. + * + * Run via the captureFixture Gradle task, or directly: + * adb shell am instrument -w \ + * -e class com.writer.recognition.StrokeFixtureCapture \ + * -e fixtureName hello_test \ + * -e expectedText "hello test" \ + * com.writer.dev.test/androidx.test.runner.AndroidJUnitRunner + */ +@RunWith(AndroidJUnit4::class) +@DevTool +class StrokeFixtureCapture { + + @Test + fun captureFixture() { + val args = InstrumentationRegistry.getArguments() + val fixtureName = args.getString("fixtureName") + val expectedText = args.getString("expectedText") + assumeTrue("Skipped — run via captureFixture Gradle task", fixtureName != null && expectedText != null) + fixtureName!! + expectedText!! + val language = args.getString("language") ?: "en-US" + val lineIndex = args.getString("lineIndex")?.toIntOrNull() ?: 0 + val documentName = args.getString("documentName") + + val context = InstrumentationRegistry.getInstrumentation().targetContext + + // Load the most recent document, or a named one + val docName = documentName ?: run { + val docs = DocumentStorage.listDocuments(context) + require(docs.isNotEmpty()) { "No documents found on device" } + docs.first().name + } + val data = requireNotNull(DocumentStorage.load(context, docName)) { + "Failed to load document: $docName" + } + require(data.strokes.isNotEmpty()) { "Document has no strokes" } + + // Segment strokes by line + val segmenter = LineSegmenter() + val lineStrokes = segmenter.getStrokesForLine(data.strokes, lineIndex) + require(lineStrokes.isNotEmpty()) { + "No strokes found on line $lineIndex. " + + "Available lines: ${segmenter.groupByLine(data.strokes).keys.sorted()}" + } + + // Build fixture JSON with downsampled strokes + val json = JSONObject().apply { + put("expectedText", expectedText) + put("language", language) + put("strokes", JSONArray().apply { + for ((i, stroke) in lineStrokes.sortedBy { it.points.first().x }.withIndex()) { + val downsampled = StrokeDownsampler.downsample(stroke) + put(JSONObject().apply { + put("strokeId", "s$i") + put("points", JSONArray().apply { + for (pt in downsampled.points) { + put(JSONObject().apply { + put("x", pt.x.toDouble()) + put("y", pt.y.toDouble()) + put("pressure", pt.pressure.toDouble()) + put("timestamp", pt.timestamp) + }) + } + }) + }) + } + }) + } + + // Write to /sdcard/Download/inkup-fixtures/ + val outDir = File("/sdcard/Download/inkup-fixtures") + outDir.mkdirs() + val outFile = File(outDir, "$fixtureName.json") + outFile.writeText(json.toString(2)) + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 13952f2..3f92ec6 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,6 +1,10 @@ + + + + - - diff --git a/app/src/main/aidl/com/onyx/android/sdk/hwr/service/HWRCommandArgs.aidl b/app/src/main/aidl/com/onyx/android/sdk/hwr/service/HWRCommandArgs.aidl new file mode 100644 index 0000000..008e543 --- /dev/null +++ b/app/src/main/aidl/com/onyx/android/sdk/hwr/service/HWRCommandArgs.aidl @@ -0,0 +1,3 @@ +package com.onyx.android.sdk.hwr.service; + +parcelable HWRCommandArgs; diff --git a/app/src/main/aidl/com/onyx/android/sdk/hwr/service/HWRInputArgs.aidl b/app/src/main/aidl/com/onyx/android/sdk/hwr/service/HWRInputArgs.aidl new file mode 100644 index 0000000..c47a0d8 --- /dev/null +++ b/app/src/main/aidl/com/onyx/android/sdk/hwr/service/HWRInputArgs.aidl @@ -0,0 +1,3 @@ +package com.onyx.android.sdk.hwr.service; + +parcelable HWRInputArgs; diff --git a/app/src/main/aidl/com/onyx/android/sdk/hwr/service/HWROutputArgs.aidl b/app/src/main/aidl/com/onyx/android/sdk/hwr/service/HWROutputArgs.aidl new file mode 100644 index 0000000..d325ae0 --- /dev/null +++ b/app/src/main/aidl/com/onyx/android/sdk/hwr/service/HWROutputArgs.aidl @@ -0,0 +1,3 @@ +package com.onyx.android.sdk.hwr.service; + +parcelable HWROutputArgs; diff --git a/app/src/main/aidl/com/onyx/android/sdk/hwr/service/HWROutputCallback.aidl b/app/src/main/aidl/com/onyx/android/sdk/hwr/service/HWROutputCallback.aidl new file mode 100644 index 0000000..9259259 --- /dev/null +++ b/app/src/main/aidl/com/onyx/android/sdk/hwr/service/HWROutputCallback.aidl @@ -0,0 +1,7 @@ +package com.onyx.android.sdk.hwr.service; + +import com.onyx.android.sdk.hwr.service.HWROutputArgs; + +interface HWROutputCallback { + void read(in HWROutputArgs args); +} diff --git a/app/src/main/aidl/com/onyx/android/sdk/hwr/service/IHWRService.aidl b/app/src/main/aidl/com/onyx/android/sdk/hwr/service/IHWRService.aidl new file mode 100644 index 0000000..b3d9dea --- /dev/null +++ b/app/src/main/aidl/com/onyx/android/sdk/hwr/service/IHWRService.aidl @@ -0,0 +1,16 @@ +package com.onyx.android.sdk.hwr.service; + +import android.os.ParcelFileDescriptor; +import com.onyx.android.sdk.hwr.service.HWROutputCallback; +import com.onyx.android.sdk.hwr.service.HWRInputArgs; +import com.onyx.android.sdk.hwr.service.HWRCommandArgs; + +// Method order determines transaction codes — must match the service exactly. +oneway interface IHWRService { + void init(in HWRInputArgs args, boolean forceReinit, HWROutputCallback callback); + void compileRecognizeText(String text, String language, HWROutputCallback callback); + void batchRecognize(in ParcelFileDescriptor pfd, HWROutputCallback callback); + void openIncrementalRecognizer(in HWRInputArgs args, HWROutputCallback callback); + void execCommand(in HWRInputArgs args, in HWRCommandArgs cmdArgs, HWROutputCallback callback); + void closeRecognizer(); +} diff --git a/app/src/main/java/com/onyx/android/sdk/hwr/service/HWRCommandArgs.kt b/app/src/main/java/com/onyx/android/sdk/hwr/service/HWRCommandArgs.kt new file mode 100644 index 0000000..ce6546f --- /dev/null +++ b/app/src/main/java/com/onyx/android/sdk/hwr/service/HWRCommandArgs.kt @@ -0,0 +1,17 @@ +package com.onyx.android.sdk.hwr.service + +import android.os.Parcel +import android.os.Parcelable + +/** Stub parcelable required by AIDL — not used in batchRecognize path. */ +class HWRCommandArgs() : Parcelable { + constructor(parcel: Parcel) : this() + + override fun writeToParcel(parcel: Parcel, flags: Int) {} + override fun describeContents(): Int = 0 + + companion object CREATOR : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel) = HWRCommandArgs(parcel) + override fun newArray(size: Int) = arrayOfNulls(size) + } +} diff --git a/app/src/main/java/com/onyx/android/sdk/hwr/service/HWRInputArgs.kt b/app/src/main/java/com/onyx/android/sdk/hwr/service/HWRInputArgs.kt new file mode 100644 index 0000000..7fdb66a --- /dev/null +++ b/app/src/main/java/com/onyx/android/sdk/hwr/service/HWRInputArgs.kt @@ -0,0 +1,76 @@ +package com.onyx.android.sdk.hwr.service + +import android.os.Parcel +import android.os.ParcelFileDescriptor +import android.os.Parcelable + +/** + * Parcelable matching the Boox ksync service's HWRInputArgs. + * Field order must match the service's classloader expectations exactly. + */ +class HWRInputArgs() : Parcelable { + var lang: String = "en_US" + var contentType: String = "Text" + var recognizerType: String = "Text" + var viewWidth: Float = 0f + var viewHeight: Float = 0f + var offsetX: Float = 0f + var offsetY: Float = 0f + var isGestureEnable: Boolean = false + var isTextEnable: Boolean = true + var isShapeEnable: Boolean = false + var isIncremental: Boolean = false + + var pfd: ParcelFileDescriptor? = null + var content: String? = null + + constructor(parcel: Parcel) : this() { + val className = parcel.readString() + if (className != null) { + lang = parcel.readString() ?: "en_US" + contentType = parcel.readString() ?: "Text" + recognizerType = parcel.readString() ?: "Text" + viewWidth = parcel.readFloat() + viewHeight = parcel.readFloat() + offsetX = parcel.readFloat() + offsetY = parcel.readFloat() + isGestureEnable = parcel.readByte() != 0.toByte() + isTextEnable = parcel.readByte() != 0.toByte() + isShapeEnable = parcel.readByte() != 0.toByte() + isIncremental = parcel.readByte() != 0.toByte() + pfd = parcel.readParcelable(ParcelFileDescriptor::class.java.classLoader, ParcelFileDescriptor::class.java) + content = parcel.readString() + } + } + + override fun writeToParcel(parcel: Parcel, flags: Int) { + parcel.writeString("com.onyx.android.sdk.hwr.bean.HWRInputData") + parcel.writeString(lang) + parcel.writeString(contentType) + parcel.writeString(recognizerType) + parcel.writeFloat(viewWidth) + parcel.writeFloat(viewHeight) + parcel.writeFloat(offsetX) + parcel.writeFloat(offsetY) + parcel.writeByte(if (isGestureEnable) 1 else 0) + parcel.writeByte(if (isTextEnable) 1 else 0) + parcel.writeByte(if (isShapeEnable) 1 else 0) + parcel.writeByte(if (isIncremental) 1 else 0) + val localPfd = pfd + if (localPfd != null) { + parcel.writeString("android.os.ParcelFileDescriptor") + localPfd.writeToParcel(parcel, flags) + } else { + parcel.writeString(null) + } + parcel.writeString(content) + } + + override fun describeContents(): Int = + if (pfd != null) Parcelable.CONTENTS_FILE_DESCRIPTOR else 0 + + companion object CREATOR : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel) = HWRInputArgs(parcel) + override fun newArray(size: Int) = arrayOfNulls(size) + } +} diff --git a/app/src/main/java/com/onyx/android/sdk/hwr/service/HWROutputArgs.kt b/app/src/main/java/com/onyx/android/sdk/hwr/service/HWROutputArgs.kt new file mode 100644 index 0000000..8251f92 --- /dev/null +++ b/app/src/main/java/com/onyx/android/sdk/hwr/service/HWROutputArgs.kt @@ -0,0 +1,47 @@ +package com.onyx.android.sdk.hwr.service + +import android.os.Parcel +import android.os.ParcelFileDescriptor +import android.os.Parcelable + +/** + * Parcelable matching the Boox KHwrService output format. + * Field order must match the service's writeToParcel exactly. + */ +class HWROutputArgs() : Parcelable { + var pfd: ParcelFileDescriptor? = null + var recognizerActivated: Boolean = false + var compileSuccess: Boolean = false + var hwrResult: String? = null + var gesture: String? = null + var outputType: Int = 0 + var itemIdMap: String? = null + + constructor(parcel: Parcel) : this() { + pfd = parcel.readParcelable(ParcelFileDescriptor::class.java.classLoader, ParcelFileDescriptor::class.java) + recognizerActivated = parcel.readByte() != 0.toByte() + compileSuccess = parcel.readByte() != 0.toByte() + hwrResult = parcel.readString() + gesture = parcel.readString() + outputType = parcel.readInt() + itemIdMap = parcel.readString() + } + + override fun writeToParcel(parcel: Parcel, flags: Int) { + parcel.writeParcelable(pfd, flags) + parcel.writeByte(if (recognizerActivated) 1 else 0) + parcel.writeByte(if (compileSuccess) 1 else 0) + parcel.writeString(hwrResult) + parcel.writeString(gesture) + parcel.writeInt(outputType) + parcel.writeString(itemIdMap) + } + + override fun describeContents(): Int = + if (pfd != null) Parcelable.CONTENTS_FILE_DESCRIPTOR else 0 + + companion object CREATOR : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel) = HWROutputArgs(parcel) + override fun newArray(size: Int) = arrayOfNulls(size) + } +} diff --git a/app/src/main/java/com/writer/recognition/HandwritingRecognizer.kt b/app/src/main/java/com/writer/recognition/GoogleMLKitTextRecognizer.kt similarity index 83% rename from app/src/main/java/com/writer/recognition/HandwritingRecognizer.kt rename to app/src/main/java/com/writer/recognition/GoogleMLKitTextRecognizer.kt index e5604b4..46f5950 100644 --- a/app/src/main/java/com/writer/recognition/HandwritingRecognizer.kt +++ b/app/src/main/java/com/writer/recognition/GoogleMLKitTextRecognizer.kt @@ -10,17 +10,21 @@ import com.google.mlkit.vision.digitalink.recognition.WritingArea import com.writer.model.InkLine import kotlinx.coroutines.tasks.await -class HandwritingRecognizer { +/** + * Google ML Kit Digital Ink recognition engine. + * Works on all Android devices. Downloads language models on first use. + */ +class GoogleMLKitTextRecognizer : TextRecognizer { companion object { - private const val TAG = "HandwritingRecognizer" + private const val TAG = "GoogleMLKitTextRecognizer" private const val PRE_CONTEXT_LENGTH = 20 } private var recognizer: DigitalInkRecognizer? = null private val modelManager = ModelManager() - suspend fun initialize(languageTag: String) { + override suspend fun initialize(languageTag: String) { val model = modelManager.ensureModelDownloaded(languageTag) recognizer = DigitalInkRecognition.getClient( DigitalInkRecognizerOptions.builder(model).build() @@ -28,7 +32,7 @@ class HandwritingRecognizer { Log.i(TAG, "Recognizer initialized for $languageTag") } - suspend fun recognizeLine(line: InkLine, preContext: String = ""): String { + override suspend fun recognizeLine(line: InkLine, preContext: String): String { val rec = recognizer ?: throw IllegalStateException("Recognizer not initialized") val inkBuilder = Ink.builder() @@ -54,7 +58,7 @@ class HandwritingRecognizer { return text } - fun close() { + override fun close() { recognizer?.close() recognizer = null } diff --git a/app/src/main/java/com/writer/recognition/HwrProtobuf.kt b/app/src/main/java/com/writer/recognition/HwrProtobuf.kt new file mode 100644 index 0000000..7895c21 --- /dev/null +++ b/app/src/main/java/com/writer/recognition/HwrProtobuf.kt @@ -0,0 +1,139 @@ +package com.writer.recognition + +import android.util.Log +import com.writer.model.InkLine +import org.json.JSONObject +import java.io.ByteArrayOutputStream + +/** + * Hand-rolled protobuf encoding for the Boox MyScript HWR service. + * No protobuf library dependency — encodes directly to the wire format + * expected by `com.onyx.android.ksync.service.KHwrService.batchRecognize()`. + * + * Extracted from the recognition engine for testability. + */ +object HwrProtobuf { + + /** + * Build the top-level HWRInputProto protobuf bytes from an [InkLine]. + * + * Field numbers (from HWRInputDataProto.HWRInputProto): + * 1: lang (string), 2: contentType (string), 4: recognizerType (string), + * 5: viewWidth (float), 6: viewHeight (float), + * 10: recognizeText (bool), 15: repeated pointerEvents + */ + fun buildProtobuf( + line: InkLine, viewWidth: Float, viewHeight: Float, lang: String = "en_US" + ): ByteArray { + val out = ByteArrayOutputStream() + + writeTag(out, 1, 2); writeString(out, lang) + writeTag(out, 2, 2); writeString(out, "Text") + writeTag(out, 4, 2); writeString(out, "MS_ON_SCREEN") + writeTag(out, 5, 5); writeFixed32(out, viewWidth) + writeTag(out, 6, 5); writeFixed32(out, viewHeight) + writeTag(out, 10, 0); writeVarint(out, 1) // recognizeText = true + + val pointerBuf = ByteArrayOutputStream(64) + for (stroke in line.strokes) { + val points = stroke.points + if (points.isEmpty()) continue + + for ((i, point) in points.withIndex()) { + val isFirst = i == 0 + val isLast = i == points.size - 1 + val eventTypes = when { + isFirst && isLast -> listOf(0, 2) // single-point: DOWN then UP + isFirst -> listOf(0) // DOWN + isLast -> listOf(2) // UP + else -> listOf(1) // MOVE + } + for (eventType in eventTypes) { + val pointerBytes = encodePointerProto( + point.x, point.y, point.timestamp, point.pressure, + pointerId = 0, eventType = eventType, pointerType = 0, + reuse = pointerBuf + ) + writeTag(out, 15, 2) + writeBytes(out, pointerBytes) + } + } + } + return out.toByteArray() + } + + /** + * Encode a single HWRPointerProto message. + * Fields: float x(1), float y(2), sint64 t(3), float f(4), + * sint32 pointerId(5), enum eventType(6), enum pointerType(7) + */ + internal fun encodePointerProto( + x: Float, y: Float, t: Long, f: Float, + pointerId: Int, eventType: Int, pointerType: Int, + reuse: ByteArrayOutputStream? = null + ): ByteArray { + val out = reuse?.apply { reset() } ?: ByteArrayOutputStream(64) + writeTag(out, 1, 5); writeFixed32(out, x) + writeTag(out, 2, 5); writeFixed32(out, y) + writeTag(out, 3, 0); writeVarint(out, (t shl 1) xor (t shr 63)) + writeTag(out, 4, 5); writeFixed32(out, f) + writeTag(out, 5, 0); writeVarint(out, ((pointerId shl 1) xor (pointerId shr 31)).toLong()) + writeTag(out, 6, 0); writeVarint(out, eventType.toLong()) + writeTag(out, 7, 0); writeVarint(out, pointerType.toLong()) + return out.toByteArray() + } + + // --- Protobuf primitives --- + + internal fun writeTag(out: ByteArrayOutputStream, fieldNumber: Int, wireType: Int) { + writeVarint(out, ((fieldNumber shl 3) or wireType).toLong()) + } + + internal fun writeVarint(out: ByteArrayOutputStream, value: Long) { + var v = value + while (v and 0x7FL.inv() != 0L) { + out.write(((v.toInt() and 0x7F) or 0x80)) + v = v ushr 7 + } + out.write(v.toInt() and 0x7F) + } + + internal fun writeFixed32(out: ByteArrayOutputStream, value: Float) { + val bits = java.lang.Float.floatToIntBits(value) + out.write(bits and 0xFF) + out.write((bits shr 8) and 0xFF) + out.write((bits shr 16) and 0xFF) + out.write((bits shr 24) and 0xFF) + } + + internal fun writeString(out: ByteArrayOutputStream, value: String) { + val bytes = value.toByteArray(Charsets.UTF_8) + writeVarint(out, bytes.size.toLong()) + out.write(bytes) + } + + internal fun writeBytes(out: ByteArrayOutputStream, bytes: ByteArray) { + writeVarint(out, bytes.size.toLong()) + out.write(bytes) + } + + // --- Result parsing --- + + /** + * Parse the JSON result from the HWR service. + * Success: `{"result":{"label":"recognized text"}}` + * Error: `{"exception":{"cause":{"message":"..."}}}` → returns empty string + */ + fun parseHwrResult(json: String): String { + return try { + val obj = JSONObject(json) + if (obj.has("exception")) return "" + val result = obj.optJSONObject("result") + if (result != null) return result.optString("label", "") + obj.optString("label", "") + } catch (e: Exception) { + Log.w("HwrProtobuf", "Failed to parse HWR result: ${e.message}") + "" + } + } +} diff --git a/app/src/main/java/com/writer/recognition/OnyxHwrTextRecognizer.kt b/app/src/main/java/com/writer/recognition/OnyxHwrTextRecognizer.kt new file mode 100644 index 0000000..76632e0 --- /dev/null +++ b/app/src/main/java/com/writer/recognition/OnyxHwrTextRecognizer.kt @@ -0,0 +1,226 @@ +package com.writer.recognition + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.os.IBinder +import android.os.ParcelFileDescriptor +import android.util.Log +import com.onyx.android.sdk.hwr.service.HWRInputArgs +import com.onyx.android.sdk.hwr.service.HWROutputArgs +import com.onyx.android.sdk.hwr.service.HWROutputCallback +import com.onyx.android.sdk.hwr.service.IHWRService +import com.writer.model.InkLine +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import java.io.FileInputStream +import java.io.FileOutputStream +import kotlin.coroutines.resume + +/** + * Handwriting recognition using the Boox firmware's built-in MyScript engine. + * Communicates via AIDL IPC with `com.onyx.android.ksync.service.KHwrService`. + * + * Each activity should create its own instance via [TextRecognizerFactory.create] + * and close it in `onDestroy`. + * + * Based on the approach used by [Notable](https://github.com/jshph/notable). + */ +class OnyxHwrTextRecognizer(private val context: Context) : TextRecognizer { + + companion object { + private const val TAG = "OnyxHwrTextRecognizer" + private const val SERVICE_PACKAGE = "com.onyx.android.ksync" + private const val SERVICE_CLASS = "com.onyx.android.ksync.service.KHwrService" + private const val BIND_TIMEOUT_MS = 3000L + private const val RECOGNIZE_TIMEOUT_MS = 10_000L + } + + @Volatile private var service: IHWRService? = null + @Volatile private var bound = false + @Volatile private var initialized = false + private var connectDeferred = CompletableDeferred() + private val initMutex = Mutex() + private var currentLang = "en_US" + + private val connection = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName?, binder: IBinder?) { + service = IHWRService.Stub.asInterface(binder) + bound = true + Log.i(TAG, "HWR service connected") + connectDeferred.complete(Unit) + } + + override fun onServiceDisconnected(name: ComponentName?) { + service = null + bound = false + initialized = false + Log.w(TAG, "HWR service disconnected") + } + } + + override suspend fun initialize(languageTag: String) { + initMutex.withLock { + if (bound && service != null) return@withLock + + connectDeferred = CompletableDeferred() + currentLang = languageTag.replace("-", "_") + val intent = Intent().apply { + component = ComponentName(SERVICE_PACKAGE, SERVICE_CLASS) + } + + val bindStarted = try { + context.bindService(intent, connection, Context.BIND_AUTO_CREATE) + } catch (e: Exception) { + Log.w(TAG, "Failed to bind HWR service: ${e.message}") + throw IllegalStateException("Onyx HWR service not available", e) + } + + if (!bindStarted) { + throw IllegalStateException("Onyx HWR service not found — is this a Boox device?") + } + + val connected = try { + withTimeoutOrNull(BIND_TIMEOUT_MS) { + connectDeferred.await() + } != null + } catch (e: kotlinx.coroutines.CancellationException) { + context.unbindService(connection) + throw e + } + if (!connected || service == null) { + context.unbindService(connection) + throw IllegalStateException("Onyx HWR service bind timed out") + } + + val svc = service!! + val inputArgs = HWRInputArgs().apply { + lang = currentLang + contentType = "Text" + recognizerType = "MS_ON_SCREEN" + viewWidth = 1000f // arbitrary default; per-recognition dimensions are in the protobuf + viewHeight = 200f + isTextEnable = true + } + + suspendCancellableCoroutine { cont -> + svc.init(inputArgs, true, object : HWROutputCallback.Stub() { + override fun read(args: HWROutputArgs?) { + initialized = args?.recognizerActivated == true + Log.i(TAG, "HWR init: activated=$initialized") + if (cont.isActive) cont.resume(Unit) + } + }) + } + + if (!initialized) { + close() + throw IllegalStateException("MyScript recognizer failed to activate") + } + Log.i(TAG, "Recognizer initialized for $languageTag") + } + } + + // Note: preContext is accepted by the interface but not used here — + // MyScript context is set via HWRInputArgs on init, not per-recognition. + override suspend fun recognizeLine(line: InkLine, preContext: String): String { + val svc = service ?: throw IllegalStateException("Recognizer not initialized") + if (!initialized) throw IllegalStateException("Recognizer not initialized") + if (line.strokes.isEmpty()) return "" + + val bb = line.boundingBox + val viewWidth = if (bb.width() > 0) bb.width() else 1000f + val viewHeight = if (bb.height() > 0) bb.height() else 200f + + val protoBytes = HwrProtobuf.buildProtobuf(line, viewWidth, viewHeight, currentLang) + + val pipe = try { + ParcelFileDescriptor.createPipe() + } catch (e: Exception) { + Log.e(TAG, "Failed to create pipe: ${e.message}") + return "" + } + val readPfd = pipe[0] + val writePfd = pipe[1] + + // Write pipe data concurrently so the service can drain while we write. + // This prevents deadlock when protoBytes exceeds the kernel pipe buffer (~64KB). + val writeJob = CoroutineScope(Dispatchers.IO).launch { + try { + FileOutputStream(writePfd.fileDescriptor).use { it.write(protoBytes) } + } catch (e: Exception) { + Log.e(TAG, "Pipe write failed: ${e.message}") + } finally { + writePfd.close() + } + } + + return try { + val result = withTimeoutOrNull(RECOGNIZE_TIMEOUT_MS) { + suspendCancellableCoroutine { cont -> + svc.batchRecognize(readPfd, object : HWROutputCallback.Stub() { + override fun read(args: HWROutputArgs?) { + if (!cont.isActive) return + try { + // Boox API: hwrResult is populated only on error; on success it's null and pfd carries the result + val errorJson = args?.hwrResult + if (!errorJson.isNullOrBlank()) { + Log.e(TAG, "HWR error: ${errorJson.take(300)}") + cont.resume("") + return + } + val resultPfd = args?.pfd + if (resultPfd == null) { + cont.resume("") + return + } + val json = readPfdAsString(resultPfd) + resultPfd.close() + val text = HwrProtobuf.parseHwrResult(json) + Log.d(TAG, "Recognized: \"$text\"") + cont.resume(text) + } catch (e: Exception) { + Log.e(TAG, "Error parsing HWR result: ${e.message}") + cont.resume("") + } + } + }) + } + } + result ?: "" + } finally { + writeJob.cancel() + readPfd.close() + } + } + + override fun close() { + if (!bound) return + try { + // closeRecognizer() is oneway (fire-and-forget); unbindService follows immediately + // so the remote recognizer may not process the close before the transport tears down + service?.closeRecognizer() + context.unbindService(connection) + } catch (e: Exception) { + Log.w(TAG, "Error closing HWR service: ${e.message}") + } + bound = false + service = null + initialized = false + } + + private fun readPfdAsString(pfd: ParcelFileDescriptor): String { + return FileInputStream(pfd.fileDescriptor).use { input -> + input.readBytes().toString(Charsets.UTF_8) + } + } + +} diff --git a/app/src/main/java/com/writer/recognition/StrokeDownsampler.kt b/app/src/main/java/com/writer/recognition/StrokeDownsampler.kt new file mode 100644 index 0000000..bff09db --- /dev/null +++ b/app/src/main/java/com/writer/recognition/StrokeDownsampler.kt @@ -0,0 +1,89 @@ +package com.writer.recognition + +import com.writer.model.InkStroke +import com.writer.model.StrokePoint +import kotlin.math.abs +import kotlin.math.sqrt + +object StrokeDownsampler { + + private const val DWELL_EPSILON = 0.5f // sub-pixel threshold for dwell collapse + + /** + * Collapse runs of nearly-identical (x,y) positions to first + last point. + * Handles pen-down dwell where the device reports many points at the same location. + */ + fun collapseIdenticalPositions(points: List): List { + if (points.size <= 1) return points + val result = mutableListOf() + var runStart = 0 + for (i in 1..points.size) { + val samePos = i < points.size && + abs(points[i].x - points[runStart].x) < DWELL_EPSILON && + abs(points[i].y - points[runStart].y) < DWELL_EPSILON + if (!samePos) { + result.add(points[runStart]) + if (i - 1 > runStart) { + result.add(points[i - 1]) + } + runStart = i + } + } + return result + } + + /** + * Ramer-Douglas-Peucker simplification on (x,y). + * Preserves pressure and timestamp of surviving points. + */ + fun rdp(points: List, epsilon: Float): List { + if (points.size <= 2) return points + + // Find the point with the maximum distance from the line (first, last) + val first = points.first() + val last = points.last() + var maxDist = 0f + var maxIdx = 0 + for (i in 1 until points.size - 1) { + val d = perpendicularDistance(points[i], first, last) + if (d > maxDist) { + maxDist = d + maxIdx = i + } + } + + return if (maxDist > epsilon) { + val left = rdp(points.subList(0, maxIdx + 1), epsilon) + val right = rdp(points.subList(maxIdx, points.size), epsilon) + left.dropLast(1) + right + } else { + listOf(first, last) + } + } + + /** + * Full downsampling pipeline: collapse dwells then RDP simplify. + */ + fun downsample(stroke: InkStroke, epsilon: Float = 0.5f): InkStroke { + val collapsed = collapseIdenticalPositions(stroke.points) + val simplified = rdp(collapsed, epsilon) + return stroke.copy(points = simplified) + } + + private fun perpendicularDistance( + point: StrokePoint, + lineStart: StrokePoint, + lineEnd: StrokePoint + ): Float { + val dx = lineEnd.x - lineStart.x + val dy = lineEnd.y - lineStart.y + val lengthSq = dx * dx + dy * dy + if (lengthSq == 0f) { + val px = point.x - lineStart.x + val py = point.y - lineStart.y + return sqrt(px * px + py * py) + } + val num = abs(dy * point.x - dx * point.y + lineEnd.x * lineStart.y - lineEnd.y * lineStart.x) + return num / sqrt(lengthSq) + } +} diff --git a/app/src/main/java/com/writer/recognition/TextRecognizer.kt b/app/src/main/java/com/writer/recognition/TextRecognizer.kt new file mode 100644 index 0000000..5236ccc --- /dev/null +++ b/app/src/main/java/com/writer/recognition/TextRecognizer.kt @@ -0,0 +1,35 @@ +package com.writer.recognition + +import com.writer.model.InkLine + +/** + * Abstraction for handwriting-to-text recognition engines. + * + * Implementations: + * - [GoogleMLKitTextRecognizer] — Google ML Kit Digital Ink (bundled model, works on all devices) + * - [OnyxHwrTextRecognizer] — Boox firmware's built-in MyScript engine via AIDL IPC + * (Onyx Boox devices only, significantly better accuracy) + */ +interface TextRecognizer { + + /** + * Initialize the recognition engine. Must be called before [recognizeLine]. + * May download models, bind to services, etc. + * + * @param languageTag BCP-47 language tag, e.g. "en-US" + * @throws Exception if initialization fails + */ + suspend fun initialize(languageTag: String) + + /** + * Recognize a single line of handwriting. + * + * @param line the strokes and bounding box for one line + * @param preContext up to 20 characters of preceding text for language model context + * @return recognized text (trimmed), or empty string if recognition fails + */ + suspend fun recognizeLine(line: InkLine, preContext: String = ""): String + + /** Release resources (models, service bindings, etc.). */ + fun close() +} diff --git a/app/src/main/java/com/writer/recognition/TextRecognizerFactory.kt b/app/src/main/java/com/writer/recognition/TextRecognizerFactory.kt new file mode 100644 index 0000000..800a064 --- /dev/null +++ b/app/src/main/java/com/writer/recognition/TextRecognizerFactory.kt @@ -0,0 +1,31 @@ +package com.writer.recognition + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build + +object TextRecognizerFactory { + fun create(context: Context): TextRecognizer { + if (isOnyxHwrAvailable(context)) return OnyxHwrTextRecognizer(context.applicationContext) + return GoogleMLKitTextRecognizer() + } + + @Suppress("DEPRECATION") + private fun isOnyxHwrAvailable(context: Context): Boolean { + val intent = Intent().apply { + component = ComponentName( + "com.onyx.android.ksync", + "com.onyx.android.ksync.service.KHwrService" + ) + } + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + context.packageManager.resolveService( + intent, PackageManager.ResolveInfoFlags.of(0) + ) + } else { + context.packageManager.resolveService(intent, 0) + } != null + } +} diff --git a/app/src/main/java/com/writer/ui/writing/SaveAsActivity.kt b/app/src/main/java/com/writer/ui/writing/SaveAsActivity.kt index 42f1065..cc2c0b0 100644 --- a/app/src/main/java/com/writer/ui/writing/SaveAsActivity.kt +++ b/app/src/main/java/com/writer/ui/writing/SaveAsActivity.kt @@ -1,7 +1,6 @@ package com.writer.ui.writing import android.content.Intent -import android.graphics.RectF import android.os.Bundle import android.util.Log import android.widget.TextView @@ -13,7 +12,7 @@ import com.writer.model.InkLine import com.writer.model.InkStroke import com.writer.model.minX import com.writer.model.maxX -import com.writer.recognition.HandwritingRecognizer +import com.writer.recognition.TextRecognizerFactory import com.writer.view.HandwritingNameInput import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -30,8 +29,7 @@ class SaveAsActivity : AppCompatActivity() { private lateinit var nameDisplay: TextView private lateinit var handwritingInput: HandwritingNameInput - private val recognizer = HandwritingRecognizer() - private var recognizerReady = false + private var recognizer: com.writer.recognition.TextRecognizer? = null private val allStrokes = mutableListOf() private var currentName = "" @@ -39,6 +37,18 @@ class SaveAsActivity : AppCompatActivity() { super.onCreate(savedInstanceState) setContentView(R.layout.activity_save_as) + lifecycleScope.launch { + val rec = TextRecognizerFactory.create(this@SaveAsActivity) + try { + rec.initialize("en-US") + recognizer = rec + } catch (e: Exception) { + rec.close() + Log.w(TAG, "Recognizer init failed", e) + Toast.makeText(this@SaveAsActivity, "Handwriting recognition unavailable", Toast.LENGTH_SHORT).show() + } + } + nameDisplay = findViewById(R.id.nameDisplay) handwritingInput = findViewById(R.id.handwritingInput) @@ -47,17 +57,6 @@ class SaveAsActivity : AppCompatActivity() { nameDisplay.text = currentName handwritingInput.placeholderText = currentName - // Initialize recognizer - lifecycleScope.launch { - try { - recognizer.initialize("en-US") - recognizerReady = true - } catch (e: Exception) { - Log.e(TAG, "Failed to init recognizer", e) - Toast.makeText(this@SaveAsActivity, "Recognition unavailable", Toast.LENGTH_SHORT).show() - } - } - // Handle stroke completion handwritingInput.onStrokeCompleted = { stroke -> onStrokeCompleted(stroke) @@ -122,7 +121,7 @@ class SaveAsActivity : AppCompatActivity() { } private fun recognizeAll() { - if (!recognizerReady) return + val rec = recognizer ?: return if (allStrokes.isEmpty()) { currentName = intent.getStringExtra(EXTRA_CURRENT_NAME) ?: "" nameDisplay.text = currentName @@ -136,7 +135,7 @@ class SaveAsActivity : AppCompatActivity() { lifecycleScope.launch { try { val text = withContext(Dispatchers.IO) { - recognizer.recognizeLine(line) + rec.recognizeLine(line) } currentName = text.trim() nameDisplay.text = currentName @@ -148,6 +147,6 @@ class SaveAsActivity : AppCompatActivity() { override fun onDestroy() { super.onDestroy() - recognizer.close() + recognizer?.close() } } diff --git a/app/src/main/java/com/writer/ui/writing/WritingActivity.kt b/app/src/main/java/com/writer/ui/writing/WritingActivity.kt index fb12b39..371263b 100644 --- a/app/src/main/java/com/writer/ui/writing/WritingActivity.kt +++ b/app/src/main/java/com/writer/ui/writing/WritingActivity.kt @@ -19,7 +19,8 @@ import androidx.core.view.WindowCompat import androidx.lifecycle.lifecycleScope import com.writer.R import com.writer.model.DocumentModel -import com.writer.recognition.HandwritingRecognizer +import com.writer.recognition.TextRecognizer +import com.writer.recognition.TextRecognizerFactory import com.writer.model.DocumentData import com.writer.storage.DocumentStorage import com.writer.view.HandwritingCanvasView @@ -39,7 +40,7 @@ class WritingActivity : AppCompatActivity() { private lateinit var recognizedTextView: RecognizedTextView private lateinit var documentModel: DocumentModel - private lateinit var recognizer: HandwritingRecognizer + private lateinit var recognizer: TextRecognizer private var coordinator: WritingCoordinator? = null private lateinit var tutorialManager: TutorialManager @@ -59,7 +60,8 @@ class WritingActivity : AppCompatActivity() { ActivityResultContracts.StartActivityForResult() ) { result -> if (result.resultCode == Activity.RESULT_OK) { - val name = result.data?.getStringExtra(SaveAsActivity.EXTRA_RESULT_NAME) + val rawName = result.data?.getStringExtra(SaveAsActivity.EXTRA_RESULT_NAME) + val name = rawName?.let { DocumentStorage.headingToFileName(it) } if (!name.isNullOrBlank() && name != currentDocumentName) { val oldName = currentDocumentName currentDocumentName = name @@ -108,7 +110,6 @@ class WritingActivity : AppCompatActivity() { recognizedTextView = findViewById(R.id.recognizedTextView) documentModel = DocumentModel() - recognizer = HandwritingRecognizer() tutorialManager = TutorialManager( context = this, @@ -135,6 +136,9 @@ class WritingActivity : AppCompatActivity() { // Tap "I" logo to open menu recognizedTextView.onLogoTap = { showMenu() } + // Pick the best available recognizer synchronously (initialized later in coroutine) + recognizer = TextRecognizerFactory.create(this) + // Create coordinator early so cached text can be displayed before model loads startCoordinator() diff --git a/app/src/main/java/com/writer/ui/writing/WritingCoordinator.kt b/app/src/main/java/com/writer/ui/writing/WritingCoordinator.kt index 9c94daa..0360dcf 100644 --- a/app/src/main/java/com/writer/ui/writing/WritingCoordinator.kt +++ b/app/src/main/java/com/writer/ui/writing/WritingCoordinator.kt @@ -5,7 +5,7 @@ import com.writer.model.DiagramArea import com.writer.model.DocumentModel import com.writer.model.InkStroke import com.writer.model.shiftY -import com.writer.recognition.HandwritingRecognizer +import com.writer.recognition.TextRecognizer import com.writer.recognition.LineSegmenter import com.writer.recognition.StrokeClassifier import com.writer.model.DocumentData @@ -21,7 +21,7 @@ import kotlinx.coroutines.withContext class WritingCoordinator( private val documentModel: DocumentModel, - private val recognizer: HandwritingRecognizer, + private val recognizer: TextRecognizer, private val inkCanvas: HandwritingCanvasView, private val textView: RecognizedTextView, private val scope: CoroutineScope, @@ -427,27 +427,14 @@ class WritingCoordinator( updateTextView(notYetVisible) updateTextScrollOffset() - val uncached = everHiddenLines.filter { !lineTextCache.containsKey(it) && !isDiagramLine(it) } + val uncached = everHiddenLines.filter { !lineTextCache.containsKey(it) && !isDiagramLine(it) && !recognizingLines.contains(it) } if (uncached.isNotEmpty()) { + for (lineIdx in uncached) { + recognizingLines.add(lineIdx) + } scope.launch { for (lineIdx in uncached) { - if (lineTextCache.containsKey(lineIdx)) continue - if (isDiagramLine(lineIdx)) continue - try { - val allStrokes = strokesByLine[lineIdx] ?: continue - val strokes = strokeClassifier.filterMarkerStrokes(allStrokes, inkCanvas.width - GUTTER_WIDTH) - if (strokes.isEmpty()) continue - val line = lineSegmenter.buildInkLine(strokes, lineIdx) - val preContext = buildPreContext(lineIdx) - val text = withContext(Dispatchers.IO) { - recognizer.recognizeLine(line, preContext) - } - lineTextCache[lineIdx] = text.trim() - Log.d(TAG, "On-scroll recognized line $lineIdx: \"${text.trim()}\"") - } catch (e: Exception) { - Log.e(TAG, "Recognition failed for line $lineIdx", e) - lineTextCache[lineIdx] = "[?]" - } + doRecognizeLine(lineIdx) } val stillNotVisible = strokesByLine.keys.filter { lineIdx -> val lineMid = lineSegmenter.getLineY(lineIdx) + HandwritingCanvasView.LINE_SPACING / 2f diff --git a/app/src/test/java/com/onyx/android/sdk/hwr/service/HWRInputArgsTest.kt b/app/src/test/java/com/onyx/android/sdk/hwr/service/HWRInputArgsTest.kt new file mode 100644 index 0000000..9d8c8b1 --- /dev/null +++ b/app/src/test/java/com/onyx/android/sdk/hwr/service/HWRInputArgsTest.kt @@ -0,0 +1,79 @@ +package com.onyx.android.sdk.hwr.service + +import android.app.Application +import android.os.Parcel +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class) +class HWRInputArgsTest { + + @Test + fun parcelRoundTrip_preservesAllFields() { + val original = HWRInputArgs().apply { + lang = "fr_FR" + contentType = "Math" + recognizerType = "MS_ON_SCREEN" + viewWidth = 1920f + viewHeight = 1080f + offsetX = 10f + offsetY = 20f + isGestureEnable = true + isTextEnable = false + isShapeEnable = true + isIncremental = true + content = "some content" + } + + val parcel = Parcel.obtain() + try { + original.writeToParcel(parcel, 0) + parcel.setDataPosition(0) + + val restored = HWRInputArgs(parcel) + + assertEquals(original.lang, restored.lang) + assertEquals(original.contentType, restored.contentType) + assertEquals(original.recognizerType, restored.recognizerType) + assertEquals(original.viewWidth, restored.viewWidth, 0f) + assertEquals(original.viewHeight, restored.viewHeight, 0f) + assertEquals(original.offsetX, restored.offsetX, 0f) + assertEquals(original.offsetY, restored.offsetY, 0f) + assertEquals(original.isGestureEnable, restored.isGestureEnable) + assertEquals(original.isTextEnable, restored.isTextEnable) + assertEquals(original.isShapeEnable, restored.isShapeEnable) + assertEquals(original.isIncremental, restored.isIncremental) + assertEquals(original.content, restored.content) + } finally { + parcel.recycle() + } + } + + @Test + fun parcelRoundTrip_defaultValues() { + val original = HWRInputArgs() + + val parcel = Parcel.obtain() + try { + original.writeToParcel(parcel, 0) + parcel.setDataPosition(0) + + val restored = HWRInputArgs(parcel) + + assertEquals("en_US", restored.lang) + assertEquals("Text", restored.contentType) + assertEquals("Text", restored.recognizerType) + assertEquals(0f, restored.viewWidth, 0f) + assertEquals(0f, restored.viewHeight, 0f) + assertNull(restored.pfd) + assertNull(restored.content) + } finally { + parcel.recycle() + } + } +} diff --git a/app/src/test/java/com/writer/recognition/HwrProtobufTest.kt b/app/src/test/java/com/writer/recognition/HwrProtobufTest.kt new file mode 100644 index 0000000..e1b8b96 --- /dev/null +++ b/app/src/test/java/com/writer/recognition/HwrProtobufTest.kt @@ -0,0 +1,355 @@ +package com.writer.recognition + +import android.graphics.RectF +import com.writer.model.InkLine +import com.writer.model.InkStroke +import com.writer.model.StrokePoint +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.ByteArrayInputStream +import java.nio.ByteBuffer +import java.nio.ByteOrder + +/** + * Tests for [HwrProtobuf] — protobuf encoding for the Boox MyScript HWR service + * and JSON result parsing. + */ +class HwrProtobufTest { + + // ── Protobuf encoding ──────────────────────────────────────────────────── + + @Test fun protobuf_containsLanguageField() { + val line = singlePointLine(100f, 200f) + val bytes = HwrProtobuf.buildProtobuf(line, 1000f, 500f) + val fields = parseProtobuf(bytes) + // Field 1 (lang) should be "en_US" + val lang = fields.firstOrNull { it.fieldNumber == 1 } + assertEquals("en_US", lang?.stringValue) + } + + @Test fun protobuf_containsContentType() { + val line = singlePointLine(100f, 200f) + val bytes = HwrProtobuf.buildProtobuf(line, 1000f, 500f) + val fields = parseProtobuf(bytes) + val contentType = fields.firstOrNull { it.fieldNumber == 2 } + assertEquals("Text", contentType?.stringValue) + } + + @Test fun protobuf_containsRecognizerType() { + val line = singlePointLine(100f, 200f) + val bytes = HwrProtobuf.buildProtobuf(line, 1000f, 500f) + val fields = parseProtobuf(bytes) + val recognizerType = fields.firstOrNull { it.fieldNumber == 4 } + assertEquals("MS_ON_SCREEN", recognizerType?.stringValue) + } + + @Test fun protobuf_containsViewDimensions() { + val line = singlePointLine(100f, 200f) + val bytes = HwrProtobuf.buildProtobuf(line, 1000f, 500f) + val fields = parseProtobuf(bytes) + val width = fields.firstOrNull { it.fieldNumber == 5 } + val height = fields.firstOrNull { it.fieldNumber == 6 } + assertEquals(1000f, width?.floatValue) + assertEquals(500f, height?.floatValue) + } + + @Test fun protobuf_singleStroke_producesDownAndUpEvents() { + // Single stroke with 2 points → DOWN then UP + val stroke = InkStroke( + strokeId = "s1", + points = listOf( + StrokePoint(10f, 20f, 0.5f, 1000L), + StrokePoint(30f, 40f, 0.6f, 1010L) + ) + ) + val line = InkLine(listOf(stroke), RectF(10f, 20f, 30f, 40f)) + val bytes = HwrProtobuf.buildProtobuf(line, 100f, 100f) + val fields = parseProtobuf(bytes) + + // Field 15 = pointer events (length-delimited) + val pointerEvents = fields.filter { it.fieldNumber == 15 } + assertEquals("2 points → 2 pointer events", 2, pointerEvents.size) + + // Verify event types: first=DOWN(0), last=UP(2) + val firstEvent = parseProtobuf(pointerEvents[0].bytesValue!!) + val lastEvent = parseProtobuf(pointerEvents[1].bytesValue!!) + assertEquals("First point should be DOWN", 0L, firstEvent.first { it.fieldNumber == 6 }.varintValue) + assertEquals("Last point should be UP", 2L, lastEvent.first { it.fieldNumber == 6 }.varintValue) + } + + @Test fun protobuf_multiPointStroke_producesDownMoveUpEvents() { + // Stroke with 4 points → DOWN, MOVE, MOVE, UP + val stroke = InkStroke( + strokeId = "s1", + points = listOf( + StrokePoint(0f, 0f, 0.5f, 100L), + StrokePoint(10f, 10f, 0.5f, 110L), + StrokePoint(20f, 20f, 0.5f, 120L), + StrokePoint(30f, 30f, 0.5f, 130L) + ) + ) + val line = InkLine(listOf(stroke), RectF(0f, 0f, 30f, 30f)) + val bytes = HwrProtobuf.buildProtobuf(line, 100f, 100f) + val fields = parseProtobuf(bytes) + val pointerEvents = fields.filter { it.fieldNumber == 15 } + assertEquals("4 points → 4 pointer events", 4, pointerEvents.size) + } + + @Test fun protobuf_emptyStroke_skipped() { + val stroke = InkStroke(strokeId = "s1", points = emptyList()) + val line = InkLine(listOf(stroke), RectF()) + val bytes = HwrProtobuf.buildProtobuf(line, 100f, 100f) + val fields = parseProtobuf(bytes) + val pointerEvents = fields.filter { it.fieldNumber == 15 } + assertEquals("Empty stroke should produce no pointer events", 0, pointerEvents.size) + } + + @Test fun protobuf_multipleStrokes_allPointsEncoded() { + val stroke1 = InkStroke( + strokeId = "s1", + points = listOf( + StrokePoint(0f, 0f, 0.5f, 100L), + StrokePoint(10f, 10f, 0.5f, 110L) + ) + ) + val stroke2 = InkStroke( + strokeId = "s2", + points = listOf( + StrokePoint(20f, 0f, 0.5f, 200L), + StrokePoint(30f, 10f, 0.5f, 210L), + StrokePoint(40f, 20f, 0.5f, 220L) + ) + ) + val line = InkLine(listOf(stroke1, stroke2), RectF(0f, 0f, 40f, 20f)) + val bytes = HwrProtobuf.buildProtobuf(line, 100f, 100f) + val fields = parseProtobuf(bytes) + val pointerEvents = fields.filter { it.fieldNumber == 15 } + assertEquals("2+3 points → 5 pointer events", 5, pointerEvents.size) + } + + @Test fun protobuf_singlePointStroke_producesDownAndUpEvents() { + // Single-point stroke → must emit both DOWN and UP (regression: previously only DOWN) + val line = singlePointLine(50f, 75f) + val bytes = HwrProtobuf.buildProtobuf(line, 100f, 100f) + val fields = parseProtobuf(bytes) + val pointerEvents = fields.filter { it.fieldNumber == 15 } + assertEquals("1-point stroke → 2 pointer events (DOWN+UP)", 2, pointerEvents.size) + + val downEvent = parseProtobuf(pointerEvents[0].bytesValue!!) + val upEvent = parseProtobuf(pointerEvents[1].bytesValue!!) + assertEquals("First event should be DOWN", 0L, downEvent.first { it.fieldNumber == 6 }.varintValue) + assertEquals("Second event should be UP", 2L, upEvent.first { it.fieldNumber == 6 }.varintValue) + + // Both events should have the same coordinates + assertEquals(50f, downEvent.first { it.fieldNumber == 1 }.floatValue) + assertEquals(75f, downEvent.first { it.fieldNumber == 2 }.floatValue) + assertEquals(50f, upEvent.first { it.fieldNumber == 1 }.floatValue) + assertEquals(75f, upEvent.first { it.fieldNumber == 2 }.floatValue) + } + + @Test fun protobuf_customLanguage_isEncoded() { + val line = singlePointLine(0f, 0f) + val bytes = HwrProtobuf.buildProtobuf(line, 100f, 100f, lang = "zh_CN") + val fields = parseProtobuf(bytes) + val lang = fields.firstOrNull { it.fieldNumber == 1 } + assertEquals("zh_CN", lang?.stringValue) + } + + @Test fun protobuf_multiPointStroke_eventTypeSequence() { + // 4 points → DOWN, MOVE, MOVE, UP + val stroke = InkStroke( + strokeId = "s1", + points = listOf( + StrokePoint(0f, 0f, 0.5f, 100L), + StrokePoint(10f, 10f, 0.5f, 110L), + StrokePoint(20f, 20f, 0.5f, 120L), + StrokePoint(30f, 30f, 0.5f, 130L) + ) + ) + val line = InkLine(listOf(stroke), RectF(0f, 0f, 30f, 30f)) + val bytes = HwrProtobuf.buildProtobuf(line, 100f, 100f) + val fields = parseProtobuf(bytes) + val pointerEvents = fields.filter { it.fieldNumber == 15 } + + val eventTypes = pointerEvents.map { event -> + parseProtobuf(event.bytesValue!!).first { it.fieldNumber == 6 }.varintValue + } + assertEquals("DOWN, MOVE, MOVE, UP", listOf(0L, 1L, 1L, 2L), eventTypes) + } + + @Test fun protobuf_pointerEvent_coordinatesAndPressureRoundTrip() { + val bytes = HwrProtobuf.encodePointerProto( + x = 123.456f, y = 789.012f, t = 999L, f = 0.42f, + pointerId = 0, eventType = 1, pointerType = 0 + ) + val fields = parseProtobuf(bytes) + assertEquals(123.456f, fields.first { it.fieldNumber == 1 }.floatValue) + assertEquals(789.012f, fields.first { it.fieldNumber == 2 }.floatValue) + assertEquals(0.42f, fields.first { it.fieldNumber == 4 }.floatValue) + } + + @Test fun protobuf_outputIsNonEmpty() { + val line = singlePointLine(100f, 200f) + val bytes = HwrProtobuf.buildProtobuf(line, 1000f, 500f) + assertTrue("Protobuf output should be non-empty", bytes.isNotEmpty()) + } + + // ── Pointer event encoding ─────────────────────────────────────────────── + + @Test fun pointerProto_containsCoordinates() { + val bytes = HwrProtobuf.encodePointerProto( + x = 42.5f, y = 99.0f, t = 12345L, f = 0.7f, + pointerId = 0, eventType = 1, pointerType = 0 + ) + val fields = parseProtobuf(bytes) + assertEquals(42.5f, fields.first { it.fieldNumber == 1 }.floatValue) + assertEquals(99.0f, fields.first { it.fieldNumber == 2 }.floatValue) + } + + @Test fun pointerProto_containsPressure() { + val bytes = HwrProtobuf.encodePointerProto( + x = 0f, y = 0f, t = 0L, f = 0.85f, + pointerId = 0, eventType = 0, pointerType = 0 + ) + val fields = parseProtobuf(bytes) + assertEquals(0.85f, fields.first { it.fieldNumber == 4 }.floatValue) + } + + @Test fun pointerProto_containsEventType() { + for (eventType in listOf(0, 1, 2)) { + val bytes = HwrProtobuf.encodePointerProto( + x = 0f, y = 0f, t = 0L, f = 0.5f, + pointerId = 0, eventType = eventType, pointerType = 0 + ) + val fields = parseProtobuf(bytes) + assertEquals( + "Event type $eventType should be encoded", + eventType.toLong(), fields.first { it.fieldNumber == 6 }.varintValue + ) + } + } + + // ── JSON result parsing ────────────────────────────────────────────────── + + @Test fun parseResult_successWithLabel() { + val json = """{"result":{"label":"hello world"}}""" + assertEquals("hello world", HwrProtobuf.parseHwrResult(json)) + } + + @Test fun parseResult_topLevelLabel() { + val json = """{"label":"fallback text"}""" + assertEquals("fallback text", HwrProtobuf.parseHwrResult(json)) + } + + @Test fun parseResult_emptyResult() { + val json = """{"result":{"label":""}}""" + assertEquals("", HwrProtobuf.parseHwrResult(json)) + } + + @Test fun parseResult_exception_returnsEmpty() { + val json = """{"exception":{"cause":{"message":"recognition failed"}}}""" + assertEquals("", HwrProtobuf.parseHwrResult(json)) + } + + @Test fun parseResult_invalidJson_returnsEmpty() { + assertEquals("", HwrProtobuf.parseHwrResult("not json")) + } + + @Test fun parseResult_emptyString_returnsEmpty() { + assertEquals("", HwrProtobuf.parseHwrResult("")) + } + + @Test fun parseResult_noLabelField_returnsEmpty() { + val json = """{"result":{"other":"data"}}""" + assertEquals("", HwrProtobuf.parseHwrResult(json)) + } + + // ── Protobuf primitives ────────────────────────────────────────────────── + + @Test fun writeVarint_smallValue() { + val out = java.io.ByteArrayOutputStream() + HwrProtobuf.writeVarint(out, 1L) + assertEquals(1, out.size()) + assertEquals(1, out.toByteArray()[0].toInt()) + } + + @Test fun writeVarint_multiByteValue() { + val out = java.io.ByteArrayOutputStream() + HwrProtobuf.writeVarint(out, 300L) + // 300 = 0b100101100 → varint: 0xAC 0x02 + assertEquals(2, out.size()) + } + + @Test fun writeFixed32_encodesFloatCorrectly() { + val out = java.io.ByteArrayOutputStream() + HwrProtobuf.writeFixed32(out, 1.0f) + val expected = java.lang.Float.floatToIntBits(1.0f) + val actual = ByteBuffer.wrap(out.toByteArray()).order(ByteOrder.LITTLE_ENDIAN).int + assertEquals(expected, actual) + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + private fun singlePointLine(x: Float, y: Float): InkLine { + val stroke = InkStroke( + strokeId = "test", + points = listOf(StrokePoint(x, y, 0.5f, 1000L)) + ) + return InkLine(listOf(stroke), RectF(x, y, x, y)) + } + + /** Minimal protobuf field parser for test assertions. */ + private data class ProtoField( + val fieldNumber: Int, + val wireType: Int, + val varintValue: Long = 0, + val floatValue: Float? = null, + val stringValue: String? = null, + val bytesValue: ByteArray? = null + ) + + private fun parseProtobuf(data: ByteArray): List { + val fields = mutableListOf() + val stream = ByteArrayInputStream(data) + + while (stream.available() > 0) { + val tag = readVarint(stream) ?: break + val fieldNumber = (tag shr 3).toInt() + val wireType = (tag and 0x7).toInt() + + when (wireType) { + 0 -> { // varint + val value = readVarint(stream) ?: break + fields.add(ProtoField(fieldNumber, wireType, varintValue = value)) + } + 2 -> { // length-delimited + val len = readVarint(stream)?.toInt() ?: break + val bytes = ByteArray(len) + stream.read(bytes) + val str = try { String(bytes, Charsets.UTF_8) } catch (_: Exception) { null } + fields.add(ProtoField(fieldNumber, wireType, stringValue = str, bytesValue = bytes)) + } + 5 -> { // fixed32 + val bytes = ByteArray(4) + stream.read(bytes) + val float = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN).float + fields.add(ProtoField(fieldNumber, wireType, floatValue = float)) + } + } + } + return fields + } + + private fun readVarint(stream: ByteArrayInputStream): Long? { + var result = 0L + var shift = 0 + while (true) { + val b = stream.read() + if (b == -1) return null + result = result or ((b.toLong() and 0x7F) shl shift) + if (b and 0x80 == 0) return result + shift += 7 + } + } +} diff --git a/app/src/test/java/com/writer/recognition/OnyxHwrTextRecognizerTest.kt b/app/src/test/java/com/writer/recognition/OnyxHwrTextRecognizerTest.kt new file mode 100644 index 0000000..9dcdc93 --- /dev/null +++ b/app/src/test/java/com/writer/recognition/OnyxHwrTextRecognizerTest.kt @@ -0,0 +1,79 @@ +package com.writer.recognition + +import android.app.Application +import android.os.ParcelFileDescriptor +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.FileInputStream +import java.io.FileOutputStream + +/** + * Tests for the pipe-based IPC used by [OnyxHwrTextRecognizer] + * to pass protobuf data to the Boox HWR service via ParcelFileDescriptor. + * + * Runs under Robolectric with a plain Application to avoid WriterApplication's + * HiddenApiBypass dependency on sun.misc.Unsafe. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class) +class OnyxHwrTextRecognizerTest { + + // ── Pipe-based IPC ──────────────────────────────────────────────────────── + + @Test + fun pipe_roundTrips_data() { + val data = ByteArray(17_000) { (it % 256).toByte() } + val pipe = ParcelFileDescriptor.createPipe() + val readPfd = pipe[0] + val writePfd = pipe[1] + + FileOutputStream(writePfd.fileDescriptor).use { it.write(data) } + writePfd.close() + + val readBack = FileInputStream(readPfd.fileDescriptor).use { it.readBytes() } + readPfd.close() + + assertEquals(data.size, readBack.size) + for (i in data.indices) { + assertEquals("Byte $i", data[i], readBack[i]) + } + } + + // ── End-to-end: real protobuf through pipe ────────────────────────────── + + @Test + fun realProtobuf_throughPipe_roundTrips() { + val stroke = com.writer.model.InkStroke( + strokeId = "s1", + points = listOf( + com.writer.model.StrokePoint(100f, 200f, 0.5f, 1000L), + com.writer.model.StrokePoint(110f, 210f, 0.6f, 1010L), + com.writer.model.StrokePoint(120f, 220f, 0.7f, 1020L) + ) + ) + val line = com.writer.model.InkLine( + listOf(stroke), + android.graphics.RectF(100f, 200f, 120f, 220f) + ) + val protoBytes = HwrProtobuf.buildProtobuf(line, 1000f, 500f, "en_US") + + val pipe = ParcelFileDescriptor.createPipe() + val readPfd = pipe[0] + val writePfd = pipe[1] + + FileOutputStream(writePfd.fileDescriptor).use { it.write(protoBytes) } + writePfd.close() + + val readBack = FileInputStream(readPfd.fileDescriptor).use { it.readBytes() } + readPfd.close() + + assertEquals("Protobuf bytes should survive pipe round-trip", + protoBytes.size, readBack.size) + for (i in protoBytes.indices) { + assertEquals("Byte $i", protoBytes[i], readBack[i]) + } + } +} diff --git a/app/src/test/java/com/writer/recognition/StrokeDownsamplerTest.kt b/app/src/test/java/com/writer/recognition/StrokeDownsamplerTest.kt new file mode 100644 index 0000000..03f798f --- /dev/null +++ b/app/src/test/java/com/writer/recognition/StrokeDownsamplerTest.kt @@ -0,0 +1,90 @@ +package com.writer.recognition + +import com.writer.model.InkStroke +import com.writer.model.StrokePoint +import org.junit.Assert.assertEquals +import org.junit.Test + +class StrokeDownsamplerTest { + + private fun pt(x: Float, y: Float, pressure: Float = 100f, timestamp: Long = 0L) = + StrokePoint(x, y, pressure, timestamp) + + @Test + fun collapseIdenticalPositions_collapsesDwell() { + val points = listOf( + pt(10f, 20f, 100f, 1L), + pt(10f, 20f, 200f, 2L), + pt(10f, 20f, 300f, 3L), + pt(10f, 20f, 400f, 4L), + pt(15f, 25f, 500f, 5L), + ) + val result = StrokeDownsampler.collapseIdenticalPositions(points) + assertEquals(3, result.size) + // First point of dwell run + assertEquals(1L, result[0].timestamp) + // Last point of dwell run + assertEquals(4L, result[1].timestamp) + // Next distinct point + assertEquals(5L, result[2].timestamp) + } + + @Test + fun collapseIdenticalPositions_singlePoint() { + val points = listOf(pt(10f, 20f)) + assertEquals(1, StrokeDownsampler.collapseIdenticalPositions(points).size) + } + + @Test + fun collapseIdenticalPositions_noDuplicates() { + val points = listOf(pt(1f, 1f), pt(2f, 2f), pt(3f, 3f)) + assertEquals(3, StrokeDownsampler.collapseIdenticalPositions(points).size) + } + + @Test + fun rdp_epsilonZero_preservesAll() { + val points = listOf(pt(0f, 0f), pt(1f, 1f), pt(2f, 0f)) + val result = StrokeDownsampler.rdp(points, 0f) + assertEquals(3, result.size) + } + + @Test + fun rdp_largeEpsilon_endpointsOnly() { + val points = listOf(pt(0f, 0f), pt(1f, 0.1f), pt(2f, 0f)) + val result = StrokeDownsampler.rdp(points, 100f) + assertEquals(2, result.size) + assertEquals(0f, result[0].x, 0.001f) + assertEquals(2f, result[1].x, 0.001f) + } + + @Test + fun rdp_twoPoints_passthrough() { + val points = listOf(pt(0f, 0f), pt(10f, 10f)) + val result = StrokeDownsampler.rdp(points, 1f) + assertEquals(2, result.size) + } + + @Test + fun rdp_singlePoint_passthrough() { + val points = listOf(pt(5f, 5f)) + assertEquals(1, StrokeDownsampler.rdp(points, 1f).size) + } + + @Test + fun downsample_combinesBothPasses() { + val points = listOf( + // Dwell at start + pt(0f, 0f, 100f, 1L), + pt(0f, 0f, 200f, 2L), + pt(0f, 0f, 300f, 3L), + // Meaningful movement + pt(5f, 5f, 400f, 4L), + pt(10f, 0f, 500f, 5L), + ) + val stroke = InkStroke(strokeId = "test", points = points) + val result = StrokeDownsampler.downsample(stroke, 0.5f) + // Dwell collapsed to 2 points, then RDP keeps significant points + assert(result.points.size < points.size) + assertEquals("test", result.strokeId) + } +} diff --git a/app/src/test/java/com/writer/recognition/TextRecognizerFactoryTest.kt b/app/src/test/java/com/writer/recognition/TextRecognizerFactoryTest.kt new file mode 100644 index 0000000..72f02c5 --- /dev/null +++ b/app/src/test/java/com/writer/recognition/TextRecognizerFactoryTest.kt @@ -0,0 +1,59 @@ +package com.writer.recognition + +import android.app.Application +import android.content.ComponentName +import android.content.Intent +import android.content.pm.ResolveInfo +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class) +class TextRecognizerFactoryTest { + + @Test + fun create_withOnyxService_returnsOnyxRecognizer() { + val app = RuntimeEnvironment.getApplication() + val pm = shadowOf(app.packageManager) + val intent = Intent().apply { + component = ComponentName( + "com.onyx.android.ksync", + "com.onyx.android.ksync.service.KHwrService" + ) + } + pm.addResolveInfoForIntent(intent, ResolveInfo()) + + val recognizer = TextRecognizerFactory.create(app) + assertTrue( + "Expected OnyxHwrTextRecognizer when Onyx service is available", + recognizer is OnyxHwrTextRecognizer + ) + recognizer.close() + } + + @Test + fun create_withoutOnyxService_returnsGoogleMLKit() { + // Robolectric has no Onyx service registered — but GoogleMLKitTextRecognizer + // requires MlKitContext which isn't available in unit tests. + // Instead, verify the selection logic: without the service, isOnyxHwrAvailable returns false. + val app = RuntimeEnvironment.getApplication() + val pm = shadowOf(app.packageManager) + val intent = Intent().apply { + component = ComponentName( + "com.onyx.android.ksync", + "com.onyx.android.ksync.service.KHwrService" + ) + } + // Ensure no resolve info is registered + val resolved = app.packageManager.resolveService(intent, 0) + assertTrue( + "Onyx HWR service should not be resolvable in test environment", + resolved == null + ) + } +} From 35d60a5aff741bc16497b05219924ab0f7b26736 Mon Sep 17 00:00:00 2001 From: Ed Wei Date: Fri, 20 Mar 2026 19:23:05 -0700 Subject: [PATCH 4/7] Add finger navigation with layered palm rejection Replace blanket finger rejection in both views with a shared TouchFilter that applies five palm-rejection layers (pen-active suppression, 150ms cooldown, touch-size threshold, multi-touch rejection, stationary timeout). Canvas allows finger vertical scroll; text view allows finger taps on logo/text and finger scroll. All stylus gestures unchanged. Fixes https://github.com/imedwei/InkUp/issues/4 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../com/writer/ui/writing/WritingActivity.kt | 5 + .../com/writer/view/HandwritingCanvasView.kt | 98 +++++- .../com/writer/view/RecognizedTextView.kt | 140 ++++++++- .../main/java/com/writer/view/TouchFilter.kt | 126 ++++++++ .../java/com/writer/view/TouchFilterTest.kt | 283 ++++++++++++++++++ 5 files changed, 648 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/com/writer/view/TouchFilter.kt create mode 100644 app/src/test/java/com/writer/view/TouchFilterTest.kt diff --git a/app/src/main/java/com/writer/ui/writing/WritingActivity.kt b/app/src/main/java/com/writer/ui/writing/WritingActivity.kt index 371263b..eb2c2da 100644 --- a/app/src/main/java/com/writer/ui/writing/WritingActivity.kt +++ b/app/src/main/java/com/writer/ui/writing/WritingActivity.kt @@ -25,6 +25,7 @@ import com.writer.model.DocumentData import com.writer.storage.DocumentStorage import com.writer.view.HandwritingCanvasView import com.writer.view.RecognizedTextView +import com.writer.view.TouchFilter import kotlinx.coroutines.launch class WritingActivity : AppCompatActivity() { @@ -109,6 +110,10 @@ class WritingActivity : AppCompatActivity() { inkCanvas = findViewById(R.id.inkCanvas) recognizedTextView = findViewById(R.id.recognizedTextView) + val touchFilter = TouchFilter() + inkCanvas.touchFilter = touchFilter + recognizedTextView.touchFilter = touchFilter + documentModel = DocumentModel() tutorialManager = TutorialManager( diff --git a/app/src/main/java/com/writer/view/HandwritingCanvasView.kt b/app/src/main/java/com/writer/view/HandwritingCanvasView.kt index 4f13e06..60c0878 100644 --- a/app/src/main/java/com/writer/view/HandwritingCanvasView.kt +++ b/app/src/main/java/com/writer/view/HandwritingCanvasView.kt @@ -149,6 +149,13 @@ class HandwritingCanvasView @JvmOverloads constructor( // Whether we've temporarily changed the Onyx SDK limit rect for a diagram stroke private var diagramLimitActive = false + /** Shared palm-rejection filter, set by WritingActivity. */ + var touchFilter: TouchFilter? = null + + // Finger scroll state + private var fingerScrollActive = false + private var fingerScrollLastY = 0f + private val idleRunnable = Runnable { onIdleTimeout?.invoke() } private var useOnyxSdk = false @@ -172,6 +179,7 @@ class HandwritingCanvasView @JvmOverloads constructor( private val onyxCallback = object : RawInputCallback() { override fun onBeginRawDrawing(b: Boolean, tp: TouchPoint) { + touchFilter?.penActive = true handler.removeCallbacks(idleRunnable) currentStrokePoints.clear() val docPt = tp.toDocStrokePoint() @@ -216,6 +224,12 @@ class HandwritingCanvasView @JvmOverloads constructor( } override fun onEndRawDrawing(b: Boolean, tp: TouchPoint) { + if (!lineDragActive && !diagramInsertActive && !undoScrubActive) { + touchFilter?.let { + it.penActive = false + it.penUpTimestamp = android.os.SystemClock.uptimeMillis() + } + } Log.d(TAG, "onEndRawDrawing: ${currentStrokePoints.size} points, lineDrag=$lineDragActive, diagramInsert=$diagramInsertActive, undoReady=$undoGestureReady, undoScrub=$undoScrubActive") if (lineDragActive || diagramInsertActive || undoScrubActive) { // SDK fires this when disabled mid-stroke (buffer dump). @@ -325,10 +339,10 @@ class HandwritingCanvasView @JvmOverloads constructor( override fun onTouchEvent(event: MotionEvent): Boolean { val toolType = event.getToolType(0) - // Reject all finger/palm touches, but cancel idle timer + // Finger touches: filter through palm rejection, allow vertical scroll if (toolType == MotionEvent.TOOL_TYPE_FINGER) { handler.removeCallbacks(idleRunnable) - return false + return handleFingerTouch(event) } // If already in a gutter drag, keep handling as gutter even if pen leaves the area @@ -374,6 +388,7 @@ class HandwritingCanvasView @JvmOverloads constructor( when (event.action) { MotionEvent.ACTION_DOWN -> { + touchFilter?.penActive = true handler.removeCallbacks(idleRunnable) currentStrokePoints.clear() currentPath.reset() @@ -412,6 +427,10 @@ class HandwritingCanvasView @JvmOverloads constructor( return true } MotionEvent.ACTION_UP -> { + touchFilter?.let { + it.penActive = false + it.penUpTimestamp = android.os.SystemClock.uptimeMillis() + } if (lineDragActive) { endLineDrag() return true @@ -484,6 +503,81 @@ class HandwritingCanvasView @JvmOverloads constructor( return false } + /** + * Handle filtered finger touches on the canvas. Only vertical scrolling is + * allowed — no taps (avoids accidental palm taps). + */ + private fun handleFingerTouch(event: MotionEvent): Boolean { + val tf = touchFilter ?: return false + val touchMinorDp = event.touchMinor / ScreenMetrics.density + + when (event.action) { + MotionEvent.ACTION_DOWN -> { + if (tf.evaluateDown( + pointerCount = event.pointerCount, + touchMinorDp = touchMinorDp, + eventTime = event.eventTime, + x = event.x, + y = event.y, + ) == TouchFilter.Decision.REJECT + ) { + fingerScrollActive = false + return false + } + fingerScrollLastY = event.y + fingerScrollActive = true + pauseRawDrawing() + return true + } + MotionEvent.ACTION_MOVE -> { + if (!fingerScrollActive) return false + if (tf.evaluateMove( + pointerCount = event.pointerCount, + touchMinorDp = touchMinorDp, + eventTime = event.eventTime, + x = event.x, + y = event.y, + checkStationary = true, + ) == TouchFilter.Decision.REJECT + ) { + // Cancel this finger gesture + fingerScrollActive = false + if (!tutorialMode) resumeRawDrawing() + return false + } + if (!tf.hasMovedPastSlop()) return true // wait for intentional drag + val dy = fingerScrollLastY - event.y // drag up = scroll down + fingerScrollLastY = event.y + if (textOverscroll > 0f && dy > 0f) { + textOverscroll = (textOverscroll - dy).coerceAtLeast(0f) + } else { + val raw = scrollOffsetY + dy + if (raw < 0f) { + scrollOffsetY = 0f + textOverscroll = (textOverscroll - raw).coerceAtLeast(0f) + } else { + scrollOffsetY = raw + } + } + drawToSurface() + onManualScroll?.invoke() + return true + } + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + if (!fingerScrollActive) return false + fingerScrollActive = false + if (textOverscroll == 0f) { + scrollOffsetY = snapToLine(scrollOffsetY) + } + drawToSurface() + if (!tutorialMode) resumeRawDrawing() + onManualScroll?.invoke() + return true + } + } + return false + } + private fun finishStroke() { if (currentStrokePoints.size < 2) { currentStrokePoints.clear() diff --git a/app/src/main/java/com/writer/view/RecognizedTextView.kt b/app/src/main/java/com/writer/view/RecognizedTextView.kt index a9741cb..f94b653 100644 --- a/app/src/main/java/com/writer/view/RecognizedTextView.kt +++ b/app/src/main/java/com/writer/view/RecognizedTextView.kt @@ -203,6 +203,13 @@ class RecognizedTextView @JvmOverloads constructor( private var textTapDownY = 0f private var textTapTracking = false + /** Shared palm-rejection filter, set by WritingActivity. */ + var touchFilter: TouchFilter? = null + + // Finger scroll state + private var fingerScrollActive = false + private var fingerScrollLastY = 0f + fun setParagraphs(paragraphs: List>) { setContent(paragraphs, emptyList()) } @@ -345,9 +352,9 @@ class RecognizedTextView @JvmOverloads constructor( override fun onTouchEvent(event: MotionEvent): Boolean { val toolType = event.getToolType(0) - // Reject finger/palm touches + // Finger touches: filter through palm rejection, allow taps and scroll if (toolType == MotionEvent.TOOL_TYPE_FINGER) { - return false + return handleFingerTouch(event) } // If already in a gutter drag, keep handling even if pen leaves gutter area @@ -406,6 +413,135 @@ class RecognizedTextView @JvmOverloads constructor( return super.onTouchEvent(event) } + /** + * Handle filtered finger touches on the text view. + * Allows: logo tap, text tap, gutter drag, text body scroll. + */ + private fun handleFingerTouch(event: MotionEvent): Boolean { + val tf = touchFilter ?: return false + val touchMinorDp = event.touchMinor / ScreenMetrics.density + + // If already in a finger scroll, keep handling + if (fingerScrollActive) { + when (event.action) { + MotionEvent.ACTION_MOVE -> { + if (tf.evaluateMove( + pointerCount = event.pointerCount, + touchMinorDp = touchMinorDp, + eventTime = event.eventTime, + x = event.x, + y = event.y, + checkStationary = false, + ) == TouchFilter.Decision.REJECT + ) { + fingerScrollActive = false + return false + } + val dy = event.y - fingerScrollLastY + fingerScrollLastY = event.y + textContentScroll += dy + invalidate() + return true + } + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + fingerScrollActive = false + return true + } + } + return true + } + + // If already in a gutter drag, keep handling + if (isGutterDragging) { + return handleGutterTouch(event) + } + + when (event.action) { + MotionEvent.ACTION_DOWN -> { + if (tf.evaluateDown( + pointerCount = event.pointerCount, + touchMinorDp = touchMinorDp, + eventTime = event.eventTime, + x = event.x, + y = event.y, + ) == TouchFilter.Decision.REJECT + ) { + return false + } + + // Gutter area → resize drag + if (event.x >= width - HandwritingCanvasView.GUTTER_WIDTH) { + return handleGutterTouch(event) + } + + // Tutorial close button + if (tutorialMode && event.x < width - HandwritingCanvasView.GUTTER_WIDTH && event.y < closeButtonHeight) { + return true + } + + // Track for tap or scroll on text body + if (!tutorialMode) { + textTapDownX = event.x + textTapDownY = event.y + textTapTracking = true + fingerScrollLastY = event.y + } + return true + } + MotionEvent.ACTION_MOVE -> { + if (tf.evaluateMove( + pointerCount = event.pointerCount, + touchMinorDp = touchMinorDp, + eventTime = event.eventTime, + x = event.x, + y = event.y, + checkStationary = false, + ) == TouchFilter.Decision.REJECT + ) { + textTapTracking = false + fingerScrollActive = false + return false + } + if (textTapTracking) { + val dx = event.x - textTapDownX + val dy = event.y - textTapDownY + if (dx * dx + dy * dy > 400f) { + textTapTracking = false + // Transition to finger scroll + fingerScrollActive = true + fingerScrollLastY = event.y + } + } + if (fingerScrollActive) { + val dy = event.y - fingerScrollLastY + fingerScrollLastY = event.y + textContentScroll += dy + invalidate() + } + return true + } + MotionEvent.ACTION_UP -> { + if (tutorialMode && event.x < width - HandwritingCanvasView.GUTTER_WIDTH && event.y < closeButtonHeight) { + onCloseTutorialTap?.invoke() + return true + } + if (textTapTracking) { + textTapTracking = false + resolveTextTap(event.x, event.y) + return true + } + fingerScrollActive = false + return true + } + MotionEvent.ACTION_CANCEL -> { + textTapTracking = false + fingerScrollActive = false + return true + } + } + return false + } + private fun handleGutterTouch(event: MotionEvent): Boolean { when (event.action) { MotionEvent.ACTION_DOWN -> { diff --git a/app/src/main/java/com/writer/view/TouchFilter.kt b/app/src/main/java/com/writer/view/TouchFilter.kt new file mode 100644 index 0000000..19edeb5 --- /dev/null +++ b/app/src/main/java/com/writer/view/TouchFilter.kt @@ -0,0 +1,126 @@ +package com.writer.view + +/** + * Layered palm-rejection filter for finger touches. + * + * Pure logic — no Android view dependency. Takes primitive parameters, returns + * accept/reject decisions. Unit-testable on JVM. + * + * Filter layers (cheapest first): + * 1. Concurrent stylus suppression — pen is down + * 2. Pen cooldown — pen lifted within [penCooldownMs] + * 3. Touch size threshold — contact area too large (palm) + * 4. Multi-touch rejection — pointerCount > 1 + * 5. Stationary contact timeout — finger hasn't moved past slop (canvas only) + */ +class TouchFilter( + private val palmSizeThresholdDp: Float = 40f, + private val penCooldownMs: Long = 150L, + private val stationarySlopDp: Float = 8f, + private val stationaryTimeoutMs: Long = 200L, +) { + + enum class Decision { ACCEPT, REJECT } + + // --- Pen state, set by the view --- + + @Volatile var penActive: Boolean = false + + /** Timestamp (uptimeMillis) when pen last lifted. */ + @Volatile var penUpTimestamp: Long = 0L + + // --- Per-touch tracking for stationary check --- + + private var fingerDownX: Float = 0f + private var fingerDownY: Float = 0f + private var fingerDownTime: Long = 0L + private var fingerMovedPastSlop: Boolean = false + + /** + * Evaluate whether a finger ACTION_DOWN should be accepted. + * + * @param pointerCount number of active pointers + * @param touchMinorDp minor axis of touch area in dp + * @param eventTime uptimeMillis of the event + * @param x screen x of the touch + * @param y screen y of the touch + */ + fun evaluateDown( + pointerCount: Int, + touchMinorDp: Float, + eventTime: Long, + x: Float, + y: Float, + ): Decision { + // Layer 1: pen is currently down + if (penActive) return Decision.REJECT + + // Layer 2: pen lifted recently + if (penUpTimestamp > 0L && (eventTime - penUpTimestamp) < penCooldownMs) { + return Decision.REJECT + } + + // Layer 3: contact area too large + if (touchMinorDp > palmSizeThresholdDp) return Decision.REJECT + + // Layer 4: multi-touch + if (pointerCount > 1) return Decision.REJECT + + // Passed all down-time checks — start tracking for stationary timeout + fingerDownX = x + fingerDownY = y + fingerDownTime = eventTime + fingerMovedPastSlop = false + + return Decision.ACCEPT + } + + /** + * Evaluate whether a finger ACTION_MOVE should be accepted. + * Must be called on every move after a successful [evaluateDown]. + * + * @param pointerCount number of active pointers + * @param touchMinorDp minor axis of touch area in dp + * @param eventTime uptimeMillis of the event + * @param x screen x + * @param y screen y + * @param checkStationary true for canvas (reject resting palm), false for text view + */ + fun evaluateMove( + pointerCount: Int, + touchMinorDp: Float, + eventTime: Long, + x: Float, + y: Float, + checkStationary: Boolean, + ): Decision { + // Layer 1: pen went down mid-gesture + if (penActive) return Decision.REJECT + + // Layer 3: contact area grew (palm settling) + if (touchMinorDp > palmSizeThresholdDp) return Decision.REJECT + + // Layer 4: second finger appeared + if (pointerCount > 1) return Decision.REJECT + + // Layer 5: stationary contact timeout (canvas only) + if (checkStationary && !fingerMovedPastSlop) { + val dx = x - fingerDownX + val dy = y - fingerDownY + val slopPx = stationarySlopDp * ScreenMetrics.density + if (dx * dx + dy * dy > slopPx * slopPx) { + fingerMovedPastSlop = true + } else if ((eventTime - fingerDownTime) > stationaryTimeoutMs) { + return Decision.REJECT + } + } + + return Decision.ACCEPT + } + + /** + * Check whether enough movement has happened for a scroll gesture. + * Call after [evaluateMove] returns ACCEPT. + */ + fun hasMovedPastSlop(): Boolean = fingerMovedPastSlop +} diff --git a/app/src/test/java/com/writer/view/TouchFilterTest.kt b/app/src/test/java/com/writer/view/TouchFilterTest.kt new file mode 100644 index 0000000..5393a04 --- /dev/null +++ b/app/src/test/java/com/writer/view/TouchFilterTest.kt @@ -0,0 +1,283 @@ +package com.writer.view + +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +/** + * Unit tests for [TouchFilter]. + * + * Uses the plain-float [ScreenMetrics.init] overload so no Android framework + * dependency is needed — these run on the JVM with plain JUnit. + */ +class TouchFilterTest { + + companion object { + private const val DENSITY = 1.875f // 300 PPI Boox devices + } + + private lateinit var filter: TouchFilter + + @Before + fun setUp() { + ScreenMetrics.init(DENSITY, smallestWidthDp = 674, widthPixels = 1264, heightPixels = 1680) + filter = TouchFilter( + palmSizeThresholdDp = 40f, + penCooldownMs = 150L, + stationarySlopDp = 8f, + stationaryTimeoutMs = 200L, + ) + } + + // ── Layer 1: Pen active ──────────────────────────────────────────────── + + @Test + fun rejects_finger_when_pen_is_down() { + filter.penActive = true + val result = filter.evaluateDown( + pointerCount = 1, touchMinorDp = 10f, eventTime = 1000L, x = 100f, y = 100f + ) + assertEquals(TouchFilter.Decision.REJECT, result) + } + + @Test + fun accepts_finger_when_pen_is_not_down() { + filter.penActive = false + val result = filter.evaluateDown( + pointerCount = 1, touchMinorDp = 10f, eventTime = 1000L, x = 100f, y = 100f + ) + assertEquals(TouchFilter.Decision.ACCEPT, result) + } + + // ── Layer 2: Pen cooldown ────────────────────────────────────────────── + + @Test + fun rejects_finger_during_pen_cooldown() { + filter.penUpTimestamp = 900L + val result = filter.evaluateDown( + pointerCount = 1, touchMinorDp = 10f, eventTime = 1000L, x = 100f, y = 100f + ) + assertEquals(TouchFilter.Decision.REJECT, result) + } + + @Test + fun accepts_finger_after_pen_cooldown() { + filter.penUpTimestamp = 800L + val result = filter.evaluateDown( + pointerCount = 1, touchMinorDp = 10f, eventTime = 1000L, x = 100f, y = 100f + ) + assertEquals(TouchFilter.Decision.ACCEPT, result) + } + + @Test + fun accepts_finger_exactly_at_cooldown_boundary() { + filter.penUpTimestamp = 850L + val result = filter.evaluateDown( + pointerCount = 1, touchMinorDp = 10f, eventTime = 1000L, x = 100f, y = 100f + ) + assertEquals(TouchFilter.Decision.ACCEPT, result) + } + + // ── Layer 3: Palm size ───────────────────────────────────────────────── + + @Test + fun rejects_large_touch_area() { + val result = filter.evaluateDown( + pointerCount = 1, touchMinorDp = 50f, eventTime = 1000L, x = 100f, y = 100f + ) + assertEquals(TouchFilter.Decision.REJECT, result) + } + + @Test + fun accepts_small_touch_area() { + val result = filter.evaluateDown( + pointerCount = 1, touchMinorDp = 15f, eventTime = 1000L, x = 100f, y = 100f + ) + assertEquals(TouchFilter.Decision.ACCEPT, result) + } + + @Test + fun rejects_touch_at_exact_threshold() { + val result = filter.evaluateDown( + pointerCount = 1, touchMinorDp = 40.1f, eventTime = 1000L, x = 100f, y = 100f + ) + assertEquals(TouchFilter.Decision.REJECT, result) + } + + // ── Layer 4: Multi-touch ─────────────────────────────────────────────── + + @Test + fun rejects_multi_touch() { + val result = filter.evaluateDown( + pointerCount = 2, touchMinorDp = 10f, eventTime = 1000L, x = 100f, y = 100f + ) + assertEquals(TouchFilter.Decision.REJECT, result) + } + + // ── Layer 5: Stationary timeout (canvas) ─────────────────────────────── + + @Test + fun rejects_stationary_finger_on_canvas_after_timeout() { + filter.evaluateDown( + pointerCount = 1, touchMinorDp = 10f, eventTime = 1000L, x = 100f, y = 100f + ) + // Finger hasn't moved, 250ms later + val result = filter.evaluateMove( + pointerCount = 1, touchMinorDp = 10f, eventTime = 1250L, + x = 100f, y = 100f, checkStationary = true + ) + assertEquals(TouchFilter.Decision.REJECT, result) + } + + @Test + fun accepts_stationary_finger_on_text_view() { + filter.evaluateDown( + pointerCount = 1, touchMinorDp = 10f, eventTime = 1000L, x = 100f, y = 100f + ) + // Finger hasn't moved, 250ms later — but checkStationary is false + val result = filter.evaluateMove( + pointerCount = 1, touchMinorDp = 10f, eventTime = 1250L, + x = 100f, y = 100f, checkStationary = false + ) + assertEquals(TouchFilter.Decision.ACCEPT, result) + } + + @Test + fun accepts_moving_finger_on_canvas_before_timeout() { + filter.evaluateDown( + pointerCount = 1, touchMinorDp = 10f, eventTime = 1000L, x = 100f, y = 100f + ) + // Finger hasn't moved much yet, within timeout + val result = filter.evaluateMove( + pointerCount = 1, touchMinorDp = 10f, eventTime = 1100L, + x = 101f, y = 101f, checkStationary = true + ) + assertEquals(TouchFilter.Decision.ACCEPT, result) + } + + @Test + fun accepts_finger_that_moves_past_slop_on_canvas() { + filter.evaluateDown( + pointerCount = 1, touchMinorDp = 10f, eventTime = 1000L, x = 100f, y = 100f + ) + // Move well past slop (8dp * 1.875 = 15px, move 50px) + val result = filter.evaluateMove( + pointerCount = 1, touchMinorDp = 10f, eventTime = 1300L, + x = 100f, y = 150f, checkStationary = true + ) + assertEquals(TouchFilter.Decision.ACCEPT, result) + } + + // ── Move-time checks ─────────────────────────────────────────────────── + + @Test + fun rejects_move_when_pen_goes_down() { + filter.evaluateDown( + pointerCount = 1, touchMinorDp = 10f, eventTime = 1000L, x = 100f, y = 100f + ) + filter.penActive = true + val result = filter.evaluateMove( + pointerCount = 1, touchMinorDp = 10f, eventTime = 1050L, + x = 100f, y = 150f, checkStationary = false + ) + assertEquals(TouchFilter.Decision.REJECT, result) + } + + @Test + fun rejects_move_when_touch_grows() { + filter.evaluateDown( + pointerCount = 1, touchMinorDp = 10f, eventTime = 1000L, x = 100f, y = 100f + ) + val result = filter.evaluateMove( + pointerCount = 1, touchMinorDp = 50f, eventTime = 1050L, + x = 100f, y = 150f, checkStationary = false + ) + assertEquals(TouchFilter.Decision.REJECT, result) + } + + @Test + fun rejects_move_when_second_finger_appears() { + filter.evaluateDown( + pointerCount = 1, touchMinorDp = 10f, eventTime = 1000L, x = 100f, y = 100f + ) + val result = filter.evaluateMove( + pointerCount = 2, touchMinorDp = 10f, eventTime = 1050L, + x = 100f, y = 150f, checkStationary = false + ) + assertEquals(TouchFilter.Decision.REJECT, result) + } + + // ── hasMovedPastSlop ─────────────────────────────────────────────────── + + @Test + fun hasMovedPastSlop_false_initially() { + filter.evaluateDown( + pointerCount = 1, touchMinorDp = 10f, eventTime = 1000L, x = 100f, y = 100f + ) + assertEquals(false, filter.hasMovedPastSlop()) + } + + @Test + fun hasMovedPastSlop_true_after_large_move() { + filter.evaluateDown( + pointerCount = 1, touchMinorDp = 10f, eventTime = 1000L, x = 100f, y = 100f + ) + filter.evaluateMove( + pointerCount = 1, touchMinorDp = 10f, eventTime = 1050L, + x = 100f, y = 150f, checkStationary = true + ) + assertEquals(true, filter.hasMovedPastSlop()) + } + + // ── Scroll acceptance (full sequence) ────────────────────────────────── + + @Test + fun full_scroll_sequence_accepted() { + // Down + assertEquals( + TouchFilter.Decision.ACCEPT, + filter.evaluateDown( + pointerCount = 1, touchMinorDp = 10f, eventTime = 1000L, x = 400f, y = 500f + ) + ) + // Small move (within slop) + assertEquals( + TouchFilter.Decision.ACCEPT, + filter.evaluateMove( + pointerCount = 1, touchMinorDp = 10f, eventTime = 1020L, + x = 400f, y = 505f, checkStationary = true + ) + ) + assertEquals(false, filter.hasMovedPastSlop()) + + // Larger move (past slop) + assertEquals( + TouchFilter.Decision.ACCEPT, + filter.evaluateMove( + pointerCount = 1, touchMinorDp = 10f, eventTime = 1040L, + x = 400f, y = 550f, checkStationary = true + ) + ) + assertEquals(true, filter.hasMovedPastSlop()) + } + + // ── Tap acceptance on text view ──────────────────────────────────────── + + @Test + fun tap_accepted_on_text_view() { + assertEquals( + TouchFilter.Decision.ACCEPT, + filter.evaluateDown( + pointerCount = 1, touchMinorDp = 10f, eventTime = 1000L, x = 200f, y = 300f + ) + ) + // Stationary for 300ms — but text view doesn't check stationary + assertEquals( + TouchFilter.Decision.ACCEPT, + filter.evaluateMove( + pointerCount = 1, touchMinorDp = 10f, eventTime = 1300L, + x = 200f, y = 300f, checkStationary = false + ) + ) + } +} From 0fec24b9ee354a1e5b63f41a31251380e8d8966b Mon Sep 17 00:00:00 2001 From: Ed Wei Date: Fri, 20 Mar 2026 19:25:35 -0700 Subject: [PATCH 5/7] Remove side gutter, add floating menu icon with complementary scroll MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the permanent ~69dp side gutter with lighter UI elements. The "I" logo is now a floating icon in the top-right of the text view that auto-hides during writing (300ms delay on pen lift). Split resize is handled by dragging the 1dp divider with an expanded touch target. Text view scroll drives canvas scroll so the two views stay complementary — preview shows recognized content, canvas shows what hasn't been recognized yet. Diagrams render progressively as they scroll off, clipped to actual stroke bounds for flush alignment at the boundary. Only currently-hidden lines appear in preview text. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../com/writer/ui/writing/TutorialContent.kt | 12 +- .../com/writer/ui/writing/WritingActivity.kt | 83 ++-- .../writer/ui/writing/WritingCoordinator.kt | 119 +++-- .../main/java/com/writer/view/CanvasTheme.kt | 12 - .../com/writer/view/HandwritingCanvasView.kt | 93 +--- .../writer/view/PreviewLayoutCalculator.kt | 165 +++++++ .../com/writer/view/RecognizedTextView.kt | 210 ++++----- .../java/com/writer/view/ScreenMetrics.kt | 16 +- .../main/java/com/writer/view/SplitLayout.kt | 88 ++++ app/src/main/res/layout/activity_writing.xml | 8 +- .../view/PreviewLayoutCalculatorTest.kt | 405 ++++++++++++++++++ .../java/com/writer/view/ScreenMetricsTest.kt | 41 -- 12 files changed, 893 insertions(+), 359 deletions(-) create mode 100644 app/src/main/java/com/writer/view/PreviewLayoutCalculator.kt create mode 100644 app/src/main/java/com/writer/view/SplitLayout.kt create mode 100644 app/src/test/java/com/writer/view/PreviewLayoutCalculatorTest.kt diff --git a/app/src/main/java/com/writer/ui/writing/TutorialContent.kt b/app/src/main/java/com/writer/ui/writing/TutorialContent.kt index 8916889..3df92bc 100644 --- a/app/src/main/java/com/writer/ui/writing/TutorialContent.kt +++ b/app/src/main/java/com/writer/ui/writing/TutorialContent.kt @@ -40,7 +40,6 @@ object TutorialContent { private val LINE_SPACING = HandwritingCanvasView.LINE_SPACING private val TOP_MARGIN = HandwritingCanvasView.TOP_MARGIN - private val GUTTER_WIDTH get() = HandwritingCanvasView.GUTTER_WIDTH private val textPaint = Paint().apply { typeface = Typeface.create("cursive", Typeface.NORMAL) @@ -48,7 +47,7 @@ object TutorialContent { } fun generate(canvasWidth: Int, canvasHeight: Int): TutorialData { - val writingWidth = canvasWidth - GUTTER_WIDTH + val writingWidth = canvasWidth.toFloat() val strokes = mutableListOf() val annotations = mutableListOf() @@ -103,15 +102,10 @@ object TutorialContent { TextAnnotation("Strike through to delete words", 700f, strikeY + 10f, red, 32f) ) - // --- Gutter scroll hint (top line, matching resize arrow style) --- + // --- Finger scroll hint --- val scrollHintY = lineTop(0) + LINE_SPACING * 0.4f - val scrollHintRight = writingWidth - 20f - val scrollHintLeft = writingWidth - 370f - annotations.add(makeLine(scrollHintLeft, scrollHintY, scrollHintRight, scrollHintY, blue, 4f)) - annotations.add(makeLine(scrollHintRight - 20f, scrollHintY - 12f, scrollHintRight, scrollHintY, blue, 4f)) - annotations.add(makeLine(scrollHintRight - 20f, scrollHintY + 12f, scrollHintRight, scrollHintY, blue, 4f)) textAnnotations.add( - TextAnnotation("Drag this gutter to scroll", scrollHintLeft.toFloat() - 10f, scrollHintY - 21f, blue, 34f) + TextAnnotation("Scroll with your finger", writingWidth - 200f, scrollHintY, blue, 34f) ) // --- Insert/delete line demo (right of eggs/bread area, +100px right) --- diff --git a/app/src/main/java/com/writer/ui/writing/WritingActivity.kt b/app/src/main/java/com/writer/ui/writing/WritingActivity.kt index eb2c2da..738a3fd 100644 --- a/app/src/main/java/com/writer/ui/writing/WritingActivity.kt +++ b/app/src/main/java/com/writer/ui/writing/WritingActivity.kt @@ -7,9 +7,10 @@ import android.util.Log import android.view.WindowInsets import android.view.WindowInsetsController import android.app.Activity -import android.widget.LinearLayout import android.view.Gravity import android.view.LayoutInflater +import android.view.View +import android.widget.LinearLayout import android.widget.PopupWindow import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts @@ -109,6 +110,8 @@ class WritingActivity : AppCompatActivity() { inkCanvas = findViewById(R.id.inkCanvas) recognizedTextView = findViewById(R.id.recognizedTextView) + val splitLayout = findViewById(R.id.splitLayout) + val splitDivider = findViewById(R.id.splitDivider) val touchFilter = TouchFilter() inkCanvas.touchFilter = touchFilter @@ -138,6 +141,25 @@ class WritingActivity : AppCompatActivity() { pendingRestore = DocumentStorage.load(this, currentDocumentName) restoreDocumentVisuals() + // Wire pen state from canvas to text view (for floating icon auto-hide) + inkCanvas.onPenStateChanged = { active -> + recognizedTextView.onPenStateChanged(active) + } + + // Text view scroll drives canvas scroll (complementary views) + recognizedTextView.onScroll = { dy -> + // dy > 0 = finger dragged down = see earlier content = scroll canvas up + val raw = inkCanvas.scrollOffsetY - dy + inkCanvas.scrollOffsetY = raw.coerceAtLeast(0f) + inkCanvas.drawToSurface() + inkCanvas.onManualScroll?.invoke() + } + recognizedTextView.onScrollEnd = { + inkCanvas.scrollOffsetY = inkCanvas.snapToLine(inkCanvas.scrollOffsetY) + inkCanvas.drawToSurface() + inkCanvas.onManualScroll?.invoke() + } + // Tap "I" logo to open menu recognizedTextView.onLogoTap = { showMenu() } @@ -147,11 +169,11 @@ class WritingActivity : AppCompatActivity() { // Create coordinator early so cached text can be displayed before model loads startCoordinator() - // Capture default heights after initial layout, then wire up the gutter + // Capture default heights after initial layout, then wire up the divider drag recognizedTextView.post { defaultTextHeight = recognizedTextView.height defaultCanvasHeight = inkCanvas.height - setupTextGutter() + setupSplitDrag(splitLayout, splitDivider) // Restore cached text and scroll position immediately (no recognizer needed) restoreCoordinatorState() @@ -206,40 +228,28 @@ class WritingActivity : AppCompatActivity() { coordinator?.restoreState(data) } - private fun setupTextGutter() { - recognizedTextView.onGutterDrag = { delta -> + private fun setupSplitDrag(splitLayout: com.writer.view.SplitLayout, divider: View) { + splitLayout.dividerView = divider + splitLayout.onSplitDragStart = { inkCanvas.pauseRawDrawing() } + splitLayout.onSplitDragEnd = { inkCanvas.resumeRawDrawing() } + splitLayout.onSplitDrag = { delta -> val totalHeight = defaultTextHeight + defaultCanvasHeight val minTextHeight = (totalHeight * 0.25f).toInt() val maxOffset = (totalHeight - minTextHeight).toFloat() - if (delta > 0f && splitOffset >= maxOffset) { - // At max size, dragging down scrolls text content - val topPadding = 40f - val maxOverscroll = (recognizedTextView.totalTextHeight - recognizedTextView.height + topPadding).coerceAtLeast(0f) - inkCanvas.textOverscroll = (inkCanvas.textOverscroll + delta).coerceIn(0f, maxOverscroll) - coordinator?.onManualTextScroll() - } else if (delta < 0f && inkCanvas.textOverscroll > 0f) { - // Dragging back up — reduce overscroll first - inkCanvas.textOverscroll = (inkCanvas.textOverscroll + delta).coerceAtLeast(0f) - coordinator?.onManualTextScroll() - } else { - val totalHeight = defaultTextHeight + defaultCanvasHeight - val minTextHeight = (totalHeight * 0.25f).toInt() - val maxOffset = (totalHeight - minTextHeight).toFloat() - splitOffset = (splitOffset + delta).coerceIn(0f, maxOffset) - - val newTextHeight = defaultTextHeight + splitOffset.toInt() - val newCanvasHeight = defaultCanvasHeight - splitOffset.toInt() - - val textParams = recognizedTextView.layoutParams as LinearLayout.LayoutParams - textParams.height = newTextHeight - textParams.weight = 0f - recognizedTextView.layoutParams = textParams - - val canvasParams = inkCanvas.layoutParams as LinearLayout.LayoutParams - canvasParams.height = newCanvasHeight.coerceAtLeast(0) - canvasParams.weight = 0f - inkCanvas.layoutParams = canvasParams - } + splitOffset = (splitOffset + delta).coerceIn(0f, maxOffset) + + val newTextHeight = defaultTextHeight + splitOffset.toInt() + val newCanvasHeight = defaultCanvasHeight - splitOffset.toInt() + + val textParams = recognizedTextView.layoutParams as LinearLayout.LayoutParams + textParams.height = newTextHeight + textParams.weight = 0f + recognizedTextView.layoutParams = textParams + + val canvasParams = inkCanvas.layoutParams as LinearLayout.LayoutParams + canvasParams.height = newCanvasHeight.coerceAtLeast(0) + canvasParams.weight = 0f + inkCanvas.layoutParams = canvasParams } } @@ -474,11 +484,10 @@ class WritingActivity : AppCompatActivity() { Toast.makeText(this, "Tutorial reset — will show on next launch", Toast.LENGTH_SHORT).show() } - // Position to the left of the gutter, at the top of the text view - val gutterWidth = HandwritingCanvasView.GUTTER_WIDTH.toInt() + // Position to the left of the floating icon, at the top of the text view val loc = IntArray(2) recognizedTextView.getLocationOnScreen(loc) - val x = loc[0] + recognizedTextView.width - gutterWidth - popupWidth + val x = loc[0] + recognizedTextView.width - popupWidth val y = loc[1] popup.showAtLocation(recognizedTextView, Gravity.NO_GRAVITY, x, y) } diff --git a/app/src/main/java/com/writer/ui/writing/WritingCoordinator.kt b/app/src/main/java/com/writer/ui/writing/WritingCoordinator.kt index 0360dcf..62d4f9f 100644 --- a/app/src/main/java/com/writer/ui/writing/WritingCoordinator.kt +++ b/app/src/main/java/com/writer/ui/writing/WritingCoordinator.kt @@ -4,13 +4,16 @@ import android.util.Log import com.writer.model.DiagramArea import com.writer.model.DocumentModel import com.writer.model.InkStroke +import com.writer.model.maxY import com.writer.model.shiftY import com.writer.recognition.TextRecognizer import com.writer.recognition.LineSegmenter import com.writer.recognition.StrokeClassifier import com.writer.model.DocumentData import com.writer.storage.SvgExporter +import com.writer.view.CanvasTheme import com.writer.view.HandwritingCanvasView +import com.writer.view.PreviewLayoutCalculator import com.writer.view.RecognizedTextView import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -32,7 +35,6 @@ class WritingCoordinator( // Scroll when writing passes this fraction of canvas height from top // 25% of canvas ≈ 50% of full screen (since canvas is 75% of screen) private const val SCROLL_THRESHOLD = 0.25f - private val GUTTER_WIDTH get() = HandwritingCanvasView.GUTTER_WIDTH // Delay before refreshing e-ink display after text view updates private const val TEXT_REFRESH_DELAY_MS = 500L } @@ -269,7 +271,7 @@ class WritingCoordinator( recognizingLines.remove(lineIndex) return null } - val strokes = strokeClassifier.filterMarkerStrokes(allStrokes, inkCanvas.width - GUTTER_WIDTH) + val strokes = strokeClassifier.filterMarkerStrokes(allStrokes, inkCanvas.width.toFloat()) if (strokes.isEmpty()) { recognizingLines.remove(lineIndex) return null @@ -411,15 +413,15 @@ class WritingCoordinator( private fun displayHiddenLines() { val strokesByLine = lineSegmenter.groupByLine(documentModel.activeStrokes) - val currentlyHidden = strokesByLine.keys.filter { lineIdx -> - val lineBottom = lineSegmenter.getLineY(lineIdx) + HandwritingCanvasView.LINE_SPACING - lineBottom <= inkCanvas.scrollOffsetY - }.toSet() + val currentlyHidden = PreviewLayoutCalculator.currentlyHiddenLines( + strokesByLine.keys, inkCanvas.scrollOffsetY, + HandwritingCanvasView.TOP_MARGIN, HandwritingCanvasView.LINE_SPACING + ) - val notYetVisible = strokesByLine.keys.filter { lineIdx -> - val lineMid = lineSegmenter.getLineY(lineIdx) + HandwritingCanvasView.LINE_SPACING / 2f - lineMid <= inkCanvas.scrollOffsetY - }.toSet() + val notYetVisible = PreviewLayoutCalculator.notYetVisibleLines( + strokesByLine.keys, inkCanvas.scrollOffsetY, + HandwritingCanvasView.TOP_MARGIN, HandwritingCanvasView.LINE_SPACING + ) everHiddenLines.addAll(currentlyHidden) everHiddenLines.retainAll(strokesByLine.keys) @@ -436,10 +438,10 @@ class WritingCoordinator( for (lineIdx in uncached) { doRecognizeLine(lineIdx) } - val stillNotVisible = strokesByLine.keys.filter { lineIdx -> - val lineMid = lineSegmenter.getLineY(lineIdx) + HandwritingCanvasView.LINE_SPACING / 2f - lineMid <= inkCanvas.scrollOffsetY - }.toSet() + val stillNotVisible = PreviewLayoutCalculator.notYetVisibleLines( + strokesByLine.keys, inkCanvas.scrollOffsetY, + HandwritingCanvasView.TOP_MARGIN, HandwritingCanvasView.LINE_SPACING + ) updateTextView(stillNotVisible) } } @@ -454,14 +456,16 @@ class WritingCoordinator( val strokes: List, val canvasWidth: Float, val heightPx: Float, - val offsetY: Float + val offsetY: Float, + /** How much of the diagram's height is scrolled off the canvas (for partial rendering). */ + val visibleHeightPx: Float = heightPx ) private fun updateTextView(currentlyHidden: Set) { val strokesByLine = lineSegmenter.groupByLine(documentModel.activeStrokes) - val writingWidth = inkCanvas.width - GUTTER_WIDTH + val writingWidth = inkCanvas.width.toFloat() - val classifiedLines = everHiddenLines.sorted().filter { !isDiagramLine(it) }.mapNotNull { lineIdx -> + val classifiedLines = currentlyHidden.sorted().filter { !isDiagramLine(it) }.mapNotNull { lineIdx -> paragraphBuilder.classifyLine(lineIdx, lineTextCache[lineIdx], strokesByLine[lineIdx], writingWidth) } @@ -479,72 +483,53 @@ class WritingCoordinator( } } - // Build diagram displays for fully-hidden diagram areas - val diagrams = documentModel.diagramAreas.filter { area -> - val areaBottom = lineSegmenter.getLineY(area.endLineIndex + 1) - areaBottom <= inkCanvas.scrollOffsetY - }.map { area -> + // Build diagram displays — include as soon as any part scrolls off the canvas + val strokeMaxYByArea = documentModel.diagramAreas.associate { area -> val areaStrokes = documentModel.activeStrokes.filter { stroke -> - val strokeLine = lineSegmenter.getStrokeLineIndex(stroke) - area.containsLine(strokeLine) + area.containsLine(lineSegmenter.getStrokeLineIndex(stroke)) } + area.startLineIndex to (if (areaStrokes.isNotEmpty()) areaStrokes.maxOf { it.maxY } else null) + }.filterValues { it != null }.mapValues { it.value!! } + + val visibilities = PreviewLayoutCalculator.diagramVisibilities( + areas = documentModel.diagramAreas, + scrollOffsetY = inkCanvas.scrollOffsetY, + topMargin = HandwritingCanvasView.TOP_MARGIN, + lineSpacing = HandwritingCanvasView.LINE_SPACING, + strokeMaxYByArea = strokeMaxYByArea, + strokeWidthPadding = CanvasTheme.DEFAULT_STROKE_WIDTH + ) + + val areaByStartLine = documentModel.diagramAreas.associateBy { it.startLineIndex } + val diagrams = visibilities.map { vis -> + val area = areaByStartLine[vis.startLineIndex] + val areaStrokes = if (area != null) { + documentModel.activeStrokes.filter { stroke -> + area.containsLine(lineSegmenter.getStrokeLineIndex(stroke)) + } + } else emptyList() DiagramDisplay( - startLineIndex = area.startLineIndex, + startLineIndex = vis.startLineIndex, strokes = areaStrokes, canvasWidth = writingWidth, - heightPx = area.heightInLines * HandwritingCanvasView.LINE_SPACING, - offsetY = lineSegmenter.getLineY(area.startLineIndex) + heightPx = vis.fullHeight, + offsetY = vis.areaTop, + visibleHeightPx = vis.visibleHeight ) } textView.setContent(paragraphs, diagrams) } - /** Called when the text view is scrolled via its gutter overscroll. */ + /** Called when the text view is scrolled via overscroll. */ fun onManualTextScroll() { textView.textContentScroll = inkCanvas.textOverscroll textView.invalidate() } private fun updateTextScrollOffset() { - val lineHeights = textView.writtenLineHeights - if (lineHeights.isEmpty()) { - textView.textScrollOffset = 0f - return - } - - var offset = 0f - - for (i in lineHeights.indices.reversed()) { - val (lineIdx, textHeight) = lineHeights[i] - - if (textHeight <= 0f) continue - - val lineTop = lineSegmenter.getLineY(lineIdx) - if (lineTop < inkCanvas.scrollOffsetY) { - break - } - - val drivingLine = lineIdx - 1 - if (drivingLine < 0) { - offset += textHeight - continue - } - - val drivingLineBottom = lineSegmenter.getLineY(drivingLine) + HandwritingCanvasView.LINE_SPACING - val fraction = ((drivingLineBottom - inkCanvas.scrollOffsetY) / HandwritingCanvasView.LINE_SPACING) - .coerceIn(0f, 1f) - - if (fraction >= 1f) { - offset += textHeight - continue - } - - offset += fraction * textHeight - break - } - - textView.textScrollOffset = offset + // Content is flush with the divider — preview and canvas are complementary + textView.textScrollOffset = 0f } // --- Markdown export --- @@ -553,7 +538,7 @@ class WritingCoordinator( if (lineTextCache.isEmpty() && documentModel.diagramAreas.isEmpty()) return "" val strokesByLine = lineSegmenter.groupByLine(documentModel.activeStrokes) - val writingWidth = inkCanvas.width - GUTTER_WIDTH + val writingWidth = inkCanvas.width.toFloat() val classifiedLines = lineTextCache.keys.sorted().filter { !isDiagramLine(it) }.mapNotNull { lineIdx -> paragraphBuilder.classifyLine(lineIdx, lineTextCache[lineIdx], strokesByLine[lineIdx], writingWidth) diff --git a/app/src/main/java/com/writer/view/CanvasTheme.kt b/app/src/main/java/com/writer/view/CanvasTheme.kt index 6206fa9..4f239f4 100644 --- a/app/src/main/java/com/writer/view/CanvasTheme.kt +++ b/app/src/main/java/com/writer/view/CanvasTheme.kt @@ -13,7 +13,6 @@ import com.writer.model.InkStroke object CanvasTheme { const val DEFAULT_STROKE_WIDTH = 5f val LINE_COLOR: Int = Color.parseColor("#AAAAAA") - val GUTTER_FILL_COLOR: Int = Color.parseColor("#DDDDDD") fun newStrokePaint() = Paint().apply { color = Color.BLACK @@ -30,17 +29,6 @@ object CanvasTheme { style = Paint.Style.STROKE } - fun newGutterFillPaint() = Paint().apply { - color = GUTTER_FILL_COLOR - style = Paint.Style.FILL - } - - fun newGutterLinePaint() = Paint().apply { - color = LINE_COLOR - strokeWidth = 1f - style = Paint.Style.STROKE - } - val DIAGRAM_BORDER_COLOR: Int = Color.parseColor("#555555") fun newDiagramBorderPaint() = Paint().apply { diff --git a/app/src/main/java/com/writer/view/HandwritingCanvasView.kt b/app/src/main/java/com/writer/view/HandwritingCanvasView.kt index 60c0878..72d2c1f 100644 --- a/app/src/main/java/com/writer/view/HandwritingCanvasView.kt +++ b/app/src/main/java/com/writer/view/HandwritingCanvasView.kt @@ -37,14 +37,12 @@ class HandwritingCanvasView @JvmOverloads constructor( companion object { private const val TAG = "HandwritingCanvas" - // Line spacing, top margin and gutter width are DPI-scaled via ScreenMetrics. + // Line spacing and top margin are DPI-scaled via ScreenMetrics. val LINE_SPACING get() = ScreenMetrics.lineSpacing // Idle timeout before checking scroll condition (ms) private const val IDLE_TIMEOUT_MS = 2000L // Top margin before the first line val TOP_MARGIN get() = ScreenMetrics.topMargin - // Width of the scroll gutter on the right edge - val GUTTER_WIDTH get() = ScreenMetrics.gutterWidth // Line-drag gesture: vertical span to activate (either direction) private const val LINE_DRAG_MIN_SPANS = 1f // Line-drag gesture: max horizontal drift ratio during activation @@ -73,8 +71,6 @@ class HandwritingCanvasView @JvmOverloads constructor( private val strokePaint = CanvasTheme.newStrokePaint() private val linePaint = CanvasTheme.newLinePaint() - private val gutterPaint = CanvasTheme.newGutterFillPaint() - private val gutterLinePaint = CanvasTheme.newGutterLinePaint() private val diagramBorderPaint = CanvasTheme.newDiagramBorderPaint() private val annotationPaint = Paint().apply { @@ -102,6 +98,9 @@ class HandwritingCanvasView @JvmOverloads constructor( /** Called when manual scrolling changes the offset. */ var onManualScroll: (() -> Unit)? = null + /** Called when pen state changes: true = pen down (writing), false = pen lifted. */ + var onPenStateChanged: ((Boolean) -> Unit)? = null + // Line-drag gesture callbacks var onLineDragStart: ((anchorLine: Int) -> Unit)? = null var onLineDragStep: ((shiftLines: Int) -> Unit)? = null @@ -123,10 +122,6 @@ class HandwritingCanvasView @JvmOverloads constructor( /** Extra scroll past the top of the document, for scrolling the text view. */ var textOverscroll: Float = 0f - // Gutter scrolling state - private var isGutterDragging = false - private var gutterDragLastY = 0f - // Line-drag gesture state private var lineDragActive = false private var lineDragStartScreenY = 0f @@ -180,6 +175,7 @@ class HandwritingCanvasView @JvmOverloads constructor( private val onyxCallback = object : RawInputCallback() { override fun onBeginRawDrawing(b: Boolean, tp: TouchPoint) { touchFilter?.penActive = true + onPenStateChanged?.invoke(true) handler.removeCallbacks(idleRunnable) currentStrokePoints.clear() val docPt = tp.toDocStrokePoint() @@ -229,6 +225,7 @@ class HandwritingCanvasView @JvmOverloads constructor( it.penActive = false it.penUpTimestamp = android.os.SystemClock.uptimeMillis() } + onPenStateChanged?.invoke(false) } Log.d(TAG, "onEndRawDrawing: ${currentStrokePoints.size} points, lineDrag=$lineDragActive, diagramInsert=$diagramInsertActive, undoReady=$undoGestureReady, undoScrub=$undoScrubActive") if (lineDragActive || diagramInsertActive || undoScrubActive) { @@ -290,7 +287,6 @@ class HandwritingCanvasView @JvmOverloads constructor( try { val limit = Rect() getLocalVisibleRect(limit) - limit.right = (limit.right - GUTTER_WIDTH).toInt() touchHelper?.setLimitRect(limit, emptyList()) } catch (e: Exception) { Log.w(TAG, "Error updating limit rect: ${e.message}") @@ -315,7 +311,6 @@ class HandwritingCanvasView @JvmOverloads constructor( try { val limit = Rect() getLocalVisibleRect(limit) - limit.right = (limit.right - GUTTER_WIDTH).toInt() touchHelper = TouchHelper.create(this, onyxCallback) touchHelper?.setStrokeWidth(CanvasTheme.DEFAULT_STROKE_WIDTH) @@ -345,16 +340,6 @@ class HandwritingCanvasView @JvmOverloads constructor( return handleFingerTouch(event) } - // If already in a gutter drag, keep handling as gutter even if pen leaves the area - if (isGutterDragging) { - return handleGutterTouch(event) - } - - // Stylus/mouse in gutter area → scroll drag - if (event.x >= width - GUTTER_WIDTH) { - return handleGutterTouch(event) - } - // If an interactive gesture is active, we've disabled the SDK and handle here if (lineDragActive) { return handleLineDragTouch(event) @@ -366,7 +351,7 @@ class HandwritingCanvasView @JvmOverloads constructor( return handleUndoTouch(event) } - // In tutorial mode, block all writing input but allow gutter (handled above) + // In tutorial mode, block all writing input if (tutorialMode) return false // If using Onyx SDK, pen input in the canvas area is handled by SDK callbacks @@ -389,6 +374,7 @@ class HandwritingCanvasView @JvmOverloads constructor( when (event.action) { MotionEvent.ACTION_DOWN -> { touchFilter?.penActive = true + onPenStateChanged?.invoke(true) handler.removeCallbacks(idleRunnable) currentStrokePoints.clear() currentPath.reset() @@ -431,6 +417,7 @@ class HandwritingCanvasView @JvmOverloads constructor( it.penActive = false it.penUpTimestamp = android.os.SystemClock.uptimeMillis() } + onPenStateChanged?.invoke(false) if (lineDragActive) { endLineDrag() return true @@ -459,50 +446,6 @@ class HandwritingCanvasView @JvmOverloads constructor( return super.onTouchEvent(event) } - private fun handleGutterTouch(event: MotionEvent): Boolean { - when (event.action) { - MotionEvent.ACTION_DOWN -> { - isGutterDragging = true - gutterDragLastY = event.y - handler.removeCallbacks(idleRunnable) - pauseRawDrawing() - return true - } - MotionEvent.ACTION_MOVE -> { - if (!isGutterDragging) return false - val dy = gutterDragLastY - event.y // drag up = positive = scroll down - gutterDragLastY = event.y - if (textOverscroll > 0f && dy > 0f) { - // Scrolling back down — reduce text overscroll first - textOverscroll = (textOverscroll - dy).coerceAtLeast(0f) - } else { - val raw = scrollOffsetY + dy - if (raw < 0f) { - scrollOffsetY = 0f - textOverscroll = (textOverscroll - raw).coerceAtLeast(0f) - } else { - scrollOffsetY = raw - } - } - drawToSurface() - onManualScroll?.invoke() - return true - } - MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { - if (!isGutterDragging) return false - isGutterDragging = false - if (textOverscroll == 0f) { - scrollOffsetY = snapToLine(scrollOffsetY) - } - drawToSurface() - if (!tutorialMode) resumeRawDrawing() - onManualScroll?.invoke() - return true - } - } - return false - } - /** * Handle filtered finger touches on the canvas. Only vertical scrolling is * allowed — no taps (avoids accidental palm taps). @@ -616,7 +559,7 @@ class HandwritingCanvasView @JvmOverloads constructor( try { val limit = Rect() limit.left = 0 - limit.right = (width - GUTTER_WIDTH).toInt() + limit.right = width limit.top = (topY - scrollOffsetY).toInt().coerceAtLeast(0) limit.bottom = (bottomY - scrollOffsetY).toInt().coerceAtMost(height) touchHelper?.setLimitRect(limit, emptyList()) @@ -626,13 +569,12 @@ class HandwritingCanvasView @JvmOverloads constructor( } } - /** Restore Onyx SDK drawing area to the full canvas minus gutter. */ + /** Restore Onyx SDK drawing area to the full canvas. */ private fun restoreLimitRect() { if (!diagramLimitActive || !useOnyxSdk) return try { val limit = Rect() getLocalVisibleRect(limit) - limit.right = (limit.right - GUTTER_WIDTH).toInt() touchHelper?.setLimitRect(limit, emptyList()) diagramLimitActive = false } catch (e: Exception) { @@ -972,6 +914,7 @@ class HandwritingCanvasView @JvmOverloads constructor( /** Common cleanup after any interactive gesture (line-drag or undo scrub). */ private fun finishInteractiveGesture() { + onPenStateChanged?.invoke(false) drawToSurface() if (useOnyxSdk) { try { @@ -998,7 +941,7 @@ class HandwritingCanvasView @JvmOverloads constructor( // Clear background canvas.drawColor(Color.WHITE) - val gutterLeft = width - GUTTER_WIDTH + val canvasRight = width.toFloat() // Apply scroll offset canvas.save() @@ -1013,9 +956,9 @@ class HandwritingCanvasView @JvmOverloads constructor( val isBottomBorder = diagramAreas.any { lineIdx == it.endLineIndex + 1 } val isInterior = diagramAreas.any { lineIdx > it.startLineIndex && lineIdx <= it.endLineIndex } if (isTopBorder || isBottomBorder) { - canvas.drawLine(0f, lineY, gutterLeft, lineY, diagramBorderPaint) + canvas.drawLine(0f, lineY, canvasRight, lineY, diagramBorderPaint) } else if (!isInterior) { - canvas.drawLine(0f, lineY, gutterLeft, lineY, linePaint) + canvas.drawLine(0f, lineY, canvasRight, lineY, linePaint) } lineY += LINE_SPACING } @@ -1036,11 +979,7 @@ class HandwritingCanvasView @JvmOverloads constructor( canvas.restore() - // Draw gutter (in screen space) - canvas.drawRect(gutterLeft, 0f, width.toFloat(), height.toFloat(), gutterPaint) - canvas.drawLine(gutterLeft, 0f, gutterLeft, height.toFloat(), gutterLinePaint) - - // Draw tutorial annotations on top of everything (including gutter) + // Draw tutorial annotations on top of everything if (annotationStrokes.isNotEmpty() || textAnnotations.isNotEmpty()) { canvas.save() canvas.translate(0f, -scrollOffsetY) diff --git a/app/src/main/java/com/writer/view/PreviewLayoutCalculator.kt b/app/src/main/java/com/writer/view/PreviewLayoutCalculator.kt new file mode 100644 index 0000000..d8e780c --- /dev/null +++ b/app/src/main/java/com/writer/view/PreviewLayoutCalculator.kt @@ -0,0 +1,165 @@ +package com.writer.view + +import com.writer.model.DiagramArea + +/** + * Pure-logic calculator for the complementary preview layout. + * + * Determines which lines are currently hidden (scrolled off the canvas), + * which diagram areas are visible in the preview, and how to size/clip + * diagram render items so the preview is flush with the canvas boundary. + * + * All methods are side-effect-free and depend only on their parameters, + * making them easy to test on JVM without Android framework dependencies. + */ +object PreviewLayoutCalculator { + + // ── Line visibility ───────────────────────────────────────────────── + + /** + * Returns the set of line indices whose bottom edge is at or above [scrollOffsetY]. + * These lines are fully scrolled off the canvas and should appear in the preview. + */ + fun currentlyHiddenLines( + lineIndices: Set, + scrollOffsetY: Float, + topMargin: Float, + lineSpacing: Float + ): Set = lineIndices.filter { lineIdx -> + val lineBottom = topMargin + lineIdx * lineSpacing + lineSpacing + lineBottom <= scrollOffsetY + }.toSet() + + /** + * Returns the set of line indices whose midpoint is at or above [scrollOffsetY]. + * Used for dimming: a line is "not yet visible" once its midpoint has scrolled off. + */ + fun notYetVisibleLines( + lineIndices: Set, + scrollOffsetY: Float, + topMargin: Float, + lineSpacing: Float + ): Set = lineIndices.filter { lineIdx -> + val lineMid = topMargin + lineIdx * lineSpacing + lineSpacing / 2f + lineMid <= scrollOffsetY + }.toSet() + + // ── Diagram visibility ────────────────────────────────────────────── + + /** Result of computing diagram visibility for the preview. */ + data class DiagramVisibility( + val startLineIndex: Int, + /** Full area height in document pixels. */ + val fullHeight: Float, + /** Document-space Y of the area top. */ + val areaTop: Float, + /** How much of the diagram is scrolled off the canvas (capped at stroke bounds). */ + val visibleHeight: Float, + /** Whether this is a partial render (not all strokes visible yet). */ + val isPartial: Boolean + ) + + /** + * Determine which diagram areas should appear in the preview and how much + * of each is visible. A diagram appears as soon as its top edge scrolls off + * the canvas. The visible height is capped at the actual stroke bottom + * (not the full area height) to avoid empty-line gaps. + */ + fun diagramVisibilities( + areas: List, + scrollOffsetY: Float, + topMargin: Float, + lineSpacing: Float, + /** For each area, the max Y of its strokes in document space (null if no strokes). */ + strokeMaxYByArea: Map, + strokeWidthPadding: Float + ): List { + return areas.mapNotNull { area -> + val areaTop = topMargin + area.startLineIndex * lineSpacing + if (areaTop >= scrollOffsetY) return@mapNotNull null // not scrolled off yet + + val fullHeight = area.heightInLines * lineSpacing + val scrolledOff = (scrollOffsetY - areaTop).coerceIn(0f, fullHeight) + + val strokeMaxY = strokeMaxYByArea[area.startLineIndex] + val strokeBottom = if (strokeMaxY != null) { + (strokeMaxY - areaTop + strokeWidthPadding).coerceAtMost(fullHeight) + } else { + fullHeight + } + + val visibleHeight = scrolledOff.coerceAtMost(strokeBottom) + + DiagramVisibility( + startLineIndex = area.startLineIndex, + fullHeight = fullHeight, + areaTop = areaTop, + visibleHeight = visibleHeight, + isPartial = visibleHeight < fullHeight + ) + } + } + + // ── Render item layout ────────────────────────────────────────────── + + /** Computed layout metrics for a diagram render item. */ + data class DiagramRenderMetrics( + val scale: Float, + val fullRenderedHeight: Float, + val renderedHeight: Float, + val isPartial: Boolean + ) + + /** + * Compute the render metrics for a diagram in the preview. + * + * @param visibleHeight How much of the diagram is scrolled off (document px) + * @param fullHeight Full diagram area height (document px) + * @param canvasWidth Width of the canvas (source coordinate space) + * @param textViewWidth Width of the text view (target coordinate space) + * @param paragraphSpacing Spacing added between render items + */ + fun diagramRenderMetrics( + visibleHeight: Float, + fullHeight: Float, + canvasWidth: Float, + textViewWidth: Float, + paragraphSpacing: Float + ): DiagramRenderMetrics { + val scale = if (canvasWidth > 0f) textViewWidth / canvasWidth else 1f + val isPartial = visibleHeight < fullHeight + val fullRenderedHeight = fullHeight * scale + paragraphSpacing + val renderedHeight = visibleHeight * scale + if (isPartial) 0f else paragraphSpacing + + return DiagramRenderMetrics( + scale = scale, + fullRenderedHeight = fullRenderedHeight, + renderedHeight = renderedHeight, + isPartial = isPartial + ) + } + + /** + * Strip trailing spacing from the last item height so the preview content + * is flush with the divider. Returns the trimmed height. + * + * @param heightPx The item's current rendered height + * @param fullHeightPx The item's full rendered height (including spacing) + * @param paragraphSpacing Spacing to remove + * @param isText True if text item, false if diagram + * @param textLayoutHeight The text StaticLayout height (only used for text items) + */ + fun trimLastItemHeight( + heightPx: Float, + fullHeightPx: Float, + paragraphSpacing: Float, + isText: Boolean, + textLayoutHeight: Float = 0f + ): Float { + return if (isText) { + textLayoutHeight + } else { + heightPx.coerceAtMost(fullHeightPx - paragraphSpacing) + } + } +} diff --git a/app/src/main/java/com/writer/view/RecognizedTextView.kt b/app/src/main/java/com/writer/view/RecognizedTextView.kt index f94b653..533a3a3 100644 --- a/app/src/main/java/com/writer/view/RecognizedTextView.kt +++ b/app/src/main/java/com/writer/view/RecognizedTextView.kt @@ -27,7 +27,7 @@ import com.writer.ui.writing.WritingCoordinator.TextSegment * Individual line segments within a paragraph can be dimmed independently * using colored spans. * - * Includes a right-side gutter with a "I" logo and resize drag handling. + * Includes a floating "I" logo icon that auto-hides during writing. */ class RecognizedTextView @JvmOverloads constructor( context: Context, @@ -36,7 +36,6 @@ class RecognizedTextView @JvmOverloads constructor( ) : View(context, attrs, defStyleAttr) { companion object { - private val GUTTER_WIDTH get() = ScreenMetrics.gutterWidth private val HORIZONTAL_PADDING get() = ScreenMetrics.dp(21f) private val PARAGRAPH_SPACING get() = ScreenMetrics.dp(12f) private val LIST_ITEM_SPACING get() = ScreenMetrics.dp(3f) @@ -45,7 +44,6 @@ class RecognizedTextView @JvmOverloads constructor( private val BULLET_HANG_INDENT get() = ScreenMetrics.dp(54f).toInt() private const val BULLET_PREFIX = "\u2022 " private val HEADING_SPACING_AFTER get() = ScreenMetrics.dp(6f) - private val BOTTOM_PADDING get() = ScreenMetrics.dp(5f) } private val textPaint = TextPaint().apply { @@ -56,9 +54,6 @@ class RecognizedTextView @JvmOverloads constructor( private val dimmedColor = CanvasTheme.LINE_COLOR - private val gutterPaint = CanvasTheme.newGutterFillPaint() - private val gutterLinePaint = CanvasTheme.newGutterLinePaint() - private val logoPaint = TextPaint().apply { color = Color.BLACK textSize = ScreenMetrics.textLogo @@ -137,6 +132,7 @@ class RecognizedTextView @JvmOverloads constructor( val strokes: List, val scale: Float, val offsetY: Float, + val fullHeightPx: Float, override val heightPx: Float, val lineIndex: Int ) : RenderItem() @@ -169,8 +165,11 @@ class RecognizedTextView @JvmOverloads constructor( /** Pixel offset to scroll text content upward (for viewing earlier text). */ var textContentScroll: Float = 0f - /** Called when the user drags the gutter. Delta is positive = drag down. */ - var onGutterDrag: ((Float) -> Unit)? = null + /** Called when the user scrolls the text view. Delta is in pixels (positive = finger drag down). */ + var onScroll: ((Float) -> Unit)? = null + + /** Called when a scroll gesture ends (finger lifted). */ + var onScrollEnd: (() -> Unit)? = null /** Called when the user taps the "I" logo. */ var onLogoTap: (() -> Unit)? = null @@ -192,11 +191,13 @@ class RecognizedTextView @JvmOverloads constructor( invalidate() } - // Gutter drag state - private var isGutterDragging = false - private var gutterDragLastY = 0f - private var gutterDragStartY = 0f - private var gutterDragMoved = false + // Floating icon visibility (auto-hides during writing) + private var iconVisible = true + private val iconSize get() = ScreenMetrics.dp(56f) + private val iconShowRunnable = Runnable { + iconVisible = true + invalidate() + } // Text tap tracking private var textTapDownX = 0f @@ -210,6 +211,19 @@ class RecognizedTextView @JvmOverloads constructor( private var fingerScrollActive = false private var fingerScrollLastY = 0f + /** Called by WritingActivity when pen state changes on the canvas. */ + fun onPenStateChanged(active: Boolean) { + handler.removeCallbacks(iconShowRunnable) + if (active) { + if (iconVisible) { + iconVisible = false + invalidate() + } + } else { + handler.postDelayed(iconShowRunnable, 300L) + } + } + fun setParagraphs(paragraphs: List>) { setContent(paragraphs, emptyList()) } @@ -221,7 +235,7 @@ class RecognizedTextView @JvmOverloads constructor( } private fun rebuildRenderItems(paragraphs: List>, diagrams: List) { - val availableWidth = (width - HORIZONTAL_PADDING - HandwritingCanvasView.GUTTER_WIDTH).toInt() + val availableWidth = (width - 2 * HORIZONTAL_PADDING).toInt() if (availableWidth <= 0) return data class Indexed(val lineIndex: Int, val item: RenderItem, val lineHeights: List>) @@ -323,20 +337,46 @@ class RecognizedTextView @JvmOverloads constructor( // Build diagram render items (full width, no text padding) val diagramItems = diagrams.map { diagram -> - val fullWidth = width - HandwritingCanvasView.GUTTER_WIDTH - val scale = if (diagram.canvasWidth > 0f) fullWidth / diagram.canvasWidth else 1f - val renderedHeight = diagram.heightPx * scale + PARAGRAPH_SPACING + val metrics = PreviewLayoutCalculator.diagramRenderMetrics( + visibleHeight = diagram.visibleHeightPx, + fullHeight = diagram.heightPx, + canvasWidth = diagram.canvasWidth, + textViewWidth = width.toFloat(), + paragraphSpacing = PARAGRAPH_SPACING + ) Indexed( lineIndex = diagram.startLineIndex, - item = DiagramRenderItem(diagram.strokes, scale, diagram.offsetY, renderedHeight, diagram.startLineIndex), - lineHeights = listOf(Pair(diagram.startLineIndex, renderedHeight)) + item = DiagramRenderItem(diagram.strokes, metrics.scale, diagram.offsetY, metrics.fullRenderedHeight, metrics.renderedHeight, diagram.startLineIndex), + lineHeights = listOf(Pair(diagram.startLineIndex, metrics.renderedHeight)) ) } // Merge and sort by line index val allItems = (textItems + diagramItems).sortedBy { it.lineIndex } - renderItems = allItems.map { it.item } + // Strip trailing spacing from the last item so content is flush with the divider + val items = allItems.map { it.item }.toMutableList() + if (items.isNotEmpty()) { + val last = items.last() + when (last) { + is TextRenderItem -> { + val trimmedHeight = PreviewLayoutCalculator.trimLastItemHeight( + last.heightPx, last.heightPx, PARAGRAPH_SPACING, + isText = true, textLayoutHeight = last.layout.height.toFloat() + ) + items[items.lastIndex] = last.copy(heightPx = trimmedHeight) + } + is DiagramRenderItem -> { + val trimmedHeight = PreviewLayoutCalculator.trimLastItemHeight( + last.heightPx, last.fullHeightPx, PARAGRAPH_SPACING, + isText = false + ) + items[items.lastIndex] = last.copy(heightPx = trimmedHeight) + } + } + } + + renderItems = items paragraphHeights = renderItems.map { it.heightPx } writtenLineHeights = allItems.flatMap { it.lineHeights } totalTextHeight = paragraphHeights.sum().toInt() @@ -347,6 +387,11 @@ class RecognizedTextView @JvmOverloads constructor( // Can't rebuild without paragraph data; next setParagraphs call will handle it } + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + handler.removeCallbacks(iconShowRunnable) + } + // --- Touch handling --- override fun onTouchEvent(event: MotionEvent): Boolean { @@ -357,13 +402,8 @@ class RecognizedTextView @JvmOverloads constructor( return handleFingerTouch(event) } - // If already in a gutter drag, keep handling even if pen leaves gutter area - if (isGutterDragging) { - return handleGutterTouch(event) - } - // Tutorial: close button tap at top of text area - if (tutorialMode && event.x < width - HandwritingCanvasView.GUTTER_WIDTH && event.y < closeButtonHeight) { + if (tutorialMode && event.y < closeButtonHeight) { if (event.action == MotionEvent.ACTION_DOWN) { return true } @@ -373,9 +413,13 @@ class RecognizedTextView @JvmOverloads constructor( } } - // Stylus/mouse in gutter area → resize drag - if (event.x >= width - HandwritingCanvasView.GUTTER_WIDTH) { - return handleGutterTouch(event) + // Floating icon tap detection + if (iconVisible && event.action == MotionEvent.ACTION_DOWN && isInIconArea(event.x, event.y)) { + return true + } + if (iconVisible && event.action == MotionEvent.ACTION_UP && isInIconArea(event.x, event.y)) { + onLogoTap?.invoke() + return true } // Stylus/mouse in text area → detect taps to scroll canvas to that line @@ -415,7 +459,7 @@ class RecognizedTextView @JvmOverloads constructor( /** * Handle filtered finger touches on the text view. - * Allows: logo tap, text tap, gutter drag, text body scroll. + * Allows: logo tap, text tap, text body scroll. */ private fun handleFingerTouch(event: MotionEvent): Boolean { val tf = touchFilter ?: return false @@ -439,23 +483,18 @@ class RecognizedTextView @JvmOverloads constructor( } val dy = event.y - fingerScrollLastY fingerScrollLastY = event.y - textContentScroll += dy - invalidate() + onScroll?.invoke(dy) return true } MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { fingerScrollActive = false + onScrollEnd?.invoke() return true } } return true } - // If already in a gutter drag, keep handling - if (isGutterDragging) { - return handleGutterTouch(event) - } - when (event.action) { MotionEvent.ACTION_DOWN -> { if (tf.evaluateDown( @@ -469,13 +508,13 @@ class RecognizedTextView @JvmOverloads constructor( return false } - // Gutter area → resize drag - if (event.x >= width - HandwritingCanvasView.GUTTER_WIDTH) { - return handleGutterTouch(event) + // Floating icon tap + if (iconVisible && isInIconArea(event.x, event.y)) { + return true } // Tutorial close button - if (tutorialMode && event.x < width - HandwritingCanvasView.GUTTER_WIDTH && event.y < closeButtonHeight) { + if (tutorialMode && event.y < closeButtonHeight) { return true } @@ -515,13 +554,16 @@ class RecognizedTextView @JvmOverloads constructor( if (fingerScrollActive) { val dy = event.y - fingerScrollLastY fingerScrollLastY = event.y - textContentScroll += dy - invalidate() + onScroll?.invoke(dy) } return true } MotionEvent.ACTION_UP -> { - if (tutorialMode && event.x < width - HandwritingCanvasView.GUTTER_WIDTH && event.y < closeButtonHeight) { + if (iconVisible && isInIconArea(event.x, event.y)) { + onLogoTap?.invoke() + return true + } + if (tutorialMode && event.y < closeButtonHeight) { onCloseTutorialTap?.invoke() return true } @@ -542,34 +584,9 @@ class RecognizedTextView @JvmOverloads constructor( return false } - private fun handleGutterTouch(event: MotionEvent): Boolean { - when (event.action) { - MotionEvent.ACTION_DOWN -> { - isGutterDragging = true - gutterDragLastY = event.y - gutterDragStartY = event.y - gutterDragMoved = false - return true - } - MotionEvent.ACTION_MOVE -> { - if (!isGutterDragging) return false - val dy = event.y - gutterDragLastY // drag down = positive - gutterDragLastY = event.y - if (Math.abs(event.y - gutterDragStartY) > 20f) gutterDragMoved = true - onGutterDrag?.invoke(dy) - return true - } - MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { - if (!isGutterDragging) return false - isGutterDragging = false - // Detect tap on the logo area (top of gutter, no significant drag) - if (!gutterDragMoved && gutterDragStartY < 130f) { - onLogoTap?.invoke() - } - return true - } - } - return false + /** Check if touch coordinates are within the floating icon tap target. */ + private fun isInIconArea(x: Float, y: Float): Boolean { + return x >= width - iconSize && y <= iconSize } /** Resolve a tap at screen coordinates (x, y) to a written lineIndex. */ @@ -577,7 +594,7 @@ class RecognizedTextView @JvmOverloads constructor( if (renderItems.isEmpty()) return val callback = onTextTap ?: return - val baseY = height - totalTextHeight - BOTTOM_PADDING + val baseY = (height - totalTextHeight).toFloat() val startY = baseY + textScrollOffset + textContentScroll val localX = x - HORIZONTAL_PADDING @@ -617,27 +634,24 @@ class RecognizedTextView @JvmOverloads constructor( override fun onDraw(canvas: Canvas) { super.onDraw(canvas) - val gutterLeft = width - HandwritingCanvasView.GUTTER_WIDTH - val gutterCenterX = gutterLeft + HandwritingCanvasView.GUTTER_WIDTH / 2f - // Draw text content or status/hint message if (statusMessage.isNotEmpty() && renderItems.isEmpty()) { // Draw status message centered in the content area - val contentCenterX = (width - HandwritingCanvasView.GUTTER_WIDTH) / 2f + val contentCenterX = width / 2f val contentCenterY = height / 2f canvas.drawText(statusMessage, contentCenterX, contentCenterY, statusPaint) if (statusSubtext.isNotEmpty()) { canvas.drawText(statusSubtext, contentCenterX, contentCenterY + 50f, statusSubtextPaint) } } else if (showScrollHint && renderItems.isEmpty() && !tutorialMode) { - val contentCenterX = (width - HandwritingCanvasView.GUTTER_WIDTH) / 2f + val contentCenterX = width / 2f val contentCenterY = height / 2f canvas.drawText("Scroll to turn writing into text", contentCenterX, contentCenterY, scrollHintPaint) } else if (renderItems.isNotEmpty()) { val baseY = if (tutorialMode) { closeButtonHeight + 5f // top-align below close button } else { - height - totalTextHeight - BOTTOM_PADDING + (height - totalTextHeight).toFloat() } val startY = baseY + textScrollOffset + textContentScroll @@ -655,17 +669,16 @@ class RecognizedTextView @JvmOverloads constructor( canvas.restore() } - // Draw gutter background - canvas.drawRect(gutterLeft, 0f, width.toFloat(), height.toFloat(), gutterPaint) - canvas.drawLine(gutterLeft, 0f, gutterLeft, height.toFloat(), gutterLinePaint) - - // Draw "I" logo in the top of the gutter - val logoY = 100f - canvas.drawText("I", gutterCenterX, logoY, logoPaint) + // Draw floating "I" icon in top-right corner + if (iconVisible) { + val iconCenterX = width - iconSize / 2f + val logoY = iconSize * 0.7f + canvas.drawText("I", iconCenterX, logoY, logoPaint) + } if (tutorialMode) { // "Close Tutorial" button centered at top of text area - val contentCenterX = (width - HandwritingCanvasView.GUTTER_WIDTH) / 2f + val contentCenterX = width / 2f val btnTextY = 52f canvas.drawText("Close Tutorial", contentCenterX, btnTextY, closeButtonPaint) val btnTextWidth = closeButtonPaint.measureText("Close Tutorial") @@ -680,29 +693,30 @@ class RecognizedTextView @JvmOverloads constructor( closeButtonBorderPaint ) - // Arrow pointing at "I" logo saying "Menu" - val menuArrowY = logoY - 20f - val menuArrowRight = gutterLeft - 10f - val menuArrowLeft = gutterLeft - 180f + // Arrow pointing at floating "I" icon saying "Menu" + val iconCenterX = width - iconSize / 2f + val menuArrowY = iconSize * 0.5f + val menuArrowRight = iconCenterX - iconSize / 2f - 10f + val menuArrowLeft = menuArrowRight - 170f canvas.drawLine(menuArrowLeft, menuArrowY, menuArrowRight, menuArrowY, tutorialAnnotationPaint) canvas.drawLine(menuArrowRight - 20f, menuArrowY - 12f, menuArrowRight, menuArrowY, tutorialAnnotationPaint) canvas.drawLine(menuArrowRight - 20f, menuArrowY + 12f, menuArrowRight, menuArrowY, tutorialAnnotationPaint) canvas.drawText("Menu", menuArrowLeft - 110f, menuArrowY + 12f, tutorialTextPaint) - // "Drag gutter to resize" with arrow pointing right toward gutter + // "Drag divider to resize" annotation at bottom val resizeY = height - 60f - val resizeLeft = gutterLeft - 370f - val resizeRight = gutterLeft - 20f - canvas.drawLine(resizeLeft, resizeY, resizeRight, resizeY, tutorialAnnotationPaint) - canvas.drawLine(resizeRight - 20f, resizeY - 12f, resizeRight, resizeY, tutorialAnnotationPaint) - canvas.drawLine(resizeRight - 20f, resizeY + 12f, resizeRight, resizeY, tutorialAnnotationPaint) - canvas.drawText("Drag this gutter to resize", resizeLeft - 10f, resizeY - 21f, tutorialTextPaint) + canvas.drawText("Drag divider to resize", width / 2f, resizeY, tutorialTextPaint) } } private fun drawDiagramItem(canvas: Canvas, item: DiagramRenderItem) { if (item.strokes.isEmpty()) return canvas.save() + // Always clip diagram to its allocated height so strokes don't overflow into adjacent items + canvas.clipRect( + -HORIZONTAL_PADDING, 0f, + width.toFloat(), item.heightPx + ) // Undo the HORIZONTAL_PADDING translation so diagram uses full page width canvas.translate(-HORIZONTAL_PADDING, 0f) canvas.scale(item.scale, item.scale) diff --git a/app/src/main/java/com/writer/view/ScreenMetrics.kt b/app/src/main/java/com/writer/view/ScreenMetrics.kt index ea48ff9..3b81917 100644 --- a/app/src/main/java/com/writer/view/ScreenMetrics.kt +++ b/app/src/main/java/com/writer/view/ScreenMetrics.kt @@ -31,16 +31,10 @@ object ScreenMetrics { // ── Standard dp constants (Go 7, Note 5C, Tab X C) ─────────────────────── private const val LINE_SPACING_DP = 63f // ≈ 10.0 mm - private const val GUTTER_TARGET_DP = 69f // ≈ 11.0 mm - private const val GUTTER_MIN_DP = 57f // ≈ 9.0 mm (hard floor) - private const val GUTTER_MAX_FRACTION = 0.12f // ≤ 12 % of screen width private const val CANVAS_FRACTION = 0.70f // 70 % of screen height for canvas // ── Compact dp constants (Palma 2 Pro) ─────────────────────────────────── private const val LINE_SPACING_COMPACT_DP = 41f // ≈ 6.5 mm - private const val GUTTER_TARGET_COMPACT_DP = 47f // ≈ 7.5 mm - private const val GUTTER_MIN_COMPACT_DP = 38f // ≈ 6.0 mm (stylus floor) - private const val GUTTER_MAX_FRACTION_COMPACT = 0.09f // ≤ 9 % of screen width private const val CANVAS_FRACTION_COMPACT = 0.82f // 82 % of screen height for canvas // ── Shared dp constants ─────────────────────────────────────────────────── @@ -68,7 +62,6 @@ object ScreenMetrics { var lineSpacing: Float = 100f; private set var topMargin: Float = 30f; private set - var gutterWidth: Float = 110f; private set var strokeWidth: Float = 4f; private set var textBody: Float = 52f; private set var textLogo: Float = 96f; private set @@ -106,7 +99,7 @@ object ScreenMetrics { * @param density [DisplayMetrics.density] (= densityDpi / 160) * @param fontScale User font scale preference (1.0 = default, >1.0 = larger text) * @param smallestWidthDp [Configuration.smallestScreenWidthDp] - * @param widthPixels screen width in pixels (used for gutter cap) + * @param widthPixels screen width in pixels * @param heightPixels screen height in pixels */ fun init( @@ -132,18 +125,11 @@ object ScreenMetrics { isCompact = smallestWidthDp < COMPACT_SW_DP val lineSpacingDp = if (isCompact) LINE_SPACING_COMPACT_DP else LINE_SPACING_DP - val gutterTargetDp = if (isCompact) GUTTER_TARGET_COMPACT_DP else GUTTER_TARGET_DP - val gutterMinDp = if (isCompact) GUTTER_MIN_COMPACT_DP else GUTTER_MIN_DP - val gutterMaxFrac = if (isCompact) GUTTER_MAX_FRACTION_COMPACT else GUTTER_MAX_FRACTION canvasFraction = if (isCompact) CANVAS_FRACTION_COMPACT else CANVAS_FRACTION lineSpacing = (lineSpacingDp * this.density).roundToInt().toFloat() topMargin = (TOP_MARGIN_DP * this.density).roundToInt().toFloat() strokeWidth = STROKE_WIDTH_DP * this.density - gutterWidth = (gutterTargetDp * this.density) - .coerceAtMost(widthPixels * gutterMaxFrac) - .coerceAtLeast(gutterMinDp * this.density) - .roundToInt().toFloat() } private fun computeTextSizes() { diff --git a/app/src/main/java/com/writer/view/SplitLayout.kt b/app/src/main/java/com/writer/view/SplitLayout.kt new file mode 100644 index 0000000..8cd4ca2 --- /dev/null +++ b/app/src/main/java/com/writer/view/SplitLayout.kt @@ -0,0 +1,88 @@ +package com.writer.view + +import android.content.Context +import android.util.AttributeSet +import android.view.MotionEvent +import android.view.View +import android.widget.LinearLayout + +/** + * A vertical LinearLayout that intercepts touch events near the divider + * to enable split-resize dragging. The divider stays visually thin (1dp) + * while the touch target is expanded to [TOUCH_TARGET_DP] on each side. + * + * Set [dividerView] after inflation and [onSplitDrag] to receive drag deltas. + */ +class SplitLayout @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : LinearLayout(context, attrs, defStyleAttr) { + + companion object { + /** Touch target expansion on each side of the divider, in dp. */ + private const val TOUCH_TARGET_DP = 24f + } + + /** The divider View whose position defines the drag zone. Must be set after inflation. */ + var dividerView: View? = null + + /** Called during a split drag with the raw Y delta in pixels. */ + var onSplitDrag: ((delta: Float) -> Unit)? = null + + /** Called when a split drag starts. */ + var onSplitDragStart: (() -> Unit)? = null + + /** Called when a split drag ends. */ + var onSplitDragEnd: (() -> Unit)? = null + + private var dragging = false + private var dragLastY = 0f + + private val touchTargetPx get() = ScreenMetrics.dp(TOUCH_TARGET_DP) + + override fun onInterceptTouchEvent(ev: MotionEvent): Boolean { + // Only intercept finger touches — stylus must pass through for writing + if (ev.getToolType(0) != MotionEvent.TOOL_TYPE_FINGER) { + return super.onInterceptTouchEvent(ev) + } + + val divider = dividerView ?: return super.onInterceptTouchEvent(ev) + + when (ev.action) { + MotionEvent.ACTION_DOWN -> { + if (isNearDivider(ev.y, divider)) { + dragging = true + dragLastY = ev.rawY + onSplitDragStart?.invoke() + return true + } + } + } + return super.onInterceptTouchEvent(ev) + } + + override fun onTouchEvent(event: MotionEvent): Boolean { + if (!dragging) return super.onTouchEvent(event) + + when (event.action) { + MotionEvent.ACTION_MOVE -> { + val delta = event.rawY - dragLastY + dragLastY = event.rawY + onSplitDrag?.invoke(delta) + return true + } + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + dragging = false + onSplitDragEnd?.invoke() + return true + } + } + return true + } + + private fun isNearDivider(y: Float, divider: View): Boolean { + val dividerCenter = divider.top + divider.height / 2f + return kotlin.math.abs(y - dividerCenter) <= touchTargetPx + } +} diff --git a/app/src/main/res/layout/activity_writing.xml b/app/src/main/res/layout/activity_writing.xml index 83dd92c..53fd024 100644 --- a/app/src/main/res/layout/activity_writing.xml +++ b/app/src/main/res/layout/activity_writing.xml @@ -1,6 +1,7 @@ - - + @@ -27,4 +29,4 @@ android:layout_height="0dp" android:layout_weight="3" /> - + diff --git a/app/src/test/java/com/writer/view/PreviewLayoutCalculatorTest.kt b/app/src/test/java/com/writer/view/PreviewLayoutCalculatorTest.kt new file mode 100644 index 0000000..db67ea9 --- /dev/null +++ b/app/src/test/java/com/writer/view/PreviewLayoutCalculatorTest.kt @@ -0,0 +1,405 @@ +package com.writer.view + +import com.writer.model.DiagramArea +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Tests for [PreviewLayoutCalculator] — the pure-logic engine that decides + * what appears in the preview and how it's sized for flush alignment with + * the canvas boundary. + * + * Uses concrete numbers based on the Go 7 device (density 1.875, line + * spacing 118px, top margin 36px) for readability, but the logic is + * density-independent. + */ +class PreviewLayoutCalculatorTest { + + companion object { + // Go 7 at 300 PPI (density = 1.875) + const val LINE_SPACING = 118f // 63dp * 1.875 + const val TOP_MARGIN = 36f // 19dp * 1.875 + const val STROKE_WIDTH = 5f + const val PARAGRAPH_SPACING = 22f // 12dp * 1.875 (approx) + const val CANVAS_WIDTH = 824f + const val TEXT_VIEW_WIDTH = 824f // same width, no gutter + } + + private fun lineTop(idx: Int) = TOP_MARGIN + idx * LINE_SPACING + private fun lineBottom(idx: Int) = lineTop(idx) + LINE_SPACING + private fun lineMid(idx: Int) = lineTop(idx) + LINE_SPACING / 2f + + // ── currentlyHiddenLines ──────────────────────────────────────────── + + @Test fun hidden_noScroll_nothingHidden() { + val hidden = PreviewLayoutCalculator.currentlyHiddenLines( + lineIndices = setOf(0, 1, 2), + scrollOffsetY = 0f, + topMargin = TOP_MARGIN, + lineSpacing = LINE_SPACING + ) + assertTrue("No lines should be hidden at scroll=0", hidden.isEmpty()) + } + + @Test fun hidden_scrollPastFirstLine_firstLineHidden() { + val hidden = PreviewLayoutCalculator.currentlyHiddenLines( + lineIndices = setOf(0, 1, 2), + scrollOffsetY = lineBottom(0), + topMargin = TOP_MARGIN, + lineSpacing = LINE_SPACING + ) + assertEquals(setOf(0), hidden) + } + + @Test fun hidden_scrollPartiallyPastLine_notHidden() { + // Scroll to just before line 0's bottom — not fully hidden + val hidden = PreviewLayoutCalculator.currentlyHiddenLines( + lineIndices = setOf(0, 1, 2), + scrollOffsetY = lineBottom(0) - 1f, + topMargin = TOP_MARGIN, + lineSpacing = LINE_SPACING + ) + assertTrue("Line 0 should NOT be hidden when scroll is 1px short", hidden.isEmpty()) + } + + @Test fun hidden_scrollPastMultipleLines() { + val hidden = PreviewLayoutCalculator.currentlyHiddenLines( + lineIndices = setOf(0, 1, 2, 3, 4), + scrollOffsetY = lineBottom(2), + topMargin = TOP_MARGIN, + lineSpacing = LINE_SPACING + ) + assertEquals(setOf(0, 1, 2), hidden) + } + + @Test fun hidden_onlyLinesWithStrokes_emptyGapsIgnored() { + // Lines 0 and 3 have strokes, lines 1 and 2 don't + val hidden = PreviewLayoutCalculator.currentlyHiddenLines( + lineIndices = setOf(0, 3), + scrollOffsetY = lineBottom(2), + topMargin = TOP_MARGIN, + lineSpacing = LINE_SPACING + ) + // Line 0 is hidden, line 3 is not (its bottom is below scroll) + assertEquals(setOf(0), hidden) + } + + // ── notYetVisibleLines ────────────────────────────────────────────── + + @Test fun notYetVisible_usesLineMidpoint() { + // Scroll to exactly line 1's midpoint + val notVisible = PreviewLayoutCalculator.notYetVisibleLines( + lineIndices = setOf(0, 1, 2), + scrollOffsetY = lineMid(1), + topMargin = TOP_MARGIN, + lineSpacing = LINE_SPACING + ) + // Line 0 midpoint is above scroll, line 1 midpoint is exactly at scroll (<=) + assertEquals(setOf(0, 1), notVisible) + } + + @Test fun notYetVisible_justBeforeMidpoint_notIncluded() { + val notVisible = PreviewLayoutCalculator.notYetVisibleLines( + lineIndices = setOf(0, 1), + scrollOffsetY = lineMid(1) - 1f, + topMargin = TOP_MARGIN, + lineSpacing = LINE_SPACING + ) + assertEquals(setOf(0), notVisible) + } + + // ── diagramVisibilities ────────────────────────────────────────────── + + @Test fun diagram_notScrolledOff_notIncluded() { + val areas = listOf(DiagramArea(startLineIndex = 5, heightInLines = 3)) + val result = PreviewLayoutCalculator.diagramVisibilities( + areas = areas, + scrollOffsetY = lineTop(5) - 1f, // 1px before area top + topMargin = TOP_MARGIN, + lineSpacing = LINE_SPACING, + strokeMaxYByArea = emptyMap(), + strokeWidthPadding = STROKE_WIDTH + ) + assertTrue("Diagram should not be included before any part scrolls off", result.isEmpty()) + } + + @Test fun diagram_1pxScrolledOff_included() { + val areas = listOf(DiagramArea(startLineIndex = 5, heightInLines = 3)) + val areaTop = lineTop(5) + val result = PreviewLayoutCalculator.diagramVisibilities( + areas = areas, + scrollOffsetY = areaTop + 1f, + topMargin = TOP_MARGIN, + lineSpacing = LINE_SPACING, + strokeMaxYByArea = emptyMap(), + strokeWidthPadding = STROKE_WIDTH + ) + assertEquals(1, result.size) + assertEquals(1f, result[0].visibleHeight, 0.01f) + assertTrue("Should be partial", result[0].isPartial) + } + + @Test fun diagram_fullyScrolledOff_fullHeight() { + val areas = listOf(DiagramArea(startLineIndex = 2, heightInLines = 4)) + val areaTop = lineTop(2) + val fullHeight = 4 * LINE_SPACING + val result = PreviewLayoutCalculator.diagramVisibilities( + areas = areas, + scrollOffsetY = areaTop + fullHeight + 100f, // well past + topMargin = TOP_MARGIN, + lineSpacing = LINE_SPACING, + strokeMaxYByArea = emptyMap(), + strokeWidthPadding = STROKE_WIDTH + ) + assertEquals(1, result.size) + assertEquals(fullHeight, result[0].visibleHeight, 0.01f) + assertFalse("Should not be partial when fully visible", result[0].isPartial) + } + + @Test fun diagram_croppedToStrokeBounds() { + val area = DiagramArea(startLineIndex = 3, heightInLines = 5) + val areaTop = lineTop(3) + val fullHeight = 5 * LINE_SPACING + // Strokes only reach 3 lines down (not all 5) + val strokeMaxY = areaTop + 3 * LINE_SPACING - 10f + + val result = PreviewLayoutCalculator.diagramVisibilities( + areas = listOf(area), + scrollOffsetY = areaTop + fullHeight, // fully scrolled + topMargin = TOP_MARGIN, + lineSpacing = LINE_SPACING, + strokeMaxYByArea = mapOf(3 to strokeMaxY), + strokeWidthPadding = STROKE_WIDTH + ) + assertEquals(1, result.size) + val expectedStrokeBottom = strokeMaxY - areaTop + STROKE_WIDTH + assertEquals(expectedStrokeBottom, result[0].visibleHeight, 0.01f) + assertTrue("Cropped diagram should be partial", result[0].isPartial) + } + + @Test fun diagram_strokeBoundsExceedArea_clampedToFullHeight() { + val area = DiagramArea(startLineIndex = 0, heightInLines = 3) + val areaTop = lineTop(0) + val fullHeight = 3 * LINE_SPACING + // Stroke maxY is beyond the area bottom + val strokeMaxY = areaTop + fullHeight + 50f + + val result = PreviewLayoutCalculator.diagramVisibilities( + areas = listOf(area), + scrollOffsetY = areaTop + fullHeight, + topMargin = TOP_MARGIN, + lineSpacing = LINE_SPACING, + strokeMaxYByArea = mapOf(0 to strokeMaxY), + strokeWidthPadding = STROKE_WIDTH + ) + assertEquals(fullHeight, result[0].visibleHeight, 0.01f) + } + + @Test fun diagram_partialScroll_croppedToScrolledOff() { + val area = DiagramArea(startLineIndex = 2, heightInLines = 5) + val areaTop = lineTop(2) + // Scroll only 1 line into the diagram + val scrolledOff = LINE_SPACING + // Strokes go all the way down + val strokeMaxY = areaTop + 4.5f * LINE_SPACING + + val result = PreviewLayoutCalculator.diagramVisibilities( + areas = listOf(area), + scrollOffsetY = areaTop + scrolledOff, + topMargin = TOP_MARGIN, + lineSpacing = LINE_SPACING, + strokeMaxYByArea = mapOf(2 to strokeMaxY), + strokeWidthPadding = STROKE_WIDTH + ) + // visibleHeight = min(scrolledOff, strokeBottom) = scrolledOff (since strokes go further) + assertEquals(scrolledOff, result[0].visibleHeight, 0.01f) + } + + @Test fun diagram_noStrokes_usesFullHeight() { + val area = DiagramArea(startLineIndex = 1, heightInLines = 3) + val areaTop = lineTop(1) + val fullHeight = 3 * LINE_SPACING + + val result = PreviewLayoutCalculator.diagramVisibilities( + areas = listOf(area), + scrollOffsetY = areaTop + fullHeight, + topMargin = TOP_MARGIN, + lineSpacing = LINE_SPACING, + strokeMaxYByArea = emptyMap(), // no strokes + strokeWidthPadding = STROKE_WIDTH + ) + assertEquals(fullHeight, result[0].visibleHeight, 0.01f) + } + + @Test fun diagram_multipleDiagrams_correctOrder() { + val areas = listOf( + DiagramArea(startLineIndex = 1, heightInLines = 2), + DiagramArea(startLineIndex = 5, heightInLines = 3), + DiagramArea(startLineIndex = 10, heightInLines = 2) + ) + // Scroll past the first two, not the third + val scrollOffsetY = lineTop(8) + + val result = PreviewLayoutCalculator.diagramVisibilities( + areas = areas, + scrollOffsetY = scrollOffsetY, + topMargin = TOP_MARGIN, + lineSpacing = LINE_SPACING, + strokeMaxYByArea = emptyMap(), + strokeWidthPadding = STROKE_WIDTH + ) + assertEquals(2, result.size) + assertEquals(1, result[0].startLineIndex) + assertEquals(5, result[1].startLineIndex) + } + + // ── diagramRenderMetrics ──────────────────────────────────────────── + + @Test fun renderMetrics_sameWidth_scale1() { + val metrics = PreviewLayoutCalculator.diagramRenderMetrics( + visibleHeight = 200f, + fullHeight = 400f, + canvasWidth = CANVAS_WIDTH, + textViewWidth = CANVAS_WIDTH, // same width + paragraphSpacing = PARAGRAPH_SPACING + ) + assertEquals(1f, metrics.scale, 0.001f) + assertTrue(metrics.isPartial) + assertEquals(200f, metrics.renderedHeight, 0.01f) // no spacing for partial + assertEquals(400f + PARAGRAPH_SPACING, metrics.fullRenderedHeight, 0.01f) + } + + @Test fun renderMetrics_fullDiagram_includesSpacing() { + val metrics = PreviewLayoutCalculator.diagramRenderMetrics( + visibleHeight = 400f, + fullHeight = 400f, + canvasWidth = CANVAS_WIDTH, + textViewWidth = CANVAS_WIDTH, + paragraphSpacing = PARAGRAPH_SPACING + ) + assertFalse(metrics.isPartial) + assertEquals(400f + PARAGRAPH_SPACING, metrics.renderedHeight, 0.01f) + } + + @Test fun renderMetrics_partialDiagram_noSpacing() { + val metrics = PreviewLayoutCalculator.diagramRenderMetrics( + visibleHeight = 100f, + fullHeight = 400f, + canvasWidth = CANVAS_WIDTH, + textViewWidth = CANVAS_WIDTH, + paragraphSpacing = PARAGRAPH_SPACING + ) + assertTrue(metrics.isPartial) + assertEquals(100f, metrics.renderedHeight, 0.01f) // no spacing + } + + @Test fun renderMetrics_differentWidths_scalesCorrectly() { + val metrics = PreviewLayoutCalculator.diagramRenderMetrics( + visibleHeight = 200f, + fullHeight = 400f, + canvasWidth = 800f, + textViewWidth = 400f, // half width + paragraphSpacing = PARAGRAPH_SPACING + ) + assertEquals(0.5f, metrics.scale, 0.001f) + assertEquals(200f * 0.5f, metrics.renderedHeight, 0.01f) + } + + // ── trimLastItemHeight ────────────────────────────────────────────── + + @Test fun trimLast_textItem_usesLayoutHeight() { + val result = PreviewLayoutCalculator.trimLastItemHeight( + heightPx = 150f, // includes spacing + fullHeightPx = 150f, + paragraphSpacing = PARAGRAPH_SPACING, + isText = true, + textLayoutHeight = 128f // raw layout height without spacing + ) + assertEquals(128f, result, 0.01f) + } + + @Test fun trimLast_diagramItem_removesSpacing() { + val fullRendered = 400f + PARAGRAPH_SPACING + val result = PreviewLayoutCalculator.trimLastItemHeight( + heightPx = fullRendered, + fullHeightPx = fullRendered, + paragraphSpacing = PARAGRAPH_SPACING, + isText = false + ) + assertEquals(400f, result, 0.01f) + } + + @Test fun trimLast_partialDiagram_alreadySmall_noChange() { + // Partial diagram: heightPx = 100 (no spacing), fullHeightPx = 422 + val result = PreviewLayoutCalculator.trimLastItemHeight( + heightPx = 100f, + fullHeightPx = 400f + PARAGRAPH_SPACING, + paragraphSpacing = PARAGRAPH_SPACING, + isText = false + ) + // coerceAtMost(422 - 22) = coerceAtMost(400) → 100 (already smaller) + assertEquals(100f, result, 0.01f) + } + + // ── Complementary view invariants ─────────────────────────────────── + + @Test fun complementary_previewAndCanvasShowComplementaryContent() { + // For any scroll position, the hidden lines + visible lines should + // cover all lines with no overlap. + val allLines = setOf(0, 1, 2, 3, 4, 5) + val scrollOffsetY = lineBottom(2) // lines 0-2 hidden + + val hidden = PreviewLayoutCalculator.currentlyHiddenLines( + allLines, scrollOffsetY, TOP_MARGIN, LINE_SPACING + ) + val visible = allLines - hidden + + assertEquals("Hidden + visible should cover all lines", allLines, hidden + visible) + assertTrue("Hidden and visible should not overlap", hidden.intersect(visible).isEmpty()) + assertEquals(setOf(0, 1, 2), hidden) + assertEquals(setOf(3, 4, 5), visible) + } + + @Test fun complementary_diagramVisibleHeight_matchesScrolledOffPortion() { + // The preview shows exactly what's scrolled off — no more, no less. + val area = DiagramArea(startLineIndex = 3, heightInLines = 4) + val areaTop = lineTop(3) + val scrolledAmount = 2.5f * LINE_SPACING + val scrollOffsetY = areaTop + scrolledAmount + + val result = PreviewLayoutCalculator.diagramVisibilities( + areas = listOf(area), + scrollOffsetY = scrollOffsetY, + topMargin = TOP_MARGIN, + lineSpacing = LINE_SPACING, + strokeMaxYByArea = emptyMap(), // no stroke cropping + strokeWidthPadding = STROKE_WIDTH + ) + + // Preview shows scrolledAmount, canvas shows the rest + assertEquals(scrolledAmount, result[0].visibleHeight, 0.01f) + val canvasRemaining = result[0].fullHeight - result[0].visibleHeight + assertEquals( + "Preview + canvas should equal full height", + result[0].fullHeight, + result[0].visibleHeight + canvasRemaining, + 0.01f + ) + } + + @Test fun complementary_partialDiagram_noSpacingGap() { + // A partial diagram should have zero spacing after it, ensuring the + // preview content is flush against the divider. + val metrics = PreviewLayoutCalculator.diagramRenderMetrics( + visibleHeight = 150f, + fullHeight = 400f, + canvasWidth = CANVAS_WIDTH, + textViewWidth = TEXT_VIEW_WIDTH, + paragraphSpacing = PARAGRAPH_SPACING + ) + // renderedHeight should NOT include spacing for partial diagrams + assertEquals(150f, metrics.renderedHeight, 0.01f) + } +} diff --git a/app/src/test/java/com/writer/view/ScreenMetricsTest.kt b/app/src/test/java/com/writer/view/ScreenMetricsTest.kt index ccea113..ed86b09 100644 --- a/app/src/test/java/com/writer/view/ScreenMetricsTest.kt +++ b/app/src/test/java/com/writer/view/ScreenMetricsTest.kt @@ -91,45 +91,6 @@ class ScreenMetricsTest { assertTrue("lineSpacing should be <= 9 mm on Palma 2 Pro, was $mm mm", mm <= 9f) } - // ── gutter width ───────────────────────────────────────────────────────── - - @Test fun gutterWidth_isAtLeastMinimumTouchTarget_standardDevices() { - val standard = listOf( - Triple(SW_TAB_X_C, W_TAB_X_C, H_TAB_X_C), - Triple(SW_NOTE_5C, W_NOTE_5C, H_NOTE_5C), - Triple(SW_GO_7, W_GO_7, H_GO_7), - ) - for ((sw, w, h) in standard) { - init(sw, w, h) - val mm = toMm(ScreenMetrics.gutterWidth) - assertTrue("gutterWidth < 9 mm on sw=$sw (was $mm mm)", mm >= 9f) - } - } - - @Test fun gutterWidth_isAtLeastStylusTarget_compactDevices() { - // Compact screens use a narrower gutter; 6 mm is still reliable for a stylus. - init(SW_PALMA_2PRO, W_PALMA_2PRO, H_PALMA_2PRO) - val mm = toMm(ScreenMetrics.gutterWidth) - assertTrue("gutterWidth < 6 mm on Palma 2 Pro (was $mm mm)", mm >= 6f) - } - - @Test fun gutterWidth_doesNotExceedMaxFraction_narrowScreen() { - // Palma 2 Pro portrait — 824 px wide at 300 PPI - init(SW_PALMA_2PRO, W_PALMA_2PRO, H_PALMA_2PRO) - val fraction = ScreenMetrics.gutterWidth / W_PALMA_2PRO.toFloat() - assertTrue("gutter fraction $fraction exceeds 0.12 on Palma 2 Pro portrait", fraction <= 0.12f) - } - - @Test fun gutterWidth_isConsistentAcrossStandardDevices() { - // All standard devices share the same density, so gutter pixel values should be equal - // (barring screen-width cap, which doesn't apply at these resolutions). - init(SW_TAB_X_C, W_TAB_X_C, H_TAB_X_C) - val tabXC = ScreenMetrics.gutterWidth - init(SW_NOTE_5C, W_NOTE_5C, H_NOTE_5C) - val note5C = ScreenMetrics.gutterWidth - assertEquals("Standard devices at same density should have equal gutter px", tabXC, note5C, 1f) - } - // ── text sizes ─────────────────────────────────────────────────────────── @Test fun textBody_isReadable_allDevices() { @@ -251,7 +212,6 @@ class ScreenMetricsTest { init(sw, w, h) assertTrue("lineSpacing <= 0 on sw=$sw", ScreenMetrics.lineSpacing > 0f) assertTrue("topMargin <= 0 on sw=$sw", ScreenMetrics.topMargin > 0f) - assertTrue("gutterWidth <= 0 on sw=$sw", ScreenMetrics.gutterWidth > 0f) assertTrue("strokeWidth <= 0 on sw=$sw", ScreenMetrics.strokeWidth > 0f) assertTrue("textBody <= 0 on sw=$sw", ScreenMetrics.textBody > 0f) assertTrue("textLogo <= 0 on sw=$sw", ScreenMetrics.textLogo > 0f) @@ -266,7 +226,6 @@ class ScreenMetricsTest { // Clamps to minimum 0.5 internally ScreenMetrics.init(0.3f, fontScale = 0.3f, smallestWidthDp = 400, widthPixels = 800, heightPixels = 600) assertTrue(ScreenMetrics.lineSpacing > 0f) - assertTrue(ScreenMetrics.gutterWidth > 0f) } @Test fun fontScale_2x_doublesTextSizes() { From 68b12c7a411f74a082182d0fbb751900a0172a97 Mon Sep 17 00:00:00 2001 From: Ed Wei Date: Fri, 20 Mar 2026 20:11:20 -0700 Subject: [PATCH 6/7] Add review script improvements: --base flag and integration rebuild --- CLAUDE.md | 2 +- docs/code-review.md | 7 ++-- scripts/rebuild-integration.sh | 62 ++++++++++++++++++++++++++++++++++ scripts/review-check.sh | 23 +++++++------ scripts/review-pr.sh | 22 +++++++----- 5 files changed, 94 insertions(+), 22 deletions(-) create mode 100644 scripts/rebuild-integration.sh diff --git a/CLAUDE.md b/CLAUDE.md index c8cd651..ac72623 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,7 +31,7 @@ JAVA_HOME="/c/Program Files/Android/Android Studio/jbr" ./gradlew assembleDebug Before creating a PR, run the self-review cycle locally: -1. **Review local changes**: `REVIEW=$(./scripts/review-pr.sh --local --no-post)` — reviews the branch diff against master, saves to `.claude/reviews/`. +1. **Review local changes**: `REVIEW=$(./scripts/review-pr.sh --local --no-post)` — reviews the branch diff against master (use `--base ` to diff against a different branch), saves to `.claude/reviews/`. 2. **Read the review file** (`cat "$REVIEW"`) and address every actionable item by editing the code and committing. 3. **Verify fixes**: `./scripts/review-check.sh --local --no-post "$REVIEW"` — confirm all items are addressed. 4. If any items remain open, go back to step 2. diff --git a/docs/code-review.md b/docs/code-review.md index 1ff9934..030677c 100644 --- a/docs/code-review.md +++ b/docs/code-review.md @@ -22,13 +22,16 @@ Run Claude Code reviews locally using your already-authenticated `claude` CLI, t ## Non-interactive / agent use -Both scripts accept `--local`, `--post`, and `--no-post` flags: +Both scripts accept `--local`, `--post`, `--no-post`, and `--base ` flags: ```bash # Review local branch diff, no remote needed REVIEW=$(./scripts/review-pr.sh --local --no-post) -# After fixing issues, verify locally +# Review against a different base branch +REVIEW=$(./scripts/review-pr.sh --local --no-post --base develop) + +# After fixing issues, verify locally (use same --base if non-default) ./scripts/review-check.sh --local --no-post "$REVIEW" # Once ready, create PR and post results diff --git a/scripts/rebuild-integration.sh b/scripts/rebuild-integration.sh new file mode 100644 index 0000000..d42e93b --- /dev/null +++ b/scripts/rebuild-integration.sh @@ -0,0 +1,62 @@ +#!/bin/bash +# scripts/rebuild-integration.sh +# Rebuilds the integrate branch by merging all feature/fix/dev branches. +# Uses Claude to auto-resolve merge conflicts when they occur. +# +# Usage: ./scripts/rebuild-integration.sh [base-branch] +# base-branch: branch to start from (default: master) + +set -euo pipefail +BASE="${1:-master}" + +git branch -D integrate 2>/dev/null || true +git checkout -B integrate "$BASE" + +for branch in $(git branch --format='%(refname:short)' | grep -E "^(feature|fix|dev)/"); do + echo "Merging $branch..." + if git merge "$branch" --no-edit; then + continue + fi + + echo "Conflict merging $branch — asking Claude to resolve..." + conflicted=$(git diff --name-only --diff-filter=U) + + if [ -z "$conflicted" ]; then + echo "ERROR: merge failed but no conflicted files found. Aborting." + git merge --abort + exit 1 + fi + + echo "Conflicted files:" + echo "$conflicted" + + # Ask Claude to resolve each conflicted file + claude --print \ + --allowedTools 'Read,Edit,Bash(git add *)' \ + -p "You are resolving merge conflicts on the integrate branch. +We are merging branch '$branch' into integrate (based on '$BASE'). +The following files have conflicts: +$conflicted + +For each file: +1. Read it to see the conflict markers (<<<<<<< ======= >>>>>>>) +2. Resolve by keeping the intent of BOTH sides — do not drop changes from either branch +3. Remove all conflict markers +4. Run: git add + +Do NOT commit. Just resolve and stage." + + # Verify no conflicts remain + remaining=$(git diff --name-only --diff-filter=U) + if [ -n "$remaining" ]; then + echo "ERROR: Claude failed to resolve all conflicts. Remaining:" + echo "$remaining" + git merge --abort + exit 1 + fi + + git commit --no-edit + echo "Resolved and committed merge of $branch." +done + +echo "Integration branch ready." diff --git a/scripts/review-check.sh b/scripts/review-check.sh index 3a4270a..7043e05 100644 --- a/scripts/review-check.sh +++ b/scripts/review-check.sh @@ -1,26 +1,31 @@ #!/usr/bin/env bash # Check which review items have been addressed by subsequent changes. -# Usage: ./scripts/review-check.sh [--post] [--no-post] [--local] [pr-number] +# Usage: ./scripts/review-check.sh [--post] [--no-post] [--local] [--base ] [pr-number] # # Options: -# --local Compare against local diff (no PR needed) -# --post Post update to PR without prompting (non-interactive) -# --no-post Save update locally without posting (non-interactive) -# (default) Prompt whether to post +# --base Base branch to diff against (default: master) +# --local Compare against local diff (no PR needed) +# --post Post update to PR without prompting (non-interactive) +# --no-post Save update locally without posting (non-interactive) +# (default) Prompt whether to post set -euo pipefail POST_MODE="" LOCAL_MODE="" +BASE_BRANCH="master" POSITIONAL=() -for arg in "$@"; do - case "$arg" in +while [ $# -gt 0 ]; do + case "$1" in --post) POST_MODE="yes" ;; --no-post) POST_MODE="no" ;; --local) LOCAL_MODE="yes" ;; - *) POSITIONAL+=("$arg") ;; + --base) BASE_BRANCH="${2:?--base requires a branch name}"; shift ;; + --base=*) BASE_BRANCH="${1#--base=}" ;; + *) POSITIONAL+=("$1") ;; esac + shift done REVIEW_FILE="${POSITIONAL[0]:?Usage: review-check.sh [options] [pr-number]}" @@ -31,8 +36,6 @@ if [ ! -f "$REVIEW_FILE" ]; then exit 1 fi -BASE_BRANCH="master" - # Determine if we're working locally or with a PR if [ "$LOCAL_MODE" = "yes" ]; then PR="" diff --git a/scripts/review-pr.sh b/scripts/review-pr.sh index e79dae5..4a44242 100644 --- a/scripts/review-pr.sh +++ b/scripts/review-pr.sh @@ -1,12 +1,13 @@ #!/usr/bin/env bash # Run Claude Code review on the current branch's changes. -# Usage: ./scripts/review-pr.sh [--post] [--no-post] [--local] [pr-number] +# Usage: ./scripts/review-pr.sh [--post] [--no-post] [--local] [--base ] [pr-number] # # Options: -# --local Review local diff against master (no PR or remote needed) -# --post Post review to PR without prompting (non-interactive) -# --no-post Save review locally without posting (non-interactive) -# (default) --local if no PR exists, prompts to post if PR exists +# --base Base branch to diff against (default: master) +# --local Review local diff against base branch (no PR or remote needed) +# --post Post review to PR without prompting (non-interactive) +# --no-post Save review locally without posting (non-interactive) +# (default) --local if no PR exists, prompts to post if PR exists # # Review output is saved to .claude/reviews/-.md # Prints the review file path as the last line of stdout. @@ -15,19 +16,22 @@ set -euo pipefail POST_MODE="" LOCAL_MODE="" +BASE_BRANCH="master" PR="" -for arg in "$@"; do - case "$arg" in +while [ $# -gt 0 ]; do + case "$1" in --post) POST_MODE="yes" ;; --no-post) POST_MODE="no" ;; --local) LOCAL_MODE="yes" ;; - *) PR="$arg" ;; + --base) BASE_BRANCH="${2:?--base requires a branch name}"; shift ;; + --base=*) BASE_BRANCH="${1#--base=}" ;; + *) PR="$1" ;; esac + shift done BRANCH=$(git rev-parse --abbrev-ref HEAD) -BASE_BRANCH="master" # Determine if we're working locally or with a PR if [ "$LOCAL_MODE" = "yes" ]; then From a9d43611257913a21d6936ce7ceb80e9d324da2d Mon Sep 17 00:00:00 2001 From: Ed Wei Date: Mon, 23 Mar 2026 10:17:42 -0700 Subject: [PATCH 7/7] Add diagram mode: shapes, connectors, scratch-out, and undo-to-unsnap Add shape snapping (rectangles, rounded rects, circles, ovals, triangles, diamonds), line-drag gesture, DPI-scaled layout constants, scratch-out gesture for diagram and text areas, diagram text filtering, arrow/connector rendering with dwell detection, undo-to-unsnap (first undo restores raw stroke, second removes it), and persistence of strokeType/isGeometric so arrowheads survive document close/reopen. Squashed from feature/shapes branch: d54e7e4..16b1d4e (8 commits). --- .gitattributes | 3 + CLAUDE.md | 2 +- .../recognition/StrokeFixtureCapture.kt | 8 +- .../android/sdk/hwr/service/HWRInputArgs.kt | 10 + .../main/java/com/writer/model/InkStroke.kt | 6 +- .../java/com/writer/model/StrokeExtensions.kt | 4 +- .../main/java/com/writer/model/StrokeType.kt | 44 + .../com/writer/recognition/HwrProtobuf.kt | 6 +- .../recognition/OnyxHwrTextRecognizer.kt | 6 +- .../com/writer/storage/DocumentStorage.kt | 21 +- .../com/writer/ui/writing/SaveAsActivity.kt | 1 + .../com/writer/ui/writing/WritingActivity.kt | 34 +- .../writer/ui/writing/WritingCoordinator.kt | 38 + .../com/writer/view/ArrowDwellDetection.kt | 81 ++ .../main/java/com/writer/view/CanvasTheme.kt | 135 +- .../com/writer/view/DiagramInsertionLogic.kt | 31 + .../java/com/writer/view/DiagramTextFilter.kt | 101 ++ .../com/writer/view/HandwritingCanvasView.kt | 354 ++++- .../java/com/writer/view/LineDragDetection.kt | 76 + .../com/writer/view/RecognizedTextView.kt | 12 + .../com/writer/view/ScratchOutDetection.kt | 268 ++++ .../com/writer/view/ShapeSnapDetection.kt | 890 ++++++++++++ .../com/writer/view/UndoGestureDetection.kt | 98 ++ .../DocumentStorageSerializationTest.kt | 178 +++ .../com/writer/ui/writing/UndoUnsnapTest.kt | 310 ++++ .../writer/view/ArrowDwellDetectionTest.kt | 113 ++ .../com/writer/view/DiagramBandLinesTest.kt | 234 +++ .../java/com/writer/view/DiagramEraseTest.kt | 311 ++++ .../writer/view/DiagramInsertionLogicTest.kt | 63 + .../com/writer/view/DiagramTextFilterTest.kt | 117 ++ .../com/writer/view/LineDragDetectionTest.kt | 219 +++ .../com/writer/view/LineDragSnapGuardTest.kt | 42 + .../writer/view/ScratchOutDetectionTest.kt | 424 ++++++ .../com/writer/view/ShapeSnapDetectionTest.kt | 1270 +++++++++++++++++ .../writer/view/UndoGestureDetectionTest.kt | 171 +++ docs/engineering-design.md | 352 +++++ 36 files changed, 6003 insertions(+), 30 deletions(-) create mode 100644 .gitattributes create mode 100644 app/src/main/java/com/writer/model/StrokeType.kt create mode 100644 app/src/main/java/com/writer/view/ArrowDwellDetection.kt create mode 100644 app/src/main/java/com/writer/view/DiagramInsertionLogic.kt create mode 100644 app/src/main/java/com/writer/view/DiagramTextFilter.kt create mode 100644 app/src/main/java/com/writer/view/LineDragDetection.kt create mode 100644 app/src/main/java/com/writer/view/ScratchOutDetection.kt create mode 100644 app/src/main/java/com/writer/view/ShapeSnapDetection.kt create mode 100644 app/src/main/java/com/writer/view/UndoGestureDetection.kt create mode 100644 app/src/test/java/com/writer/storage/DocumentStorageSerializationTest.kt create mode 100644 app/src/test/java/com/writer/ui/writing/UndoUnsnapTest.kt create mode 100644 app/src/test/java/com/writer/view/ArrowDwellDetectionTest.kt create mode 100644 app/src/test/java/com/writer/view/DiagramBandLinesTest.kt create mode 100644 app/src/test/java/com/writer/view/DiagramEraseTest.kt create mode 100644 app/src/test/java/com/writer/view/DiagramInsertionLogicTest.kt create mode 100644 app/src/test/java/com/writer/view/DiagramTextFilterTest.kt create mode 100644 app/src/test/java/com/writer/view/LineDragDetectionTest.kt create mode 100644 app/src/test/java/com/writer/view/LineDragSnapGuardTest.kt create mode 100644 app/src/test/java/com/writer/view/ScratchOutDetectionTest.kt create mode 100644 app/src/test/java/com/writer/view/ShapeSnapDetectionTest.kt create mode 100644 app/src/test/java/com/writer/view/UndoGestureDetectionTest.kt create mode 100644 docs/engineering-design.md diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..f01e13f --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# Test fixture JSON files: treat as binary for cleaner diffs and mark as +# linguist-generated so they don't count toward language statistics. +app/src/androidTest/assets/fixtures/*.json binary linguist-generated diff --git a/CLAUDE.md b/CLAUDE.md index ac72623..00ab81f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,7 +14,7 @@ JAVA_HOME="/c/Program Files/Android/Android Studio/jbr" ./gradlew assembleDebug ### Install to tablet ```bash -"/c/Users/Durham/AppData/Local/Android/Sdk/platform-tools/adb.exe" install -r app/build/outputs/apk/debug/app-debug.apk +./gradlew installDebug ``` ### Tests diff --git a/app/src/androidTest/java/com/writer/recognition/StrokeFixtureCapture.kt b/app/src/androidTest/java/com/writer/recognition/StrokeFixtureCapture.kt index f84bbc2..0e387cd 100644 --- a/app/src/androidTest/java/com/writer/recognition/StrokeFixtureCapture.kt +++ b/app/src/androidTest/java/com/writer/recognition/StrokeFixtureCapture.kt @@ -1,5 +1,6 @@ package com.writer.recognition +import android.util.Log import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import com.writer.model.StrokePoint @@ -34,6 +35,9 @@ class StrokeFixtureCapture { assumeTrue("Skipped — run via captureFixture Gradle task", fixtureName != null && expectedText != null) fixtureName!! expectedText!! + require(!fixtureName.contains("..") && !fixtureName.contains('/') && !fixtureName.contains('\\')) { + "fixtureName must not contain path separators or '..': $fixtureName" + } val language = args.getString("language") ?: "en-US" val lineIndex = args.getString("lineIndex")?.toIntOrNull() ?: 0 val documentName = args.getString("documentName") @@ -85,7 +89,9 @@ class StrokeFixtureCapture { // Write to /sdcard/Download/inkup-fixtures/ val outDir = File("/sdcard/Download/inkup-fixtures") - outDir.mkdirs() + if (!outDir.mkdirs() && !outDir.isDirectory) { + Log.w("StrokeFixtureCapture", "Failed to create fixture output dir: $outDir") + } val outFile = File(outDir, "$fixtureName.json") outFile.writeText(json.toString(2)) } diff --git a/app/src/main/java/com/onyx/android/sdk/hwr/service/HWRInputArgs.kt b/app/src/main/java/com/onyx/android/sdk/hwr/service/HWRInputArgs.kt index 7fdb66a..5ea0045 100644 --- a/app/src/main/java/com/onyx/android/sdk/hwr/service/HWRInputArgs.kt +++ b/app/src/main/java/com/onyx/android/sdk/hwr/service/HWRInputArgs.kt @@ -44,6 +44,13 @@ class HWRInputArgs() : Parcelable { } override fun writeToParcel(parcel: Parcel, flags: Int) { + // Write the class-name string that the Boox ksync service expects as a + // manual envelope. The service's own AIDL uses HWRInputData (not this class) + // and its unmarshalling code reads a leading class-name string before the + // fields. We cannot use writeParcelable() here because the receiving side + // deserializes with its own classloader keyed on this exact string, not on + // the Parcelable CREATOR of our class. The field order and class-name must + // match the service's expectations byte-for-byte. parcel.writeString("com.onyx.android.sdk.hwr.bean.HWRInputData") parcel.writeString(lang) parcel.writeString(contentType) @@ -58,6 +65,9 @@ class HWRInputArgs() : Parcelable { parcel.writeByte(if (isIncremental) 1 else 0) val localPfd = pfd if (localPfd != null) { + // Same manual envelope: the service reads a class-name string before + // calling ParcelFileDescriptor's own readFromParcel, so we must write + // the expected class name rather than using writeParcelable(). parcel.writeString("android.os.ParcelFileDescriptor") localPfd.writeToParcel(parcel, flags) } else { diff --git a/app/src/main/java/com/writer/model/InkStroke.kt b/app/src/main/java/com/writer/model/InkStroke.kt index 71658f7..6f493eb 100644 --- a/app/src/main/java/com/writer/model/InkStroke.kt +++ b/app/src/main/java/com/writer/model/InkStroke.kt @@ -7,5 +7,9 @@ data class InkStroke( val points: List, val strokeWidth: Float = 3f, val startTime: Long = points.firstOrNull()?.timestamp ?: 0L, - val endTime: Long = points.lastOrNull()?.timestamp ?: 0L + val endTime: Long = points.lastOrNull()?.timestamp ?: 0L, + /** True for snapped geometric shapes (rectangle, triangle) rendered with sharp lineTo corners. */ + val isGeometric: Boolean = false, + /** Stroke type for diagram model and arrow rendering. */ + val strokeType: StrokeType = StrokeType.FREEHAND ) diff --git a/app/src/main/java/com/writer/model/StrokeExtensions.kt b/app/src/main/java/com/writer/model/StrokeExtensions.kt index cf8c354..edc1a3f 100644 --- a/app/src/main/java/com/writer/model/StrokeExtensions.kt +++ b/app/src/main/java/com/writer/model/StrokeExtensions.kt @@ -26,6 +26,8 @@ fun InkStroke.shiftY(dy: Float): InkStroke { return InkStroke( strokeId = strokeId, points = shiftedPoints, - strokeWidth = strokeWidth + strokeWidth = strokeWidth, + isGeometric = isGeometric, + strokeType = strokeType ) } diff --git a/app/src/main/java/com/writer/model/StrokeType.kt b/app/src/main/java/com/writer/model/StrokeType.kt new file mode 100644 index 0000000..ef95220 --- /dev/null +++ b/app/src/main/java/com/writer/model/StrokeType.kt @@ -0,0 +1,44 @@ +package com.writer.model + +enum class StrokeType { + FREEHAND, + LINE, + ARROW_HEAD, // arrowhead at end (→) + ARROW_TAIL, // arrowhead at start (←) + ARROW_BOTH, // bidirectional (↔) + ELBOW, + ELBOW_ARROW_HEAD, + ELBOW_ARROW_TAIL, + ELBOW_ARROW_BOTH, + ARC, + ARC_ARROW_HEAD, + ARC_ARROW_TAIL, + ARC_ARROW_BOTH, + ELLIPSE, + RECTANGLE, + ROUNDED_RECTANGLE, + TRIANGLE, + DIAMOND; + + /** True for LINE, ARROW_HEAD, ARROW_TAIL, ARROW_BOTH — strokes rendered as a single segment. */ + val isArrowOrLine: Boolean get() = this == LINE || this == ARROW_HEAD || this == ARROW_TAIL || this == ARROW_BOTH + + /** True for any connector type (line, elbow, arc) — strokes where segments between points should be checked for overlap. */ + val isConnector: Boolean get() = isArrowOrLine || isElbow || isArc + + /** True for ELBOW and its arrow variants. */ + val isElbow: Boolean get() = this == ELBOW || this == ELBOW_ARROW_HEAD || this == ELBOW_ARROW_TAIL || this == ELBOW_ARROW_BOTH + + /** True for ARC and its arrow variants. */ + val isArc: Boolean get() = this == ARC || this == ARC_ARROW_HEAD || this == ARC_ARROW_TAIL || this == ARC_ARROW_BOTH + + /** True if this stroke type has an arrowhead at the tip (end). */ + val hasArrowAtTip: Boolean get() = this == ARROW_HEAD || this == ARROW_BOTH || + this == ELBOW_ARROW_HEAD || this == ELBOW_ARROW_BOTH || + this == ARC_ARROW_HEAD || this == ARC_ARROW_BOTH + + /** True if this stroke type has an arrowhead at the tail (start). */ + val hasArrowAtTail: Boolean get() = this == ARROW_TAIL || this == ARROW_BOTH || + this == ELBOW_ARROW_TAIL || this == ELBOW_ARROW_BOTH || + this == ARC_ARROW_TAIL || this == ARC_ARROW_BOTH +} diff --git a/app/src/main/java/com/writer/recognition/HwrProtobuf.kt b/app/src/main/java/com/writer/recognition/HwrProtobuf.kt index 7895c21..27323e2 100644 --- a/app/src/main/java/com/writer/recognition/HwrProtobuf.kt +++ b/app/src/main/java/com/writer/recognition/HwrProtobuf.kt @@ -25,7 +25,11 @@ object HwrProtobuf { fun buildProtobuf( line: InkLine, viewWidth: Float, viewHeight: Float, lang: String = "en_US" ): ByteArray { - val out = ByteArrayOutputStream() + // Pre-compute expected size: ~48 bytes per pointer event (tag + length-delimited + // sub-message with 4 fixed32 fields + 2 varints), plus ~60 bytes of header fields. + val totalPoints = line.strokes.sumOf { it.points.size } + val estimatedSize = 60 + totalPoints * 48 + val out = ByteArrayOutputStream(estimatedSize) writeTag(out, 1, 2); writeString(out, lang) writeTag(out, 2, 2); writeString(out, "Text") diff --git a/app/src/main/java/com/writer/recognition/OnyxHwrTextRecognizer.kt b/app/src/main/java/com/writer/recognition/OnyxHwrTextRecognizer.kt index 76632e0..181a513 100644 --- a/app/src/main/java/com/writer/recognition/OnyxHwrTextRecognizer.kt +++ b/app/src/main/java/com/writer/recognition/OnyxHwrTextRecognizer.kt @@ -15,6 +15,8 @@ import com.writer.model.InkLine import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.sync.Mutex @@ -50,6 +52,7 @@ class OnyxHwrTextRecognizer(private val context: Context) : TextRecognizer { private var connectDeferred = CompletableDeferred() private val initMutex = Mutex() private var currentLang = "en_US" + private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val connection = object : ServiceConnection { override fun onServiceConnected(name: ComponentName?, binder: IBinder?) { @@ -153,7 +156,7 @@ class OnyxHwrTextRecognizer(private val context: Context) : TextRecognizer { // Write pipe data concurrently so the service can drain while we write. // This prevents deadlock when protoBytes exceeds the kernel pipe buffer (~64KB). - val writeJob = CoroutineScope(Dispatchers.IO).launch { + val writeJob = ioScope.launch { try { FileOutputStream(writePfd.fileDescriptor).use { it.write(protoBytes) } } catch (e: Exception) { @@ -203,6 +206,7 @@ class OnyxHwrTextRecognizer(private val context: Context) : TextRecognizer { } override fun close() { + ioScope.cancel() if (!bound) return try { // closeRecognizer() is oneway (fire-and-forget); unbindService follows immediately diff --git a/app/src/main/java/com/writer/storage/DocumentStorage.kt b/app/src/main/java/com/writer/storage/DocumentStorage.kt index f8ea4ed..539e94a 100644 --- a/app/src/main/java/com/writer/storage/DocumentStorage.kt +++ b/app/src/main/java/com/writer/storage/DocumentStorage.kt @@ -8,6 +8,7 @@ import com.writer.model.DiagramArea import com.writer.model.DocumentData import com.writer.model.InkStroke import com.writer.model.StrokePoint +import com.writer.model.StrokeType import org.json.JSONArray import org.json.JSONObject import java.io.File @@ -176,7 +177,7 @@ object DocumentStorage { // --- Serialization --- - private fun serializeToJson(data: DocumentData): JSONObject { + internal fun serializeToJson(data: DocumentData): JSONObject { val json = JSONObject() json.put("scrollOffsetY", data.scrollOffsetY.toDouble()) @@ -212,6 +213,12 @@ object DocumentStorage { pointsArr.put(ptObj) } strokeObj.put("points", pointsArr) + if (stroke.strokeType != StrokeType.FREEHAND) { + strokeObj.put("strokeType", stroke.strokeType.name) + } + if (stroke.isGeometric) { + strokeObj.put("isGeometric", true) + } strokesArr.put(strokeObj) } json.put("strokes", strokesArr) @@ -229,7 +236,7 @@ object DocumentStorage { return json } - private fun deserializeFromJson(text: String): DocumentData { + internal fun deserializeFromJson(text: String): DocumentData { val json = JSONObject(text) val scrollOffsetY = json.optDouble("scrollOffsetY", 0.0).toFloat() @@ -275,11 +282,19 @@ object DocumentStorage { ) } + val strokeTypeName = strokeObj.optString("strokeType", "") + val strokeType = try { + if (strokeTypeName.isNotEmpty()) StrokeType.valueOf(strokeTypeName) + else StrokeType.FREEHAND + } catch (_: IllegalArgumentException) { StrokeType.FREEHAND } + strokes.add( InkStroke( strokeId = strokeId, points = points, - strokeWidth = strokeWidth + strokeWidth = strokeWidth, + strokeType = strokeType, + isGeometric = strokeObj.optBoolean("isGeometric", false) ) ) } diff --git a/app/src/main/java/com/writer/ui/writing/SaveAsActivity.kt b/app/src/main/java/com/writer/ui/writing/SaveAsActivity.kt index cc2c0b0..c46323d 100644 --- a/app/src/main/java/com/writer/ui/writing/SaveAsActivity.kt +++ b/app/src/main/java/com/writer/ui/writing/SaveAsActivity.kt @@ -13,6 +13,7 @@ import com.writer.model.InkStroke import com.writer.model.minX import com.writer.model.maxX import com.writer.recognition.TextRecognizerFactory +import com.writer.ui.writing.GestureHandler import com.writer.view.HandwritingNameInput import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch diff --git a/app/src/main/java/com/writer/ui/writing/WritingActivity.kt b/app/src/main/java/com/writer/ui/writing/WritingActivity.kt index 738a3fd..3c4bd65 100644 --- a/app/src/main/java/com/writer/ui/writing/WritingActivity.kt +++ b/app/src/main/java/com/writer/ui/writing/WritingActivity.kt @@ -27,6 +27,7 @@ import com.writer.storage.DocumentStorage import com.writer.view.HandwritingCanvasView import com.writer.view.RecognizedTextView import com.writer.view.TouchFilter +import com.writer.view.ScreenMetrics import kotlinx.coroutines.launch class WritingActivity : AppCompatActivity() { @@ -126,7 +127,11 @@ class WritingActivity : AppCompatActivity() { getCoordinator = { coordinator }, getPendingRestore = { pendingRestore }, clearPendingRestore = { pendingRestore = null }, - onClosed = { recognizedTextView.onLogoTap = { showMenu() } } + onClosed = { + recognizedTextView.onLogoTap = { showMenu() } + recognizedTextView.onUndoTap = { coordinator?.undo() } + recognizedTextView.onRedoTap = { coordinator?.redo() } + } ) // Migrate old single-file storage if needed, then determine current document @@ -162,17 +167,36 @@ class WritingActivity : AppCompatActivity() { // Tap "I" logo to open menu recognizedTextView.onLogoTap = { showMenu() } + recognizedTextView.onUndoTap = { coordinator?.undo() } + recognizedTextView.onRedoTap = { coordinator?.redo() } - // Pick the best available recognizer synchronously (initialized later in coroutine) + // Pick the best available recognizer synchronously (initialized later in coroutine). + // OnyxHwrTextRecognizer binds to a system service using applicationContext, so holding + // it in the activity is safe (no activity leak). GoogleMLKitTextRecognizer is stateless + // and does not retain a context reference. recognizer = TextRecognizerFactory.create(this) // Create coordinator early so cached text can be displayed before model loads startCoordinator() - // Capture default heights after initial layout, then wire up the divider drag + // Capture default heights after initial layout, then wire up the divider drag. + // Override the XML weight-based split with an adaptive calculation so that + // all supported screen sizes get a proportional canvas/text split. recognizedTextView.post { - defaultTextHeight = recognizedTextView.height - defaultCanvasHeight = inkCanvas.height + val totalHeight = recognizedTextView.height + inkCanvas.height + defaultCanvasHeight = ScreenMetrics.computeDefaultCanvasHeight(totalHeight) + defaultTextHeight = totalHeight - defaultCanvasHeight + + val textParams = recognizedTextView.layoutParams as LinearLayout.LayoutParams + textParams.height = defaultTextHeight + textParams.weight = 0f + recognizedTextView.layoutParams = textParams + + val canvasParams = inkCanvas.layoutParams as LinearLayout.LayoutParams + canvasParams.height = defaultCanvasHeight + canvasParams.weight = 0f + inkCanvas.layoutParams = canvasParams + setupSplitDrag(splitLayout, splitDivider) // Restore cached text and scroll position immediately (no recognizer needed) diff --git a/app/src/main/java/com/writer/ui/writing/WritingCoordinator.kt b/app/src/main/java/com/writer/ui/writing/WritingCoordinator.kt index 62d4f9f..788cf36 100644 --- a/app/src/main/java/com/writer/ui/writing/WritingCoordinator.kt +++ b/app/src/main/java/com/writer/ui/writing/WritingCoordinator.kt @@ -4,8 +4,12 @@ import android.util.Log import com.writer.model.DiagramArea import com.writer.model.DocumentModel import com.writer.model.InkStroke +import com.writer.model.minX +import com.writer.model.maxX +import com.writer.model.minY import com.writer.model.maxY import com.writer.model.shiftY +import com.writer.view.ScratchOutDetection import com.writer.recognition.TextRecognizer import com.writer.recognition.LineSegmenter import com.writer.recognition.StrokeClassifier @@ -121,6 +125,12 @@ class WritingCoordinator( inkCanvas.onUndoGestureEnd = { undoManager.endScrub() } + inkCanvas.onScratchOut = { left, top, right, bottom -> + onScratchOut(left, top, right, bottom) + } + inkCanvas.onStrokeReplaced = { oldStrokeId, newStroke -> + onStrokeReplaced(oldStrokeId, newStroke) + } } fun stop() { @@ -139,6 +149,8 @@ class WritingCoordinator( inkCanvas.onUndoGestureStart = null inkCanvas.onUndoGestureStep = null inkCanvas.onUndoGestureEnd = null + inkCanvas.onScratchOut = null + inkCanvas.onStrokeReplaced = null } fun reset() { @@ -195,6 +207,32 @@ class WritingCoordinator( } } + private fun onStrokeReplaced(oldStrokeId: String, newStroke: InkStroke) { + saveUndoSnapshot() // captures state with raw stroke (state N+1) + documentModel.activeStrokes.removeAll { it.strokeId == oldStrokeId } + documentModel.activeStrokes.add(newStroke) + Log.i(TAG, "Stroke replaced: $oldStrokeId → ${newStroke.strokeId} (${newStroke.strokeType})") + } + + private fun onScratchOut(left: Float, top: Float, right: Float, bottom: Float) { + val overlapping = documentModel.activeStrokes.filter { stroke -> + stroke.points.any { pt -> pt.x in left..right && pt.y in top..bottom } + || stroke.strokeType.isConnector + && ScratchOutDetection.strokeIntersectsRect(stroke.points, left, top, right, bottom) + } + if (overlapping.isEmpty()) return + + saveUndoSnapshot() + + val idsToRemove = overlapping.map { it.strokeId }.toSet() + documentModel.activeStrokes.removeAll { it.strokeId in idsToRemove } + + inkCanvas.removeStrokes(idsToRemove) + inkCanvas.drawToSurface() + + Log.i(TAG, "Scratch-out erase: removed ${overlapping.size} strokes in [$left,$top,$right,$bottom]") + } + // --- Recognition --- /** Recognize all lines that have strokes but no cached text or failed recognition. */ diff --git a/app/src/main/java/com/writer/view/ArrowDwellDetection.kt b/app/src/main/java/com/writer/view/ArrowDwellDetection.kt new file mode 100644 index 0000000..bf3220b --- /dev/null +++ b/app/src/main/java/com/writer/view/ArrowDwellDetection.kt @@ -0,0 +1,81 @@ +package com.writer.view + +import com.writer.model.StrokePoint +import com.writer.model.StrokeType + +object ArrowDwellDetection { + + /** + * Returns true if the last points in [pts] clustered within [radiusPx] of ([ex],[ey]) + * for at least [dwellMs] milliseconds — indicating a deliberate dwell/pause at the tip. + */ + fun hasDwellAtEnd( + pts: List, + ex: Float, ey: Float, + radiusPx: Float, + dwellMs: Long + ): Boolean { + if (pts.isEmpty()) return false + val r2 = radiusPx * radiusPx + val endTime = pts.last().timestamp + var startTime = endTime + for (pt in pts.asReversed()) { + val dx = pt.x - ex; val dy = pt.y - ey + if (dx * dx + dy * dy > r2) break + startTime = pt.timestamp + } + return endTime - startTime >= dwellMs + } + + /** + * Returns true if [pts] started within [radiusPx] of its first point for at least [dwellMs] ms. + * Used to check for a start-of-stroke dwell (the pen paused before beginning to move). + */ + fun hasDwellAtStart( + pts: List, + radiusPx: Float, + dwellMs: Long + ): Boolean { + if (pts.isEmpty()) return false + val r2 = radiusPx * radiusPx + val first = pts.first() + val startTime = first.timestamp + var endTime = startTime + for (pt in pts) { + val dx = pt.x - first.x; val dy = pt.y - first.y + if (dx * dx + dy * dy > r2) break + endTime = pt.timestamp + } + return endTime - startTime >= dwellMs + } + + /** + * Classify the arrow type from dwell flags. + */ + fun classifyArrow(tipDwell: Boolean, tailDwell: Boolean): StrokeType = when { + tipDwell && tailDwell -> StrokeType.ARROW_BOTH + tipDwell -> StrokeType.ARROW_HEAD + tailDwell -> StrokeType.ARROW_TAIL + else -> StrokeType.LINE + } + + /** + * Classify elbow stroke type from dwell flags. + */ + fun classifyElbow(tipDwell: Boolean, tailDwell: Boolean): StrokeType = when { + tipDwell && tailDwell -> StrokeType.ELBOW_ARROW_BOTH + tipDwell -> StrokeType.ELBOW_ARROW_HEAD + tailDwell -> StrokeType.ELBOW_ARROW_TAIL + else -> StrokeType.ELBOW + } + + /** + * Classify arc stroke type from dwell flags. + */ + fun classifyArc(tipDwell: Boolean, tailDwell: Boolean): StrokeType = when { + tipDwell && tailDwell -> StrokeType.ARC_ARROW_BOTH + tipDwell -> StrokeType.ARC_ARROW_HEAD + tailDwell -> StrokeType.ARC_ARROW_TAIL + else -> StrokeType.ARC + } +} diff --git a/app/src/main/java/com/writer/view/CanvasTheme.kt b/app/src/main/java/com/writer/view/CanvasTheme.kt index 4f239f4..91ff6cb 100644 --- a/app/src/main/java/com/writer/view/CanvasTheme.kt +++ b/app/src/main/java/com/writer/view/CanvasTheme.kt @@ -5,13 +5,16 @@ import android.graphics.Color import android.graphics.Paint import android.graphics.Path import com.writer.model.InkStroke +import com.writer.model.StrokeType +import kotlin.math.hypot +import kotlin.math.sqrt /** * Shared visual constants and drawing utilities used by both * HandwritingCanvasView and HandwritingNameInput. */ object CanvasTheme { - const val DEFAULT_STROKE_WIDTH = 5f + val DEFAULT_STROKE_WIDTH get() = ScreenMetrics.strokeWidth val LINE_COLOR: Int = Color.parseColor("#AAAAAA") fun newStrokePaint() = Paint().apply { @@ -44,16 +47,128 @@ object CanvasTheme { fun drawStroke(canvas: Canvas, stroke: InkStroke, path: Path, paint: Paint) { if (stroke.points.size < 2) return path.reset() - path.moveTo(stroke.points[0].x, stroke.points[0].y) - for (i in 1 until stroke.points.size) { - val prev = stroke.points[i - 1] - val curr = stroke.points[i] - val midX = (prev.x + curr.x) / 2f - val midY = (prev.y + curr.y) / 2f - path.quadTo(prev.x, prev.y, midX, midY) + val pts = stroke.points + val n = pts.size + path.moveTo(pts[0].x, pts[0].y) + if (stroke.strokeType.isArc && n == 3) { + // Arc: 3 points = start, bezier control, end → quadratic bezier + path.quadTo(pts[1].x, pts[1].y, pts[2].x, pts[2].y) + } else if (stroke.isGeometric) { + // Sharp corners: lineTo each point (rectangle, triangle, arrow line, elbow, diamond). + for (i in 1 until n) { + path.lineTo(pts[i].x, pts[i].y) + } + } else { + // Smooth freehand rendering via quadratic bezier through midpoints. + for (i in 1 until n) { + val prev = pts[i - 1] + val curr = pts[i] + val midX = (prev.x + curr.x) / 2f + val midY = (prev.y + curr.y) / 2f + path.quadTo(prev.x, prev.y, midX, midY) + } + path.lineTo(pts.last().x, pts.last().y) } - val last = stroke.points.last() - path.lineTo(last.x, last.y) canvas.drawPath(path, paint) + + // Draw arrowheads + val first = pts.first() + val last = pts.last() + val size = stroke.strokeWidth * 4f + val st = stroke.strokeType + + if (st.hasArrowAtTip) { + val (dx, dy) = tipDirection(stroke) + drawArrowhead(canvas, paint, last.x, last.y, dx, dy, size) + } + if (st.hasArrowAtTail) { + val (dx, dy) = tailDirection(stroke) + drawArrowhead(canvas, paint, first.x, first.y, dx, dy, size) + } + } + + /** + * Compute the direction vector for the arrowhead at the tip (end) of a stroke. + * Uses local tangent for arcs/elbows/freehand, chord for simple geometric lines. + */ + private fun tipDirection(stroke: InkStroke): Pair { + val pts = stroke.points + val n = pts.size + val last = pts.last() + val st = stroke.strokeType + return when { + // Arc: tip tangent = derivative of quadratic bezier at t=1 = 2(P2 - C) + st.isArc && n == 3 -> { + val dx = 2f * (pts[2].x - pts[1].x) + val dy = 2f * (pts[2].y - pts[1].y) + Pair(dx, dy) + } + // Elbow: tip direction = corner → end + st.isElbow && n == 3 -> { + Pair(pts[2].x - pts[1].x, pts[2].y - pts[1].y) + } + // Freehand with enough points: use local tangent + !stroke.isGeometric && n > 3 -> { + Pair(last.x - pts[n - 3].x, last.y - pts[n - 3].y) + } + // Simple geometric line: chord direction + else -> Pair(last.x - pts.first().x, last.y - pts.first().y) + } + } + + /** + * Compute the direction vector for the arrowhead at the tail (start) of a stroke. + */ + private fun tailDirection(stroke: InkStroke): Pair { + val pts = stroke.points + val n = pts.size + val first = pts.first() + val st = stroke.strokeType + return when { + // Arc: tail tangent = derivative at t=0 = 2(C - P0), reversed for pointing away + st.isArc && n == 3 -> { + val dx = 2f * (pts[0].x - pts[1].x) + val dy = 2f * (pts[0].y - pts[1].y) + Pair(dx, dy) + } + // Elbow: tail direction = corner → start + st.isElbow && n == 3 -> { + Pair(pts[0].x - pts[1].x, pts[0].y - pts[1].y) + } + // Freehand with enough points: use local tangent + !stroke.isGeometric && n > 3 -> { + Pair(first.x - pts[2].x, first.y - pts[2].y) + } + // Simple geometric line: reversed chord direction + else -> Pair(first.x - pts.last().x, first.y - pts.last().y) + } + } + + /** + * Draw a filled isoceles triangle arrowhead at ([tipX], [tipY]) pointing in direction ([dx], [dy]). + * [size] is the base half-width; height = size × 1.5. + */ + private fun drawArrowhead( + canvas: Canvas, paint: Paint, + tipX: Float, tipY: Float, + dx: Float, dy: Float, + size: Float + ) { + val len = hypot(dx, dy) + if (len == 0f) return + // Unit forward vector + val fx = dx / len; val fy = dy / len + // Unit perpendicular + val px = -fy; val py = fx + val height = size * 1.5f + // Base center = tip − height × forward + val bx = tipX - height * fx; val by = tipY - height * fy + val arrowPath = Path() + arrowPath.moveTo(tipX, tipY) + arrowPath.lineTo(bx + size * px, by + size * py) + arrowPath.lineTo(bx - size * px, by - size * py) + arrowPath.close() + val fillPaint = Paint(paint).apply { style = Paint.Style.FILL } + canvas.drawPath(arrowPath, fillPaint) } } diff --git a/app/src/main/java/com/writer/view/DiagramInsertionLogic.kt b/app/src/main/java/com/writer/view/DiagramInsertionLogic.kt new file mode 100644 index 0000000..d1ddff0 --- /dev/null +++ b/app/src/main/java/com/writer/view/DiagramInsertionLogic.kt @@ -0,0 +1,31 @@ +package com.writer.view + +/** + * Pure logic for computing where to insert a diagram block into the ordered list of + * text paragraphs in the recognized-text preview panel. + * + * Extracted from [RecognizedTextView] so it can be unit-tested without Android dependencies. + */ +internal object DiagramInsertionLogic { + + /** + * Returns the paragraph index *before* which the diagram block should be rendered. + * + * @param paragraphLineIndices for each paragraph, the canvas line indices of its segments + * @param diagramLineIndex canvas line index of the topmost diagram node + * ([Int.MAX_VALUE] = no diagram / diagram below all text) + * @return 0 if diagram is above all text; [Int.MAX_VALUE] if diagram is below all text; + * otherwise the index of the first paragraph whose content starts at or below + * the diagram. + */ + fun computeInsertionParagraph( + paragraphLineIndices: List>, + diagramLineIndex: Int + ): Int { + if (diagramLineIndex == Int.MAX_VALUE) return Int.MAX_VALUE + val idx = paragraphLineIndices.indexOfFirst { lineIndices -> + lineIndices.any { li -> li >= diagramLineIndex } + } + return if (idx == -1) Int.MAX_VALUE else idx + } +} diff --git a/app/src/main/java/com/writer/view/DiagramTextFilter.kt b/app/src/main/java/com/writer/view/DiagramTextFilter.kt new file mode 100644 index 0000000..dba76f6 --- /dev/null +++ b/app/src/main/java/com/writer/view/DiagramTextFilter.kt @@ -0,0 +1,101 @@ +package com.writer.view + +import com.writer.model.InkStroke +import com.writer.model.StrokeType + +/** + * Pure logic for filtering out text lines that are entirely inside diagram shape bounds. + * + * Strokes written inside a shape are recognized as the shape's label (shown in the diagram + * preview) and must not also appear as independent text paragraphs. + */ +internal object DiagramTextFilter { + + /** + * Returns the set of line indices whose freehand strokes all lie inside a diagram node. + * + * A line is excluded when every [StrokeType.FREEHAND] stroke on it has its center + * contained within at least one node's bounding box. Lines that have no freehand + * strokes at all are also excluded (they carry no text content). + * + * @param strokesByLine map from line index to all strokes on that line + * @param nodeBounds node bounding boxes as [left, top, right, bottom] float arrays + * @return set of line indices to suppress from the text preview + */ + fun diagramOnlyLines( + strokesByLine: Map>, + nodeBounds: List + ): Set { + val validBounds = nodeBounds.filter { it.size == 4 } + if (validBounds.isEmpty()) return emptySet() + return strokesByLine.entries + .filter { (_, strokes) -> + val freehand = strokes.filter { it.strokeType == StrokeType.FREEHAND } + // No freehand strokes → no text on this line + if (freehand.isEmpty()) return@filter true + // All freehand strokes must be inside some node + freehand.all { stroke -> + val cx = (stroke.points.minOf { it.x } + stroke.points.maxOf { it.x }) / 2f + val cy = (stroke.points.minOf { it.y } + stroke.points.maxOf { it.y }) / 2f + validBounds.any { (l, t, r, b) -> cx >= l && cx <= r && cy >= t && cy <= b } + } + } + .map { it.key } + .toSet() + } + + /** + * Returns line indices whose freehand strokes all lie in the diagram's Y-band + * but outside every node's bounds — i.e. "beside" the diagram, not inside a shape. + * + * @param strokesByLine map from line index to strokes + * @param nodeBounds node bounds as [left, top, right, bottom] + * @param diagramBBox overall diagram bounding box as [left, top, right, bottom] + * @param yTolerance Y padding around the diagram bbox (pass LINE_SPACING) + */ + /** + * @param rightBandLimit upper X bound for "right-side" band notes. Strokes whose + * center X exceeds this value are treated as ordinary text, not diagram notes. + * Pass `canvasWidth − 2 × gutterWidth` to exclude the rightmost gutter-zone column + * (fixes Bug #6: text near the right margin wrongly suppressed after a shape is drawn). + * Defaults to [Float.MAX_VALUE] (no upper bound — original behaviour). + */ + fun diagramBandLines( + strokesByLine: Map>, + nodeBounds: List, + diagramBBox: FloatArray, + yTolerance: Float, + leftBandLimit: Float = diagramBBox[0] - yTolerance * 4, + rightBandLimit: Float = diagramBBox[2] + yTolerance * 4 + ): Set { + if (diagramBBox.size != 4) return emptySet() + val validNodeBounds = nodeBounds.filter { it.size == 4 } + val bLeft = diagramBBox[0]; val bTop = diagramBBox[1] + val bRight = diagramBBox[2]; val bBottom = diagramBBox[3] + return strokesByLine.entries + .filter { (_, strokes) -> + val freehand = strokes.filter { it.strokeType == StrokeType.FREEHAND } + if (freehand.isEmpty()) return@filter false // no text on this line + // Exclude shape-label strokes (inside a node) — they are already handled by + // diagramOnlyLines. Only the remaining strokes need to qualify as band notes. + // This allows "side" strokes to be detected even when a shape label (e.g. "A") + // happens to share the same written line index. + val nonLabel = freehand.filter { stroke -> + val cx = (stroke.points.minOf { it.x } + stroke.points.maxOf { it.x }) / 2f + val cy = (stroke.points.minOf { it.y } + stroke.points.maxOf { it.y }) / 2f + validNodeBounds.none { (l, t, r, b) -> cx >= l && cx <= r && cy >= t && cy <= b } + } + if (nonLabel.isEmpty()) return@filter false // pure shape-label line + nonLabel.all { stroke -> + val cx = (stroke.points.minOf { it.x } + stroke.points.maxOf { it.x }) / 2f + val cy = (stroke.points.minOf { it.y } + stroke.points.maxOf { it.y }) / 2f + val inYBand = cy >= bTop - yTolerance && cy <= bBottom + yTolerance + // "Right-side" band notes must not extend past the rightmost gutter zone. + val outsideX = (cx < bLeft && cx > leftBandLimit) || (cx > bRight && cx < rightBandLimit) + inYBand && outsideX + } + } + .map { it.key } + .toSet() + } +} diff --git a/app/src/main/java/com/writer/view/HandwritingCanvasView.kt b/app/src/main/java/com/writer/view/HandwritingCanvasView.kt index 72d2c1f..3e7bd61 100644 --- a/app/src/main/java/com/writer/view/HandwritingCanvasView.kt +++ b/app/src/main/java/com/writer/view/HandwritingCanvasView.kt @@ -5,7 +5,9 @@ import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint import android.graphics.Path +import android.graphics.PointF import android.graphics.Rect + import android.graphics.Typeface import android.util.AttributeSet import android.util.Log @@ -21,8 +23,12 @@ import com.onyx.android.sdk.pen.data.TouchPointList import com.writer.model.DiagramArea import com.writer.model.InkStroke import com.writer.model.StrokePoint +import com.writer.model.StrokeType import com.writer.model.minY import com.writer.model.maxY +import kotlin.math.cos +import kotlin.math.hypot +import kotlin.math.sin /** * Primary ink input surface. Uses Onyx Pen SDK for low-latency @@ -61,6 +67,9 @@ class HandwritingCanvasView @JvmOverloads constructor( private const val SCRIBBLE_MIN_COMPLEXITY = 3.0f // Diagram insert: minimum height in lines private const val DIAGRAM_MIN_HEIGHT = 2 + // Arrow dwell detection: radius and time for start/end dwell + private const val ARROW_DWELL_RADIUS_PX = 15f // ~8 dp + private const val ARROW_DWELL_MS = 300L } private val completedStrokes = mutableListOf() @@ -88,6 +97,17 @@ class HandwritingCanvasView @JvmOverloads constructor( /** Diagram areas in the current document. */ var diagramAreas: List = emptyList() + // Dwell indicator state (arrow start-dwell inside diagram areas) + private var dwellJob: Runnable? = null + private var dwellIndicatorShown = false + private var dwellDotCenter: PointF? = null + + private val dwellDotPaint = Paint().apply { + color = Color.DKGRAY + style = Paint.Style.FILL + isAntiAlias = true + } + /** When true, all pen input is blocked and annotations are rendered. */ var tutorialMode = false var annotationStrokes: List = emptyList() @@ -116,6 +136,12 @@ class HandwritingCanvasView @JvmOverloads constructor( var onUndoGestureStep: ((absoluteOffset: Int) -> Unit)? = null var onUndoGestureEnd: (() -> Unit)? = null + // Scratch-out callback (inside diagram areas: erase overlapping strokes) + var onScratchOut: ((left: Float, top: Float, right: Float, bottom: Float) -> Unit)? = null + + // Stroke-replaced callback (shape snap: raw freehand → snapped geometric) + var onStrokeReplaced: ((oldStrokeId: String, newStroke: InkStroke) -> Unit)? = null + /** Scroll offset in document-space pixels. Increase to scroll content up. */ var scrollOffsetY: Float = 0f @@ -193,6 +219,8 @@ class HandwritingCanvasView @JvmOverloads constructor( undoGestureReady = false undoScrubActive = false initStrokeBounds(currentStrokePoints.last()) + // Start arrow dwell detection only inside diagram areas + if (currentDiagramBounds != null) startDwellJob() } override fun onRawDrawingTouchPointMoveReceived(tp: TouchPoint) { @@ -220,6 +248,7 @@ class HandwritingCanvasView @JvmOverloads constructor( } override fun onEndRawDrawing(b: Boolean, tp: TouchPoint) { + cancelDwellJob() if (!lineDragActive && !diagramInsertActive && !undoScrubActive) { touchFilter?.let { it.penActive = false @@ -386,6 +415,7 @@ class HandwritingCanvasView @JvmOverloads constructor( undoGestureReady = false undoScrubActive = false initStrokeBounds(currentStrokePoints.last()) + if (currentDiagramBounds != null) startDwellJob() drawToSurface() return true } @@ -413,6 +443,7 @@ class HandwritingCanvasView @JvmOverloads constructor( return true } MotionEvent.ACTION_UP -> { + cancelDwellJob() touchFilter?.let { it.penActive = false it.penUpTimestamp = android.os.SystemClock.uptimeMillis() @@ -526,14 +557,77 @@ class HandwritingCanvasView @JvmOverloads constructor( currentStrokePoints.clear() currentPath.reset() currentDiagramBounds = null + dwellDotCenter = null + dwellIndicatorShown = false return } - val stroke = InkStroke(points = currentStrokePoints.toList()) - completedStrokes.add(stroke) - onStrokeCompleted?.invoke(stroke) - currentStrokePoints.clear() - currentPath.reset() - currentDiagramBounds = null + + if (currentDiagramBounds != null) { + // INSIDE DIAGRAM AREA: post-stroke shape-snap pipeline + val tailDwell = dwellIndicatorShown + dwellDotCenter = null + dwellIndicatorShown = false + + // Save raw points before checkShapeSnap overwrites currentStrokePoints + val rawPoints = currentStrokePoints.toList() + + // Shape snap before scratch-out: rectangles have X-reversals that + // would otherwise be consumed as scratch-out. + val snapData = checkShapeSnap(tailDwell) + + // Only check scratch-out if no shape was snapped. + if (snapData == null && checkPostStrokeScratchOut()) { + currentDiagramBounds = null + return + } + + if (snapData != null) { + // Two-phase commit: emit raw stroke first, then replace with snapped + val rawStroke = InkStroke( + points = rawPoints, + isGeometric = false, + strokeType = StrokeType.FREEHAND + ) + completedStrokes.add(rawStroke) + onStrokeCompleted?.invoke(rawStroke) // → saves snapshot N, adds raw stroke → state N+1 + + val snappedStroke = InkStroke( + points = currentStrokePoints.toList(), + isGeometric = snapData.isGeometric, + strokeType = snapData.strokeType + ) + completedStrokes.remove(rawStroke) + completedStrokes.add(snappedStroke) + onStrokeReplaced?.invoke(rawStroke.strokeId, snappedStroke) // → saves snapshot N+1, replaces → state N+2 + } else { + val stroke = InkStroke( + points = currentStrokePoints.toList(), + isGeometric = false, + strokeType = StrokeType.FREEHAND + ) + completedStrokes.add(stroke) + onStrokeCompleted?.invoke(stroke) + } + currentStrokePoints.clear() + currentPath.reset() + currentDiagramBounds = null + if (snapData != null) { + // Flush SDK hardware overlay showing freehand stroke, redraw clean snapped shape + pauseRawDrawing() + drawToSurface() + resumeRawDrawing() + } + } else { + // OUTSIDE DIAGRAM AREA + if (checkPostStrokeScratchOut()) return + + val stroke = InkStroke(points = currentStrokePoints.toList()) + completedStrokes.add(stroke) + onStrokeCompleted?.invoke(stroke) + currentStrokePoints.clear() + currentPath.reset() + currentDiagramBounds = null + } } // --- Gesture detection --- @@ -582,6 +676,249 @@ class HandwritingCanvasView @JvmOverloads constructor( } } + // ── Diagram-area post-stroke detection ────────────────────────────────── + + /** Result of shape snap: strokeType + isGeometric flag. */ + private data class SnapData( + val strokeType: StrokeType, + val isGeometric: Boolean + ) + + /** + * Attempt to snap the completed stroke to a known geometric shape. + * Only called for strokes inside diagram areas. + */ + private fun checkShapeSnap(tailDwell: Boolean = false): SnapData? { + if (currentStrokePoints.size < 2) return null + + // Shape snapping requires a dwell at the end of the stroke — the user + // holds the pen still briefly to signal "snap this to a shape". + // Without the dwell, the stroke is treated as freehand. + val last = currentStrokePoints.last() + val hasEndDwell = ArrowDwellDetection.hasDwellAtEnd( + currentStrokePoints, last.x, last.y, ARROW_DWELL_RADIUS_PX, ARROW_DWELL_MS + ) + if (!hasEndDwell) return null + + val xs = FloatArray(currentStrokePoints.size) { currentStrokePoints[it].x } + val ys = FloatArray(currentStrokePoints.size) { currentStrokePoints[it].y } + // Dump stroke data for fixture creation (remove after debugging) + val xsStr = xs.joinToString(",") { "%.1f".format(it) } + val ysStr = ys.joinToString(",") { "%.1f".format(it) } + Log.d(TAG, "SNAP_FIXTURE n=${xs.size} ls=$LINE_SPACING") + Log.d(TAG, "SNAP_XS $xsStr") + Log.d(TAG, "SNAP_YS $ysStr") + val result = ShapeSnapDetection.detect(xs, ys, LINE_SPACING) + Log.d(TAG, "SNAP_RESULT $result") + if (result == null) return null + + val t = currentStrokePoints.first().timestamp + var strokeType = StrokeType.FREEHAND + var isGeometric = false + val snappedPoints: List = when (result) { + is ShapeSnapDetection.SnapResult.Line -> { + val tipDwell = ArrowDwellDetection.hasDwellAtEnd( + currentStrokePoints, result.x2, result.y2, ARROW_DWELL_RADIUS_PX, ARROW_DWELL_MS + ) + strokeType = ArrowDwellDetection.classifyArrow(tipDwell, tailDwell) + isGeometric = true + + listOf( + StrokePoint(result.x1, result.y1, 0f, t), + StrokePoint(result.x2, result.y2, 0f, t) + ) + } + is ShapeSnapDetection.SnapResult.Arrow -> return null + is ShapeSnapDetection.SnapResult.Elbow -> { + val tipDwell = ArrowDwellDetection.hasDwellAtEnd( + currentStrokePoints, result.x2, result.y2, ARROW_DWELL_RADIUS_PX, ARROW_DWELL_MS + ) + strokeType = ArrowDwellDetection.classifyElbow(tipDwell, tailDwell) + isGeometric = true + + listOf( + StrokePoint(result.x1, result.y1, 0f, t), + StrokePoint(result.cx, result.cy, 0f, t), + StrokePoint(result.x2, result.y2, 0f, t) + ) + } + is ShapeSnapDetection.SnapResult.Arc -> { + val tipDwell = ArrowDwellDetection.hasDwellAtEnd( + currentStrokePoints, result.x2, result.y2, ARROW_DWELL_RADIUS_PX, ARROW_DWELL_MS + ) + strokeType = ArrowDwellDetection.classifyArc(tipDwell, tailDwell) + isGeometric = false + + listOf( + StrokePoint(result.x1, result.y1, 0f, t), + StrokePoint(result.cx, result.cy, 0f, t), + StrokePoint(result.x2, result.y2, 0f, t) + ) + } + is ShapeSnapDetection.SnapResult.Ellipse -> { + strokeType = StrokeType.ELLIPSE + val n = 60 + (0..n).map { i -> + val angle = 2 * Math.PI * i / n + StrokePoint( + (result.cx + result.a * cos(angle)).toFloat(), + (result.cy + result.b * sin(angle)).toFloat(), + 0f, t + ) + } + } + is ShapeSnapDetection.SnapResult.RoundedRectangle -> { + strokeType = StrokeType.ROUNDED_RECTANGLE + val r = result.cornerRadius.coerceAtMost( + minOf(result.right - result.left, result.bottom - result.top) / 2f + ) + val cl = result.left + r; val cr = result.right - r + val ct = result.top + r; val cb = result.bottom - r + val arcN = 8 + val list = mutableListOf() + fun arc(cx: Float, cy: Float, startDeg: Double, endDeg: Double) { + for (i in 0 until arcN) { + val a = Math.toRadians(startDeg + (endDeg - startDeg) * i / arcN) + list += StrokePoint((cx + r * cos(a)).toFloat(), + (cy + r * sin(a)).toFloat(), 0f, t) + } + } + arc(cr, ct, -90.0, 0.0); list += StrokePoint(result.right, cb, 0f, t) + arc(cr, cb, 0.0, 90.0); list += StrokePoint(cl, result.bottom, 0f, t) + arc(cl, cb, 90.0, 180.0); list += StrokePoint(result.left, ct, 0f, t) + arc(cl, ct, 180.0, 270.0); list += StrokePoint(cr, result.top, 0f, t) + list + } + is ShapeSnapDetection.SnapResult.Rectangle -> { + strokeType = StrokeType.RECTANGLE + isGeometric = true + listOf( + StrokePoint(result.left, result.top, 0f, t), + StrokePoint(result.right, result.top, 0f, t), + StrokePoint(result.right, result.bottom, 0f, t), + StrokePoint(result.left, result.bottom, 0f, t), + StrokePoint(result.left, result.top, 0f, t), + ) + } + is ShapeSnapDetection.SnapResult.Diamond -> { + strokeType = StrokeType.DIAMOND + isGeometric = true + val cx = (result.left + result.right) / 2f + val cy = (result.top + result.bottom) / 2f + listOf( + StrokePoint(cx, result.top, 0f, t), + StrokePoint(result.right, cy, 0f, t), + StrokePoint(cx, result.bottom, 0f, t), + StrokePoint(result.left, cy, 0f, t), + StrokePoint(cx, result.top, 0f, t), + ) + } + is ShapeSnapDetection.SnapResult.Triangle -> { + strokeType = StrokeType.TRIANGLE + isGeometric = true + listOf( + StrokePoint(result.x1, result.y1, 0f, t), + StrokePoint(result.x2, result.y2, 0f, t), + StrokePoint(result.x3, result.y3, 0f, t), + StrokePoint(result.x1, result.y1, 0f, t), + ) + } + is ShapeSnapDetection.SnapResult.Curve -> { + val tipDwell = ArrowDwellDetection.hasDwellAtEnd( + currentStrokePoints, last.x, last.y, ARROW_DWELL_RADIUS_PX, ARROW_DWELL_MS + ) + strokeType = ArrowDwellDetection.classifyArc(tipDwell, tailDwell) + isGeometric = false + + result.points.map { (x, y) -> StrokePoint(x, y, 0f, t) } + } + is ShapeSnapDetection.SnapResult.SelfLoop -> { + val tipDwell = ArrowDwellDetection.hasDwellAtEnd( + currentStrokePoints, last.x, last.y, ARROW_DWELL_RADIUS_PX, ARROW_DWELL_MS + ) + strokeType = ArrowDwellDetection.classifyArc(tipDwell, tailDwell) + isGeometric = false + + val nPts = 40 + (0..nPts).map { i -> + val angle = result.startAngle + result.sweepAngle * i.toFloat() / nPts + StrokePoint( + (result.cx + result.rx * cos(angle.toDouble())).toFloat(), + (result.cy + result.ry * sin(angle.toDouble())).toFloat(), + 0f, t + ) + } + } + } + currentStrokePoints.clear() + currentStrokePoints.addAll(snappedPoints) + Log.i(TAG, "Shape snap: $result → $strokeType") + + return SnapData(strokeType, isGeometric) + } + + /** Check if the completed stroke is a scratch-out erase gesture. */ + private fun checkPostStrokeScratchOut(): Boolean { + val diagonal = hypot(strokeMaxX - strokeMinX, strokeMaxY - strokeMinY) + val first = currentStrokePoints.first() + val last = currentStrokePoints.last() + val closeDist = hypot(last.x - first.x, last.y - first.y) + val isClosedLoop = diagonal > 0f && closeDist < ShapeSnapDetection.CLOSE_FRACTION * diagonal + + val xs = FloatArray(currentStrokePoints.size) { currentStrokePoints[it].x } + val yRange = strokeMaxY - strokeMinY + if (!ScratchOutDetection.detect(xs, yRange, LINE_SPACING, isClosedLoop)) return false + + val left = strokeMinX; val top = strokeMinY + val right = strokeMaxX; val bottom = strokeMaxY + + // Only treat as scratch-out if there are existing strokes under the region. + // Without this, new cursive words with many reversals (e.g. "difficulty") + // are consumed as scratch-outs and disappear. + if (!ScratchOutDetection.hasTargetStrokes(completedStrokes, left, top, right, bottom)) return false + + currentStrokePoints.clear() + currentPath.reset() + + pauseRawDrawing() + onScratchOut?.invoke(left, top, right, bottom) + resumeRawDrawing() + + Log.i(TAG, "Post-stroke scratch-out: region=[$left,$top,$right,$bottom]") + return true + } + + // ── Dwell helpers ───────────────────────────────────────────────────────── + + private fun startDwellJob() { + dwellIndicatorShown = false + dwellDotCenter = null + val job = Runnable { + val pts = currentStrokePoints + if (pts.isEmpty()) return@Runnable + val first = pts.first() + val last = pts.lastOrNull() ?: return@Runnable + val dx = last.x - first.x + val dy = last.y - first.y + if (dx * dx + dy * dy < ARROW_DWELL_RADIUS_PX * ARROW_DWELL_RADIUS_PX) { + dwellIndicatorShown = true + dwellDotCenter = PointF(first.x, first.y) + if (!useOnyxSdk) { + drawToSurface() + } + } + } + dwellJob = job + handler.postDelayed(job, ARROW_DWELL_MS) + } + + private fun cancelDwellJob() { + dwellJob?.let { handler.removeCallbacks(it) } + dwellJob = null + } + + // ── Running stroke bounding box ─────────────────────────────────────────── + /** * Initialise the running stroke bounding box from the first point of a new stroke. * Must be called once at stroke start (ACTION_DOWN / onBeginRawDrawing). @@ -977,6 +1314,11 @@ class HandwritingCanvasView @JvmOverloads constructor( canvas.drawPath(currentPath, strokePaint) } + // Draw dwell indicator dot (arrow start-dwell inside diagram areas) + dwellDotCenter?.let { dot -> + canvas.drawCircle(dot.x, dot.y, CanvasTheme.DEFAULT_STROKE_WIDTH * 3f, dwellDotPaint) + } + canvas.restore() // Draw tutorial annotations on top of everything diff --git a/app/src/main/java/com/writer/view/LineDragDetection.kt b/app/src/main/java/com/writer/view/LineDragDetection.kt new file mode 100644 index 0000000..8e6eae8 --- /dev/null +++ b/app/src/main/java/com/writer/view/LineDragDetection.kt @@ -0,0 +1,76 @@ +package com.writer.view + +import kotlin.math.abs +import kotlin.math.roundToInt + +/** + * Pure geometry functions for line-drag gesture detection. + * + * Extracted from [HandwritingCanvasView] to allow JVM unit testing + * without Android framework dependencies. + */ +object LineDragDetection { + + /** + * Minimum vertical span (in line spacings) to classify a stroke as a line-drag. + * + * Set to 2.0 so the gesture must span two full line spacings (≈ 15 mm at 300 PPI). + * This is far taller than any single handwritten letter (including capitals and + * ascenders) and eliminates the false positives that caused writing strokes near + * the right margin to be silently consumed after a diagram was drawn (Bug #6). + */ + const val MIN_SPANS = 2f + + /** + * Maximum allowed horizontal drift as a fraction of the stroke's vertical span. + * A perfectly vertical stroke has drift 0; a diagonal has drift ≥ 1. + */ + const val MAX_DRIFT = 0.3f + + /** + * Determine whether a completed stroke has the shape of a line-drag gesture. + * + * A line-drag is a nearly-vertical stroke: its net vertical displacement must + * exceed [MIN_SPANS] line spacings, and its horizontal bounding-box width must + * be less than [MAX_DRIFT] times the vertical displacement. + * + * @param firstY doc-space Y of stroke start (first point) + * @param lastY doc-space Y of stroke end (last point) + * @param xRange horizontal bounding-box width: maxX − minX across all points + * @param lineSpacing line spacing in pixels + * @return the shift in lines (positive = downward, negative = upward), + * or null if the stroke is not a line-drag + */ + fun detect(firstY: Float, lastY: Float, xRange: Float, lineSpacing: Float): Int? { + val yDelta = lastY - firstY + val absYDelta = abs(yDelta) + if (absYDelta <= MIN_SPANS * lineSpacing) return null + if (xRange >= absYDelta * MAX_DRIFT) return null + return (yDelta / lineSpacing).roundToInt() + } + + /** + * Returns true if the stroke would snap to a straight line via [ShapeSnapDetection], + * meaning it should NOT be consumed as a line-drag gesture. + */ + fun isSnappableLine(xs: FloatArray, ys: FloatArray, lineSpacing: Float): Boolean { + return ShapeSnapDetection.detect(xs, ys, lineSpacing) is ShapeSnapDetection.SnapResult.Line + } + + /** + * Returns true if the stroke's leftmost X position is within the valid line-drag zone: + * the rightmost [gutterWidth] px of the writing area (i.e. the column immediately to + * the left of the scroll gutter). + * + * This guard prevents tall narrow writing strokes (ascender letters, digit '1', etc.) + * in the main text area from being falsely consumed as line-drag gestures. Only + * deliberate gestures drawn right beside the gutter should trigger a drag. + * + * @param strokeMinX leftmost X coordinate of the stroke (document space) + * @param canvasWidth full width of the writing canvas in px (before gutter is excluded) + * @param gutterWidth width of the scroll gutter in px + */ + fun isInDragZone(strokeMinX: Float, canvasWidth: Float, gutterWidth: Float): Boolean { + return strokeMinX >= canvasWidth - gutterWidth * 2 + } +} diff --git a/app/src/main/java/com/writer/view/RecognizedTextView.kt b/app/src/main/java/com/writer/view/RecognizedTextView.kt index 533a3a3..9f636d1 100644 --- a/app/src/main/java/com/writer/view/RecognizedTextView.kt +++ b/app/src/main/java/com/writer/view/RecognizedTextView.kt @@ -44,6 +44,12 @@ class RecognizedTextView @JvmOverloads constructor( private val BULLET_HANG_INDENT get() = ScreenMetrics.dp(54f).toInt() private const val BULLET_PREFIX = "\u2022 " private val HEADING_SPACING_AFTER get() = ScreenMetrics.dp(6f) + private val BOTTOM_PADDING get() = ScreenMetrics.dp(5f) + + // Gutter tap zone thresholds (multiples of GUTTER_WIDTH from top) + private const val GUTTER_LOGO_ZONE = 1.2f + private const val GUTTER_UNDO_ZONE = 2.4f + private const val GUTTER_REDO_ZONE = 3.6f } private val textPaint = TextPaint().apply { @@ -174,6 +180,12 @@ class RecognizedTextView @JvmOverloads constructor( /** Called when the user taps the "I" logo. */ var onLogoTap: (() -> Unit)? = null + /** Called when the user taps the undo button in the gutter. */ + var onUndoTap: (() -> Unit)? = null + + /** Called when the user taps the redo button in the gutter. */ + var onRedoTap: (() -> Unit)? = null + /** Called when the user taps on a text segment. Passes the written lineIndex. */ var onTextTap: ((Int) -> Unit)? = null diff --git a/app/src/main/java/com/writer/view/ScratchOutDetection.kt b/app/src/main/java/com/writer/view/ScratchOutDetection.kt new file mode 100644 index 0000000..0de5110 --- /dev/null +++ b/app/src/main/java/com/writer/view/ScratchOutDetection.kt @@ -0,0 +1,268 @@ +package com.writer.view + +import com.writer.model.InkStroke +import com.writer.model.StrokePoint +import kotlin.math.abs + +/** + * Pure geometry functions for scratch-out (scribble-to-erase) gesture detection. + * + * A scratch-out is a rapid back-and-forth horizontal stroke. Detected post-stroke + * (after pen-up) by counting X-direction reversals. Natural writing strokes have + * at most one reversal; a deliberate scratch-out has two or more. + * + * ## Threshold rationale (standard device, LS = 118 px) + * + * | Constant | Value | Standard device (118 px LS) | + * |---------------------|-------|------------------------------| + * | MIN_REVERSALS | 2 | ≥ 2 direction changes | + * | MIN_X_TRAVEL_SPANS | 1.5 | ≥ 177 px total x-travel | + * | MAX_Y_DRIFT | 0.4 | y-range < 40% of x-travel | + */ +object ScratchOutDetection { + + /** Minimum number of X-direction reversals to qualify as a scratch-out. */ + const val MIN_REVERSALS = 2 + + /** Minimum total X-travel (sum of all |dx|) in line spacings. */ + const val MIN_X_TRAVEL_SPANS = 0.5f + + /** + * Reversal density threshold: reversals per line-spacing of X-span. + * A tight scribble (many reversals packed into a small area) is unmistakably + * intentional erasing. When density exceeds this, the travel requirement + * is halved so tight scribbles are accepted sooner. + */ + const val TIGHT_DENSITY_THRESHOLD = 3.0f + + /** + * Maximum vertical bounding-box height as a fraction of total X-travel. + * Keeps the scratch roughly horizontal. + */ + const val MAX_Y_DRIFT = 0.4f + + /** + * Maximum net horizontal advance as a fraction of total X-travel. + * A scratch-out goes back and forth over the same region (advance ≈ 0); + * cursive writing progresses steadily forward (advance ≈ word width). + * If `|lastX − firstX| / totalXTravel ≥ MAX_ADVANCE_RATIO`, the stroke + * is progressive writing, not a scratch-out. + */ + const val MAX_ADVANCE_RATIO = 0.4f + + /** + * Maximum path-length / diagonal ratio for a stroke to be considered a closed loop. + * A shape outline traces its perimeter once (ratio ~3-4). + * A zigzag scratch-out covers the same ground repeatedly (ratio >> 4). + * If `pathLength / diagonal ≥ PATH_RATIO_THRESHOLD`, the stroke is a scratch-out + * zigzag even if start ≈ end, and should NOT be classified as a closed loop. + */ + const val PATH_RATIO_THRESHOLD = 4.5f + + /** + * Determine whether a stroke is a true closed loop (shape drawn around content) + * vs a compact scratch-out (start ≈ end but zigzags back and forth). + * + * @param closeDist distance between first and last points + * @param diagonal bounding-box diagonal of the stroke + * @param pathLength total path length of the stroke + * @return true if the stroke is a genuine closed loop (not a scratch-out zigzag) + */ + fun isClosedLoop(closeDist: Float, diagonal: Float, pathLength: Float): Boolean { + if (diagonal <= 0f) return false + if (closeDist >= ShapeSnapDetection.CLOSE_FRACTION * diagonal) return false + // High path/diagonal ratio → zigzag covering same ground, not a shape outline + return pathLength / diagonal < PATH_RATIO_THRESHOLD + } + + /** + * Detect a scratch-out gesture along either axis. + * + * Analyses reversals along both X and Y independently, then uses the axis + * with more reversals as the oscillation axis. The perpendicular axis is + * used for drift and advance checks. This allows scratch-outs in any + * direction — horizontal scribbles over vertical arrows and vice versa. + * + * @param xs x-coordinates of all stroke points + * @param ys y-coordinates of all stroke points (may be empty for + * backward compatibility — falls back to X-only analysis) + * @param lineSpacing line spacing in pixels + * @param isClosedLoop true if the stroke is a closed loop (start ≈ end in 2D space). + * A closed loop is never a scratch-out — it is a shape drawn around + * existing content. Callers should compute this from full (x,y) + * data before calling detect(). + * @return true if the stroke is a scratch-out gesture + */ + fun detect( + xs: FloatArray, ys: FloatArray = floatArrayOf(), + lineSpacing: Float, isClosedLoop: Boolean = false + ): Boolean { + if (isClosedLoop) return false + if (xs.size < 4) return false + + val xStats = axisStats(xs) + val yStats = if (ys.size == xs.size) axisStats(ys) else null + + // Pick the dominant oscillation axis: more reversals, or more travel if tied + val osc: AxisStats // oscillation axis (the back-and-forth direction) + val cross: Float // perpendicular axis range (drift) + val yDominant = yStats != null && (yStats.reversals > xStats.reversals + || (yStats.reversals == xStats.reversals && yStats.totalTravel > xStats.totalTravel)) + if (yDominant) { + osc = yStats + cross = xStats.span + } else { + osc = xStats + cross = yStats?.span ?: (xs.max() - xs.min()) // fallback: yRange not available + } + + if (osc.reversals < MIN_REVERSALS) return false + + // Tight scribble: many reversals packed into a small span. + // Halve the travel requirement when reversal density is high. + val spanInSpans = (osc.span / lineSpacing).coerceAtLeast(0.01f) + val density = osc.reversals / spanInSpans + val travelThreshold = if (density >= TIGHT_DENSITY_THRESHOLD) + MIN_X_TRAVEL_SPANS * lineSpacing * 0.5f + else + MIN_X_TRAVEL_SPANS * lineSpacing + if (osc.totalTravel < travelThreshold) return false + if (cross >= osc.totalTravel * MAX_Y_DRIFT) return false + // Progressive-advance guard: cursive writing advances along the oscillation + // axis while a scratch-out covers the same ground repeatedly. + if (osc.netAdvance >= osc.totalTravel * MAX_ADVANCE_RATIO) return false + return true + } + + /** @suppress backward compat: old signature with yRange instead of ys array */ + fun detect(xs: FloatArray, yRange: Float, lineSpacing: Float, isClosedLoop: Boolean = false): Boolean { + // Delegate to the full version. Without ys, only X-axis reversals are checked + // and yRange is used as the cross-axis drift. + if (isClosedLoop) return false + if (xs.size < 4) return false + + val xStats = axisStats(xs) + if (xStats.reversals < MIN_REVERSALS) return false + + val spanInSpans = (xStats.span / lineSpacing).coerceAtLeast(0.01f) + val density = xStats.reversals / spanInSpans + val travelThreshold = if (density >= TIGHT_DENSITY_THRESHOLD) + MIN_X_TRAVEL_SPANS * lineSpacing * 0.5f + else + MIN_X_TRAVEL_SPANS * lineSpacing + if (xStats.totalTravel < travelThreshold) return false + if (yRange >= xStats.totalTravel * MAX_Y_DRIFT) return false + if (xStats.netAdvance >= xStats.totalTravel * MAX_ADVANCE_RATIO) return false + return true + } + + /** Reversal / travel statistics for a single axis. */ + internal data class AxisStats( + val reversals: Int, + val totalTravel: Float, + val span: Float, + val netAdvance: Float + ) + + /** Compute reversal count, total travel, span, and net advance for one axis. */ + internal fun axisStats(vals: FloatArray): AxisStats { + var reversals = 0 + var totalTravel = 0f + var prevDir = 0 + for (i in 1 until vals.size) { + val d = vals[i] - vals[i - 1] + if (d == 0f) continue + val dir = if (d > 0f) 1 else -1 + totalTravel += abs(d) + if (prevDir != 0 && dir != prevDir) reversals++ + prevDir = dir + } + val span = vals.max() - vals.min() + val netAdvance = abs(vals.last() - vals.first()) + return AxisStats(reversals, totalTravel, span, netAdvance) + } + + /** + * Check whether any existing stroke overlaps the scratch-out bounding box. + * A scratch-out should only erase when there is pre-existing content underneath; + * otherwise new cursive words with many reversals (e.g. "difficulty") are + * consumed as scratch-outs and disappear. + */ + fun hasTargetStrokes( + existingStrokes: List, + left: Float, top: Float, right: Float, bottom: Float + ): Boolean = existingStrokes.any { stroke -> + stroke.points.any { pt -> pt.x in left..right && pt.y in top..bottom } + || stroke.strokeType.isConnector + && strokeIntersectsRect(stroke.points, left, top, right, bottom) + } + + /** + * Test whether any line segment between consecutive stroke points intersects + * the axis-aligned rectangle [left, top, right, bottom]. + * + * This catches geometric strokes (arrows/lines with only 2 points) where the + * visual line passes through a region even though neither endpoint is inside it. + * Uses Cohen–Sutherland-style segment clipping. + */ + fun strokeIntersectsRect( + points: List, + left: Float, top: Float, right: Float, bottom: Float + ): Boolean { + for (i in 0 until points.size - 1) { + if (segmentIntersectsRect( + points[i].x, points[i].y, + points[i + 1].x, points[i + 1].y, + left, top, right, bottom + )) return true + } + return false + } + + /** + * Cohen–Sutherland line segment vs AABB intersection test. + * Returns true if the segment (x1,y1)→(x2,y2) intersects or is inside the rect. + */ + internal fun segmentIntersectsRect( + x1: Float, y1: Float, x2: Float, y2: Float, + left: Float, top: Float, right: Float, bottom: Float + ): Boolean { + var ax = x1; var ay = y1; var bx = x2; var by = y2 + + fun outcode(x: Float, y: Float): Int { + var code = 0 + if (x < left) code = code or 1 + if (x > right) code = code or 2 + if (y < top) code = code or 4 + if (y > bottom) code = code or 8 + return code + } + + var codeA = outcode(ax, ay) + var codeB = outcode(bx, by) + + while (true) { + if (codeA or codeB == 0) return true // both inside + if (codeA and codeB != 0) return false // both on same outside side + // Pick the point outside the rect + val codeOut = if (codeA != 0) codeA else codeB + val x: Float; val y: Float + when { + codeOut and 8 != 0 -> { // below bottom + x = ax + (bx - ax) * (bottom - ay) / (by - ay); y = bottom + } + codeOut and 4 != 0 -> { // above top + x = ax + (bx - ax) * (top - ay) / (by - ay); y = top + } + codeOut and 2 != 0 -> { // right of right + y = ay + (by - ay) * (right - ax) / (bx - ax); x = right + } + else -> { // left of left + y = ay + (by - ay) * (left - ax) / (bx - ax); x = left + } + } + if (codeOut == codeA) { ax = x; ay = y; codeA = outcode(ax, ay) } + else { bx = x; by = y; codeB = outcode(bx, by) } + } + } +} diff --git a/app/src/main/java/com/writer/view/ShapeSnapDetection.kt b/app/src/main/java/com/writer/view/ShapeSnapDetection.kt new file mode 100644 index 0000000..475a614 --- /dev/null +++ b/app/src/main/java/com/writer/view/ShapeSnapDetection.kt @@ -0,0 +1,890 @@ +package com.writer.view + +import kotlin.math.abs +import kotlin.math.acos +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.hypot +import kotlin.math.min +import kotlin.math.PI +import kotlin.math.sin +import kotlin.math.sqrt + +/** + * Post-stroke shape snapping: converts a freehand stroke to a clean geometric + * shape when the stroke closely approximates a known shape. + * + * Shapes detected (in priority order for closed strokes): + * 1. Ellipse — closed loop with 0–1 sharp corners and low deviation from an inscribed ellipse + * 2. Triangle — closed loop with exactly 3 sharp corners + * 3. Rectangle — closed loop with ≥4 sharp corners and points near bounding-box edges + * 4. Line — open stroke closely following a straight path + * + * Corner detection uses a windowed angle-change derivative: at each interior point, + * the angle between the backward and forward vectors (window = max(3, N/12)) is computed. + * Peaks above CORNER_ANGLE_DEG that are separated by below-threshold points count as one corner. + * + * For very short strokes (N < 2·window+1) where reliable corner counting is impossible, + * closed shapes fall back to bounding-box rectangle detection (handles the minimal 5-point + * test rectangle used in unit tests without risking false positives in practice). + */ +object ShapeSnapDetection { + + // ── Line constants ──────────────────────────────────────────────────────── + + /** Maximum perpendicular deviation / line length for straight-line snap. */ + const val LINE_MAX_DEVIATION = 0.12f + + /** Minimum stroke length in line spacings for straight-line snap. */ + const val LINE_MIN_SPANS = 1.0f + + /** + * Maximum ratio of path length to straight-line length for line snap. + * Rejects curved strokes (arcs, unclosed circles) whose path winds far from + * the start-to-end axis. A straight line = 1.0; a semicircle ≈ 1.57. + */ + const val LINE_MAX_PATH_RATIO = 1.5f + + // ── Closed-shape constants ──────────────────────────────────────────────── + + /** Max distance start→end / diagonal for a stroke to be considered "closed". */ + const val CLOSE_FRACTION = 0.20f + + /** Max mean point-to-nearest-edge distance / diagonal for rectangle snapping. */ + const val RECT_MAX_PERIM_DEV = 0.15f + + /** Max single-point distance to nearest bbox edge / diagonal for rectangle snapping. + * Rejects letter-like shapes (B, P, D) whose interior valleys lie far from all edges, + * while accepting genuine rectangles and rounded rectangles whose corners merely curve. */ + const val RECT_MAX_POINT_DEV = 0.12f + + /** Minimum length of both sides of the snapped rectangle in line spacings. */ + const val RECT_MIN_SIDE_SPANS = 0.4f + + /** + * Maximum mean point-to-inscribed-ellipse distance / diagonal for ellipse snap. + * A perfect circle or oval has deviation 0; a rectangle or triangle has much higher deviation. + */ + const val ELLIPSE_MAX_DEV = 0.07f + + /** + * Maximum windowed angle (degrees) for a point to be considered "locally straight". + * Much lower than [CORNER_ANGLE_DEG] — this detects flat segments, not corners. + */ + const val STRAIGHT_ANGLE_DEG = 10f + + /** + * Minimum fraction of stroke points that are locally straight for a shape to be + * classified as a rounded rectangle rather than an ellipse. An ellipse has curvature + * everywhere (straightFraction ≈ 0); a rounded rectangle always has four flat sides + * (straightFraction > 0.2 even with generous corner radii). + */ + const val STRAIGHT_FRACTION_MIN = 0.12f + + /** + * Minimum direction-change angle (degrees) at a point to be counted as a corner. + * A right-angle (90°) corner is well above this threshold; gentle curves are below. + */ + const val CORNER_ANGLE_DEG = 50f + + /** + * Minimum distance from a bounding-box corner to the nearest stroke point, + * expressed as a fraction of the diagonal, for the shape to be treated as having + * rounded corners rather than sharp ones. If every bounding-box corner (TL/TR/BR/BL) + * is farther than this threshold from all stroke points, the shape is classified as + * a rounded rectangle before corner counting. + * + * A sharp rectangle has stroke points at or near each bounding-box corner (distance ≈ 0). + * A rounded rectangle with radius r has its closest arc point at distance 0.414·r from each + * bounding-box corner; for r ≥ ~3% of diagonal that exceeds this threshold. + */ + const val ROUNDED_CORNER_THRESHOLD = 0.03f + + // ── Elbow constants ──────────────────────────────────────────────────────── + + /** Minimum corner angle (degrees) for elbow detection. */ + const val ELBOW_MIN_ANGLE_DEG = 60f + + /** Maximum corner angle (degrees) for elbow detection. */ + const val ELBOW_MAX_ANGLE_DEG = 120f + + /** Maximum perpendicular deviation / leg length for each elbow leg to be considered straight. */ + const val ELBOW_LEG_MAX_DEVIATION = 0.15f + + // ── Arc constants ──────────────────────────────────────────────────────── + + /** Minimum path ratio (path length / chord length) for arc detection. */ + const val ARC_MIN_PATH_RATIO = 1.02f + + /** Maximum path ratio for arc detection. */ + const val ARC_MAX_PATH_RATIO = 2.5f + + /** Maximum point-to-bezier deviation / chord length for arc fit quality. */ + const val ARC_MAX_FIT_DEVIATION = 0.08f + + // ── Self-loop constants ──────────────────────────────────────────────────── + + /** Maximum gap (start→end distance / diagonal) for self-loop detection. */ + const val SELF_LOOP_MAX_GAP = 0.75f + + /** Maximum ellipse fit deviation / diagonal for self-loop (more lenient than ELLIPSE_MAX_DEV). */ + const val SELF_LOOP_MAX_ELLIPSE_DEV = 0.15f + + // ── Diamond constant ────────────────────────────────────────────────────── + + /** + * Maximum distance from each detected corner to its nearest bounding-box edge midpoint, + * as a fraction of min(width, height). If all corners satisfy this threshold, the shape + * is classified as a diamond rather than a rectangle. + */ + const val DIAMOND_CORNER_THRESHOLD = 0.20f + + // ── Result types ────────────────────────────────────────────────────────── + + sealed class SnapResult { + data class Line(val x1: Float, val y1: Float, val x2: Float, val y2: Float) : SnapResult() + data class Arrow( + val x1: Float, val y1: Float, // tail + val x2: Float, val y2: Float, // head + val tailHead: Boolean = false, // arrowhead at tail + val tipHead: Boolean = true // arrowhead at tip + ) : SnapResult() + data class Ellipse(val cx: Float, val cy: Float, val a: Float, val b: Float) : SnapResult() + data class RoundedRectangle( + val left: Float, val top: Float, + val right: Float, val bottom: Float, + val cornerRadius: Float + ) : SnapResult() + data class Rectangle( + val left: Float, val top: Float, + val right: Float, val bottom: Float + ) : SnapResult() + data class Diamond( + val left: Float, val top: Float, + val right: Float, val bottom: Float + ) : SnapResult() + data class Triangle( + val x1: Float, val y1: Float, + val x2: Float, val y2: Float, + val x3: Float, val y3: Float + ) : SnapResult() + /** L-shaped connector: start → corner → end. */ + data class Elbow( + val x1: Float, val y1: Float, + val cx: Float, val cy: Float, + val x2: Float, val y2: Float + ) : SnapResult() + /** Curved connector: start, quadratic bezier control point, end. */ + data class Arc( + val x1: Float, val y1: Float, + val cx: Float, val cy: Float, + val x2: Float, val y2: Float + ) : SnapResult() + /** Smooth open curve that doesn't fit a single bezier (e.g. U-shaped self-referential arc). */ + data class Curve(val points: List>) : SnapResult() + /** Self-referential loop: elliptical arc from startAngle sweeping sweepAngle radians. */ + data class SelfLoop( + val cx: Float, val cy: Float, + val rx: Float, val ry: Float, + val startAngle: Float, + val sweepAngle: Float + ) : SnapResult() + } + + // ── Public API ──────────────────────────────────────────────────────────── + + fun detect(xs: FloatArray, ys: FloatArray, lineSpacing: Float): SnapResult? { + if (xs.size < 2 || xs.size != ys.size) return null + + val minX = xs.min(); val maxX = xs.max() + val minY = ys.min(); val maxY = ys.max() + val w = maxX - minX; val h = maxY - minY + val diagonal = sqrt(w * w + h * h) + + val closeDistance = dist(xs.first(), ys.first(), xs.last(), ys.last()) + val isClosed = diagonal > 0f && closeDistance < CLOSE_FRACTION * diagonal + + if (isClosed && min(w, h) >= RECT_MIN_SIDE_SPANS * lineSpacing) { + // Try ellipse first: max-deviation test is robust because rounded + // shapes (circles, ovals, sloppy circles with small bumps) keep all + // points within ELLIPSE_MAX_DEV of the inscribed ellipse, while + // shapes with true sharp corners (rectangles, triangles) have corner + // points that exceed the threshold. + detectEllipse(xs, ys, minX, maxX, minY, maxY, diagonal)?.let { return it } + + // Ellipse failed: use corner counting to distinguish rectangle vs triangle. + val n = xs.size + val window = maxOf(3, n / 12) + if (n >= 2 * window + 1) { + // Count corners first so we can route correctly before the rounded-corner check. + // Cyclic corner detection: treats the closed stroke as a ring so that a + // corner exactly at the start/end boundary is never missed. + val corners = findCornerIndicesCyclic(xs, ys, window) + + // If every bounding-box corner is far from all stroke points, the stroke + // never reaches the bbox corners — typical of rounded rectangles AND diamonds. + // We check for diamond (corners near edge midpoints) before rounded rect. + if (hasRoundedBboxCorners(xs, ys, minX, maxX, minY, maxY, diagonal)) { + if (corners.size >= 4) { + detectDiamond(xs, ys, corners, minX, maxX, minY, maxY)?.let { return it } + } + detectRoundedRectangle(xs, ys, minX, maxX, minY, maxY, diagonal) + ?.let { return it } + } + + when { + corners.size == 3 -> detectTriangle(xs, ys, corners) + ?.let { return it } + corners.size >= 4 -> (detectDiamond(xs, ys, corners, minX, maxX, minY, maxY) + ?: detectRectangle(xs, ys, minX, maxX, minY, maxY, diagonal)) + ?.let { return it } + // 0–2 sharp corners: check rounded-rectangle as a final fallback. + else -> detectRoundedRectangle(xs, ys, minX, maxX, minY, maxY, diagonal) + ?.let { return it } + } + } else { + // Stroke has too few points for reliable corner counting. + // Fall back to bounding-box rectangle detection — safe because + // a genuine circle with so few points cannot form a closed loop. + detectRectangle(xs, ys, minX, maxX, minY, maxY, diagonal)?.let { return it } + } + } + + // Open strokes: try line (strictest), then elbow, then arc, self-loop, curve + detectLine(xs, ys, lineSpacing)?.let { return it } + detectElbow(xs, ys, lineSpacing)?.let { return it } + detectArc(xs, ys, lineSpacing)?.let { return it } + detectSelfLoop(xs, ys, lineSpacing)?.let { return it } + return detectCurve(xs, ys, lineSpacing) + } + + // ── Corner detection ────────────────────────────────────────────────────── + + /** + * Find corner indices: positions where the stroke direction changes sharply. + * Uses a windowed angle-change derivative and suppresses duplicate detections + * within the same corner region via an [inCorner] flag that tracks the current peak. + * + * Returns indices sorted ascending (stroke order), one per geometric corner. + */ + private fun findCornerIndices( + xs: FloatArray, ys: FloatArray, window: Int, count: Int = xs.size + ): List { + val n = count + val threshold = (CORNER_ANGLE_DEG * PI / 180).toFloat() + val result = mutableListOf() + var inCorner = false + var peakAngle = 0f + var peakIndex = -1 + + for (i in window until n - window) { + val ax = xs[i] - xs[i - window]; val ay = ys[i] - ys[i - window] + val bx = xs[i + window] - xs[i]; val by = ys[i + window] - ys[i] + val lenA = hypot(ax.toDouble(), ay.toDouble()).toFloat() + val lenB = hypot(bx.toDouble(), by.toDouble()).toFloat() + if (lenA == 0f || lenB == 0f) { + if (inCorner) { result.add(peakIndex); inCorner = false } + continue + } + val dot = ((ax * bx + ay * by) / (lenA * lenB)).coerceIn(-1f, 1f) + val angle = acos(dot.toDouble()).toFloat() + + if (angle > threshold) { + if (!inCorner) { inCorner = true; peakAngle = angle; peakIndex = i } + else if (angle > peakAngle) { peakAngle = angle; peakIndex = i } + } else { + if (inCorner) { result.add(peakIndex); inCorner = false } + } + } + if (inCorner) result.add(peakIndex) + return result + } + + /** + * Cyclic variant of [findCornerIndices] for closed strokes. + * Prepends the last [window] points and appends the first [window] points so + * that a corner exactly at the start/end boundary is detected correctly. + * + * If the last point duplicates the first (the typical explicit-close convention), + * it is trimmed before extending to avoid counting the start corner twice. + */ + // Reusable buffers for findCornerIndicesCyclic to avoid per-call allocations. + // ShapeSnapDetection is a singleton object, so these are effectively static. + private var cyclicBufXs = FloatArray(0) + private var cyclicBufYs = FloatArray(0) + + private fun findCornerIndicesCyclic(xs: FloatArray, ys: FloatArray, window: Int): List { + // Strip the closing duplicate if present so the start/end corner is not detected twice. + val lastDuplicatesFirst = dist(xs.last(), ys.last(), xs.first(), ys.first()) < 0.01f + val n = if (lastDuplicatesFirst) xs.size - 1 else xs.size + + val ext = n + 2 * window + // Grow buffers only when needed; never shrink to avoid repeated allocation + // across similarly-sized strokes. + if (cyclicBufXs.size < ext) { + cyclicBufXs = FloatArray(ext) + cyclicBufYs = FloatArray(ext) + } + val extXs = cyclicBufXs + val extYs = cyclicBufYs + for (i in 0 until ext) { + when { + i < window -> { extXs[i] = xs[n - window + i]; extYs[i] = ys[n - window + i] } + i < n + window -> { extXs[i] = xs[i - window]; extYs[i] = ys[i - window] } + else -> { extXs[i] = xs[i - n - window]; extYs[i] = ys[i - n - window] } + } + } + // Extended loop covers extIdx ∈ [window, n+window-1] → origIdx = extIdx - window ∈ [0, n-1] + // Note: findCornerIndices only reads indices [0, ext), which is within our buffer. + val translated = findCornerIndices(extXs, extYs, window, count = ext) + .map { it - window } + .distinct() + .sorted() + + // Merge corners whose cyclic distance is ≤ window: they represent the same corner + // on opposite sides of the stroke's start/end boundary. + if (translated.size < 2) return translated + val merged = translated.toMutableList() + val cyclicDistFirstLast = n - merged.last() + merged.first() + if (cyclicDistFirstLast <= window) merged.removeAt(merged.lastIndex) + return merged + } + + /** + * Returns true if every bounding-box corner (TL/TR/BR/BL) is farther than + * [ROUNDED_CORNER_THRESHOLD] × [diagonal] from all stroke points. + * Indicates that the stroke never reaches the sharp corners of its bounding box, + * which is the defining characteristic of a rounded rectangle (as opposed to a + * sharp rectangle or a triangle that touches or lands on a bounding-box corner). + */ + private fun hasRoundedBboxCorners( + xs: FloatArray, ys: FloatArray, + minX: Float, maxX: Float, minY: Float, maxY: Float, + diagonal: Float + ): Boolean { + val threshold = ROUNDED_CORNER_THRESHOLD * diagonal + val bboxCorners = arrayOf( + Pair(minX, minY), Pair(maxX, minY), Pair(maxX, maxY), Pair(minX, maxY) + ) + for ((cx, cy) in bboxCorners) { + var minDist = Float.MAX_VALUE + for (i in xs.indices) { + val d = dist(xs[i], ys[i], cx, cy) + if (d < minDist) minDist = d + } + if (minDist < threshold) return false // a sharp corner is close to this bbox corner + } + return true + } + + // ── Shape detectors ─────────────────────────────────────────────────────── + + private fun detectEllipse( + xs: FloatArray, ys: FloatArray, + minX: Float, maxX: Float, minY: Float, maxY: Float, + diagonal: Float + ): SnapResult.Ellipse? { + val cx = (minX + maxX) / 2f; val cy = (minY + maxY) / 2f + val a = (maxX - minX) / 2f; val b = (maxY - minY) / 2f + if (a == 0f || b == 0f) return null + val a2 = a * a; val b2 = b * b + + // First-order signed distance to ellipse: |f(P)| / |∇f(P)| where f(P) = dx²/a² + dy²/b² − 1. + // Exact for points on the ellipse (f=0) and accurate for points close to it. + // Unlike radial projection (atan2 → place on ellipse), this formula is correct for + // non-circular ellipses — atan2 projection gives non-zero error for oval points. + // + // We use max deviation: a rectangle with uniformly-spaced edge points has low mean + // deviation (edge midpoints lie on the inscribed ellipse) but high MAX deviation at + // corners. A true ellipse has all points near the surface, so max ≈ 0. + var maxDev = 0f + for (i in xs.indices) { + val dx = xs[i] - cx; val dy = ys[i] - cy + val f = dx * dx / a2 + dy * dy / b2 - 1f + val gx = 2f * dx / a2; val gy = 2f * dy / b2 + val gLen = sqrt(gx * gx + gy * gy) + val d = if (gLen > 0f) abs(f) / gLen else 0f + if (d > maxDev) maxDev = d + } + if (maxDev > ELLIPSE_MAX_DEV * diagonal) return null + + // Straightness check: an ellipse has non-zero curvature everywhere, so its + // straight fraction is near 0. A rounded rectangle that passes the ellipse-fit + // test (generous corner radii) still has four flat sides with straight fraction + // > 0.20. Reject the ellipse classification if the stroke has too many straight + // points — the caller will then fall through to rounded-rectangle detection. + if (straightFraction(xs, ys, minX, maxX, minY, maxY) > STRAIGHT_FRACTION_MIN) return null + + return SnapResult.Ellipse(cx, cy, a, b) + } + + private fun detectTriangle( + xs: FloatArray, ys: FloatArray, + corners: List + ): SnapResult.Triangle? { + if (corners.size != 3) return null + return SnapResult.Triangle( + xs[corners[0]], ys[corners[0]], + xs[corners[1]], ys[corners[1]], + xs[corners[2]], ys[corners[2]] + ) + } + + /** + * Returns a Diamond if all detected corners are near the four bounding-box edge midpoints + * `(cx,minY)`, `(maxX,cy)`, `(cx,maxY)`, `(minX,cy)`, within [DIAMOND_CORNER_THRESHOLD]×min(w,h). + */ + private fun detectDiamond( + xs: FloatArray, ys: FloatArray, + corners: List, + minX: Float, maxX: Float, minY: Float, maxY: Float + ): SnapResult.Diamond? { + val cx = (minX + maxX) / 2f + val cy = (minY + maxY) / 2f + val w = maxX - minX + val h = maxY - minY + val threshold = DIAMOND_CORNER_THRESHOLD * min(w, h) + val edgeMidpoints = arrayOf( + Pair(cx, minY), Pair(maxX, cy), Pair(cx, maxY), Pair(minX, cy) + ) + for (ci in corners) { + val px = xs[ci]; val py = ys[ci] + val nearestDist = edgeMidpoints.minOf { (mx, my) -> dist(px, py, mx, my) } + if (nearestDist > threshold) return null + } + return SnapResult.Diamond(minX, minY, maxX, maxY) + } + + private fun detectRectangle( + xs: FloatArray, ys: FloatArray, + minX: Float, maxX: Float, minY: Float, maxY: Float, + diagonal: Float + ): SnapResult.Rectangle? { + if (perimeterDeviation(xs, ys, minX, maxX, minY, maxY) > RECT_MAX_PERIM_DEV * diagonal) return null + if (maxPerimeterDeviation(xs, ys, minX, maxX, minY, maxY) > RECT_MAX_POINT_DEV * diagonal) return null + return SnapResult.Rectangle(minX, minY, maxX, maxY) + } + + private fun detectRoundedRectangle( + xs: FloatArray, ys: FloatArray, + minX: Float, maxX: Float, minY: Float, maxY: Float, + diagonal: Float + ): SnapResult.RoundedRectangle? { + // Same bounding-box perimeter check: points must sit near the edges. + // Rounded corners slightly increase mean deviation vs a sharp rectangle, + // but the threshold is generous enough to accept them. + if (perimeterDeviation(xs, ys, minX, maxX, minY, maxY) > RECT_MAX_PERIM_DEV * diagonal) return null + if (maxPerimeterDeviation(xs, ys, minX, maxX, minY, maxY) > RECT_MAX_POINT_DEV * diagonal) return null + val w = maxX - minX; val h = maxY - minY + val cornerRadius = min(w, h) / 4f + return SnapResult.RoundedRectangle(minX, minY, maxX, maxY, cornerRadius) + } + + private fun perimeterDeviation( + xs: FloatArray, ys: FloatArray, + minX: Float, maxX: Float, minY: Float, maxY: Float + ): Float { + var total = 0f + for (i in xs.indices) { + total += minOf(abs(xs[i] - minX), abs(xs[i] - maxX), + abs(ys[i] - minY), abs(ys[i] - maxY)) + } + return total / xs.size + } + + private fun maxPerimeterDeviation( + xs: FloatArray, ys: FloatArray, + minX: Float, maxX: Float, minY: Float, maxY: Float + ): Float { + var max = 0f + for (i in xs.indices) { + val d = minOf(abs(xs[i] - minX), abs(xs[i] - maxX), + abs(ys[i] - minY), abs(ys[i] - maxY)) + if (d > max) max = d + } + return max + } + + private fun detectLine(xs: FloatArray, ys: FloatArray, lineSpacing: Float): SnapResult? { + val x0 = xs.first(); val y0 = ys.first() + val x1 = xs.last(); val y1 = ys.last() + val len = dist(x0, y0, x1, y1) + if (len < LINE_MIN_SPANS * lineSpacing) return null + if (maxPerpendicularDeviation(xs, ys, x0, y0, x1, y1) > LINE_MAX_DEVIATION * len) return null + if (pathLength(xs, ys) > LINE_MAX_PATH_RATIO * len) return null + return SnapResult.Line(x0, y0, x1, y1) + } + + /** + * Detect an L-shaped elbow connector: exactly 1 sharp corner with angle in [60°, 120°], + * each leg locally straight. Snaps corner to the nearest right-angle position. + */ + private fun detectElbow(xs: FloatArray, ys: FloatArray, lineSpacing: Float): SnapResult.Elbow? { + val n = xs.size + if (n < 5) return null + + val x0 = xs.first(); val y0 = ys.first() + val x1 = xs.last(); val y1 = ys.last() + val totalLen = dist(x0, y0, x1, y1) + if (totalLen < LINE_MIN_SPANS * lineSpacing) return null + + val window = maxOf(3, n / 12) + if (n < 2 * window + 1) return null + + val corners = findCornerIndices(xs, ys, window) + if (corners.size != 1) return null + val ci = corners[0] + + // Verify corner angle is in [60°, 120°] + val ax = xs[ci] - x0; val ay = ys[ci] - y0 + val bx = x1 - xs[ci]; val by = y1 - ys[ci] + val lenA = hypot(ax.toDouble(), ay.toDouble()).toFloat() + val lenB = hypot(bx.toDouble(), by.toDouble()).toFloat() + if (lenA == 0f || lenB == 0f) return null + val dot = ((ax * bx + ay * by) / (lenA * lenB)).coerceIn(-1f, 1f) + val angleDeg = (acos(dot.toDouble()) * 180.0 / PI).toFloat() + if (angleDeg < ELBOW_MIN_ANGLE_DEG || angleDeg > ELBOW_MAX_ANGLE_DEG) return null + + // Each leg must be locally straight + val leg1Xs = FloatArray(ci + 1) { xs[it] } + val leg1Ys = FloatArray(ci + 1) { ys[it] } + if (maxPerpendicularDeviation(leg1Xs, leg1Ys, x0, y0, xs[ci], ys[ci]) > ELBOW_LEG_MAX_DEVIATION * lenA) return null + + val leg2Size = n - ci + val leg2Xs = FloatArray(leg2Size) { xs[ci + it] } + val leg2Ys = FloatArray(leg2Size) { ys[ci + it] } + if (maxPerpendicularDeviation(leg2Xs, leg2Ys, xs[ci], ys[ci], x1, y1) > ELBOW_LEG_MAX_DEVIATION * lenB) return null + + // Snap corner to right angle: choose (x0, y1) or (x1, y0) — whichever is closer to the raw corner + val opt1x = x0; val opt1y = y1 + val opt2x = x1; val opt2y = y0 + val d1 = dist(xs[ci], ys[ci], opt1x, opt1y) + val d2 = dist(xs[ci], ys[ci], opt2x, opt2y) + val (cx, cy) = if (d1 <= d2) Pair(opt1x, opt1y) else Pair(opt2x, opt2y) + + return SnapResult.Elbow(x0, y0, cx, cy, x1, y1) + } + + /** + * Detect a smooth arc connector via quadratic bezier fit quality. + * + * Uses bounding box diagonal (not chord) for minimum size and fit normalization, + * so self-referential arcs with close endpoints are still detected. No path-ratio + * or corner-count gates — the bezier fit alone rejects zigzags, scribbles, and + * other non-arc shapes because they deviate far from any single quadratic curve. + */ + private fun detectArc(xs: FloatArray, ys: FloatArray, lineSpacing: Float): SnapResult.Arc? { + val n = xs.size + if (n < 5) return null + + val x0 = xs.first(); val y0 = ys.first() + val x1 = xs.last(); val y1 = ys.last() + val chordLen = dist(x0, y0, x1, y1) + + val minX = xs.min(); val maxX = xs.max() + val minY = ys.min(); val maxY = ys.max() + val w = maxX - minX; val h = maxY - minY + val diagonal = sqrt(w * w + h * h) + + // Must be large enough (bounding box, not chord — short-chord arcs are valid) + if (diagonal < LINE_MIN_SPANS * lineSpacing * 0.5f) return null + + // Must not be closed + if (diagonal > 0f && chordLen < CLOSE_FRACTION * diagonal) return null + + // Find point of max perpendicular deviation from chord — this is the arc midpoint M. + // For short-chord arcs, use distance from chord midpoint instead. + var maxDev = 0f + var maxIdx = n / 2 + if (chordLen > 1f) { + val dx = x1 - x0; val dy = y1 - y0 + for (i in xs.indices) { + val dev = abs((xs[i] - x0) * dy - (ys[i] - y0) * dx) / chordLen + if (dev > maxDev) { maxDev = dev; maxIdx = i } + } + } else { + // Near-zero chord: find the point farthest from the midpoint + val midX = (x0 + x1) / 2f; val midY = (y0 + y1) / 2f + for (i in xs.indices) { + val d = dist(xs[i], ys[i], midX, midY) + if (d > maxDev) { maxDev = d; maxIdx = i } + } + } + + // The arc must have meaningful curvature (not nearly straight) + val fitRef = maxOf(chordLen, diagonal * 0.5f) + if (maxDev < fitRef * 0.05f) return null + + // Convert midpoint M to quadratic bezier control point: C = 2*M - 0.5*P0 - 0.5*P2 + val mx = xs[maxIdx]; val my = ys[maxIdx] + val cx = 2f * mx - 0.5f * x0 - 0.5f * x1 + val cy = 2f * my - 0.5f * y0 - 0.5f * y1 + + // Validate fit: for each stroke point, find minimum distance to the bezier curve. + // Normalize by the larger of chord and half-diagonal, so short-chord arcs + // get a reasonable tolerance. + val samples = 50 + var maxFitDev = 0f + for (i in xs.indices) { + var minD = Float.MAX_VALUE + for (s in 0..samples) { + val t = s.toFloat() / samples + val omt = 1f - t + val bx = omt * omt * x0 + 2f * omt * t * cx + t * t * x1 + val by = omt * omt * y0 + 2f * omt * t * cy + t * t * y1 + val d = dist(xs[i], ys[i], bx, by) + if (d < minD) minD = d + } + if (minD > maxFitDev) maxFitDev = minD + } + if (maxFitDev > ARC_MAX_FIT_DEVIATION * fitRef) return null + + return SnapResult.Arc(x0, y0, cx, cy, x1, y1) + } + + /** + * Detect a smooth open curve that doesn't fit a single quadratic bezier + * (e.g. U-shaped self-referential arcs). Strips dwell duplicates, checks + * smoothness with a fixed window, and resamples to evenly-spaced points. + */ + private fun detectCurve(xs: FloatArray, ys: FloatArray, lineSpacing: Float): SnapResult.Curve? { + val n = xs.size + if (n < 10) return null + + // Strip dwell duplicates (consecutive near-identical points) + val sxs = mutableListOf(xs[0]) + val sys = mutableListOf(ys[0]) + for (i in 1 until n) { + if (dist(xs[i], ys[i], sxs.last(), sys.last()) > 0.5f) { + sxs.add(xs[i]); sys.add(ys[i]) + } + } + val sx = sxs.toFloatArray() + val sy = sys.toFloatArray() + val sn = sx.size + if (sn < 10) return null + + val minX = sx.min(); val maxX = sx.max() + val minY = sy.min(); val maxY = sy.max() + val w = maxX - minX; val h = maxY - minY + val diagonal = sqrt(w * w + h * h) + if (diagonal < LINE_MIN_SPANS * lineSpacing * 0.5f) return null + + // Must not be closed + val chordLen = dist(sx.first(), sy.first(), sx.last(), sy.last()) + if (diagonal > 0f && chordLen < CLOSE_FRACTION * diagonal) return null + + // Smooth: no SHARP corners (>120°). Uses a higher threshold than the standard + // CORNER_ANGLE_DEG (50°) because smooth U-turns have 70-100° direction changes + // that are valid curve features, not zigzag corners. + val window = minOf(8, sn / 4) + val sharpThreshold = (120f * PI / 180f).toFloat() + if (window >= 3 && sn >= 2 * window + 1) { + for (i in window until sn - window) { + val ax = sx[i] - sx[i - window]; val ay = sy[i] - sy[i - window] + val bx = sx[i + window] - sx[i]; val by = sy[i + window] - sy[i] + val lenA = hypot(ax.toDouble(), ay.toDouble()).toFloat() + val lenB = hypot(bx.toDouble(), by.toDouble()).toFloat() + if (lenA == 0f || lenB == 0f) continue + val dot = ((ax * bx + ay * by) / (lenA * lenB)).coerceIn(-1f, 1f) + if (acos(dot.toDouble()).toFloat() > sharpThreshold) return null + } + } + + // Must have meaningful curvature + val maxPerp = maxPerpendicularDeviation(sx, sy, sx.first(), sy.first(), sx.last(), sy.last()) + if (maxPerp < diagonal * 0.05f) return null + + // Resample to ~30 evenly-spaced points along the path + val totalLen = pathLength(sx, sy) + val numPts = 30 + val step = totalLen / numPts + val pts = mutableListOf(Pair(sx[0], sy[0])) + var accumulated = 0f + var nextTarget = step + for (i in 1 until sn) { + val segLen = dist(sx[i - 1], sy[i - 1], sx[i], sy[i]) + accumulated += segLen + while (accumulated >= nextTarget && pts.size < numPts) { + val overshoot = accumulated - nextTarget + val t = if (segLen > 0f) 1f - overshoot / segLen else 1f + pts.add(Pair( + sx[i - 1] + (sx[i] - sx[i - 1]) * t, + sy[i - 1] + (sy[i] - sy[i - 1]) * t + )) + nextTarget += step + } + } + pts.add(Pair(sx.last(), sy.last())) + + return SnapResult.Curve(pts) + } + + /** + * Detect a self-referential loop: a near-closed smooth curve that fits an elliptical arc. + * Used for flowchart self-loop connectors (arrows from a node back to itself). + */ + private fun detectSelfLoop(xs: FloatArray, ys: FloatArray, lineSpacing: Float): SnapResult.SelfLoop? { + val n = xs.size + if (n < 10) return null + + val x0 = xs.first(); val y0 = ys.first() + val x1 = xs.last(); val y1 = ys.last() + + val minX = xs.min(); val maxX = xs.max() + val minY = ys.min(); val maxY = ys.max() + val w = maxX - minX; val h = maxY - minY + val diagonal = sqrt(w * w + h * h) + + // Must have significant extent + if (min(w, h) < LINE_MIN_SPANS * lineSpacing * 0.5f) return null + + // Must be nearly closed + val closeDist = dist(x0, y0, x1, y1) + if (closeDist > SELF_LOOP_MAX_GAP * diagonal) return null + + // Must be smooth (0 corners) + val window = maxOf(3, n / 12) + if (n >= 2 * window + 1) { + val corners = findCornerIndices(xs, ys, window) + if (corners.isNotEmpty()) return null + } + + // Fit ellipse using bounding box + val cx = (minX + maxX) / 2f + val cy = (minY + maxY) / 2f + val rx = w / 2f + val ry = h / 2f + if (rx == 0f || ry == 0f) return null + + // Validate ellipse fit (more lenient than full ellipse detection) + val rx2 = rx * rx; val ry2 = ry * ry + var maxDev = 0f + for (i in xs.indices) { + val dx = xs[i] - cx; val dy = ys[i] - cy + val f = dx * dx / rx2 + dy * dy / ry2 - 1f + val gx = 2f * dx / rx2; val gy = 2f * dy / ry2 + val gLen = sqrt(gx * gx + gy * gy) + val d = if (gLen > 0f) abs(f) / gLen else 0f + if (d > maxDev) maxDev = d + } + if (maxDev > SELF_LOOP_MAX_ELLIPSE_DEV * diagonal) return null + + // Compute start/end angles on the normalized ellipse + val startAngle = atan2((y0 - cy).toDouble() / ry, (x0 - cx).toDouble() / rx).toFloat() + val endAngle = atan2((y1 - cy).toDouble() / ry, (x1 - cx).toDouble() / rx).toFloat() + + // Determine winding direction using cross product of chord × midpoint offset. + // The shoelace formula doesn't work for open curves; this method checks which + // side of the start→end chord the arc's midpoint falls on. + // In screen coords (y-down): cross < 0 → clockwise, cross > 0 → counter-clockwise. + val midIdx = n / 2 + val chordDx = x1 - x0; val chordDy = y1 - y0 + val midDx = xs[midIdx] - x0; val midDy = ys[midIdx] - y0 + val cross = chordDx * midDy - chordDy * midDx + val cw = cross < 0 + + var sweep = endAngle - startAngle + if (cw) { + if (sweep < 0) sweep += (2 * PI).toFloat() + } else { + if (sweep > 0) sweep -= (2 * PI).toFloat() + } + + // Sweep must cover most of the ellipse (at least 180°) + if (abs(sweep) < PI.toFloat()) return null + + return SnapResult.SelfLoop(cx, cy, rx, ry, startAngle, sweep) + } + + // ── Geometry helpers ────────────────────────────────────────────────────── + + private fun pathLength(xs: FloatArray, ys: FloatArray): Float { + var total = 0f + for (i in 1 until xs.size) total += dist(xs[i - 1], ys[i - 1], xs[i], ys[i]) + return total + } + + /** + * Maximum perpendicular distance from any point in [xs]/[ys] to the + * line through (x0,y0)→(x1,y1). Returns 0f if line has zero length. + */ + internal fun maxPerpendicularDeviation( + xs: FloatArray, ys: FloatArray, + x0: Float, y0: Float, x1: Float, y1: Float + ): Float { + val dx = x1 - x0; val dy = y1 - y0 + val len = sqrt(dx * dx + dy * dy) + if (len == 0f) return 0f + var maxDev = 0f + for (i in xs.indices) { + val dev = abs((xs[i] - x0) * dy - (ys[i] - y0) * dx) / len + if (dev > maxDev) maxDev = dev + } + return maxDev + } + + /** + * Fraction of stroke points that are "locally straight" — where the windowed + * direction change is well below the minimum curvature expected from an + * ellipse inscribed in the same bounding box. + * + * An ellipse with semi-axes a,b has minimum curvature k_min = min(a,b)/max(a,b)² + * at the endpoints of the long axis. The corresponding windowed angle for a + * window spanning arc length s is approximately s × k_min. Points with angle + * below half this expected minimum are "truly straight" — they can only come + * from a rounded rectangle's flat sides, not from an ellipse's low-curvature zone. + * + * This adaptive threshold ensures elongated ovals (3:1, 4:1) are never + * misclassified as rounded rectangles, while still detecting the flat sides + * of rounded rectangles at any aspect ratio. + */ + internal fun straightFraction( + xs: FloatArray, ys: FloatArray, + minX: Float = xs.min(), maxX: Float = xs.max(), + minY: Float = ys.min(), maxY: Float = ys.max() + ): Float { + val n = xs.size + val window = maxOf(3, n / 14) + if (n < 2 * window + 1) return 0f + + val w = maxX - minX; val h = maxY - minY + val a = w / 2f; val b = h / 2f + if (a == 0f || b == 0f) return 0f + + // Approximate arc length per point, assuming roughly uniform spacing + // around the perimeter. Ellipse perimeter ≈ PI * (3(a+b) - sqrt((3a+b)(a+3b))). + val perim = (PI * (3 * (a + b) - sqrt((3 * a + b).toDouble() * (a + 3 * b)))).toFloat() + val arcPerWindow = perim * window / n + + // Minimum curvature of the inscribed ellipse: k_min = min(a,b) / max(a,b)² + val kMin = min(a, b) / (maxOf(a, b) * maxOf(a, b)) + + // Expected minimum windowed angle on the ellipse = arc × curvature. + // A point is "truly straight" if its angle is below half this — meaning + // it has less curvature than even the flattest part of the ellipse. + val threshold = (arcPerWindow * kMin * 0.5f) + .coerceIn((STRAIGHT_ANGLE_DEG * PI / 180).toFloat() * 0.1f, + (STRAIGHT_ANGLE_DEG * PI / 180).toFloat()) + + var straightCount = 0 + var measuredCount = 0 + for (i in window until n - window) { + val ax = xs[i] - xs[i - window]; val ay = ys[i] - ys[i - window] + val bx = xs[i + window] - xs[i]; val by = ys[i + window] - ys[i] + val lenA = hypot(ax.toDouble(), ay.toDouble()).toFloat() + val lenB = hypot(bx.toDouble(), by.toDouble()).toFloat() + if (lenA == 0f || lenB == 0f) continue + val dot = ((ax * bx + ay * by) / (lenA * lenB)).coerceIn(-1f, 1f) + val angle = acos(dot.toDouble()).toFloat() + measuredCount++ + if (angle < threshold) straightCount++ + } + return if (measuredCount > 0) straightCount.toFloat() / measuredCount else 0f + } + + private fun dist(x0: Float, y0: Float, x1: Float, y1: Float): Float { + val dx = x1 - x0; val dy = y1 - y0 + return sqrt(dx * dx + dy * dy) + } +} diff --git a/app/src/main/java/com/writer/view/UndoGestureDetection.kt b/app/src/main/java/com/writer/view/UndoGestureDetection.kt new file mode 100644 index 0000000..e308744 --- /dev/null +++ b/app/src/main/java/com/writer/view/UndoGestureDetection.kt @@ -0,0 +1,98 @@ +package com.writer.view + +import kotlin.math.abs + +/** + * Pure geometry functions for undo/redo gesture detection. + * + * The gesture is an L-shaped stroke: wide horizontal extent followed by a net + * vertical displacement. It is evaluated **post-stroke** (after pen-up), so the + * SDK is never disabled mid-draw and box drawing is not affected. + * + * ## Why post-stroke is safe for boxes + * + * Each box side is drawn as a separate stroke with the pen lifted at corners. + * - A pure horizontal stroke (box top/bottom): xRange is large but net yDelta + * is negligible — the user lifts before travelling far vertically. + * - A pure vertical stroke (box side): xRange is small — the horizontal + * threshold is not met. + * Only a deliberate L-shaped stroke (horizontal then continuous vertical) meets + * both criteria simultaneously. + * + * ## Threshold rationale + * + * | Constant | Standard (118 px LS) | Compact (77 px LS) | + * |--------------------------|----------------------|--------------------| + * | HORIZONTAL_MIN_SPANS 1.5 | ≈ 177 px / 15 mm | ≈ 115 px / 10 mm | + * | VERTICAL_ACTIVATION 1.0 | ≈ 118 px / 10 mm | ≈ 77 px / 6.5 mm | + * + * Extracted from [HandwritingCanvasView] to allow JVM unit testing without + * Android framework dependencies. + */ +object UndoGestureDetection { + + /** + * Minimum horizontal bounding-box width (in line spacings) for the trigger. + * Requires a clearly intentional horizontal stroke. + */ + const val HORIZONTAL_MIN_SPANS = 1.5f + + /** + * Maximum vertical bounding-box height as a fraction of xRange while + * evaluating the horizontal component. Keeps the stroke flat. + * + * Used by [isHorizontalTrigger] for mid-stroke (legacy) checks and unit tests. + */ + const val HORIZONTAL_MAX_DRIFT = 0.2f + + /** + * Minimum net vertical displacement (in line spacings) required to classify + * the stroke as an undo gesture. Must exceed a typical box-corner dip. + */ + const val VERTICAL_ACTIVATION_SPANS = 1.0f + + /** + * Detect a post-stroke undo/redo gesture. + * + * Returns the scrub offset (negative = undo steps, positive = redo steps) + * proportional to the stroke's vertical displacement, or null if the stroke + * does not have the undo L-shape. + * + * @param firstY doc-space Y of stroke start + * @param lastY doc-space Y of stroke end + * @param xRange horizontal bounding-box width: maxX − minX + * @param lineSpacing line spacing in pixels + * @param stepSize pixels of vertical travel per one scrub step + */ + fun detect( + firstY: Float, + lastY: Float, + xRange: Float, + lineSpacing: Float, + stepSize: Float + ): Int? { + if (xRange <= HORIZONTAL_MIN_SPANS * lineSpacing) return null + val yDelta = lastY - firstY + if (abs(yDelta) <= VERTICAL_ACTIVATION_SPANS * lineSpacing) return null + return (yDelta / stepSize).toInt() + } + + /** + * Returns true if a stroke has sufficient horizontal extent and is flat + * enough to qualify as the horizontal undo trigger. + * + * Kept for unit tests. The production path uses [detect] instead. + */ + fun isHorizontalTrigger(xRange: Float, yRange: Float, lineSpacing: Float): Boolean = + xRange > HORIZONTAL_MIN_SPANS * lineSpacing && + yRange < xRange * HORIZONTAL_MAX_DRIFT + + /** + * Returns true if the pen has moved far enough vertically from the trigger + * point to activate the scrub. + * + * Kept for unit tests. The production path uses [detect] instead. + */ + fun isVerticalActivation(verticalDisplacement: Float, lineSpacing: Float): Boolean = + abs(verticalDisplacement) > VERTICAL_ACTIVATION_SPANS * lineSpacing +} diff --git a/app/src/test/java/com/writer/storage/DocumentStorageSerializationTest.kt b/app/src/test/java/com/writer/storage/DocumentStorageSerializationTest.kt new file mode 100644 index 0000000..db56343 --- /dev/null +++ b/app/src/test/java/com/writer/storage/DocumentStorageSerializationTest.kt @@ -0,0 +1,178 @@ +package com.writer.storage + +import com.writer.model.DocumentData +import com.writer.model.InkStroke +import com.writer.model.StrokePoint +import com.writer.model.StrokeType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Round-trip tests for [DocumentStorage] JSON serialization. + * Verifies that strokeType and isGeometric survive save/load. + */ +class DocumentStorageSerializationTest { + + private fun makeData(strokes: List) = DocumentData( + strokes = strokes, + scrollOffsetY = 0f, + lineTextCache = emptyMap(), + everHiddenLines = emptySet(), + highestLineIndex = 0, + currentLineIndex = 0 + ) + + private fun roundTrip(strokes: List): List { + val json = DocumentStorage.serializeToJson(makeData(strokes)) + val loaded = DocumentStorage.deserializeFromJson(json.toString()) + return loaded.strokes + } + + private fun samplePoints() = listOf( + StrokePoint(0f, 0f, 1f, 0L), + StrokePoint(100f, 100f, 1f, 100L) + ) + + // ── strokeType persistence ────────────────────────────────────────────── + + @Test fun arrowHead_survivesRoundTrip() { + val strokes = roundTrip(listOf( + InkStroke(points = samplePoints(), strokeType = StrokeType.ARROW_HEAD, isGeometric = true) + )) + assertEquals(StrokeType.ARROW_HEAD, strokes[0].strokeType) + } + + @Test fun arrowTail_survivesRoundTrip() { + val strokes = roundTrip(listOf( + InkStroke(points = samplePoints(), strokeType = StrokeType.ARROW_TAIL, isGeometric = true) + )) + assertEquals(StrokeType.ARROW_TAIL, strokes[0].strokeType) + } + + @Test fun arrowBoth_survivesRoundTrip() { + val strokes = roundTrip(listOf( + InkStroke(points = samplePoints(), strokeType = StrokeType.ARROW_BOTH, isGeometric = true) + )) + assertEquals(StrokeType.ARROW_BOTH, strokes[0].strokeType) + } + + @Test fun line_survivesRoundTrip() { + val strokes = roundTrip(listOf( + InkStroke(points = samplePoints(), strokeType = StrokeType.LINE, isGeometric = true) + )) + assertEquals(StrokeType.LINE, strokes[0].strokeType) + } + + @Test fun rectangle_survivesRoundTrip() { + val strokes = roundTrip(listOf( + InkStroke(points = samplePoints(), strokeType = StrokeType.RECTANGLE, isGeometric = true) + )) + assertEquals(StrokeType.RECTANGLE, strokes[0].strokeType) + } + + @Test fun ellipse_survivesRoundTrip() { + val strokes = roundTrip(listOf( + InkStroke(points = samplePoints(), strokeType = StrokeType.ELLIPSE, isGeometric = true) + )) + assertEquals(StrokeType.ELLIPSE, strokes[0].strokeType) + } + + @Test fun freehand_survivesRoundTrip() { + val strokes = roundTrip(listOf( + InkStroke(points = samplePoints(), strokeType = StrokeType.FREEHAND) + )) + assertEquals(StrokeType.FREEHAND, strokes[0].strokeType) + } + + // ── isGeometric persistence ───────────────────────────────────────────── + + @Test fun isGeometricTrue_survivesRoundTrip() { + val strokes = roundTrip(listOf( + InkStroke(points = samplePoints(), isGeometric = true, strokeType = StrokeType.RECTANGLE) + )) + assertTrue("isGeometric should be true", strokes[0].isGeometric) + } + + @Test fun isGeometricFalse_survivesRoundTrip() { + val strokes = roundTrip(listOf( + InkStroke(points = samplePoints(), isGeometric = false) + )) + assertFalse("isGeometric should be false", strokes[0].isGeometric) + } + + // ── Backward compatibility ────────────────────────────────────────────── + + @Test fun oldJsonWithoutStrokeType_defaultsToFreehand() { + // Simulate a document saved before strokeType was added + val json = """ + { + "scrollOffsetY": 0, + "highestLineIndex": 0, + "currentLineIndex": 0, + "lineTextCache": {}, + "everHiddenLines": [], + "strokes": [{ + "strokeId": "s1", + "strokeWidth": 3.0, + "points": [ + {"x": 0, "y": 0, "pressure": 1, "timestamp": 0}, + {"x": 100, "y": 100, "pressure": 1, "timestamp": 100} + ] + }], + "diagramAreas": [] + } + """.trimIndent() + + val loaded = DocumentStorage.deserializeFromJson(json) + assertEquals(StrokeType.FREEHAND, loaded.strokes[0].strokeType) + assertFalse(loaded.strokes[0].isGeometric) + } + + @Test fun unknownStrokeType_defaultsToFreehand() { + val json = """ + { + "scrollOffsetY": 0, + "highestLineIndex": 0, + "currentLineIndex": 0, + "lineTextCache": {}, + "everHiddenLines": [], + "strokes": [{ + "strokeId": "s1", + "strokeWidth": 3.0, + "strokeType": "FUTURE_TYPE", + "points": [ + {"x": 0, "y": 0, "pressure": 1, "timestamp": 0} + ] + }], + "diagramAreas": [] + } + """.trimIndent() + + val loaded = DocumentStorage.deserializeFromJson(json) + assertEquals(StrokeType.FREEHAND, loaded.strokes[0].strokeType) + } + + // ── Multiple strokes with mixed types ─────────────────────────────────── + + @Test fun mixedStrokeTypes_allSurviveRoundTrip() { + val original = listOf( + InkStroke(points = samplePoints(), strokeType = StrokeType.FREEHAND), + InkStroke(points = samplePoints(), strokeType = StrokeType.ARROW_HEAD, isGeometric = true), + InkStroke(points = samplePoints(), strokeType = StrokeType.RECTANGLE, isGeometric = true), + InkStroke(points = samplePoints(), strokeType = StrokeType.ELLIPSE, isGeometric = true), + ) + + val loaded = roundTrip(original) + assertEquals(4, loaded.size) + assertEquals(StrokeType.FREEHAND, loaded[0].strokeType) + assertFalse(loaded[0].isGeometric) + assertEquals(StrokeType.ARROW_HEAD, loaded[1].strokeType) + assertTrue(loaded[1].isGeometric) + assertEquals(StrokeType.RECTANGLE, loaded[2].strokeType) + assertTrue(loaded[2].isGeometric) + assertEquals(StrokeType.ELLIPSE, loaded[3].strokeType) + assertTrue(loaded[3].isGeometric) + } +} diff --git a/app/src/test/java/com/writer/ui/writing/UndoUnsnapTest.kt b/app/src/test/java/com/writer/ui/writing/UndoUnsnapTest.kt new file mode 100644 index 0000000..ec47169 --- /dev/null +++ b/app/src/test/java/com/writer/ui/writing/UndoUnsnapTest.kt @@ -0,0 +1,310 @@ +package com.writer.ui.writing + +import com.writer.model.DiagramArea +import com.writer.model.DocumentModel +import com.writer.model.InkStroke +import com.writer.model.StrokePoint +import com.writer.model.StrokeType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * Tests the undo-to-unsnap behavior: when a stroke is snapped to a shape, + * undo once restores the raw freehand stroke; undo again removes it entirely. + * + * Exercises the two-phase commit logic that [WritingCoordinator] uses when + * [HandwritingCanvasView.onStrokeReplaced] fires after a shape snap. + */ +class UndoUnsnapTest { + + private lateinit var documentModel: DocumentModel + private lateinit var undoManager: UndoManager + + /** Simulates the state captured by [WritingCoordinator.saveUndoSnapshot]. */ + private fun currentSnapshot() = UndoManager.Snapshot( + strokes = documentModel.activeStrokes.toList(), + scrollOffsetY = 0f, + lineTextCache = emptyMap(), + diagramAreas = documentModel.diagramAreas.toList() + ) + + private fun saveUndoSnapshot() { + undoManager.saveSnapshot(currentSnapshot()) + } + + private fun applySnapshot(snapshot: UndoManager.Snapshot) { + documentModel.activeStrokes.clear() + documentModel.activeStrokes.addAll(snapshot.strokes) + documentModel.diagramAreas.clear() + documentModel.diagramAreas.addAll(snapshot.diagramAreas) + } + + private fun undo(): Boolean { + val snapshot = undoManager.undo(currentSnapshot()) ?: return false + applySnapshot(snapshot) + return true + } + + private fun redo(): Boolean { + val snapshot = undoManager.redo(currentSnapshot()) ?: return false + applySnapshot(snapshot) + return true + } + + /** Simulate onStrokeCompleted as WritingCoordinator does. */ + private fun simulateStrokeCompleted(stroke: InkStroke) { + saveUndoSnapshot() + documentModel.activeStrokes.add(stroke) + } + + /** Simulate onStrokeReplaced as WritingCoordinator does. */ + private fun simulateStrokeReplaced(oldStrokeId: String, newStroke: InkStroke) { + saveUndoSnapshot() + documentModel.activeStrokes.removeAll { it.strokeId == oldStrokeId } + documentModel.activeStrokes.add(newStroke) + } + + private fun makePoints(vararg pairs: Pair): List = + pairs.map { (x, y) -> StrokePoint(x, y, 0.5f, 0L) } + + @Before + fun setUp() { + documentModel = DocumentModel() + undoManager = UndoManager() + } + + @Test + fun `snapped stroke - undo once shows raw freehand`() { + val rawStroke = InkStroke( + points = makePoints(10f to 10f, 50f to 50f, 100f to 100f), + strokeType = StrokeType.FREEHAND + ) + val snappedStroke = InkStroke( + points = makePoints(10f to 10f, 100f to 100f), + strokeType = StrokeType.LINE, + isGeometric = true + ) + + // Phase 1: raw stroke completed + simulateStrokeCompleted(rawStroke) + // Phase 2: replaced with snapped + simulateStrokeReplaced(rawStroke.strokeId, snappedStroke) + + // Verify current state has snapped stroke + assertEquals(1, documentModel.activeStrokes.size) + assertEquals(StrokeType.LINE, documentModel.activeStrokes[0].strokeType) + + // Undo once → raw freehand + assertTrue(undo()) + assertEquals(1, documentModel.activeStrokes.size) + assertEquals(StrokeType.FREEHAND, documentModel.activeStrokes[0].strokeType) + assertEquals(rawStroke.strokeId, documentModel.activeStrokes[0].strokeId) + } + + @Test + fun `snapped stroke - undo twice removes stroke entirely`() { + val rawStroke = InkStroke( + points = makePoints(10f to 10f, 100f to 100f), + strokeType = StrokeType.FREEHAND + ) + val snappedStroke = InkStroke( + points = makePoints(10f to 10f, 100f to 100f), + strokeType = StrokeType.RECTANGLE, + isGeometric = true + ) + + simulateStrokeCompleted(rawStroke) + simulateStrokeReplaced(rawStroke.strokeId, snappedStroke) + + // Undo once → raw stroke + assertTrue(undo()) + assertEquals(1, documentModel.activeStrokes.size) + + // Undo again → empty + assertTrue(undo()) + assertEquals(0, documentModel.activeStrokes.size) + } + + @Test + fun `snapped stroke - redo from raw restores snapped`() { + val rawStroke = InkStroke( + points = makePoints(10f to 10f, 50f to 50f, 100f to 100f), + strokeType = StrokeType.FREEHAND + ) + val snappedStroke = InkStroke( + points = makePoints(10f to 10f, 100f to 100f), + strokeType = StrokeType.ARROW_HEAD, + isGeometric = true + ) + + simulateStrokeCompleted(rawStroke) + simulateStrokeReplaced(rawStroke.strokeId, snappedStroke) + + // Undo to raw + undo() + assertEquals(StrokeType.FREEHAND, documentModel.activeStrokes[0].strokeType) + + // Redo → snapped restored + assertTrue(redo()) + assertEquals(1, documentModel.activeStrokes.size) + assertEquals(StrokeType.ARROW_HEAD, documentModel.activeStrokes[0].strokeType) + } + + @Test + fun `snapped stroke - redo from empty restores raw then snapped`() { + val rawStroke = InkStroke( + points = makePoints(10f to 10f, 100f to 100f), + strokeType = StrokeType.FREEHAND + ) + val snappedStroke = InkStroke( + points = makePoints(10f to 10f, 100f to 100f), + strokeType = StrokeType.ELLIPSE + ) + + simulateStrokeCompleted(rawStroke) + simulateStrokeReplaced(rawStroke.strokeId, snappedStroke) + + // Undo twice → empty + undo() + undo() + assertEquals(0, documentModel.activeStrokes.size) + + // Redo → raw + assertTrue(redo()) + assertEquals(1, documentModel.activeStrokes.size) + assertEquals(StrokeType.FREEHAND, documentModel.activeStrokes[0].strokeType) + + // Redo → snapped + assertTrue(redo()) + assertEquals(1, documentModel.activeStrokes.size) + assertEquals(StrokeType.ELLIPSE, documentModel.activeStrokes[0].strokeType) + } + + @Test + fun `non-snapped stroke undoes in one step`() { + val stroke = InkStroke( + points = makePoints(10f to 10f, 50f to 50f), + strokeType = StrokeType.FREEHAND + ) + + // Normal stroke — only onStrokeCompleted, no onStrokeReplaced + simulateStrokeCompleted(stroke) + + assertEquals(1, documentModel.activeStrokes.size) + + // Single undo removes it + assertTrue(undo()) + assertEquals(0, documentModel.activeStrokes.size) + } + + @Test + fun `mixed strokes - snapped and non-snapped undo independently`() { + // First: a normal freehand stroke + val freehand = InkStroke( + points = makePoints(10f to 10f, 50f to 50f), + strokeType = StrokeType.FREEHAND + ) + simulateStrokeCompleted(freehand) + + // Second: a snapped rectangle + val rawStroke = InkStroke( + points = makePoints(200f to 200f, 250f to 220f, 300f to 300f), + strokeType = StrokeType.FREEHAND + ) + val snappedStroke = InkStroke( + points = makePoints(200f to 200f, 300f to 200f, 300f to 300f, 200f to 300f, 200f to 200f), + strokeType = StrokeType.RECTANGLE, + isGeometric = true + ) + simulateStrokeCompleted(rawStroke) + simulateStrokeReplaced(rawStroke.strokeId, snappedStroke) + + // State: freehand + snapped rectangle + assertEquals(2, documentModel.activeStrokes.size) + + // Undo 1: rectangle → raw + undo() + assertEquals(2, documentModel.activeStrokes.size) + assertEquals(StrokeType.FREEHAND, documentModel.activeStrokes[0].strokeType) + assertEquals(StrokeType.FREEHAND, documentModel.activeStrokes[1].strokeType) + assertEquals(rawStroke.strokeId, documentModel.activeStrokes[1].strokeId) + + // Undo 2: raw stroke removed + undo() + assertEquals(1, documentModel.activeStrokes.size) + assertEquals(freehand.strokeId, documentModel.activeStrokes[0].strokeId) + + // Undo 3: freehand removed + undo() + assertEquals(0, documentModel.activeStrokes.size) + } + + @Test + fun `scrub-based undo walks through snap states`() { + val rawStroke = InkStroke( + points = makePoints(10f to 10f, 50f to 50f, 100f to 100f), + strokeType = StrokeType.FREEHAND + ) + val snappedStroke = InkStroke( + points = makePoints(10f to 10f, 100f to 100f), + strokeType = StrokeType.LINE, + isGeometric = true + ) + + simulateStrokeCompleted(rawStroke) + simulateStrokeReplaced(rawStroke.strokeId, snappedStroke) + + // Begin scrub from current (snapped) state + undoManager.beginScrub(currentSnapshot()) + + // Scrub -1 → raw stroke + val snap1 = undoManager.scrubTo(-1)!! + applySnapshot(snap1) + assertEquals(1, documentModel.activeStrokes.size) + assertEquals(StrokeType.FREEHAND, documentModel.activeStrokes[0].strokeType) + + // Scrub -2 → empty + val snap2 = undoManager.scrubTo(-2)!! + applySnapshot(snap2) + assertEquals(0, documentModel.activeStrokes.size) + + // Scrub back to 0 → snapped + val snap3 = undoManager.scrubTo(0)!! + applySnapshot(snap3) + assertEquals(1, documentModel.activeStrokes.size) + assertEquals(StrokeType.LINE, documentModel.activeStrokes[0].strokeType) + + undoManager.endScrub() + } + + @Test + fun `diagram areas preserved through snap undo cycle`() { + val diagramArea = DiagramArea(startLineIndex = 2, heightInLines = 4) + documentModel.diagramAreas.add(diagramArea) + + val rawStroke = InkStroke( + points = makePoints(50f to 300f, 100f to 350f), + strokeType = StrokeType.FREEHAND + ) + val snappedStroke = InkStroke( + points = makePoints(50f to 300f, 100f to 350f), + strokeType = StrokeType.LINE, + isGeometric = true + ) + + simulateStrokeCompleted(rawStroke) + simulateStrokeReplaced(rawStroke.strokeId, snappedStroke) + + // Undo to raw — diagram areas still present + undo() + assertEquals(1, documentModel.diagramAreas.size) + assertEquals(diagramArea, documentModel.diagramAreas[0]) + + // Undo to empty — diagram areas still present (was there before the stroke) + undo() + assertEquals(0, documentModel.activeStrokes.size) + assertEquals(1, documentModel.diagramAreas.size) + } +} diff --git a/app/src/test/java/com/writer/view/ArrowDwellDetectionTest.kt b/app/src/test/java/com/writer/view/ArrowDwellDetectionTest.kt new file mode 100644 index 0000000..5d8412a --- /dev/null +++ b/app/src/test/java/com/writer/view/ArrowDwellDetectionTest.kt @@ -0,0 +1,113 @@ +package com.writer.view + +import com.writer.model.StrokePoint +import com.writer.model.StrokeType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Unit tests for [ArrowDwellDetection]. + * + * Uses a fixed dwell radius of 15 px and dwell duration of 300 ms. + */ +class ArrowDwellDetectionTest { + + companion object { + private const val RADIUS_PX = 15f + private const val DWELL_MS = 300L + } + + // ── hasDwellAtEnd ───────────────────────────────────────────────────────── + + @Test fun hasDwellAtEnd_clusteringNearEndpointForSufficientTime_returnsTrue() { + // Last 4 points cluster within 15 px of (100, 100) for 300 ms total + val pts = listOf( + StrokePoint(0f, 0f, 1f, 0L), + StrokePoint(50f, 50f, 1f, 100L), + StrokePoint(98f, 100f, 1f, 500L), + StrokePoint(99f, 101f, 1f, 600L), + StrokePoint(100f, 100f, 1f, 700L), + StrokePoint(101f, 99f, 1f, 800L), + ) + val result = ArrowDwellDetection.hasDwellAtEnd(pts, 100f, 100f, RADIUS_PX, DWELL_MS) + assertTrue("Should detect dwell when clustering >= 300ms near endpoint", result) + } + + @Test fun hasDwellAtEnd_clusteringForLessThanDwellMs_returnsFalse() { + // Last points cluster near (100, 100) but only for 200 ms (< 300 ms) + val pts = listOf( + StrokePoint(0f, 0f, 1f, 0L), + StrokePoint(50f, 50f, 1f, 100L), + StrokePoint(98f, 100f, 1f, 500L), + StrokePoint(100f, 100f, 1f, 600L), + StrokePoint(101f, 99f, 1f, 700L), // only 200ms span in radius + StrokePoint(100f, 100f, 1f, 700L), + ) + // Start is at 500L, end is at 700L = 200ms, below 300ms threshold + val result = ArrowDwellDetection.hasDwellAtEnd(pts, 100f, 100f, RADIUS_PX, DWELL_MS) + assertFalse("Should NOT detect dwell when clustering < 300ms", result) + } + + @Test fun hasDwellAtEnd_lastPointsFarFromEndpoint_returnsFalse() { + // Points end far from (100, 100) + val pts = listOf( + StrokePoint(0f, 0f, 1f, 0L), + StrokePoint(100f, 100f, 1f, 100L), + StrokePoint(200f, 200f, 1f, 500L), + StrokePoint(250f, 250f, 1f, 800L), + ) + val result = ArrowDwellDetection.hasDwellAtEnd(pts, 100f, 100f, RADIUS_PX, DWELL_MS) + assertFalse("Should NOT detect dwell when last points are far from endpoint", result) + } + + // ── hasDwellAtStart ─────────────────────────────────────────────────────── + + @Test fun hasDwellAtStart_firstPointsClusterNearStartForSufficientTime_returnsTrue() { + // First points stay near (50, 50) for 400 ms before moving away + val pts = listOf( + StrokePoint(50f, 50f, 1f, 0L), + StrokePoint(52f, 49f, 1f, 100L), + StrokePoint(51f, 51f, 1f, 200L), + StrokePoint(50f, 50f, 1f, 400L), // 400 ms in radius + StrokePoint(100f, 100f, 1f, 500L), // moves away + ) + val result = ArrowDwellDetection.hasDwellAtStart(pts, RADIUS_PX, DWELL_MS) + assertTrue("Should detect dwell at start when pen pauses >= 300ms", result) + } + + @Test fun hasDwellAtStart_penMovesAwayQuickly_returnsFalse() { + // Pen moves away from start right away — only 50ms within radius + val pts = listOf( + StrokePoint(50f, 50f, 1f, 0L), + StrokePoint(51f, 50f, 1f, 50L), // still near + StrokePoint(100f, 100f, 1f, 200L), // moves away at 50ms + StrokePoint(200f, 200f, 1f, 400L), + ) + val result = ArrowDwellDetection.hasDwellAtStart(pts, RADIUS_PX, DWELL_MS) + assertFalse("Should NOT detect dwell when pen moves away quickly (only 50ms in radius)", result) + } + + // ── classifyArrow ───────────────────────────────────────────────────────── + + @Test fun classifyArrow_tipDwellOnly_returnsArrowHead() { + val result = ArrowDwellDetection.classifyArrow(tipDwell = true, tailDwell = false) + assertEquals(StrokeType.ARROW_HEAD, result) + } + + @Test fun classifyArrow_tailDwellOnly_returnsArrowTail() { + val result = ArrowDwellDetection.classifyArrow(tipDwell = false, tailDwell = true) + assertEquals(StrokeType.ARROW_TAIL, result) + } + + @Test fun classifyArrow_bothDwells_returnsArrowBoth() { + val result = ArrowDwellDetection.classifyArrow(tipDwell = true, tailDwell = true) + assertEquals(StrokeType.ARROW_BOTH, result) + } + + @Test fun classifyArrow_noDwells_returnsLine() { + val result = ArrowDwellDetection.classifyArrow(tipDwell = false, tailDwell = false) + assertEquals(StrokeType.LINE, result) + } +} diff --git a/app/src/test/java/com/writer/view/DiagramBandLinesTest.kt b/app/src/test/java/com/writer/view/DiagramBandLinesTest.kt new file mode 100644 index 0000000..0264e67 --- /dev/null +++ b/app/src/test/java/com/writer/view/DiagramBandLinesTest.kt @@ -0,0 +1,234 @@ +package com.writer.view + +import com.writer.model.InkStroke +import com.writer.model.StrokePoint +import com.writer.model.StrokeType +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Tests for [DiagramTextFilter.diagramBandLines]: text written beside a diagram + * (in its Y-band but outside its X-extent) should be detected as a band note. + */ +class DiagramBandLinesTest { + + // Diagram bbox: x=[100..300], y=[200..400] + private val bbox = floatArrayOf(100f, 200f, 300f, 400f) + private val yTol = 50f + + // Helper: a single-point freehand stroke at (cx, cy) + private fun stroke(cx: Float, cy: Float, type: StrokeType = StrokeType.FREEHAND): InkStroke { + val pt = StrokePoint(cx, cy, 1f, 0L) + return InkStroke(points = listOf(pt), strokeType = type) + } + + // Node bounds as [left, top, right, bottom] + private fun nodeBounds(l: Float, t: Float, r: Float, b: Float) = floatArrayOf(l, t, r, b) + + @Test fun strokeToRightOfDiagram_isDetected() { + val strokesByLine = mapOf(0 to listOf(stroke(350f, 300f))) + val result = DiagramTextFilter.diagramBandLines(strokesByLine, emptyList(), bbox, yTol) + assertTrue("Stroke to the right should be detected as a band note", 0 in result) + } + + @Test fun strokeToLeftOfDiagram_isDetected() { + val strokesByLine = mapOf(0 to listOf(stroke(50f, 300f))) + val result = DiagramTextFilter.diagramBandLines(strokesByLine, emptyList(), bbox, yTol) + assertTrue("Stroke to the left should be detected as a band note", 0 in result) + } + + @Test fun strokeAboveDiagram_notDetected() { + // cy = 100 < (bTop - yTol) = 150 + val strokesByLine = mapOf(0 to listOf(stroke(350f, 100f))) + val result = DiagramTextFilter.diagramBandLines(strokesByLine, emptyList(), bbox, yTol) + assertFalse("Stroke above Y-band should NOT be detected", 0 in result) + } + + @Test fun strokeBelowDiagram_notDetected() { + // cy = 500 > (bBottom + yTol) = 450 + val strokesByLine = mapOf(0 to listOf(stroke(350f, 500f))) + val result = DiagramTextFilter.diagramBandLines(strokesByLine, emptyList(), bbox, yTol) + assertFalse("Stroke below Y-band should NOT be detected", 0 in result) + } + + @Test fun strokeInsideNode_notDetected() { + // Stroke is to the right of bbox X but inside a node — shape label, not a note + val nodeB = nodeBounds(300f, 200f, 450f, 400f) + val strokesByLine = mapOf(0 to listOf(stroke(375f, 300f))) + val result = DiagramTextFilter.diagramBandLines(strokesByLine, listOf(nodeB), bbox, yTol) + assertFalse("Stroke inside a node should NOT be detected (it is a shape label)", 0 in result) + } + + @Test fun mixedLine_shapeLabelPlusBandNote_isDetected() { + // Real-world case from Issue #5: "side" text written at the same Y-level as shape + // label "A". The shape-label stroke is inside node A; the "side" stroke is outside X. + // Shape-label strokes must be ignored so the band note is still detected. + val nodeA = nodeBounds(100f, 200f, 200f, 320f) // inside the diagram bbox + val strokesByLine = mapOf( + 0 to listOf( + stroke(150f, 260f), // shape label "A" — inside nodeA + stroke(350f, 260f) // "side" — outside X, in Y-band + ) + ) + val result = DiagramTextFilter.diagramBandLines(strokesByLine, listOf(nodeA), bbox, yTol) + assertTrue("Line with shape-label + band-note strokes should be detected as a band note", 0 in result) + } + + @Test fun noFreehandStrokes_notDetected() { + val strokesByLine = mapOf(0 to listOf(stroke(350f, 300f, StrokeType.RECTANGLE))) + val result = DiagramTextFilter.diagramBandLines(strokesByLine, emptyList(), bbox, yTol) + assertFalse("Line with no freehand strokes should NOT be detected", 0 in result) + } + + @Test fun noDiagram_returnsEmpty() { + // When there are no nodes the caller passes no diagramBBox, so emptySet is expected. + // This tests the function directly with an arbitrary bbox but empty strokesByLine. + val result = DiagramTextFilter.diagramBandLines(emptyMap(), emptyList(), bbox, yTol) + assertTrue("No strokes → result should be empty", result.isEmpty()) + } + + @Test fun strokeJustOutsideBbox_detected() { + // cx = bbox.right + 1 = 301 → strictly outside X + val strokesByLine = mapOf(0 to listOf(stroke(301f, 300f))) + val result = DiagramTextFilter.diagramBandLines(strokesByLine, emptyList(), bbox, yTol) + assertTrue("Stroke just outside bbox X should be detected", 0 in result) + } + + @Test fun strokeExactlyAtBboxEdge_notDetected() { + // cx = bbox.right = 300 → on the X border → inside (not < bLeft, not > bRight) + val strokesByLine = mapOf(0 to listOf(stroke(300f, 300f))) + val result = DiagramTextFilter.diagramBandLines(strokesByLine, emptyList(), bbox, yTol) + assertFalse("Stroke exactly at bbox right edge should NOT be detected (on border = inside)", 0 in result) + } + + // ── Right-margin exclusion (Bug #6) ─────────────────────────────────────── + // + // After drawing a diagram, diagramBandLines activates. Without an upper-X + // bound, ANY text written to the right of the diagram's bbox and within its + // Y-band is classified as a band note — including normal text at the far-right + // margin of the page (just before the scroll gutter). That text should not + // be suppressed from the text paragraph panel. + // + // Fix: add a rightBandLimit parameter. Only strokes with cx < rightBandLimit + // qualify as "right-side" band notes. The caller passes + // canvasWidth − 2·gutterWidth so strokes in the rightmost gutter-zone are + // treated as ordinary text. + // + // Canvas geometry used in these tests (matching a 3200 px wide device with + // 129 px gutter, i.e. Tab X C at 300 PPI): + // rightBandLimit = 3200 − 2·129 = 2942 + + private val rightBandLimit = 2942f // canvasWidth − 2·gutterWidth + + @Test fun strokeFarRightOfDiagram_notBandNote() { + // Stroke at x=2950 is to the right of the diagram (bbox.right=300) but + // also past the rightBandLimit=2942 — it is regular text, NOT a band note. + // FAILS currently (no rightBandLimit check → detected as band note). + val strokesByLine = mapOf(0 to listOf(stroke(2950f, 300f))) + val result = DiagramTextFilter.diagramBandLines( + strokesByLine, emptyList(), bbox, yTol, rightBandLimit = rightBandLimit) + assertFalse("Text beyond rightBandLimit should not be a band note", 0 in result) + } + + @Test fun strokeJustInsideRightBandLimit_isBandNote() { + // Stroke at x=2940 is outside the diagram X and below rightBandLimit → IS a band note. + val strokesByLine = mapOf(0 to listOf(stroke(2940f, 300f))) + val result = DiagramTextFilter.diagramBandLines( + strokesByLine, emptyList(), bbox, yTol, rightBandLimit = rightBandLimit) + assertTrue("Text just inside rightBandLimit should still be a band note", 0 in result) + } + + @Test fun noRightBandLimit_farRightDetected() { + // Explicit MAX_VALUE preserves the old unlimited behaviour for callers that need it. + val strokesByLine = mapOf(0 to listOf(stroke(2950f, 300f))) + val result = DiagramTextFilter.diagramBandLines( + strokesByLine, emptyList(), bbox, yTol, rightBandLimit = Float.MAX_VALUE) + assertTrue("Explicit MAX_VALUE limit: far-right stroke is still detected", 0 in result) + } + + // ── Default right-band limit (Bug #6 root cause) ────────────────────────── + // + // The previous fix used rightBandLimit = canvasWidth − 2·gutterWidth ≈ 2942, + // meaning any stroke between bRight (300) and 2942 was classified as a band note. + // That is 2600 px of canvas — nearly the entire page. Text written on the right + // side of the canvas after drawing a small left-side diagram was silently suppressed. + // + // Fix: make rightBandLimit default to bRight + yTolerance × 4 (a narrow column + // proportional to the diagram's already-known line spacing). Text further away + // is ordinary writing, not a diagram side note. + // + // With bbox x=[100..300] and yTol=50: default rightBandLimit = 300 + 50×4 = 500. + + @Test fun textFarRightOfSmallDiagram_defaultLimit_notBandNote() { + // Text at x=2800 is 2500 px to the right of a small diagram. + // With the sensible default (rightBandLimit = bRight + 4·yTol = 500) it is NOT a band note. + // CURRENTLY FAILS: default is Float.MAX_VALUE → 2800 IS classified as a band note. + val strokesByLine = mapOf(0 to listOf(stroke(2800f, 300f))) + val result = DiagramTextFilter.diagramBandLines(strokesByLine, emptyList(), bbox, yTol) + assertFalse("Text 2500 px from diagram right edge should not be a band note", 0 in result) + } + + @Test fun textJustBeyondDefaultColumnWidth_notBandNote() { + // x = bRight(300) + 4·yTol(50) + 1 = 501 → just outside the default column → not a band note + val strokesByLine = mapOf(0 to listOf(stroke(501f, 300f))) + val result = DiagramTextFilter.diagramBandLines(strokesByLine, emptyList(), bbox, yTol) + assertFalse("Stroke just outside default column should not be a band note", 0 in result) + } + + @Test fun textWithinDefaultColumnWidth_isBandNote() { + // x = 499 → inside default column (300 < 499 < 500) → IS a band note + val strokesByLine = mapOf(0 to listOf(stroke(499f, 300f))) + val result = DiagramTextFilter.diagramBandLines(strokesByLine, emptyList(), bbox, yTol) + assertTrue("Stroke inside default column (x=499 < limit=500) should be a band note", 0 in result) + } + + // ── Left-margin exclusion (Bug #7) ──────────────────────────────────────── + // + // The left-side band check was `cx < bLeft` — unlimited on the left. If the + // diagram sits in the right column, the entire normal writing area (which lies + // to the LEFT of the diagram) falls inside this half-plane. Every text line + // written beside the diagram in the standard left-column is wrongly suppressed. + // + // The user observes 4-5 "dead" lines: those are the writing lines that overlap + // the diagram's Y-extent while the strokes are in the normal left column. + // + // Fix: add a symmetric leftBandLimit = bLeft − yTolerance × 4. Only strokes + // within a narrow column immediately to the LEFT of the diagram are captured + // as left-side band notes; normal writing further left is ordinary text. + // + // With bbox x=[100..300] and yTol=50: + // leftBandLimit = 100 − 50×4 = −100 + // rightBandLimit = 300 + 50×4 = 500 + + @Test fun textFarLeftOfDiagram_defaultLimit_notBandNote() { + // Stroke at x=-200 is far to the left of the diagram (bLeft=100). + // Default leftBandLimit = 100 − 4×50 = −100. cx=−200 < −100 → NOT a band note. + // FAILS currently: no leftBandLimit → any cx < bLeft qualifies. + val strokesByLine = mapOf(0 to listOf(stroke(-200f, 300f))) + val result = DiagramTextFilter.diagramBandLines(strokesByLine, emptyList(), bbox, yTol) + assertFalse("Text far to the left of the diagram should not be a band note", 0 in result) + } + + @Test fun textJustInsideLeftBandLimit_isBandNote() { + // x = bLeft(100) − 4×yTol(50) + 1 = −99 → just inside the left column → IS a band note + val strokesByLine = mapOf(0 to listOf(stroke(-99f, 300f))) + val result = DiagramTextFilter.diagramBandLines(strokesByLine, emptyList(), bbox, yTol) + assertTrue("Stroke inside left column (x=−99 > limit=−100) should be a band note", 0 in result) + } + + @Test fun textJustOutsideLeftBandLimit_notBandNote() { + // x = bLeft(100) − 4×yTol(50) − 1 = −101 → just beyond the left column → NOT a band note + val strokesByLine = mapOf(0 to listOf(stroke(-101f, 300f))) + val result = DiagramTextFilter.diagramBandLines(strokesByLine, emptyList(), bbox, yTol) + assertFalse("Stroke just outside left column (x=−101 < limit=−100) should not be a band note", 0 in result) + } + + @Test fun explicitUnlimitedLeftBand_farLeftDetected() { + // Passing leftBandLimit = -MAX_VALUE restores the old unlimited-left behaviour. + val strokesByLine = mapOf(0 to listOf(stroke(-200f, 300f))) + val result = DiagramTextFilter.diagramBandLines( + strokesByLine, emptyList(), bbox, yTol, leftBandLimit = -Float.MAX_VALUE) + assertTrue("Explicit MIN_VALUE left limit: far-left stroke is still detected", 0 in result) + } +} diff --git a/app/src/test/java/com/writer/view/DiagramEraseTest.kt b/app/src/test/java/com/writer/view/DiagramEraseTest.kt new file mode 100644 index 0000000..bc318a1 --- /dev/null +++ b/app/src/test/java/com/writer/view/DiagramEraseTest.kt @@ -0,0 +1,311 @@ +package com.writer.view + +import com.writer.model.InkStroke +import com.writer.model.StrokePoint +import com.writer.model.StrokeType +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Tests for scratch-out overlap detection in diagram areas. + * + * WritingCoordinator.onScratchOut finds overlapping strokes by checking if + * any stored stroke point falls inside the scratch-out bounding box. + * For geometric arrows (2 endpoints only), segment intersection is also checked. + */ +class DiagramEraseTest { + + /** + * Replicates the overlap check from WritingCoordinator.onScratchOut. + * Checks point containment for all strokes. Segment intersection is only + * applied to arrow/line strokes (sparse points) — applying it to shape + * outlines would cause nearby shapes to be erased when targeting an arrow. + */ + private fun findOverlappingStrokes( + strokes: List, + left: Float, top: Float, right: Float, bottom: Float + ): List = strokes.filter { stroke -> + stroke.points.any { pt -> pt.x in left..right && pt.y in top..bottom } + || stroke.strokeType.isArrowOrLine + && ScratchOutDetection.strokeIntersectsRect(stroke.points, left, top, right, bottom) + } + + @Test fun scratchOut_overArrowMidpoint_findsGeometricArrow() { + val arrow = InkStroke( + strokeId = "arrow1", + points = listOf( + StrokePoint(100f, 300f, 0.5f, 0L), + StrokePoint(500f, 300f, 0.5f, 0L) + ), + isGeometric = true, + strokeType = StrokeType.ARROW_HEAD + ) + + val overlapping = findOverlappingStrokes( + listOf(arrow), + left = 250f, top = 280f, right = 350f, bottom = 320f + ) + + assertTrue( + "Scratch-out over arrow midpoint should find the arrow stroke", + overlapping.any { it.strokeId == "arrow1" } + ) + } + + @Test fun scratchOut_overArrowMidpoint_findsDiagonalArrow() { + val arrow = InkStroke( + strokeId = "arrow2", + points = listOf( + StrokePoint(100f, 100f, 0.5f, 0L), + StrokePoint(500f, 500f, 0.5f, 0L) + ), + isGeometric = true, + strokeType = StrokeType.ARROW_HEAD + ) + + val overlapping = findOverlappingStrokes( + listOf(arrow), + left = 270f, top = 270f, right = 330f, bottom = 330f + ) + + assertTrue( + "Scratch-out over diagonal arrow midpoint should find the arrow", + overlapping.any { it.strokeId == "arrow2" } + ) + } + + @Test fun scratchOut_overLineMidpoint_findsGeometricLine() { + val line = InkStroke( + strokeId = "line1", + points = listOf( + StrokePoint(100f, 300f, 0.5f, 0L), + StrokePoint(500f, 300f, 0.5f, 0L) + ), + isGeometric = true, + strokeType = StrokeType.LINE + ) + + val overlapping = findOverlappingStrokes( + listOf(line), + left = 250f, top = 280f, right = 350f, bottom = 320f + ) + + assertTrue( + "Scratch-out over line midpoint should find the line stroke", + overlapping.any { it.strokeId == "line1" } + ) + } + + @Test fun scratchOut_overSnappedArrowMidpoint_findsArrow() { + val arrow = InkStroke( + strokeId = "snappedArrow", + points = listOf( + StrokePoint(300f, 200f, 0.5f, 0L), + StrokePoint(600f, 200f, 0.5f, 0L) + ), + isGeometric = true, + strokeType = StrokeType.ARROW_HEAD + ) + + val overlapping = findOverlappingStrokes( + listOf(arrow), + left = 420f, top = 190f, right = 480f, bottom = 210f + ) + + assertTrue( + "Scratch-out over magnetically-snapped arrow midpoint should find it", + overlapping.any { it.strokeId == "snappedArrow" } + ) + } + + @Test fun scratchOut_overFreehandArrowMidpoint_findsArrow() { + val arrow = InkStroke( + strokeId = "freehandArrow", + points = listOf( + StrokePoint(100f, 300f, 0.5f, 0L), + StrokePoint(200f, 300f, 0.5f, 0L), + StrokePoint(300f, 300f, 0.5f, 0L), + StrokePoint(400f, 300f, 0.5f, 0L), + StrokePoint(500f, 300f, 0.5f, 0L) + ), + isGeometric = false, + strokeType = StrokeType.ARROW_HEAD + ) + + val overlapping = findOverlappingStrokes( + listOf(arrow), + left = 250f, top = 280f, right = 350f, bottom = 320f + ) + + assertTrue( + "Scratch-out between stored points of an arrow should find it via segment intersection", + overlapping.any { it.strokeId == "freehandArrow" } + ) + } + + @Test fun scratchOut_overArrowNearNode_doesNotCatchNode() { + val nodeStroke = InkStroke( + strokeId = "node1", + points = listOf( + StrokePoint(100f, 100f, 0.5f, 0L), + StrokePoint(300f, 100f, 0.5f, 0L), + StrokePoint(300f, 300f, 0.5f, 0L), + StrokePoint(100f, 300f, 0.5f, 0L), + StrokePoint(100f, 100f, 0.5f, 0L) + ), + isGeometric = true, + strokeType = StrokeType.RECTANGLE + ) + val arrowStroke = InkStroke( + strokeId = "arrow1", + points = listOf( + StrokePoint(300f, 200f, 0.5f, 0L), + StrokePoint(500f, 200f, 0.5f, 0L) + ), + isGeometric = true, + strokeType = StrokeType.ARROW_HEAD + ) + + val overlapping = findOverlappingStrokes( + listOf(nodeStroke, arrowStroke), + left = 290f, top = 190f, right = 360f, bottom = 210f + ) + + assertTrue( + "Arrow should be found (its segment crosses the scratch region)", + overlapping.any { it.strokeId == "arrow1" } + ) + assertFalse( + "Node should NOT be found (scratch targets the arrow, not the shape)", + overlapping.any { it.strokeId == "node1" } + ) + } + + @Test fun scratchOut_missesArrowCompletely_doesNotFind() { + val arrow = InkStroke( + strokeId = "arrow3", + points = listOf( + StrokePoint(100f, 300f, 0.5f, 0L), + StrokePoint(500f, 300f, 0.5f, 0L) + ), + isGeometric = true, + strokeType = StrokeType.ARROW_HEAD + ) + + val overlapping = findOverlappingStrokes( + listOf(arrow), + left = 100f, top = 500f, right = 500f, bottom = 520f + ) + + assertFalse( + "Scratch-out far from arrow should not find it", + overlapping.any { it.strokeId == "arrow3" } + ) + } + + @Test fun scratchOutGesture_overRectangleStroke_detectsAndFindsOverlap() { + val rectangle = InkStroke( + strokeId = "rect1", + points = listOf( + StrokePoint(100f, 100f, 0.5f, 0L), + StrokePoint(300f, 100f, 0.5f, 0L), + StrokePoint(300f, 250f, 0.5f, 0L), + StrokePoint(100f, 250f, 0.5f, 0L), + StrokePoint(100f, 100f, 0.5f, 0L) + ), + isGeometric = true, + strokeType = StrokeType.RECTANGLE + ) + + val scratchXs = floatArrayOf(90f, 310f, 90f, 310f, 90f) + val scratchYRange = 25f + val lineSpacing = 118f + + assertTrue("Zigzag should be detected as scratch-out", + ScratchOutDetection.detect(scratchXs, scratchYRange, lineSpacing)) + + val left = 90f; val top = 90f; val right = 310f; val bottom = 115f + val overlapping = findOverlappingStrokes(listOf(rectangle), left, top, right, bottom) + assertTrue("Rectangle should be found under scratch-out", + overlapping.any { it.strokeId == "rect1" }) + } + + // ── Scratch-out in non-diagram (text) areas ───────────────────────────── + + @Test fun scratchOut_overFreehandTextStrokes_detectsAndFindsOverlap() { + // Freehand handwriting strokes in a text area (not a diagram). + // Scratch-out should erase these just like diagram strokes. + val stroke1 = InkStroke( + strokeId = "text1", + points = listOf( + StrokePoint(100f, 200f, 0.5f, 0L), + StrokePoint(120f, 210f, 0.5f, 10L), + StrokePoint(140f, 195f, 0.5f, 20L), + StrokePoint(160f, 205f, 0.5f, 30L), + StrokePoint(180f, 200f, 0.5f, 40L) + ), + isGeometric = false, + strokeType = StrokeType.FREEHAND + ) + val stroke2 = InkStroke( + strokeId = "text2", + points = listOf( + StrokePoint(200f, 200f, 0.5f, 0L), + StrokePoint(220f, 215f, 0.5f, 10L), + StrokePoint(240f, 190f, 0.5f, 20L), + StrokePoint(260f, 200f, 0.5f, 30L) + ), + isGeometric = false, + strokeType = StrokeType.FREEHAND + ) + + // Scratch-out zigzag over both strokes + val scratchXs = floatArrayOf(80f, 280f, 80f, 280f, 80f) + val scratchYRange = 20f + val lineSpacing = 118f + + assertTrue("Zigzag over text strokes should be detected as scratch-out", + ScratchOutDetection.detect(scratchXs, scratchYRange, lineSpacing)) + + val left = 80f; val top = 185f; val right = 280f; val bottom = 220f + val overlapping = findOverlappingStrokes( + listOf(stroke1, stroke2), left, top, right, bottom + ) + assertTrue("First text stroke should be found", + overlapping.any { it.strokeId == "text1" }) + assertTrue("Second text stroke should be found", + overlapping.any { it.strokeId == "text2" }) + } + + @Test fun scratchOut_overTextStroke_doesNotAffectDistantStrokes() { + // Scratch-out over one text stroke should not catch strokes on other lines. + val targetStroke = InkStroke( + strokeId = "target", + points = listOf( + StrokePoint(100f, 200f, 0.5f, 0L), + StrokePoint(200f, 205f, 0.5f, 10L) + ), + isGeometric = false, + strokeType = StrokeType.FREEHAND + ) + val distantStroke = InkStroke( + strokeId = "distant", + points = listOf( + StrokePoint(100f, 500f, 0.5f, 0L), + StrokePoint(200f, 505f, 0.5f, 10L) + ), + isGeometric = false, + strokeType = StrokeType.FREEHAND + ) + + val left = 80f; val top = 190f; val right = 220f; val bottom = 215f + val overlapping = findOverlappingStrokes( + listOf(targetStroke, distantStroke), left, top, right, bottom + ) + assertTrue("Target stroke should be found", + overlapping.any { it.strokeId == "target" }) + assertFalse("Distant stroke should NOT be found", + overlapping.any { it.strokeId == "distant" }) + } +} diff --git a/app/src/test/java/com/writer/view/DiagramInsertionLogicTest.kt b/app/src/test/java/com/writer/view/DiagramInsertionLogicTest.kt new file mode 100644 index 0000000..24e14ff --- /dev/null +++ b/app/src/test/java/com/writer/view/DiagramInsertionLogicTest.kt @@ -0,0 +1,63 @@ +package com.writer.view + +import org.junit.Assert.assertEquals +import org.junit.Test + +class DiagramInsertionLogicTest { + + @Test + fun diagramAboveAllText_insertsFirst() { + val paragraphs = listOf(listOf(2, 3), listOf(4, 5)) + val result = DiagramInsertionLogic.computeInsertionParagraph(paragraphs, 0) + assertEquals(0, result) + } + + @Test + fun diagramBelowAllText_insertsLast() { + val paragraphs = listOf(listOf(0, 1), listOf(2, 3)) + val result = DiagramInsertionLogic.computeInsertionParagraph(paragraphs, 10) + assertEquals(Int.MAX_VALUE, result) + } + + @Test + fun diagramBetweenParagraphs_insertsAtCorrectIndex() { + val paragraphs = listOf(listOf(0, 1), listOf(4, 5), listOf(8, 9)) + val result = DiagramInsertionLogic.computeInsertionParagraph(paragraphs, 3) + assertEquals(1, result) + } + + @Test + fun diagramAtSameLineAsParagraph_insertsBefore() { + val paragraphs = listOf(listOf(0), listOf(3), listOf(6)) + val result = DiagramInsertionLogic.computeInsertionParagraph(paragraphs, 3) + assertEquals(1, result) + } + + @Test + fun noParagraphs_diagramPresent_returnsMaxValue() { + val paragraphs = emptyList>() + val result = DiagramInsertionLogic.computeInsertionParagraph(paragraphs, 5) + assertEquals(Int.MAX_VALUE, result) + } + + @Test + fun noDiagram_returnsMaxValue() { + val paragraphs = listOf(listOf(0, 1), listOf(2, 3)) + val result = DiagramInsertionLogic.computeInsertionParagraph(paragraphs, Int.MAX_VALUE) + assertEquals(Int.MAX_VALUE, result) + } + + @Test + fun singleParagraph_diagramAbove_returnsZero() { + val paragraphs = listOf(listOf(5, 6)) + val result = DiagramInsertionLogic.computeInsertionParagraph(paragraphs, 2) + assertEquals(0, result) + } + + @Test + fun singleParagraph_diagramBelow_returnsMaxValue() { + val paragraphs = listOf(listOf(0, 1)) + val result = DiagramInsertionLogic.computeInsertionParagraph(paragraphs, 5) + assertEquals(Int.MAX_VALUE, result) + } +} diff --git a/app/src/test/java/com/writer/view/DiagramTextFilterTest.kt b/app/src/test/java/com/writer/view/DiagramTextFilterTest.kt new file mode 100644 index 0000000..566cee9 --- /dev/null +++ b/app/src/test/java/com/writer/view/DiagramTextFilterTest.kt @@ -0,0 +1,117 @@ +package com.writer.view + +import com.writer.model.InkStroke +import com.writer.model.StrokePoint +import com.writer.model.StrokeType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Tests for [DiagramTextFilter]: strokes inside diagram shapes must not + * appear in text paragraphs (they are shape labels, already shown in the diagram). + */ +class DiagramTextFilterTest { + + // Helper: a stroke whose single point sits at (cx, cy) + private fun stroke(cx: Float, cy: Float, type: StrokeType = StrokeType.FREEHAND): InkStroke { + val pt = StrokePoint(cx, cy, 1f, 0L) + return InkStroke(points = listOf(pt), strokeType = type) + } + + // Node bounds as [left, top, right, bottom] + private fun bounds(l: Float, t: Float, r: Float, b: Float) = floatArrayOf(l, t, r, b) + + // ── single line fully inside one node ────────────────────────────────────── + + @Test fun lineFullyInsideNode_isExcluded() { + val strokesByLine = mapOf( + 0 to listOf(stroke(50f, 50f)) + ) + val nodes = listOf(bounds(0f, 0f, 100f, 100f)) + val result = DiagramTextFilter.diagramOnlyLines(strokesByLine, nodes) + assertTrue("Line 0 should be excluded (inside node)", 0 in result) + } + + // ── single line fully outside all nodes ─────────────────────────────────── + + @Test fun lineOutsideAllNodes_isNotExcluded() { + val strokesByLine = mapOf( + 0 to listOf(stroke(200f, 200f)) + ) + val nodes = listOf(bounds(0f, 0f, 100f, 100f)) + val result = DiagramTextFilter.diagramOnlyLines(strokesByLine, nodes) + assertFalse("Line 0 should NOT be excluded (outside node)", 0 in result) + } + + // ── line has strokes both inside and outside → not excluded ─────────────── + + @Test fun lineMixed_isNotExcluded() { + val strokesByLine = mapOf( + 0 to listOf(stroke(50f, 50f), stroke(200f, 50f)) + ) + val nodes = listOf(bounds(0f, 0f, 100f, 100f)) + val result = DiagramTextFilter.diagramOnlyLines(strokesByLine, nodes) + assertFalse("Line with mixed strokes should NOT be excluded", 0 in result) + } + + // ── two separate lines: one inside, one outside ──────────────────────────── + + @Test fun twoLines_onlyInsideLineExcluded() { + val strokesByLine = mapOf( + 0 to listOf(stroke(50f, 50f)), // inside node + 1 to listOf(stroke(200f, 200f)) // outside + ) + val nodes = listOf(bounds(0f, 0f, 100f, 100f)) + val result = DiagramTextFilter.diagramOnlyLines(strokesByLine, nodes) + assertTrue("Line 0 inside node should be excluded", 0 in result) + assertFalse("Line 1 outside should NOT be excluded", 1 in result) + } + + // ── stroke on node boundary is treated as inside ────────────────────────── + + @Test fun strokeOnBoundary_isTreatedAsInside() { + val strokesByLine = mapOf( + 0 to listOf(stroke(0f, 0f)) // exactly on corner + ) + val nodes = listOf(bounds(0f, 0f, 100f, 100f)) + val result = DiagramTextFilter.diagramOnlyLines(strokesByLine, nodes) + assertTrue("Stroke on boundary should be treated as inside", 0 in result) + } + + // ── no nodes → nothing excluded ─────────────────────────────────────────── + + @Test fun noNodes_nothingExcluded() { + val strokesByLine = mapOf( + 0 to listOf(stroke(50f, 50f)) + ) + val result = DiagramTextFilter.diagramOnlyLines(strokesByLine, emptyList()) + assertTrue("With no nodes, result should be empty", result.isEmpty()) + } + + // ── non-freehand strokes on a line don't block exclusion ────────────────── + + @Test fun nonFreehandStrokesIgnored_lineStillExcluded() { + val strokesByLine = mapOf( + 0 to listOf( + stroke(50f, 50f, StrokeType.FREEHAND), // inside, freehand + stroke(50f, 50f, StrokeType.RECTANGLE) // inside, but not freehand → ignored + ) + ) + val nodes = listOf(bounds(0f, 0f, 100f, 100f)) + val result = DiagramTextFilter.diagramOnlyLines(strokesByLine, nodes) + assertTrue("Non-freehand strokes should not block exclusion", 0 in result) + } + + // ── line with only non-freehand strokes → excluded (no freehand = no text) ─ + + @Test fun lineWithNoFreehandStrokes_isExcluded() { + val strokesByLine = mapOf( + 0 to listOf(stroke(50f, 50f, StrokeType.RECTANGLE)) + ) + val nodes = listOf(bounds(0f, 0f, 100f, 100f)) + val result = DiagramTextFilter.diagramOnlyLines(strokesByLine, nodes) + assertTrue("Line with no freehand strokes has no text to show", 0 in result) + } +} diff --git a/app/src/test/java/com/writer/view/LineDragDetectionTest.kt b/app/src/test/java/com/writer/view/LineDragDetectionTest.kt new file mode 100644 index 0000000..7898751 --- /dev/null +++ b/app/src/test/java/com/writer/view/LineDragDetectionTest.kt @@ -0,0 +1,219 @@ +package com.writer.view + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Unit tests for [LineDragDetection.detect]. + * + * Uses a fixed line spacing of 118 px (63 dp × 1.875 density — standard devices). + * All strokes are described as (firstY, lastY, xRange); the bounding box xRange + * represents the maximum horizontal spread across all points. + * + * Geometry recap: + * - Detected when: |yDelta| > MIN_SPANS (1.0) × lineSpacing + * AND xRange < |yDelta| × MAX_DRIFT (0.3) + * - Shift = round(yDelta / lineSpacing) — positive down, negative up + */ +class LineDragDetectionTest { + + companion object { + private const val LS = 118f // standard line spacing in px (63 dp × 1.875) + } + + // ── Detection: strokes that SHOULD fire ─────────────────────────────────── + + @Test fun downwardStroke_twoAndHalfLines_detectsShiftThree() { + // 2.5 line spacings down — well above the MIN_SPANS = 2.0 threshold, narrow xRange + val result = LineDragDetection.detect( + firstY = 0f, lastY = LS * 2.5f, xRange = 5f, lineSpacing = LS + ) + assertEquals("2.5 lines down → shift +3 (round-half-up)", 3, result) + } + + @Test fun downwardStroke_twoPointOneLine_detectsShiftTwo() { + // 2.1× LS is just over the MIN_SPANS = 2.0 threshold → detected, shift = 2 + val result = LineDragDetection.detect( + firstY = 0f, lastY = LS * 2.1f, xRange = 10f, lineSpacing = LS + ) + assertEquals("2.1 lines down → shift +2", 2, result) + } + + @Test fun downwardStroke_threeLines_detectsShiftThree() { + val result = LineDragDetection.detect( + firstY = 0f, lastY = LS * 3f, xRange = 15f, lineSpacing = LS + ) + assertEquals("3 lines down → shift +3", 3, result) + } + + @Test fun upwardStroke_twoPointOneLine_detectsNegativeShift() { + // 2.1× LS upward — just over MIN_SPANS = 2.0 → detected, shift = -2 + val result = LineDragDetection.detect( + firstY = LS * 5f, lastY = LS * 5f - LS * 2.1f, xRange = 8f, lineSpacing = LS + ) + assertEquals("2.1 lines up → shift -2", -2, result) + } + + @Test fun upwardStroke_twoAndHalfLines_detectsNegativeShift() { + // 2.5 line spacings up — above MIN_SPANS = 2.0; roundToInt(-2.5) → -2 (round-half-up) + val result = LineDragDetection.detect( + firstY = LS * 5f, lastY = LS * 2.5f, xRange = 10f, lineSpacing = LS + ) + assertEquals("2.5 lines up → shift -2 (round-half-up)", -2, result) + } + + @Test fun justOverNewThreshold_detectsShiftTwo() { + // Stroke just barely over the MIN_SPANS = 2.0 threshold + val result = LineDragDetection.detect( + firstY = 0f, lastY = LS * 2.05f, xRange = 5f, lineSpacing = LS + ) + assertEquals("Stroke just over 2× LS → detected, shift = 2", 2, result) + } + + // ── Rejection: strokes that should NOT fire ─────────────────────────────── + + @Test fun tooShort_exactlyTwoLineSpacings_notDetected() { + // absYDelta == 2 × lineSpacing — must be STRICTLY greater than MIN_SPANS * lineSpacing + val result = LineDragDetection.detect( + firstY = 0f, lastY = LS * 2f, xRange = 5f, lineSpacing = LS + ) + assertNull("Stroke == 2× LS is exactly at threshold, not over it", result) + } + + @Test fun tooShort_oneAndHalfLines_notDetected() { + // 1.5× LS is under the new MIN_SPANS = 2.0 threshold + val result = LineDragDetection.detect( + firstY = 0f, lastY = LS * 1.5f, xRange = 5f, lineSpacing = LS + ) + assertNull("Stroke of 1.5× LS is below MIN_SPANS = 2.0, not a line-drag", result) + } + + @Test fun tooShort_halfLine_notDetected() { + val result = LineDragDetection.detect( + firstY = 0f, lastY = LS * 0.5f, xRange = 5f, lineSpacing = LS + ) + assertNull("Short stroke (0.5× LS) not a line-drag", result) + } + + @Test fun tooWobbly_highXRange_notDetected() { + // absYDelta = 2× LS, but xRange ≥ absYDelta × 0.3 → too wobbly + val yDelta = LS * 2f + val xRange = yDelta * LineDragDetection.MAX_DRIFT // exactly at limit + val result = LineDragDetection.detect( + firstY = 0f, lastY = yDelta, xRange = xRange, lineSpacing = LS + ) + assertNull("xRange at the drift limit is not a line-drag", result) + } + + @Test fun diagonal_notDetected() { + // 45-degree diagonal: xRange ≈ yDelta → way too much drift + val result = LineDragDetection.detect( + firstY = 0f, lastY = LS * 2f, xRange = LS * 2f, lineSpacing = LS + ) + assertNull("Diagonal stroke not a line-drag", result) + } + + @Test fun horizontalStroke_notDetected() { + // Wide horizontal stroke — yDelta is tiny + val result = LineDragDetection.detect( + firstY = 0f, lastY = LS * 0.1f, xRange = LS * 3f, lineSpacing = LS + ) + assertNull("Horizontal stroke not a line-drag", result) + } + + @Test fun zeroLength_notDetected() { + val result = LineDragDetection.detect( + firstY = 100f, lastY = 100f, xRange = 0f, lineSpacing = LS + ) + assertNull("Zero-length stroke not a line-drag", result) + } + + // ── Rounding behaviour ──────────────────────────────────────────────────── + + @Test fun shiftRoundsToNearestLine_roundUp() { + // 2.7 line spacings → rounds to 3 + val result = LineDragDetection.detect( + firstY = 0f, lastY = LS * 2.7f, xRange = 5f, lineSpacing = LS + ) + assertEquals("2.7× LS rounds to shift 3", 3, result) + } + + @Test fun shiftRoundsToNearestLine_roundDown() { + // 2.3 line spacings → rounds to 2 + val result = LineDragDetection.detect( + firstY = 0f, lastY = LS * 2.3f, xRange = 5f, lineSpacing = LS + ) + assertEquals("2.3× LS rounds to shift 2", 2, result) + } + + // ── False positive: letter-height stroke is mistaken for a line-drag (Bug #6) ── + // + // MIN_SPANS = 1.0 means any stroke taller than one line spacing qualifies. + // A tall handwritten letter (capital, ascender, digit '1') can easily span + // 1.1 × LINE_SPACING. Combined with the narrow xRange of a single letter + // the stroke passes both checks and is silently consumed as a line-drag gesture. + // The user sees ink appear then vanish — nothing is added to the document. + // + // Fix: raise MIN_SPANS to 2.0 so a gesture must span two full line spacings + // (≈ 15 mm on a 300-PPI device) — a deliberate gesture, never an accidental letter. + + @Test fun letterHeightStroke_notLineDrag() { + // A stroke 1.1 × LINE_SPACING tall, narrow like a capital letter. + // Currently FAILS: detect() returns 1 (false positive). + // After fix (MIN_SPANS = 2.0): 1.1 × LS = 130 < 2.0 × LS = 236 → returns null. + assertNull( + "A letter-height stroke (1.1× LS) must not trigger line-drag", + LineDragDetection.detect(firstY = 0f, lastY = LS * 1.1f, xRange = LS * 0.2f, lineSpacing = LS) + ) + } + + // ── Gutter-zone guard (Bug #6) ──────────────────────────────────────────── + // + // Line-drag is only valid when the stroke is written near the right margin + // (within one gutter-width of the gutter boundary). Strokes in the main + // writing area must not be consumed, even if they happen to be tall and narrow + // (e.g. ascender letters, capital 'I', digit '1'). + // + // Canvas geometry (Tab X C example): + // canvasWidth = 3200 px, gutterWidth = 129 px + // drag zone = [canvasWidth − 2·gutterWidth, canvasWidth − gutterWidth] + // = [2942, 3071] + + private val CW = 3200f // typical canvas width (px) + private val GW = 129f // typical gutter width (px) + + @Test fun strokeInDragZone_isAllowed() { + // strokeMinX = 2950 is inside [2942, 3071] → zone check passes + assertTrue( + "Stroke min-X inside drag zone should be allowed", + LineDragDetection.isInDragZone(strokeMinX = 2950f, canvasWidth = CW, gutterWidth = GW) + ) + } + + @Test fun strokeAtZoneLeftEdge_isAllowed() { + // strokeMinX = canvasWidth − 2·gutterWidth = 2942 → exactly at the left boundary → allowed + assertTrue( + "Stroke at left edge of drag zone should be allowed", + LineDragDetection.isInDragZone(strokeMinX = CW - GW * 2, canvasWidth = CW, gutterWidth = GW) + ) + } + + @Test fun strokeJustOutsideDragZone_isRejected() { + // strokeMinX = 2941 is 1 px left of the drag zone → NOT allowed + assertFalse( + "Stroke just left of drag zone should be rejected", + LineDragDetection.isInDragZone(strokeMinX = CW - GW * 2 - 1f, canvasWidth = CW, gutterWidth = GW) + ) + } + + @Test fun strokeFarFromGutter_isRejected() { + // strokeMinX = 500 is deep in the writing area → NOT a line-drag zone + assertFalse( + "Stroke in the middle of the writing area should not be line-drag zone", + LineDragDetection.isInDragZone(strokeMinX = 500f, canvasWidth = CW, gutterWidth = GW) + ) + } +} diff --git a/app/src/test/java/com/writer/view/LineDragSnapGuardTest.kt b/app/src/test/java/com/writer/view/LineDragSnapGuardTest.kt new file mode 100644 index 0000000..cfba53b --- /dev/null +++ b/app/src/test/java/com/writer/view/LineDragSnapGuardTest.kt @@ -0,0 +1,42 @@ +package com.writer.view + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Unit tests for [LineDragDetection.isSnappableLine]. + * + * Verifies that straight vertical strokes that would snap to a Line shape + * are not consumed as line-drag gestures. + */ +class LineDragSnapGuardTest { + + companion object { + private const val LS = 118f // standard line spacing (63 dp × 1.875 density) + } + + @Test fun straightVerticalLine_isSnappable_returnsTrue() { + // 5-point perfectly vertical stroke spanning 200 px (> 1 × LS = 118 px) + val xs = floatArrayOf(50f, 50f, 50f, 50f, 50f) + val ys = floatArrayOf(0f, 50f, 100f, 150f, 200f) + val result = LineDragDetection.isSnappableLine(xs, ys, LS) + assertTrue("Straight vertical line should be snappable (returns true)", result) + } + + @Test fun wobblyVerticalStroke_isNotSnappable_returnsFalse() { + // Stroke with large x variation — won't pass line deviation check + val xs = floatArrayOf(0f, 50f, 100f, 50f, 0f) + val ys = floatArrayOf(0f, 50f, 100f, 150f, 200f) + val result = LineDragDetection.isSnappableLine(xs, ys, LS) + assertFalse("Wobbly vertical stroke is not snappable (too much x deviation)", result) + } + + @Test fun shortVerticalStroke_belowMinSpans_isNotSnappable_returnsFalse() { + // Short stroke below LINE_MIN_SPANS (only 50 px, LS = 118 px) + val xs = floatArrayOf(50f, 50f, 50f) + val ys = floatArrayOf(0f, 25f, 50f) + val result = LineDragDetection.isSnappableLine(xs, ys, LS) + assertFalse("Short stroke below min spans should not be snappable", result) + } +} diff --git a/app/src/test/java/com/writer/view/ScratchOutDetectionTest.kt b/app/src/test/java/com/writer/view/ScratchOutDetectionTest.kt new file mode 100644 index 0000000..a8dcc7c --- /dev/null +++ b/app/src/test/java/com/writer/view/ScratchOutDetectionTest.kt @@ -0,0 +1,424 @@ +package com.writer.view + +import com.writer.model.InkStroke +import com.writer.model.StrokePoint +import com.writer.model.StrokeType +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Unit tests for [ScratchOutDetection]. + * + * Uses a fixed line spacing of 118 px (standard device: 63 dp × 1.875 density). + * + * Geometry recap: + * MIN_REVERSALS = 2 → stroke must change X direction at least twice + * MIN_X_TRAVEL_SPANS = 1.5 → total |dx| ≥ 177 px at 118 px LS + * MAX_Y_DRIFT = 0.4 → yRange < 40% of total x-travel + */ +class ScratchOutDetectionTest { + + companion object { + private const val LS = 118f + private val X_THRESH get() = ScratchOutDetection.MIN_X_TRAVEL_SPANS * LS // ≈ 177 px + } + + // ── Strokes that SHOULD qualify ────────────────────────────────────────── + + @Test fun wideZigzag_detects() { + // 4 segments: right → left → right → left — clearly a scratch-out + val xs = zigzag(startX = 0f, segmentWidth = 60f, segments = 4) + assertTrue(ScratchOutDetection.detect(xs, yRange = 5f, lineSpacing = LS)) + } + + @Test fun minimalZigzag_exactlyTwoReversals_detects() { + // 3 segments → 2 reversals; total travel just above threshold + val segW = X_THRESH / 2f + 2f + val xs = zigzag(startX = 0f, segmentWidth = segW, segments = 3) + assertTrue(ScratchOutDetection.detect(xs, yRange = 2f, lineSpacing = LS)) + } + + @Test fun manyReversals_detects() { + val xs = zigzag(startX = 0f, segmentWidth = 50f, segments = 6) + assertTrue(ScratchOutDetection.detect(xs, yRange = 3f, lineSpacing = LS)) + } + + // ── Strokes that should NOT qualify ────────────────────────────────────── + + @Test fun singleDirection_noReversal_notDetected() { + val xs = floatArrayOf(0f, 50f, 100f, 200f, 300f) + assertFalse(ScratchOutDetection.detect(xs, yRange = 5f, lineSpacing = LS)) + } + + @Test fun oneReversal_notDetected() { + // Right then left — only 1 reversal (U-shape), not a scratch-out + val xs = floatArrayOf(0f, 100f, 200f, 100f, 0f) + assertFalse(ScratchOutDetection.detect(xs, yRange = 5f, lineSpacing = LS)) + } + + @Test fun twoReversals_tooNarrow_notDetected() { + // Zigzag with 2 reversals but total travel below threshold + val xs = zigzag(startX = 0f, segmentWidth = 5f, segments = 3) + assertFalse(ScratchOutDetection.detect(xs, yRange = 1f, lineSpacing = LS)) + } + + @Test fun twoReversals_tooWobbly_notDetected() { + // Wide zigzag but excessive vertical displacement + val xs = zigzag(startX = 0f, segmentWidth = 100f, segments = 4) + val totalXTravel = 100f * 4 // each segment is 100 px + // yRange = 200% of total x-travel → way over MAX_Y_DRIFT (0.4) + assertFalse(ScratchOutDetection.detect(xs, yRange = totalXTravel * 2f, lineSpacing = LS)) + } + + @Test fun tooFewPoints_notDetected() { + // Less than 4 points — cannot reliably detect reversals + assertFalse(ScratchOutDetection.detect(floatArrayOf(0f, 50f, 100f), yRange = 5f, lineSpacing = LS)) + } + + @Test fun emptyArray_notDetected() { + assertFalse(ScratchOutDetection.detect(floatArrayOf(), yRange = 0f, lineSpacing = LS)) + } + + // ── Boundary ───────────────────────────────────────────────────────────── + + @Test fun exactlyAtYDriftLimit_notDetected() { + // yRange == totalXTravel * MAX_Y_DRIFT — must be strictly less than + val xs = zigzag(startX = 0f, segmentWidth = 60f, segments = 4) + // total travel = 4 × 60 = 240 px; drift limit = 240 × 0.4 = 96 px + assertFalse(ScratchOutDetection.detect(xs, yRange = 240f * ScratchOutDetection.MAX_Y_DRIFT, lineSpacing = LS)) + } + + @Test fun justBelowYDriftLimit_detects() { + val xs = zigzag(startX = 0f, segmentWidth = 60f, segments = 4) + val totalXTravel = 60f * 4 + assertFalse(ScratchOutDetection.detect(xs, yRange = totalXTravel * ScratchOutDetection.MAX_Y_DRIFT, lineSpacing = LS)) + // one less px → should detect + assertTrue(ScratchOutDetection.detect(xs, yRange = totalXTravel * ScratchOutDetection.MAX_Y_DRIFT - 1f, lineSpacing = LS)) + } + + // ── Bug 1: closed-loop strokes must not trigger scratch-out ────────────── + // + // When the user draws a shape (e.g. a rounded-rectangle outline) around existing + // handwritten letters, the shape snap may fail if the stroke is too wobbly. + // checkPostStrokeScratchOut() then runs. A closed loop with multiple x-reversals + // satisfies all three scratch-out criteria (reversals ≥ 2, travel ≥ 177 px, low + // y-drift) and currently ERASES the letters inside — a false positive. + // + // The fix: ScratchOutDetection.detect() must return false whenever the stroke is + // a closed loop (stroke start ≈ stroke end relative to its own diagonal). + + /** + * BUG 1 — CURRENTLY FAILS. + * + * A stroke whose x-coordinate series returns to the same value as it started + * (xs.first() == xs.last()) with multiple x-reversals is detected as scratch-out. + * This can happen when the user draws a bumpy oval around text: + * — shape snap fails (stroke too irregular for any shape detector) + * — scratch-out sees ≥ 2 reversals + ≥ 177 px travel + low y-drift → true + * — interior letters are erased + * + * Correct behaviour: a closed loop is NEVER a scratch-out. + */ + @Test fun closedLoop_multipleXReversals_notScratchOut() { + // xs traces a figure-8 / bumpy closed oval: 0 → 200 → 0 → 200 → 0 + // reversals = 3, total x-travel = 800 px, yRange = 30 px → passes all three checks. + // But the stroke IS a closed loop — must never be treated as scratch-out. + val xs = floatArrayOf(0f, 200f, 0f, 200f, 0f) + val yRange = 30f + assertFalse( + "Closed loop must NOT trigger scratch-out (Bug 1: erases letters drawn inside a shape)", + ScratchOutDetection.detect(xs, yRange, LS, isClosedLoop = true) + ) + } + + /** + * BUG 1 variant: a rectangular outline with corner overshoots. + * + * Real freehand rectangles commonly overshoot corners: after going right the pen + * briefly continues then reverses, adding extra x-reversals. + * 300×100 px rectangle, 2 corner overshoots → 2 reversals, total x-travel = 620 px, + * yRange = 100 px → 100 < 0.4×620 = 248 — passes all scratch-out checks. + * But the stroke is closed, so it must not trigger scratch-out. + */ + @Test fun closedRectangleWithCornerOvershoots_notScratchOut() { + val xs = floatArrayOf(0f, 310f, 300f, -10f, 0f, 0f) + val yRange = 100f + assertFalse( + "Rectangular closed stroke with overshoots must NOT trigger scratch-out (Bug 1)", + ScratchOutDetection.detect(xs, yRange, LS, isClosedLoop = true) + ) + } + + // ── Compact scratch-out (start ≈ end) ───────────────────────────────────── + // + // When scratching out a thin target (arrow line), the scribble naturally + // starts and ends at nearly the same position. The closed-loop guard in + // checkPostStrokeScratchOut classifies this as a "closed loop" and rejects + // the scratch-out — but a tight zigzag is clearly NOT a shape drawn around + // content. The caller should not classify high-reversal zigzags as closed + // loops. + + @Test fun compactScratchOut_startNearEnd_notClassifiedAsClosedLoop() { + // Tight scratch-out: zigzag right-left-right-left, ending near start. + // closeDist = 5, diagonal = 80, pathLength = 500 (with Y jitter) + // closeDist/diagonal = 0.0625 < CLOSE_FRACTION (0.20) → geometrically "closed" + // BUT pathLength/diagonal = 6.25 >> PATH_RATIO_THRESHOLD (4.5) → zigzag, not shape + // isClosedLoop should return false so scratch-out detection proceeds. + val closedLoop = ScratchOutDetection.isClosedLoop( + closeDist = 5f, diagonal = 80f, pathLength = 500f + ) + assertFalse( + "Compact scratch-out (high path ratio) should NOT be classified as closed loop", + closedLoop + ) + } + + @Test fun shapeOutline_startNearEnd_classifiedAsClosedLoop() { + // Real shape outline: closeDist = 5, diagonal = 200, pathLength = 600 + // closeDist/diagonal = 0.025 < CLOSE_FRACTION → geometrically closed + // pathLength/diagonal = 3.0 < PATH_RATIO_THRESHOLD → shape outline + val closedLoop = ScratchOutDetection.isClosedLoop( + closeDist = 5f, diagonal = 200f, pathLength = 600f + ) + assertTrue( + "Shape outline (low path ratio) should be classified as closed loop", + closedLoop + ) + } + + @Test fun compactScratchOut_fullDetection_shouldDetect() { + // End-to-end: a compact zigzag with start ≈ end should be detected + // when isClosedLoop is correctly computed as false. + val xs = floatArrayOf(0f, 80f, 0f, 80f, 5f) + // isClosedLoop: closeDist=5, diagonal≈80, pathLength≈315 (X-only) + // With Y jitter, real pathLength would be higher. Use X-only path as lower bound. + // pathLength/diagonal = 315/80 ≈ 3.9 — still below 4.5 in X-only case. + // In practice, a real scratch-out has significant Y component pushing ratio > 4.5. + // Test with isClosedLoop=false to verify detect() accepts the zigzag. + assertTrue( + "Compact scratch-out should be detected when not classified as closed loop", + ScratchOutDetection.detect(xs, yRange = 10f, lineSpacing = LS, isClosedLoop = false) + ) + } + + // ── Connected cursive false positives ───────────────────────────────────── + // + // A connected cursive word is a single stroke that advances left-to-right, + // with small X-direction reversals at letter transitions (e.g. at the join + // between 'e' and 'l' in "hello"). These reversals, combined with a mostly- + // horizontal profile, satisfy all three scratch-out checks: + // + // - Reversals ≥ 2 (letter transitions in a 4+ letter word) + // - Total X-travel ≥ 1.5× LS (a word is easily > 177 px wide) + // - Y-range < 0.4 × X-travel (writing stays within one line) + // + // The key difference: a scratch-out goes BACK AND FORTH over the same region + // (net X-advance ≈ 0), while cursive PROGRESSES forward (net advance ≈ word + // width). + // + // Fix: add a progressive-advance guard. If |lastX − firstX| ≥ totalXTravel × + // MAX_ADVANCE_RATIO (0.4), the stroke is advancing forward and is NOT a + // scratch-out. + + @Test fun cursiveWord_progressiveAdvance_notScratchOut() { + // Simulated cursive "hello": advances right with small leftward dips at + // letter joins. Total travel ≈ 300 px, net advance = 240 px, + // advance ratio = 240/300 = 0.80 — clearly progressive writing. + val xs = floatArrayOf(0f, 50f, 40f, 100f, 90f, 160f, 150f, 210f, 200f, 240f) + // 4 reversals, total travel = 50+10+60+10+70+10+60+10+40 = 320 + // net advance = 240, ratio = 0.75 + assertFalse( + "Cursive word with progressive advance must NOT be scratch-out", + ScratchOutDetection.detect(xs, yRange = 40f, lineSpacing = LS) + ) + } + + @Test fun cursiveWordShort_threeLetters_notScratchOut() { + // Simulated "the": 3 letters, 2 reversals at joins + // right 80, left 15, right 80, left 15, right 60 → advances to 190 + val xs = floatArrayOf(0f, 80f, 65f, 145f, 130f, 190f) + // total travel = 80+15+80+15+60 = 250, net = 190, ratio = 0.76 + assertFalse( + "Short cursive word must NOT be scratch-out", + ScratchOutDetection.detect(xs, yRange = 35f, lineSpacing = LS) + ) + } + + @Test fun cursiveLongWord_manyReversals_notScratchOut() { + // Simulated "minimum": many up-down strokes, 6+ reversals, but steady advance + val xs = floatArrayOf( + 0f, 40f, 30f, 70f, 60f, 100f, 90f, 130f, 120f, 160f, 150f, 200f, 190f, 240f + ) + // 6 reversals, progressive advance 0→240 + assertFalse( + "Long cursive word with many reversals must NOT be scratch-out", + ScratchOutDetection.detect(xs, yRange = 45f, lineSpacing = LS) + ) + } + + @Test fun scratchOutStaysInPlace_stillDetected() { + // True scratch-out: rapid back and forth over same region, ends near start + // right 100, left 100, right 100, left 100 → net advance = 0 + val xs = floatArrayOf(0f, 100f, 0f, 100f, 0f) + // total travel = 400, net = 0, ratio = 0.0 + assertTrue( + "Scratch-out that stays in place should still be detected", + ScratchOutDetection.detect(xs, yRange = 10f, lineSpacing = LS) + ) + } + + @Test fun scratchOutSmallDrift_stillDetected() { + // Scratch-out that drifts slightly rightward: net advance is small vs total travel + // right 100, left 80, right 100, left 80 → net = 40, total = 360 + // advance ratio = 40/360 = 0.11 — well below 0.3 → still scratch-out + val xs = floatArrayOf(0f, 100f, 20f, 120f, 40f) + assertTrue( + "Scratch-out with small rightward drift should still be detected", + ScratchOutDetection.detect(xs, yRange = 10f, lineSpacing = LS) + ) + } + + // ── Vertical scratch-out (Y-axis oscillation) ─────────────────────────── + + @Test fun verticalZigzag_detects() { + // Scribbling up-down over a horizontal arrow. + // X stays roughly constant, Y zigzags. + val xs = floatArrayOf(200f, 202f, 198f, 201f, 199f) // barely moves in X + val ys = zigzag(startX = 100f, segmentWidth = 60f, segments = 4) // 4 Y-segments + assertTrue( + "Vertical zigzag should be detected as scratch-out", + ScratchOutDetection.detect(xs, ys, lineSpacing = LS) + ) + } + + @Test fun verticalZigzag_tooFewReversals_notDetected() { + val xs = floatArrayOf(200f, 202f, 198f) + val ys = floatArrayOf(100f, 160f, 100f) // 1 reversal only + assertFalse( + "Vertical zigzag with only 1 reversal should not be detected", + ScratchOutDetection.detect(xs, ys, lineSpacing = LS) + ) + } + + @Test fun diagonalZigzag_detects() { + // Scribble at ~45 degrees: both X and Y oscillate, but one axis dominates + val xs = floatArrayOf(0f, 50f, 10f, 60f, 20f) // 3 X-reversals? No: 0→50→10→60→20, reversals=3 + val ys = floatArrayOf(0f, 50f, 10f, 60f, 20f) // same pattern in Y + // Both axes have 3 reversals. Either axis works. Total travel per axis ≈ 180px. + assertTrue( + "Diagonal zigzag should be detected as scratch-out", + ScratchOutDetection.detect(xs, ys, lineSpacing = LS) + ) + } + + // ── Device-captured false positives ───────────────────────────────────── + // + // Real strokes captured from the device that were incorrectly detected as + // scratch-outs. These are downsampled (every 20th point) but preserve the + // reversal structure and advance ratio of the originals. + + /** + * Device-captured: word "difficulty" written in cursive on Palma 2 Pro (ls=77). + * 23 reversals from tight letter forms (ffi, lt), advance_ratio=0.37. + * + * This stroke DOES pass the detection heuristic (advance_ratio < 0.4). + * The actual false-positive was fixed in [HandwritingCanvasView.checkPostStrokeScratchOut] + * by requiring existing strokes under the scratch-out region — a new word written + * onto blank canvas has nothing to erase, so the scratch-out is rejected. + * + * This test documents that the detection heuristic alone matches this pattern + * (so future threshold changes don't unknowingly regress). + */ + @Test fun cursive_difficulty_matchesDetectionHeuristic() { + val ls = 77f + val xs = floatArrayOf(113.9f,113.9f,102.0f,92.7f,90.7f,103.8f,116.1f,122.0f,124.6f,124.6f,112.9f,112.7f,125.2f,134.7f,135.1f,135.1f,143.0f,157.3f,169.2f,177.1f,176.7f,158.3f,157.9f,167.0f,175.5f,184.3f,188.2f,188.2f,182.5f,165.2f,167.2f,174.9f,172.4f,172.4f,179.5f,193.8f,203.5f,203.5f,207.2f,215.2f,205.7f,185.0f,188.0f,201.7f,202.7f,201.1f,201.1f,200.5f,199.9f,198.5f,196.9f,197.3f,207.0f,209.4f,222.1f,233.0f,235.4f,241.3f,243.3f,241.7f,240.3f,252.4f,263.3f,264.3f,265.1f,273.4f,273.8f,284.9f,294.8f,301.7f,296.0f,287.1f,294.0f,307.5f,311.1f,311.8f,311.8f,316.0f,319.2f,315.6f,302.5f,312.2f,329.3f,330.3f,331.1f,329.3f,342.4f,350.7f,335.6f,317.8f,328.3f,344.7f,345.1f) + val yRange = 87.8f // 676.0 - 588.2 + assertTrue( + "Cursive 'difficulty' matches scratch-out heuristic (advance_ratio=0.37 < 0.4)", + ScratchOutDetection.detect(xs, yRange, ls) + ) + } + + // ── Target-stroke gating ──────────────────────────────────────────────── + // + // Scratch-out must only erase when there are pre-existing strokes under + // the scratch region. Without this, new cursive words with many reversals + // (e.g. "difficulty") pass the detection heuristic and disappear. + + private fun makeStroke(vararg pairs: Pair, type: StrokeType = StrokeType.FREEHAND) = + InkStroke( + points = pairs.map { (x, y) -> StrokePoint(x, y, 0.5f, 0L) }, + strokeType = type + ) + + @Test fun hasTargetStrokes_withOverlappingStroke_returnsTrue() { + val existing = listOf(makeStroke(50f to 50f, 60f to 60f)) + assertTrue( + "Scratch-out over existing stroke should find target", + ScratchOutDetection.hasTargetStrokes(existing, 40f, 40f, 70f, 70f) + ) + } + + @Test fun hasTargetStrokes_noStrokes_returnsFalse() { + assertFalse( + "Scratch-out on blank canvas must not find target", + ScratchOutDetection.hasTargetStrokes(emptyList(), 0f, 0f, 100f, 100f) + ) + } + + @Test fun hasTargetStrokes_strokeOutsideRegion_returnsFalse() { + val existing = listOf(makeStroke(200f to 200f, 210f to 210f)) + assertFalse( + "Scratch-out should not match strokes outside its region", + ScratchOutDetection.hasTargetStrokes(existing, 0f, 0f, 100f, 100f) + ) + } + + @Test fun hasTargetStrokes_connectorCrossingRegion_returnsTrue() { + // A geometric line whose segment crosses the region even though + // neither endpoint is inside it. + val connector = makeStroke(0f to 50f, 200f to 50f, type = StrokeType.LINE) + assertTrue( + "Connector line crossing scratch region should be found", + ScratchOutDetection.hasTargetStrokes(listOf(connector), 80f, 40f, 120f, 60f) + ) + } + + @Test fun cursive_difficulty_noTarget_notErased() { + // End-to-end: "difficulty" matches the detection heuristic but has no + // strokes underneath. The hasTargetStrokes guard prevents erasure. + val ls = 77f + val xs = floatArrayOf(113.9f,113.9f,102.0f,92.7f,90.7f,103.8f,116.1f,122.0f,124.6f,124.6f,112.9f,112.7f,125.2f,134.7f,135.1f,135.1f,143.0f,157.3f,169.2f,177.1f,176.7f,158.3f,157.9f,167.0f,175.5f,184.3f,188.2f,188.2f,182.5f,165.2f,167.2f,174.9f,172.4f,172.4f,179.5f,193.8f,203.5f,203.5f,207.2f,215.2f,205.7f,185.0f,188.0f,201.7f,202.7f,201.1f,201.1f,200.5f,199.9f,198.5f,196.9f,197.3f,207.0f,209.4f,222.1f,233.0f,235.4f,241.3f,243.3f,241.7f,240.3f,252.4f,263.3f,264.3f,265.1f,273.4f,273.8f,284.9f,294.8f,301.7f,296.0f,287.1f,294.0f,307.5f,311.1f,311.8f,311.8f,316.0f,319.2f,315.6f,302.5f,312.2f,329.3f,330.3f,331.1f,329.3f,342.4f,350.7f,335.6f,317.8f,328.3f,344.7f,345.1f) + val yRange = 87.8f + + // Step 1: detection heuristic fires + assertTrue("Heuristic should match", ScratchOutDetection.detect(xs, yRange, ls)) + + // Step 2: but no existing strokes → scratch-out is rejected + assertFalse( + "Cursive 'difficulty' on blank canvas must NOT be erased", + ScratchOutDetection.hasTargetStrokes( + emptyList(), + xs.min(), 588.2f, xs.max(), 676.0f + ) + ) + } + + // ── Helper ──────────────────────────────────────────────────────────────── + + /** + * Build a zigzag x-coordinate array. + * [segments] equal-width segments alternating right/left. + * Result has [segments + 1] points. + */ + private fun zigzag(startX: Float, segmentWidth: Float, segments: Int): FloatArray { + val pts = FloatArray(segments + 1) + pts[0] = startX + for (i in 1..segments) { + val dir = if (i % 2 == 1) 1f else -1f + pts[i] = pts[i - 1] + dir * segmentWidth + } + return pts + } +} diff --git a/app/src/test/java/com/writer/view/ShapeSnapDetectionTest.kt b/app/src/test/java/com/writer/view/ShapeSnapDetectionTest.kt new file mode 100644 index 0000000..70abb0c --- /dev/null +++ b/app/src/test/java/com/writer/view/ShapeSnapDetectionTest.kt @@ -0,0 +1,1270 @@ +package com.writer.view + +import com.writer.model.StrokePoint +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import kotlin.math.cos +import kotlin.math.sin +import kotlin.math.sqrt +import kotlin.math.PI +import kotlin.math.abs + +/** + * Unit tests for [ShapeSnapDetection]. + * + * Uses a fixed line spacing of 118 px (standard device: 63 dp × 1.875 density). + * + * Shape classification: + * - Circle/oval (0 corners) → Ellipse + * - Triangle (3 corners) → Triangle + * - Rectangle (4 corners) → Rectangle + * - Straight stroke → Line + */ +class ShapeSnapDetectionTest { + + companion object { + private const val LS = 118f + } + + // ── Ellipse: circles and ovals ──────────────────────────────────────────── + + @Test fun perfectCircle_snapsToEllipse() { + // 40-point circle, radius 100 px, centered at (100,100). Fully closed (point 40 == point 0). + val n = 40 + val xs = FloatArray(n + 1) { i -> (100 + 100 * cos(2 * PI * i / n)).toFloat() } + val ys = FloatArray(n + 1) { i -> (100 + 100 * sin(2 * PI * i / n)).toFloat() } + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertNotNull("Circle should snap to an ellipse", result) + assertTrue("Circle snaps to Ellipse", result is ShapeSnapDetection.SnapResult.Ellipse) + val e = result as ShapeSnapDetection.SnapResult.Ellipse + assertEquals(100f, e.cx, 2f) + assertEquals(100f, e.cy, 2f) + assertEquals(100f, e.a, 2f) + assertEquals(100f, e.b, 2f) + } + + @Test fun oval_snapsToEllipse() { + // 40-point oval: 300 px wide, 150 px tall. Fully closed. + val n = 40 + val a = 150f; val b = 75f + val xs = FloatArray(n + 1) { i -> (a + a * cos(2 * PI * i / n)).toFloat() } + val ys = FloatArray(n + 1) { i -> (b + b * sin(2 * PI * i / n)).toFloat() } + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertNotNull("Oval should snap to an ellipse", result) + assertTrue("Oval snaps to Ellipse", result is ShapeSnapDetection.SnapResult.Ellipse) + val e = result as ShapeSnapDetection.SnapResult.Ellipse + assertEquals(a, e.cx, 3f) + assertEquals(b, e.cy, 3f) + assertEquals(a, e.a, 3f) + assertEquals(b, e.b, 3f) + } + + @Test fun circle20Points_snapsToEllipse() { + // 20-point circle: each arc segment = 18°. With window=3 the subtended angle + // is 54° > CORNER_ANGLE_DEG (50°), so every point registers as a "corner". + // This causes the circle to snap to Rectangle or Triangle instead of Ellipse. + val n = 20 + val xs = FloatArray(n + 1) { i -> (100 + 100 * cos(2 * PI * i / n)).toFloat() } + val ys = FloatArray(n + 1) { i -> (100 + 100 * sin(2 * PI * i / n)).toFloat() } + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertNotNull("20-point circle should snap to an ellipse", result) + assertTrue("20-point circle must snap to Ellipse, got $result", + result is ShapeSnapDetection.SnapResult.Ellipse) + } + + @Test fun circle15Points_snapsToEllipse() { + // 15-point circle: arc step = 24°, window=3, subtended = 72° >> 50°. + val n = 15 + val xs = FloatArray(n + 1) { i -> (100 + 100 * cos(2 * PI * i / n)).toFloat() } + val ys = FloatArray(n + 1) { i -> (100 + 100 * sin(2 * PI * i / n)).toFloat() } + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertNotNull("15-point circle should snap to an ellipse", result) + assertTrue("15-point circle must snap to Ellipse, got $result", + result is ShapeSnapDetection.SnapResult.Ellipse) + } + + @Test fun bumpyCircleWithSpuriousCorners_snapsToEllipse() { + // 20-point circle (arc step = 18°, window = 3, subtended = 54° > 50°). + // Two points nudged inward by 15 px cause brief angle dips that reset + // the inCorner flag, splitting the stroke into 3 distinct corner-regions: + // region 1: i 3..6 → corner at ~6 + // region 2: i 8..13 → corner at ~13 + // region 3: i 15..17 → corner at ~17 + // corners.size == 3 → current code misclassifies as Triangle. + // Max ellipse deviation = 15 / (100*√2*2) ≈ 5.3% < ELLIPSE_MAX_DEV (7%) + // so the shape should snap to Ellipse. + val n = 20 + val nudgeAt = setOf(7, 14) + val xs = FloatArray(n + 1) { i -> + val angle = 2 * PI * i / n + val r = if (i in nudgeAt) 85.0 else 100.0 + (100 + r * cos(angle)).toFloat() + } + val ys = FloatArray(n + 1) { i -> + val angle = 2 * PI * i / n + val r = if (i in nudgeAt) 85.0 else 100.0 + (100 + r * sin(angle)).toFloat() + } + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertNotNull("Bumpy circle should snap to an ellipse", result) + assertTrue("Bumpy circle must snap to Ellipse (not Triangle), got $result", + result is ShapeSnapDetection.SnapResult.Ellipse) + } + + @Test fun threeQuarterArc_snapsToSelfLoop() { + // 3/4 of a circle (270°): start at (200,100), end at (100,0). + // Nearly closed (gap ratio ~0.50 < SELF_LOOP_MAX_GAP), smooth, fits ellipse. + val n = 30 + val xs = FloatArray(n + 1) { i -> (100 + 100 * cos(3 * PI / 2 * i / n)).toFloat() } + val ys = FloatArray(n + 1) { i -> (100 + 100 * sin(3 * PI / 2 * i / n)).toFloat() } + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertNotNull("3/4 arc should snap to SelfLoop", result) + assertTrue("Should be SelfLoop, got $result", + result is ShapeSnapDetection.SnapResult.SelfLoop) + } + + // ── RoundedRectangle ────────────────────────────────────────────────────── + + @Test fun roundedRectangle_snapsToRoundedRectangle() { + // 200×150 rounded rectangle with corner radius 37 px. + // 15 arc points per 90° corner, 5 points per straight side. + // N ≈ 81 → window = max(3, 81/12) = 6; arc step = 6°; detected angle = 36° < 50°. + // So 0 sharp corners are found → should snap to RoundedRectangle, not Rectangle. + // Max ellipse deviation at the 45° corner arc position ≈ 7.2% > 7% threshold, + // so it must NOT snap to Ellipse either. + val left = 0f; val top = 0f; val right = 200f; val bottom = 150f + val r = 37f + val arcN = 15; val sideN = 5 + val cl = left + r; val cr = right - r + val ct = top + r; val cb = bottom - r + + val pts = mutableListOf>() + fun arc(cx: Float, cy: Float, startDeg: Double, endDeg: Double) { + for (i in 0 until arcN) { + val a = Math.toRadians(startDeg + (endDeg - startDeg) * i / arcN) + pts += Pair((cx + r * cos(a)).toFloat(), (cy + r * sin(a)).toFloat()) + } + } + fun side(x0: Float, y0: Float, x1: Float, y1: Float) { + for (i in 0 until sideN) { + val t = i.toFloat() / sideN + pts += Pair(x0 + (x1 - x0) * t, y0 + (y1 - y0) * t) + } + } + // Clockwise from (cr, top): TR arc → right side → BR arc → bottom → BL arc → left → TL arc → top → close + arc(cr, ct, -90.0, 0.0); side(right, ct, right, cb) + arc(cr, cb, 0.0, 90.0); side(cr, bottom, cl, bottom) + arc(cl, cb, 90.0, 180.0); side(left, cb, left, ct) + arc(cl, ct, 180.0, 270.0); side(cl, top, cr, top) + pts += Pair(cr, top) // close + + val xs = pts.map { it.first }.toFloatArray() + val ys = pts.map { it.second }.toFloatArray() + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertNotNull("Rounded rectangle should snap", result) + assertTrue("Rounded rectangle snaps to RoundedRectangle, got $result", + result is ShapeSnapDetection.SnapResult.RoundedRectangle) + val rr = result as ShapeSnapDetection.SnapResult.RoundedRectangle + assertEquals(left, rr.left, 3f) + assertEquals(top, rr.top, 3f) + assertEquals(right, rr.right, 3f) + assertEquals(bottom, rr.bottom, 3f) + assertTrue("Corner radius should be positive", rr.cornerRadius > 0f) + } + + @Test fun sharpRectangle_doesNotSnapToRoundedRectangle() { + // A rectangle with sharp 90° corners (5 points) must snap to Rectangle, + // not RoundedRectangle, even though it has 0 smooth corner-detections + // in the small-N fallback path. + val xs = floatArrayOf(0f, 200f, 200f, 0f, 0f) + val ys = floatArrayOf(0f, 0f, 150f, 150f, 0f) + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertTrue("5-point sharp rect must snap to Rectangle", + result is ShapeSnapDetection.SnapResult.Rectangle) + } + + // ── RoundedRectangle with fewer arc points per corner ───────────────────── + + @Test fun roundedRectangleFewArcPoints_snapsToRoundedRectangle() { + // 200×150 rounded rectangle with corner radius 30 px. + // Only 5 arc points per 90° corner → arc step = 18°/pt. + // N = 4*(5+5)+1 = 41 → window = max(3, 41/12) = 3. + // At each arc midpoint the windowed angle ≈ 54° > CORNER_ANGLE_DEG (50°), + // so currently 4 sharp corners are detected → snaps to Rectangle (BUG). + // Expected: RoundedRectangle. + val left = 0f; val top = 0f; val right = 200f; val bottom = 150f + val r = 30f + val arcN = 5; val sideN = 5 + val cl = left + r; val cr = right - r + val ct = top + r; val cb = bottom - r + + val pts = mutableListOf>() + fun arc(cx: Float, cy: Float, startDeg: Double, endDeg: Double) { + for (i in 0 until arcN) { + val a = Math.toRadians(startDeg + (endDeg - startDeg) * i / arcN) + pts += Pair((cx + r * cos(a)).toFloat(), (cy + r * sin(a)).toFloat()) + } + } + fun side(x0: Float, y0: Float, x1: Float, y1: Float) { + for (i in 0 until sideN) { + val t = i.toFloat() / sideN + pts += Pair(x0 + (x1 - x0) * t, y0 + (y1 - y0) * t) + } + } + arc(cr, ct, -90.0, 0.0); side(right, ct, right, cb) + arc(cr, cb, 0.0, 90.0); side(cr, bottom, cl, bottom) + arc(cl, cb, 90.0, 180.0); side(left, cb, left, ct) + arc(cl, ct, 180.0, 270.0); side(cl, top, cr, top) + pts += Pair(cr, top) // close + + val xs = pts.map { it.first }.toFloatArray() + val ys = pts.map { it.second }.toFloatArray() + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertNotNull("Rounded rectangle (few arc pts) should snap", result) + assertTrue("Rounded rectangle (few arc pts) snaps to RoundedRectangle, got $result", + result is ShapeSnapDetection.SnapResult.RoundedRectangle) + } + + // ── Rounded rectangle vs ellipse boundary ───────────────────────────────── + + /** + * Builds a rounded rectangle with the given corner radius. + * As radius approaches min(w,h)/2, the shape approaches an ellipse. + */ + private fun makeRoundedRect( + w: Float = 200f, h: Float = 150f, r: Float, arcN: Int = 12, sideN: Int = 6 + ): Pair { + val left = 0f; val top = 0f; val right = w; val bottom = h + val cl = left + r; val cr = right - r + val ct = top + r; val cb = bottom - r + val pts = mutableListOf>() + fun arc(cx: Float, cy: Float, startDeg: Double, endDeg: Double) { + for (i in 0 until arcN) { + val a = Math.toRadians(startDeg + (endDeg - startDeg) * i / arcN) + pts += Pair((cx + r * cos(a)).toFloat(), (cy + r * sin(a)).toFloat()) + } + } + fun side(x0: Float, y0: Float, x1: Float, y1: Float) { + for (i in 0 until sideN) { + val t = i.toFloat() / sideN + pts += Pair(x0 + (x1 - x0) * t, y0 + (y1 - y0) * t) + } + } + arc(cr, ct, -90.0, 0.0); side(right, ct, right, cb) + arc(cr, cb, 0.0, 90.0); side(cr, bottom, cl, bottom) + arc(cl, cb, 90.0, 180.0); side(left, cb, left, ct) + arc(cl, ct, 180.0, 270.0); side(cl, top, cr, top) + pts += pts[0] // close + return pts.map { it.first }.toFloatArray() to pts.map { it.second }.toFloatArray() + } + + @Test fun roundedRect_smallRadius_snapsToRoundedRectangle() { + // r=20 on 200x150 — clearly rectangular with slight rounding. + val (xs, ys) = makeRoundedRect(r = 20f) + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertTrue("r=20 should be RoundedRectangle, got $result", + result is ShapeSnapDetection.SnapResult.RoundedRectangle) + } + + @Test fun roundedRect_moderateRadius_snapsToRoundedRectangle() { + // r=40 on 200x150 — noticeably rounded but still clearly a rectangle. + val (xs, ys) = makeRoundedRect(r = 40f) + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertTrue("r=40 should be RoundedRectangle, got $result", + result is ShapeSnapDetection.SnapResult.RoundedRectangle) + } + + @Test fun roundedRect_largeRadius_snapsToRoundedRectangle() { + // r=55 on 200x150 — very rounded (73% of min side). This is the boundary + // case where hand-drawn rounded rects get misclassified as ellipses. + val (xs, ys) = makeRoundedRect(r = 55f) + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertTrue("r=55 should be RoundedRectangle, got $result", + result is ShapeSnapDetection.SnapResult.RoundedRectangle) + } + + @Test fun roundedRect_nearMaxRadius_snapsToEllipse() { + // r=75 on 200x150 — radius = min(w,h)/2, this IS an ellipse. + val (xs, ys) = makeRoundedRect(r = 75f) + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertTrue("r=75 (full radius) should be Ellipse, got $result", + result is ShapeSnapDetection.SnapResult.Ellipse) + } + + // ── Ovals at various aspect ratios must snap to Ellipse ──────────────────── + + private fun makeOval(a: Float, b: Float, n: Int = 60): Pair { + val xs = FloatArray(n + 1) { i -> (a + a * cos(2 * PI * i / n)).toFloat() } + val ys = FloatArray(n + 1) { i -> (b + b * sin(2 * PI * i / n)).toFloat() } + return xs to ys + } + + @Test fun oval_2to1_40pts_snapsToEllipse() { + val (xs, ys) = makeOval(a = 150f, b = 75f, n = 40) + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertTrue("2:1 oval (40 pts) should be Ellipse, got $result", + result is ShapeSnapDetection.SnapResult.Ellipse) + } + + @Test fun oval_2to1_60pts_snapsToEllipse() { + val (xs, ys) = makeOval(a = 150f, b = 75f, n = 60) + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertTrue("2:1 oval (60 pts) should be Ellipse, got $result", + result is ShapeSnapDetection.SnapResult.Ellipse) + } + + @Test fun oval_3to1_snapsToEllipse() { + // Very elongated oval — the long sides have very low curvature. + val (xs, ys) = makeOval(a = 225f, b = 75f, n = 60) + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertTrue("3:1 oval should be Ellipse, got $result", + result is ShapeSnapDetection.SnapResult.Ellipse) + } + + @Test fun oval_3to1_100pts_snapsToEllipse() { + // More points → finer sampling → long-side segments look even straighter. + val (xs, ys) = makeOval(a = 225f, b = 75f, n = 100) + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertTrue("3:1 oval (100 pts) should be Ellipse, got $result", + result is ShapeSnapDetection.SnapResult.Ellipse) + } + + @Test fun oval_4to1_snapsToEllipse() { + val (xs, ys) = makeOval(a = 300f, b = 75f, n = 80) + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertTrue("4:1 oval should be Ellipse, got $result", + result is ShapeSnapDetection.SnapResult.Ellipse) + } + + @Test fun oval_1to3_tall_snapsToEllipse() { + // Tall narrow oval — same problem but in the vertical direction. + val (xs, ys) = makeOval(a = 75f, b = 225f, n = 60) + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertTrue("1:3 tall oval should be Ellipse, got $result", + result is ShapeSnapDetection.SnapResult.Ellipse) + } + + // ── straightFraction boundary verification ────────────────────────────── + + @Test fun straightFraction_ellipsesBelowThreshold_roundedRectsAbove() { + val thresh = ShapeSnapDetection.STRAIGHT_FRACTION_MIN + + // Ellipses: all must be below threshold + val shapes = listOf( + "circle" to makeOval(100f, 100f, 40), + "oval 2:1" to makeOval(150f, 75f, 40), + "oval 3:1" to makeOval(225f, 75f, 60), + "oval 4:1" to makeOval(300f, 75f, 80), + "oval 1:3" to makeOval(75f, 225f, 60), + "rr75/ellipse" to makeRoundedRect(r = 75f), + ) + val sfValues = mutableListOf() + for ((name, data) in shapes) { + val sf = ShapeSnapDetection.straightFraction(data.first, data.second) + sfValues += "$name=$sf" + assertTrue("$name: SF=$sf should be < $thresh (all: ${sfValues.joinToString()})", sf < thresh) + } + + // Rounded rects: all must be at or above threshold + val rrs = listOf( + "rr20" to makeRoundedRect(r = 20f), + "rr40" to makeRoundedRect(r = 40f), + "rr55" to makeRoundedRect(r = 55f), + ) + val rrValues = mutableListOf() + for ((name, data) in rrs) { + val sf = ShapeSnapDetection.straightFraction(data.first, data.second) + rrValues += "$name=$sf" + assertTrue("$name: SF=$sf should be >= $thresh (all: ${rrValues.joinToString()})", sf >= thresh) + } + } + + // ── Triangle ────────────────────────────────────────────────────────────── + + @Test fun triangleStartingAtCorner_snapsToTriangle() { + // Equilateral triangle where the stroke starts AT corner A=(0,0). + // Corner A is at stroke index 0, which is outside the corner-detection + // window range [window..n-window). With window=3, i=0 is never evaluated, + // so corner A is missed → only 2 corners detected → currently snaps to + // RoundedRectangle (BUG). Expected: Triangle. + val pts = mutableListOf>() + val c = listOf(Pair(0f, 0f), Pair(200f, 0f), Pair(100f, 173f), Pair(0f, 0f)) + for (side in 0 until 3) { + val from = c[side]; val to = c[side + 1] + for (i in 0 until 14) { + val t = i / 14f + pts += Pair(from.first + (to.first - from.first) * t, + from.second + (to.second - from.second) * t) + } + } + pts += c[0] // close + + val xs = pts.map { it.first }.toFloatArray() + val ys = pts.map { it.second }.toFloatArray() + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertNotNull("Triangle starting at corner should snap", result) + assertTrue("Triangle starting at corner snaps to Triangle, got $result", + result is ShapeSnapDetection.SnapResult.Triangle) + } + + @Test fun equilateralTriangle_snapsToTriangle() { + // Triangle: top=(100,0), bottom-right=(200,173), bottom-left=(0,173). + // Start from mid-bottom (100,173) so all 3 corners are away from the + // boundary of the corner-detection window (corners at ~i=10, 20, 30 of 41). + val pts = mutableListOf>() + val corners = listOf(Pair(100f, 173f), Pair(200f, 173f), Pair(100f, 0f), Pair(0f, 173f)) + // Sides: mid-bottom→BR, BR→top, top→BL, BL→mid-bottom + for (side in 0 until 3) { + val from = corners[side]; val to = corners[side + 1] + for (i in 0 until 10) { + val t = i / 10f + pts += Pair(from.first + (to.first - from.first) * t, + from.second + (to.second - from.second) * t) + } + } + // Last segment: BL → mid-bottom (close) + val from = corners[3]; val to = corners[0] + for (i in 0 until 10) { + val t = i / 10f + pts += Pair(from.first + (to.first - from.first) * t, + from.second + (to.second - from.second) * t) + } + pts += corners[0] // close + + val xs = pts.map { it.first }.toFloatArray() + val ys = pts.map { it.second }.toFloatArray() + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertNotNull("Triangle should snap", result) + assertTrue("Triangle snaps to Triangle", + result is ShapeSnapDetection.SnapResult.Triangle) + } + + // ── Rectangle ───────────────────────────────────────────────────────────── + + @Test fun freehandRectangleLoop_snapsToRectangle() { + // Simulate a hand-drawn closed rectangle (200×150 px). + // Start from mid-top (100, 0) so all 4 corners fall within the + // corner-detection window range (not cut off at stroke boundaries). + val pts = mutableListOf>() + + fun edge(x0: Float, y0: Float, x1: Float, y1: Float, n: Int) { + for (i in 0 until n) { + val t = i.toFloat() / n + pts += Pair( + x0 + (x1 - x0) * t + (i % 2) * 2f - 1f, + y0 + (y1 - y0) * t + (i % 3) * 1.5f + ) + } + } + + // Clockwise from mid-top: mid(100,0) → TR → BR → BL → TL → mid(100,0) + edge(100f, 0f, 200f, 0f, 5) + edge(200f, 0f, 200f, 150f, 8) + edge(200f, 150f, 0f, 150f, 10) + edge(0f, 150f, 0f, 0f, 8) + edge(0f, 0f, 100f, 0f, 5) + pts += Pair(100f, 0f) // close + + val xs = pts.map { it.first }.toFloatArray() + val ys = pts.map { it.second }.toFloatArray() + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertNotNull("Freehand rectangle loop should snap", result) + assertTrue("Freehand rectangle loop snaps to Rectangle", + result is ShapeSnapDetection.SnapResult.Rectangle) + } + + // ── detectLine: should snap ─────────────────────────────────────────────── + + @Test fun straightHorizontalLine_snaps() { + val xs = floatArrayOf(0f, 50f, 100f, 150f, 200f) + val ys = floatArrayOf(100f, 100f, 100f, 100f, 100f) + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertNotNull(result) + assertTrue(result is ShapeSnapDetection.SnapResult.Line) + val line = result as ShapeSnapDetection.SnapResult.Line + assertEquals(0f, line.x1, 0.01f) + assertEquals(100f, line.y1, 0.01f) + assertEquals(200f, line.x2, 0.01f) + assertEquals(100f, line.y2, 0.01f) + } + + @Test fun straightVerticalLine_snaps() { + val xs = floatArrayOf(50f, 50f, 50f, 50f, 50f) + val ys = floatArrayOf(0f, 50f, 100f, 150f, 200f) + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertNotNull(result) + assertTrue(result is ShapeSnapDetection.SnapResult.Line) + } + + @Test fun straightDiagonalLine_snaps() { + val n = 6 + val xs = FloatArray(n) { it * 40f } + val ys = FloatArray(n) { it * 40f } + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertNotNull(result) + assertTrue(result is ShapeSnapDetection.SnapResult.Line) + } + + @Test fun nearlyStraitLine_smallDeviation_snaps() { + val xs = floatArrayOf(0f, 100f, 150f, 200f, 300f) + val ys = floatArrayOf(0f, 0f, 5f, 0f, 0f) + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertNotNull(result) + assertTrue(result is ShapeSnapDetection.SnapResult.Line) + } + + // ── detectLine: should NOT snap ────────────────────────────────────────── + + @Test fun tooShortLine_notDetected() { + val xs = floatArrayOf(0f, 50f, 100f) + val ys = floatArrayOf(0f, 0f, 0f) + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertNull(result) + } + + @Test fun curvedLine_tooMuchDeviation_notDetected() { + val xs = floatArrayOf(0f, 100f, 200f) + val ys = floatArrayOf(0f, 50f, 0f) + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertNull(result) + } + + @Test fun semicircle_doesNotSnapToLine() { + // Semicircle: path length ≈ 314, straight-line length = 200, ratio ≈ 1.57 > 1.5. + // Must not snap to Line (too much path ratio), but may snap to Arc. + val n = 20 + val xs = FloatArray(n + 1) { (100f * cos(PI * it / n)).toFloat() } + val ys = FloatArray(n + 1) { (-100f * sin(PI * it / n)).toFloat() } + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertTrue("Semicircle must not snap to a line, got $result", + result !is ShapeSnapDetection.SnapResult.Line) + } + + @Test fun closedLoop_tooSmallForLine_notDetected() { + val xs = floatArrayOf(50f, 55f, 55f, 50f, 50f) + val ys = floatArrayOf(50f, 50f, 55f, 55f, 50f) + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertNull(result) + } + + // ── Rectangle (minimal point sets, fallback path) ───────────────────────── + + @Test fun cleanRectangularLoop_snaps() { + // 5-point rectangle — uses the small-N fallback (bounding-box detection). + val xs = floatArrayOf(0f, 200f, 200f, 0f, 0f) + val ys = floatArrayOf(0f, 0f, 150f, 150f, 0f) + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertNotNull(result) + assertTrue(result is ShapeSnapDetection.SnapResult.Rectangle) + val rect = result as ShapeSnapDetection.SnapResult.Rectangle + assertEquals(0f, rect.left, 0.01f) + assertEquals(0f, rect.top, 0.01f) + assertEquals(200f, rect.right, 0.01f) + assertEquals(150f, rect.bottom, 0.01f) + } + + @Test fun slightlyRoughRectangularLoop_snaps() { + // 5-point rectangle with small jitter — small-N fallback. + val xs = floatArrayOf(2f, 198f, 202f, 1f, -1f) + val ys = floatArrayOf(1f, -2f, 148f, 152f, 2f) + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertNotNull(result) + assertTrue(result is ShapeSnapDetection.SnapResult.Rectangle) + } + + // ── Rectangle: should NOT snap ──────────────────────────────────────────── + + @Test fun openRectangularStroke_notRect() { + val xs = floatArrayOf(0f, 200f, 200f, 0f) + val ys = floatArrayOf(0f, 0f, 150f, 148f) + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertNull(result) + } + + @Test fun tooSmallRectangle_notDetected() { + val xs = floatArrayOf(0f, 30f, 30f, 0f, 0f) + val ys = floatArrayOf(0f, 0f, 30f, 30f, 0f) + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertNull(result) + } + + @Test fun spiralLoop_notRect() { + val pts = mutableListOf>() + val n = 30; val cx = 100f; val cy = 100f + for (i in 0..n) { + val angle = 4 * PI * i / n + val r = 100f * (1f - i.toFloat() / n) + pts.add(Pair((cx + r * cos(angle)).toFloat(), (cy + r * sin(angle)).toFloat())) + } + val xs = pts.map { it.first }.toFloatArray() + val ys = pts.map { it.second }.toFloatArray() + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertNull(result) + } + + // ── Diamond ─────────────────────────────────────────────────────────────── + + @Test fun diamond_snapsToDialmond() { + // 200×160 diamond: top=(100,0), right=(200,80), bottom=(100,160), left=(0,80). + // 10 points per side, closed. + val cx = 100f; val cy = 80f + val vertices = listOf( + Pair(cx, 0f), Pair(200f, cy), Pair(cx, 160f), Pair(0f, cy), Pair(cx, 0f) + ) + val pts = mutableListOf>() + for (side in 0 until 4) { + val from = vertices[side]; val to = vertices[side + 1] + for (i in 0 until 10) { + val t = i / 10f + pts += Pair(from.first + (to.first - from.first) * t, + from.second + (to.second - from.second) * t) + } + } + pts += vertices[0] // close + val xs = pts.map { it.first }.toFloatArray() + val ys = pts.map { it.second }.toFloatArray() + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertNotNull("Diamond should snap", result) + assertTrue("Diamond snaps to Diamond, got $result", + result is ShapeSnapDetection.SnapResult.Diamond) + } + + @Test fun squareDiamond_snapsToDialmond() { + // Square rotated 45°: diagonal = 200 px + val pts = mutableListOf>() + val vertices = listOf( + Pair(100f, 0f), Pair(200f, 100f), Pair(100f, 200f), Pair(0f, 100f), Pair(100f, 0f) + ) + for (side in 0 until 4) { + val from = vertices[side]; val to = vertices[side + 1] + for (i in 0 until 8) { + val t = i / 8f + pts += Pair(from.first + (to.first - from.first) * t, + from.second + (to.second - from.second) * t) + } + } + pts += vertices[0] + val xs = pts.map { it.first }.toFloatArray() + val ys = pts.map { it.second }.toFloatArray() + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertNotNull("Square diamond should snap", result) + assertTrue("Square diamond snaps to Diamond, got $result", + result is ShapeSnapDetection.SnapResult.Diamond) + } + + @Test fun axisAlignedRectangle_doesNotSnapToDiamond() { + // Axis-aligned rectangle: corners at bbox corners, not edge midpoints → Rectangle + val xs = FloatArray(41 + 1) + val ys = FloatArray(41 + 1) + val pts = mutableListOf>() + fun edge(x0: Float, y0: Float, x1: Float, y1: Float, n: Int) { + for (i in 0 until n) { + val t = i.toFloat() / n + pts += Pair(x0 + (x1 - x0) * t, y0 + (y1 - y0) * t) + } + } + edge(100f, 0f, 200f, 0f, 5) + edge(200f, 0f, 200f, 150f, 8) + edge(200f, 150f, 0f, 150f, 10) + edge(0f, 150f, 0f, 0f, 8) + edge(0f, 0f, 100f, 0f, 5) + pts += Pair(100f, 0f) + val xsArr = pts.map { it.first }.toFloatArray() + val ysArr = pts.map { it.second }.toFloatArray() + val result = ShapeSnapDetection.detect(xsArr, ysArr, LS) + assertNotNull("Rectangle should snap", result) + assertTrue("Axis-aligned rectangle must NOT snap to Diamond, got $result", + result is ShapeSnapDetection.SnapResult.Rectangle) + } + + // ── Realistic pen input tests ────────────────────────────────────────────── + + private fun jitterX(i: Int) = (sin(i * 7.3) * 1.5).toFloat() + private fun jitterY(i: Int) = (cos(i * 5.7) * 1.5).toFloat() + + @Test fun realisticRectangle_snapsToRectangle() { + // Sharp-cornered rectangle ~200x150 px, clockwise from top-left, 41 points. + val left = 100f; val top = 100f; val right = 300f; val bottom = 250f + val pts = mutableListOf>() + var idx = 0 + + fun addPt(x: Float, y: Float) { + pts += Pair(x + jitterX(idx), y + jitterY(idx)) + idx++ + } + + // Top edge: 10 points + for (i in 0 until 10) { + addPt(left + (right - left) * i / 10f, top) + } + // Top-right corner + addPt(right, top) + // Right edge: 9 points + for (i in 1 until 10) { + addPt(right, top + (bottom - top) * i / 10f) + } + // Bottom-right corner + addPt(right, bottom) + // Bottom edge: 9 points + for (i in 1 until 10) { + addPt(right - (right - left) * i / 10f, bottom) + } + // Bottom-left corner + addPt(left, bottom) + // Left edge: 9 points + for (i in 1 until 10) { + addPt(left, bottom - (bottom - top) * i / 10f) + } + // Close (~3px from start) + pts += Pair(pts[0].first + 1.2f, pts[0].second - 0.8f) + + val xs = pts.map { it.first }.toFloatArray() + val ys = pts.map { it.second }.toFloatArray() + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertNotNull("Realistic rectangle should snap", result) + assertTrue("Realistic rectangle snaps to Rectangle, got $result", + result is ShapeSnapDetection.SnapResult.Rectangle) + } + + @Test fun realisticRoundedRectangle_snapsToRoundedRectangle() { + // Rounded-corner rectangle ~200x150 px, corner radius 30px, clockwise, 55 points. + val left = 100f; val top = 100f; val right = 300f; val bottom = 250f + val r = 30f + val cl = left + r; val crX = right - r + val ct = top + r; val cb = bottom - r + val arcN = 8 + val pts = mutableListOf>() + var idx = 0 + + fun addPt(x: Float, y: Float) { + pts += Pair(x + jitterX(idx), y + jitterY(idx)) + idx++ + } + + fun arc(cx: Float, cy: Float, startDeg: Double, endDeg: Double) { + for (i in 0 until arcN) { + val angle = Math.toRadians(startDeg + (endDeg - startDeg) * i / arcN) + addPt((cx + r * cos(angle)).toFloat(), (cy + r * sin(angle)).toFloat()) + } + } + + // Top-right arc (-90 to 0) + arc(crX, ct, -90.0, 0.0) + // Right edge: 6 points + for (i in 0 until 6) addPt(right, ct + (cb - ct) * i / 6f) + // Bottom-right arc (0 to 90) + arc(crX, cb, 0.0, 90.0) + // Bottom edge: 6 points + for (i in 0 until 6) addPt(crX - (crX - cl) * i / 6f, bottom) + // Bottom-left arc (90 to 180) + arc(cl, cb, 90.0, 180.0) + // Left edge: 6 points + for (i in 0 until 6) addPt(left, cb - (cb - ct) * i / 6f) + // Top-left arc (180 to 270) + arc(cl, ct, 180.0, 270.0) + // Top edge: 4 points + for (i in 0 until 4) addPt(cl + (crX - cl) * i / 4f, top) + // Close (~3px from start) + pts += Pair(pts[0].first + 1.0f, pts[0].second - 0.5f) + + val xs = pts.map { it.first }.toFloatArray() + val ys = pts.map { it.second }.toFloatArray() + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertNotNull("Realistic rounded rectangle should snap", result) + assertTrue("Realistic rounded rectangle snaps to RoundedRectangle, got $result", + result is ShapeSnapDetection.SnapResult.RoundedRectangle) + } + + // ── maxPerpendicularDeviation ───────────────────────────────────────────── + + @Test fun perfectLine_zeroDeviation() { + val xs = floatArrayOf(0f, 50f, 100f, 150f, 200f) + val ys = floatArrayOf(0f, 0f, 0f, 0f, 0f) + val dev = ShapeSnapDetection.maxPerpendicularDeviation(xs, ys, 0f, 0f, 200f, 0f) + assertEquals(0f, dev, 0.001f) + } + + @Test fun singlePointOffLine_correctDeviation() { + val xs = floatArrayOf(0f, 100f, 200f) + val ys = floatArrayOf(0f, 10f, 0f) + val dev = ShapeSnapDetection.maxPerpendicularDeviation(xs, ys, 0f, 0f, 200f, 0f) + assertEquals(10f, dev, 0.001f) + } + + @Test fun zeroLengthLine_returnsZero() { + val xs = floatArrayOf(50f, 60f) + val ys = floatArrayOf(50f, 70f) + val dev = ShapeSnapDetection.maxPerpendicularDeviation(xs, ys, 50f, 50f, 50f, 50f) + assertEquals(0f, dev, 0.001f) + } + + @Test fun diagonalLine_correctDeviation() { + val xs = floatArrayOf(0f, 0f, 100f) + val ys = floatArrayOf(0f, 100f, 100f) + val dev = ShapeSnapDetection.maxPerpendicularDeviation(xs, ys, 0f, 0f, 100f, 100f) + val expected = 50f * sqrt(2f) + assertEquals(expected, dev, 0.01f) + } + + // ── False-positive rejection: letter-like closed loops ──────────────────── + + /** + * Builds a "letter B"-like stroke: left spine (x=0) going down from (0,0) to (0,h), + * then two bumps on the right side whose valley at (junctionX, h/2) is interior to + * the bounding box, then back up the spine to close. + * + * Valley point deviation from nearest bbox edge = junctionX = w/3. + * For w=90, h=200: diagonal ≈ 224, ratio ≈ 30/224 = 0.134 > RECT_MAX_POINT_DEV (0.12). + */ + private fun makeBLikeStroke(w: Float = 90f, h: Float = 200f, pts: Int = 30): Pair { + val xs = mutableListOf() + val ys = mutableListOf() + val junctionX = w / 3f + val bumpAmp = w - junctionX + + // Top: spine top (0,0) → junction top (junctionX, 0) + xs.add(0f); ys.add(0f) + xs.add(junctionX); ys.add(0f) + + // Right side: 2 bumps via raised cosine from (junctionX,0) to (junctionX,h) + for (i in 0..pts) { + val t = i.toFloat() / pts + xs.add(junctionX + bumpAmp * (1 - cos(4 * PI * t)).toFloat() / 2) + ys.add(h * t) + } + + // Bottom: junction bottom (junctionX, h) → spine bottom (0, h) + xs.add(0f); ys.add(h) + + // Left spine back up to (0, 0) + for (i in pts downTo 0) { + xs.add(0f) + ys.add(h * i.toFloat() / pts) + } + + return xs.toFloatArray() to ys.toFloatArray() + } + + /** + * Letter P: one bump on right side spanning the top half, spine on left. + * The end of the bump (junctionX, h/2) has deviation = junctionX from nearest edge, + * ratio = junctionX/diagonal ≈ 30/219 = 0.137 > RECT_MAX_POINT_DEV (0.12). + */ + private fun makePLikeStroke(w: Float = 90f, h: Float = 200f, pts: Int = 30): Pair { + val xs = mutableListOf() + val ys = mutableListOf() + val junctionX = w / 3f + val bumpAmp = w - junctionX + val halfH = h / 2f + + // Top edge: spine top (0,0) → junction top (junctionX, 0) + for (i in 0..4) { xs.add(junctionX * i / 4f); ys.add(0f) } + + // Single bump from (junctionX, 0) to (junctionX, halfH) + for (i in 1..pts) { + val t = i.toFloat() / pts + xs.add(junctionX + bumpAmp * (1 - cos(2 * PI * t)).toFloat() / 2) + ys.add(halfH * t) + } + // Now at (junctionX, halfH) — interior point, deviation = junctionX = 30 px + + // Return to spine at mid-height: (junctionX, halfH) → (0, halfH) + for (i in 1..4) { xs.add(junctionX * (1f - i / 4f)); ys.add(halfH) } + + // Spine down: (0, halfH) → (0, h) + for (i in 1..pts / 2) { xs.add(0f); ys.add(halfH + halfH * i / (pts / 2f)) } + + // Spine up: (0, h) → (0, 0) [closes the loop] + for (i in 1..pts) { xs.add(0f); ys.add(h * (1f - i.toFloat() / pts)) } + + return xs.toFloatArray() to ys.toFloatArray() + } + + @Test fun letterB_twoRightBumps_doesNotSnap() { + val (xs, ys) = makeBLikeStroke() + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertNull("Letter B should not snap to any shape, got $result", result) + } + + @Test fun letterP_oneRightBump_doesNotSnap() { + val (xs, ys) = makePLikeStroke() + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertNull("Letter P should not snap to any shape, got $result", result) + } + + @Test fun wavyClosedLoop_doesNotSnapToRoundedRectangle() { + // Generic horizontally-wavy closed loop: x oscillates 0..w..0..w..0 (2 bumps), + // y goes 0..h top-to-bottom then spine back up. Interior valley has x=w/3. + val (xs, ys) = makeBLikeStroke(w = 120f, h = 180f, pts = 40) + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertTrue( + "Wavy closed loop must not snap to RoundedRectangle, got $result", + result !is ShapeSnapDetection.SnapResult.RoundedRectangle + ) + } + + // ── Boundary positives: sloppy shapes that must still snap ──────────────── + + @Test fun tallNarrowRectangle_snapsToRectangle() { + // h/w = 3. Use a full corner-counting path (enough points). + val pts = mutableListOf>() + fun edge(x0: Float, y0: Float, x1: Float, y1: Float, n: Int) { + for (i in 0 until n) { + val t = i.toFloat() / n + pts += Pair(x0 + (x1 - x0) * t, y0 + (y1 - y0) * t) + } + } + edge(50f, 0f, 100f, 0f, 5) + edge(100f, 0f, 100f, 300f, 15) + edge(100f, 300f, 0f, 300f, 5) + edge(0f, 300f, 0f, 0f, 15) + edge(0f, 0f, 50f, 0f, 5) + pts += Pair(50f, 0f) + + val xs = pts.map { it.first }.toFloatArray() + val ys = pts.map { it.second }.toFloatArray() + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertNotNull("Tall narrow rectangle should snap", result) + assertTrue("Tall narrow rectangle snaps to Rectangle, got $result", + result is ShapeSnapDetection.SnapResult.Rectangle) + } + + @Test fun sloppyRectangle_belowMaxPointDev_snapsToRectangle() { + // Rectangle with moderate jitter — max single-point offset ≈ 8% of diagonal, + // which is below RECT_MAX_POINT_DEV (12%). Should still snap. + // 200×150 rect: diagonal ≈ 250. 8% × 250 = 20 px max jitter. + val pts = mutableListOf>() + fun edge(x0: Float, y0: Float, x1: Float, y1: Float, n: Int) { + for (i in 0 until n) { + val t = i.toFloat() / n + // Jitter perpendicular to edge direction, capped at 18 px (< 20 px limit) + val jitter = if (i % 3 == 1) 10f else 0f + val isHoriz = (y0 == y1) + pts += Pair( + x0 + (x1 - x0) * t + if (!isHoriz) jitter else 0f, + y0 + (y1 - y0) * t + if (isHoriz) jitter else 0f + ) + } + } + edge(100f, 0f, 200f, 0f, 5) + edge(200f, 0f, 200f, 150f, 8) + edge(200f, 150f, 0f, 150f, 10) + edge(0f, 150f, 0f, 0f, 8) + edge(0f, 0f, 100f, 0f, 5) + pts += Pair(100f, 0f) + + val xs = pts.map { it.first }.toFloatArray() + val ys = pts.map { it.second }.toFloatArray() + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertNotNull("Sloppy rectangle (jitter < maxPointDev) should snap", result) + assertTrue("Sloppy rectangle snaps to Rectangle, got $result", + result is ShapeSnapDetection.SnapResult.Rectangle) + } + + // ── Dwell-gated shape snapping ──────────────────────────────────────────── + // + // Shape snapping requires the user to hold the pen still at the end of the + // stroke. These tests verify the combined dwell + detection pipeline: + // hasDwellAtEnd gates whether ShapeSnapDetection.detect is consulted. + + private val DWELL_RADIUS = 15f + private val DWELL_MS = 300L + + /** Build StrokePoints for a rectangle with timestamps. If [dwellAtEnd], the + * last few points cluster at the close point for >= DWELL_MS. */ + private fun makeTimedRectangle(dwellAtEnd: Boolean): List { + val pts = mutableListOf() + var t = 0L + val step = 15L // 15ms between points — fast drawing + + // Top edge: (0,0) → (200,0) + for (i in 0..9) { pts += StrokePoint(i * 20f, 0f, 1f, t); t += step } + // Right edge: (200,0) → (200,150) + for (i in 1..7) { pts += StrokePoint(200f, i * 150f / 7, 1f, t); t += step } + // Bottom edge: (200,150) → (0,150) + for (i in 1..9) { pts += StrokePoint(200f - i * 20f, 150f, 1f, t); t += step } + // Left edge: (0,150) → (0,0) + for (i in 1..7) { pts += StrokePoint(0f, 150f - i * 150f / 7, 1f, t); t += step } + // Close point + pts += StrokePoint(1f, 1f, 1f, t); t += step + + if (dwellAtEnd) { + // Add dwell: 5 points clustered at (1,1) spanning 350ms (> DWELL_MS) + for (i in 1..5) { + pts += StrokePoint(1f + i * 0.5f, 1f + i * 0.3f, 1f, t) + t += 70L + } + } + + return pts + } + + @Test fun rectangle_withEndDwell_snapsToShape() { + val pts = makeTimedRectangle(dwellAtEnd = true) + val last = pts.last() + val hasDwell = ArrowDwellDetection.hasDwellAtEnd(pts, last.x, last.y, DWELL_RADIUS, DWELL_MS) + assertTrue("Should detect end dwell", hasDwell) + + // Since dwell is present, shape detection should proceed + val xs = FloatArray(pts.size) { pts[it].x } + val ys = FloatArray(pts.size) { pts[it].y } + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertNotNull("Rectangle with dwell should snap", result) + assertTrue("Should snap to Rectangle, got $result", + result is ShapeSnapDetection.SnapResult.Rectangle) + } + + @Test fun rectangle_withoutEndDwell_doesNotSnap() { + val pts = makeTimedRectangle(dwellAtEnd = false) + val last = pts.last() + val hasDwell = ArrowDwellDetection.hasDwellAtEnd(pts, last.x, last.y, DWELL_RADIUS, DWELL_MS) + assertFalse("Should NOT detect end dwell", hasDwell) + + // The shape IS a valid rectangle geometrically... + val xs = FloatArray(pts.size) { pts[it].x } + val ys = FloatArray(pts.size) { pts[it].y } + val shapeResult = ShapeSnapDetection.detect(xs, ys, LS) + assertNotNull("Shape IS a rectangle geometrically", shapeResult) + + // ...but the dwell gate blocks snapping + val gatedResult = if (hasDwell) shapeResult else null + assertNull("Without dwell, shape snapping should not activate", gatedResult) + } + + // ── Elbow detection ───────────────────────────────────────────────────── + + /** Build an L-shaped stroke from (x0,y0) → corner → (x1,y1) with n points per leg. */ + private fun makeElbow( + x0: Float, y0: Float, cx: Float, cy: Float, x1: Float, y1: Float, + pointsPerLeg: Int = 15 + ): Pair { + val xs = mutableListOf() + val ys = mutableListOf() + for (i in 0 until pointsPerLeg) { + val t = i.toFloat() / pointsPerLeg + xs += x0 + (cx - x0) * t + ys += y0 + (cy - y0) * t + } + for (i in 0..pointsPerLeg) { + val t = i.toFloat() / pointsPerLeg + xs += cx + (x1 - cx) * t + ys += cy + (y1 - cy) * t + } + return xs.toFloatArray() to ys.toFloatArray() + } + + @Test fun rightAngleElbow_horizontal_then_vertical_snaps() { + // L-shape: (0,0) → (200,0) → (200,200) + val (xs, ys) = makeElbow(0f, 0f, 200f, 0f, 200f, 200f) + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertNotNull("L-shaped stroke should snap to Elbow", result) + assertTrue("Should be Elbow, got $result", result is ShapeSnapDetection.SnapResult.Elbow) + val elbow = result as ShapeSnapDetection.SnapResult.Elbow + assertEquals(0f, elbow.x1, 1f) + assertEquals(0f, elbow.y1, 1f) + assertEquals(200f, elbow.x2, 1f) + assertEquals(200f, elbow.y2, 1f) + } + + @Test fun rightAngleElbow_vertical_then_horizontal_snaps() { + // L-shape: (0,0) → (0,200) → (200,200) + val (xs, ys) = makeElbow(0f, 0f, 0f, 200f, 200f, 200f) + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertNotNull("Vertical-then-horizontal L should snap to Elbow", result) + assertTrue("Should be Elbow, got $result", result is ShapeSnapDetection.SnapResult.Elbow) + } + + @Test fun elbowWithAcuteAngle_doesNotSnap() { + // Angle < 60° — too sharp for an elbow + // V-shape: (0,0) → (100,200) → (50,0) + val (xs, ys) = makeElbow(0f, 0f, 100f, 200f, 50f, 0f) + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertTrue("Acute angle should not snap to Elbow, got $result", + result !is ShapeSnapDetection.SnapResult.Elbow) + } + + @Test fun straightLine_doesNotSnapToElbow() { + val xs = FloatArray(20) { it * 15f } + val ys = FloatArray(20) { 100f } + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertTrue("Straight line must not snap to Elbow, got $result", + result !is ShapeSnapDetection.SnapResult.Elbow) + } + + // ── Arc detection ────────────────────────────────────────────────────── + + /** Build a circular arc stroke from startAngle to endAngle (radians) with given center and radius. */ + private fun makeArcStroke( + cx: Float, cy: Float, radius: Float, + startAngle: Double, endAngle: Double, + n: Int = 30 + ): Pair { + val xs = FloatArray(n + 1) { i -> + val angle = startAngle + (endAngle - startAngle) * i / n + (cx + radius * cos(angle)).toFloat() + } + val ys = FloatArray(n + 1) { i -> + val angle = startAngle + (endAngle - startAngle) * i / n + (cy + radius * sin(angle)).toFloat() + } + return xs to ys + } + + @Test fun semicircularArc_snapsToArc() { + // Half circle: 180° arc, radius 100 + val (xs, ys) = makeArcStroke(100f, 100f, 100f, 0.0, PI, 30) + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertNotNull("Semicircular arc should snap", result) + assertTrue("Semicircle should snap to Arc, got $result", + result is ShapeSnapDetection.SnapResult.Arc) + } + + @Test fun quarterCircleArc_snapsToArc() { + // Quarter circle: 90° arc, radius 150 + val (xs, ys) = makeArcStroke(0f, 0f, 150f, 0.0, PI / 2, 25) + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertNotNull("Quarter-circle arc should snap", result) + assertTrue("Quarter-circle should snap to Arc, got $result", + result is ShapeSnapDetection.SnapResult.Arc) + } + + @Test fun shallowArc_snapsToArc() { + // Shallow arc: 60° sweep, radius 200 + val (xs, ys) = makeArcStroke(0f, 0f, 200f, -PI / 6, PI / 6, 25) + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertNotNull("Shallow arc should snap", result) + assertTrue("Shallow arc should snap to Arc, got $result", + result is ShapeSnapDetection.SnapResult.Arc) + } + + @Test fun closedLoop_doesNotSnapToArc() { + // Full circle — closed, should not snap to arc + val n = 40 + val xs = FloatArray(n + 1) { i -> (100 + 100 * cos(2 * PI * i / n)).toFloat() } + val ys = FloatArray(n + 1) { i -> (100 + 100 * sin(2 * PI * i / n)).toFloat() } + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertTrue("Closed loop must not snap to Arc, got $result", + result !is ShapeSnapDetection.SnapResult.Arc) + } + + @Test fun straightLine_doesNotSnapToArc() { + val xs = FloatArray(20) { it * 15f } + val ys = FloatArray(20) { 100f } + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertTrue("Straight line must not snap to Arc, got $result", + result !is ShapeSnapDetection.SnapResult.Arc) + } + + @Test fun selfReferentialArc_shortChord_snapsToArc() { + // Arc from one side of a circle to another — chord is short (80px) + // but the arc extends 120px outward. + val (xs2, ys2) = makeArcStroke(0f, 0f, 100f, -PI / 8, PI / 8, 25) + // chord ≈ 76.5px < LS (118px), but diagonal ≈ 100px > 59px + val result = ShapeSnapDetection.detect(xs2, ys2, LS) + assertNotNull("Short-chord self-referential arc should snap", result) + assertTrue("Should be Arc, got $result", + result is ShapeSnapDetection.SnapResult.Arc) + } + + @Test fun selfReferentialArc_realPenFixture_snapsToArc() { + // Downsampled real pen data: U-shaped arc from one side of a circle to another. + // Start ≈ (651, 2629), loops right to (721, 2653), then left-down to (656, 2567), + // back up to (656, 2584). Chord ≈ 43px, diagonal ≈ 111px. + // Originally 553 points; downsampled every ~18th point for the moving portion. + val xs = floatArrayOf( + 651.0f, 652.4f, 653.6f, 655.0f, 657.4f, 660.5f, 664.7f, + 668.7f, 673.2f, 677.8f, 682.3f, 686.5f, 690.3f, 695.4f, + 700.6f, 705.5f, 710.5f, 714.4f, 717.8f, 720.0f, 721.2f, + 721.2f, 720.4f, 717.2f, 713.1f, 709.9f, 706.3f, 702.4f, + 698.4f, 694.4f, 690.9f, 686.9f, 684.5f, 680.4f, 676.2f, + 672.0f, 668.1f, 665.7f, 660.3f, 656.2f + ) + val ys = floatArrayOf( + 2629.0f, 2635.2f, 2638.8f, 2641.3f, 2643.3f, 2646.5f, 2649.2f, + 2650.8f, 2652.0f, 2653.0f, 2653.6f, 2653.6f, 2652.6f, 2649.2f, + 2646.1f, 2642.9f, 2639.3f, 2635.4f, 2630.6f, 2625.9f, 2620.5f, + 2614.0f, 2608.0f, 2601.7f, 2596.2f, 2591.0f, 2585.3f, 2580.5f, + 2575.8f, 2572.0f, 2569.2f, 2567.0f, 2566.8f, 2567.4f, 2568.8f, + 2572.0f, 2575.4f, 2576.7f, 2580.7f, 2584.1f + ) + val result = ShapeSnapDetection.detect(xs, ys, 77f) + assertNotNull("Real pen self-referential arc should snap, got null", result) + assertTrue("Should be Arc or Curve, got $result", + result is ShapeSnapDetection.SnapResult.Arc || + result is ShapeSnapDetection.SnapResult.Curve) + } + + @Test fun zigzag_doesNotSnapToArc() { + // Zigzag: has corners, should not snap to arc + val xs = mutableListOf() + val ys = mutableListOf() + for (i in 0..30) { + xs += i * 10f + ys += if (i % 2 == 0) 0f else 50f + } + val result = ShapeSnapDetection.detect(xs.toFloatArray(), ys.toFloatArray(), LS) + assertTrue("Zigzag must not snap to Arc, got $result", + result !is ShapeSnapDetection.SnapResult.Arc) + } + + // ── Self-loop detection ────────────────────────────────────────────────── + + @Test fun nearCompleteCircle_snapsToSelfLoop() { + // 300° arc (5/6 of a circle), radius 100 + val n = 40 + val sweepRad = 300.0 * PI / 180.0 + val xs = FloatArray(n + 1) { i -> (100 + 100 * cos(sweepRad * i / n)).toFloat() } + val ys = FloatArray(n + 1) { i -> (100 + 100 * sin(sweepRad * i / n)).toFloat() } + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertNotNull("300° arc should snap to SelfLoop", result) + assertTrue("Should be SelfLoop, got $result", + result is ShapeSnapDetection.SnapResult.SelfLoop) + } + + @Test fun nearCompleteOval_snapsToSelfLoop() { + // 300° oval arc, rx=150, ry=80 + val n = 40 + val sweepRad = 300.0 * PI / 180.0 + val xs = FloatArray(n + 1) { i -> (150 + 150 * cos(sweepRad * i / n)).toFloat() } + val ys = FloatArray(n + 1) { i -> (80 + 80 * sin(sweepRad * i / n)).toFloat() } + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertNotNull("300° oval should snap to SelfLoop", result) + assertTrue("Should be SelfLoop, got $result", + result is ShapeSnapDetection.SnapResult.SelfLoop) + } + + @Test fun fullClosedCircle_doesNotSnapToSelfLoop() { + // Full closed circle should snap to Ellipse, not SelfLoop + val n = 40 + val xs = FloatArray(n + 1) { i -> (100 + 100 * cos(2 * PI * i / n)).toFloat() } + val ys = FloatArray(n + 1) { i -> (100 + 100 * sin(2 * PI * i / n)).toFloat() } + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertTrue("Full circle should be Ellipse not SelfLoop, got $result", + result is ShapeSnapDetection.SnapResult.Ellipse) + } + + @Test fun halfCircle_doesNotSnapToSelfLoop() { + // 180° arc — gap too large for self-loop (ratio > 0.75) + val (xs, ys) = makeArcStroke(100f, 100f, 100f, 0.0, PI, 30) + val result = ShapeSnapDetection.detect(xs, ys, LS) + assertTrue("Half circle should not be SelfLoop, got $result", + result !is ShapeSnapDetection.SnapResult.SelfLoop) + } + + @Test fun zigzagNearClosed_doesNotSnapToSelfLoop() { + // Near-closed zigzag: has corners, should not be SelfLoop + val xs = mutableListOf() + val ys = mutableListOf() + for (i in 0..30) { + xs += 100f + 80f * cos(2 * PI * i / 30).toFloat() + if (i % 2 == 0) 20f else -20f + ys += 100f + 80f * sin(2 * PI * i / 30).toFloat() + } + val result = ShapeSnapDetection.detect(xs.toFloatArray(), ys.toFloatArray(), LS) + assertTrue("Zigzag near-closed loop must not snap to SelfLoop, got $result", + result !is ShapeSnapDetection.SnapResult.SelfLoop) + } +} diff --git a/app/src/test/java/com/writer/view/UndoGestureDetectionTest.kt b/app/src/test/java/com/writer/view/UndoGestureDetectionTest.kt new file mode 100644 index 0000000..82c2629 --- /dev/null +++ b/app/src/test/java/com/writer/view/UndoGestureDetectionTest.kt @@ -0,0 +1,171 @@ +package com.writer.view + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Unit tests for [UndoGestureDetection]. + * + * Uses a fixed line spacing of 118 px (63 dp × 1.875 density — standard devices). + * Tests reference [UndoGestureDetection] constants directly so they remain valid + * when threshold values are tuned. + * + * ## Box-drawing geometry at 300 PPI (1.875 density) + * + * Line spacing = 118 px ≈ 10 mm + * HORIZONTAL_MIN_SPANS = 1.5 → trigger threshold ≈ 177 px ≈ 15 mm + * VERTICAL_ACTIVATION_SPANS = 1.0 → activation threshold ≈ 118 px ≈ 10 mm + * + * Natural horizontal stroke drift before pen-lift: < 5 mm (< 59 px). + * A deliberate undo stroke goes horizontal then curves > 10 mm vertically. + */ +class UndoGestureDetectionTest { + + companion object { + private const val LS = 118f // line spacing in px (63 dp × 1.875) + // ~11 dp × 1.875 density, same as ScreenMetrics.dp(11f) on standard devices + private const val STEP = 21f + + private val H_THRESH get() = UndoGestureDetection.HORIZONTAL_MIN_SPANS * LS + private val V_THRESH get() = UndoGestureDetection.VERTICAL_ACTIVATION_SPANS * LS + } + + // ── isHorizontalTrigger — strokes that SHOULD qualify ──────────────────── + + @Test fun wideFlat_qualifiesAsTrigger() { + assertTrue(UndoGestureDetection.isHorizontalTrigger( + xRange = H_THRESH * 2f, yRange = LS * 0.1f, lineSpacing = LS + )) + } + + @Test fun justAboveHorizontalThreshold_qualifiesAsTrigger() { + assertTrue(UndoGestureDetection.isHorizontalTrigger( + xRange = H_THRESH + 1f, yRange = LS * 0.05f, lineSpacing = LS + )) + } + + // ── isHorizontalTrigger — strokes that should NOT qualify ───────────────── + + @Test fun belowHorizontalThreshold_doesNotTrigger() { + assertFalse(UndoGestureDetection.isHorizontalTrigger( + xRange = H_THRESH - 1f, yRange = LS * 0.05f, lineSpacing = LS + )) + } + + @Test fun exactlyAtHorizontalThreshold_doesNotTrigger() { + assertFalse(UndoGestureDetection.isHorizontalTrigger( + xRange = H_THRESH, yRange = LS * 0.05f, lineSpacing = LS + )) + } + + @Test fun wideButWobbly_doesNotTrigger() { + val xRange = H_THRESH * 2f + val yRange = xRange * UndoGestureDetection.HORIZONTAL_MAX_DRIFT // at limit + assertFalse(UndoGestureDetection.isHorizontalTrigger(xRange, yRange, LS)) + } + + @Test fun diagonal_doesNotTrigger() { + assertFalse(UndoGestureDetection.isHorizontalTrigger( + xRange = H_THRESH * 2f, yRange = H_THRESH * 2f, lineSpacing = LS + )) + } + + // ── isVerticalActivation — movements that SHOULD activate scrub ────────── + + @Test fun largeVerticalDown_activates() { + assertTrue(UndoGestureDetection.isVerticalActivation(V_THRESH * 2f, LS)) + } + + @Test fun largeVerticalUp_activates() { + assertTrue(UndoGestureDetection.isVerticalActivation(-V_THRESH * 2f, LS)) + } + + @Test fun justAboveVerticalThreshold_activates() { + assertTrue(UndoGestureDetection.isVerticalActivation(V_THRESH + 1f, LS)) + } + + // ── isVerticalActivation — small dips that should NOT activate ──────────── + + @Test fun smallDip_doesNotActivate() { + // Natural pen drift well below the activation threshold + assertFalse(UndoGestureDetection.isVerticalActivation(V_THRESH * 0.4f, LS)) + } + + @Test fun exactlyAtVerticalThreshold_doesNotActivate() { + assertFalse(UndoGestureDetection.isVerticalActivation(V_THRESH, LS)) + } + + @Test fun zeroDip_doesNotActivate() { + assertFalse(UndoGestureDetection.isVerticalActivation(0f, LS)) + } + + // ── detect() — post-stroke L-shape ─────────────────────────────────────── + + @Test fun lShape_downward_detectsPositiveOffset() { + val result = UndoGestureDetection.detect( + firstY = 0f, lastY = V_THRESH * 2f, xRange = H_THRESH * 2f, + lineSpacing = LS, stepSize = STEP + ) + assertNotNull(result) + assertTrue("Downward L-shape → positive scrub offset (redo dir)", result!! > 0) + } + + @Test fun lShape_upward_detectsNegativeOffset() { + val result = UndoGestureDetection.detect( + firstY = LS * 5f, lastY = LS * 5f - V_THRESH * 2f, xRange = H_THRESH * 2f, + lineSpacing = LS, stepSize = STEP + ) + assertNotNull(result) + assertTrue("Upward L-shape → negative scrub offset (undo dir)", result!! < 0) + } + + @Test fun lShape_offsetProportionalToVerticalTravel() { + val yDelta = V_THRESH * 3f + val result = UndoGestureDetection.detect( + firstY = 0f, lastY = yDelta, xRange = H_THRESH * 2f, + lineSpacing = LS, stepSize = STEP + ) + val expected = (yDelta / STEP).toInt() + assertEquals("Scrub offset = yDelta / stepSize truncated", expected, result) + } + + // ── detect() — strokes that must NOT fire ───────────────────────────────── + + @Test fun pureHorizontal_naturalDrift_notDetected() { + // A horizontal stroke with tiny net vertical displacement (natural hand drift) + val result = UndoGestureDetection.detect( + firstY = 200f, lastY = 210f, // only 10 px drift — well under V_THRESH + xRange = H_THRESH * 2f, + lineSpacing = LS, stepSize = STEP + ) + assertNull("Horizontal stroke with tiny drift is not an undo gesture", result) + } + + @Test fun pureVertical_notDetected() { + val result = UndoGestureDetection.detect( + firstY = 0f, lastY = V_THRESH * 3f, xRange = LS * 0.3f, + lineSpacing = LS, stepSize = STEP + ) + assertNull("Pure vertical stroke not an undo gesture", result) + } + + @Test fun tooNarrow_notDetected() { + val result = UndoGestureDetection.detect( + firstY = 0f, lastY = V_THRESH * 2f, xRange = H_THRESH - 1f, + lineSpacing = LS, stepSize = STEP + ) + assertNull("Stroke narrower than horizontal threshold not an undo gesture", result) + } + + @Test fun notEnoughVertical_notDetected() { + val result = UndoGestureDetection.detect( + firstY = 0f, lastY = V_THRESH - 1f, xRange = H_THRESH * 2f, + lineSpacing = LS, stepSize = STEP + ) + assertNull("Stroke with insufficient vertical displacement not an undo gesture", result) + } +} diff --git a/docs/engineering-design.md b/docs/engineering-design.md new file mode 100644 index 0000000..1be3904 --- /dev/null +++ b/docs/engineering-design.md @@ -0,0 +1,352 @@ +# InkUp — Engineering Design + +## Overview + +InkUp is an Android application targeting Onyx Boox e-ink tablets. It provides a ruled handwriting canvas where the user writes naturally with a stylus; as lines scroll off the top of the canvas they are recognized using Google ML Kit Digital Ink Recognition and displayed as formatted text in a panel above. The result is a distraction-free note-taking workflow where ink becomes text without any explicit recognition step. + +**Target platform:** Android 10+ (API 29+), Onyx Boox devices with e-ink display +**Language:** Kotlin +**Build system:** Gradle (Kotlin DSL) +**Min SDK:** 29 · Target/Compile SDK: 34 + +--- + +## High-Level Architecture + +The app is organized into four layers: + +``` +┌─────────────────────────────────────────────────────┐ +│ UI Layer │ +│ WritingActivity · DocumentListActivity · SaveAs │ +├─────────────────────────────────────────────────────┤ +│ Coordinator Layer │ +│ WritingCoordinator · GestureHandler │ +│ ParagraphBuilder · UndoManager · TutorialManager │ +├─────────────────────────────────────────────────────┤ +│ Recognition & View Layer │ +│ GoogleMLKitTextRecognizer · LineSegmenter │ +│ StrokeClassifier · ModelManager │ +│ HandwritingCanvasView · RecognizedTextView │ +├─────────────────────────────────────────────────────┤ +│ Model & Storage Layer │ +│ DocumentModel · DocumentData · InkStroke │ +│ StrokePoint · InkLine · DocumentStorage │ +└─────────────────────────────────────────────────────┘ +``` + +--- + +## Package Structure + +``` +com.writer +├── model/ +│ ├── StrokePoint.kt — x, y, pressure, timestamp for a single pen sample +│ ├── InkStroke.kt — ordered list of StrokePoints, UUID, strokeWidth +│ ├── StrokeExtensions.kt — computed properties: bounds, pathLength, diagonal, shiftY +│ ├── InkLine.kt — group of strokes on one ruled line + bounding box +│ ├── DocumentModel.kt — runtime document state (active strokes, language) +│ └── DocumentData.kt — serializable snapshot used for persistence +│ +├── recognition/ +│ ├── ModelManager.kt — downloads/caches ML Kit language models +│ ├── GoogleMLKitTextRecognizer.kt — wraps ML Kit, recognizes InkLine → text +│ ├── LineSegmenter.kt — maps strokes to line indices, builds InkLines +│ └── StrokeClassifier.kt — detects list-marker and underline (heading) strokes +│ +├── ui/ +│ ├── writing/ +│ │ ├── WritingActivity.kt — single main activity; lifecycle, menus, doc ops +│ │ ├── WritingCoordinator.kt — orchestrates recognition, scroll, text sync, undo +│ │ ├── GestureHandler.kt — strikethrough-to-delete and heading-underline logic +│ │ ├── ParagraphBuilder.kt — groups lines into paragraphs with formatting hints +│ │ ├── UndoManager.kt — stack-based undo/redo with gesture-scrub API +│ │ ├── TutorialManager.kt — interactive tutorial overlay lifecycle +│ │ ├── TutorialContent.kt — generates tutorial strokes and annotations +│ │ └── SaveAsActivity.kt — handwriting-based document rename dialog +│ └── documents/ +│ └── DocumentListActivity.kt — launcher stub (currently redirects to WritingActivity) +│ +├── view/ +│ ├── HandwritingCanvasView.kt — SurfaceView ink canvas with Onyx SDK integration +│ ├── RecognizedTextView.kt — custom View for formatted recognized-text display +│ ├── CanvasTheme.kt — paint/color constants for e-ink rendering +│ └── HandwritingNameInput.kt — handwriting input widget for document naming +│ +├── storage/ +│ └── DocumentStorage.kt — JSON CRUD, sync folder export (SAF), migration +│ +└── WriterApplication.kt — Application subclass (Onyx hidden-API bypass init) +``` + +--- + +## Core Data Model + +### StrokePoint +Single digitizer sample: `x`, `y`, `pressure` (Float), `timestamp` (Long ms). Coordinates are in **document space** — the absolute position in the infinite scrollable document, not screen space. + +### InkStroke +An immutable sequence of `StrokePoint`s representing one pen-down to pen-up motion. Has a UUID `strokeId` for identity tracking, `strokeWidth`, and derived `startTime`/`endTime`. Extension properties (`minX`, `maxX`, `minY`, `maxY`, `xRange`, `yRange`, `pathLength`, `diagonal`) are computed lazily. + +### InkLine +A group of strokes that belong to the same ruled line, plus a bounding box computed from all their points. Used as the unit of input to the recognizer. + +### DocumentModel +Runtime-only state: the mutable list of `activeStrokes` currently in the document, plus the language tag for recognition. + +### DocumentData +The full serializable snapshot of a document: +- `strokes` — all ink strokes +- `scrollOffsetY` — current scroll position +- `lineTextCache` — `Map` +- `everHiddenLines` — set of line indices that have scrolled above the viewport +- `highestLineIndex`, `currentLineIndex` — cursor tracking +- `userRenamed` — whether the user has manually named the document + +--- + +## Line-Based Canvas Model + +The canvas is a vertically-infinite ruled sheet. Lines are evenly spaced: + +``` +TOP_MARGIN = 40px +LINE_SPACING = 128px (~0.43" at 300 ppi) +GUTTER_WIDTH = 144px (right-edge scroll strip) +``` + +A stroke's **line index** is determined solely by the Y coordinate of its **first point**: + +``` +lineIndex = floor((firstPoint.y - TOP_MARGIN) / LINE_SPACING) +``` + +Using the starting point (not the centroid or bounding-box top) means descenders (g, y, p) on one line don't bleed into the line below. + +Coordinate transforms: +- Screen Y → Document Y: add `scrollOffsetY` +- Document Y → Screen Y: subtract `scrollOffsetY` +- Line index → Document Y (top of line): `TOP_MARGIN + lineIndex * LINE_SPACING` + +--- + +## Input Pipeline + +### Onyx SDK Path (Boox devices) +`HandwritingCanvasView` uses the Onyx `TouchHelper` / `RawInputCallback` API for hardware-accelerated e-ink pen rendering. The SDK renders strokes directly to the display at low latency without involving the Android canvas pipeline. The app receives callbacks: + +1. `onBeginRawDrawing` — pen down; clears in-progress buffer +2. `onRawDrawingTouchPointMoveReceived` — per-point move; gesture detection runs here +3. `onEndRawDrawing` — pen up; assembles `InkStroke`, fires `onStrokeCompleted` + +The SDK is paused/resumed around scroll operations and interactive gestures to avoid conflicts. The limit rect excludes the right gutter so gutter touches are handled by `onTouchEvent` instead. + +### Fallback Path (non-Boox / emulator) +Standard `MotionEvent` in `onTouchEvent`. Points are collected, gestures checked, and strokes assembled identically to the SDK path. The current stroke is drawn to the Canvas directly during the move phase. + +### Gutter Touch +Pen or mouse in the right `GUTTER_WIDTH` strip is handled separately: vertical drag scrolls the canvas (`scrollOffsetY`), and once at maximum canvas scroll, further downward drag overscrolls into the text pane (`textOverscroll`). + +--- + +## Gesture System + +Three gesture types are detected inside `HandwritingCanvasView` during stroke collection, before the stroke is committed to the document: + +### Gutter Scroll +Continuous vertical drag in the gutter. No threshold — activates immediately on `ACTION_DOWN` within the gutter. Snaps to line boundaries on `ACTION_UP`. + +### Line-Drag Gesture +- **Trigger:** vertical stroke spanning ≥ 1 line spacing with horizontal drift ≤ 30% of vertical span +- **Effect:** lifts all strokes from the anchor line downward and repositions them vertically by whole-line increments. Upward drag can delete lines by merging them. +- Implementation: SDK is disabled during the gesture; `onTouchEvent` receives subsequent moves. `WritingCoordinator` receives `onLineDragStart/Step/End` callbacks and updates `DocumentModel` directly. + +### Undo/Redo Scrub Gesture +- **Phase 1 (horizontal):** stroke spanning ≥ 1.5 line spacings horizontally with vertical drift ≤ 20% sets `undoGestureReady` +- **Phase 2 (vertical):** subsequent vertical movement ≥ 0.75 line spacings activates `undoScrubActive` +- **Effect:** vertical position maps linearly to a timeline position in `UndoManager.scrubTimeline`. Moving down undoes; moving up redoes. Releasing commits the chosen position. + +### Strikethrough (via GestureHandler) +Detected *after* stroke completion (in `WritingCoordinator`, before adding to `DocumentModel`): +- Wide flat stroke (xRange ≥ 100px, yRange < 30% of xRange) +- Horizontal — starts and ends on same line index +- Not a heading underline (which starts in bottom 20% of line and spans ≥ 80% of text width) +- **Effect:** all overlapping strokes on that line are removed from `DocumentModel` and canvas + +--- + +## Recognition Pipeline + +### Model Lifecycle +`ModelManager` uses `RemoteModelManager` (ML Kit) to download language models on demand. `GoogleMLKitTextRecognizer` holds one `DigitalInkRecognizer` instance per language session and reuses it across all recognition calls. + +### Eager Recognition +Recognition is triggered eagerly — not on demand. The coordinator uses these triggers: + +| Event | Action | +|---|---| +| User moves pen to a different line | Recognize previous line (`eagerRecognizeLine`) | +| Idle timeout (2 s after last stroke) | Recognize current line | +| Stroke on a line that has been rendered to text | Re-recognize that line immediately | +| App startup with existing strokes | Recognize all lines with missing/failed cache entries | +| Line scrolls above viewport | Recognize if not yet cached | + +Recognition runs on `Dispatchers.IO` (`recognizer.recognizeLine`), but all cache mutations happen on the main thread (`Dispatchers.Main` via `lifecycleScope`). A `recognizingLines` set prevents duplicate concurrent recognitions; `pendingRerecognize` queues a follow-up if a line changes while it is being recognized. + +### Pre-Context +Each recognition call includes up to 20 characters of preceding recognized text as `preContext`. This improves accuracy for words that depend on prior context (e.g. proper nouns, punctuation). + +### Stroke Classification (pre-recognition filtering) +Before passing strokes to ML Kit, `StrokeClassifier` identifies and removes two special stroke types: + +**List Marker:** a short, flat, simple horizontal stroke on the far left (within 10.5% of writing width) with a gap of ≥ 20px before the next stroke. Indicates a list item; filtered out to prevent the recognizer from seeing it as a letter. + +**Underline (Heading):** a long, flat horizontal stroke in the lower half of the line spanning ≥ 80% of the text width. Indicates a heading. Filtered from recognition, but preserved in `DocumentData` for paragraph formatting. + +Both checks use a **path simplicity** gate: `pathLength / diagonal ≤ 2.0`. This rejects strokes that trace back on themselves (e.g. a letter "s") that would otherwise match the geometric criteria. + +--- + +## Text Display Synchronization + +### Viewport-Based Reveal +Text is only shown for lines that have **ever** scrolled above the viewport midpoint (`everHiddenLines`). This set is monotonically growing (except when strokes are deleted). As the user scrolls down, lines disappear from canvas and appear as text in the text panel above. + +### Scroll Offset Sync +The text panel scroll offset (`textScrollOffset`) is computed so that the text panel scrolls smoothly in sync with the canvas, creating the illusion that ink flows up into text. For each written line, its rendered text height contributes to the offset proportionally based on how far its successor line has scrolled below the viewport. + +### Text Overscroll +When the canvas is already scrolled to its maximum useful position, further downward gutter drag increases `textOverscroll`, which shifts the text content upward inside `RecognizedTextView` so the user can read earlier text. + +### Paragraph Formation +`ParagraphBuilder` groups `LineInfo` objects (classified lines) into paragraphs: + +A paragraph break occurs when: +- The line is a list item (`isList`) +- The line is a heading (`isHeading`) +- The previous line was a heading (heading always stands alone) +- The line is indented (leftmost X > 10.5% of writing width) and the previous was not a list +- The previous paragraph was a list and this line is not + +`RecognizedTextView` renders paragraphs as `StaticLayout` instances with: +- Body text: 64px, first-line indent 80px +- List items: bullet prefix `•`, hanging indent +- Headings: 1.3× size, bold, no indent +- Dimming: lines not yet in `notYetVisible` (i.e. partially visible) render in a light grey + +--- + +## Undo/Redo + +`UndoManager` maintains two `ArrayDeque` stacks (max 50 entries). A `Snapshot` captures the full document state: all strokes, scroll offset, and the recognized text cache. Snapshots are saved: +- Before any stroke is added to the model +- Before a gesture mutation (strikethrough, line-drag) + +The **scrub API** flattens both stacks into a single timeline for gesture-based navigation: + +``` +[ oldest_undo, ..., newest_undo, CURRENT, nearest_redo, ..., furthest_redo ] +``` + +On `endScrub`, the stacks are rebuilt from the timeline split at the final scrub position. + +--- + +## Document Storage + +### Format +Documents are stored as JSON files in `/documents/.json`. The JSON schema includes: + +```json +{ + "strokes": [{ "strokeId", "strokeWidth", "points": [{ "x", "y", "pressure", "timestamp" }] }], + "scrollOffsetY": 0.0, + "highestLineIndex": 5, + "currentLineIndex": 5, + "userRenamed": false, + "lineTextCache": { "0": "Hello", "1": "World" }, + "everHiddenLines": [0, 1] +} +``` + +### Document Naming +New documents get a date-based name (`Document YYYY-MM-DD`). If the first line has an underline (heading marker) and the user has not manually renamed the document, `WritingCoordinator` fires `onHeadingDetected` and the activity renames the file to the heading text (sanitized, max 80 chars). + +### Sync Folder Export +Via the Storage Access Framework (SAF), users can designate a folder for export. On every save, two files are written: +- `.writer` — the full JSON (same as internal storage) +- `.md` — markdown export: headings prefixed `## `, list items prefixed `- `, body text joined with spaces + +### Migration +On first launch, the legacy single-file `document.json` is migrated to `documents/Document 1.json`. + +--- + +## UI Layout + +``` +┌─────────────────────────────────────────┬───────┐ +│ │ │ +│ RecognizedTextView │ │ +│ (recognized text, bottom-aligned) │ │ +│ │ Gutter│ +├─────────────────────────────────────────┤ │ +│ │ drag │ +│ HandwritingCanvasView │ to │ +│ (ruled ink canvas, SurfaceView) │ resize│ +│ │ │ +└─────────────────────────────────────────┴───────┘ +``` + +The split between text and canvas is adjustable by dragging the gutter vertically. The text panel height ranges from its natural size to consuming the full screen. Default split: canvas takes 75% of screen height (configurable by weight in the layout XML). + +The app runs in fully-immersive fullscreen mode (status bar and navigation bar hidden, swiped in transiently). + +--- + +## Tutorial System + +On first launch `TutorialManager` takes over the full screen: +1. Saves current document state and stops the coordinator +2. Expands the text panel to fit the tutorial text +3. Loads pre-built tutorial strokes and annotation overlays into the canvas +4. Shows a "Close Tutorial" button in the text panel and arrow annotations pointing at the gutter +5. On close: restores the original document state, layout, and restarts the coordinator + +Tutorial content is generated programmatically by `TutorialContent` rather than hardcoded assets, so it adapts to the device's screen dimensions. + +--- + +## Key Dependencies + +| Dependency | Version | Purpose | +|---|---|---| +| `com.onyx.android.sdk:onyxsdk-pen` | 1.5.2 | Low-latency e-ink pen input on Boox devices | +| `com.onyx.android.sdk:onyxsdk-device` | 1.3.3 | Device-level Onyx APIs | +| `org.lsposed.hiddenapibypass` | 4.3 | Bypass hidden API restrictions on Android 14+ (required by Onyx SDK) | +| `com.google.mlkit:digital-ink-recognition` | 19.0.0 | On-device handwriting recognition | +| `androidx.room` | 2.6.1 | (Included in deps, not yet used for active storage) | +| `kotlinx-coroutines-android` | 1.8.1 | Async recognition and scroll animation | +| `kotlinx-coroutines-play-services` | 1.8.1 | Converts ML Kit `Task` to coroutine `await()` | +| `androidx.documentfile` | 1.0.1 | SAF sync folder export | + +--- + +## Threading Model + +All mutable state (`lineTextCache`, `everHiddenLines`, `recognizingLines`, `DocumentModel.activeStrokes`) lives on the **main thread**. Recognition work itself runs on `Dispatchers.IO` via `withContext`. The coordinator uses `lifecycleScope` as its coroutine scope, so all work is automatically cancelled when the activity is destroyed. + +There is no ViewModel; `WritingCoordinator` is owned directly by `WritingActivity` and survives only within the activity lifecycle. + +--- + +## Known Design Constraints / Technical Debt + +- **Room dependency declared but unused.** `DocumentStorage` uses plain JSON files; Room is included as a dependency but has no entities or DAOs defined yet. +- **`DocumentListActivity` is a stub.** The launcher activity immediately redirects to `WritingActivity`. Multi-document management is handled via a popup menu inside `WritingActivity`. +- **No background save.** Documents save synchronously on the main thread during `onStop`. Large documents with many strokes may cause a perceptible pause. +- **Pre-context is approximate.** The 20-character pre-context is built from previously recognized lines in the same session. If a line was never recognized (e.g. first launch, no model yet), it contributes nothing to context. +- **Line-drag deletes overwritten content.** When dragging lines upward, any existing strokes in the overwritten zone are silently discarded. There is no conflict resolution. +- **Single language per document.** `DocumentModel.language` is set once at startup (`en-US`) with no UI to change it.