Merge branch 'claude/latest-main-sync-497549' into worktree-agent-a46673911c3cc2a31
# Conflicts: # docs/plans/2026-09-07-campaign-vt-slice7-tabs.md # docs/plugin-ui-markup.md # src/AcDream.App/UI/UiMarkupList.cs
This commit is contained in:
commit
279de7c2db
12 changed files with 782 additions and 54 deletions
|
|
@ -640,6 +640,12 @@ public static class MarkupDocument
|
|||
binding,
|
||||
"list selected"),
|
||||
SelectionChanged = listChanged,
|
||||
// Campaign VT slice 7 resemblance re-check: VVS lists draw
|
||||
// no persistent row-selection fill by default (matches
|
||||
// both single-column and <column> mode now — see
|
||||
// UiMarkupList.SelectionBandEnabled). A plugin that wants
|
||||
// one back opts in with <list selectionband="true">.
|
||||
SelectionBandEnabled = B(el, "selectionband", false),
|
||||
};
|
||||
|
||||
if (listUsesColumns)
|
||||
|
|
|
|||
|
|
@ -4767,11 +4767,20 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
// later registration/sidepanel failure then rolls the mounted
|
||||
// subtree back through FailMount instead of leaking it.
|
||||
_bindings.Plugins.CompleteMount(panel, Host.Root, element);
|
||||
// #490 part 2: derive the authored-geometry revision from
|
||||
// the panel's own authored extent instead of a hard-coded 0
|
||||
// — see RetailWindowLayoutPersistence's class doc and
|
||||
// RetailWindowManager.ComputeAuthoredGeometryRevision's own
|
||||
// doc for why a plugin window can't use the built-in
|
||||
// windows' manual-literal scheme.
|
||||
int authoredGeometryRevision = RetailWindowManager.ComputeAuthoredGeometryRevision(
|
||||
element.Width, element.Height, element.MinWidth, element.MinHeight, element.Resizable);
|
||||
RetailWindowHandle handle = Host.WindowManager.Register(
|
||||
panel.WindowName,
|
||||
element,
|
||||
element,
|
||||
visibility);
|
||||
visibility,
|
||||
authoredGeometryRevision: authoredGeometryRevision);
|
||||
_bindings.Plugins.CompleteWindowMount(
|
||||
panel,
|
||||
() => Host.WindowManager.Unregister(panel.WindowName));
|
||||
|
|
|
|||
|
|
@ -9,6 +9,31 @@ namespace AcDream.App.UI;
|
|||
/// per-resolution settings. It deliberately ignores the temporary pre-login
|
||||
/// <c>default</c> character key so startup layout cannot overwrite a real
|
||||
/// character's state.
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Authored-geometry revision (#490 part 2).</b> Every registered window
|
||||
/// carries an <c>authoredGeometryRevision</c> (see
|
||||
/// <see cref="RetailWindowHandle.AuthoredGeometryRevision"/>); a restore
|
||||
/// whose saved revision differs from the handle's current one replaces only
|
||||
/// the saved WIDTH/HEIGHT with the current authored size
|
||||
/// (<see cref="MigrateAuthoredGeometry"/>) — position, visibility, and
|
||||
/// collapsed/maximized state are untouched, and the clamp in
|
||||
/// <see cref="Apply"/> still re-fits the kept position to the live screen.
|
||||
/// Built-in retail-imported windows hand-pick that revision as a small
|
||||
/// incrementing literal at their <c>Register</c> call site (chat windows:
|
||||
/// <c>authoredGeometryRevision = 1</c>) — a deliberate author decision each
|
||||
/// time their authored size changes. Plugin windows have no such call site
|
||||
/// an author remembers to touch, so <c>MountPlugins</c> instead derives the
|
||||
/// revision automatically from the authored geometry tuple itself via
|
||||
/// <see cref="RetailWindowManager.ComputeAuthoredGeometryRevision"/>
|
||||
/// (width, height, min width, min height, resizable): unchanged authored
|
||||
/// geometry hashes to the same revision (a user's own resize survives
|
||||
/// restore), and ANY authored geometry change hashes to a different one
|
||||
/// (the stored size resets to the new default exactly once). Because a hash
|
||||
/// is not an ordered counter, the comparison is for INEQUALITY — see
|
||||
/// <see cref="MigrateAuthoredGeometry"/>'s own doc for why the original
|
||||
/// "newer revision only" read was wrong for this case.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class RetailWindowLayoutPersistence : IDisposable
|
||||
{
|
||||
|
|
@ -295,11 +320,26 @@ public sealed class RetailWindowLayoutPersistence : IDisposable
|
|||
handle.AuthoredGeometryRevision);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #490 part 2: compares revisions for INEQUALITY, not ordering. Built-in
|
||||
/// retail-imported windows hand-pick a small incrementing literal
|
||||
/// (0, 1, 2…) that only ever grows, so the original "migrate only if
|
||||
/// saved < authored" read fine for them. Plugin windows instead derive
|
||||
/// their revision from a hash of the authored geometry itself
|
||||
/// (<see cref="RetailWindowManager.ComputeAuthoredGeometryRevision"/>) so
|
||||
/// their author never has to remember to bump a literal — but a hash is
|
||||
/// not a counter, and two different authored sizes can hash in either
|
||||
/// order. "The authored size changed" therefore means "the value
|
||||
/// differs", not "the value went up"; treating it as ordered silently
|
||||
/// dropped every size-decreasing (by hash value, not by pixels) plugin
|
||||
/// update, which is exactly how MossTank's 856x236 -> 984x271 bump got
|
||||
/// stuck at the old size for every user with a stored layout.
|
||||
/// </summary>
|
||||
private static UiWindowLayout MigrateAuthoredGeometry(
|
||||
UiWindowLayout saved,
|
||||
UiWindowLayout authored)
|
||||
{
|
||||
if (saved.AuthoredGeometryRevision >= authored.AuthoredGeometryRevision)
|
||||
if (saved.AuthoredGeometryRevision == authored.AuthoredGeometryRevision)
|
||||
return saved;
|
||||
|
||||
return saved with
|
||||
|
|
|
|||
|
|
@ -111,6 +111,45 @@ public sealed class RetailWindowManager : IDisposable
|
|||
return handle;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Derives a stable authored-geometry revision from a window's own
|
||||
/// authored extent (width, height, min width, min height, resizable), so
|
||||
/// a plugin window's <see cref="Register"/> call can invalidate an
|
||||
/// obsolete saved size across an authored-size change WITHOUT the plugin
|
||||
/// author remembering to bump an explicit revision literal the way
|
||||
/// built-in retail-imported windows do (#490 part 2 — MossTank shipped
|
||||
/// 856x236 -> 984x271 and every stored layout stayed at 856x236 forever).
|
||||
/// Deliberately NOT <see cref="HashCode"/>: that type reseeds its
|
||||
/// internal state once per process specifically to defeat hash-flooding
|
||||
/// attacks, so the SAME geometry would hash to a DIFFERENT value on
|
||||
/// every relaunch — every login would look like a fresh authored-geometry
|
||||
/// revision and reset every plugin window's saved size, every time. This
|
||||
/// instead combines the exact IEEE-754 bit patterns with a fixed FNV-1a-
|
||||
/// style multiplier, which is stable across processes, machines, and
|
||||
/// .NET versions. <see cref="RetailWindowLayoutPersistence.MigrateAuthoredGeometry"/>
|
||||
/// compares revisions for INEQUALITY, not ordering — a hash is not a
|
||||
/// counter, so "authored size changed" means "the value differs",
|
||||
/// whichever direction it moved. The sign bit is masked off the result:
|
||||
/// <see cref="Register"/> clamps a negative <c>authoredGeometryRevision</c>
|
||||
/// up to 0 (its "no explicit revision" sentinel), and a hash landing
|
||||
/// there would be indistinguishable from an old, pre-hash saved layout
|
||||
/// that never had a revision at all.
|
||||
/// </summary>
|
||||
public static int ComputeAuthoredGeometryRevision(
|
||||
float width, float height, float minWidth, float minHeight, bool resizable)
|
||||
{
|
||||
unchecked
|
||||
{
|
||||
int hash = 17;
|
||||
hash = (hash * 31) + BitConverter.SingleToInt32Bits(width);
|
||||
hash = (hash * 31) + BitConverter.SingleToInt32Bits(height);
|
||||
hash = (hash * 31) + BitConverter.SingleToInt32Bits(minWidth);
|
||||
hash = (hash * 31) + BitConverter.SingleToInt32Bits(minHeight);
|
||||
hash = (hash * 31) + (resizable ? 1 : 0);
|
||||
return hash & 0x7FFFFFFF;
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryGet(string name, out RetailWindowHandle handle)
|
||||
=> _byName.TryGetValue(name, out handle!);
|
||||
|
||||
|
|
|
|||
|
|
@ -73,6 +73,25 @@ public sealed class UiMarkupList : UiElement
|
|||
public Vector4 TextColor { get; set; } = new(0.91f, 0.87f, 0.76f, 1f);
|
||||
public Vector4 SelectedColor { get; set; } = new(0.28f, 0.23f, 0.08f, 0.95f);
|
||||
|
||||
/// <summary>
|
||||
/// Campaign VT slice 7 resemblance re-check (2026-09-07): real VVS lists
|
||||
/// (VTank's own <c>HudList</c>) draw no persistent row-selection fill at
|
||||
/// all — before this fix the column-less <c>items=</c> mode drew
|
||||
/// <see cref="SelectedColor"/> under the selected row while the
|
||||
/// <c><column></c> mode did the same, so a plugin's Buffs lists
|
||||
/// highlighted a row while the Monsters/Meta grids happened not to (or
|
||||
/// vice versa, depending on which mode a given list used) — same-looking
|
||||
/// widgets, inconsistent behavior. Default false now suppresses the fill
|
||||
/// in BOTH <see cref="OnDraw"/>'s legacy branch and
|
||||
/// <see cref="DrawColumns"/>, matching VVS. <c><list
|
||||
/// selectionband="true"></c> (parsed in <see cref="MarkupDocument"/>'s
|
||||
/// <c>case "list"</c>) opts a single list back into a visible band for
|
||||
/// plugins that want one. This gates ONLY the fill — <see cref="SelectedIndexSource"/>,
|
||||
/// <see cref="SelectionChanged"/>, and the selected-row scroll-into-view
|
||||
/// logic in <see cref="OnDraw"/>/<see cref="DrawColumns"/> are unchanged.
|
||||
/// </summary>
|
||||
public bool SelectionBandEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Owner live-client report 2026-09-07 ("For scrollable dropdown or the
|
||||
/// meta window we use the same assets as we do in for example chat or
|
||||
|
|
@ -97,6 +116,28 @@ public sealed class UiMarkupList : UiElement
|
|||
private int _topRow;
|
||||
private IReadOnlyList<UiMarkupListColumn>? _columns;
|
||||
|
||||
/// <summary>
|
||||
/// Owner live-client report 2026-09-07 ("Scrolling in advanced options
|
||||
/// does not work... or it works sometimes"): the last
|
||||
/// <see cref="SelectedIndexSource"/> value the "keep selection visible"
|
||||
/// clamp below (<see cref="OnDraw"/>/<see cref="DrawColumns"/>) actually
|
||||
/// reacted to. A real bound list (e.g. MossTank's Advanced Options,
|
||||
/// <c>selected="{SelectedAdvancedOptionIndex}"</c>) keeps a STABLE
|
||||
/// selected index while the user scrolls elsewhere with the scrollbar —
|
||||
/// re-running the reveal clamp on EVERY frame regardless of whether
|
||||
/// selection actually changed snapped <see cref="_topRow"/> straight back
|
||||
/// to the (unchanged) selected row on the very next draw, undoing the
|
||||
/// scrollbar arrow/track/thumb interaction the same frame it happened.
|
||||
/// Gating the clamp on an observed CHANGE in the selected index — the
|
||||
/// only time retail HudList-style lists scroll to reveal a row — lets a
|
||||
/// stable selection coexist with the user scrolling away from it via the
|
||||
/// bar, while still auto-revealing a genuinely NEW selection exactly
|
||||
/// once. Sentinel <see cref="int.MinValue"/> so the very first draw with
|
||||
/// ANY selected index (including the valid -1 "nothing selected") still
|
||||
/// runs the clamp once.
|
||||
/// </summary>
|
||||
private int _lastRevealedSelected = int.MinValue;
|
||||
|
||||
/// <summary>
|
||||
/// Pixel-based scroll projection used ONLY to feed
|
||||
/// <see cref="UiScrollbar.ThumbRect"/>'s geometry math (thumb
|
||||
|
|
@ -145,12 +186,16 @@ public sealed class UiMarkupList : UiElement
|
|||
: 0f;
|
||||
int visibleRows = VisibleRows;
|
||||
int selected = SelectedIndexSource();
|
||||
if (selected >= 0 && selected < items.Count)
|
||||
if (selected != _lastRevealedSelected)
|
||||
{
|
||||
if (selected < _topRow)
|
||||
_topRow = selected;
|
||||
else if (selected >= _topRow + visibleRows)
|
||||
_topRow = selected - visibleRows + 1;
|
||||
_lastRevealedSelected = selected;
|
||||
if (selected >= 0 && selected < items.Count)
|
||||
{
|
||||
if (selected < _topRow)
|
||||
_topRow = selected;
|
||||
else if (selected >= _topRow + visibleRows)
|
||||
_topRow = selected - visibleRows + 1;
|
||||
}
|
||||
}
|
||||
ClampTop(items.Count, visibleRows);
|
||||
|
||||
|
|
@ -163,7 +208,7 @@ public sealed class UiMarkupList : UiElement
|
|||
for (int index = _topRow; index < end; index++)
|
||||
{
|
||||
float y = (index - _topRow) * RowHeight;
|
||||
if (index == selected)
|
||||
if (index == selected && SelectionBandEnabled)
|
||||
context.DrawFill(1f, y + 1f, contentWidth - 2f, RowHeight - 1f, SelectedColor);
|
||||
|
||||
if (iconIds is not null && index < iconIds.Count && IconResolve is { } resolve)
|
||||
|
|
@ -364,12 +409,16 @@ public sealed class UiMarkupList : UiElement
|
|||
ComputeColumnLayout(columns, contentWidth);
|
||||
|
||||
int selected = SelectedIndexSource();
|
||||
if (selected >= 0 && selected < rowCount)
|
||||
if (selected != _lastRevealedSelected)
|
||||
{
|
||||
if (selected < _topRow)
|
||||
_topRow = selected;
|
||||
else if (selected >= _topRow + visibleRows)
|
||||
_topRow = selected - visibleRows + 1;
|
||||
_lastRevealedSelected = selected;
|
||||
if (selected >= 0 && selected < rowCount)
|
||||
{
|
||||
if (selected < _topRow)
|
||||
_topRow = selected;
|
||||
else if (selected >= _topRow + visibleRows)
|
||||
_topRow = selected - visibleRows + 1;
|
||||
}
|
||||
}
|
||||
ClampTop(rowCount, visibleRows);
|
||||
|
||||
|
|
@ -379,14 +428,17 @@ public sealed class UiMarkupList : UiElement
|
|||
// Fix round B item 10 (owner/oracle: VVS's own HudList grids have NO
|
||||
// row-selection highlight at all — Monsters/Meta/Route/Items and
|
||||
// every other <list><column> grid). SelectedIndexSource above still
|
||||
// drives scroll-into-view; the SelectedColor band draw the legacy
|
||||
// single-column path (below, unaffected) uses is deliberately
|
||||
// skipped here. Every per-cell onclick/onchange callback is
|
||||
// unchanged — only the visual band is gone.
|
||||
// drives scroll-into-view; the SelectedColor band draw uses the same
|
||||
// SelectionBandEnabled gate as the legacy single-column path below
|
||||
// and defaults to false, so plugin lists keep no band unless a
|
||||
// caller opts in. Every per-cell onclick/onchange callback is
|
||||
// unchanged — only the visual band is conditional.
|
||||
int end = Math.Min(rowCount, _topRow + visibleRows);
|
||||
for (int index = _topRow; index < end; index++)
|
||||
{
|
||||
float y = (index - _topRow) * RowHeight;
|
||||
if (index == selected && SelectionBandEnabled)
|
||||
context.DrawFill(1f, y + 1f, contentWidth - 2f, RowHeight - 1f, SelectedColor);
|
||||
|
||||
for (int c = 0; c < columns.Count; c++)
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue