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:
2026-08-07 17:08:31 +02:00
co-authored by Claude Fable 5
parent a55be79500
commit b65ff4e791
2 changed files with 261 additions and 0 deletions
+33
View File
@@ -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()
+228
View File
@@ -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")