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
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,27 @@ Every user-visible change, newest first. Pre-1.0 the minor number carries new su
carries fixes. The section for a tag is what the GitHub release publishes, so the two cannot say
different things.

## [Unreleased]

### Added

- **A demo icon in both templates.** `dotnet new cui-app` ships `<Project>.ico` wired in with
`ApplicationIcon`, and `dotnet new cui-mini` generates `<name>.cs` plus a matching `<name>.ico`;
replacing the file and rebuilding changes the exe icon and the icon on the title bar, taskbar
button and `Alt+Tab` card. The shipped icon is a black square reading CUI.

### Fixed

- **The embedded icon reaches the window.** An apphost embeds `ApplicationIcon` as `IDI_APPLICATION`,
which Explorer reads off the exe but the CUI window class does not find, so a published app wore
the system's generic logo in the title bar and taskbar. `CuiApp` now loads that resource and sends
`WM_SETICON` itself when the window opens, small and large, at the system's icon metrics.

- **`dotnet new cui-mini` no longer opens a console window.** The generated file-based app now
sets `#:property OutputType=WinExe`, so `dotnet publish` produces a GUI-subsystem executable
like the one `dotnet new cui-app` builds; `tools/verify-template.ps1` reads that subsystem out
of the published binary.

## [0.3.2] — 2026-11-11

The gallery now draws everything CUI ships, and a published binary carries only what it uses.
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,10 @@ dotnet run demo1.cs # a JIT build of one file: a second o
dotnet publish demo1.cs -c Release -o . # one native binary beside it, no .NET to install
```

`-n demo1` creates only `demo1.cs` in the current directory, without a new folder. Add `-o <folder>`
`-n demo1` creates `demo1.cs` and a demo icon `demo1.ico` in the current directory, without a new
folder; replace the .ico with your own to change the exe and window icon. Add `-o <folder>`
only when you want a separate output directory. The generated file carries the properties that make
that publish one file of about 1.1 MB, and says so at the top. It starts with
that publish one file of about 1.2 MB, and says so at the top. It starts with
`#:package CuiSharp@*`, resolving the latest stable package; use `--CuiSharpVersion 0.3.1` when
generating to pin a version. Its counter and theme switch use the same frame API as a full app;
`CUISHARP_HEADLESS=1` runs it without a window for automation.
Expand Down
6 changes: 4 additions & 2 deletions docs/SIZE.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,8 @@ way:
|---|---|---|
| minimal app | 1 498 112 | 1 165 312 (−22.2%) |
| gallery, 28 pages | 3 167 232 | 2 862 592 (−9.6%) |
| `dotnet new cui-app`, published | — | 2 244 608 |
| `dotnet new cui-mini`, published | — | 1 163 264 |
| `dotnet new cui-app`, published | — | 2 282 496 |
| `dotnet new cui-mini`, published | — | 1 198 080 |

Nearly all of it is the headless channel: `System.IO`, `Console`, `Encoding` and the reflection
over an enum the command lexer reads arrive with it, and a switch only removes them when the
Expand All @@ -67,6 +67,8 @@ hot-reload metadata that a shipped app never reads.

The two template rows carry the app's own name and version inside the executable, so `-n` moves them
by a few hundred bytes; the mini template sets the properties that keep that publish to one file.
Both now also embed the template's demo .ico, which adds the 34 494 B of the icon plus a few
kilobytes of resource directory to the executable.

`dotnet cui` passes `-p:CuiSharpHeadless=true` to the builds it runs itself, and says so when a
**published** app has no channel: publishing one for a tool to inspect needs the same property.
Expand Down
7 changes: 4 additions & 3 deletions skills/cui-dotnet/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,11 @@ draw again. Conditional UI is an `if`; a list is a `for`.
## A whole app

