summaryrefslogtreecommitdiff
path: root/launcher
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
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')
-rw-r--r--launcher/ILauncher.qml29
-rw-r--r--launcher/Launcher.qml371
-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
-rw-r--r--launcher/todo.md33
16 files changed, 1013 insertions, 0 deletions
diff --git a/launcher/ILauncher.qml b/launcher/ILauncher.qml
new file mode 100644
index 0000000..8793b97
--- /dev/null
+++ b/launcher/ILauncher.qml
@@ -0,0 +1,29 @@
+import Quickshell
+import Quickshell.Hyprland
+import QtQuick.Controls
+import QtQuick
+
+import qs.components
+import qs.config
+
+IWindow {
+ id: root
+ required property var parentLoader
+ // TODO: optional only display on focused screen
+ focusable: true
+
+ anchors {
+ top: false
+ bottom: false
+ left: false
+ right: false
+ }
+
+ HyprlandFocusGrab {
+ active: true
+ windows: [root]
+ onCleared: {
+ parentLoader.active = false; // or active = true again
+ }
+ }
+}
diff --git a/launcher/Launcher.qml b/launcher/Launcher.qml
new file mode 100644
index 0000000..e32dae5
--- /dev/null
+++ b/launcher/Launcher.qml
@@ -0,0 +1,371 @@
+import Quickshell
+import QtQuick
+import QtQuick.Layouts
+
+import "components"
+import qs.components
+import qs.config
+
+// TODO: currently, we're relying on the compositor to provide the animation which ig is fine, but we should use our own in the future, however it might look weird combined with the compositors's
+ILauncher {
+ id: launcher
+ name: "quickshell::launcher::launcher"
+
+ anchors.top: true
+ margins.top: Launcher.topMargin
+ aboveWindows: true
+ exclusionMode: ExclusionMode.Ignore
+
+ IRect {
+ id: container
+ property var selection: SelectionState?.selected || null
+ property var mappedSelection: selection?.mapToItem(null, 0, 0)
+ onMappedSelectionChanged: {
+ // SelectionState?.selected may be slow.. smh qt
+ // my guess is mapToItem is called when its not finished doing whatever it needs to do,
+ // so it doesnt get the right vars, but it doesn't update after since it already triggered it
+ selectionSync.running = true;
+ }
+ Timer {
+ id: selectionSync
+ running: false
+ repeat: false
+ // sync w/ IComponent y animation duration
+ interval: General.animationDuration / 4
+ onTriggered: {
+ parent.mappedSelection = SelectionState?.selected?.mapToItem(null, 0, 0);
+ }
+ }
+
+ IRect {
+ id: activeRect
+ visible: parent.mappedSelection || false
+ y: parent.mappedSelection?.y || 0
+ x: parent.mappedSelection?.x || 0
+ implicitWidth: SelectionState?.selected?.width || 0
+ implicitHeight: SelectionState?.selected?.height || 0
+ radius: Launcher.borderRadius
+ color: Qt.rgba(Colors.foreground2.r, Colors.foreground2.g, Colors.foreground2.b, Launcher.bgTransparency)
+ Behavior on x {
+ ISpringAnimation {}
+ }
+ Behavior on y {
+ ISpringAnimation {}
+ }
+ Behavior on implicitWidth {
+ ISpringAnimation {}
+ }
+ Behavior on height {
+ ISpringAnimation {}
+ }
+ }
+ color: Qt.rgba(Colors.background3.r, Colors.background3.g, Colors.background3.b, Launcher.bgTransparency)
+ radius: Launcher.borderRadius
+
+ width: Math.max(root.width, Launcher.defaultWidth) + Launcher.innerMargin
+ height: root.height
+
+ IRect {
+ id: root
+ property alias input: textInput.text
+
+ height: searchBar.height + widgets.height + textInput.height / 2 // * 1.5
+ width: Math.max(widgets.implicitWidth, Launcher.defaultWidth)
+
+ anchors.centerIn: parent
+
+ color: "transparent"
+
+ RowLayout {
+ id: searchBar
+ anchors {
+ top: parent.top
+ left: parent.left
+ right: parent.right
+ leftMargin: Launcher.innerMargin
+ rightMargin: Launcher.innerMargin
+ // try to center it when no widgets are present
+ topMargin: widgets.height === Launcher.widgets.length * 5 - 5 ? parent.height / 2 - textInput.height / 2 : Launcher.innerMargin // textInput or Icon is the height of this
+
+ Behavior on topMargin {
+ ISpringAnimation {}
+ // NumberAnimation {
+ // duration: General.animationDuration / 2
+ // }
+ }
+ }
+
+ spacing: Launcher.innerMargin * 2
+
+ Icon {
+ text: ""
+ }
+
+ RowLayout {
+ spacing: 0
+ TextInput {
+ id: textInput
+ Layout.fillHeight: true
+ Layout.fillWidth: true
+ Layout.maximumWidth: implicitWidth
+ color: Colors.foreground1
+ selectionColor: Colors.background3
+ text: SelectionState?.input || ""
+ clip: true
+ focus: true
+ renderType: Text.CurveRendering
+ antialiasing: true
+ smooth: true
+ font {
+ pixelSize: Style.font.size.large
+ family: Style.font.family.sans
+ wordSpacing: 5
+ }
+ // move to different file if we have more textinput
+ cursorDelegate: IRect {
+ id: cursor
+ color: Colors.cursor
+ // width: 1.5
+ width: 2
+ height: 1
+ // blinking animation
+ SequentialAnimation {
+ running: true
+ loops: Animation.Infinite
+ PauseAnimation {
+ duration: 500
+ }
+ ParallelAnimation {
+ PropertyAnimation {
+ target: cursor
+ property: "opacity"
+ to: 0
+ duration: General.animationDuration / 5
+ }
+ PropertyAnimation {
+ target: cursor
+ property: "scale"
+ to: 0
+ duration: General.animationDuration / 5
+ }
+ }
+ PauseAnimation {
+ duration: 500
+ }
+ ParallelAnimation {
+ PropertyAnimation {
+ target: cursor
+ property: "opacity"
+ to: 1
+ duration: General.animationDuration / 5
+ }
+ PropertyAnimation {
+ target: cursor
+ property: "scale"
+ to: 1
+ duration: General.animationDuration / 5
+ }
+ }
+ }
+ }
+ onTextChanged: {
+ if (SelectionState.input !== textInput.text)
+ SelectionState.input = textInput.text; // TODO: find ways to optimize this, like how i was using an alias
+ }
+ onCursorPositionChanged: {
+ SelectionState.cursorPosition = textInput.cursorPosition;
+ }
+ // onActiveFocusChanged: {
+ // parentLoader.active = activeFocus;
+ // }
+ onAccepted: {
+ SelectionState?.priorities[0]?._exec(); // TODO: kde like waiting animation for app to launch
+ parentLoader.active = false;
+ textInput.text = "";
+ }
+ Component.onCompleted: {
+ selectAll();
+ }
+ }
+ IRect {
+ opacity: SelectionState?.priorities[0]?.predictiveCompletion ? 1 : 0
+ Layout.fillHeight: true
+ Layout.preferredWidth: text.width
+ radius: Launcher.predictiveCompletionRadius
+ color: Qt.rgba(Colors.background2.r, Colors.background2.g, Colors.background2.b, Launcher.widgetBgTransparency)
+ IText {
+ id: text
+ // animate: true // too jumpy
+ color: Colors.foreground3
+ renderType: Text.CurveRendering
+ antialiasing: true
+ smooth: true
+ font {
+ pixelSize: Style.font.size.large
+ family: Style.font.family.sans
+ wordSpacing: 5
+ }
+ text: SelectionState?.priorities[0]?.predictiveCompletion || ""
+ }
+ Behavior on Layout.preferredWidth {
+ ISpringAnimation {}
+ // NumberAnimation {
+ // duration: General.animationDuration / 5
+ // }
+ }
+ Behavior on opacity {
+ NumberAnimation {
+ duration: General.animationDuration / 2
+ easing.type: Easing.InOutQuad
+ }
+ }
+ }
+ Item {
+ Layout.fillWidth: true
+ }
+ Loader {
+ active: SelectionState?.priorities[0]?.priority || false // .priority should always be true if it's in priorities
+ sourceComponent: SelectionState?.priorities[0]?.preview
+ }
+ }
+ }
+
+ ColumnLayout {
+ id: widgets
+ anchors {
+ top: searchBar.bottom
+ left: parent.left
+ right: parent.right
+ topMargin: Launcher.innerMargin * 1.5 // gap in between, maybe seperator FIXME
+ }
+
+ Repeater {
+ model: Launcher.widgets
+ delegate: Loader {
+ active: true
+ sourceComponent: modelData
+ Component.onCompleted: {
+ SelectionState.widgets[index] = item;
+ // FIXME: prev predictiveCompletion still exists here
+ // workaround atm: just clear it or preserve the text manually
+ }
+ Connections {
+ target: item
+ function onClose() {
+ parentLoader.active = false;
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // not Keys.onPressed because textinput will steal it
+ Shortcut {
+ sequence: "Ctrl+H"
+ context: Qt.ApplicationShortcut
+ onActivated: {
+ const item = SelectionState?.priorities[0];
+ if (!item)
+ return;
+ item.prev();
+ }
+ }
+
+ Shortcut {
+ sequence: "Ctrl+J"
+ context: Qt.ApplicationShortcut
+ onActivated: {
+ const item = SelectionState?.priorities[0];
+ if (!item)
+ return;
+ item.down();
+ }
+ }
+
+ Shortcut {
+ sequence: "Ctrl+K"
+ context: Qt.ApplicationShortcut
+ onActivated: {
+ const item = SelectionState?.priorities[0];
+ if (!item)
+ return;
+ item.up();
+ }
+ }
+
+ Shortcut {
+ sequence: "Ctrl+L"
+ context: Qt.ApplicationShortcut
+ onActivated: {
+ const item = SelectionState?.priorities[0];
+ if (!item)
+ return;
+ item.next();
+ }
+ }
+
+ Shortcut {
+ sequence: "Escape"
+ context: Qt.ApplicationShortcut
+ onActivated: parentLoader.active = false
+ }
+
+ Shortcut {
+ sequence: "Down"
+ context: Qt.ApplicationShortcut
+ onActivated: {
+ const item = SelectionState?.priorities[0];
+ item?.down();
+ }
+ }
+
+ Shortcut {
+ sequence: "Up"
+ context: Qt.ApplicationShortcut
+ onActivated: {
+ const item = SelectionState?.priorities[0];
+ item?.up();
+ }
+ }
+
+ Shortcut {
+ sequence: "PageDown"
+ context: Qt.ApplicationShortcut
+ onActivated: {
+ const item = SelectionState?.priorities[0];
+ item?.pageDown();
+ }
+ }
+
+ Shortcut {
+ sequence: "PageUp"
+ context: Qt.ApplicationShortcut
+ onActivated: {
+ const item = SelectionState?.priorities[0];
+ item?.pageUp();
+ }
+ }
+
+ Shortcut {
+ sequence: "Home"
+ context: Qt.ApplicationShortcut
+ onActivated: {
+ const item = SelectionState?.priorities[0];
+ item?.home();
+ }
+ }
+
+ Shortcut {
+ sequence: "End"
+ context: Qt.ApplicationShortcut
+ onActivated: {
+ const item = SelectionState?.priorities[0];
+ item?.end();
+ }
+ }
+ }
+
+ implicitWidth: container.width
+ implicitHeight: container.height
+}
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
diff --git a/launcher/todo.md b/launcher/todo.md
new file mode 100644
index 0000000..bfc2103
--- /dev/null
+++ b/launcher/todo.md
@@ -0,0 +1,33 @@
+when finished:
+- [x] reloads dynamically while typing in
+- [x] prefix: ... for certain ambiguous queries such as "pi" or "e" for math
+- [ ] bang (like ddg's)
+- [ ] web search catch all
+- [x] height dynamically shrink/grow according to content
+
+- [ ] web:
+ - [ ] show multiple options:
+ - [ ] first - search w/ search engine and open in browser
+ - [ ] second - fallback/second option
+ - [ ] custom quick search/fetch
+ - [ ] e.g. wikipedia: (also stuff like spotify, youtube)
+ - [ ] match title
+ - [ ] match first paragraph/description
+
+- [ ] files:
+ - [ ] latest modified date
+ - [ ] file size
+ - [ ] where it belongs
+ - [ ] icon/preview/quick look
+
+- [ ] math:
+ - [x] expression
+ - [ ] natural language (i.e. "5 of 3")
+ - [ ] unit conversion
+ - [ ] temperature
+ - [ ] length
+ - [ ] weight
+ - [ ] currency
+ - [ ] color
+ - [ ] time
+