"""Fluent EDID viewer for Windows display devices and offline files.""" import ctypes import json import sys from pathlib import Path from PySide6.QtCore import QThread, Qt, QTimer, Signal from PySide6.QtGui import QFont, QFontDatabase, QIcon from PySide6.QtWidgets import ( QApplication, QFileDialog, QHBoxLayout, QMainWindow, QMessageBox, QPlainTextEdit, QSizePolicy, QSplitter, QTabWidget, QTreeWidgetItem, QVBoxLayout, QWidget, ) from qfluentwidgets import ( BodyLabel, CaptionLabel, CardWidget, ComboBox, FluentIcon, PlainTextEdit, PrimaryPushButton, PushButton, StrongBodyLabel, SubtitleLabel, TreeWidget, ) from edid import hex_dump, parse, read_file from theme import ThemeManager from windows_edid import scan APP_NAME = "EDID Viewer" APP_VERSION = "0.1.0" REPO_URL = "https://gitea.hulk.wang/hulk/HW-Debug-Tools" def icon_path(): """Packaged onefile: icon is extracted under sys._MEIPASS/assets; source: ../assets.""" if hasattr(sys, "_MEIPASS"): return Path(sys._MEIPASS) / "assets" / "edid-viewer.ico" return Path(__file__).resolve().parent.parent / "assets" / "edid-viewer.ico" class Scanner(QThread): result = Signal(object) failure = Signal(str) def run(self): try: self.result.emit(scan()) except Exception as exc: self.failure.emit(str(exc)) class Window(QMainWindow): """Main Fluent surface; parser and Windows access remain separate modules.""" def __init__(self, theme_manager=None): super().__init__() self.theme_manager = theme_manager or ThemeManager(QApplication.instance()) self.setWindowTitle("EDID Viewer · HDMI 显示信息") self.resize(1240, 820) self.setMinimumSize(900, 620) self.raw = b"" self.report = {} self.worker = None self._build_ui() self.theme_manager.set_window(self) self.bin_button.setEnabled(False) self.export_button.setEnabled(False) QTimer.singleShot(0, self.start_scan) def _button(self, text, callback, primary=False, icon=None): button = PrimaryPushButton(self) if primary else PushButton(self) button.setText(text) if icon is not None: button.setIcon(icon) button.clicked.connect(callback) return button def _card(self, title, subtitle=None, parent=None): card = CardWidget(parent or self) layout = QVBoxLayout(card) layout.setContentsMargins(16, 14, 16, 16) layout.setSpacing(9) layout.addWidget(StrongBodyLabel(title, card)) if subtitle: caption = CaptionLabel(subtitle, card) caption.setWordWrap(True) layout.addWidget(caption) return card, layout def _text(self, mono=False, parent=None): text = PlainTextEdit(parent or self) text.setReadOnly(True) text.setLineWrapMode(QPlainTextEdit.NoWrap) if mono: text.setFont(QFontDatabase.systemFont(QFontDatabase.FixedFont)) return text def _build_ui(self): root = QWidget(self) root.setObjectName("root") outer = QVBoxLayout(root) outer.setContentsMargins(22, 18, 22, 18) outer.setSpacing(14) header = QHBoxLayout() title_box = QVBoxLayout() title_box.setSpacing(1) title_box.addWidget(SubtitleLabel("EDID Viewer", root)) title_box.addWidget(BodyLabel("显示设备能力与原始数据", root)) header.addLayout(title_box) header.addStretch(1) header.addWidget(BodyLabel("外观", root)) self.theme_choice = ComboBox(root) self.theme_choice.setAccessibleName("外观主题") for label, value in ( ("跟随系统", "system"), ("Light · 浅色", "light"), ("Dark · 深色", "dark"), ("dark1 · 蓝色", "dark1"), ("dark2 · 青色", "dark2"), ): self.theme_choice.addItem(label) self.theme_choice.setItemData(self.theme_choice.count() - 1, value) self.theme_choice.setCurrentIndex(self.theme_choice.findData(self.theme_manager.mode)) self.theme_choice.currentIndexChanged.connect( lambda: self.theme_manager.set_mode(self.theme_choice.currentData()) ) header.addWidget(self.theme_choice) header.addWidget(self._button("关于", self.show_about)) outer.addLayout(header) note = CaptionLabel( "Windows 驱动提供的 EDID,可能包含缓存或覆盖;EDID 是接收端声明能力,不代表当前实际输出。", root, ) note.setWordWrap(True) outer.addWidget(note) split = QSplitter(Qt.Vertical, root) split.setChildrenCollapsible(False) split.setHandleWidth(8) split.addWidget(self._build_sidebar()) split.addWidget(self._build_content()) split.setStretchFactor(0, 0) split.setStretchFactor(1, 1) split.setSizes([150, 620]) outer.addWidget(split, 1) self.setCentralWidget(root) def _build_sidebar(self): host = QWidget(self) host.setMinimumHeight(132) host.setMaximumHeight(210) layout = QHBoxLayout(host) layout.setContentsMargins(2, 2, 2, 2) layout.setSpacing(12) card, body = self._card("显示设备", "选择活动输出并读取驱动提供的原始 EDID。", host) card.setMinimumWidth(360) self.devices = ComboBox(card) self.devices.setMinimumHeight(38) self.devices.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) device_row = QHBoxLayout() device_row.setSpacing(8) device_row.addWidget(self.devices, 1) self.refresh_button = self._button("刷新设备", self.start_scan, icon=FluentIcon.SYNC) self.refresh_button.setMinimumWidth(116) device_row.addWidget(self.refresh_button) body.addLayout(device_row) layout.addWidget(card) card, body = self._card("离线文件", "适用于 .bin、.edid、.hex 和纯十六进制 .txt。", host) card.setMinimumWidth(430) self.open_button = self._button("打开 EDID", self.open_file, primary=True, icon=FluentIcon.FOLDER) self.bin_button = self._button("导出 BIN", self.save_bin, icon=FluentIcon.DOWNLOAD) self.export_button = self._button("导出 JSON 报告", self.save_report, icon=FluentIcon.SAVE) file_row = QHBoxLayout() file_row.setSpacing(8) for button in (self.open_button, self.bin_button, self.export_button): button.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) file_row.addWidget(button, 1) body.addLayout(file_row) layout.addWidget(card) layout.setStretch(0, 1) layout.setStretch(1, 1) return host def _build_content(self): card = CardWidget(self) layout = QVBoxLayout(card) layout.setContentsMargins(18, 16, 18, 18) layout.setSpacing(12) self.summary = BodyLabel("正在读取设备…", card) self.summary.setWordWrap(True) layout.addWidget(self.summary) self.tabs = QTabWidget(card) self.tabs.setDocumentMode(True) self.tree = TreeWidget(card) self.tree.setHeaderLabels(["项目", "详细信息"]) self.tree.setColumnWidth(0, 260) self.tree.setAlternatingRowColors(True) self.tabs.addTab(self.tree, "设备与能力") self.timings = TreeWidget(card) self.timings.setHeaderLabels(["来源 / 字段", "分辨率 / 数值", "刷新率"]) self.timings.setColumnWidth(0, 255) self.timings.setColumnWidth(1, 360) self.timings.setAlternatingRowColors(True) self.tabs.addTab(self.timings, "分辨率与时序") self.hex = self._text(mono=True, parent=card) self.tabs.addTab(self.hex, "原始 HEX") self.details = self._text(mono=True, parent=card) self.tabs.addTab(self.details, "完整解析 JSON") layout.addWidget(self.tabs, 1) self.devices.currentIndexChanged.connect(self.select_device) return card def show_about(self): QMessageBox.about( self, f"关于 {APP_NAME}", f"{APP_NAME} v{APP_VERSION}\n\n" "Windows 显示器 EDID 查看器 · 只读,不修改显示设置\n" "PySide6-Fluent-Widgets UI\n\n" f"{REPO_URL}", ) def start_scan(self): if self.worker and self.worker.isRunning(): return self.refresh_button.setEnabled(False) self.open_button.setEnabled(False) self.statusBar().showMessage("正在读取活动设备…") self.worker = Scanner(self) self.worker.result.connect(self.scanned) self.worker.failure.connect(self.scan_failed) self.worker.finished.connect(lambda: self.refresh_button.setEnabled(True)) self.worker.finished.connect(lambda: self.open_button.setEnabled(True)) self.worker.start() def scan_failed(self, message): self.statusBar().showMessage("设备读取失败,可打开离线 EDID 文件") self.summary.setText("Windows EDID 读取失败:" + message) def scanned(self, items): old = self.devices.currentData() old_id = old.get("instance") if old else None self.devices.blockSignals(True) self.devices.clear() selected = 0 for index, item in enumerate(items): name = item["instance"] try: info = parse(item["raw"])["基本信息"] name = info.get("显示器名称", info.get("厂家 PNP 代码", name)) except ValueError: pass self.devices.addItem( f'{item["连接"]} · {name} · {item["instance"]}', userData=item, ) if item["instance"] == old_id: selected = index self.devices.setCurrentIndex(selected) self.devices.blockSignals(False) self.select_device() self.statusBar().showMessage(f"找到 {len(items)} 个活动设备;未启用的输出可能不会列出") def select_device(self, *_): item = self.devices.currentData() if item: self.show_raw(item["raw"], item["instance"] + " / " + item["连接"], item["error"]) else: self.show_raw(b"", "未找到活动显示器。可刷新或打开离线文件。") def show_raw(self, raw, source, error=""): self.raw = raw self.report = {} self.tree.clear() self.timings.clear() self.details.clear() self.hex.setPlainText(hex_dump(raw)) self.bin_button.setEnabled(bool(raw)) self.export_button.setEnabled(False) try: report = parse(raw) except ValueError as exc: self.summary.setText(f"{source}\n{error or str(exc)}") return self.report = {"来源": source, "读取错误": error, **report, "原始 HEX": raw.hex(" ")} self.export_button.setEnabled(True) messages = report["警告"] + [item for item in report["校验"] if "失败" in item] if error: messages.insert(0, error) status = "各块校验通过" if not messages else "⚠ " + ";".join(messages) self.summary.setText(f"{source}\n{len(raw)} 字节 · {status}") for group in ("基本信息", "能力", "校验", "警告", "数据块"): parent = QTreeWidgetItem(self.tree, [group]) value = report[group] rows = value.items() if isinstance(value, dict) else enumerate(value, 1) for key, item in rows: rendered = json.dumps(item, ensure_ascii=False) if isinstance(item, (dict, list)) else str(item) QTreeWidgetItem(parent, [str(key), rendered]) parent.setExpanded(group != "数据块") for timing in report["时序"]: parent = QTreeWidgetItem(self.timings, [timing["来源"], timing["分辨率"], timing.get("刷新率", "")]) for key, value in timing.items(): if key not in ("来源", "分辨率", "刷新率"): QTreeWidgetItem(parent, [key, str(value)]) self.details.setPlainText(json.dumps(self.report, ensure_ascii=False, indent=2)) def open_path(self, path): try: raw = read_file(path) self.devices.blockSignals(True) self.devices.setCurrentIndex(-1) self.devices.blockSignals(False) self.show_raw(raw, "文件:" + str(path)) except (OSError, ValueError, UnicodeError) as exc: QMessageBox.warning(self, "无法打开", str(exc)) def open_file(self): path, _ = QFileDialog.getOpenFileName( self, "打开 EDID", "", "EDID (*.bin *.edid *.hex *.txt);;全部文件 (*)", ) if path: self.open_path(path) def save_bin(self): path, _ = QFileDialog.getSaveFileName(self, "导出原始 EDID", "edid.bin", "BIN (*.bin)") if path: self.write(path, self.raw) def save_report(self): path, _ = QFileDialog.getSaveFileName(self, "导出报告", "edid-report.json", "JSON (*.json)") if path: self.write(path, json.dumps(self.report, ensure_ascii=False, indent=2).encode("utf-8")) def write(self, path, data): try: Path(path).write_bytes(data) self.statusBar().showMessage("已保存:" + path) except OSError as exc: QMessageBox.warning(self, "保存失败", str(exc)) def closeEvent(self, event): if self.worker and self.worker.isRunning(): self.statusBar().showMessage("正在读取,请等待读取完成后关闭(最长约 45 秒)") event.ignore() else: event.accept() def main(): if sys.platform == "win32": ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID("LT8619C.Tools.EDIDViewer") app = QApplication(sys.argv) app.setApplicationName(APP_NAME) app.setFont(QFont("Microsoft YaHei UI", 10)) app.setWindowIcon(QIcon(str(icon_path()))) theme_manager = ThemeManager(app) window = Window(theme_manager) window.show() return app.exec() if __name__ == "__main__": sys.exit(main())