跳转至

2026

版本 0.4.0:把没做完的组件做完,把做完的过一遍

这一版有两半:

  1. 补齐——FluCheckBox / FluRadioBox / FluLiteNav 是新写的, FluListBox 从"占位实现"补成了真列表;
  2. 体检——把已有组件逐个体检了一遍,修掉 20 多条真缺陷 (其中有几条会让整个换肤崩掉、让图片显示不出来、让窗口销毁后残留)。

除此之外还加了 121 个 pytest 用例,ruff 在两个仓库里都清零。

新增

FluCheckBox(多选框)

20×20 的圆角方框 + 标签。勾选状态与 Windows 一致,允许三种取值:

import tkflu

box = tkflu.FluCheckBox(root, text="启用动画", checked=True)
three = tkflu.FluCheckBox(root, text="不确定态", three_state=True, checked=None)

box.dconfigure(command=lambda: print(box.dcget("checked")))

True 画勾、False 空框、None 画横杠(不确定态)。 构造时传 three_state=True 就会在 False → True → None → False 之间循环。 Tab 聚焦、空格 切换,聚焦时方框外会画一圈焦点环。

FluRadioBox(单选框)

圆环 + 实心圆点,同组互斥。两种分组写法:

import tkinter as tk

# 1) 共享变量(与 ttk.Radiobutton 一致)
plan = tk.StringVar(master=root, value="b")
tkflu.FluRadioBox(root, text="甲", variable=plan, value="a")
tkflu.FluRadioBox(root, text="乙", variable=plan, value="b")

# 2) 组名:窗口内同名的一组自动互斥,窗口销毁时自动回收
tkflu.FluRadioBox(root, text="甲", group="plan", value="a")
tkflu.FluRadioBox(root, text="乙", group="plan", value="b")

比较取值用的是 str(变量) == str(value)——这是 Tk 一直以来的语义, 也顺手避开了"IntVar 配字符串 value,看起来选了却没高亮"那个坑。 换选另一项时只有新选中的那个会回调,旧项不会跟着响一次。

FluListBox(真列表)

0.3.0 之前它只是个"长得像按钮的圆角矩形"。现在:

  • 条目数据:insert / append / delete_item / clear / get / index;
  • 虚拟滚动:只画可见的那几行,条目多也不卡;
  • 三种选择模式:single / multiple / extended(Ctrl 切换、Shift 连选);
  • 键盘:↑↓ / PageUp / PageDown / Home / End / Enter;
  • 滚轮:Windows 的 <MouseWheel> 与 X11 的 Button-4/5 都接上了;
  • 自绘滚动条:浮在右侧,可以拖;内容不多时不出现;
  • 过长的条目会截断成 abc…(Tk 画布没有裁剪概念,不截断就会糊到控件外面)。

选中的那一行左侧有一条强调色指示条——这是 Windows NavigationView 的记号。

box = tkflu.FluListBox(
    root, width=300, height=180,
    items=[f"第 {i} 行" for i in range(1, 101)],
    selectmode="extended",
    command=lambda index, item: print("激活", index, item),
    on_select=lambda indices, items: print("选中", indices),
)

条目删除叫 delete_item

控件本身是 Canvas,delete 是"删除画布元素"(重绘第一步就是 self.delete("all"))。两者同名会递归调用自己。

老写法 FluListBox(parent, text="标题", width=120) 保持原样: 列表为空时 text 就是空状态提示,高度仍是 32px。

FluLiteNav(轻量导航栏)

一排可点条目 + 一个选中项,对应 NavigationView 里最常用的那部分。 竖排时指示条在左,横排时在下方。

nav = tkflu.FluLiteNav(
    root,
    items=[
        ("🏠", "首页"),
        ("🔍", "搜索"),
        {"label": "设置", "icon": "⚙", "key": "settings"},
    ],
    orient="horizontal", style="card", selected="settings",
)
nav.dconfigure(command=lambda index, item: print("切到", item["key"]))

