735 lines
31 KiB
C#
735 lines
31 KiB
C#
using System;
|
|
using System.Globalization;
|
|
using System.Numerics;
|
|
using System.Reflection;
|
|
using System.Xml.Linq;
|
|
|
|
namespace AcDream.App.UI;
|
|
|
|
/// <summary>
|
|
/// Parses our KSML-style panel markup (mirrors retail's ElementDesc fields)
|
|
/// into a live <see cref="UiElement"/> subtree. <c>{Binding}</c> attribute
|
|
/// values resolve against a supplied object by property name (reflection).
|
|
/// This is the format the future LayoutDesc importer will emit. See D.2b spec §7.
|
|
/// </summary>
|
|
public static class MarkupDocument
|
|
{
|
|
// Retail's generic runtime-text tooltip skin. Plugin controls have no
|
|
// LayoutDesc of their own, so a tooltip= attribute explicitly opts them
|
|
// into the same popup that game-code SetTooltip call sites use.
|
|
private const uint RuntimeTooltipRootElementId = 0x10000397u;
|
|
private const uint RuntimeTooltipLayoutDid = 0x21000041u;
|
|
|
|
/// <param name="xml">Raw XML markup for a single panel.</param>
|
|
/// <param name="binding">Object whose public properties are bound to <c>{PropName}</c> attributes.</param>
|
|
/// <param name="resolve">Surface id → (GL handle, width, height) for chrome sprites.</param>
|
|
/// <param name="style">Optional controls.ini stylesheet for the title color.</param>
|
|
/// <param name="datFont">
|
|
/// Retail interface font. Supplied by the host so plugin panels render
|
|
/// their text through the same glyph path as authored panels; without it
|
|
/// they fall back to the development bitmap font and look foreign.
|
|
/// </param>
|
|
public static UiNineSlicePanel Build(
|
|
string xml, object binding, Func<uint, (uint, int, int)> resolve,
|
|
ControlsIni? style = null, UiDatFont? datFont = null)
|
|
{
|
|
var root = XDocument.Parse(xml).Root ?? throw new FormatException("empty markup");
|
|
if (root.Name.LocalName != "panel")
|
|
throw new FormatException($"root must be <panel>, got <{root.Name.LocalName}>");
|
|
|
|
var panel = new UiNineSlicePanel(resolve)
|
|
{
|
|
Left = F(root, "x"),
|
|
Top = F(root, "y"),
|
|
Width = F(root, "w"),
|
|
Height = F(root, "h"),
|
|
};
|
|
|
|
// Optional per-window resize-axis lock: resize="x" | "y" | "both" | "none".
|
|
string? resize = (string?)root.Attribute("resize");
|
|
if (resize is not null)
|
|
{
|
|
panel.ResizeX = resize is "x" or "both";
|
|
panel.ResizeY = resize is "y" or "both";
|
|
}
|
|
|
|
// Panel-level visibility binding: lets a plugin keep its window out of
|
|
// the way until it has something to act on (e.g. hidden at character
|
|
// select, shown in world).
|
|
string? visible = (string?)root.Attribute("visible");
|
|
if (visible is not null && IsBinding(visible))
|
|
{
|
|
PropertyInfo? flag = binding.GetType().GetProperty(visible[1..^1]);
|
|
if (flag is null || flag.PropertyType != typeof(bool))
|
|
{
|
|
throw new FormatException(
|
|
$"<panel visible=\"{visible}\"> did not resolve to a bool property "
|
|
+ $"on {binding.GetType().Name}");
|
|
}
|
|
panel.VisibleSource = () => flag.GetValue(binding) is true;
|
|
}
|
|
|
|
string? title = (string?)root.Attribute("title");
|
|
if (!string.IsNullOrEmpty(title))
|
|
{
|
|
Vector4 tc = style is not null && style.TryColor("title", "color", out var c) ? c : Vector4.One;
|
|
panel.AddChild(new UiLabel
|
|
{
|
|
Text = title, Left = 8, Top = 4, TextColor = tc, DatFont = datFont,
|
|
});
|
|
}
|
|
|
|
foreach (var el in root.Elements())
|
|
AddElement(panel, el, binding, resolve, datFont);
|
|
return panel;
|
|
}
|
|
|
|
private static void AddElement(
|
|
UiElement parent,
|
|
XElement el,
|
|
object binding,
|
|
Func<uint, (uint, int, int)> resolve,
|
|
UiDatFont? datFont)
|
|
{
|
|
switch (el.Name.LocalName)
|
|
{
|
|
case "group":
|
|
var group = new UiPanel
|
|
{
|
|
Left = F(el, "x"),
|
|
Top = F(el, "y"),
|
|
Width = F(el, "w"),
|
|
Height = F(el, "h"),
|
|
BackgroundColor = el.Attribute("background") is null
|
|
? Vector4.Zero
|
|
: Color((string?)el.Attribute("background")),
|
|
BorderColor = el.Attribute("border") is null
|
|
? Vector4.Zero
|
|
: Color((string?)el.Attribute("border")),
|
|
BorderThickness = el.Attribute("border") is null ? 0f : 1f,
|
|
// Transparent layout groups do not claim empty space, while
|
|
// their interactive descendants remain hittable.
|
|
ClickThrough = true,
|
|
};
|
|
ApplyCommon(group, el, binding);
|
|
parent.AddChild(group);
|
|
foreach (XElement child in el.Elements())
|
|
AddElement(group, child, binding, resolve, datFont);
|
|
break;
|
|
|
|
case "meter":
|
|
var cur = BindUint((string?)el.Attribute("cur"), binding);
|
|
var max = BindUint((string?)el.Attribute("max"), binding);
|
|
var meter = new UiMeter
|
|
{
|
|
Left = F(el, "x"),
|
|
Top = F(el, "y"),
|
|
Width = F(el, "w"),
|
|
Height = F(el, "h"),
|
|
BarColor = Color((string?)el.Attribute("color")),
|
|
Fill = BindFloat((string?)el.Attribute("fill"), binding),
|
|
Label = () => (cur(), max()) is (uint c, uint m) ? $"{c}/{m}" : null,
|
|
Anchors = Anchor((string?)el.Attribute("anchor")),
|
|
SpriteResolve = resolve,
|
|
BackLeft = Hex((string?)el.Attribute("backleft")),
|
|
BackTile = Hex((string?)el.Attribute("backtile")),
|
|
BackRight = Hex((string?)el.Attribute("backright")),
|
|
FrontLeft = Hex((string?)el.Attribute("frontleft")),
|
|
FrontTile = Hex((string?)el.Attribute("fronttile")),
|
|
FrontRight = Hex((string?)el.Attribute("frontright")),
|
|
};
|
|
ApplyCommon(meter, el, binding);
|
|
parent.AddChild(meter);
|
|
break;
|
|
|
|
case "label":
|
|
// Text may be a literal or a {Binding}. Bound labels re-read
|
|
// their property every frame through the Func, so a plugin
|
|
// updates its status line by assigning a property rather
|
|
// than by touching UI objects from its own thread.
|
|
var label = new UiLabel
|
|
{
|
|
Left = F(el, "x"),
|
|
Top = F(el, "y"),
|
|
TextSource = BindString((string?)el.Attribute("text"), binding),
|
|
DatFont = datFont,
|
|
};
|
|
if (el.Attribute("color") is not null)
|
|
label.TextColor = Color((string?)el.Attribute("color"));
|
|
ApplyCommon(label, el, binding);
|
|
parent.AddChild(label);
|
|
break;
|
|
|
|
case "button":
|
|
// onclick binds to an Action property on the binding
|
|
// object. Resolved once at build time: a button whose
|
|
// handler silently failed to bind is a bug worth failing
|
|
// loudly for, and MarkupDocument.Build is already inside
|
|
// the host's try/catch that reports panel load failures.
|
|
string? clickName = (string?)el.Attribute("onclick");
|
|
Action? onClick = BindAction(clickName, binding);
|
|
if (clickName is not null && onClick is null)
|
|
{
|
|
throw new FormatException(
|
|
$"<button onclick=\"{clickName}\"> did not resolve to an "
|
|
+ $"Action property on {binding.GetType().Name}");
|
|
}
|
|
// UiSimpleButton, not the dat-sprite UiButton: a plugin
|
|
// panel has no LayoutDesc behind it and no StateDesc
|
|
// sprites to name, so the plain rect-and-text button is the
|
|
// one that can actually render from markup alone.
|
|
var button = new UiSimpleButton
|
|
{
|
|
Left = F(el, "x"),
|
|
Top = F(el, "y"),
|
|
Width = F(el, "w"),
|
|
Height = F(el, "h"),
|
|
Text = (string?)el.Attribute("text") ?? string.Empty,
|
|
DatFont = datFont,
|
|
};
|
|
// A bound caption lets the button re-label itself (Buff /
|
|
// Stop) from the same binding object.
|
|
string? caption = (string?)el.Attribute("text");
|
|
if (caption is not null && IsBinding(caption))
|
|
button.TextSource = BindString(caption, binding);
|
|
if (el.Attribute("color") is not null)
|
|
button.TextColor = Color((string?)el.Attribute("color"));
|
|
if (el.Attribute("background") is not null)
|
|
button.BackgroundColor = Color(
|
|
(string?)el.Attribute("background"));
|
|
if (el.Attribute("border") is not null)
|
|
button.BorderColor = Color(
|
|
(string?)el.Attribute("border"));
|
|
ApplyCommon(button, el, binding);
|
|
if (onClick is not null)
|
|
button.Click += onClick;
|
|
parent.AddChild(button);
|
|
break;
|
|
|
|
case "tab":
|
|
string? tabClickName = (string?)el.Attribute("onclick");
|
|
Action? tabClick = BindAction(tabClickName, binding);
|
|
if (tabClickName is not null && tabClick is null)
|
|
{
|
|
throw new FormatException(
|
|
$"<tab onclick=\"{tabClickName}\"> did not resolve to an "
|
|
+ $"Action property on {binding.GetType().Name}");
|
|
}
|
|
|
|
var tab = new UiMarkupTabButton
|
|
{
|
|
Left = F(el, "x"),
|
|
Top = F(el, "y"),
|
|
Width = F(el, "w"),
|
|
Height = F(el, "h"),
|
|
Text = (string?)el.Attribute("text") ?? string.Empty,
|
|
DatFont = datFont,
|
|
SelectedSource = BindRequiredBoolReader(
|
|
(string?)el.Attribute("selected"),
|
|
binding,
|
|
"tab selected"),
|
|
};
|
|
ApplyCommon(tab, el, binding);
|
|
if (tabClick is not null)
|
|
tab.Click += tabClick;
|
|
parent.AddChild(tab);
|
|
break;
|
|
|
|
case "toggle":
|
|
string? toggleClickName = (string?)el.Attribute("onclick");
|
|
Action? toggleClick = BindAction(toggleClickName, binding);
|
|
if (toggleClickName is not null && toggleClick is null)
|
|
{
|
|
throw new FormatException(
|
|
$"<toggle onclick=\"{toggleClickName}\"> did not resolve to an "
|
|
+ $"Action property on {binding.GetType().Name}");
|
|
}
|
|
|
|
string? toggleCaption = (string?)el.Attribute("text");
|
|
var toggle = new UiMarkupToggle
|
|
{
|
|
Left = F(el, "x"),
|
|
Top = F(el, "y"),
|
|
Width = F(el, "w"),
|
|
Height = F(el, "h"),
|
|
Text = toggleCaption ?? string.Empty,
|
|
TextSource = BindString(toggleCaption, binding),
|
|
CheckedSource = BindRequiredBoolReader(
|
|
(string?)el.Attribute("checked"),
|
|
binding,
|
|
"toggle checked"),
|
|
DatFont = datFont,
|
|
Toggle = toggleClick,
|
|
};
|
|
if (el.Attribute("color") is not null)
|
|
toggle.TextColor = Color((string?)el.Attribute("color"));
|
|
ApplyCommon(toggle, el, binding);
|
|
parent.AddChild(toggle);
|
|
break;
|
|
|
|
case "slider":
|
|
string? changeName = (string?)el.Attribute("onchange");
|
|
Action<float>? changed = BindFloatAction(changeName, binding);
|
|
if (changeName is not null && changed is null)
|
|
{
|
|
throw new FormatException(
|
|
$"<slider onchange=\"{changeName}\"> did not resolve to an "
|
|
+ $"Action<float> property on {binding.GetType().Name}");
|
|
}
|
|
|
|
var slider = new UiScrollbar
|
|
{
|
|
Left = F(el, "x"),
|
|
Top = F(el, "y"),
|
|
Width = F(el, "w"),
|
|
Height = F(el, "h"),
|
|
Horizontal = true,
|
|
SpriteResolve = resolve,
|
|
ScalarPositionSource = BindFloat(
|
|
(string?)el.Attribute("value"),
|
|
binding),
|
|
ScalarChanged = changed,
|
|
};
|
|
RetailScrollbarChrome.ApplyHorizontal(slider);
|
|
ApplyCommon(slider, el, binding);
|
|
parent.AddChild(slider);
|
|
break;
|
|
|
|
case "field":
|
|
string? fieldChangeName = (string?)el.Attribute("onchange");
|
|
Action<string>? fieldChanged = BindStringAction(
|
|
fieldChangeName,
|
|
binding);
|
|
if (fieldChangeName is not null && fieldChanged is null)
|
|
{
|
|
throw new FormatException(
|
|
$"<field onchange=\"{fieldChangeName}\"> did not resolve to an "
|
|
+ $"Action<string> property on {binding.GetType().Name}");
|
|
}
|
|
string? submitName = (string?)el.Attribute("onsubmit");
|
|
Action<string>? submitted = BindStringAction(submitName, binding);
|
|
if (submitName is not null && submitted is null)
|
|
{
|
|
throw new FormatException(
|
|
$"<field onsubmit=\"{submitName}\"> did not resolve to an "
|
|
+ $"Action<string> property on {binding.GetType().Name}");
|
|
}
|
|
|
|
var field = new UiField
|
|
{
|
|
Left = F(el, "x"),
|
|
Top = F(el, "y"),
|
|
Width = F(el, "w"),
|
|
Height = F(el, "h"),
|
|
DatFont = datFont,
|
|
BackgroundColor = el.Attribute("background") is null
|
|
? new Vector4(0f, 0f, 0f, 0.9f)
|
|
: Color((string?)el.Attribute("background")),
|
|
TextColor = el.Attribute("color") is null
|
|
? new Vector4(0.91f, 0.87f, 0.76f, 1f)
|
|
: Color((string?)el.Attribute("color")),
|
|
MaxCharacters = Math.Max(1, I(el, "maxlength", 128)),
|
|
ClearOnSubmit = B(el, "clearonsubmit", false),
|
|
RecordHistory = false,
|
|
OnTextChanged = fieldChanged,
|
|
OnSubmit = submitted,
|
|
};
|
|
field.SetText(BindString((string?)el.Attribute("text"), binding)());
|
|
ApplyCommon(field, el, binding);
|
|
parent.AddChild(field);
|
|
break;
|
|
|
|
case "menu":
|
|
string? menuChangeName = (string?)el.Attribute("onchange");
|
|
Action<string>? menuChanged = BindStringAction(
|
|
menuChangeName,
|
|
binding);
|
|
if (menuChangeName is not null && menuChanged is null)
|
|
{
|
|
throw new FormatException(
|
|
$"<menu onchange=\"{menuChangeName}\"> did not resolve to an "
|
|
+ $"Action<string> property on {binding.GetType().Name}");
|
|
}
|
|
Func<IReadOnlyList<string>> menuItems = BindStringList(
|
|
(string?)el.Attribute("items"),
|
|
binding,
|
|
"menu items");
|
|
Func<string?> menuSelected = BindString(
|
|
(string?)el.Attribute("selected"),
|
|
binding);
|
|
var menu = new UiMenu
|
|
{
|
|
Left = F(el, "x"),
|
|
Top = F(el, "y"),
|
|
Width = F(el, "w"),
|
|
Height = F(el, "h"),
|
|
DatFont = datFont,
|
|
SpriteResolve = resolve,
|
|
RowsPerColumn = Math.Max(1, I(el, "rows", 7)),
|
|
RowHeight = Math.Max(12f, FOr(el, "rowheight", 18f)),
|
|
ColumnWidth = Math.Max(20f, F(el, "w")),
|
|
OpenUpward = B(el, "openupward", false),
|
|
TextIndent = 6f,
|
|
ButtonTextIndent = 6f,
|
|
NormalSprite = 0x06004D65u,
|
|
PressedSprite = 0x06004D66u,
|
|
PopupBgSprite = 0x0600124Cu,
|
|
ItemNormalSprite = 0x0600124Eu,
|
|
ItemHighlightSprite = 0x0600124Du,
|
|
ButtonLabelProvider = () => menuSelected() ?? string.Empty,
|
|
OnSelect = payload =>
|
|
{
|
|
if (payload is string value)
|
|
menuChanged?.Invoke(value);
|
|
},
|
|
};
|
|
void RefreshMenu()
|
|
{
|
|
menu.Items = menuItems()
|
|
.Select(static value => new UiMenu.MenuItem(value, value))
|
|
.ToArray();
|
|
menu.Selected = menuSelected();
|
|
}
|
|
RefreshMenu();
|
|
menu.BeforeOpen = RefreshMenu;
|
|
ApplyCommon(menu, el, binding);
|
|
parent.AddChild(menu);
|
|
break;
|
|
|
|
case "list":
|
|
string? listChangeName = (string?)el.Attribute("onchange");
|
|
Action<int>? listChanged = BindIntAction(listChangeName, binding);
|
|
if (listChangeName is not null && listChanged is null)
|
|
{
|
|
throw new FormatException(
|
|
$"<list onchange=\"{listChangeName}\"> did not resolve to an "
|
|
+ $"Action<int> property on {binding.GetType().Name}");
|
|
}
|
|
var list = new UiMarkupList
|
|
{
|
|
Left = F(el, "x"),
|
|
Top = F(el, "y"),
|
|
Width = F(el, "w"),
|
|
Height = F(el, "h"),
|
|
RowHeight = Math.Max(12f, FOr(el, "rowheight", 18f)),
|
|
DatFont = datFont,
|
|
ItemsSource = BindStringList(
|
|
(string?)el.Attribute("items"),
|
|
binding,
|
|
"list items"),
|
|
ItemColorsSource = BindUintList(
|
|
(string?)el.Attribute("colors"),
|
|
binding,
|
|
"list colors"),
|
|
SelectedIndexSource = BindRequiredIntReader(
|
|
(string?)el.Attribute("selected"),
|
|
binding,
|
|
"list selected"),
|
|
SelectionChanged = listChanged,
|
|
};
|
|
ApplyCommon(list, el, binding);
|
|
parent.AddChild(list);
|
|
break;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resolves <c>{PropName}</c> to a live string reader, or returns the
|
|
/// literal text unchanged. The indirection matters: binding to a
|
|
/// <see cref="Func{T}"/> rather than copying the value once is what makes a
|
|
/// plugin's status text update as its state changes.
|
|
/// </summary>
|
|
private static Func<string?> BindString(string? attribute, object binding)
|
|
{
|
|
if (attribute is null)
|
|
return static () => null;
|
|
if (!IsBinding(attribute))
|
|
return () => attribute;
|
|
|
|
string name = attribute[1..^1];
|
|
PropertyInfo? property = binding.GetType().GetProperty(name);
|
|
if (property is null)
|
|
return () => attribute;
|
|
return () => property.GetValue(binding)?.ToString();
|
|
}
|
|
|
|
/// <summary>Resolves <c>{PropName}</c> to an <see cref="Action"/> property.</summary>
|
|
private static Action? BindAction(string? attribute, object binding)
|
|
{
|
|
if (attribute is null || !IsBinding(attribute))
|
|
return null;
|
|
|
|
string name = attribute[1..^1];
|
|
PropertyInfo? property = binding.GetType().GetProperty(name);
|
|
if (property is null || !typeof(Action).IsAssignableFrom(property.PropertyType))
|
|
return null;
|
|
|
|
// Read through on each click rather than capturing the delegate now, so
|
|
// a binding object may swap its handler (or null it out while busy)
|
|
// without rebuilding the panel.
|
|
return () => (property.GetValue(binding) as Action)?.Invoke();
|
|
}
|
|
|
|
private static Action<float>? BindFloatAction(
|
|
string? attribute,
|
|
object binding)
|
|
{
|
|
if (attribute is null || !IsBinding(attribute))
|
|
return null;
|
|
|
|
string name = attribute[1..^1];
|
|
PropertyInfo? property = binding.GetType().GetProperty(name);
|
|
if (property is null
|
|
|| !typeof(Action<float>).IsAssignableFrom(property.PropertyType))
|
|
return null;
|
|
return value => (property.GetValue(binding) as Action<float>)?.Invoke(value);
|
|
}
|
|
|
|
private static Action<string>? BindStringAction(
|
|
string? attribute,
|
|
object binding)
|
|
{
|
|
if (attribute is null || !IsBinding(attribute))
|
|
return null;
|
|
|
|
string name = attribute[1..^1];
|
|
PropertyInfo? property = binding.GetType().GetProperty(name);
|
|
if (property is null
|
|
|| !typeof(Action<string>).IsAssignableFrom(property.PropertyType))
|
|
{
|
|
return null;
|
|
}
|
|
return value => (property.GetValue(binding) as Action<string>)?.Invoke(value);
|
|
}
|
|
|
|
private static Action<int>? BindIntAction(string? attribute, object binding)
|
|
{
|
|
if (attribute is null || !IsBinding(attribute))
|
|
return null;
|
|
PropertyInfo? property = binding.GetType().GetProperty(attribute[1..^1]);
|
|
if (property is null
|
|
|| !typeof(Action<int>).IsAssignableFrom(property.PropertyType))
|
|
{
|
|
return null;
|
|
}
|
|
return value => (property.GetValue(binding) as Action<int>)?.Invoke(value);
|
|
}
|
|
|
|
private static Func<IReadOnlyList<string>> BindStringList(
|
|
string? expression,
|
|
object binding,
|
|
string context)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(expression) || !IsBinding(expression))
|
|
throw new FormatException($"{context} must be a string-list binding");
|
|
PropertyInfo? property = binding.GetType().GetProperty(expression[1..^1]);
|
|
if (property is null
|
|
|| !typeof(IEnumerable<string>).IsAssignableFrom(property.PropertyType))
|
|
{
|
|
throw new FormatException(
|
|
$"{expression} did not resolve to an IEnumerable<string> property on "
|
|
+ binding.GetType().Name);
|
|
}
|
|
return () => property.GetValue(binding) is IEnumerable<string> values
|
|
? values.ToArray()
|
|
: Array.Empty<string>();
|
|
}
|
|
|
|
private static Func<IReadOnlyList<uint>> BindUintList(
|
|
string? expression,
|
|
object binding,
|
|
string context)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(expression))
|
|
return static () => Array.Empty<uint>();
|
|
if (!IsBinding(expression))
|
|
throw new FormatException($"{context} must be a uint-list binding");
|
|
PropertyInfo? property = binding.GetType().GetProperty(expression[1..^1]);
|
|
if (property is null
|
|
|| !typeof(IEnumerable<uint>).IsAssignableFrom(property.PropertyType))
|
|
{
|
|
throw new FormatException(
|
|
$"{expression} did not resolve to an IEnumerable<uint> property on "
|
|
+ binding.GetType().Name);
|
|
}
|
|
return () => property.GetValue(binding) is IEnumerable<uint> values
|
|
? values.ToArray()
|
|
: Array.Empty<uint>();
|
|
}
|
|
|
|
private static bool IsBinding(string value) =>
|
|
value.Length > 2 && value[0] == '{' && value[^1] == '}';
|
|
|
|
private static void ApplyCommon(
|
|
UiElement element,
|
|
XElement source,
|
|
object binding)
|
|
{
|
|
element.Name = (string?)source.Attribute("name")
|
|
?? (string?)source.Attribute("id");
|
|
BindBool((string?)source.Attribute("visible"), binding,
|
|
value => element.Visible = value,
|
|
sourceReader => element.VisibleSource = sourceReader);
|
|
BindBool((string?)source.Attribute("enabled"), binding,
|
|
value => element.Enabled = value,
|
|
sourceReader => element.EnabledSource = sourceReader);
|
|
|
|
string? tooltip = (string?)source.Attribute("tooltip");
|
|
if (!string.IsNullOrWhiteSpace(tooltip))
|
|
{
|
|
element.RuntimeTooltipTextSource = BindString(tooltip, binding);
|
|
element.AuthoredTooltipRootElementId = RuntimeTooltipRootElementId;
|
|
element.AuthoredTooltipLayoutDid = RuntimeTooltipLayoutDid;
|
|
element.AuthoredTooltipEnabled = true;
|
|
}
|
|
}
|
|
|
|
private static void BindBool(
|
|
string? expression,
|
|
object binding,
|
|
Action<bool> setLiteral,
|
|
Action<Func<bool>> setSource)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(expression))
|
|
return;
|
|
if (!IsBinding(expression))
|
|
{
|
|
if (bool.TryParse(expression, out bool literal))
|
|
setLiteral(literal);
|
|
return;
|
|
}
|
|
|
|
PropertyInfo? property = binding.GetType().GetProperty(expression[1..^1]);
|
|
if (property is null || property.PropertyType != typeof(bool))
|
|
{
|
|
throw new FormatException(
|
|
$"{expression} did not resolve to a bool property on "
|
|
+ binding.GetType().Name);
|
|
}
|
|
setSource(() => property.GetValue(binding) is true);
|
|
}
|
|
|
|
private static Func<bool> BindRequiredBoolReader(
|
|
string? expression,
|
|
object binding,
|
|
string context)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(expression) || !IsBinding(expression))
|
|
throw new FormatException($"{context} must be a bool binding");
|
|
|
|
PropertyInfo? property = binding.GetType().GetProperty(expression[1..^1]);
|
|
if (property is null || property.PropertyType != typeof(bool))
|
|
{
|
|
throw new FormatException(
|
|
$"{expression} did not resolve to a bool property on "
|
|
+ binding.GetType().Name);
|
|
}
|
|
return () => property.GetValue(binding) is true;
|
|
}
|
|
|
|
private static Func<int> BindRequiredIntReader(
|
|
string? expression,
|
|
object binding,
|
|
string context)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(expression) || !IsBinding(expression))
|
|
throw new FormatException($"{context} must be an int binding");
|
|
PropertyInfo? property = binding.GetType().GetProperty(expression[1..^1]);
|
|
if (property is null || property.PropertyType != typeof(int))
|
|
{
|
|
throw new FormatException(
|
|
$"{expression} did not resolve to an int property on "
|
|
+ binding.GetType().Name);
|
|
}
|
|
return () => property.GetValue(binding) is int value ? value : -1;
|
|
}
|
|
|
|
private static float F(XElement e, string attr)
|
|
=> float.TryParse((string?)e.Attribute(attr), NumberStyles.Float,
|
|
CultureInfo.InvariantCulture, out var v) ? v : 0f;
|
|
|
|
private static float FOr(XElement e, string attr, float fallback)
|
|
=> float.TryParse((string?)e.Attribute(attr), NumberStyles.Float,
|
|
CultureInfo.InvariantCulture, out float value) ? value : fallback;
|
|
|
|
private static int I(XElement e, string attr, int fallback)
|
|
=> int.TryParse((string?)e.Attribute(attr), NumberStyles.Integer,
|
|
CultureInfo.InvariantCulture, out int value) ? value : fallback;
|
|
|
|
private static bool B(XElement e, string attr, bool fallback)
|
|
=> bool.TryParse((string?)e.Attribute(attr), out bool value)
|
|
? value
|
|
: fallback;
|
|
|
|
/// <summary>
|
|
/// Parses <c>#AARRGGBB</c> → RGBA <see cref="Vector4"/> (alpha first, matching
|
|
/// controls.ini convention). Falls back to opaque white on bad input.
|
|
/// </summary>
|
|
private static Vector4 Color(string? hex)
|
|
{
|
|
if (hex is { Length: 9 } && hex[0] == '#'
|
|
&& uint.TryParse(hex.AsSpan(1), NumberStyles.HexNumber,
|
|
CultureInfo.InvariantCulture, out uint argb))
|
|
return new Vector4(
|
|
((argb >> 16) & 0xFF) / 255f,
|
|
((argb >> 8) & 0xFF) / 255f,
|
|
(argb & 0xFF) / 255f,
|
|
((argb >> 24) & 0xFF) / 255f);
|
|
return Vector4.One;
|
|
}
|
|
|
|
private static Func<float?> BindFloat(string? expr, object binding)
|
|
{
|
|
var pi = Prop(expr, binding);
|
|
if (pi is null) return () => 0f;
|
|
return () => pi.GetValue(binding) switch
|
|
{
|
|
float f => f,
|
|
null => (float?)null,
|
|
var v => Convert.ToSingle(v, CultureInfo.InvariantCulture),
|
|
};
|
|
}
|
|
|
|
private static Func<uint?> BindUint(string? expr, object binding)
|
|
{
|
|
var pi = Prop(expr, binding);
|
|
if (pi is null) return () => null;
|
|
return () => pi.GetValue(binding) switch
|
|
{
|
|
uint u => u,
|
|
null => (uint?)null,
|
|
var v => Convert.ToUInt32(v, CultureInfo.InvariantCulture),
|
|
};
|
|
}
|
|
|
|
private static PropertyInfo? Prop(string? expr, object binding)
|
|
{
|
|
if (expr is null || expr.Length < 3 || expr[0] != '{' || expr[^1] != '}') return null;
|
|
return binding.GetType().GetProperty(expr[1..^1]);
|
|
}
|
|
|
|
private static uint Hex(string? s)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(s)) return 0;
|
|
var t = s.Trim();
|
|
if (t.StartsWith("0x", System.StringComparison.OrdinalIgnoreCase)) t = t[2..];
|
|
return uint.TryParse(t, System.Globalization.NumberStyles.HexNumber,
|
|
System.Globalization.CultureInfo.InvariantCulture, out var v) ? v : 0u;
|
|
}
|
|
|
|
private static AnchorEdges Anchor(string? csv)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(csv)) return AnchorEdges.Left | AnchorEdges.Top;
|
|
var a = AnchorEdges.None;
|
|
foreach (var part in csv.Split(',', System.StringSplitOptions.TrimEntries | System.StringSplitOptions.RemoveEmptyEntries))
|
|
a |= part.ToLowerInvariant() switch
|
|
{
|
|
"left" => AnchorEdges.Left,
|
|
"top" => AnchorEdges.Top,
|
|
"right" => AnchorEdges.Right,
|
|
"bottom" => AnchorEdges.Bottom,
|
|
_ => AnchorEdges.None,
|
|
};
|
|
return a == AnchorEdges.None ? AnchorEdges.Left | AnchorEdges.Top : a;
|
|
}
|
|
}
|