Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions docs/plugin-ui-markup.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ Unknown or miscased element names throw at build time.
| `button` | Button with caption and optional icon | `x y w h text color background border onclick icon iconkind` |
| `icon` | An icon | `x y w h did` or `spell` or `item`, `tooltip` |
| `meter` | Nine-slice bar | `x y w h fill cur max color` plus the nine-slice `backleft backtile backright frontleft fronttile frontright` |
| `tab` | Tab button | `x y w h text selected onclick` |
| `tab` | Tab button with optional icon | `x y w h text selected onclick icon iconkind` |
| `toggle` | Checkbox | `x y w h text checked onclick color` |
| `slider` | Horizontal slider | `x y w h value onchange min max style` |
| `field` | Single-line text input | `x y w h text maxlength clearonsubmit onchange onsubmit onup ondown color background` |
Expand Down Expand Up @@ -249,7 +249,7 @@ Do not add the prefix to them.
## Icon sources

An icon comes from one of three sources. On `<icon>` the source is whichever
attribute is set; on `<button>` and `<list>` it is `iconkind` (default `did`).
attribute is set; on `<button>`, `<tab>` and `<list>` it is `iconkind` (default `did`).

| Source | Draws |
|---|---|
Expand All @@ -261,6 +261,7 @@ attribute is set; on `<button>` and `<list>` it is `iconkind` (default `did`).
<icon x="8" y="8" w="32" h="32" did="7735" tooltip="An icon"/>
<icon x="48" y="8" w="32" h="32" spell="{SpellId}" tooltip="{SpellName}"/>
<button x="12" y="68" w="120" h="24" text="Report" icon="0x06002D14" onclick="{Report}"/>
<tab x="140" y="68" w="90" h="24" text="Spells" icon="{SpellId}" iconkind="spell" selected="{SpellsTab}" onclick="{ShowSpells}"/>
<list x="12" y="100" w="256" h="108" items="{SpellRows}" icons="{SpellIds}" iconkind="spell" selected="{SelectedIndex}"/>
```

Expand All @@ -272,6 +273,9 @@ Rules:
and centered. An id of 0 or an unresolvable id draws nothing.
- An `<icon>` with a `tooltip` is hit-testable; without one it is
click-through.
- A `<button>` or `<tab>` with `icon` draws it in a square column at the
left and centers the caption in the rest. Leave out `text` for an
icon-only control.
- A `<list>` with `icons` draws one square icon column at the left, one
icon per row, using one `iconkind` for the whole list. Rows without a
matching icon draw text only.
Expand Down
37 changes: 21 additions & 16 deletions src/AcDream.App/UI/MarkupDocument.cs
Original file line number Diff line number Diff line change
Expand Up @@ -238,21 +238,7 @@ void BindColorSource(string? expression, object model, Action<Vector4> setValue,
(string?)el.Attribute("border"), binding,
value => button.BorderColor = value,
source => button.BorderColorSource = source);
string? buttonIcon = (string?)el.Attribute("icon");
if (buttonIcon is not null)
{
string? buttonIconKind = (string?)el.Attribute("iconkind");
ValidateIconKind(buttonIconKind);
Func<uint> buttonIconReader =
BindUintLiteralOrBinding(buttonIcon, binding, "button icon");
if (icons is not null)
{
button.IconSource = BuildIconSource(
buttonIconKind,
buttonIconReader,
icons);
}
}
ApplyButtonIcon(button, el, binding, icons, "button icon");
ApplyCommon(button, el, binding);
if (onClick is not null)
button.Click += onClick;
Expand All @@ -264,7 +250,7 @@ void BindColorSource(string? expression, object model, Action<Vector4> setValue,
if (el.Attribute("iconkind") is not null)
{
throw new FormatException(
"iconkind applies to button and list; icon derives its kind from did/spell/item");
"iconkind applies to button, tab and list; icon derives its kind from did/spell/item");
}

string? didAttr = (string?)el.Attribute("did");
Expand Down Expand Up @@ -327,6 +313,7 @@ void BindColorSource(string? expression, object model, Action<Vector4> setValue,
binding,
"tab selected"),
};
ApplyButtonIcon(tab, el, binding, icons, "tab icon");
ApplyCommon(tab, el, binding);
if (tabClick is not null)
tab.Click += tabClick;
Expand Down Expand Up @@ -675,6 +662,24 @@ void RefreshMenu()
PluginMarkupTheme.Register(themedPanel, parent.Children[firstChild], el);
}

/// <summary>
/// Reads a button-like control's optional icon/iconkind pair. Both are
/// validated at build even with no resolver wired, so a typo fails early.
/// </summary>
private static void ApplyButtonIcon(
UiSimpleButton button, XElement el, object binding,
IMarkupIconResolver? icons, string context)
{
string? iconExpression = (string?)el.Attribute("icon");
if (iconExpression is null)
return;
string? iconKind = (string?)el.Attribute("iconkind");
ValidateIconKind(iconKind);
Func<uint> iconReader = BindUintLiteralOrBinding(iconExpression, binding, context);
if (icons is not null)
button.IconSource = BuildIconSource(iconKind, iconReader, icons);
}

private static string ValidateIconKind(string? iconKind, string context = "iconkind") =>
(iconKind ?? "did") switch
{
Expand Down
6 changes: 6 additions & 0 deletions src/AcDream.App/UI/UiMarkupTabButton.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,16 @@ public sealed class UiMarkupTabButton : UiSimpleButton

public PluginUiPalette? ThemePalette { get; set; }

/// <summary>The alpha an unselected tab draws its icon at, so it recedes like its caption.</summary>
internal const float InactiveIconAlpha = 0.55f;

public Func<bool>? SelectedSource { get; set; }

public bool IsSelected => SelectedSource?.Invoke() ?? false;

private protected override Vector4 IconTint =>
IsSelected ? base.IconTint : base.IconTint with { W = base.IconTint.W * InactiveIconAlpha };

public UiMarkupTabButton()
{
BackgroundColor = Vector4.Zero;
Expand Down
5 changes: 4 additions & 1 deletion src/AcDream.App/UI/UiPanel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,9 @@ public class UiSimpleButton : UiPanel

public Func<(uint tex, int w, int h)>? IconSource { get; set; }

/// <summary>The color the icon is drawn in; white draws it as authored.</summary>
private protected virtual Vector4 IconTint => Vector4.One;

public event System.Action? Click;

private readonly UiKeyboardActivation _keyboardActivation = new();
Expand Down Expand Up @@ -147,7 +150,7 @@ protected override void OnDraw(UiRenderContext ctx)
3f + (extent - drawWidth) * 0.5f,
(Height - drawHeight) * 0.5f,
drawWidth, drawHeight,
0f, 0f, 1f, 1f, Vector4.One);
0f, 0f, 1f, 1f, IconTint);
}
}

Expand Down
144 changes: 144 additions & 0 deletions tests/AcDream.App.Tests/UI/MarkupIconTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,150 @@ public void ButtonIcon_NoResolverWired_IconSourceStaysNull()
Assert.Null(button.IconSource);
}

// ── <tab icon>: same attributes and resolver dispatch as <button icon> ──

private sealed class TabIconBinding
{
public bool Selected { get; set; }
public uint SpellId { get; set; } = 42u;
}

[Fact]
public void TabIcon_DefaultsToDidKind_AndResolvesThroughTheResolver()
{
const string xml =
"<panel x=\"0\" y=\"0\" w=\"100\" h=\"60\">" +
"<tab x=\"0\" y=\"0\" w=\"60\" h=\"20\" text=\"Spells\" " +
"selected=\"{Selected}\" icon=\"7735\"/>" +
"</panel>";
var resolver = new FakeIconResolver();

var panel = MarkupDocument.Build(xml, new TabIconBinding(), Sprite, icons: resolver);
var tab = Assert.IsType<UiMarkupTabButton>(panel.Children[0]);

Assert.NotNull(tab.IconSource);
(uint tex, _, _) = tab.IconSource!();
Assert.Equal(resolver.DidTexture, tex);
Assert.Equal(("did", 0x06001E37u), resolver.Calls[^1]);
}

[Fact]
public void TabIcon_BoundSpell_ReReadsTheBindingEveryFrame()
{
const string xml =
"<panel x=\"0\" y=\"0\" w=\"100\" h=\"60\">" +
"<tab x=\"0\" y=\"0\" w=\"20\" h=\"20\" selected=\"{Selected}\" " +
"icon=\"{SpellId}\" iconkind=\"spell\"/>" +
"</panel>";
var resolver = new FakeIconResolver();
var binding = new TabIconBinding();

var panel = MarkupDocument.Build(xml, binding, Sprite, icons: resolver);
var tab = Assert.IsType<UiMarkupTabButton>(panel.Children[0]);

tab.IconSource!();
Assert.Equal(("spell", 42u), resolver.Calls[^1]);

binding.SpellId = 99u;
tab.IconSource!();
Assert.Equal(("spell", 99u), resolver.Calls[^1]);
}

private static List<Vector4> IconColors(UiElement element, uint texture)
{
var device = new RecordingGpuDevice();
var renderer = new TextRenderer(device, new NullGpuFrameSource(), "unused");
renderer.Begin(new Vector2(800f, 600f));
element.DrawSelfAndChildren(new UiRenderContext(renderer, new Vector2(800f, 600f)));

var colors = new List<Vector4>();
foreach ((uint drawn, IReadOnlyList<float> verts) in renderer.DebugSpriteSegmentVerts)
{
if (drawn != texture) continue;
for (int i = 0; i + 8 <= verts.Count; i += 8)
colors.Add(new Vector4(verts[i + 4], verts[i + 5], verts[i + 6], verts[i + 7]));
}
return colors;
}

[Fact]
public void TabIcon_DrawsFullWhenSelected_AndDimmedWhenNot()
{
const string xml =
"<panel x=\"0\" y=\"0\" w=\"100\" h=\"60\">" +
"<tab x=\"0\" y=\"0\" w=\"60\" h=\"20\" text=\"Spells\" " +
"selected=\"{Selected}\" icon=\"7735\"/>" +
"</panel>";
var resolver = new FakeIconResolver();
var binding = new TabIconBinding { Selected = true };
var panel = MarkupDocument.Build(xml, binding, Sprite, icons: resolver);
var tab = Assert.IsType<UiMarkupTabButton>(panel.Children[0]);

List<Vector4> selected = IconColors(tab, resolver.DidTexture);
Assert.NotEmpty(selected);
Assert.All(selected, c => Assert.Equal(1f, c.W, 3));

binding.Selected = false;
List<Vector4> unselected = IconColors(tab, resolver.DidTexture);
Assert.NotEmpty(unselected);
Assert.All(unselected, c => Assert.Equal(UiMarkupTabButton.InactiveIconAlpha, c.W, 3));
}

[Fact]
public void ButtonIcon_IsNotDimmed()
{
const string xml =
"<panel x=\"0\" y=\"0\" w=\"100\" h=\"60\">" +
"<button x=\"0\" y=\"0\" w=\"60\" h=\"20\" text=\"Go\" icon=\"7735\"/>" +
"</panel>";
var resolver = new FakeIconResolver();
var panel = MarkupDocument.Build(xml, new IconBinding(), Sprite, icons: resolver);

List<Vector4> colors = IconColors(panel.Children[0], resolver.DidTexture);
Assert.NotEmpty(colors);
Assert.All(colors, c => Assert.Equal(1f, c.W, 3));
}

[Fact]
public void TabWithoutIconAttribute_HasNoIconSource()
{
const string xml =
"<panel x=\"0\" y=\"0\" w=\"100\" h=\"60\">" +
"<tab x=\"0\" y=\"0\" w=\"60\" h=\"20\" text=\"Spells\" selected=\"{Selected}\"/>" +
"</panel>";

var panel = MarkupDocument.Build(xml, new TabIconBinding(), Sprite, icons: new FakeIconResolver());
var tab = Assert.IsType<UiMarkupTabButton>(panel.Children[0]);

Assert.Null(tab.IconSource);
}

[Fact]
public void TabIcon_UnknownIconKind_ThrowsAtBuild_EvenWithNoResolverWired()
{
const string xml =
"<panel x=\"0\" y=\"0\" w=\"100\" h=\"60\">" +
"<tab x=\"0\" y=\"0\" w=\"60\" h=\"20\" text=\"Spells\" selected=\"{Selected}\" " +
"icon=\"1\" iconkind=\"spel\"/>" +
"</panel>";

Assert.Throws<FormatException>(
() => MarkupDocument.Build(xml, new TabIconBinding(), Sprite));
}

[Fact]
public void TabIcon_MalformedBinding_ThrowsAtBuild_EvenWithNoResolverWired()
{
const string xml =
"<panel x=\"0\" y=\"0\" w=\"100\" h=\"60\">" +
"<tab x=\"0\" y=\"0\" w=\"60\" h=\"20\" text=\"Spells\" selected=\"{Selected}\" " +
"icon=\"{Typo}\"/>" +
"</panel>";

Assert.Throws<FormatException>(
() => MarkupDocument.Build(xml, new TabIconBinding(), Sprite));
}

private sealed class IntIconBinding
{
public int IconIdInt { get; set; } = 42;
Expand Down
Loading