Files
sorrow404null 5ae660574e chore: re-submit 2026-09-18 changes to verify webhook
- feat(config): add atomic configuration persistence
- fix(ui): repair chat and model settings rendering
- fix(capture): map selections across high-DPI displays
- fix(webview2): scale native bounds for fractional DPI
- chore: remove test scripts from distribution
2026-09-18 16:13:05 +08:00

231 lines
9.5 KiB
Python
Raw Permalink 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 -*-
"""
屏幕截图覆盖层 (ScreenCaptureOverlay)
全屏半透明遮罩 + 鼠标拖拽选区 + 确认/取消工具条 + 截图完成发射 QImage 信号
用法:调用 start() 启动截图,监听 screenshot_captured 信号获取结果
"""
from PyQt6 import QtCore, QtGui, QtWidgets
import os
class ScreenCaptureOverlay(QtWidgets.QWidget):
"""全屏截图覆盖层:半透明遮罩 + 鼠标拖拽选区 + 确认/取消按钮"""
screenshot_captured = QtCore.pyqtSignal(QtGui.QImage)
# 按钮尺寸
BTN_W = 36
BTN_H = 30
BTN_GAP = 4
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowFlags(
QtCore.Qt.WindowType.FramelessWindowHint
| QtCore.Qt.WindowType.WindowStaysOnTopHint
| QtCore.Qt.WindowType.Tool
)
self.setCursor(QtCore.Qt.CursorShape.CrossCursor)
self._full_pixmap = None
self._start_pos = QtCore.QPoint()
self._end_pos = QtCore.QPoint()
self._is_drawing = False
self._has_selection = False
self._current_rect = QtCore.QRect()
# SVG 图标路径(项目根目录下 svg/ 文件夹)
root_dir = os.path.abspath(
os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "..")
)
# 确认 / 取消按钮(使用 SVG 图标,灰白简约风背景)
self._btn_confirm = QtWidgets.QPushButton(self)
self._btn_cancel = QtWidgets.QPushButton(self)
self._btn_confirm.setIcon(QtGui.QIcon(os.path.join(root_dir, "svg", "check.svg")))
self._btn_cancel.setIcon(QtGui.QIcon(os.path.join(root_dir, "svg", "cross.svg")))
btn_style = (
"QPushButton { background-color: #f9f9f9; border: 1px solid #ddd; border-radius: 4px; }"
"QPushButton:hover { background-color: #eee; border-color: #ccc; }"
)
for btn in (self._btn_confirm, self._btn_cancel):
btn.setFixedSize(self.BTN_W, self.BTN_H)
btn.setIconSize(QtCore.QSize(20, 20))
btn.setCursor(QtCore.Qt.CursorShape.PointingHandCursor)
btn.setStyleSheet(btn_style)
btn.hide()
self._btn_confirm.clicked.connect(self._on_confirm)
self._btn_cancel.clicked.connect(self._on_cancel)
def start(self):
"""开始截图:抓取屏幕全图并显示覆盖层"""
screen = QtWidgets.QApplication.primaryScreen()
if not screen:
print("[Screenshot] 无主屏幕 → 截图不可用", flush=True)
return
self._full_pixmap = screen.grabWindow(0)
# P1-04X11 某些 compositor/环境下 grabWindow 可能拿到空图(Wayland 已改走 portal
if self._full_pixmap is None or self._full_pixmap.isNull() or self._full_pixmap.width() == 0:
print("[Screenshot] 屏幕抓取返回空画面(当前 compositor/环境限制)→ 本次截图取消;"
"聊天与其他功能不受影响", flush=True)
self._full_pixmap = None
return
self.setGeometry(screen.geometry())
self.show()
self.activateWindow()
self.raise_()
@staticmethod
def _scale_rect(rect, logical_size, native_size):
"""把 Qt 逻辑坐标选区映射到抓屏位图的原生像素坐标。"""
if (logical_size.width() <= 0 or logical_size.height() <= 0
or native_size.width() <= 0 or native_size.height() <= 0):
return QtCore.QRect()
logical_bounds = QtCore.QRect(QtCore.QPoint(), logical_size)
clipped = rect.normalized().intersected(logical_bounds)
if clipped.isEmpty():
return QtCore.QRect()
scale_x = native_size.width() / logical_size.width()
scale_y = native_size.height() / logical_size.height()
left = round(clipped.x() * scale_x)
top = round(clipped.y() * scale_y)
right = round((clipped.x() + clipped.width()) * scale_x)
bottom = round((clipped.y() + clipped.height()) * scale_y)
left = max(0, min(native_size.width(), left))
top = max(0, min(native_size.height(), top))
right = max(left, min(native_size.width(), right))
bottom = max(top, min(native_size.height(), bottom))
return QtCore.QRect(left, top, right - left, bottom - top)
def _native_rect(self, rect):
"""返回选区在当前抓屏位图中的原生像素矩形。"""
if self._full_pixmap is None or self._full_pixmap.isNull():
return QtCore.QRect()
return self._scale_rect(rect, self.size(), self._full_pixmap.size())
def paintEvent(self, event):
if not self._full_pixmap:
return
painter = QtGui.QPainter(self)
# 1. 绘制屏幕截图作为背景
painter.drawPixmap(self.rect(), self._full_pixmap, self._full_pixmap.rect())
# 2. 半透明遮罩
painter.fillRect(self.rect(), QtGui.QColor(0, 0, 0, 100))
# 绘制中 或 已有选区 时,绘制选区高亮
if self._is_drawing or self._has_selection:
if self._is_drawing:
rect = QtCore.QRect(self._start_pos, self._end_pos).normalized()
else:
rect = self._current_rect
if rect.width() > 0 and rect.height() > 0:
native_rect = self._native_rect(rect)
# 3. 选区内重绘原图(去掉遮罩,形成高亮效果)
painter.drawPixmap(rect, self._full_pixmap, native_rect)
# 4. 蓝色边框
pen = QtGui.QPen(QtGui.QColor(0, 120, 215), 2)
painter.setPen(pen)
painter.setBrush(QtCore.Qt.BrushStyle.NoBrush)
painter.drawRect(rect)
# 5. 尺寸标注
# 标签显示最终文件的真实像素,而不是受系统缩放影响的逻辑尺寸。
size_text = f"{native_rect.width()} x {native_rect.height()} px"
font = painter.font()
font.setPointSize(9)
painter.setFont(font)
fm = painter.fontMetrics()
text_w = fm.horizontalAdvance(size_text)
text_h = fm.height()
text_x = rect.x()
text_y = rect.y() - text_h - 2
if text_y < 0:
text_y = rect.bottom() + 2
painter.fillRect(text_x, text_y, text_w + 10, text_h, QtGui.QColor(0, 120, 215))
painter.setPen(QtGui.QColor(255, 255, 255))
painter.drawText(text_x + 5, text_y + fm.ascent(), size_text)
painter.end()
def mousePressEvent(self, event):
if event.button() == QtCore.Qt.MouseButton.LeftButton:
# 开始新选区,隐藏按钮
self._hide_buttons()
self._has_selection = False
self._start_pos = event.position().toPoint()
self._end_pos = self._start_pos
self._is_drawing = True
self.update()
elif event.button() == QtCore.Qt.MouseButton.RightButton:
self.close()
def mouseMoveEvent(self, event):
if self._is_drawing:
self._end_pos = event.position().toPoint()
self.update()
def mouseReleaseEvent(self, event):
if event.button() == QtCore.Qt.MouseButton.LeftButton and self._is_drawing:
self._is_drawing = False
rect = QtCore.QRect(self._start_pos, self._end_pos).normalized()
if rect.width() > 5 and rect.height() > 5:
# 保留选区,显示确认/取消按钮
self._current_rect = rect
self._has_selection = True
self._position_buttons(rect)
self._btn_confirm.show()
self._btn_cancel.show()
self.update()
else:
self.close()
def keyPressEvent(self, event):
if event.key() == QtCore.Qt.Key.Key_Escape:
self.close()
elif event.key() in (QtCore.Qt.Key.Key_Return, QtCore.Qt.Key.Key_Enter):
if self._has_selection:
self._on_confirm()
def _position_buttons(self, rect):
"""将确认/取消按钮定位到选区右下角"""
total_w = self.BTN_W * 2 + self.BTN_GAP
# 默认放在选区右下角外侧
x = rect.right() - total_w
y = rect.bottom() + 4
# 边界检测:超出屏幕底部时翻到选区内侧
if y + self.BTN_H > self.height():
y = rect.bottom() - self.BTN_H - 4
if x < 0:
x = 0
self._btn_confirm.move(x, y)
self._btn_cancel.move(x + self.BTN_W + self.BTN_GAP, y)
def _hide_buttons(self):
self._btn_confirm.hide()
self._btn_cancel.hide()
def _on_confirm(self):
"""确认截图:裁剪并发射信号"""
if self._full_pixmap and self._current_rect.width() > 5 and self._current_rect.height() > 5:
native_rect = self._native_rect(self._current_rect)
if not native_rect.isEmpty():
captured = self._full_pixmap.toImage().copy(native_rect)
# 截图是普通位图;避免下游再次按桌面 DPR 缩小显示。
captured.setDevicePixelRatio(1.0)
self.screenshot_captured.emit(captured)
self.close()
def _on_cancel(self):
"""取消截图:直接关闭"""
self.close()
def closeEvent(self, event):
self._is_drawing = False
self._has_selection = False
self._hide_buttons()
self._full_pixmap = None
super().closeEvent(event)