feat(pen): extensible brush model (4 brushes)
All checks were successful
CI / Windows build (push) Successful in 14m54s

Replace the 2-tool ink system with a data-driven, Krita-style
BrushProfile (lib/editor/engine/brush.dart). Adding a brush is a
const map entry, not render-path branching.

Four presets from the rnote/krita spec:
- fountain pen: quadratic (p^2) pressure, wide dynamic width
- ballpoint:    near-constant width (thinning 0.15)
- highlighter:  flat width, square caps
- pencil:       sqrt(p) pressure, moderate width

Pressure is pre-warped per brush via PressureCurve(gamma) before
perfect_freehand; geometry fields (thinning/streamline/smoothing/
caps) flow through the shared stroke recipe so the PDF overlay and
the note/slide PenCanvas both honor the brush. Brush kind is now
persisted on the stroke model. Picker added to all three toolbars.

Opacity/multiply and pencil grain are carried as data but not yet
composited (TODO brush-opacity / brush-texture); this increment is
width + pressure-curve differentiation. analyze clean, 283 tests.
This commit is contained in:
2026-06-24 11:13:43 +08:00
parent 45a8931b64
commit 0feca74278
21 changed files with 805 additions and 66 deletions

View File

@@ -8,8 +8,9 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:perfect_freehand/perfect_freehand.dart' as pf;
import '../engine/brush.dart';
import '../engine/stroke_geometry.dart'
show kDefaultPenThinning, kPenSmoothing, kPenStreamline;
show freehandOutlinePoints, kDefaultPenThinning;
import 'pen_stroke.dart';
/// Builds a filled outline [Path] for one stroke (already scaled to pixels).
@@ -39,22 +40,19 @@ Path buildStrokePath(
)
.toList();
final outline = pf.getStroke(
pfPoints,
options: pf.StrokeOptions(
size: pixelWidth,
// Highlighter keeps a constant width (no thinning); pen uses the
// configurable [thinning] so real Surface-Pen pressure changes width.
thinning: isHighlighter ? 0.0 : thinning,
smoothing: kPenSmoothing,
streamline: kPenStreamline,
// Real stylus pressure → don't simulate; no pressure → let freehand fake
// it based on velocity. (perfect_freehand 2.x honors REAL pressure when
// simulatePressure is false — 1.0.4 ignored it, which made width
// unresponsive to pen force.)
simulatePressure: !hasRealPressure && !isHighlighter,
isComplete: isComplete,
),
// Route through THE shared recipe (stroke_geometry.freehandOutlinePoints) so
// this PDF-overlay path and the note/slide path can never diverge (R7), and
// resolve the stroke's brush so each brush renders with its own
// thinning/streamline/smoothing/caps (spec §4). Pressure was already
// pre-warped by the brush's gamma at capture, so it is baked into pfPoints.
final outline = freehandOutlinePoints(
pfPoints: pfPoints,
size: pixelWidth,
isHighlighter: isHighlighter,
hasRealPressure: hasRealPressure,
isComplete: isComplete,
thinning: thinning,
brush: brushProfileFor(stroke.brush),
);
final path = Path();

View File

@@ -21,6 +21,7 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import '../engine/brush.dart';
import '../engine/stroke_eraser.dart';
import '../engine/stroke_geometry.dart' show kDefaultPenThinning;
import '../engine/stroke_model.dart';
@@ -47,6 +48,7 @@ class PenCanvas extends StatefulWidget {
required this.strokes,
required this.transformationController,
required this.tool,
this.brush = BrushKind.fountainPen,
required this.color,
required this.strokeWidth,
required this.onStrokeComplete,
@@ -84,6 +86,13 @@ class PenCanvas extends StatefulWidget {
final TransformationController transformationController;
final CanvasTool tool;
/// The brush selected for the PEN tool (fountain/ballpoint/pencil). The
/// highlighter tool always renders with [BrushKind.highlighter] regardless of
/// this value; the eraser draws nothing. Drives both the capture-time pressure
/// pre-warp ([BrushProfile.pressureGamma]) and the render geometry.
final BrushKind brush;
final Color color;
/// Pen width as a fraction of page width (so it zooms with the page).
@@ -111,6 +120,12 @@ class PenCanvas extends StatefulWidget {
/// Pressure-response exponent applied to raw stylus pressure BEFORE it reaches
/// perfect_freehand. <1 boosts light touches (responsive, rnote-like); 1 is
/// raw linear (the old "pressure-finger" feel). From `PenConfig.pressureGamma`.
///
/// TODO(brush-pressure-knob): superseded by the per-brush
/// [BrushProfile.pressureGamma] (fountain p², pencil √p) which now drives the
/// capture-time warp. This config knob is retained for the API + future
/// reconciliation (e.g. a user multiplier on top of the brush curve) but is no
/// longer read by [_normalizedPressure].
final double pressureGamma;
/// Minimum shaped pressure, so a light stroke still has body instead of
@@ -204,6 +219,16 @@ class _PenCanvasState extends State<PenCanvas> {
bool _isStylus(PointerDeviceKind kind) => arbiter.isStylusKind(kind);
/// The brush in effect for the current tool: highlighter tool ⇒ highlighter
/// brush, otherwise the selected pen brush. (Eraser draws nothing, so its
/// brush is irrelevant.)
BrushKind get _currentBrush => widget.tool == CanvasTool.highlighter
? BrushKind.highlighter
: widget.brush;
/// The brush profile in effect, for the capture-time pressure pre-warp.
BrushProfile get _currentBrushProfile => brushProfileFor(_currentBrush);
/// Normalize stylus pressure to [0,1], or null when the device reports no
/// usable pressure range (then perfect_freehand simulates pressure).
///
@@ -215,8 +240,16 @@ class _PenCanvasState extends State<PenCanvas> {
if (!_isStylus(event.kind)) return null;
final double? raw = _rawNormalizedPressure(event);
if (raw == null) return null;
return PressureCurve(floor: widget.pressureFloor, gamma: widget.pressureGamma)
.apply(raw);
// Pre-warp pressure with the BRUSH's gamma (rnote PressureCurve: fountain
// = Pow2/p², pencil = Sqrt/√p, ballpoint/highlighter = Linear), reusing the
// existing PressureCurve. Baking the warp in at capture means the live
// stroke and the export replay identical pressures (no divergence). The
// brush gamma supersedes the legacy per-config `pressureGamma` knob — see
// TODO(brush-pressure-knob) on `widget.pressureGamma`.
return PressureCurve(
floor: widget.pressureFloor,
gamma: _currentBrushProfile.pressureGamma,
).apply(raw);
}
/// Raw [0,1] stylus force before response shaping (see [_normalizedPressure]).
@@ -378,6 +411,7 @@ class _PenCanvasState extends State<PenCanvas> {
color: _currentColor().toARGB32(),
width: widget.strokeWidth,
kind: _currentKind(),
brush: _currentBrush,
),
);
}
@@ -402,6 +436,7 @@ class _PenCanvasState extends State<PenCanvas> {
color: _currentColor().toARGB32(),
width: widget.strokeWidth,
kind: _currentKind(),
brush: _currentBrush,
);
});
}

