diff --git a/FrameworkTests/TerminalMousePreferencesTests.swift b/FrameworkTests/TerminalMousePreferencesTests.swift new file mode 100644 index 00000000..86f4b8a2 --- /dev/null +++ b/FrameworkTests/TerminalMousePreferencesTests.swift @@ -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)) + } +} diff --git a/Frameworks/Resources/Localizable.xcstrings b/Frameworks/Resources/Localizable.xcstrings index d1380a97..f5d2b6d5 100644 --- a/Frameworks/Resources/Localizable.xcstrings +++ b/Frameworks/Resources/Localizable.xcstrings @@ -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" : { diff --git a/Frameworks/TermindApplication/SettingsKey.swift b/Frameworks/TermindApplication/SettingsKey.swift index 6989f32a..815d7596 100644 --- a/Frameworks/TermindApplication/SettingsKey.swift +++ b/Frameworks/TermindApplication/SettingsKey.swift @@ -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" diff --git a/Frameworks/TermindDomain/Sessions/TerminalPreferences.swift b/Frameworks/TermindDomain/Sessions/TerminalPreferences.swift index 4c6b6b7b..2a2aacb6 100644 --- a/Frameworks/TermindDomain/Sessions/TerminalPreferences.swift +++ b/Frameworks/TermindDomain/Sessions/TerminalPreferences.swift @@ -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 } } diff --git a/Frameworks/TermindPersistence/TerminalPreferences+Defaults.swift b/Frameworks/TermindPersistence/TerminalPreferences+Defaults.swift index 824c912c..3f3b457d 100644 --- a/Frameworks/TermindPersistence/TerminalPreferences+Defaults.swift +++ b/Frameworks/TermindPersistence/TerminalPreferences+Defaults.swift @@ -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) } } diff --git a/Memory/ARCHITECTURE.md b/Memory/ARCHITECTURE.md index fad89d1f..064ca314 100644 --- a/Memory/ARCHITECTURE.md +++ b/Memory/ARCHITECTURE.md @@ -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; diff --git a/TFM/Features/Settings/SettingsPageView.swift b/TFM/Features/Settings/SettingsPageView.swift index 7eb29e5e..6dfd539a 100644 --- a/TFM/Features/Settings/SettingsPageView.swift +++ b/TFM/Features/Settings/SettingsPageView.swift @@ -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) { diff --git a/TFM/Features/Terminal/PersistentTerminalSurfaceView.swift b/TFM/Features/Terminal/PersistentTerminalSurfaceView.swift index cbf0353f..1b08c13b 100644 --- a/TFM/Features/Terminal/PersistentTerminalSurfaceView.swift +++ b/TFM/Features/Terminal/PersistentTerminalSurfaceView.swift @@ -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) diff --git a/TFM/Features/Terminal/SSHSessionModel.swift b/TFM/Features/Terminal/SSHSessionModel.swift index e74fe5a6..479ffacf 100644 --- a/TFM/Features/Terminal/SSHSessionModel.swift +++ b/TFM/Features/Terminal/SSHSessionModel.swift @@ -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 diff --git a/TFM/Features/Terminal/SerialSessionModel.swift b/TFM/Features/Terminal/SerialSessionModel.swift index 9d9dd25f..5f4d59dd 100644 --- a/TFM/Features/Terminal/SerialSessionModel.swift +++ b/TFM/Features/Terminal/SerialSessionModel.swift @@ -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 }, diff --git a/TFMTests/BroadcastInputTests.swift b/TFMTests/BroadcastInputTests.swift index b002ebd7..d0d08747 100644 --- a/TFMTests/BroadcastInputTests.swift +++ b/TFMTests/BroadcastInputTests.swift @@ -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() diff --git a/TFMTests/SettingsSyncTests.swift b/TFMTests/SettingsSyncTests.swift index 39b33225..737f696d 100644 --- a/TFMTests/SettingsSyncTests.swift +++ b/TFMTests/SettingsSyncTests.swift @@ -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] = [] diff --git a/TFMTests/TerminalMouseInteractionTests.swift b/TFMTests/TerminalMouseInteractionTests.swift new file mode 100644 index 00000000..0beea86c --- /dev/null +++ b/TFMTests/TerminalMouseInteractionTests.swift @@ -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.. 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) + } +} diff --git a/TFMTests/TerminalSettingsRenderingTests.swift b/TFMTests/TerminalSettingsRenderingTests.swift new file mode 100644 index 00000000..2875df30 --- /dev/null +++ b/TFMTests/TerminalSettingsRenderingTests.swift @@ -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) + } +}