diff --git a/app/src/main/java/com/itsaky/androidide/fragments/output/BuildOutputFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/output/BuildOutputFragment.kt index 86f868df4c..be2fd02ff8 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/output/BuildOutputFragment.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/output/BuildOutputFragment.kt @@ -18,28 +18,33 @@ package com.itsaky.androidide.fragments.output import android.os.Bundle import android.view.View -import com.blankj.utilcode.util.ThreadUtils +import androidx.lifecycle.lifecycleScope import com.itsaky.androidide.R import com.itsaky.androidide.editor.ui.IDEEditor import com.itsaky.androidide.idetooltips.TooltipTag +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.ReceiveChannel +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull class BuildOutputFragment : NonEditableEditorFragment() { + companion object { + private const val LAYOUT_TIMEOUT_MS = 2000L + } + override val currentEditor: IDEEditor? get() = editor - private val unsavedLines: MutableList = ArrayList() + private val logChannel = Channel(Channel.UNLIMITED) - override fun onViewCreated( - view: View, - savedInstanceState: Bundle?, - ) { + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) editor?.tag = TooltipTag.PROJECT_BUILD_OUTPUT emptyStateViewModel.setEmptyMessage(getString(R.string.msg_emptyview_buildoutput)) - if (unsavedLines.isNotEmpty()) { - for (line in unsavedLines) { - editor?.append("${line!!.trim()}\n") - } - unsavedLines.clear() + + viewLifecycleOwner.lifecycleScope.launch(Dispatchers.Default) { + processLogs() } } @@ -49,20 +54,72 @@ class BuildOutputFragment : NonEditableEditorFragment() { } fun appendOutput(output: String?) { - if (editor == null) { - unsavedLines.add(output) - return + if (!output.isNullOrEmpty()) { + logChannel.trySend(output) + } + } + + /** + * Ensures the string ends with a newline character (`\n`). + * Useful for maintaining correct formatting when concatenating log lines. + */ + private fun String.ensureNewline(): String = + if (endsWith('\n')) this else "$this\n" + + /** + * Immediately drains (consumes) all available messages from the channel into the [buffer]. + * + * This is a **non-blocking** operation that enables batching, grouping hundreds of pending lines + * into a single memory operation to avoid saturating the UI queue. + */ + private fun ReceiveChannel.drainTo(buffer: StringBuilder) { + var result = tryReceive() + while (result.isSuccess) { + val line = result.getOrNull() + if (!line.isNullOrEmpty()) { + buffer.append(line.ensureNewline()) + } + result = tryReceive() } - ThreadUtils.runOnUiThread { - val message = - if (output == null || output.endsWith("\n")) { - output - } else { - "${output}\n" - } - editor?.append(message).also { - emptyStateViewModel.setEmpty(false) + } + + /** + * Main log orchestrator: Consumes, Batches, and Dispatches. + * + * 1. Suspends (zero CPU usage) until the first log arrives. + * 2. Wakes up and drains the entire queue (Batching). + * 3. Sends the complete block to the UI in a single pass. + */ + private suspend fun processLogs() = with(StringBuilder()) { + for (firstLine in logChannel) { + append(firstLine.ensureNewline()) + logChannel.drainTo(this) + + if (isNotEmpty()) { + val batchText = toString() + clear() + flushToEditor(batchText) } } } + + /** + * Performs the safe UI update on the Main Thread. + * + * Uses [IDEEditor.awaitLayout] to guarantee the editor has physical dimensions (width > 0) + * before attempting to insert text, preventing the Sora library's `ArrayIndexOutOfBoundsException`. + */ + private suspend fun flushToEditor(text: String) = withContext(Dispatchers.Main) { + editor?.run { + withTimeoutOrNull(LAYOUT_TIMEOUT_MS) { + awaitLayout(onForceVisible = { + emptyStateViewModel.setEmpty(false) + }) + } + + appendBatch(text) + + emptyStateViewModel.setEmpty(false) + } + } } 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 9c3e61c9d6..d1c461dd86 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 @@ -24,6 +24,7 @@ import android.os.Handler import android.os.Looper import android.util.AttributeSet import android.view.MotionEvent +import android.view.View import android.view.inputmethod.EditorInfo import androidx.annotation.StringRes import androidx.annotation.VisibleForTesting @@ -97,12 +98,14 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.cancel import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode import org.slf4j.LoggerFactory import java.io.File +import kotlin.coroutines.resume fun interface OnEditorLongPressListener { fun onLongPress(event: MotionEvent) @@ -206,6 +209,9 @@ constructor( return _diagnosticWindow ?: DiagnosticWindow(this).also { _diagnosticWindow = it } } + val isReadyToAppend: Boolean + get() = !isReleased && isAttachedToWindow && isLaidOut && width > 0 + companion object { private const val TAG = "TrackpadScrollDebug" private const val SELECTION_CHANGE_DELAY = 500L @@ -266,6 +272,59 @@ constructor( } } + /** + * Suspends the current coroutine until the editor has valid dimensions (`width > 0`). + * + * This is a **reactive** alternative to busy-waiting or `postDelayed`. It ensures that + * no text insertion is attempted before the editor's internal layout engine is ready, + * preventing the `ArrayIndexOutOfBoundsException`. + * + * @param onForceVisible A callback invoked immediately if the view is not ready. + * Used to set the view to `VISIBLE` and trigger the layout pass. + */ + suspend fun awaitLayout(onForceVisible: () -> Unit) { + if (isReadyToAppend) return + + withContext(Dispatchers.Main) { + onForceVisible() + } + + return suspendCancellableCoroutine { continuation -> + val listener = object : OnLayoutChangeListener { + override fun onLayoutChange( + v: View?, left: Int, top: Int, right: Int, bottom: Int, + oldLeft: Int, oldTop: Int, oldRight: Int, oldBottom: Int + ) { + if ((v?.width ?: 0) > 0) { + v?.removeOnLayoutChangeListener(this) + if (continuation.isActive) { + continuation.resume(Unit) + } + } + } + } + + addOnLayoutChangeListener(listener) + + continuation.invokeOnCancellation { + removeOnLayoutChangeListener(listener) + } + } + } + + /** + * Appends a block of text to the editor safely. + * + * It performs a final check on [isReadyToAppend] and wraps the underlying append operation + * in [runCatching]. This prevents the app from crashing if the editor's internal layout + * calculation fails during the insertion. + */ + fun appendBatch(text: String) { + if (isReadyToAppend) { + runCatching { append(text) } + } + } + override fun setLanguageServer(server: ILanguageServer?) { if (isReleased) { return