From ec3678d90ddfd4419ad0abfce1685cc9c6d0a3ea Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Thu, 30 Jan 2025 21:57:23 -0500 Subject: [PATCH] More compose screen tweaks --- .../Views/ComposeNavigationBarActions.swift | 2 +- .../ComposeUI/Views/ComposeToolbarView.swift | 8 ++- .../Sources/ComposeUI/Views/ComposeView.swift | 14 +---- .../Views/ContentWarningTextField.swift | 34 +++++----- .../ComposeUI/Views/DraftContentEditor.swift | 23 +++++-- ...mposeDraftView.swift => DraftEditor.swift} | 14 +++-- .../ComposeUI/Views/NewMainTextView.swift | 6 +- .../Sources/ComposeUI/Views/PollEditor.swift | 62 +++++++++++++------ 8 files changed, 101 insertions(+), 62 deletions(-) rename Packages/ComposeUI/Sources/ComposeUI/Views/{ComposeDraftView.swift => DraftEditor.swift} (86%) diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeNavigationBarActions.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeNavigationBarActions.swift index c00b8ad6..33daac00 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeNavigationBarActions.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeNavigationBarActions.swift @@ -72,7 +72,7 @@ private struct PostOrDraftsButton: View { } private var draftIsEmpty: Bool { - draft.text == draft.initialText && (!draft.contentWarningEnabled || draft.contentWarning == draft.initialContentWarning) && draft.attachments.count == 0 && !draft.pollEnabled + draft.text == draft.initialText && (!draft.contentWarningEnabled || draft.contentWarning == draft.initialContentWarning) && draft.attachments.count == 0 && (!draft.pollEnabled || !draft.poll!.hasContent) } } #endif diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeToolbarView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeToolbarView.swift index ac5d0bba..361cc830 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeToolbarView.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeToolbarView.swift @@ -107,8 +107,12 @@ private struct ContentWarningButton: View { private func toggleContentWarning() { enabled.toggle() - if enabled { - focusedField = .contentWarning + if focusedField != nil { + if enabled { + focusedField = .contentWarning + } else if focusedField == .contentWarning { + focusedField = .body + } } } } diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeView.swift index 06170c5d..24111b76 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeView.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeView.swift @@ -6,6 +6,7 @@ // import SwiftUI +import CoreData struct ComposeView: View { @ObservedObject var draft: Draft @@ -103,7 +104,7 @@ struct ComposeView: View { VStack(spacing: 8) { NewReplyStatusView(draft: draft, mastodonController: mastodonController) - ComposeDraftView(draft: draft, focusedField: $focusedField) + DraftEditor(draft: draft, focusedField: $focusedField) } .padding(8) } @@ -151,16 +152,7 @@ public struct NavigationTitlePreferenceKey: PreferenceKey { enum FocusableField: Hashable { case contentWarning case body - case attachmentDescription(UUID) - - var nextField: FocusableField? { - switch self { - case .contentWarning: - return .body - default: - return nil - } - } + case pollOption(NSManagedObjectID) } #if !os(visionOS) && !targetEnvironment(macCatalyst) diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/ContentWarningTextField.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/ContentWarningTextField.swift index 651f5da4..acceab8b 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Views/ContentWarningTextField.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/ContentWarningTextField.swift @@ -12,24 +12,22 @@ struct ContentWarningTextField: View { @FocusState.Binding var focusedField: FocusableField? var body: some View { - if draft.contentWarningEnabled { - EmojiTextField( - text: $draft.contentWarning, - placeholder: "Write your warning here", - maxLength: nil, - // TODO: completely replace this with FocusState - becomeFirstResponder: .constant(false), - focusNextView: Binding(get: { - false - }, set: { - if $0 { - focusedField = .body - } - }) - ) - .focused($focusedField, equals: .contentWarning) - .modifier(FocusedInputModifier()) - } + EmojiTextField( + text: $draft.contentWarning, + placeholder: "Write your warning here", + maxLength: nil, + // TODO: completely replace this with FocusState + becomeFirstResponder: .constant(false), + focusNextView: Binding(get: { + false + }, set: { + if $0 { + focusedField = .body + } + }) + ) + .focused($focusedField, equals: .contentWarning) + .modifier(FocusedInputModifier()) } } diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/DraftContentEditor.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/DraftContentEditor.swift index 68a9d7db..3a57a01d 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Views/DraftContentEditor.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/DraftContentEditor.swift @@ -20,7 +20,7 @@ struct DraftContentEditor: View { HStack(alignment: .firstTextBaseline) { LanguageButton(draft: draft) - TogglePollButton(draft: draft) + TogglePollButton(draft: draft, focusedField: $focusedField) Spacer() CharactersRemaining(draft: draft) .padding(.trailing, 6) @@ -82,6 +82,7 @@ private struct LanguageButton: View { private struct LanguageButtonStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label + .font(.body.monospaced()) .foregroundStyle(.tint.opacity(configuration.isPressed ? 0.8 : 1)) .padding(.vertical, 2) .padding(.horizontal, 4) @@ -93,8 +94,9 @@ private struct LanguageButtonStyle: ButtonStyle { private struct TogglePollButton: View { @ObservedObject var draft: Draft + @FocusState.Binding var focusedField: FocusableField? @EnvironmentObject private var instanceFeatures: InstanceFeatures - + var body: some View { Button(action: togglePoll) { Image(systemName: draft.pollEnabled ? "chart.bar.doc.horizontal.fill" : "chart.bar.horizontal.page") @@ -112,11 +114,24 @@ private struct TogglePollButton: View { private func togglePoll() { if draft.pollEnabled { draft.pollEnabled = false + + if case .pollOption(_) = focusedField { + focusedField = .body + } } else { - if draft.poll == nil { - draft.poll = Poll(context: DraftsPersistentContainer.shared.viewContext) + let poll: Poll + if let p = draft.poll { + poll = p + } else { + poll = Poll(context: DraftsPersistentContainer.shared.viewContext) + draft.poll = poll } draft.pollEnabled = true + + if focusedField != nil { + let optionToFocus = poll.pollOptions.first(where: { $0.text.isEmpty }) ?? poll.pollOptions.last! + focusedField = .pollOption(optionToFocus.id) + } } } } diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeDraftView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/DraftEditor.swift similarity index 86% rename from Packages/ComposeUI/Sources/ComposeUI/Views/ComposeDraftView.swift rename to Packages/ComposeUI/Sources/ComposeUI/Views/DraftEditor.swift index 5262a489..b58411ce 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeDraftView.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/DraftEditor.swift @@ -1,5 +1,5 @@ // -// ComposeDraftView.swift +// DraftEditor.swift // ComposeUI // // Created by Shadowfacts on 11/16/24. @@ -9,7 +9,7 @@ import SwiftUI import Pachyderm import TuskerComponents -struct ComposeDraftView: View { +struct DraftEditor: View { @ObservedObject var draft: Draft @FocusState.Binding var focusedField: FocusableField? @Environment(\.currentAccount) private var currentAccount @@ -24,13 +24,16 @@ struct ComposeDraftView: View { AccountNameView(account: currentAccount) } - ContentWarningTextField(draft: draft, focusedField: $focusedField) + if draft.contentWarningEnabled { + ContentWarningTextField(draft: draft, focusedField: $focusedField) + .transition(.opacity) + } DraftContentEditor(draft: draft, focusedField: $focusedField) if let poll = draft.poll, draft.pollEnabled { - PollEditor(poll: poll) + PollEditor(poll: poll, focusedField: $focusedField) .padding(.bottom, 4) // So that during the appearance transition, it's behind the text view. .zIndex(-1) @@ -40,11 +43,12 @@ struct ComposeDraftView: View { AttachmentsSection(draft: draft) // We want the padding between the poll/attachments to be part of the poll, so it animates in/out with the transition. // Otherwise, when the poll is added, its bottom edge is aligned with the top edge of the attachments - .padding(.top, draft.poll == nil ? 0 : -4) + .padding(.top, draft.pollEnabled ? -4 : 0) } // These animations are here, because the height of the VStack and the positions of the lower views needs to animate too. .animation(.snappy, value: draft.pollEnabled) .modifier(PollAnimatingModifier(poll: draft.poll)) + .animation(.snappy, value: draft.contentWarningEnabled) } } } diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/NewMainTextView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/NewMainTextView.swift index baaa56fe..10ba3c91 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Views/NewMainTextView.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/NewMainTextView.swift @@ -142,6 +142,10 @@ private final class WrappedTextViewCoordinator: NSObject { private func attributedTextFromPlain(_ text: String) -> NSAttributedString { let str = NSMutableAttributedString(string: text) + str.addAttributes([ + .foregroundColor: UIColor.label, + .font: UIFontMetrics.default.scaledFont(for: .systemFont(ofSize: 20)), + ], range: NSRange(location: 0, length: str.length)) let mentionMatches = CharacterCounter.mention.matches(in: text, range: NSRange(location: 0, length: str.length)) for match in mentionMatches.reversed() { str.insert(NSAttributedString(attachment: Self.attachment), at: match.range.location) @@ -175,7 +179,7 @@ private final class WrappedTextViewCoordinator: NSObject { if mentionRegex.numberOfMatches(in: substr, range: NSRange(location: 0, length: substr.utf16.count)) == 0 { changed = true str.removeAttribute(.mention, range: range) - str.removeAttribute(.foregroundColor, range: range) + str.addAttribute(.foregroundColor, value: UIColor.label, range: range) if hasTextAttachment { str.deleteCharacters(in: NSRange(location: range.location, length: 1)) cursorOffset -= 1 diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/PollEditor.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/PollEditor.swift index a06649b9..0653e753 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Views/PollEditor.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/PollEditor.swift @@ -11,20 +11,19 @@ import TuskerComponents struct PollEditor: View { @ObservedObject var poll: Poll - + @FocusState.Binding var focusedField: FocusableField? + var body: some View { VStack(alignment: .leading, spacing: 8) { Text("Poll") .font(.headline) ForEach(poll.pollOptions) { option in - PollOptionEditor(option: option, index: poll.options.index(of: option)) { - self.removeOption(option) - } - .transition(.opacity) + PollOptionEditor(poll: poll, option: option, focusedField: $focusedField) + .transition(.opacity) } - AddOptionButton(poll: poll) + AddOptionButton(poll: poll, focusedField: $focusedField) Toggle("Multiple choice", isOn: $poll.multiple) .padding(.bottom, -8) @@ -41,19 +40,11 @@ struct PollEditor: View { .frame(maxWidth: .infinity, alignment: .leading) .composePlatterBackground() } - - private func removeOption(_ option: PollOption) { - let index = poll.options.index(of: option) - if index != NSNotFound { - var array = poll.options.array - array.remove(at: index) - poll.options = NSMutableOrderedSet(array: array) - } - } } private struct AddOptionButton: View { @ObservedObject var poll: Poll + @FocusState.Binding var focusedField: FocusableField? @EnvironmentObject private var instanceFeatures: InstanceFeatures private var canAddOption: Bool { @@ -76,6 +67,7 @@ private struct AddOptionButton: View { let option = PollOption(context: DraftsPersistentContainer.shared.viewContext) option.poll = poll poll.options.add(option) + focusedField = .pollOption(option.id) } } @@ -147,16 +139,17 @@ private enum PollDuration: Hashable, Equatable, CaseIterable { } private struct PollOptionEditor: View { + @ObservedObject var poll: Poll @ObservedObject var option: PollOption - let index: Int - let removeOption: () -> Void + @FocusState.Binding var focusedField: FocusableField? @EnvironmentObject private var instanceFeatures: InstanceFeatures var placeholder: String { + let index = poll.options.index(of: option) if index != NSNotFound { - "Option \(index + 1)" + return "Option \(index + 1)" } else { - "" + return "" } } @@ -168,12 +161,33 @@ private struct PollOptionEditor: View { .labelStyle(.iconOnly) .buttonStyle(PollOptionButtonStyle()) .accessibilityLabel("Remove option") + .disabled(poll.options.count == 1) + EmojiTextField(text: $option.text, placeholder: placeholder, maxLength: instanceFeatures.maxPollOptionChars) + .focused($focusedField, equals: .pollOption(option.id)) + } + } + + private func removeOption() { + let index = poll.options.index(of: option) + if index != NSNotFound && poll.options.count > 1 { + var array = poll.options.array + array.remove(at: index) + poll.options = NSMutableOrderedSet(array: array) + // TODO: does this leave dangling PollOptions in the managed object context? + + if case .pollOption(let id) = focusedField, + id == option.id { + let indexToFocus = index > 0 ? index - 1 : 0 + focusedField = .pollOption(poll.pollOptions[indexToFocus].id) + } } } } private struct PollOptionButtonStyle: ButtonStyle { + @Environment(\.isEnabled) private var isEnabled + func makeBody(configuration: Configuration) -> some View { configuration.label .foregroundStyle(.white) @@ -181,7 +195,15 @@ private struct PollOptionButtonStyle: ButtonStyle { .padding(4) .frame(width: 20, height: 20) .background { - let color = configuration.role == .destructive ? Color.red : .green + let color = if isEnabled { + if configuration.role == .destructive { + Color.red + } else { + Color.green + } + } else { + Color.gray + } let opacity = configuration.isPressed ? 0.8 : 1 Circle() .fill(color.opacity(opacity))