diff --git a/src/ScriptRunner/ScriptRunner.GUI/Converters/CategoryToColorConverter.cs b/src/ScriptRunner/ScriptRunner.GUI/Converters/CategoryToColorConverter.cs index b1cc00a..77141ed 100644 --- a/src/ScriptRunner/ScriptRunner.GUI/Converters/CategoryToColorConverter.cs +++ b/src/ScriptRunner/ScriptRunner.GUI/Converters/CategoryToColorConverter.cs @@ -1,5 +1,8 @@ using System; +using System.Collections.Generic; using System.Globalization; +using System.Security.Cryptography; +using System.Text; using Avalonia.Data.Converters; using Avalonia.Media; @@ -9,30 +12,84 @@ public class CategoryToColorConverter : IValueConverter { private static readonly Color[] PredefinedColors = new[] { - Color.FromRgb(70, 100, 180), // Darker Blue - Color.FromRgb(200, 90, 70), // Darker Coral - Color.FromRgb(80, 150, 80), // Darker Green - Color.FromRgb(150, 100, 150), // Darker Plum - Color.FromRgb(180, 140, 0), // Darker Gold - Color.FromRgb(85, 130, 160), // Darker Sky Blue - Color.FromRgb(180, 100, 120), // Darker Pink - Color.FromRgb(90, 160, 90), // Darker Pale Green - Color.FromRgb(180, 100, 80), // Darker Salmon - Color.FromRgb(110, 130, 150), // Darker Steel Blue - Color.FromRgb(160, 80, 160), // Darker Violet - Color.FromRgb(180, 130, 100), // Darker Peach - Color.FromRgb(100, 140, 170), // Darker Light Blue - Color.FromRgb(170, 80, 80), // Darker Coral Red - Color.FromRgb(120, 140, 70), // Olive Green + // Blues & Cyans (8 colors) + Color.FromRgb(25, 95, 145), // Dark Steel Blue + Color.FromRgb(0, 105, 200), // Strong Blue + Color.FromRgb(0, 130, 200), // Deep Blue + Color.FromRgb(0, 140, 140), // Deep Teal + Color.FromRgb(0, 150, 136), // Dark Turquoise + Color.FromRgb(30, 80, 180), // Royal Blue + Color.FromRgb(0, 85, 140), // Navy Blue + Color.FromRgb(0, 120, 150), // Ocean Blue + + // Greens (8 colors) + Color.FromRgb(30, 130, 76), // Dark Sea Green + Color.FromRgb(22, 160, 80), // Deep Emerald + Color.FromRgb(65, 165, 65), // Forest Green + Color.FromRgb(50, 120, 50), // Dark Green + Color.FromRgb(100, 140, 30), // Olive Green + Color.FromRgb(0, 135, 60), // Green + Color.FromRgb(40, 105, 40), // Hunter Green + Color.FromRgb(85, 150, 0), // Lime Green + + // Purples & Magentas (8 colors) + Color.FromRgb(100, 65, 165), // Deep Purple + Color.FromRgb(90, 24, 154), // Dark Violet + Color.FromRgb(130, 50, 150), // Dark Orchid + Color.FromRgb(155, 65, 155), // Dark Magenta + Color.FromRgb(160, 15, 100), // Deep Pink + Color.FromRgb(75, 0, 130), // Indigo + Color.FromRgb(120, 40, 140), // Purple + Color.FromRgb(145, 30, 180), // Violet + + // Reds & Pinks (8 colors) + Color.FromRgb(180, 15, 45), // Deep Crimson + Color.FromRgb(200, 50, 35), // Dark Red + Color.FromRgb(200, 50, 120), // Dark Rose + Color.FromRgb(190, 70, 70), // Brick Red + Color.FromRgb(165, 50, 50), // Dark Coral + Color.FromRgb(170, 0, 60), // Maroon + Color.FromRgb(220, 40, 85), // Ruby Red + Color.FromRgb(185, 25, 100), // Deep Rose + + // Oranges & Yellows (8 colors) + Color.FromRgb(210, 105, 0), // Deep Orange + Color.FromRgb(230, 120, 0), // Vivid Orange + Color.FromRgb(200, 160, 0), // Dark Gold + Color.FromRgb(165, 125, 20), // Dark Goldenrod + Color.FromRgb(180, 160, 50), // Dark Khaki + Color.FromRgb(190, 90, 0), // Burnt Orange + Color.FromRgb(170, 140, 0), // Mustard + Color.FromRgb(200, 140, 30), // Amber + + // Browns & Earth Tones (10 colors) + Color.FromRgb(160, 75, 20), // Dark Chocolate + Color.FromRgb(155, 95, 40), // Burnt Sienna + Color.FromRgb(140, 90, 90), // Dark Rose Brown + Color.FromRgb(120, 60, 30), // Dark Sienna + Color.FromRgb(100, 50, 15), // Deep Brown + Color.FromRgb(130, 70, 25), // Saddle Brown + Color.FromRgb(145, 85, 50), // Copper + Color.FromRgb(115, 80, 65), // Coffee Brown + Color.FromRgb(140, 100, 60), // Bronze + Color.FromRgb(105, 65, 40), // Dark Tan }; + // Cache to ensure same category always gets same color + private static readonly Dictionary CategoryColorCache = new(); + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) { if (value is string category) { - // Generate deterministic hash from category name - int hash = GetDeterministicHashCode(category); - int colorIndex = Math.Abs(hash) % PredefinedColors.Length; + // Check cache first + if (!CategoryColorCache.TryGetValue(category, out int colorIndex)) + { + // Generate deterministic hash from category name using SHA256 + colorIndex = GetStableColorIndex(category); + CategoryColorCache[category] = colorIndex; + } + return new SolidColorBrush(PredefinedColors[colorIndex]); } @@ -44,22 +101,16 @@ public object Convert(object? value, Type targetType, object? parameter, Culture throw new NotImplementedException(); } - private static int GetDeterministicHashCode(string str) + private static int GetStableColorIndex(string str) { - unchecked - { - int hash1 = 5381; - int hash2 = hash1; - - for (int i = 0; i < str.Length && str[i] != '\0'; i += 2) - { - hash1 = ((hash1 << 5) + hash1) ^ str[i]; - if (i == str.Length - 1 || str[i + 1] == '\0') - break; - hash2 = ((hash2 << 5) + hash2) ^ str[i + 1]; - } - - return hash1 + (hash2 * 1566083941); - } + // Use SHA256 for better distribution and fewer collisions + using var sha256 = SHA256.Create(); + byte[] hashBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(str)); + + // Use first 4 bytes to create an integer + int hash = BitConverter.ToInt32(hashBytes, 0); + + // Map to color index with better distribution + return Math.Abs(hash % PredefinedColors.Length); } } diff --git a/src/ScriptRunner/ScriptRunner.GUI/ScriptReader/ScriptConfigReader.cs b/src/ScriptRunner/ScriptRunner.GUI/ScriptReader/ScriptConfigReader.cs index 3267b74..09e164a 100644 --- a/src/ScriptRunner/ScriptRunner.GUI/ScriptReader/ScriptConfigReader.cs +++ b/src/ScriptRunner/ScriptRunner.GUI/ScriptReader/ScriptConfigReader.cs @@ -36,7 +36,7 @@ public static ConfigLoadResult LoadWithErrorTracking(ConfigScriptEntry source, return result; } - LoadFileSourceWithTracking(source.Path, appSettings, result, source.Name); + LoadFileSourceWithTracking(source.Path, appSettings, result, source.Name, source.Path); return result; } @@ -49,82 +49,13 @@ public static ConfigLoadResult LoadWithErrorTracking(ConfigScriptEntry source, foreach (var file in Directory.EnumerateFiles(source.Path, "*.json", source.Recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly)) { - LoadFileSourceWithTracking(file, appSettings, result, source.Name); + LoadFileSourceWithTracking(file, appSettings, result, source.Name, source.Path); } } return result; } - public static IEnumerable Load(ConfigScriptEntry source, - ScriptRunnerAppSettings appSettings) - { - if (string.IsNullOrWhiteSpace(source.Path)) - { - yield break; - } - - if (source.Type == ConfigScriptType.File) - { - if (File.Exists(source.Path) == false) - { - yield break; - } - - foreach (var scriptConfig in LoadFileSource(source.Path, appSettings)) - { - scriptConfig.SourceName = source.Name; - scriptConfig.Categories ??= new List(); - - var mainCategory = string.IsNullOrWhiteSpace(scriptConfig.SourceName) == false - ? scriptConfig.SourceName - : Path.GetFileName(source.Path); - if (string.IsNullOrWhiteSpace(mainCategory) == false) - { - scriptConfig.Categories.Add(mainCategory); - - if (mainCategory != source.Name) - { - source.Name = mainCategory; - } - } - yield return scriptConfig; - } - yield break; - } - - if (source.Type == ConfigScriptType.Directory) - { - if (Directory.Exists(source.Path) == false) - { - yield break; - } - - foreach (var file in Directory.EnumerateFiles(source.Path, "*.json", source.Recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly)) - { - foreach (var scriptConfig in LoadFileSource(file, appSettings)) - { - scriptConfig.SourceName = source.Name; - scriptConfig.Categories ??= new List(); - - var mainCategory = string.IsNullOrWhiteSpace(scriptConfig.SourceName) == false - ? scriptConfig.SourceName - : Path.GetFileName(source.Path); - if (string.IsNullOrWhiteSpace(mainCategory) == false) - { - scriptConfig.Categories.Add(mainCategory); - - if (mainCategory != source.Name) - { - source.Name = mainCategory; - } - } - yield return scriptConfig; - } - } - } - } - private static IAutoParameterBuilder CreateBuilder(ScriptConfig scriptConfig) { if (scriptConfig.AutoParameterBuilderStyle == "powershell") @@ -140,8 +71,7 @@ private static IAutoParameterBuilder CreateBuilder(ScriptConfig scriptConfig) return EmptyAutoParameterBuilder.Instance; } - private static void LoadFileSourceWithTracking(string fileName, - ScriptRunnerAppSettings appSettings, ConfigLoadResult result, string sourceName) + private static void LoadFileSourceWithTracking(string fileName, ScriptRunnerAppSettings appSettings, ConfigLoadResult result, string sourceName, string sourcePath) { if (!File.Exists(fileName)) return; @@ -166,14 +96,19 @@ private static void LoadFileSourceWithTracking(string fileName, action.Source = fileName; action.SourceName = sourceName; action.Categories ??= new List(); - - var mainCategory = string.IsNullOrWhiteSpace(action.SourceName) == false - ? action.SourceName - : Path.GetFileName(fileName); - if (string.IsNullOrWhiteSpace(mainCategory) == false) + if (string.IsNullOrWhiteSpace(action.SourceName) == false) { - action.Categories.Add(mainCategory); + action.Categories.Add(action.SourceName); + } + else + { + var dir = Path.GetDirectoryName(sourcePath)?.Split(new[]{'\\','/'}).Last(); + if (string.IsNullOrWhiteSpace(dir) == false) + { + action.Categories.Add(dir); + } } + NormalizeCategories(action.Categories); var parameterBuilder = CreateBuilder(action); @@ -300,6 +235,23 @@ string ResolveAbsolutePath(string path) } } + private static readonly Dictionary NormalizeCategoriesCache = new(); + private static void NormalizeCategories(List actionCategories) + { + var normalize = actionCategories.Select(cat => + { + var key = cat.Trim().ToLowerInvariant().Replace(" ", "").Replace("-", "").Replace("_", ""); + if (NormalizeCategoriesCache.TryGetValue(key, out var normalized)) + { + return normalized; + } + return NormalizeCategoriesCache[key] = cat.Trim(); + + }).Distinct().ToList(); + actionCategories.Clear(); + actionCategories.AddRange(normalize); + } + private static IEnumerable LoadFileSource(string fileName, ScriptRunnerAppSettings appSettings) { diff --git a/src/ScriptRunner/ScriptRunner.GUI/ViewModels/MainWindowViewModel.cs b/src/ScriptRunner/ScriptRunner.GUI/ViewModels/MainWindowViewModel.cs index a5ff2f8..0f59398 100644 --- a/src/ScriptRunner/ScriptRunner.GUI/ViewModels/MainWindowViewModel.cs +++ b/src/ScriptRunner/ScriptRunner.GUI/ViewModels/MainWindowViewModel.cs @@ -410,20 +410,39 @@ public MainWindowViewModel(ParamsPanelFactory paramsPanelFactory, VaultProvider } } - IEnumerable scriptConfigGroupWrappers = configs.SelectMany(c => + IEnumerable scriptConfigGroupWrappers; + + // When a specific category is filtered, show each action only once under that category + if (!string.IsNullOrWhiteSpace(categoryFilter) && categoryFilter != "All") + { + scriptConfigGroupWrappers = new[] { - if (c.Categories is {Count: > 0}) + new ScriptConfigGroupWrapper { - return c.Categories.DistinctBy(x=>x).Select((cat) => (category: cat, script: c)); + Name = categoryFilter, + Children = configs.Select(c => new TaggedScriptConfig(categoryFilter, c.Name, c)).OrderBy(x => x.Name) } + }; + } + else + { + // When no filter or "All" is selected, show actions grouped by all their categories + scriptConfigGroupWrappers = configs.SelectMany(c => + { + if (c.Categories is {Count: > 0}) + { + return c.Categories.DistinctBy(x=>x).Select((cat) => (category: cat, script: c)); + } - return new[] {(category: "(No Category)", script: c)}; - }).GroupBy(x => x.category).OrderBy(x=>x.Key) - .Select(x=> new ScriptConfigGroupWrapper - { - Name = x.Key, - Children = x.Select(p=> new TaggedScriptConfig(x.Key, p.script.Name, p.script)).OrderBy(x=>x.Name) - }); + return new[] {(category: "(No Category)", script: c)}; + }).GroupBy(x => x.category).OrderBy(x=>x.Key) + .Select(x=> new ScriptConfigGroupWrapper + { + Name = x.Key, + Children = x.Select(p=> new TaggedScriptConfig(x.Key, p.script.Name, p.script)).OrderBy(x=>x.Name) + }); + } + return scriptConfigGroupWrappers; }) .ObserveOn(RxApp.MainThreadScheduler)