feat: display a block cursor in Vim Normal mode
This commit is contained in:
@@ -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.
|
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 |
|
| Operation | Keys |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Move by visible character / logical line | `h` / `l`, `j` / `k` (or arrow keys) |
|
| Move by visible character / logical line | `h` / `l`, `j` / `k` (or arrow keys) |
|
||||||
|
|||||||
@@ -61,10 +61,14 @@ final class NotesTextView: NSTextView, @MainActor NSLayoutManagerDelegate {
|
|||||||
private var tasks: [(range: NSRange, attachment: ListAttachment)] = []
|
private var tasks: [(range: NSRange, attachment: ListAttachment)] = []
|
||||||
private var checkboxes: [NSButton] = []
|
private var checkboxes: [NSButton] = []
|
||||||
private var horizontalSelection: (anchor: Int, head: Int)?
|
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 vimModeChanged: (() -> Void)?
|
||||||
var vimEnabled = false {
|
var vimEnabled = false {
|
||||||
didSet { resetVim() }
|
didSet { resetVim(); needsDisplay = true }
|
||||||
}
|
}
|
||||||
|
|
||||||
var markdownContents: NSAttributedString { Self.serialize(attributedString()) }
|
var markdownContents: NSAttributedString { Self.serialize(attributedString()) }
|
||||||
@@ -77,6 +81,29 @@ final class NotesTextView: NSTextView, @MainActor NSLayoutManagerDelegate {
|
|||||||
refreshTasks()
|
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 {
|
private static func serialize(_ contents: NSAttributedString, keepingMetadata: Bool = false) -> NSAttributedString {
|
||||||
let result = NSMutableAttributedString(attributedString: contents)
|
let result = NSMutableAttributedString(attributedString: contents)
|
||||||
restoreListLayout(in: result)
|
restoreListLayout(in: result)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user