import QtQuick import QtQuick.Controls import Quickshell.Io import qs.Common import qs.Widgets Item { id: root property string currentLocation: "" property string placeholderText: "Search for a location..." signal locationSelected(string displayName, string coordinates) width: parent.width height: searchInputField.height + (searchDropdown.visible ? searchDropdown.height : 0) property bool _internalChange: false property bool isLoading: false property string helperTextState: "default" // "default", "prompt", "searching", "found", "not_found" property string currentSearchText: "" ListModel { id: searchResultsModel } function resetSearchState() { locationSearchTimer.stop() dropdownHideTimer.stop() if (locationSearcher.running) { locationSearcher.running = false; } isLoading = false searchResultsModel.clear() helperTextState = "default" } Timer { id: locationSearchTimer interval: 500 running: false repeat: false onTriggered: { if (locationInput.text.length > 2) { if (locationSearcher.running) { locationSearcher.running = false } searchResultsModel.clear() root.isLoading = true root.helperTextState = "searching" const searchLocation = locationInput.text root.currentSearchText = searchLocation const encodedLocation = encodeURIComponent(searchLocation) const curlCommand = `curl -s --connect-timeout 5 --max-time 10 'https://nominatim.openstreetmap.org/search?q=${encodedLocation}&format=json&limit=5&addressdetails=1'` locationSearcher.command = ["bash", "-c", curlCommand] locationSearcher.running = true } } } Timer { id: dropdownHideTimer interval: 200 running: false repeat: false onTriggered: { if (!locationInput.getActiveFocus() && !searchDropdown.hovered) { root.resetSearchState() } } } Process { id: locationSearcher command: ["bash", "-c", "echo"] running: false stdout: StdioCollector { onStreamFinished: { if (root.currentSearchText !== locationInput.text) { return } const raw = text.trim() root.isLoading = false searchResultsModel.clear() if (!raw || raw[0] !== "[") { root.helperTextState = "not_found" return } try { const data = JSON.parse(raw) if (data.length === 0) { root.helperTextState = "not_found" return } for (let i = 0; i < Math.min(data.length, 5); i++) { const location = data[i] if (location.display_name && location.lat && location.lon) { const parts = location.display_name.split(', ') let cleanName = parts[0] if (parts.length > 1) { const state = parts[parts.length - 2] if (state && state !== cleanName) { cleanName += `, ${state}` } } const query = `${location.lat},${location.lon}` searchResultsModel.append({ "name": cleanName, "query": query }) } } root.helperTextState = "found" } catch (e) { root.helperTextState = "not_found" } } } onExited: (exitCode) => { root.isLoading = false if (exitCode !== 0) { searchResultsModel.clear() root.helperTextState = "not_found" } } } // Search input field Item { id: searchInputField width: parent.width height: 48 DankTextField { id: locationInput width: parent.width height: parent.height leftIconName: "search" placeholderText: root.placeholderText text: root.currentLocation backgroundColor: Theme.surfaceVariant normalBorderColor: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.3) focusedBorderColor: Theme.primary onTextEdited: { if (root._internalChange) return if (getActiveFocus()) { if (text.length > 2) { root.isLoading = true root.helperTextState = "searching" locationSearchTimer.restart() } else { root.resetSearchState() root.helperTextState = "prompt" } } } onFocusStateChanged: (hasFocus) => { if (hasFocus) { dropdownHideTimer.stop() if (text.length <= 2) { root.helperTextState = "prompt" } } else { dropdownHideTimer.start() } } } // Status icon overlay DankIcon { name: { if (root.isLoading) return "hourglass_empty" if (searchResultsModel.count > 0) return "check_circle" if (locationInput.getActiveFocus() && locationInput.text.length > 2 && !root.isLoading) return "error" return "" } size: Theme.iconSize - 4 color: { if (root.isLoading) return Theme.surfaceVariantText if (searchResultsModel.count > 0) return Theme.success || Theme.primary if (locationInput.getActiveFocus() && locationInput.text.length > 2) return Theme.error return "transparent" } anchors.right: parent.right anchors.rightMargin: Theme.spacingM anchors.verticalCenter: parent.verticalCenter opacity: (locationInput.getActiveFocus() && locationInput.text.length > 2) ? 1.0 : 0.0 Behavior on opacity { NumberAnimation { duration: Theme.shortDuration easing.type: Theme.standardEasing } } } } // Search results dropdown Rectangle { id: searchDropdown width: parent.width height: Math.min(Math.max(searchResultsModel.count * 38 + Theme.spacingS * 2, 50), 200) y: searchInputField.height radius: Theme.cornerRadius color: Theme.popupBackground() border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.3) border.width: 1 visible: locationInput.getActiveFocus() && locationInput.text.length > 2 && (searchResultsModel.count > 0 || root.isLoading) property bool hovered: false MouseArea { anchors.fill: parent hoverEnabled: true onEntered: { parent.hovered = true dropdownHideTimer.stop() } onExited: { parent.hovered = false if (!locationInput.getActiveFocus()) { dropdownHideTimer.start() } } acceptedButtons: Qt.NoButton } Item { anchors.fill: parent anchors.margins: Theme.spacingS ListView { id: searchResultsList anchors.fill: parent clip: true model: searchResultsModel spacing: 2 delegate: Rectangle { width: searchResultsList.width height: 36 radius: Theme.cornerRadius color: resultMouseArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.1) : "transparent" Row { anchors.fill: parent anchors.margins: Theme.spacingM spacing: Theme.spacingS DankIcon { name: "place" size: Theme.iconSize - 6 color: Theme.surfaceVariantText anchors.verticalCenter: parent.verticalCenter } Text { text: model.name || "Unknown" font.pixelSize: Theme.fontSizeMedium color: Theme.surfaceText anchors.verticalCenter: parent.verticalCenter elide: Text.ElideRight width: parent.width - 30 } } MouseArea { id: resultMouseArea anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: { root._internalChange = true const selectedName = model.name const selectedQuery = model.query locationInput.text = selectedName root.locationSelected(selectedName, selectedQuery) root.resetSearchState() locationInput.setFocus(false) root._internalChange = false } } } } // Show message when no results Text { anchors.centerIn: parent text: root.isLoading ? "Searching..." : "No locations found" font.pixelSize: Theme.fontSizeMedium color: Theme.surfaceVariantText visible: searchResultsList.count === 0 && locationInput.text.length > 2 } } } }