diff --git a/.github/workflows/general.yml b/.github/workflows/general.yml index 3d485094f..673f550d3 100644 --- a/.github/workflows/general.yml +++ b/.github/workflows/general.yml @@ -38,7 +38,7 @@ jobs: - name: Setup Node uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 24 - name: Setup Pana source analysis run: tool/gh_actions/install_pana.sh diff --git a/devtools_options.yaml b/devtools_options.yaml new file mode 100644 index 000000000..0a727a9da --- /dev/null +++ b/devtools_options.yaml @@ -0,0 +1,4 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: + - rohd: true diff --git a/extension/devtools/config.yaml b/extension/devtools/config.yaml index 45c598205..88e472bb9 100644 --- a/extension/devtools/config.yaml +++ b/extension/devtools/config.yaml @@ -2,4 +2,4 @@ name: rohd issueTracker: https://github.com/intel/rohd/issues version: 0.0.1 materialIconCodePoint: '0xe1c5' -requiresConnection: true # optional field - defaults to true \ No newline at end of file +requiresConnection: false diff --git a/rohd_devtools_extension/assets/help/details_help.md b/rohd_devtools_extension/assets/help/details_help.md new file mode 100644 index 000000000..27689d8df --- /dev/null +++ b/rohd_devtools_extension/assets/help/details_help.md @@ -0,0 +1,28 @@ +# ℹ️ Module Details β€” Help + + + +Signal Details + Click module Select module to view signals + Signal list Shows ports and internal signals + +Signal Values + Value column Current signal value (hex/binary) + Width column Bit width of each signal + + + +## Signal Details + +| Action | Description | +| --- | --- | +| Click module (tree) | Select module and populate signal list | +| Signal list | Shows input ports, output ports, and internal signals | +| Value column | Displays the current value of each signal | +| Width column | Shows the bit width of each signal | + +## Export + +| Action | Description | +| --- | --- | +| πŸ“· Camera | Export signal table as PNG image | diff --git a/rohd_devtools_extension/assets/help/devtools_help.md b/rohd_devtools_extension/assets/help/devtools_help.md new file mode 100644 index 000000000..f7845a6bc --- /dev/null +++ b/rohd_devtools_extension/assets/help/devtools_help.md @@ -0,0 +1,34 @@ +# πŸ›  ROHD DevTools β€” Help + + + +Module Tree (left panel) + Click node Select module + Click β–Έ / β–Ύ Expand / collapse + πŸ”ƒ Refresh Reload hierarchy from VM + Type in search Filter modules by name + +Details (right panel) + Signal list Shows ports and internal signals + Search Filter signals by name + Filter Toggle input / output visibility + + + +## Module Tree (left panel) + +| Key | Description | +| --- | --- | +| Click module | Select module and show signals | +| Click β–Έ / β–Ύ | Expand or collapse sub-modules | +| πŸ”ƒ Refresh | Reload hierarchy from the VM | +| Type in search | Filter modules by name | + +## Signal Details (right panel) + +| Key | Description | +| --- | --- | +| Signal list | Shows input ports, output ports, and internal signals | +| Search | Filter signals by name | +| Filter icon | Toggle input / output signal visibility | +| πŸ“· Export | Export signal details as PNG | diff --git a/rohd_devtools_extension/lib/rohd_devtools/cubit/rohd_service_cubit.dart b/rohd_devtools_extension/lib/rohd_devtools/cubit/rohd_service_cubit.dart index 2b8b70b79..f4d8087e2 100644 --- a/rohd_devtools_extension/lib/rohd_devtools/cubit/rohd_service_cubit.dart +++ b/rohd_devtools_extension/lib/rohd_devtools/cubit/rohd_service_cubit.dart @@ -1,4 +1,4 @@ -// Copyright (C) 2025 Intel Corporation +// Copyright (C) 2025-2026 Intel Corporation // SPDX-License-Identifier: BSD-3-Clause // // rohd_service_cubit.dart @@ -7,27 +7,76 @@ // 2025 January 28 // Author: Roberto Torres +import 'dart:async'; + import 'package:devtools_app_shared/service.dart'; +import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_extensions/devtools_extensions.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:equatable/equatable.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:rohd_devtools_extension/rohd_devtools/models/tree_model.dart'; import 'package:rohd_devtools_extension/rohd_devtools/services/tree_service.dart'; +import 'package:vm_service/vm_service.dart' as vm; part 'rohd_service_state.dart'; +/// Cubit for managing ROHD service state. class RohdServiceCubit extends Cubit { + /// The TreeService instance for ROHD. TreeService? treeService; + /// The discovered ROHD isolate ID. + /// + /// Exposed so other consumers (e.g. waveform data source) can target the + /// same isolate that contains the ROHD inspector_service library. + String? get rohdIsolateId => _rohdIsolateId; + String? _rohdIsolateId; + + /// Listener for service connection state changes. + void Function()? _connectionListener; + + /// Constructor for RohdServiceCubit. RohdServiceCubit() : super(RohdServiceInitial()) { - evalModuleTree(); + // Listen for service connection state changes. + _connectionListener = _onConnectionStateChanged; + serviceManager.connectedState.addListener(_connectionListener!); + // Check if already connected (in case we missed the event). + if (serviceManager.connectedState.value.connected) { + unawaited(Future.microtask(evalModuleTree)); + } + } + + void _onConnectionStateChanged() { + final connected = serviceManager.connectedState.value.connected; + if (connected) { + // Reset tree service so we use the new connection. + treeService = null; + unawaited(evalModuleTree()); + } else { + // VM disconnected β€” reset stale references. + treeService = null; + _rohdIsolateId = null; + emit(RohdServiceInitial()); + } + } + + @override + Future close() { + if (_connectionListener != null) { + serviceManager.connectedState.removeListener(_connectionListener!); + _connectionListener = null; + } + return super.close(); } + /// Evaluate the module tree from the ROHD service. Future evalModuleTree() async { await _handleModuleTreeOperation( (treeService) => treeService.evalModuleTree()); } + /// Refresh the module tree from the ROHD service. Future refreshModuleTree() async { await _handleModuleTreeOperation( (treeService) => treeService.refreshModuleTree()); @@ -37,20 +86,72 @@ class RohdServiceCubit extends Cubit { Future Function(TreeService) operation) async { try { emit(RohdServiceLoading()); + if (serviceManager.service == null) { - throw Exception('ServiceManager is not initialized'); + // When not running in DevTools, emit loaded with null tree. + emit(const RohdServiceLoaded(null)); + return; + } + + if (treeService == null) { + // Find the isolate that actually has the ROHD library loaded. + // With `dart test`, the DevTools "selected" isolate is often the + // test-runner controller which doesn't import package:rohd. We + // need to scan all isolates to find the one with inspector_service. + final service = serviceManager.service!; + ValueListenable? rohdIsolate; + + try { + final vmInfo = await service.getVM(); + final isolates = vmInfo.isolates ?? []; + + for (final isoRef in isolates) { + final id = isoRef.id; + if (id == null) continue; + try { + final iso = await service.getIsolate(id); + final libs = iso.libraries ?? []; + final hasRohd = libs.any((lib) => + lib.uri == + 'package:rohd/src/diagnostics/inspector_service.dart'); + if (hasRohd) { + debugPrint('[RohdServiceCubit] Found ROHD in ' + '${isoRef.name}'); + rohdIsolate = ValueNotifier(isoRef); + _rohdIsolateId = id; + break; + } + } on Exception { + // Isolate not loaded yet β€” skip. + continue; + } + } + } on Exception catch (e) { + debugPrint('[RohdServiceCubit] VM scan failed: $e'); + } + + if (rohdIsolate == null) { + debugPrint('[RohdServiceCubit] ROHD isolate not found, ' + 'falling back to selected isolate'); + } + + treeService = TreeService( + EvalOnDartLibrary( + 'package:rohd/src/diagnostics/inspector_service.dart', + service, + serviceManager: serviceManager, + isolate: rohdIsolate, + ), + Disposable(), + ); } - treeService ??= TreeService( - EvalOnDartLibrary( - 'package:rohd/src/diagnostics/inspector_service.dart', - serviceManager.service!, - serviceManager: serviceManager, - ), - Disposable(), - ); + final treeModel = await operation(treeService!); emit(RohdServiceLoaded(treeModel)); - } catch (error, trace) { + } on Exception catch (error, trace) { + // Reset treeService so next attempt re-scans for the ROHD isolate. + treeService = null; + _rohdIsolateId = null; emit(RohdServiceError(error.toString(), trace)); } } diff --git a/rohd_devtools_extension/lib/rohd_devtools/services/tree_service.dart b/rohd_devtools_extension/lib/rohd_devtools/services/tree_service.dart index 578134c52..7a7fcc0ee 100644 --- a/rohd_devtools_extension/lib/rohd_devtools/services/tree_service.dart +++ b/rohd_devtools_extension/lib/rohd_devtools/services/tree_service.dart @@ -10,6 +10,8 @@ import 'dart:convert'; import 'package:devtools_app_shared/service.dart'; +import 'package:devtools_app_shared/utils.dart'; +import 'package:flutter/foundation.dart'; import 'package:rohd_devtools_extension/rohd_devtools/models/tree_model.dart'; class TreeService { @@ -28,8 +30,7 @@ class TreeService { final treeObj = jsonDecode(treeInstance.valueAsString ?? '') as Map; if (treeObj['status'] == 'fail') { - print('error'); - + debugPrint('[TreeService] evalModuleTree failed: ${treeObj['message']}'); return null; } else { return TreeModel.fromJson(jsonDecode(treeInstance.valueAsString ?? "")); diff --git a/rohd_devtools_extension/lib/rohd_devtools/ui/details_help_button.dart b/rohd_devtools_extension/lib/rohd_devtools/ui/details_help_button.dart new file mode 100644 index 000000000..4834aea35 --- /dev/null +++ b/rohd_devtools_extension/lib/rohd_devtools/ui/details_help_button.dart @@ -0,0 +1,33 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// details_help_button.dart +// Help button widget for the Details tab. +// +// Content is loaded from assets/help/details_help.md. +// Edit that markdown file to update hover tooltip and dialog content. +// +// 2026 April +// Author: Desmond Kirkpatrick + +import 'package:flutter/material.dart'; + +import 'package:rohd_devtools_widgets/rohd_devtools_widgets.dart'; + +/// A help button for the Details tab. +/// +/// Content is driven by `assets/help/details_help.md`. +/// Edit that file to update the hover tooltip and click-open dialog. +class DetailsHelpButton extends StatelessWidget { + /// Whether the current theme is dark mode. + final bool isDark; + + /// Create a [DetailsHelpButton]. + const DetailsHelpButton({required this.isDark, super.key}); + + @override + Widget build(BuildContext context) => MarkdownHelpButton( + assetPath: 'assets/help/details_help.md', + isDark: isDark, + ); +} diff --git a/rohd_devtools_extension/lib/rohd_devtools/ui/devtool_appbar.dart b/rohd_devtools_extension/lib/rohd_devtools/ui/devtool_appbar.dart index 9138fc191..d6f067e28 100644 --- a/rohd_devtools_extension/lib/rohd_devtools/ui/devtool_appbar.dart +++ b/rohd_devtools_extension/lib/rohd_devtools/ui/devtool_appbar.dart @@ -8,6 +8,7 @@ // Author: Yao Jing Quek import 'package:flutter/material.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/ui/devtools_help_button.dart'; class DevtoolAppBar extends StatelessWidget implements PreferredSizeWidget { const DevtoolAppBar({ @@ -16,11 +17,17 @@ class DevtoolAppBar extends StatelessWidget implements PreferredSizeWidget { @override Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + return AppBar( backgroundColor: Theme.of(context).colorScheme.onPrimary, title: const Text('ROHD DevTool (Beta)'), leading: const Icon(Icons.build), actions: [ + // ── Help ── + DevToolsHelpButton(isDark: isDark), + + // ── Licenses ── Padding( padding: const EdgeInsets.only(right: 20.0), child: MouseRegion( diff --git a/rohd_devtools_extension/lib/rohd_devtools/ui/devtools_help_button.dart b/rohd_devtools_extension/lib/rohd_devtools/ui/devtools_help_button.dart new file mode 100644 index 000000000..138d588b3 --- /dev/null +++ b/rohd_devtools_extension/lib/rohd_devtools/ui/devtools_help_button.dart @@ -0,0 +1,33 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// devtools_help_button.dart +// Help button widget for the ROHD DevTools app bar. +// +// Content is loaded from assets/help/devtools_help.md. +// Edit that markdown file to update hover tooltip and dialog content. +// +// 2026 April +// Author: Desmond Kirkpatrick + +import 'package:flutter/material.dart'; + +import 'package:rohd_devtools_widgets/rohd_devtools_widgets.dart'; + +/// A help button for the ROHD DevTools app bar. +/// +/// Content is driven by `assets/help/devtools_help.md`. +/// Edit that file to update the hover tooltip and click-open dialog. +class DevToolsHelpButton extends StatelessWidget { + /// Whether the current theme is dark mode. + final bool isDark; + + /// Create a [DevToolsHelpButton]. + const DevToolsHelpButton({required this.isDark, super.key}); + + @override + Widget build(BuildContext context) => MarkdownHelpButton( + assetPath: 'assets/help/devtools_help.md', + isDark: isDark, + ); +} diff --git a/rohd_devtools_extension/lib/rohd_devtools/ui/module_tree_card.dart b/rohd_devtools_extension/lib/rohd_devtools/ui/module_tree_card.dart index 40f1e72de..a12b25697 100644 --- a/rohd_devtools_extension/lib/rohd_devtools/ui/module_tree_card.dart +++ b/rohd_devtools_extension/lib/rohd_devtools/ui/module_tree_card.dart @@ -74,8 +74,9 @@ class _ModuleTreeCardState extends State { children: [ Container( decoration: BoxDecoration( - color: - isSelected ? Colors.blue.withOpacity(0.2) : Colors.transparent, + color: isSelected + ? Colors.blue.withValues(alpha: 0.2) + : Colors.transparent, borderRadius: BorderRadius.circular(4.0), ), padding: const EdgeInsets.symmetric(vertical: 4.0, horizontal: 8.0), diff --git a/rohd_devtools_extension/lib/rohd_devtools/ui/module_tree_details_navbar.dart b/rohd_devtools_extension/lib/rohd_devtools/ui/module_tree_details_navbar.dart index f84835e5e..a94935179 100644 --- a/rohd_devtools_extension/lib/rohd_devtools/ui/module_tree_details_navbar.dart +++ b/rohd_devtools_extension/lib/rohd_devtools/ui/module_tree_details_navbar.dart @@ -20,7 +20,7 @@ class ModuleTreeDetailsNavbar extends StatelessWidget { type: BottomNavigationBarType.fixed, backgroundColor: const Color(0x1B1B1FEE), selectedItemColor: Colors.white, - unselectedItemColor: Colors.white.withOpacity(.60), + unselectedItemColor: Colors.white.withValues(alpha: .60), selectedFontSize: 10, unselectedFontSize: 10, onTap: (value) { diff --git a/rohd_devtools_extension/lib/rohd_devtools/ui/signal_details_card.dart b/rohd_devtools_extension/lib/rohd_devtools/ui/signal_details_card.dart index 0d3fdeb3a..cafee83c3 100644 --- a/rohd_devtools_extension/lib/rohd_devtools/ui/signal_details_card.dart +++ b/rohd_devtools_extension/lib/rohd_devtools/ui/signal_details_card.dart @@ -8,6 +8,7 @@ // Author: Yao Jing Quek import 'package:flutter/material.dart'; +import 'package:rohd_devtools_widgets/rohd_devtools_widgets.dart'; import 'package:rohd_devtools_extension/rohd_devtools/models/tree_model.dart'; import 'package:rohd_devtools_extension/rohd_devtools/ui/signal_table_text_field.dart'; @@ -17,9 +18,9 @@ class SignalDetailsCard extends StatefulWidget { final TreeModel? module; const SignalDetailsCard({ - Key? key, + super.key, this.module, - }) : super(key: key); + }); @override SignalDetailsCardState createState() => SignalDetailsCardState(); @@ -30,6 +31,7 @@ class SignalDetailsCardState extends State { ValueNotifier inputSelected = ValueNotifier(true); ValueNotifier outputSelected = ValueNotifier(true); ValueNotifier notifier = ValueNotifier(0); + final GlobalKey _boundaryKey = GlobalKey(); void toggleNotifier() { notifier.value++; @@ -84,46 +86,61 @@ class SignalDetailsCardState extends State { ); } - return SizedBox( - height: MediaQuery.of(context).size.height / 1.4, - child: SingleChildScrollView( - scrollDirection: Axis.vertical, - child: Column( - children: [ - Padding( - padding: const EdgeInsets.all(8.0), - child: Row( - children: [ - SignalTableTextField( - labelText: 'Search Signals', - onChanged: (value) { - setState(() { - searchTerm = value; - }); - toggleNotifier(); - }, + return Stack( + children: [ + RepaintBoundary( + key: _boundaryKey, + child: SingleChildScrollView( + scrollDirection: Axis.vertical, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + children: [ + SignalTableTextField( + labelText: 'Search Signals', + onChanged: (value) { + setState(() { + searchTerm = value; + }); + toggleNotifier(); + }, + ), + IconButton( + icon: const Icon(Icons.filter_list), + onPressed: _showFilterDialog, + ), + ], ), - IconButton( - icon: const Icon(Icons.filter_list), - onPressed: _showFilterDialog, - ), - ], - ), + ), + ValueListenableBuilder( + valueListenable: notifier, + builder: (context, _, __) { + return SignalTable( + selectedModule: widget.module!, + searchTerm: searchTerm, + inputSelectedVal: inputSelected.value, + outputSelectedVal: outputSelected.value, + ); + }, + ), + ], ), - ValueListenableBuilder( - valueListenable: notifier, - builder: (context, _, __) { - return SignalTable( - selectedModule: widget.module!, - searchTerm: searchTerm, - inputSelectedVal: inputSelected.value, - outputSelectedVal: outputSelected.value, - ); - }, + ), + ), + Positioned( + right: 8, + bottom: 8, + child: ExportPngButton( + onPressed: () => captureBoundaryToPng( + context, + boundaryKey: _boundaryKey, + filePrefix: 'signal_details', ), - ], + ), ), - ), + ], ); } } diff --git a/rohd_devtools_extension/lib/rohd_devtools/view/tree_structure_page.dart b/rohd_devtools_extension/lib/rohd_devtools/view/tree_structure_page.dart index 91c57b1b7..f408b33cf 100644 --- a/rohd_devtools_extension/lib/rohd_devtools/view/tree_structure_page.dart +++ b/rohd_devtools_extension/lib/rohd_devtools/view/tree_structure_page.dart @@ -9,6 +9,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:rohd_devtools_widgets/rohd_devtools_widgets.dart'; import 'package:rohd_devtools_extension/rohd_devtools/cubit/rohd_service_cubit.dart'; import 'package:rohd_devtools_extension/rohd_devtools/cubit/tree_search_term_cubit.dart'; import 'package:rohd_devtools_extension/rohd_devtools/ui/signal_details_card.dart'; @@ -26,6 +27,7 @@ class TreeStructurePage extends StatelessWidget { final ScrollController _horizontal = ScrollController(); final ScrollController _vertical = ScrollController(); + final GlobalKey _treeBoundaryKey = GlobalKey(); @override Widget build(BuildContext context) { @@ -34,162 +36,179 @@ class TreeStructurePage extends StatelessWidget { child: SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // Module Tree render here (Left Section) SizedBox( width: screenSize.width / 2, - height: screenSize.width / 2.6, child: Card( clipBehavior: Clip.antiAlias, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.all(10), - // Module Tree Menu Bar - child: Row( - children: [ - const Icon(Icons.account_tree), - const SizedBox(width: 10), - const Text('Module Tree'), - Expanded( - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - SizedBox( - width: 200, - child: TextField( - onChanged: (value) { - context - .read() - .setTerm(value); - }, - decoration: const InputDecoration( - labelText: "Search Tree", - ), - ), - ), - IconButton( - icon: const Icon(Icons.refresh), - onPressed: () => context - .read() - .evalModuleTree(), - ), - ], - ), - ), - ], - ), - ), - // expand the available column - Expanded( - child: Scrollbar( - thumbVisibility: true, - controller: _vertical, - child: SingleChildScrollView( - scrollDirection: Axis.vertical, - controller: _vertical, + child: Stack(children: [ + RepaintBoundary( + key: _treeBoundaryKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(10), + // Module Tree Menu Bar child: Row( children: [ + const Icon(Icons.account_tree), + const SizedBox(width: 10), + const Text('Module Tree'), Expanded( - child: Scrollbar( - thumbVisibility: true, - controller: _horizontal, - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - controller: _horizontal, - child: BlocBuilder( - builder: (context, state) { - if (state is RohdServiceLoading) { - return const Center( - child: CircularProgressIndicator(), - ); - } else if (state is RohdServiceLoaded) { - final futureModuleTree = - state.treeModel; - if (futureModuleTree == null) { - return Expanded( - child: Container( - padding: - const EdgeInsets.all(20), - child: const Text( - 'Friendly Notice: Please make ' - 'sure that you use build() method ' - 'to build your module and put ' - 'the breakpoint at the ' - 'simulation time.', - style: - TextStyle(fontSize: 20), - textAlign: TextAlign.center, - ), - ), - ); - } else { - return ModuleTreeCard( - futureModuleTree: - futureModuleTree, - ); - } - } else if (state is RohdServiceError) { - return Center( - child: - Text('Error: ${state.error}'), - ); - } else { - return const Center( - child: Text('Unknown state'), - ); - } - }, + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + SizedBox( + width: 200, + child: TextField( + onChanged: (value) { + context + .read() + .setTerm(value); + }, + decoration: const InputDecoration( + labelText: "Search Tree", + ), + ), ), - ), + IconButton( + icon: const Icon(Icons.refresh), + onPressed: () => context + .read() + .evalModuleTree(), + ), + ], ), ), ], ), ), + // expand the available column + Expanded( + child: Scrollbar( + thumbVisibility: true, + controller: _vertical, + child: SingleChildScrollView( + scrollDirection: Axis.vertical, + controller: _vertical, + child: Row( + children: [ + Expanded( + child: Scrollbar( + thumbVisibility: true, + controller: _horizontal, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + controller: _horizontal, + child: BlocBuilder( + builder: (context, state) { + if (state is RohdServiceLoading) { + return const Center( + child: + CircularProgressIndicator(), + ); + } else if (state + is RohdServiceLoaded) { + final futureModuleTree = + state.treeModel; + if (futureModuleTree == null) { + return Expanded( + child: Container( + padding: + const EdgeInsets.all( + 20), + child: const Text( + 'Friendly Notice: Please make ' + 'sure that you use build() method ' + 'to build your module and put ' + 'the breakpoint at the ' + 'simulation time.', + style: TextStyle( + fontSize: 20), + textAlign: + TextAlign.center, + ), + ), + ); + } else { + return ModuleTreeCard( + futureModuleTree: + futureModuleTree, + ); + } + } else if (state + is RohdServiceError) { + return Center( + child: Text( + 'Error: ${state.error}'), + ); + } else { + return const Center( + child: Text('Unknown state'), + ); + } + }, + ), + ), + ), + ), + ], + ), + ), + ), + ), + ], + ), + ), + Positioned( + right: 8, + bottom: 8, + child: ExportPngButton( + onPressed: () => captureBoundaryToPng( + context, + boundaryKey: _treeBoundaryKey, + filePrefix: 'module_tree', ), ), - ], - ), + ), + ]), ), ), // Signal Table Right Section Module SizedBox( width: screenSize.width / 2, - height: screenSize.width / 2.6, child: Card( clipBehavior: Clip.antiAlias, - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const ModuleTreeDetailsNavbar(), - Padding( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const ModuleTreeDetailsNavbar(), + Expanded( + child: Padding( padding: const EdgeInsets.only(left: 20, right: 20), - child: SingleChildScrollView( - scrollDirection: Axis.vertical, - child: BlocBuilder( - builder: (context, state) { - if (state is SelectedModuleLoaded) { - final selectedModule = state.module; - return SignalDetailsCard( - module: selectedModule, - ); - } else { - return const Center( - child: Text('No module selected'), - ); - } - }, - ), + child: BlocBuilder( + builder: (context, state) { + if (state is SelectedModuleLoaded) { + final selectedModule = state.module; + return SignalDetailsCard( + module: selectedModule, + ); + } else { + return const Center( + child: Text('No module selected'), + ); + } + }, ), ), - ], - ), + ), + ], ), ), ), diff --git a/rohd_devtools_extension/packages/rohd_devtools_widgets/analysis_options.yaml b/rohd_devtools_extension/packages/rohd_devtools_widgets/analysis_options.yaml new file mode 100644 index 000000000..572dd239d --- /dev/null +++ b/rohd_devtools_extension/packages/rohd_devtools_widgets/analysis_options.yaml @@ -0,0 +1 @@ +include: package:lints/recommended.yaml diff --git a/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/rohd_devtools_widgets.dart b/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/rohd_devtools_widgets.dart new file mode 100644 index 000000000..335b890d3 --- /dev/null +++ b/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/rohd_devtools_widgets.dart @@ -0,0 +1,23 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// rohd_devtools_widgets.dart +// Barrel file for the rohd_devtools_widgets package. +// Combines help_api, export_png, and overlay_api into one package. +// +// 2026 April +// Author: Desmond Kirkpatrick + +// Help +export 'src/markdown_help_button.dart'; + +// Overlay +export 'src/app_bar_overlay.dart'; + +// PNG export +export 'src/capture_boundary.dart'; +export 'src/export_button.dart'; +export 'src/export_toast.dart'; +export 'src/save_png_stub.dart' + if (dart.library.io) 'src/save_png_native.dart' + if (dart.library.js_interop) 'src/save_png_web.dart'; diff --git a/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/app_bar_overlay.dart b/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/app_bar_overlay.dart new file mode 100644 index 000000000..ba210d5b2 --- /dev/null +++ b/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/app_bar_overlay.dart @@ -0,0 +1,168 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// app_bar_overlay.dart +// Auto-hiding overlay AppBar that slides in from the top edge. +// +// When [autoHide] is true, the bar slides out of view and reappears when +// the mouse enters a thin trigger zone along the top edge. When [autoHide] +// is false the bar behaves like a normal AppBar (always visible, pushes +// content down). +// +// Designed to be reusable across ROHD Wave Viewer, Schematic Viewer, etc. +// +// 2026 April +// Author: Desmond Kirkpatrick + +import 'package:flutter/material.dart'; + +/// Wraps a [body] widget and an [appBar] widget, where the AppBar +/// auto-hides by sliding up when [autoHide] is true. +/// +/// When [autoHide] is false the layout is a simple Column (AppBar + body), +/// matching normal Scaffold behaviour. +class AppBarOverlay extends StatefulWidget { + /// The AppBar-like widget to show/hide. + final PreferredSizeWidget appBar; + + /// The main content below the AppBar. + final Widget body; + + /// When true, the AppBar auto-hides and slides in on mouse hover. + /// When false, the AppBar is always visible. + final bool autoHide; + + /// Height of the invisible trigger zone along the top edge (pixels). + final double triggerHeight; + + /// Opacity of the overlay AppBar when shown (0.0–1.0). + final double panelOpacity; + + /// Duration of the slide animation. + final Duration animationDuration; + + const AppBarOverlay({ + super.key, + required this.appBar, + required this.body, + this.autoHide = false, + this.triggerHeight = 12, + this.panelOpacity = 0.92, + this.animationDuration = const Duration(milliseconds: 200), + }); + + @override + State createState() => _AppBarOverlayState(); +} + +class _AppBarOverlayState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + late final Animation _slideAnimation; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: widget.animationDuration, + ); + _slideAnimation = Tween( + begin: const Offset(0, -1), // fully off-screen above + end: Offset.zero, + ).animate(CurvedAnimation( + parent: _controller, + curve: Curves.easeOutCubic, + reverseCurve: Curves.easeInCubic, + )); + + // If not auto-hiding, snap open. + if (!widget.autoHide) { + _controller.value = 1.0; + } + } + + @override + void didUpdateWidget(covariant AppBarOverlay oldWidget) { + super.didUpdateWidget(oldWidget); + if (!widget.autoHide && oldWidget.autoHide) { + // Switched from auto-hide β†’ always visible: snap open. + _controller.forward(); + } else if (widget.autoHide && !oldWidget.autoHide) { + // Switched from always visible β†’ auto-hide: hide immediately. + _controller.reverse(); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _show() { + _controller.forward(); + } + + void _hide() { + if (!widget.autoHide) return; + _controller.reverse(); + } + + @override + Widget build(BuildContext context) { + // ── When not auto-hiding, simple column layout ── + if (!widget.autoHide) { + return Column( + children: [ + widget.appBar, + Expanded(child: widget.body), + ], + ); + } + + // ── Auto-hide mode: overlay with trigger zone ── + final appBarHeight = + widget.appBar.preferredSize.height + MediaQuery.of(context).padding.top; + + return Stack( + fit: StackFit.expand, + children: [ + // Body fills the entire area (no top inset β€” content goes edge-to-edge) + Positioned.fill(child: widget.body), + + // Trigger zone: thin invisible strip along the top edge + Positioned( + left: 0, + right: 0, + top: 0, + height: widget.triggerHeight, + child: MouseRegion( + onEnter: (_) => _show(), + opaque: false, // let clicks through when AppBar is hidden + child: const SizedBox.expand(), + ), + ), + + // Sliding overlay AppBar + Positioned( + left: 0, + right: 0, + top: 0, + height: appBarHeight, + child: SlideTransition( + position: _slideAnimation, + child: MouseRegion( + onEnter: (_) => _show(), + onExit: (_) => _hide(), + child: Opacity( + opacity: widget.panelOpacity, + child: widget.appBar, + ), + ), + ), + ), + ], + ); + } +} diff --git a/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/capture_boundary.dart b/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/capture_boundary.dart new file mode 100644 index 000000000..4512ed207 --- /dev/null +++ b/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/capture_boundary.dart @@ -0,0 +1,69 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// capture_boundary.dart +// One-call RepaintBoundary β†’ PNG export with toast feedback. +// +// 2026 April +// Author: Desmond Kirkpatrick + +import 'dart:math' as math; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart' show RenderRepaintBoundary; + +import 'package:rohd_devtools_widgets/rohd_devtools_widgets.dart' as export_png; + +/// Capture a [RepaintBoundary] identified by [boundaryKey], encode to PNG, +/// save/download, and show a toast. +/// +/// [filePrefix] is used as the first part of the file name +/// (e.g. `"schematic"` β†’ `schematic_1713052800000.png`). +/// +/// Returns `true` if the export succeeded. +Future captureBoundaryToPng( + BuildContext context, { + required GlobalKey boundaryKey, + String filePrefix = 'export', +}) async { + final boundary = + boundaryKey.currentContext?.findRenderObject() as RenderRepaintBoundary?; + if (boundary == null) { + debugPrint('[ExportPng] No RepaintBoundary found'); + return false; + } + + final pixelRatio = math.min( + 3.0, + MediaQuery.of(context).devicePixelRatio, + ); + final image = await boundary.toImage(pixelRatio: pixelRatio); + final byteData = await image.toByteData(format: ui.ImageByteFormat.png); + image.dispose(); + + if (byteData == null) { + debugPrint('[ExportPng] Failed to encode PNG'); + return false; + } + + final pngBytes = byteData.buffer.asUint8List(); + final fileName = '${filePrefix}_${DateTime.now().millisecondsSinceEpoch}.png'; + + try { + final savedPath = await export_png.savePngBytes(pngBytes, fileName); + final msg = + savedPath != null ? 'Saved: $savedPath' : 'Downloaded $fileName'; + debugPrint('[ExportPng] $msg'); + if (context.mounted) { + export_png.showExportToast(context, msg); + } + return true; + } on Object catch (e) { + debugPrint('[ExportPng] Export failed: $e'); + if (context.mounted) { + export_png.showExportToast(context, 'Export failed: $e'); + } + return false; + } +} diff --git a/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/export_button.dart b/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/export_button.dart new file mode 100644 index 000000000..2f93890ed --- /dev/null +++ b/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/export_button.dart @@ -0,0 +1,50 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// export_button.dart +// Reusable camera-icon button for PNG export. +// +// 2026 April +// Author: Desmond Kirkpatrick + +import 'package:flutter/material.dart'; + +/// Small camera-icon button for triggering PNG export. +/// +/// Designed to be placed in a [Positioned] overlay. Calls [onPressed] +/// when tapped. +class ExportPngButton extends StatelessWidget { + final VoidCallback onPressed; + final String tooltip; + + const ExportPngButton({ + super.key, + required this.onPressed, + this.tooltip = 'Export as PNG', + }); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Tooltip( + message: tooltip, + child: Material( + color: cs.surface.withAlpha(200), + shape: const CircleBorder(), + elevation: 2, + child: InkWell( + customBorder: const CircleBorder(), + onTap: onPressed, + child: Padding( + padding: const EdgeInsets.all(8), + child: Icon( + Icons.camera_alt_outlined, + size: 20, + color: cs.onSurface, + ), + ), + ), + ), + ); + } +} diff --git a/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/export_toast.dart b/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/export_toast.dart new file mode 100644 index 000000000..e962a6dd0 --- /dev/null +++ b/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/export_toast.dart @@ -0,0 +1,48 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// export_toast.dart +// Overlay-based toast that works without a Scaffold ancestor. +// +// 2026 April +// Author: Desmond Kirkpatrick + +import 'dart:async'; + +import 'package:flutter/material.dart'; + +/// Show a brief floating toast at the bottom of the screen. +/// +/// Works without a [Scaffold] ancestor by inserting directly into the +/// root [Overlay]. Auto-removes after [duration]. +void showExportToast( + BuildContext context, + String message, { + Duration duration = const Duration(seconds: 3), +}) { + final overlay = Overlay.of(context, rootOverlay: true); + late OverlayEntry entry; + entry = OverlayEntry( + builder: (ctx) => Positioned( + bottom: 32, + left: 0, + right: 0, + child: Center( + child: Material( + elevation: 4, + borderRadius: BorderRadius.circular(8), + color: Colors.grey.shade800, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Text( + message, + style: const TextStyle(color: Colors.white, fontSize: 13), + ), + ), + ), + ), + ), + ); + overlay.insert(entry); + Timer(duration, entry.remove); +} diff --git a/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/markdown_help_button.dart b/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/markdown_help_button.dart new file mode 100644 index 000000000..713e6b0f1 --- /dev/null +++ b/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/markdown_help_button.dart @@ -0,0 +1,486 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// markdown_help_button.dart +// A generic help button driven by a markdown asset file. +// +// The markdown file contains two sections separated by : +// - Above the marker: plain-text tooltip shown on hover +// - Below the marker: markdown rendered in the click-open dialog +// +// The markdown file is also directly viewable in any markdown previewer +// (GitHub, VS Code, etc.) because both sections are valid markdown and +// the separator is an invisible HTML comment. +// +// Details section format: +// ## Heading β†’ section heading +// | Key | Description | β†’ key–description entry row (markdown table) +// Paragraphs β†’ plain-text description +// +// 2026 March +// Author: Desmond Kirkpatrick + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart' show rootBundle; + +/// A help button that loads its content from a markdown asset file. +/// +/// The markdown file must contain a `` marker and a +/// `` marker. Text between those markers becomes the +/// hover tooltip; text after `` is rendered as the +/// click-open dialog body. +/// +/// The first line of the file (an `# H1` heading) is used as the dialog +/// title. Everything before `` is ignored at runtime +/// (it serves as the visible title when previewing the raw markdown). +/// +/// ### Markdown file layout +/// +/// ```markdown +/// # 🌳 My Tool β€” Help ← dialog title (H1) +/// +/// +/// +/// Short keybinding summary ← hover tooltip (plain text) +/// shown on mouse hover. +/// +/// +/// +/// ## Section ← dialog section heading +/// +/// | Key | Description | ← table header (required before rows) +/// |-----|-------------| +/// | F | Fit to canvas | ← key–description entry +/// +/// Any paragraph text. ← rendered as body text +/// ``` +class MarkdownHelpButton extends StatefulWidget { + /// Path to the markdown asset file (e.g. `assets/help/my_help.md`). + final String assetPath; + + /// Whether the current theme is dark mode. + final bool isDark; + + /// Optional override for the button label (defaults to `❓`). + final String label; + + /// Optional widget to use as the button icon instead of [label]. + /// + /// When non-null, this widget is displayed instead of `Text(label)`. + /// Use this on platforms where the emoji [label] would not render + /// (e.g. Linux without NotoColorEmoji), passing an `Icon(Icons.help_outline)` + /// or similar Material icon. + final Widget? labelIcon; + + /// Optional package name that owns the asset. + /// + /// When non-null the actual asset path becomes + /// `packages/$package/$assetPath`, which is how Flutter resolves assets + /// declared in dependency packages. + final String? package; + + /// Optional widget shown before the dialog title text. + /// + /// Use this to display a custom icon (e.g. a `CustomPaint` widget) + /// next to the dialog title instead of relying on emoji characters + /// that may not render on all platforms. + final Widget? titleIcon; + + /// Optional text substitutions applied to the markdown before parsing. + /// + /// Each key `K` replaces all occurrences of `{{K}}` in the raw markdown + /// with the corresponding value. For example: + /// ```dart + /// substitutions: {'VERSION': '1.2.3'} + /// ``` + /// will replace `{{VERSION}}` β†’ `1.2.3` in the loaded asset. + final Map? substitutions; + + /// Create a [MarkdownHelpButton]. + const MarkdownHelpButton({ + required this.assetPath, + required this.isDark, + this.label = '❓', + this.labelIcon, + this.package, + this.titleIcon, + this.substitutions, + super.key, + }); + + @override + State createState() => _MarkdownHelpButtonState(); +} + +class _MarkdownHelpButtonState extends State { + /// Parsed help content, loaded once from the asset. + _HelpContent? _content; + + @override + void initState() { + super.initState(); + _loadContent(); + } + + @override + void didUpdateWidget(MarkdownHelpButton oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.assetPath != widget.assetPath || + oldWidget.package != widget.package) { + _loadContent(); + } + } + + Future _loadContent() async { + try { + String raw; + if (widget.package != null) { + // Try the package-qualified path first (works when embedded as a + // dependency in a host app), then fall back to the bare asset path + // (standalone mode). This order avoids a spurious 404 on the web + // when the bare path doesn't exist. + // Use catch-all because rootBundle.loadString throws FlutterError + // (an Error, not Exception) when the asset is missing. + try { + raw = await rootBundle + .loadString('packages/${widget.package}/${widget.assetPath}'); + // ignore: avoid_catches_without_on_clauses + } catch (_) { + raw = await rootBundle.loadString(widget.assetPath); + } + } else { + raw = await rootBundle.loadString(widget.assetPath); + } + // Apply substitutions before parsing. + final subs = widget.substitutions; + if (subs != null) { + for (final entry in subs.entries) { + raw = raw.replaceAll('{{${entry.key}}}', entry.value); + } + } + if (mounted) { + setState(() { + _content = _HelpContent.parse(raw); + }); + } + // ignore: avoid_catches_without_on_clauses + } catch (e) { + debugPrint('Failed to load help asset: $e'); + if (mounted) { + setState(() { + _content = _HelpContent.parse( + '# Help unavailable\n\n\n\n' + 'Help content could not be loaded.\n\n\n\n' + 'Error: $e', + ); + }); + } + } + } + + @override + Widget build(BuildContext context) { + final isDark = widget.isDark; + final tooltip = _content?.tooltip ?? 'Loading help…'; + + return Tooltip( + message: tooltip, + decoration: BoxDecoration( + color: isDark ? const Color(0xFF1E1E1E) : const Color(0xFFF5F5F5), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: isDark ? Colors.white24 : Colors.black12, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: isDark ? 0.4 : 0.15), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + textStyle: TextStyle( + fontSize: 12, + fontFamily: 'monospace', + color: isDark ? Colors.white : Colors.black87, + height: 1.4, + ), + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () { + if (_content != null) { + _showHelpDialog(context, _content!, + isDark: isDark, titleIcon: widget.titleIcon); + } + }, + child: Padding( + padding: const EdgeInsets.all(8), + child: widget.labelIcon ?? + Text(widget.label, + style: const TextStyle(fontSize: 18, inherit: false)), + ), + ), + ), + ); + } + + /// Show the help dialog with parsed markdown content. + static void _showHelpDialog( + BuildContext context, + _HelpContent content, { + required bool isDark, + Widget? titleIcon, + }) { + final bgColor = isDark ? const Color(0xFF252526) : Colors.white; + final fgColor = isDark ? Colors.white : Colors.black87; + final headingColor = isDark ? Colors.blue[200]! : Colors.blue[800]!; + final keyColor = isDark ? Colors.amber[200]! : Colors.amber[900]!; + final dividerColor = isDark ? Colors.white24 : Colors.black12; + + final widgets = []; + for (final block in content.detailBlocks) { + if (block is _HeadingBlock) { + widgets.add(Padding( + padding: const EdgeInsets.only(top: 16, bottom: 4), + child: Text(block.text, + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + color: headingColor, + )), + )); + } else if (block is _EntryBlock) { + widgets.add(Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 200, + child: Text(block.key, + style: TextStyle( + fontFamily: 'monospace', + fontSize: 13, + color: keyColor, + )), + ), + Expanded( + child: Text(block.description, + style: TextStyle(fontSize: 13, color: fgColor)), + ), + ], + ), + )); + } else if (block is _ParagraphBlock) { + widgets.add(Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: + Text(block.text, style: TextStyle(fontSize: 13, color: fgColor)), + )); + } + } + + showDialog( + context: context, + builder: (ctx) => Dialog( + backgroundColor: bgColor, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 600, maxHeight: 600), + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Title row + Row( + children: [ + if (titleIcon != null) ...[ + titleIcon, + const SizedBox(width: 10), + ], + Expanded( + child: Text(content.title, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: fgColor, + )), + ), + IconButton( + icon: Icon(Icons.close, color: fgColor, size: 20), + onPressed: () => Navigator.of(ctx).pop(), + ), + ], + ), + Divider(color: dividerColor), + // Scrollable content + Flexible( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: widgets, + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Parsed help content model +// --------------------------------------------------------------------------- + +/// Parsed representation of a help markdown file. +class _HelpContent { + /// Dialog title (from the `# H1` heading). + final String title; + + /// Plain-text tooltip (between `` and ``). + final String tooltip; + + /// Parsed detail blocks (headings, entries, paragraphs). + final List<_DetailBlock> detailBlocks; + + _HelpContent({ + required this.title, + required this.tooltip, + required this.detailBlocks, + }); + + /// Parse a raw markdown string into [_HelpContent]. + factory _HelpContent.parse(String raw) { + const tooltipMarker = ''; + const detailsMarker = ''; + + final tooltipIdx = raw.indexOf(tooltipMarker); + final detailsIdx = raw.indexOf(detailsMarker); + + // Extract title from the first # heading. + String title = 'Help'; + final titleMatch = RegExp(r'^#\s+(.+)$', multiLine: true).firstMatch(raw); + if (titleMatch != null) { + title = titleMatch.group(1)!.trim(); + } + + // Extract tooltip text. + String tooltip = ''; + if (tooltipIdx >= 0 && detailsIdx > tooltipIdx) { + tooltip = + raw.substring(tooltipIdx + tooltipMarker.length, detailsIdx).trim(); + } + + // Parse detail blocks. + final detailBlocks = <_DetailBlock>[]; + if (detailsIdx >= 0) { + final detailsRaw = raw.substring(detailsIdx + detailsMarker.length); + detailBlocks.addAll(_parseDetails(detailsRaw)); + } + + return _HelpContent( + title: title, + tooltip: tooltip, + detailBlocks: detailBlocks, + ); + } + + /// Parse the details section into blocks. + static List<_DetailBlock> _parseDetails(String raw) { + final blocks = <_DetailBlock>[]; + final lines = raw.split('\n'); + + for (int i = 0; i < lines.length; i++) { + final line = lines[i]; + final trimmed = line.trim(); + + // Skip empty lines + if (trimmed.isEmpty) { + continue; + } + + // ## Heading + if (trimmed.startsWith('## ')) { + blocks.add(_HeadingBlock(trimmed.substring(3).trim())); + continue; + } + + // Table separator row (|---|---|) β€” skip + if (RegExp(r'^\|[\s\-:|]+\|$').hasMatch(trimmed)) { + continue; + } + + // Table header row (| Key | Description |) β€” skip + if (trimmed.startsWith('|') && + trimmed.endsWith('|') && + i + 1 < lines.length && + RegExp(r'^\|[\s\-:|]+\|$').hasMatch(lines[i + 1].trim())) { + continue; + } + + // Table data row (| key | description |) + if (trimmed.startsWith('|') && trimmed.endsWith('|')) { + final cells = trimmed + .substring(1, trimmed.length - 1) // strip outer pipes + .split('|') + .map((c) => c.trim()) + .toList(); + if (cells.length >= 2) { + blocks.add(_EntryBlock( + key: _stripInlineCode(cells[0]), + description: cells[1], + )); + continue; + } + } + + // Plain paragraph text (collect consecutive non-empty lines) + final para = StringBuffer(trimmed); + while (i + 1 < lines.length && lines[i + 1].trim().isNotEmpty) { + final next = lines[i + 1].trim(); + // Stop at headings, table rows, or markers + if (next.startsWith('## ') || + next.startsWith('|') || + next.startsWith('