Skip to the content.

IntelliJ IDEA 编辑器布局计算

IDEA 编辑器中的内容绘制、编辑器大小管理和坐标转化等功能都由 EditorView 完成,包含了大量的排版相关业务逻辑,例如 Inlay、Bidi 支持等等。我们来看一下 IDEA 是如何实现这些功能,并组织好这部分代码的。

一些前置知识

EditorView

先来看一下和 EditorView 有关的一些类,首先是它的成员:

其中 LogicalPositionCacheTextLayoutCacheEditorSizeManager 都实现了 PrioritizedDocumentListener,我们再把这个优先级表拿出来溜一下

public final class EditorDocumentPriorities {
  public static final int RANGE_MARKER = 40;

  public static final int FOLD_MODEL = 60;
  public static final int LOGICAL_POSITION_CACHE = 65;
  public static final int EDITOR_TEXT_LAYOUT_CACHE = 70;
  public static final int LEXER_EDITOR = 80;
  public static final int SOFT_WRAP_MODEL = 100;
  public static final int EDITOR_TEXT_WIDTH_CACHE = 110;
  public static final int CARET_MODEL = 120;
  public static final int INLAY_MODEL = 150;
  public static final int EDITOR_DOCUMENT_ADAPTER = 160;
}

可以看到在 FoldingModel 处理完并发送相应的事件后,依次由 LogicalPositionCacheTextLayoutCacheSoftWrapModelInlayModel 等进行处理,它们的具体功能会在下面进行讲解。

LogicalPositionCache

偏移量与 Logical Position 的映射逻辑在 LogicalPositionCache 里完成。它作为除了 FoldingModel 之外最优先的 DocumentListener,需要给后续的所有排版提供基本的数据支持。

不是说 Document 支持对 Logical Line 的维护吗,为什么这里还需要单独一个 Cache 类呢?

答案其实很简单:因为 Document 作为一个 CharSequence 维护的是 UTF-16 作为单位的序列;而在计算 Logical Column 时,我们需要的是其 Code Point 在计算过 Tab 的影响后对应的位置。这也就导致在计算偏移量到 Logical Position 时,虽然能直接从 Document 获得它对应的 Logical Line,但 Logical Column 还是需要从行首进行遍历,处理 UTF-16 中的 Surrogate Pairs 和编辑器 Tab 字符配置。在逻辑行长度不可控的情况下,我们有必要对这部分数据进行缓存。同时,我们可以对没有这些特殊情况的 Logical Line 进行优化,直接使用 字符偏移量 即可。

TextLayoutCache

文本的排版主要分为几个流程:将文本切分为逻辑行,将逻辑行的内容按照各种规则切分为一个个小段、把这些文本段按照正确的顺序进行摆放在视觉行上。而中间那个步骤生成的就是 TextLayoutLineLayoutChunk (每个 Chunk 对应于一个 BidiRun 中的最多 1024 个字符)为单位维护生成的 LineLayout 里的数据,这些缓存以 LRU 的形式维护在 TextLayoutCache 中,在活跃的编辑器中最多维持 1000 个、不活跃的编辑器只维护 10 个。

LineLayout 计算和保存都由 TextLayoutCache 提供,TextLayoutCache 处理 Document 变更的事件紧接在 LogicalPositionCache 前,并需要用于后续的 Lexer 计算和 Soft Wrap 计算等。(需要注意的是:TextLayoutCache 实际计算 LineLayout 的时机是在 Document 变更完成之后的,即已经能够获取当前的 Tokenize 结果)

作为 PrioritizedDocumentListener,它会监听 Document 变更事件:

invalidateLines 会清除掉对应区域缓存的 TextLayout,它接受 5 个参数,分别代表:

首先,对于 startLinemin(oldEndLine, newEndLine)LineLayout 缓存,它们肯定是直接失效的,我们需要将这些 LineLayout 里的 Chunk 从缓存中移除;在删除之后,如果 文本发生了变化且文本需要 Bidi 支持 或者 原文本是 RTL 文本,那么设置本行需要在计算时考虑 Bidi 的影响。

