From d03b4c4dd77539886610a6624f9446e0d6a8b434 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Sat, 2 May 2026 16:39:39 -0700 Subject: [PATCH] ios: send from software keyboard --- .../Sources/Sybil/SybilWorkspaceView.swift | 119 ++++++++++++++++-- 1 file changed, 106 insertions(+), 13 deletions(-) diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilWorkspaceView.swift b/ios/Packages/Sybil/Sources/Sybil/SybilWorkspaceView.swift index e792065..854828a 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilWorkspaceView.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilWorkspaceView.swift @@ -1,5 +1,6 @@ import Observation import SwiftUI +import UIKit struct SybilWorkspaceView: View { @Bindable var viewModel: SybilViewModel @@ -216,19 +217,23 @@ struct SybilWorkspaceView: View { private var composerBar: some View { HStack(alignment: .bottom, spacing: 10) { - TextField( - viewModel.isSearchMode ? "Search the web" : "Message Sybil", - text: $viewModel.composer, - axis: .vertical - ) - .focused($composerFocused) - .textInputAutocapitalization(.sentences) - .autocorrectionDisabled(false) - .lineLimit(1 ... 6) - .submitLabel(.send) - .onSubmit { - Task { - await viewModel.sendComposer() + ZStack(alignment: .topLeading) { + ComposerTextView( + text: $viewModel.composer, + isFocused: $composerFocused, + isDisabled: viewModel.isSending + ) { + Task { + await viewModel.sendComposer() + } + } + .frame(minHeight: 24, maxHeight: 132) + + if viewModel.composer.isEmpty { + Text(viewModel.isSearchMode ? "Search the web" : "Message Sybil") + .font(.body) + .foregroundStyle(SybilTheme.textMuted.opacity(0.72)) + .allowsHitTesting(false) } } .padding(.horizontal, 12) @@ -278,3 +283,91 @@ struct SybilWorkspaceView: View { ) } } + +private struct ComposerTextView: UIViewRepresentable { + @Binding var text: String + var isFocused: FocusState.Binding + var isDisabled: Bool + var onSubmit: () -> Void + + func makeUIView(context: Context) -> UITextView { + let textView = UITextView() + textView.delegate = context.coordinator + textView.backgroundColor = .clear + textView.font = .preferredFont(forTextStyle: .body) + textView.textColor = UIColor(SybilTheme.text) + textView.tintColor = UIColor(SybilTheme.primary) + textView.textContainerInset = .zero + textView.textContainer.lineFragmentPadding = 0 + textView.isScrollEnabled = true + textView.alwaysBounceVertical = false + textView.keyboardDismissMode = .interactive + textView.returnKeyType = .send + textView.enablesReturnKeyAutomatically = true + textView.autocapitalizationType = .sentences + textView.autocorrectionType = .default + textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + return textView + } + + func updateUIView(_ textView: UITextView, context: Context) { + context.coordinator.parent = self + + if textView.text != text { + textView.text = text + } + + textView.isEditable = !isDisabled + textView.alpha = isDisabled ? 0.74 : 1 + + if isFocused.wrappedValue, !textView.isFirstResponder { + textView.becomeFirstResponder() + } else if !isFocused.wrappedValue, textView.isFirstResponder { + textView.resignFirstResponder() + } + } + + func sizeThatFits(_ proposal: ProposedViewSize, uiView: UITextView, context: Context) -> CGSize? { + let width = proposal.width ?? uiView.bounds.width + let fittingSize = uiView.sizeThatFits( + CGSize(width: width, height: CGFloat.greatestFiniteMagnitude) + ) + return CGSize(width: width, height: min(max(fittingSize.height, 24), 132)) + } + + func makeCoordinator() -> Coordinator { + Coordinator(parent: self) + } + + final class Coordinator: NSObject, UITextViewDelegate { + var parent: ComposerTextView + + init(parent: ComposerTextView) { + self.parent = parent + } + + func textViewDidChange(_ textView: UITextView) { + parent.text = textView.text + } + + func textViewDidBeginEditing(_ textView: UITextView) { + parent.isFocused.wrappedValue = true + } + + func textViewDidEndEditing(_ textView: UITextView) { + parent.isFocused.wrappedValue = false + } + + func textView( + _ textView: UITextView, + shouldChangeTextIn range: NSRange, + replacementText replacement: String + ) -> Bool { + if replacement == "\n" { + parent.onSubmit() + return false + } + return true + } + } +}