diff --git a/include/ghostty.h b/include/ghostty.h index 78c6dfb89..d28dcd095 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -337,6 +337,7 @@ 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 void (*ghostty_runtime_render_inspector_cb)(void *); +typedef void (*ghostty_runtime_set_cell_size_cb)(void *, uint32_t, uint32_t); typedef struct { void *userdata; @@ -359,6 +360,7 @@ typedef struct { ghostty_runtime_toggle_fullscreen_cb toggle_fullscreen_cb; ghostty_runtime_set_initial_window_size_cb set_initial_window_size_cb; ghostty_runtime_render_inspector_cb render_inspector_cb; + ghostty_runtime_set_cell_size_cb set_cell_size_cb; } ghostty_runtime_config_s; //------------------------------------------------------------------- diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index f88f47cda..f493b2072 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -38,7 +38,6 @@ A5CEAFFF29C2410700646FDA /* Backport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CEAFFE29C2410700646FDA /* Backport.swift */; }; A5FEB3002ABB69450068369E /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FEB2FF2ABB69450068369E /* main.swift */; }; A5FECBD729D1FC3900022361 /* PrimaryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FECBD629D1FC3900022361 /* PrimaryView.swift */; }; - A5FECBD929D2010400022361 /* WindowAccessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FECBD829D2010400022361 /* WindowAccessor.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -76,7 +75,6 @@ A5D495A1299BEC7E00DD1313 /* GhosttyKit.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = GhosttyKit.xcframework; sourceTree = ""; }; A5FEB2FF2ABB69450068369E /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; A5FECBD629D1FC3900022361 /* PrimaryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrimaryView.swift; sourceTree = ""; }; - A5FECBD829D2010400022361 /* WindowAccessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowAccessor.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -120,7 +118,6 @@ A5CEAFFE29C2410700646FDA /* Backport.swift */, 8503D7C62A549C66006CFF3D /* FullScreenHandler.swift */, A59FB5D02AE0DEA7009128F3 /* MetalView.swift */, - A5FECBD829D2010400022361 /* WindowAccessor.swift */, A5CEAFDA29B8005900646FDA /* SplitView */, ); path = Helpers; @@ -313,7 +310,6 @@ A5CDF1932AAF9E0800513312 /* ConfigurationErrorsController.swift in Sources */, A59FB5D12AE0DEA7009128F3 /* MetalView.swift in Sources */, A55685E029A03A9F004303CE /* AppError.swift in Sources */, - A5FECBD929D2010400022361 /* WindowAccessor.swift in Sources */, A535B9DA299C569B0017E2E4 /* ErrorView.swift in Sources */, 85102A1C2A6E32890084AB3E /* PrimaryWindowController.swift in Sources */, A5CEAFFF29C2410700646FDA /* Backport.swift in Sources */, diff --git a/macos/Sources/Features/Primary Window/PrimaryView.swift b/macos/Sources/Features/Primary Window/PrimaryView.swift index a0047e4f8..9cff1c93f 100644 --- a/macos/Sources/Features/Primary Window/PrimaryView.swift +++ b/macos/Sources/Features/Primary Window/PrimaryView.swift @@ -14,9 +14,10 @@ struct PrimaryView: View { // If this is set, this is the base configuration that we build our surface out of. let baseConfig: Ghostty.SurfaceConfiguration? - // We need access to our window to know if we're the key window to determine - // if we show the quit confirmation or not. - @State private var window: NSWindow? + // We need access to our window to know if we're the key window and to + // modify window properties in response to events from the surface (e.g. + // updating the window title) + var window: NSWindow // This handles non-native fullscreen @State private var fullScreen = FullScreenHandler() @@ -27,6 +28,7 @@ struct PrimaryView: View { @FocusedValue(\.ghosttySurfaceView) private var focusedSurface @FocusedValue(\.ghosttySurfaceTitle) private var surfaceTitle @FocusedValue(\.ghosttySurfaceZoomed) private var zoomedSplit + @FocusedValue(\.ghosttySurfaceCellSize) private var cellSize // The title for our window private var title: String { @@ -68,7 +70,6 @@ struct PrimaryView: View { Ghostty.TerminalSplit(onClose: Self.closeWindow, baseConfig: self.baseConfig) .ghosttyApp(ghostty.app!) .ghosttyConfig(ghostty.config!) - .background(WindowAccessor(window: $window)) .onReceive(gotoTab) { onGotoTab(notification: $0) } .onReceive(toggleFullscreen) { onToggleFullscreen(notification: $0) } .focused($focused) @@ -79,8 +80,12 @@ struct PrimaryView: View { .onChange(of: title) { newValue in // We need to handle this manually because we are using AppKit lifecycle // so navigationTitle no longer works. - guard let window = self.window else { return } - window.title = newValue + self.window.title = newValue + } + .onChange(of: cellSize) { newValue in + if !ghostty.windowStepResize { return } + guard let size = newValue else { return } + self.window.contentResizeIncrements = size } } } @@ -95,8 +100,7 @@ struct PrimaryView: View { // Notification center indiscriminately sends to every subscriber (makes sense) // but we only want to process this once. In order to process it once lets only // handle it if we're the focused window. - guard let window = self.window else { return } - guard window.isKeyWindow else { return } + guard self.window.isKeyWindow else { return } // Get the tab index from the notification guard let tabIndexAny = notification.userInfo?[Ghostty.Notification.GotoTabKey] else { return } @@ -135,8 +139,7 @@ struct PrimaryView: View { // Just like in `onGotoTab`, we might receive this multiple times. But // it's fine, because `toggleFullscreen` should only apply to the // currently focused window. - guard let window = self.window else { return } - guard window.isKeyWindow else { return } + guard self.window.isKeyWindow else { return } // Check whether we use non-native fullscreen guard let useNonNativeFullscreenAny = notification.userInfo?[Ghostty.Notification.NonNativeFullscreenKey] else { return } diff --git a/macos/Sources/Features/Primary Window/PrimaryWindow.swift b/macos/Sources/Features/Primary Window/PrimaryWindow.swift index 1d7f53b26..7a8be5471 100644 --- a/macos/Sources/Features/Primary Window/PrimaryWindow.swift +++ b/macos/Sources/Features/Primary Window/PrimaryWindow.swift @@ -41,7 +41,8 @@ class PrimaryWindow: NSWindow { ghostty: ghostty, appDelegate: appDelegate, focusedSurfaceWrapper: window.focusedSurfaceWrapper, - baseConfig: baseConfig + baseConfig: baseConfig, + window: window )) // We do want to cascade when new windows are created diff --git a/macos/Sources/Ghostty/AppState.swift b/macos/Sources/Ghostty/AppState.swift index f81131b32..4f637e3a9 100644 --- a/macos/Sources/Ghostty/AppState.swift +++ b/macos/Sources/Ghostty/AppState.swift @@ -97,6 +97,15 @@ extension Ghostty { return String(cString: ptr) } + /// Whether to resize windows in discrete steps or use "fluid" resizing + var windowStepResize: Bool { + guard let config = self.config else { return true } + var v = false + let key = "window-step-resize" + _ = ghostty_config_get(config, &v, key, UInt(key.count)) + return v + } + /// The background opacity. var backgroundOpacity: Double { guard let config = self.config else { return 1 } @@ -143,7 +152,8 @@ extension Ghostty { goto_tab_cb: { userdata, n in AppState.gotoTab(userdata, n: n) }, 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) }, - render_inspector_cb: { userdata in AppState.renderInspector(userdata) } + render_inspector_cb: { userdata in AppState.renderInspector(userdata) }, + set_cell_size_cb: { userdata, width, height in AppState.setCellSize(userdata, width: width, height: height) } ) // Create the ghostty app. @@ -468,6 +478,12 @@ extension Ghostty { surfaceView.initialSize = NSMakeSize(Double(width), Double(height)) } + static func setCellSize(_ userdata: UnsafeMutableRawPointer?, width: UInt32, height: UInt32) { + guard let surfaceView = self.surfaceUserdata(from: userdata) else { return } + let backingSize = NSSize(width: Double(width), height: Double(height)) + surfaceView.cellSize = surfaceView.convertFromBacking(backingSize) + } + 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/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index db8251bdd..0252b5a42 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -78,6 +78,7 @@ extension Ghostty { .focused($surfaceFocus) .focusedValue(\.ghosttySurfaceTitle, surfaceView.title) .focusedValue(\.ghosttySurfaceView, surfaceView) + .focusedValue(\.ghosttySurfaceCellSize, surfaceView.cellSize) .onReceive(pubBecomeKey) { notification in guard let window = notification.object as? NSWindow else { return } guard let surfaceWindow = surfaceView.window else { return } @@ -240,7 +241,13 @@ 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 = "👻" - + + // The cell size of this surface. This is set by the core when the + // surface is first created and any time the cell size changes (i.e. + // when the font size changes). This is used to allow windows to be + // resized in discrete steps of a single cell. + @Published var cellSize: NSSize = .zero + // 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 @@ -930,3 +937,14 @@ extension FocusedValues { typealias Value = Bool } } + +extension FocusedValues { + var ghosttySurfaceCellSize: NSSize? { + get { self[FocusedGhosttySurfaceCellSize.self] } + set { self[FocusedGhosttySurfaceCellSize.self] = newValue } + } + + struct FocusedGhosttySurfaceCellSize: FocusedValueKey { + typealias Value = NSSize + } +} diff --git a/macos/Sources/Helpers/WindowAccessor.swift b/macos/Sources/Helpers/WindowAccessor.swift deleted file mode 100644 index f12e841bd..000000000 --- a/macos/Sources/Helpers/WindowAccessor.swift +++ /dev/null @@ -1,16 +0,0 @@ -import SwiftUI - -/// Allows accessing the window that this view is a part of. -struct WindowAccessor: NSViewRepresentable { - @Binding var window: NSWindow? - - func makeNSView(context: Context) -> NSView { - let view = NSView() - DispatchQueue.main.async { - self.window = view.window - } - return view - } - - func updateNSView(_ nsView: NSView, context: Context) {} -} diff --git a/src/Surface.zig b/src/Surface.zig index 2864c5781..509ff3ce1 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -460,6 +460,9 @@ pub fn init( .config = try DerivedConfig.init(alloc, config), }; + // Report initial cell size on surface creation + try rt_surface.setCellSize(cell_size.width, cell_size.height); + // Set a minimum size that is cols=10 h=4. This matches Mac's Terminal.app // but is otherwise somewhat arbitrary. try rt_surface.setSizeLimits(.{ @@ -896,6 +899,9 @@ fn setCellSize(self: *Surface, size: renderer.CellSize) !void { }, }, .{ .forever = {} }); self.io_thread.wakeup.notify() catch {}; + + // Notify the window + try self.rt_surface.setCellSize(size.width, size.height); } /// Change the font size. diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index bb5826594..d66682528 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -98,6 +98,9 @@ pub const App = struct { /// Render the inspector for the given surface. render_inspector: ?*const fn (SurfaceUD) callconv(.C) void = null, + + /// Called when the cell size changes. + set_cell_size: ?*const fn (SurfaceUD, u32, u32) callconv(.C) void = null, }; /// Special values for the goto_tab callback. @@ -818,6 +821,15 @@ pub const Surface = struct { func(self.opts.userdata); } + pub fn setCellSize(self: *const Surface, width: u32, height: u32) !void { + const func = self.app.opts.set_cell_size orelse { + log.info("runtime embedder does not support set_cell_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 36367e20b..bf96e1e6c 100644 --- a/src/apprt/glfw.zig +++ b/src/apprt/glfw.zig @@ -461,6 +461,13 @@ pub const Surface = struct { self.window.setSize(.{ .width = width, .height = height }); } + /// Set the cell size. Unused by GLFW. + pub fn setCellSize(self: *const Surface, width: u32, height: u32) !void { + _ = self; + _ = width; + _ = 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 c6403a0d5..86a6c816e 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -374,6 +374,12 @@ pub fn setInitialWindowSize(self: *const Surface, width: u32, height: u32) !void ); } +pub fn setCellSize(self: *const Surface, width: u32, height: u32) !void { + _ = self; + _ = width; + _ = height; +} + pub fn setSizeLimits(self: *Surface, min: apprt.SurfaceSize, max_: ?apprt.SurfaceSize) !void { _ = self; _ = min; diff --git a/src/config/Config.zig b/src/config/Config.zig index 0f42ba743..a5a343e3c 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -390,6 +390,11 @@ keybind: Keybinds = .{}, @"window-height": u32 = 0, @"window-width": u32 = 0, +/// Resize the window in discrete increments of the focused surface's +/// cell size. If this is disabled, surfaces are resized in pixel increments. +/// Currently only supported on macOS. +@"window-step-resize": bool = true, + /// 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.