From 04c151926118a534035300e040b3bc40005bd70b Mon Sep 17 00:00:00 2001
From: chylex <contact@chylex.com>
Date: Thu, 5 Sep 2024 01:08:08 +0200
Subject: [PATCH] Optimize screen visibility checks during search

When 'Search whole file' is disabled, search results are tested for visibility on the screen. There is already an optimization that only checks results on visible lines, but the number of offset-to-XY conversions still scales linearly with the number of results, which can be very large in files with long lines.

A simple observation is that every line has a first and last offset that is visible on the screen (which may be different for each line due to proportional fonts).

This commit caches the visible offset range for every line involved in one search query, so testing visibility of a search result becomes a check if its offset is inside its line's visible offset range. Finding the visible offset range requires two XY-to-offset conversions per line, so the total number of conversions is bounded by the number of lines that can fit on the screen.

The worst case for this optimization is when every line has exactly one search result; before, this would lead to one offset-to-XY conversion per line, whereas now it leads to two XY-to-offset conversions per line. However, the maximum number of conversions is twice the number of visible lines, which will generally be very small.
---
 .../acejump/boundaries/EditorOffsetCache.kt   | 33 +++++++++++++++++++
 .../acejump/boundaries/StandardBoundaries.kt  | 17 +---------
 .../org/acejump/search/SearchProcessor.kt     |  9 ++---
 3 files changed, 39 insertions(+), 20 deletions(-)

