feat(ui): unify retained window mounts

This commit is contained in:
Erik 2026-07-10 22:22:25 +02:00
parent 6e9e10367f
commit a8e9503d2e
20 changed files with 823 additions and 366 deletions

View file

@ -172,8 +172,8 @@ public static class CharacterStatController
private const uint ScrollThumbSprite = 0x06004C63u;
private const uint ScrollThumbTop = 0x06004C60u;
private const uint ScrollThumbBot = 0x06004C66u;
private const uint ScrollUpSprite = 0x06004C6Cu;
private const uint ScrollDownSprite = 0x06004C69u;
private const uint ScrollUpSprite = 0x06004C69u;
private const uint ScrollDownSprite = 0x06004C6Cu;
private enum CharacterStatTab
{
@ -634,7 +634,11 @@ public static class CharacterStatController
if (source is UiScrollbar existingBar)
{
ConfigureSkillScrollbar(existingBar, spriteResolve);
existingBar.SpriteResolve ??= id =>
{
var (handle, width, height) = spriteResolve(id);
return (handle, width, height);
};
existingBar.Visible = false;
return existingBar;
}

View file

@ -40,14 +40,6 @@ public sealed class ChatWindowController
private const uint SendId = 0x10000019u;
private const uint MaxMinId = 0x1000046Fu;
// Scrollbar sprite ids from base layout 0x2100003E (confirmed in Task D).
private const uint TrackSprite = 0x06004C5Fu;
private const uint ThumbSprite = 0x06004C63u; // 3-slice middle tile
private const uint ThumbTopSprite = 0x06004C60u; // 3-slice top cap
private const uint ThumbBotSprite = 0x06004C66u; // 3-slice bottom cap
private const uint UpSprite = 0x06004C6Cu; // up arrow (top button)
private const uint DownSprite = 0x06004C69u; // down arrow (bottom button)
// Channel menu sprite ids (confirmed in chat element dump).
private const uint MenuNormal = 0x06004D65u; // button face
private const uint MenuPressed = 0x06004D66u; // button pressed
@ -72,6 +64,12 @@ public sealed class ChatWindowController
/// <summary>Channel-selector menu widget.</summary>
public UiMenu Menu { get; private set; } = null!;
/// <summary>Resolved gmMainChatUI root metadata, including DAT size constraints.</summary>
public ElementInfo DatWindowInfo { get; private set; } = null!;
public RetailWindowHandle? WindowHandle { get; private set; }
public bool IsMaximized => _maximized;
// ── Private state ──────────────────────────────────────────────────────
private ChatChannelKind _activeChannel = ChatChannelKind.Say;
@ -121,6 +119,7 @@ public sealed class ChatWindowController
/// <summary>Window top before maximize.</summary>
private float _normalTop;
private bool _maximized;
private UiButton? _maxMinButton;
// ── Factory ────────────────────────────────────────────────────────────
@ -181,7 +180,11 @@ public sealed class ChatWindowController
// GameWindow adds this to the host, which re-parents it out of the synthetic wrapper,
// orphaning the strays so they never draw.
var window = layout.FindElement(RootId) ?? layout.Root;
var c = new ChatWindowController { Root = window };
var c = new ChatWindowController
{
Root = window,
DatWindowInfo = FindInfo(rootInfo, RootId) ?? rootInfo,
};
// Drop the dat top resize bar (0x1000000F): it is authored 800px wide and
// juts out of the content-width window. The host wraps this content in the
@ -234,13 +237,7 @@ public sealed class ChatWindowController
bar.Height = bar.Height + oldTop;
bar.ResetAnchorCapture();
bar.Model = c.Transcript.Scroll;
bar.SpriteResolve = resolve;
bar.TrackSprite = TrackSprite;
bar.ThumbSprite = ThumbSprite;
bar.ThumbTopSprite = ThumbTopSprite;
bar.ThumbBotSprite = ThumbBotSprite;
bar.UpSprite = UpSprite;
bar.DownSprite = DownSprite;
bar.SpriteResolve ??= resolve;
c.Scrollbar = bar;
}
@ -301,7 +298,7 @@ public sealed class ChatWindowController
ReflowInputRow();
}
// ── Max/min toggle — simplified gmMainChatUI::HandleMaximizeButton ──
// ── Max/min toggle — gmMainChatUI::HandleMaximizeButton ──
if (layout.FindElement(MaxMinId) is UiButton maxMinEl)
{
// The dat puts max/min and the scrollbar up-button at the SAME X (both
@ -310,6 +307,7 @@ public sealed class ChatWindowController
if (track is not null)
maxMinEl.Left = track.Left - maxMinEl.Width;
maxMinEl.ResetAnchorCapture();
c._maxMinButton = maxMinEl;
maxMinEl.OnClick = c.ToggleMaximize;
}
@ -319,28 +317,76 @@ public sealed class ChatWindowController
// ── Max/min implementation ─────────────────────────────────────────────
/// <summary>
/// Toggle between the normal chat window height and an expanded 320px height.
/// Simplified port of retail <c>gmMainChatUI::HandleMaximizeButton @0x4cddb0</c>:
/// retail stores the pre-maximize height and restores it on a second click.
/// The 320px expanded size is the approximate retail maximized chat height.
/// Attach the typed outer-frame handle after the controller's imported content
/// has been mounted. Maximize/restore must resize this frame, not the child root.
/// </summary>
public void AttachWindow(RetailWindowHandle handle)
{
ArgumentNullException.ThrowIfNull(handle);
if (!ReferenceEquals(handle.ContentRoot, Root))
throw new ArgumentException("Chat handle content root does not match the bound layout.", nameof(handle));
if (WindowHandle is not null && !ReferenceEquals(WindowHandle, handle))
throw new InvalidOperationException("Chat controller is already attached to another window.");
WindowHandle = handle;
}
/// <summary>
/// Exact control flow from <c>gmMainChatUI::HandleMaximizeButton @0x004CCE50</c>:
/// save/restore Y+height, expand by half the parent, choose up/down growth from
/// available space, clamp through the mounted frame's DAT constraints, and set
/// the imported max/min button state.
/// </summary>
private void ToggleMaximize()
{
if (!_maximized)
if (WindowHandle is not { IsRegistered: true } handle)
return;
UiElement frame = handle.OuterFrame;
float parentHeight = frame.Parent?.Height ?? 0f;
if (parentHeight <= 0f)
return;
if (_maximized)
{
_normalHeight = Root.Height;
_normalTop = Root.Top;
// Expand upward: move the top edge up so the bottom stays anchored.
Root.Top = MathF.Max(0f, Root.Top + Root.Height - 320f);
Root.Height = 320f;
_maximized = true;
float restoredHeight = Math.Clamp(_normalHeight, frame.MinHeight, frame.MaxHeight);
float restoredTop = Math.Clamp(
_normalTop,
0f,
MathF.Max(0f, parentHeight - frame.MinHeight));
_maximized = false;
_maxMinButton?.TrySetRetailState(RetailUiStateIds.Minimized);
handle.ResizeTo(frame.Width, restoredHeight);
handle.MoveTo(frame.Left, restoredTop);
return;
}
else
_normalTop = frame.Top;
_normalHeight = frame.Height;
float expansion = parentHeight / 2f;
float targetTop = frame.Top;
float targetHeight = frame.Height + expansion;
if (frame.Top + targetHeight > parentHeight || frame.Top >= parentHeight / 2f)
targetTop = MathF.Max(0f, frame.Top - expansion);
targetHeight = MathF.Min(targetHeight, parentHeight - targetTop);
targetHeight = Math.Clamp(targetHeight, frame.MinHeight, frame.MaxHeight);
_maximized = true;
_maxMinButton?.TrySetRetailState(RetailUiStateIds.Maximized);
handle.ResizeTo(frame.Width, targetHeight);
handle.MoveTo(frame.Left, targetTop);
}
private static ElementInfo? FindInfo(ElementInfo node, uint id)
{
if (node.Id == id) return node;
foreach (ElementInfo child in node.Children)
{
Root.Top = _normalTop;
Root.Height = _normalHeight;
_maximized = false;
ElementInfo? found = FindInfo(child, id);
if (found is not null) return found;
}
return null;
}
// ── Helpers ────────────────────────────────────────────────────────────

