# -*- coding: utf-8 -*- """模型选择弹窗 · 可视化调参工具 运行: C:\\Users\\14890\\miniconda3\\envs\\haocode\\python.exe tests\\tune_model_popup.py - 左侧:调参窗(改参数 → 右侧弹窗实时重渲染) - 右侧:真实的 ModelSelectPopup 本体(独立窗口,点调参窗不会消失) - 满意后点「确定」→ 参数 JSON 写入 data/model_popup_tune.json → 自动退出 """ import os import sys import json import traceback sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu" os.environ.setdefault("HAOCODE_RENDER", "software") from PyQt6.QtWidgets import ( # noqa: E402 QApplication, QWidget, QFormLayout, QHBoxLayout, QSpinBox, QPushButton, QCheckBox, QLabel, QMessageBox, QComboBox, ) from PyQt6.QtCore import Qt, QSize # noqa: E402 from PyQt6.QtGui import QPixmap, QIcon, QPainter, QFont, QFontDatabase # noqa: E402 from ui.views.main_window import ModelSelectPopup, MainWindow, _popup_svg_path # noqa: E402 (先于 QApplication) DEFAULTS = { # —— 字体 —— "font_family": "Microsoft YaHei", # 字体族(下拉选择;调参确认值) # —— 模型行 —— "item_font": 13, # 模型名字号(经 list_widget.setFont 生效) "model_item_h": 27, # 模型行高 "model_icon": 16, # 模型图标尺寸 "model_icon_pad": 34, # 模型行左边距(图标透明左垫,仅作用模型行) # —— 供应商行 —— "header_height": 38, # 供应商行高(= widget 高;默认 38 = 当前代码实际行高) "header_font": 13, # 供应商名字号 "count_font": 10, # 数量字号 "provider_svg": "provider.svg", # 供应商图标(3 个 SVG 可切换) "header_icon": 16, # 供应商图标尺寸 "header_icon_pad": 0, # 供应商图标水平位置(左侧留白,仅推图标与后续内容) "header_icon_pad_v": 1, # 供应商图标垂直位置(正=下移,负=上移) "arrow_size": 13, # 展开/收起箭头尺寸 "header_hpad_l": 3, # 供应商行左边距(头部按钮左 margin) # —— 整体 —— "popup_width": 340, # 弹窗宽度 "container_vmargin": 4, # 容器上下边距 } RANGES = { "item_font": (10, 20), "model_item_h": (18, 40), "model_icon": (8, 28), "model_icon_pad": (0, 48), "header_height": (20, 60), "header_font": (9, 18), "count_font": (8, 16), "header_icon": (10, 28), "header_icon_pad": (0, 30), "header_icon_pad_v": (-10, 10), "arrow_size": (8, 20), "header_hpad_l": (0, 20), "popup_width": (260, 420), "container_vmargin": (0, 12), } CN = { "font_family": "字体(下拉选择)", "item_font": "模型名字号 (px)", "model_item_h": "模型行高 (px)", "model_icon": "模型图标尺寸 (px)", "model_icon_pad": "模型行左边距 (px)", "header_height": "供应商行高 (px)", "header_font": "供应商名字号 (px)", "count_font": "数量字号 (px)", "header_icon": "供应商图标尺寸 (px)", "provider_svg": "供应商图标(SVG 切换)", "header_icon_pad": "供应商图标水平位置 (px)", "header_icon_pad_v": "供应商图标垂直位置 (px,正=下移)", "arrow_size": "箭头尺寸 (px)", "header_hpad_l": "供应商行左边距 (px)", "popup_width": "弹窗宽度 (px)", "container_vmargin": "容器上下边距 (px)", } SECTIONS = [ ("字体", ["font_family"]), ("模型行", ["item_font", "model_item_h", "model_icon", "model_icon_pad"]), ("供应商行", ["header_height", "header_font", "count_font", "provider_svg", "header_icon", "header_icon_pad", "header_icon_pad_v", "arrow_size", "header_hpad_l"]), ("整体", ["popup_width", "container_vmargin"]), ] # 供应商图标候选(文件名, 显示名) PROVIDER_SVGS = [ ("provider.svg", "图标 1 · 服务器"), ("provider2.svg", "图标 2 · 二级服务器"), ("provider3.svg", "图标 3 · 服务器(细线)"), ] FONT_STACK = '"HarmonyOS Sans SC", "Microsoft YaHei", "Noto Sans SC", sans-serif' QSS_TMPL = """ * { font-family: FONT_STACK; } #popup_container { background-color: #ffffff; border: 1px solid #dcdcdc; border-radius: 12px; } #group_toggle_btn { background: transparent; border: none; } #group_toggle_btn:hover { background-color: #f2f5f9; border-radius: 6px; } #model_list { border: none; background: transparent; outline: none; } #model_list::item { font-family: FONT_STACK; color: #333333; font-weight: normal; /* 实测本 Qt/PyQt6 构建下 ::item 仅 background-color/color 生效; font-size/font 缩写/font-family/margin/padding/border-radius 全部无效—— 字号走 setFont,行高走 sizeHint,边距走图标透明垫/按钮 margin */ } #model_list::item:hover { background-color: #f0f4f9; color: #111111; } #model_list::item:selected { background-color: #e8f0fe; color: #1a73e8; font-weight: bold; } QScrollBar:vertical { border: none; background: transparent; width: 5px; margin: 12px 2px; } QScrollBar::handle:vertical { background: #d0d0d0; min-height: 20px; border-radius: 3px; } QScrollBar::handle:vertical:hover { background: #a0a0a0; } QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0px; } """ class Tuner: def __init__(self): self.app = QApplication(sys.argv) self.params = dict(DEFAULTS) # ---- 弹窗本体(真实类 + 真实 config + 真实祖先上下文) ---- # ⚠️ 保真关键:实际 App 中弹窗是 MainWindow 的子部件,会继承其全局样式表 # (QListWidget::item 的 padding/margin/选中蓝条等);若 parent=None 渲染, # 就会出“调试与实际不符”的矛盾 → 借 MainWindow.setup_stylesheet 把真实 # 样式表套到宿主上,弹窗挂在宿主下渲染(与生产同构) self._host = QWidget() MainWindow.setup_stylesheet(self._host) self._host.resize(1, 1) self._host.move(-200, -200) self._host.show() cfg_path = os.path.join(os.path.dirname(__file__), "..", "data", "config.json") with open(cfg_path, "r", encoding="utf-8") as f: cfg = json.load(f) self.popup = ModelSelectPopup(self._host, cfg) self.popup.setWindowFlags(Qt.WindowType.Window | Qt.WindowType.FramelessWindowHint) self.list = self.popup.list_widget self._tag_headers() screen = self.app.primaryScreen().availableGeometry() self.px = screen.center().x() - 200 self.py = screen.center().y() - 300 self.popup.move(self.px, self.py) self.popup.show() # ---- 调参窗 ---- self.win = QWidget() self.win.setWindowTitle("模型选择弹窗调参 —— 调好后点「确定」") form = QFormLayout(self.win) form.setSpacing(7) self.spins = {} self.combos = {} self.broken = set() # 用户标记的“不生效”参数 for title, keys in SECTIONS: form.addRow(QLabel(f"—— {title} ——")) for key in keys: mark = QPushButton("不生效") mark.setCheckable(True) mark.setFixedWidth(58) mark.setCursor(Qt.CursorShape.PointingHandCursor) mark.setStyleSheet(""" QPushButton { font-size: 11px; color: #888888; border: 1px solid #cccccc; border-radius: 4px; background: #fafafa; } QPushButton:checked { color: #ffffff; background: #d93025; border-color: #d93025; } """) mark.toggled.connect(lambda on, k=key: self.on_mark(k, on)) row = QHBoxLayout() row.setSpacing(4) if key == "font_family": combo = QComboBox() fams = ["HarmonyOS Sans SC", "Microsoft YaHei", "Noto Sans SC", "Segoe UI", "Arial", "Consolas", "SimHei", "PingFang SC"] seen = set() for fam in fams + sorted(QFontDatabase.families()): if fam and fam not in seen: seen.add(fam) combo.addItem(fam) combo.setCurrentText(self.params[key]) combo.currentTextChanged.connect(lambda v, k=key: self.on_change(k, v)) self.combos[key] = combo row.addWidget(combo, 1) elif key == "provider_svg": combo = QComboBox() for fname, cname in PROVIDER_SVGS: combo.addItem(cname, fname) combo.setCurrentIndex(max(combo.findData(self.params[key]), 0)) combo.currentIndexChanged.connect( lambda i, k=key, c=combo: self.on_change(k, c.itemData(i) or "provider.svg")) self.combos[key] = combo row.addWidget(combo, 1) else: lo, hi = RANGES[key] sp = QSpinBox() sp.setRange(lo, hi) sp.setValue(self.params[key]) sp.valueChanged.connect(lambda v, k=key: self.on_change(k, v)) self.spins[key] = sp row.addWidget(sp, 1) row.addWidget(mark) form.addRow(CN[key], row) self.chk_selected = QCheckBox("显示选中行(预览)") self.chk_selected.toggled.connect(self.on_toggle_selected) form.addRow(self.chk_selected) btns = QHBoxLayout() b_reset = QPushButton("重置默认") b_reset.clicked.connect(self.reset) b_ok = QPushButton("✅ 确定(写入日志并退出)") b_ok.setStyleSheet("font-weight: bold;") b_ok.clicked.connect(self.finish) btns.addWidget(b_reset) btns.addWidget(b_ok) form.addRow(btns) self.win.resize(380, min(self.win.sizeHint().height(), screen.height() - 80)) self.win.move(max(20, self.px - 400), self.py) self.win.show() self.apply_all() # ---------- 启动时给头部标签打角色标签(此时尺寸还是默认值,判定可靠) ---------- def _tag_headers(self): for g in self.popup._groups: hwd = self.list.itemWidget(g["header_item"]) for lbl in hwd.findChildren(QLabel): txt = lbl.text() if txt == "": # 默认: 供应商图标 16×16, 箭头 12×12 → 启动时按尺寸区分(此后不再变判定基准) lbl.setProperty("_role", "icon" if lbl.size().width() >= 14 else "chevron") elif txt.isdigit(): lbl.setProperty("_role", "count") else: lbl.setProperty("_role", "name") # ---------- 实时渲染 ---------- def on_mark(self, k, on): if on: self.broken.add(k) else: self.broken.discard(k) def on_change(self, k, v): self.params[k] = v self.apply_all() def _rebuild_icons(self): p = self.params pop = self.popup pop._provider_pixmap = QPixmap(_popup_svg_path(p.get("provider_svg", "provider.svg"))).scaled( p["header_icon"], p["header_icon"], Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation) pop._arrow_expanded = QIcon(_popup_svg_path("chevron_down.svg")).pixmap(p["arrow_size"], p["arrow_size"]) pop._arrow_collapsed = QIcon(_popup_svg_path("chevron_right.svg")).pixmap(p["arrow_size"], p["arrow_size"]) m = QPixmap(_popup_svg_path("model.svg")).scaled( p["model_icon"], p["model_icon"], Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation) pad_w = p["model_icon"] + p["model_icon_pad"] _pad = QPixmap(pad_w, p["model_icon"]) _pad.fill(Qt.GlobalColor.transparent) _pp = QPainter(_pad) _pp.drawPixmap(p["model_icon_pad"], 0, m) _pp.end() pop._model_icon = QIcon(_pad) # 关键:item 持有 QIcon 的拷贝,类属性替换后必须逐个 setIcon 才会更新(实测) for g in pop._groups: for it in g["model_items"]: it.setIcon(pop._model_icon) self.list.setIconSize(QSize(pad_w, p["model_icon"])) def apply_all(self): p = self.params pop = self.popup family = p["font_family"] stack = f'"{family}", "Microsoft YaHei", "Noto Sans SC", sans-serif' pop.setFixedWidth(p["popup_width"]) pop.container_layout.setContentsMargins(0, p["container_vmargin"], 0, p["container_vmargin"]) self._rebuild_icons() for g in pop._groups: hwd = self.list.itemWidget(g["header_item"]) hwd.setFixedHeight(p["header_height"]) g["header_item"].setSizeHint(QSize(0, p["header_height"])) # 行高 = widget 高 btns = hwd.findChildren(QPushButton) if btns: bl = btns[0].layout() if bl is not None: bl.setContentsMargins(p["header_hpad_l"], 0, 8, 0) for lbl in hwd.findChildren(QLabel): role = lbl.property("_role") if role == "icon": pad = p["header_icon_pad"] pv = p["header_icon_pad_v"] # 2 倍 margin:抵消 label 行内居中吸收的一半增高 → 1:1 位移 top, bot = 2 * max(pv, 0), 2 * max(-pv, 0) lbl.setFixedSize(p["header_icon"] + pad, p["header_icon"] + top + bot) lbl.setContentsMargins(pad, top, 0, bot) lbl.setPixmap(pop._provider_pixmap) elif role == "chevron": lbl.setFixedSize(p["arrow_size"], p["arrow_size"]) lbl.setPixmap(pop._arrow_expanded if g["expanded"] else pop._arrow_collapsed) elif role == "name": lbl.setStyleSheet( f"font-family: {stack}; color: #555555; font-weight: normal;" f"font-size: {p['header_font']}px; letter-spacing: 1px; background: transparent;") elif role == "count": lbl.setStyleSheet( f"font-family: {stack}; color: #888888;" f"font-size: {p['count_font']}px; font-weight: normal; background: transparent;") for it in g["model_items"]: it.setSizeHint(QSize(0, p["model_item_h"])) pop.setStyleSheet(QSS_TMPL .replace("FONT_STACK", stack)) # 模型行字体:widget 级 setFont(QSS ::item 的 font-size/缩写都不可靠,实测) _f = self.list.font() _f.setPixelSize(p["item_font"]) _f.setFamily(family) self.list.setFont(_f) self.app.processEvents() pop.adjust_popup_height() pop.adjustSize() def on_toggle_selected(self, on): if on: for g in self.popup._groups: if g["model_items"] and not g["model_items"][0].isHidden(): self.list.setCurrentItem(g["model_items"][0]) break else: self.list.clearSelection() def reset(self): self.params = dict(DEFAULTS) for k, sp in self.spins.items(): sp.blockSignals(True) sp.setValue(self.params[k]) sp.blockSignals(False) for k, cb in self.combos.items(): cb.blockSignals(True) cb.setCurrentText(self.params[k]) cb.blockSignals(False) self.apply_all() # ---------- 确定 → 写日志 ---------- def finish(self): log = os.path.join(os.path.dirname(__file__), "..", "data", "model_popup_tune.json") os.makedirs(os.path.dirname(log), exist_ok=True) payload = dict(self.params) payload["not_working"] = sorted(self.broken) # 用户标记的不生效参数 with open(log, "w", encoding="utf-8") as f: json.dump(payload, f, ensure_ascii=False, indent=2) print("TUNE_LOG=" + os.path.abspath(log), flush=True) print(json.dumps(payload, ensure_ascii=False), flush=True) self.app.quit() def main(): try: Tuner() QApplication.instance().exec() except Exception: err = traceback.format_exc() try: with open(os.path.join(os.path.dirname(__file__), "..", "data", "tune_error.log"), "w", encoding="utf-8") as f: f.write(err) except OSError: pass QMessageBox.critical(None, "调参工具启动失败", err) sys.exit(1) if __name__ == "__main__": main()