From b65ff4e7916fe6e87c6b1d79549b6f7632522c03 Mon Sep 17 00:00:00 2001 From: Dawn Date: Fri, 7 Aug 2026 17:08:31 +0200 Subject: [PATCH] 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 --- src/aare/gui/panels/log_panel.py | 33 ++++ src/aare/gui/widgets/popout_window.py | 228 ++++++++++++++++++++++++++ 2 files changed, 261 insertions(+) create mode 100644 src/aare/gui/widgets/popout_window.py diff --git a/src/aare/gui/panels/log_panel.py b/src/aare/gui/panels/log_panel.py index cd90338c..686033b2 100644 --- a/src/aare/gui/panels/log_panel.py +++ b/src/aare/gui/panels/log_panel.py @@ -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() diff --git a/src/aare/gui/widgets/popout_window.py b/src/aare/gui/widgets/popout_window.py new file mode 100644 index 00000000..0fcaa9b6 --- /dev/null +++ b/src/aare/gui/widgets/popout_window.py @@ -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")