ctk-kit 0.2.0__tar.gz → 0.3.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.
Files changed (32) hide show
  1. {ctk_kit-0.2.0 → ctk_kit-0.3.0}/PKG-INFO +31 -7
  2. {ctk_kit-0.2.0 → ctk_kit-0.3.0}/README.md +30 -6
  3. {ctk_kit-0.2.0 → ctk_kit-0.3.0}/ctk_kit/__init__.py +6 -3
  4. {ctk_kit-0.2.0 → ctk_kit-0.3.0}/ctk_kit/scroll_list.py +168 -5
  5. ctk_kit-0.3.0/ctk_kit/showcase.py +1450 -0
  6. {ctk_kit-0.2.0 → ctk_kit-0.3.0}/ctk_kit/text.py +12 -5
  7. {ctk_kit-0.2.0 → ctk_kit-0.3.0}/ctk_kit.egg-info/PKG-INFO +31 -7
  8. {ctk_kit-0.2.0 → ctk_kit-0.3.0}/ctk_kit.egg-info/SOURCES.txt +2 -0
  9. ctk_kit-0.3.0/ctk_kit.egg-info/entry_points.txt +2 -0
  10. {ctk_kit-0.2.0 → ctk_kit-0.3.0}/pyproject.toml +4 -1
  11. {ctk_kit-0.2.0 → ctk_kit-0.3.0}/tests/test_package.py +28 -3
  12. {ctk_kit-0.2.0 → ctk_kit-0.3.0}/tests/test_scroll_list.py +86 -0
  13. {ctk_kit-0.2.0 → ctk_kit-0.3.0}/tests/test_showcase.py +16 -23
  14. {ctk_kit-0.2.0 → ctk_kit-0.3.0}/tests/test_text.py +10 -0
  15. {ctk_kit-0.2.0 → ctk_kit-0.3.0}/LICENSE +0 -0
  16. {ctk_kit-0.2.0 → ctk_kit-0.3.0}/ctk_kit/buttons.py +0 -0
  17. {ctk_kit-0.2.0 → ctk_kit-0.3.0}/ctk_kit/dropdown.py +0 -0
  18. {ctk_kit-0.2.0 → ctk_kit-0.3.0}/ctk_kit/inputs.py +0 -0
  19. {ctk_kit-0.2.0 → ctk_kit-0.3.0}/ctk_kit/layout.py +0 -0
  20. {ctk_kit-0.2.0 → ctk_kit-0.3.0}/ctk_kit/py.typed +0 -0
  21. {ctk_kit-0.2.0 → ctk_kit-0.3.0}/ctk_kit/theme.py +0 -0
  22. {ctk_kit-0.2.0 → ctk_kit-0.3.0}/ctk_kit/tokens.py +0 -0
  23. {ctk_kit-0.2.0 → ctk_kit-0.3.0}/ctk_kit.egg-info/dependency_links.txt +0 -0
  24. {ctk_kit-0.2.0 → ctk_kit-0.3.0}/ctk_kit.egg-info/requires.txt +0 -0
  25. {ctk_kit-0.2.0 → ctk_kit-0.3.0}/ctk_kit.egg-info/top_level.txt +0 -0
  26. {ctk_kit-0.2.0 → ctk_kit-0.3.0}/setup.cfg +0 -0
  27. {ctk_kit-0.2.0 → ctk_kit-0.3.0}/tests/test_buttons.py +0 -0
  28. {ctk_kit-0.2.0 → ctk_kit-0.3.0}/tests/test_dropdown.py +0 -0
  29. {ctk_kit-0.2.0 → ctk_kit-0.3.0}/tests/test_inputs.py +0 -0
  30. {ctk_kit-0.2.0 → ctk_kit-0.3.0}/tests/test_layout.py +0 -0
  31. {ctk_kit-0.2.0 → ctk_kit-0.3.0}/tests/test_theme.py +0 -0
  32. {ctk_kit-0.2.0 → ctk_kit-0.3.0}/tests/test_tokens.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ctk-kit
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: 面向 CustomTkinter 的轻量界面基件:深浅色令牌、常用控件与懒加载列表
5
5
  Author-email: 加点盐 <1431705288@qq.com>
