All checks were successful
CI / Windows build (push) Successful in 8m19s
Wire Win32 pressure into Dart, tighten pinch guards, use geometric shape strokes, expand the ink palette, and replace scratch-link split view with an on-page sticky that shares the sidecar repo. Co-authored-by: Cursor <cursoragent@cursor.com>
368 lines
12 KiB
Dart
368 lines
12 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,
|
|
};
|
|
|
|
/// 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,
|
|
),
|
|
);
|
|
}
|
|
}
|