Fix two rendering bugs found integrating OrgSwiftUI !3
4 files changed, +53 −15
Layout: unified · split
Sources/OrgSwiftUI/OrgInlineStyler.swift +14 −8
| @@ -15,8 +15,13 @@ struct OrgInlineStyler { | |||
| 15 | 15 | ||
| 16 | /// - Parameter style: the text style of the block this run sits in, so emphasis inside a | 16 | /// - Parameter style: the text style of the block this run sits in, so emphasis inside a |
| 17 | /// heading bolds the heading's font rather than the body font. | 17 | /// heading bolds the heading's font rather than the body font. |
| 18 | /// - Parameters: | ||
| 19 | /// - style: the text style of the block this run sits in, so emphasis inside a heading | ||
| 20 | /// bolds the heading's font rather than the body font. | ||
| 21 | /// - bold: forces every run bold, for headings. | ||
| 18 | func attributed( | 22 | func attributed( |
| 19 | _ objects: [OrgObject], _ notes: inout OrgFootnotes, style: Font.TextStyle = .body | 23 | _ objects: [OrgObject], _ notes: inout OrgFootnotes, |
| 24 | style: Font.TextStyle = .body, bold forceBold: Bool = false | ||
| 20 | ) -> AttributedString { | 25 | ) -> AttributedString { |
| 21 | var string = renderer.inline(objects, ¬es) | 26 | var string = renderer.inline(objects, ¬es) |
| 22 | 27 | ||
| @@ -26,7 +31,7 @@ struct OrgInlineStyler { | |||
| 26 | 31 | ||
| 27 | var textStyle = style | 32 | var textStyle = style |
| 28 | var monospaced = false | 33 | var monospaced = false |
| 29 | var bold = false | 34 | var bold = forceBold |
| 30 | var italic = false | 35 | var italic = false |
| 31 | 36 | ||
| 32 | if intents.contains(.stronglyEmphasized) { bold = true } | 37 | if intents.contains(.stronglyEmphasized) { bold = true } |
| @@ -57,12 +62,13 @@ struct OrgInlineStyler { | |||
| 57 | break | 62 | break |
| 58 | } | 63 | } |
| 59 | 64 | ||
| 60 | if bold || italic || monospaced || textStyle != style { | 65 | // Always set the font, never only on modified runs: a run with no emphasis |
| 61 | var font = Font.system(textStyle, design: monospaced ? .monospaced : .default) | 66 | // would otherwise inherit the surrounding view's font and a heading, table cell |
| 62 | if bold { font = font.bold() } | 67 | // or footnote would render at body size. |
| 63 | if italic { font = font.italic() } | 68 | var font = Font.system(textStyle, design: monospaced ? .monospaced : .default) |
| 64 | string[range].font = font | 69 | if bold { font = font.bold() } |
| 65 | } | 70 | if italic { font = font.italic() } |
| 71 | string[range].font = font | ||
| 66 | } | 72 | } |
| 67 | return string | 73 | return string |
| 68 | } | 74 | } |
Sources/OrgSwiftUI/OrgLayoutBuilder.swift +4 −3
| @@ -28,9 +28,10 @@ struct OrgLayoutBuilder { | |||
| 28 | } | 28 | } |
| 29 | 29 | ||
| 30 | private func text( | 30 | private func text( |
| 31 | _ objects: [OrgObject], _ notes: inout OrgFootnotes, style: Font.TextStyle = .body | 31 | _ objects: [OrgObject], _ notes: inout OrgFootnotes, |
| 32 | style: Font.TextStyle = .body, bold: Bool = false | ||
| 32 | ) -> AttributedString { | 33 | ) -> AttributedString { |
| 33 | inline.attributed(objects, ¬es, style: style) | 34 | inline.attributed(objects, ¬es, style: style, bold: bold) |
| 34 | } | 35 | } |
| 35 | 36 | ||
| 36 | /// One element to zero or more blocks. Zero for a footnote definition (it moves to the | 37 | /// One element to zero or more blocks. Zero for a footnote definition (it moves to the |
| @@ -50,7 +51,7 @@ struct OrgLayoutBuilder { | |||
| 50 | title += cookie | 51 | title += cookie |
| 51 | } | 52 | } |
| 52 | let level = min(6, max(1, heading.level + options.headingLevelOffset)) | 53 | let level = min(6, max(1, heading.level + options.headingLevelOffset)) |
| 53 | title += text(heading.title, ¬es, style: Self.headingStyle(level)) | 54 | title += text(heading.title, ¬es, style: Self.headingStyle(level), bold: true) |
| 54 | for tag in heading.tags { | 55 | for tag in heading.tags { |
| 55 | var chip = AttributedString(" \(tag)") | 56 | var chip = AttributedString(" \(tag)") |
| 56 | chip.foregroundColor = .secondary | 57 | chip.foregroundColor = .secondary |
Sources/OrgSwiftUI/OrgView.swift +13 −4
| @@ -9,14 +9,16 @@ import SwiftUI | |||
| 9 | /// ``` | 9 | /// ``` |
| 10 | /// | 10 | /// |
| 11 | /// The parse, footnote numbering, URL resolution and syntax highlighting run once off | 11 | /// The parse, footnote numbering, URL resolution and syntax highlighting run once off |
| 12 | /// `body`, in a task keyed to the source, so scrolling and Dynamic Type changes do not | 12 | /// `body`, in a task keyed to the source *and the color scheme*, so scrolling and Dynamic |
| 13 | /// re-derive them. Text is selectable, links open through the environment's `openURL`, and | 13 | /// Type changes do not re-derive them — but switching to dark mode does, because a |
| 14 | /// everything scales with Dynamic Type. | 14 | /// ``OrgCodeStyler`` bakes its theme's colors into the text it returns. Text is selectable, |
| 15 | /// links open through the environment's `openURL`, and everything scales with Dynamic Type. | ||
| 15 | public struct OrgView: View { | 16 | public struct OrgView: View { |
| 16 | private let source: String | 17 | private let source: String |
| 17 | private let options: OrgRenderOptions | 18 | private let options: OrgRenderOptions |
| 18 | private let styler: OrgCodeStyler | 19 | private let styler: OrgCodeStyler |
| 19 | 20 | ||
| 21 | @Environment(\.colorScheme) private var colorScheme | ||
| 20 | @State private var layout = OrgLayout() | 22 | @State private var layout = OrgLayout() |
| 21 | 23 | ||
| 22 | public init( | 24 | public init( |
| @@ -38,13 +40,20 @@ public struct OrgView: View { | |||
| 38 | } | 40 | } |
| 39 | .frame(maxWidth: .infinity, alignment: .leading) | 41 | .frame(maxWidth: .infinity, alignment: .leading) |
| 40 | .textSelection(.enabled) | 42 | .textSelection(.enabled) |
| 41 | .task(id: source) { | 43 | .task(id: LayoutKey(source: source, colorScheme: colorScheme)) { |
| 42 | layout = OrgLayoutBuilder(options: options, styler: styler) | 44 | layout = OrgLayoutBuilder(options: options, styler: styler) |
| 43 | .layout(OrgParser.parse(source)) | 45 | .layout(OrgParser.parse(source)) |
| 44 | } | 46 | } |
| 45 | } | 47 | } |
| 46 | } | 48 | } |
| 47 | 49 | ||
| 50 | /// What the prepared layout depends on. Highlighted code carries baked-in colors, so the | ||
| 51 | /// scheme belongs in the key alongside the source. | ||
| 52 | private struct LayoutKey: Equatable { | ||
| 53 | var source: String | ||
| 54 | var colorScheme: ColorScheme | ||
| 55 | } | ||
| 56 | |||
| 48 | /// The notes section org's exporter puts at the end of a document. | 57 | /// The notes section org's exporter puts at the end of a document. |
| 49 | struct OrgFootnotesView: View { | 58 | struct OrgFootnotesView: View { |
| 50 | let footnotes: [OrgFootnote] | 59 | let footnotes: [OrgFootnote] |
Tests/OrgSwiftUITests/OrgLayoutTests.swift +22
| @@ -179,6 +179,28 @@ struct OrgLayoutTests { | |||
| 179 | #expect(layout("#+begin_export latex\n\\emph{hi}\n#+end_export").blocks.isEmpty) | 179 | #expect(layout("#+begin_export latex\n\\emph{hi}\n#+end_export").blocks.isEmpty) |
| 180 | } | 180 | } |
| 181 | 181 | ||
| 182 | /// Every run carries a font, not only the emphasised ones. A plain heading run with no | ||
| 183 | /// font would inherit the surrounding view's and render at body size. | ||
| 184 | @Test | ||
| 185 | func everyRunCarriesItsBlockFont() { | ||
| 186 | guard case .heading(let title, _) = layout("* Plain heading").blocks.first else { | ||
| 187 | Issue.record("expected a heading"); return | ||
| 188 | } | ||
| 189 | #expect(title.runs.allSatisfy { $0.font != nil }) | ||
| 190 | #expect(title.runs.contains { $0.font == Font.system(.title, design: .default).bold() }) | ||
| 191 | |||
| 192 | // Same for the blocks that render at a non-body style. | ||
| 193 | guard case .table(let table) = layout("| a |\n|---|\n| b |").blocks.first else { | ||
| 194 | Issue.record("expected a table"); return | ||
| 195 | } | ||
| 196 | #expect(table.rows.allSatisfy { $0.cells.allSatisfy { cell in | ||
| 197 | cell.text.runs.allSatisfy { $0.font != nil } | ||
| 198 | } }) | ||
| 199 | |||
| 200 | let notes = layout("Claim[fn:a]\n\n[fn:a] note").footnotes | ||
| 201 | #expect(notes.first?.text.runs.allSatisfy { $0.font != nil } == true) | ||
| 202 | } | ||
| 203 | |||
| 182 | /// Emphasis nests, and the styler turns intents into fonts rather than dropping them. | 204 | /// Emphasis nests, and the styler turns intents into fonts rather than dropping them. |
| 183 | @Test | 205 | @Test |
| 184 | func inlineStylingCarriesNestedEmphasis() { | 206 | func inlineStylingCarriesNestedEmphasis() { |