diff --git a/lib/session/session_controller.dart b/lib/session/session_controller.dart index 2faa991..51a1ca7 100644 --- a/lib/session/session_controller.dart +++ b/lib/session/session_controller.dart @@ -41,6 +41,7 @@ class SessionController { final ValueNotifier _logTick = ValueNotifier(0); final ValueNotifier _snapshot = ValueNotifier(StatusSnapshot.empty()); + final ValueNotifier cursorEnabled = ValueNotifier(false); ValueListenable get frameTick => _frameTick; ValueListenable get logTick => _logTick; @@ -138,6 +139,7 @@ class SessionController { _frameTick.dispose(); _logTick.dispose(); _snapshot.dispose(); + cursorEnabled.dispose(); await decoder.dispose(); await transport.dispose(); viewState.dispose(); diff --git a/lib/ui/chart_painter.dart b/lib/ui/chart_painter.dart index cbc4068..67fbdd3 100644 --- a/lib/ui/chart_painter.dart +++ b/lib/ui/chart_painter.dart @@ -11,11 +11,15 @@ class ChartPainter extends CustomPainter { required this.channel, required this.session, required this.layout, + required this.isDark, + this.hoverLocalPos, }); final int channel; final SessionController session; final LayoutController layout; + final bool isDark; + final Offset? hoverLocalPos; @override void paint(Canvas canvas, Size size) { @@ -24,8 +28,19 @@ class ChartPainter extends CustomPainter { final plotH = size.height - padTop - padBottom; if (plotW <= 0 || plotH <= 0) return; + // Theme-dependent palette. + final bgColor = isDark ? const Color(0xFF1E1F22) : const Color(0xFFF7F7F4); + final lineColor = + isDark ? const Color(0xFF5EA8FF) : const Color(0xFF185FA5); + final hatchColor = + isDark ? const Color(0x66D17050) : const Color(0x55993C1D); + final markerColor = + isDark ? const Color(0xCCD17050) : const Color(0xAA993C1D); + final cursorColor = + isDark ? const Color(0xCCBBBBBB) : const Color(0xAA444444); + // Background. - final bgPaint = Paint()..color = const Color(0xFFF7F7F4); + final bgPaint = Paint()..color = bgColor; canvas.drawRect( Rect.fromLTWH(padLeft, padTop, plotW, plotH), bgPaint, @@ -86,7 +101,7 @@ class ChartPainter extends CustomPainter { // Hatched gap rectangles. final hatchPaint = Paint() - ..color = const Color(0x55993C1D) + ..color = hatchColor ..style = PaintingStyle.fill; for (final g in gaps.hatchedRanges) { final x0 = xForCol(g.startPx); @@ -99,7 +114,7 @@ class ChartPainter extends CustomPainter { // Polyline. final linePaint = Paint() - ..color = const Color(0xFF185FA5) + ..color = lineColor ..strokeWidth = 1.2 ..style = PaintingStyle.stroke; final path = Path(); @@ -132,7 +147,7 @@ class ChartPainter extends CustomPainter { // Sub-pixel marker lines. final markerPaint = Paint() - ..color = const Color(0xAA993C1D) + ..color = markerColor ..strokeWidth = 1; for (final px in gaps.markerPixels) { final x = xForCol(px); @@ -143,8 +158,56 @@ class ChartPainter extends CustomPainter { ); } + // Cursor crosshair + data dot (still inside the plot clip). + int? hoverCol; + double? hoverY; + int? hoverTsUs; + if (hoverLocalPos != null) { + final hx = hoverLocalPos!.dx; + final hy = hoverLocalPos!.dy; + if (hx >= padLeft && + hx <= padLeft + plotW && + hy >= padTop && + hy <= padTop + plotH) { + final col = (hx - padLeft).floor().clamp(0, cols.length - 1); + hoverCol = col; + hoverTsUs = win.startUs + + (((col + 0.5) / plotW) * (win.endUs - win.startUs)).round(); + final cursorPaint = Paint() + ..color = cursorColor + ..strokeWidth = 1; + canvas.drawLine( + Offset(xForCol(col), padTop), + Offset(xForCol(col), padTop + plotH), + cursorPaint, + ); + if (cols[col].hasData) { + hoverY = (cols[col].min + cols[col].max) / 2; + final yPx = yForVal(hoverY); + canvas.drawLine( + Offset(padLeft, yPx), + Offset(padLeft + plotW, yPx), + cursorPaint, + ); + canvas.drawCircle( + Offset(xForCol(col), yPx), + 3, + Paint()..color = lineColor, + ); + } + } + } + canvas.restore(); + // Cursor readout box (outside the clip so it can overlay labels). + if (hoverCol != null) { + final tStr = hoverTsUs != null ? _fmtTime(hoverTsUs) : '—'; + final yStr = hoverY != null ? hoverY.toStringAsFixed(3) : '—'; + _drawReadout(canvas, 'x=$tStr y=$yStr', + Offset(padLeft + 4, padTop + 2)); + } + // Axes labels. _drawText(canvas, '${yMax.toStringAsFixed(2)}', Offset(padLeft - 4, padTop), align: TextAlign.right); @@ -157,6 +220,44 @@ class ChartPainter extends CustomPainter { align: TextAlign.right); } + void _drawReadout(Canvas c, String s, Offset where) { + final textColor = + isDark ? const Color(0xFFEEEEEE) : const Color(0xFF222222); + final bgColor = + isDark ? const Color(0xCC2A2B2E) : const Color(0xCCFFFFFF); + final borderColor = + isDark ? const Color(0x66FFFFFF) : const Color(0x44000000); + final tp = TextPainter( + text: TextSpan( + text: s, + style: TextStyle( + fontFamily: 'monospace', + fontSize: 10, + color: textColor, + ), + ), + textDirection: TextDirection.ltr, + )..layout(); + final bgRect = Rect.fromLTWH( + where.dx, + where.dy, + tp.width + 8, + tp.height + 4, + ); + c.drawRRect( + RRect.fromRectAndRadius(bgRect, const Radius.circular(3)), + Paint()..color = bgColor, + ); + c.drawRRect( + RRect.fromRectAndRadius(bgRect, const Radius.circular(3)), + Paint() + ..color = borderColor + ..style = PaintingStyle.stroke + ..strokeWidth = 0.5, + ); + tp.paint(c, Offset(where.dx + 4, where.dy + 2)); + } + void _drawText(Canvas c, String s, Offset where, {TextAlign align = TextAlign.left}) { final tp = TextPainter( diff --git a/lib/ui/chart_widget.dart b/lib/ui/chart_widget.dart index afb1eea..4097d16 100644 --- a/lib/ui/chart_widget.dart +++ b/lib/ui/chart_widget.dart @@ -19,6 +19,13 @@ class ChartWidget extends StatefulWidget { 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) { @@ -35,29 +42,50 @@ class _ChartWidgetState extends State { _Header(channel: widget.channel), Expanded( child: LayoutBuilder(builder: (context, constraints) { - return Listener( - onPointerSignal: (ev) => - _onPointerSignal(ev, constraints), - child: GestureDetector( - onDoubleTap: () => _resetY(), - onHorizontalDragStart: (d) => _dragStartHandler(d), - onHorizontalDragUpdate: (d) => - _dragUpdate(d, constraints), - onHorizontalDragEnd: (_) => _dragStart = null, - child: ValueListenableBuilder( - valueListenable: scope.session.frameTick, - builder: (_, __, ___) { - return CustomPaint( - painter: ChartPainter( - channel: widget.channel, - session: scope.session, - layout: scope.layout, + 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, + ); + }, ), - size: Size.infinite, - ); - }, - ), - ), + ), + ), + ); + }, ); }), ), diff --git a/lib/ui/dialogs/layout_dialog.dart b/lib/ui/dialogs/layout_dialog.dart index e3b9a00..6b9cc3a 100644 --- a/lib/ui/dialogs/layout_dialog.dart +++ b/lib/ui/dialogs/layout_dialog.dart @@ -125,10 +125,11 @@ class _LayoutDialogState extends State { } Widget _gridPreview() { + final scheme = Theme.of(context).colorScheme; return Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( - color: Colors.grey.shade100, + color: scheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(6), ), child: GridView.builder( @@ -148,7 +149,7 @@ class _LayoutDialogState extends State { return Container( decoration: BoxDecoration( border: Border.all( - color: Colors.grey.shade400, + color: scheme.outlineVariant, style: BorderStyle.solid, ), borderRadius: BorderRadius.circular(4), @@ -159,16 +160,22 @@ class _LayoutDialogState extends State { crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text('$cellNumber', - style: const TextStyle( - fontFamily: 'monospace', - fontSize: 10, - color: Colors.grey)), - const Text('empty', - style: TextStyle( - fontSize: 11, - color: Colors.grey, - fontStyle: FontStyle.italic)), + Text( + '$cellNumber', + style: TextStyle( + fontFamily: 'monospace', + fontSize: 10, + color: scheme.onSurfaceVariant, + ), + ), + Text( + 'empty', + style: TextStyle( + fontSize: 11, + color: scheme.onSurfaceVariant, + fontStyle: FontStyle.italic, + ), + ), ], ), ), @@ -177,8 +184,8 @@ class _LayoutDialogState extends State { final cfg = _draft.configFor(ch); return Container( decoration: BoxDecoration( - color: Colors.white, - border: Border.all(color: Colors.grey.shade400), + color: scheme.surface, + border: Border.all(color: scheme.outlineVariant), borderRadius: BorderRadius.circular(4), ), child: Padding( @@ -187,15 +194,23 @@ class _LayoutDialogState extends State { crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text('$cellNumber', - style: const TextStyle( - fontFamily: 'monospace', - fontSize: 10, - color: Colors.grey)), - Text('CH$ch · ${cfg.name}', - style: const TextStyle( - fontSize: 11, fontWeight: FontWeight.w500), - overflow: TextOverflow.ellipsis), + Text( + '$cellNumber', + style: TextStyle( + fontFamily: 'monospace', + fontSize: 10, + color: scheme.onSurfaceVariant, + ), + ), + Text( + 'CH$ch · ${cfg.name}', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w500, + color: scheme.onSurface, + ), + overflow: TextOverflow.ellipsis, + ), ], ), ), diff --git a/lib/ui/mini_log_panel.dart b/lib/ui/mini_log_panel.dart index 2e9fdcf..17535c3 100644 --- a/lib/ui/mini_log_panel.dart +++ b/lib/ui/mini_log_panel.dart @@ -26,11 +26,11 @@ class MiniLogPanel extends StatelessWidget { padding: EdgeInsets.symmetric(horizontal: 12, vertical: 6), child: Row( children: [ - Text('Errors & fatals', + Text('Filtered log', style: TextStyle( fontWeight: FontWeight.w500, fontSize: 12)), SizedBox(width: 6), - Text('· filtered view', + Text('· severities from settings', style: TextStyle(fontSize: 11, color: Colors.grey)), ], ), diff --git a/lib/ui/toolbar.dart b/lib/ui/toolbar.dart index e6c5ec6..b6c3ccd 100644 --- a/lib/ui/toolbar.dart +++ b/lib/ui/toolbar.dart @@ -58,6 +58,25 @@ class Toolbar extends StatelessWidget { icon: const Icon(Icons.fit_screen), label: const Text('Reset view'), ), + const SizedBox(width: 6), + ValueListenableBuilder( + valueListenable: scope.session.cursorEnabled, + builder: (_, on, __) { + final scheme = Theme.of(context).colorScheme; + return OutlinedButton.icon( + onPressed: () => + scope.session.cursorEnabled.value = !on, + icon: const Icon(Icons.my_location), + label: const Text('Cursor'), + style: on + ? OutlinedButton.styleFrom( + backgroundColor: scheme.primaryContainer, + foregroundColor: scheme.onPrimaryContainer, + ) + : null, + ); + }, + ), const _Sep(), OutlinedButton.icon( onPressed: () => showDialog(