Ohhnews

分类导航

$ cd ..
foojay原文

在JavaFX中构建代码编辑器控件

#javafx#代码编辑器#自定义控件#语法高亮#自动补全

JavaFX 并未内置代码编辑器控件,而 TextArea 也并非为成长为代码编辑器而设计——它没有逐字符样式、没有行号栏,也没有针对数千行文本调优的虚拟化。要构建一个真正的代码编辑器,意味着要回到 Control + Skin,自行构建编辑器界面。本文将逐一介绍一个自定义编辑器控件的若干组成部分。

核心思路:两个虚拟化列表,而不是一个

本能的设计是单个 ListView<String>,每行对应一行文本,每个行项同时包含行号和代码。这种方法有个缺点:当用户水平滚动时,行号会消失。

修正方法是将编辑器拆分为两个独立的虚拟化 ListView,它们共享同一份底层数据:

$ java
private final ListView<String> listView = new ListView<String>();
private final ListView<String> gutterListView = new ListView<String>();

listView 渲染代码本身;gutterListView 只渲染行号和标记图标(错误、警告)。两者都由同一个 ObservableList<String> 提供行数据,因此它们始终以相同顺序显示相同数量的行。行号栏列表从不水平滚动,因此当代码在其下方滚动时,它在视觉上保持固定——就是你从 IntelliJ 中熟悉的那种布局。

让两个列表保持垂直同步

两个独立的 ListView 需要手动锁定它们的垂直滚动位置。每个 ListView 都有一个内部 ScrollBar,在 skin 安装后即可访问:

$ java
private void setupVerticalScrollSync() {
    ScrollBar mainVBar = findVerticalScrollBar(listView);
    ScrollBar gutterVBar = findVerticalScrollBar(gutterListView);
    mainVBar.valueProperty().bind(gutterVBar.valueProperty());
    gutterVBar.setValue(mainVBar.getValue());
}

行号栏自身的滚动条被完全隐藏并禁用——用户只会与主列表的滚动条交互;行号栏只是跟随。

渲染行号栏:GutterCell

gutterListView 的每一行都是一个自定义 ListCell<String>,它忽略该行的文本内容,只需要其索引:

$ java
private class GutterCell extends ListCell<String> {
    private final Label lineNumberLabel = new Label();
    private final HBox gutterIconBox = new HBox(2);

    @Override
    protected void updateItem(String item, boolean empty) {
        super.updateItem(item, empty);
        if (empty || item == null) {
            setGraphic(null);
            return;
        }
        setGraphic(rowBox);
        int idx = getIndex();
        lineNumberLabel.setText(String.valueOf(idx + 1));
        gutterIconBox.getChildren().setAll(buildGutterIcons(idx, item));
        boolean isCaretLine = idx == caretLine();
        rowBox.setStyle(isCaretLine
            ? "-fx-background-color: " + toWeb(getSkinnable().getCurrentLineColor())
            : "-fx-background-color: " + toWeb(getSkinnable().getLineColor()));
    }
}

getIndex() 是整个方法奏效的关键:由于 ListCell 会随着用户滚动而被复用(这正是虚拟化的意义所在),单元格不能缓存自己的行号——每次 updateItem 触发时,它都必须从自己的位置重新读取行号。同一个索引也驱动“当前行”高亮,将其与插入符所在行进行比较。

无需词法分析器的语法高亮

这里的语法高亮并不是真正的词法分析器——它是一个已注册的关键词到颜色的映射,会针对一行中每个类似单词的 token 进行检查:

$ java
private List<Text> buildHighlightedFlowChildren(String lineText) {
    Map<String, Color> keywords = getSkinnable().getKeywordColors();
    Matcher m = WORD_PATTERN.matcher(lineText);
    int last = 0;
    List<Text> parts = new ArrayList<Text>();
    while (m.find()) {
        if (m.start() > last) {
            parts.add(plainText(lineText.substring(last, m.start())));
        }
        String word = m.group();
        Text wordText = plainText(word);
        Color c = keywords.get(word);
        if (c != null) {
            wordText.setFill(c);
        }
        parts.add(wordText);
        last = m.end();
    }
    if (last < lineText.length()) {
        parts.add(plainText(lineText.substring(last)));
    }
    return parts;
}

每一行都会被拆分成一组 Text 节点——有些是普通文本,有些带颜色——然后放入 TextFlow,它会将它们像一段连续文本一样内联排版。关键词映射本身是公开暴露的,因此控件的使用者可以注册自己的词汇和颜色,而不是由控件硬编码某种语言:

