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.Widgets
import Quickshell.Io import Quickshell.Io
import QtQuick import QtQuick
import QtQml.Models
import Qt5Compat.GraphicalEffects import Qt5Compat.GraphicalEffects
PanelWindow { PanelWindow {
@ -1048,14 +1049,18 @@ in
// Launcher: rises out of the bottom frame edge (Super+R). // Launcher: rises out of the bottom frame edge (Super+R).
// Lives in the SDF field, so it melts into the frame and its // Lives in the SDF field, so it melts into the frame and its
// height morphs live as results filter. Results sit above the // height morphs live as results filter. Opens on a category
// search box; selection uses an animated ListView highlight. // 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 { Item {
id: launcherPanel id: launcherPanel
property bool open: false property bool open: false
readonly property real panelW: 420 readonly property real panelW: 520
property real targetH: 36 + launcherList.contentHeight // Cap the visible list at 8 rows; longer lists scroll.
+ (launcherList.count > 0 ? 8 + 2 * Theme.cardPad : 0) + 24 readonly property real listCapH: 8 * 56
property real targetH: launcherCol.height + 24
// Both axes animate: expands from a small point on the // Both axes animate: expands from a small point on the
// bottom edge (like the top dropdowns' stub seed). // bottom edge (like the top dropdowns' stub seed).
property real openH: open ? targetH : 0 property real openH: open ? targetH : 0
@ -1073,11 +1078,50 @@ in
height: openH height: openH
visible: openH > 0.5 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() { function toggle() {
open = !open; open = !open;
if (open) { if (open) {
searchInput.text = ""; searchInput.text = "";
launcherList.currentIndex = 0; activeCat = "";
refilter();
searchInput.forceActiveFocus(); searchInput.forceActiveFocus();
} }
} }
@ -1088,6 +1132,62 @@ in
open = false; 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) { function score(name, extra, q) {
let n = name.toLowerCase(); let n = name.toLowerCase();
if (n.startsWith(q)) return 5; if (n.startsWith(q)) return 5;
@ -1108,22 +1208,6 @@ in
return 0; 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 // Content anchored to the bottom so the grow reveals upward
Item { Item {
anchors.fill: parent anchors.fill: parent
@ -1134,107 +1218,236 @@ in
} }
Column { Column {
id: launcherCol
anchors.bottom: parent.bottom anchors.bottom: parent.bottom
anchors.bottomMargin: 12 anchors.bottomMargin: 12
anchors.horizontalCenter: parent.horizontalCenter anchors.horizontalCenter: parent.horizontalCenter
width: launcherPanel.panelW - 24 width: launcherPanel.panelW - 24
spacing: 8 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 // Results sit in a base01 card segment, like the
// notification list and the other dropdowns. // notification list and the other dropdowns.
Rectangle { Rectangle {
width: parent.width width: parent.width
height: launcherList.contentHeight + 2 * Theme.cardPad height: Math.min(launcherList.contentHeight, launcherPanel.listCapH) + 2 * Theme.cardPad
radius: Theme.radiusCard radius: Theme.radiusCard
color: Theme.cardBg color: Theme.cardBg
visible: launcherList.count > 0 visible: launcherList.count > 0
ListView { ListView {
id: launcherList id: launcherList
anchors.fill: parent anchors.fill: parent
anchors.margins: Theme.cardPad anchors.margins: Theme.cardPad
interactive: false clip: true
model: launcherPanel.entries interactive: contentHeight > height
highlight: Rectangle { model: launcherModel
radius: Theme.radiusSmall highlight: Rectangle {
color: Theme.base02 radius: Theme.radiusSmall
} color: Theme.base02
highlightMoveDuration: 200 }
highlightMoveVelocity: -1 highlightMoveDuration: 200
highlightResizeDuration: 0 highlightMoveVelocity: -1
highlightResizeDuration: 0
delegate: Item { move: Transition {
required property var modelData NumberAnimation { properties: "x,y"; duration: Theme.animContent; easing.type: Easing.OutExpo }
required property int index }
width: launcherList.width displaced: Transition {
height: 32 NumberAnimation { properties: "x,y"; duration: Theme.animContent; easing.type: Easing.OutExpo }
}
Row { add: Transition {
anchors.verticalCenter: parent.verticalCenter NumberAnimation { property: "opacity"; from: 0; to: 1; duration: Theme.animContent }
anchors.left: parent.left NumberAnimation { property: "scale"; from: 0.92; to: 1; duration: Theme.animContent; easing.type: Easing.OutExpo }
anchors.leftMargin: 10 }
spacing: 10 remove: Transition {
NumberAnimation { property: "opacity"; to: 0; duration: 100 }
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
}
} }
MouseArea { delegate: Item {
anchors.fill: parent required property string appId
hoverEnabled: true required property string name
onEntered: launcherList.currentIndex = index required property string icon
onClicked: launcherPanel.activate(modelData) 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 { Rectangle {
width: parent.width width: parent.width
height: 36 height: 44
radius: Theme.radiusCard radius: Theme.radiusCard
color: Theme.cardBg color: Theme.cardBg
SIcon { SIcon {
id: searchIcon id: searchIcon
anchors.left: parent.left anchors.left: parent.left
anchors.leftMargin: 10 anchors.leftMargin: 12
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
text: "search" text: "search"
color: Theme.base04 color: Theme.base04
font.pixelSize: 16 font.pixelSize: 18
} }
TextInput { TextInput {
id: searchInput id: searchInput
anchors.left: searchIcon.right anchors.left: searchIcon.right
anchors.leftMargin: 8 anchors.leftMargin: 10
anchors.right: parent.right anchors.right: parent.right
anchors.rightMargin: 12 anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
color: Theme.base05 color: Theme.base05
font.family: Theme.fontFamily font.family: Theme.fontFamily
font.pixelSize: 13 font.pixelSize: 14
clip: true 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.onUpPressed: launcherList.currentIndex = Math.max(0, launcherList.currentIndex - 1)
Keys.onDownPressed: launcherList.currentIndex = Math.min(launcherPanel.entries.length - 1, 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) Keys.onTabPressed: launcherList.currentIndex = (launcherList.currentIndex + 1) % Math.max(1, launcherPanel.entries.length)
@ -1244,12 +1457,12 @@ in
SText { SText {
anchors.left: searchIcon.right anchors.left: searchIcon.right
anchors.leftMargin: 8 anchors.leftMargin: 10
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
visible: searchInput.text === "" visible: searchInput.text === ""
text: "Search" text: launcherPanel.activeCat === "" ? "Search apps" : "Search all apps"
color: Theme.base03 color: Theme.base03
font.pixelSize: 13 font.pixelSize: 14
} }
} }
} }