feat: pop-out windows instead of floating docks
Floating a dock rips it from the row and reshuffles the rest, so PopoutWindow opens an ADDITIONAL frameless top-level window instead: 12px outer resize halo (border painted OUTER_GRIP inside the real edge so an outside-looking grab still works), hand-painted borderless titlebar glyphs, close hides and keeps geometry. DockTitleBar puts the popout button next to the close box. The console log adopts it: dock floating disabled, the pop-out view is a second QPlainTextEdit fed by the same emitter (a shared QTextDocument would make two views fight over one layout), clear() clears both. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,7 @@ from PySide6.QtWidgets import (
|
||||
)
|
||||
|
||||
from aare.gui.log import QtLogEmitter, QtLogHandler
|
||||
from aare.gui.widgets.popout_window import DockTitleBar, PopoutWindow
|
||||
from aare.gui.styles import (
|
||||
FLAT_CARD_RADIUS,
|
||||
LOG_BORDER,
|
||||
@@ -198,6 +199,17 @@ class LogDock(QDockWidget):
|
||||
| Qt.DockWidgetArea.LeftDockWidgetArea
|
||||
)
|
||||
|
||||
# No floating: popping a dock out rips it from the row and reshuffles
|
||||
# the rest. The ⤢ in the title bar (next to ✕) opens an ADDITIONAL
|
||||
# window on the same log instead.
|
||||
self.setFeatures(
|
||||
QDockWidget.DockWidgetFeature.DockWidgetMovable
|
||||
| QDockWidget.DockWidgetFeature.DockWidgetClosable
|
||||
)
|
||||
self._popout: PopoutWindow | None = None
|
||||
self._popout_view: QPlainTextEdit | None = None
|
||||
self.setTitleBarWidget(DockTitleBar(self, self._open_popout))
|
||||
|
||||
self.container = QWidget(self)
|
||||
|
||||
self.notification = RuntimeNotificationWidget(self.container)
|
||||
@@ -227,6 +239,25 @@ class LogDock(QDockWidget):
|
||||
def _append_line(self, text: str):
|
||||
self.view.appendPlainText(text)
|
||||
|
||||
@Slot()
|
||||
def _open_popout(self) -> None:
|
||||
if self._popout is None:
|
||||
# Mirror view fed by the same emitter; history is copied once at
|
||||
# creation. (One QTextDocument shared by two QPlainTextEdits would
|
||||
# make their layouts fight, hence the second document.)
|
||||
view = QPlainTextEdit()
|
||||
view.setReadOnly(True)
|
||||
# Frameless inside the pop-out — no nested boxes in this window.
|
||||
view.setStyleSheet("QPlainTextEdit { border: none; }")
|
||||
view.setPlainText(self.view.toPlainText())
|
||||
self.emitter.message.connect(view.appendPlainText)
|
||||
self._popout_view = view
|
||||
self._popout = PopoutWindow("Console Log", view, parent=self.window())
|
||||
self._popout.resize(1000, 450)
|
||||
self._popout.show()
|
||||
self._popout.raise_()
|
||||
self._popout.activateWindow()
|
||||
|
||||
@Slot()
|
||||
def _raise_and_focus_log(self) -> None:
|
||||
self.setVisible(True)
|
||||
@@ -258,3 +289,5 @@ class LogDock(QDockWidget):
|
||||
|
||||
def clear(self):
|
||||
self.view.clear()
|
||||
if self._popout_view is not None:
|
||||
self._popout_view.clear()
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
from PySide6.QtCore import QPoint, QRect, QSize, Qt
|
||||
from PySide6.QtGui import QCursor, QGuiApplication, QIcon, QPainter, QPen, QPixmap
|
||||
from PySide6.QtWidgets import (
|
||||
QDockWidget,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QToolButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from aare.gui.styles import FRAME_L1_COLOR, FRAME_L1_WIDTH, TEXT, qcolor
|
||||
|
||||
# Title-bar buttons: icon fills the button, both the same size.
|
||||
TITLEBAR_BUTTON_PX = 22
|
||||
TITLEBAR_ICON_PX = 18
|
||||
|
||||
|
||||
def _titlebar_icon(kind: str, size: int = TITLEBAR_ICON_PX) -> QIcon:
|
||||
"""Hand-painted borderless glyphs — the style's standard title-bar
|
||||
pixmaps draw boxed icons, and text glyphs are missing from the
|
||||
container's fonts."""
|
||||
pixmap = QPixmap(size, size)
|
||||
pixmap.fill(Qt.GlobalColor.transparent)
|
||||
painter = QPainter(pixmap)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
pen = QPen(qcolor(TEXT), 2)
|
||||
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
||||
pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
|
||||
painter.setPen(pen)
|
||||
if kind == "close":
|
||||
painter.drawLine(4, 4, size - 4, size - 4)
|
||||
painter.drawLine(size - 4, 4, 4, size - 4)
|
||||
else: # "popout": window in the lower left, arrow escaping top-right
|
||||
painter.drawRect(3, size // 2 - 1, size // 2 - 1, size // 2 - 1)
|
||||
painter.drawLine(size // 2 + 1, size // 2 - 1, size - 3, 3)
|
||||
painter.drawLine(size - 8, 3, size - 3, 3)
|
||||
painter.drawLine(size - 3, 3, size - 3, 8)
|
||||
painter.end()
|
||||
return QIcon(pixmap)
|
||||
|
||||
|
||||
def _titlebar_button(parent: QWidget, kind: str, tooltip: str) -> QToolButton:
|
||||
button = QToolButton(parent)
|
||||
button.setIcon(_titlebar_icon(kind))
|
||||
button.setIconSize(QSize(TITLEBAR_ICON_PX, TITLEBAR_ICON_PX))
|
||||
button.setFixedSize(TITLEBAR_BUTTON_PX, TITLEBAR_BUTTON_PX)
|
||||
button.setAutoRaise(True)
|
||||
# No button chrome — the glyph IS the button.
|
||||
button.setStyleSheet("QToolButton { border: none; background: transparent; }")
|
||||
button.setToolTip(tooltip)
|
||||
button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
return button
|
||||
|
||||
|
||||
class DockTitleBar(QWidget):
|
||||
"""Dock title bar with a ⤢ pop-out button right next to ✕.
|
||||
|
||||
Qt's native dock title bar cannot host extra buttons, so this replaces
|
||||
it: [title … ⤢ ✕]. Trade-off: the dock can no longer be dragged by its
|
||||
title — acceptable here, these docks are pinned to the bottom row.
|
||||
"""
|
||||
|
||||
def __init__(self, dock: QDockWidget, on_popout):
|
||||
super().__init__(dock)
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(8, 2, 4, 2)
|
||||
layout.setSpacing(2)
|
||||
|
||||
title = QLabel(dock.windowTitle(), self)
|
||||
title.setStyleSheet("background: transparent;")
|
||||
layout.addWidget(title)
|
||||
layout.addStretch(1)
|
||||
|
||||
self.popout_button = _titlebar_button(
|
||||
self, "popout", "Open in a separate window (the panel stays here too)"
|
||||
)
|
||||
self.popout_button.clicked.connect(on_popout)
|
||||
layout.addWidget(self.popout_button)
|
||||
|
||||
close_button = _titlebar_button(self, "close", "Close panel (reopen via the View menu)")
|
||||
close_button.clicked.connect(dock.close)
|
||||
layout.addWidget(close_button)
|
||||
|
||||
|
||||
class PopoutWindow(QWidget):
|
||||
"""Additional top-level window for a panel mirror.
|
||||
|
||||
Unlike a floated QDockWidget it never removes anything from the main
|
||||
window — closing it just hides it (geometry kept for reopening) and the
|
||||
main window is untouched. The layout leaves RESIZE_MARGIN px of the
|
||||
window exposed around the content as a fat, easy-to-hit resize band;
|
||||
frameless floats only give a few px. Resize uses startSystemResize with
|
||||
a manual fallback for window managers that lack it.
|
||||
"""
|
||||
|
||||
# 6px: enough to grab without pixel-hunting, small enough that the area
|
||||
# right around the content doesn't hijack table interactions.
|
||||
RESIZE_MARGIN = 6
|
||||
# Clicks can never land outside a window, so a from-the-outside grab zone
|
||||
# has to be window area that only LOOKS external: the visible border is
|
||||
# drawn OUTER_GRIP px inside the real edge, and the halo beyond it
|
||||
# resizes too.
|
||||
OUTER_GRIP = 4
|
||||
|
||||
def __init__(self, title: str, content: QWidget, parent=None):
|
||||
super().__init__(parent, Qt.WindowType.Window)
|
||||
self.setWindowTitle(title)
|
||||
self.setMinimumSize(300, 160)
|
||||
layout = QVBoxLayout(self)
|
||||
m = self.RESIZE_MARGIN + self.OUTER_GRIP
|
||||
layout.setContentsMargins(m, m, m, m)
|
||||
layout.addWidget(content)
|
||||
self.setMouseTracking(True)
|
||||
self._manual_edges = Qt.Edge(0)
|
||||
self._press_global: QPoint | None = None
|
||||
self._press_geom: QRect | None = None
|
||||
self._placed = False
|
||||
|
||||
def showEvent(self, event):
|
||||
# First show opens near the click (the ⤢ button = the cursor), not at
|
||||
# the WM's default top-left; reopening keeps the last geometry.
|
||||
if not self._placed:
|
||||
self._placed = True
|
||||
cursor = QCursor.pos()
|
||||
pos = cursor - QPoint(60, 20)
|
||||
screen = QGuiApplication.screenAt(cursor) or QGuiApplication.primaryScreen()
|
||||
if screen is not None:
|
||||
geo = screen.availableGeometry()
|
||||
pos.setX(max(geo.left(), min(pos.x(), geo.right() - self.width())))
|
||||
pos.setY(max(geo.top(), min(pos.y(), geo.bottom() - self.height())))
|
||||
self.move(pos)
|
||||
super().showEvent(event)
|
||||
|
||||
def paintEvent(self, event):
|
||||
super().paintEvent(event)
|
||||
# Optional perceived window border, inset by OUTER_GRIP (see class
|
||||
# note). Level-1 frame — weight/color are knobs in styles.py; the
|
||||
# default width 0 paints nothing (resize still works via the cursor
|
||||
# hint over the grab band).
|
||||
width = int(FRAME_L1_WIDTH.rstrip("px"))
|
||||
if width <= 0:
|
||||
return
|
||||
painter = QPainter(self)
|
||||
painter.setPen(QPen(qcolor(FRAME_L1_COLOR), width))
|
||||
g = self.OUTER_GRIP
|
||||
painter.drawRect(self.rect().adjusted(g, g, -g - 1, -g - 1))
|
||||
|
||||
def _edges_at(self, pos: QPoint) -> Qt.Edge:
|
||||
m = self.RESIZE_MARGIN + self.OUTER_GRIP
|
||||
edges = Qt.Edge(0)
|
||||
if pos.x() <= m:
|
||||
edges |= Qt.Edge.LeftEdge
|
||||
if pos.x() >= self.width() - m:
|
||||
edges |= Qt.Edge.RightEdge
|
||||
if pos.y() <= m:
|
||||
edges |= Qt.Edge.TopEdge
|
||||
if pos.y() >= self.height() - m:
|
||||
edges |= Qt.Edge.BottomEdge
|
||||
return edges
|
||||
|
||||
def _cursor_for(self, edges: Qt.Edge):
|
||||
horizontal = edges & (Qt.Edge.LeftEdge | Qt.Edge.RightEdge)
|
||||
vertical = edges & (Qt.Edge.TopEdge | Qt.Edge.BottomEdge)
|
||||
if horizontal and vertical:
|
||||
same_diag = bool(edges & Qt.Edge.LeftEdge) == bool(edges & Qt.Edge.TopEdge)
|
||||
return Qt.CursorShape.SizeFDiagCursor if same_diag else Qt.CursorShape.SizeBDiagCursor
|
||||
if horizontal:
|
||||
return Qt.CursorShape.SizeHorCursor
|
||||
if vertical:
|
||||
return Qt.CursorShape.SizeVerCursor
|
||||
return None
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
edges = self._edges_at(event.position().toPoint())
|
||||
if event.button() == Qt.MouseButton.LeftButton and edges:
|
||||
handle = self.windowHandle()
|
||||
if handle is None or not handle.startSystemResize(edges):
|
||||
self._manual_edges = edges
|
||||
self._press_global = event.globalPosition().toPoint()
|
||||
self._press_geom = QRect(self.geometry())
|
||||
return
|
||||
super().mousePressEvent(event)
|
||||
|
||||
def mouseMoveEvent(self, event):
|
||||
if self._manual_edges and self._press_global is not None:
|
||||
delta = event.globalPosition().toPoint() - self._press_global
|
||||
geom = QRect(self._press_geom)
|
||||
if self._manual_edges & Qt.Edge.LeftEdge:
|
||||
geom.setLeft(min(geom.left() + delta.x(), geom.right() - self.minimumWidth()))
|
||||
if self._manual_edges & Qt.Edge.RightEdge:
|
||||
geom.setRight(max(geom.right() + delta.x(), geom.left() + self.minimumWidth()))
|
||||
if self._manual_edges & Qt.Edge.TopEdge:
|
||||
geom.setTop(min(geom.top() + delta.y(), geom.bottom() - self.minimumHeight()))
|
||||
if self._manual_edges & Qt.Edge.BottomEdge:
|
||||
geom.setBottom(max(geom.bottom() + delta.y(), geom.top() + self.minimumHeight()))
|
||||
self.setGeometry(geom)
|
||||
return
|
||||
cursor = self._cursor_for(self._edges_at(event.position().toPoint()))
|
||||
if cursor is None:
|
||||
self.unsetCursor()
|
||||
else:
|
||||
self.setCursor(cursor)
|
||||
super().mouseMoveEvent(event)
|
||||
|
||||
def mouseReleaseEvent(self, event):
|
||||
self._manual_edges = Qt.Edge(0)
|
||||
self._press_global = None
|
||||
self._press_geom = None
|
||||
super().mouseReleaseEvent(event)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# ponytail: smallest check that fails if the edge maths breaks
|
||||
import os
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
from PySide6.QtWidgets import QApplication, QLabel
|
||||
|
||||
app = QApplication([])
|
||||
w = PopoutWindow("t", QLabel("x"))
|
||||
w.resize(400, 300)
|
||||
assert w._edges_at(QPoint(5, 150)) == Qt.Edge.LeftEdge
|
||||
assert w._edges_at(QPoint(398, 298)) == (Qt.Edge.RightEdge | Qt.Edge.BottomEdge)
|
||||
assert w._edges_at(QPoint(200, 150)) == Qt.Edge(0)
|
||||
assert w._cursor_for(Qt.Edge.LeftEdge | Qt.Edge.TopEdge) == Qt.CursorShape.SizeFDiagCursor
|
||||
assert w._cursor_for(Qt.Edge.RightEdge | Qt.Edge.TopEdge) == Qt.CursorShape.SizeBDiagCursor
|
||||
print("gude")
|
||||
Reference in New Issue
Block a user