Add macOS terminal mouse copy and paste preferences

This commit is contained in:
Kaede Akino committed 2026-09-17 12:38:31 +08:00
1 parent 429a1cee2c
commit c2d1bed4fe
14 files changed
+734 -20

No files matched your search

@@ -0,0 +1,74 @@
import Foundation
import TermindApplication
import TermindDomain
import TermindPersistence
import Testing
@Suite @MainActor
struct TerminalMousePreferencesTests {
@Test func mousePreferencesDefaultOffAndChangeIndependently() {
let model = TerminalSettingsModel()
#expect(!model.preferences.copyOnSelect)
#expect(!model.preferences.pasteOnRightClick)
#expect(model.set(true, for: \.copyOnSelect))
#expect(model.preferences.copyOnSelect)
#expect(!model.preferences.pasteOnRightClick)
#expect(model.set(true, for: \.pasteOnRightClick))
#expect(model.set(false, for: \.copyOnSelect))
#expect(!model.preferences.copyOnSelect)
#expect(model.preferences.pasteOnRightClick)
}
@Test func oldLocalBlobsAndSyncPayloadsDecodeWithMousePreferencesOff() throws {
let data = Data(
#"{"fontSize":18,"fontFollowTheme":false,"darkTheme":"Termind Dark","lightTheme":"Termind Light","darkCustomEnabled":true,"lightCustomEnabled":false,"darkCustomConfiguration":"background = 112233","lightCustomConfiguration":"","shellIntegration":false,"remoteWindowTitleInMenuBar":false}"#
.utf8)
let row = try PersistentTerminalPreferences(.init(copyOnSelect: true, pasteOnRightClick: true))
row.data = data
let value = try row.load()
#expect(!value.copyOnSelect)
#expect(!value.pasteOnRightClick)
#expect(value.fontSize == 18)
#expect(value.darkCustomConfiguration == "background = 112233")
#expect(!value.shellIntegration)
let payload = Data(#"{"terminal":{"_0": "#.utf8) + data + Data("}}".utf8)
#expect(try JSONDecoder().decode(SettingsRecordPayload.self, from: payload) == .terminal(value))
}
@Test func mousePreferencesRoundTripAndMirrorWithoutLosingOtherSettings() throws {
let domain = "TerminalMousePreferencesTests.\(UUID())"
let defaults = try #require(UserDefaults(suiteName: domain))
defer { defaults.removePersistentDomain(forName: domain) }
let original = TerminalPreferences(fontSize: 17, copyOnSelect: true, pasteOnRightClick: true)
let row = try PersistentTerminalPreferences(original)
#expect(try row.load() == original)
original.mirror(to: defaults)
#expect(defaults.bool(forKey: SettingsKey.terminalCopyOnSelect))
#expect(defaults.bool(forKey: SettingsKey.terminalPasteOnRightClick))
#expect(TerminalPreferences(defaults: defaults) == original)
}
@Test func mobileStyleTerminalEditPreservesSyncedDesktopMousePreferences() throws {
let container = try TermindPersistence.makeContainer(inMemory: true)
let domain = "TerminalMouseMobileEdit.\(UUID())"
let defaults = try #require(UserDefaults(suiteName: domain))
defer { defaults.removePersistentDomain(forName: domain) }
let store = SettingsSyncPersistence(context: container.mainContext, defaults: defaults)
try store.prepare()
let preferences = TerminalPreferences(copyOnSelect: true, pasteOnRightClick: true)
let payload = SettingsRecordPayload.terminal(preferences)
let envelope = SettingsEnvelope(
key: payload.key, version: .next(after: nil, writerID: UUID(), now: .now), payload: payload)
let snapshot = try store.applyRemote([envelope]).snapshot
let model = TerminalSettingsModel(persistence: store)
model.applyRemoteSnapshot(snapshot.terminal)
#expect(model.set(20, for: \.fontSize))
let saved = try store.loadTerminal()
#expect(saved.fontSize == 20)
#expect(saved.copyOnSelect)
#expect(saved.pasteOnRightClick)
let pending = try #require(try store.journal.pendingEnvelopes().first { $0.key.kind == .terminal })
#expect(pending.payload == .terminal(saved))
}
}
@@ -6355,6 +6355,22 @@
}
}
},
"Copies selected terminal text to the clipboard when you release the mouse button." : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "松开鼠标按键时,将选中的终端文本复制到剪贴板。"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "放開滑鼠按鍵時,將選取的終端機文字複製到剪貼簿。"
}
}
}
},
"Copy" : {
"localizations" : {
"zh-Hans" : {
@@ -6599,6 +6615,22 @@
}
}
},
"Copy on Selection" : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "选中后自动复制"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "選取後自動複製"
}
}
}
},
"Copy to %@" : {
"localizations" : {
"zh-Hans" : {
@@ -14083,6 +14115,22 @@
}
}
},
"Mouse" : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "鼠标"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "滑鼠"
}
}
}
},
"Move this conversation to Archive" : {
"extractionState" : "stale",
"localizations" : {
@@ -16859,6 +16907,22 @@
}
}
},
"Paste on Right-Click" : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "右键直接粘贴"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "按右鍵直接貼上"
}
}
}
},
"Pasted image" : {
"localizations" : {
"zh-Hans" : {
@@ -19582,6 +19646,22 @@
}
}
},
"Right-click to paste. Hold Option to keep the usual right-click behavior. Applications that capture the mouse keep their mouse controls." : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "单击右键即可粘贴。按住Option键可保留原有右键行为。接管鼠标的应用仍使用原有鼠标操作。"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "按右鍵即可貼上。按住Option鍵可保留原有右鍵行為。擷取滑鼠的應用程式仍使用原有滑鼠操作。"
}
}
}
},
"Risk" : {
"localizations" : {
"zh-Hans" : {
@@ -36,6 +36,8 @@ public nonisolated enum SettingsKey {
public static let terminalCustomConfigurationLightEnabled = "terminal.customConfiguration.light.enabled"
public static let terminalFontFollowTheme = "terminal.font.followTheme"
public static let terminalFontSize = "terminal.fontSize"
public static let terminalCopyOnSelect = "terminal.copyOnSelect"
public static let terminalPasteOnRightClick = "terminal.pasteOnRightClick"
public static let terminalThemeDark = "terminal.theme.dark"
public static let terminalThemeLight = "terminal.theme.light"
public static let vaultSyncEnabled = "vault.sync.enabled"
@@ -11,11 +11,13 @@ public nonisolated struct TerminalPreferences: Codable, Hashable, Sendable {
public var lightCustomConfiguration = ""
public var shellIntegration = true
public var remoteWindowTitleInMenuBar = true
public var copyOnSelect = false
public var pasteOnRightClick = false
public init(
fontSize: Double = 12.0, fontFollowTheme: Bool = true, darkTheme: String = "Termind Dark",
lightTheme: String = "Termind Light", darkCustomEnabled: Bool = false, lightCustomEnabled: Bool = false,
darkCustomConfiguration: String = "", lightCustomConfiguration: String = "", shellIntegration: Bool = true,
remoteWindowTitleInMenuBar: Bool = true
remoteWindowTitleInMenuBar: Bool = true, copyOnSelect: Bool = false, pasteOnRightClick: Bool = false
) {
self.fontSize = fontSize
self.fontFollowTheme = fontFollowTheme
@@ -27,6 +29,31 @@ public nonisolated struct TerminalPreferences: Codable, Hashable, Sendable {
self.lightCustomConfiguration = lightCustomConfiguration
self.shellIntegration = shellIntegration
self.remoteWindowTitleInMenuBar = remoteWindowTitleInMenuBar
self.copyOnSelect = copyOnSelect
self.pasteOnRightClick = pasteOnRightClick
}
private enum CodingKeys: String, CodingKey {
case fontSize, fontFollowTheme, darkTheme, lightTheme, darkCustomEnabled, lightCustomEnabled
case darkCustomConfiguration, lightCustomConfiguration, shellIntegration, remoteWindowTitleInMenuBar
case copyOnSelect, pasteOnRightClick
}
public init(from decoder: any Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
fontSize = try values.decode(Double.self, forKey: .fontSize)
fontFollowTheme = try values.decode(Bool.self, forKey: .fontFollowTheme)
darkTheme = try values.decode(String.self, forKey: .darkTheme)
lightTheme = try values.decode(String.self, forKey: .lightTheme)
darkCustomEnabled = try values.decode(Bool.self, forKey: .darkCustomEnabled)
lightCustomEnabled = try values.decode(Bool.self, forKey: .lightCustomEnabled)
darkCustomConfiguration = try values.decode(String.self, forKey: .darkCustomConfiguration)
lightCustomConfiguration = try values.decode(String.self, forKey: .lightCustomConfiguration)
shellIntegration = try values.decode(Bool.self, forKey: .shellIntegration)
remoteWindowTitleInMenuBar = try values.decode(Bool.self, forKey: .remoteWindowTitleInMenuBar)
// Existing local blobs and Settings sync records predate the mouse preferences.
copyOnSelect = try values.decodeIfPresent(Bool.self, forKey: .copyOnSelect) ?? false
pasteOnRightClick = try values.decodeIfPresent(Bool.self, forKey: .pasteOnRightClick) ?? false
}
}
@@ -20,6 +20,8 @@ extension TerminalPreferences {
remoteWindowTitleInMenuBar =
defaults.object(forKey: SettingsKey.remoteWindowTitleInMenuBar) as? Bool
?? remoteWindowTitleInMenuBar
copyOnSelect = defaults.bool(forKey: SettingsKey.terminalCopyOnSelect)
pasteOnRightClick = defaults.bool(forKey: SettingsKey.terminalPasteOnRightClick)
}
public nonisolated func mirror(to defaults: UserDefaults) {
@@ -33,5 +35,7 @@ extension TerminalPreferences {
defaults.set(lightCustomConfiguration, forKey: SettingsKey.terminalCustomConfigurationLight)
defaults.set(shellIntegration, forKey: SettingsKey.shellIntegration)
defaults.set(remoteWindowTitleInMenuBar, forKey: SettingsKey.remoteWindowTitleInMenuBar)
defaults.set(copyOnSelect, forKey: SettingsKey.terminalCopyOnSelect)
defaults.set(pasteOnRightClick, forKey: SettingsKey.terminalPasteOnRightClick)
}
}
+14
View File
@@ -149,6 +149,20 @@ snapshot freshness, and presentation state. Platforms provide live context,
validity, and insertion only; generated commands are inserted but never submitted.
`TerminalFindController` is shared by desktop SSH/Serial and mobile SSH.
macOS SSH and serial terminals share two default-off mouse preferences: copy a
completed mouse selection and paste on right-click. They use the existing terminal
Settings sync record and UserDefaults mirror; missing fields in older records decode
as false. Mobile preserves the values without exposing or applying them. Preference
changes update the persistent native view without rebuilding its Ghostty surface.
Only a local mouse selection completion triggers automatic copying. A right-click
chooses its route on press and pastes once on release through Ghostty's clipboard
binding inside interactive input capture, preserving bracketed paste and broadcast
routing. Remote mouse capture and Option-right-click retain Ghostty's original
handling, and mouse reports never enter the broadcast input capture scope.
Empty clipboard pastes do not arm deferred input capture. A captured bracketed paste
retains its classification across callback chunks until its closing marker; an
interrupted sequence expires with the existing short-lived input ticket window.
Mobile SSH sends shell-integration and transport-exit callbacks through one ordered,
session-scoped MainActor consumer. Close, reconnect, or background expiration ends
it. Authenticated installation and command completion establish prompt readiness;
@@ -736,6 +736,26 @@ private struct TerminalSettings: View {
}
}
}
Section("Mouse") {
Toggle(isOn: binding(\.copyOnSelect)) {
VStack(alignment: .leading, spacing: Metrics.Space.xxs) {
Text("Copy on Selection")
Text("Copies selected terminal text to the clipboard when you release the mouse button.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
Toggle(isOn: binding(\.pasteOnRightClick)) {
VStack(alignment: .leading, spacing: Metrics.Space.xxs) {
Text("Paste on Right-Click")
Text(
"Right-click to paste. Hold Option to keep the usual right-click behavior. Applications that capture the mouse keep their mouse controls."
)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
Section("Integration") {
Toggle(isOn: binding(\.shellIntegration)) {
VStack(alignment: .leading, spacing: Metrics.Space.xxs) {
@@ -2,6 +2,7 @@
import AppKit
import GhosttyTerminal
import SwiftUI
import TermindApplication
/// Mounts one long-lived Ghostty view into whichever pane currently owns it.
///
@@ -20,6 +21,8 @@
let owner: PersistentTerminalViewOwner
let isVisible: Bool
let requestsFocus: Bool
@AppStorage(SettingsKey.terminalCopyOnSelect) private var copyOnSelect = false
@AppStorage(SettingsKey.terminalPasteOnRightClick) private var pasteOnRightClick = false
final class Coordinator {
let owner: PersistentTerminalViewOwner
@@ -39,7 +42,9 @@
in: container,
state: state,
isVisible: isVisible,
requestsFocus: requestsFocus
requestsFocus: requestsFocus,
copyOnSelect: copyOnSelect,
pasteOnRightClick: pasteOnRightClick
)
return container
}
@@ -49,7 +54,9 @@
in: container,
state: state,
isVisible: isVisible,
requestsFocus: requestsFocus
requestsFocus: requestsFocus,
copyOnSelect: copyOnSelect,
pasteOnRightClick: pasteOnRightClick
)
}
@@ -76,14 +83,76 @@
override var isOpaque: Bool { false }
}
/// Keeps application-owned shortcuts in AppKit's menu command chain.
/// Adds desktop mouse preferences and keeps application-owned shortcuts in
/// AppKit's menu command chain.
///
/// Ghostty recognizes several application commands while the terminal is
/// first responder. Returning `true` prevents SwiftUI's command owner from
/// seeing the event. Registered Termind shortcuts fall through; every other
/// key still uses Ghostty's input path.
private final class TermindTerminalView: TerminalView {
final class TermindTerminalView: TerminalView {
var inputCapture: TerminalInteractiveInputCapture?
var copyOnSelect = false
var pasteOnRightClick = false
private var isSelectingWithMouse = false
private var rightClickPastes: Bool?
override func mouseDown(with event: NSEvent) {
// Remote mouse reports must never copy an unrelated retained selection.
isSelectingWithMouse = !isMouseCaptured
super.mouseDown(with: event)
}
override func mouseUp(with event: NSEvent) {
let shouldCopy = isSelectingWithMouse && copyOnSelect
isSelectingWithMouse = false
super.mouseUp(with: event)
if shouldCopy { _ = copySelectedTextToPasteboard() }
}
override func rightMouseDown(with event: NSEvent) {
let pastes = shouldPasteOnRightClick(event)
rightClickPastes = pastes
if pastes {
acquireProgrammaticFocus()
} else {
super.rightMouseDown(with: event)
}
}
override func rightMouseDragged(with event: NSEvent) {
if rightClickPastes == false { super.rightMouseDragged(with: event) }
}
override func rightMouseUp(with event: NSEvent) {
guard let pastes = rightClickPastes else { return }
rightClickPastes = nil
if pastes {
// An empty paste must not arm a deferred broadcast ticket that
// could consume the next unrelated mouse or protocol report.
let pasteboard = NSPasteboard.general
guard
pasteboard.string(forType: .string)?.isEmpty == false
|| pasteboard.canReadObject(forClasses: [NSURL.self], options: nil)
else { return }
captureInteraction { _ = performBindingAction("paste_from_clipboard") }
} else {
super.rightMouseUp(with: event)
}
}
override func menu(for event: NSEvent) -> NSMenu? {
if event.type == .rightMouseDown || event.type == .rightMouseUp,
rightClickPastes ?? shouldPasteOnRightClick(event)
{
return nil
}
return super.menu(for: event)
}
private func shouldPasteOnRightClick(_ event: NSEvent) -> Bool {
pasteOnRightClick && !isMouseCaptured && !event.modifierFlags.contains(.option)
}
override func keyDown(with event: NSEvent) {
captureInteraction { super.keyDown(with: event) }
@@ -167,7 +236,9 @@
in newContainer: TerminalMountView,
state: TerminalViewState,
isVisible: Bool,
requestsFocus: Bool
requestsFocus: Bool,
copyOnSelect: Bool = false,
pasteOnRightClick: Bool = false
) {
var didReparent = false
if container !== newContainer || terminalView.superview !== newContainer {
@@ -201,6 +272,8 @@
if terminalView.controller !== state.controller {
terminalView.controller = state.controller
}
terminalView.copyOnSelect = copyOnSelect
terminalView.pasteOnRightClick = pasteOnRightClick
if appliedVisibility != isVisible {
appliedVisibility = isVisible
terminalView.setSurfaceVisible(isVisible)
+33 -13
View File
@@ -22,6 +22,9 @@ nonisolated final class TerminalInteractiveInputCapture: @unchecked Sendable {
private var depth = 0
private var writeGeneration: UInt64 = 0
private var deferredWriteDeadline: UInt64?
private var bracketedPaste: (tail: Data, deadline: UInt64)?
private static let pasteStart = Data("\u{1B}[200~".utf8)
private static let pasteEnd = Data("\u{1B}[201~".utf8)
var isActive: Bool {
lock.withLock { depth > 0 }
@@ -49,23 +52,40 @@ nonisolated final class TerminalInteractiveInputCapture: @unchecked Sendable {
/// Classifies the backend callback while it still has the input event's
/// provenance. Ordinarily Ghostty writes synchronously. The short-lived
/// deferred ticket covers callbacks it schedules just after AppKit returns;
/// it is consumed once and expires before unrelated terminal replies can be
/// mistaken for user input.
func classifyWrite() -> Bool {
/// deferred ticket covers callbacks it schedules just after AppKit returns.
/// A bracketed paste can arrive as separate opening, body, and closing writes;
/// retain its provenance through the closing marker, even across callbacks.
/// Both the initial ticket and an interrupted paste expire promptly.
func classifyWrite(_ data: Data = Data()) -> Bool {
lock.withLock {
writeGeneration &+= 1
if depth > 0 {
deferredWriteDeadline = nil
let now = DispatchTime.now().uptimeNanoseconds
if let paste = bracketedPaste, now > paste.deadline {
bracketedPaste = nil
}
let isInteractive =
depth > 0 || bracketedPaste != nil
|| deferredWriteDeadline.map { now <= $0 } == true
deferredWriteDeadline = nil
guard isInteractive else { return false }
let pasteBytes: Data
if let paste = bracketedPaste {
pasteBytes = paste.tail + data
} else if data.starts(with: Self.pasteStart) {
pasteBytes = Data(data.dropFirst(Self.pasteStart.count))
} else {
return true
}
guard let deadline = deferredWriteDeadline,
DispatchTime.now().uptimeNanoseconds <= deadline
else {
deferredWriteDeadline = nil
return false
if pasteBytes.range(of: Self.pasteEnd) != nil {
bracketedPaste = nil
} else {
// Keep a bounded tail to recognize a split closing marker.
bracketedPaste = (
Data(pasteBytes.suffix(Self.pasteEnd.count - 1)),
now &+ Self.deferredWriteWindowNanoseconds
)
}
deferredWriteDeadline = nil
return true
}
}
@@ -289,7 +309,7 @@ final class SSHSessionModel: Identifiable {
let relay = SSHSessionRelay()
let backend = InMemoryTerminalSession(
write: { data in
let isInteractiveInput = relay.inputCapture.classifyWrite()
let isInteractiveInput = relay.inputCapture.classifyWrite(data)
Task { @MainActor in
relay.owner?.handleTerminalWrite(
data, isInteractiveInput: isInteractiveInput
@@ -52,7 +52,7 @@ final class SerialSessionModel: Identifiable {
let relay = SerialSessionRelay()
let backend = InMemoryTerminalSession(
write: { data in
let interactive = relay.inputCapture.classifyWrite()
let interactive = relay.inputCapture.classifyWrite(data)
Task { @MainActor in relay.owner?.handleTerminalWrite(data, interactive: interactive) }
},
resize: { _ in },
+40
View File
@@ -41,6 +41,46 @@ private final class WorkspaceCommandGeneratorStub: CommandGenerating {
@MainActor
struct BroadcastInputTests {
@Test
func deferredBracketedPasteKeepsEveryChunkInteractiveUntilItsClosingMarker() {
let capture = TerminalInteractiveInputCapture()
capture.withInteraction {}
#expect(capture.classifyWrite(Data("\u{1B}[200~".utf8)))
#expect(capture.classifyWrite(Data("echo 中文\necho next".utf8)))
#expect(capture.classifyWrite(Data("\u{1B}[20".utf8)))
#expect(capture.classifyWrite(Data("1~".utf8)))
#expect(!capture.classifyWrite(Data("\u{1B}[<2;1;1M".utf8)))
}
@Test
func synchronousPasteStartKeepsItsDeferredBodyInteractive() {
let capture = TerminalInteractiveInputCapture()
capture.withInteraction {
#expect(capture.classifyWrite(Data("\u{1B}[200~".utf8)))
}
#expect(capture.classifyWrite(Data("body\u{1B}[201~".utf8)))
#expect(!capture.classifyWrite(Data("\u{1B}[?1;2c".utf8)))
}
@Test
func completePasteDoesNotCaptureALaterReportAndUnsolicitedMarkersDoNotStartCapture() {
let capture = TerminalInteractiveInputCapture()
capture.withInteraction {
#expect(capture.classifyWrite(Data("\u{1B}[200~body\u{1B}[201~".utf8)))
}
#expect(!capture.classifyWrite(Data("\u{1B}[200~".utf8)))
#expect(!capture.classifyWrite(Data("body\u{1B}[201~".utf8)))
}
@Test
func interruptedPasteExpiresBeforeUnrelatedReportsArrive() async throws {
let capture = TerminalInteractiveInputCapture()
capture.withInteraction {}
#expect(capture.classifyWrite(Data("\u{1B}[200~".utf8)))
try await Task.sleep(for: .milliseconds(150))
#expect(!capture.classifyWrite(Data("\u{1B}[<2;1;1M".utf8)))
}
@Test
func routesInteractiveBytesOnlyWhenBroadcastIsEnabled() {
let (workspace, firstSSHSession, secondSSHSession) = makeTwoSSHSessionWorkspace()
+4
View File
@@ -160,6 +160,8 @@ struct SettingsSyncTests {
preferences.lightCustomConfiguration = "background = #ffffff"
preferences.shellIntegration = false
preferences.remoteWindowTitleInMenuBar = false
preferences.copyOnSelect = true
preferences.pasteOnRightClick = true
try source.saveTerminal(preferences)
#expect(sourceDefaults.double(forKey: SettingsKey.terminalFontSize) == 18)
let records = try source.journal.pendingEnvelopes().filter { $0.key.kind == .terminal }
@@ -168,6 +170,8 @@ struct SettingsSyncTests {
#expect(
defaults.string(forKey: SettingsKey.terminalCustomConfigurationDark) == preferences.darkCustomConfiguration)
#expect(!defaults.bool(forKey: SettingsKey.shellIntegration))
#expect(defaults.bool(forKey: SettingsKey.terminalCopyOnSelect))
#expect(defaults.bool(forKey: SettingsKey.terminalPasteOnRightClick))
#expect(try !destination.journal.pendingEnvelopes().contains { $0.key.kind == .terminal })
let model = TerminalSettingsModel(persistence: destination)
var changes: [Bool] = []
@@ -0,0 +1,317 @@
import AppKit
import GhosttyTerminal
import SwiftUI
import TermindApplication
import TermindDomain
import Testing
@testable import Termind
private nonisolated let terminalMouseHasDisplay: Bool = {
var count: UInt32 = 0
return CGGetActiveDisplayList(0, nil, &count) == .success && count > 0
}()
@Suite(.serialized, .disabled(if: !terminalMouseHasDisplay, "Ghostty requires an active display"))
@MainActor
struct TerminalMouseInteractionTests {
@Test func dragCopiesChineseAndMultipleLinesOnlyAfterRelease() async throws {
let fixture = try await TerminalMouseFixture.make()
defer { fixture.close() }
fixture.view.copyOnSelect = true
fixture.feed("hello 中文\r\nsecond line")
fixture.setClipboard("unchanged")
fixture.view.mouseDown(with: try fixture.event(.leftMouseDown, column: 0, row: 0))
fixture.view.mouseDragged(with: try fixture.event(.leftMouseDragged, column: 11, row: 1))
#expect(NSPasteboard.general.string(forType: .string) == "unchanged")
fixture.view.mouseUp(with: try fixture.event(.leftMouseUp, column: 11, row: 1))
#expect(NSPasteboard.general.string(forType: .string) == "hello 中文\nsecond line")
#expect(fixture.firstWrites.isEmpty)
#expect(fixture.secondWrites.isEmpty)
}
@Test(arguments: [2, 3])
func repeatedClicksCopyWordOrLine(_ count: Int) async throws {
let fixture = try await TerminalMouseFixture.make()
defer { fixture.close() }
fixture.view.copyOnSelect = true
fixture.feed("hello world")
fixture.setClipboard("unchanged")
for _ in 0..<count {
fixture.view.mouseDown(with: try fixture.event(.leftMouseDown, column: 1, row: 0))
fixture.view.mouseUp(with: try fixture.event(.leftMouseUp, column: 1, row: 0))
}
let copied = try #require(NSPasteboard.general.string(forType: .string))
#expect(copied.trimmingCharacters(in: .whitespacesAndNewlines) == (count == 2 ? "hello" : "hello world"))
}
@Test func disabledCopyEmptySelectionAndKeyboardSelectionLeaveClipboardAlone() async throws {
let fixture = try await TerminalMouseFixture.make()
defer { fixture.close() }
fixture.feed("hello world")
fixture.setClipboard("unchanged")
fixture.view.mouseDown(with: try fixture.event(.leftMouseDown, column: 0, row: 0))
fixture.view.mouseDragged(with: try fixture.event(.leftMouseDragged, column: 5, row: 0))
fixture.view.mouseUp(with: try fixture.event(.leftMouseUp, column: 5, row: 0))
#expect(fixture.state.surface?.hasSelection() == true)
#expect(NSPasteboard.general.string(forType: .string) == "unchanged")
fixture.view.copyOnSelect = true
_ = fixture.view.performBindingAction("select_all")
#expect(NSPasteboard.general.string(forType: .string) == "unchanged")
// An unmatched release must not copy an earlier keyboard selection.
fixture.view.mouseUp(with: try fixture.event(.leftMouseUp))
#expect(NSPasteboard.general.string(forType: .string) == "unchanged")
fixture.view.mouseDown(with: try fixture.event(.leftMouseDown, column: 20, row: 5))
fixture.view.mouseUp(with: try fixture.event(.leftMouseUp, column: 20, row: 5))
#expect(NSPasteboard.general.string(forType: .string) == "unchanged")
}
@Test func rightClickPastesBracketedTextOnceAndBroadcastsOnceWithASelection() async throws {
let fixture = try await TerminalMouseFixture.make()
defer { fixture.close() }
fixture.view.pasteOnRightClick = true
fixture.workspace.broadcastsInput = true
fixture.feed("selected text\u{1B}[?2004h")
_ = fixture.view.performBindingAction("select_all")
fixture.setClipboard("echo 中文\necho second")
fixture.view.rightMouseDown(with: try fixture.event(.rightMouseDown))
#expect(fixture.view.menu(for: try fixture.event(.rightMouseDown)) == nil)
#expect(fixture.firstWrites.isEmpty)
// Preference and modifier changes during a click do not change its route.
fixture.view.pasteOnRightClick = false
fixture.view.rightMouseDragged(with: try fixture.event(.rightMouseDragged))
fixture.view.rightMouseUp(with: try fixture.event(.rightMouseUp, modifiers: .option))
fixture.view.rightMouseUp(with: try fixture.event(.rightMouseUp))
try await fixture.flushWrites()
let expected = Data("\u{1B}[200~echo 中文\necho second\u{1B}[201~".utf8)
#expect(fixture.firstWrites.reduce(Data(), +) == expected)
#expect(fixture.secondWrites.reduce(Data(), +) == expected)
}
@Test func emptyClipboardAndOptionRightClickDoNotPaste() async throws {
let fixture = try await TerminalMouseFixture.make()
defer { fixture.close() }
fixture.view.pasteOnRightClick = true
NSPasteboard.general.clearContents()
try fixture.rightClick()
try await fixture.flushWrites()
#expect(fixture.firstWrites.isEmpty)
fixture.setClipboard("do not paste")
fixture.view.rightMouseDown(with: try fixture.event(.rightMouseDown, modifiers: .option))
fixture.view.rightMouseUp(with: try fixture.event(.rightMouseUp))
try await fixture.flushWrites()
#expect(fixture.firstWrites.isEmpty)
fixture.feed("selection")
_ = fixture.view.performBindingAction("select_all")
#expect(fixture.view.menu(for: try fixture.event(.rightMouseDown, modifiers: .option)) != nil)
}
@Test func capturedMouseKeepsBothReportsAndNeverBroadcastsOrCopies() async throws {
let fixture = try await TerminalMouseFixture.make()
defer { fixture.close() }
fixture.view.copyOnSelect = true
fixture.view.pasteOnRightClick = true
fixture.workspace.broadcastsInput = true
fixture.setClipboard("do not paste")
fixture.feed("\u{1B}[?1000h\u{1B}[?1006h")
#expect(fixture.view.isMouseCaptured)
fixture.view.rightMouseDown(with: try fixture.event(.rightMouseDown))
fixture.view.pasteOnRightClick = false
fixture.view.rightMouseUp(with: try fixture.event(.rightMouseUp))
fixture.view.mouseDown(with: try fixture.event(.leftMouseDown))
fixture.view.mouseUp(with: try fixture.event(.leftMouseUp))
try await fixture.flushWrites()
let reports = String(decoding: fixture.firstWrites.reduce(Data(), +), as: UTF8.self)
#expect(reports.contains("\u{1B}[<2;"))
#expect(reports.contains("M"))
#expect(reports.contains("m"))
#expect(!reports.contains("do not paste"))
#expect(fixture.secondWrites.isEmpty)
#expect(NSPasteboard.general.string(forType: .string) == "do not paste")
}
@Test func emptyPasteDoesNotMarkTheNextMouseReportAsInteractiveInput() async throws {
let fixture = try await TerminalMouseFixture.make()
defer { fixture.close() }
fixture.view.pasteOnRightClick = true
fixture.workspace.broadcastsInput = true
NSPasteboard.general.clearContents()
try fixture.rightClick()
fixture.feed("\u{1B}[?1000h\u{1B}[?1006h")
try fixture.rightClick()
try await fixture.flushWrites()
#expect(!fixture.firstWrites.isEmpty)
#expect(fixture.secondWrites.isEmpty)
}
@Test func enablingPasteDuringAClickAppliesToTheNextClick() async throws {
let fixture = try await TerminalMouseFixture.make()
defer { fixture.close() }
fixture.setClipboard("paste once")
fixture.view.rightMouseDown(with: try fixture.event(.rightMouseDown))
fixture.view.pasteOnRightClick = true
fixture.view.rightMouseUp(with: try fixture.event(.rightMouseUp))
try await fixture.flushWrites()
#expect(fixture.firstWrites.isEmpty)
try fixture.rightClick()
try await fixture.flushWrites()
#expect(fixture.firstWrites.reduce(Data(), +) == Data("paste once".utf8))
}
@Test func mirroredSettingsUpdateTheMountedSwiftUIViewWithoutReplacingItsSurface() async throws {
let fixture = try await TerminalMouseFixture.make()
defer { fixture.close() }
let domain = "TerminalMouseLiveSettings.\(UUID())"
let defaults = try #require(UserDefaults(suiteName: domain))
defer { defaults.removePersistentDomain(forName: domain) }
let hosting = NSHostingView(
rootView: PersistentTerminalSurfaceView(
state: fixture.state, owner: fixture.owner, isVisible: true, requestsFocus: false
).defaultAppStorage(defaults))
hosting.frame = fixture.mount.bounds
fixture.mount.addSubview(hosting)
hosting.layoutSubtreeIfNeeded()
try await Task.sleep(for: .milliseconds(100))
let surface = try #require(fixture.state.surface)
#expect(!fixture.view.copyOnSelect)
#expect(!fixture.view.pasteOnRightClick)
defaults.set(true, forKey: SettingsKey.terminalCopyOnSelect)
defaults.set(true, forKey: SettingsKey.terminalPasteOnRightClick)
for _ in 0..<100 where !fixture.view.copyOnSelect || !fixture.view.pasteOnRightClick {
try await Task.sleep(for: .milliseconds(10))
}
#expect(fixture.view.copyOnSelect)
#expect(fixture.view.pasteOnRightClick)
#expect(fixture.state.surface === surface)
}
@Test func preferenceUpdatesAndReparentingPreserveSurfaceAndScrollback() async throws {
let fixture = try await TerminalMouseFixture.make()
defer { fixture.close() }
fixture.feed("retained history")
let surface = try #require(fixture.state.surface)
fixture.owner.mount(
in: fixture.mount, state: fixture.state, isVisible: true, requestsFocus: false,
copyOnSelect: true, pasteOnRightClick: true)
#expect(fixture.view.copyOnSelect)
#expect(fixture.view.pasteOnRightClick)
#expect(fixture.state.surface === surface)
let newMount = TerminalMountView(frame: fixture.mount.frame)
fixture.window.contentView?.addSubview(newMount)
fixture.owner.mount(
in: newMount, state: fixture.state, isVisible: true, requestsFocus: false,
copyOnSelect: false, pasteOnRightClick: true)
newMount.layoutSubtreeIfNeeded()
#expect(newMount.subviews.first === fixture.view)
#expect(fixture.state.surface === surface)
#expect(fixture.backend.readViewportText()?.contains("retained history") == true)
#expect(!fixture.view.copyOnSelect)
#expect(fixture.view.pasteOnRightClick)
fixture.owner.unmount(from: newMount)
}
}
@MainActor
private final class TerminalMouseFixture {
let workspace: Workspace
let session: SSHSessionModel
let mount: TerminalMountView
let window: NSWindow
let view: TermindTerminalView
let backend: InMemoryTerminalSession
var firstWrites: [Data] = []
var secondWrites: [Data] = []
private let savedClipboard: [[NSPasteboard.PasteboardType: Data]]
var state: TerminalViewState { session.terminalState }
var owner: PersistentTerminalViewOwner { session.terminalViewOwner }
private init() throws {
savedClipboard = (NSPasteboard.general.pasteboardItems ?? []).map { item in
Dictionary(
uniqueKeysWithValues: item.types.compactMap { type in item.data(forType: type).map { (type, $0) } })
}
workspace = Workspace.sshSession(host: Host(name: "first", host: "first.example"))
session = try #require(workspace.focusedPane.sshSession)
let peer = Pane.sshSession(host: Host(name: "second", host: "second.example"))
workspace.splitFocusedPane(axis: .horizontal, with: peer)
guard case .inMemory(let memory) = session.terminalState.configuration.backend else {
throw CocoaError(.coderInvalidValue)
}
backend = memory
session.terminalState.setTerminalConfiguration(
TerminalConfiguration { builder in
builder.withWindowPaddingX(0)
builder.withWindowPaddingY(0)
})
mount = TerminalMountView(frame: NSRect(x: 0, y: 0, width: 640, height: 400))
window = NSWindow(contentRect: mount.frame, styleMask: [.titled], backing: .buffered, defer: false)
window.isReleasedWhenClosed = false
window.contentView = mount
window.orderFront(nil)
session.terminalViewOwner.mount(in: mount, state: session.terminalState, isVisible: true, requestsFocus: false)
mount.layoutSubtreeIfNeeded()
view = try #require(mount.subviews.first as? TermindTerminalView)
session.inputSink = { [weak self] in self?.firstWrites.append($0) }
peer.sshSession?.inputSink = { [weak self] in self?.secondWrites.append($0) }
}
static func make() async throws -> TerminalMouseFixture {
let fixture = try TerminalMouseFixture()
for _ in 0..<100 where fixture.state.surface == nil || fixture.state.surfaceSize == nil {
try await Task.sleep(for: .milliseconds(10))
}
_ = try #require(fixture.state.surface)
_ = try #require(fixture.state.surfaceSize)
return fixture
}
func feed(_ text: String) {
backend.receive(Data(text.utf8))
backend.waitForPendingOutput()
}
func setClipboard(_ text: String) {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(text, forType: .string)
}
func event(
_ type: NSEvent.EventType, column: Double = 1, row: Double = 1, modifiers: NSEvent.ModifierFlags = []
) throws -> NSEvent {
let metrics = try #require(state.surfaceSize)
let scale = window.backingScaleFactor
let point = NSPoint(
x: (column + 0.5) * Double(metrics.cellWidthPixels) / scale,
y: view.bounds.height - (row + 0.5) * Double(metrics.cellHeightPixels) / scale)
return try #require(
NSEvent.mouseEvent(
with: type, location: view.convert(point, to: nil), modifierFlags: modifiers,
timestamp: ProcessInfo.processInfo.systemUptime, windowNumber: window.windowNumber,
context: nil, eventNumber: 0, clickCount: 1, pressure: 1))
}
func rightClick() throws {
view.rightMouseDown(with: try event(.rightMouseDown))
view.rightMouseUp(with: try event(.rightMouseUp))
}
func flushWrites() async throws {
try await Task.sleep(for: .milliseconds(150))
}
func close() {
owner.unmount(from: mount)
window.orderOut(nil)
window.contentView = nil
window.close()
let items = savedClipboard.map { values in
let item = NSPasteboardItem()
for (type, data) in values { item.setData(data, forType: type) }
return item
}
NSPasteboard.general.clearContents()
NSPasteboard.general.writeObjects(items)
}
}
@@ -0,0 +1,39 @@
import AppKit
import SwiftUI
import TermindApplication
import Testing
@testable import Termind
@Suite(.serialized) @MainActor
struct TerminalSettingsRenderingTests {
@Test(arguments: ["en", "zh-Hans", "zh-Hant"])
func terminalMouseSettingsRenderInSupportedLanguages(_ language: String) async throws {
let app = AppModel(hosts: HostLibraryModel())
let model = WindowModel(workspaces: [])
model.openSettings(route: .category(.terminal))
defer { app.shutdownForTermination() }
let view = SettingsPageView().environment(app).environment(model)
.environment(\.locale, Locale(identifier: language))
.frame(width: 640, height: 1100)
let controller = NSHostingController(rootView: view)
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 640, height: 1100),
styleMask: [.borderless], backing: .buffered, defer: false)
window.isReleasedWhenClosed = false
window.contentViewController = controller
window.orderFront(nil)
defer {
window.orderOut(nil)
window.contentViewController = nil
window.close()
}
try await Task.sleep(for: .milliseconds(500))
controller.view.layoutSubtreeIfNeeded()
let bitmap = try #require(controller.view.bitmapImageRepForCachingDisplay(in: controller.view.bounds))
controller.view.cacheDisplay(in: controller.view.bounds, to: bitmap)
let data = try #require(bitmap.representation(using: .png, properties: [:]))
Attachment.record(data, named: "terminal-mouse-settings-\(language).png")
#expect(data.count > 20_000)
}
}