diff --git a/StreamDeckSimHub.Plugin/ActionEditor/GenericButtonEditor.xaml b/StreamDeckSimHub.Plugin/ActionEditor/GenericButtonEditor.xaml index a2ed31d..23ff7a5 100644 --- a/StreamDeckSimHub.Plugin/ActionEditor/GenericButtonEditor.xaml +++ b/StreamDeckSimHub.Plugin/ActionEditor/GenericButtonEditor.xaml @@ -14,7 +14,7 @@ Closed="OnClosed" Icon="/ActionEditor/GenericButtonEditor.ico" Title="{Binding NameForTitle}" - Height="800" Width="1000" MinHeight="750" MinWidth="800"> + Height="810" Width="1000" MinHeight="810" MinWidth="800"> diff --git a/StreamDeckSimHub.Plugin/ActionEditor/ViewModels/DisplayItemViewModels.cs b/StreamDeckSimHub.Plugin/ActionEditor/ViewModels/DisplayItemViewModels.cs index 7115f0d..691b2a4 100644 --- a/StreamDeckSimHub.Plugin/ActionEditor/ViewModels/DisplayItemViewModels.cs +++ b/StreamDeckSimHub.Plugin/ActionEditor/ViewModels/DisplayItemViewModels.cs @@ -1,6 +1,7 @@ // Copyright (C) 2026 Martin Renner // LGPL-3.0-or-later (see file COPYING and COPYING.LESSER) +using System.Collections.ObjectModel; using System.ComponentModel; using System.Globalization; using System.IO; @@ -13,6 +14,7 @@ using StreamDeckSimHub.Plugin.ActionEditor.Tools; using StreamDeckSimHub.Plugin.ActionEditor.Views.Controls; using StreamDeckSimHub.Plugin.Actions.GenericButton.Model; +using StreamDeckSimHub.Plugin.Actions.GenericButton.Model.Modifiers; using StreamDeckSimHub.Plugin.Tools; using Color = SixLabors.ImageSharp.Color; using Point = SixLabors.ImageSharp.Point; @@ -28,8 +30,15 @@ public abstract partial class DisplayItemViewModel(DisplayItem model, IViewModel { protected DisplayItemViewModel(DisplayItem model, IViewModel parentViewModel) : this(model, parentViewModel, null) { + Modifiers = new ObservableCollection(model.Modifiers.Select(ModifierToViewModel)); + + if (model is IAcceptsModifierBlink) AvailableModifiers.Add(ModifierBlink.UiName); + if (model is IAcceptsModifierColor) AvailableModifiers.Add(ModifierColor.UiName); + CanAddModifier = AvailableModifiers.Count > 0; } + [ObservableProperty] private int _selectedTabIndex; + #region Element Data [ObservableProperty] private float _transparency = model.DisplayParameters.Transparency; @@ -101,6 +110,92 @@ partial void OnRotationChanged(int value) #endregion + #region Modifiers + + public ObservableCollection Modifiers { get; } = []; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsModifierSelected))] + private ModifierViewModel? _selectedModifier; + + public bool IsModifierSelected => SelectedModifier != null; + + public ObservableCollection AvailableModifiers { get; } = []; + + [ObservableProperty] private bool _canAddModifier; + + [RelayCommand] + private void AddModifier(string type) + { + switch (type) + { + case ModifierBlink.UiName: + AddModifier(ModifierBlink.Create()); + break; + case ModifierColor.UiName: + AddModifier(ModifierColor.Create()); + break; + } + } + + private void AddModifier(Modifier modifier) + { + model.Modifiers.Add(modifier); + var vm = ModifierToViewModel(modifier); + Modifiers.Add(vm); + SelectedModifier = vm; + } + + private ModifierViewModel ModifierToViewModel(Modifier modifier) + { + return modifier switch + { + ModifierBlink modifierBlink => new ModifierBlinkViewModel(modifierBlink, ParentViewModel), + ModifierColor colorModifier => new ModifierColorViewModel(colorModifier, ParentViewModel), + _ => throw new InvalidOperationException($"Unknown Modifier type: {modifier.GetType().FullName}") + }; + } + + public void RemoveModifier(ModifierViewModel item) + { + // Remove from the underlying model + var modifier = item.GetModel(); + model.Modifiers.Remove(modifier); + + // Remove from the ViewModel collection + Modifiers.Remove(item); + + // Clear selection if this was the selected item + if (SelectedModifier == item) + { + SelectedModifier = null; + } + + } + + #endregion + + #region DragDrop + + /// + /// Updates the underlying model when Modifiers are reordered + /// + public void UpdateModifiersOrder() + { + // Update the underlying model's Modifiers list to match the order in the ViewModel + // We'll create a new list with the same items but in the new order + var newList = Modifiers.Select(modifierVm => modifierVm.GetModel()).ToList(); + + // Clear and repopulate the original list to maintain the reference + model.Modifiers.Clear(); + foreach (var item in newList) + { + model.Modifiers.Add(item); + } + } + + #endregion + public string Error => string.Empty; public string this[string columnName] diff --git a/StreamDeckSimHub.Plugin/ActionEditor/ViewModels/ModifierViewModels.cs b/StreamDeckSimHub.Plugin/ActionEditor/ViewModels/ModifierViewModels.cs new file mode 100644 index 0000000..d06a21e --- /dev/null +++ b/StreamDeckSimHub.Plugin/ActionEditor/ViewModels/ModifierViewModels.cs @@ -0,0 +1,88 @@ +// Copyright (C) 2026 Martin Renner +// LGPL-3.0-or-later (see file COPYING and COPYING.LESSER) + +using System.Windows.Media; +using CommunityToolkit.Mvvm.ComponentModel; +using StreamDeckSimHub.Plugin.ActionEditor.Tools; +using StreamDeckSimHub.Plugin.ActionEditor.Views.Controls; +using StreamDeckSimHub.Plugin.Actions.GenericButton.Model.Modifiers; +using StreamDeckSimHub.Plugin.SimHub.ShakeIt; +using Color = SixLabors.ImageSharp.Color; + +namespace StreamDeckSimHub.Plugin.ActionEditor.ViewModels; + +public abstract partial class ModifierViewModel : ObservableObject +{ + private readonly Modifier _model; + private readonly IViewModel _rootViewModel; + + protected ModifierViewModel(Modifier model, IViewModel rootViewModel) + { + this._model = model; + _rootViewModel = rootViewModel; + _expressionControlConditionViewModel = new ExpressionControlViewModel(model.NCalcConditionHolder) + { + ExpressionLabel = "Condition:", + ExpressionToolTip = "Please enter a valid NCalc expression, that returns true or false or a number", + Example="[DataCorePlugin.Computed.Fuel_RemainingLaps] <= 2", + FetchShakeItProfilesCallback = FetchShakeItProfilesCallback + }; + } + + public abstract ImageSource? Icon { get; } + + public string DisplayName => _model.DisplayName; + + [ObservableProperty] private ExpressionControlViewModel _expressionControlConditionViewModel; + + public Modifier GetModel() => _model; + + private Func>> FetchShakeItProfilesCallback => FetchShakeItProfiles; + + private async Task> FetchShakeItProfiles(string type) + { + return type == "Bass" + ? await _rootViewModel.FetchShakeItBassProfiles() + : await _rootViewModel.FetchShakeItMotorsProfiles(); + } +} + +public partial class ModifierBlinkViewModel(ModifierBlink model, IViewModel rootViewModel) + : ModifierViewModel(model, rootViewModel) +{ + public override ImageSource? Icon => null; + + [ObservableProperty] private int? _durationOn = model.DurationOn; + [ObservableProperty] private int? _durationOff = model.DurationOff; + + + partial void OnDurationOnChanged(int? value) + { + model.DurationOn = value; + } + + partial void OnDurationOffChanged(int? value) + { + model.DurationOff = value; + } +} + +public partial class ModifierColorViewModel(ModifierColor model, IViewModel rootViewModel) + : ModifierViewModel(model, rootViewModel), IColorSelectable +{ + public override ImageSource? Icon => null; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ColorHex))] + [NotifyPropertyChangedFor(nameof(ColorAsWpf))] + private Color _imageSharpColor = model.Color; + + public string ColorHex => $"#{model.Color.ToHexWithoutAlpha()}"; + + public System.Windows.Media.Color ColorAsWpf => ImageSharpColor.ToWpfColor(); + + partial void OnImageSharpColorChanged(Color value) + { + model.Color = value; + } +} diff --git a/StreamDeckSimHub.Plugin/ActionEditor/Views/Controls/DisplayElementControl.xaml.cs b/StreamDeckSimHub.Plugin/ActionEditor/Views/Controls/DisplayElementControl.xaml.cs deleted file mode 100644 index 6692c56..0000000 --- a/StreamDeckSimHub.Plugin/ActionEditor/Views/Controls/DisplayElementControl.xaml.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System.Windows.Controls; - -namespace StreamDeckSimHub.Plugin.ActionEditor.Views.Controls; - -public partial class DisplayElementControl : UserControl -{ - public DisplayElementControl() - { - InitializeComponent(); - } -} \ No newline at end of file diff --git a/StreamDeckSimHub.Plugin/ActionEditor/Views/Controls/DisplayElementControl.xaml b/StreamDeckSimHub.Plugin/ActionEditor/Views/Controls/DisplayItemControl.xaml similarity index 65% rename from StreamDeckSimHub.Plugin/ActionEditor/Views/Controls/DisplayElementControl.xaml rename to StreamDeckSimHub.Plugin/ActionEditor/Views/Controls/DisplayItemControl.xaml index cb640c3..17db1a5 100644 --- a/StreamDeckSimHub.Plugin/ActionEditor/Views/Controls/DisplayElementControl.xaml +++ b/StreamDeckSimHub.Plugin/ActionEditor/Views/Controls/DisplayItemControl.xaml @@ -1,4 +1,4 @@ - - - + @@ -20,13 +19,7 @@ - - - - + @@ -34,9 +27,9 @@ - + - + - + \ No newline at end of file diff --git a/StreamDeckSimHub.Plugin/ActionEditor/Views/Controls/DisplayItemControl.xaml.cs b/StreamDeckSimHub.Plugin/ActionEditor/Views/Controls/DisplayItemControl.xaml.cs new file mode 100644 index 0000000..4b93559 --- /dev/null +++ b/StreamDeckSimHub.Plugin/ActionEditor/Views/Controls/DisplayItemControl.xaml.cs @@ -0,0 +1,14 @@ +// Copyright (C) 2026 Martin Renner +// LGPL-3.0-or-later (see file COPYING and COPYING.LESSER) + +using System.Windows.Controls; + +namespace StreamDeckSimHub.Plugin.ActionEditor.Views.Controls; + +public partial class DisplayItemControl : UserControl +{ + public DisplayItemControl() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/StreamDeckSimHub.Plugin/ActionEditor/Views/Controls/ModifiersControl.xaml b/StreamDeckSimHub.Plugin/ActionEditor/Views/Controls/ModifiersControl.xaml new file mode 100644 index 0000000..2fae72f --- /dev/null +++ b/StreamDeckSimHub.Plugin/ActionEditor/Views/Controls/ModifiersControl.xaml @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/StreamDeckSimHub.Plugin/ActionEditor/Views/Controls/ModifiersControl.xaml.cs b/StreamDeckSimHub.Plugin/ActionEditor/Views/Controls/ModifiersControl.xaml.cs new file mode 100644 index 0000000..3a60c5d --- /dev/null +++ b/StreamDeckSimHub.Plugin/ActionEditor/Views/Controls/ModifiersControl.xaml.cs @@ -0,0 +1,63 @@ +// Copyright (C) 2026 Martin Renner +// LGPL-3.0-or-later (see file COPYING and COPYING.LESSER) + +using System.Windows; +using System.Windows.Controls; +using Microsoft.Xaml.Behaviors; +using StreamDeckSimHub.Plugin.ActionEditor.Behaviors; +using StreamDeckSimHub.Plugin.ActionEditor.ViewModels; + +namespace StreamDeckSimHub.Plugin.ActionEditor.Views.Controls; + +public partial class ModifiersControl : UserControl +{ + public ModifiersControl() + { + InitializeComponent(); + + // Set up drag-drop delegates for the ListBoxes + SetupDragDropBehaviors(); + } + + private void SetupDragDropBehaviors() + { + var modifierBehavior = Interaction.GetBehaviors(ModifiersListBox) + .OfType() + .FirstOrDefault(); + if (modifierBehavior != null) + { + modifierBehavior.OnItemDropped = OnModifierDropped; + } + } + + /// + /// Handles the "Delete" button click for Modifiers. + ///

