From f31dd0fb52a74f7266ea2701ba4448c837670610 Mon Sep 17 00:00:00 2001 From: Akiba So Date: Thu, 6 Aug 2026 16:01:01 +0800 Subject: [PATCH] fix: PDF finger ink, chrome UX, OneNote pens, and pen physics 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 --- lib/editor/canvas/pen_canvas.dart | 35 +- lib/editor/canvas/pen_editor_screen.dart | 313 ++++++++++++++++-- lib/editor/canvas/pen_interactive_viewer.dart | 25 +- lib/editor/canvas/pen_note_screen.dart | 40 ++- lib/editor/canvas/pen_palette_widgets.dart | 58 ++++ lib/editor/canvas/pen_slide_screen.dart | 35 +- lib/editor/canvas/sticky_note_overlay.dart | 238 +++++++------ lib/editor/engine/brush.dart | 36 +- lib/editor/engine/pen_physics.dart | 33 ++ lib/editor/engine/stroke_predictor.dart | 2 +- lib/editor/input/pen_config.dart | 7 +- lib/editor/input/pressure_curve.dart | 71 +++- lib/editor/ui/pen_settings_page.dart | 58 +++- lib/screens/notebook_screen.dart | 11 +- test/brush_test.dart | 16 +- test/pen_physics_test.dart | 34 ++ test/pressure_curve_test.dart | 57 ++++ 17 files changed, 835 insertions(+), 234 deletions(-) create mode 100644 lib/editor/engine/pen_physics.dart create mode 100644 test/pen_physics_test.dart diff --git a/lib/editor/canvas/pen_canvas.dart b/lib/editor/canvas/pen_canvas.dart index f05b4d1..67d2e51 100644 --- a/lib/editor/canvas/pen_canvas.dart +++ b/lib/editor/canvas/pen_canvas.dart @@ -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 { /// 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 { 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 { /// 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 { 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 { _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 { 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) { diff --git a/lib/editor/canvas/pen_editor_screen.dart b/lib/editor/canvas/pen_editor_screen.dart index 714a4a8..01e1beb 100644 --- a/lib/editor/canvas/pen_editor_screen.dart +++ b/lib/editor/canvas/pen_editor_screen.dart @@ -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 { /// 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 _fingerPointers = {}; + bool _fingerStrokeActive = false; + + Offset? _lastTipNorm; + Duration? _lastTipTime; + + /// Per-page sticky that was open when the user left the page — restored on return. + final Map _stickyRememberedByPage = {}; + + /// 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> _strokesByPage = {}; @@ -370,6 +392,28 @@ class _PenEditorScreenState extends State { 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 { @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 { 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 { 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 { // 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 { 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 { 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 { ), ), ), - // 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 { // 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 { _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 { 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 { 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 { Positioned( left: 16, right: 16, - bottom: 24, + bottom: 88, child: _SelectionActionBar( onHighlight: _highlightSelection, onBookmark: _addBookmark, @@ -1946,6 +2174,10 @@ class _PenEditorScreenState extends State { 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 { 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 { 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 { 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 { 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 { tooltip: l.nextPage, icon: const Icon(Icons.chevron_right), onPressed: _pageIndex < total - 1 - ? () => _goToPage(_pageIndex + 1) + ? () { + _bumpPageChrome(); + _goToPage(_pageIndex + 1); + } : null, ), ], diff --git a/lib/editor/canvas/pen_interactive_viewer.dart b/lib/editor/canvas/pen_interactive_viewer.dart index 8c3814a..d4a8d90 100644 --- a/lib/editor/canvas/pen_interactive_viewer.dart +++ b/lib/editor/canvas/pen_interactive_viewer.dart @@ -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 _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( - 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; } diff --git a/lib/editor/canvas/pen_note_screen.dart b/lib/editor/canvas/pen_note_screen.dart index d630dc3..8bbb3d9 100644 --- a/lib/editor/canvas/pen_note_screen.dart +++ b/lib/editor/canvas/pen_note_screen.dart @@ -470,12 +470,25 @@ class _PenNoteScreenState extends ConsumerState { 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 { 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, diff --git a/lib/editor/canvas/pen_palette_widgets.dart b/lib/editor/canvas/pen_palette_widgets.dart index ec0bb9a..fbea529 100644 --- a/lib/editor/canvas/pen_palette_widgets.dart +++ b/lib/editor/canvas/pen_palette_widgets.dart @@ -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 diff --git a/lib/editor/canvas/pen_slide_screen.dart b/lib/editor/canvas/pen_slide_screen.dart index 359b947..47fb503 100644 --- a/lib/editor/canvas/pen_slide_screen.dart +++ b/lib/editor/canvas/pen_slide_screen.dart @@ -465,17 +465,18 @@ class _PenSlideScreenState extends State { 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 { 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; + }); }, ), ), diff --git a/lib/editor/canvas/sticky_note_overlay.dart b/lib/editor/canvas/sticky_note_overlay.dart index 15a4580..91b3db9 100644 --- a/lib/editor/canvas/sticky_note_overlay.dart +++ b/lib/editor/canvas/sticky_note_overlay.dart @@ -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 createState() => _StickyNoteOverlayState(); } @@ -48,12 +59,13 @@ class _StickyNoteOverlayState extends State { final TransformationController _transform = TransformationController(); List _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 { 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 { 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 { 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), - ), - ], + ), ), ), ], diff --git a/lib/editor/engine/brush.dart b/lib/editor/engine/brush.dart index 4c7ae49..17d8a3c 100644 --- a/lib/editor/engine/brush.dart +++ b/lib/editor/engine/brush.dart @@ -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 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 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 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 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, diff --git a/lib/editor/engine/pen_physics.dart b/lib/editor/engine/pen_physics.dart new file mode 100644 index 0000000..962159f --- /dev/null +++ b/lib/editor/engine/pen_physics.dart @@ -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; + } +} diff --git a/lib/editor/engine/stroke_predictor.dart b/lib/editor/engine/stroke_predictor.dart index a7d84df..71a6b8d 100644 --- a/lib/editor/engine/stroke_predictor.dart +++ b/lib/editor/engine/stroke_predictor.dart @@ -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; diff --git a/lib/editor/input/pen_config.dart b/lib/editor/input/pen_config.dart index bb64b3c..7e1f55b 100644 --- a/lib/editor/input/pen_config.dart +++ b/lib/editor/input/pen_config.dart @@ -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, diff --git a/lib/editor/input/pressure_curve.dart b/lib/editor/input/pressure_curve.dart index 3e18463..a305a5a 100644 --- a/lib/editor/input/pressure_curve.dart +++ b/lib/editor/input/pressure_curve.dart @@ -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; } } diff --git a/lib/editor/ui/pen_settings_page.dart b/lib/editor/ui/pen_settings_page.dart index 51d7586..6e567c7 100644 --- a/lib/editor/ui/pen_settings_page.dart +++ b/lib/editor/ui/pen_settings_page.dart @@ -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( + 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 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 diff --git a/lib/screens/notebook_screen.dart b/lib/screens/notebook_screen.dart index a0b0e92..17f75f2 100644 --- a/lib/screens/notebook_screen.dart +++ b/lib/screens/notebook_screen.dart @@ -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, diff --git a/test/brush_test.dart b/test/brush_test.dart index 1c74732..cb771ee 100644 --- a/test/brush_test.dart +++ b/test/brush_test.dart @@ -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); }); diff --git a/test/pen_physics_test.dart b/test/pen_physics_test.dart new file mode 100644 index 0000000..eb744b4 --- /dev/null +++ b/test/pen_physics_test.dart @@ -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); + }); + }); +} diff --git a/test/pressure_curve_test.dart b/test/pressure_curve_test.dart index 4390b5e..07688a2 100644 --- a/test/pressure_curve_test.dart +++ b/test/pressure_curve_test.dart @@ -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); + }); + }); }