v0.33.0: title bars drawn by LinTunes, in the window's grey

On Wayland Qt paints the title bar through its adwaita decoration plugin,
which hardcodes pure white in light mode and ignores the palette. Every
window now goes frameless and gets an app-drawn bar in the palette's Window
color, so it follows the Background setting. Buttons follow GNOME's
button-layout; moving, snapping and edge resizing still go through the
compositor. Windows grow by the bar's height so their contents keep the
size they had.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-02 00:55:01 -07:00
co-authored by Claude Opus 5.5
parent c662ec0c2b
commit 4c0aaae463
6 changed files with 663 additions and 7 deletions
+12 -1
View File
@@ -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 `QCursor.pos()` are unreliable during a drag — `gui/drag_ghost.py` paints its
own child-widget overlay instead. `QAudioOutput` must not be constructed before own child-widget overlay instead. `QAudioOutput` must not be constructed before
a `QMainWindow` exists (Qt 6.10 deadlock). Some PyQt signal relays need explicit 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`, - **Music files are only touched deliberately:** tag edits via `tagging.py`,
and — since Round 18 — artist/album_artist/album edits relocate the file and — since Round 18 — artist/album_artist/album edits relocate the file
inside `LibraryManager.organize_root()` (`<music_folder>/Music`) to keep the inside `LibraryManager.organize_root()` (`<music_folder>/Music`) to keep the
+1 -1
View File
@@ -1,3 +1,3 @@
"""LinTunes — iTunes-style music library manager and player for Linux.""" """LinTunes — iTunes-style music library manager and player for Linux."""
__version__ = "0.32.1" __version__ = "0.33.0"
+8 -5
View File
@@ -66,7 +66,9 @@ class ArtWindow(QWidget):
return return
geo = screen.availableGeometry() geo = screen.availableGeometry()
size = fit_size(self._pixmap.size(), geo.size()) 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 # Wayland ignores move() (the compositor places the window); X11
# honors it. # honors it.
self.move(geo.x() + (geo.width() - size.width()) // 2, self.move(geo.x() + (geo.width() - size.width()) // 2,
@@ -74,12 +76,13 @@ class ArtWindow(QWidget):
def paintEvent(self, event): def paintEvent(self, event):
painter = QPainter(self) 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(): if self._pixmap is None or self._pixmap.isNull():
return return
scaled = self._pixmap.scaled( scaled = self._pixmap.scaled(
self.size(), Qt.AspectRatioMode.KeepAspectRatio, area.size(), Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation) Qt.TransformationMode.SmoothTransformation)
x = (self.width() - scaled.width()) // 2 x = area.x() + (area.width() - scaled.width()) // 2
y = (self.height() - scaled.height()) // 2 y = area.y() + (area.height() - scaled.height()) // 2
painter.drawPixmap(x, y, scaled) painter.drawPixmap(x, y, scaled)
+448
View File
@@ -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
+4
View File
@@ -132,6 +132,7 @@ def run_gui(data_dir: Path, files: list[Path],
from lintunes.preferences import Preferences from lintunes.preferences import Preferences
from lintunes.lastfm import LastFm from lintunes.lastfm import LastFm
from lintunes.gui.main_window import MainWindow from lintunes.gui.main_window import MainWindow
from lintunes.gui import titlebar
from lintunes.gui.track_table import ClickToJumpScrollStyle from lintunes.gui.track_table import ClickToJumpScrollStyle
from lintunes.mpris import setup_mpris from lintunes.mpris import setup_mpris
from lintunes.sync_watcher import LibrarySyncWatcher from lintunes.sync_watcher import LibrarySyncWatcher
@@ -152,6 +153,9 @@ def run_gui(data_dir: Path, files: list[Path],
prefs = Preferences(data_dir) prefs = Preferences(data_dir)
theme.apply_theme(app, prefs) 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) manager = LibraryManager(library, data_dir)
if new_library: if new_library:
manager.seed_new_library() manager.seed_new_library()
+190
View File
@@ -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()