ctk-kit 0.1.0__py3-none-any.whl
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.
- ctk_kit/__init__.py +88 -0
- ctk_kit/buttons.py +233 -0
- ctk_kit/dropdown.py +264 -0
- ctk_kit/inputs.py +160 -0
- ctk_kit/layout.py +194 -0
- ctk_kit/py.typed +0 -0
- ctk_kit/scroll_list.py +332 -0
- ctk_kit/theme.py +72 -0
- ctk_kit/tokens.py +86 -0
- ctk_kit-0.1.0.dist-info/METADATA +204 -0
- ctk_kit-0.1.0.dist-info/RECORD +14 -0
- ctk_kit-0.1.0.dist-info/WHEEL +5 -0
- ctk_kit-0.1.0.dist-info/licenses/LICENSE +21 -0
- ctk_kit-0.1.0.dist-info/top_level.txt +1 -0
ctk_kit/__init__.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""ctk-kit —— 面向 CustomTkinter 的轻量界面基件。
|
|
2
|
+
|
|
3
|
+
分三层:
|
|
4
|
+
|
|
5
|
+
- ``tokens`` 设计令牌:颜色(深浅两套)、字体、圆角、间距
|
|
6
|
+
- ``theme`` 取色与外观切换工具,以及原生 tk 控件的重绘
|
|
7
|
+
- 组件 ``buttons`` / ``inputs`` / ``dropdown`` / ``layout`` / ``scroll_list``
|
|
8
|
+
|
|
9
|
+
安装::
|
|
10
|
+
|
|
11
|
+
pip install ctk-kit
|
|
12
|
+
|
|
13
|
+
最小示例::
|
|
14
|
+
|
|
15
|
+
import customtkinter as ctk
|
|
16
|
+
from ctk_kit import primary_button, card, page_header
|
|
17
|
+
|
|
18
|
+
app = ctk.CTk()
|
|
19
|
+
header, info = page_header(app, "备份")
|
|
20
|
+
box = card(app)
|
|
21
|
+
primary_button(box, "立即备份", command=lambda: print("go")).pack(padx=16, pady=16)
|
|
22
|
+
app.mainloop()
|
|
23
|
+
|
|
24
|
+
全组件展示(示例脚本,见仓库 examples/)::
|
|
25
|
+
|
|
26
|
+
python examples/showcase.py
|
|
27
|
+
|
|
28
|
+
仓库: https://gitee.com/kuangjianke/ctk-kit
|
|
29
|
+
镜像: https://github.com/DashSalter/ctk-kit
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
__version__ = "0.1.0"
|
|
35
|
+
|
|
36
|
+
from . import tokens, theme
|
|
37
|
+
from .buttons import (
|
|
38
|
+
FlatButton,
|
|
39
|
+
SoftButton,
|
|
40
|
+
danger_button,
|
|
41
|
+
primary_button,
|
|
42
|
+
soft_button,
|
|
43
|
+
)
|
|
44
|
+
from .dropdown import DropField
|
|
45
|
+
from .inputs import form_entry, segmented, switch, toolbar_entry
|
|
46
|
+
from .layout import (
|
|
47
|
+
card,
|
|
48
|
+
divider,
|
|
49
|
+
empty_hint,
|
|
50
|
+
field_label,
|
|
51
|
+
hint_label,
|
|
52
|
+
page_header,
|
|
53
|
+
scrollable,
|
|
54
|
+
section_title,
|
|
55
|
+
style_scrollable,
|
|
56
|
+
)
|
|
57
|
+
from .scroll_list import LazyList
|
|
58
|
+
from .theme import apply_appearance, color_of, is_dark, repaint_native
|
|
59
|
+
|
|
60
|
+
__all__ = [
|
|
61
|
+
"DropField",
|
|
62
|
+
"FlatButton",
|
|
63
|
+
"LazyList",
|
|
64
|
+
"SoftButton",
|
|
65
|
+
"__version__",
|
|
66
|
+
"apply_appearance",
|
|
67
|
+
"card",
|
|
68
|
+
"color_of",
|
|
69
|
+
"danger_button",
|
|
70
|
+
"divider",
|
|
71
|
+
"empty_hint",
|
|
72
|
+
"field_label",
|
|
73
|
+
"form_entry",
|
|
74
|
+
"hint_label",
|
|
75
|
+
"is_dark",
|
|
76
|
+
"page_header",
|
|
77
|
+
"primary_button",
|
|
78
|
+
"repaint_native",
|
|
79
|
+
"scrollable",
|
|
80
|
+
"section_title",
|
|
81
|
+
"segmented",
|
|
82
|
+
"soft_button",
|
|
83
|
+
"style_scrollable",
|
|
84
|
+
"switch",
|
|
85
|
+
"theme",
|
|
86
|
+
"tokens",
|
|
87
|
+
"toolbar_entry",
|
|
88
|
+
]
|
ctk_kit/buttons.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""按钮:主按钮、次要按钮、危险按钮,以及卡片里用的微型按钮。
|
|
2
|
+
|
|
3
|
+
尺寸约定:
|
|
4
|
+
- 主按钮 38 高、圆角 9 —— 保存、搜索这一层的动作
|
|
5
|
+
- 次要按钮 30 高、圆角 8 —— 取消、刷新、选择目录
|
|
6
|
+
- 微型按钮 由文字宽度算出来 —— 卡片里的「复制 / 显示 / 详情」
|
|
7
|
+
|
|
8
|
+
所有按钮工厂只返回 ``ctk.CTkButton``,不负责 ``pack`` / ``grid``,摆位交给调用方。
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import tkinter as tk
|
|
14
|
+
import tkinter.font as tkfont
|
|
15
|
+
from typing import Callable, Dict, Optional
|
|
16
|
+
|
|
17
|
+
import customtkinter as ctk
|
|
18
|
+
|
|
19
|
+
from . import tokens as T
|
|
20
|
+
from .theme import ColorToken, color_of
|
|
21
|
+
|
|
22
|
+
# 无参回调;CTk 的 command 就是这么调的,不会传入事件对象。
|
|
23
|
+
Command = Optional[Callable[[], None]]
|
|
24
|
+
|
|
25
|
+
# 同一套字体反复测量很浪费,按字体规格缓存度量对象。
|
|
26
|
+
# 注意:度量对象绑定在某个 Tk 解释器上,解释器销毁后需要清空(测试里有 fixture)。
|
|
27
|
+
_MEASURERS: Dict[tuple, tkfont.Font] = {}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _measurer(font_spec: T.FontSpec) -> tkfont.Font:
|
|
31
|
+
"""返回 ``font_spec`` 对应的 ``tkfont.Font`` 度量对象,并按规格缓存。
|
|
32
|
+
|
|
33
|
+
参数:
|
|
34
|
+
font_spec: ``(字族, 字号)`` 或 ``(字族, 字号, "bold")``。
|
|
35
|
+
|
|
36
|
+
返回:
|
|
37
|
+
可用于 ``measure()`` / ``metrics()`` 的字体对象;相同规格返回同一实例。
|
|
38
|
+
"""
|
|
39
|
+
key = tuple(font_spec)
|
|
40
|
+
cached = _MEASURERS.get(key)
|
|
41
|
+
if cached is None:
|
|
42
|
+
cached = tkfont.Font(font=font_spec)
|
|
43
|
+
_MEASURERS[key] = cached
|
|
44
|
+
return cached
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def primary_button(
|
|
48
|
+
master: tk.Misc,
|
|
49
|
+
text: str,
|
|
50
|
+
command: Command = None,
|
|
51
|
+
*,
|
|
52
|
+
width: int = 120,
|
|
53
|
+
height: int = 38,
|
|
54
|
+
radius: int = T.RADIUS_SMALL + 1,
|
|
55
|
+
font: T.FontSpec = T.FONT_BODY,
|
|
56
|
+
) -> ctk.CTkButton:
|
|
57
|
+
"""强调色主按钮(保存 / 搜索 / 立即备份)。
|
|
58
|
+
|
|
59
|
+
参数:
|
|
60
|
+
master: 父容器。
|
|
61
|
+
text: 按钮文字。
|
|
62
|
+
command: 点击回调,不接收参数。
|
|
63
|
+
width: 宽度(像素),默认 120。
|
|
64
|
+
height: 高度(像素),默认 38。
|
|
65
|
+
radius: 圆角半径,默认 9。
|
|
66
|
+
font: 字体规格,默认 :data:`tokens.FONT_BODY`。
|
|
67
|
+
|
|
68
|
+
返回:
|
|
69
|
+
尚未摆位的 ``CTkButton``。
|
|
70
|
+
"""
|
|
71
|
+
return ctk.CTkButton(
|
|
72
|
+
master,
|
|
73
|
+
text=text,
|
|
74
|
+
width=width,
|
|
75
|
+
height=height,
|
|
76
|
+
corner_radius=radius,
|
|
77
|
+
font=font,
|
|
78
|
+
fg_color=T.COLOR_ACCENT,
|
|
79
|
+
hover_color=T.COLOR_ACCENT_HOVER,
|
|
80
|
+
text_color=T.COLOR_ACCENT_TEXT,
|
|
81
|
+
command=command,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def soft_button(
|
|
86
|
+
master: tk.Misc,
|
|
87
|
+
text: str,
|
|
88
|
+
command: Command = None,
|
|
89
|
+
*,
|
|
90
|
+
width: int = 76,
|
|
91
|
+
height: int = 30,
|
|
92
|
+
font: T.FontSpec = T.FONT_SMALL,
|
|
93
|
+
border: bool = False,
|
|
94
|
+
) -> ctk.CTkButton:
|
|
95
|
+
"""浅底次要按钮。
|
|
96
|
+
|
|
97
|
+
``border=True`` 时加一圈细边,适合「取消 / 清除」这类不该抢视线的动作。
|
|
98
|
+
|
|
99
|
+
参数:
|
|
100
|
+
master: 父容器。
|
|
101
|
+
text: 按钮文字。
|
|
102
|
+
command: 点击回调,不接收参数。
|
|
103
|
+
width: 宽度(像素),默认 76。
|
|
104
|
+
height: 高度(像素),默认 30;高度超过 30 时圆角自动加 1。
|
|
105
|
+
font: 字体规格,默认 :data:`tokens.FONT_SMALL`。
|
|
106
|
+
border: 是否显示 1px 边框。
|
|
107
|
+
|
|
108
|
+
返回:
|
|
109
|
+
尚未摆位的 ``CTkButton``。
|
|
110
|
+
"""
|
|
111
|
+
return ctk.CTkButton(
|
|
112
|
+
master,
|
|
113
|
+
text=text,
|
|
114
|
+
width=width,
|
|
115
|
+
height=height,
|
|
116
|
+
corner_radius=T.RADIUS_SMALL if height <= 30 else T.RADIUS_SMALL + 1,
|
|
117
|
+
font=font,
|
|
118
|
+
fg_color=T.COLOR_SOFT,
|
|
119
|
+
hover_color=T.COLOR_SOFT_HOVER,
|
|
120
|
+
text_color=T.COLOR_TEXT,
|
|
121
|
+
border_width=1 if border else 0,
|
|
122
|
+
border_color=T.COLOR_BORDER if border else T.COLOR_SOFT,
|
|
123
|
+
command=command,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def danger_button(
|
|
128
|
+
master: tk.Misc,
|
|
129
|
+
text: str,
|
|
130
|
+
command: Command = None,
|
|
131
|
+
*,
|
|
132
|
+
width: int = 76,
|
|
133
|
+
height: int = 30,
|
|
134
|
+
font: T.FontSpec = T.FONT_SMALL,
|
|
135
|
+
) -> ctk.CTkButton:
|
|
136
|
+
"""危险动作(删除 / 清空全部):淡红底 + 红字,比纯红按钮安静。
|
|
137
|
+
|
|
138
|
+
参数:
|
|
139
|
+
master: 父容器。
|
|
140
|
+
text: 按钮文字。
|
|
141
|
+
command: 点击回调,不接收参数。
|
|
142
|
+
width: 宽度(像素),默认 76。
|
|
143
|
+
height: 高度(像素),默认 30。
|
|
144
|
+
font: 字体规格,默认 :data:`tokens.FONT_SMALL`。
|
|
145
|
+
|
|
146
|
+
返回:
|
|
147
|
+
尚未摆位的 ``CTkButton``。
|
|
148
|
+
"""
|
|
149
|
+
return ctk.CTkButton(
|
|
150
|
+
master,
|
|
151
|
+
text=text,
|
|
152
|
+
width=width,
|
|
153
|
+
height=height,
|
|
154
|
+
corner_radius=T.RADIUS_SMALL,
|
|
155
|
+
font=font,
|
|
156
|
+
fg_color=T.COLOR_DANGER_SOFT,
|
|
157
|
+
hover_color=T.COLOR_DANGER_SOFT_HOVER,
|
|
158
|
+
text_color=T.COLOR_DANGER,
|
|
159
|
+
command=command,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class FlatButton(ctk.CTkButton):
|
|
164
|
+
"""卡片里的小号圆角按钮(复制 / 显示 / 详情)。
|
|
165
|
+
|
|
166
|
+
宽度按文字实测宽度算出来,既不浪费横向空间,也不会把文字挤掉。
|
|
167
|
+
颜色默认取当前外观下的浅色/深色单值(经 :func:`theme.color_of`),
|
|
168
|
+
所以深浅色切换后需要重新创建才能刷新。
|
|
169
|
+
"""
|
|
170
|
+
|
|
171
|
+
def __init__(
|
|
172
|
+
self,
|
|
173
|
+
master: tk.Misc,
|
|
174
|
+
text: str,
|
|
175
|
+
command: Command = None,
|
|
176
|
+
*,
|
|
177
|
+
bg: Optional[ColorToken] = None,
|
|
178
|
+
hover: Optional[ColorToken] = None,
|
|
179
|
+
fg: Optional[ColorToken] = None,
|
|
180
|
+
font: T.FontSpec = T.FONT_TINY,
|
|
181
|
+
padx: int = 10,
|
|
182
|
+
pady: int = 3,
|
|
183
|
+
radius: int = T.RADIUS_SMALL,
|
|
184
|
+
) -> None:
|
|
185
|
+
"""创建按文字宽度自适应的微型按钮。
|
|
186
|
+
|
|
187
|
+
参数:
|
|
188
|
+
master: 父容器。
|
|
189
|
+
text: 按钮文字;宽度由它决定。
|
|
190
|
+
command: 点击回调,不接收参数。
|
|
191
|
+
bg: 底色,默认 ``tokens.COLOR_SOFT``。
|
|
192
|
+
hover: 悬停底色,默认 ``tokens.COLOR_SOFT_HOVER``。
|
|
193
|
+
fg: 文字颜色,默认 ``tokens.COLOR_TEXT``。
|
|
194
|
+
font: 字体规格,默认 :data:`tokens.FONT_TINY`。
|
|
195
|
+
padx: 文字左右各留的空白(像素),默认 10。
|
|
196
|
+
pady: 文字上下各留的空白(像素),默认 3。
|
|
197
|
+
radius: 圆角半径,默认 :data:`tokens.RADIUS_SMALL`。
|
|
198
|
+
"""
|
|
199
|
+
measure = _measurer(font)
|
|
200
|
+
super().__init__(
|
|
201
|
+
master,
|
|
202
|
+
text=text,
|
|
203
|
+
command=command,
|
|
204
|
+
width=measure.measure(text) + padx * 2,
|
|
205
|
+
height=measure.metrics("linespace") + pady * 2,
|
|
206
|
+
corner_radius=radius,
|
|
207
|
+
font=font,
|
|
208
|
+
fg_color=bg if bg is not None else color_of(T.COLOR_SOFT),
|
|
209
|
+
hover_color=hover if hover is not None else color_of(T.COLOR_SOFT_HOVER),
|
|
210
|
+
text_color=fg if fg is not None else color_of(T.COLOR_TEXT),
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class SoftButton(FlatButton):
|
|
215
|
+
"""更安静的小按钮:灰字、更小的内边距(复制 / 显示 / 收起)。"""
|
|
216
|
+
|
|
217
|
+
def __init__(self, master: tk.Misc, text: str, command: Command = None) -> None:
|
|
218
|
+
"""创建灰字小按钮。
|
|
219
|
+
|
|
220
|
+
参数:
|
|
221
|
+
master: 父容器。
|
|
222
|
+
text: 按钮文字。
|
|
223
|
+
command: 点击回调,不接收参数。
|
|
224
|
+
"""
|
|
225
|
+
super().__init__(
|
|
226
|
+
master,
|
|
227
|
+
text,
|
|
228
|
+
command,
|
|
229
|
+
fg=color_of(T.COLOR_MUTED),
|
|
230
|
+
padx=8,
|
|
231
|
+
pady=2,
|
|
232
|
+
radius=T.RADIUS_SMALL - 2,
|
|
233
|
+
)
|
ctk_kit/dropdown.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""下拉输入框:输入框 + 右侧箭头,候选用 UI Kit 自己的卡片弹出。
|
|
2
|
+
|
|
3
|
+
不用 CTkComboBox:它底层是 tkinter.Menu(Windows 原生菜单),带粗白边、
|
|
4
|
+
直角和系统字体,和这套深色卡片的观感对不上。
|
|
5
|
+
|
|
6
|
+
箭头用字体里的实心三角字符,不做任何自绘——既不会有锯齿,也不会在输入框
|
|
7
|
+
内部留下色块。
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import contextlib
|
|
13
|
+
import sys
|
|
14
|
+
import tkinter as tk
|
|
15
|
+
from typing import Callable, Optional, Sequence
|
|
16
|
+
|
|
17
|
+
import customtkinter as ctk
|
|
18
|
+
|
|
19
|
+
from . import tokens as T
|
|
20
|
+
|
|
21
|
+
_ARROW_CLOSED = "▼"
|
|
22
|
+
_ARROW_OPEN = "▲"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class DropField(ctk.CTkFrame):
|
|
26
|
+
"""输入框 + 下拉箭头,整体是一个带描边的圆角框。
|
|
27
|
+
|
|
28
|
+
用法与输入框接近:``get()`` / ``set(value)``;选中候选后会回调
|
|
29
|
+
``command(value)``。候选由 ``values_provider()`` 提供,每次展开都会重新
|
|
30
|
+
调用,所以数据源可以在外部随意变化。
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
master: tk.Misc,
|
|
36
|
+
*,
|
|
37
|
+
values_provider: Callable[[], Sequence[str]],
|
|
38
|
+
command: Optional[Callable[[str], None]] = None,
|
|
39
|
+
placeholder: str = "",
|
|
40
|
+
height: int = 36,
|
|
41
|
+
) -> None:
|
|
42
|
+
"""创建下拉输入框。
|
|
43
|
+
|
|
44
|
+
参数:
|
|
45
|
+
master: 父容器。
|
|
46
|
+
values_provider: 返回候选列表的函数,每次展开下拉都会调用一次。
|
|
47
|
+
command: 选中候选后的回调,参数是被选中的字符串。
|
|
48
|
+
placeholder: 输入框为空时的灰色占位文字。
|
|
49
|
+
height: 整体高度(像素),默认 36。
|
|
50
|
+
"""
|
|
51
|
+
super().__init__(
|
|
52
|
+
master,
|
|
53
|
+
fg_color=T.COLOR_INPUT,
|
|
54
|
+
border_width=1,
|
|
55
|
+
border_color=T.COLOR_BORDER,
|
|
56
|
+
corner_radius=T.RADIUS_SMALL,
|
|
57
|
+
height=height,
|
|
58
|
+
)
|
|
59
|
+
self.pack_propagate(False)
|
|
60
|
+
self._values_provider: Callable[[], Sequence[str]] = values_provider
|
|
61
|
+
self._command: Optional[Callable[[str], None]] = command
|
|
62
|
+
self._popup: Optional[ctk.CTkToplevel] = None
|
|
63
|
+
self._root_bind_id: Optional[str] = None
|
|
64
|
+
self._focus_bind_id: Optional[str] = None
|
|
65
|
+
|
|
66
|
+
self.entry: ctk.CTkEntry = ctk.CTkEntry(
|
|
67
|
+
self,
|
|
68
|
+
fg_color="transparent",
|
|
69
|
+
border_width=0,
|
|
70
|
+
corner_radius=0,
|
|
71
|
+
height=height - 2,
|
|
72
|
+
font=T.FONT_BODY,
|
|
73
|
+
text_color=T.COLOR_TEXT,
|
|
74
|
+
placeholder_text=placeholder,
|
|
75
|
+
placeholder_text_color=T.COLOR_FAINT,
|
|
76
|
+
)
|
|
77
|
+
self.entry.pack(side="left", fill="both", expand=True, padx=(12, 0), pady=1)
|
|
78
|
+
|
|
79
|
+
self.arrow: ctk.CTkLabel = ctk.CTkLabel(
|
|
80
|
+
self,
|
|
81
|
+
text=_ARROW_CLOSED,
|
|
82
|
+
width=34,
|
|
83
|
+
height=26,
|
|
84
|
+
font=(T.FONT_NAME, 11),
|
|
85
|
+
text_color=T.COLOR_MUTED,
|
|
86
|
+
fg_color="transparent",
|
|
87
|
+
corner_radius=5,
|
|
88
|
+
anchor="center",
|
|
89
|
+
)
|
|
90
|
+
self.arrow.pack(side="right", padx=(0, 4), pady=1)
|
|
91
|
+
self.arrow.configure(cursor="hand2")
|
|
92
|
+
self.arrow.bind("<Button-1>", lambda _event: self.open_menu())
|
|
93
|
+
self.arrow.bind("<Enter>", lambda _event: self._set_hover(True))
|
|
94
|
+
self.arrow.bind("<Leave>", lambda _event: self._set_hover(False))
|
|
95
|
+
|
|
96
|
+
def _set_hover(self, hover: bool) -> None:
|
|
97
|
+
"""箭头悬停态:文字与底色一起变。"""
|
|
98
|
+
self.arrow.configure(
|
|
99
|
+
text_color=T.COLOR_TEXT if hover else T.COLOR_MUTED,
|
|
100
|
+
fg_color=T.COLOR_HOVER_FAINT if hover else "transparent",
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
# --- 读写 ---
|
|
104
|
+
def get(self) -> str:
|
|
105
|
+
"""当前输入框里的文字。"""
|
|
106
|
+
return self.entry.get()
|
|
107
|
+
|
|
108
|
+
def set(self, value: str) -> None:
|
|
109
|
+
"""直接写入一个值(不触发 ``command``)。"""
|
|
110
|
+
self.entry.delete(0, "end")
|
|
111
|
+
self.entry.insert(0, value)
|
|
112
|
+
|
|
113
|
+
# --- 下拉面板 ---
|
|
114
|
+
def open_menu(self) -> None:
|
|
115
|
+
"""展开候选面板;已经展开时再次调用会收起。"""
|
|
116
|
+
if self._popup is not None and self._popup.winfo_exists():
|
|
117
|
+
self._close_menu()
|
|
118
|
+
return
|
|
119
|
+
|
|
120
|
+
values = list(self._values_provider() or [])
|
|
121
|
+
if not values:
|
|
122
|
+
return
|
|
123
|
+
|
|
124
|
+
popup = ctk.CTkToplevel(self)
|
|
125
|
+
popup.withdraw()
|
|
126
|
+
popup.overrideredirect(True)
|
|
127
|
+
popup.attributes("-topmost", True)
|
|
128
|
+
popup.configure(fg_color=T.COLOR_CARD)
|
|
129
|
+
|
|
130
|
+
body = ctk.CTkFrame(
|
|
131
|
+
popup,
|
|
132
|
+
fg_color=T.COLOR_CARD,
|
|
133
|
+
corner_radius=0,
|
|
134
|
+
border_width=1,
|
|
135
|
+
border_color=T.COLOR_BORDER,
|
|
136
|
+
)
|
|
137
|
+
body.pack(fill="both", expand=True)
|
|
138
|
+
|
|
139
|
+
for value in values:
|
|
140
|
+
ctk.CTkButton(
|
|
141
|
+
body,
|
|
142
|
+
text=value,
|
|
143
|
+
anchor="w",
|
|
144
|
+
height=32,
|
|
145
|
+
corner_radius=T.RADIUS_SMALL,
|
|
146
|
+
fg_color="transparent",
|
|
147
|
+
hover_color=T.COLOR_SOFT_HOVER,
|
|
148
|
+
text_color=T.COLOR_TEXT,
|
|
149
|
+
font=T.FONT_SMALL,
|
|
150
|
+
command=lambda v=value: self._pick(v),
|
|
151
|
+
).pack(fill="x", padx=5, pady=1)
|
|
152
|
+
|
|
153
|
+
popup.update_idletasks()
|
|
154
|
+
# 宽高按逻辑像素缩放,位置参数直接是物理像素
|
|
155
|
+
scaling = self.winfo_toplevel()._get_window_scaling()
|
|
156
|
+
width = max(self.winfo_width(), int(320 * scaling)) / scaling
|
|
157
|
+
height = (popup.winfo_reqheight() + 2) / scaling
|
|
158
|
+
popup.geometry(
|
|
159
|
+
f"{int(width)}x{int(height)}+"
|
|
160
|
+
f"{self.winfo_rootx()}+{self.winfo_rooty() + self.winfo_height() + 2}"
|
|
161
|
+
)
|
|
162
|
+
popup.deiconify()
|
|
163
|
+
popup.bind("<Escape>", self._close_menu)
|
|
164
|
+
self._round_window_corners(popup)
|
|
165
|
+
|
|
166
|
+
self._popup = popup
|
|
167
|
+
self.arrow.configure(text=_ARROW_OPEN)
|
|
168
|
+
# 点击面板以外的地方收起:绑在顶层窗口上(子控件的点击也会冒泡到顶层)
|
|
169
|
+
self._root_bind_id = self.winfo_toplevel().bind(
|
|
170
|
+
"<Button-1>", self._maybe_close_menu, add="+"
|
|
171
|
+
)
|
|
172
|
+
# 焦点离开本应用(切到别的程序)时也收起,避免面板一直浮在最上面
|
|
173
|
+
self._focus_bind_id = self.winfo_toplevel().bind(
|
|
174
|
+
"<FocusOut>", self._on_root_focus_out, add="+"
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
def _on_root_focus_out(self, _event: Optional[tk.Event] = None) -> None:
|
|
178
|
+
"""顶层窗口失焦后稍等再检查焦点,避免刚点面板就被收起。"""
|
|
179
|
+
self.after(100, self._check_focus)
|
|
180
|
+
|
|
181
|
+
def _check_focus(self) -> None:
|
|
182
|
+
"""焦点既不在面板、也不在本窗口时收起面板。"""
|
|
183
|
+
if self._popup is None:
|
|
184
|
+
return
|
|
185
|
+
try:
|
|
186
|
+
focused = self.focus_get()
|
|
187
|
+
except Exception:
|
|
188
|
+
focused = None
|
|
189
|
+
if focused is not None:
|
|
190
|
+
widget_name = str(focused)
|
|
191
|
+
if widget_name.startswith(str(self._popup)) or widget_name.startswith(
|
|
192
|
+
str(self.winfo_toplevel())
|
|
193
|
+
):
|
|
194
|
+
return
|
|
195
|
+
self._close_menu()
|
|
196
|
+
|
|
197
|
+
def _round_window_corners(self, popup: ctk.CTkToplevel) -> None:
|
|
198
|
+
"""把窗口本身裁成圆角。
|
|
199
|
+
|
|
200
|
+
tk 的窗口永远是直角,靠透明键色抠角又会留下一圈抗锯齿杂边,
|
|
201
|
+
这里直接用 Windows 的 SetWindowRgn 把窗口区域切掉,四角干净没有残色。
|
|
202
|
+
非 Windows 平台什么都不做。
|
|
203
|
+
"""
|
|
204
|
+
if sys.platform != "win32":
|
|
205
|
+
return
|
|
206
|
+
with contextlib.suppress(Exception):
|
|
207
|
+
import ctypes
|
|
208
|
+
|
|
209
|
+
popup.update_idletasks()
|
|
210
|
+
hwnd = ctypes.windll.user32.GetParent(popup.winfo_id()) or popup.winfo_id()
|
|
211
|
+
scaling = self.winfo_toplevel()._get_window_scaling()
|
|
212
|
+
radius = int(T.RADIUS_MEDIUM * 2 * scaling)
|
|
213
|
+
width = popup.winfo_width()
|
|
214
|
+
height = popup.winfo_height()
|
|
215
|
+
region = ctypes.windll.gdi32.CreateRoundRectRgn(
|
|
216
|
+
0, 0, width + 1, height + 1, radius, radius
|
|
217
|
+
)
|
|
218
|
+
ctypes.windll.user32.SetWindowRgn(hwnd, region, True)
|
|
219
|
+
|
|
220
|
+
def _maybe_close_menu(self, event: tk.Event) -> None:
|
|
221
|
+
"""顶层窗口上的点击:点在面板外就收起。"""
|
|
222
|
+
popup = self._popup
|
|
223
|
+
if popup is None or not popup.winfo_exists():
|
|
224
|
+
return
|
|
225
|
+
if (
|
|
226
|
+
popup.winfo_rootx()
|
|
227
|
+
<= event.x_root
|
|
228
|
+
<= popup.winfo_rootx() + popup.winfo_width()
|
|
229
|
+
and popup.winfo_rooty()
|
|
230
|
+
<= event.y_root
|
|
231
|
+
<= popup.winfo_rooty() + popup.winfo_height()
|
|
232
|
+
):
|
|
233
|
+
return
|
|
234
|
+
self._close_menu()
|
|
235
|
+
|
|
236
|
+
def _pick(self, value: str) -> None:
|
|
237
|
+
"""选中候选:写入值、收起面板、回调。"""
|
|
238
|
+
self.set(value)
|
|
239
|
+
self._close_menu()
|
|
240
|
+
if self._command is not None:
|
|
241
|
+
self._command(value)
|
|
242
|
+
|
|
243
|
+
def _close_menu(self, _event: Optional[tk.Event] = None) -> None:
|
|
244
|
+
"""收起面板并清理绑定;重复调用安全。"""
|
|
245
|
+
if self._root_bind_id is not None:
|
|
246
|
+
with contextlib.suppress(Exception):
|
|
247
|
+
self.winfo_toplevel().unbind("<Button-1>", self._root_bind_id)
|
|
248
|
+
self._root_bind_id = None
|
|
249
|
+
if self._focus_bind_id is not None:
|
|
250
|
+
with contextlib.suppress(Exception):
|
|
251
|
+
self.winfo_toplevel().unbind("<FocusOut>", self._focus_bind_id)
|
|
252
|
+
self._focus_bind_id = None
|
|
253
|
+
with contextlib.suppress(Exception):
|
|
254
|
+
self.arrow.configure(text=_ARROW_CLOSED)
|
|
255
|
+
if self._popup is not None:
|
|
256
|
+
with contextlib.suppress(Exception):
|
|
257
|
+
if self._popup.winfo_exists():
|
|
258
|
+
self._popup.destroy()
|
|
259
|
+
self._popup = None
|
|
260
|
+
|
|
261
|
+
def destroy(self) -> None:
|
|
262
|
+
"""销毁前先收起面板,避免浮窗残留。"""
|
|
263
|
+
self._close_menu()
|
|
264
|
+
super().destroy()
|