fix(ui): finish retail stack selector polish

Keep the horizontal thumb at its raw pointer position so both endpoints are reachable, honor the stack entry's authored right alignment, and preserve PublicWeenieDesc plural names from CreateObject through the object table. Port retail's singular s/es fallback when no plural was sent.

Co-Authored-By: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-11 10:22:11 +02:00
parent a20e5c68c7
commit 6005c51c4d
22 changed files with 233 additions and 36 deletions

View file

@ -2120,7 +2120,7 @@ public sealed class GameWindow : IDisposable
handler => Combat.HealthChanged += handler,
handler => Combat.HealthChanged -= handler,
IsHealthBarTarget,
guid => Objects.Get(guid)?.Name,
guid => Objects.Get(guid)?.GetAppropriateName(),
Combat.GetHealthPercent,
Combat.HasHealth,
guid => (uint)(Objects.Get(guid)?.StackSize ?? 0),

View file

@ -405,6 +405,8 @@ public static class DatWidgetFactory
FocusFieldSprite = focusSprite,
Selectable = selectable,
OneLine = oneLine,
Centered = info.HJustify == HJustify.Center,
RightAligned = info.HJustify == HJustify.Right,
};
if (info.TryGetEffectiveInteger(0x1Eu, out int maxCharacters))
field.MaxCharacters = maxCharacters;

View file

