waffles: functioning search

This commit is contained in:
end-4
2025-12-05 00:19:21 +01:00
parent 8e704e4009
commit 9043ae7bf6
48 changed files with 648 additions and 88 deletions
@@ -19,11 +19,14 @@ QtObject {
}
property var actions: []
// Stuff needed for DesktopEntry objects
// Stuff needed for DesktopEntry
property string id: ""
property bool shown: true
property string comment: ""
property bool runInTerminal: false
property string genericName: ""
property list<string> keywords: []
// Extra stuff to allow for more flexibility
property string category: type
}
@@ -9,6 +9,7 @@ ScrollBar {
policy: ScrollBar.AsNeeded
topPadding: Appearance.rounding.normal
bottomPadding: Appearance.rounding.normal
active: hovered || pressed
contentItem: Rectangle {
implicitWidth: 4
@@ -152,6 +152,7 @@ Singleton {
property real normal: 11
property real large: 13
property real larger: 15
property real xlarger: 17
}
}
@@ -48,7 +48,7 @@ Singleton {
}
property string batteryLevelIcon: {
const discreteLevel = Math.ceil(Battery.percentage * 10)
const discreteLevel = Math.ceil(Battery.percentage * 10);
return `battery-${discreteLevel > 9 ? "full" : discreteLevel}`;
}
@@ -107,7 +107,8 @@ Singleton {
function audioAppIcon(node) {
let icon;
icon = AppSearch.guessIcon(node?.properties["application.icon-name"] ?? "");
if (AppSearch.iconExists(icon)) return icon;
if (AppSearch.iconExists(icon))
return icon;
icon = AppSearch.guessIcon(node?.properties["node.name"] ?? "");
return icon;
}
@@ -127,4 +128,60 @@ Singleton {
return "bluetooth";
}
function fluentFromMaterial(icon) {
switch (icon) {
case "calculate":
return "calculator";
case "keyboard_return":
return "arrow-enter-left";
case "open_in_new":
return "open";
case "settings_suggest":
return "wand";
case "terminal":
return "app-generic";
case "travel_explore":
return "globe-search";
case "keep":
return "pin";
case "keep_off":
return "pin-off";
default:
return "apps";
}
}
function guessIconForName(name) {
const lowerName = name.toLowerCase();
if (lowerName.includes("app") || lowerName.includes("desktop"))
return "apps";
if (lowerName.includes("news"))
return "news";
if (lowerName.includes("new") || lowerName.includes("create") || lowerName.includes("add"))
return "add";
if (lowerName.includes("open"))
return "open";
if (lowerName.includes("friends") || lowerName.includes("contact") || lowerName.includes("family"))
return "people";
if (lowerName.includes("community"))
return "people-team";
if (lowerName.includes("library"))
return "library";
if (lowerName.includes("setting"))
return "settings";
if (lowerName.includes("gallery"))
return "image-copy";
if (lowerName.includes("server"))
return "server";
if (lowerName.includes("picture") || lowerName.includes("photo") || lowerName.includes("image"))
return "image";
if (lowerName.includes("store") || lowerName.includes("shop"))
return "store-microsoft";
if (lowerName.includes("record") || lowerName.includes("capture"))
return "record";
if (lowerName.includes("screen") || lowerName.includes("display") || lowerName.includes("monitor") || lowerName.includes("desktop"))
return "desktop";
return "apps";
}
}
@@ -0,0 +1,10 @@
import qs.modules.common
import qs.modules.common.widgets
import QtQuick
import QtQuick.Controls
ListView {
id: root
ScrollBar.vertical: WScrollBar {}
}
@@ -0,0 +1,25 @@
import QtQuick
import QtQuick.Controls
import qs.modules.common
import qs.modules.common.widgets
import qs.modules.common.functions
ScrollBar {
id: root
policy: ScrollBar.AsNeeded
active: hovered || pressed
property color color: Looks.colors.controlBg
contentItem: Rectangle {
implicitWidth: root.active ? 4 : 2
implicitHeight: root.visualSize
radius: 9999
color: root.color
opacity: root.policy === ScrollBar.AlwaysOn || (root.active && root.size < 1.0) ? 0.5 : 0
Behavior on opacity {
animation: Looks.transition.opacity.createObject(this)
}
}
}
@@ -15,10 +15,16 @@ FooterRectangle {
property real horizontalPadding: 32
property real verticalPadding: 16
property bool searching: text.length > 0
property alias searchInput: searchInput
property alias text: searchInput.text
implicitHeight: outline.implicitHeight + verticalPadding * 2
Component.onCompleted: searchInput.forceActiveFocus()
signal accepted()
Component.onCompleted: forceFocus()
function forceFocus() {
searchInput.forceActiveFocus();
}
focus: true
color: searching ? Looks.colors.bgPanelBody : Looks.colors.bgPanelFooter
@@ -81,6 +87,10 @@ FooterRectangle {
visible: searchInput.text.length === 0
font.pixelSize: Looks.font.pixelSize.large
}
onAccepted: {
root.accepted();
}
}
}
}
@@ -0,0 +1,48 @@
import QtQuick
import QtQuick.Layouts
import Quickshell
import qs
import qs.services
import qs.modules.common
import qs.modules.common.models
import qs.modules.common.functions
import qs.modules.common.widgets
import qs.modules.waffle.looks
Item {
id: root
required property LauncherSearchResult entry
property int iconSize: 24
implicitWidth: Math.max(iconSize, textIconLoader.implicitWidth)
implicitHeight: iconSize
Loader {
anchors.centerIn: parent
active: root.entry.iconType === LauncherSearchResult.IconType.System && root.entry.iconName !== ""
sourceComponent: WAppIcon {
implicitSize: root.iconSize
iconName: root.entry.iconName
tryCustomIcon: false
animated: false
}
}
Loader {
id: textIconLoader
anchors.centerIn: parent
active: root.entry.iconType === LauncherSearchResult.IconType.Text
sourceComponent: WText {
text: root.entry.iconName
font.pixelSize: root.iconSize
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
Loader {
anchors.centerIn: parent
active: root.entry.iconType === LauncherSearchResult.IconType.Material || root.entry.iconType === LauncherSearchResult.IconType.None || root.entry.iconName === ""
sourceComponent: FluentIcon {
icon: root.entry.iconName ? WIcons.fluentFromMaterial(root.entry.iconName) : WIcons.guessIconForName(root.entry.name)
implicitSize: root.iconSize
animated: false
}
}
}
@@ -12,7 +12,9 @@ import qs.modules.waffle.looks
BodyRectangle {
id: root
property alias context: searchResults.context
property string searchText: LauncherSearch.query
property alias currentIndex: searchResults.currentIndex
ColumnLayout {
anchors {
@@ -24,11 +26,13 @@ BodyRectangle {
spacing: 12
TagStrip {
context: root.context
Layout.fillWidth: true
Layout.fillHeight: false
}
SearchResults {
id: searchResults
Layout.fillWidth: true
Layout.fillHeight: true
}
@@ -1,19 +1,44 @@
pragma ComponentBehavior: Bound
import qs
import qs.services
import qs.modules.common
import qs.modules.waffle.looks
import qs.modules.common.functions
import qs.modules.common
import qs.services
import qs
import qs.modules.common.models
import Quickshell
import QtQuick.Layouts
import QtQuick.Controls
import QtQuick
pragma ComponentBehavior: Bound
RowLayout {
id: root
property int maxResultsPerCategory: 4
property StartMenuContext context
property int currentIndex: context.currentIndex
onCurrentIndexChanged: {
forceCurrentIndex(currentIndex);
}
function focusFirstItem() {
resultList.currentIndex = 0;
forceCurrentIndex(0);
}
function forceCurrentIndex(index) {
context.currentIndex = index;
// Somehow this hack is needed
if (index === 0) {
resultList.incrementCurrentIndex();
resultList.decrementCurrentIndex();
} else {
resultList.decrementCurrentIndex();
resultList.incrementCurrentIndex();
}
}
Connections {
target: context
function onAccepted() {
resultList.currentItem?.execute();
}
}
ResultList {
@@ -25,23 +50,74 @@ RowLayout {
Layout.preferredWidth: 386
Layout.leftMargin: 1
Layout.rightMargin: 1
entry: resultList.model[resultList.currentIndex] ?? searchResultComp.createObject()
}
component ResultList: ListView {
component ResultList: WListView {
id: resultListView
section {
criteria: ViewSection.FullString
property: "type"
property: "category" // This is "type" with tweaks to make it match more closely
labelPositioning: ViewSection.InlineLabels
delegate: Item {
id: sectionButton
required property string section
implicitHeight: sectionChoiceButton.implicitHeight + resultListView.spacing
width: ListView.view?.width
WChoiceButton {
id: sectionChoiceButton
anchors {
left: parent.left
right: parent.right
top: parent.top
}
implicitHeight: 38
contentItem: WText {
text: sectionButton.section
font.pixelSize: Looks.font.pixelSize.large
font.weight: Looks.font.weight.strong
}
onClicked: {
root.context.selectCategory(sectionButton.section);
}
}
}
}
clip: true
spacing: 4
model: ScriptModel {
values: {
// TODO: categorize and have max per category
LauncherSearch.results.slice(0, 10)
}
onValuesChanged: {
root.focusFirstItem();
currentIndex: root.currentIndex
// We can't use a ScriptModel here because it would mess up sections
model: {
const allResults = LauncherSearch.results;
// Find categories
var categories = new Set();
for (let i = 0; i < allResults.length; i++) {
categories.add(allResults[i].type);
}
// Collect max 4 per category
var categorizedResults = [];
categories.forEach(category => {
let count = 0;
for (let i = 0; i < allResults.length; i++) {
if (allResults[i].type === category) {
const entry = allResults[i];
const tweakedEntry = searchResultComp.createObject(null, Object.assign({}, entry));
tweakedEntry.category = categorizedResults.length === 0 ? Translation.tr("Best match") : entry.type
categorizedResults.push(tweakedEntry); // Section header
count++;
if (count >= root.maxResultsPerCategory) {
break;
}
}
}
});
// print(JSON.stringify(categorizedResults, null, 2));
return categorizedResults;
}
onModelChanged: {
root.focusFirstItem();
}
delegate: WSearchResultButton {
required property int index
@@ -53,8 +129,112 @@ RowLayout {
}
component ResultPreview: Rectangle {
id: resultPreview
property LauncherSearchResult entry // LauncherSearchResult
Layout.fillHeight: true
color: Looks.colors.bg1
radius: Looks.radius.large
ColumnLayout {
anchors.fill: parent
anchors.margins: 22
spacing: 13
ColumnLayout {
id: mainInfoColumn
Layout.alignment: Qt.AlignHCenter
SearchEntryIcon {
Layout.alignment: Qt.AlignHCenter
Layout.topMargin: 10
Layout.bottomMargin: 12
entry: resultPreview.entry
iconSize: 64
}
WText {
Layout.fillWidth: true
horizontalAlignment: Text.AlignHCenter
elide: Text.ElideRight
wrapMode: Text.Wrap
maximumLineCount: 2
text: resultPreview.entry?.name || ""
font.pixelSize: Looks.font.pixelSize.xlarger
}
WText {
Layout.alignment: Qt.AlignHCenter
text: resultPreview.entry?.type || ""
color: Looks.colors.accentUnfocused
font.pixelSize: Looks.font.pixelSize.normal
}
}
Rectangle {
id: resultSeparator
implicitHeight: 2
Layout.topMargin: 16
Layout.fillWidth: true
color: Looks.colors.bg2Hover
}
WListView {
id: actionsColumn
Layout.fillHeight: true
Layout.fillWidth: true
clip: true
spacing: 2
model: {
const isAppEntry = resultPreview.entry.type === Translation.tr("App");
const appId = isAppEntry ? resultPreview.entry.id : "";
const pinned = isAppEntry ? (Config.options.dock.pinnedApps.includes(appId)) : false;
var result = [
searchResultComp.createObject(null, {
name: resultPreview.entry.verb,
iconName: isAppEntry ? "open_in_new" : "keyboard_return",
iconType: LauncherSearchResult.IconType.Material,
execute: () => {
resultPreview.entry.execute();
}
}),
...(isAppEntry ? [
searchResultComp.createObject(null, {
name: pinned ? Translation.tr("Unpin from taskbar") : Translation.tr("Pin to taskbar"),
iconName: pinned ? "keep_off" : "keep",
iconType: LauncherSearchResult.IconType.Material,
execute: () => {
TaskbarApps.togglePin(appId);
}
})
] : [])
];
result = result.concat(resultPreview.entry.actions);
return result;
}
delegate: WButton {
id: actionButton
required property var modelData
width: ListView.view?.width
icon.name: modelData.iconName
text: modelData.name
onClicked: modelData.execute();
contentItem: RowLayout {
spacing: 11
SearchEntryIcon {
entry: actionButton.modelData
iconSize: 16
}
WText {
Layout.fillWidth: true
horizontalAlignment: Text.AlignLeft
text: actionButton.text
}
}
}
}
}
}
Component {
id: searchResultComp
LauncherSearchResult {}
}
}
@@ -16,10 +16,72 @@ WBarAttachedPanelContent {
property bool searching: false
property string searchText: LauncherSearch.query
StartMenuContext {
id: context
}
Keys.onPressed: event => {
// Prevent Esc and Backspace from registering
if (event.key === Qt.Key_Escape)
return;
// Handle Backspace: focus and delete character if not focused
if (event.key === Qt.Key_Backspace) {
searchBar.forceFocus();
if (event.modifiers & Qt.ControlModifier) {
// Delete word before cursor
let text = searchBar.text;
let pos = searchBar.searchInput.cursorPosition;
if (pos > 0) {
// Find the start of the previous word
let left = text.slice(0, pos);
let match = left.match(/(\s*\S+)\s*$/);
let deleteLen = match ? match[0].length : 1;
searchBar.text = text.slice(0, pos - deleteLen) + text.slice(pos);
searchBar.searchInput.cursorPosition = pos - deleteLen;
}
} else {
// Delete character before cursor if any
if (searchBar.searchInput.cursorPosition > 0) {
searchBar.text = searchBar.text.slice(0, searchBar.searchInput.cursorPosition - 1) + searchBar.text.slice(searchBar.searchInput.cursorPosition);
searchBar.searchInput.cursorPosition -= 1;
}
}
// Always move cursor to end after programmatic edit
searchBar.searchInput.cursorPosition = searchBar.text.length;
event.accepted = true;
// If already focused, let TextField handle it
return;
}
// Only handle visible printable characters (ignore control chars, arrows, etc.)
if (event.text && event.text.length === 1 && event.key !== Qt.Key_Enter && event.key !== Qt.Key_Return && event.key !== Qt.Key_Delete && event.text.charCodeAt(0) >= 0x20) // ignore control chars like Backspace, Tab, etc.
{
if (!searchBar.searchInput.activeFocus) {
searchBar.forceFocus();
// Insert the character at the cursor position
searchBar.text = searchBar.text.slice(0, searchBar.searchInput.cursorPosition) + event.text + searchBar.text.slice(searchBar.searchInput.cursorPosition);
searchBar.searchInput.cursorPosition += 1;
event.accepted = true;
context.setCurrentIndex(0);
}
}
// Arrow keys for item navigation
if (event.key === Qt.Key_Down) {
let maxIndex = Math.max(0, LauncherSearch.results.length - 1);
context.setCurrentIndex(Math.min(context.currentIndex + 1, maxIndex));
event.accepted = true;
} else if (event.key === Qt.Key_Up) {
context.setCurrentIndex(Math.max(context.currentIndex - 1, 0));
event.accepted = true;
}
}
contentItem: WPane {
contentItem: WPanelPageColumn {
SearchBar {
focus: true
id: searchBar
Layout.fillWidth: true
implicitWidth: 832 // TODO: Make sizes naturally inferred
horizontalPadding: root.searching ? 24 : 32
@@ -27,10 +89,14 @@ WBarAttachedPanelContent {
Synchronizer on searching {
property alias target: root.searching
}
focus: true
text: root.searchText
onTextChanged: {
LauncherSearch.query = text;
}
onAccepted: {
context.accepted();
}
}
Item {
implicitHeight: root.searching ? 736 : 736 // TODO: Make sizes naturally inferred
@@ -46,7 +112,9 @@ WBarAttachedPanelContent {
Component {
id: searchPageComp
SearchPageContent {}
SearchPageContent {
context: context
}
}
Component {
@@ -0,0 +1,64 @@
import QtQuick
import Quickshell
import Quickshell.Io
import qs
import qs.modules.common
import qs.services
Scope {
id: root
signal accepted
property int currentIndex: 0
function setCurrentIndex(index) {
if (index == currentIndex)
return;
currentIndex = index;
}
function selectCategory(category) {
for (let i = 0; i < root.categories.length; i++) {
const thisCategoryName = root.categories[i].name;
if (thisCategoryName.startsWith(category) || category.startsWith(thisCategoryName)) {
LauncherSearch.ensurePrefix(root.categories[i].prefix);
return;
}
}
}
property list<var> categories: [
{
name: Translation.tr("All"),
prefix: ""
},
{
name: Translation.tr("Apps"),
prefix: Config.options.search.prefix.app
},
{
name: Translation.tr("Actions"),
prefix: Config.options.search.prefix.action
},
{
name: Translation.tr("Clipboard"),
prefix: Config.options.search.prefix.clipboard
},
{
name: Translation.tr("Emojis"),
prefix: Config.options.search.prefix.emojis
},
{
name: Translation.tr("Math"),
prefix: Config.options.search.prefix.math
},
{
name: Translation.tr("Commands"),
prefix: Config.options.search.prefix.shellCommand
},
{
name: Translation.tr("Web"),
prefix: Config.options.search.prefix.webSearch
},
]
}
@@ -10,6 +10,9 @@ import qs.modules.common.functions
import qs.modules.waffle.looks
RowLayout {
id: root
property StartMenuContext context
WPanelIconButton {
implicitWidth: 36
implicitHeight: 36
@@ -23,40 +26,7 @@ RowLayout {
Layout.fillHeight: true
orientation: Qt.Horizontal
spacing: 4
model: [
{
name: Translation.tr("All"),
prefix: ""
},
{
name: Translation.tr("Apps"),
prefix: Config.options.search.prefix.app
},
{
name: Translation.tr("Actions"),
prefix: Config.options.search.prefix.action
},
{
name: Translation.tr("Clipboard"),
prefix: Config.options.search.prefix.clipboard
},
{
name: Translation.tr("Emojis"),
prefix: Config.options.search.prefix.emojis
},
{
name: Translation.tr("Math"),
prefix: Config.options.search.prefix.math
},
{
name: Translation.tr("Commands"),
prefix: Config.options.search.prefix.shellCommand
},
{
name: Translation.tr("Web"),
prefix: Config.options.search.prefix.webSearch
},
]
model: root.context.categories
delegate: WBorderedButton {
id: tagButton
required property var modelData
@@ -11,7 +11,7 @@ import qs.modules.waffle.looks
WChoiceButton {
id: root
required property LauncherSearchResult entry
property bool firstEntry: false
@@ -21,45 +21,28 @@ WChoiceButton {
implicitHeight: contentLayout.implicitHeight + topPadding + bottomPadding
onClicked: {
GlobalStates.searchOpen = false
root.entry.execute()
execute();
}
function execute() {
GlobalStates.searchOpen = false;
root.entry.execute();
}
contentItem: RowLayout {
id: contentLayout
spacing: 8
EntryIcon {}
SearchEntryIcon {
entry: root.entry
iconSize: 24
}
EntryNameColumn {
Layout.fillWidth: true
Layout.alignment: Qt.AlignVCenter
}
}
component EntryIcon: Item {
implicitWidth: 24
implicitHeight: 24
Loader {
anchors.centerIn: parent
active: root.entry.iconType === LauncherSearchResult.IconType.System
sourceComponent: WAppIcon {
implicitSize: 24
tryCustomIcon: false
iconName: root.entry.iconName
}
}
Loader {
anchors.centerIn: parent
active: root.entry.iconType === LauncherSearchResult.IconType.Text
sourceComponent: WText {
text: root.entry.iconName
font.pixelSize: 24
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
}
component EntryNameColumn: ColumnLayout {
spacing: 4
@@ -78,4 +61,11 @@ WChoiceButton {
color: Looks.colors.accentUnfocused
}
}
MouseArea {
anchors.fill: parent
// hoverEnabled: true
acceptedButtons: Qt.NoButton
cursorShape: Qt.PointingHandCursor
}
}
@@ -15,8 +15,10 @@ Scope {
target: GlobalStates
function onSearchOpenChanged() {
if (GlobalStates.searchOpen)
if (GlobalStates.searchOpen) {
LauncherSearch.query = "";
panelLoader.active = true;
}
}
}
@@ -62,6 +64,7 @@ Scope {
onClosed: {
GlobalStates.searchOpen = false;
panelLoader.active = false;
LauncherSearch.query = "";
}
}
}