namespace AcDream.App.UI.Layout; /// /// Retained-mode port of retail DialogFactory @ 0x004773C0..0x00478470. /// It owns dialog contexts, independent FIFO queue groups, nonqueued dialogs, /// priority preemption, callback delivery, close notices, and fresh catalog roots. /// public sealed class RetailDialogFactory : IDisposable { public const uint DefaultQueueKey = 2u; public const uint NonQueuedKey = 1u; private sealed class DialogInfo { public required RetailDialogData Data { get; init; } public required uint Context { get; init; } public required uint QueueKey { get; init; } public Action? Callback { get; init; } public IRetailDialogView? View { get; set; } } private readonly UiRoot _host; private readonly Func _createLayout; private readonly Dictionary _activeQueued = new(); private readonly Dictionary _activeNonQueued = new(); private readonly Dictionary> _pending = new(); private readonly List _openOrder = new(); private uint _globalContext; private bool _resetting; private bool _disposed; public RetailDialogFactory( UiRoot host, Func createLayout) { _host = host ?? throw new ArgumentNullException(nameof(host)); _createLayout = createLayout ?? throw new ArgumentNullException(nameof(createLayout)); } /// /// Retail's global close-dialog notice, raised after the per-context callback and /// before the live root is removed from its parent. /// public event Action? DialogClosed; public event Action? DialogOpened; public bool IsOpen => _activeQueued.Count != 0 || _activeNonQueued.Count != 0; public int ActiveCount => _activeQueued.Count + _activeNonQueued.Count; public int PendingCount => _pending.Values.Sum(static queue => queue.Count); /// Exact root-element switch from CreateDialog_ @ 0x00477AD0. public static uint RootElementId(RetailDialogType type) => type switch { RetailDialogType.Confirmation => 0x15u, RetailDialogType.Wait => 0x31u, RetailDialogType.Message => 0x24u, RetailDialogType.TextInput => 0x28u, RetailDialogType.ConfirmationTextInput => 0x2Cu, RetailDialogType.Menu => 0x1Bu, RetailDialogType.ConfirmationMenu => 0x1Fu, _ => 0u, }; public uint MakeDialog(RetailDialogData data) => MakeDialog(data, callback: null); /// /// Retail MakeCallbackDialogInCurrentUI @ 0x00478430. /// public uint MakeDialog(RetailDialogData data, Action? callback) { ObjectDisposedException.ThrowIf(_disposed, this); ArgumentNullException.ThrowIfNull(data); uint context = NextContext(); RetailDialogData ownedData = data.Clone(); uint queueKey = ownedData.GetUInt32(RetailDialogProperty.QueueKey, DefaultQueueKey); if (queueKey == 0u) queueKey = DefaultQueueKey; var info = new DialogInfo { Data = ownedData, Context = context, QueueKey = queueKey, Callback = callback, }; if (queueKey == NonQueuedKey) { _activeNonQueued.Add(context, info); CreateDialog(info); return context; } if (!_activeQueued.TryGetValue(queueKey, out DialogInfo? current)) { _activeQueued.Add(queueKey, info); CreateDialog(info); return context; } LinkedList queue = PendingQueue(queueKey); if (!ownedData.GetBoolean(RetailDialogProperty.Priority)) { queue.AddLast(info); UpdatePendingDialogDisplays(); return context; } // MakeDialog's 0x8D branch removes the active root without completing the // DialogInfo, inserts that current info at the front, then displays the // priority request in its place. Suspend(current); queue.AddFirst(current); _activeQueued[queueKey] = info; CreateDialog(info); return context; } public uint MakeConfirmation( string message, Action? callback = null, uint queueKey = DefaultQueueKey, bool priority = false) { RetailDialogData data = RetailDialogData.Confirmation(message) .Set(RetailDialogProperty.QueueKey, queueKey); if (priority) data.Set(RetailDialogProperty.Priority, true); return MakeDialog(data, callback); } /// /// Text-only wait dialog (type 2), closed by the caller via /// . Property shape mirrors /// UIOption_ActionKeyMap::OpenMapWarnDialog @ 0x00488A00: type 2, /// caller-chosen queue key, element attribute 0x40 set, message text. /// public uint MakeWait(string message, uint queueKey = DefaultQueueKey) { RetailDialogData data = RetailDialogData.Wait(message) .Set(RetailDialogProperty.QueueKey, queueKey); return MakeDialog(data, callback: null); } public uint MakeMessage( string message, Action? callback = null, uint queueKey = DefaultQueueKey) { RetailDialogData data = RetailDialogData.Message(message) .Set(RetailDialogProperty.QueueKey, queueKey); return MakeDialog(data, callback); } public uint MakeConfirmationTextInput( string message, Action? callback = null, uint queueKey = DefaultQueueKey) { RetailDialogData data = RetailDialogData.ConfirmationTextInput(message) .Set(RetailDialogProperty.QueueKey, queueKey); return MakeDialog(data, callback); } /// /// Retail CloseDialog @ 0x00478160. The context can identify an active /// nonqueued dialog, an active queued dialog, or an item still pending in a queue. /// public bool CloseDialog(uint context) { if (context == 0u) return false; if (_activeNonQueued.Remove(context, out DialogInfo? nonQueued)) { DialogDone(nonQueued); return true; } foreach ((uint queueKey, DialogInfo active) in _activeQueued.ToArray()) { if (active.Context != context) continue; _activeQueued.Remove(queueKey); DialogDone(active); OpenNextDialog(queueKey); return true; } foreach ((uint queueKey, LinkedList queue) in _pending.ToArray()) { LinkedListNode? node = queue.First; while (node is not null && node.Value.Context != context) node = node.Next; if (node is null) continue; DialogInfo pending = node.Value; queue.Remove(node); if (queue.Count == 0) _pending.Remove(queueKey); DialogDone(pending); UpdatePendingDialogDisplays(); return true; } return false; } public void Tick() { foreach (DialogInfo info in _openOrder.ToArray()) info.View?.Tick(); } /// /// Retail DialogFactory::Reset @ 0x00477950 completes active and pending /// infos before clearing the factory. /// public void Reset() { if (_resetting) return; _resetting = true; List? failures = null; try { while (true) { DialogInfo[] infos = _activeNonQueued.Values .Concat(_activeQueued.Values) .Concat(_pending.Values.SelectMany(static queue => queue)) .Distinct() .ToArray(); if (infos.Length == 0) break; // Callbacks may synchronously create another dialog. Retire // this exact snapshot, then loop until those reentrant infos // have also completed through DialogDone. _activeNonQueued.Clear(); _activeQueued.Clear(); _pending.Clear(); foreach (DialogInfo info in infos) { try { DialogDone(info); } catch (Exception error) { (failures ??= []).Add(error); } } } } finally { _resetting = false; RefreshModal(); } if (failures is not null) throw new AggregateException( "One or more dialogs failed while the factory reset.", failures); } public void Dispose() { if (_disposed) return; _disposed = true; Reset(); } private uint NextContext() { _globalContext++; if (_globalContext == 0u) _globalContext++; return _globalContext; } private LinkedList PendingQueue(uint queueKey) { if (_pending.TryGetValue(queueKey, out LinkedList? queue)) return queue; queue = new LinkedList(); _pending.Add(queueKey, queue); return queue; } private void CreateDialog(DialogInfo info) { RetailDialogType type = (RetailDialogType)info.Data.GetUInt32(RetailDialogProperty.Type); if (type is not (RetailDialogType.Confirmation or RetailDialogType.Wait or RetailDialogType.Message or RetailDialogType.ConfirmationTextInput)) throw new NotSupportedException( $"Retail dialog type {(uint)type} does not have a ported presenter yet."); ImportedLayout layout = _createLayout(type) ?? throw new InvalidOperationException( $"Retail dialog catalog could not create type {(uint)type}."); IRetailDialogView view = type switch { RetailDialogType.Wait => new RetailWaitDialogView(_host, layout, info.Data), RetailDialogType.Message => new RetailMessageDialogView( _host, layout, info.Data, info.Context, context => CloseDialog(context)), RetailDialogType.ConfirmationTextInput => new RetailConfirmationTextInputDialogView( _host, layout, info.Data, info.Context, context => CloseDialog(context)), _ => new RetailConfirmationDialogView( _host, layout, info.Data, info.Context, context => CloseDialog(context)), }; info.View = view; _host.AddChild(view.Root); _host.BringToFront(view.Root); _openOrder.Add(info); _host.Modal = view.Root; view.Tick(); UpdatePendingDialogDisplays(); DialogOpened?.Invoke(info.Context); } private void Suspend(DialogInfo info) { if (info.View is null) return; IRetailDialogView view = info.View; info.View = null; view.DetachHandlers(); _openOrder.Remove(info); _host.RemoveChild(view.Root); RefreshModal(); } private void DialogDone(DialogInfo info) { // DialogDone @ 0x004773C0 delivers the registered callback first, then // broadcasts the CloseDialog notice, and only then removes the root. try { info.Callback?.Invoke(info.Data); DialogClosed?.Invoke(info.Context, info.Data); } finally { RemoveView(info); } } private void RemoveView(DialogInfo info) { if (info.View is { } view) { view.DetachHandlers(); _host.RemoveChild(view.Root); info.View = null; _openOrder.Remove(info); } RefreshModal(); } private void OpenNextDialog(uint queueKey) { // A callback invoked from DialogDone (above, in CloseDialog) may // synchronously make a new dialog under this same queue key before // control returns here — MakeDialog's queued branch will have // already re-occupied _activeQueued[queueKey]. Retail's HashTable::add // tolerates the duplicate; Dictionary.Add does not. Bail out: the // re-entrant dialog's own eventual CloseDialog will drain the // pending queue via its own OpenNextDialog call. if (_activeQueued.ContainsKey(queueKey)) return; if (!_pending.TryGetValue(queueKey, out LinkedList? queue) || queue.First is null) return; DialogInfo next = queue.First.Value; queue.RemoveFirst(); if (queue.Count == 0) _pending.Remove(queueKey); _activeQueued.Add(queueKey, next); CreateDialog(next); } private void UpdatePendingDialogDisplays() { foreach ((uint queueKey, DialogInfo active) in _activeQueued) { int count = _pending.TryGetValue(queueKey, out LinkedList? queue) ? queue.Count : 0; active.View?.SetPendingCount(count); } } private void RefreshModal() { _host.Modal = _openOrder.Count == 0 ? null : _openOrder[^1].View?.Root; } }