mirror of
https://github.com/happycola233/tchMaterial-parser.git
synced 2026-10-29 15:26:53 +08:00
fix(ui): 修复回调类型注解与复选框锯齿
- 使用 Callable 标注解析、线程调度与主题回调,兼容 Python 3.10 - 对三态复选框应用抗锯齿绘制,并保留封面合成时的透明度 - 补充注解求值及浅色、深色主题下的图标回归测试
This commit is contained in:
@@ -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(资源列表, 失败链接集合)。
|
||||
|
||||
批量选择的链接可能多达上百条,逐条解析需多次网络请求,放在主线程会让界面未响应。
|
||||
|
||||
@@ -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,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,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 卡片贴图的填充色,须与之一致,否则卡片内会出现色差;
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user