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
5 changes: 5 additions & 0 deletions QuickMD/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added
- **Reload** (⌘R, View ▸ Reload, or the ↻ button in the top-right cluster): re-reads the document from disk now and restarts the file watcher, for the times auto-reload does not notice a change (some network volumes and editors' save strategies). A toast says whether anything changed, or that the file could not be read; a file that is gone shows the "File no longer exists" banner, which clears by itself when the file comes back. Off while Source Edit shows, where the editor already follows the disk. Contributed by [@rinh](https://github.com/rinh) (#37).
- **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
- 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 İ).

## [1.12.0] - 2026-10-05

Expand Down
28 changes: 24 additions & 4 deletions QuickMD/QuickMD.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,14 @@
A1B2C3D401000009 /* MarkdownExport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D40000000C /* MarkdownExport.swift */; };
A1B2C3D40100000A /* MarkdownBlock.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D40000000D /* MarkdownBlock.swift */; };
A1B2C3D40100000B /* MarkdownBlockParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D40000000E /* MarkdownBlockParser.swift */; };
A1B2C3D40100000C /* TableBlockView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D40000000F /* TableBlockView.swift */; };
A1B2C3D40100000D /* CodeBlockView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000011 /* CodeBlockView.swift */; };
A1B2C3D4010001B0 /* TableTextBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4000001B0 /* TableTextBuilder.swift */; };
A1B2C3D4010001B1 /* TableTextBuilder.swift in Sources (Tests) */ = {isa = PBXBuildFile; fileRef = A1B2C3D4000001B0 /* TableTextBuilder.swift */; };
A1B2C3D4010001B2 /* TableTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4000001B1 /* TableTextView.swift */; };
A1B2C3D4010001B3 /* TableTextTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4000001B2 /* TableTextTests.swift */; };
A1B2C3D4010001C0 /* SelectionRows.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4000001C0 /* SelectionRows.swift */; };
A1B2C3D4010001C1 /* SelectionRows.swift in Sources (Tests) */ = {isa = PBXBuildFile; fileRef = A1B2C3D4000001C0 /* SelectionRows.swift */; };
A1B2C3D4010001C2 /* TableSelectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4000001C1 /* TableSelectionTests.swift */; };
A1B2C3D40100000E /* ImageBlockView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000012 /* ImageBlockView.swift */; };
A1B2C3D40100000F /* BlockquoteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000013 /* BlockquoteView.swift */; };
A1B2C3D401000010 /* SearchBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000014 /* SearchBar.swift */; };
Expand Down Expand Up @@ -202,9 +208,13 @@
A1B2C3D40000000C /* MarkdownExport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownExport.swift; sourceTree = "<group>"; };
A1B2C3D40000000D /* MarkdownBlock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownBlock.swift; sourceTree = "<group>"; };
A1B2C3D40000000E /* MarkdownBlockParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownBlockParser.swift; sourceTree = "<group>"; };
A1B2C3D40000000F /* TableBlockView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableBlockView.swift; sourceTree = "<group>"; };
A1B2C3D400000010 /* QuickMD.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = QuickMD.app; sourceTree = BUILT_PRODUCTS_DIR; };
A1B2C3D400000011 /* CodeBlockView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodeBlockView.swift; sourceTree = "<group>"; };
A1B2C3D4000001B0 /* TableTextBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableTextBuilder.swift; sourceTree = "<group>"; };
A1B2C3D4000001B1 /* TableTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableTextView.swift; sourceTree = "<group>"; };
A1B2C3D4000001B2 /* TableTextTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableTextTests.swift; sourceTree = "<group>"; };
A1B2C3D4000001C0 /* SelectionRows.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectionRows.swift; sourceTree = "<group>"; };
A1B2C3D4000001C1 /* TableSelectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableSelectionTests.swift; sourceTree = "<group>"; };
A1B2C3D400000012 /* ImageBlockView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageBlockView.swift; sourceTree = "<group>"; };
A1B2C3D400000013 /* BlockquoteView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockquoteView.swift; sourceTree = "<group>"; };
A1B2C3D400000014 /* SearchBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchBar.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -313,6 +323,8 @@
A1B2C3D400000082 /* ImageLoadingTests.swift */,
A1B2C3D4000000E1 /* HTMLImageTests.swift */,
A1B2C3D4000000F0 /* HeadingLayoutTests.swift */,
A1B2C3D4000001B2 /* TableTextTests.swift */,
A1B2C3D4000001C1 /* TableSelectionTests.swift */,
A1B2C3D4000000C1 /* DocumentSelectionTests.swift */,
A1B2C3D400000111 /* ExternalEditorLineTests.swift */,
A1B2C3D400000122 /* DocumentFileFormatTests.swift */,
Expand All @@ -339,10 +351,12 @@
A1B2C3D40000000D /* MarkdownBlock.swift */,
A1B2C3D40000000E /* MarkdownBlockParser.swift */,
A1B2C3D400000069 /* BlockHeightMeasurer.swift */,
A1B2C3D4000001B0 /* TableTextBuilder.swift */,
A1B2C3D40000006E /* SVGImageDecoder.swift */,
A1B2C3D400000080 /* DataImageURI.swift */,
A1B2C3D400000081 /* ImageLoader.swift */,
A1B2C3D4000000C0 /* DocumentSelection.swift */,
A1B2C3D4000001C0 /* SelectionRows.swift */,
A1B2C3D400000110 /* DocumentReadingPosition.swift */,
A1B2C3D400000120 /* DocumentFileFormat.swift */,
A1B2C3D400000121 /* DocumentFileWriter.swift */,
Expand Down Expand Up @@ -382,7 +396,7 @@
A1B2C3D403000004 /* Views */ = {
isa = PBXGroup;
children = (
A1B2C3D40000000F /* TableBlockView.swift */,
A1B2C3D4000001B1 /* TableTextView.swift */,
A1B2C3D400000011 /* CodeBlockView.swift */,
A1B2C3D400000012 /* ImageBlockView.swift */,
A1B2C3D400000013 /* BlockquoteView.swift */,
Expand Down Expand Up @@ -567,7 +581,6 @@
A1B2C3D401000006 /* MarkdownTheme.swift in Sources */,
A1B2C3D40100000A /* MarkdownBlock.swift in Sources */,
A1B2C3D40100000B /* MarkdownBlockParser.swift in Sources */,
A1B2C3D40100000C /* TableBlockView.swift in Sources */,
A1B2C3D40100000D /* CodeBlockView.swift in Sources */,
A1B2C3D40100000E /* ImageBlockView.swift in Sources */,
A1B2C3D401000007 /* TipJarManager.swift in Sources */,
Expand Down Expand Up @@ -629,12 +642,15 @@
A1B2C3D401000062 /* DocumentFonts.swift in Sources */,
A1B2C3D401000063 /* FontPickerView.swift in Sources */,
A1B2C3D401000069 /* BlockHeightMeasurer.swift in Sources */,
A1B2C3D4010001B0 /* TableTextBuilder.swift in Sources */,
A1B2C3D4010001B2 /* TableTextView.swift in Sources */,
A1B2C3D40100006C /* VirtualBlockList.swift in Sources */,
A1B2C3D40100006D /* SVGBlockView.swift in Sources */,
A1B2C3D40100006E /* SVGImageDecoder.swift in Sources */,
A1B2C3D401000080 /* DataImageURI.swift in Sources */,
A1B2C3D401000082 /* ImageLoader.swift in Sources */,
A1B2C3D4010000C0 /* DocumentSelection.swift in Sources */,
A1B2C3D4010001C0 /* SelectionRows.swift in Sources */,
A1B2C3D401000110 /* DocumentReadingPosition.swift in Sources */,
A1B2C3D401000120 /* DocumentFileFormat.swift in Sources */,
A1B2C3D401000122 /* DocumentFileWriter.swift in Sources */,
Expand Down Expand Up @@ -677,12 +693,14 @@
A1B2C3D401000068 /* ChromeHoverState.swift in Sources (Tests) */,
A1B2C3D40100006A /* BlockHeightMeasurerTests.swift in Sources */,
A1B2C3D40100006B /* BlockHeightMeasurer.swift in Sources (Tests) */,
A1B2C3D4010001B1 /* TableTextBuilder.swift in Sources (Tests) */,
A1B2C3D40100006F /* SVGImageDecoder.swift in Sources (Tests) */,
A1B2C3D401000081 /* DataImageURI.swift in Sources (Tests) */,
A1B2C3D401000083 /* ImageLoader.swift in Sources (Tests) */,
A1B2C3D401000084 /* ImageLoadingTests.swift in Sources */,
A1B2C3D4010000E2 /* HTMLImageTests.swift in Sources */,
A1B2C3D4010000C1 /* DocumentSelection.swift in Sources (Tests) */,
A1B2C3D4010001C1 /* SelectionRows.swift in Sources (Tests) */,
A1B2C3D4010000C2 /* DocumentSelectionTests.swift in Sources */,
A1B2C3D401000111 /* DocumentReadingPosition.swift in Sources (Tests) */,
A1B2C3D401000112 /* ExternalEditorLineTests.swift in Sources */,
Expand All @@ -705,6 +723,8 @@
A1B2C3D401000181 /* SourceEditLanding.swift in Sources (Tests) */,
A1B2C3D401000182 /* SourceEditLandingTests.swift in Sources */,
A1B2C3D4010000F0 /* HeadingLayoutTests.swift in Sources */,
A1B2C3D4010001B3 /* TableTextTests.swift in Sources */,
A1B2C3D4010001C2 /* TableSelectionTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down
89 changes: 34 additions & 55 deletions QuickMD/QuickMD/BlockHeightMeasurer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -276,17 +276,26 @@ enum BlockLayout {

// MARK: Tables

/// Since v1.13 a table is one NSTextView over an `NSTextTable` built by
/// `TableTextBuilder`; the builder (cell paddings, rules, font) and
/// `TableTextView` (clip, outline) read these, and the row is `.exact`.
enum Table {
static let cellHorizontalPadding: CGFloat = 12
static let headerCellVerticalPadding: CGFloat = 8
static let cellVerticalPadding: CGFloat = 6
/// Vertical rule between columns.
/// Vertical rule between columns (a cell's trailing border).
static let dividerWidth: CGFloat = 1
/// Horizontal rule under the header and between rows.
/// Horizontal rule under the header and between rows (a cell's bottom
/// border).
static let separatorHeight: CGFloat = 1
/// The rounded outline around the whole table.
/// The rounded outline around the whole table, centred on its edge.
static let borderWidth: CGFloat = 1
static let cellFontSize: CGFloat = 13
/// Every cell, header included, at this size × zoom, regular weight:
/// the size `MarkdownRenderer.renderInline` stamps on each run, i.e.
/// what 1.12 actually drew (its 13 pt / semibold header modifiers lost
/// to the runs' own font). The builder gives each cell's terminating
/// "\n" this font too, so an empty cell is one line of it.
static let cellFontSize: CGFloat = 14
static let cornerRadius: CGFloat = 4
}

Expand Down Expand Up @@ -577,6 +586,10 @@ struct MeasuredBlocks: @unchecked Sendable {
/// Keyed by `MarkdownBlock.id`, for the blocks whose text renders through
/// `TextBlockView` (paragraphs/footnotes, quote bodies, alert bodies).
let converted: [String: NSAttributedString]
/// Keyed by `MarkdownBlock.id`, for every `.table` block: the string and
/// cell layout the row was measured with, which `TableTextView` installs
/// as is (same D12 hand-off as `converted`).
let tables: [String: TableText]
/// Rows whose height could not be computed because `measure` was called
/// without the math engine (`math: nil` — the off-main call, see the file
/// header): paragraphs containing inline `$…$` and `.mathBlock` rows. Their
Expand All @@ -586,18 +599,18 @@ struct MeasuredBlocks: @unchecked Sendable {
/// got a real engine.
let mathPendingRows: [Int]

static let empty = MeasuredBlocks(table: .empty, converted: [:], mathPendingRows: [])
static let empty = MeasuredBlocks(table: .empty, converted: [:], tables: [:], mathPendingRows: [])
}

// MARK: - Measurer

/// Computes a whole document's `BlockHeightTable` off the main thread.
///
/// Exact for the NSTextView-backed kinds (`.text`, `.blockquote`, `.alert`,
/// `.codeBlock`): the same string, the same width, the same TextKit 1 settings.
/// An estimate for the kinds whose size only exists once a view is placed
/// (`.heading`, `.table`, `.mathBlock`, `.image`, `.mermaidDiagram`) — those rows
/// are `.reported` and get corrected once by the placed view.
/// `.codeBlock`, and since v1.13 `.table`): the same string, the same width, the
/// same TextKit 1 settings. An estimate for the kinds whose size only exists once
/// a view is placed (`.heading`, `.mathBlock`, `.image`, `.mermaidDiagram`) —
/// those rows are `.reported` and get corrected once by the placed view.
enum BlockHeightMeasurer {

/// Height of `ns` laid out at `width` by a standalone TextKit 1 stack.
Expand Down Expand Up @@ -682,6 +695,7 @@ enum BlockHeightMeasurer {
var heights: [CGFloat] = []
var kinds: [RowKind] = []
var converted: [String: NSAttributedString] = [:]
var tables: [String: TableText] = [:]
var mathPendingRows: [Int] = []
heights.reserveCapacity(blocks.count)
kinds.reserveCapacity(blocks.count)
Expand Down Expand Up @@ -752,10 +766,14 @@ enum BlockHeightMeasurer {
kinds.append(.reported)

case .table(let headers, let rows, let alignments):
heights.append(tableEstimate(headers: headers, rows: rows, alignments: alignments,
renderer: renderer, theme: theme, fontScale: fontScale,
contentWidth: contentWidth))
kinds.append(.reported)
// The string `TableTextView` installs (it is handed this very
// instance), at the width its frame gets: the whole column.
let table = TableTextBuilder.build(headers: headers, rows: rows, alignments: alignments,
theme: theme, fontScale: fontScale)
tables[block.id] = table
heights.append(TableTextBuilder.height(of: table.string, width: textWidth)
+ 2 * BlockLayout.Document.tableOuterVerticalPadding)
kinds.append(.exact)

case .mathBlock(let latex):
if let math {
Expand Down Expand Up @@ -800,6 +818,7 @@ enum BlockHeightMeasurer {
return MeasuredBlocks(
table: BlockHeightTable(heights: heights, kinds: kinds, contentWidth: contentWidth),
converted: converted,
tables: tables,
mathPendingRows: mathPendingRows)
}

Expand Down Expand Up @@ -842,7 +861,7 @@ enum BlockHeightMeasurer {
reusing: [String: NSAttributedString] = [:]) -> MeasuredBlocks {
guard !pending.isEmpty else {
return MeasuredBlocks(table: measured.table, converted: measured.converted,
mathPendingRows: [])
tables: measured.tables, mathPendingRows: [])
}

#if DEBUG
Expand Down Expand Up @@ -894,6 +913,7 @@ enum BlockHeightMeasurer {
table: BlockHeightTable(heights: heights, kinds: measured.table.kinds,
contentWidth: measured.table.contentWidth),
converted: converted,
tables: measured.tables,
mathPendingRows: [])
}

Expand All @@ -917,45 +937,4 @@ enum BlockHeightMeasurer {
return max(exactHeight(text: ns, width: textWidth), BlockLayout.Heading.copyButtonHeight)
}

/// `.table` — cells are SwiftUI `Text`, laid out in equal-width columns.
/// TextKit per cell is affordable because tables are rare and small.
private static func tableEstimate(headers: [String], rows: [[String]],
alignments: [TextAlignment],
renderer: MarkdownRenderer,
theme: MarkdownTheme, fontScale: CGFloat,
contentWidth: CGFloat) -> CGFloat {
let columnCount = max(1, max(headers.count, alignments.count, rows.map(\.count).max() ?? 0))
let showsHeader = headers.contains { !$0.trimmingCharacters(in: .whitespaces).isEmpty }
let dividers = CGFloat(columnCount - 1) * BlockLayout.Table.dividerWidth
let columnWidth = (contentWidth - dividers) / CGFloat(columnCount)
let cellWidth = BlockLayout.clampedWidth(columnWidth - 2 * BlockLayout.Table.cellHorizontalPadding)
// Floor: an empty cell still occupies one line in SwiftUI, where an
// empty text container reports no used height.
let minCellHeight = BlockLayout.singleLineHeight(
for: theme.fonts.appKit(size: BlockLayout.Table.cellFontSize * fontScale))

func bandHeight(_ cells: [String], padding: CGFloat) -> CGFloat {
var tallest = minCellHeight
for index in 0..<columnCount {
let text = index < cells.count ? cells[index] : ""
guard !text.isEmpty else { continue }
let ns = (try? NSAttributedString(renderer.renderInline(text), including: \.appKit))
?? NSAttributedString(string: text)
tallest = max(tallest, exactHeight(text: ns, width: cellWidth))
}
return tallest + 2 * padding
}

var height = 2 * BlockLayout.Document.tableOuterVerticalPadding
if showsHeader {
height += bandHeight(headers, padding: BlockLayout.Table.headerCellVerticalPadding)
height += BlockLayout.Table.separatorHeight
}
for (index, row) in rows.enumerated() {
height += bandHeight(row, padding: BlockLayout.Table.cellVerticalPadding)
if index < rows.count - 1 { height += BlockLayout.Table.separatorHeight }
}
return height
}

}
Loading
Loading