feat: unified shell, diagnostics pack, native Office, sticky board
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:
2026-08-05 17:55:27 +08:00
parent 3cabc7e074
commit d346cc2670
49 changed files with 2883 additions and 2515 deletions

View File

@@ -8,6 +8,7 @@
import 'package:flutter/foundation.dart';
import '../../diagnostics/frame_sampler.dart';
import '../input/diagnostic_logger.dart';
class InputDiagnostics extends ChangeNotifier {
@@ -62,6 +63,12 @@ class InputDiagnostics extends ChangeNotifier {
'${scaleDrop ? " SDROP" : ""}${focalDrop ? " FDROP" : ""}';
_trace.add(line);
if (_trace.length > 24) _trace.removeAt(0);
FrameSampler.instance.recordZoom(
rawScale: rawScale,
scaleDrop: scaleDrop,
focalDrop: focalDrop,
focalJumpPx: focalJumpPx,
);
DiagnosticLogger.instance.log('ZOOM $line');
notifyListeners();
}

View File

@@ -0,0 +1,328 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:path/path.dart' as p;
import '../../diagnostics/badnote_log.dart';
import '../../diagnostics/pen_event_ring.dart';
import '../../services/office/docx_parser.dart';
import '../../services/office/office_document.dart';
import '../../services/office/pptx_parser.dart';
import '../../theme/app_theme.dart';
/// Unified native Office viewer + ink annotation (PPTX / DOCX).
class OfficeDocumentScreen extends StatefulWidget {
const OfficeDocumentScreen({
super.key,
required this.filePath,
});
final String filePath;
@override
State<OfficeDocumentScreen> createState() => _OfficeDocumentScreenState();
}
class _OfficeDocumentScreenState extends State<OfficeDocumentScreen> {
bool _loading = true;
String? _error;
ParsedPptx? _pptx;
ParsedDocx? _docx;
int _pageIndex = 0;
final List<_InkStroke> _strokes = [];
_InkStroke? _live;
final TransformationController _transform = TransformationController();
String get _sidecarPath => '${widget.filePath}.badnote.json';
@override
void initState() {
super.initState();
_open();
}
@override
void dispose() {
_transform.dispose();
super.dispose();
}
Future<void> _open() async {
final ext = p.extension(widget.filePath).toLowerCase();
try {
if (ext == '.pptx' || ext == '.ppt') {
_pptx = await PptxParser().parse(widget.filePath);
} else if (ext == '.docx') {
_docx = await DocxParser().parse(widget.filePath);
} else {
throw StateError('Unsupported: $ext');
}
await _loadSidecar();
BadNoteLog.instance.info(LogSubsystem.office, 'office_open', fields: {
'path': widget.filePath,
'pages': pageCount,
});
} catch (e) {
_error = '$e';
BadNoteLog.instance.error(LogSubsystem.office, 'office_open_failed', fields: {
'error': '$e',
});
}
if (mounted) setState(() => _loading = false);
}
int get pageCount {
if (_pptx != null) return _pptx!.slides.length;
if (_docx != null) return (_docx!.blocks.length / 12).ceil().clamp(1, 9999);
return 0;
}
Future<void> _loadSidecar() async {
final f = File(_sidecarPath);
if (!await f.exists()) return;
try {
final json = jsonDecode(await f.readAsString()) as Map<String, dynamic>;
final pages = json['pages'] as Map<String, dynamic>? ?? {};
final key = '$_pageIndex';
final list = pages[key] as List<dynamic>? ?? [];
_strokes
..clear()
..addAll(list.map((e) => _InkStroke.fromJson(e as Map<String, dynamic>)));
} catch (_) {}
}
Future<void> _saveSidecar() async {
Map<String, dynamic> root = {'version': 1, 'pages': <String, dynamic>{}};
final f = File(_sidecarPath);
if (await f.exists()) {
try {
root = jsonDecode(await f.readAsString()) as Map<String, dynamic>;
} catch (_) {}
}
final pages = (root['pages'] as Map<String, dynamic>?) ?? {};
pages['$_pageIndex'] = _strokes.map((s) => s.toJson()).toList();
root['pages'] = pages;
await f.writeAsString(const JsonEncoder.withIndent(' ').convert(root));
}
Future<void> _goPage(int i) async {
await _saveSidecar();
setState(() {
_pageIndex = i.clamp(0, pageCount - 1);
_strokes.clear();
_live = null;
});
await _loadSidecar();
if (mounted) setState(() {});
}
void _onPointerDown(PointerDownEvent e) {
if (e.kind != ui.PointerDeviceKind.stylus &&
e.kind != ui.PointerDeviceKind.invertedStylus &&
e.kind != ui.PointerDeviceKind.mouse) {
return;
}
final local = _toScene(e.localPosition);
_live = _InkStroke(points: [local], pressures: [e.pressure]);
PenEventRing.instance.recordPointer(
kind: 'down',
pointerId: e.pointer,
deviceKind: e.kind.name,
pressure: e.pressure,
decision: 'draw',
);
setState(() {});
}
void _onPointerMove(PointerMoveEvent e) {
final live = _live;
if (live == null) return;
live.points.add(_toScene(e.localPosition));
live.pressures.add(e.pressure);
setState(() {});
}
void _onPointerUp(PointerUpEvent e) {
final live = _live;
if (live == null) return;
setState(() {
_strokes.add(live);
_live = null;
});
unawaited(_saveSidecar());
}
Offset _toScene(Offset local) {
final inv = Matrix4.inverted(_transform.value);
return MatrixUtils.transformPoint(inv, local);
}
@override
Widget build(BuildContext context) {
if (_loading) {
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
if (_error != null) {
return Scaffold(
appBar: AppBar(title: Text(p.basename(widget.filePath))),
body: Center(child: Text(_error!)),
);
}
return Scaffold(
appBar: AppBar(
title: Text(p.basename(widget.filePath)),
actions: [
IconButton(
onPressed: _pageIndex > 0 ? () => _goPage(_pageIndex - 1) : null,
icon: const Icon(Icons.chevron_left),
),
Center(child: Text('${_pageIndex + 1} / $pageCount')),
IconButton(
onPressed:
_pageIndex < pageCount - 1 ? () => _goPage(_pageIndex + 1) : null,
icon: const Icon(Icons.chevron_right),
),
],
),
body: InteractiveViewer(
transformationController: _transform,
minScale: 0.5,
maxScale: 4,
child: Listener(
onPointerDown: _onPointerDown,
onPointerMove: _onPointerMove,
onPointerUp: _onPointerUp,
child: CustomPaint(
painter: _OfficePagePainter(
pptx: _pptx,
docx: _docx,
pageIndex: _pageIndex,
strokes: _strokes,
live: _live,
),
size: _pageSize,
),
),
),
);
}
Size get _pageSize {
if (_pptx != null && _pptx!.slides.isNotEmpty) {
final s = _pptx!.slides[_pageIndex.clamp(0, _pptx!.slides.length - 1)];
return Size(s.width, s.height);
}
return const Size(800, 1100);
}
}
class _InkStroke {
_InkStroke({required this.points, required this.pressures});
final List<Offset> points;
final List<double> pressures;
Map<String, dynamic> toJson() => {
'points': [
for (final p in points) {'x': p.dx, 'y': p.dy},
],
'pressures': pressures,
};
factory _InkStroke.fromJson(Map<String, dynamic> json) {
final pts = (json['points'] as List<dynamic>)
.map((e) => Offset(
(e['x'] as num).toDouble(),
(e['y'] as num).toDouble(),
))
.toList();
final pr = (json['pressures'] as List<dynamic>?)
?.map((e) => (e as num).toDouble())
.toList() ??
List.filled(pts.length, 0.5);
return _InkStroke(points: pts, pressures: pr);
}
}
class _OfficePagePainter extends CustomPainter {
_OfficePagePainter({
required this.pptx,
required this.docx,
required this.pageIndex,
required this.strokes,
required this.live,
});
final ParsedPptx? pptx;
final ParsedDocx? docx;
final int pageIndex;
final List<_InkStroke> strokes;
final _InkStroke? live;
@override
void paint(Canvas canvas, Size size) {
final bg = Paint()..color = AppTokens.paper;
canvas.drawRect(Offset.zero & size, bg);
if (pptx != null && pptx!.slides.isNotEmpty) {
final slide = pptx!.slides[pageIndex.clamp(0, pptx!.slides.length - 1)];
final border = Paint()
..color = AppTokens.rule
..style = PaintingStyle.stroke;
canvas.drawRect(Offset.zero & Size(slide.width, slide.height), border);
for (final run in slide.runs) {
final tp = TextPainter(
text: TextSpan(
text: run.text,
style: TextStyle(
color: AppTokens.ink,
fontSize: run.fontSize,
),
),
textDirection: TextDirection.ltr,
)..layout(maxWidth: run.width > 0 ? run.width : slide.width - 96);
tp.paint(canvas, Offset(run.x, run.y));
}
} else if (docx != null) {
final start = pageIndex * 12;
final blocks = docx!.blocks.skip(start).take(12).toList();
var y = 48.0;
for (final b in blocks) {
final style = TextStyle(
color: AppTokens.ink,
fontSize: b.type == DocBlockType.heading ? 22 - b.level * 2.0 : 15,
fontWeight:
b.type == DocBlockType.heading ? FontWeight.w700 : FontWeight.w400,
);
final tp = TextPainter(
text: TextSpan(text: b.text, style: style),
textDirection: TextDirection.ltr,
)..layout(maxWidth: size.width - 96);
tp.paint(canvas, Offset(48, y));
y += tp.height + 12;
}
}
final ink = Paint()
..color = AppTokens.copper
..style = PaintingStyle.stroke
..strokeWidth = 2.2
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round;
for (final s in [...strokes, if (live != null) live!]) {
if (s.points.length < 2) continue;
final path = Path()..moveTo(s.points.first.dx, s.points.first.dy);
for (var i = 1; i < s.points.length; i++) {
path.lineTo(s.points[i].dx, s.points[i].dy);
}
canvas.drawPath(path, ink);
}
}
@override
bool shouldRepaint(covariant _OfficePagePainter oldDelegate) => true;
}

View File

@@ -25,11 +25,13 @@ import '../engine/brush.dart';
import '../engine/stroke_eraser.dart';
import '../engine/stroke_geometry.dart' show kDefaultPenThinning;
import '../engine/stroke_model.dart';
import '../engine/stroke_predictor.dart';
import '../engine/stroke_store.dart';
import '../input/input_arbiter.dart' as arbiter;
import '../input/pen_config.dart';
import '../input/pressure_curve.dart';
import '../input/pen_input_service.dart';
import '../../diagnostics/pen_event_ring.dart';
import '../engine/shape_geometry.dart';
import '../render/ink_picture_cache.dart';
import '../render/live_ink_painter.dart' as render;
@@ -210,6 +212,9 @@ class _PenCanvasState extends State<PenCanvas> {
/// In-progress stroke points (normalized).
final List<PenPoint> _livePoints = [];
final StrokePredictor _predictor = StrokePredictor();
/// Count of real (non-predicted) points in [_livePoints].
int _realPointCount = 0;
/// Live stroke snapshot handed to the LiveInkPainter; null when idle.
PenStroke? _liveStroke;
@@ -407,12 +412,21 @@ class _PenCanvasState extends State<PenCanvas> {
/// Decide whether the gesture currently forming should DRAW. Delegates to the
/// pure [arbiter.shouldDraw] (unit-tested truth table) so the live canvas and
/// the tests can never disagree on the rule.
bool _shouldDraw(PointerDeviceKind kind) => arbiter.shouldDraw(
activePointerCount: _activePointers.length,
kind: kind,
fingerDrawingEnabled: _fingerDrawingEnabled,
hwPanActive: _hwPanActive,
);
bool _shouldDraw(PointerDeviceKind kind) {
final draw = arbiter.shouldDraw(
activePointerCount: _activePointers.length,
kind: kind,
fingerDrawingEnabled: _fingerDrawingEnabled,
hwPanActive: _hwPanActive,
);
PenEventRing.instance.recordArbiter(
activeCount: _activePointers.length,
deviceKind: kind.name,
draw: draw,
fingerDrawing: _fingerDrawingEnabled,
);
return draw;
}
// --- Coordinate mapping ---------------------------------------------------
@@ -437,6 +451,8 @@ class _PenCanvasState extends State<PenCanvas> {
void _startStroke(PointerDownEvent event) {
_drawPointer = event.pointer;
_livePoints.clear();
_realPointCount = 0;
_predictor.reset();
_shapeStart = null;
_selectLast = null;
_selectDragging = false;
@@ -469,7 +485,10 @@ class _PenCanvasState extends State<PenCanvas> {
return;
}
if (p != null) _livePoints.add(p);
if (p != null) {
_livePoints.add(p);
_realPointCount = _livePoints.length;
}
_updateLiveStroke();
}
@@ -507,7 +526,21 @@ class _PenCanvasState extends State<PenCanvas> {
return;
}
// Drop previous predicted tip before appending the real sample.
if (_livePoints.length > _realPointCount) {
_livePoints.removeRange(_realPointCount, _livePoints.length);
}
_livePoints.add(p);
_realPointCount = _livePoints.length;
final pred = _predictor.observe(Offset(p.x, p.y), p.pressure ?? 0.5);
if (pred != null) {
_livePoints.add(PenPoint(
pred.offset.dx.clamp(0.0, 1.0),
pred.offset.dy.clamp(0.0, 1.0),
pred.pressure,
tilt: p.tilt,
));
}
_updateLiveStroke();
}
@@ -533,6 +566,10 @@ class _PenCanvasState extends State<PenCanvas> {
} else if (tool == CanvasTool.select) {
// Nothing to commit on release: selection + moves were applied live.
} else if (!wasEraser && _livePoints.isNotEmpty) {
// Never commit predicted tips — only real digitizer samples.
if (_livePoints.length > _realPointCount) {
_livePoints.removeRange(_realPointCount, _livePoints.length);
}
widget.onStrokeComplete(
PenStroke(
points: List.of(_livePoints),
@@ -548,6 +585,8 @@ class _PenCanvasState extends State<PenCanvas> {
_selectLast = null;
_selectDragging = false;
_livePoints.clear();
_realPointCount = 0;
_predictor.reset();
_eraserCursor.value = null; // hide the preview when the pen lifts
setState(() => _liveStroke = null);
}

View File

@@ -25,12 +25,11 @@ const double kDefaultPenThinning = 0.85;
/// perfect_freehand input-smoothing parameters, shared (single source of truth)
/// by the on-screen painter and the export path so the two can never diverge
/// (guarded by the screen==export parity test). [kPenStreamline] lowers the
/// per-point lag from freehand's 0.5 default to 0.32: at 0.5 a quick flick lags
/// so far behind the pen that a short fast stroke collapsed toward its start and
/// rendered as a dot ("写字识别成单击") and the pen felt sluggish; 0.32 tracks the
/// real path closely (crisper, lower-latency feel) while still damping digitizer
/// jitter. [kPenSmoothing] keeps freehand's 0.5 corner rounding.
const double kPenStreamline = 0.32;
/// per-point lag from freehand's 0.5 default to 0.28: paired with
/// [StrokePredictor] lookahead this tracks the Surface Pen more tightly while
/// still damping digitizer jitter. [kPenSmoothing] keeps freehand's 0.5 corner
/// rounding.
const double kPenStreamline = 0.28;
const double kPenSmoothing = 0.5;
/// THE single perfect_freehand outline recipe — the raw outline points for a

View File

@@ -0,0 +1,56 @@
// Lightweight stroke prediction — extrapolates the next point from recent
// velocity so the live stroke tip leads the digitizer slightly (lower perceived
// latency). Not a full ink-stroke-modeler; intentionally small and testable.
import 'dart:ui';
class PredictedPoint {
const PredictedPoint(this.offset, this.pressure);
final Offset offset;
final double pressure;
}
class StrokePredictor {
StrokePredictor({this.lookaheadMs = 12});
/// How far ahead to project, in milliseconds of recent velocity.
final double lookaheadMs;
Offset? _prev;
double? _prevPressure;
DateTime? _prevAt;
Offset _velocity = Offset.zero;
void reset() {
_prev = null;
_prevPressure = null;
_prevAt = null;
_velocity = Offset.zero;
}
/// Feed a real sample; returns an optional predicted tip ahead of [point].
PredictedPoint? observe(Offset point, double pressure, {DateTime? at}) {
final now = at ?? DateTime.now();
if (_prev != null && _prevAt != null) {
final dtMs = now.difference(_prevAt!).inMicroseconds / 1000.0;
if (dtMs > 0.5 && dtMs < 80) {
final raw = (point - _prev!) * (1000.0 / dtMs);
// EMA blend to avoid jerky predictions.
_velocity = Offset(
_velocity.dx * 0.55 + raw.dx * 0.45,
_velocity.dy * 0.55 + raw.dy * 0.45,
);
}
}
_prev = point;
_prevPressure = pressure;
_prevAt = now;
if (_velocity.distance < 40) return null; // idle / slow — no predict
final tip = point + _velocity * (lookaheadMs / 1000.0);
return PredictedPoint(tip, pressure);
}
/// Last known pressure (for predicted tip).
double get lastPressure => _prevPressure ?? 0.5;
}

View File

@@ -1,90 +1,38 @@
// lib/editor/input/diagnostic_logger.dart
//
// On-device input diagnostics. When the user manually enables the diagnostic
// (the toolbar toggle), every native pen event (raw button/flag/tilt fields)
// and every zoom frame is emitted through the standard `dart:developer` log
// channel (name 'badnote.input') — capturable via `flutter run`, DevTools, or
// any log tool — AND mirrored to a text file as a fallback for the packaged
// GUI build, which has no attached console. Disabled by default (no overhead).
// Compatibility facade over [BadNoteLog] + [PenEventRing]. The PDF editor's
// toolbar toggle still calls start/stop; globally, [BadNoteLog.start] runs at
// app launch so packaged builds always have a session file.
import 'dart:async';
import 'dart:developer' as developer;
import 'dart:io';
import 'package:path_provider/path_provider.dart';
import '../../diagnostics/badnote_log.dart';
class DiagnosticLogger {
DiagnosticLogger._();
static final DiagnosticLogger instance = DiagnosticLogger._();
final List<String> _buffer = <String>[];
File? _file;
Timer? _timer;
int _epochMs = 0;
bool _verbose = false;
bool get isActive => _verbose || BadNoteLog.instance.path != null;
bool _active = false;
bool get isActive => _active;
/// Absolute path of the structured session log (preferred), else null.
String? get path => BadNoteLog.instance.path;
/// Absolute path of the current log file (shown in the overlay), or null.
String? path;
/// Begin a session. Enables the `dart:developer` log channel immediately and
/// opens the fallback file (best-effort). Safe to call repeatedly.
/// Begin a verbose input session (also ensures the global log is running).
Future<void> start() async {
if (_active) return;
_active = true; // developer.log works even if the file can't be opened
_epochMs = DateTime.now().millisecondsSinceEpoch;
developer.log('--- session start ${DateTime.now().toIso8601String()} ---',
name: 'badnote.input');
try {
Directory dir;
try {
dir = await getApplicationDocumentsDirectory();
} catch (_) {
dir = await getTemporaryDirectory();
}
final file = File('${dir.path}${Platform.pathSeparator}badnote_input_log.txt');
await file.writeAsString(
'# BadNote input diagnostic log\n'
'# started ${DateTime.now().toIso8601String()}\n'
'# columns: <ms> <kind> <fields...>\n',
flush: true,
);
_file = file;
path = file.path;
_buffer.clear();
_timer = Timer.periodic(const Duration(seconds: 1), (_) => _flush());
} catch (_) {
// File is a fallback; never break the app over it.
}
_verbose = true;
await BadNoteLog.instance.start();
BadNoteLog.instance.info(LogSubsystem.diag, 'verbose_input_on');
}
/// Emit one diagnostic line through the standard log channel and the file.
void log(String line) {
if (!_active) return;
developer.log(line, name: 'badnote.input');
if (_file == null) return;
final t = DateTime.now().millisecondsSinceEpoch - _epochMs;
_buffer.add('$t $line');
if (_buffer.length >= 1000) _flush();
BadNoteLog.instance.debug(LogSubsystem.penNative, line);
}
Future<void> _flush() async {
final file = _file;
if (file == null || _buffer.isEmpty) return;
final chunk = '${_buffer.join('\n')}\n';
_buffer.clear();
try {
await file.writeAsString(chunk, mode: FileMode.append, flush: true);
} catch (_) {}
}
/// Flush and stop. The file remains on disk for retrieval.
Future<void> stop() async {
if (!_active) return;
_active = false;
_timer?.cancel();
_timer = null;
await _flush();
if (!_verbose) return;
_verbose = false;
BadNoteLog.instance.info(LogSubsystem.diag, 'verbose_input_off');
await BadNoteLog.instance.flush();
}
}

View File

@@ -23,6 +23,8 @@ import 'dart:async';
import 'package:flutter/services.dart';
import '../../diagnostics/badnote_log.dart';
import '../../diagnostics/pen_event_ring.dart';
import 'diagnostic_logger.dart';
/// Latest hardware pen state delivered by the native observer.
@@ -169,6 +171,29 @@ class PenInputService {
final key = '$rawPtr,$rawPen,$rawMask,$btnChange,${_current.tiltX},${_current.tiltY}';
if (key != _lastPenLogKey) {
_lastPenLogKey = key;
PenEventRing.instance.recordHardware(
barrel: _current.barrel,
eraser: _current.eraser,
inverted: _current.inverted,
tiltX: _current.tiltX,
tiltY: _current.tiltY,
);
BadNoteLog.instance.debug(
LogSubsystem.penNative,
'pen_hw',
fields: {
'ptrFlags': '0x${rawPtr.toRadixString(16)}',
'penFlags': '0x${rawPen.toRadixString(16)}',
'mask': '0x${rawMask.toRadixString(16)}',
'btnChg': btnChange,
'tiltX': _current.tiltX,
'tiltY': _current.tiltY,
'resolved': '0x${flags.toRadixString(16)}',
'barrel': _current.barrel,
'eraser': _current.eraser,
'inverted': _current.inverted,
},
);
DiagnosticLogger.instance.log(
'PEN ptrFlags=0x${rawPtr.toRadixString(16)} '
'penFlags=0x${rawPen.toRadixString(16)} '

View 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();
}
}

View File

@@ -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'),
])),
],
),
),
),
);
}
}

View File

@@ -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;
}

View File

@@ -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),
),
);
}

View File

@@ -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;
}

10
lib/editor/stroke.dart Normal file
View File

@@ -0,0 +1,10 @@
// Canonical stroke model surface.
//
// Historical baggage had three parallel types (PenStroke / EditorStroke /
// InkStroke). New code MUST import from this barrel and prefer [EditorStroke]
// for engine/storage. UI adapters convert at the edge.
//
// Do not add a fourth model.
export '../engine/stroke_model.dart' show EditorStroke, EditorPoint, EditorTool;
export '../canvas/pen_stroke.dart' show PenStroke, PenPoint, PenStrokeKind;