diff --git a/CHANGELOG.md b/CHANGELOG.md index d247ca08..91b2d83e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- List geometry knobs so an embedder can pin the marker column: + `ListStyle.leadingIndent` (first-line indent of every list item, previously a + silent reuse of `indentPerLevel`), `ListStyle.markerColumnWidth` (marker + column start to item text, kerned onto the `-` glyph for unordered items), + `ListStyle.markerCenterOffset` (bullet / checkbox centre inside that column, + shared by drawing and the checkbox hit-test), and `BulletStyle.diameter`. + The filled dot is now drawn as a vector like the other shapes, so one + diameter and one x-height-midline baseline apply at every depth, and the + checkbox centres on the text's cap height. Defaults reproduce the previous + rendering. + +### Fixed +- A pinned marker column now measures the item's own marker (`*` and `+`, not + always `-`), so every unordered marker puts its content in the same column. +- Bullet shapes snap their origin to the device pixel grid, like the task + checkbox already did, so a small drawn dot is not blurred across two pixels. +- In `.scrolls`, the body re-fills its viewport after a viewport change even + when the content height and the resolved overscroll are both unchanged. + `recalcOverscroll` used to skip the frame update in that case, so the text + view could stay shorter than its clip: the strip below the text then belonged + to the container and a click there placed no caret. + ## [0.10.0] - 2026-07-15 ### Added diff --git a/Sources/MarkdownEngine/Configuration/MarkdownEditorConfiguration.swift b/Sources/MarkdownEngine/Configuration/MarkdownEditorConfiguration.swift index 0ce6261b..d967057c 100644 --- a/Sources/MarkdownEngine/Configuration/MarkdownEditorConfiguration.swift +++ b/Sources/MarkdownEngine/Configuration/MarkdownEditorConfiguration.swift @@ -270,8 +270,94 @@ public struct InlineCodeStyle: Sendable { // MARK: - Lists +public enum BulletShape: Sendable, Equatable { + case filledDot + case hollowRing + case smallSquare + case triangle +} + +public struct BulletStyle: Sendable, Equatable { + /// Index 0 is depth 1; the last entry repeats for deeper levels. + public var shapeLadder: [BulletShape] + /// nil uses `theme.bodyText`. + public var color: NSColor? + /// Ink diameter of the drawn shape, in points. nil derives it from the + /// font (`round(pointSize * 0.32)`), which is the historical size. + public var diameter: CGFloat? + /// Stroke width of the `.hollowRing` shape. + public var ringStrokeWidth: CGFloat + + public init( + shapeLadder: [BulletShape] = [.filledDot], + color: NSColor? = nil, + diameter: CGFloat? = nil, + ringStrokeWidth: CGFloat = 1 + ) { + self.shapeLadder = shapeLadder + self.color = color + self.diameter = diameter + self.ringStrokeWidth = ringStrokeWidth + } + + public static let `default` = BulletStyle() + + /// 1-based depth -> shape, clamped; empty ladder -> `.filledDot`. + public func shape(forDepth depth: Int) -> BulletShape { + guard !shapeLadder.isEmpty else { return .filledDot } + return shapeLadder[min(max(depth, 1), shapeLadder.count) - 1] + } +} + +public struct TaskCheckboxStyle: Sendable, Equatable { + /// How the box is painted. `.systemSymbol` is the historical rendering + /// (SF Symbols `square` / `checkmark.square.fill`) and ignores the stroke, + /// radius and colour fields below. + public enum Rendering: Sendable, Equatable { + case systemSymbol + case drawn + } + + public var rendering: Rendering + /// nil derives the size from the font. + public var size: CGFloat? + public var strokeWidth: CGFloat + public var cornerRadius: CGFloat + /// Gap between the box's right edge and the task content's left edge. A + /// larger box needs a larger gap or the label reads as touching it. + public var gap: CGFloat + /// nil uses `theme.mutedText`. + public var uncheckedColor: NSColor? + /// nil uses `theme.bodyText`. + public var checkedFillColor: NSColor? + /// nil uses `NSColor.white`; the theme has no background color. + public var checkmarkColor: NSColor? + + public init( + rendering: Rendering = .systemSymbol, + size: CGFloat? = nil, + strokeWidth: CGFloat = 1, + cornerRadius: CGFloat = 3, + gap: CGFloat = 2, + uncheckedColor: NSColor? = nil, + checkedFillColor: NSColor? = nil, + checkmarkColor: NSColor? = nil + ) { + self.rendering = rendering + self.size = size + self.strokeWidth = strokeWidth + self.cornerRadius = cornerRadius + self.gap = gap + self.uncheckedColor = uncheckedColor + self.checkedFillColor = checkedFillColor + self.checkmarkColor = checkmarkColor + } + + public static let `default` = TaskCheckboxStyle() +} + /// Behavior toggles and metrics for ordered / unordered list editing. -public struct ListStyle: Sendable { +public struct ListStyle: Sendable, Equatable { /// Master switch for list-related editing helpers (auto-continue, /// auto-indent, marker conversion). When `false`, lists are still /// rendered, but typing-time conveniences are skipped. @@ -280,23 +366,45 @@ public struct ListStyle: Sendable { public var autoClosePairsEnabled: Bool /// Indent (in points) that one nesting level adds to the list item. public var indentPerLevel: CGFloat + /// `firstLineHeadIndent` of every list item, i.e. the x of the marker + /// column for a depth-0 item. The default matches the historical + /// behaviour, where a list item silently reused `indentPerLevel`. + public var leadingIndent: CGFloat + /// Distance from the marker column start to the item text. nil keeps the + /// natural advance of the `"- "` marker. Unordered items only. + public var markerColumnWidth: CGFloat? + /// x of the bullet / checkbox centre, measured from the marker column + /// start. nil centres the shape inside the marker advance. + public var markerCenterOffset: CGFloat? /// Maximum nesting level reachable by pressing Tab inside a list. public var maximumNestingLevel: Int /// Extra line height added on top of the default to give list items room. public var extraLineHeight: CGFloat + public var bullets: BulletStyle + public var taskCheckbox: TaskCheckboxStyle public init( helpersEnabled: Bool = true, autoClosePairsEnabled: Bool = true, indentPerLevel: CGFloat = 27.5, + leadingIndent: CGFloat = 27.5, + markerColumnWidth: CGFloat? = nil, + markerCenterOffset: CGFloat? = nil, maximumNestingLevel: Int = 3, - extraLineHeight: CGFloat = 2 + extraLineHeight: CGFloat = 2, + bullets: BulletStyle = .default, + taskCheckbox: TaskCheckboxStyle = .default ) { self.helpersEnabled = helpersEnabled self.autoClosePairsEnabled = autoClosePairsEnabled self.indentPerLevel = indentPerLevel + self.leadingIndent = leadingIndent + self.markerColumnWidth = markerColumnWidth + self.markerCenterOffset = markerCenterOffset self.maximumNestingLevel = maximumNestingLevel self.extraLineHeight = extraLineHeight + self.bullets = bullets + self.taskCheckbox = taskCheckbox } public static let `default` = ListStyle() diff --git a/Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift b/Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift index 3184dcd2..ea8cb672 100644 --- a/Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift +++ b/Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift @@ -24,6 +24,9 @@ extension NSAttributedString.Key { /// Marks a bullet-list marker char (`-`/`*`/`+`) whose glyph is hidden so /// the fragment can paint a `•` in its place. Set to `true`. static let bulletMarker = NSAttributedString.Key("BulletListMarker") + /// Int nesting level (1-based) of a bullet-list marker; selects the shape + /// from `BulletStyle.shapeLadder`. Absent means depth 1. + static let bulletListLevel = NSAttributedString.Key("BulletListLevel") /// CGFloat — natural image width; presence flags block as overlay-rendered. static let scrollableBlockNaturalWidth = NSAttributedString.Key("ScrollableBlockNaturalWidth") /// Int — hash of source text; key for overlay reconcile + offset persistence. @@ -508,10 +511,12 @@ final class MarkdownTextLayoutFragment: NSTextLayoutFragment { // MARK: - Bullet Markers - /// Paint a `•` over every hidden bullet marker (`.bulletMarker`). The - /// glyph is drawn in the same font as the source so its baseline matches - /// the surrounding text, and centered within the original marker char's - /// advance so a `•` of a different width still sits where `-`/`*`/`+` was. + /// Paint a vector shape over every hidden bullet marker (`.bulletMarker`). + /// Every depth uses the same drawn shape, so one diameter + /// (`BulletStyle.diameter`, else `round(pointSize * 0.32)`) governs all of + /// them. The centre sits on the x-height midline, and at + /// `ListStyle.markerCenterOffset` from the marker column start when the + /// embedder pins the column; otherwise in the middle of the marker advance. private func drawBulletMarkers(at point: CGPoint, in context: CGContext) { guard let ts = textStorage, let range = fragmentNSRange, range.length > 0 else { return } let selectionRanges: [NSRange] = { @@ -524,8 +529,10 @@ final class MarkdownTextLayoutFragment: NSTextLayoutFragment { let nsContext = NSGraphicsContext(cgContext: context, flipped: true) NSGraphicsContext.current = nsContext - let theme = (textLayoutManager?.textContainer?.textView as? NativeTextView)? - .configuration.theme ?? .default + let configuration = (textLayoutManager?.textContainer?.textView as? NativeTextView)?.configuration + let theme = configuration?.theme ?? .default + let lists = configuration?.lists ?? .default + let style = lists.bullets let storageString = ts.string as NSString ts.enumerateAttribute(.bulletMarker, in: range, options: []) { [weak self] value, attrRange, _ in @@ -536,16 +543,46 @@ final class MarkdownTextLayoutFragment: NSTextLayoutFragment { let font = (ts.attribute(.font, at: attrRange.location, effectiveRange: nil) as? NSFont) ?? (self.textLayoutManager?.textContainer?.textView?.font ?? NSFont.systemFont(ofSize: NSFont.systemFontSize)) - let bulletAttrs: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: theme.bodyText] - let bullet = "•" as NSString - let markerWidth = storageString.substring(with: attrRange).size(withAttributes: [.font: font]).width - let bulletWidth = bullet.size(withAttributes: bulletAttrs).width - let xOffset = max(0, (markerWidth - bulletWidth) / 2) - // Flipped context: text origin is its top edge, baseline sits one - // ascent below — so top = baseline − ascent aligns the glyph. - let topY = pos.baselineY - font.ascender - bullet.draw(at: CGPoint(x: pos.x + xOffset, y: topY), withAttributes: bulletAttrs) + let level = ts.attribute(.bulletListLevel, at: attrRange.location, effectiveRange: nil) as? Int ?? 1 + let color = style.color ?? theme.bodyText + let shape = style.shape(forDepth: level) + let diameter = style.diameter ?? round(font.pointSize * 0.32) + // Flipped context: y grows downwards, so the x-height midline sits + // half an x-height above the baseline. + let center = CGPoint( + x: pos.x + (lists.markerCenterOffset ?? markerWidth / 2), + y: pos.baselineY - font.xHeight / 2 + ) + // Snap the origin to the device grid, like the checkbox: an + // unaligned 4.5pt shape lands on half pixels and renders as a + // blurred, off-centre blob that also moves between capture runs. + let scale = self.textLayoutManager?.textContainer?.textView?.window?.backingScaleFactor + ?? NSScreen.main?.backingScaleFactor ?? 2.0 + func alignToPixel(_ value: CGFloat) -> CGFloat { + (value * scale).rounded(.toNearestOrAwayFromZero) / scale + } + let rect = CGRect(x: alignToPixel(center.x - diameter / 2), + y: alignToPixel(center.y - diameter / 2), + width: diameter, height: diameter) + color.set() + switch shape { + case .filledDot: + NSBezierPath(ovalIn: rect).fill() + case .hollowRing: + let path = NSBezierPath(ovalIn: rect) + path.lineWidth = style.ringStrokeWidth + path.stroke() + case .smallSquare: + NSBezierPath(rect: rect).fill() + case .triangle: + let path = NSBezierPath() + path.move(to: CGPoint(x: rect.midX, y: rect.minY)) + path.line(to: CGPoint(x: rect.maxX, y: rect.maxY)) + path.line(to: CGPoint(x: rect.minX, y: rect.maxY)) + path.close() + path.fill() + } } } @@ -562,6 +599,8 @@ final class MarkdownTextLayoutFragment: NSTextLayoutFragment { defer { NSGraphicsContext.restoreGraphicsState() } let nsContext = NSGraphicsContext(cgContext: context, flipped: true) NSGraphicsContext.current = nsContext + let textView = textLayoutManager?.textContainer?.textView as? NativeTextView + let style = textView?.configuration.lists.taskCheckbox ?? .default ts.enumerateAttribute(.taskCheckbox, in: range, options: []) { [weak self] value, attrRange, _ in guard let self, value != nil else { return } @@ -571,16 +610,21 @@ final class MarkdownTextLayoutFragment: NSTextLayoutFragment { guard let pos = drawPosition(forDocumentCharAt: attrRange.location, point: point) else { return } // Box collapsed to 0.1pt, so pos.x sits at the content edge; the - // square is right-aligned to it (shared with the click hit-test). + // square is placed from it by the shared geometry — centred on the + // marker column when the embedder pins one, else right-aligned + // (shared with the click hit-test). // Use baseFont, NOT NSTextView.font — its getter returns the first // char's font (0.1pt in a heading-first doc → 1px boxes). let font = (textLayoutManager?.textContainer?.textView as? NativeTextView)?.baseFont ?? NSFont.systemFont(ofSize: NSFont.systemFontSize) let ascent = max(0, font.ascender) let descent = max(0, -font.descender) - let size = TaskCheckboxGeometry.size(for: font) - let boxX = TaskCheckboxGeometry.boxX(contentX: pos.x, size: size) - let centerY = pos.baselineY + (descent - ascent) / 2 + let size = TaskCheckboxGeometry.size(for: font, style: style) + let lists = textView?.configuration.lists ?? .default + let boxX = TaskCheckboxGeometry.boxX(contentX: pos.x, size: size, lists: lists) + // Centred on the cap-height midline, which is where the eye reads + // the box as level with the text (flipped context). + let centerY = pos.baselineY - font.capHeight / 2 let boxY = centerY - size / 2 let scale = textLayoutManager?.textContainer?.textView?.window?.backingScaleFactor @@ -591,17 +635,40 @@ final class MarkdownTextLayoutFragment: NSTextLayoutFragment { let boxRect = CGRect(x: alignToPixel(boxX), y: alignToPixel(boxY), width: size, height: size) guard !boxRect.isEmpty, !boxRect.isNull else { return } - let iconInset = max(0.0, size * 0.01) - let iconRect = boxRect.insetBy(dx: iconInset, dy: iconInset) - let symbolName = isChecked ? "checkmark.square.fill" : "square" - if let baseSymbol = NSImage(systemSymbolName: symbolName, accessibilityDescription: nil) { - let sizeConfig = NSImage.SymbolConfiguration(pointSize: iconRect.height, weight: .regular) - let theme = (textLayoutManager?.textContainer?.textView as? NativeTextView)?.configuration.theme ?? .default - let tint = isChecked ? theme.bodyText : theme.mutedText - let colorConfig = NSImage.SymbolConfiguration(hierarchicalColor: tint) - let symbolConfig = sizeConfig.applying(colorConfig) - let symbol = baseSymbol.withSymbolConfiguration(symbolConfig) ?? baseSymbol - symbol.draw(in: iconRect) + if style.rendering == .systemSymbol { + let iconInset = max(0.0, size * 0.01) + let iconRect = boxRect.insetBy(dx: iconInset, dy: iconInset) + let symbolName = isChecked ? "checkmark.square.fill" : "square" + if let baseSymbol = NSImage(systemSymbolName: symbolName, accessibilityDescription: nil) { + let sizeConfig = NSImage.SymbolConfiguration(pointSize: iconRect.height, weight: .regular) + let theme = (textLayoutManager?.textContainer?.textView as? NativeTextView)?.configuration.theme ?? .default + let tint = isChecked ? theme.bodyText : theme.mutedText + let colorConfig = NSImage.SymbolConfiguration(hierarchicalColor: tint) + let symbolConfig = sizeConfig.applying(colorConfig) + let symbol = baseSymbol.withSymbolConfiguration(symbolConfig) ?? baseSymbol + symbol.draw(in: iconRect) + } + } else { + let theme = textView?.configuration.theme ?? .default + let box = NSBezierPath(roundedRect: boxRect, xRadius: style.cornerRadius, + yRadius: style.cornerRadius) + if isChecked { + (style.checkedFillColor ?? theme.bodyText).setFill() + box.fill() + let check = NSBezierPath() + check.lineWidth = style.strokeWidth + check.lineCapStyle = .round + check.lineJoinStyle = .round + check.move(to: CGPoint(x: boxRect.minX + size * 0.23, y: boxRect.minY + size * 0.52)) + check.line(to: CGPoint(x: boxRect.minX + size * 0.43, y: boxRect.minY + size * 0.72)) + check.line(to: CGPoint(x: boxRect.minX + size * 0.78, y: boxRect.minY + size * 0.30)) + (style.checkmarkColor ?? NSColor.white).setStroke() + check.stroke() + } else { + box.lineWidth = style.strokeWidth + (style.uncheckedColor ?? theme.mutedText).setStroke() + box.stroke() + } } } } diff --git a/Sources/MarkdownEngine/Renderer/TaskCheckboxGeometry.swift b/Sources/MarkdownEngine/Renderer/TaskCheckboxGeometry.swift index 294dec21..061f39bc 100644 --- a/Sources/MarkdownEngine/Renderer/TaskCheckboxGeometry.swift +++ b/Sources/MarkdownEngine/Renderer/TaskCheckboxGeometry.swift @@ -6,19 +6,18 @@ // // Shared geometry for the drawn task-checkbox square. The hidden `[ ] ` chars // are collapsed to ~zero advance by the styler, so `drawPosition`/ -// `boundingRect` of the box range sit at the task CONTENT's left edge. The -// square is right-aligned to that edge with a small gap (Obsidian-style), -// occupying the `- ` marker slot. Fragment draw and click hit-test both use -// these functions so their rects can't drift apart. +// `boundingRect` of the box range sit at the task CONTENT's left edge. When +// the embedder pins a marker column (`ListStyle.markerColumnWidth`) the square +// is centred on that column, exactly where a bullet of the same depth sits; +// otherwise it is right-aligned to the content edge with a small gap +// (Obsidian-style), occupying the `- ` marker slot. Fragment draw and click +// hit-test both use these functions so their rects can't drift apart. // import AppKit enum TaskCheckboxGeometry { - /// Gap between the box's right edge and the task content's left edge. - static let gap: CGFloat = 2.0 - /// Side length of the square for the given (body) font. static func size(for font: NSFont) -> CGFloat { let ascent = max(0, font.ascender) @@ -28,8 +27,17 @@ enum TaskCheckboxGeometry { return max(1.0, min(floor(fontHeight * 1.2), floor(markerWidth * 1.2))) } - /// Left edge of the square: right-aligned to the content start x with `gap`. - static func boxX(contentX: CGFloat, size: CGFloat) -> CGFloat { - contentX - size - gap + static func size(for font: NSFont, style: TaskCheckboxStyle) -> CGFloat { + style.size ?? size(for: font) + } + + /// Left edge of the square. With a pinned marker column the box is centred + /// on the column (same anchor as the bullet); without one it stays + /// right-aligned to the content start x with `taskCheckbox.gap`. + static func boxX(contentX: CGFloat, size: CGFloat, lists: ListStyle) -> CGFloat { + guard let column = lists.markerColumnWidth else { + return contentX - size - lists.taskCheckbox.gap + } + return contentX - column + (lists.markerCenterOffset ?? column / 2) - size / 2 } } diff --git a/Sources/MarkdownEngine/Styling/MarkdownASTStyler.swift b/Sources/MarkdownEngine/Styling/MarkdownASTStyler.swift index 6dc5d7d1..c16a8419 100644 --- a/Sources/MarkdownEngine/Styling/MarkdownASTStyler.swift +++ b/Sources/MarkdownEngine/Styling/MarkdownASTStyler.swift @@ -217,7 +217,8 @@ enum MarkdownASTStyler { } let markerWidth = (ctx.ns.substring(with: markerGroup) as NSString) .size(withAttributes: [.font: ctx.baseFont]).width - let depthIndent = CGFloat(MarkdownLists.indentLevel(from: ws)) * ctx.config.lists.indentPerLevel + let level = MarkdownLists.indentLevel(from: ws) + let depthIndent = CGFloat(level) * ctx.config.lists.indentPerLevel let ps = NSMutableParagraphStyle() let lineHeight = ctx.baseLineHeight + ctx.config.lists.extraLineHeight ps.minimumLineHeight = lineHeight @@ -227,14 +228,31 @@ enum MarkdownASTStyler { ps.paragraphSpacingBefore = 0 ps.tabStops = [] ps.defaultTabInterval = ctx.config.lists.indentPerLevel - ps.firstLineHeadIndent = ctx.config.lists.indentPerLevel + ps.firstLineHeadIndent = ctx.config.lists.leadingIndent + // Marker column: widen the `-` glyph's advance with `.kern` so the item + // text starts `markerColumnWidth` right of the column, whatever the + // font measures `"- "` at. Ordered items keep their natural `"1. "` + // advance. Applied independent of caret reveal so the text does not + // jump when the caret enters the marker syntax. + let markerKern: CGFloat = { + guard !item.ordered, let column = ctx.config.lists.markerColumnWidth else { return 0 } + // Measure THIS item's marker, not a literal `- `: `*` and `+` have + // their own advance, and using the dash's would put their content + // in a different column. + let natural = ((ctx.ns.substring(with: item.marker) + " ") as NSString) + .size(withAttributes: [.font: ctx.baseFont]).width + return column - natural + }() // Wrapped lines hang under the first line's content (indent + marker // width). No checkbox-specific extra: the box is a drawn overlay that // doesn't change text advance, so adding it here (and only here, not to // firstLineHeadIndent) shifted an unchecked task's wrapped lines right // of its first line. - ps.headIndent = ctx.config.lists.indentPerLevel + depthIndent + markerWidth + ps.headIndent = ctx.config.lists.leadingIndent + depthIndent + markerWidth + markerKern attrs.append((line, [.paragraphStyle: ps])) + if markerKern != 0 { + attrs.append((item.marker, [.kern: markerKern])) + } // 2. Marker decoration (suppressed while the caret edits the syntax). if let box = item.checkbox { @@ -262,7 +280,8 @@ enum MarkdownASTStyler { let syntax = NSRange(location: item.marker.location, length: item.contentRange.location - item.marker.location) if NSLocationInRange(ctx.caret, syntax) { return } - attrs.append((item.marker, [.bulletMarker: true, .foregroundColor: NSColor.clear])) + attrs.append((item.marker, [.bulletMarker: true, .bulletListLevel: level + 1, + .foregroundColor: NSColor.clear])) } } diff --git a/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+FrameAndOverscroll.swift b/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+FrameAndOverscroll.swift index 9ee09f02..b3c0a016 100644 --- a/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+FrameAndOverscroll.swift +++ b/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+FrameAndOverscroll.swift @@ -49,9 +49,15 @@ extension NativeTextView { PerfTrace.note { "overscroll[\(debugTag)]: fullLayout=\(forcedFullLayout ? 1 : 0) h=\(Int(measured))\(baseHeightChanged ? " hChanged" : "")\(overscrollChanged ? " osChanged" : "")" } - guard baseHeightChanged || overscrollChanged else { return } baseContentHeight = measured activeBottomOverscroll = resolvedOverscroll + // Always re-apply, even when both terms above are unchanged: in + // `.scrolls` the managed height is `max(content, viewport − header)`, + // so a viewport change alone can require a new frame. Skipping it left + // the text view SHORTER than its clip — the strip below the text then + // belongs to the container, and clicking it places no caret. The + // measure above is the expensive part; `applyManagedFrameSize` returns + // immediately when the resulting size is the current one. applyManagedFrameSize(width: targetWidth ?? frame.size.width) } diff --git a/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+TaskCheckbox.swift b/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+TaskCheckbox.swift index d17f55cf..728f5225 100644 --- a/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+TaskCheckbox.swift +++ b/Sources/MarkdownEngine/TextView/NativeTextView/NativeTextView+TaskCheckbox.swift @@ -17,21 +17,23 @@ extension NativeTextView { /// /// The `[ ]` chars are collapsed to ~zero width, so their bounding rect sits /// at the content edge; reconstruct the DRAWN square from the shared - /// `TaskCheckboxGeometry` (right-aligned to it). `baseFont`, not + /// `TaskCheckboxGeometry` (centred on the marker column, or right-aligned + /// to the content edge when no column is pinned). `baseFont`, not /// NSTextView.font (see the draw site). `searchRange` bounds the scan — /// the hovered line for cursor checks, nil (whole doc) for clicks. func taskCheckboxHit(at containerPoint: CGPoint, in searchRange: NSRange? = nil) -> (range: NSRange, isChecked: Bool)? { guard let textContainer = textContainer, let bridge = layoutBridge, let storage = textStorage, storage.length > 0 else { return nil } - let boxSize = TaskCheckboxGeometry.size(for: baseFont) + let checkboxStyle = configuration.lists.taskCheckbox + let boxSize = TaskCheckboxGeometry.size(for: baseFont, style: checkboxStyle) let scan = searchRange ?? NSRange(location: 0, length: storage.length) var hit: (range: NSRange, isChecked: Bool)? storage.enumerateAttribute(.taskCheckbox, in: scan, options: []) { value, attrRange, stop in guard let isChecked = value as? Bool else { return } let anchor = bridge.boundingRect(forCharacterRange: attrRange, in: textContainer) let rect = CGRect( - x: TaskCheckboxGeometry.boxX(contentX: anchor.minX, size: boxSize), + x: TaskCheckboxGeometry.boxX(contentX: anchor.minX, size: boxSize, lists: configuration.lists), y: anchor.minY, width: boxSize, height: max(anchor.height, boxSize) diff --git a/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift b/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift index cfae673e..c51e868d 100644 --- a/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift +++ b/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift @@ -469,7 +469,11 @@ public struct NativeTextViewWrapper: NSViewRepresentable { // makeNSView used to write it — an embedder settings change was inert // until the editor was rebuilt. Plain assignment: a tiny value struct, // and no rebuild is needed for it to take effect. + let listStyleChanged = textView.configuration.lists != configuration.lists textView.configuration.lists = configuration.lists + if listStyleChanged { + textView.setNeedsDisplay(textView.visibleRect) + } context.coordinator.configuration.lists = configuration.lists // Sync registered extensions (inline spans + fenced blocks). A change alters the GRAMMAR // (tokens differ under the new registry), so the coordinator's parsed diff --git a/Tests/MarkdownEngineTests/HeightBehaviorTests.swift b/Tests/MarkdownEngineTests/HeightBehaviorTests.swift index c2686673..79159e60 100644 --- a/Tests/MarkdownEngineTests/HeightBehaviorTests.swift +++ b/Tests/MarkdownEngineTests/HeightBehaviorTests.swift @@ -127,6 +127,23 @@ struct FitsContentInflationTests { stack.container.headerHeight = 40 #expect(stack.container.frame.height == 800) } + + /// A viewport that grows while the content height and the overscroll both + /// stay put must still re-inflate the body. Before this was fixed, the text + /// view stayed shorter than its clip, so the strip below the text belonged + /// to the container and clicking it placed no caret. + @Test func scrollsRefillsViewportWhenOverscrollIsUnchanged() { + let stack = HeightBehaviorStack(viewport: NSSize(width: 420, height: 300)) + stack.textView.recalcOverscroll(for: stack.scrollView) + #expect(stack.textView.frame.height == 300) + let settledOverscroll = stack.textView.activeBottomOverscroll + + stack.scrollView.setFrameSize(NSSize(width: 420, height: 400)) + stack.textView.recalcOverscroll(for: stack.scrollView) + + #expect(stack.textView.activeBottomOverscroll == settledOverscroll) + #expect(stack.textView.frame.height == 400) + } } // MARK: - Overscroll zeroing diff --git a/Tests/MarkdownEngineTests/MarkdownASTStylerTests.swift b/Tests/MarkdownEngineTests/MarkdownASTStylerTests.swift index 128480df..3e1e0454 100644 --- a/Tests/MarkdownEngineTests/MarkdownASTStylerTests.swift +++ b/Tests/MarkdownEngineTests/MarkdownASTStylerTests.swift @@ -195,7 +195,7 @@ struct TaskCheckboxGeometryStylerTests { private var fontName: String { NSFont.systemFont(ofSize: 14).fontName } private var baseFont: NSFont { NSFont(name: fontName, size: base) ?? .systemFont(ofSize: base) } private var hiddenSize: CGFloat { MarkdownEditorConfiguration.default.markers.hiddenMarkerFontSize } - private var indentPerLevel: CGFloat { MarkdownEditorConfiguration.default.lists.indentPerLevel } + private var leadingIndent: CGFloat { MarkdownEditorConfiguration.default.lists.leadingIndent } /// Same measurement call the styler uses for the hanging indent. private func width(_ s: String) -> CGFloat { @@ -241,7 +241,7 @@ struct TaskCheckboxGeometryStylerTests { // Hanging indent measures only "- " — identical to a bullet item. let taskIndent = headIndent(in: attrs, at: 0) - let expected = indentPerLevel + width("- ") + let expected = leadingIndent + width("- ") #expect(taskIndent != nil) #expect(abs((taskIndent ?? -1) - expected) < 0.01) @@ -262,7 +262,7 @@ struct TaskCheckboxGeometryStylerTests { #expect(f == nil || f!.pointSize != hiddenSize, "box char at \(pos) must not collapse while revealed") } // Wrapped lines align with the visible "- [ ] ". - let expected = indentPerLevel + width("- [ ] ") + let expected = leadingIndent + width("- [ ] ") let revealedIndent = headIndent(in: attrs, at: 0) #expect(revealedIndent != nil) #expect(abs((revealedIndent ?? -1) - expected) < 0.01) @@ -289,3 +289,118 @@ private func styleKeySnapshot(_ ranges: [StyledRange]) -> String { private func fmt(_ r: NSRange) -> String { r.location == NSNotFound ? "∅" : "\(r.location)+\(r.length)" } + +@Suite("List marker styles") +struct ListMarkerStyleTests { + + /// The default style must still hit the filled-dot and SF Symbol paths. + @Test("default styles preserve existing rendering") + func defaultStyles() { + #expect(BulletStyle.default.shape(forDepth: 1) == .filledDot) + #expect(TaskCheckboxStyle.default.rendering == .systemSymbol) + } + + @Test("bullet shapes clamp at the last ladder entry") + func ladderClamping() { + let style = BulletStyle(shapeLadder: [.filledDot, .hollowRing, .smallSquare]) + #expect(style.shape(forDepth: 2) == .hollowRing) + #expect(style.shape(forDepth: 9) == .smallSquare) + #expect(BulletStyle(shapeLadder: []).shape(forDepth: 1) == .filledDot) + } + + /// `indentLevel(from:)` is 0-based and the ladder is 1-based. Drive a real + /// nested list so that conversion cannot silently go off by one. + @Test("styler records bullet depth 1-based, per nesting level") + func bulletDepthPerLevel() { + let fontName = NSFont.systemFont(ofSize: 14).fontName + let attrs = MarkdownASTStyler.styleAttributes( + text: "- a\n\t- b\n\t\t- c", + fontName: fontName, + fontSize: 14 + ) + let levels = attrs + .filter { ($0.attributes[.bulletMarker] as? Bool) == true } + .compactMap { $0.attributes[.bulletListLevel] as? Int } + #expect(levels == [1, 2, 3]) + } + + @Test("checkbox size follows the style, else the font") + func checkboxSize() { + let font = NSFont.systemFont(ofSize: 14) + #expect( + TaskCheckboxGeometry.size(for: font, style: .default) + == TaskCheckboxGeometry.size(for: font) + ) + #expect(TaskCheckboxGeometry.size(for: font, style: TaskCheckboxStyle(size: 20)) == 20) + } + + @Test("checkbox gap comes from the style when no marker column is pinned") + func checkboxGap() { + let widerGap = ListStyle(taskCheckbox: TaskCheckboxStyle(gap: 6)) + #expect(TaskCheckboxGeometry.boxX(contentX: 100, size: 15, lists: widerGap) == 79) + #expect(TaskCheckboxGeometry.boxX(contentX: 100, size: 15, lists: .default) == 83) + } + + private static func styled(_ text: String, lists: ListStyle) -> [StyledRange] { + var configuration = MarkdownEditorConfiguration.default + configuration.lists = lists + return MarkdownASTStyler.styleAttributes( + text: text, + fontName: NSFont.systemFont(ofSize: 14).fontName, + fontSize: 14, + configuration: configuration + ) + } + + private static func width(_ s: String) -> CGFloat { + let name = NSFont.systemFont(ofSize: 14).fontName + let font = NSFont(name: name, size: 14) ?? .systemFont(ofSize: 14) + return (s as NSString).size(withAttributes: [.font: font]).width + } + + private static func paragraphStyle(in attrs: [StyledRange], at pos: Int) -> NSParagraphStyle? { + var result: NSParagraphStyle? + for (range, a) in attrs where NSLocationInRange(pos, range) { + if let ps = a[.paragraphStyle] as? NSParagraphStyle { result = ps } + } + return result + } + + @Test("leadingIndent drives the list item's first-line indent") + func topLevelItemUsesLeadingIndent() { + let attrs = Self.styled("- a", lists: ListStyle(leadingIndent: 0)) + let ps = Self.paragraphStyle(in: attrs, at: 0) + #expect(ps?.firstLineHeadIndent == 0) + #expect(abs((ps?.headIndent ?? -1) - Self.width("- ")) < 0.01) + } + + @Test("markerColumnWidth kerns the marker and sets the text column") + func markerColumnWidthWidensTheMarker() { + let lists = ListStyle(leadingIndent: 0, markerColumnWidth: 21) + let attrs = Self.styled("- a", lists: lists) + let kern = attrs + .first { $0.range == NSRange(location: 0, length: 1) && $0.attributes[.kern] != nil }? + .attributes[.kern] as? CGFloat + #expect(kern != nil) + #expect(abs((kern ?? 0) - (21 - Self.width("- "))) < 0.01) + #expect(abs((Self.paragraphStyle(in: attrs, at: 0)?.headIndent ?? -1) - 21) < 0.01) + + let ordered = Self.styled("1. a", lists: lists) + #expect(!ordered.contains { $0.attributes[.kern] != nil }) + + // A `*` item lands on the same text column as a `-` item, which needs + // its own marker advance measured rather than the dash's. + let star = Self.styled("* a", lists: lists) + let starKern = star + .first { $0.range == NSRange(location: 0, length: 1) && $0.attributes[.kern] != nil }? + .attributes[.kern] as? CGFloat + #expect(abs((starKern ?? 0) - (21 - Self.width("* "))) < 0.01) + #expect(abs((Self.paragraphStyle(in: star, at: 0)?.headIndent ?? -1) - 21) < 0.01) + } + + @Test("checkbox is centred on the pinned marker column") + func checkboxCentredOnMarkerColumn() { + let lists = ListStyle(markerColumnWidth: 21, markerCenterOffset: 5.5) + #expect(TaskCheckboxGeometry.boxX(contentX: 100, size: 14, lists: lists) == 77.5) + } +}