diff --git a/CHANGELOG.md b/CHANGELOG.md index dc542980..a1a30b4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- Checked-task treatment knobs: `TaskCheckboxStyle.strikethroughCompletedTasks` + gates the completed label's strikethrough (default on, as before), + `MarkdownEditorTheme.completedTaskText` colors the completed label (nil = + body ink; inline constructs keep their own colors), and + `MarkdownEditorTheme.taskCheckboxChecked` / `taskCheckboxUnchecked` tint the + drawn checkbox symbols (nil = the historical `bodyText` / `mutedText`). +- Custom heading typeface and color: `HeadingStyle.fontName` renders headings + in a specific PostScript face (honored exactly, so the chosen weight is + respected; an unresolvable name falls back to the stock bold base font), + and `MarkdownEditorTheme.headingText` colors heading text independently of + `bodyText` — the `#` glyphs stay on `headingMarker`, and inline constructs + inside a heading keep their own ink (both opt-in; the defaults are + unchanged). + ## [0.11.0] - 2026-07-31 ### Added diff --git a/Sources/MarkdownEngine/Configuration/MarkdownEditorConfiguration.swift b/Sources/MarkdownEngine/Configuration/MarkdownEditorConfiguration.swift index 871d144c..06107b18 100644 --- a/Sources/MarkdownEngine/Configuration/MarkdownEditorConfiguration.swift +++ b/Sources/MarkdownEngine/Configuration/MarkdownEditorConfiguration.swift @@ -325,20 +325,28 @@ public struct ListStyle: Sendable { /// example `"circle"` / `"checkmark.circle.fill"`. A name that doesn't /// resolve falls back to the corresponding default symbol at draw time, so a /// typo degrades to the stock look instead of drawing nothing. Tint colors -/// stay theme-driven (`MarkdownEditorTheme/mutedText` unchecked, -/// `MarkdownEditorTheme/bodyText` checked). +/// are theme-driven: ``MarkdownEditorTheme/taskCheckboxUnchecked`` / +/// ``MarkdownEditorTheme/taskCheckboxChecked``, which default to +/// `mutedText` unchecked and `bodyText` checked. public struct TaskCheckboxStyle: Sendable { /// SF Symbol drawn for an unchecked task item (`[ ]`). public var uncheckedSymbolName: String /// SF Symbol drawn for a checked task item (`[x]`). public var checkedSymbolName: String + /// Whether a checked item's label is struck through. `true` (the + /// default) keeps the historical rendering. Designs that mark completion + /// by ink alone can turn this off and set + /// ``MarkdownEditorTheme/completedTaskText`` instead. + public var strikethroughCompletedTasks: Bool public init( uncheckedSymbolName: String = "square", - checkedSymbolName: String = "checkmark.square.fill" + checkedSymbolName: String = "checkmark.square.fill", + strikethroughCompletedTasks: Bool = true ) { self.uncheckedSymbolName = uncheckedSymbolName self.checkedSymbolName = checkedSymbolName + self.strikethroughCompletedTasks = strikethroughCompletedTasks } public static let `default` = TaskCheckboxStyle() @@ -349,15 +357,30 @@ public struct TaskCheckboxStyle: Sendable { /// Per-level heading metrics. Defaults follow the historical Nodes ratios, /// which are loosely based on browser default heading sizes. public struct HeadingStyle: Sendable { + /// PostScript name of the typeface used for heading text, for example + /// `"AvenirNext-DemiBold"`. `nil` (the default) keeps the historical + /// behavior: headings render in the editor's base font with the bold + /// trait added. + /// + /// The name is honored exactly, so the chosen face's weight and style + /// are respected — pick a `-Bold` / `-Semibold` face for heavier + /// headings. Emphasis inside a heading still composes on top of it: + /// bold / italic add their traits while the family and the per-level + /// size are kept. A name that doesn't resolve falls back to the default + /// heading font at draw time, so a typo degrades to the stock look + /// instead of changing metrics. + public var fontName: String? /// Font-size multiplier per heading level (1...6). public var fontMultipliers: [CGFloat] /// Top spacing in `em` units per heading level (1...6). public var topSpacingEm: [CGFloat] public init( + fontName: String? = nil, fontMultipliers: [CGFloat] = [2.0, 1.5, 1.17, 1.0, 0.83, 0.67], topSpacingEm: [CGFloat] = [0.35, 0.30, 0.25, 0.20, 0.15, 0.10] ) { + self.fontName = fontName self.fontMultipliers = fontMultipliers self.topSpacingEm = topSpacingEm } diff --git a/Sources/MarkdownEngine/Configuration/MarkdownEditorTheme.swift b/Sources/MarkdownEngine/Configuration/MarkdownEditorTheme.swift index cc06a7f2..94749d3f 100644 --- a/Sources/MarkdownEngine/Configuration/MarkdownEditorTheme.swift +++ b/Sources/MarkdownEngine/Configuration/MarkdownEditorTheme.swift @@ -36,8 +36,34 @@ public struct MarkdownEditorTheme: Sendable { /// Foreground color for content the engine wants to deemphasize further /// than `mutedText` — for example, broken wiki-links. public var disabledText: NSColor + /// Foreground color for heading text. `nil` (the default) keeps the + /// historical behavior: headings render in ``bodyText`` like the rest + /// of the document. + /// + /// Only the heading's own text takes this color. The `#` marker glyphs + /// stay on ``headingMarker``, and inline constructs inside a heading + /// (links, inline code, extension spans) keep their own colors, exactly + /// as they do over ``bodyText``. + public var headingText: NSColor? /// Foreground color for heading marker glyphs (`#`, `##`, …). public var headingMarker: NSColor + /// Foreground color for a CHECKED task item's label. `nil` (the default) + /// keeps the historical behavior: the label stays on the document's body + /// ink. Inline constructs inside the label (links, code, extension + /// spans) keep their own colors either way, exactly as they do over + /// ``bodyText``. Pairs with + /// ``TaskCheckboxStyle/strikethroughCompletedTasks`` for designs that + /// mark completion by ink instead of a strikethrough. + public var completedTaskText: NSColor? + + // MARK: Task checkboxes + + /// Tint of the drawn checkbox symbol for a CHECKED task item. `nil` + /// (the default) keeps the historical ``bodyText`` tint. + public var taskCheckboxChecked: NSColor? + /// Tint of the drawn checkbox symbol for an UNCHECKED task item. `nil` + /// (the default) keeps the historical ``mutedText`` tint. + public var taskCheckboxUnchecked: NSColor? // MARK: Links @@ -85,7 +111,11 @@ public struct MarkdownEditorTheme: Sendable { bodyText: NSColor = .labelColor, mutedText: NSColor = .secondaryLabelColor, disabledText: NSColor = .tertiaryLabelColor, + headingText: NSColor? = nil, headingMarker: NSColor = .gray, + completedTaskText: NSColor? = nil, + taskCheckboxChecked: NSColor? = nil, + taskCheckboxUnchecked: NSColor? = nil, link: NSColor = .linkColor, incompleteLink: NSColor = .systemBlue, findMatchHighlight: NSColor = .systemYellow, @@ -98,7 +128,11 @@ public struct MarkdownEditorTheme: Sendable { self.bodyText = bodyText self.mutedText = mutedText self.disabledText = disabledText + self.headingText = headingText self.headingMarker = headingMarker + self.completedTaskText = completedTaskText + self.taskCheckboxChecked = taskCheckboxChecked + self.taskCheckboxUnchecked = taskCheckboxUnchecked self.link = link self.incompleteLink = incompleteLink self.findMatchHighlight = findMatchHighlight diff --git a/Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift b/Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift index 9c0c5ea7..e3604c51 100644 --- a/Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift +++ b/Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift @@ -651,7 +651,9 @@ final class MarkdownTextLayoutFragment: NSTextLayoutFragment { if let baseSymbol = NSImage(systemSymbolName: symbolName, accessibilityDescription: nil) ?? NSImage(systemSymbolName: fallbackName, accessibilityDescription: nil) { let sizeConfig = NSImage.SymbolConfiguration(pointSize: iconRect.height, weight: .regular) - let tint = isChecked ? configuration.theme.bodyText : configuration.theme.mutedText + let tint = isChecked + ? (configuration.theme.taskCheckboxChecked ?? configuration.theme.bodyText) + : (configuration.theme.taskCheckboxUnchecked ?? configuration.theme.mutedText) let colorConfig = NSImage.SymbolConfiguration(hierarchicalColor: tint) let symbolConfig = sizeConfig.applying(colorConfig) let symbol = baseSymbol.withSymbolConfiguration(symbolConfig) ?? baseSymbol diff --git a/Sources/MarkdownEngine/Styling/MarkdownASTStyler.swift b/Sources/MarkdownEngine/Styling/MarkdownASTStyler.swift index e76cacb2..e4c19819 100644 --- a/Sources/MarkdownEngine/Styling/MarkdownASTStyler.swift +++ b/Sources/MarkdownEngine/Styling/MarkdownASTStyler.swift @@ -404,10 +404,20 @@ enum MarkdownASTStyler { attrs.append((postGap, [.foregroundColor: NSColor.clear, .font: ctx.inlineMarkerFont])) } if item.checked, NSMaxRange(item.range) > NSMaxRange(box) { - attrs.append((NSRange(location: NSMaxRange(box), length: NSMaxRange(item.range) - NSMaxRange(box)), [ - .strikethroughStyle: NSUnderlineStyle.single.rawValue, - .strikethroughColor: ctx.theme.strikethroughColor, - ])) + let label = NSRange(location: NSMaxRange(box), length: NSMaxRange(item.range) - NSMaxRange(box)) + // theme.completedTaskText paints the whole label; inline + // constructs (links, code, extension spans) append LATER via + // styleInlines, so they keep their own ink — the same + // later-range-wins layering the bodyText default relies on. + if let completedInk = ctx.theme.completedTaskText { + attrs.append((label, [.foregroundColor: completedInk])) + } + if ctx.config.taskCheckbox.strikethroughCompletedTasks { + attrs.append((label, [ + .strikethroughStyle: NSUnderlineStyle.single.rawValue, + .strikethroughColor: ctx.theme.strikethroughColor, + ])) + } } } else if !item.ordered { let syntax = NSRange(location: item.marker.location, @@ -548,9 +558,15 @@ enum MarkdownASTStyler { case .heading(let level, let range, let markers, let inlines): let multiplier = ctx.config.headings.fontMultiplier(for: level) - let headingBase = NSFont(name: ctx.fontName, size: ctx.baseFont.pointSize * multiplier) - ?? .systemFont(ofSize: ctx.baseFont.pointSize * multiplier) - let headingFont = adding(.bold, to: headingBase) + let headingSize = ctx.baseFont.pointSize * multiplier + // A configured heading face is honored exactly — its weight is the + // embedder's choice, so no synthetic bold on top. A name that + // doesn't resolve degrades to the stock heading font (base family, + // bold trait), mirroring TaskCheckboxStyle's symbol fallback. + let headingFont = ctx.config.headings.fontName + .flatMap { NSFont(name: $0, size: headingSize) } + ?? adding(.bold, to: NSFont(name: ctx.fontName, size: headingSize) + ?? .systemFont(ofSize: headingSize)) let lineHeight = ceil(headingFont.ascender - headingFont.descender + headingFont.leading) + 1 let headingPara = NSMutableParagraphStyle() headingPara.minimumLineHeight = lineHeight @@ -558,7 +574,15 @@ enum MarkdownASTStyler { headingPara.paragraphSpacingBefore = headingFont.pointSize * ctx.config.headings.topSpacingEm(for: level) headingPara.paragraphSpacing = ctx.baseParagraphSpacing attrs.append((ctx.ns.paragraphRange(for: range), [.paragraphStyle: headingPara])) - attrs.append((range, [.font: headingFont])) + // theme.headingText paints the whole heading line; the marker loop + // and the inline descent below both append LATER, so `#` glyphs + // keep headingMarker and links / code keep their own ink — the + // same later-range-wins layering the bodyText default relies on. + var headingAttrs: [NSAttributedString.Key: Any] = [.font: headingFont] + if let headingText = ctx.theme.headingText { + headingAttrs[.foregroundColor] = headingText + } + attrs.append((range, headingAttrs)) for marker in markers { attrs.append((marker, [.foregroundColor: ctx.theme.headingMarker])) } diff --git a/Tests/MarkdownEngineTests/HeadingFontAndColorTests.swift b/Tests/MarkdownEngineTests/HeadingFontAndColorTests.swift new file mode 100644 index 00000000..bb8a8acb --- /dev/null +++ b/Tests/MarkdownEngineTests/HeadingFontAndColorTests.swift @@ -0,0 +1,179 @@ +// +// HeadingFontAndColorTests.swift +// MarkdownEngineTests +// +// The two opt-in heading knobs: `HeadingStyle.fontName` (a dedicated heading +// typeface) and `MarkdownEditorTheme.headingText` (a dedicated heading text +// color). Both default to nil, which must keep the stock styling unchanged — +// headings derive from the base font with the bold trait and inherit the +// view-level bodyText foreground. +// + +import AppKit +import Foundation +import Testing +@testable import MarkdownEngine + +@Suite("Heading font & color knobs") +struct HeadingFontAndColorTests { + + private let base: CGFloat = 14 + private var fontName: String { NSFont.systemFont(ofSize: 14).fontName } + + /// A real, always-installed face that differs from the system font in both + /// family and weight, so assertions can see it was used verbatim. + private let headingFace = "Menlo-Regular" + + /// Effective font at `pos`: the last styled range covering it that sets `.font`. + private func font(in attrs: [StyledRange], at pos: Int) -> NSFont? { + var result: NSFont? + for (range, a) in attrs where NSLocationInRange(pos, range) { + if let f = a[.font] as? NSFont { result = f } + } + return result + } + + /// Effective color at `pos`: the last styled range covering it that sets `.foregroundColor`. + private func color(in attrs: [StyledRange], at pos: Int) -> NSColor? { + var result: NSColor? + for (range, a) in attrs where NSLocationInRange(pos, range) { + if let c = a[.foregroundColor] as? NSColor { result = c } + } + return result + } + + private func style( + _ text: String, + configuration: MarkdownEditorConfiguration = .default + ) -> [StyledRange] { + MarkdownASTStyler.styleAttributes( + text: text, fontName: fontName, fontSize: base, configuration: configuration + ) + } + + // MARK: - HeadingStyle.fontName + + @Test("headings.fontName renders headings in that face at the multiplied size") + func headingFontNameUsedVerbatimAtMultipliedSize() { + let config = MarkdownEditorConfiguration(headings: HeadingStyle(fontName: headingFace)) + // "# One\n\nbody\n\n## Two": O=2, b=7, T=16 + let attrs = style("# One\n\nbody\n\n## Two", configuration: config) + + let h1 = font(in: attrs, at: 2) + #expect(h1?.fontName == headingFace) + #expect(h1?.pointSize == base * 2.0) + // The face is honored exactly: no synthetic bold on the chosen weight. + #expect(h1?.fontDescriptor.symbolicTraits.contains(.bold) == false) + + // Per-level multipliers still apply to the custom face. + let h2 = font(in: attrs, at: 16) + #expect(h2?.fontName == headingFace) + #expect(h2?.pointSize == base * 1.5) + + // Body text never takes the heading face (no .font range at all). + #expect(font(in: attrs, at: 7) == nil) + } + + @Test("emphasis inside a custom-face heading keeps family and size, adds traits") + func emphasisComposesOnTheCustomHeadingFace() { + let config = MarkdownEditorConfiguration(headings: HeadingStyle(fontName: headingFace)) + // "# **n*o*des**": n=4, o=6, d=8 + let attrs = style("# **n*o*des**", configuration: config) + let n = font(in: attrs, at: 4) + let o = font(in: attrs, at: 6) + let d = font(in: attrs, at: 8) + + #expect(n?.familyName == "Menlo") + #expect(o?.familyName == "Menlo") + #expect(d?.familyName == "Menlo") + #expect(n?.pointSize == base * 2.0) + #expect(o?.pointSize == base * 2.0) + #expect(d?.pointSize == base * 2.0) + #expect(n?.fontDescriptor.symbolicTraits.contains(.bold) == true) + #expect(o?.fontDescriptor.symbolicTraits.contains([.bold, .italic]) == true) + #expect(d?.fontDescriptor.symbolicTraits.contains(.bold) == true) + } + + @Test("an unresolvable fontName falls back to the stock heading font") + func unresolvableFontNameFallsBack() { + let config = MarkdownEditorConfiguration( + headings: HeadingStyle(fontName: "Not-A-Real-Font-Face") + ) + let stock = font(in: style("# Title"), at: 2) + let fallback = font(in: style("# Title", configuration: config), at: 2) + #expect(fallback == stock) + #expect(fallback?.fontDescriptor.symbolicTraits.contains(.bold) == true) + } + + // MARK: - MarkdownEditorTheme.headingText + + @Test("theme.headingText colors heading text; # markers and body keep their own ink") + func headingTextColorsContentOnly() { + var theme = MarkdownEditorTheme.default + theme.headingText = .systemPink + let config = MarkdownEditorConfiguration(theme: theme) + // "# Title\n\nbody": marker=0..1, T=2, b=8 + let attrs = style("# Title\n\nbody", configuration: config) + + #expect(color(in: attrs, at: 2) == .systemPink) + // The `#` marker glyphs stay on headingMarker (the separate knob). + #expect(color(in: attrs, at: 0) == theme.headingMarker) + // Body text still inherits the view-level bodyText (no styled foreground). + #expect(color(in: attrs, at: 8) == nil) + } + + @Test("a link inside a colored heading keeps the link ink") + func linkInsideColoredHeadingKeepsLinkColor() { + var theme = MarkdownEditorTheme.default + theme.headingText = .systemPink + let config = MarkdownEditorConfiguration(theme: theme) + // "# [x](https://e.com)": x=3 + let attrs = style("# [x](https://e.com)", configuration: config) + #expect(color(in: attrs, at: 3) == theme.link) + } + + @Test("emphasis inside a colored heading keeps the heading color") + func emphasisInsideColoredHeadingKeepsHeadingColor() { + var theme = MarkdownEditorTheme.default + theme.headingText = .systemPink + let config = MarkdownEditorConfiguration(theme: theme) + // "# **bold**": b=4 — emphasis composes fonts only, so the ink survives. + let attrs = style("# **bold**", configuration: config) + #expect(color(in: attrs, at: 4) == .systemPink) + } + + // MARK: - Defaults stay byte-identical + + @Test("nil knobs: heading content carries the stock font and no foreground") + func nilKnobsKeepStockHeadingAttributes() { + // "# Title": T=2 + let attrs = style("# Title") + let heading = font(in: attrs, at: 2) + let stock = NSFont(name: fontName, size: base * 2.0) ?? .systemFont(ofSize: base * 2.0) + let stockBold = NSFont( + descriptor: stock.fontDescriptor.withSymbolicTraits( + stock.fontDescriptor.symbolicTraits.union(.bold)), + size: stock.pointSize + ) ?? stock + #expect(heading == stockBold) + // No styled range sets a heading foreground — bodyText inheritance. + #expect(color(in: attrs, at: 2) == nil) + } + + @Test("explicit-nil knobs produce value-identical styling to .default") + func nilKnobsMatchDefaultsExactly() { + let doc = "# One **bold** *i*\n\nbody `code`\n\n## Two\n\n- item\n\n> quote\n" + let expected = style(doc) + let explicitNil = MarkdownEditorConfiguration( + theme: MarkdownEditorTheme(headingText: nil), + headings: HeadingStyle(fontName: nil) + ) + let actual = style(doc, configuration: explicitNil) + + #expect(actual.count == expected.count) + for (a, e) in zip(actual, expected) { + #expect(a.range == e.range) + #expect((a.attributes as NSDictionary).isEqual(to: e.attributes)) + } + } +} diff --git a/Tests/MarkdownEngineTests/TaskStylingTests.swift b/Tests/MarkdownEngineTests/TaskStylingTests.swift new file mode 100644 index 00000000..46c28cf8 --- /dev/null +++ b/Tests/MarkdownEngineTests/TaskStylingTests.swift @@ -0,0 +1,112 @@ +// +// TaskStylingTests.swift +// MarkdownEngineTests +// +// Checked-task treatment knobs: the strikethrough gate +// (`TaskCheckboxStyle.strikethroughCompletedTasks`), the completed-label ink +// (`MarkdownEditorTheme.completedTaskText`), and the checkbox tint slots. +// Defaults must keep the historical rendering (strikethrough on, body ink). +// + +import AppKit +import Foundation +import Testing +@testable import MarkdownEngine + +@Suite("Task styling knobs") +struct TaskStylingTests { + + private let base: CGFloat = 16 + private var fontName: String { NSFont.systemFont(ofSize: 16).fontName } + + private func style( + _ text: String, + theme: MarkdownEditorTheme = .default, + strikethrough: Bool = true + ) -> [StyledRange] { + MarkdownASTStyler.styleAttributes( + text: text, fontName: fontName, fontSize: base, + configuration: MarkdownEditorConfiguration( + theme: theme, + taskCheckbox: TaskCheckboxStyle(strikethroughCompletedTasks: strikethrough) + ) + ) + } + + /// Whether any styled range covering `pos` sets a strikethrough. + private func hasStrikethrough(in attrs: [StyledRange], at pos: Int) -> Bool { + attrs.contains { range, a in + NSLocationInRange(pos, range) && a[.strikethroughStyle] != nil + } + } + + /// Effective foreground at `pos` (last styled range wins). + private func color(in attrs: [StyledRange], at pos: Int) -> NSColor? { + var result: NSColor? + for (range, a) in attrs where NSLocationInRange(pos, range) { + if let c = a[.foregroundColor] as? NSColor { result = c } + } + return result + } + + private let text = "- [x] done thing\n- [ ] open thing\n" + private var donePos: Int { (text as NSString).range(of: "done").location } + private var openPos: Int { (text as NSString).range(of: "open").location } + + // MARK: - Strikethrough gate + + @Test("the default keeps the historical strikethrough on checked labels") + func defaultKeepsStrikethrough() { + let attrs = style(text) + #expect(hasStrikethrough(in: attrs, at: donePos)) + #expect(!hasStrikethrough(in: attrs, at: openPos)) + // And the label keeps the document ink — no foreground override. + #expect(color(in: attrs, at: donePos) == nil) + } + + @Test("strikethroughCompletedTasks = false drops the strikethrough") + func gateOffDropsStrikethrough() { + let attrs = style(text, strikethrough: false) + #expect(!hasStrikethrough(in: attrs, at: donePos)) + #expect(!hasStrikethrough(in: attrs, at: openPos)) + } + + // MARK: - Completed label ink + + @Test("completedTaskText colors only the checked label") + func completedInkColorsCheckedLabelOnly() { + let muted = NSColor(calibratedWhite: 0.45, alpha: 1) + let attrs = style(text, theme: MarkdownEditorTheme(completedTaskText: muted)) + #expect(color(in: attrs, at: donePos) == muted) + #expect(color(in: attrs, at: openPos) == nil, "unchecked labels keep the document ink") + } + + @Test("inline constructs inside a completed label keep their own ink") + func inlineInkWinsInsideCompletedLabel() { + let muted = NSColor(calibratedWhite: 0.45, alpha: 1) + let linked = "- [x] see [docs](https://example.com) now\n" + let attrs = MarkdownASTStyler.styleAttributes( + text: linked, fontName: fontName, fontSize: base, + configuration: MarkdownEditorConfiguration( + theme: MarkdownEditorTheme(completedTaskText: muted) + ) + ) + let ns = linked as NSString + #expect(color(in: attrs, at: ns.range(of: "see").location) == muted) + // The link label appends after the task pass, so its ink wins. + let linkPos = ns.range(of: "docs").location + #expect(color(in: attrs, at: linkPos) == MarkdownEditorTheme.default.link) + } + + // MARK: - Checkbox tint slots + + @Test("checkbox tint slots default to nil and carry custom inks") + func checkboxTintSlots() { + #expect(MarkdownEditorTheme.default.taskCheckboxChecked == nil) + #expect(MarkdownEditorTheme.default.taskCheckboxUnchecked == nil) + let brand = NSColor(calibratedRed: 1, green: 0.8, blue: 0.81, alpha: 1) + let theme = MarkdownEditorTheme(taskCheckboxChecked: brand, taskCheckboxUnchecked: .gray) + #expect(theme.taskCheckboxChecked == brand) + #expect(theme.taskCheckboxUnchecked == .gray) + } +}