diff --git a/Launcher.qml b/Launcher.qml new file mode 100644 index 0000000..cd68251 --- /dev/null +++ b/Launcher.qml @@ -0,0 +1,345 @@ +import QtQml +import QtQml.Models +import QtQuick +import QtQuick.Effects +import QtQuick.Layouts +import QtQuick.Shapes +import QtQuick.Controls +import Quickshell +import Quickshell.Io +import Quickshell.Widgets + +PanelWindow { + id: launcher + + function hide() { + visible = false + list.visible = false + searchInput.text = "" + } + function show() { + visible = true + list.visible = true + list.modelChanged() + } + + implicitWidth: 800 + exclusiveZone: 0 + focusable: true + anchors { + right: true + top: true + bottom: true + } + readonly property int radius: 20 + readonly property var font: { + family: "comfortaa" + } + readonly property int entry_height: 100 + property var currentApps: { + let apps = Array.from(DesktopEntries.applications.values); + if (searchInput.text.length === 0) { + apps.sort((a, b) => a.name.localeCompare(b.name)); + return apps; + } + apps = apps.filter(app => + app.name.toLowerCase().search(searchInput.text.toLowerCase()) !== -1 + ); + return apps + } + property string lastLaunched: "" + + color: "transparent" + Item { + anchors.fill: parent + Keys.onPressed: (event) => { + switch (event.key) { + case Qt.Key_Down: + list.incrementCurrentIndex() + break; + case Qt.Key_Up: + list.decrementCurrentIndex() + break; + case Qt.Key_Escape: + launcher.visible = false + break; + case Qt.Key_Return: + list.currentItem.execute() + } + } + + // TODO: Swap ListView for ScriptModel + ListView { + id: list + spacing: 6 + anchors.fill: parent + highlightMoveDuration: 150 + highlightRangeMode: ListView.StrictlyEnforceRange + preferredHighlightBegin: 120 + preferredHighlightEnd: this.height/2 + onModelChanged: { + if (searchInput.text === "") + currentIndex = currentApps.findIndex(entry => + entry.id === lastLaunched + ); + } + + model: currentApps + delegate: Button { + height: entry_height + width: launcher.width + 10 + property var leftMargin: (Math.pow(Math.abs(this.y - list.contentY - launcher.height/2), 1.8) / 1400) + anchors { + left: parent?.left + leftMargin: leftMargin + selectedOffset + } + z: -index + property real selectedOffset: ListView.isCurrentItem ? 16 : 64 + function execute() { + modelData.execute() + launcher.lastLaunched = modelData.id + launcher.hide() + } + Behavior on selectedOffset { + NumberAnimation {duration: 500; easing.type: Easing.OutQuint} + } + id: button + onClicked: execute() + + // Calculate background color based on average color of icon + Canvas { + id: canvas + width: icon.width/2 + height: icon.height/2 + visible: false + onImageLoaded: { + requestPaint(); + } + Component.onCompleted: loadImage(button.iconPath) + } + property var color: { + if (!canvas.available) { + return null + } + const ctx = canvas.getContext("2d"); + ctx.drawImage(button.iconPath, 0, 0, canvas.width, canvas.height) + const pixels = ctx.getImageData(0, 0, 100, 100).data; + let avg = [0, 0, 0, 0]; + let count = 0; + for (let i = 0; i < pixels.length; i += 4) { + let c_max = Math.max(pixels[i+0], pixels[i+1], pixels[i+2]); + let c_min = Math.min(pixels[i+0], pixels[i+1], pixels[i+2]); + let saturation = c_max === 0 ? 0 : (c_max - c_min) / c_max; + avg[0] += pixels[i+0] * saturation; + avg[1] += pixels[i+1] * saturation; + avg[2] += pixels[i+2] * saturation; + count += pixels[i+3] * saturation; + } + return Qt.rgba(avg[0]/count, avg[1]/count, avg[2]/count, 1); + } + property var iconPath: Quickshell.iconPath(modelData.icon) + + // Background gradient + Rectangle { + anchors.fill: parent + radius: launcher.radius + gradient: Gradient { + orientation: Gradient.Horizontal + GradientStop { + color: Qt.darker(button.color, 16) + position: 0 + } + GradientStop { + color: Qt.darker(button.color, 4) + position: 1 + } + } + } + + // Triangles + Item { + id: triangles + visible: false + anchors.fill: parent + clip: true + layer.enabled: true + Instantiator { + model: 6 + delegate: Shape { + id: tri + property real x_size: Math.random() * button.width/12 + button.width/10 + property real y_size: x_size * Math.SQRT1_2 + + property real y_anim: 0 + property real y_off: Math.random() * entry_height + x: Math.random() * (button.width + x_size*2) - x_size + y: (y_anim + y_off) % (entry_height + y_size) - y_size + ShapePath { + strokeWidth: 2 + strokeColor: Qt.darker(button.color, 2.5) + fillColor: "transparent" + + startX: tri.x_size/2 + startY: 0 + PathLine {x: 0; y: tri.y_size} + PathLine {x: tri.x_size; y: tri.y_size} + PathLine {x: tri.x_size/2; y: 0} + } + PropertyAnimation on y_anim { + from: entry_height + tri.y_size + running: launcher.visible + to: 0 + duration: Math.random() * 12000 + 5000 + loops: Animation.Infinite + } + } + onObjectAdded: (index, obj) => obj.parent = triangles + } + } + Rectangle { + id: triMask + x: triangles.x + y: triangles.y + width: triangles.width + height: triangles.height + visible: false + layer.enabled: true + radius: launcher.radius + color: "red" + } + ShaderEffect { + anchors.fill: triangles + property var src: triangles + property var mask: triMask + vertexShader: "default.vert.qsb" + fragmentShader: "trifade.frag.qsb" + + } + + Item { + anchors.fill: parent + clip: true + Image { + id: icon + source: button.iconPath + width: 80 + height: width + anchors { + right: parent.right + rightMargin: 140 + verticalCenter: parent.verticalCenter + } + } + } + + // App name/description + Column { + anchors { + leftMargin: 12 + left: parent.left + verticalCenter: parent.verticalCenter + } + spacing: 4 + Text { + text: modelData.name + font.family: launcher.font + font.pixelSize: 16 + font.bold: true + color: "white" + } + Text { + visible: modelData.comment.length > 0 + width: parent.parent.width - icon.anchors.rightMargin-icon.width - 20 + text: modelData.comment + font.family: launcher.font + color: "white" + elide: Text.ElideRight + } + } + + // Drop shadow + background: RectangularShadow { + anchors.fill: parent + radius: launcher.radius + blur: 8 + spread: 4 + opacity: 0.6 + } + } + } + + Item { + id: searchBox + anchors { + left: parent.left + right: parent.right + top: parent.top + topMargin: 12 + rightMargin: 12 + } + height: 80 + + TextInput { + z: 5 + id: searchInput + font.family: launcher.font + font.pixelSize: 16 + font.bold: true + focus: true + anchors { + verticalCenter: parent.verticalCenter + left: parent.left + leftMargin: 45 + } + color: "white" + onActiveFocusChanged: focus = true; + } + Rectangle { + color: "#333" + radius: launcher.radius + anchors { + fill: parent + leftMargin: 35 + } + Rectangle { + anchors { + left: parent.left + top: parent.top + bottom: parent.bottom + } + width: parent.width - 60 + radius: launcher.radius + color: "#222" + } + transform: [Shear { + xFactor: -0.15 + }] + RectangularShadow { + z: -1 + anchors.fill: parent + radius: launcher.radius + blur: 8 + spread: 4 + opacity: 0.6 + } + } + Text { + text: "Search apps..." + anchors.fill: searchInput + font: searchInput.font + color: "#888" + visible: searchInput.text.length === 0 + } + Image { + source: Quickshell.iconPath("search") + anchors { + right: searchBox.right + verticalCenter: searchBox.verticalCenter + rightMargin: 26 + } + width: 24 + height: 24 + } + } + } +} diff --git a/shell.qml b/shell.qml index 085fc2b..31af72d 100644 --- a/shell.qml +++ b/shell.qml @@ -1,354 +1,20 @@ -import QtQml -import QtQml.Models -import QtQuick -import QtQuick.Effects -import QtQuick.Layouts -import QtQuick.Shapes -import QtQuick.Controls +import "." as Osu import Quickshell import Quickshell.Io -import Quickshell.Widgets - -PanelWindow { - id: root - - function hide() { - visible = false - list.visible = false - searchInput.text = "" - } +ShellRoot { IpcHandler { target: "launcher" - function show() { - visible = true - list.visible = true - list.modelChanged - } function hide() { - root.hide() - searchInput.text = "" + launcher.hide() } - function toggle() { visible ? hide() : show() } + function show() { + launcher.show() + } + function toggle() { launcher.visible ? hide() : show() } } - - implicitWidth: 800 - exclusiveZone: 0 - focusable: true - anchors { - right: true - top: true - bottom: true - } - readonly property int radius: 20 - readonly property var font: { - family: "comfortaa" - } - readonly property int entry_height: 100 - property var currentApps: { - let apps = Array.from(DesktopEntries.applications.values); - if (searchInput.text.length === 0) { - apps.sort((a, b) => a.name.localeCompare(b.name)); - return apps; - } - apps = apps.filter(app => - app.name.toLowerCase().search(searchInput.text.toLowerCase()) !== -1 - ); - return apps - } - property string lastLaunched: "" - - color: "transparent" - Item { - anchors.fill: parent - Keys.onPressed: (event) => { - switch (event.key) { - case Qt.Key_Down: - list.incrementCurrentIndex() - break; - case Qt.Key_Up: - list.decrementCurrentIndex() - break; - case Qt.Key_Escape: - root.visible = false - break; - case Qt.Key_Return: - list.currentItem.execute() - } - } - - // TODO: Swap ListView for ScriptModel - ListView { - id: list - spacing: 6 - anchors.fill: parent - highlightMoveDuration: 150 - highlightRangeMode: ListView.StrictlyEnforceRange - preferredHighlightBegin: 120 - preferredHighlightEnd: this.height/2 - onModelChanged: { - if (searchInput.text === "") - currentIndex = currentApps.findIndex(entry => - entry.id === lastLaunched - ); - } - - model: currentApps - delegate: Button { - height: entry_height - width: root.width + 10 - property var leftMargin: (Math.pow(Math.abs(this.y - list.contentY - root.height/2), 1.8) / 1400) - anchors { - left: parent?.left - leftMargin: leftMargin + selectedOffset - } - z: -index - property real selectedOffset: ListView.isCurrentItem ? 16 : 64 - function execute() { - modelData.execute() - root.lastLaunched = modelData.id - root.hide() - } - Behavior on selectedOffset { - NumberAnimation {duration: 500; easing.type: Easing.OutQuint} - } - id: button - onClicked: execute() - - // Calculate background color based on average color of icon - Canvas { - id: canvas - width: icon.width/2 - height: icon.height/2 - visible: false - onImageLoaded: { - requestPaint(); - } - Component.onCompleted: loadImage(button.iconPath) - } - property var color: { - if (!canvas.available) { - return null - } - const ctx = canvas.getContext("2d"); - ctx.drawImage(button.iconPath, 0, 0, canvas.width, canvas.height) - const pixels = ctx.getImageData(0, 0, 100, 100).data; - let avg = [0, 0, 0, 0]; - let count = 0; - for (let i = 0; i < pixels.length; i += 4) { - let c_max = Math.max(pixels[i+0], pixels[i+1], pixels[i+2]); - let c_min = Math.min(pixels[i+0], pixels[i+1], pixels[i+2]); - let saturation = c_max === 0 ? 0 : (c_max - c_min) / c_max; - avg[0] += pixels[i+0] * saturation; - avg[1] += pixels[i+1] * saturation; - avg[2] += pixels[i+2] * saturation; - count += pixels[i+3] * saturation; - } - return Qt.rgba(avg[0]/count, avg[1]/count, avg[2]/count, 1); - } - property var iconPath: Quickshell.iconPath(modelData.icon) - - // Background gradient - Rectangle { - anchors.fill: parent - radius: root.radius - gradient: Gradient { - orientation: Gradient.Horizontal - GradientStop { - color: Qt.darker(button.color, 16) - position: 0 - } - GradientStop { - color: Qt.darker(button.color, 4) - position: 1 - } - } - } - - // Triangles - Item { - id: triangles - visible: false - anchors.fill: parent - clip: true - layer.enabled: true - Instantiator { - model: 6 - delegate: Shape { - id: tri - property real x_size: Math.random() * button.width/12 + button.width/10 - property real y_size: x_size * Math.SQRT1_2 - - property real y_anim: 0 - property real y_off: Math.random() * entry_height - x: Math.random() * (button.width + x_size*2) - x_size - y: (y_anim + y_off) % (entry_height + y_size) - y_size - ShapePath { - strokeWidth: 2 - strokeColor: Qt.darker(button.color, 2.5) - fillColor: "transparent" - - startX: tri.x_size/2 - startY: 0 - PathLine {x: 0; y: tri.y_size} - PathLine {x: tri.x_size; y: tri.y_size} - PathLine {x: tri.x_size/2; y: 0} - } - PropertyAnimation on y_anim { - from: entry_height + tri.y_size - running: root.visible - to: 0 - duration: Math.random() * 12000 + 5000 - loops: Animation.Infinite - } - } - onObjectAdded: (index, obj) => obj.parent = triangles - } - } - Rectangle { - id: triMask - x: triangles.x - y: triangles.y - width: triangles.width - height: triangles.height - visible: false - layer.enabled: true - radius: root.radius - color: "red" - } - ShaderEffect { - anchors.fill: triangles - property var src: triangles - property var mask: triMask - vertexShader: "default.vert.qsb" - fragmentShader: "trifade.frag.qsb" - - } - - Item { - anchors.fill: parent - clip: true - Image { - id: icon - source: button.iconPath - width: 80 - height: width - anchors { - right: parent.right - rightMargin: 140 - verticalCenter: parent.verticalCenter - } - } - } - - // App name/description - Column { - anchors { - leftMargin: 12 - left: parent.left - verticalCenter: parent.verticalCenter - } - spacing: 4 - Text { - text: modelData.name - font.family: root.font - font.pixelSize: 16 - font.bold: true - color: "white" - } - Text { - visible: modelData.comment.length > 0 - width: parent.parent.width - icon.anchors.rightMargin-icon.width - 20 - text: modelData.comment - font.family: root.font - color: "white" - elide: Text.ElideRight - } - } - - // Drop shadow - background: RectangularShadow { - anchors.fill: parent - radius: root.radius - blur: 8 - spread: 4 - opacity: 0.6 - } - } - } - - Item { - id: searchBox - anchors { - left: parent.left - right: parent.right - top: parent.top - topMargin: 12 - rightMargin: 12 - } - height: 80 - - TextInput { - z: 5 - id: searchInput - font.family: root.font - font.pixelSize: 16 - font.bold: true - focus: true - anchors { - verticalCenter: parent.verticalCenter - left: parent.left - leftMargin: 45 - } - color: "white" - onActiveFocusChanged: focus = true; - } - Rectangle { - color: "#333" - radius: root.radius - anchors { - fill: parent - leftMargin: 35 - } - Rectangle { - anchors { - left: parent.left - top: parent.top - bottom: parent.bottom - } - width: parent.width - 60 - radius: root.radius - color: "#222" - } - transform: [Shear { - xFactor: -0.15 - }] - RectangularShadow { - z: -1 - anchors.fill: parent - radius: root.radius - blur: 8 - spread: 4 - opacity: 0.6 - } - } - Text { - text: "Search apps..." - anchors.fill: searchInput - font: searchInput.font - color: "#888" - visible: searchInput.text.length === 0 - } - Image { - source: Quickshell.iconPath("search") - anchors { - right: searchBox.right - verticalCenter: searchBox.verticalCenter - rightMargin: 26 - } - width: 24 - height: 24 - } - } + + Osu.Launcher { + id: launcher } }