diff --git a/CLAUDE.md b/CLAUDE.md index 05c56cc..7cf3323 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -604,7 +604,18 @@ persistence) → GUI (Qt widgets that read the manager and connect to its signal `QCursor.pos()` are unreliable during a drag — `gui/drag_ghost.py` paints its own child-widget overlay instead. `QAudioOutput` must not be constructed before a `QMainWindow` exists (Qt 6.10 deadlock). Some PyQt signal relays need explicit - types/lambdas. + types/lambdas. **Title bars are ours** (Round 72): Mutter draws none for + Wayland clients, and Qt's `adwaita` decoration plugin paints them pure white + from hardcoded constants, deaf to the palette. So on Wayland + `gui/titlebar.install` (an app event filter) makes every `Window`/`Dialog` + frameless at `Polish` and adds a `TitleBar` at its first `Show`, in the + palette's `Window` color, room made through the window's top contents + margin and the window grown by that much so its contents keep their size. + Buttons follow GNOME's `button-layout`; move/resize go through + `startSystemMove`/`startSystemResize`. A window with no layout must paint + into `contentsRect()` (`art_window.py`); one that needs the system frame + sets the `titlebar.OPT_OUT` property. The bar must never be an event filter + on its own window — that is called during the window's teardown and aborts. - **Music files are only touched deliberately:** tag edits via `tagging.py`, and — since Round 18 — artist/album_artist/album edits relocate the file inside `LibraryManager.organize_root()` (`/Music`) to keep the diff --git a/lintunes/__init__.py b/lintunes/__init__.py index a8bae8b..fc877be 100644 --- a/lintunes/__init__.py +++ b/lintunes/__init__.py @@ -1,3 +1,3 @@ """LinTunes — iTunes-style music library manager and player for Linux.""" -__version__ = "0.32.1" +__version__ = "0.33.0" diff --git a/lintunes/gui/art_window.py b/lintunes/gui/art_window.py index 831a0a2..baf23b0 100644 --- a/lintunes/gui/art_window.py +++ b/lintunes/gui/art_window.py @@ -66,7 +66,9 @@ class ArtWindow(QWidget): return geo = screen.availableGeometry() size = fit_size(self._pixmap.size(), geo.size()) - self.resize(size) + # The app-drawn title bar (gui/titlebar.py) sits inside the window. + margins = self.contentsMargins() + self.resize(size.width(), size.height() + margins.top()) # Wayland ignores move() (the compositor places the window); X11 # honors it. self.move(geo.x() + (geo.width() - size.width()) // 2, @@ -74,12 +76,13 @@ class ArtWindow(QWidget): def paintEvent(self, event): painter = QPainter(self) - painter.fillRect(self.rect(), Qt.GlobalColor.black) + area = self.contentsRect() + painter.fillRect(area, Qt.GlobalColor.black) if self._pixmap is None or self._pixmap.isNull(): return scaled = self._pixmap.scaled( - self.size(), Qt.AspectRatioMode.KeepAspectRatio, + area.size(), Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation) - x = (self.width() - scaled.width()) // 2 - y = (self.height() - scaled.height()) // 2 + x = area.x() + (area.width() - scaled.width()) // 2 + y = area.y() + (area.height() - scaled.height()) // 2 painter.drawPixmap(x, y, scaled) diff --git a/lintunes/gui/titlebar.py b/lintunes/gui/titlebar.py new file mode 100644 index 0000000..10cb9b5 --- /dev/null +++ b/lintunes/gui/titlebar.py @@ -0,0 +1,448 @@ +"""The title bar LinTunes draws for itself on Wayland (Round 72). + +GNOME's Mutter draws no title bars for Wayland clients, so Qt draws its own +through the ``adwaita`` decoration plugin — and that plugin builds its colors +from hardcoded constants: pure white in light mode, deaf to the palette and to +stylesheets. Against LinTunes' grey window body that white strip read as part +of the content. The only way to give it another color is to not have it: every +top-level window goes frameless and gets a ``TitleBar`` painted in the +palette's ``Window`` color, so it follows the Background slider too. + +``install(app)`` does it for every window at once from an application event +filter, so no dialog has to know: the frameless flag is set at ``Polish`` +(before the native window is mapped), and the bar is added at the first +``Show`` — after the dialog's own sizing has run, so its content area can be +grown by exactly the bar's height and comes out the size its author chose. +Moving, snapping and resizing are still the compositor's, through +``QWindow.startSystemMove`` / ``startSystemResize``. + +A top-level window that must keep the system frame sets the Qt property +``OPT_OUT`` to ``True`` before it is first shown. +""" +import subprocess + +from PyQt6.QtCore import QEvent, QObject, QPoint, QRect, QSize, Qt +from PyQt6.QtGui import QColor, QPainter, QPalette, QPen +from PyQt6.QtWidgets import QAbstractButton, QLayout, QWidget + +OPT_OUT = "lintunes_system_frame" +_DONE = "_lintunes_titlebar" + +RESIZE_MARGIN = 4 # the strip along each edge that resizes +CORNER = 14 # how far along an edge a corner reaches +BUTTON = 24 # the round button's diameter +SPACING = 8 # between buttons, and from the bar's ends +BORDER_ALPHA = 70 # the 1 px outline standing in for the missing shadow + +_BUTTONS = ("close", "minimize", "maximize") +_FALLBACK_LAYOUT = ":minimize,close" +_layout_cache = None + + +def parse_button_layout(text: str) -> tuple[list[str], list[str]]: + """GNOME's ``button-layout`` (``"close,minimize:appmenu"``) as the + buttons left of the title and those right of it. Anything LinTunes has + no button for (``appmenu``, ``icon``, ``spacer``) is dropped.""" + left, _, right = text.strip().strip("'\"").partition(":") + + def names(side): + return [n for n in (p.strip() for p in side.split(",")) + if n in _BUTTONS] + return names(left), names(right) + + +def button_layout() -> tuple[list[str], list[str]]: + """The desktop's button layout, read once; GNOME's default without one.""" + global _layout_cache + if _layout_cache is None: + try: + text = subprocess.run( + ["gsettings", "get", "org.gnome.desktop.wm.preferences", + "button-layout"], + capture_output=True, text=True, timeout=2).stdout + except (OSError, subprocess.SubprocessError): + text = "" + left, right = parse_button_layout(text) + if "close" not in left + right: + left, right = parse_button_layout(_FALLBACK_LAYOUT) + _layout_cache = (left, right) + return _layout_cache + + +def edges_at(pos: QPoint, size: QSize, margin: int = RESIZE_MARGIN, + corner: int = CORNER) -> Qt.Edge: + """Which window edges a point (window coordinates) would resize. Within + ``corner`` of a corner along an edge, that corner's other edge joins in, + so a corner is easy to catch without making the edges thick.""" + x, y, w, h = pos.x(), pos.y(), size.width(), size.height() + near_left, near_right = x < margin, x >= w - margin + near_top, near_bottom = y < margin, y >= h - margin + if not (near_left or near_right or near_top or near_bottom): + return Qt.Edge(0) + edges = Qt.Edge(0) + if near_left or ((near_top or near_bottom) and x < corner): + edges |= Qt.Edge.LeftEdge + if near_right or ((near_top or near_bottom) and x >= w - corner): + edges |= Qt.Edge.RightEdge + if near_top or ((near_left or near_right) and y < corner): + edges |= Qt.Edge.TopEdge + if near_bottom or ((near_left or near_right) and y >= h - corner): + edges |= Qt.Edge.BottomEdge + return edges + + +def cursor_for(edges: Qt.Edge) -> Qt.CursorShape: + left, right = Qt.Edge.LeftEdge in edges, Qt.Edge.RightEdge in edges + top, bottom = Qt.Edge.TopEdge in edges, Qt.Edge.BottomEdge in edges + if (top and left) or (bottom and right): + return Qt.CursorShape.SizeFDiagCursor + if (top and right) or (bottom and left): + return Qt.CursorShape.SizeBDiagCursor + if left or right: + return Qt.CursorShape.SizeHorCursor + if top or bottom: + return Qt.CursorShape.SizeVerCursor + return Qt.CursorShape.ArrowCursor + + +def is_resizable(window: QWidget) -> bool: + if window.minimumSize() == window.maximumSize(): + return False + layout = window.layout() + return not (layout is not None and layout.sizeConstraint() + == QLayout.SizeConstraint.SetFixedSize) + + +def _framed(window: QWidget) -> bool: + """Whether the window shows edges at all (not maximized/fullscreen).""" + return not (window.windowState() & (Qt.WindowState.WindowMaximized + | Qt.WindowState.WindowFullScreen)) + + +def _border_color(window: QWidget) -> QColor: + return QColor(0, 0, 0, BORDER_ALPHA) + + +class _Button(QAbstractButton): + """A GNOME-style round title-bar button: a soft disc with a glyph.""" + + def __init__(self, kind: str, parent): + super().__init__(parent) + self.kind = kind + self.setFixedSize(BUTTON, BUTTON) + self.setFocusPolicy(Qt.FocusPolicy.NoFocus) + self.setCursor(Qt.CursorShape.ArrowCursor) + self.setToolTip({"close": "Close", "minimize": "Minimize", + "maximize": "Maximize"}[kind]) + + def paintEvent(self, _event): + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + base = self.palette().color(QPalette.ColorRole.Window) + if self.isDown(): + disc = base.darker(135) + elif self.underMouse(): + disc = base.darker(122) + else: + disc = base.darker(110) + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(disc) + painter.drawEllipse(self.rect().adjusted(1, 1, -1, -1)) + + active = self.window().isActiveWindow() + ink = self.palette().color(QPalette.ColorRole.WindowText) + if not active: + ink.setAlpha(140) + pen = QPen(ink, 1.6) + pen.setCapStyle(Qt.PenCapStyle.RoundCap) + painter.setPen(pen) + painter.setBrush(Qt.BrushStyle.NoBrush) + c = self.width() / 2 + r = 4.0 + if self.kind == "close": + painter.drawLine(int(c - r), int(c - r), int(c + r), int(c + r)) + painter.drawLine(int(c - r), int(c + r), int(c + r), int(c - r)) + elif self.kind == "minimize": + painter.drawLine(int(c - r), int(c + r), int(c + r), int(c + r)) + elif _framed(self.window()): + painter.drawRect(int(c - r), int(c - r), int(2 * r), int(2 * r)) + else: # restore: two overlapping squares + painter.drawRect(int(c - r), int(c - r + 2), int(2 * r - 2), + int(2 * r - 2)) + painter.drawLine(int(c - r + 2), int(c - r), int(c + r), + int(c - r)) + painter.drawLine(int(c + r), int(c - r), int(c + r), + int(c + r - 2)) + + +class _Grip(QWidget): + """A thin resize strip along the left, right or bottom edge. It also + paints that edge's outline, since a frameless window has no shadow.""" + + def __init__(self, side: str, window: QWidget): + super().__init__(window) + self.side = side + self.setMouseTracking(True) + + def _edges(self, pos: QPoint) -> Qt.Edge: + window = self.window() + if not is_resizable(window): + return Qt.Edge(0) + return edges_at(self.mapTo(window, pos), window.size()) + + def mouseMoveEvent(self, event): + self.setCursor(cursor_for(self._edges(event.position().toPoint()))) + + def mousePressEvent(self, event): + edges = self._edges(event.position().toPoint()) + handle = self.window().windowHandle() + if (event.button() == Qt.MouseButton.LeftButton and edges + and handle is not None): + handle.startSystemResize(edges) + event.accept() + return + event.ignore() + + def paintEvent(self, _event): + painter = QPainter(self) + painter.setPen(_border_color(self.window())) + if self.side == "left": + painter.drawLine(0, 0, 0, self.height()) + elif self.side == "right": + painter.drawLine(self.width() - 1, 0, self.width() - 1, + self.height()) + else: + painter.drawLine(0, self.height() - 1, self.width(), + self.height() - 1) + + +class TitleBar(QWidget): + """The bar itself: a child of the window, placed by hand above the + window's contents (which make room through the top contents margin).""" + + def __init__(self, window: QWidget): + super().__init__(window) + self.setObjectName("lintunesTitleBar") + self.setMouseTracking(True) + self._window = window + self._added = 0 # how much of the window's top margin is ours + left, right = button_layout() + self._left = [_Button(k, self) for k in left] + self._right = [_Button(k, self) for k in right] + for button in self._left + self._right: + button.clicked.connect( + lambda _=False, k=button.kind: self._on_button(k)) + self._grips = [_Grip(side, window) + for side in ("left", "right", "bottom")] + window.windowTitleChanged.connect(lambda _t: self.update()) + self._apply_height() + self._place() + + # -- geometry ------------------------------------------------------ + + def bar_height(self) -> int: + return max(BUTTON + 2 * 5, self.fontMetrics().height() + 18) + + def _apply_height(self): + """Make the window's top margin hold exactly this bar.""" + height = self.bar_height() + if height == self._added: + return + m = self._window.contentsMargins() + self._window.setContentsMargins( + m.left(), m.top() - self._added + height, m.right(), m.bottom()) + self._added = height + self._place() + + def _place(self): + w, h = self._window.width(), self._window.height() + bar = self._added + self.setGeometry(0, 0, w, bar) + y = (bar - BUTTON) // 2 + x = SPACING + for button in self._left: + button.move(x, y) + x += BUTTON + SPACING + x = w - SPACING - BUTTON + for button in reversed(self._right): + button.move(x, y) + x -= BUTTON + SPACING + resizable = is_resizable(self._window) + for button in self._left + self._right: + if button.kind == "maximize": + button.setVisible(resizable) + framed = _framed(self._window) + m = RESIZE_MARGIN + left, right, bottom = self._grips + left.setGeometry(0, bar, m, max(h - bar - m, 0)) + right.setGeometry(w - m, bar, m, max(h - bar - m, 0)) + bottom.setGeometry(0, h - m, w, m) + for grip in self._grips: + grip.setVisible(framed) + grip.raise_() + self.raise_() + + # -- events -------------------------------------------------------- + + def window_event(self, kind): + """The window's own events, relayed by ``TitleBarInstaller`` — not + an event filter on the window, which would still be called while the + window tears its children (this bar among them) down.""" + if kind in (QEvent.Type.Resize, QEvent.Type.WindowStateChange): + self._place() + else: + self.update() + for button in self._left + self._right: + button.update() + + def changeEvent(self, event): + if event.type() == QEvent.Type.FontChange: + self._apply_height() + super().changeEvent(event) + + def _on_button(self, kind: str): + if kind == "close": + self._window.close() + elif kind == "minimize": + self._window.showMinimized() + else: + self.toggle_maximized() + + def toggle_maximized(self): + if not is_resizable(self._window): + return + if self._window.isMaximized(): + self._window.showNormal() + else: + self._window.showMaximized() + + def _top_edges(self, pos: QPoint) -> Qt.Edge: + if not (_framed(self._window) and is_resizable(self._window)): + return Qt.Edge(0) + return edges_at(pos, self._window.size()) + + def mouseMoveEvent(self, event): + self.setCursor(cursor_for(self._top_edges(event.position().toPoint()))) + + def mousePressEvent(self, event): + if event.button() != Qt.MouseButton.LeftButton: + event.ignore() + return + handle = self._window.windowHandle() + if handle is not None: + edges = self._top_edges(event.position().toPoint()) + if edges: + handle.startSystemResize(edges) + else: + handle.startSystemMove() + event.accept() + + def mouseDoubleClickEvent(self, event): + if event.button() == Qt.MouseButton.LeftButton: + self.toggle_maximized() + event.accept() + + def paintEvent(self, _event): + painter = QPainter(self) + palette = self.palette() + painter.fillRect(self.rect(), palette.color(QPalette.ColorRole.Window)) + painter.setPen(palette.color(QPalette.ColorRole.Window).darker(118)) + painter.drawLine(0, self.height() - 1, self.width(), + self.height() - 1) + if _framed(self._window): + painter.setPen(_border_color(self._window)) + painter.drawLine(0, 0, self.width(), 0) + painter.drawLine(0, 0, 0, self.height()) + painter.drawLine(self.width() - 1, 0, self.width() - 1, + self.height()) + + # The title is centered on the whole bar, kept clear of both button + # groups by the wider of the two. + reach = max(len(self._left), len(self._right)) * (BUTTON + SPACING) + room = QRect(reach + SPACING, 0, + max(self.width() - 2 * (reach + SPACING), 0), + self.height()) + font = painter.font() + font.setBold(True) + painter.setFont(font) + ink = palette.color(QPalette.ColorRole.WindowText) + if not self._window.isActiveWindow(): + ink.setAlpha(150) + painter.setPen(ink) + title = painter.fontMetrics().elidedText( + self.title(), Qt.TextElideMode.ElideRight, room.width()) + painter.drawText(room, Qt.AlignmentFlag.AlignCenter, title) + + def title(self) -> str: + # Qt's "[*]" modified placeholder, as the system bar would show it. + title = self._window.windowTitle() + return title.replace("[*]", "*" if self._window.isWindowModified() + else "") + + +def title_bar(window: QWidget) -> TitleBar | None: + return window.findChild(TitleBar, "lintunesTitleBar", + Qt.FindChildOption.FindDirectChildrenOnly) + + +def wants_bar(widget) -> bool: + if not isinstance(widget, QWidget) or not widget.isWindow(): + return False + if widget.windowType() not in (Qt.WindowType.Window, + Qt.WindowType.Dialog): + return False + if widget.property(OPT_OUT): + return False + return True + + +_RELAYED = (QEvent.Type.Resize, QEvent.Type.WindowStateChange, + QEvent.Type.WindowActivate, QEvent.Type.WindowDeactivate) + + +class TitleBarInstaller(QObject): + """The application event filter that gives each window its bar.""" + + def eventFilter(self, obj, event): + kind = event.type() + if kind in _RELAYED: + if (isinstance(obj, QWidget) and obj.isWindow() + and obj.property(_DONE) == "bar"): + bar = title_bar(obj) + if bar is not None: + bar.window_event(kind) + elif kind == QEvent.Type.Polish: + if wants_bar(obj) and not obj.property(_DONE) and not ( + obj.windowFlags() & Qt.WindowType.FramelessWindowHint): + self.make_frameless(obj) + elif kind == QEvent.Type.Show: + if obj.property(_DONE) == "frameless" and wants_bar(obj): + self.add_bar(obj) + return False + + @staticmethod + def make_frameless(window: QWidget): + window.setProperty(_DONE, "frameless") + window.setWindowFlag(Qt.WindowType.FramelessWindowHint, True) + + @staticmethod + def add_bar(window: QWidget) -> TitleBar: + """Add the bar, growing the window so its contents keep the size + they had — the bar used to sit outside them.""" + window.setProperty(_DONE, "bar") + bar = TitleBar(window) + grow = bar.bar_height() + if window.minimumSize() == window.maximumSize(): + size = window.size() + window.setFixedSize(size.width(), size.height() + grow) + else: + window.resize(window.width(), window.height() + grow) + bar.show() + return bar + + +def install(app) -> TitleBarInstaller: + """Give every LinTunes window an app-drawn title bar from now on.""" + installer = TitleBarInstaller(app) + app.installEventFilter(installer) + return installer diff --git a/lintunes/main.py b/lintunes/main.py index 6a71ab2..44fff22 100644 --- a/lintunes/main.py +++ b/lintunes/main.py @@ -132,6 +132,7 @@ def run_gui(data_dir: Path, files: list[Path], from lintunes.preferences import Preferences from lintunes.lastfm import LastFm from lintunes.gui.main_window import MainWindow + from lintunes.gui import titlebar from lintunes.gui.track_table import ClickToJumpScrollStyle from lintunes.mpris import setup_mpris from lintunes.sync_watcher import LibrarySyncWatcher @@ -152,6 +153,9 @@ def run_gui(data_dir: Path, files: list[Path], prefs = Preferences(data_dir) theme.apply_theme(app, prefs) + # Qt's own Wayland title bar is hardcoded white; draw ours instead. + if app.platformName() == "wayland": + titlebar.install(app) manager = LibraryManager(library, data_dir) if new_library: manager.seed_new_library() diff --git a/tests/test_round72.py b/tests/test_round72.py new file mode 100644 index 0000000..6bc3aab --- /dev/null +++ b/tests/test_round72.py @@ -0,0 +1,190 @@ +"""Round 72: LinTunes draws its own title bars. + +On Wayland Qt's ``adwaita`` decoration plugin paints the title bar pure +white from hardcoded constants, so it can't follow the palette. Every window +goes frameless and gets ``gui/titlebar.TitleBar`` instead, in the palette's +Window color. +""" +import pytest +from PyQt6.QtCore import QPoint, QSize, Qt +from PyQt6.QtGui import QPalette +from PyQt6.QtWidgets import ( + QApplication, QDialog, QLabel, QMainWindow, QMenu, QMessageBox, + QVBoxLayout, QWidget, +) + +from lintunes.gui import titlebar +from lintunes.gui.art_window import ArtWindow +from lintunes.gui.titlebar import ( + OPT_OUT, TitleBar, cursor_for, edges_at, parse_button_layout, title_bar, +) + + +@pytest.fixture +def installed(qapp, monkeypatch): + monkeypatch.setattr(titlebar, "_layout_cache", + parse_button_layout("close,minimize:appmenu")) + installer = titlebar.install(qapp) + yield installer + qapp.removeEventFilter(installer) + + +def _dialog(title="Preferences"): + dialog = QDialog() + dialog.setWindowTitle(title) + layout = QVBoxLayout(dialog) + layout.addWidget(QLabel("A label in a dialog")) + return dialog + + +def test_dialog_gets_frameless_bar_and_keeps_its_content_size(installed): + dialog = _dialog() + dialog.resize(400, 200) + dialog.show() + bar = title_bar(dialog) + assert isinstance(bar, TitleBar) + assert dialog.windowFlags() & Qt.WindowType.FramelessWindowHint + height = bar.bar_height() + assert dialog.contentsMargins().top() == height + # The bar used to sit outside the window; the contents keep their room. + assert dialog.size() == QSize(400, 200 + height) + assert bar.geometry().topLeft() == QPoint(0, 0) + assert bar.width() == dialog.width() + dialog.close() + + +def test_title_follows_window_title(installed): + dialog = _dialog("Get Info") + dialog.show() + bar = title_bar(dialog) + assert bar.title() == "Get Info" + dialog.setWindowTitle("Get Info — Reel One[*]") + dialog.setWindowModified(True) + assert bar.title() == "Get Info — Reel One*" + dialog.close() + + +def test_buttons_follow_gnome_layout(installed): + dialog = _dialog() + dialog.resize(400, 200) + dialog.show() + bar = title_bar(dialog) + kinds = [b.kind for b in bar._left] + assert kinds == ["close", "minimize"] + assert bar._right == [] + assert bar._left[0].x() < bar._left[1].x() < bar.width() // 2 + dialog.close() + + +def test_main_window_message_box_and_art_window_get_one(installed): + window = QMainWindow() + window.menuBar().addMenu("File") + window.setCentralWidget(QLabel("hello")) + window.show() + assert title_bar(window) is not None + # The menu bar sits below the bar, not under it. + assert window.menuBar().y() >= title_bar(window).height() + + box = QMessageBox(QMessageBox.Icon.Question, "Delete?", "Really?", + QMessageBox.StandardButton.Yes, window) + box.show() + assert title_bar(box) is not None + box.close() + + art = ArtWindow() + art.show() + assert title_bar(art) is not None + assert art.contentsRect().top() == title_bar(art).height() + art.close() + window.close() + + +def test_popups_and_opted_out_windows_are_left_alone(installed): + menu = QMenu() + menu.addAction("x") + menu.popup(QPoint(0, 0)) + assert title_bar(menu) is None + menu.close() + + plain = QWidget() + plain.setProperty(OPT_OUT, True) + plain.show() + assert title_bar(plain) is None + assert not plain.windowFlags() & Qt.WindowType.FramelessWindowHint + plain.close() + + +def test_fixed_size_dialog_has_no_maximize_and_stays_fixed(installed, + monkeypatch): + monkeypatch.setattr(titlebar, "_layout_cache", + parse_button_layout(":minimize,maximize,close")) + dialog = _dialog() + dialog.setFixedSize(300, 120) + dialog.show() + bar = title_bar(dialog) + assert [b.kind for b in bar._right] == ["minimize", "maximize", "close"] + maximize = bar._right[1] + assert not maximize.isVisibleTo(dialog) + assert dialog.minimumSize() == dialog.maximumSize() \ + == QSize(300, 120 + bar.bar_height()) + assert not titlebar.is_resizable(dialog) + # No resize from the top strip either. + assert not bar._top_edges(QPoint(1, 1)) + dialog.close() + + +def test_parse_button_layout(): + assert parse_button_layout("'close,minimize:appmenu'") == ( + ["close", "minimize"], []) + assert parse_button_layout(":minimize,maximize,close") == ( + [], ["minimize", "maximize", "close"]) + assert parse_button_layout("appmenu:close") == ([], ["close"]) + + +def test_edges_at_sides_and_corners(): + size = QSize(400, 300) + E = Qt.Edge + assert not edges_at(QPoint(200, 150), size) + assert edges_at(QPoint(0, 150), size) == E.LeftEdge + assert edges_at(QPoint(399, 150), size) == E.RightEdge + assert edges_at(QPoint(200, 299), size) == E.BottomEdge + assert edges_at(QPoint(200, 0), size) == E.TopEdge + assert edges_at(QPoint(0, 0), size) == E.LeftEdge | E.TopEdge + # Along an edge, near a corner, the corner is caught. + assert edges_at(QPoint(5, 299), size) == E.LeftEdge | E.BottomEdge + assert edges_at(QPoint(399, 290), size) == E.RightEdge | E.BottomEdge + assert cursor_for(E.LeftEdge | E.TopEdge) == Qt.CursorShape.SizeFDiagCursor + assert cursor_for(E.RightEdge | E.TopEdge) == Qt.CursorShape.SizeBDiagCursor + assert cursor_for(E.BottomEdge) == Qt.CursorShape.SizeVerCursor + + +def test_bar_paints_in_the_background_preference(installed, qapp, tmp_path): + from lintunes import theme + from lintunes.preferences import Preferences + saved = (QPalette(qapp.palette()), qapp.font(), qapp.styleSheet()) + prefs = Preferences(tmp_path) + prefs.set("color_background", 224) + try: + theme.apply_theme(qapp, prefs) + dialog = _dialog() + dialog.resize(300, 150) + dialog.show() + image = title_bar(dialog).grab().toImage() + assert image.pixelColor(image.width() // 2, 3).name() == "#e0e0e0" + dialog.close() + finally: + qapp.setPalette(saved[0]) + qapp.setFont(saved[1]) + qapp.setStyleSheet(saved[2]) + + +def test_maximized_window_hides_its_edges(installed): + dialog = _dialog() + dialog.resize(300, 150) + dialog.show() + bar = title_bar(dialog) + assert all(g.isVisibleTo(dialog) for g in bar._grips) + dialog.setWindowState(Qt.WindowState.WindowMaximized) + QApplication.processEvents() + assert not any(g.isVisibleTo(dialog) for g in bar._grips) + dialog.close()