From 06241ccef9845ddf39c5fb0874bfc3e1a74d7981 Mon Sep 17 00:00:00 2001 From: "J. Ritchie Carroll" Date: Fri, 19 Jun 2026 11:53:58 -0500 Subject: [PATCH 1/2] GSF.TimeSeries.UI.WPF: Updated Menu.xml processing to optionally use UserControlPath as arguments when ExternalProcessPath is defined --- .../UI/WPF/Commands/MenuCommand.cs | 30 +++++++++++++------ 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/Source/Libraries/GSF.TimeSeries/UI/WPF/Commands/MenuCommand.cs b/Source/Libraries/GSF.TimeSeries/UI/WPF/Commands/MenuCommand.cs index 4f58462cb9..0abc5afa4e 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); + } } From 65f265aed2e28195dff54b01538b756366f991ef Mon Sep 17 00:00:00 2001 From: "J. Ritchie Carroll" Date: Fri, 19 Jun 2026 16:24:30 -0500 Subject: [PATCH 2/2] GSF.TimeSeries.UI: Added menu item icon converter Loads from specified icon resource in `Menu.xml` or from embedded icon resource when export process path is an executable --- .../UI/Converters/MenuItemIconConverter.cs | 213 ++++++++++++++++++ .../UI/GSF.TimeSeries.UI.csproj | 2 + .../UI/WPF/Resources/StyleResource.xaml | 10 + 3 files changed, 225 insertions(+) create mode 100644 Source/Libraries/GSF.TimeSeries/UI/Converters/MenuItemIconConverter.cs 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 0000000000..6590d23b5a --- /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 b74922a77a..eb4864f5c5 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/Resources/StyleResource.xaml b/Source/Libraries/GSF.TimeSeries/UI/WPF/Resources/StyleResource.xaml index aa86b2f029..ebb556324e 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 @@ + +