fix(ui): complete retail indicator detail panels

Port the authored effect row template, remaining-time and selection details, synchronize the full gmPanelUI child geometry, and route the burden indicator to Character Information panel 3.

Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-17 11:36:43 +02:00
parent a96767ba6d
commit d1d603105f
24 changed files with 3696 additions and 147 deletions

View file

@ -6,7 +6,8 @@ using AcDream.App.UI;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Per-window controller for the Character window (LayoutDesc 0x2100002E, gmCharacterInfoUI).
/// Per-window controller for the Character Information child in the production
/// gmPanelUI layout (LayoutDesc 0x2100006E, root 0x10000183).
///
/// <para>Retail fills a single scrollable <c>UIElement_Text</c> element (id 0x1000011d,
/// <c>m_pMainText</c>) by calling a sequence of Update* methods that each
@ -32,9 +33,14 @@ namespace AcDream.App.UI.Layout;
/// </summary>
public static class CharacterController
{
public const uint LayoutId = 0x2100006Eu;
public const uint RootId = 0x10000183u;
/// <summary>Dat element id for the main report text (m_pMainText). Confirmed in
/// <c>gmCharacterInfoUI::PostInit</c> 0x004b86f0: <c>GetChildRecursive(this, 0x1000011d)</c>.</summary>
public const uint MainTextId = 0x1000011du;
public const uint ScrollbarId = 0x1000011Eu;
public const uint CloseId = 0x100000FCu;
/// <summary>White body text — matches retail's default UIElement_Text foreground.</summary>
private static readonly Vector4 BodyColor = new(1f, 1f, 1f, 1f);
@ -48,27 +54,27 @@ public static class CharacterController
/// <see cref="UiText.LinesProvider"/> so the Studio or a live session can push a fresh
/// <see cref="CharacterSheet"/> without re-binding.
///
/// <para>The element must resolve as a <see cref="UiText"/> (Type 12 in the dat).
/// If 0x1000011d is absent from the layout the bind is a no-op — partial layouts
/// (e.g. test fakes) don't cause errors.</para>
/// <para>Production data resolves the element as a <see cref="UiText"/>
/// (Type 12). A synthetic fallback is created only for deliberately reduced
/// test layouts.</para>
/// </summary>
/// <param name="layout">Imported 0x2100002E layout tree.</param>
/// <param name="layout">Imported 0x2100006E / 0x10000183 layout tree.</param>
/// <param name="data">Provider returning the current <see cref="CharacterSheet"/>.</param>
/// <param name="datFont">Retail dat font forwarded from the render stack. May be null
/// (falls back to the BitmapFont debug path).</param>
public static void Bind(
ImportedLayout layout,
Func<CharacterSheet> data,
UiDatFont? datFont = null)
UiDatFont? datFont = null,
Action? close = null)
{
// Retail's gmCharacterInfoUI CREATES m_pMainText (0x1000011d) at RUNTIME — it is NOT a static
// element in any LayoutDesc (confirmed: acdream's dat-import of 0x2100002E AND 0x2100006E both
// lack it; only a runtime UI-tree capture has it). So replicate that: build the report text
// element ourselves and place it in the panel body, exactly as the gm*UI does at runtime.
// End-of-retail data authors m_pMainText under the Character Information
// child. The fallback exists only so focused controller tests can bind a
// deliberately reduced tree without fabricating production structure.
var root = layout.Root;
if (root is null) return;
var text = new UiText
UiText text = layout.FindElement(MainTextId) as UiText ?? new UiText
{
EventId = MainTextId,
Name = "m_pMainText",
@ -81,8 +87,17 @@ public static class CharacterController
DatFont = datFont,
ClickThrough = false,
};
if (text.Parent is null)
root.AddChild(text);
else if (text.DatFont is null && datFont is not null)
text.DatFont = datFont;
text.PreserveEndOnLayout = false;
if (layout.FindElement(ScrollbarId) is UiScrollbar scrollbar)
scrollbar.Model = text.Scroll;
text.LinesProvider = () => BuildReport(data());
root.AddChild(text);
if (layout.FindElement(CloseId) is UiButton closeButton)
closeButton.OnClick = close;
}
// ── Report builder ────────────────────────────────────────────────────────

View file

