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

@@ -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({