fix: PDF finger ink, chrome UX, OneNote pens, and pen physics
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:
2026-08-06 16:01:01 +08:00
parent 85af037b7d
commit f31dd0fb52
17 changed files with 835 additions and 234 deletions

View File

@@ -22,6 +22,7 @@ import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import '../engine/brush.dart';
import '../engine/pen_physics.dart';
import '../engine/stroke_eraser.dart';
import '../engine/stroke_geometry.dart' show kDefaultPenThinning;
import '../engine/stroke_model.dart';
@@ -33,6 +34,7 @@ import '../input/pressure_curve.dart';
import '../input/pen_input_service.dart';
import '../../diagnostics/pen_event_ring.dart';
import '../engine/shape_geometry.dart';
import 'dart:math' as math;
import '../render/ink_picture_cache.dart';
import '../render/live_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).
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
/// inverted stylus), detected on hover/down.
bool _eraserActive = false;
@@ -399,7 +405,8 @@ class _PenCanvasState extends State<PenCanvas> {
if (action == _lastHwAction) return;
_lastHwAction = action;
if (action == PenButtonAction.undo ||
action == PenButtonAction.toggleTool) {
action == PenButtonAction.toggleTool ||
action == PenButtonAction.select) {
widget.onPenButtonAction?.call(action);
}
}
@@ -441,7 +448,7 @@ class _PenCanvasState extends State<PenCanvas> {
/// Map a global pointer position into normalized page coords using the
/// shared transform (inverse) and this widget's geometry.
PenPoint? _toNormalized(Offset globalPosition, double? pressure,
{double? tilt}) {
{double? tilt, Duration? timeStamp}) {
final box = context.findRenderObject() as RenderBox?;
if (box == null) return null;
final local = box.globalToLocal(globalPosition);
@@ -451,7 +458,23 @@ class _PenCanvasState extends State<PenCanvas> {
final nx = scene.dx / widget.pageSize.width;
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 -----------------------------------------------------
@@ -464,8 +487,10 @@ class _PenCanvasState extends State<PenCanvas> {
_shapeStart = null;
_selectLast = null;
_selectDragging = false;
_lastTipNorm = null;
_lastTipTime = null;
final p = _toNormalized(event.position, _normalizedPressure(event),
tilt: _tiltFor(event));
tilt: _tiltFor(event), timeStamp: event.timeStamp);
if (_eraserActive || widget.tool == CanvasTool.eraser) {
_eraserCursor.value = p;
@@ -502,7 +527,7 @@ class _PenCanvasState extends State<PenCanvas> {
void _extendStroke(PointerMoveEvent event) {
final p = _toNormalized(event.position, _normalizedPressure(event),
tilt: _tiltFor(event));
tilt: _tiltFor(event), timeStamp: event.timeStamp);
if (p == null) return;
if (_eraserActive || widget.tool == CanvasTool.eraser) {

View File

@@ -23,6 +23,7 @@
// survive reopen, and a stored highlight can be removed (the un-highlight tool).
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/foundation.dart'
show ValueListenable, visibleForTesting;
@@ -37,6 +38,7 @@ import '../../models/scratch_link.dart';
import '../../storage/badnote_sidecar.dart';
import '../../storage/notebook_manifest.dart' show kAnnotationFontFamily;
import '../engine/brush.dart';
import '../engine/pen_physics.dart';
import '../engine/shape_geometry.dart';
import '../engine/stroke_eraser.dart';
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.
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).
final Map<int, List<PenStroke>> _strokesByPage = {};
@@ -370,6 +392,28 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
void _onHwPenChanged() {
_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
@@ -458,6 +502,7 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
@override
void dispose() {
_pageChromeHideTimer?.cancel();
// Flush any pending sidecar write before tearing down.
final repo = _repo;
if (repo != null) {
@@ -630,6 +675,71 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
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) {
if (_isStylus(event.kind)) _emitPenDebug(event);
@@ -661,10 +771,12 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
return;
}
_liveStrokePage = hit.page;
_lastTipNorm = null;
_lastTipTime = null;
_livePoints
..clear()
..add(PenPoint(hit.normalized.dx, hit.normalized.dy,
_normalizedPressure(event)));
_pressureWithPhysics(event, hit.normalized)));
_updateLiveStroke();
} else if (event is PointerMoveEvent) {
final page = _liveStrokePage;
@@ -689,7 +801,9 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
// A stroke belongs to ONE page: ignore samples on a different page.
if (hit == null || hit.page != page) return;
_livePoints.add(PenPoint(
hit.normalized.dx, hit.normalized.dy, _normalizedPressure(event)));
hit.normalized.dx,
hit.normalized.dy,
_pressureWithPhysics(event, hit.normalized)));
_updateLiveStroke();
} else if (event is PointerUpEvent || event is PointerCancelEvent) {
_endStroke(commit: event is PointerUpEvent);
@@ -1273,9 +1387,57 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
void _goToPage(int index) {
if (_pageCount == 0) return;
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);
}
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) {
setState(() {
_tool = tool;
@@ -1363,7 +1525,14 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
void _closeSticky() {
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).
@@ -1687,16 +1856,50 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
),
),
),
// Floating page-control pill (bottom-center).
if (_viewerReady && _pageCount > 0)
// Floating page-control pill (bottom-center). Auto-hides when idle.
if (_viewerReady && _pageCount > 0 && _pageChromeVisible)
SafeArea(
child: Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: const EdgeInsets.only(bottom: 16),
padding: EdgeInsets.only(
bottom: _hasSelection ? 72 : 16,
),
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).
SafeArea(
@@ -1728,7 +1931,9 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
// the zoom and snaps back. We drive zoom ourselves via the glitch-guarded
// _TwoFingerPinch recognizer in viewerOverlayBuilder → controller.
// 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,
// Native vector text selection. Pen falls through to this only in
// select-text mode (PenCaptureRegion.captureEnabled == false).
@@ -1742,6 +1947,7 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
_viewerReady = true;
_pageCount = document.pages.length;
});
_bumpPageChrome();
// Honor a requested initial page (search-result jump), clamped.
final target = widget.initialPage.clamp(0, _pageCount - 1);
if (target > 0) {
@@ -1751,7 +1957,17 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
onPageChanged: (pageNumber) {
if (pageNumber == null || !mounted) return;
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
// normalized page space scaled to the on-screen page rect.
@@ -1921,6 +2137,18 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
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(
child: PenCaptureRegion(
captureEnabled: _penCaptureEnabled,
@@ -1932,7 +2160,7 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
Positioned(
left: 16,
right: 16,
bottom: 24,
bottom: 88,
child: _SelectionActionBar(
onHighlight: _highlightSelection,
onBookmark: _addBookmark,
@@ -1946,6 +2174,10 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
key: ValueKey(_expandedSticky!.id),
link: _expandedSticky!,
repo: _repo!,
brush: _penBrush,
color: _color,
tool: _tool,
allowFingerDrawing: _allowFingerDrawing,
onClose: _closeSticky,
onDelete: () async {
final link = _expandedSticky!;
@@ -1973,17 +2205,20 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
BrushPickerButton(
selected: _penBrush,
active: _tool == EditorToolKind.brush && _penCaptureEnabled,
tooltip: l.brushPicker,
labelFor: (b) => brushLabel(b, l),
colorFor: (b) => _brushColors[b] ?? Colors.black,
onSelected: (b) {
setState(() => _penBrush = b);
_setTool(EditorToolKind.brush);
},
),
// OneNote-style: each pen is its own slot with remembered color.
for (final b in kPenToolBrushes)
PenSlotButton(
kind: b,
selected: _tool == EditorToolKind.brush &&
_penBrush == b &&
_penCaptureEnabled,
color: _brushColors[b] ?? Colors.black,
tooltip: brushLabel(b, l),
onPressed: () {
setState(() => _penBrush = b);
_setTool(EditorToolKind.brush);
},
),
ToolButton(
icon: Icons.brush_outlined,
selected: _tool == EditorToolKind.highlighter && _penCaptureEnabled,
@@ -2222,7 +2457,7 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
if (total > 1)
if (total > 1 && _showPageScrubber)
Container(
margin: const EdgeInsets.only(bottom: 8),
constraints: const BoxConstraints(maxWidth: 420),
@@ -2238,10 +2473,17 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
value: (scrub ?? (_pageIndex + 1).toDouble())
.clamp(1, total.toDouble()),
divisions: total > 1 ? total - 1 : null,
onChanged: (v) => setState(() => _pageScrub = v),
onChanged: (v) {
setState(() => _pageScrub = v);
_bumpPageChrome();
},
onChangeEnd: (v) {
setState(() => _pageScrub = null);
_goToPage(v.round() - 1);
final target = 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(
tooltip: l.previousPage,
icon: const Icon(Icons.chevron_left),
onPressed:
_pageIndex > 0 ? () => _goToPage(_pageIndex - 1) : null,
onPressed: _pageIndex > 0
? () {
_bumpPageChrome();
_goToPage(_pageIndex - 1);
}
: null,
),
TextButton(
onPressed: _viewerReady ? _openThumbnails : null,
onPressed: () {
_bumpPageChrome();
if (total > 1) {
setState(() => _showPageScrubber = !_showPageScrubber);
} else if (_viewerReady) {
_openThumbnails();
}
},
onLongPress: _viewerReady ? _openThumbnails : null,
child: Text(
l.pageOfPages(shown, total),
style: TextStyle(
@@ -2276,7 +2530,10 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
tooltip: l.nextPage,
icon: const Icon(Icons.chevron_right),
onPressed: _pageIndex < total - 1
? () => _goToPage(_pageIndex + 1)
? () {
_bumpPageChrome();
_goToPage(_pageIndex + 1);
}
: null,
),
],

View File

@@ -53,7 +53,7 @@ const double _kScaleGlitchHi = 1.18;
/// During a 2-finger gesture the focal point (finger midpoint) should move
/// smoothly. A single-frame local jump beyond this is a Windows touch misread,
/// and the frame is dropped (position-jump guard).
const double _kFocalGlitchPx = 100.0;
const double _kFocalGlitchPx = 64.0;
const double _kDrag = 0.0000135;
@@ -376,26 +376,9 @@ class _PenInteractiveViewerState extends State<PenInteractiveViewer>
_animation!.addListener(_handleInertiaAnimation);
_controller.forward();
case _GestureType.scale:
if (details.scaleVelocity.abs() < 0.1) return;
final double scale = _transformer.value.getMaxScaleOnAxis();
final FrictionSimulation frictionSimulation = FrictionSimulation(
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();
// No scale fling: Windows touch often reports noisy scaleVelocity that
// animates past the intended zoom and feels like a "jump" after pinch.
return;
case null:
break;
}

View File

@@ -470,12 +470,25 @@ class _PenNoteScreenState extends ConsumerState<PenNoteScreen> {
eraserRadius: _penConfig?.value.eraserRadius ?? kDefaultEraserRadius,
eraserWholeStroke: _penConfig?.value.eraserWholeStroke ?? false,
sideButtonAction:
_penConfig?.value.sideButton ?? PenButtonAction.eraser,
_penConfig?.value.sideButton ?? PenButtonAction.select,
eraserEndAction:
_penConfig?.value.eraserEnd ?? PenButtonAction.eraser,
allowFingerDrawing: _allowFingerDrawing,
onStrokeComplete: _commitStroke,
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
// the selected background template, painted in page-pixel space (so it
// scales with zoom) and BEHIND the ink layers.
@@ -510,19 +523,18 @@ class _PenNoteScreenState extends ConsumerState<PenNoteScreen> {
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// Pen tool with brush picker (fountain / ballpoint / pencil), each
// brush showing its own remembered color.
BrushPickerButton(
selected: _penBrush,
active: _tool == EditorToolKind.brush,
tooltip: 'Brush',
labelFor: brushLabelEn,
colorFor: (b) => _brushColors[b] ?? Colors.black,
onSelected: (b) => setState(() {
_penBrush = b;
_tool = EditorToolKind.brush;
}),
),
// OneNote-style: each pen is its own slot with remembered color.
for (final b in kPenToolBrushes)
PenSlotButton(
kind: b,
selected: _tool == EditorToolKind.brush && _penBrush == b,
color: _brushColors[b] ?? Colors.black,
tooltip: brushLabelEn(b),
onPressed: () => setState(() {
_penBrush = b;
_tool = EditorToolKind.brush;
}),
),
ToolButton(
icon: Icons.brush_outlined,
selected: _tool == EditorToolKind.highlighter,

View File

@@ -87,6 +87,64 @@ IconData shapeIcon(ShapeKind kind) => switch (kind) {
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).
///
/// Highlighter and eraser remain separate tools. Tapping the button opens a

View File

@@ -465,17 +465,18 @@ class _PenSlideScreenState extends State<PenSlideScreen> {
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
BrushPickerButton(
selected: _penBrush,
active: _tool == EditorToolKind.brush,
tooltip: 'Brush',
labelFor: brushLabelEn,
colorFor: (b) => _brushColors[b] ?? Colors.black,
onSelected: (b) => setState(() {
_penBrush = b;
_tool = EditorToolKind.brush;
}),
),
// OneNote-style: each pen is its own slot with remembered color.
for (final b in kPenToolBrushes)
PenSlotButton(
kind: b,
selected: _tool == EditorToolKind.brush && _penBrush == b,
color: _brushColors[b] ?? Colors.black,
tooltip: brushLabelEn(b),
onPressed: () => setState(() {
_penBrush = b;
_tool = EditorToolKind.brush;
}),
),
ToolButton(
icon: Icons.brush_outlined,
selected: _tool == EditorToolKind.highlighter,
@@ -604,8 +605,16 @@ class _PenSlideScreenState extends State<PenSlideScreen> {
divisions: _slideCount > 1 ? _slideCount - 1 : null,
onChanged: (v) => setState(() => _scrub = v),
onChangeEnd: (v) {
setState(() => _scrub = null);
_goToSlide(v.round() - 1);
final target = v.round() - 1;
setState(() {
_scrub = v;
_slideIndex = target;
});
_goToSlide(target);
setState(() {
_scrub = null;
_showSlider = false;
});
},
),
),

View File

@@ -1,10 +1,11 @@
// lib/editor/canvas/sticky_note_overlay.dart
//
// Paper-sticky UX for PDF scratch links: a floating card on the viewer that
// inks into the SAME SidecarRepository the PDF editor holds (no second open,
// no split-view race). Collapsed = page marker; expanded = this overlay.
// inks into the SAME SidecarRepository the PDF editor holds. Shares the parent
// editor's brush/color/tool so there is one toolbar mental model (OneNote-like).
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:uuid/uuid.dart';
@@ -16,8 +17,8 @@ import '../engine/brush.dart';
import '../input/pen_config.dart' show kDefaultEraserRadius;
import '../notebook/ink_stroke_adapter.dart';
import '../persistence/sidecar_repository.dart';
import 'editor_tool.dart';
import 'pen_canvas.dart';
import 'pen_palette_widgets.dart';
import 'pen_stroke.dart';
/// Default world size for a fresh sticky scratchpad (absolute px).
@@ -31,6 +32,10 @@ class StickyNoteOverlay extends StatefulWidget {
required this.repo,
required this.onClose,
required this.onDelete,
this.brush = BrushKind.ballpoint,
this.color = const Color(0xFF1A1A1A),
this.tool = EditorToolKind.brush,
this.allowFingerDrawing = false,
});
final ScratchLink link;
@@ -38,6 +43,12 @@ class StickyNoteOverlay extends StatefulWidget {
final VoidCallback onClose;
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
State<StickyNoteOverlay> createState() => _StickyNoteOverlayState();
}
@@ -48,12 +59,13 @@ class _StickyNoteOverlayState extends State<StickyNoteOverlay> {
final TransformationController _transform = TransformationController();
List<InkStroke> _strokes = [];
Size _world = kStickyWorldSize;
CanvasTool _tool = CanvasTool.pen;
BrushKind _brush = BrushKind.ballpoint; // ignore: prefer_final_fields — reserved for brush picker
Color _color = kInkPalette.first;
Timer? _saveTimer;
bool _dirty = false;
/// On-screen card size (user-resizable). World canvas stays [_world].
double _cardW = 300;
double _cardH = 360;
@override
void initState() {
super.initState();
@@ -61,13 +73,16 @@ class _StickyNoteOverlayState extends State<StickyNoteOverlay> {
if (pad != null) {
_world = Size(pad.canvasWidth, pad.canvasHeight);
_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
void dispose() {
_saveTimer?.cancel();
// Best-effort sync save before leaving the overlay.
if (_dirty) {
widget.repo.scheduleScratchpadSave(
widget.link.id,
@@ -134,6 +149,32 @@ class _StickyNoteOverlayState extends State<StickyNoteOverlay> {
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
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
@@ -142,117 +183,96 @@ class _StickyNoteOverlayState extends State<StickyNoteOverlay> {
borderRadius: BorderRadius.circular(4),
color: const Color(0xFFFFF8E1),
child: SizedBox(
width: 280,
height: 340,
child: Column(
width: _cardW,
height: _cardH,
child: Stack(
children: [
Container(
height: 36,
padding: const EdgeInsets.symmetric(horizontal: 4),
decoration: const BoxDecoration(
color: Color(0xFFFFE082),
borderRadius: BorderRadius.vertical(top: Radius.circular(4)),
),
child: Row(
children: [
const Icon(Icons.sticky_note_2, size: 18),
const SizedBox(width: 6),
const Expanded(
child: Text(
'便利贴',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
Column(
children: [
Container(
height: 36,
padding: const EdgeInsets.symmetric(horizontal: 4),
decoration: const BoxDecoration(
color: Color(0xFFFFE082),
borderRadius: BorderRadius.vertical(top: Radius.circular(4)),
),
IconButton(
tooltip: '删除',
icon: const Icon(Icons.delete_outline, size: 18),
visualDensity: VisualDensity.compact,
onPressed: () async {
await _saveNow();
widget.onDelete();
},
),
IconButton(
tooltip: '收起',
icon: const Icon(Icons.close, size: 18),
visualDensity: VisualDensity.compact,
onPressed: _close,
),
],
),
),
SizedBox(
height: 32,
child: ListView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 6),
children: [
for (final c in kInkPalette)
GestureDetector(
onTap: () => setState(() => _color = c),
child: Container(
width: 18,
height: 18,
margin: const EdgeInsets.symmetric(
horizontal: 3, vertical: 7),
decoration: BoxDecoration(
color: c,
shape: BoxShape.circle,
border: Border.all(
color: _color == c ? cs.primary : cs.outlineVariant,
width: _color == c ? 2 : 1,
child: Row(
children: [
const Icon(Icons.sticky_note_2, size: 18),
const SizedBox(width: 6),
const Expanded(
child: Text(
'便利贴',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
),
),
],
),
),
Expanded(
child: ClipRect(
child: PenCanvas(
pageSize: _world,
strokes: penStrokesFromInk(_strokes, _world),
transformationController: _transform,
tool: _tool,
brush: _brush,
color: _color,
strokeWidth: 0.008,
eraserRadius: kDefaultEraserRadius,
minScale: 0.2,
maxScale: 4.0,
onStrokeComplete: _onStrokeComplete,
onEraseStroke: _onErase,
pageWidget: const ColoredBox(color: Color(0xFFFFFDE7)),
Text(
'用顶栏笔/色',
style: TextStyle(
fontSize: 11,
color: cs.onSurface.withValues(alpha: 0.55),
),
),
IconButton(
tooltip: '删除',
icon: const Icon(Icons.delete_outline, size: 18),
visualDensity: VisualDensity.compact,
onPressed: () async {
await _saveNow();
widget.onDelete();
},
),
IconButton(
tooltip: '收起',
icon: const Icon(Icons.close, size: 18),
visualDensity: VisualDensity.compact,
onPressed: _close,
),
],
),
),
),
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(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
child: Row(
children: [
IconButton(
tooltip: '',
icon: Icon(
Icons.edit,
size: 18,
color: _tool == CanvasTool.pen ? cs.primary : null,
Positioned(
right: 0,
bottom: 0,
child: GestureDetector(
onPanUpdate: _onResizeDrag,
child: MouseRegion(
cursor: SystemMouseCursors.resizeUpLeftDownRight,
child: SizedBox(
width: 28,
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),
),
],
),
),
),
],

View File

@@ -135,16 +135,15 @@ class BrushProfile {
/// highlighter ≈ 0.02 so the existing pen/highlighter visuals are PRESERVED as
/// the fountainPen/highlighter presets (no regression).
const Map<BrushKind, BrushProfile> kBrushPresets = {
// Fountain pen — spec §4: size~6, thinning 0.9, smoothing 0.55,
// streamline 0.45, simulatePressure false, taper on, pressure pre-warped to
// p² (Pow2 / quadratic = pressureGamma 2.0). Solid ink (opacity 1.0).
// Fountain pen — Surface feel: lower streamline (less lag), higher thinning
// for expressive width, pressure pre-warped to p² (Pow2 / quadratic).
// Solid ink (opacity 1.0). See also tipVelocityWidthScale (ink starvation).
BrushKind.fountainPen: BrushProfile(
kind: BrushKind.fountainPen,
baseWidthFraction: 0.006,
pressureGamma: 2.0,
// Was 0.9 — too aggressive on short CJK strokes (width collapses mid-glyph).
pfThinning: 0.65,
pfStreamline: 0.4,
pfThinning: 0.75,
pfStreamline: 0.22,
pfSmoothing: 0.5,
simulatePressure: false,
capStart: true,
@@ -154,15 +153,14 @@ const Map<BrushKind, BrushProfile> kBrushPresets = {
opacity: 1.0,
blendMultiply: false,
),
// Ballpoint — Krita-inspired "ink pen": near-constant width, SOLID opacity.
// Pressure modulates WIDTH slightly (thinning 0.15), NOT alpha — translucent
// srcOver stacking looked like accidental multiply when strokes overlapped.
// Ballpoint — near-constant width, SOLID opacity. Lower streamline (~0.35)
// for lower latency; thinning 0.12 keeps width almost flat.
BrushKind.ballpoint: BrushProfile(
kind: BrushKind.ballpoint,
baseWidthFraction: 0.0022,
pressureGamma: 1.0,
pfThinning: 0.15,
pfStreamline: 0.55,
pfThinning: 0.12,
pfStreamline: 0.35,
pfSmoothing: 0.5,
simulatePressure: false,
capStart: true,
@@ -171,18 +169,14 @@ const Map<BrushKind, BrushProfile> kBrushPresets = {
opacity: 1.0,
blendMultiply: false,
),
// Highlighter — spec §4: size~22, thinning 0.0 (constant width),
// smoothing 0.4, streamline 0.5, square (uncapped) ends, translucent +
// 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).
// Highlighter — flat width (thinning 0), square (uncapped) ends, translucent +
// multiply build-up. streamline 0.3 for a bit less lag on broad strokes.
BrushKind.highlighter: BrushProfile(
kind: BrushKind.highlighter,
baseWidthFraction: 0.02,
pressureGamma: 1.0,
pfThinning: 0.0,
pfStreamline: 0.5,
pfStreamline: 0.3,
pfSmoothing: 0.4,
simulatePressure: false,
capStart: false,
@@ -191,15 +185,13 @@ const Map<BrushKind, BrushProfile> kBrushPresets = {
opacity: 0.35,
blendMultiply: true,
),
// Pencil — Krita-inspired: soft graphite, moderate translucency via √p, but
// NEVER multiply blend (only highlighter uses multiply). Cap ~0.88 so overlaps
// darken gently under srcOver without turning into marker blobs.
// Pencil — soft graphite via √p, moderate translucency. streamline 0.25.
BrushKind.pencil: BrushProfile(
kind: BrushKind.pencil,
baseWidthFraction: 0.003,
pressureGamma: 0.5,
pfThinning: 0.45,
pfStreamline: 0.35,
pfStreamline: 0.25,
pfSmoothing: 0.45,
simulatePressure: false,
capStart: true,

View 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;
}
}

View File

@@ -11,7 +11,7 @@ class PredictedPoint {
}
class StrokePredictor {
StrokePredictor({this.lookaheadMs = 12});
StrokePredictor({this.lookaheadMs = 8});
/// How far ahead to project, in milliseconds of recent velocity.
final double lookaheadMs;

View File

@@ -19,7 +19,10 @@ enum PenButtonAction {
toggleTool,
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,
}
@@ -28,7 +31,7 @@ enum PenButtonAction {
/// Persisted under SharedPreferences key [PenConfigController.prefsKey].
class PenConfig {
const PenConfig({
this.sideButton = PenButtonAction.eraser,
this.sideButton = PenButtonAction.select,
this.eraserEnd = PenButtonAction.eraser,
this.pressureGamma = kNaturalPressureGamma,
this.palmRejectionMs = 150.0,

View File

@@ -8,6 +8,10 @@
// - [gamma]: the response exponent — γ<1 makes light touches register more
// 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
// tested; PenConfig / the canvas wire it later (the wiring touches the live
// 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.
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]`.
class PressureCurve {
const PressureCurve({this.floor = 0.0, this.gamma = 1.0})
: assert(floor >= 0.0 && floor < 1.0),
const PressureCurve({
this.floor = 0.0,
this.gamma = 1.0,
this.shape,
}) : assert(floor >= 0.0 && floor < 1.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
/// fixed-pressure feel (marker).
final double floor;
/// Response exponent (>0). 1 = linear; <1 = more sensitive at light pressure;
/// >1 = firmer.
/// >1 = firmer. Unused when [shape] is [PressureCurveShape.logarithmic].
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).
static const PressureCurve linear = PressureCurve();
/// Shape [pressure] (clamped to [0,1]) into `[floor, 1]`.
double apply(double pressure) {
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;
}
}

View File

@@ -1,6 +1,25 @@
import 'package:flutter/material.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.
///
@@ -111,6 +130,32 @@ class _PenSettingsSheet extends StatelessWidget {
formatValue: (v) => v.toStringAsFixed(2),
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 ─────────────────────────────────────────────────
_SectionHeader(
@@ -265,12 +310,13 @@ class _ActionDropdown extends StatelessWidget {
final ValueChanged<PenButtonAction> onChanged;
static String _label(PenButtonAction action) => switch (action) {
PenButtonAction.none => 'None',
PenButtonAction.eraser => 'Eraser',
PenButtonAction.undo => 'Undo',
PenButtonAction.toggleTool => 'Toggle Tool',
PenButtonAction.pan => 'Pan',
PenButtonAction.selectText => 'Select text',
PenButtonAction.none => '',
PenButtonAction.eraser => '橡皮',
PenButtonAction.undo => '撤销',
PenButtonAction.toggleTool => '切换工具',
PenButtonAction.pan => '平移',
PenButtonAction.select => '选择(笔迹)',
PenButtonAction.selectText => '选择文本',
};
@override

View File

@@ -218,11 +218,20 @@ class _MemberTile extends StatelessWidget {
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(member.kind.name.toUpperCase()),
subtitle: Text(_kindLabel(member.kind)),
trailing: const Icon(Icons.chevron_right),
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) {
NotebookMemberKind.note => Icons.edit_note,
NotebookMemberKind.pdf => Icons.picture_as_pdf,

View File

@@ -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);
expect(b.pressureGamma, 2.0); // rnote Pow2 / quadratic
expect(b.pfThinning, 0.65);
expect(b.pfStreamline, 0.4);
expect(b.pfThinning, 0.75);
expect(b.pfStreamline, 0.22);
expect(b.pfSmoothing, 0.5);
expect(b.simulatePressure, isFalse);
expect(b.taper, isFalse);
@@ -36,11 +36,11 @@ void main() {
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);
expect(b.pressureGamma, 1.0); // rnote Linear
expect(b.pfThinning, 0.15);
expect(b.pfStreamline, 0.55);
expect(b.pfThinning, 0.12);
expect(b.pfStreamline, 0.35);
expect(b.simulatePressure, isFalse);
expect(b.taper, isFalse);
});
@@ -49,7 +49,7 @@ void main() {
final b = brushProfileFor(BrushKind.highlighter);
expect(b.pressureGamma, 1.0);
expect(b.pfThinning, 0.0); // constant width
expect(b.pfStreamline, 0.5);
expect(b.pfStreamline, 0.3);
expect(b.pfSmoothing, 0.4);
expect(b.capStart, isFalse); // square ends
expect(b.capEnd, isFalse);
@@ -61,7 +61,7 @@ void main() {
final b = brushProfileFor(BrushKind.pencil);
expect(b.pressureGamma, 0.5); // rnote Sqrt / √p
expect(b.pfThinning, 0.45);
expect(b.pfStreamline, 0.35);
expect(b.pfStreamline, 0.25);
expect(b.taper, isFalse);
expect(b.blendMultiply, isFalse);
});

View 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);
});
});
}

View File

@@ -1,5 +1,7 @@
// Tests for the configurable pen-pressure response (F5).
import 'dart:math' as math;
import 'package:flutter_test/flutter_test.dart';
import 'package:badnote/editor/input/pressure_curve.dart';
@@ -46,4 +48,59 @@ void main() {
// Even a light touch stays near full width.
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);
});
});
}