From fce376939f9d03e34324e957354c9a59e04338f5 Mon Sep 17 00:00:00 2001 From: Carlos Garnacho Date: Wed, 20 Apr 2022 23:18:27 +0200 Subject: [PATCH] keyboard: Add 'delete' OSK key action This action will replace CLUTTER_KEY_Backspace emission for the OSK backspace key. Following the available mockups, implement different modes of operation: - Single tap deletes a single character - Long tap starts deleting characters one by one - Longer tap switches to word-by-word deletion This is made possible via the input method surrounding text, inspecting the string to look the previous char/word position backwards, and relies on IM focus providing enough context. Since deleting text and getting surrounding text are both async operations, we make one happen after the other, until the button is released. Part-of: --- js/misc/inputMethod.js | 4 ++ js/ui/keyboard.js | 98 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+) diff --git a/js/misc/inputMethod.js b/js/misc/inputMethod.js index 1abe20ec2..ab4617e07 100644 --- a/js/misc/inputMethod.js +++ b/js/misc/inputMethod.js @@ -311,4 +311,8 @@ var InputMethod = GObject.registerClass({ getSurroundingText() { return [this._surroundingText, this._surroundingTextCursor]; } + + hasPreedit() { + return this._preeditVisible && this._preeditStr !== '' && this._preeditStr !== null; + } }); diff --git a/js/ui/keyboard.js b/js/ui/keyboard.js index 31e70f5f4..6fd24d24b 100644 --- a/js/ui/keyboard.js +++ b/js/ui/keyboard.js @@ -25,6 +25,7 @@ const SHOW_KEYBOARD = 'screen-keyboard-enabled'; const KEY_SIZE = 2; const KEY_RELEASE_TIMEOUT = 50; +const BACKSPACE_WORD_DELETE_THRESHOLD = 50; var AspectContainer = GObject.registerClass( class AspectContainer extends St.Widget { @@ -1526,6 +1527,9 @@ var Keyboard = GObject.registerClass({ this._toggleEmoji(); } else if (key.action === 'modifier') { this._toggleModifier(key.keyval); + } else if (key.action === 'delete') { + this._toggleDelete(true); + this._toggleDelete(false); } else if (!this._longPressed && key.action === 'levelSwitch') { this._setActiveLayer(key.level); this._setLatched( @@ -1549,6 +1553,11 @@ var Keyboard = GObject.registerClass({ } } + if (key.action === 'delete') { + button.connect('long-press', + () => this._toggleDelete(true)); + } + if (key.action === 'modifier') { let modifierKeys = this._modifierKeys[key.keyval] || []; modifierKeys.push(button); @@ -1562,6 +1571,95 @@ var Keyboard = GObject.registerClass({ } } + _previousWordPosition(text, cursor) { + /* Skip word prior to cursor */ + let pos = Math.max(0, text.slice(0, cursor).search(/\s+\S+\s*$/)); + if (pos < 0) + return 0; + + /* Skip contiguous spaces */ + for (; pos >= 0; pos--) { + if (text.charAt(pos) !== ' ') + return GLib.utf8_strlen(text.slice(0, pos + 1), -1); + } + + return 0; + } + + _toggleDelete(enabled) { + if (this._deleteEnabled === enabled) + return; + + this._deleteEnabled = enabled; + this._timesDeleted = 0; + + if (!Main.inputMethod.currentFocus || Main.inputMethod.hasPreedit()) { + /* If there is no IM focus or are in the middle of preedit, + * fallback to keypresses */ + if (enabled) + this._keyboardController.keyvalPress(Clutter.KEY_BackSpace); + else + this._keyboardController.keyvalRelease(Clutter.KEY_BackSpace); + return; + } + + if (enabled) { + let func = (text, cursor) => { + if (cursor === 0) + return; + + let encoder = new TextEncoder(); + let decoder = new TextDecoder(); + + /* Find cursor/anchor position in characters */ + const cursorIdx = GLib.utf8_strlen(decoder.decode(encoder.encode( + text).slice(0, cursor)), -1); + const anchorIdx = this._timesDeleted < BACKSPACE_WORD_DELETE_THRESHOLD + ? cursorIdx - 1 + : this._previousWordPosition(text, cursor); + /* Now get offset from cursor */ + const offset = anchorIdx - cursorIdx; + + this._timesDeleted++; + Main.inputMethod.delete_surrounding(offset, Math.abs(offset)); + }; + + this._surroundingUpdateId = Main.inputMethod.connect( + 'surrounding-text-set', () => { + let [text, cursor] = Main.inputMethod.getSurroundingText(); + if (this._timesDeleted === 0) { + func(text, cursor); + } else { + if (this._surroundingUpdateTimeoutId > 0) { + GLib.source_remove(this._surroundingUpdateTimeoutId); + this._surroundingUpdateTimeoutId = 0; + } + this._surroundingUpdateTimeoutId = + GLib.timeout_add(GLib.PRIORITY_DEFAULT, KEY_RELEASE_TIMEOUT, () => { + func(text, cursor); + this._surroundingUpdateTimeoutId = 0; + return GLib.SOURCE_REMOVE; + }); + } + }); + + let [text, cursor] = Main.inputMethod.getSurroundingText(); + if (text) + func(text, cursor); + else + Main.inputMethod.request_surrounding(); + } else { + if (this._surroundingUpdateId > 0) { + Main.inputMethod.disconnect(this._surroundingUpdateId); + this._surroundingUpdateId = 0; + } + if (this._surroundingUpdateTimeoutId > 0) { + GLib.source_remove(this._surroundingUpdateTimeoutId); + this._surroundingUpdateTimeoutId = 0; + } + } + } + _setLatched(latched) { this._latched = latched; this._setCurrentLevelLatched(this._currentPage, this._latched);