乍听之下,不无道理;仔细揣摩,胡说八道

0%

序言

入坑VS Code前,我已经是一名久经考验的Emacs老用户了,因此开始正式使用VS Code后,我第一时间启用了它的Emacs Keymap。但不久我便发现,这套键映射缺少一个重要的快捷键——ctrl-l

Emacs中,ctrl-l对应的命令是recenter-top-bottom,它用于将光标所在的行轮替地滚动到可视区域(即Emacs中的window)的中间、顶部,以及底部(如下图所示)

这是我高频使用的一个功能,尤其是跳转到函数的定义的首行后,我习惯于连按两次,将其滚动到window的顶部以便在一屏中看到尽量多的内容。

为了避免重复发明轮子,我先搜索了一番,找到了一个宣称实现了该功能的扩展Recenter Top Bottom。可惜的是,安装后并不生效。

难道只能委屈自己用鼠标小心翼翼地将光标所在行滚到顶部了吗?当然不是。既然没有开箱即用的,那便自己写一个VS Code的扩展实现这个功能吧。

年轻人的第一个VS Code扩展

创建VS Code扩展的项目

要想入门VS Code扩展的开发,官方便提供了一份不错的教程。一个扩展有许多的“八股文”代码,可以用yogenerator-code来快速生成

1
2
npm install -g yo generator-code
yo code

到这里,便得到了一个名为helloworld的目录了。用VS Code打开它,接下来要在其中大展身手。

实现将光标所在行垂直居中的功能

VS Code扩展的核心逻辑定义在文件src/extension.ts中。在yo生成的示例代码中,用registerCommand注册了一个名为helloworld.helloWorld的命令,其逻辑是简单地在右下角弹出一句Hello VS Code from HelloWorld!。这个回调函数,便是业务逻辑的落脚点。

要想实现将光标所在行滚动到中间的功能,首先要知道VS Code为开发者提供了哪些支持。在摸索了一通从VS CodeAPI文档后,我有了以下的线索:

  1. 通过vscode.window.activeTextEditor可以取得当前聚焦的编辑器——其值可能为空(undefined);
  2. TextEditor实例的属性.selection.active可以取得当前光标的位置;
  3. TextEditor实例有一个方法revealRange可以滚动文本来改变展示的范围,它需要一个vscode.Range类的实例,以及一个vscode.TextEditorRevealType类型的枚举值;
  4. vscode.TextEditorRevealType.InCenter的效果是将所给定的范围展示在中间,vscode.TextEditorRevealType.AtTop则是置顶。

有了这些知识储备,实现这样的一个回调函数便是信手拈来的事情了

1
2
3
4
5
6
7
8
function recenterTop() {
const editor = vscode.window.activeTextEditor;
if (!editor) {
return;
}
const cursorPosition = editor.selection.active;
editor.revealRange(new vscode.Range(cursorPosition, cursorPosition), vscode.TextEditorRevealType.InCenter);
}

由于暂时没有配置该命令的快捷键,只能用VS Code的命令面板来调用

实现将光标所在行置顶的功能

接下来我将实现连续调用两次helloworld.helloWorld命令,把光标所在行滚动到顶部的效果。在Emacs中,可以很轻松地知道一个命令是否被连续运行——Emacs有一个名为last-command的变量存储着上一个命令的名称,只需要检查其是否等于recenter-top-bottom即可。但VS Code没有暴露这么强大的功能,只能另辟蹊径。

我的策略是,如果调用helloworld.helloWorld时光标的位置,与上一次调用该命令时的位置相同,就认为是连续调用。为此,需要两个在函数recenterTop之外定义的变量:

  1. previousPosition负责记录上一次调用recenterTop时光标的位置,它的初始值为null
  2. revealType存储着上一次调整展示范围时传递给TextEditor实例的revealRange方法的第二个参数的值,它的初始值也为null

我的目标是尽量模拟Emacs中的recenter-top-bottom所具备的、交替使用居中、置顶效果的特点,因此:

  1. 如果revealTypenull,意味着这是第一次调用recenterTop,那么效果便是居中。否则;
  2. 如果这一次与上一次的光标位置不同,意味着在上一次调用recenterTop后调用过其它命令,效果依然是居中。否则;
  3. 如果revealType已经是居中了,就改为置顶。否则;
  4. revealType改为居中。