@ -0,0 +1,148 @@
using DatReaderWriter;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Instantiates retail's effect-token template <c>0x10000128</c>. This is the
/// retained equivalent of <c>InfoRegion::InfoRegion @ 0x004F1450</c> calling
/// <c>UIElement_ListBox::AddItemFromTemplateList</c> for
/// <c>EffectInfoRegion</c>.
/// </summary>
public sealed class EffectRowTemplateFactory
{
private readonly ElementInfo _template;
private readonly Func<uint, (uint tex, int w, int h)> _resolveSprite;
private readonly UiDatFont? _defaultFont;
private readonly IReadOnlyDictionary<uint, UiDatFont?> _fonts;
public EffectRowTemplateFactory(
ElementInfo template,
Func<uint, (uint tex, int w, int h)> resolveSprite,
UiDatFont? defaultFont,
IReadOnlyDictionary<uint, UiDatFont?>? fonts = null)
{
_template = template ?? throw new ArgumentNullException(nameof(template));
_resolveSprite = resolveSprite ?? throw new ArgumentNullException(nameof(resolveSprite));
_defaultFont = defaultFont;
_fonts = fonts ?? new Dictionary<uint, UiDatFont?>();
}
public float Width => _template.Width;
public float Height => _template.Height;
public static EffectRowTemplateFactory? TryLoad(
DatCollection dats,
Func<uint, (uint tex, int w, int h)> resolveSprite,
UiDatFont? defaultFont,
Func<uint, UiDatFont?>? resolveFont)
{
ElementInfo? template = LayoutImporter.ImportInfos(
dats,
EffectsUiController.LayoutId,
EffectsUiController.RowTemplateId);
if (template is null
|| Find(template, EffectsUiController.RowIconId) is null
|| Find(template, EffectsUiController.RowLabelId) is null
|| Find(template, EffectsUiController.RowDurationId) is null
|| template.Width <= 0f
|| template.Height <= 0f)
return null;
var fonts = new Dictionary<uint, UiDatFont?>();
CaptureFonts(template, resolveFont, fonts);
return new EffectRowTemplateFactory(
template, resolveSprite, defaultFont, fonts);
}
public EffectRow Create(
uint spellId,
uint iconTexture,
string name,
string remaining)
{
ImportedLayout content = LayoutImporter.Build(
_template,
_resolveSprite,
_defaultFont,
did => _fonts.TryGetValue(did, out UiDatFont? font)
? font
: _defaultFont);
UiElement iconHost = Required(content, EffectsUiController.RowIconId);
UiText label = Required<UiText>(content, EffectsUiController.RowLabelId);
UiText duration = Required<UiText>(content, EffectsUiController.RowDurationId);
iconHost.AddChild(new UiTextureElement
{
Width = iconHost.Width,
Height = iconHost.Height,
Anchors = AnchorEdges.Left | AnchorEdges.Top
| AnchorEdges.Right | AnchorEdges.Bottom,
Texture = iconTexture,
});
label.LinesProvider = () => [new UiText.Line(name, label.DefaultColor)];
var slot = new UiTemplateListSlot(
content,
spellId,
UiButtonStateMachine.Normal,
UiButtonStateMachine.Highlight);
return new EffectRow(slot, duration, remaining);
}
public sealed class EffectRow
{
private readonly UiText _duration;
private string _remaining;
internal EffectRow(
UiTemplateListSlot slot,
UiText duration,
string remaining)
{
Slot = slot;
_duration = duration;
_remaining = remaining;
_duration.LinesProvider = () =>
[new UiText.Line(_remaining, _duration.DefaultColor)];
}
public UiTemplateListSlot Slot { get; }
public string Remaining
{
get => _remaining;
set => _remaining = value;
}
}
private static T Required<T>(ImportedLayout content, uint id)
where T : UiElement
=> content.FindElement(id) as T
?? throw new InvalidOperationException(
$"Retail effect template element 0x{id:X8} did not resolve to {typeof(T).Name}.");
private static UiElement Required(ImportedLayout content, uint id)
=> content.FindElement(id)
?? throw new InvalidOperationException(
$"Retail effect template element 0x{id:X8} is missing.");
private static ElementInfo? Find(ElementInfo root, uint id)
{
if (root.Id == id) return root;
foreach (ElementInfo child in root.Children)
if (Find(child, id) is { } found)
return found;
return null;
}
private static void CaptureFonts(
ElementInfo info,
Func<uint, UiDatFont?>? resolveFont,
Dictionary<uint, UiDatFont?> fonts)
{
if (info.FontDid != 0u && !fonts.ContainsKey(info.FontDid))
fonts[info.FontDid] = resolveFont?.Invoke(info.FontDid);
foreach (ElementInfo child in info.Children)
CaptureFonts(child, resolveFont, fonts);
}
}

