From 299b9546a8bebd8ac4f0fc58962c63c6cce015cd Mon Sep 17 00:00:00 2001 From: Akiba So Date: Tue, 23 Jun 2026 09:43:56 +0800 Subject: [PATCH] feat(pen): apply pressure-response curve for natural feel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "手写笔就是一个带压感的手指,没有特殊适配" — correct. The new editor fed RAW LINEAR stylus pressure into perfect_freehand, and the pressureGamma config (a slider in pen-settings) was read ONLY by that slider's UI and NEVER applied to a stroke. Dead wiring, like the rest. Wire it for real: - PenCanvas applies PressureCurve(floor, gamma) at capture, so stored pressure carries the feel and live + PDF export replay identically. - Natural defaults: gamma 0.7 (light touches register more width, rnote/ OneNote-like) + floor 0.12 (thin strokes keep body, not scratchy). - pen_editor threads PenConfig.pressureGamma into the canvas — the slider now actually changes stroke width. - pen_config: natural default + one-time migration of the legacy inert gamma 1.0, guarded by a marker so a deliberate 1.0 still sticks. Tests: pen_config_gamma_migration (4) + pressure_curve (6) pass; pen widget regressions green. flutter analyze: 0 issues. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/editor/canvas/pen_canvas.dart | 25 ++++++++++ lib/editor/canvas/pen_editor_screen.dart | 6 +++ lib/editor/input/pen_config.dart | 21 +++++++- lib/editor/input/pressure_curve.dart | 9 ++++ test/pen_config_gamma_migration_test.dart | 60 +++++++++++++++++++++++ 5 files changed, 119 insertions(+), 2 deletions(-) create mode 100644 test/pen_config_gamma_migration_test.dart diff --git a/lib/editor/canvas/pen_canvas.dart b/lib/editor/canvas/pen_canvas.dart index ac5edec..6982798 100644 --- a/lib/editor/canvas/pen_canvas.dart +++ b/lib/editor/canvas/pen_canvas.dart @@ -27,6 +27,7 @@ import '../engine/stroke_model.dart'; import '../engine/stroke_store.dart'; import '../input/input_arbiter.dart' as arbiter; import '../input/pen_config.dart'; +import '../input/pressure_curve.dart'; import '../input/pen_input_service.dart'; import '../render/ink_picture_cache.dart'; import '../render/live_ink_painter.dart' as render; @@ -55,6 +56,8 @@ class PenCanvas extends StatefulWidget { this.maxScale = 8.0, this.onPenDebug, this.thinning = kDefaultPenThinning, + this.pressureGamma = kNaturalPressureGamma, + this.pressureFloor = kNaturalPressureFloor, this.sideButtonAction = PenButtonAction.eraser, this.eraserEndAction = PenButtonAction.eraser, this.onPenButtonAction, @@ -103,6 +106,15 @@ class PenCanvas extends StatefulWidget { /// perfect_freehand pressure→width response, from `PenConfig.pressureSensitivity`. final double thinning; + /// Pressure-response exponent applied to raw stylus pressure BEFORE it reaches + /// perfect_freehand. <1 boosts light touches (responsive, rnote-like); 1 is + /// raw linear (the old "pressure-finger" feel). From `PenConfig.pressureGamma`. + final double pressureGamma; + + /// Minimum shaped pressure, so a light stroke still has body instead of + /// scratchy near-zero width. From `PenConfig.pressureFloor`. + final double pressureFloor; + /// Configured action for the pen's side barrel button (W3 — resolved against /// the native pen plugin's flags on Windows). final PenButtonAction sideButtonAction; @@ -189,8 +201,21 @@ class _PenCanvasState extends State { /// Normalize stylus pressure to [0,1], or null when the device reports no /// usable pressure range (then perfect_freehand simulates pressure). + /// + /// The raw normalized force is then shaped by the pressure-response curve + /// (floor + gamma) so the stored pressure already carries the rnote-like feel + /// — and because the shaping happens at capture, the live stroke and the PDF + /// export replay identical pressures (no divergence). double? _normalizedPressure(PointerEvent event) { if (!_isStylus(event.kind)) return null; + final double? raw = _rawNormalizedPressure(event); + if (raw == null) return null; + return PressureCurve(floor: widget.pressureFloor, gamma: widget.pressureGamma) + .apply(raw); + } + + /// Raw [0,1] stylus force before response shaping (see [_normalizedPressure]). + double? _rawNormalizedPressure(PointerEvent event) { final range = event.pressureMax - event.pressureMin; if (range > 0.0001) { return ((event.pressure - event.pressureMin) / range).clamp(0.0, 1.0); diff --git a/lib/editor/canvas/pen_editor_screen.dart b/lib/editor/canvas/pen_editor_screen.dart index 17064ed..a1c13a3 100644 --- a/lib/editor/canvas/pen_editor_screen.dart +++ b/lib/editor/canvas/pen_editor_screen.dart @@ -17,6 +17,7 @@ import '../engine/undo_stack.dart'; import '../input/diagnostic_logger.dart'; import '../input/pen_config.dart'; import '../input/pen_input_service.dart'; +import '../input/pressure_curve.dart' show kNaturalPressureGamma; import '../layout/viewport_fit.dart'; import '../persistence/editor_repository.dart'; import '../persistence/save_scheduler.dart'; @@ -558,6 +559,11 @@ class _PenEditorScreenState extends State { : (_penConfig?.value.penWidth ?? _penWidthFraction), thinning: _penConfig?.value.pressureSensitivity ?? kDefaultPenThinning, + // Pressure-response shaping (the rnote-like feel). The pen-settings + // gamma slider now actually drives stroke width; fall back to the + // natural default when no config is loaded yet. + pressureGamma: + _penConfig?.value.pressureGamma ?? kNaturalPressureGamma, sideButtonAction: _penConfig?.value.sideButton ?? PenButtonAction.eraser, eraserEndAction: diff --git a/lib/editor/input/pen_config.dart b/lib/editor/input/pen_config.dart index b514487..accbf99 100644 --- a/lib/editor/input/pen_config.dart +++ b/lib/editor/input/pen_config.dart @@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../engine/stroke_geometry.dart' show kDefaultPenThinning; +import 'pressure_curve.dart' show kNaturalPressureGamma; /// Action that can be triggered by a hardware pen button or the eraser end. enum PenButtonAction { @@ -22,7 +23,7 @@ class PenConfig { const PenConfig({ this.sideButton = PenButtonAction.eraser, this.eraserEnd = PenButtonAction.eraser, - this.pressureGamma = 1.0, + this.pressureGamma = kNaturalPressureGamma, this.palmRejectionMs = 150.0, this.fingerDrawing = false, this.penWidth = 0.004, @@ -107,7 +108,8 @@ class PenConfig { eraserEnd: PenButtonAction.values.asNameMap()[json['eraserEnd'] as String? ?? ''] ?? PenButtonAction.eraser, - pressureGamma: (json['pressureGamma'] as num?)?.toDouble() ?? 1.0, + pressureGamma: + (json['pressureGamma'] as num?)?.toDouble() ?? kNaturalPressureGamma, palmRejectionMs: (json['palmRejectionMs'] as num?)?.toDouble() ?? 150.0, fingerDrawing: json['fingerDrawing'] as bool? ?? false, penWidth: (json['penWidth'] as num?)?.toDouble() ?? 0.004, @@ -161,6 +163,9 @@ class PenConfigController extends ChangeNotifier { /// The SharedPreferences key under which [PenConfig] JSON is stored. static const prefsKey = 'pen_config_v1'; + /// Marker so the legacy-gamma migration in [load] runs at most once. + static const _gammaMigratedKey = 'pen_config_gamma_migrated_v1'; + final SharedPreferences _prefs; PenConfig _value; @@ -184,6 +189,18 @@ class PenConfigController extends ChangeNotifier { config = const PenConfig(); } } + // One-time migration: before this build, pressureGamma was never applied to + // strokes (a dead slider), so a stored 1.0 is the legacy inert default, not + // a deliberate "linear feel" choice. Upgrade it ONCE to the natural curve so + // the pen feels right out of the box. Guarded by a marker key so that, after + // migrating, the user is free to set gamma back to 1.0 and have it stick. + if (!(prefs.getBool(_gammaMigratedKey) ?? false)) { + if (config.pressureGamma == 1.0) { + config = config.copyWith(pressureGamma: kNaturalPressureGamma); + await prefs.setString(prefsKey, jsonEncode(config.toJson())); + } + await prefs.setBool(_gammaMigratedKey, true); + } return PenConfigController._(prefs, config); } diff --git a/lib/editor/input/pressure_curve.dart b/lib/editor/input/pressure_curve.dart index ebf87f8..3e18463 100644 --- a/lib/editor/input/pressure_curve.dart +++ b/lib/editor/input/pressure_curve.dart @@ -14,6 +14,15 @@ import 'dart:math' as math; +/// Default pressure-response exponent. <1 so light-to-medium pressure registers +/// more width — the responsive, rnote/OneNote-like feel — instead of the raw +/// linear mapping that made the pen feel like a pressure-sensitive finger. +const double kNaturalPressureGamma = 0.7; + +/// Default minimum shaped pressure: even the lightest touch keeps ~12% of the +/// dynamic range so thin strokes have body instead of scratchy near-zero width. +const double kNaturalPressureFloor = 0.12; + /// Maps raw normalized pressure to a shaped response in `[floor, 1]`. class PressureCurve { const PressureCurve({this.floor = 0.0, this.gamma = 1.0}) diff --git a/test/pen_config_gamma_migration_test.dart b/test/pen_config_gamma_migration_test.dart new file mode 100644 index 0000000..ec16a2e --- /dev/null +++ b/test/pen_config_gamma_migration_test.dart @@ -0,0 +1,60 @@ +// Proves the pen-feel pressure default and its one-time migration. +// +// Before this build pressureGamma was a dead slider (never applied to strokes), +// so a persisted 1.0 is the legacy inert default. load() upgrades that ONCE to +// the natural curve so the pen feels right out of the box — but the migration +// must not fight a user who later deliberately picks 1.0. + +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:badnote/editor/input/pen_config.dart'; +import 'package:badnote/editor/input/pressure_curve.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('fresh install defaults to the natural pressure curve', () async { + SharedPreferences.setMockInitialValues({}); + final c = await PenConfigController.load(); + expect(c.value.pressureGamma, kNaturalPressureGamma); + expect(kNaturalPressureGamma, lessThan(1.0)); // sanity: it IS a soft curve + }); + + test('legacy stored gamma 1.0 migrates to the natural curve once', () async { + SharedPreferences.setMockInitialValues({ + PenConfigController.prefsKey: + jsonEncode(const PenConfig(pressureGamma: 1.0).toJson()), + }); + final c = await PenConfigController.load(); + expect(c.value.pressureGamma, kNaturalPressureGamma, + reason: 'the dead-default 1.0 should be upgraded'); + }); + + test('after migrating, a deliberate gamma 1.0 sticks (no re-migration)', + () async { + // First load migrates and sets the marker. + SharedPreferences.setMockInitialValues({ + PenConfigController.prefsKey: + jsonEncode(const PenConfig(pressureGamma: 1.0).toJson()), + }); + final first = await PenConfigController.load(); + await first.setPressureGamma(1.0); // user deliberately chooses linear + + // Reload: the marker is set, so 1.0 must be respected, not re-migrated. + final second = await PenConfigController.load(); + expect(second.value.pressureGamma, 1.0, + reason: 'migration is one-time; user choice must persist'); + }); + + test('a non-default stored gamma is never touched', () async { + SharedPreferences.setMockInitialValues({ + PenConfigController.prefsKey: + jsonEncode(const PenConfig(pressureGamma: 0.5).toJson()), + }); + final c = await PenConfigController.load(); + expect(c.value.pressureGamma, 0.5); + }); +}