前言
在使用诸如 Emacs 、 Vim 或者 UltraEdit 等编辑器编写代码的时候,分割窗体或者说分割缓冲区是一个频繁使用到的功能,方便了程序员快速切换和观察不同的代码缓冲区。至于缓冲区内的内容,不同的编辑器有不同的处理方法,如 Emacs 的缓冲区可以独立地装载并显示各个已打开的文件,而 UE 则为每一个打开的文件单独设立一个缓冲区窗体。 Emacs 的实现方法稍微需要一些技巧,但这么做更加方便和通用一些,这在之前的文章里已有所解释,并给出了适当的实现方法,即在 TclTk 中使用了非显示 text 组件作为基本缓冲区,并通过 peer 来实际地创造出可视缓冲区编辑区域。
至于如何分割窗体,这里还是要大致先做一些基本的介绍。如下图所示,这就是一个分割成三个缓冲区的例子。其中左侧的大缓冲区为第一次垂直分割产生,对右侧的第二次水平分割再产生了上下两个小缓冲区。每次分割都是对称比例的分割,产生两个同体积水平或者垂直并列的缓冲区。
图表 1 ( Emacs 分割示例)
我们可以在大缓冲区内编写代码,而在右侧上方的缓冲区内执行 shell 命令,在右下侧的缓冲区内进行编译,另外根据额外的要求,还可以水平或者垂直分割以上的三个缓冲区。当然这也要有一个度,过多的缓冲区容易造成杂乱,就我个人经验来说,一般是不会超过 4 个的。但是对于分割功能的实现来说,应该是能够满足任意多窗体的分割。
struct::tree 与树形数据结构
首先我们给出一个简易的例子。初始状态下,我们有一个即将被用于分割 text 组件,其下并列排着一个用于分割当前缓冲区的按钮,一个销毁当前缓冲区的按钮和一个用于指定分割方向的 checkbutton 。代码中有一个树形的数据结构,用于保存分割窗体的层次结构,这在合并时需要用到,但是无论是分割还是合并都需要对该结构进行更新以保证其能够真实地反应被分割窗体的结构状态。可以说该结构就是分割合并算法的核心组件。
图表 2 (试验窗体)
以上的图即是被分割后的试验窗体。其中的实体缓冲区将以叶子的形式存储,而被分割的原缓冲区将成为被分割出的两个缓冲区叶子的父亲节点,代替原先叶子的位置。对应于以上的分割,树形数据结构将存储以下的数据。
图表 3 (树形结构)
从以上结构中,我们可以清晰地看出来, tx1 、 tx2 和 tx3 是出于同一个层次的,而 tx4 和 tx5 出于更下面的一个层次。而在每个非叶子节点中,则都有一个保存了分割方向的 hash 键值,以说明该节点得叶子是被水平或者垂直分割的,这在合并还原时用到来指定不同方向的扩展填充。
在 tcllib 中的 struct::tree 为我们提供了操作树形数据结构的一系列方法和指令,事实上以上的数据结构就是通过该模块的命令来生成并操作的。虽然该模块提供了大量的命令,但一般来说仅需要熟练使用以下几个即可,另外的在用到的时候可以查 tcl 的手册来获得必要的信息。
各个命令的意思可以从中间的关键命令词大致得到,需要说明的一点就是这里的 index 命令式返回当前节点所处父节点之下的位置,并非全树中的 index 。另外, get 和 set 命令非别用于得到或者设定当前节点的中保存的 hash 键值,也就是为节点提供了某种保存属性的功能,这在很多应用场合的中都是很方便。
该模块的最大特点在于,首先它为每一个节点创建或设置了一个树范围内的唯一名称,这就使得立即访问成为可能,而不需要遍历整棵,其实这就相当于建立了一个 reference ;第二,可以方便地建立多叉树,而不仅仅局限于二叉或者其他;最后,没有 reference 的存在使得在并发处理时能够竟可能地保证数据的一致性,也即是说我们可以方便地限制对树的操作事实上是对树的镜像拷贝的操作,而并不影响原树的结构和数据,同时这也是 TclTk 语言本身的优点。
分割算法
其实相对于合并来说,分割算法是相对易于实现的,至少在逻辑上这是相当清晰的。因为对于被分割的缓冲区,其起点是不变的;而新分割出来的缓冲区的起点根据减半的宽度或者长度即可得出,以下是代码。
首先得到当前缓冲区路径 old 、新建缓冲区 new 并得到分割方向 dir 。之后,更新树形数据结构 buffer_struct 。具体的做法是在 old 节点所在的位置替换一个新建匿名节点,再将 old 与 new 添加到该匿名节点下。为了方便阅读,可以将代码分割开来如下:
接着通过 place info 命令得到 old 的分布参数,再在 if/else 判断中根据分割方向修改得到新缓冲区的分布数据,最后 place 该两个缓冲区。应该说,分割部分是不容易出错的,抛开树形结构不用,依然能够很好地得到应有的效果,但是合并算法就不同了,可以说树形结构的出现完全是为了配合合并算法。当然了,这也跟我采用 place 这个 geometry 命令有关,倘若使用 pack 或者 grid ,则可以通过自动扩充来填补销毁掉的缓冲区流下的空白。但是如果这样的话,在分割的时候就很难控制好主窗体的大小,新建的缓冲区将扩展父窗体的大小,以保证与原缓冲区同体积,这就有违初衷了,进而带来奇怪的效果。所以说,这几种 geometry 命令各有所长,但鱼与熊掌不可兼得,在决定使用 place 之后,剩下的合并问题还是要靠自己来解决。
合并算法
为方便讲解,还是先写一下代码:
这里大致有两个问题要解决。第一个是确定当销毁一个缓冲区之后,需要随之变更位置及大小的缓冲区有哪些;第二,如何进行变更。这是两个有递进关系的问题,所以当务之急是先解决第一个。在这里,通过树形结构,我们可以清晰地得到需要更改的缓冲区。具体的步骤可以从以上的代码中抽取若干行出来:
首先我们在第一行中要得当前节点的父节点,在第二行里删除当前节点,再通过第三行的 sibling 得到父节点的另一个子节点。以得到的该节点为根的叶子节点既是我们需要进行更改位置和大小的缓冲区集,在这里可能是得到节点本身。第五行到第八行所表达的以上过程还可以理解为,得到被删除缓冲区的兄弟缓冲区,或者在该兄弟缓冲区中再次被分割出来的若干缓冲区。
第二个问题中隐藏包含了一个分支问题,既是被删除缓冲区与兄弟缓冲区(集)所处的相对位置。对于水平和垂直分割,这就是一个上下或者左右的关系。删除位于上方或右侧的缓冲区的算法思路是不同于删除位于下方或左侧的缓冲区的,其中相差了 50% 的偏移量。即是说对于前一种情况,位于被删除缓冲区相对应位置的兄弟缓冲区(集),在扩张其起点和宽度(长度)前,应该减去0 .5 的偏移量。因此在以上的代码中,我通过考察被删除就节点的 index 值来得到其位置, index 为 0 及表示其处于左侧或者上侧位置,如为 1 则处于右侧或下侧位置。之后,我们就可以放心地二倍扩展起点与宽(长)度了。其中我使用了一个小窍门,既是将为之分布参数包装为一个 hash 表,修改完之后再还原为文本集成入 place 命令中。这就有效地减少了代码数量从而使代码结构更清晰易懂,以便于日后的维护。
结尾
多缓冲区编辑应该是工程 cucumber2 中最让我头疼的东西,既然做了就要做得好,因此我最终还是决定实现 Emacs 中的非绑定的多缓冲区编辑模式,即是说缓冲区不与特定文本关联,每一个缓冲区即是一个新编辑器。这给实现过程带来了巨大的难度,幸好抽丝剥茧地终于解决了其中的大部分主要难题了,剩下的便是统筹整合这些解决方案了。除此之外,通过分割合并算法的解决,也让我认识到 Tcl 在 list 基础上实现多种数据结构的可能性与优势。因此学好 Tcllib 中的 struct 模块,利用适当的数据结构来解决算法,将对优化代码的编写带来巨大的益处。