chore: import original project baseline
Import the pre-repair source tree as the history baseline. Runtime data (data/), virtualenvs, bytecode caches and logs are gitignored so local secrets and user state stay out of the repo.
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
# -*- 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)
|
||||
Reference in New Issue
Block a user