diff --git a/shell/plugins/panels/bluetooth/Panel.qml b/shell/plugins/panels/bluetooth/Panel.qml index 8e036595..a11d2bf0 100644 --- a/shell/plugins/panels/bluetooth/Panel.qml +++ b/shell/plugins/panels/bluetooth/Panel.qml @@ -122,6 +122,37 @@ Panel { return Model.sectionDevices(deviceGroups, section) } + // The scrollable half of the panel — remembered devices, then whatever the + // scan turned up — flattened into one model so a ListView can own the + // viewport. Each entry carries the section it came from, which is what lets + // the delegate and the cursor keep working in section-relative terms. + readonly property var scrollRows: { + var rows = [] + for (var k = 0; k < knownDevices.length; k++) + rows.push({ dev: knownDevices[k], section: "known", indexInSection: k }) + if (sectionVisible("discovered")) + for (var d = 0; d < discoveredDevices.length; d++) + rows.push({ dev: discoveredDevices[d], section: "discovered", indexInSection: d }) + return rows + } + + // Flat position of the keyboard cursor, or -1 while it sits on the hero or + // in the connected list (both of which live outside the scroll area). + readonly property int scrollRowIndex: { + if (focusSection !== "known" && focusSection !== "discovered") return -1 + for (var i = 0; i < scrollRows.length; i++) + if (scrollRows[i].section === focusSection && scrollRows[i].indexInSection === selectedIndex) return i + return -1 + } + + // A row opens a section when it is the first of its kind in the flat list. + function scrollSectionTitle(index) { + var rows = scrollRows + if (index < 0 || index >= rows.length) return "" + if (index > 0 && rows[index - 1].section === rows[index].section) return "" + return rows[index].section === "known" ? "PAIRED" : "AVAILABLE" + } + function audioSinks() { var sinks = [] for (var i = 0; i < pipewireNodes.length; i++) { @@ -388,22 +419,6 @@ Panel { onDiscoveredDevicesChanged: { reselectFocusedDevice(); syncPendingActions() } onVisibleSectionsChanged: clampCursor() - // Keep the keyboard-focused row inside the visible viewport of the device - // Flickable. Each DeviceRow calls this when it gains hasCursor. Without - // it, j/k can walk the selection off-screen in a long device list. - function ensureCursorVisible(item) { - if (!item || !deviceFlick) return - var pt = item.mapToItem(deviceFlick.contentItem, 0, 0) - var top = pt.y - var bottom = top + (item.height || 0) - var viewTop = deviceFlick.contentY - var viewBottom = viewTop + deviceFlick.height - var margin = 6 - if (top < viewTop + margin) deviceFlick.contentY = Math.max(0, top - margin) - else if (bottom > viewBottom - margin) - deviceFlick.contentY = bottom + margin - deviceFlick.height - } - function clampCursor() { var sections = visibleSections // "header" is virtual and never appears in visibleSections, so it has to @@ -645,92 +660,86 @@ Panel { } PanelSeparator { - visible: root.connectedDevices.length > 0 - && (root.knownDevices.length > 0 - || (root.adapter && root.adapter.discovering && root.discoveredDevices.length > 0)) + visible: root.connectedDevices.length > 0 && root.scrollRows.length > 0 foreground: root.bar.foreground } - Flickable { - id: deviceFlick + // ListView, not a Flickable: it owns the scroll position, so it keeps + // the current row visible on j/k, re-clamps itself when discovery + // shortens the list, and — because Contain only moves when a row is + // actually clipped — never lurches under a hovering mouse. + ListView { + id: deviceListView width: parent.width - height: Math.min(deviceList.implicitHeight, Style.space(400)) - contentWidth: width - contentHeight: deviceList.implicitHeight + height: Math.min(contentHeight, Style.space(400)) + spacing: Style.space(10) clip: true boundsBehavior: Flickable.StopAtBounds interactive: contentHeight > height ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } - Column { - id: deviceList - width: parent.width - spacing: Style.space(10) + model: root.scrollRows + currentIndex: root.scrollRowIndex + // Deferred by a turn. Called straight out of the signal the position + // does not take — verified with the cursor six rows down and + // contentY still 0 — because scrollRows is rebuilt every time + // discovery reports, and swapping the model resets the view out from + // under the call. Network's list is stable enough not to need this. + onCurrentIndexChanged: if (currentIndex >= 0) Qt.callLater(keepCurrentVisible) + function keepCurrentVisible() { + if (currentIndex >= 0) positionViewAtIndex(currentIndex, ListView.Contain) + } - // Remembered devices. - PanelSectionHeader { - visible: root.knownDevices.length > 0 - text: "PAIRED" - foreground: root.bar.foreground - fontFamily: root.bar.fontFamily - } + delegate: Item { + required property var modelData + required property int index + readonly property string sectionTitle: root.scrollSectionTitle(index) - Repeater { - model: root.knownDevices - DeviceRow { - required property var modelData - required property int index - width: deviceList.width - dev: modelData - rowIndex: index - sectionName: "known" - isDiscovered: false + width: ListView.view.width + height: delegateColumn.implicitHeight + + Column { + id: delegateColumn + width: parent.width + spacing: Style.space(10) + + PanelSeparator { + visible: index > 0 && sectionTitle !== "" + height: visible ? implicitHeight : 0 + foreground: root.bar.foreground } - } - // Discovered (unpaired) devices, only shown while scanning. - PanelSeparator { - visible: root.adapter && root.adapter.discovering && root.discoveredDevices.length > 0 - && root.knownDevices.length > 0 - foreground: root.bar.foreground - } - - PanelSectionHeader { - visible: root.adapter && root.adapter.discovering && root.discoveredDevices.length > 0 - text: "AVAILABLE" - foreground: root.bar.foreground - fontFamily: root.bar.fontFamily - } - - Repeater { - model: root.adapter && root.adapter.discovering ? root.discoveredDevices : [] - DeviceRow { - required property var modelData - required property int index - width: deviceList.width - dev: modelData - rowIndex: index - sectionName: "discovered" - isDiscovered: true + PanelSectionHeader { + visible: sectionTitle !== "" + height: visible ? implicitHeight : 0 + text: sectionTitle + foreground: root.bar.foreground + fontFamily: root.bar.fontFamily } - } - Text { - visible: root.connectedDevices.length === 0 - && root.knownDevices.length === 0 - && (!root.adapter || !root.adapter.discovering || root.discoveredDevices.length === 0) - text: !root.adapter ? "No Bluetooth adapter" - : !root.adapter.enabled ? "Turn Bluetooth on to scan" - : "Scanning for devices…" - color: Qt.darker(root.bar.foreground, 1.5) - font.family: root.bar.fontFamily - font.pixelSize: Style.font.bodySmall - wrapMode: Text.WordWrap - width: deviceList.width + DeviceRow { + width: parent.width + dev: modelData.dev + rowIndex: modelData.indexInSection + sectionName: modelData.section + isDiscovered: modelData.section === "discovered" + } } } } + + Text { + visible: root.connectedDevices.length === 0 && root.scrollRows.length === 0 + text: !root.adapter ? "No Bluetooth adapter" + : !root.adapter.enabled ? "Turn Bluetooth on to scan" + : "Scanning for devices…" + color: Qt.darker(root.bar.foreground, 1.5) + font.family: root.bar.fontFamily + font.pixelSize: Style.font.bodySmall + wrapMode: Text.WordWrap + width: parent.width + } } } } @@ -759,7 +768,6 @@ Panel { readonly property bool showForgetButton: forgetAvailable && (rowMouse.containsMouse || rowSelected) hasCursor: rowSelected && !root.actionFocused - onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(row) current: isConnected foreground: root.bar.foreground fill: root.hoverFill