diff --git a/editor-treesitter/src/main/java/io/github/rosemoe/sora/editor/ts/LineSpansGenerator.kt b/editor-treesitter/src/main/java/io/github/rosemoe/sora/editor/ts/LineSpansGenerator.kt index 0a9393c4f3..ca2a36248a 100644 --- a/editor-treesitter/src/main/java/io/github/rosemoe/sora/editor/ts/LineSpansGenerator.kt +++ b/editor-treesitter/src/main/java/io/github/rosemoe/sora/editor/ts/LineSpansGenerator.kt @@ -41,6 +41,10 @@ package io.github.rosemoe.sora.editor.ts +import android.os.Handler +import android.os.Looper +import android.util.Log +import android.util.LruCache import com.itsaky.androidide.treesitter.TSInputEdit import com.itsaky.androidide.treesitter.TSQueryCapture import com.itsaky.androidide.treesitter.TSQueryCursor @@ -55,6 +59,15 @@ import io.github.rosemoe.sora.lang.styling.TextStyle import io.github.rosemoe.sora.text.CharPosition import io.github.rosemoe.sora.text.Content import io.github.rosemoe.sora.widget.schemes.EditorColorScheme +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger /** * Spans generator for tree-sitter. Results are cached. @@ -66,42 +79,67 @@ import io.github.rosemoe.sora.widget.schemes.EditorColorScheme class LineSpansGenerator(internal var tree: TSTree, internal var lineCount: Int, private val content: Content, internal var theme: TsTheme, private val languageSpec: TsLanguageSpec, var scopedVariables: TsScopedVariables, - private val spanFactory: TsSpanFactory) : Spans { + private val spanFactory: TsSpanFactory, private val requestRedraw: () -> Unit) : Spans { companion object { const val CACHE_THRESHOLD = 60 + const val TAG = "LineSpansGenerator" + /** + * Delay in milliseconds to batch UI redraws, preventing frame drops + * when rapidly calculating multiple lines. + */ + const val REDRAW_DEBOUNCE_DELAY_MS = 32L } - private val caches = mutableListOf() + /** + * Thread-safe cache for calculated line spans. + * Automatically evicts the least recently used lines. + */ + private val caches = LruCache>(CACHE_THRESHOLD) + private val calculatingLines = ConcurrentHashMap.newKeySet() - fun edit(edit: TSInputEdit) { - tree.edit(edit) + private val tsExecutor = Executors.newSingleThreadExecutor { r -> + Thread(r, "TreeSitterWorker") } + private val tsDispatcher = tsExecutor.asCoroutineDispatcher() + private val scope = CoroutineScope(SupervisorJob() + tsDispatcher) - fun queryCache(line: Int): MutableList? { - for (i in 0 until caches.size) { - val cache = caches[i] - if (cache.line == line) { - caches.removeAt(i) - caches.add(0, cache) - return cache.spans - } + /** + * Tracks content changes so the worker can instantly abort + * outdated calculations when the user types. + */ + private val contentVersion = AtomicInteger(0) + private val mainHandler = Handler(Looper.getMainLooper()) + private var isRefreshScheduled = AtomicBoolean(false) + + fun edit(edit: TSInputEdit) { + contentVersion.incrementAndGet() + scope.launch { + tree.edit(edit) + calculatingLines.clear() } - return null } - fun pushCache(line: Int, spans: MutableList) { - while (caches.size >= CACHE_THRESHOLD) { - caches.removeAt(caches.size - 1) - } - caches.add(0, SpanCache(spans, line)) + /** + * Queues the native tree destruction in the background + * so it doesn't close while a query is running. + */ + fun destroy() { + scope.cancel() + caches.evictAll() + calculatingLines.clear() + + mainHandler.removeCallbacksAndMessages(null) + + tsExecutor.execute { runCatching { tree.close() } } + tsExecutor.shutdown() } fun captureRegion(startIndex: Int, endIndex: Int): MutableList { val list = mutableListOf() - if (!tree.canAccess()) { + if (!tree.canAccess() || tree.rootNode.hasChanges()) { list.add(emptySpan(0)) return list } @@ -199,11 +237,71 @@ class LineSpansGenerator(internal var tree: TSTree, internal var lineCount: Int, } override fun adjustOnInsert(start: CharPosition, end: CharPosition) { + val lineDiff = end.line - start.line + if (lineDiff == 0) { + val colDiff = end.column - start.column + shiftSpansOnLine(start.line, start.column, colDiff) + return + } + + rebuildCache { line, spans, cache -> + when { + line < start.line -> cache.put(line, spans) + line == start.line -> { + cache.put(line, spans) + cache.put(line + lineDiff, spans) + } + else -> cache.put(line + lineDiff, spans) + } + } } override fun adjustOnDelete(start: CharPosition, end: CharPosition) { + val lineDiff = end.line - start.line + + if (lineDiff == 0) { + val colDiff = start.column - end.column + shiftSpansOnLine(start.line, end.column, colDiff) + return + } + + rebuildCache { line, spans, cache -> + when { + line < start.line -> cache.put(line, spans) + line == start.line -> cache.put(line, spans) + line > end.line -> cache.put(line - lineDiff, spans) + } + } + } + + /** + * Shifts span columns horizontally to prevent visual flickering during inline edits. + * + * @param line Line index of the modification. + * @param startColumn Column index where the shift begins. + * @param colDiff Number of columns to shift. + */ + private fun shiftSpansOnLine(line: Int, startColumn: Int, colDiff: Int) { + caches.get(line)?.forEach { span -> + if (span.column >= startColumn) { + span.column += colDiff + } + } + } + /** + * Rebuilds the line cache for vertical text shifts (line additions or deletions). + * + * @param action Logic to determine how each cached line is re-inserted. + */ + private inline fun rebuildCache(action: (line: Int, spans: MutableList, cache: LruCache>) -> Unit) { + val snapshot = caches.snapshot() + caches.evictAll() + + for ((line, spans) in snapshot) { + action(line, spans, caches) + } } override fun read() = object : Spans.Reader { @@ -211,46 +309,61 @@ class LineSpansGenerator(internal var tree: TSTree, internal var lineCount: Int, private var spans = mutableListOf() override fun moveToLine(line: Int) { - try { - if (line < 0 || line >= lineCount) { - spans = mutableListOf() - return - } - val cached = queryCache(line) - if (cached != null) { - spans = cached - return - } - val start = content.indexer.getCharPosition(line, 0).index - val end = start + content.getColumnCount(line) - spans = captureRegion(start, end) - pushCache(line, spans) - } catch (err: Throwable) { - err.printStackTrace() - } + spans = getSpansForLine(line) } override fun getSpanCount() = spans.size override fun getSpanAt(index: Int) = spans[index] + override fun getSpansOnLine(line: Int): MutableList = getSpansForLine(line) + + private fun getSpansForLine(line: Int): MutableList { + if (line !in 0.. { - try { - val cached = queryCache(line) - if (cached != null) { - return ArrayList(cached) + val requestedVersion = contentVersion.get() + + scope.launch { + try { + if (requestedVersion != contentVersion.get()) return@launch + + val start = content.indexer.getCharPosition(line, 0).index + val end = start + content.getColumnCount(line) + + val resultSpans = captureRegion(start, end) + + if (requestedVersion == contentVersion.get()) { + caches.put(line, resultSpans) + scheduleRefresh() + } + } catch (e: Exception) { + Log.e(TAG, "Error processing spans for line $line", e) + e.printStackTrace() + } finally { + calculatingLines.remove(line) } - val start = content.indexer.getCharPosition(line, 0).index - val end = start + content.getColumnCount(line) - return captureRegion(start, end) - } catch (err: Throwable) { - err.printStackTrace() - throw err - } + } + return mutableListOf(emptySpan(0)) } } + /** + * Groups redraw requests together to avoid overloading the UI thread. + */ + private fun scheduleRefresh() { + if (!isRefreshScheduled.compareAndSet(false, true)) return + + mainHandler.postDelayed({ + isRefreshScheduled.set(false) + requestRedraw() + Log.d(TAG, "Refreshing UI with newly processed lines") + }, REDRAW_DEBOUNCE_DELAY_MS) + } + override fun supportsModify() = false override fun modify(): Spans.Modifier { @@ -259,5 +372,3 @@ class LineSpansGenerator(internal var tree: TSTree, internal var lineCount: Int, override fun getLineCount() = lineCount } - -data class SpanCache(val spans: MutableList, val line: Int) diff --git a/editor-treesitter/src/main/java/io/github/rosemoe/sora/editor/ts/TsAnalyzeManager.kt b/editor-treesitter/src/main/java/io/github/rosemoe/sora/editor/ts/TsAnalyzeManager.kt index 2a15534864..66cf1e7fd3 100644 --- a/editor-treesitter/src/main/java/io/github/rosemoe/sora/editor/ts/TsAnalyzeManager.kt +++ b/editor-treesitter/src/main/java/io/github/rosemoe/sora/editor/ts/TsAnalyzeManager.kt @@ -37,6 +37,7 @@ open class TsAnalyzeManager(val languageSpec: TsLanguageSpec, var theme: TsTheme var spanFactory: TsSpanFactory = DefaultSpanFactory() open var styles = Styles() + internal var currentBracketPairs: TsBracketPairs? = null private var _analyzeWorker: TsAnalyzeWorker? = null val analyzeWorker: TsAnalyzeWorker? @@ -131,7 +132,7 @@ open class TsAnalyzeManager(val languageSpec: TsLanguageSpec, var theme: TsTheme _analyzeWorker?.stop() _analyzeWorker = null - (styles.spans as LineSpansGenerator?)?.tree?.close() + (styles.spans as LineSpansGenerator?)?.destroy() styles.spans = null styles = Styles() @@ -149,9 +150,11 @@ open class TsAnalyzeManager(val languageSpec: TsLanguageSpec, var theme: TsTheme _analyzeWorker?.stop() _analyzeWorker = null - (styles.spans as LineSpansGenerator?)?.tree?.close() + (styles.spans as LineSpansGenerator?)?.destroy() styles.spans = null + currentBracketPairs?.close() + currentBracketPairs = null spanFactory.close() } } \ No newline at end of file diff --git a/editor-treesitter/src/main/java/io/github/rosemoe/sora/editor/ts/TsAnalyzeWorker.kt b/editor-treesitter/src/main/java/io/github/rosemoe/sora/editor/ts/TsAnalyzeWorker.kt index 3ef5626d90..57c55cc808 100644 --- a/editor-treesitter/src/main/java/io/github/rosemoe/sora/editor/ts/TsAnalyzeWorker.kt +++ b/editor-treesitter/src/main/java/io/github/rosemoe/sora/editor/ts/TsAnalyzeWorker.kt @@ -17,6 +17,8 @@ package io.github.rosemoe.sora.editor.ts +import android.os.Handler +import android.os.Looper import com.itsaky.androidide.syntax.colorschemes.SchemeAndroidIDE import com.itsaky.androidide.treesitter.TSInputEdit import com.itsaky.androidide.treesitter.TSQueryCursor @@ -270,28 +272,35 @@ class TsAnalyzeWorker( val tree = tree!! val scopedVariables = TsScopedVariables(tree, text, languageSpec) - val oldTree = (styles.spans as? LineSpansGenerator?)?.tree - val copied = tree.copy() + val oldSpans = (styles.spans as? LineSpansGenerator?) + val oldBrackets = analyzer.currentBracketPairs + oldSpans?.destroy() + + // Use separate tree copies for the background worker and the UI thread + // to prevent concurrent access crashes. styles.spans = LineSpansGenerator( - copied, + tree.copy(), reference.lineCount, reference.reference, theme, languageSpec, scopedVariables, - spanFactory + spanFactory, + requestRedraw = { stylesReceiver?.setStyles(analyzer, styles) } ) + val newBrackets = TsBracketPairs(tree.copy(), languageSpec) + analyzer.currentBracketPairs = newBrackets + val oldBlocks = styles.blocks updateCodeBlocks() oldBlocks?.also { ObjectAllocator.recycleBlockLines(it) } - stylesReceiver?.setStyles(analyzer, styles) { - oldTree?.close() - } + stylesReceiver?.setStyles(analyzer, styles) + stylesReceiver?.updateBracketProvider(analyzer, newBrackets) - stylesReceiver?.updateBracketProvider(analyzer, TsBracketPairs(copied, languageSpec)) + oldBrackets?.let { Handler(Looper.getMainLooper()).post { it.close() } } } private fun updateCodeBlocks() { diff --git a/editor-treesitter/src/main/java/io/github/rosemoe/sora/editor/ts/TsBracketPairs.kt b/editor-treesitter/src/main/java/io/github/rosemoe/sora/editor/ts/TsBracketPairs.kt index 1ae8574567..d82fdf7cbe 100644 --- a/editor-treesitter/src/main/java/io/github/rosemoe/sora/editor/ts/TsBracketPairs.kt +++ b/editor-treesitter/src/main/java/io/github/rosemoe/sora/editor/ts/TsBracketPairs.kt @@ -43,6 +43,15 @@ class TsBracketPairs(private val tree: TSTree, private val languageSpec: TsLangu } + /** + * Frees the native TreeSitter object. Must run on the UI thread + * to avoid crashing if a query is active. + */ + fun close() { + if (!tree.canAccess()) return + tree.close() + } + override fun getPairedBracketAt(text: Content, index: Int): PairedBracket? { if (!languageSpec.bracketsQuery.canAccess() || languageSpec.bracketsQuery.patternCount <= 0 || !tree.canAccess()) { return null diff --git a/editor/src/main/java/com/itsaky/androidide/editor/language/treesitter/TreeSitterIndentProvider.kt b/editor/src/main/java/com/itsaky/androidide/editor/language/treesitter/TreeSitterIndentProvider.kt index 91b4326a1e..0bf7e9f6e2 100644 --- a/editor/src/main/java/com/itsaky/androidide/editor/language/treesitter/TreeSitterIndentProvider.kt +++ b/editor/src/main/java/com/itsaky/androidide/editor/language/treesitter/TreeSitterIndentProvider.kt @@ -39,6 +39,7 @@ import io.github.rosemoe.sora.text.Content import io.github.rosemoe.sora.text.TextUtils import org.slf4j.LoggerFactory import kotlin.math.max +import kotlin.math.min /** * Computes indentation for tree sitter languages using the indents query. @@ -73,6 +74,7 @@ class TreeSitterIndentProvider( internal const val INDENT_AUTO = Int.MAX_VALUE private val DELIMITER_REGEX = Regex("""[\-.+\[\]()$^\\?*]""") + private const val CONTEXT_LINES_LIMIT = 5 } fun getIndentsForLines( @@ -152,6 +154,9 @@ class TreeSitterIndentProvider( return TSQueryCursor.create().use { cursor -> cursor.addPredicateHandler(SetDirectiveHandler()) + + optimizeCursorRange(positions, content, cursor) + cursor.exec(indentsQuery, tree.rootNode) val indents = getIndents(languageSpec.indentsQuery, cursor) @@ -163,6 +168,23 @@ class TreeSitterIndentProvider( } } + private fun optimizeCursorRange( + positions: LongArray, + content: Content, + cursor: TSQueryCursor? + ) { + if (!positions.isNotEmpty()) return + + val targetLine = IntPair.getFirst(positions[0]) + val startLine = max(targetLine - CONTEXT_LINES_LIMIT, 0) + val startByte = content.getCharIndex(startLine, 0) shl 1 + + val endLine = min(targetLine + CONTEXT_LINES_LIMIT, content.lineCount - 1) + val endByte = content.getCharIndex(endLine, content.getColumnCount(endLine)) shl 1 + + cursor?.setByteRange(startByte, endByte) + } + private fun computeIndentForLine( content: Content, line: Int, 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 0a4ab5f6c8..2fa6143c9e 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 @@ -26,6 +26,7 @@ import android.util.AttributeSet import android.view.MotionEvent import android.view.View import android.view.inputmethod.EditorInfo +import android.view.inputmethod.InputConnection import androidx.annotation.StringRes import androidx.annotation.VisibleForTesting import com.blankj.utilcode.util.FileUtils @@ -215,6 +216,7 @@ constructor( companion object { private const val TAG = "TrackpadScrollDebug" private const val SELECTION_CHANGE_DELAY = 500L + private const val LARGE_FILE_LINE_THRESHOLD = 10000 internal val log = LoggerFactory.getLogger(IDEEditor::class.java) @@ -562,6 +564,20 @@ constructor( override fun getSearcher(): EditorSearcher = this.searcher + /** + * Disables IME text extraction for large files (exceeding [LARGE_FILE_LINE_THRESHOLD] lines) to prevent massive + * IPC (Binder) payloads, fixing "oneway spamming" errors and UI lag during typing. + */ + override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection? { + val connection = super.onCreateInputConnection(outAttrs) + + if (this.lineCount > LARGE_FILE_LINE_THRESHOLD) { + outAttrs.imeOptions = outAttrs.imeOptions or EditorInfo.IME_FLAG_NO_EXTRACT_UI + } + + return connection + } + override fun getExtraArguments(): Bundle = super.getExtraArguments().apply { putString(IEditor.KEY_FILE, file?.absolutePath)