From f9ec04fe86b19ce5537e9bfa24016566dfee0c2a Mon Sep 17 00:00:00 2001 From: Akiba So Date: Tue, 23 Jun 2026 01:10:17 +0800 Subject: [PATCH] feat(diag): full input logging + read barrel from pointerFlags; focal-jump reject MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Buttons (likely fix + ground truth): device diag showed ptr=12577 pen=10056 — WM_POINTER reaches the observer and GetPointerPenInfo succeeds, so the buttons were just read from the wrong field. Native now resolves the barrel from BOTH penFlags(PEN_FLAG_BARREL) AND pointerInfo.pointerFlags(POINTER_FLAG_SECONDBUTTON) — many pens use the latter. It also emits the full raw set (pointerFlags, penFlags, penMask, ButtonChangeType, tilt) plus OR-accumulated flags so a single session reveals exactly which field each button sets. Comprehensive logging (per user request "用好用的log库 / 我手动开启日志再记录"): new DiagnosticLogger emits through dart:developer log(name 'badnote.input') — capturable via `flutter run` / DevTools / `flutter logs` — AND mirrors to a file (path shown in the overlay) for the packaged GUI build that has no console. Manually enabled by the toolbar diagnostic toggle; off by default. PEN lines log on raw-field change; ZOOM lines log every scale frame + rebaselines. Zoom: scale-only glitch rejection didn't stop the jumping, so add focal/position glitch rejection — drop a 2-finger frame whose focal jumps >250px (a touch misread). The full per-frame trace (raw scale, pointerCount, applied change, focal jump, drops) is now logged so the residual cause is unambiguous. InputDiagnostics singleton accumulates the stats; the overlay shows summary + last trace lines + log path + reset. Removed the ad-hoc inline zoom min/max. Dart: analyze clean, 66/66 tests, linux build green. Native compiles on CI. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/editor/canvas/input_diagnostics.dart | 90 +++++++++++++++ lib/editor/canvas/pen_editor_screen.dart | 106 +++++++++++------- lib/editor/canvas/pen_interactive_viewer.dart | 46 +++++++- lib/editor/input/diagnostic_logger.dart | 90 +++++++++++++++ lib/editor/input/pen_input_service.dart | 48 +++++++- windows/runner/pen_channel.cpp | 54 ++++++++- 6 files changed, 379 insertions(+), 55 deletions(-) create mode 100644 lib/editor/canvas/input_diagnostics.dart create mode 100644 lib/editor/input/diagnostic_logger.dart diff --git a/lib/editor/canvas/input_diagnostics.dart b/lib/editor/canvas/input_diagnostics.dart new file mode 100644 index 0000000..f867ac4 --- /dev/null +++ b/lib/editor/canvas/input_diagnostics.dart @@ -0,0 +1,90 @@ +// lib/editor/canvas/input_diagnostics.dart +// +// Live zoom/pan diagnostics for the pen canvas. PenInteractiveViewer records one +// entry per scale-update frame; the editor's diagnostic overlay displays the +// accumulated summary + a rolling trace so a SINGLE device session reveals the +// nature of any "跳变" (is it a raw-scale spike, a focal/position jump, or a +// pointer-count oscillation?). All numbers reset via [reset]. + +import 'package:flutter/foundation.dart'; + +import '../input/diagnostic_logger.dart'; + +class InputDiagnostics extends ChangeNotifier { + InputDiagnostics._(); + static final InputDiagnostics instance = InputDiagnostics._(); + + int frames = 0; + int scaleDropped = 0; // frames rejected as a scale glitch + int focalDropped = 0; // frames rejected as a focal/position glitch + int rebaselines = 0; // pointer-count re-baselines + int pointerCountMax = 0; + double rawScaleMin = double.infinity, rawScaleMax = 0; + double scaleMin = double.infinity, scaleMax = 0; + double maxFocalJumpPx = 0; // largest single-frame local focal delta + double maxAppliedScaleJump = 1; // largest single-frame applied scale ratio + + final List _trace = []; + List get trace => List.unmodifiable(_trace); + + void recordRebaseline() { + rebaselines++; + DiagnosticLogger.instance.log('ZOOM rebaseline'); + notifyListeners(); + } + + void recordScaleFrame({ + required double rawScale, + required int pointerCount, + required double currentScale, + required double appliedChange, // 1.0 when the frame was dropped + required double focalJumpPx, + required bool scaleDrop, + required bool focalDrop, + }) { + frames++; + if (scaleDrop) scaleDropped++; + if (focalDrop) focalDropped++; + if (rawScale < rawScaleMin) rawScaleMin = rawScale; + if (rawScale > rawScaleMax) rawScaleMax = rawScale; + final double resulting = currentScale * appliedChange; + if (resulting < scaleMin) scaleMin = resulting; + if (resulting > scaleMax) scaleMax = resulting; + if (pointerCount > pointerCountMax) pointerCountMax = pointerCount; + if (focalJumpPx > maxFocalJumpPx) maxFocalJumpPx = focalJumpPx; + final double jump = appliedChange >= 1 ? appliedChange : 1 / appliedChange; + if (jump > maxAppliedScaleJump) maxAppliedScaleJump = jump; + + final String line = 'p$pointerCount raw=${rawScale.toStringAsFixed(3)} ' + 'ch=${appliedChange.toStringAsFixed(3)} ' + 'cur=${currentScale.toStringAsFixed(3)} ' + 'fj=${focalJumpPx.toStringAsFixed(0)}' + '${scaleDrop ? " SDROP" : ""}${focalDrop ? " FDROP" : ""}'; + _trace.add(line); + if (_trace.length > 24) _trace.removeAt(0); + DiagnosticLogger.instance.log('ZOOM $line'); + notifyListeners(); + } + + void reset() { + frames = scaleDropped = focalDropped = rebaselines = pointerCountMax = 0; + rawScaleMin = scaleMin = double.infinity; + rawScaleMax = scaleMax = 0; + maxFocalJumpPx = 0; + maxAppliedScaleJump = 1; + _trace.clear(); + notifyListeners(); + } + + String _f(double v) => v.isFinite ? v.toStringAsFixed(2) : '-'; + + String summary() { + if (frames == 0) return 'zoom: (pinch to record)'; + return 'zoom f=$frames sDrop=$scaleDropped fDrop=$focalDropped ' + 'rebase=$rebaselines pMax=$pointerCountMax\n' + ' raw=${_f(rawScaleMin)}..${_f(rawScaleMax)} ' + 'scale=${_f(scaleMin)}..${_f(scaleMax)}\n' + ' maxFocalJump=${maxFocalJumpPx.toStringAsFixed(0)}px ' + 'maxScaleJump=${_f(maxAppliedScaleJump)}'; + } +} diff --git a/lib/editor/canvas/pen_editor_screen.dart b/lib/editor/canvas/pen_editor_screen.dart index cbe4083..c48e87b 100644 --- a/lib/editor/canvas/pen_editor_screen.dart +++ b/lib/editor/canvas/pen_editor_screen.dart @@ -12,12 +12,14 @@ import '../../services/database_service.dart'; import '../engine/stroke_geometry.dart' show kDefaultPenThinning; import '../engine/stroke_model.dart'; import '../engine/undo_stack.dart'; +import '../input/diagnostic_logger.dart'; import '../input/pen_config.dart'; import '../input/pen_input_service.dart'; import '../persistence/editor_repository.dart'; import '../persistence/save_scheduler.dart'; import '../ui/pen_settings_page.dart'; import '../ui/thumbnail_grid.dart'; +import 'input_diagnostics.dart'; import 'pen_canvas.dart'; import 'pen_stroke.dart'; @@ -95,13 +97,6 @@ class _PenEditorScreenState extends State { String _penDebug = ''; bool _showPenDebug = false; - /// Live zoom-scale diagnostic (only tracked while the overlay is on), so we - /// can capture the value the pinch flash jumps to. `_zoomMin/_zoomMax` record - /// the extremes seen since the overlay was last enabled. - double _zoomNow = 1.0; - double _zoomMin = double.infinity; - double _zoomMax = 0.0; - // Tool state. CanvasTool _tool = CanvasTool.pen; Color _color = Colors.black; @@ -126,23 +121,11 @@ class _PenEditorScreenState extends State { // Begin listening to the native Windows pen plugin (barrel/eraser/tilt). // No-op on platforms without the plugin (W3). PenInputService.instance.start(); - _transform.addListener(_onTransformDebug); _initPersistence(); _initPenConfig(); _open(); } - /// Track the live zoom scale for the diagnostic overlay (no-op when off). - void _onTransformDebug() { - if (!_showPenDebug) return; - final s = _transform.value.getMaxScaleOnAxis(); - setState(() { - _zoomNow = s; - if (s < _zoomMin) _zoomMin = s; - if (s > _zoomMax) _zoomMax = s; - }); - } - Future _initPenConfig() async { final controller = await PenConfigController.load(); if (!mounted) { @@ -240,10 +223,10 @@ class _PenEditorScreenState extends State { scheduler.dispose(); } _document?.dispose(); - _transform.removeListener(_onTransformDebug); _transform.dispose(); _penConfig?.dispose(); PenInputService.instance.stop(); + DiagnosticLogger.instance.stop(); super.dispose(); } @@ -453,18 +436,59 @@ class _PenEditorScreenState extends State { child: Material( color: Theme.of(context).colorScheme.inverseSurface, borderRadius: BorderRadius.circular(8), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 10, vertical: 6), - child: Text( - '${_penDebug.isEmpty ? 'hover / draw with the pen…' : _penDebug}' - '\nzoom=${_zoomNow.toStringAsFixed(2)} ' - 'min=${_zoomMin.isFinite ? _zoomMin.toStringAsFixed(2) : '-'} ' - 'max=${_zoomMax.toStringAsFixed(2)}', - style: TextStyle( - fontFamily: 'monospace', - fontSize: 12, - color: Theme.of(context).colorScheme.onInverseSurface, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 380), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 6), + child: ListenableBuilder( + listenable: InputDiagnostics.instance, + builder: (context, _) { + final cs = Theme.of(context).colorScheme; + final d = InputDiagnostics.instance; + final tail = d.trace.length > 6 + ? d.trace.sublist(d.trace.length - 6) + : d.trace; + final mono = TextStyle( + fontFamily: 'monospace', + fontSize: 11, + color: cs.onInverseSurface); + final monoFaint = mono.copyWith( + fontSize: 10, + color: + cs.onInverseSurface.withValues(alpha: 0.75)); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _penDebug.isEmpty + ? 'hover / draw with the pen…' + : _penDebug, + style: mono), + const SizedBox(height: 4), + Text(d.summary(), style: mono), + if (tail.isNotEmpty) ...[ + const SizedBox(height: 4), + Text(tail.join('\n'), style: monoFaint), + ], + const SizedBox(height: 4), + Text( + 'log: ${DiagnosticLogger.instance.path ?? "(developer.log only)"}', + style: monoFaint), + Align( + alignment: Alignment.centerRight, + child: TextButton( + onPressed: () => + InputDiagnostics.instance.reset(), + child: Text('Reset stats', + style: + TextStyle(color: cs.inversePrimary)), + ), + ), + ], + ); + }, ), ), ), @@ -621,15 +645,17 @@ class _PenEditorScreenState extends State { _ToolButton( icon: Icons.bug_report_outlined, selected: _showPenDebug, - tooltip: 'Pen + zoom diagnostic', - onPressed: () => setState(() { - _showPenDebug = !_showPenDebug; - if (_showPenDebug) { - _zoomMin = double.infinity; - _zoomMax = 0.0; - _zoomNow = _transform.value.getMaxScaleOnAxis(); + tooltip: 'Input diagnostic (writes a log file)', + onPressed: () { + final on = !_showPenDebug; + setState(() => _showPenDebug = on); + if (on) { + InputDiagnostics.instance.reset(); + DiagnosticLogger.instance.start(); + } else { + DiagnosticLogger.instance.stop(); } - }), + }, ), ], ), diff --git a/lib/editor/canvas/pen_interactive_viewer.dart b/lib/editor/canvas/pen_interactive_viewer.dart index 029d4b2..6e6d416 100644 --- a/lib/editor/canvas/pen_interactive_viewer.dart +++ b/lib/editor/canvas/pen_interactive_viewer.dart @@ -31,6 +31,8 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/physics.dart'; import 'package:flutter/widgets.dart'; +import 'input_diagnostics.dart'; + /// Devices allowed to pan/zoom. Stylus + invertedStylus are excluded so the pen /// is owned exclusively by the drawing `Listener`. const Set _kPanZoomDevices = { @@ -46,6 +48,11 @@ const Set _kPanZoomDevices = { const double _kScaleGlitchHi = 1.4; const double _kScaleGlitchLo = 1 / _kScaleGlitchHi; +/// During a 2-finger gesture the focal point (finger midpoint) should move +/// smoothly. A single-frame local jump beyond this is a Windows touch misread, +/// and the frame is dropped (position-jump guard). +const double _kFocalGlitchPx = 250.0; + const double _kDrag = 0.0000135; enum _GestureType { pan, scale } @@ -162,9 +169,11 @@ class _PenInteractiveViewerState extends State _lastPointerCount = details.pointerCount; _scaleStart = _transformer.value.getMaxScaleOnAxis(); _referenceFocalPoint = _transformer.toScene(details.localFocalPoint); + InputDiagnostics.instance.recordRebaseline(); return; } + final double focalJumpPx = details.focalPointDelta.distance; final Offset focalPointScene = _transformer.toScene(details.localFocalPoint); if (_gestureType == _GestureType.pan) { @@ -175,16 +184,36 @@ class _PenInteractiveViewerState extends State } if (!_gestureIsSupported(_gestureType)) return; + // Position-jump guard: during a pinch the focal midpoint should move + // smoothly; a big single-frame jump is a touch misread → drop the frame. + final bool focalDrop = + details.pointerCount >= 2 && focalJumpPx > _kFocalGlitchPx; + + void record(double appliedChange, bool scaleDrop, bool focalDropped) { + InputDiagnostics.instance.recordScaleFrame( + rawScale: details.scale, + pointerCount: details.pointerCount, + currentScale: scale, + appliedChange: appliedChange, + focalJumpPx: focalJumpPx, + scaleDrop: scaleDrop, + focalDrop: focalDropped, + ); + } + switch (_gestureType!) { case _GestureType.scale: assert(_scaleStart != null); final double desiredScale = _scaleStart! * details.scale; final double scaleChange = desiredScale / scale; - // Glitch rejection: drop a frame that demands an implausible per-frame - // scale jump (a Windows multi-touch position glitch). The next good - // frame resumes from the true finger positions, so the spike never - // shows — unlike clamping, which still applied a visible partial jump. - if (scaleChange > _kScaleGlitchHi || scaleChange < _kScaleGlitchLo) { + // Drop a frame demanding an implausible per-frame scale jump (Windows + // multi-touch glitch) OR an implausible focal jump. Absolute tracking + // means the next good frame resumes from the true finger positions, so + // the spike never shows. + final bool scaleDrop = + scaleChange > _kScaleGlitchHi || scaleChange < _kScaleGlitchLo; + if (scaleDrop || focalDrop) { + record(1.0, scaleDrop, focalDrop); return; } _transformer.value = _matrixScale(_transformer.value, scaleChange); @@ -203,16 +232,23 @@ class _PenInteractiveViewerState extends State if (_round(_referenceFocalPoint!) != _round(focalPointSceneCheck)) { _referenceFocalPoint = focalPointSceneCheck; } + record(scaleChange, false, false); case _GestureType.pan: assert(_referenceFocalPoint != null); // Throw away near-scale frames so a stale reference can't jump the pan. if (details.scale != 1.0) return; + if (focalDrop) { + _referenceFocalPoint = _transformer.toScene(details.localFocalPoint); + record(1.0, false, true); + return; + } final Offset translationChange = focalPointScene - _referenceFocalPoint!; _transformer.value = _matrixTranslate(_transformer.value, translationChange); _referenceFocalPoint = _transformer.toScene(details.localFocalPoint); + record(1.0, false, false); } } diff --git a/lib/editor/input/diagnostic_logger.dart b/lib/editor/input/diagnostic_logger.dart new file mode 100644 index 0000000..5345dec --- /dev/null +++ b/lib/editor/input/diagnostic_logger.dart @@ -0,0 +1,90 @@ +// lib/editor/input/diagnostic_logger.dart +// +// On-device input diagnostics. When the user manually enables the diagnostic +// (the toolbar toggle), every native pen event (raw button/flag/tilt fields) +// and every zoom frame is emitted through the standard `dart:developer` log +// channel (name 'badnote.input') — capturable via `flutter run`, DevTools, or +// any log tool — AND mirrored to a text file as a fallback for the packaged +// GUI build, which has no attached console. Disabled by default (no overhead). + +import 'dart:async'; +import 'dart:developer' as developer; +import 'dart:io'; + +import 'package:path_provider/path_provider.dart'; + +class DiagnosticLogger { + DiagnosticLogger._(); + static final DiagnosticLogger instance = DiagnosticLogger._(); + + final List _buffer = []; + File? _file; + Timer? _timer; + int _epochMs = 0; + + bool _active = false; + bool get isActive => _active; + + /// Absolute path of the current log file (shown in the overlay), or null. + String? path; + + /// Begin a session. Enables the `dart:developer` log channel immediately and + /// opens the fallback file (best-effort). Safe to call repeatedly. + Future start() async { + if (_active) return; + _active = true; // developer.log works even if the file can't be opened + _epochMs = DateTime.now().millisecondsSinceEpoch; + developer.log('--- session start ${DateTime.now().toIso8601String()} ---', + name: 'badnote.input'); + try { + Directory dir; + try { + dir = await getApplicationDocumentsDirectory(); + } catch (_) { + dir = await getTemporaryDirectory(); + } + final file = File('${dir.path}${Platform.pathSeparator}badnote_input_log.txt'); + await file.writeAsString( + '# BadNote input diagnostic log\n' + '# started ${DateTime.now().toIso8601String()}\n' + '# columns: \n', + flush: true, + ); + _file = file; + path = file.path; + _buffer.clear(); + _timer = Timer.periodic(const Duration(seconds: 1), (_) => _flush()); + } catch (_) { + // File is a fallback; never break the app over it. + } + } + + /// Emit one diagnostic line through the standard log channel and the file. + void log(String line) { + if (!_active) return; + developer.log(line, name: 'badnote.input'); + if (_file == null) return; + final t = DateTime.now().millisecondsSinceEpoch - _epochMs; + _buffer.add('$t $line'); + if (_buffer.length >= 1000) _flush(); + } + + Future _flush() async { + final file = _file; + if (file == null || _buffer.isEmpty) return; + final chunk = '${_buffer.join('\n')}\n'; + _buffer.clear(); + try { + await file.writeAsString(chunk, mode: FileMode.append, flush: true); + } catch (_) {} + } + + /// Flush and stop. The file remains on disk for retrieval. + Future stop() async { + if (!_active) return; + _active = false; + _timer?.cancel(); + _timer = null; + await _flush(); + } +} diff --git a/lib/editor/input/pen_input_service.dart b/lib/editor/input/pen_input_service.dart index a10c9f3..c3326c7 100644 --- a/lib/editor/input/pen_input_service.dart +++ b/lib/editor/input/pen_input_service.dart @@ -23,6 +23,8 @@ import 'dart:async'; import 'package:flutter/services.dart'; +import 'diagnostic_logger.dart'; + /// Latest hardware pen state delivered by the native observer. class PenHardwareState { const PenHardwareState({ @@ -94,17 +96,27 @@ class PenInputService { bool get isActive => _active; bool _active = false; - // Native-side diagnostics (see windows/runner/pen_channel.cpp): how many - // WM_POINTER / PT_PEN / legacy-mouse messages the observer has seen. Lets the - // on-device overlay tell us WHICH layer is failing for buttons/tilt. + // 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; - /// One-line native readout for the diagnostic overlay. + 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=0x${_diagMsg.toRadixString(16)}' + ? '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 @@ -140,9 +152,35 @@ class PenInputService { _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; + 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(); diff --git a/windows/runner/pen_channel.cpp b/windows/runner/pen_channel.cpp index c3d6282..08fb7a4 100644 --- a/windows/runner/pen_channel.cpp +++ b/windows/runner/pen_channel.cpp @@ -23,6 +23,15 @@ int g_pen_msgs = 0; int g_mouse_msgs = 0; int g_last_msg = 0; +// OR-accumulated raw flag fields, so a momentary button press is CAPTURED and +// held (a live readout would miss it). These are the ground truth for "which +// field/bit does the side button / eraser set?". +int g_ptr_flags_or = 0; // POINTER_INFO.pointerFlags (POINTER_FLAG_SECONDBUTTON = barrel on many pens) +int g_pen_flags_or = 0; // POINTER_PEN_INFO.penFlags (PEN_FLAG_BARREL/INVERTED/ERASER) +int g_pen_mask_or = 0; // POINTER_PEN_INFO.penMask +int g_btn_change_last = 0; // last non-zero POINTER_INFO.ButtonChangeType +int g_tilt_abs_max = 0; // max |tiltX|,|tiltY| seen + } // namespace void RegisterPenChannel(flutter::FlutterEngine* engine) { @@ -76,6 +85,10 @@ void ObservePenMessage(UINT message, WPARAM wparam, LPARAM lparam) { int flags = 0; double tilt_x = 0.0; double tilt_y = 0.0; + int raw_ptr_flags = 0; + int raw_pen_flags = 0; + int raw_pen_mask = 0; + int btn_change = 0; if (is_pointer) { ++g_ptr_msgs; @@ -85,11 +98,32 @@ void ObservePenMessage(UINT message, WPARAM wparam, LPARAM lparam) { POINTER_PEN_INFO ppi{}; if (GetPointerPenInfo(pointerId, &ppi)) { ++g_pen_msgs; - if (ppi.penFlags & PEN_FLAG_BARREL) flags |= 1; - if (ppi.penFlags & PEN_FLAG_INVERTED) flags |= 2; - if (ppi.penFlags & PEN_FLAG_ERASER) flags |= 4; + raw_pen_flags = static_cast(ppi.penFlags); + raw_pen_mask = static_cast(ppi.penMask); + raw_ptr_flags = static_cast(ppi.pointerInfo.pointerFlags); + btn_change = static_cast(ppi.pointerInfo.ButtonChangeType); tilt_x = static_cast(ppi.tiltX); tilt_y = static_cast(ppi.tiltY); + + // Barrel/side button can arrive in EITHER penFlags (PEN_FLAG_BARREL) or + // pointerFlags (POINTER_FLAG_SECONDBUTTON) depending on the pen/driver, + // so check both. Eraser end = inverted/eraser pen flags. + const bool barrel = (ppi.penFlags & PEN_FLAG_BARREL) || + (ppi.pointerInfo.pointerFlags & POINTER_FLAG_SECONDBUTTON); + const bool inverted = (ppi.penFlags & PEN_FLAG_INVERTED) != 0; + const bool eraser = (ppi.penFlags & PEN_FLAG_ERASER) != 0; + if (barrel) flags |= 1; + if (inverted) flags |= 2; + if (eraser) flags |= 4; + + g_ptr_flags_or |= raw_ptr_flags; + g_pen_flags_or |= raw_pen_flags; + g_pen_mask_or |= raw_pen_mask; + if (btn_change != 0) g_btn_change_last = btn_change; + const int ax = ppi.tiltX < 0 ? -ppi.tiltX : ppi.tiltX; + const int ay = ppi.tiltY < 0 ? -ppi.tiltY : ppi.tiltY; + if (ax > g_tilt_abs_max) g_tilt_abs_max = ax; + if (ay > g_tilt_abs_max) g_tilt_abs_max = ay; } } if (message == WM_POINTERUP) { @@ -97,8 +131,9 @@ void ObservePenMessage(UINT message, WPARAM wparam, LPARAM lparam) { } } - // Always emit the diagnostic counters so the Dart overlay can show whether - // WM_POINTER / PT_PEN ever reach this observer. + // Emit the resolved flags/tilt PLUS the full raw + OR-accumulated diagnostic + // set, so a single device session reveals exactly which field carries the + // button and what tilt/mask the pen reports. flutter::EncodableMap payload{ {flutter::EncodableValue("flags"), flutter::EncodableValue(flags)}, {flutter::EncodableValue("tiltX"), flutter::EncodableValue(tilt_x)}, @@ -107,6 +142,15 @@ void ObservePenMessage(UINT message, WPARAM wparam, LPARAM lparam) { {flutter::EncodableValue("diagPen"), flutter::EncodableValue(g_pen_msgs)}, {flutter::EncodableValue("diagMouse"), flutter::EncodableValue(g_mouse_msgs)}, {flutter::EncodableValue("diagMsg"), flutter::EncodableValue(g_last_msg)}, + {flutter::EncodableValue("rawPtrFlags"), flutter::EncodableValue(raw_ptr_flags)}, + {flutter::EncodableValue("rawPenFlags"), flutter::EncodableValue(raw_pen_flags)}, + {flutter::EncodableValue("rawPenMask"), flutter::EncodableValue(raw_pen_mask)}, + {flutter::EncodableValue("btnChange"), flutter::EncodableValue(btn_change)}, + {flutter::EncodableValue("orPtrFlags"), flutter::EncodableValue(g_ptr_flags_or)}, + {flutter::EncodableValue("orPenFlags"), flutter::EncodableValue(g_pen_flags_or)}, + {flutter::EncodableValue("orPenMask"), flutter::EncodableValue(g_pen_mask_or)}, + {flutter::EncodableValue("btnChangeLast"), flutter::EncodableValue(g_btn_change_last)}, + {flutter::EncodableValue("tiltAbsMax"), flutter::EncodableValue(g_tilt_abs_max)}, }; g_pen_sink->Success(flutter::EncodableValue(payload)); }