diff --git a/include/ghostty.h b/include/ghostty.h index ed66824ed..56b1fe5dd 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -315,6 +315,7 @@ void ghostty_app_free(ghostty_app_t); bool ghostty_app_tick(ghostty_app_t); void *ghostty_app_userdata(ghostty_app_t); void ghostty_app_keyboard_changed(ghostty_app_t); +void ghostty_app_reload_config(ghostty_app_t); ghostty_surface_config_s ghostty_surface_config_new(); diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 1a9d21e83..bf290f623 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -26,6 +26,9 @@ A59444F729A2ED5200725BBA /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59444F629A2ED5200725BBA /* SettingsView.swift */; }; A5A1F8852A489D6800D1E8BC /* terminfo in Resources */ = {isa = PBXBuildFile; fileRef = A5A1F8842A489D6800D1E8BC /* terminfo */; }; A5B30539299BEAAB0047F10C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5B30538299BEAAB0047F10C /* Assets.xcassets */; }; + A5CDF1912AAF9A5800513312 /* ConfigurationErrors.xib in Resources */ = {isa = PBXBuildFile; fileRef = A5CDF1902AAF9A5800513312 /* ConfigurationErrors.xib */; }; + A5CDF1932AAF9E0800513312 /* ConfigurationErrorsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CDF1922AAF9E0800513312 /* ConfigurationErrorsController.swift */; }; + A5CDF1952AAFA19600513312 /* ConfigurationErrorsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CDF1942AAFA19600513312 /* ConfigurationErrorsView.swift */; }; A5CEAFDC29B8009000646FDA /* SplitView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CEAFDB29B8009000646FDA /* SplitView.swift */; }; A5CEAFDE29B8058B00646FDA /* SplitView.Divider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CEAFDD29B8058B00646FDA /* SplitView.Divider.swift */; }; A5CEAFFF29C2410700646FDA /* Backport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CEAFFE29C2410700646FDA /* Backport.swift */; }; @@ -55,6 +58,9 @@ 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 = ""; }; + A5CDF1902AAF9A5800513312 /* ConfigurationErrors.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ConfigurationErrors.xib; sourceTree = ""; }; + A5CDF1922AAF9E0800513312 /* ConfigurationErrorsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigurationErrorsController.swift; sourceTree = ""; }; + A5CDF1942AAFA19600513312 /* ConfigurationErrorsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigurationErrorsView.swift; sourceTree = ""; }; A5CEAFDB29B8009000646FDA /* SplitView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitView.swift; sourceTree = ""; }; A5CEAFDD29B8058B00646FDA /* SplitView.Divider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitView.Divider.swift; sourceTree = ""; }; A5CEAFFE29C2410700646FDA /* Backport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Backport.swift; sourceTree = ""; }; @@ -112,6 +118,9 @@ isa = PBXGroup; children = ( A59444F629A2ED5200725BBA /* SettingsView.swift */, + A5CDF1902AAF9A5800513312 /* ConfigurationErrors.xib */, + A5CDF1922AAF9E0800513312 /* ConfigurationErrorsController.swift */, + A5CDF1942AAFA19600513312 /* ConfigurationErrorsView.swift */, ); path = Settings; sourceTree = ""; @@ -249,6 +258,7 @@ files = ( A545D1A22A5772CE006E0AE4 /* shell-integration in Resources */, A5A1F8852A489D6800D1E8BC /* terminfo in Resources */, + A5CDF1912AAF9A5800513312 /* ConfigurationErrors.xib in Resources */, A5B30539299BEAAB0047F10C /* Assets.xcassets in Resources */, 857F63812A5E64F200CA4815 /* MainMenu.xib in Resources */, ); @@ -265,6 +275,7 @@ 85DE1C922A6A3DCA00493853 /* PrimaryWindow.swift in Sources */, A5278A9B2AA05B2600CD3039 /* Ghostty.Input.swift in Sources */, A53426352A7DA53D00EBB7A2 /* AppDelegate.swift in Sources */, + A5CDF1952AAFA19600513312 /* ConfigurationErrorsView.swift in Sources */, A55B7BBC29B6FC330055DE60 /* SurfaceView.swift in Sources */, A59444F729A2ED5200725BBA /* SettingsView.swift in Sources */, A5FECBD729D1FC3900022361 /* PrimaryView.swift in Sources */, @@ -272,6 +283,7 @@ A55B7BBE29B701360055DE60 /* Ghostty.SplitView.swift in Sources */, A55B7BB629B6F47F0055DE60 /* AppState.swift in Sources */, A5CEAFDC29B8009000646FDA /* SplitView.swift in Sources */, + A5CDF1932AAF9E0800513312 /* ConfigurationErrorsController.swift in Sources */, A55685E029A03A9F004303CE /* AppError.swift in Sources */, A5FECBD929D2010400022361 /* WindowAccessor.swift in Sources */, A535B9DA299C569B0017E2E4 /* ErrorView.swift in Sources */, diff --git a/macos/Sources/AppDelegate.swift b/macos/Sources/AppDelegate.swift index 4ff6dcb51..6785c3a30 100644 --- a/macos/Sources/AppDelegate.swift +++ b/macos/Sources/AppDelegate.swift @@ -15,6 +15,7 @@ class AppDelegate: NSObject, ObservableObject, NSApplicationDelegate, GhosttyApp @Published var confirmQuit: Bool = false /// Various menu items so that we can programmatically sync the keyboard shortcut with the Ghostty config. + @IBOutlet private var menuReloadConfig: NSMenuItem? @IBOutlet private var menuQuit: NSMenuItem? @IBOutlet private var menuNewWindow: NSMenuItem? @@ -60,12 +61,12 @@ class AppDelegate: NSObject, ObservableObject, NSApplicationDelegate, GhosttyApp "ApplePressAndHoldEnabled": false, ]) - // Sync our menu shortcuts with our Ghostty config - syncMenuShortcuts() - // Let's launch our first window. // TODO: we should detect if we restored windows and if so not launch a new window. windowManager.addInitialWindow() + + // Initial config loading + configDidReload(ghostty) } func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { @@ -127,6 +128,7 @@ class AppDelegate: NSObject, ObservableObject, NSApplicationDelegate, GhosttyApp private func syncMenuShortcuts() { guard ghostty.config != nil else { return } + syncMenuShortcut(action: "reload_config", menuItem: self.menuReloadConfig) syncMenuShortcut(action: "quit", menuItem: self.menuQuit) syncMenuShortcut(action: "new_window", menuItem: self.menuNewWindow) @@ -180,7 +182,17 @@ class AppDelegate: NSObject, ObservableObject, NSApplicationDelegate, GhosttyApp //MARK: - GhosttyAppStateDelegate func configDidReload(_ state: Ghostty.AppState) { + // Config could change keybindings, so update our menu syncMenuShortcuts() + + // If we have configuration errors, we need to show them. + let c = ConfigurationErrorsController.sharedInstance + c.model.errors = state.configErrors() + if (c.model.errors.count > 0) { + if (c.window == nil || !c.window!.isVisible) { + c.showWindow(self) + } + } } //MARK: - Dock Menu @@ -196,6 +208,10 @@ class AppDelegate: NSObject, ObservableObject, NSApplicationDelegate, GhosttyApp //MARK: - IB Actions + @IBAction func reloadConfig(_ sender: Any?) { + ghostty.reloadConfig() + } + @IBAction func newWindow(_ sender: Any?) { windowManager.newWindow() diff --git a/macos/Sources/Features/Settings/ConfigurationErrors.xib b/macos/Sources/Features/Settings/ConfigurationErrors.xib new file mode 100644 index 000000000..d3f94b14d --- /dev/null +++ b/macos/Sources/Features/Settings/ConfigurationErrors.xib @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Sources/Features/Settings/ConfigurationErrorsController.swift b/macos/Sources/Features/Settings/ConfigurationErrorsController.swift new file mode 100644 index 000000000..fc74a2aad --- /dev/null +++ b/macos/Sources/Features/Settings/ConfigurationErrorsController.swift @@ -0,0 +1,45 @@ +import Foundation +import Cocoa +import SwiftUI +import Combine + +class ConfigurationErrorsController: NSWindowController, NSWindowDelegate { + /// Singleton for the errors view. + static let sharedInstance = ConfigurationErrorsController() + + override var windowNibName: NSNib.Name? { "ConfigurationErrors" } + + /// The data model for this view. Update this directly and the associated view will be updated, too. + let model = ConfigurationErrorsView.Model() + + private var cancellable: AnyCancellable? + + //MARK: - NSWindowController + + override func windowWillLoad() { + shouldCascadeWindows = false + + if let c = cancellable { c.cancel() } + cancellable = model.$errors.sink { newValue in + if (newValue.count == 0) { + self.window?.close() + } + } + } + + override func windowDidLoad() { + guard let window = window else { return } + window.center() + window.level = .popUpMenu + window.contentView = NSHostingView(rootView: ConfigurationErrorsView(model: model)) + } + + //MARK: - NSWindowDelegate + + func windowWillClose(_ notification: Notification) { + if let cancellable = cancellable { + cancellable.cancel() + self.cancellable = nil + } + } +} diff --git a/macos/Sources/Features/Settings/ConfigurationErrorsView.swift b/macos/Sources/Features/Settings/ConfigurationErrorsView.swift new file mode 100644 index 000000000..4ad5cf169 --- /dev/null +++ b/macos/Sources/Features/Settings/ConfigurationErrorsView.swift @@ -0,0 +1,59 @@ +import SwiftUI + +struct ConfigurationErrorsView: View { + class Model: ObservableObject { + @Published var errors: [String] = [] + } + + @ObservedObject var model: Model + + var body: some View { + VStack { + HStack { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.yellow) + .font(.system(size: 52)) + .padding() + .frame(alignment: .center) + + Text(""" + ^[\(model.errors.count) error(s) were](inflect: true) found while loading the configuration. \ + Please review the errors below and reload your configuration. + """) + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + } + + GeometryReader { geo in + ScrollView { + VStack(alignment: .leading) { + ForEach(model.errors, id: \.self) { error in + Text(error) + .lineLimit(nil) + .font(.system(size: 12).monospaced()) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .topLeading) + } + + Spacer() + } + .padding(.all) + .frame(minHeight: geo.size.height) + .background(Color.white) + } + } + + HStack { + Spacer() + Button("Reload Configuration") { reloadConfig() } + .padding([.bottom, .trailing]) + } + } + .frame(minWidth: 480, maxWidth: 960, minHeight: 270) + } + + private func reloadConfig() { + guard let delegate = NSApplication.shared.delegate as? AppDelegate else { return } + delegate.reloadConfig(nil) + } +} diff --git a/macos/Sources/Ghostty/AppState.swift b/macos/Sources/Ghostty/AppState.swift index 40225b2b1..c714a4f40 100644 --- a/macos/Sources/Ghostty/AppState.swift +++ b/macos/Sources/Ghostty/AppState.swift @@ -52,7 +52,7 @@ extension Ghostty { } // Initialize the global configuration. - guard let cfg = Self.reloadConfig() else { + guard let cfg = Self.loadConfig() else { readiness = .error return } @@ -109,7 +109,7 @@ extension Ghostty { } /// Initializes a new configuration and loads all the values. - static func reloadConfig() -> ghostty_config_t? { + static func loadConfig() -> ghostty_config_t? { // Initialize the global configuration. guard let cfg = ghostty_config_new() else { AppDelegate.logger.critical("ghostty_config_new failed") @@ -145,6 +145,21 @@ extension Ghostty { return cfg } + /// Returns the configuration errors (if any). + func configErrors() -> [String] { + guard let cfg = self.config else { return [] } + + var errors: [String] = []; + let errCount = ghostty_config_errors_count(cfg) + for i in 0.. ghostty_config_t? { - guard let newConfig = AppState.reloadConfig() else { + guard let newConfig = Self.loadConfig() else { AppDelegate.logger.warning("failed to reload configuration") return nil } diff --git a/macos/Sources/MainMenu.xib b/macos/Sources/MainMenu.xib index a65d314f2..7166541e7 100644 --- a/macos/Sources/MainMenu.xib +++ b/macos/Sources/MainMenu.xib @@ -23,6 +23,7 @@ + @@ -47,6 +48,12 @@ + + + + + + diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 650bee842..e3475cc82 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -779,6 +779,14 @@ pub const CAPI = struct { }; } + /// Reload the configuration. + export fn ghostty_app_reload_config(v: *App) void { + _ = v.reloadConfig() catch |err| { + log.err("error reloading config err={}", .{err}); + return; + }; + } + /// Returns initial surface options. export fn ghostty_surface_config_new() apprt.Surface.Options { return .{}; diff --git a/src/cli_args.zig b/src/cli_args.zig index fc056f43c..7729548d5 100644 --- a/src/cli_args.zig +++ b/src/cli_args.zig @@ -90,7 +90,7 @@ pub fn parse(comptime T: type, alloc: Allocator, dst: *T, iter: anytype) !void { error.InvalidField => try dst._errors.add(arena_alloc, .{ .message = try std.fmt.allocPrintZ( arena_alloc, - "unknown field: {s}", + "{s}: unknown field", .{key}, ), }), diff --git a/src/config/Config.zig b/src/config/Config.zig index 5ca6b27cd..c8a952e03 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -588,6 +588,11 @@ pub fn default(alloc_gpa: Allocator) Allocator.Error!Config { .{ .key = .q, .mods = .{ .super = true } }, .{ .quit = {} }, ); + try result.keybind.set.put( + alloc, + .{ .key = .comma, .mods = .{ .super = true, .shift = true } }, + .{ .reload_config = {} }, + ); try result.keybind.set.put( alloc, @@ -1140,10 +1145,6 @@ pub const Color = struct { g: u8, b: u8, - pub const Error = error{ - InvalidFormat, - }; - /// Convert this to the terminal RGB struct pub fn toTerminalRGB(self: Color) terminal.color.RGB { return .{ .r = self.r, .g = self.g, .b = self.b }; @@ -1170,7 +1171,7 @@ pub const Color = struct { const trimmed = if (input.len != 0 and input[0] == '#') input[1..] else input; // We expect exactly 6 for RRGGBB - if (trimmed.len != 6) return Error.InvalidFormat; + if (trimmed.len != 6) return error.InvalidValue; // Parse the colors two at a time. var result: Color = undefined; @@ -1209,17 +1210,13 @@ pub const Palette = struct { /// The actual value that is updated as we parse. value: terminal.color.Palette = terminal.color.default, - pub const Error = error{ - InvalidFormat, - }; - pub fn parseCLI( self: *Self, input: ?[]const u8, ) !void { const value = input orelse return error.ValueRequired; const eqlIdx = std.mem.indexOf(u8, value, "=") orelse - return Error.InvalidFormat; + return error.InvalidValue; const key = try std.fmt.parseInt(u8, value[0..eqlIdx], 10); const rgb = try Color.parseCLI(value[eqlIdx + 1 ..]); @@ -1321,14 +1318,14 @@ pub const RepeatableFontVariation = struct { pub fn parseCLI(self: *Self, alloc: Allocator, input_: ?[]const u8) !void { const input = input_ orelse return error.ValueRequired; - const eql_idx = std.mem.indexOf(u8, input, "=") orelse return error.InvalidFormat; + const eql_idx = std.mem.indexOf(u8, input, "=") orelse return error.InvalidValue; const whitespace = " \t"; const key = std.mem.trim(u8, input[0..eql_idx], whitespace); const value = std.mem.trim(u8, input[eql_idx + 1 ..], whitespace); - if (key.len != 4) return error.InvalidFormat; + if (key.len != 4) return error.InvalidValue; try self.list.append(alloc, .{ .id = fontpkg.face.Variation.Id.init(@ptrCast(key.ptr)), - .value = std.fmt.parseFloat(f64, value) catch return error.InvalidFormat, + .value = std.fmt.parseFloat(f64, value) catch return error.InvalidValue, }); }