From 4274d3f13e39d4640644acec2b57a4d9a9655bd7 Mon Sep 17 00:00:00 2001 From: Robert Santucci Date: Fri, 2 Oct 2026 11:34:48 -0500 Subject: [PATCH 1/6] Add AppLog and LogPanel: an application log and a side panel to read it --- lib/flutter_controls_core.dart | 6 + lib/src/logging/app_log.dart | 199 ++++++++++++++ lib/src/widgets/log_panel.dart | 437 ++++++++++++++++++++++++++++++ test/unit_tests/app_log_test.dart | 154 +++++++++++ test/widget/log_panel_test.dart | 105 +++++++ 5 files changed, 901 insertions(+) create mode 100644 lib/src/logging/app_log.dart create mode 100644 lib/src/widgets/log_panel.dart create mode 100644 test/unit_tests/app_log_test.dart create mode 100644 test/widget/log_panel_test.dart diff --git a/lib/flutter_controls_core.dart b/lib/flutter_controls_core.dart index d8ee48b..d66fb09 100644 --- a/lib/flutter_controls_core.dart +++ b/lib/flutter_controls_core.dart @@ -4,10 +4,12 @@ library; import "package:flutter/material.dart"; import "package:flutter_controls_auth/flutter_controls_auth.dart"; import 'package:go_router/go_router.dart'; + import 'src/app_scaffold.dart'; import 'src/otel_tracing.dart'; export 'src/app_scaffold.dart'; + export 'package:flutter_controls_auth/flutter_controls_auth.dart' show AuthInfo, @@ -19,8 +21,12 @@ export 'package:flutter_controls_auth/flutter_controls_auth.dart' warningBox; export 'src/otel_tracing.dart' show AppTracer, appTracer, Span; +export 'src/logging/app_log.dart' + show AppLog, LogEntry, LogLevel, appLog, logClock; +export 'src/widgets/log_panel.dart' show LogPanel, logPanelWidth; export 'src/widgets/param_panel.dart' show ParameterPanel; export 'src/widgets/param_row.dart' show ParameterPanelRow; + export 'package:bison_design_system/bison_design_system.dart'; /// Entry point for Fermilab applications diff --git a/lib/src/logging/app_log.dart b/lib/src/logging/app_log.dart new file mode 100644 index 0000000..17d3a8d --- /dev/null +++ b/lib/src/logging/app_log.dart @@ -0,0 +1,199 @@ +/// An application log: what the app saw, in order, for the operator. +/// +/// Apps write transitions (a link lost, a write that failed), never every +/// reading, to the shared [appLog] or to their own [AppLog]. The last +/// [AppLog.capacity] entries are kept, and each is mirrored to the terminal +/// or browser console. [LogPanel] shows them. +library; + +import 'dart:async' show Timer; +import 'dart:collection' show ListQueue; + +import 'package:flutter/foundation.dart' + show ChangeNotifier, debugPrint, immutable, visibleForTesting; + +/// `12:00:01.234`, local time. +String logClock(DateTime t) { + String two(int n) => n.toString().padLeft(2, '0'); + final l = t.toLocal(); + return '${two(l.hour)}:${two(l.minute)}:${two(l.second)}.' + '${l.millisecond.toString().padLeft(3, '0')}'; +} + +/// How much an entry matters. +enum LogLevel { + /// Something that happened (a subscription opened, a write confirmed). + info, + + /// Something to look at (a lost link, a gateway flood, PVs failing). + warn, + + /// Something that failed (a write that did not land). + error; + + /// `INFO`, `WARN`, `ERROR`. + String get tag => name.toUpperCase(); +} + +/// One line of the log. +@immutable +class LogEntry { + /// Creates an entry. + const LogEntry(this.time, this.level, this.source, this.message); + + /// When it was logged. + final DateTime time; + + /// How much it matters. + final LogLevel level; + + /// What logged it, chosen by the app (`app`, `link`, `write`, …). + final String source; + + /// What happened, on one line. + final String message; + + /// `12:00:01.234 WARN flood: booster: …`, local time. + String get line => '${logClock(time)} ${level.tag} $source: $message'; + + @override + String toString() => line; +} + +/// A bounded, ordered log the UI can watch. +/// +/// Listeners hear at most one notification per [notifyEvery], so a burst +/// of entries rebuilds a panel once. +class AppLog extends ChangeNotifier { + /// Creates a log keeping the last [capacity] entries; [now] replaces the + /// wall clock (tests). + AppLog({ + this.name, + this.capacity = 500, + this.notifyEvery = const Duration(milliseconds: 250), + this.mirror = true, + DateTime Function()? now, + }) : assert(capacity > 0, 'capacity must be positive'), + now = now ?? DateTime.now; + + /// Put before every mirrored line (the app's name), or nothing. + String? name; + + /// How many entries are kept; the oldest is dropped beyond it. + final int capacity; + + /// Listeners hear at most one notification per this interval. + final Duration notifyEvery; + + /// Whether every entry is also printed (`debugPrint`), after [name] if + /// set: the terminal off the web, the browser console on it. Tests turn + /// it off. + bool mirror; + + /// The clock entries are stamped with. + final DateTime Function() now; + + /// A message longer than this is cut (an exception's text can be long). + static const int maxMessage = 1000; + + final ListQueue _entries = ListQueue(); + List? _view; + int _dropped = 0; + Timer? _notify; + bool _disposed = false; + + /// The entries, oldest first (unmodifiable). + List get entries => _view ??= List.unmodifiable(_entries); + + /// How many entries were dropped for [capacity] since the last [clear]. + int get dropped => _dropped; + + /// The entries at [min] or above, oldest first. + List atLeast(LogLevel min) => min == LogLevel.info + ? entries + : [ + for (final e in _entries) + if (e.level.index >= min.index) e, + ]; + + /// Logs [message] from [source] at [level] and returns the entry. Line + /// breaks in [message] become spaces. + LogEntry add(LogLevel level, String source, String message) { + final e = LogEntry(now(), level, source, _oneLine(message)); + if (_entries.length >= capacity) { + _entries.removeFirst(); + _dropped++; + } + _entries.addLast(e); + _view = null; + if (mirror) debugPrint(name == null ? e.line : '$name ${e.line}'); + _schedule(); + return e; + } + + /// [add] at [LogLevel.info]. + LogEntry info(String source, String message) => + add(LogLevel.info, source, message); + + /// [add] at [LogLevel.warn]. + LogEntry warn(String source, String message) => + add(LogLevel.warn, source, message); + + /// [add] at [LogLevel.error]. + LogEntry error(String source, String message) => + add(LogLevel.error, source, message); + + /// Drops every entry; listeners hear of it at once. + void clear() { + _entries.clear(); + _view = null; + _dropped = 0; + _notify?.cancel(); + _notify = null; + if (!_disposed) notifyListeners(); + } + + /// The entries at [min] or above as text to copy: one + /// [LogEntry.line] per entry, oldest first. + String asText({LogLevel min = LogLevel.info}) => + [for (final e in atLeast(min)) e.line].join('\n'); + + /// Empties the log without telling anyone and forgets a pending + /// notification (tests: each starts from an empty log). + @visibleForTesting + void reset() { + _notify?.cancel(); + _notify = null; + _entries.clear(); + _view = null; + _dropped = 0; + } + + static final RegExp _breaks = RegExp(r'\s*[\r\n]+\s*'); + + static String _oneLine(String message) { + final m = message.replaceAll(_breaks, ' '); + return m.length > maxMessage ? '${m.substring(0, maxMessage)}…' : m; + } + + /// Nobody listening (the panel is closed): nothing to schedule; a panel + /// that opens later reads [entries] as it builds. + void _schedule() { + if (_disposed || _notify != null || !hasListeners) return; + _notify = Timer(notifyEvery, () { + _notify = null; + if (!_disposed) notifyListeners(); + }); + } + + @override + void dispose() { + _disposed = true; + _notify?.cancel(); + _notify = null; + super.dispose(); + } +} + +/// The shared application log (set [AppLog.name] to the app's name). +final AppLog appLog = AppLog(); diff --git a/lib/src/widgets/log_panel.dart b/lib/src/widgets/log_panel.dart new file mode 100644 index 0000000..7764b5f --- /dev/null +++ b/lib/src/widgets/log_panel.dart @@ -0,0 +1,437 @@ +/// A side panel showing an [AppLog]: newest first, filtered to every entry, +/// warnings and errors, or errors only. Copy puts the entries shown on the +/// clipboard (oldest first, one line each); Clear empties the log. +library; + +import 'dart:math' as math; + +import 'package:bison_design_system/bison_design_system.dart' + show BisonThemeTokens, BisonTypographyTokens; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart' show Clipboard, ClipboardData; + +import '../logging/app_log.dart'; + +/// The panel's default width. +const double logPanelWidth = 600; + +/// The panel. +class LogPanel extends StatefulWidget { + /// Creates the panel for [log] (the shared [appLog] when `null`); + /// [onClose] is its ✕. + const LogPanel({ + required this.onClose, + this.log, + this.width = logPanelWidth, + super.key, + }); + + /// Closes the panel. + final VoidCallback onClose; + + /// The log shown, or `null` for [appLog]. + final AppLog? log; + + /// The panel's width. + final double width; + + @override + State createState() => _LogPanelState(); +} + +class _LogPanelState extends State { + LogLevel _min = LogLevel.info; + + AppLog get _log => widget.log ?? appLog; + + Future _copy() async { + final n = _log.atLeast(_min).length; + await Clipboard.setData(ClipboardData(text: _log.asText(min: _min))); + if (!mounted) return; + ScaffoldMessenger.maybeOf(context)?.showSnackBar( + SnackBar( + content: Text('Copied $n log line${n == 1 ? '' : 's'}'), + duration: const Duration(seconds: 2), + ), + ); + } + + @override + Widget build(BuildContext context) { + final look = _Look.of(context); + return Material( + color: look.surface, + elevation: 12, + child: Container( + width: widget.width, + decoration: BoxDecoration( + border: Border(left: BorderSide(color: look.border)), + ), + padding: const EdgeInsets.fromLTRB(8, 4, 8, 6), + child: ListenableBuilder( + listenable: _log, + builder: (context, _) { + final all = _log.entries; + final shown = _log.atLeast(_min); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _header(look, all.length, shown.length), + Divider(height: 9, color: look.border), + Expanded( + child: shown.isEmpty + ? Center( + child: Text( + all.isEmpty + ? 'Nothing logged yet' + : _min == LogLevel.warn + ? 'No warnings or errors' + : 'No errors', + style: look.body.copyWith(color: look.label), + ), + ) + : _list(context, look, shown), + ), + ], + ); + }, + ), + ), + ); + } + + Widget _header(_Look look, int total, int shown) { + final dropped = _log.dropped; + final count = [ + _min == LogLevel.info + ? '$total entr${total == 1 ? 'y' : 'ies'}' + : '$shown of $total', + if (dropped > 0) '$dropped older dropped', + ].join(' · '); + return Row( + children: [ + Text( + 'Log', + style: look.body.copyWith( + fontSize: 13, + fontWeight: FontWeight.bold, + color: look.title, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + count, + key: const ValueKey('log-count'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: look.body.copyWith(color: look.label), + ), + ), + _Filter( + look: look, + value: _min, + onChanged: (l) => setState(() => _min = l), + ), + const SizedBox(width: 8), + _HeaderButton( + look: look, + id: 'log-copy', + icon: Icons.copy, + label: 'Copy', + tooltip: 'copy the entries shown, oldest first, one line each', + onPressed: shown == 0 ? null : _copy, + ), + _HeaderButton( + look: look, + id: 'log-clear', + icon: Icons.delete_outline, + label: 'Clear', + tooltip: 'empty the log', + onPressed: total == 0 ? null : _log.clear, + ), + IconButton( + key: const ValueKey('log-close'), + tooltip: 'close', + iconSize: 16, + color: look.text, + visualDensity: VisualDensity.compact, + onPressed: widget.onClose, + icon: const Icon(Icons.close), + ), + ], + ); + } + + /// Newest first; time, level and source in columns as wide as their + /// widest text, the message wrapping beside them. + Widget _list(BuildContext context, _Look look, List shown) { + final scaler = MediaQuery.textScalerOf(context); + double width(String text) { + final p = TextPainter( + text: TextSpan(text: text, style: look.body), + textDirection: TextDirection.ltr, + textScaler: scaler, + maxLines: 1, + )..layout(); + final w = p.width; + p.dispose(); + return w.ceilToDouble(); + } + + final sources = {for (final e in shown) e.source}; + final columns = ( + time: width('00:00:00.000'), + level: width(LogLevel.error.tag), + source: sources.map(width).fold(0, math.max), + ); + return ListView.builder( + key: const ValueKey('log-list'), + itemCount: shown.length, + itemBuilder: (context, i) => _EntryRow( + look: look, + entry: shown[shown.length - 1 - i], + columns: columns, + ), + ); + } +} + +/// The panel's colours and text style: the Bison tokens when the theme +/// carries them, else the Material colour scheme. +class _Look { + const _Look({ + required this.surface, + required this.border, + required this.title, + required this.text, + required this.muted, + required this.label, + required this.source, + required this.warn, + required this.error, + required this.selected, + required this.disabled, + required this.body, + }); + + factory _Look.of(BuildContext context) { + final theme = Theme.of(context); + final typography = theme.extension(); + final body = + (typography?.bodySmall ?? + theme.textTheme.bodySmall ?? + const TextStyle()) + .copyWith( + fontSize: 11, + fontFeatures: const [FontFeature.tabularFigures()], + ); + final t = theme.extension(); + if (t != null) { + return _Look( + surface: t.surfaceDefault, + border: t.borderPlain, + title: t.textPrimary, + text: t.textPlain, + muted: t.textMuted, + label: t.textSecondary, + source: t.textPrimary, + warn: t.iconWarning, + error: t.textError, + selected: t.surfacePressed, + disabled: t.textDisabled, + body: body.copyWith(color: t.textPlain), + ); + } + final s = theme.colorScheme; + return _Look( + surface: s.surface, + border: s.outlineVariant, + title: s.primary, + text: s.onSurface, + muted: s.onSurfaceVariant, + label: s.onSurfaceVariant, + source: s.primary, + warn: Colors.amber.shade700, + error: s.error, + selected: s.primary.withValues(alpha: 0.16), + disabled: s.onSurface.withValues(alpha: 0.38), + body: body.copyWith(color: s.onSurface), + ); + } + + final Color surface; + final Color border; + final Color title; + final Color text; + final Color muted; + final Color label; + final Color source; + final Color warn; + final Color error; + final Color selected; + final Color disabled; + final TextStyle body; + + /// The colour an entry's level is drawn in. + Color level(LogLevel l) => switch (l) { + LogLevel.info => text, + LogLevel.warn => warn, + LogLevel.error => error, + }; +} + +/// One entry: `12:00:01.234 WARN link message…`. +class _EntryRow extends StatelessWidget { + const _EntryRow({ + required this.look, + required this.entry, + required this.columns, + }); + + final _Look look; + final LogEntry entry; + final ({double time, double level, double source}) columns; + + @override + Widget build(BuildContext context) { + const gap = SizedBox(width: 8); + return Padding( + padding: const EdgeInsets.symmetric(vertical: 1.5), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: columns.time, + child: Text( + logClock(entry.time), + style: look.body.copyWith(color: look.muted), + ), + ), + gap, + SizedBox( + width: columns.level, + child: Text( + entry.level.tag, + style: look.body.copyWith( + color: look.level(entry.level), + fontWeight: entry.level == LogLevel.info + ? FontWeight.normal + : FontWeight.bold, + ), + ), + ), + gap, + SizedBox( + width: columns.source, + child: Text( + entry.source, + style: look.body.copyWith(color: look.source), + ), + ), + gap, + Expanded(child: Text(entry.message, style: look.body)), + ], + ), + ); + } +} + +/// All · Warnings · Errors, joined. +class _Filter extends StatelessWidget { + const _Filter({ + required this.look, + required this.value, + required this.onChanged, + }); + + final _Look look; + final LogLevel value; + final ValueChanged onChanged; + + static String _label(LogLevel l) => switch (l) { + LogLevel.info => 'All', + LogLevel.warn => 'Warnings', + LogLevel.error => 'Errors', + }; + + static String _tip(LogLevel l) => switch (l) { + LogLevel.info => 'every entry', + LogLevel.warn => 'warnings and errors', + LogLevel.error => 'errors only', + }; + + @override + Widget build(BuildContext context) => Container( + height: 24, + decoration: BoxDecoration( + border: Border.all(color: look.border), + borderRadius: BorderRadius.circular(3), + ), + clipBehavior: Clip.antiAlias, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (final l in LogLevel.values) + Tooltip( + message: _tip(l), + child: InkWell( + key: ValueKey('log-filter-${l.name}'), + onTap: () => onChanged(l), + child: Container( + color: l == value ? look.selected : null, + padding: const EdgeInsets.symmetric(horizontal: 8), + alignment: Alignment.center, + child: Text( + _label(l), + style: look.body.copyWith( + fontWeight: FontWeight.w500, + color: l == value ? look.title : look.text, + ), + ), + ), + ), + ), + ], + ), + ); +} + +/// A small text button with an icon, for the header. +class _HeaderButton extends StatelessWidget { + const _HeaderButton({ + required this.look, + required this.id, + required this.icon, + required this.label, + required this.tooltip, + required this.onPressed, + }); + + final _Look look; + final String id; + final IconData icon; + final String label; + final String tooltip; + final VoidCallback? onPressed; + + @override + Widget build(BuildContext context) => Tooltip( + message: tooltip, + child: TextButton.icon( + key: ValueKey(id), + style: TextButton.styleFrom( + visualDensity: VisualDensity.compact, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + minimumSize: const Size(0, 24), + padding: const EdgeInsets.symmetric(horizontal: 6), + foregroundColor: look.text, + disabledForegroundColor: look.disabled, + textStyle: look.body, + ), + onPressed: onPressed, + icon: Icon(icon, size: 14), + label: Text(label), + ), + ); +} diff --git a/test/unit_tests/app_log_test.dart b/test/unit_tests/app_log_test.dart new file mode 100644 index 0000000..7022cdf --- /dev/null +++ b/test/unit_tests/app_log_test.dart @@ -0,0 +1,154 @@ +import 'package:flutter/foundation.dart' show DebugPrintCallback, debugPrint; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:flutter_controls_core/flutter_controls_core.dart' + show AppLog, LogLevel, appLog; + +void main() { + late DateTime clock; + late AppLog log; + + setUp(() { + clock = DateTime(2026, 10, 1, 12, 0, 1, 234); + log = AppLog(mirror: false, now: () => clock); + }); + + tearDown(() => log.dispose()); + + test('keeps the last capacity entries, oldest first, and counts what ' + 'it dropped', () { + final small = AppLog(capacity: 3, mirror: false, now: () => clock); + for (var k = 1; k <= 5; k++) { + small.info('app', 'entry $k'); + } + expect( + [for (final e in small.entries) e.message], + ['entry 3', 'entry 4', 'entry 5'], + ); + expect(small.dropped, 2); + expect( + () => small.entries.add(small.entries.first), + throwsUnsupportedError, + reason: 'unmodifiable', + ); + small.clear(); + expect(small.entries, isEmpty); + expect(small.dropped, 0); + small.dispose(); + }); + + test('the default capacity is 500', () { + for (var k = 0; k < 520; k++) { + log.info('app', '$k'); + } + expect(log.entries, hasLength(500)); + expect(log.entries.first.message, '20'); + expect(log.dropped, 20); + }); + + test('levels: the helpers, the filter and the text to copy', () { + log + ..info('app', 'started') + ..warn('flood', 'gateway flood') + ..error('write', 'TBT ON failed') + ..add(LogLevel.warn, 'link', 'stream error'); + expect( + [for (final e in log.entries) e.level], + [LogLevel.info, LogLevel.warn, LogLevel.error, LogLevel.warn], + ); + expect(log.atLeast(LogLevel.info), hasLength(4)); + expect( + [for (final e in log.atLeast(LogLevel.warn)) e.source], + ['flood', 'write', 'link'], + ); + expect(log.atLeast(LogLevel.error).single.source, 'write'); + expect( + log.asText(), + '12:00:01.234 INFO app: started\n' + '12:00:01.234 WARN flood: gateway flood\n' + '12:00:01.234 ERROR write: TBT ON failed\n' + '12:00:01.234 WARN link: stream error', + ); + expect( + log.asText(min: LogLevel.error), + '12:00:01.234 ERROR write: TBT ON failed', + ); + expect(log.entries.first.toString(), '12:00:01.234 INFO app: started'); + }); + + test('a message is kept on one line, and a huge one is cut', () { + log.info('link', 'stream error: socket closed\n at line 3\r\nend'); + expect( + log.entries.single.message, + 'stream error: socket closed at line 3 end', + ); + log.info('link', 'x' * 5000); + expect(log.entries.last.message, hasLength(AppLog.maxMessage + 1)); + expect(log.entries.last.message, endsWith('…')); + }); + + testWidgets('a burst of entries is one notification, at most every ' + '250 ms', (tester) async { + var notified = 0; + log.addListener(() => notified++); + for (var k = 0; k < 50; k++) { + log.info('pv', 'entry $k'); + } + expect(notified, 0, reason: 'nothing before the interval'); + await tester.pump(const Duration(milliseconds: 249)); + expect(notified, 0); + await tester.pump(const Duration(milliseconds: 1)); + expect(notified, 1); + log.warn('flood', 'one more'); + await tester.pump(const Duration(milliseconds: 100)); + log.warn('flood', 'and another'); + await tester.pump(const Duration(milliseconds: 150)); + expect(notified, 2); + await tester.pump(const Duration(seconds: 1)); + expect(notified, 2, reason: 'quiet: nothing more'); + log.clear(); + expect(notified, 3, reason: 'Clear is shown at once'); + }); + + testWidgets('with nobody listening nothing is scheduled', (tester) async { + log.info('app', 'quiet'); + // No timer may be left pending: the test would fail if one were. + expect(log.entries, hasLength(1)); + }); + + test('every entry is mirrored to the console, unless turned off', () { + final printed = []; + final DebugPrintCallback saved = debugPrint; + debugPrint = (message, {wrapWidth}) => printed.add(message); + try { + final loud = AppLog(name: 'my-app', now: () => clock) + ..warn('flood', 'gateway flood: 1500 junk rows/s'); + expect(printed, [ + 'my-app 12:00:01.234 WARN flood: gateway flood: 1500 junk rows/s', + ]); + loud.mirror = false; + loud.info('app', 'quiet'); + log.error('write', 'not printed either'); + expect(printed, hasLength(1)); + expect(loud.entries, hasLength(2), reason: 'still logged'); + loud.dispose(); + final plain = AppLog(now: () => clock)..info('app', 'no name'); + expect(printed.last, '12:00:01.234 INFO app: no name'); + plain.dispose(); + } finally { + debugPrint = saved; + } + }); + + test('reset empties the shared log without a notification', () { + var notified = 0; + void count() => notified++; + appLog + ..addListener(count) + ..info('app', 'from an earlier test') + ..reset(); + expect(appLog.entries, isEmpty); + expect(notified, 0); + appLog.removeListener(count); + }); +} diff --git a/test/widget/log_panel_test.dart b/test/widget/log_panel_test.dart new file mode 100644 index 0000000..5fb2896 --- /dev/null +++ b/test/widget/log_panel_test.dart @@ -0,0 +1,105 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:flutter_controls_core/flutter_controls_core.dart' + show AppLog, BisonThemeData, BisonThemeTokens, LogPanel; + +Widget _host(AppLog log, {ThemeData? theme, VoidCallback? onClose}) => + MaterialApp( + theme: theme ?? BisonThemeData.dark(), + home: Scaffold( + body: Align( + alignment: Alignment.centerRight, + child: LogPanel(log: log, onClose: onClose ?? () {}), + ), + ), + ); + +AppLog _filled() { + final log = AppLog(mirror: false, now: () => DateTime(2026, 10, 1, 12)); + log + ..info('link', 'subscription opened') + ..warn('flood', 'gateway flood') + ..error('write', 'write failed'); + return log; +} + +Color? _colour(WidgetTester tester, String text) => + tester.widget(find.text(text)).style?.color; + +void main() { + testWidgets('lists newest first, coloured from the Bison theme', ( + tester, + ) async { + final log = _filled(); + await tester.pumpWidget(_host(log)); + final y = [ + for (final m in ['write failed', 'gateway flood', 'subscription opened']) + tester.getTopLeft(find.text(m)).dy, + ]; + expect(y, orderedEquals([...y]..sort())); + final tokens = BisonThemeData.dark().extension()!; + expect(_colour(tester, 'WARN'), tokens.iconWarning); + expect(_colour(tester, 'ERROR'), tokens.textError); + expect(find.text('3 entries'), findsOneWidget); + log.dispose(); + }); + + testWidgets('filters to warnings and errors, then errors', (tester) async { + final log = _filled(); + await tester.pumpWidget(_host(log)); + await tester.tap(find.byKey(const ValueKey('log-filter-warn'))); + await tester.pump(); + expect(find.text('subscription opened'), findsNothing); + expect(find.text('2 of 3'), findsOneWidget); + await tester.tap(find.byKey(const ValueKey('log-filter-error'))); + await tester.pump(); + expect(find.text('gateway flood'), findsNothing); + expect(find.text('write failed'), findsOneWidget); + log.dispose(); + }); + + testWidgets('Copy puts the entries shown on the clipboard, oldest first', ( + tester, + ) async { + String? copied; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (call) async { + if (call.method == 'Clipboard.setData') { + copied = (call.arguments as Map)['text'] as String?; + } + return null; + }, + ); + final log = _filled(); + await tester.pumpWidget(_host(log)); + await tester.tap(find.byKey(const ValueKey('log-copy'))); + await tester.pump(); + expect(copied, log.asText()); + expect(copied!.split('\n').first, endsWith('subscription opened')); + log.dispose(); + }); + + testWidgets('Clear empties the log; ✕ calls onClose', (tester) async { + var closed = 0; + final log = _filled(); + await tester.pumpWidget(_host(log, onClose: () => closed++)); + await tester.tap(find.byKey(const ValueKey('log-clear'))); + await tester.pump(); + expect(log.entries, isEmpty); + expect(find.text('Nothing logged yet'), findsOneWidget); + await tester.tap(find.byKey(const ValueKey('log-close'))); + expect(closed, 1); + log.dispose(); + }); + + testWidgets('works under a plain Material theme too', (tester) async { + final log = _filled(); + await tester.pumpWidget(_host(log, theme: ThemeData.dark())); + expect(tester.takeException(), isNull); + expect(_colour(tester, 'ERROR'), ThemeData.dark().colorScheme.error); + log.dispose(); + }); +} From f54bafdb9478b4ebc46fb67d2ec1a93aed1e4f0b Mon Sep 17 00:00:00 2001 From: Robert Santucci Date: Mon, 5 Oct 2026 08:32:29 -0500 Subject: [PATCH 2/6] LogPanel: icon-only header buttons so the header fits a narrow panel Copy, Clear and close are icon buttons; their tooltips name them. --- lib/src/widgets/log_panel.dart | 48 ++++++++++++--------------------- test/widget/log_panel_test.dart | 33 ++++++++++++++++------- 2 files changed, 40 insertions(+), 41 deletions(-) diff --git a/lib/src/widgets/log_panel.dart b/lib/src/widgets/log_panel.dart index 7764b5f..b352c0e 100644 --- a/lib/src/widgets/log_panel.dart +++ b/lib/src/widgets/log_panel.dart @@ -138,26 +138,22 @@ class _LogPanelState extends State { look: look, id: 'log-copy', icon: Icons.copy, - label: 'Copy', - tooltip: 'copy the entries shown, oldest first, one line each', + tooltip: 'Copy the entries shown', onPressed: shown == 0 ? null : _copy, ), _HeaderButton( look: look, id: 'log-clear', icon: Icons.delete_outline, - label: 'Clear', - tooltip: 'empty the log', + tooltip: 'Clear the log', onPressed: total == 0 ? null : _log.clear, ), - IconButton( - key: const ValueKey('log-close'), - tooltip: 'close', - iconSize: 16, - color: look.text, - visualDensity: VisualDensity.compact, + _HeaderButton( + look: look, + id: 'log-close', + icon: Icons.close, + tooltip: 'Close', onPressed: widget.onClose, - icon: const Icon(Icons.close), ), ], ); @@ -397,13 +393,12 @@ class _Filter extends StatelessWidget { ); } -/// A small text button with an icon, for the header. +/// A small icon button for the header; its tooltip names it. class _HeaderButton extends StatelessWidget { const _HeaderButton({ required this.look, required this.id, required this.icon, - required this.label, required this.tooltip, required this.onPressed, }); @@ -411,27 +406,18 @@ class _HeaderButton extends StatelessWidget { final _Look look; final String id; final IconData icon; - final String label; final String tooltip; final VoidCallback? onPressed; @override - Widget build(BuildContext context) => Tooltip( - message: tooltip, - child: TextButton.icon( - key: ValueKey(id), - style: TextButton.styleFrom( - visualDensity: VisualDensity.compact, - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - minimumSize: const Size(0, 24), - padding: const EdgeInsets.symmetric(horizontal: 6), - foregroundColor: look.text, - disabledForegroundColor: look.disabled, - textStyle: look.body, - ), - onPressed: onPressed, - icon: Icon(icon, size: 14), - label: Text(label), - ), + Widget build(BuildContext context) => IconButton( + key: ValueKey(id), + tooltip: tooltip, + iconSize: 16, + color: look.text, + disabledColor: look.disabled, + visualDensity: VisualDensity.compact, + onPressed: onPressed, + icon: Icon(icon), ); } diff --git a/test/widget/log_panel_test.dart b/test/widget/log_panel_test.dart index 5fb2896..5e5edee 100644 --- a/test/widget/log_panel_test.dart +++ b/test/widget/log_panel_test.dart @@ -5,16 +5,20 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_controls_core/flutter_controls_core.dart' show AppLog, BisonThemeData, BisonThemeTokens, LogPanel; -Widget _host(AppLog log, {ThemeData? theme, VoidCallback? onClose}) => - MaterialApp( - theme: theme ?? BisonThemeData.dark(), - home: Scaffold( - body: Align( - alignment: Alignment.centerRight, - child: LogPanel(log: log, onClose: onClose ?? () {}), - ), - ), - ); +Widget _host( + AppLog log, { + ThemeData? theme, + VoidCallback? onClose, + double width = 600, +}) => MaterialApp( + theme: theme ?? BisonThemeData.dark(), + home: Scaffold( + body: Align( + alignment: Alignment.centerRight, + child: LogPanel(log: log, onClose: onClose ?? () {}, width: width), + ), + ), +); AppLog _filled() { final log = AppLog(mirror: false, now: () => DateTime(2026, 10, 1, 12)); @@ -95,6 +99,15 @@ void main() { log.dispose(); }); + testWidgets('the header fits a narrow panel', (tester) async { + final log = _filled(); + await tester.pumpWidget(_host(log, width: 440)); + expect(tester.takeException(), isNull); + expect(find.byTooltip('Copy the entries shown'), findsOneWidget); + expect(find.byTooltip('Clear the log'), findsOneWidget); + log.dispose(); + }); + testWidgets('works under a plain Material theme too', (tester) async { final log = _filled(); await tester.pumpWidget(_host(log, theme: ThemeData.dark())); From 8e78c447525ae895b17bc590627f80f1146ed431 Mon Sep 17 00:00:00 2001 From: Robert Santucci Date: Mon, 5 Oct 2026 08:44:44 -0500 Subject: [PATCH 3/6] Shorten AppLog and LogPanel --- lib/flutter_controls_core.dart | 3 - lib/src/logging/app_log.dart | 137 +++------- lib/src/widgets/log_panel.dart | 456 ++++++++++++--------------------- 3 files changed, 211 insertions(+), 385 deletions(-) diff --git a/lib/flutter_controls_core.dart b/lib/flutter_controls_core.dart index d66fb09..e02b98a 100644 --- a/lib/flutter_controls_core.dart +++ b/lib/flutter_controls_core.dart @@ -4,12 +4,10 @@ library; import "package:flutter/material.dart"; import "package:flutter_controls_auth/flutter_controls_auth.dart"; import 'package:go_router/go_router.dart'; - import 'src/app_scaffold.dart'; import 'src/otel_tracing.dart'; export 'src/app_scaffold.dart'; - export 'package:flutter_controls_auth/flutter_controls_auth.dart' show AuthInfo, @@ -26,7 +24,6 @@ export 'src/logging/app_log.dart' export 'src/widgets/log_panel.dart' show LogPanel, logPanelWidth; export 'src/widgets/param_panel.dart' show ParameterPanel; export 'src/widgets/param_row.dart' show ParameterPanelRow; - export 'package:bison_design_system/bison_design_system.dart'; /// Entry point for Fermilab applications diff --git a/lib/src/logging/app_log.dart b/lib/src/logging/app_log.dart index 17d3a8d..9258b91 100644 --- a/lib/src/logging/app_log.dart +++ b/lib/src/logging/app_log.dart @@ -1,9 +1,5 @@ -/// An application log: what the app saw, in order, for the operator. -/// -/// Apps write transitions (a link lost, a write that failed), never every -/// reading, to the shared [appLog] or to their own [AppLog]. The last -/// [AppLog.capacity] entries are kept, and each is mirrored to the terminal -/// or browser console. [LogPanel] shows them. +/// An app log for notable events (transitions, not readings), mirrored to +/// the console and shown by `LogPanel`. library; import 'dart:async' show Timer; @@ -12,61 +8,43 @@ import 'dart:collection' show ListQueue; import 'package:flutter/foundation.dart' show ChangeNotifier, debugPrint, immutable, visibleForTesting; -/// `12:00:01.234`, local time. +/// [t] as local `HH:MM:SS.mmm`. String logClock(DateTime t) { - String two(int n) => n.toString().padLeft(2, '0'); final l = t.toLocal(); - return '${two(l.hour)}:${two(l.minute)}:${two(l.second)}.' - '${l.millisecond.toString().padLeft(3, '0')}'; + String p(int n, [int w = 2]) => '$n'.padLeft(w, '0'); + return '${p(l.hour)}:${p(l.minute)}:${p(l.second)}.${p(l.millisecond, 3)}'; } -/// How much an entry matters. enum LogLevel { - /// Something that happened (a subscription opened, a write confirmed). info, - - /// Something to look at (a lost link, a gateway flood, PVs failing). warn, - - /// Something that failed (a write that did not land). error; /// `INFO`, `WARN`, `ERROR`. String get tag => name.toUpperCase(); } -/// One line of the log. @immutable class LogEntry { - /// Creates an entry. const LogEntry(this.time, this.level, this.source, this.message); - /// When it was logged. final DateTime time; - - /// How much it matters. final LogLevel level; - /// What logged it, chosen by the app (`app`, `link`, `write`, …). + /// Who logged it, e.g. `link`. final String source; - - /// What happened, on one line. final String message; - /// `12:00:01.234 WARN flood: booster: …`, local time. + /// `12:00:01.234 WARN link: message`. String get line => '${logClock(time)} ${level.tag} $source: $message'; @override String toString() => line; } -/// A bounded, ordered log the UI can watch. -/// -/// Listeners hear at most one notification per [notifyEvery], so a burst -/// of entries rebuilds a panel once. +/// Keeps the last [capacity] entries; notifies at most once per +/// [notifyEvery]. class AppLog extends ChangeNotifier { - /// Creates a log keeping the last [capacity] entries; [now] replaces the - /// wall clock (tests). AppLog({ this.name, this.capacity = 500, @@ -76,124 +54,91 @@ class AppLog extends ChangeNotifier { }) : assert(capacity > 0, 'capacity must be positive'), now = now ?? DateTime.now; - /// Put before every mirrored line (the app's name), or nothing. + /// Prefix for console lines, e.g. the app's name. String? name; - - /// How many entries are kept; the oldest is dropped beyond it. final int capacity; - - /// Listeners hear at most one notification per this interval. final Duration notifyEvery; - /// Whether every entry is also printed (`debugPrint`), after [name] if - /// set: the terminal off the web, the browser console on it. Tests turn - /// it off. + /// Whether entries also go to `debugPrint`. bool mirror; - - /// The clock entries are stamped with. final DateTime Function() now; - /// A message longer than this is cut (an exception's text can be long). + /// Longer messages are cut. static const int maxMessage = 1000; + static final RegExp _breaks = RegExp(r'\s*[\r\n]+\s*'); - final ListQueue _entries = ListQueue(); - List? _view; + final ListQueue _entries = ListQueue(); int _dropped = 0; Timer? _notify; bool _disposed = false; - /// The entries, oldest first (unmodifiable). - List get entries => _view ??= List.unmodifiable(_entries); + /// Oldest first. + List get entries => List.unmodifiable(_entries); - /// How many entries were dropped for [capacity] since the last [clear]. + /// Entries dropped over [capacity] since the last [clear]. int get dropped => _dropped; - /// The entries at [min] or above, oldest first. - List atLeast(LogLevel min) => min == LogLevel.info - ? entries - : [ - for (final e in _entries) - if (e.level.index >= min.index) e, - ]; + /// Entries at [min] or above, oldest first. + List atLeast(LogLevel min) => + List.unmodifiable(_entries.where((e) => e.level.index >= min.index)); - /// Logs [message] from [source] at [level] and returns the entry. Line - /// breaks in [message] become spaces. + /// Logs [message] on one line, cut to [maxMessage]. LogEntry add(LogLevel level, String source, String message) { - final e = LogEntry(now(), level, source, _oneLine(message)); + var m = message.replaceAll(_breaks, ' '); + if (m.length > maxMessage) m = '${m.substring(0, maxMessage)}…'; + final e = LogEntry(now(), level, source, m); if (_entries.length >= capacity) { _entries.removeFirst(); _dropped++; } _entries.addLast(e); - _view = null; if (mirror) debugPrint(name == null ? e.line : '$name ${e.line}'); - _schedule(); + if (!_disposed && _notify == null && hasListeners) { + _notify = Timer(notifyEvery, () { + _notify = null; + if (!_disposed) notifyListeners(); + }); + } return e; } - /// [add] at [LogLevel.info]. LogEntry info(String source, String message) => add(LogLevel.info, source, message); - /// [add] at [LogLevel.warn]. LogEntry warn(String source, String message) => add(LogLevel.warn, source, message); - /// [add] at [LogLevel.error]. LogEntry error(String source, String message) => add(LogLevel.error, source, message); - /// Drops every entry; listeners hear of it at once. + /// One [LogEntry.line] per entry at [min] or above, oldest first. + String asText({LogLevel min = LogLevel.info}) => + atLeast(min).map((e) => e.line).join('\n'); + + /// Empties the log and notifies at once. void clear() { - _entries.clear(); - _view = null; - _dropped = 0; - _notify?.cancel(); - _notify = null; + _empty(); if (!_disposed) notifyListeners(); } - /// The entries at [min] or above as text to copy: one - /// [LogEntry.line] per entry, oldest first. - String asText({LogLevel min = LogLevel.info}) => - [for (final e in atLeast(min)) e.line].join('\n'); - - /// Empties the log without telling anyone and forgets a pending - /// notification (tests: each starts from an empty log). + /// Empties the log without notifying. @visibleForTesting - void reset() { + void reset() => _empty(); + + void _empty() { _notify?.cancel(); _notify = null; _entries.clear(); - _view = null; _dropped = 0; } - static final RegExp _breaks = RegExp(r'\s*[\r\n]+\s*'); - - static String _oneLine(String message) { - final m = message.replaceAll(_breaks, ' '); - return m.length > maxMessage ? '${m.substring(0, maxMessage)}…' : m; - } - - /// Nobody listening (the panel is closed): nothing to schedule; a panel - /// that opens later reads [entries] as it builds. - void _schedule() { - if (_disposed || _notify != null || !hasListeners) return; - _notify = Timer(notifyEvery, () { - _notify = null; - if (!_disposed) notifyListeners(); - }); - } - @override void dispose() { _disposed = true; _notify?.cancel(); - _notify = null; super.dispose(); } } -/// The shared application log (set [AppLog.name] to the app's name). +/// The shared log. final AppLog appLog = AppLog(); diff --git a/lib/src/widgets/log_panel.dart b/lib/src/widgets/log_panel.dart index b352c0e..64ac586 100644 --- a/lib/src/widgets/log_panel.dart +++ b/lib/src/widgets/log_panel.dart @@ -1,6 +1,5 @@ -/// A side panel showing an [AppLog]: newest first, filtered to every entry, -/// warnings and errors, or errors only. Copy puts the entries shown on the -/// clipboard (oldest first, one line each); Clear empties the log. +/// A side panel listing an [AppLog], newest first, with a level filter, +/// Copy and Clear. library; import 'dart:math' as math; @@ -12,13 +11,9 @@ import 'package:flutter/services.dart' show Clipboard, ClipboardData; import '../logging/app_log.dart'; -/// The panel's default width. const double logPanelWidth = 600; -/// The panel. class LogPanel extends StatefulWidget { - /// Creates the panel for [log] (the shared [appLog] when `null`); - /// [onClose] is its ✕. const LogPanel({ required this.onClose, this.log, @@ -26,13 +21,11 @@ class LogPanel extends StatefulWidget { super.key, }); - /// Closes the panel. + /// Called by the close button. final VoidCallback onClose; - /// The log shown, or `null` for [appLog]. + /// Defaults to [appLog]. final AppLog? log; - - /// The panel's width. final double width; @override @@ -40,12 +33,17 @@ class LogPanel extends StatefulWidget { } class _LogPanelState extends State { + static const _filters = [ + (LogLevel.info, 'All', 'Every entry'), + (LogLevel.warn, 'Warnings', 'Warnings and errors'), + (LogLevel.error, 'Errors', 'Errors only'), + ]; + LogLevel _min = LogLevel.info; AppLog get _log => widget.log ?? appLog; - Future _copy() async { - final n = _log.atLeast(_min).length; + Future _copy(int n) async { await Clipboard.setData(ClipboardData(text: _log.asText(min: _min))); if (!mounted) return; ScaffoldMessenger.maybeOf(context)?.showSnackBar( @@ -58,7 +56,7 @@ class _LogPanelState extends State { @override Widget build(BuildContext context) { - final look = _Look.of(context); + final look = _lookOf(context); return Material( color: look.surface, elevation: 12, @@ -71,23 +69,24 @@ class _LogPanelState extends State { child: ListenableBuilder( listenable: _log, builder: (context, _) { - final all = _log.entries; - final shown = _log.atLeast(_min); + final total = _log.entries.length; + final shown = _log.atLeast(_min).reversed.toList(); + final empty = total == 0 + ? 'Nothing logged yet' + : _min == LogLevel.warn + ? 'No warnings or errors' + : 'No errors'; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - _header(look, all.length, shown.length), + _header(look, total, shown.length), Divider(height: 9, color: look.border), Expanded( child: shown.isEmpty ? Center( child: Text( - all.isEmpty - ? 'Nothing logged yet' - : _min == LogLevel.warn - ? 'No warnings or errors' - : 'No errors', - style: look.body.copyWith(color: look.label), + empty, + style: look.body.copyWith(color: look.muted), ), ) : _list(context, look, shown), @@ -101,13 +100,24 @@ class _LogPanelState extends State { } Widget _header(_Look look, int total, int shown) { - final dropped = _log.dropped; final count = [ - _min == LogLevel.info - ? '$total entr${total == 1 ? 'y' : 'ies'}' - : '$shown of $total', - if (dropped > 0) '$dropped older dropped', + if (_min == LogLevel.info) + '$total entr${total == 1 ? 'y' : 'ies'}' + else + '$shown of $total', + if (_log.dropped > 0) '${_log.dropped} older dropped', ].join(' · '); + Widget button(String id, IconData icon, String tip, VoidCallback? f) => + IconButton( + key: ValueKey(id), + tooltip: tip, + iconSize: 16, + color: look.text, + disabledColor: look.disabled, + visualDensity: VisualDensity.compact, + onPressed: f, + icon: Icon(icon), + ); return Row( children: [ Text( @@ -115,7 +125,7 @@ class _LogPanelState extends State { style: look.body.copyWith( fontSize: 13, fontWeight: FontWeight.bold, - color: look.title, + color: look.accent, ), ), const SizedBox(width: 10), @@ -125,240 +135,29 @@ class _LogPanelState extends State { key: const ValueKey('log-count'), maxLines: 1, overflow: TextOverflow.ellipsis, - style: look.body.copyWith(color: look.label), + style: look.body.copyWith(color: look.muted), ), ), - _Filter( - look: look, - value: _min, - onChanged: (l) => setState(() => _min = l), - ), + _filter(look), const SizedBox(width: 8), - _HeaderButton( - look: look, - id: 'log-copy', - icon: Icons.copy, - tooltip: 'Copy the entries shown', - onPressed: shown == 0 ? null : _copy, + button( + 'log-copy', + Icons.copy, + 'Copy the entries shown', + shown == 0 ? null : () => _copy(shown), ), - _HeaderButton( - look: look, - id: 'log-clear', - icon: Icons.delete_outline, - tooltip: 'Clear the log', - onPressed: total == 0 ? null : _log.clear, - ), - _HeaderButton( - look: look, - id: 'log-close', - icon: Icons.close, - tooltip: 'Close', - onPressed: widget.onClose, + button( + 'log-clear', + Icons.delete_outline, + 'Clear the log', + total == 0 ? null : _log.clear, ), + button('log-close', Icons.close, 'Close', widget.onClose), ], ); } - /// Newest first; time, level and source in columns as wide as their - /// widest text, the message wrapping beside them. - Widget _list(BuildContext context, _Look look, List shown) { - final scaler = MediaQuery.textScalerOf(context); - double width(String text) { - final p = TextPainter( - text: TextSpan(text: text, style: look.body), - textDirection: TextDirection.ltr, - textScaler: scaler, - maxLines: 1, - )..layout(); - final w = p.width; - p.dispose(); - return w.ceilToDouble(); - } - - final sources = {for (final e in shown) e.source}; - final columns = ( - time: width('00:00:00.000'), - level: width(LogLevel.error.tag), - source: sources.map(width).fold(0, math.max), - ); - return ListView.builder( - key: const ValueKey('log-list'), - itemCount: shown.length, - itemBuilder: (context, i) => _EntryRow( - look: look, - entry: shown[shown.length - 1 - i], - columns: columns, - ), - ); - } -} - -/// The panel's colours and text style: the Bison tokens when the theme -/// carries them, else the Material colour scheme. -class _Look { - const _Look({ - required this.surface, - required this.border, - required this.title, - required this.text, - required this.muted, - required this.label, - required this.source, - required this.warn, - required this.error, - required this.selected, - required this.disabled, - required this.body, - }); - - factory _Look.of(BuildContext context) { - final theme = Theme.of(context); - final typography = theme.extension(); - final body = - (typography?.bodySmall ?? - theme.textTheme.bodySmall ?? - const TextStyle()) - .copyWith( - fontSize: 11, - fontFeatures: const [FontFeature.tabularFigures()], - ); - final t = theme.extension(); - if (t != null) { - return _Look( - surface: t.surfaceDefault, - border: t.borderPlain, - title: t.textPrimary, - text: t.textPlain, - muted: t.textMuted, - label: t.textSecondary, - source: t.textPrimary, - warn: t.iconWarning, - error: t.textError, - selected: t.surfacePressed, - disabled: t.textDisabled, - body: body.copyWith(color: t.textPlain), - ); - } - final s = theme.colorScheme; - return _Look( - surface: s.surface, - border: s.outlineVariant, - title: s.primary, - text: s.onSurface, - muted: s.onSurfaceVariant, - label: s.onSurfaceVariant, - source: s.primary, - warn: Colors.amber.shade700, - error: s.error, - selected: s.primary.withValues(alpha: 0.16), - disabled: s.onSurface.withValues(alpha: 0.38), - body: body.copyWith(color: s.onSurface), - ); - } - - final Color surface; - final Color border; - final Color title; - final Color text; - final Color muted; - final Color label; - final Color source; - final Color warn; - final Color error; - final Color selected; - final Color disabled; - final TextStyle body; - - /// The colour an entry's level is drawn in. - Color level(LogLevel l) => switch (l) { - LogLevel.info => text, - LogLevel.warn => warn, - LogLevel.error => error, - }; -} - -/// One entry: `12:00:01.234 WARN link message…`. -class _EntryRow extends StatelessWidget { - const _EntryRow({ - required this.look, - required this.entry, - required this.columns, - }); - - final _Look look; - final LogEntry entry; - final ({double time, double level, double source}) columns; - - @override - Widget build(BuildContext context) { - const gap = SizedBox(width: 8); - return Padding( - padding: const EdgeInsets.symmetric(vertical: 1.5), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: columns.time, - child: Text( - logClock(entry.time), - style: look.body.copyWith(color: look.muted), - ), - ), - gap, - SizedBox( - width: columns.level, - child: Text( - entry.level.tag, - style: look.body.copyWith( - color: look.level(entry.level), - fontWeight: entry.level == LogLevel.info - ? FontWeight.normal - : FontWeight.bold, - ), - ), - ), - gap, - SizedBox( - width: columns.source, - child: Text( - entry.source, - style: look.body.copyWith(color: look.source), - ), - ), - gap, - Expanded(child: Text(entry.message, style: look.body)), - ], - ), - ); - } -} - -/// All · Warnings · Errors, joined. -class _Filter extends StatelessWidget { - const _Filter({ - required this.look, - required this.value, - required this.onChanged, - }); - - final _Look look; - final LogLevel value; - final ValueChanged onChanged; - - static String _label(LogLevel l) => switch (l) { - LogLevel.info => 'All', - LogLevel.warn => 'Warnings', - LogLevel.error => 'Errors', - }; - - static String _tip(LogLevel l) => switch (l) { - LogLevel.info => 'every entry', - LogLevel.warn => 'warnings and errors', - LogLevel.error => 'errors only', - }; - - @override - Widget build(BuildContext context) => Container( + Widget _filter(_Look look) => Container( height: 24, decoration: BoxDecoration( border: Border.all(color: look.border), @@ -368,21 +167,21 @@ class _Filter extends StatelessWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ - for (final l in LogLevel.values) + for (final (level, label, tip) in _filters) Tooltip( - message: _tip(l), + message: tip, child: InkWell( - key: ValueKey('log-filter-${l.name}'), - onTap: () => onChanged(l), + key: ValueKey('log-filter-${level.name}'), + onTap: () => setState(() => _min = level), child: Container( - color: l == value ? look.selected : null, + color: level == _min ? look.selected : null, padding: const EdgeInsets.symmetric(horizontal: 8), alignment: Alignment.center, child: Text( - _label(l), + label, style: look.body.copyWith( fontWeight: FontWeight.w500, - color: l == value ? look.title : look.text, + color: level == _min ? look.accent : look.text, ), ), ), @@ -391,33 +190,118 @@ class _Filter extends StatelessWidget { ], ), ); -} -/// A small icon button for the header; its tooltip names it. -class _HeaderButton extends StatelessWidget { - const _HeaderButton({ - required this.look, - required this.id, - required this.icon, - required this.tooltip, - required this.onPressed, - }); + /// Time, level and source columns sized to their widest text. + Widget _list(BuildContext context, _Look look, List shown) { + final scaler = MediaQuery.textScalerOf(context); + double width(String text) { + final p = TextPainter( + text: TextSpan(text: text, style: look.body), + textDirection: TextDirection.ltr, + textScaler: scaler, + )..layout(); + final w = p.width.ceilToDouble(); + p.dispose(); + return w; + } - final _Look look; - final String id; - final IconData icon; - final String tooltip; - final VoidCallback? onPressed; + final time = width('00:00:00.000'); + final level = width(LogLevel.error.tag); + final source = {for (final e in shown) e.source} + .map(width) + .fold(0.0, math.max); + Widget cell(double w, String text, TextStyle style) => SizedBox( + width: w, + child: Text(text, style: style), + ); + return ListView.builder( + key: const ValueKey('log-list'), + itemCount: shown.length, + itemBuilder: (context, i) { + final e = shown[i]; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 1.5), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 8, + children: [ + cell( + time, + logClock(e.time), + look.body.copyWith(color: look.muted), + ), + cell( + level, + e.level.tag, + look.body.copyWith( + color: switch (e.level) { + LogLevel.info => look.text, + LogLevel.warn => look.warn, + LogLevel.error => look.error, + }, + fontWeight: e.level == LogLevel.info ? null : FontWeight.bold, + ), + ), + cell(source, e.source, look.body.copyWith(color: look.accent)), + Expanded(child: Text(e.message, style: look.body)), + ], + ), + ); + }, + ); + } +} - @override - Widget build(BuildContext context) => IconButton( - key: ValueKey(id), - tooltip: tooltip, - iconSize: 16, - color: look.text, - disabledColor: look.disabled, - visualDensity: VisualDensity.compact, - onPressed: onPressed, - icon: Icon(icon), +typedef _Look = ({ + Color surface, + Color border, + Color accent, + Color text, + Color muted, + Color warn, + Color error, + Color selected, + Color disabled, + TextStyle body, +}); + +/// Bison tokens when the theme has them, else the Material colour scheme. +_Look _lookOf(BuildContext context) { + final theme = Theme.of(context); + final body = + (theme.extension()?.bodySmall ?? + theme.textTheme.bodySmall ?? + const TextStyle()) + .copyWith( + fontSize: 11, + fontFeatures: const [FontFeature.tabularFigures()], + ); + final t = theme.extension(); + if (t != null) { + return ( + surface: t.surfaceDefault, + border: t.borderPlain, + accent: t.textPrimary, + text: t.textPlain, + muted: t.textSecondary, + warn: t.iconWarning, + error: t.textError, + selected: t.surfacePressed, + disabled: t.textDisabled, + body: body.copyWith(color: t.textPlain), + ); + } + final s = theme.colorScheme; + return ( + surface: s.surface, + border: s.outlineVariant, + accent: s.primary, + text: s.onSurface, + muted: s.onSurfaceVariant, + warn: Colors.amber.shade700, + error: s.error, + selected: s.primary.withValues(alpha: 0.16), + disabled: s.onSurface.withValues(alpha: 0.38), + body: body.copyWith(color: s.onSurface), ); } From 76663d5ff2d6a94187c450b61a6c370af81e72ea Mon Sep 17 00:00:00 2001 From: Robert Santucci Date: Mon, 5 Oct 2026 09:27:35 -0500 Subject: [PATCH 4/6] LogPanel: one-line bison import --- lib/src/widgets/log_panel.dart | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/src/widgets/log_panel.dart b/lib/src/widgets/log_panel.dart index 64ac586..f6a4c8f 100644 --- a/lib/src/widgets/log_panel.dart +++ b/lib/src/widgets/log_panel.dart @@ -4,8 +4,7 @@ library; import 'dart:math' as math; -import 'package:bison_design_system/bison_design_system.dart' - show BisonThemeTokens, BisonTypographyTokens; +import 'package:bison_design_system/bison_design_system.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart' show Clipboard, ClipboardData; From d5de38a08a40d5f58a2265bb7a48f9dee6292bca Mon Sep 17 00:00:00 2001 From: Robert Santucci Date: Mon, 5 Oct 2026 11:06:27 -0500 Subject: [PATCH 5/6] AppLog/LogPanel: review fixes Cut messages by code point; Copy takes the entries shown; the filter grows with text scale and announces its selection; the source column is capped at 120 px and ellipsized. --- lib/src/logging/app_log.dart | 6 ++- lib/src/widgets/log_panel.dart | 69 +++++++++++++++++++------------ test/unit_tests/app_log_test.dart | 2 + test/widget/log_panel_test.dart | 47 +++++++++++++++++++++ 4 files changed, 96 insertions(+), 28 deletions(-) diff --git a/lib/src/logging/app_log.dart b/lib/src/logging/app_log.dart index 9258b91..6838835 100644 --- a/lib/src/logging/app_log.dart +++ b/lib/src/logging/app_log.dart @@ -63,7 +63,7 @@ class AppLog extends ChangeNotifier { bool mirror; final DateTime Function() now; - /// Longer messages are cut. + /// Longer messages are cut (in Unicode code points). static const int maxMessage = 1000; static final RegExp _breaks = RegExp(r'\s*[\r\n]+\s*'); @@ -85,7 +85,9 @@ class AppLog extends ChangeNotifier { /// Logs [message] on one line, cut to [maxMessage]. LogEntry add(LogLevel level, String source, String message) { var m = message.replaceAll(_breaks, ' '); - if (m.length > maxMessage) m = '${m.substring(0, maxMessage)}…'; + if (m.runes.length > maxMessage) { + m = '${String.fromCharCodes(m.runes.take(maxMessage))}…'; + } final e = LogEntry(now(), level, source, m); if (_entries.length >= capacity) { _entries.removeFirst(); diff --git a/lib/src/widgets/log_panel.dart b/lib/src/widgets/log_panel.dart index f6a4c8f..e5cdeef 100644 --- a/lib/src/widgets/log_panel.dart +++ b/lib/src/widgets/log_panel.dart @@ -38,12 +38,17 @@ class _LogPanelState extends State { (LogLevel.error, 'Errors', 'Errors only'), ]; + static const double _maxSource = 120; + LogLevel _min = LogLevel.info; AppLog get _log => widget.log ?? appLog; - Future _copy(int n) async { - await Clipboard.setData(ClipboardData(text: _log.asText(min: _min))); + /// Copies [shown] (newest first) oldest first: what the panel shows. + Future _copy(List shown) async { + final n = shown.length; + final text = shown.reversed.map((e) => e.line).join('\n'); + await Clipboard.setData(ClipboardData(text: text)); if (!mounted) return; ScaffoldMessenger.maybeOf(context)?.showSnackBar( SnackBar( @@ -78,7 +83,7 @@ class _LogPanelState extends State { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - _header(look, total, shown.length), + _header(look, total, shown), Divider(height: 9, color: look.border), Expanded( child: shown.isEmpty @@ -98,12 +103,12 @@ class _LogPanelState extends State { ); } - Widget _header(_Look look, int total, int shown) { + Widget _header(_Look look, int total, List shown) { final count = [ if (_min == LogLevel.info) '$total entr${total == 1 ? 'y' : 'ies'}' else - '$shown of $total', + '${shown.length} of $total', if (_log.dropped > 0) '${_log.dropped} older dropped', ].join(' · '); Widget button(String id, IconData icon, String tip, VoidCallback? f) => @@ -143,7 +148,7 @@ class _LogPanelState extends State { 'log-copy', Icons.copy, 'Copy the entries shown', - shown == 0 ? null : () => _copy(shown), + shown.isEmpty ? null : () => _copy(shown), ), button( 'log-clear', @@ -157,7 +162,7 @@ class _LogPanelState extends State { } Widget _filter(_Look look) => Container( - height: 24, + constraints: const BoxConstraints(minHeight: 24), decoration: BoxDecoration( border: Border.all(color: look.border), borderRadius: BorderRadius.circular(3), @@ -167,20 +172,24 @@ class _LogPanelState extends State { mainAxisSize: MainAxisSize.min, children: [ for (final (level, label, tip) in _filters) - Tooltip( - message: tip, - child: InkWell( - key: ValueKey('log-filter-${level.name}'), - onTap: () => setState(() => _min = level), - child: Container( - color: level == _min ? look.selected : null, - padding: const EdgeInsets.symmetric(horizontal: 8), - alignment: Alignment.center, - child: Text( - label, - style: look.body.copyWith( - fontWeight: FontWeight.w500, - color: level == _min ? look.accent : look.text, + Semantics( + button: true, + selected: level == _min, + child: Tooltip( + message: tip, + child: InkWell( + key: ValueKey('log-filter-${level.name}'), + onTap: () => setState(() => _min = level), + child: Container( + color: level == _min ? look.selected : null, + padding: const EdgeInsets.symmetric(horizontal: 8), + alignment: Alignment.center, + child: Text( + label, + style: look.body.copyWith( + fontWeight: FontWeight.w500, + color: level == _min ? look.accent : look.text, + ), ), ), ), @@ -190,7 +199,8 @@ class _LogPanelState extends State { ), ); - /// Time, level and source columns sized to their widest text. + /// Time, level and source columns sized to their widest text; the source + /// column at most [_maxSource] wide. Widget _list(BuildContext context, _Look look, List shown) { final scaler = MediaQuery.textScalerOf(context); double width(String text) { @@ -206,12 +216,19 @@ class _LogPanelState extends State { final time = width('00:00:00.000'); final level = width(LogLevel.error.tag); - final source = {for (final e in shown) e.source} - .map(width) - .fold(0.0, math.max); + final source = math.min( + _maxSource, + {for (final e in shown) e.source}.map(width).fold(0.0, math.max), + ); Widget cell(double w, String text, TextStyle style) => SizedBox( width: w, - child: Text(text, style: style), + child: Text( + text, + style: style, + maxLines: 1, + softWrap: false, + overflow: TextOverflow.ellipsis, + ), ); return ListView.builder( key: const ValueKey('log-list'), diff --git a/test/unit_tests/app_log_test.dart b/test/unit_tests/app_log_test.dart index 7022cdf..d6af7d8 100644 --- a/test/unit_tests/app_log_test.dart +++ b/test/unit_tests/app_log_test.dart @@ -85,6 +85,8 @@ void main() { log.info('link', 'x' * 5000); expect(log.entries.last.message, hasLength(AppLog.maxMessage + 1)); expect(log.entries.last.message, endsWith('…')); + log.info('link', '${'a' * 999}😀😀'); + expect(log.entries.last.message, '${'a' * 999}😀…', reason: 'whole emoji'); }); testWidgets('a burst of entries is one notification, at most every ' diff --git a/test/widget/log_panel_test.dart b/test/widget/log_panel_test.dart index 5e5edee..0538ef2 100644 --- a/test/widget/log_panel_test.dart +++ b/test/widget/log_panel_test.dart @@ -108,6 +108,53 @@ void main() { log.dispose(); }); + testWidgets('Copy takes what is shown, not entries not yet drawn', ( + tester, + ) async { + String? copied; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (call) async { + if (call.method == 'Clipboard.setData') { + copied = (call.arguments as Map)['text'] as String?; + } + return null; + }, + ); + final log = _filled(); + await tester.pumpWidget(_host(log)); + log.info('late', 'not drawn yet'); + await tester.tap(find.byKey(const ValueKey('log-copy'))); + await tester.pump(); + expect(copied, isNot(contains('not drawn yet'))); + await tester.pump(const Duration(milliseconds: 300)); + log.dispose(); + }); + + testWidgets('a long source is cut, not overflowing', (tester) async { + final log = _filled()..info('x' * 200, 'message'); + await tester.pumpWidget(_host(log, width: 440)); + expect(tester.takeException(), isNull); + expect(find.text('message'), findsOneWidget); + log.dispose(); + }); + + testWidgets('the selected filter is announced', (tester) async { + final handle = tester.ensureSemantics(); + final log = _filled(); + await tester.pumpWidget(_host(log)); + expect( + tester.getSemantics(find.byKey(const ValueKey('log-filter-info'))), + isSemantics(isSelected: true), + ); + expect( + tester.getSemantics(find.byKey(const ValueKey('log-filter-error'))), + isNot(isSemantics(isSelected: true)), + ); + log.dispose(); + handle.dispose(); + }); + testWidgets('works under a plain Material theme too', (tester) async { final log = _filled(); await tester.pumpWidget(_host(log, theme: ThemeData.dark())); From 2799ea4d427b105a8877140df28f7161d3dc920e Mon Sep 17 00:00:00 2001 From: Rich Neswold Date: Fri, 9 Oct 2026 13:47:45 -0500 Subject: [PATCH 6/6] Bump version to 1.4.2 --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index 74cdc35..8381034 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: flutter_controls_core description: A Flutter package to write Fermilab applications. -version: 1.4.0 +version: 1.4.2 publish_to: none environment: