Owner report (2026-08-24): our scrollbar arrows pointed the wrong way, the thumb vanished when there was nothing to scroll, and neither the thumb nor the arrow buttons reacted to hover/press. All three are one retail mechanism we had not ported: 1. Seating: UIElement_Scrollbar::UpdateScrollingArea @0x00470AA0 moves the INCREMENT designee (attribute 0x77) to the top/left corner and the DECREMENT designee (0x78) to the bottom/right, ignoring authored positions. The vertical base skin (0x10000455 in layout 0x2100003E) authors the DOWN-arrow decrement at Y=0 and the UP-arrow increment at Y=32 (live-DAT probed; sprite art visually verified from decoded PNGs), so our authored-Y ordering drew both arrows upside down. DatWidgetFactory now seats by designation; the hand-wired sites (CharacterStatController, ExternalContainerController, the Config/Vendor menu chrome) share the new RetailScrollbarChrome catalog instead of local constants. 2. Full-track thumb: UpdateLayout @0x004710d0 sizes the thumb from proportion attribute 0x88, which DEFAULTS to 1.0 — a content-fits bar shows a thumb filling the whole track; disabled only removes input and the page regions. Our draw skipped the thumb entirely on !HasOverflow. 3. States: every arrow button and thumb slice authors Normal (red gem / dark navy), Normal_rollover (amber gem / bright blue) and Normal_pressed (gold highlight / dark) media. The widget now tracks thumb hover and selects rollover media on hover and pressed media while dragging; the factory extracts the thumb-state media for both the 3-slice and single-sprite thumb shapes. ScrollbarSkinLiveDatTests pins the designations and state media against the installed DAT so a revision or importer regression fails loudly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
628 lines
24 KiB
C#
628 lines
24 KiB
C#
using System.Linq;
|
|
using System.Numerics;
|
|
using AcDream.App.Rendering;
|
|
using AcDream.App.Rendering.Gpu;
|
|
using AcDream.App.Tests.Rendering.Gpu;
|
|
using AcDream.App.UI;
|
|
using Xunit;
|
|
|
|
namespace AcDream.App.Tests.UI;
|
|
|
|
/// <summary>
|
|
/// Pure unit tests for <see cref="UiScrollbar.ThumbRect"/> — no GL dependency.
|
|
/// </summary>
|
|
public class UiScrollbarTests
|
|
{
|
|
// Model: content=400, view=100, trackLen=200.
|
|
// ThumbRatio = 100/400 = 0.25 → thumbH = max(8, 200*0.25) = 50.
|
|
// Travel = 200 - 50 = 150.
|
|
|
|
[Fact]
|
|
public void ThumbRect_AtStart_HasCorrectSizeAndZeroOffset()
|
|
{
|
|
var m = new UiScrollable { ContentHeight = 400, ViewHeight = 100 };
|
|
// PositionRatio = 0 (start).
|
|
var (y, h) = UiScrollbar.ThumbRect(m, trackTop: 0f, trackLen: 200f);
|
|
Assert.Equal(50f, h, 3f);
|
|
Assert.Equal(0f, y, 3f);
|
|
}
|
|
|
|
[Fact]
|
|
public void ThumbRect_AtEnd_PinsToBottomOfTrack()
|
|
{
|
|
var m = new UiScrollable { ContentHeight = 400, ViewHeight = 100 };
|
|
m.ScrollToEnd(); // PositionRatio = 1.
|
|
float trackTop = 16f, trackLen = 200f;
|
|
var (y, h) = UiScrollbar.ThumbRect(m, trackTop, trackLen);
|
|
Assert.Equal(50f, h, 3f);
|
|
// y = trackTop + travel * 1 = 16 + 150 = 166.
|
|
Assert.Equal(166f, y, 3f);
|
|
}
|
|
|
|
[Fact]
|
|
public void ThumbRect_WithButtonH_CorrectlyOffsetsFromTrackTop()
|
|
{
|
|
// Matches task spec: content=400, view=100, trackLen=200, PositionRatio=1.
|
|
// thumbH=50; travel=150; y = trackTop + 150 = trackTop + 150.
|
|
var m = new UiScrollable { ContentHeight = 400, ViewHeight = 100 };
|
|
m.ScrollToEnd();
|
|
var (y, h) = UiScrollbar.ThumbRect(m, trackTop: 16f, trackLen: 200f);
|
|
Assert.Equal(50f, h, 3f);
|
|
Assert.Equal(166f, y, 3f); // 16 + 150
|
|
}
|
|
|
|
[Fact]
|
|
public void ThumbRect_MidScroll_InterpolatesPosition()
|
|
{
|
|
// content=400 view=100 → MaxScroll=300; ScrollY=150 → PositionRatio=0.5.
|
|
var m = new UiScrollable { ContentHeight = 400, ViewHeight = 100 };
|
|
m.SetScrollY(150);
|
|
Assert.Equal(0.5f, m.PositionRatio, 3);
|
|
|
|
var (y, h) = UiScrollbar.ThumbRect(m, trackTop: 0f, trackLen: 200f);
|
|
Assert.Equal(50f, h, 3f);
|
|
// y = 0 + 150 * 0.5 = 75.
|
|
Assert.Equal(75f, y, 3f);
|
|
}
|
|
|
|
[Fact]
|
|
public void ThumbRect_SmallContent_EnforcesMinThumb()
|
|
{
|
|
// content=1000, view=10, trackLen=200 → ThumbRatio=0.01 → raw=2 < 8 → clamp to 8.
|
|
var m = new UiScrollable { ContentHeight = 1000, ViewHeight = 10 };
|
|
var (_, h) = UiScrollbar.ThumbRect(m, trackTop: 0f, trackLen: 200f);
|
|
Assert.Equal(8f, h, 3f);
|
|
}
|
|
|
|
[Fact]
|
|
public void ThumbRect_NoOverflow_ThumbFillsTrack()
|
|
{
|
|
// content <= view → ThumbRatio = 1 → thumbH = trackLen.
|
|
var m = new UiScrollable { ContentHeight = 50, ViewHeight = 100 };
|
|
var (y, h) = UiScrollbar.ThumbRect(m, trackTop: 16f, trackLen: 100f);
|
|
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,
|
|
ScalarChanged = next => value = next,
|
|
};
|
|
bar.SetScalarPosition(1f);
|
|
|
|
Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseDown, Data1: 8)));
|
|
Assert.Equal(0f, value, 3);
|
|
Assert.Equal(0f, bar.ScalarPosition, 3);
|
|
|
|
Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseMove, Data1: 45)));
|
|
Assert.Equal(0.5f, value, 3);
|
|
Assert.Equal(0.5f, bar.ScalarPosition, 3);
|
|
|
|
Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseUp, Data1: 45)));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Fix round F11 (Campaign CC CC6b-MOUNT review): retail's chargen shade
|
|
/// scrollbar (<c>0x10000321</c>) is authored VERTICAL (measured 33x85
|
|
/// against the installed dat — see
|
|
/// <c>CharacterCreationLiveDatTests.AppearancePage_HasGenderChoiceSpinsSwatchesShadeAndViewport</c>),
|
|
/// but before this fix <c>UiScrollbar.OnEvent</c> only routed to
|
|
/// <c>ScalarChanged</c> when <c>Horizontal</c> was true — a vertical
|
|
/// scalar bar's clicks fell through to the Model-mode branch, which
|
|
/// returns false with no <see cref="UiScrollbar.Model"/> set, so the
|
|
/// shade control never fired in production. Mirrors
|
|
/// <see cref="HorizontalScalar_clickAndDrag_updatesNormalizedValue"/>
|
|
/// exactly, transposed onto Y/Height/Data2.
|
|
/// </summary>
|
|
[Fact]
|
|
public void VerticalScalar_clickAndDrag_updatesNormalizedValue()
|
|
{
|
|
float value = 1f;
|
|
var bar = new UiScrollbar
|
|
{
|
|
Width = 14f,
|
|
Height = 90f,
|
|
Horizontal = false,
|
|
ScalarChanged = next => value = next,
|
|
};
|
|
bar.SetScalarPosition(1f);
|
|
|
|
Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseDown, Data2: 8)));
|
|
Assert.Equal(0f, value, 3);
|
|
Assert.Equal(0f, bar.ScalarPosition, 3);
|
|
|
|
Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseMove, Data2: 45)));
|
|
Assert.Equal(0.5f, value, 3);
|
|
Assert.Equal(0.5f, bar.ScalarPosition, 3);
|
|
|
|
Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseUp, Data2: 45)));
|
|
}
|
|
|
|
// ── OP5 review fix S1: the drag-end seam (IsDragging / DragCompleted) ────
|
|
|
|
[Fact]
|
|
public void HorizontalScalar_DragCompleted_FiresOnceAtMouseUp_NotOnEachMove()
|
|
{
|
|
int completedCount = 0;
|
|
var bar = new UiScrollbar
|
|
{
|
|
Width = 90f,
|
|
Height = 14f,
|
|
Horizontal = true,
|
|
ScalarChanged = _ => { },
|
|
DragCompleted = () => completedCount++,
|
|
};
|
|
bar.SetScalarPosition(0f); // thumb spans [0, 16]
|
|
|
|
// Click INSIDE the thumb — no "jump to click" branch, a clean drag start.
|
|
Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseDown, Data1: 5)));
|
|
Assert.True(bar.IsDragging);
|
|
Assert.Equal(0, completedCount);
|
|
|
|
for (int i = 0; i < 10; i++)
|
|
{
|
|
Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseMove, Data1: 10 + i)));
|
|
Assert.Equal(0, completedCount); // N drag ticks fire zero completions
|
|
}
|
|
|
|
Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseUp, Data1: 50)));
|
|
Assert.False(bar.IsDragging);
|
|
Assert.Equal(1, completedCount); // drag end fires exactly one
|
|
}
|
|
|
|
[Fact]
|
|
public void HorizontalScalar_DragCompleted_DoesNotFireOnAMouseUpThatWasNeverADrag()
|
|
{
|
|
int completedCount = 0;
|
|
var bar = new UiScrollbar
|
|
{
|
|
Width = 90f,
|
|
Height = 14f,
|
|
Horizontal = true,
|
|
ScalarChanged = _ => { },
|
|
DragCompleted = () => completedCount++,
|
|
};
|
|
|
|
// A bare MouseUp with no prior MouseDown/drag must not fire the callback.
|
|
bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseUp, Data1: 10));
|
|
Assert.Equal(0, completedCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void VerticalModel_DragCompleted_FiresOnlyForAnActualThumbDrag_NotAButtonClick()
|
|
{
|
|
// Height=200, default 16px decrement/increment buttons -> trackTop=16,
|
|
// trackLen=168. content=400/view=100 -> ThumbRatio=0.25 -> thumbH=42,
|
|
// travel=126. At PositionRatio=0 the thumb spans local Y [16, 58].
|
|
var model = new UiScrollable { ContentHeight = 400, ViewHeight = 100 };
|
|
int completedCount = 0;
|
|
var bar = new UiScrollbar { Width = 16f, Height = 200f, Model = model, DragCompleted = () => completedCount++ };
|
|
|
|
// A click on the decrement (up-arrow) button is never a drag.
|
|
Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseDown, Data1: 0, Data2: 5)));
|
|
Assert.False(bar.IsDragging);
|
|
Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseUp, Data1: 0, Data2: 5)));
|
|
Assert.Equal(0, completedCount);
|
|
|
|
// A click INSIDE the thumb (local Y 30, within [16, 58]) starts a real drag.
|
|
Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseDown, Data1: 0, Data2: 30)));
|
|
Assert.True(bar.IsDragging);
|
|
Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseMove, Data1: 0, Data2: 40)));
|
|
Assert.Equal(0, completedCount);
|
|
Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseUp, Data1: 0, Data2: 40)));
|
|
Assert.Equal(1, completedCount);
|
|
}
|
|
|
|
// ── OP5 re-check R1/R2 (2026-08-11, coordinator pass) ───────────────────
|
|
|
|
[Fact]
|
|
public void CaptureLossMidDrag_EndsTheGesture_CompletesOnce_AndUnlatchesIsDragging()
|
|
{
|
|
// R1: UiRoot can drop capture WITHOUT a MouseUp (panel hidden by a
|
|
// keybind mid-drag; a second button re-targeting capture). The
|
|
// WM_CAPTURECHANGED delivery must end the drag, fire ONE completion
|
|
// (persisting the user's last-seen value), and unlatch IsDragging —
|
|
// otherwise every later Reset/Defaults flush is silently suppressed.
|
|
int completedCount = 0;
|
|
var bar = new UiScrollbar
|
|
{
|
|
Width = 90f,
|
|
Height = 14f,
|
|
Horizontal = true,
|
|
ScalarChanged = _ => { },
|
|
DragCompleted = () => completedCount++,
|
|
};
|
|
bar.SetScalarPosition(0f);
|
|
|
|
Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseDown, Data1: 5)));
|
|
Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseMove, Data1: 30)));
|
|
Assert.True(bar.IsDragging);
|
|
|
|
bar.OnEvent(new UiEvent(0u, bar, UiEventType.CaptureChanged));
|
|
|
|
Assert.False(bar.IsDragging);
|
|
Assert.Equal(1, completedCount);
|
|
|
|
// A later stray MouseUp (capture already gone) must not double-fire.
|
|
bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseUp, Data1: 30));
|
|
Assert.Equal(1, completedCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void CaptureChange_WithNoActiveDrag_IsANoOp()
|
|
{
|
|
int completedCount = 0;
|
|
var bar = new UiScrollbar
|
|
{
|
|
Width = 90f,
|
|
Height = 14f,
|
|
Horizontal = true,
|
|
DragCompleted = () => completedCount++,
|
|
};
|
|
|
|
bar.OnEvent(new UiEvent(0u, bar, UiEventType.CaptureChanged));
|
|
|
|
Assert.False(bar.IsDragging);
|
|
Assert.Equal(0, completedCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void HorizontalScalar_BareTrackClickJump_DefersItsTickAndCompletesExactlyOnce()
|
|
{
|
|
// R2: a bare track click (outside the thumb) jumps the scalar. The
|
|
// latch now arms BEFORE the jump applies, so the jump's own
|
|
// ScalarChanged tick observes IsDragging=true (a consumer defers its
|
|
// flush) and the MouseUp's single DragCompleted carries the gesture's
|
|
// one flush — never the inline-then-completed double.
|
|
int completedCount = 0;
|
|
bool draggingDuringTick = false;
|
|
UiScrollbar bar = null!;
|
|
bar = new UiScrollbar
|
|
{
|
|
Width = 90f,
|
|
Height = 14f,
|
|
Horizontal = true,
|
|
ScalarChanged = _ => draggingDuringTick = bar.IsDragging,
|
|
DragCompleted = () => completedCount++,
|
|
};
|
|
bar.SetScalarPosition(0f); // thumb spans [0, 16]
|
|
|
|
// Click far outside the thumb — the jump branch.
|
|
Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseDown, Data1: 70)));
|
|
Assert.True(draggingDuringTick); // the jump tick saw the latch armed
|
|
Assert.Equal(0, completedCount);
|
|
|
|
Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseUp, Data1: 70)));
|
|
Assert.Equal(1, completedCount); // one gesture, one completion
|
|
}
|
|
|
|
[Fact]
|
|
public void HorizontalModel_DragCompleted_FiresOnceAtMouseUp()
|
|
{
|
|
var model = new UiScrollable { ContentHeight = 320, ViewHeight = 80, LineHeight = 32 };
|
|
int completedCount = 0;
|
|
var bar = new UiScrollbar
|
|
{
|
|
Width = 160f,
|
|
Height = 16f,
|
|
Horizontal = true,
|
|
Model = model,
|
|
DragCompleted = () => completedCount++,
|
|
};
|
|
|
|
Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseDown, Data1: 20)));
|
|
Assert.True(bar.IsDragging);
|
|
Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseMove, Data1: 144)));
|
|
Assert.Equal(0, completedCount);
|
|
Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseUp, Data1: 144)));
|
|
Assert.False(bar.IsDragging);
|
|
Assert.Equal(1, completedCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void HorizontalModel_ButtonsTrackAndThumbDriveSharedScroll()
|
|
{
|
|
var model = new UiScrollable { ContentHeight = 320, ViewHeight = 80, LineHeight = 32 };
|
|
var bar = new UiScrollbar
|
|
{
|
|
Width = 160f,
|
|
Height = 16f,
|
|
Horizontal = true,
|
|
Model = model,
|
|
};
|
|
|
|
Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseDown, Data1: 159)));
|
|
Assert.Equal(32, model.ScrollY);
|
|
|
|
Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseDown, Data1: 100)));
|
|
Assert.True(model.ScrollY >= 80);
|
|
|
|
model.SetScrollY(0);
|
|
Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseDown, Data1: 20)));
|
|
Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseMove, Data1: 144)));
|
|
Assert.Equal(model.MaxScroll, model.ScrollY);
|
|
Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseUp, Data1: 144)));
|
|
}
|
|
|
|
[Fact]
|
|
public void HorizontalModel_UsesAuthoredArrowExtentsAndOneCellStep()
|
|
{
|
|
var model = new UiScrollable
|
|
{
|
|
ContentHeight = 640,
|
|
ViewHeight = 320,
|
|
LineHeight = 32,
|
|
};
|
|
model.SetScrollY(64);
|
|
var bar = new UiScrollbar
|
|
{
|
|
Width = 160f,
|
|
Height = 36f,
|
|
Horizontal = true,
|
|
Model = model,
|
|
DecrementButtonExtent = 23f,
|
|
IncrementButtonExtent = 29f,
|
|
};
|
|
|
|
Assert.True(bar.OnEvent(new UiEvent(
|
|
0u, bar, UiEventType.MouseDown, Data1: 22)));
|
|
Assert.Equal(32, model.ScrollY);
|
|
|
|
Assert.True(bar.OnEvent(new UiEvent(
|
|
0u, bar, UiEventType.MouseDown, Data1: 131)));
|
|
Assert.Equal(64, model.ScrollY);
|
|
}
|
|
|
|
[Fact]
|
|
public void HorizontalModel_UsesIndependentRolloverAndPressedArrowMedia()
|
|
{
|
|
var root = new UiRoot { Width = 300f, Height = 100f };
|
|
var model = new UiScrollable
|
|
{
|
|
ContentHeight = 640,
|
|
ViewHeight = 320,
|
|
LineHeight = 32,
|
|
};
|
|
var bar = new UiScrollbar
|
|
{
|
|
Width = 160f,
|
|
Height = 36f,
|
|
Horizontal = true,
|
|
Model = model,
|
|
UpSprite = 1u,
|
|
UpRolloverSprite = 2u,
|
|
UpPressedSprite = 3u,
|
|
DownSprite = 4u,
|
|
DownRolloverSprite = 5u,
|
|
DownPressedSprite = 6u,
|
|
};
|
|
root.AddChild(bar);
|
|
|
|
root.OnMouseMove(5, 10);
|
|
Assert.Equal(2u, bar.ActiveStartSpriteForTest);
|
|
Assert.Equal(4u, bar.ActiveEndSpriteForTest);
|
|
|
|
// Moving between two regions of the same procedural scrollbar must
|
|
// refresh its sub-control hover, not wait for a whole-widget leave.
|
|
root.OnMouseMove(155, 10);
|
|
Assert.Equal(1u, bar.ActiveStartSpriteForTest);
|
|
Assert.Equal(5u, bar.ActiveEndSpriteForTest);
|
|
|
|
root.OnMouseDown(UiMouseButton.Left, 155, 10);
|
|
Assert.Equal(6u, bar.ActiveEndSpriteForTest);
|
|
root.OnMouseUp(UiMouseButton.Left, 155, 10);
|
|
Assert.Equal(5u, bar.ActiveEndSpriteForTest);
|
|
}
|
|
|
|
[Fact]
|
|
public void ModelWithoutOverflow_IsDisabledAndHideDisabledSuppressesPresentation()
|
|
{
|
|
var model = new UiScrollable
|
|
{
|
|
ContentHeight = 320,
|
|
ViewHeight = 320,
|
|
LineHeight = 32,
|
|
};
|
|
var bar = new UiScrollbar
|
|
{
|
|
Width = 160f,
|
|
Height = 36f,
|
|
Horizontal = true,
|
|
Model = model,
|
|
HideWhenDisabled = true,
|
|
};
|
|
|
|
Assert.True(bar.IsModelDisabled);
|
|
Assert.False(bar.IsPresentationVisible);
|
|
Assert.False(bar.OnEvent(new UiEvent(
|
|
0u, bar, UiEventType.MouseDown, Data1: 159)));
|
|
Assert.Equal(0, model.ScrollY);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(0f, 0f, 0f)]
|
|
[InlineData(0.5f, 0f, 50f)]
|
|
[InlineData(1f, 0f, 100f)]
|
|
public void ScalarFillRect_CombatPower_GrowsLeftToRight(
|
|
float fill, float expectedX, float expectedWidth)
|
|
{
|
|
var (x, width) = UiScrollbar.ScalarFillRect(100f, fill, fromRight: false);
|
|
Assert.Equal(expectedX, x, 3);
|
|
Assert.Equal(expectedWidth, width, 3);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(0f, 104f, 0f)]
|
|
[InlineData(0.5f, 104f, 149.5f)]
|
|
[InlineData(1f, 104f, 299f)]
|
|
public void ScalarFillRect_CombatPower_StaysBetweenAuthoredLabels(
|
|
float fill, float expectedX, float expectedWidth)
|
|
{
|
|
var (x, width) = UiScrollbar.ScalarFillRect(
|
|
rangeLeft: 104f, rangeWidth: 299f, fill, fromRight: false);
|
|
Assert.Equal(expectedX, x, 3);
|
|
Assert.Equal(expectedWidth, width, 3);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 2026-08-24 owner report: retail shows a thumb FILLING the whole
|
|
/// track when there is nothing to scroll — the proportion attribute
|
|
/// 0x88 defaults to 1.0 in <c>UIElement_Scrollbar::UpdateLayout
|
|
/// @0x004710d0</c>, so a content-fits bar sizes the widget to the full
|
|
/// scrolling area; only input goes away with the disabled state. The
|
|
/// previous draw skipped the thumb entirely on <c>!HasOverflow</c>.
|
|
/// </summary>
|
|
[Fact]
|
|
public void NoOverflow_DrawsAFullTrackThumb()
|
|
{
|
|
var device = new RecordingGpuDevice();
|
|
var renderer = new TextRenderer(device, new NullGpuFrameSource(), "unused");
|
|
renderer.Begin(new Vector2(800f, 600f));
|
|
var ctx = new UiRenderContext(renderer, new Vector2(800f, 600f));
|
|
|
|
const uint topTex = 60u, midTex = 63u, botTex = 66u;
|
|
var model = new UiScrollable { ContentHeight = 150, ViewHeight = 150 };
|
|
var bar = new UiScrollbar
|
|
{
|
|
Width = 16f,
|
|
Height = 200f,
|
|
SpriteResolve = id => id is topTex or midTex or botTex ? (id, 16, 3) : (0u, 0, 0),
|
|
ThumbTopSprite = topTex,
|
|
ThumbSprite = midTex,
|
|
ThumbBotSprite = botTex,
|
|
Model = model,
|
|
};
|
|
Assert.True(bar.IsModelDisabled);
|
|
|
|
bar.DrawSelfAndChildren(ctx);
|
|
|
|
// Top cap sits at the top of the track (below the 16px up button)…
|
|
var top = Assert.Single(
|
|
renderer.DebugSpriteSegmentVerts, s => s.Texture == topTex);
|
|
float topMinY = Enumerable.Range(0, top.Verts.Count / 8)
|
|
.Min(i => top.Verts[i * 8 + 1]);
|
|
Assert.Equal(16f, topMinY, 1);
|
|
// …and the bottom cap ends at the bottom of the track (above the
|
|
// 16px down button) — a full-track thumb.
|
|
var bot = Assert.Single(
|
|
renderer.DebugSpriteSegmentVerts, s => s.Texture == botTex);
|
|
float botMaxY = Enumerable.Range(0, bot.Verts.Count / 8)
|
|
.Max(i => bot.Verts[i * 8 + 1]);
|
|
Assert.Equal(184f, botMaxY, 1);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 2026-08-24 owner report: hovering the thumb highlights it
|
|
/// (Normal_rollover media — bright blue on the base skin) and holding a
|
|
/// drag shows the pressed media (authored to look like the resting
|
|
/// color). Mirrors retail's authored three-state thumb slices.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ThumbHoverAndDrag_SelectRolloverAndPressedMedia()
|
|
{
|
|
var model = new UiScrollable { ContentHeight = 200, ViewHeight = 150, LineHeight = 10 };
|
|
var bar = new UiScrollbar
|
|
{
|
|
Width = 16f,
|
|
Height = 200f,
|
|
Model = model,
|
|
ThumbSprite = 1u,
|
|
ThumbRolloverSprite = 2u,
|
|
ThumbPressedSprite = 3u,
|
|
ThumbTopSprite = 10u,
|
|
ThumbTopRolloverSprite = 20u,
|
|
ThumbTopPressedSprite = 30u,
|
|
ThumbBotSprite = 100u,
|
|
ThumbBotRolloverSprite = 200u,
|
|
ThumbBotPressedSprite = 300u,
|
|
};
|
|
// Track 16..184 (168px), ratio 0.75 → thumb 16..142 at position 0.
|
|
Assert.Equal(1u, bar.ActiveThumbSpriteForTest);
|
|
|
|
// Hover over the thumb → rollover on every slice.
|
|
bar.OnEvent(new UiEvent(0u, bar, UiEventType.HoverEnter, Data1: 8, Data2: 50));
|
|
Assert.Equal(2u, bar.ActiveThumbSpriteForTest);
|
|
Assert.Equal(20u, bar.ActiveThumbTopSpriteForTest);
|
|
Assert.Equal(200u, bar.ActiveThumbBotSpriteForTest);
|
|
|
|
// Press and hold (drag) → pressed media.
|
|
bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseDown, Data1: 8, Data2: 50));
|
|
Assert.True(bar.IsDragging);
|
|
Assert.Equal(3u, bar.ActiveThumbSpriteForTest);
|
|
Assert.Equal(30u, bar.ActiveThumbTopSpriteForTest);
|
|
Assert.Equal(300u, bar.ActiveThumbBotSpriteForTest);
|
|
|
|
// Release while still over the thumb → back to the hover highlight.
|
|
bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseUp, Data1: 8, Data2: 50));
|
|
Assert.Equal(2u, bar.ActiveThumbSpriteForTest);
|
|
|
|
// Move to the track BELOW the thumb → back to normal.
|
|
bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseMove, Data1: 8, Data2: 170));
|
|
Assert.Equal(1u, bar.ActiveThumbSpriteForTest);
|
|
|
|
// Leave the bar entirely → normal.
|
|
bar.OnEvent(new UiEvent(0u, bar, UiEventType.HoverEnter, Data1: 8, Data2: 50));
|
|
bar.OnEvent(new UiEvent(0u, bar, UiEventType.HoverLeave));
|
|
Assert.Equal(1u, bar.ActiveThumbSpriteForTest);
|
|
}
|
|
|
|
private sealed class NullGpuFrameSource : ICurrentGpuFrameSource
|
|
{
|
|
public IGpuFrame? CurrentFrame => null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// R4-2 (Campaign CC gate round 1 re-test 3): the re-test-2 single-
|
|
/// sprite-thumb fallback (R3-4/R3-7, <see cref="UiScrollbar.OnDraw"/>'s
|
|
/// no-cap-sprites branch) used to call the same UV-repeat
|
|
/// <c>DrawTiled</c> the 3-slice middle tile uses — for a small fixed
|
|
/// "diamond" marker sprite drawn into a MUCH taller track-proportional
|
|
/// thumb rect, GL_REPEAT wrapping visibly tiled the marker several
|
|
/// times down the track (Summary's overview bar ~9, Skills 2, per the
|
|
/// live capture). Proves the fix draws exactly ONE quad for the thumb
|
|
/// texture whose V range never exceeds native (1.0) — i.e. one
|
|
/// unstretched, untiled sprite instance — even though the computed
|
|
/// thumb rect (168px trackLen * ThumbRatio 0.75 = 126px, well past the
|
|
/// sprite's native 16px) is far taller than the sprite.
|
|
/// </summary>
|
|
[Fact]
|
|
public void SingleSpriteThumb_DrawsOneUntiledInstance_NotRepeatedDownTrack()
|
|
{
|
|
var device = new RecordingGpuDevice();
|
|
var renderer = new TextRenderer(device, new NullGpuFrameSource(), "unused");
|
|
renderer.Begin(new Vector2(800f, 600f));
|
|
var ctx = new UiRenderContext(renderer, new Vector2(800f, 600f));
|
|
|
|
const uint thumbTex = 42u;
|
|
var model = new UiScrollable { ContentHeight = 200, ViewHeight = 150 };
|
|
var bar = new UiScrollbar
|
|
{
|
|
Width = 16f,
|
|
Height = 200f,
|
|
SpriteResolve = id => id == thumbTex ? (thumbTex, 16, 16) : (0u, 0, 0),
|
|
ThumbSprite = thumbTex,
|
|
// ThumbTopSprite/ThumbBotSprite stay unset -> the R3-4/R3-7
|
|
// single-sprite fallback shape (no 3-slice caps authored).
|
|
Model = model,
|
|
};
|
|
|
|
bar.DrawSelfAndChildren(ctx);
|
|
|
|
var thumbSegments = renderer.DebugSpriteSegmentVerts
|
|
.Where(s => s.Texture == thumbTex)
|
|
.ToArray();
|
|
Assert.Single(thumbSegments);
|
|
var verts = thumbSegments[0].Verts;
|
|
// 8 floats/vertex (x,y,u,v,r,g,b,a), one quad = 6 vertices.
|
|
Assert.Equal(6, verts.Count / 8);
|
|
for (int i = 0; i < verts.Count; i += 8)
|
|
Assert.True(verts[i + 3] <= 1.0001f, $"thumb sprite V={verts[i + 3]} exceeds native (tiled)");
|
|
}
|
|
}
|