using System; using System.Numerics; namespace AcDream.App.UI; /// /// Generic scrollbar. Ports retail UIElement_Scrollbar /// (RegisterElementClass(0xb) @ acclient_2013_pseudo_c.txt:124137); /// thumb size = trackLen * ThumbRatio (min 8px); step ±1 line. /// /// /// Dat element ids (chat LayoutDesc 0x21000006): track 0x10000012 (X=474 Y=6 W=16 H=68), /// thumb 0x1000048C. The track is instanced from base layout 0x2100003E which contains /// the full scrollbar widget with distinct up/down button children: /// Up button element 0x10000071 — Y=0, 16×16, Normal sprite 0x06004C69. /// Down button element 0x10000072 — Y=32, 16×16, Normal sprite 0x06004C6C. /// Track body sprite: 0x06004C5F (48px tall in the base template; stretched to H=68 in chat). /// Thumb is a 3-slice: top cap 0x06004C60, middle 0x06004C63, bottom cap 0x06004C66. /// For Task H wiring: up/down regions occupy the top and bottom ButtonH (16px) of the /// rendered scrollbar's height; the widget responds to those regions directly via hit /// comparison in OnEvent without requiring separate child elements. /// public sealed class UiScrollbar : UiElement { /// The scroll model this bar reflects + drives (shared with the transcript). public UiScrollable? Model { get; set; } /// /// Optional scalar mode used by retail's horizontal stack-split control. When set, /// the bar reflects/drives one normalized value rather than a . /// public float ScalarPosition { get; private set; } public Action? ScalarChanged { get; set; } public bool Horizontal { get; set; } /// /// Optional fill rendered beneath the scalar thumb. Retail's combat power /// control is a horizontal scrollbar containing a meter child; the importer /// folds that authored child into these properties because scrollbars consume /// their DAT children. /// public Func ScalarFill { get; set; } = () => null; public uint ScalarFillSprite { get; set; } /// /// Optional authored texture beneath the live scalar fill. gmCombatUI's /// dark-red interior media comes from element 0x100005EF. /// public uint ScalarRangeSprite { get; set; } public float ScalarRangeLeft { get; set; } public float ScalarRangeWidth { get; set; } = float.PositiveInfinity; /// /// Authored layout policy for a consumed range child. This preserves the /// child's own DAT edge modes even though the scrollbar draws it procedurally. /// public UiLayoutPolicy? ScalarRangeLayoutPolicy { get; set; } /// /// Draw the scalar meter from the power end (right) toward the speed end /// (left). This is the authored gmCombatUI power meter direction. /// public bool ScalarFillFromRight { get; set; } /// Programmatically set retail scrollbar attribute 0x86 without broadcasting. public void SetScalarPosition(float position) => ScalarPosition = Math.Clamp(position, 0f, 1f); /// RenderSurface id → (GL tex, w, h). 0 id = skip. public Func? SpriteResolve { get; set; } /// Track background sprite id (0x06004C5F from layout 0x2100003E element 0x10000455). public uint TrackSprite { get; set; } /// Thumb 3-slice MIDDLE tile sprite id (0x06004C63), tiled between the caps. public uint ThumbSprite { get; set; } /// Thumb 3-slice TOP cap sprite id (0x06004C60, 3px tall). public uint ThumbTopSprite { get; set; } /// Thumb 3-slice BOTTOM cap sprite id (0x06004C66, 3px tall). public uint ThumbBotSprite { get; set; } /// Up-arrow button sprite id (0x06004C69 Normal state, element 0x10000071). public uint UpSprite { get; set; } /// Down-arrow button sprite id (0x06004C6C Normal state, element 0x10000072). public uint DownSprite { get; set; } /// Retail attribute 0x89 floor: minimum thumb height in pixels. private const float MinThumb = 8f; /// Thumb cap height (native sprite height from base layout 0x2100003E). private const float CapH = 3f; /// Up/down button height in pixels. Matches element height 16px from /// the up/down button children in base layout 0x2100003E. private const float ButtonH = 16f; private bool _draggingThumb; private float _dragOffsetY; private float _dragOffsetX; public UiScrollbar() { CapturesPointerDrag = true; } /// The scrollbar draws its own track/thumb/arrows; its dat up/down button /// children are reproduced procedurally, so the importer must not build them. public override bool ConsumesDatChildren => true; /// /// Computes the thumb rectangle (local y origin and height) within the track area /// between the two end buttons. Ports retail UIElement_Scrollbar::UpdateLayout /// @0x4710d0: thumb height = max(MinThumb, trackLen * ThumbRatio); thumb top /// offset = trackTop + (trackLen - thumbH) * PositionRatio. /// /// The scroll model. /// Y of the top of the usable track area (below up-button). /// Pixel length of the usable track area (between up and down buttons). /// Local Y of the thumb's top edge, and its pixel height. public static (float y, float h) ThumbRect(UiScrollable m, float trackTop, float trackLen) { float h = MathF.Max(MinThumb, trackLen * m.ThumbRatio); float travel = trackLen - h; float y = trackTop + travel * m.PositionRatio; return (y, h); } /// Returns the clipped scalar-meter span in local pixels. public static (float x, float width) ScalarFillRect( float totalWidth, float fill, bool fromRight) => ScalarFillRect(0f, totalWidth, fill, fromRight); /// Returns a clipped scalar-meter span inside an authored sub-range. public static (float x, float width) ScalarFillRect( float rangeLeft, float rangeWidth, float fill, bool fromRight) { float safeWidth = MathF.Max(0f, rangeWidth); float visibleWidth = safeWidth * Math.Clamp(fill, 0f, 1f); return (fromRight ? rangeLeft + safeWidth - visibleWidth : rangeLeft, visibleWidth); } protected override void OnDraw(UiRenderContext ctx) { if (SpriteResolve is not { } resolve) return; if (Horizontal) { DrawTiled(ctx, resolve, TrackSprite, 0f, 0f, Width, Height); (float rangeLeft, float rangeWidth) = ScalarRangeRect(); if (ScalarRangeSprite != 0) DrawTiled(ctx, resolve, ScalarRangeSprite, rangeLeft, 0f, rangeWidth, Height); float thumbWidth = ScalarThumbWidth(resolve); if (ScalarFill() is float fill && ScalarFillSprite != 0) { // The authored charge child spans the entire scrollbar; it does // not share the dark range child's inset geometry. var (fillX, visibleWidth) = ScalarFillRect( Width, fill, ScalarFillFromRight); DrawTiledClipped(ctx, resolve, ScalarFillSprite, 0f, fillX, visibleWidth, Height); } float travel = MathF.Max(0f, Width - thumbWidth); float x = travel * ScalarPosition; DrawSprite(ctx, resolve, ThumbSprite, x, 0f, thumbWidth, Height); return; } if (Model is not { } m) return; // Track background — TILED vertically (retail DrawMode=Normal). The native track // sprite (~16×32) repeats to fill the element height instead of stretch-distorting. DrawTiled(ctx, resolve, TrackSprite, 0f, 0f, Width, Height); // Decrement/up button — top ButtonH rows (inherited element 0x10000071). DrawSprite(ctx, resolve, UpSprite, 0f, 0f, Width, ButtonH); // Increment/down button — bottom ButtonH rows (inherited element 0x10000072). DrawSprite(ctx, resolve, DownSprite, 0f, Height - ButtonH, Width, ButtonH); // Thumb — only when content overflows the view. Retail 3-slice: top cap + // tiled middle + bottom cap (base layout 0x2100003E thumb sub-elements // 0x10000364/65/66). Falls back to a single tiled middle if the caps are unset // or the thumb is too short to hold both caps. if (m.HasOverflow) { float trackTop = ButtonH; float trackLen = Height - 2f * ButtonH; var (ty, th) = ThumbRect(m, trackTop, trackLen); if (ThumbTopSprite != 0 && ThumbBotSprite != 0 && th >= 2f * CapH) { DrawSprite(ctx, resolve, ThumbTopSprite, 0f, ty, Width, CapH); DrawTiled(ctx, resolve, ThumbSprite, 0f, ty + CapH, Width, th - 2f * CapH); DrawSprite(ctx, resolve, ThumbBotSprite, 0f, ty + th - CapH, Width, CapH); } else { DrawTiled(ctx, resolve, ThumbSprite, 0f, ty, Width, th); } } } /// Draw a sprite stretched 1:1 to the dest rect. private void DrawSprite(UiRenderContext ctx, Func resolve, uint id, float x, float y, float w, float h) { if (id == 0 || w <= 0f || h <= 0f) return; var (tex, _, _) = resolve(id); if (tex == 0) return; ctx.DrawSprite(tex, x, y, w, h, 0f, 0f, 1f, 1f, Vector4.One); } /// Draw a sprite TILED to fill the dest rect (UV-repeat at native size on /// both axes — the UI texture is GL_REPEAT-wrapped). A native-width axis gives 1:1. private void DrawTiled(UiRenderContext ctx, Func resolve, uint id, float x, float y, float w, float h) { if (id == 0 || w <= 0f || h <= 0f) return; var (tex, tw, th) = resolve(id); if (tex == 0 || tw == 0 || th == 0) return; ctx.DrawSprite(tex, x, y, w, h, 0f, 0f, w / tw, h / th, Vector4.One); } /// /// Draws a clipped portion of a tiled range without restarting the texture /// phase at the clip edge. This mirrors retail meter child clipping. /// private void DrawTiledClipped( UiRenderContext ctx, Func resolve, uint id, float rangeLeft, float x, float w, float h) { if (id == 0 || w <= 0f || h <= 0f) return; var (tex, tw, th) = resolve(id); if (tex == 0 || tw == 0 || th == 0) return; float u0 = (x - rangeLeft) / tw; float u1 = u0 + w / tw; ctx.DrawSprite(tex, x, 0f, w, h, u0, 0f, u1, h / th, Vector4.One); } internal (float left, float width) ScalarRangeRect() { float configuredLeft = ScalarRangeLeft; float configuredWidth = ScalarRangeWidth; if (ScalarRangeLayoutPolicy is { } policy) { UiPixelRect currentParent = UiPixelRect.FromPositionAndSize( 0, 0, (int)Width, (int)Height); UiPixelRect range = policy.Apply(policy.OriginalChild, currentParent); configuredLeft = range.X0; configuredWidth = range.Width; } float left = Math.Clamp(configuredLeft, 0f, Width); float requestedWidth = float.IsPositiveInfinity(configuredWidth) ? Width - left : MathF.Max(0f, configuredWidth); float width = Math.Clamp(requestedWidth, 0f, Width - left); return (left, width); } public override bool OnEvent(in UiEvent e) { if (Horizontal && ScalarChanged is not null) return OnScalarEvent(e); if (Model is not { } m) return false; switch (e.Type) { case UiEventType.MouseDown: { // e.Data1 = local X, e.Data2 = local Y (int pixel coords, see UiRoot hit dispatch). float ly = e.Data2; // Up-button region: top ButtonH rows. if (ly <= ButtonH) { m.ScrollByLines(-1); return true; } // Down-button region: bottom ButtonH rows. if (ly >= Height - ButtonH) { m.ScrollByLines(1); return true; } // Track interior: start a thumb drag or page-scroll. float trackTop = ButtonH; float trackLen = Height - 2f * ButtonH; var (ty, th) = ThumbRect(m, trackTop, trackLen); if (ly >= ty && ly <= ty + th) { // Clicked inside the thumb — begin drag with offset from thumb top. _draggingThumb = true; _dragOffsetY = ly - ty; } else { // Clicked above or below thumb — page scroll (HandleButtonClick page case). m.ScrollByPage(ly < ty ? -1 : 1); } return true; } case UiEventType.MouseMove when _draggingThumb: { // Map current local Y (minus drag offset from thumb top) back to a // position ratio across the available travel distance. float trackTop = ButtonH; float trackLen = Height - 2f * ButtonH; float thumbH = MathF.Max(MinThumb, trackLen * m.ThumbRatio); float travel = MathF.Max(1f, trackLen - thumbH); float newRatio = ((float)e.Data2 - _dragOffsetY - trackTop) / travel; m.SetPositionRatio(newRatio); return true; } case UiEventType.MouseUp: _draggingThumb = false; return true; } return false; } private bool OnScalarEvent(in UiEvent e) { switch (e.Type) { case UiEventType.MouseDown: { float thumbWidth = ScalarThumbWidth(SpriteResolve); float travel = MathF.Max(1f, Width - thumbWidth); float thumbX = travel * ScalarPosition; float x = e.Data1; if (x >= thumbX && x <= thumbX + thumbWidth) { _dragOffsetX = x - thumbX; } else { _dragOffsetX = thumbWidth * 0.5f; ChangeScalarPosition((x - _dragOffsetX) / travel); } _draggingThumb = true; return true; } case UiEventType.MouseMove when _draggingThumb: { float thumbWidth = ScalarThumbWidth(SpriteResolve); float travel = MathF.Max(1f, Width - thumbWidth); ChangeScalarPosition(((float)e.Data1 - _dragOffsetX) / travel); return true; } case UiEventType.MouseUp: _draggingThumb = false; return true; } return false; } private float ScalarThumbWidth(Func? resolve) { if (resolve is not null && ThumbSprite != 0) { var (_, width, _) = resolve(ThumbSprite); if (width > 0) return MathF.Min(width, Width); } return MathF.Min(16f, Width); } private void ChangeScalarPosition(float position) { SetScalarPosition(position); ScalarChanged?.Invoke(ScalarPosition); } }