diff --git a/include/ghostty.h b/include/ghostty.h index 42e83db5f..1a0a7c9c6 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -326,6 +326,7 @@ typedef void (*ghostty_runtime_focus_split_cb)(void *, ghostty_split_focus_direc typedef void (*ghostty_runtime_toggle_split_zoom_cb)(void *); typedef void (*ghostty_runtime_goto_tab_cb)(void *, int32_t); typedef void (*ghostty_runtime_toggle_fullscreen_cb)(void *, ghostty_non_native_fullscreen_e); +typedef void (*ghostty_runtime_set_initial_window_size_cb)(void *, uint32_t, uint32_t); typedef struct { void *userdata; @@ -345,6 +346,7 @@ typedef struct { ghostty_runtime_toggle_split_zoom_cb toggle_split_zoom_cb; ghostty_runtime_goto_tab_cb goto_tab_cb; ghostty_runtime_toggle_fullscreen_cb toggle_fullscreen_cb; + ghostty_runtime_set_initial_window_size_cb set_initial_window_size_cb; } ghostty_runtime_config_s; //------------------------------------------------------------------- diff --git a/macos/Sources/Features/Primary Window/PrimaryWindowController.swift b/macos/Sources/Features/Primary Window/PrimaryWindowController.swift index 0d2389cd7..6add783e3 100644 --- a/macos/Sources/Features/Primary Window/PrimaryWindowController.swift +++ b/macos/Sources/Features/Primary Window/PrimaryWindowController.swift @@ -4,6 +4,9 @@ class PrimaryWindowController: NSWindowController, NSWindowDelegate { // This is used to programmatically control tabs. weak var windowManager: PrimaryWindowManager? + // This should be set to true once a surface has been initialized once. + var didInitializeFromSurface: Bool = false + // This is required for the "+" button to show up in the tab bar to add a // new tab. override func newWindowForTab(_ sender: Any?) { diff --git a/macos/Sources/Features/Settings/ConfigurationErrors.xib b/macos/Sources/Features/Settings/ConfigurationErrors.xib index d3f94b14d..dcd40ad21 100644 --- a/macos/Sources/Features/Settings/ConfigurationErrors.xib +++ b/macos/Sources/Features/Settings/ConfigurationErrors.xib @@ -1,8 +1,8 @@ - + - + diff --git a/macos/Sources/Ghostty/AppState.swift b/macos/Sources/Ghostty/AppState.swift index 5cf39f044..e01ce96af 100644 --- a/macos/Sources/Ghostty/AppState.swift +++ b/macos/Sources/Ghostty/AppState.swift @@ -125,7 +125,8 @@ extension Ghostty { focus_split_cb: { userdata, direction in AppState.focusSplit(userdata, direction: direction) }, toggle_split_zoom_cb: { userdata in AppState.toggleSplitZoom(userdata) }, goto_tab_cb: { userdata, n in AppState.gotoTab(userdata, n: n) }, - toggle_fullscreen_cb: { userdata, nonNativeFullscreen in AppState.toggleFullscreen(userdata, nonNativeFullscreen: nonNativeFullscreen) } + toggle_fullscreen_cb: { userdata, nonNativeFullscreen in AppState.toggleFullscreen(userdata, nonNativeFullscreen: nonNativeFullscreen) }, + set_initial_window_size_cb: { userdata, width, height in AppState.setInitialWindowSize(userdata, width: width, height: height) } ) // Create the ghostty app. @@ -413,6 +414,12 @@ extension Ghostty { ] ) } + + static func setInitialWindowSize(_ userdata: UnsafeMutableRawPointer?, width: UInt32, height: UInt32) { + // We need a window to set the frame + guard let surfaceView = self.surfaceUserdata(from: userdata) else { return } + surfaceView.initialSize = NSMakeSize(Double(width), Double(height)) + } static func newTab(_ userdata: UnsafeMutableRawPointer?, config: ghostty_surface_config_s) { guard let surface = self.surfaceUserdata(from: userdata) else { return } diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/Package.swift index 81404fbfb..8f97a090d 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/Package.swift @@ -98,6 +98,10 @@ extension Ghostty.Notification { /// Notification sent to toggle split maximize/unmaximize. static let didToggleSplitZoom = Notification.Name("com.mitchellh.ghostty.didToggleSplitZoom") + + /// Notification + static let didReceiveInitialWindowFrame = Notification.Name("com.mitchellh.ghostty.didReceiveInitialWindowFrame") + static let FrameKey = "com.mitchellh.ghostty.frame" } // Make the input enum hashable. diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index d33563ff2..d686055c9 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -191,7 +191,11 @@ extension Ghostty { // changed with escape codes. This is public because the callbacks go // to the app level and it is set from there. @Published var title: String = "👻" - + + // An initial size to request for a window. This will only affect + // then the view is moved to a new window. + var initialSize: NSSize? = nil + private(set) var surface: ghostty_surface_t? var error: Error? = nil @@ -407,6 +411,40 @@ extension Ghostty { // If we have a blur, set the blur ghostty_set_window_background_blur(surface, Unmanaged.passUnretained(window).toOpaque()) + + // Try to set the initial window size if we have one + setInitialWindowSize() + } + + /// Sets the initial window size requested by the Ghostty config. + /// + /// This only works under certain conditions: + /// - The window must be "uninitialized" + /// - The window must have no tabs + /// - Ghostty must have requested an initial size + /// + private func setInitialWindowSize() { + guard let initialSize = initialSize else { return } + + // If we have tabs, then do not change the window size + guard let window = self.window else { return } + guard let windowControllerRaw = window.windowController else { return } + guard let windowController = windowControllerRaw as? PrimaryWindowController else { return } + guard !windowController.didInitializeFromSurface else { return } + + // Setup our frame. We need to first subtract the views frame so that we can + // just get the chrome frame so that we only affect the surface view size. + var frame = window.frame + frame.size.width -= self.frame.size.width + frame.size.height -= self.frame.size.height + frame.size.width += initialSize.width + frame.size.height += initialSize.height + + // We have no tabs and we are not a split, so set the initial size of the window. + window.setFrame(frame, display: true) + + // Note that we did initialize + windowController.didInitializeFromSurface = true } override func becomeFirstResponder() -> Bool { diff --git a/src/Surface.zig b/src/Surface.zig index 5711edf08..172d2495a 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -471,6 +471,36 @@ pub fn init( .{&self.io_thread}, ); self.io_thr.setName("io") catch {}; + + // Determine our initial window size if configured. We need to do this + // quite late in the process because our height/width are in grid dimensions, + // so we need to know our cell sizes first. + // + // Note: it is important to do this after the renderer is setup above. + // This allows the apprt to fully initialize the surface before we + // start messing with the window. + if (config.@"window-height" > 0 and config.@"window-width" > 0) init: { + const scale = rt_surface.getContentScale() catch break :init; + const height = @max(config.@"window-height" * cell_size.height, 480); + const width = @max(config.@"window-width" * cell_size.width, 640); + const width_f32: f32 = @floatFromInt(width); + const height_f32: f32 = @floatFromInt(height); + + // The final values are affected by content scale and we need to + // account for the padding so we get the exact correct grid size. + const final_width: u32 = + @as(u32, @intFromFloat(@ceil(width_f32 / scale.x))) + + padding.left + + padding.right; + const final_height: u32 = + @as(u32, @intFromFloat(@ceil(height_f32 / scale.y))) + + padding.top + + padding.bottom; + + rt_surface.setInitialWindowSize(final_width, final_height) catch |err| { + log.warn("unable to set initial window size: {s}", .{err}); + }; + } } pub fn deinit(self: *Surface) void { diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 36240b7f0..da5a60273 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -87,6 +87,10 @@ pub const App = struct { /// Toggle fullscreen for current window. toggle_fullscreen: ?*const fn (SurfaceUD, configpkg.NonNativeFullscreen) callconv(.C) void = null, + + /// Set the initial window size. It is up to the user of libghostty to + /// determine if it is the initial window and set this appropriately. + set_initial_window_size: ?*const fn (SurfaceUD, u32, u32) callconv(.C) void = null, }; /// Special values for the goto_tab callback. @@ -734,6 +738,15 @@ pub const Surface = struct { func(self.opts.userdata, options); } + pub fn setInitialWindowSize(self: *const Surface, width: u32, height: u32) !void { + const func = self.app.opts.set_initial_window_size orelse { + log.info("runtime embedder does not set_initial_window_size", .{}); + return; + }; + + func(self.opts.userdata, width, height); + } + fn newSurfaceOptions(self: *const Surface) apprt.Surface.Options { const font_size: u16 = font_size: { if (!self.app.config.@"window-inherit-font-size") break :font_size 0; diff --git a/src/apprt/glfw.zig b/src/apprt/glfw.zig index e4acab289..4a40ed22e 100644 --- a/src/apprt/glfw.zig +++ b/src/apprt/glfw.zig @@ -447,6 +447,13 @@ pub const Surface = struct { self.app.app.alloc.destroy(self); } + /// Set the initial window size. This is called exactly once at + /// surface initialization time. This may be called before "self" + /// is fully initialized. + pub fn setInitialWindowSize(self: *const Surface, width: u32, height: u32) !void { + self.window.setSize(.{ .width = width, .height = height }); + } + /// Set the size limits of the window. /// Note: this interface is not good, we should redo it if we plan /// to use this more. i.e. you can't set max width but no max height, diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index b66c053e9..2638eca9d 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -325,6 +325,16 @@ pub fn getSize(self: *const Surface) !apprt.SurfaceSize { return self.size; } +pub fn setInitialWindowSize(self: *const Surface, width: u32, height: u32) !void { + // Note: this doesn't properly take into account the window decorations. + // I'm not currently sure how to do that. + c.gtk_window_set_default_size( + @ptrCast(self.window.window), + @intCast(width), + @intCast(height), + ); +} + pub fn setSizeLimits(self: *Surface, min: apprt.SurfaceSize, max_: ?apprt.SurfaceSize) !void { _ = self; _ = min; diff --git a/src/config/CAPI.zig b/src/config/CAPI.zig index 1912403bc..09ea27c70 100644 --- a/src/config/CAPI.zig +++ b/src/config/CAPI.zig @@ -86,6 +86,7 @@ export fn ghostty_config_get( key_str: [*]const u8, len: usize, ) bool { + @setEvalBranchQuota(10_000); const key = std.meta.stringToEnum(Key, key_str[0..len]) orelse return false; return c_get.get(self, key, ptr); } diff --git a/src/config/Config.zig b/src/config/Config.zig index 1b6e8d84b..f9913e440 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -300,6 +300,31 @@ keybind: Keybinds = .{}, /// This is currently only supported on macOS. @"window-theme": WindowTheme = .system, +/// The initial window size. This size is in terminal grid cells by default. +/// +/// We don't currently support specifying a size in pixels but a future +/// change can enable that. If this isn't specified, the app runtime will +/// determine some default size. +/// +/// Note that the window manager may put limits on the size or override +/// the size. For example, a tiling window manager may force the window +/// to be a certain size to fit within the grid. There is nothing Ghostty +/// will do about this, but it will make an effort. +/// +/// This will not affect new tabs, splits, or other nested terminal +/// elements. This only affects the initial window size of any new window. +/// Changing this value will not affect the size of the window after +/// it has been created. This is only used for the initial size. +/// +/// BUG: On Linux with GTK, the calculated window size will not properly +/// take into account window decorations. As a result, the grid dimensions +/// will not exactly match this configuration. If window decorations are +/// disabled (see window-decorations), then this will work as expected. +/// +/// Windows smaller than 10 wide by 4 high are not allowed. +@"window-height": u32 = 0, +@"window-width": u32 = 0, + /// Whether to allow programs running in the terminal to read/write to /// the system clipboard (OSC 52, for googling). The default is to /// disallow clipboard reading but allow writing. @@ -1007,6 +1032,10 @@ pub fn finalize(self: *Config) !void { // Clamp our split opacity self.@"unfocused-split-opacity" = @min(1.0, @max(0.15, self.@"unfocused-split-opacity")); + + // Minimmum window size + if (self.@"window-width" > 0) self.@"window-width" = @max(10, self.@"window-width"); + if (self.@"window-height" > 0) self.@"window-height" = @max(4, self.@"window-height"); } /// Create a shallow copy of this config. This will share all the memory