macos: swiftlint 'trailing_whitespace' rule

This commit is contained in:
Jon Parise
2026-02-19 18:56:43 -05:00
parent 33dce8511e
commit b532cd55d6
67 changed files with 659 additions and 660 deletions

View File

@@ -24,7 +24,6 @@ disabled_rules:
- orphaned_doc_comment
- shorthand_operator
- switch_case_alignment
- trailing_whitespace
- unneeded_synthesized_initializer
- unused_closure_parameter
- unused_enumerated

View File

@@ -10,14 +10,14 @@ extension AppDelegate: Ghostty.Delegate {
guard let controller = window.windowController as? BaseTerminalController else {
continue
}
for surface in controller.surfaceTree {
if surface.id == id {
return surface
}
}
}
return nil
}
}

View File

@@ -109,7 +109,7 @@ class AppDelegate: NSObject,
switch quickTerminalControllerState {
case .initialized(let controller):
return controller
case .pendingRestore(let state):
let controller = QuickTerminalController(
ghostty,
@@ -119,7 +119,7 @@ class AppDelegate: NSObject,
)
quickTerminalControllerState = .initialized(controller)
return controller
case .uninitialized:
let controller = QuickTerminalController(
ghostty,
@@ -172,7 +172,7 @@ class AppDelegate: NSObject,
// Disable the automatic full screen menu item because we handle
// it manually.
"NSFullScreenMenuItemEverywhere": false,
// On macOS 26 RC1, the autofill heuristic controller causes unusable levels
// of slowdowns and CPU usage in the terminal window under certain [unknown]
// conditions. We don't know exactly why/how. This disables the full heuristic
@@ -298,12 +298,12 @@ class AppDelegate: NSObject,
case .app:
// Don't have to do anything.
break
case .zig_run, .cli:
// Part of launch services (clicking an app, using `open`, etc.) activates
// the application and brings it to the front. When using the CLI we don't
// get this behavior, so we have to do it manually.
// This never gets called until we click the dock icon. This forces it
// activate immediately.
applicationDidBecomeActive(.init(name: NSApplication.didBecomeActiveNotification))
@@ -353,7 +353,7 @@ class AppDelegate: NSObject,
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
let windows = NSApplication.shared.windows
if windows.isEmpty { return .terminateNow }
// If we've already accepted to install an update, then we don't need to
// confirm quit. The user is already expecting the update to happen.
if updateController.isInstalling {
@@ -448,17 +448,17 @@ class AppDelegate: NSObject,
// Ghostty will validate as well but we can avoid creating an entirely new
// surface by doing our own validation here. We can also show a useful error
// this way.
var isDirectory = ObjCBool(true)
guard FileManager.default.fileExists(atPath: filename, isDirectory: &isDirectory) else { return false }
// Set to true if confirmation is required before starting up the
// new terminal.
var requiresConfirm: Bool = false
// Initialize the surface config which will be used to create the tab or window for the opened file.
var config = Ghostty.SurfaceConfiguration()
if isDirectory.boolValue {
// When opening a directory, check the configuration to decide
// whether to open in a new tab or new window.
@@ -470,24 +470,24 @@ class AppDelegate: NSObject,
// because there is a sandbox escape possible if a sandboxed application
// somehow is tricked into `open`-ing a non-sandboxed application.
requiresConfirm = true
// When opening a file, we want to execute the file. To do this, we
// don't override the command directly, because it won't load the
// profile/rc files for the shell, which is super important on macOS
// due to things like Homebrew. Instead, we set the command to
// `<filename>; exit` which is what Terminal and iTerm2 do.
config.initialInput = "\(Ghostty.Shell.quote(filename)); exit\n"
// For commands executed directly, we want to ensure we wait after exit
// because in most cases scripts don't block on exit and we don't want
// the window to just flash closed once complete.
config.waitAfterCommand = true
// Set the parent directory to our working directory so that relative
// paths in scripts work.
config.workingDirectory = (filename as NSString).deletingLastPathComponent
}
if requiresConfirm {
// Confirmation required. We use an app-wide NSAlert for now. In the future we
// may want to show this as a sheet on the focused window (especially if we're
@@ -500,12 +500,12 @@ class AppDelegate: NSObject,
switch alert.runModal() {
case .alertFirstButtonReturn:
break
default:
return false
}
}
switch ghostty.config.macosDockDropBehavior {
case .new_tab:
_ = TerminalController.newTab(
@@ -515,7 +515,7 @@ class AppDelegate: NSObject,
)
case .new_window: _ = TerminalController.newWindow(ghostty, withBaseConfig: config)
}
return true
}
@@ -1030,18 +1030,18 @@ class AppDelegate: NSObject,
func application(_ app: NSApplication, willEncodeRestorableState coder: NSCoder) {
Self.logger.debug("application will save window state")
guard ghostty.config.windowSaveState != "never" else { return }
// Encode our quick terminal state if we have it.
switch quickTerminalControllerState {
case .initialized(let controller) where controller.restorable:
let data = QuickTerminalRestorableState(from: controller)
data.encode(with: coder)
case .pendingRestore(let state):
state.encode(with: coder)
default:
break
}
@@ -1049,7 +1049,7 @@ class AppDelegate: NSObject,
func application(_ app: NSApplication, didDecodeRestorableState coder: NSCoder) {
Self.logger.debug("application will restore window state")
// Decode our quick terminal state.
if ghostty.config.windowSaveState != "never",
let state = QuickTerminalRestorableState(coder: coder) {

View File

@@ -7,7 +7,7 @@ import GhosttyKit
// rest of the app.
if ghostty_init(UInt(CommandLine.argc), CommandLine.unsafeArgv) != GHOSTTY_SUCCESS {
Ghostty.logger.critical("ghostty_init failed")
// We also write to stderr if this is executed from the CLI or zig run
switch Ghostty.launchSource {
case .cli, .zig_run:
@@ -18,7 +18,7 @@ if ghostty_init(UInt(CommandLine.argc), CommandLine.unsafeArgv) != GHOSTTY_SUCCE
"Actions start with the `+` character.\n\n" +
"View all available actions by running `ghostty +help`.\n")
exit(1)
case .app:
// For the app we exit immediately. We should handle this case more
// gracefully in the future.

View File

@@ -22,7 +22,7 @@ struct CloseTerminalIntent: AppIntent {
guard await requestIntentPermission() else {
throw GhosttyIntentError.permissionDenied
}
guard let surfaceView = terminal.surfaceView else {
throw GhosttyIntentError.surfaceNotFound
}

View File

@@ -29,7 +29,7 @@ struct CommandPaletteIntent: AppIntent {
guard await requestIntentPermission() else {
throw GhosttyIntentError.permissionDenied
}
guard let surface = terminal.surfaceModel else {
throw GhosttyIntentError.surfaceNotFound
}

View File

@@ -79,7 +79,7 @@ extension CommandEntity.ID: EntityIdentifierConvertible {
static func entityIdentifier(for entityIdentifierString: String) -> CommandEntity.ID? {
.init(rawValue: entityIdentifierString)
}
var entityIdentifierString: String {
rawValue
}

View File

@@ -52,7 +52,7 @@ struct TerminalEntity: AppEntity {
if let nsImage = ImageRenderer(content: view.screenshot()).nsImage {
self.screenshot = nsImage
}
// Determine the kind based on the window controller type
if view.window?.windowController is QuickTerminalController {
self.kind = .quick
@@ -66,9 +66,9 @@ extension TerminalEntity {
enum Kind: String, AppEnum {
case normal
case quick
static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Terminal Kind")
static var caseDisplayRepresentations: [Self: DisplayRepresentation] = [
.normal: .init(title: "Normal"),
.quick: .init(title: "Quick")
@@ -112,7 +112,7 @@ struct TerminalQuery: EntityStringQuery, EnumerableEntityQuery {
let controllers = NSApp.windows.compactMap {
$0.windowController as? BaseTerminalController
}
// Get all our surfaces
return controllers.flatMap {
$0.surfaceTree.root?.leaves() ?? []

View File

@@ -31,7 +31,7 @@ struct GetTerminalDetailsIntent: AppIntent {
guard await requestIntentPermission() else {
throw GhosttyIntentError.permissionDenied
}
switch detail {
case .title: return .result(value: terminal.title)
case .workingDirectory: return .result(value: terminal.workingDirectory)

View File

@@ -34,7 +34,7 @@ struct InputTextIntent: AppIntent {
guard await requestIntentPermission() else {
throw GhosttyIntentError.permissionDenied
}
guard let surface = terminal.surfaceModel else {
throw GhosttyIntentError.surfaceNotFound
}
@@ -86,7 +86,7 @@ struct KeyEventIntent: AppIntent {
guard await requestIntentPermission() else {
throw GhosttyIntentError.permissionDenied
}
guard let surface = terminal.surfaceModel else {
throw GhosttyIntentError.surfaceNotFound
}
@@ -95,7 +95,7 @@ struct KeyEventIntent: AppIntent {
let ghosttyMods = mods.reduce(Ghostty.Input.Mods()) { result, mod in
result.union(mod.ghosttyMod)
}
let keyEvent = Ghostty.Input.KeyEvent(
key: key,
action: action,
@@ -150,7 +150,7 @@ struct MouseButtonIntent: AppIntent {
guard await requestIntentPermission() else {
throw GhosttyIntentError.permissionDenied
}
guard let surface = terminal.surfaceModel else {
throw GhosttyIntentError.surfaceNotFound
}
@@ -159,7 +159,7 @@ struct MouseButtonIntent: AppIntent {
let ghosttyMods = mods.reduce(Ghostty.Input.Mods()) { result, mod in
result.union(mod.ghosttyMod)
}
let mouseEvent = Ghostty.Input.MouseButtonEvent(
action: action,
button: button,
@@ -184,7 +184,7 @@ struct MousePosIntent: AppIntent {
var x: Double
@Parameter(
title: "Y Position",
title: "Y Position",
description: "The vertical position of the mouse cursor in pixels.",
default: 0
)
@@ -213,7 +213,7 @@ struct MousePosIntent: AppIntent {
guard await requestIntentPermission() else {
throw GhosttyIntentError.permissionDenied
}
guard let surface = terminal.surfaceModel else {
throw GhosttyIntentError.surfaceNotFound
}
@@ -222,7 +222,7 @@ struct MousePosIntent: AppIntent {
let ghosttyMods = mods.reduce(Ghostty.Input.Mods()) { result, mod in
result.union(mod.ghosttyMod)
}
let mousePosEvent = Ghostty.Input.MousePosEvent(
x: x,
y: y,
@@ -283,7 +283,7 @@ struct MouseScrollIntent: AppIntent {
guard await requestIntentPermission() else {
throw GhosttyIntentError.permissionDenied
}
guard let surface = terminal.surfaceModel else {
throw GhosttyIntentError.surfaceNotFound
}
@@ -306,16 +306,16 @@ enum KeyEventMods: String, AppEnum, CaseIterable {
case control
case option
case command
static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Modifier Key")
static var caseDisplayRepresentations: [KeyEventMods: DisplayRepresentation] = [
.shift: "Shift",
.control: "Control",
.option: "Option",
.command: "Command"
]
var ghosttyMod: Ghostty.Input.Mods {
switch self {
case .shift: .shift

View File

@@ -26,7 +26,7 @@ struct KeybindIntent: AppIntent {
guard await requestIntentPermission() else {
throw GhosttyIntentError.permissionDenied
}
guard let surface = terminal.surfaceModel else {
throw GhosttyIntentError.surfaceNotFound
}

View File

@@ -152,7 +152,7 @@ enum NewTerminalLocation: String {
case splitRight = "split:right"
case splitUp = "split:up"
case splitDown = "split:down"
var splitDirection: SplitTree<Ghostty.SurfaceView>.NewDirection? {
switch self {
case .splitLeft: return .left

View File

@@ -15,7 +15,7 @@ struct QuickTerminalIntent: AppIntent {
guard await requestIntentPermission() else {
throw GhosttyIntentError.permissionDenied
}
guard let delegate = NSApp.delegate as? AppDelegate else {
throw GhosttyIntentError.appUnavailable
}

View File

@@ -45,16 +45,16 @@ struct ClipboardConfirmationView: View {
.font(.system(size: 42))
.padding()
.frame(alignment: .center)
Text(request.text())
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
}
TextEditor(text: .constant(contents))
.focusable(false)
.font(.system(.body, design: .monospaced))
HStack {
Spacer()
Button(Action.text(.cancel, request)) { onCancel() }

View File

@@ -23,7 +23,7 @@ struct CommandOption: Identifiable, Hashable {
let sortKey: AnySortKey?
/// The action to perform when this option is selected.
let action: () -> Void
init(
title: String,
subtitle: String? = nil,
@@ -78,7 +78,7 @@ struct CommandPaletteView: View {
($0.subtitle?.localizedCaseInsensitiveContains(query) ?? false) ||
colorMatchScore(for: $0.leadingColor, query: query) > 0
}
// Sort by color match score (higher scores first), then maintain original order
return filtered.sorted { a, b in
let scoreA = colorMatchScore(for: a.leadingColor, query: query)
@@ -200,20 +200,20 @@ struct CommandPaletteView: View {
isTextFieldFocused = isPresented
}
}
/// Returns a score (0.0 to 1.0) indicating how well a color matches a search query color name.
/// Returns 0 if no color name in the query matches, or if the color is nil.
private func colorMatchScore(for color: Color?, query: String) -> Double {
guard let color = color else { return 0 }
let queryLower = query.lowercased()
let nsColor = NSColor(color)
var bestScore: Double = 0
for name in NSColor.colorNames {
guard queryLower.contains(name),
let systemColor = NSColor(named: name) else { continue }
let distance = nsColor.distance(to: systemColor)
// Max distance in weighted RGB space is ~3.0, so normalize and invert
// Use a threshold to determine "close enough" matches
@@ -223,7 +223,7 @@ struct CommandPaletteView: View {
bestScore = max(bestScore, score)
}
}
return bestScore
}
}
@@ -346,26 +346,26 @@ private struct CommandRow: View {
.fill(color)
.frame(width: 8, height: 8)
}
if let icon = option.leadingIcon {
Image(systemName: icon)
.foregroundStyle(option.emphasis ? Color.accentColor : .secondary)
.font(.system(size: 14, weight: .medium))
}
VStack(alignment: .leading, spacing: 2) {
Text(option.title)
.fontWeight(option.emphasis ? .medium : .regular)
if let subtitle = option.subtitle {
Text(subtitle)
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer()
if let badge = option.badge, !badge.isEmpty {
Text(badge)
.font(.caption2.weight(.medium))
@@ -376,7 +376,7 @@ private struct CommandRow: View {
)
.foregroundStyle(Color.accentColor)
}
if let symbols = option.symbols {
ShortcutSymbolsView(symbols: symbols)
.foregroundStyle(.secondary)

View File

@@ -11,7 +11,7 @@ struct TerminalCommandPaletteView: View {
/// The configuration so we can lookup keyboard shortcuts.
@ObservedObject var ghosttyConfig: Ghostty.Config
/// The update view model for showing update commands.
var updateViewModel: UpdateViewModel?
@@ -54,13 +54,13 @@ struct TerminalCommandPaletteView: View {
}
}
}
/// All commands available in the command palette, combining update and terminal options.
private var commandOptions: [CommandOption] {
var options: [CommandOption] = []
// Updates always appear first
options.append(contentsOf: updateOptions)
// Sort the rest. We replace ":" with a character that sorts before space
// so that "Foo:" sorts before "Foo Bar:". Use sortKey as a tie-breaker
// for stable ordering when titles are equal.
@@ -83,11 +83,11 @@ struct TerminalCommandPaletteView: View {
/// Commands for installing or canceling available updates.
private var updateOptions: [CommandOption] {
var options: [CommandOption] = []
guard let updateViewModel, updateViewModel.state.isInstallable else {
return options
}
// We override the update available one only because we want to properly
// convey it'll go all the way through.
let title: String
@@ -96,7 +96,7 @@ struct TerminalCommandPaletteView: View {
} else {
title = updateViewModel.text
}
options.append(CommandOption(
title: title,
description: updateViewModel.description,
@@ -106,14 +106,14 @@ struct TerminalCommandPaletteView: View {
) {
(NSApp.delegate as? AppDelegate)?.updateController.installUpdate()
})
options.append(CommandOption(
title: "Cancel or Skip Update",
description: "Dismiss the current update process"
) {
updateViewModel.state.cancel()
})
return options
}

View File

@@ -29,7 +29,7 @@ class QuickTerminalController: BaseTerminalController {
/// The configuration derived from the Ghostty config so we don't need to rely on references.
private var derivedConfig: DerivedConfig
/// Tracks if we're currently handling a manual resize to prevent recursion
private var isHandlingResize: Bool = false
@@ -135,14 +135,14 @@ class QuickTerminalController: BaseTerminalController {
if let qtWindow = window as? QuickTerminalWindow {
qtWindow.initialFrame = window.frame
}
// Setup our content
window.contentView = TerminalViewContainer(
ghostty: self.ghostty,
viewModel: self,
delegate: self
)
// Clear out our frame at this point, the fixup from above is complete.
if let qtWindow = window as? QuickTerminalWindow {
qtWindow.initialFrame = nil
@@ -234,7 +234,7 @@ class QuickTerminalController: BaseTerminalController {
// Prevent recursive loops
isHandlingResize = true
defer { isHandlingResize = false }
switch position {
case .top, .bottom, .center:
// For centered positions (top, bottom, center), we need to recenter the window
@@ -369,7 +369,7 @@ class QuickTerminalController: BaseTerminalController {
} else {
var config = Ghostty.SurfaceConfiguration()
config.environmentVariables["GHOSTTY_QUICK_TERMINAL"] = "1"
let view = Ghostty.SurfaceView(ghostty_app, baseConfig: config)
surfaceTree = SplitTree(view: view)
focusedSurface = view
@@ -416,7 +416,7 @@ class QuickTerminalController: BaseTerminalController {
private func animateWindowIn(window: NSWindow, from position: QuickTerminalPosition) {
guard let screen = derivedConfig.quickTerminalScreen.screen else { return }
// Grab our last closed frame to use from the cache.
let closedFrame = screenStateCache.frame(for: screen)

View File

@@ -144,25 +144,25 @@ enum QuickTerminalPosition: String {
x: round(screen.visibleFrame.origin.x + (screen.visibleFrame.width - window.frame.width) / 2),
y: window.frame.origin.y // Keep the same Y position
)
case .bottom:
return CGPoint(
x: round(screen.visibleFrame.origin.x + (screen.visibleFrame.width - window.frame.width) / 2),
y: window.frame.origin.y // Keep the same Y position
)
case .center:
return CGPoint(
x: round(screen.visibleFrame.origin.x + (screen.visibleFrame.width - window.frame.width) / 2),
y: round(screen.visibleFrame.origin.y + (screen.visibleFrame.height - window.frame.height) / 2)
)
case .left, .right:
// For left/right positions, only adjust horizontal centering if needed
return window.frame.origin
}
}
/// Calculate the vertically centered origin for side-positioned windows
func verticallyCenteredOrigin(for window: NSWindow, on screen: NSScreen) -> CGPoint {
switch self {
@@ -171,13 +171,13 @@ enum QuickTerminalPosition: String {
x: window.frame.origin.x, // Keep the same X position
y: round(screen.visibleFrame.origin.y + (screen.visibleFrame.height - window.frame.height) / 2)
)
case .right:
return CGPoint(
x: window.frame.origin.x, // Keep the same X position
y: round(screen.visibleFrame.origin.y + (screen.visibleFrame.height - window.frame.height) / 2)
)
case .top, .bottom, .center:
// These positions don't need vertical recentering during resize
return window.frame.origin

View File

@@ -12,10 +12,10 @@ enum QuickTerminalScreen {
case "mouse":
self = .mouse
case "macos-menu-bar":
self = .menuBar
default:
return nil
}

View File

@@ -8,15 +8,15 @@ import Cocoa
/// to survive NSScreen garbage collection and automatically prunes stale entries.
class QuickTerminalScreenStateCache {
typealias Entries = [UUID: DisplayEntry]
/// The maximum number of saved screen states we retain. This is to avoid some kind of
/// pathological memory growth in case we get our screen state serializing wrong. I don't
/// know anyone with more than 10 screens, so let's just arbitrarily go with that.
private static let maxSavedScreens = 10
/// Time-to-live for screen entries that are no longer present (14 days).
private static let screenStaleTTL: TimeInterval = 14 * 24 * 60 * 60
/// Keyed by display UUID to survive NSScreen garbage collection.
private(set) var stateByDisplay: Entries = [:]
@@ -28,11 +28,11 @@ class QuickTerminalScreenStateCache {
name: NSApplication.didChangeScreenParametersNotification,
object: nil)
}
deinit {
NotificationCenter.default.removeObserver(self)
}
/// Save the window frame for a screen.
func save(frame: NSRect, for screen: NSScreen) {
guard let key = screen.displayUUID else { return }
@@ -45,27 +45,27 @@ class QuickTerminalScreenStateCache {
stateByDisplay[key] = entry
pruneCapacity()
}
/// Retrieve the last closed frame for a screen, if valid.
func frame(for screen: NSScreen) -> NSRect? {
guard let key = screen.displayUUID, var entry = stateByDisplay[key] else { return nil }
// Drop on dimension/scale change that makes the entry invalid
if !entry.isValid(for: screen) {
stateByDisplay.removeValue(forKey: key)
return nil
}
entry.lastSeen = Date()
stateByDisplay[key] = entry
return entry.frame
}
@objc private func onScreensChanged(_ note: Notification) {
let screens = NSScreen.screens
let now = Date()
let currentIDs = Set(screens.compactMap { $0.displayUUID })
for screen in screens {
guard let key = screen.displayUUID else { continue }
if var entry = stateByDisplay[key] {
@@ -80,15 +80,15 @@ class QuickTerminalScreenStateCache {
}
}
}
// TTL prune for non-present screens
stateByDisplay = stateByDisplay.filter { key, entry in
currentIDs.contains(key) || now.timeIntervalSince(entry.lastSeen) < Self.screenStaleTTL
}
pruneCapacity()
}
private func pruneCapacity() {
guard stateByDisplay.count > Self.maxSavedScreens else { return }
let toRemove = stateByDisplay
@@ -98,13 +98,13 @@ class QuickTerminalScreenStateCache {
stateByDisplay.removeValue(forKey: key)
}
}
struct DisplayEntry: Codable {
var frame: NSRect
var screenSize: CGSize
var scale: CGFloat
var lastSeen: Date
/// Returns true if this entry is still valid for the given screen.
/// Valid if the scale matches and the cached size is not larger than the current screen size.
/// This allows entries to persist when screens grow, but invalidates them when screens shrink.

View File

@@ -5,18 +5,18 @@ class QuickTerminalWindow: NSPanel {
// still become key/main and receive events.
override var canBecomeKey: Bool { return true }
override var canBecomeMain: Bool { return true }
override func awakeFromNib() {
super.awakeFromNib()
// Note: almost all of this stuff can be done in the nib/xib directly
// but I prefer to do it programmatically because the properties we
// care about are less hidden.
// Add a custom identifier so third party apps can use the Accessibility
// API to apply special rules to the quick terminal.
self.identifier = .init(rawValue: "com.mitchellh.ghostty.quickTerminal")
// Set the correct AXSubrole of kAXFloatingWindowSubrole (allows
// AeroSpace to treat the Quick Terminal as a floating window)
self.setAccessibilitySubrole(.floatingWindow)
@@ -33,7 +33,7 @@ class QuickTerminalWindow: NSPanel {
/// This is set to the frame prior to setting `contentView`. This is purely a hack to workaround
/// bugs in older macOS versions (Ventura): https://github.com/ghostty-org/ghostty/pull/8026
var initialFrame: NSRect?
override func setFrame(_ frameRect: NSRect, display flag: Bool) {
// Upon first adding this Window to its host view, older SwiftUI
// seems to have a "hiccup" and corrupts the frameRect,

View File

@@ -44,9 +44,9 @@ struct SecureInputOverlay: View {
.padding(.trailing, 10)
.popover(isPresented: $isPopover, arrowEdge: .bottom) {
Text("""
Secure Input is active. Secure Input is a macOS security feature that
prevents applications from reading keyboard events. This is enabled
automatically whenever Ghostty detects a password prompt in the terminal,
Secure Input is active. Secure Input is a macOS security feature that
prevents applications from reading keyboard events. This is enabled
automatically whenever Ghostty detects a password prompt in the terminal,
or at all times if `Ghostty > Secure Keyboard Entry` is active.
""")
.padding(.all)

View File

@@ -152,9 +152,9 @@ struct SplitView<L: View, R: View>: View {
return CGPoint(x: size.width / 2, y: leftRect.size.height)
}
}
// MARK: Accessibility
private var splitViewLabel: String {
switch direction {
case .horizontal:
@@ -163,7 +163,7 @@ struct SplitView<L: View, R: View>: View {
return "Vertical split view"
}
}
private var leftPaneLabel: String {
switch direction {
case .horizontal:
@@ -172,7 +172,7 @@ struct SplitView<L: View, R: View>: View {
return "Top pane"
}
}
private var rightPaneLabel: String {
switch direction {
case .horizontal:

View File

@@ -7,19 +7,19 @@ import SwiftUI
enum TerminalSplitOperation {
case resize(Resize)
case drop(Drop)
struct Resize {
let node: SplitTree<Ghostty.SurfaceView>.Node
let ratio: Double
}
struct Drop {
/// The surface being dragged.
let payload: Ghostty.SurfaceView
/// The surface it was dragged onto
let destination: Ghostty.SurfaceView
/// The zone it was dropped to determine how to split the destination.
let zone: TerminalSplitDropZone
}
@@ -90,10 +90,10 @@ private struct TerminalSplitLeaf: View {
let surfaceView: Ghostty.SurfaceView
let isSplit: Bool
let action: (TerminalSplitOperation) -> Void
@State private var dropState: DropState = .idle
@State private var isSelfDragging: Bool = false
var body: some View {
GeometryReader { geometry in
Ghostty.InspectableSurface(
@@ -129,26 +129,26 @@ private struct TerminalSplitLeaf: View {
.accessibilityLabel("Terminal pane")
}
}
private enum DropState: Equatable {
case idle
case dropping(TerminalSplitDropZone)
}
private struct SplitDropDelegate: DropDelegate {
@Binding var dropState: DropState
let viewSize: CGSize
let destinationSurface: Ghostty.SurfaceView
let action: (TerminalSplitOperation) -> Void
func validateDrop(info: DropInfo) -> Bool {
info.hasItemsConforming(to: [.ghosttySurfaceId])
}
func dropEntered(info: DropInfo) {
dropState = .dropping(.calculate(at: info.location, in: viewSize))
}
func dropUpdated(info: DropInfo) -> DropProposal? {
// For some reason dropUpdated is sent after performDrop is called
// and we don't want to reset our drop zone to show it so we have
@@ -157,11 +157,11 @@ private struct TerminalSplitLeaf: View {
dropState = .dropping(.calculate(at: info.location, in: viewSize))
return DropProposal(operation: .move)
}
func dropExited(info: DropInfo) {
dropState = .idle
}
func performDrop(info: DropInfo) -> Bool {
let zone = TerminalSplitDropZone.calculate(at: info.location, in: viewSize)
dropState = .idle
@@ -169,7 +169,7 @@ private struct TerminalSplitLeaf: View {
// Load the dropped surface asynchronously using Transferable
let providers = info.itemProviders(for: [.ghosttySurfaceId])
guard let provider = providers.first else { return false }
// Capture action before the async closure
_ = provider.loadTransferable(type: Ghostty.SurfaceView.self) { [weak destinationSurface] result in
switch result {
@@ -180,12 +180,12 @@ private struct TerminalSplitLeaf: View {
guard sourceSurface !== destinationSurface else { return }
action(.drop(.init(payload: sourceSurface, destination: destinationSurface, zone: zone)))
}
case .failure:
break
}
}
return true
}
}

View File

@@ -47,7 +47,7 @@ class BaseTerminalController: NSWindowController,
/// This can be set to show/hide the command palette.
@Published var commandPaletteIsShowing: Bool = false
/// Set if the terminal view should show the update overlay.
@Published var updateOverlayIsVisible: Bool = false
@@ -423,7 +423,7 @@ class BaseTerminalController: NSWindowController,
/// Goes to previous split unless we're the leftmost leaf, then goes to next.
private func findNextFocusTargetAfterClosing(node: SplitTree<Ghostty.SurfaceView>.Node) -> Ghostty.SurfaceView? {
guard let root = surfaceTree.root else { return nil }
// If we're the leftmost, then we move to the next surface after closing.
// Otherwise, we move to the previous.
if root.leftmostLeaf() == node.leftmostLeaf() {
@@ -432,7 +432,7 @@ class BaseTerminalController: NSWindowController,
return surfaceTree.focusTarget(for: .previous, from: node)
}
}
/// Remove a node from the surface tree and move focus appropriately.
///
/// This also updates the undo manager to support restoring this node.
@@ -470,13 +470,13 @@ class BaseTerminalController: NSWindowController,
Ghostty.moveFocus(to: newView, from: oldView)
}
}
// Setup our undo
guard let undoManager else { return }
if let undoAction {
undoManager.setActionName(undoAction)
}
undoManager.registerUndo(
withTarget: self,
expiresAfter: undoExpiration
@@ -487,7 +487,7 @@ class BaseTerminalController: NSWindowController,
Ghostty.moveFocus(to: oldView, from: target.focusedSurface)
}
}
undoManager.registerUndo(
withTarget: target,
expiresAfter: target.undoExpiration
@@ -608,14 +608,14 @@ class BaseTerminalController: NSWindowController,
@objc private func ghosttyDidEqualizeSplits(_ notification: Notification) {
guard let target = notification.object as? Ghostty.SurfaceView else { return }
// Check if target surface is in current controller's tree
guard surfaceTree.contains(target) else { return }
// Equalize the splits
surfaceTree = surfaceTree.equalized()
}
@objc private func ghosttyDidFocusSplit(_ notification: Notification) {
// The target must be within our tree
guard let target = notification.object as? Ghostty.SurfaceView else { return }
@@ -627,7 +627,7 @@ class BaseTerminalController: NSWindowController,
// Find the node for the target surface
guard let targetNode = surfaceTree.root?.node(view: target) else { return }
// Find the next surface to focus
guard let nextSurface = surfaceTree.focusTarget(for: direction.toSplitTreeFocusDirection(), from: targetNode) else {
return
@@ -648,7 +648,7 @@ class BaseTerminalController: NSWindowController,
Ghostty.moveFocus(to: nextSurface, from: target)
}
}
@objc private func ghosttyDidToggleSplitZoom(_ notification: Notification) {
// The target must be within our tree
guard let target = notification.object as? Ghostty.SurfaceView else { return }
@@ -676,19 +676,19 @@ class BaseTerminalController: NSWindowController,
Ghostty.moveFocus(to: target)
}
}
@objc private func ghosttyDidResizeSplit(_ notification: Notification) {
// The target must be within our tree
guard let target = notification.object as? Ghostty.SurfaceView else { return }
guard let targetNode = surfaceTree.root?.node(view: target) else { return }
// Extract direction and amount from notification
guard let directionAny = notification.userInfo?[Ghostty.Notification.ResizeSplitDirectionKey] else { return }
guard let direction = directionAny as? Ghostty.SplitResizeDirection else { return }
guard let amountAny = notification.userInfo?[Ghostty.Notification.ResizeSplitAmountKey] else { return }
guard let amount = amountAny as? UInt16 else { return }
// Convert Ghostty.SplitResizeDirection to SplitTree.Spatial.Direction
let spatialDirection: SplitTree<Ghostty.SurfaceView>.Spatial.Direction
switch direction {
@@ -697,10 +697,10 @@ class BaseTerminalController: NSWindowController,
case .left: spatialDirection = .left
case .right: spatialDirection = .right
}
// Use viewBounds for the spatial calculation bounds
let bounds = CGRect(origin: .zero, size: surfaceTree.viewBounds())
// Perform the resize using the new SplitTree resize method
do {
surfaceTree = try surfaceTree.resizing(node: targetNode, by: amount, in: spatialDirection, with: bounds)
@@ -715,7 +715,7 @@ class BaseTerminalController: NSWindowController,
// Bring the window to front and focus the surface.
window?.makeKeyAndOrderFront(nil)
// We use a small delay to ensure this runs after any UI cleanup
// (e.g., command palette restoring focus to its original surface).
Ghostty.moveFocus(to: target)
@@ -728,11 +728,11 @@ class BaseTerminalController: NSWindowController,
@objc private func ghosttySurfaceDragEndedNoTarget(_ notification: Notification) {
guard let target = notification.object as? Ghostty.SurfaceView else { return }
guard let targetNode = surfaceTree.root?.node(view: target) else { return }
// If our tree isn't split, then we never create a new window, because
// it is already a single split.
guard surfaceTree.isSplit else { return }
// If we are removing our focused surface then we move it. We need to
// keep track of our old one so undo sends focus back to the right place.
let oldFocusedSurface = focusedSurface
@@ -745,14 +745,14 @@ class BaseTerminalController: NSWindowController,
// Create a new tree with the dragged surface and open a new window
let newTree = SplitTree<Ghostty.SurfaceView>(view: target)
// Treat our undo below as a full group.
undoManager?.beginUndoGrouping()
undoManager?.setActionName("Move Split")
defer {
undoManager?.endUndoGrouping()
}
replaceSurfaceTree(removedTree, moveFocusFrom: oldFocusedSurface)
_ = TerminalController.newWindow(
ghostty,
@@ -782,7 +782,7 @@ class BaseTerminalController: NSWindowController,
if NSApp.mainWindow == window {
surfaces = surfaces.filter { $0 != focusedSurface }
}
for surface in surfaces {
surface.flagsChanged(with: event)
}
@@ -816,7 +816,7 @@ class BaseTerminalController: NSWindowController,
titleDidChange(to: "👻")
}
}
private func computeTitle(title: String, bell: Bool) -> String {
var result = title
if bell && ghostty.config.bellFeatures.contains(.title) {
@@ -833,17 +833,17 @@ class BaseTerminalController: NSWindowController,
private func applyTitleToWindow() {
guard let window else { return }
if let titleOverride {
window.title = computeTitle(
title: titleOverride,
bell: focusedSurface?.bell ?? false)
return
}
window.title = lastComputedTitle
}
func pwdDidChange(to: URL?) {
guard let window else { return }
@@ -895,7 +895,7 @@ class BaseTerminalController: NSWindowController,
case .left: .left
case .right: .right
}
// Check if source is in our tree
if let sourceNode = surfaceTree.root?.node(view: source) {
// Source is in our tree - same window move
@@ -907,7 +907,7 @@ class BaseTerminalController: NSWindowController,
Ghostty.logger.warning("failed to insert surface during drop: \(error)")
return
}
replaceSurfaceTree(
newTree,
moveFocusTo: source,
@@ -915,7 +915,7 @@ class BaseTerminalController: NSWindowController,
undoAction: "Move Split")
return
}
// Source is not in our tree - search other windows
var sourceController: BaseTerminalController?
var sourceNode: SplitTree<Ghostty.SurfaceView>.Node?
@@ -928,12 +928,12 @@ class BaseTerminalController: NSWindowController,
break
}
}
guard let sourceController, let sourceNode else {
Ghostty.logger.warning("source surface not found in any window during drop")
return
}
// Remove from source controller's tree and add it to our tree.
// We do this first because if there is an error then we can
// abort.
@@ -944,17 +944,17 @@ class BaseTerminalController: NSWindowController,
Ghostty.logger.warning("failed to insert surface during cross-window drop: \(error)")
return
}
// Treat our undo below as a full group.
undoManager?.beginUndoGrouping()
undoManager?.setActionName("Move Split")
defer {
undoManager?.endUndoGrouping()
}
// Remove the node from the source.
sourceController.removeSurfaceNode(sourceNode)
// Add in the surface to our tree
replaceSurfaceTree(
newTree,
@@ -979,17 +979,17 @@ class BaseTerminalController: NSWindowController,
func toggleBackgroundOpacity() {
// Do nothing if config is already fully opaque
guard ghostty.config.backgroundOpacity < 1 else { return }
// Do nothing if in fullscreen (transparency doesn't apply in fullscreen)
guard let window, !window.styleMask.contains(.fullScreen) else { return }
// Toggle between transparent and opaque
isBackgroundOpaque.toggle()
// Update our appearance
syncAppearance()
}
/// Override this to resync any appearance related properties. This will be called automatically
/// when certain window properties change that affect appearance. The list below should be updated
/// as we add new things:
@@ -1051,7 +1051,7 @@ class BaseTerminalController: NSWindowController,
func fullscreenDidChange() {
guard let fullscreenStyle else { return }
// When we enter fullscreen, we want to show the update overlay so that it
// is easily visible. For native fullscreen this is visible by showing the
// menubar but we don't want to rely on that.
@@ -1060,7 +1060,7 @@ class BaseTerminalController: NSWindowController,
} else {
updateOverlayIsVisible = defaultUpdateOverlayVisibility()
}
// Always resync our appearance
syncAppearance()
}
@@ -1145,26 +1145,26 @@ class BaseTerminalController: NSWindowController,
fullscreenStyle = NativeFullscreen(window)
fullscreenStyle?.delegate = self
}
// Set our update overlay state
updateOverlayIsVisible = defaultUpdateOverlayVisibility()
}
func defaultUpdateOverlayVisibility() -> Bool {
guard let window else { return true }
// No titlebar we always show the update overlay because it can't support
// updates in the titlebar
guard window.styleMask.contains(.titled) else {
return true
}
// If it's a non terminal window we can't trust it has an update accessory,
// so we always want to show the overlay.
guard let window = window as? TerminalWindow else {
return true
}
// Show the overlay if the window isn't.
return !window.supportsUpdateAccessory
}
@@ -1367,7 +1367,7 @@ class BaseTerminalController: NSWindowController,
@IBAction func toggleCommandPalette(_ sender: Any?) {
commandPaletteIsShowing.toggle()
}
@IBAction func find(_ sender: Any) {
focusedSurface?.find(sender)
}
@@ -1383,11 +1383,11 @@ class BaseTerminalController: NSWindowController,
@IBAction func findNext(_ sender: Any) {
focusedSurface?.findNext(sender)
}
@IBAction func findPrevious(_ sender: Any) {
focusedSurface?.findNext(sender)
}
@IBAction func findHide(_ sender: Any) {
focusedSurface?.findHide(sender)
}
@@ -1429,7 +1429,7 @@ extension BaseTerminalController: NSMenuItemValidation {
return true
}
}
// MARK: - Surface Color Scheme
/// Update the surface tree's color scheme only when it actually changes.

View File

@@ -137,7 +137,7 @@ class TerminalWindowRestoration: NSObject, NSWindowRestoration {
break
}
}
if let view = foundView {
c.focusedSurface = view
restoreFocus(to: view, inWindow: window)

View File

@@ -122,7 +122,7 @@ struct TabColorMenuView: View {
VStack(alignment: .leading, spacing: 3) {
Text("Tab Color")
.padding(.bottom, 2)
ForEach(Self.paletteRows, id: \.self) { row in
HStack(spacing: 2) {
ForEach(row, id: \.self) { color in
@@ -142,7 +142,7 @@ struct TabColorMenuView: View {
.padding(.top, 4)
.padding(.bottom, 4)
}
static let paletteRows: [[TerminalTabColor]] = [
[.none, .blue, .purple, .pink, .red],
[.orange, .yellow, .green, .teal, .graphite],

View File

@@ -17,7 +17,7 @@ protocol TerminalViewDelegate: AnyObject {
/// Perform an action. At the time of writing this is only triggered by the command palette.
func performAction(_ action: String, on: Ghostty.SurfaceView)
/// A split tree operation
func performSplitAction(_ action: TerminalSplitOperation)
}
@@ -32,7 +32,7 @@ protocol TerminalViewModel: ObservableObject {
/// The command palette state.
var commandPaletteIsShowing: Bool { get set }
/// The update overlay should be visible.
var updateOverlayIsVisible: Bool { get }
}
@@ -46,7 +46,7 @@ struct TerminalView<ViewModel: TerminalViewModel>: View {
// An optional delegate to receive information about terminal changes.
weak var delegate: (any TerminalViewDelegate)?
// The most recently focused surface, equal to focusedSurface when
// it is non-nil.
@State private var lastFocusedSurface: Weak<Ghostty.SurfaceView> = .init()
@@ -116,7 +116,7 @@ struct TerminalView<ViewModel: TerminalViewModel>: View {
self.delegate?.performAction(action, on: surfaceView)
}
}
// Show update information above all else.
if viewModel.updateOverlayIsVisible {
UpdateOverlay()
@@ -132,7 +132,7 @@ private struct UpdateOverlay: View {
if let appDelegate = NSApp.delegate as? AppDelegate {
VStack {
Spacer()
HStack {
Spacer()
UpdatePill(model: appDelegate.updateViewModel)

View File

@@ -3,7 +3,7 @@ import AppKit
class HiddenTitlebarTerminalWindow: TerminalWindow {
// No titlebar, we don't support accessories.
override var supportsUpdateAccessory: Bool { false }
override func awakeFromNib() {
super.awakeFromNib()
@@ -34,7 +34,7 @@ class HiddenTitlebarTerminalWindow: TerminalWindow {
.closable,
.miniaturizable,
]
/// Apply the hidden titlebar style.
private func reapplyHiddenStyle() {
// If our window is fullscreen then we don't reapply the hidden style because
@@ -43,7 +43,7 @@ class HiddenTitlebarTerminalWindow: TerminalWindow {
if terminalController?.fullscreenStyle?.isFullscreen ?? false {
return
}
// Apply our style mask while preserving the .fullScreen option
if styleMask.contains(.fullScreen) {
styleMask = Self.hiddenStyleMask.union([.fullScreen])

View File

@@ -33,7 +33,7 @@ class TerminalWindow: NSWindow {
/// The configuration derived from the Ghostty config so we don't need to rely on references.
private(set) var derivedConfig: DerivedConfig = .init()
/// Sets up our tab context menu
private var tabMenuObserver: NSObjectProtocol?
@@ -543,7 +543,7 @@ class TerminalWindow: NSWindow {
NotificationCenter.default.removeObserver(observer)
}
}
// MARK: Config
struct DerivedConfig {

View File

@@ -8,7 +8,7 @@ import SwiftUI
class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSToolbarDelegate {
/// The view model for SwiftUI views
private var viewModel = ViewModel()
/// Titlebar tabs can't support the update accessory because of the way we layout
/// the native tabs back into the menu bar.
override var supportsUpdateAccessory: Bool { false }
@@ -58,13 +58,13 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool
// Check if we have a tab bar and set it up if we have to. See the comment
// on this function to learn why we need to check this here.
setupTabBar()
viewModel.isMainWindow = true
}
override func resignMain() {
super.resignMain()
viewModel.isMainWindow = false
}
@@ -84,18 +84,18 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool
super.sendEvent(event)
return
}
guard let tabBarView else {
super.sendEvent(event)
return
}
let locationInTabBar = tabBarView.convert(event.locationInWindow, from: nil)
guard tabBarView.bounds.contains(locationInTabBar) else {
super.sendEvent(event)
return
}
tabBarView.rightMouseDown(with: event)
}
@@ -107,7 +107,7 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool
// After dragging a tab into a new window, `hasTabBar` needs to be
// updated to properly review window title
viewModel.hasTabBar = false
super.addTitlebarAccessoryViewController(childViewController)
return
}
@@ -116,7 +116,7 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool
// system will also try to add tab bar to this window, so we want to reset observer,
// to put tab bar where we want again
tabBarObserver = nil
// Some setup needs to happen BEFORE it is added, such as layout. If
// we don't do this before the call below, we'll trigger an AppKit
// assertion.
@@ -189,7 +189,7 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool
guard let clipView = tabBarView.firstSuperview(withClassName: "NSTitlebarAccessoryClipView") else { return }
guard let accessoryView = clipView.subviews[safe: 0] else { return }
guard let toolbarView = titlebarView.firstDescendant(withClassName: "NSToolbarView") else { return }
// Make sure tabBar's height won't be stretched
guard let newTabButton = titlebarView.firstDescendant(withClassName: "NSTabBarNewTabButton") else { return }
tabBarView.frame.size.height = newTabButton.frame.width
@@ -282,7 +282,7 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool
// This is the documented way to avoid the glass view on an item.
// We don't want glass on our title.
item.isBordered = false
return item
default:
return NSToolbarItem(itemIdentifier: itemIdentifier)
@@ -327,7 +327,7 @@ extension TitlebarTabsTahoeTerminalWindow {
Color.clear.frame(width: 1, height: 1)
}
}
@ViewBuilder
var titleText: some View {
Text(title)

View File

@@ -9,15 +9,15 @@ import SwiftUI
struct UpdateBadge: View {
/// The update view model that provides the current state and progress
@ObservedObject var model: UpdateViewModel
/// Current rotation angle for animated icon states
@State private var rotationAngle: Double = 0
var body: some View {
badgeContent
.accessibilityLabel(model.text)
}
@ViewBuilder
private var badgeContent: some View {
switch model.state {
@@ -28,10 +28,10 @@ struct UpdateBadge: View {
} else {
Image(systemName: "arrow.down.circle")
}
case .extracting(let extracting):
ProgressRingView(progress: min(1, max(0, extracting.progress)))
case .checking:
if let iconName = model.iconName {
Image(systemName: iconName)
@@ -47,7 +47,7 @@ struct UpdateBadge: View {
} else {
EmptyView()
}
default:
if let iconName = model.iconName {
Image(systemName: iconName)
@@ -64,15 +64,15 @@ struct UpdateBadge: View {
private struct ProgressRingView: View {
/// The current progress value, ranging from 0.0 (empty) to 1.0 (complete)
let progress: Double
/// The width of the progress ring stroke
let lineWidth: CGFloat = 2
var body: some View {
ZStack {
Circle()
.stroke(Color.primary.opacity(0.2), lineWidth: lineWidth)
Circle()
.trim(from: 0, to: progress)
.stroke(Color.primary, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round))

View File

@@ -11,16 +11,16 @@ class UpdateController {
private(set) var updater: SPUUpdater
private let userDriver: UpdateDriver
private var installCancellable: AnyCancellable?
var viewModel: UpdateViewModel {
userDriver.viewModel
}
/// True if we're installing an update.
var isInstalling: Bool {
installCancellable != nil
}
/// Initialize a new update controller.
init() {
let hostBundle = Bundle.main
@@ -34,11 +34,11 @@ class UpdateController {
delegate: userDriver
)
}
deinit {
installCancellable?.cancel()
}
/// Start the updater.
///
/// This must be called before the updater can check for updates. If starting fails,
@@ -59,35 +59,35 @@ class UpdateController {
))
}
}
/// Force install the current update. As long as we're in some "update available" state this will
/// trigger all the steps necessary to complete the update.
func installUpdate() {
// Must be in an installable state
guard viewModel.state.isInstallable else { return }
// If we're already force installing then do nothing.
guard installCancellable == nil else { return }
// Setup a combine listener to listen for state changes and to always
// confirm them. If we go to a non-installable state, cancel the listener.
// The sink runs immediately with the current state, so we don't need to
// manually confirm the first state.
installCancellable = viewModel.$state.sink { [weak self] state in
guard let self else { return }
// If we move to a non-installable state (error, idle, etc.) then we
// stop force installing.
guard state.isInstallable else {
self.installCancellable = nil
return
}
// Continue the `yes` chain!
state.confirm()
}
}
/// Check for updates.
///
/// This is typically connected to a menu item action.
@@ -97,11 +97,11 @@ class UpdateController {
updater.checkForUpdates()
return
}
// If we're not idle then we need to cancel any prior state.
installCancellable?.cancel()
viewModel.state.cancel()
// The above will take time to settle, so we delay the check for some time.
// The 100ms is arbitrary and I'd rather not, but we have to wait more than
// one loop tick it seems.
@@ -109,7 +109,7 @@ class UpdateController {
self?.updater.checkForUpdates()
}
}
/// Validate the check for updates menu item.
///
/// - Parameter item: The menu item to validate

View File

@@ -6,7 +6,7 @@ extension UpdateDriver: SPUUpdaterDelegate {
guard let appDelegate = NSApplication.shared.delegate as? AppDelegate else {
return nil
}
// Sparkle supports a native concept of "channels" but it requires that
// you share a single appcast file. We don't want to do that so we
// do this instead.

View File

@@ -5,23 +5,23 @@ import Sparkle
class UpdateDriver: NSObject, SPUUserDriver {
let viewModel: UpdateViewModel
let standard: SPUStandardUserDriver
init(viewModel: UpdateViewModel, hostBundle: Bundle) {
self.viewModel = viewModel
self.standard = SPUStandardUserDriver(hostBundle: hostBundle, delegate: nil)
super.init()
NotificationCenter.default.addObserver(
self,
selector: #selector(handleTerminalWindowWillClose),
name: TerminalWindow.terminalWillCloseNotification,
object: nil)
}
deinit {
NotificationCenter.default.removeObserver(self)
}
@objc private func handleTerminalWindowWillClose() {
// If we lost the ability to show unobtrusive states, cancel whatever
// update state we're in. This will allow the manual `check for updates`
@@ -36,7 +36,7 @@ class UpdateDriver: NSObject, SPUUserDriver {
viewModel.state = .idle
}
}
func show(_ request: SPUUpdatePermissionRequest,
reply: @escaping @Sendable (SUUpdatePermissionResponse) -> Void) {
viewModel.state = .permissionRequest(.init(request: request, reply: { [weak viewModel] response in
@@ -47,7 +47,7 @@ class UpdateDriver: NSObject, SPUUserDriver {
standard.show(request, reply: reply)
}
}
func showUserInitiatedUpdateCheck(cancellation: @escaping () -> Void) {
viewModel.state = .checking(.init(cancel: cancellation))
@@ -55,7 +55,7 @@ class UpdateDriver: NSObject, SPUUserDriver {
standard.showUserInitiatedUpdateCheck(cancellation: cancellation)
}
}
func showUpdateFound(with appcastItem: SUAppcastItem,
state: SPUUserUpdateState,
reply: @escaping @Sendable (SPUUserUpdateChoice) -> Void) {
@@ -64,25 +64,25 @@ class UpdateDriver: NSObject, SPUUserDriver {
standard.showUpdateFound(with: appcastItem, state: state, reply: reply)
}
}
func showUpdateReleaseNotes(with downloadData: SPUDownloadData) {
// We don't do anything with the release notes here because Ghostty
// doesn't use the release notes feature of Sparkle currently.
}
func showUpdateReleaseNotesFailedToDownloadWithError(_ error: any Error) {
// We don't do anything with release notes. See `showUpdateReleaseNotes`
}
func showUpdateNotFoundWithError(_ error: any Error,
acknowledgement: @escaping () -> Void) {
viewModel.state = .notFound(.init(acknowledgement: acknowledgement))
if !hasUnobtrusiveTarget {
standard.showUpdateNotFoundWithError(error, acknowledgement: acknowledgement)
}
}
func showUpdaterError(_ error: any Error,
acknowledgement: @escaping () -> Void) {
viewModel.state = .error(.init(
@@ -98,71 +98,71 @@ class UpdateDriver: NSObject, SPUUserDriver {
dismiss: { [weak viewModel] in
viewModel?.state = .idle
}))
if !hasUnobtrusiveTarget {
standard.showUpdaterError(error, acknowledgement: acknowledgement)
} else {
acknowledgement()
}
}
func showDownloadInitiated(cancellation: @escaping () -> Void) {
viewModel.state = .downloading(.init(
cancel: cancellation,
expectedLength: nil,
progress: 0))
if !hasUnobtrusiveTarget {
standard.showDownloadInitiated(cancellation: cancellation)
}
}
func showDownloadDidReceiveExpectedContentLength(_ expectedContentLength: UInt64) {
guard case let .downloading(downloading) = viewModel.state else {
return
}
viewModel.state = .downloading(.init(
cancel: downloading.cancel,
expectedLength: expectedContentLength,
progress: 0))
if !hasUnobtrusiveTarget {
standard.showDownloadDidReceiveExpectedContentLength(expectedContentLength)
}
}
func showDownloadDidReceiveData(ofLength length: UInt64) {
guard case let .downloading(downloading) = viewModel.state else {
return
}
viewModel.state = .downloading(.init(
cancel: downloading.cancel,
expectedLength: downloading.expectedLength,
progress: downloading.progress + length))
if !hasUnobtrusiveTarget {
standard.showDownloadDidReceiveData(ofLength: length)
}
}
func showDownloadDidStartExtractingUpdate() {
viewModel.state = .extracting(.init(progress: 0))
if !hasUnobtrusiveTarget {
standard.showDownloadDidStartExtractingUpdate()
}
}
func showExtractionReceivedProgress(_ progress: Double) {
viewModel.state = .extracting(.init(progress: progress))
if !hasUnobtrusiveTarget {
standard.showExtractionReceivedProgress(progress)
}
}
func showReady(toInstallAndRelaunch reply: @escaping @Sendable (SPUUserUpdateChoice) -> Void) {
if !hasUnobtrusiveTarget {
standard.showReady(toInstallAndRelaunch: reply)
@@ -170,7 +170,7 @@ class UpdateDriver: NSObject, SPUUserDriver {
reply(.install)
}
}
func showInstallingUpdate(withApplicationTerminated applicationTerminated: Bool, retryTerminatingApplication: @escaping () -> Void) {
viewModel.state = .installing(.init(
retryTerminatingApplication: retryTerminatingApplication,
@@ -178,30 +178,30 @@ class UpdateDriver: NSObject, SPUUserDriver {
viewModel?.state = .idle
}
))
if !hasUnobtrusiveTarget {
standard.showInstallingUpdate(withApplicationTerminated: applicationTerminated, retryTerminatingApplication: retryTerminatingApplication)
}
}
func showUpdateInstalledAndRelaunched(_ relaunched: Bool, acknowledgement: @escaping () -> Void) {
standard.showUpdateInstalledAndRelaunched(relaunched, acknowledgement: acknowledgement)
viewModel.state = .idle
}
func showUpdateInFocus() {
if !hasUnobtrusiveTarget {
standard.showUpdateInFocus()
}
}
func dismissUpdateInstallation() {
viewModel.state = .idle
standard.dismissUpdateInstallation()
}
// MARK: No-Window Fallback
/// True if there is a target that can render our unobtrusive update checker.
var hasUnobtrusiveTarget: Bool {
NSApp.windows.contains { window in

View File

@@ -4,16 +4,16 @@ import SwiftUI
struct UpdatePill: View {
/// The update view model that provides the current state and information
@ObservedObject var model: UpdateViewModel
/// Whether the update popover is currently visible
@State private var showPopover = false
/// Task for auto-dismissing the "No Updates" state
@State private var resetTask: Task<Void, Never>?
/// The font used for the pill text
private let textFont = NSFont.systemFont(ofSize: 11, weight: .medium)
var body: some View {
if !model.state.isIdle {
pillButton
@@ -36,7 +36,7 @@ struct UpdatePill: View {
}
}
}
/// The pill-shaped button view that displays the update badge and text
@ViewBuilder
private var pillButton: some View {
@@ -51,7 +51,7 @@ struct UpdatePill: View {
HStack(spacing: 6) {
UpdateBadge(model: model)
.frame(width: 14, height: 14)
Text(model.text)
.font(Font(textFont))
.lineLimit(1)
@@ -71,7 +71,7 @@ struct UpdatePill: View {
.help(model.text)
.accessibilityLabel(model.text)
}
/// Calculated width for the text to prevent resizing during progress updates
private var textWidth: CGFloat? {
let attributes: [NSAttributedString.Key: Any] = [.font: textFont]

View File

@@ -8,10 +8,10 @@ import Sparkle
struct UpdatePopoverView: View {
/// The update view model that provides the current state and information
@ObservedObject var model: UpdateViewModel
/// Environment value for dismissing the popover
@Environment(\.dismiss) private var dismiss
var body: some View {
VStack(alignment: .leading, spacing: 0) {
switch model.state {
@@ -19,31 +19,31 @@ struct UpdatePopoverView: View {
// Shouldn't happen in a well-formed view stack. Higher levels
// should not call the popover for idles.
EmptyView()
case .permissionRequest(let request):
PermissionRequestView(request: request, dismiss: dismiss)
case .checking(let checking):
CheckingView(checking: checking, dismiss: dismiss)
case .updateAvailable(let update):
UpdateAvailableView(update: update, dismiss: dismiss)
case .downloading(let download):
DownloadingView(download: download, dismiss: dismiss)
case .extracting(let extracting):
ExtractingView(extracting: extracting)
case .installing(let installing):
// This is only required when `installing.isAutoUpdate == true`,
// but we keep it anyway, just in case something unexpected
// happens during installing
InstallingView(installing: installing, dismiss: dismiss)
case .notFound(let notFound):
NotFoundView(notFound: notFound, dismiss: dismiss)
case .error(let error):
UpdateErrorView(error: error, dismiss: dismiss)
}
@@ -55,19 +55,19 @@ struct UpdatePopoverView: View {
private struct PermissionRequestView: View {
let request: UpdateState.PermissionRequest
let dismiss: DismissAction
var body: some View {
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 8) {
Text("Enable automatic updates?")
.font(.system(size: 13, weight: .semibold))
Text("Ghostty can automatically check for updates in the background.")
.font(.system(size: 11))
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
HStack(spacing: 8) {
Button("Not Now") {
request.reply(SUUpdatePermissionResponse(
@@ -76,9 +76,9 @@ private struct PermissionRequestView: View {
dismiss()
}
.keyboardShortcut(.cancelAction)
Spacer()
Button("Allow") {
request.reply(SUUpdatePermissionResponse(
automaticUpdateChecks: true,
@@ -96,7 +96,7 @@ private struct PermissionRequestView: View {
private struct CheckingView: View {
let checking: UpdateState.Checking
let dismiss: DismissAction
var body: some View {
VStack(alignment: .leading, spacing: 16) {
HStack(spacing: 10) {
@@ -105,7 +105,7 @@ private struct CheckingView: View {
Text("Checking for updates…")
.font(.system(size: 13))
}
HStack {
Spacer()
Button("Cancel") {
@@ -123,16 +123,16 @@ private struct CheckingView: View {
private struct UpdateAvailableView: View {
let update: UpdateState.UpdateAvailable
let dismiss: DismissAction
private let labelWidth: CGFloat = 60
var body: some View {
VStack(alignment: .leading, spacing: 0) {
VStack(alignment: .leading, spacing: 12) {
VStack(alignment: .leading, spacing: 8) {
Text("Update Available")
.font(.system(size: 13, weight: .semibold))
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 6) {
Text("Version:")
@@ -141,7 +141,7 @@ private struct UpdateAvailableView: View {
Text(update.appcastItem.displayVersionString)
}
.font(.system(size: 11))
if update.appcastItem.contentLength > 0 {
HStack(spacing: 6) {
Text("Size:")
@@ -151,7 +151,7 @@ private struct UpdateAvailableView: View {
}
.font(.system(size: 11))
}
if let date = update.appcastItem.date {
HStack(spacing: 6) {
Text("Released:")
@@ -164,23 +164,23 @@ private struct UpdateAvailableView: View {
}
.textSelection(.enabled)
}
HStack(spacing: 8) {
Button("Skip") {
update.reply(.skip)
dismiss()
}
.controlSize(.small)
Button("Later") {
update.reply(.dismiss)
dismiss()
}
.controlSize(.small)
.keyboardShortcut(.cancelAction)
Spacer()
Button("Install and Relaunch") {
update.reply(.install)
dismiss()
@@ -191,10 +191,10 @@ private struct UpdateAvailableView: View {
}
}
.padding(16)
if let notes = update.releaseNotes {
Divider()
Link(destination: notes.url) {
HStack {
Image(systemName: "doc.text")
@@ -220,13 +220,13 @@ private struct UpdateAvailableView: View {
private struct DownloadingView: View {
let download: UpdateState.Downloading
let dismiss: DismissAction
var body: some View {
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 8) {
Text("Downloading Update")
.font(.system(size: 13, weight: .semibold))
if let expectedLength = download.expectedLength, expectedLength > 0 {
let progress = min(1, max(0, Double(download.progress) / Double(expectedLength)))
VStack(alignment: .leading, spacing: 6) {
@@ -240,7 +240,7 @@ private struct DownloadingView: View {
.controlSize(.small)
}
}
HStack {
Spacer()
Button("Cancel") {
@@ -257,12 +257,12 @@ private struct DownloadingView: View {
private struct ExtractingView: View {
let extracting: UpdateState.Extracting
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Preparing Update")
.font(.system(size: 13, weight: .semibold))
VStack(alignment: .leading, spacing: 6) {
ProgressView(value: min(1, max(0, extracting.progress)), total: 1.0)
Text(String(format: "%.0f%%", min(1, max(0, extracting.progress)) * 100))
@@ -277,19 +277,19 @@ private struct ExtractingView: View {
private struct InstallingView: View {
let installing: UpdateState.Installing
let dismiss: DismissAction
var body: some View {
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 8) {
Text("Restart Required")
.font(.system(size: 13, weight: .semibold))
Text("The update is ready. Please restart the application to complete the installation.")
.font(.system(size: 11))
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
HStack {
Button("Restart Later") {
installing.dismiss()
@@ -297,9 +297,9 @@ private struct InstallingView: View {
}
.keyboardShortcut(.cancelAction)
.controlSize(.small)
Spacer()
Button("Restart Now") {
installing.retryTerminatingApplication()
dismiss()
@@ -316,19 +316,19 @@ private struct InstallingView: View {
private struct NotFoundView: View {
let notFound: UpdateState.NotFound
let dismiss: DismissAction
var body: some View {
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 8) {
Text("No Updates Found")
.font(.system(size: 13, weight: .semibold))
Text("You're already running the latest version.")
.font(.system(size: 11))
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
HStack {
Spacer()
Button("OK") {
@@ -346,7 +346,7 @@ private struct NotFoundView: View {
private struct UpdateErrorView: View {
let error: UpdateState.Error
let dismiss: DismissAction
var body: some View {
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 8) {
@@ -357,13 +357,13 @@ private struct UpdateErrorView: View {
Text("Update Failed")
.font(.system(size: 13, weight: .semibold))
}
Text(error.error.localizedDescription)
.font(.system(size: 11))
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
HStack(spacing: 8) {
Button("OK") {
error.dismiss()
@@ -371,9 +371,9 @@ private struct UpdateErrorView: View {
}
.keyboardShortcut(.cancelAction)
.controlSize(.small)
Spacer()
Button("Retry") {
error.retry()
dismiss()

View File

@@ -9,31 +9,31 @@ import Sparkle
enum UpdateSimulator {
/// Complete successful update flow: checking available download extract ready install idle
case happyPath
/// No updates available: checking (2s) "No Updates Available" (3s) idle
case notFound
/// Error during check: checking (2s) error with retry callback
case error
/// Slower download for testing progress UI: checking available download (20 steps, ~10s) extract install
case slowDownload
/// Initial permission request flow: shows permission dialog proceeds with happy path if accepted
case permissionRequest
/// User cancels during download: checking available download (5 steps) cancels idle
case cancelDuringDownload
/// User cancels while checking: checking (1s) cancels idle
case cancelDuringChecking
/// Shows the installing state with restart button: installing (stays until dismissed)
case installing
/// Simulates auto-update flow: goes directly to installing state without showing intermediate UI
case autoUpdate
func simulate(with viewModel: UpdateViewModel) {
switch self {
case .happyPath:
@@ -56,12 +56,12 @@ enum UpdateSimulator {
simulateAutoUpdate(viewModel)
}
}
private func simulateHappyPath(_ viewModel: UpdateViewModel) {
viewModel.state = .checking(.init(cancel: {
viewModel.state = .idle
}))
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
viewModel.state = .updateAvailable(.init(
appcastItem: SUAppcastItem.empty(),
@@ -75,28 +75,28 @@ enum UpdateSimulator {
))
}
}
private func simulateNotFound(_ viewModel: UpdateViewModel) {
viewModel.state = .checking(.init(cancel: {
viewModel.state = .idle
}))
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
viewModel.state = .notFound(.init(acknowledgement: {
// Acknowledgement called when dismissed
}))
DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
viewModel.state = .idle
}
}
}
private func simulateError(_ viewModel: UpdateViewModel) {
viewModel.state = .checking(.init(cancel: {
viewModel.state = .idle
}))
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
viewModel.state = .error(.init(
error: NSError(domain: "UpdateError", code: 1, userInfo: [
@@ -111,12 +111,12 @@ enum UpdateSimulator {
))
}
}
private func simulateSlowDownload(_ viewModel: UpdateViewModel) {
viewModel.state = .checking(.init(cancel: {
viewModel.state = .idle
}))
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
viewModel.state = .updateAvailable(.init(
appcastItem: SUAppcastItem.empty(),
@@ -130,7 +130,7 @@ enum UpdateSimulator {
))
}
}
private func simulateSlowDownloadProgress(_ viewModel: UpdateViewModel) {
let download = UpdateState.Downloading(
cancel: {
@@ -140,7 +140,7 @@ enum UpdateSimulator {
progress: 0
)
viewModel.state = .downloading(download)
for i in 1...20 {
DispatchQueue.main.asyncAfter(deadline: .now() + Double(i) * 0.5) {
let updatedDownload = UpdateState.Downloading(
@@ -149,7 +149,7 @@ enum UpdateSimulator {
progress: UInt64(i * 100)
)
viewModel.state = .downloading(updatedDownload)
if i == 20 {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
simulateExtract(viewModel)
@@ -158,7 +158,7 @@ enum UpdateSimulator {
}
}
}
private func simulatePermissionRequest(_ viewModel: UpdateViewModel) {
let request = SPUUpdatePermissionRequest(systemProfile: [])
viewModel.state = .permissionRequest(.init(
@@ -172,12 +172,12 @@ enum UpdateSimulator {
}
))
}
private func simulateCancelDuringDownload(_ viewModel: UpdateViewModel) {
viewModel.state = .checking(.init(cancel: {
viewModel.state = .idle
}))
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
viewModel.state = .updateAvailable(.init(
appcastItem: SUAppcastItem.empty(),
@@ -191,7 +191,7 @@ enum UpdateSimulator {
))
}
}
private func simulateDownloadThenCancel(_ viewModel: UpdateViewModel) {
let download = UpdateState.Downloading(
cancel: {
@@ -201,7 +201,7 @@ enum UpdateSimulator {
progress: 0
)
viewModel.state = .downloading(download)
for i in 1...5 {
DispatchQueue.main.asyncAfter(deadline: .now() + Double(i) * 0.3) {
let updatedDownload = UpdateState.Downloading(
@@ -210,7 +210,7 @@ enum UpdateSimulator {
progress: UInt64(i * 100)
)
viewModel.state = .downloading(updatedDownload)
if i == 5 {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
viewModel.state = .idle
@@ -219,17 +219,17 @@ enum UpdateSimulator {
}
}
}
private func simulateCancelDuringChecking(_ viewModel: UpdateViewModel) {
viewModel.state = .checking(.init(cancel: {
viewModel.state = .idle
}))
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
viewModel.state = .idle
}
}
private func simulateDownload(_ viewModel: UpdateViewModel) {
let download = UpdateState.Downloading(
cancel: {
@@ -239,7 +239,7 @@ enum UpdateSimulator {
progress: 0
)
viewModel.state = .downloading(download)
for i in 1...10 {
DispatchQueue.main.asyncAfter(deadline: .now() + Double(i) * 0.3) {
let updatedDownload = UpdateState.Downloading(
@@ -248,7 +248,7 @@ enum UpdateSimulator {
progress: UInt64(i * 100)
)
viewModel.state = .downloading(updatedDownload)
if i == 10 {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
simulateExtract(viewModel)
@@ -257,14 +257,14 @@ enum UpdateSimulator {
}
}
}
private func simulateExtract(_ viewModel: UpdateViewModel) {
viewModel.state = .extracting(.init(progress: 0.0))
for j in 1...5 {
DispatchQueue.main.asyncAfter(deadline: .now() + Double(j) * 0.3) {
viewModel.state = .extracting(.init(progress: Double(j) / 5.0))
if j == 5 {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
simulateInstalling(viewModel)
@@ -273,7 +273,7 @@ enum UpdateSimulator {
}
}
}
private func simulateInstalling(_ viewModel: UpdateViewModel) {
viewModel.state = .installing(.init(
retryTerminatingApplication: {
@@ -285,7 +285,7 @@ enum UpdateSimulator {
}
))
}
private func simulateAutoUpdate(_ viewModel: UpdateViewModel) {
viewModel.state = .installing(.init(
isAutoUpdate: true,

View File

@@ -4,7 +4,7 @@ import Sparkle
class UpdateViewModel: ObservableObject {
@Published var state: UpdateState = .idle
/// The text to display for the current update state.
/// Returns an empty string for idle state, progress percentages for downloading/extracting,
/// or descriptive text for other states.
@@ -38,7 +38,7 @@ class UpdateViewModel: ObservableObject {
return err.error.localizedDescription
}
}
/// The maximum width text for states that show progress.
/// Used to prevent the pill from resizing as percentages change.
var maxWidthText: String {
@@ -51,7 +51,7 @@ class UpdateViewModel: ObservableObject {
return text
}
}
/// The SF Symbol icon name for the current update state.
var iconName: String? {
switch state {
@@ -75,7 +75,7 @@ class UpdateViewModel: ObservableObject {
return "exclamationmark.triangle.fill"
}
}
/// A longer description for the current update state.
/// Used in contexts like the command palette where more detail is helpful.
var description: String {
@@ -100,7 +100,7 @@ class UpdateViewModel: ObservableObject {
return "An error occurred during the update process"
}
}
/// A badge to display for the current update state.
/// Returns version numbers, progress percentages, or nil.
var badge: String? {
@@ -120,7 +120,7 @@ class UpdateViewModel: ObservableObject {
return nil
}
}
/// The color to apply to the icon for the current update state.
var iconColor: Color {
switch state {
@@ -140,7 +140,7 @@ class UpdateViewModel: ObservableObject {
return .orange
}
}
/// The background color for the update pill.
var backgroundColor: Color {
switch state {
@@ -156,7 +156,7 @@ class UpdateViewModel: ObservableObject {
return Color(nsColor: .controlBackgroundColor)
}
}
/// The foreground (text) color for the update pill.
var foregroundColor: Color {
switch state {
@@ -184,12 +184,12 @@ enum UpdateState: Equatable {
case downloading(Downloading)
case extracting(Extracting)
case installing(Installing)
var isIdle: Bool {
if case .idle = self { return true }
return false
}
/// This is true if we're in a state that can be force installed.
var isInstallable: Bool {
switch self {
@@ -199,12 +199,12 @@ enum UpdateState: Equatable {
.extracting,
.installing:
return true
default:
return false
}
}
func cancel() {
switch self {
case .checking(let checking):
@@ -221,7 +221,7 @@ enum UpdateState: Equatable {
break
}
}
/// Confirms or accepts the current update state.
/// - For available updates: begins installation
/// - For ready-to-install: proceeds with installation
@@ -233,7 +233,7 @@ enum UpdateState: Equatable {
break
}
}
static func == (lhs: UpdateState, rhs: UpdateState) -> Bool {
switch (lhs, rhs) {
case (.idle, .idle):
@@ -258,38 +258,38 @@ enum UpdateState: Equatable {
return false
}
}
struct NotFound {
let acknowledgement: () -> Void
}
struct PermissionRequest {
let request: SPUUpdatePermissionRequest
let reply: @Sendable (SUUpdatePermissionResponse) -> Void
}
struct Checking {
let cancel: () -> Void
}
struct UpdateAvailable {
let appcastItem: SUAppcastItem
let reply: @Sendable (SPUUserUpdateChoice) -> Void
var releaseNotes: ReleaseNotes? {
let currentCommit = Bundle.main.infoDictionary?["GhosttyCommit"] as? String
return ReleaseNotes(displayVersionString: appcastItem.displayVersionString, currentCommit: currentCommit)
}
}
enum ReleaseNotes {
case commit(URL)
case compareTip(URL)
case tagged(URL)
init?(displayVersionString: String, currentCommit: String?) {
let version = displayVersionString
// Check for semantic version (x.y.z)
if let semver = Self.extractSemanticVersion(from: version) {
let slug = semver.replacingOccurrences(of: ".", with: "-")
@@ -298,12 +298,12 @@ enum UpdateState: Equatable {
return
}
}
// Fall back to git hash detection
guard let newHash = Self.extractGitHash(from: version) else {
return nil
}
if let currentHash = currentCommit, !currentHash.isEmpty,
let url = URL(string: "https://github.com/ghostty-org/ghostty/compare/\(currentHash)...\(newHash)") {
self = .compareTip(url)
@@ -313,7 +313,7 @@ enum UpdateState: Equatable {
return nil
}
}
private static func extractSemanticVersion(from version: String) -> String? {
let pattern = #"^\d+\.\d+\.\d+$"#
if version.range(of: pattern, options: .regularExpression) != nil {
@@ -321,7 +321,7 @@ enum UpdateState: Equatable {
}
return nil
}
private static func extractGitHash(from version: String) -> String? {
let pattern = #"[0-9a-f]{7,40}"#
if let range = version.range(of: pattern, options: .regularExpression) {
@@ -329,7 +329,7 @@ enum UpdateState: Equatable {
}
return nil
}
var url: URL {
switch self {
case .commit(let url): return url
@@ -337,7 +337,7 @@ enum UpdateState: Equatable {
case .tagged(let url): return url
}
}
var label: String {
switch self {
case .commit: return "View GitHub Commit"
@@ -346,23 +346,23 @@ enum UpdateState: Equatable {
}
}
}
struct Error {
let error: any Swift.Error
let retry: () -> Void
let dismiss: () -> Void
}
struct Downloading {
let cancel: () -> Void
let expectedLength: UInt64?
let progress: UInt64
}
struct Extracting {
let progress: Double
}
struct Installing {
/// True if this state is triggered by ``Ghostty/UpdateDriver/updater(_:willInstallUpdateOnQuit:immediateInstallationBlock:)``
var isAutoUpdate = false

View File

@@ -40,13 +40,13 @@ extension Ghostty.Action {
self.amount = c.amount
}
}
struct OpenURL {
enum Kind {
case unknown
case text
case html
init(_ c: ghostty_action_open_url_kind_e) {
switch c {
case GHOSTTY_ACTION_OPEN_URL_KIND_TEXT:
@@ -58,13 +58,13 @@ extension Ghostty.Action {
}
}
}
let kind: Kind
let url: String
init(c: ghostty_action_open_url_s) {
self.kind = Kind(c.kind)
if let urlCString = c.url {
let data = Data(bytes: urlCString, count: Int(c.len))
self.url = String(data: data, encoding: .utf8) ?? ""
@@ -81,7 +81,7 @@ extension Ghostty.Action {
case error
case indeterminate
case pause
init(_ c: ghostty_action_progress_report_state_e) {
switch c {
case GHOSTTY_PROGRESS_STATE_REMOVE:
@@ -99,26 +99,26 @@ extension Ghostty.Action {
}
}
}
let state: State
let progress: UInt8?
}
struct Scrollbar {
let total: UInt64
let offset: UInt64
let len: UInt64
init(c: ghostty_action_scrollbar_s) {
total = c.total
offset = c.offset
offset = c.offset
len = c.len
}
}
struct StartSearch {
let needle: String?
init(c: ghostty_action_start_search_s) {
if let needleCString = c.needle {
self.needle = String(cString: needleCString)

View File

@@ -379,25 +379,25 @@ extension Ghostty {
let surface = self.surfaceUserdata(from: userdata)
guard let pasteboard = NSPasteboard.ghostty(location) else { return }
guard let content = content, len > 0 else { return }
// Convert the C array to Swift array
let contentArray = (0..<len).compactMap { i in
Ghostty.ClipboardContent.from(content: content[i])
}
guard !contentArray.isEmpty else { return }
// Assert there is only one text/plain entry. For security reasons we need
// to guarantee this for now since our confirmation dialog only shows one.
assert(contentArray.filter({ $0.mime == "text/plain" }).count <= 1,
"clipboard contents should have at most one text/plain entry")
if !confirm {
// Declare all types
let types = contentArray.compactMap { item in
NSPasteboard.PasteboardType(mimeType: item.mime)
}
pasteboard.declareTypes(types, owner: nil)
// Set data for each type
for item in contentArray {
guard let type = NSPasteboard.PasteboardType(mimeType: item.mime) else { continue }
@@ -410,7 +410,7 @@ extension Ghostty {
guard let textPlainContent = contentArray.first(where: { $0.mime == "text/plain" }) else {
return
}
NotificationCenter.default.post(
name: Notification.confirmClipboard,
object: surface,
@@ -605,7 +605,7 @@ extension Ghostty {
case GHOSTTY_ACTION_CHECK_FOR_UPDATES:
checkForUpdates(app)
case GHOSTTY_ACTION_OPEN_URL:
return openURL(action.action.open_url)
@@ -681,12 +681,12 @@ extension Ghostty {
appDelegate.checkForUpdates(nil)
}
}
private static func openURL(
_ v: ghostty_action_open_url_s
) -> Bool {
let action = Ghostty.Action.OpenURL(c: v)
// If the URL doesn't have a valid scheme we assume its a file path. The URL
// initializer will gladly take invalid URLs (e.g. plain file paths) and turn
// them into schema-less URLs, but these won't open properly in text editors.
@@ -697,7 +697,7 @@ extension Ghostty {
} else {
url = URL(filePath: action.url)
}
switch action.kind {
case .text:
// Open with the default editor for `*.ghostty` file or just system text editor
@@ -706,15 +706,15 @@ extension Ghostty {
NSWorkspace.shared.open([url], withApplicationAt: textEditor, configuration: NSWorkspace.OpenConfiguration())
return true
}
case .html:
// The extension will be HTML and we do the right thing automatically.
break
case .unknown:
break
}
// Open with the default application for the URL
NSWorkspace.shared.open(url)
return true
@@ -1850,7 +1850,7 @@ extension Ghostty {
case GHOSTTY_TARGET_SURFACE:
guard let surface = target.target.surface else { return }
guard let surfaceView = self.surfaceView(from: surface) else { return }
let progressReport = Ghostty.Action.ProgressReport(c: v)
DispatchQueue.main.async {
if progressReport.state == .remove {
@@ -1877,7 +1877,7 @@ extension Ghostty {
case GHOSTTY_TARGET_SURFACE:
guard let surface = target.target.surface else { return }
guard let surfaceView = self.surfaceView(from: surface) else { return }
let scrollbar = Ghostty.Action.Scrollbar(c: v)
NotificationCenter.default.post(
name: .ghosttyDidUpdateScrollbar,
@@ -1914,7 +1914,7 @@ extension Ghostty {
} else {
surfaceView.searchState = Ghostty.SurfaceView.SearchState(from: startSearch)
}
NotificationCenter.default.post(name: .ghosttySearchFocus, object: surfaceView)
}

View File

@@ -763,7 +763,7 @@ extension Ghostty.Config {
static let navigation = SplitPreserveZoom(rawValue: 1 << 0)
}
enum MacDockDropBehavior: String {
case new_tab = "new-tab"
case new_window = "new-window"

View File

@@ -299,17 +299,17 @@ extension Ghostty {
}
}
}
struct ClipboardContent {
let mime: String
let data: String
static func from(content: ghostty_clipboard_content_s) -> ClipboardContent? {
guard let mimePtr = content.mime,
let dataPtr = content.data else {
return nil
}
return ClipboardContent(
mime: String(cString: mimePtr),
data: String(cString: dataPtr)

View File

@@ -6,12 +6,12 @@ extension Ghostty {
/// or nil if no surface is being dragged.
struct DraggingSurfaceKey: PreferenceKey {
static var defaultValue: SurfaceView.ID?
static func reduce(value: inout SurfaceView.ID?, nextValue: () -> SurfaceView.ID?) {
value = nextValue() ?? value
}
}
/// A SwiftUI view that provides drag source functionality for terminal surfaces.
///
/// This view wraps an AppKit-based drag source to enable drag-and-drop reordering
@@ -24,13 +24,13 @@ extension Ghostty {
struct SurfaceDragSource: View {
/// The surface view that will be dragged.
let surfaceView: SurfaceView
/// Binding that reflects whether a drag session is currently active.
@Binding var isDragging: Bool
/// Binding that reflects whether the mouse is hovering over this view.
@Binding var isHovering: Bool
var body: some View {
SurfaceDragSourceViewRepresentable(
surfaceView: surfaceView,
@@ -46,7 +46,7 @@ extension Ghostty {
let surfaceView: SurfaceView
@Binding var isDragging: Bool
@Binding var isHovering: Bool
func makeNSView(context: Context) -> SurfaceDragSourceView {
let view = SurfaceDragSourceView()
view.surfaceView = surfaceView
@@ -60,7 +60,7 @@ extension Ghostty {
}
return view
}
func updateNSView(_ nsView: SurfaceDragSourceView, context: Context) {
nsView.surfaceView = surfaceView
nsView.onDragStateChanged = { dragging in
@@ -73,7 +73,7 @@ extension Ghostty {
}
}
}
/// The underlying NSView that handles drag operations.
///
/// This view manages mouse tracking and drag initiation for surface reordering.
@@ -82,26 +82,26 @@ extension Ghostty {
fileprivate class SurfaceDragSourceView: NSView, NSDraggingSource {
/// Scale factor applied to the surface snapshot for the drag preview image.
private static let previewScale: CGFloat = 0.2
/// The surface view that will be dragged. Its UUID is encoded into the
/// pasteboard for drop targets to identify which surface is being moved.
var surfaceView: SurfaceView?
/// Callback invoked when the drag state changes. Called with `true` when
/// a drag session begins, and `false` when it ends (completed or cancelled).
var onDragStateChanged: ((Bool) -> Void)?
/// Callback invoked when the mouse enters or exits this view's bounds.
/// Used to update the hover state for visual feedback in the parent view.
var onHoverChanged: ((Bool) -> Void)?
/// Whether we are currently in a mouse tracking loop (between mouseDown
/// and either mouseUp or drag initiation). Used to determine cursor state.
private var isTracking: Bool = false
/// Local event monitor to detect escape key presses during drag.
private var escapeMonitor: Any?
/// Whether the current drag was cancelled by pressing escape.
private var dragCancelledByEscape: Bool = false
@@ -137,26 +137,26 @@ extension Ghostty {
userInfo: nil
))
}
override func resetCursorRects() {
addCursorRect(bounds, cursor: isTracking ? .closedHand : .openHand)
}
override func mouseEntered(with event: NSEvent) {
onHoverChanged?(true)
}
override func mouseExited(with event: NSEvent) {
onHoverChanged?(false)
}
override func mouseDragged(with event: NSEvent) {
guard !isTracking, let surfaceView = surfaceView else { return }
// Create our dragging item from our transferable
guard let pasteboardItem = surfaceView.pasteboardItem() else { return }
let item = NSDraggingItem(pasteboardWriter: pasteboardItem)
// Create a scaled preview image from the surface snapshot
if let snapshot = surfaceView.asImage {
let imageSize = NSSize(
@@ -172,7 +172,7 @@ extension Ghostty {
fraction: 1.0
)
scaledImage.unlockFocus()
// Position the drag image so the mouse is at the center of the image.
// I personally like the top middle or top left corner best but
// this matches macOS native tab dragging behavior (at least, as of
@@ -187,30 +187,30 @@ extension Ghostty {
contents: scaledImage
)
}
onDragStateChanged?(true)
let session = beginDraggingSession(with: [item], event: event, source: self)
// We need to disable this so that endedAt happens immediately for our
// drags outside of any targets.
session.animatesToStartingPositionsOnCancelOrFail = false
}
// MARK: NSDraggingSource
func draggingSession(
_ session: NSDraggingSession,
sourceOperationMaskFor context: NSDraggingContext
) -> NSDragOperation {
return context == .withinApplication ? .move : []
}
func draggingSession(
_ session: NSDraggingSession,
willBeginAt screenPoint: NSPoint
) {
isTracking = true
// Reset our escape tracking
dragCancelledByEscape = false
escapeMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
@@ -220,14 +220,14 @@ extension Ghostty {
return event
}
}
func draggingSession(
_ session: NSDraggingSession,
movedTo screenPoint: NSPoint
) {
NSCursor.closedHand.set()
}
func draggingSession(
_ session: NSDraggingSession,
endedAt screenPoint: NSPoint,
@@ -262,7 +262,7 @@ extension Notification.Name {
/// released outside a valid drop target) and was not cancelled by the user
/// pressing escape. The notification's object is the SurfaceView that was dragged.
static let ghosttySurfaceDragEndedNoTarget = Notification.Name("ghosttySurfaceDragEndedNoTarget")
/// Key for the screen point where the drag ended in the userInfo dictionary.
static let ghosttySurfaceDragEndedNoTargetPointKey = "endedAtPoint"
}

View File

@@ -1,17 +1,17 @@
import AppKit
import SwiftUI
extension Ghostty {
extension Ghostty {
/// A grab handle overlay at the top of the surface for dragging the window.
/// Only appears when hovering in the top region of the surface.
struct SurfaceGrabHandle: View {
private let handleHeight: CGFloat = 10
let surfaceView: SurfaceView
@State private var isHovering: Bool = false
@State private var isDragging: Bool = false
var body: some View {
VStack(spacing: 0) {
Rectangle()
@@ -32,7 +32,7 @@ extension Ghostty {
isHovering: $isHovering
)
}
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)

View File

@@ -5,7 +5,7 @@ import SwiftUI
/// control.
struct SurfaceProgressBar: View {
let report: Ghostty.Action.ProgressReport
private var color: Color {
switch report.state {
case .error: return .red
@@ -13,17 +13,17 @@ struct SurfaceProgressBar: View {
default: return .accentColor
}
}
private var progress: UInt8? {
// If we have an explicit progress use that.
if let v = report.progress { return v }
// Otherwise, if we're in the pause state, we act as if we're at 100%.
if report.state == .pause { return 100 }
return nil
}
private var accessibilityLabel: String {
switch report.state {
case .error: return "Terminal progress - Error"
@@ -32,7 +32,7 @@ struct SurfaceProgressBar: View {
default: return "Terminal progress"
}
}
private var accessibilityValue: String {
if let progress {
return "\(progress) percent complete"
@@ -45,7 +45,7 @@ struct SurfaceProgressBar: View {
}
}
}
var body: some View {
GeometryReader { geometry in
ZStack(alignment: .leading) {
@@ -78,15 +78,15 @@ struct SurfaceProgressBar: View {
private struct BouncingProgressBar: View {
let color: Color
@State private var position: CGFloat = 0
private let barWidthRatio: CGFloat = 0.25
var body: some View {
GeometryReader { geometry in
ZStack(alignment: .leading) {
Rectangle()
.fill(color.opacity(0.3))
Rectangle()
.fill(color)
.frame(

View File

@@ -19,12 +19,12 @@ class SurfaceScrollView: NSView {
private var observers: [NSObjectProtocol] = []
private var cancellables: Set<AnyCancellable> = []
private var isLiveScrolling = false
/// The last row position sent via scroll_to_row action. Used to avoid
/// sending redundant actions when the user drags the scrollbar but stays
/// on the same row.
private var lastSentRow: Int?
init(contentSize: CGSize, surfaceView: Ghostty.SurfaceView) {
self.surfaceView = surfaceView
// The scroll view is our outermost view that controls all our scrollbar
@@ -44,26 +44,26 @@ class SurfaceScrollView: NSView {
// (we currently only use overlay scrollers, but might as well
// configure the views correctly in case we change our mind)
scrollView.contentView.clipsToBounds = false
// The document view is what the scrollview is actually going
// to be directly scrolling. We set it up to a "blank" NSView
// with the desired content size.
documentView = NSView(frame: NSRect(origin: .zero, size: contentSize))
scrollView.documentView = documentView
// The document view contains our actual surface as a child.
// We synchronize the scrolling of the document with this surface
// so that our primary Ghostty renderer only needs to render the viewport.
documentView.addSubview(surfaceView)
super.init(frame: .zero)
// Our scroll view is our only view
addSubview(scrollView)
// Apply initial scrollbar settings
synchronizeAppearance()
// We listen for scroll events through bounds notifications on our NSClipView.
// This is based on: https://christiantietze.de/posts/2018/07/synchronize-nsscrollview/
scrollView.contentView.postsBoundsChangedNotifications = true
@@ -74,7 +74,7 @@ class SurfaceScrollView: NSView {
) { [weak self] notification in
self?.handleScrollChange(notification)
})
// Listen for scrollbar updates from Ghostty
observers.append(NotificationCenter.default.addObserver(
forName: .ghosttyDidUpdateScrollbar,
@@ -83,7 +83,7 @@ class SurfaceScrollView: NSView {
) { [weak self] notification in
self?.handleScrollbarUpdate(notification)
})
// Listen for live scroll events
observers.append(NotificationCenter.default.addObserver(
forName: NSScrollView.willStartLiveScrollNotification,
@@ -92,7 +92,7 @@ class SurfaceScrollView: NSView {
) { [weak self] _ in
self?.isLiveScrolling = true
})
observers.append(NotificationCenter.default.addObserver(
forName: NSScrollView.didEndLiveScrollNotification,
object: scrollView,
@@ -100,7 +100,7 @@ class SurfaceScrollView: NSView {
) { [weak self] _ in
self?.isLiveScrolling = false
})
observers.append(NotificationCenter.default.addObserver(
forName: NSScrollView.didLiveScrollNotification,
object: scrollView,
@@ -108,7 +108,7 @@ class SurfaceScrollView: NSView {
) { [weak self] _ in
self?.handleLiveScroll()
})
observers.append(NotificationCenter.default.addObserver(
forName: NSScroller.preferredScrollerStyleDidChangeNotification,
object: nil,
@@ -150,11 +150,11 @@ class SurfaceScrollView: NSView {
}
.store(in: &cancellables)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) not implemented")
}
deinit {
observers.forEach { NotificationCenter.default.removeObserver($0) }
}
@@ -163,10 +163,10 @@ class SurfaceScrollView: NSView {
// insets. This is necessary for the content view to match the
// surface view if we have the "hidden" titlebar style.
override var safeAreaInsets: NSEdgeInsets { return NSEdgeInsetsZero }
override func layout() {
super.layout()
// Fill entire bounds with scroll view
scrollView.frame = bounds
surfaceView.frame.size = scrollView.bounds.size
@@ -174,13 +174,13 @@ class SurfaceScrollView: NSView {
// We only set the width of the documentView here, as the height depends
// on the scrollbar state and is updated in synchronizeScrollView
documentView.frame.size.width = scrollView.bounds.width
// When our scrollview changes make sure our scroller and surface views are synchronized
synchronizeScrollView()
synchronizeSurfaceView()
synchronizeCoreSurface()
}
// MARK: Scrolling
private func synchronizeAppearance() {
@@ -220,7 +220,7 @@ class SurfaceScrollView: NSView {
private func synchronizeScrollView() {
// Update the document height to give our scroller the correct proportions
documentView.frame.size.height = documentHeight()
// Only update our actual scroll position if we're not actively scrolling.
if !isLiveScrolling {
// Convert row units to pixels using cell height, ignore zero height.
@@ -236,13 +236,13 @@ class SurfaceScrollView: NSView {
lastSentRow = Int(scrollbar.offset)
}
}
// Always update our scrolled view with the latest dimensions
scrollView.reflectScrolledClipView(scrollView.contentView)
}
// MARK: Notifications
/// Handles bounds changes in the scroll view's clip view, keeping the surface view synchronized.
private func handleScrollChange(_ notification: Notification) {
synchronizeSurfaceView()
@@ -259,7 +259,7 @@ class SurfaceScrollView: NSView {
synchronizeAppearance()
synchronizeCoreSurface()
}
/// Handles live scroll events (user actively dragging the scrollbar).
///
/// Converts the current scroll position to a row number and sends a `scroll_to_row` action
@@ -270,21 +270,21 @@ class SurfaceScrollView: NSView {
// happen with a tiny terminal.
let cellHeight = surfaceView.cellSize.height
guard cellHeight > 0 else { return }
// AppKit views are +Y going up, so we calculate from the bottom
let visibleRect = scrollView.contentView.documentVisibleRect
let documentHeight = documentView.frame.height
let scrollOffset = documentHeight - visibleRect.origin.y - visibleRect.height
let row = Int(scrollOffset / cellHeight)
// Only send action if the row changed to avoid action spam
guard row != lastSentRow else { return }
lastSentRow = row
// Use the keybinding action to scroll.
_ = surfaceView.surfaceModel?.perform(action: "scroll_to_row:\(row)")
}
/// Handles scrollbar state updates from the terminal core.
///
/// Updates the document view size to reflect total scrollback and adjusts scroll position

View File

@@ -17,11 +17,11 @@ extension Ghostty.SurfaceView: Transferable {
let uuid = data.withUnsafeBytes {
$0.load(as: UUID.self)
}
guard let imported = await Self.find(uuid: uuid) else {
throw TransferError.invalidData
}
return imported
}
}
@@ -29,7 +29,7 @@ extension Ghostty.SurfaceView: Transferable {
enum TransferError: Error {
case invalidData
}
@MainActor
static func find(uuid: UUID) -> Self? {
#if canImport(AppKit)

View File

@@ -49,7 +49,7 @@ extension Ghostty {
// True if we're hovering over the left URL view, so we can show it on the right.
@State private var isHoveringURLLeft: Bool = false
#if canImport(AppKit)
// Observe SecureInput to detect when its enabled
@ObservedObject private var secureInput = SecureInput.shared
@@ -103,7 +103,7 @@ extension Ghostty {
}
}
.ghosttySurfaceView(surfaceView)
// Progress report
if let progressReport = surfaceView.progressReport, progressReport.state != .remove {
VStack(spacing: 0) {
@@ -114,7 +114,7 @@ extension Ghostty {
.allowsHitTesting(false)
.transition(.opacity)
}
#if canImport(AppKit)
// Readonly indicator badge
if surfaceView.readonly {
@@ -122,7 +122,7 @@ extension Ghostty {
surfaceView.toggleReadonly(nil)
}
}
// Show key state indicator for active key tables and/or pending key sequences
KeyStateIndicator(
keyTables: surfaceView.keyTables,
@@ -404,9 +404,9 @@ extension Ghostty {
@State private var dragOffset: CGSize = .zero
@State private var barSize: CGSize = .zero
@FocusState private var isSearchFieldFocused: Bool
private let padding: CGFloat = 8
var body: some View {
GeometryReader { geo in
HStack(spacing: 4) {
@@ -460,7 +460,7 @@ extension Ghostty {
Image(systemName: "chevron.up")
}
.buttonStyle(SearchButtonStyle())
Button(action: {
guard let surface = surfaceView.surface else { return }
let action = "navigate_search:previous"
@@ -469,7 +469,7 @@ extension Ghostty {
Image(systemName: "chevron.down")
}
.buttonStyle(SearchButtonStyle())
Button(action: onClose) {
Image(systemName: "xmark")
}
@@ -529,7 +529,7 @@ extension Ghostty {
enum Corner {
case topLeft, topRight, bottomLeft, bottomRight
var alignment: Alignment {
switch self {
case .topLeft: return .topLeading
@@ -539,11 +539,11 @@ extension Ghostty {
}
}
}
private func centerPosition(for corner: Corner, in containerSize: CGSize, barSize: CGSize) -> CGPoint {
let halfWidth = barSize.width / 2 + padding
let halfHeight = barSize.height / 2 + padding
switch corner {
case .topLeft:
return CGPoint(x: halfWidth, y: halfHeight)
@@ -555,21 +555,21 @@ extension Ghostty {
return CGPoint(x: containerSize.width - halfWidth, y: containerSize.height - halfHeight)
}
}
private func closestCorner(to point: CGPoint, in containerSize: CGSize) -> Corner {
let midX = containerSize.width / 2
let midY = containerSize.height / 2
if point.x < midX {
return point.y < midY ? .topLeft : .bottomLeft
} else {
return point.y < midY ? .topRight : .bottomRight
}
}
struct SearchButtonStyle: ButtonStyle {
@State private var isHovered = false
func makeBody(configuration: Configuration) -> some View {
configuration.label
.foregroundStyle(isHovered || configuration.isPressed ? .primary : .secondary)
@@ -584,7 +584,7 @@ extension Ghostty {
}
.backport.pointerStyle(.link)
}
private func backgroundColor(isPressed: Bool) -> Color {
if isPressed {
return Color.primary.opacity(0.2)
@@ -647,13 +647,13 @@ extension Ghostty {
/// Explicit command to set
var command: String?
/// Environment variables to set for the terminal
var environmentVariables: [String: String] = [:]
/// Extra input to send as stdin
var initialInput: String?
/// Wait after the command
var waitAfterCommand: Bool = false
@@ -711,7 +711,7 @@ extension Ghostty {
// Zero is our default value that means to inherit the font size.
config.font_size = fontSize ?? 0
// Set wait after command
config.wait_after_command = waitAfterCommand
@@ -764,24 +764,24 @@ extension Ghostty {
struct KeyStateIndicator: View {
let keyTables: [String]
let keySequence: [KeyboardShortcut]
@State private var isShowingPopover = false
@State private var position: Position = .bottom
@State private var dragOffset: CGSize = .zero
@State private var isDragging = false
private let padding: CGFloat = 8
enum Position {
case top, bottom
var alignment: Alignment {
switch self {
case .top: return .top
case .bottom: return .bottom
}
}
var popoverEdge: Edge {
switch self {
case .top: return .top
@@ -861,14 +861,14 @@ extension Ghostty {
Divider()
.frame(height: 14)
}
// Key sequence indicator
if !keySequence.isEmpty {
HStack(alignment: .center, spacing: 4) {
ForEach(Array(keySequence.enumerated()), id: \.offset) { index, key in
KeyCap(key.description)
}
// Animated ellipsis to indicate waiting for next key
PendingIndicator(paused: isDragging)
}
@@ -898,11 +898,11 @@ extension Ghostty {
.foregroundStyle(.secondary)
}
}
if !keyTables.isEmpty && !keySequence.isEmpty {
Divider()
}
if !keySequence.isEmpty {
VStack(alignment: .leading, spacing: 4) {
Label("Key Sequence", systemImage: "character.cursor.ibeam")
@@ -921,15 +921,15 @@ extension Ghostty {
isShowingPopover.toggle()
}
}
/// A small keycap-style view for displaying keyboard shortcuts
struct KeyCap: View {
let text: String
init(_ text: String) {
self.text = text
}
var body: some View {
Text(verbatim: text)
.font(.system(size: 12, weight: .medium, design: .rounded))
@@ -946,7 +946,7 @@ extension Ghostty {
)
}
}
/// Animated dots to indicate waiting for the next key
struct PendingIndicator: View {
@State private var animationPhase: Double = 0
@@ -967,7 +967,7 @@ extension Ghostty {
}
}
}
private func dotOpacity(for index: Int) -> Double {
let phase = animationPhase
let offset = Double(index) / 3.0
@@ -981,7 +981,7 @@ extension Ghostty {
/// Visual overlay that shows a border around the edges when the bell rings with border feature enabled.
struct BellBorderOverlay: View {
let bell: Bool
var body: some View {
Rectangle()
.strokeBorder(
@@ -998,7 +998,7 @@ extension Ghostty {
/// Uses a soft, soothing highlight with a pulsing border effect.
struct HighlightOverlay: View {
let highlighted: Bool
@State private var borderPulse: Bool = false
var body: some View {
@@ -1051,21 +1051,21 @@ extension Ghostty {
}
// MARK: Readonly Badge
/// A badge overlay that indicates a surface is in readonly mode.
/// Positioned in the top-right corner and styled to be noticeable but unobtrusive.
struct ReadonlyBadge: View {
let onDisable: () -> Void
@State private var showingPopover = false
private let badgeColor = Color(hue: 0.08, saturation: 0.5, brightness: 0.8)
var body: some View {
VStack {
HStack {
Spacer()
HStack(spacing: 5) {
Image(systemName: "eye.fill")
.font(.system(size: 12))
@@ -1085,13 +1085,13 @@ extension Ghostty {
}
}
.padding(8)
Spacer()
}
.accessibilityElement(children: .ignore)
.accessibilityLabel("Read-only terminal")
}
private var badgeBackground: some View {
RoundedRectangle(cornerRadius: 6)
.fill(.regularMaterial)
@@ -1101,11 +1101,11 @@ extension Ghostty {
)
}
}
struct ReadonlyPopoverView: View {
let onDisable: () -> Void
@Binding var isPresented: Bool
var body: some View {
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 8) {
@@ -1116,16 +1116,16 @@ extension Ghostty {
Text("Read-Only Mode")
.font(.system(size: 13, weight: .semibold))
}
Text("This terminal is in read-only mode. You can still view, select, and scroll through the content, but no input events will be sent to the running application.")
.font(.system(size: 11))
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
HStack {
Spacer()
Button("Disable") {
onDisable()
isPresented = false

View File

@@ -1184,7 +1184,7 @@ extension Ghostty {
// We only care about key down events. It might not even be possible
// to receive any other event type here.
guard event.type == .keyDown else { return false }
// Only process events if we're focused. Some key events like C-/ macOS
// appears to send to the first view in the hierarchy rather than the
// the first responder (I don't know why). This prevents us from handling it.
@@ -1194,7 +1194,7 @@ extension Ghostty {
if !focused {
return false
}
// Get information about if this is a binding.
let bindingFlags = surfaceModel.flatMap { surface in
var ghosttyEvent = event.ghosttyKeyEvent(GHOSTTY_ACTION_PRESS)
@@ -1203,7 +1203,7 @@ extension Ghostty {
return surface.keyIsBinding(ghosttyEvent)
}
}
// If this is a binding then we want to perform it.
if let bindingFlags {
// Attempt to trigger a menu item for this key binding. We only do this if:
@@ -1220,7 +1220,7 @@ extension Ghostty {
return true
}
}
self.keyDown(with: event)
return true
}

View File

@@ -32,7 +32,7 @@ extension Ghostty {
// The hovered URL
@Published var hoverUrl: String?
// The progress report (if any)
@Published var progressReport: Action.ProgressReport?
@@ -42,7 +42,7 @@ extension Ghostty {
/// True when the bell is active. This is set inactive on focus or event.
@Published var bell: Bool = false
// The current search state. When non-nil, the search overlay should be shown.
@Published var searchState: SearchState?
@@ -51,7 +51,7 @@ extension Ghostty {
/// True when the surface is in readonly mode.
@Published private(set) var readonly: Bool = false
/// True when the surface should show a highlight effect (e.g., when presented via goto_split).
@Published private(set) var highlighted: Bool = false

View File

@@ -4,7 +4,7 @@ import Foundation
struct AnySortKey: Comparable {
private let value: Any
private let comparator: (Any, Any) -> ComparisonResult
init<T: Comparable>(_ value: T) {
self.value = value
self.comparator = { lhs, rhs in
@@ -14,11 +14,11 @@ struct AnySortKey: Comparable {
return .orderedSame
}
}
static func < (lhs: AnySortKey, rhs: AnySortKey) -> Bool {
lhs.comparator(lhs.value, rhs.value) == .orderedAscending
}
static func == (lhs: AnySortKey, rhs: AnySortKey) -> Bool {
lhs.comparator(lhs.value, rhs.value) == .orderedSame
}

View File

@@ -48,7 +48,7 @@ extension Backport where Content: View {
return content
#endif
}
/// Backported onKeyPress that works on macOS 14+ and is a no-op on macOS 13.
func onKeyPress(_ key: KeyEquivalent, action: @escaping (EventModifiers) -> BackportKeyPressResult) -> some View {
#if canImport(AppKit)

View File

@@ -98,10 +98,10 @@ class ExpiringUndoManager: UndoManager {
private class ExpiringTarget {
/// The actual target object for the undo operation, held weakly to avoid retain cycles.
private(set) weak var target: AnyObject?
/// Timer that triggers expiration after the specified duration.
private var timer: Timer?
/// The undo manager from which to remove actions when this target expires.
private weak var undoManager: UndoManager?
@@ -141,7 +141,7 @@ extension ExpiringTarget: Hashable, Equatable {
static func == (lhs: ExpiringTarget, rhs: ExpiringTarget) -> Bool {
return lhs === rhs
}
func hash(into hasher: inout Hasher) {
hasher.combine(ObjectIdentifier(self))
}

View File

@@ -2,7 +2,7 @@ extension Array {
subscript(safe index: Int) -> Element? {
return indices.contains(index) ? self[index] : nil
}
/// Returns the index before i, with wraparound. Assumes i is a valid index.
func indexWrapping(before i: Int) -> Int {
if i == 0 {
@@ -35,7 +35,7 @@ extension Array where Element == String {
if index == count {
return try body(accumulated)
}
return try self[index].withCString { cStr in
var newAccumulated = accumulated
newAccumulated.append(cStr)

View File

@@ -13,14 +13,14 @@ extension NSPasteboard.PasteboardType {
default:
break
}
// Try to get UTType from MIME type
guard let utType = UTType(mimeType: mimeType) else {
// Fallback: use the MIME type directly as identifier
self.init(mimeType)
return
}
// Use the UTType's identifier
self.init(utType.identifier)
}

View File

@@ -5,7 +5,7 @@ extension NSScreen {
var displayID: UInt32? {
deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? UInt32
}
/// The stable UUID for this display, suitable for tracking across reconnects and NSScreen garbage collection.
var displayUUID: UUID? {
guard let displayID = displayID else { return nil }
@@ -48,7 +48,7 @@ extension NSScreen {
// know any other situation this is true.
return safeAreaInsets.top > 0
}
/// Converts top-left offset coordinates to bottom-left origin coordinates for window positioning.
/// - Parameters:
/// - x: X offset from top-left corner
@@ -57,11 +57,11 @@ extension NSScreen {
/// - Returns: CGPoint suitable for setFrameOrigin that positions the window as requested
func origin(fromTopLeftOffsetX x: CGFloat, offsetY y: CGFloat, windowSize: CGSize) -> CGPoint {
let vf = visibleFrame
// Convert top-left coordinates to bottom-left origin
let originX = vf.minX + x
let originY = vf.maxY - y - windowSize.height
return CGPoint(x: originX, y: originY)
}
}

View File

@@ -131,12 +131,12 @@ extension NSView {
/// This includes private views like title bar views.
func firstViewFromRoot(withClassName name: String) -> NSView? {
let root = rootView
// Check if the root view itself matches
if String(describing: type(of: root)) == name {
return root
}
// Otherwise search descendants
return root.firstDescendant(withClassName: name)
}
@@ -155,67 +155,67 @@ extension NSView {
print("View Hierarchy from Root:")
print(root.viewHierarchyDescription())
}
/// Returns a string representation of the view hierarchy in a tree-like format.
func viewHierarchyDescription(indent: String = "", isLast: Bool = true) -> String {
var result = ""
// Add the tree branch characters
result += indent
if !indent.isEmpty {
result += isLast ? "└── " : "├── "
}
// Add the class name and optional identifier
let className = String(describing: type(of: self))
result += className
// Add identifier if present
if let identifier = self.identifier {
result += " (id: \(identifier.rawValue))"
}
// Add frame info
result += " [frame: \(frame)]"
// Add visual properties
var properties: [String] = []
// Hidden status
if isHidden {
properties.append("hidden")
}
// Opaque status
properties.append(isOpaque ? "opaque" : "transparent")
// Layer backing
if wantsLayer {
properties.append("layer-backed")
if let bgColor = layer?.backgroundColor {
let color = NSColor(cgColor: bgColor)
if let rgb = color?.usingColorSpace(.deviceRGB) {
properties.append(String(format: "bg:rgba(%.0f,%.0f,%.0f,%.2f)",
rgb.redComponent * 255,
rgb.greenComponent * 255,
rgb.blueComponent * 255,
properties.append(String(format: "bg:rgba(%.0f,%.0f,%.0f,%.2f)",
rgb.redComponent * 255,
rgb.greenComponent * 255,
rgb.blueComponent * 255,
rgb.alphaComponent))
} else {
properties.append("bg:\(bgColor)")
}
}
}
result += " [\(properties.joined(separator: ", "))]"
result += "\n"
// Process subviews
for (index, subview) in subviews.enumerated() {
let isLastSubview = index == subviews.count - 1
let newIndent = indent + (isLast ? " " : "")
result += subview.viewHierarchyDescription(indent: newIndent, isLast: isLastSubview)
}
return result
}
}

View File

@@ -52,31 +52,31 @@ extension NSWindow {
guard themeFrameView.responds(to: Selector(("titlebarView"))) else { return nil }
return themeFrameView.value(forKey: "titlebarView") as? NSView
}
/// Returns the [private] NSTabBar view, if it exists.
var tabBarView: NSView? {
titlebarView?.firstDescendant(withClassName: "NSTabBar")
}
/// Returns the index of the tab button at the given screen point, if any.
func tabIndex(atScreenPoint screenPoint: NSPoint) -> Int? {
guard let tabBarView else { return nil }
let locationInWindow = convertPoint(fromScreen: screenPoint)
let locationInTabBar = tabBarView.convert(locationInWindow, from: nil)
guard tabBarView.bounds.contains(locationInTabBar) else { return nil }
// Find all tab buttons and sort by x position to get visual order.
// The view hierarchy order doesn't match the visual tab order.
let tabItemViews = tabBarView.descendants(withClassName: "NSTabButton")
.sorted { $0.frame.origin.x < $1.frame.origin.x }
for (index, tabItemView) in tabItemViews.enumerated() {
let locationInTab = tabItemView.convert(locationInWindow, from: nil)
if tabItemView.bounds.contains(locationInTab) {
return index
}
}
return nil
}
}

View File

@@ -24,7 +24,7 @@ extension NSWorkspace {
nil
)?.takeRetainedValue() as? URL
}
/// Returns the URL of the default application for opening files with the specified file extension.
/// - Parameter ext: The file extension to find the default application for.
/// - Returns: The URL of the default application, or nil if no default application is found.

View File

@@ -40,16 +40,16 @@ private final class TransferableDataProvider: NSObject, NSPasteboardItemDataProv
// to block until the async load completes. This is safe because AppKit
// calls this method on a background thread during drag operations.
let semaphore = DispatchSemaphore(value: 0)
var result: Data?
itemProvider.loadDataRepresentation(forTypeIdentifier: type.rawValue) { data, _ in
result = data
semaphore.signal()
}
// Wait for the data to load
semaphore.wait()
// Set it. I honestly don't know what happens here if this fails.
if let data = result {
item.setData(data, forType: type)

View File

@@ -40,7 +40,7 @@ class PermissionRequest {
completion(storedResult)
return
}
let alert = NSAlert()
alert.messageText = message
alert.informativeText = informative
@@ -59,7 +59,7 @@ class PermissionRequest {
target: nil,
action: nil)
checkbox!.state = .off
// Set checkbox as accessory view
alert.accessoryView = checkbox
}
@@ -74,7 +74,7 @@ class PermissionRequest {
handleResponse(response, rememberDecision: checkbox?.state == .on, key: key, allowDuration: allowDuration, rememberDuration: rememberDuration, completion: completion)
}
}
/// Handles the alert response and processes caching logic
/// - Parameters:
/// - response: The alert response from the user
@@ -90,7 +90,7 @@ class PermissionRequest {
allowDuration: AllowDuration,
rememberDuration: Duration?,
completion: @escaping (Bool) -> Void) {
let result: Bool
switch response {
case .alertFirstButtonReturn: // Allow
@@ -100,7 +100,7 @@ class PermissionRequest {
default:
result = false
}
// Store the result if checkbox is checked or if "Allow" was selected and allowDuration is set
if rememberDecision, let rememberDuration = rememberDuration {
storeResult(result, for: key, duration: rememberDuration)
@@ -118,10 +118,10 @@ class PermissionRequest {
storeResult(result, for: key, duration: duration)
}
}
completion(result)
}
/// Retrieves a cached permission decision if it hasn't expired
/// - Parameter key: The UserDefaults key to check
/// - Returns: The cached decision, or nil if no valid cached decision exists
@@ -132,16 +132,16 @@ class PermissionRequest {
ofClass: StoredPermission.self, from: data) else {
return nil
}
if Date() > storedPermission.expiry {
// Decision has expired, remove stored value
userDefaults.removeObject(forKey: key)
return nil
}
return storedPermission.result
}
/// Stores a permission decision in UserDefaults with an expiration date
/// - Parameters:
/// - result: The permission decision to store
@@ -180,7 +180,7 @@ class PermissionRequest {
return "Remember my decision for \(days) day\(days == 1 ? "" : "s")"
}
}
/// Internal class for storing permission decisions with expiration dates in UserDefaults
/// Conforms to NSSecureCoding for safe archiving/unarchiving
@objc(StoredPermission)

View File

@@ -16,14 +16,14 @@ struct NSPasteboardTypeExtensionTests {
#expect(pasteboardType != nil)
#expect(pasteboardType == .string)
}
/// Test text/html MIME type converts to .html
@Test func testTextHtmlMimeType() async throws {
let pasteboardType = NSPasteboard.PasteboardType(mimeType: "text/html")
#expect(pasteboardType != nil)
#expect(pasteboardType == .html)
}
/// Test image/png MIME type
@Test func testImagePngMimeType() async throws {
let pasteboardType = NSPasteboard.PasteboardType(mimeType: "image/png")

View File

@@ -15,65 +15,65 @@ struct NSScreenExtensionTests {
// Mock screen with 1000x800 visible frame starting at (0, 100)
let mockScreenFrame = NSRect(x: 0, y: 100, width: 1000, height: 800)
let mockScreen = MockNSScreen(visibleFrame: mockScreenFrame)
// Mock window size
let windowSize = CGSize(width: 400, height: 300)
// Test top-left positioning: x=15, y=15
let origin = mockScreen.origin(
fromTopLeftOffsetX: 15,
offsetY: 15,
windowSize: windowSize)
// Expected: x = 0 + 15 = 15, y = (100 + 800) - 15 - 300 = 585
#expect(origin.x == 15)
#expect(origin.y == 585)
}
/// Test zero coordinates (exact top-left corner)
@Test func testZeroCoordinates() async throws {
let mockScreenFrame = NSRect(x: 0, y: 100, width: 1000, height: 800)
let mockScreen = MockNSScreen(visibleFrame: mockScreenFrame)
let windowSize = CGSize(width: 400, height: 300)
let origin = mockScreen.origin(
fromTopLeftOffsetX: 0,
offsetY: 0,
windowSize: windowSize)
// Expected: x = 0, y = (100 + 800) - 0 - 300 = 600
#expect(origin.x == 0)
#expect(origin.y == 600)
}
/// Test with offset screen (not starting at origin)
@Test func testOffsetScreen() async throws {
// Secondary monitor at position (1440, 0) with 1920x1080 resolution
let mockScreenFrame = NSRect(x: 1440, y: 0, width: 1920, height: 1080)
let mockScreen = MockNSScreen(visibleFrame: mockScreenFrame)
let windowSize = CGSize(width: 600, height: 400)
let origin = mockScreen.origin(
fromTopLeftOffsetX: 100,
offsetY: 50,
windowSize: windowSize)
// Expected: x = 1440 + 100 = 1540, y = (0 + 1080) - 50 - 400 = 630
#expect(origin.x == 1540)
#expect(origin.y == 630)
}
/// Test large coordinates
@Test func testLargeCoordinates() async throws {
let mockScreenFrame = NSRect(x: 0, y: 0, width: 1920, height: 1080)
let mockScreen = MockNSScreen(visibleFrame: mockScreenFrame)
let windowSize = CGSize(width: 400, height: 300)
let origin = mockScreen.origin(
fromTopLeftOffsetX: 500,
offsetY: 200,
windowSize: windowSize)
// Expected: x = 0 + 500 = 500, y = (0 + 1080) - 200 - 300 = 580
#expect(origin.x == 500)
#expect(origin.y == 580)
@@ -83,16 +83,16 @@ struct NSScreenExtensionTests {
/// Mock NSScreen class for testing coordinate conversion
private class MockNSScreen: NSScreen {
private let mockVisibleFrame: NSRect
init(visibleFrame: NSRect) {
self.mockVisibleFrame = visibleFrame
super.init()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override var visibleFrame: NSRect {
return mockVisibleFrame
}

View File

@@ -9,7 +9,7 @@ struct ReleaseNotesTests {
displayVersionString: "1.2.3",
currentCommit: nil
)
#expect(notes != nil)
if case .tagged(let url) = notes {
#expect(url.absoluteString == "https://ghostty.org/docs/install/release-notes/1-2-3")
@@ -18,14 +18,14 @@ struct ReleaseNotesTests {
Issue.record("Expected tagged case")
}
}
/// Test tip release comparison with current commit
@Test func testTipReleaseComparison() async throws {
let notes = UpdateState.ReleaseNotes(
displayVersionString: "tip-abc1234",
currentCommit: "def5678"
)
#expect(notes != nil)
if case .compareTip(let url) = notes {
#expect(url.absoluteString == "https://github.com/ghostty-org/ghostty/compare/def5678...abc1234")
@@ -34,14 +34,14 @@ struct ReleaseNotesTests {
Issue.record("Expected compareTip case")
}
}
/// Test tip release without current commit
@Test func testTipReleaseWithoutCurrentCommit() async throws {
let notes = UpdateState.ReleaseNotes(
displayVersionString: "tip-abc1234",
currentCommit: nil
)
#expect(notes != nil)
if case .commit(let url) = notes {
#expect(url.absoluteString == "https://github.com/ghostty-org/ghostty/commit/abc1234")
@@ -50,14 +50,14 @@ struct ReleaseNotesTests {
Issue.record("Expected commit case")
}
}
/// Test tip release with empty current commit
@Test func testTipReleaseWithEmptyCurrentCommit() async throws {
let notes = UpdateState.ReleaseNotes(
displayVersionString: "tip-abc1234",
currentCommit: ""
)
#expect(notes != nil)
if case .commit(let url) = notes {
#expect(url.absoluteString == "https://github.com/ghostty-org/ghostty/commit/abc1234")
@@ -65,14 +65,14 @@ struct ReleaseNotesTests {
Issue.record("Expected commit case")
}
}
/// Test version with full 40-character hash
@Test func testFullGitHash() async throws {
let notes = UpdateState.ReleaseNotes(
displayVersionString: "tip-1234567890abcdef1234567890abcdef12345678",
currentCommit: nil
)
#expect(notes != nil)
if case .commit(let url) = notes {
#expect(url.absoluteString == "https://github.com/ghostty-org/ghostty/commit/1234567890abcdef1234567890abcdef12345678")
@@ -80,46 +80,46 @@ struct ReleaseNotesTests {
Issue.record("Expected commit case")
}
}
/// Test version with no recognizable pattern
@Test func testInvalidVersion() async throws {
let notes = UpdateState.ReleaseNotes(
displayVersionString: "unknown-version",
currentCommit: nil
)
#expect(notes == nil)
}
/// Test semantic version with prerelease suffix should not match
@Test func testSemanticVersionWithSuffix() async throws {
let notes = UpdateState.ReleaseNotes(
displayVersionString: "1.2.3-beta",
currentCommit: nil
)
// Should not match semantic version pattern, falls back to hash detection
#expect(notes == nil)
}
/// Test semantic version with 4 components should not match
@Test func testSemanticVersionFourComponents() async throws {
let notes = UpdateState.ReleaseNotes(
displayVersionString: "1.2.3.4",
currentCommit: nil
)
// Should not match pattern
#expect(notes == nil)
}
/// Test version string with git hash embedded
@Test func testVersionWithEmbeddedHash() async throws {
let notes = UpdateState.ReleaseNotes(
displayVersionString: "v2024.01.15-abc1234",
currentCommit: "def5678"
)
#expect(notes != nil)
if case .compareTip(let url) = notes {
#expect(url.absoluteString == "https://github.com/ghostty-org/ghostty/compare/def5678...abc1234")

View File

@@ -5,25 +5,25 @@ import Sparkle
struct UpdateStateTests {
// MARK: - Equatable Tests
@Test func testIdleEquality() {
let state1: UpdateState = .idle
let state2: UpdateState = .idle
#expect(state1 == state2)
}
@Test func testCheckingEquality() {
let state1: UpdateState = .checking(.init(cancel: {}))
let state2: UpdateState = .checking(.init(cancel: {}))
#expect(state1 == state2)
}
@Test func testNotFoundEquality() {
let state1: UpdateState = .notFound(.init(acknowledgement: {}))
let state2: UpdateState = .notFound(.init(acknowledgement: {}))
#expect(state1 == state2)
}
@Test func testInstallingEquality() {
let state1: UpdateState = .installing(.init(isAutoUpdate: false, retryTerminatingApplication: {}, dismiss: {}))
let state2: UpdateState = .installing(.init(isAutoUpdate: false, retryTerminatingApplication: {}, dismiss: {}))
@@ -31,7 +31,7 @@ struct UpdateStateTests {
let state3: UpdateState = .installing(.init(isAutoUpdate: true, retryTerminatingApplication: {}, dismiss: {}))
#expect(state3 != state2)
}
@Test func testPermissionRequestEquality() {
let request1 = SPUUpdatePermissionRequest(systemProfile: [])
let request2 = SPUUpdatePermissionRequest(systemProfile: [])
@@ -39,43 +39,43 @@ struct UpdateStateTests {
let state2: UpdateState = .permissionRequest(.init(request: request2, reply: { _ in }))
#expect(state1 == state2)
}
@Test func testDownloadingEqualityWithSameProgress() {
let state1: UpdateState = .downloading(.init(cancel: {}, expectedLength: 1000, progress: 500))
let state2: UpdateState = .downloading(.init(cancel: {}, expectedLength: 1000, progress: 500))
#expect(state1 == state2)
}
@Test func testDownloadingInequalityWithDifferentProgress() {
let state1: UpdateState = .downloading(.init(cancel: {}, expectedLength: 1000, progress: 500))
let state2: UpdateState = .downloading(.init(cancel: {}, expectedLength: 1000, progress: 600))
#expect(state1 != state2)
}
@Test func testDownloadingInequalityWithDifferentExpectedLength() {
let state1: UpdateState = .downloading(.init(cancel: {}, expectedLength: 1000, progress: 500))
let state2: UpdateState = .downloading(.init(cancel: {}, expectedLength: 2000, progress: 500))
#expect(state1 != state2)
}
@Test func testDownloadingEqualityWithNilExpectedLength() {
let state1: UpdateState = .downloading(.init(cancel: {}, expectedLength: nil, progress: 500))
let state2: UpdateState = .downloading(.init(cancel: {}, expectedLength: nil, progress: 500))
#expect(state1 == state2)
}
@Test func testExtractingEqualityWithSameProgress() {
let state1: UpdateState = .extracting(.init(progress: 0.5))
let state2: UpdateState = .extracting(.init(progress: 0.5))
#expect(state1 == state2)
}
@Test func testExtractingInequalityWithDifferentProgress() {
let state1: UpdateState = .extracting(.init(progress: 0.5))
let state2: UpdateState = .extracting(.init(progress: 0.6))
#expect(state1 != state2)
}
@Test func testErrorEqualityWithSameDescription() {
let error1 = NSError(domain: "Test", code: 1, userInfo: [NSLocalizedDescriptionKey: "Error message"])
let error2 = NSError(domain: "Test", code: 2, userInfo: [NSLocalizedDescriptionKey: "Error message"])
@@ -83,7 +83,7 @@ struct UpdateStateTests {
let state2: UpdateState = .error(.init(error: error2, retry: {}, dismiss: {}))
#expect(state1 == state2)
}
@Test func testErrorInequalityWithDifferentDescription() {
let error1 = NSError(domain: "Test", code: 1, userInfo: [NSLocalizedDescriptionKey: "Error 1"])
let error2 = NSError(domain: "Test", code: 1, userInfo: [NSLocalizedDescriptionKey: "Error 2"])
@@ -91,20 +91,20 @@ struct UpdateStateTests {
let state2: UpdateState = .error(.init(error: error2, retry: {}, dismiss: {}))
#expect(state1 != state2)
}
@Test func testDifferentStatesAreNotEqual() {
let state1: UpdateState = .idle
let state2: UpdateState = .checking(.init(cancel: {}))
#expect(state1 != state2)
}
// MARK: - isIdle Tests
@Test func testIsIdleTrue() {
let state: UpdateState = .idle
#expect(state.isIdle == true)
}
@Test func testIsIdleFalse() {
let state: UpdateState = .checking(.init(cancel: {}))
#expect(state.isIdle == false)

View File

@@ -6,50 +6,50 @@ import Sparkle
struct UpdateViewModelTests {
// MARK: - Text Formatting Tests
@Test func testIdleText() {
let viewModel = UpdateViewModel()
viewModel.state = .idle
#expect(viewModel.text == "")
}
@Test func testPermissionRequestText() {
let viewModel = UpdateViewModel()
let request = SPUUpdatePermissionRequest(systemProfile: [])
viewModel.state = .permissionRequest(.init(request: request, reply: { _ in }))
#expect(viewModel.text == "Enable Automatic Updates?")
}
@Test func testCheckingText() {
let viewModel = UpdateViewModel()
viewModel.state = .checking(.init(cancel: {}))
#expect(viewModel.text == "Checking for Updates…")
}
@Test func testDownloadingTextWithKnownLength() {
let viewModel = UpdateViewModel()
viewModel.state = .downloading(.init(cancel: {}, expectedLength: 1000, progress: 500))
#expect(viewModel.text == "Downloading: 50%")
}
@Test func testDownloadingTextWithUnknownLength() {
let viewModel = UpdateViewModel()
viewModel.state = .downloading(.init(cancel: {}, expectedLength: nil, progress: 500))
#expect(viewModel.text == "Downloading…")
}
@Test func testDownloadingTextWithZeroExpectedLength() {
let viewModel = UpdateViewModel()
viewModel.state = .downloading(.init(cancel: {}, expectedLength: 0, progress: 500))
#expect(viewModel.text == "Downloading…")
}
@Test func testExtractingText() {
let viewModel = UpdateViewModel()
viewModel.state = .extracting(.init(progress: 0.75))
#expect(viewModel.text == "Preparing: 75%")
}
@Test func testInstallingText() {
let viewModel = UpdateViewModel()
viewModel.state = .installing(.init(isAutoUpdate: false, retryTerminatingApplication: {}, dismiss: {}))
@@ -57,34 +57,34 @@ struct UpdateViewModelTests {
viewModel.state = .installing(.init(isAutoUpdate: true, retryTerminatingApplication: {}, dismiss: {}))
#expect(viewModel.text == "Restart to Complete Update")
}
@Test func testNotFoundText() {
let viewModel = UpdateViewModel()
viewModel.state = .notFound(.init(acknowledgement: {}))
#expect(viewModel.text == "No Updates Available")
}
@Test func testErrorText() {
let viewModel = UpdateViewModel()
let error = NSError(domain: "Test", code: 1, userInfo: [NSLocalizedDescriptionKey: "Network error"])
viewModel.state = .error(.init(error: error, retry: {}, dismiss: {}))
#expect(viewModel.text == "Network error")
}
// MARK: - Max Width Text Tests
@Test func testMaxWidthTextForDownloading() {
let viewModel = UpdateViewModel()
viewModel.state = .downloading(.init(cancel: {}, expectedLength: 1000, progress: 50))
#expect(viewModel.maxWidthText == "Downloading: 100%")
}
@Test func testMaxWidthTextForExtracting() {
let viewModel = UpdateViewModel()
viewModel.state = .extracting(.init(progress: 0.5))
#expect(viewModel.maxWidthText == "Preparing: 100%")
}
@Test func testMaxWidthTextForNonProgressState() {
let viewModel = UpdateViewModel()
viewModel.state = .checking(.init(cancel: {}))