6
6
  License: MIT
@@ -63,12 +63,21 @@ primary_button(app, "新建", lambda: None).pack()
63
63
  app.mainloop()
64
64
  ```
65
65
 
66
- 看实际效果:
66
+ 看实际效果:示例随包发布,装好 ctk-kit 后可以直接跑:
67
+
68
+ ```
69
+ pip install ctk-kit
70
+ ctk-kit-showcase
71
+ ```
72
+
73
+ 也可以从源码运行(不用安装):
67
74
 
68
75
  ```
69
76
  git clone https://gitee.com/kuangjianke/ctk-kit
70
77
  cd ctk-kit
71
78
  python examples/showcase.py
79
+ # 等价写法
80
+ python -m ctk_kit.showcase
72
81
  ```
73
82
 
74
83
  展示程序有九个页面:总览、基件、按钮、输入、布局、列表、主题、表单、设置,把每个公开构建都用一遍。
@@ -101,8 +110,10 @@ python examples/showcase.py
101
110
 
102
111
  **大文本**(`text`)
103
112
 
104
- - `TextArea(master, *, placeholder="", height=200, readonly=False, show_counter=True, on_change=None)`
105
- —— 多行文本区:失焦时显示占位符、底部字数统计、可切换只读;读写用 `get()/set()/clear()`
113
+ - `TextArea(master, *, placeholder="", height=200, readonly=False, show_counter=True, on_change=None, bg_color=None, text_color=None)`
114
+ —— 多行文本区:失焦时显示占位符、底部字数统计、可切换只读;读写用 `get()/set()/clear()`。
115
+ 内部底色默认 `COLOR_INPUT`(输入框语义);当日志面板显示时传 `bg_color=COLOR_CARD` 或
116
+ `bg_color="transparent"`,就不会比所在卡片亮一截,`text_color` 也可单独覆盖
106
117
 
107
118
  **下拉**(`dropdown`)
108
119
 
@@ -135,6 +146,17 @@ python examples/showcase.py
135
146
  listing.render(items)
136
147
  ```
137
148
 
149
+ 数据量很大(几千条)时,传入固定卡高开启虚拟滚动:只把视口附近的卡片放上布局,
150
+ 滚出去的卡片留在内存里复用,滚回来直接放回去,不销毁也不重建。
151
+
152
+ ```python
153
+ listing = LazyList(frame, make_card, key_of=lambda item: item["id"], item_height=96)
154
+ ```
155
+
156
+ 虚拟滚动要求卡片高度固定(例如自己 `pack_propagate(False)` 并设置 `height`),
157
+ 并把卡片间距算进 `item_height`。`item_height` 用和控件尺寸一样的逻辑单位,
158
+ 高 DPI 缩放下由 `LazyList` 自己换算。
159
+
138
160
  ## 设计令牌
139
161
 
140
162
  颜色全部是 `(浅色, 深色)` 二元组,交给 `color_of()` 按当前外观取值:
@@ -159,10 +181,12 @@ ctk_kit/
159
181
  ├─ inputs.py 输入框 / 分段选择 / 开关
160
182
  ├─ dropdown.py 自绘下拉
161
183
  ├─ layout.py 页头、卡片、分隔线、空态、滚动容器
162
- └─ scroll_list.py 懒加载列表
184
+ ├─ scroll_list.py 懒加载列表
185
+ ├─ text.py 多行文本区
186
+ └─ showcase.py 全组件展示(随包发布,`ctk-kit-showcase`)
163
187
 
164
188
  examples/
165
- └─ showcase.py 全组件展示(九个页面,也可当模板改)
189
+ └─ showcase.py 仓库里的快捷入口(转发到 ctk_kit.showcase:main)
166
190
  ```
167
191
 
168
192
  ## 测试
@@ -173,7 +197,7 @@ pytest
173
197
  ```
