Compare commits
2 Commits
1d70c029b3
...
4fb431727e
| Author | SHA1 | Date | |
|---|---|---|---|
| 4fb431727e | |||
| 299b9546a8 |
@@ -27,6 +27,7 @@ import '../engine/stroke_model.dart';
|
|||||||
import '../engine/stroke_store.dart';
|
import '../engine/stroke_store.dart';
|
||||||
import '../input/input_arbiter.dart' as arbiter;
|
import '../input/input_arbiter.dart' as arbiter;
|
||||||
import '../input/pen_config.dart';
|
import '../input/pen_config.dart';
|
||||||
|
import '../input/pressure_curve.dart';
|
||||||
import '../input/pen_input_service.dart';
|
import '../input/pen_input_service.dart';
|
||||||
import '../render/ink_picture_cache.dart';
|
import '../render/ink_picture_cache.dart';
|
||||||
import '../render/live_ink_painter.dart' as render;
|
import '../render/live_ink_painter.dart' as render;
|
||||||
@@ -55,6 +56,10 @@ class PenCanvas extends StatefulWidget {
|
|||||||
this.maxScale = 8.0,
|
this.maxScale = 8.0,
|
||||||
this.onPenDebug,
|
this.onPenDebug,
|
||||||
this.thinning = kDefaultPenThinning,
|
this.thinning = kDefaultPenThinning,
|
||||||
|
this.pressureGamma = kNaturalPressureGamma,
|
||||||
|
this.pressureFloor = kNaturalPressureFloor,
|
||||||
|
this.eraserRadius = kDefaultEraserRadius,
|
||||||
|
this.eraserWholeStroke = false,
|
||||||
this.sideButtonAction = PenButtonAction.eraser,
|
this.sideButtonAction = PenButtonAction.eraser,
|
||||||
this.eraserEndAction = PenButtonAction.eraser,
|
this.eraserEndAction = PenButtonAction.eraser,
|
||||||
this.onPenButtonAction,
|
this.onPenButtonAction,
|
||||||
@@ -103,6 +108,24 @@ class PenCanvas extends StatefulWidget {
|
|||||||
/// perfect_freehand pressure→width response, from `PenConfig.pressureSensitivity`.
|
/// perfect_freehand pressure→width response, from `PenConfig.pressureSensitivity`.
|
||||||
final double thinning;
|
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;
|
||||||
|
|
||||||
|
/// Eraser radius as a fraction of page width (live hit area + cursor size).
|
||||||
|
/// From `PenConfig.eraserRadius`.
|
||||||
|
final double eraserRadius;
|
||||||
|
|
||||||
|
/// When true the eraser removes a whole stroke on contact (OneNote-style);
|
||||||
|
/// when false it does a partial / segment erase. From
|
||||||
|
/// `PenConfig.eraserWholeStroke`.
|
||||||
|
final bool eraserWholeStroke;
|
||||||
|
|
||||||
/// Configured action for the pen's side barrel button (W3 — resolved against
|
/// Configured action for the pen's side barrel button (W3 — resolved against
|
||||||
/// the native pen plugin's flags on Windows).
|
/// the native pen plugin's flags on Windows).
|
||||||
final PenButtonAction sideButtonAction;
|
final PenButtonAction sideButtonAction;
|
||||||
@@ -168,12 +191,6 @@ class _PenCanvasState extends State<PenCanvas> {
|
|||||||
bool get _isEraserMode =>
|
bool get _isEraserMode =>
|
||||||
widget.tool == CanvasTool.eraser || _eraserActive;
|
widget.tool == CanvasTool.eraser || _eraserActive;
|
||||||
|
|
||||||
/// Eraser radius as a fraction of page width (shared by the live erase and the
|
|
||||||
/// preview overlay so they always agree). A decisive fixed size — the old
|
|
||||||
/// strokeWidth*2 was so small that a pass removed only a couple of points and
|
|
||||||
/// the stroke visibly survived ("选中了的笔画也不见得能删掉").
|
|
||||||
static const double _eraserRadius = 0.02;
|
|
||||||
|
|
||||||
/// Page aspect (height / width) so the eraser circle stays round on screen.
|
/// Page aspect (height / width) so the eraser circle stays round on screen.
|
||||||
double get _pageAspect => widget.pageSize.width <= 0
|
double get _pageAspect => widget.pageSize.width <= 0
|
||||||
? 1.0
|
? 1.0
|
||||||
@@ -189,8 +206,21 @@ class _PenCanvasState extends State<PenCanvas> {
|
|||||||
|
|
||||||
/// Normalize stylus pressure to [0,1], or null when the device reports no
|
/// Normalize stylus pressure to [0,1], or null when the device reports no
|
||||||
/// usable pressure range (then perfect_freehand simulates pressure).
|
/// 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) {
|
double? _normalizedPressure(PointerEvent event) {
|
||||||
if (!_isStylus(event.kind)) return null;
|
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;
|
final range = event.pressureMax - event.pressureMin;
|
||||||
if (range > 0.0001) {
|
if (range > 0.0001) {
|
||||||
return ((event.pressure - event.pressureMin) / range).clamp(0.0, 1.0);
|
return ((event.pressure - event.pressureMin) / range).clamp(0.0, 1.0);
|
||||||
@@ -391,13 +421,16 @@ class _PenCanvasState extends State<PenCanvas> {
|
|||||||
/// stays round on screen (the page rect is not square).
|
/// stays round on screen (the page rect is not square).
|
||||||
void _eraseAt(PenPoint? p) {
|
void _eraseAt(PenPoint? p) {
|
||||||
if (p == null) return;
|
if (p == null) return;
|
||||||
final radius = _eraserRadius; // normalized (page-width fraction)
|
final radius = widget.eraserRadius; // normalized (page-width fraction)
|
||||||
final aspect = _pageAspect;
|
final aspect = _pageAspect;
|
||||||
for (var i = widget.strokes.length - 1; i >= 0; i--) {
|
for (var i = widget.strokes.length - 1; i >= 0; i--) {
|
||||||
final stroke = widget.strokes[i];
|
final stroke = widget.strokes[i];
|
||||||
if (!strokeHit(stroke, p.x, p.y, radius, aspect: aspect)) continue;
|
if (!strokeHit(stroke, p.x, p.y, radius, aspect: aspect)) continue;
|
||||||
final pieces =
|
// Stroke-eraser mode: a hit removes the entire stroke (empty replacement).
|
||||||
splitStrokeByCircle(stroke, p.x, p.y, radius, aspect: aspect);
|
// Point-eraser mode (default): cut out the touched span, keep the rest.
|
||||||
|
final pieces = widget.eraserWholeStroke
|
||||||
|
? const <PenStroke>[]
|
||||||
|
: splitStrokeByCircle(stroke, p.x, p.y, radius, aspect: aspect);
|
||||||
// Defensive no-op guard (strokeHit already passed, so a hit is expected).
|
// Defensive no-op guard (strokeHit already passed, so a hit is expected).
|
||||||
if (pieces.length == 1 && identical(pieces.first, stroke)) return;
|
if (pieces.length == 1 && identical(pieces.first, stroke)) return;
|
||||||
widget.onEraseStroke(i, pieces);
|
widget.onEraseStroke(i, pieces);
|
||||||
@@ -559,7 +592,7 @@ class _PenCanvasState extends State<PenCanvas> {
|
|||||||
painter: EraserPreviewPainter(
|
painter: EraserPreviewPainter(
|
||||||
strokes: widget.strokes,
|
strokes: widget.strokes,
|
||||||
cursor: _eraserCursor,
|
cursor: _eraserCursor,
|
||||||
radius: _eraserRadius,
|
radius: widget.eraserRadius,
|
||||||
aspect: _pageAspect,
|
aspect: _pageAspect,
|
||||||
pageSize: widget.pageSize,
|
pageSize: widget.pageSize,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import '../engine/undo_stack.dart';
|
|||||||
import '../input/diagnostic_logger.dart';
|
import '../input/diagnostic_logger.dart';
|
||||||
import '../input/pen_config.dart';
|
import '../input/pen_config.dart';
|
||||||
import '../input/pen_input_service.dart';
|
import '../input/pen_input_service.dart';
|
||||||
|
import '../input/pressure_curve.dart' show kNaturalPressureGamma;
|
||||||
import '../layout/viewport_fit.dart';
|
import '../layout/viewport_fit.dart';
|
||||||
import '../persistence/editor_repository.dart';
|
import '../persistence/editor_repository.dart';
|
||||||
import '../persistence/save_scheduler.dart';
|
import '../persistence/save_scheduler.dart';
|
||||||
@@ -558,6 +559,14 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
: (_penConfig?.value.penWidth ?? _penWidthFraction),
|
: (_penConfig?.value.penWidth ?? _penWidthFraction),
|
||||||
thinning:
|
thinning:
|
||||||
_penConfig?.value.pressureSensitivity ?? kDefaultPenThinning,
|
_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,
|
||||||
|
eraserRadius:
|
||||||
|
_penConfig?.value.eraserRadius ?? kDefaultEraserRadius,
|
||||||
|
eraserWholeStroke: _penConfig?.value.eraserWholeStroke ?? false,
|
||||||
sideButtonAction:
|
sideButtonAction:
|
||||||
_penConfig?.value.sideButton ?? PenButtonAction.eraser,
|
_penConfig?.value.sideButton ?? PenButtonAction.eraser,
|
||||||
eraserEndAction:
|
eraserEndAction:
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ import 'package:flutter/foundation.dart';
|
|||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
import '../engine/stroke_geometry.dart' show kDefaultPenThinning;
|
import '../engine/stroke_geometry.dart' show kDefaultPenThinning;
|
||||||
|
import 'pressure_curve.dart' show kNaturalPressureGamma;
|
||||||
|
|
||||||
|
/// Default eraser radius as a fraction of page width (the legacy fixed value,
|
||||||
|
/// now the default of the configurable [PenConfig.eraserRadius]).
|
||||||
|
const double kDefaultEraserRadius = 0.02;
|
||||||
|
|
||||||
/// Action that can be triggered by a hardware pen button or the eraser end.
|
/// Action that can be triggered by a hardware pen button or the eraser end.
|
||||||
enum PenButtonAction {
|
enum PenButtonAction {
|
||||||
@@ -22,18 +27,22 @@ class PenConfig {
|
|||||||
const PenConfig({
|
const PenConfig({
|
||||||
this.sideButton = PenButtonAction.eraser,
|
this.sideButton = PenButtonAction.eraser,
|
||||||
this.eraserEnd = PenButtonAction.eraser,
|
this.eraserEnd = PenButtonAction.eraser,
|
||||||
this.pressureGamma = 1.0,
|
this.pressureGamma = kNaturalPressureGamma,
|
||||||
this.palmRejectionMs = 150.0,
|
this.palmRejectionMs = 150.0,
|
||||||
this.fingerDrawing = false,
|
this.fingerDrawing = false,
|
||||||
this.penWidth = 0.004,
|
this.penWidth = 0.004,
|
||||||
this.highlighterWidth = 0.02,
|
this.highlighterWidth = 0.02,
|
||||||
this.pressureSensitivity = kDefaultPenThinning,
|
this.pressureSensitivity = kDefaultPenThinning,
|
||||||
|
this.eraserRadius = kDefaultEraserRadius,
|
||||||
|
this.eraserWholeStroke = false,
|
||||||
}) : assert(pressureGamma >= 0.3 && pressureGamma <= 3.0,
|
}) : assert(pressureGamma >= 0.3 && pressureGamma <= 3.0,
|
||||||
'pressureGamma must be in [0.3, 3.0]'),
|
'pressureGamma must be in [0.3, 3.0]'),
|
||||||
assert(palmRejectionMs >= 0.0 && palmRejectionMs <= 500.0,
|
assert(palmRejectionMs >= 0.0 && palmRejectionMs <= 500.0,
|
||||||
'palmRejectionMs must be in [0, 500]'),
|
'palmRejectionMs must be in [0, 500]'),
|
||||||
assert(pressureSensitivity >= 0.0 && pressureSensitivity <= 1.0,
|
assert(pressureSensitivity >= 0.0 && pressureSensitivity <= 1.0,
|
||||||
'pressureSensitivity must be in [0, 1]');
|
'pressureSensitivity must be in [0, 1]'),
|
||||||
|
assert(eraserRadius >= 0.005 && eraserRadius <= 0.1,
|
||||||
|
'eraserRadius must be in [0.005, 0.1]');
|
||||||
|
|
||||||
/// Which action fires when the side barrel button is held.
|
/// Which action fires when the side barrel button is held.
|
||||||
final PenButtonAction sideButton;
|
final PenButtonAction sideButton;
|
||||||
@@ -66,6 +75,16 @@ class PenConfig {
|
|||||||
/// export golden are unchanged.
|
/// export golden are unchanged.
|
||||||
final double pressureSensitivity;
|
final double pressureSensitivity;
|
||||||
|
|
||||||
|
/// Eraser radius as a fraction of page width. Range [0.005, 0.1], default
|
||||||
|
/// [kDefaultEraserRadius]. Controls both the live erase hit area and the
|
||||||
|
/// on-screen eraser cursor.
|
||||||
|
final double eraserRadius;
|
||||||
|
|
||||||
|
/// When true the eraser removes a WHOLE stroke on contact (OneNote-style
|
||||||
|
/// stroke eraser); when false it does a partial / segment erase (the default,
|
||||||
|
/// rnote-style point eraser).
|
||||||
|
final bool eraserWholeStroke;
|
||||||
|
|
||||||
PenConfig copyWith({
|
PenConfig copyWith({
|
||||||
PenButtonAction? sideButton,
|
PenButtonAction? sideButton,
|
||||||
PenButtonAction? eraserEnd,
|
PenButtonAction? eraserEnd,
|
||||||
@@ -75,6 +94,8 @@ class PenConfig {
|
|||||||
double? penWidth,
|
double? penWidth,
|
||||||
double? highlighterWidth,
|
double? highlighterWidth,
|
||||||
double? pressureSensitivity,
|
double? pressureSensitivity,
|
||||||
|
double? eraserRadius,
|
||||||
|
bool? eraserWholeStroke,
|
||||||
}) {
|
}) {
|
||||||
return PenConfig(
|
return PenConfig(
|
||||||
sideButton: sideButton ?? this.sideButton,
|
sideButton: sideButton ?? this.sideButton,
|
||||||
@@ -85,6 +106,8 @@ class PenConfig {
|
|||||||
penWidth: penWidth ?? this.penWidth,
|
penWidth: penWidth ?? this.penWidth,
|
||||||
highlighterWidth: highlighterWidth ?? this.highlighterWidth,
|
highlighterWidth: highlighterWidth ?? this.highlighterWidth,
|
||||||
pressureSensitivity: pressureSensitivity ?? this.pressureSensitivity,
|
pressureSensitivity: pressureSensitivity ?? this.pressureSensitivity,
|
||||||
|
eraserRadius: eraserRadius ?? this.eraserRadius,
|
||||||
|
eraserWholeStroke: eraserWholeStroke ?? this.eraserWholeStroke,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,6 +120,8 @@ class PenConfig {
|
|||||||
'penWidth': penWidth,
|
'penWidth': penWidth,
|
||||||
'highlighterWidth': highlighterWidth,
|
'highlighterWidth': highlighterWidth,
|
||||||
'pressureSensitivity': pressureSensitivity,
|
'pressureSensitivity': pressureSensitivity,
|
||||||
|
'eraserRadius': eraserRadius,
|
||||||
|
'eraserWholeStroke': eraserWholeStroke,
|
||||||
};
|
};
|
||||||
|
|
||||||
factory PenConfig.fromJson(Map<String, dynamic> json) {
|
factory PenConfig.fromJson(Map<String, dynamic> json) {
|
||||||
@@ -107,13 +132,17 @@ class PenConfig {
|
|||||||
eraserEnd:
|
eraserEnd:
|
||||||
PenButtonAction.values.asNameMap()[json['eraserEnd'] as String? ?? ''] ??
|
PenButtonAction.values.asNameMap()[json['eraserEnd'] as String? ?? ''] ??
|
||||||
PenButtonAction.eraser,
|
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,
|
palmRejectionMs: (json['palmRejectionMs'] as num?)?.toDouble() ?? 150.0,
|
||||||
fingerDrawing: json['fingerDrawing'] as bool? ?? false,
|
fingerDrawing: json['fingerDrawing'] as bool? ?? false,
|
||||||
penWidth: (json['penWidth'] as num?)?.toDouble() ?? 0.004,
|
penWidth: (json['penWidth'] as num?)?.toDouble() ?? 0.004,
|
||||||
highlighterWidth: (json['highlighterWidth'] as num?)?.toDouble() ?? 0.02,
|
highlighterWidth: (json['highlighterWidth'] as num?)?.toDouble() ?? 0.02,
|
||||||
pressureSensitivity:
|
pressureSensitivity:
|
||||||
(json['pressureSensitivity'] as num?)?.toDouble() ?? kDefaultPenThinning,
|
(json['pressureSensitivity'] as num?)?.toDouble() ?? kDefaultPenThinning,
|
||||||
|
eraserRadius:
|
||||||
|
(json['eraserRadius'] as num?)?.toDouble() ?? kDefaultEraserRadius,
|
||||||
|
eraserWholeStroke: json['eraserWholeStroke'] as bool? ?? false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,7 +158,9 @@ class PenConfig {
|
|||||||
fingerDrawing == other.fingerDrawing &&
|
fingerDrawing == other.fingerDrawing &&
|
||||||
penWidth == other.penWidth &&
|
penWidth == other.penWidth &&
|
||||||
highlighterWidth == other.highlighterWidth &&
|
highlighterWidth == other.highlighterWidth &&
|
||||||
pressureSensitivity == other.pressureSensitivity;
|
pressureSensitivity == other.pressureSensitivity &&
|
||||||
|
eraserRadius == other.eraserRadius &&
|
||||||
|
eraserWholeStroke == other.eraserWholeStroke;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode => Object.hash(
|
int get hashCode => Object.hash(
|
||||||
@@ -141,6 +172,8 @@ class PenConfig {
|
|||||||
penWidth,
|
penWidth,
|
||||||
highlighterWidth,
|
highlighterWidth,
|
||||||
pressureSensitivity,
|
pressureSensitivity,
|
||||||
|
eraserRadius,
|
||||||
|
eraserWholeStroke,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,6 +194,9 @@ class PenConfigController extends ChangeNotifier {
|
|||||||
/// The SharedPreferences key under which [PenConfig] JSON is stored.
|
/// The SharedPreferences key under which [PenConfig] JSON is stored.
|
||||||
static const prefsKey = 'pen_config_v1';
|
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;
|
final SharedPreferences _prefs;
|
||||||
PenConfig _value;
|
PenConfig _value;
|
||||||
|
|
||||||
@@ -184,6 +220,18 @@ class PenConfigController extends ChangeNotifier {
|
|||||||
config = const PenConfig();
|
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);
|
return PenConfigController._(prefs, config);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,6 +284,18 @@ class PenConfigController extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Sets [PenConfig.pressureSensitivity]. Clamped to [0, 1].
|
/// Sets [PenConfig.pressureSensitivity]. Clamped to [0, 1].
|
||||||
|
/// Sets [PenConfig.eraserRadius]. Clamped to [0.005, 0.1].
|
||||||
|
Future<void> setEraserRadius(double radius) async {
|
||||||
|
_value = _value.copyWith(eraserRadius: radius.clamp(0.005, 0.1));
|
||||||
|
await _persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets [PenConfig.eraserWholeStroke] (true = OneNote-style stroke eraser).
|
||||||
|
Future<void> setEraserWholeStroke(bool whole) async {
|
||||||
|
_value = _value.copyWith(eraserWholeStroke: whole);
|
||||||
|
await _persist();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> setPressureSensitivity(double sensitivity) async {
|
Future<void> setPressureSensitivity(double sensitivity) async {
|
||||||
_value = _value.copyWith(pressureSensitivity: sensitivity.clamp(0.0, 1.0));
|
_value = _value.copyWith(pressureSensitivity: sensitivity.clamp(0.0, 1.0));
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|||||||
@@ -14,6 +14,15 @@
|
|||||||
|
|
||||||
import 'dart:math' as math;
|
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]`.
|
/// Maps raw normalized pressure to a shaped response in `[floor, 1]`.
|
||||||
class PressureCurve {
|
class PressureCurve {
|
||||||
const PressureCurve({this.floor = 0.0, this.gamma = 1.0})
|
const PressureCurve({this.floor = 0.0, this.gamma = 1.0})
|
||||||
|
|||||||
@@ -161,6 +161,31 @@ class _PenSettingsSheet extends StatelessWidget {
|
|||||||
formatValue: (v) => v.toStringAsFixed(4),
|
formatValue: (v) => v.toStringAsFixed(4),
|
||||||
onChanged: controller.setHighlighterWidth,
|
onChanged: controller.setHighlighterWidth,
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// ── Eraser ────────────────────────────────────────────────
|
||||||
|
_SectionHeader(
|
||||||
|
title: 'Eraser',
|
||||||
|
icon: Icons.cleaning_services_outlined,
|
||||||
|
colorScheme: colorScheme,
|
||||||
|
),
|
||||||
|
_SliderTile(
|
||||||
|
label: 'Eraser Size',
|
||||||
|
value: config.eraserRadius,
|
||||||
|
min: 0.005,
|
||||||
|
max: 0.1,
|
||||||
|
divisions: 19,
|
||||||
|
formatValue: (v) => v.toStringAsFixed(3),
|
||||||
|
onChanged: controller.setEraserRadius,
|
||||||
|
),
|
||||||
|
SwitchListTile(
|
||||||
|
title: const Text('Stroke Eraser'),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Erase a whole stroke on contact (off: erase by segment)',
|
||||||
|
),
|
||||||
|
value: config.eraserWholeStroke,
|
||||||
|
onChanged: controller.setEraserWholeStroke,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
60
test/pen_config_gamma_migration_test.dart
Normal file
60
test/pen_config_gamma_migration_test.dart
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
101
test/pen_eraser_mode_widget_test.dart
Normal file
101
test/pen_eraser_mode_widget_test.dart
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
// Guards the two eraser modes (the "优化橡皮擦工具" work): the default point
|
||||||
|
// eraser cuts a stroke into surviving segments, while the new stroke-eraser
|
||||||
|
// mode removes the entire stroke on contact. Both are driven by the same
|
||||||
|
// stylus pass; only the eraserWholeStroke flag differs.
|
||||||
|
|
||||||
|
import 'package:flutter/gestures.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:badnote/editor/canvas/pen_canvas.dart';
|
||||||
|
import 'package:badnote/editor/canvas/pen_stroke.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
const pageSize = Size(400, 600);
|
||||||
|
|
||||||
|
// A long horizontal stroke spanning the page through the vertical center.
|
||||||
|
PenStroke longStroke() => PenStroke(
|
||||||
|
points: const [
|
||||||
|
PenPoint(0.1, 0.5, 0.5),
|
||||||
|
PenPoint(0.3, 0.5, 0.5),
|
||||||
|
PenPoint(0.5, 0.5, 0.5),
|
||||||
|
PenPoint(0.7, 0.5, 0.5),
|
||||||
|
PenPoint(0.9, 0.5, 0.5),
|
||||||
|
],
|
||||||
|
color: 0xFF000000,
|
||||||
|
width: 0.004,
|
||||||
|
kind: PenStrokeKind.pen,
|
||||||
|
);
|
||||||
|
|
||||||
|
Widget host({
|
||||||
|
required void Function(int, List<PenStroke>) onErase,
|
||||||
|
required bool wholeStroke,
|
||||||
|
}) {
|
||||||
|
final controller = TransformationController();
|
||||||
|
addTearDown(controller.dispose);
|
||||||
|
return MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: Center(
|
||||||
|
child: SizedBox(
|
||||||
|
width: pageSize.width,
|
||||||
|
height: pageSize.height,
|
||||||
|
child: PenCanvas(
|
||||||
|
pageWidget: Container(color: const Color(0xFFEEEEEE)),
|
||||||
|
pageSize: pageSize,
|
||||||
|
strokes: [longStroke()],
|
||||||
|
transformationController: controller,
|
||||||
|
tool: CanvasTool.eraser,
|
||||||
|
color: const Color(0xFF000000),
|
||||||
|
strokeWidth: 0.004,
|
||||||
|
eraserWholeStroke: wholeStroke,
|
||||||
|
onStrokeComplete: (_) {},
|
||||||
|
onEraseStroke: onErase,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drag the eraser vertically through the page center (normalized 0.5, 0.5),
|
||||||
|
// grazing the MIDDLE of the long horizontal stroke.
|
||||||
|
Future<void> erasePass(WidgetTester tester) async {
|
||||||
|
final center = tester.getCenter(find.byType(PenCanvas));
|
||||||
|
final g = await tester.startGesture(center + const Offset(0, -15),
|
||||||
|
kind: PointerDeviceKind.stylus);
|
||||||
|
await g.moveBy(const Offset(0, 15));
|
||||||
|
await g.moveBy(const Offset(0, 15));
|
||||||
|
await g.up();
|
||||||
|
await tester.pump();
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('point eraser (default) splits the stroke into surviving pieces',
|
||||||
|
(tester) async {
|
||||||
|
List<PenStroke>? replacement;
|
||||||
|
await tester.pumpWidget(host(
|
||||||
|
onErase: (_, pieces) => replacement = pieces,
|
||||||
|
wholeStroke: false,
|
||||||
|
));
|
||||||
|
await erasePass(tester);
|
||||||
|
|
||||||
|
expect(replacement, isNotNull, reason: 'an erase should have fired');
|
||||||
|
// Grazing the middle leaves the two ends as surviving sub-strokes.
|
||||||
|
expect(replacement!.length, greaterThanOrEqualTo(1));
|
||||||
|
expect(replacement, isNotEmpty,
|
||||||
|
reason: 'point eraser keeps the untouched ends');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('stroke eraser removes the entire stroke on contact',
|
||||||
|
(tester) async {
|
||||||
|
List<PenStroke>? replacement;
|
||||||
|
await tester.pumpWidget(host(
|
||||||
|
onErase: (_, pieces) => replacement = pieces,
|
||||||
|
wholeStroke: true,
|
||||||
|
));
|
||||||
|
await erasePass(tester);
|
||||||
|
|
||||||
|
expect(replacement, isNotNull, reason: 'an erase should have fired');
|
||||||
|
expect(replacement, isEmpty,
|
||||||
|
reason: 'stroke eraser deletes the whole stroke, leaving no pieces');
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user