View file

@ -16,16 +16,24 @@ public sealed class EffectsUiController : IRetainedPanelController
public const uint NegativeRootId = 0x10000121u;
public const uint CloseId = 0x100000FCu;
public const uint ListId = 0x10000123u;
public const uint ScrollbarId = 0x10000124u;
public const uint InfoTextId = 0x10000126u;
public const uint RowTemplateId = 0x10000128u;
public const uint RowIconId = 0x10000129u;
public const uint RowLabelId = 0x1000012Au;
public const uint RowDurationId = 0x1000012Bu;
private readonly Spellbook _spellbook;
private readonly bool _positive;
private readonly Func<double> _serverTime;
private readonly Func<uint, uint> _resolveSpellIcon;
private readonly EffectRowTemplateFactory _templates;
private readonly string _selectPrompt;
private readonly UiItemList _list;
private readonly UiText? _info;
private readonly UiButton? _close;
private readonly Dictionary<uint, UiCatalogSlot> _rows = new();
private readonly UiScrollbar? _scrollbar;
private readonly Dictionary<uint, EffectRowTemplateFactory.EffectRow> _rows = new();
private uint? _selectedSpellId;
private double _lastDurationUpdate = double.NaN;
private bool _disposed;
@ -38,6 +46,8 @@ public sealed class EffectsUiController : IRetainedPanelController
bool positive,
Func<double> serverTime,
Func<uint, uint> resolveSpellIcon,
EffectRowTemplateFactory templates,
string selectPrompt,
UiItemList list,
Action? close)
{
@ -45,13 +55,18 @@ public sealed class EffectsUiController : IRetainedPanelController
_positive = positive;
_serverTime = serverTime;
_resolveSpellIcon = resolveSpellIcon;
_templates = templates;
_selectPrompt = selectPrompt;
_list = list;
_info = layout.FindElement(InfoTextId) as UiText;
_close = layout.FindElement(CloseId) as UiButton;
_scrollbar = layout.FindElement(ScrollbarId) as UiScrollbar;
if (_close is not null) _close.OnClick = close;
_list.Columns = 1;
_list.CellWidth = Math.Max(1f, _list.Width);
_list.CellHeight = 32f;
_list.CellWidth = templates.Width;
_list.CellHeight = templates.Height;
if (_scrollbar is not null)
_scrollbar.Model = _list.Scroll;
ConfigureInfo();
_spellbook.EnchantmentsChanged += Rebuild;
Rebuild();
@ -64,6 +79,8 @@ public sealed class EffectsUiController : IRetainedPanelController
Func<double> serverTime,
Func<uint, (uint Texture, int Width, int Height)> spriteResolve,
Func<uint, uint> resolveSpellIcon,
EffectRowTemplateFactory templates,
string selectPrompt,
Action? close = null)
{
UiElement? host = layout.FindElement(ListId);
@ -82,7 +99,15 @@ public sealed class EffectsUiController : IRetainedPanelController
host.AddChild(list);
}
return new EffectsUiController(
layout, spellbook, positive, serverTime, resolveSpellIcon, list, close);
layout,
spellbook,
positive,
serverTime,
resolveSpellIcon,
templates,
selectPrompt,
list,
close);
}
public void Tick()
@ -95,8 +120,10 @@ public sealed class EffectsUiController : IRetainedPanelController
return;
_lastDurationUpdate = now;
foreach (ActiveEnchantmentRecord enchantment in VisibleEnchantments())
if (_rows.TryGetValue(enchantment.Identity, out UiCatalogSlot? row))
row.Detail = FormatRemaining(enchantment, now);
if (_rows.TryGetValue(
enchantment.Identity,
out EffectRowTemplateFactory.EffectRow? row))
row.Remaining = FormatRemaining(enchantment, now);
}
private void Rebuild()
@ -111,22 +138,18 @@ public sealed class EffectsUiController : IRetainedPanelController
{
_spellbook.TryGetMetadata(enchantment.SpellId, out SpellMetadata? metadata);
uint identity = enchantment.Identity;
var row = new UiCatalogSlot
{
EntryId = enchantment.SpellId,
CatalogIconTexture = metadata is null ? 0u : _resolveSpellIcon(enchantment.SpellId),
Label = metadata?.Name ?? $"Spell {enchantment.SpellId}",
Detail = FormatRemaining(enchantment, _serverTime()),
ShowLabel = true,
SpriteResolve = _list.SpriteResolve,
};
row.Clicked = () => Select(enchantment.SpellId);
EffectRowTemplateFactory.EffectRow row = _templates.Create(
enchantment.SpellId,
metadata is null ? 0u : _resolveSpellIcon(enchantment.SpellId),
metadata?.Name ?? $"Spell {enchantment.SpellId}",
FormatRemaining(enchantment, _serverTime()));
row.Slot.Clicked = () => Select(enchantment.SpellId);
_rows[identity] = row;
_list.AddItem(row);
_list.AddItem(row.Slot);
}
}
if (_selectedSpellId is uint selected
&& !_rows.Values.Any(row => row.EntryId == selected))
&& !_rows.Values.Any(row => row.Slot.EntryId == selected))
_selectedSpellId = null;
SyncSelection();
}
@ -145,7 +168,9 @@ public sealed class EffectsUiController : IRetainedPanelController
private static string FormatRemaining(ActiveEnchantmentRecord enchantment, double now)
{
if (enchantment.Duration < 0) return "Permanent";
// EffectInfoRegion::Update @ 0x004F1C00 starts from retail's empty
// PString and only formats it when the remaining duration is >= 0.
if (enchantment.Duration < 0) return string.Empty;
double remaining = Math.Max(0, enchantment.StartTime + enchantment.Duration - now);
if (!double.IsFinite(remaining)) return "--:--";
remaining = Math.Min(remaining, TimeSpan.MaxValue.TotalSeconds);
@ -165,25 +190,27 @@ public sealed class EffectsUiController : IRetainedPanelController
private void SyncSelection()
{
foreach (UiCatalogSlot row in _rows.Values)
row.Selected = row.EntryId == _selectedSpellId;
foreach (EffectRowTemplateFactory.EffectRow row in _rows.Values)
row.Slot.SetSelected(row.Slot.EntryId == _selectedSpellId);
}
private void ConfigureInfo()
{
if (_info is null) return;
_info.PreserveEndOnLayout = false;
_info.LinesProvider = () =>
{
if (_selectedSpellId is not uint spellId)
return Array.Empty<UiText.Line>();
return [new UiText.Line(_selectPrompt, _info.DefaultColor)];
ActiveEnchantmentRecord? selected = _spellbook.ActiveEnchantmentSnapshot
.Cast<ActiveEnchantmentRecord?>()
.FirstOrDefault(record => record?.SpellId == spellId);
if (selected is null || !_spellbook.TryGetMetadata(selected.Value.SpellId, out SpellMetadata metadata))
return Array.Empty<UiText.Line>();
return [new UiText.Line(_selectPrompt, _info.DefaultColor)];
return
[
new UiText.Line(metadata.Name, _info.DefaultColor),
new UiText.Line(string.Empty, _info.DefaultColor),
new UiText.Line(metadata.Description, _info.DefaultColor),
];
};
@ -197,5 +224,6 @@ public sealed class EffectsUiController : IRetainedPanelController
_disposed = true;
_spellbook.EnchantmentsChanged -= Rebuild;
if (_close is not null) _close.OnClick = null;
if (_scrollbar is not null) _scrollbar.Model = null;
}
}

