Files
BadNote/lib/editor/canvas/pen_palette_widgets.dart
Akiba So f31dd0fb52
Some checks failed
CI / Windows build (push) Has been cancelled
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 <cursoragent@cursor.com>
2026-08-06 16:01:01 +08:00

426 lines
14 KiB
Dart

// lib/editor/canvas/pen_palette_widgets.dart
//
// Shared Material 3 chrome for the pen-first editors (PDF, note, slide) so the
// floating tool palette looks and behaves identically everywhere — one source
// of truth for the inking UI.
import 'package:flutter/material.dart';
import '../../l10n/app_localizations.dart';
import '../engine/brush.dart';
import 'editor_tool.dart';
/// Shared ink color palette for PDF / note / slide / scratch editors.
const List<Color> kInkPalette = <Color>[
Color(0xFF1A1A1A),
Color(0xFFC62828),
Color(0xFF1565C0),
Color(0xFF2E7D32),
Color(0xFFEF6C00),
Color(0xFF6A1B9A),
Color(0xFF00838F),
Color(0xFF5D4037),
Color(0xFFF9A825),
Color(0xFFE91E63),
Color(0xFF455A64),
Color(0xFF37474F),
];
/// 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',
};
/// Localized display name for a shape kind.
String shapeLabel(ShapeKind kind, AppLocalizations l) => switch (kind) {
ShapeKind.line => l.shapeLine,
ShapeKind.rectangle => l.shapeRectangle,
ShapeKind.ellipse => l.shapeEllipse,
ShapeKind.arrow => l.shapeArrow,
};
/// English fallback shape name (for the note/slide editors which use hardcoded
/// English strings — see [brushLabelEn]).
/// TODO(brush-l10n-noteslide): localize the note/slide toolbars wholesale.
String shapeLabelEn(ShapeKind kind) => switch (kind) {
ShapeKind.line => 'Line',
ShapeKind.rectangle => 'Rectangle',
ShapeKind.ellipse => 'Ellipse',
ShapeKind.arrow => 'Arrow',
};
/// 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
};
/// Material icon for a [ShapeKind] (used in the shape picker + tool button).
IconData shapeIcon(ShapeKind kind) => switch (kind) {
ShapeKind.line => Icons.show_chart, // straight line
ShapeKind.rectangle => Icons.crop_square,
ShapeKind.ellipse => Icons.circle_outlined,
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
/// menu of [kPenToolBrushes]; the chosen brush is reported via [onSelected].
/// [labelFor] localizes each brush name so the menu honors the app locale.
/// [colorFor] returns each brush's REMEMBERED color (rnote-style per-brush color
/// memory): the active brush's color is shown as an underline on the button and
/// as a dot beside each menu item, so the toolbar makes each brush's color
/// visible at a glance.
class BrushPickerButton extends StatelessWidget {
const BrushPickerButton({
super.key,
required this.selected,
required this.active,
required this.onSelected,
required this.labelFor,
required this.colorFor,
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;
/// The remembered color for a brush (drives the underline + menu dots).
final Color Function(BrushKind) colorFor;
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)),
const SizedBox(width: 8),
// The brush's remembered color (rnote per-brush color memory).
Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: colorFor(b),
shape: BoxShape.circle,
border: Border.all(color: cs.outlineVariant),
),
),
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: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(brushIcon(selected), size: 22, color: iconColor),
Icon(Icons.arrow_drop_down, size: 18, color: iconColor),
],
),
// Per-brush color underline: shows the active brush's remembered
// color so switching brushes visibly switches the color.
Container(
height: 3,
width: 24,
decoration: BoxDecoration(
color: colorFor(selected),
borderRadius: BorderRadius.circular(2),
),
),
],
),
),
);
}
}
/// A toggle-style tool button that doubles as a [ShapeKind] picker: a short tap
/// activates the shape tool with the current shape; a long-press (or the dropdown
/// caret) opens the line / rectangle / ellipse / arrow submenu.
class ShapePickerButton extends StatelessWidget {
const ShapePickerButton({
super.key,
required this.selected,
required this.active,
required this.onActivate,
required this.onSelected,
required this.labelFor,
required this.tooltip,
});
/// The currently selected shape kind.
final ShapeKind selected;
/// True when the shape tool is the active tool — drives highlight.
final bool active;
/// Called when the button body is tapped (activate the shape tool).
final VoidCallback onActivate;
/// Called when a shape kind is picked from the submenu (also activates).
final ValueChanged<ShapeKind> onSelected;
/// Localized display name for a shape kind.
final String Function(ShapeKind) labelFor;
final String tooltip;
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final iconColor = active ? cs.onSecondaryContainer : cs.onSurfaceVariant;
return PopupMenuButton<ShapeKind>(
tooltip: tooltip,
initialValue: selected,
onSelected: onSelected,
itemBuilder: (context) => [
for (final s in ShapeKind.values)
PopupMenuItem<ShapeKind>(
value: s,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(shapeIcon(s), size: 20),
const SizedBox(width: 10),
Text(labelFor(s)),
if (s == selected) ...[
const SizedBox(width: 8),
Icon(Icons.check, size: 18, color: cs.primary),
],
],
),
),
],
child: InkWell(
borderRadius: BorderRadius.circular(20),
onTap: onActivate,
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(shapeIcon(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({
super.key,
required this.icon,
required this.selected,
required this.tooltip,
required this.onPressed,
});
final IconData icon;
final bool selected;
final String tooltip;
/// Tap handler. When null the button renders disabled (dimmed, no ripple).
final VoidCallback? onPressed;
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final enabled = onPressed != null;
final iconColor = !enabled
? cs.onSurfaceVariant.withValues(alpha: 0.38)
: selected
? cs.onSecondaryContainer
: cs.onSurfaceVariant;
return Tooltip(
message: tooltip,
child: InkWell(
borderRadius: BorderRadius.circular(20),
onTap: onPressed,
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
margin: const EdgeInsets.symmetric(horizontal: 2),
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: selected ? cs.secondaryContainer : Colors.transparent,
borderRadius: BorderRadius.circular(20),
),
child: Icon(icon, size: 22, color: iconColor),
),
),
);
}
}
/// A thin vertical divider between palette groups.
class PaletteDivider extends StatelessWidget {
const PaletteDivider({super.key, required this.cs});
final ColorScheme cs;
@override
Widget build(BuildContext context) => Container(
width: 1,
height: 24,
margin: const EdgeInsets.symmetric(horizontal: 6),
color: cs.outlineVariant,
);
}
/// A round, tonal icon button (used for the floating back button).
class RoundIconButton extends StatelessWidget {
const RoundIconButton({
super.key,
required this.icon,
required this.tooltip,
required this.onPressed,
});
final IconData icon;
final String tooltip;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Material(
color: cs.surfaceContainerHigh,
elevation: 3,
shape: const CircleBorder(),
child: IconButton(
tooltip: tooltip,
icon: Icon(icon),
color: cs.onSurfaceVariant,
onPressed: onPressed,
),
);
}
}