Talk is cheap. Show me the code.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
let previousPosition: null|vscode.Position = null;
let revealType: null|vscode.TextEditorRevealType = null;

function recenterTop() {
const editor = vscode.window.activeTextEditor;
if (!editor) {
return;
}
const cursorPosition = editor.selection.active;
if (!revealType) {
revealType = vscode.TextEditorRevealType.InCenter;
} else if (previousPosition && !cursorPosition.isEqual(previousPosition)) {
revealType = vscode.TextEditorRevealType.InCenter;
} else if (revealType === vscode.TextEditorRevealType.InCenter) {
revealType = vscode.TextEditorRevealType.AtTop;
} else {
revealType = vscode.TextEditorRevealType.InCenter;
}
previousPosition = cursorPosition;
editor.revealRange(new vscode.Range(cursorPosition, cursorPosition), revealType);
}

定义快捷键

通过命令面板来使用不是我的最终目标,通过快捷键才是。根据VS Code文档可以知道,只要在package.jsoncontributes对象中,新增名为keybindings的属性,并定义命令及按键序列即可。

1
2
3
4
5
6
7
8
9
{
// 此处省略其它不必要的属性
"contributes": {
"keybindings":{ // 新增属性
"command": "helloworld.helloWorld",
"key": "ctrl+l"
}
}
}

后记

如果看过我之前的文章《手指疼,写点代码缓解一下》的读者应当会记得,我已经从Emacs Keymap“叛逃”到了Vim Keymap了。所以,我并没有真正用上上述的VS Code扩展。相反,目前高频使用的是Vim Keymap内置的z-.以及z-↵了——前者用于垂直居中,后者用于置顶。

爱护手指,从使用Vim Keymap做起。

有一天,我用Homebrew安装了一些软件——因为已经是一个月前的事情了,所以已经记不清是安装了什么。安装后并没有立即出现什么问题,只是又过了两天我重新启动电脑后,发现同样是由Homebrew安装的Emacs不由分说地无法启动了。这下可麻烦了,毕竟我是org-mode的重度使用者,还需要偶尔用SLIME写点Common Lisp的代码,而它们都运行在Emacs中。

直觉告诉我,也许重新安装一下Emacs,一切就可以恢复正常。重装了Emacs后,又遇到了别的问题——用BetterTouchTools在Touch Bar中添加的按钮,无法在Emacs已经启动的情况下,切换到它的窗口上。

非要说,问题其实也不大,毕竟很多时候是将MacBook Pro合上盖子当主机用的,Touch Bar在工作时的使用频率并不高。此外,糊Node.js等语言的代码时也用不到Emacs——还是VSCode更合适。

但这就是令人不爽,因此我决定要解决它——用Hammerspoon

Hammerspoon是什么?

Hammerspoon官网很好地说明了这款工具的定位和原理

This is a tool for powerful automation of OS X. At its core, Hammerspoon is just a bridge between the operating system and a Lua scripting engine. What gives Hammerspoon its power is a set of extensions that expose specific pieces of system functionality, to the user.

  1. 它运行在OS X上——现在应该叫macOS;
  2. 它是用来自动化操作的——就像系统内置的Automator或第三方的Alfred Workflow那样;
  3. 它的原理是将操作系统的功能封装成了可以用Lua代码调用的模块;

例如下面的代码

1
2
3
hs.hotkey.bind({"cmd", "alt", "ctrl"}, "E", function()
hs.alert.show("Hello World!")
end)

就可以让使用者在按下组合键⌘⌥⌃e的时候,在屏幕正中间显示Hello World!这段文本

为什么用Hammerspoon?

Hammerspoon正好可以解决我的问题,它的hs.window模块既可以让使用者遍历所有打开的窗口(用hs.window.allWindows函数),也可以聚焦到指定的窗口上(用focus方法)。有了它们,将Emacs调到最前面(front-most)来也就是水到渠成的事情了:

  1. 调用函数hs.window.allWindows函数,获得所有窗口的列表;
  2. 逐个检查列表中的窗口对象,如果属于Emacs的,就调用窗口的方法focus,并跳出循环。

剩下的两个问题便是:

  1. Emacsbundle ID是什么;
  2. 如何知道一个窗口对象的bundle ID

Emacs的bundle ID

