From 82e900e48a66704cf22c385959e5ee1d6a56be7b Mon Sep 17 00:00:00 2001 From: "Desmond A. Kirkpatrick" Date: Fri, 17 Apr 2026 08:30:50 -0700 Subject: [PATCH 01/24] move synthesis naming to a common naming utility so all synthesizers agree on names --- lib/src/module.dart | 39 +- lib/src/synthesizers/synth_builder.dart | 5 +- lib/src/synthesizers/synthesizer.dart | 22 +- .../systemverilog_synthesizer.dart | 6 +- .../synthesizers/utilities/synth_logic.dart | 81 +-- .../utilities/synth_module_definition.dart | 60 +- .../synth_sub_module_instantiation.dart | 14 +- lib/src/utilities/signal_namer.dart | 271 ++++++++ test/naming_cases_test.dart | 583 ++++++++++++++++++ test/naming_consistency_test.dart | 247 ++++++++ 10 files changed, 1215 insertions(+), 113 deletions(-) create mode 100644 lib/src/utilities/signal_namer.dart create mode 100644 test/naming_cases_test.dart create mode 100644 test/naming_consistency_test.dart diff --git a/lib/src/module.dart b/lib/src/module.dart index 0fd51eac7..09e11fdc7 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,12 +11,12 @@ 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/sanitizer.dart'; +import 'package:rohd/src/utilities/signal_namer.dart'; import 'package:rohd/src/utilities/timestamper.dart'; import 'package:rohd/src/utilities/uniquifier.dart'; @@ -52,6 +52,41 @@ abstract class Module { /// An internal mapping of input names to their sources to this [Module]. late final Map _inputSources = {}; + // ─── Canonical naming (SignalNamer) ───────────────────────────── + + /// Lazily-constructed namer that owns the [Uniquifier] and the + /// sparse Logic→String cache. Initialized on first access. + @internal + late final SignalNamer signalNamer = _createSignalNamer(); + + SignalNamer _createSignalNamer() { + assert(hasBuilt, 'Module must be built before canonical names are bound.'); + return SignalNamer.forModule( + inputs: _inputs, + outputs: _outputs, + inOuts: _inOuts, + ); + } + + /// Returns the collision-free signal name for [logic] within this module. + String signalName(Logic logic) => signalNamer.nameOf(logic); + + /// Allocates a collision-free signal name in this module's namespace. + /// + /// Used by synthesizers to name connection nets, submodule instances, + /// intermediate wires, and other artifacts that have no user-created + /// [Logic] object. The returned name is guaranteed not to collide with + /// any signal name or any previously allocated name. + /// + /// When [reserved] is `true`, the exact [baseName] (after sanitization) is + /// claimed without modification; an exception is thrown if it collides. + String allocateSignalName(String baseName, {bool reserved = false}) => + signalNamer.allocate(baseName, reserved: reserved); + + /// Returns `true` if [name] has not yet been claimed as a signal name in + /// this module's namespace. + bool isSignalNameAvailable(String name) => signalNamer.isAvailable(name); + /// An internal mapping of inOut names to their sources to this [Module]. late final Map _inOutSources = {}; diff --git a/lib/src/synthesizers/synth_builder.dart b/lib/src/synthesizers/synth_builder.dart index 54e312ab3..3b3a6011c 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 @@ -56,6 +56,9 @@ class SynthBuilder { } } + // Allow the synthesizer to prepare with knowledge of top module(s) + synthesizer.prepare(this.tops); + final modulesToParse = [...tops]; for (var i = 0; i < modulesToParse.length; i++) { final moduleI = modulesToParse[i]; diff --git a/lib/src/synthesizers/synthesizer.dart b/lib/src/synthesizers/synthesizer.dart index b70c9338e..2d7730208 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,18 +6,34 @@ // // 2021 August 26 // Author: Max Korbel -// import 'package:rohd/rohd.dart'; /// An object capable of converting a module into some new output format abstract class Synthesizer { + /// Called by [SynthBuilder] before synthesis begins, with the top-level + /// module(s) being synthesized. + /// + /// Override this method to perform any initialization that requires + /// knowledge of the top module, such as resolving port names to [Logic] + /// objects, or computing global signal sets. + /// + /// The default implementation does nothing. + void prepare(List tops) {} + /// Determines whether [module] needs a separate definition or can just be /// described in-line. bool generatesDefinition(Module module); /// Synthesizes [module] into a [SynthesisResult], given the mapping provided /// by [getInstanceTypeOfModule]. + /// + /// Optionally a [lookupExistingResult] callback may be supplied which + /// allows the synthesizer to query already-generated `SynthesisResult`s + /// for child modules (useful when building parent output that needs + /// information from children). SynthesisResult synthesize( - Module module, String Function(Module module) getInstanceTypeOfModule); + Module module, String Function(Module module) getInstanceTypeOfModule, + {SynthesisResult? Function(Module module)? lookupExistingResult, + Map? existingResults}); } diff --git a/lib/src/synthesizers/systemverilog/systemverilog_synthesizer.dart b/lib/src/synthesizers/systemverilog/systemverilog_synthesizer.dart index d8b5bae36..b83acb9cc 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 @@ -137,7 +137,9 @@ class SystemVerilogSynthesizer extends Synthesizer { @override SynthesisResult synthesize( - Module module, String Function(Module module) getInstanceTypeOfModule) { + Module module, String Function(Module module) getInstanceTypeOfModule, + {SynthesisResult? Function(Module module)? lookupExistingResult, + Map? existingResults}) { assert( module is! SystemVerilog || module.generatedDefinitionType != DefinitionGenerationType.none, diff --git a/lib/src/synthesizers/utilities/synth_logic.dart b/lib/src/synthesizers/utilities/synth_logic.dart index 64ed3bed1..4a9c0e20a 100644 --- a/lib/src/synthesizers/utilities/synth_logic.dart +++ b/lib/src/synthesizers/utilities/synth_logic.dart @@ -12,7 +12,6 @@ import 'package:meta/meta.dart'; import 'package:rohd/rohd.dart'; import 'package:rohd/src/synthesizers/utilities/utilities.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 @@ -196,81 +195,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.name)) { - 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); - } - - // 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); - } + /// + /// Delegates to signal namer which handles constant value naming, priority + /// selection, and uniquification via the module's shared namespace. + String _findName() => + parentSynthModuleDefinition.module.signalNamer.nameOfBest( + logics, + constValue: _constLogic, + constNameDisallowed: _constNameDisallowed, + ); /// Creates an instance to represent [initialLogic] and any that merge /// into it. diff --git a/lib/src/synthesizers/utilities/synth_module_definition.dart b/lib/src/synthesizers/utilities/synth_module_definition.dart index b8b78476a..dac9075e8 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(); @@ -752,49 +742,59 @@ class SynthModuleDefinition { } /// Picks names of signals and sub-modules. + /// + /// Signal names are read from [Module.signalName] (for user-created + /// [Logic] objects) or kept as literal constants. Submodule instance + /// names and synthesizer artifacts are allocated from the shared + /// [Module] namespace via [Module.allocateSignalName], guaranteeing no + /// collisions across synthesizers. 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..4f1c3e4f2 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,16 @@ 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 shared namespace via + /// [Module.allocateSignalName], ensuring no collision with signal names or + /// other submodule instances — even across multiple synthesizers. + void pickName(Module parentModule) { assert(_name == null, 'Should only pick a name once.'); - _name = uniquifier.getUniqueName( - initialName: module.uniqueInstanceName, + _name = parentModule.allocateSignalName( + module.uniqueInstanceName, reserved: module.reserveName, - nullStarter: 'm', ); } diff --git a/lib/src/utilities/signal_namer.dart b/lib/src/utilities/signal_namer.dart new file mode 100644 index 000000000..b7d9dc090 --- /dev/null +++ b/lib/src/utilities/signal_namer.dart @@ -0,0 +1,271 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// signal_namer.dart +// Collision-free signal naming within a module scope. +// +// 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'; + +/// Assigns collision-free names to [Logic] signals within a single module. +/// +/// Wraps a [Uniquifier] with a sparse Logic→String cache so that each +/// signal is named exactly once and every subsequent lookup is O(1). +/// +/// Port names are reserved at construction time. Internal signals are +/// named lazily on the first [nameOf] call. +@internal +class SignalNamer { + final Uniquifier _uniquifier; + + /// Sparse cache: only entries where the canonical name has been resolved. + /// Ports whose sanitized name == logic.name may be absent (fast-path + /// through [_portLogics] check). + final Map _names = {}; + + /// The set of port [Logic] objects, for O(1) port membership tests. + final Set _portLogics; + + SignalNamer._({ + required Uniquifier uniquifier, + required Map portRenames, + required Set portLogics, + }) : _uniquifier = uniquifier, + _portLogics = portLogics { + _names.addAll(portRenames); + } + + /// Creates a [SignalNamer] for the given module ports. + /// + /// Sanitized port names are reserved in the namespace. Ports whose + /// sanitized name differs from [Logic.name] are cached immediately. + factory SignalNamer.forModule({ + required Map inputs, + required Map outputs, + required Map inOuts, + }) { + final portRenames = {}; + final portLogics = {}; + final portNames = []; + + void collectPort(String rawName, Logic logic) { + final sanitized = Sanitizer.sanitizeSV(rawName); + portNames.add(sanitized); + portLogics.add(logic); + if (sanitized != logic.name) { + portRenames[logic] = sanitized; + } + } + + for (final entry in inputs.entries) { + collectPort(entry.key, entry.value); + } + for (final entry in outputs.entries) { + collectPort(entry.key, entry.value); + } + for (final entry in inOuts.entries) { + collectPort(entry.key, entry.value); + } + + // Claim each port name as reserved so that: + // (a) non-reserved signals can't steal them, and + // (b) a second reserved signal with the same name throws. + final uniquifier = Uniquifier(); + for (final name in portNames) { + uniquifier.getUniqueName(initialName: name, reserved: true); + } + + return SignalNamer._( + uniquifier: uniquifier, + portRenames: portRenames, + portLogics: portLogics, + ); + } + + /// 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 nameOf(Logic logic) { + // Fast path: already named (port rename or previously-queried signal). + final cached = _names[logic]; + if (cached != null) { + return cached; + } + + // Port whose sanitized name == logic.name — already reserved. + if (_portLogics.contains(logic)) { + return logic.name; + } + + // First time seeing this internal signal — derive base name. + String baseName; + // Only treat as reserved for Uniquifier purposes if this is a true + // reserved internal signal (not a submodule port that happens to have + // Naming.reserved). + final isReservedInternal = logic.naming == Naming.reserved && !logic.isPort; + if (logic.naming == Naming.reserved || logic.isArrayMember) { + baseName = logic.name; + } else { + baseName = Sanitizer.sanitizeSV(logic.structureName); + } + + final name = _uniquifier.getUniqueName( + initialName: baseName, + reserved: isReservedInternal, + ); + _names[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 nameOfBest( + Iterable candidates, { + Const? constValue, + bool constNameDisallowed = false, + }) { + // Constant whose literal value string is the name. + if (constValue != null && !constNameDisallowed) { + return constValue.value.toString(); + } + + // Classify using _portLogics membership (context-aware) rather than + // Logic.naming (context-independent), because submodule ports have + // Naming.reserved but should NOT be treated as reserved here. + 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) { + // Submodule port — treat as mergeable regardless of intrinsic naming, + // matching SynthModuleDefinition's namingOverride convention. + 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); + } + } + + // Port of this module — name already reserved in namespace. + if (port != null) { + return _nameAndCacheAll(port, candidates); + } + + // Reserved internal — must keep exact name (throws on collision). + if (reserved != null) { + return _nameAndCacheAll(reserved, candidates); + } + + // Renameable — preferred base, uniquified if needed. + if (renameable != null) { + return _nameAndCacheAll(renameable, candidates); + } + + // Preferred-available mergeable. + for (final logic in preferredMergeable) { + if (_uniquifier.isAvailable(baseName(logic))) { + return _nameAndCacheAll(logic, candidates); + } + } + + // Preferred-uniquifiable mergeable. + if (preferredMergeable.isNotEmpty) { + return _nameAndCacheAll(preferredMergeable.first, candidates); + } + + // Unpreferred mergeable — prefer available. + if (unpreferredMergeable.isNotEmpty) { + final best = unpreferredMergeable + .firstWhereOrNull((e) => _uniquifier.isAvailable(baseName(e))) ?? + unpreferredMergeable.first; + return _nameAndCacheAll(best, candidates); + } + + // Unnamed — prefer non-unpreferred base name. + 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 [nameOf], then caches the same name for all other + /// non-port [Logic]s in [all]. + String _nameAndCacheAll(Logic chosen, Iterable all) { + final name = nameOf(chosen); + for (final logic in all) { + if (!identical(logic, chosen) && !_portLogics.contains(logic)) { + _names[logic] = name; + } + } + return name; + } + + /// Allocates a collision-free name for a non-signal artifact (wire, + /// instance, etc.). + /// + /// When [reserved] is `true`, the exact [baseName] (after sanitization) + /// is claimed without modification; an exception is thrown if it collides. + String allocate(String baseName, {bool reserved = false}) => + _uniquifier.getUniqueName( + initialName: Sanitizer.sanitizeSV(baseName), + reserved: reserved, + ); + + /// Returns `true` if [name] has not yet been claimed in this namespace. + bool isAvailable(String name) => _uniquifier.isAvailable(name); +} diff --git a/test/naming_cases_test.dart b/test/naming_cases_test.dart new file mode 100644 index 000000000..fbc1d9536 --- /dev/null +++ b/test/naming_cases_test.dart @@ -0,0 +1,583 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// naming_cases_test.dart +// Systematic test of all signal-naming cases in the synthesis pipeline. +// +// 2026 April 10 +// Author: Desmond Kirkpatrick + +// ════════════════════════════════════════════════════════ +// NAMING CROSS-PRODUCT TABLE +// ════════════════════════════════════════════════════════ +// +// Axis 1 — Naming enum (set at Logic construction time): +// reserved Exact name required; collision → exception. +// renameable Keeps name, uniquified on collision; never merged. +// mergeable May merge with equivalent signals; any merged name chosen. +// unnamed No user name; system generates one. +// +// Axis 2 — Context role (per SynthModuleDefinition): +// this-port Port of module being synthesized +// (namingOverride → reserved). +// sub-port Port of a child submodule +// (namingOverride → mergeable). +// internal Non-port signal inside the module (no override). +// const Const object (separate path via constValue). +// +// Axis 3 — Name preference: +// preferred baseName does NOT start with '_' +// unpreferred baseName starts with '_' +// +// Axis 4 — Constant context (only for Const): +// allowed Literal value string used as name. +// disallowed Feeding expressionlessInput; +// must use a wire name. +// +// ────────────────────────────────────────────────────── +// Row Naming Context Pref? Test Valid? +// Effective class → Outcome +// ────────────────────────────────────────────────────── +// 1 reserved this-port pref T1 ✓ +// port (in _portLogics) → exact sanitized name +// 2 reserved this-port unpref T2 ✓ unusual +// port → exact _-prefixed port name +// 3 reserved sub-port pref T3 ✓ +// preferred mergeable → merged, uniquified +// 4 reserved sub-port unpref T4 ✓ +// unpreferred mergeable → low-priority merge +// 5 reserved internal pref T5 ✓ +// reserved internal → exact name, throw on clash +// 6 reserved internal unpref T6 ✓ unusual +// reserved internal → exact _-prefixed name +// 7 renameable this-port pref — can't happen* +// port → exact port name +// 8 renameable sub-port pref — can't happen* +// preferred mergeable → merged +// 9 renameable internal pref T9 ✓ +// renameable → base name, uniquified +// 10 renameable internal unpref T10 ✓ unusual +// renameable → uniquified _-prefixed +// 11 mergeable this-port pref T11 ✓ +// port → exact port name (Logic.port()) +// 12 mergeable this-port unpref T12 ✓ unusual +// port → exact _-prefixed port name +// 13 mergeable sub-port pref T3 ✓ (=row 3) +// preferred mergeable → best-available merge +// 14 mergeable sub-port unpref T4 ✓ (=row 4) +// unpreferred mergeable → low-priority merge +// 15 mergeable internal pref T15 ✓ +// preferred mergeable → prefer available name +// 16 mergeable internal unpref T16 ✓ +// unpreferred mergeable → low-priority merge +// 17 unnamed this-port — — ✗ impossible** +// port → exact port name +// 18 unnamed sub-port — — ✗ impossible** +// mergeable → merged +// 19 unnamed internal (unpf) T19 ✓ +// unnamed → generated _s name +// 20 —(Const) — — T20 ✓ +// const allowed → literal value e.g. 8'h42 +// 21 —(Const) — — T21 ✓ +// const disallowed → wire name (not literal) +// ────────────────────────────────────────────────────── +// +// * Rows 7-8: addInput/addOutput always create +// Logic with Naming.reserved, so a port can +// never have intrinsic Naming.renameable. +// The namingOverride makes it moot anyway. +// +// ** Rows 17-18: addInput/addOutput require a +// non-null, non-empty name. chooseName() only +// yields Naming.unnamed for null/empty names, +// so a port can never be unnamed. +// +// ✗ unnamed + reserved: Logic(naming: reserved) +// with null/empty name throws +// NullReservedNameException / +// EmptyReservedNameException at construction +// time. Never reaches synthesizer. +// +// Additional cross-cutting concerns: +// COL Collision between mergeables +// → uniquified suffix (_0) +// MG Merge: directly-connected signals +// share SynthLogic +// INST Submodule instance names: unique, +// don't collide with ports +// ST Structure element: structureName +// = "parent.field" → sanitized ("_") +// AR Array element: isArrayMember +// → uses logic.name (index-based) +// +// ════════════════════════════════════════════════════════ + +import 'package:rohd/rohd.dart'; +import 'package:rohd/src/synthesizers/utilities/utilities.dart'; +import 'package:test/test.dart'; + +// ── Leaf sub-modules ────────────────────────────── + +/// A leaf module whose `in0` is an "expressionless input" — +/// meaning any constant driving it must get a real wire name, not a literal. +class _ExpressionlessSub extends Module with SystemVerilog { + @override + List get expressionlessInputs => const ['in0']; + + _ExpressionlessSub(Logic a, Logic b) : super(name: 'exprsub') { + a = addInput('in0', a, width: a.width); + b = addInput('in1', b, width: b.width); + addOutput('out', width: a.width) <= a & b; + } +} + +/// A simple sub-module with preferred-name ports. +class _SimpleSub extends Module { + _SimpleSub(Logic x) : super(name: 'simplesub') { + x = addInput('x', x, width: x.width); + addOutput('y', width: x.width) <= ~x; + } +} + +/// A sub-module with an unpreferred-name port. +class _UnprefSub extends Module { + _UnprefSub(Logic a) : super(name: 'unprefsub') { + a = addInput('_uport', a, width: a.width); + addOutput('uout', width: a.width) <= ~a; + } +} + +// ── Main test module ────────────────────────────── +// One module that exercises every valid naming case in a minimal design. +// Each signal is tagged with the row number from the table above. + +class _AllNamingCases extends Module { + // Exposed for test inspection. + // Row 1 / Row 2: ports (accessed via mod.input / mod.output). + // Row 5: + late final Logic reservedInternal; + // Row 6: + late final Logic reservedInternalUnpref; + // Row 9: + late final Logic renameableInternal; + // Row 10: + late final Logic renameableInternalUnpref; + // Row 15: + late final Logic mergeablePref; + // Row 15 collision partner: + late final Logic mergeablePrefCollide; + // Row 16: + late final Logic mergeableUnpref; + // Row 19: + late final Logic unnamed; + // Row 20: + late final Logic constAllowed; + // Row 21: + late final Logic constDisallowed; + // MG: + late final Logic mergeTarget; + + // Structure/array elements (ST, AR): + late final LogicStructure structPort; + late final LogicArray arrayPort; + + _AllNamingCases() : super(name: 'allcases') { + // ── Row 1: reserved + this-port + preferred ────────────────── + final inp = addInput('inp', Logic(width: 8), width: 8); + final out = addOutput('out', width: 8); + + // ── Row 2: reserved + this-port + unpreferred ──────────────── + final uInp = addInput('_uinp', Logic(width: 8), width: 8); + + // ── Row 11: mergeable + this-port + preferred ──────────────── + // (This is the Logic.port() → connectIO path. addInput forces + // Naming.reserved regardless of the source's naming, so intrinsic + // mergeable is overridden to reserved. We test the port keeps its + // exact name.) + final mPortInp = addInput('mport', Logic(width: 8), width: 8); + + // ── Row 12: mergeable + this-port + unpreferred ────────────── + final mPortUnpref = addInput('_muprt', Logic(width: 8), width: 8); + + // ── Row 5: reserved + internal + preferred ─────────────────── + reservedInternal = Logic(name: 'resv', width: 8, naming: Naming.reserved) + ..gets(inp ^ Const(0x01, width: 8)); + + // ── Row 6: reserved + internal + unpreferred ───────────────── + reservedInternalUnpref = + Logic(name: '_resvu', width: 8, naming: Naming.reserved) + ..gets(inp ^ Const(0x02, width: 8)); + + // ── Row 9: renameable + internal + preferred ───────────────── + renameableInternal = Logic(name: 'ren', width: 8, naming: Naming.renameable) + ..gets(inp ^ Const(0x03, width: 8)); + + // ── Row 10: renameable + internal + unpreferred ────────────── + renameableInternalUnpref = + Logic(name: '_renu', width: 8, naming: Naming.renameable) + ..gets(inp ^ Const(0x04, width: 8)); + + // ── Row 15: mergeable + internal + preferred ───────────────── + mergeablePref = Logic(name: 'mname', width: 8, naming: Naming.mergeable) + ..gets(inp ^ Const(0x05, width: 8)); + + // ── COL: collision partner — same base name 'mname' ────────── + mergeablePrefCollide = + Logic(name: 'mname', width: 8, naming: Naming.mergeable) + ..gets(inp ^ Const(0x06, width: 8)); + + // ── Row 16: mergeable + internal + unpreferred ─────────────── + mergeableUnpref = Logic(name: '_hidden', width: 8, naming: Naming.mergeable) + ..gets(inp ^ Const(0x07, width: 8)); + + // ── Row 19: unnamed + internal ─────────────────────────────── + unnamed = Logic(width: 8)..gets(inp ^ Const(0x08, width: 8)); + + // ── Rows 3/13: sub-port preferred (via _SimpleSub.x / .y) ─── + // ── Row 4/14: sub-port unpreferred (via _UnprefSub._uport) ── + final sub = _SimpleSub(renameableInternal); + final subOut = sub.output('y'); + // Use a distinct expression so the submodule port doesn't merge with + // renameableInternal (which is renameable and would win). + final unpSub = _UnprefSub(inp ^ Const(0x0a, width: 8)); + + // ── MG: merge behavior — mergeTarget merges with subOut ────── + mergeTarget = Logic(name: 'mmerge', width: 8, naming: Naming.mergeable) + ..gets(subOut); + + // ── Row 20: constant with name allowed ─────────────────────── + constAllowed = + Const(0x42, width: 8).named('const_ok', naming: Naming.mergeable); + + // ── Row 21: constant with name disallowed (expressionlessInput) + constDisallowed = + Const(0x09, width: 8).named('const_wire', naming: Naming.mergeable); + // ignore: unused_local_variable + final exprSub = _ExpressionlessSub(constDisallowed, inp); + + // ── ST: structure element (structureName = "parent.field") ──── + structPort = _SimpleStruct(); + addInput('stIn', structPort, width: structPort.width); + + // ── AR: array element (isArrayMember, uses logic.name) ─────── + arrayPort = LogicArray([3], 8, name: 'arIn'); + addInputArray('arIn', arrayPort, dimensions: [3], elementWidth: 8); + + // Drive output to use all signals (prevents pruning). + out <= + mergeTarget | + mergeablePrefCollide | + mergeableUnpref | + unnamed | + constAllowed | + uInp | + mPortInp | + mPortUnpref | + reservedInternalUnpref | + renameableInternalUnpref | + unpSub.output('uout'); + } +} + +/// A minimal LogicStructure for testing structureName sanitization. +class _SimpleStruct extends LogicStructure { + final Logic field1; + final Logic field2; + + factory _SimpleStruct({String name = 'st'}) => _SimpleStruct._( + Logic(name: 'a', width: 4), + Logic(name: 'b', width: 4), + name: name, + ); + + _SimpleStruct._(this.field1, this.field2, {required super.name}) + : super([field1, field2]); + + @override + LogicStructure clone({String? name}) => + _SimpleStruct(name: name ?? this.name); +} + +// ── Helpers ─────────────────────────────────────── + +/// Collects a map from Logic → picked name for all SynthLogics. +Map _collectNames(SynthModuleDefinition def) { + final names = {}; + for (final sl in [ + ...def.inputs, + ...def.outputs, + ...def.inOuts, + ...def.internalSignals, + ]) { + try { + final n = sl.name; + for (final logic in sl.logics) { + names[logic] = n; + } + // ignore: avoid_catches_without_on_clauses + } catch (_) { + // name not picked (pruned/replaced) + } + } + return names; +} + +/// Finds a SynthLogic that contains [logic]. +SynthLogic? _findSynthLogic(SynthModuleDefinition def, Logic logic) { + for (final sl in [ + ...def.inputs, + ...def.outputs, + ...def.inOuts, + ...def.internalSignals, + ]) { + if (sl.logics.contains(logic)) { + return sl; + } + } + return null; +} + +// ── Tests ──────────────────────────────────────── + +void main() { + late _AllNamingCases mod; + late SynthModuleDefinition def; + late Map names; + + setUp(() async { + mod = _AllNamingCases(); + await mod.build(); + def = SynthModuleDefinition(mod); + names = _collectNames(def); + }); + + group('naming cases', () { + // ── Row 1: reserved + this-port + preferred ──────────────── + + test('T1: reserved preferred port keeps exact name', () { + expect(names[mod.input('inp')], 'inp'); + expect(names[mod.output('out')], 'out'); + }); + + // ── Row 2: reserved + this-port + unpreferred ────────────── + + test('T2: reserved unpreferred port keeps exact _-prefixed name', () { + expect(names[mod.input('_uinp')], '_uinp'); + }); + + // ── Rows 3/13: sub-port + preferred (reserved or mergeable) ─ + + test('T3: submodule preferred port gets a name in parent', () { + final subX = mod.subModules.whereType<_SimpleSub>().first.input('x'); + final n = names[subX]; + expect(n, isNotNull, reason: 'Submodule port must be named'); + // Treated as preferred mergeable — name should not start with _. + expect(n, isNot(startsWith('_')), + reason: 'Preferred submodule port name should not be unpreferred'); + }); + + // ── Row 4/14: sub-port + unpreferred ──────────────────────── + + test('T4: submodule unpreferred port gets an unpreferred name', () { + final subUPort = + mod.subModules.whereType<_UnprefSub>().first.input('_uport'); + final n = names[subUPort]; + expect(n, isNotNull, reason: 'Submodule port must be named'); + expect(n, startsWith('_'), + reason: 'Unpreferred submodule port should keep _-prefix'); + }); + + // ── Row 5: reserved + internal + preferred ────────────────── + + test('T5: reserved preferred internal keeps exact name', () { + expect(names[mod.reservedInternal], 'resv'); + }); + + // ── Row 6: reserved + internal + unpreferred ──────────────── + + test('T6: reserved unpreferred internal keeps exact _-prefixed name', () { + expect(names[mod.reservedInternalUnpref], '_resvu'); + }); + + // ── Row 9: renameable + internal + preferred ──────────────── + + test('T9: renameable preferred internal gets its name', () { + final n = names[mod.renameableInternal]; + expect(n, isNotNull); + expect(n, contains('ren')); + }); + + // ── Row 10: renameable + internal + unpreferred ───────────── + + test('T10: renameable unpreferred internal keeps _-prefix', () { + final n = names[mod.renameableInternalUnpref]; + expect(n, isNotNull); + expect(n, startsWith('_'), + reason: 'Unpreferred renameable should keep _-prefix'); + expect(n, contains('renu')); + }); + + // ── Row 11: mergeable + this-port + preferred ─────────────── + + test('T11: mergeable-origin port (Logic.port) keeps exact port name', () { + // addInput overrides naming to reserved; the port name is exact. + expect(names[mod.input('mport')], 'mport'); + }); + + // ── Row 12: mergeable + this-port + unpreferred ───────────── + + test('T12: mergeable-origin unpreferred port keeps exact name', () { + expect(names[mod.input('_muprt')], '_muprt'); + }); + + // ── Row 15: mergeable + internal + preferred ──────────────── + + test('T15: mergeable preferred internal gets its name', () { + final n = names[mod.mergeablePref]; + expect(n, isNotNull); + expect(n, contains('mname')); + }); + + // ── COL: name collision → uniquified suffix ───────────────── + + test('COL: collision between two mergeables gets uniquified', () { + final n1 = names[mod.mergeablePref]; + final n2 = names[mod.mergeablePrefCollide]; + expect(n1, isNot(n2), reason: 'Colliding names must be uniquified'); + expect({n1, n2}, containsAll(['mname', 'mname_0'])); + }); + + // ── Row 16: mergeable + internal + unpreferred ────────────── + + test('T16: mergeable unpreferred internal keeps _-prefix', () { + final n = names[mod.mergeableUnpref]; + expect(n, isNotNull); + expect(n, startsWith('_'), + reason: 'Unpreferred mergeable should keep _-prefix'); + }); + + // ── Row 19: unnamed + internal ────────────────────────────── + + test('T19: unnamed signal gets a generated name', () { + final n = names[mod.unnamed]; + expect(n, isNotNull, reason: 'Unnamed signal must still get a name'); + // chooseName() gives unnamed signals a name starting with '_s'. + expect(n, startsWith('_'), + reason: 'Unnamed signals get unpreferred generated names'); + }); + + // ── Row 20: constant with name allowed ────────────────────── + + test('T20: constant with name allowed uses literal value', () { + final sl = _findSynthLogic(def, mod.constAllowed); + expect(sl, isNotNull); + if (sl != null && !sl.constNameDisallowed) { + expect(sl.name, contains("8'h42"), + reason: 'Allowed constant should use value literal'); + } + }); + + // ── Row 21: constant with name disallowed ─────────────────── + + test('T21: constant with name disallowed uses wire name', () { + final sl = _findSynthLogic(def, mod.constDisallowed); + expect(sl, isNotNull); + if (sl != null) { + if (sl.constNameDisallowed) { + expect(sl.name, isNot(contains("8'h09")), + reason: 'Disallowed constant should not use value literal'); + expect(sl.name, isNotEmpty); + } + } + }); + + // ── MG: merge behavior ────────────────────────────────────── + + test('MG: merged signals share the same SynthLogic', () { + final sl = _findSynthLogic(def, mod.mergeTarget); + expect(sl, isNotNull); + if (sl != null && sl.logics.length > 1) { + expect(sl.name, isNotEmpty); + } + }); + + // ── INST: submodule instance naming ───────────────────────── + + test('INST: submodule instances get collision-free names', () { + final instNames = def.subModuleInstantiations + .where((s) => s.needsInstantiation) + .map((s) => s.name) + .toList(); + expect(instNames.toSet().length, instNames.length, + reason: 'Instance names must be unique'); + final portNames = {...mod.inputs.keys, ...mod.outputs.keys}; + for (final name in instNames) { + expect(portNames, isNot(contains(name)), + reason: 'Instance "$name" should not collide with a port'); + } + }); + + // ── ST: structure element naming ──────────────────────────── + + test('ST: structure element structureName is sanitized', () { + // structureName for field1 is "st.a" → sanitized to "st_a". + final stIn = mod.input('stIn'); + final n = names[stIn]; + expect(n, isNotNull); + // The port itself should keep its reserved name 'stIn'. + expect(n, 'stIn'); + }); + + // ── AR: array element naming ──────────────────────────────── + + test('AR: array port keeps its name', () { + // Array ports are registered via addInputArray with Naming.reserved. + final arIn = mod.input('arIn'); + final n = names[arIn]; + expect(n, isNotNull); + expect(n, 'arIn'); + }); + + // ── Impossible cases ──────────────────────────────────────── + + test('unnamed + reserved throws at construction time', () { + expect( + () => Logic(naming: Naming.reserved), + throwsA(isA()), + ); + expect( + () => Logic(name: '', naming: Naming.reserved), + throwsA(isA()), + ); + }); + + // ── Golden SV snapshot ────────────────────────────────────── + + test('golden SV output snapshot', () { + final sv = mod.generateSynth(); + + // Port declarations. + expect(sv, contains('input logic [7:0] inp')); + expect(sv, contains('output logic [7:0] out')); + expect(sv, contains('_uinp')); + expect(sv, contains('mport')); + expect(sv, contains('_muprt')); + + // Reserved internals. + expect(sv, contains('resv')); + expect(sv, contains('_resvu')); + + // Renameable internals. + expect(sv, contains('ren')); + expect(sv, contains('_renu')); + + // Constant literal (T20). + expect(sv, contains("8'h42")); + + // Submodule instantiations. + expect(sv, contains('simplesub')); + expect(sv, contains('exprsub')); + expect(sv, contains('unprefsub')); + }); + }); +} diff --git a/test/naming_consistency_test.dart b/test/naming_consistency_test.dart new file mode 100644 index 000000000..53f95e6d8 --- /dev/null +++ b/test/naming_consistency_test.dart @@ -0,0 +1,247 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// naming_consistency_test.dart +// Validates that both the SystemVerilog synthesizer and a base +// SynthModuleDefinition (used by the netlist synthesizer) produce +// consistent signal names via the shared Module.signalNamer. +// +// 2026 April 10 +// Author: Desmond Kirkpatrick + +import 'package:rohd/rohd.dart'; +import 'package:rohd/src/synthesizers/systemverilog/systemverilog_synth_module_definition.dart'; +import 'package:rohd/src/synthesizers/utilities/utilities.dart'; +import 'package:test/test.dart'; + +// ── Helper modules ────────────────────────────────────────────────── + +/// A simple module with ports, internal wires, and a sub-module. +class _Inner extends Module { + _Inner(Logic a, Logic b) : super(name: 'inner') { + a = addInput('a', a, width: a.width); + b = addInput('b', b, width: b.width); + addOutput('y', width: a.width) <= a & b; + } +} + +class _Outer extends Module { + _Outer(Logic a, Logic b) : super(name: 'outer') { + a = addInput('a', a, width: a.width); + b = addInput('b', b, width: b.width); + final inner = _Inner(a, b); + addOutput('y', width: a.width) <= inner.output('y'); + } +} + +/// A module with a constant assignment (exercises const naming). +class _ConstModule extends Module { + _ConstModule(Logic a) : super(name: 'constmod') { + a = addInput('a', a, width: 8); + final c = Const(0x42, width: 8).named('myConst', naming: Naming.mergeable); + addOutput('y', width: 8) <= a + c; + } +} + +/// A module with Naming.renameable and Naming.mergeable signals. +class _MixedNaming extends Module { + _MixedNaming(Logic a) : super(name: 'mixednaming') { + a = addInput('a', a, width: 8); + final r = Logic(name: 'renamed', width: 8, naming: Naming.renameable) + ..gets(a); + final m = Logic(name: 'merged', width: 8, naming: Naming.mergeable) + ..gets(r); + addOutput('y', width: 8) <= m; + } +} + +/// A module with a FlipFlop sub-module. +class _FlopOuter extends Module { + _FlopOuter(Logic clk, Logic d) : super(name: 'flopouter') { + clk = addInput('clk', clk); + d = addInput('d', d, width: 8); + addOutput('q', width: 8) <= flop(clk, d); + } +} + +/// Builds [SynthModuleDefinition]s from both bases and collects a +/// Logic→name mapping for all present SynthLogics. +/// +/// Returns maps from Logic to its resolved signal name. +Map _collectNames(SynthModuleDefinition def) { + final names = {}; + for (final sl in [ + ...def.inputs, + ...def.outputs, + ...def.inOuts, + ...def.internalSignals, + ]) { + // Skip SynthLogics whose name was never picked (replaced/pruned). + try { + final n = sl.name; + for (final logic in sl.logics) { + names[logic] = n; + } + // ignore: avoid_catches_without_on_clauses + } catch (_) { + // name not picked — skip + } + } + return names; +} + +void main() { + group('naming consistency', () { + test('SV and base SynthModuleDefinition agree on port names', () async { + final mod = _Outer(Logic(width: 8), Logic(width: 8)); + await mod.build(); + + // SV synthesizer path + final svDef = SystemVerilogSynthModuleDefinition(mod); + + // Base path (same as netlist synthesizer uses) + // Since signalNamer is late final, the second constructor reuses + // the same naming state — names must be consistent. + final baseDef = SynthModuleDefinition(mod); + + final svNames = _collectNames(svDef); + final baseNames = _collectNames(baseDef); + + // Every Logic present in both must have the same name. + for (final logic in svNames.keys) { + if (baseNames.containsKey(logic)) { + expect(baseNames[logic], svNames[logic], + reason: 'Name mismatch for ${logic.name} ' + '(${logic.runtimeType}, naming=${logic.naming})'); + } + } + + // Port names specifically must match. + for (final port in [...mod.inputs.values, ...mod.outputs.values]) { + expect(svNames[port], isNotNull, + reason: 'SV def should have port ${port.name}'); + expect(baseNames[port], isNotNull, + reason: 'Base def should have port ${port.name}'); + expect(svNames[port], baseNames[port], + reason: 'Port name must match for ${port.name}'); + } + }); + + test('constant naming is consistent', () async { + final mod = _ConstModule(Logic(width: 8)); + await mod.build(); + + final svDef = SystemVerilogSynthModuleDefinition(mod); + final baseDef = SynthModuleDefinition(mod); + + final svNames = _collectNames(svDef); + final baseNames = _collectNames(baseDef); + + for (final logic in svNames.keys) { + if (baseNames.containsKey(logic)) { + expect(baseNames[logic], svNames[logic], + reason: 'Name mismatch for ${logic.name}'); + } + } + }); + + test('mixed naming (renameable + mergeable) is consistent', () async { + final mod = _MixedNaming(Logic(width: 8)); + await mod.build(); + + final svDef = SystemVerilogSynthModuleDefinition(mod); + final baseDef = SynthModuleDefinition(mod); + + final svNames = _collectNames(svDef); + final baseNames = _collectNames(baseDef); + + for (final logic in svNames.keys) { + if (baseNames.containsKey(logic)) { + expect(baseNames[logic], svNames[logic], + reason: 'Name mismatch for ${logic.name}'); + } + } + }); + + test('flop module naming is consistent', () async { + final mod = _FlopOuter(Logic(), Logic(width: 8)); + await mod.build(); + + final svDef = SystemVerilogSynthModuleDefinition(mod); + final baseDef = SynthModuleDefinition(mod); + + final svNames = _collectNames(svDef); + final baseNames = _collectNames(baseDef); + + for (final logic in svNames.keys) { + if (baseNames.containsKey(logic)) { + expect(baseNames[logic], svNames[logic], + reason: 'Name mismatch for ${logic.name}'); + } + } + }); + + test('signalNamer is shared across multiple SynthModuleDefinitions', + () async { + final mod = _Outer(Logic(width: 8), Logic(width: 8)); + await mod.build(); + + // Build one def, then build another — same signalNamer instance. + final def1 = SynthModuleDefinition(mod); + final def2 = SynthModuleDefinition(mod); + + final names1 = _collectNames(def1); + final names2 = _collectNames(def2); + + for (final logic in names1.keys) { + if (names2.containsKey(logic)) { + expect(names2[logic], names1[logic], + reason: 'Shared namer should produce same name for ' + '${logic.name}'); + } + } + }); + + test('Module.signalName matches SynthLogic.name for ports', () async { + final mod = _Outer(Logic(width: 8), Logic(width: 8)); + await mod.build(); + + final def = SynthModuleDefinition(mod); + final synthNames = _collectNames(def); + + // Module.signalName uses SignalNamer.nameOf directly + for (final port in [...mod.inputs.values, ...mod.outputs.values]) { + final moduleName = mod.signalName(port); + final synthName = synthNames[port]; + expect(synthName, moduleName, + reason: 'SynthLogic.name and Module.signalName must agree ' + 'for port ${port.name}'); + } + }); + + test('submodule instance names are allocated from shared namespace', + () async { + // When building a single SynthModuleDefinition (as each synthesizer + // does), submodule instance names come from Module.allocateSignalName. + final mod = _Outer(Logic(width: 8), Logic(width: 8)); + await mod.build(); + + final def = SynthModuleDefinition(mod); + + final instNames = def.subModuleInstantiations + .where((s) => s.needsInstantiation) + .map((s) => s.name) + .toSet(); + + // The inner module instance should have a name + expect(instNames, isNotEmpty, + reason: 'Should have at least one submodule instance'); + + // All instance names should be obtainable from the module namespace + for (final name in instNames) { + expect(mod.isSignalNameAvailable(name), isFalse, + reason: 'Instance name "$name" should be claimed in namespace'); + } + }); + }); +} From 85f88cef0f472794689c9965b1be768fc5682b59 Mon Sep 17 00:00:00 2001 From: "Desmond A. Kirkpatrick" Date: Fri, 17 Apr 2026 08:36:09 -0700 Subject: [PATCH 02/24] dart 3.11 parameter_assignments pickiness --- analysis_options.yaml | 4 +++- lib/src/module.dart | 3 --- lib/src/signals/logic.dart | 1 - lib/src/signals/wire_net.dart | 1 - lib/src/utilities/simcompare.dart | 1 - lib/src/values/logic_value.dart | 3 --- 6 files changed, 3 insertions(+), 10 deletions(-) diff --git a/analysis_options.yaml b/analysis_options.yaml index 65d475023..2b2098177 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -129,7 +129,9 @@ linter: - overridden_fields - package_names - package_prefixed_library_names - - parameter_assignments + # parameter_assignments - disabled; ROHD idiomatically reassigns + # constructor parameters via addInput/addOutput. + # - parameter_assignments - prefer_adjacent_string_concatenation - prefer_asserts_in_initializer_lists - prefer_asserts_with_message diff --git a/lib/src/module.dart b/lib/src/module.dart index 09e11fdc7..188b78890 100644 --- a/lib/src/module.dart +++ b/lib/src/module.dart @@ -702,7 +702,6 @@ abstract class Module { } if (source is LogicStructure) { - // ignore: parameter_assignments source = source.packed; } @@ -739,7 +738,6 @@ abstract class Module { String name, LogicType source) { _checkForSafePortName(name); - // ignore: parameter_assignments source = _validateType(source, isOutput: false, name: name); if (source.isNet || (source is LogicStructure && source.hasNets)) { @@ -848,7 +846,6 @@ abstract class Module { throw PortTypeException(source, 'Typed inOuts must be nets.'); } - // ignore: parameter_assignments source = _validateType(source, isOutput: false, name: name); _inOutDrivers.add(source); diff --git a/lib/src/signals/logic.dart b/lib/src/signals/logic.dart index 88afba0d6..4c5f99e5e 100644 --- a/lib/src/signals/logic.dart +++ b/lib/src/signals/logic.dart @@ -377,7 +377,6 @@ class Logic { // If we are connecting a `LogicStructure` to this simple `Logic`, // then pack it first. if (other is LogicStructure) { - // ignore: parameter_assignments other = other.packed; } diff --git a/lib/src/signals/wire_net.dart b/lib/src/signals/wire_net.dart index 78e8b1beb..f93529b0f 100644 --- a/lib/src/signals/wire_net.dart +++ b/lib/src/signals/wire_net.dart @@ -189,7 +189,6 @@ class _WireNetBlasted extends _Wire implements _WireNet { other as _WireNet; if (other is! _WireNetBlasted) { - // ignore: parameter_assignments other = other.toBlasted(); } diff --git a/lib/src/utilities/simcompare.dart b/lib/src/utilities/simcompare.dart index 3a25f4074..d7850df4e 100644 --- a/lib/src/utilities/simcompare.dart +++ b/lib/src/utilities/simcompare.dart @@ -282,7 +282,6 @@ abstract class SimCompare { : 'logic'); if (adjust != null) { - // ignore: parameter_assignments signalName = adjust(signalName); } diff --git a/lib/src/values/logic_value.dart b/lib/src/values/logic_value.dart index 0cdc3c1df..81fc7304b 100644 --- a/lib/src/values/logic_value.dart +++ b/lib/src/values/logic_value.dart @@ -218,7 +218,6 @@ abstract class LogicValue implements Comparable { if (val.width == 1 && (!val.isValid || fill)) { if (!val.isValid) { - // ignore: parameter_assignments width ??= 1; } if (width == null) { @@ -243,7 +242,6 @@ abstract class LogicValue implements Comparable { if (val.length == 1 && (val == 'x' || val == 'z' || fill)) { if (val == 'x' || val == 'z') { - // ignore: parameter_assignments width ??= 1; } if (width == null) { @@ -269,7 +267,6 @@ abstract class LogicValue implements Comparable { if (val.length == 1 && (val.first == LogicValue.x || val.first == LogicValue.z || fill)) { if (!val.first.isValid) { - // ignore: parameter_assignments width ??= 1; } if (width == null) { From b7087c40467389ae38be40e2d4c599c0d532ebe7 Mon Sep 17 00:00:00 2001 From: "Desmond A. Kirkpatrick" Date: Fri, 17 Apr 2026 13:03:20 -0700 Subject: [PATCH 03/24] conflict resolved and dart format . works --- .../synthesizers/utilities/synth_logic.dart | 79 ------------------- 1 file changed, 79 deletions(-) diff --git a/lib/src/synthesizers/utilities/synth_logic.dart b/lib/src/synthesizers/utilities/synth_logic.dart index d0a5e5d5a..b5827295b 100644 --- a/lib/src/synthesizers/utilities/synth_logic.dart +++ b/lib/src/synthesizers/utilities/synth_logic.dart @@ -221,7 +221,6 @@ class SynthLogic { } /// Finds the best name from the collection of [Logic]s. -<<<<<<< central_naming /// /// Delegates to signal namer which handles constant value naming, priority /// selection, and uniquification via the module's shared namespace. @@ -231,84 +230,6 @@ class SynthLogic { constValue: _constLogic, constNameDisallowed: _constNameDisallowed, ); -======= - 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, - ); - } - - // 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, - ); - } ->>>>>>> main /// Creates an instance to represent [initialLogic] and any that merge /// into it. From 76a29f5980fff1eb9c3f032d5b4e78c8ea600473 Mon Sep 17 00:00:00 2001 From: "Desmond A. Kirkpatrick" Date: Fri, 17 Apr 2026 17:22:51 -0700 Subject: [PATCH 04/24] devtools improvements --- devtools_options.yaml | 4 + extension/devtools/config.yaml | 2 +- .../assets/help/details_help.md | 28 ++ .../assets/help/devtools_help.md | 34 ++ .../cubit/rohd_service_cubit.dart | 127 ++++- .../rohd_devtools/services/tree_service.dart | 5 +- .../rohd_devtools/ui/details_help_button.dart | 33 ++ .../lib/rohd_devtools/ui/devtool_appbar.dart | 7 + .../ui/devtools_help_button.dart | 33 ++ .../rohd_devtools/ui/module_tree_card.dart | 5 +- .../ui/module_tree_details_navbar.dart | 2 +- .../rohd_devtools/ui/signal_details_card.dart | 93 ++-- .../view/tree_structure_page.dart | 275 +++++----- .../packages/export_png/analysis_options.yaml | 1 + .../packages/export_png/lib/export_png.dart | 16 + .../export_png/lib/src/capture_boundary.dart | 69 +++ .../export_png/lib/src/export_button.dart | 50 ++ .../export_png/lib/src/export_toast.dart | 48 ++ .../export_png/lib/src/save_png_native.dart | 20 + .../export_png/lib/src/save_png_stub.dart | 14 + .../export_png/lib/src/save_png_web.dart | 32 ++ .../packages/export_png/pubspec.yaml | 18 + .../packages/help_api/analysis_options.yaml | 1 + .../packages/help_api/lib/help_api.dart | 12 + .../lib/src/markdown_help_button.dart | 468 ++++++++++++++++++ .../packages/help_api/pubspec.yaml | 21 + rohd_devtools_extension/pubspec.yaml | 15 +- tool/gh_actions/devtool/build_web.sh | 40 +- 28 files changed, 1282 insertions(+), 191 deletions(-) create mode 100644 devtools_options.yaml create mode 100644 rohd_devtools_extension/assets/help/details_help.md create mode 100644 rohd_devtools_extension/assets/help/devtools_help.md create mode 100644 rohd_devtools_extension/lib/rohd_devtools/ui/details_help_button.dart create mode 100644 rohd_devtools_extension/lib/rohd_devtools/ui/devtools_help_button.dart create mode 100644 rohd_devtools_extension/packages/export_png/analysis_options.yaml create mode 100644 rohd_devtools_extension/packages/export_png/lib/export_png.dart create mode 100644 rohd_devtools_extension/packages/export_png/lib/src/capture_boundary.dart create mode 100644 rohd_devtools_extension/packages/export_png/lib/src/export_button.dart create mode 100644 rohd_devtools_extension/packages/export_png/lib/src/export_toast.dart create mode 100644 rohd_devtools_extension/packages/export_png/lib/src/save_png_native.dart create mode 100644 rohd_devtools_extension/packages/export_png/lib/src/save_png_stub.dart create mode 100644 rohd_devtools_extension/packages/export_png/lib/src/save_png_web.dart create mode 100644 rohd_devtools_extension/packages/export_png/pubspec.yaml create mode 100644 rohd_devtools_extension/packages/help_api/analysis_options.yaml create mode 100644 rohd_devtools_extension/packages/help_api/lib/help_api.dart create mode 100644 rohd_devtools_extension/packages/help_api/lib/src/markdown_help_button.dart create mode 100644 rohd_devtools_extension/packages/help_api/pubspec.yaml 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..39dd7152b --- /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:help_api/help_api.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..c85f61401 --- /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:help_api/help_api.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..8a687e7e0 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:export_png/export_png.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..676f9096a 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:export_png/export_png.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/export_png/analysis_options.yaml b/rohd_devtools_extension/packages/export_png/analysis_options.yaml new file mode 100644 index 000000000..572dd239d --- /dev/null +++ b/rohd_devtools_extension/packages/export_png/analysis_options.yaml @@ -0,0 +1 @@ +include: package:lints/recommended.yaml diff --git a/rohd_devtools_extension/packages/export_png/lib/export_png.dart b/rohd_devtools_extension/packages/export_png/lib/export_png.dart new file mode 100644 index 000000000..90badac3e --- /dev/null +++ b/rohd_devtools_extension/packages/export_png/lib/export_png.dart @@ -0,0 +1,16 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// export_png.dart +// Platform-conditional PNG export utilities shared across ROHD DevTools +// sub-packages (schematic viewer, waveform viewer, etc.). +// +// 2026 April +// Author: Desmond Kirkpatrick + +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'; +export 'src/capture_boundary.dart'; +export 'src/export_button.dart'; +export 'src/export_toast.dart'; diff --git a/rohd_devtools_extension/packages/export_png/lib/src/capture_boundary.dart b/rohd_devtools_extension/packages/export_png/lib/src/capture_boundary.dart new file mode 100644 index 000000000..c0afe8e59 --- /dev/null +++ b/rohd_devtools_extension/packages/export_png/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:export_png/export_png.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/export_png/lib/src/export_button.dart b/rohd_devtools_extension/packages/export_png/lib/src/export_button.dart new file mode 100644 index 000000000..2f93890ed --- /dev/null +++ b/rohd_devtools_extension/packages/export_png/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/export_png/lib/src/export_toast.dart b/rohd_devtools_extension/packages/export_png/lib/src/export_toast.dart new file mode 100644 index 000000000..e962a6dd0 --- /dev/null +++ b/rohd_devtools_extension/packages/export_png/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/export_png/lib/src/save_png_native.dart b/rohd_devtools_extension/packages/export_png/lib/src/save_png_native.dart new file mode 100644 index 000000000..98f0aac5f --- /dev/null +++ b/rohd_devtools_extension/packages/export_png/lib/src/save_png_native.dart @@ -0,0 +1,20 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// save_png_native.dart +// Native (Linux/macOS/Windows) implementation: saves PNG bytes to a file. +// +// 2026 April +// Author: Desmond Kirkpatrick + +import 'dart:io'; +import 'dart:typed_data'; + +/// Save [pngBytes] to a file named [fileName] in the current directory. +/// Returns the absolute path of the saved file. +Future savePngBytes(Uint8List pngBytes, String fileName) async { + final dir = Directory.current.path; + final filePath = '$dir/$fileName'; + await File(filePath).writeAsBytes(pngBytes); + return filePath; +} diff --git a/rohd_devtools_extension/packages/export_png/lib/src/save_png_stub.dart b/rohd_devtools_extension/packages/export_png/lib/src/save_png_stub.dart new file mode 100644 index 000000000..1f063d2be --- /dev/null +++ b/rohd_devtools_extension/packages/export_png/lib/src/save_png_stub.dart @@ -0,0 +1,14 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// save_png_stub.dart +// Stub for conditional import — never actually imported at runtime. +// +// 2026 April +// Author: Desmond Kirkpatrick + +import 'dart:typed_data'; + +/// Stub — always throws. +Future savePngBytes(Uint8List pngBytes, String fileName) => + throw UnsupportedError('savePngBytes not supported on this platform'); diff --git a/rohd_devtools_extension/packages/export_png/lib/src/save_png_web.dart b/rohd_devtools_extension/packages/export_png/lib/src/save_png_web.dart new file mode 100644 index 000000000..5b0b9221e --- /dev/null +++ b/rohd_devtools_extension/packages/export_png/lib/src/save_png_web.dart @@ -0,0 +1,32 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// save_png_web.dart +// Web implementation: triggers browser download of PNG bytes. +// +// 2026 April +// Author: Desmond Kirkpatrick + +import 'dart:js_interop'; +import 'dart:typed_data'; + +import 'package:web/web.dart' as web; + +/// Trigger a browser download of [pngBytes] as [fileName]. +/// Returns `null` (no file path on web). +Future savePngBytes(Uint8List pngBytes, String fileName) async { + final blob = web.Blob( + [pngBytes.toJS].toJS, + web.BlobPropertyBag(type: 'image/png'), + ); + final url = web.URL.createObjectURL(blob); + final anchor = web.HTMLAnchorElement() + ..href = url + ..download = fileName + ..style.display = 'none'; + web.document.body!.append(anchor); + anchor.click(); + anchor.remove(); + web.URL.revokeObjectURL(url); + return null; +} diff --git a/rohd_devtools_extension/packages/export_png/pubspec.yaml b/rohd_devtools_extension/packages/export_png/pubspec.yaml new file mode 100644 index 000000000..ef7861579 --- /dev/null +++ b/rohd_devtools_extension/packages/export_png/pubspec.yaml @@ -0,0 +1,18 @@ +name: export_png +description: "Platform-conditional PNG export — shared across ROHD DevTools sub-packages." +homepage: https://intel.github.io/rohd-website/ +version: 0.1.0 + +publish_to: none + +environment: + sdk: '>=3.4.0 <4.0.0' + flutter: '>=3.0.0' + +dependencies: + flutter: + sdk: flutter + web: ^1.0.0 + +dev_dependencies: + lints: ^5.0.0 diff --git a/rohd_devtools_extension/packages/help_api/analysis_options.yaml b/rohd_devtools_extension/packages/help_api/analysis_options.yaml new file mode 100644 index 000000000..f9b303465 --- /dev/null +++ b/rohd_devtools_extension/packages/help_api/analysis_options.yaml @@ -0,0 +1 @@ +include: package:flutter_lints/flutter.yaml diff --git a/rohd_devtools_extension/packages/help_api/lib/help_api.dart b/rohd_devtools_extension/packages/help_api/lib/help_api.dart new file mode 100644 index 000000000..a6ead311d --- /dev/null +++ b/rohd_devtools_extension/packages/help_api/lib/help_api.dart @@ -0,0 +1,12 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// help_api.dart +// Barrel file for the help_api package. +// +// 2026 March +// Author: Desmond Kirkpatrick + +library; + +export 'src/markdown_help_button.dart'; diff --git a/rohd_devtools_extension/packages/help_api/lib/src/markdown_help_button.dart b/rohd_devtools_extension/packages/help_api/lib/src/markdown_help_button.dart new file mode 100644 index 000000000..d3ee5ed88 --- /dev/null +++ b/rohd_devtools_extension/packages/help_api/lib/src/markdown_help_button.dart @@ -0,0 +1,468 @@ +// 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; + + /// Create a [MarkdownHelpButton]. + const MarkdownHelpButton({ + required this.assetPath, + required this.isDark, + this.label = '❓', + this.labelIcon, + this.package, + this.titleIcon, + 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); + } + 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(' + +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..39dd7152b --- /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:help_api/help_api.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..c85f61401 --- /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:help_api/help_api.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..8a687e7e0 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:export_png/export_png.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..676f9096a 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:export_png/export_png.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/export_png/analysis_options.yaml b/rohd_devtools_extension/packages/export_png/analysis_options.yaml new file mode 100644 index 000000000..572dd239d --- /dev/null +++ b/rohd_devtools_extension/packages/export_png/analysis_options.yaml @@ -0,0 +1 @@ +include: package:lints/recommended.yaml diff --git a/rohd_devtools_extension/packages/export_png/lib/export_png.dart b/rohd_devtools_extension/packages/export_png/lib/export_png.dart new file mode 100644 index 000000000..90badac3e --- /dev/null +++ b/rohd_devtools_extension/packages/export_png/lib/export_png.dart @@ -0,0 +1,16 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// export_png.dart +// Platform-conditional PNG export utilities shared across ROHD DevTools +// sub-packages (schematic viewer, waveform viewer, etc.). +// +// 2026 April +// Author: Desmond Kirkpatrick + +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'; +export 'src/capture_boundary.dart'; +export 'src/export_button.dart'; +export 'src/export_toast.dart'; diff --git a/rohd_devtools_extension/packages/export_png/lib/src/capture_boundary.dart b/rohd_devtools_extension/packages/export_png/lib/src/capture_boundary.dart new file mode 100644 index 000000000..c0afe8e59 --- /dev/null +++ b/rohd_devtools_extension/packages/export_png/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:export_png/export_png.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/export_png/lib/src/export_button.dart b/rohd_devtools_extension/packages/export_png/lib/src/export_button.dart new file mode 100644 index 000000000..2f93890ed --- /dev/null +++ b/rohd_devtools_extension/packages/export_png/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/export_png/lib/src/export_toast.dart b/rohd_devtools_extension/packages/export_png/lib/src/export_toast.dart new file mode 100644 index 000000000..e962a6dd0 --- /dev/null +++ b/rohd_devtools_extension/packages/export_png/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/export_png/lib/src/save_png_native.dart b/rohd_devtools_extension/packages/export_png/lib/src/save_png_native.dart new file mode 100644 index 000000000..98f0aac5f --- /dev/null +++ b/rohd_devtools_extension/packages/export_png/lib/src/save_png_native.dart @@ -0,0 +1,20 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// save_png_native.dart +// Native (Linux/macOS/Windows) implementation: saves PNG bytes to a file. +// +// 2026 April +// Author: Desmond Kirkpatrick + +import 'dart:io'; +import 'dart:typed_data'; + +/// Save [pngBytes] to a file named [fileName] in the current directory. +/// Returns the absolute path of the saved file. +Future savePngBytes(Uint8List pngBytes, String fileName) async { + final dir = Directory.current.path; + final filePath = '$dir/$fileName'; + await File(filePath).writeAsBytes(pngBytes); + return filePath; +} diff --git a/rohd_devtools_extension/packages/export_png/lib/src/save_png_stub.dart b/rohd_devtools_extension/packages/export_png/lib/src/save_png_stub.dart new file mode 100644 index 000000000..1f063d2be --- /dev/null +++ b/rohd_devtools_extension/packages/export_png/lib/src/save_png_stub.dart @@ -0,0 +1,14 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// save_png_stub.dart +// Stub for conditional import — never actually imported at runtime. +// +// 2026 April +// Author: Desmond Kirkpatrick + +import 'dart:typed_data'; + +/// Stub — always throws. +Future savePngBytes(Uint8List pngBytes, String fileName) => + throw UnsupportedError('savePngBytes not supported on this platform'); diff --git a/rohd_devtools_extension/packages/export_png/lib/src/save_png_web.dart b/rohd_devtools_extension/packages/export_png/lib/src/save_png_web.dart new file mode 100644 index 000000000..5b0b9221e --- /dev/null +++ b/rohd_devtools_extension/packages/export_png/lib/src/save_png_web.dart @@ -0,0 +1,32 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// save_png_web.dart +// Web implementation: triggers browser download of PNG bytes. +// +// 2026 April +// Author: Desmond Kirkpatrick + +import 'dart:js_interop'; +import 'dart:typed_data'; + +import 'package:web/web.dart' as web; + +/// Trigger a browser download of [pngBytes] as [fileName]. +/// Returns `null` (no file path on web). +Future savePngBytes(Uint8List pngBytes, String fileName) async { + final blob = web.Blob( + [pngBytes.toJS].toJS, + web.BlobPropertyBag(type: 'image/png'), + ); + final url = web.URL.createObjectURL(blob); + final anchor = web.HTMLAnchorElement() + ..href = url + ..download = fileName + ..style.display = 'none'; + web.document.body!.append(anchor); + anchor.click(); + anchor.remove(); + web.URL.revokeObjectURL(url); + return null; +} diff --git a/rohd_devtools_extension/packages/export_png/pubspec.yaml b/rohd_devtools_extension/packages/export_png/pubspec.yaml new file mode 100644 index 000000000..ef7861579 --- /dev/null +++ b/rohd_devtools_extension/packages/export_png/pubspec.yaml @@ -0,0 +1,18 @@ +name: export_png +description: "Platform-conditional PNG export — shared across ROHD DevTools sub-packages." +homepage: https://intel.github.io/rohd-website/ +version: 0.1.0 + +publish_to: none + +environment: + sdk: '>=3.4.0 <4.0.0' + flutter: '>=3.0.0' + +dependencies: + flutter: + sdk: flutter + web: ^1.0.0 + +dev_dependencies: + lints: ^5.0.0 diff --git a/rohd_devtools_extension/packages/help_api/analysis_options.yaml b/rohd_devtools_extension/packages/help_api/analysis_options.yaml new file mode 100644 index 000000000..f9b303465 --- /dev/null +++ b/rohd_devtools_extension/packages/help_api/analysis_options.yaml @@ -0,0 +1 @@ +include: package:flutter_lints/flutter.yaml diff --git a/rohd_devtools_extension/packages/help_api/lib/help_api.dart b/rohd_devtools_extension/packages/help_api/lib/help_api.dart new file mode 100644 index 000000000..a6ead311d --- /dev/null +++ b/rohd_devtools_extension/packages/help_api/lib/help_api.dart @@ -0,0 +1,12 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// help_api.dart +// Barrel file for the help_api package. +// +// 2026 March +// Author: Desmond Kirkpatrick + +library; + +export 'src/markdown_help_button.dart'; diff --git a/rohd_devtools_extension/packages/help_api/lib/src/markdown_help_button.dart b/rohd_devtools_extension/packages/help_api/lib/src/markdown_help_button.dart new file mode 100644 index 000000000..d3ee5ed88 --- /dev/null +++ b/rohd_devtools_extension/packages/help_api/lib/src/markdown_help_button.dart @@ -0,0 +1,468 @@ +// 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; + + /// Create a [MarkdownHelpButton]. + const MarkdownHelpButton({ + required this.assetPath, + required this.isDark, + this.label = '❓', + this.labelIcon, + this.package, + this.titleIcon, + 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); + } + 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('