using System.Collections.Generic; using System.Numerics; using AcDream.App.Rendering; using Silk.NET.Input; using Silk.NET.OpenGL; namespace AcDream.App.UI; /// /// Packages the , the 2D sprite batcher /// (), and a default font so /// GameWindow can wire the retail-style UI in with one /// construction and a handful of input callbacks. /// /// Usage (from GameWindow.OnLoad): /// /// _uiHost = new UiHost(_gl, shadersDir, _debugFont); /// _uiHost.Root.WorldMouseFallThrough += (btn, x, y, f) => HandleWorldClick(btn, x, y); /// _uiHost.Root.WorldKeyFallThrough += (vk, lp) => HandleHotkey(vk); /// /// foreach (var mouse in _input.Mice) /// _uiHost.WireMouse(mouse); /// foreach (var kb in _input.Keyboards) /// _uiHost.WireKeyboard(kb); /// /// /// And per frame (from GameWindow.OnRender): /// /// _uiHost.Tick(deltaSeconds); /// _uiHost.Draw(new Vector2(_window!.Size.X, _window.Size.Y)); /// /// /// Retail analog: the trio of DAT_00870340 (Core, owns fonts/atlases), /// DAT_00837ff4 (Device, owns input state), DAT_00870c2c /// (Keystone root, widget tree). We fuse them into a single host class /// because we're not linking to Keystone. /// public sealed class UiHost : System.IDisposable { public UiRoot Root { get; } = new(); public RetailWindowManager WindowManager => Root.WindowManager; public TextRenderer TextRenderer { get; } public BitmapFont? DefaultFont { get; set; } /// The last wired keyboard. Exposed so widgets that need clipboard /// access () or modifier-key state /// () — e.g. 's /// Ctrl+C copy — can reach the device. One-keyboard desktop: last wins. public IKeyboard? Keyboard { get; private set; } private long _startTicks = System.Environment.TickCount64; private readonly HostQuiescenceGate _quiescence; private readonly List _inputBindings = new(); private ResourceShutdownTransaction? _inputShutdown; private ResourceShutdownTransaction? _shutdown; private bool _disposeRequested; private bool _disposed; internal bool IsDisposalComplete => _disposed; public UiHost(GL gl, string shaderDir, BitmapFont? defaultFont = null) : this(gl, shaderDir, defaultFont, new HostQuiescenceGate()) { } internal UiHost( GL gl, string shaderDir, BitmapFont? defaultFont, HostQuiescenceGate quiescence) { _quiescence = quiescence ?? throw new ArgumentNullException(nameof(quiescence)); TextRenderer = new TextRenderer(gl, shaderDir); DefaultFont = defaultFont; } // ── Per-frame ────────────────────────────────────────────────────── public void Tick(double deltaSeconds) { long now = System.Environment.TickCount64 - _startTicks; Root.Tick(deltaSeconds, now); } public void Draw(Vector2 screenSize) { // Set UiRoot bounds to full screen so HitTestTopDown works. Root.Width = screenSize.X; Root.Height = screenSize.Y; var ctx = new UiRenderContext(TextRenderer, screenSize, DefaultFont); TextRenderer.Begin(screenSize); Root.Draw(ctx); TextRenderer.Flush(DefaultFont); } // ── Input wiring helpers ─────────────────────────────────────────── public void WireMouse(IMouse mouse) { System.ObjectDisposedException.ThrowIf(_disposeRequested || _disposed, this); System.ArgumentNullException.ThrowIfNull(mouse); var binding = new RetainedMouseInputBinding( new SilkRetainedMouseSurface(mouse), Root, _quiescence); _inputBindings.Add(binding); try { binding.Attach(); } catch { if (binding.IsDisposalComplete) _inputBindings.Remove(binding); throw; } } public void WireKeyboard(IKeyboard kb) { System.ObjectDisposedException.ThrowIf(_disposeRequested || _disposed, this); System.ArgumentNullException.ThrowIfNull(kb); Keyboard = kb; // last wired keyboard wins (one-keyboard desktop) var binding = new RetainedKeyboardInputBinding( new SilkRetainedKeyboardSurface(kb), Root, _quiescence); _inputBindings.Add(binding); try { binding.Attach(); } catch { if (binding.IsDisposalComplete) _inputBindings.Remove(binding); throw; } } /// /// Stops retained-device delivery without retiring the window tree or GL /// renderer. Safe to call before a potentially long live-session close. /// public void QuiesceInput() { _disposeRequested = true; foreach (IRetainedUiInputBinding binding in _inputBindings) binding.Deactivate(); Keyboard = null; } /// Physically removes every retained input edge after quiescence. public void DeactivateInput() => CompleteInputShutdown(reportFailures: true); private void CompleteInputShutdown(bool reportFailures) { QuiesceInput(); _inputShutdown ??= new ResourceShutdownTransaction( new ResourceShutdownStage( "retained UI input subscriptions", _inputBindings .AsEnumerable() .Reverse() .Select((binding, index) => new ResourceShutdownOperation( $"input binding {index}", binding.Dispose, ResourceShutdownOperationPolicy.ReportAndContinue)) .ToArray())); _inputShutdown.CompleteOrThrow(); if (_inputShutdown.IsComplete && _inputShutdown.CleanupFailures.Count == 0) _inputBindings.Clear(); if (reportFailures && _inputShutdown.CleanupFailures.Count != 0) { throw new AggregateException( "Retained UI input callback cleanup completed with failures.", _inputShutdown.CleanupFailures.Select(static failure => new InvalidOperationException( $"Retained UI operation '{failure.Operation}' failed in stage '{failure.Stage}'.", failure.Error))); } } // ── Window manager forwarders (delegate to UiRoot) ───────────────── /// Register a top-level window for Show/Hide/Toggle. See . public RetailWindowHandle RegisterWindow( string name, UiElement window, UiElement? contentRoot = null, IRetainedPanelController? controller = null, IRetainedWindowStateController? stateController = null) => Root.RegisterWindow(name, window, contentRoot, controller, stateController); public bool UnregisterWindow(string name) => Root.UnregisterWindow(name); /// Show a registered window; returns false if the name is unknown. public bool ShowWindow(string name) => Root.ShowWindow(name); /// Hide a registered window; returns false if the name is unknown. public bool HideWindow(string name) => Root.HideWindow(name); public bool CloseWindow(string name) => Root.CloseWindow(name); /// Return the current visibility of a registered window. public bool IsWindowVisible(string name) => Root.IsWindowVisible(name); /// Toggle a registered window's visibility; returns the new IsVisible. public bool ToggleWindow(string name) => Root.ToggleWindow(name); public void Dispose() { if (_disposed) return; _disposeRequested = true; _shutdown ??= CreateShutdownTransaction( [() => CompleteInputShutdown(reportFailures: false)], () => { }, WindowManager.Dispose, TextRenderer.Dispose); _shutdown.CompleteOrThrow(); _disposed = _shutdown.IsComplete; } internal static ResourceShutdownTransaction CreateShutdownTransaction( IReadOnlyList inputUnsubscribers, Action releaseInputState, Action disposeWindowManager, Action disposeTextRenderer) { ArgumentNullException.ThrowIfNull(inputUnsubscribers); ArgumentNullException.ThrowIfNull(releaseInputState); ArgumentNullException.ThrowIfNull(disposeWindowManager); ArgumentNullException.ThrowIfNull(disposeTextRenderer); return new ResourceShutdownTransaction( new ResourceShutdownStage( "retained UI input subscriptions", inputUnsubscribers .Select((unsubscribe, index) => new ResourceShutdownOperation( $"input subscription {index}", unsubscribe ?? throw new ArgumentException( "Input unsubscriber entries cannot be null.", nameof(inputUnsubscribers)))) .ToArray()), new ResourceShutdownStage("retained UI input state", [ new("input state", releaseInputState), ]), new ResourceShutdownStage("retained UI windows", [ new("window manager", disposeWindowManager), ]), new ResourceShutdownStage("retained UI renderer", [ new("text renderer", disposeTextRenderer), ])); } }