条目可以简写("标签" / ("图标", "标签"))也可以写成字典 (label / icon / key / enabled / command)。 禁用的条目在键盘导航里会被跳过。

修复

会让整趟换肤崩掉的那种

  • FluButton.theme() 查表后无条件调用结果:style 写错一个字母就 TypeError: 'NoneType' object is not callable,而且那个错误值会被存进 self.style —— 这个按钮从此再也切不了主题。更糟的是 FluThemeManager.mode() 会崩在这里,后面的兄弟组件全都轮不到换肤。
  • FluListBox.theme() 用参数而不是 self.style 判断分支(0.3.0 已修,这里补上回归)。

看不见但很要命的那种

  • FluImage 的图片被内嵌 Frame 盖住:FluFrame 的内嵌 Frame 是真实的 控件窗口,而嵌入窗口永远画在画布元素之上,跟创建顺序无关。所以 "先画背景、再叠图片"根本没生效——整张图被盖住,只露出边框内侧一圈。 现在有图片时把那个窗口元素隐藏掉。
  • FluSlider(orient="vertical") 构造即崩:非横向时轨道/把手根本没创建, 紧接着 tag_bind 抛 AttributeError。纵向方向现在真的实现了。
  • FluScrollBar.set() 是坏的:Tk 的 yscrollcommand 传进来的是字符串, 而代码直接拿它做乘法;就算转成数字,coords(item, x1,y1,x2,y2) 对图片 元素也不合法(只接受 0 或 2 个坐标)。顺带一提,横向滚动条在静止时 整个是空的(未展开分支只画竖直方向)。
  • FluFrame.destroy() 把面板留在屏幕上:画布是父容器的直接子控件, 只销毁内嵌 Frame 等于没销毁。建一次销毁一次就多留一块。
  • _after 的清理是"整个解释器"级的:销毁一个子窗口会把主窗口排的 after 一起取消,还会在别的控件里留下已删命令的名字,于是下次关窗口报 TclError: can't delete Tcl command,_default_root 残留,之后建窗口直接 "application has been destroyed"。现在改成按归属回收。
  • 图标 PhotoImage 的进程级缓存钉住解释器:反复开关窗口 50 次内存涨 ~96 MB (清掉缓存只涨 ~12 MB),而且 id(tk) 会被复用,还能拿到属于已死解释器的图片。
  • FluSlider(min=max) 抛 ZeroDivisionError(一段死代码里的除法先执行)。
  • FluPopupWindow.popup():只设了动画帧间隔(帧数为 0)时, 淡入的除法会炸——表现为菜单打不开、提示气泡一悬停就报错。

看得见的那种

  • FluButton 的过渡动画最后一帧就是"最终状态",而且那一次权威重绘被注释掉了: steps=1 时按钮会停在旧主题上,直到下一次无关事件才刷新。 顺带把写死的 border_width=1 / radius=6 换成目标状态的值—— 浅色强调色的禁用态线宽本来是 0,写死 1 会留一圈多余的白边。
  • FluToggleButton 打开时的描边是从背景色插值的(复制粘贴漏改), 勾选过程中边框会从浅灰一路闪成强调蓝。
  • 滑块 value 超出 [min, max] 时进度条会画到控件外面几百像素。
  • 拖动滑块时把手没有按下态:配色分支写成 if event:,而拖动最后那次 _draw() 不带事件 —— 于是拖动永远是静止外观。
  • 鼠标划过禁用的滚动条直接抛 TypeError(禁用配色表里没有 track_color, None 被当成 SVG 的 fill)。
  • 第二个窗口里的文字全变成很大的宋体:字体对象是按 Tk 解释器注册的, 不传 master 就挂在第一个解释器上,第二个解释器认不出这个名字。
  • FluLabel(..., font=...) 传进去的字体被整个丢掉,attributes.font 一直是 None。
  • FluMenu(tl) 的第一个位置参数是 height,于是 toplevel 被当成了高度, 弹出窗口永远挂在默认根窗口上(属主关了它还留着)。
  • 点掉子菜单里的一项,父菜单不会跟着收起。
  • FluFrame 放进 ttk.Frame / ttk.Notebook 直接报 TclError: unknown option "-background"(ttk 没有这个选项)。
  • FluEntry 的 <Button-1> 绑定漏了 add="+",把基类的处理覆盖掉了, 画布上的 button1 永远是 False。
  • 菜单宽度估算写的 Font(master=...) 参数名是错的(应为 root=), TclError 又被 except 吞掉,于是永远走"宽字符按 2 计 × 9"的估算—— 菜单比实际需要宽 1.6~2 倍。现在用真实字体度量。
  • 画廊里的"切换可用状态"按钮没有反应:dconfigure() 只写属性不重绘, 要鼠标划过才刷新。

