From 4532f55f8bb6efefc8a9a4cf24927cddab6e01fb Mon Sep 17 00:00:00 2001 From: "Desmond A. Kirkpatrick" Date: Wed, 3 Jun 2026 15:53:21 -0700 Subject: [PATCH 01/19] updated devtool area --- .github/workflows/build_devtool.yml | 2 +- .github/workflows/general.yml | 2 +- devtools_options.yaml | 4 + rohd_devtools_extension/.vscode/launch.json | 11 + rohd_devtools_extension/.vscode/tasks.json | 137 ++ .../assets/help/details_help.md | 28 + .../assets/help/devtools_help.md | 34 + .../assets/icons/rohd_logo.png | Bin 0 -> 1204 bytes rohd_devtools_extension/lib/main.dart | 15 +- .../lib/main_standalone.dart | 52 + .../lib/rohd_devtools/const/app_theme.dart | 193 +++ .../lib/rohd_devtools/cubit/cubits.dart | 12 + .../cubit/details_tab_cubit.dart | 31 + .../cubit/rohd_service_cubit.dart | 235 ++- .../cubit/rohd_service_state.dart | 12 + .../cubit/selected_module_cubit.dart | 5 +- .../cubit/selected_module_state.dart | 6 + .../cubit/signal_search_term_cubit.dart | 3 + .../lib/rohd_devtools/cubit/theme_cubit.dart | 39 + .../cubit/tree_search_term_cubit.dart | 3 + .../models/dtd_vm_service_info.dart | 74 + .../rohd_devtools/models/signal_model.dart | 41 +- .../lib/rohd_devtools/models/tree_model.dart | 43 +- .../lib/rohd_devtools/rohd_devtools.dart | 2 + .../services/connection_state_machine.dart | 618 ++++++++ .../services/io_vm_connection_strategy.dart | 145 ++ .../platform_vm_connection_strategy.dart | 21 + .../platform_vm_connection_strategy_stub.dart | 22 + .../services/service_manager_bridge.dart | 11 + .../services/service_manager_bridge_io.dart | 16 + .../services/service_manager_bridge_web.dart | 11 + .../lib/rohd_devtools/services/services.dart | 14 + .../services/signal_service.dart | 6 +- .../rohd_devtools/services/tree_service.dart | 98 +- .../services/web_vm_connection_strategy.dart | 171 +++ .../rohd_devtools/ui/details_help_button.dart | 40 + .../lib/rohd_devtools/ui/devtool_appbar.dart | 58 +- .../ui/devtools_connection_host.dart | 1306 +++++++++++++++++ .../ui/devtools_help_button.dart | 40 + .../rohd_devtools/ui/module_tree_card.dart | 84 +- .../ui/module_tree_details_navbar.dart | 170 ++- .../lib/rohd_devtools/ui/platform_icon.dart | 124 ++ .../lib/rohd_devtools/ui/schematic_icon.dart | 125 ++ .../rohd_devtools/ui/signal_details_card.dart | 208 ++- .../lib/rohd_devtools/ui/signal_table.dart | 126 +- .../ui/signal_table_text_field.dart | 35 +- .../ui/standalone_app_shell.dart | 359 +++++ .../lib/rohd_devtools/ui/ui.dart | 20 + .../rohd_devtools/ui/vm_connection_form.dart | 718 +++++++++ .../view/rohd_devtools_page.dart | 68 +- .../view/tree_structure_page.dart | 335 +++-- .../lib/rohd_devtools_observer.dart | 3 + rohd_devtools_extension/linux/.gitignore | 1 + rohd_devtools_extension/linux/CMakeLists.txt | 138 ++ .../linux/flutter/CMakeLists.txt | 88 ++ .../flutter/generated_plugin_registrant.cc | 15 + .../flutter/generated_plugin_registrant.h | 15 + .../linux/flutter/generated_plugins.cmake | 24 + .../linux/runner/CMakeLists.txt | 26 + rohd_devtools_extension/linux/runner/main.cc | 6 + .../linux/runner/my_application.cc | 144 ++ .../linux/runner/my_application.h | 18 + .../analysis_options.yaml | 1 + .../lib/rohd_devtools_widgets.dart | 23 + .../lib/src/app_bar_overlay.dart | 168 +++ .../lib/src/capture_boundary.dart | 69 + .../lib/src/export_button.dart | 53 + .../lib/src/export_toast.dart | 48 + .../lib/src/markdown_help_button.dart | 486 ++++++ .../lib/src/save_png_native.dart | 20 + .../lib/src/save_png_stub.dart | 14 + .../lib/src/save_png_web.dart | 32 + .../rohd_devtools_widgets/pubspec.yaml | 13 + rohd_devtools_extension/pubspec.yaml | 31 +- .../fixtures/tree_model.stub.dart | 24 +- .../tree_structure/model_tree_card_test.dart | 7 +- .../tree_structure_page_test.dart | 10 +- rohd_devtools_extension/web/favicon.png | Bin 917 -> 1204 bytes .../web/icons/Icon-192.png | Bin 5292 -> 6255 bytes .../web/icons/Icon-512.png | Bin 8252 -> 17748 bytes .../web/icons/Icon-maskable-192.png | Bin 5594 -> 6255 bytes .../web/icons/Icon-maskable-512.png | Bin 20998 -> 17748 bytes tool/gh_actions/devtool/build_web.sh | 18 - tool/gh_actions/devtool/install_devtools.sh | 95 ++ 84 files changed, 7040 insertions(+), 483 deletions(-) create mode 100644 devtools_options.yaml create mode 100644 rohd_devtools_extension/.vscode/tasks.json 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/assets/icons/rohd_logo.png create mode 100644 rohd_devtools_extension/lib/main_standalone.dart create mode 100644 rohd_devtools_extension/lib/rohd_devtools/const/app_theme.dart create mode 100644 rohd_devtools_extension/lib/rohd_devtools/cubit/cubits.dart create mode 100644 rohd_devtools_extension/lib/rohd_devtools/cubit/details_tab_cubit.dart create mode 100644 rohd_devtools_extension/lib/rohd_devtools/cubit/theme_cubit.dart create mode 100644 rohd_devtools_extension/lib/rohd_devtools/models/dtd_vm_service_info.dart create mode 100644 rohd_devtools_extension/lib/rohd_devtools/services/connection_state_machine.dart create mode 100644 rohd_devtools_extension/lib/rohd_devtools/services/io_vm_connection_strategy.dart create mode 100644 rohd_devtools_extension/lib/rohd_devtools/services/platform_vm_connection_strategy.dart create mode 100644 rohd_devtools_extension/lib/rohd_devtools/services/platform_vm_connection_strategy_stub.dart create mode 100644 rohd_devtools_extension/lib/rohd_devtools/services/service_manager_bridge.dart create mode 100644 rohd_devtools_extension/lib/rohd_devtools/services/service_manager_bridge_io.dart create mode 100644 rohd_devtools_extension/lib/rohd_devtools/services/service_manager_bridge_web.dart create mode 100644 rohd_devtools_extension/lib/rohd_devtools/services/services.dart create mode 100644 rohd_devtools_extension/lib/rohd_devtools/services/web_vm_connection_strategy.dart 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_connection_host.dart create mode 100644 rohd_devtools_extension/lib/rohd_devtools/ui/devtools_help_button.dart create mode 100644 rohd_devtools_extension/lib/rohd_devtools/ui/platform_icon.dart create mode 100644 rohd_devtools_extension/lib/rohd_devtools/ui/schematic_icon.dart create mode 100644 rohd_devtools_extension/lib/rohd_devtools/ui/standalone_app_shell.dart create mode 100644 rohd_devtools_extension/lib/rohd_devtools/ui/ui.dart create mode 100644 rohd_devtools_extension/lib/rohd_devtools/ui/vm_connection_form.dart create mode 100644 rohd_devtools_extension/linux/.gitignore create mode 100644 rohd_devtools_extension/linux/CMakeLists.txt create mode 100644 rohd_devtools_extension/linux/flutter/CMakeLists.txt create mode 100644 rohd_devtools_extension/linux/flutter/generated_plugin_registrant.cc create mode 100644 rohd_devtools_extension/linux/flutter/generated_plugin_registrant.h create mode 100644 rohd_devtools_extension/linux/flutter/generated_plugins.cmake create mode 100644 rohd_devtools_extension/linux/runner/CMakeLists.txt create mode 100644 rohd_devtools_extension/linux/runner/main.cc create mode 100644 rohd_devtools_extension/linux/runner/my_application.cc create mode 100644 rohd_devtools_extension/linux/runner/my_application.h create mode 100644 rohd_devtools_extension/packages/rohd_devtools_widgets/analysis_options.yaml create mode 100644 rohd_devtools_extension/packages/rohd_devtools_widgets/lib/rohd_devtools_widgets.dart create mode 100644 rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/app_bar_overlay.dart create mode 100644 rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/capture_boundary.dart create mode 100644 rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/export_button.dart create mode 100644 rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/export_toast.dart create mode 100644 rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/markdown_help_button.dart create mode 100644 rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/save_png_native.dart create mode 100644 rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/save_png_stub.dart create mode 100644 rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/save_png_web.dart create mode 100644 rohd_devtools_extension/packages/rohd_devtools_widgets/pubspec.yaml delete mode 100755 tool/gh_actions/devtool/build_web.sh create mode 100755 tool/gh_actions/devtool/install_devtools.sh diff --git a/.github/workflows/build_devtool.yml b/.github/workflows/build_devtool.yml index 240dce3ce..ba8373cb5 100644 --- a/.github/workflows/build_devtool.yml +++ b/.github/workflows/build_devtool.yml @@ -28,7 +28,7 @@ jobs: run: tool/gh_actions/devtool/run_devtool_test.sh - name: Build Static Web - run: tool/gh_actions/devtool/build_web.sh + run: tool/gh_actions/devtool/install_devtools.sh - name: Create artifact branch and commit run: | diff --git a/.github/workflows/general.yml b/.github/workflows/general.yml index 673f550d3..546ca615f 100644 --- a/.github/workflows/general.yml +++ b/.github/workflows/general.yml @@ -117,5 +117,5 @@ jobs: run: tool/gh_actions/devtool/run_devtool_test.sh - name: Build Static Web - run: tool/gh_actions/devtool/build_web.sh + run: tool/gh_actions/devtool/install_devtools.sh diff --git a/devtools_options.yaml b/devtools_options.yaml new file mode 100644 index 000000000..f17ff0ca6 --- /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 \ No newline at end of file diff --git a/rohd_devtools_extension/.vscode/launch.json b/rohd_devtools_extension/.vscode/launch.json index d80e58185..fae1b0be8 100644 --- a/rohd_devtools_extension/.vscode/launch.json +++ b/rohd_devtools_extension/.vscode/launch.json @@ -30,5 +30,16 @@ "--dart-define=use_simulated_environment=true" ], }, + { + "name": "Run: Web Standalone (port 9099)", + "request": "launch", + "type": "dart", + "program": "lib/main_standalone.dart", + "deviceId": "web-server", + "args": [ + "--web-port=9099", + "--web-hostname=0.0.0.0" + ] + } ] } \ No newline at end of file diff --git a/rohd_devtools_extension/.vscode/tasks.json b/rohd_devtools_extension/.vscode/tasks.json new file mode 100644 index 000000000..1d5530e98 --- /dev/null +++ b/rohd_devtools_extension/.vscode/tasks.json @@ -0,0 +1,137 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Run: Web Standalone (Debug, port 9099)", + "type": "shell", + "command": "flutter run -d web-server --web-port=9099 --web-hostname=0.0.0.0 lib/main_standalone.dart", + "isBackground": true, + "problemMatcher": { + "pattern": { + "regexp": "^$" + }, + "background": { + "activeOnStart": true, + "beginsPattern": "^Launching", + "endsPattern": "is being served at" + } + }, + "presentation": { + "reveal": "always", + "panel": "dedicated", + "focus": true + }, + "detail": "Runs standalone web app in debug mode on port 9099" + }, + { + "label": "Run: Web Standalone (Release, port 9099)", + "type": "shell", + "command": "flutter run --release -d web-server --web-port=9099 --web-hostname=0.0.0.0 lib/main_standalone.dart", + "isBackground": true, + "problemMatcher": { + "pattern": { + "regexp": "^$" + }, + "background": { + "activeOnStart": true, + "beginsPattern": "^Launching", + "endsPattern": "is being served at" + } + }, + "presentation": { + "reveal": "always", + "panel": "dedicated", + "focus": true + }, + "detail": "Runs standalone web app in release mode on port 9099" + }, + { + "label": "Run: Linux Standalone (Debug)", + "type": "shell", + "command": "flutter run -d linux lib/main_standalone.dart", + "isBackground": true, + "problemMatcher": { + "pattern": { + "regexp": "^$" + }, + "background": { + "activeOnStart": true, + "beginsPattern": "^Launching", + "endsPattern": "^Application finished" + } + }, + "presentation": { + "reveal": "always", + "panel": "dedicated", + "focus": true + }, + "detail": "Runs standalone Linux app in debug mode" + }, + { + "label": "Run: Linux Standalone (Debug, software rendering)", + "type": "shell", + "command": "flutter run -d linux --enable-software-rendering lib/main_standalone.dart", + "isBackground": true, + "problemMatcher": { + "pattern": { + "regexp": "^$" + }, + "background": { + "activeOnStart": true, + "beginsPattern": "^Launching", + "endsPattern": "^Application finished" + } + }, + "presentation": { + "reveal": "always", + "panel": "dedicated", + "focus": true + }, + "detail": "Runs standalone Linux app in debug mode with software rendering" + }, + { + "label": "Run: Linux Standalone (Release)", + "type": "shell", + "command": "flutter run --release -d linux lib/main_standalone.dart", + "isBackground": true, + "problemMatcher": { + "pattern": { + "regexp": "^$" + }, + "background": { + "activeOnStart": true, + "beginsPattern": "^Launching", + "endsPattern": "^Application finished" + } + }, + "presentation": { + "reveal": "always", + "panel": "dedicated", + "focus": true + }, + "detail": "Runs standalone Linux app in release mode" + }, + { + "label": "Run: Linux Standalone (Release, software rendering)", + "type": "shell", + "command": "flutter run --release -d linux --enable-software-rendering lib/main_standalone.dart", + "isBackground": true, + "problemMatcher": { + "pattern": { + "regexp": "^$" + }, + "background": { + "activeOnStart": true, + "beginsPattern": "^Launching", + "endsPattern": "^Application finished" + } + }, + "presentation": { + "reveal": "always", + "panel": "dedicated", + "focus": true + }, + "detail": "Runs standalone Linux app in release mode with software rendering" + } + ] +} 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/assets/icons/rohd_logo.png b/rohd_devtools_extension/assets/icons/rohd_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..4d641bcaf8027739f9020c2f20e59a77a2c75c88 GIT binary patch literal 1204 zcmV;l1WWsgP)340K5!i>=D&`U9r|8?p`t?e6Oj z+R=8m`zVX=pZ31zJ?DAuJ?Grh3KNKp&CTVBglB__St0NMXc;0sVsjd?Kk4soWibdMMA-)1!?lDvtiSI~4iiRw8OuA=6VsaKd`b#+}R z=}WdMtYx@P5RtgkxMK?Vd_Gs%oCSMT)t{Lcq&w+i?K>gs*UWsVE!KgsI=hyp?F}{W zI*l)5!tYzqf#?f4oemcb_O5JcX}Ok}4@M3?BiKhXbE+C>ZmbLEW8{4TKA+DuckVq$ zE+%z(R)Z2Z?rZhA@Ec)o%t@RY4W3uP=kvK1Ros5^?Dc66=C+COeQPMx=hRQ2DktU1 z2M)D$)+2b06TE!R`~zpN-#Y@H!t^+0E$f&Yn4Xia47bG==hlWJ(ane)0NI%(ke+iJ zsmo_x=DPHlv%u0Ma4{!sRP=*PiEt#kSz&kT@{$0o-@4;D5i=j9GIzmchDVaTRQ{n> zl>FW_Wz)dhnQg)L*jiED2aI^CfDQZtB!GqKqzEx-*t)}y&_l z-Pm}F0K#ptl`0wqJc7;gL=sjRtNoH`#w(o->E_^^aU()(|`E{}u=(qI4v|GMiZh10-{Vt4Y# zP$YU1W%;eZ$0MPT7+!>LM52`fhXfuq0000 const DevToolsExtension( + child: RohdDevToolsPage(), + ); } diff --git a/rohd_devtools_extension/lib/main_standalone.dart b/rohd_devtools_extension/lib/main_standalone.dart new file mode 100644 index 000000000..f42e8c4f6 --- /dev/null +++ b/rohd_devtools_extension/lib/main_standalone.dart @@ -0,0 +1,52 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// main_standalone.dart +// Unified standalone entry point for both web and native (Linux/macOS/ +// Windows) builds. The platform-appropriate [VmConnectionStrategy] is +// selected via conditional imports in +// `rohd_devtools/services/platform_vm_connection_strategy.dart`. +// +// Run on web: flutter run -d web-server lib/main_standalone.dart +// Run on Linux: flutter run -d linux lib/main_standalone.dart +// +// 2026 June +// Author: Desmond Kirkpatrick + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:logging/logging.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/services/services.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/ui/standalone_app_shell.dart'; + +/// Entry point for the standalone ROHD DevTools app. +void main(List args) { + _setupLogging(); + + final config = StandaloneAppConfig( + title: 'ROHD DevTools', + connectionStrategy: createPlatformVmConnectionStrategy(), + ); + + debugPrint( + '[main_standalone] Starting ROHD DevTools ' + '(${kIsWeb ? "Web" : "Native"})...', + ); + runApp(StandaloneRohdDevToolsApp(config: config)); +} + +void _setupLogging() { + Logger.root.level = Level.INFO; + Logger.root.onRecord.listen((record) { + final ts = record.time.toIso8601String(); + debugPrint( + '[$ts] [${record.loggerName}] ${record.level.name}: ${record.message}', + ); + if (record.error != null) { + debugPrint(' error: ${record.error}'); + } + if (record.stackTrace != null) { + debugPrint(' stack: ${record.stackTrace}'); + } + }); +} diff --git a/rohd_devtools_extension/lib/rohd_devtools/const/app_theme.dart b/rohd_devtools_extension/lib/rohd_devtools/const/app_theme.dart new file mode 100644 index 000000000..2c6df9e89 --- /dev/null +++ b/rohd_devtools_extension/lib/rohd_devtools/const/app_theme.dart @@ -0,0 +1,193 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// app_theme.dart +// Centralized theme definitions for ROHD DevTools. +// +// 2026 January +// Author: Desmond Kirkpatrick + +import 'package:flutter/material.dart'; + +const _fontFallback = ['Noto Color Emoji']; + +TextTheme _withFontFallback(TextTheme theme) => theme.apply( + fontFamilyFallback: _fontFallback, + ); + +/// Dark theme colors +class DarkThemeColors { + /// Colors matching VS Code dark theme. + static const scaffoldBackground = Color(0xFF1E1E1E); + + /// Card background color. + static const cardBackground = Color(0xFF252526); + + /// Panel background color. + static const panelBackground = Color(0xFF252526); + + /// Panel header color. + static const panelHeader = Color(0xFF333333); + + /// Divider color. + static const divider = Color(0xFF3C3C3C); + + /// Primary text color. + static const text = Colors.white; + + /// Secondary text color. + static const textSecondary = Colors.white70; + + /// AppBar background color. + static const appBarBackground = Color(0xFF252526); +} + +/// Light theme colors +class LightThemeColors { + /// Slightly darker than white + /// to reduce eye strain. + static const scaffoldBackground = Color(0xFFE8E8E8); + + /// Card background color. + static const cardBackground = Colors.white; + + /// Panel background color. + static const panelBackground = Color(0xFFFAFAFA); + + /// Panel header color. + static const panelHeader = Color(0xFFF5F5F5); + + /// Divider color. + static const divider = Colors.black26; + + /// Primary text color. + static const text = Colors.black87; + + /// Secondary text color. + static const textSecondary = Colors.black54; + + /// AppBar background color. + static const appBarBackground = Color(0xFFF5F5F5); +} + +/// AppBar themes +class AppBarThemes { + /// Dark theme AppBar - matches VS Code dark theme + static const dark = AppBarTheme( + backgroundColor: DarkThemeColors.appBarBackground, + foregroundColor: DarkThemeColors.text, + elevation: 0, + shadowColor: Colors.transparent, + ); + + /// Light theme AppBar + static const light = AppBarTheme( + backgroundColor: LightThemeColors.appBarBackground, + foregroundColor: LightThemeColors.text, + elevation: 0, + shadowColor: Colors.transparent, + ); +} + +/// Build dark theme data +ThemeData buildDarkTheme() => ThemeData.dark().copyWith( + colorScheme: ColorScheme.fromSeed( + seedColor: const Color(0xFF4A90A4), + brightness: Brightness.dark, + ), + scaffoldBackgroundColor: DarkThemeColors.scaffoldBackground, + cardColor: DarkThemeColors.cardBackground, + dividerColor: DarkThemeColors.divider, + cardTheme: const CardThemeData( + elevation: 0, + shadowColor: Colors.transparent, + color: DarkThemeColors.cardBackground, + ), + appBarTheme: AppBarThemes.dark, + popupMenuTheme: PopupMenuThemeData( + color: const Color(0xFF3C3C3C), + elevation: 8, + shadowColor: Colors.black54, + surfaceTintColor: Colors.transparent, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: BorderSide(color: Colors.white.withValues(alpha: 0.1)), + ), + textStyle: const TextStyle(color: Colors.white, fontSize: 13), + ), + dialogTheme: DialogThemeData( + backgroundColor: const Color(0xFF2D2D30).withValues(alpha: 0.90), + elevation: 16, + shadowColor: Colors.black54, + surfaceTintColor: Colors.transparent, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide(color: Colors.white.withValues(alpha: 0.08)), + ), + titleTextStyle: const TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + contentTextStyle: const TextStyle(color: Colors.white70, fontSize: 14), + ), + // Disable hover effects (workaround for Flutter #172079) + hoverColor: Colors.transparent, + splashColor: Colors.transparent, + highlightColor: Colors.transparent, + splashFactory: NoSplash.splashFactory, + textTheme: _withFontFallback(ThemeData.dark().textTheme), + primaryTextTheme: _withFontFallback(ThemeData.dark().primaryTextTheme), + ); + +/// Build light theme data +ThemeData buildLightTheme() => ThemeData.light().copyWith( + colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF4A90A4)), + scaffoldBackgroundColor: LightThemeColors.scaffoldBackground, + cardColor: LightThemeColors.cardBackground, + dividerColor: LightThemeColors.divider, + cardTheme: CardThemeData( + elevation: 2, + shadowColor: Colors.black.withValues(alpha: 0.2), + color: LightThemeColors.cardBackground, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: BorderSide(color: Colors.black.withValues(alpha: 0.1)), + ), + ), + appBarTheme: AppBarThemes.light, + popupMenuTheme: PopupMenuThemeData( + color: Colors.white.withValues(alpha: 0.85), + elevation: 8, + shadowColor: Colors.black26, + surfaceTintColor: Colors.transparent, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: BorderSide(color: Colors.black.withValues(alpha: 0.12)), + ), + textStyle: const TextStyle(color: Colors.black87, fontSize: 13), + ), + dialogTheme: DialogThemeData( + backgroundColor: Colors.white, + elevation: 16, + shadowColor: Colors.black26, + surfaceTintColor: Colors.transparent, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide(color: Colors.black.withValues(alpha: 0.1)), + ), + titleTextStyle: const TextStyle( + color: Colors.black87, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + contentTextStyle: const TextStyle(color: Colors.black54, fontSize: 14), + ), + // Disable hover effects (workaround for Flutter #172079) + hoverColor: Colors.transparent, + splashColor: Colors.transparent, + highlightColor: Colors.transparent, + splashFactory: NoSplash.splashFactory, + textTheme: _withFontFallback(ThemeData.light().textTheme), + primaryTextTheme: _withFontFallback(ThemeData.light().primaryTextTheme), + ); diff --git a/rohd_devtools_extension/lib/rohd_devtools/cubit/cubits.dart b/rohd_devtools_extension/lib/rohd_devtools/cubit/cubits.dart new file mode 100644 index 000000000..3866ef644 --- /dev/null +++ b/rohd_devtools_extension/lib/rohd_devtools/cubit/cubits.dart @@ -0,0 +1,12 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// cubits.dart +// Barrel file for rohd_devtools cubits. + +export 'details_tab_cubit.dart'; +export 'rohd_service_cubit.dart'; +export 'selected_module_cubit.dart'; +export 'signal_search_term_cubit.dart'; +export 'theme_cubit.dart'; +export 'tree_search_term_cubit.dart'; diff --git a/rohd_devtools_extension/lib/rohd_devtools/cubit/details_tab_cubit.dart b/rohd_devtools_extension/lib/rohd_devtools/cubit/details_tab_cubit.dart new file mode 100644 index 000000000..5d35eb257 --- /dev/null +++ b/rohd_devtools_extension/lib/rohd_devtools/cubit/details_tab_cubit.dart @@ -0,0 +1,31 @@ +// Copyright (C) 2025 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// details_tab_cubit.dart +// Cubit for managing the selected tab in module details view. +// +// 2025 January 12 +// Author: Desmond Kirkpatrick + +import 'package:flutter_bloc/flutter_bloc.dart'; + +/// Enum representing the available tabs in the module details view. +enum DetailsTab { + /// Details tab showing module information. + details, + + /// Waveform tab showing signal waveforms. + waveform, + + /// Schematic tab showing module schematics. + schematic, +} + +/// Cubit for managing the selected tab state. +class DetailsTabCubit extends Cubit { + /// Initializes the cubit with the default tab as [DetailsTab.details]. + DetailsTabCubit() : super(DetailsTab.details); + + /// Sets the currently selected tab. + void selectTab(DetailsTab tab) => emit(tab); +} 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..f0b0aa09a 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 @@ -7,50 +7,247 @@ // 2025 January 28 // Author: Roberto Torres +import 'dart:async'; + import 'package:devtools_app_shared/service.dart'; -import 'package:devtools_extensions/devtools_extensions.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:devtools_app_shared/utils.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:rohd_devtools_extension/rohd_devtools/services/services.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 { + final bool _manageServiceManager; + ServiceManager? _localServiceManager; + + /// Completer used to signal teardown of the standalone VM service to the + /// local [ServiceManager]. + Completer? _localServiceClosedSignal; + + /// The TreeService instance for ROHD. TreeService? treeService; - RohdServiceCubit() : super(RohdServiceInitial()) { - evalModuleTree(); + /// 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({bool manageServiceManager = true}) + : _manageServiceManager = manageServiceManager, + super(RohdServiceInitial()) { + if (_manageServiceManager) { + _connectionListener = _onConnectionStateChanged; + serviceManager.connectedState.addListener(_connectionListener!); + if (serviceManager.connectedState.value.connected) { + unawaited(Future.microtask(evalModuleTree)); + } + } + } + + /// Configure a standalone VM service session without relying on the global + /// DevTools extension [serviceManager]. + Future configureStandaloneVmService( + vm.VmService vmService, + String isolateId, + ) async { + _rohdIsolateId = null; + + if (_localServiceManager != null && + _localServiceClosedSignal != null && + !_localServiceClosedSignal!.isCompleted) { + _localServiceClosedSignal!.complete(); + } + + _localServiceManager = ServiceManager(); + final localManager = _localServiceManager!; + _localServiceClosedSignal = Completer(); + + await localManager.vmServiceOpened( + vmService, + onClosed: _localServiceClosedSignal!.future, + ); + + final vmInfo = await vmService.getVM(); + final isolates = vmInfo.isolates ?? const []; + for (final ref in isolates) { + if (ref.id == isolateId) { + localManager.isolateManager.selectIsolate(ref); + break; + } + } + + treeService = null; + _rohdIsolateId = null; } + void _onConnectionStateChanged() { + final connected = serviceManager.connectedState.value.connected; + debugPrint( + '[RohdServiceCubit] Connection state changed: ' + 'connected=$connected', + ); + if (connected) { + // Reset tree service so we use the new connection + treeService = null; + unawaited(evalModuleTree()); + } else { + // VM disconnected β€” reset so tree page can tear down waveforms + // and other stale references. + treeService = null; + _rohdIsolateId = null; + emit(RohdServiceInitial()); + } + } + + @override + Future close() { + if (_connectionListener != null && _manageServiceManager) { + serviceManager.connectedState.removeListener(_connectionListener!); + _connectionListener = null; + } + if (_localServiceClosedSignal != null && + !_localServiceClosedSignal!.isCompleted) { + _localServiceClosedSignal!.complete(); + } + _localServiceManager = null; + return super.close(); + } + + /// Evaluate the module tree from the ROHD service. Future evalModuleTree() async { + debugPrint('[RohdServiceCubit] evalModuleTree called'); await _handleModuleTreeOperation( - (treeService) => treeService.evalModuleTree()); + (treeService) => treeService.evalModuleTree(), + ); } + /// Refresh the module tree from the ROHD service. Future refreshModuleTree() async { + debugPrint('[RohdServiceCubit] refreshModuleTree called'); await _handleModuleTreeOperation( - (treeService) => treeService.refreshModuleTree()); + (treeService) => treeService.refreshModuleTree(), + ); } Future _handleModuleTreeOperation( - Future Function(TreeService) operation) async { + Future Function(TreeService) operation, + ) async { try { + debugPrint( + '[RohdServiceCubit] _handleModuleTreeOperation - emitting loading', + ); emit(RohdServiceLoading()); - if (serviceManager.service == null) { - throw Exception('ServiceManager is not initialized'); + + final activeServiceManager = + _manageServiceManager ? serviceManager : _localServiceManager; + final activeService = activeServiceManager?.service; + + if (activeService == null) { + debugPrint( + '[RohdServiceCubit] ServiceManager is not initialized - ' + 'emitting loaded with null', + ); + // When not running in DevTools, just emit loaded with null tree + // This prevents constant error states and allows the UI to work + emit(const RohdServiceLoaded(null)); + return; } - treeService ??= TreeService( - EvalOnDartLibrary( - 'package:rohd/src/diagnostics/inspector_service.dart', - serviceManager.service!, - serviceManager: serviceManager, - ), - Disposable(), - ); + + debugPrint('[RohdServiceCubit] Creating TreeService...'); + 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 = activeService; + ValueListenable? rohdIsolate; + + try { + final vmInfo = await service.getVM(); + final isolates = vmInfo.isolates ?? []; + debugPrint( + '[RohdServiceCubit] Scanning ${isolates.length} ' + 'isolate(s) for ROHD library...', + ); + + for (final isoRef in isolates) { + final id = isoRef.id; + if (id == null) { + continue; + } + try { + final iso = await service.getIsolate(id); + final libs = iso.libraries ?? []; + debugPrint( + '[RohdServiceCubit] Isolate ${isoRef.name} ' + '(${isoRef.id}): ${libs.length} 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 catch (e) { + debugPrint( + '[RohdServiceCubit] Isolate ${isoRef.name} ' + 'scan error: $e', + ); + } + } + } 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: activeServiceManager!, + isolate: rohdIsolate, + ), + Disposable(), + vmService: service, + isolateId: _rohdIsolateId, + ); + } + + debugPrint('[RohdServiceCubit] Calling operation...'); final treeModel = await operation(treeService!); + + debugPrint('[RohdServiceCubit] Operation complete, emitting loaded'); emit(RohdServiceLoaded(treeModel)); - } catch (error, trace) { + } on Exception catch (error, trace) { + debugPrint('[RohdServiceCubit] Error: $error'); + // 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/cubit/rohd_service_state.dart b/rohd_devtools_extension/lib/rohd_devtools/cubit/rohd_service_state.dart index c6239e7c9..e8b65cd14 100644 --- a/rohd_devtools_extension/lib/rohd_devtools/cubit/rohd_service_state.dart +++ b/rohd_devtools_extension/lib/rohd_devtools/cubit/rohd_service_state.dart @@ -9,30 +9,42 @@ part of 'rohd_service_cubit.dart'; +/// Base state for ROHD service loading and error handling. abstract class RohdServiceState extends Equatable { + /// Creates a ROHD service state. const RohdServiceState(); @override List get props => []; } +/// Initial state before any ROHD service activity occurs. class RohdServiceInitial extends RohdServiceState {} +/// State emitted while loading ROHD service data. class RohdServiceLoading extends RohdServiceState {} +/// State emitted after ROHD service data has been loaded. class RohdServiceLoaded extends RohdServiceState { + /// Loaded module tree data, if available. final TreeModel? treeModel; + /// Creates a loaded state with tree data. const RohdServiceLoaded(this.treeModel); @override List get props => [treeModel]; } +/// State emitted when ROHD service loading fails. class RohdServiceError extends RohdServiceState { + /// Error message. final String error; + + /// Stack trace associated with the failure. final StackTrace trace; + /// Creates an error state. const RohdServiceError(this.error, this.trace); @override diff --git a/rohd_devtools_extension/lib/rohd_devtools/cubit/selected_module_cubit.dart b/rohd_devtools_extension/lib/rohd_devtools/cubit/selected_module_cubit.dart index 500d0661f..23c97cd1c 100644 --- a/rohd_devtools_extension/lib/rohd_devtools/cubit/selected_module_cubit.dart +++ b/rohd_devtools_extension/lib/rohd_devtools/cubit/selected_module_cubit.dart @@ -7,15 +7,18 @@ // 2025 January 28 // Author: Roberto Torres -import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:rohd_devtools_extension/rohd_devtools/models/tree_model.dart'; part 'selected_module_state.dart'; +/// Cubit that tracks which module is currently selected. class SelectedModuleCubit extends Cubit { + /// Creates the selected-module cubit. SelectedModuleCubit() : super(SelectedModuleInitial()); + /// Selects a module and emits the loaded state. void setModule(TreeModel module) { emit(SelectedModuleLoaded(module)); } diff --git a/rohd_devtools_extension/lib/rohd_devtools/cubit/selected_module_state.dart b/rohd_devtools_extension/lib/rohd_devtools/cubit/selected_module_state.dart index 513c94758..422e8f0b6 100644 --- a/rohd_devtools_extension/lib/rohd_devtools/cubit/selected_module_state.dart +++ b/rohd_devtools_extension/lib/rohd_devtools/cubit/selected_module_state.dart @@ -9,18 +9,24 @@ part of 'selected_module_cubit.dart'; +/// Base state for the currently selected module. abstract class SelectedModuleState extends Equatable { + /// Creates a selected-module state. const SelectedModuleState(); @override List get props => []; } +/// State emitted when no module is selected. class SelectedModuleInitial extends SelectedModuleState {} +/// State emitted when a module has been selected. class SelectedModuleLoaded extends SelectedModuleState { + /// The currently selected module. final TreeModel module; + /// Creates a loaded state with the selected module. const SelectedModuleLoaded(this.module); @override diff --git a/rohd_devtools_extension/lib/rohd_devtools/cubit/signal_search_term_cubit.dart b/rohd_devtools_extension/lib/rohd_devtools/cubit/signal_search_term_cubit.dart index 15a8edbb7..abdbaee3b 100644 --- a/rohd_devtools_extension/lib/rohd_devtools/cubit/signal_search_term_cubit.dart +++ b/rohd_devtools_extension/lib/rohd_devtools/cubit/signal_search_term_cubit.dart @@ -9,9 +9,12 @@ import 'package:flutter_bloc/flutter_bloc.dart'; +/// Cubit that stores the current signal-table search term. class SignalSearchTermCubit extends Cubit { + /// Creates the signal-search cubit with no initial term. SignalSearchTermCubit() : super(null); + /// Updates the search term. void setTerm(String term) { emit(term); } diff --git a/rohd_devtools_extension/lib/rohd_devtools/cubit/theme_cubit.dart b/rohd_devtools_extension/lib/rohd_devtools/cubit/theme_cubit.dart new file mode 100644 index 000000000..34fdd868f --- /dev/null +++ b/rohd_devtools_extension/lib/rohd_devtools/cubit/theme_cubit.dart @@ -0,0 +1,39 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// theme_cubit.dart +// Manages light/dark theme toggle for ROHD DevTools. + +import 'package:flutter_bloc/flutter_bloc.dart'; + +/// Enum for theme modes. +enum DevToolsThemeMode { + /// Light theme mode. + light, + + /// Dark theme mode. + dark, +} + +/// Cubit for managing DevTools theme state. +class DevToolsThemeCubit extends Cubit { + /// Constructor for [DevToolsThemeCubit]. + DevToolsThemeCubit() : super(DevToolsThemeMode.dark); + + /// Toggle between light and dark themes. + void toggleTheme() { + emit( + state == DevToolsThemeMode.dark + ? DevToolsThemeMode.light + : DevToolsThemeMode.dark, + ); + } + + /// Set a specific theme mode. + void setTheme(DevToolsThemeMode mode) { + emit(mode); + } + + /// Whether the current theme is dark. + bool get isDark => state == DevToolsThemeMode.dark; +} diff --git a/rohd_devtools_extension/lib/rohd_devtools/cubit/tree_search_term_cubit.dart b/rohd_devtools_extension/lib/rohd_devtools/cubit/tree_search_term_cubit.dart index 0ce2c1933..005a5c425 100644 --- a/rohd_devtools_extension/lib/rohd_devtools/cubit/tree_search_term_cubit.dart +++ b/rohd_devtools_extension/lib/rohd_devtools/cubit/tree_search_term_cubit.dart @@ -9,9 +9,12 @@ import 'package:flutter_bloc/flutter_bloc.dart'; +/// Cubit that stores the current tree search term. class TreeSearchTermCubit extends Cubit { + /// Creates the tree-search cubit with no initial term. TreeSearchTermCubit() : super(null); + /// Updates the search term. void setTerm(String term) { emit(term); } diff --git a/rohd_devtools_extension/lib/rohd_devtools/models/dtd_vm_service_info.dart b/rohd_devtools_extension/lib/rohd_devtools/models/dtd_vm_service_info.dart new file mode 100644 index 000000000..2fbbdf241 --- /dev/null +++ b/rohd_devtools_extension/lib/rohd_devtools/models/dtd_vm_service_info.dart @@ -0,0 +1,74 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// dtd_vm_service_info.dart +// Thin wrapper around the SDK's VmServiceInfo that adds UI state +// (autoReconnect, isAlive) for use in the connection form and +// auto-reconnect logic. +// +// 2026 June +// Author: Desmond Kirkpatrick + +import 'package:dtd/dtd.dart'; + +/// Information about a discovered VM service, wrapping the SDK's +/// VmServiceInfo with additional UI-specific mutable state. +/// +/// Used by the VM connection form to display the list of available VMs +/// and by the auto-reconnect logic to match VMs by name. +class DtdVmServiceInfo { + /// The underlying SDK VM service info. + final VmServiceInfo info; + + /// Whether this VM service is currently reachable. + /// + /// Set to `false` by auto-rediscovery when the service is no longer + /// found via the DTD. Dead services are shown grayed-out in the list. + bool isAlive; + + /// Whether to automatically reconnect to this VM by name if it dies + /// and a new VM with the same name appears via DTD discovery. + bool autoReconnect; + + /// Creates a [DtdVmServiceInfo] wrapping the given [info]. + DtdVmServiceInfo({ + required this.info, + this.isAlive = true, + this.autoReconnect = false, + }); + + /// Creates a [DtdVmServiceInfo] from individual fields (convenience). + factory DtdVmServiceInfo.fromFields({ + required String uri, + String? name, + String? exposedUri, + bool isAlive = true, + bool autoReconnect = false, + }) => + DtdVmServiceInfo( + info: VmServiceInfo(uri: uri, exposedUri: exposedUri, name: name), + isAlive: isAlive, + autoReconnect: autoReconnect, + ); + + /// Human-readable name (may be null). + String? get name => info.name; + + /// Direct VM service URI. + String get uri => info.uri; + + /// Exposed/forwarded URI (preferred over [uri] when available). + String? get exposedUri => info.exposedUri; + + /// The URI to use for connection (prefers exposedUri). + String get connectionUri => exposedUri ?? uri; + + /// A compact display label. + String get displayLabel { + final label = name ?? 'VM Service'; + final uriLabel = connectionUri.length > 50 + ? '${connectionUri.substring(0, 50)}…' + : connectionUri; + return '$label β€” $uriLabel'; + } +} diff --git a/rohd_devtools_extension/lib/rohd_devtools/models/signal_model.dart b/rohd_devtools_extension/lib/rohd_devtools/models/signal_model.dart index 3cfa0023f..7b71a342f 100644 --- a/rohd_devtools_extension/lib/rohd_devtools/models/signal_model.dart +++ b/rohd_devtools_extension/lib/rohd_devtools/models/signal_model.dart @@ -2,17 +2,26 @@ // SPDX-License-Identifier: BSD-3-Clause // // signal_model.dart -// Model of the signal to be tabulate on the detail table. +// Model of the signal shown in the details table. // // 2024 January 5 // Author: Yao Jing Quek +/// Model of a signal shown in the details table. class SignalModel { + /// Signal name. final String name; + + /// Signal direction label. final String direction; + + /// Signal value rendered as text. final String value; + + /// Signal bit width. final int width; + /// Creates a signal model. SignalModel({ required this.name, required this.direction, @@ -20,21 +29,19 @@ class SignalModel { required this.width, }); - factory SignalModel.fromMap(Map map) { - return SignalModel( - name: map['name'] as String, - direction: map['direction'] as String, - value: map['value'] as String, - width: map['width'] as int, - ); - } + /// Builds a signal model from a map representation. + factory SignalModel.fromMap(Map map) => SignalModel( + name: map['name'] as String, + direction: map['direction'] as String, + value: map['value'] as String, + width: map['width'] as int, + ); - Map toMap() { - return { - 'name': name, - 'direction': direction, - 'value': value, - 'width': width, - }; - } + /// Converts the signal model to a JSON-compatible map. + Map toMap() => { + 'name': name, + 'direction': direction, + 'value': value, + 'width': width, + }; } diff --git a/rohd_devtools_extension/lib/rohd_devtools/models/tree_model.dart b/rohd_devtools_extension/lib/rohd_devtools/models/tree_model.dart index f6f60553b..39c1aa6d2 100644 --- a/rohd_devtools_extension/lib/rohd_devtools/models/tree_model.dart +++ b/rohd_devtools_extension/lib/rohd_devtools/models/tree_model.dart @@ -9,12 +9,21 @@ import 'package:rohd_devtools_extension/rohd_devtools/models/signal_model.dart'; +/// Hierarchical model of a ROHD module tree. class TreeModel { + /// Module name. final String name; + + /// Input signals for the module. final List inputs; + + /// Output signals for the module. final List outputs; + + /// Child submodules contained by this module. final List subModules; + /// Creates a tree model for a module hierarchy node. TreeModel({ required this.name, required this.inputs, @@ -22,37 +31,43 @@ class TreeModel { required this.subModules, }); + /// Builds a tree model from a JSON map. factory TreeModel.fromJson(Map json) { - List inputSignalsList = []; - List outputSignalsList = []; + final inputSignalsList = []; + final outputSignalsList = []; + final inputsJson = json['inputs'] as Map; + final outputsJson = json['outputs'] as Map; - for (var inputSignal in json['inputs'].entries) { - SignalModel signal = SignalModel.fromMap({ + for (final inputSignal in inputsJson.entries) { + final inputValue = inputSignal.value as Map; + final signal = SignalModel.fromMap({ 'name': inputSignal.key, 'direction': 'Input', - 'value': inputSignal.value['value'], - 'width': inputSignal.value['width'], + 'value': inputValue['value'], + 'width': inputValue['width'], }); inputSignalsList.add(signal); } - for (var outputSignal in json['outputs'].entries) { - SignalModel signal = SignalModel.fromMap({ + for (final outputSignal in outputsJson.entries) { + final outputValue = outputSignal.value as Map; + final signal = SignalModel.fromMap({ 'name': outputSignal.key, - 'direction': 'Input', - 'value': outputSignal.value['value'], - 'width': outputSignal.value['width'], + 'direction': 'Output', + 'value': outputValue['value'], + 'width': outputValue['width'], }); outputSignalsList.add(signal); } return TreeModel( - name: json['name'], + name: json['name'] as String, inputs: inputSignalsList, outputs: outputSignalsList, - subModules: (json["subModules"] as List) - .map((subModule) => TreeModel.fromJson(subModule)) + subModules: (json['subModules'] as List) + .map((subModule) => + TreeModel.fromJson(subModule as Map)) .toList(), ); } diff --git a/rohd_devtools_extension/lib/rohd_devtools/rohd_devtools.dart b/rohd_devtools_extension/lib/rohd_devtools/rohd_devtools.dart index 3bc48ff96..0487cfbb5 100644 --- a/rohd_devtools_extension/lib/rohd_devtools/rohd_devtools.dart +++ b/rohd_devtools_extension/lib/rohd_devtools/rohd_devtools.dart @@ -6,8 +6,10 @@ // 2025 January 28 // Author: Roberto Torres +export 'cubit/details_tab_cubit.dart'; export 'cubit/rohd_service_cubit.dart'; export 'cubit/selected_module_cubit.dart'; export 'cubit/signal_search_term_cubit.dart'; +export 'cubit/theme_cubit.dart'; export 'cubit/tree_search_term_cubit.dart'; export 'view/view.dart'; diff --git a/rohd_devtools_extension/lib/rohd_devtools/services/connection_state_machine.dart b/rohd_devtools_extension/lib/rohd_devtools/services/connection_state_machine.dart new file mode 100644 index 000000000..5f5f2789d --- /dev/null +++ b/rohd_devtools_extension/lib/rohd_devtools/services/connection_state_machine.dart @@ -0,0 +1,618 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// connection_state_machine.dart +// State machine for managing the lifecycle of VM/DTD connections and +// the associated data (hierarchy, schematic, waveforms). +// +// 2026 March +// Author: Desmond Kirkpatrick + +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:vm_service/vm_service.dart'; + +// --------------------------------------------------------------------------- +// Connection phases +// --------------------------------------------------------------------------- + +/// The coarse-grained connection phase. +/// +/// ```text +/// disconnected ──connect──▢ connecting ──success──▢ connected +/// β–² β”‚ +/// β”‚ vm dies / user +/// β”‚ disconnects +/// β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +/// +/// connected ──pause──▢ paused ──resume──▢ connected +/// β”‚ +/// connected ──vm dies──▢ vmDead ──reconnect──▢ connected +/// ``` +enum ConnectionPhase { + /// No VM connection β€” user is on the connection page. + disconnected, + + /// WebSocket handshake + isolate discovery in progress. + connecting, + + /// VM connection is live. Sub-state tracked by [DataLoadState]. + connected, + + /// User deliberately paused the connection (data preserved in UI). + paused, + + /// VM was detected as dead (polling or DTD event). + vmDead, +} + +// --------------------------------------------------------------------------- +// Data load states +// --------------------------------------------------------------------------- + +/// What data has been successfully loaded from the VM. +/// +/// These flags are orthogonal β€” hierarchy and waveforms load independently. +/// The state machine uses them to decide what still needs loading when a +/// debug pause event arrives. +class DataLoadState { + /// Whether the module tree hierarchy has been loaded. + bool hierarchyLoaded; + + /// Whether schematic JSON has been loaded. + bool schematicLoaded; + + /// Whether initial waveform data has been fetched. + bool waveformDataLoaded; + + /// Whether we've attempted hierarchy loading and it returned null + /// (the ROHD app may not have finished building ModuleTree yet). + bool hierarchyAttempted; + + /// Creates a data-load snapshot. + DataLoadState({ + this.hierarchyLoaded = false, + this.schematicLoaded = false, + this.waveformDataLoaded = false, + this.hierarchyAttempted = false, + }); + + /// True when all essential data is present. + bool get isFullyLoaded => hierarchyLoaded; + + /// True when no data has been loaded yet. + bool get isEmpty => + !hierarchyLoaded && !schematicLoaded && !waveformDataLoaded; + + /// Reset all flags (e.g. on full reconnect to a new VM process). + /// Resets all data-load flags. + void reset() { + hierarchyLoaded = false; + schematicLoaded = false; + waveformDataLoaded = false; + hierarchyAttempted = false; + } + + /// Copy constructor for snapshotting state. + /// Creates a copy of this data-load snapshot. + DataLoadState copy() => DataLoadState( + hierarchyLoaded: hierarchyLoaded, + schematicLoaded: schematicLoaded, + waveformDataLoaded: waveformDataLoaded, + hierarchyAttempted: hierarchyAttempted, + ); + + @override + + /// Returns a debug string summarizing the current data-load state. + String toString() => 'DataLoadState(' + 'hierarchy=${hierarchyLoaded ? "βœ“" : hierarchyAttempted ? "βœ—" : "–"}, ' + 'schematic=${schematicLoaded ? "βœ“" : "–"}, ' + 'wfData=${waveformDataLoaded ? "βœ“" : "–"})'; +} + +// --------------------------------------------------------------------------- +// Connection identity +// --------------------------------------------------------------------------- + +/// Identity of a VM connection for detecting same-process reconnects. +class VmIdentity { + /// The VM service URI. + final String uri; + + /// The isolate ID (unique per VM process). + final String isolateId; + + /// Human-readable VM name (from DTD discovery). + final String? vmName; + + /// Creates a VM identity. + const VmIdentity({required this.uri, required this.isolateId, this.vmName}); + + /// Whether [other] represents the same running VM process. + /// + /// The Dart VM assigns a new isolate ID for every process, so matching + /// IDs prove the same process is still alive. + /// Returns whether [other] refers to the same VM process. + bool isSameProcess(VmIdentity other) => isolateId == other.isolateId; + + @override + + /// Returns a debug string describing this VM identity. + String toString() => 'VmIdentity(uri=$uri, isolate=$isolateId, ' + 'name=$vmName)'; +} + +// --------------------------------------------------------------------------- +// Events / transitions +// --------------------------------------------------------------------------- + +/// Events that drive the state machine. +/// +/// Each event carries any data needed for the transition. +sealed class ConnectionEvent { + const ConnectionEvent(); +} + +/// User initiated a connection to a VM service URI. +class ConnectRequested extends ConnectionEvent { + /// The URI requested by the user. + final String uri; + + /// Creates a connect-request event. + const ConnectRequested(this.uri); +} + +/// VM connection succeeded. +class ConnectionEstablished extends ConnectionEvent { + /// The connected VM service. + final VmService vmService; + + /// Identity of the connected VM. + final VmIdentity identity; + + /// Creates a connection-established event. + const ConnectionEstablished(this.vmService, this.identity); +} + +/// VM connection or data loading failed. +class ConnectionFailed extends ConnectionEvent { + /// Error message describing the failure. + final String error; + + /// Creates a connection-failed event. + const ConnectionFailed(this.error); +} + +/// User deliberately disconnected. +class DisconnectRequested extends ConnectionEvent { + /// Creates a disconnect-request event. + const DisconnectRequested(); +} + +/// User paused the VM connection (data preserved). +class PauseRequested extends ConnectionEvent { + /// Creates a pause-request event. + const PauseRequested(); +} + +/// User resumed a paused VM connection. +class ResumeRequested extends ConnectionEvent { + /// Creates a resume-request event. + const ResumeRequested(); +} + +/// VM was detected as dead (via polling or DTD event). +class VmDied extends ConnectionEvent { + /// Creates a VM-died event. + const VmDied(); +} + +/// VM came back to life (liveness check recovered). +class VmRecovered extends ConnectionEvent { + /// Creates a VM-recovered event. + const VmRecovered(); +} + +/// A debug pause event was received from the VM (breakpoint, exception, etc). +class DebugPauseReceived extends ConnectionEvent { + /// Kind of debug pause event. + final String kind; + + /// Creates a debug-pause event. + const DebugPauseReceived(this.kind); +} + +/// Hierarchy data was loaded (or attempted and returned null). +class HierarchyLoadResult extends ConnectionEvent { + /// Whether the hierarchy load succeeded. + final bool success; + + /// Creates a hierarchy-load result event. + const HierarchyLoadResult({required this.success}); +} + +/// A DTD event signalled that a new VM registered. +class DtdVmRegistered extends ConnectionEvent { + /// VM service URI reported by DTD. + final String uri; + + /// Optional human-readable name. + final String? name; + + /// Creates a DTD VM registered event. + const DtdVmRegistered(this.uri, {this.name}); +} + +/// A DTD event signalled that a VM was unregistered. +class DtdVmUnregistered extends ConnectionEvent { + /// Creates a DTD VM unregistered event. + const DtdVmUnregistered(); +} + +/// User entered demo/loopback mode. +class DemoModeEntered extends ConnectionEvent { + /// Creates a demo-mode event. + const DemoModeEntered(); +} + +// --------------------------------------------------------------------------- +// State machine +// --------------------------------------------------------------------------- + +/// Callback signature for when the state machine wants the shell to load +/// hierarchy data. +typedef LoadHierarchyCallback = Future Function(); + +/// Callback signature for notifying the shell of state changes. +typedef StateChangeCallback = void Function( + ConnectionPhase phase, DataLoadState dataState); + +/// The connection state machine. +/// +/// Tracks the current [ConnectionPhase], [DataLoadState], and [VmIdentity]. +/// Emits [StateChangeCallback] whenever the state transitions so the UI +/// can update. +/// +/// ## Key design decisions +/// +/// 1. **No spinning on connect**: when the initial hierarchy load returns +/// null, we record `hierarchyAttempted = true` but do NOT retry in a +/// loop. Instead, when a [DebugPauseReceived] event arrives and +/// hierarchy is not yet loaded, we try again (exactly once per pause). +/// +/// 2. **Reconnect identity matching**: on reconnect, if the [VmIdentity] +/// has the same `isolateId` as before, we skip hierarchy/schematic +/// reload (the data is still valid). Only waveform data gets an +/// incremental pull. +/// +/// 3. **DTD events are authoritative**: when DTD says a VM died, we trust +/// it immediately (no additional liveness check). +class ConnectionStateMachine { + ConnectionPhase _phase = ConnectionPhase.disconnected; + final DataLoadState _dataState = DataLoadState(); + VmIdentity? _currentIdentity; + VmIdentity? _lastIdentity; + + /// Subscription to VM debug events for hierarchy-on-pause. + StreamSubscription? _debugEventSubscription; + + /// Debounce timer for debug pause events. + Timer? _pauseDebounceTimer; + static const _pauseDebounceDuration = Duration(milliseconds: 200); + + /// Whether a hierarchy load is currently in progress (prevents + /// concurrent loads from rapid breakpoints). + bool _hierarchyLoadInProgress = false; + + /// Callback invoked when the state machine needs hierarchy data loaded. + LoadHierarchyCallback? onLoadHierarchy; + + /// Callback invoked on every state transition. + StateChangeCallback? onStateChange; + + // ── Public getters ── + + /// Current connection phase. + ConnectionPhase get phase => _phase; + + /// Current data-load snapshot. + DataLoadState get dataState => _dataState; + + /// Identity of the currently connected VM, if any. + VmIdentity? get currentIdentity => _currentIdentity; + + /// Identity from the last successful connection, if any. + VmIdentity? get lastIdentity => _lastIdentity; + + /// Whether we're in a state where data loading makes sense. + bool get canLoadData => + _phase == ConnectionPhase.connected && _currentIdentity != null; + + /// Whether we should attempt hierarchy load on the next debug pause. + bool get shouldLoadHierarchyOnPause => + canLoadData && !_dataState.hierarchyLoaded; + + // ── State transitions ── + + /// Process an event and transition state accordingly. + void handleEvent(ConnectionEvent event) { + final oldPhase = _phase; + final oldDataSnapshot = _dataState.copy(); + + switch (event) { + case ConnectRequested(): + _onConnectRequested(event); + case ConnectionEstablished(): + _onConnectionEstablished(event); + case ConnectionFailed(): + _onConnectionFailed(event); + case DisconnectRequested(): + _onDisconnectRequested(); + case PauseRequested(): + _onPauseRequested(); + case ResumeRequested(): + _onResumeRequested(); + case VmDied(): + _onVmDied(); + case VmRecovered(): + _onVmRecovered(); + case DebugPauseReceived(): + _onDebugPause(event); + case HierarchyLoadResult(): + _onHierarchyLoadResult(event); + case DtdVmRegistered(): + _onDtdVmRegistered(event); + case DtdVmUnregistered(): + _onDtdVmUnregistered(); + case DemoModeEntered(): + _onDemoMode(); + } + + // Notify if anything changed + if (_phase != oldPhase || + _dataState.toString() != oldDataSnapshot.toString()) { + debugPrint('[CSM] ${oldPhase.name} β†’ ${_phase.name} $_dataState'); + onStateChange?.call(_phase, _dataState); + } + } + + // ── Per-event handlers ── + + /// Handles a connect-request event. + void _onConnectRequested(ConnectRequested event) { + _phase = ConnectionPhase.connecting; + } + + /// Handles a successful VM connection. + void _onConnectionEstablished(ConnectionEstablished event) { + final isReconnectSameProcess = + _lastIdentity != null && _lastIdentity!.isSameProcess(event.identity); + + _currentIdentity = event.identity; + _phase = ConnectionPhase.connected; + + if (isReconnectSameProcess) { + // Same VM process β€” keep existing data, don't reload hierarchy. + debugPrint( + '[CSM] Reconnected to same process ' + '(${event.identity.isolateId}) β€” preserving data', + ); + } else { + // New process β€” reset data state so everything gets loaded fresh. + _dataState + ..reset() + ..hierarchyLoaded = false + ..schematicLoaded = false; + _hierarchyLoadInProgress = false; + _pauseDebounceTimer?.cancel(); + debugPrint( + '[CSM] Connected to new process ' + '(${event.identity.isolateId}) β€” data reset', + ); + } + } + + /// Handles a failed connection attempt. + void _onConnectionFailed(ConnectionFailed event) { + debugPrint('[CSM] Connection failed: ${event.error}'); + _phase = ConnectionPhase.disconnected; + } + + /// Handles a user-requested disconnect. + void _onDisconnectRequested() { + unawaited(_cancelDebugSubscription()); + _lastIdentity = _currentIdentity; + _currentIdentity = null; + _dataState.reset(); + _hierarchyLoadInProgress = false; + _phase = ConnectionPhase.disconnected; + } + + /// Handles a user-requested pause. + void _onPauseRequested() { + unawaited(_cancelDebugSubscription()); + _lastIdentity = _currentIdentity; + _currentIdentity = null; + // Data state is preserved β€” the UI keeps showing cached data. + _phase = ConnectionPhase.paused; + } + + /// Handles a user-requested resume. + void _onResumeRequested() { + // Phase transition happens when ConnectionEstablished arrives. + _phase = ConnectionPhase.connecting; + } + + /// Handles a VM death notification. + void _onVmDied() { + unawaited(_cancelDebugSubscription()); + _lastIdentity = _currentIdentity; + _hierarchyLoadInProgress = false; + // Data state preserved β€” UI stays. + _phase = ConnectionPhase.vmDead; + } + + /// Handles a VM recovery notification. + void _onVmRecovered() { + if (_phase == ConnectionPhase.vmDead) { + _phase = ConnectionPhase.connected; + } + } + + /// Handles a debug pause event from the VM. + void _onDebugPause(DebugPauseReceived event) { + if (_phase != ConnectionPhase.connected) { + debugPrint('[CSM] Debug pause ignored β€” phase is ${_phase.name}'); + return; + } + + debugPrint( + '[CSM] Debug pause (${event.kind}), data: $_dataState, ' + 'shouldLoad=$shouldLoadHierarchyOnPause, ' + 'inProgress=$_hierarchyLoadInProgress', + ); + + // If hierarchy hasn't been loaded yet, try now. + // This is the key behavior: instead of spinning/polling after connect, + // we wait for the first debug pause event and load then. + if (shouldLoadHierarchyOnPause && !_hierarchyLoadInProgress) { + _hierarchyLoadInProgress = true; + debugPrint('[CSM] Hierarchy not loaded β€” requesting load on pause'); + _scheduleHierarchyLoad(); + } + } + + /// Schedules a debounced hierarchy load. + void _scheduleHierarchyLoad() { + // Debounce: if multiple pause events fire rapidly, only the last + // one triggers a load. + _pauseDebounceTimer?.cancel(); + _pauseDebounceTimer = Timer(_pauseDebounceDuration, _doHierarchyLoad); + } + + /// Performs the actual hierarchy load. + Future _doHierarchyLoad() async { + if (onLoadHierarchy == null) { + _hierarchyLoadInProgress = false; + return; + } + try { + await onLoadHierarchy!(); + } on Exception catch (e) { + debugPrint('[CSM] Hierarchy load failed: $e'); + } finally { + _hierarchyLoadInProgress = false; + } + } + + /// Handles the result of a hierarchy load. + void _onHierarchyLoadResult(HierarchyLoadResult event) { + _dataState.hierarchyAttempted = true; + _dataState.hierarchyLoaded = event.success; + if (event.success) { + debugPrint('[CSM] Hierarchy loaded successfully'); + } else { + debugPrint( + '[CSM] Hierarchy load returned null β€” will retry on next ' + 'debug pause', + ); + } + } + + /// Handles a DTD VM registration event. + void _onDtdVmRegistered(DtdVmRegistered event) { + // Handled by the shell β€” the state machine just records the event + // for logging. + debugPrint( + '[CSM] DTD: VM registered at ${event.uri} ' + '(name=${event.name})', + ); + } + + /// Handles a DTD VM unregistration event. + void _onDtdVmUnregistered() { + debugPrint('[CSM] DTD: VM unregistered'); + _onVmDied(); + } + + /// Switches the state machine into demo mode. + void _onDemoMode() { + _currentIdentity = null; + _lastIdentity = null; + // Mark hierarchy as loaded since demo mode provides it synchronously. + _dataState + ..reset() + ..hierarchyLoaded = true + ..schematicLoaded = true; + _phase = ConnectionPhase.connected; + } + + // ── Debug event subscription management ── + + /// Subscribe to VM debug events on the given [vmService]. + /// + /// When a pause event arrives, the state machine checks if hierarchy + /// data is missing and triggers a load. This replaces the old + /// "retry loop with exponential backoff" approach. + /// Subscribes to VM debug events. + Future subscribeToDebugEvents(VmService vmService) async { + await _cancelDebugSubscription(); + // Note: we deliberately do NOT call vmService.streamListen(Debug) here. + // The Debug stream is subscribed by ServiceManager.vmServiceOpened (the + // owner of the connection's stream lifecycle). Calling streamListen + // here as well would race with ServiceManager and produce an + // unhandled `Stream already subscribed (103)` error, because + // ServiceManager issues its streamListen via `unawaited(...)` inside a + // try/catch that only catches synchronous throws. + _debugEventSubscription = vmService.onDebugEvent.listen((event) { + final kind = event.kind; + if (kind == EventKind.kPauseBreakpoint || + kind == EventKind.kPauseException || + kind == EventKind.kPauseInterrupted || + kind == EventKind.kPauseExit) { + handleEvent(DebugPauseReceived(kind ?? 'unknown')); + } + }); + debugPrint('[CSM] Subscribed to debug events'); + } + + /// Cancels the current debug event subscription. + Future _cancelDebugSubscription() async { + _pauseDebounceTimer?.cancel(); + await _debugEventSubscription?.cancel(); + _debugEventSubscription = null; + } + + // ── Convenience queries ── + + /// Whether a reconnect to [identity] should skip hierarchy reload. + /// + /// Returns true when the last known VM has the same isolate ID, + /// meaning the same process is still running and its data hasn't + /// changed. + /// Returns true when hierarchy reload can be skipped for [identity]. + bool shouldSkipHierarchyReload(VmIdentity identity) => + _lastIdentity != null && + _lastIdentity!.isSameProcess(identity) && + _dataState.hierarchyLoaded; + + /// Marks waveform data as loaded. + void markWaveformDataLoaded() { + _dataState.waveformDataLoaded = true; + onStateChange?.call(_phase, _dataState); + } + + /// Marks schematic data as loaded. + void markSchematicLoaded() { + _dataState.schematicLoaded = true; + onStateChange?.call(_phase, _dataState); + } + + /// Disposes timers and subscriptions used by the state machine. + Future dispose() async { + await _cancelDebugSubscription(); + _pauseDebounceTimer?.cancel(); + } +} diff --git a/rohd_devtools_extension/lib/rohd_devtools/services/io_vm_connection_strategy.dart b/rohd_devtools_extension/lib/rohd_devtools/services/io_vm_connection_strategy.dart new file mode 100644 index 000000000..c8f2c2d27 --- /dev/null +++ b/rohd_devtools_extension/lib/rohd_devtools/services/io_vm_connection_strategy.dart @@ -0,0 +1,145 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// io_vm_connection_strategy.dart +// VM connection strategy for native (Linux/macOS/Windows) platforms. +// Uses vm_service_io for WebSocket connection. +// +// 2026 January +// Author: Desmond Kirkpatrick + +import 'dart:async'; + +import 'package:logging/logging.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/ui/ui.dart'; +import 'package:vm_service/vm_service.dart'; +import 'package:vm_service/vm_service_io.dart'; + +/// Simple logging class for VM service. +class _StdoutLog extends Log { + final Logger _logger = Logger('VMService'); + @override + void warning(String message) => _logger.warning(message); + + @override + void severe(String message) => _logger.severe(message); +} + +/// VM connection strategy for native platforms (Linux/macOS/Windows). +/// Uses vm_service_io's vmServiceConnectUri. +class IoVmConnectionStrategy extends VmConnectionStrategy { + @override + + /// Connects to a VM service on native platforms. + Future connect(String uri) async { + final normalizedUri = normalizeUri(uri); + + if (normalizedUri == null) { + throw Exception('Invalid URI format'); + } + + final vmService = await vmServiceConnectUri( + normalizedUri.toString(), + log: _StdoutLog(), + ).timeout( + const Duration(seconds: 10), + onTimeout: () => + throw TimeoutException('VM connection timed out after 10 s'), + ); + + final vm = await vmService.getVM().timeout( + const Duration(seconds: 5), + onTimeout: () => throw TimeoutException('getVM timed out after 5 s'), + ); + + // During a debugger restart the VM service endpoint becomes available + // before isolates are created, and the test isolate (which contains + // the ROHD inspector_service library) may lag behind the test-runner + // control isolate. Retry a few times with a short delay so we don't + // fall back to the slow polling reconnect path. + String? isolateId; + const maxRetries = 6; + const retryDelay = Duration(milliseconds: 500); + + for (var attempt = 1; attempt <= maxRetries; attempt++) { + final vmInfo = attempt == 1 + ? vm + : await vmService.getVM().timeout(const Duration(seconds: 3)); + final isolates = vmInfo.isolates ?? []; + + if (isolates.isEmpty) { + if (attempt < maxRetries) { + Logger('VMService').info( + 'No isolates yet (attempt $attempt/$maxRetries) β€” ' + 'waiting ${retryDelay.inMilliseconds} ms', + ); + await Future.delayed(retryDelay); + continue; + } + throw Exception( + 'No isolates found in the VM after $maxRetries ' + 'attempts (${retryDelay.inMilliseconds * maxRetries} ms)', + ); + } + + // Find the isolate that contains the ROHD inspector_service library. + for (final isolateRef in isolates) { + final id = isolateRef.id; + if (id == null) { + continue; + } + try { + final isolate = await vmService + .getIsolate(id) + .timeout(const Duration(milliseconds: 500)); + final libraries = isolate.libraries ?? []; + final hasRohd = libraries.any( + (lib) => + lib.uri != null && + lib.uri!.contains('rohd') && + lib.uri!.contains('inspector_service'), + ); + if (hasRohd) { + isolateId = id; + break; + } + } on Exception { + // Isolate not loaded yet or timed out β€” skip it + continue; + } + } + + if (isolateId != null) { + break; + } + + // Found isolates but none had ROHD β€” the test isolate may not + // have spawned yet. Retry unless this is the last attempt. + if (attempt < maxRetries) { + Logger('VMService').info( + 'ROHD isolate not found yet (attempt $attempt/$maxRetries, ' + '${isolates.length} isolate(s) seen) β€” retrying', + ); + await Future.delayed(retryDelay); + continue; + } + + // Last attempt β€” fall back to first isolate. + final fallback = isolates.first.id; + if (fallback == null) { + throw Exception('First isolate has no ID'); + } + isolateId = fallback; + Logger('VMService').info( + 'Isolate library scan incomplete after $maxRetries attempts β€” ' + 'using first isolate; evalModuleTree will verify', + ); + } + + return VmConnectionResult(vmService: vmService, isolateId: isolateId!); + } +} + +/// Returns an [IoVmConnectionStrategy]. Used by the conditional-import +/// dispatcher in `platform_vm_connection_strategy.dart`. +VmConnectionStrategy platformVmConnectionStrategy() => IoVmConnectionStrategy(); diff --git a/rohd_devtools_extension/lib/rohd_devtools/services/platform_vm_connection_strategy.dart b/rohd_devtools_extension/lib/rohd_devtools/services/platform_vm_connection_strategy.dart new file mode 100644 index 000000000..59b3ed478 --- /dev/null +++ b/rohd_devtools_extension/lib/rohd_devtools/services/platform_vm_connection_strategy.dart @@ -0,0 +1,21 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// platform_vm_connection_strategy.dart +// Conditional-import dispatcher that returns the correct +// [VmConnectionStrategy] for the current platform (IO vs. web). +// +// 2026 June +// Author: Desmond Kirkpatrick + +import 'package:rohd_devtools_extension/rohd_devtools/services/platform_vm_connection_strategy_stub.dart' + if (dart.library.io) 'package:rohd_devtools_extension/rohd_devtools/services/io_vm_connection_strategy.dart' + if (dart.library.js_interop) 'package:rohd_devtools_extension/rohd_devtools/services/web_vm_connection_strategy.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/ui/ui.dart'; + +/// Returns the platform-appropriate [VmConnectionStrategy]. +/// +/// On native (`dart:io`) platforms returns the IO strategy; +/// on web (`dart:js_interop`) platforms returns the web strategy. +VmConnectionStrategy createPlatformVmConnectionStrategy() => + platformVmConnectionStrategy(); diff --git a/rohd_devtools_extension/lib/rohd_devtools/services/platform_vm_connection_strategy_stub.dart b/rohd_devtools_extension/lib/rohd_devtools/services/platform_vm_connection_strategy_stub.dart new file mode 100644 index 000000000..8e56964d0 --- /dev/null +++ b/rohd_devtools_extension/lib/rohd_devtools/services/platform_vm_connection_strategy_stub.dart @@ -0,0 +1,22 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// platform_vm_connection_strategy_stub.dart +// Stub fallback for [createPlatformVmConnectionStrategy]. +// Real implementations live in [io_vm_connection_strategy.dart] and +// [web_vm_connection_strategy.dart] and are selected via conditional +// imports in [platform_vm_connection_strategy.dart]. +// +// 2026 June +// Author: Desmond Kirkpatrick + +import 'package:rohd_devtools_extension/rohd_devtools/ui/ui.dart'; + +/// Stub that throws when neither `dart:io` nor `dart:js_interop` is available. +/// Returns the platform VM connection strategy, or throws on unsupported +/// targets. +VmConnectionStrategy platformVmConnectionStrategy() { + throw UnsupportedError( + 'No VmConnectionStrategy available for the current platform.', + ); +} diff --git a/rohd_devtools_extension/lib/rohd_devtools/services/service_manager_bridge.dart b/rohd_devtools_extension/lib/rohd_devtools/services/service_manager_bridge.dart new file mode 100644 index 000000000..8da322797 --- /dev/null +++ b/rohd_devtools_extension/lib/rohd_devtools/services/service_manager_bridge.dart @@ -0,0 +1,11 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// service_manager_bridge.dart +// Conditional bridge that exports the platform-specific ServiceManager. +// +// 2026 June +// Author: Desmond Kirkpatrick + +export 'service_manager_bridge_io.dart' + if (dart.library.js_interop) 'service_manager_bridge_web.dart'; diff --git a/rohd_devtools_extension/lib/rohd_devtools/services/service_manager_bridge_io.dart b/rohd_devtools_extension/lib/rohd_devtools/services/service_manager_bridge_io.dart new file mode 100644 index 000000000..192cec76a --- /dev/null +++ b/rohd_devtools_extension/lib/rohd_devtools/services/service_manager_bridge_io.dart @@ -0,0 +1,16 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// service_manager_bridge_io.dart +// Native implementation for exposing a local DevTools ServiceManager. +// +// 2026 June +// Author: Desmond Kirkpatrick + +import 'package:devtools_app_shared/service.dart'; +import 'package:vm_service/vm_service.dart' as vm; + +// Native fallback: keep an app-local ServiceManager instance. +/// Local service manager used on native platforms. +final ServiceManager serviceManager = + ServiceManager(); diff --git a/rohd_devtools_extension/lib/rohd_devtools/services/service_manager_bridge_web.dart b/rohd_devtools_extension/lib/rohd_devtools/services/service_manager_bridge_web.dart new file mode 100644 index 000000000..67a2683d9 --- /dev/null +++ b/rohd_devtools_extension/lib/rohd_devtools/services/service_manager_bridge_web.dart @@ -0,0 +1,11 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// service_manager_bridge_web.dart +// Web implementation that re-exports DevTools extension ServiceManager. +// +// 2026 June +// Author: Desmond Kirkpatrick + +export 'package:devtools_extensions/devtools_extensions.dart' + show serviceManager; diff --git a/rohd_devtools_extension/lib/rohd_devtools/services/services.dart b/rohd_devtools_extension/lib/rohd_devtools/services/services.dart new file mode 100644 index 000000000..3b8a6cd40 --- /dev/null +++ b/rohd_devtools_extension/lib/rohd_devtools/services/services.dart @@ -0,0 +1,14 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// services.dart +// Barrel file for rohd_devtools services. +// +// NOTE: io_vm_connection_strategy.dart and web_vm_connection_strategy.dart +// are excluded because they have platform-specific dependencies. + +export 'connection_state_machine.dart'; +export 'platform_vm_connection_strategy.dart'; +export 'service_manager_bridge.dart'; +export 'signal_service.dart'; +export 'tree_service.dart'; diff --git a/rohd_devtools_extension/lib/rohd_devtools/services/signal_service.dart b/rohd_devtools_extension/lib/rohd_devtools/services/signal_service.dart index ba4c5cc0d..73e92ed78 100644 --- a/rohd_devtools_extension/lib/rohd_devtools/services/signal_service.dart +++ b/rohd_devtools_extension/lib/rohd_devtools/services/signal_service.dart @@ -9,14 +9,16 @@ import 'package:rohd_devtools_extension/rohd_devtools/models/signal_model.dart'; +/// Utility methods for signal filtering and lookup. abstract class SignalService { + /// Filters signals by case-insensitive name match. static List filterSignals( List signals, String searchTerm, ) { - List filteredSignals = []; + final filteredSignals = []; - for (var signal in signals) { + for (final signal in signals) { if (signal.name.toLowerCase().contains(searchTerm.toLowerCase())) { filteredSignals.add(signal); } 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..3c3d6e2b4 100644 --- a/rohd_devtools_extension/lib/rohd_devtools/services/tree_service.dart +++ b/rohd_devtools_extension/lib/rohd_devtools/services/tree_service.dart @@ -10,39 +10,99 @@ 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'; +import 'package:vm_service/vm_service.dart'; +/// Service helpers for evaluating and filtering the ROHD module tree. class TreeService { - final invokeFunc = 'ModuleTree.instance.hierarchyJSON'; + /// Primary expression for hierarchy JSON β€” available in all ROHD versions + /// that ship inspector_service.dart (i.e. main and later). + static const _primaryInvokeFunc = 'ModuleTree.instance.hierarchyJSON'; + + /// Fallback kept for any pre-inspector ROHD target. + static const _legacyInvokeFunc = 'ModuleTree.instance.hierarchyJSON'; + + /// Eval wrapper for accessing ROHD code in the target isolate. final EvalOnDartLibrary rohdControllerEval; + + /// Disposable token used to keep the eval alive. final Disposable evalDisposable; - TreeService(this.rohdControllerEval, this.evalDisposable); + /// Optional VM service for source-line lookups (cross-probe). + final VmService? vmService; + + /// Optional isolate ID used with [vmService]. + final String? isolateId; + + /// Creates a tree service around the given eval wrapper. + TreeService( + this.rohdControllerEval, + this.evalDisposable, { + this.vmService, + this.isolateId, + }); + /// Evaluates the module tree from the ROHD service. Future evalModuleTree() async { - final treeInstance = await rohdControllerEval.evalInstance( - invokeFunc, - isAlive: evalDisposable, - ); + final payload = await _evalTreePayload(); + if (payload == null || payload.isEmpty) { + debugPrint('[TreeService] evalModuleTree failed: empty payload'); + return null; + } - final treeObj = jsonDecode(treeInstance.valueAsString ?? '') as Map; + final decoded = jsonDecode(payload); + if (decoded is! Map) { + debugPrint( + '[TreeService] evalModuleTree failed: unexpected payload type ' + '${decoded.runtimeType}', + ); + return null; + } - if (treeObj['status'] == 'fail') { - print('error'); + final treeObj = decoded; + if (treeObj['status'] == 'fail' || treeObj['status'] == 'unavailable') { + final message = + treeObj['message'] ?? treeObj['reason'] ?? treeObj['error']; + debugPrint('[TreeService] evalModuleTree failed: $message'); return null; - } else { - return TreeModel.fromJson(jsonDecode(treeInstance.valueAsString ?? "")); } + + return TreeModel.fromJson(treeObj); + } + + Future _evalTreePayload() async { + final expressions = [_primaryInvokeFunc, _legacyInvokeFunc]; + + for (final expression in expressions) { + try { + final treeInstance = await rohdControllerEval.evalInstance( + expression, + isAlive: evalDisposable, + ); + return treeInstance.valueAsString; + } on Exception catch (e) { + debugPrint( + '[TreeService] Eval failed for "$expression": $e', + ); + } + } + + return null; } + /// Returns whether the current module or any descendant matches the search. static bool isNodeOrDescendentMatching( - TreeModel module, String? treeSearchTerm) { + TreeModel module, + String? treeSearchTerm, + ) { if (module.name.toLowerCase().contains(treeSearchTerm!.toLowerCase())) { return true; } - for (TreeModel childModule in module.subModules) { + for (final childModule in module.subModules) { if (isNodeOrDescendentMatching(childModule, treeSearchTerm)) { return true; } @@ -50,10 +110,12 @@ class TreeService { return false; } - Future refreshModuleTree() { - return rohdControllerEval - .evalInstance(invokeFunc, isAlive: evalDisposable) - .then((treeInstance) => - TreeModel.fromJson(jsonDecode(treeInstance.valueAsString ?? "{}"))); + /// Refreshes the module tree from the ROHD service. + Future refreshModuleTree() async { + final treeModel = await evalModuleTree(); + if (treeModel == null) { + throw StateError('Failed to refresh module tree.'); + } + return treeModel; } } diff --git a/rohd_devtools_extension/lib/rohd_devtools/services/web_vm_connection_strategy.dart b/rohd_devtools_extension/lib/rohd_devtools/services/web_vm_connection_strategy.dart new file mode 100644 index 000000000..535aff769 --- /dev/null +++ b/rohd_devtools_extension/lib/rohd_devtools/services/web_vm_connection_strategy.dart @@ -0,0 +1,171 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// web_vm_connection_strategy.dart +// VM connection strategy for web platforms. +// Uses web_socket_channel for browser-compatible WebSocket connection. +// +// 2026 January +// Author: Desmond Kirkpatrick + +import 'dart:async'; + +import 'package:logging/logging.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/ui/ui.dart'; +import 'package:vm_service/vm_service.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; + +/// Simple logging class for VM service. +class _WebLog extends Log { + final Logger _logger = Logger('VMService.Web'); + @override + void warning(String message) => _logger.warning(message); + + @override + void severe(String message) => _logger.severe(message); +} + +/// VM connection strategy for web platforms (browser). +/// Uses web_socket_channel instead of dart:io WebSocket. +class WebVmConnectionStrategy extends VmConnectionStrategy { + void _logWebSocketError(Object error) { + Logger('VMService.Web').severe('WebSocket error: $error'); + } + + @override + Future connect(String uri) async { + final normalizedUri = normalizeUri(uri); + + if (normalizedUri == null) { + throw Exception('Invalid URI format'); + } + + final wsUrl = normalizedUri.toString(); + final channel = WebSocketChannel.connect(Uri.parse(wsUrl)); + final socketSink = channel.sink; + + // Wait for the connection to be established. + await channel.ready.timeout( + const Duration(seconds: 10), + onTimeout: () => + throw TimeoutException('WebSocket connection timed out after 10 s'), + ); + + final controller = StreamController(); + final streamClosedCompleter = Completer(); + + channel.stream.listen( + controller.add, + onDone: streamClosedCompleter.complete, + onError: _logWebSocketError, + ); + + final vmService = VmService( + controller.stream, + socketSink.add, + log: _WebLog(), + disposeHandler: () async { + await controller.close(); + await socketSink.close(); + }, + streamClosed: streamClosedCompleter.future, + wsUri: wsUrl, + ); + + final vm = await vmService.getVM().timeout( + const Duration(seconds: 5), + onTimeout: () => throw TimeoutException('getVM timed out after 5 s'), + ); + + // During a debugger restart the VM service endpoint becomes available + // before isolates are created, and the test isolate (which contains + // the ROHD inspector_service library) may lag behind the test-runner + // control isolate. Retry a few times with a short delay so we don't + // fall back to the slow polling reconnect path. + String? isolateId; + const maxRetries = 6; + const retryDelay = Duration(milliseconds: 500); + + for (var attempt = 1; attempt <= maxRetries; attempt++) { + final vmInfo = attempt == 1 + ? vm + : await vmService.getVM().timeout(const Duration(seconds: 3)); + final isolates = vmInfo.isolates ?? []; + + if (isolates.isEmpty) { + if (attempt < maxRetries) { + Logger('VMService.Web').info( + 'No isolates yet (attempt $attempt/$maxRetries) β€” ' + 'waiting ${retryDelay.inMilliseconds} ms', + ); + await Future.delayed(retryDelay); + continue; + } + throw Exception( + 'No isolates found in the VM after $maxRetries ' + 'attempts (${retryDelay.inMilliseconds * maxRetries} ms)', + ); + } + + // Find the isolate that contains the ROHD inspector_service library. + for (final isolateRef in isolates) { + final id = isolateRef.id; + if (id == null) { + continue; + } + try { + final isolate = await vmService + .getIsolate(id) + .timeout(const Duration(milliseconds: 500)); + final libraries = isolate.libraries ?? []; + final hasRohd = libraries.any( + (lib) => + lib.uri != null && + lib.uri!.contains('rohd') && + lib.uri!.contains('inspector_service'), + ); + if (hasRohd) { + isolateId = id; + break; + } + } on Exception { + // Isolate not loaded yet or timed out β€” skip it + continue; + } + } + + if (isolateId != null) { + break; + } + + // Found isolates but none had ROHD β€” the test isolate may not + // have spawned yet. Retry unless this is the last attempt. + if (attempt < maxRetries) { + Logger('VMService.Web').info( + 'ROHD isolate not found yet (attempt $attempt/$maxRetries, ' + '${isolates.length} isolate(s) seen) β€” retrying', + ); + await Future.delayed(retryDelay); + continue; + } + + // Last attempt β€” fall back to first isolate. + final fallback = isolates.first.id; + if (fallback == null) { + throw Exception('First isolate has no ID'); + } + isolateId = fallback; + Logger('VMService.Web').info( + 'Isolate library scan incomplete after $maxRetries attempts β€” ' + 'using first isolate; evalModuleTree will verify', + ); + } + + return VmConnectionResult(vmService: vmService, isolateId: isolateId!); + } +} + +/// Returns a [WebVmConnectionStrategy]. Used by the conditional-import +/// dispatcher in `platform_vm_connection_strategy.dart`. +VmConnectionStrategy platformVmConnectionStrategy() => + WebVmConnectionStrategy(); 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..6fe12d1e7 --- /dev/null +++ b/rohd_devtools_extension/lib/rohd_devtools/ui/details_help_button.dart @@ -0,0 +1,40 @@ +// 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 March +// Author: Desmond Kirkpatrick + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import 'package:rohd_devtools_widgets/rohd_devtools_widgets.dart'; + +/// A help button for the Details tab. +/// +/// Content is driven by `assets/help/details_help.md`. +/// Edit that file to update the hover tooltip and click-open dialog. +class DetailsHelpButton extends StatelessWidget { + /// Whether the current theme is dark mode. + final bool isDark; + + /// Create a [DetailsHelpButton]. + const DetailsHelpButton({required this.isDark, super.key}); + + @override + Widget build(BuildContext context) => MarkdownHelpButton( + assetPath: 'assets/help/details_help.md', + isDark: isDark, + ); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(FlagProperty('isDark', value: 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..7d3cc6c16 100644 --- a/rohd_devtools_extension/lib/rohd_devtools/ui/devtool_appbar.dart +++ b/rohd_devtools_extension/lib/rohd_devtools/ui/devtool_appbar.dart @@ -7,22 +7,48 @@ // 2024 January 5 // Author: Yao Jing Quek +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/cubit/cubits.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/ui/devtools_help_button.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/ui/platform_icon.dart'; +/// App bar used by the ROHD DevTools UI. class DevtoolAppBar extends StatelessWidget implements PreferredSizeWidget { + /// Whether to render color emoji icons where available. const DevtoolAppBar({ super.key, + this.hasColorEmoji = kIsWeb, }); + /// Whether the icon set should prefer color emoji glyphs. + final bool hasColorEmoji; + @override + + /// Builds the app bar with help, license, and theme controls. Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final accentColor = Theme.of(context).colorScheme.primary; + return AppBar( backgroundColor: Theme.of(context).colorScheme.onPrimary, title: const Text('ROHD DevTool (Beta)'), - leading: const Icon(Icons.build), + leading: Padding( + padding: const EdgeInsets.all(8), + child: Image.asset( + 'assets/icons/rohd_logo.png', + fit: BoxFit.contain, + ), + ), actions: [ + // ── Help ── + DevToolsHelpButton(isDark: isDark), + + // ── Licenses ── Padding( - padding: const EdgeInsets.only(right: 20.0), + padding: const EdgeInsets.only(right: 20), child: MouseRegion( cursor: SystemMouseCursors.click, child: GestureDetector( @@ -38,10 +64,38 @@ class DevtoolAppBar extends StatelessWidget implements PreferredSizeWidget { ), ), ), + + BlocBuilder( + builder: (context, themeMode) { + final isDark = themeMode == DevToolsThemeMode.dark; + return IconButton( + tooltip: + isDark ? 'Switch to light theme' : 'Switch to dark theme', + onPressed: () { + context.read().toggleTheme(); + }, + icon: platformIcon( + isDark ? Icons.light_mode : Icons.dark_mode, + isDark ? 'β˜€οΈ' : 'πŸŒ™', + size: 24, + color: accentColor, + hasColorEmoji: hasColorEmoji, + ), + ); + }, + ), ], ); } @override + + /// The preferred height of the app bar. Size get preferredSize => const Size.fromHeight(kToolbarHeight); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(FlagProperty('hasColorEmoji', value: hasColorEmoji)); + } } diff --git a/rohd_devtools_extension/lib/rohd_devtools/ui/devtools_connection_host.dart b/rohd_devtools_extension/lib/rohd_devtools/ui/devtools_connection_host.dart new file mode 100644 index 000000000..8d0c579b7 --- /dev/null +++ b/rohd_devtools_extension/lib/rohd_devtools/ui/devtools_connection_host.dart @@ -0,0 +1,1306 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// devtools_connection_host.dart +// Abstract base class for DevTools app shells that manage VM/DTD connection +// lifecycle. Subclasses provide app-specific data loading and UI. +// +// 2026 June +// Author: Desmond Kirkpatrick + +import 'dart:async'; + +import 'package:dtd/dtd.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/models/dtd_vm_service_info.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/services/services.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/ui/ui.dart'; +import 'package:vm_service/vm_service.dart' hide Stack; + +// --------------------------------------------------------------------------- +// VM Connection Strategy +// --------------------------------------------------------------------------- + +/// Abstract base for VM connection strategies. +/// Linux uses vm_service_io, Web uses package:web WebSocket. +abstract class VmConnectionStrategy { + /// Connect to VM service at the given URI. + /// Returns the VmService and isolateId for the main isolate. + Future connect(String uri); + + /// Normalize URI to websocket format. + Uri? normalizeUri(String value) { + try { + var uri = Uri.parse(value.trim()); + + if (uri.scheme == 'http') { + uri = uri.replace(scheme: 'ws'); + } else if (uri.scheme == 'https') { + uri = uri.replace(scheme: 'wss'); + } + + if (!uri.path.endsWith('/ws')) { + uri = uri.replace(path: '${uri.path}ws'); + } + + return uri; + } on Exception { + return null; + } + } +} + +/// Result of a VM connection attempt. +class VmConnectionResult { + /// The connected VM service. + final VmService vmService; + + /// The isolate ID of the main isolate. + final String isolateId; + + /// Constructor for [VmConnectionResult]. + VmConnectionResult({required this.vmService, required this.isolateId}); +} + +// --------------------------------------------------------------------------- +// DevToolsConnectionHost base class +// --------------------------------------------------------------------------- + +/// Abstract base State that manages VM/DTD connection lifecycle. +/// +/// Subclasses (e.g. the ROHD DevTools page) extend this to get: +/// - VM connect / disconnect / pause / resume / lightweight reconnect +/// - Persistent DTD connection with VmServiceRegistered/Unregistered events +/// - DTD Service stream for extension availability (e.g. 'rohd' service) +/// - VM liveness polling with auto-reconnect by name +/// - ConnectionStateMachine integration +/// - Connection dialog management +/// +/// The subclass implements abstract hooks to react to these lifecycle events +/// and perform app-specific work (loading hierarchy, waveforms, etc.). +abstract class DevToolsConnectionHostState + extends State { + // ══════════════════════════════════════════════════════════════════════════ + // Configuration β€” override in subclass + // ══════════════════════════════════════════════════════════════════════════ + + /// The connection strategy (platform-specific VM service connection). + /// Return null if VM connection is not supported on this platform. + VmConnectionStrategy? get connectionStrategy; + + // ══════════════════════════════════════════════════════════════════════════ + // Connection state + // ══════════════════════════════════════════════════════════════════════════ + + /// Whether connected to a VM (true after successful handshake). + bool get isConnected => _isConnected; + + /// Sets whether the host is connected to a VM. + @protected + set isConnected(bool value) => _isConnected = value; + bool _isConnected = false; + + /// True while a VM connection handshake is in progress. + bool get isConnecting => _isConnecting; + bool _isConnecting = false; + + /// True when the VM service has been detected as dead. + bool get isVmDead => _isVmDead; + + /// Sets whether the host believes the VM is dead. + @protected + set isVmDead(bool value) => _isVmDead = value; + bool _isVmDead = false; + + /// True when the user deliberately paused the VM connection. + bool get isPaused => _isPaused; + bool _isPaused = false; + + /// The active VM service instance (null when disconnected). + VmService? get vmService => _vmService; + VmService? _vmService; + + /// URI of the last/current VM service connection. + String? get lastVmServiceUri => _lastVmServiceUri; + String? _lastVmServiceUri; + + /// Isolate ID from the last successful connection. + String? get lastIsolateId => _lastIsolateId; + + /// Sets the last known isolate ID. + @protected + set lastIsolateId(String? value) => _lastIsolateId = value; + String? _lastIsolateId; + + /// Name of the connected VM (from DTD discovery). + String? get connectedVmName => _connectedVmName; + String? _connectedVmName; + + /// Whether auto-reconnect by name is enabled. + bool get autoReconnect => _autoReconnect; + bool _autoReconnect = false; + + /// Whether a VM service is currently connected (shorthand). + bool get isVmConnected => _vmService != null; + + /// Monotonically increasing counter bumped on every full reconnect. + /// Used for widget keys so Flutter recreates stateful widgets. + int get connectionGeneration => _connectionGeneration; + int _connectionGeneration = 0; + + /// The connection state machine. + ConnectionStateMachine get connectionStateMachine => _csm; + final ConnectionStateMachine _csm = ConnectionStateMachine(); + + /// The persistent DTD connection (for VM lifecycle events + RPC). + DartToolingDaemon? get persistentDtd => _persistentDtd; + DartToolingDaemon? _persistentDtd; + + /// Remembered VM services across reconnects. + List? get rememberedServices => _rememberedServices; + + /// Sets the remembered VM services list. + @protected + set rememberedServices(List? value) => + _rememberedServices = value; + List? _rememberedServices; + + /// Services currently registered on DTD (populated by Service stream). + final Set _availableServices = {}; + + // ── Private connection state ── + + bool _autoReconnectInProgress = false; + int _vmLivenessFailCount = 0; + static const _vmDeadThreshold = 3; + Timer? _vmLivenessTimer; + StreamSubscription? _dtdEventSubscription; + StreamSubscription? _serviceStreamSubscription; + + // ── URI controllers (for connection dialog) ── + + /// Controller for the VM service URI field. + final TextEditingController vmServiceUriController = TextEditingController( + text: 'ws://127.0.0.1:8181/xxxx=/ws', + ); + + /// Controller for the DTD URI field. + final TextEditingController dtdUriController = TextEditingController(); + + /// Most recent connection error shown in the UI. + String? connectionError; + + // ══════════════════════════════════════════════════════════════════════════ + // Abstract hooks β€” subclass must implement + // ══════════════════════════════════════════════════════════════════════════ + + /// Called after a successful VM connection. + /// + /// The subclass should create its data sources (tree, waveform, etc.) + /// using the provided [result] and [uri]. The VM service, isolate ID, + /// CSM, liveness timer, and DTD listener are already set up. + Future onVmConnected(VmConnectionResult result, String uri); + + /// Tear down all state from a previous VM connection. + /// + /// Called during disconnect and before reconnect. The subclass should + /// dispose data sources, clear caches, reset cubits, etc. + /// Must be resilient (each step individually guarded). + Future tearDownOldConnection(); + + /// Called when a full disconnect completes (before showing dialog). + /// + /// The subclass should clear any UI state and references that are + /// specific to the old connection. + void onVmDisconnected(); + + /// Called when the VM is detected as dead. + void onVmDead() {} + + /// Called when a dead VM recovers (liveness check succeeds). + void onVmRecovered() {} + + /// Verify whether a lightweight reconnect is valid. + /// + /// Called with the new [result] after connecting to the same URI. + /// Return true if the isolate matches (same process) and a lightweight + /// swap is appropriate; return false to trigger a full reconnect. + bool onLightweightReconnectCheck(VmConnectionResult result) => + result.isolateId == _lastIsolateId; + + /// Called after a successful lightweight reconnect. + /// + /// The subclass should swap the VM service in existing transports + /// without tearing down tree/schematic/waveform state. + Future onLightweightReconnectSuccess( + VmConnectionResult result, String uri); + + /// Called when a DTD service becomes available. + /// + /// For example, when the 'rohd' extension service registers on DTD, + /// the subclass can enable source navigation. + void onServiceAvailable(String serviceName) {} + + /// Called when a DTD service becomes unavailable. + void onServiceUnavailable(String serviceName) {} + + @override + + /// Adds the host's public connection state to the diagnostics tree. + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(FlagProperty('isConnected', value: isConnected)) + ..add(FlagProperty('isConnecting', value: isConnecting)) + ..add(FlagProperty('isVmDead', value: isVmDead)) + ..add(FlagProperty('isPaused', value: isPaused)) + ..add(DiagnosticsProperty('vmService', vmService)) + ..add(StringProperty('lastVmServiceUri', lastVmServiceUri)) + ..add(StringProperty('lastIsolateId', lastIsolateId)) + ..add(StringProperty('connectedVmName', connectedVmName)) + ..add(FlagProperty('autoReconnect', value: autoReconnect)) + ..add(FlagProperty('isVmConnected', value: isVmConnected)) + ..add(IntProperty('connectionGeneration', connectionGeneration)) + ..add( + DiagnosticsProperty( + 'connectionStrategy', + connectionStrategy, + ), + ) + ..add( + DiagnosticsProperty( + 'connectionStateMachine', + connectionStateMachine, + ), + ) + ..add(DiagnosticsProperty( + 'persistentDtd', persistentDtd)) + ..add( + DiagnosticsProperty?>( + 'rememberedServices', + rememberedServices, + ), + ) + ..add( + DiagnosticsProperty( + 'vmServiceUriController', + vmServiceUriController, + ), + ) + ..add( + DiagnosticsProperty( + 'dtdUriController', + dtdUriController, + ), + ) + ..add(StringProperty('connectionError', connectionError)); + } + + // ══════════════════════════════════════════════════════════════════════════ + // Lifecycle + // ══════════════════════════════════════════════════════════════════════════ + + @override + @mustCallSuper + void initState() { + super.initState(); + _csm.onLoadHierarchy = onCsmLoadHierarchy; + _csm.onStateChange = _onCsmStateChange; + } + + @override + @mustCallSuper + void dispose() { + _vmLivenessTimer?.cancel(); + _stopDtdListener(); + unawaited(_csm.dispose()); + vmServiceUriController.dispose(); + dtdUriController.dispose(); + unawaited(_vmService?.dispose()); + super.dispose(); + } + + /// Override in subclass if the CSM's loadHierarchy callback should + /// trigger app-specific loading. Default is a no-op. + Future onCsmLoadHierarchy() async {} + + /// Called by the CSM on state changes. Override for additional behavior. + @protected + void _onCsmStateChange(ConnectionPhase phase, DataLoadState dataState) { + debugPrint('[ConnectionHost] CSM: ${phase.name} $dataState'); + } + + // ══════════════════════════════════════════════════════════════════════════ + // URI Cleaning Utilities + // ══════════════════════════════════════════════════════════════════════════ + + /// Clean a VM service URI by extracting the valid portion. + /// VM URIs start with 'ws:' and end with '=/ws'. + static String cleanVmServiceUri(String input) { + final trimmed = input.trim(); + var startIndex = trimmed.indexOf('ws:'); + if (startIndex < 0) { + startIndex = trimmed.indexOf('wss:'); + } + if (startIndex < 0) { + return trimmed; + } + + const endMarker = '=/ws'; + final endIndex = trimmed.indexOf(endMarker, startIndex); + if (endIndex < 0) { + return trimmed.substring(startIndex); + } + + return trimmed.substring(startIndex, endIndex + endMarker.length); + } + + /// Clean a DTD URI by extracting the valid portion. + /// DTD URIs start with 'ws:' and end with '='. + static String cleanDtdUri(String input) { + final trimmed = input.trim(); + var startIndex = trimmed.indexOf('ws:'); + if (startIndex < 0) { + startIndex = trimmed.indexOf('wss:'); + } + if (startIndex < 0) { + return trimmed; + } + + var searchFrom = startIndex; + while (true) { + final eqIndex = trimmed.indexOf('=', searchFrom); + if (eqIndex < 0) { + return trimmed.substring(startIndex); + } + + if (eqIndex + 3 < trimmed.length && + trimmed.substring(eqIndex, eqIndex + 4) == '=/ws') { + searchFrom = eqIndex + 1; + continue; + } + + return trimmed.substring(startIndex, eqIndex + 1); + } + } + + // ══════════════════════════════════════════════════════════════════════════ + // Connection Actions (public API for subclass and UI) + // ══════════════════════════════════════════════════════════════════════════ + + /// Connect to a VM service at the given URI. + /// + /// Tears down any previous connection, establishes a new one, starts + /// liveness polling and DTD listener, then calls [onVmConnected]. + Future connectToVmService(String vmServiceUri) async { + debugPrint('[ConnectionHost] Starting connection to: $vmServiceUri'); + final strategy = connectionStrategy; + if (strategy == null) { + throw Exception('No connection strategy available'); + } + + _csm.handleEvent(ConnectRequested(vmServiceUri)); + + try { + await tearDownOldConnection(); + } on Exception catch (e) { + debugPrint( + '[ConnectionHost] tearDownOldConnection failed (non-fatal): $e', + ); + _connectionGeneration++; + } + + debugPrint('[ConnectionHost] Calling strategy.connect...'); + final result = await strategy.connect(vmServiceUri); + debugPrint('[ConnectionHost] Connected! isolateId: ${result.isolateId}'); + + // Notify the state machine. + final identity = VmIdentity( + uri: vmServiceUri, + isolateId: result.isolateId, + vmName: _connectedVmName, + ); + _csm.handleEvent(ConnectionEstablished(result.vmService, identity)); + + setState(() { + _vmService = result.vmService; + _isConnected = true; + _isConnecting = false; + _isVmDead = false; + _isPaused = false; + _vmLivenessFailCount = 0; + _lastVmServiceUri = vmServiceUri; + _lastIsolateId = result.isolateId; + }); + + // Let the subclass set up its data sources. This is the path that + // calls ServiceManager.vmServiceOpened, which owns streamListen for + // the Debug/Isolate/etc streams. Subscribe to debug events only + // AFTER this has run so we never race ServiceManager. + await onVmConnected(result, vmServiceUri); + unawaited(_csm.subscribeToDebugEvents(result.vmService)); + + // Start VM liveness polling. + _vmLivenessTimer?.cancel(); + _vmLivenessTimer = Timer.periodic( + const Duration(seconds: 10), + (_) => unawaited(_checkVmLiveness()), + ); + debugPrint('[ConnectionHost] Started VM liveness polling (10 s)'); + + // Start persistent DTD listener. + unawaited(_startDtdListener()); + } + + /// Disconnect from the current VM service. + /// + /// Tears down the connection, resets state, and calls [onVmDisconnected]. + Future disconnect() async { + _csm.handleEvent(const DisconnectRequested()); + _vmLivenessTimer?.cancel(); + _vmLivenessTimer = null; + _stopDtdListener(); + await tearDownOldConnection(); + + setState(() { + _vmService = null; + _isConnected = false; + _isConnecting = false; + _isVmDead = false; + _isPaused = false; + _vmLivenessFailCount = 0; + connectionError = null; + _lastVmServiceUri = null; + _lastIsolateId = null; + _connectedVmName = null; + _autoReconnect = false; + }); + + onVmDisconnected(); + } + + /// Pause waveform data fetches while keeping VM connection alive. + Future pauseVm() async { + if (!isVmConnected) { + return; + } + debugPrint('[ConnectionHost] Pausing (connection stays alive)'); + _csm.handleEvent(const PauseRequested()); + setState(() { + _isPaused = true; + }); + } + + /// Resume after a pause. + Future resumeVm() async { + if (!isVmConnected) { + debugPrint('[ConnectionHost] VM not connected β€” nothing to resume'); + return; + } + debugPrint('[ConnectionHost] Resuming'); + _csm.handleEvent(const ResumeRequested()); + setState(() { + _isPaused = false; + }); + } + + /// Attempt a lightweight reconnect to the same VM process. + /// + /// Returns true if successful (state preserved), false if the caller + /// should fall through to a full reconnect. + Future lightweightReconnect(String uri) async { + debugPrint('[ConnectionHost] Attempting lightweight reconnect to: $uri'); + final strategy = connectionStrategy; + if (strategy == null) { + return false; + } + + try { + final result = await strategy.connect(uri); + + if (!onLightweightReconnectCheck(result)) { + debugPrint( + '[ConnectionHost] Lightweight check failed β€” ' + 'need full reconnect', + ); + unawaited(result.vmService.dispose()); + return false; + } + + debugPrint( + '[ConnectionHost] Same process β€” swapping VM service in-place', + ); + + // Notify the state machine. + final identity = VmIdentity( + uri: uri, + isolateId: result.isolateId, + vmName: _connectedVmName, + ); + _csm.handleEvent(ConnectionEstablished(result.vmService, identity)); + + // Let the subclass swap the transport (this re-runs vmServiceOpened + // on the local ServiceManager, which owns streamListen). Subscribe + // to debug events only AFTER that to avoid racing ServiceManager. + await onLightweightReconnectSuccess(result, uri); + unawaited(_csm.subscribeToDebugEvents(result.vmService)); + + setState(() { + _vmService = result.vmService; + _isConnecting = false; + _isPaused = false; + _isVmDead = false; + _vmLivenessFailCount = 0; + _lastIsolateId = result.isolateId; + }); + + // Restart liveness timer. + _vmLivenessTimer?.cancel(); + _vmLivenessTimer = Timer.periodic( + const Duration(seconds: 10), + (_) => unawaited(_checkVmLiveness()), + ); + + // Restart DTD listener. + unawaited(_startDtdListener()); + + debugPrint('[ConnectionHost] Lightweight reconnect succeeded'); + return true; + } on Exception catch (e) { + debugPrint('[ConnectionHost] Lightweight reconnect failed: $e'); + return false; + } + } + + // ══════════════════════════════════════════════════════════════════════════ + // Connection Dialog + // ══════════════════════════════════════════════════════════════════════════ + + /// Show the VM connection dialog. + /// + /// Subclasses can override [buildConnectionDialogContent] to customize. + Future showConnectionDialog() async { + final strategy = connectionStrategy; + if (strategy == null) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('VM connection not available on this platform'), + ), + ); + } + return; + } + + await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => AlertDialog( + title: const Text('Connect to VM Service'), + content: SizedBox( + width: 400, + child: buildConnectionDialogContent(dialogContext), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('Cancel'), + ), + ], + ), + ); + } + + /// Build the connection dialog content. + /// + /// Override in subclass to add demo-mode buttons, emoji detection, etc. + @protected + Widget buildConnectionDialogContent(BuildContext dialogContext) => + VmConnectionForm( + vmServiceUriController: vmServiceUriController, + dtdUriController: dtdUriController, + connectionError: connectionError, + onConnect: () async { + try { + await attemptConnection(); + if (mounted && dialogContext.mounted && _isConnected) { + Navigator.of(dialogContext).pop(); + } + } on Exception catch (e) { + setState(() { + connectionError = 'Connection failed: $e'; + }); + } + }, + onDemoMode: () { + Navigator.of(dialogContext).pop(); + onDemoModeRequested(); + }, + showDemoButton: true, + cleanVmServiceUri: cleanVmServiceUri, + cleanDtdUri: cleanDtdUri, + discoverVmServices: discoverVmServices, + initialDiscoveredServices: _rememberedServices + ?.map( + (s) => DiscoveredVmService( + name: s.name, + uri: s.uri, + exposedUri: s.exposedUri, + isAlive: s.isAlive, + autoReconnect: s.autoReconnect, + ), + ) + .toList(), + onServicesDiscovered: (services) { + _rememberedServices = services + .map( + (s) => DtdVmServiceInfo.fromFields( + name: s.name, + uri: s.uri, + exposedUri: s.exposedUri, + isAlive: s.isAlive, + autoReconnect: s.autoReconnect, + ), + ) + .toList(); + }, + ); + + /// Called when demo mode is selected from the connection dialog. + /// Override in subclass. + @protected + void onDemoModeRequested() {} + + /// Attempt connection using the current URI controller values. + /// + /// If only DTD URI is provided, discovers VMs and picks the first one. + Future attemptConnection() async { + final strategy = connectionStrategy; + if (strategy == null) { + setState(() { + connectionError = 'VM connection not available on this platform'; + }); + return; + } + + final rawUri = vmServiceUriController.text; + final uri = cleanVmServiceUri(rawUri); + final rawDtdUri = dtdUriController.text; + var dtdUri = ''; + if (rawDtdUri.isNotEmpty) { + dtdUri = cleanDtdUri(rawDtdUri); + if (dtdUri != rawDtdUri) { + dtdUriController.text = dtdUri; + } + } + + final hasVmUri = + uri.isNotEmpty && uri.startsWith('ws') && !uri.contains('xxxx'); + final hasDtdUri = dtdUri.isNotEmpty && dtdUri.startsWith('ws'); + + if (!hasVmUri && !hasDtdUri) { + setState(() { + connectionError = 'Please enter a VM Service URI or DTD URI'; + }); + return; + } + + if (hasVmUri && uri != rawUri) { + vmServiceUriController.text = uri; + } + + try { + setState(() { + connectionError = null; + }); + + String vmServiceUri; + if (hasVmUri) { + vmServiceUri = uri; + } else { + final services = await discoverVmServices(dtdUri); + if (services.isEmpty) { + setState(() { + connectionError = + 'No VM services found via DTD. Is your ROHD app running?'; + }); + return; + } + vmServiceUri = services.first.connectionUri; + vmServiceUriController.text = vmServiceUri; + } + + setState(() { + _isConnecting = true; + }); + + // Capture VM name and auto-reconnect from discovery list. + final matchedService = + _rememberedServices?.cast().firstWhere( + (s) => s!.connectionUri == vmServiceUri, + orElse: () => null, + ); + _connectedVmName = matchedService?.name; + _autoReconnect = matchedService?.autoReconnect ?? false; + + await connectToVmService(vmServiceUri); + } on Exception catch (e) { + debugPrint( + '[ConnectionHost] attemptConnection failed: ' + '${e.runtimeType}: $e', + ); + if (mounted) { + setState(() { + _isConnected = false; + _isConnecting = false; + connectionError = 'Connection failed: $e'; + }); + } + } + } + + // ══════════════════════════════════════════════════════════════════════════ + // DTD Discovery + // ══════════════════════════════════════════════════════════════════════════ + + /// Discover VM services from a DTD URI. + /// + /// Connects to DTD, calls getVmServices(), returns the list. + /// Also probes for registered services (new DTD 4.0 API). + Future> discoverVmServices(String dtdUri) async { + debugPrint('[ConnectionHost] Connecting to DTD at: $dtdUri'); + final dtd = await DartToolingDaemon.connect(Uri.parse(dtdUri)); + + try { + // Probe for available custom services. + try { + final registered = await dtd.getRegisteredServices(); + _availableServices.clear(); + for (final svc in registered.clientServices) { + _availableServices.add(svc.name); + } + debugPrint( + '[ConnectionHost] Registered services: $_availableServices', + ); + } on Exception catch (e) { + debugPrint('[ConnectionHost] getRegisteredServices failed: $e'); + } + + final response = await dtd.getVmServices(); + final services = response.vmServicesInfos; + + debugPrint('[ConnectionHost] Found ${services.length} VM service(s)'); + for (final svc in services) { + debugPrint( + '[ConnectionHost] ${svc.name ?? "(unnamed)"}: ' + 'uri=${svc.uri}, exposedUri=${svc.exposedUri}', + ); + } + + return services + .map( + (svc) => DiscoveredVmService( + name: svc.name, + uri: svc.uri, + exposedUri: svc.exposedUri, + ), + ) + .toList(); + } finally { + await dtd.close(); + } + } + + /// Check whether a named service is currently available on DTD. + bool isServiceAvailable(String serviceName) => + _availableServices.contains(serviceName); + + // ══════════════════════════════════════════════════════════════════════════ + // DTD Persistent Listener + // ══════════════════════════════════════════════════════════════════════════ + + /// Start the persistent DTD connection for VM lifecycle events. + Future _startDtdListener() async { + final raw = dtdUriController.text; + if (raw.isEmpty) { + return; + } + + // Don't restart if already listening. + if (_persistentDtd != null && !_persistentDtd!.isClosed) { + return; + } + + try { + final dtd = await DartToolingDaemon.connect(Uri.parse(raw)); + _persistentDtd = dtd; + + // Notify subclass that DTD is available. + onDtdConnected(dtd); + + // Listen for VM service register/unregister events. + _dtdEventSubscription = dtd.onVmServiceUpdate().listen( + _handleDtdVmEvent, + onError: (Object e) { + debugPrint('[ConnectionHost] DTD event stream error: $e'); + }, + onDone: () { + debugPrint('[ConnectionHost] DTD event stream closed'); + _persistentDtd = null; + _dtdEventSubscription = null; + onDtdDisconnected(); + }, + ); + + await dtd.streamListen(ConnectedAppServiceConstants.serviceName); + debugPrint('[ConnectionHost] Listening for VM lifecycle events'); + + // Subscribe to Service stream for extension availability. + try { + _serviceStreamSubscription = dtd + .onEvent(CoreDtdServiceConstants.servicesStreamId) + .listen(_handleServiceStreamEvent); + await dtd.streamListen(CoreDtdServiceConstants.servicesStreamId); + debugPrint('[ConnectionHost] Listening for Service stream events'); + } on Exception catch (e) { + debugPrint('[ConnectionHost] Service stream subscription failed: $e'); + } + + // Probe registered services on initial connect. + try { + final registered = await dtd.getRegisteredServices(); + _availableServices.clear(); + for (final svc in registered.clientServices) { + _availableServices.add(svc.name); + onServiceAvailable(svc.name); + } + } on Exception catch (e) { + debugPrint( + '[ConnectionHost] getRegisteredServices failed: $e', + ); + } + + // Use dtd.done as a backup death detector. + unawaited(dtd.done.then((_) { + if (_persistentDtd == dtd) { + debugPrint('[ConnectionHost] dtd.done fired β€” DTD connection lost'); + _persistentDtd = null; + unawaited(_dtdEventSubscription?.cancel()); + _dtdEventSubscription = null; + unawaited(_serviceStreamSubscription?.cancel()); + _serviceStreamSubscription = null; + onDtdDisconnected(); + } + })); + } on Exception catch (e) { + debugPrint('[ConnectionHost] Could not start DTD listener: $e'); + } + } + + /// Stop the persistent DTD listener. + void _stopDtdListener() { + unawaited(_dtdEventSubscription?.cancel()); + _dtdEventSubscription = null; + unawaited(_serviceStreamSubscription?.cancel()); + _serviceStreamSubscription = null; + if (_persistentDtd != null && !_persistentDtd!.isClosed) { + unawaited(_persistentDtd!.close()); + } + _persistentDtd = null; + onDtdDisconnected(); + } + + /// Called when the persistent DTD connection is established. + /// Override to wire DTD to source navigation, etc. + void onDtdConnected(DartToolingDaemon dtd) {} + + /// Called when the persistent DTD connection is lost. + @protected + void onDtdDisconnected() {} + + /// Handle Service stream events (extension registered/unregistered). + void _handleServiceStreamEvent(DTDEvent event) { + final kind = event.kind; + // The service name is in event.data under 'service' or 'method'. + final serviceName = event.data['service']?.toString(); + if (serviceName == null || serviceName.isEmpty) { + return; + } + + if (kind == CoreDtdServiceConstants.serviceRegisteredKind) { + if (_availableServices.add(serviceName)) { + debugPrint( + '[ConnectionHost] Service available: $serviceName', + ); + onServiceAvailable(serviceName); + } + } else if (kind == CoreDtdServiceConstants.serviceUnregisteredKind) { + if (_availableServices.remove(serviceName)) { + debugPrint( + '[ConnectionHost] Service unavailable: $serviceName', + ); + onServiceUnavailable(serviceName); + } + } + } + + /// Handle DTD VM lifecycle events. + /// + /// When a VM service is unregistered, marks the connection as dead. + /// When a new VM with our name registers, triggers auto-reconnect. + Future _handleDtdVmEvent(DTDEvent event) async { + debugPrint('[ConnectionHost] DTD event: ${event.kind} β€” ${event.data}'); + + // Ignore events while manually paused (except vmServiceRegistered). + if (_isPaused && + event.kind != ConnectedAppServiceConstants.vmServiceRegistered) { + debugPrint('[ConnectionHost] Ignoring event β€” VM is manually paused'); + return; + } + + // Ignore events while a connection is in progress. + if (_isConnecting) { + debugPrint('[ConnectionHost] Ignoring event β€” connection in progress'); + return; + } + + if (event.kind == ConnectedAppServiceConstants.vmServiceUnregistered) { + final eventUri = event.data[DtdParameters.uri]?.toString(); + final eventExposedUri = event.data[DtdParameters.exposedUri]?.toString(); + final ourUri = _lastVmServiceUri; + + bool matchesOurVm(String? candidate) { + if (candidate == null || candidate.isEmpty) { + return false; + } + if (ourUri == null || ourUri.isEmpty) { + return true; + } + return ourUri.contains(candidate) || candidate.contains(ourUri); + } + + if (ourUri != null && + ourUri.isNotEmpty && + !matchesOurVm(eventUri) && + !matchesOurVm(eventExposedUri)) { + debugPrint( + '[ConnectionHost] Ignoring unregister for different VM: ' + 'uri=$eventUri, exposedUri=$eventExposedUri (ours: $ourUri)', + ); + return; + } + + debugPrint( + '[ConnectionHost] Our VM service was unregistered β€” marking dead', + ); + _csm.handleEvent(const DtdVmUnregistered()); + _vmLivenessTimer?.cancel(); + _vmLivenessTimer = null; + + if (mounted) { + setState(() { + _isVmDead = true; + }); + onVmDead(); + } + } else if (event.kind == ConnectedAppServiceConstants.vmServiceRegistered) { + if (!_autoReconnect || !mounted) { + return; + } + + final eventName = event.data[DtdParameters.name]?.toString(); + final eventUri = event.data[DtdParameters.uri]?.toString(); + final eventExposedUri = event.data[DtdParameters.exposedUri]?.toString(); + + if (eventName == null || + eventName.isEmpty || + eventName != _connectedVmName) { + debugPrint( + '[ConnectionHost] vmServiceRegistered for "$eventName" β€” ' + 'not our target "$_connectedVmName", ignoring', + ); + return; + } + + final newUri = (eventExposedUri != null && eventExposedUri.isNotEmpty) + ? eventExposedUri + : eventUri; + if (newUri == null || newUri.isEmpty) { + debugPrint( + '[ConnectionHost] vmServiceRegistered β€” no URI in event data', + ); + return; + } + + if (_isVmDead || _isPaused) { + _csm.handleEvent(DtdVmRegistered(newUri, name: eventName)); + debugPrint( + '[ConnectionHost] vmServiceRegistered for "$eventName" at ' + '$newUri β€” auto-reconnecting', + ); + unawaited(_reconnectFromDtdEvent(newUri)); + } else if (_isConnected) { + debugPrint( + '[ConnectionHost] vmServiceRegistered for "$eventName" at ' + '$newUri β€” reconnecting (sameUri=${newUri == _lastVmServiceUri})', + ); + setState(() { + _isVmDead = true; + }); + _csm.handleEvent(DtdVmRegistered(newUri, name: eventName)); + unawaited(_reconnectFromDtdEvent(newUri)); + } + } + } + + /// Reconnect driven by a DTD vmServiceRegistered event. + Future _reconnectFromDtdEvent(String newUri) async { + if (_autoReconnectInProgress) { + debugPrint('[ConnectionHost] Already reconnecting β€” skipping'); + return; + } + _autoReconnectInProgress = true; + + final wasPaused = _isPaused; + if (wasPaused) { + debugPrint('[ConnectionHost] Clearing stale pause state'); + } + + try { + final sameUri = newUri == _lastVmServiceUri; + + if (sameUri) { + debugPrint( + '[ConnectionHost] Same URI β€” trying lightweight reconnect', + ); + _stopDtdListener(); + final success = await lightweightReconnect(newUri); + if (success) { + debugPrint('[ConnectionHost] Lightweight reconnect succeeded'); + _autoReconnectInProgress = false; + return; + } + debugPrint( + '[ConnectionHost] Lightweight failed β€” full reconnect', + ); + } + + _vmLivenessTimer?.cancel(); + _vmLivenessTimer = null; + vmServiceUriController.text = newUri; + + final savedName = _connectedVmName; + final savedAutoReconnect = _autoReconnect; + + _stopDtdListener(); + + setState(() { + _isVmDead = false; + _isPaused = false; + _vmLivenessFailCount = 0; + }); + + await connectToVmService(newUri); + if (mounted) { + setState(() {}); + } + + _connectedVmName = savedName; + _autoReconnect = savedAutoReconnect; + } on Exception catch (e) { + debugPrint('[ConnectionHost] DTD reconnect failed: $e'); + if (_autoReconnect && _isVmDead && mounted) { + unawaited(attemptAutoReconnect()); + } + } finally { + _autoReconnectInProgress = false; + } + } + + // ══════════════════════════════════════════════════════════════════════════ + // VM Liveness Polling + // ══════════════════════════════════════════════════════════════════════════ + + /// Check if the VM service is alive (getVersion with timeout). + Future isVmServiceAlive() async { + final vm = _vmService; + if (vm == null) { + return false; + } + try { + await vm.getVersion().timeout(const Duration(seconds: 5)); + return true; + } on Exception { + return false; + } + } + + /// Periodic liveness probe. + Future _checkVmLiveness() async { + if (!isVmConnected || !mounted || _isPaused || _isConnecting) { + return; + } + final alive = await isVmServiceAlive(); + + if (!mounted || _isPaused || _isConnecting || !isVmConnected) { + return; + } + + if (alive) { + _vmLivenessFailCount = 0; + if (_isVmDead && mounted) { + debugPrint('[ConnectionHost] VM recovered β€” clearing dead flag'); + _csm.handleEvent(const VmRecovered()); + setState(() { + _isVmDead = false; + }); + onVmRecovered(); + } + } else { + _vmLivenessFailCount++; + debugPrint( + '[ConnectionHost] VM check failed ' + '($_vmLivenessFailCount/$_vmDeadThreshold)', + ); + if (_vmLivenessFailCount >= _vmDeadThreshold && !_isVmDead && mounted) { + debugPrint('[ConnectionHost] VM is dead'); + _csm.handleEvent(const VmDied()); + setState(() { + _isVmDead = true; + }); + onVmDead(); + if (_autoReconnect) { + unawaited(attemptAutoReconnect()); + } + } + } + } + + // ══════════════════════════════════════════════════════════════════════════ + // Auto-Reconnect + // ══════════════════════════════════════════════════════════════════════════ + + /// Attempt to reconnect to a VM with the same name (exponential backoff). + Future attemptAutoReconnect() async { + if (_autoReconnectInProgress) { + debugPrint('[ConnectionHost] Already reconnecting β€” skipping'); + return; + } + _autoReconnectInProgress = true; + + final targetName = _connectedVmName; + final dtdUri = dtdUriController.text; + if (targetName == null || targetName.isEmpty || dtdUri.isEmpty) { + debugPrint('[ConnectionHost] No VM name or DTD URI β€” skipping'); + _autoReconnectInProgress = false; + return; + } + + debugPrint('[ConnectionHost] Will try to reconnect to "$targetName"'); + + const maxAttempts = 5; + var delay = const Duration(seconds: 2); + + for (var attempt = 1; attempt <= maxAttempts; attempt++) { + await Future.delayed(delay); + if (!mounted || !_isVmDead || !_autoReconnect) { + _autoReconnectInProgress = false; + return; + } + + debugPrint( + '[ConnectionHost] Auto-reconnect attempt ' + '$attempt/$maxAttempts', + ); + try { + final cleaned = cleanDtdUri(dtdUri); + final services = await discoverVmServices(cleaned); + final match = services.cast().firstWhere( + (s) => s!.name == targetName, + orElse: () => null, + ); + + if (match != null) { + final sameUri = match.connectionUri == _lastVmServiceUri; + debugPrint( + '[ConnectionHost] Found "$targetName" at ' + '${match.connectionUri} ' + '(${sameUri ? "same" : "different"} URI)', + ); + + _rememberedServices = services + .map( + (s) => DtdVmServiceInfo.fromFields( + name: s.name, + uri: s.uri, + exposedUri: s.exposedUri, + autoReconnect: s.connectionUri == match.connectionUri, + ), + ) + .toList(); + + if (sameUri) { + _stopDtdListener(); + final success = await lightweightReconnect(match.connectionUri); + if (success) { + debugPrint( + '[ConnectionHost] Auto lightweight reconnect succeeded', + ); + _autoReconnectInProgress = false; + return; + } + } + + // Full reconnect. + _vmLivenessTimer?.cancel(); + _vmLivenessTimer = null; + vmServiceUriController.text = match.connectionUri; + + final savedName = _connectedVmName; + final savedAutoReconnect = _autoReconnect; + + _stopDtdListener(); + setState(() { + _isVmDead = false; + _isPaused = false; + _vmLivenessFailCount = 0; + }); + + await connectToVmService(match.connectionUri); + if (mounted) { + setState(() {}); + } + + _connectedVmName = savedName; + _autoReconnect = savedAutoReconnect; + + if (isVmConnected && !_isVmDead) { + debugPrint( + '[ConnectionHost] Auto-reconnected to "$targetName"', + ); + _autoReconnectInProgress = false; + return; + } + } else { + debugPrint( + '[ConnectionHost] "$targetName" not found β€” will retry', + ); + } + } on Exception catch (e) { + debugPrint('[ConnectionHost] Attempt $attempt failed: $e'); + } + + delay *= 2; + } + + debugPrint('[ConnectionHost] Gave up after $maxAttempts attempts'); + _autoReconnectInProgress = false; + } + + /// Increment the connection generation (triggers widget recreation). + @protected + void bumpConnectionGeneration() { + _connectionGeneration++; + } +} 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..966e64fbc --- /dev/null +++ b/rohd_devtools_extension/lib/rohd_devtools/ui/devtools_help_button.dart @@ -0,0 +1,40 @@ +// 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 March +// Author: Desmond Kirkpatrick + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import 'package:rohd_devtools_widgets/rohd_devtools_widgets.dart'; + +/// A help button for the ROHD DevTools app bar. +/// +/// Content is driven by `assets/help/devtools_help.md`. +/// Edit that file to update the hover tooltip and click-open dialog. +class DevToolsHelpButton extends StatelessWidget { + /// Whether the current theme is dark mode. + final bool isDark; + + /// Create a [DevToolsHelpButton]. + const DevToolsHelpButton({required this.isDark, super.key}); + + @override + Widget build(BuildContext context) => MarkdownHelpButton( + assetPath: 'assets/help/devtools_help.md', + isDark: isDark, + ); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(FlagProperty('isDark', value: 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..450767e4e 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 @@ -7,46 +7,63 @@ // 2024 January 5 // Author: Yao Jing Quek +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_simple_treeview/flutter_simple_treeview.dart'; -import 'package:rohd_devtools_extension/rohd_devtools/cubit/selected_module_cubit.dart'; -import 'package:rohd_devtools_extension/rohd_devtools/cubit/tree_search_term_cubit.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/cubit/cubits.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:rohd_devtools_extension/rohd_devtools/services/services.dart'; +/// Displays the module tree for the currently loaded ROHD model. class ModuleTreeCard extends StatefulWidget { + /// The root module to render as the tree. final TreeModel futureModuleTree; + + /// Creates a module tree card for the provided module tree. const ModuleTreeCard({ - super.key, required this.futureModuleTree, + super.key, }); @override + + /// Creates the mutable state for [ModuleTreeCard]. State createState() => _ModuleTreeCardState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add( + DiagnosticsProperty('futureModuleTree', futureModuleTree), + ); + } } class _ModuleTreeCardState extends State { + /// Creates the module tree card state. _ModuleTreeCardState(); @override - Widget build(BuildContext context) { - return genModuleTree( - moduleTree: widget.futureModuleTree, - ); - } + /// Builds the module tree widget. + Widget build(BuildContext context) => genModuleTree( + moduleTree: widget.futureModuleTree, + ); + + /// Builds a tree node for [module], returning null if it is filtered out. TreeNode? buildNode(TreeModel module) { final treeSearchTerm = context.watch().state; - // If there's a search term, ensure that either this node or a descendant node matches it. + // If there's a search term, ensure that either this node or a + // descendant node matches it. if (treeSearchTerm != null && !TreeService.isNodeOrDescendentMatching(module, treeSearchTerm)) { return null; } // Build children recursively - List childrenNodes = buildChildrenNodes(module); + final childrenNodes = buildChildrenNodes(module); return TreeNode( content: MouseRegion( @@ -62,11 +79,13 @@ class _ModuleTreeCardState extends State { ); } + /// Builds the visible text and icon for a tree node. Widget getNodeContent(TreeModel module) { final selectedModule = context.watch().state; + final colorScheme = Theme.of(context).colorScheme; // Check if the current module is the selected module - bool isSelected = selectedModule is SelectedModuleLoaded && + final isSelected = selectedModule is SelectedModuleLoaded && selectedModule.module == module; return Column( @@ -74,20 +93,22 @@ class _ModuleTreeCardState extends State { children: [ Container( decoration: BoxDecoration( - color: - isSelected ? Colors.blue.withOpacity(0.2) : Colors.transparent, - borderRadius: BorderRadius.circular(4.0), + color: isSelected + ? Colors.blue.withValues(alpha: 0.2) + : Colors.transparent, + borderRadius: BorderRadius.circular(4), ), - padding: const EdgeInsets.symmetric(vertical: 4.0, horizontal: 8.0), + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), child: Row( children: [ - const Icon(Icons.memory), - const SizedBox(width: 2.0), + Icon(Icons.memory, color: colorScheme.onSurface), + const SizedBox(width: 2), Text( module.name, style: TextStyle( fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, - color: isSelected ? Colors.blue : Colors.white, + color: + isSelected ? colorScheme.primary : colorScheme.onSurface, ), ), ], @@ -97,37 +118,34 @@ class _ModuleTreeCardState extends State { ); } + /// Builds child tree nodes for the given module. List buildChildrenNodes( TreeModel treeModule, ) { - List childrenNodes = []; - List subModules = treeModule.subModules; + final childrenNodes = []; + final subModules = treeModule.subModules; if (subModules.isNotEmpty) { - for (var module in subModules) { - TreeNode? node = buildNode(module); + for (final module in subModules) { + final node = buildNode(module); if (node != null) { childrenNodes.add(node); } } } - return childrenNodes - .where((node) => node != null) - .toList() - .cast(); + return childrenNodes; } - TreeNode? buildTreeFromModule(TreeModel node) { - return buildNode(node); - } + /// Returns a tree node wrapper for the provided module. + TreeNode? buildTreeFromModule(TreeModel node) => buildNode(node); + /// Builds the full tree view widget for [moduleTree]. Widget genModuleTree({ required TreeModel moduleTree, }) { - var root = buildNode(moduleTree); + final root = buildNode(moduleTree); if (root != null) { return TreeView(nodes: [root]); - } else { - return const Text('No data'); } + return const Text('No data'); } } 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..3561b0ee0 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 @@ -7,39 +7,167 @@ // 2024 January 5 // Author: Yao Jing Quek +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/cubit/cubits.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/ui/details_help_button.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/ui/platform_icon.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/ui/schematic_icon.dart'; +/// Navigation bar for switching between module detail views. class ModuleTreeDetailsNavbar extends StatelessWidget { + /// Whether color emoji fonts are available on this platform. + final bool hasColorEmoji; + + /// Creates the details navigation bar. const ModuleTreeDetailsNavbar({ super.key, + this.hasColorEmoji = kIsWeb, }); @override + + /// Adds diagnostic properties for the nav bar. + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add( + FlagProperty( + 'hasColorEmoji', + value: hasColorEmoji, + ifFalse: 'using fallback emojis', + ), + ); + } + + @override + + /// Builds the tab row and help button for module details. Widget build(BuildContext context) { - return BottomNavigationBar( - type: BottomNavigationBarType.fixed, - backgroundColor: const Color(0x1B1B1FEE), - selectedItemColor: Colors.white, - unselectedItemColor: Colors.white.withOpacity(.60), - selectedFontSize: 10, - unselectedFontSize: 10, - onTap: (value) { - // Respond to item press. - }, - items: const [ - BottomNavigationBarItem( - label: 'Details', - icon: Icon(Icons.info), + final colorScheme = Theme.of(context).colorScheme; + final isDark = Theme.of(context).brightness == Brightness.dark; + + return DecoratedBox( + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + border: Border( + bottom: BorderSide(color: Theme.of(context).dividerColor), + ), + ), + child: BlocBuilder( + builder: (context, selectedTab) => Row( + children: [ + _TabButton( + label: 'Details', + icon: platformIcon( + Icons.info, + 'ℹ️', + size: 18, + hasColorEmoji: hasColorEmoji, + ), + isSelected: selectedTab == DetailsTab.details, + onTap: () => context.read().selectTab( + DetailsTab.details, + ), + ), + _TabButton( + label: 'Waveform', + icon: platformIcon( + Icons.waves, + '🌊', + size: 18, + hasColorEmoji: hasColorEmoji, + ), + isSelected: selectedTab == DetailsTab.waveform, + onTap: () => context.read().selectTab( + DetailsTab.waveform, + ), + ), + _TabButton( + label: 'Schematic', + icon: const SchematicIcon(size: 18), + isSelected: selectedTab == DetailsTab.schematic, + onTap: () => context.read().selectTab( + DetailsTab.schematic, + ), + ), + const Spacer(), + DetailsHelpButton(isDark: isDark), + ], ), - BottomNavigationBarItem( - label: 'Waveform', - icon: Icon(Icons.cable), + ), + ); + } +} + +class _TabButton extends StatelessWidget { + /// The tab text label. + final String label; + + /// Icon shown next to the label. + final Widget icon; + + /// Whether this tab is currently selected. + final bool isSelected; + + /// Callback invoked when the tab is tapped. + final VoidCallback onTap; + + const _TabButton({ + required this.label, + required this.icon, + required this.isSelected, + required this.onTap, + }); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(StringProperty('label', label)) + ..add(DiagnosticsProperty('icon', icon)) + ..add(FlagProperty('isSelected', value: isSelected)) + ..add(ObjectFlagProperty( + 'onTap', + onTap, + ifNull: 'disabled', + )); + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final selectedColor = colorScheme.primary; + final unselectedColor = colorScheme.onSurface.withValues(alpha: 0.6); + + return InkWell( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: isSelected ? selectedColor : Colors.transparent, + width: 2, + ), + ), ), - BottomNavigationBarItem( - label: 'Schematic', - icon: Icon(Icons.developer_board), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + icon, + const SizedBox(width: 8), + Text( + label, + style: TextStyle( + fontSize: 13, + fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, + color: isSelected ? selectedColor : unselectedColor, + ), + ), + ], ), - ], + ), ); } } diff --git a/rohd_devtools_extension/lib/rohd_devtools/ui/platform_icon.dart b/rohd_devtools_extension/lib/rohd_devtools/ui/platform_icon.dart new file mode 100644 index 000000000..94708c3e8 --- /dev/null +++ b/rohd_devtools_extension/lib/rohd_devtools/ui/platform_icon.dart @@ -0,0 +1,124 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// platform_icon.dart +// Provides platform-aware icon rendering with emoji fallback. +// +// 2026 January +// Author: Desmond Kirkpatrick + +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +/// A widget that renders either a Material Icon or emoji text based on +/// platform emoji font availability. +/// +/// On platforms with color emoji support, uses the provided emoji string. +/// On platforms without (or with `hasColorEmoji: false`), falls back to +/// the Material IconData. +class PlatformIcon extends StatelessWidget { + /// Material IconData to use as fallback on platforms without color emoji + final IconData nativeIcon; + + /// Emoji string to display if color emoji fonts are available + final String emoji; + + /// Size of the icon/emoji (defaults to 16) + final double? size; + + /// Color to apply to the icon/emoji + final Color? color; + + /// Whether color emoji fonts are available on this platform + /// (defaults to true - verify on native platforms) + final bool hasColorEmoji; + + /// Constructor for [PlatformIcon]. + const PlatformIcon( + this.nativeIcon, + this.emoji, { + this.size, + this.color, + this.hasColorEmoji = false, + super.key, + }); + + @override + + /// Adds this widget's diagnostic properties. + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('nativeIcon', nativeIcon)) + ..add(StringProperty('emoji', emoji)) + ..add(DoubleProperty('size', size)) + ..add(ColorProperty('color', color)) + ..add( + FlagProperty( + 'hasColorEmoji', + value: hasColorEmoji, + ifFalse: 'using fallback icons', + ), + ); + } + + @override + + /// Builds either the emoji text or the fallback material icon. + Widget build(BuildContext context) { + if (hasColorEmoji) { + return Text( + emoji, + style: TextStyle(fontSize: size ?? 16, color: color), + ); + } + return Icon(nativeIcon, size: size, color: color); + } +} + +/// Helper function for quick construction of PlatformIcon widgets. +/// +/// Returns a PlatformIcon widget that renders either emoji or Material icon +/// based on platform capabilities. +/// +/// Example: +/// ```dart +/// platformIcon(Icons.waves, 'πŸ”—', size: 24, hasColorEmoji: true) +/// ``` +Widget platformIcon( + IconData nativeIcon, + String emoji, { + double? size, + Color? color, + bool hasColorEmoji = false, +}) => + PlatformIcon( + nativeIcon, + emoji, + size: size, + color: color, + hasColorEmoji: hasColorEmoji, + ); + +/// Check whether a color emoji font (Noto Color Emoji) is installed on the +/// system. On web we conservatively return false so UI falls back to Material +/// icons and avoids runtime missing-glyph warnings. +/// Returns true if the font is detected on the host system. +Future isEmojiFontInstalled() async { + if (kIsWeb) { + return false; + } + + try { + final result = await Process.run('fc-list', []); + if (result.exitCode == 0) { + final out = result.stdout.toString().toLowerCase(); + return out.contains('noto color emoji'); + } + } on Exception { + // fc-list command not available or failed; assume no emoji font + } + return false; +} diff --git a/rohd_devtools_extension/lib/rohd_devtools/ui/schematic_icon.dart b/rohd_devtools_extension/lib/rohd_devtools/ui/schematic_icon.dart new file mode 100644 index 000000000..b8a5c69df --- /dev/null +++ b/rohd_devtools_extension/lib/rohd_devtools/ui/schematic_icon.dart @@ -0,0 +1,125 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// schematic_icon.dart +// Custom icon: three colored blocks connected by orthogonal lines, +// resembling a small schematic / block diagram. +// +// 2026 June +// Author: Desmond Kirkpatrick + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +/// A custom-painted icon showing three colored rectangles connected +/// by orthogonal (right-angle) wires β€” a miniature schematic diagram. +class SchematicIcon extends StatelessWidget { + /// Creates a schematic icon at the given [size]. + const SchematicIcon({super.key, this.size = 20, this.brightness}); + + /// Icon size in logical pixels (width = height). + final double size; + + /// Override brightness to force light/dark wire color. + /// If null, uses the ambient [Theme.of(context).brightness]. + final Brightness? brightness; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DoubleProperty('size', size)) + ..add(EnumProperty('brightness', brightness)); + } + + @override + + /// Builds the custom-painted schematic icon. + Widget build(BuildContext context) { + final effectiveBrightness = brightness ?? Theme.of(context).brightness; + return CustomPaint( + size: Size.square(size), + painter: _SchematicIconPainter(effectiveBrightness), + ); + } +} + +class _SchematicIconPainter extends CustomPainter { + _SchematicIconPainter(this.brightness); + + final Brightness brightness; + + @override + + /// Paints the schematic-style icon. + void paint(Canvas canvas, Size size) { + final s = size.width; + + final bw = s * 0.30; + final bh = s * 0.22; + final r = s * 0.04; + + final ax = s * 0.02; + final ay = s * 0.08; + final bx = s * 0.02; + final by = s * 0.62; + final cx = s * 0.64; + final cy = s * 0.38; + + final wireColor = + brightness == Brightness.dark ? Colors.white70 : Colors.black54; + final wirePaint = Paint() + ..color = wireColor + ..strokeWidth = s * 0.045 + ..style = PaintingStyle.stroke + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round; + + final jx = s * 0.52; + final aPortY = ay + bh / 2; + final bPortY = by + bh / 2; + final cPortY = cy + bh / 2; + + final wireA = Path() + ..moveTo(ax + bw, aPortY) + ..lineTo(jx, aPortY) + ..lineTo(jx, cPortY); + canvas.drawPath(wireA, wirePaint); + + final wireB = Path() + ..moveTo(bx + bw, bPortY) + ..lineTo(jx, bPortY) + ..lineTo(jx, cPortY); + canvas.drawPath(wireB, wirePaint); + + final wireC = Path() + ..moveTo(jx, cPortY) + ..lineTo(cx, cPortY); + canvas.drawPath(wireC, wirePaint); + + final dotPaint = Paint()..color = wireColor; + canvas.drawCircle(Offset(jx, cPortY), s * 0.04, dotPaint); + + const colorA = Color(0xFF4A90D9); + const colorB = Color(0xFF50B86C); + const colorC = Color(0xFFE8943A); + + void drawBlock(double x, double y, Color color) { + final rect = RRect.fromLTRBR(x, y, x + bw, y + bh, Radius.circular(r)); + final fill = Paint()..color = color; + canvas.drawRRect(rect, fill); + final border = Paint() + ..color = color.withAlpha(200) + ..style = PaintingStyle.stroke + ..strokeWidth = s * 0.02; + canvas.drawRRect(rect, border); + } + + drawBlock(ax, ay, colorA); + drawBlock(bx, by, colorB); + drawBlock(cx, cy, colorC); + } + + @override + bool shouldRepaint(_SchematicIconPainter old) => old.brightness != brightness; +} 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..3c81d9085 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 @@ -7,123 +7,173 @@ // 2024 January 5 // Author: Yao Jing Quek +import 'dart:async'; + +import 'package:flutter/foundation.dart'; import 'package:flutter/material.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'; +import 'package:rohd_devtools_extension/rohd_devtools/ui/details_help_button.dart'; import 'package:rohd_devtools_extension/rohd_devtools/ui/signal_table.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/ui/signal_table_text_field.dart'; +import 'package:rohd_devtools_widgets/rohd_devtools_widgets.dart'; +/// Shows the selected module's signal details and search controls. class SignalDetailsCard extends StatefulWidget { + /// The module currently selected for inspection. final TreeModel? module; + /// Creates a signal details card for the selected module. const SignalDetailsCard({ - Key? key, + super.key, this.module, - }) : super(key: key); + }); @override + + /// Creates the mutable state for [SignalDetailsCard]. SignalDetailsCardState createState() => SignalDetailsCardState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('module', module)); + } } +/// State for [SignalDetailsCard]. class SignalDetailsCardState extends State { + /// Search term used to filter signals. String? searchTerm; + + /// Whether input signals are shown. ValueNotifier inputSelected = ValueNotifier(true); + + /// Whether output signals are shown. ValueNotifier outputSelected = ValueNotifier(true); + + /// Notifies the widget tree to rebuild after filter changes. ValueNotifier notifier = ValueNotifier(0); - void toggleNotifier() { - notifier.value++; - } + /// Boundary used when exporting the signal details panel as PNG. + final GlobalKey _boundaryKey = GlobalKey(); + + /// Increments the rebuild notifier. + void toggleNotifier() => notifier.value++; void _showFilterDialog() { - showDialog( - context: context, - builder: (BuildContext context) { - return StatefulBuilder( - builder: (BuildContext context, StateSetter setState) { - return AlertDialog( - title: const Text('Filter Signals'), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - CheckboxListTile( - title: const Text('Input'), - value: inputSelected.value, - onChanged: (bool? value) { - setState(() { - inputSelected.value = value!; - }); - toggleNotifier(); - }, - ), - CheckboxListTile( - title: const Text('Output'), - value: outputSelected.value, - onChanged: (bool? value) { - setState(() { - outputSelected.value = value!; - }); - toggleNotifier(); - }, - ), - ], - ), - ); - }, - ); - }, + unawaited( + showDialog( + context: context, + builder: (context) => StatefulBuilder( + builder: (context, setState) => AlertDialog( + title: const Text('Filter Signals'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CheckboxListTile( + title: const Text('Input'), + value: inputSelected.value, + onChanged: (value) { + setState(() { + inputSelected.value = value!; + }); + toggleNotifier(); + }, + ), + CheckboxListTile( + title: const Text('Output'), + value: outputSelected.value, + onChanged: (value) { + setState(() { + outputSelected.value = value!; + }); + toggleNotifier(); + }, + ), + ], + ), + ), + ), + ), ); } @override + + /// Builds the signal details panel for the selected module. Widget build(BuildContext context) { if (widget.module == null) { return const Padding( - padding: EdgeInsets.only(top: 20.0), + padding: EdgeInsets.only(top: 20), child: Center(child: Text('No module selected')), ); } - 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(); - }, + final isDark = Theme.of(context).brightness == Brightness.dark; + + return Stack( + children: [ + RepaintBoundary( + key: _boundaryKey, + child: SingleChildScrollView( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(8), + child: Row( + children: [ + SignalTableTextField( + labelText: 'Search Signals', + onChanged: (value) { + setState(() { + searchTerm = value; + }); + toggleNotifier(); + }, + ), + IconButton( + icon: const Icon(Icons.filter_list), + onPressed: _showFilterDialog, + ), + DetailsHelpButton(isDark: isDark), + ], ), - IconButton( - icon: const Icon(Icons.filter_list), - onPressed: _showFilterDialog, + ), + ValueListenableBuilder( + valueListenable: notifier, + builder: (context, _, __) => 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', ), - ], + ), ), - ), + ], ); } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(StringProperty('searchTerm', searchTerm)) + ..add(FlagProperty('inputSelected', value: inputSelected.value)) + ..add(FlagProperty('outputSelected', value: outputSelected.value)) + ..add(IntProperty('notifier', notifier.value)); + } } diff --git a/rohd_devtools_extension/lib/rohd_devtools/ui/signal_table.dart b/rohd_devtools_extension/lib/rohd_devtools/ui/signal_table.dart index 8e97328d8..7c948509b 100644 --- a/rohd_devtools_extension/lib/rohd_devtools/ui/signal_table.dart +++ b/rohd_devtools_extension/lib/rohd_devtools/ui/signal_table.dart @@ -7,30 +7,55 @@ // 2024 January 5 // Author: Yao Jing Quek +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:rohd_devtools_extension/rohd_devtools/models/signal_model.dart'; import 'package:rohd_devtools_extension/rohd_devtools/models/tree_model.dart'; -import 'package:rohd_devtools_extension/rohd_devtools/services/signal_service.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/services/services.dart'; +/// Displays the signals for a selected module in a table. class SignalTable extends StatefulWidget { + /// The module whose signals are shown in the table. final TreeModel selectedModule; + + /// Optional search term used to filter visible signals. final String? searchTerm; + + /// Whether input signals should be shown. final bool inputSelectedVal; + + /// Whether output signals should be shown. final bool outputSelectedVal; + + /// Creates a signal table for the given module and filters. const SignalTable({ - super.key, required this.selectedModule, required this.searchTerm, required this.inputSelectedVal, required this.outputSelectedVal, + super.key, }); @override - State createState() => _SignalTableState(); + + /// Creates the state object for [SignalTable]. + State createState() => _SignalTableState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('selectedModule', selectedModule)) + ..add(StringProperty('searchTerm', searchTerm)) + ..add(FlagProperty('inputSelectedVal', value: inputSelectedVal)) + ..add(FlagProperty('outputSelectedVal', value: outputSelectedVal)); + } } class _SignalTableState extends State { @override + + /// Builds the signal table and its rows. Widget build(BuildContext context) { final tableHeaders = ['Name', 'Direction', 'Value', 'Width']; @@ -51,85 +76,74 @@ class _SignalTableState extends State { ), ...generateSignalsRow( widget.selectedModule, - widget.searchTerm, - widget.inputSelectedVal, - widget.outputSelectedVal, + searchTerm: widget.searchTerm, + inputSelected: widget.inputSelectedVal, + outputSelected: widget.outputSelectedVal, ), ], ); } + /// Builds the rows for the signals that match the selected filters. List generateSignalsRow( - TreeModel module, - String? searchTerm, - bool inputSelected, - bool outputSelected, - ) { - List rows = []; + TreeModel module, { + required String? searchTerm, + required bool inputSelected, + required bool outputSelected, + }) { + final rows = []; // Filter signals - List inputSignals = inputSelected + final inputSignals = inputSelected ? SignalService.filterSignals(module.inputs, searchTerm ?? '') - : []; - List outputSignals = outputSelected + : []; + final outputSignals = outputSelected ? SignalService.filterSignals(module.outputs, searchTerm ?? '') - : []; + : []; // Add input from signal model list to row - for (var signal in inputSignals) { + for (final signal in inputSignals) { rows.add(_generateSignalRow(signal)); } // Add output from signal model list to row - for (var signal in outputSignals) { + for (final signal in outputSignals) { rows.add(_generateSignalRow(signal)); } return rows; } - TableRow _generateSignalRow(SignalModel signal) { - return TableRow( - children: [ - SizedBox( - height: 32, - child: Center( - child: Text(signal.name), + TableRow _generateSignalRow(SignalModel signal) => TableRow( + children: [ + SizedBox( + height: 32, + child: Center(child: Text(signal.name)), ), - ), - SizedBox( - height: 32, - child: Center( - child: Text(signal.direction), + SizedBox( + height: 32, + child: Center(child: Text(signal.direction)), ), - ), - SizedBox( - height: 32, - child: Center( - child: Text(signal.value), + SizedBox( + height: 32, + child: Center(child: Text(signal.value)), ), - ), - SizedBox( - height: 32, - child: Center( - child: Text(signal.width.toString()), + SizedBox( + height: 32, + child: Center(child: Text(signal.width.toString())), ), - ), - ], - ); - } + ], + ); - Widget _buildTableHeader({required String text}) { - return SizedBox( - height: 32, - child: Center( - child: Text( - text, - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 15, + Widget _buildTableHeader({required String text}) => SizedBox( + height: 32, + child: Center( + child: Text( + text, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 15, + ), ), ), - ), - ); - } + ); } diff --git a/rohd_devtools_extension/lib/rohd_devtools/ui/signal_table_text_field.dart b/rohd_devtools_extension/lib/rohd_devtools/ui/signal_table_text_field.dart index 4696ac39f..02bfcc3b7 100644 --- a/rohd_devtools_extension/lib/rohd_devtools/ui/signal_table_text_field.dart +++ b/rohd_devtools_extension/lib/rohd_devtools/ui/signal_table_text_field.dart @@ -7,27 +7,44 @@ // 2024 January 5 // Author: Yao Jing Quek +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +/// Text field used to filter or search within the signal table. class SignalTableTextField extends StatelessWidget { + /// The label shown inside the text field. final String labelText; + + /// Called whenever the text changes. final ValueChanged onChanged; + /// Creates a signal table text field. const SignalTableTextField({ - super.key, required this.labelText, required this.onChanged, + super.key, }); + /// Builds the text field wrapped in an [Expanded] widget. @override - Widget build(BuildContext context) { - return Expanded( - child: TextField( - onChanged: onChanged, - decoration: InputDecoration( - labelText: labelText, + Widget build(BuildContext context) => Expanded( + child: TextField( + onChanged: onChanged, + decoration: InputDecoration( + labelText: labelText, + ), ), - ), - ); + ); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(StringProperty('labelText', labelText)) + ..add(ObjectFlagProperty>( + 'onChanged', + onChanged, + ifNull: 'disabled', + )); } } diff --git a/rohd_devtools_extension/lib/rohd_devtools/ui/standalone_app_shell.dart b/rohd_devtools_extension/lib/rohd_devtools/ui/standalone_app_shell.dart new file mode 100644 index 000000000..31e6033d7 --- /dev/null +++ b/rohd_devtools_extension/lib/rohd_devtools/ui/standalone_app_shell.dart @@ -0,0 +1,359 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// standalone_app_shell.dart +// Minimal standalone shell for early startup/connection porting. +// +// 2026 June +// Author: Desmond Kirkpatrick + +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/const/app_theme.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/cubit/cubits.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/models/dtd_vm_service_info.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/ui/ui.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/view/tree_structure_page.dart'; + +/// Configuration for the standalone ROHD DevTools app shell. +class StandaloneAppConfig { + /// Title shown in AppBar. + final String title; + + /// Strategy for connecting to VM service. + final VmConnectionStrategy? connectionStrategy; + + /// Constructor for [StandaloneAppConfig]. + const StandaloneAppConfig({ + this.title = 'ROHD DevTools (Standalone)', + this.connectionStrategy, + }); +} + +/// Standalone app entry point that wires up theming and the app shell. +class StandaloneRohdDevToolsApp extends StatelessWidget { + /// Configuration used by the standalone app shell. + final StandaloneAppConfig config; + + /// Creates the standalone ROHD DevTools app. + const StandaloneRohdDevToolsApp({ + super.key, + this.config = const StandaloneAppConfig(), + }); + + @override + + /// Builds the top-level app and injects theme state. + Widget build(BuildContext context) => BlocProvider( + create: (context) => DevToolsThemeCubit(), + child: BlocBuilder( + builder: (context, themeMode) { + final isDark = themeMode == DevToolsThemeMode.dark; + + return MaterialApp( + title: config.title, + debugShowCheckedModeBanner: false, + themeMode: isDark ? ThemeMode.dark : ThemeMode.light, + darkTheme: buildDarkTheme(), + theme: buildLightTheme(), + home: StandaloneDevToolsPage(config: config), + ); + }, + ), + ); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('config', config)); + } +} + +/// The main standalone page that manages connections and content. +class StandaloneDevToolsPage extends StatefulWidget { + /// Configuration for the standalone page. + final StandaloneAppConfig config; + + /// Creates the standalone DevTools page. + const StandaloneDevToolsPage({required this.config, super.key}); + + @override + + /// Creates the mutable state for [StandaloneDevToolsPage]. + State createState() => _StandaloneDevToolsPageState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('config', config)); + } +} + +class _StandaloneDevToolsPageState + extends DevToolsConnectionHostState { + late final RohdServiceCubit _rohdServiceCubit = RohdServiceCubit( + manageServiceManager: false, + ); + late final TreeSearchTermCubit _treeSearchTermCubit = TreeSearchTermCubit(); + late final SelectedModuleCubit _selectedModuleCubit = SelectedModuleCubit(); + late final SignalSearchTermCubit _signalSearchTermCubit = + SignalSearchTermCubit(); + + @override + + /// Returns the connection strategy requested by the widget config. + VmConnectionStrategy? get connectionStrategy => + widget.config.connectionStrategy; + + @override + + /// Initializes the connection dialog and supporting listeners. + void initState() { + super.initState(); + // Auto-pop the connection dialog after the first frame, once + // fonts have settled on web (so glyphs render correctly). + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && !isConnected) { + unawaited(_showConnectionDialogWhenReady()); + } + }); + } + + /// Wait for icon fonts to load on web before showing the dialog so + /// the form's glyphs (e.g. dropdown chevrons) render on the first + /// frame instead of as boxes. No-op on native platforms. + Future _showConnectionDialogWhenReady() async { + if (kIsWeb) { + final completer = Completer(); + void onFontsChanged() { + if (!completer.isCompleted) { + completer.complete(); + } + } + + PaintingBinding.instance.systemFonts.addListener(onFontsChanged); + await completer.future.timeout( + const Duration(milliseconds: 1500), + onTimeout: () {}, + ); + PaintingBinding.instance.systemFonts.removeListener(onFontsChanged); + + // Give CanvasKit one extra frame to rasterise the glyphs. + await Future.delayed(const Duration(milliseconds: 100)); + if (mounted) { + await WidgetsBinding.instance.endOfFrame; + } + } + if (!mounted || isConnected) { + return; + } + await showConnectionDialog(); + } + + @override + + /// Handles a successful VM connection by configuring the ROHD service. + Future onVmConnected(VmConnectionResult result, String uri) async { + await _rohdServiceCubit.configureStandaloneVmService( + result.vmService, + result.isolateId, + ); + await _rohdServiceCubit.evalModuleTree(); + } + + @override + + /// Clears the standalone tree service when the connection is torn down. + Future tearDownOldConnection() async { + _rohdServiceCubit.treeService = null; + } + + @override + + /// Reopens the connection dialog after the VM disconnects. + void onVmDisconnected() { + // Re-pop the connection dialog so the user can reconnect. + if (mounted) { + unawaited(showConnectionDialog()); + } + } + + @override + + /// Reconfigures the ROHD service after a lightweight reconnect. + Future onLightweightReconnectSuccess( + VmConnectionResult result, + String uri, + ) async { + await _rohdServiceCubit.configureStandaloneVmService( + result.vmService, + result.isolateId, + ); + await _rohdServiceCubit.evalModuleTree(); + } + + @override + + /// Releases cubits used by the standalone shell. + void dispose() { + unawaited(_rohdServiceCubit.close()); + unawaited(_treeSearchTermCubit.close()); + unawaited(_selectedModuleCubit.close()); + unawaited(_signalSearchTermCubit.close()); + super.dispose(); + } + + void _openConnectionDialog() => unawaited(showConnectionDialog()); + + void _disconnect() => unawaited(disconnect()); + + /// Override the base dialog content to wire dismiss-on-success and + /// the standalone shell's discovered-services memory. + @override + + /// Builds the standalone connection dialog content. + Widget buildConnectionDialogContent(BuildContext dialogContext) => + VmConnectionForm( + vmServiceUriController: vmServiceUriController, + dtdUriController: dtdUriController, + connectionError: connectionError, + onConnect: () async { + try { + await attemptConnection(); + if (mounted && dialogContext.mounted && isConnected) { + Navigator.of(dialogContext).pop(); + } + } on Exception catch (e) { + setState(() { + connectionError = 'Connection failed: $e'; + }); + } + }, + cleanVmServiceUri: DevToolsConnectionHostState.cleanVmServiceUri, + cleanDtdUri: DevToolsConnectionHostState.cleanDtdUri, + discoverVmServices: discoverVmServices, + hasColorEmoji: true, + initialDiscoveredServices: rememberedServices + ?.map( + (s) => DiscoveredVmService( + name: s.name, + uri: s.uri, + exposedUri: s.exposedUri, + isAlive: s.isAlive, + autoReconnect: s.autoReconnect, + ), + ) + .toList(), + onServicesDiscovered: (services) { + rememberedServices = services + .map( + (s) => DtdVmServiceInfo.fromFields( + name: s.name, + uri: s.uri, + exposedUri: s.exposedUri, + isAlive: s.isAlive, + autoReconnect: s.autoReconnect, + ), + ) + .toList(); + }, + ); + + /// Builds the empty state shown before any connection is established. + Widget _buildEmptyConnectionState() { + final isDark = Theme.of(context).brightness == Brightness.dark; + final secondaryTextColor = isDark ? Colors.white70 : Colors.black54; + + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.cable_outlined, + size: 72, + color: Theme.of(context).disabledColor, + ), + const SizedBox(height: 16), + const Text( + 'Not connected', + style: TextStyle(fontSize: 20, fontWeight: FontWeight.w500), + ), + const SizedBox(height: 8), + Text( + 'Connect to a running ROHD application to begin.', + style: TextStyle(color: secondaryTextColor), + ), + const SizedBox(height: 24), + FilledButton.icon( + icon: const Icon(Icons.link), + label: const Text('Connect…'), + onPressed: () => unawaited(showConnectionDialog()), + ), + ], + ), + ); + } + + @override + + /// Builds the standalone shell, switching between connected and empty UI. + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final accentColor = Theme.of(context).colorScheme.primary; + return Scaffold( + appBar: AppBar( + title: Text(widget.config.title), + actions: [ + if (isConnected) ...[ + IconButton( + tooltip: 'Disconnect', + onPressed: _disconnect, + icon: const Icon(Icons.link_off), + ), + ] else + IconButton( + tooltip: 'Connect…', + onPressed: _openConnectionDialog, + icon: const Icon(Icons.link), + ), + BlocBuilder( + builder: (context, themeMode) { + final isDark = themeMode == DevToolsThemeMode.dark; + + return IconButton( + tooltip: + isDark ? 'Switch to light theme' : 'Switch to dark theme', + onPressed: () { + context.read().toggleTheme(); + }, + icon: platformIcon( + isDark ? Icons.light_mode : Icons.dark_mode, + isDark ? 'β˜€οΈ' : 'πŸŒ™', + size: 24, + color: accentColor, + hasColorEmoji: kIsWeb, + ), + ); + }, + ), + DevToolsHelpButton(isDark: isDark), + ], + ), + body: !isConnected + ? _buildEmptyConnectionState() + : MultiBlocProvider( + providers: [ + BlocProvider.value(value: _rohdServiceCubit), + BlocProvider.value(value: _treeSearchTermCubit), + BlocProvider.value(value: _selectedModuleCubit), + BlocProvider.value(value: _signalSearchTermCubit), + BlocProvider(create: (context) => DetailsTabCubit()), + ], + child: TreeStructurePage(screenSize: MediaQuery.of(context).size), + ), + ); + } +} diff --git a/rohd_devtools_extension/lib/rohd_devtools/ui/ui.dart b/rohd_devtools_extension/lib/rohd_devtools/ui/ui.dart new file mode 100644 index 000000000..ddd12e73c --- /dev/null +++ b/rohd_devtools_extension/lib/rohd_devtools/ui/ui.dart @@ -0,0 +1,20 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// ui.dart +// Barrel file for rohd_devtools UI widgets. +// +// NOTE: standalone_app_shell.dart is excluded because it imports this barrel. + +export 'details_help_button.dart'; +export 'devtool_appbar.dart'; +export 'devtools_connection_host.dart'; +export 'devtools_help_button.dart'; +export 'module_tree_card.dart'; +export 'module_tree_details_navbar.dart'; +export 'platform_icon.dart'; +export 'schematic_icon.dart'; +export 'signal_details_card.dart'; +export 'signal_table.dart'; +export 'signal_table_text_field.dart'; +export 'vm_connection_form.dart'; diff --git a/rohd_devtools_extension/lib/rohd_devtools/ui/vm_connection_form.dart b/rohd_devtools_extension/lib/rohd_devtools/ui/vm_connection_form.dart new file mode 100644 index 000000000..575d0a590 --- /dev/null +++ b/rohd_devtools_extension/lib/rohd_devtools/ui/vm_connection_form.dart @@ -0,0 +1,718 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// vm_connection_form.dart +// Reusable VM connection form widget for both initial screen and dialog. +// +// 2026 February +// Author: Desmond Kirkpatrick + +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/ui/platform_icon.dart'; + +/// Describes a single VM service discovered via DTD. +class DiscoveredVmService with Diagnosticable { + /// Human-readable name (may be null). + final String? name; + + /// Direct VM service URI. + final String uri; + + /// Exposed/forwarded URI (preferred over [uri] when available). + final String? exposedUri; + + /// Whether this VM service is currently reachable. + /// + /// Set to `false` by auto-rediscovery when the service is no longer + /// found via the DTD. Dead services are shown grayed-out in the list. + bool isAlive; + + /// Whether to automatically reconnect to this VM by name if it dies + /// and a new VM with the same name appears via DTD discovery. + bool autoReconnect; + + /// The URI to use for connection (prefers exposedUri). + String get connectionUri => exposedUri ?? uri; + + /// Construction for [DiscoveredVmService]. + DiscoveredVmService({ + required this.uri, + this.name, + this.exposedUri, + this.isAlive = true, + this.autoReconnect = false, + }); + + /// A compact display label. + String get displayLabel { + final label = name ?? 'VM Service'; + final preview = connectionUri.length > 50 + ? '${connectionUri.substring(0, 50)}…' + : connectionUri; + return '$label β€” ' + '$preview'; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(StringProperty('name', name)) + ..add(StringProperty('uri', uri)) + ..add(StringProperty('exposedUri', exposedUri)) + ..add(FlagProperty('isAlive', value: isAlive)) + ..add(FlagProperty('autoReconnect', value: autoReconnect)) + ..add(StringProperty('connectionUri', connectionUri)) + ..add(StringProperty('displayLabel', displayLabel)); + } +} + +/// Callback that discovers VM services from a DTD URI. +/// +/// Returns the list of services found, or throws on error. +typedef DiscoverVmServicesCallback = Future> Function( + String dtdUri); + +/// Reusable VM connection form that can be embedded in different contexts. +/// +/// This widget encapsulates the DTD URI discovery and VM Service URI input, +/// and can be used both as the initial connection screen and in dialogs. +/// +/// Layout (top to bottom): +/// 1. DTD URI field + Discover button +/// 2. Discovered VM list (when available) +/// 3. VM Service URI field (manual override) +/// 4. Connect button +class VmConnectionForm extends StatefulWidget { + /// Controller for VM Service URI + final TextEditingController vmServiceUriController; + + /// Controller for DTD URI + final TextEditingController dtdUriController; + + /// Current connection error message (if any) + final String? connectionError; + + /// Callback when Connect button is pressed + final VoidCallback onConnect; + + /// Callback when Demo mode button is pressed (optional) + final VoidCallback? onDemoMode; + + /// Whether to show the demo mode button and help text + final bool showDemoButton; + + /// Whether emoji colors are available (for platform icons) + final bool hasColorEmoji; + + /// Callback to clean VM Service URIs + final String Function(String) cleanVmServiceUri; + + /// Callback to clean DTD URIs + final String Function(String) cleanDtdUri; + + /// Callback that discovers VM services from a DTD URI. + final DiscoverVmServicesCallback? discoverVmServices; + + /// Previously discovered services to pre-populate the list. + /// + /// When returning to the connection screen after a VM death, the parent + /// passes the remembered list (with [DiscoveredVmService.isAlive] set + /// appropriately) so the user can see which VMs are still available. + final List? initialDiscoveredServices; + + /// Called whenever the form discovers (or re-discovers) VM services. + /// + /// The parent should save this list so it can be passed back as + /// [initialDiscoveredServices] if the connection screen is shown again. + final ValueChanged>? onServicesDiscovered; + + /// Construction for [VmConnectionForm]. + const VmConnectionForm({ + required this.vmServiceUriController, + required this.dtdUriController, + required this.onConnect, + required this.cleanVmServiceUri, + required this.cleanDtdUri, + this.connectionError, + this.onDemoMode, + this.showDemoButton = false, + this.hasColorEmoji = false, + this.discoverVmServices, + this.initialDiscoveredServices, + this.onServicesDiscovered, + super.key, + }); + + @override + + /// Creates the state object for the VM connection form. + State createState() => _VmConnectionFormState(); + + @override + + /// Adds diagnostic properties for the connection form. + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add( + DiagnosticsProperty( + 'vmServiceUriController', + vmServiceUriController, + ), + ) + ..add( + DiagnosticsProperty( + 'dtdUriController', + dtdUriController, + ), + ) + ..add(StringProperty('connectionError', connectionError)) + ..add( + ObjectFlagProperty( + 'onConnect', + onConnect, + ifNull: 'disabled', + ), + ) + ..add( + ObjectFlagProperty( + 'onDemoMode', + onDemoMode, + ifNull: 'disabled', + ), + ) + ..add(FlagProperty('showDemoButton', value: showDemoButton)) + ..add(FlagProperty('hasColorEmoji', value: hasColorEmoji)) + ..add( + DiagnosticsProperty( + 'cleanVmServiceUri', + cleanVmServiceUri, + ), + ) + ..add( + DiagnosticsProperty( + 'cleanDtdUri', + cleanDtdUri, + ), + ) + ..add( + ObjectFlagProperty( + 'discoverVmServices', + discoverVmServices, + ifNull: 'disabled', + ), + ) + ..add( + DiagnosticsProperty?>( + 'initialDiscoveredServices', + initialDiscoveredServices, + ), + ) + ..add( + ObjectFlagProperty>?>( + 'onServicesDiscovered', + onServicesDiscovered, + ifNull: 'disabled', + ), + ); + } +} + +class _VmConnectionFormState extends State { + List? _discoveredServices; + bool _isDiscovering = false; + bool _discoveryCancelled = false; + String? _discoveryError; + + void _connect() { + final raw = widget.vmServiceUriController.text; + final cleaned = widget.cleanVmServiceUri(raw); + if (cleaned.isNotEmpty && cleaned != raw) { + widget.vmServiceUriController.text = cleaned; + widget.vmServiceUriController.selection = + TextSelection.collapsed(offset: cleaned.length); + } + widget.onConnect(); + } + + @override + void initState() { + super.initState(); + // Pre-populate with remembered services (may include dead ones) + if (widget.initialDiscoveredServices != null) { + _discoveredServices = List.from( + widget.initialDiscoveredServices!, + ); + } else if (widget.dtdUriController.text.isNotEmpty && + widget.discoverVmServices != null) { + // DTD URI is already set (e.g. app reload) β€” auto-discover. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + unawaited(_discoverServices()); + } + }); + } + } + + Future _discoverServices() async { + final raw = widget.dtdUriController.text; + if (raw.isEmpty) { + return; + } + + final cleaned = widget.cleanDtdUri(raw); + if (!cleaned.startsWith('ws')) { + return; + } + + widget.dtdUriController.text = cleaned; + + if (widget.discoverVmServices == null) { + return; + } + + setState(() { + _isDiscovering = true; + _discoveryCancelled = false; + _discoveryError = null; + _discoveredServices = null; + }); + + try { + final services = await widget.discoverVmServices!(cleaned); + if (!mounted || _discoveryCancelled) { + return; + } + setState(() { + _isDiscovering = false; + _discoveredServices = services; + if (services.isEmpty) { + _discoveryError = 'No VM services found. Is your app running?'; + } else if (services.length == 1) { + // Auto-select the only service and enable auto-reconnect + widget.vmServiceUriController.text = services.first.connectionUri; + services.first.autoReconnect = true; + } + }); + // Notify parent so it can remember these across reconnects + widget.onServicesDiscovered?.call(services); + } on Exception catch (e) { + if (!mounted) { + return; + } + + // Determine the specific error to display better messages + final errorStr = e.toString().toLowerCase(); + final errorMessage = _getDiscoveryErrorMessage(errorStr, cleaned); + + setState(() { + _isDiscovering = false; + _discoveryError = errorMessage; + }); + } + } + + /// Generates a user-friendly error message based on the exception type. + /// + /// Distinguishes between DTD connection errors (invalid address) + /// and other errors, providing specific guidance for each case. + String _getDiscoveryErrorMessage(String errorStr, String dtdUri) { + // Check for WebSocket connection errors (invalid DTD address) + if (errorStr.contains('websocket') || + errorStr.contains('connection') || + errorStr.contains('failed to connect') || + errorStr.contains('refused')) { + return 'Failed to connect to DTD address: $dtdUri. ' + 'Please verify the URI is correct and the Dart Tooling Daemon ' + 'is running.'; + } + + // Check for socket timeouts or DNS resolution errors + if (errorStr.contains('timeout') || errorStr.contains('dns')) { + return 'Connection to DTD timed out or could not resolve ' + 'address: $dtdUri. ' + 'Please verify the DTD address is reachable.'; + } + + // Check for certificate/SSL errors + if (errorStr.contains('certificate') || errorStr.contains('ssl')) { + return 'SSL/certificate error connecting to DTD. ' + 'Make sure the DTD certificate is valid.'; + } + + // For other errors, provide a generic message + return 'Discovery failed. Please verify the DTD URI is correct ' + 'and check the console for more details.'; + } + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + + return SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Title (only on full-screen layout) + if (widget.showDemoButton) ...[ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + platformIcon( + Icons.developer_board, + 'πŸ”§', + size: 32, + hasColorEmoji: widget.hasColorEmoji, + ), + const SizedBox(width: 12), + Text( + 'Connect to Dart VM', + style: Theme.of(context).textTheme.headlineSmall, + ), + ], + ), + const SizedBox(height: 24), + ], + + // ── 1. DTD URI field ── + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: TextField( + controller: widget.dtdUriController, + keyboardType: TextInputType.url, + autocorrect: false, + enableSuggestions: false, + smartDashesType: SmartDashesType.disabled, + smartQuotesType: SmartQuotesType.disabled, + decoration: InputDecoration( + labelText: 'DTD URI (auto-discover VMs)', + hintText: 'ws://127.0.0.1:xxxxx/xxxxx=', + border: const OutlineInputBorder(), + prefixIcon: platformIcon( + Icons.cloud, + '☁️', + size: 20, + hasColorEmoji: widget.hasColorEmoji, + ), + ), + onSubmitted: (_) => _discoverServices(), + ), + ), + const SizedBox(width: 8), + if (_isDiscovering) ...[ + const SizedBox( + height: 56, + child: ElevatedButton( + onPressed: null, + child: SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ), + const SizedBox(width: 8), + SizedBox( + height: 56, + child: TextButton( + onPressed: () { + setState(() { + _discoveryCancelled = true; + _isDiscovering = false; + }); + }, + child: const Text('Cancel'), + ), + ), + ] else + SizedBox( + height: 56, // match TextField height + child: ElevatedButton( + onPressed: _discoverServices, + child: const Text('Discover'), + ), + ), + ], + ), + const SizedBox(height: 8), + + // ── 2. Discovered VM list ── + if (_discoveredServices != null && + _discoveredServices!.isNotEmpty) ...[ + Builder( + builder: (context) { + final aliveCount = + _discoveredServices!.where((s) => s.isAlive).length; + final deadCount = _discoveredServices!.length - aliveCount; + final label = deadCount > 0 + ? '$aliveCount VM service(s) available ($deadCount ended):' + : '${_discoveredServices!.length} VM service(s) found:'; + return Text( + label, + style: TextStyle( + fontSize: 12, + color: isDark ? Colors.white70 : Colors.black54, + ), + ); + }, + ), + const SizedBox(height: 4), + Container( + constraints: const BoxConstraints(maxHeight: 160), + decoration: BoxDecoration( + border: Border.all( + color: isDark ? Colors.white24 : Colors.black12, + ), + borderRadius: BorderRadius.circular(8), + ), + child: ListView.separated( + shrinkWrap: true, + itemCount: _discoveredServices!.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (context, index) { + final svc = _discoveredServices![index]; + final isDead = !svc.isAlive; + final isSelected = !isDead && + widget.vmServiceUriController.text == svc.connectionUri; + return ListTile( + dense: true, + selected: isSelected, + selectedTileColor: isDark + ? Colors.blue.shade900.withValues(alpha: 0.4) + : Colors.blue.shade50, + leading: platformIcon( + isDead ? Icons.cloud_off : Icons.memory, + isDead ? 'πŸ”Œ' : 'πŸ”Œ', + size: 18, + hasColorEmoji: widget.hasColorEmoji, + color: isDead ? Colors.grey : null, + ), + title: Text( + svc.name ?? 'VM Service ${index + 1}', + style: TextStyle( + fontSize: 13, + fontWeight: + isSelected ? FontWeight.bold : FontWeight.normal, + color: isDead ? Colors.grey : null, + ), + ), + subtitle: Text( + isDead + ? '${svc.connectionUri} (ended)' + : svc.connectionUri, + style: TextStyle( + fontSize: 11, + fontFamily: 'monospace', + color: isDead ? Colors.grey : null, + ), + overflow: TextOverflow.ellipsis, + ), + trailing: isDead + ? null + : Tooltip( + message: 'Automatic Reconnect', + child: SizedBox( + width: 24, + height: 24, + child: Checkbox( + value: svc.autoReconnect, + onChanged: (value) { + setState(() { + svc.autoReconnect = value ?? false; + }); + widget.onServicesDiscovered?.call( + _discoveredServices!, + ); + }, + materialTapTargetSize: + MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + ), + ), + ), + onTap: () { + setState(() { + widget.vmServiceUriController.text = svc.connectionUri; + }); + // Alive VMs connect immediately; dead VMs just fill + // the URI field so the user can edit before connecting. + if (!isDead) { + widget.onConnect(); + } + }, + ); + }, + ), + ), + const SizedBox(height: 12), + ], + + // Discovery error + if (_discoveryError != null) ...[ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.orange.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + _discoveryError!, + style: const TextStyle(color: Colors.orange, fontSize: 12), + ), + ), + const SizedBox(height: 12), + ], + + // ── 3. VM Service URI field (manual / override) ── + TextField( + controller: widget.vmServiceUriController, + keyboardType: TextInputType.url, + autocorrect: false, + enableSuggestions: false, + smartDashesType: SmartDashesType.disabled, + smartQuotesType: SmartQuotesType.disabled, + decoration: InputDecoration( + labelText: 'VM Service URI', + hintText: 'ws://127.0.0.1:8181/xxxx=/ws', + border: const OutlineInputBorder(), + prefixIcon: platformIcon( + Icons.link, + 'πŸ”—', + size: 20, + hasColorEmoji: widget.hasColorEmoji, + ), + ), + onSubmitted: (_) => _connect(), + ), + const SizedBox(height: 16), + + // Connection error + if (widget.connectionError != null) + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.red.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.red.withValues(alpha: 0.3)), + ), + child: Row( + children: [ + platformIcon( + Icons.error, + '❌', + color: Colors.red, + size: 20, + hasColorEmoji: widget.hasColorEmoji, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + widget.connectionError!, + style: const TextStyle(color: Colors.red), + ), + ), + ], + ), + ), + if (widget.connectionError != null) const SizedBox(height: 16), + + // ── 4. Connect button ── + ElevatedButton.icon( + onPressed: _connect, + icon: platformIcon( + Icons.power, + '⚑', + size: 20, + hasColorEmoji: widget.hasColorEmoji, + ), + label: const Text('Connect'), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + ), + ), + + // Full-screen layout: demo mode button and help text + if (widget.showDemoButton && widget.onDemoMode != null) ...[ + const SizedBox(height: 16), + + // Divider + Row( + children: [ + const Expanded(child: Divider()), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text( + 'OR', + style: TextStyle( + color: isDark ? Colors.white54 : Colors.black54, + ), + ), + ), + const Expanded(child: Divider()), + ], + ), + const SizedBox(height: 16), + + // Demo mode button + OutlinedButton.icon( + onPressed: widget.onDemoMode, + icon: platformIcon( + Icons.play_arrow, + '▢️', + size: 20, + hasColorEmoji: widget.hasColorEmoji, + ), + label: const Text('Continue without Connection (Demo examples)'), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + ), + ), + const SizedBox(height: 24), + + // Help text + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: isDark + ? Colors.white.withValues(alpha: 0.05) + : Colors.black.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'To connect to a running ROHD app:', + style: TextStyle( + fontWeight: FontWeight.bold, + color: isDark ? Colors.white70 : Colors.black87, + ), + ), + const SizedBox(height: 8), + Text( + '1. Run your app with: dart run -- ' + 'observe your_app.dart\n' + '2. Copy the VM service URI from the console\n' + '3. Paste it above and click Connect', + style: TextStyle( + fontFamily: 'monospace', + fontSize: 12, + color: isDark ? Colors.white54 : Colors.black54, + ), + ), + ], + ), + ), + ], + ], + ), + ); + } +} diff --git a/rohd_devtools_extension/lib/rohd_devtools/view/rohd_devtools_page.dart b/rohd_devtools_extension/lib/rohd_devtools/view/rohd_devtools_page.dart index fd880f56b..728872f0a 100644 --- a/rohd_devtools_extension/lib/rohd_devtools/view/rohd_devtools_page.dart +++ b/rohd_devtools_extension/lib/rohd_devtools/view/rohd_devtools_page.dart @@ -7,52 +7,68 @@ // 2025 January 28 // Author: Roberto Torres -import 'package:devtools_app_shared/service.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/const/app_theme.dart'; import 'package:rohd_devtools_extension/rohd_devtools/rohd_devtools.dart'; -import 'package:rohd_devtools_extension/rohd_devtools/ui/devtool_appbar.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/ui/ui.dart'; +/// Main page for the embedded ROHD DevTools experience. class RohdDevToolsPage extends StatelessWidget { + /// Creates the DevTools page. const RohdDevToolsPage({super.key}); + @override - Widget build(BuildContext context) { - return MultiBlocProvider( - providers: [ - BlocProvider( - create: (context) => RohdServiceCubit(), - ), - BlocProvider( - create: (context) => TreeSearchTermCubit(), - ), - BlocProvider( - create: (context) => SelectedModuleCubit(), - ), - BlocProvider( - create: (context) => SignalSearchTermCubit(), + + /// Builds the themed DevTools page and its bloc providers. + Widget build(BuildContext context) => MultiBlocProvider( + providers: [ + BlocProvider( + create: (context) => DevToolsThemeCubit(), + ), + BlocProvider( + create: (context) => RohdServiceCubit(), + ), + BlocProvider( + create: (context) => TreeSearchTermCubit(), + ), + BlocProvider( + create: (context) => SelectedModuleCubit(), + ), + BlocProvider( + create: (context) => SignalSearchTermCubit(), + ), + BlocProvider( + create: (context) => DetailsTabCubit(), + ), + ], + child: BlocBuilder( + builder: (context, themeMode) { + final theme = themeMode == DevToolsThemeMode.dark + ? buildDarkTheme() + : buildLightTheme(); + + return Theme(data: theme, child: const RohdExtensionModule()); + }, ), - ], - child: const RohdExtensionModule(), - ); - } + ); } +/// Extension module wrapper used by the DevTools host. class RohdExtensionModule extends StatefulWidget { + /// Creates the extension module. const RohdExtensionModule({super.key}); @override + + /// Creates the module state. State createState() => _RohdExtensionModuleState(); } class _RohdExtensionModuleState extends State { - late final EvalOnDartLibrary rohdControllerEval; - @override - void initState() { - super.initState(); - } - @override + /// Builds the module scaffold and tree view. Widget build(BuildContext context) { final screenSize = MediaQuery.of(context).size; 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..0e9e7d544 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 @@ -7,87 +7,73 @@ // 2024 January 5 // Author: Yao Jing Quek +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.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'; -import 'package:rohd_devtools_extension/rohd_devtools/ui/module_tree_details_navbar.dart'; -import 'package:rohd_devtools_extension/rohd_devtools/ui/module_tree_card.dart'; -import 'package:rohd_devtools_extension/rohd_devtools/cubit/selected_module_cubit.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/cubit/cubits.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/ui/ui.dart'; +import 'package:rohd_devtools_widgets/rohd_devtools_widgets.dart'; +/// Split-pane page showing the module tree and selected module details. class TreeStructurePage extends StatelessWidget { + /// Creates the tree structure page. TreeStructurePage({ - super.key, required this.screenSize, + super.key, }); + /// Available size used to split the page into two panes. final Size screenSize; + /// Horizontal scroll controller for the tree pane. final ScrollController _horizontal = ScrollController(); + + /// Vertical scroll controller for the tree pane. final ScrollController _vertical = ScrollController(); + /// Boundary used when exporting the tree pane as PNG. + final GlobalKey _treeBoundaryKey = GlobalKey(); + @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 10.0), - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ - // Module Tree render here (Left Section) - SizedBox( - width: screenSize.width / 2, - height: screenSize.width / 2.6, - child: Card( - clipBehavior: Clip.antiAlias, + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('screenSize', screenSize)); + } + + @override + + /// Builds the split-pane tree structure page. + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.symmetric(vertical: 10), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildTreePane(context), + _buildDetailsPane(context), + ], + ), + ), + ); + + Widget _buildTreePane(BuildContext context) => SizedBox( + width: screenSize.width / 2, + child: Card( + clipBehavior: Clip.antiAlias, + 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: 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 + _buildTreeToolbar(context), Expanded( child: Scrollbar( thumbVisibility: true, controller: _vertical, child: SingleChildScrollView( - scrollDirection: Axis.vertical, controller: _vertical, child: Row( children: [ @@ -100,48 +86,8 @@ class TreeStructurePage extends StatelessWidget { 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'), - ); - } - }, + builder: (context, state) => + _buildTreeStateBody(state), ), ), ), @@ -154,46 +100,175 @@ class TreeStructurePage extends StatelessWidget { ], ), ), + Positioned( + right: 8, + bottom: 8, + child: ExportPngButton( + onPressed: () => captureBoundaryToPng( + context, + boundaryKey: _treeBoundaryKey, + filePrefix: 'module_tree', + ), + ), + ), + ], + ), + ), + ); + + Widget _buildTreeToolbar(BuildContext context) => Padding( + padding: const EdgeInsets.all(10), + 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(), + ), + ], + ), ), + ], + ), + ); + + Widget _buildTreeStateBody(RohdServiceState state) { + if (state is RohdServiceLoading) { + return const Center(child: CircularProgressIndicator()); + } - // 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, + if (state is RohdServiceLoaded) { + final futureModuleTree = state.treeModel; + if (futureModuleTree == null) { + return 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, + ), + ); + } + + return ModuleTreeCard(futureModuleTree: futureModuleTree); + } + + if (state is RohdServiceError) { + return Center(child: Text('Error: ${state.error}')); + } + + return const Center(child: Text('Unknown state')); + } + + Widget _buildDetailsPane(BuildContext context) => SizedBox( + width: screenSize.width / 2, + child: Card( + clipBehavior: Clip.antiAlias, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const ModuleTreeDetailsNavbar(), + Expanded( + child: BlocBuilder( + builder: (context, selectedTab) => IndexedStack( + index: selectedTab.index, children: [ - const ModuleTreeDetailsNavbar(), 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) { + return SignalDetailsCard(module: state.module); + } + + return const Center( + child: Text('No module selected'), + ); + }, + ), + ), + _buildFeaturePlaceholderPane( + context, + icon: platformIcon( + Icons.waves, + '🌊', + size: 36, + color: Theme.of(context).colorScheme.primary, + hasColorEmoji: kIsWeb, ), + title: 'Waveform', + message: 'Waveform content will be available ' + 'in a future release.', + ), + _buildFeaturePlaceholderPane( + context, + icon: const SchematicIcon(size: 36), + title: 'Schematic', + message: 'Schematic content will be available ' + 'in a future release.', ), ], ), ), ), - ), - ], + ], + ), + ), + ); + + Widget _buildFeaturePlaceholderPane( + BuildContext context, { + required Widget icon, + required String title, + required String message, + }) { + final colorScheme = Theme.of(context).colorScheme; + + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 360), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + icon, + const SizedBox(height: 12), + Text( + title, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + message, + textAlign: TextAlign.center, + style: TextStyle( + color: colorScheme.onSurface.withValues(alpha: 0.72), + ), + ), + ], + ), ), ), ); diff --git a/rohd_devtools_extension/lib/rohd_devtools_observer.dart b/rohd_devtools_extension/lib/rohd_devtools_observer.dart index 42b14b167..764de586e 100644 --- a/rohd_devtools_extension/lib/rohd_devtools_observer.dart +++ b/rohd_devtools_extension/lib/rohd_devtools_observer.dart @@ -11,9 +11,12 @@ import 'package:flutter_bloc/flutter_bloc.dart'; /// [BlocObserver] observe all state changes in the application. class RohdDevToolsObserver extends BlocObserver { + /// Creates the observer used by the app. const RohdDevToolsObserver(); @override + + /// Forwards bloc state changes to the default observer behavior. void onChange(BlocBase bloc, Change change) { super.onChange(bloc, change); } diff --git a/rohd_devtools_extension/linux/.gitignore b/rohd_devtools_extension/linux/.gitignore new file mode 100644 index 000000000..d3896c984 --- /dev/null +++ b/rohd_devtools_extension/linux/.gitignore @@ -0,0 +1 @@ +flutter/ephemeral diff --git a/rohd_devtools_extension/linux/CMakeLists.txt b/rohd_devtools_extension/linux/CMakeLists.txt new file mode 100644 index 000000000..5b5c5f30f --- /dev/null +++ b/rohd_devtools_extension/linux/CMakeLists.txt @@ -0,0 +1,138 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "rohd_devtools_extension") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "com.example.rohd_devtools_extension") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +# Load bundled libraries from the lib/ directory relative to the binary. +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Root filesystem for cross-building. +if(FLUTTER_TARGET_PLATFORM_SYSROOT) + set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) + set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +endif() + +# Define build configuration options. +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") +endif() + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_14) + target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) + +# Only the install-generated bundle's copy of the executable will launch +# correctly, since the resources must in the right relative locations. To avoid +# people trying to run the unbundled copy, put it in a subdirectory instead of +# the default top-level location. +set_target_properties(${BINARY_NAME} + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" +) + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# By default, "installing" just makes a relocatable bundle in the build +# directory. +set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +# Start with a clean build bundle directory every time. +install(CODE " + file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") + " COMPONENT Runtime) + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) + install(FILES "${bundled_library}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endforeach(bundled_library) + +# Copy the QuickJS bridge library from flutter_js plugin (if it exists). +# This is needed because the flutter_js plugin's CMakeLists.txt has an issue +# where it doesn't properly export the bundled libraries variable. +set(QUICKJS_BRIDGE_SOURCE "${CMAKE_CURRENT_SOURCE_DIR}/flutter/ephemeral/.plugin_symlinks/flutter_js/linux/shared/libquickjs_c_bridge_plugin.so") +if(EXISTS "${QUICKJS_BRIDGE_SOURCE}") + install(FILES "${QUICKJS_BRIDGE_SOURCE}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") + install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() diff --git a/rohd_devtools_extension/linux/flutter/CMakeLists.txt b/rohd_devtools_extension/linux/flutter/CMakeLists.txt new file mode 100644 index 000000000..d5bd01648 --- /dev/null +++ b/rohd_devtools_extension/linux/flutter/CMakeLists.txt @@ -0,0 +1,88 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.10) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. + +# Serves the same purpose as list(TRANSFORM ... PREPEND ...), +# which isn't available in 3.10. +function(list_prepend LIST_NAME PREFIX) + set(NEW_LIST "") + foreach(element ${${LIST_NAME}}) + list(APPEND NEW_LIST "${PREFIX}${element}") + endforeach(element) + set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) +endfunction() + +# === Flutter Library === +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) +pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) + +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "fl_basic_message_channel.h" + "fl_binary_codec.h" + "fl_binary_messenger.h" + "fl_dart_project.h" + "fl_engine.h" + "fl_json_message_codec.h" + "fl_json_method_codec.h" + "fl_message_codec.h" + "fl_method_call.h" + "fl_method_channel.h" + "fl_method_codec.h" + "fl_method_response.h" + "fl_plugin_registrar.h" + "fl_plugin_registry.h" + "fl_standard_message_codec.h" + "fl_standard_method_codec.h" + "fl_string_codec.h" + "fl_value.h" + "fl_view.h" + "flutter_linux.h" +) +list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") +target_link_libraries(flutter INTERFACE + PkgConfig::GTK + PkgConfig::GLIB + PkgConfig::GIO +) +add_dependencies(flutter flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CMAKE_CURRENT_BINARY_DIR}/_phony_ + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" + ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} +) diff --git a/rohd_devtools_extension/linux/flutter/generated_plugin_registrant.cc b/rohd_devtools_extension/linux/flutter/generated_plugin_registrant.cc new file mode 100644 index 000000000..f6f23bfe9 --- /dev/null +++ b/rohd_devtools_extension/linux/flutter/generated_plugin_registrant.cc @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include + +void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); + url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); +} diff --git a/rohd_devtools_extension/linux/flutter/generated_plugin_registrant.h b/rohd_devtools_extension/linux/flutter/generated_plugin_registrant.h new file mode 100644 index 000000000..e0f0a47bc --- /dev/null +++ b/rohd_devtools_extension/linux/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void fl_register_plugins(FlPluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/rohd_devtools_extension/linux/flutter/generated_plugins.cmake b/rohd_devtools_extension/linux/flutter/generated_plugins.cmake new file mode 100644 index 000000000..f16b4c342 --- /dev/null +++ b/rohd_devtools_extension/linux/flutter/generated_plugins.cmake @@ -0,0 +1,24 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + url_launcher_linux +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/rohd_devtools_extension/linux/runner/CMakeLists.txt b/rohd_devtools_extension/linux/runner/CMakeLists.txt new file mode 100644 index 000000000..e97dabc70 --- /dev/null +++ b/rohd_devtools_extension/linux/runner/CMakeLists.txt @@ -0,0 +1,26 @@ +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the application ID. +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Add dependency libraries. Add any application-specific dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) + +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") diff --git a/rohd_devtools_extension/linux/runner/main.cc b/rohd_devtools_extension/linux/runner/main.cc new file mode 100644 index 000000000..e7c5c5437 --- /dev/null +++ b/rohd_devtools_extension/linux/runner/main.cc @@ -0,0 +1,6 @@ +#include "my_application.h" + +int main(int argc, char** argv) { + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/rohd_devtools_extension/linux/runner/my_application.cc b/rohd_devtools_extension/linux/runner/my_application.cc new file mode 100644 index 000000000..307532496 --- /dev/null +++ b/rohd_devtools_extension/linux/runner/my_application.cc @@ -0,0 +1,144 @@ +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Called when first Flutter frame received. +static void first_frame_cb(MyApplication* self, FlView *view) +{ + gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view))); +} + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + // Use a header bar when running in GNOME as this is the common style used + // by applications and is the setup most users will be using (e.g. Ubuntu + // desktop). + // If running on X and not using GNOME then just use a traditional title bar + // in case the window manager does more exotic layout, e.g. tiling. + // If running on Wayland assume the header bar will work (may need changing + // if future cases occur). + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen* screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, "rohd_devtools_extension"); + gtk_header_bar_set_show_close_button(header_bar, TRUE); + gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + } else { + gtk_window_set_title(window, "rohd_devtools_extension"); + } + + gtk_window_set_default_size(window, 2100, 720); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments(project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + GdkRGBA background_color; + // Background defaults to black, override it here if necessary, e.g. #00000000 for transparent. + gdk_rgba_parse(&background_color, "#000000"); + fl_view_set_background_color(view, &background_color); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + // Show the window when Flutter renders. + // Requires the view to be realized so we can start rendering. + g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb), self); + gtk_widget_realize(GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication* application, gchar*** arguments, int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +// Implements GApplication::startup. +static void my_application_startup(GApplication* application) { + //MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application startup. + + G_APPLICATION_CLASS(my_application_parent_class)->startup(application); +} + +// Implements GApplication::shutdown. +static void my_application_shutdown(GApplication* application) { + //MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application shutdown. + + G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = my_application_local_command_line; + G_APPLICATION_CLASS(klass)->startup = my_application_startup; + G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + // Set the program name to the application ID, which helps various systems + // like GTK and desktop environments map this running application to its + // corresponding .desktop file. This ensures better integration by allowing + // the application to be recognized beyond its binary name. + g_set_prgname(APPLICATION_ID); + + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, + "flags", G_APPLICATION_NON_UNIQUE, + nullptr)); +} diff --git a/rohd_devtools_extension/linux/runner/my_application.h b/rohd_devtools_extension/linux/runner/my_application.h new file mode 100644 index 000000000..72271d5e4 --- /dev/null +++ b/rohd_devtools_extension/linux/runner/my_application.h @@ -0,0 +1,18 @@ +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, my_application, MY, APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(); + +#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/rohd_devtools_extension/packages/rohd_devtools_widgets/analysis_options.yaml b/rohd_devtools_extension/packages/rohd_devtools_widgets/analysis_options.yaml new file mode 100644 index 000000000..572dd239d --- /dev/null +++ b/rohd_devtools_extension/packages/rohd_devtools_widgets/analysis_options.yaml @@ -0,0 +1 @@ +include: package:lints/recommended.yaml diff --git a/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/rohd_devtools_widgets.dart b/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/rohd_devtools_widgets.dart new file mode 100644 index 000000000..335b890d3 --- /dev/null +++ b/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/rohd_devtools_widgets.dart @@ -0,0 +1,23 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// rohd_devtools_widgets.dart +// Barrel file for the rohd_devtools_widgets package. +// Combines help_api, export_png, and overlay_api into one package. +// +// 2026 April +// Author: Desmond Kirkpatrick + +// Help +export 'src/markdown_help_button.dart'; + +// Overlay +export 'src/app_bar_overlay.dart'; + +// PNG export +export 'src/capture_boundary.dart'; +export 'src/export_button.dart'; +export 'src/export_toast.dart'; +export 'src/save_png_stub.dart' + if (dart.library.io) 'src/save_png_native.dart' + if (dart.library.js_interop) 'src/save_png_web.dart'; diff --git a/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/app_bar_overlay.dart b/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/app_bar_overlay.dart new file mode 100644 index 000000000..ba210d5b2 --- /dev/null +++ b/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/app_bar_overlay.dart @@ -0,0 +1,168 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// app_bar_overlay.dart +// Auto-hiding overlay AppBar that slides in from the top edge. +// +// When [autoHide] is true, the bar slides out of view and reappears when +// the mouse enters a thin trigger zone along the top edge. When [autoHide] +// is false the bar behaves like a normal AppBar (always visible, pushes +// content down). +// +// Designed to be reusable across ROHD Wave Viewer, Schematic Viewer, etc. +// +// 2026 April +// Author: Desmond Kirkpatrick + +import 'package:flutter/material.dart'; + +/// Wraps a [body] widget and an [appBar] widget, where the AppBar +/// auto-hides by sliding up when [autoHide] is true. +/// +/// When [autoHide] is false the layout is a simple Column (AppBar + body), +/// matching normal Scaffold behaviour. +class AppBarOverlay extends StatefulWidget { + /// The AppBar-like widget to show/hide. + final PreferredSizeWidget appBar; + + /// The main content below the AppBar. + final Widget body; + + /// When true, the AppBar auto-hides and slides in on mouse hover. + /// When false, the AppBar is always visible. + final bool autoHide; + + /// Height of the invisible trigger zone along the top edge (pixels). + final double triggerHeight; + + /// Opacity of the overlay AppBar when shown (0.0–1.0). + final double panelOpacity; + + /// Duration of the slide animation. + final Duration animationDuration; + + const AppBarOverlay({ + super.key, + required this.appBar, + required this.body, + this.autoHide = false, + this.triggerHeight = 12, + this.panelOpacity = 0.92, + this.animationDuration = const Duration(milliseconds: 200), + }); + + @override + State createState() => _AppBarOverlayState(); +} + +class _AppBarOverlayState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + late final Animation _slideAnimation; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: widget.animationDuration, + ); + _slideAnimation = Tween( + begin: const Offset(0, -1), // fully off-screen above + end: Offset.zero, + ).animate(CurvedAnimation( + parent: _controller, + curve: Curves.easeOutCubic, + reverseCurve: Curves.easeInCubic, + )); + + // If not auto-hiding, snap open. + if (!widget.autoHide) { + _controller.value = 1.0; + } + } + + @override + void didUpdateWidget(covariant AppBarOverlay oldWidget) { + super.didUpdateWidget(oldWidget); + if (!widget.autoHide && oldWidget.autoHide) { + // Switched from auto-hide β†’ always visible: snap open. + _controller.forward(); + } else if (widget.autoHide && !oldWidget.autoHide) { + // Switched from always visible β†’ auto-hide: hide immediately. + _controller.reverse(); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _show() { + _controller.forward(); + } + + void _hide() { + if (!widget.autoHide) return; + _controller.reverse(); + } + + @override + Widget build(BuildContext context) { + // ── When not auto-hiding, simple column layout ── + if (!widget.autoHide) { + return Column( + children: [ + widget.appBar, + Expanded(child: widget.body), + ], + ); + } + + // ── Auto-hide mode: overlay with trigger zone ── + final appBarHeight = + widget.appBar.preferredSize.height + MediaQuery.of(context).padding.top; + + return Stack( + fit: StackFit.expand, + children: [ + // Body fills the entire area (no top inset β€” content goes edge-to-edge) + Positioned.fill(child: widget.body), + + // Trigger zone: thin invisible strip along the top edge + Positioned( + left: 0, + right: 0, + top: 0, + height: widget.triggerHeight, + child: MouseRegion( + onEnter: (_) => _show(), + opaque: false, // let clicks through when AppBar is hidden + child: const SizedBox.expand(), + ), + ), + + // Sliding overlay AppBar + Positioned( + left: 0, + right: 0, + top: 0, + height: appBarHeight, + child: SlideTransition( + position: _slideAnimation, + child: MouseRegion( + onEnter: (_) => _show(), + onExit: (_) => _hide(), + child: Opacity( + opacity: widget.panelOpacity, + child: widget.appBar, + ), + ), + ), + ), + ], + ); + } +} diff --git a/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/capture_boundary.dart b/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/capture_boundary.dart new file mode 100644 index 000000000..4512ed207 --- /dev/null +++ b/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/capture_boundary.dart @@ -0,0 +1,69 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// capture_boundary.dart +// One-call RepaintBoundary β†’ PNG export with toast feedback. +// +// 2026 April +// Author: Desmond Kirkpatrick + +import 'dart:math' as math; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart' show RenderRepaintBoundary; + +import 'package:rohd_devtools_widgets/rohd_devtools_widgets.dart' as export_png; + +/// Capture a [RepaintBoundary] identified by [boundaryKey], encode to PNG, +/// save/download, and show a toast. +/// +/// [filePrefix] is used as the first part of the file name +/// (e.g. `"schematic"` β†’ `schematic_1713052800000.png`). +/// +/// Returns `true` if the export succeeded. +Future captureBoundaryToPng( + BuildContext context, { + required GlobalKey boundaryKey, + String filePrefix = 'export', +}) async { + final boundary = + boundaryKey.currentContext?.findRenderObject() as RenderRepaintBoundary?; + if (boundary == null) { + debugPrint('[ExportPng] No RepaintBoundary found'); + return false; + } + + final pixelRatio = math.min( + 3.0, + MediaQuery.of(context).devicePixelRatio, + ); + final image = await boundary.toImage(pixelRatio: pixelRatio); + final byteData = await image.toByteData(format: ui.ImageByteFormat.png); + image.dispose(); + + if (byteData == null) { + debugPrint('[ExportPng] Failed to encode PNG'); + return false; + } + + final pngBytes = byteData.buffer.asUint8List(); + final fileName = '${filePrefix}_${DateTime.now().millisecondsSinceEpoch}.png'; + + try { + final savedPath = await export_png.savePngBytes(pngBytes, fileName); + final msg = + savedPath != null ? 'Saved: $savedPath' : 'Downloaded $fileName'; + debugPrint('[ExportPng] $msg'); + if (context.mounted) { + export_png.showExportToast(context, msg); + } + return true; + } on Object catch (e) { + debugPrint('[ExportPng] Export failed: $e'); + if (context.mounted) { + export_png.showExportToast(context, 'Export failed: $e'); + } + return false; + } +} diff --git a/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/export_button.dart b/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/export_button.dart new file mode 100644 index 000000000..4c0dd1327 --- /dev/null +++ b/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/export_button.dart @@ -0,0 +1,53 @@ +// 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 { + /// Called when the export button is tapped. + final VoidCallback onPressed; + + /// Tooltip text shown on hover. + final String tooltip; + + const ExportPngButton({ + super.key, + required this.onPressed, + this.tooltip = 'Export as PNG', + }); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Tooltip( + message: tooltip, + child: Material( + color: cs.surface.withAlpha(200), + shape: const CircleBorder(), + elevation: 2, + child: InkWell( + customBorder: const CircleBorder(), + onTap: onPressed, + child: Padding( + padding: const EdgeInsets.all(8), + child: Icon( + Icons.camera_alt_outlined, + size: 20, + color: cs.onSurface, + ), + ), + ), + ), + ); + } +} diff --git a/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/export_toast.dart b/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/export_toast.dart new file mode 100644 index 000000000..e962a6dd0 --- /dev/null +++ b/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/export_toast.dart @@ -0,0 +1,48 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// export_toast.dart +// Overlay-based toast that works without a Scaffold ancestor. +// +// 2026 April +// Author: Desmond Kirkpatrick + +import 'dart:async'; + +import 'package:flutter/material.dart'; + +/// Show a brief floating toast at the bottom of the screen. +/// +/// Works without a [Scaffold] ancestor by inserting directly into the +/// root [Overlay]. Auto-removes after [duration]. +void showExportToast( + BuildContext context, + String message, { + Duration duration = const Duration(seconds: 3), +}) { + final overlay = Overlay.of(context, rootOverlay: true); + late OverlayEntry entry; + entry = OverlayEntry( + builder: (ctx) => Positioned( + bottom: 32, + left: 0, + right: 0, + child: Center( + child: Material( + elevation: 4, + borderRadius: BorderRadius.circular(8), + color: Colors.grey.shade800, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Text( + message, + style: const TextStyle(color: Colors.white, fontSize: 13), + ), + ), + ), + ), + ), + ); + overlay.insert(entry); + Timer(duration, entry.remove); +} diff --git a/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/markdown_help_button.dart b/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/markdown_help_button.dart new file mode 100644 index 000000000..713e6b0f1 --- /dev/null +++ b/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/markdown_help_button.dart @@ -0,0 +1,486 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// markdown_help_button.dart +// A generic help button driven by a markdown asset file. +// +// The markdown file contains two sections separated by : +// - Above the marker: plain-text tooltip shown on hover +// - Below the marker: markdown rendered in the click-open dialog +// +// The markdown file is also directly viewable in any markdown previewer +// (GitHub, VS Code, etc.) because both sections are valid markdown and +// the separator is an invisible HTML comment. +// +// Details section format: +// ## Heading β†’ section heading +// | Key | Description | β†’ key–description entry row (markdown table) +// Paragraphs β†’ plain-text description +// +// 2026 March +// Author: Desmond Kirkpatrick + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart' show rootBundle; + +/// A help button that loads its content from a markdown asset file. +/// +/// The markdown file must contain a `` marker and a +/// `` marker. Text between those markers becomes the +/// hover tooltip; text after `` is rendered as the +/// click-open dialog body. +/// +/// The first line of the file (an `# H1` heading) is used as the dialog +/// title. Everything before `` is ignored at runtime +/// (it serves as the visible title when previewing the raw markdown). +/// +/// ### Markdown file layout +/// +/// ```markdown +/// # 🌳 My Tool β€” Help ← dialog title (H1) +/// +/// +/// +/// Short keybinding summary ← hover tooltip (plain text) +/// shown on mouse hover. +/// +/// +/// +/// ## Section ← dialog section heading +/// +/// | Key | Description | ← table header (required before rows) +/// |-----|-------------| +/// | F | Fit to canvas | ← key–description entry +/// +/// Any paragraph text. ← rendered as body text +/// ``` +class MarkdownHelpButton extends StatefulWidget { + /// Path to the markdown asset file (e.g. `assets/help/my_help.md`). + final String assetPath; + + /// Whether the current theme is dark mode. + final bool isDark; + + /// Optional override for the button label (defaults to `❓`). + final String label; + + /// Optional widget to use as the button icon instead of [label]. + /// + /// When non-null, this widget is displayed instead of `Text(label)`. + /// Use this on platforms where the emoji [label] would not render + /// (e.g. Linux without NotoColorEmoji), passing an `Icon(Icons.help_outline)` + /// or similar Material icon. + final Widget? labelIcon; + + /// Optional package name that owns the asset. + /// + /// When non-null the actual asset path becomes + /// `packages/$package/$assetPath`, which is how Flutter resolves assets + /// declared in dependency packages. + final String? package; + + /// Optional widget shown before the dialog title text. + /// + /// Use this to display a custom icon (e.g. a `CustomPaint` widget) + /// next to the dialog title instead of relying on emoji characters + /// that may not render on all platforms. + final Widget? titleIcon; + + /// Optional text substitutions applied to the markdown before parsing. + /// + /// Each key `K` replaces all occurrences of `{{K}}` in the raw markdown + /// with the corresponding value. For example: + /// ```dart + /// substitutions: {'VERSION': '1.2.3'} + /// ``` + /// will replace `{{VERSION}}` β†’ `1.2.3` in the loaded asset. + final Map? substitutions; + + /// Create a [MarkdownHelpButton]. + const MarkdownHelpButton({ + required this.assetPath, + required this.isDark, + this.label = '❓', + this.labelIcon, + this.package, + this.titleIcon, + this.substitutions, + super.key, + }); + + @override + State createState() => _MarkdownHelpButtonState(); +} + +class _MarkdownHelpButtonState extends State { + /// Parsed help content, loaded once from the asset. + _HelpContent? _content; + + @override + void initState() { + super.initState(); + _loadContent(); + } + + @override + void didUpdateWidget(MarkdownHelpButton oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.assetPath != widget.assetPath || + oldWidget.package != widget.package) { + _loadContent(); + } + } + + Future _loadContent() async { + try { + String raw; + if (widget.package != null) { + // Try the package-qualified path first (works when embedded as a + // dependency in a host app), then fall back to the bare asset path + // (standalone mode). This order avoids a spurious 404 on the web + // when the bare path doesn't exist. + // Use catch-all because rootBundle.loadString throws FlutterError + // (an Error, not Exception) when the asset is missing. + try { + raw = await rootBundle + .loadString('packages/${widget.package}/${widget.assetPath}'); + // ignore: avoid_catches_without_on_clauses + } catch (_) { + raw = await rootBundle.loadString(widget.assetPath); + } + } else { + raw = await rootBundle.loadString(widget.assetPath); + } + // Apply substitutions before parsing. + final subs = widget.substitutions; + if (subs != null) { + for (final entry in subs.entries) { + raw = raw.replaceAll('{{${entry.key}}}', entry.value); + } + } + if (mounted) { + setState(() { + _content = _HelpContent.parse(raw); + }); + } + // ignore: avoid_catches_without_on_clauses + } catch (e) { + debugPrint('Failed to load help asset: $e'); + if (mounted) { + setState(() { + _content = _HelpContent.parse( + '# Help unavailable\n\n\n\n' + 'Help content could not be loaded.\n\n\n\n' + 'Error: $e', + ); + }); + } + } + } + + @override + Widget build(BuildContext context) { + final isDark = widget.isDark; + final tooltip = _content?.tooltip ?? 'Loading help…'; + + return Tooltip( + message: tooltip, + decoration: BoxDecoration( + color: isDark ? const Color(0xFF1E1E1E) : const Color(0xFFF5F5F5), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: isDark ? Colors.white24 : Colors.black12, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: isDark ? 0.4 : 0.15), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + textStyle: TextStyle( + fontSize: 12, + fontFamily: 'monospace', + color: isDark ? Colors.white : Colors.black87, + height: 1.4, + ), + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () { + if (_content != null) { + _showHelpDialog(context, _content!, + isDark: isDark, titleIcon: widget.titleIcon); + } + }, + child: Padding( + padding: const EdgeInsets.all(8), + child: widget.labelIcon ?? + Text(widget.label, + style: const TextStyle(fontSize: 18, inherit: false)), + ), + ), + ), + ); + } + + /// Show the help dialog with parsed markdown content. + static void _showHelpDialog( + BuildContext context, + _HelpContent content, { + required bool isDark, + Widget? titleIcon, + }) { + final bgColor = isDark ? const Color(0xFF252526) : Colors.white; + final fgColor = isDark ? Colors.white : Colors.black87; + final headingColor = isDark ? Colors.blue[200]! : Colors.blue[800]!; + final keyColor = isDark ? Colors.amber[200]! : Colors.amber[900]!; + final dividerColor = isDark ? Colors.white24 : Colors.black12; + + final widgets = []; + for (final block in content.detailBlocks) { + if (block is _HeadingBlock) { + widgets.add(Padding( + padding: const EdgeInsets.only(top: 16, bottom: 4), + child: Text(block.text, + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + color: headingColor, + )), + )); + } else if (block is _EntryBlock) { + widgets.add(Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 200, + child: Text(block.key, + style: TextStyle( + fontFamily: 'monospace', + fontSize: 13, + color: keyColor, + )), + ), + Expanded( + child: Text(block.description, + style: TextStyle(fontSize: 13, color: fgColor)), + ), + ], + ), + )); + } else if (block is _ParagraphBlock) { + widgets.add(Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: + Text(block.text, style: TextStyle(fontSize: 13, color: fgColor)), + )); + } + } + + showDialog( + context: context, + builder: (ctx) => Dialog( + backgroundColor: bgColor, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 600, maxHeight: 600), + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Title row + Row( + children: [ + if (titleIcon != null) ...[ + titleIcon, + const SizedBox(width: 10), + ], + Expanded( + child: Text(content.title, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: fgColor, + )), + ), + IconButton( + icon: Icon(Icons.close, color: fgColor, size: 20), + onPressed: () => Navigator.of(ctx).pop(), + ), + ], + ), + Divider(color: dividerColor), + // Scrollable content + Flexible( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: widgets, + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Parsed help content model +// --------------------------------------------------------------------------- + +/// Parsed representation of a help markdown file. +class _HelpContent { + /// Dialog title (from the `# H1` heading). + final String title; + + /// Plain-text tooltip (between `` and ``). + final String tooltip; + + /// Parsed detail blocks (headings, entries, paragraphs). + final List<_DetailBlock> detailBlocks; + + _HelpContent({ + required this.title, + required this.tooltip, + required this.detailBlocks, + }); + + /// Parse a raw markdown string into [_HelpContent]. + factory _HelpContent.parse(String raw) { + const tooltipMarker = ''; + const detailsMarker = ''; + + final tooltipIdx = raw.indexOf(tooltipMarker); + final detailsIdx = raw.indexOf(detailsMarker); + + // Extract title from the first # heading. + String title = 'Help'; + final titleMatch = RegExp(r'^#\s+(.+)$', multiLine: true).firstMatch(raw); + if (titleMatch != null) { + title = titleMatch.group(1)!.trim(); + } + + // Extract tooltip text. + String tooltip = ''; + if (tooltipIdx >= 0 && detailsIdx > tooltipIdx) { + tooltip = + raw.substring(tooltipIdx + tooltipMarker.length, detailsIdx).trim(); + } + + // Parse detail blocks. + final detailBlocks = <_DetailBlock>[]; + if (detailsIdx >= 0) { + final detailsRaw = raw.substring(detailsIdx + detailsMarker.length); + detailBlocks.addAll(_parseDetails(detailsRaw)); + } + + return _HelpContent( + title: title, + tooltip: tooltip, + detailBlocks: detailBlocks, + ); + } + + /// Parse the details section into blocks. + static List<_DetailBlock> _parseDetails(String raw) { + final blocks = <_DetailBlock>[]; + final lines = raw.split('\n'); + + for (int i = 0; i < lines.length; i++) { + final line = lines[i]; + final trimmed = line.trim(); + + // Skip empty lines + if (trimmed.isEmpty) { + continue; + } + + // ## Heading + if (trimmed.startsWith('## ')) { + blocks.add(_HeadingBlock(trimmed.substring(3).trim())); + continue; + } + + // Table separator row (|---|---|) β€” skip + if (RegExp(r'^\|[\s\-:|]+\|$').hasMatch(trimmed)) { + continue; + } + + // Table header row (| Key | Description |) β€” skip + if (trimmed.startsWith('|') && + trimmed.endsWith('|') && + i + 1 < lines.length && + RegExp(r'^\|[\s\-:|]+\|$').hasMatch(lines[i + 1].trim())) { + continue; + } + + // Table data row (| key | description |) + if (trimmed.startsWith('|') && trimmed.endsWith('|')) { + final cells = trimmed + .substring(1, trimmed.length - 1) // strip outer pipes + .split('|') + .map((c) => c.trim()) + .toList(); + if (cells.length >= 2) { + blocks.add(_EntryBlock( + key: _stripInlineCode(cells[0]), + description: cells[1], + )); + continue; + } + } + + // Plain paragraph text (collect consecutive non-empty lines) + final para = StringBuffer(trimmed); + while (i + 1 < lines.length && lines[i + 1].trim().isNotEmpty) { + final next = lines[i + 1].trim(); + // Stop at headings, table rows, or markers + if (next.startsWith('## ') || + next.startsWith('|') || + next.startsWith(' +Connection + || Pause Pause VM connection + β–· Resume Resume VM connection + πŸ”— Connect Open connection dialog + πŸ”ƒ Refresh Reload module tree + 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 +Waveform Viewer (details tab) + ← / β†’ Pan left / right + Shift+↑ / ↓ Zoom in / out + Shift+Scroll Zoom at cursor + F Fit to viewport + Click waveform Place time marker + +Schematic Viewer (details tab) + Scroll Zoom in / out + F Fit to canvas + Ctrl+F Search blocks & wires + Click +/βˆ’/⊞ Expand / collapse blocks + +Snapshots + πŸ“Έ Camera Capture all signal values + πŸŽ₯ Video Auto-track on breakpoints + +Go to Source (requires ROHD Extension) + Right-click signal Open source location picker + DTD URI Copy from DevTools status bar + ROHD Extension Activate in VS Code for source nav +## Connection Management + +| Key | Description | +| --- | --- | +| Pause | Pause the VM connection | +| Resume | Resume the VM connection | +| πŸ”— Connect | Open connection dialog to attach to a VM | +| πŸ”ƒ Refresh | Reload module tree from the VM | + ## Module Tree (left panel) | Key | Description | @@ -24,11 +56,79 @@ Details (right panel) | πŸ”ƒ Refresh | Reload hierarchy from the VM | | Type in search | Filter modules by name | -## Signal Details (right panel) +## Waveform Viewer + +| Key | Description | +| --- | --- | +| ← / β†’ | Pan waveform left / right | +| ↑ / ↓ | Scroll signal list up / down | +| Shift + ↑ / ↓ | Zoom in / zoom out | +| Shift + Scroll | Zoom in / out at cursor | +| Scroll wheel | Pan horizontally | +| F | Fit entire waveform to viewport | +| Ctrl + Drag | Draw a time region to zoom into | +| Click waveform | Place time marker | +| ← / β†’ (focused) | Jump to previous / next edge | +| Click signal name | Add signal to monitor list | +| DEL | Remove focused signals from monitor list | + +## Schematic Viewer | 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 | +| Scroll wheel | Zoom in / out at cursor | +| F | Fit entire schematic to canvas | +| Ctrl + F / ⌘F | Open search overlay | +| Click + | Fully expand block | +| Click βˆ’ | Collapse block | +| Click ⊞ | Expand non-primitive children only | +| Click port β–Ά | Reveal the connected block | +| Shift + Click β–Ά | Expand through trivial gates | +| Double-click | Zoom to block or wire | +| Drag | Pan canvas | + +## Snapshots + +| Key | Description | +| --- | --- | +| πŸ“Έ Camera | Capture all signal values at current time | +| πŸŽ₯ Video | Auto-track signal values on breakpoints | + +## Go to Source β€” ROHD Extension + +The **Go to Source** feature lets you jump from a signal in the waveform or +schematic viewer directly to the Dart source code that defines it. It +requires the **ROHD Extension** for VS Code. + +### Setup + +1. **Install the ROHD Extension** β€” open the Extensions panel in VS Code and + install `rohd-extension` (the `.vsix` from the `rohd_extension/` + directory). +2. **Copy the DTD URI** β€” when the DevTools app connects to a running + ROHD simulation, the Dart Tooling Daemon (DTD) URI is shown in the + connection log (e.g. `ws://127.0.0.1:43369/xiiRI61v9qc=`). Copy this + URI. +3. **Activate the ROHD Extension** β€” open the VS Code Command Palette + (`Ctrl+Shift+P`) and run **ROHD: Connect to DTD**. Paste the DTD URI + when prompted. The extension registers source-navigation services + (`rohd.goToSource`, `rohd.resolveFrames`) on the DTD so the DevTools + app can request editor navigation. + +### Using Go to Source + +| Action | Description | +| --- | --- | +| Right-click signal (waveform) | Triggers Go to Source for that signal | +| Right-click signal (schematic) | Triggers Go to Source for that signal | +| Single source frame | Editor opens the file and line automatically | +| Multiple source frames | A popup picker appears listing each call site | + +When the ROHD compiler records multiple source locations for a signal +(e.g. a port wired through several `build()` methods), a **popup menu** +appears near your click. Each entry shows the enclosing method name and +file location (e.g. `Serializer.build() β€” serializer.dart:55`). Select +an entry to open that location in VS Code. + +If the ROHD Extension is not connected, Go to Source falls back to opening +the outermost (first) frame automatically via the VM service. diff --git a/tool/gh_actions/devtool/install_devtools.sh b/tool/gh_actions/devtool/install_devtools.sh index bef68c86e..b36f92391 100755 --- a/tool/gh_actions/devtool/install_devtools.sh +++ b/tool/gh_actions/devtool/install_devtools.sh @@ -4,8 +4,11 @@ # SPDX-License-Identifier: BSD-3-Clause # # install_devtools.sh -# Build the ROHD DevTools extension web artifact: +# Build all ROHD DevTools web artifacts: # extension/devtools/ – DevTools extension (iframe in Chrome DevTools) +# extension/devtools/debugger/ – Standalone debugger web app +# extension/devtools/waves/ – Standalone waveform viewer +# extension/devtools/schematics/ – Standalone schematic viewer # # Usage (from repo root): # bash tool/gh_actions/devtool/install_devtools.sh @@ -16,6 +19,9 @@ set -euo pipefail DEST="../extension/devtools" +WAVE_PKG="rohd-wave-viewer/web/pkg" +STANDALONE_VIEWS=(debugger waves schematics) +WASM_VIEWS=(debugger waves) # ── Helper: strip service-worker / PWA artifacts from a deployed build ── # @@ -42,17 +48,105 @@ SWEOF sed -i 's||\n|' "$dir/index.html" # Strip serviceWorkerSettings from flutter_bootstrap.js - if [ -f "$dir/flutter_bootstrap.js" ]; then - python3 -c " + python3 -c " import re p = '$dir/flutter_bootstrap.js' with open(p) as f: c = f.read() c = re.sub(r'_flutter\.loader\.load\(\{[^}]*serviceWorkerSettings[^}]*\{[^}]*\}[^}]*\}\);', '_flutter.loader.load();', c, flags=re.DOTALL) with open(p, 'w') as f: f.write(c) " +} + +# ── Helper: copy WASM pkg into a deployed build ── +copy_wasm_pkg() { + local dir="$1" + if [ -d "$WAVE_PKG" ]; then + echo " Copying WASM pkg into $(basename "$dir")..." + rm -rf "$dir/pkg" + cp -r "$WAVE_PKG" "$dir/pkg" + elif [ -d "$DEST/build/pkg" ]; then + echo " Copying WASM pkg from extension build into $(basename "$dir")..." + rm -rf "$dir/pkg" + cp -r "$DEST/build/pkg" "$dir/pkg" fi } +# ── Helper: copy ELK JS assets into a deployed build ── +# build_and_copy / flutter build strips web/ |' "$DEST/build/index.html" echo " Extension deployed to $DEST/ (web assets in $DEST/build/)" + +# ── 2. Debugger web app ──────────────────────────────────────────────── +echo "" +echo "════════════════════════════════════════════════════════════" +echo " 2/4 Building debugger web app..." +echo "════════════════════════════════════════════════════════════" + +flutter build web --release --base-href=/debugger/ \ + --pwa-strategy=none \ + --target=lib/main_standalone.dart \ + --output=build/web_standalone + +rm -rf "$DEST/debugger" +cp -r build/web_standalone "$DEST/debugger" + +copy_elk_assets "build/web_standalone" "$DEST/debugger" +copy_wasm_pkg "$DEST/debugger" +strip_sw_artifacts "$DEST/debugger" "debugger" + +echo " Debugger deployed to $DEST/debugger/" + +# ── 3/4. Standalone widget builds ────────────────────────────────────── +build_standalone_widget \ + "3/4" \ + "wave viewer widget" \ + "waves" \ + "rohd-wave-viewer" \ + "build/web_waves" \ + "no" \ + "yes" + +build_standalone_widget \ + "4/4" \ + "schematic viewer widget" \ + "schematics" \ + "rohd-schematic-viewer" \ + "build/web_schematics" \ + "yes" \ + "no" + +# ── Deduplicate: symlink build/{waves,schematics,debugger} ────────────── +# DevTools only serves build/ for extensions. Instead of copying ~106 MB +# of identical files, we patch the top-level viewers to use a relative +# (works for both the standalone Python server and +# DevTools iframe) and symlink from build/. +echo "" +echo " Symlinking standalone viewers into build/ for DevTools access..." + +for tool_dir in "${STANDALONE_VIEWS[@]}"; do + if [ -d "$DEST/$tool_dir" ]; then + # Patch base href to ./ β€” works in both standalone and DevTools contexts + sed -i 's|||' "$DEST/$tool_dir/index.html" + rm -rf "$DEST/build/$tool_dir" + ln -sfn "../$tool_dir" "$DEST/build/$tool_dir" + echo " build/$tool_dir/ β†’ ../$tool_dir/ (symlink, base href=./)" + fi +done + +# ── Deduplicate: shared canvaskit/ ────────────────────────────────────── +# Flutter embeds a 26 MB canvaskit/ in every web build. All copies are +# identical. Keep build/canvaskit/ as canonical and symlink from viewers. +echo "" +echo " Deduplicating canvaskit/ (~155 MB saved)..." + +for tool_dir in "${STANDALONE_VIEWS[@]}"; do + if [ -d "$DEST/$tool_dir/canvaskit" ]; then + rm -rf "$DEST/$tool_dir/canvaskit" + ln -sfn "../build/canvaskit" "$DEST/$tool_dir/canvaskit" + echo " $tool_dir/canvaskit/ β†’ build/canvaskit/ (symlink)" + fi +done + +# ── Deduplicate: shared pkg/ (WASM) ──────────────────────────────────── +# The wellen WASM bridge (~1 MB) is copied into multiple viewers. +# Keep build/pkg/ as canonical and symlink from viewers that have it. +echo "" +echo " Deduplicating pkg/ (WASM)..." + +for tool_dir in "${WASM_VIEWS[@]}"; do + if [ -d "$DEST/$tool_dir/pkg" ]; then + rm -rf "$DEST/$tool_dir/pkg" + ln -sfn "../build/pkg" "$DEST/$tool_dir/pkg" + echo " $tool_dir/pkg/ β†’ build/pkg/ (symlink)" + fi +done + +# ── Summary ───────────────────────────────────────────────────────────── +echo "" +echo "════════════════════════════════════════════════════════════" +echo " All builds complete. Deployed to extension/devtools/:" +echo " / - DevTools extension (iframe)" +echo " /debugger/ - Standalone debugger with DTD/VM connection" +echo " /waves/ - Standalone waveform viewer" +echo " /schematics/ - Standalone schematic viewer" +echo "════════════════════════════════════════════════════════════" \ No newline at end of file From f8654aea400444182a76f27a50189d5d7d95c2ff Mon Sep 17 00:00:00 2001 From: "Desmond A. Kirkpatrick" Date: Fri, 19 Jun 2026 15:25:19 -0700 Subject: [PATCH 10/19] Match folded files to integration merge output --- rohd_devtools_extension/.vscode/tasks.json | 218 +++++++++------ rohd_devtools_extension/Makefile | 248 +----------------- .../assets/help/details_help.md | 5 +- .../assets/help/devtools_help.md | 118 +-------- tool/gh_actions/devtool/install_devtools.sh | 222 +--------------- 5 files changed, 154 insertions(+), 657 deletions(-) diff --git a/rohd_devtools_extension/.vscode/tasks.json b/rohd_devtools_extension/.vscode/tasks.json index 84e3b8b6f..1d5530e98 100644 --- a/rohd_devtools_extension/.vscode/tasks.json +++ b/rohd_devtools_extension/.vscode/tasks.json @@ -1,93 +1,137 @@ { - "version": "2.0.0", - "tasks": [ - { - "label": "Run: Web Standalone (port 9099)", - "type": "shell", - "command": "make web-run", - "isBackground": true, - "problemMatcher": { - "pattern": { - "regexp": "^$" - }, - "background": { - "activeOnStart": true, - "beginsPattern": "^Launching", - "endsPattern": "is being served at" - } - }, - "presentation": { - "reveal": "always", - "panel": "dedicated", - "focus": true - }, - "detail": "Runs standalone web app on port 9099" + "version": "2.0.0", + "tasks": [ + { + "label": "Run: Web Standalone (Debug, port 9099)", + "type": "shell", + "command": "flutter run -d web-server --web-port=9099 --web-hostname=0.0.0.0 lib/main_standalone.dart", + "isBackground": true, + "problemMatcher": { + "pattern": { + "regexp": "^$" }, - { - "label": "Run: Linux Standalone (Debug)", - "type": "shell", - "command": "make widget-prepare assets/.staged && flutter run -d linux lib/main_standalone.dart", - "isBackground": true, - "problemMatcher": { - "pattern": { - "regexp": "^$" - }, - "background": { - "activeOnStart": true, - "beginsPattern": "^Launching", - "endsPattern": "^Application finished" - } - }, - "presentation": { - "reveal": "always", - "panel": "dedicated", - "focus": true - }, - "detail": "Runs on Linux with default rendering" + "background": { + "activeOnStart": true, + "beginsPattern": "^Launching", + "endsPattern": "is being served at" + } + }, + "presentation": { + "reveal": "always", + "panel": "dedicated", + "focus": true + }, + "detail": "Runs standalone web app in debug mode on port 9099" + }, + { + "label": "Run: Web Standalone (Release, port 9099)", + "type": "shell", + "command": "flutter run --release -d web-server --web-port=9099 --web-hostname=0.0.0.0 lib/main_standalone.dart", + "isBackground": true, + "problemMatcher": { + "pattern": { + "regexp": "^$" + }, + "background": { + "activeOnStart": true, + "beginsPattern": "^Launching", + "endsPattern": "is being served at" + } + }, + "presentation": { + "reveal": "always", + "panel": "dedicated", + "focus": true + }, + "detail": "Runs standalone web app in release mode on port 9099" + }, + { + "label": "Run: Linux Standalone (Debug)", + "type": "shell", + "command": "flutter run -d linux lib/main_standalone.dart", + "isBackground": true, + "problemMatcher": { + "pattern": { + "regexp": "^$" + }, + "background": { + "activeOnStart": true, + "beginsPattern": "^Launching", + "endsPattern": "^Application finished" + } + }, + "presentation": { + "reveal": "always", + "panel": "dedicated", + "focus": true + }, + "detail": "Runs standalone Linux app in debug mode" + }, + { + "label": "Run: Linux Standalone (Debug, software rendering)", + "type": "shell", + "command": "flutter run -d linux --enable-software-rendering lib/main_standalone.dart", + "isBackground": true, + "problemMatcher": { + "pattern": { + "regexp": "^$" + }, + "background": { + "activeOnStart": true, + "beginsPattern": "^Launching", + "endsPattern": "^Application finished" + } + }, + "presentation": { + "reveal": "always", + "panel": "dedicated", + "focus": true + }, + "detail": "Runs standalone Linux app in debug mode with software rendering" + }, + { + "label": "Run: Linux Standalone (Release)", + "type": "shell", + "command": "flutter run --release -d linux lib/main_standalone.dart", + "isBackground": true, + "problemMatcher": { + "pattern": { + "regexp": "^$" }, - { - "label": "Run: Linux Standalone (Debug, software render)", - "type": "shell", - "command": "make widget-prepare assets/.staged && flutter run -d linux lib/main_standalone.dart --enable-software-rendering", - "isBackground": true, - "problemMatcher": { - "pattern": { - "regexp": "^$" - }, - "background": { - "activeOnStart": true, - "beginsPattern": "^Launching", - "endsPattern": "^Application finished" - } - }, - "presentation": { - "reveal": "always", - "panel": "dedicated", - "focus": true - }, - "detail": "Runs on Linux with software rendering" + "background": { + "activeOnStart": true, + "beginsPattern": "^Launching", + "endsPattern": "^Application finished" + } + }, + "presentation": { + "reveal": "always", + "panel": "dedicated", + "focus": true + }, + "detail": "Runs standalone Linux app in release mode" + }, + { + "label": "Run: Linux Standalone (Release, software rendering)", + "type": "shell", + "command": "flutter run --release -d linux --enable-software-rendering lib/main_standalone.dart", + "isBackground": true, + "problemMatcher": { + "pattern": { + "regexp": "^$" }, - { - "label": "Run: Web Standalone - Fast (port 9099, profile mode)", - "type": "shell", - "command": "make web-run-profile", - "isBackground": true, - "problemMatcher": { - "pattern": { - "regexp": "^$" - }, - "background": { - "activeOnStart": true, - "beginsPattern": "^Launching", - "endsPattern": "is being served at" - } - }, - "presentation": { - "reveal": "always", - "panel": "dedicated", - "focus": true - }, - "detail": "Faster startup in profile mode (less overhead, limited debugging)" + "background": { + "activeOnStart": true, + "beginsPattern": "^Launching", + "endsPattern": "^Application finished" } - ] + }, + "presentation": { + "reveal": "always", + "panel": "dedicated", + "focus": true + }, + "detail": "Runs standalone Linux app in release mode with software rendering" + } + ] } diff --git a/rohd_devtools_extension/Makefile b/rohd_devtools_extension/Makefile index 65c9f749f..19c7ac8ca 100644 --- a/rohd_devtools_extension/Makefile +++ b/rohd_devtools_extension/Makefile @@ -1,255 +1,23 @@ -# Makefile for ROHD DevTools Extension -# Restructured to leverage widget Makefiles (rohd-schematic-viewer, rohd-wave-viewer) -# Minimal redundancy: each widget manages its own build, assets copied at top level +# Minimal build entrypoints for the upstream standalone extension package. ROOT := $(shell pwd) -STAMP_DIR := $(ROOT)/build/.stamp -# Widget list - each has its own Makefile -WIDGETS := rohd-schematic-viewer rohd-wave-viewer - -# Source file patterns for dependency tracking -DART_SOURCES := $(shell find lib -name '*.dart' 2>/dev/null) $(shell for w in $(WIDGETS); do find $$w/lib -name '*.dart' 2>/dev/null; done) -WIDGET_SOURCES := $(shell for w in $(WIDGETS); do find $$w -name '*.dart' -o -name '*.js' -o -name '*.ts' 2>/dev/null; done) - -.PHONY: all build-linux build-web clean clean-linux clean-web clean-stamps web-run web-run-profile help force real-clean extension install-local install-remote test dart-bindings widget-prepare $(addprefix widget-,$(WIDGETS)) +.PHONY: all linux clean-linux help all: linux -# Ensure all widget dependencies are ready (each widget manages its own bindings) -# This delegates to widget Makefiles which handle language-specific bindings. -dart-bindings: - @for widget in $(WIDGETS); do \ - echo "Ensuring $$widget bindings..."; \ - cd $(ROOT)/$$widget && $(MAKE) dart-bindings 2>/dev/null || true; \ - done - -# Run tests on Chrome (required because devtools_extensions uses dart:js_interop). -# Each test file gets its own `flutter test` invocation because the DDC -# incremental compiler hangs when loading a second test file into the same -# Chrome tab. Lightweight cubit tests + widget tests go here (~1 min cached). -# The filter_bank integration test imports the full ROHD simulation stack -# which takes several minutes to DDC-compile β€” run via `make test-integration`. -test: assets/.staged dart-bindings - @flutter test --platform chrome test/modules/cubit/cubit_test.dart - @flutter test --platform chrome test/modules/tree_structure/tree_structure_page_test.dart - @flutter test --platform chrome test/modules/tree_structure/model_tree_card_test.dart - -# Full integration test using the real FilterBank ROHD example in loopback mode. -# First run after `flutter clean` takes ~5 min (DDC compiles ROHD β†’ JS); cached runs ~30s. -test-integration: assets/.staged dart-bindings - @flutter test --platform chrome test/integration/filter_bank_integration_test.dart - -# Run everything. -test-all: test test-integration - -# Stage assets from all widgets - copies their assets to canonical assets/ location -# Each widget contributes its own assets; top-level script merges them. -# Flutter's pubspec.yaml declares assets/ and automatically bundles into: -# - build/web/assets/ (for web builds) -# - build/linux/assets/ (for Linux native builds) -# Output marker: assets/.staged (to detect when asset sources change) -assets/.staged: $(WIDGET_SOURCES) - @echo "Aggregating assets from all widgets..." - @bash scripts/stage_assets.sh - @touch $@ - -# Legacy target for compatibility -stage-js: assets/.staged - -# Prepare web artifacts (delegated to widgets that provide them) -# Each widget that supports web builds handles its own artifacts. -web/pkg: assets/.staged dart-bindings - @echo "Preparing web artifacts from widgets..." - @for widget in $(WIDGETS); do \ - if [ -f "$$widget/Makefile" ] && grep -q '^wasm:' "$$widget/Makefile" 2>/dev/null; then \ - echo "Building web artifacts from $$widget..."; \ - cd $(ROOT)/$$widget && $(MAKE) wasm; \ - fi; \ - done - -# Build web version with Flutter - output: build/web/index.html (Flutter's main artifact) -build/web/index.html: web/pkg pubspec.yaml $(DART_SOURCES) - @echo "Building Flutter web app with all widgets..." - @bash $(ROOT)/scripts/ensure_dart_bindings.sh - @flutter pub get - @flutter build web --release --target=lib/main_standalone.dart - @echo "Copying widget artifacts into build/web..." - @rm -rf $(ROOT)/build/web/pkg - @for widget in $(WIDGETS); do \ - if [ -d "$(ROOT)/$$widget/web/pkg" ]; then \ - cp -r $(ROOT)/$$widget/web/pkg $(ROOT)/build/web/; \ - break; \ - fi; \ - done - @echo "Web build ready at: build/web/" - -# Legacy target for compatibility -web: build/web/index.html - -# Run web standalone version (development - no stamp, always runs) -web-run: web/pkg - @echo "Ensuring ports are free..." - @bash $(ROOT)/scripts/free_ports.sh - @echo "Running Flutter web standalone app..." - @bash $(ROOT)/scripts/ensure_dart_bindings.sh - @flutter pub get - @flutter run -d web-server --web-port=9099 --web-hostname=0.0.0.0 lib/main_standalone.dart - -# Run web standalone version in profile mode (faster startup, limited debugging) -web-run-profile: web/pkg - @echo "Ensuring ports are free..." - @bash $(ROOT)/scripts/free_ports.sh - @echo "Running Flutter web standalone app (profile mode)..." - @bash $(ROOT)/scripts/ensure_dart_bindings.sh - @flutter pub get - @flutter run -d web-server --web-port=9099 --web-hostname=0.0.0.0 --profile lib/main_standalone.dart - -# Prepare all widgets (compilation of native/wasm code, asset staging, bindings) -# Each widget handles its own build requirements - top-level never knows implementation details -widget-prepare: dart-bindings - @for widget in $(WIDGETS); do \ - if [ -f "$$widget/Makefile" ] && grep -q '^prepare:' "$$widget/Makefile" 2>/dev/null; then \ - echo "Preparing $$widget..."; \ - cd $(ROOT)/$$widget && $(MAKE) prepare; \ - fi; \ - done - -# Build Linux standalone version - output: build/linux/release/rohd_devtools -build/linux/release: widget-prepare assets/.staged pubspec.yaml $(DART_SOURCES) - @echo "Building Flutter Linux standalone app..." - @bash $(ROOT)/scripts/ensure_dart_bindings.sh +linux: @flutter pub get @flutter build linux --target=lib/main_standalone.dart -# Legacy target for compatibility -linux: build/linux/release - -# Clean all build artifacts -clean: clean-linux clean-web clean-assets - @echo "Cleaning Flutter build cache..." - @flutter clean - @echo "Removing generated doc/..." - -rm -rf $(ROOT)/doc - @echo "Clean complete" - -# Real-clean - deep clean including dependency packages and all widget submodules -real-clean: clean - @echo "Performing real-clean - removing all build outputs and packages..." - @echo "Real-cleaning packages/rohd_devtools_widgets..." - @cd packages/rohd_devtools_widgets && flutter clean - @for widget in $(WIDGETS); do \ - echo "Real-cleaning $$widget..."; \ - cd $(ROOT)/$$widget && $(MAKE) real-clean; \ - done - @echo "Real-cleaning rohd_extension/dart..." - -@cd ../rohd_extension/dart && flutter clean - @echo "Removing generated flutter_rust_bridge artifacts..." - -find $(ROOT)/packages -name "flutter_rust_bridge.generated*" -delete - @for widget in $(WIDGETS); do \ - find $(ROOT)/$$widget/packages -name "flutter_rust_bridge.generated*" -delete 2>/dev/null || true; \ - done - @echo "Removing .dart_tool and pub cache artifacts..." - -rm -rf $(ROOT)/.dart_tool - -rm -rf $(ROOT)/.dart_plugins - -rm -rf $(ROOT)/pubspec.lock - @echo "Removing staged assets (preserving source assets/help/)..." - -rm -rf $(ROOT)/assets/js - -rm -f $(ROOT)/assets/schematic_layout_bridge.js - -rm -f $(ROOT)/assets/rohd_schematic.json - -rm -f $(ROOT)/assets/elk_layout_only.js - -rm -f $(ROOT)/assets/.staged - @echo "Real-clean complete - run 'flutter pub get' to restore dependencies" - -# Clean Linux build artifacts clean-linux: - @echo "Cleaning Linux build artifacts..." -rm -rf $(ROOT)/build/linux - -rm -rf $(ROOT)/build/native_assets -rm -rf $(ROOT)/linux/flutter/ephemeral -# Clean web build artifacts -clean-web: - @echo "Cleaning web build artifacts..." - -rm -rf $(ROOT)/build/web - -rm -rf $(ROOT)/build/flutter_assets - -rm -rf $(ROOT)/build/extension - @echo "Removing stale web asset files from parent directory..." - -rm -f $(ROOT)/web/schematic_layout_bridge.js - -rm -rf $(ROOT)/web/assets/js - -# Clean staged assets (copied from widgets) -clean-assets: - @echo "Cleaning staged assets..." - -rm -rf $(ROOT)/assets/js - -rm -f $(ROOT)/assets/schematic_layout_bridge.js - -rm -f $(ROOT)/assets/rohd_schematic.json - -rm -f $(ROOT)/assets/.staged - -# Help target help: - @echo "ROHD Debugger Makefile" - @echo "" - @echo "Core Targets:" - @echo " all - Build Linux standalone (default)" - @echo " linux - Build Linux standalone app (all widgets)" - @echo " web - Build web app (all widgets)" - @echo " web-run - Run web app in development mode" - @echo " test - Run tests (Dart + widget integration)" - @echo " test-all - Run all tests including integration tests" - @echo "" - @echo "Extension Targets:" - @echo " extension - Build VS Code extension" - @echo " install-local - Install to local VS Code" - @echo " install-remote - Install to remote VS Code Server" + @echo "ROHD DevTools Extension" @echo "" - @echo "Asset Targets:" - @echo " stage-js - Stage assets from all widgets" - @echo " dart-bindings - Ensure all widget bindings are ready" - @echo "" - @echo "Cleanup:" - @echo " clean - Clean all build artifacts" - @echo " real-clean - Deep clean: all builds, packages, widget artifacts" - @echo " force - Force rebuild (clean + all)" - @echo "" - @echo "Architecture:" - @echo " This Makefile is widget-agnostic. Top-level coordinates:" - @echo " - Asset staging from all widgets via scripts/stage_assets.sh" - @echo " - Bindings preparation by delegating to each widget" - @echo " - Web/native builds by discovering widget capabilities" - @echo " - Each widget manages its own implementation" - @echo " - No hardcoded Rust/WASM/implementation details" - -# Force rebuild of everything -force: clean all - -# Build the VSCode extension with all dependencies (schematic viewer + wave viewer) -# Output: build/extension/web/index.html (final deployable artifact) -extension: build/extension/web/index.html - -# Build VS Code extension - copies and stages all required artifacts -# Requires: WASM, Flutter web build, and widget assets -build/extension/web/index.html: build/web/index.html - @echo "Building VS Code extension (web + widget assets)..." - @bash $(ROOT)/scripts/ensure_dart_bindings.sh - @flutter pub get - @mkdir -p build/extension - @echo "Staging extension assets..." - @cp -r assets build/extension/ - @cp -r web build/extension/ - @if [ -d "$(ROOT)/build/web" ]; then cp -r build/web build/extension/web_assets; fi - @echo "Extension built at build/extension/" - -# Install extension locally to user's VS Code installation -install-local: build/extension/web/index.html - @echo "Installing extension to local VS Code..." - @mkdir -p ~/.vscode/extensions - @cp -r build/extension ~/.vscode/extensions/rohd-devtools-extension - @echo "Extension installed to ~/.vscode/extensions/rohd-devtools-extension" - -# Install extension remotely to VS Code Server on remote machine -install-remote: build/extension/web/index.html - @echo "Installing extension to remote VS Code Server..." - @mkdir -p ~/.vscode-server/extensions - @cp -r build/extension ~/.vscode-server/extensions/rohd-devtools-extension - @echo "Extension installed to ~/.vscode-server/extensions/rohd-devtools-extension" + @echo "Targets:" + @echo " all - Build the Linux standalone app (default)" + @echo " linux - Run flutter pub get and build Linux standalone" + @echo " clean-linux - Remove Linux build outputs" \ No newline at end of file diff --git a/rohd_devtools_extension/assets/help/details_help.md b/rohd_devtools_extension/assets/help/details_help.md index 5f1f2786c..27689d8df 100644 --- a/rohd_devtools_extension/assets/help/details_help.md +++ b/rohd_devtools_extension/assets/help/details_help.md @@ -21,9 +21,8 @@ Signal Values | Value column | Displays the current value of each signal | | Width column | Shows the bit width of each signal | -## Snapshots +## Export | Action | Description | | --- | --- | -| πŸ“Έ Camera | Capture all signal values at current simulation time | -| πŸŽ₯ Video | Auto-capture signal values on each breakpoint | +| πŸ“· 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 index ece2cc7b1..f7845a6bc 100644 --- a/rohd_devtools_extension/assets/help/devtools_help.md +++ b/rohd_devtools_extension/assets/help/devtools_help.md @@ -2,51 +2,19 @@ -Connection - || Pause Pause VM connection - β–· Resume Resume VM connection - πŸ”— Connect Open connection dialog - πŸ”ƒ Refresh Reload module tree - Module Tree (left panel) Click node Select module Click β–Έ / β–Ύ Expand / collapse πŸ”ƒ Refresh Reload hierarchy from VM Type in search Filter modules by name -Waveform Viewer (details tab) - ← / β†’ Pan left / right - Shift+↑ / ↓ Zoom in / out - Shift+Scroll Zoom at cursor - F Fit to viewport - Click waveform Place time marker - -Schematic Viewer (details tab) - Scroll Zoom in / out - F Fit to canvas - Ctrl+F Search blocks & wires - Click +/βˆ’/⊞ Expand / collapse blocks - -Snapshots - πŸ“Έ Camera Capture all signal values - πŸŽ₯ Video Auto-track on breakpoints - -Go to Source (requires ROHD Extension) - Right-click signal Open source location picker - DTD URI Copy from DevTools status bar - ROHD Extension Activate in VS Code for source nav +Details (right panel) + Signal list Shows ports and internal signals + Search Filter signals by name + Filter Toggle input / output visibility -## Connection Management - -| Key | Description | -| --- | --- | -| Pause | Pause the VM connection | -| Resume | Resume the VM connection | -| πŸ”— Connect | Open connection dialog to attach to a VM | -| πŸ”ƒ Refresh | Reload module tree from the VM | - ## Module Tree (left panel) | Key | Description | @@ -56,79 +24,11 @@ Go to Source (requires ROHD Extension) | πŸ”ƒ Refresh | Reload hierarchy from the VM | | Type in search | Filter modules by name | -## Waveform Viewer - -| Key | Description | -| --- | --- | -| ← / β†’ | Pan waveform left / right | -| ↑ / ↓ | Scroll signal list up / down | -| Shift + ↑ / ↓ | Zoom in / zoom out | -| Shift + Scroll | Zoom in / out at cursor | -| Scroll wheel | Pan horizontally | -| F | Fit entire waveform to viewport | -| Ctrl + Drag | Draw a time region to zoom into | -| Click waveform | Place time marker | -| ← / β†’ (focused) | Jump to previous / next edge | -| Click signal name | Add signal to monitor list | -| DEL | Remove focused signals from monitor list | - -## Schematic Viewer +## Signal Details (right panel) | Key | Description | | --- | --- | -| Scroll wheel | Zoom in / out at cursor | -| F | Fit entire schematic to canvas | -| Ctrl + F / ⌘F | Open search overlay | -| Click + | Fully expand block | -| Click βˆ’ | Collapse block | -| Click ⊞ | Expand non-primitive children only | -| Click port β–Ά | Reveal the connected block | -| Shift + Click β–Ά | Expand through trivial gates | -| Double-click | Zoom to block or wire | -| Drag | Pan canvas | - -## Snapshots - -| Key | Description | -| --- | --- | -| πŸ“Έ Camera | Capture all signal values at current time | -| πŸŽ₯ Video | Auto-track signal values on breakpoints | - -## Go to Source β€” ROHD Extension - -The **Go to Source** feature lets you jump from a signal in the waveform or -schematic viewer directly to the Dart source code that defines it. It -requires the **ROHD Extension** for VS Code. - -### Setup - -1. **Install the ROHD Extension** β€” open the Extensions panel in VS Code and - install `rohd-extension` (the `.vsix` from the `rohd_extension/` - directory). -2. **Copy the DTD URI** β€” when the DevTools app connects to a running - ROHD simulation, the Dart Tooling Daemon (DTD) URI is shown in the - connection log (e.g. `ws://127.0.0.1:43369/xiiRI61v9qc=`). Copy this - URI. -3. **Activate the ROHD Extension** β€” open the VS Code Command Palette - (`Ctrl+Shift+P`) and run **ROHD: Connect to DTD**. Paste the DTD URI - when prompted. The extension registers source-navigation services - (`rohd.goToSource`, `rohd.resolveFrames`) on the DTD so the DevTools - app can request editor navigation. - -### Using Go to Source - -| Action | Description | -| --- | --- | -| Right-click signal (waveform) | Triggers Go to Source for that signal | -| Right-click signal (schematic) | Triggers Go to Source for that signal | -| Single source frame | Editor opens the file and line automatically | -| Multiple source frames | A popup picker appears listing each call site | - -When the ROHD compiler records multiple source locations for a signal -(e.g. a port wired through several `build()` methods), a **popup menu** -appears near your click. Each entry shows the enclosing method name and -file location (e.g. `Serializer.build() β€” serializer.dart:55`). Select -an entry to open that location in VS Code. - -If the ROHD Extension is not connected, Go to Source falls back to opening -the outermost (first) frame automatically via the VM service. +| 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/tool/gh_actions/devtool/install_devtools.sh b/tool/gh_actions/devtool/install_devtools.sh index b36f92391..bef68c86e 100755 --- a/tool/gh_actions/devtool/install_devtools.sh +++ b/tool/gh_actions/devtool/install_devtools.sh @@ -4,11 +4,8 @@ # SPDX-License-Identifier: BSD-3-Clause # # install_devtools.sh -# Build all ROHD DevTools web artifacts: +# Build the ROHD DevTools extension web artifact: # extension/devtools/ – DevTools extension (iframe in Chrome DevTools) -# extension/devtools/debugger/ – Standalone debugger web app -# extension/devtools/waves/ – Standalone waveform viewer -# extension/devtools/schematics/ – Standalone schematic viewer # # Usage (from repo root): # bash tool/gh_actions/devtool/install_devtools.sh @@ -19,9 +16,6 @@ set -euo pipefail DEST="../extension/devtools" -WAVE_PKG="rohd-wave-viewer/web/pkg" -STANDALONE_VIEWS=(debugger waves schematics) -WASM_VIEWS=(debugger waves) # ── Helper: strip service-worker / PWA artifacts from a deployed build ── # @@ -48,105 +42,17 @@ SWEOF sed -i 's||\n|' "$dir/index.html" # Strip serviceWorkerSettings from flutter_bootstrap.js - python3 -c " + if [ -f "$dir/flutter_bootstrap.js" ]; then + python3 -c " import re p = '$dir/flutter_bootstrap.js' with open(p) as f: c = f.read() c = re.sub(r'_flutter\.loader\.load\(\{[^}]*serviceWorkerSettings[^}]*\{[^}]*\}[^}]*\}\);', '_flutter.loader.load();', c, flags=re.DOTALL) with open(p, 'w') as f: f.write(c) " -} - -# ── Helper: copy WASM pkg into a deployed build ── -copy_wasm_pkg() { - local dir="$1" - if [ -d "$WAVE_PKG" ]; then - echo " Copying WASM pkg into $(basename "$dir")..." - rm -rf "$dir/pkg" - cp -r "$WAVE_PKG" "$dir/pkg" - elif [ -d "$DEST/build/pkg" ]; then - echo " Copying WASM pkg from extension build into $(basename "$dir")..." - rm -rf "$dir/pkg" - cp -r "$DEST/build/pkg" "$dir/pkg" fi } -# ── Helper: copy ELK JS assets into a deployed build ── -# build_and_copy / flutter build strips web/ |' "$DEST/build/index.html" echo " Extension deployed to $DEST/ (web assets in $DEST/build/)" - -# ── 2. Debugger web app ──────────────────────────────────────────────── -echo "" -echo "════════════════════════════════════════════════════════════" -echo " 2/4 Building debugger web app..." -echo "════════════════════════════════════════════════════════════" - -flutter build web --release --base-href=/debugger/ \ - --pwa-strategy=none \ - --target=lib/main_standalone.dart \ - --output=build/web_standalone - -rm -rf "$DEST/debugger" -cp -r build/web_standalone "$DEST/debugger" - -copy_elk_assets "build/web_standalone" "$DEST/debugger" -copy_wasm_pkg "$DEST/debugger" -strip_sw_artifacts "$DEST/debugger" "debugger" - -echo " Debugger deployed to $DEST/debugger/" - -# ── 3/4. Standalone widget builds ────────────────────────────────────── -build_standalone_widget \ - "3/4" \ - "wave viewer widget" \ - "waves" \ - "rohd-wave-viewer" \ - "build/web_waves" \ - "no" \ - "yes" - -build_standalone_widget \ - "4/4" \ - "schematic viewer widget" \ - "schematics" \ - "rohd-schematic-viewer" \ - "build/web_schematics" \ - "yes" \ - "no" - -# ── Deduplicate: symlink build/{waves,schematics,debugger} ────────────── -# DevTools only serves build/ for extensions. Instead of copying ~106 MB -# of identical files, we patch the top-level viewers to use a relative -# (works for both the standalone Python server and -# DevTools iframe) and symlink from build/. -echo "" -echo " Symlinking standalone viewers into build/ for DevTools access..." - -for tool_dir in "${STANDALONE_VIEWS[@]}"; do - if [ -d "$DEST/$tool_dir" ]; then - # Patch base href to ./ β€” works in both standalone and DevTools contexts - sed -i 's|||' "$DEST/$tool_dir/index.html" - rm -rf "$DEST/build/$tool_dir" - ln -sfn "../$tool_dir" "$DEST/build/$tool_dir" - echo " build/$tool_dir/ β†’ ../$tool_dir/ (symlink, base href=./)" - fi -done - -# ── Deduplicate: shared canvaskit/ ────────────────────────────────────── -# Flutter embeds a 26 MB canvaskit/ in every web build. All copies are -# identical. Keep build/canvaskit/ as canonical and symlink from viewers. -echo "" -echo " Deduplicating canvaskit/ (~155 MB saved)..." - -for tool_dir in "${STANDALONE_VIEWS[@]}"; do - if [ -d "$DEST/$tool_dir/canvaskit" ]; then - rm -rf "$DEST/$tool_dir/canvaskit" - ln -sfn "../build/canvaskit" "$DEST/$tool_dir/canvaskit" - echo " $tool_dir/canvaskit/ β†’ build/canvaskit/ (symlink)" - fi -done - -# ── Deduplicate: shared pkg/ (WASM) ──────────────────────────────────── -# The wellen WASM bridge (~1 MB) is copied into multiple viewers. -# Keep build/pkg/ as canonical and symlink from viewers that have it. -echo "" -echo " Deduplicating pkg/ (WASM)..." - -for tool_dir in "${WASM_VIEWS[@]}"; do - if [ -d "$DEST/$tool_dir/pkg" ]; then - rm -rf "$DEST/$tool_dir/pkg" - ln -sfn "../build/pkg" "$DEST/$tool_dir/pkg" - echo " $tool_dir/pkg/ β†’ build/pkg/ (symlink)" - fi -done - -# ── Summary ───────────────────────────────────────────────────────────── -echo "" -echo "════════════════════════════════════════════════════════════" -echo " All builds complete. Deployed to extension/devtools/:" -echo " / - DevTools extension (iframe)" -echo " /debugger/ - Standalone debugger with DTD/VM connection" -echo " /waves/ - Standalone waveform viewer" -echo " /schematics/ - Standalone schematic viewer" -echo "════════════════════════════════════════════════════════════" \ No newline at end of file From ef82b77f11b0f07e844aaa0fb73222a378fff256 Mon Sep 17 00:00:00 2001 From: "Desmond A. Kirkpatrick" Date: Sat, 20 Jun 2026 05:46:14 -0700 Subject: [PATCH 11/19] Update ROHD VF tutorial interface example --- .../chapter_9/rohd_vf_example/lib/counter.dart | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/doc/tutorials/chapter_9/rohd_vf_example/lib/counter.dart b/doc/tutorials/chapter_9/rohd_vf_example/lib/counter.dart index 53205b5ef..d37508a51 100644 --- a/doc/tutorials/chapter_9/rohd_vf_example/lib/counter.dart +++ b/doc/tutorials/chapter_9/rohd_vf_example/lib/counter.dart @@ -11,19 +11,17 @@ class MyCounterInterface extends Interface { final int width; MyCounterInterface({this.width = 8}) { - setPorts( - [Logic.port('en'), Logic.port('reset')], [CounterDirection.inward]); + setPorts([Port('en'), Port('reset')], [CounterDirection.inward]); setPorts([ - Logic.port('val', width), + Port('val', width), ], [ CounterDirection.outward ]); - setPorts([Logic.port('clk')], [CounterDirection.misc]); + setPorts([Port('clk')], [CounterDirection.misc]); } - @override MyCounterInterface clone() => MyCounterInterface(width: width); } @@ -38,9 +36,10 @@ class MyCounter extends Module { late final MyCounterInterface counterintf; MyCounter(MyCounterInterface intf) : super(name: 'counter') { - counterintf = addInterfacePorts(counterintf, - inputTags: {CounterDirection.inward, CounterDirection.misc}, - outputTags: {CounterDirection.outward}); + counterintf = MyCounterInterface(width: intf.width) + ..connectIO(this, intf, + inputTags: {CounterDirection.inward, CounterDirection.misc}, + outputTags: {CounterDirection.outward}); _buildLogic(); } From a87fa5c20118f6813c1f354731e34d6634060c08 Mon Sep 17 00:00:00 2001 From: "Desmond A. Kirkpatrick" Date: Sun, 21 Jun 2026 09:26:53 -0700 Subject: [PATCH 12/19] added back pubkeys, and made a wget a fallback solution with loud warning --- tool/gh_codespaces/install_dart.sh | 80 +++++++- tool/gh_codespaces/pubkeys/dart.pub | 305 ++++++++++++++++++++++++++++ 2 files changed, 380 insertions(+), 5 deletions(-) create mode 100644 tool/gh_codespaces/pubkeys/dart.pub diff --git a/tool/gh_codespaces/install_dart.sh b/tool/gh_codespaces/install_dart.sh index f170dc247..d0bfdfe91 100755 --- a/tool/gh_codespaces/install_dart.sh +++ b/tool/gh_codespaces/install_dart.sh @@ -8,21 +8,91 @@ # # 2023 February 5 # Author: Chykon +# +# 2026 June 21 +# Updated to add fallback logic for fetching the latest Dart repository key from Google if the locally cached key fails verification (e.g. due to key rotation). +# Author: Desmond A. Kirkpatrick set -euo pipefail +declare -r cached_pubkey_file="$(dirname "${BASH_SOURCE[0]}")/pubkeys/dart.pub" +declare -r keyring_file='/usr/share/keyrings/dart.gpg' +declare -r dart_repository_file='/etc/apt/sources.list.d/dart_stable.list' +declare -r dart_repository_url='https://storage.googleapis.com/download.dartlang.org/linux/debian' +declare -r google_signing_key_url='https://dl-ssl.google.com/linux/linux_signing_key.pub' + sudo apt-get update sudo apt-get install -y wget gpg apt-transport-https sudo mkdir -p /usr/share/keyrings -wget -qO- https://dl-ssl.google.com/linux/linux_signing_key.pub \ - | gpg --dearmor \ - | sudo tee /usr/share/keyrings/dart.gpg >/dev/null # Add Dart repository. -echo "deb [signed-by=/usr/share/keyrings/dart.gpg] https://storage.googleapis.com/download.dartlang.org/linux/debian stable main" \ - | sudo tee /etc/apt/sources.list.d/dart_stable.list +echo "deb [signed-by=${keyring_file}] ${dart_repository_url} stable main" \ + | sudo tee "${dart_repository_file}" + +# Install the repository key from the locally cached, ASCII-armored public key. +install_key_from_file() { + sudo gpg --yes --output "${keyring_file}" --dearmor "${1}" +} + +# Install the repository key by fetching the latest key from Google. +install_key_from_google() { + wget -qO- "${google_signing_key_url}" \ + | gpg --dearmor \ + | sudo tee "${keyring_file}" >/dev/null +} + +# Emit a prominent warning that stands out in CI logs (and as a GitHub Actions +# annotation when available) without failing the build. +warn_loudly() { + local message="${1}" + { + echo '' + echo '################################################################################' + echo '## install_dart WARNING' + echo "## ${message}" + echo '################################################################################' + echo '' + } >&2 + # Surface a GitHub Actions warning annotation (non-fatal) when running in CI. + if [[ -n "${GITHUB_ACTIONS:-}" ]]; then + echo "::warning title=install_dart cached key bypassed::${message}" + fi +} + +# Verify that the installed keyring can authenticate the Dart repository by +# refreshing only the Dart sources list and checking for signature/key errors. +dart_repository_verified() { + local update_log + if ! update_log=$(sudo apt-get update \ + -o Dir::Etc::sourcelist="${dart_repository_file}" \ + -o Dir::Etc::sourceparts="-" \ + -o APT::Get::List-Cleanup="0" 2>&1); then + return 1 + fi + if echo "${update_log}" \ + | grep -Eiq 'NO_PUBKEY|EXPKEYSIG|REVKEYSIG|BADSIG|not signed|could.?n.?t be verified'; then + return 1 + fi + return 0 +} + +# Prefer the locally cached key. If it can no longer authenticate the repository +# (e.g. the key has been rotated), fall back to fetching the latest key from +# Google so the install can still proceed. +install_key_from_file "${cached_pubkey_file}" + +if dart_repository_verified; then + echo 'install_dart: using locally cached Dart repository key.' +else + install_key_from_google + if ! dart_repository_verified; then + echo 'install_dart: Dart repository key verification failed even after fetching the latest key from Google.' >&2 + exit 1 + fi + warn_loudly "Cached Dart repository key (${cached_pubkey_file}) failed verification and was bypassed; installed using the latest key fetched from Google. Please refresh the cached key." +fi # Install Dart. diff --git a/tool/gh_codespaces/pubkeys/dart.pub b/tool/gh_codespaces/pubkeys/dart.pub new file mode 100644 index 000000000..839f8a235 --- /dev/null +++ b/tool/gh_codespaces/pubkeys/dart.pub @@ -0,0 +1,305 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +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 +=CkA1 +-----END PGP PUBLIC KEY BLOCK----- From 6eae70828dc286e2edc77314fbc1eacbb8570f49 Mon Sep 17 00:00:00 2001 From: "Desmond A. Kirkpatrick" Date: Sun, 21 Jun 2026 21:33:15 -0700 Subject: [PATCH 13/19] trailing comma reduction --- rohd_devtools_extension/lib/main.dart | 115 +++--- .../cubit/rohd_service_cubit.dart | 92 ++--- .../rohd_devtools/models/signal_model.dart | 28 +- .../lib/rohd_devtools/models/tree_model.dart | 30 +- .../rohd_devtools/services/tree_service.dart | 28 +- .../lib/rohd_devtools/ui/devtool_appbar.dart | 88 ++--- .../rohd_devtools/ui/module_tree_card.dart | 82 ++--- .../ui/module_tree_details_navbar.dart | 159 ++++----- .../rohd_devtools/ui/signal_details_card.dart | 137 +++---- .../lib/rohd_devtools/ui/signal_table.dart | 94 +++-- .../view/rohd_devtools_page.dart | 31 +- .../view/tree_structure_page.dart | 336 +++++++----------- .../fixtures/tree_model.stub.dart | 96 ++--- 13 files changed, 508 insertions(+), 808 deletions(-) diff --git a/rohd_devtools_extension/lib/main.dart b/rohd_devtools_extension/lib/main.dart index f24f5d967..278c04212 100644 --- a/rohd_devtools_extension/lib/main.dart +++ b/rohd_devtools_extension/lib/main.dart @@ -55,59 +55,47 @@ void main() { /// controlled reconnect. void _installMessageInterceptor() { web.window.addEventListener( - 'message', - ((web.MessageEvent e) { - try { - final data = e.data.dartify(); - if (data is! Map) { - return; - } - final type = data['type']; + 'message', + ((web.MessageEvent e) { + try { + final data = e.data.dartify(); + if (data is! Map) { + return; + } + final type = data['type']; - final source = data['source'] ?? '?'; - debugPrint( - '[ROHD-MSG] type=$type source=$source ' - 'data=${data['data']}', - ); + final source = data['source'] ?? '?'; + debugPrint('[ROHD-MSG] type=$type source=$source ' + 'data=${data['data']}'); - if (type == 'forceReload') { - debugPrint( - '[ROHD-MSG] BLOCKED forceReload β€” ' - 'triggering graceful reconnection', - ); - e.stopImmediatePropagation(); + if (type == 'forceReload') { + debugPrint('[ROHD-MSG] BLOCKED forceReload β€” ' + 'triggering graceful reconnection'); + e.stopImmediatePropagation(); - // After blocking the page reload, disconnect the stale VM - // service and ask DevTools for the current (restarted) URI. - // A short delay lets the DevTools wrapper finish its own - // transition before we re-request. - Future.delayed(const Duration(milliseconds: 300), () async { - try { - if (serviceManager.connectedState.value.connected) { - debugPrint('[ROHD-MSG] Disconnecting stale VM...'); - await serviceManager.manuallyDisconnect(); + // After blocking the page reload, disconnect the stale VM + // service and ask DevTools for the current (restarted) URI. + // A short delay lets the DevTools wrapper finish its own + // transition before we re-request. + Future.delayed(const Duration(milliseconds: 300), () async { + try { + if (serviceManager.connectedState.value.connected) { + debugPrint('[ROHD-MSG] Disconnecting stale VM...'); + await serviceManager.manuallyDisconnect(); + } + debugPrint('[ROHD-MSG] Requesting fresh VM URI ' + 'from DevTools...'); + extensionManager.postMessageToDevTools(DevToolsExtensionEvent( + DevToolsExtensionEventType.vmServiceConnection)); + } on Object catch (err) { + debugPrint('[ROHD-MSG] Reconnection request ' + 'failed: $err'); } - debugPrint( - '[ROHD-MSG] Requesting fresh VM URI ' - 'from DevTools...', - ); - extensionManager.postMessageToDevTools( - DevToolsExtensionEvent( - DevToolsExtensionEventType.vmServiceConnection, - ), - ); - } on Object catch (err) { - debugPrint( - '[ROHD-MSG] Reconnection request ' - 'failed: $err', - ); - } - }); - return; - } - } on Object catch (_) {} - }).toJS, - ); + }); + return; + } + } on Object catch (_) {} + }).toJS); } /// The main ROHD DevTools application. @@ -119,27 +107,22 @@ class RohdDevToolsApp extends StatelessWidget { Widget build(BuildContext context) { debugPrint('[RohdDevToolsApp] Building app widget...'); return DevToolsExtension( - // Reset IdeTheme scaling so extension renders at 1Γ— size - // regardless of the IDE's editor.fontSize setting. - child: Builder( - builder: (context) { - final current = ideTheme; - setGlobal( - IdeTheme, - IdeTheme( + // Reset IdeTheme scaling so extension renders at 1Γ— size + // regardless of the IDE's editor.fontSize setting. + child: Builder(builder: (context) { + final current = ideTheme; + setGlobal( + IdeTheme, + IdeTheme( backgroundColor: current.backgroundColor, foregroundColor: current.foregroundColor, embedMode: current.embedMode, - isDarkMode: current.isDarkMode, - ), - ); + isDarkMode: current.isDarkMode)); - final isDark = Theme.of(context).brightness == Brightness.dark; - final base = isDark ? buildDarkTheme() : buildLightTheme(); + final isDark = Theme.of(context).brightness == Brightness.dark; + final base = isDark ? buildDarkTheme() : buildLightTheme(); - return Theme(data: base, child: const RohdDevToolsPage()); - }, - ), - ); + return Theme(data: base, child: const RohdDevToolsPage()); + })); } } 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 eb7a7e3d1..f38d1cebf 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 @@ -65,9 +65,7 @@ class RohdServiceCubit extends Cubit { /// Configure a standalone VM service session without relying on the global /// DevTools extension [serviceManager]. Future configureStandaloneVmService( - vm.VmService vmService, - String isolateId, - ) async { + vm.VmService vmService, String isolateId) async { _rohdIsolateId = null; _disposeSignalValueSource(); @@ -81,10 +79,8 @@ class RohdServiceCubit extends Cubit { final localManager = _localServiceManager!; _localServiceClosedSignal = Completer(); - await localManager.vmServiceOpened( - vmService, - onClosed: _localServiceClosedSignal!.future, - ); + await localManager.vmServiceOpened(vmService, + onClosed: _localServiceClosedSignal!.future); final vmInfo = await vmService.getVM(); final isolates = vmInfo.isolates ?? const []; @@ -101,10 +97,8 @@ class RohdServiceCubit extends Cubit { void _onConnectionStateChanged() { final connected = serviceManager.connectedState.value.connected; - debugPrint( - '[RohdServiceCubit] Connection state changed: ' - 'connected=$connected', - ); + debugPrint('[RohdServiceCubit] Connection state changed: ' + 'connected=$connected'); if (connected) { // Reset tree service so we use the new connection treeService = null; @@ -138,25 +132,21 @@ class RohdServiceCubit extends Cubit { Future evalModuleTree() async { debugPrint('[RohdServiceCubit] evalModuleTree called'); await _handleModuleTreeOperation( - (treeService) => treeService.evalModuleTree(), - ); + (treeService) => treeService.evalModuleTree()); } /// Refresh the module tree from the ROHD service. Future refreshModuleTree() async { debugPrint('[RohdServiceCubit] refreshModuleTree called'); await _handleModuleTreeOperation( - (treeService) => treeService.refreshModuleTree(), - ); + (treeService) => treeService.refreshModuleTree()); } Future _handleModuleTreeOperation( - Future Function(TreeService) operation, - ) async { + Future Function(TreeService) operation) async { try { debugPrint( - '[RohdServiceCubit] _handleModuleTreeOperation - emitting loading', - ); + '[RohdServiceCubit] _handleModuleTreeOperation - emitting loading'); emit(RohdServiceLoading()); final activeServiceManager = @@ -164,10 +154,8 @@ class RohdServiceCubit extends Cubit { final activeService = activeServiceManager?.service; if (activeService == null) { - debugPrint( - '[RohdServiceCubit] ServiceManager is not initialized - ' - 'emitting loaded with null', - ); + debugPrint('[RohdServiceCubit] ServiceManager is not initialized - ' + 'emitting loaded with null'); // When not running in DevTools, just emit loaded with null tree // This prevents constant error states and allows the UI to work emit(const RohdServiceLoaded(null)); @@ -186,10 +174,8 @@ class RohdServiceCubit extends Cubit { try { final vmInfo = await service.getVM(); final isolates = vmInfo.isolates ?? []; - debugPrint( - '[RohdServiceCubit] Scanning ${isolates.length} ' - 'isolate(s) for ROHD library...', - ); + debugPrint('[RohdServiceCubit] Scanning ${isolates.length} ' + 'isolate(s) for ROHD library...'); for (final isoRef in isolates) { final id = isoRef.id; @@ -199,29 +185,21 @@ class RohdServiceCubit extends Cubit { try { final iso = await service.getIsolate(id); final libs = iso.libraries ?? []; - debugPrint( - '[RohdServiceCubit] Isolate ${isoRef.name} ' - '(${isoRef.id}): ${libs.length} libraries', - ); - final hasRohd = libs.any( - (lib) => - lib.uri == - 'package:rohd/src/diagnostics/inspector_service.dart', - ); + debugPrint('[RohdServiceCubit] Isolate ${isoRef.name} ' + '(${isoRef.id}): ${libs.length} libraries'); + final hasRohd = libs.any((lib) => + lib.uri == + 'package:rohd/src/diagnostics/inspector_service.dart'); if (hasRohd) { - debugPrint( - '[RohdServiceCubit] β†’ Found ROHD in ' - '${isoRef.name}', - ); + debugPrint('[RohdServiceCubit] β†’ Found ROHD in ' + '${isoRef.name}'); rohdIsolate = ValueNotifier(isoRef); _rohdIsolateId = id; break; } } on Exception catch (e) { - debugPrint( - '[RohdServiceCubit] Isolate ${isoRef.name} ' - 'scan error: $e', - ); + debugPrint('[RohdServiceCubit] Isolate ${isoRef.name} ' + 'scan error: $e'); } } } on Exception catch (e) { @@ -229,29 +207,21 @@ class RohdServiceCubit extends Cubit { } if (rohdIsolate == null) { - debugPrint( - '[RohdServiceCubit] ROHD isolate not found, ' - 'falling back to selected isolate', - ); + debugPrint('[RohdServiceCubit] ROHD isolate not found, ' + 'falling back to selected isolate'); } treeService = TreeService( - EvalOnDartLibrary( - 'package:rohd/src/diagnostics/inspector_service.dart', - service, - serviceManager: activeServiceManager!, - isolate: rohdIsolate, - ), - Disposable(), - vmService: service, - isolateId: _rohdIsolateId, - ); + EvalOnDartLibrary( + 'package:rohd/src/diagnostics/inspector_service.dart', service, + serviceManager: activeServiceManager!, isolate: rohdIsolate), + Disposable(), + vmService: service, + isolateId: _rohdIsolateId); _disposeSignalValueSource(); _signalValueSource = createSignalValueSourceBinding( - treeService: treeService!, - vmService: service, - ); + treeService: treeService!, vmService: service); } debugPrint('[RohdServiceCubit] Calling operation...'); diff --git a/rohd_devtools_extension/lib/rohd_devtools/models/signal_model.dart b/rohd_devtools_extension/lib/rohd_devtools/models/signal_model.dart index 7b71a342f..e88528f86 100644 --- a/rohd_devtools_extension/lib/rohd_devtools/models/signal_model.dart +++ b/rohd_devtools_extension/lib/rohd_devtools/models/signal_model.dart @@ -22,26 +22,20 @@ class SignalModel { final int width; /// Creates a signal model. - SignalModel({ - required this.name, - required this.direction, - required this.value, - required this.width, - }); + SignalModel( + {required this.name, + required this.direction, + required this.value, + required this.width}); /// Builds a signal model from a map representation. factory SignalModel.fromMap(Map map) => SignalModel( - name: map['name'] as String, - direction: map['direction'] as String, - value: map['value'] as String, - width: map['width'] as int, - ); + name: map['name'] as String, + direction: map['direction'] as String, + value: map['value'] as String, + width: map['width'] as int); /// Converts the signal model to a JSON-compatible map. - Map toMap() => { - 'name': name, - 'direction': direction, - 'value': value, - 'width': width, - }; + Map toMap() => + {'name': name, 'direction': direction, 'value': value, 'width': width}; } diff --git a/rohd_devtools_extension/lib/rohd_devtools/models/tree_model.dart b/rohd_devtools_extension/lib/rohd_devtools/models/tree_model.dart index 39c1aa6d2..61721df20 100644 --- a/rohd_devtools_extension/lib/rohd_devtools/models/tree_model.dart +++ b/rohd_devtools_extension/lib/rohd_devtools/models/tree_model.dart @@ -24,12 +24,11 @@ class TreeModel { final List subModules; /// Creates a tree model for a module hierarchy node. - TreeModel({ - required this.name, - required this.inputs, - required this.outputs, - required this.subModules, - }); + TreeModel( + {required this.name, + required this.inputs, + required this.outputs, + required this.subModules}); /// Builds a tree model from a JSON map. factory TreeModel.fromJson(Map json) { @@ -44,7 +43,7 @@ class TreeModel { 'name': inputSignal.key, 'direction': 'Input', 'value': inputValue['value'], - 'width': inputValue['width'], + 'width': inputValue['width'] }); inputSignalsList.add(signal); } @@ -55,20 +54,19 @@ class TreeModel { 'name': outputSignal.key, 'direction': 'Output', 'value': outputValue['value'], - 'width': outputValue['width'], + 'width': outputValue['width'] }); outputSignalsList.add(signal); } return TreeModel( - name: json['name'] as String, - inputs: inputSignalsList, - outputs: outputSignalsList, - subModules: (json['subModules'] as List) - .map((subModule) => - TreeModel.fromJson(subModule as Map)) - .toList(), - ); + name: json['name'] as String, + inputs: inputSignalsList, + outputs: outputSignalsList, + subModules: (json['subModules'] as List) + .map((subModule) => + TreeModel.fromJson(subModule as Map)) + .toList()); } } 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 3c3d6e2b4..a63cb71a3 100644 --- a/rohd_devtools_extension/lib/rohd_devtools/services/tree_service.dart +++ b/rohd_devtools_extension/lib/rohd_devtools/services/tree_service.dart @@ -37,12 +37,8 @@ class TreeService { final String? isolateId; /// Creates a tree service around the given eval wrapper. - TreeService( - this.rohdControllerEval, - this.evalDisposable, { - this.vmService, - this.isolateId, - }); + TreeService(this.rohdControllerEval, this.evalDisposable, + {this.vmService, this.isolateId}); /// Evaluates the module tree from the ROHD service. Future evalModuleTree() async { @@ -54,10 +50,8 @@ class TreeService { final decoded = jsonDecode(payload); if (decoded is! Map) { - debugPrint( - '[TreeService] evalModuleTree failed: unexpected payload type ' - '${decoded.runtimeType}', - ); + debugPrint('[TreeService] evalModuleTree failed: unexpected payload type ' + '${decoded.runtimeType}'); return null; } @@ -78,15 +72,11 @@ class TreeService { for (final expression in expressions) { try { - final treeInstance = await rohdControllerEval.evalInstance( - expression, - isAlive: evalDisposable, - ); + final treeInstance = await rohdControllerEval.evalInstance(expression, + isAlive: evalDisposable); return treeInstance.valueAsString; } on Exception catch (e) { - debugPrint( - '[TreeService] Eval failed for "$expression": $e', - ); + debugPrint('[TreeService] Eval failed for "$expression": $e'); } } @@ -95,9 +85,7 @@ class TreeService { /// Returns whether the current module or any descendant matches the search. static bool isNodeOrDescendentMatching( - TreeModel module, - String? treeSearchTerm, - ) { + TreeModel module, String? treeSearchTerm) { if (module.name.toLowerCase().contains(treeSearchTerm!.toLowerCase())) { return true; } 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 7d3cc6c16..3ed1add03 100644 --- a/rohd_devtools_extension/lib/rohd_devtools/ui/devtool_appbar.dart +++ b/rohd_devtools_extension/lib/rohd_devtools/ui/devtool_appbar.dart @@ -17,10 +17,7 @@ import 'package:rohd_devtools_extension/rohd_devtools/ui/platform_icon.dart'; /// App bar used by the ROHD DevTools UI. class DevtoolAppBar extends StatelessWidget implements PreferredSizeWidget { /// Whether to render color emoji icons where available. - const DevtoolAppBar({ - super.key, - this.hasColorEmoji = kIsWeb, - }); + const DevtoolAppBar({super.key, this.hasColorEmoji = kIsWeb}); /// Whether the icon set should prefer color emoji glyphs. final bool hasColorEmoji; @@ -33,59 +30,44 @@ class DevtoolAppBar extends StatelessWidget implements PreferredSizeWidget { final accentColor = Theme.of(context).colorScheme.primary; return AppBar( - backgroundColor: Theme.of(context).colorScheme.onPrimary, - title: const Text('ROHD DevTool (Beta)'), - leading: Padding( - padding: const EdgeInsets.all(8), - child: Image.asset( - 'assets/icons/rohd_logo.png', - fit: BoxFit.contain, - ), - ), - actions: [ - // ── Help ── - DevToolsHelpButton(isDark: isDark), + backgroundColor: Theme.of(context).colorScheme.onPrimary, + title: const Text('ROHD DevTool (Beta)'), + leading: Padding( + padding: const EdgeInsets.all(8), + child: + Image.asset('assets/icons/rohd_logo.png', fit: BoxFit.contain)), + actions: [ + // ── Help ── + DevToolsHelpButton(isDark: isDark), - // ── Licenses ── - Padding( - padding: const EdgeInsets.only(right: 20), - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: GestureDetector( - onTap: () { - showLicensePage(context: context); - }, - child: const Text( - 'Licenses', - style: TextStyle( - fontWeight: FontWeight.bold, - ), - ), - ), - ), - ), + // ── Licenses ── + Padding( + padding: const EdgeInsets.only(right: 20), + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () { + showLicensePage(context: context); + }, + child: const Text('Licenses', + style: TextStyle(fontWeight: FontWeight.bold))))), - BlocBuilder( - builder: (context, themeMode) { + BlocBuilder( + builder: (context, themeMode) { final isDark = themeMode == DevToolsThemeMode.dark; return IconButton( - tooltip: - isDark ? 'Switch to light theme' : 'Switch to dark theme', - onPressed: () { - context.read().toggleTheme(); - }, - icon: platformIcon( - isDark ? Icons.light_mode : Icons.dark_mode, - isDark ? 'β˜€οΈ' : 'πŸŒ™', - size: 24, - color: accentColor, - hasColorEmoji: hasColorEmoji, - ), - ); - }, - ), - ], - ); + tooltip: + isDark ? 'Switch to light theme' : 'Switch to dark theme', + onPressed: () { + context.read().toggleTheme(); + }, + icon: platformIcon(isDark ? Icons.light_mode : Icons.dark_mode, + isDark ? 'β˜€οΈ' : 'πŸŒ™', + size: 24, + color: accentColor, + hasColorEmoji: hasColorEmoji)); + }) + ]); } @override 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 450767e4e..996b23e8d 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 @@ -22,10 +22,7 @@ class ModuleTreeCard extends StatefulWidget { final TreeModel futureModuleTree; /// Creates a module tree card for the provided module tree. - const ModuleTreeCard({ - required this.futureModuleTree, - super.key, - }); + const ModuleTreeCard({required this.futureModuleTree, super.key}); @override @@ -36,8 +33,7 @@ class ModuleTreeCard extends StatefulWidget { void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); properties.add( - DiagnosticsProperty('futureModuleTree', futureModuleTree), - ); + DiagnosticsProperty('futureModuleTree', futureModuleTree)); } } @@ -48,9 +44,8 @@ class _ModuleTreeCardState extends State { @override /// Builds the module tree widget. - Widget build(BuildContext context) => genModuleTree( - moduleTree: widget.futureModuleTree, - ); + Widget build(BuildContext context) => + genModuleTree(moduleTree: widget.futureModuleTree); /// Builds a tree node for [module], returning null if it is filtered out. TreeNode? buildNode(TreeModel module) { @@ -66,17 +61,14 @@ class _ModuleTreeCardState extends State { final childrenNodes = buildChildrenNodes(module); return TreeNode( - content: MouseRegion( - cursor: SystemMouseCursors.click, - child: GestureDetector( - onTap: () { - context.read().setModule(module); - }, - child: getNodeContent(module), - ), - ), - children: childrenNodes, - ); + content: MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () { + context.read().setModule(module); + }, + child: getNodeContent(module))), + children: childrenNodes); } /// Builds the visible text and icon for a tree node. @@ -88,40 +80,30 @@ class _ModuleTreeCardState extends State { final isSelected = selectedModule is SelectedModuleLoaded && selectedModule.module == module; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( + return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Container( decoration: BoxDecoration( - color: isSelected - ? Colors.blue.withValues(alpha: 0.2) - : Colors.transparent, - borderRadius: BorderRadius.circular(4), - ), + color: isSelected + ? Colors.blue.withValues(alpha: 0.2) + : Colors.transparent, + borderRadius: BorderRadius.circular(4)), padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), - child: Row( - children: [ - Icon(Icons.memory, color: colorScheme.onSurface), - const SizedBox(width: 2), - Text( - module.name, + child: Row(children: [ + Icon(Icons.memory, color: colorScheme.onSurface), + const SizedBox(width: 2), + Text(module.name, style: TextStyle( - fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, - color: - isSelected ? colorScheme.primary : colorScheme.onSurface, - ), - ), - ], - ), - ), - ], - ); + fontWeight: + isSelected ? FontWeight.bold : FontWeight.normal, + color: isSelected + ? colorScheme.primary + : colorScheme.onSurface)) + ])) + ]); } /// Builds child tree nodes for the given module. - List buildChildrenNodes( - TreeModel treeModule, - ) { + List buildChildrenNodes(TreeModel treeModule) { final childrenNodes = []; final subModules = treeModule.subModules; if (subModules.isNotEmpty) { @@ -139,9 +121,7 @@ class _ModuleTreeCardState extends State { TreeNode? buildTreeFromModule(TreeModel node) => buildNode(node); /// Builds the full tree view widget for [moduleTree]. - Widget genModuleTree({ - required TreeModel moduleTree, - }) { + Widget genModuleTree({required TreeModel moduleTree}) { final root = buildNode(moduleTree); if (root != null) { return TreeView(nodes: [root]); 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 3561b0ee0..61f4d6007 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 @@ -21,23 +21,15 @@ class ModuleTreeDetailsNavbar extends StatelessWidget { final bool hasColorEmoji; /// Creates the details navigation bar. - const ModuleTreeDetailsNavbar({ - super.key, - this.hasColorEmoji = kIsWeb, - }); + const ModuleTreeDetailsNavbar({super.key, this.hasColorEmoji = kIsWeb}); @override /// Adds diagnostic properties for the nav bar. void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); - properties.add( - FlagProperty( - 'hasColorEmoji', - value: hasColorEmoji, - ifFalse: 'using fallback emojis', - ), - ); + properties.add(FlagProperty('hasColorEmoji', + value: hasColorEmoji, ifFalse: 'using fallback emojis')); } @override @@ -48,55 +40,38 @@ class ModuleTreeDetailsNavbar extends StatelessWidget { final isDark = Theme.of(context).brightness == Brightness.dark; return DecoratedBox( - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest, - border: Border( - bottom: BorderSide(color: Theme.of(context).dividerColor), - ), - ), - child: BlocBuilder( - builder: (context, selectedTab) => Row( - children: [ - _TabButton( - label: 'Details', - icon: platformIcon( - Icons.info, - 'ℹ️', - size: 18, - hasColorEmoji: hasColorEmoji, - ), - isSelected: selectedTab == DetailsTab.details, - onTap: () => context.read().selectTab( - DetailsTab.details, - ), - ), - _TabButton( - label: 'Waveform', - icon: platformIcon( - Icons.waves, - '🌊', - size: 18, - hasColorEmoji: hasColorEmoji, - ), - isSelected: selectedTab == DetailsTab.waveform, - onTap: () => context.read().selectTab( - DetailsTab.waveform, - ), - ), - _TabButton( - label: 'Schematic', - icon: const SchematicIcon(size: 18), - isSelected: selectedTab == DetailsTab.schematic, - onTap: () => context.read().selectTab( - DetailsTab.schematic, - ), - ), - const Spacer(), - DetailsHelpButton(isDark: isDark), - ], - ), - ), - ); + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + border: Border( + bottom: BorderSide(color: Theme.of(context).dividerColor))), + child: BlocBuilder( + builder: (context, selectedTab) => Row(children: [ + _TabButton( + label: 'Details', + icon: platformIcon(Icons.info, 'ℹ️', + size: 18, hasColorEmoji: hasColorEmoji), + isSelected: selectedTab == DetailsTab.details, + onTap: () => context + .read() + .selectTab(DetailsTab.details)), + _TabButton( + label: 'Waveform', + icon: platformIcon(Icons.waves, '🌊', + size: 18, hasColorEmoji: hasColorEmoji), + isSelected: selectedTab == DetailsTab.waveform, + onTap: () => context + .read() + .selectTab(DetailsTab.waveform)), + _TabButton( + label: 'Schematic', + icon: const SchematicIcon(size: 18), + isSelected: selectedTab == DetailsTab.schematic, + onTap: () => context + .read() + .selectTab(DetailsTab.schematic)), + const Spacer(), + DetailsHelpButton(isDark: isDark) + ]))); } } @@ -113,12 +88,11 @@ class _TabButton extends StatelessWidget { /// Callback invoked when the tab is tapped. final VoidCallback onTap; - const _TabButton({ - required this.label, - required this.icon, - required this.isSelected, - required this.onTap, - }); + const _TabButton( + {required this.label, + required this.icon, + required this.isSelected, + required this.onTap}); @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { @@ -127,11 +101,8 @@ class _TabButton extends StatelessWidget { ..add(StringProperty('label', label)) ..add(DiagnosticsProperty('icon', icon)) ..add(FlagProperty('isSelected', value: isSelected)) - ..add(ObjectFlagProperty( - 'onTap', - onTap, - ifNull: 'disabled', - )); + ..add( + ObjectFlagProperty('onTap', onTap, ifNull: 'disabled')); } @override @@ -141,33 +112,23 @@ class _TabButton extends StatelessWidget { final unselectedColor = colorScheme.onSurface.withValues(alpha: 0.6); return InkWell( - onTap: onTap, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - decoration: BoxDecoration( - border: Border( - bottom: BorderSide( - color: isSelected ? selectedColor : Colors.transparent, - width: 2, - ), - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - icon, - const SizedBox(width: 8), - Text( - label, - style: TextStyle( - fontSize: 13, - fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, - color: isSelected ? selectedColor : unselectedColor, - ), - ), - ], - ), - ), - ); + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: isSelected ? selectedColor : Colors.transparent, + width: 2))), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + icon, + const SizedBox(width: 8), + Text(label, + style: TextStyle( + fontSize: 13, + fontWeight: + isSelected ? FontWeight.bold : FontWeight.normal, + color: isSelected ? selectedColor : unselectedColor)) + ]))); } } 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 922737bc0..29cb74d7c 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 @@ -64,41 +64,32 @@ class SignalDetailsCardState extends State { void toggleNotifier() => notifier.value++; void _showFilterDialog() { - unawaited( - showDialog( + unawaited(showDialog( context: context, builder: (context) => StatefulBuilder( - builder: (context, setState) => AlertDialog( - title: const Text('Filter Signals'), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - CheckboxListTile( - title: const Text('Input'), - value: inputSelected.value, - onChanged: (value) { - setState(() { - inputSelected.value = value!; - }); - toggleNotifier(); - }, - ), - CheckboxListTile( - title: const Text('Output'), - value: outputSelected.value, - onChanged: (value) { - setState(() { - outputSelected.value = value!; - }); - toggleNotifier(); - }, - ), - ], - ), - ), - ), - ), - ); + builder: (context, setState) => AlertDialog( + title: const Text('Filter Signals'), + content: + Column(mainAxisSize: MainAxisSize.min, children: [ + CheckboxListTile( + title: const Text('Input'), + value: inputSelected.value, + onChanged: (value) { + setState(() { + inputSelected.value = value!; + }); + toggleNotifier(); + }), + CheckboxListTile( + title: const Text('Output'), + value: outputSelected.value, + onChanged: (value) { + setState(() { + outputSelected.value = value!; + }); + toggleNotifier(); + }) + ]))))); } @override @@ -107,69 +98,49 @@ class SignalDetailsCardState extends State { Widget build(BuildContext context) { if (widget.module == null) { return const Padding( - padding: EdgeInsets.only(top: 20), - child: Center(child: Text('No module selected')), - ); + padding: EdgeInsets.only(top: 20), + child: Center(child: Text('No module selected'))); } final isDark = Theme.of(context).brightness == Brightness.dark; - return Stack( - fit: StackFit.expand, - children: [ - RepaintBoundary( + return Stack(fit: StackFit.expand, children: [ + RepaintBoundary( key: _boundaryKey, child: SingleChildScrollView( - child: Column( - children: [ - Padding( - padding: const EdgeInsets.all(8), - child: Row( - children: [ - SignalTableTextField( - labelText: 'Search Signals', - onChanged: (value) { - setState(() { - searchTerm = value; - }); - toggleNotifier(); - }, - ), - IconButton( - icon: const Icon(Icons.filter_list), - onPressed: _showFilterDialog, - ), - DetailsHelpButton(isDark: isDark), - ], - ), - ), - ValueListenableBuilder( - valueListenable: notifier, - builder: (context, _, __) => SignalTable( + child: Column(children: [ + Padding( + padding: const EdgeInsets.all(8), + child: Row(children: [ + SignalTableTextField( + labelText: 'Search Signals', + onChanged: (value) { + setState(() { + searchTerm = value; + }); + toggleNotifier(); + }), + IconButton( + icon: const Icon(Icons.filter_list), + onPressed: _showFilterDialog), + DetailsHelpButton(isDark: isDark) + ])), + ValueListenableBuilder( + valueListenable: notifier, + builder: (context, _, __) => SignalTable( selectedModule: widget.module!, searchTerm: searchTerm, inputSelectedVal: inputSelected.value, outputSelectedVal: outputSelected.value, - snapshot: widget.snapshot, - ), - ), - ], - ), - ), - ), - Positioned( + snapshot: widget.snapshot)) + ]))), + Positioned( right: 8, bottom: 8, child: ExportPngButton( - onPressed: () => captureBoundaryToPng( - context, - boundaryKey: _boundaryKey, - filePrefix: 'signal_details', - ), - ), - ), - ], - ); + onPressed: () => captureBoundaryToPng(context, + boundaryKey: _boundaryKey, filePrefix: 'signal_details'))) + ]); } @override diff --git a/rohd_devtools_extension/lib/rohd_devtools/ui/signal_table.dart b/rohd_devtools_extension/lib/rohd_devtools/ui/signal_table.dart index da4cd05e4..f2cac235a 100644 --- a/rohd_devtools_extension/lib/rohd_devtools/ui/signal_table.dart +++ b/rohd_devtools_extension/lib/rohd_devtools/ui/signal_table.dart @@ -32,14 +32,13 @@ class SignalTable extends StatefulWidget { final SnapshotLoaded? snapshot; /// Creates a signal table for the given module and filters. - const SignalTable({ - required this.selectedModule, - required this.searchTerm, - required this.inputSelectedVal, - required this.outputSelectedVal, - this.snapshot, - super.key, - }); + const SignalTable( + {required this.selectedModule, + required this.searchTerm, + required this.inputSelectedVal, + required this.outputSelectedVal, + this.snapshot, + super.key}); @override @@ -69,37 +68,29 @@ class _SignalTableState extends State { final tableHeaders = ['Name', 'Direction', valueHeader, 'Width']; return Table( - border: TableBorder.all(), - columnWidths: const { - 0: FlexColumnWidth(), - 1: FlexColumnWidth(), - 2: FlexColumnWidth(), - }, - defaultVerticalAlignment: TableCellVerticalAlignment.middle, - children: [ - TableRow( - children: List.generate( - tableHeaders.length, - (index) => _buildTableHeader(text: tableHeaders[index]), - ), - ), - ...generateSignalsRow( - widget.selectedModule, - searchTerm: widget.searchTerm, - inputSelected: widget.inputSelectedVal, - outputSelected: widget.outputSelectedVal, - ), - ], - ); + border: TableBorder.all(), + columnWidths: const { + 0: FlexColumnWidth(), + 1: FlexColumnWidth(), + 2: FlexColumnWidth() + }, + defaultVerticalAlignment: TableCellVerticalAlignment.middle, + children: [ + TableRow( + children: List.generate(tableHeaders.length, + (index) => _buildTableHeader(text: tableHeaders[index]))), + ...generateSignalsRow(widget.selectedModule, + searchTerm: widget.searchTerm, + inputSelected: widget.inputSelectedVal, + outputSelected: widget.outputSelectedVal) + ]); } /// Builds the rows for the signals that match the selected filters. - List generateSignalsRow( - TreeModel module, { - required String? searchTerm, - required bool inputSelected, - required bool outputSelected, - }) { + List generateSignalsRow(TreeModel module, + {required String? searchTerm, + required bool inputSelected, + required bool outputSelected}) { final rows = []; // Filter signals @@ -122,16 +113,14 @@ class _SignalTableState extends State { return rows; } - TableRow _generateSignalRow(SignalModel signal) => TableRow( - children: [ - SizedBox(height: 32, child: Center(child: Text(signal.name))), - SizedBox(height: 32, child: Center(child: Text(signal.direction))), - SizedBox( - height: 32, child: Center(child: Text(_lookupValue(signal)))), - SizedBox( - height: 32, child: Center(child: Text(signal.width.toString()))), - ], - ); + TableRow _generateSignalRow(SignalModel signal) => + TableRow(children: [ + SizedBox(height: 32, child: Center(child: Text(signal.name))), + SizedBox(height: 32, child: Center(child: Text(signal.direction))), + SizedBox(height: 32, child: Center(child: Text(_lookupValue(signal)))), + SizedBox( + height: 32, child: Center(child: Text(signal.width.toString()))) + ]); String _lookupValue(SignalModel signal) { // Snapshot overlay is currently keyed by signal name because the upstream @@ -149,12 +138,9 @@ class _SignalTableState extends State { } Widget _buildTableHeader({required String text}) => SizedBox( - height: 32, - child: Center( - child: Text( - text, - style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15), - ), - ), - ); + height: 32, + child: Center( + child: Text(text, + style: + const TextStyle(fontWeight: FontWeight.bold, fontSize: 15)))); } diff --git a/rohd_devtools_extension/lib/rohd_devtools/view/rohd_devtools_page.dart b/rohd_devtools_extension/lib/rohd_devtools/view/rohd_devtools_page.dart index 8199e363f..a2134f120 100644 --- a/rohd_devtools_extension/lib/rohd_devtools/view/rohd_devtools_page.dart +++ b/rohd_devtools_extension/lib/rohd_devtools/view/rohd_devtools_page.dart @@ -22,25 +22,23 @@ class RohdDevToolsPage extends StatelessWidget { /// Builds the themed DevTools page and its bloc providers. Widget build(BuildContext context) => MultiBlocProvider( - providers: [ - BlocProvider(create: (context) => DevToolsThemeCubit()), - BlocProvider(create: (context) => RohdServiceCubit()), - BlocProvider(create: (context) => TreeSearchTermCubit()), - BlocProvider(create: (context) => SelectedModuleCubit()), - BlocProvider(create: (context) => SignalSearchTermCubit()), - BlocProvider(create: (context) => DetailsTabCubit()), - BlocProvider(create: (context) => SnapshotCubit()), - ], - child: BlocBuilder( - builder: (context, themeMode) { + providers: [ + BlocProvider(create: (context) => DevToolsThemeCubit()), + BlocProvider(create: (context) => RohdServiceCubit()), + BlocProvider(create: (context) => TreeSearchTermCubit()), + BlocProvider(create: (context) => SelectedModuleCubit()), + BlocProvider(create: (context) => SignalSearchTermCubit()), + BlocProvider(create: (context) => DetailsTabCubit()), + BlocProvider(create: (context) => SnapshotCubit()) + ], + child: BlocBuilder( + builder: (context, themeMode) { final theme = themeMode == DevToolsThemeMode.dark ? buildDarkTheme() : buildLightTheme(); return Theme(data: theme, child: const RohdExtensionModule()); - }, - ), - ); + })); } /// Extension module wrapper used by the DevTools host. @@ -62,8 +60,7 @@ class _RohdExtensionModuleState extends State { final screenSize = MediaQuery.of(context).size; return Scaffold( - appBar: const DevtoolAppBar(), - body: TreeStructurePage(screenSize: screenSize), - ); + appBar: const DevtoolAppBar(), + body: TreeStructurePage(screenSize: screenSize)); } } 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 e7f0b6261..c49f56abc 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 @@ -41,9 +41,9 @@ class TreeStructurePage extends StatelessWidget { /// Builds the split-pane tree structure page. Widget build(BuildContext context) => MultiBlocListener( - listeners: [ - BlocListener( - listener: (context, state) { + listeners: [ + BlocListener( + listener: (context, state) { final snapshotCubit = context.read(); if (state is RohdServiceLoaded) { @@ -62,112 +62,83 @@ class TreeStructurePage extends StatelessWidget { state is RohdServiceError) { snapshotCubit.clear(); } - }, - ), - ], - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 10), - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [_buildTreePane(context), _buildDetailsPane(context)], - ), - ), - ), - ); + }) + ], + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 10), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildTreePane(context), + _buildDetailsPane(context) + ])))); Widget _buildTreePane(BuildContext context) => SizedBox( - width: screenSize.width / 2, - child: Card( + width: screenSize.width / 2, + child: Card( clipBehavior: Clip.antiAlias, - child: Stack( - children: [ - RepaintBoundary( + child: Stack(children: [ + RepaintBoundary( key: _treeBoundaryKey, child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildTreeToolbar(context), - Expanded( - child: Scrollbar( - thumbVisibility: true, - controller: _vertical, - child: SingleChildScrollView( - controller: _vertical, - child: Row( - children: [ - Expanded( - child: Scrollbar( - thumbVisibility: true, - controller: _horizontal, - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - controller: _horizontal, - child: BlocBuilder( - builder: (context, state) => - _buildTreeStateBody(state), - ), - ), - ), - ), - ], - ), - ), - ), - ), - ], - ), - ), - Positioned( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildTreeToolbar(context), + Expanded( + child: Scrollbar( + thumbVisibility: true, + controller: _vertical, + child: SingleChildScrollView( + controller: _vertical, + child: Row(children: [ + Expanded( + child: Scrollbar( + thumbVisibility: true, + controller: _horizontal, + child: SingleChildScrollView( + scrollDirection: + Axis.horizontal, + controller: _horizontal, + child: BlocBuilder< + RohdServiceCubit, + RohdServiceState>( + builder: (context, state) => + _buildTreeStateBody( + state))))) + ])))) + ])), + Positioned( right: 8, bottom: 8, child: ExportPngButton( - onPressed: () => captureBoundaryToPng( - context, - boundaryKey: _treeBoundaryKey, - filePrefix: 'module_tree', - ), - ), - ), - ], - ), - ), - ); + onPressed: () => captureBoundaryToPng(context, + boundaryKey: _treeBoundaryKey, + filePrefix: 'module_tree'))) + ]))); Widget _buildTreeToolbar(BuildContext context) => Padding( - padding: const EdgeInsets.all(10), - 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(), - ), - ], - ), - ), - ], - ), - ); + padding: const EdgeInsets.all(10), + 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()) + ])) + ])); Widget _buildTreeStateBody(RohdServiceState state) { if (state is RohdServiceLoading) { @@ -178,15 +149,13 @@ class TreeStructurePage extends StatelessWidget { final futureModuleTree = state.treeModel; if (futureModuleTree == null) { return 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, - ), - ); + 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)); } return ModuleTreeCard(futureModuleTree: futureModuleTree); @@ -200,101 +169,70 @@ class TreeStructurePage extends StatelessWidget { } Widget _buildDetailsPane(BuildContext context) => SizedBox( - width: screenSize.width / 2, - child: Card( + width: screenSize.width / 2, + child: Card( clipBehavior: Clip.antiAlias, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const ModuleTreeDetailsNavbar(), - Expanded( + child: + Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + const ModuleTreeDetailsNavbar(), + Expanded( child: BlocBuilder( - builder: (context, selectedTab) => IndexedStack( - index: selectedTab.index, - children: [ - Padding( - padding: const EdgeInsets.only(left: 20, right: 20), - child: BlocBuilder( - builder: (context, state) => - BlocBuilder( - builder: (context, snapshotState) { - if (state is SelectedModuleLoaded) { - return SignalDetailsCard( - module: state.module, - snapshot: snapshotState is SnapshotLoaded - ? snapshotState - : null, - ); - } - - return const Center( - child: Text('No module selected'), - ); - }, - ), - ), - ), - _buildFeaturePlaceholderPane( - context, - icon: platformIcon( - Icons.waves, - '🌊', - size: 36, - color: Theme.of(context).colorScheme.primary, - hasColorEmoji: kIsWeb, - ), - title: 'Waveform', - message: 'Waveform content will be available ' - 'in a future release.', - ), - _buildFeaturePlaceholderPane( - context, - icon: const SchematicIcon(size: 36), - title: 'Schematic', - message: 'Schematic content will be available ' - 'in a future release.', - ), - ], - ), - ), - ), - ], - ), - ), - ); - - Widget _buildFeaturePlaceholderPane( - BuildContext context, { - required Widget icon, - required String title, - required String message, - }) { + builder: (context, selectedTab) => + IndexedStack(index: selectedTab.index, children: [ + Padding( + padding: + const EdgeInsets.only(left: 20, right: 20), + child: BlocBuilder( + builder: (context, state) => + BlocBuilder( + builder: (context, snapshotState) { + if (state is SelectedModuleLoaded) { + return SignalDetailsCard( + module: state.module, + snapshot: snapshotState + is SnapshotLoaded + ? snapshotState + : null); + } + + return const Center( + child: Text('No module selected')); + }))), + _buildFeaturePlaceholderPane(context, + icon: platformIcon(Icons.waves, '🌊', + size: 36, + color: Theme.of(context).colorScheme.primary, + hasColorEmoji: kIsWeb), + title: 'Waveform', + message: 'Waveform content will be available ' + 'in a future release.'), + _buildFeaturePlaceholderPane(context, + icon: const SchematicIcon(size: 36), + title: 'Schematic', + message: 'Schematic content will be available ' + 'in a future release.') + ]))) + ]))); + + Widget _buildFeaturePlaceholderPane(BuildContext context, + {required Widget icon, required String title, required String message}) { final colorScheme = Theme.of(context).colorScheme; return Center( - child: Padding( - padding: const EdgeInsets.all(24), - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 360), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - icon, - const SizedBox(height: 12), - Text(title, style: Theme.of(context).textTheme.titleMedium), - const SizedBox(height: 8), - Text( - message, - textAlign: TextAlign.center, - style: TextStyle( - color: colorScheme.onSurface.withValues(alpha: 0.72), - ), - ), - ], - ), - ), - ), - ); + child: Padding( + padding: const EdgeInsets.all(24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 360), + child: Column(mainAxisSize: MainAxisSize.min, children: [ + icon, + const SizedBox(height: 12), + Text(title, style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + Text(message, + textAlign: TextAlign.center, + style: TextStyle( + color: colorScheme.onSurface.withValues(alpha: 0.72))) + ])))); } } diff --git a/rohd_devtools_extension/test/modules/tree_structure/fixtures/tree_model.stub.dart b/rohd_devtools_extension/test/modules/tree_structure/fixtures/tree_model.stub.dart index 7c3e5a673..8a382e09a 100644 --- a/rohd_devtools_extension/test/modules/tree_structure/fixtures/tree_model.stub.dart +++ b/rohd_devtools_extension/test/modules/tree_structure/fixtures/tree_model.stub.dart @@ -15,86 +15,38 @@ final class TreeModelStub { const TreeModelStub._(); static final simpleTreeModel = TreeModel(name: 'counter', inputs: [ - SignalModel.fromMap({ - 'name': 'en', - 'direction': 'Input', - 'value': "1'h0", - 'width': 1, - }), - SignalModel.fromMap({ - 'name': 'reset', - 'direction': 'Input', - 'value': "1'h1", - 'width': 1, - }), - SignalModel.fromMap({ - 'name': 'clk', - 'direction': 'Input', - 'value': "1'h0", - 'width': 1, - }), + SignalModel.fromMap( + {'name': 'en', 'direction': 'Input', 'value': "1'h0", 'width': 1}), + SignalModel.fromMap( + {'name': 'reset', 'direction': 'Input', 'value': "1'h1", 'width': 1}), + SignalModel.fromMap( + {'name': 'clk', 'direction': 'Input', 'value': "1'h0", 'width': 1}) ], outputs: [ - SignalModel.fromMap({ - 'name': 'val', - 'direction': 'Input', - 'value': "1'h0", - 'width': 1, - }), + SignalModel.fromMap( + {'name': 'val', 'direction': 'Input', 'value': "1'h0", 'width': 1}) ], subModules: [ TreeModel(name: 'topmod', inputs: [ - SignalModel.fromMap({ - 'name': 'in_a', - 'direction': 'Input', - 'value': "1'h0", - 'width': 1, - }), - SignalModel.fromMap({ - 'name': 'in_b', - 'direction': 'Input', - 'value': "1'h1", - 'width': 1, - }), + SignalModel.fromMap( + {'name': 'in_a', 'direction': 'Input', 'value': "1'h0", 'width': 1}), + SignalModel.fromMap( + {'name': 'in_b', 'direction': 'Input', 'value': "1'h1", 'width': 1}) ], outputs: [ - SignalModel.fromMap({ - 'name': 'out_a', - 'direction': 'Input', - 'value': "1'h1", - 'width': 1, - }), - SignalModel.fromMap({ - 'name': 'out_b', - 'direction': 'Input', - 'value': "1'h1", - 'width': 1, - }), + SignalModel.fromMap( + {'name': 'out_a', 'direction': 'Input', 'value': "1'h1", 'width': 1}), + SignalModel.fromMap( + {'name': 'out_b', 'direction': 'Input', 'value': "1'h1", 'width': 1}) ], subModules: []) ]); static final selectedModule = TreeModel(name: 'topmod', inputs: [ - SignalModel.fromMap({ - 'name': 'in_a', - 'direction': 'Input', - 'value': "1'h0", - 'width': 1, - }), - SignalModel.fromMap({ - 'name': 'in_b', - 'direction': 'Input', - 'value': "1'h1", - 'width': 1, - }), + SignalModel.fromMap( + {'name': 'in_a', 'direction': 'Input', 'value': "1'h0", 'width': 1}), + SignalModel.fromMap( + {'name': 'in_b', 'direction': 'Input', 'value': "1'h1", 'width': 1}) ], outputs: [ - SignalModel.fromMap({ - 'name': 'out_a', - 'direction': 'Input', - 'value': "1'h1", - 'width': 1, - }), - SignalModel.fromMap({ - 'name': 'out_b', - 'direction': 'Input', - 'value': "1'h1", - 'width': 1, - }), + SignalModel.fromMap( + {'name': 'out_a', 'direction': 'Input', 'value': "1'h1", 'width': 1}), + SignalModel.fromMap( + {'name': 'out_b', 'direction': 'Input', 'value': "1'h1", 'width': 1}) ], subModules: []); } From e686c0b0298fc3e7016d0d242c25b0e759c1b41c Mon Sep 17 00:00:00 2001 From: "Desmond A. Kirkpatrick" Date: Sun, 28 Jun 2026 09:35:04 -0700 Subject: [PATCH 14/19] Add cross-probe service and button to rohd_devtools_widgets Migrate CrossProbeService (abstract interface, LocalCrossProbeChannel, LocalCrossProbeService, NullCrossProbeService) and the CrossProbeButton toolbar widget into the shared rohd_devtools_widgets package, and export them from the package barrel. Both files depend only on the Flutter SDK (foundation/material) already declared by the package, so no new dependencies are introduced. --- .../lib/rohd_devtools_widgets.dart | 4 + .../lib/src/cross_probe_button.dart | 48 ++++++ .../lib/src/cross_probe_service.dart | 157 ++++++++++++++++++ 3 files changed, 209 insertions(+) create mode 100644 rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/cross_probe_button.dart create mode 100644 rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/cross_probe_service.dart diff --git a/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/rohd_devtools_widgets.dart b/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/rohd_devtools_widgets.dart index 335b890d3..4147a0bb9 100644 --- a/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/rohd_devtools_widgets.dart +++ b/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/rohd_devtools_widgets.dart @@ -21,3 +21,7 @@ export 'src/export_toast.dart'; export 'src/save_png_stub.dart' if (dart.library.io) 'src/save_png_native.dart' if (dart.library.js_interop) 'src/save_png_web.dart'; + +// Cross-probing +export 'src/cross_probe_service.dart'; +export 'src/cross_probe_button.dart'; diff --git a/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/cross_probe_button.dart b/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/cross_probe_button.dart new file mode 100644 index 000000000..ea6148492 --- /dev/null +++ b/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/cross_probe_button.dart @@ -0,0 +1,48 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// cross_probe_button.dart +// Toolbar button for toggling cross-probing between viewers. +// +// 2026 June +// Author: Desmond Kirkpatrick + +import 'package:flutter/material.dart'; +import 'cross_probe_service.dart'; + +/// A toolbar icon button for cross-probing signal selections between viewers. +/// +/// Displays a bidirectional arrows icon ([Icons.compare_arrows]). Tap to +/// toggle cross-probing on or off via [CrossProbeService.isActive]. +/// +/// When active the icon is rendered in the theme's primary colour; when +/// inactive it uses the theme's disabled colour. +class CrossProbeButton extends StatelessWidget { + /// The cross-probe service whose [CrossProbeService.isActive] state is + /// reflected by this button. + final CrossProbeService service; + + /// Creates a [CrossProbeButton] for the given [service]. + const CrossProbeButton({required this.service, super.key}); + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: service.isActive, + builder: (context, active, _) { + final color = active + ? Theme.of(context).colorScheme.primary + : Theme.of(context).disabledColor; + return Tooltip( + message: active + ? 'Cross-probing active β€” tap to disable' + : 'Cross-probing disabled β€” tap to enable', + child: IconButton( + icon: Icon(Icons.compare_arrows, color: color), + onPressed: () => service.isActive.value = !service.isActive.value, + ), + ); + }, + ); + } +} diff --git a/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/cross_probe_service.dart b/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/cross_probe_service.dart new file mode 100644 index 000000000..694950740 --- /dev/null +++ b/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/cross_probe_service.dart @@ -0,0 +1,157 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// cross_probe_service.dart +// Interfaces and local implementations for cross-probing between viewers. +// +// 2026 June +// Author: Desmond Kirkpatrick + +import 'package:flutter/foundation.dart'; + +/// Abstract interface for cross-probing signal selections between viewers. +/// +/// Cross-probing allows a user to select signals in one viewer (e.g. the +/// schematic) and have those signals automatically highlighted in all other +/// viewers (e.g. the waveform viewer). +abstract class CrossProbeService { + /// Whether cross-probing is currently active. + /// + /// When `false`, neither [send] broadcasts nor incoming messages from + /// the channel are delivered to [incomingSignals]. + ValueNotifier get isActive; + + /// The most recent incoming signal paths received from OTHER viewers. + /// + /// Updated whenever another viewer broadcasts a selection while + /// [isActive] is `true`. `null` until the first message arrives. + ValueNotifier?> get incomingSignals; + + /// Broadcast [signalPaths] from this viewer ([source]) to all others. + /// + /// [source] identifies the originating viewer (e.g. `'waveform'`, + /// `'schematic'`). [LocalCrossProbeService] uses this tag to filter + /// out its own broadcasts so it does not receive its own selections as + /// incoming signals. + /// + /// Does nothing when [isActive] is `false` or [signalPaths] is empty. + void send(List signalPaths, {required String source}); + + /// Release all resources held by this service. + void dispose(); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Local (in-process) implementation +// ───────────────────────────────────────────────────────────────────────────── + +/// Shared in-process broadcast channel. +/// +/// Create a single [LocalCrossProbeChannel] and share it among all +/// [LocalCrossProbeService] instances that should cross-probe with each other. +/// This replaces the older `SignalSelectionBus` pattern. +class LocalCrossProbeChannel extends ChangeNotifier { + String? _lastSource; + List? _lastPaths; + + /// The source tag of the most recent broadcast. + String? get lastSource => _lastSource; + + /// The signal paths of the most recent broadcast. + List? get lastPaths => _lastPaths; + + /// Broadcast [signalPaths] from [source] to all registered listeners. + /// + /// Does nothing when [signalPaths] is empty. + void broadcast(List signalPaths, String source) { + if (signalPaths.isEmpty) return; + _lastSource = source; + _lastPaths = List.unmodifiable(signalPaths); + notifyListeners(); + } +} + +/// Per-viewer [CrossProbeService] backed by a shared [LocalCrossProbeChannel]. +/// +/// Create one [LocalCrossProbeService] per viewer, all sharing the same +/// [LocalCrossProbeChannel]. Each service filters out its own broadcasts +/// (matched by [source]) so viewers do not receive their own selections. +/// +/// ```dart +/// final channel = LocalCrossProbeChannel(); +/// final waveXp = LocalCrossProbeService(channel, source: 'waveform'); +/// final schemXp = LocalCrossProbeService(channel, source: 'schematic'); +/// +/// // Pass waveXp to the wave viewer and schemXp to the schematic viewer. +/// // dispose both services and the channel when done. +/// ``` +class LocalCrossProbeService implements CrossProbeService { + final LocalCrossProbeChannel _channel; + final String _source; + + @override + final ValueNotifier isActive = ValueNotifier(true); + + @override + final ValueNotifier?> incomingSignals = + ValueNotifier?>(null); + + /// Creates a [LocalCrossProbeService] backed by [channel]. + /// + /// [source] is the identifier used to filter self-broadcasts. Use a + /// stable, descriptive tag such as `'waveform'` or `'schematic'`. + LocalCrossProbeService( + LocalCrossProbeChannel channel, { + required String source, + }) : _channel = channel, + _source = source { + _channel.addListener(_onChannelMessage); + } + + void _onChannelMessage() { + if (!isActive.value) return; + final src = _channel.lastSource; + if (src == null || src == _source) return; // ignore own broadcasts + incomingSignals.value = _channel.lastPaths; + } + + @override + void send(List signalPaths, {required String source}) { + if (!isActive.value || signalPaths.isEmpty) return; + _channel.broadcast(signalPaths, source); + } + + @override + void dispose() { + _channel.removeListener(_onChannelMessage); + isActive.dispose(); + incomingSignals.dispose(); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Null (no-op) implementation +// ───────────────────────────────────────────────────────────────────────────── + +/// A no-op [CrossProbeService] for standalone or offline contexts where +/// cross-probing between viewers is not available. +/// +/// [isActive] is always `false`; [send] is a no-op; [incomingSignals] +/// never changes. +class NullCrossProbeService implements CrossProbeService { + @override + final ValueNotifier isActive = ValueNotifier(false); + + @override + final ValueNotifier?> incomingSignals = + ValueNotifier?>(null); + + @override + void send(List signalPaths, {required String source}) {} + + @override + void dispose() { + isActive.dispose(); + incomingSignals.dispose(); + } +} From 5ec52ee6f0438cdd1d76026f746e0821b3bf2575 Mon Sep 17 00:00:00 2001 From: "Desmond A. Kirkpatrick" Date: Sun, 28 Jun 2026 09:59:01 -0700 Subject: [PATCH 15/19] Add ROHD extension client to rohd_devtools_widgets Migrate RohdExtensionClient (abstract handshake interface plus NullExtensionClient) and its shared status model (rohd_extension_status.dart: RohdSourceFormat, RohdFormatInfo, RohdModuleInfo) into the shared rohd_devtools_widgets package, and export both from the package barrel. rohd_extension_status.dart is pure Dart with no imports; the client uses only package:flutter/foundation already declared by the package, so no new dependencies are introduced. --- .../lib/rohd_devtools_widgets.dart | 4 + .../lib/src/rohd_extension_client.dart | 133 +++++++++++ .../lib/src/rohd_extension_status.dart | 223 ++++++++++++++++++ 3 files changed, 360 insertions(+) create mode 100644 rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/rohd_extension_client.dart create mode 100644 rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/rohd_extension_status.dart diff --git a/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/rohd_devtools_widgets.dart b/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/rohd_devtools_widgets.dart index 4147a0bb9..f8abd0ada 100644 --- a/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/rohd_devtools_widgets.dart +++ b/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/rohd_devtools_widgets.dart @@ -25,3 +25,7 @@ export 'src/save_png_stub.dart' // Cross-probing export 'src/cross_probe_service.dart'; export 'src/cross_probe_button.dart'; + +// ROHD extension client +export 'src/rohd_extension_status.dart'; +export 'src/rohd_extension_client.dart'; diff --git a/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/rohd_extension_client.dart b/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/rohd_extension_client.dart new file mode 100644 index 000000000..6c1c59d69 --- /dev/null +++ b/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/rohd_extension_client.dart @@ -0,0 +1,133 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// rohd_extension_client.dart +// Abstract interface for querying the ROHD VS Code extension. +// +// Implemented by: +// β€’ FlcExtensionClient β€” backed by FlcService (DevTools / standalone mode) +// β€’ VscodeExtensionClient β€” posts messages to the VS Code webview host +// β€’ NullExtensionClient β€” no-op (fully offline / demo mode) +// +// 2026 May +// Author: Desmond Kirkpatrick + +import 'package:flutter/foundation.dart'; +export 'rohd_extension_status.dart'; +import 'rohd_extension_status.dart'; + +/// Abstract client for the ROHD VS Code extension handshake. +/// +/// Both the schematic viewer and the wave viewer use this interface to: +/// 1. Detect whether the extension is reachable ([ping]). +/// 2. Query what source formats are available for a given module +/// ([queryModule]), triggering any necessary FST/VCD pre-loading. +/// 3. Observe connection status and module info reactively via +/// [isAvailable] and [currentModuleInfo] notifiers. +abstract class RohdExtensionClient { + /// Whether the ROHD extension is reachable. + /// + /// Updated after [ping] resolves and whenever the client detects a + /// disconnection. Viewers can listen to this notifier to show/hide + /// a status icon. + ValueNotifier get isAvailable; + + /// The most recently fetched [RohdModuleInfo], or `null` before the first + /// [queryModule] call. + /// + /// Updated each time [queryModule] completes. Viewers listen to this + /// notifier to rebuild their "Go to …" menus. + ValueNotifier get currentModuleInfo; + + /// Check whether the ROHD extension is running and reachable. + /// + /// Updates [isAvailable] and returns `true` on success. + /// Safe to call repeatedly (e.g. on a timer for status polling). + Future ping(); + + /// Query what source formats the extension has for [module]. + /// + /// [module] is the definition name of the module currently displayed + /// (e.g. `'Counter_L1_'`). [instancePath] is the optional instance + /// path from the root (e.g. `['serializer', 'counter']`), which may + /// help the extension locate waveform data. + /// + /// Returns a [RohdModuleInfo] describing what is available, and updates + /// [currentModuleInfo] with the same value. + /// + /// Never throws β€” returns [RohdModuleInfo.unavailable] on error. + Future queryModule( + String module, { + List? instancePath, + }); + + /// Look up source frames for [signals] via the extension host. + /// + /// [signals] is a list of maps with 'module' and 'name' keys. + /// [format] is optional β€” 'rohd' or 'sv' to filter frame types. + /// + /// Returns a list of frame maps with keys: file, line, col, desc, type. + /// Returns an empty list if no frames found or extension unavailable. + Future>> lookupSignalFrames({ + required List> signals, + String? format, + }); + + /// Open a specific source location in the editor. + /// + /// Used after the user picks a frame from the popup selection. + void openSourceLocation({ + required String file, + required int line, + int col = 0, + }); + + /// Release any resources held by this client. + void dispose(); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Null implementation β€” used in fully standalone / demo mode. +// ───────────────────────────────────────────────────────────────────────────── + +/// A no-op [RohdExtensionClient] used when no extension is reachable. +/// +/// [isAvailable] is always `false`, [queryModule] always returns +/// [RohdModuleInfo.unavailable], and [ping] always returns `false`. +class NullExtensionClient implements RohdExtensionClient { + @override + final isAvailable = ValueNotifier(false); + + @override + final currentModuleInfo = ValueNotifier(null); + + @override + Future ping() async => false; + + @override + Future queryModule( + String module, { + List? instancePath, + }) async => + RohdModuleInfo.unavailable; + + @override + Future>> lookupSignalFrames({ + required List> signals, + String? format, + }) async => + const []; + + @override + void openSourceLocation({ + required String file, + required int line, + int col = 0, + }) {} + + @override + void dispose() { + isAvailable.dispose(); + currentModuleInfo.dispose(); + } +} diff --git a/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/rohd_extension_status.dart b/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/rohd_extension_status.dart new file mode 100644 index 000000000..f87428254 --- /dev/null +++ b/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/rohd_extension_status.dart @@ -0,0 +1,223 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// rohd_extension_status.dart +// Shared data model for the ROHD extension handshake protocol. +// +// Used by both rohd-schematic-viewer and rohd-wave-viewer to represent +// what source formats and files the ROHD extension has available for a +// given module. +// +// 2026 May +// Author: Desmond Kirkpatrick + +/// Identifies a source/output format the ROHD extension can navigate to. +enum RohdSourceFormat { + /// ROHD Dart source (the design description language). + rohd, + + /// SystemVerilog output generated from ROHD. + sv, + + /// SystemC output generated from ROHD. + sc, + + /// FST/VCD waveform file associated with a simulation of this module. + fst, +} + +/// Availability status for a single source format. +class RohdFormatInfo { + /// At least one source frame exists for this format in the FLC data. + final bool available; + + /// The source file was found on disk (checked by the extension host). + /// May be `false` even when [available] is `true` if the file was + /// deleted or the path is stale. + final bool fileFound; + + /// Resolved absolute path to the file, if known. + final String? path; + + const RohdFormatInfo({ + required this.available, + this.fileFound = false, + this.path, + }); + + /// A format is usable when the FLC data says it exists AND the file was + /// found. Callers may also choose to show the option when [available] is + /// true but [fileFound] is false (degraded / missing-file indication). + bool get usable => available && fileFound; +} + +/// Information returned by the ROHD extension for a specific module. +/// +/// Viewers use this to decide which "Go to …" menu items to display and +/// whether to show a status icon indicating that the extension is connected. +class RohdModuleInfo { + /// Whether the ROHD extension responded to the query. + /// + /// `false` means the extension is not installed, not running, or the + /// viewer is operating in a context where the extension is not reachable + /// (e.g. fully standalone mode). + final bool extensionAvailable; + + /// The module definition name that was queried (e.g. `'Counter_L1_'`). + final String? module; + + /// Per-format availability, keyed by [RohdSourceFormat]. + /// + /// Only formats mentioned here have been checked; absent entries mean + /// "unknown / not checked". + final Map formats; + + /// A human-readable error message if the query failed, null otherwise. + final String? error; + + /// Whether the DTD `rohd` service appears healthy for source navigation. + /// + /// `null` means the client did not perform a DTD health check. `false` + /// means the service is missing, incomplete, or registered by an owner that + /// does not advertise the expected ROHD bridge capabilities. + final bool? dtdHealthy; + + /// True when the DTD `rohd` service is registered but does not advertise + /// the expected ROHD bridge capability marker. + final bool dtdRegistrationConflict; + + /// Human-readable DTD health detail for UI display. + final String? dtdStatusMessage; + + /// `true` while the extension is still loading an FST file asynchronously. + final bool fstLoading; + + const RohdModuleInfo({ + required this.extensionAvailable, + this.module, + this.formats = const {}, + this.error, + this.dtdHealthy, + this.dtdRegistrationConflict = false, + this.dtdStatusMessage, + this.fstLoading = false, + }); + + /// Sentinel value used when the extension is not available. + static const RohdModuleInfo unavailable = RohdModuleInfo( + extensionAvailable: false, + ); + + // ── Convenience accessors ───────────────────────────────────────────────── + + /// Whether the module has ROHD Dart source available and found on disk. + bool get hasRohd => formats[RohdSourceFormat.rohd]?.usable ?? false; + + /// Whether the module has SystemVerilog output available and found on disk. + bool get hasSv => formats[RohdSourceFormat.sv]?.usable ?? false; + + /// Whether the module has SystemC output available and found on disk. + bool get hasSc => formats[RohdSourceFormat.sc]?.usable ?? false; + + /// Whether a waveform file (FST/VCD) is available and found on disk. + bool get hasFst => formats[RohdSourceFormat.fst]?.usable ?? false; + + /// All format names for which data is available (as lower-case strings). + List get availableFormatNames => [ + if (hasRohd) 'rohd', + if (hasSv) 'sv', + if (hasSc) 'sc', + if (hasFst) 'fst', + ]; + + /// True when any source navigation format is available. + bool get hasAnySource => hasRohd || hasSv || hasSc; + + /// All source-navigable formats (ROHD, SV, SystemC) that are usable for + /// this module, in display order. Excludes [RohdSourceFormat.fst] (a + /// waveform, not a navigable source). + List get navigableSourceFormats => [ + if (hasRohd) RohdSourceFormat.rohd, + if (hasSv) RohdSourceFormat.sv, + if (hasSc) RohdSourceFormat.sc, + ]; + + /// Human-readable label for a format. + static String formatLabel(RohdSourceFormat fmt) => switch (fmt) { + RohdSourceFormat.rohd => 'ROHD (Dart)', + RohdSourceFormat.sv => 'SystemVerilog', + RohdSourceFormat.sc => 'SystemC', + RohdSourceFormat.fst => 'Waveform (FST)', + }; + + /// Build from a JSON map (as returned by the extension host or DTD). + factory RohdModuleInfo.fromJson(Map json) { + final available = json['extensionAvailable'] as bool? ?? false; + final module = json['module'] as String?; + final error = json['error'] as String?; + final dtdHealthy = json['dtdHealthy'] as bool?; + final dtdRegistrationConflict = + json['dtdRegistrationConflict'] as bool? ?? false; + final dtdStatusMessage = json['dtdStatusMessage'] as String?; + final fstLoading = json['fstLoading'] as bool? ?? false; + + final rawFormats = json['formats'] as Map? ?? const {}; + final formats = {}; + for (final entry in rawFormats.entries) { + final fmt = _parseFormat(entry.key); + if (fmt == null) continue; + final fmtMap = entry.value as Map? ?? const {}; + formats[fmt] = RohdFormatInfo( + available: fmtMap['available'] as bool? ?? false, + fileFound: fmtMap['fileFound'] as bool? ?? false, + path: fmtMap['path'] as String?, + ); + } + + return RohdModuleInfo( + extensionAvailable: available, + module: module, + formats: formats, + error: error, + dtdHealthy: dtdHealthy, + dtdRegistrationConflict: dtdRegistrationConflict, + dtdStatusMessage: dtdStatusMessage, + fstLoading: fstLoading, + ); + } + + /// Serialize to JSON for transmission over DTD or postMessage. + Map toJson() => { + 'extensionAvailable': extensionAvailable, + if (module != null) 'module': module, + 'formats': { + for (final e in formats.entries) + _formatKey(e.key): { + 'available': e.value.available, + 'fileFound': e.value.fileFound, + if (e.value.path != null) 'path': e.value.path, + }, + }, + if (error != null) 'error': error, + if (dtdHealthy != null) 'dtdHealthy': dtdHealthy, + if (dtdRegistrationConflict) + 'dtdRegistrationConflict': dtdRegistrationConflict, + if (dtdStatusMessage != null) 'dtdStatusMessage': dtdStatusMessage, + 'fstLoading': fstLoading, + }; + + static RohdSourceFormat? _parseFormat(String key) => switch (key) { + 'rohd' => RohdSourceFormat.rohd, + 'sv' => RohdSourceFormat.sv, + 'sc' => RohdSourceFormat.sc, + 'fst' => RohdSourceFormat.fst, + _ => null, + }; + + static String _formatKey(RohdSourceFormat fmt) => switch (fmt) { + RohdSourceFormat.rohd => 'rohd', + RohdSourceFormat.sv => 'sv', + RohdSourceFormat.sc => 'sc', + RohdSourceFormat.fst => 'fst', + }; +} From 422b4e8e69ca1fcc39cfcc436dc9cd697fd70922 Mon Sep 17 00:00:00 2001 From: "Desmond A. Kirkpatrick" Date: Sun, 28 Jun 2026 10:01:26 -0700 Subject: [PATCH 16/19] Add cross-probe go-to-source menu helpers to rohd_devtools_widgets Migrate cross_probe_menu.dart into the shared rohd_devtools_widgets package and export it from the barrel. Provides the AvailableSourceFormats and GoToSourceCallback typedefs, SourceFormatIconBuilder, kDefaultNavigableFormats, and the buildGotoSourceMenuItems helpers used by all viewers to build the generalized 'Go to Source' context-menu items. Depends only on package:flutter/material (already declared) and the sibling rohd_extension_status.dart, so no new dependencies are introduced. --- .../lib/rohd_devtools_widgets.dart | 1 + .../lib/src/cross_probe_menu.dart | 253 ++++++++++++++++++ 2 files changed, 254 insertions(+) create mode 100644 rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/cross_probe_menu.dart diff --git a/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/rohd_devtools_widgets.dart b/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/rohd_devtools_widgets.dart index f8abd0ada..b39f399bb 100644 --- a/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/rohd_devtools_widgets.dart +++ b/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/rohd_devtools_widgets.dart @@ -25,6 +25,7 @@ export 'src/save_png_stub.dart' // Cross-probing export 'src/cross_probe_service.dart'; export 'src/cross_probe_button.dart'; +export 'src/cross_probe_menu.dart'; // ROHD extension client export 'src/rohd_extension_status.dart'; diff --git a/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/cross_probe_menu.dart b/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/cross_probe_menu.dart new file mode 100644 index 000000000..b8134e4ad --- /dev/null +++ b/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/cross_probe_menu.dart @@ -0,0 +1,253 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// cross_probe_menu.dart +// Shared helpers for building generalized "Go to Source" cross-probe +// context-menu items across all viewers (wave, schematic, details). +// +// Viewers consult an [AvailableSourceFormats] query (backed by the cached +// ROHD extension module info) to discover which source languages are +// navigable, then build menu items via [buildGotoSourceMenuItems]. A single +// [GoToSourceCallback] handles the selection for every format, and the +// secondary frame picker (when a signal resolves to multiple frames) works +// uniformly for all formats. +// +// 2026 June +// Author: Desmond Kirkpatrick + +import 'package:flutter/material.dart'; + +import 'rohd_extension_status.dart'; + +/// Returns the source formats currently navigable for the active module. +/// +/// Must be synchronous (reads cached module info) so it can be consulted +/// while a popup menu is being built. +typedef AvailableSourceFormats = List Function(); + +/// Invoked when the user picks `Go to Source` for [signalPaths]. +typedef GoToSourceCallback = void Function( + RohdSourceFormat format, List signalPaths); + +/// Builds an icon for a source/output [format]. +typedef SourceFormatIconBuilder = Widget Function( + RohdSourceFormat format, { + double size, +}); + +/// Prefix used to encode source-navigation entries in a `String`-valued popup +/// menu (e.g. `'goto_source:rohd'`). Allows the shared items to coexist with +/// each viewer's other `String` menu values. +const String _gotoSourceValuePrefix = 'goto_source:'; + +/// Formats shown when source availability is unknown (module info not yet +/// loaded, the extension is unreachable, or the query errored). +const List kDefaultNavigableFormats = [ + RohdSourceFormat.rohd, + RohdSourceFormat.sv, +]; + +/// Encode a popup-menu value for navigating to [format]. +String gotoSourceMenuValue(RohdSourceFormat format) => + '$_gotoSourceValuePrefix${format.name}'; + +/// Decode a popup-menu value produced by [gotoSourceMenuValue]. +/// +/// Returns `null` when [value] is not a Go-to-Source entry, so callers can +/// fall through to handling their own menu values. +RohdSourceFormat? gotoSourceFormatFromValue(String? value) { + if (value == null || !value.startsWith(_gotoSourceValuePrefix)) { + return null; + } + final name = value.substring(_gotoSourceValuePrefix.length); + for (final f in RohdSourceFormat.values) { + if (f.name == name) { + return f; + } + } + return null; +} + +/// Short, menu-friendly name for [format] (e.g. `'ROHD'`, `'SV'`). +String gotoSourceShortName(RohdSourceFormat format) => switch (format) { + RohdSourceFormat.rohd => 'ROHD', + RohdSourceFormat.sv => 'SV', + RohdSourceFormat.sc => 'SystemC', + RohdSourceFormat.fst => 'Waveform', + }; + +/// Menu label for `Go to Source`, pluralized with [count]. +String gotoSourceMenuLabel(RohdSourceFormat format, {int count = 1}) { + final name = gotoSourceShortName(format); + return count <= 1 ? 'Go to $name Source' : 'Go to $name Source ($count)'; +} + +const _rohdIconAsset = 'assets/rohd_icon.png'; +const _systemVerilogIconAsset = 'assets/systemverilog_icon.png'; +const _systemCIconAsset = 'assets/systemc_icon.png'; + +/// App-bar-style icon for a source/output [format]. +Widget sourceFormatMenuIcon(RohdSourceFormat format, {double size = 18}) => + switch (format) { + RohdSourceFormat.rohd => _sourceFormatAssetIcon( + _rohdIconAsset, + semanticLabel: 'ROHD Source', + size: size, + ), + RohdSourceFormat.sv => _sourceFormatAssetIcon( + _systemVerilogIconAsset, + semanticLabel: 'SystemVerilog Source', + size: size, + ), + RohdSourceFormat.sc => _sourceFormatAssetIcon( + _systemCIconAsset, + semanticLabel: 'SystemC Source', + size: size, + ), + RohdSourceFormat.fst => Icon(Icons.timeline, size: size), + }; + +/// Backwards-compatible alias for [sourceFormatMenuIcon]. +Widget sourceFormatIcon(RohdSourceFormat format, {double size = 18}) => + sourceFormatMenuIcon(format, size: size); + +Widget _sourceFormatAssetIcon( + String asset, { + required String semanticLabel, + required double size, +}) => + Builder( + builder: (context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final image = Image.asset( + asset, + width: size, + height: size, + fit: BoxFit.contain, + filterQuality: FilterQuality.high, + semanticLabel: semanticLabel, + errorBuilder: (context, error, stackTrace) => Image.asset( + asset, + package: 'rohd_devtools_widgets', + width: size, + height: size, + fit: BoxFit.contain, + filterQuality: FilterQuality.high, + semanticLabel: semanticLabel, + errorBuilder: (context, error, stackTrace) => + Icon(Icons.code, size: size), + ), + ); + + if (!isDark) return image; + + return Container( + width: size + 4, + height: size + 4, + decoration: const BoxDecoration( + color: Color(0xFFE0E0E0), + shape: BoxShape.circle, + ), + padding: const EdgeInsets.all(2), + child: image, + ); + }, + ); + +/// Standard popup-menu row with a fixed-width prefix icon and ellipsized label. +Widget sourcePopupMenuRow({ + required Widget icon, + required String label, + TextStyle? textStyle, + double iconSlotWidth = 22, + double gap = 8, +}) => + Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: iconSlotWidth, + child: Center(child: icon), + ), + SizedBox(width: gap), + Flexible( + child: Text(label, style: textStyle, overflow: TextOverflow.ellipsis), + ), + ], + ); + +/// Standard popup-menu item using the same fixed icon gutter as source rows. +PopupMenuItem buildRohdPopupMenuItem({ + required T value, + required Widget icon, + required String label, + double height = 32, + TextStyle? textStyle, + bool enabled = true, +}) => + PopupMenuItem( + value: value, + height: height, + enabled: enabled, + child: sourcePopupMenuRow( + icon: icon, + label: label, + textStyle: textStyle, + ), + ); + +/// Compact strip of source/output format icons for trace-picker menu rows. +Widget sourceFormatIconStrip({ + required Iterable formats, + SourceFormatIconBuilder iconBuilder = sourceFormatMenuIcon, + double size = 16, + double gap = 3, +}) { + final formatList = formats.toList(growable: false); + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (var i = 0; i < formatList.length; i++) ...[ + if (i > 0) SizedBox(width: gap), + iconBuilder(formatList[i], size: size), + ], + ], + ); +} + +/// Resolve which navigable source formats to display for [info]. +/// +/// When [info] is `null`, the extension is unavailable, or the query errored, +/// availability is treated as *unknown* and [kDefaultNavigableFormats] is +/// returned so the actions stay available while metadata converges. +/// Otherwise the exact set of usable navigable formats is returned (which may +/// be empty when the module genuinely has no source). +List resolveNavigableFormats(RohdModuleInfo? info) { + if (info == null || !info.extensionAvailable || info.error != null) { + return kDefaultNavigableFormats; + } + return info.navigableSourceFormats; +} + +/// Build `Go to Source` popup-menu items for [formats]. +/// +/// Each item carries a value encoded by [gotoSourceMenuValue]; decode the +/// chosen value with [gotoSourceFormatFromValue] in the menu's result handler. +List> buildGotoSourceMenuItems({ + required List formats, + int count = 1, + double height = 32, + TextStyle? textStyle, + bool showIcons = true, + SourceFormatIconBuilder iconBuilder = sourceFormatMenuIcon, +}) => + [ + for (final format in formats) + buildRohdPopupMenuItem( + value: gotoSourceMenuValue(format), + height: height, + icon: showIcons ? iconBuilder(format) : const SizedBox.shrink(), + label: gotoSourceMenuLabel(format, count: count), + textStyle: textStyle, + ), + ]; From f4969a3494d2b9a1aef10a0548bef9dc300060d1 Mon Sep 17 00:00:00 2001 From: "Desmond A. Kirkpatrick" Date: Sun, 28 Jun 2026 11:16:18 -0700 Subject: [PATCH 17/19] Add logic type utilities to rohd_devtools_widgets Migrate logic_type_utils.dart into the shared rohd_devtools_widgets package and export it from the barrel. Provides TypeFieldNode and expandLogicType for expanding LogicStructure/LogicArray type metadata and extracting sub-field values via bit-slicing, used by rohd-schematic-viewer. Pure Dart with no imports, so no new dependencies are introduced. --- .../lib/rohd_devtools_widgets.dart | 3 + .../lib/src/logic_type_utils.dart | 387 ++++++++++++++++++ 2 files changed, 390 insertions(+) create mode 100644 rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/logic_type_utils.dart diff --git a/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/rohd_devtools_widgets.dart b/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/rohd_devtools_widgets.dart index b39f399bb..428d6114c 100644 --- a/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/rohd_devtools_widgets.dart +++ b/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/rohd_devtools_widgets.dart @@ -27,6 +27,9 @@ export 'src/cross_probe_service.dart'; export 'src/cross_probe_button.dart'; export 'src/cross_probe_menu.dart'; +// Logic type utilities +export 'src/logic_type_utils.dart'; + // ROHD extension client export 'src/rohd_extension_status.dart'; export 'src/rohd_extension_client.dart'; diff --git a/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/logic_type_utils.dart b/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/logic_type_utils.dart new file mode 100644 index 000000000..fc6151127 --- /dev/null +++ b/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/logic_type_utils.dart @@ -0,0 +1,387 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// logic_type_utils.dart +// Utilities for expanding LogicStructure/LogicArray type metadata and +// extracting sub-field values via bit-slicing. +// +// 2026 May +// Author: Desmond Kirkpatrick + +/// A node in the expanded type tree, used for structured display. +class TypeFieldNode { + /// Field name (e.g. "mantissa", "[0]"). + final String name; + + /// Bit width of this field. + final int width; + + /// Extracted value string for this field, or null if unavailable. + final String? value; + + /// Child fields for nested structs/arrays. + final List children; + + /// The bit range within the parent: [startBit, endBit) (LSB-first). + final int startBit; + + /// Creates a type field node. + TypeFieldNode({ + required this.name, + required this.width, + this.value, + this.children = const [], + this.startBit = 0, + }); +} + +/// Expand a `logic_type` metadata map into a tree of [TypeFieldNode]s. +/// +/// If [parentValue] is provided (as a binary string, MSB-first), sub-field +/// values are extracted via bit-slicing. +/// +/// The `logic_type` format for structs: +/// ```json +/// {"typeName": "FloatingPoint", "fields": [ +/// {"name": "mantissa", "width": 4, "bits": [0,1,2,3]}, +/// {"name": "exponent", "width": 4, "bits": [4,5,6,7]}, +/// {"name": "sign", "width": 1, "bits": [8]} +/// ]} +/// ``` +/// +/// For arrays: +/// ```json +/// {"width": 80, "arrayDims": [10], "elementWidth": 8} +/// ``` +List expandLogicType( + Map? logicType, { + String? parentBinaryValue, +}) { + if (logicType == null) return const []; + + // Struct case + final fields = logicType['fields'] as List?; + if (fields != null) { + return _expandStructFields(fields, parentBinaryValue); + } + + // Array case + final arrayDims = logicType['arrayDims'] as List?; + if (arrayDims != null) { + final elementWidth = (logicType['elementWidth'] as int?) ?? 1; + final elementType = logicType['elementType'] as Map?; + return _expandArrayElements( + arrayDims.cast(), + elementWidth, + elementType, + parentBinaryValue, + ); + } + + return const []; +} + +/// Expand struct fields from the `fields` list. +List _expandStructFields( + List fields, + String? parentBinaryValue, +) { + // The `bits` arrays in struct metadata may use module-level absolute + // indices (e.g. [390..398] for a 9-bit signal). Normalize to signal- + // relative indices by subtracting the global minimum across all fields. + var baseOffset = 0; + if (parentBinaryValue != null) { + var minBit = 1 << 30; + for (final fieldRaw in fields) { + final field = fieldRaw as Map; + final bits = field['bits'] as List?; + if (bits != null && bits.isNotEmpty) { + for (final b in bits) { + final bInt = b as int; + if (bInt < minBit) minBit = bInt; + } + } + } + // Only apply offset if the bits exceed the binary value length, + // indicating module-level absolute indices. + if (minBit > 0 && minBit >= parentBinaryValue.length) { + baseOffset = minBit; + } + } + + final nodes = []; + for (final fieldRaw in fields) { + final field = fieldRaw as Map; + final name = field['name'] as String? ?? '?'; + final width = field['width'] as int? ?? 1; + final bits = field['bits'] as List?; + final nestedType = field['type'] as Map?; + + // Normalize bits to signal-relative indices. + final relativeBits = bits?.cast().map((b) => b - baseOffset).toList(); + + // Determine start bit from bits array (min value, relative). + final startBit = relativeBits != null && relativeBits.isNotEmpty + ? relativeBits.reduce((a, b) => a < b ? a : b) + : 0; + + // Extract value for this field. + String? fieldValue; + if (parentBinaryValue != null && + relativeBits != null && + relativeBits.isNotEmpty) { + fieldValue = _extractBitsFromBinary(parentBinaryValue, relativeBits); + } + + // Recursively expand nested types. + final children = nestedType != null + ? expandLogicType(nestedType, parentBinaryValue: fieldValue) + : const []; + + nodes.add( + TypeFieldNode( + name: name, + width: width, + value: fieldValue, + children: children, + startBit: startBit, + ), + ); + } + return nodes; +} + +/// Expand array elements. +List _expandArrayElements( + List dims, + int elementWidth, + Map? elementType, + String? parentBinaryValue, +) { + if (dims.isEmpty) return const []; + + final outerDim = dims.first; + final nodes = []; + + // The `elementWidth` from logicType is the LEAF element width. + // For multi-dimensional arrays the actual per-element width at this level + // is the product of remaining dimensions Γ— leaf element width. + // Derive it from the parent binary length when available, or compute it. + final int stride; + if (parentBinaryValue != null && parentBinaryValue.isNotEmpty) { + stride = parentBinaryValue.length ~/ outerDim; + } else if (dims.length > 1) { + // Product of remaining dims Γ— leaf element width. + var product = elementWidth; + for (var d = 1; d < dims.length; d++) { + product *= dims[d]; + } + stride = product; + } else { + stride = elementWidth; + } + + for (var i = 0; i < outerDim; i++) { + final startBit = i * stride; + + // Extract this element's value. + String? elementValue; + if (parentBinaryValue != null) { + elementValue = _extractContiguousBits( + parentBinaryValue, + startBit, + stride, + ); + } + + // For multi-dimensional arrays, recurse into inner dimensions. + List children; + if (dims.length > 1) { + // Only propagate elementType if it describes a leaf-level type (e.g. + // a struct). When elementType itself has 'arrayDims', it merely + // re-describes the intermediate structure already encoded in the + // parent's flat dims list β€” propagating it would incorrectly expand + // leaf elements with children that exceed their bit width. + final propagateElementType = + elementType != null && !elementType.containsKey('arrayDims'); + final innerType = { + 'arrayDims': dims.sublist(1), + 'elementWidth': elementWidth, + if (propagateElementType) 'elementType': elementType, + }; + children = expandLogicType(innerType, parentBinaryValue: elementValue); + } else if (elementType != null) { + children = expandLogicType(elementType, parentBinaryValue: elementValue); + } else { + children = const []; + } + + nodes.add( + TypeFieldNode( + name: '[$i]', + width: stride, + value: elementValue, + children: children, + startBit: startBit, + ), + ); + } + return nodes; +} + +/// Extract specified bit indices from a binary string (MSB-first format). +/// +/// The binary string is MSB-first: index 0 is the rightmost (LSB) bit. +/// The [bitIndices] are LSB-indexed (matching the netlist `bits` array). +String _extractBitsFromBinary(String binaryValue, List bitIndices) { + final totalWidth = binaryValue.length; + final result = StringBuffer(); + + // Sort indices descending to produce MSB-first output. + final sorted = List.from(bitIndices)..sort((a, b) => b.compareTo(a)); + + for (final idx in sorted) { + // Convert LSB index to MSB-first string position. + final pos = totalWidth - 1 - idx; + if (pos >= 0 && pos < totalWidth) { + result.write(binaryValue[pos]); + } else { + result.write('x'); + } + } + return result.toString(); +} + +/// Extract a contiguous bit range from a binary string (MSB-first format). +/// +/// [startBit] is the LSB index, [width] is the number of bits. +String _extractContiguousBits(String binaryValue, int startBit, int width) { + final totalWidth = binaryValue.length; + final endBit = startBit + width; // exclusive + final result = StringBuffer(); + + // Extract MSB-first. + for (var i = endBit - 1; i >= startBit; i--) { + final pos = totalWidth - 1 - i; + if (pos >= 0 && pos < totalWidth) { + result.write(binaryValue[pos]); + } else { + result.write('x'); + } + } + return result.toString(); +} + +/// Convert a hex value string (e.g. "0x1a3f" or "1a3f") to binary (MSB-first). +/// +/// Returns null if the input can't be parsed. +String? hexToBinary(String hexValue, int width) { + var cleaned = hexValue.trim().toLowerCase(); + if (cleaned.startsWith('0x')) { + cleaned = cleaned.substring(2); + } + // Handle 'x' or 'z' values. + if (cleaned.contains('x') || cleaned.contains('z')) { + // Expand each hex digit to 4 binary digits, preserving x/z. + final buf = StringBuffer(); + for (final ch in cleaned.split('')) { + if (ch == 'x') { + buf.write('xxxx'); + } else if (ch == 'z') { + buf.write('zzzz'); + } else { + final nibble = int.tryParse(ch, radix: 16); + if (nibble == null) return null; + buf.write(nibble.toRadixString(2).padLeft(4, '0')); + } + } + final full = buf.toString(); + // Trim or pad to desired width. + if (full.length >= width) { + return full.substring(full.length - width); + } + return full.padLeft(width, '0'); + } + + final bigInt = BigInt.tryParse(cleaned, radix: 16); + if (bigInt == null) return null; + final binary = bigInt.toRadixString(2); + if (binary.length >= width) { + return binary.substring(binary.length - width); + } + return binary.padLeft(width, '0'); +} + +/// Format a binary field value for display. +/// +/// Short values (<=4 bits) show as binary. Longer values show as hex. +/// Uses ROHD radixString style: width'hHEX. +String formatFieldValue(String? binaryValue, int width) { + if (binaryValue == null || binaryValue.isEmpty) return ''; + if (binaryValue.contains('x')) return "$width'hx"; + if (binaryValue.contains('z')) return "$width'hz"; + if (width <= 4) return "$width'b$binaryValue"; + // Convert to hex. + final bigInt = BigInt.tryParse(binaryValue, radix: 2); + if (bigInt == null) return binaryValue; + final hexDigits = (width + 3) ~/ 4; + final hex = bigInt.toRadixString(16).padLeft(hexDigits, '0'); + return "$width'h$hex"; +} + +/// Build a multi-line indented string showing struct/array fields with values. +/// +/// Used for schematic hover tooltips. +String formatTypeTooltip( + Map? logicType, { + String? parentBinaryValue, + String? signalName, + int maxDepth = 6, +}) { + if (logicType == null) return ''; + + final nodes = expandLogicType( + logicType, + parentBinaryValue: parentBinaryValue, + ); + if (nodes.isEmpty) return ''; + + final buf = StringBuffer(); + final typeName = logicType['typeName'] as String?; + if (signalName != null) { + buf.write(signalName); + if (typeName != null) buf.write(' ($typeName)'); + buf.writeln(); + } else if (typeName != null) { + buf.writeln(typeName); + } + + for (final node in nodes) { + _formatNode(buf, node, indent: 1, maxDepth: maxDepth); + } + return buf.toString().trimRight(); +} + +void _formatNode( + StringBuffer buf, + TypeFieldNode node, { + required int indent, + required int maxDepth, +}) { + final pad = ' ' * indent; + buf.write('$pad${node.name}'); + if (node.value != null) { + buf.write(': ${formatFieldValue(node.value, node.width)}'); + } else { + buf.write(' [${node.width}]'); + } + buf.writeln(); + + if (indent < maxDepth) { + for (final child in node.children) { + _formatNode(buf, child, indent: indent + 1, maxDepth: maxDepth); + } + } else if (node.children.isNotEmpty) { + buf.writeln('$pad ...'); + } +} From eec9824c4200e90041de7552ee51ceacfe5e7eb1 Mon Sep 17 00:00:00 2001 From: "Desmond A. Kirkpatrick" Date: Sun, 28 Jun 2026 11:17:45 -0700 Subject: [PATCH 18/19] Add pixelRatio and saveFn options to captureBoundaryToPng Update capture_boundary.dart with two additive, backward-compatible parameters: - pixelRatio (default 2.0) so callers can request higher-resolution output; the schematic viewer passes a larger value for print-quality PNG exports. - saveFn (optional) so callers such as the VS Code webview host can route PNG bytes through a native Save dialog instead of the default platform download. No new dependencies: uses only the Flutter SDK and the package's own savePngBytes/showExportToast already exported from the barrel. --- .../lib/src/capture_boundary.dart | 26 ++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/capture_boundary.dart b/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/capture_boundary.dart index 4512ed207..172f79d75 100644 --- a/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/capture_boundary.dart +++ b/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/capture_boundary.dart @@ -7,7 +7,7 @@ // 2026 April // Author: Desmond Kirkpatrick -import 'dart:math' as math; +import 'dart:typed_data' show Uint8List; import 'dart:ui' as ui; import 'package:flutter/material.dart'; @@ -21,11 +21,24 @@ import 'package:rohd_devtools_widgets/rohd_devtools_widgets.dart' as export_png; /// [filePrefix] is used as the first part of the file name /// (e.g. `"schematic"` β†’ `schematic_1713052800000.png`). /// +/// When [saveFn] is provided it is used **instead** of the default platform +/// save/download. This allows callers (e.g. VS Code webview hosts) to route +/// the PNG bytes through a native Save dialog. [saveFn] receives the raw PNG +/// bytes and a suggested file name, and should return the saved path (or null +/// if no path feedback is available). +/// +/// [pixelRatio] controls the output resolution multiplier. Defaults to 2.0 +/// which works well in webview-constrained environments and keeps PNG sizes +/// manageable for postMessage serialisation. Callers that need print-quality +/// output (e.g. schematic exports) should pass a higher value explicitly. +/// /// Returns `true` if the export succeeded. Future captureBoundaryToPng( BuildContext context, { required GlobalKey boundaryKey, String filePrefix = 'export', + double pixelRatio = 2.0, + Future Function(Uint8List pngBytes, String fileName)? saveFn, }) async { final boundary = boundaryKey.currentContext?.findRenderObject() as RenderRepaintBoundary?; @@ -34,10 +47,6 @@ Future captureBoundaryToPng( 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(); @@ -51,7 +60,12 @@ Future captureBoundaryToPng( final fileName = '${filePrefix}_${DateTime.now().millisecondsSinceEpoch}.png'; try { - final savedPath = await export_png.savePngBytes(pngBytes, fileName); + final String? savedPath; + if (saveFn != null) { + savedPath = await saveFn(pngBytes, fileName); + } else { + savedPath = await export_png.savePngBytes(pngBytes, fileName); + } final msg = savedPath != null ? 'Saved: $savedPath' : 'Downloaded $fileName'; debugPrint('[ExportPng] $msg'); From fafbf1e42125e640e6f453ce69a98b7ed8d4a0d5 Mon Sep 17 00:00:00 2001 From: "Desmond A. Kirkpatrick" Date: Sun, 28 Jun 2026 14:19:36 -0700 Subject: [PATCH 19/19] Add bit-field utilities to rohd_devtools_widgets Move bit_field_utils.dart (BitFieldDef, BitFieldUtils, define-bit-fields dialog) and bit_expansion_menu.dart (BitExpansionAction, BitExpandRangeAction, the Expand Bits / Define Bit Fields popup helpers) into the widgets package and export them from the barrel library. These were previously carried only as a post-merge overlay, so decoupled consumers (e.g. the standalone wave viewer) that depend on the devtools-branch package could not resolve BitFieldDef / BitExpandRangeAction. Self-contained: only depends on flutter/material. --- .../lib/rohd_devtools_widgets.dart | 6 + .../lib/src/bit_expansion_menu.dart | 139 ++++++++++ .../lib/src/bit_field_utils.dart | 257 ++++++++++++++++++ 3 files changed, 402 insertions(+) create mode 100644 rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/bit_expansion_menu.dart create mode 100644 rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/bit_field_utils.dart diff --git a/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/rohd_devtools_widgets.dart b/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/rohd_devtools_widgets.dart index 428d6114c..452567fae 100644 --- a/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/rohd_devtools_widgets.dart +++ b/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/rohd_devtools_widgets.dart @@ -30,6 +30,12 @@ export 'src/cross_probe_menu.dart'; // Logic type utilities export 'src/logic_type_utils.dart'; +// Bit-field parsing, formatting, and dialog utilities +export 'src/bit_field_utils.dart'; + +// Shared "Expand Bits" / "Define Bit Fields" popup-menu helpers +export 'src/bit_expansion_menu.dart'; + // ROHD extension client export 'src/rohd_extension_status.dart'; export 'src/rohd_extension_client.dart'; diff --git a/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/bit_expansion_menu.dart b/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/bit_expansion_menu.dart new file mode 100644 index 000000000..c01c55e31 --- /dev/null +++ b/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/bit_expansion_menu.dart @@ -0,0 +1,139 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// bit_expansion_menu.dart +// Shared popup-menu items and dispatcher for the "Expand Bits" and +// "Define Bit Fields" actions. Used by all three surfaces that offer +// per-signal right-click menus: the waveform Signal-Selection overlay, +// the Selected-Signals panel, and the embedded Signal Details pane. +// +// 2026 January +// Author: Desmond Kirkpatrick + +import 'package:flutter/material.dart'; + +import 'bit_field_utils.dart'; + +/// Popup-menu values used by the bit-expansion items. +/// +/// Callers may match against these string constants when handling a +/// `showMenu` result that includes bit-expansion items. +abstract final class BitExpansionMenuValues { + /// Menu item: "Expand Bits [N]" β€” expand each bit (or a chosen range) + /// as a synthesized 1-bit waveform. + static const String expandBits = 'expand_bits'; + + /// Menu item: "Define Bit Fields [N]..." β€” open a dialog that lets the + /// user name arbitrary bit ranges. + static const String defineFields = 'define_fields'; +} + +/// Result of a bit-expansion menu interaction after any follow-up dialog +/// has resolved. Returned by [resolveBitExpansionMenuValue]. +sealed class BitExpansionAction { + const BitExpansionAction(); +} + +/// User picked "Expand Bits" and (implicitly or via dialog) chose the bit +/// range `[bitEnd:bitStart]` to expand into single-bit synthesized +/// waveforms. +class BitExpandRangeAction extends BitExpansionAction { + /// Low bit (inclusive) of the range to expand. + final int bitStart; + + /// High bit (inclusive) of the range to expand. + final int bitEnd; + + const BitExpandRangeAction(this.bitStart, this.bitEnd); +} + +/// User picked "Define Bit Fields..." and entered a non-empty list of +/// named [BitFieldDef]s. +class BitDefineFieldsAction extends BitExpansionAction { + /// The user-defined bit fields. + final List fields; + + const BitDefineFieldsAction(this.fields); +} + +/// Build the standard pair of popup-menu items shown when right-clicking +/// a single multi-bit signal: +/// +/// - **Expand Bits [width]** +/// - **Define Bit Fields [width]...** +/// +/// Callers should typically append these items to their existing +/// `PopupMenuEntry` list only when the signal selection contains +/// exactly one signal whose width is > 1. +/// +/// The optional [includeDivider] inserts a [PopupMenuDivider] before the +/// items so they visually separate from preceding items. +List> buildBitExpansionMenuItems({ + required int width, + double fontSize = 13, + double itemHeight = 32, + bool includeDivider = false, +}) { + return >[ + if (includeDivider) const PopupMenuDivider(height: 8), + PopupMenuItem( + height: itemHeight, + value: BitExpansionMenuValues.expandBits, + child: Text('Expand Bits [$width]', style: TextStyle(fontSize: fontSize)), + ), + PopupMenuItem( + height: itemHeight, + value: BitExpansionMenuValues.defineFields, + child: Text( + 'Define Bit Fields [$width]...', + style: TextStyle(fontSize: fontSize), + ), + ), + ]; +} + +/// Translate a popup-menu [value] returned by `showMenu` into a +/// [BitExpansionAction], showing any follow-up dialog as needed. +/// +/// Returns: +/// * [BitExpandRangeAction] when [value] is +/// [BitExpansionMenuValues.expandBits]. If [width] is at or below +/// [BitFieldUtils.expandThreshold] the full range `(0, width-1)` is +/// returned immediately. Otherwise [showBitRangeDialog] is invoked +/// and `null` is returned if the user cancels. +/// * [BitDefineFieldsAction] when [value] is +/// [BitExpansionMenuValues.defineFields]. [showDefineBitFieldsDialog] +/// is invoked; `null` is returned if the user cancels or enters no +/// fields. +/// * `null` for any other value (callers should handle their own +/// non-bit-expansion menu items first). +Future resolveBitExpansionMenuValue( + BuildContext context, { + required String? value, + required String signalName, + required int width, +}) async { + if (value == BitExpansionMenuValues.expandBits) { + if (width <= BitFieldUtils.expandThreshold) { + return BitExpandRangeAction(0, width - 1); + } + final parsed = await showBitRangeDialog( + context, + signalName: signalName, + width: width, + ); + if (parsed == null) return null; + final (high, low) = parsed; + return BitExpandRangeAction(low, high); + } + if (value == BitExpansionMenuValues.defineFields) { + final fields = await showDefineBitFieldsDialog( + context, + signalName: signalName, + width: width, + ); + if (fields == null || fields.isEmpty) return null; + return BitDefineFieldsAction(fields); + } + return null; +} diff --git a/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/bit_field_utils.dart b/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/bit_field_utils.dart new file mode 100644 index 000000000..eced510d0 --- /dev/null +++ b/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/bit_field_utils.dart @@ -0,0 +1,257 @@ +// Copyright (C) 2024-2025 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// bit_field_utils.dart +// Shared bit-field parsing, formatting, and dialog utilities. +// +// 2025 May +// Author: Desmond Kirkpatrick + +import 'package:flutter/material.dart'; + +/// A named bit-field definition within a bitvector signal. +class BitFieldDef { + /// Display name for this field (e.g. "exponent", "mantissa"). + final String name; + + /// High bit (inclusive, MSB of the field). + final int high; + + /// Low bit (inclusive, LSB of the field). + final int low; + + const BitFieldDef({ + required this.name, + required this.high, + required this.low, + }); + + /// Width of this field in bits. + int get width => high - low + 1; +} + +/// Shared utilities for parsing and formatting bit-field definitions. +abstract final class BitFieldUtils { + /// Number of elements/bits above which a confirmation pop-up is shown + /// before expanding an array, struct, or bitvector. + static const int expandThreshold = 8; + + /// Format a bit-range label from [startBit] and [width]. + /// + /// Returns e.g. `[7:4]` for startBit=4, width=4 or `[0]` for width=1. + static String formatBitRange(int startBit, int width) { + final highBit = startBit + width - 1; + return highBit == startBit ? '[$startBit]' : '[$highBit:$startBit]'; + } + + /// Parse a bit range string (`high:low`) or single bit index. + /// + /// Returns `(high, low)` clamped to `[0, maxBit]`, or `null` if invalid. + static (int, int)? parseBitRange(String input, int maxBit) { + if (input.contains(':')) { + final parts = input.split(':'); + if (parts.length != 2) return null; + final high = int.tryParse(parts[0].trim()); + final low = int.tryParse(parts[1].trim()); + if (high == null || low == null) return null; + final h = high.clamp(0, maxBit); + final l = low.clamp(0, maxBit); + return h >= l ? (h, l) : (l, h); + } + final bit = int.tryParse(input); + if (bit == null) return null; + final clamped = bit.clamp(0, maxBit); + return (clamped, clamped); + } + + /// Parse multi-line field definitions into [BitFieldDef] objects. + /// + /// Accepted formats per line: + /// - `name high:low` (e.g. `exponent 31:21`) + /// - `name high` (single bit, e.g. `sign 31`) + /// - `high:low` (unnamed, displayed as `[high:low]`) + /// - `bit` (unnamed single bit, displayed as `[bit]`) + static List parseBitFieldDefs(String input, int maxBit) { + final lines = input.split('\n'); + final fields = []; + for (final rawLine in lines) { + final line = rawLine.trim(); + if (line.isEmpty) continue; + + // Try: name high:low + final namedRange = RegExp(r'^(\w+)\s+(\d+):(\d+)$').firstMatch(line); + if (namedRange != null) { + final name = namedRange.group(1)!; + final a = int.parse(namedRange.group(2)!).clamp(0, maxBit); + final b = int.parse(namedRange.group(3)!).clamp(0, maxBit); + final high = a >= b ? a : b; + final low = a >= b ? b : a; + fields.add(BitFieldDef(name: name, high: high, low: low)); + continue; + } + + // Try: name bit (single bit) + final namedSingle = RegExp(r'^(\w+)\s+(\d+)$').firstMatch(line); + if (namedSingle != null) { + final name = namedSingle.group(1)!; + final bit = int.parse(namedSingle.group(2)!).clamp(0, maxBit); + fields.add(BitFieldDef(name: name, high: bit, low: bit)); + continue; + } + + // Try: high:low (unnamed) + final anonRange = RegExp(r'^(\d+):(\d+)$').firstMatch(line); + if (anonRange != null) { + final a = int.parse(anonRange.group(1)!).clamp(0, maxBit); + final b = int.parse(anonRange.group(2)!).clamp(0, maxBit); + final high = a >= b ? a : b; + final low = a >= b ? b : a; + fields.add(BitFieldDef(name: '[$high:$low]', high: high, low: low)); + continue; + } + + // Try: single number (unnamed single bit) + final anonSingle = RegExp(r'^(\d+)$').firstMatch(line); + if (anonSingle != null) { + final bit = int.parse(anonSingle.group(1)!).clamp(0, maxBit); + fields.add(BitFieldDef(name: '[$bit]', high: bit, low: bit)); + continue; + } + } + return fields; + } +} + +/// Show a dialog to select a bit range for a signal. +/// +/// Returns `(high, low)` or `null` if cancelled. +Future<(int, int)?> showBitRangeDialog( + BuildContext context, { + required String signalName, + required int width, +}) async { + final maxBit = width - 1; + final controller = TextEditingController(text: '$maxBit:0'); + controller.selection = TextSelection( + baseOffset: 0, + extentOffset: controller.text.length, + ); + + final result = await showDialog( + context: context, + barrierColor: Colors.black26, + builder: (ctx) { + return AlertDialog( + title: Text( + '$signalName [$width bits]', + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold), + ), + content: TextField( + controller: controller, + autofocus: true, + decoration: InputDecoration( + labelText: 'Bit range (high:low) or single bit', + hintText: '$maxBit:0', + isDense: true, + ), + onSubmitted: (value) => Navigator.of(ctx).pop(value), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.of(ctx).pop(controller.text), + child: const Text('OK'), + ), + ], + ); + }, + ); + + if (result == null || result.trim().isEmpty) return null; + return BitFieldUtils.parseBitRange(result.trim(), maxBit); +} + +/// Show a dialog to define named bit-field slices on a signal. +/// +/// Returns the parsed [BitFieldDef] list, or `null` if cancelled/empty. +Future?> showDefineBitFieldsDialog( + BuildContext context, { + required String signalName, + required int width, + List? existingDefs, +}) async { + final maxBit = width - 1; + + // Pre-fill with existing definitions if re-editing; append a trailing + // newline and place the cursor at the end so the user can immediately + // type additional fields without accidentally replacing existing ones. + final hasExisting = existingDefs != null && existingDefs.isNotEmpty; + final initialText = hasExisting + ? '${existingDefs.map((f) { + return f.high == f.low + ? '${f.name} ${f.high}' + : '${f.name} ${f.high}:${f.low}'; + }).join('\n')}\n' + : 'field0 $maxBit:0'; + + final controller = TextEditingController(text: initialText); + if (hasExisting) { + // Cursor at the end (after trailing newline) β€” ready for a new field. + controller.selection = TextSelection.collapsed( + offset: controller.text.length, + ); + } else { + // First time: select all default text for easy replacement. + controller.selection = TextSelection( + baseOffset: 0, + extentOffset: controller.text.length, + ); + } + + final result = await showDialog( + context: context, + barrierColor: Colors.black26, + builder: (ctx) { + return AlertDialog( + title: Text( + '$signalName [$width bits] β€” Define Fields', + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold), + ), + content: SizedBox( + width: 320, + child: TextField( + controller: controller, + autofocus: true, + maxLines: 8, + minLines: 3, + style: const TextStyle(fontFamily: 'monospace', fontSize: 13), + decoration: InputDecoration( + labelText: 'One field per line: name high:low', + hintText: 'exponent $maxBit:${maxBit - 10}\n' + 'mantissa ${maxBit - 11}:0', + isDense: true, + border: const OutlineInputBorder(), + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.of(ctx).pop(controller.text), + child: const Text('OK'), + ), + ], + ); + }, + ); + + if (result == null || result.trim().isEmpty) return null; + final fields = BitFieldUtils.parseBitFieldDefs(result, maxBit); + return fields.isEmpty ? null : fields; +}