fixed sluggish scroll glitch on launcher.qml

This commit is contained in:
SomeElse
2026-06-04 22:18:57 +00:00
parent c97bcaf1f3
commit f59b2d6aa9
21 changed files with 298 additions and 147 deletions

View File

@@ -34,7 +34,8 @@ PanelWindow {
property bool loading: false
property int lastCacheUpdate: 0
property var favoriteIds: []
readonly property string favoritesPath: Quickshell.env("HOME") + "/.config/quickshell/launcher/favorites.txt"
property var accessCounts: ({})
readonly property string statePath: Quickshell.env("HOME") + "/.config/quickshell/launcher/launcher_state.json"
readonly property int defaultIndex: Cfg.Config.launcherSelectFirst ? 0 : -1
@@ -48,15 +49,31 @@ PanelWindow {
filtered = allItems.filter(i => i.name.toLowerCase().startsWith(q))
}
const favs = filtered.filter(i => root.favoriteIds.includes(i.id))
const rest = filtered.filter(i => !root.favoriteIds.includes(i.id))
let rest = filtered.filter(i => !root.favoriteIds.includes(i.id))
rest.sort((a, b) => (root.accessCounts[b.id] || 0) - (root.accessCounts[a.id] || 0))
const result = []
if (favs.length > 0)
result.push(...favs.map(i => Object.assign({}, i, { section: "Favorites" })))
if (rest.length > 0)
result.push(...rest.map(i => Object.assign({}, i, { section: "All Applications" })))
if (favs.length > 0) {
result.push({ isHeader: true, section: "Favorites" })
for (let i = 0; i < favs.length; i++)
result.push(Object.assign({}, favs[i], { isHeader: false }))
}
if (rest.length > 0) {
result.push({ isHeader: true, section: "All Applications" })
for (let i = 0; i < rest.length; i++)
result.push(Object.assign({}, rest[i], { isHeader: false }))
}
return result
}
function skipHeader(idx, dir) {
while (idx >= 0 && idx < root.filteredItems.length) {
const item = root.filteredItems[idx]
if (item && !item.isHeader) return idx
idx += dir
}
return -1
}
function toggle() {
if (!_isOpen) {
var fm = Hyprland.focusedMonitor
@@ -73,8 +90,9 @@ PanelWindow {
_isOpen = true
searchInput.text = ""
resultsList.currentIndex = root.defaultIndex
Qt.callLater(() => resultsList.positionViewAtBeginning())
loadItems()
loadFavorites()
loadState()
Qt.callLater(() => searchInput.forceActiveFocus())
}
@@ -147,20 +165,16 @@ PanelWindow {
return args
}
function loadFavorites() {
favoritesReadProc.command = ["sh", "-c", "cat " + shellQuote(favoritesPath) + " 2>/dev/null || true"]
favoritesReadProc.running = true
function loadState() {
stateReadProc.command = ["sh", "-c", "cat " + shellQuote(statePath) + " 2>/dev/null || true"]
stateReadProc.running = true
}
function saveFavorites() {
if (favoriteIds.length === 0) {
favoritesWriteProc.command = ["sh", "-c", ": > " + shellQuote(favoritesPath)]
} else {
const quoted = favoriteIds.map(id => shellQuote(id)).join(" ")
favoritesWriteProc.command = ["sh", "-c",
"printf '%s\\n' " + quoted + " > " + shellQuote(favoritesPath)]
}
favoritesWriteProc.running = true
function saveState() {
const data = JSON.stringify({ favorites: favoriteIds, access: accessCounts })
stateWriteProc.command = ["sh", "-c",
"printf '%s' " + shellQuote(data) + " > " + shellQuote(statePath)]
stateWriteProc.running = true
}
function toggleFavorite(itemId) {
@@ -172,7 +186,14 @@ PanelWindow {
arr.push(itemId)
}
favoriteIds = arr
saveFavorites()
saveState()
}
function incrementAccess(itemId) {
const counts = Object.assign({}, accessCounts)
counts[itemId] = (counts[itemId] || 0) + 1
accessCounts = counts
saveState()
}
function launch(item) {
@@ -188,6 +209,7 @@ PanelWindow {
launchProc.running = true
console.log("Launching: " + cleanExec)
incrementAccess(item.id)
close()
}
@@ -238,20 +260,28 @@ PanelWindow {
Process { id: launchProc }
Process {
id: favoritesReadProc
id: stateReadProc
property string rawData: ""
stdout: SplitParser { onRead: data => favoritesReadProc.rawData += data + "\n" }
stdout: SplitParser { onRead: data => stateReadProc.rawData += data + "\n" }
onRunningChanged: {
if (!running) {
if (rawData.trim()) {
root.favoriteIds = rawData.trim().split("\n").filter(l => l.length > 0)
try {
const state = JSON.parse(rawData.trim())
if (Array.isArray(state.favorites))
root.favoriteIds = state.favorites
if (state.access && typeof state.access === "object")
root.accessCounts = state.access
} catch (e) {
console.log("Failed to parse launcher state: " + e)
}
}
rawData = ""
}
}
}
Process { id: favoritesWriteProc }
Process { id: stateWriteProc }
MouseArea {
@@ -282,7 +312,7 @@ PanelWindow {
height: targetHeight
Behavior on height {
enabled: root._isOpen
NumberAnimation { duration: 154; easing.type: Easing.OutCubic }
NumberAnimation { duration: 80; easing.type: Easing.OutCubic }
}
radius: Cfg.Config.popupRadius
@@ -328,29 +358,33 @@ PanelWindow {
onTextChanged: resultsList.currentIndex = root.defaultIndex
Keys.onReturnPressed: {
if (root.filteredItems.length > 0) {
root.launch(root.filteredItems[Math.max(0, resultsList.currentIndex)])
}
const idx = root.skipHeader(Math.max(0, resultsList.currentIndex), 1)
if (idx >= 0)
root.launch(root.filteredItems[idx])
}
Keys.onDownPressed: {
root._keyboardNav = true
if (resultsList.currentIndex < 0) {
resultsList.currentIndex = 0;
} else {
resultsList.currentIndex = Math.min(resultsList.currentIndex + 1, resultsList.count - 1)
var next = resultsList.currentIndex < 0 ? 0 : resultsList.currentIndex + 1
if (next >= resultsList.count) next = resultsList.count - 1
next = root.skipHeader(next, 1)
if (next < 0) next = root.skipHeader(0, 1)
if (next >= 0) {
resultsList.currentIndex = next
resultsList.positionViewAtIndex(next, ListView.Contain)
}
resultsList.positionViewAtIndex(resultsList.currentIndex, ListView.Contain)
}
Keys.onUpPressed: {
root._keyboardNav = true
if (resultsList.currentIndex < 0) {
resultsList.currentIndex = resultsList.count - 1;
} else {
resultsList.currentIndex = Math.max(resultsList.currentIndex - 1, 0)
var next = resultsList.currentIndex < 0 ? resultsList.count - 1 : resultsList.currentIndex - 1
if (next < 0) next = 0
next = root.skipHeader(next, -1)
if (next < 0) next = root.skipHeader(resultsList.count - 1, -1)
if (next >= 0) {
resultsList.currentIndex = next
resultsList.positionViewAtIndex(next, ListView.Contain)
}
resultsList.positionViewAtIndex(resultsList.currentIndex, ListView.Contain)
}
}
}
@@ -361,49 +395,77 @@ PanelWindow {
Layout.fillWidth: true
Layout.preferredHeight: listHeight
property int listHeight: Math.min(contentHeight, 400)
Behavior on listHeight {
NumberAnimation { duration: 154; easing.type: Easing.OutCubic }
// Avoid reacting to contentHeight changes from lazy delegate
// instantiation during scroll — only animate on model count changes.
property int listHeight: 0
NumberAnimation {
id: listHeightAnim
target: resultsList
property: "listHeight"
duration: 80
easing.type: Easing.OutCubic
}
function _syncListHeight() {
var h = Math.min(contentHeight, 400)
if (h === listHeight) return
listHeightAnim.stop()
listHeightAnim.from = listHeight
listHeightAnim.to = h
listHeightAnim.start()
}
onCountChanged: Qt.callLater(_syncListHeight)
Component.onCompleted: Qt.callLater(_syncListHeight)
clip: true
model: root.filteredItems
currentIndex: root.defaultIndex
spacing: 2
snapMode: ListView.NoSnap
highlightRangeMode: ListView.NoHighlightRange
cacheBuffer: 400
reuseItems: true
pixelAligned: false
boundsBehavior: Flickable.StopAtBounds
flickableDirection: Flickable.VerticalFlick
section.property: "section"
section.criteria: ViewSection.FullString
section.delegate: Rectangle {
width: resultsList.width
height: 28
color: "transparent"
Text {
anchors { left: parent.left; leftMargin: 12; verticalCenter: parent.verticalCenter }
text: section
color: t.launcherDim
font.pixelSize: 11
font.weight: Font.Bold
onModelChanged: {
currentIndex = root.defaultIndex
for (let i = 0; i < count; i++) {
const md = root.filteredItems[i]
if (md && !md.isHeader) {
currentIndex = i
break
}
}
Qt.callLater(() => positionViewAtBeginning())
}
onModelChanged: currentIndex = root.defaultIndex
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
add: Transition {
NumberAnimation { property: "opacity"; from: 0; to: 1; duration: 100; easing.type: Easing.InQuad }
}
delegate: Rectangle {
width: resultsList.width
height: 38
radius: 8
property bool hovered: false
color: hovered ? t.launcherHover
radius: modelData.isHeader ? 0 : 8
color: modelData.isHeader ? "transparent"
: (starHovered || hovered) ? t.launcherHover
: (resultsList.currentIndex === index && index !== -1
? t.launcherSelected : "transparent")
property bool hovered: false
property bool starHovered: false
Text {
visible: modelData.isHeader
anchors { left: parent.left; leftMargin: 12; verticalCenter: parent.verticalCenter }
text: modelData.section || ""
color: t.launcherDim
font.pixelSize: 13
font.weight: Font.Bold
}
RowLayout {
visible: !modelData.isHeader
anchors.fill: parent
anchors.leftMargin: 12
anchors.rightMargin: 12
@@ -417,10 +479,10 @@ PanelWindow {
id: appIcon
anchors.fill: parent
source: modelData.icon ? "image://icon/" + modelData.icon : ""
sourceSize: Qt.size(64, 64)
sourceSize: Qt.size(64, 64)
fillMode: Image.PreserveAspectFit
smooth: true
mipmap: Cfg.Config.launcherMipmapIcons
mipmap: Cfg.Config.launcherMipmapIcons
visible: status === Image.Ready
}
@@ -442,14 +504,22 @@ PanelWindow {
}
Item {
width: 22; height: 22
width: 26; height: 26
Layout.alignment: Qt.AlignVCenter
Text {
id: starText
anchors.centerIn: parent
text: root.favoriteIds.includes(modelData.id) ? "★" : "☆"
color: root.favoriteIds.includes(modelData.id) ? t.launcherAccent : t.launcherDim
font.pixelSize: 14
font.pixelSize: 18
readonly property bool isFav: root.favoriteIds.includes(modelData.id)
color: starHovered ? t.launcherStarHover
: (isFav ? t.launcherAccent : t.launcherStar)
Behavior on color {
ColorAnimation { duration: 180; easing.type: Easing.OutCubic }
}
}
}
}
@@ -458,8 +528,12 @@ PanelWindow {
anchors.fill: parent
hoverEnabled: true
onEntered: parent.hovered = true
onExited: parent.hovered = false
onExited: { parent.hovered = false; parent.starHovered = false }
onPositionChanged: mouse => {
parent.starHovered = !modelData.isHeader && (mouse.x >= width - 46)
}
onClicked: mouse => {
if (modelData.isHeader) return
if (mouse.x >= width - 46) {
root.toggleFavorite(modelData.id)
} else {