diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiActivity.kt b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiActivity.kt index 8213f74e496c..31295b740a97 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiActivity.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiActivity.kt @@ -13,6 +13,9 @@ import android.media.AudioManager import android.net.Uri import android.os.Build import android.os.Bundle +import android.view.KeyEvent +import android.view.KeyboardShortcutGroup +import android.view.Menu import android.view.MenuItem import android.view.View import android.view.ViewGroup @@ -54,6 +57,8 @@ import com.ichi2.anki.preferences.sharedPrefs import com.ichi2.anki.snackbar.showSnackbar import com.ichi2.anki.workarounds.AppLoadedFromBackupWorkaround.showedActivityFailedScreen import com.ichi2.async.CollectionLoader +import com.ichi2.compat.CompatHelper +import com.ichi2.compat.CompatHelper.Companion.showKeyboardShortcutsDialog import com.ichi2.compat.customtabs.CustomTabActivityHelper import com.ichi2.compat.customtabs.CustomTabsFallback import com.ichi2.compat.customtabs.CustomTabsHelper @@ -102,6 +107,27 @@ open class AnkiActivity : AppCompatActivity, SimpleMessageDialogListener { } } + override fun onProvideKeyboardShortcuts( + data: MutableList, + menu: Menu?, + deviceId: Int + ) { + // Include all shortcuts here so that the keyboard shortcut dialog can be opened from anywhere in the app using the Alt+K shortcut, + // ensuring users have quick access to all available shortcuts regardless of the current screen. + val shortcutGroups = CompatHelper.compat.getAllShortcuts(this) + data.addAll(shortcutGroups) + super.onProvideKeyboardShortcuts(data, menu, deviceId) + } + + override fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean { + if (keyCode == KeyEvent.KEYCODE_K && event.isAltPressed) { + // Alt+K: Show keyboard shortcuts dialog + showKeyboardShortcutsDialog() + return true + } + return super.onKeyUp(keyCode, event) + } + override fun onStart() { super.onStart() customTabActivityHelper.bindCustomTabsService(this) diff --git a/AnkiDroid/src/main/java/com/ichi2/compat/Compat.kt b/AnkiDroid/src/main/java/com/ichi2/compat/Compat.kt index f0c0b783b3fa..4339aa819102 100644 --- a/AnkiDroid/src/main/java/com/ichi2/compat/Compat.kt +++ b/AnkiDroid/src/main/java/com/ichi2/compat/Compat.kt @@ -29,6 +29,7 @@ import android.graphics.Bitmap.CompressFormat import android.media.MediaRecorder import android.net.Uri import android.os.Bundle +import android.view.KeyboardShortcutGroup import android.view.View import androidx.annotation.CheckResult import androidx.core.view.OnReceiveContentListener @@ -217,6 +218,18 @@ interface Compat { onReceiveContentListener: OnReceiveContentListener ) + /** + * @see CompatHelper.showKeyboardShortcutsDialog + */ + fun showKeyboardShortcutsDialog( + activity: Activity + ) + + /** + * Get all keyboard shortcuts + */ + fun getAllShortcuts(activity: Activity): List + /** * Converts a locale to a 'two letter' code (ISO-639-1 + ISO 3166-1 alpha-2) * Locale("spa", "MEX", "001") => Locale("es", "MX", "001") diff --git a/AnkiDroid/src/main/java/com/ichi2/compat/CompatHelper.kt b/AnkiDroid/src/main/java/com/ichi2/compat/CompatHelper.kt index 2d09a4067590..8c1f00803c06 100644 --- a/AnkiDroid/src/main/java/com/ichi2/compat/CompatHelper.kt +++ b/AnkiDroid/src/main/java/com/ichi2/compat/CompatHelper.kt @@ -30,6 +30,7 @@ import android.view.KeyCharacterMap.deviceHasKey import android.view.KeyEvent.KEYCODE_PAGE_DOWN import android.view.KeyEvent.KEYCODE_PAGE_UP import androidx.core.content.ContextCompat +import com.ichi2.anki.AnkiActivity import com.ichi2.compat.CompatHelper.Companion.compat import java.io.Serializable @@ -193,5 +194,10 @@ class CompatHelper private constructor() { @ContextCompat.RegisterReceiverFlags flags: Int ) = ContextCompat.registerReceiver(this, receiver, filter, flags) + + /** + * Shows keyboard shortcuts dialog + */ + fun AnkiActivity.showKeyboardShortcutsDialog() = compat.showKeyboardShortcutsDialog(this) } } diff --git a/AnkiDroid/src/main/java/com/ichi2/compat/CompatV23.kt b/AnkiDroid/src/main/java/com/ichi2/compat/CompatV23.kt index 9112fbbf48a1..b2fb3844237a 100644 --- a/AnkiDroid/src/main/java/com/ichi2/compat/CompatV23.kt +++ b/AnkiDroid/src/main/java/com/ichi2/compat/CompatV23.kt @@ -30,6 +30,7 @@ import android.os.Bundle import android.os.Environment import android.os.Vibrator import android.provider.MediaStore +import android.view.KeyboardShortcutGroup import android.view.View import androidx.appcompat.widget.TooltipCompat import androidx.core.view.OnReceiveContentListener @@ -160,6 +161,17 @@ open class CompatV23 : Compat { // No implementation possible. } + // Until API 24 + override fun showKeyboardShortcutsDialog(activity: Activity) { + // No implementation available + } + + // Until API 24 + override fun getAllShortcuts(activity: Activity): List { + // No implementation available + return mutableListOf() + } + // Until API 26 @Throws(IOException::class) override fun deleteFile(file: File) { diff --git a/AnkiDroid/src/main/java/com/ichi2/compat/CompatV24.kt b/AnkiDroid/src/main/java/com/ichi2/compat/CompatV24.kt index 4edcf131c034..8c0417042826 100644 --- a/AnkiDroid/src/main/java/com/ichi2/compat/CompatV24.kt +++ b/AnkiDroid/src/main/java/com/ichi2/compat/CompatV24.kt @@ -18,11 +18,17 @@ package com.ichi2.compat import android.annotation.TargetApi import android.app.Activity +import android.content.Context import android.icu.util.ULocale +import android.view.KeyEvent +import android.view.KeyboardShortcutGroup +import android.view.KeyboardShortcutInfo import android.view.MotionEvent import android.view.View +import androidx.annotation.StringRes import androidx.core.view.OnReceiveContentListener import androidx.draganddrop.DropHelper +import com.ichi2.anki.R import com.ichi2.anki.common.utils.android.isRobolectric import com.ichi2.utils.ClipboardUtil.MEDIA_MIME_TYPES import timber.log.Timber @@ -60,6 +66,152 @@ open class CompatV24 : CompatV23(), Compat { ) } + override fun showKeyboardShortcutsDialog(activity: Activity) { + activity.requestShowKeyboardShortcuts() + } + + override fun getAllShortcuts(activity: Activity): List { + fun List.toShortcutGroup(@StringRes labelRes: Int): KeyboardShortcutGroup { + val shortcuts = this.map { it.toShortcutInfo(activity) } + val groupLabel = activity.getString(labelRes) + return KeyboardShortcutGroup(groupLabel, shortcuts) + } + + val generalShortcutGroup = listOf( + Shortcut("Alt+K", R.string.show_keyboard_shortcuts_dialog), + Shortcut("Ctrl+Z", R.string.undo) + ).toShortcutGroup(R.string.pref_cat_general) + + val deckPickerShortcutGroup = listOf( + Shortcut("A", R.string.menu_add_note), + Shortcut("Ctrl+B", R.string.backup_restore), + Shortcut("B", R.string.card_browser_context_menu), + Shortcut("Y", R.string.pref_cat_sync), + Shortcut("SLASH", R.string.deck_conf_cram_search), + Shortcut("S", R.string.study_deck), + Shortcut("T", R.string.open_statistics), + Shortcut("C", R.string.check_db), + Shortcut("D", R.string.new_deck), + Shortcut("F", R.string.new_dynamic_deck), + Shortcut("Shift+DEL", R.string.delete_deck_without_confirmation), + Shortcut("DEL", R.string.delete_deck_title), + Shortcut("R", R.string.rename_deck), + Shortcut("P", R.string.open_settings), + Shortcut("M", R.string.check_media), + Shortcut("Ctrl+E", R.string.export_collection), + Shortcut("Ctrl+Shift+I", R.string.menu_import), + Shortcut("Ctrl+Shift+N", R.string.model_browser_label) + ).toShortcutGroup(R.string.deck_picker_group) + + val noteEditorShortcutGroup = listOf( + Shortcut("Ctrl+ENTER", R.string.save_note), + Shortcut("Ctrl+D", R.string.deck_selection_dialog), + Shortcut("Ctrl+L", R.string.card_template_editor_group), + Shortcut("Ctrl+N", R.string.note_selection_spinner), + Shortcut("Ctrl+Shift+T", R.string.tags_dialog), + Shortcut("Ctrl+Shift+C", R.string.multimedia_editor_popup_cloze), + Shortcut("Ctrl+P", R.string.perform_preview) + ).toShortcutGroup(R.string.note_editor_group) + + val cardBrowserShortcutGroup = + listOf( + Shortcut("Ctrl+Shift+A", R.string.edit_tags_dialog), + Shortcut("Ctrl+A", R.string.card_browser_select_all), + Shortcut("Ctrl+Shift+E", R.string.export_cards), + Shortcut("Ctrl+E", R.string.menu_add_note), + Shortcut("E", R.string.cardeditor_title_edit_card), + Shortcut("Ctrl+D", R.string.card_browser_change_deck), + Shortcut("Ctrl+K", R.string.toggle_mark), + Shortcut("Ctrl+Alt+R", R.string.reschedule_cards), + Shortcut("DEL", R.string.delete_card_title), + Shortcut("Ctrl+Alt+N", R.string.reset_card_dialog_title), + Shortcut("Ctrl+Alt+T", R.string.toggle_cards_notes), + Shortcut("T", R.string.card_browser_search_by_tag), + Shortcut("Ctrl+Shift+S", R.string.reposition_cards), + Shortcut("Ctrl+Alt+S", R.string.card_browser_list_my_searches), + Shortcut("Ctrl+S", R.string.card_browser_list_my_searches_save), + Shortcut("Alt+S", R.string.card_browser_show_suspended), + Shortcut("Ctrl+Shift+J", R.string.toggle_bury_cards), + Shortcut("Ctrl+J", R.string.toggle_suspended_cards), + Shortcut("Ctrl+Shift+I", R.string.display_card_info), + Shortcut("Ctrl+O", R.string.show_order_dialog), + Shortcut("Ctrl+M", R.string.card_browser_show_marked), + Shortcut("ESCAPE", R.string.card_browser_select_none), + Shortcut("Ctrl+NUMPAD_1", R.string.gesture_flag_red), + Shortcut("Ctrl+NUMPAD_2", R.string.gesture_flag_orange), + Shortcut("Ctrl+NUMPAD_3", R.string.gesture_flag_green), + Shortcut("Ctrl+NUMPAD_4", R.string.gesture_flag_blue), + Shortcut("Ctrl+NUMPAD_5", R.string.gesture_flag_pink), + Shortcut("Ctrl+NUMPAD_6", R.string.gesture_flag_turquoise), + Shortcut("Ctrl+NUMPAD_7", R.string.gesture_flag_purple) + ).toShortcutGroup(R.string.card_browser_context_menu) + + val cardTemplateEditorShortcutGroup = listOf( + Shortcut("Ctrl+P", R.string.perform_preview), + Shortcut("Ctrl+NUMPAD_1", R.string.edit_front), + Shortcut("Ctrl+NUMPAD_2", R.string.edit_back), + Shortcut("Ctrl+NUMPAD_3", R.string.edit_styling), + Shortcut("Ctrl+S", R.string.save_note), + Shortcut("Ctrl+I", R.string.card_template_editor_insert_field), + Shortcut("Ctrl+A", R.string.add_card), + Shortcut("Ctrl+R", R.string.rename_card), + Shortcut("Ctrl+B", R.string.card_template_browser_appearance_title), + Shortcut("Ctrl+D", R.string.delete_card), + Shortcut("Ctrl+O", R.string.card_template_editor_deck_override), + Shortcut("Ctrl+M", R.string.copy_markdown) + ).toShortcutGroup(R.string.card_template_editor_group) + + val shortcutGroups = listOf( + generalShortcutGroup, + deckPickerShortcutGroup, + noteEditorShortcutGroup, + cardBrowserShortcutGroup, + cardTemplateEditorShortcutGroup + ) + + return shortcutGroups + } + + /** + * Data class representing a keyboard shortcut. + * + * @param shortcut The string representation of the keyboard shortcut (e.g., "Ctrl+Alt+S"). + * @param labelRes The string resource ID for the shortcut label. + */ + data class Shortcut(val shortcut: String, @StringRes val labelRes: Int) { + + /** + * Converts the shortcut string into a KeyboardShortcutInfo object. + * + * @param context The context used to retrieve the string label resource. + * @return A KeyboardShortcutInfo object representing the keyboard shortcut. + */ + fun toShortcutInfo(context: Context): KeyboardShortcutInfo { + val label: String = context.getString(labelRes) + val parts = shortcut.split("+") + val key = parts.last() + val keycode: Int = KeyEvent.keyCodeFromString(key) + val modifierFlags: Int = parts.dropLast(1).sumOf { getModifier(it) } + + return KeyboardShortcutInfo(label, keycode, modifierFlags) + } + + /** + * Maps a modifier string to its corresponding KeyEvent meta flag. + * + * @param modifier The modifier string (e.g., "Ctrl", "Alt", "Shift"). + * @return The corresponding KeyEvent meta flag. + */ + private fun getModifier(modifier: String): Int { + return when (modifier) { + "Ctrl" -> KeyEvent.META_CTRL_ON + "Alt" -> KeyEvent.META_ALT_ON + "Shift" -> KeyEvent.META_SHIFT_ON + else -> 0 + } + } + } + override val AXIS_RELATIVE_X: Int = MotionEvent.AXIS_RELATIVE_X override val AXIS_RELATIVE_Y: Int = MotionEvent.AXIS_RELATIVE_Y } diff --git a/AnkiDroid/src/main/res/values/02-strings.xml b/AnkiDroid/src/main/res/values/02-strings.xml index 8650fc5410f8..ba1c991cfd1a 100644 --- a/AnkiDroid/src/main/res/values/02-strings.xml +++ b/AnkiDroid/src/main/res/values/02-strings.xml @@ -429,4 +429,35 @@ opening the system text to speech settings fails"> Cannot Delete Card Type Deleting this card type will leave some notes without any cards. + + Show keyboard shortcuts dialog + Deck Picker + Study deck + Open statistics + Delete deck without confirmation + Open settings + Note Editor + Save note + Deck selection dialog + Note selection spinner + Tags dialog + Perform preview + Edit tags dialog + Export cards + Toggle mark + Reschedule cards + Reposition cards + Toggle bury cards + Toggle suspended cards + Display card info + Show order dialog + Card Template Editor + Edit front + Edit back + Edit styling + Add card + Rename card + Delete card + Copy markdown +