cleaned and optimized launcher + added actionsview on listview items on launcher. TAB to open it or right click. Added typing animation for Crypto.qml and also anchored the icon on the left so it doesnt keep moving around when changing sizes.

This commit is contained in:
SomeElse
2026-06-09 04:06:40 +00:00
parent f59b2d6aa9
commit 8782fd2533
5 changed files with 366 additions and 316 deletions

View File

@@ -16,7 +16,9 @@ ModuleChip {
id: configWriter
}
width: chipRow.implicitWidth + 24
width: cryptoIcon.visible
? (12 + cryptoIcon.implicitWidth + 7 + chipRow.implicitWidth + 12)
: (chipRow.implicitWidth + 24)
radius: panelRadius
chipColor: t.cryptoBg
@@ -152,6 +154,7 @@ ModuleChip {
if (root.localPairs.length <= 1) return
root.activeCoinIndex = (root.activeCoinIndex - 1 + root.localPairs.length) % root.localPairs.length
root._applyFromCache()
root._restartTyping()
rotateTimer.restart()
}
@@ -159,6 +162,7 @@ ModuleChip {
if (root.localPairs.length <= 1) return
root.activeCoinIndex = (root.activeCoinIndex + 1) % root.localPairs.length
root._applyFromCache()
root._restartTyping()
rotateTimer.restart()
}
@@ -184,6 +188,51 @@ ModuleChip {
sparklineData = entry.sparkline || []
change7d = entry.change7d || 0
hasError = false
root._restartTyping()
}
property bool _typing: false
property int _typedLabel: 0
property int _typedValue: 0
property int _typedIcon: 0
function _restartTyping() {
typeAnim.stop()
_typing = true
_typedLabel = 0
_typedValue = 0
_typedIcon = 0
typeAnimLabel.to = (root.coinSymbol + "/" + root.displayCurrency.toUpperCase() + ": ").length
typeAnimValue.to = (root.hasError ? "err" : root.price < 0 ? "…" : root.compactPrice(root.price)).length
typeAnimIcon.to = (root.price >= 0 ? 1 : 0)
typeAnim.start()
}
SequentialAnimation {
id: typeAnim
onStopped: { root._typing = false }
NumberAnimation {
id: typeAnimLabel
target: root
property: "_typedLabel"
duration: 200
easing.type: Easing.Linear
}
NumberAnimation {
id: typeAnimValue
target: root
property: "_typedValue"
duration: 200
easing.type: Easing.Linear
}
NumberAnimation {
id: typeAnimIcon
target: root
property: "_typedIcon"
duration: 50
easing.type: Easing.Linear
}
}
function fetchPrice() {
@@ -291,9 +340,26 @@ ModuleChip {
onLocalRefreshSecChanged: refreshTimer.restart()
onLocalRotateSecChanged: rotateTimer.restart()
Text {
id: cryptoIcon
visible: root.localShowIcon
text: "󰿤"
color: t.cryptoIcon
font.pixelSize: 14
anchors {
left: parent.left
leftMargin: 12
verticalCenter: parent.verticalCenter
}
}
RowLayout {
id: chipRow
anchors.centerIn: parent
anchors {
left: cryptoIcon.visible ? cryptoIcon.right : parent.left
leftMargin: cryptoIcon.visible ? 7 : 12
verticalCenter: parent.verticalCenter
}
spacing: 0
SequentialAnimation on opacity {
@@ -310,34 +376,61 @@ ModuleChip {
}
}
Text {
visible: root.localShowIcon
text: "󰿤"
color: t.cryptoIcon
font.pixelSize: 14
Layout.alignment: Qt.AlignVCenter
Layout.rightMargin: 7
Item {
implicitWidth: labelSizer.implicitWidth
implicitHeight: labelSizer.implicitHeight
Text {
id: labelSizer
text: root.coinSymbol + "/" + root.displayCurrency.toUpperCase() + ": "
color: "transparent"
font.pixelSize: 12
font.bold: true
verticalAlignment: Text.AlignVCenter
}
Text {
text: root._typing
? (root.coinSymbol + "/" + root.displayCurrency.toUpperCase() + ": ").substring(0, root._typedLabel)
: root.coinSymbol + "/" + root.displayCurrency.toUpperCase() + ": "
color: t.cryptoLabel
font.pixelSize: 12
font.bold: true
verticalAlignment: Text.AlignVCenter
anchors.fill: labelSizer
}
}
Item {
implicitWidth: valueSizer.implicitWidth
implicitHeight: valueSizer.implicitHeight
Text {
id: valueSizer
text: root.hasError ? "err" : root.price < 0 ? "…" : root.compactPrice(root.price)
color: "transparent"
font.pixelSize: 13
font.bold: true
verticalAlignment: Text.AlignVCenter
}
Text {
text: root._typing
? (root.hasError ? "err" : root.price < 0 ? "…" : root.compactPrice(root.price)).substring(0, root._typedValue)
: root.hasError ? "err" : root.price < 0 ? "…" : root.compactPrice(root.price)
color: t.cryptoValue
font.pixelSize: 13
font.bold: true
verticalAlignment: Text.AlignVCenter
anchors.fill: valueSizer
}
}
Text {
text: root.coinSymbol + "/" + root.displayCurrency.toUpperCase() + ": "
color: t.cryptoLabel
font.pixelSize: 12
font.bold: true
verticalAlignment: Text.AlignVCenter
}
Text {
text: root.hasError ? "err" : root.price < 0 ? "…" : root.compactPrice(root.price)
color: t.cryptoValue
font.pixelSize: 13
font.bold: true
verticalAlignment: Text.AlignVCenter
}
Text {
visible: root.price >= 0
text: root.change24h >= 0 ? "▲" : "▼"
visible: root._typing || root.price >= 0
text: root._typing
? (root.price >= 0 ? (root.change24h >= 0 ? "▲" : "▼") : "").substring(0, root._typedIcon)
: (root.price >= 0 ? (root.change24h >= 0 ? "▲" : "▼") : "")
color: root.change24h >= 0 ? (t.cryptoUp) : (t.cryptoDown)
font.pixelSize: 9
font.bold: true

View File

@@ -21,7 +21,13 @@ PanelWindow {
property bool popupOpen: false
property real popupCenterX: -1
function _doOpen() { hideTimer.stop(); visible = true; popupOpen = true }
function _doOpen() {
hideTimer.stop()
if (chipRoot) {
popupCenterX = chipRoot.mapToItem(null, 0, 0).x + chipRoot.width / 2
}
visible = true; popupOpen = true
}
function open() {
_doOpen()
}

View File

@@ -118,8 +118,6 @@ QtObject {
// ────────────────────────────────────────────────────────────────────────
// LAUNCHER
// ────────────────────────────────────────────────────────────────────────
property string launcherScript: Quickshell.env("HOME") + "/.config/quickshell/launcher/launcher.sh"
property string launcherCacheDir: "/tmp/qs_launcher"
property bool launcherCloseOnClickOutside: true
property bool launcherSelectFirst: false
property bool launcherMipmapIcons: false
@@ -142,7 +140,7 @@ QtObject {
//
// This list is the *default*; pairs can be added/removed live in the
// Settings popup and those changes are persisted across restarts.
property var cryptoPairs: [{ coin: "bitcoin", currency: "usd" }, { coin: "ethereum", currency: "eur" }, { coin: "monero", currency: "usd" }, { coin: "bitcoin", currency: "eur" }, { coin: "bitcoin", currency: "brl" }, { coin: "ethereum", currency: "usd" }]
property var cryptoPairs: [{ coin: "bitcoin", currency: "usd" }, { coin: "ethereum", currency: "eur" }, { coin: "monero", currency: "usd" }, { coin: "bitcoin", currency: "eur" }, { coin: "bitcoin", currency: "brl" }, { coin: "ethereum", currency: "usd" }, { coin: "zcash", currency: "usd" }]
// ── Compact label decimals ────────────────────────────────────────────────
// 0 → "80k"

View File

@@ -3,8 +3,8 @@ import QtQuick.Layouts
import QtQuick.Controls
import Quickshell
import Quickshell.Wayland
import Quickshell.Io
import Quickshell.Hyprland
import Quickshell.Io
import "../config" as Cfg
import "../components"
@@ -21,6 +21,78 @@ PanelWindow {
readonly property int launcherGap: 12
property var allItems: []
property var favoriteIds: []
property var accessCounts: ({})
property bool _loaded: false
readonly property string _statePath: Quickshell.env("HOME") + "/.config/quickshell/launcher/launcher_state.json"
function buildItems() {
const apps = DesktopEntries.applications.values
const items = []
for (let i = 0; i < apps.length; i++) {
const e = apps[i]
items.push({
name: e.name,
exec: e.execString,
id: e.id,
icon: e.icon,
entry: e,
actions: e.actions
})
}
items.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()))
root.allItems = items
}
Component.onCompleted: buildItems()
Connections {
target: DesktopEntries
function onApplicationsChanged() { root.buildItems() }
}
FileView {
id: stateFile
path: root._statePath
printErrors: false
watchChanges: true
onFileChanged: reload()
onAdapterUpdated: writeAdapter()
adapter: JsonAdapter {
id: stateAdapter
property list<string> favorites: []
property var access: ({})
}
onLoaded: {
root.favoriteIds = stateAdapter.favorites
root.accessCounts = stateAdapter.access
root._loaded = true
}
onLoadFailed: {
root._loaded = true
}
}
function toggleFavorite(itemId) {
const arr = root.favoriteIds.slice()
const idx = arr.indexOf(itemId)
if (idx >= 0)
arr.splice(idx, 1)
else
arr.push(itemId)
root.favoriteIds = arr
stateAdapter.favorites = root.favoriteIds
}
function incrementAccess(itemId) {
const counts = Object.assign({}, root.accessCounts)
counts[itemId] = (counts[itemId] || 0) + 1
root.accessCounts = counts
stateAdapter.access = root.accessCounts
}
GlobalShortcut {
id: launcherShortcut
name: "toggle"
@@ -30,13 +102,14 @@ PanelWindow {
property bool _isOpen: false
readonly property bool isOpen: _isOpen
property bool _keyboardNav: false
property var allItems: []
property bool loading: false
property int lastCacheUpdate: 0
property var favoriteIds: []
property var accessCounts: ({})
readonly property string statePath: Quickshell.env("HOME") + "/.config/quickshell/launcher/launcher_state.json"
property string _expandingActions: ""
property string _savedExpandId: ""
property real _savedContentY: 0
property bool _pendingRestore: false
property bool _inRestore: false
property string expandedItemId: ""
readonly property int defaultIndex: Cfg.Config.launcherSelectFirst ? 0 : -1
@@ -52,15 +125,33 @@ PanelWindow {
let rest = filtered.filter(i => !root.favoriteIds.includes(i.id))
rest.sort((a, b) => (root.accessCounts[b.id] || 0) - (root.accessCounts[a.id] || 0))
const result = []
function pushItem(item) {
result.push(Object.assign({}, item, { isHeader: false, isAction: false }))
if (root.expandedItemId && item.id === root.expandedItemId && item.actions && item.actions.length > 0) {
for (let j = 0; j < item.actions.length; j++) {
const a = item.actions[j]
result.push({
isHeader: false,
isAction: true,
name: a.name,
id: item.id + ":" + a.id,
icon: a.icon,
actionObj: a
})
}
}
}
if (favs.length > 0) {
result.push({ isHeader: true, section: "Favorites" })
for (let i = 0; i < favs.length; i++)
result.push(Object.assign({}, favs[i], { isHeader: false }))
pushItem(favs[i])
}
if (rest.length > 0) {
result.push({ isHeader: true, section: "All Applications" })
for (let i = 0; i < rest.length; i++)
result.push(Object.assign({}, rest[i], { isHeader: false }))
pushItem(rest[i])
}
return result
}
@@ -88,16 +179,16 @@ PanelWindow {
closeTimer.stop()
visible = true
_isOpen = true
expandedItemId = ""
searchInput.text = ""
resultsList.currentIndex = root.defaultIndex
Qt.callLater(() => resultsList.positionViewAtBeginning())
loadItems()
loadState()
Qt.callLater(() => searchInput.forceActiveFocus())
}
function close() {
_isOpen = false
expandedItemId = ""
closeTimer.restart()
}
@@ -108,108 +199,18 @@ PanelWindow {
}
}
function loadItems() {
loading = true
var now = Date.now()
var staleMs = 300000
if (now - lastCacheUpdate < staleMs) {
readCache()
return
}
readCache()
lastCacheUpdate = now
updateProc.command = [Cfg.Config.launcherScript, "--update-only"]
updateProc.running = true
}
function readCache() {
const path = Cfg.Config.launcherCacheDir + "/drun.txt"
dataProc.rawOutput = ""
dataProc.command = ["cat", path]
dataProc.running = true
}
function shellQuote(str) {
return "'" + str.replace(/'/g, "'\\''") + "'"
}
function parseExec(str) {
const args = []
let current = ""
let inSingle = false
let inDouble = false
for (let i = 0; i < str.length; i++) {
const ch = str[i]
if (inSingle) {
if (ch === "'") inSingle = false
else current += ch
} else if (inDouble) {
if (ch === '"') inDouble = false
else current += ch
} else if (ch === "'") {
inSingle = true
} else if (ch === '"') {
inDouble = true
} else if (/\s/.test(ch)) {
if (current.length > 0) {
args.push(current)
current = ""
}
} else {
current += ch
}
}
if (current.length > 0) args.push(current)
return args
}
function loadState() {
stateReadProc.command = ["sh", "-c", "cat " + shellQuote(statePath) + " 2>/dev/null || true"]
stateReadProc.running = true
}
function saveState() {
const data = JSON.stringify({ favorites: favoriteIds, access: accessCounts })
stateWriteProc.command = ["sh", "-c",
"printf '%s' " + shellQuote(data) + " > " + shellQuote(statePath)]
stateWriteProc.running = true
}
function toggleFavorite(itemId) {
const arr = favoriteIds.slice()
const idx = arr.indexOf(itemId)
if (idx >= 0) {
arr.splice(idx, 1)
} else {
arr.push(itemId)
}
favoriteIds = arr
saveState()
}
function incrementAccess(itemId) {
const counts = Object.assign({}, accessCounts)
counts[itemId] = (counts[itemId] || 0) + 1
accessCounts = counts
saveState()
}
function launch(item) {
if (!item || !item.exec) return
if (!item || !item.entry) return
item.entry.execute()
console.log("Launching: " + item.name)
root.incrementAccess(item.id)
close()
}
let cleanExec = item.exec.replace(/%[fFuUik]/g, "").trim()
const logPath = "/tmp/qml_launch.log"
const args = parseExec(cleanExec)
const cmd = args.map(a => shellQuote(a)).join(" ") + " > " + shellQuote(logPath) + " 2>&1 < /dev/null &"
launchProc.command = ["sh", "-c", cmd]
launchProc.running = true
console.log("Launching: " + cleanExec)
incrementAccess(item.id)
function executeAction(item) {
if (!item || !item.actionObj) return
item.actionObj.execute()
console.log("Executing action: " + item.name)
close()
}
@@ -228,61 +229,9 @@ PanelWindow {
PopupHideTimer {
id: closeTimer
onTriggered: {
root.visible = false
root.allItems = []
}
onTriggered: root.visible = false
}
Process {
id: updateProc
onRunningChanged: if (!running) root.readCache()
}
Process {
id: dataProc
property string rawOutput: ""
stdout: SplitParser { onRead: data => dataProc.rawOutput += data + "\n" }
onRunningChanged: {
if (!running && rawOutput) {
const lines = rawOutput.trim().split("\n")
const items = []
for (let i = 0; i < lines.length; i++) {
const p = lines[i].split("\t")
if (p.length >= 3) items.push({ name: p[0], exec: p[1], id: p[2], icon: p[3] || "" })
}
root.allItems = items
root.loading = false
}
}
}
Process { id: launchProc }
Process {
id: stateReadProc
property string rawData: ""
stdout: SplitParser { onRead: data => stateReadProc.rawData += data + "\n" }
onRunningChanged: {
if (!running) {
if (rawData.trim()) {
try {
const state = JSON.parse(rawData.trim())
if (Array.isArray(state.favorites))
root.favoriteIds = state.favorites
if (state.access && typeof state.access === "object")
root.accessCounts = state.access
} catch (e) {
console.log("Failed to parse launcher state: " + e)
}
}
rawData = ""
}
}
}
Process { id: stateWriteProc }
MouseArea {
anchors.fill: parent
@@ -355,12 +304,48 @@ PanelWindow {
verticalAlignment: TextInput.AlignVCenter
selectionColor: t.launcherAccent
onTextChanged: resultsList.currentIndex = root.defaultIndex
onTextChanged: {
resultsList.currentIndex = root.defaultIndex
root.expandedItemId = ""
}
Keys.onReturnPressed: {
const idx = root.skipHeader(Math.max(0, resultsList.currentIndex), 1)
if (idx >= 0)
root.launch(root.filteredItems[idx])
if (idx < 0) return
const item = root.filteredItems[idx]
if (item.isAction)
root.executeAction(item)
else
root.launch(item)
}
Keys.onTabPressed: (event) => {
event.accepted = true
root._keyboardNav = true
const idx = root.skipHeader(Math.max(0, resultsList.currentIndex), 1)
if (idx < 0) return
const item = root.filteredItems[idx]
if (!item || item.isHeader || item.isAction) return
root._savedContentY = resultsList.contentY
root._savedExpandId = item.id
root._expandingActions = item.id
if (root.expandedItemId === item.id) {
root._pendingRestore = true
root.expandedItemId = ""
} else if (item.actions && item.actions.length > 0) {
root._pendingRestore = true
root.expandedItemId = item.id
}
Qt.callLater(() => {
for (let i = 0; i < resultsList.count; i++) {
const it = root.filteredItems[i]
if (it.id === item.id && !it.isAction && !it.isHeader) {
resultsList.currentIndex = i
resultsList.positionViewAtIndex(i, ListView.Contain)
break
}
}
})
}
Keys.onDownPressed: {
@@ -432,19 +417,51 @@ PanelWindow {
flickableDirection: Flickable.VerticalFlick
onModelChanged: {
currentIndex = root.defaultIndex
for (let i = 0; i < count; i++) {
const md = root.filteredItems[i]
if (md && !md.isHeader) {
currentIndex = i
break
if (root._expandingActions !== "") {
root._expandingActions = ""
if (root._savedExpandId !== "") {
Qt.callLater(() => {
for (let i = 0; i < resultsList.count; i++) {
const it = root.filteredItems[i]
if (it.id === root._savedExpandId && !it.isAction && !it.isHeader) {
resultsList.currentIndex = i
resultsList.positionViewAtIndex(i, ListView.Contain)
break
}
}
root._pendingRestore = false
root._savedExpandId = ""
})
} else {
root._pendingRestore = false
}
} else {
currentIndex = root.defaultIndex
for (let i = 0; i < count; i++) {
const md = root.filteredItems[i]
if (md && !md.isHeader && !md.isAction) {
currentIndex = i
break
}
}
Qt.callLater(() => positionViewAtBeginning())
}
Qt.callLater(() => positionViewAtBeginning())
}
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
Connections {
target: resultsList
function onContentYChanged() {
if (root._pendingRestore && !root._inRestore && Math.abs(resultsList.contentY - root._savedContentY) > 1) {
root._inRestore = true
resultsList.contentY = root._savedContentY
root._inRestore = false
}
}
}
delegate: Rectangle {
id: itemDelegate
width: resultsList.width
height: 38
radius: modelData.isHeader ? 0 : 8
@@ -452,6 +469,7 @@ PanelWindow {
: (starHovered || hovered) ? t.launcherHover
: (resultsList.currentIndex === index && index !== -1
? t.launcherSelected : "transparent")
clip: modelData.isAction
property bool hovered: false
property bool starHovered: false
@@ -467,11 +485,12 @@ PanelWindow {
RowLayout {
visible: !modelData.isHeader
anchors.fill: parent
anchors.leftMargin: 12
anchors.leftMargin: modelData.isAction ? 28 : 12
anchors.rightMargin: 12
spacing: 12
Item {
visible: !modelData.isAction
width: 22; height: 22
Layout.alignment: Qt.AlignVCenter
@@ -496,14 +515,31 @@ PanelWindow {
}
Text {
text: modelData.name
color: t.launcherText
visible: modelData.isAction
text: ""
color: t.launcherDim
font.pixelSize: 14
Layout.alignment: Qt.AlignVCenter
}
Text {
text: modelData.name
color: modelData.isAction ? t.launcherDim : t.launcherText
font.pixelSize: modelData.isAction ? 13 : 14
Layout.fillWidth: true
elide: Text.ElideRight
}
Text {
visible: !modelData.isAction && modelData.actions && modelData.actions.length > 0
text: root.expandedItemId === modelData.id ? "▲" : "▼"
color: t.launcherDim
font.pixelSize: 9
Layout.alignment: Qt.AlignVCenter
}
Item {
visible: !modelData.isAction
width: 26; height: 26
Layout.alignment: Qt.AlignVCenter
@@ -525,20 +561,40 @@ PanelWindow {
}
MouseArea {
acceptedButtons: Qt.LeftButton | Qt.RightButton
anchors.fill: parent
hoverEnabled: true
onEntered: parent.hovered = true
onExited: { parent.hovered = false; parent.starHovered = false }
onPositionChanged: mouse => {
parent.starHovered = !modelData.isHeader && (mouse.x >= width - 46)
parent.starHovered = !modelData.isHeader && !modelData.isAction && (mouse.x >= width - 46)
}
onClicked: mouse => {
if (modelData.isHeader) return
if (mouse.x >= width - 46) {
root.toggleFavorite(modelData.id)
} else {
root.launch(modelData)
if (mouse.button === Qt.RightButton) {
if (!modelData.isAction && modelData.actions && modelData.actions.length > 0) {
root._savedContentY = resultsList.contentY
root._savedExpandId = modelData.id
root._expandingActions = modelData.id
root._pendingRestore = true
if (root.expandedItemId === modelData.id)
root.expandedItemId = ""
else
root.expandedItemId = modelData.id
}
return
}
if (modelData.isAction) {
root.executeAction(modelData)
return
}
if (mouse.x >= width - 46)
root.toggleFavorite(modelData.id)
else
root.launch(modelData)
}
}
}

View File

@@ -1,103 +0,0 @@
#!/usr/bin/env bash
CACHE_DIR="/tmp/qs_launcher"
DRUN_CACHE="$CACHE_DIR/drun.txt"
RUN_CACHE="$CACHE_DIR/run.txt"
mkdir -p "$CACHE_DIR"
get_desktop_files() {
IFS=':' read -ra DIRS <<< \
"${XDG_DATA_DIRS:-/usr/local/share:/usr/share}"
for dir in "${DIRS[@]}"; do
APP_DIR="$dir/applications"
[ -d "$APP_DIR" ] || continue
find "$APP_DIR" \
-maxdepth 1 \
-name "*.desktop" \
2>/dev/null
done | while read -r f; do
name=$(
grep -m1 '^Name=' "$f" \
| cut -d= -f2-
)
exec=$(
grep -m1 '^Exec=' "$f" \
| cut -d= -f2- \
| sed 's/%[fFuUik]//g'
)
type=$(
grep -m1 '^Type=' "$f" \
| cut -d= -f2-
)
nodisplay=$(
grep -m1 '^NoDisplay=' "$f" \
| cut -d= -f2- \
| tr '[:upper:]' '[:lower:]'
)
icon=$(
grep -m1 '^Icon=' "$f" \
| cut -d= -f2-
)
[[ "$type" == "Application" ]] || continue
[[ "$nodisplay" != "true" ]] || continue
[[ -n "$name" ]] || continue
printf "%s\t%s\t%s\t%s\n" \
"$name" \
"$exec" \
"$(basename "$f" .desktop)" \
"$icon"
done
}
get_binaries() {
compgen -c \
| sort -u \
| head -600 \
| awk '{
print $1 "\t" $1 "\t" $1
}'
}
sort_and_dedup() {
sort -f -t$'\t' -k1,1 \
| awk -F'\t' '!seen[$1]++'
}
build_drun() {
get_desktop_files | sort_and_dedup
}
build_run() {
{ get_desktop_files; get_binaries; } | sort_and_dedup
}
update_cache() {
build_drun > "$DRUN_CACHE.tmp"
build_run > "$RUN_CACHE.tmp"
mv "$DRUN_CACHE.tmp" "$DRUN_CACHE"
mv "$RUN_CACHE.tmp" "$RUN_CACHE"
}
if [ "$1" == "--update-only" ]; then
update_cache
exit 0
fi