fix: restore PDF pen capture and overhaul sticky/pens/pages
All checks were successful
CI / Windows build (push) Successful in 9m55s

Reinstall PenCaptureBinding so stylus ink hits again; keep finger Listener translucent under pinch; page-anchor sticky with drag/resize; OneNote pen slots (brush+width+color); blank-note multi-page; default side button to hold-select-text.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-07 02:38:58 +08:00
parent ad9b1b46db
commit 307161f465
16 changed files with 1279 additions and 487 deletions

View File

@@ -1,11 +1,11 @@
// 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.
// Pins OneNote-style independent pen slots in the note editor: each slot
// remembers its OWN brush + color + width; selecting a slot restores that
// slot's color (the PenCanvas receives it), and picking a color updates ONLY
// the active slot — switching back to a different slot restores the other color.
//
// Drives the real PenNoteScreen toolbar (BrushPickerButton popup + color dots)
// Drives the real PenNoteScreen toolbar (PenSlotButton + color dots)
// and reads PenCanvas.color to assert the active drawing color.
import 'package:flutter/material.dart';
@@ -15,6 +15,8 @@ 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_palette_widgets.dart';
import 'package:badnote/editor/engine/brush.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
@@ -22,18 +24,17 @@ void main() {
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);
/// Activate the [PenSlotButton] for [kind] via its onPressed (avoids hit-test
/// collisions with the floating back button over the left of the palette).
Future<void> selectSlot(WidgetTester tester, BrushKind kind) async {
final finder = find.byWidgetPredicate(
(w) => w is PenSlotButton && w.kind == kind,
);
tester.widget<PenSlotButton>(finder).onPressed();
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.
/// Tap the toolbar color dot whose swatch is exactly [c].
Future<void> tapColorDot(WidgetTester tester, Color c) async {
final swatch = find.byWidgetPredicate((w) =>
w is AnimatedContainer &&
@@ -41,40 +42,49 @@ void main() {
(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.ensureVisible(gd.first);
await tester.pumpAndSettle();
await tester.tap(gd.first, warnIfMissed: false);
await tester.pump();
}
testWidgets('each brush remembers its own color; switching restores it',
testWidgets('each pen slot remembers its own color; switching restores it',
(tester) async {
SharedPreferences.setMockInitialValues({});
// Wide surface so the palette + color dots aren't crushed under chrome.
await tester.binding.setSurfaceSize(const Size(1280, 800));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(const ProviderScope(
child: MaterialApp(home: PenNoteScreen()),
));
await tester.pump(); // let PenConfig load
await tester.pump(); // let PenConfig + PenSlots load
await tester.pump();
// Defaults from _brushColors: fountain pen = black, ballpoint = blue.
// Defaults: 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');
// Switch to the ballpoint slot → its remembered color (blue) becomes active.
await selectSlot(tester, BrushKind.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');
// Change the ACTIVE (ballpoint) slot's color via a palette color dot.
const paletteRed = Color(0xFFC62828);
await tapColorDot(tester, paletteRed);
await tester.pump();
expect(canvasColor(tester), paletteRed,
reason: 'color change applies to the active slot');
// Switch back to the fountain pen → its color is still black (unchanged).
await selectBrush(tester, 'Fountain pen');
await selectSlot(tester, BrushKind.fountainPen);
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,
await selectSlot(tester, BrushKind.ballpoint);
expect(canvasColor(tester), paletteRed,
reason: 'ballpoint remembers its own updated color');
});
}

75
test/pen_slots_test.dart Normal file
View File

@@ -0,0 +1,75 @@
// Unit tests for PenSlot / PenSlotsController persistence and independence.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:badnote/editor/engine/brush.dart';
import 'package:badnote/editor/input/pen_slots.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
SharedPreferences.setMockInitialValues({});
});
group('PenSlot toJson / fromJson', () {
test('round-trips defaults', () {
for (final slot in kDefaultPenSlots()) {
final restored = PenSlot.fromJson(slot.toJson());
expect(restored, slot);
}
});
});
group('PenSlotsController', () {
test('seeds three default pens on first load', () async {
final c = await PenSlotsController.load();
expect(c.slots.length, 3);
expect(c.active.brush, BrushKind.fountainPen);
expect(c.active.color, Colors.black);
expect(c.active.width, 0.006);
expect(c.slots[1].brush, BrushKind.ballpoint);
expect(c.slots[1].width, 0.0022);
expect(c.slots[2].brush, BrushKind.pencil);
expect(c.slots[2].width, 0.003);
c.dispose();
});
test('select restores brush + color + width as a unit', () async {
final c = await PenSlotsController.load();
await c.select('slot_1');
expect(c.active.brush, BrushKind.ballpoint);
expect(c.active.color, Colors.blue);
expect(c.active.width, 0.0022);
await c.setActiveColor(Colors.red);
await c.setActiveWidth(0.01);
await c.select('slot_0');
expect(c.active.color, Colors.black);
expect(c.active.width, 0.006);
await c.select('slot_1');
expect(c.active.color, Colors.red);
expect(c.active.width, 0.01);
c.dispose();
});
test('persists across load()', () async {
final first = await PenSlotsController.load();
await first.select('slot_2');
await first.setActiveColor(Colors.purple);
await first.setActiveWidth(0.008);
first.dispose();
final second = await PenSlotsController.load();
expect(second.activeId, 'slot_2');
expect(second.active.brush, BrushKind.pencil);
expect(second.active.color.toARGB32(), Colors.purple.toARGB32());
expect(second.active.width, 0.008);
second.dispose();
});
});
}

View File

@@ -122,6 +122,26 @@ void main() {
reopened.dispose();
});
test('schedulePageCountSave persists and restores pageCount', () async {
final repo = await SidecarRepository.open(src, debounce: _fast);
repo.schedulePageCountSave(5);
await repo.flush();
expect(repo.sidecar.pageCount, 5);
repo.dispose();
final reopened = await SidecarRepository.open(src, debounce: _fast);
expect(reopened.sidecar.pageCount, 5);
reopened.dispose();
});
test('notebook open normalizes missing pageCount to 1', () async {
final noteSrc = '${tmpDir.path}/notebook';
final repo =
await SidecarRepository.open(noteSrc, docType: 'notebook', debounce: _fast);
expect(repo.sidecar.pageCount, 1);
repo.dispose();
});
test('removing a highlight persists (un-highlight)', () async {
final repo = await SidecarRepository.open(src, debounce: _fast);
repo.scheduleHighlightSave(0, const [

View File

@@ -237,6 +237,7 @@ void main() {
expect(loaded, isNotNull);
expect(loaded!.title, 'My Algebra Notes');
expect(loaded.docType, 'notebook');
expect(loaded.pageCount, 1);
// The folder holds only the sidecar (and possibly its .bak/.tmp), never
// an importable source file.
final files = Directory(expectedFolder)