For a file-based C# app: install `CuiSharp.Templates`, then `dotnet new cui-mini -n demo1`
and `dotnet run demo1.cs` — a JIT build of one file, so a second or two. This creates only
`demo1.cs` in the current directory, without a new folder. Add `-o <folder>` only for a separate
and `dotnet run demo1.cs` — a JIT build of one file, so a second or two. This creates
`demo1.cs` and a demo icon `demo1.ico` in the current directory, without a new folder; replacing
the .ico and rebuilding changes the exe and window icon. Add `-o <folder>` only for a separate
output directory. `dotnet publish demo1.cs -c Release -o .` turns it into one native binary beside
the source, about 1.1 MB, needing no .NET on the machine that runs it; the generated file sets the
the source, about 1.2 MB, needing no .NET on the machine that runs it; the generated file sets the
properties that do that, explained in its header.
The generated file uses `#:package CuiSharp@*`; pass
`--CuiSharpVersion <version>` to the template to pin it. Use `cui-app` instead for a solution with
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<OutputType>WinExe</OutputType>
<!-- The app icon: replace this one file with your own .ico, keeping the name, and rebuild.
It becomes the exe icon, and CuiApp puts the same image on the window — the title bar,
the taskbar button and Alt+Tab — when the app starts. -->
<ApplicationIcon>CuiSharp.App.ico</ApplicationIcon>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>CuiSharp.App</RootNamespace>
Expand Down
Binary file not shown.
17 changes: 11 additions & 6 deletions src/CuiSharp.Templates/CuiSharp.Mini/MiniApp.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#:package CuiSharp@__CUISHARP_VERSION__

// This file is the whole app: no project, no solution, nothing to set up.
// This file and its .ico are the whole app: no project, no solution, nothing to set up.
//
// Start it
// dotnet run MiniApp.cs
Expand All @@ -10,17 +10,22 @@
//
// Ship it
// dotnet publish MiniApp.cs -c Release -o .
// One native executable, MiniApp.exe, beside this file — about 1.1 MB, nothing else in the
// folder, and no .NET runtime on the machine that runs it. Without -o it lands in
// One native executable, MiniApp.exe, beside this file — about 1.2 MB, just the exe beside
// the source, and no .NET runtime on the machine that runs it. Without -o it lands in
// artifacts/MiniApp/ instead, which keeps the source folder clean.
// A publish also trims the binary, and that includes the channel `dotnet cui` drives an app
// with: add -p:CuiSharpHeadless=true if a tool should inspect the published exe.
//
// The five properties below are what makes that binary small: no ICU tables (this app formats the
// invariant way), size rather than speed in the generated code, and no debug symbols — that last
// pair is the difference between one file and an exe next to a six-megabyte .pdb.
// The properties below: OutputType=WinExe makes the exe a GUI-subsystem binary, so the
// published app opens its window without a console behind it; ApplicationIcon embeds the .ico
// beside this file, so replacing MiniApp.ico with your own and rebuilding changes the exe and
// window icon. The rest make that binary small: no ICU tables (this app formats the invariant
// way), size rather than speed in the generated code, and no debug symbols — that last pair is
// the difference between one file and an exe next to a six-megabyte .pdb.
// 'dotnet new cui-app' sets the same ones in its project file, for the same reasons.
#:property PublishAot=true
#:property OutputType=WinExe
#:property ApplicationIcon=MiniApp.ico
#:property OptimizationPreference=Size
#:property InvariantGlobalization=true
#:property StripSymbols=true
Expand Down
Binary file added src/CuiSharp.Templates/CuiSharp.Mini/MiniApp.ico
Binary file not shown.
2 changes: 2 additions & 0 deletions src/CuiSharp/CuiApp.cs
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ public Ui Ui
var app = new CuiApp(handle, options);
app.Window.FileDialog = options.FileDialog;
LiveResize.Install(handle == 0 ? 0 : app.NativeWindow, options.NoVsync);
WindowIcon.Install(app.NativeWindow);
options.WindowCreated?.Invoke(app.Window);
return app;
}
Expand Down Expand Up @@ -339,6 +340,7 @@ internal void Hooking(nint ui)
WindowHandle = Native.AppNativeWindow(app);
Window = new CuiWindow(app) { FileDialog = Options.FileDialog };
LiveResize.Install(WindowHandle, Options.NoVsync);
WindowIcon.Install(WindowHandle);
Options.WindowCreated?.Invoke(Window);
if (Features.Live) Live?.Bind(app, ui, WindowHandle);
}
Expand Down
57 changes: 57 additions & 0 deletions src/CuiSharp/Hosting/WindowIcon.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// Hand-written. Puts the icon the .NET apphost embeds — the one ApplicationIcon points at — into
// the title bar and the taskbar button.
using CuiSharp.Interop;