顺手清掉的

  • FluFrame 的 5 个几何代理方法是坏的:pack_propagate 把 flag 丢了 (变成"读取"而不是"设置")、grid_location 丢参数、grid_anchor 传了个 Ellipsis 对象、place_info 返回的是 grid_info。
  • FluFrame 的过渡动画是死代码:hasattr(某个 dict, "back_color") 恒为 False,那段插值从来没跑过。
  • FluSlider / FluEntry / FluText / FluScrollBar 的 theme() 在 mode 为空时会抛 AttributeError(有的是 self.mode 还不存在, 有的是把 None 直接传给设计函数)。
  • FluSlider 现在有 set() / get(),支持方向键与 Home / End, 并且接受 command=(以前 FluSlider(command=cb) 会把 -command 透给 Canvas 然后抛 TclError)。
  • FluText 不再把像素值写进 Text 的 width / height (那两个选项的单位是字符与行数,实测会算出 1334×1232 的请求尺寸)。
  • 动画帧不再堆积:每轮动画开始前先撤销上一轮没跑完的帧。
  • tkdeft 侧:DCanvas._prune_photo_refs 永远清不掉失效引用 (删除的 item 让 type() 返回 None 而不是抛异常),于是每次重绘都往 _photo_refs 里多存一张图;现在能正确回收。

测试

新增 tests/(pytest,121 个用例):

文件 覆盖
test_new_widgets.py 四个新组件的 API 与交互(多选/三态/分组/选择模式/键盘/滚动截断/命中测试)
test_regressions.py 上面每一条修复各一到两个断言——改回去就会红
test_engines.py 五个渲染引擎 ×(全部组件构建 + 换肤 + 画廊自检 + 画廊结构)
cd tkfluent
python -m pytest tests/          # 121 passed
python -m ruff check tkflu tests # All checks passed!

两个测试环境的坑(都写进了 conftest)

  • 几何断言必须在窗口已映射时做:withdraw() 状态下子控件的 winfo_width() 一律是 1,命中测试、可见行数、裁剪全都量不出来。
  • 别在整套测试里反复建新的 Tcl 解释器:一个 FluWindow 一个解释器, 开上百个之后 Tk 会开始报 Can't find a usable tk.tcl ... couldn't read file .../ttk/scale.tcl——文件其实好好地在那儿,是资源被耗干了。 所以测试共用一个解释器,每个用例开一个新的 FluToplevel。

升级提示

  • FluListBox 从占位变成真列表,行为有变化(单击=选中,双击/回车=激活)。 只传 text= 的老写法外观不变。细节见 常见问题。
  • FluButton.theme() 现在对非法 mode / style 退回默认值而不是崩掉。
  • 组件内部不再有"每次重绘都排一个 after 且从不取消"的写法; 自己扩展组件时,请照着 tkflu._after.TracedAfter 来。

版本 0.5.0:一次换肤,所有组件一起变

这一版只做一件事:把主题切换重写。

老实现是"挨个组件换肤 + 每个组件后面跟一句 update()",于是点一次 "切换主题"会卡住一两秒、组件从左到右一个一个变色、而且动画根本看不到。 现在整棵树共用一条时间轴,颜色是一起变的,而且 mode() 几乎立刻返回。

同一个组件画廊(59 个控件参与换肤,本机实测):

