fix: center bullets and checkboxes in the marker column
This commit is contained in:
@@ -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.
|
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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ private final class ListAttachment: NSTextAttachment {
|
|||||||
let cell = ListAttachmentCell(textCell: isTask ? "" : (source.length == 1 ? "•" : source.string))
|
let cell = ListAttachmentCell(textCell: isTask ? "" : (source.length == 1 ? "•" : source.string))
|
||||||
cell.attributedStringValue = NSAttributedString(string: cell.stringValue,
|
cell.attributedStringValue = NSAttributedString(string: cell.stringValue,
|
||||||
attributes: [.font: NotesAppearance.font, .foregroundColor: SunsetPalette.accent])
|
attributes: [.font: NotesAppearance.font, .foregroundColor: SunsetPalette.accent])
|
||||||
cell.alignment = .right
|
cell.alignment = source.length == 1 ? .center : .right
|
||||||
attachmentCell = cell
|
attachmentCell = cell
|
||||||
bounds = NSRect(x: 0, y: -4, width: 22, height: 21)
|
bounds = NSRect(x: 0, y: -4, width: 22, height: 21)
|
||||||
}
|
}
|
||||||
@@ -35,7 +35,8 @@ private final class ListAttachmentCell: NSTextAttachmentCell {
|
|||||||
guard !stringValue.isEmpty else { return }
|
guard !stringValue.isEmpty else { return }
|
||||||
let label = attributedStringValue
|
let label = attributedStringValue
|
||||||
let size = label.size()
|
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))
|
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 glyphs = layoutManager.glyphRange(forCharacterRange: task.range, actualCharacterRange: nil)
|
||||||
let rect = layoutManager.boundingRect(forGlyphRange: glyphs, in: textContainer)
|
let rect = layoutManager.boundingRect(forGlyphRange: glyphs, in: textContainer)
|
||||||
.offsetBy(dx: textContainerOrigin.x, dy: textContainerOrigin.y)
|
.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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1250,17 +1250,48 @@ func renderedListMarkersPaintBulletsAndNumbersInsideTheirGlyphFrames() throws {
|
|||||||
context.cgContext.clear(CGRect(x: 0, y: 0, width: 160, height: 48))
|
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))
|
layout.drawGlyphs(forGlyphRange: glyphs, at: NSPoint(x: 4 - frame.minX, y: 10 - frame.minY))
|
||||||
NSGraphicsContext.restoreGraphicsState()
|
NSGraphicsContext.restoreGraphicsState()
|
||||||
var painted = false
|
var minX = 160, maxX = -1
|
||||||
for y in 0..<48 {
|
for y in 0..<48 {
|
||||||
for x in 0..<160 where (bitmap.colorAt(x: x, y: y)?.alphaComponent ?? 0) > 0.1 {
|
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)
|
#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
|
@Test @MainActor
|
||||||
func listBodyInsertionUsesVisiblePositionForTypingPasteAndReplacementRanges() throws {
|
func listBodyInsertionUsesVisiblePositionForTypingPasteAndReplacementRanges() throws {
|
||||||
_ = NSApplication.shared
|
_ = NSApplication.shared
|
||||||
|
|||||||
Reference in New Issue
Block a user