+ /// Implemented as code-behind and not as command, because this way the ModifierViewModel does not need to know its parent Settings. + ///

+ private void ModifierDelete_OnClick(object sender, RoutedEventArgs e) + { + if (sender is Button { DataContext: ModifierViewModel modifierViewModel }) + { + var result = MessageBox.Show( + $"Are you sure you want to delete the modifier\n\"{modifierViewModel}\" ?", + "Confirm Delete", MessageBoxButton.YesNo, MessageBoxImage.Warning); + if (result == MessageBoxResult.Yes) + { + ((DisplayItemViewModel)DataContext).RemoveModifier(modifierViewModel); + } + } + } + + /// + /// Handles the drop operation for Modifiers, updating the order in the view model and underlying model. + /// + private void OnModifierDropped(object draggedItem, object targetItem, int sourceIndex, int targetIndex) + { + if (draggedItem is not ModifierViewModel) return; + + // Get the collection and reorder items + ((DisplayItemViewModel)DataContext).Modifiers.Move(sourceIndex, targetIndex); + ((DisplayItemViewModel)DataContext).UpdateModifiersOrder(); + } +} \ No newline at end of file diff --git a/StreamDeckSimHub.Plugin/ActionEditor/Views/DisplayItemView.xaml b/StreamDeckSimHub.Plugin/ActionEditor/Views/DisplayItemView.xaml index 4cfd1aa..fc7d9b8 100644 --- a/StreamDeckSimHub.Plugin/ActionEditor/Views/DisplayItemView.xaml +++ b/StreamDeckSimHub.Plugin/ActionEditor/Views/DisplayItemView.xaml @@ -22,6 +22,33 @@ - + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/StreamDeckSimHub.Plugin/ActionEditor/Views/ModifierBlinkView.xaml b/StreamDeckSimHub.Plugin/ActionEditor/Views/ModifierBlinkView.xaml new file mode 100644 index 0000000..7e0ba67 --- /dev/null +++ b/StreamDeckSimHub.Plugin/ActionEditor/Views/ModifierBlinkView.xaml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + x 100 ms + + + + + + x 100 ms + + + + + + + diff --git a/StreamDeckSimHub.Plugin/ActionEditor/Views/ModifierBlinkView.xaml.cs b/StreamDeckSimHub.Plugin/ActionEditor/Views/ModifierBlinkView.xaml.cs new file mode 100644 index 0000000..260b469 --- /dev/null +++ b/StreamDeckSimHub.Plugin/ActionEditor/Views/ModifierBlinkView.xaml.cs @@ -0,0 +1,14 @@ +// Copyright (C) 2026 Martin Renner +// LGPL-3.0-or-later (see file COPYING and COPYING.LESSER) + +using System.Windows.Controls; + +namespace StreamDeckSimHub.Plugin.ActionEditor.Views; + +public partial class ModifierBlinkView : UserControl +{ + public ModifierBlinkView() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/StreamDeckSimHub.Plugin/ActionEditor/Views/ModifierColorView.xaml b/StreamDeckSimHub.Plugin/ActionEditor/Views/ModifierColorView.xaml new file mode 100644 index 0000000..43a62ac --- /dev/null +++ b/StreamDeckSimHub.Plugin/ActionEditor/Views/ModifierColorView.xaml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + diff --git a/StreamDeckSimHub.Plugin/ActionEditor/Views/ModifierColorView.xaml.cs b/StreamDeckSimHub.Plugin/ActionEditor/Views/ModifierColorView.xaml.cs new file mode 100644 index 0000000..d9c2397 --- /dev/null +++ b/StreamDeckSimHub.Plugin/ActionEditor/Views/ModifierColorView.xaml.cs @@ -0,0 +1,14 @@ +// Copyright (C) 2026 Martin Renner +// LGPL-3.0-or-later (see file COPYING and COPYING.LESSER) + +using System.Windows.Controls; + +namespace StreamDeckSimHub.Plugin.ActionEditor.Views; + +public partial class ModifierColorView : UserControl +{ + public ModifierColorView() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/StreamDeckSimHub.Plugin/Actions/GenericButton/ConditionEvaluator.cs b/StreamDeckSimHub.Plugin/Actions/GenericButton/ConditionEvaluator.cs new file mode 100644 index 0000000..955e569 --- /dev/null +++ b/StreamDeckSimHub.Plugin/Actions/GenericButton/ConditionEvaluator.cs @@ -0,0 +1,63 @@ +// Copyright (C) 2026 Martin Renner +// LGPL-3.0-or-later (see file COPYING and COPYING.LESSER) + +using StreamDeckSimHub.Plugin.Actions.GenericButton.Model; +using StreamDeckSimHub.Plugin.Actions.GenericButton.Model.Modifiers; +using StreamDeckSimHub.Plugin.PropertyLogic; + +namespace StreamDeckSimHub.Plugin.Actions.GenericButton; + +/// +/// Helper class for evaluating conditions on Items and Modifiers. +/// Centralizes the logic for checking active states and evaluating expressions. +/// +public class ConditionEvaluator +{ + private readonly NCalcHandler _ncalcHandler; + private readonly GetPropertyDelegate _getPropertyDelegate; + private readonly Func _coordinatesProvider; + + /// + /// Creates a new instance of the ConditionEvaluator. + /// + /// The NCalc handler for expression evaluation. + /// Delegate to retrieve property values. + /// Function that provides current coordinates for logging. + public ConditionEvaluator( + NCalcHandler ncalcHandler, + GetPropertyDelegate getPropertyDelegate, + Func coordinatesProvider) + { + _ncalcHandler = ncalcHandler; + _getPropertyDelegate = getPropertyDelegate; + _coordinatesProvider = coordinatesProvider; + } + + /// + /// Evaluates the condition of an Item. If the result is true or a positive number, the item is considered active. + /// + public bool IsItemActive(Item item) + { + if (item.NCalcConditionHolder.NCalcExpression == null) return true; // No condition means always active + var value = Evaluate(item.NCalcConditionHolder, $"Visibility of \"{item.DisplayName}\""); + return value is true or > 0 or > 0.0f or > 0.0d; + } + + /// + /// Evaluates the condition of a Modifier. If the result is true or a positive number, the modifier is considered active. + /// + public bool IsModifierActive(Modifier modifier) + { + if (modifier.NCalcConditionHolder.NCalcExpression == null) return true; // No condition means always active + var value = Evaluate(modifier.NCalcConditionHolder, $"Modifier of \"{modifier.DisplayName}\""); + return value is true or > 0 or > 0.0f or > 0.0d; + } + + public object? Evaluate(NCalcHolder nCalcHolder, string loggingContext) + { + return _ncalcHandler.EvaluateExpression( + nCalcHolder, + _getPropertyDelegate, + $"({_coordinatesProvider}) {loggingContext}"); + } +} \ No newline at end of file diff --git a/StreamDeckSimHub.Plugin/Actions/GenericButton/GenericButtonAction.cs b/StreamDeckSimHub.Plugin/Actions/GenericButton/GenericButtonAction.cs index 1e6359e..22be994 100644 --- a/StreamDeckSimHub.Plugin/Actions/GenericButton/GenericButtonAction.cs +++ b/StreamDeckSimHub.Plugin/Actions/GenericButton/GenericButtonAction.cs @@ -1,4 +1,4 @@ -// Copyright (C) 2025 Martin Renner +// Copyright (C) 2026 Martin Renner // LGPL-3.0-or-later (see file COPYING and COPYING.LESSER) using System.ComponentModel; @@ -14,6 +14,7 @@ using StreamDeckSimHub.Plugin.ActionEditor; using StreamDeckSimHub.Plugin.Actions.GenericButton.JsonSettings; using StreamDeckSimHub.Plugin.Actions.GenericButton.Model; +using StreamDeckSimHub.Plugin.Actions.GenericButton.Model.Modifiers; using StreamDeckSimHub.Plugin.Actions.GenericButton.Renderer; using StreamDeckSimHub.Plugin.PropertyLogic; using StreamDeckSimHub.Plugin.SimHub; @@ -33,7 +34,7 @@ public class GenericButtonAction : StreamDeckAction private readonly ImageManager _imageManager; private readonly ActionEditorManager _actionEditorManager; private readonly ISimHubConnection _simHubConnection; - private readonly NCalcHandler _ncalcHandler; + private readonly ConditionEvaluator _conditionEvaluator; private readonly IPropertyChangedReceiver _statePropertyChangedReceiver; private readonly IButtonRenderer _buttonRenderer; private readonly CommandItemHandler _commandItemHandler; @@ -57,7 +58,7 @@ public GenericButtonAction( _imageManager = imageManager; _actionEditorManager = actionEditorManager; _simHubConnection = simHubConnection; - _ncalcHandler = ncalcHandler; + _conditionEvaluator = new ConditionEvaluator(ncalcHandler, GetProperty, () => _coordinates?.ToString() ?? "(?)"); _statePropertyChangedReceiver = new PropertyChangedDelegate(PropertyChanged); _buttonRenderer = new ButtonRendererImageSharp(GetProperty); _commandItemHandler = new CommandItemHandler(simHubConnection, new KeyboardUtils()); @@ -144,12 +145,17 @@ protected override async Task OnWillAppear(ActionEventArgs ar await SubscribeProperties(); await Render(); + PeriodicBackgroundService.Tick += OnTick; + await base.OnWillAppear(args); } protected override async Task OnWillDisappear(ActionEventArgs args) { Logger.LogInformation("({coords}) OnWillDisappear", args.Payload.Coordinates); + + PeriodicBackgroundService.Tick -= OnTick; + _actionEditorManager.RemoveGenericButtonEditor(Context); await _commandItemHandler.Stop(); await UnsubscribeProperties(); @@ -178,7 +184,7 @@ protected override async Task OnKeyDown(ActionEventArgs args) if (_settings == null) return; Logger.LogInformation("({coords}) OnKeyDown", args.Payload.Coordinates); - await _commandItemHandler.KeyDown(_settings.CommandItems[StreamDeckAction.KeyDown], IsActive); + await _commandItemHandler.KeyDown(_settings.CommandItems[StreamDeckAction.KeyDown], _conditionEvaluator.IsItemActive); } protected override async Task OnKeyUp(ActionEventArgs args) @@ -197,7 +203,7 @@ protected override async Task OnDialRotate(ActionEventArgs ar var ticks = args.Payload.Ticks; await _commandItemHandler.DialRotate( ticks < 0 ? _settings.CommandItems[StreamDeckAction.DialLeft] : _settings.CommandItems[StreamDeckAction.DialRight], - IsActive, ticks); + _conditionEvaluator.IsItemActive, ticks); } protected override async Task OnDialDown(ActionEventArgs args) @@ -205,7 +211,7 @@ protected override async Task OnDialDown(ActionEventArgs args) if (_settings == null) return; Logger.LogInformation("({coords}) OnDialDown", args.Payload.Coordinates); - await _commandItemHandler.DialDown(_settings.CommandItems[StreamDeckAction.DialDown], IsActive); + await _commandItemHandler.DialDown(_settings.CommandItems[StreamDeckAction.DialDown], _conditionEvaluator.IsItemActive); } protected override async Task OnDialUp(ActionEventArgs args) @@ -221,7 +227,7 @@ protected override async Task OnTouchTap(ActionEventArgs args) if (_settings == null) return; Logger.LogInformation("({coords}) OnTouchTap", args.Payload.Coordinates); - await _commandItemHandler.TouchTap(_settings.CommandItems[StreamDeckAction.TouchTap], IsActive); + await _commandItemHandler.TouchTap(_settings.CommandItems[StreamDeckAction.TouchTap], _conditionEvaluator.IsItemActive); } /// @@ -277,8 +283,8 @@ private async Task ConvertSettings(SettingsDto dto, StreamDeckKeyInfo } /// - /// Determines which properties are used in the current settings and compares them to the previously subscribed properties. - /// No longer used properties are unsubscribed, and new properties are subscribed. + /// Determines which SimHub properties are used in the current settings and compares them to the previously subscribed + /// SimHub properties. No longer used properties are unsubscribed, and new properties are subscribed. /// private async Task SubscribeProperties() { @@ -305,6 +311,17 @@ private async Task SubscribeProperties() newProperties.Add(propName); } } + + // DisplayItem.Modifiers can contain properties. + foreach (var modifier in displayItem.Modifiers) + { + foreach (var propName in modifier.NCalcConditionHolder.UsedProperties) + { + Logger.LogDebug("({coords}) Found property \"{propName}\" in modifier \"{name}\" of \"{itemName}\"", + _coordinates, propName, modifier.DisplayName, displayItem.DisplayName); + newProperties.Add(propName); + } + } } if (_settings?.CommandItems != null) @@ -399,10 +416,39 @@ private async Task Render() return propertyChangedArgs?.PropertyValue; } - private bool IsActive(Item item) + private async Task OnTick() { - return _ncalcHandler.IsConditionActive(item.NCalcConditionHolder, GetProperty, - $"({_coordinates}) IsActive of \"{item.DisplayName}\""); + if (_settings == null) return; + + // If any blinking modifier transitioned (inactive->active or active->inactive), or if any active blinking modifier + // changed state (on->off or off->on), we need to redraw the button. + var needsRedraw = false; + foreach (var displayItem in _settings.DisplayItems) + { + foreach (var modifier in displayItem.Modifiers) + { + if (modifier is ModifierBlink modifierBlink) + { + var isActiveNow = _conditionEvaluator.IsModifierActive(modifier); + + var transitioned = modifierBlink.DetermineTransition(isActiveNow); + if (transitioned) needsRedraw = true; + + // Only tick if the condition is active + if (isActiveNow) + { + var transitionedOnOff = modifierBlink.Tick(); + if (transitionedOnOff) needsRedraw = true; + } + } + } + } + + if (needsRedraw) + { + Logger.LogTrace("({coords}) OnTick: Redrawing due to blinking modifier change", _coordinates); + await Render(); + } } [JsonObject(ItemNullValueHandling = NullValueHandling.Ignore)] diff --git a/StreamDeckSimHub.Plugin/Actions/GenericButton/Model/DisplayItem.cs b/StreamDeckSimHub.Plugin/Actions/GenericButton/Model/DisplayItem.cs index 8dc10c8..b689048 100644 --- a/StreamDeckSimHub.Plugin/Actions/GenericButton/Model/DisplayItem.cs +++ b/StreamDeckSimHub.Plugin/Actions/GenericButton/Model/DisplayItem.cs @@ -1,7 +1,10 @@ -// Copyright (C) 2025 Martin Renner +// Copyright (C) 2026 Martin Renner // LGPL-3.0-or-later (see file COPYING and COPYING.LESSER) +using System.Collections.ObjectModel; +using System.Collections.Specialized; using CommunityToolkit.Mvvm.ComponentModel; +using StreamDeckSimHub.Plugin.Actions.GenericButton.Model.Modifiers; namespace StreamDeckSimHub.Plugin.Actions.GenericButton.Model; @@ -9,5 +12,27 @@ public abstract partial class DisplayItem : Item { [ObservableProperty] private DisplayParameters _displayParameters = new(); + public ObservableCollection Modifiers { get; set; } = []; + + protected DisplayItem() + { + Modifiers.CollectionChanged += (_, args) => + { + if (args is { Action: NotifyCollectionChangedAction.Add, NewItems: not null }) + { + // Register on PropertyChanged of child Modifiers, so that we can propagate these changes + foreach (var item in args.NewItems) + { + if (item is Modifier modifier) + { + modifier.PropertyChanged += (sender, a) => OnPropertyChanged(a); + } + } + } + + OnPropertyChanged(nameof(Modifiers)); + }; + } + public abstract Task Accept(IDisplayItemVisitor displayItemVisitor, IVisitorArgs? args = null); } \ No newline at end of file diff --git a/StreamDeckSimHub.Plugin/Actions/GenericButton/Model/DisplayItemImage.cs b/StreamDeckSimHub.Plugin/Actions/GenericButton/Model/DisplayItemImage.cs index afab5cf..bdb1b05 100644 --- a/StreamDeckSimHub.Plugin/Actions/GenericButton/Model/DisplayItemImage.cs +++ b/StreamDeckSimHub.Plugin/Actions/GenericButton/Model/DisplayItemImage.cs @@ -1,14 +1,15 @@ -// Copyright (C) 2025 Martin Renner +// Copyright (C) 2026 Martin Renner // LGPL-3.0-or-later (see file COPYING and COPYING.LESSER) using System.IO; using CommunityToolkit.Mvvm.ComponentModel; using SixLabors.ImageSharp; +using StreamDeckSimHub.Plugin.Actions.GenericButton.Model.Modifiers; using StreamDeckSimHub.Plugin.Tools; namespace StreamDeckSimHub.Plugin.Actions.GenericButton.Model; -public partial class DisplayItemImage : DisplayItem +public partial class DisplayItemImage : DisplayItem, IAcceptsModifierBlink { public const string UiName = "Image"; public const string UiIcon = "DiInsertPhotoOutlinedGray"; diff --git a/StreamDeckSimHub.Plugin/Actions/GenericButton/Model/DisplayItemText.cs b/StreamDeckSimHub.Plugin/Actions/GenericButton/Model/DisplayItemText.cs index 001eaf9..6665ea2 100644 --- a/StreamDeckSimHub.Plugin/Actions/GenericButton/Model/DisplayItemText.cs +++ b/StreamDeckSimHub.Plugin/Actions/GenericButton/Model/DisplayItemText.cs @@ -4,10 +4,11 @@ using CommunityToolkit.Mvvm.ComponentModel; using SixLabors.Fonts; using SixLabors.ImageSharp; +using StreamDeckSimHub.Plugin.Actions.GenericButton.Model.Modifiers; namespace StreamDeckSimHub.Plugin.Actions.GenericButton.Model; -public partial class DisplayItemText : DisplayItem +public partial class DisplayItemText : DisplayItem, IAcceptsModifierBlink, IAcceptsModifierColor { public const string UiName = "Text"; public const string UiIcon = "DiTextFieldsGray"; diff --git a/StreamDeckSimHub.Plugin/Actions/GenericButton/Model/DisplayItemValue.cs b/StreamDeckSimHub.Plugin/Actions/GenericButton/Model/DisplayItemValue.cs index 60b60ef..819175f 100644 --- a/StreamDeckSimHub.Plugin/Actions/GenericButton/Model/DisplayItemValue.cs +++ b/StreamDeckSimHub.Plugin/Actions/GenericButton/Model/DisplayItemValue.cs @@ -4,11 +4,12 @@ using CommunityToolkit.Mvvm.ComponentModel; using SixLabors.Fonts; using SixLabors.ImageSharp; +using StreamDeckSimHub.Plugin.Actions.GenericButton.Model.Modifiers; using StreamDeckSimHub.Plugin.PropertyLogic; namespace StreamDeckSimHub.Plugin.Actions.GenericButton.Model; -public partial class DisplayItemValue : DisplayItem +public partial class DisplayItemValue : DisplayItem, IAcceptsModifierBlink, IAcceptsModifierColor { public const string UiName = "Value"; public const string UiIcon = "DiAttachMoneyGray"; diff --git a/StreamDeckSimHub.Plugin/Actions/GenericButton/Model/Modifiers/IAcceptsModifierBlink.cs b/StreamDeckSimHub.Plugin/Actions/GenericButton/Model/Modifiers/IAcceptsModifierBlink.cs new file mode 100644 index 0000000..a341259 --- /dev/null +++ b/StreamDeckSimHub.Plugin/Actions/GenericButton/Model/Modifiers/IAcceptsModifierBlink.cs @@ -0,0 +1,6 @@ +// Copyright (C) 2026 Martin Renner +// LGPL-3.0-or-later (see file COPYING and COPYING.LESSER) + +namespace StreamDeckSimHub.Plugin.Actions.GenericButton.Model.Modifiers; + +public interface IAcceptsModifierBlink; \ No newline at end of file diff --git a/StreamDeckSimHub.Plugin/Actions/GenericButton/Model/Modifiers/IAcceptsModifierColor.cs b/StreamDeckSimHub.Plugin/Actions/GenericButton/Model/Modifiers/IAcceptsModifierColor.cs new file mode 100644 index 0000000..01cdc33 --- /dev/null +++ b/StreamDeckSimHub.Plugin/Actions/GenericButton/Model/Modifiers/IAcceptsModifierColor.cs @@ -0,0 +1,6 @@ +// Copyright (C) 2026 Martin Renner +// LGPL-3.0-or-later (see file COPYING and COPYING.LESSER) + +namespace StreamDeckSimHub.Plugin.Actions.GenericButton.Model.Modifiers; + +public interface IAcceptsModifierColor; \ No newline at end of file diff --git a/StreamDeckSimHub.Plugin/Actions/GenericButton/Model/Modifiers/Modifier.cs b/StreamDeckSimHub.Plugin/Actions/GenericButton/Model/Modifiers/Modifier.cs new file mode 100644 index 0000000..38eb30a --- /dev/null +++ b/StreamDeckSimHub.Plugin/Actions/GenericButton/Model/Modifiers/Modifier.cs @@ -0,0 +1,29 @@ +// Copyright (C) 2026 Martin Renner +// LGPL-3.0-or-later (see file COPYING and COPYING.LESSER) + +using CommunityToolkit.Mvvm.ComponentModel; +using StreamDeckSimHub.Plugin.PropertyLogic; + +namespace StreamDeckSimHub.Plugin.Actions.GenericButton.Model.Modifiers; + +public abstract partial class Modifier : ObservableObject +{ + [ObservableProperty] private NCalcHolder _nCalcConditionHolder; + + protected Modifier() + { + // Set in constructor via the generated property to ensure that OnNCalcPropertyHolderChanged is called. + NCalcConditionHolder = new NCalcHolder(); + } + + public virtual string DisplayName => GetType().Name; + + partial void OnNCalcConditionHolderChanged(NCalcHolder value) + { + value.PropertyChanged += (_, args) => OnPropertyChanged(args.PropertyName); + // No event handler on UsedProperties.CollectionChanged. + // We rely only on the event of NCalcHolder.ExpressionString. This means that UsedProperties already has to contain + // the new state when ExpressionString is being updated. + //value.UsedProperties.CollectionChanged += (_, _) => OnPropertyChanged(nameof(NCalcHolder.UsedProperties)); + } +} \ No newline at end of file diff --git a/StreamDeckSimHub.Plugin/Actions/GenericButton/Model/Modifiers/ModifierBlink.cs b/StreamDeckSimHub.Plugin/Actions/GenericButton/Model/Modifiers/ModifierBlink.cs new file mode 100644 index 0000000..1af77a9 --- /dev/null +++ b/StreamDeckSimHub.Plugin/Actions/GenericButton/Model/Modifiers/ModifierBlink.cs @@ -0,0 +1,84 @@ +// Copyright (C) 2026 Martin Renner +// LGPL-3.0-or-later (see file COPYING and COPYING.LESSER) + +using System.Text.Json.Serialization; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace StreamDeckSimHub.Plugin.Actions.GenericButton.Model.Modifiers; + +public partial class ModifierBlink : Modifier +{ + public const string UiName = "Blink"; + + public static ModifierBlink Create() + { + return new ModifierBlink(); + } + + [ObservableProperty] private int? _durationOn; + [ObservableProperty] private int? _durationOff; + + public override string DisplayName => UiName; + + [JsonIgnore] + private int CurrentTick { get; set; } + + [JsonIgnore] + private bool WasActiveLastTick { get; set; } + + /// + /// Determines if an inactive/active or active/inactive transition occurred. + /// + /// true if a transition occured + public bool DetermineTransition(bool isActiveNow) + { + var transitioned = false; + if (isActiveNow && !WasActiveLastTick) + { + // Transition inactive->active: reset tick counter + CurrentTick = 0; + transitioned = true; + } + else if (!isActiveNow && WasActiveLastTick) + { + // Transition active->inactive: Just redraw to show the item again + transitioned = true; + } + + // Update the state for next tick + WasActiveLastTick = isActiveNow; + + return transitioned; + } + + /// + /// Increments the tick counter and returns true if an on/off transition occurred (on->off or off->on). + /// + /// true if an on/off or off/on transition occurred + public bool Tick() + { + if (DurationOn == null || DurationOff == null) return false; + + var cycleDuration = DurationOn.Value + DurationOff.Value; + CurrentTick++; + + // Wrap around at the end of the cycle + if (CurrentTick > cycleDuration) + { + CurrentTick = 1; + } + + // Determine if transitioned on->off or off->on + return CurrentTick == DurationOn + 1 || CurrentTick == 1; + } + + /// + /// Is the modifier currently in the "Off" phase? + /// + public bool IsOffPhase() + { + if (DurationOn == null || DurationOff == null) return false; + + return CurrentTick > DurationOn.Value; + } +} \ No newline at end of file diff --git a/StreamDeckSimHub.Plugin/Actions/GenericButton/Model/Modifiers/ModifierColor.cs b/StreamDeckSimHub.Plugin/Actions/GenericButton/Model/Modifiers/ModifierColor.cs new file mode 100644 index 0000000..11386db --- /dev/null +++ b/StreamDeckSimHub.Plugin/Actions/GenericButton/Model/Modifiers/ModifierColor.cs @@ -0,0 +1,21 @@ +// Copyright (C) 2026 Martin Renner +// LGPL-3.0-or-later (see file COPYING and COPYING.LESSER) + +using CommunityToolkit.Mvvm.ComponentModel; +using SixLabors.ImageSharp; + +namespace StreamDeckSimHub.Plugin.Actions.GenericButton.Model.Modifiers; + +public partial class ModifierColor : Modifier +{ + public const string UiName = "Color"; + + public static ModifierColor Create() + { + return new ModifierColor(); + } + + [ObservableProperty] private Color _color = Color.White; + + public override string DisplayName => UiName; +} \ No newline at end of file diff --git a/StreamDeckSimHub.Plugin/Actions/GenericButton/Renderer/ButtonRendererImageSharp.cs b/StreamDeckSimHub.Plugin/Actions/GenericButton/Renderer/ButtonRendererImageSharp.cs index 27bedd2..54c8259 100644 --- a/StreamDeckSimHub.Plugin/Actions/GenericButton/Renderer/ButtonRendererImageSharp.cs +++ b/StreamDeckSimHub.Plugin/Actions/GenericButton/Renderer/ButtonRendererImageSharp.cs @@ -1,4 +1,4 @@ -// Copyright (C) 2025 Martin Renner +// Copyright (C) 2026 Martin Renner // LGPL-3.0-or-later (see file COPYING and COPYING.LESSER) using System.Collections.ObjectModel; @@ -9,25 +9,34 @@ using SixLabors.Fonts; using SixLabors.ImageSharp; using SixLabors.ImageSharp.Drawing.Processing; -using SixLabors.ImageSharp.Formats.Png; using SixLabors.ImageSharp.PixelFormats; using SixLabors.ImageSharp.Processing; using StreamDeckSimHub.Plugin.ActionEditor.Tools; using StreamDeckSimHub.Plugin.Actions.GenericButton.Model; +using StreamDeckSimHub.Plugin.Actions.GenericButton.Model.Modifiers; using StreamDeckSimHub.Plugin.PropertyLogic; using StreamDeckSimHub.Plugin.Tools; using Size = SixLabors.ImageSharp.Size; namespace StreamDeckSimHub.Plugin.Actions.GenericButton.Renderer; -public class ButtonRendererImageSharp(GetPropertyDelegate getProperty) : IButtonRenderer +public class ButtonRendererImageSharp : IButtonRenderer { private readonly Logger _logger = LogManager.GetCurrentClassLogger(); private readonly StreamDeckKeyInfo _defaultKeyInfo = StreamDeckKeyInfoBuilder.DefaultKeyInfo; private readonly NCalcHandler _ncalcHandler = new(); private readonly FormatHelper _formatHelper = new(); + private readonly ConditionEvaluator _conditionEvaluator; private Coordinates _coords = new() { Column = -1, Row = -1 }; + public ButtonRendererImageSharp(GetPropertyDelegate getProperty) + { + _conditionEvaluator = new ConditionEvaluator( + _ncalcHandler, + getProperty, + () => _coords.ToString()); + } + public void SetCoordinates(Coordinates coordinates) { _coords = coordinates; @@ -41,11 +50,20 @@ public Image Render(StreamDeckKeyInfo targetKeyInfo, Collection image, StreamDeckKeyInfo keyInfo, Display { try { - var value = _ncalcHandler.EvaluateExpression(valueItem.NCalcPropertyHolder, getProperty, - $"({_coords}) Value of \"{valueItem.DisplayName}\""); + var value = _conditionEvaluator.Evaluate(valueItem.NCalcPropertyHolder, $"Value of \"{valueItem.DisplayName}\""); var format = _formatHelper.CompleteFormatString(valueItem.DisplayFormat); var formattedValue = string.Format(CultureInfo.CurrentCulture, format, value); RenderString(image, keyInfo, valueItem, valueItem.Font, valueItem.Color, formattedValue); @@ -153,6 +170,13 @@ private void RenderString(Image image, StreamDeckKeyInfo keyInfo, // Color + Transparency var colorWithAlpha = color.WithAlpha(displayItem.DisplayParameters.Transparency); + foreach (var modifier in displayItem.Modifiers) + { + if (modifier is ModifierColor modifierColor && _conditionEvaluator.IsModifierActive(modifier)) + { + colorWithAlpha = modifierColor.Color.WithAlpha(displayItem.DisplayParameters.Transparency); + } + } // Position + Size var position = displayItem.DisplayParameters.Position; @@ -192,15 +216,6 @@ private void RenderString(Image image, StreamDeckKeyInfo keyInfo, }); } - /// - /// Evaluates the conditions of the item. If the result is true or a positive number, the item is considered visible. - /// - private bool IsVisible(Item item) - { - return _ncalcHandler.IsConditionActive(item.NCalcConditionHolder, getProperty, - $"({_coords}) Visibility of \"{item.DisplayName}\""); - } - /// /// Scale the font size based on the key resolution. So we can use the same font size across different Stream Deck models. /// Base size is a standard Stream Deck with 72 x 72 pixels. diff --git a/StreamDeckSimHub.Plugin/PropertyLogic/NCalcHandler.cs b/StreamDeckSimHub.Plugin/PropertyLogic/NCalcHandler.cs index 610efda..044cdb6 100644 --- a/StreamDeckSimHub.Plugin/PropertyLogic/NCalcHandler.cs +++ b/StreamDeckSimHub.Plugin/PropertyLogic/NCalcHandler.cs @@ -1,4 +1,4 @@ -// Copyright (C) 2025 Martin Renner +// Copyright (C) 2026 Martin Renner // LGPL-3.0-or-later (see file COPYING and COPYING.LESSER) using NCalc; @@ -123,14 +123,14 @@ private string BuildNCalcErrorMessage(Exception e) try { var result = expression.Evaluate(); - if (_logger.IsDebugEnabled) + if (_logger.IsTraceEnabled) { var msg = $"{loggingContext}: "; msg += $"\"{expression.ExpressionString}\" => \"{result}\", "; msg += "parameters: "; msg = nCalcHolder.UsedProperties.Aggregate(msg, (current, propName) => current + $"\"{propName}\"=\"{getProperty.Invoke(propName)}\", "); - _logger.Debug(msg); + _logger.Trace(msg); } return result; @@ -142,22 +142,6 @@ private string BuildNCalcErrorMessage(Exception e) } } - /// - /// Specialized version of that evaluates if the given expression - /// (which should be a condition) is "active". - /// - public bool IsConditionActive(NCalcHolder nCalcConditionHolder, GetPropertyDelegate getProperty, string loggingContext) - { - if (nCalcConditionHolder.NCalcExpression == null) - { - _logger.Debug($"{loggingContext}: No condition set, always active."); - return true; // No condition means always active. - } - - var value = EvaluateExpression(nCalcConditionHolder, getProperty, loggingContext); - return value is true or > 0 or > 0.0f or > 0.0d; - } - /// /// Centrally creates a NCalc expression from the given string. /// diff --git a/StreamDeckSimHub.PluginTests/Actions/GenericButton/Model/Modifiers/ModifierBlinkTests.cs b/StreamDeckSimHub.PluginTests/Actions/GenericButton/Model/Modifiers/ModifierBlinkTests.cs new file mode 100644 index 0000000..933e608 --- /dev/null +++ b/StreamDeckSimHub.PluginTests/Actions/GenericButton/Model/Modifiers/ModifierBlinkTests.cs @@ -0,0 +1,77 @@ +// Copyright (C) 2026 Martin Renner +// LGPL-3.0-or-later (see file COPYING and COPYING.LESSER) + +using StreamDeckSimHub.Plugin.Actions.GenericButton.Model; +using StreamDeckSimHub.Plugin.Actions.GenericButton.Model.Modifiers; +using StreamDeckSimHub.Plugin.Actions.GenericButton.Renderer; +using static StreamDeckSimHub.PluginTests.Actions.GenericButton.RendererTestHelper; +using static StreamDeckSimHub.Plugin.Tools.StreamDeckKeyInfoBuilder; + +namespace StreamDeckSimHub.PluginTests.Actions.GenericButton.Model.Modifiers; + +public class ModifierBlinkTests +{ + private Settings _settings; + + [SetUp] + public void Setup() + { + _settings = new Settings { KeySize = DefaultKeyInfo.KeySize }; + } + + [Test] + public void BlinkFullPhase() + { + var displayItem = new DisplayItemText { Text = "X" }; + var modifierBlink = new ModifierBlink { DurationOn = 2, DurationOff = 3 }; + displayItem.Modifiers.Add(modifierBlink); + _settings.DisplayItems.Add(displayItem); + + var renderer = new ButtonRendererImageSharp(EmptyPropertyProvider); + + var activeStateChanged = modifierBlink.DetermineTransition(true); + Assert.That(activeStateChanged, Is.True, "Expected modifier active state to transition"); + + // Tick1: On phase + modifierBlink.Tick(); + var image1 = renderer.Render(DefaultKeyInfo, _settings.DisplayItems); + Assert.That(ImageHasNonBlackPixels(image1), Is.True, "Tick 1 should be visible"); + + // Tick2: On phase + modifierBlink.Tick(); + var image2 = renderer.Render(DefaultKeyInfo, _settings.DisplayItems); + Assert.That(ImageHasNonBlackPixels(image2), Is.True, "Tick 2 should be visible"); + + // Tick 3: Off phase + modifierBlink.Tick(); + var image3 = renderer.Render(DefaultKeyInfo, _settings.DisplayItems); + Assert.That(ImageHasNonBlackPixels(image3), Is.False, "Tick 3 should be invisible"); + + // Tick 4: Off phase + modifierBlink.Tick(); + var image4 = renderer.Render(DefaultKeyInfo, _settings.DisplayItems); + Assert.That(ImageHasNonBlackPixels(image4), Is.False, "Tick 4 should be invisible"); + + // Tick 5: Off phase + modifierBlink.Tick(); + var image5 = renderer.Render(DefaultKeyInfo, _settings.DisplayItems); + Assert.That(ImageHasNonBlackPixels(image5), Is.False, "Tick 5 should be invisible"); + + // Tick 1: (wrap) On phase + modifierBlink.Tick(); + var image6 = renderer.Render(DefaultKeyInfo, _settings.DisplayItems); + Assert.That(ImageHasNonBlackPixels(image6), Is.True, "Tick 6 should be visible"); + + // Tick 2: On phase + modifierBlink.Tick(); + var image7 = renderer.Render(DefaultKeyInfo, _settings.DisplayItems); + Assert.That(ImageHasNonBlackPixels(image7), Is.True, "Tick 7 should be visible"); + + // Tick 3: Off phase + modifierBlink.Tick(); + var image8 = renderer.Render(DefaultKeyInfo, _settings.DisplayItems); + Assert.That(ImageHasNonBlackPixels(image8), Is.False, "Tick 8 should be invisible"); + } + + private string? EmptyPropertyProvider(string propName) => null; +} \ No newline at end of file diff --git a/StreamDeckSimHub.PluginTests/Actions/GenericButton/RendererTestHelper.cs b/StreamDeckSimHub.PluginTests/Actions/GenericButton/RendererTestHelper.cs new file mode 100644 index 0000000..063c750 --- /dev/null +++ b/StreamDeckSimHub.PluginTests/Actions/GenericButton/RendererTestHelper.cs @@ -0,0 +1,55 @@ +// Copyright (C) 2026 Martin Renner +// LGPL-3.0-or-later (see file COPYING and COPYING.LESSER) + +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; + +namespace StreamDeckSimHub.PluginTests.Actions.GenericButton; + +public abstract class RendererTestHelper +{ + public static bool ImageHasNonBlackPixels(Image image) + { + var nonBlackFound = false; + image.ProcessPixelRows(accessor => + { + for (var y = 0; y < accessor.Height; y++) + { + var row = accessor.GetRowSpan(y); + foreach (var pixel in row) + { + // Check if pixel is not black (R, G, or B > threshold) + if (pixel.R > 10 || pixel.G > 10 || pixel.B > 10) + { + nonBlackFound = true; + return; + } + } + } + }); + + return nonBlackFound; + } + + public static int CountNonBlackPixels(Image image) + { + var count = 0; + image.ProcessPixelRows(accessor => + { + for (var y = 0; y < accessor.Height; y++) + { + var row = accessor.GetRowSpan(y); + foreach (var pixel in row) + { + // Check if pixel is not black (R, G, or B > threshold) + if (pixel.R > 10 || pixel.G > 10 || pixel.B > 10) + { + count++; + } + } + } + }); + + return count; + } +}