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 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()