// InputBindingManager.cs using System; using System.IO; using System.Linq; using System.Collections.Generic; using System.Reactive.Linq; using System.Threading.Tasks; using UnityEngine; using UnityEngine.InputSystem; using System.Reactive.Subjects; using AlicizaX.InputGlyph; using RxUnit = System.Reactive.Unit; namespace InputRemapper { public class InputBindingManager : MonoBehaviour { public const string NULL_BINDING = "__NULL__"; [Tooltip("InputActionAsset to manage")] public InputActionAsset actions; [SerializeField] private InputGlyphDatabase inputGlyphDatabase; public string fileName = "input_bindings.json"; public bool debugMode = false; public Dictionary actionMap = new Dictionary(); public List preparedRebinds = new List(); internal InputActionRebindingExtensions.RebindingOperation rebindOperation; private readonly List pressedActions = new List(); private bool isApplyPending = false; private string defaultBindingsJson = string.Empty; public ReplaySubject OnInputsInit = new ReplaySubject(1); public Subject OnApply = new Subject(); public Subject OnRebindPrepare = new Subject(); public Subject OnRebindStart = new Subject(); public Subject OnRebindEnd = new Subject(); public Subject<(RebindContext prepared, RebindContext conflict)> OnRebindConflict = new Subject<(RebindContext, RebindContext)>(); public string SavePath { get { #if UNITY_EDITOR string folder = Application.dataPath; #else string folder = Application.persistentDataPath; #endif if (!Directory.Exists(folder)) Directory.CreateDirectory(folder); return Path.Combine(folder, fileName); } } private void Awake() { if (actions == null) { Debug.LogError("InputBindingManager: InputActionAsset not assigned."); return; } BuildActionMap(); try { defaultBindingsJson = actions.SaveBindingOverridesAsJson(); } catch { defaultBindingsJson = string.Empty; } if (File.Exists(SavePath)) { try { var json = File.ReadAllText(SavePath); if (!string.IsNullOrEmpty(json)) { actions.LoadBindingOverridesFromJson(json); RefreshBindingPathsFromActions(); if (debugMode) Debug.Log($"Loaded overrides from {SavePath}"); } } catch (Exception ex) { Debug.LogError("Failed to load overrides: " + ex); } } OnInputsInit.OnNext(RxUnit.Default); actions.Enable(); } private void OnDestroy() { rebindOperation?.Dispose(); rebindOperation = null; OnInputsInit?.OnCompleted(); OnApply?.OnCompleted(); OnRebindPrepare?.OnCompleted(); OnRebindStart?.OnCompleted(); OnRebindEnd?.OnCompleted(); OnRebindConflict?.OnCompleted(); } private void BuildActionMap() { actionMap.Clear(); foreach (var map in actions.actionMaps) actionMap.Add(map.name, new ActionMap(map)); } private void RefreshBindingPathsFromActions() { foreach (var mapPair in actionMap) { foreach (var actionPair in mapPair.Value.actions) { var a = actionPair.Value; foreach (var bpair in a.bindings) { bpair.Value.bindingPath.EffectivePath = a.action.bindings[bpair.Key].effectivePath; } } } } public class ActionMap { public string name; public Dictionary actions = new Dictionary(); public ActionMap(InputActionMap map) { name = map.name; foreach (var action in map.actions) actions.Add(action.name, new Action(action)); } public class Action { public InputAction action; public Dictionary bindings = new Dictionary(); public Action(InputAction action) { this.action = action; int count = action.bindings.Count; for (int i = 0; i < count; i++) { if (action.bindings[i].isComposite) { int first = i + 1; int last = first; while (last < count && action.bindings[last].isPartOfComposite) last++; for (int p = first; p < last; p++) AddBinding(action.bindings[p], p); i = last - 1; } else { AddBinding(action.bindings[i], i); } } void AddBinding(InputBinding binding, int bindingIndex) { bindings.Add(bindingIndex, new Binding { name = binding.name, parentAction = action.name, compositePart = binding.name, bindingIndex = bindingIndex, group = binding.groups?.Split(InputBinding.Separator) ?? Array.Empty(), bindingPath = new BindingPath(binding.path, binding.overridePath), inputBinding = binding }); } } public struct Binding { public string name; public string parentAction; public string compositePart; public int bindingIndex; public string[] group; public BindingPath bindingPath; public InputBinding inputBinding; } } } public sealed class BindingPath { public string bindingPath; public string overridePath; private readonly Subject observer = new Subject(); public BindingPath(string bindingPath, string overridePath) { this.bindingPath = bindingPath; this.overridePath = overridePath; } public string EffectivePath { get => !string.IsNullOrEmpty(overridePath) ? overridePath : bindingPath; set { overridePath = (value == bindingPath) ? string.Empty : value; observer.OnNext(RxUnit.Default); } } public IObservable EffectivePathObservable => observer.Select(_ => EffectivePath); } public class RebindContext { public InputAction action; public int bindingIndex; public string overridePath; public RebindContext(InputAction action, int bindingIndex, string overridePath) { this.action = action; this.bindingIndex = bindingIndex; this.overridePath = overridePath; } public override bool Equals(object obj) { if (obj is not RebindContext other) return false; if (action == null || other.action == null) return false; return action.name == other.action.name && bindingIndex == other.bindingIndex; } public override int GetHashCode() => (action?.name ?? string.Empty, bindingIndex).GetHashCode(); public override string ToString() => $"{action?.name ?? ""}:{bindingIndex}"; } /* ---------------- Public API ---------------- */ public static InputAction Action(string actionName) { foreach (var map in Instance.actionMap) { if (map.Value.actions.TryGetValue(actionName, out var a)) return a.action; } Debug.LogError($"[InputBindingManager] Could not find action '{actionName}'"); return null; } public static void StartRebind(string actionName, string compositePartName = null) { var action = Action(actionName); if (action == null) return; // decide bindingIndex & deviceMatch automatically int bindingIndex = Instance.FindBestBindingIndexForKeyboard(action, compositePartName); if (bindingIndex < 0) { Debug.LogError($"[InputBindingManager] No suitable binding found for action '{actionName}' (part={compositePartName ?? ""})"); return; } Instance.actions.Disable(); Instance.PerformInteractiveRebinding(action, bindingIndex, "", true); Instance.OnRebindStart.OnNext(RxUnit.Default); if (Instance.debugMode) Debug.Log("[InputBindingManager] Rebind started"); } public static void CancelRebind() => Instance.rebindOperation?.Cancel(); public static async Task ConfirmApply(bool clearConflicts = true) { if (!Instance.isApplyPending) return false; try { foreach (var ctx in Instance.preparedRebinds) { if (string.IsNullOrEmpty(ctx.overridePath)) { } else if (ctx.overridePath == NULL_BINDING) ctx.action.RemoveBindingOverride(ctx.bindingIndex); else ctx.action.ApplyBindingOverride(ctx.bindingIndex, ctx.overridePath); var bp = GetBindingPath(ctx.action.name, ctx.bindingIndex); if (bp != null) bp.EffectivePath = (ctx.overridePath == NULL_BINDING) ? string.Empty : ctx.overridePath; } Instance.preparedRebinds.Clear(); await Instance.WriteOverridesToDiskAsync(); Instance.OnApply.OnNext(true); Instance.isApplyPending = false; if (Instance.debugMode) Debug.Log("[InputBindingManager] Apply confirmed and saved."); return true; } catch (Exception ex) { Debug.LogError("[InputBindingManager] Failed to apply binds: " + ex); Instance.OnApply.OnNext(false); return false; } } public static void DiscardPrepared() { if (!Instance.isApplyPending) return; Instance.preparedRebinds.Clear(); Instance.isApplyPending = false; Instance.OnApply.OnNext(false); if (Instance.debugMode) Debug.Log("[InputBindingManager] Prepared rebinds discarded."); } private void PerformInteractiveRebinding(InputAction action, int bindingIndex, string deviceMatchPath = null, bool excludeMouseMovementAndScroll = true) { var op = action.PerformInteractiveRebinding(bindingIndex); if (!string.IsNullOrEmpty(deviceMatchPath)) op = op.WithControlsHavingToMatchPath(deviceMatchPath); if (excludeMouseMovementAndScroll) { op = op.WithControlsExcluding("/delta"); op = op.WithControlsExcluding("/scroll"); op = op.WithControlsExcluding("/scroll/x"); op = op.WithControlsExcluding("/scroll/y"); } rebindOperation = op .OnApplyBinding((o, path) => { if (AnyPreparedRebind(path, action, bindingIndex, out var existing)) { PrepareRebind(new RebindContext(action, bindingIndex, path)); PrepareRebind(new RebindContext(existing.action, existing.bindingIndex, NULL_BINDING)); OnRebindConflict.OnNext((new RebindContext(action, bindingIndex, path), existing)); } else if (AnyBindingPath(path, action, bindingIndex, out var dup)) { PrepareRebind(new RebindContext(action, bindingIndex, path)); PrepareRebind(new RebindContext(dup.action, dup.bindingIndex, NULL_BINDING)); OnRebindConflict.OnNext((new RebindContext(action, bindingIndex, path), new RebindContext(dup.action, dup.bindingIndex, dup.action.bindings[dup.bindingIndex].path))); } else { PrepareRebind(new RebindContext(action, bindingIndex, path)); } }) .OnComplete(opc => { if (debugMode) Debug.Log("[InputBindingManager] Rebind completed"); actions.Enable(); OnRebindEnd.OnNext(true); CleanRebindOperation(); }) .OnCancel(opc => { if (debugMode) Debug.Log("[InputBindingManager] Rebind cancelled"); actions.Enable(); OnRebindEnd.OnNext(false); CleanRebindOperation(); }) .WithCancelingThrough("/escape") .Start(); } private void CleanRebindOperation() { rebindOperation?.Dispose(); rebindOperation = null; } private bool AnyPreparedRebind(string bindingPath, InputAction currentAction, int currentIndex, out RebindContext duplicate) { foreach (var ctx in preparedRebinds) { if (ctx.overridePath == bindingPath && (ctx.action != currentAction || (ctx.action == currentAction && ctx.bindingIndex != currentIndex))) { duplicate = ctx; return true; } } duplicate = null; return false; } private bool AnyBindingPath(string bindingPath, InputAction currentAction, int currentIndex, out (InputAction action, int bindingIndex) duplicate) { foreach (var map in actionMap) { foreach (var actionPair in map.Value.actions) { foreach (var bindingPair in actionPair.Value.bindings) { if (actionPair.Value.action == currentAction && bindingPair.Key == currentIndex) continue; var eff = bindingPair.Value.bindingPath.EffectivePath; if (eff == bindingPath) { duplicate = (actionPair.Value.action, bindingPair.Key); return true; } } } } duplicate = default; return false; } private void PrepareRebind(RebindContext context) { preparedRebinds.RemoveAll(x => x.Equals(context)); if (string.IsNullOrEmpty(context.overridePath)) { var bp = GetBindingPath(context.action.name, context.bindingIndex); if (bp != null) context.overridePath = bp.bindingPath; } var bindingPath = GetBindingPath(context.action.name, context.bindingIndex); if (bindingPath == null) return; if (bindingPath.EffectivePath != context.overridePath) { preparedRebinds.Add(context); isApplyPending = true; OnRebindPrepare.OnNext(context); if (debugMode) Debug.Log($"Prepared rebind: {context} -> {context.overridePath}"); } } private async Task WriteOverridesToDiskAsync() { try { var json = actions.SaveBindingOverridesAsJson(); var dir = Path.GetDirectoryName(SavePath); if (!Directory.Exists(dir)) Directory.CreateDirectory(dir); using (var sw = new StreamWriter(SavePath, false)) await sw.WriteAsync(json); if (debugMode) Debug.Log($"Overrides saved to {SavePath}"); } catch (Exception ex) { Debug.LogError("Failed to save overrides: " + ex); throw; } } public async Task ResetToDefaultAsync() { try { if (!string.IsNullOrEmpty(defaultBindingsJson)) actions.LoadBindingOverridesFromJson(defaultBindingsJson); else { foreach (var map in actionMap) foreach (var a in map.Value.actions) for (int b = 0; b < a.Value.action.bindings.Count; b++) a.Value.action.RemoveBindingOverride(b); } RefreshBindingPathsFromActions(); await WriteOverridesToDiskAsync(); if (debugMode) Debug.Log("Reset to default and saved."); } catch (Exception ex) { Debug.LogError("Failed to reset defaults: " + ex); } } public static BindingPath GetBindingPath(string actionName, int bindingIndex = 0) { foreach (var map in Instance.actionMap) { if (map.Value.actions.TryGetValue(actionName, out var action)) { if (action.bindings.TryGetValue(bindingIndex, out var binding)) return binding.bindingPath; } } return null; } // choose best binding index for keyboard; if compositePartName != null then look for part public int FindBestBindingIndexForKeyboard(InputAction action, string compositePartName = null) { if (action == null) return -1; int fallbackPart = -1; int fallbackNonComposite = -1; for (int i = 0; i < action.bindings.Count; i++) { var b = action.bindings[i]; if (!string.IsNullOrEmpty(compositePartName)) { if (!b.isPartOfComposite) continue; if (!string.Equals(b.name, compositePartName, StringComparison.OrdinalIgnoreCase)) continue; } if (b.isPartOfComposite) { if (fallbackPart == -1) fallbackPart = i; if (!string.IsNullOrEmpty(b.path) && b.path.StartsWith("", StringComparison.OrdinalIgnoreCase)) return i; if (!string.IsNullOrEmpty(b.effectivePath) && b.effectivePath.StartsWith("", StringComparison.OrdinalIgnoreCase)) return i; } else { if (fallbackNonComposite == -1) fallbackNonComposite = i; if (!string.IsNullOrEmpty(b.path) && b.path.StartsWith("", StringComparison.OrdinalIgnoreCase)) return i; if (!string.IsNullOrEmpty(b.effectivePath) && b.effectivePath.StartsWith("", StringComparison.OrdinalIgnoreCase)) return i; } } if (fallbackNonComposite >= 0) return fallbackNonComposite; return fallbackPart; } public static InputBindingManager Instance => _instance ??= FindObjectOfType(); private static InputBindingManager _instance; } }