feat: window edge drag-to-resize (normal + from maximized)
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.
This commit is contained in:
+156
@@ -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()
|
||||||
@@ -47,6 +47,17 @@ class MainWindow(QMainWindow):
|
|||||||
self._build_menu()
|
self._build_menu()
|
||||||
self._build_statusbar()
|
self._build_statusbar()
|
||||||
self._apply_theme()
|
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()
|
self._load_hosts_to_list()
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -488,6 +499,193 @@ class MainWindow(QMainWindow):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
QMessageBox.critical(self, "导入失败", str(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):
|
def closeEvent(self, e):
|
||||||
try:
|
try:
|
||||||
self.monitor._stop_worker()
|
self.monitor._stop_worker()
|
||||||
|
|||||||
Reference in New Issue
Block a user