diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/GestureProcessor.kt b/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/GestureProcessor.kt index 6391f51ae1fc..f25251890a18 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/GestureProcessor.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/GestureProcessor.kt @@ -16,6 +16,7 @@ package com.ichi2.anki.cardviewer import android.content.SharedPreferences +import com.ichi2.anki.reviewer.Binding import com.ichi2.anki.reviewer.GestureMapper import com.ichi2.anki.reviewer.MappableBinding @@ -54,8 +55,8 @@ class GestureProcessor(private val processor: ViewerCommand.CommandProcessor?) { val associatedCommands = HashMap() for (command in ViewerCommand.entries) { for (mappableBinding in MappableBinding.fromPreference(preferences, command)) { - if (mappableBinding.binding.isGesture) { - associatedCommands[mappableBinding.binding.gesture!!] = command + if (mappableBinding.binding is Binding.GestureInput) { + associatedCommands[mappableBinding.binding.gesture] = command } } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/reviewer/Binding.kt b/AnkiDroid/src/main/java/com/ichi2/anki/reviewer/Binding.kt index c610a0621d42..44850c405f70 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/reviewer/Binding.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/reviewer/Binding.kt @@ -18,80 +18,77 @@ package com.ichi2.anki.reviewer import android.content.Context import android.view.KeyEvent import androidx.annotation.VisibleForTesting +import com.afollestad.materialdialogs.utils.MDUtil.ifNotZero import com.ichi2.anki.cardviewer.Gesture import com.ichi2.utils.StringUtil +import com.ichi2.utils.lastIndexOfOrNull import timber.log.Timber -class Binding private constructor(val modifierKeys: ModifierKeys?, val keycode: Int?, val unicodeCharacter: Char?, val gesture: Gesture?) { - constructor(gesture: Gesture?) : this(null, null, null, gesture) +sealed interface Binding { + data class GestureInput(val gesture: Gesture) : Binding { + override fun toDisplayString(context: Context): String = gesture.toDisplayString(context) + override fun toString() = buildString { + append(GESTURE_PREFIX) + append(gesture) + } + } + + interface KeyBinding : Binding { + val modifierKeys: ModifierKeys + } - private fun getKeyCodePrefix(): String { - val keyPrefix = KEY_PREFIX.toString() + data class KeyCode(val keycode: Int, override val modifierKeys: ModifierKeys = ModifierKeys.none()) : KeyBinding { - if (keycode == null) { - return keyPrefix + private fun getKeyCodePrefix(): String = when { + KeyEvent.isGamepadButton(keycode) -> GAMEPAD_PREFIX + else -> KEY_PREFIX.toString() } - if (KeyEvent.isGamepadButton(keycode)) { - return GAMEPAD_PREFIX + override fun toDisplayString(context: Context): String = buildString { + append(getKeyCodePrefix()) + append(' ') + append(modifierKeys.toString()) + val keyCodeString = KeyEvent.keyCodeToString(keycode) + // replace "Button" as we use the gamepad icon + append(StringUtil.toTitleCase(keyCodeString.replace("KEYCODE_", "").replace("BUTTON_", "").replace('_', ' '))) } - return keyPrefix - } - fun toDisplayString(context: Context?): String { - val string = StringBuilder() - when { - keycode != null -> { - string.append(getKeyCodePrefix()) - string.append(' ') - string.append(modifierKeys!!.toString()) - val keyCodeString = KeyEvent.keyCodeToString(keycode) - // replace "Button" as we use the gamepad icon - string.append(StringUtil.toTitleCase(keyCodeString.replace("KEYCODE_", "").replace("BUTTON_", "").replace('_', ' '))) - } - unicodeCharacter != null -> { - string.append(KEY_PREFIX) - string.append(' ') - string.append(modifierKeys!!.toString()) - string.append(unicodeCharacter) - } - gesture != null -> { - string.append(gesture.toDisplayString(context!!)) - } + override fun toString() = buildString { + append(KEY_PREFIX) + append(modifierKeys.toString()) + append(keycode) } - return string.toString() } - override fun toString(): String { - val string = StringBuilder() - when { - keycode != null -> { - string.append(KEY_PREFIX) - string.append(modifierKeys!!.toString()) - string.append(keycode) - } - unicodeCharacter != null -> { - string.append(UNICODE_PREFIX) - string.append(modifierKeys!!.toString()) - string.append(unicodeCharacter) - } - gesture != null -> { - string.append(GESTURE_PREFIX) - string.append(gesture) - } + data class UnicodeCharacter(val unicodeCharacter: Char, override val modifierKeys: ModifierKeys = AppDefinedModifierKeys.allowShift()) : KeyBinding { + override fun toDisplayString(context: Context): String = buildString { + append(KEY_PREFIX) + append(' ') + append(modifierKeys.toString()) + append(unicodeCharacter) + } + + override fun toString(): String = buildString { + append(UNICODE_PREFIX) + append(modifierKeys.toString()) + append(unicodeCharacter) } - return string.toString() } - val isValid: Boolean get() = isKey || gesture != null - val isKeyCode: Boolean get() = keycode != null + data object UnknownBinding : Binding { + override fun toDisplayString(context: Context): String = "" + override fun toString(): String = "" + override val isValid: Boolean + get() = false + } + + fun toDisplayString(context: Context): String - val isKey: Boolean - get() = isKeyCode || unicodeCharacter != null + abstract override fun toString(): String - val isGesture: Boolean = gesture != null + val isValid get() = true - open class ModifierKeys internal constructor(private val shift: Boolean, private val ctrl: Boolean, private val alt: Boolean) { + open class ModifierKeys internal constructor(val shift: Boolean, val ctrl: Boolean, val alt: Boolean) { fun matches(event: KeyEvent): Boolean { // return false if Ctrl+1 is pressed and 1 is expected return shiftMatches(event) && ctrlMatches(event) && altMatches(event) @@ -109,18 +106,21 @@ class Binding private constructor(val modifierKeys: ModifierKeys?, val keycode: fun altMatches(altPressed: Boolean): Boolean = alt == altPressed - override fun toString(): String { - val string = StringBuilder() - if (ctrl) { - string.append("Ctrl+") - } - if (alt) { - string.append("Alt+") - } - if (shift) { - string.append("Shift+") + override fun toString() = buildString { + if (ctrl) append("Ctrl+") + if (alt) append("Alt+") + if (shift) append("Shift+") + } + + fun semiStructuralEquals(keys: ModifierKeys): Boolean { + if (this.alt != keys.alt || this.ctrl != keys.ctrl) { + return false } - return string.toString() + // shiftMatches may be overridden + return ( + this.shiftMatches(true) == keys.shiftMatches(true) || + this.shiftMatches(false) == keys.shiftMatches(false) + ) } companion object { @@ -138,13 +138,9 @@ class Binding private constructor(val modifierKeys: ModifierKeys?, val keycode: * @return The [ModifierKeys], and the remainder of the string */ fun parse(s: String): Pair { - var modifiers = none() - val plus = s.lastIndexOf("+") - if (plus == -1) { - return Pair(modifiers, s) - } - modifiers = fromString(s.substring(0, plus + 1)) - return Pair(modifiers, s.substring(plus + 1)) + val plusIndex = s.lastIndexOfOrNull('+') ?: return Pair(none(), s) + val modifiers = fromString(s.substring(0, plusIndex + 1)) + return Pair(modifiers, s.substring(plusIndex + 1)) } fun fromString(from: String): ModifierKeys = @@ -177,7 +173,6 @@ class Binding private constructor(val modifierKeys: ModifierKeys?, val keycode: /** * https://www.fileformat.info/info/unicode/char/2328/index.htm (Keyboard) - * This is not usable on API 21 or 22 */ const val KEY_PREFIX = '\u2328' @@ -185,79 +180,73 @@ class Binding private constructor(val modifierKeys: ModifierKeys?, val keycode: const val GESTURE_PREFIX = '\u235D' /** https://www.fileformat.info/info/unicode/char/2705/index.htm - checkmark (often used in URLs for unicode) - * Only used for serialisation. [.KEY_PREFIX] is used for display. + * Only used for serialisation. [KEY_PREFIX] is used for display. */ const val UNICODE_PREFIX = '\u2705' const val GAMEPAD_PREFIX = "🎮" - /** This returns multiple bindings due to the "default" implementation not knowing what the keycode for a button is */ - fun key(event: KeyEvent): List { + /** + * This returns multiple bindings due to the "default" implementation not knowing what the keycode for a button is + */ + fun possibleKeyBindings(event: KeyEvent): List { val modifiers = ModifierKeys(event.isShiftPressed, event.isCtrlPressed, event.isAltPressed) - val ret: MutableList = ArrayList() - val keyCode = event.keyCode - if (keyCode != 0) { - ret.add(keyCode(modifiers, keyCode)) - } + val ret: MutableList = ArrayList() + event.keyCode.ifNotZero { keyCode -> ret.add(keyCode(keyCode, modifiers)) } // passing in metaState: 0 means that Ctrl+1 returns '1' instead of '\0' // NOTE: We do not differentiate on upper/lower case via KeyEvent.META_CAPS_LOCK_ON - val unicodeChar = event.getUnicodeChar(event.metaState and (KeyEvent.META_SHIFT_ON or KeyEvent.META_NUM_LOCK_ON)) - if (unicodeChar != 0) { - try { - ret.add(unicode(modifiers, unicodeChar.toChar())) - } catch (e: Exception) { - Timber.w(e) + event.getUnicodeChar(event.metaState and (KeyEvent.META_SHIFT_ON or KeyEvent.META_NUM_LOCK_ON)) + .ifNotZero { unicodeChar -> + try { + ret.add(unicode(unicodeChar.toChar(), modifiers) as KeyBinding) + } catch (e: Exception) { + // very slight chance it returns unknown() + Timber.w(e) + } } - } return ret } - /** - * Specifies a unicode binding from an unknown input device - * See [AppDefinedModifierKeys] - */ - fun unicode(unicodeChar: Char): Binding = - unicode(AppDefinedModifierKeys.allowShift(), unicodeChar) - - fun unicode(modifierKeys: ModifierKeys?, unicodeChar: Char): Binding { - if (unicodeChar == FORBIDDEN_UNICODE_CHAR) return unknown() - return Binding(modifierKeys, null, unicodeChar, null) - } - - fun keyCode(keyCode: Int): Binding = keyCode(ModifierKeys.none(), keyCode) - - fun keyCode(modifiers: ModifierKeys?, keyCode: Int): Binding = - Binding(modifiers, keyCode, null, null) - - fun gesture(gesture: Gesture?): Binding = Binding(null, null, null, gesture) - - @VisibleForTesting - fun unknown(): Binding = Binding(ModifierKeys.none(), null, null, null) - fun fromString(from: String): Binding { - if (from.isEmpty()) return unknown() + if (from.isEmpty()) return UnknownBinding try { return when (from[0]) { - GESTURE_PREFIX -> { - gesture(Gesture.valueOf(from.substring(1))) - } + GESTURE_PREFIX -> GestureInput(Gesture.valueOf(from.substring(1))) UNICODE_PREFIX -> { - val parsed = ModifierKeys.parse(from.substring(1)) - unicode(parsed.first, parsed.second[0]) + val (modifierKeys, char) = ModifierKeys.parse(from.substring(1)) + UnicodeCharacter(char[0], modifierKeys) } KEY_PREFIX -> { - val parsed = ModifierKeys.parse(from.substring(1)) - val keyCode = parsed.second.toInt() - keyCode(parsed.first, keyCode) + val (modifierKeys, keyCodeAsString) = ModifierKeys.parse(from.substring(1)) + val keyCode = keyCodeAsString.toInt() + KeyCode(keyCode, modifierKeys) } - else -> unknown() + else -> UnknownBinding } } catch (ex: Exception) { Timber.w(ex) } - return unknown() + return UnknownBinding + } + + fun unicode(modifierKeys: ModifierKeys, unicodeChar: Char): Binding = unicode(unicodeChar, modifierKeys) + + /** + * Specifies a unicode binding from an unknown input device + * See [AppDefinedModifierKeys] + */ + fun unicode(unicodeChar: Char, modifierKeys: ModifierKeys = AppDefinedModifierKeys.allowShift()): Binding { + if (unicodeChar == FORBIDDEN_UNICODE_CHAR) return unknown() + return UnicodeCharacter(unicodeChar, modifierKeys) } + + fun keyCode(keyCode: Int, modifiers: ModifierKeys = ModifierKeys.none()) = KeyCode(keyCode, modifiers) + fun keyCode(modifiers: ModifierKeys, keyCode: Int) = KeyCode(keyCode, modifiers) + fun gesture(gesture: Gesture) = GestureInput(gesture) + + @VisibleForTesting + fun unknown() = UnknownBinding } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/reviewer/MappableBinding.kt b/AnkiDroid/src/main/java/com/ichi2/anki/reviewer/MappableBinding.kt index 6b9d229a9ace..aa91f797ca7e 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/reviewer/MappableBinding.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/reviewer/MappableBinding.kt @@ -22,6 +22,7 @@ import androidx.annotation.CheckResult import com.ichi2.anki.R import com.ichi2.anki.cardviewer.Gesture import com.ichi2.anki.cardviewer.ViewerCommand +import com.ichi2.anki.reviewer.Binding.* import timber.log.Timber import java.util.* import kotlin.collections.ArrayList @@ -32,51 +33,43 @@ import kotlin.collections.ArrayList * https://stackoverflow.com/questions/5453226/java-need-a-hash-map-where-one-supplies-a-function-to-do-the-hashing */ class MappableBinding(val binding: Binding, private val screen: Screen) { - val isKey: Boolean get() = binding.isKey + val isKey: Boolean get() = binding is KeyBinding override fun equals(other: Any?): Boolean { - if (this === other) { - return true + if (this === other) return true + if (other == null) return false + + val otherBinding = (other as MappableBinding).binding + val bindingEquals = when { + binding is KeyCode && otherBinding is KeyCode -> binding.keycode == otherBinding.keycode && modifierEquals(otherBinding) + binding is UnicodeCharacter && otherBinding is UnicodeCharacter -> binding.unicodeCharacter == otherBinding.unicodeCharacter && modifierEquals(otherBinding) + binding is GestureInput && otherBinding is GestureInput -> binding.gesture == otherBinding.gesture + else -> false } - if (other == null || javaClass != other.javaClass) { - return false - } - val otherMappableBinding = other as MappableBinding - - val otherBinding = otherMappableBinding.binding - val bindingEquals = binding.keycode == otherBinding.keycode && - binding.unicodeCharacter == otherBinding.unicodeCharacter && - binding.gesture == otherBinding.gesture && - modifierEquals(otherBinding.modifierKeys) - if (!bindingEquals) { return false } - return screen.screenEquals(otherMappableBinding.screen) + return screen.screenEquals(other.screen) } override fun hashCode(): Int { // don't include the modifierKeys or mSide - return Objects.hash(binding.keycode, binding.unicodeCharacter, binding.gesture, screen.prefix) + val bindingHash = when (binding) { + is KeyCode -> binding.keycode + is UnicodeCharacter -> binding.unicodeCharacter + is GestureInput -> binding.gesture + else -> 0 + } + return Objects.hash(bindingHash, screen.prefix) } - private fun modifierEquals(keys: Binding.ModifierKeys?): Boolean { + private fun modifierEquals(otherBinding: KeyBinding): Boolean { // equals allowing subclasses - val thisKeys = binding.modifierKeys - if (thisKeys === keys) { - return true - } - // one is null - return if (keys == null || thisKeys == null) { - false - } else { - (thisKeys.shiftMatches(true) == keys.shiftMatches(true) || thisKeys.shiftMatches(false) == keys.shiftMatches(false)) && - (thisKeys.ctrlMatches(true) == keys.ctrlMatches(true) || thisKeys.ctrlMatches(false) == keys.ctrlMatches(false)) && - (thisKeys.altMatches(true) == keys.altMatches(true) || thisKeys.altMatches(false) == keys.altMatches(false)) - } - - // Perf: Could get a slight improvement if we check that both instances are not subclasses + val keys = otherBinding.modifierKeys + val thisKeys = (this.binding as KeyBinding).modifierKeys + if (thisKeys === keys) return true + return thisKeys.semiStructuralEquals(keys) // allow subclasses to work - a subclass which overrides shiftMatches will return true on one of the tests } @@ -157,7 +150,7 @@ class MappableBinding(val binding: Binding, private val screen: Screen) { const val PREF_SEPARATOR = '|' @CheckResult - fun fromGesture(gesture: Gesture): MappableBinding = MappableBinding(Binding(gesture), Screen.Reviewer(CardSide.BOTH)) + fun fromGesture(gesture: Gesture): MappableBinding = MappableBinding(GestureInput(gesture), Screen.Reviewer(CardSide.BOTH)) @CheckResult fun List.toPreferenceString(): String = this.mapNotNull { it.toPreferenceString() } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/reviewer/PeripheralKeymap.kt b/AnkiDroid/src/main/java/com/ichi2/anki/reviewer/PeripheralKeymap.kt index 44336f46ade3..74aa027feb60 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/reviewer/PeripheralKeymap.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/reviewer/PeripheralKeymap.kt @@ -21,7 +21,7 @@ import android.view.KeyEvent import com.ichi2.anki.AnkiDroidApp import com.ichi2.anki.cardviewer.ViewerCommand import com.ichi2.anki.preferences.sharedPrefs -import com.ichi2.anki.reviewer.Binding.Companion.key +import com.ichi2.anki.reviewer.Binding.Companion.possibleKeyBindings import com.ichi2.anki.reviewer.CardSide.Companion.fromAnswer import com.ichi2.anki.reviewer.MappableBinding.Companion.fromPreference import java.util.HashMap @@ -72,7 +72,7 @@ class PeripheralKeymap(reviewerUi: ReviewerUi, commandProcessor: ViewerCommand.C @Suppress("UNUSED_PARAMETER") fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { var ret = false - val bindings = key(event!!) + val bindings = possibleKeyBindings(event!!) val side = fromAnswer(reviewerUI.isDisplayingAnswer) for (b in bindings) { val binding = MappableBinding(b, MappableBinding.Screen.Reviewer(side)) diff --git a/AnkiDroid/src/main/java/com/ichi2/ui/KeyPicker.kt b/AnkiDroid/src/main/java/com/ichi2/ui/KeyPicker.kt index d515dae2fb06..736ea31d643f 100644 --- a/AnkiDroid/src/main/java/com/ichi2/ui/KeyPicker.kt +++ b/AnkiDroid/src/main/java/com/ichi2/ui/KeyPicker.kt @@ -54,13 +54,9 @@ class KeyPicker(val rootLayout: View) { if (event.action != KeyEvent.ACTION_DOWN) return true // When accepting a keypress, we only want to find the keycode, not the unicode character. - val isValidKeyCode = isValidKeyCode - val maybeBinding = Binding.key(event).stream().filter { x -> x.isKeyCode && (isValidKeyCode == null || isValidKeyCode(x.keycode!!)) }.findFirst() - if (!maybeBinding.isPresent) { - return true - } - - val newBinding = maybeBinding.get() + val newBinding = Binding.possibleKeyBindings(event) + .filterIsInstance() + .firstOrNull { binding -> isValidKeyCode?.invoke(binding.keycode) != false } ?: return true Timber.d("Changed key to '%s'", newBinding) binding = newBinding text = newBinding.toDisplayString(context) diff --git a/AnkiDroid/src/main/java/com/ichi2/utils/StringUtil.kt b/AnkiDroid/src/main/java/com/ichi2/utils/StringUtil.kt index 67e2f4c48d17..647fdbe3e8a0 100644 --- a/AnkiDroid/src/main/java/com/ichi2/utils/StringUtil.kt +++ b/AnkiDroid/src/main/java/com/ichi2/utils/StringUtil.kt @@ -35,3 +35,9 @@ object StringUtil { fun String.trimToLength(maxLength: Int): String { return this.substring(0, min(this.length, maxLength)) } + +fun String.lastIndexOfOrNull(c: Char): Int? = + when (val index = this.lastIndexOf(c)) { + -1 -> null + else -> index + } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ReviewerNoParamTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ReviewerNoParamTest.kt index 846985cf9d18..a6afc515330d 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/ReviewerNoParamTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/ReviewerNoParamTest.kt @@ -28,6 +28,7 @@ import com.ichi2.anki.cardviewer.GestureProcessor import com.ichi2.anki.cardviewer.ViewerCommand import com.ichi2.anki.model.WhiteboardPenColor import com.ichi2.anki.preferences.sharedPrefs +import com.ichi2.anki.reviewer.Binding import com.ichi2.anki.reviewer.FullScreenMode import com.ichi2.anki.reviewer.FullScreenMode.Companion.setPreference import com.ichi2.anki.reviewer.MappableBinding @@ -286,7 +287,8 @@ class ReviewerNoParamTest : RobolectricTest() { val prefs = targetContext.sharedPrefs() for (command in ViewerCommand.entries) { for (mappableBinding in MappableBinding.fromPreference(prefs, command)) { - if (mappableBinding.binding.gesture in gestures) { + val gestureBinding = mappableBinding.binding as? Binding.GestureInput? ?: continue + if (gestureBinding.gesture in gestures) { command.removeBinding(prefs, mappableBinding) } } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/reviewer/BindingTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/reviewer/BindingTest.kt index b21d4408fe27..95cc5d9aab93 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/reviewer/BindingTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/reviewer/BindingTest.kt @@ -71,15 +71,15 @@ class BindingTest { private fun testModifierKeys(name: String, event: KFunction1, getValue: KFunction2) { fun testModifierResult(event: KFunction1, returnedFromMock: Boolean) { - val mock = mock { + val mock = mock { on(event) doReturn returnedFromMock } - val bindings = Binding.key(mock) + val bindings = Binding.possibleKeyBindings(mock) for (binding in bindings) { - assertThat("Should match when '$name:$returnedFromMock': ", getValue(binding.modifierKeys!!, true), equalTo(returnedFromMock)) - assertThat("Should match when '$name:${!returnedFromMock}': ", getValue(binding.modifierKeys!!, false), equalTo(!returnedFromMock)) + assertThat("Should match when '$name:$returnedFromMock': ", getValue(binding.modifierKeys, true), equalTo(returnedFromMock)) + assertThat("Should match when '$name:${!returnedFromMock}': ", getValue(binding.modifierKeys, false), equalTo(!returnedFromMock)) } } @@ -94,21 +94,21 @@ class BindingTest { fun allModifierKeys() = Binding.ModifierKeys(true, true, true) - fun unicodeCharacter(c: Char): Binding { + fun unicodeCharacter(c: Char): Binding.UnicodeCharacter { val mock = mock { on { getUnicodeChar(anyInt()) } doReturn c.code on { unicodeChar } doReturn c.code } - return Binding.key(mock).first { x -> x.unicodeCharacter != null } + return Binding.possibleKeyBindings(mock).filterIsInstance().first() } - fun keyCode(keyCode: Int): Binding { + fun keyCode(keyCode: Int): Binding.KeyCode { val mock = mock { on { getKeyCode() } doReturn keyCode } - return Binding.key(mock).first { x -> x.keycode != null } + return Binding.possibleKeyBindings(mock).filterIsInstance().first() } } }