diff --git a/.idea/misc.xml b/.idea/misc.xml index 0aba872b6..30ab9a6cb 100755 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -39,6 +39,8 @@ + + @@ -66,11 +68,13 @@ + + 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 7b12c6e75..2d5ee4365 100644 --- a/actions/src/main/java/com/itsaky/androidide/actions/ActionItem.kt +++ b/actions/src/main/java/com/itsaky/androidide/actions/ActionItem.kt @@ -62,16 +62,19 @@ interface ActionItem { enum class Location(val id: String) { /** Location marker for action items shown in editor activity's toolbar. */ - EDITOR_TOOLBAR("editor_toolbar"), + EDITOR_TOOLBAR("editor.activity.toolbar"), /** Location marker for action items shown in editor's text action menu. */ - EDITOR_TEXT_ACTIONS("editor_text_actions"), + EDITOR_TEXT_ACTIONS("editor.textActions"), /** * Location marker for action items shown in 'Code actions' submenu in editor's text action * menu. */ - EDITOR_CODE_ACTIONS("editor_code_actions"); + EDITOR_CODE_ACTIONS("editor.codeActions"), + + /** Location marker for action items show in 'Find & Replace' action mode menu in editor. */ + EDITOR_SEARCH_ACTION_MODE("editor.searchActionMode"); override fun toString(): String { return id diff --git a/app/src/main/java/com/itsaky/androidide/actions/editor/find/GoToNextAction.kt b/app/src/main/java/com/itsaky/androidide/actions/editor/find/GoToNextAction.kt new file mode 100644 index 000000000..ad49591ad --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/actions/editor/find/GoToNextAction.kt @@ -0,0 +1,45 @@ +/* + * 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.actions.editor.find + +import android.content.Context +import androidx.core.content.ContextCompat +import com.itsaky.androidide.R +import com.itsaky.androidide.actions.ActionData + +/** + * 'Go to next' action in editor's search action mode. + * @author Akash Yadav + */ +class GoToNextAction() : SearchActionModeAction() { + override val id: String = "editor.find.next" + + constructor(context: Context) : this() { + label = context.getString(R.string.next) + icon = ContextCompat.getDrawable(context, R.drawable.ic_search_next) + } + + override fun execAction(data: ActionData): Any { + val editor = getEditor(data)!! + if (!editor.searcher.hasQuery()) { + return false + } + + return editor.searcher.gotoNext() + } +} diff --git a/app/src/main/java/com/itsaky/androidide/actions/editor/find/GoToPreviousAction.kt b/app/src/main/java/com/itsaky/androidide/actions/editor/find/GoToPreviousAction.kt new file mode 100644 index 000000000..346102273 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/actions/editor/find/GoToPreviousAction.kt @@ -0,0 +1,46 @@ +/* + * 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.actions.editor.find + +import android.content.Context +import androidx.core.content.ContextCompat +import com.itsaky.androidide.R +import com.itsaky.androidide.R.string +import com.itsaky.androidide.actions.ActionData + +/** + * 'Go to previous' action in editor's search action mode. + * @author Akash Yadav + */ +class GoToPreviousAction() : SearchActionModeAction() { + override val id: String = "editor.find.previous" + + constructor(context: Context) : this() { + label = context.getString(string.previous) + icon = ContextCompat.getDrawable(context, R.drawable.ic_search_previous) + } + + override fun execAction(data: ActionData): Any { + val editor = getEditor(data)!! + if (!editor.searcher.hasQuery()) { + return false + } + + return editor.searcher.gotoPrevious() + } +} diff --git a/app/src/main/java/com/itsaky/androidide/actions/editor/find/ReplaceAction.kt b/app/src/main/java/com/itsaky/androidide/actions/editor/find/ReplaceAction.kt new file mode 100644 index 000000000..b28f0a38d --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/actions/editor/find/ReplaceAction.kt @@ -0,0 +1,87 @@ +/* + * 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.actions.editor.find + +import android.content.Context +import android.view.LayoutInflater +import androidx.appcompat.view.ActionMode +import androidx.core.content.ContextCompat +import com.itsaky.androidide.R +import com.itsaky.androidide.actions.ActionData +import com.itsaky.androidide.databinding.LayoutEditorFindReplaceBinding +import com.itsaky.androidide.utils.DialogUtils +import com.itsaky.androidide.utils.Logger + +/** + * Shows dialog to replace one or all the matched text in editor searcher. + * @author Akash Yadav + */ +class ReplaceAction() : SearchActionModeAction() { + + override val id: String = "editor.search.replace" + private val log = Logger.newInstance(javaClass.simpleName) + + constructor(context: Context) : this() { + label = context.getString(R.string.replace) + icon = ContextCompat.getDrawable(context, R.drawable.ic_search_replace) + } + + override fun execAction(data: ActionData): Any { + val context = data[Context::class.java]!! + val editor = getEditor(data)!! + + if (!editor.searcher.hasQuery()) { + return false + } + + val binding = LayoutEditorFindReplaceBinding.inflate(LayoutInflater.from(context)) + val builder = DialogUtils.newMaterialDialogBuilder(context) + builder.setTitle(R.string.replace) + builder.setView(binding.root) + builder.setNegativeButton(android.R.string.cancel, null) + builder.setPositiveButton(R.string.replace) { dialog, _ -> + dialog.dismiss() + val input = binding.replacementInput.editText + if (input == null) { + log.error("Unable to perform replace action. Input field is null") + return@setPositiveButton + } + + editor.searcher.replaceThis(input.text.toString()) + } + builder.setNeutralButton(R.string.replaceAll) { dialog, _ -> + dialog.dismiss() + val input = binding.replacementInput.editText + if (input == null) { + log.error("Unable to perform replace action. Input field is null") + return@setNeutralButton + } + + editor.searcher.replaceAllAsync(input.text.toString()).whenComplete { _, error -> + if (error != null) { + log.error("Unable to replace all matched text", error) + } else { + editor.post { data[ActionMode::class.java]?.finish() } + } + } + } + builder.show() + + return true + } +} diff --git a/app/src/main/java/com/itsaky/androidide/actions/editor/find/SearchActionModeAction.kt b/app/src/main/java/com/itsaky/androidide/actions/editor/find/SearchActionModeAction.kt new file mode 100644 index 000000000..cc83e257c --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/actions/editor/find/SearchActionModeAction.kt @@ -0,0 +1,27 @@ +/* + * 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.actions.editor.find + +import com.itsaky.androidide.actions.ActionItem +import com.itsaky.androidide.actions.ActionItem.Location +import com.itsaky.androidide.actions.EditorRelatedAction + +/** @author Akash Yadav */ +abstract class SearchActionModeAction : EditorRelatedAction() { + override var location: Location = ActionItem.Location.EDITOR_SEARCH_ACTION_MODE +} diff --git a/app/src/main/java/com/itsaky/androidide/utils/EditorActivityActions.kt b/app/src/main/java/com/itsaky/androidide/utils/EditorActivityActions.kt index b6d2d0fd7..16bbeafc3 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/EditorActivityActions.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/EditorActivityActions.kt @@ -45,6 +45,9 @@ import com.itsaky.androidide.actions.editor.CutAction import com.itsaky.androidide.actions.editor.ExpandSelectionAction import com.itsaky.androidide.actions.editor.PasteAction import com.itsaky.androidide.actions.editor.SelectAllAction +import com.itsaky.androidide.actions.editor.find.GoToNextAction +import com.itsaky.androidide.actions.editor.find.GoToPreviousAction +import com.itsaky.androidide.actions.editor.find.ReplaceAction import com.itsaky.androidide.actions.etc.DaemonStatusAction import com.itsaky.androidide.actions.etc.FileTreeAction import com.itsaky.androidide.actions.etc.FindActionMenu @@ -84,6 +87,11 @@ class EditorActivityActions { registry.registerAction(CopyAction(context)) registry.registerAction(PasteAction(context)) registry.registerAction(FormatCodeAction(context)) + + // editor search actions + registry.registerAction(GoToNextAction(context)) + registry.registerAction(GoToPreviousAction(context)) + registry.registerAction(ReplaceAction(context)) } @JvmStatic diff --git a/app/src/main/java/com/itsaky/androidide/views/editor/IDEEditor.java b/app/src/main/java/com/itsaky/androidide/views/editor/IDEEditor.java index 5fa652324..dc7bac022 100644 --- a/app/src/main/java/com/itsaky/androidide/views/editor/IDEEditor.java +++ b/app/src/main/java/com/itsaky/androidide/views/editor/IDEEditor.java @@ -25,6 +25,7 @@ import android.view.inputmethod.EditorInfo; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatActivity; import com.blankj.utilcode.util.ThreadUtils; import com.itsaky.androidide.R; @@ -34,10 +35,8 @@ import com.itsaky.androidide.language.IDELanguage; import com.itsaky.androidide.lsp.IDELanguageClientImpl; import com.itsaky.androidide.managers.PreferenceManager; import com.itsaky.androidide.syntax.colorschemes.SchemeAndroidIDE; -import com.itsaky.androidide.utils.DialogUtils; import com.itsaky.androidide.utils.Logger; import com.itsaky.lsp.api.ILanguageServer; -import com.itsaky.lsp.models.CodeActionItem; import com.itsaky.lsp.models.Command; import com.itsaky.lsp.models.DefinitionResult; import com.itsaky.lsp.models.DiagnosticItem; @@ -65,6 +64,7 @@ import io.github.rosemoe.sora.event.ContentChangeEvent; import io.github.rosemoe.sora.event.SelectionChangeEvent; import io.github.rosemoe.sora.event.Unsubscribe; import io.github.rosemoe.sora.widget.CodeEditor; +import io.github.rosemoe.sora.widget.IDEEditorSearcher; import io.github.rosemoe.sora.widget.component.EditorAutoCompletion; import io.github.rosemoe.sora.widget.component.EditorTextActionWindow; @@ -73,6 +73,7 @@ public class IDEEditor extends CodeEditor { public static final String KEY_FILE = "editor_file"; private static final Logger LOG = Logger.newInstance("IDEEditor"); private final EditorActionsMenu mActionsPopup; + private IDEEditorSearcher mSearcher; private int mFileVersion; private File file; private ILanguageServer mLanguageServer; @@ -99,6 +100,7 @@ public class IDEEditor extends CodeEditor { mActionsPopup.init(); setColorScheme(new SchemeAndroidIDE()); + setSearcher(new IDEEditorSearcher(this)); getComponent(EditorTextActionWindow.class).setEnabled(false); subscribeEvent(SelectionChangeEvent.class, this::handleSelectionChange); subscribeEvent(ContentChangeEvent.class, this::handleContentChange); @@ -857,15 +859,23 @@ public class IDEEditor extends CodeEditor { return new Range(start, end); } - - /** - * Tells the language client to perform the given code action item. - * - * @param action The action to perform. - */ - public void performCodeAction(CodeActionItem action) { - if (mLanguageClient != null) { - mLanguageClient.performCodeAction(this, action); + + @Override + public IDEEditorSearcher getSearcher() { + return mSearcher; + } + + protected void setSearcher(@NonNull IDEEditorSearcher searcher) { + mSearcher = searcher; + } + + @Override + public void beginSearchMode() { + final var callback = new SearchActionMode(this); + if (getContext() instanceof AppCompatActivity) { + startActionMode (callback); + } else { + LOG.error("Unable start search action mode. Activity must inherit AppCompatActivity."); } } } diff --git a/app/src/main/java/com/itsaky/androidide/views/editor/SearchActionMode.kt b/app/src/main/java/com/itsaky/androidide/views/editor/SearchActionMode.kt new file mode 100644 index 000000000..29cd94f1d --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/views/editor/SearchActionMode.kt @@ -0,0 +1,107 @@ +/* + * 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.views.editor + +import android.annotation.SuppressLint +import android.content.Context +import android.text.TextUtils +import android.view.ActionMode +import android.view.Menu +import android.view.MenuItem +import androidx.appcompat.view.menu.MenuBuilder +import androidx.appcompat.widget.SearchView +import com.itsaky.androidide.actions.ActionData +import com.itsaky.androidide.actions.ActionItem +import com.itsaky.androidide.actions.ActionsRegistry +import io.github.rosemoe.sora.R +import io.github.rosemoe.sora.widget.CodeEditor +import io.github.rosemoe.sora.widget.EditorSearcher.SearchOptions + +/** + * Action mode for find and replace functions in editor. + * @author Akash Yadav + */ +class SearchActionMode(val editor: IDEEditor) : ActionMode.Callback { + + @SuppressLint("RestrictedApi") + override fun onCreateActionMode(mode: ActionMode?, menu: Menu?): Boolean { + + if (mode == null) { + return false + } + + if (menu is MenuBuilder) { + menu.setOptionalIconsVisible(true) + } + + val search = SearchView(editor.context) + search.setOnQueryTextListener(OnQueryListener(editor)) + search.isIconified = false + search.queryHint = editor.context.getString(R.string.text_to_search) + search.performClick() + search.setIconifiedByDefault(false) + mode.customView = search + return true + } + + override fun onPrepareActionMode(mode: ActionMode?, menu: Menu?): Boolean { + if (mode == null || menu == null) { + return false + } + + menu.clear() + val registry = ActionsRegistry.getInstance() + registry.fillMenu(createData(mode), ActionItem.Location.EDITOR_SEARCH_ACTION_MODE, menu) + + return true + } + + override fun onActionItemClicked(mode: ActionMode?, item: MenuItem?): Boolean { + return true + } + + override fun onDestroyActionMode(mode: ActionMode?) { + editor.searcher.stopSearch() + } + + private fun createData(mode: ActionMode): ActionData { + return ActionData().apply { + put(Context::class.java, editor.context) + put(IDEEditor::class.java, editor) + put(CodeEditor::class.java, editor) + put(ActionMode::class.java, mode) + } + } + + private class OnQueryListener(val editor: IDEEditor) : SearchView.OnQueryTextListener { + override fun onQueryTextSubmit(query: String?): Boolean { + editor.searcher.gotoNext() + return false + } + + override fun onQueryTextChange(newText: String?): Boolean { + if (TextUtils.isEmpty(newText)) { + editor.searcher.stopSearch() + return false + } + + editor.searcher.search(newText!!, SearchOptions(false, false)) + return false + } + } +} diff --git a/app/src/main/java/io/github/rosemoe/sora/widget/IDEEditorSearcher.kt b/app/src/main/java/io/github/rosemoe/sora/widget/IDEEditorSearcher.kt new file mode 100644 index 000000000..0426f772a --- /dev/null +++ b/app/src/main/java/io/github/rosemoe/sora/widget/IDEEditorSearcher.kt @@ -0,0 +1,117 @@ +/* + * 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 . + */ + +@file:Suppress("DEPRECATION") + +package io.github.rosemoe.sora.widget + +import android.app.ProgressDialog +import android.widget.Toast +import com.itsaky.androidide.R.string +import com.itsaky.androidide.utils.Logger +import com.itsaky.androidide.views.editor.IDEEditor +import io.github.rosemoe.sora.text.TextUtils +import io.github.rosemoe.sora.util.IntPair +import java.util.concurrent.* + +/** + * Search text in editor. As the constructor of [EditorSearcher] is package private, we cannot + * extend it in another package. So we put this class in the same package. + * + * @author Akash Yadav + */ +open class IDEEditorSearcher(editor: IDEEditor) : EditorSearcher(editor) { + + private val log = Logger.newInstance(javaClass.simpleName) + + protected fun getEditor(): CodeEditor { + try { + val field = EditorSearcher::class.java.getDeclaredField("mEditor") + field.isAccessible = true + return field.get(this) as CodeEditor + } catch (error: Throwable) { + throw RuntimeException("Unable get instance of editor", error) + } + } + + fun replaceAllAsync(replacement: String): CompletableFuture { + val dialog = + ProgressDialog.show( + getEditor().context, + getEditor().context.getString(string.replaceAll), + getEditor().context.getString(string.msg_replacing), + true, + false) + return CompletableFuture.runAsync { replaceAll(replacement) }.thenAccept { + getEditor().post { dialog.dismiss() } + } + } + + override fun replaceAll(replacement: String) { + checkState() + if (!isResultValid) { + Toast.makeText(getEditor().context, "Editor is still preparing", Toast.LENGTH_SHORT) + .show() + return + } + + val res = mLastResults + try { + val sb = getEditor().text.toStringBuilder() + val newLength = replacement.length + if (mOptions.useRegex) { + var delta = 0 + for (i in 0 until res.size()) { + val region = res[i] + val start = IntPair.getFirst(region) + val end = IntPair.getSecond(region) + val oldLength = end - start + sb.replace(start + delta, end + delta, replacement) + delta += newLength - oldLength + } + } else { + var fromIndex = 0 + var foundIndex: Int + while (TextUtils.indexOf(sb, mPattern, mOptions.ignoreCase, fromIndex).also { + foundIndex = it + } != -1) { + sb.replace(foundIndex, foundIndex + mPattern.length, replacement) + fromIndex = foundIndex + newLength + } + } + getEditor().post { + val pos = getEditor().cursor.left() + // stopSearch(); + getEditor() + .text + .replace( + 0, + 0, + getEditor().lineCount - 1, + getEditor().text.getColumnCount(getEditor().lineCount - 1), + sb) + getEditor().setSelectionAround(pos.line, pos.column) + } + } catch (e: Exception) { + log.error("Failed to replace", e) + } + } + + private fun checkState() { + check(hasQuery()) { "pattern not set" } + } +} diff --git a/app/src/main/res/drawable/ic_search_next.xml b/app/src/main/res/drawable/ic_search_next.xml new file mode 100644 index 000000000..8fd5a6c6b --- /dev/null +++ b/app/src/main/res/drawable/ic_search_next.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_search_previous.xml b/app/src/main/res/drawable/ic_search_previous.xml new file mode 100644 index 000000000..d81a72f3b --- /dev/null +++ b/app/src/main/res/drawable/ic_search_previous.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_search_replace.xml b/app/src/main/res/drawable/ic_search_replace.xml new file mode 100644 index 000000000..dcd686788 --- /dev/null +++ b/app/src/main/res/drawable/ic_search_replace.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/layout/layout_editor_find_replace.xml b/app/src/main/res/layout/layout_editor_find_replace.xml new file mode 100644 index 000000000..02d7d0da0 --- /dev/null +++ b/app/src/main/res/layout/layout_editor_find_replace.xml @@ -0,0 +1,44 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5e08e1bd8..df82119d6 100755 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -349,6 +349,10 @@ Visible password flag Use for visible password input type flag in editor. This makes sure that no suggestions are proposed by soft keyboard. No code actions found + Previous + Case sensitive + Regex + Replacing...