import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../layout/chart_config.dart'; import 'app_scope.dart'; import 'chart_painter.dart'; /// One chart cell: header + painted plot area. Listens to frameTick for /// repaints, and translates pointer events into view-state and y-zoom edits. class ChartWidget extends StatefulWidget { const ChartWidget({super.key, required this.channel}); final int channel; @override State createState() => _ChartWidgetState(); } class _ChartWidgetState extends State { Offset? _dragStart; int? _dragStartUs; final ValueNotifier _hover = ValueNotifier(null); @override void dispose() { _hover.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final scope = AppScope.of(context); return Container( decoration: BoxDecoration( border: Border.all(color: Theme.of(context).colorScheme.outlineVariant), borderRadius: BorderRadius.circular(6), ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _Header(channel: widget.channel), Expanded( child: LayoutBuilder(builder: (context, constraints) { return ValueListenableBuilder( valueListenable: scope.session.cursorEnabled, builder: (_, cursorOn, __) { return MouseRegion( cursor: cursorOn ? SystemMouseCursors.precise : MouseCursor.defer, onHover: cursorOn ? (e) => _hover.value = e.localPosition : null, onExit: cursorOn ? (_) => _hover.value = null : null, child: Listener( onPointerSignal: (ev) => _onPointerSignal(ev, constraints), child: GestureDetector( onDoubleTap: () => _resetY(), onHorizontalDragStart: (d) => _dragStartHandler(d), onHorizontalDragUpdate: (d) => _dragUpdate(d, constraints), onHorizontalDragEnd: (_) => _dragStart = null, child: ListenableBuilder( listenable: Listenable.merge([ scope.session.frameTick, _hover, ]), builder: (_, __) { return CustomPaint( painter: ChartPainter( channel: widget.channel, session: scope.session, layout: scope.layout, isDark: Theme.of(context).brightness == Brightness.dark, hoverLocalPos: cursorOn ? _hover.value : null, ), size: Size.infinite, ); }, ), ), ), ); }, ); }), ), ], ), ); } // Modifier conventions: // Ctrl/Meta + wheel → Y-zoom (per-chart). // Shift + wheel → X-zoom (shared). // Bare wheel → bubble to the outer GridView scroll. void _onPointerSignal(PointerSignalEvent ev, BoxConstraints c) { if (ev is! PointerScrollEvent) return; final ctrl = HardwareKeyboard.instance.isControlPressed || HardwareKeyboard.instance.isMetaPressed; final shift = HardwareKeyboard.instance.isShiftPressed; if (!ctrl && !shift) return; // Let the grid scroll handle it. GestureBinding.instance.pointerSignalResolver.register(ev, (e) { if (e is! PointerScrollEvent) return; if (ctrl) { _zoomY(e, c); } else { _zoomX(e, c); } }); } void _zoomY(PointerScrollEvent ev, BoxConstraints c) { final scope = AppScope.of(context); final factor = ev.scrollDelta.dy > 0 ? 1.1 : 1 / 1.1; final cfg = scope.layout.configFor(widget.channel); final localY = ev.localPosition.dy; final h = c.maxHeight; final v = cfg.yMax - (localY / h) * (cfg.yMax - cfg.yMin); scope.layout.zoomY( channel: widget.channel, pivotValue: v, factor: factor, ); } void _zoomX(PointerScrollEvent ev, BoxConstraints c) { final scope = AppScope.of(context); final factor = ev.scrollDelta.dy > 0 ? 1.1 : 1 / 1.1; final newest = scope.session.packets.newest; final oldestUs = scope.session.packets.oldest?.timestampUs.toInt() ?? 0; final newestUs = newest?.timestampUs.toInt() ?? DateTime.now().microsecondsSinceEpoch; final win = scope.session.viewState.currentWindow( nowUs: newestUs, oldestUs: oldestUs, newestUs: newestUs, ); final localX = ev.localPosition.dx; final w = c.maxWidth; final pivotUs = win.startUs + ((localX / w) * (win.endUs - win.startUs)).round(); final maxWindow = Duration( microseconds: (newestUs - oldestUs).clamp( const Duration(milliseconds: 10).inMicroseconds, 1 << 62, ), ); scope.session.viewState.zoomAt( pivotUs: pivotUs, factor: factor, maxWindow: maxWindow, oldestUs: oldestUs, newestUs: newestUs, ); } void _dragStartHandler(DragStartDetails d) { final scope = AppScope.of(context); _dragStart = d.localPosition; final newest = scope.session.packets.newest; final newestUs = newest?.timestampUs.toInt() ?? DateTime.now().microsecondsSinceEpoch; _dragStartUs = newestUs; } void _dragUpdate(DragUpdateDetails d, BoxConstraints c) { final scope = AppScope.of(context); if (_dragStart == null || _dragStartUs == null) return; final dxPx = d.localPosition.dx - _dragStart!.dx; final widthUs = scope.session.viewState.windowWidth.inMicroseconds; final usPerPx = widthUs / c.maxWidth; final shiftUs = (-dxPx * usPerPx).round(); final newest = scope.session.packets.newest; final oldestUs = scope.session.packets.oldest?.timestampUs.toInt() ?? 0; final newestUs = newest?.timestampUs.toInt() ?? _dragStartUs!; scope.session.viewState.panBy( Duration(microseconds: shiftUs), oldestUs: oldestUs, newestUs: newestUs, ); _dragStart = d.localPosition; } void _resetY() { final scope = AppScope.of(context); scope.layout.setYMode(widget.channel, YMode.auto); } } class _Header extends StatelessWidget { const _Header({required this.channel}); final int channel; @override Widget build(BuildContext context) { final scope = AppScope.of(context); return ListenableBuilder( listenable: scope.layout, builder: (_, __) { final cfg = scope.layout.configFor(channel); final modeTag = switch (cfg.yMode) { YMode.auto => 'auto', YMode.fixed => 'fixed', YMode.userZoomed => 'user', }; return Padding( padding: const EdgeInsets.fromLTRB(8, 4, 8, 4), child: Row( children: [ Text('CH$channel · ${cfg.name}', style: const TextStyle( fontWeight: FontWeight.w500, fontSize: 12)), const Spacer(), Text( '$modeTag · y: ${cfg.yMin.toStringAsFixed(2)} → ' '${cfg.yMax.toStringAsFixed(2)}', style: const TextStyle(fontSize: 10, color: Colors.grey), ), ], ), ); }, ); } }