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.
- {tkdeft-0.3.0 → tkdeft-0.4.0}/PKG-INFO +26 -1
- {tkdeft-0.3.0 → tkdeft-0.4.0}/README.md +25 -0
- {tkdeft-0.3.0 → tkdeft-0.4.0}/pyproject.toml +1 -1
- {tkdeft-0.3.0 → tkdeft-0.4.0}/tkdeft/__init__.py +32 -2
- tkdeft-0.4.0/tkdeft/extra/__init__.py +42 -0
- tkdeft-0.4.0/tkdeft/extra/ripple.py +370 -0
- {tkdeft-0.3.0 → tkdeft-0.4.0}/tkdeft/windows/canvas.py +12 -2
- {tkdeft-0.3.0 → tkdeft-0.4.0}/tkdeft/engines/__init__.py +0 -0
- {tkdeft-0.3.0 → tkdeft-0.4.0}/tkdeft/engines/base.py +0 -0
- {tkdeft-0.3.0 → tkdeft-0.4.0}/tkdeft/engines/cache.py +0 -0
- {tkdeft-0.3.0 → tkdeft-0.4.0}/tkdeft/engines/cairo_engine.py +0 -0
- {tkdeft-0.3.0 → tkdeft-0.4.0}/tkdeft/engines/colors.py +0 -0
- {tkdeft-0.3.0 → tkdeft-0.4.0}/tkdeft/engines/pillow_engine.py +0 -0
- {tkdeft-0.3.0 → tkdeft-0.4.0}/tkdeft/engines/skia_engine.py +0 -0
- {tkdeft-0.3.0 → tkdeft-0.4.0}/tkdeft/object.py +0 -0
- {tkdeft-0.3.0 → tkdeft-0.4.0}/tkdeft/svg.py +0 -0
- {tkdeft-0.3.0 → tkdeft-0.4.0}/tkdeft/utility/__init__.py +0 -0
- {tkdeft-0.3.0 → tkdeft-0.4.0}/tkdeft/utility/fonts.py +0 -0
- {tkdeft-0.3.0 → tkdeft-0.4.0}/tkdeft/utility/segoe_font.ttf +0 -0
- {tkdeft-0.3.0 → tkdeft-0.4.0}/tkdeft/windows/__init__.py +0 -0
- {tkdeft-0.3.0 → tkdeft-0.4.0}/tkdeft/windows/draw.py +0 -0
- {tkdeft-0.3.0 → tkdeft-0.4.0}/tkdeft/windows/drawwidget.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tkdeft
|
|
3
|
-
Version: 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) |
|
|
@@ -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.
|
|
103
|
+
__version__ = "0.4.0"
|
|
91
104
|
|
|
92
|
-
#: :data:`__version__` 的数字形式,便于比较:``(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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|