Files
qbz/crates/qbz-qt/qml/immersive/ImmersiveHeader.qml
T
vicrodh aeeb55198d feat(qt): add minimize and maximize to the immersive window capsule
The window-controls capsule only offered fullscreen and exit; on
platforms without native traffic lights there was no way to minimize or
maximize from immersive. Non-macOS expands to four slots (minus,
square/copy for maximize/restore, fullscreen, X) with the same
expansion-bool gating; macOS keeps the two-slot capsule since the native
controls already cover the rest.

cc
2026-08-31 15:03:25 -06:00

660 lines
28 KiB
QML

// ImmersiveHeader — the immersive chrome band (ImmersiveView.slint:470-1184,
// 1618-1685), mounted by ImmersiveView at x=24 / y=16 / height=36 /
// width=parent-48 (§5.2 of the 2026-08-02 immersive-port contract).
//
// Three zones:
// LEFT — a 122x36 previous | picker | next capsule + a wide three-column
// popup (two FOCUS columns, one SPLIT). Every row
// fires QbzImmersive.setView(...); the data-panel rows also fire
// their §5.5 entry load (Queue / Track Info / Suggestions —
// Lyrics needs nothing: Qt fetches lyrics automatically per
// track, §5.5).
// CENTER — the QbzLineEdit-based search field, absolutely centered,
// min(band.width, 420) wide. The cortinilla itself ships in B5;
// the Rust search invokables are the inert B1 surface until then
// (that is CORRECT — do not implement them here).
// RIGHT — the window-controls capsule: a 44px circle (sliders glyph) that
// expands LEFTWARD to 112px on hover (150ms ease-in-out), revealing
// the fullscreen toggle (the FIRST FullScreen write in the Qt
// frontend, §5.2) and the X exit.
//
// The whole band fades with the auto-hide chrome (ImmersiveView drives the
// opacity); nothing here knows about the hide timer.
import QtQuick
import QtQuick.Controls
import QtQuick.Window
import com.blitzfc.qbz
import "../controls"
import "../theme"
Item {
id: header
// §5.4 text-input gate: the view root reads this to inert the seek
// arrows while the search field has focus (D16 — the QbzLineEdit
// `fieldActive` seam).
readonly property bool searchActive: searchField.fieldActive
/// macOS with the NATIVE traffic lights floating over the client area —
/// the only configuration where the band's left edge is already occupied.
/// Same predicate the view's old `chromeLeftInset` used.
readonly property bool trailingTrigger:
QbzShell.isMacos && !QbzShell.systemTitleBar
// Persistent custom-chrome drag strip. Tauri kept a top-level
// data-tauri-drag-region over Immersive, but the Qt port never brought
// that surface across; the full-coverage click blocker therefore left no
// route to start a system move. Declared FIRST so every real control below
// wins hit-testing, matching HeaderBar.qml's proven drag pattern.
MouseArea {
anchors.fill: parent
// Immersive must remain draggable whenever native chrome is absent,
// including the explicit hide-titlebar preference: there is no second
// title strip behind this overlay to rescue that configuration.
enabled: !QbzShell.systemTitleBar
&& header.Window.window !== null
&& header.Window.window.visibility !== Window.FullScreen
acceptedButtons: Qt.LeftButton
property bool dragStarted: false
onPressed: dragStarted = false
onReleased: dragStarted = false
onPositionChanged: {
if (pressed && !dragStarted && header.Window.window) {
dragStarted = true
header.Window.window.startSystemMove()
}
}
onDoubleClicked: {
var w = header.Window.window
if (w)
w.visibility = w.visibility === Window.Maximized
? Window.Windowed : Window.Maximized
}
}
// --- LEFT: previous | view menu | next -------------------------------
Rectangle {
id: viewTrigger
// MACOS ONLY: over on the right, beside the capsule. Everywhere else it
// stays at the band's left edge, where it has always been.
//
// The move exists for ONE reason — on macOS the band's left edge is the
// native traffic lights' corner (x ~ 7-85, this pill's exact spot), so
// something had to give. Nothing else has traffic lights, so nothing
// else has the problem, and moving the control there was a change with
// no cause. (It shipped unconditional first; this is that fix.)
//
// `x` rather than swapped anchors: toggling between `anchors.left` and
// `anchors.right` means clearing one with `undefined` on every flip,
// and the position is one expression either way.
//
// The trailing position RESERVES THE CAPSULE'S EXPANDED WIDTH (112)
// plus a 12px gap instead of tracking its live left edge. The capsule
// grows leftward on hover, so tracking it would slide this pill
// sideways every time the pointer crossed the other one — motion with
// no meaning. Reserving the maximum keeps both still.
x: header.trailingTrigger ? header.width - 112 - 12 - width : 0
anchors.verticalCenter: parent.verticalCenter
width: 122
height: 36
// Half the height — same silhouette as the capsule it now sits beside.
radius: height / 2
// Slint glass #00000080 / border #ffffff2e (RRGGBBAA), converted.
color: "#80000000"
border.width: 1
border.color: "#2effffff"
clip: true
Rectangle {
width: 38
height: parent.height
color: previousArea.containsMouse ? "#18ffffff" : "transparent"
QbzIcon {
name: "chevron-left"
width: 15
height: 15
anchors.centerIn: parent
tintName: "white"
}
MouseArea {
id: previousArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: viewMenu.cycle(-1)
}
}
Rectangle {
x: 38
width: 46
height: parent.height
color: menuArea.containsMouse ? "#18ffffff" : "transparent"
QbzIcon {
name: "layout-grid"
width: 16
height: 16
anchors.centerIn: parent
tintName: "white"
}
MouseArea {
id: menuArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: viewMenu.openBelowLeft(viewTrigger)
}
}
Rectangle {
x: 84
width: 38
height: parent.height
color: nextArea.containsMouse ? "#18ffffff" : "transparent"
QbzIcon {
name: "chevron-right"
width: 15
height: 15
anchors.centerIn: parent
tintName: "white"
}
MouseArea {
id: nextArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: viewMenu.cycle(1)
}
}
Rectangle { x: 38; width: 1; height: parent.height; color: "#24ffffff" }
Rectangle { x: 83; width: 1; height: parent.height; color: "#24ffffff" }
}
QbzContextMenu {
id: viewMenu
// The old single column could run past a short display. The owner
// explicitly chose a wider three-column picker: two balanced FOCUS
// columns and one SPLIT column.
menuWidth: 660
contentSpacing: 0
// §5.2 popup chrome: #0b0b0ef7, radius 16 (Slint RRGGBBAA converted).
background: Rectangle {
color: "#f70b0b0e"
radius: 16
border.width: 1
border.color: "#2effffff"
}
// One row model entry: vm 0 = FOCUS (mode m), vm 1 = SPLIT (panel sp).
// Block A1 adds `sc`: >= 0 = a SHADER SCENE row (the Slint shader
// rows at ImmersiveView.slint:654-711), -1/absent = a panel row.
// `entry` names the §5.5 entry load fired alongside setView:
// "queue" -> QbzQueue.queuePanelOpened()
// "trackinfo" -> QbzAlbum.openTrackInfo(QbzPlayer.npTrackId)
// "suggestions" -> QbzSuggestions.load(QbzPlayer.npTrackId) (B4)
// "" -> none (Lyrics rows: Qt fetches lyrics
// automatically per track, §5.5).
readonly property var focusRows: {
var rows = [
{ "vm": 0, "m": 0, "sp": -1, "entry": "",
"label": QbzSession.tr("Album Reactive", QbzSession.trRev) },
{ "vm": 0, "m": 1, "sp": -1, "entry": "",
"label": QbzSession.tr("Static", QbzSession.trRev) },
{ "vm": 0, "m": 2, "sp": -1, "entry": "",
"label": QbzSession.tr("Coverflow", QbzSession.trRev) },
{ "vm": 0, "m": 4, "sp": -1, "entry": "",
"label": QbzSession.tr("Lyrics", QbzSession.trRev) },
{ "vm": 0, "m": 5, "sp": -1, "entry": "queue",
"label": QbzSession.tr("Queue", QbzSession.trRev) },
{ "vm": 0, "m": 3, "sp": -1, "entry": "",
"label": QbzSession.tr("Spectrum", QbzSession.trRev) },
{ "vm": 0, "m": 6, "sp": -1, "entry": "",
"label": QbzSession.tr("Wave Bed", QbzSession.trRev) },
{ "vm": 0, "m": 9, "sp": -1, "entry": "",
"label": QbzSession.tr("Reactive Rings", QbzSession.trRev) },
]
if (QbzShell.gpuTier) {
rows.push({ "vm": 0, "m": 7, "sp": -1, "entry": "",
"label": QbzSession.tr("Goniometer", QbzSession.trRev) })
rows.push({ "vm": 0, "m": 8, "sp": -1, "entry": "",
"label": QbzSession.tr("Oscilloscope", QbzSession.trRev) })
}
if (QbzShell.shaderScenesAvailable) {
rows.push(
{ "vm": 0, "m": -1, "sp": -1, "entry": "", "sc": 1,
"label": QbzSession.tr("Plasma", QbzSession.trRev) },
{ "vm": 0, "m": -1, "sp": -1, "entry": "", "sc": 2,
"label": QbzSession.tr("Tunnel", QbzSession.trRev) },
{ "vm": 0, "m": -1, "sp": -1, "entry": "", "sc": 3,
"label": QbzSession.tr("Aurora", QbzSession.trRev) },
{ "vm": 0, "m": -1, "sp": -1, "entry": "", "sc": 7,
"label": QbzSession.tr("Ambient", QbzSession.trRev) },
{ "vm": 0, "m": -1, "sp": -1, "entry": "", "sc": 4,
"label": QbzSession.tr("Spectral Ribbon", QbzSession.trRev) },
{ "vm": 0, "m": -1, "sp": -1, "entry": "", "sc": 5,
"label": QbzSession.tr("Line Bed", QbzSession.trRev) },
{ "vm": 0, "m": -1, "sp": -1, "entry": "", "sc": 8,
"label": QbzSession.tr("Tunnel Flow", QbzSession.trRev) })
}
return rows
}
readonly property var splitRows: [
{ "vm": 1, "m": -1, "sp": 0, "entry": "",
"label": QbzSession.tr("Lyrics", QbzSession.trRev) },
{ "vm": 1, "m": -1, "sp": 1, "entry": "trackinfo",
"label": QbzSession.tr("Track Info", QbzSession.trRev) },
{ "vm": 1, "m": -1, "sp": 2, "entry": "suggestions",
"label": QbzSession.tr("Suggestions", QbzSession.trRev) },
{ "vm": 1, "m": -1, "sp": 3, "entry": "queue",
"label": QbzSession.tr("Queue", QbzSession.trRev) },
{ "vm": 1, "m": -1, "sp": 4, "entry": "",
"label": QbzSession.tr("Now Playing", QbzSession.trRev) },
{ "vm": 1, "m": -1, "sp": 5, "entry": "",
"label": QbzSession.tr("Now Playing", QbzSession.trRev)
+ " · " + QbzSession.tr("Lyrics", QbzSession.trRev) },
]
readonly property int focusCut: Math.ceil(focusRows.length / 2)
function rowIsActive(row) {
if (row.sc !== undefined && row.sc >= 0)
return QbzImmersive.viewMode === 0
&& QbzShaderScene.scene === row.sc
if (row.vm === 0)
return QbzImmersive.viewMode === 0
&& QbzImmersive.mode === row.m
&& QbzShaderScene.scene === 0
return QbzImmersive.viewMode === 1
&& QbzImmersive.splitPanel === row.sp
&& QbzShaderScene.scene === 0
}
function fireRow(row) {
if (row.sc !== undefined && row.sc >= 0) {
// Shader scene row: FOCUS view + scene N (Slint sets
// view-mode=0 and shader-mode=N and leaves `mode` alone;
// setView is the ONLY mode mutator here, §3.2).
if (row.sc > 0 && QbzImmersive.viewMode !== 0)
QbzImmersive.setView(0, QbzImmersive.mode, QbzImmersive.splitPanel)
QbzShaderScene.scene = row.sc
viewMenu.close()
return
}
// Panel rows turn the scene OFF — every Slint panel row sets
// shader-mode = 0 (:586-650) — then setView as before.
QbzShaderScene.scene = 0
QbzImmersive.setView(
row.vm === 0 ? 0 : 1,
row.vm === 0 ? row.m : QbzImmersive.mode,
row.vm === 0 ? QbzImmersive.splitPanel : row.sp)
if (row.entry === "queue")
QbzQueue.queuePanelOpened()
else if (row.entry === "trackinfo")
QbzAlbum.openTrackInfo(QbzPlayer.npTrackId)
else if (row.entry === "suggestions")
QbzSuggestions.load(QbzPlayer.npTrackId)
viewMenu.close()
}
// The columns are presentation only. Arrow navigation is ONE circular
// sequence in menu order: every FOCUS panel/scene, then every SPLIT
// panel, crossing the family boundary in both directions.
function cycle(direction) {
var rows = focusRows.concat(splitRows)
if (rows.length === 0)
return
var current = -1
for (var i = 0; i < rows.length; ++i) {
if (rowIsActive(rows[i])) {
current = i
break
}
}
var next = current < 0 ? (direction < 0 ? rows.length - 1 : 0)
: (current + direction + rows.length) % rows.length
fireRow(rows[next])
}
Component {
id: menuRow
Rectangle {
required property var modelData
// A1: shader rows (sc >= 0) are active when their scene is
// on; FOCUS panel rows additionally require the scene OFF
// (Slint: `active: is-focus && sh == 0 && md == N`).
readonly property bool active: viewMenu.rowIsActive(modelData)
width: parent ? parent.width : 0
height: 33
radius: 8
// Active row #ffffff26 (Slint RRGGBBAA converted).
color: active ? "#26ffffff"
: (rowArea.containsMouse ? "#14ffffff" : "transparent")
Text {
anchors.left: parent.left
anchors.leftMargin: 10
anchors.right: dot.left
anchors.rightMargin: 8
height: parent.height
text: modelData.label
color: "#f2ffffff"
font.pixelSize: 13
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
// Trailing 7px active dot (:529-537).
Rectangle {
id: dot
visible: parent.active
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
width: 7
height: 7
radius: 3.5
color: "#ffffff"
}
MouseArea {
id: rowArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: viewMenu.fireRow(modelData)
}
}
}
// Group headers: FOCUS spans its two balanced columns; SPLIT owns the
// third. The row below determines the popup's height from its tallest
// column, so the picker never needs its own scroll area.
Row {
width: viewMenu.availableWidth
height: 26
spacing: 8
Text {
width: (parent.width - 16) / 3 * 2 + 8
height: parent.height
leftPadding: 10
verticalAlignment: Text.AlignVCenter
text: QbzSession.tr("FOCUS", QbzSession.trRev)
color: "#80ffffff"
font.pixelSize: 11
font.weight: Font.Bold
font.letterSpacing: 1.5
}
Text {
width: (parent.width - 16) / 3
height: parent.height
leftPadding: 10
verticalAlignment: Text.AlignVCenter
text: QbzSession.tr("SPLIT", QbzSession.trRev)
color: "#80ffffff"
font.pixelSize: 11
font.weight: Font.Bold
font.letterSpacing: 1.5
}
}
Row {
id: menuColumns
width: viewMenu.availableWidth
spacing: 8
readonly property real columnWidth: (width - spacing * 2) / 3
Column {
width: menuColumns.columnWidth
spacing: 1
Repeater {
model: viewMenu.focusRows.slice(0, viewMenu.focusCut)
delegate: menuRow
}
}
Column {
width: menuColumns.columnWidth
spacing: 1
Repeater {
model: viewMenu.focusRows.slice(viewMenu.focusCut)
delegate: menuRow
}
}
Column {
width: menuColumns.columnWidth
spacing: 1
Repeater {
model: viewMenu.splitRows
delegate: menuRow
}
}
}
}
// --- CENTER: the search field -----------------------------------------
// QbzLineEdit-based (§5.2). 36px tall, min(band.width, 420) wide,
// absolutely centered in the band. The 220ms debounce + >=2-char gate
// live in the Rust immersive controller (§3.4, B5) — the field reports
// every keystroke.
QbzLineEdit {
id: searchField
searchMode: true
anchors.horizontalCenter: parent.horizontalCenter
anchors.verticalCenter: parent.verticalCenter
width: Math.min(header.width, 420)
height: 36
placeholder: QbzSession.tr("Search", QbzSession.trRev)
// Two-way with the bridge (the B5 controller clears it on
// activation); the B1 surface is inert until then — correct.
text: QbzImmersive.searchInputText
onEdited: function (value) { QbzImmersive.searchLive(value) }
onAccepted: function (value) {
QbzImmersive.searchRowActivated(QbzImmersive.immSearchSelectedIndex)
}
// Up/Down on the field ROOT (Qt handles them here; Slint rejects
// them in the field because winit owns them — D15, same observable
// behavior). Escape is NOT handled here: the inner input declines it
// (QbzLineEdit non-expandable arm) and it propagates to the view
// root, whose handler dismisses search first / exits otherwise
// (§3.4).
Keys.onPressed: function (event) {
if (event.key === Qt.Key_Down) {
QbzImmersive.searchMoveSelection(1)
event.accepted = true
} else if (event.key === Qt.Key_Up) {
QbzImmersive.searchMoveSelection(-1)
event.accepted = true
}
}
}
// "↵ Enter" hint, visible only while the cortinilla is open (:985-993) —
// inert in B2 because immSearchOpen cannot flip true before B5.
Text {
visible: QbzImmersive.immSearchOpen
anchors.left: searchField.right
anchors.leftMargin: 10
anchors.verticalCenter: searchField.verticalCenter
text: "↵ " + QbzSession.tr("Enter", QbzSession.trRev)
color: "#bfffffff"
font.pixelSize: 12
}
// --- RIGHT: the window-controls capsule --------------------------------
// Collapsed: 44px circle, sliders glyph. Expands LEFTWARD (150ms
// ease-in-out, anchored right) revealing the window controls and the X
// exit. On macOS the native traffic lights already carry
// minimize/maximize, so the capsule keeps only fullscreen + X (112px);
// everywhere else it also reveals minimize and maximize/restore (184px —
// 2026-08-31 visual cleanup; the Slint had them parked, :1173-1183).
//
// The expansion is the Slint state machine ported 1:1
// (ImmersiveView.slint:1057-1184): `expanded = pinned || anyHover` is a
// BOOL and the buttons gate on that bool — NEVER on the animated width.
// (2026-08-15 flicker root cause: gating on `capsule.width > 100`
// mid-animation let the binding flip while the capsule was traveling,
// and the expanding left edge could cross a resting cursor frame by
// frame — a visible 44↔112 bounce. Clicking the circle toggles `pinned`
// (Slint circle-ta, :1159-1162), which is what "stays 100% open" means.)
Rectangle {
id: capsule
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
height: 36
// 40px lead-in + one 36px slot per button (2 on macOS, 4 elsewhere).
readonly property int buttonSlots: QbzShell.isMacos ? 2 : 4
width: expanded ? 40 + 36 * buttonSlots : 44
radius: 18
color: "#80000000"
border.width: 1
border.color: "#2effffff"
clip: true
Behavior on width {
NumberAnimation { duration: 150; easing.type: Easing.InOutQuad }
}
// Click-pin state; survives the auto-hide (Slint keeps it too).
property bool pinned: false
// Hover over ANY part of the capsule: the background or either
// button (their hover areas extend 9px past the glyphs).
readonly property bool anyHover: capHover.containsMouse
|| minArea.containsMouse || maxArea.containsMouse
|| fsArea.containsMouse || xArea.containsMouse
readonly property bool expanded: pinned || anyHover
MouseArea {
id: capHover
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
// Collapsed-circle click = pin open (Slint circle-ta). Clicking
// the expanded body un-pins; hover still holds it open, so this
// can never collapse anything out from under the cursor.
onClicked: capsule.pinned = !capsule.pinned
}
// Collapsed-state glyph (fades out as the capsule opens).
QbzIcon {
name: "sliders-horizontal"
width: 16
height: 16
anchors.centerIn: parent
tintName: "white"
opacity: capsule.expanded ? 0 : 1
Behavior on opacity { NumberAnimation { duration: 100 } }
}
// Minimize + maximize/restore — non-macOS only (see the header note);
// both gate on the expansion BOOL exactly like the pair below.
readonly property bool platformButtons: !QbzShell.isMacos
QbzIcon {
name: "minus"
width: 15
height: 15
visible: capsule.platformButtons
x: capsule.width - 144
anchors.verticalCenter: parent.verticalCenter
tintName: minArea.containsMouse ? "white" : "muted"
opacity: capsule.expanded && capsule.platformButtons ? 1 : 0
Behavior on opacity { NumberAnimation { duration: 100 } }
MouseArea {
id: minArea
anchors.fill: parent
anchors.margins: -9
enabled: capsule.expanded && capsule.platformButtons
hoverEnabled: enabled
cursorShape: Qt.PointingHandCursor
onClicked: {
var w = header.Window.window
if (w)
w.showMinimized()
}
}
}
QbzIcon {
name: (header.Window.window
&& header.Window.window.visibility === Window.Maximized)
? "copy" : "square"
width: 14
height: 14
visible: capsule.platformButtons
x: capsule.width - 108
anchors.verticalCenter: parent.verticalCenter
tintName: maxArea.containsMouse ? "white" : "muted"
opacity: capsule.expanded && capsule.platformButtons ? 1 : 0
Behavior on opacity { NumberAnimation { duration: 100 } }
MouseArea {
id: maxArea
anchors.fill: parent
anchors.margins: -9
enabled: capsule.expanded && capsule.platformButtons
hoverEnabled: enabled
cursorShape: Qt.PointingHandCursor
onClicked: {
var w = header.Window.window
if (!w)
return
w.visibility = w.visibility === Window.Maximized
? Window.Windowed : Window.Maximized
}
}
}
// Fullscreen toggle (glyph swaps on state) + X exit — revealed by the
// expansion, pinned to the capsule's right half.
QbzIcon {
id: fsGlyph
name: (header.Window.window
&& header.Window.window.visibility === Window.FullScreen)
? "minimize-2" : "maximize-2"
width: 15
height: 15
x: capsule.width - 72
anchors.verticalCenter: parent.verticalCenter
tintName: fsArea.containsMouse ? "white" : "muted"
opacity: capsule.expanded ? 1 : 0
Behavior on opacity { NumberAnimation { duration: 100 } }
MouseArea {
id: fsArea
anchors.fill: parent
anchors.margins: -9
// Gated on the expansion BOOL: the two button MouseAreas are
// INVISIBLE while collapsed but would still cover the 44px
// circle (the -9 margins make the X's hit area span almost
// all of it), so a click on the collapsed capsule exited
// immersive instead of pinning it.
enabled: capsule.expanded
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
var w = header.Window.window
if (!w)
return
w.visibility = w.visibility === Window.FullScreen
? Window.Windowed : Window.FullScreen
}
}
}
QbzIcon {
name: "x"
width: 14
height: 14
x: capsule.width - 36
anchors.verticalCenter: parent.verticalCenter
tintName: xArea.containsMouse ? "white" : "muted"
opacity: capsule.expanded ? 1 : 0
Behavior on opacity { NumberAnimation { duration: 100 } }
MouseArea {
id: xArea
anchors.fill: parent
anchors.margins: -9
enabled: capsule.expanded
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
// Exit path 2 (§5.4) — every exit funnels through the bridge's
// open setter.
onClicked: QbzImmersive.open = false
}
}
}
}