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 @@
+
diff --git a/src/EDNexus.App/Views/SettingsWindow.axaml.cs b/src/EDNexus.App/Views/SettingsWindow.axaml.cs
index 91043e2..a3713f8 100644
--- a/src/EDNexus.App/Views/SettingsWindow.axaml.cs
+++ b/src/EDNexus.App/Views/SettingsWindow.axaml.cs
@@ -69,6 +69,7 @@ public SettingsWindow(Bootstrap boot, ViewModels.MainWindowViewModel? dashboard
MiningSpotsSummary.Text += $" {boot.Settings.Mining.KnownSpots.Count:N0} spots recorded so far.";
OverlayToggle.IsChecked = boot.Settings.Overlay.Enabled;
+ OverlayGameOnlyToggle.IsChecked = boot.Settings.Overlay.OnlyWhenGameFocused;
VoiceToggle.IsChecked = boot.Settings.Voice.Enabled;
VoiceNameCombo.ItemsSource = boot.Voice.AvailableVoices;
@@ -252,6 +253,7 @@ private async Task ApplyAsync(int miningThreshold)
_boot.ApplyShareDeliveries(RavenLookupToggle.IsChecked == true && ShareDeliveriesToggle.IsChecked == true);
_boot.ApplyMiningThreshold(miningThreshold);
_boot.ApplyMiningSpotAnnouncements(MiningSpotAnnounceToggle.IsChecked == true);
+ _boot.ApplyOverlayOnlyWhenGameFocused(OverlayGameOnlyToggle.IsChecked == true);
_boot.ApplyOverlayChoice(OverlayToggle.IsChecked == true);
_boot.ApplyVoiceChoice(
VoiceToggle.IsChecked == true,
diff --git a/src/EDNexus.Core/Overlay/IOverlay.cs b/src/EDNexus.Core/Overlay/IOverlay.cs
index d213316..be6916c 100644
--- a/src/EDNexus.Core/Overlay/IOverlay.cs
+++ b/src/EDNexus.Core/Overlay/IOverlay.cs
@@ -23,4 +23,10 @@ public interface IOverlay
/// Push a fresh content snapshot to the overlay. A no-op while hidden or unsupported.
void Update(OverlayContent content);
+
+ ///
+ /// Whether the overlay should be on screen only while Elite Dangerous (or EDNexus) is the app in front
+ /// (see ). Takes effect immediately; a no-op when unsupported.
+ ///
+ void SetOnlyWhenGameFocused(bool value);
}
diff --git a/src/EDNexus.Core/Overlay/NullOverlay.cs b/src/EDNexus.Core/Overlay/NullOverlay.cs
index 4125ddd..cd77b3c 100644
--- a/src/EDNexus.Core/Overlay/NullOverlay.cs
+++ b/src/EDNexus.Core/Overlay/NullOverlay.cs
@@ -11,4 +11,5 @@ public sealed class NullOverlay : IOverlay
public void Show() { }
public void Hide() { }
public void Update(OverlayContent content) { }
+ public void SetOnlyWhenGameFocused(bool value) { }
}
diff --git a/src/EDNexus.Core/Overlay/OverlayModels.cs b/src/EDNexus.Core/Overlay/OverlayModels.cs
index 8fe99fe..9eee7f8 100644
--- a/src/EDNexus.Core/Overlay/OverlayModels.cs
+++ b/src/EDNexus.Core/Overlay/OverlayModels.cs
@@ -47,4 +47,25 @@ public sealed record OverlayContent(
/// True when there is an active construction site with at least one outstanding commodity.
public bool HasColonisationShortfall => ColonisationShortfalls.Count > 0;
+
+ ///
+ /// Whether would draw exactly the same overlay: every field equal, with the
+ /// shortfall lines compared by value. The built-in record equality compares that list by reference,
+ /// and a fresh list is built every tick, so it cannot tell an unchanged snapshot from a changed one.
+ /// The overlay uses this to skip redundant updates, so an idle overlay touches nothing.
+ ///
+ public bool SameAs(OverlayContent? other)
+ {
+ if (ReferenceEquals(this, other)) return true;
+ if (other is null) return false;
+
+ return StarSystem == other.StarSystem
+ && NextJumpSystem == other.NextJumpSystem
+ && FuelMain.Equals(other.FuelMain)
+ && FuelCapacity.Equals(other.FuelCapacity)
+ && BioSignalBody == other.BioSignalBody
+ && BioSignalCount == other.BioSignalCount
+ && BioSignalDetail == other.BioSignalDetail
+ && ColonisationShortfalls.SequenceEqual(other.ColonisationShortfalls);
+ }
}
diff --git a/src/EDNexus.Core/Overlay/OverlayVisibilityPolicy.cs b/src/EDNexus.Core/Overlay/OverlayVisibilityPolicy.cs
new file mode 100644
index 0000000..ab02be3
--- /dev/null
+++ b/src/EDNexus.Core/Overlay/OverlayVisibilityPolicy.cs
@@ -0,0 +1,42 @@
+namespace EDNexus.Core.Overlay;
+
+///
+/// When the overlay should actually be on screen. The overlay is a topmost window, so left on all the
+/// time it sits over every other app, including other overlays such as EDCopilot's. By default it is
+/// shown only while Elite Dangerous (or EDNexus itself, so the Settings preview works) is the app in
+/// front. Pure so the rule can be tested without a window.
+///
+public static class OverlayVisibilityPolicy
+{
+ /// Process names (without .exe) of the game client.
+ public static readonly IReadOnlyList GameProcessNames = ["EliteDangerous64", "EliteDangerous"];
+
+ /// Whether the overlay window should be visible right now.
+ /// The commander has the overlay switched on.
+ /// The "only while Elite is in front" setting.
+ ///
+ /// Name of the process that owns the foreground window, or null when it cannot be determined. Unknown
+ /// fails open (visible), so a detection problem never leaves the overlay permanently hidden.
+ ///
+ /// EDNexus's own process name.
+ public static bool ShouldShow(bool wanted, bool onlyWhenGameFocused, string? foregroundProcess, string? ownProcess)
+ {
+ if (!wanted) return false;
+ if (!onlyWhenGameFocused) return true;
+ if (string.IsNullOrWhiteSpace(foregroundProcess)) return true;
+
+ var name = Normalise(foregroundProcess);
+ if (ownProcess is not null && string.Equals(name, Normalise(ownProcess), StringComparison.OrdinalIgnoreCase))
+ return true;
+
+ foreach (var game in GameProcessNames)
+ {
+ if (string.Equals(name, game, StringComparison.OrdinalIgnoreCase)) return true;
+ }
+
+ return false;
+ }
+
+ private static string Normalise(string processName)
+ => processName.EndsWith(".exe", StringComparison.OrdinalIgnoreCase) ? processName[..^4] : processName;
+}
diff --git a/src/EDNexus.Core/Settings/AppSettings.cs b/src/EDNexus.Core/Settings/AppSettings.cs
index eb01b12..ad3c692 100644
--- a/src/EDNexus.Core/Settings/AppSettings.cs
+++ b/src/EDNexus.Core/Settings/AppSettings.cs
@@ -165,6 +165,13 @@ public sealed class OverlaySettings
{
/// When true, the overlay window is shown over the game. Default off.
public bool Enabled { get; set; }
+
+ ///
+ /// When true (the default), the overlay is shown only while Elite Dangerous (or EDNexus itself) is the
+ /// app in front, instead of sitting on top of every other window, including other overlays such as
+ /// EDCopilot's. Turn off to keep it always on top.
+ ///
+ public bool OnlyWhenGameFocused { get; set; } = true;
}
///
diff --git a/tests/EDNexus.Tests/Overlay/OverlayVisibilityTests.cs b/tests/EDNexus.Tests/Overlay/OverlayVisibilityTests.cs
new file mode 100644
index 0000000..e565483
--- /dev/null
+++ b/tests/EDNexus.Tests/Overlay/OverlayVisibilityTests.cs
@@ -0,0 +1,85 @@
+using EDNexus.Core.Overlay;
+using Xunit;
+
+namespace EDNexus.Tests.Overlay;
+
+///
+/// The overlay is a topmost window, so (#204) it must touch the desktop as little as possible: it is only
+/// shown while Elite is in front, and an unchanged snapshot must be recognisable as unchanged.
+///
+public class OverlayVisibilityTests
+{
+ private const string Own = "EDNexus.App";
+
+ [Theory]
+ [InlineData("EliteDangerous64", true)]
+ [InlineData("elitedangerous64", true)] // case-insensitive
+ [InlineData("EliteDangerous64.exe", true)] // a name with the extension still matches
+ [InlineData("EDNexus.App", true)] // EDNexus itself, so the Settings preview works
+ [InlineData("EDCopilot", false)]
+ [InlineData("chrome", false)]
+ [InlineData("explorer", false)]
+ public void Only_the_game_or_edNexus_in_front_shows_the_overlay(string foreground, bool expected)
+ => Assert.Equal(expected, OverlayVisibilityPolicy.ShouldShow(true, true, foreground, Own));
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ public void An_unknown_foreground_fails_open_so_a_detection_problem_never_hides_the_overlay_for_good(string? foreground)
+ => Assert.True(OverlayVisibilityPolicy.ShouldShow(true, true, foreground, Own));
+
+ [Fact]
+ public void Switched_off_is_never_shown_whatever_is_in_front()
+ {
+ Assert.False(OverlayVisibilityPolicy.ShouldShow(false, true, "EliteDangerous64", Own));
+ Assert.False(OverlayVisibilityPolicy.ShouldShow(false, false, "EliteDangerous64", Own));
+ }
+
+ [Fact]
+ public void The_always_on_top_option_keeps_the_old_behaviour()
+ => Assert.True(OverlayVisibilityPolicy.ShouldShow(true, false, "EDCopilot", Own));
+
+ [Fact]
+ public void Focus_only_is_the_default()
+ => Assert.True(new EDNexus.Core.Settings.OverlaySettings().OnlyWhenGameFocused);
+
+ // ---- unchanged content is recognised, so the overlay can skip updates ----
+
+ private static OverlayContent Content(string system = "Sol", params (string Name, int Remaining)[] shortfalls)
+ => new(system, "Alpha Centauri", 12.5, 32, "Sol 3 a", 2,
+ shortfalls.Select(s => new OverlayShortfallLine(s.Name, s.Remaining)).ToList(), "detail");
+
+ [Fact]
+ public void Two_separately_built_identical_snapshots_are_the_same()
+ {
+ // The builder makes a new list every tick, so reference equality (the record's default) says "changed".
+ var a = Content("Sol", ("Steel", 100), ("Aluminium", 40));
+ var b = Content("Sol", ("Steel", 100), ("Aluminium", 40));
+
+ Assert.NotSame(a.ColonisationShortfalls, b.ColonisationShortfalls);
+ Assert.True(a.SameAs(b));
+ }
+
+ [Fact]
+ public void Any_visible_change_makes_a_snapshot_different()
+ {
+ var baseline = Content("Sol", ("Steel", 100));
+
+ Assert.False(baseline.SameAs(Content("Barnard's Star", ("Steel", 100)))); // a jump
+ Assert.False(baseline.SameAs(Content("Sol", ("Steel", 99)))); // a delivery
+ Assert.False(baseline.SameAs(Content("Sol", ("Steel", 100), ("Copper", 5)))); // a new line
+ Assert.False(baseline.SameAs(baseline with { FuelMain = 12.4 }));
+ Assert.False(baseline.SameAs(baseline with { NextJumpSystem = null }));
+ Assert.False(baseline.SameAs(baseline with { BioSignalDetail = "other" }));
+ Assert.False(baseline.SameAs(null));
+ }
+
+ [Fact]
+ public void A_snapshot_is_the_same_as_itself_and_the_empty_snapshot_is_stable()
+ {
+ var c = Content();
+ Assert.True(c.SameAs(c));
+ Assert.True(OverlayContent.Empty.SameAs(new OverlayContent(null, null, 0, 0, null, 0, Array.Empty(), null)));
+ }
+}