137 lines
4.5 KiB
Dart
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;
|
||
|
|
}
|
||
|
|
}
|