diff --git a/build.zig.zon b/build.zig.zon index 58ea0a4f8..d6b38e31f 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -61,6 +61,12 @@ .hash = "gobject-0.3.0-Skun7ANLnwDvEfIpVmohcppXgOvg_I6YOJFmPIsKfXk-", .lazy = true, }, + .win32 = .{ + // marlersoft/zigwin32 -- Win32 API bindings for the win32 apprt. + .url = "git+https://github.com/marlersoft/zigwin32#ec98bb4d9eea532320a8551720a9e3ec6de64994", + .hash = "win32-25.0.28-preview-mX5pFWMt5QPTVIGh3r2-OpPunpcCCjApyRbA6Zn6WALH", + .lazy = true, + }, // C libs .dcimgui = .{ .path = "./pkg/dcimgui", .lazy = true }, diff --git a/src/apprt.zig b/src/apprt.zig index c467f1801..ceec17ce9 100644 --- a/src/apprt.zig +++ b/src/apprt.zig @@ -16,6 +16,7 @@ pub const action = @import("apprt/action.zig"); pub const ipc = @import("apprt/ipc.zig"); pub const gtk = @import("apprt/gtk.zig"); pub const none = @import("apprt/none.zig"); +pub const win32 = @import("apprt/win32.zig"); pub const browser = @import("apprt/browser.zig"); pub const embedded = @import("apprt/embedded.zig"); pub const surface = @import("apprt/surface.zig"); @@ -43,6 +44,7 @@ pub const runtime = switch (build_config.artifact) { .exe => switch (build_config.app_runtime) { .none => none, .gtk => gtk, + .win32 => win32, }, .lib => embedded, .wasm_module => browser, diff --git a/src/apprt/runtime.zig b/src/apprt/runtime.zig index 1be503536..3d88364b2 100644 --- a/src/apprt/runtime.zig +++ b/src/apprt/runtime.zig @@ -11,11 +11,17 @@ pub const Runtime = enum { /// approach to building the application. gtk, + /// Win32. Native Windows application using the Win32 API with OpenGL + /// rendering. + win32, + pub fn default(target: std.Target) Runtime { return switch (target.os.tag) { // The Linux and FreeBSD default is GTK because it is a full // featured application. .linux, .freebsd => .gtk, + // Windows uses the native Win32 API. + .windows => .win32, // Otherwise, we do NONE so we don't create an exe and we create // libghostty. On macOS, Xcode is used to build the app that links // to libghostty. diff --git a/src/apprt/structs.zig b/src/apprt/structs.zig index 2c37dbd5e..215668278 100644 --- a/src/apprt/structs.zig +++ b/src/apprt/structs.zig @@ -54,7 +54,7 @@ pub const Clipboard = enum(Backing) { .{ .name = "GhosttyApprtClipboard" }, ), - .none => void, + .none, .win32 => void, }; }; diff --git a/src/apprt/win32.zig b/src/apprt/win32.zig new file mode 100644 index 000000000..7563f1405 --- /dev/null +++ b/src/apprt/win32.zig @@ -0,0 +1,8 @@ +// The required comptime API for any apprt. +pub const App = @import("win32/App.zig"); +pub const Surface = @import("win32/Surface.zig"); +pub const resourcesDir = @import("../os/main.zig").resourcesDir; + +test { + @import("std").testing.refAllDecls(@This()); +} diff --git a/src/apprt/win32/App.zig b/src/apprt/win32/App.zig new file mode 100644 index 000000000..d334f4a8c --- /dev/null +++ b/src/apprt/win32/App.zig @@ -0,0 +1,294 @@ +/// Win32 application runtime for Ghostty. This is a minimal native Windows +/// application using the Win32 API with OpenGL rendering. +const App = @This(); + +const std = @import("std"); +const builtin = @import("builtin"); +const win32 = @import("win32").everything; +const Allocator = std.mem.Allocator; +const apprt = @import("../../apprt.zig"); +const configpkg = @import("../../config.zig"); +const Config = configpkg.Config; +const CoreApp = @import("../../App.zig"); +const CoreSurface = @import("../../Surface.zig"); +const Surface = @import("Surface.zig"); +const renderer = @import("../../renderer.zig"); + +const log = std.log.scoped(.win32); + +/// User-defined wakeup message sent via PostMessage to break out of +/// GetMessage and run the core app's tick. +const WM_WAKEUP = win32.WM_USER + 1; + +/// The core app instance. +core_app: *CoreApp, + +/// The configuration. +config: *Config, + +/// The allocator. +alloc: Allocator, + +/// Whether the app is running. +running: bool = true, + +/// The main window handle. +hwnd: ?win32.HWND = null, + +/// The surface for the main window. +surface: Surface = undefined, + +pub fn init( + self: *App, + core_app: *CoreApp, + opts: struct {}, +) !void { + _ = opts; + + const alloc = core_app.alloc; + + // Load configuration + var config = try Config.load(alloc); + errdefer config.deinit(); + + const config_ptr = try alloc.create(Config); + config_ptr.* = config; + + self.* = .{ + .core_app = core_app, + .config = config_ptr, + .alloc = alloc, + }; + + // Create the main window + try self.createWindow(); + + // Initialize the surface with OpenGL + try self.surface.init(self.hwnd.?); + + // Store self pointer in window for use in wndProc. SetWindowLongPtrW + // returns the previous value, which for a freshly created window is 0; + // we don't care about it here. + _ = win32.SetWindowLongPtrW( + self.hwnd.?, + win32.GWLP_USERDATA, + @bitCast(@intFromPtr(self)), + ); + + // Initialize the core surface (terminal emulation + rendering) + try self.initCoreSurface(); +} + +pub fn run(self: *App) !void { + log.info("starting Win32 event loop", .{}); + + while (self.running) { + var msg: win32.MSG = std.mem.zeroes(win32.MSG); + const ret = win32.GetMessageW(&msg, null, 0, 0); + if (ret == 0) { + // WM_QUIT + self.running = false; + break; + } + if (ret == -1) { + log.err("GetMessage failed: err={d}", .{@intFromEnum(win32.GetLastError())}); + return error.Win32Error; + } + _ = win32.TranslateMessage(&msg); + _ = win32.DispatchMessageW(&msg); + } +} + +pub fn terminate(self: *App) void { + self.surface.deinit(); + if (self.hwnd) |hwnd| { + if (win32.DestroyWindow(hwnd) == 0) { + log.warn("DestroyWindow failed: err={d}", .{@intFromEnum(win32.GetLastError())}); + } + self.hwnd = null; + } + self.config.deinit(); + self.alloc.destroy(self.config); +} + +pub fn wakeup(self: *App) void { + if (self.hwnd) |hwnd| { + if (win32.PostMessageW(hwnd, WM_WAKEUP, 0, 0) == 0) { + log.warn("PostMessage(WM_WAKEUP) failed: err={d}", .{@intFromEnum(win32.GetLastError())}); + } + } +} + +pub fn performAction( + self: *App, + target: apprt.Target, + comptime action: apprt.Action.Key, + value: apprt.Action.Value(action), +) !bool { + _ = self; + _ = target; + _ = value; + + switch (action) { + .quit => { + win32.PostQuitMessage(0); + return true; + }, + .new_window => { + // TODO: implement multiple windows + return false; + }, + else => return false, + } +} + +pub fn performIpc( + _: Allocator, + _: apprt.ipc.Target, + comptime action: apprt.ipc.Action.Key, + _: apprt.ipc.Action.Value(action), +) !bool { + return false; +} + +pub fn redrawInspector(_: *App, surface: *Surface) void { + surface.redrawInspector(); +} + +fn initCoreSurface(self: *App) !void { + const alloc = self.alloc; + + // Set the app pointer on the surface + self.surface.app = self; + + // Create the core surface + const core_surface = try alloc.create(CoreSurface); + errdefer alloc.destroy(core_surface); + + // Register with the core app + try self.core_app.addSurface(&self.surface); + errdefer self.core_app.deleteSurface(&self.surface); + + // Create a surface config + var config = try apprt.surface.newConfig( + self.core_app, + self.config, + .window, + ); + defer config.deinit(); + + // Initialize the core surface + core_surface.init( + alloc, + &config, + self.core_app, + self, + &self.surface, + ) catch |err| { + log.err("failed to initialize core surface: {}", .{err}); + return err; + }; + + self.surface.core_surface = core_surface; + log.info("core surface initialized successfully", .{}); +} + +fn createWindow(self: *App) !void { + const class_name = win32.L("GhosttyWindow"); + const hinstance = win32.GetModuleHandleW(null); + + const wc: win32.WNDCLASSEXW = .{ + .cbSize = @sizeOf(win32.WNDCLASSEXW), + .style = .{ .HREDRAW = 1, .VREDRAW = 1, .OWNDC = 1 }, + .lpfnWndProc = wndProc, + .cbClsExtra = 0, + .cbWndExtra = 0, + .hInstance = hinstance, + .hIcon = null, + .hCursor = win32.LoadCursorW(null, win32.IDC_ARROW), + .hbrBackground = null, + .lpszMenuName = null, + .lpszClassName = class_name, + .hIconSm = null, + }; + + if (win32.RegisterClassExW(&wc) == 0) { + log.err("RegisterClassExW failed: err={d}", .{@intFromEnum(win32.GetLastError())}); + return error.Win32Error; + } + + const title = win32.L("Ghostty"); + + self.hwnd = win32.CreateWindowExW( + .{}, + class_name, + title, + win32.WS_OVERLAPPEDWINDOW, + win32.CW_USEDEFAULT, + win32.CW_USEDEFAULT, + 800, + 600, + null, + null, + hinstance, + null, + ) orelse { + log.err("CreateWindowExW failed: err={d}", .{@intFromEnum(win32.GetLastError())}); + return error.Win32Error; + }; + + _ = win32.ShowWindow(self.hwnd.?, win32.SW_SHOWNORMAL); + _ = win32.UpdateWindow(self.hwnd.?); +} + +fn getApp(hwnd: win32.HWND) ?*App { + const ptr = win32.GetWindowLongPtrW(hwnd, win32.GWLP_USERDATA); + if (ptr == 0) return null; + return @ptrFromInt(@as(usize, @bitCast(ptr))); +} + +fn wndProc(hwnd: win32.HWND, msg: u32, wparam: win32.WPARAM, lparam: win32.LPARAM) callconv(.winapi) win32.LRESULT { + switch (msg) { + win32.WM_CLOSE => { + win32.PostQuitMessage(0); + return 0; + }, + win32.WM_SIZE => { + if (getApp(hwnd)) |app| { + const width: u32 = @intCast(lparam & 0xFFFF); + const height: u32 = @intCast((lparam >> 16) & 0xFFFF); + if (width > 0 and height > 0) { + app.surface.width = width; + app.surface.height = height; + if (app.surface.core_surface) |core| { + core.sizeCallback(.{ + .width = width, + .height = height, + }) catch |err| { + log.err("size callback error: {}", .{err}); + }; + } + } + } + return 0; + }, + win32.WM_PAINT => { + var ps: win32.PAINTSTRUCT = std.mem.zeroes(win32.PAINTSTRUCT); + _ = win32.BeginPaint(hwnd, &ps); + if (getApp(hwnd)) |app| { + app.surface.swapBuffers(); + } + _ = win32.EndPaint(hwnd, &ps); + return 0; + }, + WM_WAKEUP => { + if (getApp(hwnd)) |app| { + app.core_app.tick(app) catch |err| { + log.err("core app tick failed: {}", .{err}); + }; + } + return 0; + }, + else => return win32.DefWindowProcW(hwnd, msg, wparam, lparam), + } +} diff --git a/src/apprt/win32/Surface.zig b/src/apprt/win32/Surface.zig new file mode 100644 index 000000000..45b37e645 --- /dev/null +++ b/src/apprt/win32/Surface.zig @@ -0,0 +1,192 @@ +/// Win32 surface - represents a terminal surface within a window. +/// Manages the WGL OpenGL context and provides the interface +/// expected by CoreSurface. +const Self = @This(); + +const std = @import("std"); +const win32 = @import("win32").everything; +const Allocator = std.mem.Allocator; +const apprt = @import("../../apprt.zig"); +const configpkg = @import("../../config.zig"); +const CoreSurface = @import("../../Surface.zig"); +const CoreApp = @import("../../App.zig"); + +const log = std.log.scoped(.win32_surface); + +/// The window this surface belongs to. +hwnd: win32.HWND, + +/// Pointer back to the App. +app: ?*App = null, + +/// GDI device context. +hdc: ?win32.HDC = null, + +/// OpenGL rendering context. +hglrc: ?win32.HGLRC = null, + +/// The core surface, if initialized. +core_surface: ?*CoreSurface = null, + +/// Window dimensions. +width: u32 = 800, +height: u32 = 600, + +const App = @import("App.zig"); + +pub fn core(self: *Self) *CoreSurface { + return self.core_surface.?; +} + +pub fn rtApp(self: *Self) *App { + return self.app.?; +} + +pub fn init(self: *Self, hwnd: win32.HWND) !void { + self.* = .{ .hwnd = hwnd }; + try self.initOpenGL(); +} + +pub fn deinit(self: *Self) void { + if (self.core_surface) |surface| { + surface.deinit(); + // core_surface is allocated by CoreApp, freed there + } + if (self.hglrc) |hglrc| { + _ = win32.wglMakeCurrent(null, null); + _ = win32.wglDeleteContext(hglrc); + } + if (self.hdc) |hdc| { + _ = win32.ReleaseDC(self.hwnd, hdc); + } +} + +fn initOpenGL(self: *Self) !void { + self.hdc = win32.GetDC(self.hwnd) orelse { + log.err("GetDC failed: err={d}", .{@intFromEnum(win32.GetLastError())}); + return error.Win32Error; + }; + + var pfd: win32.PIXELFORMATDESCRIPTOR = std.mem.zeroes(win32.PIXELFORMATDESCRIPTOR); + pfd.nSize = @sizeOf(win32.PIXELFORMATDESCRIPTOR); + pfd.nVersion = 1; + pfd.dwFlags = .{ .DRAW_TO_WINDOW = 1, .SUPPORT_OPENGL = 1, .DOUBLEBUFFER = 1 }; + pfd.iPixelType = .RGBA; + pfd.cColorBits = 32; + pfd.cDepthBits = 24; + pfd.cStencilBits = 8; + pfd.iLayerType = .MAIN_PLANE; + + const pixel_format = win32.ChoosePixelFormat(self.hdc, &pfd); + if (pixel_format == 0) { + log.err("ChoosePixelFormat failed: err={d}", .{@intFromEnum(win32.GetLastError())}); + return error.Win32Error; + } + + if (win32.SetPixelFormat(self.hdc, pixel_format, &pfd) == 0) { + log.err("SetPixelFormat failed: err={d}", .{@intFromEnum(win32.GetLastError())}); + return error.Win32Error; + } + + self.hglrc = win32.wglCreateContext(self.hdc) orelse { + log.err("wglCreateContext failed: err={d}", .{@intFromEnum(win32.GetLastError())}); + return error.Win32Error; + }; + + if (win32.wglMakeCurrent(self.hdc, self.hglrc) == 0) { + log.err("wglMakeCurrent failed: err={d}", .{@intFromEnum(win32.GetLastError())}); + return error.Win32Error; + } + + log.info("WGL OpenGL context created successfully", .{}); +} + +pub fn swapBuffers(self: *Self) void { + if (self.hdc) |hdc| { + if (win32.SwapBuffers(hdc) == 0) { + log.warn("SwapBuffers failed: err={d}", .{@intFromEnum(win32.GetLastError())}); + } + } +} + +/// Make the WGL context current on the calling thread. +pub fn makeContextCurrent(self: *Self) void { + if (self.hdc) |hdc| { + if (self.hglrc) |hglrc| { + if (win32.wglMakeCurrent(hdc, hglrc) == 0) { + log.warn("wglMakeCurrent failed: err={d}", .{@intFromEnum(win32.GetLastError())}); + } + } + } +} + +/// Release the WGL context from the calling thread. +pub fn releaseContext() void { + if (win32.wglMakeCurrent(null, null) == 0) { + log.warn("wglMakeCurrent(null) failed: err={d}", .{@intFromEnum(win32.GetLastError())}); + } +} + +/// Release context from the main thread before handing off to renderer thread. +pub fn releaseMainThreadContext(self: *Self) void { + _ = self; + if (win32.wglMakeCurrent(null, null) == 0) { + log.warn("wglMakeCurrent(null) failed: err={d}", .{@intFromEnum(win32.GetLastError())}); + } +} + +// --- Interface methods required by CoreSurface --- + +pub fn getContentScale(_: *const Self) !apprt.ContentScale { + // TODO: query DPI from the monitor + return .{ .x = 1.0, .y = 1.0 }; +} + +pub fn getSize(self: *const Self) !apprt.SurfaceSize { + return .{ + .width = self.width, + .height = self.height, + }; +} + +pub fn getCursorPos(_: *const Self) !apprt.CursorPos { + // TODO: track mouse position + return .{ .x = 0, .y = 0 }; +} + +pub fn getTitle(_: *Self) ?[:0]const u8 { + return null; +} + +pub fn close(_: *Self, _: bool) void { + // TODO: handle close with confirmation +} + +pub fn supportsClipboard(_: *Self, clipboard: apprt.Clipboard) bool { + return clipboard == .standard; +} + +pub fn clipboardRequest( + _: *Self, + _: apprt.Clipboard, + _: apprt.ClipboardRequest, +) !bool { + // TODO: implement clipboard read + return false; +} + +pub fn setClipboard( + _: *Self, + _: apprt.Clipboard, + _: []const apprt.ClipboardContent, + _: bool, +) !void { + // TODO: implement clipboard write +} + +pub fn defaultTermioEnv(_: *Self) !std.process.EnvMap { + // Return an empty env map; the shell will inherit the process env. + return std.process.EnvMap.init(std.heap.page_allocator); +} + +pub fn redrawInspector(_: *Self) void {} diff --git a/src/build/SharedDeps.zig b/src/build/SharedDeps.zig index b68be92d0..b0496baa3 100644 --- a/src/build/SharedDeps.zig +++ b/src/build/SharedDeps.zig @@ -605,6 +605,14 @@ pub fn add( switch (self.config.app_runtime) { .none => {}, .gtk => try self.addGtkNg(step), + .win32 => { + step.linkSystemLibrary2("user32", .{}); + step.linkSystemLibrary2("gdi32", .{}); + step.linkSystemLibrary2("opengl32", .{}); + if (b.lazyDependency("win32", .{})) |dep| { + step.root_module.addImport("win32", dep.module("win32")); + } + }, } } diff --git a/src/config/Config.zig b/src/config/Config.zig index 66b8c6057..d433aca6d 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -4647,7 +4647,7 @@ pub fn finalize(self: *Config) !void { // Apprt-specific defaults switch (build_config.app_runtime) { - .none => {}, + .none, .win32 => {}, .gtk => { switch (self.@"gtk-single-instance") { .true, .false => {}, diff --git a/src/renderer/OpenGL.zig b/src/renderer/OpenGL.zig index 4b01da0c5..c327726ed 100644 --- a/src/renderer/OpenGL.zig +++ b/src/renderer/OpenGL.zig @@ -169,6 +169,11 @@ pub fn surfaceInit(surface: *apprt.Surface) !void { apprt.gtk, => try prepareContext(null), + // Win32: WGL context is already current, load via null (GLAD + // uses opengl32.dll + wglGetProcAddress automatically). + apprt.win32, + => try prepareContext(null), + apprt.embedded => { // TODO(mitchellh): this does nothing today to allow libghostty // to compile for OpenGL targets but libghostty is strictly @@ -190,48 +195,39 @@ pub fn surfaceInit(surface: *apprt.Surface) !void { /// thread for final main thread setup requirements. pub fn finalizeSurfaceInit(self: *const OpenGL, surface: *apprt.Surface) !void { _ = self; - _ = surface; + + switch (apprt.runtime) { + apprt.win32 => { + // Release the WGL context from the main thread so the + // renderer thread can acquire it. + surface.releaseMainThreadContext(); + }, + else => {}, + } } /// Callback called by renderer.Thread when it begins. pub fn threadEnter(self: *const OpenGL, surface: *apprt.Surface) !void { _ = self; - _ = surface; - switch (apprt.runtime) { - else => @compileError("unsupported app runtime for OpenGL"), - - apprt.gtk => { - // GTK doesn't support threaded OpenGL operations as far as I can - // tell, so we use the renderer thread to setup all the state - // but then do the actual draws and texture syncs and all that - // on the main thread. As such, we don't do anything here. - }, - - apprt.embedded => { - // TODO(mitchellh): this does nothing today to allow libghostty - // to compile for OpenGL targets but libghostty is strictly - // broken for rendering on this platforms. - }, + if (apprt.runtime == apprt.win32) { + // Win32: make the WGL context current on this thread and + // reload GL function pointers. + surface.makeContextCurrent(); + try prepareContext(null); } + // GTK and embedded don't need thread-specific GL setup. } /// Callback called by renderer.Thread when it exits. pub fn threadExit(self: *const OpenGL) void { _ = self; - switch (apprt.runtime) { - else => @compileError("unsupported app runtime for OpenGL"), - - apprt.gtk => { - // We don't need to do any unloading for GTK because we may - // be sharing the global bindings with other windows. - }, - - apprt.embedded => { - // TODO: see threadEnter - }, + if (apprt.runtime == apprt.win32) { + // Release the WGL context from this thread. + apprt.win32.Surface.releaseContext(); } + // GTK and embedded don't need thread-specific GL cleanup. } pub fn displayRealized(self: *const OpenGL) void { diff --git a/src/renderer/Thread.zig b/src/renderer/Thread.zig index 488642199..43e131aa8 100644 --- a/src/renderer/Thread.zig +++ b/src/renderer/Thread.zig @@ -513,6 +513,12 @@ fn drawFrame(self: *Thread, now: bool) void { } else { self.renderer.drawFrame(false) catch |err| log.warn("error drawing err={}", .{err}); + + // On Win32, we need to explicitly swap buffers after rendering + // since there's no toolkit managing the GL context for us. + if (comptime @hasDecl(apprt.runtime.Surface, "swapBuffers")) { + self.surface.swapBuffers(); + } } }