feat(ui): port retail selected-stack quantity

Bind the authored stack count entry and horizontal slider to one Core split-quantity owner, preserve retail count-first naming and exact 1000-step rounding, refresh on stack changes, and consume the selected amount during merges. Conformance covers the production DAT fixture and retained pointer/focus paths.

Co-Authored-By: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-11 10:07:25 +02:00
parent dc1649c493
commit a20e5c68c7
19 changed files with 595 additions and 51 deletions

View file

@ -199,6 +199,42 @@ public class DatWidgetFactoryTests
Assert.IsType<UiScrollbar>(e);
}
[Fact]
public void Type11_HorizontalScrollbar_usesDirectRetailTrackAndThumbChildren()
{
const uint Track = 0x06004CF6u;
const uint Thumb = 0x06005DC3u;
var info = new ElementInfo { Type = 11, Id = 0x100001A4u, Width = 90, Height = 14 };
var track = new ElementInfo { Id = 4u, Type = 3u, Width = 90, Height = 14 };
track.States[UiStateInfo.DirectStateId] = new UiStateInfo
{ Id = UiStateInfo.DirectStateId, Image = new UiImageMedia(Track, 1) };
var thumb = new ElementInfo { Id = 1u, Type = 3u, Width = 16, Height = 14 };
thumb.States[UiStateInfo.DirectStateId] = new UiStateInfo
{ Id = UiStateInfo.DirectStateId, Image = new UiImageMedia(Thumb, 1) };
info.Children.Add(track);
info.Children.Add(thumb);
var bar = Assert.IsType<UiScrollbar>(DatWidgetFactory.Create(info, NoTex, null));
Assert.True(bar.Horizontal);
Assert.Equal(Track, bar.TrackSprite);
Assert.Equal(Thumb, bar.ThumbSprite);
Assert.Equal(0u, bar.UpSprite);
Assert.Equal(0u, bar.DownSprite);
}
[Fact]
public void RetailToolbarFixture_buildsEditableStackEntry_andAuthoredHorizontalSlider()
{
ImportedLayout layout = FixtureLoader.LoadToolbar();
Assert.IsType<UiField>(layout.FindElement(0x100001A3u));
var bar = Assert.IsType<UiScrollbar>(layout.FindElement(0x100001A4u));
Assert.True(bar.Horizontal);
Assert.Equal(0x06004CF6u, bar.TrackSprite);
Assert.Equal(0x06005DC3u, bar.ThumbSprite);
}
// ── Test 5e: Type 3 is NOT registered — chrome/containers stay generic ────
//
// Retail Type 3 = UIElement_Field, but acdream's Type-3 dat elements (vitals/chat

View file

@ -60,7 +60,8 @@ public class InventoryControllerTests
List<(uint source, uint target)>? mergeNotices = null,
string? ownerName = null,
Action? onClose = null,
SelectionState? selection = null)
SelectionState? selection = null,
StackSplitQuantityState? stackSplitQuantity = null)
=> InventoryController.Bind(layout, objects, () => Player,
iconIds: (_, _, _, _, _) => 0u,
strength: () => strength, datFont: null,
@ -71,7 +72,8 @@ public class InventoryControllerTests
sendStackableMerge: merges is null ? null : (s, t, a) => merges.Add((s, t, a)),
notifyMergeAttempt: mergeNotices is null ? null : (s, t) => mergeNotices.Add((s, t)),
onClose: onClose,
selection: selection ?? new SelectionState());
selection: selection ?? new SelectionState(),
stackSplitQuantity: stackSplitQuantity);
private static UiButton MakeButton(uint id)
{
@ -604,6 +606,35 @@ public class InventoryControllerTests
Assert.Equal(0xBu, selection.SelectedObjectId);
}
[Fact]
public void Drop_selectedStack_usesSharedToolbarSplitQuantity()
{
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject
{
ObjectId = 0xA, WeenieClassId = 0x1234, StackSize = 40, StackSizeMax = 100,
});
objects.MoveItem(0xA, Player, 0);
objects.AddOrUpdate(new ClientObject
{
ObjectId = 0xB, WeenieClassId = 0x1234, StackSize = 50, StackSizeMax = 100,
});
objects.MoveItem(0xB, Player, 1);
var selection = new SelectionState();
selection.Select(0xAu, SelectionChangeSource.Inventory);
var split = new StackSplitQuantityState();
split.Reset(40u);
split.SetValue(7u);
var merges = new List<(uint source, uint target, uint amount)>();
var ctrl = Bind(layout, objects, merges: merges, selection: selection,
stackSplitQuantity: split);
ctrl.HandleDropRelease(grid, grid.GetItem(1)!, Payload(0xAu));
Assert.Equal(new[] { (0xAu, 0xBu, 7u) }, merges);
}
[Fact]
public void Drop_differentStackType_fallsThroughToNormalInventoryInsert()
{

View file

@ -4,6 +4,7 @@ using System.Linq;
using System.Numerics;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Core.Items;
using AcDream.Core.Selection;
using Xunit;
@ -59,6 +60,14 @@ public class SelectedObjectControllerTests
dict[SelectedObjectController.HealthMeterId] = healthMeterEl;
root.AddChild(healthMeterEl);
var stackEntry = new UiField { Width = 50, Height = 14, Visible = true };
dict[SelectedObjectController.StackSizeEntryId] = stackEntry;
root.AddChild(stackEntry);
var stackSlider = new UiScrollbar { Width = 90, Height = 14, Visible = true };
dict[SelectedObjectController.StackSizeSliderId] = stackSlider;
root.AddChild(stackSlider);
return (new ImportedLayout(root, dict), nameEl, overlayEl, healthMeterEl);
}
@ -67,7 +76,9 @@ public class SelectedObjectControllerTests
private sealed class Harness
{
public readonly SelectionState Selection = new();
public readonly StackSplitQuantityState SplitQuantity = new();
public Action<uint, float>? HealthHandler;
public Action<ClientObject>? ObjectUpdatedHandler;
public readonly List<uint> QueryHealthCalls = new();
public readonly Dictionary<uint, bool> HealthTargetMap = new();
@ -100,7 +111,13 @@ public class SelectedObjectControllerTests
hasHealth: g => HasHealthMap.TryGetValue(g, out var v) && v,
stackSize: g => StackMap.TryGetValue(g, out var v) ? v : 0u,
sendQueryHealth: g => QueryHealthCalls.Add(g),
datFont: datFont);
datFont: datFont,
splitQuantity: SplitQuantity,
subscribeObjectUpdated: h => ObjectUpdatedHandler = h,
unsubscribeObjectUpdated: h =>
{
if (ObjectUpdatedHandler == h) ObjectUpdatedHandler = null;
});
}
// ── B1: Bind initialisation ──────────────────────────────────────────────
@ -247,6 +264,58 @@ public class SelectedObjectControllerTests
Assert.False(healthMeterEl.Visible);
}
[Fact]
public void SelectStackedItem_showsRetailCountEntryAndSlider_atFullStack()
{
const uint Guid = 0xBB03u;
var (layout, nameEl, _, _) = FakeLayout();
var h = new Harness();
h.NameMap[Guid] = "Healing Kits";
h.StackMap[Guid] = 17u;
h.Bind(layout);
h.FireSelection(Guid);
var entry = Assert.IsType<UiField>(layout.FindElement(SelectedObjectController.StackSizeEntryId));
var slider = Assert.IsType<UiScrollbar>(layout.FindElement(SelectedObjectController.StackSizeSliderId));
Assert.True(entry.Visible);
Assert.True(slider.Visible);
Assert.Equal("17", entry.Text);
Assert.Equal(17u, h.SplitQuantity.Value);
Assert.Equal(17u, h.SplitQuantity.Maximum);
Assert.Equal(1f, slider.ScalarValue!());
Assert.Equal("17 Healing Kits", nameEl.Children.OfType<UiText>().Single().LinesProvider().Single().Text);
slider.ScalarChanged!(0.5f);
Assert.Equal(9u, h.SplitQuantity.Value);
Assert.Equal("9", entry.Text);
entry.SetText("999");
entry.Submit();
Assert.Equal(17u, h.SplitQuantity.Value);
Assert.Equal("17", entry.Text);
}
[Fact]
public void StackSizeUpdate_refreshesSelectedControls_andHidesThemAtOne()
{
const uint Guid = 0xBB04u;
var (layout, _, _, _) = FakeLayout();
var h = new Harness();
h.NameMap[Guid] = "Healing Kits";
h.StackMap[Guid] = 5u;
h.Bind(layout);
h.FireSelection(Guid);
h.StackMap[Guid] = 1u;
h.ObjectUpdatedHandler!(new ClientObject { ObjectId = Guid, StackSize = 1 });
Assert.False(layout.FindElement(SelectedObjectController.StackSizeEntryId)!.Visible);
Assert.False(layout.FindElement(SelectedObjectController.StackSizeSliderId)!.Visible);
Assert.Equal(1u, h.SplitQuantity.Value);
Assert.Equal(1u, h.SplitQuantity.Maximum);
}
// ── H3: Non-health target (friendly NPC / scenery / Door) ───────────────
[Fact]

View file

@ -69,4 +69,29 @@ public class UiFieldTests
for (int i = 0; i < 150; i++) { input.InsertChar('x'); input.Submit(); }
Assert.True(input.HistoryCount <= 100);
}
[Fact]
public void CharacterFilter_rejectsDisallowedInput()
{
var input = new UiField { CharacterFilter = static c => char.IsAsciiDigit(c) };
input.InsertChar('4');
input.InsertChar('x');
input.InsertChar('2');
Assert.Equal("42", input.Text);
}
[Fact]
public void SelectAllOnFocus_survivesInitiatingMouseDown_andTypingReplacesValue()
{
var input = new UiField { SelectAllOnFocus = true, Selectable = true };
input.SetText("17");
input.OnEvent(new UiEvent(0u, input, UiEventType.FocusGained));
input.OnEvent(new UiEvent(0u, input, UiEventType.MouseDown, Data1: 2));
input.InsertChar('5');
Assert.Equal("5", input.Text);
}
}

View file

@ -78,4 +78,26 @@ public class UiScrollbarTests
Assert.Equal(100f, h, 3f);
Assert.Equal(16f, y, 3f); // travel = 0 → y = trackTop
}
[Fact]
public void HorizontalScalar_clickAndDrag_updatesNormalizedValue()
{
float value = 1f;
var bar = new UiScrollbar
{
Width = 90f,
Height = 14f,
Horizontal = true,
ScalarValue = () => value,
ScalarChanged = next => value = next,
};
Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseDown, Data1: 8)));
Assert.Equal(0f, value, 3);
Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseMove, Data1: 45)));
Assert.Equal(0.5f, value, 3);
Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseUp, Data1: 45)));
}
}