using System; using System.Globalization; using System.Numerics; using System.Reflection; using System.Xml.Linq; namespace AcDream.App.UI; /// /// Parses our KSML-style panel markup (mirrors retail's ElementDesc fields) /// into a live subtree. {Binding} 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. /// 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; /// Raw XML markup for a single panel. /// Object whose public properties are bound to {PropName} attributes. /// Surface id → (GL handle, width, height) for chrome sprites. /// Optional controls.ini stylesheet for the title color. /// /// 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. /// public static UiNineSlicePanel Build( string xml, object binding, Func 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 , 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( $" 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 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( $"