diff --git a/app/src/main/java/com/itsaky/androidide/actions/file/ShowTooltipAction.kt b/app/src/main/java/com/itsaky/androidide/actions/file/ShowTooltipAction.kt index fdb0b0bf2a..533a8aa6b1 100644 --- a/app/src/main/java/com/itsaky/androidide/actions/file/ShowTooltipAction.kt +++ b/app/src/main/java/com/itsaky/androidide/actions/file/ShowTooltipAction.kt @@ -23,6 +23,8 @@ import com.itsaky.androidide.R import com.itsaky.androidide.actions.ActionData import com.itsaky.androidide.actions.ActionItem import com.itsaky.androidide.actions.BaseEditorAction +import com.itsaky.androidide.editor.utils.isJavaOperatorToken +import com.itsaky.androidide.editor.utils.isKotlinOperatorToken import com.itsaky.androidide.editor.utils.isXmlAttribute import com.itsaky.androidide.idetooltips.TooltipCategory import com.itsaky.androidide.idetooltips.TooltipManager @@ -58,29 +60,19 @@ class ShowTooltipAction(private val context: Context, override val order: Int) : val anchorView = target.getAnchorView() ?: return false val editor = getEditor(data) - val category: String - val tag: String - - if (editor != null) { - val selectedText = target.getSelectedText() - category = when (editor.file?.extension) { - "java" -> TooltipCategory.CATEGORY_JAVA - "kt" -> TooltipCategory.CATEGORY_KOTLIN - "xml" -> TooltipCategory.CATEGORY_XML - else -> TooltipCategory.CATEGORY_IDE - } - - val useEditorTag = editor.tag != null - val textToUse = selectedText ?: "" - tag = when { - useEditorTag -> editor.tag.toString() - category == TooltipCategory.CATEGORY_XML && editor.isXmlAttribute() -> textToUse.substringAfterLast(":") - else -> textToUse + val categoryAndTag = + if (editor != null) { + val category = tooltipCategoryForExtension(editor.file?.extension) + resolveTooltipTag( + category = category, + selectedText = target.getSelectedText(), + editorTag = editor.tag?.toString(), + isXmlAttribute = category == TooltipCategory.CATEGORY_XML && editor.isXmlAttribute(), + ).let { tag -> category to tag } + } else { + TooltipCategory.CATEGORY_IDE to TooltipTag.DIALOG_FIND_IN_PROJECT } - } else { - category = TooltipCategory.CATEGORY_IDE - tag = TooltipTag.DIALOG_FIND_IN_PROJECT - } + val (category, tag) = categoryAndTag if (tag.isEmpty()) return false @@ -95,4 +87,28 @@ class ShowTooltipAction(private val context: Context, override val order: Int) : } override fun retrieveTooltipTag(isAlternateContext: Boolean) = TooltipTag.EDITOR_TOOLBAR_HELP +} + +internal fun tooltipCategoryForExtension(extension: String?): String = + when (extension) { + "java" -> TooltipCategory.CATEGORY_JAVA + "kt" -> TooltipCategory.CATEGORY_KOTLIN + "xml" -> TooltipCategory.CATEGORY_XML + else -> TooltipCategory.CATEGORY_IDE + } + +internal fun resolveTooltipTag( + category: String, + selectedText: String?, + editorTag: String?, + isXmlAttribute: Boolean, +): String { + val textToUse = selectedText ?: "" + return when { + !editorTag.isNullOrEmpty() -> editorTag + category == TooltipCategory.CATEGORY_XML && isXmlAttribute -> textToUse.substringAfterLast(":") + category == TooltipCategory.CATEGORY_KOTLIN && isKotlinOperatorToken(textToUse) -> "kotlin.operator.$textToUse" + category == TooltipCategory.CATEGORY_JAVA && isJavaOperatorToken(textToUse) -> "java.operator.$textToUse" + else -> textToUse + } } \ No newline at end of file diff --git a/app/src/main/java/com/itsaky/androidide/fragments/EmptyStateFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/EmptyStateFragment.kt index ad19f86ec9..6754ef416b 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/EmptyStateFragment.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/EmptyStateFragment.kt @@ -54,7 +54,7 @@ abstract class EmptyStateFragment : FragmentWithBinding { open fun onFragmentLongPressed() { val currentEditor = currentEditor ?: return - currentEditor.selectCurrentWord() + currentEditor.selectWordOrOperatorAtCursor() } private val gestureListener = diff --git a/editor/src/main/java/com/itsaky/androidide/editor/ui/IDEEditor.kt b/editor/src/main/java/com/itsaky/androidide/editor/ui/IDEEditor.kt index 2fa6143c9e..89d4be990f 100644 --- a/editor/src/main/java/com/itsaky/androidide/editor/ui/IDEEditor.kt +++ b/editor/src/main/java/com/itsaky/androidide/editor/ui/IDEEditor.kt @@ -45,6 +45,7 @@ import com.itsaky.androidide.editor.language.treesitter.TreeSitterLanguage import com.itsaky.androidide.editor.language.treesitter.TreeSitterLanguageProvider import com.itsaky.androidide.editor.processing.ProcessContext import com.itsaky.androidide.editor.processing.TextProcessorEngine +import com.itsaky.androidide.editor.utils.getOperatorRangeAt import com.itsaky.androidide.editor.schemes.IDEColorScheme import com.itsaky.androidide.editor.schemes.IDEColorSchemeProvider import com.itsaky.androidide.editor.snippets.AbstractSnippetVariableResolver @@ -1231,4 +1232,21 @@ constructor( log.error("Error setting selection from point", e) } } + + /** + * Selects the word at the cursor, or if none (e.g. on an operator), selects + * the operator at the cursor so the code-action toolbar can be shown. + */ + fun selectWordOrOperatorAtCursor() { + if (isReleased) return + selectCurrentWord() + if (cursor.isSelected) return + val line = cursor.leftLine + val column = cursor.leftColumn + val columnCount = text.getColumnCount(line) + if (column < 0 || column >= columnCount) return + val range = text.getOperatorRangeAt(line, column) ?: return + val (startCol, endCol) = range + setSelectionRegion(line, startCol, line, endCol) + } } diff --git a/editor/src/main/java/com/itsaky/androidide/editor/utils/OperatorSelection.kt b/editor/src/main/java/com/itsaky/androidide/editor/utils/OperatorSelection.kt new file mode 100644 index 0000000000..5fa1fc8c19 --- /dev/null +++ b/editor/src/main/java/com/itsaky/androidide/editor/utils/OperatorSelection.kt @@ -0,0 +1,157 @@ +/* + * 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.editor.utils + +import io.github.rosemoe.sora.text.Content + +/** + * Java/Kotlin operators for long-press selection, ordered by length descending + * so longer matches are tried first (e.g. `>>>=` before `>>>` before `>>` before `>`). + */ +private val OPERATORS: List = + listOf( + // 4-char (`>>>=` must precede `>>>` — the latter is a prefix) + ">>>=", + // 3-char (=== and !== before 2-char == and !=) + "===", + "!==", + ">>>", + "<<=", + ">>=", + // 2-char + "==", + "!=", + "<=", + ">=", + "+=", + "-=", + "*=", + "/=", + "%=", + "&=", + "|=", + "^=", + "++", + "--", + "&&", + "||", + "<<", + ">>", + "->", + "?.", + "?:", + "..", + "!!", + "::", + // 1-char + "+", + "-", + "*", + "/", + "%", + "=", + "<", + ">", + "!", + "&", + "|", + "^", + "~", + "?", + ":", + ";", + ",", + ".", + "@", + "(", + ")", + "[", + "]", + "{", + "}", + ) + +private val OPERATOR_SET: Set = OPERATORS.toSet() + +/** + * Tokens matched for editor selection that exist only in Kotlin, not Java. + * Used so [isJavaOperatorToken] does not emit `java.operator.*` tags for these. + */ +private val KOTLIN_ONLY_OPERATOR_TOKENS: Set = + setOf( + "?.", + "?:", + "..", + "!!", + "===", + "!==", + ) + +private val JAVA_OPERATOR_SET: Set = OPERATOR_SET - KOTLIN_ONLY_OPERATOR_TOKENS + +/** + * Returns true when [text] is exactly one Kotlin operator/punctuation token for tooltip lookup + * (same token set as the long-press operator list). + */ +fun isKotlinOperatorToken(text: CharSequence): Boolean { + if (text.isEmpty()) return false + return OPERATOR_SET.contains(text.toString()) +} + +/** + * Returns true when [text] is exactly one Java operator/punctuation token for tooltip lookup. + * Excludes Kotlin-only tokens such as `?.`, `..`, `===`, and `!==`. + */ +fun isJavaOperatorToken(text: CharSequence): Boolean { + if (text.isEmpty()) return false + return JAVA_OPERATOR_SET.contains(text.toString()) +} + +/** + * Returns the column range of the operator at the given position, if any. + * Columns are 0-based; endColumn is exclusive (one past the last character). + * + * @param lineContent The full line text. + * @param column Cursor column (0-based). + * @return (startColumn, endColumnExclusive) or null if no operator at this position. + */ +fun getOperatorRangeAt(lineContent: CharSequence, column: Int): Pair? { + if (column < 0 || column >= lineContent.length) return null + val suffix = lineContent.subSequence(column, lineContent.length) + for (op in OPERATORS) { + if (op.length <= suffix.length && suffix.subSequence(0, op.length) == op) { + return column to (column + op.length) + } + } + return null +} + +/** + * Returns the column range of the operator at (line, column) in [content], if any. + * Columns are 0-based; endColumn is exclusive. + */ +fun Content.getOperatorRangeAt(line: Int, column: Int): Pair? { + if (line < 0 || line >= lineCount) return null + val lineContent = getLine(line) + val maxColumn = lineContent.length + if (column < 0 || column > maxColumn) return null + val range = getOperatorRangeAt(lineContent, column) ?: return null + val (start, end) = range + if (end > maxColumn) return null + return range +}