From 5ac0d0a4061078d944f5608f1286c149c8bb20de Mon Sep 17 00:00:00 2001 From: Kevin Dethlefs Date: Fri, 9 Oct 2026 00:17:04 -0500 Subject: [PATCH] Overlay: fixed-size, non-activating, shown only while Elite is in front (#204) The overlay was a topmost, self-resizing window over every app: each jump resized it, it was never marked non-activating, and it sat over EDCopilot permanently. It now has a fixed window size, updates only when the snapshot changed, is WS_EX_NOACTIVATE, and by default is visible only while Elite Dangerous (or EDNexus) is the foreground app (setting Overlay.OnlyWhenGameFocused, on by default). Co-Authored-By: Claude Sonnet 5.5 --- src/EDNexus.App/Bootstrap.cs | 14 ++ .../Services/Overlay/WindowsOverlay.cs | 134 ++++++++++++++++-- src/EDNexus.App/Views/OverlayWindow.axaml | 10 +- src/EDNexus.App/Views/SettingsWindow.axaml | 6 + src/EDNexus.App/Views/SettingsWindow.axaml.cs | 2 + src/EDNexus.Core/Overlay/IOverlay.cs | 6 + src/EDNexus.Core/Overlay/NullOverlay.cs | 1 + src/EDNexus.Core/Overlay/OverlayModels.cs | 21 +++ .../Overlay/OverlayVisibilityPolicy.cs | 42 ++++++ src/EDNexus.Core/Settings/AppSettings.cs | 7 + .../Overlay/OverlayVisibilityTests.cs | 85 +++++++++++ 11 files changed, 315 insertions(+), 13 deletions(-) create mode 100644 src/EDNexus.Core/Overlay/OverlayVisibilityPolicy.cs create mode 100644 tests/EDNexus.Tests/Overlay/OverlayVisibilityTests.cs diff --git a/src/EDNexus.App/Bootstrap.cs b/src/EDNexus.App/Bootstrap.cs index eeaafab..4da2007 100644 --- a/src/EDNexus.App/Bootstrap.cs +++ b/src/EDNexus.App/Bootstrap.cs @@ -46,6 +46,9 @@ public Bootstrap(SettingsStore store, AppSettings settings, CrashReporting crash TwitchCleanup.Start(); Twitch = BuildTwitchAuth(); + // The overlay's visibility rule must be in force before it is first shown. + Overlay.SetOnlyWhenGameFocused(Settings.Overlay.OnlyWhenGameFocused); + // Apply the saved voice choice up front so the very first callout already uses it. Voice.SetVoice(Settings.Voice.VoiceName); Voice.SetVolume(Settings.Voice.Volume); @@ -219,6 +222,17 @@ public void ApplyMiningSpotAnnouncements(bool enabled) Store.Save(Settings); } + /// + /// Persist whether the overlay is shown only while Elite Dangerous (or EDNexus) is in front, and apply it + /// to the live window. + /// + public void ApplyOverlayOnlyWhenGameFocused(bool enabled) + { + Settings.Overlay.OnlyWhenGameFocused = enabled; + Store.Save(Settings); + Overlay.SetOnlyWhenGameFocused(enabled); + } + /// Persist the overlay's on/off state and show/hide the live window to match. public void ApplyOverlayChoice(bool enabled) { diff --git a/src/EDNexus.App/Services/Overlay/WindowsOverlay.cs b/src/EDNexus.App/Services/Overlay/WindowsOverlay.cs index 4216f6f..9587772 100644 --- a/src/EDNexus.App/Services/Overlay/WindowsOverlay.cs +++ b/src/EDNexus.App/Services/Overlay/WindowsOverlay.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using System.Runtime.InteropServices; using Avalonia.Controls; using Avalonia.Threading; @@ -8,14 +9,32 @@ namespace EDNexus.App.Services.Overlay; /// /// Windows overlay: a transparent, click-through, always-on-top Avalonia window drawn over Elite -/// Dangerous in borderless/windowed mode. Click-through is applied via the Win32 extended window -/// styles once the native handle exists, so the game keeps input focus and the overlay never -/// intercepts a click meant for the cockpit. All calls are best-effort: a failure here degrades to +/// Dangerous in borderless/windowed mode. Click-through and "never take focus" are applied via the Win32 +/// extended window styles once the native handle exists, so the game keeps input focus and the overlay +/// never intercepts a click meant for the cockpit. All calls are best-effort: a failure here degrades to /// "no overlay shown" rather than crashing the app. /// +/// +/// Because it is a topmost window, an overlay that is always up sits over every other app (EDCopilot's own +/// overlay included), and anything it does to its own window (a resize, a z-order change) is a desktop +/// event the game and other overlays can react to. So: the window has a fixed size (the panel grows inside +/// it, the native window never resizes on a jump); content is pushed only when it actually changed; the +/// window can never be activated; and by default it is visible only while Elite Dangerous is in front +/// (). +/// public sealed class WindowsOverlay : IOverlay { + private static readonly TimeSpan FocusPollInterval = TimeSpan.FromMilliseconds(750); + + // All of the following are touched on the UI thread only. private OverlayWindow? _window; + private DispatcherTimer? _focusTimer; + private bool _wanted; + private bool _onlyWhenGameFocused = true; + private OverlayContent? _lastContent; + private int _lastForegroundPid; + private string? _lastForegroundName; + private readonly string _ownProcess = Process.GetCurrentProcess().ProcessName; public bool IsSupported => RuntimeInformation.IsOSPlatform(OSPlatform.Windows); public bool IsVisible => _window?.IsVisible == true; @@ -27,12 +46,18 @@ public void Show() { try { + _wanted = true; if (_window is null) { _window = new OverlayWindow(); _window.Opened += (_, _) => MakeClickThrough(_window); + // It is created non-activating and styled WS_EX_NOACTIVATE, so this should never fire. If it + // does, that is exactly the focus problem this window must not cause: leave a trace. + _window.Activated += (_, _) => Trace.TraceWarning("Overlay: the overlay window was activated (it should never take focus)."); } - _window.Show(); + + StartFocusTimer(); + ApplyVisibility(); } catch { @@ -46,28 +71,111 @@ public void Hide() if (!IsSupported) return; Dispatcher.UIThread.Post(() => { - try { _window?.Hide(); } + try + { + _wanted = false; + _focusTimer?.Stop(); + ApplyVisibility(); + } catch { /* best-effort */ } }); } public void Update(OverlayContent content) { - if (!IsSupported || _window is null) return; + if (!IsSupported) return; Dispatcher.UIThread.Post(() => { - try { _window?.UpdateContent(content); } + try + { + if (_window is null) return; + + // Called every UI tick (250 ms) with a freshly built snapshot. Writing identical text back + // and rebuilding the shortfall list each time churns layout for nothing; touch the window + // only when something changed. + if (_lastContent is not null && _lastContent.SameAs(content)) return; + _lastContent = content; + _window.UpdateContent(content); + } catch { /* best-effort */ } }); } - // --- Win32: WS_EX_LAYERED | WS_EX_TRANSPARENT makes the window click-through, and - // WS_EX_TOOLWINDOW keeps it out of the taskbar / alt-tab list alongside ShowInTaskbar="False". --- + public void SetOnlyWhenGameFocused(bool value) + { + if (!IsSupported) return; + Dispatcher.UIThread.Post(() => + { + try + { + _onlyWhenGameFocused = value; + ApplyVisibility(); + } + catch { /* best-effort */ } + }); + } + + private void StartFocusTimer() + { + _focusTimer ??= new DispatcherTimer { Interval = FocusPollInterval }; + _focusTimer.Tick -= OnFocusTick; + _focusTimer.Tick += OnFocusTick; + _focusTimer.Start(); + } + + private void OnFocusTick(object? sender, EventArgs e) + { + try { ApplyVisibility(); } + catch { /* best-effort */ } + } + + /// Shows or hides the window to match the policy. Does nothing when the state is already right. + private void ApplyVisibility() + { + var window = _window; + if (window is null) return; + + var foreground = _wanted && _onlyWhenGameFocused ? ForegroundProcessName() : null; + var show = OverlayVisibilityPolicy.ShouldShow(_wanted, _onlyWhenGameFocused, foreground, _ownProcess); + if (show == window.IsVisible) return; + + if (show) window.Show(); + else window.Hide(); + Trace.TraceInformation($"Overlay: {(show ? "shown" : "hidden")} (foreground: {foreground ?? "n/a"})."); + } + + /// The process that owns the foreground window, or null when it cannot be determined. + private string? ForegroundProcessName() + { + try + { + var hwnd = GetForegroundWindow(); + if (hwnd == IntPtr.Zero) return null; + + GetWindowThreadProcessId(hwnd, out var pid); + if (pid == 0) return null; + if ((int)pid == _lastForegroundPid) return _lastForegroundName; + + using var process = Process.GetProcessById((int)pid); + _lastForegroundPid = (int)pid; + _lastForegroundName = process.ProcessName; + return _lastForegroundName; + } + catch + { + return null; + } + } + + // --- Win32: WS_EX_LAYERED | WS_EX_TRANSPARENT makes the window click-through, WS_EX_TOOLWINDOW keeps it + // out of the taskbar / alt-tab list alongside ShowInTaskbar="False", and WS_EX_NOACTIVATE guarantees it + // can never become the active window (ShowActivated="False" only covers the first show). --- private const int GwlExStyle = -20; private const int WsExLayered = 0x80000; private const int WsExTransparent = 0x20; private const int WsExToolWindow = 0x80; + private const int WsExNoActivate = 0x08000000; private static void MakeClickThrough(Window window) { @@ -76,7 +184,7 @@ private static void MakeClickThrough(Window window) var handle = window.TryGetPlatformHandle()?.Handle ?? IntPtr.Zero; if (handle == IntPtr.Zero) return; var style = GetWindowLong(handle, GwlExStyle); - SetWindowLong(handle, GwlExStyle, style | WsExLayered | WsExTransparent | WsExToolWindow); + SetWindowLong(handle, GwlExStyle, style | WsExLayered | WsExTransparent | WsExToolWindow | WsExNoActivate); } catch { @@ -89,4 +197,10 @@ private static void MakeClickThrough(Window window) [DllImport("user32.dll", SetLastError = true)] private static extern int SetWindowLong(IntPtr hWnd, int nIndex, int dwNewLong); + + [DllImport("user32.dll")] + private static extern IntPtr GetForegroundWindow(); + + [DllImport("user32.dll", SetLastError = true)] + private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId); } diff --git a/src/EDNexus.App/Views/OverlayWindow.axaml b/src/EDNexus.App/Views/OverlayWindow.axaml index 6c82955..590ae2d 100644 --- a/src/EDNexus.App/Views/OverlayWindow.axaml +++ b/src/EDNexus.App/Views/OverlayWindow.axaml @@ -10,12 +10,16 @@ CanResize="False" Topmost="True" WindowStartupLocation="Manual" - SizeToContent="WidthAndHeight" + Width="360" Height="330" Focusable="False"> + exists, so the game underneath always keeps mouse/keyboard focus. + The window has a FIXED size on purpose: it used to size itself to its content, so every jump (a new + system name, the "next jump" line appearing, a changed shortfall list) was a native resize of a + topmost window over the game, which the game and other overlays can react to. The visible panel is + the Border below, which grows and shrinks inside the transparent, click-through window instead. --> + MinWidth="230" HorizontalAlignment="Left" VerticalAlignment="Top"> diff --git a/src/EDNexus.App/Views/SettingsWindow.axaml b/src/EDNexus.App/Views/SettingsWindow.axaml index 665fc3d..a943295 100644 --- a/src/EDNexus.App/Views/SettingsWindow.axaml +++ b/src/EDNexus.App/Views/SettingsWindow.axaml @@ -179,6 +179,10 @@ + + + @@ -198,6 +202,8 @@ +