From 70c224e6f13065e73e28a8853fd5534248df5935 Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Mon, 19 Oct 2026 21:13:09 +0200 Subject: [PATCH] fix: align numbered list markers with the text baseline --- README.md | 2 +- Sources/Conjure/NotesTextView.swift | 5 ++- .../ConjureKitTests/NumberBaselineTests.swift | 41 +++++++++++++++++++ 3 files changed, 45 insertions(+), 3 deletions(-) create mode 100644 Tests/ConjureKitTests/NumberBaselineTests.swift diff --git a/README.md b/README.md index 68cf40d..4786e65 100644 --- a/README.md +++ b/README.md @@ -171,7 +171,7 @@ When typing a list marker before existing whitespace on an otherwise blank line, List markers are atomic inline tokens, including nested bullets and numbers; task markers such as `- [ ]` and `- [x]` use native checkboxes. Left/Right crosses a marker and its single required separator (a space or tab) in one visible step, between the marker's start and the item body. Only that separator is hidden: additional spaces and tabs are visible body text with normal cursor stops. Shift-Left/Right uses the same boundaries and preserves the selection anchor when extending or reversing direction. Visible indentation and body whitespace retain normal character stops. Selecting only a token copies only its original marker; a keyboard selection across the marker also includes its required separator. Click a checkbox to toggle completion without moving the selection. Backspace at the start of the body expands the original marker and deletes its final character, preserving the separator; forward Delete immediately before the token expands the marker and deletes its first character. Each edit is one Undo step. Removing an item's preceding newline after prose makes its marker literal inline text; joining onto an empty or indentation-only line keeps a valid list token. Saved notes and copied text retain the original Markdown spelling, numbers, case, and spacing, alongside embedded images; alignment formatting is display-only. Thematic breaks and list syntax inside fenced code or ordinary prose stay literal. -Unordered markers display as `•`; bullets and checkboxes are centered in the shared marker column. Checkboxes align vertically with the text’s cap-height center, using its baseline rather than the attachment frame. Numbered markers display their original number and delimiter, right-aligned before the item text. Item text and wrapped lines retain their shared alignment, including nested lists and columns widened by longer numbers. The stored and copied Markdown retains the original bullet spelling. +Unordered markers display as `•`; bullets and checkboxes are centered in the shared marker column. Checkboxes align vertically with the text’s cap-height center, using its baseline rather than the attachment frame. Numbered markers display their original number and delimiter, right-aligned before the item text and resting on the same text baseline. Item text and wrapped lines retain their shared alignment, including nested lists and columns widened by longer numbers. The stored and copied Markdown retains the original bullet spelling. Typing, pasting, and Return at the visible start of an item use the position after its preserved Markdown separator. Continuing to type after a marker converts therefore keeps the item rendered, rather than inserting characters into the marker syntax. diff --git a/Sources/Conjure/NotesTextView.swift b/Sources/Conjure/NotesTextView.swift index 92268f6..740ab35 100644 --- a/Sources/Conjure/NotesTextView.swift +++ b/Sources/Conjure/NotesTextView.swift @@ -36,8 +36,9 @@ private final class ListAttachmentCell: NSTextAttachmentCell { let label = attributedStringValue let size = label.size() let x = alignment == .center ? cellFrame.midX - size.width / 2 : cellFrame.maxX - 4 - size.width - label.draw(at: NSPoint(x: x, - y: cellFrame.midY - size.height / 2)) + let y = alignment == .center ? cellFrame.midY - size.height / 2 + : cellFrame.maxY + cellBaselineOffset().y - ceil(NotesAppearance.font.ascender) + label.draw(at: NSPoint(x: x, y: y)) } } diff --git a/Tests/ConjureKitTests/NumberBaselineTests.swift b/Tests/ConjureKitTests/NumberBaselineTests.swift new file mode 100644 index 0000000..5f6b8c5 --- /dev/null +++ b/Tests/ConjureKitTests/NumberBaselineTests.swift @@ -0,0 +1,41 @@ +import AppKit +import Testing +@testable import Conjure + +@Test @MainActor func numberedMarkersShareTheBodyTextBaseline() throws { + _ = NSApplication.shared + let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 400, height: 200)) + for source in ["1. 1", "123) 123", " 12. 12"] { + editor.setMarkdownContents(NSAttributedString(string: source)) + MarkdownHighlighting.apply(to: editor) + editor.refreshTasks() + let layout = try #require(editor.layoutManager) + let container = try #require(editor.textContainer) + layout.ensureLayout(for: container) + let text = editor.string as NSString + let marker = text.range(of: "\u{fffc}") + let body = NSRange(location: NSMaxRange(marker) + 1, length: text.length - NSMaxRange(marker) - 1) + var bottoms: [Int] = [] + for range in [marker, body] { + let bitmap = try #require(NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: 400, pixelsHigh: 80, + bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false, + colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0)) + let bitmapContext = try #require(NSGraphicsContext(bitmapImageRep: bitmap)) + bitmapContext.cgContext.translateBy(x: 0, y: 80) + bitmapContext.cgContext.scaleBy(x: 1, y: -1) + let context = NSGraphicsContext(cgContext: bitmapContext.cgContext, flipped: true) + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = context + context.cgContext.clear(CGRect(x: 0, y: 0, width: 400, height: 80)) + layout.drawGlyphs(forGlyphRange: layout.glyphRange(forCharacterRange: range, actualCharacterRange: nil), at: .zero) + NSGraphicsContext.restoreGraphicsState() + var bottom = -1 + for y in 0..<80 { + for x in 0..<400 where (bitmap.colorAt(x: x, y: y)?.alphaComponent ?? 0) > 0.2 { bottom = y } + } + try #require(bottom >= 0) + bottoms.append(bottom) + } + #expect(abs(bottoms[0] - bottoms[1]) <= 1, "Marker/body ink baseline for \(source)") + } +}