From c4dd4d8adf1844ca4573d982d1785b0617367724 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 29 Jul 2026 07:49:10 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20top/bottom=20edge=20resize=20broken=20?= =?UTF-8?q?=E2=80=94=20menubar=20ate=20events=20+=20minimumSize=20clamped?= =?UTF-8?q?=20to=201121px?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two independent root causes for '左右能拉,上下不能拉': 1. Event delivery: the top 6px edge band belongs to QMenuBar, which actively consumes mouse events (menu hover) — nothing ever propagated to QMainWindow, so T/TL/TR (and maximize-restore from top edge) were dead on every platform. Install an event filter on menuBar()/statusBar(): within the edge band it handles hover cursor, press (grabMouse), drag and release; outside the band everything passes through (menus open normally, native QSizeGrip corner drag preserved). 2. Layout minimum: MonitorPanel's minimumSizeHint (~1015px of stacked tables/group boxes) propagated through QTabWidget → central widget → QMainWindow, making the window minimum size 898x1121. Any vertical shrink was clamped dead. Set explicit per-tab-page minimumSize (320x200) + window minimumSize (760x480). Tests: test_resize.py now drives edges through real event delivery (childAt → child widget, propagation/filter path) for L/R/T/B/TL/TR/BL, verifies BR stays on QSizeGrip, maximize-restore via menubar filter, and minimum-size regression. All 8 suites pass. --- test_resize.py | 113 +++++++++++++++++++++++++++++++++++++++++++--- ui/main_window.py | 75 ++++++++++++++++++++++++++++-- 2 files changed, 176 insertions(+), 12 deletions(-) diff --git a/test_resize.py b/test_resize.py index 6b93c7e..269897c 100644 --- a/test_resize.py +++ b/test_resize.py @@ -1,8 +1,11 @@ """ 窗口边缘拖动测试: - _hit_test_edge 返回正确的边 -- mouseMove + mousePress 模拟拖动 +- mouseMove + mousePress 模拟拖动(直接投递到 QMainWindow) - 普通模式 + 最大化模式都覆盖 +- 真实事件投递(childAt → 子控件,依赖传播/事件过滤器):4 边 + 3 角 +- 最大化时真实按压菜单栏顶缘 → 还原 +- 最小尺寸合理性(回归:MinimumSizeHint 曾把窗口最小高度撑到 1121px,上下无法缩小) """ import os import sys @@ -12,7 +15,7 @@ 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 PyQt5.QtWidgets import QApplication, QSizeGrip from ui.main_window import MainWindow @@ -23,6 +26,35 @@ def make_mouse_event(type_, pos, button=Qt.LeftButton): return QMouseEvent(type_, pos, glb, button, button, Qt.NoModifier) +def deliver(target, type_, local_pos, button=Qt.LeftButton, buttons=Qt.NoButton): + """把事件投给 target(模拟 Qt 投给光标下子控件的真实路径)""" + ev = QMouseEvent(type_, local_pos, QPoint(local_pos.x(), local_pos.y()), + button, buttons, Qt.NoModifier) + QApplication.sendEvent(target, ev) + return ev + + +def try_real_drag(w, press_pos, delta, expected_edge): + """真实投递:press 到 childAt 命中的控件 → move → release。 + 返回 (命中控件, geo_before, geo_after)""" + child = w.childAt(press_pos) + target = child if child else w + local = target.mapFrom(w, press_pos) if child else press_pos + geo0 = w.geometry() + deliver(target, QEvent.MouseButtonPress, local, + button=Qt.LeftButton, buttons=Qt.LeftButton) + assert w._resize_edge == expected_edge, \ + f"{expected_edge}: press 后 _resize_edge={w._resize_edge!r}(命中 {type(target).__name__})" + moved = QPoint(local.x() + delta[0], local.y() + delta[1]) + deliver(target, QEvent.MouseMove, moved, button=Qt.NoButton, buttons=Qt.LeftButton) + deliver(target, QEvent.MouseMove, moved, button=Qt.NoButton, buttons=Qt.LeftButton) + geo1 = w.geometry() + deliver(target, QEvent.MouseButtonRelease, moved, + button=Qt.LeftButton, buttons=Qt.NoButton) + assert w._resize_edge == "", "release 后 _resize_edge 未清空" + return type(target).__name__, geo0, geo1 + + def main(): app = QApplication(sys.argv) # 在 offscreen 平台,默认屏幕可能很小(800x600),把窗口放大会被限制 @@ -43,7 +75,7 @@ def main(): orig_primary = QApplication.primaryScreen QApplication.primaryScreen = staticmethod(lambda: FakeScreen()) - print("[1/5] 创建 MainWindow + 边缘 hit-test") + print("[1/8] 创建 MainWindow + 边缘 hit-test") w = MainWindow() w.resize(800, 600) w.show() @@ -66,7 +98,7 @@ def main(): assert w._hit_test_edge(QPoint(w_real_w - 2, w_real_h - 2)) == "BR" print(" ✓ 8 个区域(4 边 + 4 角)全部正确命中") - print("[2/5] 普通模式:拖右边缘放大窗口") + print("[2/8] 普通模式:拖右边缘放大窗口") w.resize(800, 600) w.show() rw, rh = w.width(), w.height() @@ -87,7 +119,7 @@ def main(): assert w._resize_edge == "" print(f" ✓ 右拖: 宽 {geo0.width()} → {geo1.width()} (+{geo1.width() - geo0.width()})") - print("[3/5] 普通模式:拖左边缘(鼠标右移 → 窗口变宽)") + print("[3/8] 普通模式:拖左边缘(鼠标右移 → 窗口变宽)") w.resize(800, 600) w.show() rw, rh = w.width(), w.height() @@ -111,7 +143,7 @@ def main(): assert geo1.x() > geo0.x(), f"x 没变: {geo0.x()} -> {geo1.x()}" QApplication.sendEvent(w, make_mouse_event(QEvent.MouseButtonRelease, new_pos)) - print("[4/5] 普通模式:拖角(左上)") + print("[4/8] 普通模式:拖角(左上)") w.resize(800, 600) w.show() rw, rh = w.width(), w.height() @@ -131,7 +163,7 @@ def main(): 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] 最大化时:拖顶部边缘应还原窗口") + print("[5/8] 最大化时:拖顶部边缘应还原窗口(直接投递路径)") w.resize(800, 600) w.showMaximized() app.processEvents() @@ -149,6 +181,73 @@ def main(): QApplication.sendEvent(w, make_mouse_event(QEvent.MouseButtonRelease, QPoint(400, 3))) print(f" ✓ 最大化时点顶部 → 已还原 (geometry={w.geometry().width()}x{w.geometry().height()})") + print("[6/8] 真实事件投递:4 边 + 3 角都能拖动(经子控件传播/事件过滤器)") + cases = [ + # (名称, 按压点函数, 拖动增量, 期望 edge, 断言函数) + ("L", lambda W, H: QPoint(3, H // 2), (60, 0), "L", + lambda g0, g1: g1.x() > g0.x()), + ("R", lambda W, H: QPoint(W - 4, H // 2), (60, 0), "R", + lambda g0, g1: g1.width() > g0.width()), + ("T", lambda W, H: QPoint(W // 2, 3), (0, 60), "T", + lambda g0, g1: g1.y() > g0.y()), + ("B", lambda W, H: QPoint(W // 2, H - 4), (0, 60), "B", + lambda g0, g1: g1.height() > g0.height()), + ("TL", lambda W, H: QPoint(3, 3), (60, 60), "TL", + lambda g0, g1: g1.x() > g0.x() and g1.y() > g0.y()), + ("TR", lambda W, H: QPoint(W - 4, 3), (-60, 60), "TR", + lambda g0, g1: g1.y() > g0.y()), + ("BL", lambda W, H: QPoint(3, H - 4), (60, -60), "BL", + lambda g0, g1: g1.x() > g0.x()), + ] + for name, pos_fn, delta, edge, check in cases: + w.resize(800, 600) + w.show() + app.processEvents() + W, H = w.width(), w.height() + hit, geo0, geo1 = try_real_drag(w, pos_fn(W, H), delta, edge) + assert check(geo0, geo1), \ + f"{name}: 几何未生效 {geo0.getRect()} → {geo1.getRect()} (命中 {hit})" + print(f" ✓ {name:2s} 命中 {hit:10s} → {geo0.width()}x{geo0.height()} " + f"→ {geo1.width()}x{geo1.height()}") + # BR:原生 QSizeGrip 负责(真实桌面可拖;offscreen 下只验证 grip 存在且不被过滤器拦截) + w.resize(800, 600) + w.show() + app.processEvents() + br_child = w.childAt(QPoint(w.width() - 4, w.height() - 4)) + assert isinstance(br_child, QSizeGrip), f"右下角应为 QSizeGrip, 实际 {type(br_child).__name__}" + print(f" ✓ BR 命中 QSizeGrip(原生角拖保留)") + + print("[7/8] 最大化时:真实按压菜单栏顶缘 → 还原(事件过滤器路径)") + w.resize(800, 600) + w.showMaximized() + app.processEvents() + assert w.isMaximized() + mb = w.menuBar() + local = QPoint(mb.width() // 2, 2) # 菜单栏局部坐标,顶缘 6px 带内 + deliver(mb, QEvent.MouseButtonPress, local, + button=Qt.LeftButton, buttons=Qt.LeftButton) + app.processEvents() + assert not w.isMaximized(), "事件过滤器路径:最大化时按菜单栏顶缘应还原" + deliver(mb, QEvent.MouseButtonRelease, local, + button=Qt.LeftButton, buttons=Qt.NoButton) + assert w._resize_edge == "" + print(f" ✓ 菜单栏顶缘按压 → 已还原 ({w.width()}x{w.height()})") + + print("[8/8] 最小尺寸合理性(回归:曾被 MinimumSizeHint 撑到 1121px 高)") + assert w.minimumWidth() <= 800, f"最小宽度异常: {w.minimumWidth()}" + assert w.minimumHeight() <= 600, f"最小高度异常: {w.minimumHeight()}" + w.resize(1280, 800) + app.processEvents() + assert w.width() == 1280 and w.height() == 800, \ + f"resize(1280,800) 被 clamp 成 {w.width()}x{w.height()}" + # 能缩小到最小尺寸 + w.resize(760, 480) + app.processEvents() + assert w.width() == 760 and w.height() == 480, \ + f"无法缩到下限: {w.width()}x{w.height()}" + print(f" ✓ minimumSize={w.minimumWidth()}x{w.minimumHeight()}, " + f"resize(1280,800)/resize(760,480) 均精确生效") + print("\n窗口边缘拖动测试通过 ✓") diff --git a/ui/main_window.py b/ui/main_window.py index e36c198..93818e6 100644 --- a/ui/main_window.py +++ b/ui/main_window.py @@ -7,7 +7,7 @@ import sys import time from typing import Optional -from PyQt5.QtCore import Qt, QSize +from PyQt5.QtCore import Qt, QSize, QEvent from PyQt5.QtGui import QFont, QIcon from PyQt5.QtWidgets import ( QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QListWidget, QListWidgetItem, @@ -47,6 +47,13 @@ class MainWindow(QMainWindow): self._build_menu() self._build_statusbar() + # 菜单栏/状态栏事件过滤器: + # 窗口上下边缘分别被 QMenuBar / QStatusBar 覆盖,QMenuBar 会主动消费 + # 鼠标事件(悬停高亮菜单),事件不会传播到 QMainWindow,导致上边缘 + # (及最大化时从顶部拖还原)永远失效。装过滤器在边缘带内截获处理。 + self.menuBar().installEventFilter(self) + self.statusBar().installEventFilter(self) + # 左侧主机栏折叠状态 # 注意:Ctrl+B 快捷键只由「视图」菜单项(act_toggle_sidebar)承载, # 不再额外建 QShortcut —— 否则一次按键触发两个绑定,会 toggle 两次等于没反应 @@ -172,6 +179,16 @@ class MainWindow(QMainWindow): splitter.addWidget(self.tabs) splitter.setSizes([280, 1000]) + # 各 Tab 页显式设小最小尺寸:否则 QTabWidget 取所有页 minimumSizeHint + # 的最大值(MonitorPanel 内部表格/分组堆出 ~1015px 高),一路传到 + # QMainWindow,导致窗口最小高度 > 1100px —— 上下边缘拖动被 clamp 死, + # 表现为"上下不能拉"。QSplitter 子件默认 minimumSize=0,压缩无碍。 + for _page in (self.terminal_panel, self.file_browser, + self.monitor, self.ai_panel): + _page.setMinimumSize(320, 200) + # 窗口级合理下限(显式 minimumSize 覆盖布局算出的巨大值) + self.setMinimumSize(760, 480) + def _toggle_sidebar(self): """折叠/展开左侧主机栏""" self._sidebar_collapsed = not self._sidebar_collapsed @@ -620,7 +637,7 @@ class MainWindow(QMainWindow): return "B" return "" - def _edge_to_cursor(self, edge: str): + def _edge_to_cursor(self, edge: str, widget=None): from PyQt5.QtGui import QCursor from PyQt5.QtCore import Qt as _Qt cursors = { @@ -631,10 +648,58 @@ class MainWindow(QMainWindow): } c = cursors.get(edge) if c is not None: - self.setCursor(QCursor(c)) + (widget or self).setCursor(QCursor(c)) - def _restore_cursor(self): - self.unsetCursor() + def _restore_cursor(self, widget=None): + (widget or self).unsetCursor() + + def eventFilter(self, obj, e): + """拦截菜单栏/状态栏上的鼠标事件,实现上/下边缘拖动调整窗口。 + + QMenuBar 主动消费鼠标事件(悬停高亮菜单项),事件不会向上传播到 + QMainWindow,上边缘 hit-test 永远收不到事件;状态栏在部分平台/样式 + 下同理。过滤器只在 6px 边缘带内接管,其余区域原样放行 + (菜单照常点开、QSizeGrip 原生右下角拖动不受影响)。 + """ + mb, sb = self.menuBar(), self.statusBar() + if obj is mb or obj is sb: + et = e.type() + if et == QEvent.MouseMove: + # 拖动中:press 时已 grabMouse,move 持续走这里 + if self._resize_edge and (e.buttons() & Qt.LeftButton): + self._do_resize(e.globalPos()) + return True + # 悬停:边缘带内显示调整光标并屏蔽菜单高亮,带外放行 + if not e.buttons(): + edge = self._hit_test_edge(obj.mapTo(self, e.pos())) + if edge: + self._edge_to_cursor(edge, obj) + return True + self._restore_cursor(obj) + return False + if et == QEvent.MouseButtonPress and e.button() == Qt.LeftButton: + # 最大化时:菜单栏顶部边缘按下 → 还原并跟随鼠标 + if obj is mb and self.isMaximized() and e.pos().y() <= self._resize_margin: + self._restore_from_max(e.globalPos()) + return True + edge = self._hit_test_edge(obj.mapTo(self, e.pos())) + if edge: + self._resize_edge = edge + self._resize_start_geo = self.geometry() + self._resize_start_pos = e.globalPos() + obj.grabMouse() # 后续 move/release 全部经过本过滤器 + return True + return False + if et == QEvent.MouseButtonRelease and self._resize_edge: + self._resize_edge = "" + self._resize_start_geo = None + self._resize_start_pos = None + obj.releaseMouse() + return True + if et == QEvent.Leave: + self._restore_cursor(obj) + return False + return super().eventFilter(obj, e) def mouseMoveEvent(self, e): # 拖动中