fix(qbz-qt): phase 12 — card rounding, menu anchoring, opaque window, titlebar toggle

Rounding: QML clip is rectangular (radius+clip does NOT round-clip) —
new shared RoundedImage (software Canvas raster clip, GL-independent)
applied at 16 artwork sites incl. true circular artist crops. Menus:
new QbzContextMenu (cursor-anchored / below-trigger, window-clamped)
converted at 7 sites, replicating TrackRow.slint positioning. Window:
main window is OPAQUE in Slint (transparency is miniplayer-only;
rounded corners are KWin's) — dropped the translucent frameless
misread, squared app corners, opaque root. Titlebar: wired the EXISTING
shared ui_prefs use_system_title_bar key (app-menu checkable item,
restart semantics like Slint).

km
This commit is contained in:
vicrodh committed 2026-07-27 21:43:53 -06:00
1 parent da36d86ccc
commit 71d41b927c
24 files changed
+403 -187

No files matched your search

+2
View File
@@ -56,6 +56,8 @@ fn main() {
"qml/QbzIcon.qml",
"qml/QbzScrollBar.qml",
"qml/QbzSpinner.qml",
"qml/RoundedImage.qml",
"qml/QbzContextMenu.qml",
],
qrc_files: &qrc_refs,
..Default::default()
+6 -18
View File
@@ -65,16 +65,16 @@ Rectangle {
color: theme.surfaceElevated
clip: true
Image {
RoundedImage {
anchors.fill: parent
source: root.artSource
fillMode: Image.PreserveAspectCrop
asynchronous: true
radius: theme.radiusSm
}
// Hover scrim.
Rectangle {
anchors.fill: parent
radius: theme.radiusSm
color: "#000000"
opacity: root.overlayOn ? 0.6 : 0.0
Behavior on opacity { NumberAnimation { duration: 150 } }
@@ -223,7 +223,7 @@ Rectangle {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: albumMenu.open()
onClicked: function (mouse) { albumMenu.openAtCursor(moreArea, mouse.x, mouse.y) }
}
}
Item { width: (200 - 44 - 2 * 36 - 2 * 12) / 2; height: 1 }
@@ -231,20 +231,9 @@ Rectangle {
// Context menu (AlbumCard.slint's album-menu): 196px, items
// 33px, icon 15 + label 13.
Popup {
QbzContextMenu {
id: albumMenu
x: 150
y: 140
width: 196
padding: 5
closePolicy: Popup.CloseOnPressOutside
background: Rectangle {
color: theme.surfaceMain
radius: theme.radiusSm
border.width: 1
border.color: theme.borderMuted
}
contentItem: Column {
menuWidth: 196
Repeater {
model: [
{ "label": QbzBridge.tr("Open album"), "icon": "library-big", "action": "open" },
@@ -301,7 +290,6 @@ Rectangle {
}
}
}
}
// Award ribbon — content-width, capped at the card width.
Rectangle {
+18 -37
View File
@@ -19,6 +19,10 @@ import com.blitzfc.qbz
Rectangle {
id: root
color: theme.surfaceMain
// Round to the AppShell content-frame bezel (Radius.md): QML clips
// are rectangular, so the frame's own rounding never reaches the
// view — the view's own fill must round instead.
radius: 12
QbzTheme { id: theme }
@@ -71,7 +75,7 @@ Rectangle {
onHeaderChanged: dispatchCovers()
function dispatchCovers() {
var urls = []
if (header.artUrl) urls.push(header.artUrl)
if (header && header.artUrl) urls.push(header.artUrl)
var more = album.moreFromArtist || []
for (var i = 0; i < more.length; i++) if (more[i].artUrl) urls.push(more[i].artUrl)
var sug = album.suggestions || []
@@ -84,7 +88,7 @@ Rectangle {
component CircleBtn: Rectangle {
property string name: ""
property bool active: false
signal clicked()
signal clicked(var mouse)
width: 32
height: 32
radius: 16
@@ -103,7 +107,7 @@ Rectangle {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: parent.clicked()
onClicked: function (mouse) { parent.clicked(mouse) }
}
}
@@ -292,22 +296,11 @@ Rectangle {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: rowMenu.open()
onClicked: function (mouse) { rowMenu.openAtCursor(moreArea, mouse.x, mouse.y) }
}
Popup {
QbzContextMenu {
id: rowMenu
x: 32 - 196
y: 38
width: 196
padding: 5
closePolicy: Popup.CloseOnPressOutside
background: Rectangle {
color: theme.surfaceMain
radius: theme.radiusSm
border.width: 1
border.color: theme.borderMuted
}
contentItem: Column {
menuWidth: 196
Repeater {
model: [
{ "label": QbzBridge.tr("Play"), "icon": "play-fill", "action": "play" },
@@ -356,7 +349,6 @@ Rectangle {
}
}
}
}
}
}
@@ -430,6 +422,7 @@ Rectangle {
// ============================ the page ================================
Flickable {
id: pageFlick
anchors.fill: parent
clip: true
contentWidth: width
@@ -459,11 +452,10 @@ Rectangle {
radius: 12
color: theme.surfaceElevated
clip: true
Image {
RoundedImage {
anchors.fill: parent
source: root.coverMap[header.artUrl] || ""
fillMode: Image.PreserveAspectCrop
asynchronous: true
radius: 12
}
}
@@ -631,9 +623,10 @@ Rectangle {
CircleBtn { name: "cassette-tape"; anchors.verticalCenter: parent.verticalCenter }
CircleBtn { name: "info"; anchors.verticalCenter: parent.verticalCenter }
CircleBtn {
id: albumMenuBtn
name: "ellipsis"
anchors.verticalCenter: parent.verticalCenter
onClicked: albumMenu.open()
onClicked: function (mouse) { albumMenu.openAtCursor(albumMenuBtn, mouse.x, mouse.y) }
}
}
}
@@ -917,7 +910,7 @@ Rectangle {
anchors.rightMargin: 4
anchors.top: parent.top
anchors.bottom: parent.bottom
target: page.parent
target: pageFlick
}
// --- Full-description modal ------------------------------------------
@@ -993,20 +986,9 @@ Rectangle {
}
// Album ⋯ menu (AlbumContextMenu subset — card menu + pin).
Popup {
QbzContextMenu {
id: albumMenu
x: 260
y: 260
width: 196
padding: 5
closePolicy: Popup.CloseOnPressOutside
background: Rectangle {
color: theme.surfaceMain
radius: theme.radiusSm
border.width: 1
border.color: theme.borderMuted
}
contentItem: Column {
menuWidth: 196
Repeater {
model: [
{ "label": QbzBridge.tr("Play"), "icon": "play-fill", "action": "play" },
@@ -1059,5 +1041,4 @@ Rectangle {
}
}
}
}
}
+7 -11
View File
@@ -17,11 +17,10 @@ import com.blitzfc.qbz
Rectangle {
id: root
// TRANSPARENT — the chrome (header / sidebar / queue column / NPB)
// paints every pixel between the header's rounded top corners and the
// NPB's rounded bottom corners, so the window's rounded corners show
// through (frameless translucent window).
color: "transparent"
// OPAQUE surface-card chrome (phase 12: the translucent-window read
// was wrong — the Slint main window is opaque with square corners;
// the compositor owns any rounding).
color: theme.surfaceCard
// The host ApplicationWindow (custom chrome: drag / maximize / resize).
property var hostWindow: null
@@ -35,9 +34,8 @@ Rectangle {
anchors.top: parent.top
height: theme.headerHeight
hostWindow: root.hostWindow
// Rounded top window corners (custom chrome); the bottom corners
// are covered by the same-color surfaces below.
radius: 12
// Square corners (phase 12: the window is opaque; any rounding is
// the compositor's business).
}
NowPlayingBarSmall {
@@ -46,9 +44,7 @@ Rectangle {
anchors.right: parent.right
anchors.bottom: parent.bottom
height: theme.npbSmallHeight
// Rounded bottom window corners (custom chrome); the top corners
// are covered by the same-color surfaces above.
radius: 12
// Square corners (see the header note above).
}
Sidebar {
+25 -56
View File
@@ -21,6 +21,10 @@ import com.blitzfc.qbz
Rectangle {
id: root
color: theme.surfaceMain
// Round to the AppShell content-frame bezel (Radius.md): QML clips
// are rectangular, so the frame's own rounding never reaches the
// view — the view's own fill must round instead.
radius: 12
QbzTheme { id: theme }
@@ -118,7 +122,7 @@ Rectangle {
component CircleBtn: Rectangle {
property string name: ""
property bool active: false
signal clicked()
signal clicked(var mouse)
width: 32
height: 32
radius: 16
@@ -137,7 +141,7 @@ Rectangle {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: parent.clicked()
onClicked: function (mouse) { parent.clicked(mouse) }
}
}
@@ -197,14 +201,14 @@ Rectangle {
height: showAlbum ? 16 : 14
tintName: "muted"
}
Image {
RoundedImage {
anchors.fill: parent
source: root.coverMap[row.artUrl] || ""
fillMode: Image.PreserveAspectCrop
asynchronous: true
radius: theme.radiusSm
}
Rectangle {
anchors.fill: parent
radius: theme.radiusSm
color: "#000000"
opacity: trArea.containsMouse || isActive ? 0.6 : 0.0
Behavior on opacity { NumberAnimation { duration: 150 } }
@@ -549,11 +553,10 @@ Rectangle {
radius: 100
color: theme.surfaceElevated
clip: true
Image {
RoundedImage {
anchors.fill: parent
source: root.coverMap[artist.artUrl] || ""
fillMode: Image.PreserveAspectCrop
asynchronous: true
radius: 100
}
}
@@ -611,9 +614,10 @@ Rectangle {
}
}
CircleBtn {
id: radioBtn
name: "radio"
anchors.verticalCenter: parent.verticalCenter
onClicked: radioPopup.open()
onClicked: radioPopup.openBelowRight(radioBtn)
}
CircleBtn {
name: "element-connect"
@@ -622,9 +626,10 @@ Rectangle {
onClicked: root.networkOpen = !root.networkOpen
}
CircleBtn {
id: overflowBtn
name: "ellipsis"
anchors.verticalCenter: parent.verticalCenter
onClicked: overflowMenu.open()
onClicked: function (mouse) { overflowMenu.openAtCursor(overflowBtn, mouse.x, mouse.y) }
}
Item { width: parent.width - 4 * 32 - 4 * 12 - segTabs.width; height: 1 }
// From catalog / In library (webplayer parity).
@@ -799,9 +804,10 @@ Rectangle {
// POC-NOTE: multi-select out of scope.
}
CircleBtn {
id: topMenuBtn
name: "ellipsis"
anchors.verticalCenter: parent.verticalCenter
onClicked: topMenu.open()
onClicked: function (mouse) { topMenu.openAtCursor(topMenuBtn, mouse.x, mouse.y) }
}
}
Item { visible: topTracks.length > 0; width: 1; height: 10 }
@@ -954,11 +960,10 @@ Rectangle {
radius: theme.radiusSm
color: theme.surfaceElevated
clip: true
Image {
RoundedImage {
anchors.fill: parent
source: root.coverMap[modelData.artUrl] || ""
fillMode: Image.PreserveAspectCrop
asynchronous: true
radius: theme.radiusSm
}
MouseArea {
anchors.fill: parent
@@ -1318,20 +1323,9 @@ Rectangle {
}
// --- Radio dropdown (INERT items — POC-NOTE: radio engines) ----------
Popup {
QbzContextMenu {
id: radioPopup
x: 260
y: 300
width: 180
padding: 5
closePolicy: Popup.CloseOnPressOutside
background: Rectangle {
color: theme.surfaceMain
radius: theme.radiusSm
border.width: 1
border.color: theme.borderMuted
}
contentItem: Column {
menuWidth: 180
Repeater {
model: [QbzBridge.tr("QBZ Radio"), QbzBridge.tr("Qobuz Radio")]
delegate: Rectangle {
@@ -1363,23 +1357,11 @@ Rectangle {
}
}
}
}
// --- ⋯ overflow menu ---------------------------------------------------
Popup {
QbzContextMenu {
id: overflowMenu
x: 260
y: 300
width: 224
padding: 5
closePolicy: Popup.CloseOnPressOutside
background: Rectangle {
color: theme.surfaceMain
radius: theme.radiusSm
border.width: 1
border.color: theme.borderMuted
}
contentItem: Column {
menuWidth: 224
Repeater {
model: [
{ "label": QbzBridge.tr("Create Artist Collection"), "icon": "library-big", "action": "stub" },
@@ -1451,23 +1433,11 @@ Rectangle {
}
}
}
}
// --- Popular Tracks ⋯ menu ---------------------------------------------
Popup {
QbzContextMenu {
id: topMenu
x: 260
y: 340
width: 224
padding: 5
closePolicy: Popup.CloseOnPressOutside
background: Rectangle {
color: theme.surfaceMain
radius: theme.radiusSm
border.width: 1
border.color: theme.borderMuted
}
contentItem: Column {
menuWidth: 224
Repeater {
model: [
{ "label": QbzBridge.tr("Play all next"), "icon": "list-start", "action": "next-all" },
@@ -1513,7 +1483,6 @@ Rectangle {
}
}
}
}
// --- Full-bio modal ----------------------------------------------------
Rectangle {
+37 -1
View File
@@ -32,6 +32,9 @@ Rectangle {
// after a real movement so plain clicks/double-clicks still work.
MouseArea {
anchors.fill: parent
// Inert under the system titlebar (the native chrome owns
// drag/double-click) — phase 12 titlebar toggle.
enabled: !QbzBridge.systemTitleBar
property bool dragStarted: false
onPressed: dragStarted = false
onPositionChanged: {
@@ -221,7 +224,7 @@ Rectangle {
Row {
id: rightControls
// Shifts left of the drawn window controls (3x34 + 2x2 = 106px).
x: root.width - width - theme.spacingMd + 2 - 110
x: root.width - width - theme.spacingMd + 2 - (QbzBridge.systemTitleBar ? 0 : 110)
y: (root.height - height) / 2
height: 36
spacing: 4
@@ -364,6 +367,7 @@ Rectangle {
// --- Drawn window controls (WindowControls.slint, right placement:
// minimize · maximize · close; close gets the danger-red hover) ------
Row {
visible: !QbzBridge.systemTitleBar
x: root.width - width - 8
y: (root.height - height) / 2
height: 26
@@ -487,6 +491,7 @@ Rectangle {
component AppMenuItem: Rectangle {
property string name: ""
property string label: ""
property bool checkedItem: false
signal clicked()
width: parent ? parent.width : 0
@@ -505,12 +510,26 @@ Rectangle {
tintName: "secondary"
}
Text {
id: miLabel
height: parent.height
text: parent.parent.label
color: theme.textSecondary
font.pixelSize: 13
verticalAlignment: Text.AlignVCenter
}
Item {
visible: parent.parent.checkedItem
width: visible ? parent.width - 15 - miLabel.implicitWidth - 14 - 2 * parent.spacing : 0
height: 1
}
QbzIcon {
visible: parent.parent.checkedItem
name: "check"
width: 14
height: 14
anchors.verticalCenter: parent.verticalCenter
tintName: "accent"
}
}
MouseArea {
id: miArea
@@ -521,6 +540,23 @@ Rectangle {
}
}
AppMenuItem {
name: "layout-grid"
label: QbzBridge.tr("Use system title bar")
checkedItem: QbzBridge.systemTitleBarPref
onClicked: {
QbzBridge.toggleSystemTitleBar()
// Keep the menu open so the check state reads; the
// applied mode changes on the next launch (1:1 Slint).
}
}
Text {
width: parent.width
leftPadding: 37
text: QbzBridge.tr("Takes effect after restarting QBZ.")
color: theme.textMuted
font.pixelSize: 11
}
AppMenuItem {
name: "settings-2"
label: QbzBridge.tr("Settings")
+14 -15
View File
@@ -26,6 +26,10 @@ import com.blitzfc.qbz
Rectangle {
id: root
color: theme.surfaceMain
// Round to the AppShell content-frame bezel (Radius.md): QML clips
// are rectangular, so the frame's own rounding never reaches the
// view — the view's own fill must round instead.
radius: 12
QbzTheme { id: theme }
@@ -235,11 +239,10 @@ Rectangle {
anchors.verticalCenter: parent.verticalCenter
color: theme.surfaceElevated
clip: true
Image {
RoundedImage {
anchors.fill: parent
source: card.artPath
fillMode: Image.PreserveAspectCrop
asynchronous: true
radius: 4
}
}
Column {
@@ -348,11 +351,10 @@ Rectangle {
anchors.centerIn: parent
color: theme.surfaceMain
clip: true
Image {
RoundedImage {
anchors.fill: parent
source: card.artPath || ""
fillMode: Image.PreserveAspectCrop
asynchronous: true
radius: 95
}
MouseArea {
anchors.fill: parent
@@ -396,11 +398,10 @@ Rectangle {
radius: theme.radiusSm
color: theme.surfaceElevated
clip: true
Image {
RoundedImage {
anchors.fill: parent
source: card.artPath || ""
fillMode: Image.PreserveAspectCrop
asynchronous: true
radius: theme.radiusSm
}
MouseArea {
anchors.fill: parent
@@ -505,11 +506,10 @@ Rectangle {
radius: theme.radiusSm
color: theme.surfaceElevated
clip: true
Image {
RoundedImage {
anchors.fill: parent
source: card.artPath
fillMode: Image.PreserveAspectCrop
asynchronous: true
radius: theme.radiusSm
}
MouseArea {
anchors.fill: parent
@@ -559,11 +559,10 @@ Rectangle {
anchors.horizontalCenter: parent.horizontalCenter
color: theme.surfaceElevated
clip: true
Image {
RoundedImage {
anchors.fill: parent
source: card.artPath
fillMode: Image.PreserveAspectCrop
asynchronous: true
radius: 60
}
MouseArea {
anchors.fill: parent
+10 -14
View File
@@ -26,6 +26,10 @@ import com.blitzfc.qbz
Rectangle {
id: root
color: theme.surfaceMain
// Round to the AppShell content-frame bezel (Radius.md): QML clips
// are rectangular, so the frame's own rounding never reaches the
// view — the view's own fill must round instead.
radius: 12
QbzTheme { id: theme }
@@ -406,23 +410,16 @@ Rectangle {
color: theme.surfaceElevated
clip: true
Image {
RoundedImage {
anchors.fill: parent
source: root.artMap[item.artKey] || ""
fillMode: Image.PreserveAspectCrop
asynchronous: true
}
// Artist cards are circular in Slint (ArtistGridCard).
Rectangle {
visible: item.kind === "artist"
anchors.fill: parent
radius: 100
color: "transparent"
border.width: 0
// Artist cards are circular in Slint (ArtistGridCard).
radius: item.kind === "artist" ? 100 : theme.radiusSm
}
// Hover scrim + play overlay (albums + tracks).
Rectangle {
anchors.fill: parent
radius: item.kind === "artist" ? 100 : theme.radiusSm
color: "#000000"
opacity: overlayOn && (item.kind === "album" || item.kind === "track") ? 0.5 : 0.0
Behavior on opacity { NumberAnimation { duration: 150 } }
@@ -550,11 +547,10 @@ Rectangle {
anchors.verticalCenter: parent.verticalCenter
color: theme.surfaceElevated
clip: true
Image {
RoundedImage {
anchors.fill: parent
source: root.artMap[item.artKey] || ""
fillMode: Image.PreserveAspectCrop
asynchronous: true
radius: 4
}
}
Column {
+2 -2
View File
@@ -17,8 +17,8 @@ import com.blitzfc.qbz
Rectangle {
id: root
color: theme.surfaceMain
// Rounded window corners (custom chrome).
radius: 12
// Square window corners (phase 12: opaque window; the compositor owns
// any rounding).
// Set by the Loader (custom chrome drag/maximize); unused here.
property var hostWindow: null
+24 -9
View File
@@ -19,10 +19,17 @@ ApplicationWindow {
minimumHeight: 600
visible: true
title: "QBZ"
// Custom chrome (phase 7): frameless + translucent so the window draws
// its own rounded corners, 1:1 with the Slint custom-chrome look.
flags: Qt.Window | Qt.FramelessWindowHint
color: "transparent"
// Custom chrome (phase 7/12): frameless but OPAQUE — the phase-7
// translucent window was a misread: the Slint MAIN window keeps an
// OPAQUE swapchain (only the miniplayer blends; crates/qbz/src/main.rs
// set_surface_prefers_transparent + the Cargo.toml patch comment), and
// the rounded corners in the Slint screenshots come from the
// COMPOSITOR, not the app — app.slint's root is opaque surface-main
// with square corners and a square 1px hairline frame. The system
// titlebar is the `use_system_title_bar` pref (ui_prefs.json; applied
// at startup, restart semantics like Slint).
flags: QbzBridge.systemTitleBar ? Qt.Window : (Qt.Window | Qt.FramelessWindowHint)
color: "#1a1a1a"
FontLoader { id: interRegular; source: "assets/fonts/Inter_18pt-Regular.ttf" }
FontLoader { id: interMedium; source: "assets/fonts/Inter_18pt-Medium.ttf" }
@@ -32,6 +39,7 @@ ApplicationWindow {
Component.onCompleted: QbzBridge.boot()
Loader {
id: screenLoader
anchors.fill: parent
@@ -43,15 +51,15 @@ ApplicationWindow {
onLoaded: if (screenLoader.item) screenLoader.item.hostWindow = window
}
// Rounded-window hairline frame (app.slint's no-frame 1px edge) —
// paints at the very rim, over everything, clipped by the corners.
// Frameless hairline (app.slint's no-frame 1px edge) — paints at the
// very rim, over everything. SQUARE (the app draws no corner rounding).
Rectangle {
anchors.fill: parent
color: "transparent"
radius: 12
border.width: 1
border.color: "#14ffffff"
visible: window.visibility !== Window.Maximized && window.visibility !== Window.FullScreen
visible: !QbzBridge.systemTitleBar
&& window.visibility !== Window.Maximized && window.visibility !== Window.FullScreen
}
// Edge/corner resize grips (custom chrome — the compositor draws no
@@ -59,41 +67,49 @@ ApplicationWindow {
MouseArea {
anchors.left: parent.left; anchors.top: parent.top; anchors.bottom: parent.bottom; width: 6
cursorShape: Qt.SizeHorCursor
enabled: !QbzBridge.systemTitleBar
onPressed: window.startSystemResize(Qt.LeftEdge)
}
MouseArea {
anchors.right: parent.right; anchors.top: parent.top; anchors.bottom: parent.bottom; width: 6
cursorShape: Qt.SizeHorCursor
enabled: !QbzBridge.systemTitleBar
onPressed: window.startSystemResize(Qt.RightEdge)
}
MouseArea {
anchors.left: parent.left; anchors.right: parent.right; anchors.top: parent.top; height: 6
cursorShape: Qt.SizeVerCursor
enabled: !QbzBridge.systemTitleBar
onPressed: window.startSystemResize(Qt.TopEdge)
}
MouseArea {
anchors.left: parent.left; anchors.right: parent.right; anchors.bottom: parent.bottom; height: 6
cursorShape: Qt.SizeVerCursor
enabled: !QbzBridge.systemTitleBar
onPressed: window.startSystemResize(Qt.BottomEdge)
}
MouseArea {
anchors.left: parent.left; anchors.top: parent.top; width: 12; height: 12
cursorShape: Qt.SizeFDiagCursor
enabled: !QbzBridge.systemTitleBar
onPressed: window.startSystemResize(Qt.TopEdge | Qt.LeftEdge)
}
MouseArea {
anchors.right: parent.right; anchors.top: parent.top; width: 12; height: 12
cursorShape: Qt.SizeBDiagCursor
enabled: !QbzBridge.systemTitleBar
onPressed: window.startSystemResize(Qt.TopEdge | Qt.RightEdge)
}
MouseArea {
anchors.left: parent.left; anchors.bottom: parent.bottom; width: 12; height: 12
cursorShape: Qt.SizeBDiagCursor
enabled: !QbzBridge.systemTitleBar
onPressed: window.startSystemResize(Qt.BottomEdge | Qt.LeftEdge)
}
MouseArea {
anchors.right: parent.right; anchors.bottom: parent.bottom; width: 12; height: 12
cursorShape: Qt.SizeFDiagCursor
enabled: !QbzBridge.systemTitleBar
onPressed: window.startSystemResize(Qt.BottomEdge | Qt.RightEdge)
}
@@ -102,7 +118,6 @@ ApplicationWindow {
Rectangle {
anchors.fill: parent
color: "#0f0f0f"
radius: 12
visible: QbzBridge.screen === "splash"
Rectangle {
+2 -3
View File
@@ -187,11 +187,10 @@ Rectangle {
color: theme.surfaceElevated
clip: true
Image {
visible: QbzBridge.npHasTrack && QbzBridge.npArtworkPath !== ""
RoundedImage {
anchors.fill: parent
source: QbzBridge.npArtworkPath
fillMode: Image.PreserveAspectCrop
radius: 6
}
// Idle (no track): a STATIC album glyph
// placeholder (50% of the art size, muted).
+71
View File
@@ -0,0 +1,71 @@
// QbzContextMenu — the shared popup surface for the app's ⋯ / context
// menus, with CORRECT positioning (phase 12):
//
// - openAtCursor(item, localX, localY): context menus open AT THE
// POINTER, like the Slint unified TrackContextMenu (TrackRow.slint:
// `absolute-position - layout-offset + local-pointer`, clamped into
// the row/window). Coordinates are mapped to the window and the menu
// is parented to the Overlay, so a menu never renders inside the
// triggering card/row.
// - openBelowRight(item): button menus anchor under their trigger,
// right edge aligned (the QbzSelect / sort-menu convention).
// - Both clamp into the window with an 8px margin (Slint clamps the x
// to `width - menu-width - 12`) so a menu never opens off-screen —
// Popup does NOT do this on its own.
//
// The background matches ContextMenu.slint (surface-main, Radius.sm,
// 1px border-muted). Rows go in the default content (33px, icon 15 +
// label 13 per the existing per-site convention).
import QtQuick
import QtQuick.Controls
import com.blitzfc.qbz
Popup {
id: root
parent: Overlay.overlay
width: menuWidth
padding: 5
closePolicy: Popup.CloseOnPressOutside | Popup.CloseOnEscape
property int menuWidth: 196
default property alias menuContent: col.data
QbzTheme { id: theme }
background: Rectangle {
color: theme.surfaceMain
radius: theme.radiusSm
border.width: 1
border.color: theme.borderMuted
}
contentItem: Column { id: col }
// Final placement is computed BEFORE open() — the popup's implicit
// height is already known (static content), and `Window.window` is
// only reliably attached on the SOURCE item, not on the popup.
function _place(gx, gy, win) {
if (win) {
x = Math.max(8, Math.min(gx, win.width - width - 8))
y = Math.max(8, Math.min(gy, win.height - implicitHeight - 8))
} else {
x = gx
y = gy
}
open()
}
/// Context menu at the pointer. `localX/localY` are the click coords
/// inside `sourceItem` (the MouseArea that caught the click).
function openAtCursor(sourceItem, localX, localY) {
var g = sourceItem.mapToItem(null, localX, localY)
_place(g.x, g.y, sourceItem.Window.window)
}
/// Button menu: below the trigger, right edges aligned.
function openBelowRight(sourceItem) {
var g = sourceItem.mapToItem(null, sourceItem.width - width, sourceItem.height + 4)
_place(g.x, g.y, sourceItem.Window.window)
}
}
+7 -21
View File
@@ -153,11 +153,10 @@ Rectangle {
anchors.verticalCenter: parent.verticalCenter
color: theme.surfaceElevated
clip: true
Image {
RoundedImage {
anchors.fill: parent
source: root.coverMap[row.artUrl] || ""
fillMode: Image.PreserveAspectCrop
asynchronous: true
radius: 4
}
}
@@ -227,22 +226,11 @@ Rectangle {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: rowMenu.open()
onClicked: function (mouse) { rowMenu.openAtCursor(menuArea, mouse.x, mouse.y) }
}
Popup {
QbzContextMenu {
id: rowMenu
x: width - 196
y: 44
width: 196
padding: 5
closePolicy: Popup.CloseOnPressOutside
background: Rectangle {
color: theme.surfaceMain
radius: theme.radiusSm
border.width: 1
border.color: theme.borderMuted
}
contentItem: Column {
menuWidth: 196
Repeater {
model: [
{ "label": QbzBridge.tr("Remove from queue"), "icon": "trash-2", "action": "remove" },
@@ -289,7 +277,6 @@ Rectangle {
}
}
}
}
}
}
@@ -423,11 +410,10 @@ Rectangle {
anchors.verticalCenter: parent.verticalCenter
color: theme.surfaceCard
clip: true
Image {
RoundedImage {
anchors.fill: parent
source: root.currentRow ? (root.coverMap[root.currentRow.artUrl] || "") : ""
fillMode: Image.PreserveAspectCrop
asynchronous: true
radius: 4
}
}
Column {
+76
View File
@@ -0,0 +1,76 @@
// RoundedImage — an Image with TRUE rounded corners (PreserveAspectCrop).
//
// Why this exists: QML's `clip` is rectangular — a Rectangle with
// `radius` + `clip: true` does NOT clip children to the rounded shape
// (proven with an isolated scene on this Qt build: the child paints
// square over the rounded fill). Shader masks (Qt5Compat OpacityMask,
// QtQuick.Effects MultiEffect) render NOTHING on the software/offscreen
// path (the phase-2 icon-tinting probe), and the Pi kiosk can run the
// software path — so rounding is done with a Canvas raster clip
// (QPainter, antialiased, renderTarget Image = CPU).
//
// Usage mirrors the old pattern: `RoundedImage { anchors.fill: parent;
// source: artPath; radius: theme.radiusSm }` inside the same rounded
// placeholder Rectangle (the placeholder's OWN fill rounds fine — only
// children needed the workaround).
import QtQuick
Canvas {
id: root
property string source: ""
property real radius: 8
renderTarget: Canvas.Image
renderStrategy: Canvas.Cooperative
// Dimension probe: Canvas can't query the loaded image's intrinsic
// size, and PreserveAspectCrop needs it. A hidden Image doubles as
// the async loader notification.
Image {
id: probe
source: root.source
visible: false
asynchronous: true
cache: true
onStatusChanged: if (status === Image.Ready || status === Image.Error) root.requestPaint()
}
onSourceChanged: loadImage(source)
Component.onCompleted: loadImage(source)
onImageLoaded: requestPaint()
onWidthChanged: requestPaint()
onHeightChanged: requestPaint()
onPaint: {
var ctx = getContext("2d")
ctx.reset()
var w = width
var h = height
if (w <= 0 || h <= 0) return
var r = Math.max(0, Math.min(radius, w / 2, h / 2))
if (r > 0) {
ctx.beginPath()
ctx.moveTo(r, 0)
ctx.lineTo(w - r, 0)
ctx.arcTo(w, 0, w, r, r)
ctx.lineTo(w, h - r)
ctx.arcTo(w, h, w - r, h, r)
ctx.lineTo(r, h)
ctx.arcTo(0, h, 0, h - r, r)
ctx.lineTo(0, r)
ctx.arcTo(0, 0, r, 0, r)
ctx.closePath()
ctx.clip()
}
if (probe.status === Image.Ready && probe.sourceSize.width > 0 && isImageLoaded(source)) {
// PreserveAspectCrop: uniform scale covering the rect, centered.
var iw = probe.sourceSize.width
var ih = probe.sourceSize.height
var scale = Math.max(w / iw, h / ih)
var dw = iw * scale
var dh = ih * scale
ctx.drawImage(source, (w - dw) / 2, (h - dh) / 2, dw, dh)
}
}
}
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#4285f4" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10" />
<circle cx="12" cy="12" r="3" />
</svg>

After

Width:  |  Height:  |  Size: 255 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10" />
<circle cx="12" cy="12" r="3" />
</svg>

After

Width:  |  Height:  |  Size: 255 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10" />
<circle cx="12" cy="12" r="3" />
</svg>

After

Width:  |  Height:  |  Size: 255 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#888888" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10" />
<circle cx="12" cy="12" r="3" />
</svg>

After

Width:  |  Height:  |  Size: 255 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10" />
<circle cx="12" cy="12" r="3" />
</svg>

After

Width:  |  Height:  |  Size: 255 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#cccccc" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10" />
<circle cx="12" cy="12" r="3" />
</svg>

After

Width:  |  Height:  |  Size: 255 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fbbf24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10" />
<circle cx="12" cy="12" r="3" />
</svg>

After

Width:  |  Height:  |  Size: 255 B

+23
View File
@@ -146,6 +146,15 @@ pub mod qbz_bridge {
// row states, select option lists, output-device groups).
#[qproperty(QString, settings_json)]
// --- Window chrome (phase 12) --------------------------------------
// The APPLIED titlebar mode (the ui_prefs `use_system_title_bar`
// value read at startup — drives the window flags; never mutated at
// runtime, matching the Slint restart semantics).
#[qproperty(bool, system_title_bar)]
// The PERSISTED pref as it stands NOW (the app-menu check state;
// flips live when the user toggles, applies on the next launch).
#[qproperty(bool, system_title_bar_pref)]
type QbzBridge = super::QbzBridgeRust;
/// Called once from Main.qml's Component.onCompleted: registers the
@@ -370,6 +379,12 @@ pub mod qbz_bridge {
/// Output-device refresh button: release a held device, re-enumerate.
#[qinvokable]
fn refresh_devices(self: Pin<&mut QbzBridge>);
/// App-menu chrome toggle: flip the persisted `use_system_title_bar`
/// pref (applies on the next launch — the window flags are fixed at
/// creation, 1:1 Slint). Updates `systemTitleBarPref` only.
#[qinvokable]
fn toggle_system_title_bar(self: Pin<&mut QbzBridge>);
}
impl cxx_qt::Threading for QbzBridge {}
@@ -441,6 +456,8 @@ pub struct QbzBridgeRust {
lyrics_open: bool,
lyrics_json: QString,
settings_json: QString,
system_title_bar: bool,
system_title_bar_pref: bool,
}
impl Default for QbzBridgeRust {
@@ -502,6 +519,8 @@ impl Default for QbzBridgeRust {
lyrics_open: false,
lyrics_json: QString::from("{}"),
settings_json: QString::from("{}"),
system_title_bar: crate::settings_qt::use_system_title_bar(),
system_title_bar_pref: crate::settings_qt::use_system_title_bar(),
}
}
}
@@ -770,4 +789,8 @@ impl qbz_bridge::QbzBridge {
pub fn refresh_devices(self: Pin<&mut Self>) {
crate::refresh_devices();
}
pub fn toggle_system_title_bar(self: Pin<&mut Self>) {
crate::toggle_system_title_bar();
}
}
+10
View File
@@ -725,6 +725,16 @@ pub(crate) fn refresh_devices() {
spawn(async move { settings_qt::refresh_devices(&runtime).await });
}
/// App-menu chrome toggle: persist the flipped pref and update the menu
/// state. The APPLIED mode (`systemTitleBar`) is deliberately untouched —
/// it changes on the next launch (the window flags are read once at
/// startup, 1:1 the Slint restart semantics).
pub(crate) fn toggle_system_title_bar() {
let next = settings_qt::toggle_system_title_bar();
log::info!("[qbz-qt] use_system_title_bar -> {next} (applies on next launch)");
ui(move |mut b| b.as_mut().set_system_title_bar_pref(next));
}
fn load_library_once() {
if *LIBRARY_LOADED.lock().unwrap() {
return;
+41
View File
@@ -115,6 +115,47 @@ fn save_streaming_quality(key: &str) {
}
}
// ---------------------------------------------------------------------------
// ui_prefs.json (window chrome, phase 12) — the Slint `use_system_title_bar`
// pref (crates/qbz/src/ui_prefs.rs): SAME shared file, additive key patch so
// every other Slint key survives. Default TRUE on Linux (the Slint default
// is `!macos` — Linux keeps the system decorations). Applied at startup
// only: decorations negotiate at surface creation on Wayland, so a toggle
// takes effect on the next launch (restart semantics, 1:1 Slint).
// ---------------------------------------------------------------------------
pub fn use_system_title_bar() -> bool {
let Some(path) = prefs_path() else {
return true;
};
std::fs::read_to_string(path)
.ok()
.and_then(|text| serde_json::from_str::<serde_json::Value>(&text).ok())
.and_then(|v| v.get("use_system_title_bar").and_then(|q| q.as_bool()))
.unwrap_or(true)
}
/// Flip + persist the pref; returns the new value (for the menu state).
pub fn toggle_system_title_bar() -> bool {
let next = !use_system_title_bar();
if let Some(path) = prefs_path() {
let mut value: serde_json::Value = std::fs::read_to_string(&path)
.ok()
.and_then(|text| serde_json::from_str(&text).ok())
.unwrap_or_else(|| serde_json::json!({}));
if let Some(obj) = value.as_object_mut() {
obj.insert(
"use_system_title_bar".to_string(),
serde_json::Value::Bool(next),
);
if let Ok(text) = serde_json::to_string_pretty(&value) {
let _ = std::fs::write(&path, text);
}
}
}
next
}
pub const STREAMING_QUALITY_KEYS: &[&str] = &["mp3", "cd", "hires", "hires_plus"];
pub const STREAMING_QUALITY_LABELS: &[&str] = &["MP3", "CD Quality", "Hi-Res", "Hi-Res+"];