diff --git a/src/main/java/com/maddyhome/idea/vim/helper/SearchHighlightsHelper.kt b/src/main/java/com/maddyhome/idea/vim/helper/SearchHighlightsHelper.kt index e27be4d7c8..3597f681be 100644 --- a/src/main/java/com/maddyhome/idea/vim/helper/SearchHighlightsHelper.kt +++ b/src/main/java/com/maddyhome/idea/vim/helper/SearchHighlightsHelper.kt @@ -27,6 +27,7 @@ import com.maddyhome.idea.vim.newapi.ij import com.maddyhome.idea.vim.newapi.vim import com.maddyhome.idea.vim.state.mode.inCommandLineModeWithVisual import com.maddyhome.idea.vim.state.mode.inVisualMode +import com.maddyhome.idea.vim.ui.ex.ExEntryPanel import org.jetbrains.annotations.Contract import java.awt.Font import java.util.* @@ -36,8 +37,9 @@ internal fun updateSearchHighlights( shouldIgnoreSmartCase: Boolean, showHighlights: Boolean, forceUpdate: Boolean, + newCaretPosition: Int? = null, ) { - updateSearchHighlights(null, pattern, 1, shouldIgnoreSmartCase, showHighlights, -1, null, true, forceUpdate) + updateSearchHighlights(null, pattern, 1, shouldIgnoreSmartCase, showHighlights, -1, null, true, forceUpdate, newCaretPosition) } internal fun updateIncsearchHighlights( @@ -63,7 +65,8 @@ internal fun updateIncsearchHighlights( searchStartOffset, searchRange, forwards, - false + false, + null ) } @@ -84,6 +87,12 @@ internal fun addSubstitutionConfirmationHighlight(editor: Editor, start: Int, en ) } +enum class CountMatchesState { + NoCountMatches, + ShowCount, + MaybeClearCount +} + /** * Refreshes current search highlights for all visible editors */ @@ -97,6 +106,7 @@ private fun updateSearchHighlights( searchRange: LineRange?, forwards: Boolean, forceUpdate: Boolean, + newCaretPosition: Int? ): Int { var currentEditorCurrentMatchOffset = -1 @@ -107,15 +117,37 @@ private fun updateSearchHighlights( && (currentEditor == null || it.projectId == currentEditor.projectId) } + val countMatchesSetting = injector.globalOptions().showmatchcount + var editorWithSearchMatches = if (countMatchesSetting) { currentEditor?.ij } else { null } + if (countMatchesSetting && editorWithSearchMatches == null) { + editorWithSearchMatches = injector.editorGroup.getFocusedEditor()?.ij + } + + if (!countMatchesSetting) { + // In case we were counting matches before clear it, the function + // itself ensures nothing happens if it is cleared already + ExEntryPanel.getOrCreatePanelInstance().clearStatusText() + } + + val shouldIgnoreCase = pattern == null || shouldIgnoreCase(pattern, shouldIgnoreSmartCase) + + var maxhlduringincsearch = injector.globalOptions().maxhlduringincsearch + if (maxhlduringincsearch < 0) + maxhlduringincsearch = Int.MAX_VALUE + editors.forEach { val editor = it.ij + val isCurrentEditor = editor == editorWithSearchMatches + var countMatches = if (!isCurrentEditor || !countMatchesSetting) CountMatchesState.NoCountMatches else CountMatchesState.ShowCount + var currentMatchOffset = -1 // Try to keep existing highlights if possible. Update if hlsearch has changed or if the pattern has changed. // Force update for the situations where the text is the same, but the ignore case values have changed. // E.g., Use `*` to search for a word (which ignores smartcase), then use `/` to search for the same pattern, // which will match smartcase. Or changing the smartcase/ignorecase settings - if (shouldRemoveSearchHighlights(editor, pattern, showHighlights) || forceUpdate) { + val clearHighlights = shouldRemoveSearchHighlights(editor, pattern, showHighlights) + if (clearHighlights || forceUpdate) { removeSearchHighlights(editor) } @@ -125,23 +157,21 @@ private fun updateSearchHighlights( // hlsearch (+ incsearch/noincsearch) // Make sure the range fits this editor. Note that Vim will use the same range for all windows. E.g., given // `:1,5s/foo`, Vim will highlight all occurrences of `foo` in the first five lines of all visible windows - val vimEditor = editor.vim - val editorLastLine = vimEditor.lineCount() - 1 - val searchStartLine = searchRange?.startLine ?: 0 - val searchEndLine = (searchRange?.endLine ?: -1).coerceAtMost(editorLastLine) - if (searchStartLine <= editorLastLine) { - val results = - injector.searchHelper.findAll( - vimEditor, - pattern, - searchStartLine, - searchEndLine, - shouldIgnoreCase(pattern, shouldIgnoreSmartCase) - ) - if (results.isNotEmpty()) { - if (editor === currentEditor?.ij) { - currentMatchOffset = findClosestMatch(results, initialOffset, count1, forwards) + val results = findAllMatches(pattern, editor.vim, searchRange, shouldIgnoreCase) + if (results.isNotEmpty()) { + // Only in incsearch is current editor not null, then check result size + val showHighlightsInEditor = currentEditor == null || results.size < maxhlduringincsearch + if (editor == currentEditor?.ij) { + val currentMatchIndex = findClosestMatch(results, initialOffset, count1, forwards) + currentMatchOffset = if (currentMatchIndex == -1) -1 else results[currentMatchIndex].startOffset + + if (!showHighlightsInEditor) { + // Always highlight at least the "current" match in the active editor + highlightSearchResults(editor, pattern, listOf(results[currentMatchIndex]), currentMatchOffset) } + } + + if (showHighlightsInEditor) { highlightSearchResults(editor, pattern, results, currentMatchOffset) } } @@ -172,11 +202,44 @@ private fun updateSearchHighlights( if (offset != null && editor === currentEditor?.ij) { currentMatchOffset = offset } + } else { + countMatches = CountMatchesState.MaybeClearCount } - if (editor === currentEditor?.ij) { + if (!isCurrentEditor) + return@forEach + + var editorCaretOffset = editor.vim.primaryCaret().offset + if (currentEditor?.ij != null) { currentEditorCurrentMatchOffset = currentMatchOffset + editorCaretOffset = currentEditorCurrentMatchOffset + } else if (newCaretPosition != null) { + editorCaretOffset = newCaretPosition } + + if (countMatches == CountMatchesState.NoCountMatches) { + return@forEach + } + + // If any of the following hold we are still searching: + // - We just highlighted some search results (countMatches is not MaybeClear + // - We did not clear highlights ( + // - We are moving towards to a new position + if (countMatches == CountMatchesState.MaybeClearCount && clearHighlights && newCaretPosition == null) { + ExEntryPanel.getOrCreatePanelInstance().clearStatusText() + return@forEach + } + + // Search file for pattern, and determine total and position in results + val results = findAllMatches(pattern, editor.vim, searchRange, shouldIgnoreCase) + val patternIndex = if (results.isEmpty()) { + -1 + } else { + findClosestOrCurrentMatch(results, editorCaretOffset) + } + + val countMessage = "[" + (patternIndex + 1) + "/" + results.size + "]" + ExEntryPanel.getOrCreatePanelInstance().setStatusText(editor, countMessage) } return currentEditorCurrentMatchOffset @@ -207,6 +270,23 @@ private fun shouldAddAllSearchHighlights(editor: Editor, newPattern: String?, hl return hlSearch && newPattern != null && newPattern != editor.vimLastSearch && newPattern != "" } +private fun findAllMatches(pattern: String, vimEditor: VimEditor, searchRange: LineRange?, shouldIgnoreCase: Boolean): List { + val editorLastLine = vimEditor.lineCount() - 1 + val searchStartLine = searchRange?.startLine ?: 0 + val searchEndLine = (searchRange?.endLine ?: -1).coerceAtMost(editorLastLine) + if (searchStartLine > editorLastLine) { + return listOf() + } + + return injector.searchHelper.findAll( + vimEditor, + pattern, + searchStartLine, + searchEndLine, + shouldIgnoreCase + ) +} + private fun findClosestMatch( results: List, initialOffset: Int, @@ -237,7 +317,24 @@ private fun findClosestMatch( return -1 } - return sortedResults[nextIndex % results.size].startOffset + return nextIndex % results.size +} + +private fun findClosestOrCurrentMatch( + results: List, + initialOffset: Int, +): Int { + if (results.isEmpty() || initialOffset == -1) { + return -1 + } + + val firstMatch = results.filter { it.endOffset >= initialOffset }.minByOrNull { it.endOffset } + if (firstMatch == null) { + // Results is not empty but there is no match before offset, we must be past the last match + return results.size - 1 + } + // Note that wrapping for the count does not make sense + return results.indexOfFirst { it.endOffset == firstMatch.endOffset } } internal fun highlightSearchResults( diff --git a/src/main/java/com/maddyhome/idea/vim/newapi/IjVimSearchGroup.kt b/src/main/java/com/maddyhome/idea/vim/newapi/IjVimSearchGroup.kt index a095c91415..e9d1a7815e 100644 --- a/src/main/java/com/maddyhome/idea/vim/newapi/IjVimSearchGroup.kt +++ b/src/main/java/com/maddyhome/idea/vim/newapi/IjVimSearchGroup.kt @@ -81,8 +81,8 @@ open class IjVimSearchGroup : VimSearchGroupBase(), PersistentStateComponent + injector.searchHelper.findAll(fixture.editor.vim, "\\", 0, -1, ignoreCase) + } + + // Ensure ignore and smart case are off + enterCommand("set noignorecase") + enterCommand("set nosmartcase") + + kotlin.test.assertEquals(listOf(capitalMatch), search(false)) + // Even if both are off, ignore case should still ignore cases + kotlin.test.assertEquals(listOf(capitalMatch, lowerMatch), search(true)) + + enterCommand("set smartcase") + kotlin.test.assertEquals(listOf(capitalMatch), search(false)) + kotlin.test.assertEquals(listOf(capitalMatch, lowerMatch), search(true)) + } + @Test fun testMotionOuterWordAction() { doTest( diff --git a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/OptionProperties.kt b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/OptionProperties.kt index 5116b778e7..c87d1aa4ee 100644 --- a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/OptionProperties.kt +++ b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/OptionProperties.kt @@ -50,6 +50,8 @@ open class GlobalOptions(scope: OptionAccessScope) : OptionsPropertiesBase(scope var wrapscan: Boolean by optionProperty(Options.wrapscan) // IdeaVim specific options. Put any editor or IDE specific options in IjOptionProperties + var maxhlduringincsearch: Int by optionProperty(Options.maxhlduringincsearch) + var showmatchcount: Boolean by optionProperty(Options.showmatchcount) // Temporary flags for work-in-progress behaviour. Hidden from the output of `:set all` var ideastrictmode: Boolean by optionProperty(Options.ideastrictmode) diff --git a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/Options.kt b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/Options.kt index f2ef52cd77..966f59dcdb 100644 --- a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/Options.kt +++ b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/Options.kt @@ -136,6 +136,7 @@ object Options { ) ) ) + val maxhlduringincsearch: NumberOption = addOption(NumberOption(name="maxhlduringincsearch", GLOBAL, "maxhld", 100, -1)) val maxmapdepth: NumberOption = addOption(NumberOption("maxmapdepth", GLOBAL, "mmd", 20)) val more: ToggleOption = addOption(ToggleOption("more", GLOBAL, "more", true)) val nrformats: StringListOption = addOption( @@ -182,6 +183,7 @@ object Options { ) ) val showcmd: ToggleOption = addOption(ToggleOption("showcmd", GLOBAL, "sc", true)) + val showmatchcount: ToggleOption = addOption(ToggleOption("showmatchcount", GLOBAL, "smc", false)) val showmode: ToggleOption = addOption(ToggleOption("showmode", GLOBAL, "smd", true)) val sidescroll: NumberOption = addOption(UnsignedNumberOption("sidescroll", GLOBAL, "ss", 0)) val sidescrolloff: NumberOption = addOption( diff --git a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimSearchGroupBase.kt b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimSearchGroupBase.kt index 15393a807d..1cc3e63085 100644 --- a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimSearchGroupBase.kt +++ b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimSearchGroupBase.kt @@ -106,7 +106,7 @@ abstract class VimSearchGroupBase : VimSearchGroup { * * @param force Whether to force this update. */ - protected abstract fun updateSearchHighlights(force: Boolean) + protected abstract fun updateSearchHighlights(force: Boolean, newCaretPosition: Int? = null) /** * Reset the search highlights to the last used pattern after highlighting incsearch results. @@ -441,11 +441,13 @@ abstract class VimSearchGroupBase : VimSearchGroup { lastPatternTrailing = "" lastDirection = dir - setShouldShowSearchHighlights() - updateSearchHighlights(true) val offset = findItOffset(editor, range.startOffset, count, lastDirection)?.first ?: -1 - return if (offset == -1) range.startOffset else offset + + val newOffset = if (offset == -1) range.startOffset else offset + setShouldShowSearchHighlights() + updateSearchHighlights(true, newOffset) + return newOffset } override fun findEndOfPattern( @@ -518,9 +520,6 @@ abstract class VimSearchGroupBase : VimSearchGroup { count: Int, dir: Direction, ): Int { - setShouldShowSearchHighlights() - updateSearchHighlights(false) - val startOffset: Int = caret.offset var offset = findItOffset(editor, startOffset, count, dir)?.first ?: -1 if (offset == startOffset) { @@ -529,6 +528,9 @@ abstract class VimSearchGroupBase : VimSearchGroup { * in the buffer: Repeat with count + 1. */ offset = findItOffset(editor, startOffset, count + 1, dir)?.first ?: -1 } + + setShouldShowSearchHighlights() + updateSearchHighlights(false, offset) return offset } diff --git a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimSearchHelperBase.kt b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimSearchHelperBase.kt index 12b1a26953..ee9947d5ff 100644 --- a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimSearchHelperBase.kt +++ b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimSearchHelperBase.kt @@ -385,8 +385,9 @@ abstract class VimSearchHelperBase : VimSearchHelper { ignoreCase: Boolean, ): List { val options = enumSetOf() - if (injector.globalOptions().smartcase) options.add(VimRegexOptions.SMART_CASE) - if (injector.globalOptions().ignorecase) options.add(VimRegexOptions.IGNORE_CASE) + // If we are explicitly asked to ignore case, assume that smartcase check has already taken place + if (injector.globalOptions().smartcase && !ignoreCase) options.add(VimRegexOptions.SMART_CASE) + if (injector.globalOptions().ignorecase || ignoreCase) options.add(VimRegexOptions.IGNORE_CASE) val regex = try { VimRegex(pattern) } catch (e: VimRegexException) {