diff --git a/schema/v1/ScriptRunnerSchema.json b/schema/v1/ScriptRunnerSchema.json index 6fad487..d69c128 100644 --- a/schema/v1/ScriptRunnerSchema.json +++ b/schema/v1/ScriptRunnerSchema.json @@ -185,7 +185,32 @@ "type": "object", "properties": { "options": { - "type": "string" + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "array", + "items": { + "type": "object", + "required": ["label", "value"], + "properties": { + "label": { + "type": "string" + }, + "value": { + "type": "string" + } + } + } + } + ] }, "searchable": { "type": "boolean" @@ -212,7 +237,32 @@ "type": "object", "properties": { "options": { - "type": "string" + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "array", + "items": { + "type": "object", + "required": ["label", "value"], + "properties": { + "label": { + "type": "string" + }, + "value": { + "type": "string" + } + } + } + } + ] }, "delimiter": { "type": "string" @@ -282,12 +332,27 @@ }, "required": ["prompt"] }, + { + "properties": { + "prompt": { + "const": "multilineText" + }, + "promptSettings": { + "type": "object", + "properties": { + "syntax": { + "type": "string" + } + } + } + }, + "required": ["prompt"] + }, { "properties": { "prompt": { "enum": [ - "text", - "multilineText", + "text", "password", "filePicker", "directoryPicker" diff --git a/src/ScriptRunner/ScriptRunner.GUI/App.axaml b/src/ScriptRunner/ScriptRunner.GUI/App.axaml index 0213660..304e126 100644 --- a/src/ScriptRunner/ScriptRunner.GUI/App.axaml +++ b/src/ScriptRunner/ScriptRunner.GUI/App.axaml @@ -10,7 +10,7 @@ - + diff --git a/src/ScriptRunner/ScriptRunner.GUI/CheckBoxListBox.cs b/src/ScriptRunner/ScriptRunner.GUI/CheckBoxListBox.cs index c5baa2f..51faaf2 100644 --- a/src/ScriptRunner/ScriptRunner.GUI/CheckBoxListBox.cs +++ b/src/ScriptRunner/ScriptRunner.GUI/CheckBoxListBox.cs @@ -55,5 +55,15 @@ public CheckBoxListBox() } }; this.Styles.Add(style); + + // Style for selected items + var selectedStyle = new Style(x => x.OfType().Class(":selected").Template().OfType()) + { + Setters = + { + new Setter(ContentPresenter.BackgroundProperty, Avalonia.Media.Brushes.Transparent) + } + }; + this.Styles.Add(selectedStyle); } } diff --git a/src/ScriptRunner/ScriptRunner.GUI/Converters/CategoryToColorConverter.cs b/src/ScriptRunner/ScriptRunner.GUI/Converters/CategoryToColorConverter.cs new file mode 100644 index 0000000..b1cc00a --- /dev/null +++ b/src/ScriptRunner/ScriptRunner.GUI/Converters/CategoryToColorConverter.cs @@ -0,0 +1,65 @@ +using System; +using System.Globalization; +using Avalonia.Data.Converters; +using Avalonia.Media; + +namespace ScriptRunner.GUI.Converters; + +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 + }; + + 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; + return new SolidColorBrush(PredefinedColors[colorIndex]); + } + + return new SolidColorBrush(Colors.Gray); + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } + + private static int GetDeterministicHashCode(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); + } + } +} diff --git a/src/ScriptRunner/ScriptRunner.GUI/Converters/StringEmptyToVisibilityConverter.cs b/src/ScriptRunner/ScriptRunner.GUI/Converters/StringEmptyToVisibilityConverter.cs new file mode 100644 index 0000000..93152bf --- /dev/null +++ b/src/ScriptRunner/ScriptRunner.GUI/Converters/StringEmptyToVisibilityConverter.cs @@ -0,0 +1,23 @@ +using System; +using System.Globalization; +using Avalonia.Data.Converters; + +namespace ScriptRunner.GUI.Converters; + +public class StringEmptyToVisibilityConverter : IValueConverter +{ + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is string str) + { + return !string.IsNullOrEmpty(str); + } + return false; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } +} + diff --git a/src/ScriptRunner/ScriptRunner.GUI/Parameters/DropdownControl.cs b/src/ScriptRunner/ScriptRunner.GUI/Parameters/DropdownControl.cs index d089fdd..c94aee3 100644 --- a/src/ScriptRunner/ScriptRunner.GUI/Parameters/DropdownControl.cs +++ b/src/ScriptRunner/ScriptRunner.GUI/Parameters/DropdownControl.cs @@ -1,4 +1,7 @@ -using Avalonia.Controls; +using System.Collections.ObjectModel; +using System.Linq; +using Avalonia.Controls; +using ScriptRunner.GUI.ScriptConfigs; using ScriptRunner.GUI.Views; namespace ScriptRunner.GUI; @@ -7,15 +10,33 @@ public class DropdownControl : IControlRecord { public Control Control { get; set; } public Control InputControl { get; set; } + public ObservableCollection DropdownOptions { get; set; } public string GetFormattedValue() { - return InputControl switch + var selectedItem = InputControl switch { - ComboBox cb => cb.SelectedItem?.ToString(), + ComboBox cb => cb.SelectedItem, SearchableComboBox acb => acb.SelectedItem, - _ => "" - } ?? string.Empty; + _ => null + }; + + if (selectedItem is DropdownOption option) + { + return option.Value; + } + + // For SearchableComboBox, we need to map the label back to value + if (selectedItem is string label && DropdownOptions != null) + { + var matchingOption = DropdownOptions.FirstOrDefault(opt => opt.Label == label); + if (matchingOption != null) + { + return matchingOption.Value; + } + } + + return selectedItem?.ToString() ?? string.Empty; } public string Name { get; set; } diff --git a/src/ScriptRunner/ScriptRunner.GUI/Parameters/FileContent.cs b/src/ScriptRunner/ScriptRunner.GUI/Parameters/FileContent.cs index 08c19ed..3a6bc7b 100644 --- a/src/ScriptRunner/ScriptRunner.GUI/Parameters/FileContent.cs +++ b/src/ScriptRunner/ScriptRunner.GUI/Parameters/FileContent.cs @@ -3,6 +3,7 @@ using System.Security.Cryptography; using System.Text; using Avalonia.Controls; +using AvaloniaEdit; namespace ScriptRunner.GUI; @@ -20,7 +21,12 @@ public FileContent(string extension) public string GetFormattedValue() { - var fileContent = ((TextBox)Control).Text; + var fileContent = Control switch + { + TextBox textBox => textBox.Text, + TextEditor textEditor => textEditor.Text, + _ => ((TextBox)Control).Text + }; var hash = string.IsNullOrWhiteSpace(fileContent)? "EMPTY" : ComputeSHA256(fileContent).Substring(0,10); FileName = Path.Combine(Path.GetTempPath(), hash + "." + _extension); File.WriteAllText(FileName, fileContent, Encoding.UTF8); diff --git a/src/ScriptRunner/ScriptRunner.GUI/Parameters/MultiSelectControl.cs b/src/ScriptRunner/ScriptRunner.GUI/Parameters/MultiSelectControl.cs index cf9a196..d688808 100644 --- a/src/ScriptRunner/ScriptRunner.GUI/Parameters/MultiSelectControl.cs +++ b/src/ScriptRunner/ScriptRunner.GUI/Parameters/MultiSelectControl.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using Avalonia.Controls; +using ScriptRunner.GUI.ScriptConfigs; namespace ScriptRunner.GUI; @@ -13,7 +14,11 @@ public string GetFormattedValue() var copy = new List(); foreach (var item in selectedItems) { - if (item.ToString() is { } nonNullItem) + if (item is DropdownOption option) + { + copy.Add(option.Value); + } + else if (item?.ToString() is { } nonNullItem) { copy.Add(nonNullItem); } diff --git a/src/ScriptRunner/ScriptRunner.GUI/Parameters/ParamsPanelFactory.cs b/src/ScriptRunner/ScriptRunner.GUI/Parameters/ParamsPanelFactory.cs index c8834c3..7c24bc3 100644 --- a/src/ScriptRunner/ScriptRunner.GUI/Parameters/ParamsPanelFactory.cs +++ b/src/ScriptRunner/ScriptRunner.GUI/Parameters/ParamsPanelFactory.cs @@ -4,6 +4,7 @@ using System.Globalization; using System.IO; using System.Linq; +using System.Reflection.Metadata; using System.Threading.Tasks; using Avalonia; using Avalonia.Collections; @@ -14,12 +15,17 @@ using Avalonia.Layout; using Avalonia.Media; using Avalonia.Threading; +using AvaloniaEdit; +using AvaloniaEdit.Document; +using AvaloniaEdit.Highlighting; +using AvaloniaEdit.TextMate; using Projektanker.Icons.Avalonia; using ScriptRunner.GUI.Infrastructure; using ScriptRunner.GUI.ScriptConfigs; using ScriptRunner.GUI.Settings; using ScriptRunner.GUI.ViewModels; using ScriptRunner.GUI.Views; +using TextMateSharp.Grammars; using Path = System.IO.Path; namespace ScriptRunner.GUI; @@ -53,13 +59,21 @@ public ParamsPanel Create(ScriptConfig action, Dictionary values controlRecord.Name = param.Name; if (controlRecord.Control is Layoutable l) { - l.MaxWidth = 500; + // Don't set MaxWidth for multiline/file content controls since they have resize handles + if (param.Prompt is not (PromptType.Multilinetext or PromptType.FileContent)) + { + l.MaxWidth = 500; + } } var label = new Label { - Content = string.IsNullOrWhiteSpace(param.Description)? param.Name: param.Description, - + Content = new TextBlock + { + Text = string.IsNullOrWhiteSpace(param.Description) ? param.Name : param.Description, + TextWrapping = TextWrapping.Wrap, + MaxWidth = 300 + } }; ToolTip.SetTip(label, param.Name); @@ -73,12 +87,13 @@ public ParamsPanel Create(ScriptConfig action, Dictionary values var resizeHandle = new Border() { Height = 10, + Width = 10, Background = Brushes.Transparent, HorizontalAlignment = HorizontalAlignment.Right, ZIndex = 1, Margin = new Thickness(0,-10,0,0), VerticalAlignment = VerticalAlignment.Bottom, - Cursor = new Cursor(StandardCursorType.SizeNorthSouth) + Cursor = new Cursor(StandardCursorType.BottomRightCorner) }; resizeHandle.Child = new Icon() { @@ -102,7 +117,13 @@ public ParamsPanel Create(ScriptConfig action, Dictionary values var delta = currentPosition - _lastPointerPosition; var textBox = controlRecord.Control; + + // Resize vertically textBox.Height = Math.Max(textBox.MinHeight, textBox.Height + delta.Y); + + // Resize horizontally + var currentWidth = double.IsNaN(textBox.Width) ? textBox.Bounds.Width : textBox.Width; + textBox.Width = Math.Max(100, currentWidth + delta.X); _lastPointerPosition = currentPosition; } @@ -158,9 +179,13 @@ public ParamsPanel Create(ScriptConfig action, Dictionary values if (controlRecord is { Control: TextBox tb }) { tb.Text = result?.Trim() ?? string.Empty; - generateButton.Classes.Remove("spinning"); - generateButton.IsEnabled = true; } + else if (controlRecord is { Control: TextEditor te }) + { + te.Text = result?.Trim() ?? string.Empty; + } + generateButton.Classes.Remove("spinning"); + generateButton.IsEnabled = true; }); }; Attached.SetIcon(generateButton, "fas fa-wand-magic-sparkles"); @@ -241,32 +266,60 @@ private IControlRecord CreateControlRecord(ScriptParam p, string? value, int ind }; case PromptType.Dropdown: var delimiterForOptions = p.GetPromptSettings("delimiter", x => x, ","); - var initialOptions = p.GetPromptSettings("options", out var options) ? options.Split(delimiterForOptions):Array.Empty(); - var observableOptions = new ObservableCollection(initialOptions); + var dropdownOptions = p.GetDropdownOptions(delimiterForOptions); + var observableDropdownOptions = new ObservableCollection(dropdownOptions); var searchable = p.GetPromptSettings("searchable", bool.Parse, false); var optionsGeneratorCommand = p.GetPromptSettings("optionsGeneratorCommand", out var optionsGeneratorCommandText) ? optionsGeneratorCommandText : null; - - if (observableOptions.Count == 0 && string.IsNullOrWhiteSpace(value) == false && string.IsNullOrWhiteSpace(optionsGeneratorCommand) == false) + // Find selected item by matching value + DropdownOption? selectedOption = null; + if (!string.IsNullOrWhiteSpace(value)) { - observableOptions.Add(value); + selectedOption = observableDropdownOptions.FirstOrDefault(opt => opt.Value == value); + if (selectedOption == null && string.IsNullOrWhiteSpace(optionsGeneratorCommand) == false) + { + // Add the value as a temporary option if not found and generator is available + selectedOption = new DropdownOption(value); + observableDropdownOptions.Add(selectedOption); + } } - Control inputControl = searchable ? new SearchableComboBox() + Control inputControl; + + if (searchable) { - Items = observableOptions, - SelectedItem = value, - TabIndex = index, - IsTabStop = true, - Width = 500 - }: new ComboBox - { - ItemsSource = observableOptions, - SelectedItem = value, - TabIndex = index, - IsTabStop = true, - Width = 500 - }; + // For searchable, convert to strings (SearchableComboBox only supports strings) + var stringOptions = new ObservableCollection(dropdownOptions.Select(o => o.Label)); + var selectedString = selectedOption?.Label; + + var searchBox = new SearchableComboBox() + { + Items = stringOptions, + TabIndex = index, + IsTabStop = true, + Width = 500 + }; + + // Set selected item after Items collection is set + if (!string.IsNullOrWhiteSpace(selectedString) && stringOptions.Contains(selectedString)) + { + searchBox.SelectedItem = selectedString; + } + + inputControl = searchBox; + } + else + { + inputControl = new ComboBox + { + ItemsSource = observableDropdownOptions, + SelectedItem = selectedOption, + TabIndex = index, + IsTabStop = true, + Width = 500 + }; + } + var actionPanel = new StackPanel() { Orientation = Orientation.Horizontal, @@ -293,24 +346,42 @@ private IControlRecord CreateControlRecord(ScriptParam p, string? value, int ind var result = await commandExecutor($"Generate options for '{p.Name}'", optionsGeneratorCommand) ?? ""; Dispatcher.UIThread.Post(() => { - observableOptions.Clear(); - foreach (var option in result.Split(new[]{"\r", "\n",delimiterForOptions}, StringSplitOptions.RemoveEmptyEntries).Distinct().OrderBy(x=>x)) + var newOptions = result.Split(new[]{"\r", "\n",delimiterForOptions}, StringSplitOptions.RemoveEmptyEntries) + .Distinct() + .OrderBy(x=>x) + .Select(opt => new DropdownOption(opt.Trim())) + .ToList(); + + if (searchable && inputControl is SearchableComboBox searchBox) { - observableOptions.Add(option); + searchBox.Items.Clear(); + foreach (var option in newOptions) + { + searchBox.Items.Add(option.Label); + } } + else if (inputControl is ComboBox comboBox) + { + observableDropdownOptions.Clear(); + foreach (var option in newOptions) + { + observableDropdownOptions.Add(option); + } + } + generateButton.Classes.Remove("spinning"); generateButton.IsEnabled = true; wasGenerated = true; - if (inputControl is SearchableComboBox scb) + if (inputControl is SearchableComboBox scb2) { - scb.ShowAll(); + scb2.ShowAll(); } }); }; generateButton.Click += generate; - if(inputControl is SearchableComboBox scb) + if(inputControl is SearchableComboBox searchableBox) { - scb.GotFocus += (sender, args) => + searchableBox.GotFocus += (sender, args) => { if (wasGenerated == false) { @@ -326,17 +397,24 @@ private IControlRecord CreateControlRecord(ScriptParam p, string? value, int ind return new DropdownControl { Control = actionPanel, - InputControl = inputControl + InputControl = inputControl, + DropdownOptions = observableDropdownOptions }; case PromptType.Multiselect: var delimiter = p.GetPromptSettings("delimiter", s => s, ","); + var multiSelectOptions = p.GetDropdownOptions(delimiter); + + // Parse selected values + var selectedValues = (value ?? string.Empty).Split(new[] { delimiter }, StringSplitOptions.RemoveEmptyEntries).Select(v => v.Trim()).ToList(); + var selectedDropdownOptions = multiSelectOptions.Where(opt => selectedValues.Contains(opt.Value)).ToList(); + return new MultiSelectControl { Control = new CheckBoxListBox { SelectionMode = SelectionMode.Multiple, - ItemsSource = p.GetPromptSettings("options", out var multiSelectOptions) ? multiSelectOptions.Split(delimiter) : Array.Empty(), - SelectedItems = new AvaloniaList((value ?? string.Empty).Split(delimiter)), + ItemsSource = multiSelectOptions, + SelectedItems = new AvaloniaList(selectedDropdownOptions), TabIndex = index, IsTabStop = true, BorderBrush = new SolidColorBrush(Color.Parse("#99ffffff")), @@ -403,6 +481,15 @@ private IControlRecord CreateControlRecord(ScriptParam p, string? value, int ind UncheckedValue = p.GetPromptSettings("uncheckedValue", out var uncheckedValue)? uncheckedValue: defaultUnchecked, }; case PromptType.Multilinetext: + if (p.GetPromptSettings("syntax", out var syntax) && !string.IsNullOrWhiteSpace(syntax)) + { + return new TextControl + { + Control = CreateAvaloniaEdit(value, index, syntax) + }; + } + + // Use regular TextBox without syntax highlighting return new TextControl { Control = new TextBox @@ -414,7 +501,6 @@ private IControlRecord CreateControlRecord(ScriptParam p, string? value, int ind TabIndex = index, IsTabStop = true, Width = 500, - } }; case PromptType.FileContent: @@ -426,19 +512,11 @@ private IControlRecord CreateControlRecord(ScriptParam p, string? value, int ind var templateText = p.GetPromptSettings("templateText", out var rawTemplate)? rawTemplate: ""; var textForControl = File.Exists(value) ? File.ReadAllText(value) : templateText; - - return new FileContent(p.GetPromptSettings("extension", out var extension)?extension:"dat") + + var fileExtension = p.GetPromptSettings("extension", out var extension)?extension:"dat"; + return new FileContent(fileExtension) { - Control = new TextBox - { - TextWrapping = TextWrapping.Wrap, - AcceptsReturn = true, - Height = 100, - Text = textForControl, - TabIndex = index, - IsTabStop = true, - Width = 500 - } + Control = CreateAvaloniaEdit(textForControl, index, fileExtension.TrimStart('.')) }; case PromptType.FilePicker: @@ -489,4 +567,31 @@ private IControlRecord CreateControlRecord(ScriptParam p, string? value, int ind throw new ArgumentOutOfRangeException(nameof(p.Prompt), p.Prompt, null); } } -} \ No newline at end of file + + private static TextEditor CreateAvaloniaEdit(string? value, int index, string syntax) + { + var textEditor = new TextEditor + { + Document = new TextDocument(value ?? string.Empty), + TabIndex = index, + MinHeight = 100, + Height = 100, + Width = 500, + ShowLineNumbers = true, + FontFamily = new FontFamily("Cascadia Code,Consolas,Menlo,Monospace"), + Background = new SolidColorBrush(Color.FromRgb(30, 30, 30)), + BorderBrush = new SolidColorBrush(Color.FromArgb(153, 255, 255,255)), + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(3) + }; + textEditor.TextArea.TextView.Margin = new Thickness(10, 0); + var registry = new RegistryOptions(ThemeName.DarkPlus); + TextMate.Installation textMateInstallation = textEditor.InstallTextMate(registry); + if (registry.GetLanguageByExtension("." + syntax) is { } languageByExtension) + { + textMateInstallation.SetGrammar(registry.GetScopeByLanguageId(languageByExtension.Id)); + } + + return textEditor; + } +} diff --git a/src/ScriptRunner/ScriptRunner.GUI/Parameters/TextControl.cs b/src/ScriptRunner/ScriptRunner.GUI/Parameters/TextControl.cs index 781e216..379d24a 100644 --- a/src/ScriptRunner/ScriptRunner.GUI/Parameters/TextControl.cs +++ b/src/ScriptRunner/ScriptRunner.GUI/Parameters/TextControl.cs @@ -1,4 +1,5 @@ using Avalonia.Controls; +using AvaloniaEdit; namespace ScriptRunner.GUI; @@ -8,7 +9,12 @@ public class TextControl : IControlRecord public string GetFormattedValue() { - return ((TextBox)Control).Text; + return Control switch + { + TextBox textBox => textBox.Text, + TextEditor textEditor => textEditor.Text, + _ => ((TextBox)Control).Text + }; } public string Name { get; set; } diff --git a/src/ScriptRunner/ScriptRunner.GUI/ScriptConfigs/DropdownOption.cs b/src/ScriptRunner/ScriptRunner.GUI/ScriptConfigs/DropdownOption.cs new file mode 100644 index 0000000..7430fa7 --- /dev/null +++ b/src/ScriptRunner/ScriptRunner.GUI/ScriptConfigs/DropdownOption.cs @@ -0,0 +1,25 @@ +namespace ScriptRunner.GUI.ScriptConfigs; + +/// +/// Represents a dropdown or multiselect option with a display label and value +/// +public class DropdownOption +{ + public string Label { get; set; } + public string Value { get; set; } + + public DropdownOption(string label, string value) + { + Label = label; + Value = value; + } + + public DropdownOption(string value) + { + Label = value; + Value = value; + } + + public override string ToString() => Label; +} + diff --git a/src/ScriptRunner/ScriptRunner.GUI/ScriptConfigs/ScriptConfig.cs b/src/ScriptRunner/ScriptRunner.GUI/ScriptConfigs/ScriptConfig.cs index fce31e5..6e63972 100644 --- a/src/ScriptRunner/ScriptRunner.GUI/ScriptConfigs/ScriptConfig.cs +++ b/src/ScriptRunner/ScriptRunner.GUI/ScriptConfigs/ScriptConfig.cs @@ -3,6 +3,8 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.IO; +using System.Linq; +using System.Text.Json; using ScriptRunner.GUI.ViewModels; @@ -87,6 +89,58 @@ public T GetPromptSettings(string name, Func convert, T @default) return @default; } + + public List GetDropdownOptions(string delimiter = ",") + { + if (!PromptSettings.TryGetValue("options", out var optionsValue)) + { + return new List(); + } + + // Case 1: String with comma-separated values + if (optionsValue is string optionsString) + { + return optionsString + .Split(new[] { delimiter }, StringSplitOptions.RemoveEmptyEntries) + .Select(x => new DropdownOption(x.Trim())) + .ToList(); + } + + // Case 2: JsonElement (from deserialization) + if (optionsValue is JsonElement jsonElement) + { + if (jsonElement.ValueKind == JsonValueKind.String) + { + return jsonElement.GetString()! + .Split(new[] { delimiter }, StringSplitOptions.RemoveEmptyEntries) + .Select(x => new DropdownOption(x.Trim())) + .ToList(); + } + + if (jsonElement.ValueKind == JsonValueKind.Array) + { + var result = new List(); + foreach (var item in jsonElement.EnumerateArray()) + { + if (item.ValueKind == JsonValueKind.String) + { + // Array of strings + result.Add(new DropdownOption(item.GetString()!)); + } + else if (item.ValueKind == JsonValueKind.Object) + { + // Array of objects with label and value + var label = item.GetProperty("label").GetString()!; + var value = item.GetProperty("value").GetString()!; + result.Add(new DropdownOption(label, value)); + } + } + return result; + } + } + + return new List(); + } } public class InteractiveInputDescription @@ -99,4 +153,5 @@ public class InteractiveInputItem { public string Label { get; set; } public string Value { get; set; } -} \ No newline at end of file +} + diff --git a/src/ScriptRunner/ScriptRunner.GUI/ScriptRunner.GUI.csproj b/src/ScriptRunner/ScriptRunner.GUI/ScriptRunner.GUI.csproj index e32acda..868f0d0 100644 --- a/src/ScriptRunner/ScriptRunner.GUI/ScriptRunner.GUI.csproj +++ b/src/ScriptRunner/ScriptRunner.GUI/ScriptRunner.GUI.csproj @@ -34,6 +34,9 @@ + + + diff --git a/src/ScriptRunner/ScriptRunner.GUI/Scripts/DropdownOptionsExample.json b/src/ScriptRunner/ScriptRunner.GUI/Scripts/DropdownOptionsExample.json new file mode 100644 index 0000000..cb627fe --- /dev/null +++ b/src/ScriptRunner/ScriptRunner.GUI/Scripts/DropdownOptionsExample.json @@ -0,0 +1,66 @@ +{ + "$schema": "../../../../schema/v1/ScriptRunnerSchema.json", + "actions": [ + { + "name": "Coma separated", + "description": "Demonstrates different formats for dropdown options", + "command": "echo", + "autoParameterBuilderStyle": "powershell", + "params": [ + { + "name": "Environment", + "description": "Coma separated string", + "prompt": "dropdown", + "promptSettings": { + "options": "Development,Staging,Production" + } + }, + { + "name": "List", + "description": "List of strings", + "prompt": "dropdown", + "promptSettings": { + "options": ["us-east-1", "us-west-2", "eu-west-1", "ap-southeast-1"] + } + }, + { + "name": "Size", + "description": "List of label/value objects", + "prompt": "dropdown", + "promptSettings": { + "options": [ + {"label": "Small (1 CPU, 2GB RAM)", "value": "t2.small"}, + {"label": "Medium (2 CPU, 4GB RAM)", "value": "t2.medium"}, + {"label": "Large (4 CPU, 8GB RAM)", "value": "t2.large"}, + {"label": "X-Large (8 CPU, 16GB RAM)", "value": "t2.xlarge"} + ] + } + }, + { + "name": "Features", + "description": "Select features (multiSelect with array of strings)", + "prompt": "multiSelect", + "promptSettings": { + "options": ["Monitoring", "Backups", "Auto-scaling", "High Availability"], + "delimiter": "," + } + }, + { + "name": "Services", + "description": "Select services (multiSelect with label/value objects)", + "prompt": "multiSelect", + "promptSettings": { + "options": [ + {"label": "Web Server (HTTPS)", "value": "web-https"}, + {"label": "Database (PostgreSQL)", "value": "db-postgres"}, + {"label": "Cache (Redis)", "value": "cache-redis"}, + {"label": "Queue (RabbitMQ)", "value": "queue-rabbitmq"} + ], + "delimiter": "," + } + } + ] + } + ] +} + diff --git a/src/ScriptRunner/ScriptRunner.GUI/Scripts/ScriptRunnerSchema.json b/src/ScriptRunner/ScriptRunner.GUI/Scripts/ScriptRunnerSchema.json deleted file mode 100644 index df4c822..0000000 --- a/src/ScriptRunner/ScriptRunner.GUI/Scripts/ScriptRunnerSchema.json +++ /dev/null @@ -1,239 +0,0 @@ -{ - "$schema": "http://json-schema.org/schema", - "$id": "ScriptRunnerSchema", - "title": "Product", - "description": "ScriptRunnerSchema", - "type": "object", - "properties": { - "actions": { - "description": "A list of available actions", - "type": "array", - "items": { - "type": "object", - "additionalProperties": false, - "required": ["name", "command"], - "properties": { - "name": { - "type": "string" - }, - "description": { - "type": "string" - }, - "command": { - "type": "string" - }, - "workingDirectory": { - "type": "string" - }, - "installCommand":{ - "type": "string" - }, - "installCommandWorkingDirectory":{ - "type": "string" - }, - "predefinedArgumentSets":{ - "type": "array", - "items": { - "type": "object", - "properties": { - "description":{ - "type":"string" - }, - "fallbackToDefault":{ - "type":"boolean" - }, - "arguments":{ - "type":"object" - } - } - } - }, - "predefinedArgumentSetsOrdering": { - "type": "string", - "enum": [ - "ascending", - "descending" - ] - }, - "environmentVariables":{ - "type":"object", - "additionalProperties": true - }, - "params": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": true, - "properties": { - "name": { - "type": "string" - }, - "description": { - "type": "string" - }, - "default": { - "type": "string" - }, - "prompt": { - "type":"string", - "enum": [ - "text", - "multilineText", - "password", - "checkbox", - "dropdown", - "multiSelect", - "filePicker", - "directoryPicker", - "datePicker", - "numeric", - "timePicker" - ] - } - }, - "required": ["name", "prompt"], - "anyOf": [ - { - "properties": { - "prompt": { - "const": "datePicker" - }, - "promptSettings": { - "type": "object", - "additionalProperties": false, - "properties": { - "format": { - "type": "string" - }, - "yearVisible": { - "type": "string" - }, - "monthVisible": { - "type": "string" - }, - "dayVisible": { - "type": "string" - }, - "todayAsDefault": { - "type": "string" - } - } - } - }, - "required": ["prompt"] - }, - { - "properties": { - "prompt": { - "const": "timePicker" - }, - "promptSettings": { - "type": "object", - "additionalProperties": false, - "properties": { - "format": { - "type": "string" - } - } - } - }, - "required": ["prompt"] - }, - { - "properties": { - "prompt": { - "const": "dropdown" - }, - "promptSettings": { - "type": "object", - "properties": { - "options": { - "type": "string" - } - } - } - }, - "required": ["prompt"] - }, - { - "properties": { - "prompt": { - "const": "multiSelect" - }, - "promptSettings": { - "type": "object", - "properties": { - "options": { - "type": "string" - }, - "delimiter": { - "type": "string" - } - } - } - }, - "required": ["prompt"] - }, - { - "properties": { - "prompt": { - "const": "checkbox" - }, - "promptSettings": { - "type": "object", - "properties": { - "checkedValue": { - "type": "string" - }, - "uncheckedValue": { - "type": "string" - } - } - } - }, - "required": ["prompt"] - }, - { - "properties": { - "prompt": { - "const": "numeric" - }, - "promptSettings": { - "type": "object", - "properties": { - "min": { - "type": "string" - }, - "max": { - "type": "string" - }, - "step": { - "type": "string" - } - } - } - }, - "required": ["prompt"] - }, - { - "properties": { - "prompt": { - "enum": [ - "text", - "multilineText", - "password", - "filePicker", - "directoryPicker" - ] - } - }, - "required": ["prompt"] - } - ] - } - } - } - } - } - } -} \ No newline at end of file diff --git a/src/ScriptRunner/ScriptRunner.GUI/Scripts/TextInputScript.json b/src/ScriptRunner/ScriptRunner.GUI/Scripts/TextInputScript.json index 7196f6b..a67b688 100644 --- a/src/ScriptRunner/ScriptRunner.GUI/Scripts/TextInputScript.json +++ b/src/ScriptRunner/ScriptRunner.GUI/Scripts/TextInputScript.json @@ -1,5 +1,5 @@ { - "$schema": "https://raw.githubusercontent.com/cezarypiatek/ScriptRunnerPOC/main/schema/v1/ScriptRunnerSchema.json", + "$schema": "../../../../schema/v1/ScriptRunnerSchema.json", "actions": [ { "name": "Ping", @@ -25,9 +25,9 @@ "address": "wwww.github.com" } } - ], - "predefinedArgumentSetsOrdering": "ascending" - },{ + ] + }, + { "name": "AllControlsTest", "description": "Demo of all available controls", "command": "pwsh.exe -NoProfile -Command Write-Host '{p1} {p2} {p3} {p4} {p5} {p6} {p7} {p8} {p9} {p10} {p11} {p12}'", @@ -44,6 +44,25 @@ "default": "Line 1\r\n Line 2", "prompt": "multilineText" }, + { + "name": "p2", + "description": "Multiline JSONr", + "default": "Line 1\r\n Line 2", + "prompt": "multilineText", + "promptSettings": { + "syntax": "json" + } + }, + { + "name": "p22", + "description": "Fil Content", + "default": "using System;\r\n\r\nclass Program {\r\n static void Main() {\r\n Console.WriteLine(\"Hello, World!\");\r\n }\r\n}", + "prompt": "fileContent", + "promptSettings": { + "extension": "cs", + "templateText": "using System;\r\n\r\nclass Program {\r\n static void Main() {\r\n Console.WriteLine(\"Hello, World!\");\r\n }\r\n}" + } + }, { "name": "p3", "description": "Checkbox parameter", @@ -52,7 +71,7 @@ "promptSettings":{ "checkedValue": "checked", "uncheckedValue": "unchecked" - } + } }, { "name": "p4", diff --git a/src/ScriptRunner/ScriptRunner.GUI/Themes/StyleClasses.axaml b/src/ScriptRunner/ScriptRunner.GUI/Themes/StyleClasses.axaml index 2d28ef0..bfaa321 100644 --- a/src/ScriptRunner/ScriptRunner.GUI/Themes/StyleClasses.axaml +++ b/src/ScriptRunner/ScriptRunner.GUI/Themes/StyleClasses.axaml @@ -1,7 +1,8 @@  + xmlns:tanker="https://github.com/projektanker/icons.avalonia" + xmlns:gui="clr-namespace:ScriptRunner.GUI"> @@ -118,19 +119,52 @@ + + + + + + + + + + + + + + + - - - + - - - + - - - + + + + + + + + + + + + + @@ -185,17 +211,10 @@ Compacted - - - - - - - - - - - + diff --git a/src/ScriptRunner/ScriptRunner.GUI/Views/ActionDetailsSection.axaml.cs b/src/ScriptRunner/ScriptRunner.GUI/Views/ActionDetailsSection.axaml.cs index b838245..80a6244 100644 --- a/src/ScriptRunner/ScriptRunner.GUI/Views/ActionDetailsSection.axaml.cs +++ b/src/ScriptRunner/ScriptRunner.GUI/Views/ActionDetailsSection.axaml.cs @@ -65,4 +65,12 @@ private void SplitButton_OnClick(object? sender, RoutedEventArgs e) else sp.Flyout.ShowAt(sp); } } + + private void OnActionPanelScrollChange(object? sender, ScrollChangedEventArgs e) + { + if (sender is ScrollViewer sc && e.ExtentDelta.Y > 0) + { + sc.ScrollToHome(); + } + } } \ No newline at end of file diff --git a/src/ScriptRunner/ScriptRunner.GUI/Views/ActionsList.axaml b/src/ScriptRunner/ScriptRunner.GUI/Views/ActionsList.axaml index 5231dbd..8176b14 100644 --- a/src/ScriptRunner/ScriptRunner.GUI/Views/ActionsList.axaml +++ b/src/ScriptRunner/ScriptRunner.GUI/Views/ActionsList.axaml @@ -4,24 +4,31 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:viewModels="clr-namespace:ScriptRunner.GUI.ViewModels" xmlns:scriptConfigs="clr-namespace:ScriptRunner.GUI.ScriptConfigs" + xmlns:avalonia="https://github.com/projektanker/icons.avalonia" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" x:DataType="viewModels:MainWindowViewModel" x:Class="ScriptRunner.GUI.Views.ActionsList"> - + - + + + + - + + + + diff --git a/src/ScriptRunner/ScriptRunner.GUI/Views/ContentWithSidebar.axaml b/src/ScriptRunner/ScriptRunner.GUI/Views/ContentWithSidebar.axaml index 67862a5..d16a253 100644 --- a/src/ScriptRunner/ScriptRunner.GUI/Views/ContentWithSidebar.axaml +++ b/src/ScriptRunner/ScriptRunner.GUI/Views/ContentWithSidebar.axaml @@ -9,11 +9,11 @@ + + + + + + + + + + + + + + + + + diff --git a/src/ScriptRunner/ScriptRunner.GUI/Views/DatePickerOverlay.axaml.cs b/src/ScriptRunner/ScriptRunner.GUI/Views/DatePickerOverlay.axaml.cs new file mode 100644 index 0000000..8d5694e --- /dev/null +++ b/src/ScriptRunner/ScriptRunner.GUI/Views/DatePickerOverlay.axaml.cs @@ -0,0 +1,43 @@ +using System; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Markup.Xaml; +using ScriptRunner.GUI.ViewModels; + +namespace ScriptRunner.GUI.Views; + +public partial class DatePickerOverlay : UserControl +{ + public DatePickerOverlay() + { + InitializeComponent(); + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } + + private void OnOverlayClicked(object? sender, PointerPressedEventArgs e) + { + // Close the overlay when clicking on the background + if (DataContext is MainWindowViewModel viewModel) + { + viewModel.IsDatePickerVisible = false; + } + } + + private void OnDateItemClicked(object? sender, PointerPressedEventArgs e) + { + if (sender is Border border && border.DataContext is DateGroupInfo dateInfo) + { + if (DataContext is MainWindowViewModel viewModel) + { + viewModel.ScrollToDate(dateInfo.Date); + } + } + e.Handled = true; + } +} diff --git a/src/ScriptRunner/ScriptRunner.GUI/Views/ExecutionLogList.axaml b/src/ScriptRunner/ScriptRunner.GUI/Views/ExecutionLogList.axaml new file mode 100644 index 0000000..5336ba4 --- /dev/null +++ b/src/ScriptRunner/ScriptRunner.GUI/Views/ExecutionLogList.axaml @@ -0,0 +1,162 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/ScriptRunner/ScriptRunner.GUI/Views/ExecutionLogList.axaml.cs b/src/ScriptRunner/ScriptRunner.GUI/Views/ExecutionLogList.axaml.cs new file mode 100644 index 0000000..2f4081d --- /dev/null +++ b/src/ScriptRunner/ScriptRunner.GUI/Views/ExecutionLogList.axaml.cs @@ -0,0 +1,191 @@ +using System; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Linq; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Markup.Xaml; +using Avalonia.VisualTree; +using ScriptRunner.GUI.ViewModels; + +namespace ScriptRunner.GUI.Views; + +public partial class ExecutionLogList : UserControl +{ + public static readonly StyledProperty?> ItemsProperty = + AvaloniaProperty.Register?>(nameof(Items)); + + public static readonly StyledProperty?> GroupedItemsProperty = + AvaloniaProperty.Register?>(nameof(GroupedItems)); + + public static readonly StyledProperty SelectedItemProperty = + AvaloniaProperty.Register(nameof(SelectedItem), defaultBindingMode: Avalonia.Data.BindingMode.TwoWay); + + public static readonly StyledProperty SelectedLogItemProperty = + AvaloniaProperty.Register(nameof(SelectedLogItem), defaultBindingMode: Avalonia.Data.BindingMode.TwoWay); + + public static readonly StyledProperty ShowDatePickerProperty = + AvaloniaProperty.Register(nameof(ShowDatePicker), defaultValue: false); + + // Event for when date header is clicked + public event EventHandler? DateHeaderClicked; + + private INotifyCollectionChanged? _currentCollection; + + public IEnumerable? Items + { + get => GetValue(ItemsProperty); + set => SetValue(ItemsProperty, value); + } + + public IEnumerable? GroupedItems + { + get => GetValue(GroupedItemsProperty); + private set => SetValue(GroupedItemsProperty, value); + } + + public ExecutionLogAction? SelectedItem + { + get => GetValue(SelectedItemProperty); + set => SetValue(SelectedItemProperty, value); + } + + public ExecutionLogItemBase? SelectedLogItem + { + get => GetValue(SelectedLogItemProperty); + set => SetValue(SelectedLogItemProperty, value); + } + + public bool ShowDatePicker + { + get => GetValue(ShowDatePickerProperty); + set => SetValue(ShowDatePickerProperty, value); + } + + public ExecutionLogList() + { + InitializeComponent(); + + // Watch for changes to Items and rebuild grouped list + this.GetObservable(ItemsProperty).Subscribe(items => + { + // Unsubscribe from old collection + if (_currentCollection != null) + { + _currentCollection.CollectionChanged -= OnCollectionChanged; + } + + // Subscribe to new collection if it's observable + if (items is INotifyCollectionChanged observable) + { + _currentCollection = observable; + observable.CollectionChanged += OnCollectionChanged; + } + else + { + _currentCollection = null; + } + + RebuildGroupedList(); + }); + + // Watch for changes to SelectedLogItem and update SelectedItem + this.GetObservable(SelectedLogItemProperty).Subscribe(item => + { + if (item is ExecutionLogDateHeader) + { + // Ignore date header selections + SelectedLogItem = null; + return; + } + + if (item is ExecutionLogItemAction actionItem) + { + SelectedItem = actionItem.Action; + } + }); + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } + + private void RebuildGroupedList() + { + if (Items == null) + { + GroupedItems = null; + return; + } + + var items = new List(); + DateTime? lastDate = null; + + foreach (var action in Items) + { + var actionDate = action.Timestamp.Date; + + // Add date header if the date changed + if (lastDate == null || lastDate != actionDate) + { + items.Add(new ExecutionLogDateHeader(actionDate)); + lastDate = actionDate; + } + + items.Add(new ExecutionLogItemAction(action)); + } + + GroupedItems = items; + } + + private void OnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + RebuildGroupedList(); + } + + public void OnDateHeaderClicked(object? sender, PointerPressedEventArgs e) + { + // Show the date picker overlay when a date header is clicked (if enabled) + if (ShowDatePicker) + { + // Raise the event so parent can show date picker + DateHeaderClicked?.Invoke(this, EventArgs.Empty); + } + e.Handled = true; + } + + public async Task ScrollToDate(DateTime date) + { + var listBox = this.FindControl("ExecutionLogListBox"); + if (listBox == null || GroupedItems == null) return; + + var items = GroupedItems.ToList(); + var targetItem = items.FirstOrDefault(item => + item is ExecutionLogDateHeader header && header.Date == date.Date); + + if (targetItem != null) + { + listBox.ScrollIntoView(targetItem); + + await Task.Delay(200); + + var itemContainer = listBox.ContainerFromItem(targetItem); + if (itemContainer != null) + { + var border = itemContainer.GetVisualDescendants() + .OfType() + .FirstOrDefault(b => b.Name == "DateHeaderBorder"); + + if (border != null) + { + border.Classes.Add("highlight"); + await Task.Delay(2000); + border.Classes.Remove("highlight"); + } + } + } + } +} diff --git a/src/ScriptRunner/ScriptRunner.GUI/Views/MainWindow.axaml b/src/ScriptRunner/ScriptRunner.GUI/Views/MainWindow.axaml index 32f8a5e..cec6890 100644 --- a/src/ScriptRunner/ScriptRunner.GUI/Views/MainWindow.axaml +++ b/src/ScriptRunner/ScriptRunner.GUI/Views/MainWindow.axaml @@ -24,16 +24,6 @@ BorderBrush="#3baced"> - - - - - - - - - - - - - + + + + + + @@ -86,6 +74,18 @@ + + + + + + diff --git a/src/ScriptRunner/ScriptRunner.GUI/Views/MainWindow.axaml.cs b/src/ScriptRunner/ScriptRunner.GUI/Views/MainWindow.axaml.cs index 984c910..e454731 100644 --- a/src/ScriptRunner/ScriptRunner.GUI/Views/MainWindow.axaml.cs +++ b/src/ScriptRunner/ScriptRunner.GUI/Views/MainWindow.axaml.cs @@ -34,6 +34,26 @@ public MainWindow() InitializeComponent(); ViewModel = Locator.Current.GetService(); Title = $"ScriptRunner {this.GetType().Assembly.GetName().Version}"; + + // Set up scroll action for date navigation + if (ViewModel != null) + { + ViewModel.ScrollToDateAction = ScrollToDate; + } + + // Subscribe to date header click event from ExecutionLogList control + var executionLogList = this.FindControl("ExecutionLogListControl"); + if (executionLogList != null) + { + executionLogList.DateHeaderClicked += (sender, args) => + { + if (ViewModel != null) + { + ViewModel.IsDatePickerVisible = true; + } + }; + } + if (AppSettingsService.Load().Layout is { } layoutSettings) { @@ -69,4 +89,14 @@ public MainWindow() }); } } + + private async void ScrollToDate(DateTime date) + { + // Find the ExecutionLogList control + var executionLogList = this.FindControl("ExecutionLogListControl"); + if (executionLogList != null) + { + await executionLogList.ScrollToDate(date); + } + } } \ No newline at end of file diff --git a/src/ScriptRunner/ScriptRunner.GUI/Views/RunningJobsSection.axaml b/src/ScriptRunner/ScriptRunner.GUI/Views/RunningJobsSection.axaml index 098e40c..d49d0f9 100644 --- a/src/ScriptRunner/ScriptRunner.GUI/Views/RunningJobsSection.axaml +++ b/src/ScriptRunner/ScriptRunner.GUI/Views/RunningJobsSection.axaml @@ -11,6 +11,13 @@ x:DataType="viewModels:MainWindowViewModel" > + + + + + + + @@ -73,6 +80,7 @@ + Follow output @@ -83,3 +91,4 @@ + diff --git a/src/ScriptRunner/ScriptRunner.GUI/Views/RunningJobsSection.axaml.cs b/src/ScriptRunner/ScriptRunner.GUI/Views/RunningJobsSection.axaml.cs index 8be5aa9..6a580ad 100644 --- a/src/ScriptRunner/ScriptRunner.GUI/Views/RunningJobsSection.axaml.cs +++ b/src/ScriptRunner/ScriptRunner.GUI/Views/RunningJobsSection.axaml.cs @@ -10,6 +10,8 @@ namespace ScriptRunner.GUI.Views; public partial class RunningJobsSection : UserControl { + private bool _isUserScrolling = false; + public RunningJobsSection() { InitializeComponent(); @@ -22,9 +24,30 @@ private void InitializeComponent() private void ScrollChangedHandler(object? sender, ScrollChangedEventArgs e) { - if (sender is ScrollViewer sc && e.ExtentDelta.Y > 0) + if (sender is ScrollViewer sc && sc.DataContext is RunningJobViewModel viewModel) { - sc.ScrollToEnd(); + // If content was added (extent changed), auto-scroll if follow output is enabled + if (e.ExtentDelta.Y > 0 && viewModel.FollowOutput) + { + _isUserScrolling = false; + sc.ScrollToEnd(); + } + // If user manually scrolled (offset changed without extent change) + else if (e.OffsetDelta.Y != 0 && e.ExtentDelta.Y == 0) + { + _isUserScrolling = true; + // Check if user scrolled away from bottom + var isAtBottom = Math.Abs(sc.Offset.Y - sc.ScrollBarMaximum.Y) < 1.0; + if (!isAtBottom && viewModel.FollowOutput) + { + viewModel.FollowOutput = false; + } + // If user scrolled back to bottom, re-enable follow output + else if (isAtBottom && !viewModel.FollowOutput) + { + viewModel.FollowOutput = true; + } + } } } diff --git a/src/ScriptRunner/ScriptRunner.GUI/Views/SideMenu.axaml b/src/ScriptRunner/ScriptRunner.GUI/Views/SideMenu.axaml index 637f78b..ece75c8 100644 --- a/src/ScriptRunner/ScriptRunner.GUI/Views/SideMenu.axaml +++ b/src/ScriptRunner/ScriptRunner.GUI/Views/SideMenu.axaml @@ -7,7 +7,7 @@ mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" x:DataType="viewModels:MainWindowViewModel" x:Class="ScriptRunner.GUI.Views.SideMenu"> - +