0.4.0 0.5.0
mode() 阻塞(skia) 2.8 ~ 4.0 s ≈ 30 ms
mode() 阻塞(tksvg,默认引擎) ≈ 3.3 s ≈ 25 ms
各组件"第一次变色"的时刻差 150 ms 以上 0 ms(同一帧)
过渡中间色 1 种(瞬间跳变) 3 ~ 6 种

升级不需要改任何调用代码:FluThemeManager.mode() / toggle() / tkflu.toggle_theme() 的用法一个都没变。

真画廊上连拍的几帧(按窗口句柄抓的屏幕像素,不是属性):

主题过渡的实测帧

同一个组件画廊(59 个控件)从浅色切到深色。+300ms / +460ms 两帧里整屏 处在**同一个中间色**上——这就是"同步":不存在"这一半变了、那一半还没变"。 每张图下方标注的是离切换时刻的毫秒数。

(生成方式:python benchmarks/theme_transition_shots.py 的同类脚本, 用 Win32 PrintWindow 按窗口句柄抓客户区,见 benchmarks/README.md。)

老实现坏在哪

for widget in window.winfo_children():
    widget.theme(mode=mode)   # 每个组件内部各排各的过渡帧
    widget._draw()
    widget.update()           # ← 这一句把事件循环跑了起来

update() 会处理全部待办事件(含用户输入、重绘、定时器)。三个毛病 互相放大:

  1. 阻塞。组件越多这一趟越久,而且这段时间里窗口完全点不动。
  2. 不同步。每个组件的过渡各排各的 after,起点是"轮到它自己"那一刻; 而前面组件的 update() 又把后面组件已经到点的帧就地执行掉, 于是画面变成"一个一个地变色"。
  3. 动画被吃掉。真正跑完的帧全在那次 mode() 里消耗掉了, 用户看到的不是过渡,而是"卡两秒然后瞬间变色"。

顺带一提,FluFrame / FluMenuBar / FluToplevel 里的递归换肤也各带一遍 update(),所以实际跑的事件循环次数比组件数还多。

现在的做法

新增 tkflu.theme_transition, 一次换肤分三步:

  1. 静默算目标。在"组件动画全部让路"的上下文里,依次调用各组件原有的 theme(),把目标配色写进 attributes。这一步的 _draw() 被临时屏蔽, 所以屏幕还停在换肤前的样子——那正好就是过渡的第 0 帧。
  2. 统一插值。对每个组件做一次 attributes 快照,与目标值逐叶子比对, 挑出能插值的叶子:#rrggbb 颜色按 RGB 插值,*_opacity 按数值插值。 线宽、圆角、字体这类字段直接落到目标值——让边框"长出来"比瞬间变过去更怪。
  3. 一条时间轴画所有组件。整棵树共用一组帧,每一帧把同一个进度 t 写给所有组件再统一重绘。任意时刻屏幕上所有控件都处于同一个 t, 不会出现"这一半变了、那一半还没变"的撕裂。
import tkflu

tkflu.run_theme_transition(root, "dark")          # 整棵子树一起过渡
tkflu.run_theme_transition(root, "dark", steps=0) # 只要结果,不要动画

顺带修掉的两个真缺陷

  • 顶层就是叶子的配色字段从来没被插值过。 FluLabel 只有 attributes.text_color、FluBadge 只有 back_color—— 回写时对"路径长度为 1"的叶子取 path[1:] 得到空元组, 插值时直接 IndexError,异常被兜住,于是这些组件从不参与过渡。
  • pywinstyles 内部会 window.update()。 BWm.theme_myself() 每次换肤都调 pywinstyles.apply_style(), 而那个第三方函数里带了一句 window.update()——一个不在我们控制里的 "跑一遍事件循环"。现在只在模式真的变了的时候才调它。

新增

缓动曲线

默认从匀速换成了 ease_in_out(两头慢、中间快),观感自然得多:

from tkflu import easing_names, set_theme_easing

