Files
BadNote/lib/editor/input/pen_input_service.dart
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

137 lines
4.5 KiB
Dart

// lib/editor/input/pen_input_service.dart
//
// Dart side of the native Windows pen observer (`windows/runner/pen_channel.cpp`).
//
// WHY THIS EXISTS: Flutter 3.44 on Windows delivers stylus PRESSURE but drops
// the pen's barrel button, eraser/inverted end, and tilt (it does not map
// POINTER_PEN_FLAG_* into `PointerEvent.buttons`/`invertedStylus`/`tilt`). The
// native plugin observes WM_POINTER + GetPointerPenInfo and streams the missing
// hardware state over an EventChannel; this service latches the LATEST value.
//
// CORRELATION (plan M2): we do NOT key state by Win32 pointerId joined to
// Flutter's `event.pointer` — those are different id spaces. Only one pen is
// active at a time, so a single latched "current" state is correct. The native
// observer runs at the TOP of the window proc (BEFORE Flutter synthesizes its
// pointer event, plan M1), so by the time Dart's pointer-down handler reads
// [current], the latch already reflects that exact contact — no hover required.
//
// GRACEFUL DEGRADATION: on non-Windows (or if the channel is silent) the stream
// simply never emits / errors are swallowed, and [current] stays [PenHardwareState.empty]
// so the canvas falls back to its normal Flutter-pressure drawing.
import 'dart:async';
import 'package:flutter/services.dart';
/// Latest hardware pen state delivered by the native observer.
class PenHardwareState {
const PenHardwareState({
this.barrel = false,
this.inverted = false,
this.eraser = false,
this.tiltX = 0.0,
this.tiltY = 0.0,
});
/// Side barrel button held.
final bool barrel;
/// Pen flipped to the inverted (eraser) end.
final bool inverted;
/// Hardware eraser flag set.
final bool eraser;
/// Tilt in degrees along X / Y ([-90, 90]); 0 = perpendicular.
final double tiltX;
final double tiltY;
/// Combined tilt magnitude in degrees (for [PenPoint.tilt]).
double get tiltMagnitude {
final t = tiltX * tiltX + tiltY * tiltY;
return t <= 0 ? 0.0 : _sqrt(t);
}
static const empty = PenHardwareState();
}
// Avoids importing dart:math for a single call.
double _sqrt(double v) {
if (v <= 0) return 0;
var x = v;
var last = 0.0;
// Newton's method; converges fast for the small (<=~127) magnitudes here.
for (var i = 0; i < 12 && x != last; i++) {
last = x;
x = 0.5 * (x + v / x);
}
return x;
}
/// Latches the most recent [PenHardwareState] streamed by the native pen plugin.
///
/// Use the singleton [PenInputService.instance]. Call [start] once (e.g. in the
/// editor's `initState`) and [stop] on dispose.
class PenInputService {
PenInputService._();
/// Process-wide singleton (one physical pen).
static final PenInputService instance = PenInputService._();
/// Must match the native `EventChannel` name in `pen_channel.cpp`.
static const EventChannel _channel = EventChannel('badnote/pen');
StreamSubscription<dynamic>? _sub;
PenHardwareState _current = PenHardwareState.empty;
/// The latest hardware pen state (or [PenHardwareState.empty] when no native
/// data has arrived — non-Windows, plugin absent, or channel silent).
PenHardwareState get current => _current;
/// Whether the native channel has delivered at least one event (i.e. the
/// native pen plugin is present and active). Used to prefer hardware signals
/// over the Flutter fallback only when they are actually available.
bool get isActive => _active;
bool _active = false;
/// Begins listening to the native channel. Idempotent; safe on any platform
/// (no-ops where the channel has no handler).
void start() {
if (_sub != null) return;
try {
_sub = _channel.receiveBroadcastStream().listen(
_onEvent,
onError: (Object _) {
// No native handler (e.g. Linux/macOS) or transient error — ignore
// and keep the empty fallback state.
},
cancelOnError: false,
);
} catch (_) {
// receiveBroadcastStream can throw synchronously if the platform side is
// unavailable; degrade silently.
}
}
void _onEvent(dynamic event) {
if (event is! Map) return;
final flags = (event['flags'] as num?)?.toInt() ?? 0;
_current = PenHardwareState(
barrel: flags & 0x1 != 0,
inverted: flags & 0x2 != 0,
eraser: flags & 0x4 != 0,
tiltX: (event['tiltX'] as num?)?.toDouble() ?? 0.0,
tiltY: (event['tiltY'] as num?)?.toDouble() ?? 0.0,
);
_active = true;
}
/// Stops listening and resets state.
void stop() {
_sub?.cancel();
_sub = null;
_active = false;
_current = PenHardwareState.empty;
}
}