232 lines
8.9 KiB
Python
232 lines
8.9 KiB
Python
# -*- 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",
|
||
"onRequestWindowPage", # 🆕 P1-01 渲染窗口换页请求
|
||
)
|
||
|
||
|
||
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 = float(self.devicePixelRatioF())
|
||
return d if d > 0 else 1.0
|
||
except Exception:
|
||
return 1.0
|
||
|
||
@staticmethod
|
||
def _physical_bounds(x, y, width, height, dpr):
|
||
"""按矩形边界换算物理像素,避免分数 DPR 产生相邻白缝。"""
|
||
def pixel(value):
|
||
scaled = value * dpr
|
||
return int(scaled + 0.5) if scaled >= 0 else int(scaled - 0.5)
|
||
|
||
left = pixel(x)
|
||
top = pixel(y)
|
||
right = pixel(x + width)
|
||
bottom = pixel(y + height)
|
||
return left, top, max(1, right - left), max(1, bottom - top)
|
||
|
||
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, W, H = self._physical_bounds(
|
||
p.x(), p.y(), self.width(), 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)
|