diff --git a/app/src/keyboards/java/be/scri/helpers/BackspaceHandler.kt b/app/src/keyboards/java/be/scri/helpers/BackspaceHandler.kt index e5eb546a..a609b30f 100644 --- a/app/src/keyboards/java/be/scri/helpers/BackspaceHandler.kt +++ b/app/src/keyboards/java/be/scri/helpers/BackspaceHandler.kt @@ -24,6 +24,92 @@ class BackspaceHandler( */ var isDeleteRepeating: Boolean = false + /** + * Stack to store deleted text blocks for undo restoration. + */ + private val deletedChunksStack = java.util.Stack() + + /** + * Timestamp of the last programmatic swipe operation (delete or restore). + */ + var lastSwipeOperationTime: Long = 0 + + /** + * Clear all elements in the undo stack. + */ + fun clearUndoStack() { + deletedChunksStack.clear() + } + + /** + * Deletes the character or word before the cursor and pushes it onto the stack. + */ + fun performSwipeDelete() { + lastSwipeOperationTime = System.currentTimeMillis() + val inputConnection = ime.currentInputConnection ?: return + val textBeforeCursor = inputConnection.getTextBeforeCursor(MAX_TEXT_LENGTH, 0)?.toString() ?: "" + if (textBeforeCursor.isEmpty()) { + return + } + + val isWordByWordEnabled = getIsWordByWordDeletionEnabled(ime.applicationContext, ime.language) + val deletionLength = + if (isWordByWordEnabled) { + getWordDeletionLength(textBeforeCursor) + } else { + 1 + } + + if (deletionLength > 0) { + val chunkToDelete = textBeforeCursor.takeLast(deletionLength) + deletedChunksStack.push(chunkToDelete) + + inputConnection.deleteSurroundingText(deletionLength, 0) + } + } + + /** + * Pops the last deleted chunk from the stack and restores it. + */ + fun performSwipeRestore() { + lastSwipeOperationTime = System.currentTimeMillis() + val inputConnection = ime.currentInputConnection ?: return + if (!deletedChunksStack.isEmpty()) { + val chunkToRestore = deletedChunksStack.pop() + + inputConnection.commitText(chunkToRestore, 1) + } + } + + /** + * Helper to compute deletion length for the word before the cursor. + */ + private fun getWordDeletionLength(text: String): Int { + var deletionLength = 0 + var index = text.length - 1 + + // Skip any whitespace. + while (index >= 0 && text[index].isWhitespace()) { + deletionLength++ + index-- + } + + if (index < 0) { + return deletionLength + } + + // Delete word characters or a single special character + if (isWordCharacter(text[index])) { + while (index >= 0 && isWordCharacter(text[index])) { + deletionLength++ + index-- + } + } else { + deletionLength++ + } + return deletionLength + } + /** * Handles the logic for the Delete/Backspace key. It deletes characters from either * the main input field or the command bar, depending on the context. diff --git a/app/src/keyboards/java/be/scri/helpers/ui/HintUtils.kt b/app/src/keyboards/java/be/scri/helpers/ui/HintUtils.kt index 5210e371..0f0330a6 100644 --- a/app/src/keyboards/java/be/scri/helpers/ui/HintUtils.kt +++ b/app/src/keyboards/java/be/scri/helpers/ui/HintUtils.kt @@ -34,6 +34,8 @@ object HintUtils { putBoolean("hint_shown_main", false) putBoolean("hint_shown_settings", false) putBoolean("hint_shown_about", false) + putBoolean("swipe_tutorial_shown", false) + putBoolean("swipe_tutorial_interactive_shown", false) apply() } } diff --git a/app/src/keyboards/java/be/scri/services/GeneralKeyboardIME.kt b/app/src/keyboards/java/be/scri/services/GeneralKeyboardIME.kt index 57c7e1ca..aa3ef124 100644 --- a/app/src/keyboards/java/be/scri/services/GeneralKeyboardIME.kt +++ b/app/src/keyboards/java/be/scri/services/GeneralKeyboardIME.kt @@ -115,6 +115,15 @@ abstract class GeneralKeyboardIME( internal val binding: InputMethodViewBinding get() = uiManager.binding + private enum class SwipeTutorialState { + NOT_ACTIVE, + SWIPE_LEFT_STEP, + SWIPE_RIGHT_STEP, + COMPLETED, + } + + private var swipeTutorialState = SwipeTutorialState.NOT_ACTIVE + // MARK: State Variables internal var isSingularAndPlural: Boolean = false @@ -306,6 +315,7 @@ abstract class GeneralKeyboardIME( restarting: Boolean, ) { super.onStartInput(attribute, restarting) + backspaceHandler.clearUndoStack() inputTypeClass = attribute!!.inputType and TYPE_MASK_CLASS enterKeyType = attribute.imeOptions and (IME_MASK_ACTION or IME_FLAG_NO_ENTER_ACTION) currentEnterKeyType = enterKeyType @@ -406,6 +416,73 @@ abstract class GeneralKeyboardIME( } keyboardView?.invalidateAllKeys() } + + // Show swipe delete & undo gesture tutorial overlay if not already shown + initSwipeTutorial() + } + + private fun initSwipeTutorial() { + val sharedPref = applicationContext.getSharedPreferences("app_preferences", MODE_PRIVATE) + val tutorialShown = sharedPref.getBoolean("swipe_tutorial_interactive_shown", false) + if (!tutorialShown) { + val ic = currentInputConnection + if (ic != null) { + ic.commitText("Scribe ", 1) + } + binding.swipeTutorialOverlay.visibility = View.VISIBLE + binding.swipeTutorialClose.setOnClickListener { + dismissSwipeTutorial() + } + setSwipeTutorialState(SwipeTutorialState.SWIPE_LEFT_STEP) + } else { + binding.swipeTutorialOverlay.visibility = View.GONE + swipeTutorialState = SwipeTutorialState.NOT_ACTIVE + } + } + + private fun setSwipeTutorialState(state: SwipeTutorialState) { + swipeTutorialState = state + when (state) { + SwipeTutorialState.SWIPE_LEFT_STEP -> { + binding.swipeTutorialOverlay.visibility = View.VISIBLE + binding.swipeTutorialProgress.text = "Step 1 of 2" + binding.swipeTutorialIcon.setImageResource(R.drawable.ic_swipe_left) + binding.swipeTutorialTitle.text = "Swipe Left to Delete" + binding.swipeTutorialDesc.text = "Swipe left anywhere on the keyboard to delete the last word." + binding.swipeTutorialStatus.text = "Practice: Swipe left on the keyboard below to delete 'Scribe'!" + binding.swipeTutorialStatus.setTextColor(ContextCompat.getColor(applicationContext, R.color.theme_scribe_blue)) + binding.swipeTutorialClose.text = "Skip" + } + SwipeTutorialState.SWIPE_RIGHT_STEP -> { + binding.swipeTutorialOverlay.visibility = View.VISIBLE + binding.swipeTutorialProgress.text = "Step 2 of 2" + binding.swipeTutorialIcon.setImageResource(R.drawable.ic_swipe_right) + binding.swipeTutorialTitle.text = "Swipe Right to Restore" + binding.swipeTutorialDesc.text = "Swipe right anywhere on the keyboard to restore/undo deletion." + binding.swipeTutorialStatus.text = "Practice: Swipe right now to restore the word!" + binding.swipeTutorialStatus.setTextColor(ContextCompat.getColor(applicationContext, R.color.theme_scribe_blue)) + binding.swipeTutorialClose.text = "Skip" + } + SwipeTutorialState.COMPLETED -> { + binding.swipeTutorialOverlay.visibility = View.VISIBLE + binding.swipeTutorialProgress.text = "Tutorial Completed!" + binding.swipeTutorialIcon.setImageResource(R.drawable.ic_swipe_success) + binding.swipeTutorialTitle.text = "You're All Set!" + binding.swipeTutorialDesc.text = "You can swipe left to delete and swipe right to restore at any time." + binding.swipeTutorialStatus.text = "Success! Tap 'Got it!' to start typing." + binding.swipeTutorialStatus.setTextColor(android.graphics.Color.parseColor("#10B981")) + binding.swipeTutorialClose.text = "Got it!" + } + SwipeTutorialState.NOT_ACTIVE -> { + binding.swipeTutorialOverlay.visibility = View.GONE + } + } + } + + private fun dismissSwipeTutorial() { + val sharedPref = applicationContext.getSharedPreferences("app_preferences", MODE_PRIVATE) + sharedPref.edit().putBoolean("swipe_tutorial_interactive_shown", true).apply() + setSwipeTutorialState(SwipeTutorialState.NOT_ACTIVE) } /** @@ -416,9 +493,26 @@ abstract class GeneralKeyboardIME( */ override fun onFinishInputView(finishingInput: Boolean) { super.onFinishInputView(finishingInput) + backspaceHandler.clearUndoStack() moveToIdleState() } + override fun onUpdateSelection( + oldSelStart: Int, + oldSelEnd: Int, + newSelStart: Int, + newSelEnd: Int, + candidatesStart: Int, + candidatesEnd: Int, + ) { + super.onUpdateSelection(oldSelStart, oldSelEnd, newSelStart, newSelEnd, candidatesStart, candidatesEnd) + // If the selection/cursor changed manually (not from our programmatic swipe gestures within 500ms), clear the stack + val timeSinceLastSwipe = System.currentTimeMillis() - backspaceHandler.lastSwipeOperationTime + if (timeSinceLastSwipe > 500) { + backspaceHandler.clearUndoStack() + } + } + // MARK: OnKeyboardActionListener /** @@ -482,13 +576,35 @@ abstract class GeneralKeyboardIME( override fun moveCursorRight() = moveCursor(true) override fun onText(text: String) { + backspaceHandler.clearUndoStack() currentInputConnection?.commitText(text, 0) } + override fun onSwipeLeft() { + if (swipeTutorialState == SwipeTutorialState.SWIPE_LEFT_STEP) { + backspaceHandler.performSwipeDelete() + setSwipeTutorialState(SwipeTutorialState.SWIPE_RIGHT_STEP) + } else if (swipeTutorialState == SwipeTutorialState.NOT_ACTIVE) { + backspaceHandler.performSwipeDelete() + } + } + + override fun onSwipeRight() { + if (swipeTutorialState == SwipeTutorialState.SWIPE_RIGHT_STEP) { + backspaceHandler.performSwipeRestore() + setSwipeTutorialState(SwipeTutorialState.COMPLETED) + } else if (swipeTutorialState == SwipeTutorialState.NOT_ACTIVE) { + backspaceHandler.performSwipeRestore() + } + } + /** * Handles key input from the keyboard. Delegates to specific handlers based on the key code. */ override fun onKey(code: Int) { + if (code != KeyboardBase.KEYCODE_DELETE) { + backspaceHandler.clearUndoStack() + } val inputConnection = currentInputConnection if (inputConnection != null) { when (code) { @@ -734,11 +850,13 @@ abstract class GeneralKeyboardIME( override fun onEmojiSelected(emoji: String) { if (emoji.isNotEmpty()) { + backspaceHandler.clearUndoStack() insertEmoji(emoji, currentInputConnection, emojiKeywords, emojiMaxKeywordLength) } } override fun onSuggestionClicked(suggestion: String) { + backspaceHandler.clearUndoStack() currentInputConnection?.commitText("$suggestion ", 1) moveToIdleState() } @@ -768,6 +886,7 @@ abstract class GeneralKeyboardIME( } override fun commitText(text: String) { + backspaceHandler.clearUndoStack() if (currentState == ScribeState.SELECT_VERB_CONJUNCTION) { val label = text.trim() val conjugateIndex = getValidatedConjugateIndex() diff --git a/app/src/main/java/be/scri/helpers/PreferencesHelper.kt b/app/src/main/java/be/scri/helpers/PreferencesHelper.kt index 1427bb8a..569df994 100644 --- a/app/src/main/java/be/scri/helpers/PreferencesHelper.kt +++ b/app/src/main/java/be/scri/helpers/PreferencesHelper.kt @@ -605,6 +605,8 @@ object PreferencesHelper { putBoolean("hint_shown_main", false) putBoolean("hint_shown_settings", false) putBoolean("hint_shown_about", false) + putBoolean("swipe_tutorial_shown", false) + putBoolean("swipe_tutorial_interactive_shown", false) apply() } } diff --git a/app/src/main/java/be/scri/views/KeyboardView.kt b/app/src/main/java/be/scri/views/KeyboardView.kt index fd9372cf..cef8069f 100644 --- a/app/src/main/java/be/scri/views/KeyboardView.kt +++ b/app/src/main/java/be/scri/views/KeyboardView.kt @@ -142,6 +142,16 @@ class KeyboardView * that don't need delete repeat tracking. */ fun setDeleteRepeating(isRepeating: Boolean) {} + + /** + * Triggered when the user swipes left across the keyboard. + */ + fun onSwipeLeft() {} + + /** + * Triggered when the user swipes right across the keyboard. + */ + fun onSwipeRight() {} } var mKeyboard: KeyboardBase? = null @@ -208,6 +218,12 @@ class KeyboardView private val mSpaceMoveThreshold: Int private var ignoreTouches = false + private var mSwipeActive = false + private var mStartX = 0f + private var mStartY = 0f + private var mLastSwipeX = 0f + private var mSwipeAccumulatedDelta = 0f + var mKeyLabel: String = "He" var mKeyLabelFPS: String = "FPS" @@ -329,6 +345,8 @@ class KeyboardView private const val KEY_HEIGHT = 100 private var leftShiftForLabel = 0 private const val LEFT_RIGHT_CONJUGATE_KEY_EXTRA_HEIGHT = 340 + private const val SWIPE_THRESHOLD = 80f + private const val SWIPE_STEP_PX = 45f } var setPreview: Boolean = true @@ -1458,6 +1476,14 @@ class KeyboardView override fun commitPeriodAfterSpace() { mOnKeyboardActionListener!!.commitPeriodAfterSpace() } + + override fun onSwipeLeft() { + mOnKeyboardActionListener?.onSwipeLeft() + } + + override fun onSwipeRight() { + mOnKeyboardActionListener?.onSwipeRight() + } } val keyboard = @@ -1667,8 +1693,9 @@ class KeyboardView val eventTime = me.eventTime val keyIndex = getPressedKeyIndex(touchX, touchY) - // Ignore all motion events until a DOWN. - if (mAbortKey && action != MotionEvent.ACTION_DOWN && action != MotionEvent.ACTION_CANCEL) { + // Ignore all motion events until a DOWN, unless a swipe gesture is active. + val isIgnoredAction = action != MotionEvent.ACTION_DOWN && action != MotionEvent.ACTION_CANCEL + if (mAbortKey && !mSwipeActive && isIgnoredAction) { handled = true } @@ -1702,6 +1729,11 @@ class KeyboardView handled = true } MotionEvent.ACTION_DOWN -> { + mStartX = me.x + mStartY = me.y + mLastSwipeX = me.x + mSwipeAccumulatedDelta = 0f + mSwipeActive = false mAbortKey = false mLastCodeX = touchX mLastCodeY = touchY @@ -1749,6 +1781,46 @@ class KeyboardView } } MotionEvent.ACTION_MOVE -> { + val dx = me.x - mStartX + val dy = me.y - mStartY + if (!mSwipeActive) { + if (Math.abs(dx) > SWIPE_THRESHOLD && Math.abs(dx) > Math.abs(dy) * 2) { + mSwipeActive = true + mHandler?.removeMessages(MSG_LONGPRESS) + mAbortKey = true + showPreview(NOT_A_KEY) + if (mRepeatKeyIndex != NOT_A_KEY) { + mHandler?.removeMessages(MSG_REPEAT) + if (mKeys.getOrNull(mRepeatKeyIndex)?.code == KEYCODE_DELETE) { + mOnKeyboardActionListener?.setDeleteRepeating(false) + } + mRepeatKeyIndex = NOT_A_KEY + } + mLastSwipeX = me.x + } + } + + if (mSwipeActive) { + val deltaX = me.x - mLastSwipeX + mSwipeAccumulatedDelta += deltaX + mLastSwipeX = me.x + + while (mSwipeAccumulatedDelta <= -SWIPE_STEP_PX) { + mOnKeyboardActionListener?.onSwipeLeft() + vibrateIfNeeded() + mSwipeAccumulatedDelta += SWIPE_STEP_PX + } + + while (mSwipeAccumulatedDelta >= SWIPE_STEP_PX) { + mOnKeyboardActionListener?.onSwipeRight() + vibrateIfNeeded() + mSwipeAccumulatedDelta -= SWIPE_STEP_PX + } + + mLastMoveTime = eventTime + return true + } + var continueLongPress = false if (keyIndex != NOT_A_KEY) { if (mCurrentKey == NOT_A_KEY) { @@ -1802,6 +1874,20 @@ class KeyboardView } } MotionEvent.ACTION_UP -> { + if (mSwipeActive) { + mSwipeActive = false + mLastSpaceMoveX = 0 + removeMessages() + showPreview(NOT_A_KEY) + if (mRepeatKeyIndex != NOT_A_KEY && mKeys.getOrNull(mRepeatKeyIndex)?.code == KEYCODE_DELETE) { + mOnKeyboardActionListener?.setDeleteRepeating(false) + } + mRepeatKeyIndex = NOT_A_KEY + mOnKeyboardActionListener!!.onActionUp() + mIsLongPressingSpace = false + return true + } + mLastSpaceMoveX = 0 removeMessages() if (keyIndex == mCurrentKey) { @@ -1852,6 +1938,21 @@ class KeyboardView mIsLongPressingSpace = false } MotionEvent.ACTION_CANCEL -> { + if (mSwipeActive) { + mSwipeActive = false + mIsLongPressingSpace = false + mLastSpaceMoveX = 0 + if (mRepeatKeyIndex != NOT_A_KEY && mKeys.getOrNull(mRepeatKeyIndex)?.code == KEYCODE_DELETE) { + mOnKeyboardActionListener?.setDeleteRepeating(false) + } + removeMessages() + dismissPopupKeyboard() + mAbortKey = true + showPreview(NOT_A_KEY) + mRepeatKeyIndex = NOT_A_KEY + return true + } + mIsLongPressingSpace = false mLastSpaceMoveX = 0 // Reset delete repeating flag when action is cancelled. diff --git a/app/src/main/res/drawable/ic_swipe_left.xml b/app/src/main/res/drawable/ic_swipe_left.xml new file mode 100644 index 00000000..0d209ed8 --- /dev/null +++ b/app/src/main/res/drawable/ic_swipe_left.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_swipe_right.xml b/app/src/main/res/drawable/ic_swipe_right.xml new file mode 100644 index 00000000..4b16a70c --- /dev/null +++ b/app/src/main/res/drawable/ic_swipe_right.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_swipe_success.xml b/app/src/main/res/drawable/ic_swipe_success.xml new file mode 100644 index 00000000..578f714f --- /dev/null +++ b/app/src/main/res/drawable/ic_swipe_success.xml @@ -0,0 +1,22 @@ + + + + + + + diff --git a/app/src/main/res/layout/input_method_view.xml b/app/src/main/res/layout/input_method_view.xml index f79cf813..ed50ae4b 100644 --- a/app/src/main/res/layout/input_method_view.xml +++ b/app/src/main/res/layout/input_method_view.xml @@ -707,4 +707,107 @@ app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" /> + + + + + + + + + + + + + + + + +