280行Python代码打造一个带语法高亮的IDE

 更新时间:2026年05月06日 08:23:21   作者:winfredzhang  
本文详细介绍了一个基于wxPython和Scintilla实现的桌面IDE的开发过程,涵盖语法高亮,多线程处理,跨平台I/O读取等核心技术,感兴趣的小伙伴可以了解下

本文以 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.TextCtrlAPI 简单无语法高亮,性能差
wx.stc.StyledTextCtrlScintilla 引擎,专业级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_WORDSTC_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}")   # 失败不崩溃,只打印

三层防御:

  1. 文件不存在时直接返回(首次运行)
  2. 每个字段用 .get(key, default) 读取(兼容旧版配置)
  3. 恢复文件前检查文件是否仍存在(文件可能被移动或删除)

六、项目发现: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 混合读取

同时读取 stdoutstderr 是一个经典难题。直接顺序读取会产生死锁(进程等待写 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_compileast.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的资料请关注脚本之家其它相关文章!

相关文章

  • Python Socket库基础方法与应用详解

    Python Socket库基础方法与应用详解

    这篇文章主要介绍了关于Python socket库的详细技术解析,包含基础方法说明、工作原理剖析,以及多个应用领域的完整实现代码,对大家的学习或工作有一定的帮助,需要的朋友可以参考下
    2025-04-04
  • windows环境中利用celery实现简单任务队列过程解析

    windows环境中利用celery实现简单任务队列过程解析

    这篇文章主要介绍了windows环境中利用celery实现简单任务队列过程解析,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友可以参考下
    2019-11-11
  • Python自定义线程类简单示例

    Python自定义线程类简单示例

    这篇文章主要介绍了Python自定义线程类,结合简单实例形式分析Python线程的定义与调用相关操作技巧,需要的朋友可以参考下
    2018-03-03
  • Python Missingno 数据缺失值可视化利器案例详解

    Python Missingno 数据缺失值可视化利器案例详解

    数据分析中数据缺失是常见问题,missingno库通过矩阵图、条形图等可视化工具高效识别缺失模式,适用于大型数据集,助于提升效率与决策,建议优先使用矩阵图并结合热力图分析,本文给大家介绍Python Missingno数据缺失值可视化利器,感兴趣的朋友一起看看吧
    2025-06-06
  • Python使用concurrent.futures模块实现多进程多线程编程

    Python使用concurrent.futures模块实现多进程多线程编程

    Python的concurrent.futures模块可以很方便的实现多进程、多线程运行,减少了多进程带来的的同步和共享数据问题,下面就跟随小编一起了解一下concurrent.futures模块的具体使用吧
    2023-12-12
  • mac PyCharm添加Python解释器及添加package路径的方法

    mac PyCharm添加Python解释器及添加package路径的方法

    今天小编就为大家分享一篇mac PyCharm添加Python解释器及添加package路径的方法,具有很好的参考价值,希望对大家有所帮助。一起跟随小编过来看看吧
    2018-10-10
  • python实现飞机大战游戏(pygame版)

    python实现飞机大战游戏(pygame版)

    这篇文章主要为大家详细介绍了python实现pygame版的飞机大战游戏,文中示例代码介绍的非常详细,具有一定的参考价值,感兴趣的小伙伴们可以参考一下
    2020-02-02
  • 深入理解Python内置函数map filter reduce及与列表推导式对比

    深入理解Python内置函数map filter reduce及与列表推导式对比

    这篇文章主要为大家介绍了Python内置函数map filter reduce及与列表推导式对比方法详解,有需要的朋友可以借鉴参考下,希望能够有所帮助,祝大家多多进步,早日升职加薪
    2023-06-06
  • Python实现API开发的详细教程

    Python实现API开发的详细教程

    在现代软件开发中,API扮演着至关重要的角色,API接口用于不同软件组件之间的通信和数据交换,实现了系统之间的互操作性,Python作为一种简单易用且功能强大的编程语言,广泛应用于API接口的开发,本文将详细介绍如何使用Python开发API接口,需要的朋友可以参考下
    2024-12-12
  • Django 表单验证Form的使用小结

    Django 表单验证Form的使用小结

    Django表单验证机制通过cleaned_data、clean()和clean_xxx()方法确保数据安全和完整性,下面就来介绍一下Django 表单验证Form的使用,感兴趣的可以了解一下
    2025-12-12

最新评论