diff --git a/Assets/HighlightStyles/README.md b/Assets/HighlightStyles/README.md new file mode 100644 index 00000000..f74f558c --- /dev/null +++ b/Assets/HighlightStyles/README.md @@ -0,0 +1,11 @@ +# Syntax highlight captures + +Native iPhone 17 Pro Simulator captures from a temporary UIKit host using Runestone `TextView`, rendered against framework commit `3d7ff92e158ddb37f0661b99a1301a111e2917c1` on iOS 26.5. + +Each screen compares two views in the same build. The upper view uses the individual `Theme` callbacks; the lower view returns a complete `HighlightStyle`. These are API comparisons, not screenshots of two different framework revisions, and are visual evidence rather than automated appearance regression tests. + +- `token-backgrounds.png`: Alabaster BG colors with HTML captures. The complete style adds yellow comment, green string, and blue tag backgrounds. +- `nested-style-reset.png`: A synthetic JavaScript query captures `(program) @emphasis` and `(string) @plain`. The outer capture supplies bold, italic, purple text, and shadow. Returning `HighlightStyle()` for strings restores the default foreground and regular font and removes the shadow. The individual callbacks retain the enclosing style. +- `font-weight-compatibility.png`: Tomorrow colors with JavaScript captures. Keywords and functions use a semibold font (weight 600) through both APIs, demonstrating compatibility with existing custom-font callbacks. + +The sample code, fonts, and view layout are identical between each pair of panels. The screenshots were captured directly from Simulator without compositing or retouching. diff --git a/Assets/HighlightStyles/font-weight-compatibility.png b/Assets/HighlightStyles/font-weight-compatibility.png new file mode 100644 index 00000000..bedee390 Binary files /dev/null and b/Assets/HighlightStyles/font-weight-compatibility.png differ diff --git a/Assets/HighlightStyles/nested-style-reset.png b/Assets/HighlightStyles/nested-style-reset.png new file mode 100644 index 00000000..831295d3 Binary files /dev/null and b/Assets/HighlightStyles/nested-style-reset.png differ diff --git a/Assets/HighlightStyles/token-backgrounds.png b/Assets/HighlightStyles/token-backgrounds.png new file mode 100644 index 00000000..30cfafd4 Binary files /dev/null and b/Assets/HighlightStyles/token-backgrounds.png differ diff --git a/Sources/Runestone/Documentation.docc/CreatingATheme.md b/Sources/Runestone/Documentation.docc/CreatingATheme.md index a7d1541c..6d24a9bb 100644 --- a/Sources/Runestone/Documentation.docc/CreatingATheme.md +++ b/Sources/Runestone/Documentation.docc/CreatingATheme.md @@ -75,3 +75,36 @@ The list below contains highlight names that are commonly used across a suite of - `type.builtin` - `variable` - `variable.builtin` + + +## Complete Highlight Styles + +Implement ``Theme/highlightStyle(for:)`` when a capture should specify its complete +appearance instead of adding individual attributes to enclosing captures: + +```swift +func highlightStyle(for highlightName: String) -> HighlightStyle? { + switch highlightName { + case "comment": + return HighlightStyle(textColor: .secondaryLabel, + font: .italicSystemFont(ofSize: font.pointSize)) + case "comment.special": + return HighlightStyle(backgroundColor: .systemYellow) + default: + return nil + } +} +``` + +Here `comment.special` uses the theme's default foreground and font, clears any +inherited shadow, and adds a yellow background. A supplied font preserves its exact +weight and traits. `HighlightStyle()` restores the default text appearance, including +clearing a token background and shadow. + +Returning `nil` preserves the existing individual callbacks for that capture, including +resolving its font from `font(for:)` or the theme's default font. It does not inherit a +complete outer capture's custom font. Existing themes need no changes because the +protocol supplies this default implementation. +The highlight names, query configuration, and capture ordering remain unchanged. +Backgrounds are painted behind search highlights, marked text, and glyphs. A +`StringSyntaxHighlighter` result carries them as `.backgroundColor` attributes. diff --git a/Sources/Runestone/TextView/Appearance/HighlightStyle.swift b/Sources/Runestone/TextView/Appearance/HighlightStyle.swift new file mode 100644 index 00000000..f99f58db --- /dev/null +++ b/Sources/Runestone/TextView/Appearance/HighlightStyle.swift @@ -0,0 +1,29 @@ +import UIKit + +/// A complete syntax highlight style applied to a captured range. +/// +/// Unlike the individual styling callbacks on ``Theme``, a complete style replaces +/// styling from enclosing captures. This allows an inner capture to restore a regular +/// font or clear a background or shadow. It does not change unrelated attributes such +/// as paragraph layout or kerning. +public struct HighlightStyle { + /// Foreground color, or `nil` to use ``Theme/textColor``. + public var textColor: UIColor? + /// Font including its weight and traits, or `nil` to use ``Theme/font``. + public var font: UIFont? + /// Token background color, or `nil` to clear the background. + public var backgroundColor: UIColor? + /// Text shadow, or `nil` to clear the shadow. + public var shadow: NSShadow? + + /// Creates a complete style. An empty style restores the theme's default text appearance. + public init(textColor: UIColor? = nil, + font: UIFont? = nil, + backgroundColor: UIColor? = nil, + shadow: NSShadow? = nil) { + self.textColor = textColor + self.font = font + self.backgroundColor = backgroundColor + self.shadow = shadow + } +} diff --git a/Sources/Runestone/TextView/Appearance/Theme.swift b/Sources/Runestone/TextView/Appearance/Theme.swift index e81301fa..cf374f60 100644 --- a/Sources/Runestone/TextView/Appearance/Theme.swift +++ b/Sources/Runestone/TextView/Appearance/Theme.swift @@ -35,6 +35,15 @@ public protocol Theme: AnyObject { /// Corner radius of the background of marked text. Text will be marked when writing certain languages, for example Chinese and Japanese. /// A value of zero or less means that the background will not have rounded corners. Defaults to 0. var markedTextBackgroundCornerRadius: CGFloat { get } + /// Complete style for text matching a highlight name. + /// + /// Return a style to replace the foreground color, font, background color, and shadow + /// inherited from enclosing captures. A missing color or font uses the theme's defaults; + /// a missing background or shadow removes that attribute. The font includes all its traits. + /// + /// Return `nil` to use the existing individual styling callbacks for this capture. + /// The default implementation returns `nil`. + func highlightStyle(for highlightName: String) -> HighlightStyle? /// Color of text matching the capture sequence. /// /// See for more information on higlight names. @@ -65,6 +74,10 @@ public protocol Theme: AnyObject { } public extension Theme { + func highlightStyle(for highlightName: String) -> HighlightStyle? { + nil + } + var gutterHairlineWidth: CGFloat { hairlineLength } diff --git a/Sources/Runestone/TextView/LineController/LineFragmentRenderer.swift b/Sources/Runestone/TextView/LineController/LineFragmentRenderer.swift index ad166537..3b4ac53c 100644 --- a/Sources/Runestone/TextView/LineController/LineFragmentRenderer.swift +++ b/Sources/Runestone/TextView/LineController/LineFragmentRenderer.swift @@ -33,6 +33,7 @@ final class LineFragmentRenderer { } func draw(to context: CGContext, inCanvasOfSize canvasSize: CGSize) { + drawTokenBackgrounds(to: context) drawHighlightedRanges(to: context, inCanvasOfSize: canvasSize) drawMarkedRange(to: context) drawInvisibleCharacters() @@ -98,6 +99,41 @@ final class LineFragmentRenderer { } private extension LineFragmentRenderer { + private func drawTokenBackgrounds(to context: CGContext) { + // Paint token backgrounds separately from glyphs to keep overlays visible. + // Runs are visual segments, so their glyph positions also handle mixed writing + // directions and avoid spanning unrelated text across a bidirectional boundary. + context.saveGState() + defer { context.restoreGState() } + for run in CTLineGetGlyphRuns(lineFragment.line) as? [CTRun] ?? [] { + let attributes = CTRunGetAttributes(run) as NSDictionary + guard let color = attributes[NSAttributedString.Key.tokenBackgroundColor] as? UIColor else { + continue + } + let count = CTRunGetGlyphCount(run) + guard count > 0 else { + continue + } + var positions = [CGPoint](repeating: .zero, count: count) + var advances = [CGSize](repeating: .zero, count: count) + CTRunGetPositions(run, CFRange(location: 0, length: 0), &positions) + CTRunGetAdvances(run, CFRange(location: 0, length: 0), &advances) + var minX = CGFloat.greatestFiniteMagnitude + var maxX = -CGFloat.greatestFiniteMagnitude + for index in 0 ..< count { + let start = positions[index].x + let end = start + advances[index].width + minX = min(minX, min(start, end)) + maxX = max(maxX, max(start, end)) + } + guard maxX > minX else { + continue + } + context.setFillColor(color.cgColor) + context.fill(CGRect(x: minX, y: 0, width: maxX - minX, height: lineFragment.scaledSize.height)) + } + } + private func horizontalBounds(of run: CTRun) -> ClosedRange? { let count = CTRunGetGlyphCount(run) guard count > 0 else { diff --git a/Sources/Runestone/TextView/LineController/LineSyntaxHighlighter.swift b/Sources/Runestone/TextView/LineController/LineSyntaxHighlighter.swift index 8f031985..6f3b39c3 100644 --- a/Sources/Runestone/TextView/LineController/LineSyntaxHighlighter.swift +++ b/Sources/Runestone/TextView/LineController/LineSyntaxHighlighter.swift @@ -4,6 +4,7 @@ import Foundation extension NSAttributedString.Key { static let isBold = NSAttributedString.Key("runestone_isBold") static let isItalic = NSAttributedString.Key("runestone_isItalic") + static let tokenBackgroundColor = NSAttributedString.Key("runestone_tokenBackgroundColor") } struct LineSyntaxHiglighterSetAttributesResult { diff --git a/Sources/Runestone/TextView/LineController/LineTypesetter.swift b/Sources/Runestone/TextView/LineController/LineTypesetter.swift index 56a79d7f..cb2e9019 100644 --- a/Sources/Runestone/TextView/LineController/LineTypesetter.swift +++ b/Sources/Runestone/TextView/LineController/LineTypesetter.swift @@ -79,7 +79,7 @@ final class LineTypesetter { func prepareToTypeset(_ attributedString: NSAttributedString) { self.attributedString = attributedString stringLength = CFAttributedStringGetLength(attributedString) - typesetter = CTTypesetterCreateWithAttributedString(attributedString) + typesetter = CTTypesetterCreateWithAttributedString(stringForTypesetting(attributedString)) } @discardableResult @@ -112,6 +112,24 @@ final class LineTypesetter { } private extension LineTypesetter { + private func stringForTypesetting(_ string: NSAttributedString) -> NSAttributedString { + // Keep token backgrounds out of CTLineDraw so they cannot cover search or + // marked-text highlights. The renderer paints these backgrounds first. + // Preserve the original attributes for clients of StringSyntaxHighlighter. + var copy: NSMutableAttributedString? + string.enumerateAttribute(.backgroundColor, in: NSRange(location: 0, length: string.length)) { value, range, _ in + guard let value else { + return + } + if copy == nil { + copy = NSMutableAttributedString(attributedString: string) + } + copy?.removeAttribute(.backgroundColor, range: range) + copy?.addAttribute(.tokenBackgroundColor, value: value, range: range) + } + return copy ?? string + } + private func updateState(from typesetResult: TypesetResult) { lineFragments.append(contentsOf: typesetResult.lineFragments) for (id, index) in typesetResult.lineFragmentsMap { diff --git a/Sources/Runestone/TextView/SyntaxHighlighting/Internal/TreeSitter/TreeSitterSyntaxHighlightToken.swift b/Sources/Runestone/TextView/SyntaxHighlighting/Internal/TreeSitter/TreeSitterSyntaxHighlightToken.swift index 64cd6cde..10d55e7e 100644 --- a/Sources/Runestone/TextView/SyntaxHighlighting/Internal/TreeSitter/TreeSitterSyntaxHighlightToken.swift +++ b/Sources/Runestone/TextView/SyntaxHighlighting/Internal/TreeSitter/TreeSitterSyntaxHighlightToken.swift @@ -1,16 +1,18 @@ import UIKit final class TreeSitterSyntaxHighlightToken { + let completeStyle: HighlightStyle? let range: NSRange let textColor: UIColor? let shadow: NSShadow? let font: UIFont? let fontTraits: FontTraits var isEmpty: Bool { - range.length == 0 || (textColor == nil && font == nil && shadow == nil) + range.length == 0 || (completeStyle == nil && textColor == nil && font == nil && shadow == nil && fontTraits.isEmpty) } - init(range: NSRange, textColor: UIColor?, shadow: NSShadow?, font: UIFont?, fontTraits: FontTraits) { + init(range: NSRange, textColor: UIColor?, shadow: NSShadow?, font: UIFont?, fontTraits: FontTraits, completeStyle: HighlightStyle? = nil) { + self.completeStyle = completeStyle self.range = range self.textColor = textColor self.shadow = shadow diff --git a/Sources/Runestone/TextView/SyntaxHighlighting/Internal/TreeSitter/TreeSitterSyntaxHighlighter.swift b/Sources/Runestone/TextView/SyntaxHighlighting/Internal/TreeSitter/TreeSitterSyntaxHighlighter.swift index c67d8e07..31dfd3e6 100644 --- a/Sources/Runestone/TextView/SyntaxHighlighting/Internal/TreeSitter/TreeSitterSyntaxHighlighter.swift +++ b/Sources/Runestone/TextView/SyntaxHighlighting/Internal/TreeSitter/TreeSitterSyntaxHighlighter.swift @@ -84,44 +84,73 @@ private extension TreeSitterSyntaxHighlighter { private func setAttributes(for tokens: [TreeSitterSyntaxHighlightToken], on attributedString: NSMutableAttributedString) { attributedString.beginEditing() for token in tokens { - var attributes: [NSAttributedString.Key: Any] = [:] - if let foregroundColor = token.textColor { - attributes[.foregroundColor] = foregroundColor - } - if let shadow = token.shadow { - attributes[.shadow] = shadow - } - if token.fontTraits.contains(.bold) { - attributedString.addAttribute(.isBold, value: true, range: token.range) - } - if token.fontTraits.contains(.italic) { - attributedString.addAttribute(.isItalic, value: true, range: token.range) - } - var symbolicTraits: UIFontDescriptor.SymbolicTraits = [] - if let isBold = attributedString.attribute(.isBold, at: token.range.location, effectiveRange: nil) as? Bool, isBold { - symbolicTraits.insert(.traitBold) - } - if let isItalic = attributedString.attribute(.isItalic, at: token.range.location, effectiveRange: nil) as? Bool, isItalic { - symbolicTraits.insert(.traitItalic) - } - let currentFont = attributedString.attribute(.font, at: token.range.location, effectiveRange: nil) as? UIFont - let baseFont = token.font ?? theme.font - let newFont: UIFont - if !symbolicTraits.isEmpty { - newFont = baseFont.withSymbolicTraits(symbolicTraits) ?? baseFont - } else { - newFont = baseFont - } - if newFont != currentFont { - attributes[.font] = newFont - } - if !attributes.isEmpty { - attributedString.addAttributes(attributes, range: token.range) + if let style = token.completeStyle { + apply(style, to: attributedString, in: token.range) + continue } + applyIndividualAttributes(for: token, to: attributedString) } attributedString.endEditing() } + private func applyIndividualAttributes(for token: TreeSitterSyntaxHighlightToken, to attributedString: NSMutableAttributedString) { + var attributes: [NSAttributedString.Key: Any] = [:] + if let foregroundColor = token.textColor { + attributes[.foregroundColor] = foregroundColor + } + if let shadow = token.shadow { + attributes[.shadow] = shadow + } + if token.fontTraits.contains(.bold) { + attributedString.addAttribute(.isBold, value: true, range: token.range) + } + if token.fontTraits.contains(.italic) { + attributedString.addAttribute(.isItalic, value: true, range: token.range) + } + var symbolicTraits: UIFontDescriptor.SymbolicTraits = [] + if let isBold = attributedString.attribute(.isBold, at: token.range.location, effectiveRange: nil) as? Bool, isBold { + symbolicTraits.insert(.traitBold) + } + if let isItalic = attributedString.attribute(.isItalic, at: token.range.location, effectiveRange: nil) as? Bool, isItalic { + symbolicTraits.insert(.traitItalic) + } + let currentFont = attributedString.attribute(.font, at: token.range.location, effectiveRange: nil) as? UIFont + let baseFont = token.font ?? theme.font + let newFont: UIFont + if !symbolicTraits.isEmpty { + newFont = baseFont.withSymbolicTraits(symbolicTraits) ?? baseFont + } else { + newFont = baseFont + } + if newFont != currentFont { + attributes[.font] = newFont + } + if !attributes.isEmpty { + attributedString.addAttributes(attributes, range: token.range) + } + } + + private func apply(_ style: HighlightStyle, to attributedString: NSMutableAttributedString, in range: NSRange) { + // Complete styles must also reset our bookkeeping for subsequent captures that + // use the individual callbacks, otherwise an outer trait can reappear later. + let font = style.font ?? theme.font + let traits = font.fontDescriptor.symbolicTraits + attributedString.addAttributes([ + .foregroundColor: style.textColor ?? theme.textColor, + .font: font, + .isBold: traits.contains(.traitBold), + .isItalic: traits.contains(.traitItalic) + ], range: range) + attributedString.removeAttribute(.backgroundColor, range: range) + attributedString.removeAttribute(.shadow, range: range) + if let backgroundColor = style.backgroundColor { + attributedString.addAttribute(.backgroundColor, value: backgroundColor, range: range) + } + if let shadow = style.shadow { + attributedString.addAttribute(.shadow, value: shadow, range: range) + } + } + private func tokens(for captures: [TreeSitterCapture], localTo localRange: ByteRange) -> [TreeSitterSyntaxHighlightToken] { var tokens: [TreeSitterSyntaxHighlightToken] = [] for capture in captures where capture.byteRange.overlaps(localRange) { @@ -146,6 +175,10 @@ private extension TreeSitterSyntaxHighlighter { private extension TreeSitterSyntaxHighlighter { private func token(from capture: TreeSitterCapture, in byteRange: ByteRange) -> TreeSitterSyntaxHighlightToken { let range = NSRange(byteRange) + if let style = theme.highlightStyle(for: capture.name) { + return TreeSitterSyntaxHighlightToken( + range: range, textColor: nil, shadow: nil, font: nil, fontTraits: [], completeStyle: style) + } let textColor = theme.textColor(for: capture.name) let shadow = theme.shadow(for: capture.name) let font = theme.font(for: capture.name) diff --git a/Tests/RunestoneTests/CompleteHighlightStyleTests.swift b/Tests/RunestoneTests/CompleteHighlightStyleTests.swift new file mode 100644 index 00000000..2d5d5d79 --- /dev/null +++ b/Tests/RunestoneTests/CompleteHighlightStyleTests.swift @@ -0,0 +1,202 @@ +@testable import Runestone +import TestTreeSitterLanguages +import UIKit +import XCTest + +final class CompleteHighlightStyleTests: XCTestCase { + func testCompleteStyleRestoresDefaultsWithinEnclosingCaptureAndPreservesWeight() throws { + let theme = CompleteCaptureTheme() + let outerDescriptor = try XCTUnwrap(theme.font.fontDescriptor.withSymbolicTraits([.traitBold, .traitItalic])) + let outerFont = UIFont(descriptor: outerDescriptor, size: 17) + let preciseFont = UIFont.monospacedSystemFont(ofSize: 17, weight: .semibold) + let shadow = NSShadow() + shadow.shadowBlurRadius = 3 + theme.styles = [ + "container": HighlightStyle(textColor: .red, font: outerFont, backgroundColor: .yellow, shadow: shadow), + "string": HighlightStyle(), + "number": HighlightStyle(font: preciseFont) + ] + let highlighter = StringSyntaxHighlighter(theme: theme, language: jsonLanguage()) + let text = "{\"Grøn 😀\": 42}" + let output = highlighter.syntaxHighlight(text) + XCTAssertEqual(output.string, text) + let source = text as NSString + XCTAssertEqual(output.attribute(.font, at: 0, effectiveRange: nil) as? UIFont, outerFont) + XCTAssertEqual(output.attribute(.backgroundColor, at: 0, effectiveRange: nil) as? UIColor, .yellow) + for range in [source.range(of: "\"Grøn 😀\""), source.range(of: "42")] { + output.enumerateAttributes(in: range) { attributes, _, _ in + XCTAssertEqual(attributes[.foregroundColor] as? UIColor, theme.textColor) + XCTAssertNil(attributes[.backgroundColor]) + XCTAssertNil(attributes[.shadow]) + XCTAssertEqual(attributes[.isItalic] as? Bool, false) + } + } + XCTAssertEqual(output.attribute(.font, at: source.range(of: "Grøn").location, effectiveRange: nil) as? UIFont, theme.font) + XCTAssertEqual(output.attribute(.isBold, at: source.range(of: "Grøn").location, effectiveRange: nil) as? Bool, false) + XCTAssertEqual(output.attribute(.font, at: source.range(of: "42").location, effectiveRange: nil) as? UIFont, preciseFont) + XCTAssertEqual(theme.individualCallbackCount, 0, "Complete styles must not also invoke the individual callbacks.") + } + + func testFontOnlyCompleteStyleIsAppliedWithoutForeground() { + let theme = CompleteCaptureTheme() + let font = UIFont.italicSystemFont(ofSize: 21) + theme.styles = ["string": HighlightStyle(font: font)] + let result = StringSyntaxHighlighter(theme: theme, language: jsonLanguage()).syntaxHighlight("\"text\"") + XCTAssertEqual(result.attribute(.font, at: 1, effectiveRange: nil) as? UIFont, font) + XCTAssertEqual(result.attribute(.foregroundColor, at: 1, effectiveRange: nil) as? UIColor, theme.textColor) + } + + func testExistingThemeUsesIndividualCallbacksIncludingTraitsOnlyCaptures() throws { + let theme = ExistingCaptureTheme() + let erasedTheme: Theme = theme + XCTAssertNil(erasedTheme.highlightStyle(for: "string")) + let result = StringSyntaxHighlighter(theme: theme, language: jsonLanguage()).syntaxHighlight("[\"text\", 42]") + let stringFont = try XCTUnwrap(result.attribute(.font, at: 2, effectiveRange: nil) as? UIFont) + XCTAssertTrue(stringFont.fontDescriptor.symbolicTraits.contains(.traitItalic)) + XCTAssertEqual(result.attribute(.foregroundColor, at: 2, effectiveRange: nil) as? UIColor, theme.textColor) + XCTAssertEqual(result.attribute(.foregroundColor, at: 9, effectiveRange: nil) as? UIColor, .blue) + } + + func testNilCompleteStyleFallsBackForThatCapture() { + let theme = CompleteCaptureTheme() + theme.styles = ["string": HighlightStyle(textColor: .red)] + let result = StringSyntaxHighlighter(theme: theme, language: jsonLanguage()).syntaxHighlight("[\"text\", 42]") + XCTAssertEqual(result.attribute(.foregroundColor, at: 2, effectiveRange: nil) as? UIColor, .red) + XCTAssertEqual(result.attribute(.foregroundColor, at: 9, effectiveRange: nil) as? UIColor, .blue) + XCTAssertGreaterThan(theme.individualCallbackCount, 0) + } + + func testSpecializedCaptureResetsCompleteStyleOnSameRange() { + let theme = CompleteCaptureTheme() + theme.styles = [ + "string": HighlightStyle(textColor: .red, font: .italicSystemFont(ofSize: 17), backgroundColor: .yellow), + "string.special": HighlightStyle() + ] + let language = TreeSitterLanguage(tree_sitter_json(), highlightsQuery: .init(string: """ + (string) @string.special + (string) @string + """)) + let result = StringSyntaxHighlighter(theme: theme, language: language).syntaxHighlight("\"text\"") + XCTAssertEqual(result.attribute(.foregroundColor, at: 1, effectiveRange: nil) as? UIColor, theme.textColor) + XCTAssertEqual(result.attribute(.font, at: 1, effectiveRange: nil) as? UIFont, theme.font) + XCTAssertNil(result.attribute(.backgroundColor, at: 1, effectiveRange: nil)) + } + + func testMultilineCaptureIsClippedAndThemeSwitchDoesNotRetainBackground() { + let theme = CompleteCaptureTheme() + theme.styles = ["container": HighlightStyle(backgroundColor: .yellow), "string": HighlightStyle()] + let text = "{\n\"key\": 42\n}" + let language = jsonLanguage() + let mode = LanguageModeFactory.languageMode(language: language, text: text) + mode.parse(text as NSString) + let highlighter = mode.createLineSyntaxHighlighter() + highlighter.theme = theme + let localText = "\"key\": 42" + let attributes: [NSAttributedString.Key: Any] = [.font: theme.font, .foregroundColor: theme.textColor] + let local = NSMutableAttributedString(string: localText, attributes: attributes) + highlighter.syntaxHighlight(LineSyntaxHighlighterInput( + attributedString: local, + byteRange: ByteRange(utf16Range: NSRange(location: 2, length: local.length)))) + XCTAssertNil(local.attribute(.backgroundColor, at: 1, effectiveRange: nil)) + XCTAssertEqual(local.attribute(.backgroundColor, at: 5, effectiveRange: nil) as? UIColor, .yellow) + highlighter.theme = ExistingCaptureTheme() + local.setAttributes(attributes, range: NSRange(location: 0, length: local.length)) + highlighter.syntaxHighlight(LineSyntaxHighlighterInput( + attributedString: local, + byteRange: ByteRange(utf16Range: NSRange(location: 2, length: local.length)))) + XCTAssertNil(local.attribute(.backgroundColor, at: 5, effectiveRange: nil)) + } + + func testCancelledHighlightCannotApplyPreviousThemeAfterSwitch() { + let text = "\"text\"" + let stringView = StringView(string: text) + let lineManager = LineManager(stringView: stringView) + lineManager.rebuild() + let mode = TreeSitterInternalLanguageMode( + language: jsonLanguage().internalLanguage, languageProvider: nil, + stringView: stringView, lineManager: lineManager) + mode.parse(text as NSString) + let queue = OperationQueue() + queue.isSuspended = true + let highlighter = TreeSitterSyntaxHighlighter(stringView: stringView, languageMode: mode, operationQueue: queue) + let previousTheme = CompleteCaptureTheme() + previousTheme.styles = ["string": HighlightStyle(textColor: .red, backgroundColor: .yellow)] + highlighter.theme = previousTheme + let attributes: [NSAttributedString.Key: Any] = [.foregroundColor: UIColor.black, .font: previousTheme.font] + let cancelledString = NSMutableAttributedString(string: text, attributes: attributes) + let newString = NSMutableAttributedString(string: text, attributes: attributes) + let range = ByteRange(utf16Range: NSRange(location: 0, length: cancelledString.length)) + highlighter.syntaxHighlight(LineSyntaxHighlighterInput(attributedString: cancelledString, byteRange: range)) { result in + if case .success = result { + XCTFail("A cancelled operation must not apply its attributes.") + } + } + highlighter.cancel() + let nextTheme = CompleteCaptureTheme() + nextTheme.styles = ["string": HighlightStyle(textColor: .blue)] + highlighter.theme = nextTheme + let applied = expectation(description: "Next theme is applied") + highlighter.syntaxHighlight(LineSyntaxHighlighterInput(attributedString: newString, byteRange: range)) { result in + if case .failure(let error) = result { + XCTFail("Unexpected highlighting failure: \(error)") + } + applied.fulfill() + } + queue.isSuspended = false + wait(for: [applied], timeout: 2) + XCTAssertEqual(cancelledString.attribute(.foregroundColor, at: 1, effectiveRange: nil) as? UIColor, .black) + XCTAssertNil(cancelledString.attribute(.backgroundColor, at: 1, effectiveRange: nil)) + XCTAssertEqual(newString.attribute(.foregroundColor, at: 1, effectiveRange: nil) as? UIColor, .blue) + XCTAssertNil(newString.attribute(.backgroundColor, at: 1, effectiveRange: nil)) + } + + private func jsonLanguage() -> TreeSitterLanguage { + TreeSitterLanguage(tree_sitter_json(), highlightsQuery: .init(string: """ + (object) @container + (string) @string + (number) @number + """)) + } +} + +private protocol CaptureThemeDefaults: Theme {} + +private extension CaptureThemeDefaults { + var font: UIFont { .monospacedSystemFont(ofSize: 17, weight: .regular) } + var textColor: UIColor { .black } + var gutterBackgroundColor: UIColor { .white } + var gutterHairlineColor: UIColor { .gray } + var lineNumberColor: UIColor { .gray } + var lineNumberFont: UIFont { font } + var selectedLineBackgroundColor: UIColor { .clear } + var selectedLinesLineNumberColor: UIColor { .black } + var selectedLinesGutterBackgroundColor: UIColor { .white } + var invisibleCharactersColor: UIColor { .gray } + var pageGuideHairlineColor: UIColor { .gray } + var pageGuideBackgroundColor: UIColor { .white } + var markedTextBackgroundColor: UIColor { .yellow } +} + +private final class ExistingCaptureTheme: CaptureThemeDefaults { + func textColor(for highlightName: String) -> UIColor? { + highlightName == "number" ? .blue : nil + } + + func fontTraits(for highlightName: String) -> FontTraits { + highlightName == "string" ? .italic : [] + } +} + +private final class CompleteCaptureTheme: CaptureThemeDefaults { + var styles: [String: HighlightStyle] = [:] + var individualCallbackCount = 0 + + func highlightStyle(for highlightName: String) -> HighlightStyle? { + styles[highlightName] + } + + func textColor(for highlightName: String) -> UIColor? { + individualCallbackCount += 1 + return highlightName == "number" ? .blue : nil + } +} diff --git a/Tests/RunestoneTests/HighlightBackgroundRenderingTests.swift b/Tests/RunestoneTests/HighlightBackgroundRenderingTests.swift new file mode 100644 index 00000000..57b93654 --- /dev/null +++ b/Tests/RunestoneTests/HighlightBackgroundRenderingTests.swift @@ -0,0 +1,116 @@ +import CoreText +@testable import Runestone +import UIKit +import XCTest + +final class HighlightBackgroundRenderingTests: XCTestCase { + func testTokenBackgroundPaintsWrappedMixedDirectionTextAndComposedCharacters() throws { + let text = "previous line abc אבג cafe\u{301} 😀 tail" as NSString + let attributed = NSMutableAttributedString(string: text as String, attributes: [ + .font: UIFont.monospacedSystemFont(ofSize: 18, weight: .regular), + .foregroundColor: UIColor.clear + ]) + for word in ["אבג", "cafe\u{301}", "😀"] { + attributed.addAttribute(.backgroundColor, value: UIColor.red, range: text.range(of: word)) + } + let fragments = makeFragments(attributed, width: 220) + XCTAssertGreaterThan(fragments.count, 1) + var checkedWords: Set = [] + for fragment in fragments { + let renderer = LineFragmentRenderer(lineFragment: fragment, invisibleCharacterConfiguration: .init()) + let bitmap = try draw(renderer) + // Sample interiors, away from run edges and antialiasing. Offsets remain + // relative to the original string in subsequent wrapped fragments. + for word in ["אבג", "cafe\u{301}", "😀", "abc", "tail"] { + let range = text.range(of: word) + guard range.location >= fragment.visibleRange.location, + range.upperBound <= fragment.visibleRange.upperBound else { + continue + } + let first = CTLineGetOffsetForStringIndex(fragment.line, range.location, nil) + let last = CTLineGetOffsetForStringIndex(fragment.line, range.upperBound, nil) + let expected: [UInt8] = ["abc", "tail"].contains(word) ? [255, 255, 255, 255] : [255, 0, 0, 255] + XCTAssertEqual(bitmap.pixel(x: Int((first + last) / 2), y: 2), expected, word) + checkedWords.insert(word) + } + XCTAssertEqual(bitmap.pixel(x: Int(ceil(fragment.baseSize.width)) + 3, y: 2), [255, 255, 255, 255]) + } + XCTAssertEqual(checkedWords, ["אבג", "cafe\u{301}", "😀", "abc", "tail"]) + } + + func testSearchAndMarkedHighlightsPaintAboveTokenBackground() throws { + let attributed = NSAttributedString(string: "abcdef", attributes: [ + .font: UIFont.monospacedSystemFont(ofSize: 18, weight: .regular), + .foregroundColor: UIColor.clear, + .backgroundColor: UIColor.red + ]) + let fragment = try XCTUnwrap(makeFragments(attributed, width: 500).first) + let renderer = LineFragmentRenderer(lineFragment: fragment, invisibleCharacterConfiguration: .init()) + renderer.highlightedRangeFragments = [HighlightedRangeFragment(range: NSRange(location: 2, length: 2), + containsStart: true, containsEnd: true, + color: .green, cornerRadius: 0)] + renderer.markedRange = NSRange(location: 4, length: 2) + renderer.markedTextBackgroundColor = .blue + let bitmap = try draw(renderer) + for (index, expected): (Int, [UInt8]) in [(1, [255, 0, 0, 255]), (3, [0, 255, 0, 255]), (5, [0, 0, 255, 255])] { + let x = CTLineGetOffsetForStringIndex(fragment.line, index, nil) + 2 + XCTAssertEqual(bitmap.pixel(x: Int(x), y: 2), expected) + } + } + + func testSpellingUnderlinesRemainVisibleOverTokenBackground() throws { + let attributed = NSAttributedString(string: "mispelled", attributes: [ + .font: UIFont.monospacedSystemFont(ofSize: 18, weight: .regular), + .foregroundColor: UIColor.clear, + .backgroundColor: UIColor.yellow + ]) + let fragment = try XCTUnwrap(makeFragments(attributed, width: 500).first) + let renderer = LineFragmentRenderer(lineFragment: fragment, invisibleCharacterConfiguration: .init()) + renderer.misspelledRanges = [NSRange(location: 0, length: attributed.length)] + let bitmap = try draw(renderer) + // At 1x the narrow dotted stroke blends with yellow; require a visible + // red-dominant pixel rather than an unblended system-red pixel. + let hasUnderline = stride(from: 0, to: bitmap.bytes.count, by: 4).contains { offset in + let red = Int(bitmap.bytes[offset]) + return red > 200 && red - Int(bitmap.bytes[offset + 1]) > 64 && red - Int(bitmap.bytes[offset + 2]) > 64 + } + XCTAssertTrue(hasUnderline, "The spelling underline must paint above the opaque token background.") + } + + private func makeFragments(_ string: NSAttributedString, width: CGFloat) -> [LineFragment] { + let typesetter = LineTypesetter(lineID: "test") + typesetter.constrainingWidth = width + typesetter.prepareToTypeset(string) + return typesetter.typesetLineFragments(toLocation: string.length) + } + + private func draw(_ renderer: LineFragmentRenderer) throws -> Bitmap { + let width = Int(ceil(renderer.lineFragment.baseSize.width)) + 8 + let height = Int(ceil(renderer.lineFragment.scaledSize.height)) + let context = try XCTUnwrap(CGContext(data: nil, width: width, height: height, bitsPerComponent: 8, + bytesPerRow: width * 4, space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue | CGBitmapInfo.byteOrder32Big.rawValue)) + context.setFillColor(UIColor.white.cgColor) + context.fill(CGRect(x: 0, y: 0, width: width, height: height)) + context.translateBy(x: 0, y: CGFloat(height)) + context.scaleBy(x: 1, y: -1) + renderer.draw(to: context, inCanvasOfSize: CGSize(width: width, height: height)) + let data = try XCTUnwrap(context.data) + let bytes = Array(UnsafeBufferPointer(start: data.assumingMemoryBound(to: UInt8.self), count: width * height * 4)) + return Bitmap(bytes: bytes, width: width, height: height) + } +} + +private struct Bitmap { + let bytes: [UInt8] + let width: Int + let height: Int + + func pixel(x: Int, y: Int) -> [UInt8] { + guard (0 ..< width).contains(x), (0 ..< height).contains(y) else { + return [] + } + let offset = ((height - 1 - y) * width + x) * 4 + return Array(bytes[offset ..< offset + 4]) + } +}