Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 60 additions & 1 deletion ImGui.App/ImGuiApp.cs
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,23 @@ internal set
[SuppressMessage("Major Code Smell", "S2223:Non-constant static fields should not be visible", Justification = "Mutable static app-lifecycle state; single-instance by design, accessed via InternalsVisibleTo.")]
internal static bool startHiddenPending;

/// <summary>
/// The icons last applied through <see cref="SetWindowIcon"/>, kept so they can be applied again
/// once the window is on screen. See <see cref="windowIconReapplyPending"/>.
/// </summary>
[SuppressMessage("Major Code Smell", "S2223:Non-constant static fields should not be visible", Justification = "Mutable static app-lifecycle state; single-instance by design, accessed via InternalsVisibleTo.")]
internal static Silk.NET.Core.RawImage[]? windowIcons;

/// <summary>
/// Set when the icon is applied during Load, before the window is shown; cleared once it has been
/// applied again to the visible window. The Windows taskbar takes a button's icon from the window
/// class when the button is created on show, and only picks up the window's own icon when that
/// icon changes afterwards, so an icon applied before the window is shown never reaches the
/// taskbar of an unpinned app.
/// </summary>
[SuppressMessage("Major Code Smell", "S2223:Non-constant static fields should not be visible", Justification = "Mutable static app-lifecycle state; single-instance by design, accessed via InternalsVisibleTo.")]
internal static bool windowIconReapplyPending;

/// <summary>
/// Gets a value indicating whether the ImGui application window is visible.
/// </summary>
Expand Down Expand Up @@ -309,6 +326,7 @@ internal static void SetupWindowLoadHandler(ImGuiAppConfig config)
{
DebugLogger.Log("Window.Load: Setting window icon");
SetWindowIcon(config.IconPath);
windowIconReapplyPending = OperatingSystem.IsWindows();
}

DebugLogger.Log("Window.Load: Creating OpenGL factory");
Expand Down Expand Up @@ -403,6 +421,9 @@ private static void ApplyNativeVisibility(bool visible)
{
NativeMethods.ShowWindow(hwnd, NativeMethods.SW_RESTORE);
NativeMethods.SetForegroundWindow(hwnd);

// Showing a hidden window creates a new taskbar button, which starts from the class icon.
Invoker.Invoke(ReapplyWindowIcon);
}
else
{
Expand Down Expand Up @@ -725,6 +746,11 @@ internal static void SetupWindowUpdateHandler(ImGuiAppConfig config)
}
}

if (windowIconReapplyPending)
{
ReapplyWindowIconOnceShown(TryGetWindowHandle());
}

EnsureWindowPositionIsValid();
UpdateWindowPerformance();
UpdatePerformanceMonitoring((float)delta);
Expand Down Expand Up @@ -1657,7 +1683,38 @@ public static void SetWindowIcon(string iconPath)
}

_ = TrySetMacOSApplicationIcon(iconPath);
Invoker.Invoke(() => window?.SetWindowIcon([.. icons]));
windowIcons = [.. icons];
Invoker.Invoke(ReapplyWindowIcon);
}

/// <summary>
/// Applies the icons from the last <see cref="SetWindowIcon"/> call to the window again. Each call
/// gives the window new native icon handles, and that change is what the Windows taskbar responds
/// to. Does nothing before an icon has been set.
/// </summary>
internal static void ReapplyWindowIcon()
{
if (windowIcons is { } icons)
{
window?.SetWindowIcon(icons);
}
}

/// <summary>
/// Applies the icon again once the window is visible, so its taskbar button exists to pick it up.
/// Called each update tick while <see cref="windowIconReapplyPending"/> is set, because the window is
/// shown over the first few frames rather than at a fixed point.
/// </summary>
/// <param name="hwnd">The native window handle, or zero when it is not available yet.</param>
internal static void ReapplyWindowIconOnceShown(nint hwnd)
{
if (hwnd == 0 || !NativeMethods.IsWindowVisible(hwnd))
{
return;
}

windowIconReapplyPending = false;
ReapplyWindowIcon();
}