$ java
editor.addKeywords(Color.web("#CC7832"), "public", "class", "return", "if", "else");

这是一种刻意保持简单的做法——单次正则匹配,没有分词器状态机,没有上下文感知(字符串字面量中的关键词也会被着色)。

括号匹配

每次插入符移动时,skin 都会检查它是否位于某个括号字符旁边,如果是,则搜索其配对括号:

$ java
private void updateBracketMatch() {
    getSkinnable().matchBracketAProperty().set(-1);
    getSkinnable().matchBracketBProperty().set(-1);
    String text = getSkinnable().getText();
    if (text.isEmpty()) {
        return;
    }
    int checkPos = -1;
    if (isBracket(text.charAt(caretIndex))) {
        checkPos = caretIndex;
    } else if (isBracket(text.charAt(caretIndex - 1))) {
        checkPos = caretIndex - 1;
    }
    if (checkPos < 0) {
        return;
    }
    char c = text.charAt(checkPos);
    int match = OPEN_BRACKETS.indexOf(c) >= 0
        ? findMatchingForward(text, checkPos, c, closeFor(c))
        : findMatchingBackward(text, checkPos, c, openFor(c));
    if (match >= 0) {
        getSkinnable().matchBracketAProperty().set(checkPos);
        getSkinnable().matchBracketBProperty().set(match);
    }
}

搜索本身(findMatchingForward/findMatchingBackward)是一个简单的深度计数扫描——它遍历文本并跟踪嵌套深度,除了括号字符之外忽略其他一切。它不了解字符串字面量或注释,因此引号字符串中的游离括号可能会让它出错。这是有意的取舍:完整的分词器可以解决这个问题,但对于业务应用编辑器中的可视化配对辅助来说,一次线性扫描已经足够。

它产生的两个偏移量(matchBracketA/matchBracketB)会在渲染期间被读回——每个 LineCell 都会检查任一偏移量是否落在自己的行内,如果是,则在该字符周围绘制一个小轮廓矩形。

多光标编辑

多光标支持依附于鼠标处理器,并以 Alt 键作为门控。普通 Alt+Click 会添加一个新光标;Alt+拖拽则会开始列(框)选择——而代码在鼠标释放之前并不知道是哪一种:

$ java
private void handleMousePressed(MouseEvent e) {
    if (e.isAltDown()) {
        // Might become a click (new cursor) or a drag (column selection) — resolved later.
        getSkinnable().altDragOccurredProperty().set(false);
        getSkinnable().columnSelectingProperty().set(true);
        // ...
        return;
    }
    // A plain click always collapses back to a single caret.
    extraCarets.clear();
}

handleMouseDragged 会在指针实际移动的那一刻将 altDragOccurred 翻转为 true,因此当 handleMouseReleased 运行时,它就能区分这两种情况:

$ java
private void handleMouseReleased(MouseEvent e) {
    if (columnSelecting && !altDragOccurred) {
        // No drag happened — treat this as a plain Alt+Click: add a cursor.
        int offset = offsetForMouse(e.getX(), e.getY());
        extraCarets.add(new CaretState(caretIndex, selectionAnchor));
        getSkinnable().caretIndexProperty().set(offset);
    }
    getSkinnable().columnSelectingProperty().set(false);
}

每个额外光标只是一个 CaretState(插入符偏移量 + 选择锚点),放在一个普通列表中。渲染它们开销很低:LineCell 已经知道如何根据文本偏移量绘制主插入符,因此 buildExtraCaretDecorations 会对每个恰好落在当前正在渲染行上的 CaretState 复用同一套计算。多光标激活时的编辑会把每次按键都通过 CaretEdit 函数式接口运行,对每个光标应用一次,并按文档从下到上的顺序执行,这样较早的编辑就不会移动仍在等待处理的光标的偏移量。

撤销/重做

撤销/重做基于整个文档快照,而不是 diff/patch 日志——推理起来更简单,代价是对非常大的文档会占用内存:

$ java
private void pushUndoState() {
    if (getSkinnable().isIsUndoRedoOperation()) return; // don't record undo/redo as new edits
    getSkinnable().getUndoStack().push(new EditorState(
        getSkinnable().getText(),
        getSkinnable().caretIndexProperty().get(),
        getSkinnable().selectionAnchorProperty().get()
    ));
    getSkinnable().getRedoStack().clear();
    if (getSkinnable().getUndoStack().size() > getSkinnable().getMAX_UNDO_STEPS()) {
        getSkinnable().getUndoStack().removeLast();
    }
}

