这个项目挺有意思的地方在于,它用 turtle 这个画图库来做了一个有交互界面的小软件。turtle 大家一般用来画图,拿它做 UI 不是主流选择,但代码量少、依赖少,对初学者来说反而是个不错的切入点。
【点击视频预览程序效果】
整个项目分 5 个步骤递进完成,每一步只加一个新东西,最终拼出来一个完整的程序。下面按这 5 步走一遍,重点讲每一步加了什么、为什么这样加。
第一步:先把窗口搭起来
fromturtleimport*win = Screen()win.setup(1050, 530)win.title("命令查询软件")win.bgpic("Huiliu_x_BG.png")done()这一步什么功能都没有,就是验证环境——窗口能不能弹出来,背景图能不能加载,尺寸对不对。
win.setup(1050, 530) 定好了画布大小,后面所有坐标都在这个范围内算。turtle 的坐标原点在窗口中心,x 向右为正,y 向上为正,所以左下角大约是 (-525, -265)。
done() 是让窗口保持开着,不加的话程序跑完窗口直接关了。
第二步:把菜单画出来,顺便摸清坐标系
defgo(x, y):penup()goto(x, y)pendown()defwrite_menu():forn, iinenumerate(list_menu):text, align, x, y, size = iifn!= 0: text = str(n) +". "+textgo(x, y)write(text, align=align, font=("楷体", size))y = 100y1 = 50list_menu = [ ("命令展示软件", "center", -280, 170, 20), ("通用函数", "left", -350, y-y1*0, 15), ("字符串函数", "left", -350, y-y1*1, 15), ("列表函数", "left", -350, y-y1*2, 15), ("字典元组集合", "left", -350, y-y1*3, 15),]write_menu()win.onclick(pt)菜单数据结构的设计
菜单每一项存了 5 个信息:文字内容、对齐方式、x 坐标、y 坐标、字号,打包成一个元组放进列表。这样 write_menu() 只需要遍历一次,每项解包拿到这 5 个值,统一处理。
如果不这样做,每个菜单项单独写一行 goto + write,改一个坐标就要改很多地方。把数据集中在 list_menu 里,逻辑只写一遍,后面加菜单项只往列表里追加就行。
y 坐标的算法
y = 100y1 = 50# y-y1*0 = 100, y-y1*1 = 50, y-y1*2 = 0 ...
菜单项从上往下排列,每项间距 50 像素。用 y - y1*n 计算每项的 y 坐标,比手动填 100、50、0、-50 更清晰,间距想改只改 y1 这一个数。
enumerate() 给菜单项自动编号
forn, iinenumerate(list_menu):ifn!= 0: text = str(n) +". "+text
遍历列表时同时拿到下标 n 和内容 i。n=0 是标题,不加编号;n=1 开始的项自动变成 "1. 通用函数"、"2. 字符串函数" 这样的格式。不用手动在数据里写编号,后面调整顺序也不会出错。
win.onclick(pt) 是调试用的
defpt(x, y):print(x, y)
点哪里打印哪里的坐标。做 UI 时不可能凭空算出每个元素该放哪,先点一下看坐标,再填进去,这个函数贯穿整个开发过程。
第三步:读文件,显示命令,支持翻页
这步加了核心功能——从 txt 文件读取命令列表,分页显示,按下键翻页。
defload_file(file):f = open(file, "r", encoding="utf-8")list1 = f.readlines()f.close()returnlist1
readlines() 一次读完整个文件,返回一个列表,每行是一个元素,末尾带换行符 \n。后面显示时用 .strip() 去掉这个换行符。
分页显示逻辑
N = 0# 当前从第几条命令开始显示N_max = 10# 每页最多显示多少条defshow_cmd():globalNnum = len(list_cmd)tracer(0)P1.clear()# 显示页码P1.goto(20, -240)P1.write(f"第 {N//N_max+1} 页 / 共 {num//N_max+1} 页", font=("楷体", 15))x, y = -130, 200foriinrange(N_max):P1.goto(x, y)text = list_cmd[N].strip()P1.write(text, font=("楷体", 15))y -= 40N += 1ifN>= num:N = 0breaktracer(1)N 是全局变量,记录当前读到哪一条了。每次按下键调用 show_cmd(),从第 N 条开始取最多 10 条显示,然后 N 向前推 10。读到末尾时 N 归零,下一次从头开始——实现了循环翻页。
页码算法 N//N_max+1 值得看一下:N=0 时显示第 1 页,N=10 时显示第 2 页,整除后加 1。总页数同理。
tracer(0) 和 tracer(1) 是干什么的
turtle 默认每画一笔都刷新屏幕,内容多了就能看到文字一行一行地出来,有明显的闪烁感。tracer(0) 关掉自动刷新,等所有内容都画完之后,tracer(1) 重新打开,一次性显示出来,视觉上干净很多。
为什么要单独建一个 P1 = Pen()
turtle 里默认有一个全局画笔,但这里需要随时 P1.clear() 清掉上一页的内容,如果用全局画笔,菜单也会被清掉。单独建一个 Pen 对象给命令显示区域用,清理互不干扰。
第四步:数字键切换分类,lambda 传参
精简版在第三步基础上加了两件事:数字键 1~6 直接切换分类,show_cmd 改造成既能翻页又能切换分类。
defshow_cmd(event):globalN, list_cmdifevent!= 0:name = list_menu[event][0]list_cmd = load_file(name+".txt")N = 0# 后面显示逻辑不变
event 传 0 表示翻页,传 1~6 表示切换到对应分类。切换时根据菜单项名称拼出文件名("通用函数" → "通用函数.txt"),重新加载文件,并把 N 归零从头显示。
lambda 的用法
按键绑定函数时,onkeypress 只接受无参数的函数。但 show_cmd 需要传 event 参数,这里用 lambda 包一层:
win.onkeypress(lambda: show_cmd(1), "1")win.onkeypress(lambda: show_cmd(2), "2")
lambda: show_cmd(1) 创建了一个无参数的匿名函数,调用时固定传 1 给 show_cmd。按不同数字键调用同一个函数,参数不同。
说实话我第一次看到 lambda 的时候觉得这玩意儿有点多余,直接定义六个函数不也行。后来写多了才理解,那六个函数除了数字不一样,其他完全相同,写出来纯粹是在凑行数。lambda 在这里就是省掉这些重复的壳子,只保留真正不同的那一个数字。
第五步:最终完整版,三处关键升级
对比精简版和最终版,代码量其实差不了多少,但有几处改动值得单独说一下。
升级一:用类封装 Pen 的初始化
classPen1(Pen):def__init__(self):super().__init__()self.penup()self.hideturtle()
精简版里 P1 创建完要立刻 penup() 再 hideturtle(),最终版用了两个 Pen 对象,写成 P1 = Pen(); P1.penup(); P1.hideturtle(); P2 = Pen(); P2.penup(); P2.hideturtle(),光这六行就占了不少位置。
把这两行固定的操作提进子类的 __init__,之后用 Pen1() 创建时就自动跑了,外面不用再写。
super().__init__() 那行不能省——它先把父类 Pen 自己的初始化跑一遍,不然 turtle 的底层功能没初始化,之后调 goto、write 都会出问题,在这基础上再加 penup 和 hideturtle 才安全。
升级二:启动时预加载所有文件到字典
精简版每次按数字键切换分类,都要现场打开一次文件读完再关掉。六个分类来回切,IO 操作就来回跑,其实文件内容根本没变过。最终版把这个改成程序一启动就把六个文件全读进字典:
defload_file():dict1 = {}foriinrange(1, 7):name = list_menu[i][0]file = name+'.txt'f = open(file, "r", encoding="utf-8")dict1[name] = f.readlines()f.close()returndict1DICT_cmd = load_file()存进字典,键是分类名,值是命令列表。切换时直接查字典:
list_cmd = DICT_cmd.get(list_menu[event][0], [])
list_menu[event][0] 取出菜单项名称,作为字典的键查数据,.get(..., []) 表示找不到就返回空列表,不至于后面访问 list_cmd 时直接崩。
有个细节我觉得挺巧的:菜单里第 1 项叫"通用函数",对应的文件名是"通用函数.txt",字典的键也是"通用函数"。三个地方用的是同一个字符串,查起来不需要任何转换,菜单名就是文件名就是字典键,一个字符串串联了整条数据链。
升级三:选中项高亮,P2 单独管菜单
最终版菜单多了一个效果:当前选中的分类变成绿色。
defwrite_menu(event):globallist_cmd, Ntracer(0)P2.clear()forn, iinenumerate(list_menu):text, align, x, y, size = iP2.pencolor("black")ifn%7!= 0:text = str(n) +". "+textlist_cmd = DICT_cmd.get(list_menu[event][0], [])ifn == event:P2.pencolor("green") # 选中项变绿P2.goto(x, y)P2.write(text, align=align, font=("楷体", size))N = 0show_cmd()tracer(1)每次切换分类,P2 先 clear() 清掉旧菜单,重新画一遍,只有 n == event 的那项用绿色写。
用两个独立的 Pen 对象是有原因的:如果 P1 和 P2 用同一支笔,调 clear() 就全清了,命令和菜单一起没了。分开之后,想清命令区就 P1.clear(),菜单不动;想重绘菜单就 P2.clear(),命令区也不受影响。
另外 tracer(0) 要放在 clear 之前,不然清掉菜单那一瞬间屏幕上会有空白一闪而过,看起来会抖。等所有内容都画完再 tracer(1) 刷新,用户看到的就是直接切换过去的效果。
回头看看这五步做了什么
写完这个软件,我觉得最有意思的地方不是某个具体的函数,而是每一步改了什么、为什么改。
第一步什么都没做,就是把窗口搭起来验证环境。第二步开始想怎么存菜单数据,用列表套元组,之后改起来只动一个地方。第三步发现需要一支独立的笔来管显示区域,不然清不干净。第四步遇到 onkeypress 不接受参数的问题,lambda 解决了它。第五步发现两个 Pen 有重复初始化,顺手提进了子类。
每次都是碰到了具体的问题才加东西,不是一开始就设计好的。这可能才是一个程序从零到完整的真实过程——你不会在第一行代码就想到最后要用继承,都是写着写着发现重复了、不对了,然后改。
最后数据在程序里的走向大概是:启动时 load_file() 把六个文件一次性读进 DICT_cmd,之后按数字键 write_menu(event) 从字典里取出对应的列表给 list_cmd,再交给 show_cmd() 按页展示,按下键 N 往前推。文件只在启动时读一次,后面全走字典查询,这一点从头到尾都没变过。