WIP rewritten main text view

This commit is contained in:
Shadowfacts 2024-08-15 21:55:58 -07:00
parent 54fadaa270
commit 17c67a3d5d
4 changed files with 272 additions and 6 deletions

View File

@ -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)

View File

@ -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 Fools!").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("Whats 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

View File

@ -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) }

View File

@ -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<String>
init(value: Binding<String>) {
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)
}
}