174
198
 
175
199
  测试跑在真实 Tk 窗口里(窗口全透明,不会在屏幕上闪),覆盖令牌、主题、按钮、输入框、
176
- 下拉、布局、懒加载列表,以及 `examples/showcase.py` 的九个示例页;没有图形环境时相关用例自动 skip。
200
+ 下拉、布局、懒加载列表、大文本区,以及 `ctk_kit/showcase.py` 的九个示例页;没有图形环境时相关用例自动 skip。
177
201
 
178
202
  CI 在 GitHub Actions 上跑 Python 3.8 – 3.14 全矩阵(`.github/workflows/ci.yml`),
179
203
  无头环境下用 Xvfb 提供虚拟屏幕;`CI` 环境变量存在时如果建不出窗口会直接失败,而不是静默跳过。
@@ -29,12 +29,21 @@ primary_button(app, "新建", lambda: None).pack()
29
29
  app.mainloop()
30
30
  ```
31
31
 
32
- 看实际效果:
32
+ 看实际效果:示例随包发布,装好 ctk-kit 后可以直接跑:
33
+
34
+ ```
35
+ pip install ctk-kit
36
+ ctk-kit-showcase
37
+ ```
38
+
39
+ 也可以从源码运行(不用安装):
33
40
 
34
41
  ```
35
42
  git clone https://gitee.com/kuangjianke/ctk-kit
36
43
  cd ctk-kit
37
44
  python examples/showcase.py
45
+ # 等价写法
46
+ python -m ctk_kit.showcase
38
47
  ```
39
48
 
40
49
  展示程序有九个页面:总览、基件、按钮、输入、布局、列表、主题、表单、设置,把每个公开构建都用一遍。
@@ -67,8 +76,10 @@ python examples/showcase.py
67
76
 
68
77
  **大文本**(`text`)
69
78
 
70
- - `TextArea(master, *, placeholder="", height=200, readonly=False, show_counter=True, on_change=None)`
71
- —— 多行文本区:失焦时显示占位符、底部字数统计、可切换只读;读写用 `get()/set()/clear()`
79
+ - `TextArea(master, *, placeholder="", height=200, readonly=False, show_counter=True, on_change=None, bg_color=None, text_color=None)`
80
+ —— 多行文本区:失焦时显示占位符、底部字数统计、可切换只读;读写用 `get()/set()/clear()`。
81
+ 内部底色默认 `COLOR_INPUT`(输入框语义);当日志面板显示时传 `bg_color=COLOR_CARD` 或
82
+ `bg_color="transparent"`,就不会比所在卡片亮一截,`text_color` 也可单独覆盖
72
83
 
73
84
  **下拉**(`dropdown`)
74
85
 
@@ -101,6 +112,17 @@ python examples/showcase.py
101
112
  listing.render(items)
102
113
  ```
103
114
 
115
+ 数据量很大(几千条)时,传入固定卡高开启虚拟滚动:只把视口附近的卡片放上布局,
116
+ 滚出去的卡片留在内存里复用,滚回来直接放回去,不销毁也不重建。
117
+
118
+ ```python
119
+ listing = LazyList(frame, make_card, key_of=lambda item: item["id"], item_height=96)
120
+ ```
121
+
122
+ 虚拟滚动要求卡片高度固定(例如自己 `pack_propagate(False)` 并设置 `height`),
123
+ 并把卡片间距算进 `item_height`。`item_height` 用和控件尺寸一样的逻辑单位,
124
+ 高 DPI 缩放下由 `LazyList` 自己换算。
125
+
104
126
  ## 设计令牌
105
127
 
106
128
  颜色全部是 `(浅色, 深色)` 二元组,交给 `color_of()` 按当前外观取值:
@@ -125,10 +147,12 @@ ctk_kit/
125
147
  ├─ inputs.py 输入框 / 分段选择 / 开关
126
148
  ├─ dropdown.py 自绘下拉
