From 4532f55f8bb6efefc8a9a4cf24927cddab6e01fb Mon Sep 17 00:00:00 2001 From: "Desmond A. Kirkpatrick" Date: Wed, 3 Jun 2026 15:53:21 -0700 Subject: [PATCH 1/5] 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('