feat(tools): rnote-style toolbar core writing batch
Some checks failed
CI / Windows build (push) Has been cancelled
Some checks failed
CI / Windows build (push) Has been cancelled
Replace the ad-hoc tool palette with a shared tool system (EditorToolKind) across the PDF, note and slide editors, and add the core writing tools. - Multiple brushes, each remembering its OWN color (rnote-style): selecting a brush restores its color, changing color updates only that brush, and each brush button shows its current color. - Select tool: tap-select a committed stroke, drag to move it, delete it — persisted and undoable. - Shape tool: line / rectangle / ellipse / arrow, drawn with a live preview and committed as generated PenStrokes (shape_geometry.dart) so they reuse stroke rendering, erase, persistence and undo. - Highlighter + eraser fold into the same tool system. Text/bookmark/search+OCR/backgrounds/Windows-Ink are later batches (TODO). Brush opacity still deferred. analyze clean, 302 tests.
This commit is contained in:
80
test/pen_brush_color_memory_test.dart
Normal file
80
test/pen_brush_color_memory_test.dart
Normal file
@@ -0,0 +1,80 @@
|
||||
// test/pen_brush_color_memory_test.dart
|
||||
//
|
||||
// Pins rnote-style per-brush color memory in the note editor: each brush
|
||||
// remembers its OWN color, selecting a brush restores that brush's color (the
|
||||
// PenCanvas receives it), and picking a color updates ONLY the active brush's
|
||||
// entry — switching back to a different brush restores the other color.
|
||||
//
|
||||
// Drives the real PenNoteScreen toolbar (BrushPickerButton popup + color dots)
|
||||
// and reads PenCanvas.color to assert the active drawing color.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'package:badnote/editor/canvas/pen_canvas.dart';
|
||||
import 'package:badnote/editor/canvas/pen_note_screen.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
Color canvasColor(WidgetTester tester) =>
|
||||
tester.widget<PenCanvas>(find.byType(PenCanvas)).color;
|
||||
|
||||
/// Pick the brush named [label] from the BrushPickerButton popup menu.
|
||||
Future<void> selectBrush(WidgetTester tester, String label) async {
|
||||
// The brush picker carries the 'Brush' tooltip.
|
||||
await tester.tap(find.byTooltip('Brush'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text(label).last);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
/// Tap the toolbar color dot whose swatch is exactly [c]. The dot is an
|
||||
/// AnimatedContainer (the swatch) inside a GestureDetector; tap the gesture
|
||||
/// detector ancestor so the onTap fires.
|
||||
Future<void> tapColorDot(WidgetTester tester, Color c) async {
|
||||
final swatch = find.byWidgetPredicate((w) =>
|
||||
w is AnimatedContainer &&
|
||||
w.decoration is BoxDecoration &&
|
||||
(w.decoration as BoxDecoration).color == c &&
|
||||
(w.decoration as BoxDecoration).shape == BoxShape.circle);
|
||||
final gd = find.ancestor(of: swatch, matching: find.byType(GestureDetector));
|
||||
await tester.tap(gd.first);
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
testWidgets('each brush remembers its own color; switching restores it',
|
||||
(tester) async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
await tester.pumpWidget(const ProviderScope(
|
||||
child: MaterialApp(home: PenNoteScreen()),
|
||||
));
|
||||
await tester.pump(); // let PenConfig load
|
||||
|
||||
// Defaults from _brushColors: fountain pen = black, ballpoint = blue.
|
||||
expect(canvasColor(tester), Colors.black,
|
||||
reason: 'fountain pen starts black');
|
||||
|
||||
// Switch to the ballpoint brush → its remembered color (blue) becomes active.
|
||||
await selectBrush(tester, 'Ballpoint');
|
||||
expect(canvasColor(tester), Colors.blue,
|
||||
reason: 'selecting ballpoint restores ITS remembered color');
|
||||
|
||||
// Change the ACTIVE (ballpoint) brush's color to red via a color dot.
|
||||
await tapColorDot(tester, Colors.red);
|
||||
expect(canvasColor(tester), Colors.red,
|
||||
reason: 'color change applies to the active brush');
|
||||
|
||||
// Switch back to the fountain pen → its color is still black (unchanged).
|
||||
await selectBrush(tester, 'Fountain pen');
|
||||
expect(canvasColor(tester), Colors.black,
|
||||
reason: 'fountain pen color was NOT affected by changing ballpoint');
|
||||
|
||||
// Back to ballpoint → it remembers the red we set.
|
||||
await selectBrush(tester, 'Ballpoint');
|
||||
expect(canvasColor(tester), Colors.red,
|
||||
reason: 'ballpoint remembers its own updated color');
|
||||
});
|
||||
}
|
||||
131
test/pen_select_move_test.dart
Normal file
131
test/pen_select_move_test.dart
Normal file
@@ -0,0 +1,131 @@
|
||||
// test/pen_select_move_test.dart
|
||||
//
|
||||
// Pins the SELECT tool's select + move + delete on the PenCanvas editors (note):
|
||||
// * tapping a committed stroke selects it (onSelectStroke fires with its index);
|
||||
// * dragging the selection translates the stroke's normalized points (the
|
||||
// committed stroke list is replaced with shifted points);
|
||||
// * the move is one undoable step (undo restores the original points).
|
||||
//
|
||||
// Drives the real PenNoteScreen with a pre-loaded stroke and a stylus gesture.
|
||||
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'package:badnote/editor/canvas/pen_canvas.dart';
|
||||
import 'package:badnote/editor/canvas/pen_note_screen.dart';
|
||||
import 'package:badnote/editor/canvas/pen_stroke.dart';
|
||||
import 'package:badnote/editor/engine/shape_geometry.dart';
|
||||
import 'package:badnote/models/ink_point.dart';
|
||||
import 'package:badnote/models/ink_stroke.dart';
|
||||
import 'package:badnote/models/note.dart';
|
||||
import 'package:badnote/models/pen_tool.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// A note with a single stroke crossing the page center so a center tap hits it.
|
||||
Note noteWithStroke() => Note(
|
||||
id: 'n1',
|
||||
title: 'Test',
|
||||
strokes: [
|
||||
InkStroke(
|
||||
id: 's1',
|
||||
// Through the page center (kNoteLogicalPage = 1000 x 1414) so a
|
||||
// center tap on the canvas hits the stroke.
|
||||
points: const [
|
||||
InkPoint(x: 300, y: 707, timestamp: 0),
|
||||
InkPoint(x: 500, y: 707, timestamp: 0),
|
||||
InkPoint(x: 700, y: 707, timestamp: 0),
|
||||
],
|
||||
tool: PenTool.pen,
|
||||
createdAt: DateTime.fromMillisecondsSinceEpoch(0),
|
||||
),
|
||||
],
|
||||
createdAt: DateTime.fromMillisecondsSinceEpoch(0),
|
||||
updatedAt: DateTime.fromMillisecondsSinceEpoch(0),
|
||||
);
|
||||
|
||||
PenCanvas canvas(WidgetTester tester) =>
|
||||
tester.widget<PenCanvas>(find.byType(PenCanvas));
|
||||
|
||||
List<PenStroke> strokes(WidgetTester tester) => canvas(tester).strokes;
|
||||
|
||||
/// Activate the SELECT tool via its toolbar button (the "Select" tooltip).
|
||||
Future<void> activateSelect(WidgetTester tester) async {
|
||||
await tester.tap(find.byTooltip('Select'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('tapping a stroke selects it, dragging moves it, undo restores',
|
||||
(tester) async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
await tester.pumpWidget(ProviderScope(
|
||||
child: MaterialApp(home: PenNoteScreen(note: noteWithStroke())),
|
||||
));
|
||||
await tester.pump(); // PenConfig load
|
||||
|
||||
expect(strokes(tester), hasLength(1));
|
||||
final before = strokes(tester).single;
|
||||
final beforeBounds = penStrokeBounds(before)!;
|
||||
|
||||
await activateSelect(tester);
|
||||
expect(canvas(tester).tool, CanvasTool.select);
|
||||
|
||||
// Tap the stroke (page center) to select, then drag it down-right.
|
||||
final center = tester.getCenter(find.byType(PenCanvas));
|
||||
final g = await tester.startGesture(center, kind: PointerDeviceKind.stylus);
|
||||
await tester.pump();
|
||||
// selection should now be set.
|
||||
expect(canvas(tester).selectedStrokeIndex, 0);
|
||||
|
||||
await g.moveBy(const Offset(40, 30));
|
||||
await g.moveBy(const Offset(20, 10));
|
||||
await g.up();
|
||||
await tester.pump();
|
||||
|
||||
final after = strokes(tester).single;
|
||||
final afterBounds = penStrokeBounds(after)!;
|
||||
// The stroke translated: its bounds shifted right and down.
|
||||
expect(afterBounds.left, greaterThan(beforeBounds.left),
|
||||
reason: 'stroke moved right');
|
||||
expect(afterBounds.top, greaterThan(beforeBounds.top),
|
||||
reason: 'stroke moved down');
|
||||
// Same number of points (a translate, not a redraw).
|
||||
expect(after.points.length, before.points.length);
|
||||
|
||||
// Undo restores the original position (one undoable step).
|
||||
await tester.tap(find.byTooltip('Undo'));
|
||||
await tester.pump();
|
||||
final undoneBounds = penStrokeBounds(strokes(tester).single)!;
|
||||
expect(undoneBounds.left, closeTo(beforeBounds.left, 1e-6));
|
||||
expect(undoneBounds.top, closeTo(beforeBounds.top, 1e-6));
|
||||
});
|
||||
|
||||
testWidgets('delete-selection removes the selected stroke (undoable)',
|
||||
(tester) async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
await tester.pumpWidget(ProviderScope(
|
||||
child: MaterialApp(home: PenNoteScreen(note: noteWithStroke())),
|
||||
));
|
||||
await tester.pump();
|
||||
|
||||
await activateSelect(tester);
|
||||
final center = tester.getCenter(find.byType(PenCanvas));
|
||||
final g = await tester.startGesture(center, kind: PointerDeviceKind.stylus);
|
||||
await g.up();
|
||||
await tester.pump();
|
||||
expect(canvas(tester).selectedStrokeIndex, 0);
|
||||
|
||||
// The delete button appears only with a live selection.
|
||||
await tester.tap(find.byTooltip('Delete selection'));
|
||||
await tester.pump();
|
||||
expect(strokes(tester), isEmpty);
|
||||
|
||||
await tester.tap(find.byTooltip('Undo'));
|
||||
await tester.pump();
|
||||
expect(strokes(tester), hasLength(1));
|
||||
});
|
||||
}
|
||||
132
test/shape_geometry_test.dart
Normal file
132
test/shape_geometry_test.dart
Normal file
@@ -0,0 +1,132 @@
|
||||
// test/shape_geometry_test.dart
|
||||
//
|
||||
// Pins the SHAPE-tool geometry (lib/editor/engine/shape_geometry.dart): each
|
||||
// shape is generated as a normalized PenPoint polyline with the documented point
|
||||
// counts (line→2, rect→5 closed, ellipse→kEllipseSamples+1), the shapes span the
|
||||
// requested start→end box, and translateStroke/translatePoints shift every point
|
||||
// without disturbing color/width/kind/brush (the SELECT-tool move primitive).
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:badnote/editor/canvas/editor_tool.dart';
|
||||
import 'package:badnote/editor/canvas/pen_stroke.dart';
|
||||
import 'package:badnote/editor/engine/brush.dart';
|
||||
import 'package:badnote/editor/engine/shape_geometry.dart';
|
||||
|
||||
void main() {
|
||||
const a = PenPoint(0.2, 0.3, 1.0);
|
||||
const b = PenPoint(0.8, 0.7, 1.0);
|
||||
|
||||
group('point counts', () {
|
||||
test('line → exactly 2 points (start, end)', () {
|
||||
final pts = generateShapePoints(ShapeKind.line, a, b);
|
||||
expect(pts, hasLength(2));
|
||||
expect(pts.first.x, closeTo(0.2, 1e-9));
|
||||
expect(pts.first.y, closeTo(0.3, 1e-9));
|
||||
expect(pts.last.x, closeTo(0.8, 1e-9));
|
||||
expect(pts.last.y, closeTo(0.7, 1e-9));
|
||||
});
|
||||
|
||||
test('rectangle → 5 points, closed (last == first)', () {
|
||||
final pts = generateShapePoints(ShapeKind.rectangle, a, b);
|
||||
expect(pts, hasLength(5));
|
||||
expect(pts.first.x, closeTo(pts.last.x, 1e-9));
|
||||
expect(pts.first.y, closeTo(pts.last.y, 1e-9));
|
||||
// Axis-aligned box corners spanning the start/end bounds.
|
||||
final xs = pts.map((p) => p.x).toSet();
|
||||
final ys = pts.map((p) => p.y).toSet();
|
||||
expect(xs, containsAll(<double>{0.2, 0.8}));
|
||||
expect(ys, containsAll(<double>{0.3, 0.7}));
|
||||
});
|
||||
|
||||
test('ellipse → kEllipseSamples + 1 points, closed', () {
|
||||
final pts = generateShapePoints(ShapeKind.ellipse, a, b);
|
||||
expect(pts, hasLength(kEllipseSamples + 1));
|
||||
expect(pts.first.x, closeTo(pts.last.x, 1e-9));
|
||||
expect(pts.first.y, closeTo(pts.last.y, 1e-9));
|
||||
// ~48 samples by spec.
|
||||
expect(kEllipseSamples, 48);
|
||||
});
|
||||
|
||||
test('arrow → shaft + 2 head barbs (6 points)', () {
|
||||
final pts = generateShapePoints(ShapeKind.arrow, a, b);
|
||||
// start, end, barb1, back-to-tip, barb2, back-to-tip = 6.
|
||||
expect(pts, hasLength(6));
|
||||
expect(pts[0].x, closeTo(0.2, 1e-9));
|
||||
expect(pts[1].x, closeTo(0.8, 1e-9));
|
||||
});
|
||||
|
||||
test('degenerate arrow (zero length) → just the 2 shaft points', () {
|
||||
final pts = generateShapePoints(ShapeKind.arrow, a, a);
|
||||
expect(pts, hasLength(2));
|
||||
});
|
||||
});
|
||||
|
||||
group('ellipse spans the start→end box', () {
|
||||
test('points stay within the bounding box (inclusive)', () {
|
||||
final pts = generateShapePoints(ShapeKind.ellipse, a, b);
|
||||
for (final p in pts) {
|
||||
expect(p.x, greaterThanOrEqualTo(0.2 - 1e-9));
|
||||
expect(p.x, lessThanOrEqualTo(0.8 + 1e-9));
|
||||
expect(p.y, greaterThanOrEqualTo(0.3 - 1e-9));
|
||||
expect(p.y, lessThanOrEqualTo(0.7 + 1e-9));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
group('translate (SELECT-tool move primitive)', () {
|
||||
test('translatePoints shifts every point, preserves pressure', () {
|
||||
const pts = [PenPoint(0.1, 0.2, 0.5), PenPoint(0.3, 0.4, null)];
|
||||
final moved = translatePoints(pts, 0.05, -0.1);
|
||||
expect(moved[0].x, closeTo(0.15, 1e-9));
|
||||
expect(moved[0].y, closeTo(0.1, 1e-9));
|
||||
expect(moved[0].pressure, 0.5);
|
||||
expect(moved[1].x, closeTo(0.35, 1e-9));
|
||||
expect(moved[1].pressure, isNull);
|
||||
});
|
||||
|
||||
test('translateStroke shifts points and preserves metadata', () {
|
||||
const stroke = PenStroke(
|
||||
points: [PenPoint(0.1, 0.1, 1.0), PenPoint(0.2, 0.2, 1.0)],
|
||||
color: 0xFF112233,
|
||||
width: 0.01,
|
||||
kind: PenStrokeKind.highlighter,
|
||||
brush: BrushKind.pencil,
|
||||
);
|
||||
final moved = translateStroke(stroke, 0.1, 0.2);
|
||||
expect(moved.points[0].x, closeTo(0.2, 1e-9));
|
||||
expect(moved.points[0].y, closeTo(0.3, 1e-9));
|
||||
expect(moved.points[1].x, closeTo(0.3, 1e-9));
|
||||
expect(moved.color, 0xFF112233);
|
||||
expect(moved.width, 0.01);
|
||||
expect(moved.kind, PenStrokeKind.highlighter);
|
||||
expect(moved.brush, BrushKind.pencil);
|
||||
});
|
||||
});
|
||||
|
||||
group('penStrokeBounds', () {
|
||||
test('tight bounds over the points', () {
|
||||
const stroke = PenStroke(
|
||||
points: [PenPoint(0.2, 0.5, 1.0), PenPoint(0.8, 0.1, 1.0)],
|
||||
color: 0xFF000000,
|
||||
width: 0.01,
|
||||
kind: PenStrokeKind.pen,
|
||||
);
|
||||
final bnds = penStrokeBounds(stroke)!;
|
||||
expect(bnds.left, closeTo(0.2, 1e-9));
|
||||
expect(bnds.right, closeTo(0.8, 1e-9));
|
||||
expect(bnds.top, closeTo(0.1, 1e-9));
|
||||
expect(bnds.bottom, closeTo(0.5, 1e-9));
|
||||
});
|
||||
|
||||
test('empty stroke → null bounds', () {
|
||||
const stroke = PenStroke(
|
||||
points: [],
|
||||
color: 0xFF000000,
|
||||
width: 0.01,
|
||||
kind: PenStrokeKind.pen,
|
||||
);
|
||||
expect(penStrokeBounds(stroke), isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user