diff --git a/app/src/main/java/com/capyreader/app/MainActivity.kt b/app/src/main/java/com/capyreader/app/MainActivity.kt index c9550f5d2..e7d1791ea 100644 --- a/app/src/main/java/com/capyreader/app/MainActivity.kt +++ b/app/src/main/java/com/capyreader/app/MainActivity.kt @@ -2,10 +2,14 @@ package com.capyreader.app import android.content.Intent import android.os.Bundle +import android.view.KeyboardShortcutGroup +import android.view.KeyboardShortcutInfo +import android.view.Menu import androidx.activity.compose.setContent import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import com.capyreader.app.keyboard.KeyboardShortcutManager import com.capyreader.app.notifications.NotificationHelper import com.capyreader.app.preferences.AppPreferences import com.capyreader.app.ui.App @@ -37,6 +41,23 @@ class MainActivity : BaseActivity() { pendingArticleID = NotificationHelper.openFromIntent(intent, appPreferences = appPreferences) } + override fun onProvideKeyboardShortcuts( + data: MutableList?, + menu: Menu?, + deviceId: Int + ) { + super.onProvideKeyboardShortcuts(data, menu, deviceId) + val manager = get() + val shortcuts = manager.effectiveBindings().flatMap { (action, keys) -> + keys.map { key -> + KeyboardShortcutInfo(getString(action.labelRes), key.keyCode, key.meta) + } + } + data?.add( + KeyboardShortcutGroup(getString(R.string.shortcuts_group_title), shortcuts) + ) + } + private fun startDestination(): Route { val appPreferences = get() diff --git a/app/src/main/java/com/capyreader/app/keyboard/KeyboardShortcutManager.kt b/app/src/main/java/com/capyreader/app/keyboard/KeyboardShortcutManager.kt new file mode 100644 index 000000000..5257f5b22 --- /dev/null +++ b/app/src/main/java/com/capyreader/app/keyboard/KeyboardShortcutManager.kt @@ -0,0 +1,56 @@ +package com.capyreader.app.keyboard + +import com.jocmp.capy.preferences.Preference +import com.jocmp.capy.preferences.getAndSet + +class KeyboardShortcutManager( + private val overridesPreference: Preference, +) { + fun resolve(keyCode: Int, meta: Int): ShortcutAction? { + val key = ShortcutKey(keyCode = keyCode, meta = meta) + val reverseLookup = buildReverseLookup() + return reverseLookup[key] + } + + fun effectiveBindings(): Map> { + val overrides = overridesPreference.get().bindings + return ShortcutAction.entries.associateWith { action -> + overrides[action] ?: action.defaultKeys + } + } + + fun updateBinding(action: ShortcutAction, keys: List) { + overridesPreference.getAndSet { current -> + current.copy(bindings = current.bindings + (action to keys)) + } + } + + fun resetBinding(action: ShortcutAction) { + overridesPreference.getAndSet { current -> + current.copy(bindings = current.bindings - action) + } + } + + fun resetAll() { + overridesPreference.set(ShortcutOverrides()) + } + + fun findConflict(key: ShortcutKey, excludeAction: ShortcutAction): ShortcutAction? { + return effectiveBindings() + .filterKeys { it != excludeAction } + .entries + .firstOrNull { (_, keys) -> key in keys } + ?.key + } + + private fun buildReverseLookup(): Map { + val bindings = effectiveBindings() + val lookup = mutableMapOf() + bindings.forEach { (action, keys) -> + keys.forEach { key -> + lookup[key] = action + } + } + return lookup + } +} diff --git a/app/src/main/java/com/capyreader/app/keyboard/ShortcutAction.kt b/app/src/main/java/com/capyreader/app/keyboard/ShortcutAction.kt new file mode 100644 index 000000000..e7c03550b --- /dev/null +++ b/app/src/main/java/com/capyreader/app/keyboard/ShortcutAction.kt @@ -0,0 +1,85 @@ +package com.capyreader.app.keyboard + +import android.view.KeyEvent +import androidx.annotation.StringRes +import com.capyreader.app.R +import kotlinx.serialization.Serializable + +@Serializable +enum class ShortcutAction( + @StringRes val labelRes: Int, + val defaultKeys: List, +) { + NEXT_ARTICLE( + labelRes = R.string.shortcut_next_article, + defaultKeys = listOf( + ShortcutKey(KeyEvent.KEYCODE_J), + ), + ), + PREVIOUS_ARTICLE( + labelRes = R.string.shortcut_previous_article, + defaultKeys = listOf( + ShortcutKey(KeyEvent.KEYCODE_K), + ), + ), + TOGGLE_STAR( + labelRes = R.string.shortcut_toggle_star, + defaultKeys = listOf( + ShortcutKey(KeyEvent.KEYCODE_S), + ), + ), + TOGGLE_READ( + labelRes = R.string.shortcut_toggle_read, + defaultKeys = listOf( + ShortcutKey(KeyEvent.KEYCODE_M), + ), + ), + TOGGLE_FULL_CONTENT( + labelRes = R.string.shortcut_toggle_full_content, + defaultKeys = listOf( + ShortcutKey(KeyEvent.KEYCODE_C), + ), + ), + OPEN_IN_BROWSER( + labelRes = R.string.shortcut_open_in_browser, + defaultKeys = listOf( + ShortcutKey(KeyEvent.KEYCODE_V), + ), + ), + REFRESH( + labelRes = R.string.shortcut_refresh, + defaultKeys = listOf( + ShortcutKey(KeyEvent.KEYCODE_R), + ), + ), + GO_BACK( + labelRes = R.string.shortcut_go_back, + defaultKeys = listOf( + ShortcutKey(KeyEvent.KEYCODE_ESCAPE), + ), + ), + MARK_ALL_READ( + labelRes = R.string.shortcut_mark_all_read, + defaultKeys = listOf( + ShortcutKey(KeyEvent.KEYCODE_A, meta = KeyEvent.META_SHIFT_ON), + ), + ), + FOCUS_SEARCH( + labelRes = R.string.shortcut_focus_search, + defaultKeys = listOf( + ShortcutKey(KeyEvent.KEYCODE_SLASH), + ), + ), + TOGGLE_FULLSCREEN( + labelRes = R.string.shortcut_toggle_fullscreen, + defaultKeys = listOf( + ShortcutKey(KeyEvent.KEYCODE_F, meta = KeyEvent.META_SHIFT_ON), + ), + ); + + fun isCharacterBound(): Boolean { + return defaultKeys.any { key -> + key.meta == 0 && key.keyCode in KeyEvent.KEYCODE_A..KeyEvent.KEYCODE_Z + } + } +} diff --git a/app/src/main/java/com/capyreader/app/keyboard/ShortcutKey.kt b/app/src/main/java/com/capyreader/app/keyboard/ShortcutKey.kt new file mode 100644 index 000000000..b3c7e4097 --- /dev/null +++ b/app/src/main/java/com/capyreader/app/keyboard/ShortcutKey.kt @@ -0,0 +1,66 @@ +package com.capyreader.app.keyboard + +import android.view.KeyEvent +import kotlinx.serialization.Serializable + +@Serializable +data class ShortcutKey( + val keyCode: Int, + val meta: Int = 0, +) { + fun label(): String { + val parts = mutableListOf() + + if (meta and KeyEvent.META_CTRL_ON != 0) parts.add("Ctrl") + if (meta and KeyEvent.META_ALT_ON != 0) parts.add("Alt") + if (meta and KeyEvent.META_SHIFT_ON != 0) parts.add("Shift") + if (meta and KeyEvent.META_META_ON != 0) parts.add("Meta") + + parts.add(keyCodeLabel(keyCode)) + + return parts.joinToString("+") + } + + companion object { + private fun keyCodeLabel(keyCode: Int): String { + return when (keyCode) { + KeyEvent.KEYCODE_DPAD_UP -> "Up" + KeyEvent.KEYCODE_DPAD_DOWN -> "Down" + KeyEvent.KEYCODE_DPAD_LEFT -> "Left" + KeyEvent.KEYCODE_DPAD_RIGHT -> "Right" + KeyEvent.KEYCODE_ENTER -> "Enter" + KeyEvent.KEYCODE_SPACE -> "Space" + KeyEvent.KEYCODE_ESCAPE -> "Esc" + KeyEvent.KEYCODE_DEL -> "Backspace" + KeyEvent.KEYCODE_FORWARD_DEL -> "Delete" + KeyEvent.KEYCODE_TAB -> "Tab" + else -> KeyEvent.keyCodeToString(keyCode) + .removePrefix("KEYCODE_") + .lowercase() + .replaceFirstChar { it.uppercase() } + } + } + + fun isModifierOnly(keyCode: Int): Boolean { + return keyCode in listOf( + KeyEvent.KEYCODE_SHIFT_LEFT, + KeyEvent.KEYCODE_SHIFT_RIGHT, + KeyEvent.KEYCODE_CTRL_LEFT, + KeyEvent.KEYCODE_CTRL_RIGHT, + KeyEvent.KEYCODE_ALT_LEFT, + KeyEvent.KEYCODE_ALT_RIGHT, + KeyEvent.KEYCODE_META_LEFT, + KeyEvent.KEYCODE_META_RIGHT, + ) + } + + fun metaState(event: KeyEvent): Int { + var meta = 0 + if (event.isCtrlPressed) meta = meta or KeyEvent.META_CTRL_ON + if (event.isAltPressed) meta = meta or KeyEvent.META_ALT_ON + if (event.isShiftPressed) meta = meta or KeyEvent.META_SHIFT_ON + if (event.isMetaPressed) meta = meta or KeyEvent.META_META_ON + return meta + } + } +} diff --git a/app/src/main/java/com/capyreader/app/keyboard/ShortcutOverrides.kt b/app/src/main/java/com/capyreader/app/keyboard/ShortcutOverrides.kt new file mode 100644 index 000000000..b0d93cbca --- /dev/null +++ b/app/src/main/java/com/capyreader/app/keyboard/ShortcutOverrides.kt @@ -0,0 +1,8 @@ +package com.capyreader.app.keyboard + +import kotlinx.serialization.Serializable + +@Serializable +data class ShortcutOverrides( + val bindings: Map> = emptyMap() +) diff --git a/app/src/main/java/com/capyreader/app/preferences/AppPreferences.kt b/app/src/main/java/com/capyreader/app/preferences/AppPreferences.kt index b17f60f44..35a328e41 100644 --- a/app/src/main/java/com/capyreader/app/preferences/AppPreferences.kt +++ b/app/src/main/java/com/capyreader/app/preferences/AppPreferences.kt @@ -4,6 +4,7 @@ import android.content.Context import androidx.preference.PreferenceManager import com.capyreader.app.common.FeedGroup import com.capyreader.app.common.ImagePreview +import com.capyreader.app.keyboard.ShortcutOverrides import com.capyreader.app.refresher.RefreshInterval import com.capyreader.app.ui.articles.ArticleListFontScale import com.capyreader.app.ui.articles.DefaultPaneExpansionIndex @@ -82,6 +83,20 @@ class AppPreferences(context: Context) { val badgeStyle: Preference get() = preferenceStore.getEnum("badge_style", BadgeStyle.default) + val shortcutOverrides: Preference + get() = preferenceStore.getObject( + key = "keyboard_shortcut_overrides", + defaultValue = ShortcutOverrides(), + serializer = { Json.encodeToString(it) }, + deserializer = { + try { + Json.decodeFromString(it) + } catch (e: Throwable) { + ShortcutOverrides() + } + } + ) + fun clearAll() { preferenceStore.clearAll() } diff --git a/app/src/main/java/com/capyreader/app/ui/articles/ArticleList.kt b/app/src/main/java/com/capyreader/app/ui/articles/ArticleList.kt index 33cc11172..a880ebec0 100644 --- a/app/src/main/java/com/capyreader/app/ui/articles/ArticleList.kt +++ b/app/src/main/java/com/capyreader/app/ui/articles/ArticleList.kt @@ -35,6 +35,7 @@ fun ArticleList( onMarkAllRead: (range: MarkRead) -> Unit = {}, refreshingAll: Boolean, dimReadArticles: Boolean = true, + keyboardNavigated: Boolean = false, ) { val articleOptions = rememberArticleOptions().copy( dim = dimReadArticles, @@ -58,6 +59,7 @@ fun ArticleList( article = item, index = index, selected = selectedArticleKey == item.id, + keyboardNavigated = keyboardNavigated, onSelect = { onSelect(it) }, diff --git a/app/src/main/java/com/capyreader/app/ui/articles/ArticleRow.kt b/app/src/main/java/com/capyreader/app/ui/articles/ArticleRow.kt index cd1b9c4ed..80d73e3ab 100644 --- a/app/src/main/java/com/capyreader/app/ui/articles/ArticleRow.kt +++ b/app/src/main/java/com/capyreader/app/ui/articles/ArticleRow.kt @@ -3,6 +3,7 @@ package com.capyreader.app.ui.articles import android.content.res.Configuration import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -95,6 +96,7 @@ fun ArticleRow( onMarkAllRead: (range: MarkRead) -> Unit = {}, currentTime: LocalDateTime, options: ArticleRowOptions = ArticleRowOptions(), + keyboardNavigated: Boolean = false, ) { val imageURL = article.imageURL val isMonochrome = LocalAppTheme.current.value == AppTheme.MONOCHROME @@ -125,6 +127,7 @@ fun ArticleRow( onClick = { onSelect(article.id) }, onLongClick = openArticleMenu, article = article, + showBorder = selected && keyboardNavigated, ) { ArticleListItem( headlineContent = { @@ -415,11 +418,22 @@ private fun ArticleBox( onClick: () -> Unit, onLongClick: () -> Unit, article: Article, + showBorder: Boolean, content: @Composable () -> Unit ) { ArticleRowSwipeBox(article) { - Box( + val borderModifier = if (showBorder) { + Modifier.border( + width = 2.dp, + color = colorScheme.primary, + shape = RoundedCornerShape(4.dp), + ) + } else { Modifier + } + + Box( + borderModifier .combinedClickable( onClick = onClick, onLongClick = onLongClick, diff --git a/app/src/main/java/com/capyreader/app/ui/articles/ArticleScreen.kt b/app/src/main/java/com/capyreader/app/ui/articles/ArticleScreen.kt index 9f8225a60..ea676e1bb 100644 --- a/app/src/main/java/com/capyreader/app/ui/articles/ArticleScreen.kt +++ b/app/src/main/java/com/capyreader/app/ui/articles/ArticleScreen.kt @@ -4,6 +4,7 @@ import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.lazy.LazyListState @@ -32,7 +33,12 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.nestedScroll @@ -49,6 +55,9 @@ import com.capyreader.app.R import com.capyreader.app.common.Media import com.capyreader.app.common.Saver import com.capyreader.app.common.asState +import com.capyreader.app.keyboard.KeyboardShortcutManager +import com.capyreader.app.keyboard.ShortcutAction +import com.capyreader.app.keyboard.ShortcutKey import com.capyreader.app.preferences.AfterReadAllBehavior import com.capyreader.app.preferences.AppPreferences import com.capyreader.app.preferences.ArticleListVerticalSwipe @@ -466,6 +475,70 @@ fun ArticleScreen( selectArticle(id) } + fun selectFirstVisibleArticle() { + val firstVisibleIndex = listState.firstVisibleItemIndex + articles.itemSnapshotList.getOrNull(firstVisibleIndex)?.let { + setArticle(it.id) + } + } + + fun selectNextArticle() { + val currentArticle = article + if (currentArticle == null) { + selectFirstVisibleArticle() + return + } + val snapshot = articles.itemSnapshotList + val index = snapshot.indexOfFirst { it?.id == currentArticle.id } + val nextIndex = index + 1 + if (nextIndex < snapshot.size) { + snapshot[nextIndex]?.let { + setArticle(it.id) + scrollToArticle(nextIndex) + } + } + } + + fun selectPreviousArticle() { + val currentArticle = article + if (currentArticle == null) { + selectFirstVisibleArticle() + return + } + val snapshot = articles.itemSnapshotList + val index = snapshot.indexOfFirst { it?.id == currentArticle.id } + val previousIndex = index - 1 + if (previousIndex >= 0) { + snapshot[previousIndex]?.let { + setArticle(it.id) + scrollToArticle(previousIndex) + } + } + } + + fun onKeyboardOpenInBrowser() { + val currentArticle = article ?: return + val url = currentArticle.url?.toString() ?: return + linkOpener.open(url.toUri()) + } + + fun onKeyboardToggleFullContent() { + val currentArticle = article ?: return + if (currentArticle.fullContent == Article.FullContentState.LOADED) { + fullContent.reset() + } else if (currentArticle.fullContent != Article.FullContentState.LOADING) { + fullContent.fetch() + } + } + + var keyboardNavigated by remember { mutableStateOf(false) } + val shortcutManager: KeyboardShortcutManager = koinInject() + val listFocusRequester = remember { FocusRequester() } + + LaunchedEffect(Unit) { + listFocusRequester.requestFocus() + } + ArticleScaffold( drawerState = drawerState, scaffoldNavigator = scaffoldNavigator, @@ -509,6 +582,44 @@ fun ArticleScreen( ) { Scaffold( modifier = Modifier + .focusRequester(listFocusRequester) + .focusable() + .onPreviewKeyEvent { event -> + if (event.type != KeyEventType.KeyDown) { + return@onPreviewKeyEvent false + } + + val nativeEvent = event.nativeKeyEvent + val meta = ShortcutKey.metaState(nativeEvent) + + if (search.isActive && nativeEvent.keyCode in android.view.KeyEvent.KEYCODE_A..android.view.KeyEvent.KEYCODE_Z && meta == 0) { + return@onPreviewKeyEvent false + } + + val action = shortcutManager.resolve(nativeEvent.keyCode, meta) + ?: return@onPreviewKeyEvent false + + when (action) { + ShortcutAction.NEXT_ARTICLE -> { + keyboardNavigated = true + selectNextArticle() + } + ShortcutAction.PREVIOUS_ARTICLE -> { + keyboardNavigated = true + selectPreviousArticle() + } + ShortcutAction.TOGGLE_STAR -> viewModel.toggleArticleStar() + ShortcutAction.TOGGLE_READ -> viewModel.toggleArticleRead() + ShortcutAction.TOGGLE_FULL_CONTENT -> onKeyboardToggleFullContent() + ShortcutAction.OPEN_IN_BROWSER -> onKeyboardOpenInBrowser() + ShortcutAction.REFRESH -> refreshFeeds() + ShortcutAction.GO_BACK -> clearArticle() + ShortcutAction.MARK_ALL_READ -> markAllRead(MarkRead.All) + ShortcutAction.FOCUS_SEARCH -> search.start() + ShortcutAction.TOGGLE_FULLSCREEN -> paneExpansion.toggleFullscreen() + } + true + } .nestedScroll(scrollBehavior.nestedScrollConnection) .nestedScroll(object : NestedScrollConnection { override fun onPostScroll( @@ -593,10 +704,12 @@ fun ArticleScreen( listState = listState, refreshingAll = viewModel.refreshingAll, dimReadArticles = filter.status != ArticleStatus.STARRED, + keyboardNavigated = keyboardNavigated, onMarkAllRead = { range -> onMarkAllRead(range) }, onSelect = { articleID -> + keyboardNavigated = false selectArticle(articleID) }, ) diff --git a/app/src/main/java/com/capyreader/app/ui/settings/SettingsList.kt b/app/src/main/java/com/capyreader/app/ui/settings/SettingsList.kt index c4935346a..50ba73ec4 100644 --- a/app/src/main/java/com/capyreader/app/ui/settings/SettingsList.kt +++ b/app/src/main/java/com/capyreader/app/ui/settings/SettingsList.kt @@ -1,5 +1,6 @@ package com.capyreader.app.ui.settings +import android.content.res.Configuration import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -22,6 +23,7 @@ import androidx.compose.material3.TopAppBarDefaults.pinnedScrollBehavior import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.draw.clip import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.stringResource @@ -40,7 +42,12 @@ fun SettingsList( onNavigateBack: () -> Unit, ) { val scrollBehavior = pinnedScrollBehavior() - val items = remember { SettingsPanel.items } + val hasHardwareKeyboard = LocalConfiguration.current.keyboard != Configuration.KEYBOARD_NOKEYS + val items = remember(hasHardwareKeyboard) { + SettingsPanel.items.filter { panel -> + panel != SettingsPanel.Shortcuts || hasHardwareKeyboard + } + } Scaffold( modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), diff --git a/app/src/main/java/com/capyreader/app/ui/settings/SettingsModule.kt b/app/src/main/java/com/capyreader/app/ui/settings/SettingsModule.kt index f3508b4d9..4f78ea6b9 100644 --- a/app/src/main/java/com/capyreader/app/ui/settings/SettingsModule.kt +++ b/app/src/main/java/com/capyreader/app/ui/settings/SettingsModule.kt @@ -1,5 +1,7 @@ package com.capyreader.app.ui.settings +import com.capyreader.app.keyboard.KeyboardShortcutManager +import com.capyreader.app.preferences.AppPreferences import com.capyreader.app.transfers.OPMLImportWorker import com.capyreader.app.ui.settings.panels.AccountSettingsViewModel import com.capyreader.app.ui.settings.panels.DisplaySettingsViewModel @@ -11,6 +13,11 @@ import org.koin.androidx.workmanager.dsl.worker import org.koin.dsl.module val settingsModule = module { + single { + KeyboardShortcutManager( + overridesPreference = get().shortcutOverrides + ) + } viewModel { GeneralSettingsViewModel( refreshScheduler = get(), diff --git a/app/src/main/java/com/capyreader/app/ui/settings/SettingsView.kt b/app/src/main/java/com/capyreader/app/ui/settings/SettingsView.kt index c9484f10b..030332c98 100644 --- a/app/src/main/java/com/capyreader/app/ui/settings/SettingsView.kt +++ b/app/src/main/java/com/capyreader/app/ui/settings/SettingsView.kt @@ -28,6 +28,7 @@ import com.capyreader.app.ui.settings.panels.GeneralSettingsPanel import com.capyreader.app.ui.settings.panels.GesturesSettingPanel import com.capyreader.app.ui.settings.panels.NotificationsSettingsPanel import com.capyreader.app.ui.settings.panels.SettingsPanel +import com.capyreader.app.ui.settings.panels.ShortcutsSettingsPanel import com.capyreader.app.ui.settings.panels.UnreadBadgesSettingsPanel import com.capyreader.app.ui.settings.panels.SettingsViewModel import com.jocmp.capy.common.launchUI @@ -112,6 +113,7 @@ fun SettingsView( } ) SettingsPanel.Gestures -> GesturesSettingPanel() + SettingsPanel.Shortcuts -> ShortcutsSettingsPanel() SettingsPanel.Account -> AccountSettingsPanel(onRemoveAccount = onRemoveAccount) SettingsPanel.About -> AboutSettingsPanel() SettingsPanel.ArticleList -> ArticleListSettingsPanel() diff --git a/app/src/main/java/com/capyreader/app/ui/settings/panels/SettingsPanel.kt b/app/src/main/java/com/capyreader/app/ui/settings/panels/SettingsPanel.kt index 7196724d4..6661d3877 100644 --- a/app/src/main/java/com/capyreader/app/ui/settings/panels/SettingsPanel.kt +++ b/app/src/main/java/com/capyreader/app/ui/settings/panels/SettingsPanel.kt @@ -7,6 +7,7 @@ import androidx.compose.material.icons.rounded.AccountCircle import androidx.compose.material.icons.rounded.Build import androidx.compose.material.icons.rounded.Gesture import androidx.compose.material.icons.rounded.Info +import androidx.compose.material.icons.rounded.Keyboard import androidx.compose.material.icons.rounded.Notifications import androidx.compose.material.icons.rounded.Palette import androidx.compose.material.icons.rounded.Visibility @@ -38,6 +39,12 @@ sealed class SettingsPanel(@StringRes val title: Int) { override fun icon() = Icons.Rounded.Gesture } + @Parcelize + data object Shortcuts : SettingsPanel(title = R.string.settings_panel_shortcuts_title), + Parcelable { + override fun icon() = Icons.Rounded.Keyboard + } + @Parcelize data object Account : SettingsPanel(title = R.string.settings_account_title), Parcelable { override fun icon() = Icons.Rounded.AccountCircle @@ -68,6 +75,7 @@ sealed class SettingsPanel(@StringRes val title: Int) { General, Display, Gestures, + Shortcuts, Account, About, ) diff --git a/app/src/main/java/com/capyreader/app/ui/settings/panels/ShortcutsSettingsPanel.kt b/app/src/main/java/com/capyreader/app/ui/settings/panels/ShortcutsSettingsPanel.kt new file mode 100644 index 000000000..55513048f --- /dev/null +++ b/app/src/main/java/com/capyreader/app/ui/settings/panels/ShortcutsSettingsPanel.kt @@ -0,0 +1,194 @@ +package com.capyreader.app.ui.settings.panels + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.capyreader.app.R +import com.capyreader.app.keyboard.KeyboardShortcutManager +import com.capyreader.app.keyboard.ShortcutAction +import com.capyreader.app.keyboard.ShortcutKey +import org.koin.compose.koinInject + +@Composable +fun ShortcutsSettingsPanel( + shortcutManager: KeyboardShortcutManager = koinInject(), +) { + var bindings by remember { mutableStateOf(shortcutManager.effectiveBindings()) } + var remapAction by remember { mutableStateOf(null) } + + fun refreshBindings() { + bindings = shortcutManager.effectiveBindings() + } + + Column( + modifier = Modifier.verticalScroll(rememberScrollState()), + ) { + ShortcutAction.entries.forEach { action -> + val keys = bindings[action].orEmpty() + + ListItem( + modifier = Modifier.clickable { remapAction = action }, + headlineContent = { + Text(stringResource(action.labelRes)) + }, + supportingContent = { + Text( + text = keys.joinToString(", ") { it.label() }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + ) + } + + Spacer(Modifier.height(8.dp)) + + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + contentAlignment = Alignment.CenterEnd, + ) { + TextButton( + onClick = { + shortcutManager.resetAll() + refreshBindings() + } + ) { + Text(stringResource(R.string.shortcuts_reset_all)) + } + } + + Spacer(Modifier.height(16.dp)) + } + + remapAction?.let { action -> + RemapDialog( + action = action, + shortcutManager = shortcutManager, + onAssign = { key -> + shortcutManager.updateBinding(action, listOf(key)) + refreshBindings() + remapAction = null + }, + onReset = { + shortcutManager.resetBinding(action) + refreshBindings() + remapAction = null + }, + onDismiss = { remapAction = null }, + ) + } +} + +@Composable +private fun RemapDialog( + action: ShortcutAction, + shortcutManager: KeyboardShortcutManager, + onAssign: (ShortcutKey) -> Unit, + onReset: () -> Unit, + onDismiss: () -> Unit, +) { + var capturedKey by remember { mutableStateOf(null) } + val focusRequester = remember { FocusRequester() } + + val conflict = capturedKey?.let { key -> + shortcutManager.findConflict(key, excludeAction = action) + } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(action.labelRes)) }, + text = { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester) + .focusable() + .onPreviewKeyEvent { event -> + if (event.type != KeyEventType.KeyDown) { + return@onPreviewKeyEvent false + } + val nativeEvent = event.nativeKeyEvent + if (ShortcutKey.isModifierOnly(nativeEvent.keyCode)) { + return@onPreviewKeyEvent false + } + capturedKey = ShortcutKey( + keyCode = nativeEvent.keyCode, + meta = ShortcutKey.metaState(nativeEvent), + ) + true + } + ) { + Text( + text = capturedKey?.label() + ?: stringResource(R.string.shortcuts_press_key_prompt), + style = MaterialTheme.typography.headlineSmall, + ) + if (conflict != null) { + Spacer(Modifier.height(8.dp)) + Text( + text = stringResource( + R.string.shortcuts_conflict_warning, + stringResource(conflict.labelRes) + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + } + }, + confirmButton = { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TextButton(onClick = onReset) { + Text(stringResource(R.string.shortcuts_reset_to_default)) + } + TextButton( + enabled = capturedKey != null && conflict == null, + onClick = { capturedKey?.let(onAssign) }, + ) { + Text(stringResource(android.R.string.ok)) + } + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(android.R.string.cancel)) + } + }, + ) + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 26f6b1a9e..f128249c5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -212,6 +212,7 @@ Display & Appearance Notifications Gestures + Shortcuts Account About Settings @@ -363,4 +364,20 @@ Delete Are you sure you want to delete this page? Delete + Next article + Previous article + Toggle star + Toggle read + Toggle full content + Open in browser + Refresh + Go back + Mark all as read + Search + Toggle fullscreen + Article Reader + Press a key to assign + Reset to default + Already assigned to %s + Reset all