summaryrefslogtreecommitdiff
path: root/popups
diff options
context:
space:
mode:
authorlittleblack111 <littleblack11111@gmail.com>2025-08-19 20:46:39 +0800
committerlittleblack111 <littleblack11111@gmail.com>2025-08-19 21:50:03 +0800
commit68e1a276192872c57931e9e43911a479d4619c4a (patch)
tree3cb7f752edc931971a429ead938eb1aa7e0af299 /popups
parenta607f21ebaf6f7752626c1f568f90b36ec40c2c8 (diff)
chore(launcher -> popups): reorganize to allow futher expansion on popups
refactor(launcher): ILauncher is now a derivative of IPopup
Diffstat (limited to 'popups')
-rw-r--r--popups/IPopup.qml18
-rw-r--r--popups/launcher/ILauncher.qml15
-rw-r--r--popups/launcher/Launcher.qml388
-rw-r--r--popups/launcher/components/App.qml181
-rw-r--r--popups/launcher/components/Calc.qml81
-rw-r--r--popups/launcher/components/Clip.qml199
-rw-r--r--popups/launcher/components/Emoji.qml176
-rw-r--r--popups/launcher/components/Finder.qml0
-rw-r--r--popups/launcher/components/Flight.qml0
-rw-r--r--popups/launcher/components/IComponent.qml93
-rw-r--r--popups/launcher/components/IInnerComponent.qml34
-rw-r--r--popups/launcher/components/SelectionState.qml49
-rw-r--r--popups/launcher/components/Spell.qml235
-rw-r--r--popups/launcher/components/Time.qml0
-rw-r--r--popups/launcher/components/Translate.qml0
-rw-r--r--popups/launcher/components/Weather.qml0
-rw-r--r--popups/launcher/components/Web.qml0
-rw-r--r--popups/launcher/todo.md37
18 files changed, 1506 insertions, 0 deletions
diff --git a/popups/IPopup.qml b/popups/IPopup.qml
new file mode 100644
index 0000000..59d569d
--- /dev/null
+++ b/popups/IPopup.qml
@@ -0,0 +1,18 @@
+import Quickshell.Wayland
+
+import qs.components
+
+IWindow {
+ required property var parentLoader
+ // TODO: optional only display on focused screen
+ focusable: true
+
+ layer: WlrLayer.Overlay
+
+ anchors {
+ top: false
+ bottom: false
+ left: false
+ right: false
+ }
+}
diff --git a/popups/launcher/ILauncher.qml b/popups/launcher/ILauncher.qml
new file mode 100644
index 0000000..325ab2c
--- /dev/null
+++ b/popups/launcher/ILauncher.qml
@@ -0,0 +1,15 @@
+import Quickshell.Hyprland
+
+import ".."
+
+IPopup {
+ id: root
+
+ HyprlandFocusGrab {
+ active: true
+ windows: [root]
+ onCleared: {
+ parentLoader.active = false; // or active = true again
+ }
+ }
+}
diff --git a/popups/launcher/Launcher.qml b/popups/launcher/Launcher.qml
new file mode 100644
index 0000000..25b2926
--- /dev/null
+++ b/popups/launcher/Launcher.qml
@@ -0,0 +1,388 @@
+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
+
+ property list<Component> widgetItems: Launcher.widgets
+ property var state: SelectionState
+
+ name: "quickshell::launcher::launcher"
+
+ anchors.top: true
+ margins.top: Launcher.topMargin
+ aboveWindows: true
+ exclusionMode: ExclusionMode.Ignore
+
+ IRect {
+ id: container
+ property var selection: launcher.state?.selected || null
+ property var mappedSelection: selection?.mapToItem(null, 0, 0)
+ onMappedSelectionChanged: {
+ // launcher.state?.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 = launcher.state?.selected?.mapToItem(null, 0, 0);
+ }
+ }
+
+ IRect {
+ id: activeRect
+ visible: parent.mappedSelection || false
+ y: parent.mappedSelection?.y || 0
+ x: parent.mappedSelection?.x || 0
+ implicitWidth: launcher.state?.selected?.width || 0
+ implicitHeight: launcher.state?.selected?.height || 0
+ radius: Launcher.borderRadius
+ color: Qt.rgba(Colors.background1.r, Colors.background1.g, Colors.background1.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
+ property bool pendingUpdate: false
+
+ Layout.fillHeight: true
+ Layout.fillWidth: true
+ Layout.maximumWidth: implicitWidth
+
+ color: Colors.foreground1
+ selectionColor: Colors.background3
+
+ text: launcher.state?.input || ""
+ clip: true
+ focus: true
+
+ 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 (launcher.state.input !== textInput.text)
+ pendingUpdate = true;
+ Qt.callLater(() => {
+ if (pendingUpdate) {
+ pendingUpdate = false;
+ launcher.state.input = textInput.text; // TODO: find ways to optimize this, like how i was using an alias
+ }
+ });
+ }
+ onCursorPositionChanged: {
+ launcher.state.cursorPosition = textInput.cursorPosition;
+ }
+ // onActiveFocusChanged: {
+ // parentLoader.active = activeFocus;
+ // }
+ onAccepted: {
+ launcher.state?.priorities[launcher.state.selectedPriority]?._exec(); // TODO: kde like waiting animation for app to launch
+ parentLoader.active = false;
+ textInput.text = "";
+ }
+ Component.onCompleted: {
+ selectAll();
+ }
+ }
+ IRect {
+ opacity: launcher.state?.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: launcher.state?.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: launcher.state?.priorities[0]?.priority || false // .priority should always be true if it's in priorities
+ sourceComponent: launcher.state?.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.widgetItems
+ delegate: Loader {
+ active: true
+ sourceComponent: modelData
+ asynchronous: true
+ onLoaded: {
+ launcher.state.widgets[index] = item;
+ // FIXME: prev predictiveCompletion still exists here
+ // workaround atm: just clear it or preserve the text manually
+ }
+ Connections {
+ target: item
+ ignoreUnknownSignals: true
+ function onClose() {
+ parentLoader.active = false;
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // not Keys.onPressed because textinput will steal it
+ Shortcut {
+ sequence: "Escape"
+ context: Qt.ApplicationShortcut
+ onActivated: parentLoader.active = false
+ }
+
+ Shortcut {
+ sequence: "Ctrl+H"
+ context: Qt.ApplicationShortcut
+ onActivated: changePos(1)
+ }
+
+ Shortcut {
+ sequence: "Ctrl+J"
+ context: Qt.ApplicationShortcut
+ onActivated: changePos(-2)
+ }
+
+ Shortcut {
+ sequence: "Ctrl+K"
+ context: Qt.ApplicationShortcut
+ onActivated: changePos(2)
+ }
+
+ Shortcut {
+ sequence: "Ctrl+L"
+ context: Qt.ApplicationShortcut
+ onActivated: changePos(-1)
+ }
+
+ Shortcut {
+ sequence: "Down"
+ context: Qt.ApplicationShortcut
+ onActivated: changePos(-2)
+ }
+
+ Shortcut {
+ sequence: "Up"
+ context: Qt.ApplicationShortcut
+ onActivated: changePos(2)
+ }
+
+ Shortcut {
+ sequence: "PageDown"
+ context: Qt.ApplicationShortcut
+ onActivated: changePos(-20)
+ }
+
+ Shortcut {
+ sequence: "PageUp"
+ context: Qt.ApplicationShortcut
+ onActivated: changePos(20)
+ }
+ }
+ // 1 = left
+ // -1 = right
+ // 2 = up
+ // -2 = down
+ function changePos(direction: int) {
+ const item = launcher.state?.priorities[launcher.state.selectedPriority] || null;
+ if (!item)
+ return;
+
+ let set = 0;
+
+ switch (direction) {
+ case 1:
+ if (item.prev())
+ set = -1;
+ break;
+ case -1:
+ if (item.next())
+ set = 1;
+ break;
+ case 2:
+ if (item.up())
+ set = -1;
+ break;
+ case -2:
+ if (item.down())
+ set = 1;
+ break;
+ case 20:
+ if (item.pgup())
+ set = -1;
+ break;
+ case -20:
+ if (item.pgdn())
+ set = 1;
+ break;
+ }
+
+ switch (set) {
+ case 1:
+ if (launcher.state.selectedPriority < launcher.state.priorities.length - 1)
+ launcher.state.selectedPriority++;
+ break;
+ case -1:
+ if (launcher.state.selectedPriority > 0)
+ launcher.state.selectedPriority--;
+ break;
+ }
+
+ selectionSync.running = true;
+ }
+
+ implicitWidth: container.width
+ implicitHeight: container.height
+}
diff --git a/popups/launcher/components/App.qml b/popups/launcher/components/App.qml
new file mode 100644
index 0000000..0bb5fd9
--- /dev/null
+++ b/popups/launcher/components/App.qml
@@ -0,0 +1,181 @@
+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: active ? AppSearch.query(inputCleaned) : []
+ property int selectedIndex: -1
+ property bool mouseTriggered: false
+
+ 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?.icon || ""
+ };
+ }
+
+ exec: function () {
+ SelectionState.selected.modelData.execute();
+ }
+
+ up: function () {
+ if (selectedIndex <= 0)
+ return true;
+ mouseTriggered = false;
+ selectedIndex--;
+ }
+ down: function () {
+ if (selectedIndex + 1 > listView.count - 1)
+ return true;
+ mouseTriggered = false;
+ selectedIndex++;
+ }
+
+ home: function () {
+ if (listView.count === 0)
+ return true;
+ mouseTriggered = false;
+ selectedIndex = -1;
+ selectedIndex = 0;
+ }
+ end: function () {
+ if (listView.count === 0)
+ return true;
+ mouseTriggered = false;
+ selectedIndex = listView.count;
+ selectedIndex = listView.count - 1;
+ }
+
+ pgup: function () {
+ if (listView.count === 0)
+ return true;
+ mouseTriggered = false;
+ const pageSize = Math.floor(listView.height / (General.appIconSize + Launcher.innerMargin * 2));
+ if (selectedIndex - pageSize < 0) {
+ selectedIndex = 0;
+ } else {
+ selectedIndex -= pageSize;
+ }
+ }
+
+ pgdn: function () {
+ if (listView.count === 0)
+ return true;
+ mouseTriggered = false;
+ const pageSize = Math.floor(listView.height / (General.appIconSize + Launcher.innerMargin * 2));
+ if (selectedIndex + pageSize >= listView.count) {
+ selectedIndex = listView.count - 1;
+ } else {
+ selectedIndex += pageSize;
+ }
+ }
+
+ onEntriesChanged: {
+ if (selectedIndex !== -1) {
+ syncSelectionState();
+ return;
+ }
+ selectedIndex = 0;
+ listView.contentY = 0;
+ syncSelectionState();
+ }
+
+ onSelectedIndexChanged: {
+ syncSelectionState();
+ if (selectedIndex < 0 || selectedIndex >= listView.count || mouseTriggered)
+ return;
+ Qt.callLater(() => {
+ listView.positionViewAtIndex(selectedIndex, ListView.Contain);
+ });
+ }
+
+ syncSelectionState: function () {
+ Qt.callLater(() => state.selected = listView.itemAtIndex(selectedIndex));
+ }
+
+ IInnerComponent {
+ id: layout
+ fromParent: false
+ width: parent.width
+ height: Math.min(listView.contentHeight + titleBar.height, Launcher.widgetHeight * 1.5)
+
+ ListView {
+ id: listView
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ clip: true
+ model: entries
+ spacing: 0
+
+ delegate: Item {
+ required property DesktopEntry modelData
+ required property int index
+ width: item.implicitWidth + Launcher.innerMargin * 4
+ height: item.height + Launcher.innerMargin * 4
+
+ RowLayout {
+ id: item
+ scale: index === selectedIndex ? 1.01 : 0.99
+ anchors.left: parent.left
+ anchors.top: parent.top
+ anchors.margins: Launcher.innerMargin * 2
+ spacing: Launcher.innerMargin * 2
+
+ IconImage {
+ source: Quickshell.iconPath(modelData.icon, "image-missing")
+ implicitWidth: General.appIconSize
+ implicitHeight: General.appIconSize
+ }
+ IText {
+ text: modelData.name
+ renderType: Text.QtRendering
+ color: index === selectedIndex ? Colors.foreground1 : Colors.foreground2
+ }
+
+ Behavior on scale {
+ ISpringAnimation {}
+ }
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ hoverEnabled: true
+ onPositionChanged: {
+ root.mouseTriggered = true;
+ root.selectedIndex = index;
+ }
+ onExited: {
+ root.mouseTriggered = true;
+ }
+ onPressed: {
+ root._exec();
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/popups/launcher/components/Calc.qml b/popups/launcher/components/Calc.qml
new file mode 100644
index 0000000..c6cdbc1
--- /dev/null
+++ b/popups/launcher/components/Calc.qml
@@ -0,0 +1,81 @@
+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/popups/launcher/components/Clip.qml b/popups/launcher/components/Clip.qml
new file mode 100644
index 0000000..bc8d65f
--- /dev/null
+++ b/popups/launcher/components/Clip.qml
@@ -0,0 +1,199 @@
+import Quickshell
+import Quickshell.Widgets
+import QtQuick
+import QtQuick.Layouts
+
+import qs.components
+import qs.services
+import qs.config
+
+IComponent {
+ id: root
+
+ property var clipHist: active ? Clip.query(inputCleaned) : []
+ property int selectedIndex: -1
+ property bool mouseTriggered: false
+
+ name: "Clipboard"
+
+ prefix: "clip"
+
+ implicitHeight: valid ? layout.height : 0
+
+ process: function () {
+ const isValid = clipHist.length > 0;
+ return {
+ valid: isValid,
+ priority: isValid
+ };
+ }
+
+ exec: function () {
+ Clip.decodeAndCopy(clipHist[selectedIndex].raw);
+ }
+
+ up: function () {
+ if (selectedIndex <= 0)
+ return true;
+ mouseTriggered = false;
+ selectedIndex--;
+ }
+ down: function () {
+ if (selectedIndex + 1 > listView.count - 1)
+ return true;
+ mouseTriggered = false;
+ selectedIndex++;
+ }
+
+ onClipHistChanged: {
+ if (selectedIndex !== -1) {
+ syncSelectionState();
+ return;
+ }
+ mouseTriggered = false;
+ selectedIndex = 0;
+ listView.positionViewAtBeginning();
+ syncSelectionState();
+ }
+
+ onSelectedIndexChanged: {
+ if (!mouseTriggered && selectedIndex >= 0 && selectedIndex < listView.count) {
+ Qt.callLater(() => {
+ listView.positionViewAtIndex(selectedIndex, ListView.Contain);
+ });
+ }
+ syncSelectionState();
+ }
+
+ syncSelectionState: function () {
+ Qt.callLater(() => state.selected = listView.itemAtIndex(selectedIndex));
+ }
+
+ IInnerComponent {
+ id: layout
+ fromParent: false
+ width: parent.width
+ height: Math.min(listView.contentHeight + titleBar.height, Launcher.widgetHeight * 1.5)
+
+ RowLayout {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+
+ ListView {
+ id: listView
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ clip: true
+ model: clipHist
+ spacing: 0
+
+ delegate: Item {
+ required property var modelData
+ required property int index
+ width: Math.min(item.implicitWidth + Launcher.innerMargin * 4, listView.width)
+ height: item.height + Launcher.innerMargin * 4
+
+ Row {
+ id: item
+ anchors.left: parent.left
+ anchors.top: parent.top
+ anchors.margins: Launcher.innerMargin * 2
+ spacing: Launcher.innerMargin * 2
+ clip: true
+
+ IconImage {
+ scale: index === root.selectedIndex ? 1.01 : 0.9
+ source: modelData?.appIcon
+ implicitSize: parent.height
+
+ Behavior on scale {
+ ISpringAnimation {}
+ }
+ }
+
+ Column {
+ Loader {
+ sourceComponent: modelData?.type === "image" ? img : text
+ readonly property Component text: IText {
+ text: modelData?.data || ""
+ color: index === root.selectedIndex ? Colors.foreground1 : Colors.foreground2
+ font.pixelSize: Launcher.widgetFontSize
+ }
+ readonly property Component img: Image {
+ source: modelData?.data || ""
+ }
+ }
+ Row {
+ spacing: Launcher.innerMargin
+ property string sinceWhen: TimeDate.sinceWhen(modelData?.timestamp) || ""
+ IText {
+ text: modelData.type
+ color: index === root.selectedIndex ? Colors.foreground2 : Colors.foreground3
+ font.pixelSize: Launcher.widgetFontSize / 1.35
+ }
+ IText {
+ visible: parent.sinceWhen
+ text: '·'
+ color: index === root.selectedIndex ? Colors.foreground2 : Colors.foreground3
+ font.pixelSize: Launcher.widgetFontSize / 1.35
+ }
+ IText {
+ text: parent.sinceWhen || ""
+ color: index === root.selectedIndex ? Colors.foreground2 : Colors.foreground3
+ font.pixelSize: Launcher.widgetFontSize / 1.35
+ }
+ }
+ }
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ hoverEnabled: true
+ onPositionChanged: {
+ root.mouseTriggered = true;
+ root.selectedIndex = index;
+ }
+ onExited: {
+ root.mouseTriggered = true;
+ }
+ onPressed: {
+ root._exec();
+ }
+ }
+ }
+ }
+
+ Flickable {
+ id: preview
+ clip: true
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+
+ contentWidth: loader.item.width
+ contentHeight: loader.item.height
+ flickableDirection: Flickable.VerticalFlick
+ boundsBehavior: Flickable.StopAtBounds
+
+ Loader {
+ id: loader
+ sourceComponent: clipHist[selectedIndex]?.type === "image" ? img : text
+ property Component text: TextEdit {
+ id: textEdit
+ readOnly: true
+ width: preview.width
+ color: Colors.foreground1
+ selectionColor: Colors.background3
+ text: clipHist[selectedIndex]?.decoded || ""
+ }
+ property Component img: Image {
+ id: image
+ width: preview.width
+ height: preview.height
+ source: clipHist[selectedIndex]?.image || ""
+ fillMode: Image.PreserveAspectFit
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/popups/launcher/components/Emoji.qml b/popups/launcher/components/Emoji.qml
new file mode 100644
index 0000000..d1343e6
--- /dev/null
+++ b/popups/launcher/components/Emoji.qml
@@ -0,0 +1,176 @@
+import Quickshell
+import Quickshell.Io
+import QtQuick
+import QtQuick.Layouts
+
+import qs.components
+import qs.services
+import qs.config
+
+IComponent {
+ id: root
+
+ property var emojis: active ? Emoji.query(input) : []
+ property int selectedIndex: -1
+
+ name: "Emoji"
+ prefix: name.toLowerCase() + " "
+
+ onEmojisChanged: {
+ Qt.callLater(() => {
+ if (selectedIndex !== -1) {
+ syncSelectionState();
+ return;
+ }
+ selectedIndex = emojis.length > 0 ? 0 : -1;
+ syncSelectionState();
+ if (loader.item && loader.item.flickable)
+ loader.item.flickable.contentY = 0;
+ });
+ }
+
+ onSelectedIndexChanged: {
+ if (!loader.view || !loader.item || !loader.item.flickable)
+ return;
+ if (selectedIndex < 0 || selectedIndex >= loader.view.count)
+ return;
+ Qt.callLater(() => {
+ syncSelectionState();
+ loader.view.currentIndex = selectedIndex;
+ loader.view.positionViewAtIndex(selectedIndex, GridView.Contain);
+ });
+ }
+
+ process: function () {
+ const isValid = selectedIndex >= 0 && emojis.length > 0;
+ const select = isValid ? emojis[selectedIndex] : null;
+ return {
+ valid: isValid,
+ priority: isValid,
+ answer: isValid ? select.emoji : ""
+ };
+ }
+
+ home: function () {
+ if (!loader.view.count)
+ return true;
+ selectedIndex = 0;
+ }
+ end: function () {
+ if (!loader.view.count)
+ return true;
+ selectedIndex = loader.view.count - 1;
+ }
+ prev: function () {
+ if (selectedIndex <= 0)
+ return true;
+ selectedIndex--;
+ }
+ next: function () {
+ if (selectedIndex + 1 >= emojis.length)
+ return true;
+ selectedIndex++;
+ }
+ up: function () {
+ const cols = loader.view ? Math.max(1, Math.floor(loader.view.width / loader.view.cellWidth)) : 1;
+ if (selectedIndex - cols < 0)
+ return true;
+ selectedIndex -= cols;
+ }
+ down: function () {
+ const cols = loader.view ? Math.max(1, Math.floor(loader.view.width / loader.view.cellWidth)) : 1;
+ if (selectedIndex + cols >= emojis.length)
+ return true;
+ selectedIndex += cols;
+ }
+
+ exec: function () {
+ if (selectedIndex >= 0 && emojis.length > 0)
+ Emoji.copy(emojis[selectedIndex].emoji);
+ }
+
+ syncSelectionState: function () {
+ Qt.callLater(() => state.selected = loader.view.itemAtIndex(selectedIndex));
+ }
+
+ IInnerComponent {
+ id: innerLoader
+
+ Loader {
+ id: loader
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ Layout.preferredHeight: parent.height
+ sourceComponent: show
+ property var view: null
+
+ readonly property Component show: Component {
+ Item {
+ property alias flickable: grid
+
+ GridView {
+ id: grid
+ anchors.fill: parent
+ clip: true
+ boundsBehavior: Flickable.StopAtBounds
+ cacheBuffer: cellHeight * 2
+ model: emojis
+ interactive: true
+ flow: GridView.FlowLeftToRight
+ snapMode: GridView.NoSnap
+
+ cellWidth: Math.min(width / Math.max(1, Math.floor(width / (Launcher.widgetFontSize * 5))), width / 3)
+ cellHeight: Launcher.widgetFontSize * 2.2 + Launcher.innerMargin * 2
+
+ delegate: Item {
+ required property var modelData
+ required property int index
+ width: grid.cellWidth
+ height: grid.cellHeight
+ scale: index === selectedIndex ? 1.03 : 0.97
+
+ IRect {
+ anchors.fill: parent
+ color: Qt.rgba(Colors.background2.r, Colors.background2.g, Colors.background2.b, Launcher.widgetBgTransparency)
+ radius: Launcher.widgetRadius
+
+ Column {
+ id: chipContent
+ anchors.centerIn: parent
+ spacing: 2
+
+ Icon {
+ anchors.horizontalCenter: parent.horizontalCenter
+ text: modelData.emoji
+ font.pixelSize: Launcher.widgetFontSize * 1.2
+ }
+ IText {
+ anchors.horizontalCenter: parent.horizontalCenter
+ text: modelData.name
+ font.pixelSize: Launcher.widgetFontSize * 0.7
+ color: index === selectedIndex ? Colors.foreground1 : Colors.foreground2
+ elide: Text.ElideRight
+ width: parent.parent.width - Launcher.innerMargin * 2
+ }
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ hoverEnabled: true
+ onPositionChanged: root.selectedIndex = index
+ onPressed: root._exec()
+ }
+ }
+
+ Behavior on scale {
+ ISpringAnimation {}
+ }
+ }
+
+ Component.onCompleted: loader.view = grid
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/popups/launcher/components/Finder.qml b/popups/launcher/components/Finder.qml
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/popups/launcher/components/Finder.qml
diff --git a/popups/launcher/components/Flight.qml b/popups/launcher/components/Flight.qml
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/popups/launcher/components/Flight.qml
diff --git a/popups/launcher/components/IComponent.qml b/popups/launcher/components/IComponent.qml
new file mode 100644
index 0000000..4f47b88
--- /dev/null
+++ b/popups/launcher/components/IComponent.qml
@@ -0,0 +1,93 @@
+import Quickshell
+import QtQuick
+
+import qs.components
+import qs.config
+
+IRect {
+ id: root
+ // virtual properties
+ property bool standalone: false
+ property var state: SelectionState
+ property string name // Component/File name
+ readonly property string _input: state.input
+ readonly property string input: !standalone ? _input.slice(prefix.length) : _input
+ readonly property string inputCleaned: input.toLowerCase().trim()
+ property string prefix
+ property bool active: !standalone ? input && _input.startsWith(prefix) : true
+ property bool valid: !standalone ? processed?.valid || false : true
+ property bool priority: !standalone ? valid && processed?.priority || false : true // 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: active ? 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
+ property var syncSelectionState: () => {}
+ // actions
+ property var up: () => ({
+ top: false
+ })
+ property var down: () => ({
+ bottom: false
+ })
+ property var prev: () => {}
+ property var next: () => {}
+ property var exec: () => {}
+ property var home: () => {}
+ property var end: () => {}
+ property var pgup: () => {}
+ property var pgdn: () => {}
+
+ 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: {
+ // sync with state.priorities
+ Qt.callLater(() => {
+ if (!standalone)
+ state.priorities = [...[...state.priorities, root].reduce((s, x) => (s[(s.has(x) && 'delete') || 'add'](x), s), new Set())];
+ else
+ state.priorities = [root];
+ });
+ }
+
+ Behavior on y {
+ NumberAnimation {
+ duration: General.animationDuration / 4
+ easing.type: Easing.InOutQuad
+ }
+ }
+
+ Component.onCompleted: {
+ Qt.callLater(() => {
+ syncSelectionState();
+ });
+ }
+}
diff --git a/popups/launcher/components/IInnerComponent.qml b/popups/launcher/components/IInnerComponent.qml
new file mode 100644
index 0000000..7bbc9f0
--- /dev/null
+++ b/popups/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/popups/launcher/components/SelectionState.qml b/popups/launcher/components/SelectionState.qml
new file mode 100644
index 0000000..1ab577a
--- /dev/null
+++ b/popups/launcher/components/SelectionState.qml
@@ -0,0 +1,49 @@
+pragma Singleton
+import Quickshell
+import QtQuick
+
+Singleton {
+ id: root
+
+ PersistentProperties {
+ id: persist
+ reloadableId: "launcherSelectionState"
+
+ property string input: ""
+ property int cursorPosition: 0
+ property int selectedPriority: 0
+ property int previousSelectedPriority: 0
+ }
+
+ property Item selected: null
+ property alias input: persist.input
+ property alias cursorPosition: persist.cursorPosition
+ property var priorities: []
+ property alias selectedPriority: persist.selectedPriority
+ property alias previousSelectedPriority: persist.previousSelectedPriority
+ property var widgets: []
+
+ onSelectedPriorityChanged: {
+ if (selectedPriority > previousSelectedPriority) {
+ priorities[selectedPriority].home();
+ priorities[selectedPriority].syncSelectionState();
+ } else {
+ priorities[selectedPriority].end();
+ priorities[selectedPriority].syncSelectionState();
+ }
+
+ previousSelectedPriority = selectedPriority;
+ }
+ // order them based on widgets(which came from order from config)
+ onPrioritiesChanged: {
+ for (let i = priorities.length - 1; i >= 0; i--)
+ if (!priorities[i] || priorities[i].priority !== true)
+ priorities.splice(i, 1);
+
+ priorities.sort((a, b) => (widgets.indexOf(a) - widgets.indexOf(b)) || 0);
+
+ priorities.forEach((item, index) => {});
+
+ priorities[selectedPriority]?.syncSelectionState();
+ }
+}
diff --git a/popups/launcher/components/Spell.qml b/popups/launcher/components/Spell.qml
new file mode 100644
index 0000000..fe01c3f
--- /dev/null
+++ b/popups/launcher/components/Spell.qml
@@ -0,0 +1,235 @@
+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 int selectedIndex: -1
+
+ name: "Spell"
+ prefix: name.toLowerCase() + ' '
+
+ onValidChanged: {
+ if (valid)
+ return;
+
+ aspell = [];
+ proc.running = false;
+ selectedIndex = -1;
+ return;
+ }
+
+ onAspellChanged: {
+ if (selectedIndex !== -1) {
+ syncSelectionState();
+ return;
+ }
+ selectedIndex = aspell.length > 0 ? 0 : -1;
+ // selectedIndexChanged dont get called somehow
+ syncSelectionState();
+ if (loader.item && loader.item.flickable) {
+ loader.item.flickable.contentY = 0;
+ }
+ }
+
+ onSelectedIndexChanged: {
+ if (!loader.view || !loader.item || !loader.item.flickable)
+ return;
+ if (selectedIndex < 0 || selectedIndex >= loader.view.count)
+ return;
+ Qt.callLater(() => {
+ syncSelectionState();
+ loader.view.currentIndex = selectedIndex;
+ loader.view.positionViewAtIndex(selectedIndex, GridView.Contain);
+ });
+ }
+
+ process: function () {
+ Qt.callLater(() => {
+ proc.running = true;
+ });
+
+ const isValid = selectedIndex >= 0 && aspell.length > 0;
+ const selected = isValid ? aspell[selectedIndex] : "";
+ const answer = selected === "*" ? input : selected;
+
+ return {
+ valid: isValid,
+ priority: isValid,
+ answer: answer || "",
+ predictiveCompletion: answer?.slice(input.length) || ""
+ };
+ }
+
+ home: function () {
+ if (loader.view.count === 0)
+ return true;
+ selectedIndex = -1;
+ selectedIndex = 0;
+ }
+ end: function () {
+ if (loader.view.count === 0)
+ return true;
+ selectedIndex = loader.view.count;
+ selectedIndex = loader.view.count - 1;
+ }
+
+ prev: function () {
+ if (selectedIndex <= 0)
+ return true;
+ selectedIndex--;
+ }
+
+ next: function () {
+ if (selectedIndex + 1 >= aspell.length)
+ return true;
+ selectedIndex++;
+ }
+
+ up: function () {
+ const cols = loader.view ? Math.max(1, Math.floor(loader.view.width / loader.view.cellWidth)) : 1;
+ if (selectedIndex - cols < 0)
+ return true;
+ selectedIndex -= cols;
+ }
+
+ down: function () {
+ const cols = loader.view ? Math.max(1, Math.floor(loader.view.width / loader.view.cellWidth)) : 1;
+ if (selectedIndex + cols >= aspell.length)
+ return true;
+ selectedIndex += cols;
+ }
+
+ exec: function () {
+ if (selectedIndex >= 0)
+ if (aspell[selectedIndex] !== "*")
+ Clip.copy(aspell[selectedIndex]);
+ else
+ Clip.copy(input);
+ }
+
+ syncSelectionState: function () {
+ Qt.callLater(() => state.selected = loader.view.itemAtIndex(selectedIndex));
+ }
+
+ IInnerComponent {
+ id: innerLoader
+ Loader {
+ id: loader
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ Layout.preferredHeight: parent.height
+ sourceComponent: aspell[0] === "*" ? correct : show
+ property var view: null
+
+ readonly property Component show: Component {
+ Item {
+ property alias flickable: grid
+
+ GridView {
+ id: grid
+ anchors.fill: parent
+ clip: true
+ boundsBehavior: Flickable.StopAtBounds
+ cacheBuffer: 0
+ model: aspell
+ interactive: true
+ flow: GridView.FlowLeftToRight
+ snapMode: GridView.NoSnap
+
+ cellWidth: Math.min(width / Math.max(1, Math.floor(width / (Launcher.widgetFontSize * 4))), width / 2)
+ cellHeight: Launcher.widgetFontSize * 1.4 + Launcher.innerMargin * 2
+
+ delegate: Item {
+ required property var modelData
+ required property int index
+
+ width: grid.cellWidth
+ height: grid.cellHeight
+ scale: index === selectedIndex ? 1.03 : 0.97
+
+ IRect {
+ anchors.fill: parent
+ 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
+ color: index === selectedIndex ? Colors.foreground1 : Colors.foreground2
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ hoverEnabled: true
+ onPositionChanged: {
+ root.selectedIndex = index;
+ }
+ onPressed: {
+ root._exec();
+ }
+ }
+ }
+
+ Behavior on scale {
+ ISpringAnimation {}
+ }
+ }
+
+ Component.onCompleted: {
+ loader.view = grid;
+ }
+ }
+ }
+ }
+
+ 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 " + input + " | aspell -a"]
+ property bool found: false
+ stdout: SplitParser {
+ onRead: data => {
+ if (data.startsWith("&")) {
+ proc.found = true;
+ aspell = data.split(/\W+/).filter(input => /^[A-Za-z]+$/.test(input)).slice(1);
+ } else if (data.startsWith("*")) {
+ proc.found = true;
+ aspell = ["*"];
+ }
+ }
+ }
+ onStarted: {
+ found = false;
+ }
+ onExited: {
+ if (!found)
+ aspell = [];
+ }
+ }
+}
diff --git a/popups/launcher/components/Time.qml b/popups/launcher/components/Time.qml
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/popups/launcher/components/Time.qml
diff --git a/popups/launcher/components/Translate.qml b/popups/launcher/components/Translate.qml
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/popups/launcher/components/Translate.qml
diff --git a/popups/launcher/components/Weather.qml b/popups/launcher/components/Weather.qml
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/popups/launcher/components/Weather.qml
diff --git a/popups/launcher/components/Web.qml b/popups/launcher/components/Web.qml
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/popups/launcher/components/Web.qml
diff --git a/popups/launcher/todo.md b/popups/launcher/todo.md
new file mode 100644
index 0000000..3ed0252
--- /dev/null
+++ b/popups/launcher/todo.md
@@ -0,0 +1,37 @@
+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
+
+emoji picker
+desktopentry action
+
+stop querying if prev hav no results