From 968b39af24e812025bb6e8feb9fdbda5aa302cf3 Mon Sep 17 00:00:00 2001 From: Aleksandras Kostarevas Date: Wed, 10 Jan 2024 18:42:20 +0200 Subject: [PATCH] Improve text selection logic * Shift + swiping space now lets you select text * Text editor uses more consistent selection logic instead of sending dpad and relying on apps to implement shift+dpad selection, which many of them don't. Dpad is still used for up/down --- .../inputmethod/latin/LatinIMELegacy.java | 20 ++-- .../latin/RichInputConnection.java | 76 +++++++++++++++ .../latin/inputlogic/InputLogic.java | 94 +++++++++++++++++++ .../org/futo/inputmethod/latin/uix/Action.kt | 3 + .../futo/inputmethod/latin/uix/UixManager.kt | 8 ++ .../latin/uix/actions/TextEditAction.kt | 57 ++++++++--- 6 files changed, 231 insertions(+), 27 deletions(-) diff --git a/java/src/org/futo/inputmethod/latin/LatinIMELegacy.java b/java/src/org/futo/inputmethod/latin/LatinIMELegacy.java index a6dfe0782..5e3e10a2b 100644 --- a/java/src/org/futo/inputmethod/latin/LatinIMELegacy.java +++ b/java/src/org/futo/inputmethod/latin/LatinIMELegacy.java @@ -1369,21 +1369,17 @@ public class LatinIMELegacy implements KeyboardActionListener, @Override public void onMovePointer(int steps) { - if (mInputLogic.mConnection.hasCursorPosition()) { - if (TextUtils.getLayoutDirectionFromLocale(mLocale) == View.LAYOUT_DIRECTION_RTL) - steps = -steps; + int shiftMode = mKeyboardSwitcher.getKeyboardShiftMode(); + boolean select = (shiftMode == WordComposer.CAPS_MODE_MANUAL_SHIFTED) || (shiftMode == WordComposer.CAPS_MODE_MANUAL_SHIFT_LOCKED); - steps = mInputLogic.mConnection.getUnicodeSteps(steps, true); - final int end = mInputLogic.mConnection.getExpectedSelectionEnd() + steps; - final int start = mInputLogic.mConnection.hasSelection() ? mInputLogic.mConnection.getExpectedSelectionStart() : end; + if(select) { + mInputLogic.disableRecapitalization(); + } - mInputLogic.finishInput(); - mInputLogic.mConnection.setSelection(start, end); + if(steps < 0) { + mInputLogic.cursorLeft(steps, false, select); } else { - for (; steps < 0; steps++) - mInputLogic.sendDownUpKeyEvent(KeyEvent.KEYCODE_DPAD_LEFT, 0); - for (; steps > 0; steps--) - mInputLogic.sendDownUpKeyEvent(KeyEvent.KEYCODE_DPAD_RIGHT, 0); + mInputLogic.cursorRight(steps, false, select); } } diff --git a/java/src/org/futo/inputmethod/latin/RichInputConnection.java b/java/src/org/futo/inputmethod/latin/RichInputConnection.java index 6e719a1dc..2db66cf0b 100644 --- a/java/src/org/futo/inputmethod/latin/RichInputConnection.java +++ b/java/src/org/futo/inputmethod/latin/RichInputConnection.java @@ -1081,4 +1081,80 @@ public final class RichInputConnection implements PrivateCommandPerformer { } return steps; } + + private int getCharacterClass(char c) { + if(Character.isLetter(c) || c == '_') return 1; + else if(Character.isDigit(c)) return 2; + else if(Character.isWhitespace(c)) return 3; + else return 4; + } + + /** + * Gets number of steps needed to step by a whole word + * @param direction direction to step, only sign is checked + * @param rightSidePointer whether or not right side is fixed + * @return number of characters to step + */ + public int getWordBoundarySteps(int direction, boolean rightSidePointer) { + int steps = 0; + if (direction < 0) { + CharSequence charsBeforeCursor = !rightSidePointer && hasSelection() ? + getSelectedText(0) : + getTextBeforeCursor(64, 0); + + if (charsBeforeCursor != null) { + int i = charsBeforeCursor.length() - 1; + + // Skip trailing whitespace + while (i >= 0 && Character.isWhitespace(charsBeforeCursor.charAt(i))) { + i--; + steps--; + } + + // Find the last word boundary + int charClass = getCharacterClass(charsBeforeCursor.charAt(i)); + while (i >= 0 && getCharacterClass(charsBeforeCursor.charAt(i)) == charClass) { + i--; + steps--; + } + + if(!rightSidePointer) { + // Skip initial whitespace + while (i >= 0 && Character.isWhitespace(charsBeforeCursor.charAt(i))) { + i--; + steps--; + } + } + } + } else if (direction > 0) { + CharSequence charsAfterCursor = rightSidePointer && hasSelection() ? + getSelectedText(0) : + getTextAfterCursor(64, 0); + if (charsAfterCursor != null) { + int i = 0; + + // Skip initial whitespace + while (i < charsAfterCursor.length() && Character.isWhitespace(charsAfterCursor.charAt(i))) { + i++; + steps++; + } + + // Find the first word boundary + int charClass = getCharacterClass(charsAfterCursor.charAt(i)); + while (i < charsAfterCursor.length() && getCharacterClass(charsAfterCursor.charAt(i)) == charClass) { + i++; + steps++; + } + + if(rightSidePointer) { + // Skip trailing whitespace + while (i < charsAfterCursor.length() && Character.isWhitespace(charsAfterCursor.charAt(i))) { + i++; + steps++; + } + } + } + } + return steps; + } } diff --git a/java/src/org/futo/inputmethod/latin/inputlogic/InputLogic.java b/java/src/org/futo/inputmethod/latin/inputlogic/InputLogic.java index 96a3fcf11..6e23e4a6e 100644 --- a/java/src/org/futo/inputmethod/latin/inputlogic/InputLogic.java +++ b/java/src/org/futo/inputmethod/latin/inputlogic/InputLogic.java @@ -2394,4 +2394,98 @@ public final class InputLogic { public int getComposingLength() { return mWordComposer.size(); } + + /** + * Which direction the selection should be expanded/contracted in via cursorLeft/Right methods + * If true, the right side of the selection (the end) is fixed and the left side (start) gets + * moved around, and vice versa. + */ + private boolean isRightSidePointer = true; + + /** + * Shifts the cursor/selection based on isRightSidePointer and the parameters + * @param steps How many characters to step over, or the direction if stepOverWords + * @param stepOverWords Whether to ignore the magnitude of steps and step over full words + * @param select Whether or not to start/continue selection + */ + private void cursorStep(int steps, boolean stepOverWords, boolean select) { + if(stepOverWords) { + steps = mConnection.getWordBoundarySteps(steps, isRightSidePointer); + } else { + steps = mConnection.getUnicodeSteps(steps, isRightSidePointer); + } + + int cursor = isRightSidePointer ? mConnection.getExpectedSelectionStart() : mConnection.getExpectedSelectionEnd(); + int start = mConnection.getExpectedSelectionStart(); + int end = mConnection.getExpectedSelectionEnd(); + if(isRightSidePointer) { + start += steps; + cursor += steps; + } else { + end += steps; + cursor += steps; + } + + if (!select) { + start = cursor; + end = cursor; + } + + mConnection.setSelection(start, end); + } + + /** + * Disables recapitalization + */ + public void disableRecapitalization() { + mRecapitalizeStatus.disable(); + } + + /** + * Shifts the cursor left by a number of characters + * @param steps How many characters to step over, or the direction if stepOverWords + * @param stepOverWords Whether to ignore the magnitude of steps and step over full words + * @param select Whether or not to start/continue selection + */ + public void cursorLeft(int steps, boolean stepOverWords, boolean select) { + steps = Math.abs(steps); + if(!mConnection.hasCursorPosition()) { + int meta = 0; + if(stepOverWords) meta = meta | KeyEvent.META_CTRL_ON; + if(select) meta = meta | KeyEvent.META_SHIFT_ON; + + for(int i=0; i Unit) { +fun ArrowKeys( + modifier: Modifier, + moveCursor: (direction: Direction) -> Unit +) { Row(modifier = modifier) { ActionKey( modifier = Modifier .weight(1.0f) .fillMaxHeight(), - onTrigger = { sendEvent(KeyEvent.KEYCODE_DPAD_LEFT) } + onTrigger = { moveCursor(Direction.Left) } ) { IconWithColor( iconId = R.drawable.arrow_left, @@ -151,7 +154,7 @@ fun ArrowKeys(modifier: Modifier, sendEvent: (Int) -> Unit) { modifier = Modifier .weight(1.0f) .fillMaxWidth(), - onTrigger = { sendEvent(KeyEvent.KEYCODE_DPAD_UP) } + onTrigger = { moveCursor(Direction.Up) } ) { IconWithColor( iconId = R.drawable.arrow_up, @@ -164,7 +167,7 @@ fun ArrowKeys(modifier: Modifier, sendEvent: (Int) -> Unit) { modifier = Modifier .weight(1.0f) .fillMaxWidth(), - onTrigger = { sendEvent(KeyEvent.KEYCODE_DPAD_DOWN) } + onTrigger = { moveCursor(Direction.Down) } ) { IconWithColor( iconId = R.drawable.arrow_down, @@ -177,7 +180,7 @@ fun ArrowKeys(modifier: Modifier, sendEvent: (Int) -> Unit) { modifier = Modifier .weight(1.0f) .fillMaxHeight(), - onTrigger = { sendEvent(KeyEvent.KEYCODE_DPAD_RIGHT) } + onTrigger = { moveCursor(Direction.Right) } ) { IconWithColor( iconId = R.drawable.arrow_right, @@ -299,16 +302,24 @@ fun SideKeys(modifier: Modifier, onEvent: (Int, Int) -> Unit, onCodePoint: (Int) } } +enum class Direction { + Left, + Right, + Up, + Down +} + @Composable -fun TextEditScreen(onCodePoint: (Int) -> Unit, onEvent: (Int, Int) -> Unit, keyboardShown: Boolean) { +fun TextEditScreen( + onCodePoint: (Int) -> Unit, + onEvent: (Int, Int) -> Unit, + moveCursor: (direction: Direction, ctrl: Boolean, shift: Boolean) -> Unit, + keyboardShown: Boolean +) { val shiftState = remember { mutableStateOf(false) } val ctrlState = remember { mutableStateOf(false) } - val metaState = 0 or - (if(shiftState.value) { KeyEvent.META_SHIFT_ON } else { 0 }) or - (if(ctrlState.value) { KeyEvent.META_CTRL_ON } else { 0 }) - - val sendEvent = { keycode: Int -> onEvent(keycode, metaState) } + val sendMoveCursor = { direction: Direction -> moveCursor(direction, ctrlState.value, shiftState.value) } Row(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier @@ -318,7 +329,7 @@ fun TextEditScreen(onCodePoint: (Int) -> Unit, onEvent: (Int, Int) -> Unit, keyb modifier = Modifier .weight(3.0f) .fillMaxWidth(), - sendEvent = sendEvent + moveCursor = sendMoveCursor ) CtrlShiftMetaKeys( modifier = Modifier @@ -354,7 +365,23 @@ val TextEditAction = Action( @Composable override fun WindowContents(keyboardShown: Boolean) { - TextEditScreen(onCodePoint = { a -> manager.sendCodePointEvent(a)}, onEvent = { a, b -> manager.sendKeyEvent(a, b) }, keyboardShown = keyboardShown) + TextEditScreen( + onCodePoint = { a -> manager.sendCodePointEvent(a)}, + onEvent = { a, b -> manager.sendKeyEvent(a, b) }, + moveCursor = { direction, ctrl, shift -> + val keyEventMetaState = 0 or + (if(shift) { KeyEvent.META_SHIFT_ON } else { 0 }) or + (if(ctrl) { KeyEvent.META_CTRL_ON } else { 0 }) + + when(direction) { + Direction.Left -> manager.cursorLeft(1, stepOverWords = ctrl, select = shift) + Direction.Right -> manager.cursorRight(1, stepOverWords = ctrl, select = shift) + Direction.Up -> manager.sendKeyEvent(KeyEvent.KEYCODE_DPAD_UP, keyEventMetaState) + Direction.Down -> manager.sendKeyEvent(KeyEvent.KEYCODE_DPAD_DOWN, keyEventMetaState) + } + }, + keyboardShown = keyboardShown + ) } override fun close() { @@ -367,13 +394,13 @@ val TextEditAction = Action( @Preview(showBackground = true) fun TextEditScreenPreview() { Surface(modifier = Modifier.height(256.dp)) { - TextEditScreen(onCodePoint = { }, onEvent = { _, _ -> }, keyboardShown = false) + TextEditScreen(onCodePoint = { }, onEvent = { _, _ -> }, moveCursor = { _, _, _ -> }, keyboardShown = false) } } @Composable @Preview(showBackground = true) fun TextEditScreenPreviewWithKb() { Surface(modifier = Modifier.height(256.dp)) { - TextEditScreen(onCodePoint = { }, onEvent = { _, _ -> }, keyboardShown = true) + TextEditScreen(onCodePoint = { }, onEvent = { _, _ -> }, moveCursor = { _, _, _ -> }, keyboardShown = true) } } \ No newline at end of file