127
149
  ├─ layout.py 页头、卡片、分隔线、空态、滚动容器
128
- └─ scroll_list.py 懒加载列表
150
+ ├─ scroll_list.py 懒加载列表
151
+ ├─ text.py 多行文本区
152
+ └─ showcase.py 全组件展示(随包发布,`ctk-kit-showcase`)
129
153
 
130
154
  examples/
131
- └─ showcase.py 全组件展示(九个页面,也可当模板改)
155
+ └─ showcase.py 仓库里的快捷入口(转发到 ctk_kit.showcase:main)
132
156
  ```
133
157
 
134
158
  ## 测试
@@ -139,7 +163,7 @@ pytest
139
163
  ```
140
164
 
141
165
  测试跑在真实 Tk 窗口里(窗口全透明,不会在屏幕上闪),覆盖令牌、主题、按钮、输入框、
142
- 下拉、布局、懒加载列表,以及 `examples/showcase.py` 的九个示例页;没有图形环境时相关用例自动 skip。
166
+ 下拉、布局、懒加载列表、大文本区,以及 `ctk_kit/showcase.py` 的九个示例页;没有图形环境时相关用例自动 skip。
143
167
 
144
168
  CI 在 GitHub Actions 上跑 Python 3.8 – 3.14 全矩阵(`.github/workflows/ci.yml`),
145
169
  无头环境下用 Xvfb 提供虚拟屏幕;`CI` 环境变量存在时如果建不出窗口会直接失败,而不是静默跳过。
@@ -21,9 +21,12 @@
21
21
  primary_button(box, "立即备份", command=lambda: print("go")).pack(padx=16, pady=16)
22
22
  app.mainloop()
23
23
 
24
- 全组件展示(示例脚本,见仓库 examples/)::
24
+ 全组件展示(随包发布,装好后直接运行)::
25
25
 
26
- python examples/showcase.py
26
+ ctk-kit-showcase
27
+ python -m ctk_kit.showcase
28
+
29
+ 从源码运行时等价写法:``python examples/showcase.py``。
27
30
 
28
31
  仓库: https://gitee.com/kuangjianke/ctk-kit
29
32
  镜像: https://github.com/DashSalter/ctk-kit
@@ -31,7 +34,7 @@
31
34
 
32
35
  from __future__ import annotations
33
36
 
34
- __version__ = "0.2.0"
37
+ __version__ = "0.3.0"
35
38
 
36
39
  from . import tokens, theme
