280行Python代码打造一个带语法高亮的IDE
作者:winfredzhang
本文以 PyEditor v2(py_editor.py)的完整源码为蓝本,逐层拆解一个生产级桌面 IDE 的构建逻辑。全文约 6000 字,涵盖 wxPython 控件体系、Scintilla 语法高亮原理、多线程 I/O 流读取、跨平台差异处理、JSON 配置持久化、以及进程生命周期管理六大主题。无论你是 wxPython 初学者,还是希望在自己的项目中嵌入代码编辑能力的开发者,本文都能为你提供可直接复用的设计模式。

一、总体架构:三层结构,职责分明
在深入单个类之前,先从鸟瞰视角理解整个程序的组织方式。
py_editor.py
├── PythonEditor(stc.StyledTextCtrl 子类)
│ └── 负责:语法高亮、行号、代码折叠、缩进指示
├── OutputPanel(wx.Panel 子类)
│ └── 负责:彩色富文本输出、只读控制台
└── MainFrame(wx.Frame 子类)
├── _build_ui() UI 布局与控件装配
├── _save/load_settings() JSON 配置持久化
├── _refresh_proj_list() 目录遍历与项目发现
├── _load_file() 文件读取与编辑器同步
├── _on_save/run/stop() 核心业务逻辑
└── _on_close() 退出守卫
这种分层方式有三个好处:视图与逻辑解耦(编辑器不知道运行逻辑)、状态集中管理(_cur_path、_process、_proj_map 全在 MainFrame)、子组件可复用(OutputPanel 可以直接移植到其他项目)。
二、语法高亮核心:PythonEditor与 Scintilla
2.1 为什么用wx.stc.StyledTextCtrl
wxPython 提供两种多行文本控件:
| 控件 | 优点 | 缺点 |
|---|---|---|
wx.TextCtrl | API 简单 | 无语法高亮,性能差 |
wx.stc.StyledTextCtrl | Scintilla 引擎,专业级 | API 较复杂 |
StyledTextCtrl(STC)是 Scintilla 文本编辑组件的 wxPython 封装。Scintilla 被 Notepad++、Code::Blocks、Geany 等编辑器广泛使用,拥有词法分析、代码折叠、自动补全等专业特性。
2.2 词法分析器的绑定
self.SetLexer(stc.STC_LEX_PYTHON)
这一行将内置的 Python 词法分析器绑定到控件上。Scintilla 内置了数十种语言的词法分析器(C、Java、HTML、SQL 等),通过 SetLexer 一行即可切换。绑定后,Scintilla 会在每次文本变更时自动进行词法分析,将文档拆分为不同类型的 Token。
2.3 样式系统:Token → 颜色
Scintilla 的样式系统基于"样式编号"(Style Number)。每个 Token 类型都有一个预定义的整数编号:
tok = {
stc.STC_P_DEFAULT: "#CDD6F4", # 普通文本
stc.STC_P_COMMENTLINE: "#6C7086", # 单行注释 #...
stc.STC_P_NUMBER: "#FAB387", # 数字字面量
stc.STC_P_STRING: "#A6E3A1", # 字符串
stc.STC_P_WORD: "#CBA6F7", # 关键字(def, if, for...)
stc.STC_P_CLASSNAME: "#F9E2AF", # 类名
stc.STC_P_DEFNAME: "#89B4FA", # 函数名
stc.STC_P_OPERATOR: "#89DCEB", # 运算符
...
}
for t, c in tok.items():
self.StyleSetForeground(t, c)
self.StyleSetBackground(t, "#1E1E2E")
这里使用了 Catppuccin Mocha 配色方案——一套在 GitHub 上拥有数千星的精心设计的暗色主题。每种 Token 类型都有独立的前景色和背景色,实现精确的视觉区分。
关键细节: 必须先调用 StyleClearAll() 让所有样式继承 STC_STYLE_DEFAULT 的字体设置,再逐个覆盖颜色。否则自定义字体不会生效。
2.4 关键字分级
self.SetKeyWords(0,
"and as assert async await break class ...") # 语言关键字(紫色)
self.SetKeyWords(1,
"abs all any bin bool ... self cls ...") # 内建函数(青色)
Scintilla 支持最多 9 级关键字列表(SetKeyWords(0~8))。Python 词法分析器默认使用 0 和 1 两级,分别对应 STC_P_WORD 和 STC_P_WORD2。通过给两级关键字设置不同颜色,用户可以直观区分 for(控制流关键字)和 print(内建函数)。
2.5 行号与折叠边距
Scintilla 支持最多 5 个左侧"边距"(Margin),每个边距可独立配置类型和宽度:
# 边距 0:行号
self.SetMarginType(0, stc.STC_MARGIN_NUMBER)
self.SetMarginWidth(0, 50)
# 边距 1:折叠标记
self.SetMarginType(1, stc.STC_MARGIN_SYMBOL)
self.SetMarginMask(1, stc.STC_MASK_FOLDERS)
self.SetMarginSensitive(1, True) # 允许鼠标点击
self.SetProperty("fold", "1") # 启用折叠解析
SetMarginSensitive(True) 是实现点击折叠的关键——它让该边距响应 EVT_STC_MARGINCLICK 事件:
def _on_margin(self, e):
if e.GetMargin() == 1:
self.ToggleFold(self.LineFromPosition(e.GetPosition()))
点击行号旁的折叠图标,ToggleFold 会自动展开或折叠该块。Scintilla 根据 Python 的缩进规则自动识别可折叠的代码块。
2.6 辅助视觉特性
# 当前行高亮
self.SetCaretLineVisible(True)
self.SetCaretLineBackground("#313244")
# 缩进指示线(虚线)
self.SetIndentationGuides(stc.STC_IV_LOOKBOTH)
# 右侧 88 列参考线
self.SetEdgeMode(stc.STC_EDGE_LINE)
self.SetEdgeColumn(88)
这些都是专业编辑器的标配。88 列参考线遵循 Black 格式化工具的默认行长度(比 PEP 8 的 79 列更宽松,是现代 Python 项目的主流选择)。
三、输出面板:OutputPanel的彩色控制台实现
class OutputPanel(wx.Panel):
def append(self, text, color="#CDD6F4"):
self.txt.SetDefaultStyle(wx.TextAttr(wx.Colour(color)))
self.txt.AppendText(text)
wx.TextCtrl 配合 wx.TE_RICH2 样式标志支持富文本。SetDefaultStyle 在追加文字前设置当前默认样式,AppendText 使用该样式写入文本。这种方式实现了简洁的彩色输出:
- 白色
#CDD6F4:标准输出(stdout) - 红色
#F38BA8:错误输出(stderr) - 蓝色
#89B4FA:系统信息(文件路径、运行提示) - 灰色
#6C7086:工作目录等次要信息
wx.TE_READONLY 防止用户误修改输出内容,wx.BORDER_NONE 使控件与父面板无缝融合。
四、布局系统:Sizer 与 SplitterWindow 的嵌套
4.1 wxPython 的布局哲学
wxPython 使用"Sizer"(布局管理器)而不是绝对像素坐标来布局控件。这使得界面在不同 DPI、不同操作系统下都能正确缩放。本程序使用三层布局:
root_vbox(垂直 BoxSizer)
├── 工具栏 Panel(固定高度 54px)
├── 分割线 StaticLine
└── main_sp(水平 SplitterWindow)
├── left Panel(项目列表,210px 初始宽度)
└── right_sp(垂直 SplitterWindow)
├── 编辑器 Panel(600px 初始宽度)
└── 输出 Panel(余下空间)
4.2 SplitterWindow 的关键参数
main_sp = wx.SplitterWindow(root, style=wx.SP_LIVE_UPDATE | wx.SP_3DSASH) main_sp.SetMinimumPaneSize(80) main_sp.SplitVertically(left, right_sp, sashPosition=210)
SP_LIVE_UPDATE:拖动分隔条时实时重绘,而不是松手后才刷新SP_3DSASH:3D 风格分隔条(在 Windows 上效果明显)SetMinimumPaneSize(80):防止拖动时将某一面板缩小到消失sashPosition=210:初始分隔位置(像素)
4.3 工具栏的 Sizer 装配
工具栏是一个横向 BoxSizer,混合使用了固定控件和弹性间距:
tbs.Add(logo, 0, wx.ALIGN_CENTER_VERTICAL | wx.LEFT, 14) tbs.AddSpacer(20) # ... 添加各个控件 ...
wx.ALIGN_CENTER_VERTICAL 让所有控件在 54px 高的工具栏中垂直居中对齐,是工具栏布局的标准写法。
五、配置持久化:JSON 的读写策略
5.1 配置文件路径的确定
_APP_DIR = os.path.dirname(os.path.abspath(__file__))
_CFG_NAME = os.path.splitext(os.path.basename(__file__))[0]
_CFG_PATH = os.path.join(_APP_DIR, f"{_CFG_NAME}.json")
用 __file__ 获取程序自身路径,再用 os.path.splitext 去掉后缀,得到与程序同名的 JSON 文件路径(如 py_editor.json)。这种策略的优点是无需硬编码文件名——改名后配置文件自动跟着改名,不会产生孤立配置。
工程提示: 生产级应用通常应将配置写入用户目录(%APPDATA% 或 ~/.config/),避免程序目录因权限问题无法写入。对于单用户工具,放在程序目录更简单直接。
5.2 保存的内容
cfg = {
"folder": self.folder_ctrl.GetValue().strip(),
"name": self.name_ctrl.GetValue().strip(),
"cur_path": self._cur_path, # 上次打开的文件完整路径
"win_x": pos.x, "win_y": pos.y, # 窗口位置
"win_w": size.width, "win_h": size.height, # 窗口尺寸
}
保存的是用户状态,而不是程序内部状态。这遵循"配置应反映用户意图"的原则:下次启动时,窗口回到上次的位置和大小,自动打开上次编辑的文件。
5.3 加载时的防御性编程
def _load_settings(self):
if not os.path.isfile(_CFG_PATH):
return # 首次运行,无配置文件,静默跳过
try:
with open(_CFG_PATH, "r", encoding="utf-8") as f:
cfg = json.load(f)
# 每个字段都用 .get() 带默认值读取
folder = cfg.get("folder", "")
...
cur = cfg.get("cur_path", "")
if cur and os.path.isfile(cur): # 文件可能已被删除
self._load_file(cur, silent=True)
except Exception as e:
print(f"[加载设置失败] {e}") # 失败不崩溃,只打印
三层防御:
- 文件不存在时直接返回(首次运行)
- 每个字段用
.get(key, default)读取(兼容旧版配置) - 恢复文件前检查文件是否仍存在(文件可能被移动或删除)
六、项目发现:os.walk的递归目录遍历
6.1 设计决策:什么算一个"项目"?
程序约定:如果一个子目录中存在与目录同名的 .py 文件,则视其为项目。
例如:projects/hello/hello.py ✅,projects/utils/helper.py ❌(目录名与文件名不同)。
这个约定简单且自然——它与"保存"逻辑保持一致(保存时自动创建同名目录和文件)。
6.2os.walk的遍历逻辑
for dirpath, dirnames, filenames in os.walk(folder):
dirnames.sort() # 原地排序,确保遍历顺序稳定
for fname in sorted(filenames):
if not fname.endswith(".py"):
continue
stem = fname[:-3] # 去掉 .py
if os.path.basename(dirpath) == stem: # 目录名 == 文件主名
full = os.path.join(dirpath, fname)
rel = os.path.relpath(dirpath, folder)
display = rel.replace(os.sep, " / ") # 统一路径分隔符为 " / "
self._proj_map[display] = full
self.proj_list.Append(display)
os.walk 默认是深度优先遍历。dirnames.sort() 让同级目录按字母顺序遍历(os.walk 修改 dirnames 会影响遍历顺序)。os.path.relpath 将绝对路径转为相对路径,让列表显示更简洁:不显示 /Users/alice/projects/hello,而是显示 hello。
_proj_map 是一个字典,将列表中的显示名映射到实际文件路径。这种"显示名 → 数据"的映射模式在 UI 编程中极为常见,避免了在列表控件中存储原始数据。
6.3 自动触发刷新
self.folder_ctrl.Bind(wx.EVT_TEXT, self._on_folder_text)
def _on_folder_text(self, event):
folder = self.folder_ctrl.GetValue().strip()
if folder and os.path.isdir(folder):
self._refresh_proj_list(folder)
监听文件夹输入框的文字变化事件:用户手动输入路径时(不仅是通过对话框选择),只要路径合法即自动刷新列表。这提升了键盘用户的体验。
七、进程管理:子进程的启动、监控与终止
这是整个程序最复杂的部分,涉及多线程、跨平台 I/O、进程生命周期管理三个交叉问题。
7.1 为什么必须用线程?
wxPython(以及几乎所有 GUI 框架)要求所有 UI 操作必须在主线程执行。如果在主线程中阻塞等待子进程输出,整个 UI 将冻结(无法移动窗口、按钮无响应)。解决方案是在子线程中运行进程,通过 wx.CallAfter 将 UI 更新调度回主线程:
threading.Thread(target=_run, daemon=True).start() # 子线程内部: wx.CallAfter(self.output.append, line, color) # 安全地从子线程更新 UI
daemon=True 确保主窗口关闭时子线程自动退出,不会造成程序悬挂。
7.2 跨平台的 stdout/stderr 混合读取
同时读取 stdout 和 stderr 是一个经典难题。直接顺序读取会产生死锁(进程等待写 stderr,而你在阻塞读 stdout)。程序针对不同平台使用了不同策略:
Unix/macOS(selectors 多路复用):
import selectors
sel = selectors.DefaultSelector()
sel.register(proc.stdout, selectors.EVENT_READ, "out")
sel.register(proc.stderr, selectors.EVENT_READ, "err")
open_fds = 2
while open_fds > 0:
for key, _ in sel.select(timeout=0.1):
line = key.fileobj.readline()
if line:
color = "#CDD6F4" if key.data == "out" else "#F38BA8"
wx.CallAfter(self.output.append, line, color)
else:
sel.unregister(key.fileobj)
open_fds -= 1
sel.close()
selectors 模块是对 select/poll/epoll 系统调用的高层封装。sel.select(timeout=0.1) 会等待任意一个文件描述符可读,返回就绪的描述符列表。当 readline() 返回空字符串时,说明该流已关闭(进程结束),计数器 open_fds 递减,两个流都关闭后退出循环。这是真正的并发读取,不会有任何死锁风险。
Windows(双线程读取):
def _rd(stream, color):
for line in stream:
wx.CallAfter(self.output.append, line, color)
t1 = threading.Thread(target=_rd, args=(proc.stdout, "#CDD6F4"), daemon=True)
t2 = threading.Thread(target=_rd, args=(proc.stderr, "#F38BA8"), daemon=True)
t1.start(); t2.start(); t1.join(); t2.join()
Windows 上 selectors 仅支持 socket,不支持管道(pipe),因此改用两个独立线程分别阻塞读取两个流。join() 确保两个流都读完后才继续执行后续逻辑(检查退出码)。
7.3 退出码的处理
proc.wait()
rc = proc.returncode
if rc == 0:
wx.CallAfter(self.output.append,
f"\n✅ 进程结束,退出码: {rc}\n", "#A6E3A1")
else:
wx.CallAfter(self.output.append,
f"\n❌ 进程异常,退出码: {rc}\n", "#F38BA8")
Unix 惯例:退出码 0 表示正常结束,非 0 表示异常。Python 未捕获异常时退出码为 1。
7.4 工作目录的切换
proc = subprocess.Popen(
[sys.executable, py_path],
...
cwd=cwd, # cwd = os.path.dirname(py_path)
)
cwd 参数让子进程的工作目录设置为脚本所在的子文件夹。这意味着脚本内部的相对路径(如 open("data.csv"))会相对于脚本目录解析,而不是 IDE 的启动目录。这是 IDE 最基础的行为之一,一行代码即可实现。
7.5 停止进程
def _on_stop(self, event):
if self._process and self._process.poll() is None:
self._process.terminate()
poll() 是非阻塞的进程状态检查:返回 None 表示进程仍在运行,返回退出码表示已结束。terminate() 在 Unix 上发送 SIGTERM,在 Windows 上调用 TerminateProcess。对于需要强制杀死的情况,可改用 kill()(Unix SIGKILL)。
八、文件加载的状态同步
_load_file 是一个细节丰富的方法,负责加载文件后的全面状态同步:
def _load_file(self, full_path, silent=False):
with open(full_path, "r", encoding="utf-8") as f:
code = f.read()
self.editor.SetText(code)
self.editor.EmptyUndoBuffer() # ← 清空撤销历史
self._cur_path = full_path
stem = os.path.splitext(os.path.basename(full_path))[0]
self.name_ctrl.SetValue(stem) # ← 同步名称输入框
# 在列表中高亮对应项
for i, (disp, path) in enumerate(self._proj_map.items()):
if path == full_path:
self.proj_list.SetSelection(i)
break
cwd = os.path.dirname(full_path)
self._set_status(f"工作目录: {cwd}", "#6C7086", field=1) # ← 更新状态栏
EmptyUndoBuffer() 的必要性: SetText() 会被记录为一个可撤销操作。如果不清空撤销历史,用户按 Ctrl+Z 会撤销掉整个文件内容,回到空白状态。清空后,撤销历史从当前状态重新开始,符合用户预期。
silent 参数: 程序启动时恢复上次文件使用 silent=True,不向输出面板追加加载消息。用户主动点击列表时使用 silent=False,会输出加载路径提示。这种"静默/非静默"模式是 UI 编程的常见技巧。
九、关闭守卫:_on_close的职责
def _on_close(self, event):
if self._process and self._process.poll() is None:
self._process.terminate() # 关闭时杀掉子进程
self._save_settings() # 保存配置
event.Skip() # 允许窗口真正关闭
event.Skip() 是 wxPython 中容易踩坑的地方。绑定 EVT_CLOSE 后,必须调用 event.Skip() 才能让窗口实际关闭。如果只处理事件而不调用 Skip(),窗口关闭事件会被消费掉,窗口将无法关闭。
十、设计亮点与可改进之处
亮点总结
| 设计决策 | 实现效果 |
|---|---|
| Scintilla 词法分析器 | 无需自己解析代码,直接获得专业级高亮 |
selectors + 双线程的平台适配 | 跨平台零死锁的实时输出流 |
_proj_map 字典 | 列表显示与文件路径完全解耦 |
os.walk + 目录名匹配 | 无需配置文件即可发现所有项目 |
EmptyUndoBuffer | 加载文件后撤销历史正确重置 |
daemon=True 子线程 | 主窗口关闭时线程自动清理 |
| 配置文件与程序同名 | 重命名程序时配置自动跟随 |
可进一步扩展的方向
1. 自动补全
Scintilla 内置 AutoCompShow() 方法,配合 Python 的 jedi 库可以实现上下文感知的代码补全:
import jedi script = jedi.Script(self.editor.GetText(), path=self._cur_path) completions = script.complete(line, col) words = "\n".join(c.name for c in completions) self.editor.AutoCompShow(0, words)
2. 实时语法检查
利用 py_compile 或 ast.parse 在保存时检查语法错误,将错误行用红色标记显示在行号边距。
3. 多文件标签页
将单一的编辑器替换为 wx.aui.AuiNotebook,每个标签页承载一个 PythonEditor 实例,即可支持同时编辑多个文件。
4. 虚拟环境支持
目前 sys.executable 使用运行 IDE 的解释器。增加虚拟环境选择器(一个路径输入框),将可执行路径替换为 venv 内的 python,即可让每个项目使用独立的依赖环境。
5. stdin 交互
当前实现使用 PIPE 捕获输出,但没有连接 stdin。如果脚本调用 input(),进程会阻塞。可以在输出面板底部添加一个输入框,通过 proc.stdin.write() 向子进程发送数据。
结语
PyEditor v2 用不到 300 行 Python 代码实现了一个功能完整的桌面 IDE 原型,涵盖了语法高亮、项目管理、进程控制、配置持久化等所有核心特性。它的价值不在于替代 VS Code,而在于展示了如何用 wxPython 的标准工具集构建专业级桌面应用。
Scintilla 的强大、subprocess 的灵活、os.walk 的简洁、selectors 的跨平台优雅——这些 Python 标准库和第三方库的组合,让一个人用一个下午就能写出在生产环境中可用的工具。这正是 Python 作为"胶水语言"最迷人的地方。
本文源码完整可运行,依赖安装:pip install wxpython,Python 3.8+ 适用。
以上就是280行Python代码打造一个带语法高亮的IDE的详细内容,更多关于Python实现带语法高亮IDE的资料请关注脚本之家其它相关文章!
