diff --git a/README.md b/README.md index 685989f..66b37b1 100644 --- a/README.md +++ b/README.md @@ -198,6 +198,8 @@ Notes autosave locally as RTFD packages, including embedded images, under `~/Lib Click **Vim Off** at the top-right of the Notes header to enable modal editing. The button shows **NORMAL**, **INSERT**, **VISUAL**, or **VISUAL LINE**; click it again to disable Vim. This preference persists across reopening and app restarts. Opening or switching notes starts in Normal mode when enabled; ordinary editing is unchanged when disabled. +Normal mode uses a translucent, one-character-wide block cursor. Insert mode and Vim-disabled editing keep the native thin caret. + | Operation | Keys | | --- | --- | | Move by visible character / logical line | `h` / `l`, `j` / `k` (or arrow keys) | diff --git a/Sources/Conjure/NotesTextView.swift b/Sources/Conjure/NotesTextView.swift index 4ee870b..304ecbc 100644 --- a/Sources/Conjure/NotesTextView.swift +++ b/Sources/Conjure/NotesTextView.swift @@ -61,10 +61,14 @@ final class NotesTextView: NSTextView, @MainActor NSLayoutManagerDelegate { private var tasks: [(range: NSRange, attachment: ListAttachment)] = [] private var checkboxes: [NSButton] = [] private var horizontalSelection: (anchor: Int, head: Int)? - var vimState = NotesVimState() + var vimState = NotesVimState() { + didSet { + if oldValue.mode != vimState.mode { needsDisplay = true } + } + } var vimModeChanged: (() -> Void)? var vimEnabled = false { - didSet { resetVim() } + didSet { resetVim(); needsDisplay = true } } var markdownContents: NSAttributedString { Self.serialize(attributedString()) } @@ -77,6 +81,29 @@ final class NotesTextView: NSTextView, @MainActor NSLayoutManagerDelegate { refreshTasks() } + private var normalCursorWidth: CGFloat { + let position = selectedRange().location + let cursorFont = position < (textStorage?.length ?? 0) + ? textStorage?.attribute(.font, at: position, effectiveRange: nil) as? NSFont : font + return max(2, ceil((cursorFont ?? NotesAppearance.font).maximumAdvancement.width)) + } + + override func drawInsertionPoint(in rect: NSRect, color: NSColor, turnedOn flag: Bool) { + guard vimEnabled, vimMode == .normal else { + super.drawInsertionPoint(in: rect, color: color, turnedOn: flag) + return + } + var block = rect + block.size.width = normalCursorWidth + super.drawInsertionPoint(in: block, color: color.withAlphaComponent(0.45), turnedOn: flag) + } + + override func setNeedsDisplay(_ invalidRect: NSRect) { + var rect = invalidRect + if vimEnabled, vimMode == .normal { rect.size.width += normalCursorWidth } + super.setNeedsDisplay(rect) + } + private static func serialize(_ contents: NSAttributedString, keepingMetadata: Bool = false) -> NSAttributedString { let result = NSMutableAttributedString(attributedString: contents) restoreListLayout(in: result) diff --git a/Tests/ConjureKitTests/NotesCursorTests.swift b/Tests/ConjureKitTests/NotesCursorTests.swift new file mode 100644 index 0000000..c57220c --- /dev/null +++ b/Tests/ConjureKitTests/NotesCursorTests.swift @@ -0,0 +1,37 @@ +import AppKit +import Testing +@testable import Conjure + +@Test @MainActor +func vimNormalCursorIsWideButInsertAndDisabledCursorsStayThin() throws { + _ = NSApplication.shared + let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 100, height: 50)) + editor.font = NotesAppearance.font + editor.drawsBackground = false + editor.setMarkdownContents(NSAttributedString(string: "text")) + func paintedWidth() throws -> Int { + let bitmap = try #require(NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: 100, pixelsHigh: 50, + 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 + NSColor.clear.setFill() + NSRect(x: 0, y: 0, width: 100, height: 50).fill(using: .copy) + editor.drawInsertionPoint(in: NSRect(x: 10, y: 10, width: 1, height: 20), color: .orange, turnedOn: true) + context.flushGraphics() + NSGraphicsContext.restoreGraphicsState() + return (0..<100).filter { x in + (0..<50).contains { y in (bitmap.colorAt(x: x, y: y)?.alphaComponent ?? 0) > 0.1 } + }.count + } + let native = try paintedWidth() + editor.vimEnabled = true + let normal = try paintedWidth() + #expect(normal >= 8) + #expect(normal > native) + editor.vimState.mode = .insert + #expect(try paintedWidth() == native) + editor.vimEnabled = false + #expect(try paintedWidth() == native) +}