Files
BadNote/lib/editor/pdf/spike_app.dart
Akiba So ee1b3a39f1
Some checks failed
CI / Windows build (push) Has been cancelled
feat(editor): M1 pdfrx spike + pen/touch capture
Add pdfrx 2.4.4. PenCaptureRegion routes stylus to ink (arena-bypass
via PenCaptureBinding) while touch falls through to pdfrx scroll/zoom.
Spike pane hosts PdfViewer with page-overlay ink at normalized coords +
frame-time HUD; reachable from home screen for on-device testing.
Bench/coordinate harness under integration_test. Generated assets are
gitignored (regenerate via tool/gen_*.dart).
2026-06-21 20:15:21 +08:00

188 lines
5.8 KiB
Dart

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