using System; using System.Collections.Generic; using System.Globalization; using Uigenx.Animation; using UnityEngine; using UnityEngine.UIElements; namespace Uigenx.Binding { /// Which binding engine the compiler asked for. `Auto` lets the connector decide. public enum UgxBindingEngine { Auto = 0, Unity = 1, Runtime = 2, } public enum UgxFormatKind { None = 0, Number = 1, Percent = 2, Template = 3, Custom = 4, } /// One row of the compiled binding table. public readonly struct UgxBindingEntry { public readonly string ElementName; /// UI property (`text`, `value`) or a style property (`style.width`, `opacity`). public readonly string Property; public readonly string Path; public readonly BindingMode Mode; public readonly UgxBindingEngine Engine; public readonly UgxFormatKind Format; public readonly int Decimals; /// Template text for `Template`, converter group id for `Custom`. public readonly string FormatText; public UgxBindingEntry(string elementName, string property, string path, BindingMode mode = BindingMode.ToTarget, UgxBindingEngine engine = UgxBindingEngine.Auto, UgxFormatKind format = UgxFormatKind.None, int decimals = 0, string formatText = null) { ElementName = elementName; Property = property; Path = path; Mode = mode; Engine = engine; Format = format; Decimals = decimals; FormatText = formatText; } } /// /// Applies a compiled binding table. Unity's own runtime binding does the work whenever it can; /// the UI Creator binding covers what UXML bindings cannot express: formatted text, style /// targets, and sources that resolve paths themselves rather than through a property bag. /// public static class UgxBindingConnector { public static UgxBindingSet Apply(VisualElement root, IReadOnlyList table, IUgxDataSource source, string screenKey = null) { if (root == null) throw new ArgumentNullException(nameof(root)); var set = new UgxBindingSet(root, source); if (table == null || table.Count == 0) return set; UgxConverters.Register(); for (int i = 0; i < table.Count; i++) { var entry = table[i]; var element = string.IsNullOrEmpty(entry.ElementName) ? root : root.Q(entry.ElementName); if (element == null) { Debug.LogWarning("[ugx] screen '" + screenKey + "' binds missing element '" + entry.ElementName + "', skipped"); continue; } if (UseUnityBinding(entry, source)) ApplyUnityBinding(set, element, entry, source); else ApplyRuntimeBinding(set, element, entry, source); } set.PushNow(); // A source that cannot announce a change is written once and then frozen, which looks // exactly like a broken screen. Say so rather than leaving it to be discovered. if (set.RuntimeBindingCount > 0 && !(source is INotifyBindablePropertyChanged)) { Debug.LogWarning("[ugx] screen '" + screenKey + "' has " + set.RuntimeBindingCount + " binding(s) on a data source that does not implement " + "INotifyBindablePropertyChanged, so they show the initial value and never " + "update. Derive from UgxDataSource, use UgxDictionaryDataSource, or call " + "PushNow() yourself."); } return set; } /// /// The engine decision, exposed so the compiler can predict it and warn at export time. /// Unity's binding resolves paths through a property bag, so a source that owns its own /// path resolution, a formatted value, or a style target all need the UI Creator binding. /// public static bool UseUnityBinding(in UgxBindingEntry entry, IUgxDataSource source) { if (entry.Engine == UgxBindingEngine.Runtime) return false; if (entry.Engine == UgxBindingEngine.Unity) return true; if (entry.Format != UgxFormatKind.None) return false; if (IsStyleProperty(entry.Property)) return false; return source is UgxDataSource || source is UgxReflectiveDataSource; } public static bool IsStyleProperty(string property) => !string.IsNullOrEmpty(property) && (property.StartsWith("style.", StringComparison.Ordinal) || UgxStyleSetters.TryGet(property, out _)); static void ApplyUnityBinding(UgxBindingSet set, VisualElement element, in UgxBindingEntry entry, IUgxDataSource source) { var binding = new DataBinding { dataSourcePath = UgxPropertyPaths.Get(entry.Path), bindingMode = entry.Mode, }; if (source is UgxReflectiveDataSource reflective) binding.dataSource = reflective.Target; var id = new BindingId(entry.Property); element.SetBinding(id, binding); set.Track(element, id, null); } static void ApplyRuntimeBinding(UgxBindingSet set, VisualElement element, in UgxBindingEntry entry, IUgxDataSource source) { IUgxStyleSetter styleSetter = null; if (IsStyleProperty(entry.Property) && !UgxStyleSetters.TryGet(entry.Property, out styleSetter)) { Debug.LogWarning("[ugx] no setter for style property '" + entry.Property + "', binding skipped"); return; } var binding = new UgxPathBinding(source, entry, styleSetter); var id = new BindingId(entry.Property); element.SetBinding(id, binding); set.Track(element, id, binding); if (entry.Mode == BindingMode.TwoWay || entry.Mode == BindingMode.ToSource) set.TrackWriteBack(element, source, entry.Path); } } /// /// Owns every binding a screen installed. Disposing clears the bindings from the elements and /// releases the write back listeners, so a detached screen leaves nothing behind. /// public sealed class UgxBindingSet : IDisposable { readonly VisualElement _root; readonly IUgxDataSource _source; readonly List _tracked = new List(8); readonly Dictionary> _byPath = new Dictionary>(StringComparer.Ordinal); readonly UgxDisposableSet _writeBacks = new UgxDisposableSet(4); readonly INotifyBindablePropertyChanged _notifier; bool _disposed; internal UgxBindingSet(VisualElement root, IUgxDataSource source) { _root = root; _source = source; // Unity's own bindings resolve their path against this, and it is inherited by children. if (source != null) root.dataSource = source; // Unity's binding pass would pick the change up on the next frame, and not at all for a // tree that is not in a panel yet. Pushing here makes the update immediate; the pass // then finds the value unchanged and does nothing. _notifier = source as INotifyBindablePropertyChanged; if (_notifier != null) _notifier.propertyChanged += OnSourceChanged; } public int Count => _tracked.Count; /// Bindings driven by this package rather than by Unity's own DataBinding. public int RuntimeBindingCount { get; private set; } public IUgxDataSource Source => _source; internal void Track(VisualElement element, BindingId id, UgxPathBinding binding) { _tracked.Add(new Tracked(element, id, binding)); if (binding == null) return; RuntimeBindingCount++; if (string.IsNullOrEmpty(binding.Path)) return; if (!_byPath.TryGetValue(binding.Path, out var indices)) { indices = new List(1); _byPath[binding.Path] = indices; } indices.Add(_tracked.Count - 1); } void OnSourceChanged(object sender, BindablePropertyChangedEventArgs args) { if (_disposed) return; var path = (string)args.propertyName; if (string.IsNullOrEmpty(path)) { PushNow(); return; } if (!_byPath.TryGetValue(path, out var indices)) return; for (int i = 0; i < indices.Count; i++) { var tracked = _tracked[indices[i]]; tracked.Binding.ApplyTo(tracked.Element, tracked.Id); } } internal void TrackWriteBack(VisualElement element, IUgxDataSource source, string path) { _writeBacks.Add(WriteBack.Install(element, source, path)); _writeBacks.Add(WriteBack.Install(element, source, path)); _writeBacks.Add(WriteBack.Install(element, source, path)); _writeBacks.Add(WriteBack.Install(element, source, path)); } /// /// Pushes the current source value into every UI Creator binding right now, so a screen /// never paints one frame of stale content before the panel's binding pass runs. /// public void PushNow() { for (int i = 0; i < _tracked.Count; i++) { var t = _tracked[i]; t.Binding?.ApplyTo(t.Element, t.Id); } } public void Dispose() { if (_disposed) return; _disposed = true; if (_notifier != null) _notifier.propertyChanged -= OnSourceChanged; for (int i = 0; i < _tracked.Count; i++) { var t = _tracked[i]; t.Element.ClearBinding(t.Id); } _tracked.Clear(); _byPath.Clear(); _writeBacks.Dispose(); if (_source != null && ReferenceEquals(_root.dataSource, _source)) _root.dataSource = null; } readonly struct Tracked { public readonly VisualElement Element; public readonly BindingId Id; public readonly UgxPathBinding Binding; public Tracked(VisualElement element, BindingId id, UgxPathBinding binding) { Element = element; Id = id; Binding = binding; } } static class WriteBack { /// /// `change` is generic in Unity, so two way binding covers the value types the built in /// controls produce. Registering a type a control never raises costs nothing. /// public static IDisposable Install(VisualElement element, IUgxDataSource source, string path) { if (source == null) return null; // ChangeEvent bubbles, so without the target check a two way binding on a container // would write every child control's edit into the container's path. EventCallback> callback = evt => { if (!ReferenceEquals(evt.target, element)) return; source.TrySet(path, evt.newValue); }; element.RegisterCallback(callback); return new Registration(element, callback); } sealed class Registration : IDisposable { VisualElement _element; EventCallback> _callback; public Registration(VisualElement element, EventCallback> callback) { _element = element; _callback = callback; } public void Dispose() { if (_callback == null) return; _element.UnregisterCallback(_callback); _element = null; _callback = null; } } } } /// /// Pulls a value from an by path, formats it and writes it to a UI /// property or a style property. Runs inside Unity's binding pass, and also on demand through /// . /// public sealed class UgxPathBinding : CustomBinding { static readonly string[] FixedFormats = { "F0", "F1", "F2", "F3", "F4", "F5", "F6", "F7", "F8" }; readonly IUgxDataSource _source; readonly string _path; readonly UgxFormatKind _format; readonly int _decimals; readonly string _formatText; readonly IUgxStyleSetter _styleSetter; readonly ConverterGroup _converters; object _lastRaw; bool _hasLast; internal UgxPathBinding(IUgxDataSource source, in UgxBindingEntry entry, IUgxStyleSetter styleSetter) { _source = source; _path = entry.Path; _format = entry.Format; _decimals = Mathf.Clamp(entry.Decimals, 0, 8); _formatText = entry.FormatText; _styleSetter = styleSetter; if (entry.Format == UgxFormatKind.Custom && !string.IsNullOrEmpty(entry.FormatText)) UgxConverters.TryGet(entry.FormatText, out _converters); // The source announces its changes, so there is no reason to poll every frame. updateTrigger = BindingUpdateTrigger.OnSourceChanged; } public string Path => _path; protected override BindingResult Update(in BindingContext context) => ApplyTo(context.targetElement, context.bindingId); internal BindingResult ApplyTo(VisualElement element, BindingId id) { if (_source == null) return new BindingResult(BindingStatus.Failure, "no data source"); if (!_source.TryGet(_path, out var raw)) return new BindingResult(BindingStatus.Failure, "path '" + _path + "' is not in the data source"); if (_hasLast && Equals(_lastRaw, raw)) return new BindingResult(BindingStatus.Success); _lastRaw = raw; _hasLast = true; if (_styleSetter != null) return ApplyStyle(element, raw); return ApplyProperty(element, id, raw); } /// /// Style values go through the setter's own parser so the unit survives. A bare number /// would otherwise become pixels, which silently turns a 0..1 progress value bound to /// `width` into a sub pixel bar. /// BindingResult ApplyStyle(VisualElement element, object raw) { var text = raw as string ?? Format(raw); if (!_styleSetter.TryParse(text, out var value)) return new BindingResult(BindingStatus.Failure, "'" + text + "' is not a valid " + _styleSetter.Property + " value"); _styleSetter.Apply(element, value); return new BindingResult(BindingStatus.Success); } /// /// Values arrive boxed, so the concrete type is recovered before handing them to Unity: /// a boxed `object` would make the property set fail with an invalid cast. /// BindingResult ApplyProperty(VisualElement element, BindingId id, object raw) { if (_format != UgxFormatKind.None) return Set(element, id, FormatWithConverters(raw)); switch (raw) { case null: return Set(element, id, null); case string s: return Set(element, id, s); case bool b: return Set(element, id, b); case int i: return Set(element, id, i); case float f: return Set(element, id, f); case double d: return Set(element, id, (float)d); case long l: return l >= int.MinValue && l <= int.MaxValue ? Set(element, id, (int)l) : Set(element, id, (float)l); default: return Set(element, id, raw); } } static BindingResult Set(VisualElement element, BindingId id, T value) { var target = element; return ConverterGroups.TrySetValueGlobal(ref target, id, value, out var code) ? new BindingResult(BindingStatus.Success) : new BindingResult(BindingStatus.Failure, code.ToString()); } string FormatWithConverters(object raw) { if (_format != UgxFormatKind.Custom) return Format(raw); if (_converters == null) return ToText(raw); if (raw is float f && _converters.TryConvert(ref f, out var fromFloat)) return fromFloat; if (raw is int i && _converters.TryConvert(ref i, out var fromInt)) return fromInt; return ToText(raw); } string Format(object raw) { switch (_format) { case UgxFormatKind.Number: return ToFloat(raw).ToString(FixedFormats[_decimals], CultureInfo.InvariantCulture); case UgxFormatKind.Percent: return (ToFloat(raw) * 100f).ToString(FixedFormats[_decimals], CultureInfo.InvariantCulture) + "%"; case UgxFormatKind.Template: return string.IsNullOrEmpty(_formatText) ? ToText(raw) : string.Format(CultureInfo.InvariantCulture, _formatText, raw); default: return ToText(raw); } } static string ToText(object raw) { if (raw == null) return string.Empty; if (raw is string s) return s; if (raw is IFormattable f) return f.ToString(null, CultureInfo.InvariantCulture); return raw.ToString(); } static float ToFloat(object raw) { switch (raw) { case float f: return f; case double d: return (float)d; case int i: return i; case long l: return l; case bool b: return b ? 1f : 0f; case string s: return float.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed) ? parsed : 0f; default: return 0f; } } } }