View File

@@ -26,6 +26,7 @@ import 'package:pdfrx/pdfrx.dart';
import '../../l10n/app_localizations.dart';
import '../../services/database_service.dart';
import '../engine/brush.dart';
import '../engine/stroke_eraser.dart';
import '../engine/stroke_geometry.dart' show kDefaultPenThinning;
import '../engine/stroke_model.dart';
@@ -35,7 +36,7 @@ import '../input/diagnostic_logger.dart';
import '../input/pen_config.dart';
import '../input/pen_input_service.dart';
import '../input/pressure_curve.dart'
show PressureCurve, kNaturalPressureGamma, kNaturalPressureFloor;
show PressureCurve, kNaturalPressureFloor;
import '../pdf/pen_capture_region.dart';
import '../persistence/editor_repository.dart';
import '../persistence/save_scheduler.dart';
@@ -146,6 +147,11 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
// Tool state.
CanvasTool _tool = CanvasTool.pen;
/// Selected brush for the PEN tool (fountain/ballpoint/pencil). The
/// highlighter tool always uses [BrushKind.highlighter]. Local state only for
/// this increment (not persisted — TODO(brush-persist-selection)).
BrushKind _penBrush = BrushKind.fountainPen;
/// When true the "select text" tool is active: pen capture is disabled so the
/// pen falls through to pdfrx for native text selection.
bool _selectTextMode = false;
@@ -235,6 +241,13 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
kind: es.tool == EditorTool.highlighter
? PenStrokeKind.highlighter
: PenStrokeKind.pen,
// Brush isn't persisted yet (TODO(brush-persist)); derive it
// from the tool so a loaded highlighter still renders with the
// highlighter brush (flat width), and pens fall back to the
// fountainPen default.
brush: es.tool == EditorTool.highlighter
? BrushKind.highlighter
: BrushKind.fountainPen,
))
.toList();
}
@@ -358,9 +371,11 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
final raw = _rawNormalizedPressure(event);
if (raw == null) return null;
// PenConfig exposes gamma but not floor; use the shared natural floor (the
// bitmap editor did the same — it never sourced floor from config).
// bitmap editor did the same — it never sourced floor from config). The
// gamma is the BRUSH's pressure warp (fountain p² / pencil √p / linear),
// superseding the legacy config gamma — see TODO(brush-pressure-knob).
const floor = kNaturalPressureFloor;
final gamma = _penConfig?.value.pressureGamma ?? kNaturalPressureGamma;
final gamma = brushProfileFor(_currentBrush()).pressureGamma;
return PressureCurve(floor: floor, gamma: gamma).apply(raw);
}
@@ -440,6 +455,7 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
color: _currentColor().toARGB32(),
width: _currentStrokeWidth(),
kind: _currentKind(),
brush: _currentBrush(),
);
_bumpOverlay();
}
@@ -456,6 +472,7 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
color: _currentColor().toARGB32(),
width: _currentStrokeWidth(),
kind: _currentKind(),
brush: _currentBrush(),
),
);
}
@@ -510,6 +527,12 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
? PenStrokeKind.highlighter
: PenStrokeKind.pen;
/// Brush in effect: highlighter tool ⇒ highlighter brush, else the selected
/// pen brush. Drives both the capture-time pressure warp and render geometry.
BrushKind _currentBrush() => _tool == CanvasTool.highlighter
? BrushKind.highlighter
: _penBrush;
Color _currentColor() => _tool == CanvasTool.highlighter
? _color.withAlpha(0x80)
: _color;
@@ -754,11 +777,15 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
ToolButton(
icon: Icons.edit_outlined,
selected: _tool == CanvasTool.pen && !_selectTextMode,
tooltip: l.toolPen,
onPressed: () => _setTool(CanvasTool.pen),
BrushPickerButton(
selected: _penBrush,
active: _tool == CanvasTool.pen && !_selectTextMode,
tooltip: l.brushPicker,
labelFor: (b) => brushLabel(b, l),
onSelected: (b) {
setState(() => _penBrush = b);
_setTool(CanvasTool.pen);
},
),
ToolButton(
icon: Icons.brush_outlined,

View File

@@ -13,6 +13,7 @@ import '../../models/ink_stroke.dart';
import '../../models/note.dart';
import '../../providers/note_provider.dart';
import '../../providers/ocr_provider.dart';
import '../engine/brush.dart';
import '../input/pen_config.dart';
import '../input/pen_input_service.dart';
import '../input/pressure_curve.dart' show kNaturalPressureGamma;
@@ -45,6 +46,12 @@ class _PenNoteScreenState extends ConsumerState<PenNoteScreen> {
final List<List<PenStroke>> _redo = [];
CanvasTool _tool = CanvasTool.pen;
/// Selected brush for the PEN tool (fountain/ballpoint/pencil). The
/// highlighter tool always uses [BrushKind.highlighter]; local state only for
/// this increment (not persisted — see TODO(brush-persist-selection)).
BrushKind _penBrush = BrushKind.fountainPen;
Color _color = Colors.black;
bool _allowFingerDrawing = false;
bool _dirty = false;
@@ -318,6 +325,7 @@ class _PenNoteScreenState extends ConsumerState<PenNoteScreen> {
strokes: _strokes,
transformationController: _transform,
tool: _tool,
brush: _penBrush,
color: _color,
strokeWidth: _strokeWidth,
pressureGamma:
@@ -359,11 +367,16 @@ class _PenNoteScreenState extends ConsumerState<PenNoteScreen> {
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
ToolButton(
icon: Icons.edit_outlined,
selected: _tool == CanvasTool.pen,
tooltip: 'Pen',
onPressed: () => setState(() => _tool = CanvasTool.pen),
// Pen tool with brush picker (fountain / ballpoint / pencil).
BrushPickerButton(
selected: _penBrush,
active: _tool == CanvasTool.pen,
tooltip: 'Brush',
labelFor: brushLabelEn,
onSelected: (b) => setState(() {
_penBrush = b;
_tool = CanvasTool.pen;
}),
),
ToolButton(
icon: Icons.brush_outlined,

View File

@@ -6,6 +6,119 @@
import 'package:flutter/material.dart';
import '../../l10n/app_localizations.dart';
import '../engine/brush.dart';
/// Localized display name for a brush (single source so all three editors agree).
String brushLabel(BrushKind kind, AppLocalizations l) => switch (kind) {
BrushKind.fountainPen => l.brushFountainPen,
BrushKind.ballpoint => l.brushBallpoint,
BrushKind.pencil => l.brushPencil,
BrushKind.highlighter => l.brushHighlighter,
};
/// English fallback brush name, for the note/slide editors which (like their
/// other chrome) use hardcoded English strings rather than [AppLocalizations]
/// (their test harness mounts a MaterialApp without localization delegates).
/// TODO(brush-l10n-noteslide): localize the note/slide toolbars wholesale.
String brushLabelEn(BrushKind kind) => switch (kind) {
BrushKind.fountainPen => 'Fountain pen',
BrushKind.ballpoint => 'Ballpoint',
BrushKind.pencil => 'Pencil',
BrushKind.highlighter => 'Highlighter',
};
/// The brushes selectable as the PEN tool. The highlighter is its own tool, so
/// it is NOT offered here (eraser is also a separate tool).
const List<BrushKind> kPenToolBrushes = [
BrushKind.fountainPen,
BrushKind.ballpoint,
BrushKind.pencil,
];
/// Material icon for a brush (used in the brush picker + the pen tool button).
IconData brushIcon(BrushKind kind) => switch (kind) {
BrushKind.fountainPen => Icons.edit_outlined, // nib pen
BrushKind.ballpoint => Icons.create_outlined, // ballpoint
BrushKind.pencil => Icons.draw_outlined, // pencil
BrushKind.highlighter => Icons.brush_outlined, // marker
};
/// A dropdown that selects the active PEN brush (fountain / ballpoint / pencil).
///
/// Highlighter and eraser remain separate tools. Tapping the button opens a
/// menu of [kPenToolBrushes]; the chosen brush is reported via [onSelected].
/// [labelFor] localizes each brush name so the menu honors the app locale.
class BrushPickerButton extends StatelessWidget {
const BrushPickerButton({
super.key,
required this.selected,
required this.active,
required this.onSelected,
required this.labelFor,
required this.tooltip,
});
/// The currently selected pen brush.
final BrushKind selected;
/// True when the pen tool (this brush) is the active tool — drives highlight.
final bool active;
final ValueChanged<BrushKind> onSelected;
/// Localized display name for a brush.
final String Function(BrushKind) labelFor;
final String tooltip;
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final iconColor =
active ? cs.onSecondaryContainer : cs.onSurfaceVariant;
return PopupMenuButton<BrushKind>(
tooltip: tooltip,
initialValue: selected,
onSelected: onSelected,
itemBuilder: (context) => [
for (final b in kPenToolBrushes)
PopupMenuItem<BrushKind>(
value: b,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(brushIcon(b), size: 20),
const SizedBox(width: 10),
Text(labelFor(b)),
if (b == selected) ...[
const SizedBox(width: 8),
Icon(Icons.check, size: 18, color: cs.primary),
],
],
),
),
],
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
margin: const EdgeInsets.symmetric(horizontal: 2),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 8),
decoration: BoxDecoration(
color: active ? cs.secondaryContainer : Colors.transparent,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(brushIcon(selected), size: 22, color: iconColor),
Icon(Icons.arrow_drop_down, size: 18, color: iconColor),
],
),
),
);
}
}
/// A Material 3 toggle-style icon button for the floating tool palette.
class ToolButton extends StatelessWidget {
const ToolButton({

View File

@@ -15,6 +15,7 @@ import 'package:flutter/material.dart';
import 'package:path/path.dart' as p;
import 'package:syncfusion_flutter_pdf/pdf.dart';
import '../engine/brush.dart';
import '../input/pen_config.dart';
import '../input/pen_input_service.dart';
import '../input/pressure_curve.dart' show kNaturalPressureGamma;
@@ -52,6 +53,11 @@ class _PenSlideScreenState extends State<PenSlideScreen> {
Map<int, Size>? _slideSizes;
CanvasTool _tool = CanvasTool.pen;
/// Selected brush for the PEN tool. Highlighter tool uses the highlighter
/// brush; local state only (not persisted — TODO(brush-persist-selection)).
BrushKind _penBrush = BrushKind.fountainPen;
Color _color = Colors.black;
bool _allowFingerDrawing = false;
bool _needsCenter = true;
@@ -363,6 +369,7 @@ class _PenSlideScreenState extends State<PenSlideScreen> {
strokes: _currentStrokes,
transformationController: _transform,
tool: _tool,
brush: _penBrush,
color: _color,
strokeWidth: _strokeWidth,
pressureGamma:
@@ -396,11 +403,15 @@ class _PenSlideScreenState extends State<PenSlideScreen> {
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
ToolButton(
icon: Icons.edit_outlined,
selected: _tool == CanvasTool.pen,
tooltip: 'Pen',
onPressed: () => setState(() => _tool = CanvasTool.pen),
BrushPickerButton(
selected: _penBrush,
active: _tool == CanvasTool.pen,
tooltip: 'Brush',
labelFor: brushLabelEn,
onSelected: (b) => setState(() {
_penBrush = b;
_tool = CanvasTool.pen;
}),
),
ToolButton(
icon: Icons.brush_outlined,

View File

@@ -6,6 +6,8 @@
import 'package:flutter/foundation.dart';
import '../engine/brush.dart';
/// A single captured sample of a stroke.
///
/// [x]/[y] are normalized to the page rectangle ([0,1]).
@@ -35,6 +37,7 @@ class PenStroke {
required this.color,
required this.width,
required this.kind,
this.brush = BrushKind.fountainPen,
});
/// Normalized points (see [PenPoint]).
@@ -48,4 +51,12 @@ class PenStroke {
final double width;
final PenStrokeKind kind;
/// The brush this stroke was drawn with — drives the perfect_freehand
/// geometry (thinning/streamline/smoothing/caps) at render time via
/// [brushProfileFor]. The pressure pre-warp ([BrushProfile.pressureGamma]) is
/// applied at CAPTURE so it is already baked into [points]. Defaults to
/// [BrushKind.fountainPen] (the legacy pen visual) so old/loaded strokes keep
/// rendering as before.
final BrushKind brush;
}