Merge pull request #429 from mitchellh/macos-config-err

macos: show configuration errors in GUI
This commit is contained in:
Mitchell Hashimoto
2023-09-11 15:22:06 -07:00
committed by GitHub
11 changed files with 216 additions and 20 deletions

View File

@ -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();

View File

@ -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 = "<group>"; };
A5B3053D299BEAAB0047F10C /* Ghostty.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Ghostty.entitlements; sourceTree = "<group>"; };
A5CDF1902AAF9A5800513312 /* ConfigurationErrors.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ConfigurationErrors.xib; sourceTree = "<group>"; };
A5CDF1922AAF9E0800513312 /* ConfigurationErrorsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigurationErrorsController.swift; sourceTree = "<group>"; };
A5CDF1942AAFA19600513312 /* ConfigurationErrorsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigurationErrorsView.swift; sourceTree = "<group>"; };
A5CEAFDB29B8009000646FDA /* SplitView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitView.swift; sourceTree = "<group>"; };
A5CEAFDD29B8058B00646FDA /* SplitView.Divider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitView.Divider.swift; sourceTree = "<group>"; };
A5CEAFFE29C2410700646FDA /* Backport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Backport.swift; sourceTree = "<group>"; };
@ -112,6 +118,9 @@
isa = PBXGroup;
children = (
A59444F629A2ED5200725BBA /* SettingsView.swift */,
A5CDF1902AAF9A5800513312 /* ConfigurationErrors.xib */,
A5CDF1922AAF9E0800513312 /* ConfigurationErrorsController.swift */,
A5CDF1942AAFA19600513312 /* ConfigurationErrorsView.swift */,
);
path = Settings;
sourceTree = "<group>";
@ -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 */,

View File

@ -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()

View File

@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="21701" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
<dependencies>
<deployment identifier="macosx"/>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="21701"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
<customObject id="-2" userLabel="File's Owner" customClass="ConfigurationErrorsController" customModule="Ghostty" customModuleProvider="target">
<connections>
<outlet property="window" destination="QvC-M9-y7g" id="H7g-uf-37u"/>
</connections>
</customObject>
<customObject id="-1" userLabel="First Responder" customClass="FirstResponder"/>
<customObject id="-3" userLabel="Application" customClass="NSObject"/>
<window title="Configuration Errors" allowsToolTipsWhenApplicationIsInactive="NO" autorecalculatesKeyViewLoop="NO" restorable="NO" releasedWhenClosed="NO" visibleAtLaunch="NO" animationBehavior="default" id="QvC-M9-y7g" userLabel="Configuration Errors">
<windowStyleMask key="styleMask" titled="YES" closable="YES" miniaturizable="YES" resizable="YES"/>
<windowPositionMask key="initialPositionMask" leftStrut="YES" rightStrut="YES" topStrut="YES" bottomStrut="YES"/>
<rect key="contentRect" x="196" y="240" width="480" height="270"/>
<rect key="screenRect" x="0.0" y="0.0" width="3008" height="1667"/>
<view key="contentView" wantsLayer="YES" id="EiT-Mj-1SZ">
<rect key="frame" x="0.0" y="0.0" width="480" height="270"/>
<autoresizingMask key="autoresizingMask"/>
</view>
<connections>
<outlet property="delegate" destination="-2" id="vqU-UH-2Ks"/>
</connections>
<point key="canvasLocation" x="127.5" y="137.5"/>
</window>
</objects>
</document>

View File

@ -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
}
}
}

View File

@ -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)
}
}

View File

@ -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..<errCount {
let err = ghostty_config_get_error(cfg, UInt32(i))
let message = String(cString: err.message)
errors.append(message)
}
return errors
}
func appTick() {
guard let app = self.app else { return }
@ -156,6 +171,11 @@ extension Ghostty {
NSApplication.shared.terminate(nil)
}
func reloadConfig() {
guard let app = self.app else { return }
ghostty_app_reload_config(app)
}
/// Request that the given surface is closed. This will trigger the full normal surface close event
/// cycle which will call our close surface callback.
func requestClose(surface: ghostty_surface_t) {
@ -271,7 +291,7 @@ extension Ghostty {
}
static func reloadConfig(_ userdata: UnsafeMutableRawPointer?) -> ghostty_config_t? {
guard let newConfig = AppState.reloadConfig() else {
guard let newConfig = Self.loadConfig() else {
AppDelegate.logger.warning("failed to reload configuration")
return nil
}

View File

@ -23,6 +23,7 @@
<outlet property="menuPaste" destination="i27-pK-umN" id="ICc-X2-gV3"/>
<outlet property="menuPreviousSplit" destination="Lic-px-1wg" id="Rto-CG-yRe"/>
<outlet property="menuQuit" destination="4sb-4s-VLi" id="qYN-S1-6UW"/>
<outlet property="menuReloadConfig" destination="KKH-XX-5py" id="Wvp-7J-wqX"/>
<outlet property="menuSelectSplitAbove" destination="0yU-hC-8xF" id="aPc-lS-own"/>
<outlet property="menuSelectSplitBelow" destination="QDz-d9-CBr" id="FsH-Dq-jij"/>
<outlet property="menuSelectSplitLeft" destination="cTK-oy-KuV" id="Jpr-5q-dqz"/>
@ -47,6 +48,12 @@
</menuItem>
<menuItem isSeparatorItem="YES" id="VOq-y0-SEH"/>
<menuItem title="Preferences…" keyEquivalent="," id="BOF-NM-1cW"/>
<menuItem title="Reload Configuration" id="KKH-XX-5py">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="reloadConfig:" target="bbz-4X-AYv" id="h5x-tu-Izk"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="4je-JR-u6R"/>
<menuItem title="Hide Ghostty" keyEquivalent="h" id="Olw-nP-bQN">
<connections>

View File

@ -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 .{};

View File

@ -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},
),
}),

View File

@ -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,
});
}