diff --git a/src/main/kotlin/org/acejump/boundaries/EditorOffsetCache.kt b/src/main/kotlin/org/acejump/boundaries/EditorOffsetCache.kt
index ecb3e3b..be2a9c2 100644
--- a/src/main/kotlin/org/acejump/boundaries/EditorOffsetCache.kt
+++ b/src/main/kotlin/org/acejump/boundaries/EditorOffsetCache.kt
@@ -21,6 +21,11 @@ sealed class EditorOffsetCache {
    */
   abstract fun visibleArea(editor: Editor): Pair<Point, Point>
 
+  /**
+   * Returns whether the offset is in the visible area rectangle.
+   */
+  abstract fun isVisible(editor: Editor, offset: Int): Boolean
+  
   /**
    * Returns the editor offset at the provided pixel coordinate.
    */
@@ -35,6 +40,7 @@ sealed class EditorOffsetCache {
 
   private class Cache: EditorOffsetCache() {
     private var visibleArea: Pair<Point, Point>? = null
+    private val lineToVisibleOffsetRange = Int2ObjectOpenHashMap<IntRange>()
     private val pointToOffset =
       Object2IntOpenHashMap<Point>().apply { defaultReturnValue(-1) }
     private val offsetToPoint = Int2ObjectOpenHashMap<Point>()
@@ -42,6 +48,24 @@ sealed class EditorOffsetCache {
     override fun visibleArea(editor: Editor): Pair<Point, Point> =
       visibleArea ?: Uncached.visibleArea(editor).also { visibleArea = it }
 
+    override fun isVisible(editor: Editor, offset: Int): Boolean {
+      val visualLine = editor.offsetToVisualLine(offset, false)
+
+      var visibleRange = lineToVisibleOffsetRange.get(visualLine)
+      if (visibleRange == null) {
+        val (topLeft, bottomRight) = visibleArea(editor)
+        val lineY = editor.visualLineToY(visualLine)
+
+        val firstVisibleOffset = xyToOffset(editor, Point(topLeft.x, lineY))
+        val lastVisibleOffset = xyToOffset(editor, Point(bottomRight.x, lineY))
+
+        visibleRange = firstVisibleOffset..lastVisibleOffset
+        lineToVisibleOffsetRange.put(visualLine, visibleRange)
+      }
+
+      return offset in visibleRange
+    }
+
     override fun xyToOffset(editor: Editor, pos: Point): Int =
       pointToOffset.getInt(pos).let { offset ->
         if (offset != -1) offset
@@ -64,6 +88,15 @@ sealed class EditorOffsetCache {
         )
       }
 
+    override fun isVisible(editor: Editor, offset: Int): Boolean {
+      val (topLeft, bottomRight) = visibleArea(editor)
+      val pos = offsetToXY(editor, offset)
+      val x = pos.x
+      val y = pos.y
+
+      return x >= topLeft.x && y >= topLeft.y && x <= bottomRight.x && y <= bottomRight.y
+    }
+
     override fun xyToOffset(editor: Editor, pos: Point): Int =
       read { editor.logicalPositionToOffset(editor.xyToLogicalPosition(pos)) }
 
diff --git a/src/main/kotlin/org/acejump/boundaries/StandardBoundaries.kt b/src/main/kotlin/org/acejump/boundaries/StandardBoundaries.kt
index 1a47935..da034e1 100644
--- a/src/main/kotlin/org/acejump/boundaries/StandardBoundaries.kt
+++ b/src/main/kotlin/org/acejump/boundaries/StandardBoundaries.kt
@@ -21,22 +21,7 @@ enum class StandardBoundaries : Boundaries {
     }
     
     override fun isOffsetInside(editor: Editor, offset: Int, cache: EditorOffsetCache): Boolean {
-      // If we are not using a cache, calling getOffsetRange will cause
-      // additional 1-2 pixel coordinate -> offset lookups, which is a lot
-      // more expensive than one lookup compared against the visible area.
-      
-      // However, if we are using a cache, it's likely that the topmost and
-      // bottommost positions are already cached whereas the provided offset
-      // isn't, so we save a lookup for every offset outside the range.
-      
-      if (cache !== EditorOffsetCache.Uncached && offset !in getOffsetRange(editor, cache)) return false
-      
-      val (topLeft, bottomRight) = cache.visibleArea(editor)
-      val pos = cache.offsetToXY(editor, offset)
-      val x = pos.x
-      val y = pos.y
-      
-      return x >= topLeft.x && y >= topLeft.y && x <= bottomRight.x && y <= bottomRight.y
+      return cache.isVisible(editor, offset)
     }
   },
   
diff --git a/src/main/kotlin/org/acejump/search/SearchProcessor.kt b/src/main/kotlin/org/acejump/search/SearchProcessor.kt
index bc9cfca..c7fdd56 100644
--- a/src/main/kotlin/org/acejump/search/SearchProcessor.kt
+++ b/src/main/kotlin/org/acejump/search/SearchProcessor.kt
@@ -3,6 +3,7 @@ package org.acejump.search
 import com.intellij.openapi.editor.Editor
 import it.unimi.dsi.fastutil.ints.IntArrayList
 import org.acejump.boundaries.Boundaries
+import org.acejump.boundaries.EditorOffsetCache
 import org.acejump.immutableText
 import org.acejump.isWordPart
 import org.acejump.matchesAt
@@ -12,7 +13,6 @@ import org.acejump.matchesAt
  * previous results when the user [type]s a character.
  */
 internal class SearchProcessor private constructor(
-  private val editors: List<Editor>,
   query: SearchQuery,
   results: MutableMap<Editor, IntArrayList>
 ) {
@@ -24,14 +24,15 @@ internal class SearchProcessor private constructor(
       SearchProcessor(editors, SearchQuery.RegularExpression(pattern), boundaries)
   }
   
-  private constructor(editors: List<Editor>, query: SearchQuery, boundaries: Boundaries) : this(editors, query, mutableMapOf()) {
+  private constructor(editors: List<Editor>, query: SearchQuery, boundaries: Boundaries) : this(query, mutableMapOf()) {
     val regex = query.toRegex()
     
     if (regex != null) {
       for (editor in editors) {
+        val cache = EditorOffsetCache.new()
         val offsets = IntArrayList()
         
-        val offsetRange = boundaries.getOffsetRange(editor)
+        val offsetRange = boundaries.getOffsetRange(editor, cache)
         var result = regex.find(editor.immutableText, offsetRange.first)
         
         while (result != null) {
@@ -43,7 +44,7 @@ internal class SearchProcessor private constructor(
           if (highlightEnd > offsetRange.last) {
             break
           }
-          else if (boundaries.isOffsetInside(editor, index)) {
+          else if (boundaries.isOffsetInside(editor, index, cache)) {
             offsets.add(index)
           }