Add shell plugin model tests
This commit is contained in:
+24
-195
@@ -3,6 +3,7 @@ import Quickshell.Io
|
||||
import Quickshell.Wayland
|
||||
import QtQuick
|
||||
import qs.Commons
|
||||
import "MenuModel.js" as MenuModel
|
||||
|
||||
Item {
|
||||
id: root
|
||||
@@ -169,112 +170,35 @@ Item {
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
function stripJsonc(raw) {
|
||||
// Match what the bash bin's jq pipeline accepts: full-line `//` comments
|
||||
// plus trailing commas before } or ]. Anything fancier should land in
|
||||
// valid JSON anyway.
|
||||
return String(raw || "")
|
||||
.replace(/^\s*\/\/[^\n]*(\n|$)/gm, "")
|
||||
.replace(/,(\s*[}\]])/g, "$1")
|
||||
return MenuModel.stripJsonc(raw)
|
||||
}
|
||||
|
||||
function normalizeAliases(value) {
|
||||
if (Array.isArray(value)) return value.filter(function(v) { return v })
|
||||
if (typeof value === "string" && value) return [value]
|
||||
return []
|
||||
return MenuModel.normalizeAliases(value)
|
||||
}
|
||||
|
||||
function normalizeKeywords(id, aliases, raw) {
|
||||
var parts = String(raw || "").split(/\s+/)
|
||||
var seen = {}
|
||||
var out = []
|
||||
for (var i = 0; i < parts.length; i++) {
|
||||
var p = parts[i]
|
||||
if (!p || seen[p]) continue
|
||||
seen[p] = true
|
||||
out.push(p)
|
||||
}
|
||||
return out.join(" ")
|
||||
return MenuModel.normalizeKeywords(id, aliases, raw)
|
||||
}
|
||||
|
||||
function normalizeItem(id, raw) {
|
||||
var aliases = normalizeAliases(raw.aliases)
|
||||
var parent = raw.parent
|
||||
if (parent === undefined) {
|
||||
parent = id.indexOf(".") >= 0 ? id.split(".").slice(0, -1).join(".") : "root"
|
||||
}
|
||||
if (id === "root") parent = ""
|
||||
|
||||
var kind = raw.action ? "action" : (raw.target ? "link" : "menu")
|
||||
|
||||
return {
|
||||
id: id,
|
||||
parent: parent,
|
||||
kind: kind,
|
||||
icon: raw.icon || "",
|
||||
label: raw.label || id,
|
||||
target: raw.target || "",
|
||||
keywords: normalizeKeywords(id, aliases, raw.keywords),
|
||||
description: raw.description || "",
|
||||
action: raw.action || "",
|
||||
provider: raw.provider || "",
|
||||
aliases: aliases,
|
||||
when: raw.when || "",
|
||||
checked: raw.checked || ""
|
||||
}
|
||||
return MenuModel.normalizeItem(id, raw)
|
||||
}
|
||||
|
||||
function parseMenuJsonc(raw) {
|
||||
var stripped = stripJsonc(raw)
|
||||
if (!stripped.trim()) return []
|
||||
var parsed
|
||||
try { parsed = JSON.parse(stripped) } catch (e) {
|
||||
console.warn("omarchy-menu: JSONC parse failed:", e)
|
||||
return []
|
||||
}
|
||||
if (typeof parsed !== "object" || parsed === null) return []
|
||||
var source = (parsed.items && typeof parsed.items === "object" && !Array.isArray(parsed.items))
|
||||
? parsed.items
|
||||
: parsed
|
||||
var out = []
|
||||
for (var id in source) {
|
||||
var entry = source[id]
|
||||
if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue
|
||||
out.push(normalizeItem(id, entry))
|
||||
}
|
||||
return out
|
||||
return MenuModel.parseMenuJsonc(raw)
|
||||
}
|
||||
|
||||
// Merge defaults + user extension. Later entries override earlier ones
|
||||
// on a per-key basis (so the user can tweak label/icon/action without
|
||||
// re-declaring the whole row).
|
||||
function rebuildItemsFromSources() {
|
||||
var nextItems = ({})
|
||||
var nextOrder = []
|
||||
var sources = [root.defaultMenuItems || [], root.userMenuItems || []]
|
||||
for (var s = 0; s < sources.length; s++) {
|
||||
var src = sources[s]
|
||||
for (var i = 0; i < src.length; i++) {
|
||||
var entry = src[i]
|
||||
if (!entry || !entry.id) continue
|
||||
if (!nextItems[entry.id]) nextOrder.push(entry.id)
|
||||
var prior = nextItems[entry.id] || {}
|
||||
var merged = {}
|
||||
for (var k in prior) merged[k] = prior[k]
|
||||
for (var k2 in entry) merged[k2] = entry[k2]
|
||||
merged.id = entry.id
|
||||
nextItems[entry.id] = merged
|
||||
}
|
||||
}
|
||||
if (!nextItems.root) {
|
||||
nextItems.root = { id: "root", parent: "", kind: "menu", icon: "", label: "Go", target: "", keywords: "", description: "", aliases: [], when: "", checked: "", action: "", provider: "" }
|
||||
nextOrder.unshift("root")
|
||||
}
|
||||
for (var k3 = 0; k3 < nextOrder.length; k3++) nextItems[nextOrder[k3]].order = k3
|
||||
var mergedMenu = MenuModel.mergeMenuSources(root.defaultMenuItems, root.userMenuItems)
|
||||
root.providerRevision += 1
|
||||
root.providersLoaded = ({})
|
||||
root.providerQueue = []
|
||||
root.items = nextItems
|
||||
root.itemOrder = nextOrder
|
||||
root.items = mergedMenu.items
|
||||
root.itemOrder = mergedMenu.itemOrder
|
||||
root.rowsLoaded = true
|
||||
root.evaluateGuards()
|
||||
if (root.opened) {
|
||||
@@ -305,7 +229,7 @@ Item {
|
||||
})
|
||||
|
||||
function slugify(value) {
|
||||
return String(value || "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "item"
|
||||
return MenuModel.slugify(value)
|
||||
}
|
||||
|
||||
function startProviderForMenu(id) {
|
||||
@@ -399,62 +323,23 @@ Item {
|
||||
}
|
||||
|
||||
function depthFor(id) {
|
||||
var depth = 0
|
||||
var current = root.item(id)
|
||||
var guard = 0
|
||||
|
||||
while (current && current.parent && current.parent !== "root" && guard < 32) {
|
||||
depth += 1
|
||||
current = root.item(current.parent)
|
||||
guard += 1
|
||||
}
|
||||
|
||||
return depth
|
||||
return MenuModel.depthFor(root.items, id)
|
||||
}
|
||||
|
||||
function pathFor(id) {
|
||||
var labels = []
|
||||
var current = root.item(id)
|
||||
var guard = 0
|
||||
|
||||
while (current && current.id !== "root" && guard < 32) {
|
||||
labels.unshift(current.label)
|
||||
current = root.item(current.parent)
|
||||
guard += 1
|
||||
}
|
||||
|
||||
return labels.join(" › ")
|
||||
return MenuModel.pathFor(root.items, id)
|
||||
}
|
||||
|
||||
function parentPathFor(id) {
|
||||
var entry = root.item(id)
|
||||
if (!entry || !entry.parent || entry.parent === "root") return ""
|
||||
|
||||
return root.pathFor(entry.parent)
|
||||
return MenuModel.parentPathFor(root.items, id)
|
||||
}
|
||||
|
||||
function isDescendantOf(id, ancestorId) {
|
||||
if (ancestorId === "root") return id !== "root"
|
||||
|
||||
var current = root.item(id)
|
||||
var guard = 0
|
||||
while (current && current.parent && guard < 32) {
|
||||
if (current.parent === ancestorId) return true
|
||||
current = root.item(current.parent)
|
||||
guard += 1
|
||||
}
|
||||
|
||||
return false
|
||||
return MenuModel.isDescendantOf(root.items, id, ancestorId)
|
||||
}
|
||||
|
||||
function childCount(id) {
|
||||
var count = 0
|
||||
for (var i = 0; i < root.itemOrder.length; i++) {
|
||||
var entry = root.item(root.itemOrder[i])
|
||||
if (entry && entry.parent === id) count += 1
|
||||
}
|
||||
|
||||
return count
|
||||
return MenuModel.childCount(root.items, root.itemOrder, id)
|
||||
}
|
||||
|
||||
// Items whose `when:` evaluated to false are hidden everywhere — nav,
|
||||
@@ -468,95 +353,39 @@ Item {
|
||||
|
||||
// Label with the ✓ marker baked in when `checked:` evaluated truthy.
|
||||
function labelFor(entry) {
|
||||
if (!entry) return ""
|
||||
if (entry.checked && root.checkedResults[entry.id]) return entry.label + " ✓"
|
||||
return entry.label
|
||||
return MenuModel.labelFor(entry, root.checkedResults)
|
||||
}
|
||||
|
||||
function searchableToken(value) {
|
||||
return String(value || "").replace(/[._-]+/g, " ")
|
||||
return MenuModel.searchableToken(value)
|
||||
}
|
||||
|
||||
function leafIdFor(id) {
|
||||
var parts = String(id || "").split(".")
|
||||
return parts.length > 0 ? parts[parts.length - 1] : id
|
||||
return MenuModel.leafIdFor(id)
|
||||
}
|
||||
|
||||
function nameSearchText(entry) {
|
||||
if (!entry) return ""
|
||||
var aliases = []
|
||||
for (var i = 0; i < entry.aliases.length; i++) aliases.push(root.searchableToken(entry.aliases[i]))
|
||||
return [entry.label, root.searchableToken(root.leafIdFor(entry.id)), aliases.join(" ")].join(" ").toLowerCase()
|
||||
return MenuModel.nameSearchText(entry)
|
||||
}
|
||||
|
||||
function termInSearchWords(term, text) {
|
||||
var words = String(text || "").toLowerCase().split(/\s+/)
|
||||
for (var i = 0; i < words.length; i++) {
|
||||
if (words[i] === term) return true
|
||||
}
|
||||
return false
|
||||
return MenuModel.termInSearchWords(term, text)
|
||||
}
|
||||
|
||||
function keywordTextMatches(query, text) {
|
||||
var terms = query.toLowerCase().trim().split(/\s+/)
|
||||
for (var i = 0; i < terms.length; i++) {
|
||||
if (terms[i] && !root.termInSearchWords(terms[i], text)) return false
|
||||
}
|
||||
return true
|
||||
return MenuModel.keywordTextMatches(query, text)
|
||||
}
|
||||
|
||||
function matchesQuery(entry, query) {
|
||||
if (!entry || entry.id === "root") return false
|
||||
if (!root.isVisible(entry)) return false
|
||||
|
||||
var nameText = root.nameSearchText(entry)
|
||||
var keywordText = (entry.keywords + " " + entry.description).toLowerCase()
|
||||
var terms = query.toLowerCase().trim().split(/\s+/)
|
||||
|
||||
for (var i = 0; i < terms.length; i++) {
|
||||
if (!terms[i]) continue
|
||||
if (nameText.indexOf(terms[i]) >= 0) continue
|
||||
if (root.termInSearchWords(terms[i], keywordText)) continue
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
return MenuModel.matchesQuery(entry, query, root.isVisible(entry))
|
||||
}
|
||||
|
||||
function searchScore(entry, query) {
|
||||
var needle = query.toLowerCase().trim()
|
||||
var label = entry.label.toLowerCase()
|
||||
var nameText = root.nameSearchText(entry)
|
||||
var keywordText = (entry.keywords + " " + entry.description).toLowerCase()
|
||||
var score = 80
|
||||
|
||||
if (label === needle) score = entry.parent === "root" ? 2 : 0
|
||||
else if (label.indexOf(needle) === 0) score = 10
|
||||
else if (label.indexOf(needle) >= 0) score = 30
|
||||
else if (nameText.indexOf(needle) >= 0) score = 40
|
||||
else if (root.keywordTextMatches(needle, keywordText)) score = 60
|
||||
|
||||
if (entry.kind === "menu" || entry.kind === "link") score -= 2
|
||||
|
||||
return score * 1000 + root.depthFor(entry.id) * 25 + entry.order
|
||||
return MenuModel.searchScore(root.items, entry, query)
|
||||
}
|
||||
|
||||
function displayRow(entry, detail, score, section) {
|
||||
var target = entry.kind === "link" ? entry.target : entry.id
|
||||
return {
|
||||
itemId: entry.id,
|
||||
kind: entry.kind,
|
||||
icon: entry.icon,
|
||||
label: root.labelFor(entry),
|
||||
target: target,
|
||||
detail: detail || "",
|
||||
path: root.pathFor(entry.id),
|
||||
childCount: (entry.kind === "menu" || entry.kind === "link") ? root.childCount(target) : 0,
|
||||
action: entry.action || "",
|
||||
provider: entry.provider || "",
|
||||
score: score || 0,
|
||||
section: section || ""
|
||||
}
|
||||
return MenuModel.displayRow(root.items, root.itemOrder, root.checkedResults, entry, detail, score, section)
|
||||
}
|
||||
|
||||
function rebuildDmenuDisplay() {
|
||||
|
||||
Reference in New Issue
Block a user