400 lines
17 KiB
Python
400 lines
17 KiB
Python
# -*- 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()
|