View file

@ -79,7 +79,7 @@ public static class DatWidgetFactory
6 => new UiMenu(), // UIElement_Menu (reg :120163)
7 => BuildMeter(info, resolve, elementFont), // UIElement_Meter
0xD => new UiViewport(), // UIElement_Viewport — 3-D mini-scene blit leaf
11 => new UiScrollbar(), // UIElement_Scrollbar (reg :124137)
11 => BuildScrollbar(info, resolve), // UIElement_Scrollbar (reg :124137)
12 => BuildText(info, resolve, elementFont), // UIElement_Text (reg :115655)
0x10000031u => new UiItemList(resolve), // UIElement_ItemList — toolbar/inventory/paperdoll slots
_ => new UiDatElement(info, resolve), // generic fallback (incl. Type 3 chrome/containers)
@ -129,6 +129,68 @@ public static class DatWidgetFactory
return e;
}
/// <summary>
/// Bind inherited scrollbar media structurally. Property 0x77 names the
/// increment button and 0x78 the decrement button; the remaining Type-1
/// child is the thumb with ordered top/middle/bottom image slices.
/// </summary>
private static UiScrollbar BuildScrollbar(
ElementInfo info,
Func<uint, (uint tex, int w, int h)> resolve)
{
var bar = new UiScrollbar
{
SpriteResolve = resolve,
TrackSprite = DefaultImage(info),
};
uint incrementId = ReferencedElementId(info, 0x77u);
uint decrementId = ReferencedElementId(info, 0x78u);
ElementInfo? increment = info.Children.FirstOrDefault(child => child.Id == incrementId);
ElementInfo? decrement = info.Children.FirstOrDefault(child => child.Id == decrementId);
bar.UpSprite = decrement is null ? 0u : DefaultImage(decrement);
bar.DownSprite = increment is null ? 0u : DefaultImage(increment);
ElementInfo? thumb = info.Children.FirstOrDefault(child =>
child.Type == 1u && child.Id != incrementId && child.Id != decrementId);
if (thumb is not null)
{
ElementInfo[] slices = thumb.Children
.Where(child => DefaultImage(child) != 0u)
.OrderBy(child => child.Y)
.ThenBy(child => child.ReadOrder)
.ToArray();
if (slices.Length > 0) bar.ThumbTopSprite = DefaultImage(slices[0]);
if (slices.Length > 1) bar.ThumbSprite = DefaultImage(slices[1]);
if (slices.Length > 2) bar.ThumbBotSprite = DefaultImage(slices[^1]);
}
return bar;
}
private static uint ReferencedElementId(ElementInfo info, uint propertyId)
{
if (!info.TryGetEffectiveProperty(propertyId, out var property))
return 0u;
return property.Kind switch
{
UiPropertyKind.Enum or UiPropertyKind.DataId => (uint)property.UnsignedValue,
UiPropertyKind.Integer when property.IntegerValue >= 0 => (uint)property.IntegerValue,
_ => 0u,
};
}
private static uint DefaultImage(ElementInfo info)
{
uint stateId = info.EffectiveDefaultStateId();
if (info.States.TryGetValue(stateId, out var state) && state.Image is { } image)
return image.File;
if (info.States.TryGetValue(UiStateInfo.DirectStateId, out var direct)
&& direct.Image is { } directImage)
return directImage.File;
return 0u;
}
// ── Meter ────────────────────────────────────────────────────────────────
/// <summary>

