Skip to main content

Blink: allow viewing and editing captions

ID
ee6f05d
date
2026-07-03 21:07:45+00:00
author
Alex Chan <alex@alexwlchan.net>
parent
584f492
message
Blink: allow viewing and editing captions

This adds a new CaptionOverlay view on the currently focused image, which
shows the current caption. Tapping space switches to a text field where
I can edit or enter a new caption, which is then saved to the Photos
database.
changed files
7 files, 305 additions, 31 deletions

Changed files

Blink.xcodeproj/project.pbxproj (35857) → Blink.xcodeproj/project.pbxproj (36689)

diff --git a/Blink.xcodeproj/project.pbxproj b/Blink.xcodeproj/project.pbxproj
index b306942..7c184d8 100644
--- a/Blink.xcodeproj/project.pbxproj
+++ b/Blink.xcodeproj/project.pbxproj
@@ -10,6 +10,7 @@
 		940331732A336B5100200C5D /* DeferredRendering.swift in Sources */ = {isa = PBXBuildFile; fileRef = 940331722A336B5100200C5D /* DeferredRendering.swift */; };
 		9404B57D2A3EEBBA00068FA8 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9404B57C2A3EEBBA00068FA8 /* SettingsView.swift */; };
 		941E18FA2A35362600A2EA98 /* Info.swift in Sources */ = {isa = PBXBuildFile; fileRef = 941E18F92A35362600A2EA98 /* Info.swift */; };
+		944EB0DB2FF1A0D5002C81C3 /* CaptionOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 944EB0DA2FF1A0D1002C81C3 /* CaptionOverlay.swift */; };
 		944EB0DF2FF1AF01002C81C3 /* ScriptHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 944EB0DE2FF1AEFE002C81C3 /* ScriptHelper.swift */; };
 		945F17B02A33D167004FC479 /* ThumbnailImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 945F17AF2A33D167004FC479 /* ThumbnailImage.swift */; };
 		945F17B22A33D69B004FC479 /* FavoriteOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 945F17B12A33D69B004FC479 /* FavoriteOverlay.swift */; };