Bundle ID可以在macOS中独一无二地标识一个应用。要想知道Emacsbundle ID是什么,只需要打开文件/Applications/Emacs.app/Contents/Info.plist,看看其中键为CFBundleIdentifier的值即可。

1
2
3
4
➜  Contents grep -A 1 'CFBundleIdentifier' Info.plist
<key>CFBundleIdentifier</key>
<string>org.gnu.Emacs</string>
➜ Contents

可以看到,Emacsbundle IDorg.gnu.Emacs

来点Lua代码吧

有了Emacsbundle ID,接下来就可以在Hammerspoon中定义快捷键了。由于最后会通过Touch Bar上的按钮来触发这组快捷键,复杂点也不要紧,因此我直接沿用了Hammerspoon的入门指引中作为例子的⌘⌥⌃w

1
2
hs.hotkey.bind({"cmd", "alt", "ctrl"}, "W", function()
end)

为了在一个循环中逐个遍历窗口对象,将hs.window.allWindows的返回值保存到一个局部变量中

1
2
3
hs.hotkey.bind({"cmd", "alt", "ctrl"}, "W", function()
local windows = hs.window.allWindows()
end)

照着简书上的这篇文章,依葫芦画瓢地用forpairs来遍历变量windows

1
2
3
4
5
6
hs.hotkey.bind({"cmd", "alt", "ctrl"}, "W", function()
local windows = hs.window.allWindows()
-- 在Lua中遍历表的方法:https://www.jianshu.com/p/de5a4b132918
for _, win in pairs(windows) do
end
end)

窗口自身没有bundle ID,为此需要先获取窗口所属的应用。查看文档可以知道,有一个application方法正是用来获取应用对象的

1
2
3
4
5
6
7
hs.hotkey.bind({"cmd", "alt", "ctrl"}, "W", function()
local windows = hs.window.allWindows()
-- 在Lua中遍历表的方法:https://www.jianshu.com/p/de5a4b132918
for _, win in pairs(windows) do
local app = win:application()
end
end)

调用allWindows时使用的是英文句号(.),调用application则是用冒号(:),这正是Lua中调用函数与方法时语法上的差异。

再用应用的bundleID方法获得它的bundle ID

1
2
3
4
5
6
7
8
hs.hotkey.bind({"cmd", "alt", "ctrl"}, "W", function()
local windows = hs.window.allWindows()
-- 在Lua中遍历表的方法:https://www.jianshu.com/p/de5a4b132918
for _, win in pairs(windows) do
local app = win:application()
local bundleID = app:bundleID()
end
end)

现在,只要变量bundleID等于Emacsbundle ID就可以聚焦到当前遍历的窗口上了

1
2
3
4
5
6
7
8
9
10
11
hs.hotkey.bind({"cmd", "alt", "ctrl"}, "W", function()
local windows = hs.window.allWindows()
-- 在Lua中遍历表的方法:https://www.jianshu.com/p/de5a4b132918
for _, win in pairs(windows) do
local app = win:application()
local bundleID = app:bundleID()
if bundleID == "org.gnu.Emacs" then
win:focus()
end
end
end)

让Touch Bar按钮触发这一切

只需要在BetterTouchTools中配置一下即可

这个方法比此前唤起/Applications/Emacs.app的方式更好,因为它只依赖于Emacs逻辑上亘古不变的东西——bundle ID,而不依赖于其物理上的安装位置。

“实战Elisp”系列旨在讲述我使用Elisp定制Emacs的经验,抛砖引玉,还请广大Emacs同好不吝赐教——如果真的有广大Emacs用户的话,哈哈哈。

org-mode中,一个条目(entry)可以设置多个属性(Properties)。有的属性是org-mode内置的,有它们的特殊用途。有的属性是自定义的,用在一些插件或仅仅用于记录信息。CUSTOM_ID属于前者,而ID属性后者。

CUSTOM_ID

CUSTOM_ID用于跳转。org-mode支持丰富的外部链接格式,其中之一便是链接到指定.org文件的指定CUSTOM_ID的条目。

比如在一个.org文件中有file:/Users/liutos/Dropbox/gtd/roles/writer.org::#d1bdc978-a8ce-4266-9ffa-b6041f818431这么一段文本,那么当光标置于这个文本中时,按下快捷键C-c C-o,Emacs便会打开文件/Users/liutos/Dropbox/gtd/roles/writer.org,并将光标对应的条目上。

