quickshell: launcher v2 — categories, animated reshuffle, bigger rows
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
ce14f695c6
commit
dfe8c9ecdf
1 changed files with 293 additions and 80 deletions
|
|
@ -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,17 +1218,50 @@ 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
|
||||
|
|
@ -1153,8 +1270,9 @@ in
|
|||
id: launcherList
|
||||
anchors.fill: parent
|
||||
anchors.margins: Theme.cardPad
|
||||
interactive: false
|
||||
model: launcherPanel.entries
|
||||
clip: true
|
||||
interactive: contentHeight > height
|
||||
model: launcherModel
|
||||
highlight: Rectangle {
|
||||
radius: Theme.radiusSmall
|
||||
color: Theme.base02
|
||||
|
|
@ -1163,35 +1281,65 @@ in
|
|||
highlightMoveVelocity: -1
|
||||
highlightResizeDuration: 0
|
||||
|
||||
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 }
|
||||
}
|
||||
|
||||
delegate: Item {
|
||||
required property var modelData
|
||||
required property string appId
|
||||
required property string name
|
||||
required property string icon
|
||||
required property string desc
|
||||
required property int index
|
||||
width: launcherList.width
|
||||
height: 32
|
||||
height: 56
|
||||
|
||||
Row {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 10
|
||||
spacing: 10
|
||||
anchors.leftMargin: 12
|
||||
spacing: 12
|
||||
|
||||
Image {
|
||||
visible: source != ""
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 18
|
||||
height: 18
|
||||
sourceSize.width: 18
|
||||
sourceSize.height: 18
|
||||
source: Quickshell.iconPath(modelData.icon, true)
|
||||
width: 32
|
||||
height: 32
|
||||
sourceSize.width: 32
|
||||
sourceSize.height: 32
|
||||
source: icon
|
||||
}
|
||||
|
||||
SText {
|
||||
Column {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: modelData.name
|
||||
color: Theme.base05
|
||||
font.pixelSize: 13
|
||||
width: launcherList.width - 68
|
||||
spacing: 1
|
||||
|
||||
SText {
|
||||
width: parent.width
|
||||
text: name
|
||||
font.pixelSize: 14
|
||||
font.weight: Font.Medium
|
||||
elide: Text.ElideRight
|
||||
width: 330
|
||||
}
|
||||
SText {
|
||||
width: parent.width
|
||||
visible: text !== ""
|
||||
text: desc
|
||||
font.pixelSize: 11
|
||||
color: Theme.base04
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1199,7 +1347,65 @@ in
|
|||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
onEntered: launcherList.currentIndex = index
|
||||
onClicked: launcherPanel.activate(modelData)
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1207,34 +1413,41 @@ in
|
|||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue