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
2 changes: 2 additions & 0 deletions QuickMD/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
96 changes: 94 additions & 2 deletions QuickMD/QuickMD/BlockHeightMeasurer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<NSString, NSAttributedString> = {
let cache = NSCache<NSString, NSAttributedString>()
cache.countLimit = 1024
return cache
}()

private static let usedWidthCache: NSCache<NSString, NSNumber> = {
let cache = NSCache<NSString, NSNumber>()
// 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)
Expand Down Expand Up @@ -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)
}

Expand Down
33 changes: 30 additions & 3 deletions QuickMD/QuickMD/TableTextBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -211,6 +212,7 @@ enum TableTextBuilder {
ranges.reserveCapacity(columnCount)
for column in 0..<columnCount {
let cell = renderedCell(column < band.count ? band[column] : "", renderer: renderer)
if isHeader { raiseToHeaderWeight(cell) }
ranges.append(NSRange(location: result.length, length: cell.length))
cell.append(NSAttributedString(string: "\n", attributes: terminatorAttributes))

Expand Down Expand Up @@ -295,6 +297,31 @@ enum TableTextBuilder {
return cell
}

/// Header cells are semibold. Only raises the weight: a `**bold**` run
/// stays bold, italic and monospaced runs keep their face. A family
/// without a semibold face (a Settings font) falls back to its bold.
/// Attributes only — the characters (search, copy) are unchanged.
static func raiseToHeaderWeight(_ cell: NSMutableAttributedString) {
let semibold = NSFont.Weight.semibold.rawValue
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
}
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:)`).
///
Expand Down
73 changes: 53 additions & 20 deletions QuickMD/QuickMD/Views/ChromeButtons.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading
Loading