ID

ID用于联系两个条目。一个名叫org-edna的第三方插件能够实现两个条目间的依赖,其中一个要素便是条目的ID属性。

比如我有一个讲解Ada语言的任务(以一个条目的形式存在),同时也有一个学习Ada语言的任务(另一个条目)。显然,必须先学习一番才能讲给他人听,所以第一个条目依赖于第二个条目,于是我先给学习Ada语言的条目设置一个ID属性,值为905fc2f4-4e28-4966-84fa-84c9e6bae96c,然后再为讲解Ada语言的条目中设置一个BLOCKER属性,值为ids(905fc2f4-4e28-4966-84fa-84c9e6bae96c)。如此一来,当讲解Ada语言的条目出现在*Org Agenda*中时,Emacs会将其置灰显示,代表它处于阻塞的状态,必须先处理它的依赖才行。

自动填充CUSTOM_ID和ID

建立依赖和跳转都是很常用的功能,因此我会给每一个条目都设置CUSTOM_IDID属性。为了免除每次都手动设置的麻烦,我用org-modecapture-template特性来实现自动填充。

capture-template是org-mode的又一项利器,用于生成条目间共性的内容,比如行首的星号、关键字,以及写入到哪一个文件的哪一个层级中。org-mode的官网便有一个例子

1
2
3
4
5
(setq org-capture-templates
'(("t" "Todo" entry (file+headline "~/org/gtd.org" "Tasks")
"* TODO %?\n %i\n %a")
("j" "Journal" entry (file+datetree "~/org/journal.org")
"* %?\nEntered on %U\n %i\n %a")))

在capture-template中除了可以用预置的占位符(比如上文的%U%i,以及%a),还可以调用任意的Elisp函数——这正适合填充IDCUSTOM_ID这类不重复,并且有一定的格式要求的属性。ID属性的值可以用来自于第三方插件uuidgenuuidgen-4函数来生成

1
2
3
(setq org-capture-templates
'(("t" "Todo" entry (file+headline "~/org/gtd.org" "Tasks")
"* TODO %? :PROPERTIES:\n :CUSTOM_ID: %(uuidgen-4)\n :ID: %(uuidgen-4)\n :END:")))

美中不足的是,CUSTOM_IDID的值是不同的,因为uuidgen-4每次都会返回不同的字符串。有没有什么办法能够让它们一样的呢?答案是肯定的。

一式两份

既然两次调用uuidgen-4的结果不同,那么就将第一次调用后的结果保存起来,然后重复使用即可。思路很简单,实现代码也很直白

1
2
3
4
5
6
7
8
(let (lt-org-capture--uuid)
(defun lt-org-capture-uuidgen ()
"生成一个UUID并填充到词法作用域的变量中。"
(setf lt-org-capture--uuid (uuidgen-4))
lt-org-capture--uuid)
(defun lt-org-capture-uuidclr ()
"返回生成好的UUID并清空它。"
lt-org-capture--uuid))

capture-template也是水到渠成的

1
2
3
(setq org-capture-templates
'(("t" "Todo" entry (file+headline "~/org/gtd.org" "Tasks")
"* TODO %? :PROPERTIES:\n :CUSTOM_ID: %(lt-org-capture-uuidgen)\n :ID: %(lt-org-capture-uuidclr)\n :END:")))

后记

在上面的函数定义中,我试图利用词法作用域特性,使得lt-org-capture--uuid只能被lt-org-capture-uuidgenlt-org-capture-uuidclr读写。遗憾的是,Elisp并不支持词法作用域,lt-org-capture--uuid实际上是一个全局变量——完全可以用C-h v来审视它。

全文完。

序言

备考某等级考试的时候,在教材中碰到了几个一直不太理解的、关于硬盘的概念:磁道、柱面号、扇区。然而教材没有配图,无法直观地了解这些概念的物理形态。维基百科的硬盘词条页中倒是有一副不错的示意图,我截图搬运了过来

机械硬盘示意图

原图是一张SVG图片,本质上是一堆指令——也就是所谓的语绘啦。我是一个语绘爱好者,也想试试看能否用代码画一幅差不多的图出来。

在旧文《程序员特有的画图方式——语绘工具小入门》中,我演示过几款写代码画图的工具,但它们都不适合用来绘制几何图形,所以这次它们没有用武之地。

