diff --git a/Sources/TUIkit/Modifiers/BackgroundModifier.swift b/Sources/TUIkit/Modifiers/BackgroundModifier.swift index 52fe30a3..9531c1a2 100644 --- a/Sources/TUIkit/Modifiers/BackgroundModifier.swift +++ b/Sources/TUIkit/Modifiers/BackgroundModifier.swift @@ -16,24 +16,27 @@ public struct BackgroundModifier: ViewModifier { guard !buffer.isEmpty else { return buffer } let resolvedColor = color.resolve(with: context.environment.palette) - let width = buffer.width - var lines: [String] = [] - - for line in buffer.lines { - // Pad the line to full width so background covers everything - let paddedLine = line.padToVisibleWidth(width) - - // We need to handle existing ANSI codes in the line - // For simplicity, we wrap the whole line with background - let colored = applyBackground(to: paddedLine, color: resolvedColor) - lines.append(colored) - } - - return FrameBuffer(lines: lines) + return FrameBuffer( + terminalSurface: buffer.terminalSurface.applyingBackground( + resolvedColor.terminalBackgroundColor + ) + ) } +} - /// Applies background color to a string, preserving existing formatting. - private func applyBackground(to string: String, color: Color) -> String { - ANSIRenderer.backgroundCode(for: color) + string + ANSIRenderer.reset +private extension Color { + var terminalBackgroundColor: TerminalColor { + switch value { + case .standard(let color): + .ansi(Int(color.backgroundCode)) + case .bright(let color): + .ansi(Int(color.brightBackgroundCode)) + case .palette256(let index): + .indexed(Int(index)) + case .rgb(let red, let green, let blue): + .rgb(red: Int(red), green: Int(green), blue: Int(blue)) + case .semantic: + preconditionFailure("Semantic color must be resolved before rendering") + } } } diff --git a/Sources/TUIkit/Notification/NotificationTiming.swift b/Sources/TUIkit/Notification/NotificationTiming.swift index 6fa51133..8daff93a 100644 --- a/Sources/TUIkit/Notification/NotificationTiming.swift +++ b/Sources/TUIkit/Notification/NotificationTiming.swift @@ -48,19 +48,20 @@ enum NotificationTiming { return 0.0 } - /// Wraps text into lines that fit a maximum character width. + /// Wraps text into lines that fit a maximum terminal-cell width. /// /// Splits on word boundaries (spaces). Words longer than `maxWidth` /// are placed on their own line without further splitting. /// /// - Parameters: /// - text: The text to wrap. - /// - maxWidth: Maximum characters per line. + /// - maxWidth: Maximum terminal cells per line. /// - Returns: An array of wrapped lines (never empty). static func wordWrap(_ text: String, maxWidth: Int) -> [String] { - guard maxWidth > 0 else { return [text] } + let sanitizedText = text.sanitizedForTerminal + guard maxWidth > 0 else { return [sanitizedText] } - let words = text.split(separator: " ", omittingEmptySubsequences: false) + let words = sanitizedText.split(separator: " ", omittingEmptySubsequences: false) var lines: [String] = [] var currentLine = "" @@ -68,7 +69,7 @@ enum NotificationTiming { let wordStr = String(word) if currentLine.isEmpty { currentLine = wordStr - } else if currentLine.count + 1 + wordStr.count <= maxWidth { + } else if currentLine.strippedLength + 1 + wordStr.strippedLength <= maxWidth { currentLine += " " + wordStr } else { lines.append(currentLine) diff --git a/Sources/TUIkit/Rendering/FrameDiffWriter.swift b/Sources/TUIkit/Rendering/FrameDiffWriter.swift index 7374eec4..95fadf67 100644 --- a/Sources/TUIkit/Rendering/FrameDiffWriter.swift +++ b/Sources/TUIkit/Rendering/FrameDiffWriter.swift @@ -67,8 +67,12 @@ extension FrameDiffWriter { bgCode: String, reset: String ) -> [String] { + let outputWidth = max(0, terminalWidth) + let outputHeight = max(0, terminalHeight) + let clippedBuffer = buffer.clipped(toWidth: outputWidth, height: outputHeight) + let clippedLines = clippedBuffer.lines var lines: [String] = [] - lines.reserveCapacity(terminalHeight) + lines.reserveCapacity(outputHeight) // ESC[2K erases the entire line using the current background color. // Placed after bgCode so the erase uses the app background, not the @@ -77,11 +81,10 @@ extension FrameDiffWriter { let eraseLine = "\u{1B}[2K" let emptyLine = bgCode + eraseLine + reset - for row in 0.. Character + /// Maps one source grapheme to its displayed grapheme. + /// For TextField: the original character. For SecureField: a bullet. + let displayCharacter: (_ character: Character) -> Character // MARK: - Content Building @@ -41,14 +41,15 @@ struct TextFieldContentRenderer { cursorTimer: CursorTimer?, contentWidth: Int ) -> String { - let isEmpty = text.isEmpty + let sanitizedText = text.sanitizedForTerminal + let isEmpty = sanitizedText.isEmpty let backgroundColor = palette.accent.opacity(ViewConstants.focusBorderDim) if isEmpty && !isFocused && prompt != nil { return buildPromptContent(palette: palette, background: backgroundColor, width: contentWidth) } else if isFocused { return buildTextWithCursor( - text: text, + text: sanitizedText, cursorPosition: cursorPosition, selectionRange: selectionRange, palette: palette, @@ -59,7 +60,7 @@ struct TextFieldContentRenderer { ) } else { return buildTextContent( - text: text, + text: sanitizedText, palette: palette, background: backgroundColor, width: contentWidth @@ -78,8 +79,8 @@ struct TextFieldContentRenderer { } else { promptText = "" } - let truncated = String(promptText.prefix(width)) - let paddedPrompt = truncated.padding(toLength: width, withPad: " ", startingAt: 0) + let truncated = promptText.ansiAwarePrefix(visibleCount: width) + let paddedPrompt = truncated.padToVisibleWidth(width) return ANSIRenderer.colorize(paddedPrompt, foreground: palette.foregroundTertiary, background: background) } @@ -87,12 +88,18 @@ struct TextFieldContentRenderer { /// Builds text content without cursor (unfocused state). private func buildTextContent(text: String, palette: any Palette, background: Color, width: Int) -> String { - let visibleCount = min(text.count, width) + let characters = displayCharacters(for: text) var displayText = "" - for characterIndex in 0.. String { - let clampedPosition = max(0, min(cursorPosition, text.count)) - - // Calculate scroll offset to keep cursor visible - let visibleTextWidth = width - 1 // Reserve 1 char for cursor - let scrollOffset: Int - if clampedPosition <= visibleTextWidth { - scrollOffset = 0 - } else { - scrollOffset = clampedPosition - visibleTextWidth - } - - let visibleStart = scrollOffset + let characters = displayCharacters(for: text) + let clampedPosition = max(0, min(cursorPosition, characters.count)) + let cursorCharacter = cursorStyle.shape.character + let cursorCharacterWidth = max(1, cursorCharacter.terminalWidth) + let underlyingCursorWidth = clampedPosition < characters.count + ? max(1, characters[clampedPosition].terminalWidth) + : 1 + let cursorSlotWidth = max(cursorCharacterWidth, underlyingCursorWidth) + let visibleStart = visibleStart( + characters: characters, + cursorPosition: clampedPosition, + availableWidth: max(0, width - min(max(0, width), cursorSlotWidth)) + ) // Compute cursor visibility and color based on animation style let (cursorVisible, cursorColor) = computeCursorState( @@ -133,29 +141,34 @@ struct TextFieldContentRenderer { cursorTimer: cursorTimer ) - // Build output character by character + // Build output one complete grapheme at a time. var result = "" var outputWidth = 0 + var textIndex = visibleStart + var cursorRendered = false - for visibleIndex in 0..