@ -99,6 +99,7 @@ public sealed class SelectedObjectController : IRetainedPanelController
private uint? _current;
private string? _currentName;
private double _flashRemaining; // > 0 while the selection overlay is flashing
private bool _changingSplitFromSlider;
private bool _disposed;
/// <summary>White label color for the name line.</summary>
@ -166,8 +167,8 @@ public sealed class SelectedObjectController : IRetainedPanelController
{
_stackSizeSlider.Visible = false;
_stackSizeSlider.Horizontal = true;
_stackSizeSlider.ScalarValue = () => _splitQuantity.Ratio;
_stackSizeSlider.ScalarChanged = _splitQuantity.SetFromSliderRatio;
_stackSizeSlider.SetScalarPosition(_splitQuantity.Ratio);
_stackSizeSlider.ScalarChanged = OnStackSliderChanged;
}
// Attach a centered UiText child to the name element for the object name display.
@ -224,7 +225,7 @@ public sealed class SelectedObjectController : IRetainedPanelController
/// (typical host: <c>h =&gt; Combat.HealthChanged += h</c>) — drives meter visibility.</param>
/// <param name="isHealthTarget">Returns true for guids that may show a health meter
/// (proxy for retail's <c>IsPlayer() || pet_owner || ObjectIsAttackable()</c>).</param>
/// <param name="name">Returns the display name for a given guid (or null if unknown).</param>
/// <param name="name">Returns retail's NAME_APPROPRIATE display name for a guid.</param>
/// <param name="healthPercent">Returns the health fill fraction [0..1] for a given guid.</param>
/// <param name="hasHealth">Returns true if real health has been received for a guid
/// (so a re-selected, already-known target shows its bar immediately).</param>
@ -342,7 +343,26 @@ public sealed class SelectedObjectController : IRetainedPanelController
=> _splitQuantity.SetFromText(text);
private void OnSplitQuantityChanged()
=> _stackSizeEntry?.SetText(_splitQuantity.Value.ToString(System.Globalization.CultureInfo.InvariantCulture));
{
_stackSizeEntry?.SetText(_splitQuantity.Value.ToString(System.Globalization.CultureInfo.InvariantCulture));
// UIElement_Scrollbar owns its live thumb position while dragging and only
// broadcasts message 0xA. Entry/selection changes explicitly write attr 0x86.
if (!_changingSplitFromSlider)
_stackSizeSlider?.SetScalarPosition(_splitQuantity.Ratio);
}
private void OnStackSliderChanged(float position)
{
_changingSplitFromSlider = true;
try
{
_splitQuantity.SetFromSliderRatio(position);
}
finally
{
_changingSplitFromSlider = false;
}
}
private void OnObjectUpdated(ClientObject updated)
{

View file

@ -33,6 +33,8 @@ public sealed class UiField : UiElement
public int MaxCharacters { get; set; } = 0xFFFF;
public bool OneLine { get; set; } = true;
public bool Selectable { get; set; }
public bool Centered { get; set; }
public bool RightAligned { get; set; }
public bool ClearOnSubmit { get; set; } = true;
public bool RecordHistory { get; set; } = true;
public Func<char, bool>? CharacterFilter { get; set; }
@ -272,7 +274,7 @@ public sealed class UiField : UiElement
/// FindPixelsFromPos inverse. Accounts for the horizontal scroll offset.</summary>
private int HitCharX(float localX)
{
float target = localX - Padding + _scrollX;
float target = localX - Padding - TextAlignmentOffset() + _scrollX;
if (target <= 0f) return 0;
int best = 0;
float bestDist = float.MaxValue;
@ -324,6 +326,7 @@ public sealed class UiField : UiElement
if (caretX - _scrollX > visibleW) _scrollX = caretX - visibleW;
if (caretX < _scrollX) _scrollX = caretX;
_scrollX = Math.Clamp(_scrollX, 0f, MathF.Max(0f, fullW - visibleW));
float alignX = TextAlignmentOffset();
// Visible character window [start, end).
int start = 0;
@ -335,15 +338,15 @@ public sealed class UiField : UiElement
if (HasSelection)
{
var (lo, hi) = SelSpan();
float h0 = MathF.Max(MeasureTo(lo) - _scrollX, 0f);
float h1 = MathF.Min(MeasureTo(hi) - _scrollX, visibleW);
float h0 = MathF.Max(alignX + MeasureTo(lo) - _scrollX, 0f);
float h1 = MathF.Min(alignX + MeasureTo(hi) - _scrollX, visibleW);
if (h1 > h0) ctx.DrawFill(Padding + h0, ty, h1 - h0, lh, SelectionColor);
}
if (end > start)
{
string vis = _text.Substring(start, end - start);
float vx = Padding + (MeasureTo(start) - _scrollX);
float vx = Padding + alignX + (MeasureTo(start) - _scrollX);
if (DatFont is { } df2) ctx.DrawStringDat(df2, vis, vx, ty, TextColor);
else ctx.DrawString(vis, vx, ty, TextColor, Font);
}
@ -351,12 +354,21 @@ public sealed class UiField : UiElement
if (_focused)
{
// Caret on TOP of the text → submitted after the text in the same bucket.
float cx = Padding + (caretX - _scrollX);
float cx = Padding + alignX + (caretX - _scrollX);
if (cx >= Padding - 1f && cx <= Width - Padding + 1f)
ctx.DrawFill(cx, ty, 1f, lh, TextColor);
}
}
private float TextAlignmentOffset()
{
float visibleW = MathF.Max(1f, Width - 2f * Padding);
float spare = MathF.Max(0f, visibleW - MeasureTo(_text.Length));
if (RightAligned) return spare;
if (Centered) return spare * 0.5f;
return 0f;
}
// ── Auto-repeat ──────────────────────────────────────────────────────
protected override void OnTick(double deltaSeconds)

View file

@ -29,10 +29,14 @@ public sealed class UiScrollbar : UiElement
/// Optional scalar mode used by retail's horizontal stack-split control. When set,
/// the bar reflects/drives one normalized value rather than a <see cref="UiScrollable"/>.
/// </summary>
public Func<float>? ScalarValue { get; set; }
public float ScalarPosition { get; private set; }
public Action<float>? ScalarChanged { get; set; }
public bool Horizontal { get; set; }
/// <summary>Programmatically set retail scrollbar attribute 0x86 without broadcasting.</summary>
public void SetScalarPosition(float position)
=> ScalarPosition = Math.Clamp(position, 0f, 1f);
/// <summary>RenderSurface id → (GL tex, w, h). 0 id = skip.</summary>
public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; }
@ -95,12 +99,12 @@ public sealed class UiScrollbar : UiElement
protected override void OnDraw(UiRenderContext ctx)
{
if (SpriteResolve is not { } resolve) return;
if (Horizontal && ScalarValue is not null)
if (Horizontal)
{
DrawTiled(ctx, resolve, TrackSprite, 0f, 0f, Width, Height);
float thumbWidth = ScalarThumbWidth(resolve);
float travel = MathF.Max(0f, Width - thumbWidth);
float x = travel * Math.Clamp(ScalarValue(), 0f, 1f);
float x = travel * ScalarPosition;
DrawSprite(ctx, resolve, ThumbSprite, x, 0f, thumbWidth, Height);
return;
}
@ -162,7 +166,7 @@ public sealed class UiScrollbar : UiElement
public override bool OnEvent(in UiEvent e)
{
if (Horizontal && ScalarValue is not null && ScalarChanged is not null)
if (Horizontal && ScalarChanged is not null)
return OnScalarEvent(e);
if (Model is not { } m) return false;
@ -228,7 +232,7 @@ public sealed class UiScrollbar : UiElement
{
float thumbWidth = ScalarThumbWidth(SpriteResolve);
float travel = MathF.Max(1f, Width - thumbWidth);
float thumbX = travel * Math.Clamp(ScalarValue!(), 0f, 1f);
float thumbX = travel * ScalarPosition;
float x = e.Data1;
if (x >= thumbX && x <= thumbX + thumbWidth)
{
@ -237,7 +241,7 @@ public sealed class UiScrollbar : UiElement
else
{
_dragOffsetX = thumbWidth * 0.5f;
ScalarChanged!(Math.Clamp((x - _dragOffsetX) / travel, 0f, 1f));
ChangeScalarPosition((x - _dragOffsetX) / travel);
}
_draggingThumb = true;
return true;
@ -247,7 +251,7 @@ public sealed class UiScrollbar : UiElement
{
float thumbWidth = ScalarThumbWidth(SpriteResolve);
float travel = MathF.Max(1f, Width - thumbWidth);
ScalarChanged!(Math.Clamp(((float)e.Data1 - _dragOffsetX) / travel, 0f, 1f));
ChangeScalarPosition(((float)e.Data1 - _dragOffsetX) / travel);
return true;
}
@ -268,4 +272,10 @@ public sealed class UiScrollbar : UiElement
}
return MathF.Min(16f, Width);
}
private void ChangeScalarPosition(float position)
{
SetScalarPosition(position);
ScalarChanged?.Invoke(ScalarPosition);
}
}

View file

@ -194,7 +194,10 @@ public static class CreateObject
// an explicitly transmitted zero (the enum's undefined/default value).
byte? RadarBlipColor = null,
byte? RadarBehavior = null,
byte? CombatUse = null);
byte? CombatUse = null,
// PublicWeenieDesc._plural_name, gated by WeenieHeader flag 0x1.
// ACCWeenieObject::GetObjectName(NAME_APPROPRIATE) selects this for stacks.
string? PluralName = null);
/// <summary>
/// The relevant subset of the server-sent <c>MovementData</c> /
@ -653,6 +656,7 @@ public static class CreateObject
string? name = null;
uint? itemType = null;
uint weenieFlags = 0;
string? pluralName = null;
uint iconId = 0;
uint weenieClassId = 0;
int? wValue = null;
@ -708,7 +712,7 @@ public static class CreateObject
// bit field width
// --------- ------------------ -----
// 0x04000000 (objDescFlags) weenieFlags2 u32 (skip)
// 0x00000001 PluralName String16L (skip)
// 0x00000001 PluralName String16L CAPTURE
// 0x00000002 ItemsCapacity u8 (skip)
// 0x00000004 ContainersCapacity u8 (skip)
// 0x00000100 AmmoType u16 (skip)
@ -774,7 +778,7 @@ public static class CreateObject
}
if ((weenieFlags & 0x00000001u) != 0) // PluralName
_ = ReadString16L(body, ref pos);
pluralName = ReadString16L(body, ref pos);
if ((weenieFlags & 0x00000002u) != 0) // ItemsCapacity u8
{
@ -969,7 +973,8 @@ public static class CreateObject
Priority: wPriority, Structure: wStructure, MaxStructure: wMaxStructure,
Workmanship: wWorkmanship,
RadarBlipColor: radarBlipColor, RadarBehavior: radarBehavior,
CombatUse: combatUse);
CombatUse: combatUse,
PluralName: pluralName);
// Local helper: if we ran out of fields past PhysicsData, still
// return the useful prefix (guid/position/setup/animParts/textures/palettes/scale/motion).

