From 9e92dc3d7f8910f4ab40a88f9705525c77b5d934 Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Mon, 19 Oct 2026 21:00:43 +0200 Subject: [PATCH] fix: center bullets and checkboxes in the marker column --- README.md | 2 +- Sources/Conjure/NotesTextView.swift | 8 ++-- Tests/ConjureKitTests/NotesEditingTests.swift | 37 +++++++++++++++++-- 3 files changed, 40 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 9f2bda5..23489fd 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 `•`; numbered markers display their original number and delimiter, right-aligned before the item text. The stored and copied Markdown retains the original bullet spelling. +Unordered markers display as `•`; bullets and checkboxes are centered in the shared marker column. 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. 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 4a51e7b..2ab411c 100644 --- a/Sources/Conjure/NotesTextView.swift +++ b/Sources/Conjure/NotesTextView.swift @@ -19,7 +19,7 @@ private final class ListAttachment: NSTextAttachment { let cell = ListAttachmentCell(textCell: isTask ? "" : (source.length == 1 ? "•" : source.string)) cell.attributedStringValue = NSAttributedString(string: cell.stringValue, attributes: [.font: NotesAppearance.font, .foregroundColor: SunsetPalette.accent]) - cell.alignment = .right + cell.alignment = source.length == 1 ? .center : .right attachmentCell = cell bounds = NSRect(x: 0, y: -4, width: 22, height: 21) } @@ -35,7 +35,8 @@ private final class ListAttachmentCell: NSTextAttachmentCell { guard !stringValue.isEmpty else { return } let label = attributedStringValue let size = label.size() - label.draw(at: NSPoint(x: cellFrame.maxX - 4 - size.width, + 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)) } } @@ -617,7 +618,8 @@ final class NotesTextView: NSTextView, @MainActor NSLayoutManagerDelegate { let glyphs = layoutManager.glyphRange(forCharacterRange: task.range, actualCharacterRange: nil) let rect = layoutManager.boundingRect(forGlyphRange: glyphs, in: textContainer) .offsetBy(dx: textContainerOrigin.x, dy: textContainerOrigin.y) - checkbox.setFrameOrigin(NSPoint(x: rect.maxX - 18, y: rect.midY - checkbox.frame.height / 2)) + checkbox.setFrameOrigin(NSPoint(x: rect.midX - checkbox.frame.width / 2, + y: rect.midY - checkbox.frame.height / 2)) } } diff --git a/Tests/ConjureKitTests/NotesEditingTests.swift b/Tests/ConjureKitTests/NotesEditingTests.swift index 4d77249..6a767e6 100644 --- a/Tests/ConjureKitTests/NotesEditingTests.swift +++ b/Tests/ConjureKitTests/NotesEditingTests.swift @@ -1250,17 +1250,48 @@ func renderedListMarkersPaintBulletsAndNumbersInsideTheirGlyphFrames() throws { 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 + var minX = 160, maxX = -1 for y in 0..<48 { for x in 0..<160 where (bitmap.colorAt(x: x, y: y)?.alphaComponent ?? 0) > 0.1 { - painted = true + minX = min(minX, x) + maxX = max(maxX, x) } } - #expect(painted, "The actual TextKit draw path must paint the marker for \(source)") + #expect(maxX >= minX, "The actual TextKit draw path must paint the marker for \(source)") + if ["-", "*", "+"].contains(String(source.prefix(1))) { + #expect(abs(CGFloat(minX + maxX + 1) / 2 - (4 + frame.width / 2)) <= 1) + } else { + #expect(abs(CGFloat(maxX + 1) - frame.width) <= 3) + } #expect(editor.markdownString == source) } } +@Test @MainActor +func listCheckboxesStayCenteredInTheSharedColumnAtEachIndentation() throws { + _ = NSApplication.shared + let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 400, height: 300)) + let source = "- bullet\n- [ ] task\n12345. wide number\n + nested bullet\n - [x] nested task" + editor.setMarkdownContents(NSAttributedString(string: source)) + let layout = try #require(editor.layoutManager) + let container = try #require(editor.textContainer) + layout.ensureLayout(for: container) + editor.layout() + let text = editor.string as NSString + let checkboxes = editor.subviews.compactMap { $0 as? NSButton } + for (index, label) in ["task", "nested task"].enumerated() { + let body = text.range(of: label) + let line = text.lineRange(for: body) + let marker = text.range(of: "\u{fffc}", options: [], range: line) + let glyphs = layout.glyphRange(forCharacterRange: marker, actualCharacterRange: nil) + let frame = layout.boundingRect(forGlyphRange: glyphs, in: container) + .offsetBy(dx: editor.textContainerOrigin.x, dy: editor.textContainerOrigin.y) + let checkbox = try #require(checkboxes.indices.contains(index) ? checkboxes[index] : nil) + #expect(abs(checkbox.frame.midX - frame.midX) <= 0.5) + } + #expect(editor.markdownString == source) +} + @Test @MainActor func listBodyInsertionUsesVisiblePositionForTypingPasteAndReplacementRanges() throws { _ = NSApplication.shared