本来我想试试用MetaPost来画的,但鉴于“入门”了太多次,这次还是换点新花样吧。这一次,我用LaTeX+TikZ来画。

TikZ是什么及光速入门

著名的压泡面神器、麻将桌脚垫《TAOCP》的作者发明了TeX,知名的Raft竞品Paxos算法的作者在此基础上创造了LaTeX,它们都是程序员简历论文排版的好帮手。而TikZ则是如虎添翼地在LaTeX中实现了简单易懂的绘图功能的一个红包宏包(macro package,TeX的术语)。简而言之,TikZ自定义了一套“语言”,可以在用LaTeX编写的文档中画出各种图形。

百闻不如一见,我演示一下如何用TikZ画一条线段、一个圆,以及一段圆弧。先将下列的代码保存到一个文件three_in_one.tex

1
2
3
4
5
6
7
8
9
10
11
12
13
\documentclass{standalone}
\usepackage{tikz}
\usetikzlibrary{shapes.geometric, arrows}
\begin{document}
\begin{tikzpicture}[scale=2]
%% 画一条从原点指向(1, 1)的线段
\draw (0, 0) -- (1, 1);
%% 画一个以(1, 1)为圆心,半径为2的圆。
\draw (1, 1) circle (2);
%% 画一段以原点为圆心,半径为1,张开角度为30度的圆弧。
\draw (1, 0) arc (0:30:1);
\end{tikzpicture}
\end{document}

再使用xelatex将其编译成PDF文件(xelatex可以通过安装TeXLive 2020获得)

1
xelatex three_in_one.tex

此时便得到了three_in_one.pdf文件。为了可以在文章中显示,我用ImageMagick将其转换为PNG文件

1
convert three_in_one.pdf /tmp/three_in_one.png

最终的图片如下

简单,就像画一匹马一样简单。

现在该来试试用TikZ复刻维基百科上的硬盘示意图了。

来点同心圆

在原图中最引人注目的,当属那十几个同心圆了。简单起见,我只画六个圆。这六个圆的半径相差1ptpt是TikZ默认的长度单位),从3pt一直递增到8pt,它们的圆心都在坐标原点(0, 0)上。

1
2
3
4
5
6
7
8
9
%% 为了节省篇幅,只给出TikZ部分的代码。
\begin{tikzpicture}
\draw (0, 0) circle (3);
\draw (0, 0) circle (4);
\draw (0, 0) circle (5);
\draw (0, 0) circle (6);
\draw (0, 0) circle (7);
\draw (0, 0) circle (8);
\end{tikzpicture}

来点等分线

原图中有12根线段,将每一个圆等分成了全等的12份。从前一节的内容可知,要用\draw命令绘制线段,需要的是线段两端的坐标,那么这批坐标要怎么计算呢?尽管可以用三角函数计算出这些点的笛卡尔坐标,但在TikZ中可以用更方便的极坐标来指定这些点。

以原图中从X轴开始逆时针旋转遇到的第一条线段为例,它在半径为3pt的圆上的点的坐标为(30:3)(30是极坐标中的角度,3是半径长度),而在半径为8pt的圆上的点的坐标为(30:8),因此可以用\draw (30:3) -- (30:8)来画出这根线段。

通过调整其中的角度可以画出剩余的其它线段。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
\begin{tikzpicture}
\draw (0, 0) circle (3);
\draw (0, 0) circle (4);
\draw (0, 0) circle (5);
\draw (0, 0) circle (6);
\draw (0, 0) circle (7);
\draw (0, 0) circle (8);

\draw (0:3) -- (0:8);
\draw (30:3) -- (30:8);
\draw (60:3) -- (60:8);
\draw (90:3) -- (90:8);
\draw (120:3) -- (120:8);
\draw (150:3) -- (150:8);
\draw (180:3) -- (180:8);
\draw (210:3) -- (210:8);
\draw (240:3) -- (240:8);
\draw (270:3) -- (270:8);
\draw (300:3) -- (300:8);
\draw (330:3) -- (330:8);
\end{tikzpicture}

来张色图

原图大致的骨架已经画完了,现在来尝试给它上色。在TikZ中,可以用\fill命令给一段封闭的曲线上色。比如用\fill[red] (0, 0) -- (1, 0) -- (1, 1) -- (0, 1) -- cycle可以将左下角在原点、边长为1pt的正方形涂成红色。