private void undo() {
    if (getSkinnable().getUndoStack().isEmpty()) return;
    getSkinnable().getRedoStack().push(currentState());
    applyState(getSkinnable().getUndoStack().pop());
}

pushUndoState() 会在每个修改文本的操作开始时被调用——输入、粘贴、缩进、接受自动补全建议——并且它会用 isUndoRedoOperation 标志保护自己,这样 applyState()(撤销和重做都会使用)就不会递归地将自己的恢复操作压入栈中。每次压栈还会清空重做栈:一旦你进行了新的编辑,撤销本可以重做的“未来”就消失了,正如所有主流编辑器一样。applyState() 还会额外清空 extraCarets——撤销/重做快照只跟踪主插入符,因此恢复快照会折叠任何活动的多光标会话。

自动补全

自动补全弹窗是第三个 ListView,显示在 Popup 中,其定位依据插入符在屏幕上的实际位置,而不是任何固定位置:

$ java
private void updateAutocomplete() {
    String currentWord = wordAt(getSkinnable().caretIndexProperty().get());
    if (currentWord.isEmpty()) { 
        autocompletePopup.hide();
         return;
    }
    List<String> matches = new ArrayList<>();
    for (String s : getSkinnable().getAutocompleteSuggestions()) {
        if (s.toLowerCase().startsWith(currentWord.toLowerCase())
                && !s.equalsIgnoreCase(currentWord)) {
            matches.add(s);
        }
    }
    if (matches.isEmpty()) { 
        autocompletePopup.hide();
        return;
    }
    autocompleteListView.setItems(FXCollections.observableArrayList(matches));
    autocompleteListView.getSelectionModel().selectFirst();
    // Find the on-screen cell for the caret's line, then position the popup under it.
    VirtualFlow<IndexedCell<String>> flow = getVirtualFlow();
    IndexedCell<String> cell = null;
    if(flow != null){
        cell=flow.getCell(caretLine());
    }
    if (cell == null) {
        return;
    }
    Bounds cellBounds = cell.localToScreen(cell.getBoundsInLocal());
    autocompletePopup.show(listView, cellBounds.getMinX() + x, cellBounds.getMaxY());
}

匹配逻辑本身刻意很简单——针对已注册建议的扁平列表进行不区分大小写的前缀检查,没有模糊匹配或排名。让它感觉原生的是定位:它会遍历 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

JavaFX [LOADING...]
[LOADING...]Gerrit Grunwald2021年2月19日 16,123 次浏览

JavaFX 中的自定义控件(第四部分):Control 与 Skin 类

JavaFX

JavaFX [LOADING...]
[LOADING...]Gerrit Grunwald2021年3月5日 9,059 次浏览

JavaFX 中的自定义控件(第六部分)——Canvas 类

JavaFX

JavaFX [LOADING...]
[LOADING...]Frank Delporte2024年8月7日 6,032 次浏览

JavaFX Nodes 与 Canvas 对比

JavaFX

JavaFX [LOADING...]
[LOADING...]Almas Baimagambetov2021年1月18日 24,966 次浏览

JavaFX 高性能渲染技巧

JavaFX

Java [LOADING...]
[LOADING...]George Ball2023年10月25日 4,464 次浏览

使用事件处理行为

Java

参与讨论

JavaFX [LOADING...]
[LOADING...]Libán Bande González2026年10月1日 194 次浏览

宣布《Skinning JavaFX Applications》

JavaFX

JavaFX [LOADING...]
[LOADING...]Gerrit Grunwald2021年2月19日 16,123 次浏览

JavaFX 中的自定义控件(第四部分):Control 与 Skin 类

JavaFX

JavaFX [LOADING...]
[LOADING...]Gerrit Grunwald2021年3月5日 9,059 次浏览

JavaFX 中的自定义控件(第六部分)——Canvas 类

JavaFX

JavaFX [LOADING...]
[LOADING...]Frank Delporte2024年8月7日 6,032 次浏览

JavaFX Nodes 与 Canvas 对比

JavaFX

JavaFX [LOADING...]
[LOADING...]Almas Baimagambetov2021年1月18日 24,966 次浏览

JavaFX 高性能渲染技巧

JavaFX

Java [LOADING...]
[LOADING...]George Ball2023年10月25日 4,464 次浏览

使用事件处理行为

Java