summaryrefslogtreecommitdiff
path: root/launcher/components
diff options
context:
space:
mode:
authorlittleblack111 <littleblack11111@gmail.com>2025-08-05 17:25:30 +0800
committerlittleblack111 <littleblack11111@gmail.com>2025-08-05 17:25:30 +0800
commitf9efa56caeeb5289d6b2314e50041a1b7055a3c8 (patch)
tree8a9e5b59f8690555ba8ad51f653db02d9483e2a7 /launcher/components
parentf6b5ac3cfba03602fea9d8c30b13075514dbd131 (diff)
feat: add launcher support and image clipboard history utility
- Integrated a new Launcher component into the shell.qml for improved application launching. - Implemented an IPC handler to toggle the launcher visibility. - Added a new script (cliphist-img.sh) to manage image history from the clipboard. - Removed the outdated fuzzysort.js utility to streamline the codebase. - Refactored the Levenshtein distance algorithm for better performance and clarity.
Diffstat (limited to 'launcher/components')
-rw-r--r--launcher/components/App.qml143
-rw-r--r--launcher/components/Calc.qml80
-rw-r--r--launcher/components/Clip.qml0
-rw-r--r--launcher/components/Finder.qml0
-rw-r--r--launcher/components/Flight.qml0
-rw-r--r--launcher/components/IComponent.qml72
-rw-r--r--launcher/components/IInnerComponent.qml34
-rw-r--r--launcher/components/SelectionState.qml27
-rw-r--r--launcher/components/Spell.qml224
-rw-r--r--launcher/components/Time.qml0
-rw-r--r--launcher/components/Translate.qml0
-rw-r--r--launcher/components/Weather.qml0
-rw-r--r--launcher/components/Web.qml0
13 files changed, 580 insertions, 0 deletions
diff --git a/launcher/components/App.qml b/launcher/components/App.qml
new file mode 100644
index 0000000..6f0e4ce
--- /dev/null
+++ b/launcher/components/App.qml
@@ -0,0 +1,143 @@
+import Quickshell
+import Quickshell.Widgets
+import QtQuick
+import QtQuick.Layouts
+
+import qs.components
+import qs.services
+import qs.config
+
+IComponent {
+ id: root
+
+ property list<DesktopEntry> entries: inputCleaned ? AppSearch.fuzzyQuery(inputCleaned) : []
+ property int selectedIndex: -1
+
+ name: "Applications"
+
+ implicitHeight: valid ? layout.height : 0
+
+ preview: Component {
+ IconImage {
+ source: Quickshell.iconPath(answer, "image-missing")
+ implicitWidth: General.appIconSize
+ implicitHeight: General.appIconSize
+ }
+ }
+
+ property string predictiveCompletion: valid ? entries[selectedIndex].name.slice(input.length) : ""
+
+ process: function () {
+ const isValid = entries.length > 0;
+ const selected = isValid ? entries[selectedIndex] : '';
+
+ return {
+ valid: isValid,
+ priority: isValid,
+ answer: selected ? selected.icon : ""
+ };
+ }
+
+ exec: function () {
+ SelectionState.selected.modelData.execute();
+ }
+
+ up: function () {
+ if (selectedIndex <= 0)
+ return true;
+ selectedIndex--;
+ }
+ down: function () {
+ if (selectedIndex + 1 > repeater.count - 1)
+ return true;
+ selectedIndex++;
+ }
+
+ onEntriesChanged: {
+ selectedIndex = 0;
+ flickable.contentY = 0;
+ // selectedIndexChanged dont get called somehow
+ syncSelectionState();
+ }
+
+ onSelectedIndexChanged: {
+ syncSelectionState();
+ if (selectedIndex < 0 || selectedIndex >= repeater.count)
+ return;
+ Qt.callLater(() => {
+ const i = repeater.itemAt(selectedIndex), t = i.y, b = i.y + i.height, v = flickable.contentY, vb = v + flickable.height, maxY = Math.max(0, flickable.contentHeight - flickable.height);
+ const y = t < v ? t : b > vb ? b - flickable.height : v;
+ flickable.contentY = Math.max(0, Math.min(maxY, y));
+ });
+ }
+
+ function syncSelectionState() {
+ Qt.callLater(() => {
+ // fucking Qt why tf is repeater not ready when this is called
+ SelectionState.selected = repeater.itemAt(selectedIndex);
+ SelectionState.exec = root.exec;
+ });
+ }
+
+ IInnerComponent {
+ id: layout
+ fromParent: false
+ width: parent.width
+ height: Math.min(innerLayout.height + titleBar.height, Launcher.widgetHeight * 1.5)
+
+ Flickable {
+ id: flickable
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ clip: true
+ contentWidth: innerLayout.width
+ contentHeight: innerLayout.height
+
+ ColumnLayout {
+ id: innerLayout
+ spacing: 0
+
+ Repeater {
+ id: repeater
+ model: entries
+
+ Item {
+ required property DesktopEntry modelData
+ required property int index
+ Layout.margins: Launcher.innerMargin * 2
+ implicitWidth: item.width
+ implicitHeight: item.height
+
+ RowLayout {
+ id: item
+ spacing: Launcher.innerMargin * 2
+
+ IconImage {
+ source: Quickshell.iconPath(modelData.icon, "image-missing")
+ implicitWidth: General.appIconSize
+ implicitHeight: General.appIconSize
+ }
+ IText {
+ text: modelData.name
+ }
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ hoverEnabled: true
+ onPositionChanged: {
+ root.selectedIndex = index;
+ }
+ onExited: {
+ root.selectedIndex = 0;
+ }
+ onPressed: {
+ root._exec();
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/launcher/components/Calc.qml b/launcher/components/Calc.qml
new file mode 100644
index 0000000..ec5b45f
--- /dev/null
+++ b/launcher/components/Calc.qml
@@ -0,0 +1,80 @@
+import Quickshell
+import QtQuick
+import QtQuick.Layouts
+
+import qs.components
+import qs.services
+import qs.config
+
+IComponent {
+ property int cursorPosition: SelectionState.cursorPosition
+ name: "Calculator"
+ preview: Component {
+ Icon {
+ text: ""
+ }
+ }
+
+ process: function () {
+ const isValid = /^(?=.*\d)(?=.*[+\-*\/\^])[0-9+\-*\/\^().\s]+$/.test(input);
+ const answer = isValid ? String(eval(input.replace(/\^/g, '**').replace(/([\d)])\(/g, '$1*(').replace(/\)([\d])/g, ')*$1'))) : null;
+ return {
+ valid: isValid,
+ priority: isValid,
+ answer: answer,
+ predictiveCompletion: isValid ? ' = ' + answer : ''
+ };
+ }
+ exec: function () {
+ Clip.copy(answer);
+ }
+
+ IInnerComponent {
+ RowLayout {
+ spacing: 0
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ Layout.preferredHeight: parent.height
+
+ Item {
+ Layout.fillWidth: true
+ IText {
+ anchors.centerIn: parent
+ elide: cursorPosition > input.length / 2 ? Text.ElideLeft : Text.ElideRight
+ width: Math.min(implicitWidth, parent.width - Launcher.innerMargin * 2)
+ clip: true
+ renderType: Text.CurveRendering
+ visible: valid
+ text: input.replace(/ /g, "").replace(/\+/g, " + ").replace(/-/g, " - ").replace(/\*/g, " × ").replace(/\//g, " ÷ ").replace(/%/g, " % ").replace(/\(/g, " ( ").replace(/\)/g, " ) ")
+ font {
+ pixelSize: Launcher.widgetFontSize
+ bold: true
+ }
+ }
+ }
+
+ IText {
+ visible: valid
+ text: "→"
+ font.pixelSize: Launcher.widgetFontSize
+ font.bold: true
+ }
+
+ Item {
+ Layout.fillWidth: true
+ IText {
+ animate: true
+ anchors.centerIn: parent
+ width: Math.min(implicitWidth, parent.width - Launcher.innerMargin * 2)
+ renderType: Text.CurveRendering
+ visible: valid
+ text: valid ? answer : ''
+ font {
+ pixelSize: Launcher.widgetFontSize
+ bold: true
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/launcher/components/Clip.qml b/launcher/components/Clip.qml
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/launcher/components/Clip.qml
diff --git a/launcher/components/Finder.qml b/launcher/components/Finder.qml
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/launcher/components/Finder.qml
diff --git a/launcher/components/Flight.qml b/launcher/components/Flight.qml
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/launcher/components/Flight.qml
diff --git a/launcher/components/IComponent.qml b/launcher/components/IComponent.qml
new file mode 100644
index 0000000..b8963d2
--- /dev/null
+++ b/launcher/components/IComponent.qml
@@ -0,0 +1,72 @@
+import Quickshell
+import QtQuick
+
+import qs.components
+import qs.config
+
+IRect {
+ id: root
+ // virtual properties
+ property string name // Component/File name
+ readonly property string input: SelectionState.input
+ readonly property string inputCleaned: input.toLowerCase().trim()
+ property string prefix
+ property bool valid: processed?.valid || false
+ property bool priority: valid && processed?.priority || false // please set priority to false if it's invalid
+ property string answer: processed?.answer || ""
+ property Component preview: Component {
+ IText {
+ animate: true
+ text: answer
+ }
+ }
+ property string predictiveCompletion: processed?.predictiveCompletion || "" // would technically work with just from answer, but stuff like calc's answer wouldnt have anything to do with the input
+ property var processed: input && input.startsWith(prefix) ? process() : {} // cached process, thought qml would do that automatically :/
+ property var process: () => ({
+ valid: valid,
+ priority: priority,
+ answer: answer,
+ preview: preview,
+ predictiveCompletion: predictiveCompletion
+ })// use the current IRect if valid is set
+ // actions
+ property var up: () => ({
+ top: false
+ })
+ property var down: () => ({
+ bottom: false
+ })
+ property var prev: () => {}
+ property var next: () => {}
+ property var exec: () => {}
+
+ signal close
+
+ function _exec() {
+ exec();
+ close();
+ }
+
+ opacity: valid ? 1 : 0
+ y: valid ? 0 : -Launcher.widgetHeight
+
+ implicitWidth: valid ? Launcher.widgetWidth : 0
+ implicitHeight: valid ? Launcher.widgetHeight : 0
+
+ // implicitWidth: Launcher.widgetWidth
+ // implicitHeight: Launcher.widgetHeight
+
+ radius: Launcher.widgetRadius
+ color: Qt.rgba(Colors.background3.r, Colors.background3.g, Colors.background3.b, Launcher.widgetBgTransparency) // TODO when prioritized, highlight
+
+ onPriorityChanged: {
+ SelectionState.priorities = [...[...SelectionState.priorities, root].reduce((s, x) => (s[(s.has(x) && 'delete') || 'add'](x), s), new Set())]; // sync with SelectionState.priorities
+ }
+
+ Behavior on y {
+ NumberAnimation {
+ duration: General.animationDuration / 4
+ easing.type: Easing.InOutQuad
+ }
+ }
+}
diff --git a/launcher/components/IInnerComponent.qml b/launcher/components/IInnerComponent.qml
new file mode 100644
index 0000000..7bbc9f0
--- /dev/null
+++ b/launcher/components/IInnerComponent.qml
@@ -0,0 +1,34 @@
+import QtQuick
+import QtQuick.Layouts
+
+import qs.components
+import qs.config
+
+ColumnLayout {
+ property bool fromParent: true
+ property alias titleBar: titleBar
+ anchors.fill: fromParent ? parent : undefined
+ spacing: 0
+ IRect {
+ id: titleBar
+ Layout.fillWidth: true
+ Layout.fillHeight: false
+ visible: Launcher.showWidgetTitle && valid
+ height: childrenRect.height
+ width: parent.width
+ topLeftRadius: Launcher.widgetRadius
+ topRightRadius: Launcher.widgetRadius
+ color: Qt.rgba(Colors.background3.r, Colors.background3.g, Colors.background3.b, Launcher.widgetTitleBgTransparency)
+
+ IText {
+ anchors {
+ top: parent.top
+ left: parent.left
+ leftMargin: Launcher.innerMargin
+ }
+ visible: valid
+ font.pixelSize: Launcher.widgetFontSize
+ text: name
+ }
+ }
+}
diff --git a/launcher/components/SelectionState.qml b/launcher/components/SelectionState.qml
new file mode 100644
index 0000000..00d2420
--- /dev/null
+++ b/launcher/components/SelectionState.qml
@@ -0,0 +1,27 @@
+pragma Singleton
+import Quickshell
+import QtQuick
+
+Singleton {
+ property Item selected: null
+ property string input: ""
+ property int cursorPosition: 0
+ property var priorities: []
+ property var widgets: []
+
+ // order them based on widgets(which came from order from config)
+ onPrioritiesChanged: {
+ // priorities = priorities.filter(item => item.priority === true); // bind loop
+ // dunno y ts works w/o triggering a binding loop but okay..
+ for (let i = 0; i < priorities.length; i++)
+ if (priorities[i].priority !== true)
+ priorities.splice(i, 1);
+
+ priorities.sort((a, b) => widgets.indexOf(a) - widgets.indexOf(b));
+ priorities.forEach((item, index) => {});
+ }
+
+ function clear() {
+ selected = null;
+ }
+}
diff --git a/launcher/components/Spell.qml b/launcher/components/Spell.qml
new file mode 100644
index 0000000..4501132
--- /dev/null
+++ b/launcher/components/Spell.qml
@@ -0,0 +1,224 @@
+import Quickshell
+import Quickshell.Io
+import QtQuick
+import QtQuick.Layouts
+
+import qs.components
+import qs.services
+import qs.config
+
+IComponent {
+ id: root
+
+ property list<string> aspell
+ property string word: input.slice(prefix.length)
+ property int selectedIndex: -1
+
+ name: "Spell"
+ prefix: name.toLowerCase() + " "
+
+ onValidChanged: {
+ if (valid)
+ return;
+
+ aspell = [];
+ proc.running = false;
+ selectedIndex = -1;
+ return;
+ }
+
+ onAspellChanged: {
+ selectedIndex = aspell.length > 0 ? 0 : -1;
+ // selectedIndexChanged dont get called somehow
+ syncSelectionState();
+ if (loader.item && loader.item.flickable) {
+ loader.item.flickable.contentY = 0;
+ }
+ }
+
+ onSelectedIndexChanged: {
+ syncSelectionState();
+ if (!loader.repeater || !loader.item || !loader.item.flickable)
+ return;
+ if (selectedIndex < 0 || selectedIndex >= loader.repeater.count)
+ return;
+ Qt.callLater(() => {
+ const flickable = loader.item.flickable;
+ const i = loader.repeater.itemAt(selectedIndex);
+ if (!i)
+ return;
+ const t = i.y;
+ const b = i.y + i.height;
+ const v = flickable.contentY;
+ const vb = v + flickable.height;
+ const maxY = Math.max(0, flickable.contentHeight - flickable.height);
+ const y = t < v ? t : b > vb ? b - flickable.height : v;
+ flickable.contentY = Math.max(0, Math.min(maxY, y));
+ });
+ }
+
+ process: function () {
+ proc.running = true;
+
+ const isValid = selectedIndex >= 0 && aspell.length > 0;
+ const selected = isValid ? aspell[selectedIndex] : "n";
+ const answer = selected === "*" ? word : selected;
+
+ return {
+ valid: isValid,
+ priority: isValid,
+ answer: isValid ? answer : "",
+ predictiveCompletion: isValid ? answer.slice(word.length) : ""
+ };
+ }
+
+ prev: function () {
+ if (selectedIndex <= 0)
+ return true;
+ selectedIndex--;
+ }
+
+ next: function () {
+ if (selectedIndex + 1 >= aspell.length)
+ return true;
+ selectedIndex++;
+ }
+
+ up: function () {
+ const cols = Math.max(1, Math.floor(innerLoader.width / Math.max(1, Math.round(Launcher.widgetFontSize * 6))));
+ if (selectedIndex - cols < 0)
+ return true;
+ selectedIndex -= cols;
+ }
+
+ down: function () {
+ const cols = Math.max(1, Math.floor(innerLoader.width / Math.max(1, Math.round(Launcher.widgetFontSize * 6))));
+ if (selectedIndex + cols >= aspell.length)
+ return true;
+ selectedIndex += cols;
+ }
+
+ exec: function () {
+ if (selectedIndex >= 0)
+ if (aspell[selectedIndex] !== "*")
+ Clip.copy(aspell[selectedIndex]);
+ else
+ Clip.copy(word);
+ }
+
+ function syncSelectionState() {
+ if (!loader.repeater)
+ return;
+
+ Qt.callLater(() => {
+ SelectionState.selected = loader.repeater.itemAt(selectedIndex);
+ SelectionState.exec = root.exec;
+ });
+ }
+
+ IInnerComponent {
+ id: innerLoader
+ Loader {
+ id: loader
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ Layout.preferredHeight: parent.height
+ sourceComponent: aspell[0] === "*" ? correct : show
+ property var repeater: null
+
+ readonly property Component show: Component {
+ Item {
+ property alias flickable: flickable
+
+ Flickable {
+ id: flickable
+ anchors.fill: parent
+ clip: true
+ contentWidth: grid.width
+ contentHeight: grid.height
+
+ GridLayout {
+ id: grid
+ width: flickable.width
+ columns: Math.max(1, Math.floor(width / Math.max(1, Math.round(Launcher.widgetFontSize * 6))))
+ rowSpacing: Launcher.innerMargin
+ columnSpacing: Launcher.innerMargin
+
+ Repeater {
+ id: repeater
+ model: aspell
+
+ IRect {
+ Layout.fillWidth: true
+ Layout.preferredWidth: Math.max(chipContent.implicitWidth + Launcher.innerMargin * 2, Math.floor(grid.width / grid.columns) - grid.columnSpacing)
+ Layout.preferredHeight: chipContent.implicitHeight + Launcher.innerMargin * 2
+ color: Qt.rgba(Colors.background2.r, Colors.background2.g, Colors.background2.b, Launcher.widgetBgTransparency)
+ radius: Launcher.widgetRadius
+
+ IText {
+ id: chipContent
+ anchors.centerIn: parent
+ text: modelData
+ font.pixelSize: Launcher.widgetFontSize
+ elide: Text.ElideRight
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ hoverEnabled: true
+ onPositionChanged: {
+ root.selectedIndex = index;
+ }
+ onPressed: {
+ root._exec();
+ }
+ }
+ }
+
+ Component.onCompleted: {
+ loader.repeater = repeater;
+ }
+ }
+ }
+ }
+ }
+ }
+
+ readonly property Component correct: Component {
+ IRect {
+ Layout.fillWidth: true
+ color: Qt.rgba(Colors.background1.r, Colors.background1.g, Colors.background1.b, Launcher.widgetBgTransparency / 4)
+ radius: Launcher.widgetRadius
+ IText {
+ anchors.centerIn: parent
+ text: "Correct"
+ font.pixelSize: Launcher.widgetFontSize
+ anchors.horizontalCenter: parent.horizontalCenter
+ }
+ }
+ }
+ }
+ }
+
+ Process {
+ id: proc
+ command: ["sh", "-c", "echo " + word + " | aspell -a"]
+ property bool found: false
+ stdout: SplitParser {
+ onRead: data => {
+ if (data.startsWith("&")) {
+ proc.found = true;
+ aspell = data.split(/\W+/).filter(word => /^[A-Za-z]+$/.test(word)).slice(1);
+ } else if (data.startsWith("*"))
+ aspell = ["*"];
+ }
+ }
+ onStarted: {
+ found = false;
+ }
+ onExited: {
+ if (!found)
+ aspell = [];
+ }
+ }
+}
diff --git a/launcher/components/Time.qml b/launcher/components/Time.qml
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/launcher/components/Time.qml
diff --git a/launcher/components/Translate.qml b/launcher/components/Translate.qml
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/launcher/components/Translate.qml
diff --git a/launcher/components/Weather.qml b/launcher/components/Weather.qml
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/launcher/components/Weather.qml
diff --git a/launcher/components/Web.qml b/launcher/components/Web.qml
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/launcher/components/Web.qml