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:
@@ -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_folder>/Music`) to keep the
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""LinTunes — iTunes-style music library manager and player for Linux."""
|
||||
|
||||
__version__ = "0.32.1"
|
||||
__version__ = "0.33.0"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user