@@ -62,6 +63,7 @@
 		940331722A336B5100200C5D /* DeferredRendering.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeferredRendering.swift; sourceTree = "<group>"; };
 		9404B57C2A3EEBBA00068FA8 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
 		941E18F92A35362600A2EA98 /* Info.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Info.swift; sourceTree = "<group>"; };
+		944EB0DA2FF1A0D1002C81C3 /* CaptionOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaptionOverlay.swift; sourceTree = "<group>"; };
 		944EB0DE2FF1AEFE002C81C3 /* ScriptHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScriptHelper.swift; sourceTree = "<group>"; };
 		945F17AF2A33D167004FC479 /* ThumbnailImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThumbnailImage.swift; sourceTree = "<group>"; };
 		945F17B12A33D69B004FC479 /* FavoriteOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteOverlay.swift; sourceTree = "<group>"; };
@@ -156,6 +158,7 @@
 		94A0835F2A33E7E900238964 /* FocusedImage */ = {
 			isa = PBXGroup;
 			children = (
+				944EB0DA2FF1A0D1002C81C3 /* CaptionOverlay.swift */,
 				94A0835D2A33E49E00238964 /* FocusedImage.swift */,
 				94A083602A33E98000238964 /* AlbumInfoOverlay.swift */,
 				94A083622A33F30300238964 /* LoadingIndicatorOverlay.swift */,
@@ -335,7 +338,7 @@
 			attributes = {
 				BuildIndependentTargetsInParallel = 1;
 				LastSwiftUpdateCheck = 1430;
-				LastUpgradeCheck = 1430;
+				LastUpgradeCheck = 2620;
 				TargetAttributes = {
 					94D750EB2A31A796005859E7 = {
 						CreatedOnToolsVersion = 14.3.1;
@@ -437,6 +440,7 @@
 				94A083612A33E98000238964 /* AlbumInfoOverlay.swift in Sources */,
 				94C5FFF22A33ADD4004ADDF5 /* PHFetchResultCollection.swift in Sources */,
 				944EB0DF2FF1AF01002C81C3 /* ScriptHelper.swift in Sources */,
+				944EB0DB2FF1A0D5002C81C3 /* CaptionOverlay.swift in Sources */,
 				94A083662A33F50900238964 /* Debug.swift in Sources */,
 				94A083682A33F6E900238964 /* ThumbnailList.swift in Sources */,
 				94D750F02A31A796005859E7 /* BlinkApp.swift in Sources */,
@@ -520,6 +524,7 @@
 				CLANG_WARN_UNREACHABLE_CODE = YES;
 				CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
 				COPY_PHASE_STRIP = NO;
+				DEAD_CODE_STRIPPING = YES;
 				DEBUG_INFORMATION_FORMAT = dwarf;
 				ENABLE_STRICT_OBJC_MSGSEND = YES;
 				ENABLE_TESTABILITY = YES;
@@ -542,6 +547,7 @@
 				MTL_FAST_MATH = YES;
 				ONLY_ACTIVE_ARCH = YES;
 				SDKROOT = macosx;
+				STRING_CATALOG_GENERATE_SYMBOLS = YES;
 				SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
 				SWIFT_OPTIMIZATION_LEVEL = "-Onone";
 			};
@@ -580,6 +586,7 @@
 				CLANG_WARN_UNREACHABLE_CODE = YES;
 				CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
 				COPY_PHASE_STRIP = NO;
+				DEAD_CODE_STRIPPING = YES;
 				DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
 				ENABLE_NS_ASSERTIONS = NO;
 				ENABLE_STRICT_OBJC_MSGSEND = YES;
@@ -595,6 +602,7 @@
 				MTL_ENABLE_DEBUG_INFO = NO;
 				MTL_FAST_MATH = YES;
 				SDKROOT = macosx;
+				STRING_CATALOG_GENERATE_SYMBOLS = YES;
 				SWIFT_COMPILATION_MODE = wholemodule;
 				SWIFT_OPTIMIZATION_LEVEL = "-O";
 			};
@@ -605,13 +613,16 @@
 			buildSettings = {
 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+				AUTOMATION_APPLE_EVENTS = YES;
 				CODE_SIGN_ENTITLEMENTS = Blink/Blink.entitlements;
 				CODE_SIGN_STYLE = Automatic;
 				COMBINE_HIDPI_IMAGES = YES;
-				CURRENT_PROJECT_VERSION = 136;
+				CURRENT_PROJECT_VERSION = 199;
+				DEAD_CODE_STRIPPING = YES;
 				DEVELOPMENT_ASSET_PATHS = "\"Blink/Preview Content\"";
 				ENABLE_HARDENED_RUNTIME = YES;
 				ENABLE_PREVIEWS = YES;
+				ENABLE_RESOURCE_ACCESS_PHOTO_LIBRARY = YES;
 				GENERATE_INFOPLIST_FILE = YES;
 				INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.photography";
 				INFOPLIST_KEY_NSHumanReadableCopyright = "Made by Alex Chan <alex@alexwlchan.net>";
@@ -620,6 +631,7 @@
 					"$(inherited)",
 					"@executable_path/../Frameworks",
 				);
+				MACOSX_DEPLOYMENT_TARGET = 15.6;
 				MARKETING_VERSION = 1.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.alexwlchan.Blink;
 				PRODUCT_NAME = "$(TARGET_NAME)";
@@ -634,13 +646,16 @@
 			buildSettings = {
 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+				AUTOMATION_APPLE_EVENTS = YES;
 				CODE_SIGN_ENTITLEMENTS = Blink/Blink.entitlements;
 				CODE_SIGN_STYLE = Automatic;
 				COMBINE_HIDPI_IMAGES = YES;
-				CURRENT_PROJECT_VERSION = 136;
+				CURRENT_PROJECT_VERSION = 199;
+				DEAD_CODE_STRIPPING = YES;
 				DEVELOPMENT_ASSET_PATHS = "\"Blink/Preview Content\"";
 				ENABLE_HARDENED_RUNTIME = YES;
 				ENABLE_PREVIEWS = YES;
+				ENABLE_RESOURCE_ACCESS_PHOTO_LIBRARY = YES;
 				GENERATE_INFOPLIST_FILE = YES;
 				INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.photography";
 				INFOPLIST_KEY_NSHumanReadableCopyright = "Made by Alex Chan <alex@alexwlchan.net>";
@@ -649,6 +664,7 @@
 					"$(inherited)",
 					"@executable_path/../Frameworks",
 				);
+				MACOSX_DEPLOYMENT_TARGET = 15.6;
 				MARKETING_VERSION = 1.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.alexwlchan.Blink;
 				PRODUCT_NAME = "$(TARGET_NAME)";
@@ -661,10 +677,10 @@
 		94D751152A31A798005859E7 /* Debug */ = {
 			isa = XCBuildConfiguration;
 			buildSettings = {
-				ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
 				BUNDLE_LOADER = "$(TEST_HOST)";
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 136;
+				CURRENT_PROJECT_VERSION = 199;
+				DEAD_CODE_STRIPPING = YES;
 				GENERATE_INFOPLIST_FILE = YES;
 				MACOSX_DEPLOYMENT_TARGET = 13.3;
 				MARKETING_VERSION = 1.0;
@@ -679,10 +695,10 @@
 		94D751162A31A798005859E7 /* Release */ = {
 			isa = XCBuildConfiguration;
 			buildSettings = {
-				ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
 				BUNDLE_LOADER = "$(TEST_HOST)";
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 136;
+				CURRENT_PROJECT_VERSION = 199;
+				DEAD_CODE_STRIPPING = YES;
 				GENERATE_INFOPLIST_FILE = YES;
 				MACOSX_DEPLOYMENT_TARGET = 13.3;
 				MARKETING_VERSION = 1.0;
@@ -697,9 +713,9 @@
 		94D751182A31A798005859E7 /* Debug */ = {
 			isa = XCBuildConfiguration;
 			buildSettings = {
-				ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 136;
+				CURRENT_PROJECT_VERSION = 199;
+				DEAD_CODE_STRIPPING = YES;
 				GENERATE_INFOPLIST_FILE = YES;
 				MARKETING_VERSION = 1.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.alexwlchan.BlinkReviewerUITests;
@@ -713,9 +729,9 @@
 		94D751192A31A798005859E7 /* Release */ = {
 			isa = XCBuildConfiguration;
 			buildSettings = {
-				ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 136;
+				CURRENT_PROJECT_VERSION = 199;
+				DEAD_CODE_STRIPPING = YES;
 				GENERATE_INFOPLIST_FILE = YES;
 				MARKETING_VERSION = 1.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.alexwlchan.BlinkReviewerUITests;

Blink.xcodeproj/xcshareddata/xcschemes/Blink.xcscheme (2824) → Blink.xcodeproj/xcshareddata/xcschemes/Blink.xcscheme (2824)

diff --git a/Blink.xcodeproj/xcshareddata/xcschemes/Blink.xcscheme b/Blink.xcodeproj/xcshareddata/xcschemes/Blink.xcscheme
index 23503ec..bba7fb3 100644
--- a/Blink.xcodeproj/xcshareddata/xcschemes/Blink.xcscheme
+++ b/Blink.xcodeproj/xcshareddata/xcschemes/Blink.xcscheme
@@ -1,6 +1,6 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <Scheme
-   LastUpgradeVersion = "1430"
+   LastUpgradeVersion = "2620"
    version = "1.7">
    <BuildAction
       parallelizeBuildables = "YES"

Blink/Helpers/ScriptHelper.swift (976) → Blink/Helpers/ScriptHelper.swift (1906)

diff --git a/Blink/Helpers/ScriptHelper.swift b/Blink/Helpers/ScriptHelper.swift
index 3e45e4a..37afce2 100644
--- a/Blink/Helpers/ScriptHelper.swift
+++ b/Blink/Helpers/ScriptHelper.swift
@@ -1,29 +1,48 @@
 import Foundation
 
 /// ScriptHelper is a helper for invoking AppleScript.
-///
-/// This allows
 enum ScriptHelper {
     
     /// Execute an AppleScript via osascript with the provided arguments.
     /// Returns the output or throws an error.
     @discardableResult
-    static func runAppleScript(_ script: String, arguments: [String]) throws -> String {
-        let task = Process()
-        task.launchPath = "/usr/bin/osascript"
-        
-        var args = ["-e", script]
-        args.append(contentsOf: arguments)
-        task.arguments = args
-        
-        let pipe = Pipe()
-        task.standardOutput = pipe
-        task.standardError = Pipe()
-        
-        try task.run()
-        task.waitUntilExit()
-        
-        let data = pipe.fileHandleForReading.readDataToEndOfFile()
+    static func runAppleScript(_ script: String, arguments: [String]) async throws -> String {
+        try await Task.detached(priority: .userInitiated) {
+            let task = Process()
+            task.launchPath = "/usr/bin/osascript"
+            
+            var args = ["-e", script]
+            args.append(contentsOf: arguments)
+            task.arguments = args
+            
+            let stdoutPipe = Pipe()
+            let stderrPipe = Pipe()
+            task.standardOutput = stdoutPipe
+            task.standardError = stderrPipe
+            
+            try task.run()
+            task.waitUntilExit()
+            
+            let stdout = self.readAsString(stdoutPipe)
+            let stderr = self.readAsString(stderrPipe)
+
+            print("AppleScript output: status=\(task.terminationStatus), stdout=\(stdout), stderr=\(stderr)")
+            
+            if task.terminationStatus != 0 {
+                let errorMessage = stderr.isEmpty ? "Unknown AppleScript execution error." : "AppleScript execution error: \(stderr)"
+                throw NSError(
+                    domain: "ScriptHelperDomain",
+                    code: Int(task.terminationStatus),
+                    userInfo: [NSLocalizedDescriptionKey: errorMessage]
+                )
+            }
+            
+            return stdout
+        }.value
+    }
+    
+    private static func readAsString(_ p: Pipe) -> String {
+        let data = p.fileHandleForReading.readDataToEndOfFile()
         guard let output = String(data: data, encoding: .utf8) else {
             return ""
         }

Blink/Photos/AssetHelpers.swift (2485) → Blink/Photos/AssetHelpers.swift (3953)

diff --git a/Blink/Photos/AssetHelpers.swift b/Blink/Photos/AssetHelpers.swift
index 6c7eb49..e8bedb4 100644
--- a/Blink/Photos/AssetHelpers.swift
+++ b/Blink/Photos/AssetHelpers.swift
@@ -74,4 +74,50 @@ extension PHAsset {
         changeAlbum.addAssets(assets)
       }
     }
+    
+    /// Fetch the caption from the Photos app.
+    func getCaption() async -> String {
+        let script = """
+            on run argv
+              set targetId to item 1 of argv
+
+              tell application "Photos"
+                set targetMedia to media item id targetId
+                set desc to description of targetMedia
+                
+                if desc is missing value then
+                  return ""
+                else
+                  return desc
+                end if
+              end tell
+            end run
+        """
+        do {
+            return try await ScriptHelper.runAppleScript(script, arguments: [self.localIdentifier])
+        } catch {
+            print("Failed to run AppleScript getCaption: \(error)")
+            return ""
+        }
+    }
+    
+    /// Update the caption in the Photos app.
+    func setCaption(_ caption: String) async {
+        let script = """
+            on run argv
+              set targetId to item 1 of argv
+              set newCaption to item 2 of argv
+
+              tell application "Photos"
+                set targetMedia to media item id targetId
+                set description of targetMedia to newCaption
+              end tell
+            end run
+        """
+        do {
+            try await ScriptHelper.runAppleScript(script, arguments: [self.localIdentifier, caption])
+        } catch {
+            print("Failed to run AppleScript setCaption: \(error)")
+        }
+    }
 }

Blink/Views/FocusedImage/CaptionOverlay.swift (0) → Blink/Views/FocusedImage/CaptionOverlay.swift (5829)

diff --git a/Blink/Views/FocusedImage/CaptionOverlay.swift b/Blink/Views/FocusedImage/CaptionOverlay.swift
index e69de29..4f388b5 100644
--- a/Blink/Views/FocusedImage/CaptionOverlay.swift
+++ b/Blink/Views/FocusedImage/CaptionOverlay.swift
@@ -0,0 +1,167 @@
+import SwiftUI
+import Photos
+
+/// Render the current caption on top of the image.
+struct CaptionOverlay: ViewModifier {
+    var asset: PHAsset
+    
+    @State private var captionText: String = ""
+    @State private var isEditing: Bool = false
+    @State private var isLoading: Bool = false
+    
+    // Holds a task that delays the "caption loading" spinner so we don't get
+    // short-lived flashes of loading content.
+    @State private var spinnerDelayTask: Task<Void, Never>? = nil
+    
+    init(asset: PHAsset) {
+        self.asset = asset
+    }
+    
+    func body(content: Content) -> some View {
+        content.overlay(alignment: Alignment(horizontal: .center, vertical: .bottom)) {
+            HStack {
+                if isLoading && captionText.isEmpty {
+                    ProgressView().controlSize(.small)
+                } else if isEditing {
+                    CaptionEditorView(
+                        text: $captionText,
+                        onSave: saveAndExit,
+                        onCancel: revertAndExit
+                    )
+                } else {
+                    Text(self.captionText.isEmpty ? "Add a caption..." : captionText)
+                        .font(.headline)
+                        .italic(captionText.isEmpty)
+                        
+                        .onTapGesture {
+                            enterEditingMode()
+                        }
+                        .padding()
+                }
+            }
+            .foregroundColor(captionText.isEmpty ? .gray : .white)
+            .frame(maxWidth: .infinity, minHeight: 44, alignment: .center)
+            .background(Color.black.opacity(0.6))
+            .cornerRadius(8)
+            .padding()
+            // If the user presses the space key and we're not already editing
+            // or loading the caption, switch into the editing view.
+            .onReceive(NotificationCenter.default.publisher(for: .triggerCaptionEditing)) { _ in
+                if !isEditing && !isLoading {
+                    enterEditingMode()
+                }
+            }
+            .task {
+                await fetchCaption()
+            }
+        }
+    }
+    
+    /// fetchCaption fetches the caption text asynchronously, so it doesn't
+    /// block the main thread.
+    ///
+    /// We show a progress spinner while loading the caption, but only if it
+    /// takes more than a quarter of a second to load. In practice, captions
+    /// load near-instantly and so you normally don't see the spinnner.
+    private func fetchCaption() async {
+        spinnerDelayTask?.cancel()
+        
+        spinnerDelayTask = Task {
+            try? await Task.sleep(for: .seconds(0.25))
+            if !Task.isCancelled {
+                isLoading = true
+            }
+        }
+        
+        captionText = await asset.getCaption()
+        
+        spinnerDelayTask?.cancel()
+        isLoading = false
+    }
+    
+    /// enterEditingMode enables the text field, so the user can enter a caption.
+    private func enterEditingMode() {
+        isEditing = true
+    }
+    
+    /// saveAndExit saves the new caption to the Photos app and closes the text field,
+    /// displaying the new caption.
+    private func saveAndExit() {
+        let textToSave = captionText
+        isEditing = false
+                
+        Task {
+            await asset.setCaption(textToSave)
+        }
+    }
+    
+    /// revertAndExit closes the text field and cancels the caption edit, reloading the
+    /// text from Photos app.
+    private func revertAndExit() {
+        isEditing = false
+
+        Task {
+            captionText = await asset.getCaption()
+        }
+    }
+}
+
+/// CaptionEditorView provides a text field for editing the caption.
+///
+/// There are "Cancel" and "Save" buttons on the right-hand side, which can
+/// be triggered with keyboard shortcuts "Esc" and "⌘+Enter".
+struct CaptionEditorView: View {
+    @Binding var text: String
+    var onSave: () -> Void
+    var onCancel: () -> Void
+    
+    @FocusState private var isFocused: Bool
+    
+    var body: some View {
+        HStack {
+            // Left-hand side: two invisible buttons offset the size of the
+            // buttons on the right-hand side, so text is centred in the window.
+            Button("Cancel") {} .hidden().allowsHitTesting(false).padding(.leading)
+            Button("Save") {}.hidden().allowsHitTesting(false)
+            
+            // Centre: the editable text field.
+            TextField("Enter caption...", text: $text, axis: .vertical)
+                .textFieldStyle(.plain)
+                .foregroundColor(.white)
+                .multilineTextAlignment(.center)
+                .padding()
+                .focused($isFocused)
+                .onSubmit {
+                    onSave()
+                }
+            
+            // Right-hand side: buttons to cancel or save the current caption.
+            Button("Cancel") { onCancel() }
+                .buttonStyle(.bordered)
+                .keyboardShortcut(.escape, modifiers: [])
+            
+            Button("Save") { onSave() }
+                .buttonStyle(.borderedProminent)
+                .keyboardShortcut(.return, modifiers: .command)
+                .padding(.trailing)
+        }
+        .onAppear {
+            // Automatically focus the text field as soon as this
+            // view is displayed.
+            isFocused = true
+        }
+    }
+}
+
+extension Notification.Name {
+    // triggerCaptionEditing is a notification sent by the keyDown handler
+    // in the top-level view when it receives a space, telling the overlay
+    // it should switch into editing mode.
+    static let triggerCaptionEditing = Notification.Name("triggerCaptionEditing")
+}
+
+extension View {
+    func caption(for asset: PHAsset) -> some View {
+        modifier(CaptionOverlay(asset: asset))
+    }
+}

Blink/Views/FocusedImage/FocusedImage.swift (2076) → Blink/Views/FocusedImage/FocusedImage.swift (2109)

diff --git a/Blink/Views/FocusedImage/FocusedImage.swift b/Blink/Views/FocusedImage/FocusedImage.swift
index 1e250f2..db1ee7d 100644
--- a/Blink/Views/FocusedImage/FocusedImage.swift
+++ b/Blink/Views/FocusedImage/FocusedImage.swift
@@ -42,6 +42,7 @@ private struct FocusedImageContent: View {
             .draggable(Image(nsImage: assetImage.image))
             .aspectRatio(contentMode: .fit)
             .albumInfo(for: asset)
+            .caption(for: asset)
             .loadingIndicator(isLoading: assetImage.isDegraded)
             .contextMenu {
                 Button {

Blink/Views/PhotoReviewer.swift (14247) → Blink/Views/PhotoReviewer.swift (15313)

diff --git a/Blink/Views/PhotoReviewer.swift b/Blink/Views/PhotoReviewer.swift
index 77bf5bc..b6dd56b 100644
--- a/Blink/Views/PhotoReviewer.swift
+++ b/Blink/Views/PhotoReviewer.swift
@@ -214,6 +214,15 @@ struct PhotoReviewer: View {
     /// issues, this results in an annoying "funk" sound playing on
     /// every event, because the OS thinks the event is unhandled.
     private func handleKeyDown(_ event: NSEvent) -> NSEvent? {
+        
+        // Check if a text field is currently focused (in particular, the caption
+        // overlay) -- if so, let the user type directly into that text field
+        // rather than taking actions on the photo.
+        if let firstResponder = NSApp.keyWindow?.firstResponder,
+           firstResponder is NSTextView || firstResponder is NSTextField {
+            return event
+        }
+        
         let logger = Logger()
         
         switch event {
@@ -328,7 +337,23 @@ struct PhotoReviewer: View {
                     end tell
                 end run
                 """
-                try! ScriptHelper.runAppleScript(script, arguments: [focusedAsset.localIdentifier])
+            
+                let targetId = focusedAsset.localIdentifier
+            
+                Task {
+                    do {
+                        try await ScriptHelper.runAppleScript(script, arguments: [targetId])
+                    } catch {
+                        logger.error("Failed to open asset in Photos: \(error.localizedDescription)")
+                    }
+                }
+            
+                return nil
+            
+            // If the user types a space, send a notification to the CaptionOverlay view
+            // telling it to open the editing view (if it's not already open).
+            case let e where e.characters == " ":
+                NotificationCenter.default.post(name: .triggerCaptionEditing, object: nil)
                 return nil
 
             default: