Add shell plugin model tests

This commit is contained in:
David Heinemeier Hansson
2026-05-25 14:18:39 +02:00
parent 4277f5346b
commit 829c1fa4f7
61 changed files with 3236 additions and 1168 deletions
+24 -195
View File
@@ -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() {