fix: Surface pen pressure, zoom glitches, sticky notes, selection UX
All checks were successful
CI / Windows build (push) Successful in 8m19s
All checks were successful
CI / Windows build (push) Successful in 8m19s
Wire Win32 pressure into Dart, tighten pinch guards, use geometric shape strokes, expand the ink palette, and replace scratch-link split view with an on-page sticky that shares the sidecar repo. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -18,6 +18,9 @@ enum PenButtonAction {
|
||||
undo,
|
||||
toggleTool,
|
||||
pan,
|
||||
|
||||
/// Hold to temporarily enable PDF text selection (OneNote-style).
|
||||
selectText,
|
||||
}
|
||||
|
||||
/// Immutable configuration for pen input behaviour.
|
||||
|
||||
@@ -2,22 +2,9 @@
|
||||
//
|
||||
// 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.
|
||||
// Streams barrel / eraser / tilt / PRESSURE from WM_POINTER + GetPointerPenInfo.
|
||||
// Flutter's PointerEvent.pressure on Windows is unreliable (often flat); native
|
||||
// pressure (0..1024 → [0,1]) is preferred when [PenHardwareState.pressureValid].
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
@@ -35,22 +22,20 @@ class PenHardwareState {
|
||||
this.eraser = false,
|
||||
this.tiltX = 0.0,
|
||||
this.tiltY = 0.0,
|
||||
this.pressure = 0.0,
|
||||
this.pressureValid = false,
|
||||
});
|
||||
|
||||
/// 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]).
|
||||
/// Normalized stylus pressure in [0,1] when [pressureValid] is true.
|
||||
final double pressure;
|
||||
final bool pressureValid;
|
||||
|
||||
double get tiltMagnitude {
|
||||
final t = tiltX * tiltX + tiltY * tiltY;
|
||||
return t <= 0 ? 0.0 : _sqrt(t);
|
||||
@@ -59,12 +44,10 @@ class PenHardwareState {
|
||||
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);
|
||||
@@ -72,33 +55,34 @@ double _sqrt(double v) {
|
||||
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;
|
||||
|
||||
// Native-side diagnostics (see windows/runner/pen_channel.cpp).
|
||||
final List<VoidCallback> _listeners = <VoidCallback>[];
|
||||
|
||||
/// Notify when native hardware state changes (barrel / pressure / tilt).
|
||||
void addListener(VoidCallback listener) => _listeners.add(listener);
|
||||
|
||||
void removeListener(VoidCallback listener) => _listeners.remove(listener);
|
||||
|
||||
void _notifyListeners() {
|
||||
for (final l in List<VoidCallback>.of(_listeners)) {
|
||||
l();
|
||||
}
|
||||
}
|
||||
|
||||
int _diagPtr = 0;
|
||||
int _diagPen = 0;
|
||||
int _diagMouse = 0;
|
||||
@@ -108,47 +92,42 @@ class PenInputService {
|
||||
int _orPenMask = 0;
|
||||
int _btnChangeLast = 0;
|
||||
int _tiltAbsMax = 0;
|
||||
double _pressureMaxSeen = 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'
|
||||
'\n pressure=${_current.pressureValid ? _current.pressure.toStringAsFixed(3) : "n/a"}'
|
||||
' maxSeen=${_pressureMaxSeen.toStringAsFixed(3)}'
|
||||
: '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.
|
||||
},
|
||||
onError: (Object _) {},
|
||||
cancelOnError: false,
|
||||
);
|
||||
} catch (_) {
|
||||
// receiveBroadcastStream can throw synchronously if the platform side is
|
||||
// unavailable; degrade silently.
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
void _onEvent(dynamic event) {
|
||||
if (event is! Map) return;
|
||||
final flags = (event['flags'] as num?)?.toInt() ?? 0;
|
||||
final pressureValid = ((event['pressureValid'] as num?)?.toInt() ?? 0) != 0;
|
||||
final pressure = (event['pressure'] as num?)?.toDouble() ?? 0.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,
|
||||
pressure: pressure.clamp(0.0, 1.0),
|
||||
pressureValid: pressureValid,
|
||||
);
|
||||
_diagPtr = (event['diagPtr'] as num?)?.toInt() ?? _diagPtr;
|
||||
_diagPen = (event['diagPen'] as num?)?.toInt() ?? _diagPen;
|
||||
@@ -159,16 +138,20 @@ class PenInputService {
|
||||
_orPenMask = (event['orPenMask'] as num?)?.toInt() ?? _orPenMask;
|
||||
_btnChangeLast = (event['btnChangeLast'] as num?)?.toInt() ?? _btnChangeLast;
|
||||
_tiltAbsMax = (event['tiltAbsMax'] as num?)?.toInt() ?? _tiltAbsMax;
|
||||
_pressureMaxSeen =
|
||||
(event['pressureMaxSeen'] as num?)?.toDouble() ?? _pressureMaxSeen;
|
||||
if (pressureValid && pressure > _pressureMaxSeen) {
|
||||
_pressureMaxSeen = pressure;
|
||||
}
|
||||
_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}';
|
||||
final key =
|
||||
'$rawPtr,$rawPen,$rawMask,$btnChange,${_current.tiltX},${_current.tiltY},'
|
||||
'${pressureValid ? pressure.toStringAsFixed(2) : "x"}';
|
||||
if (key != _lastPenLogKey) {
|
||||
_lastPenLogKey = key;
|
||||
PenEventRing.instance.recordHardware(
|
||||
@@ -188,7 +171,8 @@ class PenInputService {
|
||||
'btnChg': btnChange,
|
||||
'tiltX': _current.tiltX,
|
||||
'tiltY': _current.tiltY,
|
||||
'resolved': '0x${flags.toRadixString(16)}',
|
||||
'pressure': pressureValid ? pressure : null,
|
||||
'pressureValid': pressureValid,
|
||||
'barrel': _current.barrel,
|
||||
'eraser': _current.eraser,
|
||||
'inverted': _current.inverted,
|
||||
@@ -199,14 +183,15 @@ class PenInputService {
|
||||
'penFlags=0x${rawPen.toRadixString(16)} '
|
||||
'mask=0x${rawMask.toRadixString(16)} btnChg=$btnChange '
|
||||
'tilt=${_current.tiltX.toStringAsFixed(0)},${_current.tiltY.toStringAsFixed(0)} '
|
||||
'p=${pressureValid ? pressure.toStringAsFixed(3) : "n/a"} '
|
||||
'msg=0x${_diagMsg.toRadixString(16)} resolved=0x${flags.toRadixString(16)}',
|
||||
);
|
||||
}
|
||||
_notifyListeners();
|
||||
}
|
||||
|
||||
String _lastPenLogKey = '';
|
||||
|
||||
/// Stops listening and resets state.
|
||||
void stop() {
|
||||
_sub?.cancel();
|
||||
_sub = null;
|
||||
|
||||
Reference in New Issue
Block a user