2020-08-31 23:28:50 +00:00
//
// M a i n C o m p o s e T e x t V i e w . s w i f t
// T u s k e r
//
// C r e a t e d b y S h a d o w f a c t s o n 8 / 2 9 / 2 0 .
// C o p y r i g h t © 2 0 2 0 S h a d o w f a c t s . A l l r i g h t s r e s e r v e d .
//
import SwiftUI
import Pachyderm
struct MainComposeTextView : View {
@ ObservedObject var draft : Draft
2022-10-31 20:09:40 +00:00
@ State private var placeholder : Text = {
2022-10-30 18:47:36 +00:00
let components = Calendar . current . dateComponents ( [ . month , . day ] , from : Date ( ) )
2022-10-31 20:09:40 +00:00
if components . month = = 3 && components . day = = 14 {
if Date ( ) . formatted ( date : . numeric , time : . omitted ) . starts ( with : " 3 " ) {
return Text ( " Happy π day! " )
}
2022-11-08 03:55:39 +00:00
} else if components . month = = 9 && components . day = = 5 {
// h t t p s : / / w e i r d e r . e a r t h / @ n o r a c o d e s / 1 0 9 2 7 6 4 1 9 8 4 7 2 5 4 5 5 2
// h t t p s : / / r e t r o c o m p u t i n g . s t a c k e x c h a n g e . c o m / q u e s t i o n s / 1 4 7 6 3 / w h a t - w a r n i n g - w a s - g i v e n - o n - a t t e m p t i n g - t o - p o s t - t o - u s e n e t - c i r c a - 1 9 9 0
return Text ( " This program posts news to thousands of machines throughout the entire populated world. Please be sure you know what you are doing. " ) . italic ( )
2022-10-31 20:09:40 +00:00
} else if components . month = = 9 && components . day = = 21 {
2022-10-30 18:47:36 +00:00
return Text ( " Do you remember? " )
} else if components . month = = 10 && components . day = = 31 {
2022-10-31 20:09:40 +00:00
if . random ( ) {
return Text ( " Post something spooky! " )
} else {
return Text ( " Any questions? " )
}
2022-10-30 18:47:36 +00:00
}
return Text ( " What's on your mind? " )
} ( )
2020-08-31 23:28:50 +00:00
let minHeight : CGFloat = 150
@ State private var height : CGFloat ?
2022-11-12 19:16:05 +00:00
@ Binding var becomeFirstResponder : Bool
2020-08-31 23:28:50 +00:00
@ State private var hasFirstAppeared = false
2022-11-05 01:49:37 +00:00
@ ScaledMetric private var fontSize = 20
2020-08-31 23:28:50 +00:00
var body : some View {
ZStack ( alignment : . topLeading ) {
2020-09-17 22:40:02 +00:00
Color ( UIColor . secondarySystemBackground )
if draft . text . isEmpty {
placeholder
2022-11-05 01:49:37 +00:00
. font ( . system ( size : fontSize ) )
2020-09-17 22:40:02 +00:00
. foregroundColor ( . secondary )
. offset ( x : 4 , y : 8 )
2022-11-13 03:48:02 +00:00
. accessibilityHidden ( true )
2020-09-17 22:40:02 +00:00
}
2020-08-31 23:28:50 +00:00
MainComposeWrappedTextView (
text : $ draft . text ,
visibility : draft . visibility ,
becomeFirstResponder : $ becomeFirstResponder
) { ( textView ) in
self . height = max ( textView . contentSize . height , minHeight )
}
2020-10-18 20:31:41 +00:00
}
. frame ( height : height ? ? minHeight )
. onAppear {
2020-08-31 23:28:50 +00:00
if ! hasFirstAppeared {
hasFirstAppeared = true
becomeFirstResponder = true
}
}
}
}
struct MainComposeWrappedTextView : UIViewRepresentable {
typealias UIViewType = UITextView
@ Binding var text : String
let visibility : Status . Visibility
@ Binding var becomeFirstResponder : Bool
var textDidChange : ( UITextView ) -> Void
@ EnvironmentObject var uiState : ComposeUIState
2022-01-24 04:44:38 +00:00
@ EnvironmentObject var mastodonController : MastodonController
2022-11-22 16:06:21 +00:00
@ ObservedObject var preferences = Preferences . shared
2022-11-12 18:52:36 +00:00
@ Environment ( \ . isEnabled ) var isEnabled : Bool
2020-08-31 23:28:50 +00:00
func makeUIView ( context : Context ) -> UITextView {
2022-12-11 17:54:25 +00:00
let textView = WrappedTextView ( uiState : uiState )
2020-08-31 23:28:50 +00:00
textView . delegate = context . coordinator
textView . isEditable = true
2020-09-17 22:40:02 +00:00
textView . backgroundColor = . clear
2022-11-05 01:49:37 +00:00
textView . font = UIFontMetrics ( forTextStyle : . body ) . scaledFont ( for : . systemFont ( ofSize : 20 ) )
textView . adjustsFontForContentSizeCategory = true
2020-08-31 23:28:50 +00:00
textView . textContainer . lineBreakMode = . byWordWrapping
context . coordinator . textView = textView
return textView
}
func updateUIView ( _ uiView : UITextView , context : Context ) {
2021-05-01 23:18:00 +00:00
if context . coordinator . skipSettingTextOnNextUpdate {
context . coordinator . skipSettingTextOnNextUpdate = false
} else {
uiView . text = text
}
2020-10-24 15:19:35 +00:00
2022-11-12 18:52:36 +00:00
uiView . isEditable = isEnabled
2022-11-22 16:06:21 +00:00
uiView . keyboardType = preferences . useTwitterKeyboard ? . twitter : . default
2022-11-12 18:52:36 +00:00
2020-08-31 23:28:50 +00:00
context . coordinator . text = $ text
context . coordinator . didChange = textDidChange
context . coordinator . uiState = uiState
2020-09-25 15:31:53 +00:00
// w a i t u n t i l t h e n e x t r u n l o o p i t e r a t i o n s o t h a t S w i f t U I v i e w u p d a t e s h a v e f i n i s h e d a n d
// t h e t e x t v i e w k n o w s i t s n e w c o n t e n t s i z e
DispatchQueue . main . async {
self . textDidChange ( uiView )
if becomeFirstResponder {
2020-09-07 03:27:43 +00:00
// c a l l i n g b e c o m e F i r s t R e s p o n d e r d u r i n g t h e S w i f t U I u p d a t e c a u s e s a c r a s h o n i O S 1 3
uiView . becomeFirstResponder ( )
// c a n ' t u p d a t e @ S t a t e v a r s d u r i n g t h e S w i f t U I u p d a t e
2020-08-31 23:28:50 +00:00
becomeFirstResponder = false
}
}
}
func makeCoordinator ( ) -> Coordinator {
return Coordinator ( text : $ text , uiState : uiState , didChange : textDidChange )
}
2020-11-14 16:47:20 +00:00
class WrappedTextView : UITextView {
private let formattingActions = [ #selector ( toggleBoldface ( _ : ) ) , #selector ( toggleItalics ( _ : ) ) ]
2022-12-11 17:54:25 +00:00
unowned var uiState : ComposeUIState
init ( uiState : ComposeUIState ) {
self . uiState = uiState
super . init ( frame : . zero , textContainer : nil )
}
required init ? ( coder : NSCoder ) {
fatalError ( " init(coder:) has not been implemented " )
}
2020-11-14 16:47:20 +00:00
override func canPerformAction ( _ action : Selector , withSender sender : Any ? ) -> Bool {
if formattingActions . contains ( action ) {
return Preferences . shared . statusContentType != . plain
}
return super . canPerformAction ( action , withSender : sender )
}
override func toggleBoldface ( _ sender : Any ? ) {
( delegate as ! Coordinator ) . applyFormat ( . bold )
}
override func toggleItalics ( _ sender : Any ? ) {
( delegate as ! Coordinator ) . applyFormat ( . italics )
}
override func validate ( _ command : UICommand ) {
super . validate ( command )
if formattingActions . contains ( command . action ) ,
Preferences . shared . statusContentType != . plain {
command . attributes . remove ( . disabled )
}
}
2022-04-09 15:41:27 +00:00
2022-12-11 17:54:25 +00:00
override func paste ( _ sender : Any ? ) {
2022-12-14 14:47:17 +00:00
// w e d e l i b e r a t e l y e x c l u d e t h e o t h e r C o m p o s i t i o n A t t a c h m e n t r e a d a b l e t y p e i d e n t i f i e r s , b e c a u s e t h a t ' s t o o o v e r z e a l o u s w i t h t h e c o n v e r s i o n
// a n d t h i n g s l i k e U R L s e n d u p p a s t i n g a s a t t a c h m e n t s
if UIPasteboard . general . contains ( pasteboardTypes : UIImage . readableTypeIdentifiersForItemProvider ) {
2022-12-11 17:54:25 +00:00
uiState . delegate ? . paste ( itemProviders : UIPasteboard . general . itemProviders )
} else {
super . paste ( sender )
}
}
2020-11-14 16:47:20 +00:00
}
2022-04-09 15:41:27 +00:00
class Coordinator : NSObject , UITextViewDelegate , ComposeInput , ComposeTextViewCaretScrolling {
2020-08-31 23:28:50 +00:00
weak var textView : UITextView ?
var text : Binding < String >
var didChange : ( UITextView ) -> Void
2022-04-09 15:41:27 +00:00
// b r e a k r e t a i n e d c y c l e t h r o u g h C o m p o s e U I S t a t e . c u r r e n t I n p u t
unowned var uiState : ComposeUIState
2020-11-11 17:24:00 +00:00
var caretScrollPositionAnimator : UIViewPropertyAnimator ?
2020-08-31 23:28:50 +00:00
2021-05-01 23:18:00 +00:00
var skipSettingTextOnNextUpdate = false
2022-04-09 15:41:27 +00:00
var toolbarElements : [ ComposeUIState . ToolbarElement ] {
2022-04-09 15:52:09 +00:00
[ . emojiPicker , . formattingButtons ]
2022-04-09 15:41:27 +00:00
}
2020-08-31 23:28:50 +00:00
init ( text : Binding < String > , uiState : ComposeUIState , didChange : @ escaping ( UITextView ) -> Void ) {
self . text = text
self . didChange = didChange
self . uiState = uiState
2022-11-09 22:18:17 +00:00
super . init ( )
NotificationCenter . default . addObserver ( self , selector : #selector ( keyboardDidShow ) , name : UIResponder . keyboardDidShowNotification , object : nil )
}
@objc private func keyboardDidShow ( ) {
guard let textView ,
textView . isFirstResponder else { return }
ensureCursorVisible ( textView : textView )
2020-08-31 23:28:50 +00:00
}
func textViewDidChange ( _ textView : UITextView ) {
text . wrappedValue = textView . text
didChange ( textView )
2020-10-24 19:46:24 +00:00
2020-11-11 17:24:00 +00:00
ensureCursorVisible ( textView : textView )
2020-08-31 23:28:50 +00:00
}
2020-11-14 16:47:20 +00:00
func applyFormat ( _ format : StatusFormat ) {
guard let textView = textView ,
textView . isFirstResponder ,
let insertionResult = format . insertionResult else {
return
}
2020-08-31 23:28:50 +00:00
let currentSelectedRange = textView . selectedRange
if currentSelectedRange . length = = 0 {
textView . insertText ( insertionResult . prefix + insertionResult . suffix )
textView . selectedRange = NSRange ( location : currentSelectedRange . location + insertionResult . insertionPoint , length : 0 )
} else {
let start = textView . text . index ( textView . text . startIndex , offsetBy : currentSelectedRange . lowerBound )
let end = textView . text . index ( textView . text . startIndex , offsetBy : currentSelectedRange . upperBound )
let selectedText = textView . text [ start . . < end ]
textView . insertText ( String ( insertionResult . prefix + selectedText + insertionResult . suffix ) )
textView . selectedRange = NSRange ( location : currentSelectedRange . location + insertionResult . prefix . utf16 . count , length : currentSelectedRange . length )
}
}
2020-10-12 23:17:57 +00:00
func textViewDidBeginEditing ( _ textView : UITextView ) {
2022-04-09 15:41:27 +00:00
uiState . currentInput = self
2020-10-12 02:14:45 +00:00
updateAutocompleteState ( )
}
2020-10-12 23:17:57 +00:00
func textViewDidEndEditing ( _ textView : UITextView ) {
2022-04-09 15:41:27 +00:00
uiState . currentInput = nil
2020-10-12 02:14:45 +00:00
updateAutocompleteState ( )
}
2021-05-01 23:18:00 +00:00
func textViewDidChangeSelection ( _ textView : UITextView ) {
// S e t t i n g t h e t e x t v i e w ' s t e x t c a u s e s i t t o m o v e t h e c u r s o r t o t h e e n d ( t h o u g h o n l y
// w h e n t h e t e x t c o n t a i n s a n e m o j i : / ) , s o s k i p s e t t i n g t h e t e x t o n t h e n e x t S w i f t U I u p d a t e
// t h a t ' s t r i g g e r e d b y s e t t i n g t h e a u t o c o m p l e t e s t a t e .
skipSettingTextOnNextUpdate = true
self . updateAutocompleteState ( )
}
2022-06-07 22:10:25 +00:00
func textView ( _ textView : UITextView , editMenuForTextIn range : NSRange , suggestedActions : [ UIMenuElement ] ) -> UIMenu ? {
var actions = suggestedActions
if Preferences . shared . statusContentType != . plain ,
let index = suggestedActions . firstIndex ( where : { ( $0 as ? UIMenu ) ? . identifier . rawValue = = " com.apple.menu.format " } ) {
if range . length > 0 {
let formatMenu = suggestedActions [ index ] as ! UIMenu
let newFormatMenu = formatMenu . replacingChildren ( StatusFormat . allCases . map { fmt in
2022-11-13 03:48:02 +00:00
var image : UIImage ?
if let imageName = fmt . imageName {
image = UIImage ( systemName : imageName )
}
return UIAction ( title : fmt . accessibilityLabel , image : image ) { [ weak self ] _ in
2022-06-07 22:10:25 +00:00
self ? . applyFormat ( fmt )
}
} )
actions [ index ] = newFormatMenu
} else {
actions . remove ( at : index )
}
}
if range . length = = 0 {
actions . append ( UIAction ( title : " Insert Emoji " , image : UIImage ( systemName : " face.smiling " ) , handler : { [ weak self ] _ in
self ? . uiState . shouldEmojiAutocompletionBeginExpanded = true
self ? . beginAutocompletingEmoji ( )
} ) )
}
return UIMenu ( children : actions )
}
2022-04-09 15:52:09 +00:00
func beginAutocompletingEmoji ( ) {
2022-06-07 22:10:25 +00:00
guard let textView = textView else {
return
}
var insertSpace = false
if let text = textView . text ,
textView . selectedRange . upperBound > 0 {
let characterBeforeCursorIndex = text . utf16 . index ( before : text . utf16 . index ( text . startIndex , offsetBy : textView . selectedRange . upperBound ) )
insertSpace = ! text [ characterBeforeCursorIndex ] . isWhitespace
}
textView . insertText ( ( insertSpace ? " " : " " ) + " : " )
2022-04-09 15:52:09 +00:00
}
2020-10-12 02:14:45 +00:00
func autocomplete ( with string : String ) {
guard let textView = textView ,
let text = textView . text ,
let ( lastWordStartIndex , _ ) = findAutocompleteLastWord ( ) else {
return
}
2020-10-12 23:39:50 +00:00
let distanceToEnd = text . utf16 . count - textView . selectedRange . upperBound
2020-10-12 02:14:45 +00:00
let characterBeforeCursorIndex = text . utf16 . index ( text . startIndex , offsetBy : textView . selectedRange . upperBound )
2020-10-13 02:03:50 +00:00
let insertSpace : Bool
2020-10-18 15:11:47 +00:00
if distanceToEnd > 0 {
let charAfterCursor = text [ characterBeforeCursorIndex ]
2020-10-13 02:03:50 +00:00
insertSpace = charAfterCursor != " " && charAfterCursor != " \n "
} else {
insertSpace = true
}
let string = insertSpace ? string + " " : string
2020-10-12 02:14:45 +00:00
textView . text . replaceSubrange ( lastWordStartIndex . . < characterBeforeCursorIndex , with : string )
self . textViewDidChange ( textView )
2020-10-18 15:11:47 +00:00
self . updateAutocompleteState ( )
2020-10-12 23:39:50 +00:00
// k e e p t h e c u r s o r a t t h e s a m e p o s i t i o n i n t h e t e x t , i m m e d i a t e l y a f t e r w h a t w a s i n s e r t e d
2020-10-18 15:11:47 +00:00
// i f w e i n s e r t e d a s p a c e , m o v e t h e c u r s o r 1 f a r t h e r s o i t ' s i m m e d i a t e l y a f t e r t h e p r e - e x i s t i n g s p a c e
let insertSpaceOffset = insertSpace ? 0 : 1
textView . selectedRange = NSRange ( location : textView . text . utf16 . count - distanceToEnd + insertSpaceOffset , length : 0 )
2020-10-12 02:14:45 +00:00
}
private func updateAutocompleteState ( ) {
guard let textView = textView ,
let text = textView . text ,
let ( lastWordStartIndex , foundFirstAtSign ) = findAutocompleteLastWord ( ) else {
uiState . autocompleteState = nil
return
}
let triggerChars : [ Character ] = [ " @ " , " : " , " # " ]
if lastWordStartIndex > text . startIndex {
// i f t h e c h a r a c t e r b e f o r e t h e " w o r d " b e g i n n i n g i s a v a l i d p a r t o f a " w o r d " ,
// w e a r e n ' t a b l e t o a u t o c o m p l e t e
let c = text [ text . index ( before : lastWordStartIndex ) ]
if isPermittedForAutocomplete ( c ) || triggerChars . contains ( c ) {
uiState . autocompleteState = nil
return
}
}
let characterBeforeCursorIndex = text . utf16 . index ( text . startIndex , offsetBy : textView . selectedRange . upperBound )
if lastWordStartIndex >= text . startIndex {
let lastWord = text [ lastWordStartIndex . . < characterBeforeCursorIndex ]
let exceptFirst = lastWord [ lastWord . index ( after : lastWord . startIndex ) . . . ]
// p e r i o d s a r e o n l y a l l o w e d i n m e n t i o n s i n t h e d o m a i n p a r t
if lastWord . contains ( " . " ) {
if lastWord . first = = " @ " && foundFirstAtSign {
uiState . autocompleteState = . mention ( String ( exceptFirst ) )
} else {
uiState . autocompleteState = nil
}
return
}
switch lastWord . first {
case " @ " :
uiState . autocompleteState = . mention ( String ( exceptFirst ) )
case " : " :
uiState . autocompleteState = . emoji ( String ( exceptFirst ) )
case " # " :
uiState . autocompleteState = . hashtag ( String ( exceptFirst ) )
default :
uiState . autocompleteState = nil
}
} else {
uiState . autocompleteState = nil
}
}
private func isPermittedForAutocomplete ( _ c : Character ) -> Bool {
return ( c >= " a " && c <= " z " ) || ( c >= " A " && c <= " Z " ) || ( c >= " 0 " && c <= " 9 " ) || c = = " _ "
}
private func findAutocompleteLastWord ( ) -> ( index : String . Index , foundFirstAtSign : Bool ) ? {
guard let textView = textView ,
textView . isFirstResponder ,
textView . selectedRange . length = = 0 ,
textView . selectedRange . upperBound > 0 ,
let text = textView . text ,
text . count > 0 else {
return nil
}
let characterBeforeCursorIndex = text . utf16 . index ( text . startIndex , offsetBy : textView . selectedRange . upperBound )
var lastWordStartIndex = text . index ( before : characterBeforeCursorIndex )
var foundFirstAtSign = false
while true {
2020-10-12 23:17:57 +00:00
let c = text [ lastWordStartIndex ]
2020-10-12 02:14:45 +00:00
if ! isPermittedForAutocomplete ( c ) {
if foundFirstAtSign {
if c != " @ " {
// m o v e t h e i n d e x f o r w a r d b y 1 , s o t h a t t h e f i r s t c h a r o f t h e s u b s t r i n g i s t h e 1 s t @ i n s t e a d o f w h a t e v e r c o m e s b e f o r e i t
lastWordStartIndex = text . index ( after : lastWordStartIndex )
}
break
} else {
if c = = " @ " {
foundFirstAtSign = true
} else if c != " . " {
// p e r i o d s a r e a l l o w e d f o r d o m a i n n a m e s i n m e n t i o n s
break
}
}
}
if lastWordStartIndex > text . startIndex {
lastWordStartIndex = text . index ( before : lastWordStartIndex )
} else {
break
}
}
return ( lastWordStartIndex , foundFirstAtSign )
}
2020-08-31 23:28:50 +00:00
}
}