37
40
  from .buttons import (
@@ -52,6 +52,11 @@ class LazyList(tk.Frame):
52
52
  load_threshold: 可滚动区滚到多少比例触发追加,默认 0.92。
53
53
  gap: 卡片上下的外边距,默认 ``(0, 10)``。
54
54
  padx: 卡片左右留白(给滚动条让位),默认 ``(0, 8)``。
55
+ item_height: 固定卡片高度(含 ``gap``)。给定时开启虚拟滚动:只把
56
+ 视口附近的卡片放上布局,滚出视口的卡片保留在内存里复用,滚回来
57
+ 直接重新放上去,不销毁、不重建。适合几千条数据的列表;要求
58
+ ``render_card`` 返回的卡片高度固定(例如自己 ``pack_propagate(False)``
59
+ 并设置 ``height``),否则卡片会互相叠压。
55
60
  """
56
61
 
57
62
  def __init__(
@@ -66,6 +71,7 @@ class LazyList(tk.Frame):
66
71
  load_threshold: float = 0.92,
67
72
  gap: tuple[int, int] = (0, 10),
68
73
  padx: tuple[int, int] = (0, 8),
74
+ item_height: Optional[int] = None,
69
75
  ) -> None:
70
76
  super().__init__(master, bg=color_of(T.COLOR_BG), highlightthickness=0)
71
77
  self.render_card: RenderCard = render_card
@@ -76,6 +82,9 @@ class LazyList(tk.Frame):
76
82
  self.load_threshold: float = load_threshold
77
83
  self.gap: tuple[int, int] = gap
78
84
  self.padx: tuple[int, int] = padx
85
+ #: 固定卡片高度(含间距)。给定时开启虚拟滚动:只把视口附近的卡片
86
+ #: 放上布局,滚过去的卡片留在内存里复用,不销毁,回滚时直接放回来。
87
+ self.item_height: Optional[int] = item_height
79
88
 
80
89
  self._items: list[Any] = []
81
90
  self._shown: int = 0
@@ -85,6 +94,10 @@ class LazyList(tk.Frame):
85
94
  self._signature: Optional[tuple] = None
86
95
  self._empty_box: Optional[tk.Frame] = None
87
96
  self._scrollbar_visible: Optional[bool] = None
97
+ #: 虚拟模式:索引 -> 已建卡片(卡片不销毁,只在视口外 place_forget)。
98
+ self._built: dict[int, tk.Misc] = {}
99
+ self._range: Optional[tuple[int, int]] = None
100
+ self._resize_job: Optional[str] = None
88
101
 
89
102
  self.grid_rowconfigure(0, weight=1)
90
103
  self.grid_columnconfigure(0, weight=1)
@@ -157,6 +170,12 @@ class LazyList(tk.Frame):
157
170
  self._empty_box = box
158
171
  return
159
172
 
173
+ if self.item_height is not None:
174
+ self._layout_virtual()
175
+ self._suppress = False
176
+ self.after(1, self._refresh_scroll)
177
+ return
178
+
160
179
  self._append(self.first_batch)
161
180
  self._suppress = False
162
181
  # 滚动条位置留到下一帧再算:set() 会同步布局,放在这里会拖慢首帧
@@ -189,14 +208,45 @@ class LazyList(tk.Frame):
189
208
 
190
209
  @property
191
210
  def cards(self) -> list[tk.Misc]:
192
- """当前已建出的卡片控件(副本,改动它不会影响列表)。"""
211
+ """当前已建出的卡片控件(副本,改动它不会影响列表)。
212
+
213
+ 虚拟模式下返回所有建过的卡片(含视口外未布局的),按索引排序。
214
+ """
215
+ if self.item_height is not None:
216
+ return [self._built[index] for index in sorted(self._built)]
193
217
  return list(self.inner.winfo_children())
194
218
 
195
219
  def flush(self) -> None:
196
- """一次性补齐所有卡片(测试、导出时用)。"""
220
+ """一次性补齐所有卡片(测试、导出时用)。
221
+
222
+ 虚拟模式下只建卡、不改变视口,所以耗时也只和卡片总数有关。
223
+ """
224
+ if self.item_height is not None:
225
+ for index in range(len(self._items)):
226
+ self._ensure_card(index)
227
+ self._sync_window(force=True)
228
+ return
197
229
  self._cancel_load()
198
230
  self._append(len(self._items) - self._shown)
199
231
 
232
+ def reset(self) -> None:
233
+ """回到首屏:只保留 ``first_batch`` 张卡并把滚动位置归零。
234
+
235
+ 适合页面被隐藏时调用。已展开几百张卡时,控件数量会让重新显示
236
+ 或窗口缩放明显变慢;缩回首屏后重排成本就回到常数级。
237
+
238
+ 虚拟模式下卡片本来就只布局视口附近,所以这里只把滚动位置归零,
239
+ 已建卡片全部保留复用,不会重建。
240
+ """
241
+ if not self._items:
242
+ return
243
+ if self.item_height is not None:
244
+ self.canvas.yview_moveto(0)
245
+ self._sync_window(force=True)
246
+ return
247
+ self._signature = None
248
+ self.render(self._items, force=True)
249
+
200
250
  def scroll_units(self, steps: int) -> None:
201
251
  """按「行」滚动;steps>0 向下,steps<0 向上。
202
252
 
@@ -225,6 +275,7 @@ class LazyList(tk.Frame):
225
275
  def destroy(self) -> None:
226
276
  """停止滚轮绑定后销毁。"""
227
277
  self._alive = False
278
+ self._cancel_resize()
228
279
  super().destroy()
229
280
 
230
281
  # ------------------------------------------------------------------ #
@@ -256,8 +307,11 @@ class LazyList(tk.Frame):
256
307
 
257
308
  def _sync_scrollbar(self) -> None:
258
309
  """内容装得下时把滑块涂成背景色(布局宽度不变)。"""
259
- box = self.canvas.bbox("all")
260
- content = (box[3] - box[1]) if box else 0
310
+ if self.item_height is not None:
311
+ content = len(self._items) * self.item_height * self._card_scale()
312
+ else:
313
+ box = self.canvas.bbox("all")
314
+ content = (box[3] - box[1]) if box else 0
261
315
  visible = content > self.canvas.winfo_height() + 1
262
316
  if visible == self._scrollbar_visible:
263
317
  return
@@ -275,6 +329,8 @@ class LazyList(tk.Frame):
275
329
  放在定时器里:Tk 要跑完一次事件循环才会更新内层 Frame 的尺寸,
276
330
  这时 bbox 才是准的。
277
331
  """
332
+ if self.item_height is not None:
333
+ return
278
334
  if self._shown >= len(self._items):
279
335
  return
280
336
  box = self.canvas.bbox("all")
@@ -294,6 +350,10 @@ class LazyList(tk.Frame):
294
350
  if self._suppress:
295
351
  return
296
352
  self.scrollbar.set(first, last)
353
+ if self.item_height is not None:
354
+ self._sync_scrollbar()
355
+ self._sync_window(float(first), float(last))
356
+ return
297
357
  if self._loading or self._shown >= len(self._items):
298
358
  return
299
359
  # 不信回调参数:scrollregion 刚变时会回放一次 (0, 1)
@@ -314,12 +374,23 @@ class LazyList(tk.Frame):
314
374
  self.after_cancel(self._load_job)
315
375
  self._load_job = None
316
376
 
377
+ def _cancel_resize(self) -> None:
378
+ """取消还没触发的窗口尺寸同步任务。"""
379
+ if self._resize_job is not None:
380
+ self.after_cancel(self._resize_job)
381
+ self._resize_job = None
382
+
317
383
  def _clear_cards(self) -> None:
318
384
  """清空内层卡片并复位滚动位置。"""
319
385
  self._loading = False
320
386
  self._cancel_load()
387
+ self._cancel_resize()
388
+ self._built.clear()
389
+ self._range = None
321
390
  for child in self.inner.winfo_children():
322
391
  child.destroy()
392
+ if self.item_height is not None:
393
+ self.inner.configure(height=0)
323
394
  self._empty_box = None
324
395
  self.canvas.yview_moveto(0)
325
396
  self._sync_scrollregion()
@@ -334,9 +405,101 @@ class LazyList(tk.Frame):
334
405
 
335
406
  def _sync_scrollregion(self, _event: Optional[tk.Event] = None) -> None:
336
407
  """把 canvas 的可滚动区域更新为内容包围盒。"""
408
+ if self.item_height is not None and self._items:
409
+ # 虚拟模式的内容高度是算出来的,不依赖当前放上布局的卡片
410
+ height = self.item_height * len(self._items) * self._card_scale()
411
+ self.canvas.configure(
412
+ scrollregion=(0, 0, self.canvas.winfo_width(), height)
413
+ )
414
+ return
337
415
  self.canvas.configure(scrollregion=self.canvas.bbox("all"))
338
416
 
339
417
  def _on_canvas_resize(self, event: tk.Event) -> None:
340
418
  """canvas 宽度变化时让内层 Frame 跟着变宽。"""
341
419
  self.canvas.itemconfigure(self._window, width=event.width)
342
- self.after(1, self._sync_scrollbar)
420
+ if self.item_height is None:
421
+ self.after(1, self._sync_scrollbar)
422
+ return
423
+ # 拖动窗口时 Configure 会连发很多次,合并成一次,避免反复排版
424
+ if self._resize_job is not None:
425
+ self.after_cancel(self._resize_job)
426
+ self._resize_job = self.after(60, self._after_resize)
427
+
428
+ def _after_resize(self) -> None:
429
+ """窗口尺寸稳定后补一次滚动条与视口同步。"""
430
+ self._resize_job = None
431
+ self._sync_scrollbar()
432
+ if self.item_height is not None:
433
+ self._sync_window()
434
+
435
+ # ------------------------------------------------------------------ #
436
+ # 虚拟滚动
437
+ def _card_scale(self) -> float:
438
+ """卡片 place 时 CTk 会按窗口缩放放大坐标,这里取同一个系数。"""
439
+ try:
440
+ scale = float(ctk.ScalingTracker.get_widget_scaling(self))
441
+ except Exception:
442
+ scale = 1.0
443
+ return scale if scale > 0 else 1.0
444
+
445
+ def _layout_virtual(self) -> None:
446
+ """按固定卡片高度把内层撑到总高,然后只铺视口附近的卡片。"""
447
+ height = self.item_height or 0
448
+ total = len(self._items) * height * self._card_scale()
449
+ self.inner.configure(height=total)
450
+ self.canvas.itemconfigure(self._window, width=self.canvas.winfo_width())
451
+ self.canvas.configure(
452
+ scrollregion=(0, 0, self.canvas.winfo_width(), total)
453
+ )
454
+ self._sync_window(force=True)
455
+
456
+ def _ensure_card(self, index: int) -> tk.Misc:
457
+ """按索引取卡:建过的直接复用,没建过的才调 ``render_card``。"""
458
+ card = self._built.get(index)
459
+ if card is None:
460
+ card = self.render_card(self.inner, self._items[index])
461
+ self._built[index] = card
462
+ return card
463
+
464
+ def _sync_window(
465
+ self, first: Optional[float] = None, last: Optional[float] = None, force: bool = False
466
+ ) -> None:
467
+ """把视口附近的卡片放上布局,滚出视口的收起(保留对象不销毁)。
468
+
469
+ ``first`` / ``last`` 由 yscrollcommand 回调直接给出;不传时读
470
+ ``canvas.yview()``。滚动过程中回调参数比再读一次更接近真实位置。
471
+ """
472
+ height = self.item_height
473
+ if height is None:
474
+ return
475
+ count = len(self._items)
476
+ if count == 0:
477
+ return
478
+ # 内层是普通 tk.Frame,高度按物理像素算;卡片 place 的坐标是逻辑值,
479
+ # CTk 会再乘一次缩放,所以总高要乘缩放系数,否则滚动位置会错位。
480
+ scale = self._card_scale()
481
+ total = count * height * scale
482
+ step = height * scale
483
+ if int(self.inner.cget("height")) != total:
484
+ # 任何地方把内层改小时都拉回总高,否则滚动条比例会错
485
+ self.inner.configure(height=total)
486
+ self.canvas.configure(
487
+ scrollregion=(0, 0, self.canvas.winfo_width(), total)
488
+ )
489
+ if first is None or last is None:
490
+ current = self.canvas.yview()
491
+ first, last = float(current[0]), float(current[1])
492
+ # 缓冲几张,快速滚动或拖动滚动条时不会露出空白
493
+ start = max(0, int(first * total // step) - 3)
494
+ end = min(count, int(last * total // step) + 4)
495
+ if not force and (start, end) == self._range:
496
+ return
497
+ self._range = (start, end)
498
+ for index in range(start, end):
499
+ card = self._ensure_card(index)
500
+ # CTk 控件禁止在 place() 里传 width/height,卡片高度由
501
+ # render_card 自己固定;这里只负责纵向定位和铺满宽度。
502
+ card.place(x=self.padx[0], y=index * height + self.gap[0], relwidth=1)
503
+ for index, card in list(self._built.items()):
504
+ if index < start or index >= end:
505
+ card.place_forget()