Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions GhosttyTabs.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
D0B10008A1B2C3D4E5F60001 /* PortalTabDragRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B10009A1B2C3D4E5F60001 /* PortalTabDragRoutingTests.swift */; };
D0B1000AA1B2C3D4E5F60001 /* GhosttyTerminalViewSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B1000BA1B2C3D4E5F60001 /* GhosttyTerminalViewSupport.swift */; };
D0B10012A1B2C3D4E5F60001 /* GhosttyApp+SurfaceConfigurationReload.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B10013A1B2C3D4E5F60001 /* GhosttyApp+SurfaceConfigurationReload.swift */; };
D3571000A1B2C3D4E5F60718 /* GhosttyNSView+IMEComposition.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3571001A1B2C3D4E5F60718 /* GhosttyNSView+IMEComposition.swift */; };
D0B1000CA1B2C3D4E5F60001 /* CmuxWebViewDragRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B1000DA1B2C3D4E5F60001 /* CmuxWebViewDragRoutingTests.swift */; };
D0B1000EA1B2C3D4E5F60001 /* BrowserPaneDropRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B1000FA1B2C3D4E5F60001 /* BrowserPaneDropRoutingTests.swift */; };
D0B10010A1B2C3D4E5F60001 /* BonsplitTabBarDebug.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B10011A1B2C3D4E5F60001 /* BonsplitTabBarDebug.swift */; };
Expand Down Expand Up @@ -334,6 +335,7 @@
F2000000A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */; };
F20F85FC5900550685FA33AD /* StackAuth in Frameworks */ = {isa = PBXBuildFile; productRef = A8BD195031FC4B82B4354297 /* StackAuth */; };
F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */; };
D3571002A1B2C3D4E5F60718 /* CJKIMEMarkedSelectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3571003A1B2C3D4E5F60718 /* CJKIMEMarkedSelectionTests.swift */; };
1718C0DE1718C0DE17180002 /* GhosttyCommandShiftForwardingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1718C0DE1718C0DE17180001 /* GhosttyCommandShiftForwardingTests.swift */; };
D3284001A1B2C3D4E5F60718 /* TraditionalChineseIMENumpadRegressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3284002A1B2C3D4E5F60718 /* TraditionalChineseIMENumpadRegressionTests.swift */; };
F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */; };
Expand Down Expand Up @@ -572,6 +574,7 @@
D7AB00000000000000000006 /* GhosttyNSView+MoveTabToNewWorkspace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GhosttyNSView+MoveTabToNewWorkspace.swift"; sourceTree = "<group>"; };
D0B1000BA1B2C3D4E5F60001 /* GhosttyTerminalViewSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalViewSupport.swift; sourceTree = "<group>"; };
D0B10013A1B2C3D4E5F60001 /* GhosttyApp+SurfaceConfigurationReload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GhosttyApp+SurfaceConfigurationReload.swift"; sourceTree = "<group>"; };
D3571001A1B2C3D4E5F60718 /* GhosttyNSView+IMEComposition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GhosttyNSView+IMEComposition.swift"; sourceTree = "<group>"; };
D0B10001A1B2C3D4E5F60001 /* TerminalPaneDropTargetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalPaneDropTargetView.swift; sourceTree = "<group>"; };
D0B10015A1B2C3D4E5F60001 /* DragOverlayRoutingPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DragOverlayRoutingPolicy.swift; sourceTree = "<group>"; };
D0B10017A1B2C3D4E5F60001 /* FilePreviewTextEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/FilePreviewTextEditor.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -767,6 +770,7 @@
F1C1AA20B7E84D10A1C10001 /* InactivePaneFirstClickFocusTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InactivePaneFirstClickFocusTests.swift; sourceTree = "<group>"; };
F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePillReleaseVisibilityTests.swift; sourceTree = "<group>"; };
F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CJKIMEInputTests.swift; sourceTree = "<group>"; };
D3571003A1B2C3D4E5F60718 /* CJKIMEMarkedSelectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CJKIMEMarkedSelectionTests.swift; sourceTree = "<group>"; };
1718C0DE1718C0DE17180001 /* GhosttyCommandShiftForwardingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyCommandShiftForwardingTests.swift; sourceTree = "<group>"; };
D3284002A1B2C3D4E5F60718 /* TraditionalChineseIMENumpadRegressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TraditionalChineseIMENumpadRegressionTests.swift; sourceTree = "<group>"; };
F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyConfigTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1050,6 +1054,7 @@
D7AB00000000000000000006 /* GhosttyNSView+MoveTabToNewWorkspace.swift */,
D0B1000BA1B2C3D4E5F60001 /* GhosttyTerminalViewSupport.swift */,
D0B10013A1B2C3D4E5F60001 /* GhosttyApp+SurfaceConfigurationReload.swift */,
D3571001A1B2C3D4E5F60718 /* GhosttyNSView+IMEComposition.swift */,
D0B10001A1B2C3D4E5F60001 /* TerminalPaneDropTargetView.swift */,
A5001531 /* TerminalWindowPortal.swift */,
D0B10007A1B2C3D4E5F60001 /* TerminalWindowPortalDebug.swift */,
Expand Down Expand Up @@ -1202,6 +1207,7 @@
children = (
F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */,
F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */,
D3571003A1B2C3D4E5F60718 /* CJKIMEMarkedSelectionTests.swift */,
1718C0DE1718C0DE17180001 /* GhosttyCommandShiftForwardingTests.swift */,
D3284002A1B2C3D4E5F60718 /* TraditionalChineseIMENumpadRegressionTests.swift */,
F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */,
Expand Down Expand Up @@ -1628,6 +1634,7 @@
D7AB00000000000000000005 /* GhosttyNSView+MoveTabToNewWorkspace.swift in Sources */,
D0B1000AA1B2C3D4E5F60001 /* GhosttyTerminalViewSupport.swift in Sources */,
D0B10012A1B2C3D4E5F60001 /* GhosttyApp+SurfaceConfigurationReload.swift in Sources */,
D3571000A1B2C3D4E5F60718 /* GhosttyNSView+IMEComposition.swift in Sources */,
D0B10000A1B2C3D4E5F60001 /* TerminalPaneDropTargetView.swift in Sources */,
D0B10014A1B2C3D4E5F60001 /* DragOverlayRoutingPolicy.swift in Sources */,
A5001532 /* TerminalWindowPortal.swift in Sources */,
Expand Down Expand Up @@ -1821,6 +1828,7 @@
files = (
F2000000A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift in Sources */,
F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */,
D3571002A1B2C3D4E5F60718 /* CJKIMEMarkedSelectionTests.swift in Sources */,
1718C0DE1718C0DE17180002 /* GhosttyCommandShiftForwardingTests.swift in Sources */,
D3284001A1B2C3D4E5F60718 /* TraditionalChineseIMENumpadRegressionTests.swift in Sources */,
F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */,
Expand Down
66 changes: 66 additions & 0 deletions Sources/GhosttyNSView+IMEComposition.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import AppKit

extension GhosttyNSView {
/// Clamps AppKit's marked-text selection into the active preedit buffer.
func normalizedMarkedSelectionRange(_ range: NSRange, markedLength: Int) -> NSRange {
guard markedLength > 0 else {
return NSRange(location: NSNotFound, length: 0)
}
guard range.location != NSNotFound else {
return NSRange(location: markedLength, length: 0)
}

let clampedLocation = min(max(range.location, 0), markedLength)
let clampedLength = min(max(range.length, 0), markedLength - clampedLocation)
return NSRange(location: clampedLocation, length: clampedLength)
}

/// Clamps an AppKit substring query so it can be served from marked text.
func clampedMarkedTextRange(_ range: NSRange, markedLength: Int) -> NSRange? {
guard range.length > 0, range.location != NSNotFound else { return nil }
guard markedLength > 0 else { return nil }

let location = min(max(range.location, 0), markedLength)
let maxLength = markedLength - location
guard maxLength > 0 else { return nil }

let length = min(max(range.length, 0), maxLength)
guard length > 0 else { return nil }
return NSRange(location: location, length: length)
}

/// Returns true when AppKit consumed the key by changing IME composition state.
func shouldSuppressGhosttyKeyForwardingAfterIMEHandling(
before: (text: String, selection: NSRange),
after: (text: String, selection: NSRange),
accumulatedText: [String]
) -> Bool {
guard accumulatedText.isEmpty else { return false }

let hadMarkedTextBefore = !before.text.isEmpty
let hasMarkedTextAfter = !after.text.isEmpty
guard hadMarkedTextBefore || hasMarkedTextAfter else { return false }

if before.text != after.text {
return true
}

return before.selection != after.selection
}

#if DEBUG
func shouldSuppressGhosttyKeyForwardingAfterIMEHandlingForTesting(
markedTextBefore: String,
markedSelectionBefore: NSRange,
markedTextAfter: String,
markedSelectionAfter: NSRange,
accumulatedText: [String]
) -> Bool {
shouldSuppressGhosttyKeyForwardingAfterIMEHandling(
before: (markedTextBefore, markedSelectionBefore),
after: (markedTextAfter, markedSelectionAfter),
accumulatedText: accumulatedText
)
}
#endif
}
28 changes: 23 additions & 5 deletions Sources/GhosttyTerminalView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7043,6 +7043,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
// For NSTextInputClient - accumulates text during key events
private(set) var keyTextAccumulator: [String]? = nil
private var markedText = NSMutableAttributedString()
private var markedSelectedRange = NSRange(location: NSNotFound, length: 0)
private var lastPerformKeyEvent: TimeInterval?
private(set) var externalCommittedTextDepth = 0
var numpadIMECommitDeduplicator = NumpadIMECommitDeduplicator()
Expand Down Expand Up @@ -7467,9 +7468,8 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
keyTextAccumulator = []
defer { keyTextAccumulator = nil }

// Track whether we had marked text (IME preedit) before this event,
// so we can detect when composition ends.
let markedTextBefore = markedText.length > 0
let markedStateBefore = (markedText.string, markedSelectedRange)

// Capture the keyboard layout ID before interpretation so we can
// detect if an IME changed it (e.g. toggling input methods).
Expand Down Expand Up @@ -7518,6 +7518,11 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
syncPreeditMs = (ProcessInfo.processInfo.systemUptime - syncPreeditStart) * 1000.0
#endif

let accumulatedText = keyTextAccumulator ?? []
if shouldSuppressGhosttyKeyForwardingAfterIMEHandling(
before: markedStateBefore, after: (markedText.string, markedSelectedRange), accumulatedText: accumulatedText
) { return }

// Build the key event
var keyEvent = ghostty_input_key_s()
keyEvent.action = action
Expand All @@ -7536,7 +7541,6 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
keyEvent.composing = markedText.length > 0 || markedTextBefore

// Use accumulated text from insertText (for IME), or compute text for key
let accumulatedText = keyTextAccumulator ?? []
var shouldRefreshAfterTextInput = false
if !accumulatedText.isEmpty {
// Accumulated text comes from insertText (IME composition result).
Expand Down Expand Up @@ -12724,7 +12728,13 @@ extension GhosttyNSView: NSTextInputClient {
}

func selectedRange() -> NSRange {
readSelectionSnapshot()?.range ?? NSRange(location: 0, length: 0)
if markedText.length > 0 {
#if DEBUG
assert(markedSelectedRange.location != NSNotFound, "markedSelectedRange must be valid")
#endif
return markedSelectedRange
}
return readSelectionSnapshot()?.range ?? NSRange(location: 0, length: 0)
}
Comment thread
austinywang marked this conversation as resolved.

func setMarkedText(_ string: Any, selectedRange: NSRange, replacementRange: NSRange) {
Expand All @@ -12744,8 +12754,9 @@ extension GhosttyNSView: NSTextInputClient {
case let v as String:
markedText = NSMutableAttributedString(string: v)
default:
break
return
}
markedSelectedRange = normalizedMarkedSelectionRange(selectedRange, markedLength: markedText.length)

// If we're not in a keyDown event, sync preedit immediately.
// This can happen due to external events like changing keyboard layouts
Expand All @@ -12770,6 +12781,7 @@ extension GhosttyNSView: NSTextInputClient {
#endif
if markedText.length > 0 {
markedText.mutableString.setString("")
markedSelectedRange = NSRange(location: NSNotFound, length: 0)
syncPreedit()
invalidateTextInputCoordinates(selectionChanged: true)
}
Expand Down Expand Up @@ -12812,6 +12824,12 @@ extension GhosttyNSView: NSTextInputClient {
}

func attributedSubstring(forProposedRange range: NSRange, actualRange: NSRangePointer?) -> NSAttributedString? {
if markedText.length > 0 {
guard let substringRange = clampedMarkedTextRange(range, markedLength: markedText.length) else { return nil }
actualRange?.pointee = substringRange
return markedText.attributedSubstring(from: substringRange)
}

guard range.length > 0,
let snapshot = readSelectionSnapshot() else { return nil }
actualRange?.pointee = snapshot.range
Expand Down
8 changes: 1 addition & 7 deletions cmuxTests/CJKIMEInputTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -364,13 +364,7 @@ final class CJKIMEMarkedTextTests: XCTestCase {
XCTAssertFalse(view.hasMarkedText())
}

// MARK: - selectedRange / validAttributesForMarkedText

func testSelectedRangeReturnsEmptyRangeWithoutSelection() {
let view = GhosttyNSView(frame: .zero)
let range = view.selectedRange()
XCTAssertEqual(range, NSRange(location: 0, length: 0))
}
// MARK: - validAttributesForMarkedText

func testValidAttributesForMarkedTextReturnsEmpty() {
let view = GhosttyNSView(frame: .zero)
Expand Down
Loading
Loading