diff --git a/Source/Libraries/GSF.TimeSeries/UI/Converters/MenuItemIconConverter.cs b/Source/Libraries/GSF.TimeSeries/UI/Converters/MenuItemIconConverter.cs
new file mode 100644
index 00000000000..6590d23b5a6
--- /dev/null
+++ b/Source/Libraries/GSF.TimeSeries/UI/Converters/MenuItemIconConverter.cs
@@ -0,0 +1,213 @@
+//******************************************************************************************************
+// MenuItemIconConverter.cs - Gbtc
+//
+// Copyright © 2026, Grid Protection Alliance. All Rights Reserved.
+//
+// Licensed to the Grid Protection Alliance (GPA) under one or more contributor license agreements. See
+// the NOTICE file distributed with this work for additional information regarding copyright ownership.
+// The GPA licenses this file to you under the MIT License (MIT), the "License"; you may
+// not use this file except in compliance with the License. You may obtain a copy of the License at:
+//
+// http://www.opensource.org/licenses/MIT
+//
+// Unless agreed to in writing, the subject software distributed under the License is distributed on an
+// "AS-IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. Refer to the
+// License for the specific language governing permissions and limitations.
+//
+// Code Modification History:
+// ----------------------------------------------------------------------------------------------------
+// 06/19/2026 - J. Ritchie Carroll
+// Generated original version of source code.
+//
+//******************************************************************************************************
+
+using System;
+using System.Globalization;
+using System.IO;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Data;
+using System.Windows.Interop;
+using System.Windows.Media;
+using System.Windows.Media.Imaging;
+using GSF.IO;
+using DrawingIcon = System.Drawing.Icon;
+
+namespace GSF.TimeSeries.UI.Converters
+{
+ ///
+ /// Represents an that produces the image to display in the icon
+ /// region of a menu item from an icon reference and/or an external process path.
+ ///
+ ///
+ /// The converter expects two bound values: the first is the explicit icon reference (i.e., the menu item's
+ /// Icon value) and the second is the external process path (i.e., the menu item's
+ /// ExternalProcessPath value). When the icon reference is defined, the referenced image is loaded and
+ /// displayed. When no icon is defined but an external process path is set, the embedded application icon for
+ /// the external process is extracted and used instead. The converter returns a new instance
+ /// on each evaluation so that the result can be safely assigned to the property from
+ /// within a shared setter (a single instance cannot be the
+ /// logical child of multiple menu items).
+ ///
+ public class MenuItemIconConverter : IMultiValueConverter
+ {
+ ///
+ /// Default width and height, in device-independent pixels, of a generated menu item icon.
+ ///
+ public const double DefaultIconSize = 16.0D;
+
+ ///
+ /// Returns an element representing the icon for a menu item.
+ ///
+ ///
+ /// The bound values: values[0] is the explicit icon reference and values[1] is the external
+ /// process path.
+ ///
+ /// The type of the binding target property.
+ /// Optional icon size override; when parsable as a , overrides .
+ /// The culture to use in the converter.
+ /// An when an icon could be resolved; otherwise, null.
+ public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
+ {
+ if ((object)values == null)
+ return null;
+
+ string icon = values.Length > 0 ? values[0] as string : null;
+ string externalProcessPath = values.Length > 1 ? values[1] as string : null;
+
+ ImageSource source = null;
+
+ // (1) Use the explicitly defined icon when one is specified.
+ if (!string.IsNullOrWhiteSpace(icon))
+ source = LoadImageSource(icon);
+
+ // (2) Otherwise, fall back to the embedded icon of the external process, when defined.
+ if ((object)source == null && !string.IsNullOrWhiteSpace(externalProcessPath))
+ source = ExtractAssociatedIcon(externalProcessPath);
+
+ if ((object)source == null)
+ return null;
+
+ double size = DefaultIconSize;
+
+ if (parameter != null && double.TryParse(parameter.ToString(), NumberStyles.Any, CultureInfo.InvariantCulture, out double parsedSize) && parsedSize > 0.0D)
+ size = parsedSize;
+
+ return new Image
+ {
+ Source = source,
+ Width = size,
+ Height = size,
+ Stretch = Stretch.Uniform,
+ SnapsToDevicePixels = true
+ };
+ }
+
+ ///
+ /// Not supported; this converter only supports one-way conversion.
+ ///
+ public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture)
+ {
+ throw new NotSupportedException();
+ }
+
+ ///
+ /// Loads an from an icon reference that may be a WPF pack/resource URI or a file path.
+ ///
+ ///
+ /// Example to load an embedded image from the GSF.TimeSeries.UI assembly:
+ ///
+ /// <MenuDataItem Icon="/GSF.TimeSeries.UI;component/images/Configure.png" MenuText="Measurements" ... />
+ ///
+ ///
+ private static ImageSource LoadImageSource(string icon)
+ {
+ try
+ {
+ icon = Environment.ExpandEnvironmentVariables(icon.Trim());
+
+ Uri uri;
+
+ if (icon.StartsWith("pack://", StringComparison.OrdinalIgnoreCase))
+ {
+ // Absolute pack URI, e.g.: pack://application:,,,/GSF.TimeSeries.UI;component/images/Icon.png
+ uri = new Uri(icon, UriKind.Absolute);
+ }
+ else if (icon.StartsWith("/", StringComparison.Ordinal))
+ {
+ // Relative pack URI, e.g.: /GSF.TimeSeries.UI;component/images/Icon.png - resolved against the application.
+ uri = new Uri(icon, UriKind.Relative);
+ }
+ else if (Uri.TryCreate(icon, UriKind.Absolute, out Uri absoluteUri) && !absoluteUri.IsFile)
+ {
+ // Any other absolute URI (e.g. http://) is used as-is.
+ uri = absoluteUri;
+ }
+ else
+ {
+ // Treat as a file path, resolving relative paths against the application directory.
+ string path = FilePath.GetAbsolutePath(icon);
+
+ if (!File.Exists(path))
+ return null;
+
+ uri = new Uri(path, UriKind.Absolute);
+ }
+
+ BitmapImage bitmap = new BitmapImage();
+
+ bitmap.BeginInit();
+ bitmap.CacheOption = BitmapCacheOption.OnLoad;
+ bitmap.UriSource = uri;
+ bitmap.EndInit();
+ bitmap.Freeze();
+
+ return bitmap;
+ }
+ catch
+ {
+ // Icon is best-effort: a bad reference simply yields no icon rather than failing the menu render.
+ return null;
+ }
+ }
+
+ ///
+ /// Extracts the embedded application icon associated with an external process executable.
+ ///
+ private static ImageSource ExtractAssociatedIcon(string processPath)
+ {
+ try
+ {
+ processPath = Environment.ExpandEnvironmentVariables(processPath.Trim());
+
+ // System.Drawing.Icon.ExtractAssociatedIcon requires an existing local file path.
+ if (!File.Exists(processPath))
+ {
+ string resolvedPath = FilePath.GetAbsolutePath(processPath);
+
+ if (!File.Exists(resolvedPath))
+ return null;
+
+ processPath = resolvedPath;
+ }
+
+ using (DrawingIcon icon = DrawingIcon.ExtractAssociatedIcon(processPath))
+ {
+ if ((object)icon == null)
+ return null;
+
+ // CreateBitmapSourceFromHIcon copies the icon data, so the source GDI icon can be disposed afterward.
+ ImageSource source = Imaging.CreateBitmapSourceFromHIcon(icon.Handle, Int32Rect.Empty, BitmapSizeOptions.FromEmptyOptions());
+ source.Freeze();
+
+ return source;
+ }
+ }
+ catch
+ {
+ // Icon extraction is best-effort: failures simply yield no icon.
+ return null;
+ }
+ }
+ }
+}
diff --git a/Source/Libraries/GSF.TimeSeries/UI/GSF.TimeSeries.UI.csproj b/Source/Libraries/GSF.TimeSeries/UI/GSF.TimeSeries.UI.csproj
index b74922a77a8..eb4864f5c57 100755
--- a/Source/Libraries/GSF.TimeSeries/UI/GSF.TimeSeries.UI.csproj
+++ b/Source/Libraries/GSF.TimeSeries/UI/GSF.TimeSeries.UI.csproj
@@ -48,6 +48,7 @@
+
@@ -61,6 +62,7 @@
+
diff --git a/Source/Libraries/GSF.TimeSeries/UI/WPF/Commands/MenuCommand.cs b/Source/Libraries/GSF.TimeSeries/UI/WPF/Commands/MenuCommand.cs
index 4f58462cb98..0abc5afa4ef 100755
--- a/Source/Libraries/GSF.TimeSeries/UI/WPF/Commands/MenuCommand.cs
+++ b/Source/Libraries/GSF.TimeSeries/UI/WPF/Commands/MenuCommand.cs
@@ -29,6 +29,7 @@
using System.Diagnostics;
using System.Linq;
using System.Reflection;
+using System.Windows;
using System.Windows.Input;
using GSF.IO;
using GSF.Security;
@@ -185,26 +186,37 @@ public void Execute(object parameter)
bool hasUserControlPath = !string.IsNullOrEmpty(UserControlPath);
bool hasExternalProcessPath = !string.IsNullOrEmpty(ExternalProcessPath);
- if (hasUserControlAssembly != hasUserControlPath)
- throw new InvalidOperationException("UserControlAssembly and UserControlPath must both be populated or neither");
+ if (!hasExternalProcessPath && hasUserControlAssembly != hasUserControlPath)
+ throw new InvalidOperationException("UserControlAssembly and UserControlPath must both be populated or neither when ExternalProcessPath is not set");
if (hasUserControlAssembly == hasExternalProcessPath)
throw new InvalidOperationException("One of UserControlAssembly and ExternalProcessPath must be populated, but not both");
- try
+ if (hasExternalProcessPath)
{
- if (hasExternalProcessPath)
+ try
+ {
+ // If ExternalProcessPath requires arguments, they can be specified in the UserControlPath property
+ Process.Start(ExternalProcessPath, UserControlPath)?.Dispose();
+ }
+ catch (Exception ex)
{
- Process.Start(ExternalProcessPath).Dispose();
- return;
+ CommonFunctions.Popup($"Failed to launch external process \"{ExternalProcessPath}\": {ex.Message}", "Menu Launch Exception:", MessageBoxImage.Error);
+ CommonFunctions.LogException(null, "Menu Launch Exception", ex);
}
+ return;
+ }
- Assembly assembly = Assembly.LoadFrom(FilePath.GetAbsolutePath(m_userControlAssembly));
- CommonFunctions.LoadUserControl(m_description, assembly.GetType(m_userControlPath));
+ try
+ {
+ Assembly assembly = Assembly.LoadFrom(FilePath.GetAbsolutePath(UserControlAssembly));
+ CommonFunctions.LoadUserControl(m_description, assembly.GetType(UserControlPath));
}
catch (Exception ex)
{
- throw new InvalidOperationException(string.Format("Failed to create user control {0}: {1}", m_userControlPath, ex.Message), ex);
+ CommonFunctions.Popup($"Failed to create user control {UserControlAssembly}: {ex.Message}", "Menu Load Exception:", MessageBoxImage.Error);
+ CommonFunctions.LogException(null, "Menu Load Exception", ex);
+
}
}
diff --git a/Source/Libraries/GSF.TimeSeries/UI/WPF/Resources/StyleResource.xaml b/Source/Libraries/GSF.TimeSeries/UI/WPF/Resources/StyleResource.xaml
index aa86b2f0290..ebb556324e4 100644
--- a/Source/Libraries/GSF.TimeSeries/UI/WPF/Resources/StyleResource.xaml
+++ b/Source/Libraries/GSF.TimeSeries/UI/WPF/Resources/StyleResource.xaml
@@ -336,8 +336,18 @@
+
+