fix: top/bottom edge resize broken — menubar ate events + minimumSize clamped to 1121px

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.
This commit is contained in:
Your Name
2026-07-29 07:49:10 +08:00
parent e0d33adb97
commit c4dd4d8adf
2 changed files with 176 additions and 12 deletions
+106 -7
View File
@@ -1,8 +1,11 @@
""" """
窗口边缘拖动测试: 窗口边缘拖动测试:
- _hit_test_edge 返回正确的边 - _hit_test_edge 返回正确的边
- mouseMove + mousePress 模拟拖动 - mouseMove + mousePress 模拟拖动(直接投递到 QMainWindow
- 普通模式 + 最大化模式都覆盖 - 普通模式 + 最大化模式都覆盖
- 真实事件投递(childAt → 子控件,依赖传播/事件过滤器):4 边 + 3 角
- 最大化时真实按压菜单栏顶缘 → 还原
- 最小尺寸合理性(回归:MinimumSizeHint 曾把窗口最小高度撑到 1121px,上下无法缩小)
""" """
import os import os
import sys import sys
@@ -12,7 +15,7 @@ sys.path.insert(0, os.path.dirname(__file__))
from PyQt5.QtCore import Qt, QPoint, QEvent from PyQt5.QtCore import Qt, QPoint, QEvent
from PyQt5.QtGui import QMouseEvent from PyQt5.QtGui import QMouseEvent
from PyQt5.QtWidgets import QApplication from PyQt5.QtWidgets import QApplication, QSizeGrip
from ui.main_window import MainWindow 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) 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(): def main():
app = QApplication(sys.argv) app = QApplication(sys.argv)
# 在 offscreen 平台,默认屏幕可能很小(800x600),把窗口放大会被限制 # 在 offscreen 平台,默认屏幕可能很小(800x600),把窗口放大会被限制
@@ -43,7 +75,7 @@ def main():
orig_primary = QApplication.primaryScreen orig_primary = QApplication.primaryScreen
QApplication.primaryScreen = staticmethod(lambda: FakeScreen()) QApplication.primaryScreen = staticmethod(lambda: FakeScreen())
print("[1/5] 创建 MainWindow + 边缘 hit-test") print("[1/8] 创建 MainWindow + 边缘 hit-test")
w = MainWindow() w = MainWindow()
w.resize(800, 600) w.resize(800, 600)
w.show() w.show()
@@ -66,7 +98,7 @@ def main():
assert w._hit_test_edge(QPoint(w_real_w - 2, w_real_h - 2)) == "BR" assert w._hit_test_edge(QPoint(w_real_w - 2, w_real_h - 2)) == "BR"
print(" ✓ 8 个区域(4 边 + 4 角)全部正确命中") print(" ✓ 8 个区域(4 边 + 4 角)全部正确命中")
print("[2/5] 普通模式:拖右边缘放大窗口") print("[2/8] 普通模式:拖右边缘放大窗口")
w.resize(800, 600) w.resize(800, 600)
w.show() w.show()
rw, rh = w.width(), w.height() rw, rh = w.width(), w.height()
@@ -87,7 +119,7 @@ def main():
assert w._resize_edge == "" assert w._resize_edge == ""
print(f" ✓ 右拖: 宽 {geo0.width()}{geo1.width()} (+{geo1.width() - geo0.width()})") print(f" ✓ 右拖: 宽 {geo0.width()}{geo1.width()} (+{geo1.width() - geo0.width()})")
print("[3/5] 普通模式:拖左边缘(鼠标右移 → 窗口变宽)") print("[3/8] 普通模式:拖左边缘(鼠标右移 → 窗口变宽)")
w.resize(800, 600) w.resize(800, 600)
w.show() w.show()
rw, rh = w.width(), w.height() rw, rh = w.width(), w.height()
@@ -111,7 +143,7 @@ def main():
assert geo1.x() > geo0.x(), f"x 没变: {geo0.x()} -> {geo1.x()}" assert geo1.x() > geo0.x(), f"x 没变: {geo0.x()} -> {geo1.x()}"
QApplication.sendEvent(w, make_mouse_event(QEvent.MouseButtonRelease, new_pos)) QApplication.sendEvent(w, make_mouse_event(QEvent.MouseButtonRelease, new_pos))
print("[4/5] 普通模式:拖角(左上)") print("[4/8] 普通模式:拖角(左上)")
w.resize(800, 600) w.resize(800, 600)
w.show() w.show()
rw, rh = w.width(), w.height() 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 限制)") print(f" ✓ TL 拖: {geo0.width()}x{geo0.height()}{geo1.width()}x{geo1.height()} (受 minSize 限制)")
QApplication.sendEvent(w, make_mouse_event(QEvent.MouseButtonRelease, new_pos)) QApplication.sendEvent(w, make_mouse_event(QEvent.MouseButtonRelease, new_pos))
print("[5/5] 最大化时:拖顶部边缘应还原窗口") print("[5/8] 最大化时:拖顶部边缘应还原窗口(直接投递路径)")
w.resize(800, 600) w.resize(800, 600)
w.showMaximized() w.showMaximized()
app.processEvents() app.processEvents()
@@ -149,6 +181,73 @@ def main():
QApplication.sendEvent(w, make_mouse_event(QEvent.MouseButtonRelease, QPoint(400, 3))) QApplication.sendEvent(w, make_mouse_event(QEvent.MouseButtonRelease, QPoint(400, 3)))
print(f" ✓ 最大化时点顶部 → 已还原 (geometry={w.geometry().width()}x{w.geometry().height()})") 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窗口边缘拖动测试通过 ✓") print("\n窗口边缘拖动测试通过 ✓")
+70 -5
View File
@@ -7,7 +7,7 @@ import sys
import time import time
from typing import Optional 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.QtGui import QFont, QIcon
from PyQt5.QtWidgets import ( from PyQt5.QtWidgets import (
QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QListWidget, QListWidgetItem, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QListWidget, QListWidgetItem,
@@ -47,6 +47,13 @@ class MainWindow(QMainWindow):
self._build_menu() self._build_menu()
self._build_statusbar() self._build_statusbar()
# 菜单栏/状态栏事件过滤器:
# 窗口上下边缘分别被 QMenuBar / QStatusBar 覆盖,QMenuBar 会主动消费
# 鼠标事件(悬停高亮菜单),事件不会传播到 QMainWindow,导致上边缘
# (及最大化时从顶部拖还原)永远失效。装过滤器在边缘带内截获处理。
self.menuBar().installEventFilter(self)
self.statusBar().installEventFilter(self)
# 左侧主机栏折叠状态 # 左侧主机栏折叠状态
# 注意:Ctrl+B 快捷键只由「视图」菜单项(act_toggle_sidebar)承载, # 注意:Ctrl+B 快捷键只由「视图」菜单项(act_toggle_sidebar)承载,
# 不再额外建 QShortcut —— 否则一次按键触发两个绑定,会 toggle 两次等于没反应 # 不再额外建 QShortcut —— 否则一次按键触发两个绑定,会 toggle 两次等于没反应
@@ -172,6 +179,16 @@ class MainWindow(QMainWindow):
splitter.addWidget(self.tabs) splitter.addWidget(self.tabs)
splitter.setSizes([280, 1000]) 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): def _toggle_sidebar(self):
"""折叠/展开左侧主机栏""" """折叠/展开左侧主机栏"""
self._sidebar_collapsed = not self._sidebar_collapsed self._sidebar_collapsed = not self._sidebar_collapsed
@@ -620,7 +637,7 @@ class MainWindow(QMainWindow):
return "B" return "B"
return "" return ""
def _edge_to_cursor(self, edge: str): def _edge_to_cursor(self, edge: str, widget=None):
from PyQt5.QtGui import QCursor from PyQt5.QtGui import QCursor
from PyQt5.QtCore import Qt as _Qt from PyQt5.QtCore import Qt as _Qt
cursors = { cursors = {
@@ -631,10 +648,58 @@ class MainWindow(QMainWindow):
} }
c = cursors.get(edge) c = cursors.get(edge)
if c is not None: if c is not None:
self.setCursor(QCursor(c)) (widget or self).setCursor(QCursor(c))
def _restore_cursor(self): def _restore_cursor(self, widget=None):
self.unsetCursor() (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 时已 grabMousemove 持续走这里
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): def mouseMoveEvent(self, e):
# 拖动中 # 拖动中