diff --git a/CHANGELOG.md b/CHANGELOG.md index b9f9977..72fd6a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,32 @@ Notes: --- -## v1.1.2.0 - Patch Release +## v1.2.0.0 - Minor Release + +### Features / Improvements + +- Added `Adaptive Dial` action for Stream Deck+ devices. Supports assigning separate Star Citizen functions to rotate left, rotate right, and dial push. Rotation executes the assigned function once per tick. Push respects the action's activation mode (e.g., Tap vs Hold). +- Click sound support for the `Adaptive Dial` (same `.wav`/`.mp3` configuration as `Adaptive Key`). + +### Internal / Refactor + +- `SCActionBase` and `ControlPanelKey` now inherit from `KeypadBase` instead of `KeyAndEncoderBase`, removing stub dial/touchpad overrides that were never used for key actions. +- Added explicit `Controllers` declarations (`Keypad` / `Encoder`) to all actions in `manifest.json`. +- Added unit tests for `AdaptiveDial` covering rotation resolution and executable binding resolution. +- Refactored Property Inspector function picker logic into shared helpers (`SCPI.functionPicker`) used by both key and dial scripts, reducing duplicated code. + +## v1.1.3.0 - Patch Release + +### Features / Improvements + +- Bindings that use mwheel_up/mwheel_down will be retriggered while key is held down. + +### Bug Fixes + +- Fixed an Issue where some modifier keys didn't properly execute when combined with a mouse button. +- Fixed an Issue where `Hold` ActivationModes triggered multiple times when a binding had a modifier key + +## v1.1.2.0 - Minor Release ### Features / Improvements @@ -35,7 +60,7 @@ Notes: ### Internal / Refactor - Introduced a keybindings JSON schema version to force-refresh cached keybindings when the data format changes. -- + ## v1.0.2.0 - Patch Release > [!NOTE] > This update introduces a stable v2 identifier format for saved function bindings (v2||). diff --git a/PluginCore/ActionKeys/AdaptiveDial.cs b/PluginCore/ActionKeys/AdaptiveDial.cs new file mode 100644 index 0000000..4e0c2c9 --- /dev/null +++ b/PluginCore/ActionKeys/AdaptiveDial.cs @@ -0,0 +1,201 @@ +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using BarRaider.SdTools; +using BarRaider.SdTools.Payloads; +using SCStreamDeck.Common; +using SCStreamDeck.Logging; +using SCStreamDeck.Models; + +namespace SCStreamDeck.ActionKeys; + +/// +/// Adaptive Star Citizen dial. +/// Supports separate functions for rotate left, rotate right, and dial press. +/// +[SuppressMessage("ReSharper", "UnusedType.Global", Justification = "Stream Deck action instantiated via SDK reflection")] +[PluginActionId("com.jarex985.scstreamdeck.adaptivedial")] +public sealed class AdaptiveDial(SDConnection connection, InitialPayload payload) : SCDialActionBase(connection, payload) +{ + public override async void DialRotate(DialRotatePayload payload) + { + try + { + await ProcessDialRotateAsync(payload.Ticks).ConfigureAwait(false); + } + catch (Exception ex) + { + Log.Err($"{GetType().Name}: {ex.Message}", ex); + } + } + + public override async void DialDown(DialPayload payload) + { + try + { + PlayClickSoundIfConfigured(); + await ProcessPressEventAsync(true).ConfigureAwait(false); + } + catch (Exception ex) + { + Log.Err($"{GetType().Name}: {ex.Message}", ex); + } + } + + public override async void DialUp(DialPayload payload) + { + try + { + await ProcessPressEventAsync(false).ConfigureAwait(false); + } + catch (Exception ex) + { + Log.Err($"{GetType().Name}: {ex.Message}", ex); + } + } + + public override async void TouchPress(TouchpadPressPayload payload) + { + try + { + // SDK versions may differ on TouchpadPressPayload shape; read pressed-state defensively. + bool isKeyDown = true; + object? pressedValue = payload.GetType().GetProperty("Pressed")?.GetValue(payload); + if (pressedValue is bool pressed) + { + isKeyDown = pressed; + } + + if (isKeyDown) + { + PlayClickSoundIfConfigured(); + } + + await ProcessPressEventAsync(isKeyDown).ConfigureAwait(false); + } + catch (Exception ex) + { + Log.Err($"{GetType().Name}: {ex.Message}", ex); + } + } + + private async Task ProcessDialRotateAsync(int ticks) + { + if (ticks == 0) + { + return; + } + + string? actionId = ResolveRotationFunction(Settings, ticks); + (KeybindingAction, string)? validationResult = ValidateAndResolve(actionId); + if (validationResult == null || string.IsNullOrWhiteSpace(actionId)) + { + return; + } + + int rotationSteps = Math.Abs(ticks); + string executableBinding = validationResult.Value.Item2; + + for (int i = 0; i < rotationSteps; i++) + { + bool success = await KeybindingService.ExecutePressNoRepeatAsync(actionId, executableBinding) + .ConfigureAwait(false); + if (success) + { + LogRotateExec(actionId, ticks, executableBinding); + } + } + } + + private async Task ProcessPressEventAsync(bool isKeyDown) + { + (KeybindingAction, string)? validationResult = ValidateAndResolve(Settings.PressFunction); + if (validationResult == null || string.IsNullOrWhiteSpace(Settings.PressFunction)) + { + return; + } + + (KeybindingAction action, string executableBinding) = validationResult.Value; + + KeybindingExecutionContext context = new() + { + ActionName = Settings.PressFunction, + Binding = executableBinding, + ActivationMode = action.ActivationMode, + IsKeyDown = isKeyDown + }; + + await ExecuteKeybindingAsync(context).ConfigureAwait(false); + } + + internal static string? ResolveRotationFunction(Settings.DialSettings settings, int ticks) + { + ArgumentNullException.ThrowIfNull(settings); + + return ticks switch + { + > 0 => settings.RotateRightFunction, + < 0 => settings.RotateLeftFunction, + _ => null + }; + } + + [ExcludeFromCodeCoverage] + private (KeybindingAction, string)? ValidateAndResolve(string? actionId) + { + if (string.IsNullOrWhiteSpace(actionId) || !CanExecuteBindings) + { + return null; + } + + if (!KeybindingService.TryGetAction(actionId, out KeybindingAction? action) || action == null) + { + return null; + } + + string? executableBinding = ResolveExecutableBinding(action); + return executableBinding == null ? null : (action, executableBinding); + } + + internal static string? ResolveExecutableBinding(KeybindingAction action) + { + ArgumentNullException.ThrowIfNull(action); + + if (!string.IsNullOrWhiteSpace(action.KeyboardBinding)) + { + return action.KeyboardBinding; + } + + if (string.IsNullOrWhiteSpace(action.MouseBinding)) + { + return null; + } + + InputType bindingType = action.MouseBinding.GetInputType(); + return bindingType is InputType.MouseButton or InputType.MouseWheel ? action.MouseBinding : null; + } + + private async Task ExecuteKeybindingAsync(KeybindingExecutionContext context) + { + try + { + bool success = await KeybindingService.ExecuteAsync(context).ConfigureAwait(false); + if (success) + { + LogPressExec(context); + } + } + catch (Exception ex) + { + Log.Err($"{GetType().Name}: '{context.ActionName}': {ex.Message}", ex); + } + } + + [Conditional("DEBUG")] + private void LogPressExec(KeybindingExecutionContext context) => + Log.Debug( + $"{GetType().Name}: {(context.IsKeyDown ? "pressed" : "released")} '{context.ActionName}' ({context.ActivationMode}) → '{context.Binding}'"); + + [Conditional("DEBUG")] + private static void LogRotateExec(string actionName, int ticks, string binding) => + Log.Debug($"{nameof(AdaptiveDial)}: rotated '{actionName}' ticks={ticks} → '{binding}'"); +} diff --git a/PluginCore/ActionKeys/ControlPanelKey.cs b/PluginCore/ActionKeys/ControlPanelKey.cs index a7084be..b919c87 100644 --- a/PluginCore/ActionKeys/ControlPanelKey.cs +++ b/PluginCore/ActionKeys/ControlPanelKey.cs @@ -18,7 +18,7 @@ namespace SCStreamDeck.ActionKeys; /// [SuppressMessage("ReSharper", "UnusedType.Global", Justification = "Stream Deck action instantiated via SDK reflection")] [PluginActionId("com.jarex985.scstreamdeck.controlpanel")] -public sealed class ControlPanelKey : KeyAndEncoderBase +public sealed class ControlPanelKey : KeypadBase { private const string PiEventConnected = "propertyInspectorConnected"; private const string PiEventSetTheme = "setTheme"; @@ -67,22 +67,6 @@ public override void OnTick() { } - public override void DialRotate(DialRotatePayload payload) - { - } - - public override void DialDown(DialPayload payload) - { - } - - public override void DialUp(DialPayload payload) - { - } - - public override void TouchPress(TouchpadPressPayload payload) - { - } - public override void Dispose() { Connection.OnPropertyInspectorDidAppear -= OnPropertyInspectorDidAppear; diff --git a/PluginCore/ActionKeys/SCActionBase.cs b/PluginCore/ActionKeys/SCActionBase.cs index fdc7133..3a8ad8d 100644 --- a/PluginCore/ActionKeys/SCActionBase.cs +++ b/PluginCore/ActionKeys/SCActionBase.cs @@ -16,9 +16,9 @@ namespace SCStreamDeck.ActionKeys; /// -/// Base class for Star Citizen Stream Deck Keys and Dials. +/// Base class for Star Citizen Stream Deck key actions. /// -public abstract class SCActionBase : KeyAndEncoderBase +public abstract class SCActionBase : KeypadBase { #region Constructor and Initialization @@ -269,36 +269,4 @@ public override void OnTick() } #endregion - - #region Dial and Touchpad Methods - - /// - /// Called when the dial is rotated. Not used for keys. - /// - public override void DialRotate(DialRotatePayload payload) - { - } - - /// - /// Called when the dial is pressed down. Not used for keys. - /// - public override void DialDown(DialPayload payload) - { - } - - /// - /// Called when the dial is released. Not used for keys. - /// - public override void DialUp(DialPayload payload) - { - } - - /// - /// Called when the touchpad is pressed. Not used for keys. - /// - public override void TouchPress(TouchpadPressPayload payload) - { - } - - #endregion } diff --git a/PluginCore/ActionKeys/SCDialActionBase.cs b/PluginCore/ActionKeys/SCDialActionBase.cs new file mode 100644 index 0000000..a8e4d7a --- /dev/null +++ b/PluginCore/ActionKeys/SCDialActionBase.cs @@ -0,0 +1,227 @@ +using System.Diagnostics.CodeAnalysis; +using BarRaider.SdTools; +using BarRaider.SdTools.Events; +using BarRaider.SdTools.Payloads; +using BarRaider.SdTools.Wrappers; +using Newtonsoft.Json.Linq; +using SCStreamDeck.ActionKeys.Settings; +using SCStreamDeck.Common; +using SCStreamDeck.Infrastructure; +using SCStreamDeck.Logging; +using SCStreamDeck.Models; +using SCStreamDeck.Services.Audio; +using SCStreamDeck.Services.Core; +using SCStreamDeck.Services.Keybinding; + +namespace SCStreamDeck.ActionKeys; + +/// +/// Base class for Star Citizen Stream Deck Plus dial actions. +/// +public abstract class SCDialActionBase : EncoderBase +{ + [ExcludeFromCodeCoverage] + protected SCDialActionBase(SDConnection connection, InitialPayload payload) : base(connection, payload) + { + ArgumentNullException.ThrowIfNull(payload); + + Settings = payload.Settings is { Count: > 0 } + ? payload.Settings.ToObject() ?? new DialSettings() + : new DialSettings(); + + SCActionBaseDependencies deps = ActionDependencies.ForSCActionBase(); + InitializationService = deps.InitializationService; + KeybindingService = deps.KeybindingService; + AudioPlayerService = deps.AudioPlayerService; + + InitializationService.KeybindingsStateChanged += OnKeybindingsStateChanged; + Connection.OnPropertyInspectorDidAppear += OnPropertyInspectorDidAppear; + Connection.OnSendToPlugin += OnSendToPlugin; + + TryMigrateFunctionSettingsIfPossible(); + + if (CanExecuteBindings) + { + SendPropertyInspectorUpdate(); + } + } + + private InitializationService InitializationService { get; } + private AudioPlayerService AudioPlayerService { get; } + protected KeybindingService KeybindingService { get; } + + protected bool CanExecuteBindings => + InitializationService.IsInitialized && + KeybindingService.IsLoaded && + InitializationService.KeybindingsJsonExists(); + + protected DialSettings Settings { get; private set; } + + protected void PlayClickSoundIfConfigured() + { + if (string.IsNullOrWhiteSpace(Settings.ClickSoundPath)) + { + return; + } + + if (!File.Exists(Settings.ClickSoundPath)) + { + Settings.ClickSoundPath = null; + return; + } + + try + { + AudioPlayerService.Play(Settings.ClickSoundPath); + } + catch (Exception ex) + { + Log.Err($"[{GetType().Name}] Audio playback error: {ex.Message}", ex); + } + } + + public override void TouchPress(TouchpadPressPayload payload) + { + } + + public override void ReceivedGlobalSettings(ReceivedGlobalSettingsPayload payload) + { + } + + public override void OnTick() + { + } + + public override void Dispose() + { + Connection.OnPropertyInspectorDidAppear -= OnPropertyInspectorDidAppear; + Connection.OnSendToPlugin -= OnSendToPlugin; + InitializationService.KeybindingsStateChanged -= OnKeybindingsStateChanged; + GC.SuppressFinalize(this); + } + + [ExcludeFromCodeCoverage] + public override void ReceivedSettings(ReceivedSettingsPayload payload) + { + ArgumentNullException.ThrowIfNull(payload); + + if (payload.Settings is { Count: > 0 }) + { + Settings = payload.Settings.ToObject() ?? Settings; + } + + TryMigrateFunctionSettingsIfPossible(); + } + + private void SendPropertyInspectorUpdate() + { + try + { + if (!InitializationService.KeybindingsJsonExists() || !KeybindingService.IsLoaded) + { + Connection.SendToPropertyInspectorAsync(new JObject + { + ["functionsLoaded"] = false, + ["functions"] = new JArray() + }); + return; + } + + IReadOnlyList allActions = KeybindingService.GetAllActions(); + IntPtr hkl = KeyboardLayoutDetector.DetectCurrent().Hkl; + JArray groups = FunctionsPayloadBuilder.BuildGroupedFunctionsPayload(allActions, hkl); + + Connection.SendToPropertyInspectorAsync(new JObject { ["functionsLoaded"] = true, ["functions"] = groups }); + } + catch (Exception ex) + { + Log.Err($"[{GetType().Name}]: {ex.Message}", ex); + Connection.SendToPropertyInspectorAsync(new JObject { ["functionsLoaded"] = false, ["functions"] = new JArray() }); + } + } + + private void OnPropertyInspectorDidAppear(object? sender, SDEventReceivedEventArgs e) => + SendPropertyInspectorUpdate(); + + private void OnKeybindingsStateChanged() + { + TryMigrateFunctionSettingsIfPossible(); + SendPropertyInspectorUpdate(); + } + + private void TryMigrateFunctionSettingsIfPossible() + { + if (!CanExecuteBindings) + { + return; + } + + bool changed = false; + changed |= TryNormalizeFunctionSetting(Settings.RotateLeftFunction, value => Settings.RotateLeftFunction = value); + changed |= TryNormalizeFunctionSetting(Settings.RotateRightFunction, value => Settings.RotateRightFunction = value); + changed |= TryNormalizeFunctionSetting(Settings.PressFunction, value => Settings.PressFunction = value); + + if (!changed) + { + return; + } + + try + { + _ = Connection.SetSettingsAsync(JObject.FromObject(Settings)); + } + catch (Exception ex) + { + Log.Err($"[{GetType().Name}] Failed to persist migrated dial function ids: {ex.Message}", ex); + } + } + + private bool TryNormalizeFunctionSetting(string? currentValue, Action setValue) + { + if (string.IsNullOrWhiteSpace(currentValue)) + { + return false; + } + + if (!KeybindingService.TryNormalizeActionId(currentValue, out string normalizedId) || + string.IsNullOrWhiteSpace(normalizedId) || + string.Equals(currentValue, normalizedId, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + setValue(normalizedId); + return true; + } + + [ExcludeFromCodeCoverage] + private void OnSendToPlugin(object? sender, SDEventReceivedEventArgs e) + { + try + { + if (e.Event?.Payload == null) + { + return; + } + + string? piEvent = null; + if (e.Event.Payload.TryGetValue("event", out JToken? eventToken)) + { + piEvent = eventToken.ToString(); + } + else if (e.Event.Payload.TryGetValue("property_inspector", out JToken? legacyToken)) + { + piEvent = legacyToken.ToString(); + } + + if (piEvent == "propertyInspectorConnected") + { + SendPropertyInspectorUpdate(); + } + } + catch (Exception ex) + { + Log.Err($"[{GetType().Name}] PI message handling error: {ex.Message}", ex); + } + } +} diff --git a/PluginCore/ActionKeys/Settings/DialSettings.cs b/PluginCore/ActionKeys/Settings/DialSettings.cs new file mode 100644 index 0000000..2bdc6e4 --- /dev/null +++ b/PluginCore/ActionKeys/Settings/DialSettings.cs @@ -0,0 +1,21 @@ +using Newtonsoft.Json; + +namespace SCStreamDeck.ActionKeys.Settings; + +/// +/// Settings schema for Stream Deck Plus dial actions. +/// +public sealed class DialSettings +{ + [JsonProperty(PropertyName = "rotateLeftFunction")] + public string? RotateLeftFunction { get; set; } + + [JsonProperty(PropertyName = "rotateRightFunction")] + public string? RotateRightFunction { get; set; } + + [JsonProperty(PropertyName = "pressFunction")] + public string? PressFunction { get; set; } + + [JsonProperty(PropertyName = "clickSoundPath")] + public string? ClickSoundPath { get; set; } +} diff --git a/PluginCore/PropertyInspector/Dials/AdaptiveDial.html b/PluginCore/PropertyInspector/Dials/AdaptiveDial.html new file mode 100644 index 0000000..624270b --- /dev/null +++ b/PluginCore/PropertyInspector/Dials/AdaptiveDial.html @@ -0,0 +1,111 @@ + + + + + + Star Citizen Stream Deck Plugin PI - Adaptive Dial + + + + + + + + +
+
+
+
+
Rotate Left
+
+

Executed once for each tick when the dial rotates left.

+
+
+
+ KEYBOARD + +
+
+ MOUSE + +
+
+ GAMEPAD + +
+
+ JOYSTICK + +
+
+
+
+ +
+
Rotate Right
+
+

Executed once for each tick when the dial rotates right.

+
+
+
+ KEYBOARD + +
+
+ MOUSE + +
+
+ GAMEPAD + +
+
+ JOYSTICK + +
+
+
+
+ +
+
Push
+
+

Uses the action activation mode while the dial is pressed and released.

