WinUI Added 200 MB, So I Built My Own Windows UI Toolkit

Termy runs over games, videos, and other desktop apps, so much of its code sits close to the operating system. It uses global shortcuts and screen capture. It draws transparent overlays, accepts gamepad input, and recognizes text locally. Its windows need to appear when they are useful and stay out of the way the rest of the time.

I wanted the Windows download to stay small and self-contained. I also wanted the Windows and macOS versions to feel at home on their own systems. Those two decisions eventually left me maintaining a custom Windows UI framework. I did not start with a plan to write one. I started with a few controls in an installer that I did not want to copy.

The library is called Slim UI. It has grown to about 15,000 lines across 30 headers, with around 150 regression tests. That still looks absurd when I write it down. But those lines replaced a real set of tradeoffs, and the trade has mostly held up.

Why I walked away from the obvious choices

WinUI was the first serious option for Windows. It had modern controls, accessibility support, and the advantage of being Microsoft's own UI framework. I made a small test using the deployment model Termy needed: a self-contained direct download that could start on a clean machine. That build gained roughly 200 MB before I added Termy's OCR models, dictionaries, images, or application code.

The number needs some context. It came from the build I tested; it does not mean that every WinUI app downloads 200 MB. WinUI 3 has several deployment models. Microsoft's Windows App SDK deployment guide explains that framework-dependent apps can use a shared runtime, while self-contained apps carry the Windows App SDK files with them. The shared route makes sense through the Microsoft Store or when an installer manages the prerequisite. Termy's direct installer had to work by itself, so I cared about the self-contained result.

That result was hard to accept. Termy's current compressed Windows installer is about 41 MB, and that already includes local text-recognition models. The UI framework alone would have been several times larger than the product I was trying to ship. WinUI would have saved development time, but it would also have made a small utility look oddly large before the user even ran it.

The other candidates were multiplatform UI frameworks. Sharing the view layer sounded useful because I was building both desktop clients. In practice, the previews felt slightly foreign on both systems. The problem was bigger than a wrong color or corner radius. Windows and macOS have different habits around navigation, window chrome, focus, menus, keyboard input, scrolling, dialogs, and motion.

I could have tuned a shared interface until it looked closer to each platform. Every fix would add another Windows or macOS branch inside the supposedly shared layer. I would still own the platform work, only now I would do it through someone else's layout and event rules. Termy already needed separate code for capture and overlays, so sharing the view tree was not as valuable as it first seemed.

I accepted that the two clients would have separate interfaces. The macOS app uses Swift and Apple frameworks. Windows uses C++, Win32, Direct2D, and DirectWrite. They save the same settings and follow the same backend contracts, but they do not need to arrange every screen in the same way.

Slim UI began as code I did not want to copy

The first controls lived in Termy's Windows installer. There were only a few buttons and fields, but they still had to work from the keyboard and scale correctly on a high-DPI display. I drew them directly with Direct2D and DirectWrite. Later, the main app needed many of the same pieces. Copying the code would have been quick, and then the two versions would have started drifting apart.

So I pulled the common parts into a library. Early Slim UI had a renderer, a small layout helper, color schemes, buttons, and a handful of controls. It stayed in plain C because the installer did not need the rest of Termy's C++ application. The library uses the familiar single-header pattern: include the declarations where needed, then compile the implementation in one translation unit.

The name still fit at that point. Then the settings window needed text editing, dropdowns, sliders, navigation, tooltips, and shortcut recording. The vocabulary window brought large lists, scrolling, tabs, and embedded web content. Resizing those windows found mistakes in the minimum-size calculations. Moving them between monitors found the DPI mistakes.

By then Slim UI was doing much more than drawing. It had to calculate layouts, find the control under the pointer, move keyboard focus, manage popups, and expose a UI Automation tree to screen readers. Exact placement works well for unusual windows, while a second API handles stacks and flexible sizing on ordinary pages. Each window owns its Direct2D and DirectWrite resources. Controls live in a fixed-size array, so a normal button or label does not need its own heap allocation.

The heavier parts are optional. A small consumer can leave out the declarative layout engine or the vsync animation pump. Slim UI can use the Windows process heap instead of the C runtime, which lets the installer remain CRT-free. It has no third-party runtime beyond Windows.

The scope kept growing. There is now a small .NET wrapper and a managed demo, mostly to check whether the C interface works cleanly for another consumer. The wrapper did not make Termy cross-platform or remove the cost of the native code. It simply made the label unavoidable. Slim UI had become a framework.

