6.7.0 - Alpha19 - 代码编辑器 "查找/替换" 支持状态持久化及实时显示搜索计数信息
This commit is contained in:
@@ -442,8 +442,24 @@ public class EditorMenu {
|
||||
mEditor.getSelection()
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(s -> {
|
||||
FindOrReplaceDialogBuilder builder = new FindOrReplaceDialogBuilder(mContext, mEditorView)
|
||||
.setQueryIfNotEmpty(s);
|
||||
// Prefer restoring last search (even after exit search mode).
|
||||
// zh-CN: 优先恢复 "上一次搜索" (即使已退出搜索模式), 避免 selection (命中内容) 覆盖关键词.
|
||||
String preferredQuery = mEditorView.getPreferredSearchQueryForDialogOrNull();
|
||||
boolean preferredRegex = mEditorView.getPreferredSearchUsingRegexForDialog();
|
||||
|
||||
FindOrReplaceDialogBuilder builder = new FindOrReplaceDialogBuilder(mContext, mEditorView);
|
||||
|
||||
// Only override regex checkbox when we are restoring an actual previous query.
|
||||
// Otherwise keep the persisted checkbox state from SharedPreferences (restoreState()).
|
||||
//
|
||||
// zh-CN: 仅当我们确实在恢复 "上一次 query" 时才覆盖 regex 勾选状态;
|
||||
// 否则使用 SharedPreferences 中持久化的勾选状态.
|
||||
if (preferredQuery != null) {
|
||||
builder.setUsingRegex(preferredRegex);
|
||||
}
|
||||
|
||||
builder.setQueryIfNotEmpty(preferredQuery != null ? preferredQuery : s);
|
||||
|
||||
builder.positiveColorRes(R.color.dialog_button_attraction);
|
||||
builder.show();
|
||||
});
|
||||
|
||||
@@ -112,6 +112,7 @@ import java.util.concurrent.ConcurrentLinkedQueue
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import java.util.regex.Pattern
|
||||
|
||||
/**
|
||||
* Created by Stardust on Sep 28, 2017.
|
||||
@@ -159,7 +160,7 @@ class EditorView : LinearLayout, OnHintClickListener, ClickCallback, ToolbarFrag
|
||||
get() = AutoJs.instance.scriptEngineService.getScriptExecution(scriptExecutionId)
|
||||
|
||||
// Whether the editor is currently loading text (including streaming append).
|
||||
// zh-CN: 编辑器是否正在加载文本(包括流式追加阶段).
|
||||
// zh-CN: 编辑器是否正在加载文本 (包括流式追加阶段).
|
||||
@Volatile
|
||||
private var mEditorLoading: Boolean = false
|
||||
|
||||
@@ -169,7 +170,7 @@ class EditorView : LinearLayout, OnHintClickListener, ClickCallback, ToolbarFrag
|
||||
private var mLargeFileMode: Boolean = false
|
||||
|
||||
// Active large-file loading cancel flag.
|
||||
// zh-CN: 大文件加载的取消标记(当前会话).
|
||||
// zh-CN: 大文件加载的取消标记 (当前会话).
|
||||
private val mLargeFileCancel = AtomicBoolean(false)
|
||||
|
||||
// Active IO subscription disposable for large-file loading.
|
||||
@@ -178,11 +179,11 @@ class EditorView : LinearLayout, OnHintClickListener, ClickCallback, ToolbarFrag
|
||||
private var mLargeFileIoDisposable: Disposable? = null
|
||||
|
||||
// Active streaming source stream reference (for forced close).
|
||||
// zh-CN: 流式读取的底层流引用(用于强制 close 以打断阻塞 read).
|
||||
// zh-CN: 流式读取的底层流引用 (用于强制 close 以打断阻塞 read).
|
||||
private val mLargeFileStreamRef = AtomicReference<InputStream?>(null)
|
||||
|
||||
// Active Choreographer callback for streaming append.
|
||||
// zh-CN: 流式追加的 Choreographer 回调引用(用于取消帧回调).
|
||||
// zh-CN: 流式追加的 Choreographer 回调引用 (用于取消帧回调).
|
||||
@Volatile
|
||||
private var mLargeFileFrameCallback: Choreographer.FrameCallback? = null
|
||||
|
||||
@@ -263,6 +264,36 @@ class EditorView : LinearLayout, OnHintClickListener, ClickCallback, ToolbarFrag
|
||||
// zh-CN: 复用的窗口可见区域 Rect.
|
||||
private val mTmpWindowVisibleRect = Rect()
|
||||
|
||||
// Search mode UI state.
|
||||
// zh-CN: 搜索模式 UI 状态.
|
||||
private var mInSearchMode = false
|
||||
private var mToolbarTitleBeforeSearch: CharSequence? = null
|
||||
private var mToolbarSubtitleBeforeSearch: CharSequence? = null
|
||||
private var mCurrentSearchQuery: String? = null
|
||||
private var mCurrentSearchUsingRegex: Boolean = false
|
||||
private var mCurrentSearchShowReplaceItem: Boolean = false
|
||||
|
||||
// Remember last successful search even after exiting search mode.
|
||||
// zh-CN: 即使退出搜索模式, 也保留上一次成功搜索的 query/regex.
|
||||
private var mLastSearchQuery: String? = null
|
||||
private var mLastSearchUsingRegex: Boolean = false
|
||||
|
||||
// Search stats (global count) state.
|
||||
// zh-CN: 搜索统计 (全局匹配数) 状态.
|
||||
private var mSearchStatsGeneration: Int = 0
|
||||
private var mSearchStatsDisposable: Disposable? = null
|
||||
private var mSearchStatsCurrentOrdinal: Int? = null
|
||||
private var mSearchStatsTotal: Int? = null
|
||||
private var mSearchStatsTotalCapped: Boolean = false
|
||||
private var mSearchStatsCounting: Boolean = false
|
||||
|
||||
private val mSearchStatsDebounceMs = 300L
|
||||
private val mSearchStatsMaxTotal = 1000
|
||||
|
||||
private val mSearchStatsDebounceRunnable = Runnable {
|
||||
restartSearchStatsAsync(reason = "debounce")
|
||||
}
|
||||
|
||||
constructor(context: Context) : super(context)
|
||||
|
||||
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
|
||||
@@ -331,7 +362,7 @@ class EditorView : LinearLayout, OnHintClickListener, ClickCallback, ToolbarFrag
|
||||
}
|
||||
|
||||
// Loading is finished for intent-handling stage (actual file loading may continue).
|
||||
// zh-CN: Intent 处理阶段结束(实际文件加载可能仍在继续).
|
||||
// zh-CN: Intent 处理阶段结束 (实际文件加载可能仍在继续).
|
||||
mEditorLoading = false
|
||||
syncPrimaryMenuState()
|
||||
}
|
||||
@@ -383,20 +414,18 @@ class EditorView : LinearLayout, OnHintClickListener, ClickCallback, ToolbarFrag
|
||||
|
||||
// Compute the real overlap between editor and the visible window area.
|
||||
// zh-CN: 计算 editor 与窗口可见区域之间的真实重叠量.
|
||||
val targetBottom = if (imeVisible) {
|
||||
val activity = findHostActivityOrNull()
|
||||
if (activity != null) {
|
||||
activity.window.decorView.getWindowVisibleDisplayFrame(mTmpWindowVisibleRect)
|
||||
val activity = findHostActivityOrNull()
|
||||
val targetBottom = if (activity != null) {
|
||||
activity.window.decorView.getWindowVisibleDisplayFrame(mTmpWindowVisibleRect)
|
||||
|
||||
val loc = IntArray(2)
|
||||
editor.getLocationOnScreen(loc)
|
||||
val editorBottomOnScreen = loc[1] + editor.height
|
||||
val loc = IntArray(2)
|
||||
editor.getLocationOnScreen(loc)
|
||||
val editorBottomOnScreen = loc[1] + editor.height
|
||||
|
||||
(editorBottomOnScreen - mTmpWindowVisibleRect.bottom).coerceAtLeast(0)
|
||||
} else {
|
||||
0
|
||||
}
|
||||
} else 0
|
||||
(editorBottomOnScreen - mTmpWindowVisibleRect.bottom).coerceAtLeast(0)
|
||||
} else {
|
||||
0
|
||||
}
|
||||
|
||||
if (targetBottom != mLastImeBottomInset) {
|
||||
mLastImeBottomInset = targetBottom
|
||||
@@ -419,6 +448,12 @@ class EditorView : LinearLayout, OnHintClickListener, ClickCallback, ToolbarFrag
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestApplyInsetsSoon() {
|
||||
// Helps fixing timing issues when IME hides/shows during dialog dismiss/focus change.
|
||||
// zh-CN: 用于修复对话框 dismiss/焦点切换期间 IME 显隐导致的时序问题.
|
||||
post { ViewCompat.requestApplyInsets(this@EditorView) }
|
||||
}
|
||||
|
||||
private fun ensureCursorVisibleIfPossible() {
|
||||
// Bring the caret into visible area when keyboard shows, without "scrolling back" on hide.
|
||||
// zh-CN: 键盘弹起时将光标区域滚动到可见范围内, 键盘收起时不回滚.
|
||||
@@ -551,14 +586,13 @@ class EditorView : LinearLayout, OnHintClickListener, ClickCallback, ToolbarFrag
|
||||
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: 大文件: 在增强栏显示加载提示, 允许查看/滚动(只读).
|
||||
// zh-CN: 大文件: 在增强栏显示加载提示, 允许查看/滚动 (只读).
|
||||
beginEditorLoadingUi(context.getString(R.string.text_loading_with_dots))
|
||||
return loadUriStreamed(uri)
|
||||
}
|
||||
|
||||
return Observable
|
||||
.fromCallable {
|
||||
// ... existing code ...
|
||||
val resolver = context.contentResolver
|
||||
val rawBytes = resolver.openInputStream(uri)?.use { it.readBytes() } ?: ByteArray(0)
|
||||
|
||||
@@ -638,6 +672,21 @@ class EditorView : LinearLayout, OnHintClickListener, ClickCallback, ToolbarFrag
|
||||
(context as? HostActivity)?.backPressedObserver?.unregisterHandler(mFunctionsKeyboardHelper)
|
||||
}
|
||||
|
||||
// Expose current/last search session for reopening find/replace dialog.
|
||||
// zh-CN: 对外暴露当前/上一次搜索会话信息, 用于再次打开查找/替换对话框时恢复状态.
|
||||
fun getCurrentSearchQueryOrNull(): String? = mCurrentSearchQuery
|
||||
fun getCurrentSearchUsingRegex(): Boolean = mCurrentSearchUsingRegex
|
||||
fun isInSearchMode(): Boolean = mInSearchMode
|
||||
|
||||
fun getLastSearchQueryOrNull(): String? = mLastSearchQuery
|
||||
fun getLastSearchUsingRegex(): Boolean = mLastSearchUsingRegex
|
||||
|
||||
fun getPreferredSearchQueryForDialogOrNull(): String? =
|
||||
(mCurrentSearchQuery ?: mLastSearchQuery)
|
||||
|
||||
fun getPreferredSearchUsingRegexForDialog(): Boolean =
|
||||
(if (mInSearchMode) mCurrentSearchUsingRegex else mLastSearchUsingRegex)
|
||||
|
||||
// Cancel current large-file loading as soon as possible.
|
||||
// zh-CN: 尽快取消当前大文件加载.
|
||||
fun cancelLargeFileLoading() {
|
||||
@@ -666,7 +715,7 @@ class EditorView : LinearLayout, OnHintClickListener, ClickCallback, ToolbarFrag
|
||||
}
|
||||
|
||||
// Cleanup UI state best-effort (do NOT re-enable editing in read-only editor).
|
||||
// zh-CN: 尽力清理 UI 状态(只读编辑器不要被重新启用编辑).
|
||||
// zh-CN: 尽力清理 UI 状态 (只读编辑器不要被重新启用编辑).
|
||||
post {
|
||||
runCatching {
|
||||
endEditorLoadingUi()
|
||||
@@ -704,34 +753,34 @@ class EditorView : LinearLayout, OnHintClickListener, ClickCallback, ToolbarFrag
|
||||
val bis = BufferedInputStream(counted)
|
||||
|
||||
try {
|
||||
bis.use { input ->
|
||||
bis.use { input ->
|
||||
if (mLargeFileCancel.get() || emitter.isDisposed) {
|
||||
// Cancel early.
|
||||
// zh-CN: 提前取消.
|
||||
return@use
|
||||
}
|
||||
|
||||
val sampleLimitBytes = 64 * 1024
|
||||
input.mark(sampleLimitBytes + 8)
|
||||
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 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)
|
||||
val detected = StringUtils.detectCharset(sampleBytes)
|
||||
mCurrentCharsetConfidence = detected.confidence ?: 0
|
||||
mCurrentCharset = detected.charsetOrDefault()
|
||||
mHadBom = StringUtils.hasBom(sampleBytes, mCurrentCharset)
|
||||
|
||||
runCatching { input.reset() }.getOrElse {
|
||||
runCatching { input.reset() }.getOrElse {
|
||||
if (!emitter.isDisposed && !mLargeFileCancel.get()) {
|
||||
emitter.onError(IllegalStateException("Stream reset failed for $uri"))
|
||||
emitter.onError(IllegalStateException("Stream reset failed for $uri"))
|
||||
}
|
||||
return@use
|
||||
}
|
||||
return@use
|
||||
}
|
||||
|
||||
streamDecodeAndEmit(input, emitter)
|
||||
}
|
||||
streamDecodeAndEmit(input, emitter)
|
||||
}
|
||||
} finally {
|
||||
// Clear reference when finished/failed.
|
||||
// zh-CN: 结束/失败后清理引用.
|
||||
@@ -776,8 +825,8 @@ class EditorView : LinearLayout, OnHintClickListener, ClickCallback, ToolbarFrag
|
||||
}, { e ->
|
||||
done.set(true)
|
||||
if (!mLargeFileCancel.get()) {
|
||||
post { endEditorLoadingUi() }
|
||||
e.printStackTrace()
|
||||
post { endEditorLoadingUi() }
|
||||
e.printStackTrace()
|
||||
}
|
||||
}, {
|
||||
done.set(true)
|
||||
@@ -811,7 +860,7 @@ class EditorView : LinearLayout, OnHintClickListener, ClickCallback, ToolbarFrag
|
||||
val choreographer = Choreographer.getInstance()
|
||||
|
||||
// Per-frame time budget for appending text (favor throughput).
|
||||
// zh-CN: 每帧用于追加文本的时间预算(优先吞吐).
|
||||
// zh-CN: 每帧用于追加文本的时间预算 (优先吞吐).
|
||||
val appendBudgetMsIdle = 12L
|
||||
val appendBudgetMsTouching = 3L
|
||||
|
||||
@@ -1033,7 +1082,7 @@ class EditorView : LinearLayout, OnHintClickListener, ClickCallback, ToolbarFrag
|
||||
// editor.setProgress(true)
|
||||
|
||||
// Frame time budget in ms (tune for devices).
|
||||
// zh-CN: 单帧时间预算(毫秒), 可按设备调参.
|
||||
// zh-CN: 单帧时间预算 (毫秒), 可按设备调参.
|
||||
val frameBudgetMs = 7L
|
||||
|
||||
// Adaptive chunk size to balance layout cost and responsiveness.
|
||||
@@ -1177,8 +1226,31 @@ class EditorView : LinearLayout, OnHintClickListener, ClickCallback, ToolbarFrag
|
||||
}
|
||||
syncPrimaryMenuState()
|
||||
})
|
||||
|
||||
editText.addTextChangedListener(SimpleTextWatcher { _ ->
|
||||
// Live refresh search stats in search mode (debounced).
|
||||
// Behavior:
|
||||
// - Immediately show ".../..." to indicate recounting.
|
||||
// - Debounce the actual background scan.
|
||||
//
|
||||
// zh-CN: 搜索模式下实时刷新统计 (带防抖).
|
||||
// - 立刻把 subtitle 变成 ".../..." 表示重新统计中.
|
||||
// - 后台扫描做 debounce, 避免频繁扫全文.
|
||||
if (!mInSearchMode) return@SimpleTextWatcher
|
||||
if (editText.isLoadingText() || mEditorLoading) return@SimpleTextWatcher
|
||||
|
||||
mSearchStatsCounting = true
|
||||
mSearchStatsCurrentOrdinal = null
|
||||
mSearchStatsTotal = null
|
||||
mSearchStatsTotalCapped = false
|
||||
updateSearchSubtitleBestEffort()
|
||||
|
||||
scheduleSearchStatsRefreshDebounced()
|
||||
})
|
||||
|
||||
editText.textSize = getEditorTextSize(pxToSp(editText.textSize).toInt()).toFloat()
|
||||
}
|
||||
|
||||
editor.addCursorChangeCallback(object : CodeEditor.CursorChangeCallback {
|
||||
override fun onCursorChange(line: String, cursor: Int) {
|
||||
autoComplete(line, cursor)
|
||||
@@ -1243,13 +1315,47 @@ class EditorView : LinearLayout, OnHintClickListener, ClickCallback, ToolbarFrag
|
||||
R.id.save -> saveFile()
|
||||
R.id.undo -> undo()
|
||||
R.id.redo -> redo()
|
||||
R.id.replace -> replace()
|
||||
R.id.find_next -> findNext()
|
||||
R.id.find_prev -> findPrev()
|
||||
R.id.cancel_search -> cancelSearch()
|
||||
|
||||
R.id.find_next -> {
|
||||
// IMPORTANT: base "next" on current caret, not previous foundIndex.
|
||||
// zh-CN: 重要: "下一个" 必须以当前光标为起点, 而不是以上次 foundIndex 为起点.
|
||||
editor.resetFoundIndexForFindNextFromCursor()
|
||||
findNext()
|
||||
onSearchStateMaybeChanged()
|
||||
}
|
||||
|
||||
R.id.find_prev -> {
|
||||
// IMPORTANT: base "prev" on current caret, not previous foundIndex.
|
||||
// zh-CN: 重要: "上一个" 必须以当前光标为起点, 而不是以上次 foundIndex 为起点.
|
||||
editor.resetFoundIndexForFindPrevFromCursor()
|
||||
findPrev()
|
||||
onSearchStateMaybeChanged()
|
||||
}
|
||||
|
||||
R.id.cancel_search -> {
|
||||
cancelSearch()
|
||||
}
|
||||
|
||||
R.id.replace -> {
|
||||
replace()
|
||||
onSearchStateMaybeChanged()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onSearchStateMaybeChanged() {
|
||||
// After next/prev/replace, selection likely changed -> current ordinal changes.
|
||||
// zh-CN: next/prev/replace 后 selection 可能变化 -> current 序号需要刷新.
|
||||
if (!mInSearchMode) return
|
||||
updateSearchSubtitleBestEffort()
|
||||
scheduleSearchStatsRefreshDebounced()
|
||||
}
|
||||
|
||||
private fun scheduleSearchStatsRefreshDebounced() {
|
||||
mUiHandler.removeCallbacks(mSearchStatsDebounceRunnable)
|
||||
mUiHandler.postDelayed(mSearchStatsDebounceRunnable, mSearchStatsDebounceMs)
|
||||
}
|
||||
|
||||
@SuppressLint("CheckResult")
|
||||
fun runAndSaveFileIfNeeded() {
|
||||
saveToTmpFile()
|
||||
@@ -1677,8 +1783,6 @@ class EditorView : LinearLayout, OnHintClickListener, ClickCallback, ToolbarFrag
|
||||
|
||||
private fun findPrev() = editor.findPrev()
|
||||
|
||||
private fun cancelSearch() = showNormalToolbar()
|
||||
|
||||
private fun showNormalToolbar() {
|
||||
activity.supportFragmentManager.beginTransaction()
|
||||
.replace(R.id.toolbar_menu, mNormalToolbar)
|
||||
@@ -1762,10 +1866,319 @@ class EditorView : LinearLayout, OnHintClickListener, ClickCallback, ToolbarFrag
|
||||
}
|
||||
}
|
||||
|
||||
private fun getHostToolbarOrNull(): androidx.appcompat.widget.Toolbar? {
|
||||
// The activity toolbar id is "toolbar".
|
||||
// zh-CN: Activity Toolbar 的 id 为 toolbar.
|
||||
return activity.findViewById(R.id.toolbar)
|
||||
}
|
||||
|
||||
private fun enterSearchMode(query: String, usingRegex: Boolean, showReplaceItem: Boolean) {
|
||||
if (!mInSearchMode) {
|
||||
val tb = getHostToolbarOrNull()
|
||||
mToolbarTitleBeforeSearch = tb?.title
|
||||
mToolbarSubtitleBeforeSearch = tb?.subtitle
|
||||
}
|
||||
|
||||
mInSearchMode = true
|
||||
mCurrentSearchQuery = query
|
||||
mCurrentSearchUsingRegex = usingRegex
|
||||
mCurrentSearchShowReplaceItem = showReplaceItem
|
||||
|
||||
// Persist last successful search so reopening dialog restores it even after exit.
|
||||
// zh-CN: 记录最近一次成功搜索, 使退出后再次打开对话框仍可恢复.
|
||||
mLastSearchQuery = query
|
||||
mLastSearchUsingRegex = usingRegex
|
||||
|
||||
// Reset stats UI to "counting".
|
||||
// zh-CN: 重置统计 UI 为 "统计中".
|
||||
mSearchStatsCurrentOrdinal = null
|
||||
mSearchStatsTotal = null
|
||||
mSearchStatsTotalCapped = false
|
||||
mSearchStatsCounting = true
|
||||
|
||||
val tb = getHostToolbarOrNull()
|
||||
tb?.title = query
|
||||
tb?.subtitle = null
|
||||
|
||||
// Start stats immediately.
|
||||
// zh-CN: 立即启动统计.
|
||||
restartSearchStatsAsync(reason = "enter")
|
||||
requestApplyInsetsSoon()
|
||||
}
|
||||
|
||||
private fun exitSearchModeAndRestoreToolbar() {
|
||||
if (!mInSearchMode) return
|
||||
|
||||
mInSearchMode = false
|
||||
mCurrentSearchQuery = null
|
||||
mCurrentSearchUsingRegex = false
|
||||
mCurrentSearchShowReplaceItem = false
|
||||
|
||||
cancelSearchStats()
|
||||
|
||||
val tb = getHostToolbarOrNull()
|
||||
tb?.title = mToolbarTitleBeforeSearch
|
||||
tb?.subtitle = mToolbarSubtitleBeforeSearch
|
||||
|
||||
requestApplyInsetsSoon()
|
||||
}
|
||||
|
||||
private fun cancelSearchStats() {
|
||||
mSearchStatsGeneration++
|
||||
mUiHandler.removeCallbacks(mSearchStatsDebounceRunnable)
|
||||
mSearchStatsDisposable?.let { d -> runCatching { d.dispose() } }
|
||||
mSearchStatsDisposable = null
|
||||
mSearchStatsCounting = false
|
||||
}
|
||||
|
||||
private fun buildSearchSubtitleText(): CharSequence? {
|
||||
if (!mInSearchMode) return null
|
||||
|
||||
val current = mSearchStatsCurrentOrdinal
|
||||
val total = mSearchStatsTotal
|
||||
val counting = mSearchStatsCounting
|
||||
|
||||
fun formatTotal(): String {
|
||||
if (total == null) return "..."
|
||||
return if (mSearchStatsTotalCapped) "${mSearchStatsMaxTotal - 1}+" else total.toString()
|
||||
}
|
||||
|
||||
val totalText = when {
|
||||
total != null -> formatTotal()
|
||||
counting -> "..."
|
||||
else -> null
|
||||
}
|
||||
|
||||
val currentText = when {
|
||||
current != null -> current.toString()
|
||||
counting -> "..."
|
||||
total != null -> "..." // <-- key: total known but current unknown => show ".../total"
|
||||
else -> null
|
||||
}
|
||||
|
||||
return when {
|
||||
currentText != null && totalText != null -> "$currentText/$totalText"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateSearchSubtitleBestEffort() {
|
||||
val tb = getHostToolbarOrNull() ?: return
|
||||
if (!mInSearchMode) return
|
||||
tb.subtitle = buildSearchSubtitleText()
|
||||
}
|
||||
|
||||
private fun showNoSearchResultDialog(query: String) {
|
||||
// Show a clear no-result prompt.
|
||||
// zh-CN: 显示明确的无结果提示.
|
||||
DialogUtils.buildAndShowAdaptive {
|
||||
MaterialDialog.Builder(context)
|
||||
.title(R.string.text_prompt)
|
||||
.content(context.getString(R.string.text_no_search_results_for_keyword, query))
|
||||
.positiveText(R.string.dialog_button_dismiss)
|
||||
.positiveColorRes(R.color.dialog_button_default)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
private fun restartSearchStatsAsync(reason: String) {
|
||||
if (!mInSearchMode) return
|
||||
|
||||
val query = mCurrentSearchQuery
|
||||
if (query.isNullOrEmpty()) return
|
||||
|
||||
// Cancel previous task.
|
||||
// zh-CN: 取消上一轮任务.
|
||||
cancelSearchStats()
|
||||
|
||||
mSearchStatsCounting = true
|
||||
mSearchStatsCurrentOrdinal = null
|
||||
mSearchStatsTotal = null
|
||||
mSearchStatsTotalCapped = false
|
||||
|
||||
updateSearchSubtitleBestEffort()
|
||||
|
||||
val gen = ++mSearchStatsGeneration
|
||||
|
||||
val usingRegex = mCurrentSearchUsingRegex
|
||||
val selStart = editor.codeEditText.selectionStart
|
||||
val selEnd = editor.codeEditText.selectionEnd
|
||||
val textSnapshot = editor.text
|
||||
|
||||
mSearchStatsDisposable = Observable
|
||||
.fromCallable {
|
||||
computeSearchStats(
|
||||
text = textSnapshot,
|
||||
query = query,
|
||||
usingRegex = usingRegex,
|
||||
selectionStart = minOf(selStart, selEnd),
|
||||
selectionEnd = maxOf(selStart, selEnd),
|
||||
maxTotal = mSearchStatsMaxTotal,
|
||||
generation = gen,
|
||||
)
|
||||
}
|
||||
.subscribeOn(Schedulers.computation())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe({ result ->
|
||||
// Ignore stale results.
|
||||
// zh-CN: 忽略过期结果.
|
||||
if (gen != mSearchStatsGeneration) return@subscribe
|
||||
if (!mInSearchMode) return@subscribe
|
||||
|
||||
mSearchStatsCounting = false
|
||||
mSearchStatsCurrentOrdinal = result.currentOrdinal
|
||||
mSearchStatsTotal = result.total
|
||||
mSearchStatsTotalCapped = result.capped
|
||||
|
||||
updateSearchSubtitleBestEffort()
|
||||
}, { e ->
|
||||
if (gen != mSearchStatsGeneration) return@subscribe
|
||||
mSearchStatsCounting = false
|
||||
e.printStackTrace()
|
||||
updateSearchSubtitleBestEffort()
|
||||
})
|
||||
}
|
||||
|
||||
private data class SearchStatsResult(
|
||||
val currentOrdinal: Int?,
|
||||
val total: Int,
|
||||
val capped: Boolean,
|
||||
)
|
||||
|
||||
private fun computeSearchStats(
|
||||
text: String,
|
||||
query: String,
|
||||
usingRegex: Boolean,
|
||||
selectionStart: Int,
|
||||
selectionEnd: Int,
|
||||
maxTotal: Int,
|
||||
generation: Int,
|
||||
): SearchStatsResult {
|
||||
// Note:
|
||||
// - Non-regex: count non-overlapping occurrences.
|
||||
// - Regex: count matcher.find() occurrences.
|
||||
// - currentOrdinal: match whose range equals current selection range.
|
||||
// zh-CN:
|
||||
// - 非正则: 统计不重叠匹配次数.
|
||||
// - 正则: 统计 matcher.find() 次数.
|
||||
// - currentOrdinal: 匹配区间与当前 selection 区间相等时记录序号.
|
||||
if (query.isEmpty()) return SearchStatsResult(null, 0, false)
|
||||
|
||||
fun cancelled(): Boolean = generation != mSearchStatsGeneration
|
||||
|
||||
var total = 0
|
||||
var capped = false
|
||||
var currentOrdinal: Int? = null
|
||||
|
||||
if (!usingRegex) {
|
||||
var from = 0
|
||||
while (from <= text.length) {
|
||||
if (cancelled()) break
|
||||
|
||||
val idx = text.indexOf(query, from)
|
||||
if (idx < 0) break
|
||||
|
||||
total++
|
||||
val end = idx + query.length
|
||||
if (idx == selectionStart && end == selectionEnd) {
|
||||
currentOrdinal = total
|
||||
}
|
||||
|
||||
if (total >= maxTotal) {
|
||||
capped = true
|
||||
break
|
||||
}
|
||||
|
||||
// Non-overlapping.
|
||||
// zh-CN: 不重叠推进.
|
||||
from = end
|
||||
}
|
||||
} else {
|
||||
val p = Pattern.compile(query)
|
||||
val m = p.matcher(text)
|
||||
while (m.find()) {
|
||||
if (cancelled()) break
|
||||
|
||||
total++
|
||||
val start = m.start()
|
||||
val end = m.end()
|
||||
if (start == selectionStart && end == selectionEnd) {
|
||||
currentOrdinal = total
|
||||
}
|
||||
|
||||
if (total >= maxTotal) {
|
||||
capped = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return SearchStatsResult(
|
||||
currentOrdinal = currentOrdinal,
|
||||
total = total,
|
||||
capped = capped,
|
||||
)
|
||||
}
|
||||
|
||||
// Try to start "Find" and enter search mode. Returns true if any result is found.
|
||||
// zh-CN: 尝试开始 "查找" 并进入搜索模式. 若找到任意结果则返回 true.
|
||||
fun tryFind(keywords: String, usingRegex: Boolean): Boolean {
|
||||
editor.find(keywords, usingRegex)
|
||||
|
||||
val hasResult = editor.getFoundIndex() >= 0
|
||||
if (!hasResult) {
|
||||
showNoSearchResultDialog(keywords)
|
||||
return false
|
||||
}
|
||||
|
||||
enterSearchMode(query = keywords, usingRegex = usingRegex, showReplaceItem = false)
|
||||
showSearchToolbar(showReplaceItem = false)
|
||||
updateSearchSubtitleBestEffort()
|
||||
|
||||
// Fix possible IME/insets timing issues after dialog confirm and IME hide.
|
||||
// zh-CN: 修复对话框确认导致 IME 收起后的 insets 时序问题.
|
||||
requestApplyInsetsSoon()
|
||||
return true
|
||||
}
|
||||
|
||||
// Try to start "Replace (single)" and enter search mode. Returns true if any result is found.
|
||||
// zh-CN: 尝试开始 "替换 (单次)" 并进入搜索模式. 若找到任意结果则返回 true.
|
||||
fun tryReplace(keywords: String, replacement: String, usingRegex: Boolean): Boolean {
|
||||
editor.replace(keywords, replacement, usingRegex)
|
||||
|
||||
val hasResult = editor.getFoundIndex() >= 0
|
||||
if (!hasResult) {
|
||||
showNoSearchResultDialog(keywords)
|
||||
return false
|
||||
}
|
||||
|
||||
enterSearchMode(query = keywords, usingRegex = usingRegex, showReplaceItem = true)
|
||||
showSearchToolbar(showReplaceItem = true)
|
||||
updateSearchSubtitleBestEffort()
|
||||
|
||||
// Fix possible IME/insets timing issues after dialog confirm and IME hide.
|
||||
// zh-CN: 修复对话框确认导致 IME 收起后的 insets 时序问题.
|
||||
requestApplyInsetsSoon()
|
||||
return true
|
||||
}
|
||||
|
||||
private fun cancelSearch() {
|
||||
exitSearchModeAndRestoreToolbar()
|
||||
showNormalToolbar()
|
||||
}
|
||||
|
||||
@Throws(CheckedPatternSyntaxException::class)
|
||||
fun find(keywords: String, usingRegex: Boolean) {
|
||||
editor.find(keywords, usingRegex)
|
||||
showSearchToolbar(false)
|
||||
// Keep old API for callers that don't care about result.
|
||||
// zh-CN: 保留旧 API, 兼容不关心结果的调用方.
|
||||
tryFind(keywords, usingRegex)
|
||||
}
|
||||
|
||||
@Throws(CheckedPatternSyntaxException::class)
|
||||
fun replace(keywords: String, replacement: String, usingRegex: Boolean) {
|
||||
// Keep old API for callers that don't care about result.
|
||||
// zh-CN: 保留旧 API, 兼容不关心结果的调用方.
|
||||
tryReplace(keywords, replacement, usingRegex)
|
||||
}
|
||||
|
||||
private fun showSearchToolbar(showReplaceItem: Boolean) {
|
||||
@@ -1778,12 +2191,6 @@ class EditorView : LinearLayout, OnHintClickListener, ClickCallback, ToolbarFrag
|
||||
.commit()
|
||||
}
|
||||
|
||||
@Throws(CheckedPatternSyntaxException::class)
|
||||
fun replace(keywords: String, replacement: String, usingRegex: Boolean) {
|
||||
editor.replace(keywords, replacement, usingRegex)
|
||||
showSearchToolbar(true)
|
||||
}
|
||||
|
||||
@Throws(CheckedPatternSyntaxException::class)
|
||||
fun replaceAll(keywords: String, replacement: String, usingRegex: Boolean) {
|
||||
editor.replaceAll(keywords, replacement, usingRegex)
|
||||
@@ -1936,6 +2343,10 @@ class EditorView : LinearLayout, OnHintClickListener, ClickCallback, ToolbarFrag
|
||||
// zh-CN: 销毁前先取消加载, 再销毁编辑器相关资源.
|
||||
cancelLargeFileLoading()
|
||||
|
||||
// Cancel search stats.
|
||||
// zh-CN: 取消搜索统计任务.
|
||||
cancelSearchStats()
|
||||
|
||||
editor.destroy()
|
||||
mAutoCompletion?.shutdown()
|
||||
if (mDebugging) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import android.text.TextWatcher
|
||||
import android.view.LayoutInflater
|
||||
import android.widget.CheckBox
|
||||
import androidx.appcompat.widget.AppCompatEditText
|
||||
import androidx.core.content.edit
|
||||
import androidx.preference.PreferenceManager
|
||||
import com.afollestad.materialdialogs.MaterialDialog
|
||||
import org.autojs.autojs.ui.edit.editor.CodeEditor.CheckedPatternSyntaxException
|
||||
@@ -17,6 +18,7 @@ import org.autojs.autojs6.databinding.DialogFindOrReplaceBinding
|
||||
* Created by Stardust on Sep 28, 2017.
|
||||
* Modified by SuperMonster003 as of Dec 1, 2021.
|
||||
* Transformed by SuperMonster003 on May 12, 2023.
|
||||
* Modified by JetBrains AI Assistant (GPT-5.2) as of Feb 8, 2026.
|
||||
*/
|
||||
class FindOrReplaceDialogBuilder(context: Context, private val mEditorView: EditorView) : MaterialDialog.Builder(context) {
|
||||
|
||||
@@ -41,7 +43,9 @@ class FindOrReplaceDialogBuilder(context: Context, private val mEditorView: Edit
|
||||
val binding = DialogFindOrReplaceBinding.inflate(LayoutInflater.from(context))
|
||||
|
||||
mRegexCheckBox = binding.checkboxRegex
|
||||
mReplaceCheckBox = binding.checkboxReplace
|
||||
mReplaceCheckBox = binding.checkboxReplace.apply {
|
||||
setOnCheckedChangeListener { _, _ -> syncWithReplaceAllCheckBox() }
|
||||
}
|
||||
mKeywordsEditText = binding.keywords
|
||||
mReplaceAllCheckBox = binding.checkboxReplaceAll.apply {
|
||||
setOnCheckedChangeListener { _, _ -> syncWithReplaceCheckBox() }
|
||||
@@ -67,9 +71,10 @@ class FindOrReplaceDialogBuilder(context: Context, private val mEditorView: Edit
|
||||
}
|
||||
|
||||
private fun storeState() {
|
||||
PreferenceManager.getDefaultSharedPreferences(getContext()).edit()
|
||||
.putString(KEY_KEYWORDS, mKeywordsEditText.text.toString())
|
||||
.apply()
|
||||
PreferenceManager.getDefaultSharedPreferences(getContext()).edit {
|
||||
putString(KEY_KEYWORDS, mKeywordsEditText.text.toString())
|
||||
putBoolean(KEY_USING_REGEX, mRegexCheckBox.isChecked)
|
||||
}
|
||||
}
|
||||
|
||||
private fun restoreState() {
|
||||
@@ -77,6 +82,14 @@ class FindOrReplaceDialogBuilder(context: Context, private val mEditorView: Edit
|
||||
.getString(KEY_KEYWORDS, "")
|
||||
.let { mKeywordsEditText.setText(it) }
|
||||
|
||||
PreferenceManager.getDefaultSharedPreferences(getContext())
|
||||
.getBoolean(KEY_USING_REGEX, false)
|
||||
.let { mRegexCheckBox.isChecked = it }
|
||||
}
|
||||
|
||||
fun setUsingRegex(usingRegex: Boolean): FindOrReplaceDialogBuilder {
|
||||
mRegexCheckBox.isChecked = usingRegex
|
||||
return this
|
||||
}
|
||||
|
||||
private fun syncWithReplaceCheckBox() {
|
||||
@@ -85,6 +98,12 @@ class FindOrReplaceDialogBuilder(context: Context, private val mEditorView: Edit
|
||||
}
|
||||
}
|
||||
|
||||
private fun syncWithReplaceAllCheckBox() {
|
||||
if (!mReplaceCheckBox.isChecked && mReplaceAllCheckBox.isChecked) {
|
||||
mReplaceAllCheckBox.isChecked = false
|
||||
}
|
||||
}
|
||||
|
||||
private fun findOrReplace(dialog: MaterialDialog) {
|
||||
val keywords = mKeywordsEditText.text.toString()
|
||||
if (keywords.isEmpty()) {
|
||||
@@ -93,16 +112,22 @@ class FindOrReplaceDialogBuilder(context: Context, private val mEditorView: Edit
|
||||
try {
|
||||
val usingRegex = mRegexCheckBox.isChecked
|
||||
if (!mReplaceCheckBox.isChecked) {
|
||||
mEditorView.find(keywords, usingRegex)
|
||||
val ok = mEditorView.tryFind(keywords, usingRegex)
|
||||
if (ok) {
|
||||
dialog.dismiss()
|
||||
}
|
||||
} else {
|
||||
val replacement = mReplacementEditText.text.toString()
|
||||
if (mReplaceAllCheckBox.isChecked) {
|
||||
mEditorView.replaceAll(keywords, replacement, usingRegex)
|
||||
dialog.dismiss()
|
||||
} else {
|
||||
mEditorView.replace(keywords, replacement, usingRegex)
|
||||
val ok = mEditorView.tryReplace(keywords, replacement, usingRegex)
|
||||
if (ok) {
|
||||
dialog.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
dialog.dismiss()
|
||||
} catch (e: CheckedPatternSyntaxException) {
|
||||
e.printStackTrace()
|
||||
mKeywordsEditText.error = getContext().getString(R.string.error_pattern_syntax)
|
||||
@@ -117,6 +142,7 @@ class FindOrReplaceDialogBuilder(context: Context, private val mEditorView: Edit
|
||||
companion object {
|
||||
|
||||
private const val KEY_KEYWORDS = "..."
|
||||
private const val KEY_USING_REGEX = "find_or_replace_using_regex"
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -483,6 +483,55 @@ class CodeEditor : HVScrollView {
|
||||
|
||||
fun redo() = mTextViewRedoUndo.redo()
|
||||
|
||||
// Expose current found index for UI logic (e.g., no-result prompt).
|
||||
// zh-CN: 暴露当前命中位置, 供 UI 逻辑使用 (例如无结果提示).
|
||||
fun getFoundIndex(): Int = mFoundIndex
|
||||
|
||||
/**
|
||||
* Reset search cursor for "Find Next" based on current caret/selection.
|
||||
*
|
||||
* Behavior:
|
||||
* - Uses current selection start (min of start/end).
|
||||
* - Sets mFoundIndex to (cursor - 1), so findNext() starts at cursor.
|
||||
*
|
||||
* zh-CN:
|
||||
* 将 "查找下一个" 的起点对齐到当前光标/选择起点.
|
||||
* - 使用 selectionStart/End 的较小值作为光标位置
|
||||
* - mFoundIndex 设为 cursor-1, 使 findNext() 从 cursor 开始找
|
||||
*/
|
||||
fun resetFoundIndexForFindNextFromCursor() {
|
||||
val textLen = codeEditText.text?.length ?: 0
|
||||
|
||||
// Use selection END as the starting anchor for "next",
|
||||
// so when current match is selected we won't re-select it again.
|
||||
//
|
||||
// zh-CN: "下一个" 以 selectionEnd 作为起点锚点,
|
||||
// 这样当当前命中被选中时不会再次选中同一条.
|
||||
val cursor = maxOf(codeEditText.selectionStart, codeEditText.selectionEnd).coerceIn(0, textLen)
|
||||
|
||||
// Make findNext() start from `cursor`.
|
||||
// zh-CN: 让 findNext() 从 cursor 开始找.
|
||||
mFoundIndex = (cursor - 1).coerceAtLeast(-1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset search cursor for "Find Prev" based on current caret/selection.
|
||||
*
|
||||
* Behavior:
|
||||
* - Uses current selection start (min of start/end).
|
||||
* - Sets mFoundIndex to cursor, so findPrev() searches before cursor.
|
||||
*
|
||||
* zh-CN:
|
||||
* 将 "查找上一个" 的起点对齐到当前光标/选择起点.
|
||||
* - 使用 selectionStart/End 的较小值作为光标位置
|
||||
* - mFoundIndex 设为 cursor, 使 findPrev() 从 cursor 往前找
|
||||
*/
|
||||
fun resetFoundIndexForFindPrevFromCursor() {
|
||||
val textLen = codeEditText.text?.length ?: 0
|
||||
val cursor = minOf(codeEditText.selectionStart, codeEditText.selectionEnd).coerceIn(0, textLen)
|
||||
mFoundIndex = cursor
|
||||
}
|
||||
|
||||
@Throws(CheckedPatternSyntaxException::class)
|
||||
fun find(keywords: String, usingRegex: Boolean) {
|
||||
if (usingRegex) {
|
||||
|
||||
@@ -393,7 +393,7 @@ public class HVScrollView extends FrameLayout {
|
||||
//
|
||||
// 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: 当子 View 尚未完成测量/布局时 (width/height == 0), 原边界判断可能拒绝所有 ACTION_DOWN,
|
||||
// zh-CN: 从而导致滚动完全失效.
|
||||
final int childWidth = child.getWidth();
|
||||
final int childHeight = child.getHeight();
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
android:id="@+id/checkbox_regex"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:text="@string/text_regex" />
|
||||
|
||||
<org.autojs.autojs.theme.widget.ThemeColorTextInputLayout
|
||||
@@ -44,7 +45,8 @@
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal">
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginBottom="4dp">
|
||||
|
||||
<org.autojs.autojs.theme.widget.ThemeColorCheckBox
|
||||
android:id="@+id/checkbox_replace"
|
||||
|
||||
@@ -1287,4 +1287,5 @@
|
||||
<string name="text_write_system_settings">修改系统设置</string>
|
||||
<string name="text_xiaomi_background_popup_permission">后台弹出界面</string>
|
||||
<string name="text_loading_completed">加载完毕</string>
|
||||
<string name="text_no_search_results_for_keyword">未找到匹配项:\n\n%1$s</string>
|
||||
</resources>
|
||||
@@ -1562,4 +1562,5 @@
|
||||
<string name="text_write_system_settings">Write system settings</string>
|
||||
<string name="text_xiaomi_background_popup_permission">Display pop-up windows while running in the background</string>
|
||||
<string name="text_loading_completed">Loading completed</string>
|
||||
<string name="text_no_search_results_for_keyword">No results found for:\n\n%1$s</string>
|
||||
</resources>
|
||||
Reference in New Issue
Block a user