diff --git a/lib/builtin/output/output.dart b/lib/builtin/output/output.dart new file mode 100644 index 00000000..bdf6d48e --- /dev/null +++ b/lib/builtin/output/output.dart @@ -0,0 +1,2 @@ +export 'src/output_controller.dart'; +export 'src/output_view.dart'; diff --git a/lib/builtin/output/src/output_controller.dart b/lib/builtin/output/src/output_controller.dart new file mode 100644 index 00000000..a14d15ba --- /dev/null +++ b/lib/builtin/output/src/output_controller.dart @@ -0,0 +1,69 @@ +/// Filter state + filtered view over a [LogRing] for the Output dock tab +/// (T-54 / D-87). Notifies when the ring changes or a filter is set, so the +/// view rebuilds; the ring itself stays Flutter-free. +library; + +import 'dart:async'; + +import 'package:clide/kernel/src/log.dart'; +import 'package:clide/kernel/src/log_ring.dart'; +import 'package:flutter/foundation.dart'; + +class OutputController extends ChangeNotifier { + OutputController(this.ring) { + _sub = ring.changes.listen((_) => notifyListeners()); + } + + final LogRing ring; + late final StreamSubscription _sub; + + /// Minimum level shown. Defaults to debug (trace is firehose-noise). + LogLevel minLevel = LogLevel.debug; + + /// Source filter; null = all sources. + String? source; + + /// Free-text filter over message + source. + String text = ''; + + void setMinLevel(LogLevel level) { + if (minLevel == level) return; + minLevel = level; + notifyListeners(); + } + + void setSource(String? value) { + if (source == value) return; + source = value; + notifyListeners(); + } + + void setText(String value) { + if (text == value) return; + text = value; + notifyListeners(); + } + + /// Clear the underlying ring (the panel's Clear action). The ring's change + /// event drives the rebuild. + void clear() => ring.clear(); + + /// Records passing the current filters, oldest first. + List get filtered { + final lf = text.toLowerCase(); + return ring.records.where((r) { + if (r.level.index < minLevel.index) return false; + if (source != null && r.source != source) return false; + if (lf.isNotEmpty && !r.message.toLowerCase().contains(lf) && !r.source.toLowerCase().contains(lf)) { + return false; + } + return true; + }).toList(); + } + + @override + void dispose() { + _sub.cancel(); + super.dispose(); + } +} diff --git a/lib/builtin/output/src/output_view.dart b/lib/builtin/output/src/output_view.dart new file mode 100644 index 00000000..e60794e1 --- /dev/null +++ b/lib/builtin/output/src/output_view.dart @@ -0,0 +1,256 @@ +/// The Output tab of the bottom dock (T-54 / D-87): a read-only, filterable, +/// auto-scrolling view of the [LogRing]. +library; + +import 'package:clide/kernel/kernel.dart'; +import 'package:clide/kernel/src/log_ring.dart'; +import 'package:clide/widgets/widgets.dart'; +import 'package:flutter/widgets.dart'; + +import 'output_controller.dart'; + +class OutputView extends StatefulWidget { + const OutputView({super.key, required this.ring}); + + /// The retained log buffer to render. The view owns a controller over it + /// but never the ring itself (the app owns that). + final LogRing ring; + + @override + State createState() => _OutputViewState(); +} + +class _OutputViewState extends State { + late final OutputController _c = OutputController(widget.ring); + final ScrollController _scroll = ScrollController(); + + /// Follow the tail until the user scrolls up; resumes when they return to + /// the bottom. + bool _following = true; + + @override + void initState() { + super.initState(); + _c.addListener(_onChange); + _scroll.addListener(_onScroll); + } + + void _onScroll() { + if (!_scroll.hasClients) return; + final atBottom = _scroll.offset >= _scroll.position.maxScrollExtent - 8; + if (atBottom != _following) setState(() => _following = atBottom); + } + + void _onChange() { + if (_following) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_scroll.hasClients) _scroll.jumpTo(_scroll.position.maxScrollExtent); + }); + } + } + + void _jumpToLatest() { + setState(() => _following = true); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_scroll.hasClients) _scroll.jumpTo(_scroll.position.maxScrollExtent); + }); + } + + @override + void dispose() { + _c + ..removeListener(_onChange) + ..dispose(); + _scroll.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: _c, + builder: (context, _) { + final tokens = ClideTheme.of(context).surface; + final rows = _c.filtered; + return Semantics( + label: 'output log', + container: true, + explicitChildNodes: true, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _header(tokens), + Expanded( + child: rows.isEmpty + ? Padding( + padding: const EdgeInsets.all(12), + child: ClideText( + widget.ring.isEmpty ? 'No output yet.' : 'No output matches the filter.', + muted: true, + ), + ) + : Stack( + children: [ + SingleChildScrollView( + controller: _scroll, + padding: const EdgeInsets.symmetric(vertical: 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [for (final r in rows) _LogRow(record: r)], + ), + ), + if (!_following) + Positioned( + right: 12, + bottom: 8, + child: _JumpPill(onTap: _jumpToLatest), + ), + ], + ), + ), + ], + ), + ); + }, + ); + } + + Widget _header(SurfaceTokens tokens) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + border: Border(bottom: BorderSide(color: tokens.panelBorder)), + ), + child: Row( + children: [ + Expanded( + child: ClideFilterBox(hint: 'Filter…', onChanged: _c.setText), + ), + const SizedBox(width: 8), + _Chip( + label: 'Level: ${_c.minLevel.name}', + onTap: () => _c.setMinLevel(LogLevel.values[(_c.minLevel.index + 1) % LogLevel.values.length]), + ), + const SizedBox(width: 6), + _Chip( + label: 'Source: ${_c.source ?? 'all'}', + onTap: _cycleSource, + ), + const SizedBox(width: 6), + _Chip(label: 'Clear', onTap: _c.clear), + ], + ), + ); + } + + void _cycleSource() { + final options = [null, ...widget.ring.sources]; + final i = options.indexOf(_c.source); + _c.setSource(options[(i + 1) % options.length]); + } +} + +class _Chip extends StatelessWidget { + const _Chip({required this.label, required this.onTap}); + final String label; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final tokens = ClideTheme.of(context).surface; + return Semantics( + button: true, + label: label, + child: GestureDetector( + onTap: onTap, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + border: Border.all(color: tokens.panelBorder), + borderRadius: BorderRadius.circular(4), + ), + child: ClideText(label, fontSize: clideFontCaption, color: tokens.globalTextMuted), + ), + ), + ), + ); + } +} + +class _JumpPill extends StatelessWidget { + const _JumpPill({required this.onTap}); + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final tokens = ClideTheme.of(context).surface; + return Semantics( + button: true, + label: 'jump to latest', + child: GestureDetector( + onTap: onTap, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: tokens.panelHeader, + border: Border.all(color: tokens.globalFocus), + borderRadius: BorderRadius.circular(12), + ), + child: ClideText('Jump to latest ↓', fontSize: clideFontCaption, color: tokens.globalFocus), + ), + ), + ), + ); + } +} + +class _LogRow extends StatelessWidget { + const _LogRow({required this.record}); + final LogRecord record; + + @override + Widget build(BuildContext context) { + final tokens = ClideTheme.of(context).surface; + final fg = _levelColor(record.level, tokens); + final t = record.timestamp; + final hh = t.hour.toString().padLeft(2, '0'); + final mm = t.minute.toString().padLeft(2, '0'); + final ss = t.second.toString().padLeft(2, '0'); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 1), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClideText('$hh:$mm:$ss', fontSize: clideFontMono, color: tokens.globalTextMuted, fontFamily: clideMonoFamily), + const SizedBox(width: 8), + SizedBox( + width: 48, + child: ClideText(record.level.name.toUpperCase(), fontSize: clideFontMono, color: fg, fontFamily: clideMonoFamily), + ), + const SizedBox(width: 8), + SizedBox( + width: 92, + child: ClideText(record.source, + fontSize: clideFontMono, color: tokens.globalTextMuted, fontFamily: clideMonoFamily, maxLines: 1, overflow: TextOverflow.clip), + ), + const SizedBox(width: 8), + Expanded( + child: ClideText(record.message, fontSize: clideFontMono, color: fg, fontFamily: clideMonoFamily), + ), + ], + ), + ); + } + + Color _levelColor(LogLevel level, SurfaceTokens tokens) => switch (level) { + LogLevel.error => tokens.statusError, + LogLevel.warn => tokens.statusWarning, + LogLevel.info => tokens.globalForeground, + LogLevel.debug || LogLevel.trace => tokens.globalTextMuted, + }; +} diff --git a/test/builtin/output/output_view_test.dart b/test/builtin/output/output_view_test.dart new file mode 100644 index 00000000..446487b3 --- /dev/null +++ b/test/builtin/output/output_view_test.dart @@ -0,0 +1,145 @@ +/// T-54: Output dock tab — OutputController filtering + OutputView rendering. +library; + +import 'package:clide/builtin/output/output.dart'; +import 'package:clide/kernel/src/log.dart'; +import 'package:clide/kernel/src/log_ring.dart'; +import 'package:clide/widgets/widgets.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/kernel_fixture.dart'; +import '../../helpers/widget_harness.dart'; + +LogRecord _rec(LogLevel level, String source, String message) => + LogRecord(level: level, source: source, message: message, timestamp: DateTime.utc(2026, 6, 6, 10, 42, 3)); + +LogRing _seeded() => LogRing(capacity: 100) + ..add(_rec(LogLevel.info, 'ipc', 'alpha')) + ..add(_rec(LogLevel.warn, 'pql', 'beta')) + ..add(_rec(LogLevel.error, 'extensions', 'gamma')); + +bool _textIs(Object? w, String s) => w is ClideText && w.data == s; + +void main() { + group('OutputController filtering', () { + test('minLevel hides lower levels', () { + final c = OutputController(_seeded())..setMinLevel(LogLevel.warn); + expect(c.filtered.map((r) => r.message), ['beta', 'gamma']); + }); + + test('source filter narrows to one subsystem', () { + final c = OutputController(_seeded())..setSource('pql'); + expect(c.filtered.map((r) => r.message), ['beta']); + }); + + test('text filter matches message or source', () { + final c = OutputController(_seeded()); + c.setText('gam'); + expect(c.filtered.map((r) => r.message), ['gamma']); + c.setText('ipc'); // matches by source + expect(c.filtered.map((r) => r.message), ['alpha']); + }); + + test('clear empties via the ring', () { + final ring = _seeded(); + OutputController(ring).clear(); + expect(ring.isEmpty, isTrue); + }); + }); + + group('OutputView', () { + late KernelFixture f; + setUp(() async => f = await KernelFixture.create()); + tearDown(() => f.dispose()); + + testWidgets('renders a row per record', (tester) async { + await tester.pumpWidget(harness(f, OutputView(ring: _seeded()))); + await tester.pumpAndSettle(); + expect(find.byWidgetPredicate((w) => _textIs(w, 'alpha')), findsOneWidget); + expect(find.byWidgetPredicate((w) => _textIs(w, 'beta')), findsOneWidget); + expect(find.byWidgetPredicate((w) => _textIs(w, 'gamma')), findsOneWidget); + }); + + testWidgets('empty ring shows the empty state', (tester) async { + await tester.pumpWidget(harness(f, OutputView(ring: LogRing(capacity: 10)))); + await tester.pumpAndSettle(); + expect(find.byWidgetPredicate((w) => _textIs(w, 'No output yet.')), findsOneWidget); + }); + + testWidgets('the level chip cycles its label', (tester) async { + await tester.pumpWidget(harness(f, OutputView(ring: _seeded()))); + await tester.pumpAndSettle(); + expect(find.byWidgetPredicate((w) => _textIs(w, 'Level: debug')), findsOneWidget); + await tester.tap(find.ancestor( + of: find.byWidgetPredicate((w) => _textIs(w, 'Level: debug')), + matching: find.byType(GestureDetector), + )); + await tester.pumpAndSettle(); + expect(find.byWidgetPredicate((w) => _textIs(w, 'Level: info')), findsOneWidget); + }); + + testWidgets('the source chip cycles and filters', (tester) async { + await tester.pumpWidget(harness(f, OutputView(ring: _seeded()))); + await tester.pumpAndSettle(); + expect(find.byWidgetPredicate((w) => _textIs(w, 'Source: all')), findsOneWidget); + await tester.tap(find.ancestor( + of: find.byWidgetPredicate((w) => _textIs(w, 'Source: all')), + matching: find.byType(GestureDetector), + )); + await tester.pumpAndSettle(); + // First source alphabetically is 'extensions'. + expect(find.byWidgetPredicate((w) => _textIs(w, 'Source: extensions')), findsOneWidget); + expect(find.byWidgetPredicate((w) => _textIs(w, 'alpha')), findsNothing); // ipc row filtered out + expect(find.byWidgetPredicate((w) => _textIs(w, 'gamma')), findsOneWidget); + }); + + testWidgets('shows the no-match state when a filter excludes everything', (tester) async { + final ring = LogRing(capacity: 10)..add(_rec(LogLevel.debug, 'x', 'only-debug')); + await tester.pumpWidget(harness(f, OutputView(ring: ring))); + await tester.pumpAndSettle(); + // Cycle level debug → info, hiding the only (debug) record. + await tester.tap(find.ancestor( + of: find.byWidgetPredicate((w) => _textIs(w, 'Level: debug')), + matching: find.byType(GestureDetector), + )); + await tester.pumpAndSettle(); + expect(find.byWidgetPredicate((w) => _textIs(w, 'No output matches the filter.')), findsOneWidget); + }); + + testWidgets('follows the tail, shows jump-to-latest on scroll-up, resumes on tap', (tester) async { + final ring = LogRing(capacity: 500); + for (var i = 0; i < 60; i++) { + ring.add(_rec(LogLevel.info, 'ipc', 'line $i')); + } + // Bounded viewport so the list actually scrolls (shared harness is + // unbounded; impose a tight box). + await tester.pumpWidget(harness(f, SizedBox(width: 600, height: 140, child: OutputView(ring: ring)))); + await tester.pumpAndSettle(); + // A new record auto-scrolls to the tail (follow). + ring.add(_rec(LogLevel.info, 'ipc', 'tail line')); + await tester.pump(); + await tester.pump(); + // Scroll up (drag content down) → follow pauses, pill appears. + await tester.drag(find.byType(SingleChildScrollView), const Offset(0, 250)); + await tester.pumpAndSettle(); + expect(find.byWidgetPredicate((w) => _textIs(w, 'Jump to latest ↓')), findsOneWidget); + // Tap it → resume follow, pill gone. + await tester.tap(find.byWidgetPredicate((w) => _textIs(w, 'Jump to latest ↓'))); + await tester.pumpAndSettle(); + expect(find.byWidgetPredicate((w) => _textIs(w, 'Jump to latest ↓')), findsNothing); + }); + + testWidgets('Clear empties the view', (tester) async { + await tester.pumpWidget(harness(f, OutputView(ring: _seeded()))); + await tester.pumpAndSettle(); + await tester.tap(find.ancestor( + of: find.byWidgetPredicate((w) => _textIs(w, 'Clear')), + matching: find.byType(GestureDetector), + )); + await tester.pumpAndSettle(); + expect(find.byWidgetPredicate((w) => _textIs(w, 'alpha')), findsNothing); + expect(find.byWidgetPredicate((w) => _textIs(w, 'No output yet.')), findsOneWidget); + }); + }); +}