1
0
mirror of https://github.com/chylex/IntelliJ-AceJump.git synced 2025-05-13 16:34:03 +02:00
IntelliJ-AceJump/src/main/kotlin/org/acejump/search/Finder.kt

229 lines
7.0 KiB
Kotlin

package org.acejump.search
import com.intellij.openapi.diagnostic.Logger
import com.intellij.openapi.editor.markup.HighlighterLayer
import com.intellij.openapi.editor.markup.HighlighterTargetArea.EXACT_RANGE
import com.intellij.openapi.editor.markup.RangeHighlighter
import org.acejump.config.AceConfig
import org.acejump.control.Handler
import org.acejump.control.Trigger
import org.acejump.label.Pattern
import org.acejump.label.Tagger
import org.acejump.view.Boundary
import org.acejump.view.Marker
import org.acejump.view.Model.LONG_DOCUMENT
import org.acejump.view.Model.boundaries
import org.acejump.view.Model.editor
import org.acejump.view.Model.editorText
import org.acejump.view.Model.markup
import org.acejump.view.Model.viewBounds
import java.util.*
import kotlin.math.max
import kotlin.math.min
import kotlin.system.measureTimeMillis
/**
* Singleton that searches for text in editor and highlights matching results.
*/
object Finder : Resettable {
@Volatile
private var results: SortedSet<Int> = sortedSetOf()
@Volatile
private var textHighlights = listOf<RangeHighlighter>()
private var HIGHLIGHT_LAYER = HighlighterLayer.LAST + 1
private val logger = Logger.getInstance(Finder::class.java)
private val skimTrigger = Trigger()
var isShiftSelectEnabled = false
var skim = false
private set
@Volatile
var query: String = ""
set(value) {
field = value
if (query.isNotEmpty()) logger.info("Received query: \"$value\"")
isShiftSelectEnabled = value.lastOrNull()?.isUpperCase() == true
when {
value.isEmpty() -> return
Tagger.regex -> search()
value.length == 1 -> skimThenSearch()
value.isValidQuery() -> skimThenSearch()
else -> {
logger.info("Invalid query \"$field\", dropping: ${field.last()}")
field = field.dropLast(1)
}
}
}
/**
* A user has two possible intentions when launching an AceJump search.
*
* 1. To locate the position of a known string in the document (a.k.a. Find)
* 2. To reposition the caret to a known location (i.e. staring at location)
*
* Since we cannot know why the user initiated any query a priori, here we
* attempt to satisfy both goals. First, we highlight all matches on (or off)
* the screen. This operation has very low latency. As soon as the user types
* a single character, we highlight all matches immediately. If we should
* receive no further characters after a short delay (indicating a pause in
* typing cadence), then we apply tags.
*
* Typically when a user searches for a known string, they will type several
* characters in rapid succession. We can avoid unnecessary work by only
* applying tags once we have received a "chunk" of search text.
*/
private fun skimThenSearch() {
if (results.size == 0 && LONG_DOCUMENT && AceConfig.searchWholeFile) {
logger.info("Skimming document for matches of: $query")
skim = true
search()
skimTrigger(400L) { skim = false; search() }
}
else {
search()
}
}
fun search(pattern: Pattern, bounds: Boundary) {
logger.info("Searching for regular expression: ${pattern.name} in $bounds")
search(pattern.string, bounds)
}
fun search(pattern: String, bounds: Boundary) {
boundaries = bounds
// TODO: Fix this broken reset
reset()
Tagger.reset()
search(AceFindModel(pattern, true))
}
fun search(model: AceFindModel = AceFindModel(query)) {
val time = measureTimeMillis {
results = Scanner.find(model, calculateSearchBoundaries(), results)
}
logger.info("Found ${results.size} matching sites in $time ms")
markResults(results, model)
}
private fun calculateSearchBoundaries(): IntRange {
if (AceConfig.searchWholeFile) {
return boundaries.intRange()
}
val bounds1 = boundaries
val bounds2 = Boundary.SCREEN_BOUNDARY
return max(bounds1.start, bounds2.start)..min(bounds1.endInclusive, bounds2.endInclusive)
}
/**
* This method is used by IdeaVim integration plugin and must not be inlined.
*
* By default, when this function is called externally, [results] are already
* collected and [AceFindModel] should be empty. Additionally, if the flag
* [AceFindModel.isRegularExpressions] is true only one symbol is highlighted.
*/
@ExternalUsage
fun markResults(results: SortedSet<Int>,
model: AceFindModel = AceFindModel("", true)
) {
markup(results, model.isRegularExpressions)
if (!skim) tag(model, results)
}
/**
* Paints text highlights beneath each query result to the editor using the
* [com.intellij.openapi.editor.markup.MarkupModel].
*/
fun markup(markers: Set<Int> = results, isRegexQuery: Boolean = false) {
if (markers.isEmpty()) {
return
}
runLater {
val highlightLen = if (isRegexQuery) 1 else query.length
editor.document.isInBulkUpdate = true
textHighlights.forEach { markup.removeHighlighter(it) }
textHighlights = markers.map {
val start = it - if (it == editorText.length) 1 else 0
val end = start + highlightLen
createTextHighlight(max(start, 0), min(end, editorText.length - 1))
}
editor.document.isInBulkUpdate = false
}
}
private fun createTextHighlight(start: Int, end: Int) =
markup.addRangeHighlighter(start, end, HIGHLIGHT_LAYER, null, EXACT_RANGE)
.apply { customRenderer = Marker.Companion }
private fun tag(model: AceFindModel, results: SortedSet<Int>) {
synchronized(this) { Tagger.markOrJump(model, results) }
val (ivb, ovb) = textHighlights.partition { it.startOffset in viewBounds }
ivb.cull()
runLater { ovb.cull() }
if (model.stringToFind == query || model.isRegularExpressions)
Handler.repaintTagMarkers()
}
/**
* Erases highlights which are no longer compatible with the current query.
*/
private fun List<RangeHighlighter>.cull() =
eraseIf { Tagger canDiscard startOffset }
.also { newHighlights ->
val numDiscarded = size - newHighlights.size
if (numDiscarded != 0) logger.info("Discarded $numDiscarded highlights")
}
fun List<RangeHighlighter>.eraseIf(cond: RangeHighlighter.() -> Boolean): List<RangeHighlighter> {
val (erased, kept) = partition(cond)
if (erased.isNotEmpty()) {
runLater {
editor.document.isInBulkUpdate = true
erased.forEach { markup.removeHighlighter(it) }
editor.document.isInBulkUpdate = false
}
}
return kept
}
fun visibleResults() = results.filter { it in viewBounds }
private fun String.isValidQuery() =
Tagger.hasTagSuffixInView(query) ||
results.any {
editorText.regionMatches(
thisOffset = it,
other = this,
otherOffset = 0,
length = length,
ignoreCase = true
)
}
override fun reset() {
runLater {
editor.document.isInBulkUpdate = true
markup.removeAllHighlighters()
editor.document.isInBulkUpdate = false
}
query = ""
skim = false
results = sortedSetOf()
textHighlights = listOf()
}
}