231 lines
9.5 KiB
Python
231 lines
9.5 KiB
Python
# -*- 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-04:X11 某些 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)
|