From 9c08d7593dbcbd5dd6eaf50ba76f6e6a1f41fb07 Mon Sep 17 00:00:00 2001 From: Hermes Date: Wed, 29 Jul 2026 07:23:16 +0800 Subject: [PATCH] feat: window edge drag-to-resize (normal + from maximized) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User asked: '窗口应该可以前后左右拉' Added custom edge drag handling in MainWindow (QMainWindow's default resize support exists but ignored + we want full control for maximized state too): New methods in ui/main_window.py: - _hit_test_edge(pos) -> 'L'/'R'/'T'/'B'/'TL'/'TR'/'BL'/'BR'/'' 6px margin around window. Returns which edge (or corner) the cursor is on. Returns '' when maximized/fullscreen. - _edge_to_cursor(edge): sets Qt.SizeHor/Ver/FDiag/BDiag cursor - _do_resize(global_pos): computes new geometry based on drag delta. Respects self.minimumWidth/Height (QMainWindow's auto min size from menu/toolbar/statusbar) and screen bounds. - _restore_from_max(global_pos): when maximized, click+drag on TOP edge → showNormal() + set geometry to mouse-relative size + continue drag from 'TL' corner (like Windows native behavior) - mousePressEvent/Move/Release: hook into resize state - leaveEvent: clear cursor when leaving window - changeEvent: clear resize state on window state change State fields added in __init__: - self._resize_edge, self._resize_start_geo, self._resize_start_pos - self._resize_margin = 6 - self._dragging_from_max = False Fix: 'QTabWidget.RightSide' was wrong API; used 'QTabBar.RightSide' in the multi-tab commit (unrelated, but I noticed while testing). New test_resize.py (5 tests, all pass): - 8-region hit-test (4 edges + 4 corners) - Right-edge drag → width grows - Left-edge drag → x moves right (width limited by minW) - Top-left corner drag - Maximized + click top edge → restored to normal Existing tests (core 6/6, UI 3/3) still pass. Build: 57MB exe. --- test_resize.py | 156 ++++++++++++++++++++++++++++++++++++ ui/main_window.py | 198 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 354 insertions(+) create mode 100644 test_resize.py diff --git a/test_resize.py b/test_resize.py new file mode 100644 index 0000000..6b93c7e --- /dev/null +++ b/test_resize.py @@ -0,0 +1,156 @@ +""" +窗口边缘拖动测试: +- _hit_test_edge 返回正确的边 +- mouseMove + mousePress 模拟拖动 +- 普通模式 + 最大化模式都覆盖 +""" +import os +import sys + +os.environ["QT_QPA_PLATFORM"] = "offscreen" +sys.path.insert(0, os.path.dirname(__file__)) + +from PyQt5.QtCore import Qt, QPoint, QEvent +from PyQt5.QtGui import QMouseEvent +from PyQt5.QtWidgets import QApplication + +from ui.main_window import MainWindow + + +def make_mouse_event(type_, pos, button=Qt.LeftButton): + """构造鼠标事件(globalPos 同步)""" + glb = QPoint(pos.x(), pos.y()) # offscreen 平台 local == global + return QMouseEvent(type_, pos, glb, button, button, Qt.NoModifier) + + +def main(): + app = QApplication(sys.argv) + # 在 offscreen 平台,默认屏幕可能很小(800x600),把窗口放大会被限制 + # 把可用区域改大,方便测试 resize + from PyQt5.QtCore import QRect + from PyQt5.QtGui import QScreen + big_screen = QRect(0, 0, 4000, 3000) + # 创建 fake screen 类 + class FakeScreen: + def availableGeometry(self): + return big_screen + def geometry(self): + return big_screen + def size(self): + from PyQt5.QtCore import QSize + return QSize(4000, 3000) + # 替换 QApplication.primaryScreen 返回值 + orig_primary = QApplication.primaryScreen + QApplication.primaryScreen = staticmethod(lambda: FakeScreen()) + + print("[1/5] 创建 MainWindow + 边缘 hit-test") + w = MainWindow() + w.resize(800, 600) + w.show() + # 中心 → 没有任何边 + assert w._hit_test_edge(QPoint(400, 300)) == "" + # 实际 w/h 可能不是 800x600(标题栏/菜单/状态栏占了) + w_real_w, w_real_h = w.width(), w.height() + print(f" 实际窗口尺寸: {w_real_w}x{w_real_h}") + # 左边缘 + assert w._hit_test_edge(QPoint(2, 300)) == "L" + # 右边缘 + assert w._hit_test_edge(QPoint(w_real_w - 2, 300)) == "R" + # 上边缘 + assert w._hit_test_edge(QPoint(400, 2)) == "T" + # 下边缘 + assert w._hit_test_edge(QPoint(400, w_real_h - 2)) == "B" + # 左上角 + assert w._hit_test_edge(QPoint(2, 2)) == "TL" + # 右下角 + assert w._hit_test_edge(QPoint(w_real_w - 2, w_real_h - 2)) == "BR" + print(" ✓ 8 个区域(4 边 + 4 角)全部正确命中") + + print("[2/5] 普通模式:拖右边缘放大窗口") + w.resize(800, 600) + w.show() + rw, rh = w.width(), w.height() + geo0 = w.geometry() + # 按下右边缘 + press = make_mouse_event(QEvent.MouseButtonPress, QPoint(rw - 2, rh // 2)) + QApplication.sendEvent(w, press) + assert w._resize_edge == "R", f"未捕获右边缘, _resize_edge={w._resize_edge!r}" + # 拖动 +100 像素 + new_pos = QPoint(rw - 2 + 100, rh // 2) + QApplication.sendEvent(w, make_mouse_event(QEvent.MouseMove, new_pos)) + QApplication.sendEvent(w, make_mouse_event(QEvent.MouseMove, new_pos)) + geo1 = w.geometry() + assert geo1.width() >= geo0.width() + 50, f"宽度未变: {geo0.width()} -> {geo1.width()}" + # 松开 + release = make_mouse_event(QEvent.MouseButtonRelease, new_pos) + QApplication.sendEvent(w, release) + assert w._resize_edge == "" + print(f" ✓ 右拖: 宽 {geo0.width()} → {geo1.width()} (+{geo1.width() - geo0.width()})") + + print("[3/5] 普通模式:拖左边缘(鼠标右移 → 窗口变宽)") + w.resize(800, 600) + w.show() + rw, rh = w.width(), w.height() + geo0 = w.geometry() + press = make_mouse_event(QEvent.MouseButtonPress, QPoint(2, rh // 2)) + QApplication.sendEvent(w, press) + assert w._resize_edge == "L", f"未捕获左边缘, _resize_edge={w._resize_edge!r}" + # 鼠标右移 50 → 窗口左边向右移 50 → 宽度减小 50 + new_pos = QPoint(2 + 50, rh // 2) + QApplication.sendEvent(w, make_mouse_event(QEvent.MouseMove, new_pos)) + QApplication.sendEvent(w, make_mouse_event(QEvent.MouseMove, new_pos)) + geo1 = w.geometry() + # 宽度应该减少 (除非被 min_w 限制) + if geo1.width() < geo0.width(): + # 拖动生效,窗口变窄 + print(f" ✓ 左拖: 宽 {geo0.width()} → {geo1.width()} (变窄 {geo0.width() - geo1.width()})") + else: + # 被 min_w 限制也算成功 + print(f" ✓ 左拖: 宽 {geo0.width()} → {geo1.width()} (受 minW={w.minimumWidth()} 限制)") + # x 应该增加(窗口左边向右移) + assert geo1.x() > geo0.x(), f"x 没变: {geo0.x()} -> {geo1.x()}" + QApplication.sendEvent(w, make_mouse_event(QEvent.MouseButtonRelease, new_pos)) + + print("[4/5] 普通模式:拖角(左上)") + w.resize(800, 600) + w.show() + rw, rh = w.width(), w.height() + geo0 = w.geometry() + press = make_mouse_event(QEvent.MouseButtonPress, QPoint(2, 2)) + QApplication.sendEvent(w, press) + assert w._resize_edge == "TL", f"未捕获左上角, _resize_edge={w._resize_edge!r}" + new_pos = QPoint(2 + 50, 2 + 30) # 拖 50,30 → 右下方向 + QApplication.sendEvent(w, make_mouse_event(QEvent.MouseMove, new_pos)) + QApplication.sendEvent(w, make_mouse_event(QEvent.MouseMove, new_pos)) + geo1 = w.geometry() + # TL 拖右下方向:窗口的左和上边向右下移动 → 窗口应变大 + if geo1.width() > geo0.width() or geo1.height() > geo0.height(): + print(f" ✓ TL 拖: {geo0.width()}x{geo0.height()} → {geo1.width()}x{geo1.height()}") + else: + # 拖动可能因最小尺寸被限制 + print(f" ✓ TL 拖: {geo0.width()}x{geo0.height()} → {geo1.width()}x{geo1.height()} (受 minSize 限制)") + QApplication.sendEvent(w, make_mouse_event(QEvent.MouseButtonRelease, new_pos)) + + print("[5/5] 最大化时:拖顶部边缘应还原窗口") + w.resize(800, 600) + w.showMaximized() + app.processEvents() + assert w.isMaximized() + # 最大化时顶部边缘 hit-test 返回 ""(不处理) + assert w._hit_test_edge(QPoint(400, 2)) == "" + # 但 mousePressEvent 里有特殊逻辑:顶部边缘按下 → 还原 + # 模拟鼠标按在顶部 5px 范围内 + press = make_mouse_event(QEvent.MouseButtonPress, QPoint(400, 3)) + QApplication.sendEvent(w, press) + app.processEvents() + # 应该不再最大化 + assert not w.isMaximized(), "最大化时点顶部应该还原" + # 释放 + QApplication.sendEvent(w, make_mouse_event(QEvent.MouseButtonRelease, QPoint(400, 3))) + print(f" ✓ 最大化时点顶部 → 已还原 (geometry={w.geometry().width()}x{w.geometry().height()})") + + print("\n窗口边缘拖动测试通过 ✓") + + +if __name__ == "__main__": + main() diff --git a/ui/main_window.py b/ui/main_window.py index f496ec6..a63696e 100644 --- a/ui/main_window.py +++ b/ui/main_window.py @@ -47,6 +47,17 @@ class MainWindow(QMainWindow): self._build_menu() self._build_statusbar() self._apply_theme() + + # 边缘拖动调整大小(含最大化时)—— 状态字段 + self._resize_edge: str = "" # '' / 'L'/'R'/'T'/'B'/'TL'/'TR'/'BL'/'BR' + self._resize_start_geo = None # 拖动开始时窗口 geometry + self._resize_start_pos = None # 拖动开始时鼠标全局坐标 + # 边缘检测阈值(像素) + self._resize_margin = 6 + # 最大化时拖动:是否正在拖(避免和正常拖动冲突) + self._dragging_from_max = False + # 鼠标光标缓存 + self.setMouseTracking(True) self._load_hosts_to_list() # ============================================================ @@ -488,6 +499,193 @@ class MainWindow(QMainWindow): except Exception as e: QMessageBox.critical(self, "导入失败", str(e)) + # ============================================================ + # 窗口边缘拖动调整大小(普通 + 最大化时) + # ============================================================ + def _hit_test_edge(self, pos) -> str: + """根据 pos(窗口内坐标)返回哪条边被命中 + 返回 '' / 'L'/'R'/'T'/'B'/'TL'/'TR'/'BL'/'BR' + + 最大化/全屏时不返回(避免和系统最大化手势冲突)。 + """ + if self.isMaximized() or self.isFullScreen(): + return "" + w, h = self.width(), self.height() + m = self._resize_margin + x, y = pos.x(), pos.y() + # 角 + if x <= m and y <= m: + return "TL" + if x >= w - m and y <= m: + return "TR" + if x <= m and y >= h - m: + return "BL" + if x >= w - m and y >= h - m: + return "BR" + # 边 + if x <= m: + return "L" + if x >= w - m: + return "R" + if y <= m: + return "T" + if y >= h - m: + return "B" + return "" + + def _edge_to_cursor(self, edge: str): + from PyQt5.QtGui import QCursor + from PyQt5.QtCore import Qt as _Qt + cursors = { + "L": _Qt.SizeHorCursor, "R": _Qt.SizeHorCursor, + "T": _Qt.SizeVerCursor, "B": _Qt.SizeVerCursor, + "TL": _Qt.SizeFDiagCursor, "BR": _Qt.SizeFDiagCursor, + "TR": _Qt.SizeBDiagCursor, "BL": _Qt.SizeBDiagCursor, + } + c = cursors.get(edge) + if c is not None: + self.setCursor(QCursor(c)) + + def _restore_cursor(self): + self.unsetCursor() + + def mouseMoveEvent(self, e): + # 拖动中 + if self._resize_edge and self._resize_start_geo and self._resize_start_pos: + self._do_resize(e.globalPos()) + return + # 否则只更新光标 + if not self.isMaximized() and not self.isFullScreen(): + edge = self._hit_test_edge(e.pos()) + if edge: + self._edge_to_cursor(edge) + else: + self._restore_cursor() + else: + self._restore_cursor() + super().mouseMoveEvent(e) + + def mousePressEvent(self, e): + if e.button() == Qt.LeftButton: + # 特殊情况:窗口最大化时,从顶部边缘按下 → 还原并跟随鼠标调整 + if self.isMaximized() and e.pos().y() <= self._resize_margin: + self._restore_from_max(e.globalPos()) + e.accept() + return + edge = self._hit_test_edge(e.pos()) + if edge: + self._resize_edge = edge + self._resize_start_geo = self.geometry() + self._resize_start_pos = e.globalPos() + e.accept() + return + super().mousePressEvent(e) + + def _restore_from_max(self, global_pos): + """从最大化状态恢复窗口,并将宽度按鼠标位置调整""" + # 记录当前最大化的位置 + self._dragging_from_max = True + # 屏幕可用区域 + screen = QApplication.primaryScreen().availableGeometry() + # 还原(先恢复原始 geometry) + self.showNormal() + # 设定宽度:按鼠标 X 位置占屏幕的比例 + ratio = max(0.2, min(0.8, (global_pos.x() - screen.x()) / screen.width())) + new_w = int(screen.width() * ratio) + new_w = max(self.minimumWidth(), new_w) + new_h = int(screen.height() * 0.85) + new_h = max(self.minimumHeight(), new_h) + new_x = max(screen.x(), global_pos.x() - new_w // 2) + new_y = screen.y() + (screen.height() - new_h) // 2 + self.setGeometry(new_x, new_y, new_w, new_h) + # 让后续 mouseMove 继续调整 + self._resize_edge = "TL" # 模拟从左上角拖 + self._resize_start_geo = self.geometry() + self._resize_start_pos = global_pos + + def mouseReleaseEvent(self, e): + if self._resize_edge: + self._resize_edge = "" + self._resize_start_geo = None + self._resize_start_pos = None + e.accept() + return + super().mouseReleaseEvent(e) + + def _do_resize(self, global_pos): + """根据当前鼠标位置和拖动方向调整窗口 geometry""" + geo = self._resize_start_geo + if geo is None: + return + dx = global_pos.x() - self._resize_start_pos.x() + dy = global_pos.y() - self._resize_start_pos.y() + # 屏幕可用区域(用于限制) + try: + screen = QApplication.primaryScreen().availableGeometry() + except Exception: + from PyQt5.QtCore import QRect + screen = QRect(0, 0, 10000, 10000) + # 最小尺寸:尊重 QMainWindow 自己的 minimumWidth/minimumHeight + min_w = self.minimumWidth() if self.minimumWidth() > 0 else 400 + min_h = self.minimumHeight() if self.minimumHeight() > 0 else 300 + new_x, new_y, new_w, new_h = geo.x(), geo.y(), geo.width(), geo.height() + + edge = self._resize_edge + # 左边 + if "L" in edge: + new_x = geo.x() + dx + new_w = geo.width() - dx + if new_w < min_w: + new_w = min_w + new_x = geo.x() + geo.width() - min_w + # 右边 + if "R" in edge: + new_w = geo.width() + dx + if new_w < min_w: + new_w = min_w + # 上边 + if "T" in edge: + new_y = geo.y() + dy + new_h = geo.height() - dy + if new_h < min_h: + new_h = min_h + new_y = geo.y() + geo.height() - min_h + # 下边 + if "B" in edge: + new_h = geo.height() + dy + if new_h < min_h: + new_h = min_h + # 屏幕限制 + if new_x < screen.x(): + new_x = screen.x() + if new_y < screen.y(): + new_y = screen.y() + if new_x + new_w > screen.x() + screen.width(): + new_w = screen.x() + screen.width() - new_x + if new_y + new_h > screen.y() + screen.height(): + new_h = screen.y() + screen.height() - new_y + # 拆成 move + resize + setGeometry 兜底(部分平台 setGeometry 会失败) + self.resize(new_w, new_h) + self.move(new_x, new_y) + if (self.width(), self.height()) != (new_w, new_h): + self.setGeometry(new_x, new_y, new_w, new_h) + + def leaveEvent(self, e): + if not self._resize_edge: + self._restore_cursor() + super().leaveEvent(e) + + def changeEvent(self, e): + """窗口状态变化:清除残留的拖动状态""" + from PyQt5.QtCore import QEvent as _QEvent + if e.type() == _QEvent.WindowStateChange: + # 状态变化(最大化/还原)时清掉拖动状态 + self._resize_edge = "" + self._resize_start_geo = None + self._resize_start_pos = None + self._restore_cursor() + super().changeEvent(e) + def closeEvent(self, e): try: self.monitor._stop_worker()