+
+
+
+ KEYBOARD + +
+
+ MOUSE + +
+
+ GAMEPAD + +
+
+ JOYSTICK + +
+
+
+
+
+ +
+
+
Sound
+
+
+
+
+
+ + diff --git a/PluginCore/PropertyInspector/css/base.css b/PluginCore/PropertyInspector/css/base.css index d7656b9..299f6b3 100644 --- a/PluginCore/PropertyInspector/css/base.css +++ b/PluginCore/PropertyInspector/css/base.css @@ -1,2 +1,2 @@ /* generated: base.css */ -:root{--space-xs:4px;--space-sm:8px;--space-md:12px;--space-lg:16px;--space-xl:20px;--radius-sm:4px;--radius-md:6px;--radius-lg:8px;--frame-corner-size:12px;--frame-corner-size-sm:8px;--frame-corner-thickness:2px;--frame-corner-inset:2px;--dropdown-height:36px;--section-title-min-height:36px;--transition-fast:.15s;--transition-normal:.3s;--ease-out:cubic-bezier(.25,.46,.45,.94);--font-size-sm:12px;--font-size-md:14px;--theme-accent-rgb:94,195,202;--theme-accent-hi-rgb:154,230,236;--theme-surface-0-rgb:18,18,18;--theme-surface-1-rgb:15,15,15;--theme-surface-2-rgb:33,33,33;--theme-border-rgb:42,42,42;--color-bg-elgato:rgba(45,45,45,1);--color-bg-darker:rgba(var(--theme-surface-0-rgb),1);--color-bg-card:rgba(var(--theme-surface-1-rgb),1);--color-bg-elevated:rgba(var(--theme-surface-2-rgb),1);--color-border:rgba(var(--theme-border-rgb),1);--color-primary:rgba(var(--theme-accent-rgb),1);--color-primary-light:rgba(var(--theme-accent-hi-rgb),1);--color-primary-opacity-20:rgba(var(--theme-accent-hi-rgb),.2);--color-primary-opacity-30:rgba(var(--theme-accent-hi-rgb),.3);--color-primary-glow:rgba(var(--theme-accent-rgb),.3);--color-text-primary:rgba(255,255,255,1);--color-text-secondary:rgba(204,204,204,1);--color-text-tertiary:rgba(153,153,153,1);--color-error:rgba(244,67,54,1);--color-warning:rgba(255,193,7,1);--color-warning-glow:rgba(255,193,7,.35);--color-success:rgba(76,175,80,1);--color-success-glow:rgba(76,175,80,.55);--color-success-opacity-20:rgba(76,175,80,.2);--color-error-opacity-40:rgba(255,0,0,.4);--color-error-opacity-20:rgba(255,0,0,.2);--color-error-opacity-70:rgba(255,0,0,.7);--color-error-opacity-90:rgba(255,0,0,.9);--color-dark-opacity-40:rgba(0,0,0,.4);--color-dark-opacity-60:rgba(0,0,0,.6);--color-white-opacity-10:rgba(255,255,255,.1);--color-white-opacity-70:rgba(255,255,255,.7);--color-panel-glow:rgba(var(--theme-accent-rgb),.05);--color-inset-top:rgba(255,255,255,.02);--color-inset-bottom:rgba(0,0,0,.3);--color-glow-hover:rgba(var(--theme-accent-rgb),.4);--color-interactive-glow-hover:rgba(var(--theme-accent-hi-rgb),.85);--color-interactive-glow-active:rgba(var(--theme-accent-hi-rgb),.45)}@keyframes glow-pulse{0%,100%{opacity:1}50%{opacity:.7}}@keyframes focus-glow{0%{box-shadow:0 0 0 0 var(--color-glow-hover)}50%{box-shadow:0 0 8px 2px var(--color-glow-hover)}100%{box-shadow:0 0 4px 1px var(--color-primary-glow)}}@keyframes pi-spin{to{transform:rotate(360deg)}}@keyframes pi-loading-breathe{0%,100%{opacity:.55}50%{opacity:.95}}@keyframes pi-success-blink{0%{opacity:.15}35%{opacity:1}100%{opacity:.15}}@keyframes pi-dot{0%{opacity:.25;transform:translateY(0)}40%{opacity:1;transform:translateY(-1px)}80%{opacity:.25;transform:translateY(0)}100%{opacity:.25;transform:translateY(0)}}*,*::before,*::after{box-sizing:border-box;font-family:'Segoe UI',Tahoma,Geneva,Verdana,sans-serif}body{display:flex;justify-content:center;align-items:center;padding:var(--space-xs);margin:2px;background:var(--color-bg-elgato);color:var(--color-text-primary);overflow-x:hidden}.pi-container{width:100%;max-width:600px;position:relative;z-index:1}.pi-panel,.pi-section{position:relative;background-color:var(--color-bg-darker);background-image:linear-gradient(135deg,var(--color-primary-opacity-20) 0,var(--color-primary-opacity-20) var(--frame-corner-thickness),transparent var(--frame-corner-thickness)),linear-gradient(225deg,var(--color-primary-opacity-20) 0,var(--color-primary-opacity-20) var(--frame-corner-thickness),transparent var(--frame-corner-thickness)),linear-gradient(45deg,var(--color-primary-opacity-20) 0,var(--color-primary-opacity-20) var(--frame-corner-thickness),transparent var(--frame-corner-thickness)),linear-gradient(-45deg,var(--color-primary-opacity-20) 0,var(--color-primary-opacity-20) var(--frame-corner-thickness),transparent var(--frame-corner-thickness)),linear-gradient(180deg,transparent 0%,var(--color-inset-bottom) 100%);background-repeat:no-repeat;background-size:var(--frame-corner-size) var(--frame-corner-size),var(--frame-corner-size) var(--frame-corner-size),var(--frame-corner-size) var(--frame-corner-size),var(--frame-corner-size) var(--frame-corner-size),100% 100%;background-position:top left,top right,bottom left,bottom right,center center;border:1px solid var(--color-border);border-radius:var(--radius-md);box-shadow:0 0 6px var(--color-primary-glow),inset 0 0 0 1px var(--color-border),inset 0 2px 4px var(--color-dark-opacity-40)}.pi-panel::before,.pi-section::before,.pi-section__header::before{content:'';position:absolute;top:0;left:0;right:0;height:1px;background:linear-gradient(90deg,transparent,var(--color-primary),transparent);opacity:.5}.pi-panel{padding:var(--space-xl) var(--space-sm);overflow:hidden;background-image:linear-gradient(135deg,var(--color-primary-opacity-30) 0,var(--color-primary-opacity-30) var(--frame-corner-thickness),transparent var(--frame-corner-thickness)),linear-gradient(225deg,var(--color-primary-opacity-30) 0,var(--color-primary-opacity-30) var(--frame-corner-thickness),transparent var(--frame-corner-thickness)),linear-gradient(45deg,var(--color-primary-opacity-30) 0,var(--color-primary-opacity-30) var(--frame-corner-thickness),transparent var(--frame-corner-thickness)),linear-gradient(-45deg,var(--color-primary-opacity-30) 0,var(--color-primary-opacity-30) var(--frame-corner-thickness),transparent var(--frame-corner-thickness)),radial-gradient(ellipse at 50% 0%,var(--color-panel-glow) 0%,transparent 60%),linear-gradient(180deg,var(--color-inset-top) 0%,transparent 20%,transparent 80%,var(--color-inset-bottom) 100%);background-size:var(--frame-corner-size) var(--frame-corner-size),var(--frame-corner-size) var(--frame-corner-size),var(--frame-corner-size) var(--frame-corner-size),var(--frame-corner-size) var(--frame-corner-size),100% 100%,100% 100%;background-position:top left,top right,bottom left,bottom right,center top,center center;border-radius:var(--radius-lg);box-shadow:0 0 10px var(--color-primary-glow),inset 0 0 0 1px var(--color-border)}.pi-panel::before{height:2px}.pi-panel::after{content:'';position:absolute;bottom:0;left:0;right:0;height:2px;background:linear-gradient(90deg,transparent,var(--color-primary),transparent);opacity:.5}.pi-section{padding:var(--space-lg);margin-top:var(--space-lg)}.pi-page-title{display:flex;font-weight:bold;text-transform:uppercase;letter-spacing:1px;align-content:center;justify-content:center;margin-bottom:var(--space-lg);padding-bottom:var(--space-md);color:var(--color-primary);border-bottom:1px solid var(--color-border)}.pi-dropdown__toggle,.file-picker-button,.file-picker-clear,.pi-button{cursor:pointer;position:relative;background:var(--color-bg-darker);border:1px solid var(--color-border);color:var(--color-primary);box-shadow:0 0 6px var(--color-primary-glow),inset 0 0 0 1px var(--color-border);transition:border-color var(--transition-normal) ease,box-shadow var(--transition-normal) ease,background-color var(--transition-normal) ease,transform var(--transition-fast) var(--ease-out)}.pi-dropdown__toggle::before,.file-picker-button::before,.file-picker-clear::before,.pi-button::before{content:'';position:absolute;top:0;left:0;right:0;bottom:0;pointer-events:none;border:1px solid var(--color-primary);border-radius:inherit;opacity:0;transition:opacity var(--transition-normal) ease}.pi-dropdown__toggle:hover,.pi-dropdown__toggle:focus-visible,.file-picker-button:hover,.file-picker-button:focus-visible,.file-picker-clear:hover,.file-picker-clear:focus-visible,.pi-button:hover,.pi-button:focus-visible{background:var(--color-bg-darker);border-color:var(--color-primary);color:var(--color-primary-light);box-shadow:0 0 10px var(--color-interactive-glow-hover)}.pi-dropdown__toggle:hover::before,.pi-dropdown__toggle:focus-visible::before,.file-picker-button:hover::before,.file-picker-button:focus-visible::before,.file-picker-clear:hover::before,.file-picker-clear:focus-visible::before,.pi-button:hover::before,.pi-button:focus-visible::before{opacity:1}.pi-dropdown__toggle:focus-visible,.file-picker-button:focus-visible,.file-picker-clear:focus-visible,.pi-button:focus-visible{outline:1px solid var(--color-primary-light);outline-offset:1px;animation:focus-glow .4s var(--ease-out) forwards}.pi-dropdown__toggle:active,.file-picker-button:active,.file-picker-clear:active,.pi-button:active{transform:scale(.98);box-shadow:0 0 4px var(--color-interactive-glow-active)}.pi-section__header,.pi-details__binding{position:relative;justify-content:center;font-weight:bold;background:linear-gradient(180deg,var(--color-bg-card) 0%,var(--color-bg-darker) 100%);border:1px solid var(--color-border);box-shadow:inset 0 1px 0 var(--color-white-opacity-10);max-height:32px}.pi-dropdown__input-wrapper::after,.pi-dropdown__toggle::after,.file-picker-button::after,.file-picker-clear::after,.pi-button::after{content:'';position:absolute;inset:var(--frame-corner-inset);pointer-events:none;border-radius:inherit;background-image:linear-gradient(135deg,var(--color-primary-opacity-20) 0,var(--color-primary-opacity-20) var(--frame-corner-thickness),transparent var(--frame-corner-thickness)),linear-gradient(225deg,var(--color-primary-opacity-20) 0,var(--color-primary-opacity-20) var(--frame-corner-thickness),transparent var(--frame-corner-thickness)),linear-gradient(45deg,var(--color-primary-opacity-20) 0,var(--color-primary-opacity-20) var(--frame-corner-thickness),transparent var(--frame-corner-thickness)),linear-gradient(-45deg,var(--color-primary-opacity-20) 0,var(--color-primary-opacity-20) var(--frame-corner-thickness),transparent var(--frame-corner-thickness));background-repeat:no-repeat;background-size:var(--frame-corner-size-sm) var(--frame-corner-size-sm);background-position:top left,top right,bottom left,bottom right;opacity:.6;transition:opacity var(--transition-normal) ease}.pi-dropdown__input-wrapper:hover::after,.pi-dropdown__input-wrapper:focus-within::after,.pi-dropdown__toggle:hover::after,.pi-dropdown__toggle:focus-visible::after,.file-picker-button:hover::after,.file-picker-button:focus-visible::after,.file-picker-clear:hover::after,.file-picker-clear:focus-visible::after,.pi-button:hover::after,.pi-button:focus-visible::after{opacity:1;animation:glow-pulse 1.5s ease-in-out infinite}.pi-button{display:inline-flex;align-items:center;justify-content:center;gap:var(--space-sm);padding:var(--space-sm) var(--space-md);border-radius:var(--radius-sm);font-size:var(--font-size-sm);letter-spacing:.6px;text-transform:uppercase;user-select:none}.pi-button-row{display:flex;gap:var(--space-sm);margin-top:0}.pi-button-row>.pi-button{flex:1 1 0}.pi-inline-error{margin-top:var(--space-sm);font-size:var(--font-size-sm);color:var(--color-error);white-space:normal;word-break:break-word}.pi-inline-banner{margin-top:var(--space-sm);padding:var(--space-sm) var(--space-md);display:flex;align-items:center;justify-content:center;text-align:center;background:linear-gradient(180deg,var(--color-bg-card) 0%,var(--color-bg-darker) 100%);border:1px solid var(--color-border);border-radius:var(--radius-md);box-shadow:inset 0 1px 0 var(--color-white-opacity-10)}.pi-inline-banner .pi-inline-error{margin-top:0;text-align:center}.pi-button--danger{border-color:var(--color-error-opacity-40);color:var(--color-error-opacity-70)}.pi-button--danger::before{border-color:var(--color-error-opacity-90)}.pi-button--danger::after{background-image:linear-gradient(135deg,var(--color-error-opacity-20) 0,var(--color-error-opacity-20) var(--frame-corner-thickness),transparent var(--frame-corner-thickness)),linear-gradient(225deg,var(--color-error-opacity-20) 0,var(--color-error-opacity-20) var(--frame-corner-thickness),transparent var(--frame-corner-thickness)),linear-gradient(45deg,var(--color-error-opacity-20) 0,var(--color-error-opacity-20) var(--frame-corner-thickness),transparent var(--frame-corner-thickness)),linear-gradient(-45deg,var(--color-error-opacity-20) 0,var(--color-error-opacity-20) var(--frame-corner-thickness),transparent var(--frame-corner-thickness))}.pi-button--danger:hover,.pi-button--danger:focus-visible{border-color:var(--color-error-opacity-90);color:var(--color-error-opacity-90);box-shadow:0 0 10px var(--color-error-opacity-90)}.pi-picker-grid{display:flex;flex-direction:column;gap:var(--space-md);margin-top:var(--space-md)}.pi-picker-grid--top{gap:var(--space-xl)}.pi-section>.pi-picker-grid:first-child{margin-top:0}.pi-picker-row{display:flex;align-items:center;gap:var(--space-sm);width:100%;min-width:0;flex-wrap:nowrap}.pi-picker-label{min-width:60px;font-size:var(--font-size-sm);color:var(--color-text-tertiary);letter-spacing:.6px;font-weight:700;white-space:nowrap}.pi-picker-row .pi-dropdown{flex:1 1 auto;min-width:0;margin-bottom:0}.pi-picker-row .pi-file-picker{flex:1 1 auto;min-width:0}.pi-dropdown{position:relative;margin-bottom:var(--space-lg)}.pi-dropdown__input-row{display:flex;width:100%;gap:var(--space-xs);height:var(--dropdown-height)}.pi-dropdown__input-wrapper{flex:1;height:100%;padding:0;display:flex;align-items:center;justify-content:flex-start;position:relative;background-color:var(--color-bg-darker);border:1px solid var(--color-border);border-radius:var(--radius-md);transition:border-color var(--transition-normal) ease,box-shadow var(--transition-normal) ease,background-color var(--transition-normal) ease;box-shadow:0 0 8px var(--color-primary-glow),inset 0 0 0 1px var(--color-border)}.pi-spinner{width:12px;height:12px;display:inline-block;border-radius:50%;border:2px solid var(--color-primary-opacity-20);border-top-color:var(--color-primary);box-shadow:0 0 6px var(--color-primary-glow);animation:pi-spin .75s linear infinite}.pi-dropdown__loading{position:absolute;inset:0;display:none;align-items:center;gap:var(--space-sm);padding:0 var(--space-md);color:var(--color-text-tertiary);pointer-events:none}.pi-dropdown__loading-label{font-size:var(--font-size-md)}.pi-dropdown__loading-dots{display:inline-flex;align-items:center}.pi-dropdown__loading-dot{display:inline-block;min-width:.35em;opacity:.25;animation:pi-dot 1.2s ease-in-out infinite}.pi-dropdown__loading-dot:nth-child(2){animation-delay:.15s}.pi-dropdown__loading-dot:nth-child(3){animation-delay:.3s}.pi-dropdown--loading .pi-dropdown__loading{display:flex;animation:pi-loading-breathe 1.4s ease-in-out infinite}.pi-dropdown--error.pi-dropdown--loading .pi-dropdown__loading{color:var(--color-error);animation:none}.pi-dropdown--error.pi-dropdown--loading .pi-dropdown__loading-dots,.pi-dropdown--error.pi-dropdown--loading .pi-spinner{display:none}.pi-dropdown--success .pi-dropdown__loading{display:flex;color:var(--color-success);animation:pi-success-blink .22s ease-out 1}.pi-dropdown--loading .pi-dropdown__input-wrapper input,.pi-dropdown--success .pi-dropdown__input-wrapper input{opacity:0;pointer-events:none}.pi-dropdown--loading .pi-dropdown__toggle,.pi-dropdown--success .pi-dropdown__toggle{opacity:.65;pointer-events:none}.pi-dropdown__input-wrapper::before{content:'';position:absolute;top:0;left:0;right:0;bottom:0;pointer-events:none;border:1px solid var(--color-primary);border-radius:var(--radius-md);opacity:0;transition:opacity var(--transition-normal) ease}.pi-dropdown__input-wrapper:hover,.pi-dropdown__input-wrapper:focus-within{border-color:var(--color-primary);box-shadow:0 0 12px var(--color-glow-hover),inset 0 0 0 1px var(--color-primary-opacity-20)}.pi-dropdown__input-wrapper:focus-within{outline:1px solid var(--color-primary-light);outline-offset:1px;animation:focus-glow .4s var(--ease-out) forwards}.pi-dropdown__input-wrapper:hover::before,.pi-dropdown__input-wrapper:focus-within::before{opacity:1}.pi-dropdown__input-wrapper input{font-size:var(--font-size-md);font-family:inherit;width:100%;padding:0 var(--space-md);margin:0;outline:0;background:transparent;border:0;color:var(--color-text-primary)}.pi-dropdown__input-wrapper input::placeholder{color:var(--color-text-tertiary);background:transparent}.pi-dropdown__toggle{height:100%;padding:var(--space-sm);display:flex;align-items:center;justify-content:center;flex-shrink:0;border-radius:var(--radius-md);box-shadow:0 0 8px var(--color-primary-glow),inset 0 0 0 1px var(--color-border)}.pi-dropdown__arrow{transition:transform var(--transition-normal) var(--ease-out)}.pi-dropdown__arrow--open{transform:rotate(180deg)}.pi-dropdown__menu{position:absolute;top:100%;left:0;right:0;margin-top:var(--space-xs);max-height:300px;overflow-y:auto;overflow-x:hidden;z-index:1000;display:none;width:100%;background:linear-gradient(180deg,var(--color-bg-elevated) 0%,var(--color-bg-card) 100%);border:1px solid var(--color-border);border-radius:0 0 var(--radius-md) var(--radius-md);box-shadow:0 4px 12px var(--color-dark-opacity-60),0 0 8px var(--color-primary-glow)}.pi-dropdown__menu::-webkit-scrollbar{width:4px}.pi-dropdown__menu::-webkit-scrollbar-track{background:var(--color-border)}.pi-dropdown__menu::-webkit-scrollbar-thumb{background:var(--color-primary);border-radius:2px}.pi-dropdown__menu::-webkit-scrollbar-thumb:hover{background:var(--color-primary-light)}.pi-dropdown--open .pi-dropdown__menu{display:block}.pi-dropdown__empty-state{font-size:var(--font-size-sm);text-align:center;padding:var(--space-xs);color:var(--color-text-tertiary)}.pi-dropdown__group-header{padding:var(--space-sm) var(--space-lg);font-weight:bold;font-size:var(--font-size-md);background:repeating-linear-gradient(var(--color-bg-darker),var(--color-primary-glow) 2px);border-bottom:1px solid var(--color-border);color:var(--color-primary)}.pi-dropdown__option{padding:10px var(--space-lg);cursor:pointer;display:flex;align-items:center;width:100%;transition:background-color var(--transition-fast) ease,border-color var(--transition-fast) ease,box-shadow var(--transition-fast) ease;border-top:1px solid var(--color-primary-glow);border-left:var(--space-sm) solid transparent}.pi-dropdown__option:hover{background:var(--color-primary-glow);border-left-color:var(--color-primary);box-shadow:-5px 0 10px var(--color-primary-glow)}.pi-dropdown__option.disabled{cursor:not-allowed;opacity:.5}.pi-dropdown__option-label{flex:1}.pi-dropdown__option-badge{display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;border-radius:999px;font-size:12px;font-weight:800;margin-left:var(--space-sm);user-select:none}.pi-dropdown__option-badge--warn{color:rgba(0,0,0,.95);background:var(--color-warning);box-shadow:0 0 10px var(--color-warning-glow)}.pi-section__header{padding:var(--space-sm);margin-bottom:var(--space-lg);font-size:var(--font-size-md);display:flex;align-items:center;min-height:var(--section-title-min-height);border-radius:var(--radius-md);color:var(--color-text-primary);text-shadow:0 0 10px var(--color-primary-glow)}.pi-details__title{word-wrap:break-word;word-break:break-word;white-space:normal}.pi-details__title.pi-content-box{display:flex;align-items:center;justify-content:center;min-height:var(--section-title-min-height);font-size:var(--font-size-md);font-weight:bold;color:var(--color-primary);margin-bottom:var(--space-lg)}.pi-details__binding{display:grid;grid-template-columns:auto 1fr;align-items:center;font-size:var(--font-size-md);margin:var(--space-xs);padding:var(--space-xs);gap:var(--space-md);background:linear-gradient(180deg,var(--color-bg-darker) 0%,var(--color-bg-card) 100%);border-radius:var(--radius-sm);border-top:1px solid var(--color-border);border-bottom:1px solid var(--color-border)}.pi-details__binding-label{font-weight:bold;text-align:left;width:120px;padding:0 0 0 8px;color:var(--color-text-secondary);border-right:4px solid var(--color-border);border-left:4px solid var(--color-primary)}.pi-details__binding-value{font-weight:bold;text-align:left;color:var(--color-text-primary)}.pi-content-box{font-size:var(--font-size-sm);color:var(--color-text-primary);padding:var(--space-md);background:linear-gradient(180deg,var(--color-bg-elevated) 0%,var(--color-bg-card) 100%);border:1px solid var(--color-border);border-radius:var(--radius-md);box-shadow:inset 0 1px 0 var(--color-white-opacity-10);white-space:normal;word-break:break-word;overflow-x:hidden}.file-picker-container{display:flex;align-items:center;gap:var(--space-sm);flex:1 1 auto;min-width:0;flex-wrap:nowrap}.file-picker-button{display:flex;align-items:center;gap:var(--space-xs);padding:var(--space-sm) var(--space-md);white-space:nowrap;border-radius:var(--radius-sm)}.file-picker-button .button-icon{font-size:var(--font-size-sm);letter-spacing:.5px}.file-picker-display{flex:1;padding:var(--space-sm);min-width:0;background:linear-gradient(180deg,var(--color-bg-elevated) 0%,var(--color-bg-card) 100%);border:1px solid var(--color-border);border-radius:var(--radius-sm);box-shadow:inset 0 1px 0 var(--color-white-opacity-10)}.file-picker-display .filename-text{display:block;font-size:var(--font-size-sm);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--color-text-secondary)}.file-picker-clear{padding:var(--space-sm);font-size:var(--font-size-md);line-height:1;border-radius:var(--radius-sm);border-color:var(--color-error-opacity-40);color:var(--color-error-opacity-70)}.file-picker-clear::before{border-color:var(--color-error-opacity-90)}.file-picker-clear::after{background-image:linear-gradient(135deg,var(--color-error-opacity-20) 0,var(--color-error-opacity-20) var(--frame-corner-thickness),transparent var(--frame-corner-thickness)),linear-gradient(225deg,var(--color-error-opacity-20) 0,var(--color-error-opacity-20) var(--frame-corner-thickness),transparent var(--frame-corner-thickness)),linear-gradient(45deg,var(--color-error-opacity-20) 0,var(--color-error-opacity-20) var(--frame-corner-thickness),transparent var(--frame-corner-thickness)),linear-gradient(-45deg,var(--color-error-opacity-20) 0,var(--color-error-opacity-20) var(--frame-corner-thickness),transparent var(--frame-corner-thickness))}.file-picker-clear:hover,.file-picker-clear:focus-visible{border-color:var(--color-error-opacity-90);color:var(--color-error-opacity-90);box-shadow:0 0 10px var(--color-error-opacity-90)}.file-picker-clear:disabled,.file-picker-clear[disabled]{opacity:.45;cursor:not-allowed;pointer-events:none;transform:none;box-shadow:none} +:root{--space-xs:4px;--space-sm:8px;--space-md:12px;--space-lg:16px;--space-xl:20px;--radius-sm:4px;--radius-md:6px;--radius-lg:8px;--frame-corner-size:12px;--frame-corner-size-sm:8px;--frame-corner-thickness:2px;--frame-corner-inset:2px;--dropdown-height:36px;--section-title-min-height:36px;--transition-fast:.15s;--transition-normal:.3s;--ease-out:cubic-bezier(.25,.46,.45,.94);--font-size-sm:12px;--font-size-md:14px;--theme-accent-rgb:94,195,202;--theme-accent-hi-rgb:154,230,236;--theme-surface-0-rgb:18,18,18;--theme-surface-1-rgb:15,15,15;--theme-surface-2-rgb:33,33,33;--theme-border-rgb:42,42,42;--color-bg-elgato:rgba(45,45,45,1);--color-bg-darker:rgba(var(--theme-surface-0-rgb),1);--color-bg-card:rgba(var(--theme-surface-1-rgb),1);--color-bg-elevated:rgba(var(--theme-surface-2-rgb),1);--color-border:rgba(var(--theme-border-rgb),1);--color-primary:rgba(var(--theme-accent-rgb),1);--color-primary-light:rgba(var(--theme-accent-hi-rgb),1);--color-primary-opacity-20:rgba(var(--theme-accent-hi-rgb),.2);--color-primary-opacity-30:rgba(var(--theme-accent-hi-rgb),.3);--color-primary-glow:rgba(var(--theme-accent-rgb),.3);--color-text-primary:rgba(255,255,255,1);--color-text-secondary:rgba(204,204,204,1);--color-text-tertiary:rgba(153,153,153,1);--color-error:rgba(244,67,54,1);--color-warning:rgba(255,193,7,1);--color-warning-glow:rgba(255,193,7,.35);--color-success:rgba(76,175,80,1);--color-success-glow:rgba(76,175,80,.55);--color-success-opacity-20:rgba(76,175,80,.2);--color-error-opacity-40:rgba(255,0,0,.4);--color-error-opacity-20:rgba(255,0,0,.2);--color-error-opacity-70:rgba(255,0,0,.7);--color-error-opacity-90:rgba(255,0,0,.9);--color-dark-opacity-40:rgba(0,0,0,.4);--color-dark-opacity-60:rgba(0,0,0,.6);--color-white-opacity-10:rgba(255,255,255,.1);--color-white-opacity-70:rgba(255,255,255,.7);--color-panel-glow:rgba(var(--theme-accent-rgb),.05);--color-inset-top:rgba(255,255,255,.02);--color-inset-bottom:rgba(0,0,0,.3);--color-glow-hover:rgba(var(--theme-accent-rgb),.4);--color-interactive-glow-hover:rgba(var(--theme-accent-hi-rgb),.85);--color-interactive-glow-active:rgba(var(--theme-accent-hi-rgb),.45)}@keyframes glow-pulse{0%,100%{opacity:1}50%{opacity:.7}}@keyframes focus-glow{0%{box-shadow:0 0 0 0 var(--color-glow-hover)}50%{box-shadow:0 0 8px 2px var(--color-glow-hover)}100%{box-shadow:0 0 4px 1px var(--color-primary-glow)}}@keyframes pi-spin{to{transform:rotate(360deg)}}@keyframes pi-loading-breathe{0%,100%{opacity:.55}50%{opacity:.95}}@keyframes pi-success-blink{0%{opacity:.15}35%{opacity:1}100%{opacity:.15}}@keyframes pi-dot{0%{opacity:.25;transform:translateY(0)}40%{opacity:1;transform:translateY(-1px)}80%{opacity:.25;transform:translateY(0)}100%{opacity:.25;transform:translateY(0)}}*,*::before,*::after{box-sizing:border-box;font-family:'Segoe UI',Tahoma,Geneva,Verdana,sans-serif}body{display:flex;justify-content:center;align-items:center;padding:var(--space-xs);margin:2px;background:var(--color-bg-elgato);color:var(--color-text-primary);overflow-x:hidden}.pi-container{width:100%;max-width:600px;position:relative;z-index:1}.pi-panel,.pi-section{position:relative;background-color:var(--color-bg-darker);background-image:linear-gradient(135deg,var(--color-primary-opacity-20) 0,var(--color-primary-opacity-20) var(--frame-corner-thickness),transparent var(--frame-corner-thickness)),linear-gradient(225deg,var(--color-primary-opacity-20) 0,var(--color-primary-opacity-20) var(--frame-corner-thickness),transparent var(--frame-corner-thickness)),linear-gradient(45deg,var(--color-primary-opacity-20) 0,var(--color-primary-opacity-20) var(--frame-corner-thickness),transparent var(--frame-corner-thickness)),linear-gradient(-45deg,var(--color-primary-opacity-20) 0,var(--color-primary-opacity-20) var(--frame-corner-thickness),transparent var(--frame-corner-thickness)),linear-gradient(180deg,transparent 0%,var(--color-inset-bottom) 100%);background-repeat:no-repeat;background-size:var(--frame-corner-size) var(--frame-corner-size),var(--frame-corner-size) var(--frame-corner-size),var(--frame-corner-size) var(--frame-corner-size),var(--frame-corner-size) var(--frame-corner-size),100% 100%;background-position:top left,top right,bottom left,bottom right,center center;border:1px solid var(--color-border);border-radius:var(--radius-md);box-shadow:0 0 6px var(--color-primary-glow),inset 0 0 0 1px var(--color-border),inset 0 2px 4px var(--color-dark-opacity-40)}.pi-panel::before,.pi-section::before,.pi-section__header::before{content:'';position:absolute;top:0;left:0;right:0;height:1px;background:linear-gradient(90deg,transparent,var(--color-primary),transparent);opacity:.5}.pi-panel{padding:var(--space-xl) var(--space-sm);overflow:hidden;background-image:linear-gradient(135deg,var(--color-primary-opacity-30) 0,var(--color-primary-opacity-30) var(--frame-corner-thickness),transparent var(--frame-corner-thickness)),linear-gradient(225deg,var(--color-primary-opacity-30) 0,var(--color-primary-opacity-30) var(--frame-corner-thickness),transparent var(--frame-corner-thickness)),linear-gradient(45deg,var(--color-primary-opacity-30) 0,var(--color-primary-opacity-30) var(--frame-corner-thickness),transparent var(--frame-corner-thickness)),linear-gradient(-45deg,var(--color-primary-opacity-30) 0,var(--color-primary-opacity-30) var(--frame-corner-thickness),transparent var(--frame-corner-thickness)),radial-gradient(ellipse at 50% 0%,var(--color-panel-glow) 0%,transparent 60%),linear-gradient(180deg,var(--color-inset-top) 0%,transparent 20%,transparent 80%,var(--color-inset-bottom) 100%);background-size:var(--frame-corner-size) var(--frame-corner-size),var(--frame-corner-size) var(--frame-corner-size),var(--frame-corner-size) var(--frame-corner-size),var(--frame-corner-size) var(--frame-corner-size),100% 100%,100% 100%;background-position:top left,top right,bottom left,bottom right,center top,center center;border-radius:var(--radius-lg);box-shadow:0 0 10px var(--color-primary-glow),inset 0 0 0 1px var(--color-border)}.pi-panel::before{height:2px}.pi-panel::after{content:'';position:absolute;bottom:0;left:0;right:0;height:2px;background:linear-gradient(90deg,transparent,var(--color-primary),transparent);opacity:.5}.pi-section{padding:var(--space-lg);margin-top:var(--space-lg)}.pi-page-title{display:flex;font-weight:bold;text-transform:uppercase;letter-spacing:1px;align-content:center;justify-content:center;margin-bottom:var(--space-lg);padding-bottom:var(--space-md);color:var(--color-primary);border-bottom:1px solid var(--color-border)}.pi-dropdown__toggle,.file-picker-button,.file-picker-clear,.pi-button{cursor:pointer;position:relative;background:var(--color-bg-darker);border:1px solid var(--color-border);color:var(--color-primary);box-shadow:0 0 6px var(--color-primary-glow),inset 0 0 0 1px var(--color-border);transition:border-color var(--transition-normal) ease,box-shadow var(--transition-normal) ease,background-color var(--transition-normal) ease,transform var(--transition-fast) var(--ease-out)}.pi-dropdown__toggle::before,.file-picker-button::before,.file-picker-clear::before,.pi-button::before{content:'';position:absolute;top:0;left:0;right:0;bottom:0;pointer-events:none;border:1px solid var(--color-primary);border-radius:inherit;opacity:0;transition:opacity var(--transition-normal) ease}.pi-dropdown__toggle:hover,.pi-dropdown__toggle:focus-visible,.file-picker-button:hover,.file-picker-button:focus-visible,.file-picker-clear:hover,.file-picker-clear:focus-visible,.pi-button:hover,.pi-button:focus-visible{background:var(--color-bg-darker);border-color:var(--color-primary);color:var(--color-primary-light);box-shadow:0 0 10px var(--color-interactive-glow-hover)}.pi-dropdown__toggle:hover::before,.pi-dropdown__toggle:focus-visible::before,.file-picker-button:hover::before,.file-picker-button:focus-visible::before,.file-picker-clear:hover::before,.file-picker-clear:focus-visible::before,.pi-button:hover::before,.pi-button:focus-visible::before{opacity:1}.pi-dropdown__toggle:focus-visible,.file-picker-button:focus-visible,.file-picker-clear:focus-visible,.pi-button:focus-visible{outline:1px solid var(--color-primary-light);outline-offset:1px;animation:focus-glow .4s var(--ease-out) forwards}.pi-dropdown__toggle:active,.file-picker-button:active,.file-picker-clear:active,.pi-button:active{transform:scale(.98);box-shadow:0 0 4px var(--color-interactive-glow-active)}.pi-section__header,.pi-details__binding{position:relative;justify-content:center;font-weight:bold;background:linear-gradient(180deg,var(--color-bg-card) 0%,var(--color-bg-darker) 100%);border:1px solid var(--color-border);box-shadow:inset 0 1px 0 var(--color-white-opacity-10);max-height:32px}.pi-dropdown__input-wrapper::after,.pi-dropdown__toggle::after,.file-picker-button::after,.file-picker-clear::after,.pi-button::after{content:'';position:absolute;inset:var(--frame-corner-inset);pointer-events:none;border-radius:inherit;background-image:linear-gradient(135deg,var(--color-primary-opacity-20) 0,var(--color-primary-opacity-20) var(--frame-corner-thickness),transparent var(--frame-corner-thickness)),linear-gradient(225deg,var(--color-primary-opacity-20) 0,var(--color-primary-opacity-20) var(--frame-corner-thickness),transparent var(--frame-corner-thickness)),linear-gradient(45deg,var(--color-primary-opacity-20) 0,var(--color-primary-opacity-20) var(--frame-corner-thickness),transparent var(--frame-corner-thickness)),linear-gradient(-45deg,var(--color-primary-opacity-20) 0,var(--color-primary-opacity-20) var(--frame-corner-thickness),transparent var(--frame-corner-thickness));background-repeat:no-repeat;background-size:var(--frame-corner-size-sm) var(--frame-corner-size-sm);background-position:top left,top right,bottom left,bottom right;opacity:.6;transition:opacity var(--transition-normal) ease}.pi-dropdown__input-wrapper:hover::after,.pi-dropdown__input-wrapper:focus-within::after,.pi-dropdown__toggle:hover::after,.pi-dropdown__toggle:focus-visible::after,.file-picker-button:hover::after,.file-picker-button:focus-visible::after,.file-picker-clear:hover::after,.file-picker-clear:focus-visible::after,.pi-button:hover::after,.pi-button:focus-visible::after{opacity:1;animation:glow-pulse 1.5s ease-in-out infinite}.pi-button{display:inline-flex;align-items:center;justify-content:center;gap:var(--space-sm);padding:var(--space-sm) var(--space-md);border-radius:var(--radius-sm);font-size:var(--font-size-sm);letter-spacing:.6px;text-transform:uppercase;user-select:none}.pi-button-row{display:flex;gap:var(--space-sm);margin-top:0}.pi-button-row>.pi-button{flex:1 1 0}.pi-inline-error{margin-top:var(--space-sm);font-size:var(--font-size-sm);color:var(--color-error);white-space:normal;word-break:break-word}.pi-inline-banner{margin-top:var(--space-sm);padding:var(--space-sm) var(--space-md);display:flex;align-items:center;justify-content:center;text-align:center;background:linear-gradient(180deg,var(--color-bg-card) 0%,var(--color-bg-darker) 100%);border:1px solid var(--color-border);border-radius:var(--radius-md);box-shadow:inset 0 1px 0 var(--color-white-opacity-10)}.pi-inline-banner .pi-inline-error{margin-top:0;text-align:center}.pi-button--danger{border-color:var(--color-error-opacity-40);color:var(--color-error-opacity-70)}.pi-button--danger::before{border-color:var(--color-error-opacity-90)}.pi-button--danger::after{background-image:linear-gradient(135deg,var(--color-error-opacity-20) 0,var(--color-error-opacity-20) var(--frame-corner-thickness),transparent var(--frame-corner-thickness)),linear-gradient(225deg,var(--color-error-opacity-20) 0,var(--color-error-opacity-20) var(--frame-corner-thickness),transparent var(--frame-corner-thickness)),linear-gradient(45deg,var(--color-error-opacity-20) 0,var(--color-error-opacity-20) var(--frame-corner-thickness),transparent var(--frame-corner-thickness)),linear-gradient(-45deg,var(--color-error-opacity-20) 0,var(--color-error-opacity-20) var(--frame-corner-thickness),transparent var(--frame-corner-thickness))}.pi-button--danger:hover,.pi-button--danger:focus-visible{border-color:var(--color-error-opacity-90);color:var(--color-error-opacity-90);box-shadow:0 0 10px var(--color-error-opacity-90)}.pi-picker-grid{display:flex;flex-direction:column;gap:var(--space-md);margin-top:var(--space-md)}.pi-picker-grid--top{gap:var(--space-xl)}.pi-section>.pi-picker-grid:first-child{margin-top:0}.pi-picker-row{display:flex;align-items:center;gap:var(--space-sm);width:100%;min-width:0;flex-wrap:nowrap}.pi-picker-label{min-width:60px;font-size:var(--font-size-sm);color:var(--color-text-tertiary);letter-spacing:.6px;font-weight:700;white-space:nowrap}.pi-picker-row .pi-dropdown{flex:1 1 auto;min-width:0;margin-bottom:0}.pi-picker-row .pi-file-picker{flex:1 1 auto;min-width:0}.pi-dropdown{position:relative;margin-bottom:var(--space-lg)}.pi-dropdown__input-row{display:flex;width:100%;gap:var(--space-xs);height:var(--dropdown-height)}.pi-dropdown__input-wrapper{flex:1;height:100%;padding:0;display:flex;align-items:center;justify-content:flex-start;position:relative;background-color:var(--color-bg-darker);border:1px solid var(--color-border);border-radius:var(--radius-md);transition:border-color var(--transition-normal) ease,box-shadow var(--transition-normal) ease,background-color var(--transition-normal) ease;box-shadow:0 0 8px var(--color-primary-glow),inset 0 0 0 1px var(--color-border)}.pi-spinner{width:12px;height:12px;display:inline-block;border-radius:50%;border:2px solid var(--color-primary-opacity-20);border-top-color:var(--color-primary);box-shadow:0 0 6px var(--color-primary-glow);animation:pi-spin .75s linear infinite}.pi-dropdown__loading{position:absolute;inset:0;display:none;align-items:center;gap:var(--space-sm);padding:0 var(--space-md);color:var(--color-text-tertiary);pointer-events:none}.pi-dropdown__loading-label{font-size:var(--font-size-md)}.pi-dropdown__loading-dots{display:inline-flex;align-items:center}.pi-dropdown__loading-dot{display:inline-block;min-width:.35em;opacity:.25;animation:pi-dot 1.2s ease-in-out infinite}.pi-dropdown__loading-dot:nth-child(2){animation-delay:.15s}.pi-dropdown__loading-dot:nth-child(3){animation-delay:.3s}.pi-dropdown--loading .pi-dropdown__loading{display:flex;animation:pi-loading-breathe 1.4s ease-in-out infinite}.pi-dropdown--error.pi-dropdown--loading .pi-dropdown__loading{color:var(--color-error);animation:none}.pi-dropdown--error.pi-dropdown--loading .pi-dropdown__loading-dots,.pi-dropdown--error.pi-dropdown--loading .pi-spinner{display:none}.pi-dropdown--success .pi-dropdown__loading{display:flex;color:var(--color-success);animation:pi-success-blink .22s ease-out 1}.pi-dropdown--loading .pi-dropdown__input-wrapper input,.pi-dropdown--success .pi-dropdown__input-wrapper input{opacity:0;pointer-events:none}.pi-dropdown--loading .pi-dropdown__toggle,.pi-dropdown--success .pi-dropdown__toggle{opacity:.65;pointer-events:none}.pi-dropdown__input-wrapper::before{content:'';position:absolute;top:0;left:0;right:0;bottom:0;pointer-events:none;border:1px solid var(--color-primary);border-radius:var(--radius-md);opacity:0;transition:opacity var(--transition-normal) ease}.pi-dropdown__input-wrapper:hover,.pi-dropdown__input-wrapper:focus-within{border-color:var(--color-primary);box-shadow:0 0 12px var(--color-glow-hover),inset 0 0 0 1px var(--color-primary-opacity-20)}.pi-dropdown__input-wrapper:focus-within{outline:1px solid var(--color-primary-light);outline-offset:1px;animation:focus-glow .4s var(--ease-out) forwards}.pi-dropdown__input-wrapper:hover::before,.pi-dropdown__input-wrapper:focus-within::before{opacity:1}.pi-dropdown__input-wrapper input{font-size:var(--font-size-md);font-family:inherit;width:100%;padding:0 var(--space-md);margin:0;outline:0;background:transparent;border:0;color:var(--color-text-primary)}.pi-dropdown__input-wrapper input::placeholder{color:var(--color-text-tertiary);background:transparent}.pi-dropdown__toggle{height:100%;padding:var(--space-sm);display:flex;align-items:center;justify-content:center;flex-shrink:0;border-radius:var(--radius-md);box-shadow:0 0 8px var(--color-primary-glow),inset 0 0 0 1px var(--color-border)}.pi-dropdown__arrow{transition:transform var(--transition-normal) var(--ease-out)}.pi-dropdown__arrow--open{transform:rotate(180deg)}.pi-dropdown__menu{position:absolute;top:100%;left:0;right:0;margin-top:var(--space-xs);max-height:300px;overflow-y:auto;overflow-x:hidden;z-index:1000;display:none;width:100%;background:linear-gradient(180deg,var(--color-bg-elevated) 0%,var(--color-bg-card) 100%);border:1px solid var(--color-border);border-radius:0 0 var(--radius-md) var(--radius-md);box-shadow:0 4px 12px var(--color-dark-opacity-60),0 0 8px var(--color-primary-glow)}.pi-dropdown__menu::-webkit-scrollbar{width:4px}.pi-dropdown__menu::-webkit-scrollbar-track{background:var(--color-border)}.pi-dropdown__menu::-webkit-scrollbar-thumb{background:var(--color-primary);border-radius:2px}.pi-dropdown__menu::-webkit-scrollbar-thumb:hover{background:var(--color-primary-light)}.pi-dropdown--open .pi-dropdown__menu{display:block}.pi-dropdown__empty-state{font-size:var(--font-size-sm);text-align:center;padding:var(--space-xs);color:var(--color-text-tertiary)}.pi-dropdown__group-header{padding:var(--space-sm) var(--space-lg);font-weight:bold;font-size:var(--font-size-md);background:repeating-linear-gradient(var(--color-bg-darker),var(--color-primary-glow) 2px);border-bottom:1px solid var(--color-border);color:var(--color-primary)}.pi-dropdown__option{padding:10px var(--space-lg);cursor:pointer;display:flex;align-items:center;width:100%;transition:background-color var(--transition-fast) ease,border-color var(--transition-fast) ease,box-shadow var(--transition-fast) ease;border-top:1px solid var(--color-primary-glow);border-left:var(--space-sm) solid transparent}.pi-dropdown__option:hover{background:var(--color-primary-glow);border-left-color:var(--color-primary);box-shadow:-5px 0 10px var(--color-primary-glow)}.pi-dropdown__option.disabled{cursor:not-allowed;opacity:.5}.pi-dropdown__option-label{flex:1}.pi-dropdown__option-badge{display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;border-radius:999px;font-size:12px;font-weight:800;margin-left:var(--space-sm);user-select:none}.pi-dropdown__option-badge--warn{color:rgba(0,0,0,.95);background:var(--color-warning);box-shadow:0 0 10px var(--color-warning-glow)}.pi-section__header{padding:var(--space-sm);margin-bottom:var(--space-lg);font-size:var(--font-size-md);display:flex;align-items:center;min-height:var(--section-title-min-height);border-radius:var(--radius-md);color:var(--color-text-primary);text-shadow:0 0 10px var(--color-primary-glow)}.pi-details__title{word-wrap:break-word;word-break:break-word;white-space:normal}.pi-details__title.pi-content-box{display:flex;align-items:center;justify-content:center;min-height:var(--section-title-min-height);font-size:var(--font-size-md);font-weight:bold;color:var(--color-primary);margin-bottom:var(--space-lg)}.pi-details__binding{display:grid;grid-template-columns:auto 1fr;align-items:center;font-size:var(--font-size-md);margin:var(--space-xs);padding:var(--space-xs);gap:var(--space-md);background:linear-gradient(180deg,var(--color-bg-darker) 0%,var(--color-bg-card) 100%);border-radius:var(--radius-sm);border-top:1px solid var(--color-border);border-bottom:1px solid var(--color-border)}.pi-details__binding-label{font-weight:bold;text-align:left;width:120px;padding:0 0 0 8px;color:var(--color-text-secondary);border-right:4px solid var(--color-border);border-left:4px solid var(--color-primary)}.pi-details__binding-value{font-weight:bold;text-align:left;color:var(--color-text-primary)}.pi-content-box{font-size:var(--font-size-sm);color:var(--color-text-primary);padding:var(--space-md);background:linear-gradient(180deg,var(--color-bg-elevated) 0%,var(--color-bg-card) 100%);border:1px solid var(--color-border);border-radius:var(--radius-md);box-shadow:inset 0 1px 0 var(--color-white-opacity-10);white-space:normal;word-break:break-word;overflow-x:hidden}.file-picker-container{display:flex;align-items:center;gap:var(--space-sm);flex:1 1 auto;min-width:0;flex-wrap:nowrap}.file-picker-button{display:flex;align-items:center;gap:var(--space-xs);padding:var(--space-sm) var(--space-md);white-space:nowrap;border-radius:var(--radius-sm)}.file-picker-button .button-icon{font-size:var(--font-size-sm);letter-spacing:.5px}.file-picker-display{flex:1;padding:var(--space-sm);min-width:0;background:linear-gradient(180deg,var(--color-bg-elevated) 0%,var(--color-bg-card) 100%);border:1px solid var(--color-border);border-radius:var(--radius-sm);box-shadow:inset 0 1px 0 var(--color-white-opacity-10)}.file-picker-display .filename-text{display:block;font-size:var(--font-size-sm);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--color-text-secondary)}.file-picker-clear{padding:var(--space-sm);font-size:var(--font-size-md);line-height:1;border-radius:var(--radius-sm);border-color:var(--color-error-opacity-40);color:var(--color-error-opacity-70)}.file-picker-clear::before{border-color:var(--color-error-opacity-90)}.file-picker-clear::after{background-image:linear-gradient(135deg,var(--color-error-opacity-20) 0,var(--color-error-opacity-20) var(--frame-corner-thickness),transparent var(--frame-corner-thickness)),linear-gradient(225deg,var(--color-error-opacity-20) 0,var(--color-error-opacity-20) var(--frame-corner-thickness),transparent var(--frame-corner-thickness)),linear-gradient(45deg,var(--color-error-opacity-20) 0,var(--color-error-opacity-20) var(--frame-corner-thickness),transparent var(--frame-corner-thickness)),linear-gradient(-45deg,var(--color-error-opacity-20) 0,var(--color-error-opacity-20) var(--frame-corner-thickness),transparent var(--frame-corner-thickness))}.file-picker-clear:hover,.file-picker-clear:focus-visible{border-color:var(--color-error-opacity-90);color:var(--color-error-opacity-90);box-shadow:0 0 10px var(--color-error-opacity-90)}.file-picker-clear:disabled,.file-picker-clear[disabled]{opacity:.45;cursor:not-allowed;pointer-events:none;transform:none;box-shadow:none}.pi-dial-inputs{display:grid;gap:12px}.pi-dial-input{display:grid;gap:6px}.pi-dial-input__hint{color:var(--sdpi-color-secondary);font-size:11px;line-height:1.4;margin:0}.pi-dial-input .pi-details{display:none} diff --git a/PluginCore/PropertyInspector/css/src/base.css b/PluginCore/PropertyInspector/css/src/base.css index f6c093c..bc82b08 100644 --- a/PluginCore/PropertyInspector/css/src/base.css +++ b/PluginCore/PropertyInspector/css/src/base.css @@ -902,3 +902,26 @@ body { transform: none; box-shadow: none; } + +/* === DIAL INPUTS === */ + +.pi-dial-inputs { + display: grid; + gap: 12px; +} + +.pi-dial-input { + display: grid; + gap: 6px; +} + +.pi-dial-input__hint { + color: var(--color-text-secondary); + font-size: 11px; + line-height: 1.4; + margin: 0; +} + +.pi-dial-input .pi-details { + display: none; +} diff --git a/PluginCore/PropertyInspector/js/pi-dial-action.js b/PluginCore/PropertyInspector/js/pi-dial-action.js new file mode 100644 index 0000000..23a90cf --- /dev/null +++ b/PluginCore/PropertyInspector/js/pi-dial-action.js @@ -0,0 +1,2 @@ +/* generated: pi-dial-action.js */ +(function(){function o(t){const i=n?.functionPicker?.flattenFunctionsData;return typeof i!="function"?[]:i(t,{requireToggleCandidates:!1,excludeBindingTypes:["mouseaxis","joystick","gamepad"]})}function s(n){r=o(n);t.forEach(n=>{n.dropdown?.setItems?.(r),n.dropdown?.setSelectedValue?.(n.currentValue,{rerender:!1}),n.currentValue&&f(n.settingsKey,n.currentValue)})}function f(n,u){const o=t.find(t=>t.settingsKey===n);if(o){o.currentValue=u||"";const f=r.find(n=>n.value===u||n.legacyValue===u);if(!f){u||i(o,null);return}const s=f.legacyValue===u&&f.value!==u;e(n,f,{persist:s})}}function e(n,r,f={}){const e=t.find(t=>t.settingsKey===n);if(e){const o=f.persist!==!1;u=!0;e.currentValue=r.value;e.dropdown?.setSelectedValue?.(r.value,{rerender:!0});i(e,r);o&&e.setValue(r.value);setTimeout(()=>{u=!1},200)}}function i(t,i){if(t&&t.idPrefix){const u=n?.functionPicker?.updateFunctionDetails;if(typeof u=="function"){const r=t.idPrefix;u({containerEl:document.getElementById(`${r}-details-container`),titleEl:document.getElementById(`${r}-details-title`),descriptionEl:document.getElementById(`${r}-description`),bindingElements:{keyboard:document.getElementById(`${r}-binding-keyboard`),mouse:document.getElementById(`${r}-binding-mouse`),gamepad:document.getElementById(`${r}-binding-gamepad`),joystick:document.getElementById(`${r}-binding-joystick`)},hideContainerWhenEmpty:!0,showContainerWhenFilled:!0,slotLabel:t.slotLabel,selectedOption:i})}}}const n=globalThis.SCPI;n?.bus?.start?.();n?.theme?.initThemeDropdown?.();let r=[],u=!1;const t=[{rootId:"rotateLeftDropdown",settingsKey:"rotateLeftFunction",slotLabel:"Rotate Left",idPrefix:"rotateLeft"},{rootId:"rotateRightDropdown",settingsKey:"rotateRightFunction",slotLabel:"Rotate Right",idPrefix:"rotateRight"},{rootId:"pressDropdown",settingsKey:"pressFunction",slotLabel:"Push",idPrefix:"press"}].map(t=>{const i=n?.ui?.dropdown?.initDropdown?.({rootId:t.rootId,searchEnabled:!0,minLoadingMs:0,successFlashMs:100,emptyText:"No matching functions found",maxResults:50,getText:n=>String(n?.text??""),getValue:n=>String(n?.value??""),getGroup:n=>String(n?.group??""),isDisabled:n=>!!n?.disabled,onSelect:n=>e(t.settingsKey,n,{persist:!0})});i?.setLoading?.(!0,"Loading functions");const[r,o]=globalThis.SDPIComponents.useSettings(t.settingsKey,n=>{u||f(t.settingsKey,n)});return{...t,dropdown:i,getValue:r,setValue:o,currentValue:""}});n?.ui?.filePicker?.createFilePicker?.({rootId:"audioFilePicker",placeholderText:"No file selected",settingsKey:"clickSoundPath"});n?.bus?.on?.(n=>{const r=n?.functionsLoaded;r===!0&&(t.forEach(n=>{n.dropdown?.setLoading?.(!1),document.getElementById(n.rootId)?.classList.remove("pi-dropdown--error")}),s(n.functions||[]));r===!1&&t.forEach(n=>{document.getElementById(n.rootId)?.classList.add("pi-dropdown--error"),n.dropdown?.setLoading?.(!0,"No installation detected. Set custom path."),n.dropdown?.setItems?.([]),i(n,null)})});n?.util?.onDocumentReady?.(()=>{t.forEach(n=>{const t=n.getValue();n.currentValue=t||"";n.dropdown?.setSelectedValue?.(n.currentValue,{rerender:!1});i(n,null)}),n?.bus?.sendOnce?.("pi.connected","propertyInspectorConnected")})})() diff --git a/PluginCore/PropertyInspector/js/pi-function-key.js b/PluginCore/PropertyInspector/js/pi-function-key.js index ae15b67..11be196 100644 --- a/PluginCore/PropertyInspector/js/pi-function-key.js +++ b/PluginCore/PropertyInspector/js/pi-function-key.js @@ -1,2 +1,2 @@ /* generated: pi-function-key.js */ -(function(){function l(n){const t=[],i=globalThis.SCPI_REQUIRE_TOGGLE_CANDIDATES===!0;return Array.isArray(n)&&n.forEach(n=>{const r=n.label||"Other",u=n.options||[];u.forEach(n=>{const u=String(n.bindingType||"").toLowerCase();if(i){const t=n&&n.details&&n.details.isToggleCandidate===!0;if(!t)return}const e=u==="mouseaxis"||u==="joystick"||u==="gamepad";if(!e){const o=r,s=String(n.disabledReason||""),f=u==="unbound",h=!!n.disabled&&!f;t.push({value:n.value,legacyValue:n.legacyValue,text:n.text,group:o,details:n.details,bindingType:u,disabledReason:s,unbound:f,disabled:h})}})}),t}function a(n){u=l(n);t?.setItems?.(u);t?.setSelectedValue?.(i,{rerender:!1});i&&s(i)}function o(n,r={}){const u=r.persist!==!1;f=!0;i=n.value;t?.setSelectedValue?.(n.value,{rerender:!0});e(n);u&&c(n.value);setTimeout(()=>{f=!1},200)}function s(n){const t=u.find(t=>t.value===n||t.legacyValue===n);if(t){const i=t.legacyValue===n&&t.value!==n;o(t,{persist:i})}}function e(n){const i=document.querySelector(".pi-details");if(i){if(!n||!n.details){const n=i.querySelector(".pi-details__title");n&&(n.textContent="");const t=document.querySelector(".pi-description__content");t&&(t.textContent="");const r=document.getElementById("pi-details__binding-keyboard"),u=document.getElementById("pi-details__binding-mouse"),f=document.getElementById("pi-details__binding-gamepad"),e=document.getElementById("pi-details__binding-joystick");[r,u,f,e].forEach(n=>{n&&(n.textContent="")});return}const t=n.details,f=String(t.label||n.text||""),e=String(t.description||""),o=Array.isArray(t.devices)?t.devices:[],r=i.querySelector(".pi-details__title");r&&(r.textContent=f);["keyboard","mouse","gamepad","joystick"].forEach(n=>{const t=o.find(t=>t.device&&t.device.toLowerCase()===n.toLowerCase()),i=document.getElementById(`pi-details__binding-${n}`);let r="Unbound";if(t&&Array.isArray(t.bindings)&&t.bindings.length>0){const n=t.bindings.map(n=>String(n.display||n.raw||"")).filter(n=>n);n.length>0&&(r=n.join(", "))}i&&(i.textContent=r)});const u=document.querySelector(".pi-description__content");u&&(u.textContent=e||"No description available.")}}const r=globalThis.SCPI;r?.bus?.start?.();const t=r?.ui?.dropdown?.initDropdown?.({rootId:"functionDropdown",searchEnabled:!0,minLoadingMs:0,successFlashMs:100,emptyText:"No matching functions found",maxResults:50,getText:n=>String(n?.text??""),getValue:n=>String(n?.value??""),getGroup:n=>String(n?.group??""),isDisabled:n=>!!n?.disabled,onSelect:n=>o(n,{persist:!0})});t?.setLoading?.(!0,"Loading functions");let u=[],i="",f=!1;const[h,c]=globalThis.SDPIComponents.useSettings("function",n=>{f||(i=n,s(n))});r?.ui?.filePicker?.createFilePicker?.({rootId:"audioFilePicker",placeholderText:"No file selected",settingsKey:"clickSoundPath"});const n=document.getElementById("resetHoldSeconds");if(n){const i=1,e=.2,o=10,r=document.getElementById("resetHoldSecondsClear");function u(n){const t=typeof n=="number"?n:parseFloat(String(n));return Number.isFinite(t)?Math.min(o,Math.max(e,t)):i}function t(){if(r){const t=u(n.value);r.disabled=Math.abs(t-i)<.0001}}const[s,f]=globalThis.SDPIComponents.useSettings("resetHoldSeconds",i=>{const r=u(i);n.value=r.toFixed(1);t()});r&&r.addEventListener("click",()=>{n.value=i.toFixed(1),f(i),t()});n.addEventListener("input",()=>{t()});n.addEventListener("change",()=>{const i=u(n.value);n.value=i.toFixed(1);f(i);t()});n.value=u(s()).toFixed(1);t()}r?.bus?.on?.(n=>{const i=n?.functionsLoaded;i===!0&&(t?.setLoading?.(!1),document.getElementById("functionDropdown")?.classList.remove("pi-dropdown--error"),a(n.functions||[]));i===!1&&(document.getElementById("functionDropdown")?.classList.add("pi-dropdown--error"),t?.setLoading?.(!0,"No installation detected. Set custom path."),t?.setItems?.([]),e(null))});r?.util?.onDocumentReady?.(()=>{const n=h();n?i=n:e(null);t?.setSelectedValue?.(i,{rerender:!1});r?.bus?.sendOnce?.("pi.connected","propertyInspectorConnected")})})() +(function(){function l(n){const r=globalThis.SCPI_REQUIRE_TOGGLE_CANDIDATES===!0,i=t?.functionPicker?.flattenFunctionsData;return typeof i=="function"?i(n,{requireToggleCandidates:r,excludeBindingTypes:["mouseaxis","joystick","gamepad"]}):[]}function a(n){u=l(n);i?.setItems?.(u);i?.setSelectedValue?.(r,{rerender:!1});r&&s(r)}function o(n,t={}){const u=t.persist!==!1;f=!0;r=n.value;i?.setSelectedValue?.(n.value,{rerender:!0});e(n);u&&c(n.value);setTimeout(()=>{f=!1},200)}function s(n){const t=u.find(t=>t.value===n||t.legacyValue===n);if(t){const i=t.legacyValue===n&&t.value!==n;o(t,{persist:i})}}function e(n){const i=document.querySelector(".pi-details");if(i){const r=t?.functionPicker?.updateFunctionDetails;typeof r=="function"&&r({containerEl:i,titleEl:i.querySelector(".pi-details__title"),descriptionEl:document.querySelector(".pi-description__content"),bindingElements:{keyboard:document.getElementById("pi-details__binding-keyboard"),mouse:document.getElementById("pi-details__binding-mouse"),gamepad:document.getElementById("pi-details__binding-gamepad"),joystick:document.getElementById("pi-details__binding-joystick")},hideContainerWhenEmpty:!1,showContainerWhenFilled:!1,selectedOption:n})}}const t=globalThis.SCPI;t?.bus?.start?.();const i=t?.ui?.dropdown?.initDropdown?.({rootId:"functionDropdown",searchEnabled:!0,minLoadingMs:0,successFlashMs:100,emptyText:"No matching functions found",maxResults:50,getText:n=>String(n?.text??""),getValue:n=>String(n?.value??""),getGroup:n=>String(n?.group??""),isDisabled:n=>!!n?.disabled,onSelect:n=>o(n,{persist:!0})});i?.setLoading?.(!0,"Loading functions");let u=[],r="",f=!1;const[h,c]=globalThis.SDPIComponents.useSettings("function",n=>{f||(r=n,s(n))});t?.ui?.filePicker?.createFilePicker?.({rootId:"audioFilePicker",placeholderText:"No file selected",settingsKey:"clickSoundPath"});const n=document.getElementById("resetHoldSeconds");if(n){const i=1,e=.2,o=10,r=document.getElementById("resetHoldSecondsClear");function u(n){const t=typeof n=="number"?n:parseFloat(String(n));return Number.isFinite(t)?Math.min(o,Math.max(e,t)):i}function t(){if(r){const t=u(n.value);r.disabled=Math.abs(t-i)<.0001}}const[s,f]=globalThis.SDPIComponents.useSettings("resetHoldSeconds",i=>{const r=u(i);n.value=r.toFixed(1);t()});r&&r.addEventListener("click",()=>{n.value=i.toFixed(1),f(i),t()});n.addEventListener("input",()=>{t()});n.addEventListener("change",()=>{const i=u(n.value);n.value=i.toFixed(1);f(i);t()});n.value=u(s()).toFixed(1);t()}t?.bus?.on?.(n=>{const t=n?.functionsLoaded;t===!0&&(i?.setLoading?.(!1),document.getElementById("functionDropdown")?.classList.remove("pi-dropdown--error"),a(n.functions||[]));t===!1&&(document.getElementById("functionDropdown")?.classList.add("pi-dropdown--error"),i?.setLoading?.(!0,"No installation detected. Set custom path."),i?.setItems?.([]),e(null))});t?.util?.onDocumentReady?.(()=>{const n=h();n?r=n:e(null);i?.setSelectedValue?.(r,{rerender:!1});t?.bus?.sendOnce?.("pi.connected","propertyInspectorConnected")})})() diff --git a/PluginCore/PropertyInspector/js/sc-components.js b/PluginCore/PropertyInspector/js/sc-components.js index a33b554..f141e1d 100644 --- a/PluginCore/PropertyInspector/js/sc-components.js +++ b/PluginCore/PropertyInspector/js/sc-components.js @@ -1,2 +1,2 @@ /* generated: sc-components.js */ -(function(){function r(n,t){let i;return function(...r){const u=()=>{clearTimeout(i),n(...r)};clearTimeout(i);i=setTimeout(u,t)}}function u(n){document.readyState==="loading"?document.addEventListener("DOMContentLoaded",n):n()}function f(t,i={}){try{if(!n.SDPIComponents?.streamDeckClient?.send){console.warn("[sc-common] SDPIComponents.streamDeckClient.send not available");return}n.SDPIComponents.streamDeckClient.send("sendToPlugin",{event:t,...i})}catch(r){console.error("[sc-common] sendToPlugin failed",r)}}const n=globalThis,i=n.SCPI=n.SCPI||{},t=i.util=i.util||{};t.debounce=r;t.onDocumentReady=u;t.sendToPlugin=f;typeof n.debounce!="function"&&(n.debounce=r);typeof n.onDocumentReady!="function"&&(n.onDocumentReady=u);typeof n.sendToPlugin!="function"&&(n.sendToPlugin=f)})(),function(){function e(){return n.SDPIComponents?.streamDeckClient||null}function i(n,t={}){const i=e();if(!i?.send){console.warn("[sc-bus] SDPI streamDeckClient.send not available");return}i.send("sendToPlugin",{event:n,...t})}function s(n,t,u={}){const f=String(n||"").trim();if(!f){i(t,u);return}r.has(f)||(r.add(f),i(t,u))}function h(n){return typeof n!="function"?()=>{}:(t.add(n),()=>t.delete(n))}function c(){if(!u){u=!0;const n=()=>{if(!f){const i=e(),r=i?.sendToPropertyInspector?.subscribe;if(typeof r!="function"){setTimeout(n,0);return}f=!0;i.sendToPropertyInspector.subscribe(n=>{if(n?.event==="sendToPropertyInspector"&&n.payload)for(const i of Array.from(t))try{i(n.payload,n)}catch(r){console.error("[sc-bus] listener failed",r)}})}};n()}}const n=globalThis,o=n.SCPI=n.SCPI||{},t=new Set,r=new Set;let u=!1,f=!1;o.bus={start:c,on:h,send:i,sendOnce:s}}(),function(){function i(){const n=document.createElementNS("http://www.w3.org/2000/svg","svg");n.setAttribute("class","pi-dropdown__arrow");n.setAttribute("fill","none");n.setAttribute("height","20");n.setAttribute("width","20");n.setAttribute("xmlns","http://www.w3.org/2000/svg");const t=document.createElementNS("http://www.w3.org/2000/svg","path");return t.setAttribute("d","m5 7.5 5 5 5-5"),t.setAttribute("stroke","var(--color-primary)"),t.setAttribute("stroke-linecap","round"),t.setAttribute("stroke-linejoin","round"),t.setAttribute("stroke-width","2"),n.appendChild(t),n}function r(n,t={}){const s=!!n.querySelector(".pi-dropdown__search"),h=!!n.querySelector(".pi-dropdown__toggle"),c=!!n.querySelector(".pi-dropdown__menu");if(!s||!h||!c){const l=typeof t.placeholder=="string"?t.placeholder:n.getAttribute("data-placeholder")||"",r=document.createElement("div");r.className="pi-dropdown__input-row";const f=document.createElement("div");f.className="pi-dropdown__input-wrapper";const u=document.createElement("input");u.className="pi-dropdown__search";u.type="text";u.placeholder=l;f.appendChild(u);const e=document.createElement("div");e.className="pi-dropdown__toggle";e.appendChild(i());r.appendChild(f);r.appendChild(e);const o=document.createElement("div");o.className="pi-dropdown__menu";n.replaceChildren(r,o)}}function u(t={}){function l(){return u.classList.contains("pi-dropdown--open")}function lt(n){d&&(n?d.classList.add("pi-dropdown__arrow--open"):d.classList.remove("pi-dropdown__arrow--open"))}function it(n){if(h.textContent="",!Array.isArray(n)||n.length===0){const n=document.createElement("div");n.className="pi-dropdown__empty-state";n.textContent=bt;h.appendChild(n);return}if(ot){const t=new Map;for(const i of n){const n=String(ot(i)??"");t.has(n)||t.set(n,[]);t.get(n).push(i)}for(const[n,i]of t.entries()){if(n){const t=document.createElement("div");t.className="pi-dropdown__group-header";t.textContent=n;h.appendChild(t)}for(const n of i)h.appendChild(at(n))}}else for(const t of n)h.appendChild(at(t))}function at(n){const t=document.createElement("div");t.className="pi-dropdown__option";const u=!!n?.unbound,r=wt(n);r&&t.classList.add("disabled");const i=document.createElement("span");if(i.className="pi-dropdown__option-label",i.textContent=p(n),t.appendChild(i),u){const n=document.createElement("span");n.className="pi-dropdown__option-badge pi-dropdown__option-badge--warn";n.textContent="!";n.title="Unbound";t.appendChild(n)}return t.addEventListener("mousedown",()=>{tt=!0}),t.addEventListener("click",()=>{r||ti(n)}),t}function vt(n){const i=(n||"").toLowerCase().trim();let t=a.filter(n=>p(n).toLowerCase().includes(i));return t.length>ft&&(t=t.slice(0,ft)),t}function k(){if(!e)if(u.classList.add("pi-dropdown--open"),lt(!0),f){const n=(i.value||"").trim();it(n?vt(n):a)}else it(a)}function v(){u.classList.remove("pi-dropdown--open");lt(!1)}function yt(){e||(l()?v():k())}function gt(n){a=Array.isArray(n)?n:[];l()&&k()}function ni(n,t={}){if(nt=typeof n=="string"?n:"",ht){const n=a.find(n=>et(n)===nt);i.value=n?p(n):""}t.rerender&&l()&&k()}function ti(n){e||(nt=et(n),ht&&(i.value=p(n)),f&&(i.value=""),v(),st&&st(n),setTimeout(()=>{tt=!1},200))}function pt(n){if(f&&!e){const t=n?.target?.value??"";it(vt(t));!l()&&String(t).trim()&&k()}}function ii(){f&&(tt||(l()&&v(),i.value=""))}function ri(n){f||e||(n?.stopPropagation?.(),yt())}function ei(n,t="Loading"){const r=!!n;if(r===e){e&&o&&(o.textContent=String(t||"Loading"));return}if(w+=1,c&&(clearTimeout(c),c=null),r){e=!0;ct=Date.now();v();u.classList.remove("pi-dropdown--success");u.classList.add("pi-dropdown--loading");i.readOnly=!0;i.setAttribute("readonly","");i.blur?.();y.setAttribute("aria-disabled","true");o&&(o.textContent=String(t||"Loading"));return}const f=w,s=Date.now()-ct,h=Math.max(0,kt-s);c=setTimeout(()=>{f===w&&(u.classList.remove("pi-dropdown--loading"),u.classList.add("pi-dropdown--success"),o&&typeof g=="string"&&g.trim().length>0&&(o.textContent=g),c=setTimeout(()=>{f===w&&(u.classList.remove("pi-dropdown--success"),y.removeAttribute("aria-disabled"),i.readOnly=ui,fi?i.setAttribute("readonly",""):i.removeAttribute("readonly"),e=!1,c=null)},dt))},h)}const rt=t.rootId;if(!rt)return null;const u=document.getElementById(rt);if(!u)return null;r(u,{placeholder:t.placeholder});const i=u.querySelector(".pi-dropdown__search"),y=u.querySelector(".pi-dropdown__toggle"),d=u.querySelector(".pi-dropdown__arrow"),h=u.querySelector(".pi-dropdown__menu"),ut=u.querySelector(".pi-dropdown__input-wrapper");if(!i||!y||!h)return null;const f=t.searchEnabled!==!1,ft=typeof t.maxResults=="number"?t.maxResults:50,p=typeof t.getText=="function"?t.getText:n=>String(n?.text??""),et=typeof t.getValue=="function"?t.getValue:n=>String(n?.value??""),ot=typeof t.getGroup=="function"?t.getGroup:null,wt=typeof t.isDisabled=="function"?t.isDisabled:n=>!!n?.disabled,st=typeof t.onSelect=="function"?t.onSelect:null,bt=typeof t.emptyText=="string"?t.emptyText:"No items found",ht=t.displaySelectedInInput!==undefined?!!t.displaySelectedInInput:!f,kt=typeof t.minLoadingMs=="number"?t.minLoadingMs:500,dt=typeof t.successFlashMs=="number"?t.successFlashMs:220,g=typeof t.successText=="string"?t.successText:"";let a=[],nt="",tt=!1,e=!1,ct=0,w=0,c=null,s=null,o=null,b=null;if(ut){s=document.createElement("div");s.className="pi-dropdown__loading";s.setAttribute("aria-hidden","true");const n=document.createElement("span");n.className="pi-spinner";n.setAttribute("aria-hidden","true");o=document.createElement("span");o.className="pi-dropdown__loading-label";b=document.createElement("span");b.className="pi-dropdown__loading-dots";for(let n=0;n<3;n+=1){const n=document.createElement("span");n.className="pi-dropdown__loading-dot";n.textContent=".";b.appendChild(n)}s.appendChild(o);s.appendChild(b);s.appendChild(n);ut.appendChild(s)}f||(i.readOnly=!0,i.setAttribute("readonly",""));const ui=i.readOnly,fi=i.hasAttribute("readonly");if(f){const t=n.SCPI?.util?.debounce||n.debounce,r=typeof t=="function"?t(pt,150):pt;i.addEventListener("input",r);i.addEventListener("blur",ii)}else i.addEventListener("click",ri);return y.addEventListener("click",n=>{n.stopPropagation(),yt()}),document.addEventListener("click",n=>{u.contains(n.target)||(l()&&v(),f&&(i.value=""))},!0),{setItems:gt,setSelectedValue:ni,setLoading:ei}}const n=globalThis;const t=n.SCPI=n.SCPI||{};t.ui=t.ui||{};t.ui.dropdown={initDropdown:u};n.SCDropdown=n.SCDropdown||t.ui.dropdown}(),function(){function e(n){try{return decodeURIComponent(n)}catch(t){return n}}function i(n){return typeof n!="string"?"":e(String(n).replace(/^C:\\fakepath\\/i,""))}function r(n){return typeof n!="string"||n.length===0?"":n.split("\\").pop().split("/").pop()}function o(n,t={}){const c=n.querySelector(".file-picker-container");if(!c){const h=typeof t.accept=="string"?t.accept:n.getAttribute("data-accept")||"",l=typeof t.placeholderText=="string"?t.placeholderText:n.getAttribute("data-placeholder")||"No file selected",a=typeof t.buttonText=="string"?t.buttonText:n.getAttribute("data-button-text")||"FILE",v=typeof t.selectTitle=="string"?t.selectTitle:n.getAttribute("data-select-title")||"Select file",y=typeof t.clearTitle=="string"?t.clearTitle:n.getAttribute("data-clear-title")||"Clear",i=document.createElement("div");i.className="file-picker-container";const f=document.createElement("input");f.type="file";f.style.display="none";h&&f.setAttribute("accept",h);const e=document.createElement("div");e.className="file-picker-display";const o=document.createElement("span");o.className="filename-text";o.textContent=l;e.appendChild(o);const u=document.createElement("button");u.className="file-picker-button";u.type="button";u.title=v;const s=document.createElement("span");s.className="button-icon";s.textContent=a;u.appendChild(s);const r=document.createElement("button");r.className="file-picker-clear";r.type="button";r.title=y;r.disabled=!0;r.textContent="X";i.appendChild(f);i.appendChild(e);i.appendChild(u);i.appendChild(r);n.replaceChildren(i)}}function u(t={}){function p(n){const t=typeof n=="string"&&n.length>0,i=t?rt==="full"?n:r(n):it;a.textContent=i;a.title=t?n:"";c.disabled=!t}function f(n,t={}){const r=t.persist!==!1,u=!!t.silent,i=typeof n=="string"?n:"";if(s=i,p(s),!u&&b)try{b(s)}catch(f){}if(r&&y){v=!0;try{y(i.length>0?i:null)}finally{setTimeout(()=>{v=!1},50)}}}function nt(){e.value="";f("",{persist:!0})}const w=t.rootId,u=w?document.getElementById(w):null;if(!u)return null;o(u,t);const tt=t.filenameSelector||".filename-text",it=t.placeholderText||"No file selected",h=t.settingsKey,rt=t.displayMode||"basename",b=typeof t.onValueChanged=="function"?t.onValueChanged:null,k=typeof t.initialValue=="string"?t.initialValue:"",e=u.querySelector('input[type="file"]'),d=u.querySelector(".file-picker-button"),c=u.querySelector(".file-picker-clear"),l=u.querySelector(".file-picker-display"),a=l?l.querySelector(tt):null;if(!e||!d||!c||!l||!a)return null;let s="",v=!1,y=null,g=null;return d.addEventListener("click",()=>{e.click()}),e.addEventListener("change",n=>{const t=n?.target?.files?.[0],u=n?.target?.value||"",e=i(u),r=t?.path||e;if(r){f(r,{persist:!0});return}t?.name&&p(t.name)}),c.addEventListener("click",()=>{nt()}),typeof h=="string"&&h.length>0&&n.SDPIComponents?.useSettings&&([g,y]=n.SDPIComponents.useSettings(h,n=>{v||f(typeof n=="string"?n:"",{persist:!1})}),Promise.resolve(g()).then(n=>{f(typeof n=="string"?n:"",{persist:!1})}).catch(()=>{})),p(""),k&&f(k,{persist:!1,silent:!0}),{setValue:f,clear:nt,getValue:()=>s}}function f(t={}){function y(n){const t=typeof n=="string"&&n.length>0,i=t?et==="full"?n:r(n):ft;l.textContent=i;l.title=t?n:"";c.disabled=!t}function f(n,t={}){const r=t.persist!==!1,u=!!t.silent,i=typeof n=="string"?n:"";if(s=i,y(s),!u&&p)try{p(s)}catch(f){}if(r&&v){a=!0;try{v(i.length>0?i:null)}finally{setTimeout(()=>{a=!1},50)}}}function g(){e.value="";f("",{persist:!0})}if(typeof t.rootId=="string"&&t.rootId.length>0)return u(t);const nt=t.inputId,tt=t.buttonId,it=t.clearId,rt=t.displayId,ut=t.filenameSelector||".filename-text",ft=t.placeholderText||"No file selected",h=t.settingsKey,et=t.displayMode||"basename",p=typeof t.onValueChanged=="function"?t.onValueChanged:null,w=typeof t.initialValue=="string"?t.initialValue:"",e=document.getElementById(nt),b=document.getElementById(tt),c=document.getElementById(it),o=document.getElementById(rt),l=o?o.querySelector(ut):null;if(!e||!b||!c||!o||!l)return null;const ot=document.createElement("div"),k=o.closest(".file-picker-container");k&&ot.appendChild(k.cloneNode(!0));let s="",a=!1,v=null,d=null;return b.addEventListener("click",()=>{e.click()}),e.addEventListener("change",n=>{const t=n?.target?.files?.[0],u=n?.target?.value||"",e=i(u),r=t?.path||e;if(r){f(r,{persist:!0});return}t?.name&&y(t.name)}),c.addEventListener("click",()=>{g()}),typeof h=="string"&&h.length>0&&n.SDPIComponents?.useSettings&&([d,v]=n.SDPIComponents.useSettings(h,n=>{a||f(typeof n=="string"?n:"",{persist:!1})}),Promise.resolve(d()).then(n=>{f(typeof n=="string"?n:"",{persist:!1})}).catch(()=>{})),y(""),w&&f(w,{persist:!1,silent:!0}),{setValue:f,clear:g,getValue:()=>s}}const n=globalThis;const t=n.SCPI=n.SCPI||{};t.ui=t.ui||{};t.ui.filePicker={createFilePicker:u,initFilePicker:f};n.SCFilePicker=n.SCFilePicker||{initFilePicker:f}}(),function(){function i(n,t){if(n&&typeof n=="string"){t&&(t.href=`../css/themes/${n}`);try{localStorage.setItem(r,n)}catch(i){}}}function u(r={}){const o=r.rootId||"themeDropdown",s=r.linkId||"pi-theme-styles",u=document.getElementById(s);if(u){let e=[];n.bus?.start?.();const f=n.ui?.dropdown?.initDropdown?.({rootId:o,searchEnabled:!1,displaySelectedInInput:!0,minLoadingMs:500,successFlashMs:220,emptyText:"No themes found",getText:n=>String(n?.name??""),getValue:n=>String(n?.file??""),onSelect:r=>{const f=String(r?.file??"");f&&(i(f,u),(n.bus?.send||n.util?.sendToPlugin||t.sendToPlugin)?.("setTheme",{themeFile:f}))}});f?.setLoading?.(!0,"Loading themes");n.bus?.on?.(n=>{if(n&&(n.themesLoaded&&(e=n.themes||[],f?.setItems?.(e),f?.setLoading?.(!1)),typeof n.selectedTheme=="string"&&n.selectedTheme.length>0)){const t=n.selectedTheme;i(t,u);f?.setSelectedValue?.(t,{rerender:!0})}});try{const t=u.getAttribute("href")||"",n=t.split("/").pop().split("?")[0];n&&(i(n,u),f?.setSelectedValue?.(n,{rerender:!0}))}catch(h){}}}const t=globalThis,n=t.SCPI=t.SCPI||{},r="scsd.selectedTheme";n.theme={initThemeDropdown:u};t.SCTheme=t.SCTheme||n.theme}() +(function(){function r(n,t){let i;return function(...r){const u=()=>{clearTimeout(i),n(...r)};clearTimeout(i);i=setTimeout(u,t)}}function u(n){document.readyState==="loading"?document.addEventListener("DOMContentLoaded",n):n()}function f(t,i={}){try{if(!n.SDPIComponents?.streamDeckClient?.send){console.warn("[sc-common] SDPIComponents.streamDeckClient.send not available");return}n.SDPIComponents.streamDeckClient.send("sendToPlugin",{event:t,...i})}catch(r){console.error("[sc-common] sendToPlugin failed",r)}}const n=globalThis,i=n.SCPI=n.SCPI||{},t=i.util=i.util||{};t.debounce=r;t.onDocumentReady=u;t.sendToPlugin=f;typeof n.debounce!="function"&&(n.debounce=r);typeof n.onDocumentReady!="function"&&(n.onDocumentReady=u);typeof n.sendToPlugin!="function"&&(n.sendToPlugin=f)})(),function(){function r(n,t={}){const i=[],r=t.requireToggleCandidates===!0,u=new Set((t.excludeBindingTypes||[]).map(n=>String(n||"").toLowerCase()));return Array.isArray(n)?(n.forEach(n=>{const t=n?.label||"Other",f=Array.isArray(n?.options)?n.options:[];f.forEach(n=>{const f=String(n?.bindingType||"").toLowerCase();if(r){const t=n&&n.details&&n.details.isToggleCandidate===!0;if(!t)return}if(!u.has(f)){const o=String(n?.disabledReason||""),e=f==="unbound",s=!!n?.disabled&&!e;i.push({value:n?.value,legacyValue:n?.legacyValue,text:n?.text,group:t,details:n?.details,bindingType:f,disabledReason:o,unbound:e,disabled:s})}})}),i):i}function u(n={}){const t=n.containerEl||null,i=n.titleEl||null,r=n.descriptionEl||null,e=n.bindingElements||{},c=n.hideContainerWhenEmpty===!0,l=n.showContainerWhenFilled===!0,u=n.selectedOption||null,o=String(n.slotLabel||"").trim(),s=["keyboard","mouse","gamepad","joystick"];if(!u||!u.details){c&&t&&(t.style.display="none");i&&(i.textContent="");r&&(r.textContent="");s.forEach(n=>{const t=e[n]||null;t&&(t.textContent="")});return}l&&t&&(t.style.display="block");const f=u.details,h=String(f.label||u.text||""),a=String(f.description||""),v=Array.isArray(f.devices)?f.devices:[];i&&(i.textContent=o.length>0?`${o}: ${h}`:h);s.forEach(n=>{const t=v.find(t=>t.device&&t.device.toLowerCase()===n),i=e[n]||null;let r="Unbound";if(t&&Array.isArray(t.bindings)&&t.bindings.length>0){const n=t.bindings.map(n=>String(n.display||n.raw||"")).filter(n=>n);n.length>0&&(r=n.join(", "))}i&&(i.textContent=r)});r&&(r.textContent=a||"No description available.")}const n=globalThis,t=n.SCPI=n.SCPI||{},i=t.functionPicker=t.functionPicker||{};i.flattenFunctionsData=r;i.updateFunctionDetails=u}(),function(){function e(){return n.SDPIComponents?.streamDeckClient||null}function i(n,t={}){const i=e();if(!i?.send){console.warn("[sc-bus] SDPI streamDeckClient.send not available");return}i.send("sendToPlugin",{event:n,...t})}function s(n,t,u={}){const f=String(n||"").trim();if(!f){i(t,u);return}r.has(f)||(r.add(f),i(t,u))}function h(n){return typeof n!="function"?()=>{}:(t.add(n),()=>t.delete(n))}function c(){if(!u){u=!0;const n=()=>{if(!f){const i=e(),r=i?.sendToPropertyInspector?.subscribe;if(typeof r!="function"){setTimeout(n,0);return}f=!0;i.sendToPropertyInspector.subscribe(n=>{if(n?.event==="sendToPropertyInspector"&&n.payload)for(const i of Array.from(t))try{i(n.payload,n)}catch(r){console.error("[sc-bus] listener failed",r)}})}};n()}}const n=globalThis,o=n.SCPI=n.SCPI||{},t=new Set,r=new Set;let u=!1,f=!1;o.bus={start:c,on:h,send:i,sendOnce:s}}(),function(){function i(){const n=document.createElementNS("http://www.w3.org/2000/svg","svg");n.setAttribute("class","pi-dropdown__arrow");n.setAttribute("fill","none");n.setAttribute("height","20");n.setAttribute("width","20");n.setAttribute("xmlns","http://www.w3.org/2000/svg");const t=document.createElementNS("http://www.w3.org/2000/svg","path");return t.setAttribute("d","m5 7.5 5 5 5-5"),t.setAttribute("stroke","var(--color-primary)"),t.setAttribute("stroke-linecap","round"),t.setAttribute("stroke-linejoin","round"),t.setAttribute("stroke-width","2"),n.appendChild(t),n}function r(n,t={}){const s=!!n.querySelector(".pi-dropdown__search"),h=!!n.querySelector(".pi-dropdown__toggle"),c=!!n.querySelector(".pi-dropdown__menu");if(!s||!h||!c){const l=typeof t.placeholder=="string"?t.placeholder:n.getAttribute("data-placeholder")||"",r=document.createElement("div");r.className="pi-dropdown__input-row";const f=document.createElement("div");f.className="pi-dropdown__input-wrapper";const u=document.createElement("input");u.className="pi-dropdown__search";u.type="text";u.placeholder=l;f.appendChild(u);const e=document.createElement("div");e.className="pi-dropdown__toggle";e.appendChild(i());r.appendChild(f);r.appendChild(e);const o=document.createElement("div");o.className="pi-dropdown__menu";n.replaceChildren(r,o)}}function u(t={}){function l(){return u.classList.contains("pi-dropdown--open")}function lt(n){d&&(n?d.classList.add("pi-dropdown__arrow--open"):d.classList.remove("pi-dropdown__arrow--open"))}function it(n){if(h.textContent="",!Array.isArray(n)||n.length===0){const n=document.createElement("div");n.className="pi-dropdown__empty-state";n.textContent=bt;h.appendChild(n);return}if(ot){const t=new Map;for(const i of n){const n=String(ot(i)??"");t.has(n)||t.set(n,[]);t.get(n).push(i)}for(const[n,i]of t.entries()){if(n){const t=document.createElement("div");t.className="pi-dropdown__group-header";t.textContent=n;h.appendChild(t)}for(const n of i)h.appendChild(at(n))}}else for(const t of n)h.appendChild(at(t))}function at(n){const t=document.createElement("div");t.className="pi-dropdown__option";const u=!!n?.unbound,r=wt(n);r&&t.classList.add("disabled");const i=document.createElement("span");if(i.className="pi-dropdown__option-label",i.textContent=p(n),t.appendChild(i),u){const n=document.createElement("span");n.className="pi-dropdown__option-badge pi-dropdown__option-badge--warn";n.textContent="!";n.title="Unbound";t.appendChild(n)}return t.addEventListener("mousedown",()=>{tt=!0}),t.addEventListener("click",()=>{r||ti(n)}),t}function vt(n){const i=(n||"").toLowerCase().trim();let t=a.filter(n=>p(n).toLowerCase().includes(i));return t.length>ft&&(t=t.slice(0,ft)),t}function k(){if(!e)if(u.classList.add("pi-dropdown--open"),lt(!0),f){const n=(i.value||"").trim();it(n?vt(n):a)}else it(a)}function v(){u.classList.remove("pi-dropdown--open");lt(!1)}function yt(){e||(l()?v():k())}function gt(n){a=Array.isArray(n)?n:[];l()&&k()}function ni(n,t={}){if(nt=typeof n=="string"?n:"",ht){const n=a.find(n=>et(n)===nt);i.value=n?p(n):""}t.rerender&&l()&&k()}function ti(n){e||(nt=et(n),ht&&(i.value=p(n)),f&&(i.value=""),v(),st&&st(n),setTimeout(()=>{tt=!1},200))}function pt(n){if(f&&!e){const t=n?.target?.value??"";it(vt(t));!l()&&String(t).trim()&&k()}}function ii(){f&&(tt||(l()&&v(),i.value=""))}function ri(n){f||e||(n?.stopPropagation?.(),yt())}function ei(n,t="Loading"){const r=!!n;if(r===e){e&&o&&(o.textContent=String(t||"Loading"));return}if(w+=1,c&&(clearTimeout(c),c=null),r){e=!0;ct=Date.now();v();u.classList.remove("pi-dropdown--success");u.classList.add("pi-dropdown--loading");i.readOnly=!0;i.setAttribute("readonly","");i.blur?.();y.setAttribute("aria-disabled","true");o&&(o.textContent=String(t||"Loading"));return}const f=w,s=Date.now()-ct,h=Math.max(0,kt-s);c=setTimeout(()=>{f===w&&(u.classList.remove("pi-dropdown--loading"),u.classList.add("pi-dropdown--success"),o&&typeof g=="string"&&g.trim().length>0&&(o.textContent=g),c=setTimeout(()=>{f===w&&(u.classList.remove("pi-dropdown--success"),y.removeAttribute("aria-disabled"),i.readOnly=ui,fi?i.setAttribute("readonly",""):i.removeAttribute("readonly"),e=!1,c=null)},dt))},h)}const rt=t.rootId;if(!rt)return null;const u=document.getElementById(rt);if(!u)return null;r(u,{placeholder:t.placeholder});const i=u.querySelector(".pi-dropdown__search"),y=u.querySelector(".pi-dropdown__toggle"),d=u.querySelector(".pi-dropdown__arrow"),h=u.querySelector(".pi-dropdown__menu"),ut=u.querySelector(".pi-dropdown__input-wrapper");if(!i||!y||!h)return null;const f=t.searchEnabled!==!1,ft=typeof t.maxResults=="number"?t.maxResults:50,p=typeof t.getText=="function"?t.getText:n=>String(n?.text??""),et=typeof t.getValue=="function"?t.getValue:n=>String(n?.value??""),ot=typeof t.getGroup=="function"?t.getGroup:null,wt=typeof t.isDisabled=="function"?t.isDisabled:n=>!!n?.disabled,st=typeof t.onSelect=="function"?t.onSelect:null,bt=typeof t.emptyText=="string"?t.emptyText:"No items found",ht=t.displaySelectedInInput!==undefined?!!t.displaySelectedInInput:!f,kt=typeof t.minLoadingMs=="number"?t.minLoadingMs:500,dt=typeof t.successFlashMs=="number"?t.successFlashMs:220,g=typeof t.successText=="string"?t.successText:"";let a=[],nt="",tt=!1,e=!1,ct=0,w=0,c=null,s=null,o=null,b=null;if(ut){s=document.createElement("div");s.className="pi-dropdown__loading";s.setAttribute("aria-hidden","true");const n=document.createElement("span");n.className="pi-spinner";n.setAttribute("aria-hidden","true");o=document.createElement("span");o.className="pi-dropdown__loading-label";b=document.createElement("span");b.className="pi-dropdown__loading-dots";for(let n=0;n<3;n+=1){const n=document.createElement("span");n.className="pi-dropdown__loading-dot";n.textContent=".";b.appendChild(n)}s.appendChild(o);s.appendChild(b);s.appendChild(n);ut.appendChild(s)}f||(i.readOnly=!0,i.setAttribute("readonly",""));const ui=i.readOnly,fi=i.hasAttribute("readonly");if(f){const t=n.SCPI?.util?.debounce||n.debounce,r=typeof t=="function"?t(pt,150):pt;i.addEventListener("input",r);i.addEventListener("blur",ii)}else i.addEventListener("click",ri);return y.addEventListener("click",n=>{n.stopPropagation(),yt()}),document.addEventListener("click",n=>{u.contains(n.target)||(l()&&v(),f&&(i.value=""))},!0),{setItems:gt,setSelectedValue:ni,setLoading:ei}}const n=globalThis;const t=n.SCPI=n.SCPI||{};t.ui=t.ui||{};t.ui.dropdown={initDropdown:u};n.SCDropdown=n.SCDropdown||t.ui.dropdown}(),function(){function e(n){try{return decodeURIComponent(n)}catch(t){return n}}function i(n){return typeof n!="string"?"":e(String(n).replace(/^C:\\fakepath\\/i,""))}function r(n){return typeof n!="string"||n.length===0?"":n.split("\\").pop().split("/").pop()}function o(n,t={}){const c=n.querySelector(".file-picker-container");if(!c){const h=typeof t.accept=="string"?t.accept:n.getAttribute("data-accept")||"",l=typeof t.placeholderText=="string"?t.placeholderText:n.getAttribute("data-placeholder")||"No file selected",a=typeof t.buttonText=="string"?t.buttonText:n.getAttribute("data-button-text")||"FILE",v=typeof t.selectTitle=="string"?t.selectTitle:n.getAttribute("data-select-title")||"Select file",y=typeof t.clearTitle=="string"?t.clearTitle:n.getAttribute("data-clear-title")||"Clear",i=document.createElement("div");i.className="file-picker-container";const f=document.createElement("input");f.type="file";f.style.display="none";h&&f.setAttribute("accept",h);const e=document.createElement("div");e.className="file-picker-display";const o=document.createElement("span");o.className="filename-text";o.textContent=l;e.appendChild(o);const u=document.createElement("button");u.className="file-picker-button";u.type="button";u.title=v;const s=document.createElement("span");s.className="button-icon";s.textContent=a;u.appendChild(s);const r=document.createElement("button");r.className="file-picker-clear";r.type="button";r.title=y;r.disabled=!0;r.textContent="X";i.appendChild(f);i.appendChild(e);i.appendChild(u);i.appendChild(r);n.replaceChildren(i)}}function u(t={}){function p(n){const t=typeof n=="string"&&n.length>0,i=t?rt==="full"?n:r(n):it;a.textContent=i;a.title=t?n:"";c.disabled=!t}function f(n,t={}){const r=t.persist!==!1,u=!!t.silent,i=typeof n=="string"?n:"";if(s=i,p(s),!u&&b)try{b(s)}catch(f){}if(r&&y){v=!0;try{y(i.length>0?i:null)}finally{setTimeout(()=>{v=!1},50)}}}function nt(){e.value="";f("",{persist:!0})}const w=t.rootId,u=w?document.getElementById(w):null;if(!u)return null;o(u,t);const tt=t.filenameSelector||".filename-text",it=t.placeholderText||"No file selected",h=t.settingsKey,rt=t.displayMode||"basename",b=typeof t.onValueChanged=="function"?t.onValueChanged:null,k=typeof t.initialValue=="string"?t.initialValue:"",e=u.querySelector('input[type="file"]'),d=u.querySelector(".file-picker-button"),c=u.querySelector(".file-picker-clear"),l=u.querySelector(".file-picker-display"),a=l?l.querySelector(tt):null;if(!e||!d||!c||!l||!a)return null;let s="",v=!1,y=null,g=null;return d.addEventListener("click",()=>{e.click()}),e.addEventListener("change",n=>{const t=n?.target?.files?.[0],u=n?.target?.value||"",e=i(u),r=t?.path||e;if(r){f(r,{persist:!0});return}t?.name&&p(t.name)}),c.addEventListener("click",()=>{nt()}),typeof h=="string"&&h.length>0&&n.SDPIComponents?.useSettings&&([g,y]=n.SDPIComponents.useSettings(h,n=>{v||f(typeof n=="string"?n:"",{persist:!1})}),Promise.resolve(g()).then(n=>{f(typeof n=="string"?n:"",{persist:!1})}).catch(()=>{})),p(""),k&&f(k,{persist:!1,silent:!0}),{setValue:f,clear:nt,getValue:()=>s}}function f(t={}){function y(n){const t=typeof n=="string"&&n.length>0,i=t?et==="full"?n:r(n):ft;l.textContent=i;l.title=t?n:"";c.disabled=!t}function f(n,t={}){const r=t.persist!==!1,u=!!t.silent,i=typeof n=="string"?n:"";if(s=i,y(s),!u&&p)try{p(s)}catch(f){}if(r&&v){a=!0;try{v(i.length>0?i:null)}finally{setTimeout(()=>{a=!1},50)}}}function g(){e.value="";f("",{persist:!0})}if(typeof t.rootId=="string"&&t.rootId.length>0)return u(t);const nt=t.inputId,tt=t.buttonId,it=t.clearId,rt=t.displayId,ut=t.filenameSelector||".filename-text",ft=t.placeholderText||"No file selected",h=t.settingsKey,et=t.displayMode||"basename",p=typeof t.onValueChanged=="function"?t.onValueChanged:null,w=typeof t.initialValue=="string"?t.initialValue:"",e=document.getElementById(nt),b=document.getElementById(tt),c=document.getElementById(it),o=document.getElementById(rt),l=o?o.querySelector(ut):null;if(!e||!b||!c||!o||!l)return null;const ot=document.createElement("div"),k=o.closest(".file-picker-container");k&&ot.appendChild(k.cloneNode(!0));let s="",a=!1,v=null,d=null;return b.addEventListener("click",()=>{e.click()}),e.addEventListener("change",n=>{const t=n?.target?.files?.[0],u=n?.target?.value||"",e=i(u),r=t?.path||e;if(r){f(r,{persist:!0});return}t?.name&&y(t.name)}),c.addEventListener("click",()=>{g()}),typeof h=="string"&&h.length>0&&n.SDPIComponents?.useSettings&&([d,v]=n.SDPIComponents.useSettings(h,n=>{a||f(typeof n=="string"?n:"",{persist:!1})}),Promise.resolve(d()).then(n=>{f(typeof n=="string"?n:"",{persist:!1})}).catch(()=>{})),y(""),w&&f(w,{persist:!1,silent:!0}),{setValue:f,clear:g,getValue:()=>s}}const n=globalThis;const t=n.SCPI=n.SCPI||{};t.ui=t.ui||{};t.ui.filePicker={createFilePicker:u,initFilePicker:f};n.SCFilePicker=n.SCFilePicker||{initFilePicker:f}}(),function(){function i(n,t){if(n&&typeof n=="string"){t&&(t.href=`../css/themes/${n}`);try{localStorage.setItem(r,n)}catch(i){}}}function u(r={}){const o=r.rootId||"themeDropdown",s=r.linkId||"pi-theme-styles",u=document.getElementById(s);if(u){let e=[];n.bus?.start?.();const f=n.ui?.dropdown?.initDropdown?.({rootId:o,searchEnabled:!1,displaySelectedInInput:!0,minLoadingMs:500,successFlashMs:220,emptyText:"No themes found",getText:n=>String(n?.name??""),getValue:n=>String(n?.file??""),onSelect:r=>{const f=String(r?.file??"");f&&(i(f,u),(n.bus?.send||n.util?.sendToPlugin||t.sendToPlugin)?.("setTheme",{themeFile:f}))}});f?.setLoading?.(!0,"Loading themes");n.bus?.on?.(n=>{if(n&&(n.themesLoaded&&(e=n.themes||[],f?.setItems?.(e),f?.setLoading?.(!1)),typeof n.selectedTheme=="string"&&n.selectedTheme.length>0)){const t=n.selectedTheme;i(t,u);f?.setSelectedValue?.(t,{rerender:!0})}});try{const t=u.getAttribute("href")||"",n=t.split("/").pop().split("?")[0];n&&(i(n,u),f?.setSelectedValue?.(n,{rerender:!0}))}catch(h){}}}const t=globalThis,n=t.SCPI=t.SCPI||{},r="scsd.selectedTheme";n.theme={initThemeDropdown:u};t.SCTheme=t.SCTheme||n.theme}() diff --git a/PluginCore/PropertyInspector/js/src/pi-dial-action.js b/PluginCore/PropertyInspector/js/src/pi-dial-action.js new file mode 100644 index 0000000..654cbfb --- /dev/null +++ b/PluginCore/PropertyInspector/js/src/pi-dial-action.js @@ -0,0 +1,183 @@ +(function () { + const SCPI = globalThis.SCPI; + SCPI?.bus?.start?.(); + SCPI?.theme?.initThemeDropdown?.(); + + const slotConfigs = [ + {rootId: 'rotateLeftDropdown', settingsKey: 'rotateLeftFunction', slotLabel: 'Rotate Left', idPrefix: 'rotateLeft'}, + {rootId: 'rotateRightDropdown', settingsKey: 'rotateRightFunction', slotLabel: 'Rotate Right', idPrefix: 'rotateRight'}, + {rootId: 'pressDropdown', settingsKey: 'pressFunction', slotLabel: 'Push', idPrefix: 'press'} + ]; + + let allOptions = []; + let isSelectingOption = false; + + const slotStates = slotConfigs.map((config) => { + const dropdown = SCPI?.ui?.dropdown?.initDropdown?.({ + rootId: config.rootId, + searchEnabled: true, + minLoadingMs: 0, + successFlashMs: 100, + emptyText: 'No matching functions found', + maxResults: 50, + getText: (opt) => String(opt?.text ?? ''), + getValue: (opt) => String(opt?.value ?? ''), + getGroup: (opt) => String(opt?.group ?? ''), + isDisabled: (opt) => !!opt?.disabled, + onSelect: (opt) => selectOption(config.settingsKey, opt, {persist: true}) + }); + + dropdown?.setLoading?.(true, 'Loading functions'); + + const [getValue, setValue] = globalThis.SDPIComponents.useSettings( + config.settingsKey, + (value) => { + if (!isSelectingOption) { + syncSelection(config.settingsKey, value); + } + } + ); + + return { + ...config, + dropdown, + getValue, + setValue, + currentValue: '' + }; + }); + + SCPI?.ui?.filePicker?.createFilePicker?.({ + rootId: 'audioFilePicker', + placeholderText: 'No file selected', + settingsKey: 'clickSoundPath' + }); + + function flattenFunctionsData(groups) { + const flatten = SCPI?.functionPicker?.flattenFunctionsData; + if (typeof flatten !== 'function') { + return []; + } + + return flatten(groups, { + requireToggleCandidates: false, + excludeBindingTypes: ['mouseaxis', 'joystick', 'gamepad'] + }); + } + + function populateFunctionsDropdowns(functionsData) { + allOptions = flattenFunctionsData(functionsData); + + slotStates.forEach((slot) => { + slot.dropdown?.setItems?.(allOptions); + slot.dropdown?.setSelectedValue?.(slot.currentValue, {rerender: false}); + + if (slot.currentValue) { + syncSelection(slot.settingsKey, slot.currentValue); + } + }); + } + + function syncSelection(settingsKey, value) { + const slot = slotStates.find((entry) => entry.settingsKey === settingsKey); + if (!slot) { + return; + } + + slot.currentValue = value || ''; + + const opt = allOptions.find((entry) => entry.value === value || entry.legacyValue === value); + if (!opt) { + if (!value) { + updateFunctionDetails(slot, null); + } + return; + } + + const isLegacyMatch = opt.legacyValue === value && opt.value !== value; + selectOption(settingsKey, opt, {persist: isLegacyMatch}); + } + + function selectOption(settingsKey, opt, opts = {}) { + const slot = slotStates.find((entry) => entry.settingsKey === settingsKey); + if (!slot) { + return; + } + + const persist = opts.persist !== false; + + isSelectingOption = true; + slot.currentValue = opt.value; + slot.dropdown?.setSelectedValue?.(opt.value, {rerender: true}); + updateFunctionDetails(slot, opt); + + if (persist) { + slot.setValue(opt.value); + } + + setTimeout(() => { + isSelectingOption = false; + }, 200); + } + + function updateFunctionDetails(slot, opt) { + if (!slot || !slot.idPrefix) { + return; + } + + const updateDetails = SCPI?.functionPicker?.updateFunctionDetails; + if (typeof updateDetails !== 'function') { + return; + } + + const prefix = slot.idPrefix; + updateDetails({ + containerEl: document.getElementById(`${prefix}-details-container`), + titleEl: document.getElementById(`${prefix}-details-title`), + descriptionEl: document.getElementById(`${prefix}-description`), + bindingElements: { + keyboard: document.getElementById(`${prefix}-binding-keyboard`), + mouse: document.getElementById(`${prefix}-binding-mouse`), + gamepad: document.getElementById(`${prefix}-binding-gamepad`), + joystick: document.getElementById(`${prefix}-binding-joystick`) + }, + hideContainerWhenEmpty: true, + showContainerWhenFilled: true, + slotLabel: slot.slotLabel, + selectedOption: opt + }); + } + + SCPI?.bus?.on?.((payload) => { + const loaded = payload?.functionsLoaded; + + if (loaded === true) { + slotStates.forEach((slot) => { + slot.dropdown?.setLoading?.(false); + document.getElementById(slot.rootId)?.classList.remove('pi-dropdown--error'); + }); + + populateFunctionsDropdowns(payload.functions || []); + } + + if (loaded === false) { + slotStates.forEach((slot) => { + document.getElementById(slot.rootId)?.classList.add('pi-dropdown--error'); + slot.dropdown?.setLoading?.(true, 'No installation detected. Set custom path.'); + slot.dropdown?.setItems?.([]); + updateFunctionDetails(slot, null); + }); + } + }); + + SCPI?.util?.onDocumentReady?.(() => { + slotStates.forEach((slot) => { + const savedValue = slot.getValue(); + slot.currentValue = savedValue || ''; + slot.dropdown?.setSelectedValue?.(slot.currentValue, {rerender: false}); + updateFunctionDetails(slot, null); + }); + + SCPI?.bus?.sendOnce?.('pi.connected', 'propertyInspectorConnected'); + }); +})(); diff --git a/PluginCore/PropertyInspector/js/src/pi-function-key.js b/PluginCore/PropertyInspector/js/src/pi-function-key.js index e7b4bdd..02badd6 100644 --- a/PluginCore/PropertyInspector/js/src/pi-function-key.js +++ b/PluginCore/PropertyInspector/js/src/pi-function-key.js @@ -134,58 +134,17 @@ * @returns {Array} - Flattened array of options */ function flattenFunctionsData(groups) { - const flat = []; - const requireToggleCandidates = globalThis.SCPI_REQUIRE_TOGGLE_CANDIDATES === true; - if (Array.isArray(groups)) { - groups.forEach(group => { - const groupName = group.label || 'Other'; - const options = group.options || []; - - options.forEach(opt => { - const bindingType = String(opt.bindingType || '').toLowerCase(); - - if (requireToggleCandidates) { - const isToggleCandidate = opt && opt.details && opt.details.isToggleCandidate === true; - if (!isToggleCandidate) { - return; - } - } - - // Filter out unsupported options - // TODO: When implementing full axis support, stop hiding axis-only options in the PI. - const isUnsupported = bindingType === 'mouseaxis' || bindingType === 'joystick' || bindingType === 'gamepad'; - if (isUnsupported) { - return; - } - - // Keep the original category; unbound actions are shown with a warning indicator. - const category = groupName; - - const disabledReason = String(opt.disabledReason || ''); - const isUnbound = bindingType === 'unbound'; - - // Unbound actions are selectable (so users can bind them later), but still visually flagged. - const isDisabled = !!opt.disabled && !isUnbound; - - flat.push( - { - value: opt.value, - legacyValue: opt.legacyValue, - text: opt.text, - group: category, - details: opt.details, - bindingType, - disabledReason, - unbound: isUnbound, - disabled: isDisabled - }); - }); + const flatten = SCPI?.functionPicker?.flattenFunctionsData; + if (typeof flatten === 'function') { + return flatten(groups, { + requireToggleCandidates, + excludeBindingTypes: ['mouseaxis', 'joystick', 'gamepad'] }); } - return flat; + return []; } /** @@ -260,72 +219,25 @@ return; } - // Show empty state if no option selected - if (!opt || !opt.details) { - const titleEl = detailsEl.querySelector('.pi-details__title'); - if (titleEl) { - titleEl.textContent = ''; - } - - const scContentEl = document.querySelector('.pi-description__content'); - if (scContentEl) { - scContentEl.textContent = ''; - } - - // Clear all binding values - const kbEl = document.getElementById('pi-details__binding-keyboard'); - const mouseEl = document.getElementById('pi-details__binding-mouse'); - const gpEl = document.getElementById('pi-details__binding-gamepad'); - const jsEl = document.getElementById('pi-details__binding-joystick'); - - [kbEl, mouseEl, gpEl, jsEl].forEach((el) => { - if (!el) { - return; - } - el.textContent = ''; - }); + const updateDetails = SCPI?.functionPicker?.updateFunctionDetails; + if (typeof updateDetails !== 'function') { return; } - const details = opt.details; - const title = String(details.label || opt.text || ''); - const desc = String(details.description || ''); - const devices = Array.isArray(details.devices) ? details.devices : []; - - const titleEl = detailsEl.querySelector('.pi-details__title'); - if (titleEl) { - titleEl.textContent = title; - } - - // Update binding values for all four device types - const allDeviceTypes = ['keyboard', 'mouse', 'gamepad', 'joystick']; - - allDeviceTypes.forEach(deviceType => { - const deviceData = devices.find(d => d.device && d.device.toLowerCase() === deviceType.toLowerCase()); - const bindingEl = document.getElementById(`pi-details__binding-${deviceType}`); - - let bindingValue = 'Unbound'; - - if (deviceData && Array.isArray(deviceData.bindings) && deviceData.bindings.length > 0) { - const bindingLines = deviceData.bindings - .map(b => String(b.display || b.raw || '')) - .filter(x => x); - - if (bindingLines.length > 0) { - bindingValue = bindingLines.join(', '); - } - } - - if (bindingEl) { - bindingEl.textContent = bindingValue; - } + updateDetails({ + containerEl: detailsEl, + titleEl: detailsEl.querySelector('.pi-details__title'), + descriptionEl: document.querySelector('.pi-description__content'), + bindingElements: { + keyboard: document.getElementById('pi-details__binding-keyboard'), + mouse: document.getElementById('pi-details__binding-mouse'), + gamepad: document.getElementById('pi-details__binding-gamepad'), + joystick: document.getElementById('pi-details__binding-joystick') + }, + hideContainerWhenEmpty: false, + showContainerWhenFilled: false, + selectedOption: opt }); - - // Update Description content - const scContentEl = document.querySelector('.pi-description__content'); - if (scContentEl) { - scContentEl.textContent = desc || 'No description available.'; - } } // #endregion diff --git a/PluginCore/PropertyInspector/js/src/sc-common.js b/PluginCore/PropertyInspector/js/src/sc-common.js index a587bc3..c630c64 100644 --- a/PluginCore/PropertyInspector/js/src/sc-common.js +++ b/PluginCore/PropertyInspector/js/src/sc-common.js @@ -55,3 +55,169 @@ root.sendToPlugin = sendToPlugin; } })(); + +(function () { + const root = globalThis; + const SCPI = root.SCPI = root.SCPI || {}; + const functionPicker = SCPI.functionPicker = SCPI.functionPicker || {}; + + /** + * Flatten grouped function payload data into a dropdown-friendly list. + * + * Supports two key filters: + * - `requireToggleCandidates`: keep only functions that can be used by Toggle Key. + * - `excludeBindingTypes`: hide binding types that a PI context cannot execute. + * + * @param {Array} groups + * @param {{ requireToggleCandidates?: boolean, excludeBindingTypes?: string[] }} options + * @returns {Array} + */ + function flattenFunctionsData(groups, options = {}) { + const flat = []; + const requireToggleCandidates = options.requireToggleCandidates === true; + const excluded = new Set((options.excludeBindingTypes || []).map((t) => String(t || '').toLowerCase())); + + if (!Array.isArray(groups)) { + return flat; + } + + groups.forEach((group) => { + const groupName = group?.label || 'Other'; + const entries = Array.isArray(group?.options) ? group.options : []; + + entries.forEach((opt) => { + const bindingType = String(opt?.bindingType || '').toLowerCase(); + + if (requireToggleCandidates) { + const isToggleCandidate = opt && opt.details && opt.details.isToggleCandidate === true; + if (!isToggleCandidate) { + return; + } + } + + // Filter out unsupported options. + // TODO: When implementing full axis support, stop hiding axis-only options in the PI. + if (excluded.has(bindingType)) { + return; + } + + const disabledReason = String(opt?.disabledReason || ''); + const isUnbound = bindingType === 'unbound'; + + // Keep the original category; unbound actions are shown with a warning indicator. + // Unbound actions are selectable (so users can bind them later), but still visually flagged. + const isDisabled = !!opt?.disabled && !isUnbound; + + flat.push({ + value: opt?.value, + legacyValue: opt?.legacyValue, + text: opt?.text, + group: groupName, + details: opt?.details, + bindingType, + disabledReason, + unbound: isUnbound, + disabled: isDisabled + }); + }); + }); + + return flat; + } + + /** + * Render function metadata and per-device bindings into a details panel. + * + * The caller controls whether the details container is hidden for empty + * state and shown again when an option is selected. + * + * @param {{ + * containerEl?: HTMLElement | null, + * titleEl?: HTMLElement | null, + * descriptionEl?: HTMLElement | null, + * bindingElements?: Object, + * hideContainerWhenEmpty?: boolean, + * showContainerWhenFilled?: boolean, + * selectedOption?: Object | null, + * slotLabel?: string + * }} options + */ + function updateFunctionDetails(options = {}) { + const containerEl = options.containerEl || null; + const titleEl = options.titleEl || null; + const descriptionEl = options.descriptionEl || null; + const bindingElements = options.bindingElements || {}; + const hideContainerWhenEmpty = options.hideContainerWhenEmpty === true; + const showContainerWhenFilled = options.showContainerWhenFilled === true; + const selectedOption = options.selectedOption || null; + const slotLabel = String(options.slotLabel || '').trim(); + + const devices = ['keyboard', 'mouse', 'gamepad', 'joystick']; + + // Empty state: clear all displayed values to avoid stale details. + if (!selectedOption || !selectedOption.details) { + if (hideContainerWhenEmpty && containerEl) { + containerEl.style.display = 'none'; + } + + if (titleEl) { + titleEl.textContent = ''; + } + + if (descriptionEl) { + descriptionEl.textContent = ''; + } + + devices.forEach((device) => { + const el = bindingElements[device] || null; + if (el) { + el.textContent = ''; + } + }); + + return; + } + + if (showContainerWhenFilled && containerEl) { + containerEl.style.display = 'block'; + } + + const details = selectedOption.details; + const title = String(details.label || selectedOption.text || ''); + const description = String(details.description || ''); + const detailDevices = Array.isArray(details.devices) ? details.devices : []; + + if (titleEl) { + titleEl.textContent = slotLabel.length > 0 ? `${slotLabel}: ${title}` : title; + } + + // Update binding values for all four device types. + devices.forEach((deviceType) => { + const deviceData = detailDevices.find((d) => d.device && d.device.toLowerCase() === deviceType); + const bindingEl = bindingElements[deviceType] || null; + + let bindingValue = 'Unbound'; + if (deviceData && Array.isArray(deviceData.bindings) && deviceData.bindings.length > 0) { + const bindingLines = deviceData.bindings + .map((binding) => String(binding.display || binding.raw || '')) + .filter((text) => text); + + if (bindingLines.length > 0) { + bindingValue = bindingLines.join(', '); + } + } + + if (bindingEl) { + bindingEl.textContent = bindingValue; + } + }); + + // Update Description content. + if (descriptionEl) { + descriptionEl.textContent = description || 'No description available.'; + } + } + + functionPicker.flattenFunctionsData = flattenFunctionsData; + functionPicker.updateFunctionDetails = updateFunctionDetails; +})(); diff --git a/PluginCore/Services/Keybinding/ActivationHandlers/ActivationModeHandlers.cs b/PluginCore/Services/Keybinding/ActivationHandlers/ActivationModeHandlers.cs index 721c134..1e10829 100644 --- a/PluginCore/Services/Keybinding/ActivationHandlers/ActivationModeHandlers.cs +++ b/PluginCore/Services/Keybinding/ActivationHandlers/ActivationModeHandlers.cs @@ -23,6 +23,12 @@ internal sealed class ImmediatePressHandler(Func? utcNow = null) : IAc /// private readonly ConcurrentDictionary _keyDownTimes = new(StringComparer.OrdinalIgnoreCase); + /// + /// Mouse wheel inputs are special: some Star Citizen actions are configured as ActivationMode.press but should + /// still repeat while the Stream Deck key is physically held. + /// + private readonly ConcurrentDictionary _mouseWheelHoldActions = new(StringComparer.OrdinalIgnoreCase); + public IReadOnlyCollection SupportedModes => [ ActivationMode.press, @@ -40,8 +46,9 @@ public bool Execute(ActivationExecutionContext context, IInputExecutor executor) private bool HandleKeyDown(ActivationExecutionContext context, IInputExecutor executor) { - // Clear any previous activation block for this action (new key press cycle) + // Clear any previous activation block for this action _activationBlocks.TryRemove(context.ActionName, out _); + _mouseWheelHoldActions.TryRemove(context.ActionName, out _); // Record KeyDown time for release-threshold logic (tap modes). _keyDownTimes[context.ActionName] = _utcNow(); @@ -51,9 +58,22 @@ private bool HandleKeyDown(ActivationExecutionContext context, IInputExecutor ex return true; // Ignore KeyDown, wait for KeyUp } - bool result = context.Metadata.Retriggerable - ? executor.ExecuteDown(context.Input, context.ActionName) - : executor.ExecutePressNoRepeat(context.Input); + bool shouldRepeatMouseWheel = + context.Input.Type == InputType.MouseWheel && + (context.Mode == ActivationMode.press || context.Mode == ActivationMode.press_quicker); + + bool result; + if (shouldRepeatMouseWheel) + { + _mouseWheelHoldActions[context.ActionName] = true; + result = executor.ExecuteDown(context.Input, context.ActionName); + } + else + { + result = context.Metadata.Retriggerable + ? executor.ExecuteDown(context.Input, context.ActionName) + : executor.ExecutePressNoRepeat(context.Input); + } // If MultiTapBlock is set, block subsequent OnRelease execution if (context.Metadata.MultiTapBlock > 0) @@ -71,6 +91,11 @@ private bool HandleKeyUp(ActivationExecutionContext context, IInputExecutor exec _keyDownTimes.TryRemove(context.ActionName, out DateTime keyDownTime); + if (_mouseWheelHoldActions.TryRemove(context.ActionName, out _)) + { + return executor.ExecuteUp(context.Input, context.ActionName); + } + // If OnRelease is true AND not blocked by MultiTapBlock, execute additional release press if (context.Metadata.OnRelease && !wasBlocked) { diff --git a/PluginCore/Services/Keybinding/ActivationHandlers/KeybindingInputExecutor.cs b/PluginCore/Services/Keybinding/ActivationHandlers/KeybindingInputExecutor.cs index 0a878ac..35a49ce 100644 --- a/PluginCore/Services/Keybinding/ActivationHandlers/KeybindingInputExecutor.cs +++ b/PluginCore/Services/Keybinding/ActivationHandlers/KeybindingInputExecutor.cs @@ -45,8 +45,7 @@ public bool ExecutePress(ParsedInput input) return true; case InputType.MouseButton: - VirtualKeyCode button = (VirtualKeyCode)input.Value; - ExecuteMouseButtonClick(button); + ExecuteMouseButtonClick(input.Value); return true; case InputType.MouseWheel: @@ -69,7 +68,7 @@ public bool ExecutePressNoRepeat(ParsedInput input) { if (modifiers.Length > 0) { - ExecuteModifiedKeyPress(key, modifiers); + _inputSimulator.Keyboard.DelayedModifiedKeyStroke(modifiers, key, 50); } else { @@ -80,8 +79,7 @@ public bool ExecutePressNoRepeat(ParsedInput input) return true; case InputType.MouseButton: - VirtualKeyCode button = (VirtualKeyCode)input.Value; - ExecuteMouseButtonClick(button); + ExecuteMouseButtonClick(input.Value); return true; case InputType.MouseWheel: @@ -106,8 +104,7 @@ public bool ExecuteDown(ParsedInput input, string actionKey) return ExecuteKeyboardDown(input.Value, actionKey); case InputType.MouseButton: - VirtualKeyCode button = (VirtualKeyCode)input.Value; - ExecuteMouseButtonDown(button); + ExecuteMouseButtonDown(input.Value); return true; case InputType.MouseWheel: @@ -137,8 +134,7 @@ public bool ExecuteUp(ParsedInput input, string actionKey) return ExecuteKeyboardUp(input.Value); case InputType.MouseButton: - VirtualKeyCode button = (VirtualKeyCode)input.Value; - ExecuteMouseButtonUp(button); + ExecuteMouseButtonUp(input.Value); return true; case InputType.MouseWheel: @@ -192,16 +188,6 @@ public void CancelDelayedHold(string actionKey) } } - private void ExecuteModifiedKeyPress(DirectInputKeyCode key, DirectInputKeyCode[] modifiers) - { - PressModifiers(modifiers); - Thread.Sleep(20); - _inputSimulator.Keyboard.KeyDown(key); - Thread.Sleep(30); - _inputSimulator.Keyboard.KeyUp(key); - ReleaseModifiers(modifiers); - } - private void PressModifiers(DirectInputKeyCode[] modifiers) { foreach (DirectInputKeyCode modifier in modifiers) @@ -244,14 +230,11 @@ private bool ExecuteKeyboardDown(object keyboardValue, string actionKey) if (modifiers.Length > 0) { PressModifiers(modifiers); - Timer repeatTimer = new(_ => + + foreach (DirectInputKeyCode key in keys) { - foreach (DirectInputKeyCode key in keys) - { - _inputSimulator.Keyboard.DelayedKeyPress(key, 50); - } - }, null, 0, 200); - _activationTimers[actionKey] = repeatTimer; + _inputSimulator.Keyboard.KeyDown(key); + } } else { @@ -287,14 +270,12 @@ private bool ExecuteKeyboardUp(object keyboardValue) (DirectInputKeyCode[] modifiers, DirectInputKeyCode[] keys) = ((DirectInputKeyCode[], DirectInputKeyCode[]))keyboardValue; - if (modifiers.Length == 0) + foreach (DirectInputKeyCode key in keys) { - foreach (DirectInputKeyCode key in keys) - { - _inputSimulator.Keyboard.KeyUp(key); - } + _inputSimulator.Keyboard.KeyUp(key); } - else + + if (modifiers.Length > 0) { ReleaseModifiers(modifiers); } @@ -323,6 +304,22 @@ private void CancelExistingTimer(string actionKey) #region Mouse Button Helpers + private void ExecuteMouseButtonClick(object mouseValue) + { + if (mouseValue is ValueTuple mouseWithModifiers) + { + (DirectInputKeyCode[] modifiers, VirtualKeyCode btn) = mouseWithModifiers; + PressModifiers(modifiers); + Thread.Sleep(20); + ExecuteMouseButtonClick(btn); + Thread.Sleep(50); + ReleaseModifiers(modifiers); + return; + } + + ExecuteMouseButtonClick((VirtualKeyCode)mouseValue); + } + private void ExecuteMouseButtonClick(VirtualKeyCode button) { switch (button) @@ -345,6 +342,20 @@ private void ExecuteMouseButtonClick(VirtualKeyCode button) } } + private void ExecuteMouseButtonDown(object mouseValue) + { + if (mouseValue is ValueTuple mouseWithModifiers) + { + (DirectInputKeyCode[] modifiers, VirtualKeyCode btn) = mouseWithModifiers; + PressModifiers(modifiers); + Thread.Sleep(20); + ExecuteMouseButtonDown(btn); + return; + } + + ExecuteMouseButtonDown((VirtualKeyCode)mouseValue); + } + private void ExecuteMouseButtonDown(VirtualKeyCode button) { switch (button) @@ -367,6 +378,20 @@ private void ExecuteMouseButtonDown(VirtualKeyCode button) } } + private void ExecuteMouseButtonUp(object mouseValue) + { + if (mouseValue is ValueTuple mouseWithModifiers) + { + (DirectInputKeyCode[] modifiers, VirtualKeyCode btn) = mouseWithModifiers; + ExecuteMouseButtonUp(btn); + Thread.Sleep(20); + ReleaseModifiers(modifiers); + return; + } + + ExecuteMouseButtonUp((VirtualKeyCode)mouseValue); + } + private void ExecuteMouseButtonUp(VirtualKeyCode button) { switch (button) diff --git a/PluginCore/Services/Keybinding/KeybindingParserService.cs b/PluginCore/Services/Keybinding/KeybindingParserService.cs index 1c542aa..4a693a7 100644 --- a/PluginCore/Services/Keybinding/KeybindingParserService.cs +++ b/PluginCore/Services/Keybinding/KeybindingParserService.cs @@ -68,6 +68,11 @@ public static class KeybindingParserService return mouseWheelResult; } + if (TryParseMouseButtonWithModifiers(normalized, out ParsedInputResult? mouseButtonWithModifiersResult)) + { + return mouseButtonWithModifiersResult; + } + if (TryParseMouseButton(normalized, out VirtualKeyCode mouseButton)) { return new ParsedInputResult(InputType.MouseButton, mouseButton); @@ -206,6 +211,41 @@ private static bool TryParseMouseButton(string normalized, out VirtualKeyCode bu return false; } + private static bool TryParseMouseButtonWithModifiers(string normalized, out ParsedInputResult? result) + { + result = null; + + if (!normalized.Contains('+', StringComparison.Ordinal)) + { + return false; + } + + List modifierList = []; + VirtualKeyCode? button = null; + + foreach (string token in SplitTokens(normalized)) + { + if (TryParseModifier(token, out DirectInputKeyCode modifier)) + { + modifierList.Add(modifier); + continue; + } + + if (TryParseMouseButton(token, out VirtualKeyCode mouseButton)) + { + button = mouseButton; + } + } + + if (modifierList.Count == 0 || button == null) + { + return false; + } + + result = new ParsedInputResult(InputType.MouseButton, (modifierList.ToArray(), button.Value)); + return true; + } + private static bool TryParseKeyboard(string scBinding, out DirectInputKeyCode[] modifiers, out DirectInputKeyCode[] keys) { modifiers = []; diff --git a/PluginCore/manifest.json b/PluginCore/manifest.json index 641689f..8ea0a73 100644 --- a/PluginCore/manifest.json +++ b/PluginCore/manifest.json @@ -3,6 +3,9 @@ { "Icon": "Images/icon", "Name": "Adaptive Key", + "Controllers": [ + "Keypad" + ], "States": [ { "Image": "PropertyInspector/svg/key", @@ -15,9 +18,37 @@ "UUID": "com.jarex985.scstreamdeck.adaptivekey", "PropertyInspectorPath": "PropertyInspector/Keys/AdaptiveKey.html" }, + { + "Icon": "Images/icon", + "Name": "Adaptive Dial", + "Controllers": [ + "Encoder" + ], + "Encoder": { + "layout": "$B1", + "TriggerDescription": { + "Rotate": "Execute left or right function", + "Push": "Execute push function" + } + }, + "States": [ + { + "Image": "PropertyInspector/svg/key", + "TitleAlignment": "middle", + "FontSize": 12 + } + ], + "SupportedInMultiActions": false, + "Tooltip": "Star Citizen Adaptive Dial", + "UUID": "com.jarex985.scstreamdeck.adaptivedial", + "PropertyInspectorPath": "PropertyInspector/Dials/AdaptiveDial.html" + }, { "Icon": "Images/icon", "Name": "Toggle Key", + "Controllers": [ + "Keypad" + ], "States": [ { "Image": "PropertyInspector/svg/key", @@ -39,6 +70,9 @@ { "Icon": "Images/icon", "Name": "Control Panel", + "Controllers": [ + "Keypad" + ], "States": [ { "Image": "PropertyInspector/svg/key", @@ -59,7 +93,7 @@ "Description": "Plugin that provides bindable actions for Star Citizen via Stream Deck.\nThis Plugin is not endorsed by or affiliated with the Cloud Imperium or Roberts Space Industries group of companies. All game content and materials are copyright Cloud Imperium Rights LLC and Cloud Imperium Rights Ltd.. Star Citizen®,Squadron 42®, Roberts Space Industries®, and Cloud Imperium® are registered trademarks of Cloud Imperium Rights LLC. All rights reserved.", "Icon": "Images/pluginIcon", "URL": "https://github.com/Jarex985/SCStreamDeck", - "Version": "1.1.2.0", + "Version": "1.2.0.0", "CodePath": "com.jarex985.scstreamdeck.exe", "Category": "Star Citizen Stream Deck", "CategoryIcon": "Images/categoryIcon", diff --git a/README.md b/README.md index 63e7a18..5ffb38c 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,7 @@ Report bugs / feature requests: https://github.com/Jarex985/SCStreamDeck/issues - **Adaptive Key**: A key that executes keybindings based on the activation mode for a given binding. - *Example:* Two in-game bindings (Tap vs Hold) on `Num-` executes only the Tap function when this is the assigned function. +- **Adaptive Dial** *(Stream Deck+)*: A dial that maps separate Star Citizen functions to rotate left, rotate right, and dial push. Rotation fires the assigned function once per tick; push respects the action's activation mode. - **Toggle Key**: A key that toggles between two states (e.g., landing gear up/down). Can be reset to match the current in-game state on de-sync. - **Control Panel Key**: A dedicated key for managing global plugin settings such as themes, channel selection, and installation paths. - **Auto-Detection of Star Citizen Installation Path**: Automatically detects the installation path of Star Citizen. diff --git a/Tests/Unit/ActionKeys/AdaptiveDialTests.cs b/Tests/Unit/ActionKeys/AdaptiveDialTests.cs new file mode 100644 index 0000000..5afc88e --- /dev/null +++ b/Tests/Unit/ActionKeys/AdaptiveDialTests.cs @@ -0,0 +1,328 @@ +using FluentAssertions; +using Newtonsoft.Json.Linq; +using SCStreamDeck.ActionKeys; +using SCStreamDeck.ActionKeys.Settings; +using SCStreamDeck.Models; + +namespace Tests.Unit.ActionKeys; + +public sealed class AdaptiveDialTests +{ + private static KeybindingAction CreateAction( + string? keyboardBinding = null, + string? mouseBinding = null) => + new() + { + ActionName = "test_action", + MapName = "Gameplay", + MapLabel = "Gameplay", + UiLabel = "Test Action", + UiDescription = "Test Description", + UiCategory = "Flight", + KeyboardBinding = keyboardBinding ?? string.Empty, + MouseBinding = mouseBinding ?? string.Empty, + JoystickBinding = string.Empty, + GamepadBinding = string.Empty, + ActivationMode = ActivationMode.press + }; + + #region ResolveRotationFunction + + [Fact] + public void ResolveRotationFunction_ReturnsRotateRight_ForPositiveTicks() + { + DialSettings settings = new() + { + RotateLeftFunction = "left_function", + RotateRightFunction = "right_function" + }; + + string? result = AdaptiveDial.ResolveRotationFunction(settings, 3); + + result.Should().Be("right_function"); + } + + [Fact] + public void ResolveRotationFunction_ReturnsRotateLeft_ForNegativeTicks() + { + DialSettings settings = new() + { + RotateLeftFunction = "left_function", + RotateRightFunction = "right_function" + }; + + string? result = AdaptiveDial.ResolveRotationFunction(settings, -2); + + result.Should().Be("left_function"); + } + + [Fact] + public void ResolveRotationFunction_ReturnsNull_ForZeroTicks() + { + DialSettings settings = new() + { + RotateLeftFunction = "left_function", + RotateRightFunction = "right_function" + }; + + string? result = AdaptiveDial.ResolveRotationFunction(settings, 0); + + result.Should().BeNull(); + } + + [Theory] + [InlineData(1)] + [InlineData(10)] + [InlineData(int.MaxValue)] + public void ResolveRotationFunction_ReturnsRightFunction_ForAnyPositiveTicks(int ticks) + { + DialSettings settings = new() { RotateRightFunction = "right" }; + + string? result = AdaptiveDial.ResolveRotationFunction(settings, ticks); + + result.Should().Be("right"); + } + + [Theory] + [InlineData(-1)] + [InlineData(-10)] + [InlineData(int.MinValue)] + public void ResolveRotationFunction_ReturnsLeftFunction_ForAnyNegativeTicks(int ticks) + { + DialSettings settings = new() { RotateLeftFunction = "left" }; + + string? result = AdaptiveDial.ResolveRotationFunction(settings, ticks); + + result.Should().Be("left"); + } + + [Fact] + public void ResolveRotationFunction_ReturnsNull_WhenRightFunctionIsNull_AndPositiveTicks() + { + DialSettings settings = new() { RotateLeftFunction = "left" }; + + string? result = AdaptiveDial.ResolveRotationFunction(settings, 1); + + result.Should().BeNull(); + } + + [Fact] + public void ResolveRotationFunction_ReturnsNull_WhenLeftFunctionIsNull_AndNegativeTicks() + { + DialSettings settings = new() { RotateRightFunction = "right" }; + + string? result = AdaptiveDial.ResolveRotationFunction(settings, -1); + + result.Should().BeNull(); + } + + [Fact] + public void ResolveRotationFunction_ThrowsArgumentNullException_ForNullSettings() + { + Action act = () => AdaptiveDial.ResolveRotationFunction(null!, 1); + + act.Should().Throw(); + } + + #endregion + + #region ResolveExecutableBinding + + [Fact] + public void ResolveExecutableBinding_PrefersKeyboardBinding_WhenPresent() + { + KeybindingAction action = CreateAction(keyboardBinding: "f1", mouseBinding: "mouse1"); + + string? result = AdaptiveDial.ResolveExecutableBinding(action); + + result.Should().Be("f1"); + } + + [Fact] + public void ResolveExecutableBinding_ReturnsMouseButton_WhenNoKeyboardBindingExists() + { + KeybindingAction action = CreateAction(mouseBinding: "mouse1"); + + string? result = AdaptiveDial.ResolveExecutableBinding(action); + + result.Should().Be("mouse1"); + } + + [Fact] + public void ResolveExecutableBinding_ReturnsMouseWheel_WhenNoKeyboardBindingExists() + { + KeybindingAction action = CreateAction(mouseBinding: "mwheel_up"); + + string? result = AdaptiveDial.ResolveExecutableBinding(action); + + result.Should().Be("mwheel_up"); + } + + [Fact] + public void ResolveExecutableBinding_ReturnsNull_ForUnsupportedAxisBinding() + { + KeybindingAction action = CreateAction(mouseBinding: "maxis_x"); + + string? result = AdaptiveDial.ResolveExecutableBinding(action); + + result.Should().BeNull(); + } + + [Fact] + public void ResolveExecutableBinding_ReturnsNull_WhenNoSupportedBindingExists() + { + KeybindingAction action = CreateAction(); + + string? result = AdaptiveDial.ResolveExecutableBinding(action); + + result.Should().BeNull(); + } + + [Theory] + [InlineData("mouse1")] + [InlineData("mouse2")] + [InlineData("mouse3")] + public void ResolveExecutableBinding_ReturnsMouseButton_ForAllMouseButtonVariants(string mouseBinding) + { + KeybindingAction action = CreateAction(mouseBinding: mouseBinding); + + string? result = AdaptiveDial.ResolveExecutableBinding(action); + + result.Should().Be(mouseBinding); + } + + [Theory] + [InlineData("mwheel_up")] + [InlineData("mwheel_down")] + public void ResolveExecutableBinding_ReturnsMouseWheel_ForAllWheelVariants(string mouseBinding) + { + KeybindingAction action = CreateAction(mouseBinding: mouseBinding); + + string? result = AdaptiveDial.ResolveExecutableBinding(action); + + result.Should().Be(mouseBinding); + } + + [Theory] + [InlineData("maxis_x")] + [InlineData("maxis_y")] + public void ResolveExecutableBinding_ReturnsNull_ForAllMouseAxisVariants(string mouseBinding) + { + KeybindingAction action = CreateAction(mouseBinding: mouseBinding); + + string? result = AdaptiveDial.ResolveExecutableBinding(action); + + result.Should().BeNull(); + } + + [Theory] + [InlineData("f1")] + [InlineData("a")] + [InlineData("lshift+space")] + [InlineData("lctrl+alt+f4")] + public void ResolveExecutableBinding_ReturnsKeyboardBinding_ForVariousKeyboardCombos(string keyboardBinding) + { + KeybindingAction action = CreateAction(keyboardBinding: keyboardBinding); + + string? result = AdaptiveDial.ResolveExecutableBinding(action); + + result.Should().Be(keyboardBinding); + } + + [Fact] + public void ResolveExecutableBinding_ThrowsArgumentNullException_ForNullAction() + { + Action act = () => AdaptiveDial.ResolveExecutableBinding(null!); + + act.Should().Throw(); + } + + #endregion + + #region DialSettings + + [Fact] + public void DialSettings_DefaultValues_AreAllNull() + { + DialSettings settings = new(); + + settings.RotateLeftFunction.Should().BeNull(); + settings.RotateRightFunction.Should().BeNull(); + settings.PressFunction.Should().BeNull(); + settings.ClickSoundPath.Should().BeNull(); + } + + [Fact] + public void DialSettings_SerializesAndDeserializes_UsingExpectedPropertyNames() + { + DialSettings settings = new() + { + RotateLeftFunction = "left_function", + RotateRightFunction = "right_function", + PressFunction = "press_function", + ClickSoundPath = @"C:\\Audio\\click.wav" + }; + + JObject json = JObject.FromObject(settings); + DialSettings? roundTripped = json.ToObject(); + + json.Value("rotateLeftFunction").Should().Be("left_function"); + json.Value("rotateRightFunction").Should().Be("right_function"); + json.Value("pressFunction").Should().Be("press_function"); + json.Value("clickSoundPath").Should().Be(@"C:\\Audio\\click.wav"); + roundTripped.Should().NotBeNull(); + roundTripped!.RotateLeftFunction.Should().Be("left_function"); + roundTripped.RotateRightFunction.Should().Be("right_function"); + roundTripped.PressFunction.Should().Be("press_function"); + roundTripped.ClickSoundPath.Should().Be(@"C:\\Audio\\click.wav"); + } + + [Fact] + public void DialSettings_DeserializesPartialJson_LeavingMissingPropertiesNull() + { + JObject json = new() { ["rotateLeftFunction"] = "only_left" }; + + DialSettings? settings = json.ToObject(); + + settings.Should().NotBeNull(); + settings!.RotateLeftFunction.Should().Be("only_left"); + settings.RotateRightFunction.Should().BeNull(); + settings.PressFunction.Should().BeNull(); + settings.ClickSoundPath.Should().BeNull(); + } + + [Fact] + public void DialSettings_SerializesNullProperties_AsJsonNulls() + { + DialSettings settings = new() { RotateLeftFunction = "left" }; + + JObject json = JObject.FromObject(settings); + + json["rotateLeftFunction"]!.Value().Should().Be("left"); + json["rotateRightFunction"]!.Type.Should().Be(JTokenType.Null); + json["pressFunction"]!.Type.Should().Be(JTokenType.Null); + json["clickSoundPath"]!.Type.Should().Be(JTokenType.Null); + } + + [Fact] + public void DialSettings_RoundTrip_EmptyStringValues_ArePreserved() + { + DialSettings settings = new() + { + RotateLeftFunction = "", + RotateRightFunction = "", + PressFunction = "", + ClickSoundPath = "" + }; + + JObject json = JObject.FromObject(settings); + DialSettings? roundTripped = json.ToObject(); + + roundTripped!.RotateLeftFunction.Should().Be(""); + roundTripped.RotateRightFunction.Should().Be(""); + roundTripped.PressFunction.Should().Be(""); + roundTripped.ClickSoundPath.Should().Be(""); + } + + #endregion +} diff --git a/Tests/Unit/Services/Keybinding/ActivationHandlers/ImmediatePressHandlerTests.cs b/Tests/Unit/Services/Keybinding/ActivationHandlers/ImmediatePressHandlerTests.cs index aa7c766..b6d8cf7 100644 --- a/Tests/Unit/Services/Keybinding/ActivationHandlers/ImmediatePressHandlerTests.cs +++ b/Tests/Unit/Services/Keybinding/ActivationHandlers/ImmediatePressHandlerTests.cs @@ -6,6 +6,61 @@ namespace Tests.Unit.Services.Keybinding.ActivationHandlers; public sealed class ImmediatePressHandlerTests { + [Fact] + public void Execute_MouseWheel_Press_RepeatsWhileHeld_UsingDownUp() + { + ImmediatePressHandler handler = new(); + RecordingInputExecutor exec = new(); + + ActivationModeMetadata metadata = new() + { + OnPress = true, + OnRelease = false, + Retriggerable = false, + MultiTapBlock = 0, + ReleaseTriggerDelay = 0, + ReleaseTriggerThreshold = 0 + }; + + ActivationExecutionContext downCtx = Ctx(true, ActivationMode.press, InputType.MouseWheel, metadata); + ActivationExecutionContext upCtx = Ctx(false, ActivationMode.press, InputType.MouseWheel, metadata); + + handler.Execute(downCtx, exec).Should().BeTrue(); + handler.Execute(upCtx, exec).Should().BeTrue(); + + exec.DownCount.Should().Be(1); + exec.UpCount.Should().Be(1); + exec.PressNoRepeatCount.Should().Be(0); + } + + [Fact] + public void Execute_MouseWheel_PressQuicker_RepeatsWhileHeld_UsingDownUp_AndDoesNotPressOnRelease() + { + ImmediatePressHandler handler = new(); + RecordingInputExecutor exec = new(); + + ActivationModeMetadata metadata = new() + { + OnPress = true, + OnRelease = true, + Retriggerable = false, + MultiTapBlock = 0, + ReleaseTriggerDelay = 0, + ReleaseTriggerThreshold = 0.15f + }; + + ActivationExecutionContext downCtx = Ctx(true, ActivationMode.press_quicker, InputType.MouseWheel, metadata); + ActivationExecutionContext upCtx = Ctx(false, ActivationMode.press_quicker, InputType.MouseWheel, metadata); + + handler.Execute(downCtx, exec).Should().BeTrue(); + handler.Execute(upCtx, exec).Should().BeTrue(); + + exec.DownCount.Should().Be(1); + exec.UpCount.Should().Be(1); + exec.PressNoRepeatCount.Should().Be(0); + exec.ScheduledPresses.Should().BeEmpty(); + } + [Fact] public void Execute_OnRelease_IsBlocked_WhenMultiTapBlockWasSetOnPress() { @@ -153,12 +208,15 @@ public void Execute_OnRelease_WithReleaseTriggerThreshold_DoesNotExecute_WhenHel exec.ScheduledPresses.Should().BeEmpty(); } - private static ActivationExecutionContext Ctx(bool isKeyDown, ActivationModeMetadata metadata) => new() + private static ActivationExecutionContext Ctx(bool isKeyDown, ActivationModeMetadata metadata) => + Ctx(isKeyDown, ActivationMode.press, InputType.Keyboard, metadata); + + private static ActivationExecutionContext Ctx(bool isKeyDown, ActivationMode mode, InputType inputType, ActivationModeMetadata metadata) => new() { ActionName = "TestAction", - Input = new ParsedInput { Type = InputType.Keyboard, Value = new object() }, + Input = new ParsedInput { Type = inputType, Value = new object() }, IsKeyDown = isKeyDown, - Mode = ActivationMode.press, + Mode = mode, Metadata = metadata }; diff --git a/Tests/Unit/Services/Keybinding/ActivationHandlers/KeybindingInputExecutorTests.cs b/Tests/Unit/Services/Keybinding/ActivationHandlers/KeybindingInputExecutorTests.cs index e80af30..e7bcbb2 100644 --- a/Tests/Unit/Services/Keybinding/ActivationHandlers/KeybindingInputExecutorTests.cs +++ b/Tests/Unit/Services/Keybinding/ActivationHandlers/KeybindingInputExecutorTests.cs @@ -50,6 +50,29 @@ public void ExecutePress_Keyboard_WithModifiers_UsesDelayedModifiedKeyStroke() 50), Times.Once); } + [Fact] + public void ExecutePressNoRepeat_Keyboard_WithModifiers_UsesDelayedModifiedKeyStroke() + { + (KeybindingInputExecutor executor, _, Mock keyboard) = CreateExecutor(); + + DirectInputKeyCode[] modifiers = [DirectInputKeyCode.DikLalt]; + ParsedInput input = new() { Type = InputType.Keyboard, Value = (modifiers, new[] { DirectInputKeyCode.DikO }) }; + + keyboard + .Setup(k => k.DelayedModifiedKeyStroke( + It.Is>(m => m.SequenceEqual(modifiers)), + DirectInputKeyCode.DikO, + 50)) + .Returns(keyboard.Object); + + executor.ExecutePressNoRepeat(input).Should().BeTrue(); + + keyboard.Verify(k => k.DelayedModifiedKeyStroke( + It.Is>(m => m.SequenceEqual(modifiers)), + DirectInputKeyCode.DikO, + 50), Times.Once); + } + [Fact] public void ExecutePressNoRepeat_MouseButton_LeftButton_InvokesMouseClick() { @@ -78,6 +101,29 @@ public void ExecutePress_MouseButton_MiddleButton_InvokesMouseClick() mouse.Verify(m => m.MiddleButtonClick(), Times.Once); } + [Fact] + public void ExecutePressNoRepeat_MouseButton_WithModifiers_PressesAndReleasesModifiersAroundClick() + { + (KeybindingInputExecutor executor, Mock mouse, Mock keyboard) = CreateExecutor(); + + DirectInputKeyCode[] modifiers = [DirectInputKeyCode.DikLshift]; + ParsedInput input = new() + { + Type = InputType.MouseButton, + Value = (modifiers, VirtualKeyCode.MBUTTON) + }; + + keyboard.Setup(k => k.KeyDown(DirectInputKeyCode.DikLshift)).Returns(keyboard.Object); + keyboard.Setup(k => k.KeyUp(DirectInputKeyCode.DikLshift)).Returns(keyboard.Object); + mouse.Setup(m => m.MiddleButtonClick()).Returns(mouse.Object); + + executor.ExecutePressNoRepeat(input).Should().BeTrue(); + + keyboard.Verify(k => k.KeyDown(DirectInputKeyCode.DikLshift), Times.Once); + mouse.Verify(m => m.MiddleButtonClick(), Times.Once); + keyboard.Verify(k => k.KeyUp(DirectInputKeyCode.DikLshift), Times.Once); + } + [Fact] public void ExecuteDownUp_MouseButton_IsIdempotent_PerActionKey() { @@ -120,6 +166,36 @@ public void ExecuteDownUp_Keyboard_NoModifiers_CallsKeyDownAndUp_AndRemovesTimer keyboard.Verify(k => k.KeyUp(DirectInputKeyCode.DikF1), Times.Once); } + [Fact] + public void ExecuteDownUp_Keyboard_WithModifiers_HoldsKeyDown_NoRepeat_AndReleasesKeysThenModifiers() + { + (KeybindingInputExecutor executor, _, Mock keyboard) = CreateExecutor(); + + DirectInputKeyCode[] modifiers = [DirectInputKeyCode.DikLalt]; + DirectInputKeyCode[] keys = [DirectInputKeyCode.DikAdd]; + ParsedInput input = new() + { + Type = InputType.Keyboard, + Value = (modifiers, keys) + }; + + MockSequence seq = new(); + keyboard.InSequence(seq).Setup(k => k.KeyDown(DirectInputKeyCode.DikLalt)).Returns(keyboard.Object); + keyboard.InSequence(seq).Setup(k => k.KeyDown(DirectInputKeyCode.DikAdd)).Returns(keyboard.Object); + keyboard.InSequence(seq).Setup(k => k.KeyUp(DirectInputKeyCode.DikAdd)).Returns(keyboard.Object); + keyboard.InSequence(seq).Setup(k => k.KeyUp(DirectInputKeyCode.DikLalt)).Returns(keyboard.Object); + + executor.ExecuteDown(input, "Action").Should().BeTrue(); + executor.ExecuteUp(input, "Action").Should().BeTrue(); + + keyboard.Verify(k => k.DelayedKeyPress(It.IsAny(), It.IsAny()), Times.Never); + keyboard.Verify(k => k.KeyDown(DirectInputKeyCode.DikLalt), Times.Once); + keyboard.Verify(k => k.KeyDown(DirectInputKeyCode.DikAdd), Times.Once); + keyboard.Verify(k => k.KeyUp(DirectInputKeyCode.DikAdd), Times.Once); + keyboard.Verify(k => k.KeyUp(DirectInputKeyCode.DikLalt), Times.Once); + } + + private static (KeybindingInputExecutor Executor, Mock Mouse, Mock Keyboard) CreateExecutor() { diff --git a/Tests/Unit/Services/Keybinding/KeybindingParserServiceTests.cs b/Tests/Unit/Services/Keybinding/KeybindingParserServiceTests.cs index 296fc69..80dc274 100644 --- a/Tests/Unit/Services/Keybinding/KeybindingParserServiceTests.cs +++ b/Tests/Unit/Services/Keybinding/KeybindingParserServiceTests.cs @@ -60,6 +60,20 @@ public void ParseBinding_ParsesMouseButtons() right.Value.Should().Be(VirtualKeyCode.RBUTTON); } + [Fact] + public void ParseBinding_ParsesMouseButtonWithModifiers() + { + ParsedInputResult? result = KeybindingParserService.ParseBinding("lshift+mouse3"); + + result.Should().NotBeNull(); + result.Type.Should().Be(InputType.MouseButton); + result.Value.Should().BeOfType>(); + + (DirectInputKeyCode[] modifiers, VirtualKeyCode button) = ((DirectInputKeyCode[], VirtualKeyCode))result.Value; + modifiers.Should().ContainSingle().Which.Should().Be(DirectInputKeyCode.DikLshift); + button.Should().Be(VirtualKeyCode.MBUTTON); + } + [Fact] public void ParseBinding_ParsesKeyboardWithModifiers() { diff --git a/Tools/PiAssetsBuilder/Program.cs b/Tools/PiAssetsBuilder/Program.cs index 5a14cfb..5d51f84 100644 --- a/Tools/PiAssetsBuilder/Program.cs +++ b/Tools/PiAssetsBuilder/Program.cs @@ -49,6 +49,7 @@ private static void BuildAll(string piRoot) BuildPageScript(jsSrc, jsOut, "sc-preload.js"); BuildPageScript(jsSrc, jsOut, "pi-control-panel.js"); BuildPageScript(jsSrc, jsOut, "pi-function-key.js"); + BuildPageScript(jsSrc, jsOut, "pi-dial-action.js"); BuildCss(cssSrc, cssOut); } diff --git a/docs/assets/images/adaptive-dial.png b/docs/assets/images/adaptive-dial.png new file mode 100644 index 0000000..b1f0c0e Binary files /dev/null and b/docs/assets/images/adaptive-dial.png differ diff --git a/docs/index.md b/docs/index.md index ef5ea7e..7fa5c64 100644 --- a/docs/index.md +++ b/docs/index.md @@ -26,6 +26,7 @@ This plugin lets you control various aspects of Star Citizen from your Elgato St - **Adaptive Key**: A key that executes keybindings based on the activation mode for a given binding. - *Example:* Two in-game bindings (Tap vs Hold) on `Num-` executes only the Tap function when this is the assigned function. +- **Adaptive Dial** *(Stream Deck+)*: A dial that maps separate Star Citizen functions to rotate left, rotate right, and dial push. Rotation fires the assigned function once per tick; push respects the action's activation mode. - **Toggle Key**: A key that toggles between two states (e.g., landing gear up/down). Can be reset to match the current in-game state on de-sync. - **Control Panel Key**: A dedicated key for managing global plugin settings such as themes, channel selection, and installation paths. - **Auto-Detection of Star Citizen Installation Path**: Automatically detects the installation path of Star Citizen. diff --git a/docs/usage.md b/docs/usage.md index 9c0dea9..0ba117f 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -14,6 +14,30 @@ Basic flow: ![Adaptive Key](assets/images/adaptive-key.png){ style="width:50%; height:auto;" } +## Adaptive Dial *(Stream Deck+)* + +Use `Adaptive Dial` on a **Stream Deck+** device to map Star Citizen functions to the physical dial. + +Each dial slot is independent: + +| Slot | Trigger | Behaviour | +|---|---|---| +| **Rotate Left** | Turn dial counter-clockwise | Executes the assigned function once per tick | +| **Rotate Right** | Turn dial clockwise | Executes the assigned function once per tick | +| **Push** | Press the dial down / release | Executes the assigned function using its activation mode (e.g., Tap vs Hold) | + +Basic flow: + +1. Drag `Adaptive Dial` onto a dial slot on your Stream Deck+. +2. Click on it to open the Property Inspector. +3. Select a Star Citizen function for each slot you want to use (slots left empty are ignored). +4. (Optional) Select a sound file (.wav/.mp3) to play on dial press. + +![Adaptive Dial](assets/images/adaptive-dial.png){ style="width:50%; height:auto;" } + +!!! tip "Rotation step size" + Each physical tick of the dial fires the function exactly once, so fast spins will queue multiple executions automatically. + ## Toggle Key Use `Toggle Key` for bindings that have two states (e.g., landing gear up/down).