MooTool next-compose DIFF-266 实现解析:打开查找条时从 RSTA 编辑器预填选区(含 EDT 线程调度)
MooTool next-compose DIFF-266 实现解析:打开查找条时从 RSTA 编辑器预填选区(含 EDT 线程调度)
本篇围绕 MooTool 的 next-compose(Compose for Desktop 版本)中一条查找条交互特性展开:打开查找条时,若编辑器内存在非空选区,则把选中文本自动写入查找框。文档源出 DIFF-266 说明,它是从 Electron 版对齐(parity)过来的一项编辑器行为。读完后你能掌握:selectedTextForFind() 如何安全地从 RSyntaxTextArea 读取选区、openFindBarSeedingSelection 如何在 EDT(Swing 事件分发线程)上做调度、JSON / 随手记等工具中三条打开查找条的路径如何统一收敛到同一入口,以及对应的单测验证方式。
一、对照 Electron:这条特性要复刻什么行为
在 Electron 版(next 目录)中,JSON 与随手记两个工具各自实现了 openFindReplace,核心逻辑一致:
- JsonTool.openFindReplace:读取
editorRef.current?.getSelection(),当selection.end > selection.start时,用state.content.slice(selection.start, selection.end)取出选中文本;随后findIndexRef.current = 0、jsonFindIndex = 0重置查找游标,并update({ findOpen: true, replacedCount: 0, ...(selected !== undefined ? { findQuery: selected } : {}) })——注意这里用条件展开,只有选区非空时才写入findQuery,否则保留上一次查找词。 - QuickNoteTool.openFindReplace:结构相同,只是写入的会话字段是
findText,末尾追加requestFindFocus()把焦点请求到查找框。
也就是说,Electron 版的语义是:打开查找时若有非空选区,将选中文本写入查找框;若无选区,不动原有查询。DIFF-266 的目标就是让 next-compose 桌面版具备同一行为,并覆盖所有打开查找条的入口。
二、核心实现:FindBarOpen.kt 中的两个内部函数
整个特性的核心代码集中在 FindBarOpen.kt 一个文件里,包含三个 internal 声明。
2.1 EditorBuffer.selectedTextForFind():读取选区并做边界收敛
/** 须在 EDT 调用;有非空选区时返回选中文本,供打开查找条时预填(对齐 Electron `openFindReplace`)。 */
internal fun EditorBuffer.selectedTextForFind(): String? {
val start = area.selectionStart
val end = area.selectionEnd
if (end <= start) return null
val text = text
return text.substring(start.coerceIn(0, text.length), end.coerceIn(0, text.length))
}
要点拆解:
- 依赖 RSTA 选区 API:
EditorBuffer是对RSyntaxTextArea(RSTA)的包装,area.selectionStart/area.selectionEnd即 Swing 文本组件的选区端点。EditorBuffer的定义见 EditorBuffer.kt#L29-L46,其中text属性直接取area.text.orEmpty()(EditorBuffer.kt#L71)。 - 空选区判定:
end <= start时返回null,对应“光标未拖动”或全选清空后的状态,调用方据此不覆盖已有findQuery。 - 边界收敛:
coerceIn(0, text.length)防止文档在选区记录之后被修改导致越界(例如选区还指着旧长度时substring抛StringIndexOutOfBoundsException)。这是典型的防御式写法,保证即便文本变更与读取之间发生轻微时序差也不会崩溃,最多只是预填一段旧选区文本。 - 线程契约:KDoc 明确“须在 EDT 调用”。Swing 的文本组件不是线程安全的,
selectionStart/selectionEnd属于 Swing 模型状态,跨线程读取可能读到中间态甚至触发IllegalThreadStateException。这个约束被外层openFindBarSeedingSelection统一满足(见 2.2),调用方无需各自处理。
同一文件还提供 TextFieldValue.selectedTextForFind():
/** Compose 多行文本(如 Host 正文)打开查找时预填选区。 */
internal fun TextFieldValue.selectedTextForFind(): String? {
val sel = selection
if (sel.collapsed) return null
val start = minOf(sel.start, sel.end).coerceIn(0, text.length)
val end = maxOf(sel.start, sel.end).coerceIn(0, text.length)
if (end <= start) return null
return text.substring(start, end).takeIf { it.isNotEmpty() }
}
这是为纯 Compose 的 BasicTextField/多行文本准备的平行实现(Host 编辑器正文就是 Compose 文本字段而非 RSTA),同样遵循“折叠选区返回 null + 边界收敛”的规则。它说明了这套“预填选区”约定在 RSTA 与 Compose 文本两种编辑器宿主上都被抽象为同名函数,方便各工具按宿主类型选用。
2.2 openFindBarSeedingSelection():把 EDT 调度收敛到一个入口
internal fun openFindBarSeedingSelection(editor: EditorBuffer, open: (seedFromSelection: String?) -> Unit) {
if (SwingUtilities.isEventDispatchThread()) {
open(editor.selectedTextForFind())
} else {
SwingUtilities.invokeLater { open(editor.selectedTextForFind()) }
}
}
设计上有两层含义:
-
线程安全兜底:Compose 的按键事件(
onPreviewKeyEvent)与按钮点击回调并不保证在 EDT 上执行(Compose for Desktop 的输入管线与 Swing AWT 事件队列是两个世界),而读取 RSTA 选区必须发生在 EDT。这里用SwingUtilities.isEventDispatchThread()判断后决定“直接执行”还是invokeLater投递,等价于项目其他位置通用的onEdt辅助函数,例如 HttpScreen.kt#L1282-L1284 中的私有实现:private fun onEdt(block: () -> Unit) { if (SwingUtilities.isEventDispatchThread()) block() else SwingUtilities.invokeLater(block) } -
把“读选区”与“改 Compose 状态”解耦:回调
open: (String?) -> Unit只拿到选中文本(或null),由各工具自己决定如何更新会话状态。这使得“预填”与“打开查找条”在数据上是一次原子写入(见第三节),同时让入口可复用——后续 DIFF-269(HTTP 响应查找预填选区) 的openHttpResponseFind就是通过openFindBarSeedingSelection(session.responseEditor)复用同一入口的。
三、打开查找条的三条路径与统一写入
DIFF-266 的行为约定覆盖三条打开路径(关闭查找仍直接关条,不预填):Cmd/Ctrl+F 壳层快捷键、编辑器内快捷键、工具栏「查找」按钮。以 JSON 工具为例,JsonScreen.kt 中三处调用完全同构:
路径 1:壳层快捷键(窗口级按键处理),JsonScreen.kt#L301-L315:
EditorFindShortcutPolicy.opensShellFind(
ToolId.Json,
event.key,
meta = meta,
shift = event.isShiftPressed,
alt = event.isAltPressed,
) -> {
openFindBarSeedingSelection(session.editor) { selected ->
selected?.let { session.findQuery = it }
session.findOpen = true
session.findReplacedCount = 0
refresh()
}
true
}
路径 2:编辑器内快捷键(RSTA 宿主内的应用级快捷键分发),JsonScreen.kt#L415-L421:EditorAppShortcuts 的 onFind 回调中执行同样的 openFindBarSeedingSelection(session.editor) { ... }。
路径 3:工具栏「查找」按钮,JsonScreen.kt#L721-L733:
MooButton(container.t("json.action.find"), onClick = {
if (session.findOpen) {
session.findOpen = false
onChanged()
} else {
openFindBarSeedingSelection(session.editor) { selected ->
selected?.let { session.findQuery = it }
session.findOpen = true
session.findReplacedCount = 0
onChanged()
}
}
})
注意按钮的分支逻辑正好落实了“关闭查找仍直接关条,不预填”:已打开时只把 findOpen 置回 false,不触发任何选区读取;只有从关闭到打开时才走预填。
随手记工具 QuickNoteScreen.kt 的三条路径与之平行:编辑器内快捷键 onFind 见 QuickNoteScreen.kt#L235-L243,壳层快捷键分支见 QuickNoteScreen.kt#L321-L343,写入目标同样是会话的 findQuery(对应 DIFF-266 文档“JSON / 随手记均写入会话 findQuery”的约定)。
3.1 快捷键判定策略
壳层按键不是裸写 F 键判断,而是走两层策略对象:
- FindReplaceShortcutPolicy:
meta && !shift && !alt && (key == Key.F || key == Key.R),注释写明“对齐 Electron JSON/随手记/Host:Cmd/Ctrl+F与Cmd/Ctrl+R(无 Shift/Alt)”。即Cmd/Ctrl+F与Cmd/Ctrl+R均可打开查找,且排除 Shift/Alt 组合(避免与Cmd+Shift+F格式化等冲突)。 - EditorFindShortcutPolicy.opensShellFind:按
ToolId做工具级差异化——HTTP 仅支持Cmd/Ctrl+F(无R),其余走FindReplaceShortcutPolicy;F05(代码运行)是 Compose 扩展工具,只读查找条。
3.2 状态写入与 Escape 关闭
无论哪条路径,打开时执行的状态写入是固定的三步:
selected?.let { session.findQuery = it }—— 仅当选区非空时覆盖查询词;session.findOpen = true—— 触发FindBar渲染(JsonScreen.kt#L360-L362 中if (session.findOpen) { FindBar(...) });session.findReplacedCount = 0—— 重置替换计数。
关闭路径则刻意不读选区:Escape 分支(JsonScreen.kt#L321-L326)只做 session.findOpen = false 与计数重置。这样再次打开查找条时,findQuery 里保留的是用户上次输入的查询词(或本次预填的选区),符合“选区只在打开瞬间播种一次”的语义。
查找条 UI 本身是 EditorFindOnlyBar:打开时用 FocusRequester 自动请求焦点到输入框,FindReplace.findAll 实时统计匹配数,上/下一处跳转通过 RstaFindNavigation.jump 在 onEdt 包装中调用,与预填逻辑共享同一套 EDT 纪律。
四、查找状态进入会话持久化
预填写入的 findQuery 不是临时 UI 状态,而是会话的一部分。以 JSON 为例,SessionManager.kt#L88-L127 中:
@Serializable
data class JsonSessionSnapshot(
val content: String,
...
val findOpen: Boolean = false,
val findQuery: String = "",
val replaceText: String = "",
val matchCase: Boolean = false,
val wholeWord: Boolean = false,
val regex: Boolean = false,
val findReplacedCount: Int = 0,
...
)
class JsonSession {
val editor = EditorBuffer(SAMPLE_JSON)
...
var findOpen: Boolean = false
var findQuery: String = ""
...
}
findOpen / findQuery 随 JsonSessionSnapshot 序列化保存,并在会话恢复时回填(同一文件中的 apply 方向赋值)。这意味着 DIFF-266 播种的选区查询词会跨窗口存活——用户关闭再打开 JSON 面板,之前由选区预填的查询词依然存在。这一点是从源码结构看可以直接确认的实现事实,也让“预填”不只是瞬间交互,而是会话状态机的起点。
五、验证:FindBarOpenTest 与 desktopTest
DIFF-266 文档给出的验证入口是 FindBarOpenTest 与 ./gradlew :composeApp:desktopTest --offline。单测本体见 FindBarOpenTest.kt:
@Test
fun selectedTextForFind_returns_selection_on_edt() {
val editor = EditorBuffer("hello world")
SwingUtilities.invokeAndWait {
editor.area.selectionStart = 6
editor.area.selectionEnd = 11
assertEquals("world", editor.selectedTextForFind())
editor.area.selectionStart = 0
editor.area.selectionEnd = 0
assertNull(editor.selectedTextForFind())
}
}
测试设计有两个值得注意的细节:
- 在 EDT 内断言:整个断言块包在
SwingUtilities.invokeAndWait里执行,与被测函数“须在 EDT 调用”的契约严格一致;测试名returns_selection_on_edt也直接点题。 - 正反两用例:
selectionStart = 6 / selectionEnd = 11取出"hello world"中的"world"(含"world"精确断言),随后把选区收缩为 0–0 验证返回null,覆盖了“非空选区预填”与“空选区不预填”两条分支。
运行方式(next-compose 目录内):
./gradlew :composeApp:desktopTest --offline
--offline 表示离线执行 Gradle 任务,不拉取远端依赖,适合 CI 或内网环境跑桌面端测试;这也是该批 diff 文档统一的验证命令。
六、复用面:同一入口在其他编辑器宿主的落地
openFindBarSeedingSelection 目前不止服务于 JSON / 随手记,从源码调用点看(next-compose/composeApp/src/desktopMain 下 features/json、features/quicknote、features/http、features/runtime 均 import 该函数):
- HTTP 响应查找:DIFF-269 中
openHttpResponseFind通过openFindBarSeedingSelection(session.responseEditor)预填,壳层Cmd/Ctrl+F、响应区「查找」按钮与响应EditorHost查找快捷键都走该入口; - 代码运行(F05):CodeRunScreen.kt#L239 同样调用它,属于 Compose 端的只读查找条扩展;
- Compose 文本宿主:
TextFieldValue.selectedTextForFind()重载则服务于 Host 正文这类纯 Compose 多行文本编辑器。
这些调用点可以作为理解 DIFF-266 的延伸阅读:它们共享“空选区返回 null → 仅非空时覆盖查询词 → EDT 内读 Swing 状态”这一套约定,只是宿主编辑器类型不同。
小结
DIFF-266 用一个约 30 行的 FindBarOpen.kt 完成了 Electron 行为对齐:selectedTextForFind() 负责“安全地读出选区”(空选区返回 null + coerceIn 边界收敛),openFindBarSeedingSelection 负责“在正确的线程上读”(isEventDispatchThread 判定 + invokeLater 兜底),再配合 FindReplaceShortcutPolicy / EditorFindShortcutPolicy 两条策略对象,把壳层快捷键、编辑器内快捷键、工具栏按钮三条打开路径收敛到同一预填逻辑;关闭路径(Escape、再次点击「查找」)明确不预填。验证由 FindBarOpenTest 在 EDT 内断言正/反两种选区状态,./gradlew :composeApp:desktopTest --offline 一条命令即可复现。对于在 Compose for Desktop 中嵌入 Swing 组件(如 RSTA)的项目,这一组函数也是处理“Compose 事件 → Swing 模型读取”线程边界的可参考范本。