[ExcludeFromCodeCoverage(Justification = "Native AppKit Objective-C interop is host/platform dependent; unit tests cover the public SetWindowIcon behavior and non-macOS branch.")]
Expand Down Expand Up @@ -2584,6 +2641,8 @@ internal static void Reset()
glProvider = null;
userHidden = false;
startHiddenPending = false;
windowIcons = null;
windowIconReapplyPending = false;
hideOnCloseProc = null;
originalWindowProc = 0;
LastNormalWindowState = new();
Expand Down
2 changes: 1 addition & 1 deletion ImGui.App/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -518,7 +518,7 @@ Configuration for the ImGui application.
|------|------|---------|-------------|
| `TestMode` | `bool` | `false` | Whether the application is running in test mode |
| `Title` | `string` | `"ImGuiApp"` | The window title |
| `IconPath` | `string` | `""` | The file path to the application window icon |
| `IconPath` | `string` | `""` | The file path to the application window icon. On Windows it is applied again once the window is shown, because the taskbar button only picks up an icon that changes after the button exists |
| `InitialWindowState` | `ImGuiAppWindowState` | `new()` | The initial state of the application window |
| `WindowGeometry` | `WindowGeometryMode` | `Auto` | Who owns window position and size (see [Tiling window managers](#tiling-window-managers-and-wayland)) |
| `Fonts` | `Dictionary<string, byte[]>` | `[]` | Font name to font data mapping |
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -497,7 +497,7 @@ Configuration for `ImGuiApp.Start()`.
| Name | Type | Description |
| ---- | ---- | ----------- |
| `Title` | `string` | Window title (default: "ImGuiApp") |
| `IconPath` | `string` | Path to window icon (also used for the dock icon on macOS) |
| `IconPath` | `string` | Path to window icon (also used for the dock icon on macOS, and applied again once the window is shown on Windows so the taskbar button carries it) |
| `OnStart` | `Action` | Initialization callback |
| `OnUpdate` | `Action<float>` | Per-frame update callback |
| `OnRender` | `Action<float>` | Per-frame render callback |
Expand Down
83 changes: 83 additions & 0 deletions tests/ImGui.App.Tests/ImGuiAppWindowManagementTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -748,7 +748,7 @@
}

[TestMethod]
public void SetWindowIcon_WithValidPng_DoesNotThrow()

Check warning on line 751 in tests/ImGui.App.Tests/ImGuiAppWindowManagementTests.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Add at least one assertion to this test case.

Check warning on line 751 in tests/ImGui.App.Tests/ImGuiAppWindowManagementTests.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Add at least one assertion to this test case.

Check warning on line 751 in tests/ImGui.App.Tests/ImGuiAppWindowManagementTests.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Add at least one assertion to this test case.

Check warning on line 751 in tests/ImGui.App.Tests/ImGuiAppWindowManagementTests.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Add at least one assertion to this test case.
{
byte[] png = TestImageBuilder.Png(1, 1, colorType: 6, bitDepth: 8, [0x20, 0x40, 0x60, 0xFF]);
string path = Path.Join(Path.GetTempPath(), $"{Guid.NewGuid():N}.png");
Expand All @@ -768,6 +768,89 @@
}
}

[TestMethod]
public void SetWindowIcon_WithValidPng_KeepsEveryIconSizeForReapplying()
{
byte[] png = TestImageBuilder.Png(1, 1, colorType: 6, bitDepth: 8, [0x20, 0x40, 0x60, 0xFF]);
string path = Path.Join(Path.GetTempPath(), $"{Guid.NewGuid():N}.png");
try
{
File.WriteAllBytes(path, png);
ImGuiApp.window = null;
ImGuiApp.Invoker = new ktsu.Invoker.Invoker();
ImGuiApp.SetWindowIcon(path);

Assert.IsNotNull(ImGuiApp.windowIcons);
Assert.AreSequenceEqual(
[128, 64, 48, 32, 28, 24, 22, 20, 18, 16],
ImGuiApp.windowIcons.Select(icon => icon.Width));
}
finally
{
if (File.Exists(path))
{
File.Delete(path);
}
}
}

[TestMethod]
public void ReapplyWindowIcon_BeforeAnyIconIsSet_DoesNothing()
{
ImGuiApp.window = null;

ImGuiApp.ReapplyWindowIcon();

Assert.IsNull(ImGuiApp.windowIcons);
}

/// <summary>
/// The window is shown over the first few frames, so a tick before its native handle exists has
/// to leave the reapply pending for a later tick rather than give up on it.
/// </summary>
[TestMethod]
public void ReapplyWindowIconOnceShown_NoWindowHandleYet_StaysPending()
{
ImGuiApp.windowIconReapplyPending = true;

ImGuiApp.ReapplyWindowIconOnceShown(0);

Assert.IsTrue(ImGuiApp.windowIconReapplyPending);
}

/// <summary>
/// Once the window is visible its taskbar button exists, so the icon is applied once more and the
/// pending flag clears. The desktop window stands in for the app's window: it is always visible,
/// even on a headless build agent.
/// </summary>
[TestMethod]
public void ReapplyWindowIconOnceShown_VisibleWindow_ClearsPending()
{
if (!OperatingSystem.IsWindows())
{
Assert.Inconclusive("The taskbar reapply only runs on Windows.");
}

Check warning on line 832 in tests/ImGui.App.Tests/ImGuiAppWindowManagementTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use '[OSCondition]' attribute instead of 'RuntimeInformation.IsOSPlatform' calls with early return or 'Assert.Inconclusive'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_ImGuiApp&issues=AaEU0-pkzR-9afISza2v&open=AaEU0-pkzR-9afISza2v&pullRequest=591

ImGuiApp.window = null;
ImGuiApp.windowIconReapplyPending = true;

ImGuiApp.ReapplyWindowIconOnceShown(Interop.NativeMethods.GetDesktopWindow());

Assert.IsFalse(ImGuiApp.windowIconReapplyPending);
}

[TestMethod]
public void Reset_AfterAnIconWasSet_ClearsTheIconState()
{
ImGuiApp.windowIcons = [];
ImGuiApp.windowIconReapplyPending = true;

ImGuiApp.Reset();

Assert.IsNull(ImGuiApp.windowIcons);
Assert.IsFalse(ImGuiApp.windowIconReapplyPending);
}

#endregion

#region Texture Management Integration Tests
Expand Down
17 changes: 17 additions & 0 deletions tests/ImGui.App.Tests/Interop/NativeMethods.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.ImGui.App.Tests.Interop;

using System.Runtime.InteropServices;

/// <summary>
/// Native calls the tests need for themselves. In a namespace of their own so this class does not
/// shadow the library's <c>NativeMethods</c>, which other tests inspect by that name.
/// </summary>
internal static partial class NativeMethods
{
/// <summary>The desktop window, which is always visible on Windows, even on a headless agent.</summary>
[LibraryImport("user32.dll")]
[DefaultDllImportSearchPaths(DllImportSearchPath.System32)]
internal static partial nint GetDesktopWindow();
}
Loading