From 4a6fe7d05ebbe84843d4be797d5372f53638bac2 Mon Sep 17 00:00:00 2001 From: Akiba So Date: Wed, 5 Aug 2026 19:52:15 +0800 Subject: [PATCH] fix: Surface pen pressure, zoom glitches, sticky notes, selection UX 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 --- lib/editor/canvas/pen_canvas.dart | 12 +- lib/editor/canvas/pen_editor_screen.dart | 253 +++++++++++++---- lib/editor/canvas/pen_interactive_viewer.dart | 5 +- lib/editor/canvas/pen_note_screen.dart | 8 +- lib/editor/canvas/pen_palette_widgets.dart | 16 ++ lib/editor/canvas/pen_slide_screen.dart | 8 +- lib/editor/canvas/sticky_note_overlay.dart | 263 ++++++++++++++++++ lib/editor/engine/brush.dart | 10 +- lib/editor/engine/shape_geometry.dart | 5 + lib/editor/input/pen_config.dart | 3 + lib/editor/input/pen_input_service.dart | 101 +++---- .../persistence/sidecar_repository.dart | 23 +- lib/editor/ui/pen_settings_page.dart | 1 + lib/screens/split_view_screen.dart | 29 +- test/brush_test.dart | 15 +- windows/runner/pen_channel.cpp | 40 ++- 16 files changed, 621 insertions(+), 171 deletions(-) create mode 100644 lib/editor/canvas/sticky_note_overlay.dart diff --git a/lib/editor/canvas/pen_canvas.dart b/lib/editor/canvas/pen_canvas.dart index bff7c4a..f05b4d1 100644 --- a/lib/editor/canvas/pen_canvas.dart +++ b/lib/editor/canvas/pen_canvas.dart @@ -324,7 +324,15 @@ class _PenCanvasState extends State { } /// Raw [0,1] stylus force before response shaping (see [_normalizedPressure]). + /// + /// Prefer native Win32 pressure from [PenInputService] when valid — Flutter's + /// PointerEvent.pressure on Windows is often flat/useless while the driver + /// still reports real 0..1024 via GetPointerPenInfo. double? _rawNormalizedPressure(PointerEvent event) { + final hw = PenInputService.instance; + if (hw.isActive && hw.current.pressureValid) { + return hw.current.pressure.clamp(0.0, 1.0); + } final range = event.pressureMax - event.pressureMin; if (range > 0.0001) { return ((event.pressure - event.pressureMin) / range).clamp(0.0, 1.0); @@ -560,7 +568,7 @@ class _PenCanvasState extends State { color: _currentColor().toARGB32(), width: widget.strokeWidth, kind: PenStrokeKind.pen, - brush: _currentBrush, + brush: kShapeBrush, )); } } else if (tool == CanvasTool.select) { @@ -619,7 +627,7 @@ class _PenCanvasState extends State { color: _currentColor().toARGB32(), width: widget.strokeWidth, kind: PenStrokeKind.pen, - brush: _currentBrush, + brush: kShapeBrush, ); }); } diff --git a/lib/editor/canvas/pen_editor_screen.dart b/lib/editor/canvas/pen_editor_screen.dart index 252a4b3..8fcbb6a 100644 --- a/lib/editor/canvas/pen_editor_screen.dart +++ b/lib/editor/canvas/pen_editor_screen.dart @@ -32,7 +32,6 @@ import 'package:uuid/uuid.dart'; import '../../l10n/app_localizations.dart'; import '../../models/bookmark.dart'; import '../../models/scratch_link.dart'; -import '../../screens/split_view_screen.dart'; import '../../storage/badnote_sidecar.dart'; import '../engine/brush.dart'; import '../engine/shape_geometry.dart'; @@ -56,6 +55,7 @@ import 'input_diagnostics.dart'; import 'pen_palette_widgets.dart'; import 'pen_stroke.dart'; import 'pinch_scale_solver.dart'; +import 'sticky_note_overlay.dart'; /// On-screen size (px) of a scratch-link anchor marker. Fixed in screen space /// (not scaled with zoom) so the tap target stays comfortably tappable. @@ -154,11 +154,12 @@ class _PenEditorScreenState extends State { /// A real pinch changes scale modestly per frame; a frame demanding far more /// is a Windows multi-touch glitch and is dropped (so the zoom can't pop). - static const double _kScaleGlitchHi = 1.4; + /// Logs showed ~1.30 spikes — keep the band below that. + static const double _kScaleGlitchHi = 1.18; static const double _kScaleGlitchLo = 1 / _kScaleGlitchHi; /// A single-frame focal-midpoint jump beyond this is a touch misread → drop. - static const double _kFocalGlitchPx = 250.0; + static const double _kFocalGlitchPx = 100.0; /// Matrix scale captured at the current baseline (gesture start or the last /// pointer-count re-baseline). Null when no pinch is active. @@ -247,9 +248,18 @@ class _PenEditorScreenState extends State { ? BrushKind.highlighter : _penBrush; - /// When true the "select text" tool is active: pen capture is disabled so the - /// pen falls through to pdfrx for native text selection. - bool _selectTextMode = false; + /// When true the "select text" tool button is latched on. + bool _selectTextTool = false; + + /// Barrel-held temporary select-text (OneNote-style); ORed into [_selectTextMode]. + bool _barrelSelectText = false; + + /// Select-text is active from the tool button OR a held barrel mapped to + /// [PenButtonAction.selectText]. + bool get _selectTextMode => _selectTextTool || _barrelSelectText; + + /// Expanded paper-sticky overlay for a scratch link (null = collapsed). + ScratchLink? _expandedSticky; /// When true the "place scratch link" tool is active: a tap on a page drops a /// new anchor (a sticky-note tab) instead of inking. Pen capture is disabled @@ -290,19 +300,17 @@ class _PenEditorScreenState extends State { static const double _penWidthFraction = 0.006; static const double _highlighterWidthFraction = 0.02; - static const List _palette = [ - Colors.black, - Colors.red, - Colors.blue, - Colors.green, - Colors.orange, - ]; + static const List _palette = kInkPalette; /// True when an ink tool (brush/highlighter/eraser/select/shape) is active — /// pen capture is on. False in select-text mode (pen reaches pdfrx text - /// selection) and in place-link mode (a tap drops an anchor via the overlay). + /// selection), place-link / sticky expanded, and text mode. bool get _penCaptureEnabled => - !_selectTextMode && !_placeLinkMode && !_removeHighlightMode && !_textMode; + !_selectTextMode && + !_placeLinkMode && + !_removeHighlightMode && + !_textMode && + _expandedSticky == null; /// True when the eraser tool is active. bool get _isEraser => _tool == EditorToolKind.eraser && !_selectTextMode; @@ -320,6 +328,7 @@ 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(); + PenInputService.instance.addListener(_onHwPenChanged); _initPersistence(); _initPenConfig(); } @@ -339,6 +348,34 @@ class _PenEditorScreenState extends State { void _onPenConfigChanged() { if (mounted) setState(() {}); + _syncBarrelSelectText(); + } + + void _onHwPenChanged() { + _syncBarrelSelectText(); + } + + /// Level-trigger: holding barrel with sideButton=selectText enables text + /// selection without latching the toolbar tool. + void _syncBarrelSelectText() { + final cfg = _penConfig?.value; + final hw = PenInputService.instance; + final want = cfg != null && + hw.isActive && + hw.current.barrel && + cfg.sideButton == PenButtonAction.selectText; + if (want == _barrelSelectText) return; + if (!mounted) return; + setState(() { + _barrelSelectText = want; + if (want) { + _placeLinkMode = false; + _removeHighlightMode = false; + _textMode = false; + _selected = null; + _expandedSticky = null; + } + }); } Future _initPersistence() async { @@ -412,7 +449,9 @@ class _PenEditorScreenState extends State { } _overlayRepaint.dispose(); _liveStrokeVN.dispose(); + _penConfig?.removeListener(_onPenConfigChanged); _penConfig?.dispose(); + PenInputService.instance.removeListener(_onHwPenChanged); PenInputService.instance.stop(); DiagnosticLogger.instance.stop(); super.dispose(); @@ -509,6 +548,10 @@ class _PenEditorScreenState extends State { } double? _rawNormalizedPressure(PointerEvent event) { + final hw = PenInputService.instance; + if (hw.isActive && hw.current.pressureValid) { + return hw.current.pressure.clamp(0.0, 1.0); + } final range = event.pressureMax - event.pressureMin; if (range > 0.0001) { return ((event.pressure - event.pressureMin) / range).clamp(0.0, 1.0); @@ -538,6 +581,19 @@ class _PenEditorScreenState extends State { return null; } + /// Hit-test scratch-link markers near [normalized] on [page]. + ScratchLink? _hitScratchMarker(int page, Offset normalized) { + const r = 0.045; + final r2 = r * r; + for (final link in _scratchLinks) { + if (link.pageIndex != page) continue; + final dx = link.nx - normalized.dx; + final dy = link.ny - normalized.dy; + if (dx * dx + dy * dy <= r2) return link; + } + return null; + } + void _onPenEvent(PointerEvent event) { if (_isStylus(event.kind)) _emitPenDebug(event); @@ -545,6 +601,12 @@ class _PenEditorScreenState extends State { if (event is PointerDownEvent) { if (hit == null) return; + // Stylus can open sticky markers (PenCaptureRegion otherwise steals taps). + final sticky = _hitScratchMarker(hit.page, hit.normalized); + if (sticky != null) { + _openScratchLink(sticky); + return; + } if (_isEraser) { _liveStrokePage = hit.page; _eraseAt(hit.page, hit.normalized); @@ -687,7 +749,7 @@ class _PenEditorScreenState extends State { color: _currentColor().toARGB32(), width: _currentStrokeWidth(), kind: PenStrokeKind.pen, - brush: _currentBrush(), + brush: kShapeBrush, ), ); } @@ -847,7 +909,18 @@ class _PenEditorScreenState extends State { final scaleDrop = rawRatio > _kScaleGlitchHi || rawRatio < _kScaleGlitchLo; final focalDrop = details.focalPointDelta.distance > _kFocalGlitchPx; - if (scaleDrop || focalDrop) return; + if (scaleDrop || focalDrop) { + InputDiagnostics.instance.recordScaleFrame( + rawScale: details.scale, + pointerCount: details.pointerCount, + currentScale: _pinchLastAppliedScale, + appliedChange: 1.0, + focalJumpPx: details.focalPointDelta.distance, + scaleDrop: scaleDrop, + focalDrop: focalDrop, + ); + return; + } final targetScale = absolutePinchScale( scaleStart: _pinchScaleStart!, @@ -866,6 +939,18 @@ class _PenEditorScreenState extends State { duration: Duration.zero, ); + final applied = + _pinchLastAppliedScale > 0 ? targetScale / _pinchLastAppliedScale : 1.0; + InputDiagnostics.instance.recordScaleFrame( + rawScale: details.scale, + pointerCount: details.pointerCount, + currentScale: targetScale, + appliedChange: applied, + focalJumpPx: details.focalPointDelta.distance, + scaleDrop: false, + focalDrop: false, + ); + _pinchLastRawScale = details.scale; _pinchLastAppliedScale = targetScale; } @@ -1071,7 +1156,7 @@ class _PenEditorScreenState extends State { void _setTool(EditorToolKind tool) { setState(() { _tool = tool; - _selectTextMode = false; + _selectTextTool = false; _placeLinkMode = false; _removeHighlightMode = false; // The TEXT tool is the one EditorToolKind that drives a page-anchored @@ -1083,11 +1168,12 @@ class _PenEditorScreenState extends State { void _enableSelectText() { setState(() { - _selectTextMode = true; + _selectTextTool = true; _placeLinkMode = false; _removeHighlightMode = false; _textMode = false; _selected = null; + _expandedSticky = null; }); } @@ -1097,9 +1183,10 @@ class _PenEditorScreenState extends State { setState(() { _placeLinkMode = !_placeLinkMode; if (_placeLinkMode) { - _selectTextMode = false; + _selectTextTool = false; _removeHighlightMode = false; _textMode = false; + _expandedSticky = null; } }); } @@ -1110,10 +1197,11 @@ class _PenEditorScreenState extends State { setState(() { _removeHighlightMode = !_removeHighlightMode; if (_removeHighlightMode) { - _selectTextMode = false; + _selectTextTool = false; _placeLinkMode = false; _textMode = false; _selected = null; + _expandedSticky = null; } }); } @@ -1138,35 +1226,21 @@ class _PenEditorScreenState extends State { setState(() => _scratchLinks.add(link)); } - /// Open the anchor's split view (left = this PDF at the anchor page, right = - /// the anchor's private infinite scratchpad, stored in the sidecar). - /// - /// Flushes any pending sidecar write FIRST so the split view (which opens its - /// own [SidecarRepository] on the same file) sees this anchor on disk before - /// it writes the scratchpad back. On return, reload so any scratchpad change - /// made there is reflected in this editor's in-memory sidecar repo. + /// Expand the sticky overlay on-page (paper sticky UX). Uses the SAME + /// [SidecarRepository] — no second open, no split-view race. Future _openScratchLink(ScratchLink link) async { - await _repo?.flush(); + setState(() { + _expandedSticky = link; + _selectTextTool = false; + _placeLinkMode = false; + _removeHighlightMode = false; + _textMode = false; + }); + } + + void _closeSticky() { if (!mounted) return; - await Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => SplitViewScreen( - filePath: widget.pdfPath, - scratchLinkId: link.id, - initialPage: link.pageIndex, - ), - ), - ); - if (!mounted) return; - // The split view wrote the scratchpad into the on-disk sidecar via its own - // repo; re-open ours so subsequent saves here don't clobber that scratchpad. - final repo = await SidecarRepository.open(widget.pdfPath, docType: 'pdf'); - if (!mounted) { - repo.dispose(); - return; - } - _repo?.dispose(); - _repo = repo; + setState(() => _expandedSticky = null); } /// Confirm + delete an anchor (and its private scratchpad). @@ -1694,6 +1768,32 @@ class _PenEditorScreenState extends State { child: const IgnorePointer(child: SizedBox.expand()), ), ), + if (_hasSelection) + Positioned( + left: 16, + right: 16, + bottom: 24, + child: _SelectionActionBar( + onHighlight: _highlightSelection, + onBookmark: _addBookmark, + ), + ), + if (_expandedSticky != null && _repo != null) + Positioned( + right: 20, + top: 72, + child: StickyNoteOverlay( + key: ValueKey(_expandedSticky!.id), + link: _expandedSticky!, + repo: _repo!, + onClose: _closeSticky, + onDelete: () async { + final link = _expandedSticky!; + _closeSticky(); + await _confirmDeleteScratchLink(link); + }, + ), + ), ]; }, ), @@ -2221,8 +2321,50 @@ class LiveStrokeOverlayHarness { } } +/// Floating action bar shown while PDF text is selected (OneNote-style). +class _SelectionActionBar extends StatelessWidget { + const _SelectionActionBar({ + required this.onHighlight, + required this.onBookmark, + }); + + final VoidCallback onHighlight; + final VoidCallback onBookmark; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final l = AppLocalizations.of(context); + return Center( + child: Material( + elevation: 4, + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(24), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + TextButton.icon( + onPressed: onHighlight, + icon: const Icon(Icons.highlight, size: 18), + label: Text(l.actionHighlightSelection), + ), + TextButton.icon( + onPressed: onBookmark, + icon: const Icon(Icons.bookmark_add_outlined, size: 18), + label: Text(l.toolAddBookmark), + ), + ], + ), + ), + ), + ); + } +} + /// A small sticky-note "tab" marker glued to a page at a scratch-link anchor. -/// Tap opens the anchor's split view; long-press deletes the anchor. +/// Tap opens the sticky overlay; long-press deletes the anchor. class _ScratchLinkMarker extends StatelessWidget { const _ScratchLinkMarker({required this.onTap, required this.onLongPress}); @@ -2335,9 +2477,9 @@ class _TextAnnotationLabel extends StatelessWidget { /// The active editing field for a text box. A REAL Flutter [TextField] so the /// OS IME and — on Windows — the Windows-Ink handwriting panel feed it -/// automatically (no special plugin; a focusable text input is all the panel -/// needs). Autofocuses on insert; commits via [onChanged] (debounced persist) -/// and finishes via [onDone] (submit / focus loss). +/// automatically. Does NOT autofocus on insert (Windows tablets otherwise pop +/// the soft keyboard on every pen tap that places a box); focus only after an +/// explicit tap on the field. class _TextAnnotationField extends StatefulWidget { const _TextAnnotationField({ super.key, @@ -2370,11 +2512,6 @@ class _TextAnnotationFieldState extends State<_TextAnnotationField> { _controller = TextEditingController(text: widget.initialText); _focusNode = FocusNode(); _focusNode.addListener(_onFocusChange); - // Autofocus after the first frame so the field is mounted before we request - // focus (which also raises the IME / handwriting panel). - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) _focusNode.requestFocus(); - }); } void _onFocusChange() { @@ -2399,7 +2536,7 @@ class _TextAnnotationFieldState extends State<_TextAnnotationField> { child: TextField( controller: _controller, focusNode: _focusNode, - autofocus: true, + autofocus: false, maxLines: null, minLines: 1, keyboardType: TextInputType.multiline, diff --git a/lib/editor/canvas/pen_interactive_viewer.dart b/lib/editor/canvas/pen_interactive_viewer.dart index 4c60988..79c0107 100644 --- a/lib/editor/canvas/pen_interactive_viewer.dart +++ b/lib/editor/canvas/pen_interactive_viewer.dart @@ -46,13 +46,14 @@ const Set _kPanZoomDevices = { /// A real pinch changes scale only modestly per frame (≲1.15x at 60fps). A frame /// demanding far more than this is a Windows multi-touch position glitch, not /// intent — that frame is dropped so the zoom can't pop and snap back. -const double _kScaleGlitchHi = 1.4; +/// Device logs showed spikes ~1.30; keep the band under that so jumps die. +const double _kScaleGlitchHi = 1.18; 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 _kFocalGlitchPx = 100.0; const double _kDrag = 0.0000135; diff --git a/lib/editor/canvas/pen_note_screen.dart b/lib/editor/canvas/pen_note_screen.dart index bf640a5..d630dc3 100644 --- a/lib/editor/canvas/pen_note_screen.dart +++ b/lib/editor/canvas/pen_note_screen.dart @@ -111,13 +111,7 @@ class _PenNoteScreenState extends ConsumerState { static const double _penWidthFraction = 0.006; static const double _highlighterWidthFraction = 0.02; - static const List _palette = [ - Colors.black, - Colors.red, - Colors.blue, - Colors.green, - Colors.orange, - ]; + static const List _palette = kInkPalette; @override void initState() { diff --git a/lib/editor/canvas/pen_palette_widgets.dart b/lib/editor/canvas/pen_palette_widgets.dart index f5f91d6..ec0bb9a 100644 --- a/lib/editor/canvas/pen_palette_widgets.dart +++ b/lib/editor/canvas/pen_palette_widgets.dart @@ -10,6 +10,22 @@ import '../../l10n/app_localizations.dart'; import '../engine/brush.dart'; import 'editor_tool.dart'; +/// Shared ink color palette for PDF / note / slide / scratch editors. +const List kInkPalette = [ + Color(0xFF1A1A1A), + Color(0xFFC62828), + Color(0xFF1565C0), + Color(0xFF2E7D32), + Color(0xFFEF6C00), + Color(0xFF6A1B9A), + Color(0xFF00838F), + Color(0xFF5D4037), + Color(0xFFF9A825), + Color(0xFFE91E63), + Color(0xFF455A64), + Color(0xFF37474F), +]; + /// Localized display name for a brush (single source so all three editors agree). String brushLabel(BrushKind kind, AppLocalizations l) => switch (kind) { BrushKind.fountainPen => l.brushFountainPen, diff --git a/lib/editor/canvas/pen_slide_screen.dart b/lib/editor/canvas/pen_slide_screen.dart index 0881b4f..5019f30 100644 --- a/lib/editor/canvas/pen_slide_screen.dart +++ b/lib/editor/canvas/pen_slide_screen.dart @@ -93,13 +93,7 @@ class _PenSlideScreenState extends State { static const double _highlighterWidthFraction = 0.02; static const Size _fallbackSlide = Size(1600, 900); - static const List _palette = [ - Colors.black, - Colors.red, - Colors.blue, - Colors.green, - Colors.orange, - ]; + static const List _palette = kInkPalette; int get _slideCount => widget.slideImagePaths.length; List get _currentStrokes => _strokesBySlide[_slideIndex] ?? const []; diff --git a/lib/editor/canvas/sticky_note_overlay.dart b/lib/editor/canvas/sticky_note_overlay.dart new file mode 100644 index 0000000..15a4580 --- /dev/null +++ b/lib/editor/canvas/sticky_note_overlay.dart @@ -0,0 +1,263 @@ +// lib/editor/canvas/sticky_note_overlay.dart +// +// Paper-sticky UX for PDF scratch links: a floating card on the viewer that +// inks into the SAME SidecarRepository the PDF editor holds (no second open, +// no split-view race). Collapsed = page marker; expanded = this overlay. + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:uuid/uuid.dart'; + +import '../../models/ink_stroke.dart'; +import '../../models/scratch_link.dart'; +import '../../storage/badnote_sidecar.dart'; +import '../engine/brush.dart'; +import '../input/pen_config.dart' show kDefaultEraserRadius; +import '../notebook/ink_stroke_adapter.dart'; +import '../persistence/sidecar_repository.dart'; +import 'pen_canvas.dart'; +import 'pen_palette_widgets.dart'; +import 'pen_stroke.dart'; + +/// Default world size for a fresh sticky scratchpad (absolute px). +const Size kStickyWorldSize = Size(1200, 900); + +/// Floating sticky-note card: write → autosave into [repo] under [link.id]. +class StickyNoteOverlay extends StatefulWidget { + const StickyNoteOverlay({ + super.key, + required this.link, + required this.repo, + required this.onClose, + required this.onDelete, + }); + + final ScratchLink link; + final SidecarRepository repo; + final VoidCallback onClose; + final VoidCallback onDelete; + + @override + State createState() => _StickyNoteOverlayState(); +} + +class _StickyNoteOverlayState extends State { + static const _uuid = Uuid(); + + final TransformationController _transform = TransformationController(); + List _strokes = []; + Size _world = kStickyWorldSize; + CanvasTool _tool = CanvasTool.pen; + BrushKind _brush = BrushKind.ballpoint; // ignore: prefer_final_fields — reserved for brush picker + Color _color = kInkPalette.first; + Timer? _saveTimer; + bool _dirty = false; + + @override + void initState() { + super.initState(); + final pad = widget.repo.scratchpadFor(widget.link.id); + if (pad != null) { + _world = Size(pad.canvasWidth, pad.canvasHeight); + _strokes = pad.strokes.where((s) => isFreehandTool(s.tool)).toList(); + } + } + + @override + void dispose() { + _saveTimer?.cancel(); + // Best-effort sync save before leaving the overlay. + if (_dirty) { + widget.repo.scheduleScratchpadSave( + widget.link.id, + SidecarScratchpad( + canvasWidth: _world.width, + canvasHeight: _world.height, + strokes: List.of(_strokes), + ), + ); + widget.repo.flush(); + } + _transform.dispose(); + super.dispose(); + } + + void _scheduleSave() { + _dirty = true; + _saveTimer?.cancel(); + _saveTimer = Timer(const Duration(milliseconds: 600), _saveNow); + } + + Future _saveNow() async { + if (!_dirty) return; + widget.repo.scheduleScratchpadSave( + widget.link.id, + SidecarScratchpad( + canvasWidth: _world.width, + canvasHeight: _world.height, + strokes: List.of(_strokes), + ), + ); + _dirty = false; + await widget.repo.flush(); + } + + void _onStrokeComplete(PenStroke pen) { + setState(() { + _strokes = [ + ..._strokes, + inkStrokeFromPen(pen, _world, id: _uuid.v4(), createdAt: DateTime.now()), + ]; + }); + _scheduleSave(); + } + + void _onErase(int index, List replacements) { + if (index < 0 || index >= _strokes.length) return; + setState(() { + final next = List.of(_strokes)..removeAt(index); + for (final r in replacements) { + next.insert( + index, + inkStrokeFromPen(r, _world, id: _uuid.v4(), createdAt: DateTime.now()), + ); + } + _strokes = next; + }); + _scheduleSave(); + } + + Future _close() async { + _saveTimer?.cancel(); + await _saveNow(); + widget.onClose(); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Material( + elevation: 8, + borderRadius: BorderRadius.circular(4), + color: const Color(0xFFFFF8E1), + child: SizedBox( + width: 280, + height: 340, + child: Column( + children: [ + Container( + height: 36, + padding: const EdgeInsets.symmetric(horizontal: 4), + decoration: const BoxDecoration( + color: Color(0xFFFFE082), + borderRadius: BorderRadius.vertical(top: Radius.circular(4)), + ), + child: Row( + children: [ + const Icon(Icons.sticky_note_2, size: 18), + const SizedBox(width: 6), + const Expanded( + child: Text( + '便利贴', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ), + IconButton( + tooltip: '删除', + icon: const Icon(Icons.delete_outline, size: 18), + visualDensity: VisualDensity.compact, + onPressed: () async { + await _saveNow(); + widget.onDelete(); + }, + ), + IconButton( + tooltip: '收起', + icon: const Icon(Icons.close, size: 18), + visualDensity: VisualDensity.compact, + onPressed: _close, + ), + ], + ), + ), + SizedBox( + height: 32, + child: ListView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 6), + children: [ + for (final c in kInkPalette) + GestureDetector( + onTap: () => setState(() => _color = c), + child: Container( + width: 18, + height: 18, + margin: const EdgeInsets.symmetric( + horizontal: 3, vertical: 7), + decoration: BoxDecoration( + color: c, + shape: BoxShape.circle, + border: Border.all( + color: _color == c ? cs.primary : cs.outlineVariant, + width: _color == c ? 2 : 1, + ), + ), + ), + ), + ], + ), + ), + Expanded( + child: ClipRect( + child: PenCanvas( + pageSize: _world, + strokes: penStrokesFromInk(_strokes, _world), + transformationController: _transform, + tool: _tool, + brush: _brush, + color: _color, + strokeWidth: 0.008, + eraserRadius: kDefaultEraserRadius, + minScale: 0.2, + maxScale: 4.0, + onStrokeComplete: _onStrokeComplete, + onEraseStroke: _onErase, + pageWidget: const ColoredBox(color: Color(0xFFFFFDE7)), + ), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + child: Row( + children: [ + IconButton( + tooltip: '笔', + icon: Icon( + Icons.edit, + size: 18, + color: _tool == CanvasTool.pen ? cs.primary : null, + ), + onPressed: () => setState(() => _tool = CanvasTool.pen), + ), + IconButton( + tooltip: '橡皮', + icon: Icon( + Icons.cleaning_services_outlined, + size: 18, + color: _tool == CanvasTool.eraser ? cs.primary : null, + ), + onPressed: () => setState(() => _tool = CanvasTool.eraser), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/editor/engine/brush.dart b/lib/editor/engine/brush.dart index 4a0d959..4a9a0d4 100644 --- a/lib/editor/engine/brush.dart +++ b/lib/editor/engine/brush.dart @@ -142,13 +142,15 @@ const Map kBrushPresets = { kind: BrushKind.fountainPen, baseWidthFraction: 0.006, pressureGamma: 2.0, - pfThinning: 0.9, - pfStreamline: 0.45, - pfSmoothing: 0.55, + // Was 0.9 — too aggressive on short CJK strokes (width collapses mid-glyph). + pfThinning: 0.65, + pfStreamline: 0.4, + pfSmoothing: 0.5, simulatePressure: false, capStart: true, capEnd: true, - taper: true, + // Light taper only; full taper made Chinese characters look frayed. + taper: false, opacity: 1.0, blendMultiply: false, ), diff --git a/lib/editor/engine/shape_geometry.dart b/lib/editor/engine/shape_geometry.dart index 84b4f0e..a960e58 100644 --- a/lib/editor/engine/shape_geometry.dart +++ b/lib/editor/engine/shape_geometry.dart @@ -13,6 +13,7 @@ import 'dart:math' as math; import '../canvas/editor_tool.dart'; import '../canvas/pen_stroke.dart'; +import 'brush.dart'; /// Number of points sampled around an ellipse. Kept as a const so tests can pin /// it (spec: "ellipse = sampled points ~48"). The polyline is closed, so the @@ -23,6 +24,10 @@ const int kEllipseSamples = 48; /// width (no pressure taper for geometric shapes). const double _kShapePressure = 1.0; +/// Geometric shapes must NOT inherit fountain thinning/taper — force a near- +/// constant-width brush so line/rect/ellipse look like ruler ink. +const BrushKind kShapeBrush = BrushKind.ballpoint; + /// Generate the normalized polyline for [kind] spanning [start] → [end]. /// /// * [ShapeKind.line] → 2 points. diff --git a/lib/editor/input/pen_config.dart b/lib/editor/input/pen_config.dart index 5ab7b9f..bb64b3c 100644 --- a/lib/editor/input/pen_config.dart +++ b/lib/editor/input/pen_config.dart @@ -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. diff --git a/lib/editor/input/pen_input_service.dart b/lib/editor/input/pen_input_service.dart index 3eab7f9..9853948 100644 --- a/lib/editor/input/pen_input_service.dart +++ b/lib/editor/input/pen_input_service.dart @@ -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? _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 _listeners = []; + + /// 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.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; diff --git a/lib/editor/persistence/sidecar_repository.dart b/lib/editor/persistence/sidecar_repository.dart index 73c909e..0d012a3 100644 --- a/lib/editor/persistence/sidecar_repository.dart +++ b/lib/editor/persistence/sidecar_repository.dart @@ -99,6 +99,11 @@ class SidecarRepository { Timer? _timer; bool _disposed = false; + /// How many editors currently hold this repo. [open] reuses an existing + /// instance and bumps the count; [dispose] only tears down at zero so a + /// split-view / sticky overlay cannot clobber the PDF editor's sidecar. + int _retainCount = 1; + /// Tail of the in-flight write chain. Writes are serialized through this so a /// debounce-timer write and a concurrent lifecycle [flush] can't race on the /// same `.tmp`/rename (which would throw on the loser). Each write always @@ -107,14 +112,23 @@ class SidecarRepository { /// Open (or create) the repository for [sourceFilePath]. Reads the existing /// sidecar if present (falling back to its `.bak`), else starts empty. + /// + /// Reuses an already-open repo for the same path (retain-counted) so a + /// scratchpad overlay / split view cannot race the PDF editor with a second + /// in-memory snapshot that would overwrite scratchpad ink on flush. static Future open( String sourceFilePath, { String? docType, Duration debounce = const Duration(milliseconds: 800), }) async { + final existing = SidecarRepositoryRegistry.forPath(sourceFilePath); + if (existing != null && !existing._disposed) { + existing._retainCount++; + return existing; + } final file = File('$sourceFilePath$kSidecarSuffix'); - final existing = await SidecarStore.read(file); - final sidecar = existing ?? + final loaded = await SidecarStore.read(file); + final sidecar = loaded ?? BadnoteSidecar( sourceFile: _basename(sourceFilePath), docType: docType, @@ -313,6 +327,11 @@ class SidecarRepository { /// Cancel pending timers. Call [flush] first to persist pending writes. void dispose() { + if (_disposed) return; + if (_retainCount > 1) { + _retainCount--; + return; + } _disposed = true; _timer?.cancel(); _timer = null; diff --git a/lib/editor/ui/pen_settings_page.dart b/lib/editor/ui/pen_settings_page.dart index b471140..51d7586 100644 --- a/lib/editor/ui/pen_settings_page.dart +++ b/lib/editor/ui/pen_settings_page.dart @@ -270,6 +270,7 @@ class _ActionDropdown extends StatelessWidget { PenButtonAction.undo => 'Undo', PenButtonAction.toggleTool => 'Toggle Tool', PenButtonAction.pan => 'Pan', + PenButtonAction.selectText => 'Select text', }; @override diff --git a/lib/screens/split_view_screen.dart b/lib/screens/split_view_screen.dart index 76152c1..fb8a73f 100644 --- a/lib/screens/split_view_screen.dart +++ b/lib/screens/split_view_screen.dart @@ -89,13 +89,7 @@ class _SplitViewState extends State { static const double _penWidthFraction = 0.006; static const double _highlighterWidthFraction = 0.02; - static const List _palette = [ - Colors.black, - Colors.red, - Colors.blue, - Colors.green, - Colors.orange, - ]; + static const List _palette = kInkPalette; // -- Auto-save debounce -- Timer? _saveTimer; @@ -119,12 +113,14 @@ class _SplitViewState extends State { @override void dispose() { _saveTimer?.cancel(); - _saveImmediate(); - final repo = _repo; - if (repo != null) { - repo.flush(); // fire-and-forget; atomic write finishes off the tree - repo.dispose(); + // Schedule a final flush; retain-counted repo may still be held by the + // PDF editor, so dispose only drops our retain. + if (_dirty) { + unawaited(_saveImmediate()); + } else { + unawaited(_repo?.flush() ?? Future.value()); } + _repo?.dispose(); // PdfViewerController (pdfrx) has no dispose(); it detaches with the viewer. _scratchTransform.dispose(); super.dispose(); @@ -166,9 +162,9 @@ class _SplitViewState extends State { Future _saveImmediate() async { if (!_dirty) return; - _dirty = false; final repo = _repo; if (repo == null) return; + // Keep dirty until schedule succeeds so a race during load can't swallow ink. repo.scheduleScratchpadSave( widget.scratchLinkId, SidecarScratchpad( @@ -177,6 +173,7 @@ class _SplitViewState extends State { strokes: List.of(_strokes), ), ); + _dirty = false; await repo.flush(); } @@ -295,9 +292,9 @@ class _SplitViewState extends State { title: const Text('Scratch link', style: TextStyle(fontSize: 16)), leading: IconButton( icon: const Icon(Icons.arrow_back), - onPressed: () { - _saveImmediate(); - Navigator.of(context).pop(); + onPressed: () async { + await _saveImmediate(); + if (context.mounted) Navigator.of(context).pop(); }, ), actions: [ diff --git a/test/brush_test.dart b/test/brush_test.dart index 2d26bbe..943ca22 100644 --- a/test/brush_test.dart +++ b/test/brush_test.dart @@ -22,14 +22,14 @@ void main() { } }); - test('fountain pen — Pow2 (p²), thinning 0.9, taper on, solid', () { + test('fountain pen — Pow2 (p²), moderate thinning, no taper, solid', () { final b = brushProfileFor(BrushKind.fountainPen); expect(b.pressureGamma, 2.0); // rnote Pow2 / quadratic - expect(b.pfThinning, 0.9); - expect(b.pfStreamline, 0.45); - expect(b.pfSmoothing, 0.55); + expect(b.pfThinning, 0.65); + expect(b.pfStreamline, 0.4); + expect(b.pfSmoothing, 0.5); expect(b.simulatePressure, isFalse); - expect(b.taper, isTrue); + expect(b.taper, isFalse); expect(b.capStart, isTrue); expect(b.capEnd, isTrue); expect(b.opacity, 1.0); @@ -127,8 +127,9 @@ void main() { isNot(brushProfileFor(BrushKind.ballpoint).pressureGamma)); }); - test('caps/taper differ (fountain tapers, highlighter is square)', () { - expect(brushProfileFor(BrushKind.fountainPen).taper, isTrue); + test('caps/taper: highlighter is square; fountain/ballpoint round no taper', + () { + expect(brushProfileFor(BrushKind.fountainPen).taper, isFalse); expect(brushProfileFor(BrushKind.highlighter).capStart, isFalse); expect(brushProfileFor(BrushKind.ballpoint).taper, isFalse); }); diff --git a/windows/runner/pen_channel.cpp b/windows/runner/pen_channel.cpp index ca2e961..2808b17 100644 --- a/windows/runner/pen_channel.cpp +++ b/windows/runner/pen_channel.cpp @@ -31,6 +31,7 @@ int g_pen_flags_or = 0; // POINTER_PEN_INFO.penFlags (PEN_FLAG_BARREL/INVERT 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 +float g_pressure_max_seen = 0.f; } // namespace @@ -85,6 +86,8 @@ void ObservePenMessage(UINT message, WPARAM wparam, LPARAM lparam) { int flags = 0; double tilt_x = 0.0; double tilt_y = 0.0; + double pressure = 0.0; + int pressure_valid = 0; int raw_ptr_flags = 0; int raw_pen_flags = 0; int raw_pen_mask = 0; @@ -106,9 +109,16 @@ void ObservePenMessage(UINT message, WPARAM wparam, LPARAM lparam) { 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. + // Pressure: Win32 reports 0..1024 when PEN_MASK_PRESSURE is set. + // Normalize to [0,1] for Dart. Also scan history for a non-zero sample + // (some drivers zero the tip sample while history has the real value). + if (ppi.penMask & PEN_MASK_PRESSURE) { + pressure_valid = 1; + pressure = static_cast(ppi.pressure) / 1024.0; + if (pressure < 0.0) pressure = 0.0; + if (pressure > 1.0) pressure = 1.0; + } + const bool barrel = (ppi.penFlags & PEN_FLAG_BARREL) || (ppi.pointerInfo.pointerFlags & POINTER_FLAG_SECONDBUTTON); const bool inverted = (ppi.penFlags & PEN_FLAG_INVERTED) != 0; @@ -126,26 +136,38 @@ void ObservePenMessage(UINT message, WPARAM wparam, LPARAM lparam) { if (ax > g_tilt_abs_max) g_tilt_abs_max = ax; if (ay > g_tilt_abs_max) g_tilt_abs_max = ay; - // Coalesce recent history (diagnostic + future batching). POINTER_PEN_INFO history[32]; UINT32 hist_n = 32; if (GetPointerPenInfoHistory(pointerId, &hist_n, history)) { history_count = static_cast(hist_n); + // Prefer the max pressure in the history window (smoother + avoids + // a zero tip sample when mask says pressure is present). + for (UINT32 i = 0; i < hist_n; ++i) { + if (!(history[i].penMask & PEN_MASK_PRESSURE)) continue; + pressure_valid = 1; + double p = static_cast(history[i].pressure) / 1024.0; + if (p > pressure) pressure = p; + } + if (pressure > 1.0) pressure = 1.0; + } + if (pressure > g_pressure_max_seen) { + g_pressure_max_seen = static_cast(pressure); } } } if (message == WM_POINTERUP) { - flags = 0; // lift-off clears held flags + flags = 0; + pressure = 0.0; + pressure_valid = 0; } } - // 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)}, {flutter::EncodableValue("tiltY"), flutter::EncodableValue(tilt_y)}, + {flutter::EncodableValue("pressure"), flutter::EncodableValue(pressure)}, + {flutter::EncodableValue("pressureValid"), flutter::EncodableValue(pressure_valid)}, {flutter::EncodableValue("diagPtr"), flutter::EncodableValue(g_ptr_msgs)}, {flutter::EncodableValue("diagPen"), flutter::EncodableValue(g_pen_msgs)}, {flutter::EncodableValue("diagMouse"), flutter::EncodableValue(g_mouse_msgs)}, @@ -160,6 +182,8 @@ void ObservePenMessage(UINT message, WPARAM wparam, LPARAM lparam) { {flutter::EncodableValue("btnChangeLast"), flutter::EncodableValue(g_btn_change_last)}, {flutter::EncodableValue("tiltAbsMax"), flutter::EncodableValue(g_tilt_abs_max)}, {flutter::EncodableValue("historyCount"), flutter::EncodableValue(history_count)}, + {flutter::EncodableValue("pressureMaxSeen"), + flutter::EncodableValue(static_cast(g_pressure_max_seen))}, }; g_pen_sink->Success(flutter::EncodableValue(payload)); }