Compare commits

..

3 Commits

Author SHA1 Message Date
Valdemar Lorenzen 5065ba59bd fix: use downstream caret affinity after heading edits 2026-10-21 16:35:27 +02:00
Valdemar Lorenzen 489fa84b3c fix: insert heading-start newlines before hidden prefixes 2026-10-21 15:34:46 +02:00
Valdemar Lorenzen b5a0edd5bf fix: match Notes footer to shared window insets 2026-10-21 15:30:02 +02:00
6 changed files with 107 additions and 6 deletions
+3 -1
View File
@@ -70,6 +70,8 @@ Action and selection popups paint a popup-sized background using the launcher wi
The launcher, emoji picker, Translate, and Notes share `ActionPill` for footer styling, alignment, spacing, and keyboard indicators: primary action first, then Actions with separate Command and K keycaps. Notes places Browse Notes (Command-P) and Actions (Command-K) at the bottom right, with both popups opening upwards; New Note remains in Actions and on Command-N. Its formatting controls scroll horizontally when the remaining footer space is too narrow. Controllers supply the buttons and retain their targets, enabled state, and popup behavior.
Notes keeps its 36-point footer pill 8 points from the bottom and right window edges, matching the other views even at the minimum window size.
The header’s trailing edge is constrained to the footer’s trailing edge. The translation provider pill and footer pill therefore share the same 8-point right inset and 36-point height.
### Global shortcuts and launch at login
@@ -171,7 +173,7 @@ Formatting shortcuts use the same actions as the bottom strip. They apply to the
Edit Markdown directly: headings, bold, italic, strikethrough, links, quotes, inline code, and fenced code blocks render without their recognized formatting delimiters. Heading `#` prefixes, emphasis markers, link wrappers and destinations, quote prefixes, and code fences are hidden rather than deleted. Unmatched or escaped punctuation and literal code remain visible; fence lines retain their line breaks. Copying a selection containing hidden syntax and saving notes preserve the original Markdown. Arrow navigation and typing skip hidden syntax, while the formatting strip still toggles it. Bullet (`-`, `*`, `+`), numbered (`1.`, `1)`), and native checkbox markers share a marker column, so item text aligns at the same nesting depth even with long numbers; wrapped lines align with the item text, not the marker. Paste images or drag image files into the editor to embed them locally; no file-picker button is needed. Return continues lists at the same indentation, incrementing numbers and resetting task checkboxes. On an empty item, Return exits one nesting level; repeat to leave the list entirely. Nesting has no fixed depth limit. Prose and fenced code retain ordinary newlines. Command-Z undoes edits; Command-Shift-Z redoes them. Undo history is cleared when switching notes so edits cannot spill into another note.
Typing `# ` through `###### ` at the visible start of an existing heading replaces its level rather than adding to it. Backspace at a heading's visible start removes the preceding newline, leaving a heading at the start of the document unchanged. Removing an empty line above preserves the heading; joining onto nonempty text inherits the preceding paragraph's type (`Text\n## Heading` becomes `TextHeading`). These edits support Undo and retain Markdown serialization inside the existing RTFD autosaves.
Typing `# ` through `###### ` at the visible start of an existing heading replaces its level rather than adding to it. Backspace at a heading's visible start removes the preceding newline, leaving a heading at the start of the document unchanged. Removing an empty line above preserves the heading; joining onto nonempty text inherits the preceding paragraph's type (`Text\n## Heading` becomes `TextHeading`). Return at a heading's visible start inserts an empty line above it, keeping its hidden prefix with the heading text, including after Backspace. Both commands keep the caret on the heading's visible line rather than the preceding line. These edits support Undo and retain Markdown serialization inside the existing RTFD autosaves.
Tab anywhere on a bullet, numbered, or task item indents that line by two spaces, preserving the caret or selection within the line. Shift-Tab removes one leading tab or up to two spaces; at the root it does nothing and never removes the item. These commands change only the current line’s indentation, not nested children. Moving an item into or out of a numbered list updates the affected sibling sequences: a new nested sequence starts at 1, an existing sequence continues from its preceding sibling, and later siblings are renumbered in the saved and copied Markdown too. Existing standalone starting numbers and each marker’s delimiter are preserved. Indentation and numbering changes undo together. Return on a whitespace-only item outdents to its actual parent, or removes one indent unit when there is no parent; only Return on an empty root item removes the marker. Repeated Return therefore exits even a standalone nested list one level at a time.
+1 -1
View File
@@ -163,7 +163,7 @@ final class NotesController: NSViewController, NSTextViewDelegate {
body.topAnchor.constraint(equalTo: header.bottomAnchor),
body.leadingAnchor.constraint(equalTo: view.leadingAnchor),
body.trailingAnchor.constraint(equalTo: view.trailingAnchor),
body.bottomAnchor.constraint(equalTo: view.bottomAnchor),
body.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -8),
scroll.widthAnchor.constraint(equalTo: body.widthAnchor),
footer.leadingAnchor.constraint(equalTo: body.leadingAnchor, constant: 24),
footer.trailingAnchor.constraint(equalTo: body.trailingAnchor, constant: -8),
+11 -3
View File
@@ -248,11 +248,17 @@ final class NotesTextView: NSTextView, @MainActor NSLayoutManagerDelegate {
typedInput: insertString is String)
}
private func replaceSource(_ replacement: NSAttributedString, range: NSRange, semantic: Bool, typedInput: Bool = false) {
private func replaceSource(_ replacement: NSAttributedString, range originalRange: NSRange, semantic: Bool, typedInput: Bool = false) {
guard isEditable else { return }
let source = NSMutableAttributedString(attributedString: Self.serialize(attributedString(), keepingMetadata: true))
var range = originalRange
guard range.location <= source.length, NSMaxRange(range) <= source.length else { return }
let oldHeading = Self.heading(in: source.string as NSString, at: range.location)
// Return at the visible paragraph start keeps the heading prefix with its body.
if replacement.string == "\n", range.length == 0, let heading = oldHeading,
range.location == NSMaxRange(heading) {
range.location = heading.location
}
var caretPosition = range.location + replacement.length
var existingWhitespace = false
if typedInput, range.length == 0, replacement.length > 0 {
@@ -322,14 +328,16 @@ final class NotesTextView: NSTextView, @MainActor NSLayoutManagerDelegate {
}
let removed = NSRange(location: prefix, length: old.length - prefix - suffix)
let inserted = NSRange(location: prefix, length: new.length - prefix - suffix)
let structural = semantic || caretPosition != range.location + replacement.length
let structural = semantic || caretPosition != originalRange.location + replacement.length
if removed.length > 0 || inserted.length > 0 {
if structural { breakUndoCoalescing() }
super.insertText(rendered.attributedSubstring(from: inserted), replacementRange: removed)
if structural { breakUndoCoalescing() }
}
let caret = renderedRange(for: NSRange(location: caretPosition, length: 0))
setSelectedRange(NSRange(location: hiddenBoundary(from: caret.location, forward: true), length: 0))
// Upstream affinity places the caret above a paragraph's hidden leading glyphs.
setSelectedRange(NSRange(location: hiddenBoundary(from: caret.location, forward: true), length: 0),
affinity: .downstream, stillSelecting: false)
}
override func deleteBackward(_ sender: Any?) {
+7
View File
@@ -17,3 +17,10 @@
- Copy as Markdown: replace images with inline-code placeholders such as `<photo.png>`.
- Export as Markdown…: save a single `.md` file without images; with images, create a folder containing the Markdown and image files, using relative links.
- exports leave the original note and RTFD autosaves unchanged.
- [x] bug: pressing backspace at the start of a heading will move it back a line (as expected), but a subsequent return will cause the heading `#`s to appear on the first and the heading text to move down a line.
- [x] keep the caret on the heading's visible line after Backspace and Return, not on the preceding line.
- [ ] improve:
- add cmd+shift+s for strikethrough and cmd+shift+u for underline
- don't hide the backticks surrounding a codeblock
- give inline codeblocks (e.g. `some code snippet`) an inline container and an orange text color matching the color scheme of the conjure application
- [ ] feature: vim motions
@@ -3,6 +3,33 @@ import ConjureKit
import Testing
@testable import Conjure
@Test @MainActor
func notesActionPillKeepsWindowInsetsWhenResized() throws {
_ = NSApplication.shared
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
let suite = "NotesLayout.\(UUID().uuidString)"
let defaults = try #require(UserDefaults(suiteName: suite))
defer {
try? FileManager.default.removeItem(at: directory)
defaults.removePersistentDomain(forName: suite)
}
let controller = NotesController(defaults: defaults, directory: directory)
let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 750, height: 390),
styleMask: [.borderless], backing: .buffered, defer: false)
window.contentViewController = controller
defer { _ = controller.close(); window.orderOut(nil) }
func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) }
let pill = try #require(descendants(controller.view).first { $0 is ActionPill })
for size in [NSSize(width: 750, height: 390), NSSize(width: 570, height: 260)] {
window.setContentSize(size)
controller.view.layoutSubtreeIfNeeded()
let frame = pill.convert(pill.bounds, to: controller.view)
#expect(abs(frame.minY - controller.view.bounds.minY - 8) < 0.5)
#expect(abs(controller.view.bounds.maxX - frame.maxX - 8) < 0.5)
#expect(abs(frame.height - 36) < 0.5)
}
}
@Test @MainActor
func notesSwitchWithoutLeakingEditsUndoOrAttachmentsAndRestoreSelection() throws {
_ = NSApplication.shared
@@ -49,6 +49,38 @@ func headingsFollowVisibleParagraphEditingAndRoundTrip() throws {
#expect(editor.markdownString == after)
}
}
for before in ["\n# Title", "\n## Title", "Text\n\n###### Title", "- [ ] 🦊\n\n## Title"] {
reset(before)
editor.undoManager?.beginUndoGrouping()
editor.doCommand(by: #selector(NSResponder.deleteBackward(_:)))
editor.undoManager?.endUndoGrouping()
let joined = editor.markdownString
editor.breakUndoCoalescing()
editor.undoManager?.removeAllActions()
editor.undoManager?.beginUndoGrouping()
editor.doCommand(by: #selector(NSResponder.insertNewline(_:)))
editor.undoManager?.endUndoGrouping()
#expect(editor.markdownString == before)
#expect(editor.selectedRange() == NSRange(location: (editor.string as NSString).range(of: "Title").location, length: 0))
editor.undoManager?.undo()
#expect(editor.markdownString == joined)
editor.undoManager?.redo()
#expect(editor.markdownString == before)
#expect(controller.save())
#expect(try NotesStore(directory: directory).notes.first?.contents.string == before)
}
for (before, position, after) in [
("## Title", 3, "\n## Title"),
("## Title", 5, "## Ti\ntle"),
("## Title", 8, "## Title\n"),
("Text\n## Title", 4, "Text\n\n## Title"),
("```text\n## Title\n```", 11, "```text\n## \nTitle\n```")
] {
reset(before)
editor.setSelectedRange(NSRange(location: position, length: 0))
editor.doCommand(by: #selector(NSResponder.insertNewline(_:)))
#expect(editor.markdownString == after)
}
for level in 1...6 {
reset("### Title")
editor.undoManager?.beginUndoGrouping()
@@ -74,3 +106,28 @@ func headingsFollowVisibleParagraphEditingAndRoundTrip() throws {
editor.insertText("# ", replacementRange: editor.selectedRange())
#expect(editor.markdownString == "```text\n## # Title\n```")
}
@Test @MainActor
func headingCommandsKeepCaretOnTheVisibleBodyLine() throws {
_ = NSApplication.shared
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 600, height: 500))
let window = NSWindow(contentRect: editor.frame, styleMask: .borderless, backing: .buffered, defer: false)
window.contentView = editor
window.makeFirstResponder(editor)
defer { window.orderOut(nil) }
for source in ["\n# Title", "Text\n\n## Title", "Text\n\n\n###### Title"] {
editor.setMarkdownContents(NSAttributedString(string: source))
editor.setSelectedRange(NSRange(location: (editor.string as NSString).range(of: "Title").location, length: 0))
for command in [#selector(NSResponder.deleteBackward(_:)), #selector(NSResponder.insertNewline(_:))] {
editor.doCommand(by: command)
let title = (editor.string as NSString).range(of: "Title")
#expect(editor.selectedRange() == NSRange(location: title.location, length: 0))
let caretRect = editor.firstRect(forCharacterRange: editor.selectedRange(), actualRange: nil)
let titleRect = editor.firstRect(forCharacterRange: NSRange(location: title.location, length: 1), actualRange: nil)
try #require(titleRect.height > 0)
#expect(caretRect.origin == titleRect.origin)
#expect(caretRect.height == titleRect.height)
}
#expect(editor.markdownString == source)
}
}