using S3.Core;
using S3.Core.Ui;
using UI.Common;
using UI.Map;
using UnityEngine;
using UnityModManagerNet;
namespace S3.Modules.Popout;
///
/// S³ module that detaches the in-game map into a resizable native OS window
/// (rendered by the shared Win32 + D3D11 + Dear ImGui native engine).
///
/// Loads the native S3Native.dll on enable, then spawns a
/// MonoBehaviour that drives the per-frame map button, hotkey, and panel lifecycle.
///
public sealed class PopoutModule : IModule
{
private const string SettingsFile = "S3.popout.json";
public static PopoutSettings Settings { get; private set; } = new();
// Persistent KeyBinding instance (not rebuilt per frame) so .Down() edge detection works.
private static KeyBinding _hotkey = new();
public static KeyBinding Hotkey => _hotkey;
private static DetachedPanel? _activePanel;
private static Window? _hiddenWindow;
private static Vector3 _savedWindowScale;
private static bool _mapWasOpen;
private static GameObject? _host;
// Delayed-open state: after the in-game overlay closes we wait before handing the
// camera to DetachedPanel, giving Unity a full half-second to tear down the RT.
private static bool _pendingOpen;
private static float _pendingTimer;
// True when the in-game overlay was active at the moment the popout was triggered.
// Restored when the popout closes so the user gets their overlay back automatically.
private static bool _inGameWasOpen;
// Short delay before reopening the overlay after a popout closes.
// Gives Unity time to finish its end-of-frame Destroy for the popout's RT so the
// D3D address is fully gone before UiHost.ActivateMap allocates a new one.
private static bool _pendingOverlayOpen;
private static float _pendingOverlayTimer;
public static bool IsDetached => _activePanel != null;
public PopoutModule()
{
Settings = SettingsStore.Load(SettingsFile);
RebuildHotkey();
}
public string Id => "popout";
public string DisplayName => "Map Popout";
public string Description =>
"Detaches the in-game map into a separate resizable window you can put on a " +
"second monitor. Open a save, then press the hotkey or use the Pop Out button " +
"on the map window. Optional MapEnhancer integration for follow modes.";
public bool Enabled
{
get => Settings.enabled;
set => Settings.enabled = value;
}
public void OnEnable()
{
if (!NativeLoader.EnsureLoaded())
{
Log.Error("[popout] native load failed — module will not run this session.");
return;
}
_host = new GameObject("S3.Popout.Host");
Object.DontDestroyOnLoad(_host);
_host.AddComponent();
}
public void OnDisable()
{
// Reserved for live toggling. When wired, destroy the active panel + host here.
if (_activePanel != null) CloseActivePanel("[popout] Module disabled.");
_pendingOpen = false;
_pendingOverlayOpen = false;
if (_host != null) { Object.Destroy(_host); _host = null; }
}
public void SaveSettings() => Persist();
internal static void Persist() => SettingsStore.Save(SettingsFile, Settings);
public void DrawSettings() => PopoutSettingsUI.Draw();
internal static void RebuildHotkey() =>
_hotkey = new KeyBinding { modifiers = (byte)Settings.hotkeyModifiers, keyCode = (KeyCode)Settings.hotkeyKeyCode };
// Called by UiService (Pop Out toolbar button or F10) to transition the map from
// the in-game overlay to the OS popout window. Captures the overlay state BEFORE
// closing so the overlay can be restored when the popout is later dismissed.
internal static void ScheduleExternalLaunch()
{
if (_activePanel != null || _pendingOpen) return; // already detached or pending
if (_host == null)
{
if (!NativeLoader.EnsureLoaded()) return;
_host = new GameObject("S3.Popout.Host");
Object.DontDestroyOnLoad(_host);
_host.AddComponent();
}
_inGameWasOpen = UiService.IsOverlayVisible; // capture before closing
UiService.CloseOverlay(); // release camera + RT now
_pendingOpen = true;
_pendingTimer = 0.5f;
}
// Called each frame by PopoutHost.
internal static void Tick()
{
MapWindowButton.TryInstall();
MapWindowButton.UpdateLabel(_activePanel != null);
// Delayed open: wait for the overlay's RT to fully tear down before DetachedPanel
// grabs the camera.
if (_pendingOpen)
{
_pendingTimer -= UnityEngine.Time.deltaTime;
if (_pendingTimer <= 0f)
{
_pendingOpen = false;
DoOpenPopout();
}
}
// Delayed overlay restore: wait one frame for the popout's RT Destroy to process
// at end-of-frame before UiHost.ActivateMap allocates a new RT.
if (_pendingOverlayOpen)
{
_pendingOverlayTimer -= UnityEngine.Time.deltaTime;
if (_pendingOverlayTimer <= 0f)
{
_pendingOverlayOpen = false;
UiService.OpenOverlay();
}
}
if (_hotkey.Down())
Toggle();
_activePanel?.Update();
if (_activePanel != null && (!_activePanel.IsAlive || _activePanel.CloseRequested))
{
CloseActivePanel("[popout] Map popout closed.");
}
}
internal static void Toggle()
{
if (_activePanel != null)
{
CloseActivePanel("[popout] Map re-attached.");
return;
}
// If the in-game overlay owns the camera, schedule a delayed open so the RT
// has time to fully tear down before DetachedPanel grabs the camera.
if (UiService.IsOverlayVisible)
{
_inGameWasOpen = true;
UiService.CloseOverlay();
_pendingOpen = true;
_pendingTimer = 0.5f;
return;
}
DoOpenPopout();
}
// Closes the active popout panel and optionally restores the in-game overlay.
private static void CloseActivePanel(string logMsg)
{
_activePanel!.Destroy();
_activePanel = null;
bool wasInGame = _inGameWasOpen;
_inGameWasOpen = false;
RestoreInGameWindow();
if (wasInGame)
{
// Delay overlay reopen by ~0.1 s so Unity's end-of-frame Destroy for the
// popout's RT finishes before UiHost.ActivateMap creates a new one. Without
// this, the pool can reissue the same D3D address and the deferred Destroy
// then invalidates the brand-new RT from under ActivateMap.
_pendingOverlayOpen = true;
_pendingOverlayTimer = 0.1f;
}
Log.Info(logMsg);
}
// Creates the DetachedPanel after the camera is free.
private static void DoOpenPopout()
{
// Remember whether the map was already open so we can close it again on teardown.
Window? win = PanelFinder.GetMapWindowUI();
_mapWasOpen = win != null && win.IsShown;
UiService.MapBypass = true;
MapWindow.Show();
UiService.MapBypass = false;
if (!PanelFinder.IsMapReady())
{
Log.Warn("[popout] Map not ready - ensure a save is loaded.");
return;
}
// Scale the in-game panel to zero (invisible, all components active). Using
// localScale (not sizeDelta) also scales away the corner-anchored chrome
// and restores reliably to (1,1,1).
_hiddenWindow = PanelFinder.GetMapWindowUI();
if (_hiddenWindow != null && _hiddenWindow.transform is RectTransform rect)
{
_savedWindowScale = rect.localScale;
rect.localScale = Vector3.zero;
}
_activePanel = new DetachedPanel("Railroader Map");
if (!_activePanel.IsAlive)
{
Log.Error("[popout] RRPOPOUT_CreateWindow returned 0.");
_activePanel = null;
RestoreInGameWindow();
return;
}
Log.Info("[popout] Map detached.");
}
private static void RestoreInGameWindow()
{
if (_hiddenWindow == null) return;
if (!_mapWasOpen)
{
// Close the window while localScale is still zero (invisible) to prevent a
// 1-frame flash. Toggle() is used instead of SetActive(false) so MapWindow
// remains findable by FindObjectOfType on the next open.
UiService.MapBypass = true;
MapWindow.Toggle();
UiService.MapBypass = false;
}
// Always restore scale so the next map open starts from normal state.
if (_hiddenWindow.transform is RectTransform rect)
rect.localScale = _savedWindowScale;
_hiddenWindow = null;
}
}