From 45d89b7790d9bd2cd8401bc65a7d2c68837ba98c Mon Sep 17 00:00:00 2001 From: Akiba So Date: Mon, 22 Jun 2026 21:14:50 +0800 Subject: [PATCH] fix(pen): re-baseline zoom on pointer-count change; observe pen on child HWND MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zoom jumping (device: min 0.5 / max 2.47 while zooming near 1): the per-frame scale clamp limited single-frame spikes but not multi-frame runs. Root cause is pointer-count transitions — on Windows touch the two fingers land/lift at different times and digitizers drop/re-acquire touches, and stock InteractiveViewer keeps _scaleStart/_referenceFocalPoint from the OLD finger set, so the next frame jumps. PenInteractiveViewer now re-baselines (and skips the transitional frame) whenever details.pointerCount changes. The per-frame clamp stays as a secondary guard. Buttons (device evidence: btn=1 for tip-down, side-button, AND inverted; kind never becomes invertedStylus): Flutter does NOT surface the barrel/eraser/inverted state at all — unreachable from Dart. The only path is the native badnote/pen plugin, which was SILENT because WM_POINTER is delivered to the Flutter CHILD view window, not the top-level FlutterWindow where ObservePenMessage was hooked. Fix: subclass the child HWND (SetWindowSubclass + comctl32) and observe its WM_POINTER messages, passing every message through unchanged via DefSubclassProc (observation-only, input behavior preserved). This is what should finally feed GetPointerPenInfo penFlags + tilt to the channel — to be confirmed on-device with the diagnostic (btn / kind / tilt readout). Dart: analyze clean, 66/66 tests, linux build green. Native compiles on Windows CI. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/editor/canvas/pen_interactive_viewer.dart | 18 +++++++++ windows/runner/CMakeLists.txt | 3 ++ windows/runner/flutter_window.cpp | 40 ++++++++++++++++++- 3 files changed, 60 insertions(+), 1 deletion(-) diff --git a/lib/editor/canvas/pen_interactive_viewer.dart b/lib/editor/canvas/pen_interactive_viewer.dart index a320acf..66940d9 100644 --- a/lib/editor/canvas/pen_interactive_viewer.dart +++ b/lib/editor/canvas/pen_interactive_viewer.dart @@ -91,6 +91,11 @@ class _PenInteractiveViewerState extends State double? _scaleStart; _GestureType? _gestureType; + /// Number of pointers in the active gesture. When it changes (a finger lands + /// or lifts, or a Windows touch dropout/re-acquire), we re-baseline instead of + /// applying a frame whose scale/focal still refer to the old finger set. + int _lastPointerCount = 0; + // --- Matrix helpers (infinite boundary → no clamping to bounds) ----------- Matrix4 _matrixTranslate(Matrix4 matrix, Offset translation) { @@ -139,6 +144,7 @@ class _PenInteractiveViewerState extends State _scaleAnimation = null; } _gestureType = null; + _lastPointerCount = details.pointerCount; _scaleStart = _transformer.value.getMaxScaleOnAxis(); _referenceFocalPoint = _transformer.toScene(details.localFocalPoint); } @@ -146,6 +152,17 @@ class _PenInteractiveViewerState extends State void _onScaleUpdate(ScaleUpdateDetails details) { final double scale = _transformer.value.getMaxScaleOnAxis(); _scaleAnimationFocalPoint = details.localFocalPoint; + + // Re-baseline on any pointer-count change so a finger landing/lifting (or a + // Windows touch dropout) can't make scale/focal jump from the stale set. + // The transitional frame itself is skipped. + if (details.pointerCount != _lastPointerCount) { + _lastPointerCount = details.pointerCount; + _scaleStart = _transformer.value.getMaxScaleOnAxis(); + _referenceFocalPoint = _transformer.toScene(details.localFocalPoint); + return; + } + final Offset focalPointScene = _transformer.toScene(details.localFocalPoint); if (_gestureType == _GestureType.pan) { @@ -200,6 +217,7 @@ class _PenInteractiveViewerState extends State void _onScaleEnd(ScaleEndDetails details) { _scaleStart = null; _referenceFocalPoint = null; + _lastPointerCount = 0; _animation?.removeListener(_handleInertiaAnimation); _scaleAnimation?.removeListener(_handleScaleAnimation); _controller.reset(); diff --git a/windows/runner/CMakeLists.txt b/windows/runner/CMakeLists.txt index 3319b72..90bf01f 100644 --- a/windows/runner/CMakeLists.txt +++ b/windows/runner/CMakeLists.txt @@ -36,6 +36,9 @@ target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") # dependencies here. target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib" "windowsapp.lib") +# comctl32 provides SetWindowSubclass/DefSubclassProc for the pen WM_POINTER +# observer installed on the Flutter child window (flutter_window.cpp). +target_link_libraries(${BINARY_NAME} PRIVATE "comctl32.lib") target_compile_options(${BINARY_NAME} PRIVATE /await) target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") diff --git a/windows/runner/flutter_window.cpp b/windows/runner/flutter_window.cpp index b6cddf4..4e20694 100644 --- a/windows/runner/flutter_window.cpp +++ b/windows/runner/flutter_window.cpp @@ -1,11 +1,36 @@ #include "flutter_window.h" +#include + #include #include "flutter/generated_plugin_registrant.h" #include "ocr_channel.h" #include "pen_channel.h" +// SetWindowSubclass / DefSubclassProc / RemoveWindowSubclass live in comctl32. +#pragma comment(lib, "comctl32.lib") + +namespace { + +// Id for the pen observer subclass installed on the Flutter child view window. +constexpr UINT_PTR kPenSubclassId = 1; + +// Subclass proc on the FLUTTER CHILD window. WM_POINTER messages are delivered +// to the window under the pointer — that is the Flutter view's child HWND, not +// the top-level window — so this is where the pen's barrel/eraser/tilt actually +// arrive. We observe (non-consuming) and pass every message straight through to +// Flutter's own handler via DefSubclassProc, so input behavior is unchanged. +LRESULT CALLBACK FlutterChildSubclassProc(HWND hwnd, UINT message, + WPARAM wparam, LPARAM lparam, + UINT_PTR /*subclass_id*/, + DWORD_PTR /*ref_data*/) { + ObservePenMessage(message, wparam, lparam); + return DefSubclassProc(hwnd, message, wparam, lparam); +} + +} // namespace + FlutterWindow::FlutterWindow(const flutter::DartProject& project) : project_(project) {} @@ -29,7 +54,15 @@ bool FlutterWindow::OnCreate() { RegisterPlugins(flutter_controller_->engine()); RegisterOcrChannel(flutter_controller_->engine()); RegisterPenChannel(flutter_controller_->engine()); - SetChildContent(flutter_controller_->view()->GetNativeWindow()); + + HWND flutter_child = flutter_controller_->view()->GetNativeWindow(); + SetChildContent(flutter_child); + + // Observe WM_POINTER on the Flutter child window (where pen messages land) so + // the pen barrel/eraser/inverted flags + tilt reach the badnote/pen channel. + if (flutter_child) { + SetWindowSubclass(flutter_child, FlutterChildSubclassProc, kPenSubclassId, 0); + } flutter_controller_->engine()->SetNextFrameCallback([&]() { this->Show(); @@ -45,6 +78,11 @@ bool FlutterWindow::OnCreate() { void FlutterWindow::OnDestroy() { if (flutter_controller_) { + HWND flutter_child = flutter_controller_->view()->GetNativeWindow(); + if (flutter_child) { + RemoveWindowSubclass(flutter_child, FlutterChildSubclassProc, + kPenSubclassId); + } flutter_controller_ = nullptr; }