diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiActivity.kt b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiActivity.kt index 8213f74e496c..955e17e2cf37 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiActivity.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiActivity.kt @@ -54,6 +54,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.CompatV24 +import com.ichi2.compat.ShortcutGroupProvider import com.ichi2.compat.customtabs.CustomTabActivityHelper import com.ichi2.compat.customtabs.CustomTabsFallback import com.ichi2.compat.customtabs.CustomTabsHelper @@ -66,7 +68,7 @@ import androidx.browser.customtabs.CustomTabsIntent.Builder as CustomTabsIntentB @UiThread @KotlinCleanup("set activityName") -open class AnkiActivity : AppCompatActivity, SimpleMessageDialogListener { +open class AnkiActivity : AppCompatActivity, SimpleMessageDialogListener, ShortcutGroupProvider { /** The name of the parent class (example: 'Reviewer') */ private val activityName: String @@ -587,6 +589,8 @@ open class AnkiActivity : AppCompatActivity, SimpleMessageDialogListener { return false } + override val shortcuts: CompatV24.ShortcutGroup? = null + companion object { const val DIALOG_FRAGMENT_TAG = "dialog" diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiFragment.kt index 1c34f059cd9b..b719fb378ff6 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiFragment.kt @@ -29,6 +29,7 @@ import androidx.appcompat.widget.Toolbar import androidx.fragment.app.DialogFragment import androidx.fragment.app.Fragment import com.ichi2.async.CollectionLoader +import com.ichi2.compat.CompatV24 import com.ichi2.libanki.Collection import com.ichi2.utils.increaseHorizontalPaddingOfOverflowMenuIcons import com.ichi2.utils.tintOverflowMenuIcons @@ -218,4 +219,9 @@ open class AnkiFragment(@LayoutRes layout: Int) : Fragment(layout) { requireActivity().finish() return false } + + /** + * Lists of shortcuts for this fragment, and the IdRes of the name of this shortcut group. + */ + open val shortcuts: CompatV24.ShortcutGroup? = null } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/CardBrowser.kt b/AnkiDroid/src/main/java/com/ichi2/anki/CardBrowser.kt index 186682d07eab..0cfe4e273463 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/CardBrowser.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/CardBrowser.kt @@ -29,6 +29,7 @@ import android.os.SystemClock import android.text.TextUtils import android.util.TypedValue import android.view.KeyEvent +import android.view.KeyboardShortcutGroup import android.view.LayoutInflater import android.view.Menu import android.view.MenuItem @@ -115,7 +116,10 @@ import com.ichi2.anki.utils.roundedTimeSpanUnformatted import com.ichi2.anki.widgets.DeckDropDownAdapter.SubtitleListener import com.ichi2.annotations.NeedsTest import com.ichi2.async.renderBrowserQA +import com.ichi2.compat.CompatHelper import com.ichi2.compat.CompatHelper.Companion.registerReceiverCompat +import com.ichi2.compat.CompatV24 +import com.ichi2.compat.CompatV24.Shortcut import com.ichi2.libanki.Card import com.ichi2.libanki.CardId import com.ichi2.libanki.ChangeManager @@ -147,6 +151,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import net.ankiweb.rsdroid.RustCleanup +import net.ankiweb.rsdroid.Translations import timber.log.Timber import kotlin.math.abs import kotlin.math.ceil @@ -631,6 +636,16 @@ open class CardBrowser : viewModel.setDeckId(deckId) } + override fun onProvideKeyboardShortcuts( + data: MutableList, + menu: Menu?, + deviceId: Int + ) { + val shortcutGroups = CompatHelper.compat.getShortcuts(this) + data.addAll(shortcutGroups) + super.onProvideKeyboardShortcuts(data, menu, deviceId) + } + override fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean { // This method is called even when the user is typing in the search text field. // So we must ensure that all shortcuts uses a modifier. @@ -673,6 +688,9 @@ open class CardBrowser : Timber.i("Ctrl+K: Toggle Mark") toggleMark() return true + } else if (event.isAltPressed) { + CompatHelper.compat.showKeyboardShortcutsDialog(this) + return true } } KeyEvent.KEYCODE_R -> { @@ -789,6 +807,12 @@ open class CardBrowser : } } } + + // Show snackbar only if a modifier key is pressed and the keyCode is an unmapped alphabet key + if ((event.isCtrlPressed || event.isAltPressed || event.isShiftPressed) && keyCode in KeyEvent.KEYCODE_A..KeyEvent.KEYCODE_Z) { + showSnackbar(R.string.show_shortcuts_message, Snackbar.LENGTH_SHORT) + } + return super.onKeyUp(keyCode, event) } @@ -2370,6 +2394,41 @@ open class CardBrowser : } } + override val shortcuts = CompatV24.ShortcutGroup( + listOf( + Shortcut("Ctrl+Shift+A", R.string.edit_tags_dialog), + Shortcut("Ctrl+A", R.string.card_browser_select_all), + Shortcut("Ctrl+Shift+E", Translations::exportingExport), + 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", Translations::browsingToggleMark), + Shortcut("Ctrl+Alt+R", Translations::browsingReschedule), + 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", Translations::actionsReposition), + 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", Translations::browsingToggleBury), + Shortcut("Ctrl+J", Translations::browsingToggleSuspend), + Shortcut("Ctrl+Shift+I", Translations::actionsCardInfo), + 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) + ), + R.string.card_browser_context_menu + ) + companion object { /** * Argument key to add on change deck dialog, diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt b/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt index ad4e362f1c40..37c25daa9ea8 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt @@ -27,6 +27,7 @@ import android.text.Editable import android.text.TextWatcher import android.view.ActionMode import android.view.KeyEvent +import android.view.KeyboardShortcutGroup import android.view.LayoutInflater import android.view.Menu import android.view.MenuInflater @@ -72,7 +73,10 @@ import com.ichi2.anki.snackbar.showSnackbar import com.ichi2.anki.utils.ext.isImageOcclusion import com.ichi2.anki.utils.postDelayed import com.ichi2.annotations.NeedsTest +import com.ichi2.compat.CompatHelper import com.ichi2.compat.CompatHelper.Companion.getSerializableCompat +import com.ichi2.compat.CompatV24 +import com.ichi2.compat.CompatV24.Shortcut import com.ichi2.libanki.Collection import com.ichi2.libanki.Note import com.ichi2.libanki.NoteId @@ -86,6 +90,7 @@ import com.ichi2.ui.FixedTextView import com.ichi2.utils.KotlinCleanup import com.ichi2.utils.copyToClipboard import com.ichi2.utils.jsonObjectIterable +import net.ankiweb.rsdroid.Translations import org.json.JSONArray import org.json.JSONException import org.json.JSONObject @@ -321,6 +326,16 @@ open class CardTemplateEditor : AnkiActivity(), DeckSelectionListener { invalidateOptionsMenu() } + override fun onProvideKeyboardShortcuts( + data: MutableList, + menu: Menu?, + deviceId: Int + ) { + val shortcutGroups = CompatHelper.compat.getShortcuts(this) + data.addAll(shortcutGroups) + super.onProvideKeyboardShortcuts(data, menu, deviceId) + } + override fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean { val currentFragment = currentFragment ?: return super.onKeyUp(keyCode, event) if (event.isCtrlPressed) { @@ -328,55 +343,77 @@ open class CardTemplateEditor : AnkiActivity(), DeckSelectionListener { KeyEvent.KEYCODE_P -> { Timber.i("Ctrl+P: Perform preview from keypress") currentFragment.performPreview() + return true } KeyEvent.KEYCODE_1 -> { Timber.i("Ctrl+1: Edit front template from keypress") currentFragment.bottomNavigation.selectedItemId = R.id.front_edit + return true } KeyEvent.KEYCODE_2 -> { Timber.i("Ctrl+2: Edit back template from keypress") currentFragment.bottomNavigation.selectedItemId = R.id.back_edit + return true } KeyEvent.KEYCODE_3 -> { Timber.i("Ctrl+3: Edit styling from keypress") currentFragment.bottomNavigation.selectedItemId = R.id.styling_edit + return true } KeyEvent.KEYCODE_S -> { Timber.i("Ctrl+S: Save note from keypress") currentFragment.saveNoteType() + return true } KeyEvent.KEYCODE_I -> { Timber.i("Ctrl+I: Insert field from keypress") currentFragment.showInsertFieldDialog() + return true } KeyEvent.KEYCODE_A -> { Timber.i("Ctrl+A: Add card template from keypress") currentFragment.addCardTemplate() + return true } KeyEvent.KEYCODE_R -> { Timber.i("Ctrl+R: Rename card from keypress") currentFragment.showRenameDialog() + return true } KeyEvent.KEYCODE_B -> { Timber.i("Ctrl+B: Open browser appearance from keypress") currentFragment.openBrowserAppearance() + return true } KeyEvent.KEYCODE_D -> { Timber.i("Ctrl+D: Delete card from keypress") currentFragment.deleteCardTemplate() + return true } KeyEvent.KEYCODE_O -> { Timber.i("Ctrl+O: Display deck override dialog from keypress") currentFragment.displayDeckOverrideDialog(currentFragment.tempModel) + return true } KeyEvent.KEYCODE_M -> { Timber.i("Ctrl+M: Copy markdown from keypress") currentFragment.copyMarkdownTemplateToClipboard() + return true } - else -> return super.onKeyUp(keyCode, event) } } - return true + + if (event.isAltPressed && keyCode == KeyEvent.KEYCODE_K) { + CompatHelper.compat.showKeyboardShortcutsDialog(this) + return true + } + + // Show snackbar only if a modifier key is pressed and the keyCode is an unmapped alphabet key + if ((event.isCtrlPressed || event.isAltPressed || event.isShiftPressed) && keyCode in KeyEvent.KEYCODE_A..KeyEvent.KEYCODE_Z) { + showSnackbar(R.string.show_shortcuts_message, Snackbar.LENGTH_SHORT) + } + + return super.onKeyUp(keyCode, event) } @get:VisibleForTesting @@ -421,6 +458,24 @@ open class CardTemplateEditor : AnkiActivity(), DeckSelectionListener { } } + override val shortcuts = CompatV24.ShortcutGroup( + listOf( + Shortcut("Ctrl+P", R.string.card_editor_preview_card), + Shortcut("Ctrl+NUMPAD_1", R.string.edit_question), + Shortcut("Ctrl+NUMPAD_2", R.string.edit_answer), + Shortcut("Ctrl+NUMPAD_3", R.string.edit_styling), + Shortcut("Ctrl+S", R.string.save), + Shortcut("Ctrl+I", R.string.card_template_editor_insert_field), + Shortcut("Ctrl+A", Translations::cardTemplatesAddCardType), + Shortcut("Ctrl+R", Translations::cardTemplatesRenameCardType), + Shortcut("Ctrl+B", R.string.edit_browser_appearance), + Shortcut("Ctrl+D", Translations::cardTemplatesRemoveCardType), + Shortcut("Ctrl+O", Translations::cardTemplatesDeckOverride), + Shortcut("Ctrl+M", R.string.copy_the_template) + ), + R.string.card_template_editor_group + ) + class CardTemplateFragment : Fragment() { private val refreshFragmentHandler = Handler(Looper.getMainLooper()) private var currentEditorTitle: FixedTextView? = null diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt index bf0daacf9c71..a2933bb1a115 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt @@ -40,6 +40,7 @@ import android.os.Bundle import android.os.Message import android.util.TypedValue import android.view.KeyEvent +import android.view.KeyboardShortcutGroup import android.view.Menu import android.view.MenuItem import android.view.View @@ -156,6 +157,8 @@ import com.ichi2.compat.CompatHelper import com.ichi2.compat.CompatHelper.Companion.getSerializableCompat import com.ichi2.compat.CompatHelper.Companion.registerReceiverCompat import com.ichi2.compat.CompatHelper.Companion.sdkVersion +import com.ichi2.compat.CompatV24 +import com.ichi2.compat.CompatV24.Shortcut import com.ichi2.libanki.ChangeManager import com.ichi2.libanki.Consts import com.ichi2.libanki.DeckId @@ -192,6 +195,7 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import net.ankiweb.rsdroid.RustCleanup +import net.ankiweb.rsdroid.Translations import org.json.JSONException import timber.log.Timber import java.io.File @@ -1264,6 +1268,16 @@ open class DeckPicker : } } + override fun onProvideKeyboardShortcuts( + data: MutableList, + menu: Menu?, + deviceId: Int + ) { + val shortcutGroups = CompatHelper.compat.getShortcuts(this) + data.addAll(shortcutGroups) + super.onProvideKeyboardShortcuts(data, menu, deviceId) + } + override fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean { if (toolbarSearchView?.hasFocus() == true) { Timber.d("Skipping keypress: search action bar is focused") @@ -1388,8 +1402,20 @@ open class DeckPicker : return true } } + KeyEvent.KEYCODE_K -> { + if (event.isAltPressed) { + CompatHelper.compat.showKeyboardShortcutsDialog(this) + return true + } + } else -> {} } + + // Show snackbar only if a modifier key is pressed and the keyCode is an unmapped alphabet key + if ((event.isCtrlPressed || event.isAltPressed || event.isShiftPressed) && keyCode in KeyEvent.KEYCODE_A..KeyEvent.KEYCODE_Z) { + showSnackbar(R.string.show_shortcuts_message, Snackbar.LENGTH_SHORT) + } + return super.onKeyUp(keyCode, event) } @@ -2412,6 +2438,29 @@ open class DeckPicker : SKIP_STUDY_OPTIONS } + override val shortcuts = CompatV24.ShortcutGroup( + listOf( + Shortcut("A", R.string.menu_add_note), + 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", Translations::decksStudyDeck), + 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("DEL", R.string.delete_deck_title), + Shortcut("Shift+DEL", R.string.delete_deck_without_confirmation), + 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) + ), + R.string.deck_picker_group + ) + companion object { /** * Result codes from other activities diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditor.kt b/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditor.kt index ce0ed4bf1393..aded893d1ecb 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditor.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditor.kt @@ -36,6 +36,7 @@ import android.text.Editable import android.text.TextWatcher import android.view.ActionMode import android.view.KeyEvent +import android.view.KeyboardShortcutGroup import android.view.Menu import android.view.MenuInflater import android.view.MenuItem @@ -137,6 +138,9 @@ import com.ichi2.annotations.NeedsTest import com.ichi2.compat.CompatHelper import com.ichi2.compat.CompatHelper.Companion.getSerializableCompat import com.ichi2.compat.CompatHelper.Companion.registerReceiverCompat +import com.ichi2.compat.CompatV24 +import com.ichi2.compat.CompatV24.Shortcut +import com.ichi2.compat.ShortcutGroupProvider import com.ichi2.imagecropper.ImageCropper import com.ichi2.imagecropper.ImageCropper.Companion.CROP_IMAGE_RESULT import com.ichi2.imagecropper.ImageCropperLauncher @@ -198,7 +202,7 @@ import androidx.appcompat.widget.Toolbar as MainToolbar */ @KotlinCleanup("Go through the class and select elements to fix") @KotlinCleanup("see if we can lateinit") -class NoteEditor : AnkiFragment(R.layout.note_editor), DeckSelectionListener, SubtitleListener, TagsDialogListener, BaseSnackbarBuilderProvider, DispatchKeyEventListener, MenuProvider { +class NoteEditor : AnkiFragment(R.layout.note_editor), DeckSelectionListener, SubtitleListener, TagsDialogListener, BaseSnackbarBuilderProvider, DispatchKeyEventListener, KeyboardShortcutEventListener, MenuProvider, ShortcutGroupProvider { /** Whether any change are saved. E.g. multimedia, new card added, field changed and saved. */ private var changed = false private var isTagsEdited = false @@ -897,6 +901,15 @@ class NoteEditor : AnkiFragment(R.layout.note_editor), DeckSelectionListener, Su } } + override fun onProvideKeyboardShortcuts( + data: MutableList, + menu: Menu?, + deviceId: Int + ) { + val shortcutGroups = CompatHelper.compat.getShortcuts(ankiActivity) + data.addAll(shortcutGroups) + } + @KotlinCleanup("convert KeyUtils to extension functions") override fun dispatchKeyEvent(event: KeyEvent): Boolean { // We want to behave as onKeyUp and thus only react to ACTION_UP @@ -950,6 +963,12 @@ class NoteEditor : AnkiFragment(R.layout.note_editor), DeckSelectionListener, Su } } } + KeyEvent.KEYCODE_K -> { + if (event.isAltPressed) { + CompatHelper.compat.showKeyboardShortcutsDialog(ankiActivity) + return true + } + } } // 7573: Ctrl+Shift+[Num] to select a field @@ -961,6 +980,12 @@ class NoteEditor : AnkiFragment(R.layout.note_editor), DeckSelectionListener, Su selectFieldIndex(humanReadableDigit - 1) return true } + + // Show snackbar only if a modifier key is pressed and the keyCode is an unmapped alphabet key + if ((event.isCtrlPressed || event.isAltPressed || event.isShiftPressed) && keyCode in KeyEvent.KEYCODE_A..KeyEvent.KEYCODE_Z) { + showSnackbar(R.string.show_shortcuts_message, Snackbar.LENGTH_SHORT) + } + return false } @@ -2373,6 +2398,19 @@ class NoteEditor : AnkiFragment(R.layout.note_editor), DeckSelectionListener, Su noteTypeSpinner!!.setSelection(position, false) } + override val shortcuts = CompatV24.ShortcutGroup( + listOf( + Shortcut("Ctrl+ENTER", R.string.save), + Shortcut("Ctrl+D", R.string.select_deck), + Shortcut("Ctrl+L", R.string.card_template_editor_group), + Shortcut("Ctrl+N", R.string.select_note_type), + Shortcut("Ctrl+Shift+T", R.string.tag_editor), + Shortcut("Ctrl+Shift+C", R.string.multimedia_editor_popup_cloze), + Shortcut("Ctrl+P", R.string.card_editor_preview_card) + ), + R.string.note_editor_group + ) + private fun updateTags() { if (selectedTags == null) { selectedTags = ArrayList(0) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/SingleFragmentActivity.kt b/AnkiDroid/src/main/java/com/ichi2/anki/SingleFragmentActivity.kt index 39aa79756d27..8dc3d83f1c05 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/SingleFragmentActivity.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/SingleFragmentActivity.kt @@ -19,9 +19,13 @@ import android.content.Context import android.content.Intent import android.os.Bundle import android.view.KeyEvent +import android.view.KeyboardShortcutGroup +import android.view.Menu import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentContainerView import androidx.fragment.app.commit +import com.ichi2.compat.CompatV24 +import com.ichi2.compat.ShortcutGroupProvider import com.ichi2.utils.getInstanceFromClassName import kotlin.reflect.KClass import kotlin.reflect.jvm.jvmName @@ -36,6 +40,9 @@ import kotlin.reflect.jvm.jvmName * [getIntent] can be used as an easy way to build a [SingleFragmentActivity] */ open class SingleFragmentActivity : AnkiActivity() { + // The displayed fragment. + lateinit var fragment: Fragment + override fun onCreate(savedInstanceState: Bundle?) { if (showedActivityFailedScreen(savedInstanceState)) { return @@ -55,7 +62,7 @@ open class SingleFragmentActivity : AnkiActivity() { val fragmentClassName = requireNotNull(intent.getStringExtra(FRAGMENT_NAME_EXTRA)) { "'$FRAGMENT_NAME_EXTRA' extra should be provided" } - val fragment = getInstanceFromClassName(fragmentClassName).apply { + fragment = getInstanceFromClassName(fragmentClassName).apply { arguments = intent.getBundleExtra(FRAGMENT_ARGS_EXTRA) } supportFragmentManager.commit { @@ -63,6 +70,19 @@ open class SingleFragmentActivity : AnkiActivity() { } } + override fun onProvideKeyboardShortcuts( + data: MutableList, + menu: Menu?, + deviceId: Int + ) { + val fragment = supportFragmentManager.findFragmentById(R.id.fragment_container)!! + if (fragment is KeyboardShortcutEventListener) { + fragment.onProvideKeyboardShortcuts(data, menu, deviceId) + } else { + super.onProvideKeyboardShortcuts(data, menu, deviceId) + } + } + override fun dispatchKeyEvent(event: KeyEvent): Boolean { val fragment = supportFragmentManager.findFragmentById(R.id.fragment_container)!! return if (fragment is DispatchKeyEventListener) { @@ -84,8 +104,19 @@ open class SingleFragmentActivity : AnkiActivity() { } } } + + override val shortcuts: CompatV24.ShortcutGroup? + get() = (fragment as? ShortcutGroupProvider)?.shortcuts } interface DispatchKeyEventListener { fun dispatchKeyEvent(event: KeyEvent): Boolean } + +interface KeyboardShortcutEventListener { + fun onProvideKeyboardShortcuts( + data: MutableList, + menu: Menu?, + deviceId: Int + ) +} diff --git a/AnkiDroid/src/main/java/com/ichi2/compat/Compat.kt b/AnkiDroid/src/main/java/com/ichi2/compat/Compat.kt index 69ae97c7d7f8..80849c60c028 100644 --- a/AnkiDroid/src/main/java/com/ichi2/compat/Compat.kt +++ b/AnkiDroid/src/main/java/com/ichi2/compat/Compat.kt @@ -29,10 +29,12 @@ 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 import androidx.draganddrop.DropHelper +import com.ichi2.anki.AnkiActivity import java.io.File import java.io.FileNotFoundException import java.io.IOException @@ -218,6 +220,18 @@ interface Compat { onReceiveContentListener: OnReceiveContentListener ) + /** + * Shows keyboard shortcuts dialog + */ + fun showKeyboardShortcutsDialog( + activity: AnkiActivity + ) + + /** + * Get current activity keyboard shortcuts + */ + fun getShortcuts(activity: AnkiActivity): 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/CompatV23.kt b/AnkiDroid/src/main/java/com/ichi2/compat/CompatV23.kt index 31b5eacdb280..03b794f0ff2a 100644 --- a/AnkiDroid/src/main/java/com/ichi2/compat/CompatV23.kt +++ b/AnkiDroid/src/main/java/com/ichi2/compat/CompatV23.kt @@ -30,10 +30,12 @@ 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 import androidx.draganddrop.DropHelper +import com.ichi2.anki.AnkiActivity import com.ichi2.utils.KotlinCleanup import timber.log.Timber import java.io.File @@ -161,6 +163,17 @@ open class CompatV23 : Compat { // No implementation possible. } + // Until API 24 + override fun showKeyboardShortcutsDialog(activity: AnkiActivity) { + // No implementation available + } + + // Until API 24 + override fun getShortcuts(activity: AnkiActivity): List { + // No implementation available + return listOf() + } + // 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 5efb377a43f7..21dddf790127 100644 --- a/AnkiDroid/src/main/java/com/ichi2/compat/CompatV24.kt +++ b/AnkiDroid/src/main/java/com/ichi2/compat/CompatV24.kt @@ -18,12 +18,20 @@ 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.AnkiActivity +import com.ichi2.anki.R import com.ichi2.anki.common.utils.android.isRobolectric +import net.ankiweb.rsdroid.Translations import timber.log.Timber import java.util.Locale @@ -60,6 +68,82 @@ open class CompatV24 : CompatV23(), Compat { ) } + override fun showKeyboardShortcutsDialog(activity: AnkiActivity) { + val shortcutsGroup = getShortcuts(activity) + // Don't show keyboard shortcuts dialog if there is no available shortcuts + if (shortcutsGroup.size <= 1) return + activity.requestShowKeyboardShortcuts() + } + + override fun getShortcuts(activity: AnkiActivity): List { + val generalShortcutGroup = ShortcutGroup( + listOf( + Shortcut("Alt+K", R.string.show_keyboard_shortcuts_dialog), + Shortcut("Ctrl+Z", R.string.undo) + ), + R.string.pref_cat_general + ).toShortcutGroup(activity) + + return listOfNotNull(activity.shortcuts?.toShortcutGroup(activity), generalShortcutGroup) + } + + /** + * Data class representing a keyboard shortcut. + * + * @param shortcut The string representation of the keyboard shortcut (e.g., "Ctrl+Alt+S"). + * @param label The string resource for the shortcut label. + */ + data class Shortcut(val shortcut: String, val label: StringResource) { + constructor(shortcut: String, @StringRes labelRes: Int) : this(shortcut, StringResource.AndroidTranslation(labelRes)) + constructor(shortcut: String, getTranslation: Translations.() -> String) : this(shortcut, StringResource.AnkiBackendTranslation(getTranslation)) + + /** + * 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 = label.toDisplayString(context) + 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 + } + } + } + + data class ShortcutGroup(val shortcuts: List, @StringRes val id: Int) { + fun toShortcutGroup(activity: AnkiActivity): KeyboardShortcutGroup { + val shortcuts = shortcuts.map { it.toShortcutInfo(activity) } + val groupLabel = activity.getString(id) + return KeyboardShortcutGroup(groupLabel, shortcuts) + } + } + override val AXIS_RELATIVE_X: Int = MotionEvent.AXIS_RELATIVE_X override val AXIS_RELATIVE_Y: Int = MotionEvent.AXIS_RELATIVE_Y } + +interface ShortcutGroupProvider { + /** + * Lists of shortcuts for this fragment, and the IdRes of the name of this shortcut group. + */ + val shortcuts: CompatV24.ShortcutGroup? +} diff --git a/AnkiDroid/src/main/java/com/ichi2/compat/StringResource.kt b/AnkiDroid/src/main/java/com/ichi2/compat/StringResource.kt new file mode 100644 index 000000000000..04df5798dd66 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/compat/StringResource.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2024 David Allison + * + * This program is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3 of the License, or (at your option) any later + * version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A + * PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ + +package com.ichi2.compat + +import android.content.Context +import androidx.annotation.StringRes +import com.ichi2.anki.CollectionManager.TR +import net.ankiweb.rsdroid.Translations + +sealed interface StringResource { + /** + * @param labelRes The string resource ID for the shortcut label. + */ + data class AndroidTranslation(@StringRes val labelRes: Int) : StringResource { + override fun toDisplayString(context: Context): String = context.getString(labelRes) + } + + /** + * Represents a function returning a translated string from the Anki Backend + * + * **Usage** + * ```kotlin + * AnkiBackendTranslation { editingTags() } + * ``` + */ + data class AnkiBackendTranslation(val getTranslation: Translations.() -> String) : StringResource { + override fun toDisplayString(context: Context): String = getTranslation(TR) + } + + // TODO: Parameters are not supported + fun toDisplayString(context: Context): String +} diff --git a/AnkiDroid/src/main/res/values/02-strings.xml b/AnkiDroid/src/main/res/values/02-strings.xml index 5b57206e7533..dd9a009f6e1a 100644 --- a/AnkiDroid/src/main/res/values/02-strings.xml +++ b/AnkiDroid/src/main/res/values/02-strings.xml @@ -402,4 +402,20 @@ 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 + Delete deck without confirmation + Note Editor + Select deck + Select note type + Tag editor + Card Template Editor + Edit the question + Edit the answer + Edit styling + Copy template as markdown + Edit the browser appearance + + Press Alt+K to show keyboard shortcuts diff --git a/AnkiDroid/src/main/res/values/06-statistics.xml b/AnkiDroid/src/main/res/values/06-statistics.xml index a9a802521c24..fb6757649605 100644 --- a/AnkiDroid/src/main/res/values/06-statistics.xml +++ b/AnkiDroid/src/main/res/values/06-statistics.xml @@ -23,5 +23,6 @@ <b>%1$.1f</b> months <b>%1$.1f</b> days <b>%1$.1f</b> hours + Open statistics diff --git a/AnkiDroid/src/main/res/values/07-cardbrowser.xml b/AnkiDroid/src/main/res/values/07-cardbrowser.xml index af85e81be132..318fc6a880a0 100644 --- a/AnkiDroid/src/main/res/values/07-cardbrowser.xml +++ b/AnkiDroid/src/main/res/values/07-cardbrowser.xml @@ -90,4 +90,8 @@ %d card deleted %d cards deleted + + + Edit tags dialog + Show order dialog \ No newline at end of file diff --git a/AnkiDroid/src/main/res/values/08-widget.xml b/AnkiDroid/src/main/res/values/08-widget.xml index 134f9238d176..800d75b19ee4 100644 --- a/AnkiDroid/src/main/res/values/08-widget.xml +++ b/AnkiDroid/src/main/res/values/08-widget.xml @@ -38,7 +38,7 @@ Add new AnkiDroid note - Deck Picker + Deck Picker Select decks diff --git a/AnkiDroid/src/main/res/values/10-preferences.xml b/AnkiDroid/src/main/res/values/10-preferences.xml index af395ac270eb..cce6186c9700 100644 --- a/AnkiDroid/src/main/res/values/10-preferences.xml +++ b/AnkiDroid/src/main/res/values/10-preferences.xml @@ -446,4 +446,7 @@ this formatter is used if the bind only applies to both the question and the ans Ignore display cutout Hide answer buttons Hide ‘Hard’ and ‘Easy’ buttons + + + Open settings