-
Notifications
You must be signed in to change notification settings - Fork 234
EAF架构设计
EAF是Emacs Application Framework的缩写,EAF是新一代的Emacs应用框架,通过EAF框架,你可以使用Qt来任意扩展Emacs的多媒体能力。
目前EAF已经开发了如下应用:
- 全键盘操作的黑客浏览器
- Markdown和Org-Mode实时预览器 (支持Mermaid、PUML和GNUPlot语法)
- 基于Viewer.js开发的图片浏览器
- 视频播放器,包括Qt和plyr.js两个版本
- Emacs中性能最好的PDF阅读器
- 摄像头应用
- 移动设备文件共享插件
- 基于Xterm.js的终端模拟器,你可以在里面运行vi,哈哈哈哈
- Aria2图形下载客户端
- 全键盘操作的思维导图软件, 支持Org-Mode和Markdown文件导出
- 基于浏览器和Org-Mode的笔记管理应用
- 基于Vue.js的多媒体应用开发框架
通过EAF框架,顶级黑客可以全键盘做所有事情,你不再需要平铺式窗口管理器和外部应用就可以用统一的键盘快捷键来操作所有应用。
Emacs距今已经有45年的发展历史,在这45年内,全世界最顶级的黑客在贡献自己的智慧和想象力,一起构建了Emacs这个伟大的开发者工具生态。 当你是一个会十几门编程语言的黑客和键盘流信仰者,Emacs绝对是你的不二之选。
Emacs的劣势也是因为它太古老了,导致在多线程和图形扩展能力已经无法跟上时代的步伐,在很多地方发展落后于IDEA和VSCode。
EAF的愿景是在保留Emacs古老的黑客文化和庞大的开发者插件生态前提下,通过EAF框架扩展Emacs的多线程和图形渲染能力,实现Live In Emacs的理想。
用最通俗的话来讲, EAF其实做的工作和手机贴膜差不多, 就是先把 PyQt5 的图形程序运行起来, 然后通过QWindow::setParent的技术把PyQt5的窗口粘贴到Emacs窗口对应的位置。
EAF整体架构的关键技术有以下几点:
- 通过 QtGraphicsView/QtGraphicsScene 来实现实时图形混合, 多个窗口可以共用一个进程的绘制内容, 这样就可以适应 Emacs 的 Window/Buffer 窗口模型设计, 从而最终让 PyQt5 窗口可以像 Emacs Buffer 那样集成和管理
- 通过QWindow::setParent技术, 粘贴 PyQt5 的窗口到 Emacs EAF Buffer 的坐标上, 实现多个进程的窗口看起来是Emacs程序里面的不同部分, 不清楚QWindow::setParent技术的同学, 可以想象一下 Google Chrome 的多进程架构设计. QWindow::setParent的技术除了达到跨进程粘贴的作用外, 还变相的实现了多进程沙箱的设计, EAF图形化的程序运行在单独的进程中, 即使出现了意外崩溃的情况, 也不会影响Emacs本身的稳定性
- 在Emacs端实现了一个事件监听循环, 当用户在 EAF Buffer按下任何按键, 都会通过 EPC 发送事件消息给 Python 进程, Python 进程再伪造相应的事件来模拟Emacs端用户的键盘输入
目前为止,EAF具备的协同开发能力有:
- Elisp <-> EPC <-> Python 之间互相调用,通过PyQt5来利用Python和Qt5的生态软件包
- Elisp <-> EAF Browser <-> JavaScript 之间互相调用,通过浏览器来利用NodeJS生态软件包
- Elisp <-> EAF Browser <-> Vue.js 之间互相调用,通过浏览器来利用Vue.js生态软件包
EAF现在可以联合Python和JavaScript两种编程语言进行协同编程,同时可以通过Qt5、NodeJS、Vue.js三种方式来扩展Emacs的多媒体能力。
目录 | 目录说明 |
---|---|
app | EAF应用目录,每个应用都保持 app/app_name/buffer.py 的目录结构,每个应用的入口文件都是 buffer.py |
core | EAF的核心模块 |
core/buffer.py | EAF应用的抽象接口文件,包括应用的IPC调用、事件转发和消息处理都通过这个接口文件来定义,如果你要扩展EAF核心功能,请仔细研究这个文件 |
core/view.py | EAF应用的显示接口文件,包括应用的跨进程粘贴、显示和缩放都通过这个接口文件来定义,在大多数情况下,EAF黑客都可以不用研究这个文件 |
core/webengine.py | EAF的浏览器模块,所有浏览器的核心代码都在这个文件中,方便你通过浏览器模块结合JavaScript库快速开发应用,Python和JavaScript相互调用的方法可以重点研究这个文件 |
core/utils.py | 一些常用的工具库,比如创建文件、获取新端口等,和应用无关的工具库代码可以放到这里,用于减少基础函数的重复代码 |
core/pyaria2.py | aria2 库文件,主要作用是让浏览器可以直接调用这个库发送RPC下载命令给 aria2 daemon |
docker | Dockerfile, 主要用于构建EAF的Docker镜像,除非你用Docker来运行EAF,请直接忽视这个文件 |
screenshot | EAF各个应用截图效果 |
eaf.el | EAF Elisp进程部分,主要定义EAF应用的快捷键、监听Emacs窗口变化和事件,并通过EPC IPC发送消息给EAF的Python进程 |
eaf-org.el | EAF针对Org-Mode的扩展 |
eaf-evil.el | EAF针对Evil的扩展 |
eaf-all-the-icons.el | EAF图标显示扩展 |
eaf.py | EAF Python进程部分,用于启动EAF进程和接收Emacs消息,如果你要增加Emacs和EAF之间的IPC通讯接口,需要重点研究这个文件 |
LICENSE | GPLv3许可证文件 |
README.md | 英文README |
README.zh-CN.md | 中文README |
setup.py | python项目设置和工程文件,只是方便识别工程位置,没什么用,请直接忽视它 |
install-eaf.* | 系统安装脚本 |
在EAF项目中有一个最简单的示例应用 app/demo/buffer.py
from PyQt5.QtGui import QColor
from PyQt5.QtWidgets import QPushButton
from core.buffer import Buffer
class AppBuffer(Buffer):
def __init__(self, buffer_id, url, arguments):
Buffer.__init__(self, buffer_id, url, arguments, True)
self.add_widget(QPushButton("Hello, EAF hacker, it's working!!!"))
self.buffer_widget.setStyleSheet("font-size: 100px")
你可以通过 M-x eaf-open-demo
命令来打开这个示例应用,示例应用是在Emacs中打开一个Qt窗口,Qt窗口只有一个可以点击的按钮,简单吧?
Demo |
---|
当你开发一个新的EAF应用时,你只需要下面的操作即可完成:
- 在app目录下新建一个应用的子目录,拷贝
app/demo/buffer.py
到应用子目录下面; - 新建一个Qt控件类,然后通过
self.add_widget
接口替换上面的对应代码,用来添加新的控件 - 基于
eaf-open
接口创建一个打开EAF应用的Elisp函数,具体可以参考 eaf.el 文件中的eaf-open-demo
代码实现
from PyQt5.QtGui import QColor
from PyQt5.QtWidgets import QPushButton
from core.buffer import Buffer
class AppBuffer(Buffer):
def __init__(self, buffer_id, url, arguments):
Buffer.__init__(self, buffer_id, url, arguments, True)
self.add_widget(QPushButton("Hello, EAF hacker, it's working!!!"))
self.buffer_widget.setStyleSheet("font-size: 100px")
AppBuffer
就是开发EAF应用所需要的入口类,它继承于Buffer
类,Buffer
类定义于 core/buffer.py
中。
-
buffer_id
: 是Emacs传递过来的唯一ID,用于辨别每个EAF应用对象 -
url
: EAF应用的路径,可以是网页地址,也可以是PDF文件路径等,具体的形式取决于EAF应用的用途 -
arguments
: EAF应用除了url外,从Emacs端传递的额外自定义数据, 比如EAF Browser应用,可以传递temp_html_file
参数表示渲染HTML邮件后删除临时HTML文件
如果你想发送消息给Emacs,告诉用户状态,可以在 AppBuffer
类下调用下面的接口即可发送消息给Emacs:
from core.utils import message_to_emacs
message_to_emacs("hello from eaf")
你可以通过下面的接口,直接在Python进程中设置Emacs的任意变量:
from core.utils import set_emacs_var
set_emacs_var("name", "value", "eaf-specific")
你可以通过下面的接口,直接在Python进程中获取Emacs的任意变量:
from core.utils import get_emacs_var
get_emacs_var("var-name")
如果你想在Python端执行Elisp代码,可以用下面的方式来实现, 需要注意字符串写法,用Python的 '''
来包裹,避免Elisp代码中有字符串符号:
from core.utils import eval_in_emacs
eval_in_emacs('''(message "hello")''')
在Emacs调用Python会稍微复杂点,你首先需要在AppBuffer
定义Python函数,比如:
def get_foo(self):
return "Python Result"
然后在Elisp端,通过 eaf-call-sync
和execute_function
接口来实现对Python方法的调用:
(eaf-call-sync "execute_function" eaf--buffer-id "get_foo")
eaf--buffer-id
是EAF Buffer的局部变量,你按照上面的格式写代码,EAF框架会自动找到EAF应用对应的 get_foo
方法,并返回Python函数执行结果到Emacs进程。
如果需要在调用Python函数时传递参数,可以用 execute_function_with_args
接口来替代:
(eaf-call-sync "execute_function_with_args" eaf--buffer-id "function_name" "function_args")
如果Elisp端只是执行Python方法,但是并不想要获取结果,可以用 eaf-call-async
替换 eaf-call-sync
如果需要绑定Emacs按键到Python函数,需要在Python函数定义上一行加入装饰器 @interactive
(由utils.py
提供), 如果是浏览器基础的应用,可以在Python函数定义上一行加入 @interactive(insert_or_do=True)
, 这样Python端就自动会为函数 foo
生成 insert_or_foo
函数,方便绑定到单按键上,保障在浏览器输入框的时候输入字符,输入框以外执行命令。
从Python端读取Emacs的输入,可以先看下面的示例代码:
...
class AppBuffer(Buffer):
def __init__(self, buffer_id, url, arguments):
Buffer.__init__(self, buffer_id, url, arguments, False)
self.add_widget(PdfViewerWidget(url, QColor(0, 0, 0, 255)))
self.buffer_widget.send_jump_page_message.connect(self.send_jump_page_message)
def send_jump_page_message(self):
self.send_input_message("Jump to Page: ", "jump_page")
def handle_input_response(self, callback_tag, result_content):
if callback_tag == "jump_page":
self.buffer_widget.jump_to_page(int(result_content))
def cancel_input_response(self, callback_tag):
if callback_tag == "jump_page":
...
...
- 首先在Python端,调用
self.send_input_message
接口发送用户输入提示字符,消息ID(例子里是jump_page
,用于区分从Emacs返回的input所对应的函数),输入类型(可选"string"
/"file"
/"yes-or-no"
,默认是"string"
),初始输入内容(可选)。 - 用户通过Emacs Minibuffer输入数据后,会通过
handle_input_response
接口返回输入结果给Python回调函数,根据self.send_input_message
时选择的消息ID来判断具体函数。 - 如果用户取消了输入,你依然可以通过
cancel_input_response
接口,并根据输入消息类型做一些清理工作
如果你的EAF应用基于浏览器和JavaScript库来开发,建议你先看一下 app/mindmap/buffer.py
的代码,你可以通过 eval_js
接口来实现Python直接调用浏览器中的JavaScript代码:
self.buffer_widget.eval_js("js_function(js_argument)")
JavaScript函数定义在每个app的index.html
文件中。
获取JavaScript函数返回的结果和 eval_js
接口类似,只不过接口换成 execute_js
的形式,下面是从浏览器中获取选中文本的示例:
text = self.buffer_widget.execute_js("get_selection();")
- 首先,先在Vue组件的methods字段里定义函数,比如为
foo
。 - 其次,在Vue组件的 mounted() 函数中,添加
window.foo = this.foo
的代码 - 最后,直接在Python端使用
self.buffer_widget.execute_js("foo()")
的方式调用Vue组件的foo
方法
首先,在Vue组件的 created() 函数中写下如下代码,表示绑定Python端的QWebChannel对象 pyobject
到JavaScript端的 window.pyobject
created() {
// eslint-disable-next-line no-undef
new QWebChannel(qt.webChannelTransport, channel => {
window.pyobject = channel.objects.pyobject;
});
}
其次,在Python端定义函数:
from PyQt5 import QtCore
@QtCore.pyqtSlot(str)
def open_in_dired(self, path):
eval_in_emacs('dired', [path])
最后,直接在Vue组件任意地方调用 window.pyobject.open_in_dired(path)
即可
我们在编写Vue.js应用的时候,很多时候数据都保存在Vue.js组件中,如果一个Python函数需要访问这些数据才能工作的话,很有可能面临 Python -> Vue.js -> Python 的函数调用循环,为了减少绕圈圈的麻烦,我们可以在 Vue.js 组件中使用 watch 方法把JS的一些数据实时同步到Python属性中,在Python需要访问JS数据时就不用绕圈圈了。
举例:
watch: {
currentIndex: {
// eslint-disable-next-line no-unused-vars
handler: function(val, oldVal) {
window.pyobject.vue_update_current_index(val);
}
},
},
上面的代码意思是Vue组件会在 currentIndex 更新后,调用Python端的 vue_update_current_index 函数来同步 currentIndex 这个值。
@QtCore.pyqtSlot(int)
def vue_update_current_index(self, inex):
self.vue_current_index = inex
Python端把最新的值保存在属性内,下次Python任意函数就可以直接调用 self.vue_current_index 这个值就行了,因为 vue.js 的 watch 方法会保证Python端的数据和Vue组件最新状态保持同步。
改变当前EAF的buffer标题很简单,直接调用接口 self.change_title("new_title")
即可。
保存数据直接调用 save_session_data
接口,恢复数据接口用 restore_session_data
,下面是视频播放器保存和恢复视频观看位置的示例代码,存储方式就是任意字符串, 你可以根据字符串保存任意结构的数据,比如JSON数据。
def save_session_data(self):
return str(self.buffer_widget.media_player.position())
def restore_session_data(self, session_data):
position = int(session_data)
self.buffer_widget.media_player.setPosition(position)
一些应用,比如浏览器点击视频播放器全屏按钮需要切换到全屏的状态,直接调用下面三个接口即可实现全屏的控制:
-
toggle_fullscreen
: 切换全屏 -
enable_fullscreen
: 开启全屏 -
disable_fullscreen
: 退出全屏
请注意,如果当前buffer数量大于1的时候,这不会变成一般熟知的全屏。
有时候,我们需要直接从Emacs端发送按键给EAF Python进程,比如我想在浏览器中按 Win-m
实现回车的操作,可以先定义一个Elisp函数:
(defun eaf-send-return-key ()
"Directly send return key to EAF Python side."
(interactive)
(eaf-call-sync "send_key" eaf--buffer-id "RET"))
然后在Emacs中通过调用eaf-bind-key
或者定义 eaf-*-keybinding
变量来实现 Win-m
快捷键发送回车事件, 具体代码可以查看 eaf.el
文件。
如果你的EAF应用有后台进程,你可以通过 destroy_buffer
接口在EAF应用 Buffer 被关闭之前做一些清理工作,下面是终端浏览器在退出之前杀掉 NodeJS 后台进程的示例代码:
def destroy_buffer(self):
os.kill(self.background_process.pid, signal.SIGKILL)
if self.buffer_widget is not None:
# NOTE: We need delete QWebEnginePage manual, otherwise QtWebEngineProcess won't quit.
self.buffer_widget.web_page.deleteLater()
self.buffer_widget.deleteLater()
下面分享有一些调试方法,方便你快速开发EAF应用:
如果你在EAF Python端添加了 print
代码,或者当Python进程出错误了,你可以在Emacs中切换到 *eaf*
buffer来观察运行时Python的输出结果。
调用EAF应用后,EAF会在后台启动一个Python进程,如果你更新了EAF Python代码,请先调用 eaf-kill-process
命令,这个命令会杀掉老版本的Python后台进程,然后你再重新调用EAF函数即可测试新版EAF Python代码。你也可以直接调用eaf-restart-process
,省时省力。
这个方法你掌握后,你不需要每次更新Python代码都要重启Emacs,极大提高了EAF的开发效率。
如果你在开发中遇到了段错误,可以按照下面的方式调试段错误:
- 先在Emacs 中执行
(setq eaf-enable-debug t)
命令打开段错误调试选项 - 然后调用
eaf-stop-process
命令杀掉EAF Python老版后台进程 - 继续调试,当发生段错误时, EAF会自动在
*eaf*
Buffer中打印段错误的堆栈信息
EAF是一个多语言的高难度项目,你需要同时具备Elisp、Python、Qt甚至JavaScript开发技能才能任意Hacking EAF,下面是一些我收集的学习资料帮助你快速学习入门:
- Elisp材料:http://ergoemacs.org/emacs/elisp.html
- Python材料:《Python核心编程》这本书足够你精通Python语言
- PyQt5材料:http://zetcode.com/gui/pyqt5/ 这是我找到最简单的Qt入门教程
- JavaScript材料: Google和Github是你学习JavaScript最好的导师
- EAF: 建议先用熟练EAF后,直接阅读源代码来学习,最好的方法就是看app目录下的现有应用源码依葫芦画瓢 ;)