stylet 0.1.2
stylet: ^0.1.2 copied to clipboard
Cross-platform Flutter stylus input with pressure, tilt, buttons, barrel rotation, hover, double-tap, and squeeze support.
Stylet #
High-fidelity stylus input for Flutter on Android, iOS, Linux, macOS, Web, and Windows.
Stylet keeps Flutter's regular PointerEvent pipeline intact and enriches it
with native values that Flutter does not expose everywhere: barrel rotation,
tangential pressure, Apple Pencil double-tap and squeeze, native tilt axes, and
device identifiers. Replaceable motion predictions and corrections for
initially estimated Apple Pencil values are exposed separately from definitive
input. On the Web, Pointer Events add raw/coalesced samples, tangential pressure,
rotation, and browser-generated predictions; the experimental Ink API is an
optional compositor trail. Stylet is designed for drawing applications such as
Focale, but does not impose a canvas, brush engine, or state-management
solution.
Installation #
Add Stylet to the consuming application's pubspec.yaml:
flutter pub add stylet
Then run flutter pub get.
Quick start #
Wrap an interactive canvas with StyletListener. The callback receives a
normalized sample for every stylus pointer event, while the child continues to
receive its original Flutter events.
import 'package:flutter/widgets.dart';
import 'package:stylet/stylet.dart';
StyletListener(
behavior: HitTestBehavior.opaque,
onEvent: (StylusMotionEvent event) {
final double pressure = event.normalizedPressure ?? 1;
final double tilt = event.tilt ?? 0;
final double orientation = event.orientation ?? 0;
final double barrelRotation = event.barrelRotation ?? 0;
updateBrush(
position: event.localPosition,
pressure: pressure,
tilt: tilt,
orientation: orientation,
barrelRotation: barrelRotation,
);
},
onAction: (StylusActionEvent event) {
final bool commitsPreferredAction = event.phase == StylusActionPhase.discrete || event.phase == StylusActionPhase.ended;
if (commitsPreferredAction && event.preferredAction == StylusPreferredAction.switchEraser) {
toggleEraser();
}
if (event.action == StylusAction.squeeze &&
event.phase == StylusActionPhase.began) {
showToolPalette(at: event.pose?.position);
}
},
onPrediction: (StylusPredictionEvent event) {
replacePredictedPath(event.pointerIdentifier, event.samples);
},
onCorrection: (StylusCorrectionEvent event) {
replaceRecordedSample(event.sampleIdentifier, event.correctedSample);
},
child: const DocumentCanvas(),
)
StyletListener ignores mouse and touch input by default. Set
includeNonStylus: true when one callback should normalize every pointer kind.
Do not dispose Stylet.instance; create and dispose a dedicated Stylet
controller only when an isolated lifecycle is needed.
Integrating an existing Listener #
If your editor already handles raw events in its document canvas, you can retain that listener and replace its local pressure/tilt extraction with one conversion:
final Stylet stylet = Stylet.instance;
void onPointerMove(PointerMoveEvent pointerEvent) {
final StylusMotionEvent event = stylet.convertPointerEvent(
event: pointerEvent,
);
brushController.extendStroke(
position: event.localPosition,
pressure: event.normalizedPressure ?? 1,
tilt: event.tilt ?? 0,
orientation: event.orientation ?? 0,
barrelRotation: event.barrelRotation ?? 0,
);
}
The controller maintains a short native-event cache and correlates samples by
embedder identifier when available, then by timestamp, tool, phase, and
position. event.source == StyletEventSource.combined indicates that a Flutter
event received native extensions.
Stylus body interactions are independent of pointer movement:
final StreamSubscription<StylusActionEvent> subscription = Stylet.instance.actions.listen(handleStylusAction);
// Cancel this application-owned subscription with its lifecycle.
await subscription.cancel();
Predictions are temporary previews. Each event replaces the preceding preview for its pointer, including an empty sample list that clears it:
final StreamSubscription<StylusPredictionEvent> predictions = Stylet.instance.predictions.listen((event) {
replacePredictedPath(
pointerIdentifier: event.pointerIdentifier,
samples: event.samples,
);
});
final StreamSubscription<StylusCorrectionEvent> corrections = Stylet.instance.corrections.listen((event) {
replaceRecordedSample(
identifier: event.sampleIdentifier,
sample: event.correctedSample,
);
});
Cancel both application-owned subscriptions with their lifecycle. Prediction positions use Flutter-view coordinates like other native events; transform them through the receiving render object when drawing inside a nested widget.
Web applications can additionally opt into a browser-composited delegated ink
trail. It is a visual latency optimization, not a replacement for authoritative
StyletListener samples. See docs/web_support.md for
feature detection, setup, and lifecycle details.
API model #
StylusMotionEvent uses consistent semantics across platforms:
- positions and deltas are logical pixels relative to the Flutter view;
- pressure and distance retain their device ranges, with normalized getters;
- tilt is the angle in radians away from the surface normal;
- orientation is the direction in radians of the projected stylus axis;
tiltXandtiltYare optional signed components in radians;- barrel rotation is clockwise radians around the stylus axis;
- tangential pressure is normalized from -1 to 1;
wheelDeltais signed relative stylus-wheel movement in radians;- buttons use Flutter's public button bit field; use
isSideButtonPressed(number: 1)instead of hard-coded masks; sampleIdentifiercorrelates an estimated sample with a later correction;- estimated properties and properties still expecting updates are distinct, because a value can remain estimated after the platform stops refining it.
StylusCapabilities describes what the current backend can potentially
provide. The features set on an individual motion sample is more precise and
should be used when behavior depends on a value being present.
Native device and tablet-pad events are available independently of motion:
final StreamSubscription<StylusDeviceEvent> devices = Stylet.instance.deviceEvents.listen(handleDeviceChange);
final StreamSubscription<TabletPadEvent> controls = Stylet.instance.padEvents.listen(handleTabletControl);
final Map<String, StylusDevice> connected = Stylet.instance.connectedDevices;
Cancel application-owned subscriptions with their lifecycle. Linux reports pad buttons, rings, strips, and mapping modes through Wayland tablet-v2 or GTK; relative dials require tablet-v2 version 2.
Wacom controls on macOS and Wintab controls on Windows require the application to replace the driver's current per-application mappings. Enable that behavior only while your editing surface is ready to consume the events:
final bool controlsClaimed = await Stylet.instance.setTabletPadOverrideEnabled(enabled: true);
// Release explicitly when leaving the editing experience. Cancelling Stylet's
// native event stream or unloading the plugin also restores the driver.
await Stylet.instance.setTabletPadOverrideEnabled(enabled: false);
The call returns false when there is no compatible driver/control or when the
platform uses passive pad delivery. macOS applications must also provide the
Automation usage description and entitlement described in
docs/native_backends.md.
Platform support #
| Feature | Android | iOS/iPadOS | Linux | macOS | Web | Windows |
|---|---|---|---|---|---|---|
| Pressure, tilt, orientation | Yes | Yes | Yes | Yes | Pointer Events | Yes |
| Hover pose | Yes | iPadOS 16.1+ | Yes | Yes | Pointer Events | Yes |
| Side buttons | Yes | No; double-tap/squeeze only | Yes | Yes | First barrel button | Yes |
| Eraser tool | Yes | Virtual via switchEraser |
Yes | Yes | Pointer Events | Yes |
| Barrel rotation | Driver AXIS_RZ |
iOS 17.5+ | GTK or tablet-v2 | AppKit rotation | PointerEvent.twist |
Windows Ink |
| Tangential pressure | — | — | GTK or tablet-v2 | AppKit barrel pressure | Pointer Events | Wintab driver, when present |
| Stylus wheel | — | — | Wayland tablet-v2 | — | — | — |
| High-rate sample delivery | Motion history | Coalesced touches | Protocol frames | Coalescing disabled | Coalesced/raw updates | Windows Ink history batches |
| Predicted trajectories | AndroidX Input | UIKit | — | — | Browser, when available | Experimental opt-in |
| Delegated compositor trail | — | — | — | — | Experimental Ink API | — |
| Estimated-value corrections | — | UIKit | — | — | — | — |
| Device identity / metadata | InputManager | — | GTK or tablet-v2 | AppKit | PE4 session ID, if available | Windows Ink |
| Pad buttons, rings, strips | — | — | GTK or tablet-v2 + libwacom metadata | Wacom DRI, explicit opt-in | — | Wintab, explicit opt-in |
| Relative pad dials | — | — | Tablet-v2 version 2 | — | — | — |
| Double-tap | — | Apple Pencil + preference | — | Sidecar / AppKit | — | — |
| Squeeze | — | iOS 17.5+, Apple Pencil Pro | — | — | — | — |
Hardware and tablet drivers determine whether an advertised axis produces meaningful values. Pen-motion backends observe input passively and return the native event unchanged, so Flutter's gesture arena remains authoritative. The only active takeover is the explicitly requested Wacom/Wintab pad override described above.
The iOS deployment target is 15.0 and the macOS deployment target is 12.0. Other targets follow the minimum versions supported by the current Flutter toolchain.
Native event contract #
Backend maintainers can find channel names, required packet fields, units, and
lifecycle rules in docs/platform_contract.md.
Native dependencies and fallback behavior are detailed in
docs/native_backends.md.
Web Pointer Events and delegated ink behavior are detailed in
docs/web_support.md.
The runnable application in example/ visualizes capabilities,
pressure, tilt, barrel angle, stylus-wheel movement, side buttons, body
actions, predictions, corrections, native devices, and tablet-pad controls.
Built for Focale, an advanced local image editor. Discover what these packages make possible in a real creative workflow.
