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