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/inputs.py ADDED
@@ -0,0 +1,160 @@
1
+ """输入控件:工具栏搜索框、分段选择、表单输入框。
2
+
3
+ 分段选择(CTkSegmentedButton)有几个必须显式指定的参数,否则深浅色的观感
4
+ 会不一致:
5
+
6
+ - ``fg_color`` / ``unselected_color`` 用输入框底色,否则默认是灰色
7
+ - ``selected_color`` 用淡强调色,避免整块高饱和蓝
8
+ - ``dynamic_resizing=False`` 否则切换选项会改控件宽度,把右边的元素挤动
9
+ - 不能传 ``border_width`` / ``border_color``,CTk 会直接抛 ValueError
10
+
11
+ 所有工厂只返回控件,不负责摆位。
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import tkinter as tk
17
+ from typing import Callable, Optional, Sequence
18
+
19
+ import customtkinter as ctk
20
+
21
+ from . import tokens as T
22
+
23
+ # 分段选择的选中回调:收到被选中的那一段文字。
24
+ SegmentCommand = Optional[Callable[[str], None]]
25
+ # 开关回调:不接收参数(CTk 的约定)。
26
+ SwitchCommand = Optional[Callable[[], None]]
27
+
28
+
29
+ def toolbar_entry(master: tk.Misc, placeholder: str, *, height: int = 40) -> ctk.CTkEntry:
30
+ """页面顶部搜索框:聚焦时描边变强调色。
31
+
32
+ 参数:
33
+ master: 父容器。
34
+ placeholder: 未输入时显示的灰色占位文字。
35
+ height: 高度(像素),默认 40。
36
+
37
+ 返回:
38
+ 已经绑好焦点样式、尚未摆位的 ``CTkEntry``。
39
+ """
40
+ entry = ctk.CTkEntry(
41
+ master,
42
+ placeholder_text=placeholder,
43
+ placeholder_text_color=T.COLOR_FAINT,
44
+ height=height,
45
+ corner_radius=T.RADIUS_LARGE,
46
+ font=T.FONT_BODY,
47
+ fg_color=T.COLOR_INPUT,
48
+ border_color=T.COLOR_BORDER,
49
+ border_width=1,
50
+ text_color=T.COLOR_TEXT,
51
+ )
52
+ entry.bind("<FocusIn>", lambda _event: entry.configure(border_color=T.COLOR_ACCENT))
53
+ entry.bind(
54
+ "<FocusOut>", lambda _event: entry.configure(border_color=T.COLOR_BORDER)
55
+ )
56
+ return entry
57
+
58
+
59
+ def form_entry(
60
+ master: tk.Misc,
61
+ *,
62
+ show: str = "",
63
+ placeholder: str = "",
64
+ height: int = 38,
65
+ password: bool = False,
66
+ ) -> ctk.CTkEntry:
67
+ """表单里的单行输入。
68
+
69
+ ``password=True`` 时用圆点打码。注意 CTkEntry 带了 ``placeholder_text``
70
+ 时,内部 Entry 的 ``show`` 会被占位符机制置空,``cget("show")`` 读到的是
71
+ 空字符串——要判断是否在打码,得自己记住状态,别去读控件。
72
+
73
+ 参数:
74
+ master: 父容器。
75
+ show: 自定义替代字符(例如 ``"*"``),空字符串表示正常显示。
76
+ placeholder: 未输入时的灰色占位文字。
77
+ height: 高度(像素),默认 38。
78
+ password: 为 ``True`` 时用圆点 ``"•"`` 打码,优先级高于 ``show``。
79
+
80
+ 返回:
81
+ 尚未摆位的 ``CTkEntry``。
82
+ """
83
+ return ctk.CTkEntry(
84
+ master,
85
+ show=("•" if password else show) or "",
86
+ placeholder_text=placeholder,
87
+ placeholder_text_color=T.COLOR_FAINT,
88
+ height=height,
89
+ corner_radius=T.RADIUS_SMALL,
90
+ font=T.FONT_BODY,
91
+ fg_color=T.COLOR_INPUT,
92
+ border_color=T.COLOR_BORDER,
93
+ border_width=1,
94
+ text_color=T.COLOR_TEXT,
95
+ )
96
+
97
+
98
+ def segmented(
99
+ master: tk.Misc,
100
+ values: Sequence[str],
101
+ command: SegmentCommand = None,
102
+ *,
103
+ width: int = 300,
104
+ height: int = 34,
105
+ font: T.FontSpec = T.FONT_SMALL,
106
+ ) -> ctk.CTkSegmentedButton:
107
+ """统一样式的分段选择:底色同输入框,选中段淡色高亮。
108
+
109
+ 参数:
110
+ master: 父容器。
111
+ values: 各段文字,按显示顺序排列;非空时默认选中第一项。
112
+ command: 切换回调,参数是被选中的那一段文字。
113
+ width: 整体宽度(像素),默认 300;``dynamic_resizing=False`` 保证
114
+ 切换选项时宽度不变。
115
+ height: 高度(像素),默认 34。
116
+ font: 字体规格,默认 :data:`tokens.FONT_SMALL`。
117
+
118
+ 返回:
119
+ 尚未摆位的 ``CTkSegmentedButton``。
120
+ """
121
+ button = ctk.CTkSegmentedButton(
122
+ master,
123
+ values=values,
124
+ width=width,
125
+ height=height,
126
+ corner_radius=T.RADIUS_SMALL,
127
+ fg_color=T.COLOR_INPUT,
128
+ unselected_color=T.COLOR_INPUT,
129
+ selected_color=T.COLOR_SEG_SELECTED,
130
+ unselected_hover_color=T.COLOR_SOFT,
131
+ selected_hover_color=T.COLOR_SEG_SELECTED,
132
+ text_color=T.COLOR_TEXT,
133
+ font=font,
134
+ dynamic_resizing=False,
135
+ command=command,
136
+ )
137
+ if values:
138
+ # 不留「一段都没选中」的中间态
139
+ button.set(values[0])
140
+ return button
141
+
142
+
143
+ def switch(master: tk.Misc, command: SwitchCommand = None, *, width: int = 44) -> ctk.CTkSwitch:
144
+ """设置页的开关:去掉文字,只留轨道,颜色跟随主色。
145
+
146
+ 参数:
147
+ master: 父容器。
148
+ command: 拨动回调,不接收参数;用 ``toggle()`` 或点击都会触发。
149
+ width: 开关宽度(像素),默认 44;卡片里的紧凑尺寸可以传 60 / 40。
150
+
151
+ 返回:
152
+ 尚未摆位的 ``CTkSwitch``。
153
+ """
154
+ return ctk.CTkSwitch(
155
+ master,
156
+ text="",
157
+ width=width,
158
+ progress_color=T.COLOR_ACCENT,
159
+ command=command,
160
+ )
ctk_kit/layout.py ADDED
@@ -0,0 +1,194 @@
1
+ """页面骨架:页头、分区卡片、分隔线、滚动容器、空状态。
2
+
3
+ 一套页面按这个顺序搭出来,视觉就不会散:
4
+
5
+ page_header → section(title) → card 里的若干行 → divider → footer
6
+
7
+ 除滚动容器外,这里的工厂都会顺手 ``pack``(``fill="x"``),因为它们的位置
8
+ 在页面骨架里是固定的;按钮、输入框这类需要自己摆位。
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import tkinter as tk
14
+
15
+ import customtkinter as ctk
16
+
17
+ from . import tokens as T
18
+
19
+
20
+ def page_header(master: tk.Misc, title: str) -> tuple[ctk.CTkFrame, ctk.CTkLabel]:
21
+ """页面顶部:左边标题,右边统计信息。
22
+
23
+ 返回 ``(容器, 信息标签)``,调用方拿到信息标签后自己 ``configure(text=...)``,
24
+ 容器已经 ``pack(fill="x")``。
25
+
26
+ 参数:
27
+ master: 父容器。
28
+ title: 左侧大标题。
29
+
30
+ 返回:
31
+ ``(head, info)``:``head`` 是透明容器,``info`` 是右侧灰色小字标签。
32
+ """
33
+ head = ctk.CTkFrame(master, fg_color="transparent")
34
+ head.pack(fill="x")
35
+ ctk.CTkLabel(head, text=title, font=T.FONT_TITLE, text_color=T.COLOR_TEXT).pack(
36
+ side="left"
37
+ )
38
+ info = ctk.CTkLabel(
39
+ head, text="", font=T.FONT_TINY, text_color=T.COLOR_MUTED, anchor="e"
40
+ )
41
+ info.pack(side="right", pady=(9, 0))
42
+ return head, info
43
+
44
+
45
+ def section_title(master: tk.Misc, text: str) -> ctk.CTkLabel:
46
+ """分区标题(设置页的「外观」「备份」这一层)。
47
+
48
+ 参数:
49
+ master: 父容器。
50
+ text: 标题文字。
51
+
52
+ 返回:
53
+ 已 ``pack(fill="x", pady=(16, 6))`` 的标签。
54
+ """
55
+ label = ctk.CTkLabel(
56
+ master, text=text, font=T.FONT_SECTION, text_color=T.COLOR_TEXT, anchor="w"
57
+ )
58
+ label.pack(fill="x", pady=(T.SPACE_LG, T.SPACE_SM - 2))
59
+ return label
60
+
61
+
62
+ def card(master: tk.Misc) -> ctk.CTkFrame:
63
+ """圆角卡片容器:内容都往这里放。
64
+
65
+ 参数:
66
+ master: 父容器。
67
+
68
+ 返回:
69
+ 已 ``pack(fill="x")`` 的卡片 Frame(1px 边框、10 圆角)。
70
+ """
71
+ frame = ctk.CTkFrame(
72
+ master,
73
+ fg_color=T.COLOR_CARD,
74
+ border_width=1,
75
+ border_color=T.COLOR_BORDER,
76
+ corner_radius=T.RADIUS_MEDIUM,
77
+ )
78
+ frame.pack(fill="x")
79
+ return frame
80
+
81
+
82
+ def divider(master: tk.Misc, *, pady: tuple[int, int] = (T.SPACE_MD, 0)) -> ctk.CTkFrame:
83
+ """1px 分隔线;用 Frame 而不是 ttk.Separator,颜色好控制。
84
+
85
+ 参数:
86
+ master: 父容器。
87
+ pady: ``(上, 下)`` 外边距,默认 ``(12, 0)``。
88
+
89
+ 返回:
90
+ 已 ``pack(fill="x")`` 的 1px 高 Frame。
91
+ """
92
+ line = ctk.CTkFrame(master, height=1, fg_color=T.COLOR_BORDER)
93
+ line.pack(fill="x", pady=pady)
94
+ return line
95
+
96
+
97
+ def style_scrollable(frame: ctk.CTkScrollableFrame) -> ctk.CTkScrollableFrame:
98
+ """把 CTkScrollableFrame 的滚动条调成窄条淡色。
99
+
100
+ CTkScrollableFrame 的滚动条藏在 ``_scrollbar`` 私有属性里,只能这样拿;
101
+ CTk 升级时如果改名,这里会静默失效(不报错,只是回到默认样式)。
102
+
103
+ 参数:
104
+ frame: ``CTkScrollableFrame`` 实例;传入其它控件时原样返回。
105
+
106
+ 返回:
107
+ 同一个 ``frame``,方便链式调用。
108
+ """
109
+ bar = getattr(frame, "_scrollbar", None)
110
+ if bar is not None:
111
+ bar.configure(
112
+ width=T.SCROLLBAR_WIDTH,
113
+ fg_color="transparent",
114
+ button_color=T.COLOR_SCROLLBAR,
115
+ button_hover_color=T.COLOR_SCROLLBAR_HOVER,
116
+ )
117
+ return frame
118
+
119
+
120
+ def scrollable(master: tk.Misc) -> ctk.CTkScrollableFrame:
121
+ """已经配好滚动条样式的滚动容器(透明底)。
122
+
123
+ 参数:
124
+ master: 父容器。
125
+
126
+ 返回:
127
+ 尚未摆位的 ``CTkScrollableFrame``;由调用方决定 ``pack`` 还是 ``grid``。
128
+ """
129
+ return style_scrollable(ctk.CTkScrollableFrame(master, fg_color="transparent"))
130
+
131
+
132
+ def empty_hint(master: tk.Misc, title: str, detail: str = "") -> ctk.CTkFrame:
133
+ """居中的空状态:一行主提示 + 一行补充说明。
134
+
135
+ 参数:
136
+ master: 父容器。
137
+ title: 主提示(如「这里还没有内容」)。
138
+ detail: 补充说明;为空时只显示一行。
139
+
140
+ 返回:
141
+ 透明容器,已经装好 1~2 行灰色文字;摆放由调用方决定。
142
+ """
143
+ box = ctk.CTkFrame(master, fg_color="transparent")
144
+ ctk.CTkLabel(box, text=title, font=T.FONT_BODY, text_color=T.COLOR_MUTED).pack()
145
+ if detail:
146
+ ctk.CTkLabel(
147
+ box,
148
+ text=detail,
149
+ font=T.FONT_SMALL,
150
+ text_color=T.COLOR_MUTED,
151
+ justify="center",
152
+ ).pack(pady=(6, 0))
153
+ return box
154
+
155
+
156
+ def field_label(master: tk.Misc, text: str, *, width: int = 112) -> ctk.CTkLabel:
157
+ """卡片内左对齐的字段名,宽度固定让右边控件能对齐成一列。
158
+
159
+ 参数:
160
+ master: 父容器。
161
+ text: 字段名。
162
+ width: 固定宽度(像素),默认 112。
163
+
164
+ 返回:
165
+ 尚未摆位的 ``CTkLabel``(``anchor="w"``)。
166
+ """
167
+ return ctk.CTkLabel(
168
+ master,
169
+ text=text,
170
+ font=T.FONT_SMALL,
171
+ text_color=T.COLOR_MUTED,
172
+ width=width,
173
+ anchor="w",
174
+ )
175
+
176
+
177
+ def hint_label(master: tk.Misc, text: str) -> ctk.CTkLabel:
178
+ """字段下方的说明文字。
179
+
180
+ 参数:
181
+ master: 父容器。
182
+ text: 说明内容,支持 ``\\n`` 换行。
183
+
184
+ 返回:
185
+ 尚未摆位的 ``CTkLabel``(左对齐、多行左对齐)。
186
+ """
187
+ return ctk.CTkLabel(
188
+ master,
189
+ text=text,
190
+ font=T.FONT_SMALL,
191
+ text_color=T.COLOR_MUTED,
192
+ anchor="w",
193
+ justify="left",
194
+ )
ctk_kit/py.typed ADDED
File without changes
ctk_kit/scroll_list.py ADDED
@@ -0,0 +1,332 @@
1
+ """懒加载滚动列表。
2
+
3
+ 为什么不用 ``CTkScrollableFrame`` 直接塞卡片:上百张卡片时,一次建完要几秒,
4
+ 切页面会明显卡住。这里的分批策略是——
5
+
6
+ 1. 首屏只建 ``first_batch`` 张,先把页面显示出来;
7
+ 2. 滚到接近底部、且手停下来 ``load_delay_ms`` 之后,再补 ``load_more`` 张;
8
+ 3. 首屏没铺满窗口时自动补,直到铺满或补完。
9
+
10
+ 滚动条的两个坑(都踩过):
11
+
12
+ - ``CTkScrollbar.set()`` 内部会调 ``update_idletasks()``,而它**会立刻执行
13
+ idle 回调**——所以加载任务必须用 ``after(1, ...)`` 定时器,不能用
14
+ ``after_idle``,否则会在 ``set()`` 里递归建卡。
15
+ - scrollregion 变化后 Tk 会回放一次 ``yscrollcommand(0, 1)``,用回调参数判断
16
+ 「滚到底」会误判,必须读 ``canvas.yview()`` 的真实值。
17
+
18
+ 滚动条外观:内容不足一屏时把滑块涂成背景色(视觉上消失,但**布局宽度不变**)。
19
+ 不要用 ``grid_remove`` 隐藏——canvas 会变宽,卡片跟着拉伸一下,看起来像抖。
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import tkinter as tk
25
+ from typing import Any, Callable, Optional
26
+
27
+ import customtkinter as ctk
28
+
29
+ from . import tokens as T
30
+ from .layout import empty_hint
31
+ from .theme import color_of, repaint_native
32
+
33
+ # ``render_card(parent, item)``:建一张卡片并返回其顶层控件。
34
+ RenderCard = Callable[[tk.Misc, Any], tk.Misc]
35
+ # ``key_of(item) -> 可哈希的签名``:用于判断数据是否变化。
36
+ KeyOf = Callable[[Any], Any]
37
+
38
+
39
+ class LazyList(tk.Frame):
40
+ """竖向懒加载列表。
41
+
42
+ 参数:
43
+ master: 父容器。
44
+ render_card: 回调 ``render_card(parent, item) -> widget``,返回一张卡片
45
+ 的顶层控件。卡片自己要负责外观,这里只负责把它 ``pack`` 进列表。
46
+ key_of: ``key_of(item) -> tuple``,用于判断数据是否变化。默认取
47
+ ``repr``;数据量大时建议换成
48
+ ``lambda item: (item["id"], item["updated_at"])``。
49
+ first_batch: 首屏一次建多少张,默认 12。
50
+ load_more: 每次追加多少张,默认 8。
51
+ load_delay_ms: 滚到底后等多久再追加(毫秒),默认 90——让手停下来。
52
+ load_threshold: 可滚动区滚到多少比例触发追加,默认 0.92。
53
+ gap: 卡片上下的外边距,默认 ``(0, 10)``。
54
+ padx: 卡片左右留白(给滚动条让位),默认 ``(0, 8)``。
55
+ """
56
+
57
+ def __init__(
58
+ self,
59
+ master: tk.Misc,
60
+ render_card: RenderCard,
61
+ *,
62
+ key_of: Optional[KeyOf] = None,
63
+ first_batch: int = 12,
64
+ load_more: int = 8,
65
+ load_delay_ms: int = 90,
66
+ load_threshold: float = 0.92,
67
+ gap: tuple[int, int] = (0, 10),
68
+ padx: tuple[int, int] = (0, 8),
69
+ ) -> None:
70
+ super().__init__(master, bg=color_of(T.COLOR_BG), highlightthickness=0)
71
+ self.render_card: RenderCard = render_card
72
+ self.key_of: KeyOf = key_of or repr
73
+ self.first_batch: int = first_batch
74
+ self.load_more: int = load_more
75
+ self.load_delay_ms: int = load_delay_ms
76
+ self.load_threshold: float = load_threshold
77
+ self.gap: tuple[int, int] = gap
78
+ self.padx: tuple[int, int] = padx
79
+
80
+ self._items: list[Any] = []
81
+ self._shown: int = 0
82
+ self._loading: bool = False
83
+ self._load_job: Optional[str] = None
84
+ self._suppress: bool = False
85
+ self._signature: Optional[tuple] = None
86
+ self._empty_box: Optional[tk.Frame] = None
87
+ self._scrollbar_visible: Optional[bool] = None
88
+
89
+ self.grid_rowconfigure(0, weight=1)
90
+ self.grid_columnconfigure(0, weight=1)
91
+
92
+ self.canvas: tk.Canvas = tk.Canvas(
93
+ self, highlightthickness=0, bg=color_of(T.COLOR_BG), bd=0
94
+ )
95
+ self.canvas.grid(row=0, column=0, sticky="nsew")
96
+ self.scrollbar: ctk.CTkScrollbar = ctk.CTkScrollbar(
97
+ self,
98
+ command=self.canvas.yview,
99
+ width=T.SCROLLBAR_WIDTH,
100
+ fg_color="transparent",
101
+ button_color=T.COLOR_SCROLLBAR,
102
+ button_hover_color=T.COLOR_SCROLLBAR_HOVER,
103
+ )
104
+ self.scrollbar.grid(row=0, column=1, sticky="ns")
105
+ self.canvas.configure(yscrollcommand=self._on_scroll)
106
+
107
+ self.inner: tk.Frame = tk.Frame(self.canvas, bg=color_of(T.COLOR_BG))
108
+ self._window: int = self.canvas.create_window(
109
+ (0, 0), window=self.inner, anchor="nw"
110
+ )
111
+
112
+ self.inner.bind("<Configure>", self._sync_scrollregion)
113
+ self.canvas.bind("<Configure>", self._on_canvas_resize)
114
+
115
+ # 滚轮:控件在 canvas 里是独立窗口,事件不会冒泡到父容器,
116
+ # 所以统一在 toplevel 上挂一个,按指针位置判断该不该由自己滚动。
117
+ self._alive: bool = True
118
+ self.bind_all("<MouseWheel>", self._on_wheel, add="+")
119
+
120
+ # ------------------------------------------------------------------ #
121
+ # 对外接口
122
+ def render(
123
+ self,
124
+ items: list[Any],
125
+ *,
126
+ empty_title: str = "这里还没有内容",
127
+ empty_detail: str = "",
128
+ force: bool = False,
129
+ ) -> None:
130
+ """重画列表。数据没变时直接返回,避免每次切页都重建控件。
131
+
132
+ 参数:
133
+ items: 数据列表,原样交给 ``render_card``。
134
+ empty_title: 列表为空时的主提示。
135
+ empty_detail: 列表为空时的补充说明。
136
+ force: 为 ``True`` 时忽略签名缓存,强制重建。
137
+ """
138
+ signature = (ctk.get_appearance_mode(), tuple(self.key_of(i) for i in items))
139
+ if not force and signature == self._signature:
140
+ return
141
+ self._signature = signature
142
+ self._items = list(items)
143
+ self._shown = 0
144
+ self._suppress = True
145
+ self._refresh_colors()
146
+ self._clear_cards()
147
+
148
+ if not items:
149
+ self._suppress = False
150
+ box = empty_hint(self.inner, empty_title, empty_detail)
151
+ box.pack(pady=70)
152
+ self._empty_box = box
153
+ return
154
+
155
+ self._append(self.first_batch)
156
+ self._suppress = False
157
+ # 滚动条位置留到下一帧再算:set() 会同步布局,放在这里会拖慢首帧
158
+ self.after(1, self._refresh_scroll)
159
+
160
+ def show_hint(self, title: str, detail: str = "") -> None:
161
+ """只显示一条提示(例如「还没有备份」),不占列表数据。
162
+
163
+ 参数:
164
+ title: 主提示。
165
+ detail: 补充说明,可为空。
166
+ """
167
+ signature = ("hint", ctk.get_appearance_mode(), title)
168
+ if signature == self._signature:
169
+ return
170
+ self._signature = signature
171
+ self._items = []
172
+ self._shown = 0
173
+ self._suppress = True
174
+ self._refresh_colors()
175
+ self._clear_cards()
176
+ self._empty_box = empty_hint(self.inner, title, detail)
177
+ self._empty_box.pack(pady=70)
178
+ self.canvas.yview_moveto(0)
179
+ self._suppress = False
180
+
181
+ def invalidate(self) -> None:
182
+ """丢掉缓存标记,下次 render 一定重建(主题切换后调用)。"""
183
+ self._signature = None
184
+
185
+ @property
186
+ def cards(self) -> list[tk.Misc]:
187
+ """当前已建出的卡片控件(副本,改动它不会影响列表)。"""
188
+ return list(self.inner.winfo_children())
189
+
190
+ def flush(self) -> None:
191
+ """一次性补齐所有卡片(测试、导出时用)。"""
192
+ self._cancel_load()
193
+ self._append(len(self._items) - self._shown)
194
+
195
+ def scroll_units(self, steps: int) -> None:
196
+ """按「行」滚动;steps>0 向下,steps<0 向上。"""
197
+ self.canvas.yview_scroll(steps * 3, "units")
198
+
199
+ def _on_wheel(self, event: tk.Event) -> Optional[str]:
200
+ """只滚动指针所在的列表;其它区域的滚轮不归这里管。"""
201
+ if not self._alive or not self._owns(event.widget):
202
+ return None
203
+ self.scroll_units(-1 if event.delta > 0 else 1)
204
+ return "break"
205
+
206
+ def _owns(self, widget: Optional[tk.Misc]) -> bool:
207
+ """``widget`` 是否在本列表的控件树里。"""
208
+ node: Optional[tk.Misc] = widget
209
+ while node is not None:
210
+ if node is self:
211
+ return True
212
+ node = getattr(node, "master", None)
213
+ return False
214
+
215
+ def destroy(self) -> None:
216
+ """停止滚轮绑定后销毁。"""
217
+ self._alive = False
218
+ super().destroy()
219
+
220
+ # ------------------------------------------------------------------ #
221
+ # 内部
222
+ def _append(self, count: int) -> None:
223
+ """从 ``_shown`` 处继续建 ``count`` 张卡。"""
224
+ end = min(self._shown + count, len(self._items))
225
+ if end <= self._shown:
226
+ return
227
+ # 建卡期间暂停滚动回调:每加一张都会触发 scrollregion 变化,而
228
+ # CTkScrollbar 每次 set 都要整条重绘(实测是布局耗时的大头)。
229
+ self._loading = True
230
+ self.canvas.configure(yscrollcommand="")
231
+ for item in self._items[self._shown : end]:
232
+ card = self.render_card(self.inner, item)
233
+ card.pack(fill="x", pady=self.gap, padx=self.padx)
234
+ self._shown = end
235
+ self._sync_scrollregion()
236
+ self.canvas.configure(yscrollcommand=self._on_scroll)
237
+ self.after(1, self._refresh_scroll)
238
+
239
+ def _refresh_scroll(self) -> None:
240
+ """同步滚动条位置,并在空闲后检查要不要补卡。"""
241
+ first, last = self.canvas.yview()
242
+ self.scrollbar.set(first, last)
243
+ self._sync_scrollbar()
244
+ self._loading = False
245
+ self.after(150, self._check_fill)
246
+
247
+ def _sync_scrollbar(self) -> None:
248
+ """内容装得下时把滑块涂成背景色(布局宽度不变)。"""
249
+ box = self.canvas.bbox("all")
250
+ content = (box[3] - box[1]) if box else 0
251
+ visible = content > self.canvas.winfo_height() + 1
252
+ if visible == self._scrollbar_visible:
253
+ return
254
+ self._scrollbar_visible = visible
255
+ bg = color_of(T.COLOR_BG)
256
+ self.scrollbar.configure(
257
+ fg_color=bg,
258
+ button_color=color_of(T.COLOR_SCROLLBAR) if visible else bg,
259
+ button_hover_color=color_of(T.COLOR_SCROLLBAR_HOVER) if visible else bg,
260
+ )
261
+
262
+ def _check_fill(self) -> None:
263
+ """窗口较高、首屏没铺满时补一批。
264
+
265
+ 放在定时器里:Tk 要跑完一次事件循环才会更新内层 Frame 的尺寸,
266
+ 这时 bbox 才是准的。
267
+ """
268
+ if self._shown >= len(self._items):
269
+ return
270
+ box = self.canvas.bbox("all")
271
+ content = (box[3] - box[1]) if box else 0
272
+ # 差得多才补:多建一批要重新布局整页,代价比底部一小条空白高得多
273
+ if content > self.canvas.winfo_height() * 0.8:
274
+ return
275
+ self._append(self.load_more)
276
+
277
+ def _load_more(self) -> None:
278
+ """定时器回调:追加下一批。"""
279
+ self._load_job = None
280
+ self._append(self.load_more)
281
+
282
+ def _on_scroll(self, first: str, last: str) -> None:
283
+ """canvas 的 yscrollcommand 回调。"""
284
+ if self._suppress:
285
+ return
286
+ self.scrollbar.set(first, last)
287
+ if self._loading or self._shown >= len(self._items):
288
+ return
289
+ # 不信回调参数:scrollregion 刚变时会回放一次 (0, 1)
290
+ current = self.canvas.yview()
291
+ if float(current[1]) < self.load_threshold:
292
+ return
293
+ self._schedule_load()
294
+
295
+ def _schedule_load(self) -> None:
296
+ """滚到底后等手停下再补下一批,避免边滚边排版造成顿挫。"""
297
+ if self._load_job is not None:
298
+ self.after_cancel(self._load_job)
299
+ self._load_job = self.after(self.load_delay_ms, self._load_more)
300
+
301
+ def _cancel_load(self) -> None:
302
+ """取消还没触发的追加任务。"""
303
+ if self._load_job is not None:
304
+ self.after_cancel(self._load_job)
305
+ self._load_job = None
306
+
307
+ def _clear_cards(self) -> None:
308
+ """清空内层卡片并复位滚动位置。"""
309
+ self._loading = False
310
+ self._cancel_load()
311
+ for child in self.inner.winfo_children():
312
+ child.destroy()
313
+ self._empty_box = None
314
+ self.canvas.yview_moveto(0)
315
+ self._sync_scrollregion()
316
+
317
+ def _refresh_colors(self) -> None:
318
+ """主题切换后刷新原生 tk 控件的底色(见 theme.repaint_native)。"""
319
+ bg = color_of(T.COLOR_BG)
320
+ for widget in (self, self.canvas, self.inner):
321
+ repaint_native(widget, bg)
322
+ self._scrollbar_visible = None
323
+ self._sync_scrollbar()
324
+
325
+ def _sync_scrollregion(self, _event: Optional[tk.Event] = None) -> None:
326
+ """把 canvas 的可滚动区域更新为内容包围盒。"""
327
+ self.canvas.configure(scrollregion=self.canvas.bbox("all"))
328
+
329
+ def _on_canvas_resize(self, event: tk.Event) -> None:
330
+ """canvas 宽度变化时让内层 Frame 跟着变宽。"""
331
+ self.canvas.itemconfigure(self._window, width=event.width)
332
+ self.after(1, self._sync_scrollbar)