print(easing_names())
# ['ease_in', 'ease_in_out', 'ease_out', 'ease_out_back', 'linear']

set_theme_easing("ease_out")

自适应帧数:宁可少几帧,也不冻窗口

过渡的每一帧都要重绘整棵树,所以"能画几帧"完全取决于渲染引擎: 默认的 tksvg 下一屏 20 来个组件重绘一次要 100~200ms,skia 只要十几毫秒。 硬按 steps × step_time 的时间表去排,时间轴上大部分帧会被直接跳过, 用户看到的其实是"闪一下"。

所以现在先量一次重绘开销,再把帧数降到预算以内(默认 600ms), 第一帧实测之后还会再收紧一次:

import tkflu

transition = thememanager.mode("dark")
transition.describe()
# {'mode': 'dark', 'widgets': 59, 'requested_steps': 5, 'steps': 3,
#  'frames_painted': 2, 'paint_cost_ms': 316.83, 'failures': []}

tkflu.set_transition_budget(900)   # 想要更长更顺的过渡

想要丝滑就换引擎,比加帧数有效得多:

tkflu.set_renderer("skia")     # 或 "pillow"(零额外依赖)

过渡对象

transition = thememanager.mode("dark")

transition.finished          # 画完了吗
transition.frames_painted    # 实际画了几帧
transition.failures          # 这次换肤里抛异常的组件(不影响其它组件)
transition.cancel()          # 中断;restore=False 可保留中间色接着切

连点"切换主题"不会叠加帧队列:新的过渡会就地接管旧的(以当前中间色为 新起点),画面是连续的。

按次覆盖动画参数

thememanager.mode("dark", animation_steps=8, animation_step_time=16,
                  easing="ease_out")
thememanager.mode("dark", animation_steps=0)     # 这一次不要动画

改了什么行为

  • FluThemeManager.mode() 不再在返回前画完。它只把目标配色静默写进 attributes,重绘交给事件循环;所以 mode() 之后立刻读 attributes 已经是新主题的值,但画面还没变。要同步等结果:
transition = thememanager.mode("dark")
while not transition.finished:
    root.update()
  • 换肤的覆盖范围从"窗口的直接子组件 + 各容器自己向下传播"变成 整棵控件树。以前需要自己写 update_children() 的中间层容器, 现在什么都不用做。

  • FluWindow.theme(mode) / FluToplevel.theme(mode) 现在会连带换掉 整个窗口里的组件(以前只管窗口自己)。想让一个子树单独换肤时, 直接对它调 run_theme_transition(subtree, mode) 即可。

验证

  • 新增 tests/test_theme_transition.py(18 个用例):同一帧里所有组件的进度 必须相同、换肤不跑事件循环、mode() 返回时一帧都没画、帧数按预算裁剪、 第二次切换接管第一次、中断/取消、坏组件不拖垮整趟换肤、缓动端点与单调性。
  • 新增 benchmarks/theme_switch_bench.py:量 mode() 阻塞时长、总时长、 最长无响应间隔、过渡中间色帧数与各组件变色时刻的同步偏差。 上表的原始结果 JSON 与该脚本一起提交在 benchmarks/ 里。
  • 新增 benchmarks/theme_transition_shots.py:用 Win32 PrintWindow 按窗口句柄连拍,用屏幕上的像素证明那段过渡真的发生了 (面板颜色 255 → 251 → 251 → 224 → 149 → 74 → 47,单调变暗)。

ruff check tkflu tests benchmarks 清零,python -m tkflu --check 退出码 0, pytest tests 全绿。

版本 0.3.0:跟着底层接口走一遍

这一版没有新组件,做的是三件事:把组件里重复的绘制样板交还给底层库、 修掉两个被回归漏掉的缺陷、把文档补成一本能读的书。

修改

绘制样板收敛回 tkdeft(净删约 400 行)

以前每个组件都要自己写一遍"先试栅格引擎、失败再回退 SVG":

# 旧写法:每个组件抄一遍
item = self.create_roundrect_raster(x1, y1, x2, y2, radius, fill=fill, ...)
if item is not None:
    return item