先给原图中的区域B上色。区域B是一个扇形,它由两根长度为8pt的半径和一段夹角为30度的圆弧构成。要描述这段封闭曲线,可以借助入门一节中介绍的arc命令。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
\begin{tikzpicture}
%% 给区域B上色。
\fill[blue] (0, 0) -- (30:8) arc (30:60:8) -- cycle;

\draw (0, 0) circle (3);
\draw (0, 0) circle (4);
\draw (0, 0) circle (5);
\draw (0, 0) circle (6);
\draw (0, 0) circle (7);
\draw (0, 0) circle (8);

\draw (0:3) -- (0:8);
\draw (30:3) -- (30:8);
\draw (60:3) -- (60:8);
\draw (90:3) -- (90:8);
\draw (120:3) -- (120:8);
\draw (150:3) -- (150:8);
\draw (180:3) -- (180:8);
\draw (210:3) -- (210:8);
\draw (240:3) -- (240:8);
\draw (270:3) -- (270:8);
\draw (300:3) -- (300:8);
\draw (330:3) -- (330:8);
\end{tikzpicture}

\fill命令那一行最后的cycle的意思,是让曲线回到起点组成一个封闭的形状。另外,\fill命令需要写在\draw命令之前,是为了避免蓝色颜料将区域内的圆弧给盖住了。

对于区域C和区域D,方法是一样的,只是描述封闭曲线的坐标不同罢了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
\begin{tikzpicture}
%% 给区域B上色。
\fill[blue] (0, 0) -- (30:8) arc (30:60:8) -- cycle;
%% 给区域C上色。
\fill[purple] (30:4) -- (30:5) arc (30:60:5) -- (60:4) -- (60:4) arc (60:30:4);
%% 给区域D上色。
\fill[green] (240:6) -- (240:7) arc (240:330:7) -- (330:6) -- (330:6) arc (330:240:6);

\draw (0, 0) circle (3);
\draw (0, 0) circle (4);
\draw (0, 0) circle (5);
\draw (0, 0) circle (6);
\draw (0, 0) circle (7);
\draw (0, 0) circle (8);

\draw (0:3) -- (0:8);
\draw (30:3) -- (30:8);
\draw (60:3) -- (60:8);
\draw (90:3) -- (90:8);
\draw (120:3) -- (120:8);
\draw (150:3) -- (150:8);
\draw (180:3) -- (180:8);
\draw (210:3) -- (210:8);
\draw (240:3) -- (240:8);
\draw (270:3) -- (270:8);
\draw (300:3) -- (300:8);
\draw (330:3) -- (330:8);
\end{tikzpicture}

给环形上色

聪明的读者也许已经发现了,区域A的环形没办法用这种方式来描述。不过没关系,只要将其视为上下半两部分,再分别上色即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
\begin{tikzpicture}
%% 环的上半部分
\fill[red] (4, 0) -- (5, 0) arc (0:180:5) -- (-4, 0) -- (-4, 0) arc (180:0:4);
%% 环的下半部分
\fill[red] (4, 0) -- (5, 0) arc (360:180:5) -- (-4, 0) -- (-4, 0) arc (180:360:4);
%% 给区域B上色。
\fill[blue] (0, 0) -- (30:8) arc (30:60:8) -- cycle;
%% 给区域C上色。
\fill[purple] (30:4) -- (30:5) arc (30:60:5) -- (60:4) -- (60:4) arc (60:30:4);
%% 给区域D上色。
\fill[green] (240:6) -- (240:7) arc (240:330:7) -- (330:6) -- (330:6) arc (330:240:6);

\draw (0, 0) circle (3);
\draw (0, 0) circle (4);
\draw (0, 0) circle (5);
\draw (0, 0) circle (6);
\draw (0, 0) circle (7);
\draw (0, 0) circle (8);

\draw (0:3) -- (0:8);
\draw (30:3) -- (30:8);
\draw (60:3) -- (60:8);
\draw (90:3) -- (90:8);
\draw (120:3) -- (120:8);
\draw (150:3) -- (150:8);
\draw (180:3) -- (180:8);
\draw (210:3) -- (210:8);
\draw (240:3) -- (240:8);
\draw (270:3) -- (270:8);
\draw (300:3) -- (300:8);
\draw (330:3) -- (330:8);
\end{tikzpicture}

