From 7c464e4ac5b2077cae010dddbc7a52130cf4208e Mon Sep 17 00:00:00 2001 From: John Trujillo Date: Mon, 16 Mar 2026 16:21:47 -0500 Subject: [PATCH 1/5] feat(shortcuts): introduce modular IDE shortcut catalog and execution system add grouped shortcuts and editor dispatch integration --- .../actions/file/CloseFileAction.kt | 6 +- .../sidebar/PreferencesSidebarAction.kt | 8 +- .../androidide/activities/MainActivity.kt | 41 +++++++ .../editor/EditorHandlerActivity.kt | 25 ++++ .../shortcuts/IdeShortcutActions.kt | 116 ++++++++++++++++++ .../androidide/shortcuts/KeyShortcut.kt | 43 +++++++ .../androidide/shortcuts/ShortcutActionIds.kt | 11 ++ .../androidide/shortcuts/ShortcutCatalog.kt | 13 ++ .../androidide/shortcuts/ShortcutCategory.kt | 8 ++ .../androidide/shortcuts/ShortcutContext.kt | 8 ++ .../shortcuts/ShortcutDefinition.kt | 10 ++ .../shortcuts/ShortcutExecutionContext.kt | 8 ++ .../shortcuts/ShortcutGroupProvider.kt | 19 +++ .../androidide/shortcuts/ShortcutManager.kt | 69 +++++++++++ .../shortcuts/groups/FileManagementGroup.kt | 43 +++++++ .../groups/ProjectsAndSolutionsGroup.kt | 53 ++++++++ .../shortcuts/groups/SearchAndReplaceGroup.kt | 42 +++++++ .../shortcuts/groups/ShortcutDefinitionIds.kt | 14 +++ .../shortcuts/groups/ShortcutGroup.kt | 8 ++ .../groups/WindowsAndDisplayGroup.kt | 57 +++++++++ .../itsaky/androidide/utils/FragmentUtils.kt | 33 +++++ resources/src/main/res/values/strings.xml | 10 ++ 22 files changed, 642 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/com/itsaky/androidide/shortcuts/IdeShortcutActions.kt create mode 100644 app/src/main/java/com/itsaky/androidide/shortcuts/KeyShortcut.kt create mode 100644 app/src/main/java/com/itsaky/androidide/shortcuts/ShortcutActionIds.kt create mode 100644 app/src/main/java/com/itsaky/androidide/shortcuts/ShortcutCatalog.kt create mode 100644 app/src/main/java/com/itsaky/androidide/shortcuts/ShortcutCategory.kt create mode 100644 app/src/main/java/com/itsaky/androidide/shortcuts/ShortcutContext.kt create mode 100644 app/src/main/java/com/itsaky/androidide/shortcuts/ShortcutDefinition.kt create mode 100644 app/src/main/java/com/itsaky/androidide/shortcuts/ShortcutExecutionContext.kt create mode 100644 app/src/main/java/com/itsaky/androidide/shortcuts/ShortcutGroupProvider.kt create mode 100644 app/src/main/java/com/itsaky/androidide/shortcuts/ShortcutManager.kt create mode 100644 app/src/main/java/com/itsaky/androidide/shortcuts/groups/FileManagementGroup.kt create mode 100644 app/src/main/java/com/itsaky/androidide/shortcuts/groups/ProjectsAndSolutionsGroup.kt create mode 100644 app/src/main/java/com/itsaky/androidide/shortcuts/groups/SearchAndReplaceGroup.kt create mode 100644 app/src/main/java/com/itsaky/androidide/shortcuts/groups/ShortcutDefinitionIds.kt create mode 100644 app/src/main/java/com/itsaky/androidide/shortcuts/groups/ShortcutGroup.kt create mode 100644 app/src/main/java/com/itsaky/androidide/shortcuts/groups/WindowsAndDisplayGroup.kt create mode 100644 app/src/main/java/com/itsaky/androidide/utils/FragmentUtils.kt diff --git a/app/src/main/java/com/itsaky/androidide/actions/file/CloseFileAction.kt b/app/src/main/java/com/itsaky/androidide/actions/file/CloseFileAction.kt index 38224fce64..ba3e0e1a71 100644 --- a/app/src/main/java/com/itsaky/androidide/actions/file/CloseFileAction.kt +++ b/app/src/main/java/com/itsaky/androidide/actions/file/CloseFileAction.kt @@ -30,7 +30,11 @@ import com.itsaky.androidide.activities.editor.EditorHandlerActivity */ class CloseFileAction(context: Context, override val order: Int) : FileTabAction() { - override val id: String = "ide.editor.fileTab.close.current" + override val id: String = ID + + companion object { + const val ID = "ide.editor.fileTab.close.current" + } init { label = context.getString(R.string.action_closeThis) diff --git a/app/src/main/java/com/itsaky/androidide/actions/sidebar/PreferencesSidebarAction.kt b/app/src/main/java/com/itsaky/androidide/actions/sidebar/PreferencesSidebarAction.kt index cbeb3e4bd6..69cf782a8d 100644 --- a/app/src/main/java/com/itsaky/androidide/actions/sidebar/PreferencesSidebarAction.kt +++ b/app/src/main/java/com/itsaky/androidide/actions/sidebar/PreferencesSidebarAction.kt @@ -35,7 +35,11 @@ import kotlin.reflect.KClass */ class PreferencesSidebarAction(context: Context, override val order: Int) : AbstractSidebarAction() { - override val id: String = "ide.editor.sidebar.preferences" + override val id: String = ID + + companion object { + const val ID = "ide.editor.sidebar.preferences" + } // TODO : Should we show the preferences in the sidebar itself? override val fragmentClass: KClass? = null @@ -51,4 +55,4 @@ class PreferencesSidebarAction(context: Context, override val order: Int) : Abst } override fun retrieveTooltipTag(isAlternateContext: Boolean) = TooltipTag.PREFERENCES_SIDEBAR -} \ No newline at end of file +} diff --git a/app/src/main/java/com/itsaky/androidide/activities/MainActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/MainActivity.kt index 35bc498476..cbf35cf213 100755 --- a/app/src/main/java/com/itsaky/androidide/activities/MainActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/MainActivity.kt @@ -20,6 +20,7 @@ package com.itsaky.androidide.activities import android.content.Intent import android.content.res.Configuration import android.os.Bundle +import android.view.KeyEvent import android.view.View import androidx.activity.OnBackPressedCallback import androidx.activity.viewModels @@ -33,6 +34,7 @@ import com.google.android.material.transition.MaterialSharedAxis import com.itsaky.androidide.FeedbackButtonManager import com.itsaky.androidide.R import com.itsaky.androidide.activities.editor.EditorActivityKt +import com.itsaky.androidide.actions.ActionData import com.itsaky.androidide.analytics.IAnalyticsManager import com.itsaky.androidide.app.EdgeToEdgeIDEActivity import com.itsaky.androidide.databinding.ActivityMainBinding @@ -52,6 +54,10 @@ import com.itsaky.androidide.utils.flashInfo import com.itsaky.androidide.utils.applyBottomWindowInsetsPadding import com.itsaky.androidide.fragments.MainFragment import com.itsaky.androidide.fragments.RecentProjectsFragment +import com.itsaky.androidide.shortcuts.IdeShortcutActions +import com.itsaky.androidide.shortcuts.ShortcutContext +import com.itsaky.androidide.shortcuts.ShortcutExecutionContext +import com.itsaky.androidide.shortcuts.ShortcutManager import com.itsaky.androidide.viewmodel.MainViewModel import com.itsaky.androidide.viewmodel.MainViewModel.Companion.SCREEN_CLONE_REPO import com.itsaky.androidide.viewmodel.MainViewModel.Companion.SCREEN_DELETE_PROJECTS @@ -68,6 +74,7 @@ import org.appdevforall.localwebserver.WebServer import org.koin.android.ext.android.inject import org.slf4j.LoggerFactory import java.io.File +import com.itsaky.androidide.utils.hasVisibleDialog class MainActivity : EdgeToEdgeIDEActivity() { private val log = LoggerFactory.getLogger(MainActivity::class.java) @@ -79,6 +86,7 @@ class MainActivity : EdgeToEdgeIDEActivity() { private val analyticsManager: IAnalyticsManager by inject() private var feedbackButtonManager: FeedbackButtonManager? = null private var webServer: WebServer? = null + private val shortcutManager by lazy { ShortcutManager(applicationContext) } private val onBackPressedCallback = object : OnBackPressedCallback(true) { @@ -157,6 +165,39 @@ class MainActivity : EdgeToEdgeIDEActivity() { } } + override fun dispatchKeyEvent(event: KeyEvent): Boolean { + return shortcutManager.dispatch( + event = event, + context = ShortcutContext.MAIN, + focusView = currentFocus, + hasModal = supportFragmentManager.hasVisibleDialog(), + executionContext = mainShortcutExecutionContext, + ) || super.dispatchKeyEvent(event) + } + + private val mainShortcutExecutionContext by lazy { + ShortcutExecutionContext( + ideShortcutActions = IdeShortcutActions { + ActionData.create(this) + }, + ) + } + + fun showCreateProject(): Boolean { + viewModel.setScreen(SCREEN_TEMPLATE_LIST) + return true + } + + fun showOpenProject(): Boolean { + viewModel.setScreen(SCREEN_SAVED_PROJECTS) + return true + } + + fun showCloneRepository(): Boolean { + viewModel.setScreen(SCREEN_CLONE_REPO) + return true + } + private fun showWarningDialog() { val builder = DialogUtils.newMaterialDialogBuilder(this) diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt index 1d012b22bb..ebe0ebd0da 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt @@ -22,6 +22,7 @@ import android.content.res.Configuration import android.os.Bundle import android.text.TextUtils import android.util.Log +import android.view.KeyEvent import android.view.View import android.view.ViewGroup.LayoutParams import androidx.collection.MutableIntObjectMap @@ -66,6 +67,10 @@ import com.itsaky.androidide.plugins.manager.fragment.PluginFragmentFactory import com.itsaky.androidide.plugins.manager.ui.PluginEditorTabManager import com.itsaky.androidide.projects.ProjectManagerImpl import com.itsaky.androidide.projects.builder.BuildResult +import com.itsaky.androidide.shortcuts.IdeShortcutActions +import com.itsaky.androidide.shortcuts.ShortcutContext +import com.itsaky.androidide.shortcuts.ShortcutExecutionContext +import com.itsaky.androidide.shortcuts.ShortcutManager import com.itsaky.androidide.tasks.executeAsync import com.itsaky.androidide.ui.CodeEditorView import com.itsaky.androidide.utils.DialogUtils.newMaterialDialogBuilder @@ -85,6 +90,7 @@ import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.atomic.AtomicBoolean import java.util.function.Consumer +import com.itsaky.androidide.utils.hasVisibleDialog /** * Base class for EditorActivity. Handles logic for working with file editors. @@ -107,6 +113,7 @@ open class EditorHandlerActivity : private val pluginTabIndices = mutableMapOf() private val tabIndexToPluginId = mutableMapOf() + private val shortcutManager by lazy { ShortcutManager(applicationContext) } private fun getTabPositionForFileIndex(fileIndex: Int): Int { val safeContent = contentOrNull ?: return -1 @@ -125,6 +132,24 @@ open class EditorHandlerActivity : return -1 } + override fun dispatchKeyEvent(event: KeyEvent): Boolean { + return shortcutManager.dispatch( + event = event, + context = ShortcutContext.EDITOR, + focusView = currentFocus, + hasModal = supportFragmentManager.hasVisibleDialog(), + executionContext = editorShortcutExecutionContext(), + ) || super.dispatchKeyEvent(event) + } + + private fun editorShortcutExecutionContext(): ShortcutExecutionContext { + return ShortcutExecutionContext( + ideShortcutActions = IdeShortcutActions { + createToolbarActionData() + }, + ) + } + override fun doOpenFile( file: File, selection: Range?, diff --git a/app/src/main/java/com/itsaky/androidide/shortcuts/IdeShortcutActions.kt b/app/src/main/java/com/itsaky/androidide/shortcuts/IdeShortcutActions.kt new file mode 100644 index 0000000000..ea93c62f88 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/shortcuts/IdeShortcutActions.kt @@ -0,0 +1,116 @@ +package com.itsaky.androidide.shortcuts + +import android.util.Log +import android.content.Context +import android.content.Intent +import androidx.fragment.app.FragmentActivity +import com.itsaky.androidide.actions.ActionData +import com.itsaky.androidide.actions.ActionItem +import com.itsaky.androidide.actions.ActionsRegistry +import com.itsaky.androidide.actions.internal.DefaultActionsRegistry +import com.itsaky.androidide.actions.sidebar.PreferencesSidebarAction +import com.itsaky.androidide.actions.sidebar.TerminalSidebarAction +import com.itsaky.androidide.activities.MainActivity +import com.itsaky.androidide.activities.PreferencesActivity +import com.itsaky.androidide.activities.TerminalActivity +import com.itsaky.androidide.utils.dismissTopDialog + +/** + * Executes IDE shortcut actions using the actions registry with fallbacks. + */ +class IdeShortcutActions( + private val actionDataProvider: () -> ActionData?, +) { + + private val actionsRegistry: ActionsRegistry + get() = ActionsRegistry.getInstance() + + /** + * Executes the shortcut action with the given ID, returning whether it ran. + */ + fun execute(actionId: String): Boolean { + val data = actionDataProvider() + if (data == null) { + Log.w("IdeShortcutActions", "Missing ActionData for shortcut actionId=$actionId") + return false + } + + val context = data.get(Context::class.java) + if (actionId == TerminalSidebarAction.ID && context is MainActivity) { + return executeFallback(actionId, data) + } + + val registry = actionsRegistry as? DefaultActionsRegistry ?: return executeFallback(actionId, data) + val action = findActionById(actionsRegistry, actionId) ?: return executeFallback(actionId, data) + action.prepare(data) + if (!action.enabled) { + return executeFallback(actionId, data) + } + registry.executeAction(action, data) + return true + } + + /** + * Locates a registered action by ID across all action locations. + */ + private fun findActionById( + actionsRegistry: ActionsRegistry, + actionId: String, + ): ActionItem? { + return ActionItem.Location.entries + .asSequence() + .mapNotNull { location -> actionsRegistry.findAction(location, actionId) } + .firstOrNull() + } + + /** + * Executes built-in fallback behaviors when no registered action is found. + */ + private fun executeFallback( + actionId: String, + data: ActionData, + ): Boolean { + return when (actionId) { + ShortcutActionIds.MAIN_CREATE_PROJECT -> { + (data.get(Context::class.java) as? MainActivity) + ?.showCreateProject() + ?: false + } + + ShortcutActionIds.MAIN_OPEN_PROJECT -> { + (data.get(Context::class.java) as? MainActivity) + ?.showOpenProject() + ?: false + } + + ShortcutActionIds.MAIN_CLONE_REPOSITORY -> { + (data.get(Context::class.java) as? MainActivity) + ?.showCloneRepository() + ?: false + } + + ShortcutActionIds.DISMISS_MODAL -> { + val activity = data.get(Context::class.java) as? FragmentActivity ?: return false + activity.supportFragmentManager.dismissTopDialog() + } + + TerminalSidebarAction.ID -> { + val context = data.get(Context::class.java) ?: return false + if (context is MainActivity) { + context.startActivity(Intent(context, TerminalActivity::class.java)) + return true + } + TerminalSidebarAction.startTerminalActivity(data, false) + true + } + + PreferencesSidebarAction.ID -> { + val context = data.get(Context::class.java) ?: return false + context.startActivity(Intent(context, PreferencesActivity::class.java)) + true + } + + else -> false + } + } +} diff --git a/app/src/main/java/com/itsaky/androidide/shortcuts/KeyShortcut.kt b/app/src/main/java/com/itsaky/androidide/shortcuts/KeyShortcut.kt new file mode 100644 index 0000000000..1ee043a4e5 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/shortcuts/KeyShortcut.kt @@ -0,0 +1,43 @@ +package com.itsaky.androidide.shortcuts + +import android.view.KeyEvent + +data class KeyShortcut( + val keyCode: Int, + val ctrl: Boolean = false, + val shift: Boolean = false, + val alt: Boolean = false, +) { + fun matches(event: KeyEvent): Boolean { + if (event.action != KeyEvent.ACTION_DOWN) return false + if (event.repeatCount > 0) return false + + return event.keyCode == keyCode && + event.isCtrlPressed == ctrl && + event.isShiftPressed == shift && + event.isAltPressed == alt + } + + companion object { + fun ctrl(keyCode: Int) = KeyShortcut( + keyCode = keyCode, + ctrl = true, + ) + + fun ctrlShift(keyCode: Int) = KeyShortcut( + keyCode = keyCode, + ctrl = true, + shift = true, + ) + + fun ctrlAlt(keyCode: Int) = KeyShortcut( + keyCode = keyCode, + ctrl = true, + alt = true, + ) + + fun esc() = KeyShortcut( + keyCode = KeyEvent.KEYCODE_ESCAPE, + ) + } +} diff --git a/app/src/main/java/com/itsaky/androidide/shortcuts/ShortcutActionIds.kt b/app/src/main/java/com/itsaky/androidide/shortcuts/ShortcutActionIds.kt new file mode 100644 index 0000000000..8a0e0d6797 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/shortcuts/ShortcutActionIds.kt @@ -0,0 +1,11 @@ +package com.itsaky.androidide.shortcuts + +/** + * Central registry of shortcut action IDs used across the IDE. + */ +object ShortcutActionIds { + const val MAIN_CREATE_PROJECT = "ide.main.createProject" + const val MAIN_OPEN_PROJECT = "ide.main.openProject" + const val MAIN_CLONE_REPOSITORY = "ide.main.cloneRepository" + const val DISMISS_MODAL = "ide.app.dismissModal" +} diff --git a/app/src/main/java/com/itsaky/androidide/shortcuts/ShortcutCatalog.kt b/app/src/main/java/com/itsaky/androidide/shortcuts/ShortcutCatalog.kt new file mode 100644 index 0000000000..6771057bca --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/shortcuts/ShortcutCatalog.kt @@ -0,0 +1,13 @@ +package com.itsaky.androidide.shortcuts + +import android.content.Context + +class ShortcutCatalog( + private val groupProvider: ShortcutGroupProvider = ShortcutGroupProvider(), +) { + + fun all(context: Context): List { + val groups = groupProvider.all() + return groups.flatMap { group -> group.shortcuts(context) } + } +} diff --git a/app/src/main/java/com/itsaky/androidide/shortcuts/ShortcutCategory.kt b/app/src/main/java/com/itsaky/androidide/shortcuts/ShortcutCategory.kt new file mode 100644 index 0000000000..6a22bad2f7 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/shortcuts/ShortcutCategory.kt @@ -0,0 +1,8 @@ +package com.itsaky.androidide.shortcuts + +enum class ShortcutCategory { + FILE_MANAGEMENT, + SEARCH_AND_REPLACE, + PROJECTS_AND_SOLUTIONS, + WINDOWS_AND_DISPLAY, +} diff --git a/app/src/main/java/com/itsaky/androidide/shortcuts/ShortcutContext.kt b/app/src/main/java/com/itsaky/androidide/shortcuts/ShortcutContext.kt new file mode 100644 index 0000000000..d0df89914c --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/shortcuts/ShortcutContext.kt @@ -0,0 +1,8 @@ +package com.itsaky.androidide.shortcuts + +enum class ShortcutContext { + APP_GLOBAL, + MAIN, + EDITOR, + MODAL, +} diff --git a/app/src/main/java/com/itsaky/androidide/shortcuts/ShortcutDefinition.kt b/app/src/main/java/com/itsaky/androidide/shortcuts/ShortcutDefinition.kt new file mode 100644 index 0000000000..a3209c3426 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/shortcuts/ShortcutDefinition.kt @@ -0,0 +1,10 @@ +package com.itsaky.androidide.shortcuts + +data class ShortcutDefinition( + val id: String, + val title: String, + val category: ShortcutCategory, + val bindings: List, + val contexts: Set, + val actionId: String, +) diff --git a/app/src/main/java/com/itsaky/androidide/shortcuts/ShortcutExecutionContext.kt b/app/src/main/java/com/itsaky/androidide/shortcuts/ShortcutExecutionContext.kt new file mode 100644 index 0000000000..cec5e9dd9e --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/shortcuts/ShortcutExecutionContext.kt @@ -0,0 +1,8 @@ +package com.itsaky.androidide.shortcuts + +/** + * Execution context for resolving and running IDE shortcuts. + */ +data class ShortcutExecutionContext( + val ideShortcutActions: IdeShortcutActions, +) diff --git a/app/src/main/java/com/itsaky/androidide/shortcuts/ShortcutGroupProvider.kt b/app/src/main/java/com/itsaky/androidide/shortcuts/ShortcutGroupProvider.kt new file mode 100644 index 0000000000..c66cdc5af1 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/shortcuts/ShortcutGroupProvider.kt @@ -0,0 +1,19 @@ +package com.itsaky.androidide.shortcuts + +import com.itsaky.androidide.shortcuts.groups.FileManagementGroup +import com.itsaky.androidide.shortcuts.groups.ProjectsAndSolutionsGroup +import com.itsaky.androidide.shortcuts.groups.SearchAndReplaceGroup +import com.itsaky.androidide.shortcuts.groups.ShortcutGroup +import com.itsaky.androidide.shortcuts.groups.WindowsAndDisplayGroup + +/** + * Provides the set of available shortcut groups for the IDE. + */ +class ShortcutGroupProvider { + fun all(): List = listOf( + ProjectsAndSolutionsGroup(), + WindowsAndDisplayGroup(), + FileManagementGroup(), + SearchAndReplaceGroup(), + ) +} diff --git a/app/src/main/java/com/itsaky/androidide/shortcuts/ShortcutManager.kt b/app/src/main/java/com/itsaky/androidide/shortcuts/ShortcutManager.kt new file mode 100644 index 0000000000..17d05fdffe --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/shortcuts/ShortcutManager.kt @@ -0,0 +1,69 @@ +package com.itsaky.androidide.shortcuts + +import android.content.Context +import android.view.KeyEvent +import android.view.View +import android.widget.EditText + +class ShortcutManager( + context: Context, +) { + + private val catalog: List = ShortcutCatalog().all(context) + + private val contextPriority = listOf( + ShortcutContext.MODAL, + ShortcutContext.EDITOR, + ShortcutContext.MAIN, + ShortcutContext.APP_GLOBAL, + ) + + fun dispatch( + event: KeyEvent, + context: ShortcutContext, + focusView: View?, + executionContext: ShortcutExecutionContext, + hasModal: Boolean = false, + ): Boolean { + if (!shouldHandleShortcuts(event, focusView)) return false + + val activeContexts = if (hasModal) { + setOf(ShortcutContext.APP_GLOBAL, ShortcutContext.MODAL) + } else { + setOf(ShortcutContext.APP_GLOBAL, context) + } + + val definition = findMatchingShortcut(event, activeContexts) ?: return false + return executionContext.ideShortcutActions.execute(definition.actionId) + } + + private fun shouldHandleShortcuts( + event: KeyEvent, + focusView: View?, + ): Boolean { + if (event.keyCode == KeyEvent.KEYCODE_ESCAPE) return true + return focusView !is EditText + } + + private fun findMatchingShortcut( + event: KeyEvent, + activeContexts: Set, + ): ShortcutDefinition? { + val matchingDefinitions = catalog + .filter { definition -> definition.contexts.any { it in activeContexts } } + .filter { definition -> definition.bindings.any { binding -> binding.matches(event) } } + + if (matchingDefinitions.isEmpty()) return null + + return matchingDefinitions.maxByOrNull { definition -> + definition.contexts.maxOfOrNull { shortcutContext -> + priorityScore(shortcutContext) + } ?: Int.MIN_VALUE + } + } + + private fun priorityScore(context: ShortcutContext): Int { + val index = contextPriority.indexOf(context) + return if (index == -1) Int.MIN_VALUE else contextPriority.size - index + } +} diff --git a/app/src/main/java/com/itsaky/androidide/shortcuts/groups/FileManagementGroup.kt b/app/src/main/java/com/itsaky/androidide/shortcuts/groups/FileManagementGroup.kt new file mode 100644 index 0000000000..6fff39379c --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/shortcuts/groups/FileManagementGroup.kt @@ -0,0 +1,43 @@ +package com.itsaky.androidide.shortcuts.groups + +import android.content.Context +import android.view.KeyEvent +import com.itsaky.androidide.actions.file.CloseFileAction +import com.itsaky.androidide.actions.file.SaveFileAction +import com.itsaky.androidide.resources.R +import com.itsaky.androidide.shortcuts.KeyShortcut +import com.itsaky.androidide.shortcuts.ShortcutCategory +import com.itsaky.androidide.shortcuts.ShortcutContext +import com.itsaky.androidide.shortcuts.ShortcutDefinition + +internal class FileManagementGroup : ShortcutGroup { + override fun shortcuts(context: Context): List { + return listOf( + ShortcutDefinition( + id = ShortcutDefinitionIds.SAVE_FILE, + title = context.getString(R.string.save), + bindings = listOf( + KeyShortcut.ctrl(KeyEvent.KEYCODE_S), + ), + category = ShortcutCategory.FILE_MANAGEMENT, + contexts = setOf( + ShortcutContext.EDITOR, + ), + actionId = SaveFileAction.ID, + ), + ShortcutDefinition( + id = ShortcutDefinitionIds.CLOSE_CURRENT_FILE, + title = context.getString(R.string.shortcut_close_current_file), + bindings = listOf( + KeyShortcut.ctrl(KeyEvent.KEYCODE_W), + KeyShortcut.ctrl(KeyEvent.KEYCODE_F4), + ), + category = ShortcutCategory.FILE_MANAGEMENT, + contexts = setOf( + ShortcutContext.EDITOR, + ), + actionId = CloseFileAction.ID, + ), + ) + } +} diff --git a/app/src/main/java/com/itsaky/androidide/shortcuts/groups/ProjectsAndSolutionsGroup.kt b/app/src/main/java/com/itsaky/androidide/shortcuts/groups/ProjectsAndSolutionsGroup.kt new file mode 100644 index 0000000000..8173e278b0 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/shortcuts/groups/ProjectsAndSolutionsGroup.kt @@ -0,0 +1,53 @@ +package com.itsaky.androidide.shortcuts.groups + +import android.content.Context +import android.view.KeyEvent +import com.itsaky.androidide.resources.R +import com.itsaky.androidide.shortcuts.KeyShortcut +import com.itsaky.androidide.shortcuts.ShortcutActionIds +import com.itsaky.androidide.shortcuts.ShortcutCategory +import com.itsaky.androidide.shortcuts.ShortcutContext +import com.itsaky.androidide.shortcuts.ShortcutDefinition + +internal class ProjectsAndSolutionsGroup : ShortcutGroup { + override fun shortcuts(context: Context): List { + return listOf( + ShortcutDefinition( + id = ShortcutDefinitionIds.CREATE_PROJECT, + title = context.getString(R.string.shortcut_create_project), + bindings = listOf( + KeyShortcut.ctrl(KeyEvent.KEYCODE_N), + ), + category = ShortcutCategory.PROJECTS_AND_SOLUTIONS, + contexts = setOf( + ShortcutContext.MAIN, + ), + actionId = ShortcutActionIds.MAIN_CREATE_PROJECT, + ), + ShortcutDefinition( + id = ShortcutDefinitionIds.OPEN_PROJECT, + title = context.getString(R.string.shortcut_open_project), + bindings = listOf( + KeyShortcut.ctrl(KeyEvent.KEYCODE_O), + ), + category = ShortcutCategory.PROJECTS_AND_SOLUTIONS, + contexts = setOf( + ShortcutContext.MAIN, + ), + actionId = ShortcutActionIds.MAIN_OPEN_PROJECT, + ), + ShortcutDefinition( + id = ShortcutDefinitionIds.CLONE_REPOSITORY, + title = context.getString(R.string.shortcut_clone_repository), + bindings = listOf( + KeyShortcut.ctrlShift(KeyEvent.KEYCODE_O), + ), + category = ShortcutCategory.PROJECTS_AND_SOLUTIONS, + contexts = setOf( + ShortcutContext.MAIN, + ), + actionId = ShortcutActionIds.MAIN_CLONE_REPOSITORY, + ), + ) + } +} diff --git a/app/src/main/java/com/itsaky/androidide/shortcuts/groups/SearchAndReplaceGroup.kt b/app/src/main/java/com/itsaky/androidide/shortcuts/groups/SearchAndReplaceGroup.kt new file mode 100644 index 0000000000..215bb4177a --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/shortcuts/groups/SearchAndReplaceGroup.kt @@ -0,0 +1,42 @@ +package com.itsaky.androidide.shortcuts.groups + +import android.content.Context +import android.view.KeyEvent +import com.itsaky.androidide.actions.etc.FindInFileAction +import com.itsaky.androidide.actions.etc.FindInProjectAction +import com.itsaky.androidide.resources.R +import com.itsaky.androidide.shortcuts.KeyShortcut +import com.itsaky.androidide.shortcuts.ShortcutCategory +import com.itsaky.androidide.shortcuts.ShortcutContext +import com.itsaky.androidide.shortcuts.ShortcutDefinition + +internal class SearchAndReplaceGroup : ShortcutGroup { + override fun shortcuts(context: Context): List { + return listOf( + ShortcutDefinition( + id = ShortcutDefinitionIds.FIND_IN_PROJECT, + title = context.getString(R.string.menu_find_project), + bindings = listOf( + KeyShortcut.ctrl(KeyEvent.KEYCODE_F), + ), + category = ShortcutCategory.SEARCH_AND_REPLACE, + contexts = setOf( + ShortcutContext.EDITOR, + ), + actionId = FindInProjectAction.ID, + ), + ShortcutDefinition( + id = ShortcutDefinitionIds.FIND_IN_FILE, + title = context.getString(R.string.menu_find_file), + bindings = listOf( + KeyShortcut.ctrlShift(KeyEvent.KEYCODE_F), + ), + category = ShortcutCategory.SEARCH_AND_REPLACE, + contexts = setOf( + ShortcutContext.EDITOR, + ), + actionId = FindInFileAction.ID, + ), + ) + } +} diff --git a/app/src/main/java/com/itsaky/androidide/shortcuts/groups/ShortcutDefinitionIds.kt b/app/src/main/java/com/itsaky/androidide/shortcuts/groups/ShortcutDefinitionIds.kt new file mode 100644 index 0000000000..00f5931219 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/shortcuts/groups/ShortcutDefinitionIds.kt @@ -0,0 +1,14 @@ +package com.itsaky.androidide.shortcuts.groups + +internal object ShortcutDefinitionIds { + const val OPEN_TERMINAL = "shortcut.openTerminal" + const val OPEN_PREFERENCES = "shortcut.openPreferences" + const val DISMISS_MODAL = "shortcut.dismissModal" + const val CREATE_PROJECT = "shortcut.createProject" + const val OPEN_PROJECT = "shortcut.openProject" + const val CLONE_REPOSITORY = "shortcut.cloneRepository" + const val SAVE_FILE = "shortcut.saveFile" + const val CLOSE_CURRENT_FILE = "shortcut.closeCurrentFile" + const val FIND_IN_PROJECT = "shortcut.findInProject" + const val FIND_IN_FILE = "shortcut.findInFile" +} diff --git a/app/src/main/java/com/itsaky/androidide/shortcuts/groups/ShortcutGroup.kt b/app/src/main/java/com/itsaky/androidide/shortcuts/groups/ShortcutGroup.kt new file mode 100644 index 0000000000..f3b2800e3b --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/shortcuts/groups/ShortcutGroup.kt @@ -0,0 +1,8 @@ +package com.itsaky.androidide.shortcuts.groups + +import android.content.Context +import com.itsaky.androidide.shortcuts.ShortcutDefinition + +interface ShortcutGroup { + fun shortcuts(context: Context): List +} diff --git a/app/src/main/java/com/itsaky/androidide/shortcuts/groups/WindowsAndDisplayGroup.kt b/app/src/main/java/com/itsaky/androidide/shortcuts/groups/WindowsAndDisplayGroup.kt new file mode 100644 index 0000000000..1201dd1cb9 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/shortcuts/groups/WindowsAndDisplayGroup.kt @@ -0,0 +1,57 @@ +package com.itsaky.androidide.shortcuts.groups + +import android.content.Context +import android.view.KeyEvent +import com.itsaky.androidide.actions.sidebar.PreferencesSidebarAction +import com.itsaky.androidide.actions.sidebar.TerminalSidebarAction +import com.itsaky.androidide.resources.R +import com.itsaky.androidide.shortcuts.KeyShortcut +import com.itsaky.androidide.shortcuts.ShortcutActionIds +import com.itsaky.androidide.shortcuts.ShortcutCategory +import com.itsaky.androidide.shortcuts.ShortcutContext +import com.itsaky.androidide.shortcuts.ShortcutDefinition + +internal class WindowsAndDisplayGroup : ShortcutGroup { + override fun shortcuts(context: Context): List { + return listOf( + ShortcutDefinition( + id = ShortcutDefinitionIds.OPEN_TERMINAL, + title = context.getString(R.string.shortcut_open_terminal), + bindings = listOf( + KeyShortcut.ctrlAlt(KeyEvent.KEYCODE_T), + ), + category = ShortcutCategory.WINDOWS_AND_DISPLAY, + contexts = setOf( + ShortcutContext.MAIN, + ShortcutContext.EDITOR, + ), + actionId = TerminalSidebarAction.ID, + ), + ShortcutDefinition( + id = ShortcutDefinitionIds.OPEN_PREFERENCES, + title = context.getString(R.string.shortcut_open_preferences), + bindings = listOf( + KeyShortcut.ctrl(KeyEvent.KEYCODE_COMMA), + ), + category = ShortcutCategory.WINDOWS_AND_DISPLAY, + contexts = setOf( + ShortcutContext.MAIN, + ShortcutContext.EDITOR, + ), + actionId = PreferencesSidebarAction.ID, + ), + ShortcutDefinition( + id = ShortcutDefinitionIds.DISMISS_MODAL, + title = context.getString(android.R.string.cancel), + bindings = listOf( + KeyShortcut.esc(), + ), + category = ShortcutCategory.WINDOWS_AND_DISPLAY, + contexts = setOf( + ShortcutContext.MODAL, + ), + actionId = ShortcutActionIds.DISMISS_MODAL, + ), + ) + } +} diff --git a/app/src/main/java/com/itsaky/androidide/utils/FragmentUtils.kt b/app/src/main/java/com/itsaky/androidide/utils/FragmentUtils.kt new file mode 100644 index 0000000000..fdfd723310 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/utils/FragmentUtils.kt @@ -0,0 +1,33 @@ +package com.itsaky.androidide.utils + +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.FragmentManager + +fun FragmentManager.hasVisibleDialog(): Boolean { + for (fragment in fragments) { + if (fragment is DialogFragment && fragment.dialog?.isShowing == true) { + return true + } + + if (fragment.childFragmentManager.hasVisibleDialog()) { + return true + } + } + return false +} + +fun FragmentManager.dismissTopDialog(): Boolean { + for (fragment in fragments.asReversed()) { + + if (fragment is DialogFragment && fragment.dialog?.isShowing == true) { + fragment.dismissAllowingStateLoss() + return true + } + + if (fragment.childFragmentManager.dismissTopDialog()) { + return true + } + } + + return false +} diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index ca8b98ecd8..3f64fddaa1 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -273,6 +273,16 @@ New Kotlin class Failed to list project files + + Close current file + Create project + Open project + Clone repository + Close file + Open preferences + Find in project + Open terminal + Open with… From d1ecb660548ab8388956659e1aafa9a0b7a7c78f Mon Sep 17 00:00:00 2001 From: John Trujillo Date: Tue, 17 Mar 2026 08:31:17 -0500 Subject: [PATCH 2/5] fix(shortcuts): correct priority scoring using only active contexts refactor(shortcuts): rename saveFile to saveAllFiles for consistency --- .../com/itsaky/androidide/shortcuts/ShortcutManager.kt | 9 ++++----- .../androidide/shortcuts/groups/FileManagementGroup.kt | 2 +- .../androidide/shortcuts/groups/ShortcutDefinitionIds.kt | 2 +- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/shortcuts/ShortcutManager.kt b/app/src/main/java/com/itsaky/androidide/shortcuts/ShortcutManager.kt index 17d05fdffe..b9b14bc3bd 100644 --- a/app/src/main/java/com/itsaky/androidide/shortcuts/ShortcutManager.kt +++ b/app/src/main/java/com/itsaky/androidide/shortcuts/ShortcutManager.kt @@ -53,12 +53,11 @@ class ShortcutManager( .filter { definition -> definition.contexts.any { it in activeContexts } } .filter { definition -> definition.bindings.any { binding -> binding.matches(event) } } - if (matchingDefinitions.isEmpty()) return null - return matchingDefinitions.maxByOrNull { definition -> - definition.contexts.maxOfOrNull { shortcutContext -> - priorityScore(shortcutContext) - } ?: Int.MIN_VALUE + definition.contexts + .filter { it in activeContexts } + .maxOfOrNull(::priorityScore) + ?: Int.MIN_VALUE } } diff --git a/app/src/main/java/com/itsaky/androidide/shortcuts/groups/FileManagementGroup.kt b/app/src/main/java/com/itsaky/androidide/shortcuts/groups/FileManagementGroup.kt index 6fff39379c..c88ef80c1d 100644 --- a/app/src/main/java/com/itsaky/androidide/shortcuts/groups/FileManagementGroup.kt +++ b/app/src/main/java/com/itsaky/androidide/shortcuts/groups/FileManagementGroup.kt @@ -14,7 +14,7 @@ internal class FileManagementGroup : ShortcutGroup { override fun shortcuts(context: Context): List { return listOf( ShortcutDefinition( - id = ShortcutDefinitionIds.SAVE_FILE, + id = ShortcutDefinitionIds.SAVE_ALL_FILES, title = context.getString(R.string.save), bindings = listOf( KeyShortcut.ctrl(KeyEvent.KEYCODE_S), diff --git a/app/src/main/java/com/itsaky/androidide/shortcuts/groups/ShortcutDefinitionIds.kt b/app/src/main/java/com/itsaky/androidide/shortcuts/groups/ShortcutDefinitionIds.kt index 00f5931219..0a6a7bf228 100644 --- a/app/src/main/java/com/itsaky/androidide/shortcuts/groups/ShortcutDefinitionIds.kt +++ b/app/src/main/java/com/itsaky/androidide/shortcuts/groups/ShortcutDefinitionIds.kt @@ -7,7 +7,7 @@ internal object ShortcutDefinitionIds { const val CREATE_PROJECT = "shortcut.createProject" const val OPEN_PROJECT = "shortcut.openProject" const val CLONE_REPOSITORY = "shortcut.cloneRepository" - const val SAVE_FILE = "shortcut.saveFile" + const val SAVE_ALL_FILES = "shortcut.saveAllFiles" const val CLOSE_CURRENT_FILE = "shortcut.closeCurrentFile" const val FIND_IN_PROJECT = "shortcut.findInProject" const val FIND_IN_FILE = "shortcut.findInFile" From babb0e1dcbd76514b1adaeb702c60cbca2108433 Mon Sep 17 00:00:00 2001 From: John Trujillo Date: Thu, 19 Mar 2026 08:18:00 -0500 Subject: [PATCH 3/5] fix: guard `childFragmentManager` access before recursion. --- .../main/java/com/itsaky/androidide/utils/FragmentUtils.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/utils/FragmentUtils.kt b/app/src/main/java/com/itsaky/androidide/utils/FragmentUtils.kt index fdfd723310..b31049cef4 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/FragmentUtils.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/FragmentUtils.kt @@ -9,7 +9,7 @@ fun FragmentManager.hasVisibleDialog(): Boolean { return true } - if (fragment.childFragmentManager.hasVisibleDialog()) { + if (fragment.isAdded && fragment.childFragmentManager.hasVisibleDialog()) { return true } } @@ -24,7 +24,7 @@ fun FragmentManager.dismissTopDialog(): Boolean { return true } - if (fragment.childFragmentManager.dismissTopDialog()) { + if (fragment.isAdded && fragment.childFragmentManager.dismissTopDialog()) { return true } } From c12791c53e4d0ba4eb89b43b4fad191dca7fb0fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?John=20Andr=C3=A9s=20Trujillo?= <34223334+jatezzz@users.noreply.github.com> Date: Thu, 19 Mar 2026 09:33:48 -0500 Subject: [PATCH 4/5] ADFA-3350 | Refactor main screen actions to use ActionsRegistry (#1091) * fix(main): refresh main screen actions on resume refactor(shortcuts): route main screen actions through registry and update bindings * refactor: migrate shortcut actions logging to SLF4J and improve error visibility Add logs for missing action data, registry mismatch, and unresolved action IDs to avoid silent failures --- .../itsaky/androidide/actions/ActionItem.kt | 5 +- .../actions/main/CloneRepositoryAction.kt | 44 ++++++ .../actions/main/CreateProjectAction.kt | 43 +++++ .../actions/main/DeleteProjectAction.kt | 43 +++++ .../androidide/actions/main/DocsAction.kt | 52 +++++++ .../androidide/actions/main/DonateAction.kt | 42 +++++ .../actions/main/OpenProjectAction.kt | 43 +++++ .../actions/main/OpenTerminalAction.kt | 44 ++++++ .../actions/main/PreferencesAction.kt | 44 ++++++ .../androidide/activities/MainActivity.kt | 10 +- .../adapters/MainActionsListAdapter.kt | 21 +-- .../androidide/fragments/MainFragment.kt | 147 ++++++------------ .../androidide/models/MainScreenAction.kt | 137 ---------------- .../shortcuts/IdeShortcutActions.kt | 86 ++-------- .../androidide/shortcuts/KeyShortcut.kt | 37 ++++- .../androidide/shortcuts/ShortcutActionIds.kt | 11 -- .../androidide/shortcuts/ShortcutManager.kt | 7 +- .../groups/ProjectsAndSolutionsGroup.kt | 10 +- .../shortcuts/groups/SearchAndReplaceGroup.kt | 4 +- .../shortcuts/groups/ShortcutDefinitionIds.kt | 1 - .../groups/WindowsAndDisplayGroup.kt | 27 +++- .../androidide/utils/MainScreenActions.kt | 38 +++++ 22 files changed, 542 insertions(+), 354 deletions(-) create mode 100644 app/src/main/java/com/itsaky/androidide/actions/main/CloneRepositoryAction.kt create mode 100644 app/src/main/java/com/itsaky/androidide/actions/main/CreateProjectAction.kt create mode 100644 app/src/main/java/com/itsaky/androidide/actions/main/DeleteProjectAction.kt create mode 100644 app/src/main/java/com/itsaky/androidide/actions/main/DocsAction.kt create mode 100644 app/src/main/java/com/itsaky/androidide/actions/main/DonateAction.kt create mode 100644 app/src/main/java/com/itsaky/androidide/actions/main/OpenProjectAction.kt create mode 100644 app/src/main/java/com/itsaky/androidide/actions/main/OpenTerminalAction.kt create mode 100644 app/src/main/java/com/itsaky/androidide/actions/main/PreferencesAction.kt delete mode 100644 app/src/main/java/com/itsaky/androidide/models/MainScreenAction.kt delete mode 100644 app/src/main/java/com/itsaky/androidide/shortcuts/ShortcutActionIds.kt create mode 100644 app/src/main/java/com/itsaky/androidide/utils/MainScreenActions.kt diff --git a/actions/src/main/java/com/itsaky/androidide/actions/ActionItem.kt b/actions/src/main/java/com/itsaky/androidide/actions/ActionItem.kt index aafb2776b4..3afed20ba3 100644 --- a/actions/src/main/java/com/itsaky/androidide/actions/ActionItem.kt +++ b/actions/src/main/java/com/itsaky/androidide/actions/ActionItem.kt @@ -209,7 +209,10 @@ interface ActionItem { EDITOR_FILE_TREE("ide.editor.fileTree"), /** Location marker for action items shown in UI Designer activity's toolbar. */ - UI_DESIGNER_TOOLBAR("ide.uidesigner.toolbar"); + UI_DESIGNER_TOOLBAR("ide.uidesigner.toolbar"), + + /** Location marker for action items shown on the main screen. */ + MAIN_SCREEN("ide.main.screen"); override fun toString(): String { return id diff --git a/app/src/main/java/com/itsaky/androidide/actions/main/CloneRepositoryAction.kt b/app/src/main/java/com/itsaky/androidide/actions/main/CloneRepositoryAction.kt new file mode 100644 index 0000000000..bf393d14fb --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/actions/main/CloneRepositoryAction.kt @@ -0,0 +1,44 @@ +package com.itsaky.androidide.actions.main + +import android.content.Context +import android.graphics.drawable.Drawable +import androidx.core.content.ContextCompat +import com.itsaky.androidide.R +import com.itsaky.androidide.actions.ActionData +import com.itsaky.androidide.actions.ActionItem +import com.itsaky.androidide.actions.markInvisible +import com.itsaky.androidide.activities.MainActivity +import com.itsaky.androidide.idetooltips.TooltipTag +import com.itsaky.androidide.utils.FeatureFlags + +class CloneRepositoryAction(context: Context) : ActionItem { + + override val id: String = ID + + companion object { + const val ID = "ide.main.cloneRepository" + } + + override var label: String = context.getString(R.string.download_git_project) + override var visible: Boolean = true + override var enabled: Boolean = true + override var icon: Drawable? = ContextCompat.getDrawable(context, R.drawable.ic_clone_repo) + override var requiresUIThread: Boolean = true + override var location: ActionItem.Location = ActionItem.Location.MAIN_SCREEN + override val order: Int = 2 + + override fun retrieveTooltipTag(isReadOnlyContext: Boolean): String = TooltipTag.MAIN_GIT + + override fun prepare(data: ActionData) { + super.prepare(data) + val context = data.get(Context::class.java) + if (!FeatureFlags.isExperimentsEnabled || context !is MainActivity) { + markInvisible() + } + } + + override suspend fun execAction(data: ActionData): Any { + val context = data.get(Context::class.java) as? MainActivity ?: return false + return context.showCloneRepository() + } +} diff --git a/app/src/main/java/com/itsaky/androidide/actions/main/CreateProjectAction.kt b/app/src/main/java/com/itsaky/androidide/actions/main/CreateProjectAction.kt new file mode 100644 index 0000000000..42b405ef51 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/actions/main/CreateProjectAction.kt @@ -0,0 +1,43 @@ +package com.itsaky.androidide.actions.main + +import android.content.Context +import android.graphics.drawable.Drawable +import androidx.core.content.ContextCompat +import com.itsaky.androidide.R +import com.itsaky.androidide.actions.ActionData +import com.itsaky.androidide.actions.ActionItem +import com.itsaky.androidide.actions.markInvisible +import com.itsaky.androidide.activities.MainActivity +import com.itsaky.androidide.idetooltips.TooltipTag + +class CreateProjectAction(context: Context) : ActionItem { + + override val id: String = ID + + companion object { + const val ID = "ide.main.createProject" + } + + override var label: String = context.getString(R.string.create_project) + override var visible: Boolean = true + override var enabled: Boolean = true + override var icon: Drawable? = ContextCompat.getDrawable(context, R.drawable.ic_add) + override var requiresUIThread: Boolean = true + override var location: ActionItem.Location = ActionItem.Location.MAIN_SCREEN + override val order: Int = 0 + + override fun retrieveTooltipTag(isReadOnlyContext: Boolean): String = TooltipTag.PROJECT_NEW + + override fun prepare(data: ActionData) { + super.prepare(data) + val context = data.get(Context::class.java) + if (context !is MainActivity) { + markInvisible() + } + } + + override suspend fun execAction(data: ActionData): Any { + val context = data.get(Context::class.java) as? MainActivity ?: return false + return context.showCreateProject() + } +} diff --git a/app/src/main/java/com/itsaky/androidide/actions/main/DeleteProjectAction.kt b/app/src/main/java/com/itsaky/androidide/actions/main/DeleteProjectAction.kt new file mode 100644 index 0000000000..23fc18802f --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/actions/main/DeleteProjectAction.kt @@ -0,0 +1,43 @@ +package com.itsaky.androidide.actions.main + +import android.content.Context +import android.graphics.drawable.Drawable +import androidx.core.content.ContextCompat +import com.itsaky.androidide.R +import com.itsaky.androidide.actions.ActionData +import com.itsaky.androidide.actions.ActionItem +import com.itsaky.androidide.actions.markInvisible +import com.itsaky.androidide.idetooltips.TooltipTag +import com.itsaky.androidide.viewmodel.MainViewModel + +class DeleteProjectAction(context: Context) : ActionItem { + + override val id: String = ID + + companion object { + const val ID = "ide.main.deleteProject" + } + + override var label: String = context.getString(R.string.msg_delete_existing_project) + override var visible: Boolean = true + override var enabled: Boolean = true + override var icon: Drawable? = ContextCompat.getDrawable(context, R.drawable.ic_delete) + override var requiresUIThread: Boolean = true + override var location: ActionItem.Location = ActionItem.Location.MAIN_SCREEN + override val order: Int = 3 + + override fun retrieveTooltipTag(isReadOnlyContext: Boolean): String = TooltipTag.MAIN_PROJECT_DELETE + + override fun prepare(data: ActionData) { + super.prepare(data) + if (data.get(MainViewModel::class.java) == null || data.get(Context::class.java) == null) { + markInvisible() + } + } + + override suspend fun execAction(data: ActionData): Any { + val viewModel = data.get(MainViewModel::class.java) ?: return false + viewModel.setScreen(MainViewModel.SCREEN_DELETE_PROJECTS) + return true + } +} diff --git a/app/src/main/java/com/itsaky/androidide/actions/main/DocsAction.kt b/app/src/main/java/com/itsaky/androidide/actions/main/DocsAction.kt new file mode 100644 index 0000000000..349107e487 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/actions/main/DocsAction.kt @@ -0,0 +1,52 @@ +package com.itsaky.androidide.actions.main + +import android.content.Context +import android.content.Intent +import android.graphics.drawable.Drawable +import androidx.core.content.ContextCompat +import com.itsaky.androidide.R +import com.itsaky.androidide.actions.ActionData +import com.itsaky.androidide.actions.ActionItem +import com.itsaky.androidide.actions.markInvisible +import com.itsaky.androidide.activities.editor.HelpActivity +import com.itsaky.androidide.idetooltips.TooltipTag +import com.itsaky.androidide.resources.R.string +import org.adfa.constants.CONTENT_KEY +import org.adfa.constants.CONTENT_TITLE_KEY + +class DocsAction(context: Context) : ActionItem { + + override val id: String = ID + + companion object { + const val ID = "ide.main.docs" + } + + override var label: String = context.getString(R.string.btn_docs) + override var visible: Boolean = true + override var enabled: Boolean = true + override var icon: Drawable? = ContextCompat.getDrawable(context, R.drawable.ic_action_help_outlined) + override var requiresUIThread: Boolean = true + override var location: ActionItem.Location = ActionItem.Location.MAIN_SCREEN + override val order: Int = 7 + + override fun retrieveTooltipTag(isReadOnlyContext: Boolean): String = TooltipTag.MAIN_HELP + + override fun prepare(data: ActionData) { + super.prepare(data) + if (data.get(Context::class.java) == null) { + markInvisible() + } + } + + override suspend fun execAction(data: ActionData): Any { + val context = data.get(Context::class.java) ?: return false + val intent = + Intent(context, HelpActivity::class.java).apply { + putExtra(CONTENT_KEY, context.getString(string.docs_url)) + putExtra(CONTENT_TITLE_KEY, context.getString(string.back_to_cogo)) + } + context.startActivity(intent) + return true + } +} diff --git a/app/src/main/java/com/itsaky/androidide/actions/main/DonateAction.kt b/app/src/main/java/com/itsaky/androidide/actions/main/DonateAction.kt new file mode 100644 index 0000000000..caa6f6852d --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/actions/main/DonateAction.kt @@ -0,0 +1,42 @@ +package com.itsaky.androidide.actions.main + +import android.content.Context +import android.graphics.drawable.Drawable +import androidx.core.content.ContextCompat +import com.itsaky.androidide.R +import com.itsaky.androidide.actions.ActionData +import com.itsaky.androidide.actions.ActionItem +import com.itsaky.androidide.actions.markInvisible +import com.itsaky.androidide.actions.requireContext +import com.itsaky.androidide.utils.UrlManager + +class DonateAction(context: Context) : ActionItem { + + override val id: String = ID + + companion object { + const val ID = "ide.main.donate" + } + + override var label: String = context.getString(R.string.btn_donate) + override var visible: Boolean = true + override var enabled: Boolean = true + override var icon: Drawable? = ContextCompat.getDrawable(context, R.drawable.ic_heart) + override var requiresUIThread: Boolean = true + override var location: ActionItem.Location = ActionItem.Location.MAIN_SCREEN + override val order: Int = 6 + + override fun prepare(data: ActionData) { + super.prepare(data) + if (data.get(Context::class.java) == null) { + markInvisible() + } + markInvisible() // Until we allow donations + } + + override suspend fun execAction(data: ActionData): Any { + val context = data.requireContext() + UrlManager.openUrl(context.getString(R.string.sponsor_url), null, context) + return true + } +} diff --git a/app/src/main/java/com/itsaky/androidide/actions/main/OpenProjectAction.kt b/app/src/main/java/com/itsaky/androidide/actions/main/OpenProjectAction.kt new file mode 100644 index 0000000000..948fe43bfb --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/actions/main/OpenProjectAction.kt @@ -0,0 +1,43 @@ +package com.itsaky.androidide.actions.main + +import android.content.Context +import android.graphics.drawable.Drawable +import androidx.core.content.ContextCompat +import com.itsaky.androidide.R +import com.itsaky.androidide.actions.ActionData +import com.itsaky.androidide.actions.ActionItem +import com.itsaky.androidide.actions.markInvisible +import com.itsaky.androidide.activities.MainActivity +import com.itsaky.androidide.idetooltips.TooltipTag + +class OpenProjectAction(context: Context) : ActionItem { + + override val id: String = ID + + companion object { + const val ID = "ide.main.openProject" + } + + override var label: String = context.getString(R.string.msg_open_existing_project) + override var visible: Boolean = true + override var enabled: Boolean = true + override var icon: Drawable? = ContextCompat.getDrawable(context, R.drawable.ic_folder) + override var requiresUIThread: Boolean = true + override var location: ActionItem.Location = ActionItem.Location.MAIN_SCREEN + override val order: Int = 1 + + override fun retrieveTooltipTag(isReadOnlyContext: Boolean): String = TooltipTag.PROJECT_OPEN + + override fun prepare(data: ActionData) { + super.prepare(data) + val context = data.get(Context::class.java) + if (context !is MainActivity) { + markInvisible() + } + } + + override suspend fun execAction(data: ActionData): Any { + val context = data.get(Context::class.java) as? MainActivity ?: return false + return context.showOpenProject() + } +} diff --git a/app/src/main/java/com/itsaky/androidide/actions/main/OpenTerminalAction.kt b/app/src/main/java/com/itsaky/androidide/actions/main/OpenTerminalAction.kt new file mode 100644 index 0000000000..febc8f2be5 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/actions/main/OpenTerminalAction.kt @@ -0,0 +1,44 @@ +package com.itsaky.androidide.actions.main + +import android.content.Context +import android.content.Intent +import android.graphics.drawable.Drawable +import androidx.core.content.ContextCompat +import com.itsaky.androidide.R +import com.itsaky.androidide.actions.ActionData +import com.itsaky.androidide.actions.ActionItem +import com.itsaky.androidide.actions.markInvisible +import com.itsaky.androidide.activities.TerminalActivity +import com.itsaky.androidide.idetooltips.TooltipTag + +class OpenTerminalAction(context: Context) : ActionItem { + + override val id: String = ID + + companion object { + const val ID = "ide.main.openTerminal" + } + + override var label: String = context.getString(R.string.title_terminal) + override var visible: Boolean = true + override var enabled: Boolean = true + override var icon: Drawable? = ContextCompat.getDrawable(context, R.drawable.ic_terminal) + override var requiresUIThread: Boolean = true + override var location: ActionItem.Location = ActionItem.Location.MAIN_SCREEN + override val order: Int = 4 + + override fun retrieveTooltipTag(isReadOnlyContext: Boolean): String = TooltipTag.MAIN_TERMINAL + + override fun prepare(data: ActionData) { + super.prepare(data) + if (data.get(Context::class.java) == null) { + markInvisible() + } + } + + override suspend fun execAction(data: ActionData): Any { + val context = data.get(Context::class.java) ?: return false + context.startActivity(Intent(context, TerminalActivity::class.java)) + return true + } +} diff --git a/app/src/main/java/com/itsaky/androidide/actions/main/PreferencesAction.kt b/app/src/main/java/com/itsaky/androidide/actions/main/PreferencesAction.kt new file mode 100644 index 0000000000..6e70f4c511 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/actions/main/PreferencesAction.kt @@ -0,0 +1,44 @@ +package com.itsaky.androidide.actions.main + +import android.content.Context +import android.content.Intent +import android.graphics.drawable.Drawable +import androidx.core.content.ContextCompat +import com.itsaky.androidide.R +import com.itsaky.androidide.actions.ActionData +import com.itsaky.androidide.actions.ActionItem +import com.itsaky.androidide.actions.markInvisible +import com.itsaky.androidide.activities.PreferencesActivity +import com.itsaky.androidide.idetooltips.TooltipTag + +class PreferencesAction(context: Context) : ActionItem { + + override val id: String = ID + + companion object { + const val ID = "ide.main.preferences" + } + + override var label: String = context.getString(R.string.msg_preferences) + override var visible: Boolean = true + override var enabled: Boolean = true + override var icon: Drawable? = ContextCompat.getDrawable(context, R.drawable.ic_settings) + override var requiresUIThread: Boolean = true + override var location: ActionItem.Location = ActionItem.Location.MAIN_SCREEN + override val order: Int = 5 + + override fun retrieveTooltipTag(isReadOnlyContext: Boolean): String = TooltipTag.MAIN_PREFERENCES + + override fun prepare(data: ActionData) { + super.prepare(data) + if (data.get(Context::class.java) == null) { + markInvisible() + } + } + + override suspend fun execAction(data: ActionData): Any { + val context = data.get(Context::class.java) ?: return false + context.startActivity(Intent(context, PreferencesActivity::class.java)) + return true + } +} diff --git a/app/src/main/java/com/itsaky/androidide/activities/MainActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/MainActivity.kt index cbf35cf213..ef5c45f3d2 100755 --- a/app/src/main/java/com/itsaky/androidide/activities/MainActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/MainActivity.kt @@ -52,6 +52,7 @@ import com.itsaky.androidide.utils.UrlManager import com.itsaky.androidide.utils.findValidProjects import com.itsaky.androidide.utils.flashInfo import com.itsaky.androidide.utils.applyBottomWindowInsetsPadding +import com.itsaky.androidide.utils.MainScreenActions import com.itsaky.androidide.fragments.MainFragment import com.itsaky.androidide.fragments.RecentProjectsFragment import com.itsaky.androidide.shortcuts.IdeShortcutActions @@ -114,8 +115,9 @@ class MainActivity : EdgeToEdgeIDEActivity() { private val binding: ActivityMainBinding get() = checkNotNull(_binding) - override fun onCreate(savedInstanceState: Bundle?) { + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + MainScreenActions.register(this) // Start WebServer after installation is complete startWebServer() @@ -222,9 +224,15 @@ class MainActivity : EdgeToEdgeIDEActivity() { override fun onResume() { super.onResume() + MainScreenActions.register(this) feedbackButtonManager?.loadFabPosition() } + override fun onPause() { + MainScreenActions.clear() + super.onPause() + } + /** * With configChanges="orientation|screenSize", the activity is not recreated on rotation, * so fragment views stay inflated with the initial layout. Replace the visible fragment diff --git a/app/src/main/java/com/itsaky/androidide/adapters/MainActionsListAdapter.kt b/app/src/main/java/com/itsaky/androidide/adapters/MainActionsListAdapter.kt index bdf32e9151..620cb53b6b 100644 --- a/app/src/main/java/com/itsaky/androidide/adapters/MainActionsListAdapter.kt +++ b/app/src/main/java/com/itsaky/androidide/adapters/MainActionsListAdapter.kt @@ -22,8 +22,8 @@ import android.view.View import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView import com.google.android.material.button.MaterialButton +import com.itsaky.androidide.actions.ActionItem import com.itsaky.androidide.databinding.LayoutMainActionItemBinding -import com.itsaky.androidide.models.MainScreenAction /** * Adapter for the actions available on the main screen. @@ -32,7 +32,11 @@ import com.itsaky.androidide.models.MainScreenAction */ class MainActionsListAdapter @JvmOverloads -constructor(val actions: List = emptyList()) : +constructor( + val actions: List = emptyList(), + private val onClick: ((ActionItem, View) -> Unit)? = null, + private val onLongClick: ((ActionItem, View) -> Boolean)? = null, +) : RecyclerView.Adapter() { inner class VH(val binding: LayoutMainActionItemBinding) : RecyclerView.ViewHolder(binding.root) @@ -46,22 +50,19 @@ constructor(val actions: List = emptyList()) : override fun onBindViewHolder(holder: VH, position: Int) { val action = getAction(index = position) val binding = holder.binding - val button = binding.root binding.root.apply { - val originalText = context.getString(action.text) + val originalText = action.label text = originalText - setText(originalText) - setIconResource(action.icon) + isEnabled = action.enabled + icon = action.icon contentDescription = originalText setOnClickListener { - action.onClick?.invoke(action, it) + onClick?.invoke(action, it) } setOnLongClickListener { - action.onLongClick?.invoke(action, it) - true + onLongClick?.invoke(action, it) ?: true } - action.view = button } (binding.root as? MaterialButton)?.findViewById(com.google.android.material.R.id.icon) ?.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO diff --git a/app/src/main/java/com/itsaky/androidide/fragments/MainFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/MainFragment.kt index 8b132a622a..aa14f0c56a 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/MainFragment.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/MainFragment.kt @@ -7,30 +7,16 @@ import android.view.View import android.view.ViewGroup import androidx.fragment.app.activityViewModels import com.itsaky.androidide.R -import com.itsaky.androidide.activities.PreferencesActivity -import com.itsaky.androidide.activities.TerminalActivity import com.itsaky.androidide.activities.editor.HelpActivity import com.itsaky.androidide.adapters.MainActionsListAdapter +import com.itsaky.androidide.actions.ActionData +import com.itsaky.androidide.actions.ActionItem +import com.itsaky.androidide.actions.ActionsRegistry +import com.itsaky.androidide.actions.internal.DefaultActionsRegistry import com.itsaky.androidide.databinding.FragmentMainBinding import com.itsaky.androidide.idetooltips.TooltipManager import com.itsaky.androidide.idetooltips.TooltipTag.MAIN_GET_STARTED -import com.itsaky.androidide.idetooltips.TooltipTag.MAIN_GIT -import com.itsaky.androidide.idetooltips.TooltipTag.MAIN_HELP -import com.itsaky.androidide.idetooltips.TooltipTag.MAIN_PREFERENCES -import com.itsaky.androidide.idetooltips.TooltipTag.MAIN_PROJECT_DELETE -import com.itsaky.androidide.idetooltips.TooltipTag.MAIN_TERMINAL -import com.itsaky.androidide.idetooltips.TooltipTag.PROJECT_NEW -import com.itsaky.androidide.idetooltips.TooltipTag.PROJECT_OPEN -import com.itsaky.androidide.models.MainScreenAction -import com.itsaky.androidide.models.MainScreenAction.Companion.ACTION_CLONE_REPO -import com.itsaky.androidide.models.MainScreenAction.Companion.ACTION_CREATE_PROJECT -import com.itsaky.androidide.models.MainScreenAction.Companion.ACTION_DELETE_PROJECT -import com.itsaky.androidide.models.MainScreenAction.Companion.ACTION_DOCS -import com.itsaky.androidide.models.MainScreenAction.Companion.ACTION_OPEN_PROJECT -import com.itsaky.androidide.models.MainScreenAction.Companion.ACTION_OPEN_TERMINAL -import com.itsaky.androidide.models.MainScreenAction.Companion.ACTION_PREFERENCES import com.itsaky.androidide.viewmodel.MainViewModel -import com.termux.shared.termux.TermuxConstants.TERMUX_APP.TERMUX_ACTIVITY import org.adfa.constants.CONTENT_KEY import org.adfa.constants.CONTENT_TITLE_KEY @@ -42,6 +28,8 @@ class MainFragment : BaseFragment() { const val KEY_TOOLTIP_URL = "tooltip_url" } + private val registry by lazy { ActionsRegistry.getInstance() } + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -51,56 +39,30 @@ class MainFragment : BaseFragment() { return binding!!.root } + private val onClick: (ActionItem, View) -> Unit = { action, _ -> + ifAttached { + val actionData = createActionData() + if (action.enabled && registry is DefaultActionsRegistry) { + (registry as DefaultActionsRegistry).executeAction(action, actionData) + } + } + } + + private val onLongClick: (ActionItem, View) -> Boolean = { action, view -> + ifAttached { + val tag = action.retrieveTooltipTag(false) + if (tag.isNotEmpty()) { + TooltipManager.showIdeCategoryTooltip(requireContext(), view, tag) + } + } + true + } + override fun onViewCreated( view: View, savedInstanceState: Bundle?, ) { super.onViewCreated(view, savedInstanceState) - val actions = - MainScreenAction.mainScreen().also { actions -> - val onClick = { action: MainScreenAction, _: View -> - ifAttached { - when (action.id) { - ACTION_CREATE_PROJECT -> showCreateProject() - ACTION_OPEN_PROJECT -> showViewSavedProjects() - ACTION_CLONE_REPO -> showCloneRepository() - ACTION_DELETE_PROJECT -> pickDirectoryForDeletion() - ACTION_OPEN_TERMINAL -> - startActivity( - Intent(requireActivity(), TerminalActivity::class.java), - ) - - ACTION_PREFERENCES -> gotoPreferences() - - ACTION_DOCS -> { - val intent = - Intent(requireContext(), HelpActivity::class.java).apply { - putExtra(CONTENT_KEY, getString(R.string.docs_url)) - putExtra( - CONTENT_TITLE_KEY, - getString(R.string.back_to_cogo), - ) - } - startActivity(intent) - } - } - } - } - val onLongClick = { action: MainScreenAction, _: View -> - ifAttached { performOptionsMenuClick(action) } - true - } - - actions.forEach { action -> - action.onClick = onClick - action.onLongClick = onLongClick - } - } - - // Portrait: single list. Landscape: first 3 (Create, Open, Delete) in middle, last 3 (Terminal, Preferences, Docs) on right. - val leftActions = if (binding!!.actionsRight != null) actions.take(3) else actions - binding!!.actions.adapter = MainActionsListAdapter(leftActions) - binding!!.actionsRight?.adapter = MainActionsListAdapter(actions.drop(3)) binding!!.headerContainer?.setOnClickListener { ifAttached { openQuickstartPageAction() } } binding!!.headerContainer?.setOnLongClickListener { @@ -121,28 +83,6 @@ class MainFragment : BaseFragment() { binding!!.greetingText.setOnClickListener { ifAttached { openQuickstartPageAction() } } } - private fun performOptionsMenuClick(action: MainScreenAction) { - val view = action.view - val tag = getToolTipTagForAction(action.id) - if (tag.isNotEmpty()) { - view.let { - TooltipManager.showIdeCategoryTooltip(requireContext(), it!!, tag) - } - } - } - - private fun getToolTipTagForAction(id: Int): String = - when (id) { - ACTION_CREATE_PROJECT -> PROJECT_NEW - ACTION_OPEN_PROJECT -> PROJECT_OPEN - ACTION_DELETE_PROJECT -> MAIN_PROJECT_DELETE - ACTION_DOCS -> MAIN_HELP - ACTION_OPEN_TERMINAL -> MAIN_TERMINAL - ACTION_PREFERENCES -> MAIN_PREFERENCES - ACTION_CLONE_REPO -> MAIN_GIT - else -> "" - } - private fun openQuickstartPageAction() { val intent = Intent(requireContext(), HelpActivity::class.java).apply { @@ -152,28 +92,33 @@ class MainFragment : BaseFragment() { startActivity(intent) } - override fun onDestroyView() { - super.onDestroyView() - binding = null + override fun onResume() { + super.onResume() + reloadActions() } - private fun pickDirectoryForDeletion() { - viewModel.setScreen(MainViewModel.SCREEN_DELETE_PROJECTS) - } + private fun reloadActions() { + val actionData = createActionData() + val actions = registry.getActions(ActionItem.Location.MAIN_SCREEN) + .values + .onEach { it.prepare(actionData) } + .filter { it.visible } + .sortedWith(compareBy({ it.order }, { it.id })) - private fun showCreateProject() { - viewModel.setScreen(MainViewModel.SCREEN_TEMPLATE_LIST) - } - - private fun showViewSavedProjects() { - viewModel.setScreen(MainViewModel.SCREEN_SAVED_PROJECTS) + // Portrait: single list. Landscape: first 3 (Create, Open, Delete) in middle, last 3 (Terminal, Preferences, Docs) on right. + val leftActions = if (binding!!.actionsRight != null) actions.take(3) else actions + binding!!.actions.adapter = MainActionsListAdapter(leftActions, onClick, onLongClick) + binding!!.actionsRight?.adapter = MainActionsListAdapter(actions.drop(3), onClick, onLongClick) } - private fun showCloneRepository() { - viewModel.setScreen(MainViewModel.SCREEN_CLONE_REPO) + private fun createActionData(): ActionData { + return ActionData.create(requireActivity()).apply { + put(MainViewModel::class.java, viewModel) + } } - private fun gotoPreferences() { - startActivity(Intent(requireActivity(), PreferencesActivity::class.java)) + override fun onDestroyView() { + super.onDestroyView() + binding = null } } diff --git a/app/src/main/java/com/itsaky/androidide/models/MainScreenAction.kt b/app/src/main/java/com/itsaky/androidide/models/MainScreenAction.kt deleted file mode 100644 index d8c2627e71..0000000000 --- a/app/src/main/java/com/itsaky/androidide/models/MainScreenAction.kt +++ /dev/null @@ -1,137 +0,0 @@ -/* - * This file is part of AndroidIDE. - * - * AndroidIDE 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. - * - * AndroidIDE 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 AndroidIDE. If not, see . - */ - -package com.itsaky.androidide.models - -import android.view.View -import androidx.annotation.DrawableRes -import androidx.annotation.StringRes -import com.itsaky.androidide.resources.R -import com.itsaky.androidide.utils.FeatureFlags - -/** - * An action button shown on the main screen. - * - * @author Akash Yadav - */ -data class MainScreenAction - @JvmOverloads - constructor( - val id: Int, - @StringRes val text: Int, - @DrawableRes val icon: Int, - var onClick: ((MainScreenAction, View) -> Unit)? = null, - var onLongClick: ((MainScreenAction, View) -> Boolean)? = null, - var view: View? = null, - ) { - companion object { - const val ACTION_CREATE_PROJECT = 100 - const val ACTION_OPEN_PROJECT = 110 - const val ACTION_CLONE_REPO = 120 - const val ACTION_OPEN_TERMINAL = 130 - const val ACTION_PREFERENCES = 140 - const val ACTION_DONATE = 150 - const val ACTION_DOCS = 160 - const val ACTION_DELETE_PROJECT = 170 - - /** - * Get all main screen actions. - */ - - val createProject = - MainScreenAction( - ACTION_CREATE_PROJECT, - R.string.create_project, - R.drawable.ic_add, - ) - - val openProject = - MainScreenAction( - ACTION_OPEN_PROJECT, - R.string.msg_open_existing_project, - R.drawable.ic_folder, - ) - - val deleteProject = - MainScreenAction( - ACTION_DELETE_PROJECT, - R.string.msg_delete_existing_project, - R.drawable.ic_delete, - ) - - val openTerminal = - MainScreenAction( - ACTION_OPEN_TERMINAL, - R.string.title_terminal, - R.drawable.ic_terminal, - ) - - val preferences = - MainScreenAction( - ACTION_PREFERENCES, - R.string.msg_preferences, - R.drawable.ic_settings, - ) - - val donate = - MainScreenAction( - ACTION_DONATE, - R.string.btn_donate, - R.drawable.ic_heart, - ) - - val docs = - MainScreenAction( - ACTION_DOCS, - R.string.btn_docs, - R.drawable.ic_action_help_outlined, - ) - - val cloneRepo = - MainScreenAction( - ACTION_CLONE_REPO, - R.string.download_git_project, - R.drawable.ic_clone_repo, - ) - - private val allActions: List = - listOf( - createProject, - openProject, - cloneRepo, - deleteProject, - openTerminal, - preferences, - donate, - docs, - ) - - private val mainActions = allActions.minus(donate) - - @JvmStatic - fun all(): List = allActions - - @JvmStatic - fun mainScreen(): List { - return if (FeatureFlags.isExperimentsEnabled) { - mainActions - } else { - mainActions.minus(cloneRepo) - } - } - } - } diff --git a/app/src/main/java/com/itsaky/androidide/shortcuts/IdeShortcutActions.kt b/app/src/main/java/com/itsaky/androidide/shortcuts/IdeShortcutActions.kt index ea93c62f88..c7043ac5bb 100644 --- a/app/src/main/java/com/itsaky/androidide/shortcuts/IdeShortcutActions.kt +++ b/app/src/main/java/com/itsaky/androidide/shortcuts/IdeShortcutActions.kt @@ -1,26 +1,18 @@ package com.itsaky.androidide.shortcuts -import android.util.Log -import android.content.Context -import android.content.Intent -import androidx.fragment.app.FragmentActivity import com.itsaky.androidide.actions.ActionData import com.itsaky.androidide.actions.ActionItem import com.itsaky.androidide.actions.ActionsRegistry import com.itsaky.androidide.actions.internal.DefaultActionsRegistry -import com.itsaky.androidide.actions.sidebar.PreferencesSidebarAction -import com.itsaky.androidide.actions.sidebar.TerminalSidebarAction -import com.itsaky.androidide.activities.MainActivity -import com.itsaky.androidide.activities.PreferencesActivity -import com.itsaky.androidide.activities.TerminalActivity -import com.itsaky.androidide.utils.dismissTopDialog +import org.slf4j.LoggerFactory /** - * Executes IDE shortcut actions using the actions registry with fallbacks. + * Executes IDE shortcut actions using the actions registry. */ class IdeShortcutActions( private val actionDataProvider: () -> ActionData?, ) { + private val log = LoggerFactory.getLogger(IdeShortcutActions::class.java) private val actionsRegistry: ActionsRegistry get() = ActionsRegistry.getInstance() @@ -31,21 +23,28 @@ class IdeShortcutActions( fun execute(actionId: String): Boolean { val data = actionDataProvider() if (data == null) { - Log.w("IdeShortcutActions", "Missing ActionData for shortcut actionId=$actionId") + log.warn("Missing ActionData for shortcut actionId={}", actionId) return false } - val context = data.get(Context::class.java) - if (actionId == TerminalSidebarAction.ID && context is MainActivity) { - return executeFallback(actionId, data) + val registry = actionsRegistry as? DefaultActionsRegistry + if (registry == null) { + log.warn("ActionsRegistry is not DefaultActionsRegistry for actionId={}", actionId) + return false + } + + val action = findActionById(actionsRegistry, actionId) + if (action == null) { + log.warn("No action found for shortcut actionId={}", actionId) + return false } - val registry = actionsRegistry as? DefaultActionsRegistry ?: return executeFallback(actionId, data) - val action = findActionById(actionsRegistry, actionId) ?: return executeFallback(actionId, data) action.prepare(data) if (!action.enabled) { - return executeFallback(actionId, data) + log.debug("Shortcut action is disabled actionId={}", actionId) + return false } + registry.executeAction(action, data) return true } @@ -62,55 +61,4 @@ class IdeShortcutActions( .mapNotNull { location -> actionsRegistry.findAction(location, actionId) } .firstOrNull() } - - /** - * Executes built-in fallback behaviors when no registered action is found. - */ - private fun executeFallback( - actionId: String, - data: ActionData, - ): Boolean { - return when (actionId) { - ShortcutActionIds.MAIN_CREATE_PROJECT -> { - (data.get(Context::class.java) as? MainActivity) - ?.showCreateProject() - ?: false - } - - ShortcutActionIds.MAIN_OPEN_PROJECT -> { - (data.get(Context::class.java) as? MainActivity) - ?.showOpenProject() - ?: false - } - - ShortcutActionIds.MAIN_CLONE_REPOSITORY -> { - (data.get(Context::class.java) as? MainActivity) - ?.showCloneRepository() - ?: false - } - - ShortcutActionIds.DISMISS_MODAL -> { - val activity = data.get(Context::class.java) as? FragmentActivity ?: return false - activity.supportFragmentManager.dismissTopDialog() - } - - TerminalSidebarAction.ID -> { - val context = data.get(Context::class.java) ?: return false - if (context is MainActivity) { - context.startActivity(Intent(context, TerminalActivity::class.java)) - return true - } - TerminalSidebarAction.startTerminalActivity(data, false) - true - } - - PreferencesSidebarAction.ID -> { - val context = data.get(Context::class.java) ?: return false - context.startActivity(Intent(context, PreferencesActivity::class.java)) - true - } - - else -> false - } - } } diff --git a/app/src/main/java/com/itsaky/androidide/shortcuts/KeyShortcut.kt b/app/src/main/java/com/itsaky/androidide/shortcuts/KeyShortcut.kt index 1ee043a4e5..a24d54df9c 100644 --- a/app/src/main/java/com/itsaky/androidide/shortcuts/KeyShortcut.kt +++ b/app/src/main/java/com/itsaky/androidide/shortcuts/KeyShortcut.kt @@ -7,10 +7,12 @@ data class KeyShortcut( val ctrl: Boolean = false, val shift: Boolean = false, val alt: Boolean = false, + val keyAction: Int = KeyEvent.ACTION_DOWN, + val allowRepeat: Boolean = false, ) { fun matches(event: KeyEvent): Boolean { - if (event.action != KeyEvent.ACTION_DOWN) return false - if (event.repeatCount > 0) return false + if (event.action != keyAction) return false + if (!allowRepeat && event.repeatCount > 0) return false return event.keyCode == keyCode && event.isCtrlPressed == ctrl && @@ -19,25 +21,48 @@ data class KeyShortcut( } companion object { - fun ctrl(keyCode: Int) = KeyShortcut( + fun ctrl( + keyCode: Int, + keyAction: Int = KeyEvent.ACTION_DOWN, + allowRepeat: Boolean = false, + ) = KeyShortcut( keyCode = keyCode, ctrl = true, + keyAction = keyAction, + allowRepeat = allowRepeat, ) - fun ctrlShift(keyCode: Int) = KeyShortcut( + fun ctrlShift( + keyCode: Int, + keyAction: Int = KeyEvent.ACTION_DOWN, + allowRepeat: Boolean = false, + ) = KeyShortcut( keyCode = keyCode, ctrl = true, shift = true, + keyAction = keyAction, + allowRepeat = allowRepeat, ) - fun ctrlAlt(keyCode: Int) = KeyShortcut( + fun ctrlAlt( + keyCode: Int, + keyAction: Int = KeyEvent.ACTION_DOWN, + allowRepeat: Boolean = false, + ) = KeyShortcut( keyCode = keyCode, ctrl = true, alt = true, + keyAction = keyAction, + allowRepeat = allowRepeat, ) - fun esc() = KeyShortcut( + fun esc( + keyAction: Int = KeyEvent.ACTION_DOWN, + allowRepeat: Boolean = false, + ) = KeyShortcut( keyCode = KeyEvent.KEYCODE_ESCAPE, + keyAction = keyAction, + allowRepeat = allowRepeat, ) } } diff --git a/app/src/main/java/com/itsaky/androidide/shortcuts/ShortcutActionIds.kt b/app/src/main/java/com/itsaky/androidide/shortcuts/ShortcutActionIds.kt deleted file mode 100644 index 8a0e0d6797..0000000000 --- a/app/src/main/java/com/itsaky/androidide/shortcuts/ShortcutActionIds.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.itsaky.androidide.shortcuts - -/** - * Central registry of shortcut action IDs used across the IDE. - */ -object ShortcutActionIds { - const val MAIN_CREATE_PROJECT = "ide.main.createProject" - const val MAIN_OPEN_PROJECT = "ide.main.openProject" - const val MAIN_CLONE_REPOSITORY = "ide.main.cloneRepository" - const val DISMISS_MODAL = "ide.app.dismissModal" -} diff --git a/app/src/main/java/com/itsaky/androidide/shortcuts/ShortcutManager.kt b/app/src/main/java/com/itsaky/androidide/shortcuts/ShortcutManager.kt index b9b14bc3bd..7bef063b81 100644 --- a/app/src/main/java/com/itsaky/androidide/shortcuts/ShortcutManager.kt +++ b/app/src/main/java/com/itsaky/androidide/shortcuts/ShortcutManager.kt @@ -49,9 +49,10 @@ class ShortcutManager( event: KeyEvent, activeContexts: Set, ): ShortcutDefinition? { - val matchingDefinitions = catalog - .filter { definition -> definition.contexts.any { it in activeContexts } } - .filter { definition -> definition.bindings.any { binding -> binding.matches(event) } } + val matchingDefinitions = catalog.filter { definition -> + definition.contexts.any(activeContexts::contains) && + definition.bindings.any { it.matches(event) } + } return matchingDefinitions.maxByOrNull { definition -> definition.contexts diff --git a/app/src/main/java/com/itsaky/androidide/shortcuts/groups/ProjectsAndSolutionsGroup.kt b/app/src/main/java/com/itsaky/androidide/shortcuts/groups/ProjectsAndSolutionsGroup.kt index 8173e278b0..7ab45d11e0 100644 --- a/app/src/main/java/com/itsaky/androidide/shortcuts/groups/ProjectsAndSolutionsGroup.kt +++ b/app/src/main/java/com/itsaky/androidide/shortcuts/groups/ProjectsAndSolutionsGroup.kt @@ -2,9 +2,11 @@ package com.itsaky.androidide.shortcuts.groups import android.content.Context import android.view.KeyEvent +import com.itsaky.androidide.actions.main.CloneRepositoryAction +import com.itsaky.androidide.actions.main.CreateProjectAction +import com.itsaky.androidide.actions.main.OpenProjectAction import com.itsaky.androidide.resources.R import com.itsaky.androidide.shortcuts.KeyShortcut -import com.itsaky.androidide.shortcuts.ShortcutActionIds import com.itsaky.androidide.shortcuts.ShortcutCategory import com.itsaky.androidide.shortcuts.ShortcutContext import com.itsaky.androidide.shortcuts.ShortcutDefinition @@ -22,7 +24,7 @@ internal class ProjectsAndSolutionsGroup : ShortcutGroup { contexts = setOf( ShortcutContext.MAIN, ), - actionId = ShortcutActionIds.MAIN_CREATE_PROJECT, + actionId = CreateProjectAction.ID, ), ShortcutDefinition( id = ShortcutDefinitionIds.OPEN_PROJECT, @@ -34,7 +36,7 @@ internal class ProjectsAndSolutionsGroup : ShortcutGroup { contexts = setOf( ShortcutContext.MAIN, ), - actionId = ShortcutActionIds.MAIN_OPEN_PROJECT, + actionId = OpenProjectAction.ID, ), ShortcutDefinition( id = ShortcutDefinitionIds.CLONE_REPOSITORY, @@ -46,7 +48,7 @@ internal class ProjectsAndSolutionsGroup : ShortcutGroup { contexts = setOf( ShortcutContext.MAIN, ), - actionId = ShortcutActionIds.MAIN_CLONE_REPOSITORY, + actionId = CloneRepositoryAction.ID, ), ) } diff --git a/app/src/main/java/com/itsaky/androidide/shortcuts/groups/SearchAndReplaceGroup.kt b/app/src/main/java/com/itsaky/androidide/shortcuts/groups/SearchAndReplaceGroup.kt index 215bb4177a..e349dd6f2c 100644 --- a/app/src/main/java/com/itsaky/androidide/shortcuts/groups/SearchAndReplaceGroup.kt +++ b/app/src/main/java/com/itsaky/androidide/shortcuts/groups/SearchAndReplaceGroup.kt @@ -17,7 +17,7 @@ internal class SearchAndReplaceGroup : ShortcutGroup { id = ShortcutDefinitionIds.FIND_IN_PROJECT, title = context.getString(R.string.menu_find_project), bindings = listOf( - KeyShortcut.ctrl(KeyEvent.KEYCODE_F), + KeyShortcut.ctrlShift(KeyEvent.KEYCODE_F), ), category = ShortcutCategory.SEARCH_AND_REPLACE, contexts = setOf( @@ -29,7 +29,7 @@ internal class SearchAndReplaceGroup : ShortcutGroup { id = ShortcutDefinitionIds.FIND_IN_FILE, title = context.getString(R.string.menu_find_file), bindings = listOf( - KeyShortcut.ctrlShift(KeyEvent.KEYCODE_F), + KeyShortcut.ctrl(KeyEvent.KEYCODE_F), ), category = ShortcutCategory.SEARCH_AND_REPLACE, contexts = setOf( diff --git a/app/src/main/java/com/itsaky/androidide/shortcuts/groups/ShortcutDefinitionIds.kt b/app/src/main/java/com/itsaky/androidide/shortcuts/groups/ShortcutDefinitionIds.kt index 0a6a7bf228..7eb13081a5 100644 --- a/app/src/main/java/com/itsaky/androidide/shortcuts/groups/ShortcutDefinitionIds.kt +++ b/app/src/main/java/com/itsaky/androidide/shortcuts/groups/ShortcutDefinitionIds.kt @@ -3,7 +3,6 @@ package com.itsaky.androidide.shortcuts.groups internal object ShortcutDefinitionIds { const val OPEN_TERMINAL = "shortcut.openTerminal" const val OPEN_PREFERENCES = "shortcut.openPreferences" - const val DISMISS_MODAL = "shortcut.dismissModal" const val CREATE_PROJECT = "shortcut.createProject" const val OPEN_PROJECT = "shortcut.openProject" const val CLONE_REPOSITORY = "shortcut.cloneRepository" diff --git a/app/src/main/java/com/itsaky/androidide/shortcuts/groups/WindowsAndDisplayGroup.kt b/app/src/main/java/com/itsaky/androidide/shortcuts/groups/WindowsAndDisplayGroup.kt index 1201dd1cb9..2807dc8cf0 100644 --- a/app/src/main/java/com/itsaky/androidide/shortcuts/groups/WindowsAndDisplayGroup.kt +++ b/app/src/main/java/com/itsaky/androidide/shortcuts/groups/WindowsAndDisplayGroup.kt @@ -2,11 +2,12 @@ package com.itsaky.androidide.shortcuts.groups import android.content.Context import android.view.KeyEvent +import com.itsaky.androidide.actions.main.OpenTerminalAction +import com.itsaky.androidide.actions.main.PreferencesAction import com.itsaky.androidide.actions.sidebar.PreferencesSidebarAction import com.itsaky.androidide.actions.sidebar.TerminalSidebarAction import com.itsaky.androidide.resources.R import com.itsaky.androidide.shortcuts.KeyShortcut -import com.itsaky.androidide.shortcuts.ShortcutActionIds import com.itsaky.androidide.shortcuts.ShortcutCategory import com.itsaky.androidide.shortcuts.ShortcutContext import com.itsaky.androidide.shortcuts.ShortcutDefinition @@ -22,11 +23,22 @@ internal class WindowsAndDisplayGroup : ShortcutGroup { ), category = ShortcutCategory.WINDOWS_AND_DISPLAY, contexts = setOf( - ShortcutContext.MAIN, ShortcutContext.EDITOR, ), actionId = TerminalSidebarAction.ID, ), + ShortcutDefinition( + id = "${ShortcutDefinitionIds.OPEN_TERMINAL}.main", + title = context.getString(R.string.shortcut_open_terminal), + bindings = listOf( + KeyShortcut.ctrlAlt(KeyEvent.KEYCODE_T), + ), + category = ShortcutCategory.WINDOWS_AND_DISPLAY, + contexts = setOf( + ShortcutContext.MAIN, + ), + actionId = OpenTerminalAction.ID, + ), ShortcutDefinition( id = ShortcutDefinitionIds.OPEN_PREFERENCES, title = context.getString(R.string.shortcut_open_preferences), @@ -35,22 +47,21 @@ internal class WindowsAndDisplayGroup : ShortcutGroup { ), category = ShortcutCategory.WINDOWS_AND_DISPLAY, contexts = setOf( - ShortcutContext.MAIN, ShortcutContext.EDITOR, ), actionId = PreferencesSidebarAction.ID, ), ShortcutDefinition( - id = ShortcutDefinitionIds.DISMISS_MODAL, - title = context.getString(android.R.string.cancel), + id = "${ShortcutDefinitionIds.OPEN_PREFERENCES}.main", + title = context.getString(R.string.shortcut_open_preferences), bindings = listOf( - KeyShortcut.esc(), + KeyShortcut.ctrl(KeyEvent.KEYCODE_COMMA), ), category = ShortcutCategory.WINDOWS_AND_DISPLAY, contexts = setOf( - ShortcutContext.MODAL, + ShortcutContext.MAIN, ), - actionId = ShortcutActionIds.DISMISS_MODAL, + actionId = PreferencesAction.ID, ), ) } diff --git a/app/src/main/java/com/itsaky/androidide/utils/MainScreenActions.kt b/app/src/main/java/com/itsaky/androidide/utils/MainScreenActions.kt new file mode 100644 index 0000000000..f799c5616d --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/utils/MainScreenActions.kt @@ -0,0 +1,38 @@ +package com.itsaky.androidide.utils + +import android.content.Context +import com.itsaky.androidide.actions.ActionItem +import com.itsaky.androidide.actions.ActionsRegistry +import com.itsaky.androidide.actions.main.CloneRepositoryAction +import com.itsaky.androidide.actions.main.CreateProjectAction +import com.itsaky.androidide.actions.main.DeleteProjectAction +import com.itsaky.androidide.actions.main.DonateAction +import com.itsaky.androidide.actions.main.DocsAction +import com.itsaky.androidide.actions.main.OpenProjectAction +import com.itsaky.androidide.actions.main.OpenTerminalAction +import com.itsaky.androidide.actions.main.PreferencesAction + +/** + * Takes care of registering actions to the actions registry for the main screen. + */ +object MainScreenActions { + + @JvmStatic + fun register(context: Context) { + clear() + val registry = ActionsRegistry.getInstance() + registry.registerAction(CreateProjectAction(context)) + registry.registerAction(OpenProjectAction(context)) + registry.registerAction(CloneRepositoryAction(context)) + registry.registerAction(DeleteProjectAction(context)) + registry.registerAction(OpenTerminalAction(context)) + registry.registerAction(PreferencesAction(context)) + registry.registerAction(DonateAction(context)) + registry.registerAction(DocsAction(context)) + } + + @JvmStatic + fun clear() { + ActionsRegistry.getInstance().clearActions(ActionItem.Location.MAIN_SCREEN) + } +} From 1e9747f4fe902b103ee511c8cc3150efc8ae4525 Mon Sep 17 00:00:00 2001 From: John Trujillo Date: Thu, 19 Mar 2026 11:55:48 -0500 Subject: [PATCH 5/5] refactor: extract `.main` shortcut IDs into `ShortcutDefinitionIds` --- .../androidide/shortcuts/groups/ShortcutDefinitionIds.kt | 2 ++ .../androidide/shortcuts/groups/WindowsAndDisplayGroup.kt | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/shortcuts/groups/ShortcutDefinitionIds.kt b/app/src/main/java/com/itsaky/androidide/shortcuts/groups/ShortcutDefinitionIds.kt index 7eb13081a5..80ab64d9d6 100644 --- a/app/src/main/java/com/itsaky/androidide/shortcuts/groups/ShortcutDefinitionIds.kt +++ b/app/src/main/java/com/itsaky/androidide/shortcuts/groups/ShortcutDefinitionIds.kt @@ -2,7 +2,9 @@ package com.itsaky.androidide.shortcuts.groups internal object ShortcutDefinitionIds { const val OPEN_TERMINAL = "shortcut.openTerminal" + const val OPEN_TERMINAL_MAIN = "$OPEN_TERMINAL.main" const val OPEN_PREFERENCES = "shortcut.openPreferences" + const val OPEN_PREFERENCES_MAIN = "$OPEN_PREFERENCES.main" const val CREATE_PROJECT = "shortcut.createProject" const val OPEN_PROJECT = "shortcut.openProject" const val CLONE_REPOSITORY = "shortcut.cloneRepository" diff --git a/app/src/main/java/com/itsaky/androidide/shortcuts/groups/WindowsAndDisplayGroup.kt b/app/src/main/java/com/itsaky/androidide/shortcuts/groups/WindowsAndDisplayGroup.kt index 2807dc8cf0..496d48eb32 100644 --- a/app/src/main/java/com/itsaky/androidide/shortcuts/groups/WindowsAndDisplayGroup.kt +++ b/app/src/main/java/com/itsaky/androidide/shortcuts/groups/WindowsAndDisplayGroup.kt @@ -28,7 +28,7 @@ internal class WindowsAndDisplayGroup : ShortcutGroup { actionId = TerminalSidebarAction.ID, ), ShortcutDefinition( - id = "${ShortcutDefinitionIds.OPEN_TERMINAL}.main", + id = ShortcutDefinitionIds.OPEN_TERMINAL_MAIN, title = context.getString(R.string.shortcut_open_terminal), bindings = listOf( KeyShortcut.ctrlAlt(KeyEvent.KEYCODE_T), @@ -52,7 +52,7 @@ internal class WindowsAndDisplayGroup : ShortcutGroup { actionId = PreferencesSidebarAction.ID, ), ShortcutDefinition( - id = "${ShortcutDefinitionIds.OPEN_PREFERENCES}.main", + id = ShortcutDefinitionIds.OPEN_PREFERENCES_MAIN, title = context.getString(R.string.shortcut_open_preferences), bindings = listOf( KeyShortcut.ctrl(KeyEvent.KEYCODE_COMMA),