self._img = self.svgdraw.create_roundrect(...)
self._tkimg = self.svgdraw.create_svg_image(self._img, temppath2, way=get_renderer())
return self._keep_photo(self.create_image(x1, y1, anchor="nw", image=self._tkimg), self._tkimg)

现在统一交给 tkdeft 的 draw_roundrect() 一族:

# 新写法:一行,判定与回退都在底层
return self.draw_roundrect(x1, y1, x2, y2, radius, fill=fill, ...)

button / badge / togglebutton / entry / text / listbox / frame / scrollbar / slider 九个模块都换了过来,其中 button 与 frame 的整段覆盖代码被直接删掉。

修复:点"切换主题"直接崩

FluListBox.theme() 用参数 style 判断分支,而一键换肤只传 mode, 于是 None.lower() 抛 AttributeError。已改为读构造时存下的 self.style 并兜底。 其余组件本来就是 self.style,只有这里写错。

修复:自检把真异常吞掉了

python -m tkflu --check 为了跳过"没有这个钩子的组件",写的是 except AttributeError: continue——结果把钩子内部抛出的 AttributeError 也一起吞了,所以上面那个崩溃在 9 项回归全绿的情况下一直没被发现。

现在改成先 getattr 判断钩子是否存在再调用,并且额外跑一遍真实路径: thememanager.mode("dark") / mode("light")。把 FluListBox.theme 换回旧写法, 自检会立刻以退出码 1 报错。

其它

  • FluSlider.pos() 加了 event is None 守卫(_event_on_button1(event=None) 允许省略事件,但 pos 会直接读 event.x)
  • 新增 tkflu.__version__ 与 tkflu.MIN_TKDEFT_VERSION
  • 依赖由 tkdeft = "0.2.0" 放宽为 >=0.2.0,避免底层升小版本时被 pip 降级

文档

升级建议

pip install -U tkfluent tkdeft

对外 API 是纯加法,旧代码原样能跑。如果你自己写过组件, 建议看一眼有没有这两处:用参数 style 判断主题分支、或者手写"栅格优先 + 回退 SVG"。

版本 0.2.0:可切换的渲染引擎

这一版把绘制后端接到了底层的 tkdeft.engines 上, 并修掉一批长期存在的真实缺陷。

新增

  • 可切换的渲染引擎:tksvg(默认)/ wand / skia / pillow / cairo
import tkflu

tkflu.set_renderer("skia")     # 或 set_renderer(2)
tkflu.get_renderer_name()      # -> "skia"
  • FluLabel 宽度自适应:不传 width 时按文本自动撑开(只增不减), 中文长文本不会再被左右裁掉
  • 按规格缓存的图片缓存:同尺寸同配色的一组控件共用同一张 PhotoImage

修复

问题 影响
临时文件 / 文件描述符泄漏(旧实现每绘制一帧 mkstemp()) 长跑程序句柄耗尽
PhotoImage 被 GC 导致画布 item 变空白 同一画布多张图片时除最后一张外全部消失
下边框和右边框被裁(描边中心线压在画布边界上) 按钮看起来只有上/左边框
菜单按钮挤成一团(len(label) * 8 对中文严重偏窄) "文件" 只算出 16px
菜单项被垂直压扁、底部按钮几乎不可见 布局按实测重新调整
FluEntry / FluText 的内嵌控件挂到默认根窗口 多窗口场景嵌不进去、销毁后不回收
关窗后刷 invalid command name ...,甚至 root.destroy() 失败 after 回调统一由 tkflu/_after.py 回收

性能

栅格引擎配合规格缓存后(本机 120×32 圆角矩形实测):

场景 默认 tksvg skia 提升
按钮重绘 4.70 ms 0.076 ms ≈62×
按钮 hover 往返 7.10 ms 0.16 ms ≈46×
20 个按钮批量重绘 129.8 ms 2.22 ms ≈59×

复现:python benchmarks/run_all.py(在 tkdeft 仓库里)。

文档