# -*- coding: utf-8 -*- """ WebView2View —— 外观兼容 QWebEngineView 的 QWidget(仅实现 main_window 用到的 API 面): setUrl / loadFinished / page().runJavaScript / page().setBackgroundColor / grab 内部由 core.webview2.Wv2Session 驱动: - 30ms timer + resizeEvent 双路 SetBoundsAndZoomFactor(slot 矩形 → 父窗客户区物理像素) - WebMessageReceived → ChatBridge 的 JS→Python slot - run_js → ExecuteScriptAsync(与 QWebChannel 路径生成的 JS 文本同构) """ import json import ctypes import ctypes.wintypes as wintypes from PyQt6 import QtCore, QtGui, QtWidgets from PyQt6.QtCore import Qt, pyqtSignal, QTimer, QUrl # JS → Python 消息名 → ChatBridge slot 名(与 QWebChannel 注册的对象方法一一对应) _BRIDGE_METHODS = ( "onRegenerateClicked", "onBranchSwitch", "onDeleteMessageClicked", "onAttachmentClicked", "onScrollChanged", ) class _PageShim: """QWebEnginePage 的最小替身""" def __init__(self, view): self._view = view def runJavaScript(self, script, callback=None): if callback is None: self._view.run_js(script) else: self._view.execute_js_async(script, callback) def setBackgroundColor(self, color): pass # WebView2 底色由页面 CSS 决定(页面本身就是白底) class WebView2View(QtWidgets.QWidget): loadFinished = pyqtSignal(bool) def __init__(self, session, parent=None): super().__init__(parent) self._session = session self._page = _PageShim(self) self._bridge = None self._session.on_load_finished = self._on_page_loaded self.setAttribute(Qt.WidgetAttribute.WA_OpaquePaintEvent, True) # 子窗口发现(异步创建) self._child_timer = QTimer(self) self._child_timer.setInterval(100) self._child_timer.timeout.connect(self._try_find_child) self._child_timer.start() # bounds 同步兜底(resizeEvent 主路 + 30ms 兜底,与 P1 实测一致) self._sync_timer = QTimer(self) self._sync_timer.setInterval(30) self._sync_timer.timeout.connect(self.sync_bounds) self._sync_timer.start() # 几何去重:值没变就不发跨进程 COM(移动窗口时避免主线程被 WebView2 阻塞) self._last_sent_bounds = None # ---------- 页面加载完成 ---------- def _on_page_loaded(self): # 立即启动 JS 管线(子窗口全程可见,无节流/竞态)。 # 首帧前的"透底色"问题由 Qt 侧白色 paintEvent 解决(透明子窗表面露出白底)。 self.loadFinished.emit(True) def paintEvent(self, ev): # 子窗口隐藏期间(启动首帧前)槽位必须自绘白底, # 否则 WA_OpaquePaintEvent + 无 paintEvent = 未绘制表面 = 纯黑 p = QtGui.QPainter(self) p.fillRect(self.rect(), QtGui.QColor("#ffffff")) p.end() # ---------- QWebEngineView 兼容 API ---------- def page(self): return self._page def setUrl(self, url): # 接受 QUrl 或 str s = url.toString() if isinstance(url, QUrl) else str(url) self._session.navigate(s) def grab(self): """子窗口区域截图(调试工具用)""" h = self._session.child_hwnd if not h: return QtGui.QPixmap(self.size()) r = wintypes.RECT() if not ctypes.windll.user32.GetWindowRect(wintypes.HWND(h), ctypes.byref(r)): return QtGui.QPixmap(self.size()) w = max(1, r.right - r.left) hgt = max(1, r.bottom - r.top) screen_dc = ctypes.windll.user32.GetDC(0) mem_dc = ctypes.windll.gdi32.CreateCompatibleDC(screen_dc) bmp = ctypes.windll.gdi32.CreateCompatibleBitmap(screen_dc, w, hgt) ctypes.windll.gdi32.SelectObject(mem_dc, bmp) ctypes.windll.gdi32.BitBlt(mem_dc, 0, 0, w, hgt, screen_dc, r.left, r.top, 0x00CC0020) # BITMAPINFO class _BMI(ctypes.Structure): _fields_ = [("biSize", wintypes.DWORD), ("biWidth", ctypes.c_long), ("biHeight", ctypes.c_long), ("biPlanes", wintypes.WORD), ("biBitCount", wintypes.WORD), ("biCompression", wintypes.DWORD), ("biSizeImage", wintypes.DWORD), ("biXPelsPerMeter", ctypes.c_long), ("biYPelsPerMeter", ctypes.c_long), ("biClrUsed", wintypes.DWORD), ("biClrImportant", wintypes.DWORD)] bmi = _BMI() bmi.biSize = ctypes.sizeof(_BMI) bmi.biWidth = w bmi.biHeight = -hgt # 顶向下 bmi.biPlanes = 1 bmi.biBitCount = 32 bmi.biCompression = 0 buf = ctypes.create_string_buffer(w * hgt * 4) ctypes.windll.gdi32.GetDIBits(mem_dc, bmp, 0, hgt, buf, ctypes.byref(bmi), 0) ctypes.windll.gdi32.DeleteObject(bmp) ctypes.windll.gdi32.DeleteDC(mem_dc) ctypes.windll.user32.ReleaseDC(0, screen_dc) img = QtGui.QImage(buf, w, hgt, w * 4, QtGui.QImage.Format.Format_RGBA8888).copy() return QtGui.QPixmap.fromImage(img) # ---------- 桥接 ---------- def attach_bridge(self, bridge): self._bridge = bridge self._session.on_message = self._dispatch_message def _dispatch_message(self, data): if not isinstance(data, dict) or not self._bridge: return name = data.get("m") args = data.get("a", []) if name in _BRIDGE_METHODS: fn = getattr(self._bridge, name, None) if fn: try: fn(*args) except Exception as ex: print(f"[WV2] bridge {name} error:", ex) # ---------- JS → Python 用的执行通道 ---------- def run_js(self, script): self._session.execute_js(script) def execute_js_async(self, script, cb): self._session.execute_js_async(script, cb) # ---------- 几何同步 ---------- def _dpr(self): try: d = self.dpr() return d if d > 0 else 1.0 except Exception: return 1.0 def sync_bounds(self): if not self.isVisible() or not self._session.child_hwnd: return top = self.window() p = self.mapTo(top, QtCore.QPoint(0, 0)) d = self._dpr() L, T = p.x() * d, p.y() * d W, H = max(1, self.width() * d), max(1, self.height() * d) # 去重:值没变就不发跨进程 COM(移动窗口时避免主线程被 WebView2 阻塞 → 整窗黑屏) # 注:旧版 resize hold(放大时钳制旧尺寸→露白边)已移除 —— # 拖动黑边的真凶是假异步 JS 泵(已修真异步),高频 SetBounds 实测 Chromium 完全跟得上; # 用户实测:立即发送 = 缩小/放大都实时渲染、无阻塞。 if self._last_sent_bounds == (L, T, W, H): return self._session.set_bounds(L, T, W, H) self._last_sent_bounds = (L, T, W, H) def resizeEvent(self, ev): super().resizeEvent(ev) self.sync_bounds() def showEvent(self, ev): super().showEvent(ev) # 确保渲染层点亮(SDK 初始状态下控制器可能不可见) self._session.set_visible(True) self.sync_bounds() def _force_repaint(self): """WebView2 子窗口创建会打断首帧合成 → 强制整窗重绘(治启动时控件渲染丢失)""" top = self.window() if top is None: return top.update() QtWidgets.QApplication.processEvents() def _try_find_child(self): if self._session.child_hwnd: self._child_timer.stop() self._session.set_visible(True) self.sync_bounds() self._force_repaint() QtCore.QTimer.singleShot(300, self._force_repaint) return if self._session.find_child_once(): self._child_timer.stop() self._session.set_visible(True) self.sync_bounds() self._force_repaint() QtCore.QTimer.singleShot(300, self._force_repaint) # ---------- 生命周期 ---------- def closeEvent(self, ev): try: self._child_timer.stop() self._sync_timer.stop() self._session.close() except Exception: pass super().closeEvent(ev)