From 3d8f096d622a0f85e0e8b0878abfd85bbc8b78e5 Mon Sep 17 00:00:00 2001 From: Zabir Raihan Date: Fri, 3 Apr 2026 02:17:24 +0600 Subject: [PATCH 1/3] Wrap dictation overlay --- Sources/SwiftTerm/Mac/MacTerminalView.swift | 36 ++++++++++++++++----- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/Sources/SwiftTerm/Mac/MacTerminalView.swift b/Sources/SwiftTerm/Mac/MacTerminalView.swift index 93eac10cf..6cafc608c 100644 --- a/Sources/SwiftTerm/Mac/MacTerminalView.swift +++ b/Sources/SwiftTerm/Mac/MacTerminalView.swift @@ -1432,6 +1432,10 @@ open class TerminalView: NSView, NSTextInputClient, NSUserInterfaceValidations, overlay.isEditable = false overlay.drawsBackground = true overlay.backgroundColor = nativeBackgroundColor.withAlphaComponent(0.9) + overlay.lineBreakMode = .byWordWrapping + overlay.maximumNumberOfLines = 0 + overlay.cell?.wraps = true + overlay.cell?.usesSingleLineMode = false overlay.wantsLayer = true overlay.layer?.cornerRadius = 3 addSubview(overlay, positioned: .above, relativeTo: nil) @@ -1448,14 +1452,30 @@ open class TerminalView: NSView, NSTextInputClient, NSUserInterfaceValidations, ], range: fullRange) overlay.attributedStringValue = displayString - // Position at the caret. - overlay.sizeToFit() - overlay.frame.origin = caretView.frame.origin - - // Clamp to view bounds so the overlay doesn't extend off-screen. - if overlay.frame.maxX > bounds.maxX { - overlay.frame.origin.x = max(0, bounds.maxX - overlay.frame.width) - } + // Allow the overlay to wrap within the available view width instead of + // growing past the terminal's visible bounds. + let horizontalInset = max(CGFloat(4), contentInsets.left) + let rightInset = max(CGFloat(4), contentInsets.right) + let maxWidth = max(cellDimension.width, bounds.width - horizontalInset - rightInset) + let overlayOriginX = min( + max(horizontalInset, caretView.frame.origin.x), + max(horizontalInset, bounds.maxX - rightInset - maxWidth) + ) + let availableWidth = max(cellDimension.width, bounds.maxX - overlayOriginX - rightInset) + let textBounds = displayString.boundingRect( + with: NSSize(width: availableWidth, height: .greatestFiniteMagnitude), + options: [.usesLineFragmentOrigin, .usesFontLeading] + ).integral + let overlayHeight = ceil(textBounds.height) + let overlayWidth = ceil(max(cellDimension.width, min(availableWidth, textBounds.width))) + let overlayOriginY = caretView.frame.maxY - overlayHeight + + overlay.frame = NSRect( + x: overlayOriginX, + y: overlayOriginY, + width: overlayWidth, + height: overlayHeight + ) } private func kittyEncoder() -> KittyKeyboardEncoder { From e704a8e71f5fdb065a520b6c856350d0a0929630 Mon Sep 17 00:00:00 2001 From: Zabir Raihan Date: Sun, 12 Apr 2026 11:29:13 +0600 Subject: [PATCH 2/3] Fix dictation overlay clipping last word Account for NSTextField internal cell margins when measuring text bounds so the overlay height correctly reflects word-wrapping. Co-Authored-By: Claude Opus 4.6 (1M context) --- Sources/SwiftTerm/Mac/MacTerminalView.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Sources/SwiftTerm/Mac/MacTerminalView.swift b/Sources/SwiftTerm/Mac/MacTerminalView.swift index 6cafc608c..5a7c66834 100644 --- a/Sources/SwiftTerm/Mac/MacTerminalView.swift +++ b/Sources/SwiftTerm/Mac/MacTerminalView.swift @@ -1462,12 +1462,16 @@ open class TerminalView: NSView, NSTextInputClient, NSUserInterfaceValidations, max(horizontalInset, bounds.maxX - rightInset - maxWidth) ) let availableWidth = max(cellDimension.width, bounds.maxX - overlayOriginX - rightInset) + // NSTextField has small internal cell margins even with isBezeled = false. + // Measure at the effective rendering width so the height accounts for any + // word-wrapping the text field will actually perform. + let cellPadding: CGFloat = 4 let textBounds = displayString.boundingRect( - with: NSSize(width: availableWidth, height: .greatestFiniteMagnitude), + with: NSSize(width: max(cellDimension.width, availableWidth - cellPadding), height: .greatestFiniteMagnitude), options: [.usesLineFragmentOrigin, .usesFontLeading] ).integral let overlayHeight = ceil(textBounds.height) - let overlayWidth = ceil(max(cellDimension.width, min(availableWidth, textBounds.width))) + let overlayWidth = ceil(max(cellDimension.width, min(availableWidth, textBounds.width + cellPadding))) let overlayOriginY = caretView.frame.maxY - overlayHeight overlay.frame = NSRect( From 5ac0f5dbb9e60cd257bda18aae7e98d910c9f406 Mon Sep 17 00:00:00 2001 From: Zabir Raihan Date: Thu, 16 Apr 2026 18:49:30 +0600 Subject: [PATCH 3/3] fill dictation overlay bg per line fragment --- Sources/SwiftTerm/Mac/MacTerminalView.swift | 148 +++++++++++++------- 1 file changed, 100 insertions(+), 48 deletions(-) diff --git a/Sources/SwiftTerm/Mac/MacTerminalView.swift b/Sources/SwiftTerm/Mac/MacTerminalView.swift index 5a7c66834..106212227 100644 --- a/Sources/SwiftTerm/Mac/MacTerminalView.swift +++ b/Sources/SwiftTerm/Mac/MacTerminalView.swift @@ -179,7 +179,7 @@ open class TerminalView: NSView, NSTextInputClient, NSUserInterfaceValidations, /// Marked (uncommitted) text from an input source (IME, dictation, etc.). private var markedTextStorage: NSAttributedString? private var markedSelectedRange: NSRange = NSRange(location: NSNotFound, length: 0) - private var markedTextOverlay: NSTextField? + private var markedTextOverlay: DictationOverlayTextView? private var progressBarView: TerminalProgressBarView? private var progressReportTimer: Timer? private var lastProgressValue: UInt8? @@ -1423,60 +1423,81 @@ open class TerminalView: NSView, NSTextInputClient, NSUserInterfaceValidations, return } - let overlay: NSTextField + let overlay: DictationOverlayTextView if let existing = markedTextOverlay { overlay = existing } else { - overlay = NSTextField(labelWithString: "") - overlay.isBezeled = false - overlay.isEditable = false - overlay.drawsBackground = true - overlay.backgroundColor = nativeBackgroundColor.withAlphaComponent(0.9) - overlay.lineBreakMode = .byWordWrapping - overlay.maximumNumberOfLines = 0 - overlay.cell?.wraps = true - overlay.cell?.usesSingleLineMode = false - overlay.wantsLayer = true - overlay.layer?.cornerRadius = 3 - addSubview(overlay, positioned: .above, relativeTo: nil) - markedTextOverlay = overlay - } - - // Style the text to match the terminal font/colors with an underline. - let displayString = NSMutableAttributedString(attributedString: markedTextStorage) - let fullRange = NSRange(location: 0, length: displayString.length) - displayString.addAttributes([ + let tv = DictationOverlayTextView(frame: .zero) + tv.isEditable = false + tv.isSelectable = false + tv.isRichText = true + tv.textContainerInset = .zero + tv.textContainer?.lineFragmentPadding = 0 + // Our subclass fills the background per-line-fragment instead of + // over the whole frame, so turn off NSTextView's built-in fill. + tv.drawsBackground = false + addSubview(tv, positioned: .above, relativeTo: nil) + markedTextOverlay = tv + overlay = tv + } + + // Match the terminal's effective background so the overlay blends in + // with whatever the rest of the view is rendering. `nativeBackgroundColor` + // is typically `NSColor.windowBackgroundColor` (dynamic: adapts to + // light/dark) when the host has called `configureNativeColors()`. + overlay.overlayBackgroundColor = nativeBackgroundColor + + // Match terminal line metrics so wrapped lines line up with terminal rows. + let lineHeight = cellDimension.height + let para = NSMutableParagraphStyle() + para.minimumLineHeight = lineHeight + para.maximumLineHeight = lineHeight + para.lineBreakMode = .byWordWrapping + + // Use labelColor so the text flips correctly between light/dark mode, + // and otherwise render as plain terminal text. + // + // The terminal pixel-snaps each cell's width, so the effective per-cell + // advance is slightly wider than the font's natural advance. Apply a + // matching `.kern` so overlay characters line up with the terminal grid. + let glyphW = font.glyph(withName: "W") + let naturalAdvance = font.advancement(forGlyph: glyphW).width + let kern = max(0, cellDimension.width - naturalAdvance) + + let display = NSMutableAttributedString(attributedString: markedTextStorage) + let fullRange = NSRange(location: 0, length: display.length) + display.addAttributes([ .font: font, - .foregroundColor: nativeForegroundColor, - .underlineStyle: NSUnderlineStyle.single.rawValue, + .foregroundColor: NSColor.labelColor, + .paragraphStyle: para, + .kern: kern, ], range: fullRange) - overlay.attributedStringValue = displayString - - // Allow the overlay to wrap within the available view width instead of - // growing past the terminal's visible bounds. - let horizontalInset = max(CGFloat(4), contentInsets.left) - let rightInset = max(CGFloat(4), contentInsets.right) - let maxWidth = max(cellDimension.width, bounds.width - horizontalInset - rightInset) - let overlayOriginX = min( - max(horizontalInset, caretView.frame.origin.x), - max(horizontalInset, bounds.maxX - rightInset - maxWidth) - ) - let availableWidth = max(cellDimension.width, bounds.maxX - overlayOriginX - rightInset) - // NSTextField has small internal cell margins even with isBezeled = false. - // Measure at the effective rendering width so the height accounts for any - // word-wrapping the text field will actually perform. - let cellPadding: CGFloat = 4 - let textBounds = displayString.boundingRect( - with: NSSize(width: max(cellDimension.width, availableWidth - cellPadding), height: .greatestFiniteMagnitude), - options: [.usesLineFragmentOrigin, .usesFontLeading] - ).integral - let overlayHeight = ceil(textBounds.height) - let overlayWidth = ceil(max(cellDimension.width, min(availableWidth, textBounds.width + cellPadding))) - let overlayOriginY = caretView.frame.maxY - overlayHeight + overlay.textStorage?.setAttributedString(display) + + // The overlay spans the full content width. Line 1 skips the portion + // already occupied by text to the left of the caret (e.g. the prompt) + // via an exclusion path; wrapped lines 2+ start from the left edge. + let horizontalInset: CGFloat = 4 + let rightInset: CGFloat = 4 + let overlayX = horizontalInset + let overlayWidth = max(cellDimension.width, bounds.width - horizontalInset - rightInset) + let caretX = max(horizontalInset, caretView.frame.origin.x) + let exclusionWidth = max(0, caretX - overlayX) + + if let container = overlay.textContainer { + container.containerSize = NSSize(width: overlayWidth, height: .greatestFiniteMagnitude) + container.exclusionPaths = exclusionWidth > 0 + ? [NSBezierPath(rect: NSRect(x: 0, y: 0, width: exclusionWidth, height: lineHeight + 1))] + : [] + } + + // Size the text view so its frame exactly wraps the wrapped line fragments. + overlay.layoutManager?.ensureLayout(for: overlay.textContainer!) + let overlayHeight = overlay.layoutManager?.usedRect(for: overlay.textContainer!).height ?? lineHeight overlay.frame = NSRect( - x: overlayOriginX, - y: overlayOriginY, + x: overlayX, + y: caretView.frame.maxY - overlayHeight, width: overlayWidth, height: overlayHeight ) @@ -2748,4 +2769,35 @@ extension TerminalViewDelegate { return nil } } + +/// NSTextView subclass used for the dictation / IME marked-text overlay. +/// +/// Fills its background only under the laid-out line fragments rather than +/// across the full frame. Line 1 already has an exclusion path covering the +/// portion occupied by pre-existing terminal text (e.g. the prompt), so this +/// drawing strategy leaves that area untouched while still covering wrapped +/// lines below. +final class DictationOverlayTextView: NSTextView { + var overlayBackgroundColor: NSColor = .clear { + didSet { needsDisplay = true } + } + + override func draw(_ dirtyRect: NSRect) { + drawPerFragmentBackground(in: dirtyRect) + super.draw(dirtyRect) + } + + private func drawPerFragmentBackground(in dirtyRect: NSRect) { + guard let layoutManager, let container = textContainer else { return } + overlayBackgroundColor.setFill() + let glyphRange = layoutManager.glyphRange(for: container) + let origin = textContainerOrigin + layoutManager.enumerateLineFragments(forGlyphRange: glyphRange) { fragmentRect, _, _, _, _ in + let r = fragmentRect.offsetBy(dx: origin.x, dy: origin.y) + if r.intersects(dirtyRect) { + r.fill() + } + } + } +} #endif