View file

@ -25,15 +25,6 @@ public sealed class InventoryController : IItemListDragHandler
public const uint TitleTextId = 0x100001D3u; // "Inventory of <name>"
public const uint ContentsScrollbarId = 0x100001C7u; // gm3DItemsUI gutter scrollbar (right of the grid)
// Scrollbar chrome from base layout 0x2100003E (shared with the chat scrollbar; see
// ChatWindowController). Both inventory + chat scrollbars inherit this base.
private const uint ScrollTrackSprite = 0x06004C5Fu;
private const uint ScrollThumbSprite = 0x06004C63u; // 3-slice middle tile
private const uint ScrollThumbTop = 0x06004C60u; // 3-slice top cap
private const uint ScrollThumbBot = 0x06004C66u; // 3-slice bottom cap
private const uint ScrollUpSprite = 0x06004C6Cu; // up arrow (top button)
private const uint ScrollDownSprite = 0x06004C69u; // down arrow (bottom button)
// 3D-items grid: 192x96 → 6 cols x 3 rows of the 32x32 UIItem cell (template 0x21000037).
private const int ContentsColumns = 6;
private const float ContentsCellPx = 32f; // gm3DItemsUI grid (192x96 = 6x3 of 32px)
@ -108,19 +99,13 @@ public sealed class InventoryController : IItemListDragHandler
_contentsGrid.CellHeight = ContentsCellPx;
}
// Bind the gutter scrollbar to the contents grid's scroll model (the factory built
// 0x100001C7 as a bare Type-11 UiScrollbar; wire it like ChatWindowController does).
// Bind the gutter scrollbar to the contents grid's scroll model. The factory
// already imported its inherited track/thumb/arrow media from LayoutDesc.
if (_contentsGrid is not null
&& layout.FindElement(ContentsScrollbarId) is UiScrollbar bar)
{
bar.Model = _contentsGrid.Scroll;
bar.SpriteResolve = _contentsGrid.SpriteResolve; // chrome resolve from the factory ctor
bar.TrackSprite = ScrollTrackSprite;
bar.ThumbSprite = ScrollThumbSprite;
bar.ThumbTopSprite = ScrollThumbTop;
bar.ThumbBotSprite = ScrollThumbBot;
bar.UpSprite = ScrollUpSprite;
bar.DownSprite = ScrollDownSprite;
bar.Model = _contentsGrid.Scroll;
bar.SpriteResolve ??= _contentsGrid.SpriteResolve;
}
if (_containerList is not null)
{

View file

@ -2,65 +2,73 @@ using System;
namespace AcDream.App.UI.Layout;
/// <summary>How a production retained window obtains its outer chrome.</summary>
public enum RetailWindowChrome
{
/// <summary>The imported root already is the complete retail outer frame.</summary>
Imported,
/// <summary>Wrap imported content in the shared retail nine-slice frame.</summary>
NineSlice,
/// <summary>Use the shared frame variant with the toolbar's two height stops.</summary>
CollapsibleNineSlice,
}
/// <summary>
/// Mounts an imported retail window's content into the shared 8-piece beveled
/// chrome (<see cref="UiNineSlicePanel"/>) and optionally registers it with
/// the <see cref="UiRoot"/> window manager. This is THE mount recipe every
/// retail window follows: frame sized content + 2×border, content inset by
/// the border with Draggable/Resizable off (the frame owns move/resize).
///
/// <para>The vitals/chat/toolbar/inventory windows still inline this recipe
/// in <c>GameWindow</c> (pre-extraction); new windows must mount through this
/// helper instead of copying the block again — see
/// docs/research/2026-07-02-ui-architecture-review.md (theme T1).</para>
/// The single mount path for production retained windows. It owns the distinction
/// between DAT roots that already contain chrome and content roots that require the
/// shared nine-slice wrapper, applies exact resize/opacity/visibility policy, mounts
/// the outer frame, and returns its registered typed handle.
/// </summary>
public static class RetailWindowFrame
{
/// <summary>Per-window placement + behavior knobs. Null-valued knobs keep
/// the widget defaults (<see cref="UiElement"/> / <see cref="UiNineSlicePanel"/>).</summary>
public sealed record Options
{
/// <summary>Initial window position (screen px).</summary>
public required string WindowName { get; init; }
public RetailWindowChrome Chrome { get; init; } = RetailWindowChrome.NineSlice;
/// <summary>Initial outer-frame position in screen pixels.</summary>
public float Left { get; init; }
public float Top { get; init; }
/// <summary>Minimum frame size; null = lock to content + 2×border (non-shrinkable).</summary>
/// <summary>
/// Optional mounted content extent. Used when a LayoutDesc root shares a
/// larger auxiliary coordinate space (the main chat root is cropped to 490px).
/// </summary>
public float? ContentWidth { get; init; }
public float? ContentHeight { get; init; }
public bool RebaseContentLayout { get; init; }
/// <summary>
/// Optional DAT element whose effective 0x3C..0x3F properties provide
/// max-height, max-width, min-height, and min-width respectively. Explicit
/// option values win; wrapper chrome is added to DAT content constraints.
/// </summary>
public ElementInfo? DatConstraintSource { get; init; }
public float? MinWidth { get; init; }
public float? MinHeight { get; init; }
/// <summary>Maximum frame height while resizing; null = unbounded.</summary>
public float? MaxWidth { get; init; }
public float? MaxHeight { get; init; }
/// <summary>Allow horizontal resize (frame default: true).</summary>
public bool Draggable { get; init; } = true;
public bool Resizable { get; init; } = true;
public bool ResizeX { get; init; } = true;
public bool ResizeY { get; init; } = true;
public ResizeEdges ResizableEdges { get; init; } =
ResizeEdges.Left | ResizeEdges.Right | ResizeEdges.Top | ResizeEdges.Bottom;
public bool ConstrainDragToParent { get; init; }
/// <summary>Restrict which edges start a resize; null = all edges.</summary>
public ResizeEdges? ResizableEdges { get; init; }
/// <summary>Window translucency (retail SetDefaultOpacity analog).</summary>
public float Opacity { get; init; } = 1f;
/// <summary>Start shown or hidden (hidden windows toggle via the window manager).</summary>
public bool Visible { get; init; } = true;
/// <summary>How the content tracks the frame when it resizes.</summary>
public AnchorEdges ContentAnchors { get; init; } =
AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right | AnchorEdges.Bottom;
/// <summary>Force the content's ClickThrough; null = leave as imported.</summary>
public bool? ContentClickThrough { get; init; }
/// <summary>Register the frame under this name in the UiRoot window
/// manager (<see cref="WindowNames"/>); null = unmanaged window.</summary>
public string? WindowName { get; init; }
public IRetainedPanelController? Controller { get; init; }
}
/// <summary>
/// Wrap <paramref name="content"/> (sized by the importer) in the beveled
/// chrome, add it to <paramref name="root"/>, and register it when
/// <see cref="Options.WindowName"/> is set. Returns the frame.
/// </summary>
public static UiNineSlicePanel Mount(
public static RetailWindowHandle Mount(
UiRoot root,
UiElement content,
Func<uint, (uint handle, int w, int h)> resolveChrome,
@ -70,39 +78,103 @@ public static class RetailWindowFrame
ArgumentNullException.ThrowIfNull(content);
ArgumentNullException.ThrowIfNull(resolveChrome);
ArgumentNullException.ThrowIfNull(options);
ArgumentException.ThrowIfNullOrWhiteSpace(options.WindowName);
const int border = RetailChromeSprites.Border;
float contentW = content.Width;
float contentH = content.Height;
float contentWidth = options.ContentWidth ?? content.Width;
float contentHeight = options.ContentHeight ?? content.Height;
bool wrapped = options.Chrome != RetailWindowChrome.Imported;
int inset = wrapped ? 2 * RetailChromeSprites.Border : 0;
float outerWidth = contentWidth + inset;
float outerHeight = contentHeight + inset;
var frame = new UiNineSlicePanel(resolveChrome)
UiElement outerFrame;
if (wrapped)
{
Left = options.Left,
Top = options.Top,
Width = contentW + 2 * border,
Height = contentH + 2 * border,
MinWidth = options.MinWidth ?? contentW + 2 * border,
MinHeight = options.MinHeight ?? contentH + 2 * border,
ResizeX = options.ResizeX,
Opacity = options.Opacity,
Visible = options.Visible,
};
if (options.MaxHeight is { } maxHeight) frame.MaxHeight = maxHeight;
if (options.ResizableEdges is { } edges) frame.ResizableEdges = edges;
outerFrame = options.Chrome switch
{
RetailWindowChrome.NineSlice => new UiNineSlicePanel(resolveChrome),
RetailWindowChrome.CollapsibleNineSlice => new UiCollapsibleFrame(resolveChrome),
_ => throw new ArgumentOutOfRangeException(nameof(options.Chrome)),
};
content.Left = border;
content.Top = border;
content.Width = contentW;
content.Height = contentH;
content.Anchors = options.ContentAnchors;
if (options.ContentClickThrough is { } clickThrough) content.ClickThrough = clickThrough;
content.Draggable = false; // the frame owns window move/resize
content.Resizable = false;
const int border = RetailChromeSprites.Border;
content.Left = border;
content.Top = border;
content.Width = contentWidth;
content.Height = contentHeight;
content.Anchors = options.ContentAnchors;
content.Draggable = false;
content.Resizable = false;
if (options.ContentClickThrough is { } clickThrough)
content.ClickThrough = clickThrough;
if (options.RebaseContentLayout)
content.RebaseChildLayoutBaselines();
outerFrame.AddChild(content);
}
else
{
outerFrame = content;
content.Width = contentWidth;
content.Height = contentHeight;
content.Anchors = AnchorEdges.None;
if (options.ContentClickThrough is { } clickThrough)
content.ClickThrough = clickThrough;
if (options.RebaseContentLayout)
content.RebaseChildLayoutBaselines();
}
frame.AddChild(content);
root.AddChild(frame);
if (options.WindowName is { } name)
root.RegisterWindow(name, frame);
return frame;
outerFrame.Left = options.Left;
outerFrame.Top = options.Top;
outerFrame.Width = outerWidth;
outerFrame.Height = outerHeight;
outerFrame.Anchors = AnchorEdges.None;
outerFrame.Draggable = options.Draggable;
outerFrame.Resizable = options.Resizable;
outerFrame.ResizeX = options.ResizeX;
outerFrame.ResizeY = options.ResizeY;
outerFrame.ResizableEdges = options.ResizableEdges;
outerFrame.ConstrainDragToParent = options.ConstrainDragToParent;
outerFrame.Opacity = Math.Clamp(options.Opacity, 0f, 1f);
outerFrame.Visible = options.Visible;
outerFrame.MinWidth = ResolveConstraint(
options.MinWidth, options.DatConstraintSource, 0x3Fu, outerWidth, inset);
outerFrame.MinHeight = ResolveConstraint(
options.MinHeight, options.DatConstraintSource, 0x3Eu, outerHeight, inset);
outerFrame.MaxWidth = Math.Max(
outerFrame.MinWidth,
ResolveConstraint(options.MaxWidth, options.DatConstraintSource, 0x3Du, float.MaxValue, inset));
outerFrame.MaxHeight = Math.Max(
outerFrame.MinHeight,
ResolveConstraint(options.MaxHeight, options.DatConstraintSource, 0x3Cu, float.MaxValue, inset));
// Capture the wrapper/content baseline at the mounted design extent now,
// so a resize that occurs before the first draw uses the same margins as a
// resize after rendering one frame.
if (wrapped)
content.ApplyAnchor(outerWidth, outerHeight);
root.AddChild(outerFrame);
return root.RegisterWindow(
options.WindowName,
outerFrame,
content,
options.Controller);
}
private static float ResolveConstraint(
float? explicitValue,
ElementInfo? datSource,
uint propertyId,
float fallback,
int chromeInset)
{
if (explicitValue is { } value)
return value;
if (datSource is not null
&& datSource.TryGetEffectiveInteger(propertyId, out int datValue)
&& datValue >= 0)
return datValue + chromeInset;
return fallback;
}
}

View file

@ -38,6 +38,8 @@ synthesis + six deep-dive docs under
geometry events, lock propagation, and controller teardown.
- `IRetainedPanelController.cs` — disposable lifecycle contract for
panel-specific show/hide and descendant-focus behavior.
- `Layout/RetailWindowFrame.cs` — the single production/Studio mount
contract for imported-chrome and shared-wrapper windows.
- `UiHost.cs` — one-shot wrapper: owns the `UiRoot`, a `TextRenderer`,
and a default `BitmapFont`. Provides `WireMouse` / `WireKeyboard`
helpers for Silk.NET plumbing.

View file

@ -138,7 +138,7 @@ public sealed class RetailWindowManager : IDisposable
var frame = handle.OuterFrame;
if (!frame.ResizeX) width = frame.Width;
if (!frame.ResizeY) height = frame.Height;
width = Math.Clamp(width, frame.MinWidth, float.MaxValue);
width = Math.Clamp(width, frame.MinWidth, frame.MaxWidth);
height = Math.Clamp(height, frame.MinHeight, frame.MaxHeight);
if (frame.Width == width && frame.Height == height) return true;
frame.Width = width;

View file

@ -219,7 +219,8 @@ public abstract class UiElement
public float MinWidth { get; set; } = 40f;
public float MinHeight { get; set; } = 40f;
/// <summary>Maximum height enforced while resizing (default unbounded). Pairs with MinHeight.</summary>
/// <summary>Maximum size enforced while resizing (default unbounded).</summary>
public float MaxWidth { get; set; } = float.MaxValue;
public float MaxHeight { get; set; } = float.MaxValue;
/// <summary>Allow horizontal (width) resize. Ignored unless <see cref="Resizable"/>.</summary>

View file

@ -344,7 +344,7 @@ public sealed class UiRoot : UiElement
_resizeStartX, _resizeStartY, _resizeStartW, _resizeStartH,
_resizeEdges, x - _resizeMouseX, y - _resizeMouseY,
_resizeTarget.MinWidth, _resizeTarget.MinHeight,
float.MaxValue, _resizeTarget.MaxHeight);
_resizeTarget.MaxWidth, _resizeTarget.MaxHeight);
_resizeTarget.Left = nx; _resizeTarget.Top = ny;
_resizeTarget.Width = nw; _resizeTarget.Height = nh;
return;

