feat(ui): port retail spellbook interactions
Resolve the authored spell shortcut row prototype so the spellbook presents the retail icon, name, separator, selected overlay, and scrollbar geometry. Port exact school and level filters, stable display ordering, selection exposure, and learned-spell drags into the open favorite bar. Route deletion through the shared retail confirmation dialog and send CM_Magic::Event_RemoveSpell only after an affirmative answer, leaving the inbound server notice authoritative for list state. Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
parent
ad30c37a48
commit
ac2ca8f965
17 changed files with 727 additions and 47 deletions
72
src/AcDream.App/UI/Layout/SpellbookRowStyle.cs
Normal file
72
src/AcDream.App/UI/Layout/SpellbookRowStyle.cs
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
using System.Numerics;
|
||||
using DatReaderWriter;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Resolved presentation of retail's spell-shortcut row prototype. The spellbook
|
||||
/// list points at this prototype through ItemList attribute <c>0x1000000E</c>;
|
||||
/// retail clones it for every learned spell.
|
||||
/// </summary>
|
||||
public readonly record struct SpellbookRowStyle(
|
||||
float Width,
|
||||
float Height,
|
||||
float IconLeft,
|
||||
float IconTop,
|
||||
float IconWidth,
|
||||
float IconHeight,
|
||||
float LabelLeft,
|
||||
float LabelWidth,
|
||||
uint FontDid,
|
||||
Vector4 LabelColor,
|
||||
uint BackgroundSprite,
|
||||
uint SelectedSprite)
|
||||
{
|
||||
public const uint CatalogLayoutId = 0x21000037u;
|
||||
public const uint PrototypeId = 0x10000343u;
|
||||
public const uint SelectedOverlayId = 0x10000342u;
|
||||
public const uint LabelId = 0x10000344u;
|
||||
public const uint IconId = 0x1000033Bu;
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the complete row geometry and media from the installed retail DAT.
|
||||
/// Returns null if the authored prototype is incomplete; callers then leave the
|
||||
/// panel unbound rather than inventing replacement art.
|
||||
/// </summary>
|
||||
public static SpellbookRowStyle? TryLoad(DatCollection dats)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dats);
|
||||
ElementInfo? prototype = LayoutImporter.ImportInfos(dats, CatalogLayoutId, PrototypeId);
|
||||
if (prototype is null
|
||||
|| Find(prototype, SelectedOverlayId) is not { } selected
|
||||
|| Find(prototype, LabelId) is not { } label
|
||||
|| Find(prototype, IconId) is not { } icon
|
||||
|| !prototype.StateMedia.TryGetValue("", out var background)
|
||||
|| !selected.StateMedia.TryGetValue("", out var selection)
|
||||
|| prototype.Width <= 0f
|
||||
|| prototype.Height <= 0f)
|
||||
return null;
|
||||
|
||||
return new SpellbookRowStyle(
|
||||
prototype.Width,
|
||||
prototype.Height,
|
||||
icon.X,
|
||||
icon.Y,
|
||||
icon.Width,
|
||||
icon.Height,
|
||||
label.X,
|
||||
label.Width,
|
||||
label.FontDid,
|
||||
label.FontColor ?? Vector4.One,
|
||||
background.File,
|
||||
selection.File);
|
||||
}
|
||||
|
||||
private static ElementInfo? Find(ElementInfo root, uint id)
|
||||
{
|
||||
if (root.Id == id) return root;
|
||||
foreach (ElementInfo child in root.Children)
|
||||
if (Find(child, id) is { } found) return found;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -28,6 +28,8 @@ public sealed class SpellbookWindowController : IRetainedPanelController
|
|||
public const uint SpellPageId = 0x100002ACu;
|
||||
public const uint ComponentPageId = 0x100002ADu;
|
||||
public const uint SpellListId = 0x10000295u;
|
||||
public const uint SpellScrollbarId = 0x10000296u;
|
||||
public const uint DeleteButtonId = 0x100002A5u;
|
||||
public const uint ComponentListId = 0x10000464u;
|
||||
public const uint ComponentScrollbarId = 0x10000465u;
|
||||
|
||||
|
|
@ -52,6 +54,8 @@ public sealed class SpellbookWindowController : IRetainedPanelController
|
|||
private readonly Action<uint> _selectObject;
|
||||
private readonly Action<uint> _addFavorite;
|
||||
private readonly Action<uint> _sendFilter;
|
||||
private readonly Action<uint> _removeSpell;
|
||||
private readonly Action<string, Action<bool>> _showConfirmation;
|
||||
private readonly Action<uint, uint> _setDesiredComponent;
|
||||
private readonly Action _close;
|
||||
private readonly UiElement _spellPage;
|
||||
|
|
@ -59,9 +63,12 @@ public sealed class SpellbookWindowController : IRetainedPanelController
|
|||
private readonly UiElement _spellTab;
|
||||
private readonly UiElement _componentTab;
|
||||
private readonly UiButton _closeButton;
|
||||
private readonly UiButton _deleteButton;
|
||||
private readonly UiItemList _spellList;
|
||||
private readonly UiItemList _componentList;
|
||||
private readonly List<(UiButton Button, uint Mask)> _filters = new();
|
||||
private readonly SpellbookRowStyle _rowStyle;
|
||||
private readonly UiDatFont? _rowFont;
|
||||
private uint? _selectedSpell;
|
||||
private bool _spellsDirty;
|
||||
private bool _componentsDirty;
|
||||
|
|
@ -80,6 +87,8 @@ public sealed class SpellbookWindowController : IRetainedPanelController
|
|||
Action<uint> selectObject,
|
||||
Action<uint> addFavorite,
|
||||
Action<uint> sendFilter,
|
||||
Action<uint> removeSpell,
|
||||
Action<string, Action<bool>> showConfirmation,
|
||||
Action<uint, uint> setDesiredComponent,
|
||||
Action close,
|
||||
UiElement spellPage,
|
||||
|
|
@ -87,8 +96,11 @@ public sealed class SpellbookWindowController : IRetainedPanelController
|
|||
UiElement spellTab,
|
||||
UiElement componentTab,
|
||||
UiButton closeButton,
|
||||
UiButton deleteButton,
|
||||
UiItemList spellList,
|
||||
UiItemList componentList)
|
||||
UiItemList componentList,
|
||||
SpellbookRowStyle rowStyle,
|
||||
UiDatFont? rowFont)
|
||||
{
|
||||
_spellbook = spellbook;
|
||||
_objects = objects;
|
||||
|
|
@ -100,6 +112,8 @@ public sealed class SpellbookWindowController : IRetainedPanelController
|
|||
_selectObject = selectObject;
|
||||
_addFavorite = addFavorite;
|
||||
_sendFilter = sendFilter;
|
||||
_removeSpell = removeSpell;
|
||||
_showConfirmation = showConfirmation;
|
||||
_setDesiredComponent = setDesiredComponent;
|
||||
_close = close;
|
||||
_spellPage = spellPage;
|
||||
|
|
@ -107,12 +121,16 @@ public sealed class SpellbookWindowController : IRetainedPanelController
|
|||
_spellTab = spellTab;
|
||||
_componentTab = componentTab;
|
||||
_closeButton = closeButton;
|
||||
_deleteButton = deleteButton;
|
||||
_spellList = spellList;
|
||||
_componentList = componentList;
|
||||
_rowStyle = rowStyle;
|
||||
_rowFont = rowFont;
|
||||
|
||||
SetClick(spellTab, () => ShowPage(SpellbookWindowPage.Spells));
|
||||
SetClick(componentTab, () => ShowPage(SpellbookWindowPage.Components));
|
||||
closeButton.OnClick = close;
|
||||
deleteButton.OnClick = RequestDeleteSelected;
|
||||
foreach ((uint id, uint mask) in FilterButtons)
|
||||
{
|
||||
if (layout.FindElement(id) is not UiButton button) continue;
|
||||
|
|
@ -121,7 +139,7 @@ public sealed class SpellbookWindowController : IRetainedPanelController
|
|||
_filters.Add((button, mask));
|
||||
}
|
||||
|
||||
ConfigureSpellList();
|
||||
ConfigureSpellList(layout);
|
||||
ConfigureComponentList(layout);
|
||||
_spellbook.SpellbookChanged += OnSpellbookChanged;
|
||||
_spellbook.DesiredComponentsChanged += OnDesiredComponentsChanged;
|
||||
|
|
@ -148,14 +166,19 @@ public sealed class SpellbookWindowController : IRetainedPanelController
|
|||
Action<uint> selectObject,
|
||||
Action<uint> addFavorite,
|
||||
Action<uint> sendFilter,
|
||||
Action<uint> removeSpell,
|
||||
Action<string, Action<bool>> showConfirmation,
|
||||
Action<uint, uint> setDesiredComponent,
|
||||
Action close)
|
||||
Action close,
|
||||
SpellbookRowStyle rowStyle,
|
||||
UiDatFont? rowFont)
|
||||
{
|
||||
if (layout.FindElement(SpellPageId) is not { } spellPage
|
||||
|| layout.FindElement(ComponentPageId) is not { } componentPage
|
||||
|| layout.FindElement(SpellTabId) is not { } spellTab
|
||||
|| layout.FindElement(ComponentTabId) is not { } componentTab
|
||||
|| layout.FindElement(CloseId) is not UiButton closeButton
|
||||
|| layout.FindElement(DeleteButtonId) is not UiButton deleteButton
|
||||
|| layout.FindElement(SpellListId) is not UiItemList spellList)
|
||||
return null;
|
||||
|
||||
|
|
@ -179,9 +202,10 @@ public sealed class SpellbookWindowController : IRetainedPanelController
|
|||
layout, spellbook, objects, playerGuid, components,
|
||||
resolveSpellIcon, resolveComponentIcon,
|
||||
spellLevel, selectObject,
|
||||
addFavorite, sendFilter, setDesiredComponent, close,
|
||||
addFavorite, sendFilter, removeSpell, showConfirmation,
|
||||
setDesiredComponent, close,
|
||||
spellPage, componentPage, spellTab, componentTab, closeButton,
|
||||
spellList, componentList);
|
||||
deleteButton, spellList, componentList, rowStyle, rowFont);
|
||||
}
|
||||
|
||||
public void ShowPage(SpellbookWindowPage page)
|
||||
|
|
@ -193,11 +217,13 @@ public sealed class SpellbookWindowController : IRetainedPanelController
|
|||
SetTabOpen(_componentTab, page == SpellbookWindowPage.Components);
|
||||
}
|
||||
|
||||
private void ConfigureSpellList()
|
||||
private void ConfigureSpellList(ImportedLayout layout)
|
||||
{
|
||||
_spellList.Columns = 6;
|
||||
_spellList.CellWidth = 32f;
|
||||
_spellList.CellHeight = 32f;
|
||||
_spellList.Columns = 1;
|
||||
_spellList.CellWidth = Math.Max(1f, _spellList.Width);
|
||||
_spellList.CellHeight = _rowStyle.Height;
|
||||
if (layout.FindElement(SpellScrollbarId) is UiScrollbar scrollbar)
|
||||
scrollbar.Model = _spellList.Scroll;
|
||||
}
|
||||
|
||||
private void ConfigureComponentList(ImportedLayout layout)
|
||||
|
|
@ -211,9 +237,12 @@ public sealed class SpellbookWindowController : IRetainedPanelController
|
|||
|
||||
private void ToggleFilter(uint mask)
|
||||
{
|
||||
// gmSpellbookUI::UpdateFilter @ 0x0048B5E0 persists only the changed
|
||||
// filter bitfield, rebuilds from learned spells, and returns to row zero.
|
||||
uint filters = _spellbook.SpellbookFilters ^ mask;
|
||||
_spellbook.SetSpellbookFilters(filters);
|
||||
_sendFilter(filters);
|
||||
_spellList.Scroll.SetScrollY(0);
|
||||
}
|
||||
|
||||
private void RebuildAll()
|
||||
|
|
@ -230,11 +259,12 @@ public sealed class SpellbookWindowController : IRetainedPanelController
|
|||
foreach ((UiButton button, uint mask) in _filters)
|
||||
button.Selected = (effective & mask) != 0;
|
||||
|
||||
// gmSpellbookUI::GetSortedInsertionPlace @ 0x0048B440 inserts before
|
||||
// the first GREATER display order. Stable OrderBy preserves ties.
|
||||
SpellMetadata[] spells = _spellbook.LearnedSpells
|
||||
.Select(id => _spellbook.TryGetMetadata(id, out SpellMetadata metadata) ? metadata : null)
|
||||
.Where(metadata => metadata is not null && IsVisible(metadata, effective))
|
||||
.OrderBy(metadata => metadata!.SortKey)
|
||||
.ThenBy(metadata => metadata!.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.Cast<SpellMetadata>()
|
||||
.ToArray();
|
||||
|
||||
|
|
@ -249,7 +279,21 @@ public sealed class SpellbookWindowController : IRetainedPanelController
|
|||
EntryId = spellId,
|
||||
CatalogIconTexture = _resolveSpellIcon(spellId),
|
||||
Label = metadata.Name,
|
||||
ShowLabel = true,
|
||||
BackgroundSprite = _rowStyle.BackgroundSprite,
|
||||
SelectedSprite = _rowStyle.SelectedSprite,
|
||||
SelectionBehindContent = true,
|
||||
LabelFont = _rowFont,
|
||||
LabelColor = _rowStyle.LabelColor,
|
||||
IconLeft = _rowStyle.IconLeft,
|
||||
IconTop = _rowStyle.IconTop,
|
||||
IconWidth = _rowStyle.IconWidth,
|
||||
IconHeight = _rowStyle.IconHeight,
|
||||
LabelLeft = _rowStyle.LabelLeft,
|
||||
LabelWidth = _rowStyle.LabelWidth,
|
||||
SpriteResolve = _spellList.SpriteResolve,
|
||||
CatalogDragPayload = new SpellbookShortcutDragPayload(spellId),
|
||||
DragBegan = _ => SelectSpell(spellId),
|
||||
};
|
||||
slot.Clicked = () => SelectSpell(spellId);
|
||||
// gmSpellbookUI double-click publishes AddSpellShortcut; the open
|
||||
|
|
@ -399,8 +443,33 @@ public sealed class SpellbookWindowController : IRetainedPanelController
|
|||
|
||||
private void SelectSpell(uint spellId)
|
||||
{
|
||||
// gmSpellbookUI::SetSelected @ 0x0048B540 toggles the separate overlay
|
||||
// for every row and asks the listbox to expose the matching row.
|
||||
_selectedSpell = spellId;
|
||||
SyncSpellSelection();
|
||||
for (int i = 0; i < _spellList.GetNumUIItems(); i++)
|
||||
if (_spellList.GetItem(i) is UiCatalogSlot slot && slot.EntryId == spellId)
|
||||
{
|
||||
_spellList.ScrollItemIntoView(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void RequestDeleteSelected()
|
||||
{
|
||||
// gmSpellbookUI::DeleteSpell @ 0x0048BD50 stores the spell id in the
|
||||
// shared callback dialog; DeleteSpellDialogCallback @ 0x0048BC10 sends
|
||||
// only after an affirmative result. The inbound remove notice owns UI state.
|
||||
if (_selectedSpell is not uint spellId
|
||||
|| !_spellbook.TryGetMetadata(spellId, out SpellMetadata metadata))
|
||||
return;
|
||||
|
||||
string message = $"Are you sure you want to remove {metadata.Name} from your spellbook? "
|
||||
+ "You will no longer be able to cast this spell unless you learn it again!";
|
||||
_showConfirmation(message, accepted =>
|
||||
{
|
||||
if (accepted) _removeSpell(spellId);
|
||||
});
|
||||
}
|
||||
|
||||
private void SyncSpellSelection()
|
||||
|
|
@ -454,6 +523,7 @@ public sealed class SpellbookWindowController : IRetainedPanelController
|
|||
SetClick(_spellTab, null);
|
||||
SetClick(_componentTab, null);
|
||||
_closeButton.OnClick = null;
|
||||
_deleteButton.OnClick = null;
|
||||
foreach ((UiButton button, _) in _filters) button.OnClick = null;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -274,6 +274,12 @@ public sealed class SpellcastingUiController : IRetainedPanelController
|
|||
UiItemList? list = _lists[tab];
|
||||
if (list is null) continue;
|
||||
IReadOnlyList<uint> favorites = _spellbook.GetFavorites(tab);
|
||||
int targetTab = tab;
|
||||
list.CatalogDropped = (payload, x, _) =>
|
||||
{
|
||||
if (payload is SpellbookShortcutDragPayload shortcut)
|
||||
DropSpellbookShortcut(shortcut, targetTab, DropPosition(list, x));
|
||||
};
|
||||
using (list.DeferLayout())
|
||||
{
|
||||
list.Flush();
|
||||
|
|
@ -297,7 +303,9 @@ public sealed class SpellcastingUiController : IRetainedPanelController
|
|||
Dropped = payload =>
|
||||
{
|
||||
if (payload is SpellFavoriteDragPayload favorite)
|
||||
DropFavorite(favorite, tab, position);
|
||||
DropFavorite(favorite, targetTab, position);
|
||||
else if (payload is SpellbookShortcutDragPayload shortcut)
|
||||
DropSpellbookShortcut(shortcut, targetTab, position);
|
||||
},
|
||||
};
|
||||
slot.Clicked = () => SelectSpell(id);
|
||||
|
|
@ -322,6 +330,27 @@ public sealed class SpellcastingUiController : IRetainedPanelController
|
|||
_selected[targetTab] = payload.SpellId;
|
||||
}
|
||||
|
||||
private void DropSpellbookShortcut(
|
||||
SpellbookShortcutDragPayload payload,
|
||||
int targetTab,
|
||||
int targetPosition)
|
||||
{
|
||||
// gmSpellbookUI::ListenToElementMessage @ 0x0048BFD0 publishes
|
||||
// CM_Magic::SendNotice_AddSpellShortcut; the open spellcasting menu
|
||||
// chooses the favorite tab/position and persists it.
|
||||
_addFavorite?.Invoke(targetTab, targetPosition, payload.SpellId);
|
||||
_spellbook.SetFavorite(targetTab, targetPosition, payload.SpellId);
|
||||
_selected[targetTab] = payload.SpellId;
|
||||
}
|
||||
|
||||
private static int DropPosition(UiItemList list, int localX)
|
||||
{
|
||||
int position = list.CellWidth <= 0f
|
||||
? list.GetNumUIItems()
|
||||
: (int)MathF.Floor(MathF.Max(0f, localX) / list.CellWidth);
|
||||
return Math.Clamp(position, 0, list.GetNumUIItems());
|
||||
}
|
||||
|
||||
private void OnSpellbookChanged() => _favoritesDirty = true;
|
||||
|
||||
private void OnObjectChanged(ClientObject _) => _endowmentDirty = true;
|
||||
|
|
@ -465,6 +494,12 @@ public sealed class SpellcastingUiController : IRetainedPanelController
|
|||
|
||||
public sealed record SpellFavoriteDragPayload(int SourceTab, int SourcePosition, uint SpellId);
|
||||
|
||||
/// <summary>
|
||||
/// A learned-spell shortcut carried from the spellbook. Unlike a favorite drag,
|
||||
/// lifting this payload never removes anything from its source collection.
|
||||
/// </summary>
|
||||
public sealed record SpellbookShortcutDragPayload(uint SpellId);
|
||||
|
||||
internal static class FavoriteListExtensions
|
||||
{
|
||||
public static int IndexOf(this IReadOnlyList<uint> values, uint value)
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ public sealed record MagicRuntimeBindings(
|
|||
Action<int, int, uint> AddFavorite,
|
||||
Action<int, uint> RemoveFavorite,
|
||||
Action<uint> SendSpellbookFilter,
|
||||
Action<uint> RemoveSpell,
|
||||
Action<uint, uint> SetDesiredComponent,
|
||||
Func<double> ServerTime);
|
||||
|
||||
|
|
@ -707,6 +708,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
private void MountSpellbook()
|
||||
{
|
||||
ImportedLayout? layout;
|
||||
SpellbookRowStyle? rowStyle;
|
||||
lock (_bindings.Assets.DatLock)
|
||||
{
|
||||
layout = LayoutImporter.Import(
|
||||
|
|
@ -716,13 +718,19 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
_bindings.Assets.ResolveSprite,
|
||||
_bindings.Assets.DefaultFont,
|
||||
_bindings.Assets.ResolveFont);
|
||||
rowStyle = SpellbookRowStyle.TryLoad(_bindings.Assets.Dats);
|
||||
}
|
||||
if (layout is null)
|
||||
if (layout is null || rowStyle is null)
|
||||
{
|
||||
Console.WriteLine("[M3] spellbook: LayoutDesc 0x21000034 not found.");
|
||||
Console.WriteLine("[M3] spellbook: layout or authored UIItem row prototype not found.");
|
||||
return;
|
||||
}
|
||||
|
||||
UiDatFont? rowFont = rowStyle.Value.FontDid == 0u
|
||||
? _bindings.Assets.DefaultFont
|
||||
: _bindings.Assets.ResolveFont(rowStyle.Value.FontDid)
|
||||
?? _bindings.Assets.DefaultFont;
|
||||
|
||||
SpellbookWindowController? controller = Layout.SpellbookWindowController.Bind(
|
||||
layout,
|
||||
_bindings.Magic.Spellbook,
|
||||
|
|
@ -735,8 +743,12 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
_bindings.Magic.SelectObject,
|
||||
spellId => SpellcastingUiController?.AddFavorite(spellId),
|
||||
_bindings.Magic.SendSpellbookFilter,
|
||||
_bindings.Magic.RemoveSpell,
|
||||
(message, completed) => ShowConfirmation(message, completed),
|
||||
_bindings.Magic.SetDesiredComponent,
|
||||
() => CloseWindow(WindowNames.Spellbook));
|
||||
() => CloseWindow(WindowNames.Spellbook),
|
||||
rowStyle.Value,
|
||||
rowFont);
|
||||
if (controller is null)
|
||||
{
|
||||
Console.WriteLine("[M3] spellbook: required controls missing in LayoutDesc 0x21000034.");
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
using System;
|
||||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
|
||||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Icon-list cell for non-weenie catalog entries such as spells and components.
|
||||
/// It reuses the retail UIItemList geometry without pretending a spell id is an
|
||||
/// object GUID and without participating in item drag/drop.
|
||||
/// object GUID; catalog drags stay distinct from physical item drag/drop.
|
||||
/// </summary>
|
||||
public sealed class UiCatalogSlot : UiItemSlot
|
||||
{
|
||||
|
|
@ -17,6 +18,16 @@ public sealed class UiCatalogSlot : UiItemSlot
|
|||
public string Label { get; set; } = string.Empty;
|
||||
public string? Detail { get; set; }
|
||||
public bool ShowLabel { get; init; }
|
||||
public uint BackgroundSprite { get; init; }
|
||||
public UiDatFont? LabelFont { get; init; }
|
||||
public Vector4 LabelColor { get; init; } = new(0.92f, 0.88f, 0.70f, 1f);
|
||||
public float IconLeft { get; init; }
|
||||
public float IconTop { get; init; }
|
||||
public float IconWidth { get; init; }
|
||||
public float IconHeight { get; init; }
|
||||
public float LabelLeft { get; init; }
|
||||
public float LabelWidth { get; init; }
|
||||
public bool SelectionBehindContent { get; init; }
|
||||
|
||||
public new Action? Clicked { get; set; }
|
||||
public new Action? DoubleClicked { get; set; }
|
||||
|
|
@ -67,31 +78,78 @@ public sealed class UiCatalogSlot : UiItemSlot
|
|||
|
||||
protected override void OnDraw(UiRenderContext ctx)
|
||||
{
|
||||
float iconSize = ShowLabel ? MathF.Min(32f, Height) : Width;
|
||||
if (BackgroundSprite != 0u && SpriteResolve is not null)
|
||||
{
|
||||
var (texture, _, _) = SpriteResolve(BackgroundSprite);
|
||||
if (texture != 0u)
|
||||
ctx.DrawSprite(texture, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One);
|
||||
}
|
||||
|
||||
if (SelectionBehindContent)
|
||||
DrawSelection(ctx);
|
||||
|
||||
float iconWidth = IconWidth > 0f ? IconWidth : ShowLabel ? MathF.Min(32f, Height) : Width;
|
||||
float iconHeight = IconHeight > 0f ? IconHeight : ShowLabel ? MathF.Min(32f, Height) : Height;
|
||||
if (CatalogIconTexture != 0)
|
||||
ctx.DrawSprite(CatalogIconTexture, 0f, 0f, iconSize, iconSize, 0f, 0f, 1f, 1f, Vector4.One);
|
||||
ctx.DrawSprite(CatalogIconTexture, IconLeft, IconTop, iconWidth, iconHeight, 0f, 0f, 1f, 1f, Vector4.One);
|
||||
else if (SpriteResolve is not null && EmptySprite != 0)
|
||||
{
|
||||
var (texture, _, _) = SpriteResolve(EmptySprite);
|
||||
if (texture != 0)
|
||||
ctx.DrawSprite(texture, 0f, 0f, iconSize, iconSize, 0f, 0f, 1f, 1f, Vector4.One);
|
||||
ctx.DrawSprite(texture, IconLeft, IconTop, iconWidth, iconHeight, 0f, 0f, 1f, 1f, Vector4.One);
|
||||
}
|
||||
|
||||
if (CatalogOverlayTexture != 0)
|
||||
ctx.DrawSprite(CatalogOverlayTexture, 0f, 0f, iconSize, iconSize, 0f, 0f, 1f, 1f, Vector4.One);
|
||||
ctx.DrawSprite(CatalogOverlayTexture, IconLeft, IconTop, iconWidth, iconHeight, 0f, 0f, 1f, 1f, Vector4.One);
|
||||
|
||||
if (Selected && SpriteResolve is not null && SelectedSprite != 0)
|
||||
{
|
||||
var (texture, _, _) = SpriteResolve(SelectedSprite);
|
||||
if (texture != 0)
|
||||
ctx.DrawSprite(texture, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One);
|
||||
}
|
||||
if (!SelectionBehindContent)
|
||||
DrawSelection(ctx);
|
||||
|
||||
if (ShowLabel)
|
||||
{
|
||||
ctx.DrawString(Label, iconSize + 4f, 3f, new Vector4(0.92f, 0.88f, 0.70f, 1f));
|
||||
float labelLeft = LabelLeft > 0f ? LabelLeft : IconLeft + iconWidth + 4f;
|
||||
float labelWidth = LabelWidth > 0f ? LabelWidth : MathF.Max(0f, Width - labelLeft);
|
||||
if (LabelFont is not null)
|
||||
{
|
||||
string text = FitText(Label, labelWidth, LabelFont.MeasureWidth);
|
||||
float y = MathF.Max(0f, (Height - LabelFont.LineHeight) * 0.5f);
|
||||
ctx.DrawStringDat(LabelFont, text, labelLeft, y, LabelColor);
|
||||
}
|
||||
else
|
||||
{
|
||||
BitmapFont? font = ctx.DefaultFont;
|
||||
string text = font is null ? Label : FitText(Label, labelWidth, font.MeasureWidth);
|
||||
float y = font is null ? 3f : MathF.Max(0f, (Height - font.LineHeight) * 0.5f);
|
||||
ctx.DrawString(text, labelLeft, y, LabelColor, font);
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(Detail))
|
||||
ctx.DrawString(Detail!, MathF.Max(iconSize + 4f, Width - 48f), 3f, Vector4.One);
|
||||
ctx.DrawString(Detail!, MathF.Max(labelLeft, Width - 48f), 3f, Vector4.One);
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawSelection(UiRenderContext ctx)
|
||||
{
|
||||
if (!Selected || SpriteResolve is null || SelectedSprite == 0u) return;
|
||||
var (texture, _, _) = SpriteResolve(SelectedSprite);
|
||||
if (texture != 0u)
|
||||
ctx.DrawSprite(texture, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One);
|
||||
}
|
||||
|
||||
private static string FitText(string text, float width, Func<string, float> measure)
|
||||
{
|
||||
if (width <= 0f || string.IsNullOrEmpty(text) || measure(text) <= width)
|
||||
return text;
|
||||
|
||||
const string ellipsis = "...";
|
||||
float ellipsisWidth = measure(ellipsis);
|
||||
if (ellipsisWidth >= width) return string.Empty;
|
||||
int low = 0, high = text.Length;
|
||||
while (low < high)
|
||||
{
|
||||
int mid = (low + high + 1) / 2;
|
||||
if (measure(text[..mid]) + ellipsisWidth <= width) low = mid;
|
||||
else high = mid - 1;
|
||||
}
|
||||
return text[..low] + ellipsis;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,13 @@ public sealed class UiItemList : UiElement
|
|||
|
||||
public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional non-weenie catalog drop target. Used by retail spell shortcuts;
|
||||
/// physical-item drops continue through <see cref="DragHandler"/>.
|
||||
/// Coordinates are local to this list.
|
||||
/// </summary>
|
||||
public Action<object, int, int>? CatalogDropped { get; set; }
|
||||
|
||||
private uint _cellEmptySprite;
|
||||
/// <summary>Empty-slot sprite for THIS list's cells, resolved from the dat cell template
|
||||
/// (retail attribute 0x1000000e -> catalog 0x21000037 prototype's ItemSlot_Empty; see
|
||||
|
|
@ -204,6 +211,16 @@ public sealed class UiItemList : UiElement
|
|||
|
||||
public override bool OnEvent(in UiEvent e)
|
||||
{
|
||||
if (CatalogDropped is not null && e.Payload is not null)
|
||||
{
|
||||
if (e.Type is UiEventType.DragEnter or UiEventType.DragOver)
|
||||
return true;
|
||||
if (e.Type == UiEventType.DropReleased)
|
||||
{
|
||||
CatalogDropped(e.Payload, e.Data1, e.Data2);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (e.Type == UiEventType.Scroll && CellWidth > 0f)
|
||||
{
|
||||
// Mirror UiText: Silk +Y wheel = up/older = decrease ScrollY; negate Data0.
|
||||
|
|
@ -213,6 +230,24 @@ public sealed class UiItemList : UiElement
|
|||
return base.OnEvent(e);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Port of the listbox expose-item behavior called by
|
||||
/// <c>gmSpellbookUI::SetSelected @ 0x0048B540</c>.
|
||||
/// </summary>
|
||||
public void ScrollItemIntoView(int index)
|
||||
{
|
||||
if (CellWidth <= 0f || index < 0 || index >= _cells.Count) return;
|
||||
int columns = Math.Max(1, Columns);
|
||||
int row = Flow == UiItemListFlow.ColumnMajor
|
||||
? index % Math.Max(1, RowCount(_cells.Count, columns))
|
||||
: index / columns;
|
||||
float top = row * CellHeight;
|
||||
float bottom = top + CellHeight;
|
||||
if (top < Scroll.ScrollY) Scroll.SetScrollY((int)MathF.Floor(top));
|
||||
else if (bottom > Scroll.ScrollY + Height)
|
||||
Scroll.SetScrollY((int)MathF.Ceiling(bottom - Height));
|
||||
}
|
||||
|
||||
protected override void OnDraw(UiRenderContext ctx)
|
||||
{
|
||||
// The factory sets Width/Height AFTER construction, so re-layout each frame:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue