Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -90,14 +90,15 @@ struct WritingAssistantExample: View {
.waHelperAction(self.$helperAction)
.frame(height: 100)

NoteFormView(text: self.$text2, placeholder: "NoteFormView2", allowsBeyondLimit: false)
TextFieldFormView(title: {
Text("TextFieldFormView Title")
}, text: self.$text2)
.waTextInput(self.$text2, menus: WAMenu.availableMenus, menuHandler: { menu, value in
await self.fetchData(for: menu, value: value)
}, feedbackOptions: self.feedbackOptions, feedbackHandler: { state, values in
await self.submitFeedback(state: state, values: values)
})
.waHelperAction(self.$helperAction)
.frame(height: 100)

Spacer()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ struct InternalWAForm: View {
.alert("Discard all changes?", isPresented: self.$context.showCancelAlert, actions: {
Button(role: .cancel) {
self.context.showCancelAlert = false
self.context.updateInWAFlow(true)
} label: {
Text("Keep Working")
.font(.fiori(forTextStyle: .caption1))
Expand Down
92 changes: 61 additions & 31 deletions Sources/FioriSwiftUICore/AIWritingAssistant/WATextInput.swift
Original file line number Diff line number Diff line change
Expand Up @@ -96,32 +96,44 @@ struct WATextInputModifier: ViewModifier {
self.formView = WritingAssistantForm(text: text, menus: menus)
}

func isSameTextInput(_ l: (any WATextInput)?, _ r: (any WATextInput)?) -> Bool {
ObjectIdentifier(l as AnyObject) == ObjectIdentifier(r as AnyObject)
}

// swiftlint:disable function_body_length cyclomatic_complexity
func body(content: Content) -> some View {
content
.modifier(
FioriIntrospectModifier<UITextView> { textView in
self.context.textView = textView
self.context.waTextInput = textView
self.context.observeSelectionChange(for: textView)
}
)
.modifier(
FioriIntrospectModifier<UITextField> { textField in
self.context.waTextInput = textField
self.context.observeSelectionChange(for: textField)
}
)
.focused(self.$isTextInputFocused)
.onReceive(self.keyboardPublisher) { value in
if self.context.logKeyboardChanged {
if value.0, self.isTextInputFocused, value.1 == self.context.textView {
let isSameTextInput = self.isSameTextInput(value.1, self.context.waTextInput)
if value.0, self.isTextInputFocused, isSameTextInput {
self.context.updateInWAFlow(true)
} else if value.0, value.1 != self.context.textView {
} else if value.0, !isSameTextInput {
self.context.updateInWAFlow(false)
}
}
}
.toolbar {
#if !os(visionOS)
ToolbarItem(placement: .keyboard) {
if !self.context.isPresented, self.context.isInWAFlow {
if self.isTextInputFocused, !self.context.isPresented, self.context.isInWAFlow {
HStack {
Spacer()
self.waAction
.fixedSize()
.onSimultaneousTapGesture {
self.context.updateOriginalSelectedRange()
self.context.isPresented = true
Expand Down Expand Up @@ -225,19 +237,19 @@ struct WATextInputModifier: ViewModifier {
}

@MainActor func restoreSelectedRange(by range: NSRange? = nil) {
if let textView = self.context.textView {
if let textView = self.context.waTextInput as? UITextView {
let selectedRange = range ?? self.context.usedSelectedRange
DispatchQueue.main.async {
self.context.canResetSelectedRange = false
textView.select(textView)
textView.selectedRange = selectedRange
}
} else if let textField = self.context.textField {
} else if let textField = self.context.waTextInput as? UITextField {
let selectedRange = range ?? self.context.usedSelectedRange
DispatchQueue.main.async {
self.context.canResetSelectedRange = false
textField.select(textField)
textField.resetSelectedTextRange(by: selectedRange)
textField.selectedRange = selectedRange
}
}
}
Expand All @@ -247,7 +259,7 @@ struct WATextInputModifier: ViewModifier {
self.context.logKeyboardChanged = true
}
self.context.logKeyboardChanged = false
if let textView = self.context.textView {
if let textView = self.context.waTextInput as? UITextView {
if !self.context.isInWAFlow {
textView.inputView = nil
textView.isEditable = true
Expand All @@ -268,13 +280,12 @@ struct WATextInputModifier: ViewModifier {
textView.isSelectable = true
textView.becomeFirstResponder()
}
} else if let textField = self.context.textField {
} else if let textField = self.context.waTextInput as? UITextField {
if !self.context.isInWAFlow {
textField.inputView = nil
textField.resignFirstResponder()
return
}
let currentRange = textField.selectedRange
if useWAPanel {
textField.resignFirstResponder()
textField.inputView = UIView()
Expand All @@ -285,18 +296,19 @@ struct WATextInputModifier: ViewModifier {
textField.inputView = nil
textField.becomeFirstResponder()
}
textField.resetSelectedTextRange(by: currentRange)
}
}

var keyboardPublisher: AnyPublisher<(keyboardShown: Bool, textView: UITextView?), Never> {
var keyboardPublisher: AnyPublisher<(keyboardShown: Bool, textView: (any WATextInput)?), Never> {
Publishers.Merge(
NotificationCenter
.default
.publisher(for: UIResponder.keyboardWillShowNotification)
.map { _ in
if let textView = UIResponder.findFirstResponder() as? UITextView {
return (true, textView)
} else if let textField = UIResponder.findFirstResponder() as? UITextField {
return (true, textField)
} else {
return (true, nil)
}
Expand All @@ -307,6 +319,8 @@ struct WATextInputModifier: ViewModifier {
.map { _ in
if let textView = UIResponder.findFirstResponder() as? UITextView {
return (false, textView)
} else if let textField = UIResponder.findFirstResponder() as? UITextField {
return (false, textField)
} else {
return (false, nil)
}
Expand All @@ -333,32 +347,48 @@ extension WritingAssistantContext {
}
}
}

func observeSelectionChange(for textField: UITextField) {
selectionKVO?.invalidate()
selectionKVO = textField.observe(\.selectedTextRange, options: [.new]) { [weak self] tv, _ in
guard let self else { return }
if let range = tv.selectedTextRange {
let start = tv.offset(from: tv.beginningOfDocument, to: range.start)
let end = tv.offset(from: tv.beginningOfDocument, to: range.end)
let nsRange = NSRange(location: start, length: end - start)
self.resetSelectedRange(nsRange)
}
}
}
}

extension WritingAssistantContext: UITextFieldDelegate {
func textFieldDidChangeSelection(_ textField: UITextField) {
self.resetSelectedRange(textField.selectedRange)
}
protocol WATextInput: Equatable {
var isFirstResponder: Bool { get }
var selectedRange: NSRange { get set }
}

extension UITextField: WATextInput {}
extension UITextView: WATextInput {}

extension UITextField {
var selectedRange: NSRange {
if let range = self.selectedTextRange {
let start = self.offset(from: self.beginningOfDocument, to: range.start)
let end = self.offset(from: self.beginningOfDocument, to: range.end)
let selectedRange = NSRange(location: start, length: end - start)
return selectedRange
} else {
return NSRange(location: self.text?.count ?? 0, length: 0)
get {
if let range = self.selectedTextRange {
let start = self.offset(from: self.beginningOfDocument, to: range.start)
let end = self.offset(from: self.beginningOfDocument, to: range.end)
let selectedRange = NSRange(location: start, length: end - start)
return selectedRange
} else {
return NSRange(location: self.text?.count ?? 0, length: 0)
}
}
}

func resetSelectedTextRange(by range: NSRange) {
if let start = self.position(from: self.beginningOfDocument, offset: range.location),
let end = self.position(from: start, offset: range.length),
let selectedRange = self.textRange(from: start, to: end)
{
self.selectedTextRange = selectedRange
set {
if let start = self.position(from: self.beginningOfDocument, offset: newValue.location),
let end = self.position(from: start, offset: newValue.length),
let selectedRange = self.textRange(from: start, to: end)
{
self.selectedTextRange = selectedRange
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,7 @@ struct WAErrorModel: Equatable {

class WritingAssistantContext: NSObject, ObservableObject {
var selectionKVO: NSKeyValueObservation?
var textView: UITextView?
var textField: UITextField?
var waTextInput: (any WATextInput)?

@Published var originalValue: String
@Published var displayedValue: String
Expand All @@ -59,7 +58,7 @@ class WritingAssistantContext: NSObject, ObservableObject {
var logKeyboardChanged = true

func updateInWAFlow(_ showKeyboard: Bool) {
self.isInWAFlow = showKeyboard && self.textView?.isFirstResponder ?? false
self.isInWAFlow = showKeyboard && self.waTextInput?.isFirstResponder ?? false
}

@Published var selection: WAMenu? = nil
Expand Down Expand Up @@ -190,10 +189,8 @@ class WritingAssistantContext: NSObject, ObservableObject {
}

func updateOriginalSelectedRange() {
if let textView = self.textView {
self.originalSelectedRange = textView.selectedRange
} else if let textField = self.textField {
self.originalSelectedRange = textField.selectedRange
if let textInput = self.waTextInput {
self.originalSelectedRange = textInput.selectedRange
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
@testable import FioriSwiftUICore
import SwiftUI
import XCTest

final class WATextInputModifierTests: XCTestCase {
var textValue: String = "Initial"

func testKeyboardPublisher() {
let menus = [[WAMenu(title: "Test")]]
let menuHandler: (WAMenu, String) async -> WAResult = { _, _ in .success("result") }
let feedbackHandler: (AIUserFeedbackVoteState, [String]) async -> WAFeedbackResult = { _, _ in .success }
let text = Binding<String>(get: { self.textValue },
set: { newValue in self.textValue = newValue })

let modifier = WATextInputModifier(
text: text,
menus: menus,
menuHandler: menuHandler,
feedbackOptions: [],
feedbackHandler: feedbackHandler
)

let expectation = XCTestExpectation(description: "Keyboard publisher emits")
let cancellable = modifier.keyboardPublisher.sink { value in
XCTAssertNotNil(value)
expectation.fulfill()
}
NotificationCenter.default.post(name: UIResponder.keyboardWillShowNotification, object: nil)
wait(for: [expectation], timeout: 1)
cancellable.cancel()
}
}

final class WritingAssistantContextTests: XCTestCase {
func testInitAndBasicProperties() {
let menu = WAMenu(title: "Test Menu")
let menus = [[menu]]
let menuHandler: (WAMenu, String) async -> WAResult = { _, _ in .success("result") }
let feedbackHandler: (AIUserFeedbackVoteState, [String]) async -> WAFeedbackResult = { _, _ in .success }
let context = WritingAssistantContext(
originalValue: "Hello",
menus: menus,
menuHandler: menuHandler,
feedbackOptions: ["A", "B"],
feedbackHandler: feedbackHandler
)

XCTAssertEqual(context.originalValue, "Hello")
XCTAssertEqual(context.displayedValue, "Hello")
XCTAssertEqual(context.menus.count, 1)
XCTAssertFalse(context.inProgress)
XCTAssertFalse(context.isPresented)
XCTAssertFalse(context.showCancelAlert)
XCTAssertFalse(context.showFeedbackSuccessToast)
XCTAssertNil(context.customDestination)
XCTAssertTrue(context.textIsChanged == false)
}

func testSetError() {
let menuHandler: (WAMenu, String) async -> WAResult = { _, _ in .success("result") }
let context = WritingAssistantContext(
originalValue: "",
menus: [],
menuHandler: menuHandler,
feedbackOptions: [],
feedbackHandler: nil
)
let error = WAError(title: "Test error")
context.setError(error, isFeedbackError: true, isInMenuView: false)
XCTAssertTrue(context.errorModel.isFeedbackError)
XCTAssertFalse(context.errorModel.isInMenuView)
}

func testRevertAndForwardValue() {
let menuHandler: (WAMenu, String) async -> WAResult = { _, _ in .success("result") }
let context = WritingAssistantContext(
originalValue: "A",
menus: [],
menuHandler: menuHandler,
feedbackOptions: [],
feedbackHandler: nil
)
context.addNewValue("B", for: nil)
context.addNewValue("C", for: nil)
XCTAssertEqual(context.displayedValue, "C")
context.revertToPreviousValue()
XCTAssertEqual(context.displayedValue, "B")
context.revertToPreviousValue()
XCTAssertEqual(context.displayedValue, "A")
context.forwardToNextValue()
XCTAssertEqual(context.displayedValue, "B")
}
}
Loading