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:
2026-05-04 12:01:43 +02:00
parent 176474ec2a
commit 7012e8c0d8
5 changed files with 92 additions and 0 deletions
+4
View File
@@ -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_Name => Get(nameof(Settings_Window_PopOutInputEnabled_Name));
internal static string Settings_Window_PopOutInputEnabled_Description => Get(nameof(Settings_Window_PopOutInputEnabled_Description)); 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 // 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_HintText => Get(nameof(Popout_v060_HintText));
internal static string Popout_v060_HintAck => Get(nameof(Popout_v060_HintAck)); internal static string Popout_v060_HintAck => Get(nameof(Popout_v060_HintAck));
@@ -591,6 +591,12 @@
<data name="Settings_Window_PopOutInputEnabled_Description" xml:space="preserve"> <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> <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>
<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"> <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> <value>Neu in v0.6.0: Du kannst jetzt direkt im Pop-Out tippen. Master-Switch in den Fenster-Settings aktivieren.</value>
</data> </data>
@@ -591,6 +591,12 @@
<data name="Settings_Window_PopOutInputEnabled_Description" xml:space="preserve"> <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> <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>
<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"> <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> <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> </data>
+67
View File
@@ -66,6 +66,14 @@ public sealed class ChatLogWindow : Window
public Vector2 LastWindowPos { get; private set; } = Vector2.Zero; public Vector2 LastWindowPos { get; private set; } = Vector2.Zero;
public Vector2 LastWindowSize { 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; public unsafe ImGuiViewport* LastViewport;
private bool WasDocked; private bool WasDocked;
@@ -542,6 +550,22 @@ public sealed class ChatLogWindow : Window
LastWindowSize = currentSize; LastWindowSize = currentSize;
LastWindowPos = ImGui.GetWindowPos(); 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) if (resized)
LastResize.Restart(); LastResize.Restart();
@@ -2035,4 +2059,47 @@ public sealed class ChatLogWindow : Window
var hashCode = $"{Salt}{playerName}{worldId}".GetHashCode(); var hashCode = $"{Salt}{playerName}{worldId}".GetHashCode();
return $"Player {hashCode:X8}"; 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.
}
} }
+9
View File
@@ -142,6 +142,15 @@ internal sealed class Window : ISettingsTab
ImGui.Checkbox(Language.Options_SidebarTabView_Name, ref Mutable.SidebarTabView); ImGui.Checkbox(Language.Options_SidebarTabView_Name, ref Mutable.SidebarTabView);
ImGuiUtil.HelpMarker(string.Format(Language.Options_SidebarTabView_Description, Plugin.PluginName)); 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);
} }
} }