diff --git a/ImGui.App/ImGui.App.csproj b/ImGui.App/ImGui.App.csproj index 185c956..ea90fac 100644 --- a/ImGui.App/ImGui.App.csproj +++ b/ImGui.App/ImGui.App.csproj @@ -48,6 +48,7 @@ + diff --git a/ImGui.App/ImGuiApp.cs b/ImGui.App/ImGuiApp.cs index 03a397d..372e093 100644 --- a/ImGui.App/ImGuiApp.cs +++ b/ImGui.App/ImGuiApp.cs @@ -105,6 +105,16 @@ public static ImGuiAppWindowState WindowState internal static readonly PidFrameLimiter frameLimiter = new(); internal static double previousTargetFrameTimeMs = 1000.0 / 30.0; + /// Drives the native window styling that backs overlay mode (see ). + internal static readonly OverlayChrome overlayChrome = new(); + + /// + /// Gets a value indicating whether the window is currently in overlay mode (borderless, + /// always-on-top, and translucent). Toggled via and + /// . + /// + public static bool IsOverlayActive => overlayChrome.IsActive; + /// /// Updates the last input time to the current time. Called by the input system when user input is detected. /// @@ -372,6 +382,43 @@ public static void Hide() ApplyNativeVisibility(false); } + /// + /// Switches the window into overlay mode: borderless, always-on-top, and whole-window + /// translucent, with optional click-through. Safe to call every frame to keep the opacity + /// and click-through in sync with live settings; the underlying window styles are only + /// touched when something actually changes. + /// + /// Overlay styling is implemented on Windows. On other platforms + /// still reflects the request (so overlay frame-rate throttling via + /// still applies) but the window is not + /// restyled. Call from the thread that owns the render window (e.g. from ). + /// + /// + /// Whole-window opacity, clamped to 0.2 (faint) – 1.0 (opaque). + /// When true, mouse input passes through to whatever is behind the overlay. + public static void EnableOverlay(float opacity = 1.0f, bool clickThrough = false) => + overlayChrome.Enable(TryGetWindowHandle(), opacity, clickThrough); + + /// + /// Locks the overlay to a corner of the active monitor's work area at the given offset and + /// size. No-op unless overlay mode is active (see ). Re-applies + /// only when the geometry changes, so it is cheap to call every frame. Windows-only; + /// elsewhere the consumer is responsible for positioning. + /// + /// Which work-area corner to anchor the overlay to. + /// Horizontal inset (px) from the anchored corner. + /// Vertical inset (px) from the anchored corner. + /// Overlay width in pixels (minimum 200). + /// Overlay height in pixels (minimum 140). + public static void SetOverlayGeometry(OverlayCorner corner, int offsetX, int offsetY, int width, int height) => + overlayChrome.SetGeometry(TryGetWindowHandle(), corner, offsetX, offsetY, width, height); + + /// + /// Leaves overlay mode and restores the decorated, non-topmost, opaque window. Safe to call + /// repeatedly (including when overlay mode was never entered). + /// + public static void DisableOverlay() => overlayChrome.Disable(); + internal static void SetupWindowResizeHandler(ImGuiAppConfig config) { window!.FramebufferResize += s => @@ -419,6 +466,18 @@ internal static void UpdateWindowPerformance() return; } + // Overlay mode runs at its own dedicated rate. An always-on-top overlay is usually + // unfocused and may report as "not visible" to some backends, which would otherwise + // throttle it to a crawl — yet it typically shows live, continuously-updating data. + // So while overlay mode is active we bypass the focus/idle/visibility reductions and + // use OverlayFps directly. + if (IsOverlayActive) + { + IsIdle = false; + targetFrameTimeMs = settings.OverlayFps > 0 ? (1000.0 / settings.OverlayFps) : 10000.0; + return; + } + // Update idle state if idle detection is enabled if (settings.EnableIdleDetection) { @@ -1733,6 +1792,7 @@ internal static void Reset() ScaleFactor = 1; GlobalScale = 1.0f; Textures.Clear(); + overlayChrome.ResetState(); Config = new(); } diff --git a/ImGui.App/ImGuiAppPerformanceSettings.cs b/ImGui.App/ImGuiAppPerformanceSettings.cs index ec0918c..6265be1 100644 --- a/ImGui.App/ImGuiAppPerformanceSettings.cs +++ b/ImGui.App/ImGuiAppPerformanceSettings.cs @@ -35,6 +35,15 @@ public class ImGuiAppPerformanceSettings /// public double NotVisibleFps { get; init; } = 2.0; + /// + /// Gets or sets the target frame rate (FPS) used while the window is in overlay mode + /// (entered via ImGuiApp.EnableOverlay). Because an always-on-top overlay is usually + /// unfocused — and may even report as not visible — the normal focus/idle/visibility + /// throttling would make it sluggish despite showing live data. While overlay mode is + /// active this rate is used instead, bypassing those reductions. Defaults to 30 FPS. + /// + public double OverlayFps { get; init; } = 30.0; + /// /// Gets or sets a value indicating whether idle detection is enabled. /// When true, the application will detect when there's no user input and reduce frame rate further. diff --git a/ImGui.App/NativeMethods.cs b/ImGui.App/NativeMethods.cs index f191d52..cd90b46 100644 --- a/ImGui.App/NativeMethods.cs +++ b/ImGui.App/NativeMethods.cs @@ -32,6 +32,54 @@ internal static partial class NativeMethods [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] internal static partial nint SetWindowLongPtr(nint hWnd, int nIndex, nint dwNewLong); + /// Reads a window's style/extended-style word (used to enter and restore overlay mode). + [LibraryImport("user32.dll", EntryPoint = "GetWindowLongPtrW")] + [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] + internal static partial nint GetWindowLongPtr(nint hWnd, int nIndex); + + /// Sets the per-pixel/whole-window alpha for a layered window (overlay translucency). + [LibraryImport("user32.dll")] + [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool SetLayeredWindowAttributes(nint hWnd, uint crKey, byte bAlpha, uint dwFlags); + + /// Changes a window's size, position, and Z-order (used to make the overlay topmost and corner-locked). + [LibraryImport("user32.dll")] + [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool SetWindowPos(nint hWnd, nint hWndInsertAfter, int x, int y, int cx, int cy, uint uFlags); + + /// Returns the monitor that contains (or is nearest to) the given window. + [LibraryImport("user32.dll")] + [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] + internal static partial nint MonitorFromWindow(nint hWnd, uint dwFlags); + + /// Retrieves monitor geometry, including the work area excluding the taskbar. + [LibraryImport("user32.dll", EntryPoint = "GetMonitorInfoW")] + [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool GetMonitorInfo(nint hMonitor, ref MONITORINFO lpmi); + + /// A rectangle defined by its edges, matching the Win32 RECT structure. + [StructLayout(LayoutKind.Sequential)] + internal struct RECT + { + public int Left; + public int Top; + public int Right; + public int Bottom; + } + + /// Monitor geometry returned by , matching the Win32 MONITORINFO structure. + [StructLayout(LayoutKind.Sequential)] + internal struct MONITORINFO + { + public int cbSize; + public RECT rcMonitor; + public RECT rcWork; + public uint dwFlags; + } + /// Forwards a message to the previously installed window procedure. [LibraryImport("user32.dll", EntryPoint = "CallWindowProcW")] [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] diff --git a/ImGui.App/OverlayWindow.cs b/ImGui.App/OverlayWindow.cs new file mode 100644 index 0000000..28b2836 --- /dev/null +++ b/ImGui.App/OverlayWindow.cs @@ -0,0 +1,260 @@ +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.ImGui.App; + +using System.Runtime.Versioning; + +/// +/// Identifies which corner of the active monitor's work area an overlay window locks to +/// when its geometry is managed via . +/// +public enum OverlayCorner +{ + /// Top-left corner of the work area. + TopLeft, + + /// Top-right corner of the work area. + TopRight, + + /// Bottom-left corner of the work area. + BottomLeft, + + /// Bottom-right corner of the work area. + BottomRight, +} + +/// +/// Turns the application's native window into an "overlay": borderless, always-on-top, and +/// whole-window translucent, with optional click-through and corner-locked geometry. The +/// original window styles are cached so the decorated window can be restored exactly. +/// +/// Native styling is implemented on Windows; on other platforms the logical +/// state is still tracked (so frame-rate throttling and consumer +/// logic behave consistently) but no window styles are changed. All calls are best-effort +/// and no-op when the native handle is unavailable (e.g. before the window is created) or +/// when the platform does not export the required APIs. +/// +/// Per-frame calls are cheap: styles are only re-applied when something actually changed. +/// Must be driven from the thread that owns the render window. +/// +internal sealed class OverlayChrome +{ + private const int GWL_STYLE = -16; + private const int GWL_EXSTYLE = -20; + + private const int WS_CAPTION = 0x00C00000; + private const int WS_THICKFRAME = 0x00040000; + private const int WS_MINIMIZEBOX = 0x00020000; + private const int WS_MAXIMIZEBOX = 0x00010000; + private const int WS_SYSMENU = 0x00080000; + + private const int WS_EX_LAYERED = 0x00080000; + private const int WS_EX_TRANSPARENT = 0x00000020; + + private const uint LWA_ALPHA = 0x2; + + private static readonly nint HWND_TOPMOST = new(-1); + private static readonly nint HWND_NOTOPMOST = new(-2); + + private const uint SWP_NOSIZE = 0x0001; + private const uint SWP_NOMOVE = 0x0002; + private const uint SWP_NOACTIVATE = 0x0010; + private const uint SWP_FRAMECHANGED = 0x0020; + + private const uint MONITOR_DEFAULTTONEAREST = 2; + + private nint hwnd; + private bool styled; + private nint originalStyle; + private nint originalExStyle; + + // Set once if the platform doesn't export the window-style APIs (e.g. 32-bit Windows), + // so we stop retrying the native calls every frame. + private bool nativeUnavailable; + + // Last-applied values, so per-frame calls only touch Win32 on a real change. + private bool lastClickThrough; + private byte lastAlpha; + private (OverlayCorner Corner, int OffsetX, int OffsetY, int Width, int Height) lastGeometry; + private bool geometryApplied; + + /// + /// Gets a value indicating whether overlay mode is logically active (i.e. + /// has been called more recently than ). Tracked on all platforms, + /// independently of whether native styling could be applied. + /// + public bool IsActive { get; private set; } + + /// + /// Switches the window into overlay mode (borderless, topmost, layered) and keeps its + /// click-through and opacity in sync. Safe to call every frame. + /// + /// The native window handle, or zero if unavailable. + /// Whole-window opacity in the range 0.2–1.0. + /// When true, mouse input passes through to whatever is behind the overlay. + public void Enable(nint windowHandle, float opacity, bool clickThrough) + { + IsActive = true; + + if (!OperatingSystem.IsWindows() || windowHandle == 0 || nativeUnavailable) + { + return; + } + + ApplyWindowsStyles(windowHandle, opacity, clickThrough); + } + + /// + /// Locks the overlay to the given corner of its monitor's work area at the given offset + /// and size. Re-applies only when something changed (or on the first call after entering + /// overlay mode). No-op unless overlay mode is active and native styling has been applied. + /// + public void SetGeometry(nint windowHandle, OverlayCorner corner, int offsetX, int offsetY, int width, int height) + { + if (!IsActive || !styled || !OperatingSystem.IsWindows() || windowHandle == 0) + { + return; + } + + ApplyWindowsGeometry(windowHandle, corner, offsetX, offsetY, width, height); + } + + /// Restores the original decorated, non-topmost, opaque window. Safe to call repeatedly. + public void Disable() + { + if (styled && OperatingSystem.IsWindows()) + { + RestoreWindowsStyles(); + } + + IsActive = false; + } + + /// Clears all logical and cached state without touching the native window (test/reset hook). + internal void ResetState() + { + IsActive = false; + styled = false; + geometryApplied = false; + nativeUnavailable = false; + hwnd = 0; + originalStyle = 0; + originalExStyle = 0; + lastAlpha = 0; + lastClickThrough = false; + lastGeometry = default; + } + + [SupportedOSPlatform("windows")] + private void ApplyWindowsStyles(nint windowHandle, float opacity, bool clickThrough) + { + byte alpha = (byte)Math.Clamp(opacity * 255f, 51f, 255f); + + try + { + if (!styled || hwnd != windowHandle) + { + hwnd = windowHandle; + originalStyle = NativeMethods.GetWindowLongPtr(windowHandle, GWL_STYLE); + originalExStyle = NativeMethods.GetWindowLongPtr(windowHandle, GWL_EXSTYLE); + + nint style = originalStyle & ~(nint)(WS_CAPTION | WS_THICKFRAME | WS_MINIMIZEBOX | WS_MAXIMIZEBOX | WS_SYSMENU); + _ = NativeMethods.SetWindowLongPtr(windowHandle, GWL_STYLE, style); + _ = NativeMethods.SetWindowLongPtr(windowHandle, GWL_EXSTYLE, originalExStyle | WS_EX_LAYERED); + _ = NativeMethods.SetWindowPos(windowHandle, HWND_TOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE | SWP_FRAMECHANGED); + + styled = true; + // Force the click-through and alpha branches below to run for the fresh window. + lastClickThrough = !clickThrough; + lastAlpha = 0; + } + + if (clickThrough != lastClickThrough) + { + nint ex = NativeMethods.GetWindowLongPtr(windowHandle, GWL_EXSTYLE) | WS_EX_LAYERED; + ex = clickThrough ? (ex | WS_EX_TRANSPARENT) : (ex & ~(nint)WS_EX_TRANSPARENT); + _ = NativeMethods.SetWindowLongPtr(windowHandle, GWL_EXSTYLE, ex); + lastClickThrough = clickThrough; + } + + if (alpha != lastAlpha) + { + _ = NativeMethods.SetLayeredWindowAttributes(windowHandle, 0, alpha, LWA_ALPHA); + lastAlpha = alpha; + } + } + catch (Exception ex) when (ex is EntryPointNotFoundException or DllNotFoundException) + { + // Older/32-bit Windows may not export the *Ptr window-style APIs; degrade to a + // normal decorated window rather than throwing every frame. + nativeUnavailable = true; + styled = false; + DebugLogger.Log($"OverlayChrome: native styling unavailable ({ex.Message}); overlay will render as a normal window."); + } + } + + [SupportedOSPlatform("windows")] + private void ApplyWindowsGeometry(nint windowHandle, OverlayCorner corner, int offsetX, int offsetY, int width, int height) + { + width = Math.Max(200, width); + height = Math.Max(140, height); + + (OverlayCorner corner, int offsetX, int offsetY, int width, int height) geometry = (corner, offsetX, offsetY, width, height); + if (geometryApplied && geometry == lastGeometry) + { + return; + } + + if (!TryGetWorkArea(windowHandle, out NativeMethods.RECT work)) + { + return; + } + + bool right = corner is OverlayCorner.TopRight or OverlayCorner.BottomRight; + bool bottom = corner is OverlayCorner.BottomLeft or OverlayCorner.BottomRight; + + int x = right ? work.Right - width - offsetX : work.Left + offsetX; + int y = bottom ? work.Bottom - height - offsetY : work.Top + offsetY; + + // The explicit resize also forces the renderer to refresh its framebuffer after the + // title bar was removed — without it the top of the content can be clipped. + _ = NativeMethods.SetWindowPos(windowHandle, HWND_TOPMOST, x, y, width, height, SWP_NOACTIVATE); + lastGeometry = geometry; + geometryApplied = true; + } + + [SupportedOSPlatform("windows")] + private void RestoreWindowsStyles() + { + if (hwnd == 0) + { + return; + } + + _ = NativeMethods.SetWindowLongPtr(hwnd, GWL_STYLE, originalStyle); + _ = NativeMethods.SetWindowLongPtr(hwnd, GWL_EXSTYLE, originalExStyle); + _ = NativeMethods.SetWindowPos(hwnd, HWND_NOTOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE | SWP_FRAMECHANGED); + + styled = false; + geometryApplied = false; + lastAlpha = 0; + lastClickThrough = false; + } + + [SupportedOSPlatform("windows")] + private static bool TryGetWorkArea(nint windowHandle, out NativeMethods.RECT work) + { + nint monitor = NativeMethods.MonitorFromWindow(windowHandle, MONITOR_DEFAULTTONEAREST); + NativeMethods.MONITORINFO info = new() { cbSize = System.Runtime.InteropServices.Marshal.SizeOf() }; + if (monitor != 0 && NativeMethods.GetMonitorInfo(monitor, ref info)) + { + work = info.rcWork; + return true; + } + + work = default; + return false; + } +} diff --git a/README.md b/README.md index 04dd3e5..b1554b9 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,44 @@ ImGuiApp.Start(new ImGuiAppConfig }); ``` +### Overlay Mode (Always-On-Top HUD) + +Overlay mode turns the application window into a borderless, always-on-top, translucent HUD +with optional click-through — ideal for live status displays that sit over other apps. Drive +it from your render callback; the calls are cheap to make every frame (the native window is +only restyled when something actually changes). A dedicated `OverlayFps` keeps the overlay +animating smoothly even while unfocused, bypassing the normal focus/idle/visibility throttling. + +```csharp +bool overlayEnabled = true; + +ImGuiApp.Start(new ImGuiAppConfig +{ + Title = "Status HUD", + PerformanceSettings = new ImGuiAppPerformanceSettings { OverlayFps = 60.0 }, + OnRender = delta => + { + if (overlayEnabled) + { + // opacity 0.2–1.0, plus optional click-through (mouse passes through to apps behind it). + ImGuiApp.EnableOverlay(opacity: 0.9f, clickThrough: false); + // Optional: lock it to a corner of the monitor work area (Windows). + ImGuiApp.SetOverlayGeometry(OverlayCorner.TopRight, offsetX: 24, offsetY: 24, width: 360, height: 280); + } + else + { + ImGuiApp.DisableOverlay(); // restores the decorated window + } + + ImGui.Text("Live status…"); + } +}); +``` + +Overlay window styling (borderless / topmost / translucency / click-through) is implemented on +Windows. On other platforms `IsOverlayActive` and `OverlayFps` still apply, but the window is +not restyled. + ### Widgets ```csharp @@ -312,6 +350,10 @@ Application lifecycle and utilities. | ---- | ----------- | ----------- | | `Start(ImGuiAppConfig)` | `void` | Initialize and run the application | | `Stop()` | `void` | Close the application window | +| `Show()` / `Hide()` | `void` | Show or hide the window without stopping the render loop | +| `EnableOverlay(float, bool)` | `void` | Enter overlay mode: borderless, always-on-top, translucent, optional click-through | +| `SetOverlayGeometry(OverlayCorner, int, int, int, int)` | `void` | Lock the overlay to a work-area corner at an offset and size | +| `DisableOverlay()` | `void` | Restore the decorated, non-topmost, opaque window | | `SetGlobalScale(float)` | `void` | Set accessibility UI scale (0.5-3.0) | | `SetWindowIcon(string)` | `void` | Set window icon from image file | | `GetOrLoadTexture(AbsoluteFilePath)` | `ImGuiAppTextureInfo` | Load or retrieve cached GPU texture | @@ -326,6 +368,7 @@ Application lifecycle and utilities. | `IsFocused` | `bool` | Whether the window has focus | | `IsVisible` | `bool` | Whether the window is visible | | `IsIdle` | `bool` | Whether the app is idle | +| `IsOverlayActive` | `bool` | Whether the window is in overlay mode | | `ScaleFactor` | `float` | DPI-based scale factor | | `GlobalScale` | `float` | User-adjustable UI scale | | `Invoker` | `Invoker` | Delegate invocation for window thread | @@ -363,6 +406,7 @@ Frame rate throttling configuration. | `UnfocusedFps` | `double` | Target FPS when unfocused (default: 5) | | `IdleFps` | `double` | Target FPS when idle (default: 10) | | `NotVisibleFps` | `double` | Target FPS when minimized (default: 2) | +| `OverlayFps` | `double` | Target FPS in overlay mode, bypassing focus/idle/visibility throttling (default: 30) | | `IdleTimeoutSeconds` | `double` | Seconds before idle state (default: 30) | ### `ImGuiWidgets` (Static) diff --git a/examples/ImGuiAppDemo/ImGuiAppDemo.cs b/examples/ImGuiAppDemo/ImGuiAppDemo.cs index 9a5f4c7..e52ee53 100644 --- a/examples/ImGuiAppDemo/ImGuiAppDemo.cs +++ b/examples/ImGuiAppDemo/ImGuiAppDemo.cs @@ -13,6 +13,13 @@ namespace ktsu.ImGui.Examples.App; internal static class ImGuiAppDemo { private static bool showAbout; + + // Overlay-mode demo state: a borderless, always-on-top, translucent window locked to a corner. + private static bool overlayEnabled; + private static float overlayOpacity = 0.9f; + private static bool overlayClickThrough; + private static OverlayCorner overlayCorner = OverlayCorner.TopRight; + private static readonly List demoTabs = []; static ImGuiAppDemo() @@ -53,6 +60,9 @@ private static void Main() => ImGuiApp.Start(new() PerformanceSettings = new() { EnableThrottledRendering = true, + // Overlay mode keeps animating at 60 FPS even while unfocused (it's always-on-top + // and shows live content), bypassing the focus/idle/visibility throttling. + OverlayFps = 60.0, // Using default values: Focused=30, Unfocused=5, Idle=10 FPS // But with a shorter idle timeout for demo purposes IdleTimeoutSeconds = 5.0, // Consider idle after 5 seconds (default is 30) @@ -61,14 +71,33 @@ private static void Main() => ImGuiApp.Start(new() private static void OnRender(float dt) { + // Keep the native window styling in sync with the overlay-demo toggle. Calling these + // every frame is cheap — the underlying window is only restyled when something changes. + if (overlayEnabled) + { + ImGuiApp.EnableOverlay(overlayOpacity, overlayClickThrough); + ImGuiApp.SetOverlayGeometry(overlayCorner, offsetX: 24, offsetY: 24, width: 380, height: 320); + } + else + { + ImGuiApp.DisableOverlay(); + } + // Update all demo tabs foreach (IDemoTab demo in demoTabs) { demo.Update(dt); } - // Render main demo window - RenderMainDemoWindow(); + // In overlay mode show a compact control strip instead of the full tabbed UI. + if (overlayEnabled) + { + RenderOverlayControls(); + } + else + { + RenderMainDemoWindow(); + } // Show about window if requested if (showAbout) @@ -77,6 +106,32 @@ private static void OnRender(float dt) } } + // Demonstrates the canonical overlay API: toggle, opacity, click-through, and corner anchor. + private static void RenderOverlayControls() + { + ImGui.TextUnformatted("Overlay mode"); + ImGui.Separator(); + + _ = ImGui.SliderFloat("Opacity", ref overlayOpacity, 0.2f, 1.0f, "%.2f"); + _ = ImGui.Checkbox("Click-through", ref overlayClickThrough); + + int corner = (int)overlayCorner; + if (ImGui.Combo("Corner", ref corner, "Top-left\0Top-right\0Bottom-left\0Bottom-right\0")) + { + overlayCorner = (OverlayCorner)corner; + } + + if (overlayClickThrough) + { + ImGui.TextDisabled("Click-through is on — toggle it from the View menu to interact."); + } + + if (ImGui.Button("Exit overlay")) + { + overlayEnabled = false; + } + } + private static void RenderMainDemoWindow() { // Create tabs for different demo sections @@ -118,6 +173,16 @@ private static void RenderAboutWindow() private static void OnAppMenu() { + if (ImGui.BeginMenu("View")) + { + ImGui.MenuItem("Overlay mode", string.Empty, ref overlayEnabled); + if (overlayEnabled) + { + ImGui.MenuItem("Overlay click-through", string.Empty, ref overlayClickThrough); + } + ImGui.EndMenu(); + } + if (ImGui.BeginMenu("Help")) { ImGui.MenuItem("About", string.Empty, ref showAbout); diff --git a/tests/ImGui.App.Tests/ImGuiAppTests.cs b/tests/ImGui.App.Tests/ImGuiAppTests.cs index c148b83..dd08543 100644 --- a/tests/ImGui.App.Tests/ImGuiAppTests.cs +++ b/tests/ImGui.App.Tests/ImGuiAppTests.cs @@ -467,6 +467,7 @@ public void PerformanceSettings_DefaultValues_AreCorrect() Assert.AreEqual(5.0, settings.UnfocusedFps); Assert.AreEqual(10.0, settings.IdleFps); Assert.AreEqual(2.0, settings.NotVisibleFps); + Assert.AreEqual(30.0, settings.OverlayFps); Assert.IsTrue(settings.EnableIdleDetection, "EnableIdleDetection should default to true"); Assert.AreEqual(30.0, settings.IdleTimeoutSeconds); } diff --git a/tests/ImGui.App.Tests/ImGuiAppWindowManagementTests.cs b/tests/ImGui.App.Tests/ImGuiAppWindowManagementTests.cs index 7bf74b8..a4ab7d1 100644 --- a/tests/ImGui.App.Tests/ImGuiAppWindowManagementTests.cs +++ b/tests/ImGui.App.Tests/ImGuiAppWindowManagementTests.cs @@ -422,6 +422,98 @@ public void UpdateWindowPerformance_WithUnfocusedWindow_DoesNotThrow() } } + [TestMethod] + public void UpdateWindowPerformance_WhenOverlayActive_UsesOverlayFps() + { + ImGuiAppConfig config = new() + { + Title = "Test", + InitialWindowState = new ImGuiAppWindowState + { + Size = new Vector2(800, 600), + Pos = new Vector2(100, 100), + LayoutState = WindowState.Normal + }, + PerformanceSettings = new ImGuiAppPerformanceSettings + { + FocusedFps = 30.0, + OverlayFps = 60.0 + } + }; + ImGuiApp.Config = config; + + // No native window in tests, but overlay mode is logically active regardless of platform. + ImGuiApp.EnableOverlay(opacity: 0.85f); + Assert.IsTrue(ImGuiApp.IsOverlayActive, "Overlay mode should be active after EnableOverlay."); + + ImGuiApp.UpdateWindowPerformance(); + + Assert.AreEqual(1000.0 / 60.0, ImGuiApp.targetFrameTimeMs, 0.0001, + "Overlay mode should drive the frame rate from OverlayFps, bypassing focus/idle/visibility throttling."); + + ImGuiApp.DisableOverlay(); + Assert.IsFalse(ImGuiApp.IsOverlayActive, "Overlay mode should be inactive after DisableOverlay."); + } + + [TestMethod] + public void UpdateWindowPerformance_WhenOverlayActive_ClearsIdleState() + { + ImGuiAppConfig config = new() + { + Title = "Test", + PerformanceSettings = new ImGuiAppPerformanceSettings + { + OverlayFps = 30.0, + EnableIdleDetection = true, + IdleTimeoutSeconds = 0.0 + } + }; + ImGuiApp.Config = config; + ImGuiApp.EnableOverlay(); + + ImGuiApp.UpdateWindowPerformance(); + + Assert.IsFalse(ImGuiApp.IsIdle, "Overlay mode shows live data, so it should not be throttled to the idle rate."); + + ImGuiApp.DisableOverlay(); + } + + [TestMethod] + public void EnableOverlay_ThenDisableOverlay_TogglesIsOverlayActive() + { + Assert.IsFalse(ImGuiApp.IsOverlayActive, "Overlay mode should be inactive by default."); + + ImGuiApp.EnableOverlay(opacity: 0.5f, clickThrough: true); + Assert.IsTrue(ImGuiApp.IsOverlayActive); + + // Idempotent: re-enabling (e.g. every frame) keeps it active. + ImGuiApp.EnableOverlay(opacity: 0.9f, clickThrough: false); + Assert.IsTrue(ImGuiApp.IsOverlayActive); + + ImGuiApp.DisableOverlay(); + Assert.IsFalse(ImGuiApp.IsOverlayActive); + + // Idempotent: disabling again is safe. + ImGuiApp.DisableOverlay(); + Assert.IsFalse(ImGuiApp.IsOverlayActive); + } + + [TestMethod] + public void SetOverlayGeometry_WhenOverlayInactive_DoesNotThrow() + { + try + { + // No overlay active and no native window: should be a safe no-op. + ImGuiApp.SetOverlayGeometry(OverlayCorner.TopRight, 24, 24, 460, 320); + } +#pragma warning disable CA1031 // Do not catch general exception types + catch (Exception ex) +#pragma warning restore CA1031 // Do not catch general exception types + { + Assert.Fail($"Expected no exception, but got: {ex.Message}"); + } + } + #endregion #region Window Icon Tests