diff --git a/Packages/ComposeUI/Sources/ComposeUI/CharacterCounter.swift b/Packages/ComposeUI/Sources/ComposeUI/CharacterCounter.swift index 2e2002cc..ea649dc5 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/CharacterCounter.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/CharacterCounter.swift @@ -12,7 +12,7 @@ import InstanceFeatures public struct CharacterCounter { private static let linkDetector = try! NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) - private static let mention = try! NSRegularExpression(pattern: "(@[a-z0-9_]+)(?:@[a-z0-9\\-\\.]+[a-z0-9]+)?", options: .caseInsensitive) + static let mention = try! NSRegularExpression(pattern: "(@[a-z0-9_]+)(?:@[a-z0-9\\-\\.]+[a-z0-9]+)?", options: .caseInsensitive) public static func count(text: String, for instanceFeatures: InstanceFeatures) -> Int { let mentionsRemoved = removeMentions(in: text) diff --git a/Packages/ComposeUI/Sources/ComposeUI/Controllers/PlaceholderController.swift b/Packages/ComposeUI/Sources/ComposeUI/Controllers/PlaceholderController.swift index 0b04a5d4..584234c6 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Controllers/PlaceholderController.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Controllers/PlaceholderController.swift @@ -17,7 +17,7 @@ final class PlaceholderController: ViewController, PlaceholderViewProvider { Date().formatted(date: .numeric, time: .omitted).starts(with: "3") { Text("Happy π day!") } else if components.month == 4 && components.day == 1 { - Text("April Fool's!").rotationEffect(.radians(.pi), anchor: .center) + Text("April Fool’s!").rotationEffect(.radians(.pi), anchor: .center) } else if components.month == 9 && components.day == 5 { // https://weirder.earth/@noracodes/109276419847254552 // https://retrocomputing.stackexchange.com/questions/14763/what-warning-was-given-on-attempting-to-post-to-usenet-circa-1990 @@ -31,7 +31,7 @@ final class PlaceholderController: ViewController, PlaceholderViewProvider { Text("Any questions?") } } else { - Text("What's on your mind?") + Text("What’s on your mind?") } } @@ -41,7 +41,7 @@ final class PlaceholderController: ViewController, PlaceholderViewProvider { } // exists to provide access to the type alias since the @State property needs it to be explicit -private protocol PlaceholderViewProvider { +protocol PlaceholderViewProvider { associatedtype PlaceholderView: View @ViewBuilder static func makePlaceholderView() -> PlaceholderView diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeView.swift index d2da03b1..64b94f3d 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeView.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeView.swift @@ -12,6 +12,7 @@ struct ComposeView: View { let mastodonController: any ComposeMastodonContext @State private var poster: PostService? = nil @FocusState private var focusedField: FocusableField? + @EnvironmentObject private var controller: ComposeController var body: some View { if #available(iOS 16.0, *) { @@ -55,7 +56,7 @@ struct ComposeView: View { .modifier(NavigationTitleModifier(draft: draft, mastodonController: mastodonController)) .navigationBarTitleDisplayMode(.inline) .toolbar { - ToolbarActions(draft: draft) + ToolbarActions(draft: draft, controller: controller) #if os(visionOS) ToolbarItem(placement: .bottomOrnament) { toolbarView @@ -76,6 +77,8 @@ struct ComposeView: View { NewHeaderView(draft: draft, instanceFeatures: mastodonController.instanceFeatures) ContentWarningTextField(draft: draft, focusedField: $focusedField) + + NewMainTextView(value: $draft.text, focusedField: $focusedField) } .padding(8) } @@ -113,7 +116,7 @@ public struct NavigationTitlePreferenceKey: PreferenceKey { private struct ToolbarActions: ToolbarContent { @ObservedObject var draft: Draft - @EnvironmentObject private var controller: ComposeController + let controller: ComposeController var body: some ToolbarContent { ToolbarItem(placement: .cancellationAction) { ToolbarCancelButton(draft: draft) } diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/NewMainTextView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/NewMainTextView.swift new file mode 100644 index 00000000..65343477 --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/NewMainTextView.swift @@ -0,0 +1,263 @@ +// +// NewMainTextView.swift +// ComposeUI +// +// Created by Shadowfacts on 8/11/24. +// + +import SwiftUI + +struct NewMainTextView: View { + @Binding var value: String + @FocusState.Binding var focusedField: FocusableField? + @State private var becomeFirstResponder = true + + var body: some View { + NewMainTextViewRepresentable(value: $value, becomeFirstResponder: $becomeFirstResponder) + .focused($focusedField, equals: .body) + .modifier(FocusedInputModifier()) + .overlay(alignment: .topLeading) { + if value.isEmpty { + PlaceholderView() + } + } + } +} + +private struct NewMainTextViewRepresentable: UIViewRepresentable { + @Binding var value: String + @Binding var becomeFirstResponder: Bool + @Environment(\.composeInputBox) private var inputBox + @Environment(\.isEnabled) private var isEnabled + @Environment(\.colorScheme) private var colorScheme + @Environment(\.composeUIConfig.fillColor) private var fillColor + @Environment(\.composeUIConfig.useTwitterKeyboard) private var useTwitterKeyboard + // TODO: test textSelectionStartsAtBeginning + @Environment(\.composeUIConfig.textSelectionStartsAtBeginning) private var textSelectionStartsAtBeginning + + func makeUIView(context: Context) -> UITextView { + let view: UITextView + // TODO: if we're not doing the pill background, reevaluate whether this version fork is necessary + if #available(iOS 16.0, *) { + view = WrappedTextView(usingTextLayoutManager: true) + } else { + view = WrappedTextView() + } + view.delegate = context.coordinator + view.adjustsFontForContentSizeCategory = true + view.textContainer.lineBreakMode = .byWordWrapping + view.isScrollEnabled = false + view.typingAttributes = [ + .foregroundColor: UIColor.label, + .font: UIFontMetrics.default.scaledFont(for: .systemFont(ofSize: 20)), + ] + + view.layer.cornerRadius = 5 + view.layer.cornerCurve = .continuous +// view.layer.shadowColor = UIColor.black.cgColor +// view.layer.shadowOpacity = 0.15 +// view.layer.shadowOffset = .zero +// view.layer.shadowRadius = 1 + + if textSelectionStartsAtBeginning { + // Update the text immediately so that the selection isn't invalidated by the text changing. + context.coordinator.updateTextViewTextIfNecessary(value, textView: view) + view.selectedTextRange = view.textRange(from: view.beginningOfDocument, to: view.beginningOfDocument) + } + + return view + } + + func updateUIView(_ uiView: UIViewType, context: Context) { + context.coordinator.value = $value + context.coordinator.updateTextViewTextIfNecessary(value, textView: uiView) + + uiView.isEditable = isEnabled + uiView.keyboardType = useTwitterKeyboard ? .twitter : .default + #if !os(visionOS) + uiView.backgroundColor = colorScheme == .dark ? UIColor(fillColor) : .secondarySystemBackground + #endif + + // Trying to set this with the @FocusState binding in onAppear results in the + // keyboard not appearing until after the sheet presentation animation completes :/ + if becomeFirstResponder { + uiView.becomeFirstResponder() + DispatchQueue.main.async { + becomeFirstResponder = false + } + } + } + + func makeCoordinator() -> WrappedTextViewCoordinator { + let coordinator = WrappedTextViewCoordinator(value: $value) +// DispatchQueue.main.async { +// inputBox.wrappedValue = coordinator +// } + return coordinator + } + + // TODO: fallback for this on iOS 15 + @available(iOS 16.0, *) + func sizeThatFits(_ proposal: ProposedViewSize, uiView: UIViewType, context: Context) -> CGSize? { + let width = proposal.width ?? 10 + let size = uiView.sizeThatFits(CGSize(width: width, height: 0)) + return CGSize(width: width, height: max(150, size.height)) + } +} + +private final class WrappedTextViewCoordinator: NSObject { + private static let attachment: NSTextAttachment = { + let font = UIFont.systemFont(ofSize: 20) + let size = /*1.4 * */font.capHeight + let renderer = UIGraphicsImageRenderer(size: CGSize(width: size, height: size)) + let image = renderer.image { ctx in + UIColor.systemRed.setFill() + ctx.fill(CGRect(x: 0, y: 0, width: size, height: size)) + } + let attachment = NSTextAttachment(image: image) + attachment.bounds = CGRect(x: 0, y: -1, width: size + 2, height: size) + attachment.lineLayoutPadding = 1 + return attachment + }() + + var value: Binding + + init(value: Binding) { + self.value = value + } + + private func plainTextFromAttributed(_ attributedText: NSAttributedString) -> String { + attributedText.string.replacingOccurrences(of: "\u{FFFC}", with: "") + } + + private func attributedTextFromPlain(_ text: String) -> NSAttributedString { + let str = NSMutableAttributedString(string: text) + let mentionMatches = CharacterCounter.mention.matches(in: text, range: NSRange(location: 0, length: str.length)) + for match in mentionMatches.reversed() { + let range: NSRange + if #available(iOS 16.0, *) { + str.insert(NSAttributedString(attachment: Self.attachment), at: match.range.location) + range = NSRange(location: match.range.location, length: match.range.length + 1) + } else { + range = match.range + } + str.addAttributes([ + .mention: true, + .foregroundColor: UIColor.tintColor, + ], range: range) + } + return str + } + + func updateTextViewTextIfNecessary(_ text: String, textView: UITextView) { + if text != plainTextFromAttributed(textView.attributedText) { + textView.attributedText = attributedTextFromPlain(text) + } + } + + private func updateAttributes(in textView: UITextView) { + let str = NSMutableAttributedString(attributedString: textView.attributedText!) + var changed = false + + // remove existing mentions that aren't valid + str.enumerateAttribute(.mention, in: NSRange(location: 0, length: str.length), options: .reverse) { value, range, stop in + var substr = (str.string as NSString).substring(with: range) + let hasTextAttachment = substr.unicodeScalars.first == UnicodeScalar(NSTextAttachment.character) + if hasTextAttachment { + substr = String(substr.dropFirst()) + } + if CharacterCounter.mention.numberOfMatches(in: substr, range: NSRange(location: 0, length: substr.utf16.count)) == 0 { + changed = true + str.removeAttribute(.mention, range: range) + str.removeAttribute(.foregroundColor, range: range) + if hasTextAttachment { + str.deleteCharacters(in: NSRange(location: range.location, length: 1)) + } + } + } + + // add mentions for those missing + let mentionMatches = CharacterCounter.mention.matches(in: str.string, range: NSRange(location: 0, length: str.length)) + for match in mentionMatches.reversed() { + var attributeRange = NSRange() + let attribute = str.attribute(.mention, at: match.range.location, effectiveRange: &attributeRange) + // the attribute range should always be one greater than the match range, to account for the text attachment + if attribute == nil || attributeRange.length <= match.range.length { + changed = true + if #available(iOS 16.0, *) { + let newAttributeRange: NSRange + if attribute == nil { + str.insert(NSAttributedString(attachment: Self.attachment), at: match.range.location) + newAttributeRange = NSRange(location: match.range.location, length: match.range.length + 1) + } else { + newAttributeRange = match.range + } + str.addAttributes([ + .mention: true, + .foregroundColor: UIColor.tintColor, + ], range: newAttributeRange) + } else { + str.addAttribute(.foregroundColor, value: UIColor.tintColor, range: match.range) + } + } + } + + if changed { + textView.attributedText = str + } + } +} + +extension WrappedTextViewCoordinator: UITextViewDelegate { + func textViewDidChange(_ textView: UITextView) { + if textView.text.isEmpty { + textView.typingAttributes = [ + .foregroundColor: UIColor.label, + .font: UIFontMetrics.default.scaledFont(for: .systemFont(ofSize: 20)), + ] + } else { + updateAttributes(in: textView) + } + + let plain = plainTextFromAttributed(textView.attributedText) + if plain != value.wrappedValue { + value.wrappedValue = plain + } + } + + func textViewDidChangeSelection(_ textView: UITextView) { + } +} + +//extension WrappedTextViewCoordinator: ComposeInput { +// +//} + +private final class WrappedTextView: UITextView { +} + +private extension NSAttributedString.Key { + static let mention = NSAttributedString.Key("Tusker.ComposeUI.mention") +} + +private struct PlaceholderView: View { + @State private var placeholder: PlaceholderController.PlaceholderView = PlaceholderController.makePlaceholderView() + @ScaledMetric private var fontSize = 20 + + private var placeholderOffset: CGSize { + #if os(visionOS) + CGSize(width: 8, height: 8) + #else + CGSize(width: 4, height: 8) + #endif + } + + var body: some View { + placeholder + .font(.system(size: fontSize)) + .foregroundStyle(.secondary) + .offset(placeholderOffset) + .accessibilityHidden(true) + .allowsHitTesting(false) + } +}