View file

@ -87,7 +87,7 @@ public sealed class IndicatorBarController : IRetainedPanelController
_harmful.OnClick = () => bindings.TogglePanel(RetailPanelCatalog.NegativeEffects);
_link.OnClick = () => bindings.TogglePanel(RetailPanelCatalog.LinkStatus);
_vitae.OnClick = () => bindings.TogglePanel(RetailPanelCatalog.Vitae);
_burden.OnClick = () => bindings.TogglePanel(RetailPanelCatalog.Character);
_burden.OnClick = () => bindings.TogglePanel(RetailPanelCatalog.CharacterInformation);
_miniGame.OnClick = () => bindings.TogglePanel(RetailPanelCatalog.MiniGame);
_endCharacterSession.OnClick = bindings.RequestEndCharacterSession;

View file

@ -21,8 +21,8 @@ public sealed class RetailPanelUiController : IDisposable
private uint? _activePanel;
private uint? _deferredPanel;
private bool _applying;
private bool _synchronizingPlacement;
private PanelPlacement? _mainPanelPlacement;
private bool _synchronizingGeometry;
private PanelGeometry? _mainPanelGeometry;
private bool _disposed;
public RetailPanelUiController(
@ -38,11 +38,11 @@ public sealed class RetailPanelUiController : IDisposable
public uint? ActivePanelId => _activePanel;
/// <summary>
/// Registers one of retail <c>gmPanelUI</c>'s primary toolbar children.
/// Inventory, Character, and Magic retain panel-specific content/resize
/// policy but share the one parent placement. A drag of any registered
/// child updates every hidden sibling through typed window handles, so
/// persistence observes the same canonical position too.
/// Registers one of retail <c>gmPanelUI</c>'s toolbar or detail children.
/// All primary and detail children retain panel-specific content but share
/// one parent geometry. A move or resize of any registered child updates
/// every hidden sibling through typed window handles, so persistence sees
/// the same canonical rectangle too.
/// </summary>
public void RegisterMainPanel(
uint panelId,
@ -61,7 +61,7 @@ public sealed class RetailPanelUiController : IDisposable
windowName,
restorePrevious,
window,
sharesMainPanelPlacement: true);
sharesMainPanelGeometry: true);
}
/// <param name="restorePrevious">
@ -75,14 +75,14 @@ public sealed class RetailPanelUiController : IDisposable
windowName,
restorePrevious,
window: null,
sharesMainPanelPlacement: false);
sharesMainPanelGeometry: false);
private void RegisterCore(
uint panelId,
string windowName,
bool restorePrevious,
RetailWindowHandle? window,
bool sharesMainPanelPlacement)
bool sharesMainPanelGeometry)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (panelId == 0) throw new ArgumentOutOfRangeException(nameof(panelId));
@ -95,14 +95,17 @@ public sealed class RetailPanelUiController : IDisposable
windowName,
restorePrevious,
window,
sharesMainPanelPlacement);
sharesMainPanelGeometry);
_byPanel.Add(panelId, entry);
_byWindow.Add(windowName, panelId);
if (window is not null)
{
window.Moved += OnWindowMoved;
window.Resized += OnWindowResized;
}
if (sharesMainPanelPlacement && _mainPanelPlacement is { } placement)
MoveWindowTo(entry, placement);
if (sharesMainPanelGeometry && _mainPanelGeometry is { } geometry)
ApplyWindowGeometry(entry, geometry);
if (_isVisible(windowName))
ObserveWindowVisibility(windowName, visible: true);
}
@ -144,7 +147,7 @@ public sealed class RetailPanelUiController : IDisposable
{
if (_activePanel == panelId)
{
PrepareMainPanelPlacement(requested);
PrepareMainPanelGeometry(requested);
return _show(requested.WindowName);
}
@ -156,7 +159,7 @@ public sealed class RetailPanelUiController : IDisposable
: null;
if (previous is { } previousEntry)
CaptureMainPanelPlacement(previousEntry, synchronizeSiblings: true);
CaptureMainPanelGeometry(previousEntry, synchronizeSiblings: true);
_deferredPanel = requested.RestorePrevious
&& previous is { RestorePrevious: false }
@ -165,7 +168,7 @@ public sealed class RetailPanelUiController : IDisposable
_activePanel = panelId;
if (previous is not null)
_hide(previous.Value.WindowName);
PrepareMainPanelPlacement(requested);
PrepareMainPanelGeometry(requested);
return _show(requested.WindowName);
}
@ -175,7 +178,7 @@ public sealed class RetailPanelUiController : IDisposable
return _hide(requested.WindowName);
}
CaptureMainPanelPlacement(requested, synchronizeSiblings: true);
CaptureMainPanelGeometry(requested, synchronizeSiblings: true);
bool hidden = _hide(requested.WindowName);
_activePanel = null;
if (_deferredPanel is not uint deferredId
@ -184,7 +187,7 @@ public sealed class RetailPanelUiController : IDisposable
_deferredPanel = null;
_activePanel = deferredId;
PrepareMainPanelPlacement(deferred);
PrepareMainPanelGeometry(deferred);
return _show(deferred.WindowName) || hidden;
}
finally
@ -205,76 +208,109 @@ public sealed class RetailPanelUiController : IDisposable
private void OnWindowMoved(RetailWindowHandle window)
{
if (_disposed || _synchronizingPlacement) return;
if (_disposed || _synchronizingGeometry) return;
foreach (PanelEntry entry in _byPanel.Values)
{
if (!entry.SharesMainPanelPlacement
if (!entry.SharesMainPanelGeometry
|| !ReferenceEquals(entry.Window, window))
continue;
_mainPanelPlacement = new PanelPlacement(window.Left, window.Top);
SynchronizeMainPanelSiblings(window);
CaptureAndSynchronizeMainPanelGeometry(window);
return;
}
}
private void CaptureMainPanelPlacement(
private void OnWindowResized(RetailWindowHandle window)
{
if (_disposed || _synchronizingGeometry) return;
foreach (PanelEntry entry in _byPanel.Values)
{
if (!entry.SharesMainPanelGeometry
|| !ReferenceEquals(entry.Window, window))
continue;
CaptureAndSynchronizeMainPanelGeometry(window);
return;
}
}
private void CaptureAndSynchronizeMainPanelGeometry(RetailWindowHandle source)
{
_mainPanelGeometry = new PanelGeometry(
source.Left,
source.Top,
source.Width,
source.Height);
SynchronizeMainPanelSiblings(source);
}
private void CaptureMainPanelGeometry(
PanelEntry entry,
bool synchronizeSiblings)
{
if (!entry.SharesMainPanelPlacement || entry.Window is not { } window)
if (!entry.SharesMainPanelGeometry || entry.Window is not { } window)
return;
_mainPanelPlacement = new PanelPlacement(window.Left, window.Top);
_mainPanelGeometry = new PanelGeometry(
window.Left,
window.Top,
window.Width,
window.Height);
if (synchronizeSiblings)
SynchronizeMainPanelSiblings(window);
}
private void PrepareMainPanelPlacement(PanelEntry entry)
private void PrepareMainPanelGeometry(PanelEntry entry)
{
if (!entry.SharesMainPanelPlacement || entry.Window is not { } window)
if (!entry.SharesMainPanelGeometry || entry.Window is not { } window)
return;
if (_mainPanelPlacement is not { } placement)
if (_mainPanelGeometry is not { } geometry)
{
_mainPanelPlacement = new PanelPlacement(window.Left, window.Top);
_mainPanelGeometry = new PanelGeometry(
window.Left,
window.Top,
window.Width,
window.Height);
SynchronizeMainPanelSiblings(window);
return;
}
MoveWindowTo(entry, placement);
ApplyWindowGeometry(entry, geometry);
}
private void SynchronizeMainPanelSiblings(RetailWindowHandle source)
{
if (_mainPanelPlacement is not { } placement) return;
if (_mainPanelGeometry is not { } geometry) return;
_synchronizingPlacement = true;
_synchronizingGeometry = true;
try
{
foreach (PanelEntry sibling in _byPanel.Values)
{
if (!sibling.SharesMainPanelPlacement
if (!sibling.SharesMainPanelGeometry
|| sibling.Window is not { } window
|| ReferenceEquals(window, source))
continue;
MoveWindowTo(sibling, placement);
ApplyWindowGeometry(sibling, geometry);
}
}
finally
{
_synchronizingPlacement = false;
_synchronizingGeometry = false;
}
}
private void MoveWindowTo(PanelEntry entry, PanelPlacement placement)
private static void ApplyWindowGeometry(PanelEntry entry, PanelGeometry geometry)
{
if (entry.Window is not { } window
|| (window.Left == placement.Left && window.Top == placement.Top))
return;
if (entry.Window is not { } window) return;
window.MoveTo(placement.Left, placement.Top);
if (window.Left != geometry.Left || window.Top != geometry.Top)
window.MoveTo(geometry.Left, geometry.Top);
if (window.Width != geometry.Width || window.Height != geometry.Height)
window.ResizeTo(geometry.Width, geometry.Height);
}
public void Dispose()
@ -283,14 +319,21 @@ public sealed class RetailPanelUiController : IDisposable
_disposed = true;
foreach (PanelEntry entry in _byPanel.Values)
if (entry.Window is { } window)
{
window.Moved -= OnWindowMoved;
window.Resized -= OnWindowResized;
}
}
private readonly record struct PanelPlacement(float Left, float Top);
private readonly record struct PanelGeometry(
float Left,
float Top,
float Width,
float Height);
private readonly record struct PanelEntry(
string WindowName,
bool RestorePrevious,
RetailWindowHandle? Window,
bool SharesMainPanelPlacement);
bool SharesMainPanelGeometry);
}