Files
BadNote/docs/plans/2026-06-22-badnote-pen-polish.md
T
akibaandClaude Opus 4.8 3295018ee3
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

19 KiB
Raw Blame History

BadNote — Pen-Polish + Native-Pen Addendum (ralplan consensus)

Status: APPROVED (ralplan consensus 2026-06-22 — Architect APPROVE-WITH-MUST-FIX M1–M4 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:456–459); 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 W1–W3.

Non-goals here: no new layout modes, board, search, server, or CAS (those stay in the parent roadmap's P1–P5). 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.002–0.02), a highlighter size slider, and a pressure sensitivity slider (drives pressureSensitivity 0–1). 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:466–474), 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:441–448) 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:56–64). 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:73–118) — 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:134–136, 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.