diff --git a/QuickMD/CHANGELOG.md b/QuickMD/CHANGELOG.md index e24e91c..79d4f8a 100644 --- a/QuickMD/CHANGELOG.md +++ b/QuickMD/CHANGELOG.md @@ -12,12 +12,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Select text inside tables:** a word, a whole cell (triple-click) or any range of cells, starting or ending anywhere in the document. A selection inside one cell copies as plain text, across cells as tab-separated values; a whole table copies exactly as before. Asked for by [@sporto](https://github.com/sporto) (#33). ### Changed +- Table headers are semibold, so the first row reads as a header in light themes too. The table view always asked for it, but the cells' own font won and headers drew in the regular weight. - Search inside tables counts what the table shows: a term that appears only in Markdown markup or in a link's address is no longer counted (it was counted but never highlighted), and stepping with ⌘G follows the cells in reading order. ### Fixed - Full screen and Split View: opening another file adds a tab in place. Before, the tab group moved to a full-screen Space of its own, leaving the Split View broken, half the screen black and no menu bar. A full-screen window size is also no longer remembered as the size for new windows. Contributed by [@onemannoplan](https://github.com/onemannoplan) (#36). - The Support pill (GitHub build) no longer keeps a blue focus ring after a click when keyboard navigation is on in System Settings. - Search highlights no longer shift after letters that change length when lowercased (such as the Turkish dotted capital İ). +- A heading's copy-section button sits right after the heading text again, as before 1.11 (it had moved to the far right of the column), and shows the arrow cursor. ## [1.12.0] - 2026-10-05 diff --git a/QuickMD/QuickMD/BlockHeightMeasurer.swift b/QuickMD/QuickMD/BlockHeightMeasurer.swift index ccebe51..0d3b5b0 100644 --- a/QuickMD/QuickMD/BlockHeightMeasurer.swift +++ b/QuickMD/QuickMD/BlockHeightMeasurer.swift @@ -234,6 +234,99 @@ enum BlockLayout { /// title at the same reduced width. static let copyButtonReservedWidth: CGFloat = copyButtonSpacing + copyButtonWidth + // MARK: Horizontal placement of the copy button + + /// The width the title wraps at inside a heading row `rowWidth` wide — + /// the row minus the reserved strip, as the title's padding gives it. + static func titleWrapWidth(rowWidth: CGFloat) -> CGFloat { + BlockLayout.clampedWidth(rowWidth - copyButtonReservedWidth) + } + + /// Leading edge of the copy button, from the row's leading edge. + /// + /// v1.10's `HStack { Text(title); button; Spacer }` put the button + /// `copyButtonSpacing` after the title's widest laid-out line; the + /// overlay reproduces that. Clamped to the row's trailing edge (and to + /// zero for a row narrower than the button), so a title that fills its + /// line lands the button where it sat in v1.11–1.13. The reserved strip + /// keeps the title off the button in every case. + static func copyButtonLeading(titleUsedWidth: CGFloat, rowWidth: CGFloat) -> CGFloat { + max(0, min(titleUsedWidth + copyButtonSpacing, rowWidth - copyButtonWidth)) + } + + /// Width of the widest line TextKit 1 lays `title` out in at + /// `wrapWidth` — the same settings the title's `SelfSizingTextView` + /// uses (`lineFragmentPadding = 0`, unbounded height). + static func titleUsedWidth(_ title: NSAttributedString, wrapWidth: CGFloat) -> CGFloat { + let storage = NSTextStorage(attributedString: title) + let layoutManager = NSLayoutManager() + let container = NSTextContainer(size: NSSize(width: max(0, wrapWidth), + height: CGFloat.greatestFiniteMagnitude)) + container.lineFragmentPadding = 0 + container.widthTracksTextView = false + container.heightTracksTextView = false + layoutManager.addTextContainer(container) + storage.addLayoutManager(layoutManager) + layoutManager.ensureLayout(for: container) + var widest: CGFloat = 0 + layoutManager.enumerateLineFragments( + forGlyphRange: NSRange(location: 0, length: layoutManager.numberOfGlyphs) + ) { _, usedRect, _, _, _ in + widest = max(widest, usedRect.maxX) + } + return ceil(widest) + } + + /// The cache key for a heading title's layout: exactly the inputs that + /// decide its laid-out width — the title, the level, the zoom and the + /// document fonts. Colours (theme, light/dark) and the parse version do + /// not change a glyph's advance, so they stay out: a reload, a Source + /// Edit save or a theme switch keeps hitting the same entry. + static func titleLayoutKey(title: String, level: Int, fontScale: CGFloat, + fonts: DocumentFonts) -> String { + "\(level)|\(fontScale)|\(fonts.body ?? "")|\(fonts.code ?? "")|\(title)" + } + + /// `titleUsedWidth` behind two caches, for `HeadingBlockView.body`: a + /// hover toggle re-evaluates the body and must cost a lookup, and a + /// live-resize step must cost one TextKit layout — the converted title + /// is width-independent, so it is cached once per `key` + /// (`titleLayoutKey`) and only the used width is cached per wrap width. + /// `makeTitle` runs when the string is not cached. The cached string + /// may carry another theme's colours; it is only ever measured. NSCache + /// is thread-safe and evicts under memory pressure. + static func titleUsedWidth(cachedAs key: String, wrapWidth: CGFloat, + makeTitle: () -> NSAttributedString) -> CGFloat { + let widthKey = "\(wrapWidth)|\(key)" as NSString + if let hit = usedWidthCache.object(forKey: widthKey) { + return CGFloat(hit.doubleValue) + } + let titleKey = key as NSString + let title: NSAttributedString + if let cached = titleCache.object(forKey: titleKey) { + title = cached + } else { + title = makeTitle() + titleCache.setObject(title, forKey: titleKey) + } + let width = titleUsedWidth(title, wrapWidth: wrapWidth) + usedWidthCache.setObject(NSNumber(value: Double(width)), forKey: widthKey) + return width + } + + private static let titleCache: NSCache = { + let cache = NSCache() + cache.countLimit = 1024 + return cache + }() + + private static let usedWidthCache: NSCache = { + let cache = NSCache() + // A few hundred visible headings × a handful of widths per resize. + cache.countLimit = 2048 + return cache + }() + /// Vertical placement of a heading's title and its copy button. /// /// Until v1.11 the heading was `HStack(alignment: .firstTextBaseline) @@ -932,8 +1025,7 @@ enum BlockHeightMeasurer { let attributed = renderer.renderHeader(title, level: level) let ns = (try? NSAttributedString(attributed, including: \.appKit)) ?? NSAttributedString(string: title) - let textWidth = BlockLayout.clampedWidth(contentWidth - - BlockLayout.Heading.copyButtonReservedWidth) + let textWidth = BlockLayout.Heading.titleWrapWidth(rowWidth: contentWidth) return max(exactHeight(text: ns, width: textWidth), BlockLayout.Heading.copyButtonHeight) } diff --git a/QuickMD/QuickMD/TableTextBuilder.swift b/QuickMD/QuickMD/TableTextBuilder.swift index e3a2585..48fe4fc 100644 --- a/QuickMD/QuickMD/TableTextBuilder.swift +++ b/QuickMD/QuickMD/TableTextBuilder.swift @@ -168,9 +168,10 @@ final class FullWidthTextTable: NSTextTable { /// - header cells get `headerBackgroundColor`; the table's own background, the /// rounded clip and the outline are the hosting view's job. /// -/// Font: every run carries the renderer's explicit `cellFontSize`·scale font, -/// header included — that is what 1.12 drew (its `.font(13 [semibold])` -/// modifiers lost to the runs' own font). +/// Font: every run carries the renderer's explicit `cellFontSize`·scale font; +/// header cells are raised to semibold (v1.13, the user's call). 1.12's +/// SwiftUI view asked for a semibold header too, but its `.font` modifier lost +/// to the runs' own font, so headers drew regular until v1.13. /// /// Pure: value types plus objects it creates, no actor isolation — safe off-main /// (the measurer calls it from the parse task). @@ -211,6 +212,7 @@ enum TableTextBuilder { ranges.reserveCapacity(columnCount) for column in 0.. CGFloat { + let traits = font.fontDescriptor.object(forKey: .traits) as? [NSFontDescriptor.TraitKey: Any] + return (traits?[.weight] as? CGFloat) ?? NSFont.Weight.regular.rawValue + } + cell.enumerateAttribute(.font, in: NSRange(location: 0, length: cell.length)) { value, range, _ in + guard let font = value as? NSFont, weight(of: font) < semibold else { return } + var traits = font.fontDescriptor.object(forKey: .traits) as? [NSFontDescriptor.TraitKey: Any] ?? [:] + traits[.weight] = semibold + let descriptor = font.fontDescriptor.addingAttributes([.traits: traits]) + var raised = NSFont(descriptor: descriptor, size: font.pointSize) ?? font + if weight(of: raised) < semibold { + // Descriptor, not NSFontManager: build runs off-main (measurer). + let bold = font.fontDescriptor.withSymbolicTraits(font.fontDescriptor.symbolicTraits.union(.bold)) + raised = NSFont(descriptor: bold, size: font.pointSize) ?? font + } + cell.addAttribute(.font, value: raised, range: range) + } + } + /// The table's height in a laid-out container — the view's /// (`TableTextView.layoutHeight`) and the measurer's (`height(of:width:)`). /// diff --git a/QuickMD/QuickMD/Views/ChromeButtons.swift b/QuickMD/QuickMD/Views/ChromeButtons.swift index 1ddfd43..29c6c23 100644 --- a/QuickMD/QuickMD/Views/ChromeButtons.swift +++ b/QuickMD/QuickMD/Views/ChromeButtons.swift @@ -11,12 +11,16 @@ import AppKit /// delegate → `onLink`. Never `Text(...).textSelection(.enabled)`: its /// SelectionOverlay is constraints.md bug B. /// -/// The copy button is an OVERLAY in the trailing strip the title gives up +/// The copy button is an OVERLAY, not an HStack sibling: an HStack +/// re-negotiates an NSViewRepresentable child's width every pass (scroll-freeze +/// rule 4). The title still gives up a trailing strip /// (`Metrics.copyButtonReservedWidth`, the same width `BlockHeightMeasurer` -/// wraps the title at), not an HStack sibling: an HStack re-negotiates an -/// NSViewRepresentable child's width every pass (scroll-freeze rule 4). An -/// overlay takes no space, so the old HStack's first-baseline union is -/// reproduced with explicit insets — see `BlockLayout.Heading.titleGeometry`. +/// wraps the title at), so the button never covers text. An overlay takes no +/// space, so the old HStack's first-baseline union is reproduced with explicit +/// insets — see `BlockLayout.Heading.titleGeometry` — and its horizontal +/// position (right after the title's widest line, as in v1.10) is computed +/// synchronously from the row width and the title string, never fed back from +/// the text view's layout (no async layout state — scroll-freeze rule 2). struct HeadingBlockView: View { let id: String let level: Int @@ -62,22 +66,34 @@ struct HeadingBlockView: View { .padding(.trailing, Metrics.copyButtonReservedWidth) .padding(.top, geometry.titleTopInset) .frame(minHeight: geometry.minimumHeight, alignment: .top) - .overlay(alignment: .topTrailing) { - // `buttonTopInset` puts the icon's baseline on the title's first - // baseline, as the old `.firstTextBaseline` HStack did. - Button { - onCopySection() - } label: { - Image(systemName: "doc.on.doc") - .font(.system(size: Metrics.copyButtonIconFontSize)) - .foregroundColor(.secondary.opacity(0.7)) - .padding(Metrics.copyButtonIconPadding) - .contentShape(Rectangle()) + .overlay { + // The reader only reads the row width the cell already gave this + // view; nothing here sizes the row. Its empty area is not hittable, + // so drags on the title still reach the text view. + GeometryReader { proxy in + // `buttonTopInset` puts the icon's baseline on the title's first + // baseline, as the old `.firstTextBaseline` HStack did. + Button { + onCopySection() + } label: { + Image(systemName: "doc.on.doc") + .font(.system(size: Metrics.copyButtonIconFontSize)) + .foregroundColor(.secondary.opacity(0.7)) + .padding(Metrics.copyButtonIconPadding) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .help("Copy section") + // The button lies on the title's text view, whose I-beam would + // otherwise show over the icon; re-set on every move, as the + // text view updates its cursor on mouse moves too. + .onContinuousHover { phase in + if case .active = phase { NSCursor.arrow.set() } + } + .opacity(isHovered ? 1 : 0) + .padding(.top, geometry.buttonTopInset) + .padding(.leading, copyButtonLeading(rowWidth: proxy.size.width, headingAttr: headingAttr)) } - .buttonStyle(.plain) - .help("Copy section") - .opacity(isHovered ? 1 : 0) - .padding(.top, geometry.buttonTopInset) } .contentShape(Rectangle()) .onHover { hovering in @@ -91,6 +107,23 @@ struct HeadingBlockView: View { } } } + + /// The button's leading inset in a row `rowWidth` wide: right after the + /// title's widest line at the width the title wraps at. The string is the + /// one the title shows (the same conversion `MarkdownView.selectableText` + /// applies to headings); it is built once per layout key and laid out + /// once per wrap width, so a hover toggle is a lookup. + private func copyButtonLeading(rowWidth: CGFloat, headingAttr: AttributedString) -> CGFloat { + let key = Metrics.titleLayoutKey(title: title, level: level, fontScale: fontScale, + fonts: theme.fonts) + let usedWidth = Metrics.titleUsedWidth( + cachedAs: key, wrapWidth: Metrics.titleWrapWidth(rowWidth: rowWidth) + ) { + TextBlockView.makeNSAttributedString(from: headingAttr, hasInlineMath: false, + theme: theme, fontScale: fontScale) + } + return Metrics.copyButtonLeading(titleUsedWidth: usedWidth, rowWidth: rowWidth) + } } // MARK: - Shared hover-expand capsule diff --git a/QuickMD/QuickMDTests/HeadingLayoutTests.swift b/QuickMD/QuickMDTests/HeadingLayoutTests.swift index 93f0535..8b84afc 100644 --- a/QuickMD/QuickMDTests/HeadingLayoutTests.swift +++ b/QuickMD/QuickMDTests/HeadingLayoutTests.swift @@ -239,6 +239,154 @@ final class HeadingLayoutTests: XCTestCase { } } + // MARK: - Copy button: right after the title (as in v1.10) + + func testCopyButtonLeadingFollowsTheTitleAndStopsAtTheEdge() { + let s = Metrics.copyButtonSpacing + let w = Metrics.copyButtonWidth + XCTAssertEqual(Metrics.copyButtonLeading(titleUsedWidth: 0, rowWidth: 300), s) + XCTAssertEqual(Metrics.copyButtonLeading(titleUsedWidth: 120, rowWidth: 300), 120 + s) + // A title that fills its wrap width: the button sits at the trailing + // edge, where it was in v1.11–1.13. + let full = Metrics.titleWrapWidth(rowWidth: 300) + XCTAssertEqual(full, 300 - Metrics.copyButtonReservedWidth) + XCTAssertEqual(Metrics.copyButtonLeading(titleUsedWidth: full, rowWidth: 300), 300 - w) + // Never past the edge, never negative. + XCTAssertEqual(Metrics.copyButtonLeading(titleUsedWidth: 500, rowWidth: 300), 300 - w) + XCTAssertEqual(Metrics.copyButtonLeading(titleUsedWidth: 10, rowWidth: w - 5), 0) + } + + /// Real heading strings, H1–H6, 100 % / 150 %, a narrow column and the + /// reading-mode one: the button's leading edge is the widest laid-out + /// line + spacing — cross-checked against `usedRect(for:)` of a separate + /// TextKit stack and, for single lines, CoreText's typographic width — + /// and a wrapped title keeps it inside the row. + func testCopyButtonSitsAfterTheTitlesWidestLine() { + let titles = ["Heading", "Title with `code` and [a link](https://example.com)", + "Emoji \u{1F680} heading", Self.longTitle] + var singleLines = 0 + var wrapped = 0 + for fonts in [DocumentFonts.system, DocumentFonts(body: "Georgia", code: nil)] { + var theme = MarkdownTheme.cached(for: .light) + theme.fonts = fonts + for scale in [1.0, 1.5] as [CGFloat] { + for level in 1...6 { + for title in titles { + let (_, ns) = headingString(title, level: level, theme: theme, fontScale: scale) + for width in [300, 700] as [CGFloat] { + let wrapWidth = Metrics.titleWrapWidth(rowWidth: width) + let used = Metrics.titleUsedWidth(ns, wrapWidth: wrapWidth) + let leading = Metrics.copyButtonLeading(titleUsedWidth: used, rowWidth: width) + let lines = lineCount(ns, width: wrapWidth) + let context = "H\(level) @\(scale) \(fonts) w=\(width) lines=\(lines) '\(title)'" + + XCTAssertEqual(used, independentUsedWidth(ns, width: wrapWidth), accuracy: 1, context) + XCTAssertLessThanOrEqual(used, wrapWidth, context) + XCTAssertLessThanOrEqual(leading, width - Metrics.copyButtonWidth, context) + if lines == 1 { + singleLines += 1 + let line = CTLineCreateWithAttributedString(ns) + let typographic = CGFloat(CTLineGetTypographicBounds(line, nil, nil, nil)) + XCTAssertEqual(used, typographic, accuracy: 1, context) + XCTAssertEqual(leading, used + Metrics.copyButtonSpacing, context) + } else { + wrapped += 1 + XCTAssertEqual(leading, min(used + Metrics.copyButtonSpacing, + width - Metrics.copyButtonWidth), context) + // After the widest line, i.e. well into the row. + XCTAssertGreaterThan(leading, wrapWidth / 2, context) + } + } + } + } + } + } + XCTAssertGreaterThan(singleLines, 0) + XCTAssertGreaterThan(wrapped, 0) + } + + /// The cached lookup the view uses returns the uncached answer, builds the + /// string once per key — a new wrap width (a resize step) re-lays it out + /// without converting it again — and keys the width on the wrap width. + func testCachedTitleUsedWidthMatchesAndBuildsOnce() { + let (_, ns) = headingString("Cached heading", level: 2, + theme: MarkdownTheme.cached(for: .light), fontScale: 1) + let key = "test|\(UUID().uuidString)" + var builds = 0 + let make: () -> NSAttributedString = { builds += 1; return ns } + let first = Metrics.titleUsedWidth(cachedAs: key, wrapWidth: 400, makeTitle: make) + let second = Metrics.titleUsedWidth(cachedAs: key, wrapWidth: 400, makeTitle: make) + XCTAssertEqual(first, Metrics.titleUsedWidth(ns, wrapWidth: 400)) + XCTAssertEqual(second, first) + XCTAssertEqual(builds, 1) + let narrow = Metrics.titleUsedWidth(cachedAs: key, wrapWidth: 60, makeTitle: make) + XCTAssertEqual(builds, 1) + XCTAssertEqual(narrow, Metrics.titleUsedWidth(ns, wrapWidth: 60)) + XCTAssertLessThan(narrow, first) + } + + /// The layout key holds exactly what decides the title's width: inputs + /// that only colour it (light/dark theme) or re-parse it share an entry; + /// a different title, level, zoom or font family does not. + func testTitleLayoutKeySharesOnlyWidthIrrelevantInputs() { + let title = "Key heading \(UUID().uuidString)" + let light = MarkdownTheme.cached(for: .light) + let dark = MarkdownTheme.cached(for: .dark) + func key(_ title: String, _ level: Int, _ scale: CGFloat, _ theme: MarkdownTheme) -> String { + Metrics.titleLayoutKey(title: title, level: level, fontScale: scale, fonts: theme.fonts) + } + var builds = 0 + func lookup(_ key: String, _ theme: MarkdownTheme, _ level: Int, _ scale: CGFloat, + _ title: String) -> CGFloat { + Metrics.titleUsedWidth(cachedAs: key, wrapWidth: 500) { + builds += 1 + return headingString(title, level: level, theme: theme, fontScale: scale).1 + } + } + + let base = lookup(key(title, 2, 1, light), light, 2, 1, title) + XCTAssertEqual(builds, 1) + // Dark theme: other colours, same key, same entry, no rebuild. + XCTAssertEqual(key(title, 2, 1, dark), key(title, 2, 1, light)) + XCTAssertEqual(lookup(key(title, 2, 1, dark), dark, 2, 1, title), base) + XCTAssertEqual(builds, 1) + + var georgia = light + georgia.fonts = DocumentFonts(body: "Georgia", code: nil) + var mono = light + mono.fonts = DocumentFonts(body: nil, code: "Menlo") + let changed: [(String, MarkdownTheme, Int, CGFloat, String)] = [ + ("title", light, 2, 1, title + " longer"), + ("level", light, 1, 1, title), + ("scale", light, 2, 1.5, title), + ("body font", georgia, 2, 1, title), + ("code font", mono, 2, 1, title), + ] + var keys: Set = [key(title, 2, 1, light)] + for (name, theme, level, scale, changedTitle) in changed { + let changedKey = key(changedTitle, level, scale, theme) + XCTAssertTrue(keys.insert(changedKey).inserted, "\(name) must change the key") + let before = builds + let width = lookup(changedKey, theme, level, scale, changedTitle) + XCTAssertEqual(builds, before + 1, "\(name) must not reuse the entry") + XCTAssertEqual(width, Metrics.titleUsedWidth( + headingString(changedTitle, level: level, theme: theme, fontScale: scale).1, + wrapWidth: 500), name) + } + } + + /// The widest line as a fresh TextKit stack's `usedRect(for:)` reports it. + private func independentUsedWidth(_ ns: NSAttributedString, width: CGFloat) -> CGFloat { + let storage = NSTextStorage(attributedString: ns) + let layoutManager = NSLayoutManager() + storage.addLayoutManager(layoutManager) + let container = NSTextContainer(size: NSSize(width: width, height: CGFloat.greatestFiniteMagnitude)) + container.lineFragmentPadding = 0 + layoutManager.addTextContainer(container) + layoutManager.ensureLayout(for: container) + return layoutManager.usedRect(for: container).maxX + } + /// Lines TextKit breaks `ns` into at `width`. private func lineCount(_ ns: NSAttributedString, width: CGFloat) -> Int { let storage = NSTextStorage(attributedString: ns) diff --git a/QuickMD/QuickMDTests/TableTextTests.swift b/QuickMD/QuickMDTests/TableTextTests.swift index ad87f25..893059e 100644 --- a/QuickMD/QuickMDTests/TableTextTests.swift +++ b/QuickMD/QuickMDTests/TableTextTests.swift @@ -199,16 +199,36 @@ final class TableTextTests: XCTestCase { XCTAssertEqual(TableTextBuilder.height(of: built.string, width: 500), 0) } - /// What 1.12 drew: the renderer's 14·scale regular in EVERY cell, header - /// included; header band background; rules on trailing/bottom edges only, - /// the header always keeping its bottom rule. + private func weight(of font: NSFont) -> CGFloat { + let traits = font.fontDescriptor.object(forKey: .traits) as? [NSFontDescriptor.TraitKey: Any] + return (traits?[.weight] as? CGFloat) ?? NSFont.Weight.regular.rawValue + } + + /// The renderer's 14·scale in every cell: semibold in the header band + /// (v1.13), regular in the data bands (as 1.12 drew every cell); header + /// band background; rules on trailing/bottom edges only, the header always + /// keeping its bottom rule. func testCellFontsBackgroundsAndRules() { for scale in [CGFloat(0.75), 1.0, 1.5] { let built = build(table(Self.fixtures[0].markdown, fontScale: scale), fontScale: scale) built.string.enumerateAttribute(.font, in: NSRange(location: 0, length: built.string.length)) { value, range, _ in guard let font = value as? NSFont else { return XCTFail("no font at \(range)") } XCTAssertEqual(font.pointSize, BlockLayout.Table.cellFontSize * scale, accuracy: 0.001) - XCTAssertFalse(font.fontDescriptor.symbolicTraits.contains(.bold), "bold at \(range)") + } + // Cell text only: the "\n" terminators keep the plain font (never drawn). + for (band, cells) in built.layout.cellRanges.enumerated() { + for cell in cells { + built.string.enumerateAttribute(.font, in: cell) { value, range, _ in + guard let font = value as? NSFont else { return } + if band == 0 { + XCTAssertGreaterThanOrEqual(weight(of: font), NSFont.Weight.semibold.rawValue, + "header at \(range)") + } else { + XCTAssertLessThan(weight(of: font), NSFont.Weight.semibold.rawValue, "data at \(range)") + XCTAssertFalse(font.fontDescriptor.symbolicTraits.contains(.bold), "bold at \(range)") + } + } + } } } let built = build(table(Self.fixtures[0].markdown)) @@ -232,6 +252,31 @@ final class TableTextTests: XCTestCase { "the header separator is drawn even with no rows below it (as 1.12)") } + /// The header weight only goes UP: bold stays bold, italic keeps its + /// slant, inline code stays monospaced — each at least semibold. The + /// characters do not change (search and copy read them). + func testHeaderMarkupKeepsItsFaceAndIsAtLeastSemibold() { + let built = build(table(""" + | **Bold** | *Italic* | `code` | plain | + |---|---|---|---| + | a | b | c | d | + """)) + let cells = built.layout.cellRanges[0] + func font(_ column: Int) -> NSFont { + built.string.attribute(.font, at: cells[column].location, effectiveRange: nil) as! NSFont + } + XCTAssertGreaterThanOrEqual(weight(of: font(0)), NSFont.Weight.bold.rawValue, "bold stays bold") + XCTAssertTrue(font(1).fontDescriptor.symbolicTraits.contains(.italic), "italic keeps its slant") + XCTAssertTrue(font(2).fontDescriptor.symbolicTraits.contains(.monoSpace), "code stays monospaced") + for column in 0..<4 { + XCTAssertGreaterThanOrEqual(weight(of: font(column)), NSFont.Weight.semibold.rawValue, "column \(column)") + } + XCTAssertEqual(built.string.string, "Bold\nItalic\ncode\nplain\na\nb\nc\nd\n") + XCTAssertLessThan(weight(of: built.string.attribute(.font, at: built.layout.cellRanges[1][3].location, + effectiveRange: nil) as! NSFont), + NSFont.Weight.semibold.rawValue) + } + // MARK: - Measurer (TS-D3) func testTableRowIsExactAndCarriesTheBuiltString() {