Slim UI demo window showing navigation, buttons, a progress bar, a slider, and appearance controls View larger
The Slim UI demo keeps the controls in one place so I can check layout, input, scaling, and appearance without opening the full Termy app.

It still does not render every part of Termy. The transparent language overlay has its own Direct2D and Direct3D paths because it has to sit above games without taking focus, and sometimes it draws inside the game renderer itself. Slim UI serves the installer and the ordinary Windows screens: settings, vocabulary, and dialogs. I have kept that boundary rather than forcing two very different rendering problems through one abstraction.

The hard part was never drawing a button

A rectangle, some text, and a click handler can look like a finished button in an afternoon. Then someone reaches for the Tab key. Or moves the window to a monitor at a different scale. Or opens a screen reader. The painted rectangle is the easy part.

The combobox has been the clearest example. Its dropdown lives in a separate popup window. That popup must stay inside the current monitor, and it has to open above the field when there is no room below. The selected row should stay visible. Scrolling should not leave half an item at the edge. Keyboard input, focus changes, and accessibility all have their own rules. Only after those work do shadows and animation matter, and even those can produce a blank first frame or a stale highlight.

Text editing has a different collection of traps. Selection, clipboard commands, undo, cursor movement, word boundaries, and the blinking caret all need state. That state has to survive a layout rebuild. Tabs must stop sending input to a page while it slides away. Tooltips need their own DPI-aware popup. The shortcut recorder must recognize modifier keys, mouse buttons, and gamepad buttons without leaving the user stuck inside it.

I knew accessibility had to be implemented explicitly, and I treated it as part of building the controls rather than a later polish pass. A custom button is invisible to a screen reader until the application provides a UI Automation tree. A list does not expose the same behavior as an edit field or a slider. Focus changes also need events; drawing a new border is not enough.

The tests grew after the bugs. There are tests for layout math, popup placement, control state, minimum sizes, undo, tab animation, and many smaller promises that are easy to break. A demo window exercises every control because a test cannot tell me that a dropdown feels wrong or a hover flickers. The installer and the real Termy windows are the final check.

And I own all of it. There is no upstream update that will fix a popup on an unusual monitor arrangement. If I need a new control, I cannot install a mature package and move on. An evening spent tracing focus or clipping is an evening not spent on OCR, vocabulary practice, or onboarding.

This is also why I do not want Slim UI to become a universal framework. Its fixed limits and direct Win32 message handling suit Termy's small windows. They would be strange defaults for a document editor. The narrow scope is useful.

The trade bought control, not development speed

Slim UI did not save time. I moved the cost out of the download and into code that I maintain. For many products, especially an idea that still needs to find its first users, that would be a bad exchange.

Termy keeps getting practical value from it. The installer remains a small, ordinary executable, and the Windows app has no separate third-party UI runtime. I can follow rendering, input, memory, and cleanup through code inside the project. The keyboard and gamepad shortcut recorder behaves like part of Termy because it was written for Termy.

The two clients are also allowed to disagree. Windows can use Windows navigation and spacing. The macOS version can present the same setting in Swift in the way a Mac app normally would. They still save the same value. Product parity does not require matching screenshots.

The choice also fits the kind of application Termy became. It runs for long periods, so idle work and memory matter. It deals with fullscreen software and unusual window arrangements. The set of normal controls is large enough to reuse, but small enough that I can still test it. I expect to maintain the app long enough to keep using the work.

I would choose an existing framework for a dashboard, database client, or internal tool. Shipping sooner would matter more than trimming the runtime or matching every platform habit. I would also stop immediately if the product needed rich editors, large data grids, or a wide ecosystem of third-party controls. Rebuilding those would swallow the application.

A native toolkit made sense here because several constraints arrived together: deep operating-system integration, a long product life, strict resource limits, and a control set with a clear boundary. Take away a couple of those constraints and the calculation changes. Even here, Slim UI grew from finished screens. I never tried to design every control that Termy might need one day.

If I started Termy again, I would still build separate native interfaces. I would also extract the installer controls rather than copy them. I would just be less surprised when a small helper library turned into a framework. Once you own a control, you inherit all the old problems hidden behind it.

You can see the result in Termy's Windows installer, settings, vocabulary screens, and dialogs. Termy is free to try on Windows and macOS.

Related: how Termy improved FSRS for vocabulary found in real content.

Termy explains difficult words on your screen and keeps them connected to the context where you found them.

Try Termy Free
Back to all articles