View file

@ -33,6 +33,25 @@ public sealed class UiScrollable
/// <summary>True when the offset is at (or past) the bottom — used for bottom-pin.</summary>
public bool AtEnd => _scrollY >= MaxScroll;
/// <summary>
/// Update the content/view extents and clamp the current offset. When
/// <paramref name="preserveEnd"/> is true, a view that was at the end before
/// the geometry change remains at the end afterward. User-driven scrollbar
/// changes move <see cref="ScrollY"/> off the end, so later layout passes
/// preserve that selected position instead of snapping it back.
/// </summary>
public void SetExtents(int contentHeight, int viewHeight, bool preserveEnd = false)
{
bool wasAtEnd = AtEnd;
ContentHeight = Math.Max(0, contentHeight);
ViewHeight = Math.Max(0, viewHeight);
if (preserveEnd && wasAtEnd)
ScrollToEnd();
else
SetScrollY(_scrollY);
}
/// <summary>Set the offset, clamped to [0, MaxScroll] (SetScrollableXY clamp).</summary>
public void SetScrollY(int y) => _scrollY = Math.Clamp(y, 0, MaxScroll);

View file

@ -91,10 +91,10 @@ public sealed class UiScrollbar : UiElement
// sprite (~16×32) repeats to fill the element height instead of stretch-distorting.
DrawTiled(ctx, resolve, TrackSprite, 0f, 0f, Width, Height);
// Up button — top ButtonH rows. UpSprite (0x06004C6C) is the up-arrow art, drawn 1:1.
// Decrement/up button — top ButtonH rows (inherited element 0x10000071).
DrawSprite(ctx, resolve, UpSprite, 0f, 0f, Width, ButtonH);
// Down button — bottom ButtonH rows. DownSprite (0x06004C69) is the down-arrow art.
// 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 +
@ -129,17 +129,6 @@ public sealed class UiScrollbar : UiElement
ctx.DrawSprite(tex, x, y, w, h, 0f, 0f, 1f, 1f, Vector4.One);
}
/// <summary>Draw a sprite 1:1 but vertically FLIPPED (V0/V1 swapped) — used to point
/// the top scroll button's (down-art) arrow upward.</summary>
private void DrawSpriteFlipV(UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> 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, 1f, 1f, 0f, Vector4.One);
}
/// <summary>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.</summary>
private void DrawTiled(UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> resolve,

View file

@ -145,9 +145,6 @@ public sealed class UiText : UiElement
/// <summary>The scroll model — also read by the linked UiScrollbar.</summary>
public UiScrollable Scroll { get; } = new();
/// <summary>True while the view is pinned to the newest line (auto-scrolls as content grows).</summary>
private bool _pinBottom = true;
private const float WheelLines = 1f; // lines advanced per wheel notch (retail = 1 line per notch)
// ── Cached layout from the last OnDraw, so OnEvent hit-tests the SAME geometry ──
@ -292,9 +289,10 @@ public sealed class UiText : UiElement
// Drive the shared scroll model with the current geometry.
Scroll.LineHeight = (int)MathF.Round(lh);
Scroll.ContentHeight = (int)MathF.Ceiling(contentH);
Scroll.ViewHeight = (int)MathF.Floor(innerH);
if (_pinBottom) Scroll.ScrollToEnd();
Scroll.SetExtents(
(int)MathF.Ceiling(contentH),
(int)MathF.Floor(innerH),
preserveEnd: true);
// UiScrollable: ScrollY=0 is TOP/oldest, ScrollY=MaxScroll is BOTTOM/newest.
// Visual layout: newest at bottom → baseY = bottom - contentH (ScrollY at max).
@ -358,7 +356,6 @@ public sealed class UiText : UiElement
// ScrollByLines sign: +down/newer, -up/older.
// e.Data0 > 0 → wheel up → want older → ScrollByLines with negative lines.
Scroll.ScrollByLines((int)(-e.Data0 * WheelLines));
_pinBottom = Scroll.AtEnd;
return true;
}