接下来分两种情况:

  1. oldEndLine < newEndLine:需要在此之后新增一些 LineLayout
  2. oldEndLine > newEndLine:需要移除剩下那部分的 LineLayout 缓存

至此,TextLayoutCache 里过时的缓存已经被清除。那么什么时候它会向里面存储呢?TextLayoutCache 采取的是 Lazy 的策略,只在其它组件进行询问的时候才进行 LineLayout 的创建并完成存储。

LineLayout 的创建过程分为两个部分:第一部分是根据 Bidi 规则将文本分为一个个 BidiRun,保证每个 BidiRun 中的文本拥有同一个方向属性;第二部分则是将 BidiRun 按照 Bidi 规则整理出正确的视觉顺序,并计算出每一个 BidiRun 在视觉起始位置上对应的逻辑位置。

第一部分的逻辑如下:

第二部分:

LineLayout 不关心实际的文本中每个元素的具体的位置,这些逻辑将在 EditorCoordinateMapper 里进行处理

EditorCoordinateMapper

EditorView 提供了下面四种坐标的互相转换功能(共计 12 个方法,但实际上只需要处理相邻的两层即可,即 3 对映射关系)

这些转换方法要求:

  1. 当前线程是 EDT
  2. 当前不处于批量修改状态

它们的逻辑在 EditorCoordinateMapper 里,下面讨论一下里面的细节

偏移量与 Logical Position 的映射

这部分逻辑在 LogicalPositionCache 里提供了实现。

Logical Position 与 Visual Position 的映射

在理想状态下 Logical Position 和 Visual Position 之间应该是一一对应的,那么有哪些东西会影响这种和谐的关系让我们不得不实现这么多复杂的逻辑呢?

从 offset 到 Visual Line 的计算,首先需要确认该 offset 对应的位置是否处于折叠区域内,如果是,则找到最外部的折叠区域,并调整 offset 到折叠区域的最左侧;然后,根据 offset 找到该位置之前出现过的 Soft Wrap 个数,那么最终的 Visual Line 行号就是:Logical Line Number + Soft Wrap 个数 - 折叠的行数。

获取了行号之后我们需要在这个 Visual Line 上获得它的 Column。IDEA 编辑器没有对 Visual Line 进行维护;他们采取的方式是实现一个迭代器,即 VisualLineFragmentsIterator(它所遍历的 Fragment 需要保存相同的字体和方向)完成对 Visual Line 里面各种元素的遍历,并将这些数据保存在迭代器里面,迭代器访问过程中生成的 Fragment 仅仅是对于迭代器内容的一个包装。

这个迭代器包含较为复杂的 Folding Region 和 Inlay 处理逻辑


下面讨论内容中的一些术语:

迭代器的初始化流程如下:

setInlaysAndFragmentIterator 的主要作用是:如果迭代器当前访问的不是一个折叠区域,那么准备好对应的 Inlay 数据或者 LineFragment 数据(LineFragment 的数据主要通过 LineLayout 创建的 VisualFragment 迭代器进行提供),它的逻辑为:

VisualLineFragmentIterator 在迭代时的流程如下:

在所有 Segment 被遍历完之后,迭代过程结束


迭代结束了但事情还没结束,我们还得回到 EditorCoordinateMapper 。在获取到 offset 位置开始时的迭代器后,对于每个 Fragment


上面是 Logical 到 Visual 的计算,反过来如何呢?

类似地,先拿到 Visual Line 对应的 Offset,这里 IJ 的实现槽点很多:它从 0 到 Document 的长度进行二分,找到第一个满足其对应的 Visual Line 为输入的 Visual Line 的 Offset(也就是说每一次检查都需要执行一次 offsetToVisualLine,而这个方法需要对 SoftWrapModelFoldingModel 进行查询,整个过程大概是 $O(\log^2 n)$ 的)。然后根据此 offset 创建 VisualLineFragmentsIterator,遍历过程基本与之前的过程一样,只不过会检查的内容从 Visual 相关变成 Logical 相关,这里也不再赘述。

Visual Position 与 XY 的映射

