fix: PDF finger ink, chrome UX, OneNote pens, and pen physics
Some checks failed
CI / Windows build (push) Has been cancelled
Some checks failed
CI / Windows build (push) Has been cancelled
Wire finger drawing on PDF without breaking pinch; auto-hide page scrubber and fix bounce; share sticky tools with resize and per-page remember; side-button select; separate pen slots with colors; rnote pressure shapes plus tip-velocity width and lower stroke latency. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -22,6 +22,7 @@ import 'package:flutter/gestures.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../engine/brush.dart';
|
import '../engine/brush.dart';
|
||||||
|
import '../engine/pen_physics.dart';
|
||||||
import '../engine/stroke_eraser.dart';
|
import '../engine/stroke_eraser.dart';
|
||||||
import '../engine/stroke_geometry.dart' show kDefaultPenThinning;
|
import '../engine/stroke_geometry.dart' show kDefaultPenThinning;
|
||||||
import '../engine/stroke_model.dart';
|
import '../engine/stroke_model.dart';
|
||||||
@@ -33,6 +34,7 @@ import '../input/pressure_curve.dart';
|
|||||||
import '../input/pen_input_service.dart';
|
import '../input/pen_input_service.dart';
|
||||||
import '../../diagnostics/pen_event_ring.dart';
|
import '../../diagnostics/pen_event_ring.dart';
|
||||||
import '../engine/shape_geometry.dart';
|
import '../engine/shape_geometry.dart';
|
||||||
|
import 'dart:math' as math;
|
||||||
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;
|
||||||
import '../render/static_ink_painter.dart' as render;
|
import '../render/static_ink_painter.dart' as render;
|
||||||
@@ -230,6 +232,10 @@ class _PenCanvasState extends State<PenCanvas> {
|
|||||||
/// undo snapshot is recorded once, on the first drag delta — see _extendStroke).
|
/// undo snapshot is recorded once, on the first drag delta — see _extendStroke).
|
||||||
bool _selectDragging = false;
|
bool _selectDragging = false;
|
||||||
|
|
||||||
|
/// Tip-velocity tracker for [tipVelocityWidthScale] (physical ink starvation).
|
||||||
|
Offset? _lastTipNorm;
|
||||||
|
Duration? _lastTipTime;
|
||||||
|
|
||||||
/// True when the active stylus reports the eraser signal (barrel button or
|
/// True when the active stylus reports the eraser signal (barrel button or
|
||||||
/// inverted stylus), detected on hover/down.
|
/// inverted stylus), detected on hover/down.
|
||||||
bool _eraserActive = false;
|
bool _eraserActive = false;
|
||||||
@@ -399,7 +405,8 @@ class _PenCanvasState extends State<PenCanvas> {
|
|||||||
if (action == _lastHwAction) return;
|
if (action == _lastHwAction) return;
|
||||||
_lastHwAction = action;
|
_lastHwAction = action;
|
||||||
if (action == PenButtonAction.undo ||
|
if (action == PenButtonAction.undo ||
|
||||||
action == PenButtonAction.toggleTool) {
|
action == PenButtonAction.toggleTool ||
|
||||||
|
action == PenButtonAction.select) {
|
||||||
widget.onPenButtonAction?.call(action);
|
widget.onPenButtonAction?.call(action);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -441,7 +448,7 @@ class _PenCanvasState extends State<PenCanvas> {
|
|||||||
/// Map a global pointer position into normalized page coords using the
|
/// Map a global pointer position into normalized page coords using the
|
||||||
/// shared transform (inverse) and this widget's geometry.
|
/// shared transform (inverse) and this widget's geometry.
|
||||||
PenPoint? _toNormalized(Offset globalPosition, double? pressure,
|
PenPoint? _toNormalized(Offset globalPosition, double? pressure,
|
||||||
{double? tilt}) {
|
{double? tilt, Duration? timeStamp}) {
|
||||||
final box = context.findRenderObject() as RenderBox?;
|
final box = context.findRenderObject() as RenderBox?;
|
||||||
if (box == null) return null;
|
if (box == null) return null;
|
||||||
final local = box.globalToLocal(globalPosition);
|
final local = box.globalToLocal(globalPosition);
|
||||||
@@ -451,7 +458,23 @@ class _PenCanvasState extends State<PenCanvas> {
|
|||||||
|
|
||||||
final nx = scene.dx / widget.pageSize.width;
|
final nx = scene.dx / widget.pageSize.width;
|
||||||
final ny = scene.dy / widget.pageSize.height;
|
final ny = scene.dy / widget.pageSize.height;
|
||||||
return PenPoint(nx, ny, pressure, tilt: tilt);
|
|
||||||
|
double? shaped = pressure;
|
||||||
|
if (shaped != null && timeStamp != null && _lastTipNorm != null &&
|
||||||
|
_lastTipTime != null) {
|
||||||
|
final dt = (timeStamp - _lastTipTime!).inMicroseconds / 1e6;
|
||||||
|
if (dt > 0) {
|
||||||
|
final dx = nx - _lastTipNorm!.dx;
|
||||||
|
final dy = ny - _lastTipNorm!.dy;
|
||||||
|
final speed = math.sqrt(dx * dx + dy * dy) / dt;
|
||||||
|
shaped = (shaped * tipVelocityWidthScale(_currentBrush, speed))
|
||||||
|
.clamp(0.0, 1.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_lastTipNorm = Offset(nx, ny);
|
||||||
|
_lastTipTime = timeStamp;
|
||||||
|
|
||||||
|
return PenPoint(nx, ny, shaped, tilt: tilt);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Stroke lifecycle -----------------------------------------------------
|
// --- Stroke lifecycle -----------------------------------------------------
|
||||||
@@ -464,8 +487,10 @@ class _PenCanvasState extends State<PenCanvas> {
|
|||||||
_shapeStart = null;
|
_shapeStart = null;
|
||||||
_selectLast = null;
|
_selectLast = null;
|
||||||
_selectDragging = false;
|
_selectDragging = false;
|
||||||
|
_lastTipNorm = null;
|
||||||
|
_lastTipTime = null;
|
||||||
final p = _toNormalized(event.position, _normalizedPressure(event),
|
final p = _toNormalized(event.position, _normalizedPressure(event),
|
||||||
tilt: _tiltFor(event));
|
tilt: _tiltFor(event), timeStamp: event.timeStamp);
|
||||||
|
|
||||||
if (_eraserActive || widget.tool == CanvasTool.eraser) {
|
if (_eraserActive || widget.tool == CanvasTool.eraser) {
|
||||||
_eraserCursor.value = p;
|
_eraserCursor.value = p;
|
||||||
@@ -502,7 +527,7 @@ class _PenCanvasState extends State<PenCanvas> {
|
|||||||
|
|
||||||
void _extendStroke(PointerMoveEvent event) {
|
void _extendStroke(PointerMoveEvent event) {
|
||||||
final p = _toNormalized(event.position, _normalizedPressure(event),
|
final p = _toNormalized(event.position, _normalizedPressure(event),
|
||||||
tilt: _tiltFor(event));
|
tilt: _tiltFor(event), timeStamp: event.timeStamp);
|
||||||
if (p == null) return;
|
if (p == null) return;
|
||||||
|
|
||||||
if (_eraserActive || widget.tool == CanvasTool.eraser) {
|
if (_eraserActive || widget.tool == CanvasTool.eraser) {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
// survive reopen, and a stored highlight can be removed (the un-highlight tool).
|
// survive reopen, and a stored highlight can be removed (the un-highlight tool).
|
||||||
|
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart'
|
import 'package:flutter/foundation.dart'
|
||||||
show ValueListenable, visibleForTesting;
|
show ValueListenable, visibleForTesting;
|
||||||
@@ -37,6 +38,7 @@ import '../../models/scratch_link.dart';
|
|||||||
import '../../storage/badnote_sidecar.dart';
|
import '../../storage/badnote_sidecar.dart';
|
||||||
import '../../storage/notebook_manifest.dart' show kAnnotationFontFamily;
|
import '../../storage/notebook_manifest.dart' show kAnnotationFontFamily;
|
||||||
import '../engine/brush.dart';
|
import '../engine/brush.dart';
|
||||||
|
import '../engine/pen_physics.dart';
|
||||||
import '../engine/shape_geometry.dart';
|
import '../engine/shape_geometry.dart';
|
||||||
import '../engine/stroke_eraser.dart';
|
import '../engine/stroke_eraser.dart';
|
||||||
import '../engine/stroke_geometry.dart' show kDefaultPenThinning;
|
import '../engine/stroke_geometry.dart' show kDefaultPenThinning;
|
||||||
@@ -113,6 +115,26 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
/// dragged (like the slide editor's scrubber); null when not scrubbing.
|
/// dragged (like the slide editor's scrubber); null when not scrubbing.
|
||||||
double? _pageScrub;
|
double? _pageScrub;
|
||||||
|
|
||||||
|
/// Scrubber is opt-in (tap page label); not always on-screen.
|
||||||
|
bool _showPageScrubber = false;
|
||||||
|
|
||||||
|
/// Bottom page chrome auto-hides after idle (OneNote-like).
|
||||||
|
bool _pageChromeVisible = true;
|
||||||
|
Timer? _pageChromeHideTimer;
|
||||||
|
|
||||||
|
/// Finger-ink pointer tracking (PDF path; does NOT go through PenCaptureRegion).
|
||||||
|
final Set<int> _fingerPointers = <int>{};
|
||||||
|
bool _fingerStrokeActive = false;
|
||||||
|
|
||||||
|
Offset? _lastTipNorm;
|
||||||
|
Duration? _lastTipTime;
|
||||||
|
|
||||||
|
/// Per-page sticky that was open when the user left the page — restored on return.
|
||||||
|
final Map<int, String> _stickyRememberedByPage = <int, String>{};
|
||||||
|
|
||||||
|
/// Rising-edge tracker for hardware side-button actions on PDF.
|
||||||
|
PenButtonAction _lastHwSideAction = PenButtonAction.none;
|
||||||
|
|
||||||
/// Strokes per page, keyed by 0-based page index (normalized coords).
|
/// Strokes per page, keyed by 0-based page index (normalized coords).
|
||||||
final Map<int, List<PenStroke>> _strokesByPage = {};
|
final Map<int, List<PenStroke>> _strokesByPage = {};
|
||||||
|
|
||||||
@@ -370,6 +392,28 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
|
|
||||||
void _onHwPenChanged() {
|
void _onHwPenChanged() {
|
||||||
_syncBarrelSelectText();
|
_syncBarrelSelectText();
|
||||||
|
_dispatchHwSideButton();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rising-edge side-button → universal stroke select (default mapping).
|
||||||
|
void _dispatchHwSideButton() {
|
||||||
|
final cfg = _penConfig?.value;
|
||||||
|
final hw = PenInputService.instance;
|
||||||
|
if (cfg == null || !hw.isActive) return;
|
||||||
|
final action = hw.current.barrel ? cfg.sideButton : PenButtonAction.none;
|
||||||
|
if (action == _lastHwSideAction) return;
|
||||||
|
final prev = _lastHwSideAction;
|
||||||
|
_lastHwSideAction = action;
|
||||||
|
if (action == PenButtonAction.select && prev != PenButtonAction.select) {
|
||||||
|
_setTool(EditorToolKind.select);
|
||||||
|
} else if (action == PenButtonAction.undo && prev != PenButtonAction.undo) {
|
||||||
|
if (_undoFor(_pageIndex).canUndo) _performUndo();
|
||||||
|
} else if (action == PenButtonAction.toggleTool &&
|
||||||
|
prev != PenButtonAction.toggleTool) {
|
||||||
|
_setTool(_tool == EditorToolKind.eraser
|
||||||
|
? EditorToolKind.brush
|
||||||
|
: EditorToolKind.eraser);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Level-trigger: holding barrel with sideButton=selectText enables text
|
/// Level-trigger: holding barrel with sideButton=selectText enables text
|
||||||
@@ -458,6 +502,7 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_pageChromeHideTimer?.cancel();
|
||||||
// Flush any pending sidecar write before tearing down.
|
// Flush any pending sidecar write before tearing down.
|
||||||
final repo = _repo;
|
final repo = _repo;
|
||||||
if (repo != null) {
|
if (repo != null) {
|
||||||
@@ -630,6 +675,71 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Brush gamma pressure, then tip-velocity physical scale (fountain thins at speed).
|
||||||
|
double? _pressureWithPhysics(PointerEvent event, Offset normalized) {
|
||||||
|
final base = _normalizedPressure(event);
|
||||||
|
if (base == null) {
|
||||||
|
_lastTipNorm = normalized;
|
||||||
|
_lastTipTime = event.timeStamp;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
double shaped = base;
|
||||||
|
if (_lastTipNorm != null && _lastTipTime != null) {
|
||||||
|
final dt = (event.timeStamp - _lastTipTime!).inMicroseconds / 1e6;
|
||||||
|
if (dt > 0) {
|
||||||
|
final dx = normalized.dx - _lastTipNorm!.dx;
|
||||||
|
final dy = normalized.dy - _lastTipNorm!.dy;
|
||||||
|
final speed = math.sqrt(dx * dx + dy * dy) / dt;
|
||||||
|
shaped = (shaped * tipVelocityWidthScale(_currentBrush(), speed))
|
||||||
|
.clamp(0.0, 1.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_lastTipNorm = normalized;
|
||||||
|
_lastTipTime = event.timeStamp;
|
||||||
|
return shaped;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onFingerPointer(PointerEvent event) {
|
||||||
|
if (!_allowFingerDrawing || !_penCaptureEnabled) return;
|
||||||
|
if (event.kind != PointerDeviceKind.touch) return;
|
||||||
|
|
||||||
|
if (event is PointerDownEvent) {
|
||||||
|
_fingerPointers.add(event.pointer);
|
||||||
|
if (_fingerPointers.length >= 2) {
|
||||||
|
if (_fingerStrokeActive) {
|
||||||
|
_endStroke(commit: false);
|
||||||
|
_fingerStrokeActive = false;
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!_fingerStrokeActive) {
|
||||||
|
setState(() => _fingerStrokeActive = true);
|
||||||
|
}
|
||||||
|
_onPenEvent(event);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event is PointerMoveEvent) {
|
||||||
|
if (!_fingerStrokeActive || !_fingerPointers.contains(event.pointer)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_fingerPointers.length >= 2) return;
|
||||||
|
_onPenEvent(event);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event is PointerUpEvent || event is PointerCancelEvent) {
|
||||||
|
_fingerPointers.remove(event.pointer);
|
||||||
|
if (_fingerStrokeActive && _fingerPointers.isEmpty) {
|
||||||
|
_onPenEvent(event);
|
||||||
|
_fingerStrokeActive = false;
|
||||||
|
if (mounted) setState(() {});
|
||||||
|
} else if (_fingerPointers.isEmpty && _fingerStrokeActive) {
|
||||||
|
_fingerStrokeActive = false;
|
||||||
|
if (mounted) setState(() {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _onPenEvent(PointerEvent event) {
|
void _onPenEvent(PointerEvent event) {
|
||||||
if (_isStylus(event.kind)) _emitPenDebug(event);
|
if (_isStylus(event.kind)) _emitPenDebug(event);
|
||||||
|
|
||||||
@@ -661,10 +771,12 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_liveStrokePage = hit.page;
|
_liveStrokePage = hit.page;
|
||||||
|
_lastTipNorm = null;
|
||||||
|
_lastTipTime = null;
|
||||||
_livePoints
|
_livePoints
|
||||||
..clear()
|
..clear()
|
||||||
..add(PenPoint(hit.normalized.dx, hit.normalized.dy,
|
..add(PenPoint(hit.normalized.dx, hit.normalized.dy,
|
||||||
_normalizedPressure(event)));
|
_pressureWithPhysics(event, hit.normalized)));
|
||||||
_updateLiveStroke();
|
_updateLiveStroke();
|
||||||
} else if (event is PointerMoveEvent) {
|
} else if (event is PointerMoveEvent) {
|
||||||
final page = _liveStrokePage;
|
final page = _liveStrokePage;
|
||||||
@@ -689,7 +801,9 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
// A stroke belongs to ONE page: ignore samples on a different page.
|
// A stroke belongs to ONE page: ignore samples on a different page.
|
||||||
if (hit == null || hit.page != page) return;
|
if (hit == null || hit.page != page) return;
|
||||||
_livePoints.add(PenPoint(
|
_livePoints.add(PenPoint(
|
||||||
hit.normalized.dx, hit.normalized.dy, _normalizedPressure(event)));
|
hit.normalized.dx,
|
||||||
|
hit.normalized.dy,
|
||||||
|
_pressureWithPhysics(event, hit.normalized)));
|
||||||
_updateLiveStroke();
|
_updateLiveStroke();
|
||||||
} else if (event is PointerUpEvent || event is PointerCancelEvent) {
|
} else if (event is PointerUpEvent || event is PointerCancelEvent) {
|
||||||
_endStroke(commit: event is PointerUpEvent);
|
_endStroke(commit: event is PointerUpEvent);
|
||||||
@@ -1273,9 +1387,57 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
void _goToPage(int index) {
|
void _goToPage(int index) {
|
||||||
if (_pageCount == 0) return;
|
if (_pageCount == 0) return;
|
||||||
final clamped = index.clamp(0, _pageCount - 1);
|
final clamped = index.clamp(0, _pageCount - 1);
|
||||||
|
// Optimistic index so the pill doesn't flash the old page while pdfrx animates.
|
||||||
|
if (clamped != _pageIndex) {
|
||||||
|
setState(() {
|
||||||
|
_pageIndex = clamped;
|
||||||
|
_pageScrub = null;
|
||||||
|
});
|
||||||
|
_onPageIndexChanging(clamped);
|
||||||
|
} else {
|
||||||
|
setState(() => _pageScrub = null);
|
||||||
|
}
|
||||||
|
_bumpPageChrome();
|
||||||
_controller.goToPage(pageNumber: clamped + 1);
|
_controller.goToPage(pageNumber: clamped + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _bumpPageChrome() {
|
||||||
|
_pageChromeHideTimer?.cancel();
|
||||||
|
if (!_pageChromeVisible && mounted) {
|
||||||
|
setState(() => _pageChromeVisible = true);
|
||||||
|
}
|
||||||
|
_pageChromeHideTimer = Timer(const Duration(seconds: 3), () {
|
||||||
|
if (!mounted) return;
|
||||||
|
if (_pageScrub != null || _showPageScrubber) return;
|
||||||
|
setState(() {
|
||||||
|
_pageChromeVisible = false;
|
||||||
|
_showPageScrubber = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hide sticky when leaving its page; remember id so returning re-expands it.
|
||||||
|
void _onPageIndexChanging(int newIndex) {
|
||||||
|
final open = _expandedSticky;
|
||||||
|
if (open != null && open.pageIndex != newIndex) {
|
||||||
|
_stickyRememberedByPage[open.pageIndex] = open.id;
|
||||||
|
_expandedSticky = null;
|
||||||
|
}
|
||||||
|
final rememberedId = _stickyRememberedByPage[newIndex];
|
||||||
|
if (rememberedId != null && _expandedSticky == null) {
|
||||||
|
ScratchLink? link;
|
||||||
|
for (final s in _scratchLinks) {
|
||||||
|
if (s.id == rememberedId) {
|
||||||
|
link = s;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (link != null) {
|
||||||
|
_expandedSticky = link;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _setTool(EditorToolKind tool) {
|
void _setTool(EditorToolKind tool) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_tool = tool;
|
_tool = tool;
|
||||||
@@ -1363,7 +1525,14 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
|
|
||||||
void _closeSticky() {
|
void _closeSticky() {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _expandedSticky = null);
|
final open = _expandedSticky;
|
||||||
|
setState(() {
|
||||||
|
if (open != null) {
|
||||||
|
// Explicit close: do not auto-reopen when returning to this page.
|
||||||
|
_stickyRememberedByPage.remove(open.pageIndex);
|
||||||
|
}
|
||||||
|
_expandedSticky = null;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Confirm + delete an anchor (and its private scratchpad).
|
/// Confirm + delete an anchor (and its private scratchpad).
|
||||||
@@ -1687,16 +1856,50 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Floating page-control pill (bottom-center).
|
// Floating page-control pill (bottom-center). Auto-hides when idle.
|
||||||
if (_viewerReady && _pageCount > 0)
|
if (_viewerReady && _pageCount > 0 && _pageChromeVisible)
|
||||||
SafeArea(
|
SafeArea(
|
||||||
child: Align(
|
child: Align(
|
||||||
alignment: Alignment.bottomCenter,
|
alignment: Alignment.bottomCenter,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 16),
|
padding: EdgeInsets.only(
|
||||||
|
bottom: _hasSelection ? 72 : 16,
|
||||||
|
),
|
||||||
child: _buildPagePill(),
|
child: _buildPagePill(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
)
|
||||||
|
else if (_viewerReady && _pageCount > 0)
|
||||||
|
SafeArea(
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.bottomCenter,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
|
child: Material(
|
||||||
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.surfaceContainerHigh
|
||||||
|
.withValues(alpha: 0.92),
|
||||||
|
elevation: 2,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
onTap: _bumpPageChrome,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 14, vertical: 6),
|
||||||
|
child: Text(
|
||||||
|
'${_pageIndex + 1} / $_pageCount',
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
// Back button (top-left).
|
// Back button (top-left).
|
||||||
SafeArea(
|
SafeArea(
|
||||||
@@ -1728,7 +1931,9 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
// the zoom and snaps back. We drive zoom ourselves via the glitch-guarded
|
// the zoom and snaps back. We drive zoom ourselves via the glitch-guarded
|
||||||
// _TwoFingerPinch recognizer in viewerOverlayBuilder → controller.
|
// _TwoFingerPinch recognizer in viewerOverlayBuilder → controller.
|
||||||
// zoomOnLocalPosition (focal zoom). See _onPinchUpdate.
|
// zoomOnLocalPosition (focal zoom). See _onPinchUpdate.
|
||||||
panEnabled: true,
|
// Suppress 1-finger pan while a finger-ink stroke is active so the
|
||||||
|
// page doesn't scroll under the stroke (finger draw is opt-in).
|
||||||
|
panEnabled: !_fingerStrokeActive,
|
||||||
scaleEnabled: false,
|
scaleEnabled: false,
|
||||||
// Native vector text selection. Pen falls through to this only in
|
// Native vector text selection. Pen falls through to this only in
|
||||||
// select-text mode (PenCaptureRegion.captureEnabled == false).
|
// select-text mode (PenCaptureRegion.captureEnabled == false).
|
||||||
@@ -1742,6 +1947,7 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
_viewerReady = true;
|
_viewerReady = true;
|
||||||
_pageCount = document.pages.length;
|
_pageCount = document.pages.length;
|
||||||
});
|
});
|
||||||
|
_bumpPageChrome();
|
||||||
// Honor a requested initial page (search-result jump), clamped.
|
// Honor a requested initial page (search-result jump), clamped.
|
||||||
final target = widget.initialPage.clamp(0, _pageCount - 1);
|
final target = widget.initialPage.clamp(0, _pageCount - 1);
|
||||||
if (target > 0) {
|
if (target > 0) {
|
||||||
@@ -1751,7 +1957,17 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
onPageChanged: (pageNumber) {
|
onPageChanged: (pageNumber) {
|
||||||
if (pageNumber == null || !mounted) return;
|
if (pageNumber == null || !mounted) return;
|
||||||
final idx = pageNumber - 1;
|
final idx = pageNumber - 1;
|
||||||
if (idx != _pageIndex) setState(() => _pageIndex = idx);
|
if (idx != _pageIndex) {
|
||||||
|
setState(() {
|
||||||
|
_onPageIndexChanging(idx);
|
||||||
|
_pageIndex = idx;
|
||||||
|
if (_pageScrub != null &&
|
||||||
|
(_pageScrub!.round() - 1) == idx) {
|
||||||
|
_pageScrub = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
_bumpPageChrome();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
// (1) Per-page overlay: committed ink + live stroke + highlights, all in
|
// (1) Per-page overlay: committed ink + live stroke + highlights, all in
|
||||||
// normalized page space scaled to the on-screen page rect.
|
// normalized page space scaled to the on-screen page rect.
|
||||||
@@ -1921,6 +2137,18 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
child: const SizedBox.expand(),
|
child: const SizedBox.expand(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
Positioned.fill(
|
||||||
|
child: Listener(
|
||||||
|
behavior: _fingerStrokeActive
|
||||||
|
? HitTestBehavior.opaque
|
||||||
|
: HitTestBehavior.translucent,
|
||||||
|
onPointerDown: _onFingerPointer,
|
||||||
|
onPointerMove: _onFingerPointer,
|
||||||
|
onPointerUp: _onFingerPointer,
|
||||||
|
onPointerCancel: _onFingerPointer,
|
||||||
|
child: const SizedBox.expand(),
|
||||||
|
),
|
||||||
|
),
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: PenCaptureRegion(
|
child: PenCaptureRegion(
|
||||||
captureEnabled: _penCaptureEnabled,
|
captureEnabled: _penCaptureEnabled,
|
||||||
@@ -1932,7 +2160,7 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
Positioned(
|
Positioned(
|
||||||
left: 16,
|
left: 16,
|
||||||
right: 16,
|
right: 16,
|
||||||
bottom: 24,
|
bottom: 88,
|
||||||
child: _SelectionActionBar(
|
child: _SelectionActionBar(
|
||||||
onHighlight: _highlightSelection,
|
onHighlight: _highlightSelection,
|
||||||
onBookmark: _addBookmark,
|
onBookmark: _addBookmark,
|
||||||
@@ -1946,6 +2174,10 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
key: ValueKey(_expandedSticky!.id),
|
key: ValueKey(_expandedSticky!.id),
|
||||||
link: _expandedSticky!,
|
link: _expandedSticky!,
|
||||||
repo: _repo!,
|
repo: _repo!,
|
||||||
|
brush: _penBrush,
|
||||||
|
color: _color,
|
||||||
|
tool: _tool,
|
||||||
|
allowFingerDrawing: _allowFingerDrawing,
|
||||||
onClose: _closeSticky,
|
onClose: _closeSticky,
|
||||||
onDelete: () async {
|
onDelete: () async {
|
||||||
final link = _expandedSticky!;
|
final link = _expandedSticky!;
|
||||||
@@ -1973,17 +2205,20 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
BrushPickerButton(
|
// OneNote-style: each pen is its own slot with remembered color.
|
||||||
selected: _penBrush,
|
for (final b in kPenToolBrushes)
|
||||||
active: _tool == EditorToolKind.brush && _penCaptureEnabled,
|
PenSlotButton(
|
||||||
tooltip: l.brushPicker,
|
kind: b,
|
||||||
labelFor: (b) => brushLabel(b, l),
|
selected: _tool == EditorToolKind.brush &&
|
||||||
colorFor: (b) => _brushColors[b] ?? Colors.black,
|
_penBrush == b &&
|
||||||
onSelected: (b) {
|
_penCaptureEnabled,
|
||||||
setState(() => _penBrush = b);
|
color: _brushColors[b] ?? Colors.black,
|
||||||
_setTool(EditorToolKind.brush);
|
tooltip: brushLabel(b, l),
|
||||||
},
|
onPressed: () {
|
||||||
),
|
setState(() => _penBrush = b);
|
||||||
|
_setTool(EditorToolKind.brush);
|
||||||
|
},
|
||||||
|
),
|
||||||
ToolButton(
|
ToolButton(
|
||||||
icon: Icons.brush_outlined,
|
icon: Icons.brush_outlined,
|
||||||
selected: _tool == EditorToolKind.highlighter && _penCaptureEnabled,
|
selected: _tool == EditorToolKind.highlighter && _penCaptureEnabled,
|
||||||
@@ -2222,7 +2457,7 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
return Column(
|
return Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
if (total > 1)
|
if (total > 1 && _showPageScrubber)
|
||||||
Container(
|
Container(
|
||||||
margin: const EdgeInsets.only(bottom: 8),
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
constraints: const BoxConstraints(maxWidth: 420),
|
constraints: const BoxConstraints(maxWidth: 420),
|
||||||
@@ -2238,10 +2473,17 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
value: (scrub ?? (_pageIndex + 1).toDouble())
|
value: (scrub ?? (_pageIndex + 1).toDouble())
|
||||||
.clamp(1, total.toDouble()),
|
.clamp(1, total.toDouble()),
|
||||||
divisions: total > 1 ? total - 1 : null,
|
divisions: total > 1 ? total - 1 : null,
|
||||||
onChanged: (v) => setState(() => _pageScrub = v),
|
onChanged: (v) {
|
||||||
|
setState(() => _pageScrub = v);
|
||||||
|
_bumpPageChrome();
|
||||||
|
},
|
||||||
onChangeEnd: (v) {
|
onChangeEnd: (v) {
|
||||||
setState(() => _pageScrub = null);
|
final target = v.round() - 1;
|
||||||
_goToPage(v.round() - 1);
|
// Keep scrub until optimistic _goToPage clears it — no bounce.
|
||||||
|
setState(() => _pageScrub = v);
|
||||||
|
_goToPage(target);
|
||||||
|
setState(() => _showPageScrubber = false);
|
||||||
|
_bumpPageChrome();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -2259,11 +2501,23 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
IconButton(
|
IconButton(
|
||||||
tooltip: l.previousPage,
|
tooltip: l.previousPage,
|
||||||
icon: const Icon(Icons.chevron_left),
|
icon: const Icon(Icons.chevron_left),
|
||||||
onPressed:
|
onPressed: _pageIndex > 0
|
||||||
_pageIndex > 0 ? () => _goToPage(_pageIndex - 1) : null,
|
? () {
|
||||||
|
_bumpPageChrome();
|
||||||
|
_goToPage(_pageIndex - 1);
|
||||||
|
}
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: _viewerReady ? _openThumbnails : null,
|
onPressed: () {
|
||||||
|
_bumpPageChrome();
|
||||||
|
if (total > 1) {
|
||||||
|
setState(() => _showPageScrubber = !_showPageScrubber);
|
||||||
|
} else if (_viewerReady) {
|
||||||
|
_openThumbnails();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onLongPress: _viewerReady ? _openThumbnails : null,
|
||||||
child: Text(
|
child: Text(
|
||||||
l.pageOfPages(shown, total),
|
l.pageOfPages(shown, total),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
@@ -2276,7 +2530,10 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
tooltip: l.nextPage,
|
tooltip: l.nextPage,
|
||||||
icon: const Icon(Icons.chevron_right),
|
icon: const Icon(Icons.chevron_right),
|
||||||
onPressed: _pageIndex < total - 1
|
onPressed: _pageIndex < total - 1
|
||||||
? () => _goToPage(_pageIndex + 1)
|
? () {
|
||||||
|
_bumpPageChrome();
|
||||||
|
_goToPage(_pageIndex + 1);
|
||||||
|
}
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ const double _kScaleGlitchHi = 1.18;
|
|||||||
/// During a 2-finger gesture the focal point (finger midpoint) should move
|
/// 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,
|
/// smoothly. A single-frame local jump beyond this is a Windows touch misread,
|
||||||
/// and the frame is dropped (position-jump guard).
|
/// and the frame is dropped (position-jump guard).
|
||||||
const double _kFocalGlitchPx = 100.0;
|
const double _kFocalGlitchPx = 64.0;
|
||||||
|
|
||||||
const double _kDrag = 0.0000135;
|
const double _kDrag = 0.0000135;
|
||||||
|
|
||||||
@@ -376,26 +376,9 @@ class _PenInteractiveViewerState extends State<PenInteractiveViewer>
|
|||||||
_animation!.addListener(_handleInertiaAnimation);
|
_animation!.addListener(_handleInertiaAnimation);
|
||||||
_controller.forward();
|
_controller.forward();
|
||||||
case _GestureType.scale:
|
case _GestureType.scale:
|
||||||
if (details.scaleVelocity.abs() < 0.1) return;
|
// No scale fling: Windows touch often reports noisy scaleVelocity that
|
||||||
final double scale = _transformer.value.getMaxScaleOnAxis();
|
// animates past the intended zoom and feels like a "jump" after pinch.
|
||||||
final FrictionSimulation frictionSimulation = FrictionSimulation(
|
return;
|
||||||
widget.interactionEndFrictionCoefficient * widget.scaleFactor,
|
|
||||||
scale,
|
|
||||||
details.scaleVelocity / 10,
|
|
||||||
);
|
|
||||||
final double tFinal = _getFinalTime(
|
|
||||||
details.scaleVelocity.abs(),
|
|
||||||
widget.interactionEndFrictionCoefficient,
|
|
||||||
effectivelyMotionless: 0.1,
|
|
||||||
);
|
|
||||||
_scaleAnimation = Tween<double>(
|
|
||||||
begin: scale,
|
|
||||||
end: frictionSimulation.x(tFinal),
|
|
||||||
).animate(
|
|
||||||
CurvedAnimation(parent: _scaleController, curve: Curves.decelerate));
|
|
||||||
_scaleController.duration = Duration(milliseconds: (tFinal * 1000).round());
|
|
||||||
_scaleAnimation!.addListener(_handleScaleAnimation);
|
|
||||||
_scaleController.forward();
|
|
||||||
case null:
|
case null:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -470,12 +470,25 @@ class _PenNoteScreenState extends ConsumerState<PenNoteScreen> {
|
|||||||
eraserRadius: _penConfig?.value.eraserRadius ?? kDefaultEraserRadius,
|
eraserRadius: _penConfig?.value.eraserRadius ?? kDefaultEraserRadius,
|
||||||
eraserWholeStroke: _penConfig?.value.eraserWholeStroke ?? false,
|
eraserWholeStroke: _penConfig?.value.eraserWholeStroke ?? false,
|
||||||
sideButtonAction:
|
sideButtonAction:
|
||||||
_penConfig?.value.sideButton ?? PenButtonAction.eraser,
|
_penConfig?.value.sideButton ?? PenButtonAction.select,
|
||||||
eraserEndAction:
|
eraserEndAction:
|
||||||
_penConfig?.value.eraserEnd ?? PenButtonAction.eraser,
|
_penConfig?.value.eraserEnd ?? PenButtonAction.eraser,
|
||||||
allowFingerDrawing: _allowFingerDrawing,
|
allowFingerDrawing: _allowFingerDrawing,
|
||||||
onStrokeComplete: _commitStroke,
|
onStrokeComplete: _commitStroke,
|
||||||
onEraseStroke: _eraseStroke,
|
onEraseStroke: _eraseStroke,
|
||||||
|
onPenButtonAction: (action) {
|
||||||
|
if (action == PenButtonAction.select) {
|
||||||
|
setState(() => _tool = EditorToolKind.select);
|
||||||
|
} else if (action == PenButtonAction.undo) {
|
||||||
|
if (_undo.isNotEmpty) _performUndo();
|
||||||
|
} else if (action == PenButtonAction.toggleTool) {
|
||||||
|
setState(() {
|
||||||
|
_tool = _tool == EditorToolKind.eraser
|
||||||
|
? EditorToolKind.brush
|
||||||
|
: EditorToolKind.eraser;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
// A white sheet with a soft shadow — the note "paper" — overlaid with
|
// A white sheet with a soft shadow — the note "paper" — overlaid with
|
||||||
// the selected background template, painted in page-pixel space (so it
|
// the selected background template, painted in page-pixel space (so it
|
||||||
// scales with zoom) and BEHIND the ink layers.
|
// scales with zoom) and BEHIND the ink layers.
|
||||||
@@ -510,19 +523,18 @@ class _PenNoteScreenState extends ConsumerState<PenNoteScreen> {
|
|||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
// Pen tool with brush picker (fountain / ballpoint / pencil), each
|
// OneNote-style: each pen is its own slot with remembered color.
|
||||||
// brush showing its own remembered color.
|
for (final b in kPenToolBrushes)
|
||||||
BrushPickerButton(
|
PenSlotButton(
|
||||||
selected: _penBrush,
|
kind: b,
|
||||||
active: _tool == EditorToolKind.brush,
|
selected: _tool == EditorToolKind.brush && _penBrush == b,
|
||||||
tooltip: 'Brush',
|
color: _brushColors[b] ?? Colors.black,
|
||||||
labelFor: brushLabelEn,
|
tooltip: brushLabelEn(b),
|
||||||
colorFor: (b) => _brushColors[b] ?? Colors.black,
|
onPressed: () => setState(() {
|
||||||
onSelected: (b) => setState(() {
|
_penBrush = b;
|
||||||
_penBrush = b;
|
_tool = EditorToolKind.brush;
|
||||||
_tool = EditorToolKind.brush;
|
}),
|
||||||
}),
|
),
|
||||||
),
|
|
||||||
ToolButton(
|
ToolButton(
|
||||||
icon: Icons.brush_outlined,
|
icon: Icons.brush_outlined,
|
||||||
selected: _tool == EditorToolKind.highlighter,
|
selected: _tool == EditorToolKind.highlighter,
|
||||||
|
|||||||
@@ -87,6 +87,64 @@ IconData shapeIcon(ShapeKind kind) => switch (kind) {
|
|||||||
ShapeKind.arrow => Icons.arrow_outward,
|
ShapeKind.arrow => Icons.arrow_outward,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// OneNote-style pen slot: each brush is its own toolbar button with a color
|
||||||
|
/// underline (per-brush remembered color). Prefer this over [BrushPickerButton]
|
||||||
|
/// when the UX wants pens visible side-by-side.
|
||||||
|
class PenSlotButton extends StatelessWidget {
|
||||||
|
const PenSlotButton({
|
||||||
|
super.key,
|
||||||
|
required this.kind,
|
||||||
|
required this.selected,
|
||||||
|
required this.color,
|
||||||
|
required this.tooltip,
|
||||||
|
required this.onPressed,
|
||||||
|
});
|
||||||
|
|
||||||
|
final BrushKind kind;
|
||||||
|
final bool selected;
|
||||||
|
final Color color;
|
||||||
|
final String tooltip;
|
||||||
|
final VoidCallback onPressed;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
final iconColor =
|
||||||
|
selected ? cs.onSecondaryContainer : cs.onSurfaceVariant;
|
||||||
|
return Tooltip(
|
||||||
|
message: tooltip,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onPressed,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 150),
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 2),
|
||||||
|
padding: const EdgeInsets.fromLTRB(6, 8, 6, 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: selected ? cs.secondaryContainer : Colors.transparent,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(brushIcon(kind), size: 22, color: iconColor),
|
||||||
|
const SizedBox(height: 3),
|
||||||
|
Container(
|
||||||
|
width: 16,
|
||||||
|
height: 3,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color,
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A dropdown that selects the active PEN brush (fountain / ballpoint / pencil).
|
/// A dropdown that selects the active PEN brush (fountain / ballpoint / pencil).
|
||||||
///
|
///
|
||||||
/// Highlighter and eraser remain separate tools. Tapping the button opens a
|
/// Highlighter and eraser remain separate tools. Tapping the button opens a
|
||||||
|
|||||||
@@ -465,17 +465,18 @@ class _PenSlideScreenState extends State<PenSlideScreen> {
|
|||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
BrushPickerButton(
|
// OneNote-style: each pen is its own slot with remembered color.
|
||||||
selected: _penBrush,
|
for (final b in kPenToolBrushes)
|
||||||
active: _tool == EditorToolKind.brush,
|
PenSlotButton(
|
||||||
tooltip: 'Brush',
|
kind: b,
|
||||||
labelFor: brushLabelEn,
|
selected: _tool == EditorToolKind.brush && _penBrush == b,
|
||||||
colorFor: (b) => _brushColors[b] ?? Colors.black,
|
color: _brushColors[b] ?? Colors.black,
|
||||||
onSelected: (b) => setState(() {
|
tooltip: brushLabelEn(b),
|
||||||
_penBrush = b;
|
onPressed: () => setState(() {
|
||||||
_tool = EditorToolKind.brush;
|
_penBrush = b;
|
||||||
}),
|
_tool = EditorToolKind.brush;
|
||||||
),
|
}),
|
||||||
|
),
|
||||||
ToolButton(
|
ToolButton(
|
||||||
icon: Icons.brush_outlined,
|
icon: Icons.brush_outlined,
|
||||||
selected: _tool == EditorToolKind.highlighter,
|
selected: _tool == EditorToolKind.highlighter,
|
||||||
@@ -604,8 +605,16 @@ class _PenSlideScreenState extends State<PenSlideScreen> {
|
|||||||
divisions: _slideCount > 1 ? _slideCount - 1 : null,
|
divisions: _slideCount > 1 ? _slideCount - 1 : null,
|
||||||
onChanged: (v) => setState(() => _scrub = v),
|
onChanged: (v) => setState(() => _scrub = v),
|
||||||
onChangeEnd: (v) {
|
onChangeEnd: (v) {
|
||||||
setState(() => _scrub = null);
|
final target = v.round() - 1;
|
||||||
_goToSlide(v.round() - 1);
|
setState(() {
|
||||||
|
_scrub = v;
|
||||||
|
_slideIndex = target;
|
||||||
|
});
|
||||||
|
_goToSlide(target);
|
||||||
|
setState(() {
|
||||||
|
_scrub = null;
|
||||||
|
_showSlider = false;
|
||||||
|
});
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
// lib/editor/canvas/sticky_note_overlay.dart
|
// lib/editor/canvas/sticky_note_overlay.dart
|
||||||
//
|
//
|
||||||
// Paper-sticky UX for PDF scratch links: a floating card on the viewer that
|
// 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,
|
// inks into the SAME SidecarRepository the PDF editor holds. Shares the parent
|
||||||
// no split-view race). Collapsed = page marker; expanded = this overlay.
|
// editor's brush/color/tool so there is one toolbar mental model (OneNote-like).
|
||||||
|
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
@@ -16,8 +17,8 @@ import '../engine/brush.dart';
|
|||||||
import '../input/pen_config.dart' show kDefaultEraserRadius;
|
import '../input/pen_config.dart' show kDefaultEraserRadius;
|
||||||
import '../notebook/ink_stroke_adapter.dart';
|
import '../notebook/ink_stroke_adapter.dart';
|
||||||
import '../persistence/sidecar_repository.dart';
|
import '../persistence/sidecar_repository.dart';
|
||||||
|
import 'editor_tool.dart';
|
||||||
import 'pen_canvas.dart';
|
import 'pen_canvas.dart';
|
||||||
import 'pen_palette_widgets.dart';
|
|
||||||
import 'pen_stroke.dart';
|
import 'pen_stroke.dart';
|
||||||
|
|
||||||
/// Default world size for a fresh sticky scratchpad (absolute px).
|
/// Default world size for a fresh sticky scratchpad (absolute px).
|
||||||
@@ -31,6 +32,10 @@ class StickyNoteOverlay extends StatefulWidget {
|
|||||||
required this.repo,
|
required this.repo,
|
||||||
required this.onClose,
|
required this.onClose,
|
||||||
required this.onDelete,
|
required this.onDelete,
|
||||||
|
this.brush = BrushKind.ballpoint,
|
||||||
|
this.color = const Color(0xFF1A1A1A),
|
||||||
|
this.tool = EditorToolKind.brush,
|
||||||
|
this.allowFingerDrawing = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
final ScratchLink link;
|
final ScratchLink link;
|
||||||
@@ -38,6 +43,12 @@ class StickyNoteOverlay extends StatefulWidget {
|
|||||||
final VoidCallback onClose;
|
final VoidCallback onClose;
|
||||||
final VoidCallback onDelete;
|
final VoidCallback onDelete;
|
||||||
|
|
||||||
|
/// Shared from the parent PDF toolbar (no mini duplicate palette).
|
||||||
|
final BrushKind brush;
|
||||||
|
final Color color;
|
||||||
|
final EditorToolKind tool;
|
||||||
|
final bool allowFingerDrawing;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<StickyNoteOverlay> createState() => _StickyNoteOverlayState();
|
State<StickyNoteOverlay> createState() => _StickyNoteOverlayState();
|
||||||
}
|
}
|
||||||
@@ -48,12 +59,13 @@ class _StickyNoteOverlayState extends State<StickyNoteOverlay> {
|
|||||||
final TransformationController _transform = TransformationController();
|
final TransformationController _transform = TransformationController();
|
||||||
List<InkStroke> _strokes = [];
|
List<InkStroke> _strokes = [];
|
||||||
Size _world = kStickyWorldSize;
|
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;
|
Timer? _saveTimer;
|
||||||
bool _dirty = false;
|
bool _dirty = false;
|
||||||
|
|
||||||
|
/// On-screen card size (user-resizable). World canvas stays [_world].
|
||||||
|
double _cardW = 300;
|
||||||
|
double _cardH = 360;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -61,13 +73,16 @@ class _StickyNoteOverlayState extends State<StickyNoteOverlay> {
|
|||||||
if (pad != null) {
|
if (pad != null) {
|
||||||
_world = Size(pad.canvasWidth, pad.canvasHeight);
|
_world = Size(pad.canvasWidth, pad.canvasHeight);
|
||||||
_strokes = pad.strokes.where((s) => isFreehandTool(s.tool)).toList();
|
_strokes = pad.strokes.where((s) => isFreehandTool(s.tool)).toList();
|
||||||
|
// Prefer a card that roughly matches aspect of the world, clamped.
|
||||||
|
final aspect = _world.width / math.max(_world.height, 1);
|
||||||
|
_cardW = (280.0 * aspect).clamp(220.0, 520.0);
|
||||||
|
_cardH = (_cardW / aspect + 40).clamp(260.0, 640.0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_saveTimer?.cancel();
|
_saveTimer?.cancel();
|
||||||
// Best-effort sync save before leaving the overlay.
|
|
||||||
if (_dirty) {
|
if (_dirty) {
|
||||||
widget.repo.scheduleScratchpadSave(
|
widget.repo.scheduleScratchpadSave(
|
||||||
widget.link.id,
|
widget.link.id,
|
||||||
@@ -134,6 +149,32 @@ class _StickyNoteOverlayState extends State<StickyNoteOverlay> {
|
|||||||
widget.onClose();
|
widget.onClose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
CanvasTool get _canvasTool {
|
||||||
|
switch (widget.tool) {
|
||||||
|
case EditorToolKind.eraser:
|
||||||
|
return CanvasTool.eraser;
|
||||||
|
case EditorToolKind.select:
|
||||||
|
return CanvasTool.select;
|
||||||
|
case EditorToolKind.highlighter:
|
||||||
|
case EditorToolKind.brush:
|
||||||
|
case EditorToolKind.shape:
|
||||||
|
case EditorToolKind.text:
|
||||||
|
return CanvasTool.pen;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BrushKind get _canvasBrush =>
|
||||||
|
widget.tool == EditorToolKind.highlighter
|
||||||
|
? BrushKind.highlighter
|
||||||
|
: widget.brush;
|
||||||
|
|
||||||
|
void _onResizeDrag(DragUpdateDetails d) {
|
||||||
|
setState(() {
|
||||||
|
_cardW = (_cardW + d.delta.dx).clamp(220.0, 640.0);
|
||||||
|
_cardH = (_cardH + d.delta.dy).clamp(240.0, 720.0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final cs = Theme.of(context).colorScheme;
|
final cs = Theme.of(context).colorScheme;
|
||||||
@@ -142,117 +183,96 @@ class _StickyNoteOverlayState extends State<StickyNoteOverlay> {
|
|||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
color: const Color(0xFFFFF8E1),
|
color: const Color(0xFFFFF8E1),
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: 280,
|
width: _cardW,
|
||||||
height: 340,
|
height: _cardH,
|
||||||
child: Column(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Column(
|
||||||
height: 36,
|
children: [
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
Container(
|
||||||
decoration: const BoxDecoration(
|
height: 36,
|
||||||
color: Color(0xFFFFE082),
|
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(4)),
|
decoration: const BoxDecoration(
|
||||||
),
|
color: Color(0xFFFFE082),
|
||||||
child: Row(
|
borderRadius: BorderRadius.vertical(top: Radius.circular(4)),
|
||||||
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(
|
child: Row(
|
||||||
tooltip: '删除',
|
children: [
|
||||||
icon: const Icon(Icons.delete_outline, size: 18),
|
const Icon(Icons.sticky_note_2, size: 18),
|
||||||
visualDensity: VisualDensity.compact,
|
const SizedBox(width: 6),
|
||||||
onPressed: () async {
|
const Expanded(
|
||||||
await _saveNow();
|
child: Text(
|
||||||
widget.onDelete();
|
'便利贴',
|
||||||
},
|
style: TextStyle(
|
||||||
),
|
fontSize: 13,
|
||||||
IconButton(
|
fontWeight: FontWeight.w600,
|
||||||
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,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
Text(
|
||||||
],
|
'用顶栏笔/色',
|
||||||
),
|
style: TextStyle(
|
||||||
),
|
fontSize: 11,
|
||||||
Expanded(
|
color: cs.onSurface.withValues(alpha: 0.55),
|
||||||
child: ClipRect(
|
),
|
||||||
child: PenCanvas(
|
),
|
||||||
pageSize: _world,
|
IconButton(
|
||||||
strokes: penStrokesFromInk(_strokes, _world),
|
tooltip: '删除',
|
||||||
transformationController: _transform,
|
icon: const Icon(Icons.delete_outline, size: 18),
|
||||||
tool: _tool,
|
visualDensity: VisualDensity.compact,
|
||||||
brush: _brush,
|
onPressed: () async {
|
||||||
color: _color,
|
await _saveNow();
|
||||||
strokeWidth: 0.008,
|
widget.onDelete();
|
||||||
eraserRadius: kDefaultEraserRadius,
|
},
|
||||||
minScale: 0.2,
|
),
|
||||||
maxScale: 4.0,
|
IconButton(
|
||||||
onStrokeComplete: _onStrokeComplete,
|
tooltip: '收起',
|
||||||
onEraseStroke: _onErase,
|
icon: const Icon(Icons.close, size: 18),
|
||||||
pageWidget: const ColoredBox(color: Color(0xFFFFFDE7)),
|
visualDensity: VisualDensity.compact,
|
||||||
|
onPressed: _close,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
Expanded(
|
||||||
|
child: ClipRect(
|
||||||
|
child: PenCanvas(
|
||||||
|
pageSize: _world,
|
||||||
|
strokes: penStrokesFromInk(_strokes, _world),
|
||||||
|
transformationController: _transform,
|
||||||
|
tool: _canvasTool,
|
||||||
|
brush: _canvasBrush,
|
||||||
|
color: widget.color,
|
||||||
|
strokeWidth: brushProfileFor(_canvasBrush).baseWidthFraction,
|
||||||
|
eraserRadius: kDefaultEraserRadius,
|
||||||
|
allowFingerDrawing: widget.allowFingerDrawing,
|
||||||
|
minScale: 0.2,
|
||||||
|
maxScale: 4.0,
|
||||||
|
onStrokeComplete: _onStrokeComplete,
|
||||||
|
onEraseStroke: _onErase,
|
||||||
|
pageWidget: const ColoredBox(color: Color(0xFFFFFDE7)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
Padding(
|
Positioned(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
right: 0,
|
||||||
child: Row(
|
bottom: 0,
|
||||||
children: [
|
child: GestureDetector(
|
||||||
IconButton(
|
onPanUpdate: _onResizeDrag,
|
||||||
tooltip: '笔',
|
child: MouseRegion(
|
||||||
icon: Icon(
|
cursor: SystemMouseCursors.resizeUpLeftDownRight,
|
||||||
Icons.edit,
|
child: SizedBox(
|
||||||
size: 18,
|
width: 28,
|
||||||
color: _tool == CanvasTool.pen ? cs.primary : null,
|
height: 28,
|
||||||
|
child: Icon(
|
||||||
|
Icons.south_east,
|
||||||
|
size: 16,
|
||||||
|
color: cs.onSurface.withValues(alpha: 0.45),
|
||||||
),
|
),
|
||||||
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),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -135,16 +135,15 @@ class BrushProfile {
|
|||||||
/// highlighter ≈ 0.02 so the existing pen/highlighter visuals are PRESERVED as
|
/// highlighter ≈ 0.02 so the existing pen/highlighter visuals are PRESERVED as
|
||||||
/// the fountainPen/highlighter presets (no regression).
|
/// the fountainPen/highlighter presets (no regression).
|
||||||
const Map<BrushKind, BrushProfile> kBrushPresets = {
|
const Map<BrushKind, BrushProfile> kBrushPresets = {
|
||||||
// Fountain pen — spec §4: size~6, thinning 0.9, smoothing 0.55,
|
// Fountain pen — Surface feel: lower streamline (less lag), higher thinning
|
||||||
// streamline 0.45, simulatePressure false, taper on, pressure pre-warped to
|
// for expressive width, pressure pre-warped to p² (Pow2 / quadratic).
|
||||||
// p² (Pow2 / quadratic = pressureGamma 2.0). Solid ink (opacity 1.0).
|
// Solid ink (opacity 1.0). See also tipVelocityWidthScale (ink starvation).
|
||||||
BrushKind.fountainPen: BrushProfile(
|
BrushKind.fountainPen: BrushProfile(
|
||||||
kind: BrushKind.fountainPen,
|
kind: BrushKind.fountainPen,
|
||||||
baseWidthFraction: 0.006,
|
baseWidthFraction: 0.006,
|
||||||
pressureGamma: 2.0,
|
pressureGamma: 2.0,
|
||||||
// Was 0.9 — too aggressive on short CJK strokes (width collapses mid-glyph).
|
pfThinning: 0.75,
|
||||||
pfThinning: 0.65,
|
pfStreamline: 0.22,
|
||||||
pfStreamline: 0.4,
|
|
||||||
pfSmoothing: 0.5,
|
pfSmoothing: 0.5,
|
||||||
simulatePressure: false,
|
simulatePressure: false,
|
||||||
capStart: true,
|
capStart: true,
|
||||||
@@ -154,15 +153,14 @@ const Map<BrushKind, BrushProfile> kBrushPresets = {
|
|||||||
opacity: 1.0,
|
opacity: 1.0,
|
||||||
blendMultiply: false,
|
blendMultiply: false,
|
||||||
),
|
),
|
||||||
// Ballpoint — Krita-inspired "ink pen": near-constant width, SOLID opacity.
|
// Ballpoint — near-constant width, SOLID opacity. Lower streamline (~0.35)
|
||||||
// Pressure modulates WIDTH slightly (thinning 0.15), NOT alpha — translucent
|
// for lower latency; thinning 0.12 keeps width almost flat.
|
||||||
// srcOver stacking looked like accidental multiply when strokes overlapped.
|
|
||||||
BrushKind.ballpoint: BrushProfile(
|
BrushKind.ballpoint: BrushProfile(
|
||||||
kind: BrushKind.ballpoint,
|
kind: BrushKind.ballpoint,
|
||||||
baseWidthFraction: 0.0022,
|
baseWidthFraction: 0.0022,
|
||||||
pressureGamma: 1.0,
|
pressureGamma: 1.0,
|
||||||
pfThinning: 0.15,
|
pfThinning: 0.12,
|
||||||
pfStreamline: 0.55,
|
pfStreamline: 0.35,
|
||||||
pfSmoothing: 0.5,
|
pfSmoothing: 0.5,
|
||||||
simulatePressure: false,
|
simulatePressure: false,
|
||||||
capStart: true,
|
capStart: true,
|
||||||
@@ -171,18 +169,14 @@ const Map<BrushKind, BrushProfile> kBrushPresets = {
|
|||||||
opacity: 1.0,
|
opacity: 1.0,
|
||||||
blendMultiply: false,
|
blendMultiply: false,
|
||||||
),
|
),
|
||||||
// Highlighter — spec §4: size~22, thinning 0.0 (constant width),
|
// Highlighter — flat width (thinning 0), square (uncapped) ends, translucent +
|
||||||
// smoothing 0.4, streamline 0.5, square (uncapped) ends, translucent +
|
// multiply build-up. streamline 0.3 for a bit less lag on broad strokes.
|
||||||
// multiply build-up. opacity 0.35 / blendMultiply true are APPLIED via
|
|
||||||
// resolveStrokePaint: the 0.35 is multiplied INTO the color's existing alpha
|
|
||||||
// (the capture path ships a 0x80 / 50% translucent color), and the stroke
|
|
||||||
// composites with BlendMode.multiply (cross-stroke overlap darkens = marker).
|
|
||||||
BrushKind.highlighter: BrushProfile(
|
BrushKind.highlighter: BrushProfile(
|
||||||
kind: BrushKind.highlighter,
|
kind: BrushKind.highlighter,
|
||||||
baseWidthFraction: 0.02,
|
baseWidthFraction: 0.02,
|
||||||
pressureGamma: 1.0,
|
pressureGamma: 1.0,
|
||||||
pfThinning: 0.0,
|
pfThinning: 0.0,
|
||||||
pfStreamline: 0.5,
|
pfStreamline: 0.3,
|
||||||
pfSmoothing: 0.4,
|
pfSmoothing: 0.4,
|
||||||
simulatePressure: false,
|
simulatePressure: false,
|
||||||
capStart: false,
|
capStart: false,
|
||||||
@@ -191,15 +185,13 @@ const Map<BrushKind, BrushProfile> kBrushPresets = {
|
|||||||
opacity: 0.35,
|
opacity: 0.35,
|
||||||
blendMultiply: true,
|
blendMultiply: true,
|
||||||
),
|
),
|
||||||
// Pencil — Krita-inspired: soft graphite, moderate translucency via √p, but
|
// Pencil — soft graphite via √p, moderate translucency. streamline 0.25.
|
||||||
// NEVER multiply blend (only highlighter uses multiply). Cap ~0.88 so overlaps
|
|
||||||
// darken gently under srcOver without turning into marker blobs.
|
|
||||||
BrushKind.pencil: BrushProfile(
|
BrushKind.pencil: BrushProfile(
|
||||||
kind: BrushKind.pencil,
|
kind: BrushKind.pencil,
|
||||||
baseWidthFraction: 0.003,
|
baseWidthFraction: 0.003,
|
||||||
pressureGamma: 0.5,
|
pressureGamma: 0.5,
|
||||||
pfThinning: 0.45,
|
pfThinning: 0.45,
|
||||||
pfStreamline: 0.35,
|
pfStreamline: 0.25,
|
||||||
pfSmoothing: 0.45,
|
pfSmoothing: 0.45,
|
||||||
simulatePressure: false,
|
simulatePressure: false,
|
||||||
capStart: true,
|
capStart: true,
|
||||||
|
|||||||
33
lib/editor/engine/pen_physics.dart
Normal file
33
lib/editor/engine/pen_physics.dart
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
// lib/editor/engine/pen_physics.dart
|
||||||
|
//
|
||||||
|
// Simple physical tip model: modulate stroke width by tip velocity so fountain
|
||||||
|
// ink feels slightly thinner when moving fast (starvation), while ballpoint
|
||||||
|
// stays nearly velocity-invariant.
|
||||||
|
//
|
||||||
|
// TODO(pen-physics-wire): wired at capture in PenCanvas._toNormalized via
|
||||||
|
// tip velocity × pressure. PDF editor path still uses brush gamma only.
|
||||||
|
|
||||||
|
import 'brush.dart';
|
||||||
|
|
||||||
|
/// Modulate width fraction by tip velocity (page-normalized units per second).
|
||||||
|
///
|
||||||
|
/// Fountain: faster → slightly thinner (ink starvation feel).
|
||||||
|
/// Ballpoint: nearly ignore velocity.
|
||||||
|
/// Pencil: mild thinning at speed.
|
||||||
|
/// Highlighter: ignore velocity (flat marker).
|
||||||
|
double tipVelocityWidthScale(BrushKind kind, double speedNormPerSec) {
|
||||||
|
final speed =
|
||||||
|
speedNormPerSec.isNaN || speedNormPerSec < 0 ? 0.0 : speedNormPerSec;
|
||||||
|
// Reference: ~2 page-widths/sec ≈ fast handwriting; clamp influence to [0,1].
|
||||||
|
final t = (speed / 2.0).clamp(0.0, 1.0);
|
||||||
|
switch (kind) {
|
||||||
|
case BrushKind.fountainPen:
|
||||||
|
return 1.0 - 0.15 * t;
|
||||||
|
case BrushKind.ballpoint:
|
||||||
|
return 1.0 - 0.02 * t;
|
||||||
|
case BrushKind.pencil:
|
||||||
|
return 1.0 - 0.08 * t;
|
||||||
|
case BrushKind.highlighter:
|
||||||
|
return 1.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ class PredictedPoint {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class StrokePredictor {
|
class StrokePredictor {
|
||||||
StrokePredictor({this.lookaheadMs = 12});
|
StrokePredictor({this.lookaheadMs = 8});
|
||||||
|
|
||||||
/// How far ahead to project, in milliseconds of recent velocity.
|
/// How far ahead to project, in milliseconds of recent velocity.
|
||||||
final double lookaheadMs;
|
final double lookaheadMs;
|
||||||
|
|||||||
@@ -19,7 +19,10 @@ enum PenButtonAction {
|
|||||||
toggleTool,
|
toggleTool,
|
||||||
pan,
|
pan,
|
||||||
|
|
||||||
/// Hold to temporarily enable PDF text selection (OneNote-style).
|
/// Rising-edge: switch to the universal stroke [select] tool (OneNote-like).
|
||||||
|
select,
|
||||||
|
|
||||||
|
/// Hold to temporarily enable PDF text selection.
|
||||||
selectText,
|
selectText,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,7 +31,7 @@ enum PenButtonAction {
|
|||||||
/// Persisted under SharedPreferences key [PenConfigController.prefsKey].
|
/// Persisted under SharedPreferences key [PenConfigController.prefsKey].
|
||||||
class PenConfig {
|
class PenConfig {
|
||||||
const PenConfig({
|
const PenConfig({
|
||||||
this.sideButton = PenButtonAction.eraser,
|
this.sideButton = PenButtonAction.select,
|
||||||
this.eraserEnd = PenButtonAction.eraser,
|
this.eraserEnd = PenButtonAction.eraser,
|
||||||
this.pressureGamma = kNaturalPressureGamma,
|
this.pressureGamma = kNaturalPressureGamma,
|
||||||
this.palmRejectionMs = 150.0,
|
this.palmRejectionMs = 150.0,
|
||||||
|
|||||||
@@ -8,6 +8,10 @@
|
|||||||
// - [gamma]: the response exponent — γ<1 makes light touches register more
|
// - [gamma]: the response exponent — γ<1 makes light touches register more
|
||||||
// width (more sensitive), γ>1 requires firmer pressure (less sensitive).
|
// width (more sensitive), γ>1 requires firmer pressure (less sensitive).
|
||||||
//
|
//
|
||||||
|
// Named [PressureCurveShape] presets mirror rnote-style curves (linear / soft /
|
||||||
|
// Pow2 / cubic / log / sqrt) via [PressureCurve.shaped]. Logarithmic uses
|
||||||
|
// ln(1+k·p)/ln(1+k); all others use p^gamma.
|
||||||
|
//
|
||||||
// Pure value type (widget-free, storage-free) so the full mapping is unit
|
// Pure value type (widget-free, storage-free) so the full mapping is unit
|
||||||
// tested; PenConfig / the canvas wire it later (the wiring touches the live
|
// tested; PenConfig / the canvas wire it later (the wiring touches the live
|
||||||
// draw path and is validated on-device).
|
// draw path and is validated on-device).
|
||||||
@@ -23,27 +27,86 @@ const double kNaturalPressureGamma = 0.7;
|
|||||||
/// dynamic range so thin strokes have body instead of scratchy near-zero width.
|
/// dynamic range so thin strokes have body instead of scratchy near-zero width.
|
||||||
const double kNaturalPressureFloor = 0.12;
|
const double kNaturalPressureFloor = 0.12;
|
||||||
|
|
||||||
|
/// Steepness for [PressureCurveShape.logarithmic]: `ln(1+k·p)/ln(1+k)`.
|
||||||
|
const double kLogarithmicPressureK = 9.0;
|
||||||
|
|
||||||
|
/// Named rnote-style pressure-response shapes.
|
||||||
|
enum PressureCurveShape {
|
||||||
|
/// Identity: gamma 1.
|
||||||
|
linear,
|
||||||
|
|
||||||
|
/// Light-touch sensitive: gamma ≈ 0.6.
|
||||||
|
soft,
|
||||||
|
|
||||||
|
/// rnote Pow2 / fountain: gamma 2.
|
||||||
|
quadratic,
|
||||||
|
|
||||||
|
/// gamma 3.
|
||||||
|
cubic,
|
||||||
|
|
||||||
|
/// Log curve: ln(1+k·p)/ln(1+k).
|
||||||
|
logarithmic,
|
||||||
|
|
||||||
|
/// Pencil: gamma 0.5.
|
||||||
|
sqrt,
|
||||||
|
}
|
||||||
|
|
||||||
/// 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({
|
||||||
: assert(floor >= 0.0 && floor < 1.0),
|
this.floor = 0.0,
|
||||||
|
this.gamma = 1.0,
|
||||||
|
this.shape,
|
||||||
|
}) : assert(floor >= 0.0 && floor < 1.0),
|
||||||
assert(gamma > 0.0);
|
assert(gamma > 0.0);
|
||||||
|
|
||||||
|
/// Named-shape factory. Sets [gamma] for power-law shapes; logarithmic
|
||||||
|
/// ignores gamma and uses [kLogarithmicPressureK] in [apply].
|
||||||
|
factory PressureCurve.shaped(
|
||||||
|
PressureCurveShape shape, {
|
||||||
|
double floor = 0.0,
|
||||||
|
}) {
|
||||||
|
switch (shape) {
|
||||||
|
case PressureCurveShape.linear:
|
||||||
|
return PressureCurve(floor: floor, gamma: 1.0, shape: shape);
|
||||||
|
case PressureCurveShape.soft:
|
||||||
|
return PressureCurve(floor: floor, gamma: 0.6, shape: shape);
|
||||||
|
case PressureCurveShape.quadratic:
|
||||||
|
return PressureCurve(floor: floor, gamma: 2.0, shape: shape);
|
||||||
|
case PressureCurveShape.cubic:
|
||||||
|
return PressureCurve(floor: floor, gamma: 3.0, shape: shape);
|
||||||
|
case PressureCurveShape.logarithmic:
|
||||||
|
return PressureCurve(floor: floor, gamma: 1.0, shape: shape);
|
||||||
|
case PressureCurveShape.sqrt:
|
||||||
|
return PressureCurve(floor: floor, gamma: 0.5, shape: shape);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Minimum output (>=0, <1). 0 = full dynamic range; raise toward 1 for a
|
/// Minimum output (>=0, <1). 0 = full dynamic range; raise toward 1 for a
|
||||||
/// fixed-pressure feel (marker).
|
/// fixed-pressure feel (marker).
|
||||||
final double floor;
|
final double floor;
|
||||||
|
|
||||||
/// Response exponent (>0). 1 = linear; <1 = more sensitive at light pressure;
|
/// Response exponent (>0). 1 = linear; <1 = more sensitive at light pressure;
|
||||||
/// >1 = firmer.
|
/// >1 = firmer. Unused when [shape] is [PressureCurveShape.logarithmic].
|
||||||
final double gamma;
|
final double gamma;
|
||||||
|
|
||||||
|
/// Optional named shape. When [PressureCurveShape.logarithmic], [apply] uses
|
||||||
|
/// the log formula; otherwise (or when null) uses `p^gamma`.
|
||||||
|
final PressureCurveShape? shape;
|
||||||
|
|
||||||
/// Linear, full-range pen response (identity).
|
/// Linear, full-range pen response (identity).
|
||||||
static const PressureCurve linear = PressureCurve();
|
static const PressureCurve linear = PressureCurve();
|
||||||
|
|
||||||
/// Shape [pressure] (clamped to [0,1]) into `[floor, 1]`.
|
/// Shape [pressure] (clamped to [0,1]) into `[floor, 1]`.
|
||||||
double apply(double pressure) {
|
double apply(double pressure) {
|
||||||
final p = pressure.isNaN ? 0.0 : pressure.clamp(0.0, 1.0);
|
final p = pressure.isNaN ? 0.0 : pressure.clamp(0.0, 1.0);
|
||||||
final shaped = gamma == 1.0 ? p : math.pow(p, gamma).toDouble();
|
final double shaped;
|
||||||
|
if (shape == PressureCurveShape.logarithmic) {
|
||||||
|
shaped = math.log(1.0 + kLogarithmicPressureK * p) /
|
||||||
|
math.log(1.0 + kLogarithmicPressureK);
|
||||||
|
} else {
|
||||||
|
shaped = gamma == 1.0 ? p : math.pow(p, gamma).toDouble();
|
||||||
|
}
|
||||||
return floor + (1.0 - floor) * shaped;
|
return floor + (1.0 - floor) * shaped;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,25 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../input/pen_config.dart';
|
import '../input/pen_config.dart';
|
||||||
|
import '../input/pressure_curve.dart';
|
||||||
|
|
||||||
|
String _shapeNameForGamma(double gamma) {
|
||||||
|
if ((gamma - 0.6).abs() < 0.05) return 'soft';
|
||||||
|
if ((gamma - 1.0).abs() < 0.05) return 'linear';
|
||||||
|
if ((gamma - 2.0).abs() < 0.05) return 'quadratic';
|
||||||
|
if ((gamma - 3.0).abs() < 0.05) return 'cubic';
|
||||||
|
if ((gamma - 0.5).abs() < 0.05) return 'sqrt';
|
||||||
|
return 'soft';
|
||||||
|
}
|
||||||
|
|
||||||
|
PressureCurve _curveForName(String name) => switch (name) {
|
||||||
|
'linear' => PressureCurve.shaped(PressureCurveShape.linear),
|
||||||
|
'quadratic' => PressureCurve.shaped(PressureCurveShape.quadratic),
|
||||||
|
'cubic' => PressureCurve.shaped(PressureCurveShape.cubic),
|
||||||
|
'sqrt' => PressureCurve.shaped(PressureCurveShape.sqrt),
|
||||||
|
'logarithmic' => PressureCurve.shaped(PressureCurveShape.soft), // gamma proxy
|
||||||
|
_ => PressureCurve.shaped(PressureCurveShape.soft),
|
||||||
|
};
|
||||||
|
|
||||||
/// Shows a Material You modal bottom sheet for configuring pen input.
|
/// Shows a Material You modal bottom sheet for configuring pen input.
|
||||||
///
|
///
|
||||||
@@ -111,6 +130,32 @@ class _PenSettingsSheet extends StatelessWidget {
|
|||||||
formatValue: (v) => v.toStringAsFixed(2),
|
formatValue: (v) => v.toStringAsFixed(2),
|
||||||
onChanged: controller.setPressureGamma,
|
onChanged: controller.setPressureGamma,
|
||||||
),
|
),
|
||||||
|
_LabeledRow(
|
||||||
|
label: 'Curve Preset (rnote)',
|
||||||
|
child: DropdownMenu<String>(
|
||||||
|
initialSelection: _shapeNameForGamma(config.pressureGamma),
|
||||||
|
onSelected: (name) {
|
||||||
|
if (name == null) return;
|
||||||
|
final shaped = _curveForName(name);
|
||||||
|
controller.setPressureGamma(shaped.gamma);
|
||||||
|
},
|
||||||
|
dropdownMenuEntries: const [
|
||||||
|
DropdownMenuEntry(value: 'soft', label: 'Soft (γ≈0.6)'),
|
||||||
|
DropdownMenuEntry(value: 'linear', label: 'Linear'),
|
||||||
|
DropdownMenuEntry(
|
||||||
|
value: 'quadratic', label: 'Quadratic / Pow2'),
|
||||||
|
DropdownMenuEntry(value: 'cubic', label: 'Cubic'),
|
||||||
|
DropdownMenuEntry(value: 'sqrt', label: 'Sqrt (pencil)'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.only(left: 8, bottom: 8),
|
||||||
|
child: Text(
|
||||||
|
'笔刷自带曲线优先(钢笔=二次/Pow2,铅笔=平方根)。全局 gamma 作后备。',
|
||||||
|
style: TextStyle(fontSize: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
// ── Input ─────────────────────────────────────────────────
|
// ── Input ─────────────────────────────────────────────────
|
||||||
_SectionHeader(
|
_SectionHeader(
|
||||||
@@ -265,12 +310,13 @@ class _ActionDropdown extends StatelessWidget {
|
|||||||
final ValueChanged<PenButtonAction> onChanged;
|
final ValueChanged<PenButtonAction> onChanged;
|
||||||
|
|
||||||
static String _label(PenButtonAction action) => switch (action) {
|
static String _label(PenButtonAction action) => switch (action) {
|
||||||
PenButtonAction.none => 'None',
|
PenButtonAction.none => '无',
|
||||||
PenButtonAction.eraser => 'Eraser',
|
PenButtonAction.eraser => '橡皮',
|
||||||
PenButtonAction.undo => 'Undo',
|
PenButtonAction.undo => '撤销',
|
||||||
PenButtonAction.toggleTool => 'Toggle Tool',
|
PenButtonAction.toggleTool => '切换工具',
|
||||||
PenButtonAction.pan => 'Pan',
|
PenButtonAction.pan => '平移',
|
||||||
PenButtonAction.selectText => 'Select text',
|
PenButtonAction.select => '选择(笔迹)',
|
||||||
|
PenButtonAction.selectText => '选择文本',
|
||||||
};
|
};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -218,11 +218,20 @@ class _MemberTile extends StatelessWidget {
|
|||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
subtitle: Text(member.kind.name.toUpperCase()),
|
subtitle: Text(_kindLabel(member.kind)),
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _kindLabel(NotebookMemberKind kind) => switch (kind) {
|
||||||
|
NotebookMemberKind.note => '空白页 · 手写',
|
||||||
|
NotebookMemberKind.pdf => 'PDF · 批注',
|
||||||
|
NotebookMemberKind.pptx => 'PPTX · 幻灯片',
|
||||||
|
NotebookMemberKind.ppt => 'PPT · 幻灯片',
|
||||||
|
NotebookMemberKind.docx => 'DOCX · 文档',
|
||||||
|
};
|
||||||
|
|
||||||
IconData _iconFor(NotebookMemberKind kind) => switch (kind) {
|
IconData _iconFor(NotebookMemberKind kind) => switch (kind) {
|
||||||
NotebookMemberKind.note => Icons.edit_note,
|
NotebookMemberKind.note => Icons.edit_note,
|
||||||
NotebookMemberKind.pdf => Icons.picture_as_pdf,
|
NotebookMemberKind.pdf => Icons.picture_as_pdf,
|
||||||
|
|||||||
@@ -22,11 +22,11 @@ void main() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('fountain pen — Pow2 (p²), moderate thinning, no taper, solid', () {
|
test('fountain pen — Pow2 (p²), higher thinning, low streamline, solid', () {
|
||||||
final b = brushProfileFor(BrushKind.fountainPen);
|
final b = brushProfileFor(BrushKind.fountainPen);
|
||||||
expect(b.pressureGamma, 2.0); // rnote Pow2 / quadratic
|
expect(b.pressureGamma, 2.0); // rnote Pow2 / quadratic
|
||||||
expect(b.pfThinning, 0.65);
|
expect(b.pfThinning, 0.75);
|
||||||
expect(b.pfStreamline, 0.4);
|
expect(b.pfStreamline, 0.22);
|
||||||
expect(b.pfSmoothing, 0.5);
|
expect(b.pfSmoothing, 0.5);
|
||||||
expect(b.simulatePressure, isFalse);
|
expect(b.simulatePressure, isFalse);
|
||||||
expect(b.taper, isFalse);
|
expect(b.taper, isFalse);
|
||||||
@@ -36,11 +36,11 @@ void main() {
|
|||||||
expect(b.blendMultiply, isFalse);
|
expect(b.blendMultiply, isFalse);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('ballpoint — linear, near-constant width (thinning 0.15)', () {
|
test('ballpoint — linear, near-constant width (thinning 0.12)', () {
|
||||||
final b = brushProfileFor(BrushKind.ballpoint);
|
final b = brushProfileFor(BrushKind.ballpoint);
|
||||||
expect(b.pressureGamma, 1.0); // rnote Linear
|
expect(b.pressureGamma, 1.0); // rnote Linear
|
||||||
expect(b.pfThinning, 0.15);
|
expect(b.pfThinning, 0.12);
|
||||||
expect(b.pfStreamline, 0.55);
|
expect(b.pfStreamline, 0.35);
|
||||||
expect(b.simulatePressure, isFalse);
|
expect(b.simulatePressure, isFalse);
|
||||||
expect(b.taper, isFalse);
|
expect(b.taper, isFalse);
|
||||||
});
|
});
|
||||||
@@ -49,7 +49,7 @@ void main() {
|
|||||||
final b = brushProfileFor(BrushKind.highlighter);
|
final b = brushProfileFor(BrushKind.highlighter);
|
||||||
expect(b.pressureGamma, 1.0);
|
expect(b.pressureGamma, 1.0);
|
||||||
expect(b.pfThinning, 0.0); // constant width
|
expect(b.pfThinning, 0.0); // constant width
|
||||||
expect(b.pfStreamline, 0.5);
|
expect(b.pfStreamline, 0.3);
|
||||||
expect(b.pfSmoothing, 0.4);
|
expect(b.pfSmoothing, 0.4);
|
||||||
expect(b.capStart, isFalse); // square ends
|
expect(b.capStart, isFalse); // square ends
|
||||||
expect(b.capEnd, isFalse);
|
expect(b.capEnd, isFalse);
|
||||||
@@ -61,7 +61,7 @@ void main() {
|
|||||||
final b = brushProfileFor(BrushKind.pencil);
|
final b = brushProfileFor(BrushKind.pencil);
|
||||||
expect(b.pressureGamma, 0.5); // rnote Sqrt / √p
|
expect(b.pressureGamma, 0.5); // rnote Sqrt / √p
|
||||||
expect(b.pfThinning, 0.45);
|
expect(b.pfThinning, 0.45);
|
||||||
expect(b.pfStreamline, 0.35);
|
expect(b.pfStreamline, 0.25);
|
||||||
expect(b.taper, isFalse);
|
expect(b.taper, isFalse);
|
||||||
expect(b.blendMultiply, isFalse);
|
expect(b.blendMultiply, isFalse);
|
||||||
});
|
});
|
||||||
|
|||||||
34
test/pen_physics_test.dart
Normal file
34
test/pen_physics_test.dart
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
// Tests for tipVelocityWidthScale (physical tip model).
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:badnote/editor/engine/brush.dart';
|
||||||
|
import 'package:badnote/editor/engine/pen_physics.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('tipVelocityWidthScale', () {
|
||||||
|
test('idle speed leaves all brushes at 1.0', () {
|
||||||
|
for (final k in BrushKind.values) {
|
||||||
|
expect(tipVelocityWidthScale(k, 0.0), closeTo(1.0, 1e-9));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fountain thins more than ballpoint at the same speed', () {
|
||||||
|
const speed = 2.0; // reference fast handwriting
|
||||||
|
final fountain = tipVelocityWidthScale(BrushKind.fountainPen, speed);
|
||||||
|
final ballpoint = tipVelocityWidthScale(BrushKind.ballpoint, speed);
|
||||||
|
expect(fountain, closeTo(0.85, 1e-9));
|
||||||
|
expect(ballpoint, closeTo(0.98, 1e-9));
|
||||||
|
expect(fountain, lessThan(ballpoint));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('highlighter ignores velocity', () {
|
||||||
|
expect(tipVelocityWidthScale(BrushKind.highlighter, 5.0), 1.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('NaN / negative speed treated as idle', () {
|
||||||
|
expect(tipVelocityWidthScale(BrushKind.fountainPen, double.nan), 1.0);
|
||||||
|
expect(tipVelocityWidthScale(BrushKind.fountainPen, -1.0), 1.0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
// Tests for the configurable pen-pressure response (F5).
|
// Tests for the configurable pen-pressure response (F5).
|
||||||
|
|
||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
import 'package:badnote/editor/input/pressure_curve.dart';
|
import 'package:badnote/editor/input/pressure_curve.dart';
|
||||||
@@ -46,4 +48,59 @@ void main() {
|
|||||||
// Even a light touch stays near full width.
|
// Even a light touch stays near full width.
|
||||||
expect(marker.apply(0.1), greaterThan(0.9));
|
expect(marker.apply(0.1), greaterThan(0.9));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('PressureCurve.shaped', () {
|
||||||
|
const floor = 0.1;
|
||||||
|
|
||||||
|
test('every shape maps 0→floor and 1→1', () {
|
||||||
|
for (final shape in PressureCurveShape.values) {
|
||||||
|
final c = PressureCurve.shaped(shape, floor: floor);
|
||||||
|
expect(c.apply(0.0), closeTo(floor, 1e-9), reason: '$shape at 0');
|
||||||
|
expect(c.apply(1.0), closeTo(1.0, 1e-9), reason: '$shape at 1');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('midpoints differ across shapes', () {
|
||||||
|
final mids = {
|
||||||
|
for (final shape in PressureCurveShape.values)
|
||||||
|
shape: PressureCurve.shaped(shape).apply(0.5),
|
||||||
|
};
|
||||||
|
expect(mids[PressureCurveShape.linear], closeTo(0.5, 1e-9));
|
||||||
|
expect(mids[PressureCurveShape.soft], closeTo(math.pow(0.5, 0.6), 1e-9));
|
||||||
|
expect(mids[PressureCurveShape.quadratic], closeTo(0.25, 1e-9));
|
||||||
|
expect(mids[PressureCurveShape.cubic], closeTo(0.125, 1e-9));
|
||||||
|
expect(mids[PressureCurveShape.sqrt], closeTo(math.sqrt(0.5), 1e-9));
|
||||||
|
final logMid = math.log(1 + kLogarithmicPressureK * 0.5) /
|
||||||
|
math.log(1 + kLogarithmicPressureK);
|
||||||
|
expect(mids[PressureCurveShape.logarithmic], closeTo(logMid, 1e-9));
|
||||||
|
|
||||||
|
// Soft (γ<1) > linear > quadratic > cubic at p=0.5; log is above linear.
|
||||||
|
expect(mids[PressureCurveShape.soft]! > mids[PressureCurveShape.linear]!,
|
||||||
|
isTrue);
|
||||||
|
expect(
|
||||||
|
mids[PressureCurveShape.linear]! >
|
||||||
|
mids[PressureCurveShape.quadratic]!,
|
||||||
|
isTrue);
|
||||||
|
expect(
|
||||||
|
mids[PressureCurveShape.quadratic]! >
|
||||||
|
mids[PressureCurveShape.cubic]!,
|
||||||
|
isTrue);
|
||||||
|
expect(
|
||||||
|
mids[PressureCurveShape.logarithmic]! >
|
||||||
|
mids[PressureCurveShape.linear]!,
|
||||||
|
isTrue);
|
||||||
|
// Distinct midpoints (no two shapes collide at p=0.5).
|
||||||
|
expect(mids.values.toSet().length, PressureCurveShape.values.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shaped presets set expected gamma (except log)', () {
|
||||||
|
expect(PressureCurve.shaped(PressureCurveShape.linear).gamma, 1.0);
|
||||||
|
expect(PressureCurve.shaped(PressureCurveShape.soft).gamma, 0.6);
|
||||||
|
expect(PressureCurve.shaped(PressureCurveShape.quadratic).gamma, 2.0);
|
||||||
|
expect(PressureCurve.shaped(PressureCurveShape.cubic).gamma, 3.0);
|
||||||
|
expect(PressureCurve.shaped(PressureCurveShape.sqrt).gamma, 0.5);
|
||||||
|
expect(PressureCurve.shaped(PressureCurveShape.logarithmic).shape,
|
||||||
|
PressureCurveShape.logarithmic);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user