namespace CuiSharp.Hosting;

internal static class WindowIcon
{
// The .NET apphost embeds the ApplicationIcon as RT_GROUP_ICON 32512, which is IDI_APPLICATION.
// The CUI window class asks for group 1 and, not finding it, falls back to the icon a NULL
// instance loads: the system's generic application logo. The embedded icon then shows on the
// exe file in Explorer but not on the window — Explorer reads the resource, the shell's window
// decorations ask the window. WM_SETICON is the call only managed code that knows the id can make.
private const int IdiApplication = 32512;
private const int ImageIcon = 1;
private const int SmCxIcon = 11;
private const int SmCyIcon = 12;
private const int SmCxSmIcon = 49;
private const int SmCySmIcon = 50;
private const int IconSmall = 0;
private const int IconBig = 1;

// Process-lifetime copies, loaded once and reused by every window: WM_SETICON does not take
// ownership of an icon, so destroying one on window close would strand the other windows, and
// two handles that live until the process exits are the honest alternative.
private static nint _large;
private static nint _small;
private static bool _tried;

/// <summary>Applies the host executable's icon to a top-level window. Does nothing when the
/// host embeds none, leaving the class icon — the system default — in place.</summary>
internal static void Install(nint hwnd)
{
if (!OperatingSystem.IsWindows() || hwnd == 0)
{
return;
}

if (!_tried)
{
_tried = true;
nint module = Win32.GetModuleHandle(null);
_large = Win32.LoadIcon(module, IdiApplication, ImageIcon,
Win32.GetSystemMetrics(SmCxIcon), Win32.GetSystemMetrics(SmCyIcon), 0);
_small = Win32.LoadIcon(module, IdiApplication, ImageIcon,
Win32.GetSystemMetrics(SmCxSmIcon), Win32.GetSystemMetrics(SmCySmIcon), 0);
}

if (_large == 0 && _small == 0)
{
return;
}

_ = Win32.SendMessage(hwnd, Win32.WmSetIcon, IconSmall, _small);
_ = Win32.SendMessage(hwnd, Win32.WmSetIcon, IconBig, _large);
}
}
13 changes: 13 additions & 0 deletions src/CuiSharp/Interop/Win32.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ internal static unsafe partial class Win32
internal const uint WmTimer = 0x0113;
internal const uint WmPaint = 0x000F;
internal const uint WmClose = 0x0010;
internal const uint WmSetIcon = 0x0080;
internal const uint WmNcDestroy = 0x0082;
internal const uint WmApp = 0x8000;
internal const uint WmEnterSizeMove = 0x0231;
Expand Down Expand Up @@ -82,6 +83,18 @@ internal static unsafe partial class Win32
[LibraryImport("kernel32.dll", EntryPoint = "GetModuleHandleW", StringMarshalling = StringMarshalling.Utf16)]
internal static partial nint GetModuleHandle(string? name);

/// <summary>HICON LoadImageW(HINSTANCE, LPCWSTR, IMAGE_ICON, int, int, UINT).</summary>
[LibraryImport("user32.dll", EntryPoint = "LoadImageW", SetLastError = true)]
internal static partial nint LoadIcon(nint module, nint name, int imageType, int width, int height, uint flags);

/// <summary>int GetSystemMetrics(int).</summary>
[LibraryImport("user32.dll", EntryPoint = "GetSystemMetrics")]
internal static partial int GetSystemMetrics(int index);

/// <summary>LRESULT SendMessageW(HWND, UINT, WPARAM, LPARAM).</summary>
[LibraryImport("user32.dll", EntryPoint = "SendMessageW")]
internal static partial nint SendMessage(nint hwnd, uint message, nint wparam, nint lparam);

/// <summary>FARPROC GetProcAddress(HMODULE, LPCSTR).</summary>
[LibraryImport("kernel32.dll", EntryPoint = "GetProcAddress")]
internal static partial nint GetProcAddress(nint module, [MarshalAs(UnmanagedType.LPStr)] string name);
Expand Down
Loading
Loading