From f8f2d471e575fc5d3521c40b930c2b6f835c2878 Mon Sep 17 00:00:00 2001 From: "xinzhu.yin" Date: Thu, 28 May 2026 10:20:17 +0800 Subject: [PATCH] =?UTF-8?q?=E5=B1=8F=E6=A8=A1=E7=BB=84=E6=B7=BB=E5=8A=A0co?= =?UTF-8?q?lorinfo=E8=AE=BE=E7=BD=AE=E3=80=81=E4=BF=AE=E6=94=B9=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E9=A1=B9UI=E6=A0=B7=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/pq/pq_config.py | 14 +- app/resources.py | 12 +- app/services/pattern_service.py | 45 +++- app/views/collapsing_frame.py | 195 ++++++++++------- app/views/modern_styles.py | 219 ++++++++++++++++++++ app/views/panels/main_layout.py | 356 ++++++++++++++++++++++++++------ pqAutomationApp.py | 41 +++- settings/pq_config.json | 25 ++- 8 files changed, 752 insertions(+), 155 deletions(-) create mode 100644 app/views/modern_styles.py diff --git a/app/pq/pq_config.py b/app/pq/pq_config.py index 3518446..fde143d 100644 --- a/app/pq/pq_config.py +++ b/app/pq/pq_config.py @@ -87,6 +87,7 @@ _DEFAULT_TEST_TYPES = { "name": "屏模组性能测试", "test_items": ["gamut", "gamma", "cct", "contrast"], "timing": "DMT 1920x 1080 @ 60Hz", + "data_range": "Full", "color_format": "RGB", "bpc": 8, "colorimetry": "sRGB", @@ -96,6 +97,7 @@ _DEFAULT_TEST_TYPES = { "name": "SDR Movie测试", "test_items": ["gamut", "gamma", "cct", "contrast", "accuracy"], "timing": "DMT 1920x 1080 @ 60Hz", + "data_range": "Full", "color_format": "RGB", "bpc": 8, "colorimetry": "sRGB", @@ -105,6 +107,7 @@ _DEFAULT_TEST_TYPES = { "name": "HDR Movie测试", "test_items": ["gamut", "eotf", "cct", "contrast", "accuracy"], "timing": "DMT 1920x 1080 @ 60Hz", + "data_range": "Full", "color_format": "RGB", "bpc": 8, "colorimetry": "sRGB", @@ -695,7 +698,16 @@ class PQConfig: def from_dict(self, config_dict): """从字典加载配置""" self.current_test_type = config_dict.get("current_test_type", "screen_module") - self.current_test_types = config_dict.get("test_types", self.current_test_types) + + # 以默认模板为底,叠加历史配置,保证新字段(如 data_range)在旧配置下也有值。 + loaded_test_types = config_dict.get("test_types", {}) + merged_test_types = copy.deepcopy(_DEFAULT_TEST_TYPES) + if isinstance(loaded_test_types, dict): + for test_type, loaded_cfg in loaded_test_types.items(): + if test_type in merged_test_types and isinstance(loaded_cfg, dict): + merged_test_types[test_type].update(loaded_cfg) + self.current_test_types = merged_test_types + self.device_config = config_dict.get("device_config", self.device_config) self.custom_pattern = config_dict.get("custom_pattern", self.custom_pattern) diff --git a/app/resources.py b/app/resources.py index e58cfea..71a4cce 100644 --- a/app/resources.py +++ b/app/resources.py @@ -45,11 +45,7 @@ def load_icon(png_path): def backgroud_style_set(): - style = ttk.Style() - # 移除背景色设置,使用默认背景色 - style.configure( - "SidebarSelected.TButton", - # anchor="w", - padding=10, - background="#005470", - ) + style = ttk.Style() # noqa: F841 - 保持原副作用:确保 Style 实例化 + # 现代化样式集中注册(Card / ConfigHeader / Toolbar / StatusBar / Sidebar 等) + from app.views.modern_styles import apply_modern_styles + apply_modern_styles() diff --git a/app/services/pattern_service.py b/app/services/pattern_service.py index c759097..81ef958 100644 --- a/app/services/pattern_service.py +++ b/app/services/pattern_service.py @@ -30,15 +30,52 @@ class PatternService: source_params = self._get_source_pattern_params(mode) if test_type == "screen_module": + screen_cfg = self.app.config.current_test_types.get("screen_module", {}) + color_space = ( + self.app.screen_module_color_space_var.get() + if hasattr(self.app, "screen_module_color_space_var") + else screen_cfg.get("colorimetry", "sRGB") + ) + data_range = ( + self.app.screen_module_data_range_var.get() + if hasattr(self.app, "screen_module_data_range_var") + else screen_cfg.get("data_range", "Full") + ) + bit_depth = ( + self.app.screen_module_bit_depth_var.get() + if hasattr(self.app, "screen_module_bit_depth_var") + else f"{int(screen_cfg.get('bpc', 8))}bit" + ) + output_format = ( + self.app.screen_module_output_format_var.get() + if hasattr(self.app, "screen_module_output_format_var") + else screen_cfg.get("color_format", "RGB") + ) + if log_details: self._log("=" * 50, "separator") self._log("设置屏模组信号格式:", "info") self._log("=" * 50, "separator") - self._log( - f" Timing: {self.app.config.current_test_types[test_type]['timing']}", - "info", - ) + for label, value in [ + ("色彩空间", color_space), + ("色彩格式", output_format), + ("数据范围", data_range), + ("编码位深", bit_depth), + ("Timing", self.app.config.current_test_types[test_type]["timing"]), + ]: + self._log(f" {label}: {value}", "info") self.app.signal_service.apply_config(active_config) + success = self.app.signal_service.update_signal_format( + color_space=color_space, + data_range=data_range, + bit_depth=bit_depth, + output_format=output_format, + ) + if log_details: + self._log( + f"屏模组信号格式设置{'成功' if success else '失败'}", + "success" if success else "error", + ) elif test_type == "sdr_movie": data_range = self.app.sdr_data_range_var.get() diff --git a/app/views/collapsing_frame.py b/app/views/collapsing_frame.py index 0e50c2a..d1c8123 100644 --- a/app/views/collapsing_frame.py +++ b/app/views/collapsing_frame.py @@ -1,104 +1,149 @@ -import ttkbootstrap as ttk +"""现代化的可折叠面板(取代 v1 的图标按钮版本)。 + +升级要点(保留 ``add(child, title=...)`` 旧签名兼容): +- header 整条可点击切换展开/收起; +- 使用 Unicode chevron (▾/▸),无需 PNG 资源; +- 新增 ``preview_textvariable``:折叠时在 header 显示当前配置摘要; +- 新增 ``header_actions``:在 header 右侧注入自定义按钮(如顶部工具条)。 +""" + import tkinter from tkinter import ttk -from pathlib import Path -from ttkbootstrap import Style -import sys -import os - - -def get_resource_path(relative_path): - """ - 获取资源文件的绝对路径(兼容开发环境和打包后) - - Args: - relative_path: 相对路径,如 "assets/icons8_double_up_24px.png" - - Returns: - str: 资源文件的绝对路径 - """ - try: - # PyInstaller 打包后的临时文件夹路径 - base_path = sys._MEIPASS - except AttributeError: - # 开发环境:使用项目根目录 - # 当前文件: app/views/collapsing_frame.py - # 项目根目录: app/views 的祖父目录 - current_file = os.path.abspath(__file__) - views_dir = os.path.dirname(current_file) - app_dir = os.path.dirname(views_dir) - base_path = os.path.dirname(app_dir) - - return os.path.join(base_path, relative_path) class CollapsingFrame(ttk.Frame): - """ - A collapsible frame widget that opens and closes with a button click. - """ + """A modern collapsible frame widget.""" + + CHEVRON_OPEN = "\u25be" # ▾ + CHEVRON_CLOSED = "\u25b8" # ▸ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.columnconfigure(0, weight=1) self.cumulative_rows = 0 - p = Path(__file__).parent - self.images = [ - tkinter.PhotoImage( - name="open", file=get_resource_path("assets/icons8_double_up_24px.png") - ), - tkinter.PhotoImage( - name="closed", - file=get_resource_path("assets/icons8_double_right_24px.png"), - ), - ] + # 兼容旧代码可能引用 self.images + self.images: list = [] - def add(self, child, title="", style="primary.TButton", **kwargs): - """Add a child to the collapsible frame + # ------------------------------------------------------------------ + # 公共 API + # ------------------------------------------------------------------ + def add( + self, + child, + title: str = "", + style: str = "primary.TButton", # 兼容旧签名(不再使用) + preview_textvariable=None, + header_actions=None, + **kwargs, + ): + """添加一个子区段到折叠面板。 - :param ttk.Frame child: the child frame to add to the widget - :param str title: the title appearing on the collapsible section header - :param str style: the ttk style to apply to the collapsible section header + :param child: 必须是一个 ttk.Frame; + :param title: 标题文本; + :param preview_textvariable: 折叠时显示在 header 上的状态摘要 StringVar; + :param header_actions: 回调 ``fn(actions_frame)``,可在 header 右侧添加按钮。 """ - if child.winfo_class() != "TFrame": # must be a frame + if child.winfo_class() != "TFrame": return - style_color = style.split(".")[0] - frm = ttk.Frame(self, style=f"{style_color}.TFrame") - frm.grid(row=self.cumulative_rows, column=0, sticky="ew") - # header title - lbl = ttk.Label(frm, text=title, style=f"{style_color}.Inverse.TLabel") - if kwargs.get("textvariable"): - lbl.configure(textvariable=kwargs.get("textvariable")) - lbl.pack(side="left", fill="both", padx=10) + header = ttk.Frame(self, style="ConfigHeader.TFrame", padding=(12, 6)) + header.grid(row=self.cumulative_rows, column=0, sticky="ew") + header.columnconfigure(1, weight=1) - # header toggle button - btn = ttk.Button( - frm, - image="open", - style=style, - command=lambda c=child: self._toggle_open_close(child), + # 左:chevron + 标题 + title_box = ttk.Frame(header, style="ConfigHeader.TFrame") + title_box.grid(row=0, column=0, sticky="w") + + chevron = ttk.Label( + title_box, text=self.CHEVRON_OPEN, style="ConfigChevron.TLabel" ) - btn.pack(side="right") + chevron.pack(side="left", padx=(0, 8)) + + title_lbl = ttk.Label(title_box, text=title, style="ConfigHeader.TLabel") + if kwargs.get("textvariable"): + title_lbl.configure(textvariable=kwargs.get("textvariable")) + title_lbl.pack(side="left") + + # 中:折叠状态预览 + preview_lbl = None + if preview_textvariable is not None: + preview_lbl = ttk.Label( + header, + textvariable=preview_textvariable, + style="ConfigPreview.TLabel", + ) + preview_lbl.grid(row=0, column=1, sticky="w", padx=(16, 8)) + + # 右:actions(如顶部工具条按钮) + actions_frame = ttk.Frame(header, style="ConfigHeader.TFrame") + actions_frame.grid(row=0, column=2, sticky="e") + if callable(header_actions): + try: + header_actions(actions_frame) + except Exception: + # 注入失败不应影响整体折叠面板渲染 + pass + + # 整条 header 点击切换 + clickable = [header, title_box, chevron, title_lbl] + if preview_lbl is not None: + clickable.append(preview_lbl) + for w in clickable: + w.bind( + "", + lambda _e, c=child: self._toggle_open_close(c), + ) + try: + w.configure(cursor="hand2") + except tkinter.TclError: + pass + + child._chevron = chevron + child._header = header + child._preview_lbl = preview_lbl + # 兼容旧代码 child.btn.invoke() / child.btn.configure(image=...) + child.btn = _HeaderToggleProxy(self, child, chevron) - # assign toggle button to child so that it's accesible when toggling (need to change image) - child.btn = btn child.grid(row=self.cumulative_rows + 1, column=0, sticky="news") - - # increment the row assignment self.cumulative_rows += 2 + # ------------------------------------------------------------------ + # 内部实现 + # ------------------------------------------------------------------ def _toggle_open_close(self, child): - """ - Open or close the section and change the toggle button image accordingly - - :param ttk.Frame child: the child element to add or remove from grid manager - """ if child.winfo_viewable(): child.grid_remove() - child.btn.configure(image="closed") + try: + child._chevron.configure(text=self.CHEVRON_CLOSED) + except (AttributeError, tkinter.TclError): + pass else: child.grid() - child.btn.configure(image="open") + try: + child._chevron.configure(text=self.CHEVRON_OPEN) + except (AttributeError, tkinter.TclError): + pass + + +class _HeaderToggleProxy: + """兼容旧代码:``child.btn.invoke()`` / ``child.btn.configure(image=...)``。""" + + def __init__(self, owner: "CollapsingFrame", child, chevron): + self._owner = owner + self._child = child + self._chevron = chevron + + def invoke(self): + self._owner._toggle_open_close(self._child) + + def configure(self, **kwargs): + image = kwargs.get("image") + if image == "closed": + self._chevron.configure(text=CollapsingFrame.CHEVRON_CLOSED) + elif image == "open": + self._chevron.configure(text=CollapsingFrame.CHEVRON_OPEN) + + config = configure # class Application(tkinter.Tk): diff --git a/app/views/modern_styles.py b/app/views/modern_styles.py new file mode 100644 index 0000000..1eab196 --- /dev/null +++ b/app/views/modern_styles.py @@ -0,0 +1,219 @@ +"""现代化 UI 样式注册(跟随 ttkbootstrap 当前主题)。 + +由 backgroud_style_set() 调用一次。这里集中定义"配置项卡片化"、 +"现代化标题栏"、"工具条"、"状态栏" 等所需的所有 ttk Style, +保持主题切换时颜色自动跟随。 +""" + +from __future__ import annotations + +import ttkbootstrap as ttk + + +def _hex_to_rgb(h: str) -> tuple[int, int, int]: + h = h.lstrip("#") + return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16) + + +def _rgb_to_hex(r: int, g: int, b: int) -> str: + return f"#{r:02x}{g:02x}{b:02x}" + + +def _mix(c1: str, c2: str, ratio: float) -> str: + """按 ratio (0~1) 将 c1 与 c2 线性混合。""" + r1, g1, b1 = _hex_to_rgb(c1) + r2, g2, b2 = _hex_to_rgb(c2) + return _rgb_to_hex( + int(r1 * (1 - ratio) + r2 * ratio), + int(g1 * (1 - ratio) + g2 * ratio), + int(b1 * (1 - ratio) + b2 * ratio), + ) + + +def _is_dark(color: str) -> bool: + r, g, b = _hex_to_rgb(color) + # ITU-R BT.601 亮度 + return (r * 299 + g * 587 + b * 114) / 1000 < 128 + + +def apply_modern_styles() -> None: + """注册或刷新现代化样式集。可在主题切换后再次调用。""" + style = ttk.Style() + theme = style.colors # ttkbootstrap.style.Colors + + bg = theme.bg # 主背景 + fg = theme.fg # 主前景 + primary = theme.primary + secondary = theme.secondary + border = theme.border + inputbg = theme.inputbg + selectbg = theme.selectbg + selectfg = theme.selectfg + + dark_theme = _is_dark(bg) + + # 卡片背景:在主背景上轻微偏移,营造层级感 + card_bg = _mix(bg, "#ffffff", 0.04) if dark_theme else _mix(bg, "#000000", 0.025) + card_border = _mix(bg, fg, 0.18) if dark_theme else _mix(bg, "#000000", 0.10) + # 配置项 header 用 secondary 主题色 + header_bg = secondary + header_fg = "#ffffff" if _is_dark(secondary) else "#1a1a1a" + header_hover_bg = _mix(secondary, "#ffffff", 0.08) if _is_dark(secondary) else _mix(secondary, "#000000", 0.08) + + preview_fg = _mix(header_fg, header_bg, 0.35) + + # ---------------- 卡片 ---------------- + style.configure( + "Card.TFrame", + background=card_bg, + bordercolor=card_border, + relief="solid", + borderwidth=1, + ) + style.configure( + "CardTitle.TLabel", + background=card_bg, + foreground=primary, + font=("微软雅黑", 10, "bold"), + ) + style.configure( + "CardBody.TLabel", + background=card_bg, + foreground=fg, + font=("微软雅黑", 9), + ) + style.configure( + "CardIcon.TLabel", + background=card_bg, + foreground=primary, + font=("Segoe UI Emoji", 14), + ) + + # 内嵌于 Card 的容器(与 Card.TFrame 同背景,无边框) + style.configure("CardInner.TFrame", background=card_bg, borderwidth=0) + + # ---------------- 配置项 Header ---------------- + style.configure( + "ConfigHeader.TFrame", + background=header_bg, + borderwidth=0, + ) + style.configure( + "ConfigHeaderHover.TFrame", + background=header_hover_bg, + borderwidth=0, + ) + style.configure( + "ConfigHeader.TLabel", + background=header_bg, + foreground=header_fg, + font=("微软雅黑", 10, "bold"), + ) + style.configure( + "ConfigHeaderHover.TLabel", + background=header_hover_bg, + foreground=header_fg, + font=("微软雅黑", 10, "bold"), + ) + style.configure( + "ConfigChevron.TLabel", + background=header_bg, + foreground=header_fg, + font=("Segoe UI Symbol", 12, "bold"), + ) + style.configure( + "ConfigPreview.TLabel", + background=header_bg, + foreground=preview_fg, + font=("微软雅黑", 9), + ) + + # ---------------- 顶部工具条 ---------------- + style.configure("Toolbar.TFrame", background=bg, borderwidth=0) + # 工具条上的次要按钮(清理配置等) + style.configure( + "ToolbarMuted.TButton", + font=("微软雅黑", 9), + padding=(10, 5), + ) + + # ---------------- 区段标题(侧栏 / 卡片外) ---------------- + style.configure( + "SectionTitle.TLabel", + background=bg, + foreground=_mix(fg, bg, 0.45), + font=("微软雅黑", 8, "bold"), + ) + # 侧栏内的小区段标题(侧栏背景是 primary) + style.configure( + "SidebarSection.TLabel", + background=primary, + foreground=_mix("#ffffff", primary, 0.35), + font=("微软雅黑", 8, "bold"), + ) + + # ---------------- 结果区无边框标题行 ---------------- + style.configure("ResultHeader.TFrame", background=bg, borderwidth=0) + style.configure( + "ResultHeader.TLabel", + background=bg, + foreground=fg, + font=("微软雅黑", 11, "bold"), + ) + + # ---------------- 状态栏 ---------------- + statusbar_bg = _mix(bg, "#000000", 0.06) if not dark_theme else _mix(bg, "#ffffff", 0.06) + statusbar_fg = _mix(fg, bg, 0.15) + style.configure( + "StatusBar.TFrame", + background=statusbar_bg, + borderwidth=0, + ) + style.configure( + "StatusBar.TLabel", + background=statusbar_bg, + foreground=statusbar_fg, + font=("微软雅黑", 9), + padding=(10, 4), + ) + style.configure( + "StatusBarAccent.TLabel", + background=statusbar_bg, + foreground=primary, + font=("微软雅黑", 9, "bold"), + padding=(10, 4), + ) + + # ---------------- Sidebar 按钮(保留兼容名) ---------------- + style.configure( + "Sidebar.TButton", + background=primary, + foreground="#ffffff" if _is_dark(primary) else "#1a1a1a", + font=("微软雅黑", 10), + padding=(12, 10), + borderwidth=0, + anchor="w", + ) + style.map( + "Sidebar.TButton", + background=[ + ("active", _mix(primary, "#ffffff", 0.10)), + ("pressed", _mix(primary, "#000000", 0.10)), + ], + ) + style.configure( + "SidebarSelected.TButton", + background=_mix(primary, "#000000", 0.18), + foreground="#ffffff", + font=("微软雅黑", 10, "bold"), + padding=(12, 10), + borderwidth=0, + anchor="w", + ) + style.map( + "SidebarSelected.TButton", + background=[ + ("active", _mix(primary, "#000000", 0.10)), + ("pressed", _mix(primary, "#000000", 0.25)), + ], + ) diff --git a/app/views/panels/main_layout.py b/app/views/panels/main_layout.py index c628c00..10621b8 100644 --- a/app/views/panels/main_layout.py +++ b/app/views/panels/main_layout.py @@ -14,50 +14,129 @@ if TYPE_CHECKING: from pqAutomationApp import PQAutomationApp +# --------------------------------------------------------------------------- +# 内部工具:现代化卡片容器 +# --------------------------------------------------------------------------- +def _make_card(parent, icon: str, title: str) -> ttk.Frame: + """构造一个"卡片"容器(圆角描边 + 顶部图标/标题)。 + + 返回值是 outer card frame;通过 ``outer._body`` 拿到内部 body Frame, + 旧代码里习惯把控件挂在 ``self.connection_frame`` 等"卡片本身"上,所以 + 保留 outer 作为对外别名,但实际控件父级换成 body 以避开边框 padding。 + """ + outer = ttk.Frame(parent, style="Card.TFrame", padding=12) + header = ttk.Frame(outer, style="CardInner.TFrame") + header.pack(fill="x", pady=(0, 8)) + ttk.Label(header, text=icon, style="CardIcon.TLabel").pack(side="left", padx=(0, 8)) + ttk.Label(header, text=title, style="CardTitle.TLabel").pack(side="left") + body = ttk.Frame(outer, style="CardInner.TFrame") + body.pack(fill="both", expand=True) + outer._body = body # type: ignore[attr-defined] + return outer + + def create_floating_config_panel(self: "PQAutomationApp"): - """创建右上角悬浮配置框""" + """创建顶部"配置项"现代化折叠面板。 + + 布局变化(vs 旧版): + - 用 Unicode chevron + 整条 header 可点击折叠/展开; + - header 上额外显示折叠状态预览(``config_preview_var``); + - header 右侧承载常驻操作工具条(开始/停止/保存 等),不再放中部; + - 内部三个区段从 LabelFrame 改为统一的 Card 样式。 + """ cf = CollapsingFrame(self.control_frame_top) cf.pack(fill="both") - # 创建悬浮框主容器 + self._config_collapsing = cf + + # 配置项主体容器(卡片宿主) self.config_panel_frame = ttk.Frame(cf) - cf.add(self.config_panel_frame, title="配置项") - # 创建一个统一的frame来替代选项卡控件 + # 折叠预览:呈现"测试类型 · 已选测试项" + self.config_preview_var = tk.StringVar(value="") + + # header 右侧工具条占位 —— create_operation_frame 之后向这里挂按钮 + self.toolbar_actions_frame: ttk.Frame | None = None + + def _header_actions(parent: ttk.Frame): + # 暴露给 create_operation_frame 使用 + self.toolbar_actions_frame = parent + + cf.add( + self.config_panel_frame, + title="配置项", + preview_textvariable=self.config_preview_var, + header_actions=_header_actions, + ) + + # 卡片三栏 self.config_content_frame = ttk.Frame(self.config_panel_frame) - self.config_content_frame.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) + self.config_content_frame.pack(fill=tk.BOTH, expand=True, padx=8, pady=8) - # 创建一个横向排列的Frame config_row_frame = ttk.Frame(self.config_content_frame) - config_row_frame.pack(fill=tk.X, expand=False, padx=5, pady=5) + config_row_frame.pack(fill=tk.X, expand=False) - # 创建连接内容区域 - self.connection_frame = ttk.LabelFrame(config_row_frame, text="设备连接") - self.connection_frame.pack( - side=tk.LEFT, fill=tk.BOTH, expand=True, padx=5, pady=5 - ) + # 设备连接 卡片 + connection_card = _make_card(config_row_frame, icon="\U0001F4E1", title="设备连接") # 📡 + connection_card.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(0, 6)) + self.connection_frame = connection_card._body # type: ignore[attr-defined] - # 创建测试项目区域 - self.test_items_frame = ttk.LabelFrame(config_row_frame, text="测试项目") - self.test_items_frame.pack( - side=tk.LEFT, fill=tk.BOTH, expand=True, padx=5, pady=5 - ) + # 测试项目 卡片 + test_items_card = _make_card(config_row_frame, icon="\u2714", title="测试项目") # ✔ + test_items_card.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=6) + self.test_items_frame = test_items_card._body # type: ignore[attr-defined] - # 创建信号格式区域 - self.signal_format_frame = ttk.LabelFrame(config_row_frame, text="信号格式") - self.signal_format_frame.pack( - side=tk.LEFT, fill=tk.BOTH, expand=True, padx=5, pady=5 - ) + # 信号格式 卡片 + signal_card = _make_card(config_row_frame, icon="\u2699", title="信号格式") # ⚙ + signal_card.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(6, 0)) + self.signal_format_frame = signal_card._body # type: ignore[attr-defined] - # 创建连接内容 + # 创建卡片内部内容(沿用旧函数,父级已是 body Frame) self.create_connection_content() - # 创建测试项目内容 self.create_test_items_content() - # 创建信号格式内容 self.create_signal_format_content() + # 默认收起 —— 与旧版行为保持一致 self.config_panel_frame.grid_remove() self.config_panel_frame.btn.configure(image="closed") + # 初始化预览文本 + refresh_config_preview(self) + + +def refresh_config_preview(self: "PQAutomationApp") -> None: + """根据当前测试类型 + 已选测试项刷新折叠预览。""" + if not hasattr(self, "config_preview_var"): + return + type_labels = { + "screen_module": "屏模组", + "sdr_movie": "SDR Movie", + "hdr_movie": "HDR Movie", + } + current_type = getattr(self.config, "current_test_type", "") + type_label = type_labels.get(current_type, "") + + item_labels = [] + if current_type and hasattr(self, "test_items"): + info = self.test_items.get(current_type, {}) + label_map = {code: name for name, code in info.get("items", [])} + if hasattr(self, "test_vars"): + for code, var in self.test_vars.items(): + try: + if var.get(): + item_labels.append(label_map.get(code, code)) + except Exception: + pass + + parts = [] + if type_label: + parts.append(f"[{type_label}]") + if item_labels: + # 限制宽度,避免顶部条过挤 + shown = item_labels[:5] + suffix = " \u2026" if len(item_labels) > 5 else "" + parts.append(" \u00b7 ".join(shown) + suffix) + self.config_preview_var.set(" ".join(parts)) + def create_test_items_content(self: "PQAutomationApp"): """创建测试项目选项卡内容""" @@ -109,13 +188,17 @@ def create_signal_format_content(self: "PQAutomationApp"): # ==================== 屏模组格式设置 ==================== self.screen_module_signal_frame = ttk.Frame(self.signal_tabs) - self.screen_module_signal_frame.grid_columnconfigure(0, weight=1) + self.screen_module_signal_frame.grid_columnconfigure(0, weight=0) + self.screen_module_signal_frame.grid_columnconfigure(1, weight=1) self.signal_tabs.add(self.screen_module_signal_frame, text="屏模组测试") + screen_cfg = self.config.current_test_types.get("screen_module", {}) + self.screen_module_timing_var = tk.StringVar( - value=self.config.current_test_types[self.config.current_test_type][ - "timing" - ] + value=screen_cfg.get("timing", "DMT 1920x 1080 @ 60Hz") + ) + ttk.Label(self.screen_module_signal_frame, text="分辨率:").grid( + row=0, column=0, sticky=tk.W, padx=5, pady=2 ) screen_module_timing_combo = ttk.Combobox( self.screen_module_signal_frame, @@ -126,7 +209,83 @@ def create_signal_format_content(self: "PQAutomationApp"): screen_module_timing_combo.bind( "<>", self.on_screen_module_timing_changed ) - screen_module_timing_combo.grid(row=0, column=0, sticky="ew", padx=5, pady=5) + screen_module_timing_combo.grid(row=0, column=1, sticky=tk.W, padx=5, pady=2) + + ttk.Label(self.screen_module_signal_frame, text="色彩空间:").grid( + row=1, column=0, sticky=tk.W, padx=5, pady=2 + ) + self.screen_module_color_space_var = tk.StringVar( + value=screen_cfg.get("colorimetry", "sRGB") + ) + screen_module_color_space_combo = ttk.Combobox( + self.screen_module_signal_frame, + textvariable=self.screen_module_color_space_var, + values=["sRGB", "BT.709", "BT.601", "BT.2020", "DCI-P3"], + width=10, + state="readonly", + ) + screen_module_color_space_combo.bind( + "<>", self.on_screen_module_signal_format_changed + ) + screen_module_color_space_combo.grid(row=1, column=1, sticky=tk.W, padx=5, pady=2) + + ttk.Label(self.screen_module_signal_frame, text="数据范围:").grid( + row=2, column=0, sticky=tk.W, padx=5, pady=2 + ) + self.screen_module_data_range_var = tk.StringVar( + value=screen_cfg.get("data_range", UCDEnum.SignalFormat.DataRange.FULL) + ) + screen_module_data_range_combo = ttk.Combobox( + self.screen_module_signal_frame, + textvariable=self.screen_module_data_range_var, + values=UCDEnum.SignalFormat.DataRange.get_list(), + width=10, + state="readonly", + ) + screen_module_data_range_combo.bind( + "<>", self.on_screen_module_signal_format_changed + ) + screen_module_data_range_combo.grid(row=2, column=1, sticky=tk.W, padx=5, pady=2) + + default_screen_bpc = int(screen_cfg.get("bpc", 8)) + default_screen_bit_depth = ( + f"{default_screen_bpc}bit" + if f"{default_screen_bpc}bit" in UCDEnum.SignalFormat.BitDepth.get_list() + else UCDEnum.SignalFormat.BitDepth.BIT_8 + ) + ttk.Label(self.screen_module_signal_frame, text="编码位深:").grid( + row=3, column=0, sticky=tk.W, padx=5, pady=2 + ) + self.screen_module_bit_depth_var = tk.StringVar(value=default_screen_bit_depth) + screen_module_bit_depth_combo = ttk.Combobox( + self.screen_module_signal_frame, + textvariable=self.screen_module_bit_depth_var, + values=UCDEnum.SignalFormat.BitDepth.get_list(), + width=10, + state="readonly", + ) + screen_module_bit_depth_combo.bind( + "<>", self.on_screen_module_signal_format_changed + ) + screen_module_bit_depth_combo.grid(row=3, column=1, sticky=tk.W, padx=5, pady=2) + + ttk.Label(self.screen_module_signal_frame, text="色彩格式:").grid( + row=4, column=0, sticky=tk.W, padx=5, pady=2 + ) + self.screen_module_output_format_var = tk.StringVar( + value=screen_cfg.get("color_format", UCDEnum.SignalFormat.OutputFormat.RGB) + ) + screen_module_output_format_combo = ttk.Combobox( + self.screen_module_signal_frame, + textvariable=self.screen_module_output_format_var, + values=UCDEnum.SignalFormat.OutputFormat.get_list(), + width=10, + state="readonly", + ) + screen_module_output_format_combo.bind( + "<>", self.on_screen_module_signal_format_changed + ) + screen_module_output_format_combo.grid(row=4, column=1, sticky=tk.W, padx=5, pady=2) # ==================== SDR信号格式设置 ==================== self.sdr_signal_frame = ttk.Frame(self.signal_tabs) @@ -578,57 +737,87 @@ def update_config_info_display(self: "PQAutomationApp"): def create_operation_frame(self: "PQAutomationApp"): - """创建操作按钮区域""" - operation_frame = ttk.Frame(self.control_frame_top) - operation_frame.pack(fill=tk.X, padx=5, pady=10) + """创建操作按钮区域。 + + 新布局:按钮挂到配置项 header 右侧常驻工具条 ``self.toolbar_actions_frame``。 + 若该容器尚未创建(极端情况下顺序异常),回退到原 control_frame_top 位置, + 确保按钮始终可见、调用方代码不破。 + """ + parent = getattr(self, "toolbar_actions_frame", None) + fallback = parent is None + if fallback: + parent = ttk.Frame(self.control_frame_top) + parent.pack(fill=tk.X, padx=5, pady=8) + + # 用 bootstyle 取代旧 style="xxx.TButton",跟随主题; + # 给按钮统一加 padding,触摸友好。 + btn_pad = dict(padx=4, pady=0) self.start_btn = ttk.Button( - operation_frame, - text="开始测试", + parent, + text="\u25b6 开始测试", command=self.start_test, - style="success.TButton", + bootstyle="success", + padding=(12, 6), + takefocus=False, ) - self.start_btn.pack(side=tk.LEFT, padx=5) + self.start_btn.pack(side=tk.LEFT, **btn_pad) self.simulate_btn = ttk.Button( - operation_frame, + parent, text="模拟测试", command=self.run_simulation_test, - style="warning.TButton", + bootstyle="warning-outline", + padding=(12, 6), + takefocus=False, ) - self.simulate_btn.pack(side=tk.LEFT, padx=5) + self.simulate_btn.pack(side=tk.LEFT, **btn_pad) self.stop_btn = ttk.Button( - operation_frame, - text="停止测试", + parent, + text="\u25a0 停止", command=self.stop_test, - style="danger.TButton", + bootstyle="danger", + padding=(12, 6), state=tk.DISABLED, + takefocus=False, ) - self.stop_btn.pack(side=tk.LEFT, padx=5) + self.stop_btn.pack(side=tk.LEFT, **btn_pad) + + # 分隔 + ttk.Separator(parent, orient="vertical").pack(side=tk.LEFT, fill="y", padx=8, pady=4) self.save_btn = ttk.Button( - operation_frame, + parent, text="保存结果", command=self.save_results, + bootstyle="info-outline", + padding=(12, 6), state=tk.DISABLED, + takefocus=False, ) - self.save_btn.pack(side=tk.LEFT, padx=5) - - self.clear_config_btn = ttk.Button( - operation_frame, - text="清理配置", - command=self.clear_config_file, - ) - self.clear_config_btn.pack(side=tk.LEFT, padx=5) + self.save_btn.pack(side=tk.LEFT, **btn_pad) self.custom_btn = ttk.Button( - operation_frame, + parent, text="客户模版", command=self.start_custom_template_test, - style="info.TButton", + bootstyle="info", + padding=(12, 6), + takefocus=False, ) - self.custom_btn.pack(side=tk.LEFT, padx=5) + self.custom_btn.pack(side=tk.LEFT, **btn_pad) + + self.clear_config_btn = ttk.Button( + parent, + text="清理配置", + command=self.clear_config_file, + bootstyle="secondary-outline", + padding=(10, 6), + takefocus=False, + ) + self.clear_config_btn.pack(side=tk.LEFT, **btn_pad) + self.update_custom_button_visibility() @@ -656,8 +845,10 @@ def on_screen_module_timing_changed(self: "PQAutomationApp", event=None): if refresh_rate >= 120: self.log_gui.log(" ℹ️ 检测到高刷新率", level="info") - # 更新配置 - self.config.set_current_timing(selected_timing) + # 更新屏模组配置(独立于 current_test_type) + self.config.current_test_types.setdefault("screen_module", {})[ + "timing" + ] = selected_timing # 如果正在测试,提示用户 if self.testing: @@ -670,6 +861,50 @@ def on_screen_module_timing_changed(self: "PQAutomationApp", event=None): self.log_gui.log(f"屏模组信号格式更改失败: {str(e)}", level="error") +def on_screen_module_signal_format_changed(self: "PQAutomationApp", event=None): + """屏模组 ColorInfo 相关选项变更回调。""" + try: + color_space = self.screen_module_color_space_var.get() + data_range = self.screen_module_data_range_var.get() + bit_depth = self.screen_module_bit_depth_var.get() + output_format = self.screen_module_output_format_var.get() + + screen_cfg = self.config.current_test_types.setdefault("screen_module", {}) + screen_cfg["colorimetry"] = color_space + screen_cfg["color_format"] = output_format + screen_cfg["bpc"] = UCDEnum.SignalFormat.BitDepth.get_bit_value(bit_depth) + screen_cfg["data_range"] = data_range + + self.log_gui.log( + ( + "屏模组信号格式已更新: " + f"色彩空间={color_space}, 数据范围={data_range}, " + f"位深={bit_depth}, 色彩格式={output_format}" + ), + level="info", + ) + + if self.testing: + self.log_gui.log("警告: 测试进行中,格式更改将在下次测试时生效", level="error") + self.save_pq_config() + return + + if getattr(self.ucd, "status", False): + ok = self.signal_service.update_signal_format( + color_space=color_space, + data_range=data_range, + bit_depth=bit_depth, + output_format=output_format, + ) + if not ok: + self.log_gui.log("屏模组信号格式应用到UCD失败", level="error") + + self.save_pq_config() + + except Exception as e: + self.log_gui.log(f"屏模组信号格式更改失败: {str(e)}", level="error") + + def on_sdr_timing_changed(self: "PQAutomationApp", event=None): """SDR测试分辨率改变时的回调""" try: @@ -784,6 +1019,9 @@ def update_test_items(self: "PQAutomationApp"): if hasattr(self, "cct_params_frame"): self.toggle_cct_params_frame() + # 同步刷新 header 折叠预览 + refresh_config_preview(self) + def on_test_type_change(self: "PQAutomationApp"): """根据测试类型更新内容区域""" @@ -801,6 +1039,7 @@ class MainLayoutMixin: 把本模块的自由函数挂到 PQAutomationApp 上,便于 F12 跳转与类型推断。 """ create_floating_config_panel = create_floating_config_panel + refresh_config_preview = refresh_config_preview create_test_items_content = create_test_items_content create_signal_format_content = create_signal_format_content create_connection_content = create_connection_content @@ -808,6 +1047,7 @@ class MainLayoutMixin: update_config_info_display = update_config_info_display create_operation_frame = create_operation_frame on_screen_module_timing_changed = on_screen_module_timing_changed + on_screen_module_signal_format_changed = on_screen_module_signal_format_changed on_sdr_timing_changed = on_sdr_timing_changed on_sdr_output_format_changed = on_sdr_output_format_changed on_hdr_output_format_changed = on_hdr_output_format_changed diff --git a/pqAutomationApp.py b/pqAutomationApp.py index 9c2a548..4df6777 100644 --- a/pqAutomationApp.py +++ b/pqAutomationApp.py @@ -183,8 +183,8 @@ class PQAutomationApp( # 创建右上角悬浮配置框 self.create_floating_config_panel() - # 创建右侧结果显示区域 - self.result_frame = ttk.LabelFrame(self.control_frame_middle, text="测试结果") + # 创建右侧结果显示区域(无边框,纯 Frame,让图表占满) + self.result_frame = ttk.Frame(self.control_frame_middle) self.result_frame.pack( side=tk.BOTTOM, fill=tk.BOTH, expand=True, padx=0, pady=5 ) @@ -214,12 +214,24 @@ class PQAutomationApp( # 在所有控件创建完成后,统一初始化测试类型 self.root.after(100, self.initialize_default_test_type) - # 状态栏 - self.status_var = tk.StringVar(value="就绪") + # 状态栏(现代化扁平条,跟随 ttkbootstrap 主题) + self.status_var = tk.StringVar(value="\u25cf 就绪") + status_container = ttk.Frame(root, style="StatusBar.TFrame") + status_container.pack(side=tk.BOTTOM, fill=tk.X) self.status_bar = ttk.Label( - root, textvariable=self.status_var, relief=tk.SUNKEN, anchor=tk.W + status_container, + textvariable=self.status_var, + style="StatusBar.TLabel", + anchor=tk.W, ) - self.status_bar.pack(side=tk.BOTTOM, fill=tk.X) + self.status_bar.pack(side=tk.LEFT, fill=tk.X, expand=True) + # 右侧版本号 + ttk.Label( + status_container, + text=f"v{APP_VERSION}", + style="StatusBarAccent.TLabel", + anchor=tk.E, + ).pack(side=tk.RIGHT) def _dispatch_ui(self, fn, *args, **kwargs): """把 ``fn(*args, **kwargs)`` 调度到 Tk 主线程执行。 @@ -752,8 +764,14 @@ class PQAutomationApp( # 保存当前选中的测试项到配置 self.config.set_current_test_items(self.get_selected_test_items()) - # 待修改为三种测试类型的timing值 - self.config.set_current_timing(self.screen_module_timing_var.get()) + # 按当前测试类型保存对应 timing,避免误覆盖其它测试类型配置。 + if self.config.current_test_type == "screen_module": + self.config.set_current_timing(self.screen_module_timing_var.get()) + elif ( + self.config.current_test_type == "sdr_movie" + and hasattr(self, "sdr_timing_var") + ): + self.config.set_current_timing(self.sdr_timing_var.get()) # 自动保存配置到文件 self.save_pq_config() @@ -772,6 +790,13 @@ class PQAutomationApp( # 控制参数框的显示 self.toggle_cct_params_frame() + # 同步刷新顶部 header 折叠预览(现代化布局新增) + if hasattr(self, "refresh_config_preview"): + try: + self.refresh_config_preview() + except Exception: + pass + def on_closing(self): """窗口关闭时的处理""" try: diff --git a/settings/pq_config.json b/settings/pq_config.json index 50f46c1..3b0c675 100644 --- a/settings/pq_config.json +++ b/settings/pq_config.json @@ -10,9 +10,16 @@ "contrast" ], "timing": "OVT 1280x 720 @ 120Hz", + "data_range": "Full", "color_format": "RGB", "bpc": 8, - "colorimetry": "sRGB", + "colorimetry": "DCI-P3", + "patterns": { + "gamut": "rgb", + "gamma": "gray", + "cct": "gray", + "contrast": "rgb" + }, "cct_params": { "x_ideal": 0.3127, "x_tolerance": 0.003, @@ -28,9 +35,17 @@ "accuracy" ], "timing": "OVT 1280x 720 @ 120Hz", + "data_range": "Full", "color_format": "RGB", "bpc": 8, "colorimetry": "sRGB", + "patterns": { + "gamut": "rgb", + "gamma": "gray", + "cct": "gray", + "contrast": "rgb", + "accuracy": "accuracy" + }, "cct_params": { "x_ideal": 0.3127, "x_tolerance": 0.003, @@ -49,9 +64,17 @@ "accuracy" ], "timing": "DMT 1920x 1080 @ 60Hz", + "data_range": "Full", "color_format": "RGB", "bpc": 8, "colorimetry": "sRGB", + "patterns": { + "gamut": "rgb", + "eotf": "gray", + "cct": "gray", + "contrast": "rgb", + "accuracy": "accuracy" + }, "cct_params": { "x_ideal": 0.3127, "x_tolerance": 0.003,