From 35cb42fbc8564c006cfc43de5f9a968fe30aedc8 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Sun, 29 Mar 2026 18:16:05 -0700 Subject: [PATCH] Add copy-on-select preference (#2282) --- Resources/Localizable.xcstrings | 51 ++++++++++++++++++++++++++++ Sources/GhosttyTerminalView.swift | 54 ++++++++++++++++++++++-------- Sources/cmuxApp.swift | 49 +++++++++++++++++++++++++++ cmuxTests/GhosttyConfigTests.swift | 38 +++++++++++++++++++++ 4 files changed, 178 insertions(+), 14 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 2ab4d3bc..e07ac5ad 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -47815,6 +47815,57 @@ } } }, + "settings.app.copyOnSelect": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Copy on Select" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "選択時にコピー" + } + } + } + }, + "settings.app.copyOnSelect.subtitleOff": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Selecting terminal text does not copy it to the system clipboard." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナルテキストを選択してもシステムのクリップボードにはコピーしません。" + } + } + } + }, + "settings.app.copyOnSelect.subtitleOn": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Automatically copy selected terminal text to the system clipboard." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "選択したターミナルテキストをシステムのクリップボードに自動でコピーします。" + } + } + } + }, "settings.app.paneFirstClickFocus": { "extractionState": "manual", "localizations": { diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 978ab9df..b4f6e41a 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -1349,11 +1349,45 @@ class GhosttyApp { #endif } + private func loadInlineGhosttyConfig( + _ contents: String, + into config: ghostty_config_t, + prefix: String, + logLabel: String + ) { + let trimmed = contents.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + + let tmpURL = FileManager.default.temporaryDirectory + .appendingPathComponent("\(prefix)-\(UUID().uuidString).conf") + do { + try trimmed.write(to: tmpURL, atomically: true, encoding: .utf8) + defer { try? FileManager.default.removeItem(at: tmpURL) } + tmpURL.path.withCString { path in + ghostty_config_load_file(config, path) + } + } catch { + #if DEBUG + dlog("ghostty.config.inlineLoad.failed label=\(logLabel) error=\(error.localizedDescription)") + #endif + } + } + + private func loadCopyOnSelectOverride(_ config: ghostty_config_t) { + loadInlineGhosttyConfig( + TerminalCopyOnSelectSettings.overrideConfigLine(), + into: config, + prefix: "cmux-copy-on-select", + logLabel: "copy-on-select override" + ) + } + private func loadDefaultConfigFilesWithLegacyFallback(_ config: ghostty_config_t) { ghostty_config_load_default_files(config) loadLegacyGhosttyConfigIfNeeded(config) ghostty_config_load_recursive_files(config) loadCmuxAppSupportGhosttyConfigIfNeeded(config) + loadCopyOnSelectOverride(config) loadCJKFontFallbackIfNeeded(config) ghostty_config_finalize(config) } @@ -1376,20 +1410,12 @@ class GhosttyApp { let lines = mappings.map { range, font in "font-codepoint-map = \(range)=\(font)" }.joined(separator: "\n") - - let tmpURL = FileManager.default.temporaryDirectory - .appendingPathComponent("cmux-cjk-font-fallback-\(UUID().uuidString).conf") - do { - try lines.write(to: tmpURL, atomically: true, encoding: .utf8) - defer { try? FileManager.default.removeItem(at: tmpURL) } - tmpURL.path.withCString { path in - ghostty_config_load_file(config, path) - } - } catch { - #if DEBUG - Self.initLog("failed to write CJK font fallback config: \(error)") - #endif - } + loadInlineGhosttyConfig( + lines, + into: config, + prefix: "cmux-cjk-font-fallback", + logLabel: "CJK font fallback" + ) } /// Unicode ranges shared by all CJK languages (Han ideographs, symbols, fullwidth forms). diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index cae679df..c16f88b5 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -3785,6 +3785,22 @@ enum CommandPaletteRenameSelectionSettings { } } +enum TerminalCopyOnSelectSettings { + static let enabledKey = "terminalCopyOnSelectEnabled" + static let defaultEnabled = false + + static func isEnabled(defaults: UserDefaults = .standard) -> Bool { + if defaults.object(forKey: enabledKey) == nil { + return defaultEnabled + } + return defaults.bool(forKey: enabledKey) + } + + static func overrideConfigLine(defaults: UserDefaults = .standard) -> String { + isEnabled(defaults: defaults) ? "copy-on-select = clipboard" : "copy-on-select = false" + } +} + enum CommandPaletteSwitcherSearchSettings { static let searchAllSurfacesKey = "commandPalette.switcherSearchAllSurfaces" static let defaultSearchAllSurfaces = false @@ -3869,6 +3885,8 @@ struct SettingsView: View { @AppStorage(QuitWarningSettings.warnBeforeQuitKey) private var warnBeforeQuitShortcut = QuitWarningSettings.defaultWarnBeforeQuit @AppStorage(CommandPaletteRenameSelectionSettings.selectAllOnFocusKey) private var commandPaletteRenameSelectAllOnFocus = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus + @AppStorage(TerminalCopyOnSelectSettings.enabledKey) + private var terminalCopyOnSelectEnabled = TerminalCopyOnSelectSettings.defaultEnabled @AppStorage(CommandPaletteSwitcherSearchSettings.searchAllSurfacesKey) private var commandPaletteSearchAllSurfaces = CommandPaletteSwitcherSearchSettings.defaultSearchAllSurfaces @AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey) @@ -3990,6 +4008,19 @@ struct SettingsView: View { ) } + private var terminalCopyOnSelectSubtitle: String { + if terminalCopyOnSelectEnabled { + return String( + localized: "settings.app.copyOnSelect.subtitleOn", + defaultValue: "Automatically copy selected terminal text to the system clipboard." + ) + } + return String( + localized: "settings.app.copyOnSelect.subtitleOff", + defaultValue: "Selecting terminal text does not copy it to the system clipboard." + ) + } + private var selectedSidebarActiveTabIndicatorStyle: SidebarActiveTabIndicatorStyle { SidebarActiveTabIndicatorSettings.resolvedStyle(rawValue: sidebarActiveTabIndicatorStyle) } @@ -4511,6 +4542,20 @@ struct SettingsView: View { SettingsCardDivider() + SettingsCardRow( + String(localized: "settings.app.copyOnSelect", defaultValue: "Copy on Select"), + subtitle: terminalCopyOnSelectSubtitle + ) { + Toggle("", isOn: $terminalCopyOnSelectEnabled) + .labelsHidden() + .controlSize(.small) + .accessibilityLabel( + String(localized: "settings.app.copyOnSelect", defaultValue: "Copy on Select") + ) + } + + SettingsCardDivider() + SettingsCardRow( String(localized: "settings.app.reorderOnNotification", defaultValue: "Reorder on Notification"), subtitle: String(localized: "settings.app.reorderOnNotification.subtitle", defaultValue: "Move workspaces to the top when they receive a notification. Disable for stable shortcut positions.") @@ -5654,6 +5699,9 @@ struct SettingsView: View { .onChange(of: notificationSoundCustomFilePath) { _, _ in refreshNotificationCustomSoundStatus() } + .onChange(of: terminalCopyOnSelectEnabled) { _, _ in + GhosttyApp.shared.reloadConfiguration(source: "settings.copy_on_select") + } .onChange(of: browserInsecureHTTPAllowlist) { oldValue, newValue in // Keep draft in sync with external changes unless the user has local unsaved edits. if browserInsecureHTTPAllowlistDraft == oldValue { @@ -5777,6 +5825,7 @@ struct SettingsView: View { showMenuBarExtra = MenuBarExtraSettings.defaultShowInMenuBar warnBeforeQuitShortcut = QuitWarningSettings.defaultWarnBeforeQuit commandPaletteRenameSelectAllOnFocus = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus + terminalCopyOnSelectEnabled = TerminalCopyOnSelectSettings.defaultEnabled commandPaletteSearchAllSurfaces = CommandPaletteSwitcherSearchSettings.defaultSearchAllSurfaces ShortcutHintDebugSettings.resetVisibilityDefaults() alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index f3cbdc07..83d57082 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -584,6 +584,44 @@ final class GhosttyConfigTests: XCTestCase { XCTAssertFalse(TelemetrySettings.isEnabled(defaults: defaults)) } + func testTerminalCopyOnSelectDefaultsToDisabledWhenUnset() { + let suiteName = "cmux.tests.copy-on-select.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated user defaults suite") + return + } + defer { + defaults.removePersistentDomain(forName: suiteName) + } + + defaults.removeObject(forKey: TerminalCopyOnSelectSettings.enabledKey) + + XCTAssertFalse(TerminalCopyOnSelectSettings.isEnabled(defaults: defaults)) + XCTAssertEqual( + TerminalCopyOnSelectSettings.overrideConfigLine(defaults: defaults), + "copy-on-select = false" + ) + } + + func testTerminalCopyOnSelectUsesClipboardOverrideWhenEnabled() { + let suiteName = "cmux.tests.copy-on-select.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated user defaults suite") + return + } + defer { + defaults.removePersistentDomain(forName: suiteName) + } + + defaults.set(true, forKey: TerminalCopyOnSelectSettings.enabledKey) + + XCTAssertTrue(TerminalCopyOnSelectSettings.isEnabled(defaults: defaults)) + XCTAssertEqual( + TerminalCopyOnSelectSettings.overrideConfigLine(defaults: defaults), + "copy-on-select = clipboard" + ) + } + private func rgb255(_ color: NSColor) -> RGB { let srgb = color.usingColorSpace(.sRGB)! var red: CGFloat = 0