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.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,17 +1218,50 @@ 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
|
||||||
|
|
@ -1153,8 +1270,9 @@ in
|
||||||
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
|
||||||
|
model: launcherModel
|
||||||
highlight: Rectangle {
|
highlight: Rectangle {
|
||||||
radius: Theme.radiusSmall
|
radius: Theme.radiusSmall
|
||||||
color: Theme.base02
|
color: Theme.base02
|
||||||
|
|
@ -1163,35 +1281,65 @@ in
|
||||||
highlightMoveVelocity: -1
|
highlightMoveVelocity: -1
|
||||||
highlightResizeDuration: 0
|
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 {
|
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
|
required property int index
|
||||||
width: launcherList.width
|
width: launcherList.width
|
||||||
height: 32
|
height: 56
|
||||||
|
|
||||||
Row {
|
Row {
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
anchors.left: parent.left
|
anchors.left: parent.left
|
||||||
anchors.leftMargin: 10
|
anchors.leftMargin: 12
|
||||||
spacing: 10
|
spacing: 12
|
||||||
|
|
||||||
Image {
|
Image {
|
||||||
visible: source != ""
|
visible: source != ""
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
width: 18
|
width: 32
|
||||||
height: 18
|
height: 32
|
||||||
sourceSize.width: 18
|
sourceSize.width: 32
|
||||||
sourceSize.height: 18
|
sourceSize.height: 32
|
||||||
source: Quickshell.iconPath(modelData.icon, true)
|
source: icon
|
||||||
}
|
}
|
||||||
|
|
||||||
SText {
|
Column {
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
text: modelData.name
|
width: launcherList.width - 68
|
||||||
color: Theme.base05
|
spacing: 1
|
||||||
font.pixelSize: 13
|
|
||||||
|
SText {
|
||||||
|
width: parent.width
|
||||||
|
text: name
|
||||||
|
font.pixelSize: 14
|
||||||
|
font.weight: Font.Medium
|
||||||
elide: Text.ElideRight
|
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
|
anchors.fill: parent
|
||||||
hoverEnabled: true
|
hoverEnabled: true
|
||||||
onEntered: launcherList.currentIndex = index
|
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 {
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue