diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index b60eb11f5..57070dc47 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -61,6 +61,7 @@ A59FB5CF2AE0DB50009128F3 /* InspectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59FB5CE2AE0DB50009128F3 /* InspectorView.swift */; }; A59FB5D12AE0DEA7009128F3 /* MetalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59FB5D02AE0DEA7009128F3 /* MetalView.swift */; }; A5A1F8852A489D6800D1E8BC /* terminfo in Resources */ = {isa = PBXBuildFile; fileRef = A5A1F8842A489D6800D1E8BC /* terminfo */; }; + A5A6F72A2CC41B8900B232A5 /* Xcode.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A6F7292CC41B8700B232A5 /* Xcode.swift */; }; A5B30539299BEAAB0047F10C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5B30538299BEAAB0047F10C /* Assets.xcassets */; }; A5CBD0562C9E65B80017A1AE /* DraggableWindowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */; }; A5CBD0582C9F30960017A1AE /* Cursor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD0572C9F30860017A1AE /* Cursor.swift */; }; @@ -139,6 +140,7 @@ A59FB5CE2AE0DB50009128F3 /* InspectorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InspectorView.swift; sourceTree = ""; }; A59FB5D02AE0DEA7009128F3 /* MetalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetalView.swift; sourceTree = ""; }; A5A1F8842A489D6800D1E8BC /* terminfo */ = {isa = PBXFileReference; lastKnownFileType = folder; name = terminfo; path = "../zig-out/share/terminfo"; sourceTree = ""; }; + A5A6F7292CC41B8700B232A5 /* Xcode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Xcode.swift; sourceTree = ""; }; A5B30531299BEAAA0047F10C /* Ghostty.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Ghostty.app; sourceTree = BUILT_PRODUCTS_DIR; }; A5B30538299BEAAB0047F10C /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; A5B3053D299BEAAB0047F10C /* Ghostty.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Ghostty.entitlements; sourceTree = ""; }; @@ -233,6 +235,7 @@ A534263D2A7DCBB000EBB7A2 /* Helpers */ = { isa = PBXGroup; children = ( + A5A6F7292CC41B8700B232A5 /* Xcode.swift */, A5CEAFFE29C2410700646FDA /* Backport.swift */, A5333E1B2B5A1CE3008AEFF7 /* CrossKit.swift */, A5CBD0572C9F30860017A1AE /* Cursor.swift */, @@ -582,6 +585,7 @@ A52FFF5D2CAB4D08000C6A5B /* NSScreen+Extension.swift in Sources */, A53426352A7DA53D00EBB7A2 /* AppDelegate.swift in Sources */, A5CBD0582C9F30960017A1AE /* Cursor.swift in Sources */, + A5A6F72A2CC41B8900B232A5 /* Xcode.swift in Sources */, A52FFF5B2CAA54B1000C6A5B /* FullscreenMode+Extension.swift in Sources */, A5333E222B5A2128008AEFF7 /* SurfaceView_AppKit.swift in Sources */, A5CDF1952AAFA19600513312 /* ConfigurationErrorsView.swift in Sources */, diff --git a/macos/Sources/Features/About/About.xib b/macos/Sources/Features/About/About.xib index e884beff1..5803a32de 100644 --- a/macos/Sources/Features/About/About.xib +++ b/macos/Sources/Features/About/About.xib @@ -14,7 +14,7 @@ - + diff --git a/macos/Sources/Features/About/AboutController.swift b/macos/Sources/Features/About/AboutController.swift index d2ae68ea7..efd7a515a 100644 --- a/macos/Sources/Features/About/AboutController.swift +++ b/macos/Sources/Features/About/AboutController.swift @@ -10,6 +10,7 @@ class AboutController: NSWindowController, NSWindowDelegate { override func windowDidLoad() { guard let window = window else { return } window.center() + window.isMovableByWindowBackground = true window.contentView = NSHostingView(rootView: AboutView()) } diff --git a/macos/Sources/Features/About/AboutView.swift b/macos/Sources/Features/About/AboutView.swift index 02f899cc4..71fe9c252 100644 --- a/macos/Sources/Features/About/AboutView.swift +++ b/macos/Sources/Features/About/AboutView.swift @@ -1,35 +1,136 @@ import SwiftUI struct AboutView: View { + @Environment(\.openURL) var openURL + + private let githubLink = URL(string: "https://github.com/ghostty-org/ghostty") + /// Read the commit from the bundle. - var build: String? { Bundle.main.infoDictionary?["CFBundleVersion"] as? String } - var commit: String? { Bundle.main.infoDictionary?["GhosttyCommit"] as? String } - var version: String? { Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String } + private var build: String? { Bundle.main.infoDictionary?["CFBundleVersion"] as? String } + private var commit: String? { Bundle.main.infoDictionary?["GhosttyCommit"] as? String } + private var version: String? { Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String } + private var copyright: String? { Bundle.main.infoDictionary?["NSHumanReadableCopyright"] as? String } + + private var properties: [KeyValue] { + let list: [KeyValue] = [ + .init(key: "Version", value: version), + .init(key: "Build", value: build), + .init(key: "Commit", value: commit == "" ? nil : commit) + ] + + return list.compactMap { + guard let value = $0.value else { return nil } + return .init(key: $0.key, value: value) + } + } + + private struct KeyValue: Identifiable { + var id = UUID() + public let key: LocalizedStringResource + public let value: Value + } + + #if os(macOS) + // This creates a background style similar to the Apple "About My Mac" Window + private struct VisualEffectBackground: NSViewRepresentable { + let material: NSVisualEffectView.Material + let blendingMode: NSVisualEffectView.BlendingMode + let isEmphasized: Bool + + init(material: NSVisualEffectView.Material, + blendingMode: NSVisualEffectView.BlendingMode = .behindWindow, + isEmphasized: Bool = false) + { + self.material = material + self.blendingMode = blendingMode + self.isEmphasized = isEmphasized + } + + func updateNSView(_ nsView: NSVisualEffectView, context: Context) { + nsView.material = material + nsView.blendingMode = blendingMode + nsView.isEmphasized = isEmphasized + } + + func makeNSView(context: Context) -> NSVisualEffectView { + let visualEffect = NSVisualEffectView() + visualEffect.autoresizingMask = [.width, .height] + return visualEffect + } + } + #endif var body: some View { VStack(alignment: .center) { Image("AppIconImage") .resizable() .aspectRatio(contentMode: .fit) - .frame(maxHeight: 96) + .frame(height: 128) - Text("Ghostty") - .font(.title3) + VStack(alignment: .center, spacing: 32) { + VStack(alignment: .center, spacing: 8) { + Text("Ghostty") + .bold() + .font(.title) + Text("Fast, native, feature-rich terminal \nemulator pushing modern features.") + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + .font(.caption) + .tint(.secondary) + .opacity(0.8) + } .textSelection(.enabled) + VStack(spacing: 2) { + ForEach(properties) { item in + HStack(spacing: 4) { + Text(item.key) + .frame(width: 126, alignment: .trailing) + .padding(.trailing, 2) + Text(item.value) + .frame(width: 125, alignment: .leading) + .padding(.leading, 2) + .tint(.secondary) + .opacity(0.8) + } + .font(.callout) + .textSelection(.enabled) + .frame(maxWidth: .infinity) + } + } + .frame(maxWidth: .infinity) - if let version = self.version { - Text("Version: \(version)") - .font(.body) - .textSelection(.enabled) - } + HStack(spacing: 8) { + if let url = githubLink { + Button("GitHub") { + openURL(url) + } + } - if let build = self.build { - Text("Build: \(build)") - .font(.body) - .textSelection(.enabled) + } + + if let copy = self.copyright { + Text(copy) + .font(.caption) + .textSelection(.enabled) + .tint(.secondary) + .opacity(0.8) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + } } + .frame(maxWidth: .infinity) } - .frame(minWidth: 300) - .padding() + .padding(.top, 8) + .padding(32) + .frame(minWidth: 256) + #if os(macOS) + .background(VisualEffectBackground(material: .underWindowBackground).ignoresSafeArea()) + #endif + } +} + +struct AboutView_Previews: PreviewProvider { + static var previews: some View { + AboutView() } } diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 77d2d0033..ea35790fd 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -57,6 +57,14 @@ class BaseTerminalController: NSWindowController, /// Event monitor (see individual events for why) private var eventMonitor: Any? = nil + /// The previous frame information from the window + private var savedFrame: SavedFrame? = nil + + struct SavedFrame { + let window: NSRect + let screen: NSRect + } + required init?(coder: NSCoder) { fatalError("init(coder:) is not supported for this view") } @@ -80,6 +88,11 @@ class BaseTerminalController: NSWindowController, selector: #selector(onConfirmClipboardRequest), name: Ghostty.Notification.confirmClipboard, object: nil) + center.addObserver( + self, + selector: #selector(didChangeScreenParametersNotification), + name: NSApplication.didChangeScreenParametersNotification, + object: nil) // Listen for local events that we need to know of outside of // single surface handlers. @@ -89,6 +102,8 @@ class BaseTerminalController: NSWindowController, } deinit { + NotificationCenter.default.removeObserver(self) + if let eventMonitor { NSEvent.removeMonitor(eventMonitor) } @@ -121,6 +136,57 @@ class BaseTerminalController: NSWindowController, } } + // Call this whenever the frame changes + private func windowFrameDidChange() { + // We need to update our saved frame information in case of monitor + // changes (see didChangeScreenParameters notification). + savedFrame = nil + guard let window, let screen = window.screen else { return } + savedFrame = .init(window: window.frame, screen: screen.visibleFrame) + } + + // MARK: Notifications + + @objc private func didChangeScreenParametersNotification(_ notification: Notification) { + // If we have a window that is visible and it is outside the bounds of the + // screen then we clamp it back to within the screen. + guard let window else { return } + guard window.isVisible else { return } + guard let screen = window.screen else { return } + + let visibleFrame = screen.visibleFrame + var newFrame = window.frame + + // Clamp width/height + if newFrame.size.width > visibleFrame.size.width { + newFrame.size.width = visibleFrame.size.width + } + if newFrame.size.height > visibleFrame.size.height { + newFrame.size.height = visibleFrame.size.height + } + + // Ensure the window is on-screen. We only do this if the previous frame + // was also on screen. If a user explicitly wanted their window off screen + // then we let it stay that way. + x: if newFrame.origin.x < visibleFrame.origin.x { + if let savedFrame, savedFrame.window.origin.x < savedFrame.screen.origin.x { + break x; + } + + newFrame.origin.x = visibleFrame.origin.x + } + y: if newFrame.origin.y < visibleFrame.origin.y { + if let savedFrame, savedFrame.window.origin.y < savedFrame.screen.origin.y { + break y; + } + + newFrame.origin.y = visibleFrame.origin.y + } + + // Apply the new window frame + window.setFrame(newFrame, display: true) + } + // MARK: Local Events private func localEventHandler(_ event: NSEvent) -> NSEvent? { @@ -371,6 +437,14 @@ class BaseTerminalController: NSWindowController, } } + func windowDidResize(_ notification: Notification) { + windowFrameDidChange() + } + + func windowDidMove(_ notification: Notification) { + windowFrameDidChange() + } + // MARK: First Responder @IBAction func close(_ sender: Any) { diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 70df52b4b..f43454edd 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -358,7 +358,8 @@ class TerminalController: BaseTerminalController { self.fixTabBar() } - func windowDidMove(_ notification: Notification) { + override func windowDidMove(_ notification: Notification) { + super.windowDidMove(notification) self.fixTabBar() } diff --git a/macos/Sources/Features/Terminal/TerminalToolbar.swift b/macos/Sources/Features/Terminal/TerminalToolbar.swift index 3e88ba744..87cb6ce4f 100644 --- a/macos/Sources/Features/Terminal/TerminalToolbar.swift +++ b/macos/Sources/Features/Terminal/TerminalToolbar.swift @@ -43,8 +43,8 @@ class TerminalToolbar: NSToolbar, NSToolbarDelegate { item.view = self.titleTextField item.visibilityPriority = .user + // This ensures the title text field doesn't disappear when shrinking the view self.titleTextField.translatesAutoresizingMaskIntoConstraints = false - self.titleTextField.setContentHuggingPriority(.defaultLow, for: .horizontal) self.titleTextField.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) @@ -52,7 +52,6 @@ class TerminalToolbar: NSToolbar, NSToolbarDelegate { NSLayoutConstraint.activate([ // Set the height constraint to match the toolbar's height self.titleTextField.heightAnchor.constraint(equalToConstant: 22), // Adjust as needed - ]) item.isEnabled = true @@ -82,7 +81,6 @@ class TerminalToolbar: NSToolbar, NSToolbarDelegate { fileprivate class CenteredDynamicLabel: NSTextField { override func viewDidMoveToSuperview() { // Configure the text field - isEditable = false isBordered = false drawsBackground = false diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index 69c9f992b..29639c39e 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -56,7 +56,13 @@ extension Ghostty { // same filesystem concept. #if os(macOS) ghostty_config_load_default_files(cfg); - ghostty_config_load_cli_args(cfg); + + // We only load CLI args when not running in Xcode because in Xcode we + // pass some special parameters to control the debugger. + if !isRunningInXcode() { + ghostty_config_load_cli_args(cfg); + } + ghostty_config_load_recursive_files(cfg); #endif diff --git a/macos/Sources/Helpers/Xcode.swift b/macos/Sources/Helpers/Xcode.swift new file mode 100644 index 000000000..281bad18b --- /dev/null +++ b/macos/Sources/Helpers/Xcode.swift @@ -0,0 +1,10 @@ +import Foundation + +/// True if we appear to be running in Xcode. +func isRunningInXcode() -> Bool { + if let _ = ProcessInfo.processInfo.environment["__XCODE_BUILT_PRODUCTS_DIR_PATHS"] { + return true + } + + return false +} diff --git a/src/Surface.zig b/src/Surface.zig index c1b7afd7b..bd5073e3a 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -500,6 +500,7 @@ pub fn init( try termio.Termio.init(&self.io, alloc, .{ .grid_size = grid_size, + .cell_size = cell_size, .screen_size = screen_size, .padding = padding, .full_config = config, @@ -1331,6 +1332,7 @@ fn setCellSize(self: *Surface, size: renderer.CellSize) !void { self.io.queueMessage(.{ .resize = .{ .grid_size = self.grid_size, + .cell_size = self.cell_size, .screen_size = self.screen_size, .padding = self.padding, }, @@ -1435,6 +1437,7 @@ fn resize(self: *Surface, size: renderer.ScreenSize) !void { self.io.queueMessage(.{ .resize = .{ .grid_size = self.grid_size, + .cell_size = self.cell_size, .screen_size = self.screen_size, .padding = self.padding, }, diff --git a/src/cli/help.zig b/src/cli/help.zig index c0db37afe..e9e449550 100644 --- a/src/cli/help.zig +++ b/src/cli/help.zig @@ -23,7 +23,7 @@ pub fn run(alloc: Allocator) !u8 { defer opts.deinit(); { - var iter = try std.process.argsWithAllocator(alloc); + var iter = try args.argsIterator(alloc); defer iter.deinit(); try args.parse(Options, alloc, &opts, &iter); } diff --git a/src/config/Config.zig b/src/config/Config.zig index 9097051ad..74933960d 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1456,8 +1456,13 @@ keybind: Keybinds = .{}, /// Note that if an *Option*-sequence doesn't produce a printable character, it /// will be treated as *Alt* regardless of this setting. (i.e. `alt+ctrl+a`). /// +/// The default value is `left`. This allows alt-based bindings to work +/// with the left *Option* key while still allowing the right *Option* key +/// to be used for Unicode input. This is a common setup for users of +/// certain keyboard layouts. +/// /// This does not work with GLFW builds. -@"macos-option-as-alt": OptionAsAlt = .false, +@"macos-option-as-alt": OptionAsAlt = .left, /// Whether to enable the macOS window shadow. The default value is true. /// With some window managers and window transparency settings, you may diff --git a/src/font/CodepointMap.zig b/src/font/CodepointMap.zig index 8c9ded402..5b174f129 100644 --- a/src/font/CodepointMap.zig +++ b/src/font/CodepointMap.zig @@ -54,8 +54,9 @@ pub fn add(self: *CodepointMap, alloc: Allocator, entry: Entry) !void { /// Get a descriptor for a codepoint. pub fn get(self: *const CodepointMap, cp: u21) ?discovery.Descriptor { const items = self.list.items(.range); - for (items, 0..) |range, forward_i| { + for (0..items.len) |forward_i| { const i = items.len - forward_i - 1; + const range = items[i]; if (range[0] <= cp and cp <= range[1]) { const descs = self.list.items(.descriptor); return descs[i]; @@ -110,4 +111,15 @@ test "codepointmap" { // Non-matching try testing.expect(m.get(0) == null); try testing.expect(m.get(3) == null); + + try m.add(alloc, .{ .range = .{ 3, 4 }, .descriptor = .{ .family = "C" } }); + try m.add(alloc, .{ .range = .{ 5, 6 }, .descriptor = .{ .family = "D" } }); + { + const d = m.get(3).?; + try testing.expectEqualStrings("C", d.family.?); + } + { + const d = m.get(1).?; + try testing.expectEqualStrings("B", d.family.?); + } } diff --git a/src/termio/Options.zig b/src/termio/Options.zig index fe862a503..8014ed403 100644 --- a/src/termio/Options.zig +++ b/src/termio/Options.zig @@ -11,6 +11,9 @@ const termio = @import("../termio.zig"); /// The size of the terminal grid. grid_size: renderer.GridSize, +/// The size of a single cell, in pixels. +cell_size: renderer.CellSize, + /// The size of the viewport in pixels. screen_size: renderer.ScreenSize, diff --git a/src/termio/Termio.zig b/src/termio/Termio.zig index 865ca8d90..f28eb118e 100644 --- a/src/termio/Termio.zig +++ b/src/termio/Termio.zig @@ -60,6 +60,9 @@ surface_mailbox: apprt.surface.Mailbox, /// The cached grid size whenever a resize is called. grid_size: renderer.GridSize, +/// The size of a single cell. Used for size reports. +cell_size: renderer.CellSize, + /// The mailbox implementation to use. mailbox: termio.Mailbox, @@ -171,9 +174,8 @@ pub fn init(self: *Termio, alloc: Allocator, opts: termio.Options) !void { backend.initTerminal(&term); // Setup our terminal size in pixels for certain requests. - const screen_size = opts.screen_size.subPadding(opts.padding); - term.width_px = screen_size.width; - term.height_px = screen_size.height; + term.width_px = opts.grid_size.columns * opts.cell_size.width; + term.height_px = opts.grid_size.rows * opts.cell_size.height; // Create our stream handler. This points to memory in self so it // isn't safe to use until self.* is set. @@ -214,6 +216,7 @@ pub fn init(self: *Termio, alloc: Allocator, opts: termio.Options) !void { .renderer_mailbox = opts.renderer_mailbox, .surface_mailbox = opts.surface_mailbox, .grid_size = opts.grid_size, + .cell_size = opts.cell_size, .backend = opts.backend, .mailbox = opts.mailbox, .terminal_stream = .{ @@ -348,6 +351,7 @@ pub fn resize( self: *Termio, td: *ThreadData, grid_size: renderer.GridSize, + cell_size: renderer.CellSize, screen_size: renderer.ScreenSize, padding: renderer.Padding, ) !void { @@ -357,6 +361,7 @@ pub fn resize( // Update our cached grid size self.grid_size = grid_size; + self.cell_size = cell_size; // Enter the critical area that we want to keep small { @@ -371,8 +376,8 @@ pub fn resize( ); // Update our pixel sizes - self.terminal.width_px = padded_size.width; - self.terminal.height_px = padded_size.height; + self.terminal.width_px = self.grid_size.columns * self.cell_size.width; + self.terminal.height_px = self.grid_size.rows * self.cell_size.height; // Disable synchronized output mode so that we show changes // immediately for a resize. This is allowed by the spec. @@ -412,24 +417,24 @@ fn sizeReportLocked(self: *Termio, td: *ThreadData, style: termio.Message.SizeRe .{ self.grid_size.rows, self.grid_size.columns, - self.terminal.height_px, - self.terminal.width_px, + self.grid_size.rows * self.cell_size.height, + self.grid_size.columns * self.cell_size.width, }, ), .csi_14_t => try std.fmt.bufPrint( &buf, "\x1b[4;{};{}t", .{ - self.terminal.height_px, - self.terminal.width_px, + self.grid_size.rows * self.cell_size.height, + self.grid_size.columns * self.cell_size.width, }, ), .csi_16_t => try std.fmt.bufPrint( &buf, "\x1b[6;{};{}t", .{ - self.terminal.height_px / self.grid_size.rows, - self.terminal.width_px / self.grid_size.columns, + self.cell_size.height, + self.cell_size.width, }, ), .csi_18_t => try std.fmt.bufPrint( diff --git a/src/termio/Thread.zig b/src/termio/Thread.zig index 4c75b3b9e..0f9cd782e 100644 --- a/src/termio/Thread.zig +++ b/src/termio/Thread.zig @@ -383,6 +383,7 @@ fn coalesceCallback( cb.io.resize( &cb.data, v.grid_size, + v.cell_size, v.screen_size, v.padding, ) catch |err| { diff --git a/src/termio/message.zig b/src/termio/message.zig index 79b920ad7..22b72235b 100644 --- a/src/termio/message.zig +++ b/src/termio/message.zig @@ -20,6 +20,9 @@ pub const Message = union(enum) { /// The grid size for the given screen size with padding applied. grid_size: renderer.GridSize, + /// The updated cell size. + cell_size: renderer.CellSize, + /// The full screen (drawable) size. This does NOT include padding. /// This should be sent on to the renderer. screen_size: renderer.ScreenSize,