润色一下

用macOS的“数码测色计”看了一下原图中各个区域的颜色的RGB值,区域A大概是(236, 133, 130)、区域B大概是(122, 127, 237)、区域C大概是(131, 132, 139)、区域D大概是(0, 151, 27)。接下来我让TikZ以这四种指定的颜色填充图中的四个区域,先用LaTeX的\definecolor命令定义四个新的颜色的名字。

1
2
3
4
5
%% 下列四行代码置于document环境之前
\definecolor{areaA}{RGB}{236,133,130}
\definecolor{areaB}{RGB}{122,127,237}
\definecolor{areaC}{RGB}{131,32,139}
\definecolor{areaD}{RGB}{0,151,27}

再替换掉\fill命令中的颜色名即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
\begin{tikzpicture}
%% 环的上半部分
\fill[areaA] (4, 0) -- (5, 0) arc (0:180:5) -- (-4, 0) -- (-4, 0) arc (180:0:4);
%% 环的下半部分
\fill[areaA] (4, 0) -- (5, 0) arc (360:180:5) -- (-4, 0) -- (-4, 0) arc (180:360:4);
%% 给区域B上色。
\fill[areaB] (0, 0) -- (30:8) arc (30:60:8) -- cycle;
%% 给区域C上色。
\fill[areaC] (30:4) -- (30:5) arc (30:60:5) -- (60:4) -- (60:4) arc (60:30:4);
%% 给区域D上色。
\fill[areaD] (240:6) -- (240:7) arc (240:330:7) -- (330:6) -- (330:6) arc (330:240:6);

\draw (0, 0) circle (3);
\draw (0, 0) circle (4);
\draw (0, 0) circle (5);
\draw (0, 0) circle (6);
\draw (0, 0) circle (7);
\draw (0, 0) circle (8);

\draw (0:3) -- (0:8);
\draw (30:3) -- (30:8);
\draw (60:3) -- (60:8);
\draw (90:3) -- (90:8);
\draw (120:3) -- (120:8);
\draw (150:3) -- (150:8);
\draw (180:3) -- (180:8);
\draw (210:3) -- (210:8);
\draw (240:3) -- (240:8);
\draw (270:3) -- (270:8);
\draw (300:3) -- (300:8);
\draw (330:3) -- (330:8);
\end{tikzpicture}

图文并茂

剩下的需要复刻的东西就是原图中的文字以及标注用的线了。线很容易画,只要规定了坐标后用\draw命令即可。比如说,我可以把四条线定义如下,其中的坐标和线段的长度纯粹是个人偏好

1
2
3
4
\draw (75:4.5) -- (75:9);
\draw (40:7.5) -- (40:9);
\draw (50:4.5) -- (50:9);
\draw (285:6.5) -- (285:9);

线画完了,再到每一根线的“终点”标上文字说明,这需要用到TikZ的node功能。用法很简单,就是在需要标注文字的坐标后,紧跟着关键字node,以及一段用花括号包裹的文本即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
\documentclass{standalone}
\usepackage{tikz}
\usepackage{xeCJK}
\setCJKmainfont{Songti TC}
\usetikzlibrary{shapes.geometric, arrows}
\definecolor{areaA}{RGB}{236,133,130}
\definecolor{areaB}{RGB}{122,127,237}
\definecolor{areaC}{RGB}{131,32,139}
\definecolor{areaD}{RGB}{0,151,27}
\begin{document}
\begin{tikzpicture}
%% 环的上半部分
\fill[areaA] (4, 0) -- (5, 0) arc (0:180:5) -- (-4, 0) -- (-4, 0) arc (180:0:4);
%% 环的下半部分
\fill[areaA] (4, 0) -- (5, 0) arc (360:180:5) -- (-4, 0) -- (-4, 0) arc (180:360:4);
%% 给区域B上色。
\fill[areaB] (0, 0) -- (30:8) arc (30:60:8) -- cycle;
%% 给区域C上色。
\fill[areaC] (30:4) -- (30:5) arc (30:60:5) -- (60:4) -- (60:4) arc (60:30:4);
%% 给区域D上色。
\fill[areaD] (240:6) -- (240:7) arc (240:330:7) -- (330:6) -- (330:6) arc (330:240:6);

