diff --git a/settings/quickshell.nix b/settings/quickshell.nix index e5005da..d01fb8a 100644 --- a/settings/quickshell.nix +++ b/settings/quickshell.nix @@ -631,6 +631,7 @@ in import Quickshell.Widgets import Quickshell.Io import QtQuick + import QtQml.Models import Qt5Compat.GraphicalEffects PanelWindow { @@ -1048,14 +1049,18 @@ in // ── Launcher: rises out of the bottom frame edge (Super+R). // Lives in the SDF field, so it melts into the frame and its - // height morphs live as results filter. Results sit above the - // search box; selection uses an animated ListView highlight. + // height morphs live as results filter. Opens on a category + // browser; typing switches to fuzzy search. Results live in a + // ListModel that's DIFFED against each new filter pass, so + // surviving rows slide to their new rank (ListView move + // transitions) instead of the whole list repopulating. Item { id: launcherPanel property bool open: false - readonly property real panelW: 420 - property real targetH: 36 + launcherList.contentHeight - + (launcherList.count > 0 ? 8 + 2 * Theme.cardPad : 0) + 24 + readonly property real panelW: 520 + // Cap the visible list at 8 rows; longer lists scroll. + readonly property real listCapH: 8 * 56 + property real targetH: launcherCol.height + 24 // Both axes animate: expands from a small point on the // bottom edge (like the top dropdowns' stub seed). property real openH: open ? targetH : 0 @@ -1073,11 +1078,50 @@ in height: openH visible: openH > 0.5 + property string activeCat: "" + property var entries: [] + + readonly property var catDefs: [ + { key: "All", label: "All apps", icon: "apps" }, + { key: "Network", label: "Internet", icon: "public" }, + { key: "Development", label: "Development", icon: "code" }, + { key: "Game", label: "Games", icon: "sports_esports" }, + { key: "AudioVideo", label: "Media", icon: "play_circle" }, + { key: "Graphics", label: "Graphics", icon: "palette" }, + { key: "Office", label: "Office", icon: "description" }, + { key: "System", label: "System", icon: "monitor" }, + { key: "Utility", label: "Utilities", icon: "widgets" }, + { key: "Other", label: "Other", icon: "more_horiz" } + ] + + function inCat(a, key) { + if (key === "All") return true; + const c = a.categories; + if (key === "System") return c.indexOf("System") !== -1 || c.indexOf("Settings") !== -1; + if (key === "Other") { + const main = ["Network", "Development", "Game", "AudioVideo", "Graphics", "Office", "System", "Settings", "Utility"]; + for (let i = 0; i < main.length; i++) + if (c.indexOf(main[i]) !== -1) return false; + return true; + } + return c.indexOf(key) !== -1; + } + + function catCount(key) { + return DesktopEntries.applications.values.filter(a => !a.noDisplay && inCat(a, key)).length; + } + + function catLabel(key) { + const d = catDefs.find(x => x.key === key); + return d ? d.label : key; + } + function toggle() { open = !open; if (open) { searchInput.text = ""; - launcherList.currentIndex = 0; + activeCat = ""; + refilter(); searchInput.forceActiveFocus(); } } @@ -1088,6 +1132,62 @@ in open = false; } + function refilter() { + let q = searchInput.text.toLowerCase().trim(); + let apps = DesktopEntries.applications.values.filter(a => !a.noDisplay); + let list; + if (q === "") { + if (activeCat === "") { + list = []; + } else { + list = apps.filter(a => inCat(a, activeCat)); + list.sort((a, b) => a.name.localeCompare(b.name)); + } + } else { + let scored = []; + for (let i = 0; i < apps.length; i++) { + let s = score(apps[i].name, apps[i].genericName + " " + apps[i].comment, q); + if (s > 0) scored.push({ app: apps[i], s: s }); + } + scored.sort((a, b) => b.s - a.s || a.app.name.localeCompare(b.app.name)); + list = scored.slice(0, 12).map(x => x.app); + } + entries = list; + syncModel(list); + launcherList.currentIndex = list.length > 0 ? 0 : -1; + } + + // Diff the new result list into the ListModel instead of + // resetting it: surviving rows MOVE (animated), dropped + // rows fade out, new rows fade in. A plain array model + // would rebuild every delegate on each keystroke. + function syncModel(list) { + const m = launcherModel; + const ids = {}; + for (let i = 0; i < list.length; i++) ids[list[i].id] = true; + for (let i = m.count - 1; i >= 0; i--) + if (!ids[m.get(i).appId]) m.remove(i); + for (let i = 0; i < list.length; i++) { + const a = list[i]; + let j = -1; + for (let k = i; k < m.count; k++) + if (m.get(k).appId === a.id) { j = k; break; } + if (j === -1) { + m.insert(i, { + appId: a.id, + name: a.name, + icon: Quickshell.iconPath(a.icon, true) || "", + desc: a.genericName !== "" ? a.genericName : a.comment + }); + } else if (j !== i) { + m.move(j, i, 1); + } + } + while (m.count > list.length) m.remove(m.count - 1); + } + + ListModel { id: launcherModel } + function score(name, extra, q) { let n = name.toLowerCase(); if (n.startsWith(q)) return 5; @@ -1108,22 +1208,6 @@ in return 0; } - property var entries: { - let q = searchInput.text.toLowerCase().trim(); - let apps = DesktopEntries.applications.values.filter(a => !a.noDisplay); - if (q === "") { - apps.sort((a, b) => a.name.localeCompare(b.name)); - return apps.slice(0, 8); - } - let scored = []; - for (let i = 0; i < apps.length; i++) { - let s = score(apps[i].name, apps[i].genericName + " " + apps[i].comment, q); - if (s > 0) scored.push({ app: apps[i], s: s }); - } - scored.sort((a, b) => b.s - a.s || a.app.name.localeCompare(b.app.name)); - return scored.slice(0, 8).map(x => x.app); - } - // Content anchored to the bottom so the grow reveals upward Item { anchors.fill: parent @@ -1134,107 +1218,236 @@ in } Column { + id: launcherCol anchors.bottom: parent.bottom anchors.bottomMargin: 12 anchors.horizontalCenter: parent.horizontalCenter width: launcherPanel.panelW - 24 spacing: 8 + // Category-browse breadcrumb: back to the grid. + HoverRow { + width: parent.width + height: 30 + visible: launcherPanel.activeCat !== "" && searchInput.text === "" + onClicked: { launcherPanel.activeCat = ""; launcherPanel.refilter(); } + + Row { + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.leftMargin: 10 + spacing: 8 + SIcon { + anchors.verticalCenter: parent.verticalCenter + text: "arrow_back" + font.pixelSize: 16 + color: Theme.base04 + } + SText { + anchors.verticalCenter: parent.verticalCenter + text: launcherPanel.catLabel(launcherPanel.activeCat) + font.weight: Font.Medium + } + SText { + anchors.verticalCenter: parent.verticalCenter + text: launcherPanel.entries.length + " apps" + font.pixelSize: 11 + color: Theme.base04 + } + } + } + // Results sit in a base01 card segment, like the // notification list and the other dropdowns. Rectangle { width: parent.width - height: launcherList.contentHeight + 2 * Theme.cardPad + height: Math.min(launcherList.contentHeight, launcherPanel.listCapH) + 2 * Theme.cardPad radius: Theme.radiusCard color: Theme.cardBg visible: launcherList.count > 0 ListView { - id: launcherList - anchors.fill: parent - anchors.margins: Theme.cardPad - interactive: false - model: launcherPanel.entries - highlight: Rectangle { - radius: Theme.radiusSmall - color: Theme.base02 - } - highlightMoveDuration: 200 - highlightMoveVelocity: -1 - highlightResizeDuration: 0 + id: launcherList + anchors.fill: parent + anchors.margins: Theme.cardPad + clip: true + interactive: contentHeight > height + model: launcherModel + highlight: Rectangle { + radius: Theme.radiusSmall + color: Theme.base02 + } + highlightMoveDuration: 200 + highlightMoveVelocity: -1 + highlightResizeDuration: 0 - delegate: Item { - required property var modelData - required property int index - width: launcherList.width - height: 32 - - Row { - anchors.verticalCenter: parent.verticalCenter - anchors.left: parent.left - anchors.leftMargin: 10 - spacing: 10 - - Image { - visible: source != "" - anchors.verticalCenter: parent.verticalCenter - width: 18 - height: 18 - sourceSize.width: 18 - sourceSize.height: 18 - source: Quickshell.iconPath(modelData.icon, true) - } - - SText { - anchors.verticalCenter: parent.verticalCenter - text: modelData.name - color: Theme.base05 - font.pixelSize: 13 - elide: Text.ElideRight - width: 330 - } + move: Transition { + NumberAnimation { properties: "x,y"; duration: Theme.animContent; easing.type: Easing.OutExpo } + } + displaced: Transition { + NumberAnimation { properties: "x,y"; duration: Theme.animContent; easing.type: Easing.OutExpo } + } + add: Transition { + NumberAnimation { property: "opacity"; from: 0; to: 1; duration: Theme.animContent } + NumberAnimation { property: "scale"; from: 0.92; to: 1; duration: Theme.animContent; easing.type: Easing.OutExpo } + } + remove: Transition { + NumberAnimation { property: "opacity"; to: 0; duration: 100 } } - MouseArea { - anchors.fill: parent - hoverEnabled: true - onEntered: launcherList.currentIndex = index - onClicked: launcherPanel.activate(modelData) + delegate: Item { + required property string appId + required property string name + required property string icon + required property string desc + required property int index + width: launcherList.width + height: 56 + + Row { + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.leftMargin: 12 + spacing: 12 + + Image { + visible: source != "" + anchors.verticalCenter: parent.verticalCenter + width: 32 + height: 32 + sourceSize.width: 32 + sourceSize.height: 32 + source: icon + } + + Column { + anchors.verticalCenter: parent.verticalCenter + width: launcherList.width - 68 + spacing: 1 + + SText { + width: parent.width + text: name + font.pixelSize: 14 + font.weight: Font.Medium + elide: Text.ElideRight + } + SText { + width: parent.width + visible: text !== "" + text: desc + font.pixelSize: 11 + color: Theme.base04 + elide: Text.ElideRight + } + } + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + onEntered: launcherList.currentIndex = index + onClicked: launcherPanel.activate(launcherPanel.entries[index]) + } } } } + + // Category grid: the browse view when nothing is + // typed and no category is active. + Rectangle { + width: parent.width + height: catGrid.height + 2 * Theme.cardPad + radius: Theme.radiusCard + color: Theme.cardBg + visible: searchInput.text === "" && launcherPanel.activeCat === "" + + Grid { + id: catGrid + anchors.top: parent.top + anchors.topMargin: Theme.cardPad + anchors.horizontalCenter: parent.horizontalCenter + width: parent.width - 2 * Theme.cardPad + columns: 2 + columnSpacing: 8 + rowSpacing: 8 + + Repeater { + model: launcherPanel.catDefs + + HoverRow { + required property var modelData + width: (catGrid.width - catGrid.columnSpacing) / 2 + height: 48 + onClicked: { launcherPanel.activeCat = modelData.key; launcherPanel.refilter(); } + + Row { + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.leftMargin: 12 + spacing: 10 + + SIcon { + anchors.verticalCenter: parent.verticalCenter + text: modelData.icon + font.pixelSize: 20 + color: Theme.base0D + } + Column { + anchors.verticalCenter: parent.verticalCenter + SText { + text: modelData.label + font.weight: Font.Medium + } + SText { + text: launcherPanel.catCount(modelData.key) + " apps" + font.pixelSize: 10 + color: Theme.base04 + } + } + } + } + } + } } Rectangle { width: parent.width - height: 36 + height: 44 radius: Theme.radiusCard color: Theme.cardBg SIcon { id: searchIcon anchors.left: parent.left - anchors.leftMargin: 10 + anchors.leftMargin: 12 anchors.verticalCenter: parent.verticalCenter text: "search" color: Theme.base04 - font.pixelSize: 16 + font.pixelSize: 18 } TextInput { id: searchInput anchors.left: searchIcon.right - anchors.leftMargin: 8 + anchors.leftMargin: 10 anchors.right: parent.right anchors.rightMargin: 12 anchors.verticalCenter: parent.verticalCenter color: Theme.base05 font.family: Theme.fontFamily - font.pixelSize: 13 + font.pixelSize: 14 clip: true - onTextChanged: launcherList.currentIndex = 0 + onTextChanged: launcherPanel.refilter() - Keys.onEscapePressed: launcherPanel.open = false + Keys.onEscapePressed: { + if (launcherPanel.activeCat !== "" && searchInput.text === "") { + launcherPanel.activeCat = ""; + launcherPanel.refilter(); + } else { + launcherPanel.open = false; + } + } Keys.onUpPressed: launcherList.currentIndex = Math.max(0, launcherList.currentIndex - 1) Keys.onDownPressed: launcherList.currentIndex = Math.min(launcherPanel.entries.length - 1, launcherList.currentIndex + 1) Keys.onTabPressed: launcherList.currentIndex = (launcherList.currentIndex + 1) % Math.max(1, launcherPanel.entries.length) @@ -1244,12 +1457,12 @@ in SText { anchors.left: searchIcon.right - anchors.leftMargin: 8 + anchors.leftMargin: 10 anchors.verticalCenter: parent.verticalCenter visible: searchInput.text === "" - text: "Search" + text: launcherPanel.activeCat === "" ? "Search apps" : "Search all apps" color: Theme.base03 - font.pixelSize: 13 + font.pixelSize: 14 } } }