diff --git a/README.md b/README.md index 3e98ee7..aef55e4 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,8 @@ Checkbox conversion waits for a space or tab after the closing bracket: type `- List markers are atomic inline tokens, including nested bullets and numbers; task markers such as `- [ ]` and `- [x]` use native checkboxes. Indentation and following whitespace remain editable source text without changing the aligned body column; selecting a token selects the whole marker, with no hidden source characters. Click a checkbox to toggle completion without moving the selection. Backspace immediately after a token expands its original marker and deletes its final character; forward Delete immediately before it 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 `•`; numbered markers display their original number and delimiter, right-aligned before the item text. The stored and copied Markdown retains the original bullet spelling. + Fenced code uses [HighlighterSwift](https://github.com/smittytone/HighlighterSwift) (Highlight.js) for language-aware token colors. Add a language after the opening backticks or tildes, for example `py`, `python`, `swift`, `js`, or `json`. Unlabeled fences use automatic detection; unknown languages remain plain monospace. Highlighting runs entirely offline. Notes autosave locally as RTFD packages, including embedded images, under `~/Library/Application Support/Conjure/Notes`. No account, network access, or cloud sync is involved. Save failures appear below the editor and prevent switching, closing, or quitting until saving succeeds, preserving the unsaved buffer. The Back chevron restores the launcher search when no popup is open. Command-W dismisses Conjure, including any open popup; leaving Notes or switching destinations removes its popup completely. diff --git a/Sources/Conjure/NotesTextView.swift b/Sources/Conjure/NotesTextView.swift index 6da1cc7..51f20aa 100644 --- a/Sources/Conjure/NotesTextView.swift +++ b/Sources/Conjure/NotesTextView.swift @@ -33,9 +33,10 @@ private final class ListAttachmentCell: NSTextAttachmentCell { nonisolated override func cellBaselineOffset() -> NSPoint { NSPoint(x: 0, y: -3) } override func draw(withFrame cellFrame: NSRect, in controlView: NSView?) { guard !stringValue.isEmpty else { return } - var frame = cellFrame - frame.size.width -= 4 - super.draw(withFrame: frame, in: controlView) + let label = attributedStringValue + let size = label.size() + label.draw(at: NSPoint(x: cellFrame.maxX - 4 - size.width, + y: cellFrame.midY - size.height / 2)) } } diff --git a/Tests/ConjureKitTests/NotesEditingTests.swift b/Tests/ConjureKitTests/NotesEditingTests.swift index 078b493..f78bfd2 100644 --- a/Tests/ConjureKitTests/NotesEditingTests.swift +++ b/Tests/ConjureKitTests/NotesEditingTests.swift @@ -824,3 +824,35 @@ func mixedListMarkersAlignBodyGlyphsAndWrappedLinesWithoutChangingMarkdown() thr #expect(editor.markdownContents.attribute(markerAttribute, at: 0, effectiveRange: nil) as? String == "first") #expect(editor.markdownContents.attribute(markerAttribute, at: 2, effectiveRange: nil) as? String == "third") } + +@Test @MainActor +func renderedListMarkersPaintBulletsAndNumbersInsideTheirGlyphFrames() throws { + _ = NSApplication.shared + let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 400, height: 200)) + editor.isRichText = true + for source in ["- item", "* item", "+ item", "1. item", "123) item"] { + editor.setMarkdownContents(NSAttributedString(string: source)) + let layout = try #require(editor.layoutManager) + let container = try #require(editor.textContainer) + layout.ensureLayout(for: container) + let glyphs = layout.glyphRange(forCharacterRange: NSRange(location: 0, length: 1), actualCharacterRange: nil) + let frame = layout.boundingRect(forGlyphRange: glyphs, in: container) + let bitmap = try #require(NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: 160, pixelsHigh: 48, + bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false, + colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0)) + let context = try #require(NSGraphicsContext(bitmapImageRep: bitmap)) + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = context + context.cgContext.clear(CGRect(x: 0, y: 0, width: 160, height: 48)) + layout.drawGlyphs(forGlyphRange: glyphs, at: NSPoint(x: 4 - frame.minX, y: 10 - frame.minY)) + NSGraphicsContext.restoreGraphicsState() + var painted = false + for y in 0..<48 { + for x in 0..<160 where (bitmap.colorAt(x: x, y: y)?.alphaComponent ?? 0) > 0.1 { + painted = true + } + } + #expect(painted, "The actual TextKit draw path must paint the marker for \(source)") + #expect(editor.markdownString == source) + } +}