View file

@ -87,5 +87,6 @@ public static class ObjectTableWiring
RadarBlipColor: s.RadarBlipColor,
RadarBehavior: s.RadarBehavior,
PublicWeenieBitfield: s.ObjectDescriptionFlags,
CombatUse: s.CombatUse);
CombatUse: s.CombatUse,
PluralName: s.PluralName);
}

View file

@ -123,7 +123,8 @@ public sealed class WorldSession : IDisposable
// undefined/default value.
byte? RadarBlipColor = null,
byte? RadarBehavior = null,
byte? CombatUse = null);
byte? CombatUse = null,
string? PluralName = null);
/// <summary>
/// Projects the wire-level CreateObject result into the stable session
@ -175,7 +176,8 @@ public sealed class WorldSession : IDisposable
ServerControlSequence: parsed.ServerControlSequence,
RadarBlipColor: parsed.RadarBlipColor,
RadarBehavior: parsed.RadarBehavior,
CombatUse: parsed.CombatUse);
CombatUse: parsed.CombatUse,
PluralName: parsed.PluralName);
/// <summary>Fires when the session finishes parsing a CreateObject.</summary>
public event Action<EntitySpawn>? EntitySpawned;

View file

@ -145,6 +145,8 @@ public sealed class ClientObject
public uint ObjectId { get; init; }
public uint WeenieClassId { get; set; } // "blueprint"
public string Name { get; set; } = "";
/// <summary>Retail <c>PublicWeenieDesc._plural_name</c>; empty falls back to singular.</summary>
public string PluralName { get; set; } = "";
public ItemType Type { get; set; }
public EquipMask ValidLocations { get; set; }
public EquipMask CurrentlyEquippedLocation { get; set; }
@ -194,6 +196,19 @@ public sealed class ClientObject
public int MaxStructure { get; set; }
public float Workmanship { get; set; } // 0..10 (fractional on the wire)
public PropertyBundle Properties { get; } = new();
/// <summary>
/// Ports <c>ACCWeenieObject::GetObjectName(NAME_APPROPRIATE) @ 0x0058E6E0</c>:
/// stacked objects prefer <see cref="PluralName"/>; when the wire omitted it,
/// retail appends <c>s</c>, or <c>es</c> when the singular already ends in lowercase s.
/// </summary>
public string GetAppropriateName()
{
if (StackSize <= 1) return Name;
if (!string.IsNullOrEmpty(PluralName)) return PluralName;
if (string.IsNullOrEmpty(Name)) return Name;
return Name[^1] == 's' ? Name + "es" : Name + "s";
}
}
/// <summary>
@ -233,7 +248,8 @@ public readonly record struct WeenieData(
byte? RadarBlipColor = null,
byte? RadarBehavior = null,
uint? PublicWeenieBitfield = null,
byte? CombatUse = null);
byte? CombatUse = null,
string? PluralName = null);
/// <summary>
/// Retail ITEM_USEABLE helpers (acclient.h:6478, ItemUses::* at 0x004fccd0).

View file

@ -366,6 +366,7 @@ public sealed class ClientObjectTable
uint oldContainer = obj.ContainerId;
if (!string.IsNullOrEmpty(d.Name)) obj.Name = d.Name!;
if (!string.IsNullOrEmpty(d.PluralName)) obj.PluralName = d.PluralName!;
if (d.Type is { } t) obj.Type = t;
// WeenieClassId arrives on every CreateObject (fixed prefix) and is never
// legitimately 0 for a real weenie; the != 0 guard avoids clobbering a known