Skip to content

Commit

Permalink
Merge pull request #10633 from keymanapp/fix/ios/backspace-context-sy…
Browse files Browse the repository at this point in the history
…nc-lazy

fix(ios): long-held backspace handling
  • Loading branch information
jahorton authored Feb 7, 2024
2 parents 0699be3 + 346e6bb commit 562aa92
Show file tree
Hide file tree
Showing 2 changed files with 89 additions and 31 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ open class InputViewController: UIInputViewController, KeymanWebDelegate {
var isSystemKeyboard: Bool {
return _isSystemKeyboard;
}

// Constraints dependent upon the device's current rotation state.
// For now, should be mostly upon keymanWeb.view.heightAnchor.
var portraitConstraint: NSLayoutConstraint?
Expand All @@ -170,14 +170,14 @@ open class InputViewController: UIInputViewController, KeymanWebDelegate {
var expandedHeight: CGFloat {
return keymanWeb.keyboardHeight + activeTopBarHeight
}

public convenience init() {
// iOS will call this constructor to initialize the system keyboard app extension.
// It's safe and there will only ever be one active instance of this class within process scope.
// See https://developer.apple.com/library/archive/documentation/General/Conceptual/ExtensibilityPG/ExtensionOverview.html
self.init(forSystem: true)
}

public convenience init(forSystem: Bool) {
// In-app uses of the keyboard should call this constructor for simplicity, setting `forSystem`=`false`.
self.init(nibName: nil, bundle: nil)
Expand All @@ -189,7 +189,7 @@ open class InputViewController: UIInputViewController, KeymanWebDelegate {
_isSystemKeyboard = true
keymanWeb = KeymanWebViewController(storage: Storage.active)
super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil)

addChild(keymanWeb)
}

Expand All @@ -202,7 +202,7 @@ open class InputViewController: UIInputViewController, KeymanWebDelegate {

super.updateViewConstraints()
}

open override func loadView() {
let baseView = CustomInputView(frame: CGRect.zero, innerVC: keymanWeb, inputViewStyle: .keyboard)
baseView.backgroundColor = Colors.keyboardBackground
Expand Down Expand Up @@ -294,18 +294,61 @@ open class InputViewController: UIInputViewController, KeymanWebDelegate {
self.swallowBackspaceTextChange = false
return
}

let contextBeforeInput = textDocumentProxy.documentContextBeforeInput ?? ""
let selection = textDocumentProxy.selectedText ?? ""
let contextAfterInput = textDocumentProxy.documentContextAfterInput ?? ""
let context = "\(contextBeforeInput)\(selection)\(contextAfterInput)"
let bLength = contextBeforeInput.utf16.count
let sLength = selection.utf16.count
let bLength = contextBeforeInput.unicodeScalars.count
let sLength = selection.unicodeScalars.count
setContextState(text: context, range: NSMakeRange(bLength, sLength))
// Within the app, this is triggered after every keyboard input.
// We should NOT call .resetContext() here for this reason.
}


// Pre-condition: no text is selected. As this is currently only called by `insertText`
// below, this condition is met.
func sendContextUpdate() {
let preCaretContext = textDocumentProxy.documentContextBeforeInput ?? ""
let postCaretContext = textDocumentProxy.documentContextAfterInput ?? ""

let updater = { (_ before: String, _ after: String) -> Void in
let contextWindowText = "\(before)\(after)"

let range = NSRange(location: before.unicodeScalars.count, length: 0)

self.setContextState(text: contextWindowText, range: range)
}

updater(preCaretContext, postCaretContext)

if preCaretContext == "" {
/* The `textDocumentProxy` abstraction is documented (in passing) as involving
* inter-process communication. It is thus asynchronous. Despite all attempts to prod
* it, the context window is only ever updated if an attempt to make an actual *edit*
* outside of the context window occurs. The first such edit will NOT have
* available synchronous data... but a bit of an async wait will usually succeed in
* getting the update.
*
* 33ms seemed sufficient. Can we go lower? (~30 Hz rate)
* Success with 20ms. (50 Hz rate)
* 1ms is not sufficient, nor is 10ms. :(
*
* Note: these notes were taken via Simulator, not on a physical device;
* there's no guarantee (yet) that the times will be the same.
* But something refresh-rate related is a fairly reasonable assumption.
*/
DispatchQueue.main.asyncAfter(deadline: .now() + 0.033) { // 33 msec; contrast: held backspace - every 100 msec.

// Does NOT update after half a second if there's no context manipulation.
let preCaretAsyncContext = self.textDocumentProxy.documentContextBeforeInput ?? ""
let postCaretAsyncContext = self.textDocumentProxy.documentContextAfterInput ?? ""

updater(preCaretAsyncContext, postCaretAsyncContext)
}
}
}

func insertText(_ keymanWeb: KeymanWebViewController, numCharsToDelete: Int, newText: String) {
if keymanWeb.isSubKeysMenuVisible {
return
Expand All @@ -318,9 +361,9 @@ open class InputViewController: UIInputViewController, KeymanWebDelegate {
isInputClickSoundEnabled = false
perform(#selector(self.enableInputClickSound), with: nil, afterDelay: 0.1)
}

var hasDeletedSelection = false

if let selected = textDocumentProxy.selectedText {
if selected.count > 0 {
textDocumentProxy.deleteBackward()
Expand All @@ -331,14 +374,16 @@ open class InputViewController: UIInputViewController, KeymanWebDelegate {
if numCharsToDelete <= 0 || hasDeletedSelection {
textDocumentProxy.insertText(newText)

sendContextUpdate()
return
}

if numCharsToDelete > 0 && textDocumentProxy.documentContextBeforeInput == nil {
textDocumentProxy.deleteBackward()
sendContextUpdate()
return
}

for _ in 0..<numCharsToDelete {
let oldContext = textDocumentProxy.documentContextBeforeInput ?? ""
textDocumentProxy.deleteBackward()
Expand Down Expand Up @@ -378,6 +423,8 @@ open class InputViewController: UIInputViewController, KeymanWebDelegate {
if !newText.isEmpty {
textDocumentProxy.insertText(newText)
}

sendContextUpdate()
}

func menuKeyUp(_ keymanWeb: KeymanWebViewController) {
Expand Down Expand Up @@ -411,7 +458,7 @@ open class InputViewController: UIInputViewController, KeymanWebDelegate {
func updateShowBannerSetting() {
keymanWeb.updateShowBannerSetting()
}

func updateSpacebarText() {
keymanWeb.updateSpacebarText()
}
Expand Down Expand Up @@ -452,7 +499,7 @@ open class InputViewController: UIInputViewController, KeymanWebDelegate {
// If 'isSystemKeyboard' is true, always show the top bar.
return isTopBarActive ? CGFloat(InputViewController.topBarHeight) : 0
}

public var kmwHeight: CGFloat {
return keymanWeb.keyboardHeight
}
Expand All @@ -468,12 +515,12 @@ open class InputViewController: UIInputViewController, KeymanWebDelegate {
self.updateViewConstraints()
fixLayout()
}

func fixLayout() {
view.setNeedsLayout()
view.layoutIfNeeded()
}

open override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)

Expand Down Expand Up @@ -507,7 +554,7 @@ open class InputViewController: UIInputViewController, KeymanWebDelegate {
keymanWeb.shouldReload = false
}
}

func setKeyboard(_ kb: InstallableKeyboard) throws {
try keymanWeb.setKeyboard(kb)
}
Expand All @@ -519,19 +566,19 @@ open class InputViewController: UIInputViewController, KeymanWebDelegate {
internal var shouldReload: Bool {
return keymanWeb.shouldReload
}

func registerLexicalModel(_ lm: InstallableLexicalModel) throws {
try keymanWeb.registerLexicalModel(lm)
}

func deregisterLexicalModel(_ lm: InstallableLexicalModel) {
keymanWeb.deregisterLexicalModel(lm)
}

func showHelpBubble() {
keymanWeb.showHelpBubble()
}

func showHelpBubble(afterDelay delay: TimeInterval) {
keymanWeb.showHelpBubble(afterDelay: delay)
}
Expand All @@ -543,53 +590,60 @@ open class InputViewController: UIInputViewController, KeymanWebDelegate {
os_log("%{public}s", log: KeymanEngineLogger.ui, type: .info, message)
SentryManager.breadcrumb(message)
}

func resetContext() {
keymanWeb.resetContext()
}

internal func setSentryState(enabled: Bool) {
keymanWeb.setSentryState(enabled: enabled)
}


/**
* Facilitates context synchronization with the KMW-app/webview side.
*
* The range's components should be SMP-aware, as the embedded engine
* will be expecting SMP-aware measurements. Swift's `.unicodeScalars`
* property on `String`s lines up best with this.
*/
func setContextState(text: String?, range: NSRange) {
// Check for any LTR or RTL marks at the context's start; if they exist, we should
// offset the selection range.
let characterOrderingChecks = [ "\u{200e}" /*LTR*/, "\u{202e}" /*RTL 1*/, "\u{200f}" /*RTL 2*/ ]
var offsetPrefix = false;

let context = text ?? ""

for codepoint in characterOrderingChecks {
if(context.hasPrefix(codepoint)) {
offsetPrefix = true;
break;
}
}

var selRange = range;
if(offsetPrefix) { // If we have a character ordering mark, offset range location to hide it.
selRange = NSRange(location: selRange.location - 1, length: selRange.length)
}

keymanWeb.setText(context)
if range.location != NSNotFound {
keymanWeb.setCursorRange(selRange)
}
}

func resetKeyboardState() {
keymanWeb.resetKeyboardState()
}

func endEditing(_ force: Bool) {
keymanWeb.view.endEditing(force)
}

func dismissKeyboardMenu() {
keymanWeb.dismissKeyboardMenu()
}

open func setBannerImage(to path: String) {
keymanWeb.setBannerImage(to: path)
}
Expand Down
6 changes: 5 additions & 1 deletion web/src/app/webview/src/contextManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,11 @@ class ContextHost extends Mock {
// changing the context's text.
setText(text: string): void {
this.text = text;
this.setSelection(this.text._kmwLength());
// Regardless of keyboard, we should check the SMP-aware length of the string.
// Our host app will not know whether or not the keyboard uses SMP chars,
// and we want a consistent interface for context synchronization between
// host app + app/webview KMW.
this.setSelection(this.text.kmwLength());
}
}

Expand Down

0 comments on commit 562aa92

Please sign in to comment.