Files
BadNote/docs/plans/2026-06-22-badnote-pen-polish.md
Akiba So 3295018ee3
All checks were successful
CI / Windows build (push) Successful in 11m34s
feat(pen): pressure-responsive width, configurable thinning, native Windows pen (tilt/buttons)
W1 — Custom pen width + pressure sensitivity (Saber-style):
- Root cause of "压感没用": perfect_freehand 1.0.4 IGNORES real stylus pressure
  (hardcodes radius=size/2 when simulatePressure=false) — width never tracked pen
  force. Upgraded perfect_freehand ^1.0.0 -> ^2.0.0 (honors real pressure); migrated
  all 5 getStroke call sites to the 2.x API (PointVector / StrokeOptions / Offset).
- De-hardcoded `thinning` into `kDefaultPenThinning` (0.85), single source shared by
  the on-screen painter and the PDF export path; exposed as PenConfig.pressureSensitivity
  with a Pressure Sensitivity slider; live-applies via a config listener.

W3 — Native Windows pen plugin (tilt + barrel/eraser buttons):
- windows/runner/pen_channel.{h,cpp}: observe WM_POINTER at the TOP of MessageHandler
  (before HandleTopLevelWindowProc, which Flutter uses to consume pen events), read
  GetPointerPenInfo penFlags + tilt, stream over EventChannel('badnote/pen'); non-consuming.
- PenInputService: single latched hardware state (no Win32-pointerId<->event.pointer
  correlation); graceful no-op off-Windows.
- pen_canvas maps barrel/inverted/eraser through PenConfig.sideButton/eraserEnd
  (eraser/undo/toggleTool/pan) and captures tilt into PenPoint.tilt -> EditorPoint.tilt.

W2 — Zoom flicker: page raster isolated in its own RepaintBoundary (safe interim);
definitive crisp-on-zoom fix gated on the on-device root-cause probe (plan M3).

Plans: ralplan-consensus plan at docs/plans/2026-06-22-badnote-pen-polish.md
(Architect APPROVE-WITH-MUST-FIX M1-M4 + Critic ITERATE->APPROVE).

Tests: 58/58 pass incl. shared-thinning invariant + thinning-affects-outline +
tilt-adapter round-trip. flutter analyze clean; linux debug build OK.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 02:10:05 +08:00

