tkdeft 0.3.0__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tkdeft
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: 使用tkinter+tksvg开发的现代化界面库
5
5
  Author: XiangQinxi
6
6
  Author-email: xiangqinxi@outlook.com
@@ -95,6 +95,30 @@ root.mainloop()
95
95
  完整文档(安装 / 快速上手 / 概念与架构 / 指南 / API):
96
96
  <https://tkdeft.netlify.app>
97
97
 
98
+ `tkdeft.extra` 里放可以直接用的功能,不是成品控件。目前有一个
99
+ [Material 涟漪效果](tkdeft/extra/ripple.py):给任意控件加一层从按下处扩散的半透明涟漪,
100
+ 圆角裁剪、抗锯齿、按时间算进度都处理好,你的按钮长什么样它不管。
101
+
102
+ ```python
103
+ from tkdeft.extra.ripple import Ripple
104
+
105
+ self.ripple = Ripple(self, on_frame=self._paint) # ① 建控制器,每帧回调你重绘
106
+ self.ripple.start(event.x, event.y) # ② 点击处起涟漪
107
+ self.ripple.paint(color="#000000", opacity=0.12, # ③ 在重绘里贴一层
108
+ corner_radius=8)
109
+ ```
110
+
111
+ 仓库里的 `examples/ripple_button.py` 是接法演示:圆角按钮、悬停/按压/焦点/禁用、
112
+ `T` 换主题都由示例自己画,涟漪只借 `tkdeft.extra.ripple`;它同时带一个非交互自检:
113
+
114
+ ```bash
115
+ python examples/ripple_button.py # 自动挑栅格引擎(skia > cairo > pillow)
116
+ python examples/ripple_button.py --check # 只跑自检,不开窗口
117
+ ```
118
+
119
+ 写法讲解见文档站的
120
+ [涟漪按钮:可直接用的成品控件](https://tkdeft.netlify.app/usage/ripple-button)。
121
+
98
122
  ## 绘制引擎
99
123
 
100
124
  `tkdeft` 把"**画什么**"和"**用什么画**"分开了。组件只需要描述一个
@@ -175,6 +199,7 @@ print(cache_stats()) # 缓存命中率,便于诊断
175
199
 
176
200
  | 版本 | 一句话 | 发布说明 |
177
201
  | --- | --- | --- |
202
+ | `0.4.0` | 新增 `tkdeft.extra`:可复用的 Material 涟漪效果(控制器 + 纯函数) | [2026-10-18](docs/docs/blog/posts/2026-10-18-release.md) |
178
203
  | `0.3.0` | 把对外接口补齐:统一绘制入口、引擎查询、`DObject` 补全 | [2026-09-13](docs/docs/blog/posts/2026-09-13.md) |
179
204
  | `0.2.0` | 可插拔绘制引擎层 + 进程内栅格引擎 + 规格缓存 | [2026-09-12](docs/docs/blog/posts/2026-09-12.md) |
180
205
  | `0.1.0` | 完善功能 | [2025-06-26](docs/docs/blog/posts/2025-06-26.md) |
@@ -65,6 +65,30 @@ root.mainloop()
65
65
  完整文档(安装 / 快速上手 / 概念与架构 / 指南 / API):
66
66
  <https://tkdeft.netlify.app>
67
67
 
68
+ `tkdeft.extra` 里放可以直接用的功能,不是成品控件。目前有一个
69
+ [Material 涟漪效果](tkdeft/extra/ripple.py):给任意控件加一层从按下处扩散的半透明涟漪,
70
+ 圆角裁剪、抗锯齿、按时间算进度都处理好,你的按钮长什么样它不管。
71
+
72
+ ```python
73
+ from tkdeft.extra.ripple import Ripple
74
+
75
+ self.ripple = Ripple(self, on_frame=self._paint) # ① 建控制器,每帧回调你重绘
76
+ self.ripple.start(event.x, event.y) # ② 点击处起涟漪
77
+ self.ripple.paint(color="#000000", opacity=0.12, # ③ 在重绘里贴一层
78
+ corner_radius=8)
79
+ ```
80
+
81
+ 仓库里的 `examples/ripple_button.py` 是接法演示:圆角按钮、悬停/按压/焦点/禁用、
82
+ `T` 换主题都由示例自己画,涟漪只借 `tkdeft.extra.ripple`;它同时带一个非交互自检:
83
+
84
+ ```bash
85
+ python examples/ripple_button.py # 自动挑栅格引擎(skia > cairo > pillow)
86
+ python examples/ripple_button.py --check # 只跑自检,不开窗口
87
+ ```
88
+
89
+ 写法讲解见文档站的
90
+ [涟漪按钮:可直接用的成品控件](https://tkdeft.netlify.app/usage/ripple-button)。
91
+
68
92
  ## 绘制引擎
69
93
 
70
94
  `tkdeft` 把"**画什么**"和"**用什么画**"分开了。组件只需要描述一个
@@ -145,6 +169,7 @@ print(cache_stats()) # 缓存命中率,便于诊断
145
169
 
146
170
  | 版本 | 一句话 | 发布说明 |
147
171
  | --- | --- | --- |
172
+ | `0.4.0` | 新增 `tkdeft.extra`:可复用的 Material 涟漪效果(控制器 + 纯函数) | [2026-10-18](docs/docs/blog/posts/2026-10-18-release.md) |
148
173
  | `0.3.0` | 把对外接口补齐:统一绘制入口、引擎查询、`DObject` 补全 | [2026-09-13](docs/docs/blog/posts/2026-09-13.md) |
149
174
  | `0.2.0` | 可插拔绘制引擎层 + 进程内栅格引擎 + 规格缓存 | [2026-09-12](docs/docs/blog/posts/2026-09-12.md) |
150
175
  | `0.1.0` | 完善功能 | [2025-06-26](docs/docs/blog/posts/2025-06-26.md) |
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "tkdeft"
3
- version = "0.3.0"
3
+ version = "0.4.0"
4
4
  description = "使用tkinter+tksvg开发的现代化界面库"
5
5
  authors = ["XiangQinxi <xiangqinxi@outlook.com>"]
6
6
  readme = "README.md"
@@ -7,6 +7,7 @@
7
7
  * :mod:`tkdeft.windows` —— ``DCanvas`` / ``DDraw`` / ``DSvgDraw`` / ``DDrawWidget``
8
8
  * :mod:`tkdeft.object` —— ``DObject`` 配置容器
9
9
  * :mod:`tkdeft.utility` —— 字体等零碎工具
10
+ * :mod:`tkdeft.extra` —— 可选的附加功能(目前是 Material 涟漪效果)
10
11
 
11
12
  最常用的东西都从顶层再导出一份,所以下面两种写法等价::
12
13
 
@@ -44,8 +45,20 @@
44
45
 
45
46
  if getattr(tkdeft, "HAS_ENGINES", False):
46
47
  from tkdeft.engines import set_engine
48
+
49
+ 附加功能
50
+ --------
51
+ :mod:`tkdeft.extra` 里是可以直接拿去用的小功能,它按需加载(用到时才 import
52
+ Pillow 的 ``ImageTk``)::
53
+
54
+ from tkdeft.extra.ripple import Ripple
55
+ from tkdeft import extra # 等价,懒加载整个子包
47
56
  """
48
57
 
58
+ from __future__ import annotations
59
+
60
+ import importlib
61
+
49
62
  from . import engines, svg, utility, windows
50
63
  from .engines import (
51
64
  DEFAULT_ENGINE_NAME,
@@ -87,21 +100,38 @@ from .windows import (
87
100
 
88
101
  #: 版本号。tkfluent 会据此判断环境里的 tkdeft 是否够新。
89
102
  #: 注意:需要与 pyproject.toml 里的 version 保持一致。
90
- __version__ = "0.3.0"
103
+ __version__ = "0.4.0"
91
104
 
92
- #: :data:`__version__` 的数字形式,便于比较:``(0, 3, 0)``
105
+ #: :data:`__version__` 的数字形式,便于比较:``(0, 4, 0)``
93
106
  __version_info__ = tuple(int(part) for part in __version__.split(".") if part.isdigit())
94
107
 
95
108
  #: 是否提供绘制引擎层(tkfluent >= 0.2.0 依赖它)。
96
109
  #: 旧版本没有这个子包,用它做能力探测比解析版本号更直接。
97
110
  HAS_ENGINES = True
98
111
 
112
+
113
+ def __getattr__(name: str):
114
+ """按需加载 :mod:`tkdeft.extra`。
115
+
116
+ ``import tkdeft`` 不应该顺带把 Pillow 的 ``ImageTk`` 和附加功能拉进来,
117
+ 所以这里只在真的用到时再导入::
118
+
119
+ from tkdeft import extra # 触发 __getattr__
120
+ """
121
+ if name == "extra":
122
+ # 用 import_module 而不是 ``from . import extra``:后者会走
123
+ # ``_handle_fromlist`` → ``getattr(self, "extra")`` → 又回到本函数,无限递归。
124
+ return importlib.import_module(".extra", __name__)
125
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
126
+
127
+
99
128
  __all__ = [
100
129
  # 子包
101
130
  "engines",
102
131
  "svg",
103
132
  "utility",
104
133
  "windows",
134
+ "extra",
105
135
  # 版本与能力探测
106
136
  "__version__",
107
137
  "__version_info__",
@@ -0,0 +1,42 @@
1
+ """tkdeft.extra —— 用核心零件做的可复用功能(不是成品控件)。
2
+
3
+ 核心包只提供零件(画布、绘制后端、配置容器),这里放**可以直接拿去用的小功能**,
4
+ 每个模块自成一体、按需加载。
5
+
6
+ 当前内容:
7
+
8
+ * :mod:`tkdeft.extra.ripple` —— Material 涟漪效果:一个管理状态与计时的
9
+ :class:`~tkdeft.extra.ripple.Ripple`,加上算出半透明图层的
10
+ :func:`~tkdeft.extra.ripple.ripple_layer`。它不提供按钮,
11
+ 你给自己的控件加三行调用就能用::
12
+
13
+ from tkdeft.extra.ripple import Ripple
14
+
15
+ self.ripple = Ripple(self, on_frame=self._paint) # ① 建控制器
16
+ self.ripple.start(event.x, event.y) # ② 点击处起涟漪
17
+ self.ripple.paint(color="#000", opacity=0.12) # ③ 在重绘里贴一层
18
+ """
19
+
20
+ from .ripple import (
21
+ FRAME_MS,
22
+ RIPPLE_MS,
23
+ SUPERSAMPLE,
24
+ Ripple,
25
+ RippleState,
26
+ ease_out_cubic,
27
+ ripple_alpha,
28
+ ripple_layer,
29
+ ripple_radius,
30
+ )
31
+
32
+ __all__ = [
33
+ "Ripple",
34
+ "RippleState",
35
+ "RIPPLE_MS",
36
+ "FRAME_MS",
37
+ "SUPERSAMPLE",
38
+ "ease_out_cubic",
39
+ "ripple_alpha",
40
+ "ripple_radius",
41
+ "ripple_layer",
42
+ ]
@@ -0,0 +1,370 @@
1
+ """Material 涟漪效果的可复用零件:给任意画布控件加一层涟漪。
2
+
3
+ 这里**不含按钮**。控件的外观、状态、文字都由你自己画,只用这里的三样东西:
4
+
5
+ * :func:`ripple_layer` —— 算出某一帧的 RGBA 图层(圆角裁剪 + 抗锯齿 + 半透明);
6
+ * :class:`Ripple` —— 管理「谁在扩散、扩散到哪了」,到点回调你重绘;
7
+ * :func:`ease_out_cubic` / :func:`ripple_alpha` —— 想自己控制节奏时改这两条曲线。
8
+
9
+ 接到已有控件上通常只加三处::
10
+
11
+ from tkdeft.extra.ripple import Ripple
12
+
13
+ class MyButton(DDrawWidget):
14
+ def __init__(self, master, **kwargs):
15
+ super().__init__(master, **kwargs)
16
+ self.ripple = Ripple(self, on_frame=self._paint)
17
+
18
+ def _paint(self):
19
+ self.delete("all")
20
+ ...画底色...
21
+ self.ripple.paint(color="#000000", opacity=0.12, corner_radius=8)
22
+ ...画文字...
23
+
24
+ def _event_on_button1(self, event):
25
+ super()._event_on_button1(event)
26
+ self.ripple.start(event.x, event.y)
27
+
28
+ 每帧重绘由你决定:``Ripple`` 不碰 canvas item,只在 ``on_frame`` 时叫你一声,
29
+ 所以涟漪压在哪一层、和文字谁先谁后,完全由 ``paint()`` 的调用位置决定。
30
+
31
+ 不依赖 ``DDrawWidget`` 也行——任何有 ``create_image`` / ``winfo_width`` 的控件都能用。
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import math
37
+ import time
38
+ from dataclasses import dataclass
39
+ from typing import Callable, Dict, List, Optional
40
+
41
+ from PIL import Image, ImageChops, ImageDraw, ImageTk
42
+
43
+ __all__ = [
44
+ "RIPPLE_MS",
45
+ "FRAME_MS",
46
+ "SUPERSAMPLE",
47
+ "Ripple",
48
+ "RippleState",
49
+ "ease_out_cubic",
50
+ "ripple_alpha",
51
+ "ripple_radius",
52
+ "ripple_layer",
53
+ ]
54
+
55
+ #: 一次涟漪的总时长(毫秒)。Material 规范里大控件约 450–600ms。
56
+ RIPPLE_MS = 500
57
+
58
+ #: 动画帧间隔。16ms 约等于 60fps;渲染跟不上时 Tk 会自然丢帧。
59
+ FRAME_MS = 16
60
+
61
+ #: 栅格化涟漪时的超采样倍率(最后缩小,边缘才不锯齿)。
62
+ SUPERSAMPLE = 3
63
+
64
+
65
+ # ----------------------------------------------------------------------
66
+ # 数学与图层
67
+ # ----------------------------------------------------------------------
68
+ def ease_out_cubic(progress: float) -> float:
69
+ """Material 的「减速」曲线:起步快、收尾慢。
70
+
71
+ :param progress: ``0``–``1`` 的播放进度(超出范围会被夹到端点上)
72
+ :returns: ``0``–``1`` 的缓动值
73
+ """
74
+ progress = min(1.0, max(0.0, progress))
75
+ return 1.0 - (1.0 - progress) ** 3
76
+
77
+
78
+ def ripple_alpha(progress: float, peak: float = 0.12) -> float:
79
+ """涟漪的不透明度:前 15% 淡入,之后按幂曲线淡出。
80
+
81
+ :param progress: ``0``–``1`` 的播放进度
82
+ :param peak: 最浓时的 alpha
83
+ :returns: 该帧的 alpha;进度 0 与 1 都返回 ``0``
84
+ """
85
+ if progress <= 0.0 or progress >= 1.0:
86
+ return 0.0
87
+ fade_in = min(1.0, progress / 0.15)
88
+ fade_out = max(0.0, 1.0 - (progress - 0.15) / 0.85) ** 1.2
89
+ return peak * fade_in * fade_out
90
+
91
+
92
+ def ripple_radius(width: float, height: float, x: float, y: float) -> float:
93
+ """从 ``(x, y)`` 到最远角的距离——涟漪扩到它才算盖满整个控件。
94
+
95
+ :param width: 控件宽度
96
+ :param height: 控件高度
97
+ :param x: 圆心 x
98
+ :param y: 圆心 y
99
+ :returns: 需要扩散到的半径
100
+ """
101
+ return max(
102
+ math.hypot(corner_x - x, corner_y - y)
103
+ for corner_x, corner_y in ((0, 0), (width, 0), (0, height), (width, height))
104
+ )
105
+
106
+
107
+ def ripple_layer(
108
+ width: int,
109
+ height: int,
110
+ corner_radius: float,
111
+ x: float,
112
+ y: float,
113
+ progress: float,
114
+ color: str = "#000000",
115
+ peak_opacity: float = 0.12,
116
+ ) -> Image.Image:
117
+ """算出某一帧的涟漪图层(RGBA,尺寸就是控件尺寸)。
118
+
119
+ 为什么不用 ``Canvas.create_oval``:Tk 的 canvas item 没有 alpha——
120
+ 给 item 设 ``-alpha`` 是整块位图的透明度,而且**圆角遮不住**。
121
+ 这里改成在 Pillow 里先画「圆 ∩ 圆角矩形」的遮罩,再整体缩小:
122
+ 半透明、圆角裁剪、抗锯齿就都对了。
123
+
124
+ :param width: 控件宽度(像素)
125
+ :param height: 控件高度(像素)
126
+ :param corner_radius: 控件圆角半径,用来裁剪涟漪(矩形控件传 ``0``)
127
+ :param x: 涟漪圆心 x(控件坐标)
128
+ :param y: 涟漪圆心 y
129
+ :param progress: ``0``–``1`` 的播放进度
130
+ :param color: 涟漪颜色
131
+ :param peak_opacity: 最浓时的 alpha
132
+ :returns: 与控件等大的 RGBA 图层,进度 0/1 时完全透明
133
+ """
134
+ width = int(round(width))
135
+ height = int(round(height))
136
+ layer = Image.new("RGBA", (max(width, 1), max(height, 1)), (0, 0, 0, 0))
137
+ opacity = ripple_alpha(progress, peak_opacity)
138
+ if width <= 0 or height <= 0 or opacity <= 0.0:
139
+ return layer
140
+
141
+ scale = SUPERSAMPLE
142
+ size = (width * scale, height * scale)
143
+
144
+ # 1) 圆角矩形遮罩:涟漪不能漫出圆角
145
+ clip = Image.new("L", size, 0)
146
+ ImageDraw.Draw(clip).rounded_rectangle(
147
+ (0, 0, size[0] - 1, size[1] - 1), radius=corner_radius * scale, fill=255
148
+ )
149
+
150
+ # 2) 当前半径的圆
151
+ r = ripple_radius(width, height, x, y) * ease_out_cubic(progress) * scale
152
+ circle = Image.new("L", size, 0)
153
+ ImageDraw.Draw(circle).ellipse(
154
+ (x * scale - r, y * scale - r, x * scale + r, y * scale + r), fill=255
155
+ )
156
+
157
+ # 3) 交集 = 圆 ∩ 圆角矩形(darker 逐像素取较小值),再缩小做抗锯齿
158
+ mask = ImageChops.darker(clip, circle).resize((width, height), Image.LANCZOS)
159
+ mask = mask.point(lambda value: int(value * opacity))
160
+
161
+ layer = Image.new("RGBA", (width, height), color)
162
+ layer.putalpha(mask)
163
+ return layer
164
+
165
+
166
+ # ----------------------------------------------------------------------
167
+ # 状态与动画
168
+ # ----------------------------------------------------------------------
169
+ @dataclass
170
+ class RippleState:
171
+ """一次涟漪:圆心、开始时刻,以及「现在播到哪了」。"""
172
+
173
+ x: float
174
+ y: float
175
+ started: float
176
+
177
+ def progress(self, duration: float = RIPPLE_MS) -> float:
178
+ """当前进度(``0``–``1``),按 ``time.monotonic()`` 现算。"""
179
+ elapsed_ms = (time.monotonic() - self.started) * 1000.0
180
+ return min(1.0, max(0.0, elapsed_ms / duration))
181
+
182
+
183
+ class Ripple:
184
+ """把涟漪接到一个控件上:只管状态与计时,绘制时机交给你。
185
+
186
+ :param widget: 宿主控件(``DCanvas`` / ``DDrawWidget`` 或任何有
187
+ ``create_image`` / ``winfo_width`` 的 Tk 控件);用来取尺寸、贴图层
188
+ :param on_frame: 每帧回调,通常是宿主自己的重绘方法;``None`` 表示不自动刷新
189
+ :param duration: 一次涟漪的时长(毫秒),默认 :data:`RIPPLE_MS`
190
+ :param frame_ms: 帧间隔(毫秒),默认 :data:`FRAME_MS`
191
+
192
+ 控制器不保存颜色:颜色/浓度/圆角都在 :meth:`paint` 时传进来,所以同一份
193
+ 状态可以跟着主题换色,不需要重建对象。
194
+ """
195
+
196
+ def __init__(
197
+ self,
198
+ widget,
199
+ on_frame: Optional[Callable[[], None]] = None,
200
+ *,
201
+ duration: float = RIPPLE_MS,
202
+ frame_ms: int = FRAME_MS,
203
+ ):
204
+ self.widget = widget
205
+ self.on_frame = on_frame
206
+ self.duration = duration
207
+ self.frame_ms = frame_ms
208
+
209
+ self._states: List[RippleState] = []
210
+ self._job: Optional[str] = None
211
+ self._photos: Dict[int, object] = {}
212
+
213
+ # 宿主被销毁时把 after 撤掉,免得回调打到已销毁的控件上
214
+ try:
215
+ widget.bind("<Destroy>", self._event_destroy, add="+")
216
+ except Exception: # 不是 Tk 控件(测试替身)就跳过
217
+ pass
218
+
219
+ # ------------------------------------------------------------------
220
+ # 状态
221
+ # ------------------------------------------------------------------
222
+ @property
223
+ def states(self) -> List[RippleState]:
224
+ """正在播放的涟漪(只读副本,按开始顺序)。"""
225
+ return list(self._states)
226
+
227
+ @property
228
+ def active(self) -> bool:
229
+ """是否还有涟漪在播。"""
230
+ return bool(self._states)
231
+
232
+ def start(
233
+ self,
234
+ x: Optional[float] = None,
235
+ y: Optional[float] = None,
236
+ started: Optional[float] = None,
237
+ ) -> RippleState:
238
+ """从 ``(x, y)`` 起播一次涟漪;不传坐标按控件中心算。
239
+
240
+ :param x: 圆心 x(控件坐标)
241
+ :param y: 圆心 y
242
+ :param started: 起始时刻(``time.monotonic()``);给自检/截图复现用,
243
+ 传 ``time.monotonic() - 0.2`` 就等于快进到 40% 进度
244
+ :returns: 新加入的状态,可用它自己算进度
245
+ """
246
+ if x is None:
247
+ x = self.widget.winfo_width() / 2.0
248
+ if y is None:
249
+ y = self.widget.winfo_height() / 2.0
250
+ state = RippleState(
251
+ float(x), float(y), time.monotonic() if started is None else started
252
+ )
253
+ self._states.append(state)
254
+ self._schedule()
255
+ return state
256
+
257
+ def stop(self) -> None:
258
+ """立刻停掉所有涟漪并撤掉计时器。"""
259
+ self._stop_tick()
260
+ self._states.clear()
261
+
262
+ def advance(self) -> None:
263
+ """丢掉已经播完的涟漪(一般不用手调,内部计时器会做)。"""
264
+ self._states = [
265
+ state for state in self._states if state.progress(self.duration) < 1.0
266
+ ]
267
+
268
+ # ------------------------------------------------------------------
269
+ # 计时
270
+ # ------------------------------------------------------------------
271
+ def _schedule(self) -> None:
272
+ if self._job is None and self._states:
273
+ self._job = self.widget.after(self.frame_ms, self._tick)
274
+
275
+ def _stop_tick(self) -> None:
276
+ if self._job is not None:
277
+ self.widget.after_cancel(self._job)
278
+ self._job = None
279
+
280
+ def _tick(self) -> None:
281
+ self._job = None
282
+ if not self._states:
283
+ return
284
+ self.advance()
285
+ self._notify()
286
+ self._schedule()
287
+
288
+ def _notify(self) -> None:
289
+ if self.on_frame is not None:
290
+ self.on_frame()
291
+
292
+ # ------------------------------------------------------------------
293
+ # 绘制
294
+ # ------------------------------------------------------------------
295
+ def paint(
296
+ self,
297
+ color: str = "#000000",
298
+ opacity: float = 0.12,
299
+ corner_radius: float = 0.0,
300
+ tag: str = "ripple",
301
+ ) -> int:
302
+ """把所有正在播的涟漪画到宿主控件上,返回最后一张图层对应的 item。
303
+
304
+ 在你自己的 ``_paint`` 里调用——位置决定层级:先画底色再调它,
305
+ 涟漪就在底色之上;之后再画文字,文字就在最上面。
306
+
307
+ :param color: 涟漪颜色
308
+ :param opacity: 最浓时的 alpha
309
+ :param corner_radius: 控件圆角半径;矩形控件传 ``0``
310
+ :param tag: 给贴图打的 canvas tag,方便宿主用 ``delete(tag)`` 单独清理
311
+ :returns: 最后一张图层对应的 canvas item id;没有涟漪时返回 ``0``
312
+ """
313
+ self._prune_photos()
314
+ width = self.widget.winfo_width()
315
+ height = self.widget.winfo_height()
316
+ if width <= 1 or height <= 1:
317
+ return 0
318
+
319
+ last = 0
320
+ for state in self._states:
321
+ progress = state.progress(self.duration)
322
+ if progress >= 1.0:
323
+ continue
324
+ layer = ripple_layer(
325
+ width,
326
+ height,
327
+ corner_radius,
328
+ state.x,
329
+ state.y,
330
+ progress,
331
+ color=color,
332
+ peak_opacity=opacity,
333
+ )
334
+ photo = ImageTk.PhotoImage(layer, master=self.widget)
335
+ item = self.widget.create_image(
336
+ 0, 0, anchor="nw", image=photo, tags=(tag,)
337
+ )
338
+ self._keep_photo(item, photo)
339
+ last = item
340
+ return last
341
+
342
+ def _keep_photo(self, item: int, photo) -> None:
343
+ """Tk 不会自己保住 PhotoImage:交给 DCanvas 的缓存,否则自己记账。"""
344
+ keep = getattr(self.widget, "_keep_photo", None)
345
+ if callable(keep):
346
+ keep(item, photo)
347
+ return
348
+ if hasattr(self.widget, "_photo_refs") and isinstance(
349
+ self.widget._photo_refs, dict
350
+ ):
351
+ self.widget._photo_refs[item] = photo
352
+ return
353
+ self._photos[item] = photo
354
+
355
+ def _prune_photos(self) -> None:
356
+ """删掉已被宿主 ``delete`` 过的 item 对应的照片引用。"""
357
+ if not self._photos:
358
+ return
359
+ stale = [item for item in self._photos if not self.widget.type(item)]
360
+ for item in stale:
361
+ del self._photos[item]
362
+
363
+ # ------------------------------------------------------------------
364
+ # 生命周期
365
+ # ------------------------------------------------------------------
366
+ def _event_destroy(self, event=None) -> None:
367
+ if event is not None and getattr(event, "widget", None) is not self.widget:
368
+ return
369
+ self._stop_tick()
370
+ self._states.clear()
@@ -99,11 +99,21 @@ class DCanvas(Canvas):
99
99
  return item
100
100
 
101
101
  def _prune_photo_refs(self):
102
- """丢掉已经不在画布上的 item 所对应的图片引用。"""
102
+ """丢掉已经不在画布上的 item 所对应的图片引用。
103
+
104
+ .. note::
105
+ 判定"这个 item 还在不在"必须用 ``type()`` 的**返回值**:
106
+ 对已经删除的 item,Tk 返回的是 ``None`` 而不是抛异常。
107
+ 旧实现写成 ``except`` 收集,于是**一个也清不掉**——
108
+ 控件每重绘一次(每次 ``<Configure>``、每帧动画)就往
109
+ ``_photo_refs`` 里多塞一个引用,直到超过阈值后每次
110
+ ``_keep_photo`` 都要全表扫一遍,退化成平方复杂度。
111
+ """
103
112
  stale = []
104
113
  for item in self._photo_refs:
105
114
  try:
106
- self.type(item)
115
+ if not self.type(item):
116
+ stale.append(item)
107
117
  except Exception:
108
118
  stale.append(item)
109
119
  for item in stale:
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes