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

19 KiB
Raw Permalink Blame History

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-gapPdfPageView 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 :29never 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 fieldEditorPoint.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.fromPenStrokeEditorPoint.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.dartsideButton/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.