feat(qt): the mini footer, the hover capsule and always-on-top
Block B3. The owner's verdict on the four blocks that landed before it was that the miniplayer was "no draggeable, no hay controles de ningun tipo. Ni de reproducción, ni para cambiar entre modos, no es pinneable." All of that lived here. qml/miniplayer/ — MiniFooter with its three modes, and the five primitives the reference declares as inner components: MiniSeek, MiniTransport, MiniVolume, TBtn and CapBtn, one file each because a 396-line Slint footer folded into one QML file lands well past the size rule. MiniWindowControls is the hover capsule: five surface tabs, a separator, then expand, pin, backdrop, drag and close, plus the collapsed square trigger. The transport in full and compact is centred by a PHANTOM SPACER the width of the volume button, not by a centred layout — micro has no volume and is the one mode genuinely centred. The seek carries the reference's pending machine: the -1.0 sentinel, the 0.02 convergence window and the 8s watchdog. The capsule is a conditional MOUNT, so leaving the card destroys its expanded flag and its 250ms timer with it. ALWAYS-ON-TOP is new work, not a port: three comments in the Slint tree promise it and no code implements it. The owner asked for it back because Tauri had it and Slint structurally cannot. Qt.WindowStaysOnTopHint, a pin button, and a persisted pref that defaults ON. COLOUR LITERALS: six were carried over from the reference verbatim and every one of them was WRONG. Slint writes #RRGGBBAA; Qt parses #AARRGGBB, so #ffffff33 is not white at 20% but opaque pale yellow — which is exactly how the capsule rendered until it was driven and looked at. This is the second time this trap has landed in this port. Each converted literal now carries the reference form beside it. One of the six is MiniShell's, from B2. mini_qt.rs — surface 0 leaves the temporary clamp, because the micro footer IS the card and now exists. Surfaces 3 and 4 stay clamped until B4. Driven and seen, not inferred: the footer in artwork and micro, the capsule revealing on hover and collapsing on leave, the micro card at 380x57 with its centred transport, and the pin persisting mini_always_on_top across a toggle. The drag handle is wired to startSystemMove on pressed; the VNC platform cannot move a window, so that one is owed a real compositor. cc
No files matched your search
@@ -316,14 +316,21 @@ fn main() {
|
||||
// Miniplayer (2026-08-03 miniplayer/tray-port contract §3.4) —
|
||||
// its own module directory, like immersive/ and kiosk/. B2
|
||||
// ships the window, the card shell and the two DISPLAY
|
||||
// surfaces; B3 adds the footer + capsule, B4 the queue and
|
||||
// lyrics surfaces.
|
||||
// surfaces; B3 the footer, its four primitives and the hover
|
||||
// capsule; B4 the queue and lyrics surfaces.
|
||||
"qml/miniplayer/MiniWindow.qml",
|
||||
"qml/miniplayer/MiniShell.qml",
|
||||
"qml/miniplayer/MiniExplicitBadge.qml",
|
||||
"qml/miniplayer/MiniCoverArt.qml",
|
||||
"qml/miniplayer/MiniCompactSurface.qml",
|
||||
"qml/miniplayer/MiniArtworkSurface.qml",
|
||||
"qml/miniplayer/MiniFooter.qml",
|
||||
"qml/miniplayer/MiniWindowControls.qml",
|
||||
"qml/miniplayer/MiniSeek.qml",
|
||||
"qml/miniplayer/MiniTransport.qml",
|
||||
"qml/miniplayer/MiniVolume.qml",
|
||||
"qml/miniplayer/TBtn.qml",
|
||||
"qml/miniplayer/CapBtn.qml",
|
||||
],
|
||||
qrc_files: &qrc_refs,
|
||||
..Default::default()
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<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">
|
||||
<rect width="9" height="6" x="6" y="14" rx="2" />
|
||||
<rect width="16" height="6" x="6" y="4" rx="2" />
|
||||
<path d="M2 2v20" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 311 B |
@@ -0,0 +1,8 @@
|
||||
<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">
|
||||
<path d="M12 2v20" />
|
||||
<path d="m15 19-3 3-3-3" />
|
||||
<path d="m19 9 3 3-3 3" />
|
||||
<path d="M2 12h20" />
|
||||
<path d="m5 9-3 3 3 3" />
|
||||
<path d="m9 5 3-3 3 3" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 347 B |
@@ -0,0 +1,3 @@
|
||||
<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">
|
||||
<rect width="18" height="18" x="3" y="3" rx="2" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 237 B |
@@ -0,0 +1,5 @@
|
||||
<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">
|
||||
<rect width="9" height="6" x="6" y="14" rx="2" />
|
||||
<rect width="16" height="6" x="6" y="4" rx="2" />
|
||||
<path d="M2 2v20" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 311 B |
@@ -0,0 +1,8 @@
|
||||
<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">
|
||||
<path d="M12 2v20" />
|
||||
<path d="m15 19-3 3-3-3" />
|
||||
<path d="m19 9 3 3-3 3" />
|
||||
<path d="M2 12h20" />
|
||||
<path d="m5 9-3 3 3 3" />
|
||||
<path d="m9 5 3-3 3 3" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 347 B |
@@ -0,0 +1,3 @@
|
||||
<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">
|
||||
<rect width="18" height="18" x="3" y="3" rx="2" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 237 B |
@@ -0,0 +1,5 @@
|
||||
<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">
|
||||
<rect width="9" height="6" x="6" y="14" rx="2" />
|
||||
<rect width="16" height="6" x="6" y="4" rx="2" />
|
||||
<path d="M2 2v20" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 311 B |
@@ -0,0 +1,8 @@
|
||||
<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">
|
||||
<path d="M12 2v20" />
|
||||
<path d="m15 19-3 3-3-3" />
|
||||
<path d="m19 9 3 3-3 3" />
|
||||
<path d="M2 12h20" />
|
||||
<path d="m5 9-3 3 3 3" />
|
||||
<path d="m9 5 3-3 3 3" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 347 B |
@@ -0,0 +1,3 @@
|
||||
<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">
|
||||
<rect width="18" height="18" x="3" y="3" rx="2" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 237 B |
@@ -0,0 +1,5 @@
|
||||
<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">
|
||||
<rect width="9" height="6" x="6" y="14" rx="2" />
|
||||
<rect width="16" height="6" x="6" y="4" rx="2" />
|
||||
<path d="M2 2v20" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 311 B |
@@ -0,0 +1,8 @@
|
||||
<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">
|
||||
<path d="M12 2v20" />
|
||||
<path d="m15 19-3 3-3-3" />
|
||||
<path d="m19 9 3 3-3 3" />
|
||||
<path d="M2 12h20" />
|
||||
<path d="m5 9-3 3 3 3" />
|
||||
<path d="m9 5 3-3 3 3" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 347 B |
@@ -0,0 +1,3 @@
|
||||
<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">
|
||||
<rect width="18" height="18" x="3" y="3" rx="2" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 237 B |
@@ -0,0 +1,5 @@
|
||||
<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">
|
||||
<rect width="9" height="6" x="6" y="14" rx="2" />
|
||||
<rect width="16" height="6" x="6" y="4" rx="2" />
|
||||
<path d="M2 2v20" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 311 B |
@@ -0,0 +1,8 @@
|
||||
<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">
|
||||
<path d="M12 2v20" />
|
||||
<path d="m15 19-3 3-3-3" />
|
||||
<path d="m19 9 3 3-3 3" />
|
||||
<path d="M2 12h20" />
|
||||
<path d="m5 9-3 3 3 3" />
|
||||
<path d="m9 5 3-3 3 3" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 347 B |
@@ -0,0 +1,3 @@
|
||||
<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">
|
||||
<rect width="18" height="18" x="3" y="3" rx="2" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 237 B |
@@ -0,0 +1,5 @@
|
||||
<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">
|
||||
<rect width="9" height="6" x="6" y="14" rx="2" />
|
||||
<rect width="16" height="6" x="6" y="4" rx="2" />
|
||||
<path d="M2 2v20" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 311 B |
@@ -0,0 +1,8 @@
|
||||
<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">
|
||||
<path d="M12 2v20" />
|
||||
<path d="m15 19-3 3-3-3" />
|
||||
<path d="m19 9 3 3-3 3" />
|
||||
<path d="M2 12h20" />
|
||||
<path d="m5 9-3 3 3 3" />
|
||||
<path d="m9 5 3-3 3 3" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 347 B |
@@ -0,0 +1,3 @@
|
||||
<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">
|
||||
<rect width="18" height="18" x="3" y="3" rx="2" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 237 B |
@@ -0,0 +1,79 @@
|
||||
// CapBtn — the hover capsule's button (2026-08-03 miniplayer/tray contract
|
||||
// A-29, §4.3.4), port of `component CapBtn inherits Rectangle` at
|
||||
// `crates/qbz-ui/ui/miniplayer/MiniWindowControls.slint:14-53`.
|
||||
//
|
||||
// 22 x 20, radius 6 — NOT a disc like TBtn, and the trigger overrides the
|
||||
// height to 22 so the collapsed capsule is a circle.
|
||||
//
|
||||
// TWO SIGNALS, and the difference is load-bearing (§15 trap 8): `clicked` is
|
||||
// the ordinary press-release, `pressed` fires on pointer DOWN. `a-move` and the
|
||||
// micro header's drag handle use the second one, because `startSystemMove()`
|
||||
// hands the pointer grab to the compositor and a press-release would never
|
||||
// arrive — a clicked handler would simply never drag.
|
||||
//
|
||||
// `danger` paints the close button's red hover fill, and the glyph on it stays
|
||||
// a LITERAL #ffffff (tint "white"). QbzTheme's owner-approved
|
||||
// glyph-on-accent selector (`accentGlyphTint`) does NOT apply: that decides the
|
||||
// glyph on an ACCENT fill, and #ef4444 is not the accent (§4.11).
|
||||
//
|
||||
// The idle tint is `Theme.alpha-65`, spelled as textPrimary at 0.65 opacity for
|
||||
// the reason TBtn.qml's header gives.
|
||||
//
|
||||
// The drop shadow the reference draws on the capsule chrome is dropped by the
|
||||
// port's standing convention (QbzToast.qml:30, QbzTooltip.qml:51-61,
|
||||
// MyQbzModals.qml:23) — it lives on MiniWindowControls, not here.
|
||||
|
||||
import QtQuick
|
||||
import "../theme"
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
property string name: ""
|
||||
property int iconSize: 13
|
||||
property bool active: false
|
||||
/// The close button: red hover fill, white glyph on it.
|
||||
property bool danger: false
|
||||
/// `a-move` only — the grab cursor that says "this drags the window".
|
||||
property bool dragCursor: false
|
||||
/// Read by MiniWindowControls' collapse machine when it aggregates hover
|
||||
/// per button; the shipped machine uses one HoverHandler on the chrome
|
||||
/// instead, so this stays available without being on a hot path.
|
||||
readonly property bool hovered: ta.containsMouse
|
||||
|
||||
signal clicked()
|
||||
/// Pointer DOWN. See the header.
|
||||
signal pressed()
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
width: 22
|
||||
height: 20
|
||||
radius: 6
|
||||
antialiasing: true
|
||||
color: ta.containsMouse
|
||||
? (root.danger ? "#ccef4444" : theme.alphaTier(12)) // #ef4444cc in Slint; Qt is #AARRGGBB
|
||||
: "transparent"
|
||||
|
||||
QbzIcon {
|
||||
name: root.name
|
||||
width: root.iconSize
|
||||
height: root.iconSize
|
||||
// Pixel-snapped, exactly as TBtn does (:35-36).
|
||||
x: Math.round((root.width - width) / 2)
|
||||
y: Math.round((root.height - height) / 2)
|
||||
tintName: root.active
|
||||
? "accent"
|
||||
: ((ta.containsMouse && root.danger) ? "white" : "textPrimary")
|
||||
opacity: (root.active || ta.containsMouse) ? 1.0 : 0.65
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: ta
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: root.dragCursor ? Qt.OpenHandCursor : Qt.PointingHandCursor
|
||||
onClicked: root.clicked()
|
||||
onPressed: root.pressed()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
// MiniFooter — the miniplayer's control strip (2026-08-03 miniplayer/tray
|
||||
// contract A-28, §4.3.2), port of
|
||||
// `crates/qbz-ui/ui/miniplayer/MiniFooter.slint` (396 L, read whole).
|
||||
//
|
||||
// THREE LAYOUTS IN ONE COMPONENT, and its `mode` is numbered the OPPOSITE way
|
||||
// from the surface enum (§15 trap 2): 0 full · 1 compact · 2 micro, against the
|
||||
// surface's 0 micro · 1 compact · 2 artwork. MiniShell computes the inversion
|
||||
// once into `footerMode` and this file consumes it — it never reads
|
||||
// QbzMini.surface itself.
|
||||
//
|
||||
// In micro the footer IS the card: surface 0 has no surface area at all, the
|
||||
// window is 380 x 57 and this component fills the 45 px card (§13-D10).
|
||||
//
|
||||
// THE PHANTOM SPACER (§15 trap 12). In full and compact the transport is
|
||||
// centred by a trailing invisible box the width of the volume button
|
||||
// (:318-319), NOT by a centred layout — the reference's own comment at :294-296
|
||||
// says so. Drop it and the transport drifts right by half the volume button.
|
||||
// MICRO HAS NO VOLUME CONTROL AND THEREFORE NO PHANTOM: it is the one mode that
|
||||
// is genuinely centred, which is why the rule is scoped to full/compact.
|
||||
//
|
||||
// EVERY PER-MODE NUMBER IS A CACHED READONLY PROPERTY (§7-M2). The reference
|
||||
// caches six and writes a seventh — the volume trigger's glyph size — inline at
|
||||
// its mount (:306); six-plus-one is the inconsistency LESSONS L5 calls the
|
||||
// smell, so all of them are cached here, including the paddings and the seek
|
||||
// sizes the reference also spells inline.
|
||||
//
|
||||
// THE CAPSULE IS A CONDITIONAL MOUNT (§15 trap 7). `Loader { active: }`, never
|
||||
// `visible: false`: when the pointer leaves the card the component is destroyed
|
||||
// and its expanded flag and 250 ms collapse timer go with it.
|
||||
//
|
||||
// The two mode branches are Loaders for the same reason Slint uses `if`
|
||||
// (:263, :332) rather than a visibility flip — an always-visible surface pays
|
||||
// for every live binding it keeps, and only one of the two layouts is ever on
|
||||
// screen.
|
||||
|
||||
import QtQuick
|
||||
import com.blitzfc.qbz
|
||||
import "../theme"
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
/// 0 full · 1 compact · 2 micro — MiniShell's `footerMode`, already
|
||||
/// inverted. NOT the surface id.
|
||||
property int mode: 0
|
||||
/// The MINI window (§8 rule 3), for startSystemMove().
|
||||
property var hostWindow: null
|
||||
/// Drives the capsule's conditional mount. Fed by MiniShell's HoverHandler
|
||||
/// on the card, which is §13-D4's replacement for the reference's winit
|
||||
/// CursorMoved/CursorLeft feed.
|
||||
property bool windowHovered: false
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
// --- The cached per-mode tokens (§7-M2, §4.3.2's table) ----------------
|
||||
readonly property bool compactMode: root.mode === 1
|
||||
readonly property int tbtn: root.compactMode ? 26 : 30
|
||||
readonly property int tgap: root.compactMode ? 14 : 18
|
||||
readonly property int icShuffle: root.compactMode ? 13 : 16
|
||||
readonly property int icSkip: root.compactMode ? 15 : 18
|
||||
readonly property int icPlay: root.compactMode ? 16 : 20
|
||||
readonly property int ctrlH: root.compactMode ? 34 : 40
|
||||
/// THE SEVENTH (:306) — inline in the reference, cached here.
|
||||
readonly property int icVolume: root.compactMode ? 15 : 18
|
||||
// The paddings and the seek sizing, same treatment (:266-274, :284).
|
||||
readonly property int padH: root.compactMode ? 10 : 14
|
||||
readonly property int padBottom: root.compactMode ? 6 : 10
|
||||
readonly property int seekH: root.compactMode ? 10 : 14
|
||||
readonly property int seekTrack: root.compactMode ? 2 : 3
|
||||
readonly property int timeFont: root.compactMode ? 10 : 11
|
||||
|
||||
readonly property bool hasTrack: QbzPlayer.npHasTrack
|
||||
|
||||
// --- The pending-seek machine (:214-249) -------------------------------
|
||||
// All three constants are load-bearing: the -1.0 SENTINEL (every test is
|
||||
// `>= 0`, so 0.0 is a legitimate position and not "none"), the 0.02
|
||||
// convergence window, and the 8 s watchdog.
|
||||
//
|
||||
// The problem it solves: the engine takes a moment to seek, and
|
||||
// QbzPlayer.npProgress keeps reporting the OLD position until it lands. A
|
||||
// bar bound straight to npProgress therefore snaps back under the cursor
|
||||
// after every click. `effProgress` holds the requested position until the
|
||||
// player converges on it — or until the watchdog gives up, which is what
|
||||
// keeps a failed seek from freezing the bar forever.
|
||||
property real dragPreview: -1.0
|
||||
property real pendingSeek: -1.0
|
||||
property bool dragging: false
|
||||
readonly property real effProgress: (root.dragging && root.dragPreview >= 0)
|
||||
? root.dragPreview
|
||||
: (root.pendingSeek >= 0 ? root.pendingSeek
|
||||
: QbzPlayer.npProgress)
|
||||
|
||||
// The reference watches a mirrored copy of the progress (`watch-progress`,
|
||||
// :222-228) because a Slint global cannot carry a `changed` handler; in Qt
|
||||
// the singleton's own notify signal is the same edge, with no mirror.
|
||||
Connections {
|
||||
target: QbzPlayer
|
||||
function onNpProgressChanged() {
|
||||
if (root.pendingSeek >= 0
|
||||
&& Math.abs(QbzPlayer.npProgress - root.pendingSeek) < 0.02) {
|
||||
root.pendingSeek = -1.0
|
||||
// The reference leaves the watchdog armed here (:223-228 clears
|
||||
// only the sentinel) and lets it fire into a no-op. Stopping it
|
||||
// on convergence is one wakeup fewer on a window whose whole
|
||||
// point is to be left running (§7).
|
||||
pendingTimer.stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// T2 (§4.12) — 8 s one-shot, re-armed on every commit. A Timer has no
|
||||
// `parent` (§8 rule 2); everything it touches is reached through `root`.
|
||||
Timer {
|
||||
id: pendingTimer
|
||||
interval: 8000
|
||||
repeat: false
|
||||
onTriggered: root.pendingSeek = -1.0
|
||||
}
|
||||
|
||||
function previewSeek(f) {
|
||||
root.dragging = true
|
||||
root.dragPreview = f
|
||||
}
|
||||
|
||||
function commitSeek(f) {
|
||||
root.dragging = false
|
||||
root.dragPreview = -1.0
|
||||
root.pendingSeek = f
|
||||
QbzPlayer.seek(f)
|
||||
pendingTimer.restart()
|
||||
}
|
||||
|
||||
/// m:ss. The four now-playing bars each declare this locally
|
||||
/// (PlayerBar.qml:211-215 and its three twins); there is no shared
|
||||
/// formatter in the tree, so the footer declares ONE rather than a copy per
|
||||
/// row (§7-M2, L5).
|
||||
function fmt(secs) {
|
||||
var m = Math.floor(secs / 60)
|
||||
var s = Math.floor(secs % 60)
|
||||
return m + ":" + (s < 10 ? "0" : "") + s
|
||||
}
|
||||
|
||||
color: QbzMini.backgroundBlur ? "#d906070a" : theme.surfaceCard // #06070ad9 in Slint; Qt is #AARRGGBB
|
||||
|
||||
// ======================================================================
|
||||
// FULL / COMPACT (:263-329)
|
||||
// ======================================================================
|
||||
Loader {
|
||||
active: root.mode !== 2
|
||||
anchors.fill: parent
|
||||
anchors.topMargin: 0
|
||||
anchors.leftMargin: root.padH
|
||||
anchors.rightMargin: root.padH
|
||||
anchors.bottomMargin: root.padBottom
|
||||
|
||||
sourceComponent: Component {
|
||||
Item {
|
||||
MiniSeek {
|
||||
id: seek
|
||||
anchors.top: parent.top
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
height: root.seekH
|
||||
trackHeight: root.seekTrack
|
||||
progress: root.effProgress
|
||||
onPreview: function (f) { root.previewSeek(f) }
|
||||
onCommit: function (f) { root.commitSeek(f) }
|
||||
}
|
||||
|
||||
// The times row (:280-292). It takes whatever the seek bar and
|
||||
// the controls row leave, which is how the reference's
|
||||
// VerticalLayout distributes it — 16 px at full, 14 at compact.
|
||||
Item {
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: seek.bottom
|
||||
anchors.bottom: ctrlRow.top
|
||||
|
||||
Text {
|
||||
anchors.left: parent.left
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: root.fmt(QbzPlayer.npElapsedSecs)
|
||||
color: theme.textMuted
|
||||
font.pixelSize: root.timeFont
|
||||
}
|
||||
Text {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
// EXACTLY ONE minus sign (§15 trap 14). The trap is
|
||||
// about the SLINT property `NowPlayingState.remaining`,
|
||||
// which arrives pre-signed from
|
||||
// `crates/qbz/src/playback.rs:1679-1682`; the Qt bridge
|
||||
// publishes no such property (there is no np_remaining
|
||||
// in src/player_bridge.rs), so this is the tree's own
|
||||
// idiom — PlayerBar.qml:339 — which supplies the sign
|
||||
// itself.
|
||||
text: "-" + root.fmt(Math.max(0, QbzPlayer.npDurationSecs
|
||||
- QbzPlayer.npElapsedSecs))
|
||||
color: theme.textMuted
|
||||
font.pixelSize: root.timeFont
|
||||
}
|
||||
}
|
||||
|
||||
// The controls row (:297-328).
|
||||
Item {
|
||||
id: ctrlRow
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
height: root.ctrlH
|
||||
|
||||
MiniVolume {
|
||||
id: vol
|
||||
// Above the transport, so its popup and the
|
||||
// click-outside layer inside it are not painted under
|
||||
// the buttons they have to cover.
|
||||
z: 2
|
||||
anchors.left: parent.left
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
btn: root.tbtn
|
||||
iconSize: root.icVolume
|
||||
}
|
||||
|
||||
// THE PHANTOM SPACER (:318-319). Invisible, no children,
|
||||
// exactly the volume button's width — it exists so the
|
||||
// transport's two stretch spacers are balanced and the
|
||||
// cluster lands on the WINDOW's centre line rather than on
|
||||
// the centre of what is left after the volume button.
|
||||
Item {
|
||||
id: phantom
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: root.tbtn
|
||||
height: root.tbtn
|
||||
}
|
||||
|
||||
MiniTransport {
|
||||
id: transport
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
// Centred in the band BETWEEN the volume button and the
|
||||
// phantom — the two equal stretch spacers, written out.
|
||||
// Because the two ends are the same width this lands on
|
||||
// the row's centre, which is the point of the phantom.
|
||||
x: vol.x + vol.width
|
||||
+ Math.round((phantom.x - vol.x - vol.width - width) / 2)
|
||||
btn: root.tbtn
|
||||
gap: root.tgap
|
||||
icShuffle: root.icShuffle
|
||||
icSkip: root.icSkip
|
||||
icPlay: root.icPlay
|
||||
btnEnabled: root.hasTrack
|
||||
}
|
||||
|
||||
// The capsule (:322-327): right-aligned, vertically centred
|
||||
// on the controls row, above everything including the
|
||||
// volume popup's dismiss layer.
|
||||
Loader {
|
||||
z: 3
|
||||
active: root.windowHovered
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
sourceComponent: Component {
|
||||
MiniWindowControls {
|
||||
activeSurface: QbzMini.surface
|
||||
hostWindow: root.hostWindow
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ======================================================================
|
||||
// MICRO (:332-395) — 1 + 17 + 16 + 4 = 38 px of content in the 45 px card
|
||||
// ======================================================================
|
||||
Loader {
|
||||
active: root.mode === 2
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 8
|
||||
anchors.rightMargin: 8
|
||||
anchors.topMargin: 1
|
||||
// No bottom margin — the reference sets none (:335-338).
|
||||
|
||||
sourceComponent: Component {
|
||||
Item {
|
||||
// The header: one elided line, and the whole of it is a drag
|
||||
// handle.
|
||||
Item {
|
||||
id: microHeader
|
||||
anchors.top: parent.top
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
height: 17
|
||||
|
||||
Text {
|
||||
anchors.fill: parent
|
||||
// The separator is a LITERAL " - " (space, hyphen-minus,
|
||||
// space) — :347, not an en dash and not translated.
|
||||
text: QbzPlayer.npHasTrack
|
||||
? (QbzPlayer.npArtist !== ""
|
||||
? QbzPlayer.npTitle + " - " + QbzPlayer.npArtist
|
||||
: QbzPlayer.npTitle)
|
||||
: QbzSession.tr("No track playing", QbzSession.trRev)
|
||||
color: theme.alphaTier(70)
|
||||
font.pixelSize: 11
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
// THE DRAG HANDLE (:356-362). PRESSED, NOT CLICKED (§15
|
||||
// trap 8): startSystemMove() hands the pointer grab to the
|
||||
// compositor, so the release never returns to Qt and a
|
||||
// clicked handler would never fire. It covers the whole
|
||||
// header because the reference's TouchArea sets no geometry
|
||||
// — in micro there is nothing else in this strip to hit.
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
onPressed: {
|
||||
if (root.hostWindow)
|
||||
root.hostWindow.startSystemMove()
|
||||
}
|
||||
}
|
||||
|
||||
// The capsule, with the EXTRA 8 px inset the full/compact
|
||||
// mount does not have (:364).
|
||||
Loader {
|
||||
z: 1
|
||||
active: root.windowHovered
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 8
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
sourceComponent: Component {
|
||||
MiniWindowControls {
|
||||
activeSurface: QbzMini.surface
|
||||
hostWindow: root.hostWindow
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The controls strip (:371-384). NO VOLUME, therefore NO
|
||||
// PHANTOM — micro is the one mode that is genuinely centred
|
||||
// (`HorizontalLayout { alignment: center }`, :373-374).
|
||||
Item {
|
||||
id: microCtrl
|
||||
anchors.top: microHeader.bottom
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
height: 16
|
||||
|
||||
MiniTransport {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
btn: 16
|
||||
gap: 9
|
||||
icShuffle: 8
|
||||
icSkip: 9
|
||||
icPlay: 9
|
||||
btnEnabled: root.hasTrack
|
||||
}
|
||||
}
|
||||
|
||||
// The bottom seek (:387-394): 4 px tall, a 1 px SQUARE-ENDED
|
||||
// track. The reference's comment calls it "full-bleed"; the
|
||||
// layout's 8 px side padding says otherwise and the CODE wins
|
||||
// (§12-P6).
|
||||
MiniSeek {
|
||||
anchors.top: microCtrl.bottom
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
height: 4
|
||||
trackHeight: 1
|
||||
rounded: false
|
||||
progress: root.effProgress
|
||||
onPreview: function (f) { root.previewSeek(f) }
|
||||
onCommit: function (f) { root.commitSeek(f) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// MiniSeek — the footer's seek bar (2026-08-03 miniplayer/tray contract A-29,
|
||||
// §4.3.3), port of `component MiniSeek inherits Rectangle` at
|
||||
// `crates/qbz-ui/ui/miniplayer/MiniFooter.slint:168-209`.
|
||||
//
|
||||
// A THIN TRACK INSIDE A TALL HIT AREA: the visible bar is `trackHeight`
|
||||
// (3 px full, 2 px compact, 1 px micro) and the pointer target is the whole
|
||||
// root height (14 / 10 / 4). No thumb, no hover growth, no buffered overlay —
|
||||
// the reference has none of the three (:168-209 read whole), and the deferred
|
||||
// polish its file header records (a pending-seek spinner thumb, a micro marquee)
|
||||
// is deferred THERE too.
|
||||
//
|
||||
// This component owns NO state. It reports `preview` while the pointer is down
|
||||
// and `commit` on release, and MiniFooter owns the pending-seek machine — which
|
||||
// is why `progress` is an input rather than a read of QbzPlayer: the footer
|
||||
// feeds it the EFFECTIVE progress, so the fill does not snap back to the
|
||||
// player's stale value between the click and the engine catching up.
|
||||
//
|
||||
// DELIBERATELY NOT clamped to `QbzPlayer.npSeekableMax`, unlike the main bars:
|
||||
// the reference's mini has no seekable-max notion at all, and §12-P16 keeps the
|
||||
// mini's "ignores is-remote / volume-locked / cast-active" behaviour as a
|
||||
// REPRODUCE-1:1 row rather than half-fixing it here.
|
||||
|
||||
import QtQuick
|
||||
import "../theme"
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
/// Effective 0..1, fed by MiniFooter's pending-seek machine.
|
||||
property real progress: 0.0
|
||||
property int trackHeight: 3
|
||||
/// Micro's bar is square-ended (:390).
|
||||
property bool rounded: true
|
||||
|
||||
signal preview(real fraction)
|
||||
signal commit(real fraction)
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
function fractionAt(px) {
|
||||
return root.width > 0 ? Math.max(0, Math.min(1, px / root.width)) : 0
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: track
|
||||
width: root.width
|
||||
height: root.trackHeight
|
||||
y: Math.round((root.height - height) / 2)
|
||||
radius: root.rounded ? height / 2 : 0
|
||||
color: theme.alphaTier(12)
|
||||
clip: true
|
||||
|
||||
Rectangle {
|
||||
x: 0
|
||||
y: 0
|
||||
width: parent.width * Math.max(0, Math.min(1, root.progress))
|
||||
height: parent.height
|
||||
radius: parent.radius
|
||||
color: theme.accent
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
// down -> preview, drag -> preview, up -> commit (:195-207).
|
||||
onPressed: function (mouse) { root.preview(root.fractionAt(mouse.x)) }
|
||||
onPositionChanged: function (mouse) {
|
||||
if (pressed)
|
||||
root.preview(root.fractionAt(mouse.x))
|
||||
}
|
||||
onReleased: function (mouse) { root.commit(root.fractionAt(mouse.x)) }
|
||||
}
|
||||
}
|
||||
@@ -24,10 +24,23 @@ Rectangle {
|
||||
QbzTheme { id: theme }
|
||||
|
||||
// §8 rule 3: the window, handed down explicitly by MiniWindow. Consumed by
|
||||
// the footer's drag handle and the hover capsule (B3), which call
|
||||
// the footer's drag handle and the hover capsule, which call
|
||||
// startSystemMove() on it rather than walking a parent chain.
|
||||
property var hostWindow: null
|
||||
|
||||
// §13-D4 — the reference feeds `MiniPlayerState.window-hovered` from winit
|
||||
// CursorMoved/CursorLeft (`crates/qbz/src/miniplayer.rs:420-429`) because a
|
||||
// borderless Slint card has no reliable root hover; `state.slint:4506`
|
||||
// names "or a root TouchArea" as the alternative and Qt takes that branch.
|
||||
// A HoverHandler, NOT a MouseArea: a MouseArea would lose containsMouse the
|
||||
// moment the pointer crossed onto any button in the footer, which is
|
||||
// exactly the measured failure the tree records at
|
||||
// qml/immersive/SuggestionsPanel.qml:316-325 and
|
||||
// qml/shell/Cortinilla.qml:215-222. This drives the capsule's conditional
|
||||
// mount, so getting it wrong means the capsule vanishes under the cursor
|
||||
// that is reaching for it.
|
||||
HoverHandler { id: cardHover }
|
||||
|
||||
// --- Cached per-mode values (§7-M2) ------------------------------------
|
||||
// The reference computes each of these ONCE into a named property
|
||||
// (MiniShell.slint:18, :23, :62). An always-visible surface pays a repeated
|
||||
@@ -160,7 +173,7 @@ Rectangle {
|
||||
// track, the card is the flat scrim over #050506.
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
color: "#050506bf"
|
||||
color: "#bf050506" // #050506bf in Slint; Qt is #AARRGGBB
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,9 +199,9 @@ Rectangle {
|
||||
// minus the footer, which is how §4.2's "surface-area H" column is
|
||||
// reproduced: 102 at compact, 448 at the 540-tall expanded default.
|
||||
//
|
||||
// MiniFooter mounts under it and arrives with B3; the space it will occupy
|
||||
// is reserved here, by footerHeight, because that reservation is what makes
|
||||
// the surface geometry above it correct in the first place.
|
||||
// MiniFooter mounts under it; the space it occupies is reserved here, by
|
||||
// footerHeight, because that reservation is what makes the surface geometry
|
||||
// above it correct in the first place.
|
||||
Item {
|
||||
id: surfaceArea
|
||||
anchors.left: parent.left
|
||||
@@ -217,4 +230,22 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- The footer (MiniShell.slint:59-63) --------------------------------
|
||||
// In micro it IS the card: `surfaceArea` is invisible at surface 0 and this
|
||||
// fills all 45 px. Its `mode` is the INVERTED number (§15 trap 2), computed
|
||||
// once above.
|
||||
//
|
||||
// Declared AFTER surfaceArea so the hover capsule — which overflows the
|
||||
// 17 px micro header by 4.5 px in each direction — paints above it.
|
||||
MiniFooter {
|
||||
id: footer
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
height: root.footerHeight
|
||||
mode: root.footerMode
|
||||
hostWindow: root.hostWindow
|
||||
windowHovered: cardHover.hovered
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// MiniTransport — the five-button transport row (2026-08-03 miniplayer/tray
|
||||
// contract A-29, §4.3.3), port of `component MiniTransport inherits
|
||||
// HorizontalLayout` at `crates/qbz-ui/ui/miniplayer/MiniFooter.slint:52-103`.
|
||||
//
|
||||
// Mounted THREE times with three different size sets: full (btn 30), compact
|
||||
// (btn 26) and micro (btn 16) — which is why every dimension is a property and
|
||||
// none is a literal.
|
||||
//
|
||||
// NO LOADING STATE ON PLAY (§12-P4). The reference mirrors
|
||||
// `NowPlayingState.loading` into the mini (`crates/qbz/src/miniplayer.rs:559`)
|
||||
// and never renders it; the main bar's spinner has no counterpart here. Do not
|
||||
// add one.
|
||||
//
|
||||
// The repeat button reuses `icShuffle` for its glyph size — it has no size
|
||||
// token of its own in the reference (:100), and that is not an oversight worth
|
||||
// "fixing": shuffle and repeat are the two outer buttons and they match.
|
||||
|
||||
import QtQuick
|
||||
import com.blitzfc.qbz
|
||||
|
||||
Row {
|
||||
id: root
|
||||
|
||||
property int btn: 30
|
||||
property int icShuffle: 16
|
||||
property int icSkip: 18
|
||||
property int icPlay: 20
|
||||
property int gap: 18
|
||||
/// Gated on `NowPlayingState.has-track` by the footer (:315).
|
||||
property bool btnEnabled: true
|
||||
|
||||
spacing: root.gap
|
||||
|
||||
TBtn {
|
||||
name: "shuffle"
|
||||
iconSize: root.icShuffle
|
||||
btn: root.btn
|
||||
active: QbzPlayer.npShuffle
|
||||
btnEnabled: root.btnEnabled
|
||||
onClicked: QbzPlayer.toggleShuffle()
|
||||
}
|
||||
TBtn {
|
||||
name: "skip-back"
|
||||
iconSize: root.icSkip
|
||||
btn: root.btn
|
||||
btnEnabled: root.btnEnabled
|
||||
onClicked: QbzPlayer.previous()
|
||||
}
|
||||
TBtn {
|
||||
name: QbzPlayer.npPlaying ? "pause" : "play-fill"
|
||||
iconSize: root.icPlay
|
||||
btn: root.btn
|
||||
btnEnabled: root.btnEnabled
|
||||
onClicked: QbzPlayer.togglePlay()
|
||||
}
|
||||
TBtn {
|
||||
name: "skip-forward"
|
||||
iconSize: root.icSkip
|
||||
btn: root.btn
|
||||
btnEnabled: root.btnEnabled
|
||||
onClicked: QbzPlayer.next()
|
||||
}
|
||||
TBtn {
|
||||
// 0 off · 1 all · 2 one (src/player_bridge.rs:52).
|
||||
name: QbzPlayer.npRepeatMode === 2 ? "repeat-1" : "repeat"
|
||||
iconSize: root.icShuffle
|
||||
btn: root.btn
|
||||
active: QbzPlayer.npRepeatMode !== 0
|
||||
btnEnabled: root.btnEnabled
|
||||
onClicked: QbzPlayer.cycleRepeat()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
// MiniVolume — the footer's volume trigger and its popup (2026-08-03
|
||||
// miniplayer/tray contract A-29, §4.3.3), port of `component MiniVolume
|
||||
// inherits Rectangle` at `crates/qbz-ui/ui/miniplayer/MiniFooter.slint:106-164`.
|
||||
//
|
||||
// THE POPUP OPENS TO THE RIGHT, not below: `x: root.btn + 8px`,
|
||||
// `y: (root.height - 34px) / 2` (:122-123) — 8 px past the trigger's right edge
|
||||
// and vertically centred on the trigger's own row, so it sits over the
|
||||
// transport rather than over the seek bar.
|
||||
//
|
||||
// INHERITED ODDITY, REPRODUCED (§12-P5): the popup's mute button is hard-coded
|
||||
// `volume-x.svg` and never reflects the mute state (:139), where the TRIGGER
|
||||
// does swap between volume-x and volume-2 (:114). Not a bug worth diverging
|
||||
// for — it is a mute BUTTON, not a mute indicator.
|
||||
//
|
||||
// WHY THE DISMISS LAYER IS A HUGE RECTANGLE. Slint gets close-on-click-outside
|
||||
// from `PopupWindow`'s `close-policy` (:126). Qt's equivalent would be a
|
||||
// QtQuick.Controls Popup, whose overlay would take focus from the frameless
|
||||
// mini and whose window-level surface is exactly what §4.3.4 refuses for the
|
||||
// capsule. So dismissal is a plain MouseArea sized far past this component and
|
||||
// CLIPPED BY THE CARD — MiniShell sets `clip: true`, and Qt clips hit-testing to
|
||||
// the clip rect as well as painting, so the layer covers the whole card and
|
||||
// nothing beyond it. It sits under the popup (z 1 vs 2) and over the trigger,
|
||||
// which is why clicking the trigger a second time closes rather than reopens —
|
||||
// 1:1 with a close-on-click-outside policy consuming that click.
|
||||
//
|
||||
// The reference's 24 px drop shadow (:132-133) is dropped by the port's
|
||||
// standing convention for Slint shadows (QbzToast.qml:30, QbzTooltip.qml:51-61,
|
||||
// MyQbzModals.qml:23, AddToMixtapeModal.qml:19).
|
||||
//
|
||||
// NOT PORTED, and it is a REFERENCE GAP rather than an omission: the main bar
|
||||
// persists the settled volume on drag-end (`QbzSlider.onReleased` ->
|
||||
// QbzPlayer.persistVolume, qml/shell/PlayerBar.qml:582), and the Slint mini
|
||||
// does NOT — its slider binds `changed` only (:151) even though
|
||||
// `NowPlayingState.persist-volume` exists beside `set-volume`
|
||||
// (state.slint:4482-4483). Reported as a divergence PROPOSAL rather than taken,
|
||||
// because §13.0 rule 5 is that a divergence never lands without its §13 row.
|
||||
|
||||
import QtQuick
|
||||
import com.blitzfc.qbz
|
||||
import "../controls"
|
||||
import "../theme"
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
property int btn: 30
|
||||
/// The TRIGGER's glyph size — the SEVENTH per-mode value, cached by the
|
||||
/// footer beside the other six (contract §4.3.2).
|
||||
property int iconSize: 18
|
||||
/// Read by MiniFooter so it can raise this whole subtree above the
|
||||
/// transport while the popup is up.
|
||||
property bool popupOpen: false
|
||||
|
||||
function closePopup() { root.popupOpen = false }
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
width: root.btn
|
||||
height: root.btn
|
||||
color: "transparent"
|
||||
|
||||
TBtn {
|
||||
name: (QbzPlayer.npMuted || QbzPlayer.npVolume === 0) ? "volume-x" : "volume-2"
|
||||
iconSize: root.iconSize
|
||||
btn: root.btn
|
||||
onClicked: root.popupOpen = true
|
||||
}
|
||||
|
||||
// Close-on-click-outside. See the header for why it is shaped like this.
|
||||
MouseArea {
|
||||
z: 1
|
||||
visible: root.popupOpen
|
||||
enabled: root.popupOpen
|
||||
x: -1000
|
||||
y: -1000
|
||||
width: 2000
|
||||
height: 2000
|
||||
// hoverEnabled stays FALSE so the card's HoverHandler — the thing that
|
||||
// mounts the capsule — keeps seeing the pointer through this layer.
|
||||
onClicked: root.closePopup()
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: popup
|
||||
z: 2
|
||||
visible: root.popupOpen
|
||||
x: root.btn + 8
|
||||
y: Math.round((root.height - height) / 2)
|
||||
width: 172
|
||||
height: 34
|
||||
radius: 17
|
||||
antialiasing: true
|
||||
color: theme.surfaceCard
|
||||
border.width: 1
|
||||
border.color: theme.alphaTier(12)
|
||||
|
||||
// Swallow clicks on the popup's own background so they do not fall
|
||||
// through to the dismiss layer (the Cortinilla.qml:227-232 idiom).
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
acceptedButtons: Qt.AllButtons
|
||||
onClicked: {}
|
||||
}
|
||||
|
||||
Row {
|
||||
x: 8
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 7
|
||||
|
||||
TBtn {
|
||||
// Always volume-x — see the header (§12-P5).
|
||||
name: "volume-x"
|
||||
iconSize: 14
|
||||
btn: 20
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
onClicked: QbzPlayer.toggleMute()
|
||||
}
|
||||
|
||||
QbzSlider {
|
||||
width: 96
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
minimum: 0
|
||||
// 0..1000, not 0..100: the slider is integer-stepped, so a
|
||||
// 0..100 scale quantizes the volume to 1 % where 0.1 % drags
|
||||
// fluidly (:146-149).
|
||||
maximum: 1000
|
||||
value: Math.round(QbzPlayer.npVolume * 1000)
|
||||
onChanged: function (v) { QbzPlayer.setVolume(v / 1000.0) }
|
||||
}
|
||||
|
||||
Text {
|
||||
// 26, not the reference's `min-width: 22`: 8 + 20 + 7 + 96 + 7
|
||||
// + 26 = 164 leaves exactly the 8 px right padding the Slint
|
||||
// layout ends with, where a hard 22 would leave 12. The number
|
||||
// it renders is at most three digits at 10 px.
|
||||
width: 26
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: Math.round(QbzPlayer.npVolume * 100)
|
||||
color: theme.textMuted
|
||||
font.pixelSize: 10
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,14 +33,27 @@ Window {
|
||||
// step of enter() (§4.6). Explicitly null, never inherited.
|
||||
transientParent: null
|
||||
|
||||
// Borderless, 1:1 with `no-frame: true` (app.slint:293).
|
||||
// Borderless, 1:1 with `no-frame: true` (app.slint:293), plus always-on-top
|
||||
// — owner ruling K1, §13-D15, and it DEFAULTS ON.
|
||||
//
|
||||
// Always-on-top is owner ruling K1 and defaults ON, but its property
|
||||
// (QbzMini.alwaysOnTop, A-5) and the pin button that toggles it are block
|
||||
// B3's; B3 adds the `| (QbzMini.alwaysOnTop ? Qt.WindowStaysOnTopHint : 0)`
|
||||
// term to this expression. Naming a property that does not exist would be
|
||||
// the L1 failure this contract's symbol rule exists to prevent.
|
||||
// The AOT term is NEW WORK rather than a port: §4.8's two probes found
|
||||
// three comments in the reference promising the affordance
|
||||
// (state.slint:4497, miniplayer.rs:170-171, MiniWindowControls.slint:2) and
|
||||
// no code anywhere that sets it. The property is seeded from
|
||||
// `mini_always_on_top` at bridge construction and toggled by the capsule's
|
||||
// pin button.
|
||||
//
|
||||
// ⚠ THE ONE RUNTIME RISK, and it is §16 U-6: reassigning `flags` on a
|
||||
// MAPPED window can make Qt destroy and recreate the platform window, which
|
||||
// on Wayland loses the compositor-chosen position. §4.8 pre-specifies the
|
||||
// fallback rather than leaving it to be improvised — evaluate the flags at
|
||||
// enter() only and keep the pref and the button state — and the fallback is
|
||||
// NOT taken: driven over RFB, toggling the pin left the window's geometry,
|
||||
// its contents and its input focus untouched (no flicker, no jump, no
|
||||
// re-created surface). A real compositor is still the owner's smoke item;
|
||||
// the pref persists either way, so the next open honours it regardless.
|
||||
flags: Qt.Window | Qt.FramelessWindowHint
|
||||
| (QbzMini.alwaysOnTop ? Qt.WindowStaysOnTopHint : 0)
|
||||
|
||||
// Literal, never translated (app.slint:283 uses a plain string, not @tr).
|
||||
title: "QBZ Mini Player"
|
||||
@@ -97,6 +110,35 @@ Window {
|
||||
minimumWidth: 340
|
||||
minimumHeight: root.winMinHeight
|
||||
|
||||
// --- The geometry persist (§4.7, A-14, §13-D14) ------------------------
|
||||
// Qt has no `WindowEvent::Resized`, so the resize handler is this debounce.
|
||||
// The template is the main window's own (qml/Main.qml:377-385), and the
|
||||
// 400 ms interval is the same.
|
||||
//
|
||||
// It is DEBOUNCED because the alternative is what the reference does: a
|
||||
// whole-file ui_prefs.json load + save per qualifying resize event, with no
|
||||
// dirty check (`crates/qbz/src/miniplayer.rs:460-463`), which in Qt would
|
||||
// be blocking file IO on the GUI thread inside a resize drag (§7-M10).
|
||||
//
|
||||
// It needs NO arming latch of its own, unlike Main.qml's 1200 ms one: the
|
||||
// two guards inside `mini_qt::geometry_to_persist` already reject exactly
|
||||
// the frames a latch would — `surface >= 2` drops micro and compact, whose
|
||||
// sizes are constants, and `height >= 320` drops the mid-transition frames
|
||||
// a surface switch produces (a 178 px compact frame is precisely what that
|
||||
// filter is for). Note the filter is 320 and the stored floor is 420 — two
|
||||
// different numbers, both behavioural (§15 trap 17).
|
||||
Timer {
|
||||
id: geometrySaveTimer
|
||||
interval: 400
|
||||
repeat: false
|
||||
// A Timer has no `parent` (§8 rule 2, §15 trap 29) — the window is
|
||||
// reached through its own id.
|
||||
onTriggered: QbzMini.saveGeometry(root.width, root.height)
|
||||
}
|
||||
|
||||
onWidthChanged: geometrySaveTimer.restart()
|
||||
onHeightChanged: geometrySaveTimer.restart()
|
||||
|
||||
// A compositor close (Alt+F4, a panel's close entry) behaves EXACTLY like
|
||||
// the capsule's expand button — §13-D13, and it closes a hole the reference
|
||||
// has: Slint has no on_close_requested on the mini, so its default
|
||||
@@ -105,6 +147,13 @@ Window {
|
||||
// (§3.1), so `accepted = false` is load-bearing twice.
|
||||
onClosing: function (close) {
|
||||
close.accepted = false
|
||||
// Flush a pending resize (§4.7). Only when one is actually pending —
|
||||
// an unconditional call would put a prefs write on every close, and the
|
||||
// bridge's own dirty check would then be the only thing stopping it.
|
||||
if (geometrySaveTimer.running) {
|
||||
geometrySaveTimer.stop()
|
||||
QbzMini.saveGeometry(root.width, root.height)
|
||||
}
|
||||
QbzMini.exit()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
// MiniWindowControls — the hover capsule (2026-08-03 miniplayer/tray contract
|
||||
// A-28, §4.3.4), port of
|
||||
// `crates/qbz-ui/ui/miniplayer/MiniWindowControls.slint` (184 L, read whole).
|
||||
//
|
||||
// A collapsed 26 px disc pinned to the RIGHT that unfolds LEFT into ten
|
||||
// buttons: five surface tabs, a separator, then expand / pin / background /
|
||||
// move / close. INLINE CHROME, NOT A POPUP — the reference's header says so at
|
||||
// :1-4 and gives the reason: a popup surface would steal focus from a frameless
|
||||
// always-on-top window.
|
||||
//
|
||||
// THE PIN IS NEW WORK, NOT A PORT (K1, §4.8, §13-D15). Three of the reference's
|
||||
// own comments promise always-on-top — state.slint:4497, miniplayer.rs:170-171
|
||||
// and this component's own header, which describes the cluster as
|
||||
// "expand/pin/move/close" — and ZERO code implements it. The shipped Slint
|
||||
// cluster has nine buttons; this one has ten, and the width arithmetic below
|
||||
// carries the K1 column of §4.3.4's table (cluster 243, expanded 272), not the
|
||||
// nine-button one (219 / 248).
|
||||
//
|
||||
// THIS COMPONENT IS MOUNTED CONDITIONALLY, NEVER `visible: false` (§15 trap 7).
|
||||
// MiniFooter mounts it through a `Loader { active: windowHovered }`, so when the
|
||||
// pointer leaves the card the whole thing is destroyed and `expanded` plus the
|
||||
// 250 ms collapse timer die with it. A `visible` flip would leave a capsule
|
||||
// mid-unfold waiting to be re-revealed in that state.
|
||||
//
|
||||
// THE WIDTH IS COMPUTED FROM CONSTANTS, NOT FROM THE CHILDREN (§8 rule 1).
|
||||
// The reference reads `cluster.preferred-width` (:81); a QML port that reached
|
||||
// for `cluster.implicitWidth` through a `parent` chain would be exactly the
|
||||
// binding qmllint cannot verify. `clusterWidth` is spelled out with its
|
||||
// arithmetic instead, and `implicitWidth`/`implicitHeight` are what the Loader
|
||||
// sizes itself from.
|
||||
//
|
||||
// ONE HOVER SENSOR, NOT TEN. The reference ORs ten per-button `has-hover`
|
||||
// flags (:58-61) because a Slint Rectangle has no whole-area hover. A single
|
||||
// HoverHandler on the chrome reports for the item's whole area regardless of
|
||||
// what is stacked above it (the Cortinilla.qml:215-222 / SuggestionsPanel.qml
|
||||
// :316-325 precedent, both measured), which is the same behaviour minus nine
|
||||
// bindings on an always-visible surface (§7-M2, §7-M7). The only difference is
|
||||
// the 2-3 px gaps BETWEEN buttons, where the reference would start its 250 ms
|
||||
// collapse and cancel it on the next motion.
|
||||
//
|
||||
// The drop shadow (:93-95, discretely switched on `expanded` — never animated)
|
||||
// is dropped by the port's standing convention for Slint shadows
|
||||
// (QbzToast.qml:30, QbzTooltip.qml:51-61, MyQbzModals.qml:23).
|
||||
|
||||
import QtQuick
|
||||
import com.blitzfc.qbz
|
||||
import "../theme"
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
/// The lit tab. Passed in rather than read off QbzMini here so the mount
|
||||
/// site owns the binding (:56, `active-surface`).
|
||||
property int activeSurface: 2
|
||||
/// The MINI window, handed down MiniWindow -> MiniShell -> MiniFooter ->
|
||||
/// here (§8 rule 3). `a-move` calls startSystemMove() on it; a
|
||||
/// parent.parent chain is what that rule exists to forbid.
|
||||
property var hostWindow: null
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
// 5 tabs x 22 + 4 gaps x 2 = 118 · separator 1 · 5 actions x 22 + 4 x 2 =
|
||||
// 118 · two 3 px gaps in the outer row = 118 + 3 + 1 + 3 + 118.
|
||||
readonly property int clusterWidth: 243
|
||||
|
||||
property bool expanded: false
|
||||
|
||||
// A1 (§4.12): 220 ms, cubic-bezier(0.4, 0, 0.2, 1). The ONLY animated
|
||||
// geometry in the miniplayer.
|
||||
property real slotW: root.expanded ? root.clusterWidth : 0
|
||||
Behavior on slotW {
|
||||
NumberAnimation {
|
||||
duration: 220
|
||||
easing.type: Easing.Bezier
|
||||
easing.bezierCurve: [0.4, 0, 0.2, 1, 1, 1]
|
||||
}
|
||||
}
|
||||
|
||||
// 2 left pad + animated slot + a DISCRETELY switched 3 px gap + the 22 px
|
||||
// trigger + 2 right pad (:88). Collapsed 26, expanded 272.
|
||||
implicitWidth: 2 + root.slotW + (root.expanded ? 3 : 0) + 22 + 2
|
||||
implicitHeight: 26
|
||||
|
||||
radius: 13
|
||||
antialiasing: true
|
||||
color: "#e606070a" // #06070ae6 in Slint; Qt is #AARRGGBB
|
||||
border.width: 1
|
||||
border.color: "#33ffffff" // #ffffff33 in Slint; Qt is #AARRGGBB
|
||||
|
||||
HoverHandler {
|
||||
onHoveredChanged: {
|
||||
if (hovered) {
|
||||
collapseTimer.stop()
|
||||
root.expanded = true
|
||||
} else {
|
||||
collapseTimer.restart()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// T1 (§4.12): the 250 ms collapse grace. A Timer is a QtObject and has NO
|
||||
// `parent` — an unqualified `parent` in here would resolve up and out of
|
||||
// the block (§8 rule 2, §15 trap 29), so it says `root`.
|
||||
Timer {
|
||||
id: collapseTimer
|
||||
interval: 250
|
||||
repeat: false
|
||||
onTriggered: root.expanded = false
|
||||
}
|
||||
|
||||
// The cluster slot: clipped so the buttons slide out of a shrinking box
|
||||
// rather than being scaled.
|
||||
Item {
|
||||
id: slot
|
||||
x: 2
|
||||
y: Math.round((root.height - height) / 2)
|
||||
width: root.slotW
|
||||
height: 22
|
||||
clip: true
|
||||
// A2 (§4.12): 160 ms, default easing.
|
||||
opacity: root.expanded ? 1.0 : 0.0
|
||||
Behavior on opacity { NumberAnimation { duration: 160 } }
|
||||
|
||||
Row {
|
||||
id: cluster
|
||||
height: 22
|
||||
spacing: 3
|
||||
|
||||
// --- Group A: the five surface tabs -------------------------
|
||||
// QML writes the surface by ASSIGNMENT. There is NO setSurface
|
||||
// member on QbzMini — the property carries a custom WRITE whose
|
||||
// Rust name collides with the generated setter's, so the funnel
|
||||
// (persist + the surface-3 queue kick) is reachable only this way
|
||||
// (src/mini_bridge.rs:46-57).
|
||||
Row {
|
||||
height: 22
|
||||
spacing: 2
|
||||
|
||||
CapBtn {
|
||||
name: "minus"
|
||||
active: root.activeSurface === 0
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
onClicked: QbzMini.surface = 0
|
||||
}
|
||||
CapBtn {
|
||||
name: "disc-3"
|
||||
active: root.activeSurface === 1
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
onClicked: QbzMini.surface = 1
|
||||
}
|
||||
CapBtn {
|
||||
name: "image"
|
||||
active: root.activeSurface === 2
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
onClicked: QbzMini.surface = 2
|
||||
}
|
||||
CapBtn {
|
||||
name: "list-music"
|
||||
active: root.activeSurface === 3
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
onClicked: QbzMini.surface = 3
|
||||
}
|
||||
CapBtn {
|
||||
name: "align-start-vertical"
|
||||
active: root.activeSurface === 4
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
onClicked: QbzMini.surface = 4
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: 1
|
||||
height: 12
|
||||
color: "#29ffffff" // #ffffff29 in Slint; Qt is #AARRGGBB
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
// --- Group B: the actions -----------------------------------
|
||||
Row {
|
||||
height: 22
|
||||
spacing: 2
|
||||
|
||||
// `expand()` in the reference IS exit — `mp.on_expand(|| exit())`
|
||||
// (crates/qbz/src/miniplayer.rs:295).
|
||||
CapBtn {
|
||||
name: "maximize-2"
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
onClicked: QbzMini.exit()
|
||||
}
|
||||
// K1. The pin the reference's own comments promise.
|
||||
CapBtn {
|
||||
name: "pin"
|
||||
active: QbzMini.alwaysOnTop
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
onClicked: QbzMini.toggleAlwaysOnTop()
|
||||
}
|
||||
CapBtn {
|
||||
name: "layers"
|
||||
active: QbzMini.backgroundBlur
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
onClicked: QbzMini.toggleBackgroundBlur()
|
||||
}
|
||||
// PRESSED, NOT CLICKED (§15 trap 8): startSystemMove() hands
|
||||
// the pointer grab to the compositor, so the release never
|
||||
// comes back and a clicked handler would never fire.
|
||||
CapBtn {
|
||||
name: "move"
|
||||
iconSize: 12
|
||||
dragCursor: true
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
onPressed: {
|
||||
if (root.hostWindow)
|
||||
root.hostWindow.startSystemMove()
|
||||
}
|
||||
}
|
||||
CapBtn {
|
||||
name: "x"
|
||||
danger: true
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
onClicked: QbzMini.closeApp()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The trigger: always visible, HOVER-ONLY — it has no clicked handler in
|
||||
// the reference either (:176-183). 22 x 22 overrides CapBtn's 22 x 20 so
|
||||
// the collapsed capsule is a circle inside the 26 px radius-13 chrome.
|
||||
CapBtn {
|
||||
name: "square"
|
||||
iconSize: 12
|
||||
width: 22
|
||||
height: 22
|
||||
x: root.width - 2 - 22
|
||||
y: Math.round((root.height - height) / 2)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// TBtn — the miniplayer footer's transport/volume button (2026-08-03
|
||||
// miniplayer/tray contract A-29, §4.3.3), port of the inner
|
||||
// `component TBtn inherits Rectangle` at
|
||||
// `crates/qbz-ui/ui/miniplayer/MiniFooter.slint:16-49`.
|
||||
//
|
||||
// A file of its own rather than a QML inline component: the rule is A-29's and
|
||||
// it is a SIZE rule, not a language rule — a 396-line MiniFooter.slint whose
|
||||
// port folded five inner types in would land well past TRACK-RULES' ~500-line
|
||||
// line. (QML does have `component X: Rectangle { … }`, and this tree uses it at
|
||||
// qml/immersive/QueueTabsPanel.qml:135; nothing in this diff qualifies.)
|
||||
//
|
||||
// TWO details that look like bugs and are not:
|
||||
//
|
||||
// - THE ICON TINT DOES NOT CHECK `enabled` (§12-P7). A disabled button still
|
||||
// lightens its glyph under the cursor; only the root's 0.3 opacity conveys
|
||||
// disabled. That is the reference's behaviour (:36-38 has no enabled arm
|
||||
// where :28 does), and it is reproduced rather than "fixed".
|
||||
// - The hover BACKGROUND does check it (:28).
|
||||
//
|
||||
// The idle glyph is `Theme.alpha-70`, which is not a name in QbzIcon's tint
|
||||
// vocabulary — the vocabulary carries theme TOKENS, not the alpha ramp. It is
|
||||
// spelled here as textPrimary at 0.70 opacity, which is what alpha-70 IS: the
|
||||
// ramp is white-based on dark themes and black-based on light ones
|
||||
// (QbzTheme.qml's alphaTier), and textPrimary follows the same polarity. Using
|
||||
// "secondary" instead — the QbzIconButton idle tint — would be a different
|
||||
// colour (#cccccc, 80 %) and would not track the ramp on light themes.
|
||||
//
|
||||
// `btnEnabled`, not `enabled`: `enabled` is a QQuickItem property and
|
||||
// shadowing it disarms the whole subtree. Same spelling as QbzIconButton.
|
||||
|
||||
import QtQuick
|
||||
import "../theme"
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
/// Icon file stem, e.g. "shuffle" (QbzIcon appends ".svg").
|
||||
property string name: ""
|
||||
property int iconSize: 16
|
||||
/// The square edge; the radius is half of it, so the hover fill is a disc.
|
||||
property int btn: 30
|
||||
property bool active: false
|
||||
property bool btnEnabled: true
|
||||
signal clicked()
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
width: root.btn
|
||||
height: root.btn
|
||||
radius: root.btn / 2
|
||||
antialiasing: true
|
||||
opacity: root.btnEnabled ? 1.0 : 0.3
|
||||
color: (ta.containsMouse && root.btnEnabled) ? theme.alphaTier(8) : "transparent"
|
||||
|
||||
QbzIcon {
|
||||
name: root.name
|
||||
width: root.iconSize
|
||||
height: root.iconSize
|
||||
// Pixel-snapped centring, 1:1 with the reference's
|
||||
// `Math.round((parent.width - self.width) / 2 / 1px) * 1px` (:34-35):
|
||||
// a half-pixel offset on a 13 px glyph is visible as a blur.
|
||||
x: Math.round((root.width - width) / 2)
|
||||
y: Math.round((root.height - height) / 2)
|
||||
tintName: root.active ? "accent" : "textPrimary"
|
||||
opacity: (root.active || ta.containsMouse) ? 1.0 : 0.70
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: ta
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: root.btnEnabled ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||
onClicked: if (root.btnEnabled) root.clicked()
|
||||
}
|
||||
}
|
||||
@@ -155,6 +155,7 @@ pub struct QbzIconTintRust {
|
||||
#[rustfmt::skip]
|
||||
const MASTERS: &[(&str, &str)] = &[
|
||||
("add-to-list", include_str!("../qml/assets/icons/primary/add-to-list.svg")),
|
||||
("align-start-vertical", include_str!("../qml/assets/icons/primary/align-start-vertical.svg")),
|
||||
("arrow-up-down", include_str!("../qml/assets/icons/primary/arrow-up-down.svg")),
|
||||
("audio-lines", include_str!("../qml/assets/icons/primary/audio-lines.svg")),
|
||||
("award", include_str!("../qml/assets/icons/primary/award.svg")),
|
||||
@@ -224,6 +225,7 @@ const MASTERS: &[(&str, &str)] = &[
|
||||
("monitor", include_str!("../qml/assets/icons/primary/monitor.svg")),
|
||||
("monitor-speaker", include_str!("../qml/assets/icons/primary/monitor-speaker.svg")),
|
||||
("moon", include_str!("../qml/assets/icons/primary/moon.svg")),
|
||||
("move", include_str!("../qml/assets/icons/primary/move.svg")),
|
||||
("mp3", include_str!("../qml/assets/icons/primary/mp3.svg")),
|
||||
("music-library-2", include_str!("../qml/assets/icons/primary/music-library-2.svg")),
|
||||
("music", include_str!("../qml/assets/icons/primary/music.svg")),
|
||||
@@ -256,6 +258,7 @@ const MASTERS: &[(&str, &str)] = &[
|
||||
("sliders-horizontal", include_str!("../qml/assets/icons/primary/sliders-horizontal.svg")),
|
||||
("smartphone", include_str!("../qml/assets/icons/primary/smartphone.svg")),
|
||||
("speaker", include_str!("../qml/assets/icons/primary/speaker.svg")),
|
||||
("square", include_str!("../qml/assets/icons/primary/square.svg")),
|
||||
("square-check-big", include_str!("../qml/assets/icons/primary/square-check-big.svg")),
|
||||
("star", include_str!("../qml/assets/icons/primary/star.svg")),
|
||||
("sun-moon", include_str!("../qml/assets/icons/primary/sun-moon.svg")),
|
||||
|
||||
@@ -58,6 +58,13 @@ pub mod qbz_mini {
|
||||
// The static artwork-derived card background (`mini_background_blur`).
|
||||
// Written by `toggle_background_blur` (contract A-10).
|
||||
#[qproperty(bool, background_blur)]
|
||||
// K1 / contract A-5, and it is NEW WORK rather than a port: the Slint
|
||||
// miniplayer has three comments promising always-on-top and zero code
|
||||
// implementing it (§4.8). Seeded from `mini_always_on_top`, DEFAULT
|
||||
// TRUE, and consumed by `MiniWindow.qml`'s `flags` expression as
|
||||
// `| (QbzMini.alwaysOnTop ? Qt.WindowStaysOnTopHint : 0)`. Written by
|
||||
// `toggle_always_on_top` (contract A-11).
|
||||
#[qproperty(bool, always_on_top)]
|
||||
// The navigable queue: the current track first, then the FULL
|
||||
// unfiltered upcoming list. Built by `mini_qt::mini_queue_doc` and
|
||||
// published from the ONE queue funnel (`queue_qt::publish`), never from
|
||||
@@ -95,6 +102,28 @@ pub mod qbz_mini {
|
||||
#[qinvokable]
|
||||
fn exit(self: Pin<&mut QbzMini>);
|
||||
|
||||
/// Flip + persist the card backdrop (contract A-10). The capsule's
|
||||
/// `a-bg` button is its only caller; the whole render path already
|
||||
/// exists in `MiniShell.qml` gated on this one property.
|
||||
#[qinvokable]
|
||||
fn toggle_background_blur(self: Pin<&mut QbzMini>);
|
||||
|
||||
/// Flip + persist always-on-top (contract A-11, K1). The capsule's
|
||||
/// `a-pin` button is its only caller. Reassigning `flags` on a MAPPED
|
||||
/// window is the one runtime risk §4.8 names (U-6); the pref persists
|
||||
/// either way, so the next open honours it even if a platform were to
|
||||
/// refuse the live change.
|
||||
#[qinvokable]
|
||||
fn toggle_always_on_top(self: Pin<&mut QbzMini>);
|
||||
|
||||
/// Persist the expanded window size (contract A-14). Qt has no
|
||||
/// `WindowEvent::Resized`, so `MiniWindow.qml`'s 400 ms debounce calls
|
||||
/// in. The §4.7 guards live in `mini_qt::geometry_to_persist` — the
|
||||
/// surface is read from THIS object rather than passed, so no call site
|
||||
/// can disagree with the property the window is sized from.
|
||||
#[qinvokable]
|
||||
fn save_geometry(self: Pin<&mut QbzMini>, width: f32, height: f32);
|
||||
|
||||
/// Close the whole APP from the mini (contract A-12, §4.6). Exits the
|
||||
/// mini first, then routes to the ONE close choreography by emitting
|
||||
/// `close_app_requested` below.
|
||||
@@ -154,6 +183,7 @@ pub struct QbzMiniRust {
|
||||
open: bool,
|
||||
surface: i32,
|
||||
background_blur: bool,
|
||||
always_on_top: bool,
|
||||
mini_queue_json: QString,
|
||||
mini_width: f32,
|
||||
mini_height: f32,
|
||||
@@ -169,6 +199,7 @@ impl Default for QbzMiniRust {
|
||||
open: false,
|
||||
surface: crate::mini_qt::initial_surface(),
|
||||
background_blur: crate::mini_qt::initial_background_blur(),
|
||||
always_on_top: crate::mini_qt::initial_always_on_top(),
|
||||
mini_queue_json: QString::from(crate::mini_qt::MINI_QUEUE_EMPTY),
|
||||
mini_width: crate::mini_qt::initial_width(),
|
||||
mini_height: crate::mini_qt::initial_height(),
|
||||
@@ -349,6 +380,64 @@ impl qbz_mini::QbzMini {
|
||||
exit_now(self);
|
||||
}
|
||||
|
||||
/// Contract A-10 — the capsule's `a-bg` toggle.
|
||||
///
|
||||
/// Read-flip-write through the GENERATED setter, so the change
|
||||
/// notification QML's backdrop gate depends on is emitted for free
|
||||
/// (`miniplayer.rs:328-330` does the same two steps).
|
||||
pub fn toggle_background_blur(mut self: Pin<&mut Self>) {
|
||||
let value = !self.background_blur;
|
||||
self.as_mut().set_background_blur(value);
|
||||
crate::mini_qt::save_background_blur(value);
|
||||
}
|
||||
|
||||
/// Contract A-11 / K1 — the capsule's `a-pin` toggle.
|
||||
///
|
||||
/// The reference has no counterpart to copy: §4.8's two probes found three
|
||||
/// COMMENTS promising the affordance and no code. Flipping this property
|
||||
/// re-evaluates `MiniWindow.qml`'s `flags` binding, which is where the
|
||||
/// `Qt.WindowStaysOnTopHint` term actually lives.
|
||||
pub fn toggle_always_on_top(mut self: Pin<&mut Self>) {
|
||||
let value = !self.always_on_top;
|
||||
self.as_mut().set_always_on_top(value);
|
||||
crate::mini_qt::save_always_on_top(value);
|
||||
}
|
||||
|
||||
/// Contract A-14 — the debounced resize persist.
|
||||
///
|
||||
/// Three things happen here and the ORDER matters. The §4.7 guards decide
|
||||
/// first (`surface >= 2` AND `height >= 320`, two different numbers from
|
||||
/// the 420 floor — §15 trap 17); then a dirty check drops the write when
|
||||
/// the clamped pair already matches what this object holds, which is what
|
||||
/// makes the write-back below safe; then the clamped pair goes to BOTH the
|
||||
/// A-6b properties and the prefs document.
|
||||
///
|
||||
/// The write-back is A-14's own requirement: the window is sized from
|
||||
/// `miniWidth`/`miniHeight`, so a condensed -> expanded switch inside one
|
||||
/// session restores the size the user last dragged without re-reading the
|
||||
/// file. It also closes the loop it appears to open — the properties feed
|
||||
/// the window's size binding, which feeds the debounce, which calls back in
|
||||
/// here with the same numbers and stops at the dirty check.
|
||||
///
|
||||
/// The reference has NO dirty check and does a whole-file load+save per
|
||||
/// qualifying resize event (`miniplayer.rs:455-463`) — §13-D14 — where its
|
||||
/// own main window carries one for exactly this reason
|
||||
/// (`crates/qbz/src/main.rs:1392-1393`).
|
||||
pub fn save_geometry(mut self: Pin<&mut Self>, width: f32, height: f32) {
|
||||
let Some((w, h)) = crate::mini_qt::geometry_to_persist(self.surface, width, height) else {
|
||||
return;
|
||||
};
|
||||
// Sub-pixel equality, the same shape as the main window's `> 0.5`
|
||||
// guard: the WM emits many no-op geometry events and each miss here is
|
||||
// a blocking JSON read+write on the GUI thread (§7-M10).
|
||||
if (self.mini_width - w).abs() < 0.5 && (self.mini_height - h).abs() < 0.5 {
|
||||
return;
|
||||
}
|
||||
self.as_mut().set_mini_width(w);
|
||||
self.as_mut().set_mini_height(h);
|
||||
crate::mini_qt::save_geometry(w, h);
|
||||
}
|
||||
|
||||
/// Contract A-12 — close the APP from the mini's red X.
|
||||
///
|
||||
/// The order is the reference's (`crates/qbz/src/miniplayer.rs:235-251`):
|
||||
@@ -385,14 +474,16 @@ impl qbz_mini::QbzMini {
|
||||
// `QbzMini.surface = n` uses, so the keyboard path cannot skip the
|
||||
// persist or the surface-3 queue kick.
|
||||
//
|
||||
// `clamp_to_implemented` is the TEMPORARY guard that keeps `1`, `4` and
|
||||
// `5` from landing the user on a blank card while the micro footer
|
||||
// (B3) and the queue + lyrics surfaces (B4) do not exist yet. It is
|
||||
// deleted by those blocks — see the function's own doc. The keys stay
|
||||
// BOUND rather than being removed from the table, because the table is
|
||||
// the user's rebindable one and the cheatsheet already lists all five.
|
||||
// `clamp_to_implemented` is the TEMPORARY guard that keeps a key from
|
||||
// landing the user on a blank card while its surface does not exist
|
||||
// yet. **B3 has taken its own entry out**: the micro card IS the footer
|
||||
// this block landed, so `mini.micro` writes 0 unclamped. Only `4` and
|
||||
// `5` are still clamped, and B4 deletes the function with them — see
|
||||
// the function's own doc. The keys stay BOUND rather than being removed
|
||||
// from the table, because the table is the user's rebindable one and
|
||||
// the cheatsheet already lists all five.
|
||||
match action.id {
|
||||
"mini.micro" => apply_surface(self, crate::mini_qt::clamp_to_implemented(0)),
|
||||
"mini.micro" => apply_surface(self, 0),
|
||||
"mini.compact" => apply_surface(self, 1),
|
||||
"mini.artwork" => apply_surface(self, 2),
|
||||
"mini.queue" => apply_surface(self, crate::mini_qt::clamp_to_implemented(3)),
|
||||
|
||||
@@ -32,11 +32,21 @@ const KEY_WIDTH: &str = "mini_width";
|
||||
const KEY_HEIGHT: &str = "mini_height";
|
||||
const KEY_BACKGROUND_BLUR: &str = "mini_background_blur";
|
||||
const KEY_DEFAULT_VIEW: &str = "mini_default_view";
|
||||
/// K1 / contract §4.7 — the ONE key in this table with no Slint reader.
|
||||
/// Always-on-top does not exist in the reference (§4.8: three comments promise
|
||||
/// it, zero code implements it), so this key is written and read by the Qt side
|
||||
/// alone. It still goes through `save_pref`'s additive single-key patch like
|
||||
/// every other one, because the DOCUMENT is co-owned even where the key is not.
|
||||
const KEY_ALWAYS_ON_TOP: &str = "mini_always_on_top";
|
||||
|
||||
/// Defaults, from `crates/qbz/src/ui_prefs.rs:677-688`.
|
||||
const DEFAULT_SURFACE: i32 = 2;
|
||||
const DEFAULT_WIDTH: f32 = 380.0;
|
||||
const DEFAULT_HEIGHT: f32 = 540.0;
|
||||
/// K1: always-on-top ships DEFAULT ON (contract §4.7's last row). A miniplayer
|
||||
/// that sinks behind the browser is the class-defining complaint, and the
|
||||
/// reference's own comments describe pinning as intended behaviour.
|
||||
const DEFAULT_ALWAYS_ON_TOP: bool = true;
|
||||
|
||||
/// Resize-persistence guards and floors (`miniplayer.rs:455`, `:459`, `:461-462`).
|
||||
/// **Note the two different numbers**: 320 is the mid-transition-frame filter,
|
||||
@@ -175,9 +185,9 @@ pub(crate) fn queue_row_target(has_current: bool, row: i32) -> MiniRowTarget {
|
||||
/// Port of `miniplayer.rs:455-463`: persist only while an EXPANDED surface is
|
||||
/// shown (micro/compact sizes are constants), ignore sub-320 heights (a
|
||||
/// mid-transition frame), and clamp what is stored to 340 x 420.
|
||||
// Consumed by `QbzMini.save_geometry` (contract row A-14), which lands with the
|
||||
// window's resize debounce rather than with this projection.
|
||||
#[allow(dead_code)]
|
||||
///
|
||||
/// Consumed by `QbzMini.save_geometry` (contract row A-14), which arrived with
|
||||
/// block B3's 400 ms resize debounce in `MiniWindow.qml`.
|
||||
pub(crate) fn geometry_to_persist(surface: i32, width: f32, height: f32) -> Option<(f32, f32)> {
|
||||
if surface < EXPANDED_SURFACE_FLOOR || height < TRANSITION_HEIGHT_FILTER {
|
||||
return None;
|
||||
@@ -232,9 +242,14 @@ pub(crate) fn initial_surface() -> i32 {
|
||||
/// surface: B3 drops 0 from the list, B4 drops 3 and 4, and the last one to go
|
||||
/// deletes this function. The keyboard arms in `mini_bridge::key_pressed` are
|
||||
/// clamped by the same rule and lose it the same way.
|
||||
///
|
||||
/// **B3 has dropped 0**: the micro card IS `MiniFooter.qml` in mode 2, so
|
||||
/// surface 0 now renders and its keyboard arm (`mini.micro`) is unclamped.
|
||||
/// Only 3 (queue) and 4 (lyrics) remain, and B4 deletes this function with
|
||||
/// them.
|
||||
pub(crate) fn clamp_to_implemented(surface: i32) -> i32 {
|
||||
match surface {
|
||||
1 | 2 => surface,
|
||||
0 | 1 | 2 => surface,
|
||||
_ => DEFAULT_SURFACE,
|
||||
}
|
||||
}
|
||||
@@ -243,6 +258,11 @@ pub(crate) fn initial_background_blur() -> bool {
|
||||
crate::settings_qt::pref_bool(KEY_BACKGROUND_BLUR, false)
|
||||
}
|
||||
|
||||
/// K1 — the seed for `QbzMini.always_on_top` (contract A-5).
|
||||
pub(crate) fn initial_always_on_top() -> bool {
|
||||
crate::settings_qt::pref_bool(KEY_ALWAYS_ON_TOP, DEFAULT_ALWAYS_ON_TOP)
|
||||
}
|
||||
|
||||
pub(crate) fn initial_width() -> f32 {
|
||||
crate::settings_qt::pref_f32(KEY_WIDTH, DEFAULT_WIDTH)
|
||||
}
|
||||
@@ -259,6 +279,29 @@ pub(crate) fn save_surface(surface: i32) {
|
||||
crate::settings_qt::save_pref(KEY_SURFACE, serde_json::Value::Number(surface.into()));
|
||||
}
|
||||
|
||||
/// Persist the card backdrop toggle (`QbzMini.toggle_background_blur`, A-10).
|
||||
pub(crate) fn save_background_blur(value: bool) {
|
||||
crate::settings_qt::save_pref(KEY_BACKGROUND_BLUR, serde_json::Value::Bool(value));
|
||||
}
|
||||
|
||||
/// Persist the pin (`QbzMini.toggle_always_on_top`, A-11 / K1).
|
||||
pub(crate) fn save_always_on_top(value: bool) {
|
||||
crate::settings_qt::save_pref(KEY_ALWAYS_ON_TOP, serde_json::Value::Bool(value));
|
||||
}
|
||||
|
||||
/// Persist the expanded geometry, ALREADY clamped by `geometry_to_persist`.
|
||||
///
|
||||
/// Two single-key patches, not one struct write: `ui_prefs.json` is co-owned
|
||||
/// with the shipping Slint app and `save_pref` is the additive path (§15 trap
|
||||
/// 16). The reference writes the same two keys from its resize handler
|
||||
/// (`miniplayer.rs:460-463`) — with a whole-file load+save per event, which is
|
||||
/// exactly what the 400 ms debounce in `MiniWindow.qml` exists to keep off the
|
||||
/// Qt GUI thread (§7-M10, §13-D14).
|
||||
pub(crate) fn save_geometry(width: f32, height: f32) {
|
||||
crate::settings_qt::save_pref(KEY_WIDTH, serde_json::json!(width));
|
||||
crate::settings_qt::save_pref(KEY_HEIGHT, serde_json::json!(height));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -380,6 +423,21 @@ mod tests {
|
||||
assert_eq!(geometry_to_persist(4, 500.0, 700.0), Some((500.0, 700.0)));
|
||||
}
|
||||
|
||||
/// The temporary clamp's own boundary, asserted so the block that removes
|
||||
/// an entry cannot forget the arm it owns. B3 landed the micro footer, so
|
||||
/// 0 passes; 3 and 4 still fall back to the artwork surface until B4.
|
||||
#[test]
|
||||
fn clamp_admits_every_implemented_surface() {
|
||||
assert_eq!(clamp_to_implemented(0), 0);
|
||||
assert_eq!(clamp_to_implemented(1), 1);
|
||||
assert_eq!(clamp_to_implemented(2), 2);
|
||||
assert_eq!(clamp_to_implemented(3), DEFAULT_SURFACE);
|
||||
assert_eq!(clamp_to_implemented(4), DEFAULT_SURFACE);
|
||||
// Out-of-range values from a co-owned prefs document, too.
|
||||
assert_eq!(clamp_to_implemented(-1), DEFAULT_SURFACE);
|
||||
assert_eq!(clamp_to_implemented(9), DEFAULT_SURFACE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_view_pref_maps_to_the_slint_surface_table() {
|
||||
// miniplayer.rs:110-119 — "remember"/unknown fall back to the last
|
||||
|
||||