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>
19 KiB
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
- 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 intoink_painters.dartonly. - No hardcoded feel constants.
thinning,size, taper, and pressure-sensitivity arePenConfigfields with sane defaults — mirroring Saber'sStrokeOptions-per-pen model. - 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.
- 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
- D1 — Unblock the user's primary device today (eraser-end + side-button + tilt are dead; pen feel needs a real size/pressure control).
- D2 — Don't derail the approved refactor — every change feeds
engine/+input/+pen_config, not the soon-retired widgets. - 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/sizeas parameters fromPenConfigthroughbuildStrokeOutline/buildStrokePath; addpressureSensitivity(→thinning) + reuse existingpenWidth/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 tinyRawImagecache) so the async re-render never exposes a white frame; then the full R11page_tilein P0.5 replaces it. Pros: kills the visible flicker immediately with a small, localized change; forward-compatible (becomespage_tile's double-buffer). Cons: a stopgop that P0.5 supersedes. - Option B: jump straight to the full
pdf/page_tile+page_tile_cachenow. 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 handleWM_POINTER*, callGetPointerPenInfo/GetPointerPenInfoHistoryforpenFlags(BARREL/INVERTED/ERASER) +tiltX/tiltY, key state bypointerId, forward to Dart via anEventChannel('badnote/pen'). DartPenInputServiceexposes the latest per-pointer pen state;InputArbiter/canvas reads it to set eraser + tilt. Pros: smallest footprint (mirrors existingocr_channel.cpppattern); 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.cppproves 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)
- "Tilt/buttons still dead after the plugin ships." Cause: WM_POINTER not reaching our handler, or Flutter's own
FlutterWindowconsumes the message first. Prevention: before writing the EventChannel, add a WM_POINTER logging probe in the window proc and confirm on-device thatGetPointerPenInforeturns non-zeropenFlags/tilt (systematic-debugging Phase-1 evidence at the component boundary). Only then wire the channel. - "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.
- "Width/thinning change broke export goldens." Cause: only
ink_painters.dartwas updated,stroke_geometry.dart(export path) drifted. Prevention: change BOTH via the sharedkDefaultPenThinning; default stays0.85(M4) so the existing golden is unchanged; add a unit test that both builders read identical thinning for the samePenConfig. If a golden must be regenerated, name + commit the new baseline explicitly. - "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] — adddouble pressureSensitivity(maps to perfect_freehandthinning; range[0,1], default0.85= the current live value, M4, so existing stroke feel and the export golden are preserved;0= constant width). KeeppenWidth/highlighterWidthas size. Add tocopyWith/toJson/fromJson/==/hashCode+PenConfigController.setPressureSensitivity(clamped). Additive persisted field (default-filled on missing key — no migration).lib/editor/engine/stroke_geometry.dart[MODIFY] — defineconst double kDefaultPenThinning = 0.85;(M4 — NOT 0.6; preserves goldens).buildStrokeOutline(..., {required bool isComplete, double thinning = kDefaultPenThinning}); remove the0.85literal in favour of the named const. Highlighter still forces0.0.lib/editor/canvas/ink_painters.dart[MODIFY] —buildStrokePath(..., {required bool isComplete, double thinning = kDefaultPenThinning}); importkDefaultPenThinningfromstroke_geometry.dartso screen+export share ONE default and can never diverge.lib/editor/canvas/pen_canvas.dart[MODIFY] — accept athinningfield onPenCanvas; pass it to the painters (the painters need the value at paint time → pass via the painter constructorsStaticInkPainter/LiveInkPainter, addshouldRepaintcheck onthinning).lib/editor/canvas/pen_editor_screen.dart[MODIFY] — feed_penConfig?.value.pressureSensitivity ?? kDefaultPenThinningintoPenCanvas.thinning.lib/editor/ui/pen_settings_page.dart[MODIFY] — add a pen size slider (drivespenWidth, e.g.0.002–0.02), a highlighter size slider, and a pressure sensitivity slider (drivespressureSensitivity0–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 —
PdfPageViewblanks 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/_needsCenterpost-frame callback (pen_editor_screen.dart:441–448) orValueKey(_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.Imagepainted as an underlay for the current page only, swapped atomically when the new render is ready (the seed of P0.5page_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 belowPdfPageViewand 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_tileDPI-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): ifmessage ∈ {WM_POINTERENTER, WM_POINTERDOWN, WM_POINTERUPDATE, WM_POINTERUP},GET_POINTERID_WPARAM(wparam)→GetPointerType→ ifPT_PEN,GetPointerPenInfo(id, &POINTER_PEN_INFO); readpenFlags(PEN_FLAG_BARREL,PEN_FLAG_INVERTED,PEN_FLAG_ERASER) +tiltX/tiltY. Latch into a native singleton AND push{flags, tiltX, tiltY}overEventChannel('badnote/pen'). Returns void; never consumes.windows/runner/flutter_window.cpp[MODIFY] — callObservePenMessage(message, wparam, lparam)at the top ofMessageHandler(before theHandleTopLevelWindowProcblock, M1); register the channel alongsideRegisterOcrChannelinOnCreate(:29).lib/editor/input/pen_input_service.dart[ADD] — listens toEventChannel('badnote/pen'); holds the latest singlePenHardwareState{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 capturesPenPoint{x, y, pressure}(pen_stroke.dart:15-21) which has notiltfield —EditorPoint.tiltlives in the separate engine model the live widget does not use. Adddouble? tilttoPenPointnow, populate it at capture, and map it throughEditorStroke.fromPenStroke→EditorPoint.tilt(SF1) so tilt is lossless end-to-end on the live path. (When the canvas migrates toEditorStrokein P0,PenPointretires and this collapses toEditorPoint.tiltdirectly.)lib/editor/canvas/pen_canvas.dart[MODIFY] — replace the dead_isEraserSignal(pen_canvas.dart:134–136,kSecondaryButton||invertedStylusnever fires on Windows): on hover/down readPenInputService.currentand resolve eraser/undo/etc. through the configuredPenConfig.sideButton/eraserEndmapping; stashtiltX/Yinto the capturedPenPoint.tilt(added above).lib/editor/input/pen_config.dart—sideButton/eraserEndPenButtonActionalready 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
- W1 (pure Dart, CI-testable, low risk) — ship first; unblocks "pen feel" immediately.
- W2 investigate → minimal fix — systematic-debugging Phase 1 evidence, then the double-buffer.
- W3 native plugin — biggest/native; (a) WM_POINTER logging probe → device-confirm
GetPointerPenInforeturns flags+tilt; (b) EventChannel + Dart service; (c) wire eraser/tilt +PenConfigmappings. - W4 — resume the parent roadmap P0 (task #7) → P0.5. W1's
stroke_geometrychange and W3'spen_input_serviceare written to land inengine/+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
thinningin the canonical geometry), fix the zoom flicker with a double-buffer stopgap that seeds the P0.5page_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 approvedengine//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_tilenow (deferred to P0.5), federated plugin package (rejected —ocr_channelin-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;
PenConfiggainspressureSensitivity;thinningdefaults centralized askDefaultPenThinning. - Follow-ups: W2 stopgap deleted when
page_tilelands; W3 eraser/tilt wiring moves intoinput_arbiterduring P0; confirmGetPointerPenInfotilt units + sign on-device.