@@ -2,6 +2,21 @@ import AppKit
import ConjureKit
import Highlighter
@ MainActor
enum NotesAppearance {
static let background = NSColor ( white : 0.035 , alpha : 1 )
static let text = NSColor ( white : 0.94 , alpha : 1 )
static let muted = NSColor ( white : 0.58 , alpha : 1 )
static let accent = NSColor ( srgbRed : 1 , green : 0.36 , blue : 0.39 , alpha : 1 )
static let font = NSFont . systemFont ( ofSize : 17 )
static let paragraph : NSParagraphStyle = {
let style = NSMutableParagraphStyle ( )
style . lineSpacing = 5
style . paragraphSpacing = 8
return style
} ( )
}
@ MainActor
final class NotesController : NSViewController , NSTextViewDelegate {
private let browseButton = NSButton ( title : " Browse Notes " , target : nil , action : nil )
@@ -9,6 +24,8 @@ final class NotesController: NSViewController, NSTextViewDelegate {
private let actions = ApplicationActionsController ( )
private let editor = NotesTextView ( )
private let status = NSTextField ( labelWithString : " " )
private let noteTitle = NSTextField ( labelWithString : " Untitled Note " )
private let formatting = NSStackView ( )
private let defaults : UserDefaults
private var store : NotesStore ?
private var selectedID : UUID ?
@@ -32,32 +49,84 @@ final class NotesController: NSViewController, NSTextViewDelegate {
let content = NotesView ( )
content . commandShortcut = { [ weak self ] in self ? . handleCommandKey ( $0 ) ? ? false }
view = content
content . wantsLayer = true
content . layer ? . backgroundColor = NotesAppearance . background . cgColor
let close = iconButton ( " circle.fill " , label : " Close notes " , action : #selector ( closeWindow ) )
close . contentTintColor = NotesAppearance . accent
close . isBordered = false
close . image = close . image ? . withSymbolConfiguration ( . init ( paletteColors : [ NotesAppearance . accent ] ) )
let lights = NSStackView ( views : [ close ] )
lights . spacing = 8
for _ in 0. . < 2 {
let dot = NSView ( )
dot . wantsLayer = true
dot . layer ? . backgroundColor = NSColor ( white : 0.15 , alpha : 1 ) . cgColor
dot . layer ? . cornerRadius = 6
dot . setAccessibilityElement ( false )
NSLayoutConstraint . activate ( [ dot . widthAnchor . constraint ( equalToConstant : 12 ) , dot . heightAnchor . constraint ( equalToConstant : 12 ) ] )
lights . addArrangedSubview ( dot )
}
browseButton . target = self
browseButton . action = #selector ( browseNotes )
browseButton . setAccessibilityLabel ( " Browse notes " )
browseButton . toolTip = " Browse notes (Command-P) "
configureIcon ( browseButton , symbol : " note.text " , label : " Browse notes (⌘P) " )
browseButton . keyEquivalent = " p "
actionsButton . target = self
actionsButton . action = #selector ( showActions )
actionsButton . setAccessibilityLabel ( " Notes actions " )
actionsButton . toolTip = " Notes actions (Command-K) "
configureIcon ( actionsButton , symbol : " command " , label : " Notes actions (⌘K) " )
let create = iconButton ( " plus " , label : " New note (⌘N) " , action : #selector ( newNote ) )
let controls = NSStackView ( views : [ actionsButton , browseButton , create ] )
controls . spacing = 2
noteTitle . font = . systemFont ( ofSize : 13 )
noteTitle . textColor = NotesAppearance . muted
noteTitle . alignment = . center
noteTitle . lineBreakMode = . byTruncatingTail
noteTitle . setAccessibilityLabel ( " Note title " )
let header = NSView ( )
for control in [ lights , noteTitle , controls ] {
control . translatesAutoresizingMaskIntoConstraints = false
header . addSubview ( control )
control . centerYAnchor . constraint ( equalTo : header . centerYAnchor ) . isActive = true
}
NSLayoutConstraint . activate ( [
lights . leadingAnchor . constraint ( equalTo : header . leadingAnchor , constant : 10 ) ,
controls . trailingAnchor . constraint ( equalTo : header . trailingAnchor , constant : - 8 ) ,
noteTitle . centerXAnchor . constraint ( equalTo : header . centerXAnchor ) ,
noteTitle . leadingAnchor . constraint ( greaterThanOrEqualTo : lights . trailingAnchor , constant : 16 ) ,
noteTitle . trailingAnchor . constraint ( lessThanOrEqualTo : controls . leadingAnchor , constant : - 16 ) ,
] )
actions . closed = { [ weak self ] in self ? . focusEditor ( ) }
actions . selectNote = { [ weak self ] in self ? . switchNote ( $0 ) }
actions . newNote = { [ weak self ] in self ? . newNote ( ) }
actions . browseNotes = { [ weak self ] in self ? . browseNotes ( ) }
let pill = ActionPill ( primaryButton : browseButton , actionsButton : actionsButton , primaryCommand : " p " )
let footer = NSStackView ( views : [ status , NSView ( ) , pill ] )
footer . orientation = . horizontal
let tools = [
( " textformat.size " , " Heading " ) , ( " bold " , " Bold " ) , ( " italic " , " Italic " ) ,
( " strikethrough " , " Strikethrough " ) , ( " chevron.left.forwardslash.chevron.right " , " Inline code " ) ,
( " link " , " Link " ) , ( " curlybraces.square " , " Code block " ) , ( " text.quote " , " Quote " ) ,
( " list.number " , " Ordered list " ) , ( " list.bullet " , " Bullet list " ) , ( " checklist " , " Checklist " ) ,
]
for ( index , tool ) in tools . enumerated ( ) {
let button = iconButton ( tool . 0 , label : tool . 1 , action : #selector ( formatNote ( _ : ) ) )
button . tag = index
formatting . addArrangedSubview ( button )
}
formatting . spacing = 8
let toggle = iconButton ( " xmark.circle.fill " , label : " Hide formatting " , action : #selector ( toggleFormatting ( _ : ) ) )
let separator = NSBox ( )
separator . boxType = . separator
let footer = NSStackView ( views : [ formatting , NSView ( ) , separator , toggle ] )
footer . alignment = . centerY
footer . spacing = 12
footer . spacing = 8
separator . heightAnchor . constraint ( equalToConstant : 22 ) . isActive = true
editor . delegate = self
editor . isRichText = true
editor . importsGraphics = true
editor . allowsUndo = true
editor . drawsBackground = false
editor . textColor = SunsetPalette . text
editor . insertionPointColor = SunsetPalette . accent
editor . font = . systemFont ( ofSize : 14 )
editor . textColor = NotesAppearance . text
editor . insertionPointColor = NotesAppearance . accent
editor . selectedTextAttributes = [ . backgroundColor : NotesAppearance . accent . withAlphaComponent ( 0.25 ) ]
editor . font = NotesAppearance . font
editor . defaultParagraphStyle = NotesAppearance . paragraph
editor . isAutomaticQuoteSubstitutionEnabled = false
editor . isAutomaticDashSubstitutionEnabled = false
editor . isAutomaticTextReplacementEnabled = false
@@ -65,39 +134,49 @@ final class NotesController: NSViewController, NSTextViewDelegate {
editor . isHorizontallyResizable = false
editor . autoresizingMask = [ . width ]
editor . textContainer ? . widthTracksTextView = true
editor . textContainerInset = NSSize ( width : 10 , height : 10 )
editor . textContainerInset = NSSize ( width : 22 , height : 8 )
editor . setAccessibilityLabel ( " Note editor " )
let scroll = NSScrollView ( )
scroll . documentView = editor
scroll . hasVerticalScroller = true
scroll . autohidesScrollers = true
scroll . drawsBackground = false
scroll . wantsLayer = true
scroll . layer ? . cornerRadius = 8
scroll . layer ? . borderWidth = 0.5
scroll . layer ? . borderColor = SunsetPalette . secondaryText . withAlphaComponent ( 0.22 ) . cgColor
scroll . scrollerStyle = . overlay
status . font = . systemFont ( ofSize : 12 )
status . textColor = SunsetPalette . secondaryText
status . textColor = NotesAppearance . muted
status . setAccessibilityLabel ( " Notes save status " )
status . lineBreakMode = . byTruncatingTail
status . setContentCompressionResistancePriority ( . defaultLow , for : . horizontal )
for control in [ scroll , footer ] {
status . isHidden = true
let body = NSStackView ( views : [ scroll , status , footer ] )
body . orientation = . vertical
body . alignment = . leading
body . spacing = 4
for control in [ header , body ] {
control . translatesAutoresizingMaskIntoConstraints = false
view . addSubview ( control )
}
NSLayoutConstraint . activate ( [
scroll . topAnchor . constraint ( equalTo : view . topAnchor ) ,
scroll . leadingAnchor . constraint ( equalTo : view . leadingAnchor , constant : 16 ) ,
scroll . trailingAnchor . constraint ( equalTo : view . trailingAnchor , constant : - 16 ) ,
scroll . bottomAnchor . constraint ( equalTo : footer . topAnchor , constant : - 8 ) ,
footer . leadingAnchor . constraint ( equalTo : view . leadingAnchor , constant : 16 ) ,
footer . trailingAnchor . constraint ( equalTo : view . trailingAnchor , constant : - 8 ) ,
footer . bottomAnchor . constraint ( equalTo : view . bottomAnchor , constant : - 8 ) ,
footer . heightAnchor . constraint ( equalToConstant : 36 ) ,
header . topAnchor . constraint ( equalTo : view . topAnchor ) ,
header . leadingAnchor . constraint ( equalTo : view . leadingAnchor ) ,
header . trailingAnchor . constraint ( equalTo : view . trailingAnchor ) ,
header . heightAnchor . constraint ( equalToConstant : 38 ) ,
body . topAnchor . constraint ( equalTo : header . bottomAnchor , constant : 4 ) ,
body . leadingAnchor . constraint ( equalTo : view . leadingAnchor ) ,
body . trailingAnchor . constraint ( equalTo : view . trailingAnchor ) ,
body . bottomAnchor . constraint ( equalTo : view . bottomAnchor , constant : - 4 ) ,
scroll . widthAnchor . constraint ( equalTo : body . widthAnchor ) ,
footer . leadingAnchor . constraint ( equalTo : body . leadingAnchor , constant : 24 ) ,
footer . trailingAnchor . constraint ( equalTo : body . trailingAnchor , constant : - 6 ) ,
status . leadingAnchor . constraint ( equalTo : body . leadingAnchor , constant : 28 ) ,
status . trailingAnchor . constraint ( equalTo : body . trailingAnchor , constant : - 12 ) ,
scroll . heightAnchor . constraint ( greaterThanOrEqualToConstant : 80 ) ,
footer . heightAnchor . constraint ( equalToConstant : 32 ) ,
] )
guard let store else {
editor . isEditable = false
for control in [ browseButton , actionsButton ] { control . isEnabled = false }
for control in [ browseButton , actionsButton , create ] { control . isEnabled = false }
formatting . subviews . compactMap { $0 as ? NSButton } . forEach { $0 . isEnabled = false }
showError ( loadError ! )
return
}
@@ -111,6 +190,94 @@ final class NotesController: NSViewController, NSTextViewDelegate {
}
}
private func configureIcon ( _ button : NSButton , symbol : String , label : String ) {
button . image = NSImage ( systemSymbolName : symbol , accessibilityDescription : nil ) ?
. withSymbolConfiguration ( . init ( pointSize : 16 , weight : . regular ) )
button . imagePosition = . imageOnly
button . bezelStyle = . accessoryBarAction
button . showsBorderOnlyWhileMouseInside = true
button . contentTintColor = NotesAppearance . muted
button . toolTip = label
button . setAccessibilityLabel ( label )
NSLayoutConstraint . activate ( [ button . widthAnchor . constraint ( equalToConstant : 28 ) , button . heightAnchor . constraint ( equalToConstant : 28 ) ] )
}
private func iconButton ( _ symbol : String , label : String , action : Selector ) -> NSButton {
let button = NSButton ( title : " " , target : self , action : action )
configureIcon ( button , symbol : symbol , label : label )
return button
}
@objc private func closeWindow ( ) {
guard save ( ) else { return }
view . window ? . performClose ( nil )
}
@objc private func toggleFormatting ( _ sender : NSButton ) {
formatting . isHidden . toggle ( )
sender . image = NSImage ( systemSymbolName : formatting . isHidden ? " textformat " : " xmark.circle.fill " , accessibilityDescription : nil )
sender . toolTip = formatting . isHidden ? " Show formatting " : " Hide formatting "
sender . setAccessibilityLabel ( sender . toolTip )
focusEditor ( )
}
@objc private func formatNote ( _ sender : NSButton ) {
guard editor . isEditable else { return }
let contents = editor . markdownContents
let source = contents . string as NSString
var selection = editor . markdownInsertionRange ( for : editor . selectedRange ( ) )
if sender . tag = = 6 {
selection = source . lineRange ( for : selection )
if selection . length > 0 , source . character ( at : NSMaxRange ( selection ) - 1 ) = = 10 { selection . length -= 1 }
}
let wrappers = [ 1 : ( " ** " , " ** " ) , 2 : ( " * " , " * " ) , 3 : ( " ~~ " , " ~~ " ) , 4 : ( " ` " , " ` " ) ,
5 : ( " [ " , " ](url) " ) , 6 : ( " ``` \n " , " \n ``` " ) ]
if let ( open , close ) = wrappers [ sender . tag ] {
let before = selection . location - open . utf16 . count
let after = NSMaxRange ( selection )
if before >= 0 , after + close . utf16 . count <= source . length ,
source . substring ( with : NSRange ( location : before , length : open . utf16 . count ) ) = = open ,
source . substring ( with : NSRange ( location : after , length : close . utf16 . count ) ) = = close {
let body = contents . attributedSubstring ( from : selection )
selection = NSRange ( location : before , length : selection . length + open . utf16 . count + close . utf16 . count )
replaceMarkdown ( body , range : selection , selection : NSRange ( location : before , length : body . length ) )
} else {
let body = NSMutableAttributedString ( string : open , attributes : editor . typingAttributes )
body . append ( contents . attributedSubstring ( from : selection ) )
body . append ( NSAttributedString ( string : close , attributes : editor . typingAttributes ) )
replaceMarkdown ( body , range : selection ,
selection : NSRange ( location : selection . location + open . utf16 . count , length : selection . length ) )
}
} else {
let prefixes = [ 0 : " # " , 7 : " > " , 8 : " 1. " , 9 : " - " , 10 : " - [ ] " ]
guard let prefix = prefixes [ sender . tag ] else { return }
let length = selection . length > 0 && source . character ( at : NSMaxRange ( selection ) - 1 ) = = 10
? selection . length - 1 : selection . length
let lines = source . lineRange ( for : NSRange ( location : selection . location , length : length ) )
let body = NSMutableAttributedString ( attributedString : contents . attributedSubstring ( from : lines ) )
let rows = body . string . components ( separatedBy : " \n " )
var offset = body . length , firstDelta = 0
for ( index , line ) in rows . enumerated ( ) . reversed ( ) {
offset -= line . utf16 . count + ( index < rows . count - 1 ? 1 : 0 )
if index = = rows . count - 1 , index > 0 , line . isEmpty { continue }
let indent = String ( line . prefix { $0 = = " " || $0 = = " \t " } )
let content = String ( line . dropFirst ( indent . count ) )
let item = Self . listMatch ( in : content as NSString )
let kind = item . map { $0 . range ( at : 6 ) . location != NSNotFound ? 10 : $0 . range ( at : 3 ) . location != NSNotFound ? 8 : 9 }
let removing = kind = = sender . tag || ( item = = nil && content . hasPrefix ( prefix ) )
let oldPrefix = item ? . range . length ? ? ( removing ? prefix . utf16 . count : 0 )
let newPrefix = removing ? " " : sender . tag = = 8 ? " \( index + 1 ) . " : prefix
body . replaceCharacters ( in : NSRange ( location : offset + indent . utf16 . count , length : oldPrefix ) ,
with : NSAttributedString ( string : newPrefix , attributes : editor . typingAttributes ) )
if index = = 0 { firstDelta = newPrefix . utf16 . count - oldPrefix }
}
let start = max ( lines . location , selection . location + firstDelta )
let end = max ( start , NSMaxRange ( selection ) + body . length - lines . length )
replaceMarkdown ( body , range : lines , selection : NSRange ( location : start , length : end - start ) )
}
focusEditor ( )
}
func focusEditor ( ) { view . window ? . makeFirstResponder ( editor ) }
func textDidChange ( _ notification : Notification ) {
@@ -164,6 +331,10 @@ final class NotesController: NSViewController, NSTextViewDelegate {
}
private func replaceListMarkdown ( _ replacement : String , range : NSRange , selection : NSRange ) {
replaceMarkdown ( NSAttributedString ( string : replacement , attributes : editor . typingAttributes ) , range : range , selection : selection )
}
private func replaceMarkdown ( _ replacement : NSAttributedString , range : NSRange , selection : NSRange ) {
editor . undoManager ? . beginUndoGrouping ( )
// N a t i v e t e x t u n d o r e s t o r e s i t s d i f f b o u n d a r y , n o t t h e u s e r ' s s e l e c t i o n .
setSelectionWithUndo ( editor . selectedRange ( ) )
@@ -246,8 +417,8 @@ final class NotesController: NSViewController, NSTextViewDelegate {
do {
try store . update ( id : selectedID , contents : editor . markdownContents )
isDirty = false
status . stringValue = " Saved locally · Markdown · Paste or drop images "
status . textColor = SunsetPalette . secondaryText
noteTitle . stringValue = store . notes . first { $0 . id = = selectedID } ? . title ? ? " Untitled Note "
status . isHidden = true
return true
} catch {
showError ( error )
@@ -312,12 +483,13 @@ final class NotesController: NSViewController, NSTextViewDelegate {
editor . setSelectedRange ( NSRange ( location : editor . string . utf16 . count , length : 0 ) )
MarkdownHighlighting . apply ( to : editor )
editor . refreshTasks ( )
status . stringValue = " Saved locally · Markdown · Paste or drop images "
status . textColor = SunsetPalette . secondaryText
noteTitle . stringValue = note . title
status . isHidden = true
}
private func showError ( _ error : Error ) {
status . isHidden = false
status . stringValue = " Couldn’ t save or load notes: \( error . localizedDescription ) "
status . toolTip = status . stringValue
status . textColor = . systemRed
@@ -353,16 +525,17 @@ private final class NotesView: NSView {
enum MarkdownHighlighting {
// p o n y t a i l : w h o l e - n o t e h i g h l i g h t i n g ; i n c r e m e n t a l p a r s i n g i f l a r g e n o t e s m a k e t y p i n g s l o w .
private static let rules : [ ( NSRegularExpression , [ NSAttributedString . Key : Any ] ) ] = [
( try ! NSRegularExpression ( pattern : " (?m)^#{1,6} .+$ " ) , [ . font : NSFont . systemFont ( ofSize : 17 , weight : . semibold ) , . foregroundColor : SunsetPalette . accent ] ) ,
( try ! NSRegularExpression ( pattern : " \\ * \\ *[^ \\ n]+? \\ * \\ *|__[^ \\ n]+?__ " ) , [ . font : NSFont . boldSystemFont ( ofSize : 14 ) ] ) ,
( try ! NSRegularExpression ( pattern : " (?<! \\ *) \\ *(?! \\ *)[^* \\ n]+ \\ *(?! \\ *)|(?<!_)_(?!_)[^_ \\ n]+_(?!_) " ) , [ . font : NSFontManager . shared . convert ( . systemFont ( ofSize : 14 ) , toHaveTrait : . italicFontMask ) ] ) ,
( try ! NSRegularExpression ( pattern : " !? \\ [[^ \\ ] \\ n]+ \\ ] \\ ([^ \\ n)]+ \\ )|(?m)^ \\ s*(?:[-+*]|[0-9]+ \\ .) \\ s " ) , [ . foregroundColor : SunsetPalette . accent ] ) ,
( try ! NSRegularExpression ( pattern : " ` [^` \\ n]+` " ) , [ . font : NSFont . monospacedSystemFont ( ofSize : 14 , weight : . regular ) , . foregroundColor : NSColor . systemGreen ] ) ,
( try ! NSRegularExpression ( pattern : " (?m)^#{1,6} .+$ " ) , [ . font : NSFont . systemFont ( ofSize : 23 , weight : . semibold ) ] ) ,
( try ! NSRegularExpression ( pattern : " \\ * \\ *[^ \\ n]+? \\ * \\ *|__[^ \\ n]+?__ " ) , [ . font : NSFont . boldSystemFont ( ofSize : 17 ) ] ) ,
( try ! NSRegularExpression ( pattern : " (?<! \\ *) \\ *(?! \\ *)[^* \\ n]+ \\ *(?! \\ *)|(?<!_)_(?!_)[^_ \\ n]+_(?!_) " ) , [ . font : NSFontManager . shared . convert ( NotesAppearance . font , toHaveTrait : . italicFontMask ) ] ) ,
( try ! NSRegularExpression ( pattern : " !? \\ [[^ \\ ] \\ n]+ \\ ] \\ ([^ \\ n)]+ \\ )|(?m)^ \\ s*(?:[-+*]|[0-9]+ \\ .) \\ s " ) , [ . foregroundColor : NotesAppearance . accent ] ) ,
( try ! NSRegularExpression ( pattern : " ~~ [^\\ n]+?~~ " ) , [ . strikethroughStyle : NSUnderlineStyle . single . rawValue ] ) ,
( try ! NSRegularExpression ( pattern : " `[^` \\ n]+` " ) , [ . font : NSFont . monospacedSystemFont ( ofSize : 17 , weight : . regular ) , . foregroundColor : NSColor . systemGreen ] ) ,
]
private static let highlighter : Highlighter = {
guard let value = Highlighter ( ) else { preconditionFailure ( " Missing bundled syntax highlighter " ) }
value . setTheme ( " atom-one-dark " , withFont : " Menlo-Regular " , ofSize : 14 )
value . setTheme ( " atom-one-dark " , withFont : " Menlo-Regular " , ofSize : 17 )
return value
} ( )
private static let openingFence = try ! NSRegularExpression ( pattern : " (?m)^ {0,3}(`{3,}|~{3,})([^ \\ r \\ n]*) \\ r? \\ n " )
@@ -401,7 +574,9 @@ enum MarkdownHighlighting {
let scale = min ( 1 , 460 / max ( size . width , 1 ) , 240 / max ( size . height , 1 ) )
if scale < 1 { image . size = NSSize ( width : size . width * scale , height : size . height * scale ) }
}
storage . addAttributes ( [ . font : NSFont . systemFont ( ofSize : 14 ) , . foregroundColor : SunsetPalette . text ] , range : range )
storage . removeAttribute ( . strikethroughStyle , range : range )
storage . addAttributes ( [ . font : NotesAppearance . font , . foregroundColor : NotesAppearance . text ,
. paragraphStyle : NotesAppearance . paragraph ] , range : range )
let blocks = codeBlocks ( in : text )
for ( expression , attributes ) in rules {
for match in expression . matches ( in : text , range : range ) {
@@ -410,9 +585,9 @@ enum MarkdownHighlighting {
}
}
for block in blocks {
storage . addAttributes ( [ . font : NSFont . monospacedSystemFont ( ofSize : 14 , weight : . regular ) ,
. foregroundColor : SunsetPalette . secondaryText ] , range : block . range )
storage . addAttribute ( . foregroundColor , value : SunsetPalette . text , range : block . body )
storage . addAttributes ( [ . font : NSFont . monospacedSystemFont ( ofSize : 17 , weight : . regular ) ,
. foregroundColor : NotesAppearance . muted ] , range : block . range )
storage . addAttribute ( . foregroundColor , value : NotesAppearance . text , range : block . body )
let code = ( text as NSString ) . substring ( with : block . body )
guard let highlighted = highlighter . highlight ( code , as : block . language ) ,
highlighted . string = = code else { continue }
@@ -423,6 +598,7 @@ enum MarkdownHighlighting {
}
}
}
editor . typingAttributes = [ . font : NSFont . systemFont ( ofSize : 14 ) , . foregroundColor : SunsetPalette . text ]
editor . typingAttributes = [ . font : NotesAppearance . font , . foregroundColor : NotesAppearance . text ,
. paragraphStyle : NotesAppearance . paragraph ]
}
}