Files
Haocode/ui/views/wv2_view.py
T

232 lines
8.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""
WebView2View —— 外观兼容 QWebEngineView 的 QWidget(仅实现 main_window 用到的 API 面):
setUrl / loadFinished / page().runJavaScript / page().setBackgroundColor / grab
内部由 core.webview2.Wv2Session 驱动:
- 30ms timer + resizeEvent 双路 SetBoundsAndZoomFactorslot 矩形 → 父窗客户区物理像素)
- 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)