noctalia-shell/Bar/Modules/Applauncher.qml

430 lines
No EOL
18 KiB
QML

import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Io
import qs.Components
import qs.Settings
import Quickshell.Wayland
import "../../Helpers/Fuzzysort.js" as Fuzzysort
PanelWithOverlay {
id: appLauncherPanel
WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand
function showAt() {
appLauncherPanelRect.showAt();
}
function hidePanel() {
appLauncherPanelRect.hidePanel();
}
function show() {
appLauncherPanelRect.showAt();
}
function dismiss() {
appLauncherPanelRect.hidePanel();
}
Rectangle {
id: appLauncherPanelRect
implicitWidth: 460
implicitHeight: 640
color: "transparent"
visible: parent.visible
property bool shouldBeVisible: false
anchors.top: parent.top
anchors.horizontalCenter: parent.horizontalCenter
function showAt() {
appLauncherPanel.visible = true;
shouldBeVisible = true;
searchField.forceActiveFocus();
root.selectedIndex = 0;
root.appModel = DesktopEntries.applications.values;
root.updateFilter();
}
function hidePanel() {
shouldBeVisible = false;
searchField.text = "";
root.selectedIndex = 0;
}
Rectangle {
id: root
width: 460
height: 640
x: (parent.width - width) / 2
color: Theme.backgroundPrimary
bottomLeftRadius: 28
bottomRightRadius: 28
property var appModel: DesktopEntries.applications.values
property var filteredApps: []
property int selectedIndex: 0
property int targetY: (parent.height - height) / 2
y: appLauncherPanelRect.shouldBeVisible ? targetY : -height
Behavior on y {
NumberAnimation {
duration: 300
easing.type: Easing.OutCubic
}
}
scale: appLauncherPanelRect.shouldBeVisible ? 1 : 0
Behavior on scale {
NumberAnimation {
duration: 200
easing.type: Easing.InOutCubic
}
}
onScaleChanged: {
if (scale === 0 && !appLauncherPanelRect.shouldBeVisible) {
appLauncherPanel.visible = false;
}
}
function isMathExpression(str) {
return /^[-+*/().0-9\s]+$/.test(str);
}
function safeEval(expr) {
try {
return Function('return (' + expr + ')')();
} catch (e) {
return undefined;
}
}
function updateFilter() {
var query = searchField.text ? searchField.text.toLowerCase() : "";
var apps = root.appModel.slice();
var results = [];
// Calculator mode: starts with '='
if (query.startsWith("=")) {
var expr = searchField.text.slice(1).trim();
if (expr && isMathExpression(expr)) {
var value = safeEval(expr);
if (value !== undefined && value !== null && value !== "") {
results.push({
isCalculator: true,
name: `Calculator: ${expr} = ${value}`,
result: value,
expr: expr,
icon: "calculate"
});
}
}
}
if (!query || query.startsWith("=")) {
results = results.concat(apps.sort(function (a, b) {
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
}));
} else {
var fuzzyResults = Fuzzysort.go(query, apps, {
keys: ["name", "comment", "genericName"]
});
results = results.concat(fuzzyResults.map(function (r) {
return r.obj;
}));
}
root.filteredApps = results;
root.selectedIndex = 0;
}
function selectNext() {
if (filteredApps.length > 0)
selectedIndex = Math.min(selectedIndex + 1, filteredApps.length - 1);
}
function selectPrev() {
if (filteredApps.length > 0)
selectedIndex = Math.max(selectedIndex - 1, 0);
}
function activateSelected() {
if (filteredApps.length === 0)
return;
var modelData = filteredApps[selectedIndex];
const termEmu = Quickshell.env("TERMINAL") || Quickshell.env("TERM_PROGRAM") || none;
if (modelData.isCalculator) {
Qt.callLater(function () {
Quickshell.clipboardText = String(modelData.result);
Quickshell.execDetached(["notify-send", "Calculator Result", `${modelData.expr} = ${modelData.result} (copied to clipboard)`]);
});
} else if (modelData.runInTerminal && termEmu){
Quickshell.execDetached([termEmu, "-e", modelData.execString.trim()]);
} else if (modelData.execute) {
modelData.execute();
} else {
var execCmd = modelData.execString || modelData.exec || "";
if (execCmd) {
execCmd = execCmd.replace(/\s?%[fFuUdDnNiCkvm]/g, '');
Quickshell.execDetached(["sh", "-c", execCmd.trim()]);
}
}
appLauncherPanel.hidePanel();
searchField.text = "";
}
Component.onCompleted: updateFilter()
ColumnLayout {
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: parent.bottom
anchors.margins: 32
spacing: 18
// Search Bar
Rectangle {
id: searchBar
color: Theme.surfaceVariant
radius: 22
height: 48
Layout.fillWidth: true
border.color: searchField.activeFocus ? Theme.accentPrimary : Theme.outline
border.width: searchField.activeFocus ? 2 : 1
RowLayout {
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: 14
anchors.rightMargin: 14
spacing: 10
Text {
text: "search"
font.family: "Material Symbols Outlined"
font.pixelSize: Theme.fontSizeHeader
color: searchField.activeFocus ? Theme.accentPrimary : Theme.textSecondary
verticalAlignment: Text.AlignVCenter
Layout.alignment: Qt.AlignVCenter
}
TextField {
id: searchField
placeholderText: "Search apps..."
color: Theme.textPrimary
placeholderTextColor: Theme.textSecondary
background: null
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeBody
Layout.fillWidth: true
Layout.alignment: Qt.AlignVCenter
onTextChanged: root.updateFilter()
selectedTextColor: Theme.onAccent
selectionColor: Theme.accentPrimary
padding: 0
verticalAlignment: TextInput.AlignVCenter
leftPadding: 0
rightPadding: 0
topPadding: 0
bottomPadding: 0
font.bold: true
Component.onCompleted: contentItem.cursorColor = Theme.textPrimary
onActiveFocusChanged: contentItem.cursorColor = Theme.textPrimary
Keys.onDownPressed: root.selectNext()
Keys.onUpPressed: root.selectPrev()
Keys.onEnterPressed: root.activateSelected()
Keys.onReturnPressed: root.activateSelected()
Keys.onEscapePressed: appLauncherPanel.hidePanel()
}
}
Behavior on border.color {
ColorAnimation {
duration: 120
}
}
Behavior on border.width {
NumberAnimation {
duration: 120
}
}
}
// App List Card
Rectangle {
color: Theme.surface
radius: 20
Layout.fillWidth: true
Layout.fillHeight: true
clip: true
property int innerPadding: 16
Item {
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
height: parent.innerPadding
visible: false
}
ListView {
id: appList
anchors.fill: parent
anchors.margins: parent.innerPadding
spacing: 2
model: root.filteredApps
currentIndex: root.selectedIndex
delegate: Item {
id: appDelegate
width: appList.width
height: 48
property bool hovered: mouseArea.containsMouse
property bool isSelected: index === root.selectedIndex
Rectangle {
anchors.fill: parent
color: hovered || isSelected ? Theme.accentPrimary : "transparent"
radius: 12
border.color: hovered || isSelected ? Theme.accentPrimary : "transparent"
border.width: hovered || isSelected ? 2 : 0
Behavior on color {
ColorAnimation {
duration: 120
}
}
Behavior on border.color {
ColorAnimation {
duration: 120
}
}
Behavior on border.width {
NumberAnimation {
duration: 120
}
}
}
RowLayout {
anchors.fill: parent
anchors.leftMargin: 10
anchors.rightMargin: 10
spacing: 10
Item {
width: 28
height: 28
property bool iconLoaded: !modelData.isCalculator && iconImg.status === Image.Ready && iconImg.source !== "" && iconImg.status !== Image.Error
Image {
id: iconImg
anchors.fill: parent
fillMode: Image.PreserveAspectFit
smooth: true
cache: false
asynchronous: true
source: modelData.isCalculator ? "qrc:/icons/calculate.svg" : Quickshell.iconPath(modelData.icon, "application-x-executable")
visible: modelData.isCalculator || parent.iconLoaded
}
Text {
anchors.centerIn: parent
visible: !modelData.isCalculator && !parent.iconLoaded
text: "broken_image"
font.family: "Material Symbols Outlined"
font.pixelSize: Theme.fontSizeHeader
color: Theme.accentPrimary
}
}
ColumnLayout {
Layout.fillWidth: true
spacing: 1
Text {
text: modelData.name
color: hovered || isSelected ? Theme.onAccent : Theme.textPrimary
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.bold: hovered || isSelected
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
Layout.fillWidth: true
}
Text {
text: modelData.isCalculator ? (modelData.expr + " = " + modelData.result) : (modelData.comment || modelData.genericName || "No description available")
color: hovered || isSelected ? Theme.onAccent : Theme.textSecondary
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeCaption
font.italic: !(modelData.comment || modelData.genericName)
opacity: (modelData.comment || modelData.genericName) ? 1.0 : 0.6
elide: Text.ElideRight
Layout.fillWidth: true
}
}
Item {
Layout.fillWidth: true
}
Text {
text: modelData.isCalculator ? "content_copy" : "chevron_right"
font.family: "Material Symbols Outlined"
font.pixelSize: Theme.fontSizeBody
color: hovered || isSelected ? Theme.onAccent : Theme.textSecondary
verticalAlignment: Text.AlignVCenter
}
}
Rectangle {
id: ripple
anchors.fill: parent
color: Theme.onAccent
opacity: 0.0
}
MouseArea {
id: mouseArea
anchors.fill: parent
hoverEnabled: true
onClicked: {
ripple.opacity = 0.18;
rippleNumberAnimation.start();
root.selectedIndex = index;
root.activateSelected();
}
cursorShape: Qt.PointingHandCursor
onPressed: ripple.opacity = 0.18
onReleased: ripple.opacity = 0.0
}
NumberAnimation {
id: rippleNumberAnimation
target: ripple
property: "opacity"
to: 0.0
duration: 320
}
Rectangle {
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: 1
color: Theme.outline
opacity: index === appList.count - 1 ? 0 : 0.10
}
}
}
}
}
}
Corners {
id: launcherCornerRight
position: "bottomleft"
size: 1.1
fillColor: Theme.backgroundPrimary
anchors.top: root.top
offsetX: 416
offsetY: 0
}
Corners {
id: launcherCornerLeft
position: "bottomright"
size: 1.1
fillColor: Theme.backgroundPrimary
anchors.top: root.top
offsetX: -416
offsetY: 0
}
}
}