feat: unified shell, diagnostics pack, native Office, sticky board
All checks were successful
CI / Windows build (push) Successful in 14m22s
All checks were successful
CI / Windows build (push) Successful in 14m22s
Make Surface remote debugging and classroom workflows viable: always-on structured logs with one-click zip export, a single AppShell chrome, OOXML PPTX/DOCX annotation without LibreOffice, and a first-class sticky board. Also drop spike/legacy ink widgets and tighten pen feel (predictor, PenInfoHistory, page-tile layer). Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
61
lib/editor/pdf/page_tile_layer.dart
Normal file
61
lib/editor/pdf/page_tile_layer.dart
Normal file
@@ -0,0 +1,61 @@
|
||||
// Double-buffer helper on top of [PageTileCache] to kill zoom white-flash:
|
||||
// keep painting the last good tile while a higher-DPI raster is in flight.
|
||||
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'page_tile_cache.dart';
|
||||
|
||||
/// Holds the "last good" page image for the currently visible page so a zoom
|
||||
/// settle never exposes an empty frame (plan W2 / R11).
|
||||
class PageTileLayer extends ChangeNotifier {
|
||||
PageTileLayer({PageTileCache? cache}) : _cache = cache ?? PageTileCache();
|
||||
|
||||
final PageTileCache _cache;
|
||||
ui.Image? _lastGood;
|
||||
TileKey? _lastKey;
|
||||
|
||||
PageTileCache get cache => _cache;
|
||||
ui.Image? get lastGood => _lastGood;
|
||||
TileKey? get lastKey => _lastKey;
|
||||
|
||||
/// Snap continuous zoom to a coarse DPI bucket (avoids a tile per frame).
|
||||
static int dpiBucketFor(double zoom, {double baseDpi = 96, double step = 0.5}) {
|
||||
final raw = zoom / step;
|
||||
final snapped = raw.round().clamp(1, 16);
|
||||
return (snapped * step * baseDpi).round();
|
||||
}
|
||||
|
||||
/// Promote [image] as the last-good tile for [key].
|
||||
void put(TileKey key, ui.Image image) {
|
||||
_cache.put(key, image);
|
||||
_lastGood = image;
|
||||
_lastKey = key;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Prefer exact bucket; else fall back to last-good so zoom never blanks.
|
||||
ui.Image? resolve(TileKey key) {
|
||||
final hit = _cache.get(key);
|
||||
if (hit != null) {
|
||||
_lastGood = hit;
|
||||
_lastKey = key;
|
||||
return hit;
|
||||
}
|
||||
return _lastGood;
|
||||
}
|
||||
|
||||
void clear() {
|
||||
_lastGood = null;
|
||||
_lastKey = null;
|
||||
_cache.dispose();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
clear();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,187 +0,0 @@
|
||||
// lib/editor/pdf/spike_app.dart
|
||||
//
|
||||
// THROWAWAY M1 spike app shell (plan §10). Wraps [SpikeEditorPane] with an
|
||||
// on-screen frame-timing HUD (median build & raster ms over the last ~120
|
||||
// frames) and an ink-load toggle, so MUST #4/#5 are observable on-device when
|
||||
// launched via `flutter run -t lib/editor/pdf/spike_main.dart` on the tablet.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:pdfrx/pdfrx.dart';
|
||||
|
||||
import 'spike_editor_pane.dart';
|
||||
|
||||
class SpikeApp extends StatelessWidget {
|
||||
const SpikeApp({super.key, required this.pdfPath, this.denseStrokesAsset});
|
||||
|
||||
final String pdfPath;
|
||||
final String? denseStrokesAsset;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'BadNote M1 Spike',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: ThemeData(useMaterial3: true, colorSchemeSeed: Colors.indigo),
|
||||
home: SpikeHome(
|
||||
pdfPath: pdfPath,
|
||||
denseStrokesAsset: denseStrokesAsset,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SpikeHome extends StatefulWidget {
|
||||
const SpikeHome({super.key, required this.pdfPath, this.denseStrokesAsset});
|
||||
|
||||
final String pdfPath;
|
||||
final String? denseStrokesAsset;
|
||||
|
||||
@override
|
||||
State<SpikeHome> createState() => _SpikeHomeState();
|
||||
}
|
||||
|
||||
class _SpikeHomeState extends State<SpikeHome> {
|
||||
final GlobalKey<SpikeEditorPaneState> _paneKey =
|
||||
GlobalKey<SpikeEditorPaneState>();
|
||||
final PdfViewerController _controller = PdfViewerController();
|
||||
bool _inkLoad = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Stack(
|
||||
children: [
|
||||
SpikeEditorPane(
|
||||
key: _paneKey,
|
||||
controller: _controller,
|
||||
pdfPath: widget.pdfPath,
|
||||
denseStrokesAsset: widget.denseStrokesAsset,
|
||||
),
|
||||
const Positioned(top: 8, left: 8, child: FrameTimingHud()),
|
||||
],
|
||||
),
|
||||
floatingActionButton: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
FloatingActionButton.extended(
|
||||
heroTag: 'inkload',
|
||||
onPressed: () async {
|
||||
final next = !_inkLoad;
|
||||
await _paneKey.currentState?.setInkLoad(next);
|
||||
setState(() => _inkLoad = next);
|
||||
},
|
||||
label: Text(_inkLoad ? 'Ink load: ON' : 'Ink load: OFF'),
|
||||
icon: const Icon(Icons.brush),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// On-screen median build/raster frame-time HUD, driven by
|
||||
/// [SchedulerBinding.addTimingsCallback]. Shows the median of the last
|
||||
/// [_window] frames for both the build (`buildDuration`) and raster
|
||||
/// (`rasterDuration`) phases — the two halves of the 16.6ms budget tracked by
|
||||
/// MUST #4/#5.
|
||||
class FrameTimingHud extends StatefulWidget {
|
||||
const FrameTimingHud({super.key});
|
||||
|
||||
@override
|
||||
State<FrameTimingHud> createState() => _FrameTimingHudState();
|
||||
}
|
||||
|
||||
class _FrameTimingHudState extends State<FrameTimingHud> {
|
||||
static const int _window = 120;
|
||||
final List<double> _build = <double>[];
|
||||
final List<double> _raster = <double>[];
|
||||
double _medBuild = 0;
|
||||
double _medRaster = 0;
|
||||
double _p95Build = 0;
|
||||
double _p95Raster = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
SchedulerBinding.instance.addTimingsCallback(_onTimings);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
SchedulerBinding.instance.removeTimingsCallback(_onTimings);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onTimings(List<FrameTiming> timings) {
|
||||
for (final t in timings) {
|
||||
_build.add(t.buildDuration.inMicroseconds / 1000.0);
|
||||
_raster.add(t.rasterDuration.inMicroseconds / 1000.0);
|
||||
}
|
||||
while (_build.length > _window) {
|
||||
_build.removeAt(0);
|
||||
}
|
||||
while (_raster.length > _window) {
|
||||
_raster.removeAt(0);
|
||||
}
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_medBuild = _percentile(_build, 50);
|
||||
_medRaster = _percentile(_raster, 50);
|
||||
_p95Build = _percentile(_build, 95);
|
||||
_p95Raster = _percentile(_raster, 95);
|
||||
});
|
||||
}
|
||||
|
||||
static double _percentile(List<double> values, int p) {
|
||||
if (values.isEmpty) return 0;
|
||||
final sorted = List<double>.from(values)..sort();
|
||||
final idx = ((p / 100.0) * (sorted.length - 1)).round();
|
||||
return sorted[idx.clamp(0, sorted.length - 1)];
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Color budget(double ms) => ms <= 16.6
|
||||
? Colors.greenAccent
|
||||
: (ms <= 22 ? Colors.amberAccent : Colors.redAccent);
|
||||
return IgnorePointer(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.65),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: DefaultTextStyle(
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
color: Colors.white,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('frames: ${_build.length}/$_window'),
|
||||
Text.rich(TextSpan(children: [
|
||||
const TextSpan(text: 'build med '),
|
||||
TextSpan(
|
||||
text: '${_medBuild.toStringAsFixed(1)}ms',
|
||||
style: TextStyle(color: budget(_medBuild))),
|
||||
TextSpan(text: ' p95 ${_p95Build.toStringAsFixed(1)}ms'),
|
||||
])),
|
||||
Text.rich(TextSpan(children: [
|
||||
const TextSpan(text: 'raster med '),
|
||||
TextSpan(
|
||||
text: '${_medRaster.toStringAsFixed(1)}ms',
|
||||
style: TextStyle(color: budget(_medRaster))),
|
||||
TextSpan(text: ' p95 ${_p95Raster.toStringAsFixed(1)}ms'),
|
||||
])),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,344 +0,0 @@
|
||||
// lib/editor/pdf/spike_editor_pane.dart
|
||||
//
|
||||
// THROWAWAY M1 spike widget (plan §10 / MUST #2, #4, #5). Hosts a pdfrx
|
||||
// PdfViewer.file and exercises the three things the M1 gate must prove:
|
||||
//
|
||||
// 1. Coordinate correctness (MUST #2): a `pageOverlaysBuilder` paints a
|
||||
// diagnostic crosshair at normalized (0.5, 0.5) using
|
||||
// `canvas.scale(size.width, size.height)`, with the CustomPaint sized to
|
||||
// `pageRect.size` (plan §2.1). This dot MUST sit at the visual page center
|
||||
// at every zoom level. `coordinate_assertion_test.dart` asserts this.
|
||||
//
|
||||
// 2. Pen/touch arbitration (MUST #3): a `viewerOverlayBuilder` wraps a
|
||||
// `PenCaptureRegion` so pen events draw a live viewer-level stroke while
|
||||
// touch scrolls and pinch zooms — same overlay, no mode switch.
|
||||
//
|
||||
// 3. Ink-overlay build cost (MUST #5): a toggle injects ~N synthetic strokes
|
||||
// per page (from dense_strokes.json) into the page overlay so the perf
|
||||
// bench can measure BUILD time with a non-trivial ui.Picture per page.
|
||||
//
|
||||
// This file is NOT production code and is excluded from the real editor.
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:pdfrx/pdfrx.dart';
|
||||
|
||||
import 'pen_capture_region.dart';
|
||||
|
||||
/// Normalized page-space point the diagnostic marker is painted at. The M1
|
||||
/// coordinate assertion checks this maps to the page-center pixel at all zooms.
|
||||
const Offset kMarkerNormalized = Offset(0.5, 0.5);
|
||||
|
||||
/// A single captured pen sample in normalized page space, tagged with its page.
|
||||
class _PenSample {
|
||||
const _PenSample(this.pageIndex, this.normalized);
|
||||
final int pageIndex;
|
||||
final Offset normalized;
|
||||
}
|
||||
|
||||
/// Spike editor pane. Provide a [pdfPath] to a local PDF (e.g.
|
||||
/// test/assets/large_300p.pdf). [denseStrokesAsset] is a filesystem PATH to the
|
||||
/// synthetic ink load (MUST #5); if null the ink-load toggle is inert.
|
||||
class SpikeEditorPane extends StatefulWidget {
|
||||
const SpikeEditorPane({
|
||||
super.key,
|
||||
required this.pdfPath,
|
||||
this.denseStrokesAsset,
|
||||
this.strokesPerPage = 300,
|
||||
this.strokeCountKey = '2000',
|
||||
this.onViewerReady,
|
||||
this.controller,
|
||||
});
|
||||
|
||||
final String pdfPath;
|
||||
final String? denseStrokesAsset;
|
||||
final int strokesPerPage;
|
||||
|
||||
/// Which top-level array in dense_strokes.json to draw from ("2000"/"5000").
|
||||
final String strokeCountKey;
|
||||
|
||||
/// Forwarded from pdfrx once the document is laid out and interactive.
|
||||
final void Function(PdfDocument document, PdfViewerController controller)?
|
||||
onViewerReady;
|
||||
|
||||
/// Optional externally-owned controller (tests drive zoom through this).
|
||||
final PdfViewerController? controller;
|
||||
|
||||
@override
|
||||
State<SpikeEditorPane> createState() => SpikeEditorPaneState();
|
||||
}
|
||||
|
||||
class SpikeEditorPaneState extends State<SpikeEditorPane> {
|
||||
late final PdfViewerController _controller =
|
||||
widget.controller ?? PdfViewerController();
|
||||
|
||||
/// Live pen strokes captured via PenCaptureRegion (viewer-level overlay).
|
||||
final List<List<_PenSample>> _penStrokes = <List<_PenSample>>[];
|
||||
List<_PenSample>? _activeStroke;
|
||||
|
||||
/// Synthetic strokes for the ink-load gate, lazily loaded. Each entry is a
|
||||
/// list of normalized polylines (one stroke = list of points).
|
||||
List<List<Offset>>? _syntheticStrokes;
|
||||
bool _inkLoadEnabled = false;
|
||||
bool _loadingSynthetic = false;
|
||||
|
||||
bool get inkLoadEnabled => _inkLoadEnabled;
|
||||
|
||||
/// Toggle the dense synthetic-ink overlay (MUST #5). Loads the asset on first
|
||||
/// enable. Public so the perf bench can drive it programmatically.
|
||||
Future<void> setInkLoad(bool enabled) async {
|
||||
if (enabled && _syntheticStrokes == null) {
|
||||
await _loadSyntheticStrokes();
|
||||
}
|
||||
if (mounted) setState(() => _inkLoadEnabled = enabled);
|
||||
}
|
||||
|
||||
Future<void> _loadSyntheticStrokes() async {
|
||||
final asset = widget.denseStrokesAsset;
|
||||
if (asset == null || _loadingSynthetic) return;
|
||||
_loadingSynthetic = true;
|
||||
try {
|
||||
// [asset] is a filesystem path (e.g. test/assets/dense_strokes.json),
|
||||
// not a bundled rootBundle key — regenerate via tool/gen_dense_strokes.dart.
|
||||
final raw = await File(asset).readAsString();
|
||||
final decoded = jsonDecode(raw) as Map<String, dynamic>;
|
||||
final strokesJson =
|
||||
(decoded[widget.strokeCountKey] as List<dynamic>? ?? const []);
|
||||
final result = <List<Offset>>[];
|
||||
for (final s in strokesJson) {
|
||||
final points = (s as Map<String, dynamic>)['points'] as List<dynamic>;
|
||||
final poly = <Offset>[];
|
||||
for (final p in points) {
|
||||
final pt = p as Map<String, dynamic>;
|
||||
poly.add(Offset(
|
||||
(pt['x'] as num).toDouble(),
|
||||
(pt['y'] as num).toDouble(),
|
||||
));
|
||||
}
|
||||
if (poly.length >= 2) result.add(poly);
|
||||
}
|
||||
_syntheticStrokes = result;
|
||||
} finally {
|
||||
_loadingSynthetic = false;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Pen capture (viewer-level) ---------------------------------------
|
||||
|
||||
void _onPenEvent(PointerEvent event) {
|
||||
// Convert global → document → which page + normalized page coords.
|
||||
final doc = _controller.globalToDocument(event.position);
|
||||
if (doc == null) return;
|
||||
final hit = _documentToPage(doc);
|
||||
if (hit == null) return;
|
||||
|
||||
if (event is PointerDownEvent) {
|
||||
_activeStroke = <_PenSample>[hit];
|
||||
_penStrokes.add(_activeStroke!);
|
||||
setState(() {});
|
||||
} else if (event is PointerMoveEvent) {
|
||||
_activeStroke?.add(hit);
|
||||
setState(() {});
|
||||
} else if (event is PointerUpEvent || event is PointerCancelEvent) {
|
||||
_activeStroke = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps a document-space point to (pageIndex, normalized-in-page) using the
|
||||
/// controller's page layout rects (document coordinates). Returns null if the
|
||||
/// point is outside every page box.
|
||||
_PenSample? _documentToPage(Offset doc) {
|
||||
if (!_controller.isReady) return null;
|
||||
final rects = _controller.layout.pageLayouts;
|
||||
for (var i = 0; i < rects.length; i++) {
|
||||
final r = rects[i];
|
||||
if (r.contains(doc)) {
|
||||
final nx = ((doc.dx - r.left) / r.width).clamp(0.0, 1.0);
|
||||
final ny = ((doc.dy - r.top) / r.height).clamp(0.0, 1.0);
|
||||
return _PenSample(i, Offset(nx, ny));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
children: [
|
||||
PdfViewer.file(
|
||||
widget.pdfPath,
|
||||
controller: _controller,
|
||||
params: PdfViewerParams(
|
||||
onViewerReady: widget.onViewerReady,
|
||||
// (1) Per-page overlay: diagnostic center marker + optional synthetic
|
||||
// ink. CustomPaint is sized to pageRect.size so canvas.scale maps
|
||||
// normalized [0,1] → zoomed pixels (plan §2.1).
|
||||
pageOverlaysBuilder: (context, pageRectInViewer, page) {
|
||||
final pageIndex = page.pageNumber - 1;
|
||||
return [
|
||||
SizedBox.fromSize(
|
||||
size: pageRectInViewer.size,
|
||||
child: CustomPaint(
|
||||
painter: _SpikeInkPainter(
|
||||
synthetic:
|
||||
_inkLoadEnabled ? _strokesForPage(pageIndex) : null,
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
},
|
||||
// (2) Viewer-level overlay: pen capture + live pen rendering. Touch
|
||||
// falls through to pdfrx for scroll/zoom (per-kind hit-test split).
|
||||
viewerOverlayBuilder: (context, size, handleLinkTap) {
|
||||
return [
|
||||
Positioned.fill(
|
||||
child: PenCaptureRegion(
|
||||
onPenEvent: _onPenEvent,
|
||||
child: IgnorePointer(
|
||||
child: CustomPaint(
|
||||
size: size,
|
||||
painter: _LivePenPainter(
|
||||
strokes: _penStrokes,
|
||||
controller: _controller,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Deterministic per-page slice of the synthetic stroke pool so each page
|
||||
/// shows ~[widget.strokesPerPage] strokes without loading 300× the data.
|
||||
List<List<Offset>> _strokesForPage(int pageIndex) {
|
||||
final pool = _syntheticStrokes;
|
||||
if (pool == null || pool.isEmpty) return const [];
|
||||
final n = widget.strokesPerPage.clamp(0, pool.length);
|
||||
final start = (pageIndex * n) % pool.length;
|
||||
final out = <List<Offset>>[];
|
||||
for (var i = 0; i < n; i++) {
|
||||
out.add(pool[(start + i) % pool.length]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Note: PdfViewerController is not a Listenable/ChangeNotifier we own a
|
||||
// lifecycle for; pdfrx attaches/detaches it via the PdfViewer. No dispose().
|
||||
}
|
||||
|
||||
/// Paints the diagnostic center marker (always) plus synthetic ink (when the
|
||||
/// MUST #5 load is enabled), in normalized [0,1] page space scaled to the
|
||||
/// CustomPaint size (== zoomed page box). This is what the coordinate assertion
|
||||
/// inspects.
|
||||
class _SpikeInkPainter extends CustomPainter {
|
||||
_SpikeInkPainter({this.synthetic});
|
||||
|
||||
final List<List<Offset>>? synthetic;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
canvas.save();
|
||||
// Map normalized [0,1] → zoomed pixels (plan §2.1).
|
||||
canvas.scale(size.width, size.height);
|
||||
|
||||
// Synthetic ink load (MUST #5): a non-trivial set of polylines per page.
|
||||
final syn = synthetic;
|
||||
if (syn != null && syn.isNotEmpty) {
|
||||
final inkPaint = Paint()
|
||||
..color = const Color(0x5500AAFF)
|
||||
..style = PaintingStyle.stroke
|
||||
// Stroke width is in normalized units post-scale; keep it page-relative
|
||||
// and hairline-ish so 300 strokes are visible but cheap.
|
||||
..strokeWidth = 0.002
|
||||
..strokeCap = StrokeCap.round;
|
||||
for (final poly in syn) {
|
||||
if (poly.length < 2) continue;
|
||||
final path = Path()..moveTo(poly.first.dx, poly.first.dy);
|
||||
for (var i = 1; i < poly.length; i++) {
|
||||
path.lineTo(poly[i].dx, poly[i].dy);
|
||||
}
|
||||
canvas.drawPath(path, inkPaint);
|
||||
}
|
||||
}
|
||||
|
||||
canvas.restore();
|
||||
|
||||
// Diagnostic crosshair at normalized (0.5,0.5) — drawn in PIXEL space (after
|
||||
// restore) so its line thickness is constant on screen and its CENTER is at
|
||||
// exactly size.width*0.5, size.height*0.5. The coordinate assertion checks
|
||||
// this pixel.
|
||||
final center = Offset(
|
||||
size.width * kMarkerNormalized.dx,
|
||||
size.height * kMarkerNormalized.dy,
|
||||
);
|
||||
final markerPaint = Paint()
|
||||
..color = const Color(0xFFFF0066)
|
||||
..strokeWidth = 2.0
|
||||
..style = PaintingStyle.stroke;
|
||||
const arm = 16.0;
|
||||
canvas.drawLine(
|
||||
center.translate(-arm, 0), center.translate(arm, 0), markerPaint);
|
||||
canvas.drawLine(
|
||||
center.translate(0, -arm), center.translate(0, arm), markerPaint);
|
||||
canvas.drawCircle(center, 3.0, Paint()..color = const Color(0xFFFF0066));
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _SpikeInkPainter oldDelegate) =>
|
||||
oldDelegate.synthetic != synthetic;
|
||||
}
|
||||
|
||||
/// Paints live pen strokes captured by the PenCaptureRegion. Strokes are stored
|
||||
/// in normalized page space, so for each sample we re-project page→document→
|
||||
/// local each paint via the controller (keeps strokes glued to pages under
|
||||
/// scroll/zoom — the §2.1 property, exercised at the viewer level here).
|
||||
class _LivePenPainter extends CustomPainter {
|
||||
_LivePenPainter({required this.strokes, required this.controller})
|
||||
: super(repaint: controller);
|
||||
|
||||
final List<List<_PenSample>> strokes;
|
||||
final PdfViewerController controller;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
if (!controller.isReady) return;
|
||||
final rects = controller.layout.pageLayouts;
|
||||
final paint = Paint()
|
||||
..color = const Color(0xFF1565C0)
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 3.0
|
||||
..strokeCap = StrokeCap.round
|
||||
..strokeJoin = StrokeJoin.round;
|
||||
|
||||
for (final stroke in strokes) {
|
||||
Path? path;
|
||||
for (final s in stroke) {
|
||||
if (s.pageIndex >= rects.length) continue;
|
||||
final r = rects[s.pageIndex];
|
||||
// normalized page → document
|
||||
final docPt = Offset(
|
||||
r.left + s.normalized.dx * r.width,
|
||||
r.top + s.normalized.dy * r.height,
|
||||
);
|
||||
// document → local (viewer) coords
|
||||
final local = controller.documentToLocal(docPt);
|
||||
if (path == null) {
|
||||
path = Path()..moveTo(local.dx, local.dy);
|
||||
} else {
|
||||
path.lineTo(local.dx, local.dy);
|
||||
}
|
||||
}
|
||||
if (path != null) canvas.drawPath(path, paint);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _LivePenPainter oldDelegate) => true;
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
// lib/editor/pdf/spike_launcher.dart
|
||||
//
|
||||
// THROWAWAY M1 entry: lets the user open the pdfrx pen/perf spike from the
|
||||
// running app (so the CI-built Windows package can exercise MUST #3/#4/#5 on a
|
||||
// real Surface Pen with the user's OWN large PDFs). Remove together with the
|
||||
// rest of lib/editor/pdf/spike_* once M1 is signed off.
|
||||
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../canvas/pen_editor_screen.dart';
|
||||
|
||||
/// Opens a file picker for a PDF, then pushes the NEW pen-first canvas editor.
|
||||
///
|
||||
/// The 🧪 entry now opens the clean-room canvas (lib/editor/canvas/), which
|
||||
/// OWNS the gesture pipeline (pressure, pinch-zoom, palm rejection). The old
|
||||
/// spike_* files are left in place but no longer wired to this entry.
|
||||
Future<void> openM1Spike(BuildContext context) async {
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
type: FileType.custom,
|
||||
allowedExtensions: ['pdf'],
|
||||
);
|
||||
final path = result?.files.single.path;
|
||||
if (path == null) return;
|
||||
if (!context.mounted) return;
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => PenEditorScreen(pdfPath: path),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
// lib/editor/pdf/spike_main.dart
|
||||
//
|
||||
// Standalone entry point for the THROWAWAY M1 pdfrx spike (plan §10).
|
||||
//
|
||||
// Launch on the Windows tablet (or any desktop with a display):
|
||||
// flutter run -t lib/editor/pdf/spike_main.dart
|
||||
//
|
||||
// It opens test/assets/large_300p.pdf in [SpikeEditorPane] with the
|
||||
// frame-timing HUD and ink-load toggle, so the M1 perf/pen gates are
|
||||
// observable on-device.
|
||||
//
|
||||
// IMPORTANT: pen capture requires the kind-aware [PenCaptureBinding] (installed
|
||||
// below before pdfrx init). pdfrx itself is initialized via
|
||||
// pdfrxFlutterInitialize() — confirmed from pdfrx 2.4.4 example/pdf_combine.
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:pdfrx/pdfrx.dart';
|
||||
|
||||
import 'pen_capture_region.dart';
|
||||
import 'spike_app.dart';
|
||||
|
||||
/// Default benchmark asset (300-page PDF generated by tool/gen_bench_pdf.dart).
|
||||
const String _kDefaultPdfRelPath = 'test/assets/large_300p.pdf';
|
||||
|
||||
/// Filesystem path for the synthetic ink load (regenerate via
|
||||
/// tool/gen_dense_strokes.dart; not bundled — read from disk at the project root).
|
||||
const String _kDenseStrokesAsset = 'test/assets/dense_strokes.json';
|
||||
|
||||
void main(List<String> args) {
|
||||
// Kind-aware binding MUST be installed before runApp so PenCaptureRegion can
|
||||
// gate hit-testing by pointer kind (see pen_capture_region.dart header).
|
||||
PenCaptureBinding.ensureInitialized();
|
||||
// pdfrx native engine init (pdfrx 2.4.4 example pattern).
|
||||
pdfrxFlutterInitialize();
|
||||
|
||||
// Allow overriding the PDF path as the first CLI arg (otherwise the default
|
||||
// 300-page bench asset relative to the project root / cwd).
|
||||
final pdfPath = args.isNotEmpty ? args.first : _resolvePdfPath();
|
||||
|
||||
runApp(
|
||||
SpikeApp(
|
||||
pdfPath: pdfPath,
|
||||
denseStrokesAsset: _kDenseStrokesAsset,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Resolve the bench PDF path. `flutter run` sets cwd to the project root, so
|
||||
/// the relative asset path works on desktop; we also try a couple of fallbacks.
|
||||
String _resolvePdfPath() {
|
||||
final candidates = <String>[
|
||||
_kDefaultPdfRelPath,
|
||||
'${Directory.current.path}/$_kDefaultPdfRelPath',
|
||||
];
|
||||
for (final c in candidates) {
|
||||
if (File(c).existsSync()) return c;
|
||||
}
|
||||
// Return the primary path anyway; pdfrx will surface a clear load error.
|
||||
return _kDefaultPdfRelPath;
|
||||
}
|
||||
Reference in New Issue
Block a user