diff --git a/core/internal/server/cups/actions.go b/core/internal/server/cups/actions.go index 15e7063f..01b95f70 100644 --- a/core/internal/server/cups/actions.go +++ b/core/internal/server/cups/actions.go @@ -2,6 +2,8 @@ package cups import ( "errors" + "net" + "net/url" "strings" "time" @@ -156,9 +158,42 @@ func (m *Manager) PurgeJobs(printerName string) error { return err } +func resolveIPFromURI(uri string) string { + parsed, err := url.Parse(uri) + if err != nil { + return "" + } + host := parsed.Hostname() + if host == "" { + return "" + } + if ip := net.ParseIP(host); ip != nil { + return ip.String() + } + addrs, err := net.LookupIP(host) + if err != nil || len(addrs) == 0 { + return "" + } + for _, addr := range addrs { + if v4 := addr.To4(); v4 != nil { + return v4.String() + } + } + return addrs[0].String() +} + func (m *Manager) GetDevices() ([]Device, error) { if m.pkHelper != nil { - return m.pkHelper.DevicesGet(10, 0, nil, nil) + devices, err := m.pkHelper.DevicesGet(10, 0, nil, nil) + if err != nil { + return nil, err + } + for i := range devices { + if devices[i].Class == "network" { + devices[i].IP = resolveIPFromURI(devices[i].URI) + } + } + return devices, nil } deviceAttrs, err := m.client.GetDevices() @@ -176,6 +211,9 @@ func (m *Manager) GetDevices() ([]Device, error) { ID: getStringAttr(attrs, "device-id"), Location: getStringAttr(attrs, "device-location"), } + if device.Class == "network" { + device.IP = resolveIPFromURI(uri) + } devices = append(devices, device) } diff --git a/core/internal/server/cups/types.go b/core/internal/server/cups/types.go index 824d8a50..5e17dbee 100644 --- a/core/internal/server/cups/types.go +++ b/core/internal/server/cups/types.go @@ -42,6 +42,7 @@ type Device struct { MakeModel string `json:"makeModel"` ID string `json:"id"` Location string `json:"location"` + IP string `json:"ip,omitempty"` } type PPD struct { diff --git a/quickshell/Modules/Settings/PrinterTab.qml b/quickshell/Modules/Settings/PrinterTab.qml index 16f16894..199a3c7b 100644 --- a/quickshell/Modules/Settings/PrinterTab.qml +++ b/quickshell/Modules/Settings/PrinterTab.qml @@ -287,6 +287,8 @@ Item { id: deviceDropdown dropdownWidth: parent.width - 80 - scanDevicesBtn.width - Theme.spacingS * 2 popupWidth: parent.width - 80 - scanDevicesBtn.width - Theme.spacingS * 2 + enableFuzzySearch: true + emptyText: I18n.tr("No devices found") currentValue: { if (CupsService.loadingDevices) return I18n.tr("Scanning..."); @@ -294,20 +296,12 @@ Item { return CupsService.getDeviceDisplayName(printerTab.selectedDevice); return I18n.tr("Select device..."); } - options: { - const filtered = CupsService.filteredDevices; - if (filtered.length === 0) - return [I18n.tr("No devices found")]; - return filtered.map(d => CupsService.getDeviceDisplayName(d)); - } + options: CupsService.filteredDevices.map(d => CupsService.getDeviceDisplayName(d)) onValueChanged: value => { - if (value === I18n.tr("No devices found") || value === I18n.tr("Scanning...")) - return; const filtered = CupsService.filteredDevices; const device = filtered.find(d => CupsService.getDeviceDisplayName(d) === value); - if (device) { + if (device) printerTab.selectDevice(device); - } } } @@ -365,6 +359,8 @@ Item { id: ppdDropdown dropdownWidth: parent.width - 80 - refreshPpdsBtn.width - Theme.spacingS * 2 popupWidth: parent.width - 80 - refreshPpdsBtn.width - Theme.spacingS * 2 + enableFuzzySearch: true + emptyText: I18n.tr("No drivers found") currentValue: { if (CupsService.loadingPPDs) return I18n.tr("Loading..."); @@ -379,20 +375,15 @@ Item { return printerTab.suggestedPPDs.length > 0 ? I18n.tr("Recommended available") : I18n.tr("Select driver..."); } options: { - if (CupsService.ppds.length === 0) - return [I18n.tr("No drivers found")]; const suggested = printerTab.suggestedPPDs.map(p => "★ " + (p.makeModel || p.name)); const others = CupsService.ppds.filter(p => !printerTab.suggestedPPDs.some(s => s.name === p.name)).map(p => p.makeModel || p.name); return suggested.concat(others); } onValueChanged: value => { - if (value === I18n.tr("No drivers found") || value === I18n.tr("Loading...")) - return; const cleanValue = value.replace(/^★ /, ""); const ppd = CupsService.ppds.find(p => (p.makeModel || p.name) === cleanValue); - if (ppd) { + if (ppd) printerTab.selectedPpd = ppd.name; - } } } diff --git a/quickshell/Services/CupsService.qml b/quickshell/Services/CupsService.qml index ad853845..cb575e9b 100644 --- a/quickshell/Services/CupsService.qml +++ b/quickshell/Services/CupsService.qml @@ -109,33 +109,36 @@ Singleton { function getDeviceDisplayName(device) { if (!device) return ""; + let name = ""; if (device.info && device.info.length > 0) { - return decodeUri(device.info); + name = decodeUri(device.info); + } else if (device.makeModel && device.makeModel.length > 0) { + name = decodeUri(device.makeModel); + } else { + return decodeUri(device.uri); } - if (device.makeModel && device.makeModel.length > 0) { - return decodeUri(device.makeModel); - } - return decodeUri(device.uri); + if (device.ip) + return name + " (" + device.ip + ")"; + return name; } function getDeviceSubtitle(device) { if (!device) return ""; const parts = []; - if (device.class) { - switch (device.class) { - case "direct": - parts.push(I18n.tr("Local")); - break; - case "network": - parts.push(I18n.tr("Network")); - break; - case "file": - parts.push(I18n.tr("File")); - break; - default: + switch (device.class) { + case "direct": + parts.push(I18n.tr("Local")); + break; + case "network": + parts.push(I18n.tr("Network")); + break; + case "file": + parts.push(I18n.tr("File")); + break; + default: + if (device.class) parts.push(device.class); - } } if (device.location) parts.push(decodeUri(device.location)); diff --git a/quickshell/Widgets/DankDropdown.qml b/quickshell/Widgets/DankDropdown.qml index a3a4d2d1..a69cf934 100644 --- a/quickshell/Widgets/DankDropdown.qml +++ b/quickshell/Widgets/DankDropdown.qml @@ -18,17 +18,20 @@ Item { property var options: [] property var optionIcons: [] property bool enableFuzzySearch: false + property var optionIconMap: ({}) - onOptionsChanged: { - if (dropdownMenu.visible) { - dropdownMenu.fzfFinder = new Fzf.Finder(options, { - "selector": option => option, - "limit": 50, - "casing": "case-insensitive" - }); - dropdownMenu.updateFilteredOptions(); + function rebuildIconMap() { + const map = {}; + for (let i = 0; i < options.length; i++) { + if (optionIcons.length > i) + map[options[i]] = optionIcons[i]; } + optionIconMap = map; } + + onOptionsChanged: rebuildIconMap() + onOptionIconsChanged: rebuildIconMap() + property int popupWidthOffset: 0 property int maxPopupHeight: 400 property bool openUpwards: false @@ -37,6 +40,7 @@ Item { property int dropdownWidth: 200 property bool compactMode: text === "" && description === "" property bool addHorizontalPadding: false + property string emptyText: "" signal valueChanged(string value) @@ -44,10 +48,8 @@ Item { implicitHeight: compactMode ? 40 : Math.max(60, labelColumn.implicitHeight + Theme.spacingM) Component.onDestruction: { - const popup = dropdownMenu; - if (popup && popup.visible) { - popup.close(); - } + if (dropdownMenu.visible) + dropdownMenu.close(); } Column { @@ -105,36 +107,16 @@ Item { dropdownMenu.close(); return; } - - dropdownMenu.searchQuery = ""; - dropdownMenu.updateFilteredOptions(); - dropdownMenu.open(); - const pos = dropdown.mapToItem(Overlay.overlay, 0, 0); - const popupWidth = dropdownMenu.width; - const popupHeight = dropdownMenu.height; - const overlayHeight = Overlay.overlay.height; - - if (root.openUpwards || pos.y + dropdown.height + popupHeight + 4 > overlayHeight) { - if (root.alignPopupRight) { - dropdownMenu.x = pos.x + dropdown.width - popupWidth; - } else { - dropdownMenu.x = pos.x - (root.popupWidthOffset / 2); - } - dropdownMenu.y = pos.y - popupHeight - 4; - } else { - if (root.alignPopupRight) { - dropdownMenu.x = pos.x + dropdown.width - popupWidth; - } else { - dropdownMenu.x = pos.x - (root.popupWidthOffset / 2); - } - dropdownMenu.y = pos.y + dropdown.height + 4; - } - - if (root.enableFuzzySearch && searchField.visible) { + const popupW = dropdownMenu.width; + const popupH = dropdownMenu.height; + const overlayH = Overlay.overlay.height; + const goUp = root.openUpwards || pos.y + dropdown.height + popupH + 4 > overlayH; + dropdownMenu.x = root.alignPopupRight ? pos.x + dropdown.width - popupW : pos.x - (root.popupWidthOffset / 2); + dropdownMenu.y = goUp ? pos.y - popupH - 4 : pos.y + dropdown.height + 4; + if (root.enableFuzzySearch) searchField.forceActiveFocus(); - } } } @@ -149,10 +131,7 @@ Item { spacing: Theme.spacingS DankIcon { - name: { - const currentIndex = root.options.indexOf(root.currentValue); - return currentIndex >= 0 && root.optionIcons.length > currentIndex ? root.optionIcons[currentIndex] : ""; - } + name: root.optionIconMap[root.currentValue] ?? "" size: 18 color: Theme.surfaceText anchors.verticalCenter: parent.verticalCenter @@ -193,51 +172,52 @@ Item { id: dropdownMenu property string searchQuery: "" - property var filteredOptions: [] + property var filteredOptions: { + if (!root.enableFuzzySearch || searchQuery.length === 0) + return root.options; + if (!fzfFinder) + return root.options; + return fzfFinder.find(searchQuery).map(r => r.item); + } property int selectedIndex: -1 - property var fzfFinder: new Fzf.Finder(root.options, { - "selector": option => option, - "limit": 50, - "casing": "case-insensitive" - }) + property var fzfFinder: null - function updateFilteredOptions() { - if (!root.enableFuzzySearch || searchQuery.length === 0) { - filteredOptions = root.options; - selectedIndex = -1; - return; - } - - const results = fzfFinder.find(searchQuery); - filteredOptions = results.map(result => result.item); - selectedIndex = -1; + function initFinder() { + fzfFinder = new Fzf.Finder(root.options, { + "selector": option => option, + "limit": 50, + "casing": "case-insensitive" + }); } function selectNext() { - if (filteredOptions.length === 0) { + if (filteredOptions.length === 0) return; - } selectedIndex = (selectedIndex + 1) % filteredOptions.length; listView.positionViewAtIndex(selectedIndex, ListView.Contain); } function selectPrevious() { - if (filteredOptions.length === 0) { + if (filteredOptions.length === 0) return; - } selectedIndex = selectedIndex <= 0 ? filteredOptions.length - 1 : selectedIndex - 1; listView.positionViewAtIndex(selectedIndex, ListView.Contain); } function selectCurrent() { - if (selectedIndex < 0 || selectedIndex >= filteredOptions.length) { + if (selectedIndex < 0 || selectedIndex >= filteredOptions.length) return; - } root.currentValue = filteredOptions[selectedIndex]; root.valueChanged(filteredOptions[selectedIndex]); close(); } + onOpened: { + fzfFinder = null; + searchQuery = ""; + selectedIndex = -1; + } + parent: Overlay.overlay width: root.popupWidth === -1 ? undefined : (root.popupWidth > 0 ? root.popupWidth : (dropdown.width + root.popupWidthOffset)) height: Math.min(root.maxPopupHeight, (root.enableFuzzySearch ? 54 : 0) + Math.min(filteredOptions.length, 10) * 36 + 16) @@ -283,30 +263,38 @@ Item { anchors.fill: parent anchors.margins: 1 placeholderText: I18n.tr("Search...") - text: dropdownMenu.searchQuery topPadding: Theme.spacingS bottomPadding: Theme.spacingS - onTextChanged: { - dropdownMenu.searchQuery = text; - dropdownMenu.updateFilteredOptions(); - } + onTextChanged: searchDebounce.restart() Keys.onDownPressed: dropdownMenu.selectNext() Keys.onUpPressed: dropdownMenu.selectPrevious() Keys.onReturnPressed: dropdownMenu.selectCurrent() Keys.onEnterPressed: dropdownMenu.selectCurrent() Keys.onPressed: event => { - if (event.key === Qt.Key_N && event.modifiers & Qt.ControlModifier) { + if (!(event.modifiers & Qt.ControlModifier)) + return; + switch (event.key) { + case Qt.Key_N: + case Qt.Key_J: dropdownMenu.selectNext(); event.accepted = true; - } else if (event.key === Qt.Key_P && event.modifiers & Qt.ControlModifier) { - dropdownMenu.selectPrevious(); - event.accepted = true; - } else if (event.key === Qt.Key_J && event.modifiers & Qt.ControlModifier) { - dropdownMenu.selectNext(); - event.accepted = true; - } else if (event.key === Qt.Key_K && event.modifiers & Qt.ControlModifier) { + break; + case Qt.Key_P: + case Qt.Key_K: dropdownMenu.selectPrevious(); event.accepted = true; + break; + } + } + + Timer { + id: searchDebounce + interval: 50 + onTriggered: { + if (!dropdownMenu.fzfFinder) + dropdownMenu.initFinder(); + dropdownMenu.searchQuery = searchField.text; + dropdownMenu.selectedIndex = -1; } } } @@ -318,12 +306,28 @@ Item { visible: root.enableFuzzySearch } + Item { + width: parent.width + height: 32 + visible: root.options.length === 0 && root.emptyText !== "" + + StyledText { + anchors.left: parent.left + anchors.leftMargin: Theme.spacingS + anchors.verticalCenter: parent.verticalCenter + text: root.emptyText + font.pixelSize: Theme.fontSizeMedium + color: Theme.surfaceVariantText + } + } + DankListView { id: listView width: parent.width - height: parent.height - (root.enableFuzzySearch ? searchContainer.height + Theme.spacingXS : 0) + height: parent.height - (root.enableFuzzySearch ? searchContainer.height + Theme.spacingXS : 0) - (root.options.length === 0 && root.emptyText !== "" ? 32 : 0) clip: true + visible: root.options.length > 0 model: ScriptModel { values: dropdownMenu.filteredOptions } @@ -338,9 +342,13 @@ Item { flickableDirection: Flickable.VerticalFlick delegate: Rectangle { + id: delegateRoot + + required property var modelData + required property int index property bool isSelected: dropdownMenu.selectedIndex === index property bool isCurrentValue: root.currentValue === modelData - property int optionIndex: root.options.indexOf(modelData) + property string iconName: root.optionIconMap[modelData] ?? "" width: ListView.view.width height: 32 @@ -354,19 +362,19 @@ Item { spacing: Theme.spacingS DankIcon { - name: optionIndex >= 0 && root.optionIcons.length > optionIndex ? root.optionIcons[optionIndex] : "" + name: delegateRoot.iconName size: 18 - color: isCurrentValue ? Theme.primary : Theme.surfaceText + color: delegateRoot.isCurrentValue ? Theme.primary : Theme.surfaceText visible: name !== "" } StyledText { anchors.verticalCenter: parent.verticalCenter - text: modelData + text: delegateRoot.modelData font.pixelSize: Theme.fontSizeMedium - color: isCurrentValue ? Theme.primary : Theme.surfaceText - font.weight: isCurrentValue ? Font.Medium : Font.Normal - width: root.popupWidth > 0 ? undefined : (parent.parent.width - parent.x - Theme.spacingS) + color: delegateRoot.isCurrentValue ? Theme.primary : Theme.surfaceText + font.weight: delegateRoot.isCurrentValue ? Font.Medium : Font.Normal + width: root.popupWidth > 0 ? undefined : (delegateRoot.width - parent.x - Theme.spacingS) elide: root.popupWidth > 0 ? Text.ElideNone : Text.ElideRight wrapMode: Text.NoWrap } @@ -379,8 +387,8 @@ Item { hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: { - root.currentValue = modelData; - root.valueChanged(modelData); + root.currentValue = delegateRoot.modelData; + root.valueChanged(delegateRoot.modelData); dropdownMenu.close(); } }