mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-04-06 07:38:21 +00:00
macos: swiftlint 'trailing_whitespace' rule
This commit is contained in:
@@ -24,7 +24,6 @@ disabled_rules:
|
||||
- orphaned_doc_comment
|
||||
- shorthand_operator
|
||||
- switch_case_alignment
|
||||
- trailing_whitespace
|
||||
- unneeded_synthesized_initializer
|
||||
- unused_closure_parameter
|
||||
- unused_enumerated
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -22,7 +22,7 @@ struct CloseTerminalIntent: AppIntent {
|
||||
guard await requestIntentPermission() else {
|
||||
throw GhosttyIntentError.permissionDenied
|
||||
}
|
||||
|
||||
|
||||
guard let surfaceView = terminal.surfaceView else {
|
||||
throw GhosttyIntentError.surfaceNotFound
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ struct CommandPaletteIntent: AppIntent {
|
||||
guard await requestIntentPermission() else {
|
||||
throw GhosttyIntentError.permissionDenied
|
||||
}
|
||||
|
||||
|
||||
guard let surface = terminal.surfaceModel else {
|
||||
throw GhosttyIntentError.surfaceNotFound
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ extension CommandEntity.ID: EntityIdentifierConvertible {
|
||||
static func entityIdentifier(for entityIdentifierString: String) -> CommandEntity.ID? {
|
||||
.init(rawValue: entityIdentifierString)
|
||||
}
|
||||
|
||||
|
||||
var entityIdentifierString: String {
|
||||
rawValue
|
||||
}
|
||||
|
||||
@@ -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() ?? []
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -26,7 +26,7 @@ struct KeybindIntent: AppIntent {
|
||||
guard await requestIntentPermission() else {
|
||||
throw GhosttyIntentError.permissionDenied
|
||||
}
|
||||
|
||||
|
||||
guard let surface = terminal.surfaceModel else {
|
||||
throw GhosttyIntentError.surfaceNotFound
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -12,10 +12,10 @@ enum QuickTerminalScreen {
|
||||
|
||||
case "mouse":
|
||||
self = .mouse
|
||||
|
||||
|
||||
case "macos-menu-bar":
|
||||
self = .menuBar
|
||||
|
||||
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -137,7 +137,7 @@ class TerminalWindowRestoration: NSObject, NSWindowRestoration {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if let view = foundView {
|
||||
c.focusedSurface = view
|
||||
restoreFocus(to: view, inWindow: window)
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: {}))
|
||||
|
||||
Reference in New Issue
Block a user