fix(ui): 修复回调类型注解与复选框锯齿

- 使用 Callable 标注解析、线程调度与主题回调,兼容 Python 3.10
- 对三态复选框应用抗锯齿绘制,并保留封面合成时的透明度
- 补充注解求值及浅色、深色主题下的图标回归测试
This commit is contained in:
肥宅水水呀
2026-09-09 14:10:47 +08:00
parent f6859dd68b
commit baf3ba3fbc
6 changed files with 64 additions and 15 deletions
+12 -2
View File
@@ -5,6 +5,7 @@
import os, re, threading, time, traceback
import tkinter as tk
from collections import Counter
from collections.abc import Callable
from tkinter import ttk, messagebox, filedialog
from urllib.parse import urlsplit, urlunsplit
from xml.etree import ElementTree
@@ -243,7 +244,12 @@ def refresh_download_progress() -> None: # 汇总全部任务状态刷新进度
progress_text += f",{failed_number} 个失败"
ui_call(progress_label.config, text=progress_text) # 更新标签以显示当前下载进度
def collect_parsed_resources(parse_fn: callable, urls: list[str], bookmarks: bool, on_progress: callable | None = None) -> tuple[list[ResourceInfo], set[str]]:
def collect_parsed_resources(
parse_fn: Callable[[str, bool], list[ResourceInfo] | None],
urls: list[str],
bookmarks: bool,
on_progress: Callable[[int, int], None] | None = None,
) -> tuple[list[ResourceInfo], set[str]]:
"""逐条解析链接并汇总结果:按资源直链去重,解析失败的链接单独收集。"""
resources_info_list: list[ResourceInfo] = []
resource_urls: set[str] = set()
@@ -262,7 +268,11 @@ def collect_parsed_resources(parse_fn: callable, urls: list[str], bookmarks: boo
resource_urls.add(resource.url)
return resources_info_list, failed_urls
def parse_urls_in_background(urls: list[str], bookmarks: bool, on_finished: callable) -> None:
def parse_urls_in_background(
urls: list[str],
bookmarks: bool,
on_finished: Callable[[list[ResourceInfo], set[str]], None],
) -> None:
"""在后台线程逐条解析链接,完成后回到主线程执行 on_finished(资源列表, 失败链接集合)。
批量选择的链接可能多达上百条,逐条解析需多次网络请求,放在主线程会让界面未响应。
+13 -7
View File
@@ -58,13 +58,19 @@ def should_check_category(leaf_ids: list[str], checked_items: set[str]) -> bool:
return category_check_state(leaf_ids, checked_items) != "checked"
def draw_checkbox_image(size: int, state: str, colors: dict[str, str]) -> Image.Image: # 绘制跟随主题配色的三态复选框图标
# 放大绘制后缩回目标尺寸,让圆角、对勾与半选横线的边缘抗锯齿。
scale = 4
target_size = size
border_width = max(2, size // 10) * scale
corner_radius = max(2, size // 5) * scale
check_width = max(2, size // 7) * scale
size *= scale
image = Image.new("RGBA", (size, size), (0, 0, 0, 0))
draw = ImageDraw.Draw(image)
selected = state in ("checked", "partial")
border_width = max(2, size // 10)
draw.rounded_rectangle(
(border_width // 2, border_width // 2, size - 1 - border_width // 2, size - 1 - border_width // 2),
radius=max(2, size // 5),
radius=corner_radius,
fill=colors["selbg"] if state == "checked" else colors["surface"],
outline=colors["selbg"] if selected else colors["muted"],
width=border_width,
@@ -73,13 +79,13 @@ def draw_checkbox_image(size: int, state: str, colors: dict[str, str]) -> Image.
draw.line(
(size * 0.24, size * 0.53, size * 0.44, size * 0.74, size * 0.78, size * 0.3),
fill=colors["selfg"],
width=max(2, size // 7),
width=check_width,
joint="curve",
)
elif state == "partial": # 半选横线
inset = size * 0.32
draw.line((inset, size / 2, size - inset, size / 2), fill=colors["selbg"], width=max(2, size // 10))
return image
draw.line((inset, size / 2, size - inset, size / 2), fill=colors["selbg"], width=border_width)
return image.resize((target_size, target_size), Image.Resampling.LANCZOS)
def build_resource_tree(pane: ttk.Frame, resource_list: dict[str, dict], url_text: tk.Text) -> None: # 在给定的子框架内构建资源列表
pane.columnconfigure(0, weight=1)
@@ -197,8 +203,8 @@ def build_resource_tree(pane: ttk.Frame, resource_list: dict[str, dict], url_tex
checkbox = checkbox_pils[state]
height = max(checkbox.height, cover.height)
image = Image.new("RGBA", (checkbox.width + checkbox_gap + cover.width, height), (0, 0, 0, 0))
image.paste(checkbox, (0, (height - checkbox.height) // 2), checkbox)
image.paste(cover, (checkbox.width + checkbox_gap, (height - cover.height) // 2), cover)
image.alpha_composite(checkbox, (0, (height - checkbox.height) // 2))
image.alpha_composite(cover, (checkbox.width + checkbox_gap, (height - cover.height) // 2))
return ImageTk.PhotoImage(image)
def refresh_item_image(item_id: str) -> None: # 重新合成并应用树项图标(勾选状态或封面变化后调用)
+3 -2
View File
@@ -3,6 +3,7 @@
import threading
import tkinter as tk
from collections.abc import Callable
ui_scale = 1.0 # 界面缩放因子,由 app.py 根据屏幕 DPI 写入
app_closing = False
@@ -14,12 +15,12 @@ def bind_root(window: tk.Tk) -> None: # 由 app.py 在创建主窗口后写入
def scaled(size: float) -> int: # 按缩放因子换算界面元素的像素尺寸
return round(size * ui_scale)
def thread_it(func: callable, *args: tuple, **kwargs: dict) -> None: # 打包函数到线程
def thread_it(func: Callable[..., object], *args: tuple, **kwargs: dict) -> None: # 打包函数到线程
t = threading.Thread(target=func, args=args, kwargs=kwargs)
t.daemon = True
t.start()
def ui_call(func: callable, *args: tuple, **kwargs: dict) -> str | None: # 在主线程执行 Tkinter UI 更新
def ui_call(func: Callable[..., object], *args: tuple, **kwargs: dict) -> str | None: # 在主线程执行 Tkinter UI 更新
if app_closing:
return None
+3 -2
View File
@@ -3,6 +3,7 @@
import subprocess
import tkinter as tk
from collections.abc import Callable
from tkinter import ttk
import tkinter.font as tkfont
from typing import Literal
@@ -16,9 +17,9 @@ switched_theme = "system" # 选择的主题
current_theme = "light" # 当前主题,若 switched_theme 为 `system` 则 current_theme 为系统主题(`light` 或 `dark`)
current_colors: dict[str, str] = {} # 当前主题的配色,在 apply_theme() 中填充
themed_widgets: set[tk.Widget] = set() # 当前仍存在且需要跟随主题调整配色的 tk 原生控件
theme_actions: list[callable] = [] # 主题应用后需要额外执行的回调(如重建跟随主题的图片资源)
theme_actions: list[Callable[[], None]] = [] # 主题应用后需要额外执行的回调(如重建跟随主题的图片资源)
def on_theme_applied(action: callable) -> None: # 登记回调,在每次 apply_theme() 末尾执行
def on_theme_applied(action: Callable[[], None]) -> None: # 登记回调,在每次 apply_theme() 末尾执行
theme_actions.append(action)
# 主题配色,surface 为 sv-ttk 卡片贴图的填充色,须与之一致,否则卡片内会出现色差;
+7 -1
View File
@@ -1,7 +1,8 @@
import unittest
from typing import get_type_hints
from src.tchmaterial_parser.api import ResourceInfo
from src.tchmaterial_parser.ui.download_panel import collect_parsed_resources
from src.tchmaterial_parser.ui.download_panel import collect_parsed_resources, parse_urls_in_background
def make_resource(url: str, title: str = "资源") -> ResourceInfo:
@@ -9,6 +10,11 @@ def make_resource(url: str, title: str = "资源") -> ResourceInfo:
class CollectParsedResourcesTest(unittest.TestCase):
def test_callback_annotations_can_be_evaluated(self) -> None:
# Python 3.14 延迟求值注解;显式求值以免启动错误被新版解释器掩盖。
get_type_hints(collect_parsed_resources)
get_type_hints(parse_urls_in_background)
def test_flattens_multiple_resources_per_url(self) -> None:
def fake_parse(url: str, bookmarks: bool):
return [make_resource("https://example.com/a.pdf"), make_resource("https://example.com/b.mp3")]
+26 -1
View File
@@ -1,6 +1,6 @@
import unittest
from PIL import Image
from PIL import Image, ImageColor
from src.tchmaterial_parser.ui.resource_tree import (
category_check_state,
@@ -128,6 +128,31 @@ class DrawCheckboxImageTest(unittest.TestCase):
# 中心区域被复选框底色填充(不透明)
self.assertEqual(image.getpixel((9, 9))[3], 255)
def test_edges_are_antialiased_in_both_themes_at_common_scales(self) -> None:
for theme_name, colors in THEME_COLORS.items():
for size in (18, 23, 27, 36):
for state in ("checked", "partial", "unchecked"):
with self.subTest(theme=theme_name, size=size, state=state):
image = draw_checkbox_image(size, state, colors)
self.assertEqual(image.size, (size, size))
self.assertEqual(image.getpixel((0, 0))[3], 0)
self.assertGreater(sum(image.getchannel("A").histogram()[1:255]), 0)
def test_checkmark_has_smooth_color_transitions(self) -> None:
for theme_name, colors in THEME_COLORS.items():
with self.subTest(theme=theme_name):
image = draw_checkbox_image(27, "checked", colors)
background = ImageColor.getrgb(colors["selbg"])
foreground = ImageColor.getrgb(colors["selfg"])
# 检查内部对勾的颜色过渡,避免仅外框抗锯齿而对勾仍有锯齿。
interior = image.crop((6, 6, 21, 21))
pixels = (interior.getpixel((x, y)) for y in range(interior.height) for x in range(interior.width))
self.assertTrue(any(
alpha == 255 and all(low < channel < high for channel, low, high in zip((r, g, b), background, foreground))
for r, g, b, alpha in pixels
))
if __name__ == "__main__":
unittest.main()