MooTool next-compose DIFF-266 实现解析:打开查找条时从 RSTA 编辑器预填选区(含 EDT 线程调度)

原创2026-10-14 16:45:381,373 阅读
文章标签:开发工具桌面应用MCP 服务

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))
}

(见 FindBarOpen.kt#L7-L13)

要点拆解:

  • 依赖 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()) }
    }
}

(见 FindBarOpen.kt#L25-L31)

设计上有两层含义:

  1. 线程安全兜底: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)
    }
    
  2. 把“读选区”与“改 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 关闭

无论哪条路径,打开时执行的状态写入是固定的三步:

  1. selected?.let { session.findQuery = it } —— 仅当选区非空时覆盖查询词;
  2. session.findOpen = true —— 触发 FindBar 渲染(JsonScreen.kt#L360-L362 中 if (session.findOpen) { FindBar(...) });
  3. 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 模型读取”线程边界的可参考范本。

登录后查看全文
MooTool