Add macOS terminal mouse copy and paste preferences
This commit is contained in:
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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user