从 Visual Position 到 XY 的计算过程中,首先根据 Visual Line 来获得当前行的 Y,方法为 visualLineToY。它的计算也很简单:View 的顶部间隙 + Visual Line * 行高 + Visual Line 之前的 Block Inlay 高度之和。Inlay 的高度的前缀和在 InlayModel 使用平衡树进行维护,每次的查询时间为 $O(\log n)$;当前行的初始 X 需要考虑文本的 Prefix Text 和 View 的左间隙。

然后获取 Visual Line 的开始 Offset,并创建 VisualLineFragmentsIterator 开始遍历 Fragment。此迭代器保证,Fragment 按照遍历的顺序,其开始的 Visual Column 一定是递增的。

迭代过程中,找到第一个满足 Visual Column 处于其中的 Fragment 并使用 Fragment 提供的坐标转换功能获得 Visual Column 对应的 X。需要注意的是,如果 Visual Column 在 Soft Wrap 符号后面或者是行末的 Inlay 中,Fragment 的 Column 是遍历不到的,这里需要特殊处理。


再讨论一下 XY 到 Visual Position: 依旧是先根据 Y 拿到 Visual Line。IJ 在这里的实现和 visualToLogicalPosition 一样丑陋,仍然是根据 Visual Line 进行二分,每一次查询都需要调用一次 visualLineToYgetInlaysHeight,合计 $O(\log^2 n)$。之后也是遍历 Visual Line 的各个 Fragment,找到第一个 X 在内部的 Fragment,并通过 Fragment 获取对应的 Column。Soft Wrap 引入的上一行结束和下一行开始的符号,需要根据 x 在其中的位置来确认与 Soft Wrap 的相对位置。行末的 Inlay 同理


可以看出,整个 EditorCoordinateMapper 的逻辑都与 VisualLineFragmentsIterator 密切相关,而且通过维护的 Soft Wrap 保证每次只需要遍历 Visual Line 内部的 Fragment(其个数是相对较少的);但为了支持 Inlay 和 Soft Wrap 符号等功能也引入了大量的业务逻辑。

EditorSizeManager

文本元素大小更改的管理和缓存相关的逻辑都在 EditorSizeManager 里。它作为 PrioritizedDocumentListener 的优先级是 EDITOR_TEXT_WIDTH_CACHE 。在上面的表中,我们可以看到它在 Soft Wrap 之后,在 Caret 之前。同时,它还是 FoldingInlayListener。但是它并不直接为 Editor 的排版提供数据,它主要的作用是计算编辑器偏好的大小。

EditorSizeManager 主要维护的数据是每一个视觉行的宽度(单位为像素):myLineWidth: TIntArrayList 。在接收到 Document 变更事件时,根据文本变更的范围对缓存的行宽进行失效,折叠区域和 Inlay 的处理类似。

不过对于批量的代码折叠操作,EditorSizeManager 会把相应的文本变更处理进行 defer,并在整体的折叠操作结束后通知 EditorContentComponent 重新验证内部 layout。

那么它维护的行宽究竟有什么用呢?主要用于计算 preferedHeightpreferedWidth,这些数据将会用于滚动条和整体布局计算的逻辑中。

preferredHeight 代表编辑器所有可滚动区域的高度,preferredWidth 则是编辑器整体最宽的部分,计算通过所有 Visual Line 的 X 以及 BlockInlay 的最大宽度。

总结

IDEA 的文本元素排版为了保证在增量文本更新下的性能,采用了惰性求值与缓存的策略,仅在重绘等场合才会对文本的排版进行计算。排版时,首先更新 UTF-16 的 Code Unit 到 Code Point 的映射(即调整 Logical Position 和 Offset 的关系),然后对文本进行 Tokenize 得到每个 Token 的位置;在 Inlay Inlay 和 Folding 处理完之后,根据一个个 Fragment 以计算 Soft Wrap 从而生成概念上的 Visual Line ,同时计算出 Visual Line 上每个元素的 X 坐标;最后根据 Block Inlay 来计算出每个 Visual Line 的纵坐标,从而完成最终的排版