# -*- 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)