135 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# BadNote — Pen-Polish + Native-Pen Addendum (ralplan consensus)
**Status:** APPROVED (ralplan consensus 2026-06-22 — Architect APPROVE-WITH-MUST-FIX M1M4 applied; Critic ITERATE→APPROVE after C1 PenPoint.tilt wrong-model fix + C2 eraser-race re-grounded on M1 native ordering + no-hover-down test)
**Date:** 2026-06-22
**Mode:** DELIBERATE (native Windows plugin = new platform code; can only be device-verified)
**Owner plan file:** `docs/plans/2026-06-22-badnote-pen-polish.md`
**Extends (does NOT supersede):** `docs/plans/2026-06-21-badnote-full-refactor.md`
> **Grounding (read live code 2026-06-22):** `lib/editor/canvas/{pen_canvas,pen_editor_screen,ink_painters,pen_stroke}.dart`,
> `lib/editor/engine/stroke_geometry.dart`, `lib/editor/input/pen_config.dart`,
> `windows/runner/{ocr_channel.cpp,flutter_window.cpp,win32_window.cpp,main.cpp}`,
> `windows/flutter/generated_plugin_registrant.cc`.
---
## 0. Scope (4 user asks, mapped to the roadmap)
| # | User ask (verbatim intent) | Root cause (verified) | Roadmap fit |
| --- | --- | --- | --- |
| **W1** | 添加自定义笔粗; `thinning` 肯定不能写死, 学习 Saber | `penWidth`/`highlighterWidth` already read from `PenConfig` (pen_editor_screen.dart:456459); **but `thinning` is a hardcoded literal `0.85`** in `ink_painters.buildStrokePath` (line 38) AND `stroke_geometry.buildStrokeOutline` (line 52). | Pull **F5** (configurable pen) width+pressure slice forward to **now** |
| **W2** | 缩放的时候会闪一下 (zoom flickers once) | `PdfPageView` lives inside the `InteractiveViewer` child subtree (pen_canvas.dart:354); pdfrx re-rasterizes its page bitmap when the effective scale changes, showing a **one-frame white gap** during the async re-render = the flicker. | This IS **R11 / `pdf/page_tile`** (P0.5); add a **minimal double-buffer fix now**, full fix in P0.5 |
| **W3** | 修好 tilt 和笔按键映射 (fix tilt + pen button mapping) | **No native pen plugin exists** (only `badnote/ocr` MethodChannel). Flutter 3.44 Windows delivers `pressure` but NOT barrel→`buttons`, NOT eraser/inverted→`invertedStylus`, NOT `tilt`. So `_isEraserSignal` (pen_canvas.dart:134) never fires; `event.tilt` is always `0.0`. | **NEW work** the full-refactor plan did not budget: a native Windows pen plugin. Gates F5 button-mapping. |
| **W4** | 继续推进整体重构, 完成我所有的需求 | Roadmap exists & is approved; task #7 (P0 engine+persistence) `in_progress`. | Resume `2026-06-21-badnote-full-refactor.md` P0 → P0.5 → … after W1W3. |
**Non-goals here:** no new layout modes, board, search, server, or CAS (those stay in the parent roadmap's P1P5). This addendum is only the pen-feel polish + the native-pen unblock that the user is blocked on *today*, sequenced so it feeds the parent plan's engine (`stroke_geometry`, `input_arbiter`, `pen_config`) rather than the throwaway live widgets.
---
## 1. RALPLAN-DR
### Principles
1. **Touch the canonical engine, not the live widgets.** Width/thinning changes land in `engine/stroke_geometry.dart` (the single source for screen+export, §2/F1 of the parent plan), so the fix survives the P0 relocation and exports match the screen. Do not fork logic into `ink_painters.dart` only.
2. **No hardcoded feel constants.** `thinning`, `size`, taper, and pressure-sensitivity are `PenConfig` fields with sane defaults — mirroring Saber's `StrokeOptions`-per-pen model.
3. **Native pen is additive + degrades gracefully.** The plugin enriches pointer events with barrel/inverted/tilt; if it is absent or returns nothing, the canvas behaves exactly as today (Flutter pressure still works). Never make drawing depend on the plugin.
4. **Device-gate the un-CI-testable.** Tilt/buttons/flicker can only be confirmed on the Surface; each ships behind a CI package + a manual checklist, never claimed "done" from a green analyze.
### Decision Drivers
1. **D1 — Unblock the user's primary device today** (eraser-end + side-button + tilt are dead; pen feel needs a real size/pressure control).
2. **D2 — Don't derail the approved refactor** — every change feeds `engine/`+`input/`+`pen_config`, not the soon-retired widgets.
3. **D3 — Crisp, flicker-free zoom** without prematurely building the whole multi-resolution tiler (that's P0.5).
### Viable options
**W1 — configurable thinning/size**
- **Option A (CHOSEN): thread `thinning`/`size` as parameters from `PenConfig` through `buildStrokeOutline`/`buildStrokePath`; add `pressureSensitivity` (→thinning) + reuse existing `penWidth`/`highlighterWidth` (→size); Pen-settings sliders.** Pros: matches Saber (`StrokeOptions(size, thinning, …)` per pen); one source of truth; tiny diff. Cons: 2 signatures change + every caller.
- Option B: keep literals, expose only `penWidth`. Rejected — user explicitly says thinning 不能写死.
**W2 — zoom flicker**
- **Option A (CHOSEN, now): double-buffer the page bitmap** — keep the last successfully-rendered page image painted underneath `PdfPageView` (or wrap in a tiny `RawImage` cache) so the async re-render never exposes a white frame; **then** the full R11 `page_tile` in P0.5 replaces it. Pros: kills the visible flicker immediately with a small, localized change; forward-compatible (becomes `page_tile`'s double-buffer). Cons: a stopgop that P0.5 supersedes.
- Option B: jump straight to the full `pdf/page_tile` + `page_tile_cache` now. Rejected for *now* — it's the P0.5 gate; pulling all of it forward derails P0. (We DO confirm the flicker root cause via systematic-debugging before coding either.)
**W3 — native pen (tilt + buttons)**
- **Option A (CHOSEN): in-app Windows plugin in `windows/runner/` (pen_channel.cpp) subclassing the window proc to handle `WM_POINTER*`, call `GetPointerPenInfo`/`GetPointerPenInfoHistory` for `penFlags` (BARREL/INVERTED/ERASER) + `tiltX/tiltY`, key state by `pointerId`, forward to Dart via an `EventChannel('badnote/pen')`.** Dart `PenInputService` exposes the latest per-pointer pen state; `InputArbiter`/canvas reads it to set eraser + tilt. Pros: smallest footprint (mirrors existing `ocr_channel.cpp` pattern); no new pub package; full control of WM_POINTER. Cons: native C++ to maintain; CI-build-only, device-verify-only.
- Option B: standalone federated Flutter plugin package. Rejected — heavier scaffolding for a single-platform need; `ocr_channel.cpp` proves the in-runner pattern works here.
- Option C: wait for Flutter engine to deliver penFlags/tilt upstream. Rejected — unbounded; user blocked now.
### Pre-mortem (3 scenarios)
1. **"Tilt/buttons still dead after the plugin ships."** Cause: WM_POINTER not reaching our handler, or Flutter's own `FlutterWindow` consumes the message first. *Prevention:* before writing the EventChannel, add a WM_POINTER **logging probe** in the window proc and confirm on-device that `GetPointerPenInfo` returns non-zero `penFlags`/tilt (systematic-debugging Phase-1 evidence at the component boundary). Only then wire the channel.
2. **"Zoom flicker fix made scrolling janky / doubled memory."** Cause: keeping a full-res second bitmap per page. *Prevention:* hold exactly ONE last-good image for the *current* page only; drop it on page change; measure frame cost on the Surface before/after.
3. **"Width/thinning change broke export goldens."** Cause: only `ink_painters.dart` was updated, `stroke_geometry.dart` (export path) drifted. *Prevention:* change BOTH via the shared `kDefaultPenThinning`; default stays `0.85` (M4) so the existing golden is unchanged; add a unit test that both builders read identical thinning for the same `PenConfig`. If a golden must be regenerated, name + commit the new baseline explicitly.
4. **"Eraser end doesn't erase on a direct pen-down (no hover)."** Cause: correctness was hung on hover-precedence instead of the M1 native ordering. *Prevention:* re-grounded above on observer-before-`HandleTopLevelWindowProc`; **on-device test: tap the eraser end straight onto the page with no prior hover — first contact must erase, not draw.**
---
## 2. Work items (file-level)
### W1 — Configurable pen width + pressure sensitivity (Saber-style)
**Modify:**
- `lib/editor/input/pen_config.dart` **[MODIFY]** — add `double pressureSensitivity` (maps to perfect_freehand `thinning`; range `[0,1]`, **default `0.85` = the current live value, M4**, so existing stroke feel and the export golden are preserved; `0` = constant width). Keep `penWidth`/`highlighterWidth` as size. Add to `copyWith`/`toJson`/`fromJson`/`==`/`hashCode` + `PenConfigController.setPressureSensitivity` (clamped). Additive persisted field (default-filled on missing key — no migration).
- `lib/editor/engine/stroke_geometry.dart` **[MODIFY]** — define `const double kDefaultPenThinning = 0.85;` (M4 — NOT 0.6; preserves goldens). `buildStrokeOutline(..., {required bool isComplete, double thinning = kDefaultPenThinning})`; remove the `0.85` literal in favour of the named const. Highlighter still forces `0.0`.
- `lib/editor/canvas/ink_painters.dart` **[MODIFY]** — `buildStrokePath(..., {required bool isComplete, double thinning = kDefaultPenThinning})`; import `kDefaultPenThinning` from `stroke_geometry.dart` so screen+export share ONE default and can never diverge.
- `lib/editor/canvas/pen_canvas.dart` **[MODIFY]** — accept a `thinning` field on `PenCanvas`; pass it to the painters (the painters need the value at paint time → pass via the painter constructors `StaticInkPainter`/`LiveInkPainter`, add `shouldRepaint` check on `thinning`).
- `lib/editor/canvas/pen_editor_screen.dart` **[MODIFY]** — feed `_penConfig?.value.pressureSensitivity ?? kDefaultPenThinning` into `PenCanvas.thinning`.
- `lib/editor/ui/pen_settings_page.dart` **[MODIFY]** — add a **pen size** slider (drives `penWidth`, e.g. `0.0020.02`), a **highlighter size** slider, and a **pressure sensitivity** slider (drives `pressureSensitivity` `01`). Live-preview stroke swatch optional.
**Acceptance:** changing pressure-sensitivity to 0 yields constant-width strokes; to ~0.8 makes light/hard press sweep width visibly; pen-size slider changes base width; values persist across restart; a unit test asserts `buildStrokeOutline` and `buildStrokePath` use the same thinning for a given config; export golden still matches screen (R7).
### W2 — Zoom flicker — **ROOT-CAUSE FIRST, fix is probe-gated (M3)**
The flicker fix is NOT pre-committed to a double-buffer. The Architect notes the page is rendered **once** and matrix-scaled (`pen_editor_screen.dart:466474`), so the cause may be **R11-class** (a re-raster swap / matrix blur on zoom-settle) rather than an async white-gap — and the right fix differs per cause.
**Step 1 — Phase-1 probe (systematic-debugging, MANDATORY before any fix):** instrument one zoom on the Surface and determine which of these the "闪一下" is:
- (a) **async white-gap**`PdfPageView` blanks for a frame while it re-rasterizes at the new scale; OR
- (b) **re-raster swap** — pdfrx renders a fresh higher-DPI bitmap and swaps it in (brief tone/size pop) = R11 territory; OR
- (c) **rebuild flash** — a `setState`/`_needsCenter` post-frame callback (`pen_editor_screen.dart:441448`) or `ValueKey(_pageIndex)` re-mounts the subtree.
Record the verdict + a frame capture in the perf-results doc.
**Step 2 — fix chosen by cause:**
- If **(a)**: keep the last good `ui.Image` painted as an underlay for the **current page only**, swapped atomically when the new render is ready (the seed of P0.5 `page_tile`'s double-buffer — link the TODO so it's replaced, not duplicated). **Caveat (Architect): this can race pdfrx's own internal raster cache** — verify the underlay sits *below* `PdfPageView` and is only shown while the live raster is absent.
- If **(b)**: this is R11 — do NOT build a bespoke fix; the cheap interim is "keep the page painted across the swap" and the real fix is the P0.5 `page_tile` DPI-on-settle. Defer, note in the roadmap.
- If **(c)**: remove the spurious rebuild (gate `_needsCenter`, avoid re-keying on zoom) — cheapest of all.
**Acceptance:** root cause documented; pinch-zoom on the Surface shows **no flash**; frame cost unchanged (≤16.6ms median) before/after; if deferred to P0.5, that decision is recorded with evidence (not silently dropped).
### W3 — Native Windows pen plugin (tilt + barrel/eraser → buttons)
**Hook point (MUST-FIX M1):** `FlutterWindow::MessageHandler` calls `flutter_controller_->HandleTopLevelWindowProc(...)` **first** and returns early when Flutter handles the message (`windows/runner/flutter_window.cpp:5664`). Flutter 3.44 itself consumes WM_POINTER to synthesize stylus events, so a `switch` *after* that call (line 66) — and the `ocr_channel` registration site at `:29`**never see consumed pen messages**. The observer therefore reads pen info at the **TOP of `MessageHandler`, before** `HandleTopLevelWindowProc`, observing without consuming (do not `return` a result; fall through so Flutter still processes its event).
**Correlation (MUST-FIX M2):** do NOT build `Map<Win32 pointerId → state>` and join it to Flutter's `event.pointer` — they are **different id spaces**. Only one pen is active at a time, so latch a **single "current stylus hardware state"** natively, update it on every observed WM_POINTER (incl. hover/`WM_POINTERENTER`), and read it at the decision points in Dart.
**Why the eraser decision is NOT racy (re-grounded per Critic #2 — do NOT rely on hover-precedence):** the guarantee is the **M1 native ordering**, not "hover precedes down." A pen that contacts the screen directly with no hover dwell delivers `WM_POINTERDOWN` as its first message. Our observer runs at the **top of `MessageHandler`** (M1), so it latches that down's `penFlags` **before** `HandleTopLevelWindowProc` synthesizes the corresponding Flutter pointer-down — therefore when Dart's `_onPointerDown` reads `PenInputService.current`, the latch already reflects this exact contact. The EventChannel push is async, but the **native latch is updated synchronously in the same window-proc pass that precedes Flutter's event**; so the Dart service must source the eraser flag from a value guaranteed fresh by that ordering (i.e. the channel delivers the down-flags before/with the Dart down event because both originate from the same WM_POINTERDOWN, observer-first). Tilt is per-point and tolerant of one-frame lag. **This must be proven on-device with a pen-down-without-hover test (see pre-mortem #4).**
**Threading (Architect):** the observer runs on the **platform (UI) thread** inside the window proc. Do **NOT** copy `ocr_channel.cpp`'s MTA worker-thread model (`ocr_channel.cpp:73118`) — that pattern is for the long-running OCR call, wrong for low-latency per-event pen state. Latch + post to the channel sink directly on the platform thread.
**Add/modify:**
- `windows/runner/pen_channel.{h,cpp}` **[ADD]** — `ObservePenMessage(message, wparam, lparam)`: if `message ∈ {WM_POINTERENTER, WM_POINTERDOWN, WM_POINTERUPDATE, WM_POINTERUP}`, `GET_POINTERID_WPARAM(wparam)``GetPointerType` → if `PT_PEN`, `GetPointerPenInfo(id, &POINTER_PEN_INFO)`; read `penFlags` (`PEN_FLAG_BARREL`, `PEN_FLAG_INVERTED`, `PEN_FLAG_ERASER`) + `tiltX`/`tiltY`. Latch into a native singleton AND push `{flags, tiltX, tiltY}` over `EventChannel('badnote/pen')`. Returns void; never consumes.
- `windows/runner/flutter_window.cpp` **[MODIFY]** — call `ObservePenMessage(message, wparam, lparam)` at the **top of `MessageHandler` (before** the `HandleTopLevelWindowProc` block, M1); register the channel alongside `RegisterOcrChannel` in `OnCreate` (`:29`).
- `lib/editor/input/pen_input_service.dart` **[ADD]** — listens to `EventChannel('badnote/pen')`; holds the **latest single** `PenHardwareState{barrel, inverted, eraser, tiltX, tiltY}` (not keyed by pointerId, M2). No-op / empty on non-Windows or a silent channel.
- `lib/editor/canvas/pen_stroke.dart` **[MODIFY] (Critic #1 — wrong-model fix):** the live canvas captures `PenPoint{x, y, pressure}` (`pen_stroke.dart:15-21`) which has **no `tilt` field**`EditorPoint.tilt` lives in the *separate* engine model the live widget does not use. Add `double? tilt` to `PenPoint` now, populate it at capture, and map it through `EditorStroke.fromPenStroke``EditorPoint.tilt` (SF1) so tilt is lossless end-to-end on the live path. (When the canvas migrates to `EditorStroke` in P0, `PenPoint` retires and this collapses to `EditorPoint.tilt` directly.)
- `lib/editor/canvas/pen_canvas.dart` **[MODIFY]** — replace the dead `_isEraserSignal` (`pen_canvas.dart:134136`, `kSecondaryButton||invertedStylus` never fires on Windows): on hover/down read `PenInputService.current` and resolve eraser/undo/etc. through the configured `PenConfig.sideButton`/`eraserEnd` mapping; stash `tiltX/Y` into the captured `PenPoint.tilt` (added above).
- `lib/editor/input/pen_config.dart``sideButton`/`eraserEnd` `PenButtonAction` already exist; the canvas now actually consults them for plugin-delivered flags.
**Phase-1 probe before wiring (pre-mortem #1):** first land ONLY a WM_POINTER logging line in the observer and confirm on the Surface that `GetPointerPenInfo` returns non-zero `penFlags`/tilt. Only then add the EventChannel + Dart wiring.
**Acceptance (device-gated):** on the Surface, the eraser end erases; the barrel button performs its mapped `PenButtonAction`; remapping side-button→undo makes the barrel undo; the diagnostic readout shows non-zero `tilt`; with the channel silent the app still draws with pressure (graceful degradation). CI builds the Windows package green; correctness confirmed only on-device + recorded with commit hash.
---
## 3. Sequencing
1. **W1** (pure Dart, CI-testable, low risk) — ship first; unblocks "pen feel" immediately.
2. **W2 investigate → minimal fix** — systematic-debugging Phase 1 evidence, then the double-buffer.
3. **W3 native plugin** — biggest/native; (a) WM_POINTER logging probe → device-confirm `GetPointerPenInfo` returns flags+tilt; (b) EventChannel + Dart service; (c) wire eraser/tilt + `PenConfig` mappings.
4. **W4** — resume the parent roadmap P0 (task #7) → P0.5. W1's `stroke_geometry` change and W3's `pen_input_service` are written to land in `engine/`+`input/` so the P0 relocation absorbs them rather than re-doing them.
Each of W1/W2/W3 is an independently shippable CI package; W2/W3 carry a manual Surface checklist before "done."
---
## 4. ADR
- **Decision:** Pull the **width+pressure-sensitivity** slice of F5 forward (de-hardcode `thinning` in the canonical geometry), fix the zoom flicker with a **double-buffer stopgap** that seeds the P0.5 `page_tile`, and add a **native Windows pen plugin** (`pen_channel.cpp` + `EventChannel('badnote/pen')`) to recover barrel/eraser/tilt that the Flutter 3.44 engine drops — all feeding the approved `engine/`/`input/` seams, then resume the parent roadmap.
- **Drivers:** D1 unblock primary device today; D2 don't derail the refactor; D3 flicker-free zoom without prematurely building the full tiler.
- **Alternatives:** literals-only width (rejected — user), full `page_tile` now (deferred to P0.5), federated plugin package (rejected — `ocr_channel` in-runner pattern suffices), wait-for-upstream (rejected — blocked now).
- **Consequences:** one new native file (Windows-only, device-gated); a temporary page double-buffer superseded by P0.5; `PenConfig` gains `pressureSensitivity`; `thinning` defaults centralized as `kDefaultPenThinning`.
- **Follow-ups:** W2 stopgap deleted when `page_tile` lands; W3 eraser/tilt wiring moves into `input_arbiter` during P0; confirm `GetPointerPenInfo` tilt units + sign on-device.