diff --git a/include/ghostty.h b/include/ghostty.h index 6cc288b8f..0f4c65f56 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -291,7 +291,7 @@ typedef struct { typedef struct { const char* message; -} ghostty_error_s; +} ghostty_diagnostic_s; typedef struct { double tl_px_x; @@ -607,7 +607,6 @@ ghostty_info_s ghostty_info(void); ghostty_config_t ghostty_config_new(); void ghostty_config_free(ghostty_config_t); void ghostty_config_load_cli_args(ghostty_config_t); -void ghostty_config_load_string(ghostty_config_t, const char*, uintptr_t); void ghostty_config_load_default_files(ghostty_config_t); void ghostty_config_load_recursive_files(ghostty_config_t); void ghostty_config_finalize(ghostty_config_t); @@ -615,8 +614,8 @@ bool ghostty_config_get(ghostty_config_t, void*, const char*, uintptr_t); ghostty_input_trigger_s ghostty_config_trigger(ghostty_config_t, const char*, uintptr_t); -uint32_t ghostty_config_errors_count(ghostty_config_t); -ghostty_error_s ghostty_config_get_error(ghostty_config_t, uint32_t); +uint32_t ghostty_config_diagnostics_count(ghostty_config_t); +ghostty_diagnostic_s ghostty_config_get_diagnostic(ghostty_config_t, uint32_t); void ghostty_config_open(); ghostty_app_t ghostty_app_new(const ghostty_runtime_config_s*, 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 d47565fd5..4aaf3e1da 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -365,7 +365,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/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index 95f8ad734..29639c39e 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -22,15 +22,15 @@ extension Ghostty { var errors: [String] { guard let cfg = self.config else { return [] } - var errors: [String] = []; - let errCount = ghostty_config_errors_count(cfg) - for i in 0.. 0 { - logger.warning("config error: \(errCount) configuration errors on reload") - var errors: [String] = []; - for i in 0.. 0 { + logger.warning("config error: \(diagsCount) configuration errors on reload") + var diags: [String] = []; + for i in 0.. Bool { + if let _ = ProcessInfo.processInfo.environment["__XCODE_BUILT_PRODUCTS_DIR_PATHS"] { + return true + } + + return false +} diff --git a/src/App.zig b/src/App.zig index 0d09fed58..c54c67167 100644 --- a/src/App.zig +++ b/src/App.zig @@ -66,6 +66,8 @@ font_grid_set: font.SharedGridSet, last_notification_time: ?std.time.Instant = null, last_notification_digest: u64 = 0, +pub const CreateError = Allocator.Error || font.SharedGridSet.InitError; + /// Initialize the main app instance. This creates the main window, sets /// up the renderer state, compiles the shaders, etc. This is the primary /// "startup" logic. @@ -74,7 +76,7 @@ last_notification_digest: u64 = 0, /// `focusEvent` to set the initial focus state of the app. pub fn create( alloc: Allocator, -) !*App { +) CreateError!*App { var app = try alloc.create(App); errdefer alloc.destroy(app); @@ -150,7 +152,10 @@ pub fn updateConfig(self: *App, config: *const Config) !void { /// Add an initialized surface. This is really only for the runtime /// implementations to call and should NOT be called by general app users. /// The surface must be from the pool. -pub fn addSurface(self: *App, rt_surface: *apprt.Surface) !void { +pub fn addSurface( + self: *App, + rt_surface: *apprt.Surface, +) Allocator.Error!void { try self.surfaces.append(self.alloc, rt_surface); // Since we have non-zero surfaces, we can cancel the quit timer. @@ -225,11 +230,20 @@ fn drainMailbox(self: *App, rt_app: *apprt.App) !void { .reload_config => try self.reloadConfig(rt_app), .open_config => try self.performAction(rt_app, .open_config), .new_window => |msg| try self.newWindow(rt_app, msg), - .close => |surface| try self.closeSurface(surface), - .quit => try self.setQuit(), + .close => |surface| self.closeSurface(surface), .surface_message => |msg| try self.surfaceMessage(msg.surface, msg.message), - .redraw_surface => |surface| try self.redrawSurface(rt_app, surface), - .redraw_inspector => |surface| try self.redrawInspector(rt_app, surface), + .redraw_surface => |surface| self.redrawSurface(rt_app, surface), + .redraw_inspector => |surface| self.redrawInspector(rt_app, surface), + + // If we're quitting, then we set the quit flag and stop + // draining the mailbox immediately. This lets us defer + // mailbox processing to the next tick so that the apprt + // can try to quit as quickly as possible. + .quit => { + log.info("quit message received, short circuiting mailbox drain", .{}); + self.setQuit(); + return; + }, } } } @@ -242,7 +256,7 @@ pub fn reloadConfig(self: *App, rt_app: *apprt.App) !void { } } -pub fn closeSurface(self: *App, surface: *Surface) !void { +pub fn closeSurface(self: *App, surface: *Surface) void { if (!self.hasSurface(surface)) return; surface.close(); } @@ -252,12 +266,12 @@ pub fn focusSurface(self: *App, surface: *Surface) void { self.focused_surface = surface; } -fn redrawSurface(self: *App, rt_app: *apprt.App, surface: *apprt.Surface) !void { +fn redrawSurface(self: *App, rt_app: *apprt.App, surface: *apprt.Surface) void { if (!self.hasSurface(&surface.core_surface)) return; rt_app.redrawSurface(surface); } -fn redrawInspector(self: *App, rt_app: *apprt.App, surface: *apprt.Surface) !void { +fn redrawInspector(self: *App, rt_app: *apprt.App, surface: *apprt.Surface) void { if (!self.hasSurface(&surface.core_surface)) return; rt_app.redrawInspector(surface); } @@ -278,7 +292,7 @@ pub fn newWindow(self: *App, rt_app: *apprt.App, msg: Message.NewWindow) !void { } /// Start quitting -pub fn setQuit(self: *App) !void { +pub fn setQuit(self: *App) void { if (self.quit) return; self.quit = true; } @@ -373,7 +387,7 @@ pub fn performAction( switch (action) { .unbind => unreachable, .ignore => {}, - .quit => try self.setQuit(), + .quit => self.setQuit(), .new_window => try self.newWindow(rt_app, .{ .parent = null }), .open_config => try rt_app.performAction(.app, .open_config, {}), .reload_config => try self.reloadConfig(rt_app), diff --git a/src/Surface.zig b/src/Surface.zig index 116b98214..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, }, @@ -4011,7 +4014,7 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .close_surface => self.close(), - .close_window => try self.app.closeSurface(self), + .close_window => self.app.closeSurface(self), .crash => |location| switch (location) { .main => @panic("crash binding action, crashing intentionally"), diff --git a/src/apprt/glfw.zig b/src/apprt/glfw.zig index cbb9b7e48..668dd9143 100644 --- a/src/apprt/glfw.zig +++ b/src/apprt/glfw.zig @@ -11,6 +11,7 @@ const Allocator = std.mem.Allocator; const glfw = @import("glfw"); const macos = @import("macos"); const objc = @import("objc"); +const cli = @import("../cli.zig"); const input = @import("../input.zig"); const internal_os = @import("../os/main.zig"); const renderer = @import("../renderer.zig"); @@ -69,13 +70,26 @@ pub const App = struct { errdefer config.deinit(); // If we had configuration errors, then log them. - if (!config._errors.empty()) { - for (config._errors.list.items) |err| { - log.warn("configuration error: {s}", .{err.message}); + if (!config._diagnostics.empty()) { + var buf = std.ArrayList(u8).init(core_app.alloc); + defer buf.deinit(); + for (config._diagnostics.items()) |diag| { + try diag.write(buf.writer()); + log.warn("configuration error: {s}", .{buf.items}); + buf.clearRetainingCapacity(); + } + + // If we have any CLI errors, exit. + if (config._diagnostics.containsLocation(.cli)) { + log.warn("CLI errors detected, exiting", .{}); + _ = core_app.mailbox.push(.{ + .quit = {}, + }, .{ .forever = {} }); } } // Queue a single new window that starts on launch + // Note: above we may send a quit so this may never happen _ = core_app.mailbox.push(.{ .new_window = .{}, }, .{ .forever = {} }); diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index 67d08812e..10a2bdc02 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -123,9 +123,19 @@ pub fn init(core_app: *CoreApp, opts: Options) !App { errdefer config.deinit(); // If we had configuration errors, then log them. - if (!config._errors.empty()) { - for (config._errors.list.items) |err| { - log.warn("configuration error: {s}", .{err.message}); + if (!config._diagnostics.empty()) { + var buf = std.ArrayList(u8).init(core_app.alloc); + defer buf.deinit(); + for (config._diagnostics.items()) |diag| { + try diag.write(buf.writer()); + log.warn("configuration error: {s}", .{buf.items}); + buf.clearRetainingCapacity(); + } + + // If we have any CLI errors, exit. + if (config._diagnostics.containsLocation(.cli)) { + log.warn("CLI errors detected, exiting", .{}); + std.posix.exit(1); } } @@ -815,7 +825,7 @@ fn syncConfigChanges(self: *App) !void { /// there are new configuration errors and hide the window if the errors /// are resolved. fn updateConfigErrors(self: *App) !void { - if (!self.config._errors.empty()) { + if (!self.config._diagnostics.empty()) { if (self.config_errors_window == null) { try ConfigErrorsWindow.create(self); assert(self.config_errors_window != null); @@ -1364,10 +1374,7 @@ fn gtkActionQuit( ud: ?*anyopaque, ) callconv(.C) void { const self: *App = @ptrCast(@alignCast(ud orelse return)); - self.core_app.setQuit() catch |err| { - log.warn("error setting quit err={}", .{err}); - return; - }; + self.core_app.setQuit(); } /// Action sent by the window manager asking us to present a specific surface to diff --git a/src/apprt/gtk/ConfigErrorsWindow.zig b/src/apprt/gtk/ConfigErrorsWindow.zig index 44fa83363..3f8ba3205 100644 --- a/src/apprt/gtk/ConfigErrorsWindow.zig +++ b/src/apprt/gtk/ConfigErrorsWindow.zig @@ -28,7 +28,7 @@ pub fn create(app: *App) !void { } pub fn update(self: *ConfigErrors) void { - if (self.app.config._errors.empty()) { + if (self.app.config._diagnostics.empty()) { c.gtk_window_destroy(@ptrCast(self.window)); return; } @@ -130,8 +130,21 @@ const PrimaryView = struct { const buf = c.gtk_text_buffer_new(null); errdefer c.g_object_unref(buf); - for (config._errors.list.items) |err| { - c.gtk_text_buffer_insert_at_cursor(buf, err.message, @intCast(err.message.len)); + var msg_buf: [4096]u8 = undefined; + var fbs = std.io.fixedBufferStream(&msg_buf); + + for (config._diagnostics.items()) |diag| { + fbs.reset(); + diag.write(fbs.writer()) catch |err| { + log.warn( + "error writing diagnostic to buffer err={}", + .{err}, + ); + continue; + }; + + const msg = fbs.getWritten(); + c.gtk_text_buffer_insert_at_cursor(buf, msg.ptr, @intCast(msg.len)); c.gtk_text_buffer_insert_at_cursor(buf, "\n", -1); } diff --git a/src/cli.zig b/src/cli.zig index 871060b02..4336501a8 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -1,5 +1,10 @@ +const diags = @import("cli/diagnostics.zig"); + pub const args = @import("cli/args.zig"); pub const Action = @import("cli/action.zig").Action; +pub const DiagnosticList = diags.DiagnosticList; +pub const Diagnostic = diags.Diagnostic; +pub const Location = diags.Location; test { @import("std").testing.refAllDecls(@This()); diff --git a/src/cli/args.zig b/src/cli/args.zig index 2244a801d..bfd40c633 100644 --- a/src/cli/args.zig +++ b/src/cli/args.zig @@ -3,8 +3,10 @@ const mem = std.mem; const assert = std.debug.assert; const Allocator = mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; - -const ErrorList = @import("../config/ErrorList.zig"); +const diags = @import("diagnostics.zig"); +const internal_os = @import("../os/main.zig"); +const Diagnostic = diags.Diagnostic; +const DiagnosticList = diags.DiagnosticList; // TODO: // - Only `--long=value` format is accepted. Do we want to allow @@ -32,13 +34,18 @@ pub const Error = error{ /// an arena allocator will be created (or reused if set already) for any /// allocations. Allocations are necessary for certain types, like `[]const u8`. /// -/// If the destination type has a field "_errors" of type "ErrorList" then -/// errors will be added to that list. In this case, the only error returned by -/// parse are allocation errors. +/// If the destination type has a field "_diagnostics", it must be of type +/// "DiagnosticList" and any diagnostic messages will be added to that list. +/// When diagnostics are present, only allocation errors will be returned. /// /// Note: If the arena is already non-null, then it will be used. In this /// case, in the case of an error some memory might be leaked into the arena. -pub fn parse(comptime T: type, alloc: Allocator, dst: *T, iter: anytype) !void { +pub fn parse( + comptime T: type, + alloc: Allocator, + dst: *T, + iter: anytype, +) !void { const info = @typeInfo(T); assert(info == .Struct); @@ -69,7 +76,11 @@ pub fn parse(comptime T: type, alloc: Allocator, dst: *T, iter: anytype) !void { while (iter.next()) |arg| { // Do manual parsing if we have a hook for it. if (@hasDecl(T, "parseManuallyHook")) { - if (!try dst.parseManuallyHook(arena_alloc, arg, iter)) return; + if (!try dst.parseManuallyHook( + arena_alloc, + arg, + iter, + )) return; } // If the destination supports help then we check for it, call @@ -83,69 +94,66 @@ pub fn parse(comptime T: type, alloc: Allocator, dst: *T, iter: anytype) !void { } } - if (mem.startsWith(u8, arg, "--")) { - var key: []const u8 = arg[2..]; - const value: ?[]const u8 = value: { - // If the arg has "=" then the value is after the "=". - if (mem.indexOf(u8, key, "=")) |idx| { - defer key = key[0..idx]; - break :value key[idx + 1 ..]; - } + // If this doesn't start with "--" then it isn't a config + // flag. We don't support positional arguments or configuration + // values set with spaces so this is an error. + if (!mem.startsWith(u8, arg, "--")) { + if (comptime !canTrackDiags(T)) return Error.InvalidField; - break :value null; - }; + // Add our diagnostic + try dst._diagnostics.append(arena_alloc, .{ + .key = try arena_alloc.dupeZ(u8, arg), + .message = "invalid field", + .location = diags.Location.fromIter(iter), + }); - parseIntoField(T, arena_alloc, dst, key, value) catch |err| { - if (comptime !canTrackErrors(T)) return err; - - // The error set is dependent on comptime T, so we always add - // an extra error so we can have the "else" below. - const ErrSet = @TypeOf(err) || error{Unknown}; - switch (@as(ErrSet, @errorCast(err))) { - // OOM is not recoverable since we need to allocate to - // track more error messages. - error.OutOfMemory => return err, - - error.InvalidField => try dst._errors.add(arena_alloc, .{ - .message = try std.fmt.allocPrintZ( - arena_alloc, - "{s}: unknown field", - .{key}, - ), - }), - - error.ValueRequired => try dst._errors.add(arena_alloc, .{ - .message = try std.fmt.allocPrintZ( - arena_alloc, - "{s}: value required", - .{key}, - ), - }), - - error.InvalidValue => try dst._errors.add(arena_alloc, .{ - .message = try std.fmt.allocPrintZ( - arena_alloc, - "{s}: invalid value", - .{key}, - ), - }), - - else => try dst._errors.add(arena_alloc, .{ - .message = try std.fmt.allocPrintZ( - arena_alloc, - "{s}: unknown error {}", - .{ key, err }, - ), - }), - } - }; + continue; } + + var key: []const u8 = arg[2..]; + const value: ?[]const u8 = value: { + // If the arg has "=" then the value is after the "=". + if (mem.indexOf(u8, key, "=")) |idx| { + defer key = key[0..idx]; + break :value key[idx + 1 ..]; + } + + break :value null; + }; + + parseIntoField(T, arena_alloc, dst, key, value) catch |err| { + if (comptime !canTrackDiags(T)) return err; + + // The error set is dependent on comptime T, so we always add + // an extra error so we can have the "else" below. + const ErrSet = @TypeOf(err) || error{Unknown}; + const message: [:0]const u8 = switch (@as(ErrSet, @errorCast(err))) { + // OOM is not recoverable since we need to allocate to + // track more error messages. + error.OutOfMemory => return err, + error.InvalidField => "unknown field", + error.ValueRequired => "value required", + error.InvalidValue => "invalid value", + else => try std.fmt.allocPrintZ( + arena_alloc, + "unknown error {}", + .{err}, + ), + }; + + // Add our diagnostic + try dst._diagnostics.append(arena_alloc, .{ + .key = try arena_alloc.dupeZ(u8, key), + .message = message, + .location = diags.Location.fromIter(iter), + }); + }; } } -/// Returns true if this type can track errors. -fn canTrackErrors(comptime T: type) bool { - return @hasField(T, "_errors"); +/// Returns true if this type can track diagnostics. +fn canTrackDiags(comptime T: type) bool { + return @hasField(T, "_diagnostics"); } /// Parse a single key/value pair into the destination type T. @@ -199,15 +207,6 @@ fn parseIntoField( // 3 arg = (self, alloc, input) => void 3 => try @field(dst, field.name).parseCLI(alloc, value), - // 4 arg = (self, alloc, errors, input) => void - 4 => if (comptime canTrackErrors(T)) { - try @field(dst, field.name).parseCLI(alloc, &dst._errors, value); - } else { - var list: ErrorList = .{}; - try @field(dst, field.name).parseCLI(alloc, &list, value); - if (!list.empty()) return error.InvalidValue; - }, - else => @compileError("parseCLI invalid argument count"), } @@ -468,7 +467,28 @@ test "parse: empty value resets to default" { try testing.expect(!data.b); } -test "parse: error tracking" { +test "parse: positional arguments are invalid" { + const testing = std.testing; + + var data: struct { + a: u8 = 42, + _arena: ?ArenaAllocator = null, + } = .{}; + defer if (data._arena) |arena| arena.deinit(); + + var iter = try std.process.ArgIteratorGeneral(.{}).init( + testing.allocator, + "--a=84 what", + ); + defer iter.deinit(); + try testing.expectError( + error.InvalidField, + parse(@TypeOf(data), testing.allocator, &data, &iter), + ); + try testing.expectEqual(@as(u8, 84), data.a); +} + +test "parse: diagnostic tracking" { const testing = std.testing; var data: struct { @@ -476,7 +496,7 @@ test "parse: error tracking" { b: enum { one } = .one, _arena: ?ArenaAllocator = null, - _errors: ErrorList = .{}, + _diagnostics: DiagnosticList = .{}, } = .{}; defer if (data._arena) |arena| arena.deinit(); @@ -488,7 +508,48 @@ test "parse: error tracking" { try parse(@TypeOf(data), testing.allocator, &data, &iter); try testing.expect(data._arena != null); try testing.expectEqualStrings("42", data.a); - try testing.expect(!data._errors.empty()); + try testing.expect(data._diagnostics.items().len == 1); + { + const diag = data._diagnostics.items()[0]; + try testing.expectEqual(diags.Location.none, diag.location); + try testing.expectEqualStrings("what", diag.key); + try testing.expectEqualStrings("unknown field", diag.message); + } +} + +test "parse: diagnostic location" { + const testing = std.testing; + + var data: struct { + a: []const u8 = "", + b: enum { one, two } = .one, + + _arena: ?ArenaAllocator = null, + _diagnostics: DiagnosticList = .{}, + } = .{}; + defer if (data._arena) |arena| arena.deinit(); + + var fbs = std.io.fixedBufferStream( + \\a=42 + \\what + \\b=two + ); + const r = fbs.reader(); + + const Iter = LineIterator(@TypeOf(r)); + var iter: Iter = .{ .r = r, .filepath = "test" }; + try parse(@TypeOf(data), testing.allocator, &data, &iter); + try testing.expect(data._arena != null); + try testing.expectEqualStrings("42", data.a); + try testing.expect(data.b == .two); + try testing.expect(data._diagnostics.items().len == 1); + { + const diag = data._diagnostics.items()[0]; + try testing.expectEqualStrings("what", diag.key); + try testing.expectEqualStrings("unknown field", diag.message); + try testing.expectEqualStrings("test", diag.location.file.path); + try testing.expectEqual(2, diag.location.file.line); + } } test "parseIntoField: ignore underscore-prefixed fields" { @@ -738,62 +799,6 @@ test "parseIntoField: struct with parse func" { try testing.expectEqual(@as([]const u8, "HELLO!"), data.a.v); } -test "parseIntoField: struct with parse func with error tracking" { - const testing = std.testing; - var arena = ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const alloc = arena.allocator(); - - var data: struct { - a: struct { - const Self = @This(); - - pub fn parseCLI( - _: Self, - parse_alloc: Allocator, - errors: *ErrorList, - value: ?[]const u8, - ) !void { - _ = value; - try errors.add(parse_alloc, .{ .message = "OH NO!" }); - } - } = .{}, - - _errors: ErrorList = .{}, - } = .{}; - - try parseIntoField(@TypeOf(data), alloc, &data, "a", "42"); - try testing.expect(!data._errors.empty()); -} - -test "parseIntoField: struct with parse func with unsupported error tracking" { - const testing = std.testing; - var arena = ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const alloc = arena.allocator(); - - var data: struct { - a: struct { - const Self = @This(); - - pub fn parseCLI( - _: Self, - parse_alloc: Allocator, - errors: *ErrorList, - value: ?[]const u8, - ) !void { - _ = value; - try errors.add(parse_alloc, .{ .message = "OH NO!" }); - } - } = .{}, - } = .{}; - - try testing.expectError( - error.InvalidValue, - parseIntoField(@TypeOf(data), alloc, &data, "a", "42"), - ); -} - test "parseIntoField: tagged union" { const testing = std.testing; var arena = ArenaAllocator.init(testing.allocator); @@ -887,6 +892,74 @@ test "parseIntoField: tagged union missing tag" { ); } +/// An iterator that considers its location to be CLI args. It +/// iterates through an underlying iterator and increments a counter +/// to track the current CLI arg index. +/// +/// This also ignores any argument that starts with `+`. It assumes that +/// actions were parsed out before this iterator was created. +pub fn ArgsIterator(comptime Iterator: type) type { + return struct { + const Self = @This(); + + /// The underlying args iterator. + iterator: Iterator, + + /// Our current index into the iterator. This is 1-indexed. + /// The 0 value is used to indicate that we haven't read any + /// values yet. + index: usize = 0, + + pub fn deinit(self: *Self) void { + if (@hasDecl(Iterator, "deinit")) { + self.iterator.deinit(); + } + } + + pub fn next(self: *Self) ?[]const u8 { + const value = self.iterator.next() orelse return null; + self.index += 1; + + // We ignore any argument that starts with "+". This is used + // to indicate actions and are expected to be parsed out before + // this iterator is created. + if (value.len > 0 and value[0] == '+') return self.next(); + + return value; + } + + /// Returns a location for a diagnostic message. + pub fn location(self: *const Self) ?diags.Location { + return .{ .cli = self.index }; + } + }; +} + +/// Create an args iterator for the process args. This will skip argv0. +pub fn argsIterator(alloc_gpa: Allocator) internal_os.args.ArgIterator.InitError!ArgsIterator(internal_os.args.ArgIterator) { + var iter = try internal_os.args.iterator(alloc_gpa); + errdefer iter.deinit(); + _ = iter.next(); // skip argv0 + return .{ .iterator = iter }; +} + +test "ArgsIterator" { + const testing = std.testing; + + const child = try std.process.ArgIteratorGeneral(.{}).init( + testing.allocator, + "--what +list-things --a=42", + ); + const Iter = ArgsIterator(@TypeOf(child)); + var iter: Iter = .{ .iterator = child }; + defer iter.deinit(); + + try testing.expectEqualStrings("--what", iter.next().?); + try testing.expectEqualStrings("--a=42", iter.next().?); + try testing.expectEqual(@as(?[]const u8, null), iter.next()); + try testing.expectEqual(@as(?[]const u8, null), iter.next()); +} + /// Returns an iterator (implements "next") that reads CLI args by line. /// Each CLI arg is expected to be a single line. This is used to implement /// configuration files. @@ -899,7 +972,21 @@ pub fn LineIterator(comptime ReaderType: type) type { /// like 4 years and be wrong about this. pub const MAX_LINE_SIZE = 4096; + /// Our stateful reader. r: ReaderType, + + /// Filepath that is used for diagnostics. This is only used for + /// diagnostic messages so it can be formatted however you want. + /// It is prefixed to the messages followed by the line number. + filepath: []const u8 = "", + + /// The current line that we're on. This is 1-indexed because + /// lines are generally 1-indexed in the real world. The value + /// can be zero if we haven't read any lines yet. + line: usize = 0, + + /// This is the buffer where we store the current entry that + /// is formatted to be compatible with the parse function. entry: [MAX_LINE_SIZE]u8 = [_]u8{ '-', '-' } ++ ([_]u8{0} ** (MAX_LINE_SIZE - 2)), pub fn next(self: *Self) ?[]const u8 { @@ -912,6 +999,9 @@ pub fn LineIterator(comptime ReaderType: type) type { unreachable; } orelse return null; + // Increment our line counter + self.line += 1; + // Trim any whitespace (including CR) around it const trim = std.mem.trim(u8, entry, whitespace ++ "\r"); if (trim.len != entry.len) { @@ -959,11 +1049,22 @@ pub fn LineIterator(comptime ReaderType: type) type { // as CLI args. return self.entry[0 .. buf.len + 2]; } + + /// Returns a location for a diagnostic message. + pub fn location(self: *const Self) ?diags.Location { + // If we have no filepath then we have no location. + if (self.filepath.len == 0) return null; + + return .{ .file = .{ + .path = self.filepath, + .line = self.line, + } }; + } }; } // Constructs a LineIterator (see docs for that). -pub fn lineIterator(reader: anytype) LineIterator(@TypeOf(reader)) { +fn lineIterator(reader: anytype) LineIterator(@TypeOf(reader)) { return .{ .r = reader }; } diff --git a/src/cli/crash_report.zig b/src/cli/crash_report.zig index 0ec6a8ce0..dd5fe99cc 100644 --- a/src/cli/crash_report.zig +++ b/src/cli/crash_report.zig @@ -33,9 +33,9 @@ pub fn run(alloc_gpa: Allocator) !u8 { defer opts.deinit(); { - var iter = try std.process.argsWithAllocator(alloc); + var iter = try args.argsIterator(alloc_gpa); defer iter.deinit(); - try args.parse(Options, alloc, &opts, &iter); + try args.parse(Options, alloc_gpa, &opts, &iter); } const crash_dir = try crash.defaultDir(alloc); diff --git a/src/cli/diagnostics.zig b/src/cli/diagnostics.zig new file mode 100644 index 000000000..e4d390c03 --- /dev/null +++ b/src/cli/diagnostics.zig @@ -0,0 +1,139 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const build_config = @import("../build_config.zig"); + +/// A diagnostic message from parsing. This is used to provide additional +/// human-friendly warnings and errors about the parsed data. +/// +/// All of the memory for the diagnostic is allocated from the arena +/// associated with the config structure. If an arena isn't available +/// then diagnostics are not supported. +pub const Diagnostic = struct { + location: Location = .none, + key: [:0]const u8 = "", + message: [:0]const u8, + + /// Write the full user-friendly diagnostic message to the writer. + pub fn write(self: *const Diagnostic, writer: anytype) !void { + switch (self.location) { + .none => {}, + .cli => |index| try writer.print("cli:{}:", .{index}), + .file => |file| try writer.print( + "{s}:{}:", + .{ file.path, file.line }, + ), + } + + if (self.key.len > 0) { + try writer.print("{s}: ", .{self.key}); + } else if (self.location != .none) { + try writer.print(" ", .{}); + } + + try writer.print("{s}", .{self.message}); + } +}; + +/// The possible locations for a diagnostic message. This is used +/// to provide context for the message. +pub const Location = union(enum) { + none, + cli: usize, + file: struct { + path: []const u8, + line: usize, + }, + + pub const Key = @typeInfo(Location).Union.tag_type.?; + + pub fn fromIter(iter: anytype) Location { + const Iter = t: { + const T = @TypeOf(iter); + break :t switch (@typeInfo(T)) { + .Pointer => |v| v.child, + .Struct => T, + else => return .none, + }; + }; + + if (!@hasDecl(Iter, "location")) return .none; + return iter.location() orelse .none; + } +}; + +/// A list of diagnostics. The "_diagnostics" field must be this type +/// for diagnostics to be supported. If this field is an incorrect type +/// a compile-time error will be raised. +/// +/// This is implemented as a simple wrapper around an array list +/// so that we can inject some logic around adding diagnostics +/// and potentially in the future structure them differently. +pub const DiagnosticList = struct { + /// The list of diagnostics. + list: std.ArrayListUnmanaged(Diagnostic) = .{}, + + /// Precomputed data for diagnostics. This is used specifically + /// when we build libghostty so that we can precompute the messages + /// and return them via the C API without allocating memory at + /// call time. + precompute: Precompute = precompute_init, + + const precompute_enabled = switch (build_config.artifact) { + // We enable precompute for tests so that the logic is + // semantically analyzed and run. + .exe, .wasm_module => builtin.is_test, + + // We specifically want precompute for libghostty. + .lib => true, + }; + const Precompute = if (precompute_enabled) struct { + messages: std.ArrayListUnmanaged([:0]const u8) = .{}, + } else void; + const precompute_init: Precompute = if (precompute_enabled) .{} else {}; + + pub fn append( + self: *DiagnosticList, + alloc: Allocator, + diag: Diagnostic, + ) Allocator.Error!void { + try self.list.append(alloc, diag); + errdefer _ = self.list.pop(); + + if (comptime precompute_enabled) { + var buf = std.ArrayList(u8).init(alloc); + defer buf.deinit(); + try diag.write(buf.writer()); + + const owned: [:0]const u8 = try buf.toOwnedSliceSentinel(0); + errdefer alloc.free(owned); + + try self.precompute.messages.append(alloc, owned); + errdefer _ = self.precompute.messages.pop(); + + assert(self.precompute.messages.items.len == self.list.items.len); + } + } + + pub fn empty(self: *const DiagnosticList) bool { + return self.list.items.len == 0; + } + + pub fn items(self: *const DiagnosticList) []const Diagnostic { + return self.list.items; + } + + /// Returns true if there are any diagnostics for the given + /// location type. + pub fn containsLocation( + self: *const DiagnosticList, + location: Location.Key, + ) bool { + for (self.list.items) |diag| { + if (diag.location == location) return true; + } + + return false; + } +}; 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/cli/list_actions.zig b/src/cli/list_actions.zig index c6a5cf240..8dbadc65a 100644 --- a/src/cli/list_actions.zig +++ b/src/cli/list_actions.zig @@ -29,7 +29,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/cli/list_colors.zig b/src/cli/list_colors.zig index b9a250519..bfe17df7c 100644 --- a/src/cli/list_colors.zig +++ b/src/cli/list_colors.zig @@ -22,7 +22,7 @@ pub fn run(alloc: std.mem.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/cli/list_fonts.zig b/src/cli/list_fonts.zig index 397c85064..aba596b64 100644 --- a/src/cli/list_fonts.zig +++ b/src/cli/list_fonts.zig @@ -53,7 +53,7 @@ pub const Config = struct { /// specific styles. It is not guaranteed that only those styles are returned, /// it will just prioritize fonts that match those styles. pub fn run(alloc: Allocator) !u8 { - var iter = try std.process.argsWithAllocator(alloc); + var iter = try args.argsIterator(alloc); defer iter.deinit(); return try runArgs(alloc, &iter); } diff --git a/src/cli/list_keybinds.zig b/src/cli/list_keybinds.zig index 7e0bbd692..ccd6dfd21 100644 --- a/src/cli/list_keybinds.zig +++ b/src/cli/list_keybinds.zig @@ -52,7 +52,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/cli/list_themes.zig b/src/cli/list_themes.zig index 99b419801..9782951db 100644 --- a/src/cli/list_themes.zig +++ b/src/cli/list_themes.zig @@ -91,7 +91,7 @@ pub fn run(gpa_alloc: std.mem.Allocator) !u8 { defer opts.deinit(); { - var iter = try std.process.argsWithAllocator(gpa_alloc); + var iter = try args.argsIterator(gpa_alloc); defer iter.deinit(); try args.parse(Options, gpa_alloc, &opts, &iter); } @@ -882,7 +882,7 @@ const Preview = struct { next_start += child.height; } - if (!config._errors.empty()) { + if (config._diagnostics.items().len > 0) { const child = win.child( .{ .x_off = x_off, @@ -891,7 +891,7 @@ const Preview = struct { .limit = width, }, .height = .{ - .limit = if (config._errors.empty()) 0 else 2 + config._errors.list.items.len, + .limit = if (config._diagnostics.items().len == 0) 0 else 2 + config._diagnostics.items().len, }, }, ); @@ -908,10 +908,14 @@ const Preview = struct { }, ); } - for (config._errors.list.items, 0..) |err, i| { + + var buf = std.ArrayList(u8).init(alloc); + defer buf.deinit(); + for (config._diagnostics.items(), 0..) |diag, i| { + try diag.write(buf.writer()); _ = try child.printSegment( .{ - .text = err.message, + .text = buf.items, .style = self.ui_err(), }, .{ @@ -919,6 +923,7 @@ const Preview = struct { .col_offset = 2, }, ); + buf.clearRetainingCapacity(); } next_start += child.height; } diff --git a/src/cli/show_config.zig b/src/cli/show_config.zig index e3f1341e8..cbcd2486d 100644 --- a/src/cli/show_config.zig +++ b/src/cli/show_config.zig @@ -60,7 +60,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/cli/validate_config.zig b/src/cli/validate_config.zig index d6fedc544..1615ef66b 100644 --- a/src/cli/validate_config.zig +++ b/src/cli/validate_config.zig @@ -32,7 +32,7 @@ pub fn run(alloc: std.mem.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); } @@ -46,7 +46,6 @@ pub fn run(alloc: std.mem.Allocator) !u8 { if (opts.@"config-file") |config_path| { var buf: [std.fs.max_path_bytes]u8 = undefined; const abs_path = try std.fs.cwd().realpath(config_path, &buf); - try cfg.loadFile(alloc, abs_path); try cfg.loadRecursiveFiles(alloc); } else { @@ -55,9 +54,14 @@ pub fn run(alloc: std.mem.Allocator) !u8 { try cfg.finalize(); - if (!cfg._errors.empty()) { - for (cfg._errors.list.items) |err| { - try stdout.print("{s}\n", .{err.message}); + if (cfg._diagnostics.items().len > 0) { + var buf = std.ArrayList(u8).init(alloc); + defer buf.deinit(); + + for (cfg._diagnostics.items()) |diag| { + try diag.write(buf.writer()); + try stdout.print("{s}\n", .{buf.items}); + buf.clearRetainingCapacity(); } return 1; diff --git a/src/config/CAPI.zig b/src/config/CAPI.zig index 1949d6e91..bf86a0954 100644 --- a/src/config/CAPI.zig +++ b/src/config/CAPI.zig @@ -39,24 +39,6 @@ export fn ghostty_config_load_cli_args(self: *Config) void { }; } -/// Load the configuration from a string in the same format as -/// the file-based syntax for the desktop version of the terminal. -export fn ghostty_config_load_string( - self: *Config, - str: [*]const u8, - len: usize, -) void { - config_load_string_(self, str[0..len]) catch |err| { - log.err("error loading config err={}", .{err}); - }; -} - -fn config_load_string_(self: *Config, str: []const u8) !void { - var fbs = std.io.fixedBufferStream(str); - var iter = cli.args.lineIterator(fbs.reader()); - try cli.args.parse(Config, global.alloc, self, &iter); -} - /// Load the configuration from the default file locations. This /// is usually done first. The default file locations are locations /// such as the home directory. @@ -112,14 +94,15 @@ fn config_trigger_( return trigger.cval(); } -export fn ghostty_config_errors_count(self: *Config) u32 { - return @intCast(self._errors.list.items.len); +export fn ghostty_config_diagnostics_count(self: *Config) u32 { + return @intCast(self._diagnostics.items().len); } -export fn ghostty_config_get_error(self: *Config, idx: u32) Error { - if (idx >= self._errors.list.items.len) return .{}; - const err = self._errors.list.items[idx]; - return .{ .message = err.message.ptr }; +export fn ghostty_config_get_diagnostic(self: *Config, idx: u32) Diagnostic { + const items = self._diagnostics.items(); + if (idx >= items.len) return .{}; + const message = self._diagnostics.precompute.messages.items[idx]; + return .{ .message = message.ptr }; } export fn ghostty_config_open() void { @@ -128,7 +111,7 @@ export fn ghostty_config_open() void { }; } -/// Sync with ghostty_error_s -const Error = extern struct { +/// Sync with ghostty_diagnostic_s +const Diagnostic = extern struct { message: [*:0]const u8 = "", }; diff --git a/src/config/Config.zig b/src/config/Config.zig index 765b44db1..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 @@ -1662,10 +1667,9 @@ term: []const u8 = "xterm-ghostty", /// This is set by the CLI parser for deinit. _arena: ?ArenaAllocator = null, -/// List of errors that occurred while loading. This can be accessed directly -/// by callers. It is only underscore-prefixed so it can't be set by the -/// configuration file. -_errors: ErrorList = .{}, +/// List of diagnostics that were generated during the loading of +/// the configuration. +_diagnostics: cli.DiagnosticList = .{}, /// The steps we can use to reload the configuration after it has been loaded /// without reopening the files. This is used in very specific cases such @@ -2261,7 +2265,9 @@ pub fn loadFile(self: *Config, alloc: Allocator, path: []const u8) !void { std.log.info("reading configuration file path={s}", .{path}); var buf_reader = std.io.bufferedReader(file.reader()); - var iter = cli.args.lineIterator(buf_reader.reader()); + const reader = buf_reader.reader(); + const Iter = cli.args.LineIterator(@TypeOf(reader)); + var iter: Iter = .{ .r = reader, .filepath = path }; try self.loadIter(alloc, &iter); try self.expandPaths(std.fs.path.dirname(path).?); } @@ -2364,13 +2370,9 @@ pub fn loadCliArgs(self: *Config, alloc_gpa: Allocator) !void { counter[i] = @field(self, field).list.items.len; } - // Initialize our CLI iterator. The first argument is always assumed - // to be the program name so we skip over that. - var iter = try internal_os.args.iterator(alloc_gpa); + // Initialize our CLI iterator. + var iter = try cli.args.argsIterator(alloc_gpa); defer iter.deinit(); - if (iter.next()) |argv0| log.debug("skipping argv0 value={s}", .{argv0}); - - // Parse the config from the CLI args try self.loadIter(alloc_gpa, &iter); // If we are not loading the default files, then we need to @@ -2446,7 +2448,7 @@ pub fn loadRecursiveFiles(self: *Config, alloc_gpa: Allocator) !void { // We must only load a unique file once if (try loaded.fetchPut(path, {}) != null) { - try self._errors.add(arena_alloc, .{ + try self._diagnostics.append(arena_alloc, .{ .message = try std.fmt.allocPrintZ( arena_alloc, "config-file {s}: cycle detected", @@ -2458,7 +2460,7 @@ pub fn loadRecursiveFiles(self: *Config, alloc_gpa: Allocator) !void { var file = cwd.openFile(path, .{}) catch |err| { if (err != error.FileNotFound or !optional) { - try self._errors.add(arena_alloc, .{ + try self._diagnostics.append(arena_alloc, .{ .message = try std.fmt.allocPrintZ( arena_alloc, "error opening config-file {s}: {}", @@ -2472,7 +2474,9 @@ pub fn loadRecursiveFiles(self: *Config, alloc_gpa: Allocator) !void { log.info("loading config-file path={s}", .{path}); var buf_reader = std.io.bufferedReader(file.reader()); - var iter = cli.args.lineIterator(buf_reader.reader()); + const reader = buf_reader.reader(); + const Iter = cli.args.LineIterator(@TypeOf(reader)); + var iter: Iter = .{ .r = reader, .filepath = path }; try self.loadIter(alloc_gpa, &iter); try self.expandPaths(std.fs.path.dirname(path).?); } @@ -2495,7 +2499,7 @@ fn expandPaths(self: *Config, base: []const u8) !void { try @field(self, field.name).expand( arena_alloc, base, - &self._errors, + &self._diagnostics, ); } } @@ -2503,11 +2507,13 @@ fn expandPaths(self: *Config, base: []const u8) !void { fn loadTheme(self: *Config, theme: []const u8) !void { // Find our theme file and open it. See the open function for details. - const file: std.fs.File = (try themepkg.open( + const themefile = (try themepkg.open( self._arena.?.allocator(), theme, - &self._errors, + &self._diagnostics, )) orelse return; + const path = themefile.path; + const file = themefile.file; defer file.close(); // From this point onwards, we load the theme and do a bit of a dance @@ -2533,7 +2539,9 @@ fn loadTheme(self: *Config, theme: []const u8) !void { // Load our theme var buf_reader = std.io.bufferedReader(file.reader()); - var iter = cli.args.lineIterator(buf_reader.reader()); + const reader = buf_reader.reader(); + const Iter = cli.args.LineIterator(@TypeOf(reader)); + var iter: Iter = .{ .r = reader, .filepath = path }; try new_config.loadIter(alloc_gpa, &iter); // Replay our previous inputs so that we can override values @@ -2697,7 +2705,12 @@ pub fn finalize(self: *Config) !void { /// Callback for src/cli/args.zig to allow us to handle special cases /// like `--help` or `-e`. Returns "false" if the CLI parsing should halt. -pub fn parseManuallyHook(self: *Config, alloc: Allocator, arg: []const u8, iter: anytype) !bool { +pub fn parseManuallyHook( + self: *Config, + alloc: Allocator, + arg: []const u8, + iter: anytype, +) !bool { // Keep track of our input args no matter what.. try self._replay_steps.append(alloc, .{ .arg = try alloc.dupe(u8, arg) }); @@ -2714,7 +2727,8 @@ pub fn parseManuallyHook(self: *Config, alloc: Allocator, arg: []const u8, iter: } if (command.items.len == 0) { - try self._errors.add(alloc, .{ + try self._diagnostics.append(alloc, .{ + .location = cli.Location.fromIter(iter), .message = try std.fmt.allocPrintZ( alloc, "missing command after {s}", @@ -2758,7 +2772,10 @@ pub fn shallowClone(self: *const Config, alloc_gpa: Allocator) Config { /// Create a copy of this configuration. This is useful as a starting /// point for modifying a configuration since a config can NOT be /// modified once it is in use by an app or surface. -pub fn clone(self: *const Config, alloc_gpa: Allocator) !Config { +pub fn clone( + self: *const Config, + alloc_gpa: Allocator, +) Allocator.Error!Config { // Start with an empty config with a new arena we're going // to use for all our copies. var result: Config = .{ @@ -2779,7 +2796,11 @@ pub fn clone(self: *const Config, alloc_gpa: Allocator) !Config { return result; } -fn cloneValue(alloc: Allocator, comptime T: type, src: T) !T { +fn cloneValue( + alloc: Allocator, + comptime T: type, + src: T, +) Allocator.Error!T { // Do known named types first switch (T) { []const u8 => return try alloc.dupe(u8, src), @@ -3129,7 +3150,7 @@ pub const Color = packed struct(u24) { } /// Deep copy of the struct. Required by Config. - pub fn clone(self: Color, _: Allocator) !Color { + pub fn clone(self: Color, _: Allocator) error{}!Color { return self; } @@ -3225,7 +3246,7 @@ pub const Palette = struct { } /// Deep copy of the struct. Required by Config. - pub fn clone(self: Self, _: Allocator) !Self { + pub fn clone(self: Self, _: Allocator) error{}!Self { return self; } @@ -3301,7 +3322,7 @@ pub const RepeatableString = struct { } /// Deep copy of the struct. Required by Config. - pub fn clone(self: *const Self, alloc: Allocator) !Self { + pub fn clone(self: *const Self, alloc: Allocator) Allocator.Error!Self { // Copy the list and all the strings in the list. const list = try self.list.clone(alloc); for (list.items) |*item| { @@ -3445,7 +3466,7 @@ pub const RepeatablePath = struct { } /// Deep copy of the struct. Required by Config. - pub fn clone(self: *const Self, alloc: Allocator) !Self { + pub fn clone(self: *const Self, alloc: Allocator) Allocator.Error!Self { const value = try self.value.clone(alloc); for (value.items) |*item| { switch (item.*) { @@ -3499,7 +3520,7 @@ pub const RepeatablePath = struct { self: *Self, alloc: Allocator, base: []const u8, - errors: *ErrorList, + diags: *cli.DiagnosticList, ) !void { assert(std.fs.path.isAbsolute(base)); var dir = try std.fs.cwd().openDir(base, .{}); @@ -3526,7 +3547,7 @@ pub const RepeatablePath = struct { break :abs buf[0..resolved.len]; } - try errors.add(alloc, .{ + try diags.append(alloc, .{ .message = try std.fmt.allocPrintZ( alloc, "error resolving file path {s}: {}", @@ -3656,7 +3677,7 @@ pub const RepeatableFontVariation = struct { } /// Deep copy of the struct. Required by Config. - pub fn clone(self: *const Self, alloc: Allocator) !Self { + pub fn clone(self: *const Self, alloc: Allocator) Allocator.Error!Self { return .{ .list = try self.list.clone(alloc), }; @@ -3789,7 +3810,7 @@ pub const Keybinds = struct { } /// Deep copy of the struct. Required by Config. - pub fn clone(self: *const Keybinds, alloc: Allocator) !Keybinds { + pub fn clone(self: *const Keybinds, alloc: Allocator) Allocator.Error!Keybinds { return .{ .set = try self.set.clone(alloc) }; } @@ -3944,7 +3965,7 @@ pub const RepeatableCodepointMap = struct { } /// Deep copy of the struct. Required by Config. - pub fn clone(self: *const Self, alloc: Allocator) !Self { + pub fn clone(self: *const Self, alloc: Allocator) Allocator.Error!Self { return .{ .map = try self.map.clone(alloc) }; } @@ -4227,7 +4248,7 @@ pub const FontStyle = union(enum) { } /// Deep copy of the struct. Required by Config. - pub fn clone(self: Self, alloc: Allocator) !Self { + pub fn clone(self: Self, alloc: Allocator) Allocator.Error!Self { return switch (self) { .default, .false => self, .name => |v| .{ .name = try alloc.dupeZ(u8, v) }, @@ -4332,7 +4353,7 @@ pub const RepeatableLink = struct { } /// Deep copy of the struct. Required by Config. - pub fn clone(self: *const Self, alloc: Allocator) !Self { + pub fn clone(self: *const Self, alloc: Allocator) error{}!Self { _ = self; _ = alloc; return .{}; @@ -4539,7 +4560,7 @@ pub const Duration = struct { .{ .name = "ns", .factor = 1 }, }; - pub fn clone(self: *const Duration, _: Allocator) !Duration { + pub fn clone(self: *const Duration, _: Allocator) error{}!Duration { return .{ .duration = self.duration }; } @@ -4661,7 +4682,7 @@ pub const WindowPadding = struct { top_left: u32 = 0, bottom_right: u32 = 0, - pub fn clone(self: Self, _: Allocator) !Self { + pub fn clone(self: Self, _: Allocator) error{}!Self { return self; } diff --git a/src/config/theme.zig b/src/config/theme.zig index fdb5dd08a..4616d6363 100644 --- a/src/config/theme.zig +++ b/src/config/theme.zig @@ -4,7 +4,7 @@ const assert = std.debug.assert; const Allocator = std.mem.Allocator; const global_state = &@import("../global.zig").state; const internal_os = @import("../os/main.zig"); -const ErrorList = @import("ErrorList.zig"); +const cli = @import("../cli.zig"); /// Location of possible themes. The order of this enum matters because it /// defines the priority of theme search (from top to bottom). @@ -107,19 +107,25 @@ pub const LocationIterator = struct { pub fn open( arena_alloc: Allocator, theme: []const u8, - errors: *ErrorList, -) error{OutOfMemory}!?std.fs.File { + diags: *cli.DiagnosticList, +) error{OutOfMemory}!?struct { + path: []const u8, + file: std.fs.File, +} { // Absolute themes are loaded a different path. - if (std.fs.path.isAbsolute(theme)) return try openAbsolute( - arena_alloc, - theme, - errors, - ); + if (std.fs.path.isAbsolute(theme)) { + const file: std.fs.File = try openAbsolute( + arena_alloc, + theme, + diags, + ) orelse return null; + return .{ .path = theme, .file = file }; + } const basename = std.fs.path.basename(theme); if (!std.mem.eql(u8, theme, basename)) { - try errors.add(arena_alloc, .{ + try diags.append(arena_alloc, .{ .message = try std.fmt.allocPrintZ( arena_alloc, "theme \"{s}\" cannot include path separators unless it is an absolute path", @@ -135,15 +141,16 @@ pub fn open( const cwd = std.fs.cwd(); while (try it.next()) |loc| { const path = try std.fs.path.join(arena_alloc, &.{ loc.dir, theme }); - if (cwd.openFile(path, .{})) |file| { - return file; + if (cwd.openFile(path, .{})) |file| return .{ + .path = path, + .file = file, } else |err| switch (err) { // Not an error, just continue to the next location. error.FileNotFound => {}, // Anything else is an error we log and give up on. else => { - try errors.add(arena_alloc, .{ + try diags.append(arena_alloc, .{ .message = try std.fmt.allocPrintZ( arena_alloc, "failed to load theme \"{s}\" from the file \"{s}\": {}", @@ -163,7 +170,7 @@ pub fn open( it.reset(); while (try it.next()) |loc| { const path = try std.fs.path.join(arena_alloc, &.{ loc.dir, theme }); - try errors.add(arena_alloc, .{ + try diags.append(arena_alloc, .{ .message = try std.fmt.allocPrintZ( arena_alloc, "theme \"{s}\" not found, tried path \"{s}\"", @@ -186,18 +193,18 @@ pub fn open( pub fn openAbsolute( arena_alloc: Allocator, theme: []const u8, - errors: *ErrorList, + diags: *cli.DiagnosticList, ) error{OutOfMemory}!?std.fs.File { return std.fs.openFileAbsolute(theme, .{}) catch |err| { switch (err) { - error.FileNotFound => try errors.add(arena_alloc, .{ + error.FileNotFound => try diags.append(arena_alloc, .{ .message = try std.fmt.allocPrintZ( arena_alloc, "failed to load theme from the path \"{s}\"", .{theme}, ), }), - else => try errors.add(arena_alloc, .{ + else => try diags.append(arena_alloc, .{ .message = try std.fmt.allocPrintZ( arena_alloc, "failed to load theme from the path \"{s}\": {}", 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/font/Collection.zig b/src/font/Collection.zig index 8f338be16..476787749 100644 --- a/src/font/Collection.zig +++ b/src/font/Collection.zig @@ -118,7 +118,7 @@ pub fn getFace(self: *Collection, index: Index) !*Face { break :item item; }; - return self.getFaceFromEntry(item); + return try self.getFaceFromEntry(item); } /// Get the face from an entry. diff --git a/src/font/SharedGridSet.zig b/src/font/SharedGridSet.zig index f0dd095d5..c3067fa6d 100644 --- a/src/font/SharedGridSet.zig +++ b/src/font/SharedGridSet.zig @@ -46,8 +46,10 @@ font_discover: ?Discover = null, /// Lock to protect multi-threaded access to the map. lock: std.Thread.Mutex = .{}, +pub const InitError = Library.InitError; + /// Initialize a new SharedGridSet. -pub fn init(alloc: Allocator) !SharedGridSet { +pub fn init(alloc: Allocator) InitError!SharedGridSet { var font_lib = try Library.init(); errdefer font_lib.deinit(); @@ -428,7 +430,10 @@ pub const DerivedConfig = struct { /// Initialize a DerivedConfig. The config should be either a /// config.Config or another DerivedConfig to clone from. - pub fn init(alloc_gpa: Allocator, config: anytype) !DerivedConfig { + pub fn init( + alloc_gpa: Allocator, + config: anytype, + ) Allocator.Error!DerivedConfig { var arena = ArenaAllocator.init(alloc_gpa); errdefer arena.deinit(); const alloc = arena.allocator(); @@ -511,7 +516,7 @@ pub const Key = struct { alloc_gpa: Allocator, config_src: *const DerivedConfig, font_size: DesiredSize, - ) !Key { + ) Allocator.Error!Key { var arena = ArenaAllocator.init(alloc_gpa); errdefer arena.deinit(); const alloc = arena.allocator(); diff --git a/src/font/library.zig b/src/font/library.zig index 57e11e64a..b00bbfce0 100644 --- a/src/font/library.zig +++ b/src/font/library.zig @@ -24,7 +24,9 @@ pub const Library = switch (options.backend) { pub const FreetypeLibrary = struct { lib: freetype.Library, - pub fn init() freetype.Error!Library { + pub const InitError = freetype.Error; + + pub fn init() InitError!Library { return Library{ .lib = try freetype.Library.init() }; } @@ -34,7 +36,9 @@ pub const FreetypeLibrary = struct { }; pub const NoopLibrary = struct { - pub fn init() !Library { + pub const InitError = error{}; + + pub fn init() InitError!Library { return Library{}; } 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,