Some checks failed
CI / Windows build (push) Has been cancelled
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).
188 lines
5.8 KiB
Dart
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'),
|
|
])),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|