在JavaFX中构建代码编辑器控件
JavaFX 并未内置代码编辑器控件,而 TextArea 也并非为成长为代码编辑器而设计——它没有逐字符样式、没有行号栏,也没有针对数千行文本调优的虚拟化。要构建一个真正的代码编辑器,意味着要回到 Control + Skin,自行构建编辑器界面。本文将逐一介绍一个自定义编辑器控件的若干组成部分。
核心思路:两个虚拟化列表,而不是一个
本能的设计是单个 ListView<String>,每行对应一行文本,每个行项同时包含行号和代码。这种方法有个缺点:当用户水平滚动时,行号会消失。
修正方法是将编辑器拆分为两个独立的虚拟化 ListView,它们共享同一份底层数据:
listView 渲染代码本身;gutterListView 只渲染行号和标记图标(错误、警告)。两者都由同一个 ObservableList<String> 提供行数据,因此它们始终以相同顺序显示相同数量的行。行号栏列表从不水平滚动,因此当代码在其下方滚动时,它在视觉上保持固定——就是你从 IntelliJ 中熟悉的那种布局。
让两个列表保持垂直同步
两个独立的 ListView 需要手动锁定它们的垂直滚动位置。每个 ListView 都有一个内部 ScrollBar,在 skin 安装后即可访问:
行号栏自身的滚动条被完全隐藏并禁用——用户只会与主列表的滚动条交互;行号栏只是跟随。
渲染行号栏:GutterCell
gutterListView 的每一行都是一个自定义 ListCell<String>,它忽略该行的文本内容,只需要其索引:
getIndex() 是整个方法奏效的关键:由于 ListCell 会随着用户滚动而被复用(这正是虚拟化的意义所在),单元格不能缓存自己的行号——每次 updateItem 触发时,它都必须从自己的位置重新读取行号。同一个索引也驱动“当前行”高亮,将其与插入符所在行进行比较。
无需词法分析器的语法高亮
这里的语法高亮并不是真正的词法分析器——它是一个已注册的关键词到颜色的映射,会针对一行中每个类似单词的 token 进行检查:
每一行都会被拆分成一组 Text 节点——有些是普通文本,有些带颜色——然后放入 TextFlow,它会将它们像一段连续文本一样内联排版。关键词映射本身是公开暴露的,因此控件的使用者可以注册自己的词汇和颜色,而不是由控件硬编码某种语言:
这是一种刻意保持简单的做法——单次正则匹配,没有分词器状态机,没有上下文感知(字符串字面量中的关键词也会被着色)。
括号匹配
每次插入符移动时,skin 都会检查它是否位于某个括号字符旁边,如果是,则搜索其配对括号:
搜索本身(findMatchingForward/findMatchingBackward)是一个简单的深度计数扫描——它遍历文本并跟踪嵌套深度,除了括号字符之外忽略其他一切。它不了解字符串字面量或注释,因此引号字符串中的游离括号可能会让它出错。这是有意的取舍:完整的分词器可以解决这个问题,但对于业务应用编辑器中的可视化配对辅助来说,一次线性扫描已经足够。
它产生的两个偏移量(matchBracketA/matchBracketB)会在渲染期间被读回——每个 LineCell 都会检查任一偏移量是否落在自己的行内,如果是,则在该字符周围绘制一个小轮廓矩形。
多光标编辑
多光标支持依附于鼠标处理器,并以 Alt 键作为门控。普通 Alt+Click 会添加一个新光标;Alt+拖拽则会开始列(框)选择——而代码在鼠标释放之前并不知道是哪一种:
handleMouseDragged 会在指针实际移动的那一刻将 altDragOccurred 翻转为 true,因此当 handleMouseReleased 运行时,它就能区分这两种情况:
每个额外光标只是一个 CaretState(插入符偏移量 + 选择锚点),放在一个普通列表中。渲染它们开销很低:LineCell 已经知道如何根据文本偏移量绘制主插入符,因此 buildExtraCaretDecorations 会对每个恰好落在当前正在渲染行上的 CaretState 复用同一套计算。多光标激活时的编辑会把每次按键都通过 CaretEdit 函数式接口运行,对每个光标应用一次,并按文档从下到上的顺序执行,这样较早的编辑就不会移动仍在等待处理的光标的偏移量。
撤销/重做
撤销/重做基于整个文档快照,而不是 diff/patch 日志——推理起来更简单,代价是对非常大的文档会占用内存:
pushUndoState() 会在每个修改文本的操作开始时被调用——输入、粘贴、缩进、接受自动补全建议——并且它会用 isUndoRedoOperation 标志保护自己,这样 applyState()(撤销和重做都会使用)就不会递归地将自己的恢复操作压入栈中。每次压栈还会清空重做栈:一旦你进行了新的编辑,撤销本可以重做的“未来”就消失了,正如所有主流编辑器一样。applyState() 还会额外清空 extraCarets——撤销/重做快照只跟踪主插入符,因此恢复快照会折叠任何活动的多光标会话。
自动补全
自动补全弹窗是第三个 ListView,显示在 Popup 中,其定位依据插入符在屏幕上的实际位置,而不是任何固定位置:
匹配逻辑本身刻意很简单——针对已注册建议的扁平列表进行不区分大小写的前缀检查,没有模糊匹配或排名。让它感觉原生的是定位:它会遍历 VirtualFlow,找到插入符当前单元格的实际屏幕边界,这也是 skin 中其他地方使用的同一种查找(例如,单词文档就复用了这一确切模式)。接受建议(acceptAutocomplete())会替换部分输入的单词,先压入一个撤销状态,并且——就像普通点击一样——折叠任何活动的多光标状态,因为接受建议是一个单插入符意图的操作。## 它在更大型编辑器中的位置
行号、高亮、括号匹配、多光标编辑、撤销/重做和自动补全只是一个更大设计的一部分——同一个控件还处理列选择、单词出现高亮、快速文档和自动闭合配对,所有这些都建立在这个 Control/Skin 基础之上。我在我的书 Skinning JavaFX Applications 中介绍了完整实现以及一组其他控件。
分享此页面
发现错误,或想补充内容?在 GitHub 上编辑此页面
[LOADING...]
作者:
Libán Bande González
Libán Bande González 是一名软件开发者、UX/UI 设计师和技术作者。他为各类企业构建了一套 JavaFX 和 SQL Server 桌面业务管理应用程序。他是《Skinning JavaFX Applications》一书的作者。
相关文章
JavaFX [LOADING...]
[LOADING...]Libán Bande González2026年10月1日 194 次浏览
宣布《Skinning JavaFX Applications》
JavaFX [LOADING...]
[LOADING...]Gerrit Grunwald2021年2月19日 16,123 次浏览
JavaFX 中的自定义控件(第四部分):Control 与 Skin 类
JavaFX [LOADING...]
[LOADING...]Gerrit Grunwald2021年3月5日 9,059 次浏览
JavaFX 中的自定义控件(第六部分)——Canvas 类
JavaFX [LOADING...]
[LOADING...]Frank Delporte2024年8月7日 6,032 次浏览
JavaFX Nodes 与 Canvas 对比
JavaFX [LOADING...]
[LOADING...]Almas Baimagambetov2021年1月18日 24,966 次浏览
JavaFX 高性能渲染技巧
Java [LOADING...]
[LOADING...]George Ball2023年10月25日 4,464 次浏览
使用事件处理行为
参与讨论
JavaFX [LOADING...]
[LOADING...]Libán Bande González2026年10月1日 194 次浏览
宣布《Skinning JavaFX Applications》
JavaFX [LOADING...]
[LOADING...]Gerrit Grunwald2021年2月19日 16,123 次浏览
JavaFX 中的自定义控件(第四部分):Control 与 Skin 类
JavaFX [LOADING...]
[LOADING...]Gerrit Grunwald2021年3月5日 9,059 次浏览
JavaFX 中的自定义控件(第六部分)——Canvas 类
JavaFX [LOADING...]
[LOADING...]Frank Delporte2024年8月7日 6,032 次浏览
JavaFX Nodes 与 Canvas 对比
JavaFX [LOADING...]
[LOADING...]Almas Baimagambetov2021年1月18日 24,966 次浏览
JavaFX 高性能渲染技巧
Java [LOADING...]
[LOADING...]George Ball2023年10月25日 4,464 次浏览