diff --git a/lib/editor/canvas/note_background.dart b/lib/editor/canvas/note_background.dart new file mode 100644 index 0000000..a14af47 --- /dev/null +++ b/lib/editor/canvas/note_background.dart @@ -0,0 +1,160 @@ +// lib/editor/canvas/note_background.dart +// +// rnote-style page background TEMPLATES for the blank-note editor. A background +// is a repeating PATTERN painted in the note page's local pixel space (the +// `pageWidget` is sized to the page rect inside the InteractiveViewer, so a +// CustomPainter here scales 1:1 with zoom — no extra transform needed). +// +// The choice is per-notebook and persists in the sidecar (stored as the enum +// `name`; missing/unknown → [NoteBackground.blank] for back-compat). + +import 'package:flutter/material.dart'; + +/// The available page-background templates (rnote: blank + dots/lines/grid + +/// the Cornell note layout). +enum NoteBackground { + /// Plain white sheet, no pattern. + blank, + + /// A regular grid of small dots (dotted paper). + dots, + + /// Evenly spaced horizontal lines (ruled / lined paper). + ruled, + + /// Square grid (graph paper). + grid, + + /// Cornell layout: a left cue-column line + a bottom summary line over a + /// ruled note-taking body. + cornell, +} + +/// Decode a persisted background name (the enum [NoteBackground.name]); unknown +/// or missing values fall back to [NoteBackground.blank] (back-compat). +NoteBackground noteBackgroundFromName(String? name) { + for (final b in NoteBackground.values) { + if (b.name == name) return b; + } + return NoteBackground.blank; +} + +/// Localized-ish English display label for the picker menu. +String noteBackgroundLabel(NoteBackground b) { + switch (b) { + case NoteBackground.blank: + return 'Blank'; + case NoteBackground.dots: + return 'Dots'; + case NoteBackground.ruled: + return 'Ruled lines'; + case NoteBackground.grid: + return 'Grid'; + case NoteBackground.cornell: + return 'Cornell'; + } +} + +/// An icon for the picker menu. +IconData noteBackgroundIcon(NoteBackground b) { + switch (b) { + case NoteBackground.blank: + return Icons.crop_portrait; + case NoteBackground.dots: + return Icons.grain; + case NoteBackground.ruled: + return Icons.notes; + case NoteBackground.grid: + return Icons.grid_4x4; + case NoteBackground.cornell: + return Icons.view_quilt_outlined; + } +} + +/// Paints a [NoteBackground] template behind the ink, in the page's local pixel +/// space. Spacing is page-relative (a fraction of page width) so the template +/// looks the same on any logical page size, and the lines are a light, subtle +/// grey so they sit behind handwriting. +class NoteBackgroundPainter extends CustomPainter { + const NoteBackgroundPainter(this.background); + + final NoteBackground background; + + /// Pattern spacing as a fraction of the page WIDTH — a ~28-line page. + static const double _spacingFraction = 1 / 28; + + static const Color _lineColor = Color(0x1A000000); // ~10% black, subtle grey. + static const Color _dotColor = Color(0x33000000); // dots a touch darker. + static const Color _accentColor = Color(0x33335C81); // Cornell margin lines. + + @override + void paint(Canvas canvas, Size size) { + if (background == NoteBackground.blank) return; + final spacing = size.width * _spacingFraction; + if (spacing <= 0) return; + + switch (background) { + case NoteBackground.blank: + break; + case NoteBackground.dots: + _paintDots(canvas, size, spacing); + case NoteBackground.ruled: + _paintRuled(canvas, size, spacing); + case NoteBackground.grid: + _paintGrid(canvas, size, spacing); + case NoteBackground.cornell: + _paintCornell(canvas, size, spacing); + } + } + + void _paintDots(Canvas canvas, Size size, double spacing) { + final paint = Paint() + ..color = _dotColor + ..style = PaintingStyle.fill; + final r = (spacing * 0.06).clamp(0.6, 2.0); + for (double y = spacing; y < size.height; y += spacing) { + for (double x = spacing; x < size.width; x += spacing) { + canvas.drawCircle(Offset(x, y), r, paint); + } + } + } + + void _paintRuled(Canvas canvas, Size size, double spacing) { + final paint = Paint() + ..color = _lineColor + ..strokeWidth = 1.0; + for (double y = spacing; y < size.height; y += spacing) { + canvas.drawLine(Offset(0, y), Offset(size.width, y), paint); + } + } + + void _paintGrid(Canvas canvas, Size size, double spacing) { + final paint = Paint() + ..color = _lineColor + ..strokeWidth = 1.0; + for (double y = spacing; y < size.height; y += spacing) { + canvas.drawLine(Offset(0, y), Offset(size.width, y), paint); + } + for (double x = spacing; x < size.width; x += spacing) { + canvas.drawLine(Offset(x, 0), Offset(x, size.height), paint); + } + } + + void _paintCornell(Canvas canvas, Size size, double spacing) { + // Ruled body lines. + _paintRuled(canvas, size, spacing); + final accent = Paint() + ..color = _accentColor + ..strokeWidth = 1.4; + // Left cue-column vertical line (~25% of width). + final cueX = size.width * 0.25; + // Bottom summary horizontal line (~80% down). + final summaryY = size.height * 0.80; + canvas.drawLine(Offset(cueX, 0), Offset(cueX, summaryY), accent); + canvas.drawLine(Offset(0, summaryY), Offset(size.width, summaryY), accent); + } + + @override + bool shouldRepaint(covariant NoteBackgroundPainter oldDelegate) => + oldDelegate.background != background; +} diff --git a/lib/editor/canvas/pen_note_screen.dart b/lib/editor/canvas/pen_note_screen.dart index 10bdb18..bf640a5 100644 --- a/lib/editor/canvas/pen_note_screen.dart +++ b/lib/editor/canvas/pen_note_screen.dart @@ -24,6 +24,7 @@ import '../layout/viewport_fit.dart'; import '../notebook/ink_stroke_adapter.dart'; import '../ui/pen_settings_page.dart'; import 'editor_tool.dart'; +import 'note_background.dart'; import 'pen_canvas.dart'; import 'pen_palette_widgets.dart'; import 'pen_stroke.dart'; @@ -83,6 +84,10 @@ class _PenNoteScreenState extends ConsumerState { /// The active drawing color (the active brush's remembered color). Color get _color => _brushColors[_activeColorBrush] ?? Colors.black; + /// The page-background template painted behind the ink (rnote-style). Default + /// blank; persisted per-notebook in the sidecar. + NoteBackground _background = NoteBackground.blank; + bool _allowFingerDrawing = false; bool _dirty = false; bool _needsCenter = true; @@ -151,6 +156,7 @@ class _PenNoteScreenState extends ConsumerState { if (title != null && title.isNotEmpty) { _titleController.text = title; } + _background = noteBackgroundFromName(repo.loadedBackground); }); } @@ -288,6 +294,7 @@ class _PenNoteScreenState extends ConsumerState { for (final s in _strokes) EditorStroke.fromPenStroke(s), ]; repo.scheduleTitleSave(title); + repo.scheduleBackgroundSave(_background.name); repo.scheduleStrokeSave(_notePageIndex, editorStrokes); await repo.flush(); @@ -475,8 +482,10 @@ class _PenNoteScreenState extends ConsumerState { allowFingerDrawing: _allowFingerDrawing, onStrokeComplete: _commitStroke, onEraseStroke: _eraseStroke, - // A white sheet with a soft shadow — the note "paper". - pageWidget: Container( + // A white sheet with a soft shadow — the note "paper" — overlaid with + // the selected background template, painted in page-pixel space (so it + // scales with zoom) and BEHIND the ink layers. + pageWidget: DecoratedBox( decoration: BoxDecoration( color: Colors.white, boxShadow: [ @@ -487,6 +496,10 @@ class _PenNoteScreenState extends ConsumerState { ), ], ), + child: CustomPaint( + painter: NoteBackgroundPainter(_background), + size: Size.infinite, + ), ), ); }, @@ -570,6 +583,55 @@ class _PenNoteScreenState extends ConsumerState { PaletteDivider(cs: cs), for (final c in _palette) _colorDot(c, cs), PaletteDivider(cs: cs), + // Page-background template picker (rnote-style: blank / dots / ruled + // / grid / cornell). Persists per-notebook in the sidecar. + PopupMenuButton( + tooltip: 'Page background', + initialValue: _background, + onSelected: (b) { + setState(() { + _background = b; + _dirty = true; + }); + }, + itemBuilder: (context) => [ + for (final b in NoteBackground.values) + PopupMenuItem( + value: b, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(noteBackgroundIcon(b), size: 20), + const SizedBox(width: 10), + Text(noteBackgroundLabel(b)), + if (b == _background) ...[ + const SizedBox(width: 8), + Icon(Icons.check, size: 18, color: cs.primary), + ], + ], + ), + ), + ], + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 8), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + noteBackgroundIcon(_background), + size: 22, + color: cs.onSurfaceVariant, + ), + Icon( + Icons.arrow_drop_down, + size: 18, + color: cs.onSurfaceVariant, + ), + ], + ), + ), + ), + PaletteDivider(cs: cs), ToolButton( icon: _allowFingerDrawing ? Icons.touch_app : Icons.do_not_touch, selected: _allowFingerDrawing, diff --git a/lib/editor/persistence/sidecar_repository.dart b/lib/editor/persistence/sidecar_repository.dart index 2ac41c0..dd0de54 100644 --- a/lib/editor/persistence/sidecar_repository.dart +++ b/lib/editor/persistence/sidecar_repository.dart @@ -145,6 +145,10 @@ class SidecarRepository { /// The standalone-notebook title loaded from the sidecar, or null. String? get loadedTitle => _sidecar.title; + /// The page-background template name loaded from the sidecar, or null + /// (missing → blank, decoded by the editor). + String? get loadedBackground => _sidecar.background; + // ── Mutations (synchronous in-memory update + debounced atomic write) ────── /// Replace the standalone-notebook title and schedule a save. No-op if the @@ -154,6 +158,13 @@ class SidecarRepository { _replace(title: title); } + /// Replace the page-background template (a [NoteBackground] enum name) and + /// schedule a save. No-op if unchanged. + void scheduleBackgroundSave(String background) { + if (_sidecar.background == background) return; + _replace(background: background); + } + /// Replace the handwriting-OCR search text and schedule a save (Phase 6 /// search index). No-op if unchanged. void scheduleOcrTextSave(String? ocrText) { @@ -266,6 +277,7 @@ class SidecarRepository { List? scratchLinks, String? ocrText, bool clearOcrText = false, + String? background, }) { if (_disposed) return; _sidecar = BadnoteSidecar( @@ -282,6 +294,7 @@ class SidecarRepository { bookmarks: _sidecar.bookmarks, scratchLinks: scratchLinks ?? _sidecar.scratchLinks, ocrText: clearOcrText ? null : (ocrText ?? _sidecar.ocrText), + background: background ?? _sidecar.background, ); _timer?.cancel(); _timer = Timer(_debounce, () { diff --git a/lib/storage/badnote_sidecar.dart b/lib/storage/badnote_sidecar.dart index 2f1be5d..c796b70 100644 --- a/lib/storage/badnote_sidecar.dart +++ b/lib/storage/badnote_sidecar.dart @@ -224,6 +224,7 @@ class BadnoteSidecar { Map? legacyAnnotations, this.ocrText, this.legacyId, + this.background, }) : strokes = strokes ?? >{}, highlights = highlights ?? >{}, bookmarks = bookmarks ?? [], @@ -278,6 +279,12 @@ class BadnoteSidecar { /// its folder name collided). Null for all freshly authored sidecars. final String? legacyId; + /// The page-background template for a standalone notebook, stored as the + /// [NoteBackground] enum `name` (e.g. `dots`, `cornell`). Kept as a raw String + /// here so the storage model stays UI-decoupled; the editor decodes it via + /// `noteBackgroundFromName` (missing/unknown → blank, back-compat). + final String? background; + Map toJson() => { 'badnoteSidecarVersion': version, if (sourceFile != null) 'sourceFile': sourceFile, @@ -306,6 +313,7 @@ class BadnoteSidecar { }, if (ocrText != null && ocrText!.isNotEmpty) 'ocrText': ocrText, if (legacyId != null) 'legacyId': legacyId, + if (background != null) 'background': background, }; factory BadnoteSidecar.fromJson(Map json) { @@ -361,6 +369,7 @@ class BadnoteSidecar { }(), ocrText: json['ocrText'] as String?, legacyId: json['legacyId'] as String?, + background: json['background'] as String?, ); } } diff --git a/test/badnote_sidecar_test.dart b/test/badnote_sidecar_test.dart index 6b1f763..5b4bb2d 100644 --- a/test/badnote_sidecar_test.dart +++ b/test/badnote_sidecar_test.dart @@ -231,6 +231,22 @@ void main() { expect(reparsed.strokes[0]!.single.id, 's0'); }); + test('background name round-trips through the sidecar', () { + final original = BadnoteSidecar(docType: 'notebook', background: 'cornell'); + final reparsed = _roundTrip(original); + expect(reparsed.background, 'cornell'); + // Omitted when null (no key bloat for blank/legacy sidecars). + expect(BadnoteSidecar().toJson().containsKey('background'), isFalse); + }); + + test('missing background decodes to null (back-compat → blank)', () { + final json = + jsonDecode(jsonEncode(BadnoteSidecar().toJson())) as Map; + expect(json.containsKey('background'), isFalse); + final reparsed = BadnoteSidecar.fromJson(json); + expect(reparsed.background, isNull); + }); + test('missing scratchpad defaults to 4000x4000 empty pad', () { final json = SidecarScratchLink( link: const ScratchLink( diff --git a/test/note_background_test.dart b/test/note_background_test.dart new file mode 100644 index 0000000..a6fb96e --- /dev/null +++ b/test/note_background_test.dart @@ -0,0 +1,41 @@ +// test/note_background_test.dart +// +// Covers the NoteBackground template enum decode (back-compat: missing/unknown → +// blank) and the painter's shouldRepaint reacting to a background change. + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:badnote/editor/canvas/note_background.dart'; + +void main() { + group('noteBackgroundFromName', () { + test('decodes each enum name round-trip', () { + for (final b in NoteBackground.values) { + expect(noteBackgroundFromName(b.name), b); + } + }); + + test('null → blank (back-compat for legacy sidecars)', () { + expect(noteBackgroundFromName(null), NoteBackground.blank); + }); + + test('unknown name → blank (forward-compat)', () { + expect(noteBackgroundFromName('isometric'), NoteBackground.blank); + expect(noteBackgroundFromName(''), NoteBackground.blank); + }); + }); + + group('NoteBackgroundPainter.shouldRepaint', () { + test('repaints when the background changes', () { + const a = NoteBackgroundPainter(NoteBackground.blank); + const b = NoteBackgroundPainter(NoteBackground.dots); + expect(a.shouldRepaint(b), isTrue); + }); + + test('does not repaint when the background is identical', () { + const a = NoteBackgroundPainter(NoteBackground.grid); + const b = NoteBackgroundPainter(NoteBackground.grid); + expect(a.shouldRepaint(b), isFalse); + }); + }); +}