\draw (0, 0) circle (3);
\draw (0, 0) circle (4);
\draw (0, 0) circle (5);
\draw (0, 0) circle (6);
\draw (0, 0) circle (7);
\draw (0, 0) circle (8);

\draw (0:3) -- (0:8);
\draw (30:3) -- (30:8);
\draw (60:3) -- (60:8);
\draw (90:3) -- (90:8);
\draw (120:3) -- (120:8);
\draw (150:3) -- (150:8);
\draw (180:3) -- (180:8);
\draw (210:3) -- (210:8);
\draw (240:3) -- (240:8);
\draw (270:3) -- (270:8);
\draw (300:3) -- (300:8);
\draw (330:3) -- (330:8);

\draw (75:4.5) -- (75:9) node {磁道};
\draw (40:7.5) -- (40:9) node {扇面};
\draw (50:4.5) -- (50:9) node {扇区};
\draw (285:6.5) -- (285:9) node {簇};
\end{tikzpicture}
\end{document}

需要留意的是,我在源代码开头的位置,引入了xeCJK宏包(\usepackage{xeCJK}),并且指定了中文内容用的字体为宋体(\setCJKmainfont{Songti TC}),这样才能成功编译。

至此,复刻算是完成了。

后记

本文只是管中窥豹,TikZ还可以画出其它更复杂更美轮美奂的图形,有兴趣的读者可以移步这里观赏。此外,TikZ也可以“编程”,比如下面的两行代码便足矣画出上文中12行代码才完成的等分线

1
2
\foreach \x in {0,30,60,90,120,150,180,210,240,270,300,330}
\draw (\x:3) -- (\x:8);

TikZ的更多潜力和乐趣,就由各位读者自己探索吧。

用Emacs的时候,我习惯将它分成“四个部分”

怎么弄的呢?一般是先按C-x 3分出左右两个window,再到各个window中用C-x 2分出上下两个window——这不是我的笔误,在Emacs的术语中,用来显示一个buffer的区域就叫做一个window。而常常被人们冠名为window的、最外层的窗体,则叫做frame

这样划分后,多次按下C-Tab(我把这个快捷键绑定到了命令other-window上),便可以按照左上、左下、右上、右下的顺序轮换当前聚焦的window了。

如果需要从其它window中复制内容到当前window中粘贴,操作会麻烦一点。以右上角需要左下角的内容为例:

  1. 按三次C-Tab换到左下角的window中——用快捷键是因为我不想去挪鼠标;
  2. kj上下移动光标到目标行——用kj是因为用了evil-mode插件(参见这篇文章);
  3. 复制内容,再按一次C-Tab回到原来的window中粘贴。

听起来可麻烦了。

好在Emacs有一个非常好用的插件,可以把第1和第2个步骤合在一起完成。

avy

这个非常好用的插件就是avy,它提供的avy-goto-line函数可以一步到位地完成上面的第1和第2个步骤。

用Emacs的包管理器就可以安装它

1
M-x package-install RET avy RET

接着要为命令avy-goto-line绑定一个喜欢的快捷键

1
(global-set-key (kbd "M-g f") 'avy-goto-line)

至此便可以在Emacs中愉快地使用M-g f来快速跳转到当前或其它window中的行了。百闻不如一见,我来演示一番。

avy-goto-line

众所周知,我用org-mode来跟踪自己的学习计划,还会将摸索过程中的一些半成品代码保存到org-mode的条目中。例如,我想要将左下角的window中的三个函数的定义,复制到右上角的代码块中去

于是我先按下M-g f,让avy为每一行赋予一个标记

因为希望切换到左下角的window的第一行,所以我先按下j

此时,在前一幅截图中不以字母j开始的标记统统消失了,而以字母j开始的标记则只留下了从第二个字符开始的部分。

再按下字母l,就可以将焦点切换到左下角的window,并且将光标移动到第一行的行首了。然后只需要选中内容、复制,并返回原来的window中粘贴即可。完整的过程如下

后记

如果在按下组合键M-g f后,接着按下的是数字键的话,avy-goto-line会认为使用者打算跳转到指定的行。它将在Emacs的minibuffer中继续等待输入更多的数字或按下回车。不过我不怎么用这个功能,因为我没有让Emacs显示行号,按行号来跳转对我并不方便。