feat(window): recover off-screen position after display layout change
Persisted ImGui window position can end up off-screen when the user disconnects a monitor or changes display resolution between sessions. The chat log window then renders outside the visible viewport with no drag handles available, and the only recovery path is editing the JSON config by hand. This commit adds two layers of safety: - Automatic one-shot bounds check on the first draw after plugin load. If less than 100x40 pixels of the saved window position overlap the primary viewport, the window snaps to a safe default offset (top-left + 50px). Logged at INF level so users can verify the recovery happened. - Manual "Reset Window Position" button in Settings -> Window -> Frame as a deliberate escape hatch when anything else slips past the automatic check (different DPI scaling, viewport edge cases). Pop-outs are intentionally not part of this recovery path: they are non-persistent (cleared on plugin reload) and therefore cannot survive a session boundary in an off-screen state. Tested on Linux/Wayland (KAZAMA, Plasma, 3-monitor setup): hard-cut test with both auxiliary monitors physically disconnected between sessions reproduces the off-screen window before the patch and recovers cleanly with this fix in place.
This commit is contained in:
@@ -261,6 +261,10 @@ internal class HellionStrings
|
||||
internal static string Settings_Window_PopOutInputEnabled_Name => Get(nameof(Settings_Window_PopOutInputEnabled_Name));
|
||||
internal static string Settings_Window_PopOutInputEnabled_Description => Get(nameof(Settings_Window_PopOutInputEnabled_Description));
|
||||
|
||||
// Hellion Chat — Window position recovery (off-screen safety net)
|
||||
internal static string Settings_Window_ResetPosition_Name => Get(nameof(Settings_Window_ResetPosition_Name));
|
||||
internal static string Settings_Window_ResetPosition_Description => Get(nameof(Settings_Window_ResetPosition_Description));
|
||||
|
||||
// Hellion Chat — v0.6.0 one-time hint banner shown inside pop-outs
|
||||
internal static string Popout_v060_HintText => Get(nameof(Popout_v060_HintText));
|
||||
internal static string Popout_v060_HintAck => Get(nameof(Popout_v060_HintAck));
|
||||
|
||||
@@ -591,6 +591,12 @@
|
||||
<data name="Settings_Window_PopOutInputEnabled_Description" xml:space="preserve">
|
||||
<value>Master-Switch: erlaubt direktes Tippen und Absenden in jedem Pop-Out-Fenster (inkl. Auto-Tell-Tabs). Channel-Wechsel im Pop-Out wirkt global wie im Hauptfenster; Text-Buffer und History-Cursor sind pro Pop-Out unabhängig.</value>
|
||||
</data>
|
||||
<data name="Settings_Window_ResetPosition_Name" xml:space="preserve">
|
||||
<value>Fenster-Position zurücksetzen</value>
|
||||
</data>
|
||||
<data name="Settings_Window_ResetPosition_Description" xml:space="preserve">
|
||||
<value>Holt das Chat-Fenster und alle aktiven Pop-Outs zurück in die linke obere Ecke des Hauptmonitors. Hilfreich wenn ein Fenster nach einem Display-Layout-Wechsel außerhalb des sichtbaren Bereichs gelandet ist (Monitor abgezogen, Auflösung geändert). Das Plugin macht außerdem einmal pro Session einen automatischen Bounds-Check, dieser Button ist der manuelle Notausgang falls trotzdem etwas unerreichbar bleibt.</value>
|
||||
</data>
|
||||
<data name="Popout_v060_HintText" xml:space="preserve">
|
||||
<value>Neu in v0.6.0: Du kannst jetzt direkt im Pop-Out tippen. Master-Switch in den Fenster-Settings aktivieren.</value>
|
||||
</data>
|
||||
|
||||
@@ -591,6 +591,12 @@
|
||||
<data name="Settings_Window_PopOutInputEnabled_Description" xml:space="preserve">
|
||||
<value>Master switch: lets you type and send messages directly inside every pop-out window (including auto-tell tabs). Channel changes inside a pop-out apply globally just like in the main window; the text buffer and history cursor stay independent per pop-out.</value>
|
||||
</data>
|
||||
<data name="Settings_Window_ResetPosition_Name" xml:space="preserve">
|
||||
<value>Reset Window Position</value>
|
||||
</data>
|
||||
<data name="Settings_Window_ResetPosition_Description" xml:space="preserve">
|
||||
<value>Snaps the chat window and every active pop-out back to the primary monitor's top-left corner. Useful when a window has drifted off-screen after a display layout change (monitor disconnected, resolution changed). The plugin also runs an automatic bounds check once per session — this button is the manual backup if anything still ends up unreachable.</value>
|
||||
</data>
|
||||
<data name="Popout_v060_HintText" xml:space="preserve">
|
||||
<value>New in v0.6.0: you can type directly inside pop-out windows. Toggle the master switch in the window settings to enable it.</value>
|
||||
</data>
|
||||
|
||||
@@ -66,6 +66,14 @@ public sealed class ChatLogWindow : Window
|
||||
public Vector2 LastWindowPos { get; private set; } = Vector2.Zero;
|
||||
public Vector2 LastWindowSize { get; private set; } = Vector2.Zero;
|
||||
|
||||
// Window position recovery: guards against off-screen positions after a
|
||||
// display layout change (monitor disconnected, resolution changed). On
|
||||
// the first draw after plugin load we run a one-shot bounds check to see
|
||||
// whether the stored position still overlaps any visible viewport area.
|
||||
// The manual reset button in the settings forces the position regardless.
|
||||
private bool DidOnLoadBoundsCheck;
|
||||
internal bool RequestPositionReset { get; set; }
|
||||
|
||||
public unsafe ImGuiViewport* LastViewport;
|
||||
private bool WasDocked;
|
||||
|
||||
@@ -542,6 +550,22 @@ public sealed class ChatLogWindow : Window
|
||||
LastWindowSize = currentSize;
|
||||
LastWindowPos = ImGui.GetWindowPos();
|
||||
|
||||
// Window position recovery. Manual reset takes precedence and snaps
|
||||
// the window to the safe default unconditionally; the one-shot
|
||||
// on-load check only fires when the persisted position has no
|
||||
// overlap with any visible viewport area.
|
||||
if (RequestPositionReset)
|
||||
{
|
||||
RequestPositionReset = false;
|
||||
DidOnLoadBoundsCheck = true;
|
||||
ApplySafeDefaultPosition("manual-reset");
|
||||
}
|
||||
else if (!DidOnLoadBoundsCheck)
|
||||
{
|
||||
DidOnLoadBoundsCheck = true;
|
||||
EnsureWindowOnScreen("on-load");
|
||||
}
|
||||
|
||||
if (resized)
|
||||
LastResize.Restart();
|
||||
|
||||
@@ -2035,4 +2059,47 @@ public sealed class ChatLogWindow : Window
|
||||
var hashCode = $"{Salt}{playerName}{worldId}".GetHashCode();
|
||||
return $"Player {hashCode:X8}";
|
||||
}
|
||||
|
||||
// Snap threshold in pixels: at least this much of the window must overlap
|
||||
// a visible viewport so the user can still grab the first tab header.
|
||||
// Below the threshold the window is considered off-screen.
|
||||
private const int OnScreenMinOverlapX = 100;
|
||||
private const int OnScreenMinOverlapY = 40;
|
||||
|
||||
// Default snap position relative to the primary viewport (top-left with a
|
||||
// safety margin from the game title bar).
|
||||
private static readonly Vector2 SafeDefaultOffset = new(50, 50);
|
||||
|
||||
private void EnsureWindowOnScreen(string source)
|
||||
{
|
||||
if (LastWindowSize.X < 1 || LastWindowSize.Y < 1)
|
||||
return;
|
||||
|
||||
var viewport = ImGui.GetMainViewport();
|
||||
var visibleMin = viewport.WorkPos;
|
||||
var visibleMax = viewport.WorkPos + viewport.WorkSize;
|
||||
|
||||
var overlapMin = Vector2.Max(LastWindowPos, visibleMin);
|
||||
var overlapMax = Vector2.Min(LastWindowPos + LastWindowSize, visibleMax);
|
||||
var overlap = overlapMax - overlapMin;
|
||||
|
||||
if (overlap.X >= OnScreenMinOverlapX && overlap.Y >= OnScreenMinOverlapY)
|
||||
return;
|
||||
|
||||
ApplySafeDefaultPosition(source);
|
||||
}
|
||||
|
||||
private void ApplySafeDefaultPosition(string source)
|
||||
{
|
||||
var viewport = ImGui.GetMainViewport();
|
||||
var safePos = viewport.WorkPos + SafeDefaultOffset;
|
||||
Position = safePos;
|
||||
Plugin.Log.Info(
|
||||
$"[Window-Recovery] {source}: snapping main window from {LastWindowPos} (size {LastWindowSize}) to {safePos}.");
|
||||
|
||||
// Pop-outs are intentionally non-persistent (cleared on plugin reload),
|
||||
// so an off-screen pop-out can never survive a session boundary. The
|
||||
// main window above is the only persistence target that needs an
|
||||
// explicit recovery path.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,6 +142,15 @@ internal sealed class Window : ISettingsTab
|
||||
|
||||
ImGui.Checkbox(Language.Options_SidebarTabView_Name, ref Mutable.SidebarTabView);
|
||||
ImGuiUtil.HelpMarker(string.Format(Language.Options_SidebarTabView_Description, Plugin.PluginName));
|
||||
|
||||
ImGui.Spacing();
|
||||
|
||||
// Manual escape hatch for off-screen windows. The plugin already
|
||||
// runs an automatic bounds check once per session, but a button
|
||||
// is the user-friendly fallback after a display layout change.
|
||||
if (ImGui.Button(HellionStrings.Settings_Window_ResetPosition_Name))
|
||||
Plugin.ChatLogWindow.RequestPositionReset = true;
|
||||
ImGuiUtil.HelpMarker(HellionStrings.Settings_Window_ResetPosition_Description);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user