// 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'; import '../../diagnostics/badnote_log.dart'; import '../../diagnostics/pen_event_ring.dart'; import 'diagnostic_logger.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? _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; // Native-side diagnostics (see windows/runner/pen_channel.cpp). int _diagPtr = 0; int _diagPen = 0; int _diagMouse = 0; int _diagMsg = 0; int _orPtrFlags = 0; int _orPenFlags = 0; int _orPenMask = 0; int _btnChangeLast = 0; int _tiltAbsMax = 0; String _hex(int v) => '0x${v.toRadixString(16)}'; /// Multi-line native readout for the diagnostic overlay. The OR-accumulated /// flag fields are the ground truth for which field carries the side/eraser /// button: e.g. orPtrFlags with bit 0x20 (POINTER_FLAG_SECONDBUTTON) set means /// the barrel button IS detectable. String get debugSummary => _active ? 'native ptr=$_diagPtr pen=$_diagPen mouse=$_diagMouse msg=${_hex(_diagMsg)}' '\n orPtrFlags=${_hex(_orPtrFlags)} orPenFlags=${_hex(_orPenFlags)}' ' mask=${_hex(_orPenMask)} btnChg=$_btnChangeLast tiltMax=$_tiltAbsMax' : 'native: channel silent (no events)'; /// 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, ); _diagPtr = (event['diagPtr'] as num?)?.toInt() ?? _diagPtr; _diagPen = (event['diagPen'] as num?)?.toInt() ?? _diagPen; _diagMouse = (event['diagMouse'] as num?)?.toInt() ?? _diagMouse; _diagMsg = (event['diagMsg'] as num?)?.toInt() ?? _diagMsg; _orPtrFlags = (event['orPtrFlags'] as num?)?.toInt() ?? _orPtrFlags; _orPenFlags = (event['orPenFlags'] as num?)?.toInt() ?? _orPenFlags; _orPenMask = (event['orPenMask'] as num?)?.toInt() ?? _orPenMask; _btnChangeLast = (event['btnChangeLast'] as num?)?.toInt() ?? _btnChangeLast; _tiltAbsMax = (event['tiltAbsMax'] as num?)?.toInt() ?? _tiltAbsMax; _active = true; // Log a PEN line whenever the raw per-event button/flag fields change, so // the file captures exactly which field a button press sets (without // flooding on every high-rate WM_POINTERUPDATE). final rawPtr = (event['rawPtrFlags'] as num?)?.toInt() ?? 0; final rawPen = (event['rawPenFlags'] as num?)?.toInt() ?? 0; final rawMask = (event['rawPenMask'] as num?)?.toInt() ?? 0; final btnChange = (event['btnChange'] as num?)?.toInt() ?? 0; final key = '$rawPtr,$rawPen,$rawMask,$btnChange,${_current.tiltX},${_current.tiltY}'; if (key != _lastPenLogKey) { _lastPenLogKey = key; PenEventRing.instance.recordHardware( barrel: _current.barrel, eraser: _current.eraser, inverted: _current.inverted, tiltX: _current.tiltX, tiltY: _current.tiltY, ); BadNoteLog.instance.debug( LogSubsystem.penNative, 'pen_hw', fields: { 'ptrFlags': '0x${rawPtr.toRadixString(16)}', 'penFlags': '0x${rawPen.toRadixString(16)}', 'mask': '0x${rawMask.toRadixString(16)}', 'btnChg': btnChange, 'tiltX': _current.tiltX, 'tiltY': _current.tiltY, 'resolved': '0x${flags.toRadixString(16)}', 'barrel': _current.barrel, 'eraser': _current.eraser, 'inverted': _current.inverted, }, ); DiagnosticLogger.instance.log( 'PEN ptrFlags=0x${rawPtr.toRadixString(16)} ' 'penFlags=0x${rawPen.toRadixString(16)} ' 'mask=0x${rawMask.toRadixString(16)} btnChg=$btnChange ' 'tilt=${_current.tiltX.toStringAsFixed(0)},${_current.tiltY.toStringAsFixed(0)} ' 'msg=0x${_diagMsg.toRadixString(16)} resolved=0x${flags.toRadixString(16)}', ); } } String _lastPenLogKey = ''; /// Stops listening and resets state. void stop() { _sub?.cancel(); _sub = null; _active = false; _current = PenHardwareState.empty; } }