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/doc/tutorials/chapter_6/answers/exercise_2_n_bit_subtractor.dart b/doc/tutorials/chapter_6/answers/exercise_2_n_bit_subtractor.dart index 5765f08bf..18efb5a2d 100644 --- a/doc/tutorials/chapter_6/answers/exercise_2_n_bit_subtractor.dart +++ b/doc/tutorials/chapter_6/answers/exercise_2_n_bit_subtractor.dart @@ -7,7 +7,6 @@ import '../../chapter_3/answers/helper.dart'; import '../../chapter_5/answers/full_subtractor.dart'; class FullSubtractorComb extends FullSubtractor { - @override FullSubtractorComb(super.a, super.b, super.borrowIn) { // Declare input and output final a = input('a'); 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/lib/rohd.dart b/lib/rohd.dart index 841505590..d0ea2a266 100644 --- a/lib/rohd.dart +++ b/lib/rohd.dart @@ -1,6 +1,7 @@ // Copyright (C) 2021-2023 Intel Corporation // SPDX-License-Identifier: BSD-3-Clause +export 'src/diagnostics/module_services.dart'; export 'src/exceptions/exceptions.dart'; export 'src/external.dart'; export 'src/finite_state_machine.dart'; diff --git a/lib/src/diagnostics/module_services.dart b/lib/src/diagnostics/module_services.dart new file mode 100644 index 000000000..a0e583b59 --- /dev/null +++ b/lib/src/diagnostics/module_services.dart @@ -0,0 +1,78 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// module_services.dart +// Singleton service registry for DevTools integration. +// +// 2026 April 25 +// Author: Desmond Kirkpatrick + +import 'dart:convert'; + +import 'package:rohd/rohd.dart'; +import 'package:rohd/src/diagnostics/inspector_service.dart'; + +/// Singleton service registry that provides a unified query surface for +/// DevTools and other inspection tools. +/// +/// Services register themselves here on construction; DevTools evaluates +/// getters on [instance] via `EvalOnDartLibrary` to pull data. +/// +/// **Auto-registered:** +/// - [rootModule] / [hierarchyJSON] — set by [Module.build]. +/// +/// **Opt-in (registered by service constructors):** +/// - [svService] — SystemVerilog synthesis results. +/// +/// Additional services (netlist, trace, waveform) can be added by setting +/// the corresponding field after construction. +class ModuleServices { + ModuleServices._(); + + /// The singleton instance. + static final ModuleServices instance = ModuleServices._(); + + // ─── Hierarchy (auto-registered by Module.build) ────────────── + + /// The most recently built top-level [Module]. + /// + /// Set automatically at the end of [Module.build]. + Module? rootModule; + + /// Returns the module hierarchy as a JSON string. + /// + /// DevTools evaluates this via `EvalOnDartLibrary` to display + /// the module hierarchy. + String get hierarchyJSON { + ModuleTree.rootModuleInstance = rootModule; + return ModuleTree.instance.hierarchyJSON; + } + + /// Returns the primary inspector JSON for DevTools. + /// + /// Returns the hierarchy JSON. Downstream branches (e.g. netlist) may + /// override this to return richer data when available. + String get inspectorJSON => hierarchyJSON; + + // ─── SystemVerilog service (opt-in) ─────────────────────────── + + /// The active [SvService], if one has been registered. + SvService? svService; + + /// Returns SV synthesis metadata as JSON, or an unavailable status. + String get svJSON => + svService != null ? jsonEncode(svService!.toJson()) : _unavailable('sv'); + + // ─── Helpers ────────────────────────────────────────────────── + + static String _unavailable(String service) => jsonEncode({ + 'status': 'unavailable', + 'reason': '$service service not registered', + }); + + /// Resets all services. Intended for test teardown. + void reset() { + rootModule = null; + svService = null; + } +} diff --git a/lib/src/module.dart b/lib/src/module.dart index 92fc410e0..9f6ec634e 100644 --- a/lib/src/module.dart +++ b/lib/src/module.dart @@ -1,4 +1,4 @@ -// Copyright (C) 2021-2025 Intel Corporation +// Copyright (C) 2021-2026 Intel Corporation // SPDX-License-Identifier: BSD-3-Clause // // module.dart @@ -11,11 +11,10 @@ import 'dart:async'; import 'dart:collection'; import 'package:meta/meta.dart'; - import 'package:rohd/rohd.dart'; import 'package:rohd/src/collections/traverseable_collection.dart'; -import 'package:rohd/src/diagnostics/inspector_service.dart'; import 'package:rohd/src/utilities/config.dart'; +import 'package:rohd/src/utilities/namer.dart'; import 'package:rohd/src/utilities/sanitizer.dart'; import 'package:rohd/src/utilities/timestamper.dart'; import 'package:rohd/src/utilities/uniquifier.dart'; @@ -52,6 +51,22 @@ abstract class Module { /// An internal mapping of input names to their sources to this [Module]. late final Map _inputSources = {}; + // ─── Central naming (Namer) ───────────────────────────────────── + + /// Central namer that owns both the signal and instance namespaces. + /// Initialized lazily on first access (after build). + @internal + late final Namer namer = _createNamer(); + + Namer _createNamer() { + assert(hasBuilt, 'Module must be built before canonical names are bound.'); + return Namer.forModule( + inputs: _inputs, + outputs: _outputs, + inOuts: _inOuts, + ); + } + /// An internal mapping of inOut names to their sources to this [Module]. late final Map _inOutSources = {}; @@ -317,7 +332,7 @@ abstract class Module { _hasBuilt = true; - ModuleTree.rootModuleInstance = this; + ModuleServices.instance.rootModule = this; } /// Confirms that the post-[build] hierarchy is valid. diff --git a/lib/src/synthesizers/synth_builder.dart b/lib/src/synthesizers/synth_builder.dart index 54e312ab3..f9d0a0d08 100644 --- a/lib/src/synthesizers/synth_builder.dart +++ b/lib/src/synthesizers/synth_builder.dart @@ -1,4 +1,4 @@ -// Copyright (C) 2021-2025 Intel Corporation +// Copyright (C) 2021-2026 Intel Corporation // SPDX-License-Identifier: BSD-3-Clause // // synth_builder.dart diff --git a/lib/src/synthesizers/synthesizer.dart b/lib/src/synthesizers/synthesizer.dart index b70c9338e..687bbab03 100644 --- a/lib/src/synthesizers/synthesizer.dart +++ b/lib/src/synthesizers/synthesizer.dart @@ -1,4 +1,4 @@ -// Copyright (C) 2021-2023 Intel Corporation +// Copyright (C) 2021-2026 Intel Corporation // SPDX-License-Identifier: BSD-3-Clause // // synthesizer.dart @@ -6,7 +6,6 @@ // // 2021 August 26 // Author: Max Korbel -// import 'package:rohd/rohd.dart'; diff --git a/lib/src/synthesizers/systemverilog/sv_service.dart b/lib/src/synthesizers/systemverilog/sv_service.dart new file mode 100644 index 000000000..e1adf43cd --- /dev/null +++ b/lib/src/synthesizers/systemverilog/sv_service.dart @@ -0,0 +1,114 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// sv_service.dart +// Service wrapper for SystemVerilog synthesis. +// +// 2026 April 25 +// Author: Desmond Kirkpatrick + +import 'dart:io'; + +import 'package:collection/collection.dart'; +import 'package:rohd/rohd.dart'; + +/// A service that wraps SystemVerilog synthesis of a [Module] hierarchy. +/// +/// Provides access to the generated SV file contents and per-module +/// synthesis results, and optionally registers with [ModuleServices] +/// for DevTools inspection. +/// +/// Example: +/// ```dart +/// final dut = MyModule(...); +/// await dut.build(); +/// final sv = SvService(dut); +/// +/// // Write individual .sv files: +/// sv.writeFiles('build/'); +/// +/// // Or get the concatenated output (like generateSynth): +/// print(sv.allContents); +/// ``` +class SvService { + /// The top-level [Module] being synthesized. + final Module module; + + /// The underlying [SynthBuilder] that drove synthesis. + late final SynthBuilder synthBuilder; + + /// The generated file contents (one per unique module definition). + late final List fileContents; + + /// Creates an [SvService] for [module]. + /// + /// [module] must already be built. Set [register] to `true` (the + /// default) to register this service with [ModuleServices] for + /// DevTools access. + SvService(this.module, {bool register = true}) { + if (!module.hasBuilt) { + throw Exception('Module must be built before creating SvService. ' + 'Call build() first.'); + } + + synthBuilder = SynthBuilder(module, SystemVerilogSynthesizer()); + fileContents = synthBuilder.getSynthFileContents(); + + if (register) { + ModuleServices.instance.svService = this; + } + } + + /// All [SynthesisResult]s produced by synthesis. + Set get synthesisResults => synthBuilder.synthesisResults; + + /// Returns the concatenated SystemVerilog output as a single string, + /// matching the format of [Module.generateSynth]. + String get allContents => fileContents.map((fc) => fc.contents).join('\n\n'); + + /// Returns a map from module definition name to its SV file contents. + /// + /// Keys are [SynthesisResult.instanceTypeName] (the uniquified definition + /// name used in the generated SV). + Map get contentsByName => { + for (final fc in fileContents) fc.name: fc.contents, + }; + + /// Returns a map from module definition name + /// ([Module.definitionName]) to its SV file contents. + /// + /// This uses the original definition name (not uniquified), matching + /// the keys used by FLC trace data. + Map get contentsByDefinitionName { + final result = {}; + for (final sr in synthesisResults) { + final defName = sr.module.definitionName; + final instanceName = sr.instanceTypeName; + // Find the file content matching this instance type name. + final fc = fileContents.firstWhereOrNull((f) => f.name == instanceName); + if (fc != null) { + result[defName] = fc.contents; + } + } + return result; + } + + /// Writes each module's SV to a separate file in [directory]. + /// + /// Files are named `.sv`. + void writeFiles(String directory) { + final dir = Directory(directory)..createSync(recursive: true); + for (final fc in fileContents) { + File('${dir.path}/${fc.name}.sv').writeAsStringSync(fc.contents); + } + } + + /// Returns a JSON-serialisable summary of the SV synthesis. + /// + /// Contains the list of generated module definition names. + Map toJson() => { + 'modules': [ + for (final fc in fileContents) fc.name, + ], + }; +} diff --git a/lib/src/synthesizers/systemverilog/systemverilog.dart b/lib/src/synthesizers/systemverilog/systemverilog.dart index 281b05df9..e5f772e44 100644 --- a/lib/src/synthesizers/systemverilog/systemverilog.dart +++ b/lib/src/synthesizers/systemverilog/systemverilog.dart @@ -1,5 +1,6 @@ // Copyright (C) 2021-2024 Intel Corporation // SPDX-License-Identifier: BSD-3-Clause +export 'sv_service.dart'; export 'systemverilog_mixins.dart'; export 'systemverilog_synthesizer.dart'; diff --git a/lib/src/synthesizers/systemverilog/systemverilog_synthesizer.dart b/lib/src/synthesizers/systemverilog/systemverilog_synthesizer.dart index d8b5bae36..062647ac3 100644 --- a/lib/src/synthesizers/systemverilog/systemverilog_synthesizer.dart +++ b/lib/src/synthesizers/systemverilog/systemverilog_synthesizer.dart @@ -1,4 +1,4 @@ -// Copyright (C) 2021-2025 Intel Corporation +// Copyright (C) 2021-2026 Intel Corporation // SPDX-License-Identifier: BSD-3-Clause // // systemverilog_synthesizer.dart diff --git a/lib/src/synthesizers/utilities/synth_logic.dart b/lib/src/synthesizers/utilities/synth_logic.dart index c3026a0d5..8fcbc014a 100644 --- a/lib/src/synthesizers/utilities/synth_logic.dart +++ b/lib/src/synthesizers/utilities/synth_logic.dart @@ -11,8 +11,8 @@ import 'package:collection/collection.dart'; import 'package:meta/meta.dart'; import 'package:rohd/rohd.dart'; import 'package:rohd/src/synthesizers/utilities/utilities.dart'; +import 'package:rohd/src/utilities/namer.dart'; import 'package:rohd/src/utilities/sanitizer.dart'; -import 'package:rohd/src/utilities/uniquifier.dart'; /// Represents a logic signal in the generated code within a module. @internal @@ -212,92 +212,25 @@ class SynthLogic { /// The name of this, if it has been picked. String? _name; - /// Picks a [name]. + /// Picks a [name] using the module's signal namer. /// /// Must be called exactly once. - void pickName(Uniquifier uniquifier) { + void pickName() { assert(_name == null, 'Should only pick a name once.'); - _name = _findName(uniquifier); + _name = _findName(); } /// Finds the best name from the collection of [Logic]s. - String _findName(Uniquifier uniquifier) { - // check for const - if (_constLogic != null) { - if (!_constNameDisallowed) { - return _constLogic!.value.toString(); - } else { - assert( - logics.length > 1, - 'If there is a constant, but the const name is not allowed, ' - 'there needs to be another option', - ); - } - } - - // check for reserved - if (_reservedLogic != null) { - return uniquifier.getUniqueName( - initialName: _reservedLogic!.name, - reserved: true, - ); - } - - // check for renameable - if (_renameableLogic != null) { - return uniquifier.getUniqueName( - initialName: _renameableLogic!.preferredSynthName, - ); - } - - // pick a preferred, available, mergeable name, if one exists - final unpreferredMergeableLogics = []; - final uniquifiableMergeableLogics = []; - for (final mergeableLogic in _mergeableLogics) { - if (Naming.isUnpreferred(mergeableLogic.preferredSynthName)) { - unpreferredMergeableLogics.add(mergeableLogic); - } else if (!uniquifier.isAvailable(mergeableLogic.preferredSynthName)) { - uniquifiableMergeableLogics.add(mergeableLogic); - } else { - return uniquifier.getUniqueName( - initialName: mergeableLogic.preferredSynthName, - ); - } - } - - // uniquify a preferred, mergeable name, if one exists - if (uniquifiableMergeableLogics.isNotEmpty) { - return uniquifier.getUniqueName( - initialName: uniquifiableMergeableLogics.first.preferredSynthName, - ); - } - - // pick an available unpreferred mergeable name, if one exists, otherwise - // uniquify an unpreferred mergeable name - if (unpreferredMergeableLogics.isNotEmpty) { - return uniquifier.getUniqueName( - initialName: unpreferredMergeableLogics - .firstWhereOrNull( - (element) => - uniquifier.isAvailable(element.preferredSynthName), - ) - ?.preferredSynthName ?? - unpreferredMergeableLogics.first.preferredSynthName, + /// + /// Delegates to signal namer which handles constant value naming, priority + /// selection, and uniquification via the module's shared namespace. + String _findName() => + parentSynthModuleDefinition.module.namer.signalNameOfBest( + logics, + constValue: _constLogic, + constNameDisallowed: _constNameDisallowed, ); - } - - // pick anything (unnamed) and uniquify as necessary (considering preferred) - // no need to prefer an available one here, since it's all unnamed - return uniquifier.getUniqueName( - initialName: _unnamedLogics - .firstWhereOrNull( - (element) => !Naming.isUnpreferred(element.preferredSynthName), - ) - ?.preferredSynthName ?? - _unnamedLogics.first.preferredSynthName, - ); - } /// Creates an instance to represent [initialLogic] and any that merge /// into it. @@ -404,7 +337,7 @@ class SynthLogic { @override String toString() => '${_name == null ? 'null' : '"$name"'}, ' - 'logics contained: ${logics.map((e) => e.preferredSynthName).toList()}'; + 'logics contained: ${logics.map(Namer.baseName).toList()}'; /// Provides a definition for a range in SV from a width. static String _widthToRangeDef(int width, {bool forceRange = false}) { @@ -551,17 +484,3 @@ class SynthLogicArrayElement extends SynthLogic { ' parentArray=($parentArray), element ${logic.arrayIndex}, logic: $logic' ' logics contained: ${logics.map((e) => e.name).toList()}'; } - -extension on Logic { - /// Returns the preferred name for this [Logic] while generating in the synth - /// stack. - String get preferredSynthName => naming == Naming.reserved - // if reserved, keep the exact name - ? name - : isArrayMember - // arrays nicely name their elements already - ? name - // sanitize to remove any `.` in struct names - // the base `name` will be returned if not a structure. - : Sanitizer.sanitizeSV(structureName); -} diff --git a/lib/src/synthesizers/utilities/synth_module_definition.dart b/lib/src/synthesizers/utilities/synth_module_definition.dart index 37ebfb323..9ea120646 100644 --- a/lib/src/synthesizers/utilities/synth_module_definition.dart +++ b/lib/src/synthesizers/utilities/synth_module_definition.dart @@ -14,7 +14,6 @@ import 'package:meta/meta.dart'; import 'package:rohd/rohd.dart'; import 'package:rohd/src/collections/traverseable_collection.dart'; import 'package:rohd/src/synthesizers/utilities/utilities.dart'; -import 'package:rohd/src/utilities/uniquifier.dart'; /// A version of [BusSubset] that can be used for slicing on [LogicStructure] /// ports. @@ -110,10 +109,6 @@ class SynthModuleDefinition { @override String toString() => "module name: '${module.name}'"; - /// Used to uniquify any identifiers, including signal names - /// and module instances. - final Uniquifier _synthInstantiationNameUniquifier; - /// Indicates whether [logic] has a corresponding present [SynthLogic] in /// this definition. @internal @@ -289,14 +284,7 @@ class SynthModuleDefinition { /// Creates a new definition representation for this [module]. SynthModuleDefinition(this.module) - : _synthInstantiationNameUniquifier = Uniquifier( - reservedNames: { - ...module.inputs.keys, - ...module.outputs.keys, - ...module.inOuts.keys, - }, - ), - assert( + : assert( !(module is SystemVerilog && module.generatedDefinitionType == DefinitionGenerationType.none), @@ -465,6 +453,7 @@ class SynthModuleDefinition { final receiverIsSubModuleOutput = receiver.isOutput && (receiver.parentModule?.parent == module); + if (receiverIsSubModuleOutput) { final subModule = receiver.parentModule!; @@ -513,6 +502,7 @@ class SynthModuleDefinition { _collapseArrays(); _collapseAssignments(); _assignSubmodulePortMapping(); + _pruneUnused(); process(); _pickNames(); @@ -767,49 +757,59 @@ class SynthModuleDefinition { } /// Picks names of signals and sub-modules. + /// + /// Signal names are read from `Namer.signalNameOf` (for user-created + /// [Logic] objects) or kept as literal constants and are allocated from + /// `Namer.signalNameOf`. Submodule instance names are allocated + /// from `Namer.allocateRawName`. All names share a single + /// namespace managed by the module's `Namer`. void _pickNames() { - // first ports get priority + // Name allocation order matters — earlier claims get the unsuffixed name + // when there are collisions. This matches production ROHD priority: + // 1. Ports (reserved by _initNamespace, claimed via signalName) + // 2. Reserved submodule instances + // 3. Reserved internal signals + // 4. Non-reserved submodule instances + // 5. Non-reserved internal signals for (final input in inputs) { - input.pickName(_synthInstantiationNameUniquifier); + input.pickName(); } for (final output in outputs) { - output.pickName(_synthInstantiationNameUniquifier); + output.pickName(); } for (final inOut in inOuts) { - inOut.pickName(_synthInstantiationNameUniquifier); + inOut.pickName(); } - // pick names of *reserved* submodule instances - final nonReservedSubmodules = []; + // Reserved submodule instances first (they assert their exact name). for (final submodule in subModuleInstantiations) { if (submodule.module.reserveName) { - submodule.pickName(_synthInstantiationNameUniquifier); + submodule.pickName(module); assert(submodule.module.name == submodule.name, 'Expect reserved names to retain their name.'); - } else { - nonReservedSubmodules.add(submodule); } } - // then *reserved* internal signals get priority + // Reserved internal signals next. final nonReservedSignals = []; for (final signal in internalSignals) { if (signal.isReserved) { - signal.pickName(_synthInstantiationNameUniquifier); + signal.pickName(); } else { nonReservedSignals.add(signal); } } - // then submodule instances - for (final submodule in nonReservedSubmodules - .where((element) => element.needsInstantiation)) { - submodule.pickName(_synthInstantiationNameUniquifier); + // Then non-reserved submodule instances. + for (final submodule in subModuleInstantiations) { + if (!submodule.module.reserveName && submodule.needsInstantiation) { + submodule.pickName(module); + } } - // then the rest of the internal signals + // Then the rest of the internal signals. for (final signal in nonReservedSignals) { - signal.pickName(_synthInstantiationNameUniquifier); + signal.pickName(); } } diff --git a/lib/src/synthesizers/utilities/synth_sub_module_instantiation.dart b/lib/src/synthesizers/utilities/synth_sub_module_instantiation.dart index 80a415a09..cf7da28e8 100644 --- a/lib/src/synthesizers/utilities/synth_sub_module_instantiation.dart +++ b/lib/src/synthesizers/utilities/synth_sub_module_instantiation.dart @@ -1,4 +1,4 @@ -// Copyright (C) 2021-2025 Intel Corporation +// Copyright (C) 2021-2026 Intel Corporation // SPDX-License-Identifier: BSD-3-Clause // // synth_sub_module_instantiation.dart @@ -11,7 +11,6 @@ import 'dart:collection'; import 'package:rohd/rohd.dart'; import 'package:rohd/src/synthesizers/utilities/utilities.dart'; -import 'package:rohd/src/utilities/uniquifier.dart'; /// Represents an instantiation of a module within another module. class SynthSubModuleInstantiation { @@ -25,13 +24,15 @@ class SynthSubModuleInstantiation { String get name => _name!; /// Selects a name for this module instance. Must be called exactly once. - void pickName(Uniquifier uniquifier) { + /// + /// Names are allocated from [parentModule]'s `Namer`'s shared namespace + /// via `Namer.allocateName`. + void pickName(Module parentModule) { assert(_name == null, 'Should only pick a name once.'); - _name = uniquifier.getUniqueName( - initialName: module.uniqueInstanceName, + _name = parentModule.namer.allocateRawName( + module.uniqueInstanceName, reserved: module.reserveName, - nullStarter: 'm', ); } diff --git a/lib/src/utilities/namer.dart b/lib/src/utilities/namer.dart new file mode 100644 index 000000000..efbe8e3e4 --- /dev/null +++ b/lib/src/utilities/namer.dart @@ -0,0 +1,233 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// namer.dart +// Central collision-free naming for signals and instances within a module. +// +// 2026 April 10 +// Author: Desmond Kirkpatrick + +import 'package:collection/collection.dart'; +import 'package:meta/meta.dart'; +import 'package:rohd/rohd.dart'; +import 'package:rohd/src/utilities/sanitizer.dart'; +import 'package:rohd/src/utilities/uniquifier.dart'; + +/// Central namer that manages collision-free names for both signals and +/// submodule instances within a single module scope. +/// +/// All identifiers (signals and instances) share a single namespace, +/// ensuring no name collisions in the generated SystemVerilog. +/// +/// Port names are reserved at construction time. Internal signal names +/// are assigned lazily on the first [signalNameOf] call. Instance names +/// are allocated explicitly via [allocateRawName]. +@internal +class Namer { + // ─── Shared namespace ─────────────────────────────────────────── + + final Uniquifier _uniquifier; + + /// Cache of resolved names for internal (non-port) signals only. + /// Port names are returned directly from [_portLogics] and never cached here. + final Map _signalNames = {}; + + /// The set of port [Logic] objects, for O(1) port membership tests. + final Set _portLogics; + + // ─── Construction ─────────────────────────────────────────────── + + Namer._({ + required Uniquifier uniquifier, + required Set portLogics, + }) : _uniquifier = uniquifier, + _portLogics = portLogics; + + /// Creates a [Namer] for the given module ports. + /// + /// Port names are reserved in the shared namespace. Port names are + /// guaranteed sanitary by [Module]'s `_checkForSafePortName`. + factory Namer.forModule({ + required Map inputs, + required Map outputs, + required Map inOuts, + }) { + final portLogics = { + ...inputs.values, + ...outputs.values, + ...inOuts.values, + }; + + final uniquifier = Uniquifier(); + for (final logic in portLogics) { + uniquifier.getUniqueName(initialName: logic.name, reserved: true); + } + + return Namer._( + uniquifier: uniquifier, + portLogics: portLogics, + ); + } + + // ─── Name availability / allocation ───────────────────────────── + + /// Returns `true` if [name] has not yet been claimed in the namespace. + bool isAvailable(String name) => _uniquifier.isAvailable(name); + + /// Allocates a collision-free name in the shared namespace. + /// + /// When [reserved] is `true`, the exact [baseName] (after sanitization) + /// is claimed without modification; an exception is thrown if it collides. + String allocateRawName(String baseName, {bool reserved = false}) => + _uniquifier.getUniqueName( + initialName: Sanitizer.sanitizeSV(baseName), + reserved: reserved, + ); + + // ─── Signal naming (Logic → String) ───────────────────────────── + + /// Returns the canonical name for [logic]. + /// + /// The first call for a given [logic] allocates a collision-free name + /// via the underlying [Uniquifier]. Subsequent calls return the cached + /// result in O(1). + String signalNameOf(Logic logic) { + final cached = _signalNames[logic]; + if (cached != null) { + return cached; + } + + if (_portLogics.contains(logic)) { + return logic.name; + } + + String base; + final isReservedInternal = logic.naming == Naming.reserved && !logic.isPort; + if (logic.naming == Naming.reserved || logic.isArrayMember) { + base = logic.name; + } else { + base = Sanitizer.sanitizeSV(logic.structureName); + } + + final name = _uniquifier.getUniqueName( + initialName: base, + reserved: isReservedInternal, + ); + _signalNames[logic] = name; + return name; + } + + /// The base name that would be used for [logic] before uniquification. + static String baseName(Logic logic) => + (logic.naming == Naming.reserved || logic.isArrayMember) + ? logic.name + : Sanitizer.sanitizeSV(logic.structureName); + + /// Chooses the best name from a pool of merged [Logic] signals. + /// + /// When [constValue] is provided and [constNameDisallowed] is `false`, + /// the constant's value string is used directly as the name (no + /// uniquification). When [constNameDisallowed] is `true`, the constant + /// is excluded from the candidate pool and the normal priority applies. + /// + /// Priority (after constant handling): + /// 1. Port of this module (always wins — its name is already reserved). + /// 2. Reserved internal signal (exact name, throws on collision). + /// 3. Renameable signal. + /// 4. Preferred-available mergeable (base name not yet taken). + /// 5. Preferred-uniquifiable mergeable. + /// 6. Available-unpreferred mergeable. + /// 7. First unpreferred mergeable. + /// 8. Unnamed (prefer non-unpreferred base name). + /// + /// The winning name is allocated once and cached for the chosen [Logic]. + /// All other non-port [Logic]s in [candidates] are also cached to the + /// same name. + String signalNameOfBest( + Iterable candidates, { + Const? constValue, + bool constNameDisallowed = false, + }) { + if (constValue != null && !constNameDisallowed) { + return constValue.value.toString(); + } + + Logic? port; + Logic? reserved; + Logic? renameable; + final preferredMergeable = []; + final unpreferredMergeable = []; + final unnamed = []; + + for (final logic in candidates) { + if (_portLogics.contains(logic)) { + port = logic; + } else if (logic.isPort) { + if (Naming.isUnpreferred(baseName(logic))) { + unpreferredMergeable.add(logic); + } else { + preferredMergeable.add(logic); + } + } else if (logic.naming == Naming.reserved) { + reserved = logic; + } else if (logic.naming == Naming.renameable) { + renameable = logic; + } else if (logic.naming == Naming.mergeable) { + if (Naming.isUnpreferred(baseName(logic))) { + unpreferredMergeable.add(logic); + } else { + preferredMergeable.add(logic); + } + } else { + unnamed.add(logic); + } + } + + if (port != null) { + return _nameAndCacheAll(port, candidates); + } + + if (reserved != null) { + return _nameAndCacheAll(reserved, candidates); + } + + if (renameable != null) { + return _nameAndCacheAll(renameable, candidates); + } + + if (preferredMergeable.isNotEmpty) { + final best = preferredMergeable + .firstWhereOrNull((e) => isAvailable(baseName(e))) ?? + preferredMergeable.first; + return _nameAndCacheAll(best, candidates); + } + + if (unpreferredMergeable.isNotEmpty) { + final best = unpreferredMergeable + .firstWhereOrNull((e) => isAvailable(baseName(e))) ?? + unpreferredMergeable.first; + return _nameAndCacheAll(best, candidates); + } + + if (unnamed.isNotEmpty) { + final best = + unnamed.firstWhereOrNull((e) => !Naming.isUnpreferred(baseName(e))) ?? + unnamed.first; + return _nameAndCacheAll(best, candidates); + } + + throw StateError('No Logic candidates to name.'); + } + + /// Names [chosen] via [signalNameOf], then caches the same name for all + /// other non-port [Logic]s in [all]. + String _nameAndCacheAll(Logic chosen, Iterable all) { + final name = signalNameOf(chosen); + for (final logic in all) { + if (!identical(logic, chosen) && !_portLogics.contains(logic)) { + _signalNames[logic] = name; + } + } + return name; + } +} 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..d83519f96 100644 --- a/rohd_devtools_extension/lib/rohd_devtools/services/tree_service.dart +++ b/rohd_devtools_extension/lib/rohd_devtools/services/tree_service.dart @@ -10,10 +10,12 @@ 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 { - final invokeFunc = 'ModuleTree.instance.hierarchyJSON'; + final invokeFunc = 'ModuleServices.instance.hierarchyJSON'; final EvalOnDartLibrary rohdControllerEval; final Disposable evalDisposable; @@ -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('