From 29fba208b0515ef5fc6cf7114493656884194857 Mon Sep 17 00:00:00 2001 From: SuperMonster003 Date: Sun, 8 Feb 2026 13:22:15 +0800 Subject: [PATCH] =?UTF-8?q?6.7.0=20-=20Alpha19=20-=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E7=BC=96=E8=BE=91=E5=99=A8=E5=8A=A0=E8=BD=BD?= =?UTF-8?q?=E5=A4=A7=E6=96=87=E4=BB=B6=E5=8F=AF=E8=83=BD=E5=AF=BC=E8=87=B4?= =?UTF-8?q?=E5=BA=94=E7=94=A8=E5=B4=A9=E6=BA=83=E7=9A=84=E9=97=AE=E9=A2=98?= =?UTF-8?q?,=20=E4=B8=80=E5=AE=9A=E7=A8=8B=E5=BA=A6=E6=8F=90=E5=8D=87?= =?UTF-8?q?=E5=A4=A7=E6=96=87=E4=BB=B6=E5=8A=A0=E8=BD=BD=E6=B5=81=E7=95=85?= =?UTF-8?q?=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changelog/lang_zh-Hans.json | 3 + .../org/autojs/autojs/ui/edit/EditorView.kt | 881 +++++++++++++++++- .../autojs/ui/edit/editor/CodeEditText.kt | 104 ++- .../autojs/ui/edit/editor/CodeEditor.kt | 180 +++- .../autojs/ui/edit/editor/HVScrollView.java | 16 + .../ui/edit/editor/JavaScriptHighlighter.java | 56 ++ .../ui/edit/editor/TextViewUndoRedo.java | 33 +- app/src/main/res/layout/editor_view.xml | 23 + app/src/main/res/values-zh/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + version.properties | 6 +- 11 files changed, 1256 insertions(+), 48 deletions(-) diff --git a/.changelog/lang_zh-Hans.json b/.changelog/lang_zh-Hans.json index a2fbeffb..84de2d48 100644 --- a/.changelog/lang_zh-Hans.json +++ b/.changelog/lang_zh-Hans.json @@ -99,6 +99,8 @@ "部分设备代码编辑器空行显示方框字符的问题 (试修)", "代码编辑器在只读模式下依然可以编辑代码内容的问题", "代码编辑器在只读模式下点击标题区域及部分菜单项导致应用崩溃的问题", + "代码编辑器加载大文件可能导致应用崩溃的问题 (试修)", + "代码编辑器自动打开新建文件时功能按钮状态初始化异常", "ErrorDialogActivity 可能无法正常启动或短时间自动消失的问题 _[`issue #479`](http://issues.autojs6.com/479)_ _[`issue #471`](http://issues.autojs6.com/471)_ _[`issue #414`](http://issues.autojs6.com/414)_ _[`issue #340`](http://issues.autojs6.com/340#issuecomment-2973485826)_", "Canvas 构造函数可接受的参数类型错误 _[`issue #402`](http://issues.autojs6.com/402)_", "崩溃报告页面复制详细信息功能失效的问题", @@ -138,6 +140,7 @@ "文件管理器浮动按钮展开后点击菜单项时优化菜单收起时机", "文件管理器/任务面板支持显示文件/任务数量统计信息", "代码编辑器保存文件失败时自动存为草稿并支持另存为新文件", + "代码编辑器加载大文件时提升一定程度的流畅度", "打包应用页面默认勾选必要权限 (WAKE_LOCK/INTERNET/WRITE_EXTERNAL_STORAGE) _[`issue #397`](http://issues.autojs6.com/397)_", "打包应用设置页面增加前台服务开关 _[`issue #406`](http://issues.autojs6.com/406)_", "脚本项目配置文件保存时增加键名冲突检测机制防止键名歧义", diff --git a/app/src/main/java/org/autojs/autojs/ui/edit/EditorView.kt b/app/src/main/java/org/autojs/autojs/ui/edit/EditorView.kt index e73d52cd..13e7f883 100644 --- a/app/src/main/java/org/autojs/autojs/ui/edit/EditorView.kt +++ b/app/src/main/java/org/autojs/autojs/ui/edit/EditorView.kt @@ -7,16 +7,23 @@ import android.content.Context import android.content.ContextWrapper import android.content.Intent import android.content.IntentFilter +import android.database.Cursor import android.net.Uri import android.os.Build import android.os.Bundle +import android.os.Handler +import android.os.Looper import android.os.Parcelable +import android.os.SystemClock +import android.provider.OpenableColumns import android.text.TextUtils import android.util.AttributeSet import android.util.SparseBooleanArray +import android.view.Choreographer import android.view.View import android.widget.ImageView import android.widget.LinearLayout +import android.widget.TextView import androidx.core.view.GravityCompat import androidx.drawerlayout.widget.DrawerLayout import androidx.fragment.app.FragmentActivity @@ -24,9 +31,9 @@ import com.afollestad.materialdialogs.MaterialDialog import com.google.android.material.snackbar.Snackbar import io.reactivex.Observable import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.disposables.Disposable import io.reactivex.schedulers.Schedulers import org.autojs.autojs.AutoJs -import org.autojs.autojs.util.DialogUtils import org.autojs.autojs.core.pref.Pref.getEditorTextSize import org.autojs.autojs.core.pref.Pref.setEditorTextSize import org.autojs.autojs.engine.JavaScriptEngine @@ -51,6 +58,7 @@ import org.autojs.autojs.storage.history.HistoryPrefs import org.autojs.autojs.storage.history.HistoryRepository import org.autojs.autojs.storage.history.HistoryUriUtils import org.autojs.autojs.storage.history.VersionHistoryController +import org.autojs.autojs.theme.ThemeColorManager import org.autojs.autojs.tool.Callback import org.autojs.autojs.ui.doc.ManualDialog import org.autojs.autojs.ui.edit.completion.CodeCompletionBar @@ -58,6 +66,7 @@ import org.autojs.autojs.ui.edit.completion.CodeCompletionBar.OnHintClickListene import org.autojs.autojs.ui.edit.debug.DebugBar import org.autojs.autojs.ui.edit.editor.CodeEditor import org.autojs.autojs.ui.edit.editor.CodeEditor.CheckedPatternSyntaxException +import org.autojs.autojs.ui.edit.editor.JavaScriptHighlighter.MAX_HIGHLIGHT_CHARS import org.autojs.autojs.ui.edit.keyboard.FunctionsKeyboardHelper import org.autojs.autojs.ui.edit.keyboard.FunctionsKeyboardView import org.autojs.autojs.ui.edit.keyboard.FunctionsKeyboardView.ClickCallback @@ -72,9 +81,11 @@ import org.autojs.autojs.ui.log.LogActivity import org.autojs.autojs.ui.widget.EWebView import org.autojs.autojs.ui.widget.SimpleTextWatcher import org.autojs.autojs.util.ClipboardUtils +import org.autojs.autojs.util.ColorUtils +import org.autojs.autojs.util.DialogUtils +import org.autojs.autojs.util.DialogUtils.choiceWidgetThemeColor import org.autojs.autojs.util.DisplayUtils.pxToSp import org.autojs.autojs.util.DocsUtils.getUrl -import org.autojs.autojs.util.DialogUtils.choiceWidgetThemeColor import org.autojs.autojs.util.Observers import org.autojs.autojs.util.StringUtils import org.autojs.autojs.util.ViewUtils @@ -83,23 +94,31 @@ import org.autojs.autojs.util.ViewUtils.showToast import org.autojs.autojs6.R import org.autojs.autojs6.R.string.text_unknown import org.autojs.autojs6.databinding.EditorViewBinding +import java.io.BufferedInputStream import java.io.File import java.io.IOException +import java.io.InputStream +import java.io.InputStreamReader import java.nio.charset.Charset import java.nio.charset.StandardCharsets import java.security.MessageDigest import java.util.Locale +import java.util.concurrent.ConcurrentLinkedQueue +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicLong +import java.util.concurrent.atomic.AtomicReference +import kotlin.text.set /** * Created by Stardust on Sep 28, 2017. * Transformed by SuperMonster003 on May 1, 2023. - * Modified by JetBrains AI Assistant (GPT-5.2) as of Feb 3, 2026. * Modified by SuperMonster003 as of Feb 3, 2026. + * Modified by JetBrains AI Assistant (GPT-5.2) as of Feb 8, 2026. */ @SuppressLint("CheckResult") class EditorView : LinearLayout, OnHintClickListener, ClickCallback, ToolbarFragment.OnMenuItemClickListener { - private var binding: EditorViewBinding = EditorViewBinding.bind(View.inflate(context, R.layout.editor_view, this)) + private var binding: EditorViewBinding = EditorViewBinding.bind(inflate(context, R.layout.editor_view, this)) @JvmField val editor: CodeEditor = binding.editor @@ -135,6 +154,34 @@ class EditorView : LinearLayout, OnHintClickListener, ClickCallback, ToolbarFrag private val scriptExecution: ScriptExecution? get() = AutoJs.instance.scriptEngineService.getScriptExecution(scriptExecutionId) + // Whether the editor is currently loading text (including streaming append). + // zh-CN: 编辑器是否正在加载文本(包括流式追加阶段). + @Volatile + private var mEditorLoading: Boolean = false + + // Large file mode flag: keep undo/redo disabled to avoid memory/perf issues. + // zh-CN: 大文件模式标记: 为避免内存/性能问题, 保持撤销/重做不可用. + @Volatile + private var mLargeFileMode: Boolean = false + + // Active large-file loading cancel flag. + // zh-CN: 大文件加载的取消标记(当前会话). + private val mLargeFileCancel = AtomicBoolean(false) + + // Active IO subscription disposable for large-file loading. + // zh-CN: 大文件加载的 IO 订阅 Disposable. + @Volatile + private var mLargeFileIoDisposable: Disposable? = null + + // Active streaming source stream reference (for forced close). + // zh-CN: 流式读取的底层流引用(用于强制 close 以打断阻塞 read). + private val mLargeFileStreamRef = AtomicReference(null) + + // Active Choreographer callback for streaming append. + // zh-CN: 流式追加的 Choreographer 回调引用(用于取消帧回调). + @Volatile + private var mLargeFileFrameCallback: Choreographer.FrameCallback? = null + private val mCodeCompletionBar: CodeCompletionBar = binding.codeCompletionBar private val mInputMethodEnhanceBar: View = binding.inputMethodEnhanceBar private val mSymbolBar: CodeCompletionBar = binding.symbolBar @@ -174,6 +221,32 @@ class EditorView : LinearLayout, OnHintClickListener, ClickCallback, ToolbarFrag private var mDebugging = false private var mTmpSavedFileForRunning: File? = null + private val mUiHandler = Handler(Looper.getMainLooper()) + + // Delay showing loading bar to avoid flicker. + // zh-CN: 延迟显示加载提示条, 避免闪烁. + private val mLoadingBarShowDelayMs = 1500L + + // Once shown, keep it visible for at least this duration. + // zh-CN: 一旦显示, 则保证最短展示时间. + private val mLoadingBarMinShowMs = 500L + + private val mLoadingBarContainer: View = binding.loadingBarContainer + private val mLoadingBarText: TextView = binding.loadingBarText + + private var mLoadingBarRequested = false + private var mLoadingBarShownAtMs = 0L + + private var mWasReadOnlyBeforeLoading = false + + private val mShowLoadingBarRunnable = Runnable { + if (!mLoadingBarRequested) return@Runnable + if (mLoadingBarContainer.visibility == VISIBLE) return@Runnable + + mLoadingBarContainer.visibility = VISIBLE + mLoadingBarShownAtMs = SystemClock.uptimeMillis() + } + constructor(context: Context) : super(context) constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) @@ -206,13 +279,12 @@ class EditorView : LinearLayout, OnHintClickListener, ClickCallback, ToolbarFrag (context as? HostActivity)?.backPressedObserver?.registerHandler(mFunctionsKeyboardHelper) } - override fun onDetachedFromWindow() { - super.onDetachedFromWindow() - context.unregisterReceiver(mOnRunFinishedReceiver) - (context as? HostActivity)?.backPressedObserver?.unregisterHandler(mFunctionsKeyboardHelper) - } - fun handleIntent(intent: Intent): Observable { + // Initialize menu state early to avoid "first render" flicker. + // zh-CN: 尽早初始化菜单状态, 避免首次渲染闪烁. + mEditorLoading = true + syncPrimaryMenuState() + val name = intent.getStringExtra(EXTRA_NAME) if (name != null) { this.name = name @@ -240,6 +312,11 @@ class EditorView : LinearLayout, OnHintClickListener, ClickCallback, ToolbarFrag if (readOnly) { editor.setReadOnly(true) } + + // Loading is finished for intent-handling stage (actual file loading may continue). + // zh-CN: Intent 处理阶段结束(实际文件加载可能仍在继续). + mEditorLoading = false + syncPrimaryMenuState() } } @@ -273,10 +350,92 @@ class EditorView : LinearLayout, OnHintClickListener, ClickCallback, ToolbarFrag } } - @SuppressLint("CheckResult") + private fun setLoadingBar(loading: Boolean) { + if (loading) { + mLoadingBarRequested = true + mUiHandler.removeCallbacks(mShowLoadingBarRunnable) + mUiHandler.postDelayed(mShowLoadingBarRunnable, mLoadingBarShowDelayMs) + return + } + + mLoadingBarRequested = false + mUiHandler.removeCallbacks(mShowLoadingBarRunnable) + + if (mLoadingBarContainer.visibility != VISIBLE) { + mLoadingBarContainer.visibility = GONE + return + } + + val elapsed = SystemClock.uptimeMillis() - mLoadingBarShownAtMs + val remain = (mLoadingBarMinShowMs - elapsed).coerceAtLeast(0L) + + if (remain == 0L) { + mLoadingBarContainer.visibility = GONE + return + } + + mUiHandler.postDelayed({ + if (!mLoadingBarRequested) { + mLoadingBarContainer.visibility = GONE + } + }, remain) + } + + private fun setEnhanceBarInteractive(interactive: Boolean) { + // When not interactive, hide function/completion bars and show loading bar. + // zh-CN: 非可操作状态时隐藏功能/补全栏, 显示加载提示条. + when (interactive) { + true -> { + mShowFunctionsButton.visibility = VISIBLE + mCodeCompletionBar.visibility = VISIBLE + mSymbolBar.visibility = VISIBLE + } + else -> { + mShowFunctionsButton.visibility = INVISIBLE + mCodeCompletionBar.visibility = INVISIBLE + mSymbolBar.visibility = INVISIBLE + } + } + } + + private fun beginEditorLoadingUi(initialMessage: String) { + mLoadingBarText.text = initialMessage + setEnhanceBarInteractive(false) + setLoadingBar(true) + + // Use read-only during loading to avoid accidental edits. + // zh-CN: 加载期间启用只读以避免误修改. + mWasReadOnlyBeforeLoading = mReadOnly + if (!mReadOnly) { + editor.setReadOnly(true) + } + } + + private fun endEditorLoadingUi() { + setLoadingBar(false) + setEnhanceBarInteractive(!mReadOnly) + + // Restore read-only state. + // zh-CN: 恢复只读状态. + if (!mWasReadOnlyBeforeLoading) { + editor.setReadOnly(false) + } + } + private fun loadUri(uri: Uri): Observable { + val streamThresholdBytes = 1024L * 1024L + + val sizeOrNull = runCatching { queryContentLengthOrNull(uri) }.getOrNull() + if (sizeOrNull != null && sizeOrNull >= streamThresholdBytes) { + // Large file: show loading in enhance bar, allow viewing/scrolling (read-only). + // zh-CN: 大文件: 在增强栏显示加载提示, 允许查看/滚动(只读). + beginEditorLoadingUi(context.getString(R.string.text_loading_with_dots)) + return loadUriStreamed(uri, totalBytesOrNull = sizeOrNull) + } + return Observable .fromCallable { + // ... existing code ... val resolver = context.contentResolver val rawBytes = resolver.openInputStream(uri)?.use { it.readBytes() } ?: ByteArray(0) @@ -285,24 +444,650 @@ class EditorView : LinearLayout, OnHintClickListener, ClickCallback, ToolbarFrag mCurrentCharset = detectedCharsetWrapper.charsetOrDefault() mHadBom = StringUtils.hasBom(rawBytes, mCurrentCharset) - val effectiveBytes = if (mHadBom) { - StringUtils.dropBom(rawBytes, mCurrentCharset) - } else rawBytes + val offset = if (mHadBom) { + StringUtils.bomBytes(mCurrentCharset).size.coerceAtMost(rawBytes.size) + } else 0 + val len = (rawBytes.size - offset).coerceAtLeast(0) - String(effectiveBytes, mCurrentCharset) + String(rawBytes, offset, len, mCurrentCharset) } .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) - .doOnNext { text: String -> setInitialText(text) } + .doOnSubscribe { + beginEditorLoadingUi(context.getString(R.string.text_loading_with_dots)) + mEditorLoading = true + syncPrimaryMenuState() + } + .doOnNext { text: String -> + post { + runCatching { + setInitialText(text) + + // IMPORTANT: end loading UI on success. + // zh-CN: 重要: 成功时必须结束加载 UI. + endEditorLoadingUi() + + mEditorLoading = false + syncPrimaryMenuState() + }.onFailure { + endEditorLoadingUi() + mEditorLoading = false + syncPrimaryMenuState() + } + } + } + .doOnError { + endEditorLoadingUi() + mEditorLoading = false + syncPrimaryMenuState() + } + } + + private fun queryContentLengthOrNull(uri: Uri): Long? { + return when (uri.scheme?.lowercase()) { + "file" -> uri.path?.let { File(it).length().coerceAtLeast(0L) } + "content" -> { + val resolver = context.contentResolver + var cursor: Cursor? = null + try { + cursor = resolver.query(uri, arrayOf(OpenableColumns.SIZE), null, null, null) + if (cursor != null && cursor.moveToFirst()) { + val idx = cursor.getColumnIndex(OpenableColumns.SIZE) + if (idx >= 0 && !cursor.isNull(idx)) { + cursor.getLong(idx).coerceAtLeast(0L) + } else null + } else null + } finally { + runCatching { cursor?.close() } + } + } + else -> null + } + } + + override fun onDetachedFromWindow() { + // Cancel loading before unregistering receivers to avoid ANR on exit. + // zh-CN: 退出前先取消加载, 避免返回退出时 ANR. + cancelLargeFileLoading("onDetachedFromWindow") + + super.onDetachedFromWindow() + context.unregisterReceiver(mOnRunFinishedReceiver) + (context as? HostActivity)?.backPressedObserver?.unregisterHandler(mFunctionsKeyboardHelper) + } + + // Cancel current large-file loading as soon as possible. + // zh-CN: 尽快取消当前大文件加载. + private fun cancelLargeFileLoading(reason: String) { + // Mark cancel first so both IO/UI can observe it. + // zh-CN: 先标记取消, 让 IO/UI 两侧都能观察到. + mLargeFileCancel.set(true) + + // Stop UI frame callback quickly. + // zh-CN: 尽快停止 UI 帧回调. + mLargeFileFrameCallback?.let { cb -> + runCatching { Choreographer.getInstance().removeFrameCallback(cb) } + } + mLargeFileFrameCallback = null + + // Dispose IO subscription. + // zh-CN: 取消 IO 订阅. + mLargeFileIoDisposable?.let { d -> + runCatching { d.dispose() } + } + mLargeFileIoDisposable = null + + // Force close stream to interrupt blocking read(). + // zh-CN: 强制关闭流以打断可能阻塞的 read(). + mLargeFileStreamRef.getAndSet(null)?.let { s -> + runCatching { s.close() } + } + + // Cleanup UI state best-effort (do NOT re-enable editing in read-only editor). + // zh-CN: 尽力清理 UI 状态(只读编辑器不要被重新启用编辑). + post { + runCatching { + endEditorLoadingUi() + } + mEditorLoading = false + syncPrimaryMenuState() + } + } + + private fun loadUriStreamed(uri: Uri, totalBytesOrNull: Long?): Observable { + val readBytes = AtomicLong(0L) + + // Reset cancel state for a new session. + // zh-CN: 新的加载会话开始前重置取消标记. + mLargeFileCancel.set(false) + + return Observable + .create { emitter -> + val resolver = context.contentResolver + + // Emit an early placeholder so handleIntent() can proceed. + // zh-CN: 提前发出占位字符串, 使 handleIntent() 能继续运行. + emitter.onNext("") + + val raw = resolver.openInputStream(uri) ?: run { + emitter.onError(IllegalStateException("Cannot open input stream for $uri")) + return@create + } + + // Keep a reference for cancellation. + // zh-CN: 保存引用以便取消时强制 close. + mLargeFileStreamRef.set(raw) + + val counted = CountingInputStream(raw, readBytes) + val bis = BufferedInputStream(counted) + + try { + bis.use { input -> + if (mLargeFileCancel.get() || emitter.isDisposed) { + // Cancel early. + // zh-CN: 提前取消. + return@use + } + + val sampleLimitBytes = 64 * 1024 + input.mark(sampleLimitBytes + 8) + + val sample = ByteArray(sampleLimitBytes) + val sampleRead = input.read(sample).coerceAtLeast(0) + val sampleBytes = if (sampleRead == sample.size) sample else sample.copyOf(sampleRead) + + val detected = StringUtils.detectCharset(sampleBytes) + mCurrentCharsetConfidence = detected.confidence ?: 0 + mCurrentCharset = detected.charsetOrDefault() + mHadBom = StringUtils.hasBom(sampleBytes, mCurrentCharset) + + runCatching { input.reset() }.getOrElse { + if (!emitter.isDisposed && !mLargeFileCancel.get()) { + emitter.onError(IllegalStateException("Stream reset failed for $uri")) + } + return@use + } + + streamDecodeAndEmit(input, emitter) + } + } finally { + // Clear reference when finished/failed. + // zh-CN: 结束/失败后清理引用. + mLargeFileStreamRef.set(null) + } + } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .let { upstream -> + bridgeStreamToUiWithProgress( + upstream = upstream, + totalBytesOrNull = totalBytesOrNull, + readBytes = readBytes, + ) + } + } + + private fun bridgeStreamToUiWithProgress( + upstream: Observable, + totalBytesOrNull: Long?, + readBytes: AtomicLong, + ): Observable { + val queue = ConcurrentLinkedQueue() + val done = AtomicBoolean(false) + val started = AtomicBoolean(false) + + // Dispose previous session if any. + // zh-CN: 若存在上一会话, 则先取消. + runCatching { mLargeFileIoDisposable?.dispose() } + mLargeFileIoDisposable = null + + // Subscribe on IO side, push chunks into queue. + // zh-CN: 在 IO 侧订阅, 将分片推入队列. + mLargeFileIoDisposable = upstream + .observeOn(Schedulers.io()) + .subscribe({ chunk -> + if (mLargeFileCancel.get()) { + // Stop producing. + // zh-CN: 停止生产. + done.set(true) + return@subscribe + } + if (chunk.isNotEmpty()) { + queue.add(chunk) + } + }, { e -> + done.set(true) + if (!mLargeFileCancel.get()) { + post { endEditorLoadingUi() } + e.printStackTrace() + } + }, { + done.set(true) + }) + + return Observable + .just("") + .observeOn(AndroidSchedulers.mainThread()) + .doOnNext { + if (started.compareAndSet(false, true)) { + startStreamingAppendWithProgress(queue, done, totalBytesOrNull, readBytes) + } + } + } + + private fun startStreamingAppendWithProgress( + queue: ConcurrentLinkedQueue, + done: AtomicBoolean, + totalBytesOrNull: Long?, + readBytes: AtomicLong, + ) { + val editText = editor.codeEditText + + mLargeFileMode = true + mEditorLoading = true + syncPrimaryMenuState() + + editText.setLoadingText(true) + editor.setRedoUndoEnabled(false) + editText.setLoadingGutterDigits(7) + editText.setText("") + + val choreographer = Choreographer.getInstance() + + // Per-frame time budget for appending text (favor throughput). + // zh-CN: 每帧用于追加文本的时间预算(优先吞吐). + val appendBudgetMsIdle = 12L + val appendBudgetMsTouching = 3L + + // Upper bound of chars appended per frame as a second guardrail. + // zh-CN: 每帧追加字符数的上限, 作为第二道护栏. + val maxCharsPerFrameIdle = 128 * 1024 + val maxCharsPerFrameTouching = 16 * 1024 + + // Reuse StringBuilder to reduce per-frame allocations. + // zh-CN: 复用 StringBuilder, 降低每帧分配压力. + val sb = StringBuilder(128 * 1024) + + val callback = object : Choreographer.FrameCallback { + override fun doFrame(frameTimeNanos: Long) { + if (mLargeFileCancel.get()) { + // Cancel: stop immediately and do not schedule next frame. + // zh-CN: 取消: 立即停止且不再调度下一帧. + editText.setLoadingText(false) + mEditorLoading = false + syncPrimaryMenuState() + endEditorLoadingUi() + mLargeFileFrameCallback = null + return + } + + val finished = done.get() && queue.isEmpty() + if (finished) { + editText.setLoadingText(false) + editor.setRedoUndoEnabled(false) + editor.markUndoRedoBaselineAsUnchanged() + + mEditorLoading = false + syncPrimaryMenuState() + + // IMPORTANT: end loading UI on success. + // zh-CN: 重要: 成功时必须结束加载 UI. + endEditorLoadingUi() + + mLargeFileFrameCallback = null + return + } + + val now = SystemClock.uptimeMillis() + + mLoadingBarText.text = context.getString(R.string.text_loading_with_dots) + + val userTouching = editor.isUserTouching() + + val budgetMs = if (userTouching) appendBudgetMsTouching else appendBudgetMsIdle + val maxCharsThisFrame = if (userTouching) maxCharsPerFrameTouching else maxCharsPerFrameIdle + + // Append with time budget. + // zh-CN: 采用时间预算追加. + val t0 = now + + sb.setLength(0) + while (sb.length < maxCharsThisFrame) { + if (mLargeFileCancel.get()) { + break + } + if (SystemClock.uptimeMillis() - t0 >= budgetMs) { + break + } + val s = queue.poll() ?: break + sb.append(s) + } + + if (sb.isNotEmpty()) { + editText.beginBatchEdit() + try { + editText.text?.append(sb) + } finally { + editText.endBatchEdit() + } + } + + choreographer.postFrameCallback(this) + } + } + + // Keep reference for cancellation. + // zh-CN: 保存引用以便取消. + mLargeFileFrameCallback = callback + choreographer.postFrameCallback(callback) + } + + private fun streamDecodeAndEmit(input: BufferedInputStream, emitter: io.reactivex.ObservableEmitter) { + // Skip BOM bytes by consuming from raw stream before decoding. + // zh-CN: 在解码前先从原始字节流中消费 BOM 字节. + val bomBytes = StringUtils.bomBytes(mCurrentCharset) + if (mHadBom && bomBytes.isNotEmpty()) { + var skipped = 0 + while (skipped < bomBytes.size) { + if (mLargeFileCancel.get() || emitter.isDisposed) { + // Cancel early. + // zh-CN: 提前取消. + return + } + val r = input.read() + if (r == -1) break + skipped++ + } + } + + val reader = InputStreamReader(input, mCurrentCharset) + val buf = CharArray(32 * 1024) + while (!emitter.isDisposed && !mLargeFileCancel.get()) { + val n = reader.read(buf) + if (n <= 0) break + emitter.onNext(String(buf, 0, n)) + } + + if (!emitter.isDisposed && !mLargeFileCancel.get()) { + emitter.onComplete() + } + } + + private fun bridgeStreamToUi(upstream: Observable): Observable { + val queue = ConcurrentLinkedQueue() + val done = AtomicBoolean(false) + val started = AtomicBoolean(false) + + // Subscribe on IO side, push chunks into queue. + // zh-CN: 在 IO 侧订阅, 将分片推入队列. + upstream + .observeOn(Schedulers.io()) + .subscribe({ chunk -> + if (chunk.isNotEmpty()) { + queue.add(chunk) + } else { + // Ignore placeholder. + // zh-CN: 忽略占位分片. + } + }, { e -> + done.set(true) + post { editor.setProgress(false) } + e.printStackTrace() + }, { + done.set(true) + }) + + // Return a UI-side observable that triggers the append loop once. + // zh-CN: 返回 UI 侧 observable, 仅用于触发一次追加循环. + return Observable + .just("") + .observeOn(AndroidSchedulers.mainThread()) + .doOnNext { + if (started.compareAndSet(false, true)) { + startStreamingAppend(queue, done) + } + } + } + + private fun startStreamingAppend(queue: ConcurrentLinkedQueue, done: AtomicBoolean) { + val editText = editor.codeEditText + + // Large file streaming: disable all buttons while loading. + // zh-CN: 大文件流式加载: 加载期间禁用所有按钮. + mLargeFileMode = true + mEditorLoading = true + syncPrimaryMenuState() + + // Keep a non-blocking indicator during streaming. + // zh-CN: 流式加载期间保持一个不遮挡操作的提示. + editor.setProgress(true, interactive = true) + + editText.setLoadingText(true) + + // Keep undo/redo disabled in large file mode. + // zh-CN: 大文件模式下保持撤销/重做禁用. + editor.setRedoUndoEnabled(false) + + editText.setLoadingGutterDigits(7) + editText.setText("") + + val targetMs = 6L + var chunkSize = 64 * 1024 + val minChunkSize = 2 * 1024 + val maxChunkSize = 256 * 1024 + var lastAppendCostMs = 0L + + val choreographer = Choreographer.getInstance() + + val callback = object : Choreographer.FrameCallback { + override fun doFrame(frameTimeNanos: Long) { + val finished = done.get() && queue.isEmpty() + if (finished) { + // Finish: restore state and close progress. + // zh-CN: 完成: 恢复状态并关闭进度. + editText.setLoadingText(false) + + // Undo/redo stay disabled for large files. + // zh-CN: 大文件撤销/重做保持禁用. + editor.setRedoUndoEnabled(false) + editor.markUndoRedoBaselineAsUnchanged() + + editor.setProgress(false) + + mEditorLoading = false + syncPrimaryMenuState() + + val len = editText.text?.length ?: 0 + if (len in 1..MAX_HIGHLIGHT_CHARS) { + post { editor.refreshHighlightTokensIfAllowed() } + } + return + } + + val sb = StringBuilder(chunkSize.coerceAtLeast(1024)) + while (sb.length < chunkSize) { + val s = queue.poll() ?: break + sb.append(s) + } + + if (sb.isNotEmpty()) { + val t0 = SystemClock.uptimeMillis() + editText.beginBatchEdit() + try { + editText.text?.append(sb) + } finally { + editText.endBatchEdit() + } + lastAppendCostMs = (SystemClock.uptimeMillis() - t0).coerceAtLeast(0L) + + chunkSize = when { + lastAppendCostMs > targetMs * 2 -> (chunkSize / 2).coerceAtLeast(minChunkSize) + lastAppendCostMs < targetMs / 2 -> (chunkSize * 2).coerceAtMost(maxChunkSize) + else -> chunkSize + } + } + + choreographer.postFrameCallback(this) + } + } + + choreographer.postFrameCallback(callback) } private fun setInitialText(text: String) { + // NOTE: This method should not manage loading UI lifecycle anymore. + // zh-CN: 注意: 此方法不再管理加载 UI 的生命周期. + mEditorLoading = true + syncPrimaryMenuState() + if (mRestoredText != null) { editor.text = mRestoredText!! mRestoredText = null + + editor.markUndoRedoBaselineAsUnchanged() + editor.setRedoUndoEnabled(!mLargeFileMode) + + // Refresh highlight for restored text if size allows. + // zh-CN: 若大小允许, 则对恢复文本刷新一次高亮. + post { editor.refreshHighlightTokensIfAllowed() } + return } - editor.setInitialText(text) + + val progressiveThreshold = 1024 * 1024 + if (text.length >= progressiveThreshold) { + mLargeFileMode = true + setInitialTextProgressively(text) + return + } + + val editText = editor.codeEditText + editText.setLoadingText(true) + editor.setRedoUndoEnabled(false) + try { + editText.setText(text) + editor.markUndoRedoBaselineAsUnchanged() + } finally { + editor.setRedoUndoEnabled(true) + editText.setLoadingText(false) + } + + // Re-highlight once after fast load, but only when size is within highlighter limit. + // zh-CN: 快速加载完成后主动触发一次高亮, 但仅在文本大小不超过高亮上限时执行. + if (text.length <= MAX_HIGHLIGHT_CHARS) { + post { editor.refreshHighlightTokensIfAllowed() } + } + } + + private fun syncPrimaryMenuState() { + // Unified primary buttons state. + // zh-CN: 统一主按钮状态. + when { + mEditorLoading -> { + setMenuItemStatus(R.id.run, false) + setMenuItemStatus(R.id.undo, false) + setMenuItemStatus(R.id.redo, false) + setMenuItemStatus(R.id.save, false) + } + + mLargeFileMode -> { + // Large file: keep undo/redo disabled; save is enabled only when text changed by explicit edits. + // zh-CN: 大文件: 撤销/重做保持禁用; 保存仅在明确编辑导致文本变化时启用. + setMenuItemStatus(R.id.run, true) + setMenuItemStatus(R.id.undo, false) + setMenuItemStatus(R.id.redo, false) + setMenuItemStatus(R.id.save, editor.isTextChanged) + } + + else -> { + // Normal mode. + // zh-CN: 普通模式. + setMenuItemStatus(R.id.run, true) + setMenuItemStatus(R.id.undo, editor.canUndo()) + setMenuItemStatus(R.id.redo, editor.canRedo()) + setMenuItemStatus(R.id.save, editor.isTextChanged) + } + } + } + + private fun setInitialTextProgressively(text: String) { + val editText = editor.codeEditText + + // Mark loading state to suppress heavy callbacks during bulk insertion. + // zh-CN: 标记加载状态, 在批量插入期间抑制高开销回调. + editText.setLoadingText(true) + + // Disable undo/redo recording during progressive load to avoid huge history and allocations. + // zh-CN: 渐进式加载期间禁用 undo/redo 记录, 避免巨大历史与大量分配. + editor.setRedoUndoEnabled(false) + + // Clear existing content quickly. + // zh-CN: 快速清空现有内容. + editText.setText("") + + // Do NOT call editor.setProgress(true) here. + // zh-CN: 不要在这里再次 setProgress(true), 进度对话框已由 loadUri() 负责显示. + // editor.setProgress(true) + + // Frame time budget in ms (tune for devices). + // zh-CN: 单帧时间预算(毫秒), 可按设备调参. + val frameBudgetMs = 7L + + // Adaptive chunk size to balance layout cost and responsiveness. + // zh-CN: 自适应分片大小, 在布局成本与响应性之间取平衡. + var chunkSize = 64 * 1024 + val minChunkSize = 4 * 1024 + val maxChunkSize = 256 * 1024 + + var index = 0 + + val choreographer = Choreographer.getInstance() + + val callback = object : Choreographer.FrameCallback { + override fun doFrame(frameTimeNanos: Long) { + if (index >= text.length) { + // Finish: mark current content as baseline without re-setting text. + // zh-CN: 完成: 将当前内容设为基线, 避免再次 setText. + editText.setLoadingText(false) + editor.setRedoUndoEnabled(true) + editor.markUndoRedoBaselineAsUnchanged() + editor.setProgress(false) + return + } + + val start = SystemClock.uptimeMillis() + + var appendedChunks = 0 + + // Do as much work as possible within the frame budget. + // zh-CN: 在单帧预算内尽可能推进加载. + while (index < text.length) { + val end = (index + chunkSize).coerceAtMost(text.length) + editText.text?.append(text, index, end) + index = end + appendedChunks++ + + if (SystemClock.uptimeMillis() - start >= frameBudgetMs) { + break + } + } + + // Adapt chunk size based on how many chunks we managed to append in this frame. + // zh-CN: 根据本帧完成的分片数量自适应调整 chunkSize. + chunkSize = when { + appendedChunks <= 1 -> (chunkSize / 2).coerceAtLeast(minChunkSize) + appendedChunks >= 4 -> (chunkSize * 2).coerceAtMost(maxChunkSize) + else -> chunkSize + } + + // Continue on next frame. + // zh-CN: 下一帧继续执行. + choreographer.postFrameCallback(this) + } + } + + // Start on next frame to let the progress dialog animate. + // zh-CN: 下一帧开始, 让进度对话框先跑起来. + choreographer.postFrameCallback(callback) } private fun setMenuItemStatus(id: Int, enabled: Boolean) { @@ -380,9 +1165,12 @@ class EditorView : LinearLayout, OnHintClickListener, ClickCallback, ToolbarFrag editor.let { editor -> editor.codeEditText.let { editText -> editText.addTextChangedListener(SimpleTextWatcher { _ -> - setMenuItemStatus(R.id.save, editor.isTextChanged) - setMenuItemStatus(R.id.undo, editor.canUndo()) - setMenuItemStatus(R.id.redo, editor.canRedo()) + // Skip menu state updates during progressive loading. + // zh-CN: 渐进式加载期间跳过菜单状态更新. + if (editText.isLoadingText() || mEditorLoading) { + return@SimpleTextWatcher + } + syncPrimaryMenuState() }) editText.textSize = getEditorTextSize(pxToSp(editText.textSize).toInt()).toFloat() } @@ -392,6 +1180,15 @@ class EditorView : LinearLayout, OnHintClickListener, ClickCallback, ToolbarFrag } }) editor.layoutDirection = LAYOUT_DIRECTION_LTR + + // Initialize to "new file" expected state: only Run enabled. + // zh-CN: 初始化为 "新建文件" 预期状态: 仅运行可用. + mEditorLoading = false + mLargeFileMode = false + setMenuItemStatus(R.id.run, true) + setMenuItemStatus(R.id.undo, false) + setMenuItemStatus(R.id.redo, false) + setMenuItemStatus(R.id.save, false) } } @@ -402,13 +1199,23 @@ class EditorView : LinearLayout, OnHintClickListener, ClickCallback, ToolbarFrag private fun setTheme(theme: Theme?) { theme?.let { mEditorTheme = it + + val appThemeColor = ThemeColorManager.colorPrimary + val imeBarBackgroundColor = it.imeBarBackgroundColor + editor.setTheme(it) - mInputMethodEnhanceBar.setBackgroundColor(it.imeBarBackgroundColor) + mInputMethodEnhanceBar.setBackgroundColor(imeBarBackgroundColor) + + run { + val adjustedImageContrastColor = ColorUtils.adjustColorForContrast(imeBarBackgroundColor, appThemeColor, 3.6) + mLoadingBarText.setTextColor(adjustedImageContrastColor) + } + val textColor = it.imeBarForegroundColor mCodeCompletionBar.setTextColor(textColor) mSymbolBar.setTextColor(textColor) mShowFunctionsButton.setColorFilter(textColor) - ViewUtils.setNavigationBarBackgroundColor(activity, it.imeBarBackgroundColor) + ViewUtils.setNavigationBarBackgroundColor(activity, imeBarBackgroundColor) invalidate() } } @@ -1120,6 +1927,10 @@ class EditorView : LinearLayout, OnHintClickListener, ClickCallback, ToolbarFrag } fun destroy() { + // Cancel loading before destroying editor resources. + // zh-CN: 销毁前先取消加载, 再销毁编辑器相关资源. + cancelLargeFileLoading("destroy") + editor.destroy() mAutoCompletion?.shutdown() if (mDebugging) { @@ -1142,6 +1953,32 @@ class EditorView : LinearLayout, OnHintClickListener, ClickCallback, ToolbarFrag mTmpSavedFileForRunning?.deleteOnExit() } + private class CountingInputStream(private val delegate: InputStream, private val counter: AtomicLong) : InputStream() { + override fun read(): Int { + val r = delegate.read() + if (r >= 0) counter.incrementAndGet() + return r + } + + override fun read(b: ByteArray, off: Int, len: Int): Int { + val n = delegate.read(b, off, len) + if (n > 0) counter.addAndGet(n.toLong()) + return n + } + + override fun skip(n: Long): Long { + val s = delegate.skip(n) + if (s > 0) counter.addAndGet(s) + return s + } + + override fun available(): Int = delegate.available() + override fun close() = delegate.close() + override fun mark(readlimit: Int) = delegate.mark(readlimit) + override fun reset() = delegate.reset() + override fun markSupported(): Boolean = delegate.markSupported() + } + companion object { private const val MIN_CONFIDENCE_TO_WRITE_FILE = 90 diff --git a/app/src/main/java/org/autojs/autojs/ui/edit/editor/CodeEditText.kt b/app/src/main/java/org/autojs/autojs/ui/edit/editor/CodeEditText.kt index 0f47faa6..f5d8e5e5 100644 --- a/app/src/main/java/org/autojs/autojs/ui/edit/editor/CodeEditText.kt +++ b/app/src/main/java/org/autojs/autojs/ui/edit/editor/CodeEditText.kt @@ -54,6 +54,7 @@ import java.util.concurrent.CopyOnWriteArrayList /** * Created by Administrator on Feb 11, 2018. * Modified by SuperMonster003 as of May 1, 2023. + * Modified by JetBrains AI Assistant (GPT-5.2) as of Feb 8, 2026. */ class CodeEditText : AppCompatEditText { @@ -63,6 +64,16 @@ class CodeEditText : AppCompatEditText { @Volatile private var mHighlightTokens: HighlightTokens? = null + // Loading state, used to suppress expensive callbacks during bulk text insertion. + // zh-CN: 加载状态, 用于在批量插入文本时抑制高开销回调. + @Volatile + private var mLoadingText = false + + // Fixed gutter digits used during loading to avoid frequent requestLayout(). + // zh-CN: 加载期间使用固定 gutter 位数, 避免频繁 requestLayout(). + @Volatile + private var mLoadingGutterDigits: Int = 1 + private var mTheme: Theme = Theme.getDefault(context) private val mLineHighlightPaint = Paint().apply { style = Paint.Style.FILL } private var mFirstLineForDraw = -1 @@ -118,6 +129,43 @@ class CodeEditText : AppCompatEditText { isLongClickable = true } + // Toggle loading state. + // zh-CN: 切换加载状态. + fun setLoadingText(loading: Boolean) { + mLoadingText = loading + if (loading) { + applyFixedGutterPaddingForLoading() + } else { + // Recompute gutter once after loading. + // zh-CN: 加载结束后重新计算 gutter(仅一次). + requestLayout() + invalidate() + } + } + + // Expose loading state for outer components to suppress expensive UI updates. + // zh-CN: 对外暴露加载状态, 以便外部组件抑制高开销 UI 更新. + fun isLoadingText(): Boolean = mLoadingText + + // Configure a fixed gutter width for loading. + // zh-CN: 配置加载期间的固定 gutter 宽度. + fun setLoadingGutterDigits(digits: Int) { + mLoadingGutterDigits = digits.coerceIn(2, 10) + if (mLoadingText) { + applyFixedGutterPaddingForLoading() + } + } + + private fun applyFixedGutterPaddingForLoading() { + // Pre-allocate gutter width based on digits, e.g., "888888". + // zh-CN: 基于位数预分配 gutter 宽度, 例如 "888888". + val sample = "8".repeat(mLoadingGutterDigits) + val gutterWidth = paint.measureText(sample) + 20 + if (paddingLeft.toFloat() != gutterWidth) { + setPadding(gutterWidth.toInt(), 0, 0, 0) + } + } + // Public API to toggle read-only mode. // zh-CN: 切换只读模式的公开接口. fun setReadOnly(readOnly: Boolean) { @@ -173,7 +221,8 @@ class CodeEditText : AppCompatEditText { android.R.id.cut, android.R.id.paste, android.R.id.pasteAsPlainText, - android.R.id.replaceText -> return false + android.R.id.replaceText, + -> return false } } return super.onTextContextMenuItem(id) @@ -279,6 +328,12 @@ class CodeEditText : AppCompatEditText { } private fun updatePaddingForGutter() { + // During loading, keep gutter fixed to avoid setPadding() loops and relayout storms. + // zh-CN: 加载期间保持 gutter 固定, 避免 setPadding() 循环与频繁 relayout. + if (mLoadingText) { + return + } + // 根据行号计算左边距 padding 留出绘制行号的空间 val max = lineCount.toString() val gutterWidth = paint.measureText(max) + 20 @@ -292,7 +347,9 @@ class CodeEditText : AppCompatEditText { private fun drawText(canvas: Canvas) { if (mFirstLineForDraw < 0) return - val textLength = mHighlightTokens?.text?.length ?: 0 + val highlightTokens = mHighlightTokens + val safeText = text ?: return + val textLength = highlightTokens?.text?.length ?: 0 val scrollX = (mParentScrollView!!.scrollX + scrollX - paddingLeft).coerceAtLeast(0) for (line in mFirstLineForDraw..lineCount.coerceAtMost(mLastLineForDraw)) { @@ -303,7 +360,6 @@ class CodeEditText : AppCompatEditText { val lineBottom = layout.getLineTop(lineNumber) val lineTop = layout.getLineTop(line) val lineBaseline = lineBottom - layout.getLineDescent(line) - val highlightTokens = mHighlightTokens // if there is a breakpoint at this line, draw a highlight background for line number if (breakpoints.containsKey(line)) { @@ -324,15 +380,10 @@ class CodeEditText : AppCompatEditText { /* paint = */ paint.apply { color = mTheme.lineNumberColor }, ) - if (highlightTokens == null) continue - - // Draw code - val lineStart = layout.getLineStart(line) // Never draw line-break control characters. // zh-CN: 永远不要绘制换行等控制字符. - val safeText = text ?: continue var lineEnd = layout.getLineEnd(line).coerceAtMost(safeText.length) while (lineEnd > lineStart) { val ch = safeText[lineEnd - 1] @@ -343,11 +394,28 @@ class CodeEditText : AppCompatEditText { } } + // Fast path: no syntax highlighting, draw the line once with default color. + // zh-CN: 快速路径: 无语法高亮时, 使用默认颜色一次性绘制整行. + if (highlightTokens == null) { + val visibleCharStart = getVisibleCharIndex(paint, scrollX, lineStart, lineEnd) + val visibleCharEnd = (getVisibleCharIndex(paint, scrollX + mParentScrollView!!.width, lineStart, lineEnd) + 1) + .coerceAtMost(lineEnd) + + if (visibleCharStart >= visibleCharEnd) continue + + paint.color = mTheme.getColorForToken(Token.NAME) + runCatching { + val offsetX = paint.measureText(safeText, lineStart, visibleCharStart) + canvas.drawText(safeText, visibleCharStart, visibleCharEnd, paddingLeft + offsetX, lineBaseline.toFloat(), paint) + }.onFailure { it.printStackTrace() } + continue + } + if (lineStart >= textLength) continue if (lineEnd > textLength) continue // If this is an empty line (or the line only contains line-break chars), skip drawing. - // zh-CN: 如果这是空白行(或该行只包含换行字符), 则跳过绘制. + // zh-CN: 如果这是空白行 (或该行只包含换行字符), 则跳过绘制. if (lineStart >= lineEnd) continue val localColors = highlightTokens.colors @@ -403,8 +471,8 @@ class CodeEditText : AppCompatEditText { } runCatching { - val offsetX = paint.measureText(currentText, lineStart, previousColorPos) - canvas.drawText(currentText, previousColorPos, visibleCharEnd, paddingLeft + offsetX, lineBaseline.toFloat(), paint) + val offsetX = paint.measureText(safeText, lineStart, previousColorPos) + canvas.drawText(safeText, previousColorPos, visibleCharEnd, paddingLeft + offsetX, lineBaseline.toFloat(), paint) }.onFailure { it.printStackTrace() runCatching { @@ -460,6 +528,13 @@ class CodeEditText : AppCompatEditText { } override fun onSelectionChanged(selStart: Int, selEnd: Int) { + // Skip selection callbacks during bulk loading to keep UI responsive. + // zh-CN: 批量加载期间跳过 selection 回调, 以保持 UI 响应. + if (mLoadingText) { + super.onSelectionChanged(selStart, selEnd) + return + } + // 调用父类的 onSelectionChanged 时会发送一个 AccessibilityEvent, 当文本过大时造成异常 // super.onSelectionChanged(selStart, selEnd); // 父类构造函数会调用 onSelectionChanged, 此时 mCursorChangeCallbacks 还没有初始化 @@ -524,6 +599,13 @@ class CodeEditText : AppCompatEditText { fun removeCursorChangeCallback(callback: CursorChangeCallback) = mCursorChangeCallbacks!!.remove(callback) + // Clear syntax highlight tokens and redraw with plain text. + // zh-CN: 清空语法高亮 tokens, 并用纯文本方式重绘. + fun clearHighlightTokens() { + mHighlightTokens = null + postInvalidate() + } + fun updateHighlightTokens(highlightTokens: HighlightTokens) { if (mHighlightTokens != null && mHighlightTokens!!.id >= highlightTokens.id) { return diff --git a/app/src/main/java/org/autojs/autojs/ui/edit/editor/CodeEditor.kt b/app/src/main/java/org/autojs/autojs/ui/edit/editor/CodeEditor.kt index 8877e58c..63f16c36 100644 --- a/app/src/main/java/org/autojs/autojs/ui/edit/editor/CodeEditor.kt +++ b/app/src/main/java/org/autojs/autojs/ui/edit/editor/CodeEditor.kt @@ -3,11 +3,15 @@ package org.autojs.autojs.ui.edit.editor import android.annotation.SuppressLint import android.content.Context import android.graphics.Canvas +import android.os.Handler +import android.os.Looper +import android.os.SystemClock import android.util.AttributeSet import android.view.LayoutInflater import android.view.MotionEvent import android.view.ScaleGestureDetector import android.view.ScaleGestureDetector.SimpleOnScaleGestureListener +import android.view.WindowManager import com.afollestad.materialdialogs.MaterialDialog import io.reactivex.Observable import org.autojs.autojs.core.pref.Pref.getEditorTextSize @@ -53,7 +57,7 @@ import kotlin.math.floor /** * Transformed by SuperMonster003 on Jul 16, 2023. * Modified by SuperMonster003 as of Jul 16, 2023. - * Modified by JetBrains AI Assistant (GPT-5.2) as of Feb 6, 2026. + * Modified by JetBrains AI Assistant (GPT-5.2) as of Feb 8, 2026. */ class CodeEditor : HVScrollView { @@ -65,6 +69,15 @@ class CodeEditor : HVScrollView { ThemeColorHelper.setThemeColorPrimary(it, true) } + // Whether the user is interacting with the editor via touch. + // zh-CN: 用户是否正在通过触摸与编辑器交互. + @Volatile + private var mUserTouching = false + + // Public getter for streaming loader to prioritize scroll responsiveness. + // zh-CN: 给流式加载器使用的公开读取口, 用于优先保障滚动响应性. + fun isUserTouching(): Boolean = mUserTouching + val lineCount get() = Observable.just(codeEditText.layout.lineCount) @@ -146,6 +159,46 @@ class CodeEditor : HVScrollView { private var mFoundIndex = -1 private var mLastScaleFactor = 1.0 + private val mUiHandler = Handler(Looper.getMainLooper()) + + // Delay showing the loading dialog to avoid flicker for fast operations. + // zh-CN: 延迟显示加载对话框, 避免快速操作产生闪烁. + private val mProgressShowDelayMs = 1500L + + // Once shown, keep the dialog visible for at least this duration to avoid flash. + // zh-CN: 对话框一旦出现, 至少显示一段时间, 避免一闪而过. + private val mProgressMinShowMs = 500L + + private var mProgressRequested = false + private var mProgressShownAtMs = 0L + private var mProgressInteractive = false + + private val mShowProgressRunnable = Runnable { + if (!mProgressRequested) return@Runnable + if (mProcessDialog?.isShowing == true) return@Runnable + + mProcessDialog = MaterialDialog.Builder(context) + .content(R.string.text_processing) + // Text only, no progress spinner. + // zh-CN: 仅显示文字, 不使用进度圆圈动画. + .cancelable(false) + .canceledOnTouchOutside(false) + .show() + + mProgressShownAtMs = SystemClock.uptimeMillis() + + // Make it non-modal (optional) so user can scroll/view during loading. + // zh-CN: 可选地设置为非模态, 使用户在加载时仍可滚动/查看. + if (mProgressInteractive) { + mProcessDialog?.window?.let { w -> + w.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) + w.setDimAmount(0f) + w.addFlags(WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL) + w.addFlags(WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH) + } + } + } + constructor(context: Context?) : super(context) constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs) @@ -154,6 +207,92 @@ class CodeEditor : HVScrollView { applyScaleGesture() } + fun refreshHighlightTokensIfAllowed() { + // Force a highlight refresh after bulk loading, when loading flags are cleared. + // zh-CN: 在批量加载结束且 loading 标记已清除后, 主动触发一次高亮刷新. + val t = codeEditText.text?.toString() ?: return + mJavaScriptHighlighter.updateTokens(t) + } + + /** + * Show or hide a "processing" indicator. + * + * Behavior: + * - Delayed show to avoid flicker. + * - Minimum show time once visible. + * - Optional interactive mode: don't block touches and don't dim background. + * + * zh-CN: + * 显示或隐藏 "处理中" 提示. + * + * 行为: + * - 延迟显示以避免闪烁. + * - 一旦出现则保证最短展示时间. + * - 可选交互模式: 不拦截触摸/不压暗背景. + */ + /** + * Show or hide a "processing" indicator. + * + * Behavior: + * - Delayed show to avoid flicker. + * - Minimum show time once visible. + * - Optional interactive mode: don't block touches and don't dim background. + * + * zh-CN: + * 显示或隐藏 "处理中" 提示. + * + * 行为: + * - 延迟显示以避免闪烁. + * - 一旦出现则保证最短展示时间. + * - 可选交互模式: 不拦截触摸/不压暗背景. + */ + fun setProgress(progress: Boolean, interactive: Boolean = false) { + mProgressInteractive = interactive + + if (progress) { + mProgressRequested = true + + // If already showing, keep it. + // zh-CN: 若已显示则保持不变. + if (mProcessDialog?.isShowing == true) return + + // Schedule delayed show. + // zh-CN: 延迟调度显示. + mUiHandler.removeCallbacks(mShowProgressRunnable) + mUiHandler.postDelayed(mShowProgressRunnable, mProgressShowDelayMs) + return + } + + // Hide requested. + // zh-CN: 请求隐藏. + mProgressRequested = false + mUiHandler.removeCallbacks(mShowProgressRunnable) + + val dlg = mProcessDialog + if (dlg == null || dlg.isShowing != true) { + mProcessDialog = null + return + } + + val elapsed = SystemClock.uptimeMillis() - mProgressShownAtMs + val remain = (mProgressMinShowMs - elapsed).coerceAtLeast(0L) + + if (remain == 0L) { + dlg.dismiss() + mProcessDialog = null + return + } + + mUiHandler.postDelayed({ + // Only dismiss if no new show request came in. + // zh-CN: 仅当没有新的显示请求时才 dismiss. + if (!mProgressRequested) { + runCatching { dlg.dismiss() } + mProcessDialog = null + } + }, remain) + } + private fun applyScaleGesture(key: String? = null) { var niceKey = key if (niceKey == null) { @@ -174,6 +313,23 @@ class CodeEditor : HVScrollView { @SuppressLint("ClickableViewAccessibility") override fun onTouchEvent(ev: MotionEvent): Boolean { + when (ev.actionMasked) { + MotionEvent.ACTION_DOWN, + MotionEvent.ACTION_POINTER_DOWN, + MotionEvent.ACTION_MOVE -> { + // Mark touching as early as possible. + // zh-CN: 尽可能早地标记触摸中状态. + mUserTouching = true + } + MotionEvent.ACTION_UP, + MotionEvent.ACTION_CANCEL, + MotionEvent.ACTION_POINTER_UP -> { + // Clear touching flag when gesture ends. + // zh-CN: 手势结束时清除触摸中标记. + mUserTouching = false + } + } + return mScaleGestureDetector?.let { it.onTouchEvent(ev) !it.isInProgress && super.onTouchEvent(ev) @@ -182,6 +338,14 @@ class CodeEditor : HVScrollView { override fun onScrollChanged(l: Int, t: Int, oldl: Int, oldt: Int) { super.onScrollChanged(l, t, oldl, oldt) + + // Avoid scheduling extra invalidations during bulk loading. + // zh-CN: 批量加载期间避免额外调度 invalidate, 降低重绘压力. + if (codeEditText.isLoadingText()) { + codeEditText.invalidate() + return + } + codeEditText.postInvalidate() } @@ -301,16 +465,10 @@ class CodeEditor : HVScrollView { mTextViewRedoUndo.isEnabled = enabled } - fun setProgress(progress: Boolean) { - mProcessDialog?.dismiss() - mProcessDialog = when { - !progress -> null - else -> MaterialDialog.Builder(context) - .content(R.string.text_processing) - .progress(true, 0) - .cancelable(false) - .show() - } + fun markUndoRedoBaselineAsUnchanged() { + // Reset undo/redo history but keep current text intact. + // zh-CN: 重置 undo/redo 历史但保持当前文本不变. + mTextViewRedoUndo.resetHistoryAsUnchanged() } fun addCursorChangeCallback(callback: CursorChangeCallback?) { diff --git a/app/src/main/java/org/autojs/autojs/ui/edit/editor/HVScrollView.java b/app/src/main/java/org/autojs/autojs/ui/edit/editor/HVScrollView.java index 953c8ae1..94e6cdcf 100644 --- a/app/src/main/java/org/autojs/autojs/ui/edit/editor/HVScrollView.java +++ b/app/src/main/java/org/autojs/autojs/ui/edit/editor/HVScrollView.java @@ -21,6 +21,8 @@ import java.util.List; /** * Reference to ScrollView and HorizontalScrollView + * + * Modified by JetBrains AI Assistant (GPT-5.2) as of Feb 8, 2026. */ public class HVScrollView extends FrameLayout { static final int ANIMATED_SCROLL_GAP = 250; @@ -385,6 +387,20 @@ public class HVScrollView extends FrameLayout { final int scrollX = getScrollX(); final int scrollY = getScrollY(); final View child = getChildAt(0); + + // Fallback for transient layout states during progressive loading. + // zh-CN: 渐进式加载期间可能出现的短暂布局状态回退处理. + // + // When the child is not measured/layouted yet (width/height == 0), the original + // bounds check may reject all ACTION_DOWN events, causing scrolling to be stuck. + // zh-CN: 当子 View 尚未完成测量/布局时(width/height == 0), 原边界判断可能拒绝所有 ACTION_DOWN, + // zh-CN: 从而导致滚动完全失效. + final int childWidth = child.getWidth(); + final int childHeight = child.getHeight(); + if (childWidth <= 0 || childHeight <= 0) { + return x >= 0 && x < getWidth() && y >= 0 && y < getHeight(); + } + return !(y < child.getTop() - scrollY || y >= child.getBottom() - scrollY || x < child.getLeft() - scrollX diff --git a/app/src/main/java/org/autojs/autojs/ui/edit/editor/JavaScriptHighlighter.java b/app/src/main/java/org/autojs/autojs/ui/edit/editor/JavaScriptHighlighter.java index 1a6c8f75..8f980615 100644 --- a/app/src/main/java/org/autojs/autojs/ui/edit/editor/JavaScriptHighlighter.java +++ b/app/src/main/java/org/autojs/autojs/ui/edit/editor/JavaScriptHighlighter.java @@ -17,8 +17,15 @@ import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; +/** + * Modified by JetBrains AI Assistant (GPT-5.2) as of Feb 8, 2026. + */ public class JavaScriptHighlighter implements SimpleTextWatcher.AfterTextChangedListener { + // Syntax highlight hard limit for performance and memory. + // zh-CN: 为性能和内存设置的语法高亮硬限制. + public static final int MAX_HIGHLIGHT_CHARS = 512 * 1024; + public static class HighlightTokens { public final int[] colors; @@ -81,6 +88,28 @@ public class JavaScriptHighlighter implements SimpleTextWatcher.AfterTextChanged @Override public void afterTextChanged(Editable s) { + if (mTheme == null) { + return; + } + + // Skip highlighting during progressive loading to avoid UI-thread allocations and CPU contention. + // zh-CN: 渐进式加载期间跳过高亮, 避免 UI 线程分配与 CPU 争用. + if (mCodeEditText != null && mCodeEditText.isLoadingText()) { + mRunningHighlighterId.incrementAndGet(); + mCodeEditText.clearHighlightTokens(); + return; + } + + // Avoid calling toString() on huge Editable, which allocates a large String on UI thread. + // zh-CN: 避免对超大的 Editable 调用 toString(), 否则会在 UI 线程分配巨大的 String. + if (s != null && s.length() > MAX_HIGHLIGHT_CHARS) { + mRunningHighlighterId.incrementAndGet(); + if (mCodeEditText != null) { + mCodeEditText.clearHighlightTokens(); + } + return; + } + updateTokens(s.toString()); } @@ -92,10 +121,24 @@ public class JavaScriptHighlighter implements SimpleTextWatcher.AfterTextChanged if (mTheme == null) { return; } + + // Disable highlighting for very large text to avoid ANR/OOM on low-end devices. + // zh-CN: 对超大文本禁用高亮, 避免低端设备出现 ANR/OOM. + if (sourceString.length() > MAX_HIGHLIGHT_CHARS) { + mRunningHighlighterId.incrementAndGet(); + mCodeEditText.clearHighlightTokens(); + return; + } + final int id = mRunningHighlighterId.incrementAndGet(); if (mExecutorService.isShutdown() || mExecutorService.isTerminated() || mExecutorService.isTerminating()) { return; } + + // Keep only the latest highlight task. + // zh-CN: 仅保留最新的高亮任务, 丢弃过期任务以避免队列堆积. + mExecutorService.getQueue().clear(); + mExecutorService.execute(() -> { try { updateTokens(sourceString, id); @@ -106,14 +149,27 @@ public class JavaScriptHighlighter implements SimpleTextWatcher.AfterTextChanged } private void updateTokens(String sourceString, int id) throws IOException { + // Drop stale tasks early. + // zh-CN: 尽早丢弃过期任务. + if (id != mRunningHighlighterId.get()) { + return; + } + TokenStream ts = new TokenStream(null, sourceString, 0); HighlightTokens highlightTokens = new HighlightTokens(sourceString, id); int token; int color = mTheme.getColorForToken(Token.NAME); + while ((token = ts.getToken()) != Token.EOF) { + // Abort quickly if a newer task arrives. + // zh-CN: 如果有更新任务到来, 则尽快中止. + if (id != mRunningHighlighterId.get()) { + return; + } color = mTheme.getColorForToken(token); highlightTokens.addToken(ts.getTokenBeg(), ts.getTokenEnd(), color); } + if (highlightTokens.getCharCount() < sourceString.length()) { highlightTokens.addToken(highlightTokens.getCharCount(), sourceString.length(), color); } diff --git a/app/src/main/java/org/autojs/autojs/ui/edit/editor/TextViewUndoRedo.java b/app/src/main/java/org/autojs/autojs/ui/edit/editor/TextViewUndoRedo.java index f9dfa49a..9f78ae95 100644 --- a/app/src/main/java/org/autojs/autojs/ui/edit/editor/TextViewUndoRedo.java +++ b/app/src/main/java/org/autojs/autojs/ui/edit/editor/TextViewUndoRedo.java @@ -17,6 +17,8 @@ import android.widget.TextView; /** * A generic undo/redo implementation for TextViews. + * + * Modified by JetBrains AI Assistant (GPT-5.2) as of Feb 8, 2026. */ public class TextViewUndoRedo { @@ -72,10 +74,39 @@ public class TextViewUndoRedo { mEnabled = enabled; } + /** + * Reset undo/redo history without modifying the current text. + * + * This is useful after bulk loading text progressively, where the text is already in the TextView + * and we only want to treat it as the "baseline" (unchanged) state. + * + * zh-CN: + * + * 在不修改当前文本的前提下重置 undo/redo 历史. + * + * 适用于渐进式批量加载文本后: 文本已经在 TextView 中, 此时只需要把它视为 "基线" (未修改) 状态. + */ + public final void resetHistoryAsUnchanged() { + clearHistory(); + markTextAsUnchanged(); + } + + // public final void setDefaultText(CharSequence text) { + // clearHistory(); + // mIsUndoOrRedo = true; + // ((Editable) mTextView.getText()).replace(0, text.length(), text); + // mIsUndoOrRedo = false; + // } + public final void setDefaultText(CharSequence text) { clearHistory(); mIsUndoOrRedo = true; - ((Editable) mTextView.getText()).replace(0, text.length(), text); + Editable editable = (Editable) mTextView.getText(); + + // Replace the entire old content, not based on the new text length. + // zh-CN: 替换整个旧内容, 而不是按新文本长度截断替换范围. + editable.replace(0, editable.length(), text); + mIsUndoOrRedo = false; } diff --git a/app/src/main/res/layout/editor_view.xml b/app/src/main/res/layout/editor_view.xml index 89c71069..a7a2422d 100644 --- a/app/src/main/res/layout/editor_view.xml +++ b/app/src/main/res/layout/editor_view.xml @@ -83,6 +83,29 @@ android:layout_alignParentTop="true" android:layout_toEndOf="@+id/functions" /> + + + + + + 修改安全设置 修改系统设置 后台弹出界面 + 加载完毕 \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 007d3648..d10e0053 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1561,4 +1561,5 @@ Write security settings Write system settings Display pop-up windows while running in the background + Loading completed \ No newline at end of file diff --git a/version.properties b/version.properties index ff7711b1..504af6ee 100644 --- a/version.properties +++ b/version.properties @@ -1,5 +1,5 @@ -#Sat Feb 07 23:30:55 CST 2026 -BUILD_TIME=1770478255016 +#Sun Feb 08 13:21:45 CST 2026 +BUILD_TIME=1770528105646 COMPILE_SDK_VERSION=36 IMAGE_QUANT_CMAKE_VERSION=3.22.1 IMAGE_QUANT_NDK_VERSION=26.1.10909125 @@ -27,6 +27,6 @@ RAPID_OCR_OPENCV_MOBILE_LABEL_VERSION=13 RAPID_OCR_OPENCV_MOBILE_VERSION=4.5.3 TARGET_SDK_VERSION=36 TARGET_SDK_VERSION_INRT=29 -VERSION_BUILD=3710 +VERSION_BUILD=3713 VERSION_NAME=6.7.0 Alpha19 VSCODE_EXT_REQUIRED_VERSION=1.0.13