Skip to content

feat(windows): scaffold WinUI 3 app shell consuming libghostty#156

Merged
deblasis merged 3 commits intowindowsfrom
feat/winui3-shell
Apr 6, 2026
Merged

feat(windows): scaffold WinUI 3 app shell consuming libghostty#156
deblasis merged 3 commits intowindowsfrom
feat/winui3-shell

Conversation

@deblasis
Copy link
Copy Markdown
Owner

@deblasis deblasis commented Apr 6, 2026

First pass at a native Windows shell for Ghostty, mirroring how macos/ consumes libghostty. Adds windows/Ghostty/ as a WinUI 3 unpackaged desktop app that hosts a single libghostty surface via the DX12 composition path (SwapChainPanel, null HWND).

What's in place

  • Ghostty.sln + Ghostty.csproj — net9.0-windows, unpackaged. Copies ghostty.dll from zig-out/lib into a native/ subdirectory to avoid collision with the managed Ghostty.dll on case-insensitive filesystems. NativeLibrary.SetDllImportResolver loads it at startup.
  • Directory.Build.props — redirects AppxMSBuildToolsPath to VS Build Tools so dotnet build from the CLI can find the PRI task DLL (WindowsAppSDK#3997).
  • global.json — pins .NET 9 SDK. .NET 10 is installed and breaks the WinUI 3 build targets.
  • app.manifest — PerMonitorV2 DPI, longPathAware, UTF-8 code page.
  • Interop/NativeMethods.cs — P/Invoke covering init, config lifecycle, app lifecycle, surface lifecycle, size/scale/focus, key/text/mouse. Incremental subset of ghostty.h sorted in header order for easy drift detection on rebases. Runtime config callbacks held in fields so the GC does not free them while libghostty holds the function pointers.
  • Interop/ISwapChainPanelNative.cs — COM interop. QueryInterface on the WinUI 3 SwapChainPanel returns the ISwapChainPanelNative v-table pointer, which is what libghostty's DX12 renderer calls SetSwapChain on (slot 3). Passing the raw IUnknown crashes inside the renderer.
  • App.xaml, MainWindow.xaml — Mica backdrop, default title bar (custom title bar is a follow-up PR).
  • Controls/TerminalControl.xaml(.cs) — SwapChainPanel + input routing. Fills working_directory/command/initial_input with an empty C string (not null) because Zig crashes in surface_new otherwise. Resize is debounced on a 30ms DispatcherQueueTimer because the DX12 renderer recreates the swap chain on every set_size, which can't keep up with WinUI 3's per-pixel drag events. CharacterReceived filters C0 control chars so Ctrl+key combinations don't double up (same fix as the Win32 example in 8dde86d).

just recipes

  • `just build-win` — dotnet build the shell alone.
  • `just run-win` — zig build the DLL, dotnet build the shell, launch it.

First-boot status

Opens a Mica window, creates the surface, spawns cmd.exe, DX12 renders, title updates, resize no longer crashes.

Known issues to address in follow-up stacked PRs:

  • Focus flaps between the UserControl and the inner SwapChainPanel
  • Terminal does not pixel-perfect fill the window (swap chain is sized correctly, visual clipping to investigate)
  • VirtualKey is passed as the raw Keycode instead of a translated GhosttyKey

Not included yet (intentional)

Tabs, splits, custom title bar, settings UI, clipboard, bell, shell integration, dispatcher for ghostty_action callbacks, full VirtualKey -> GhosttyKey mapping, and about 70 of the 88 functions in ghostty.h. Each unlocks its own small PR.

Adds windows/Ghostty/, a WinUI 3 unpackaged desktop app that hosts a
single libghostty surface via the DX12 composition path (SwapChainPanel,
null HWND). Mirrors the macos/ layout: native shell in this repo,
consuming the ghostty.dll produced by zig build -Dapp-runtime=none.

What's in place:
- Ghostty.sln + Ghostty.csproj: net9.0-windows, x64/ARM64, unpackaged,
  copies ghostty.dll from zig-out/lib into a native/ subdirectory to
  avoid collision with the managed Ghostty.dll on case-insensitive
  filesystems. NativeLibrary.SetDllImportResolver loads it at startup.
- Directory.Build.props: redirects AppxMSBuildToolsPath to VS Build
  Tools so 'dotnet build' from the CLI can find the PRI task DLL
  (see WindowsAppSDK#3997).
- global.json: pins .NET 9 SDK (10 is installed too, breaks the
  WinUI 3 build targets).
- app.manifest: PerMonitorV2 DPI, longPathAware, UTF-8 code page.
- Interop/NativeMethods.cs: P/Invoke covering init, config lifecycle,
  app lifecycle, surface lifecycle, size/scale/focus, key/text/mouse.
  Incremental subset of ghostty.h sorted in header order for easy
  drift detection. Runtime config callbacks held in fields so the
  GC does not free them while libghostty holds function pointers.
- Interop/ISwapChainPanelNative.cs: COM interop. QueryInterface on
  the WinUI 3 SwapChainPanel returns the ISwapChainPanelNative
  v-table pointer, which is what libghostty's DX12 renderer calls
  SetSwapChain on (slot 3) - passing the raw IUnknown crashes inside
  the renderer.
- App.xaml, MainWindow.xaml with Mica backdrop (default title bar
  for this first pass; custom title bar is a follow-up PR).
- Controls/TerminalControl.xaml(.cs): SwapChainPanel + input routing.
  Fills working_directory/command/initial_input with an empty C
  string (not null) because Zig would otherwise crash in surface_new.
  Resize is debounced on a 30ms DispatcherQueueTimer because the DX12
  renderer recreates the swap chain on every set_size call, which
  can't keep up with WinUI 3's per-pixel drag events. CharacterReceived
  filters C0 control chars so Ctrl+key combinations don't double up
  (mirrors the Win32 example fix in 8dde86d).

just recipes:
- just build-win: dotnet build the shell alone.
- just run-win: zig build the DLL, dotnet build the shell, launch it.

First-boot status (captured for the follow-up stack):
- Opens a Mica window, creates the surface, spawns cmd.exe, DX12
  renders, title updates, resize no longer crashes.
- Known issues: focus flaps between the control and the panel,
  terminal doesn't pixel-perfect fill the window, VirtualKey is
  passed as the raw Keycode instead of a translated GhosttyKey.
  Each becomes its own small stacked PR.

Not included yet (intentional):
- Tabs, splits, custom title bar, settings UI, clipboard, bell,
  shell integration, dispatcher for ghostty_action callbacks,
  full VirtualKey -> GhosttyKey mapping, ~70 of the 88 functions
  in ghostty.h.
@deblasis deblasis marked this pull request as ready for review April 6, 2026 06:21
- Release ISwapChainPanelNative* after ghostty_surface_new returns.
  The DX12 device only uses it synchronously (SetSwapChain), so
  holding our AddRef was a leak per open/close cycle.
- Split the aliased empty-UTF8 buffer into three independent
  allocations for working_directory / command / initial_input.
- OnWakeup: capture app handle + DispatcherQueue locally before
  enqueue and re-verify on UI thread to avoid a race with Unloaded.
- OnAction: return false so the core falls back to its own defaults
  instead of silently swallowing every action.
- SendKey: pass the native Win32 scancode (KeyStatus.ScanCode) as
  the keycode, which is what embedded.zig's keycodes table matches
  against on Windows. VirtualKey was wrong.
- Drop the now-redundant C0 filter in OnCharacterReceived; the
  embedded apprt handles key+text combining at comptime.
- CurrentMods: distinguish L/R shift, ctrl, alt (AltGr), win; set
  the *Right flags; also report Caps and Num.
- Resize timer: subscribe Tick once instead of -=/+= per event.
- App.xaml.cs: prefer AppContext.BaseDirectory in the DllImport
  resolver so single-file publish and Native AOT still find
  native/ghostty.dll.
- Delete the empty GhosttyKey stub enum; scancode path does not
  need a translation table on the C# side.

Signed-off-by: Alessandro De Blasis <alex@deblasis.net>
- Gate Mica backdrop on MicaController.IsSupported so Win10 hosts get
  the default backdrop instead of a transparent black window.
- Stop and detach the resize debounce timer in OnUnloaded so a pending
  tick cannot fire after teardown and the timer does not pin the control.
- Drop CompositionScale multiplication from OnPointerMoved: embedded.zig
  cursorPosCallback runs the input through cursorPosToPixels, so feeding
  it pixel coords double-scales on high DPI displays.
- Pin GhosttyTarget layout explicitly (Size=16, FieldOffset 0/8) so the
  ABI cannot drift if the union ever gains a wider variant.
@deblasis deblasis merged commit d692633 into windows Apr 6, 2026
@deblasis deblasis deleted the feat/winui3-shell branch April 6, 2026 15:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant