diff options
| author | littleblack111 <littleblack11111@gmail.com> | 2025-08-19 20:46:39 +0800 |
|---|---|---|
| committer | littleblack111 <littleblack11111@gmail.com> | 2025-08-19 21:50:03 +0800 |
| commit | 68e1a276192872c57931e9e43911a479d4619c4a (patch) | |
| tree | 3cb7f752edc931971a429ead938eb1aa7e0af299 /popups | |
| parent | a607f21ebaf6f7752626c1f568f90b36ec40c2c8 (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.qml | 18 | ||||
| -rw-r--r-- | popups/launcher/ILauncher.qml | 15 | ||||
| -rw-r--r-- | popups/launcher/Launcher.qml | 388 | ||||
| -rw-r--r-- | popups/launcher/components/App.qml | 181 | ||||
| -rw-r--r-- | popups/launcher/components/Calc.qml | 81 | ||||
| -rw-r--r-- | popups/launcher/components/Clip.qml | 199 | ||||
| -rw-r--r-- | popups/launcher/components/Emoji.qml | 176 | ||||
| -rw-r--r-- | popups/launcher/components/Finder.qml | 0 | ||||
| -rw-r--r-- | popups/launcher/components/Flight.qml | 0 | ||||
| -rw-r--r-- | popups/launcher/components/IComponent.qml | 93 | ||||
| -rw-r--r-- | popups/launcher/components/IInnerComponent.qml | 34 | ||||
| -rw-r--r-- | popups/launcher/components/SelectionState.qml | 49 | ||||
| -rw-r--r-- | popups/launcher/components/Spell.qml | 235 | ||||
| -rw-r--r-- | popups/launcher/components/Time.qml | 0 | ||||
| -rw-r--r-- | popups/launcher/components/Translate.qml | 0 | ||||
| -rw-r--r-- | popups/launcher/components/Weather.qml | 0 | ||||
| -rw-r--r-- | popups/launcher/components/Web.qml | 0 | ||||
| -rw-r--r-- | popups/launcher/todo.md | 37 |
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 |
