quickshell: launcher v2 — categories, animated reshuffle, bigger rows

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-07-28 16:22:30 +01:00
parent ce14f695c6
commit dfe8c9ecdf

View file

@ -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
}
}
}