Files
arthur-os/shell/plugins/menu/Menu.qml
T

1059 lines
35 KiB
QML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import QtQuick
import qs.Commons
import "MenuModel.js" as MenuModel
Item {
id: root
// Injected by omarchy-shell when this plugin is summoned.
property string omarchyPath: Quickshell.env("OMARCHY_PATH")
// Plugin lifecycle hooks. The host calls open(payloadJson) after
// `omarchy-shell shell summon omarchy.menu ...` and close() when hidden.
property string pendingInitialMenu: "root"
function open(payloadJson) {
var payload = ({})
try { payload = JSON.parse(payloadJson || "{}") } catch (e) { payload = ({}) }
if (payload.fontFamily) root.fontFamily = payload.fontFamily
if (payload.mode === "select" || payload.mode === "input") {
root.openDmenu(payload)
} else {
root.openRoute(payload.initialMenu || payload.menu || "root")
}
}
function close() {
root.cancel()
}
function refresh() {
defaultMenuFile.reload()
userMenuFile.reload()
return "ok"
}
function ping() { return "ok" }
property string fontFamily: Style.font.menuFamily
// JSONC menu definitions. The shell parses both at startup and merges
// the user file on top of the defaults, so the keybind → IPC → visible
// path doesn't have to shell out to bash + jq on every open.
property string defaultMenuPath: omarchyPath + "/default/omarchy/omarchy-menu.jsonc"
property string userMenuPath: Quickshell.env("HOME") + "/.config/omarchy/extensions/omarchy-menu.jsonc"
property var defaultMenuItems: []
property var userMenuItems: []
property bool opened: false
property string mode: "menu"
readonly property bool dmenuActive: mode === "select" || mode === "input"
property string dmenuPrompt: ""
property var dmenuOptions: []
property string selectionFile: ""
property string doneFile: ""
property int dmenuWidth: 300
property int dmenuMaxHeight: 0
property bool requestActive: false
property bool rowsLoaded: false
property string activeMenu: "root"
property string filterText: ""
property int selectedIndex: 0
property bool cursorActive: false
property int requestSerial: 0
property int applySerial: 0
property var items: ({})
property var itemOrder: []
property var navStack: []
property var providersLoaded: ({})
property var providerQueue: []
property int providerRevision: 0
// Bound to the central [menu] section in shell.toml via Color.qml.
// Each color already includes its alpha companion (composed in the
// singleton), so consumers can drop them straight into a Rectangle.
property color background: Color.menu.background
property color foreground: Color.menu.text
property color border: Color.menu.border
property color scrim: Color.menu.scrim
property color selectedBackground: Color.menu.selectedBackground
property color selectedText: Color.menu.selectedText
readonly property int cornerRadius: Style.cornerRadius
property int contentMargin: Style.spacing.panelPadding
property int headerHeight: Math.max(Style.space(34), Style.font.title + Style.spacing.controlPaddingY * 2)
property int contentSpacing: Style.spacing.md
property int baseRowHeight: Math.max(Style.space(50), Style.font.body + Style.spacing.rowPaddingX * 2)
property int detailRowHeight: Math.max(Style.space(58), Style.font.body + Style.font.caption + Style.spacing.rowPaddingX * 2)
property int rowSpacing: Style.spacing.xs
property int dividerHeight: Style.space(17)
property bool searchDivider: false
property int layoutSerial: 0
property int cardWidth: Math.min(root.dmenuActive ? Style.space(root.dmenuWidth) : ((root.activeMenu === "trigger.capture.screenrecord" || root.activeMenu === "style.font") ? Style.space(520) : Style.space(300)), panel.width - Style.gapsOut * 2)
property int visibleRowsHeight: root.dmenuActive ? dmenuRowListHeight(layoutSerial, displayModel.count, filterText) : rowListHeight(layoutSerial, displayModel.count, filterText, searchDivider)
property int cardHeight: root.dmenuActive
? Math.min(contentMargin * 2 + headerHeight + (mode === "input" ? 0 : contentSpacing + visibleRowsHeight), panel.height - Style.gapsOut * 2)
: Math.min(Math.max(Style.space(220), contentMargin * 2 + headerHeight + contentSpacing + visibleRowsHeight), panel.height - Style.gapsOut * 2)
function finishRequest(selection) {
if (!root.requestActive || !root.doneFile) {
root.opened = false
return
}
var activeSelectionFile = root.selectionFile
var activeDoneFile = root.doneFile
root.requestActive = false
root.selectionFile = ""
root.doneFile = ""
if (selection === null || selection === undefined) {
resultProc.command = ["bash", "-lc", ": > " + Util.shellQuote(activeDoneFile)]
} else {
resultProc.command = ["bash", "-lc", "printf '%s\\n' " + Util.shellQuote(selection) + " > " + Util.shellQuote(activeSelectionFile) + "; : > " + Util.shellQuote(activeDoneFile)]
}
resultProc.running = true
}
function runAction(action) {
var command = String(action || "")
if (!command) return
Quickshell.execDetached(["bash", "-lc", command])
}
function rowHeightForDetail(detail) {
return root.filterText && detail ? root.detailRowHeight : root.baseRowHeight
}
function rowListHeight(_serial, _count, _filter, _divider) {
if (displayModel.count === 0) return root.baseRowHeight
var count = Math.min(displayModel.count, 10)
var total = 0
var previousSection = ""
for (var i = 0; i < count; i++) {
var row = displayModel.get(i)
if (i > 0) total += root.rowSpacing
if (row.section === "drilldown" && previousSection !== "drilldown") total += root.dividerHeight
total += root.rowHeightForDetail(row.detail)
previousSection = row.section
}
return total
}
function dmenuRowListHeight(_serial, _count, _filter) {
if (root.mode === "input") return 0
if (displayModel.count === 0) return root.baseRowHeight
var count = Math.min(displayModel.count, 10)
var total = 0
for (var i = 0; i < count; i++) {
if (i > 0) total += root.rowSpacing
total += root.baseRowHeight
}
return root.dmenuMaxHeight > 0 ? Math.min(total, Style.space(root.dmenuMaxHeight)) : total
}
function item(id) {
return root.items[id] || null
}
// ------------------------------------------------------------------
// JSONC → normalized item array. Mirrors the bash bin's jq pipeline so
// the on-disk authoring format stays untouched.
// ------------------------------------------------------------------
function stripJsonc(raw) {
return MenuModel.stripJsonc(raw)
}
function normalizeAliases(value) {
return MenuModel.normalizeAliases(value)
}
function normalizeKeywords(id, aliases, raw) {
return MenuModel.normalizeKeywords(id, aliases, raw)
}
function normalizeItem(id, raw) {
return MenuModel.normalizeItem(id, raw)
}
function parseMenuJsonc(raw) {
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 mergedMenu = MenuModel.mergeMenuSources(root.defaultMenuItems, root.userMenuItems)
root.providerRevision += 1
root.providersLoaded = ({})
root.providerQueue = []
root.items = mergedMenu.items
root.itemOrder = mergedMenu.itemOrder
root.rowsLoaded = true
root.evaluateGuards()
if (root.opened) {
root.rebuildDisplay()
if (!root.dmenuActive) {
if (root.filterText.trim()) root.loadProvidersForSearch()
else root.loadProviderForMenu(root.activeMenu)
}
}
}
// Each known provider is a tiny bash one-liner that enumerates a list and
// emits one tab-delimited row per item: `label\tvalue\tcurrent`. The shell
// turns those into menu items children of `menuId`.
readonly property var providers: ({
"fonts": {
script: "current=$(omarchy-font-current 2>/dev/null); omarchy-font-list 2>/dev/null | while read -r f; do [[ -z $f ]] && continue; printf '%s\\t%s\\t%s\\n' \"$f\" \"$f\" \"$current\"; done",
icon: "",
actionFor: function(value) { return "omarchy-font-set '" + value.replace(/'/g, "'\\''") + "'" },
keywordsFor: function(value) { return value + " typeface" }
},
"power-profiles": {
script: "current=$(powerprofilesctl get 2>/dev/null); omarchy-powerprofiles-list 2>/dev/null | while read -r p; do [[ -z $p ]] && continue; printf '%s\\t%s\\t%s\\n' \"$p\" \"$p\" \"$current\"; done",
icon: "\udb81\udc0b",
actionFor: function(value) { return "powerprofilesctl set '" + value.replace(/'/g, "'\\''") + "'" },
keywordsFor: function(value) { return value + " power profile" }
}
})
function slugify(value) {
return MenuModel.slugify(value)
}
function startProviderForMenu(id) {
var entry = root.item(id)
if (!entry || !entry.provider || root.providersLoaded[id]) return
var spec = root.providers[entry.provider]
if (!spec) return
root.providersLoaded[id] = true
providerProc.menuId = id
providerProc.providerKey = entry.provider
providerProc.revision = root.providerRevision
providerProc.collected = ""
providerProc.command = ["bash", "-lc", spec.script]
providerProc.running = true
}
function mergeProviderRows(rows, menuId, providerKey) {
var spec = root.providers[providerKey]
if (!spec) return
var changed = false
var lines = String(rows || "").split("\n")
var nextOrder = root.itemOrder.slice()
for (var i = 0; i < lines.length; i++) {
var line = lines[i].trim()
if (!line) continue
var parts = line.split("\t")
var label = parts[0] || ""
var value = parts[1] || parts[0] || ""
var current = parts[2] || ""
if (!label) continue
var id = menuId + "." + root.slugify(value)
if (!root.items[id]) nextOrder.push(id)
root.items[id] = {
id: id,
parent: menuId,
kind: "action",
icon: (value === current) ? "✓" : (spec.icon || ""),
label: label,
target: "",
keywords: spec.keywordsFor(value),
description: "",
action: spec.actionFor(value),
provider: "",
aliases: [],
when: "",
checked: "",
order: nextOrder.indexOf(id)
}
changed = true
}
root.itemOrder = nextOrder
if (changed && root.opened) root.rebuildDisplay()
}
function startNextProvider() {
if (providerProc.running) return
while (root.providerQueue.length > 0) {
var id = root.providerQueue.shift()
var entry = root.item(id)
if (!entry || !entry.provider || root.providersLoaded[id]) continue
root.startProviderForMenu(id)
return
}
}
function loadProviderForMenu(id) {
var entry = root.item(id)
if (!entry || !entry.provider || root.providersLoaded[id]) return
if (providerProc.running) {
if (root.providerQueue.indexOf(id) < 0) root.providerQueue = root.providerQueue.concat([id])
return
}
root.startProviderForMenu(id)
}
function loadProvidersForSearch() {
var active = root.item(root.activeMenu) ? root.activeMenu : "root"
for (var i = 0; i < root.itemOrder.length; i++) {
var entry = root.item(root.itemOrder[i])
if (!entry || !entry.provider || root.providersLoaded[entry.id]) continue
if (active !== "root" && entry.id !== active && !root.isDescendantOf(entry.id, active)) continue
root.loadProviderForMenu(entry.id)
}
}
function depthFor(id) {
return MenuModel.depthFor(root.items, id)
}
function pathFor(id) {
return MenuModel.pathFor(root.items, id)
}
function parentPathFor(id) {
return MenuModel.parentPathFor(root.items, id)
}
function isDescendantOf(id, ancestorId) {
return MenuModel.isDescendantOf(root.items, id, ancestorId)
}
function childCount(id) {
return MenuModel.childCount(root.items, root.itemOrder, id)
}
// Items whose `when:` evaluated to false are hidden everywhere — nav,
// drilldown, and search. Items with no `when:` are always visible.
function isVisible(entry) {
if (!entry) return false
if (!entry.when) return true
var result = root.whenResults[entry.id]
return result === undefined ? true : result
}
// Label with the ✓ marker baked in when `checked:` evaluated truthy.
function labelFor(entry) {
return MenuModel.labelFor(entry, root.checkedResults)
}
function searchableToken(value) {
return MenuModel.searchableToken(value)
}
function leafIdFor(id) {
return MenuModel.leafIdFor(id)
}
function nameSearchText(entry) {
return MenuModel.nameSearchText(entry)
}
function termInSearchWords(term, text) {
return MenuModel.termInSearchWords(term, text)
}
function keywordTextMatches(query, text) {
return MenuModel.keywordTextMatches(query, text)
}
function matchesQuery(entry, query) {
return MenuModel.matchesQuery(entry, query, root.isVisible(entry))
}
function searchScore(entry, query) {
return MenuModel.searchScore(root.items, entry, query)
}
function displayRow(entry, detail, score, section) {
return MenuModel.displayRow(root.items, root.itemOrder, root.checkedResults, entry, detail, score, section)
}
function rebuildDmenuDisplay() {
displayModel.clear()
root.searchDivider = false
if (root.mode === "input") {
layoutSerial += 1
return
}
var query = root.filterText.trim().toLowerCase()
for (var i = 0; i < root.dmenuOptions.length; i++) {
var label = String(root.dmenuOptions[i] || "")
if (query && label.toLowerCase().indexOf(query) < 0) continue
displayModel.append({
itemId: "dmenu." + i,
kind: "dmenu",
icon: "",
label: label,
target: "",
detail: "",
path: "",
childCount: 0,
action: "",
provider: "",
score: i,
section: ""
})
}
layoutSerial += 1
if (displayModel.count === 0) selectedIndex = 0
else if (selectedIndex >= displayModel.count) selectedIndex = displayModel.count - 1
else if (selectedIndex < 0) selectedIndex = 0
Qt.callLater(function() {
if (displayModel.count > 0) resultList.positionViewAtIndex(root.selectedIndex, ListView.Contain)
})
}
function rebuildDisplay() {
if (root.dmenuActive) {
root.rebuildDmenuDisplay()
return
}
displayModel.clear()
if (!root.rowsLoaded) return
var active = root.item(root.activeMenu) ? root.activeMenu : "root"
root.activeMenu = active
var rows = []
var query = root.filterText.trim()
root.searchDivider = false
if (query) {
var currentRows = []
var drilldownRows = []
for (var i = 0; i < root.itemOrder.length; i++) {
var entry = root.item(root.itemOrder[i])
if (!entry || entry.id === "root") continue
if (!root.isDescendantOf(entry.id, active)) continue
if (!root.matchesQuery(entry, query)) continue
var detail = root.parentPathFor(entry.id)
var row = root.displayRow(entry, detail, root.searchScore(entry, query))
if (entry.parent === active) currentRows.push(row)
else drilldownRows.push(row)
}
var searchSort = function(a, b) {
if (a.score !== b.score) return a.score - b.score
return a.path.localeCompare(b.path)
}
currentRows.sort(searchSort)
drilldownRows.sort(searchSort)
root.searchDivider = currentRows.length > 0 && drilldownRows.length > 0
if (root.searchDivider) {
for (var d = 0; d < drilldownRows.length; d++) drilldownRows[d].section = "drilldown"
}
rows = currentRows.concat(drilldownRows)
} else {
for (var j = 0; j < root.itemOrder.length; j++) {
var child = root.item(root.itemOrder[j])
if (!child || child.parent !== active) continue
if (!root.isVisible(child)) continue
rows.push(root.displayRow(child, child.description, child.order))
}
}
for (var k = 0; k < rows.length; k++) displayModel.append(rows[k])
layoutSerial += 1
if (displayModel.count === 0) selectedIndex = 0
else if (selectedIndex >= displayModel.count) selectedIndex = displayModel.count - 1
else if (selectedIndex < 0) selectedIndex = 0
Qt.callLater(function() {
if (displayModel.count > 0) resultList.positionViewAtIndex(root.selectedIndex, ListView.Contain)
})
}
function select(delta) {
if (displayModel.count === 0) return
if (!cursorActive) {
cursorActive = true
selectedIndex = delta < 0 ? displayModel.count - 1 : 0
} else {
selectedIndex = (selectedIndex + delta + displayModel.count) % displayModel.count
}
resultList.positionViewAtIndex(selectedIndex, ListView.Contain)
}
function setFilter(nextFilter) {
root.filterText = nextFilter
root.selectedIndex = 0
root.cursorActive = root.mode !== "input"
if (!root.dmenuActive && root.filterText.trim()) root.loadProvidersForSearch()
root.rebuildDisplay()
}
function setActiveMenu(id, pushHistory) {
if (!root.item(id)) id = "root"
if (pushHistory && id !== root.activeMenu) root.navStack = root.navStack.concat([root.activeMenu])
root.activeMenu = id
root.filterText = ""
root.selectedIndex = 0
root.cursorActive = true
root.rebuildDisplay()
root.loadProviderForMenu(id)
}
function goBack() {
if (root.activeMenu === "root") return false
if (root.navStack.length > 0) {
var previous = root.navStack[root.navStack.length - 1]
root.navStack = root.navStack.slice(0, root.navStack.length - 1)
root.setActiveMenu(previous, false)
return true
}
var active = root.item(root.activeMenu)
root.setActiveMenu((active && active.parent) ? active.parent : "root", false)
return true
}
function activateIndex(index) {
if (root.dmenuActive) {
if (root.mode === "input") {
root.applyDmenuSelection(root.filterText)
return
}
if (index < 0 || index >= displayModel.count) return
root.applyDmenuSelection(displayModel.get(index).label)
return
}
if (index < 0 || index >= displayModel.count) return
var row = displayModel.get(index)
if (row.kind === "menu" || row.kind === "link") {
root.setActiveMenu(row.target || row.itemId, true)
} else {
root.applySelected(row.itemId, row.action)
}
}
function applyDmenuSelection(value) {
applySerial = requestSerial
opened = false
filterText = ""
root.finishRequest(value)
}
function applySelected(id, action) {
if (!id) { cancel(); return }
applySerial = requestSerial
opened = false
filterText = ""
root.runAction(action)
}
function cancel() {
if (root.dmenuActive) root.finishRequest(null)
opened = false
filterText = ""
}
function openExistingMenu(initialMenu) {
requestSerial += 1
mode = "menu"
requestActive = false
selectionFile = ""
doneFile = ""
activeMenu = root.item(initialMenu) ? initialMenu : "root"
navStack = []
filterText = ""
selectedIndex = 0
cursorActive = true
root.evaluateGuards()
opened = true
rebuildDisplay()
loadProviderForMenu(activeMenu)
Qt.callLater(function() { keyCatcher.forceActiveFocus() })
}
function openDmenu(payload) {
requestSerial += 1
mode = payload.mode === "input" ? "input" : "select"
dmenuPrompt = String(payload.prompt || (mode === "input" ? "Input" : "Select"))
dmenuOptions = Array.isArray(payload.options) ? payload.options : []
selectionFile = String(payload.selectionFile || "")
doneFile = String(payload.doneFile || "")
requestActive = !!doneFile
dmenuWidth = Math.max(1, Number(payload.width || 300))
dmenuMaxHeight = Math.max(0, Number(payload.maxHeight || 0))
activeMenu = "root"
navStack = []
filterText = ""
selectedIndex = 0
cursorActive = mode !== "input"
opened = true
rebuildDisplay()
Qt.callLater(function() { keyCatcher.forceActiveFocus() })
}
ListModel { id: displayModel }
// ----------------------------------------------------------- route surface
//
// The menu is opened through the standard plugin lifecycle:
// `omarchy-shell shell summon omarchy.menu '{"menu":"system"}'`.
// Callers may pass a real id (`system`, `setup.power`) or an alias declared
// in JSONC (`power`, `reminder-set`). Unknown strings fall through to the
// id-as-route behavior so misspellings still attempt to open the literal id.
function resolveRoute(input) {
var raw = String(input || "").toLowerCase().replace(/_/g, "-")
if (!raw || raw === "go" || raw === "menu") return "root"
for (var i = 0; i < root.itemOrder.length; i++) {
var entry = root.items[root.itemOrder[i]]
if (!entry || !entry.aliases) continue
for (var j = 0; j < entry.aliases.length; j++) {
var alias = String(entry.aliases[j] || "").toLowerCase().replace(/_/g, "-")
if (alias === raw) return entry.id
}
}
return raw
}
function openRoute(initialMenu) {
var id = root.resolveRoute(initialMenu)
var entry = root.items[id]
// If the resolved id is an action (i.e. the user invoked an alias for
// a leaf, e.g. `omarchy menu summon screenrecord-stop`), run it directly
// instead of opening an action with no children.
if (entry && entry.kind === "action" && entry.action) {
root.cancel()
root.runAction(entry.action)
return "ok"
}
// If it's a link (a redirect to another menu), follow the link.
if (entry && entry.kind === "link" && entry.target) id = entry.target
root.pendingInitialMenu = id
root.openExistingMenu(id)
return "ok"
}
Process {
id: providerProc
property string menuId: ""
property string providerKey: ""
property string collected: ""
property int revision: 0
stdout: SplitParser {
onRead: function(data) { providerProc.collected += data + "\n" }
}
onExited: {
if (providerProc.revision === root.providerRevision) {
root.mergeProviderRows(providerProc.collected, providerProc.menuId, providerProc.providerKey)
if (root.filterText.trim()) root.loadProvidersForSearch()
}
root.startNextProvider()
}
}
Process {
id: resultProc
onExited: {
if (root.applySerial === root.requestSerial)
root.opened = false
}
}
// The JSONC sources are watched so live edits to the default file (or the
// user extension at ~/.config/omarchy/extensions/omarchy-menu.jsonc) take
// effect without restarting the shell.
FileView {
id: defaultMenuFile
path: root.defaultMenuPath
watchChanges: true
printErrors: false
onLoaded: { root.defaultMenuItems = root.parseMenuJsonc(text()); root.rebuildItemsFromSources() }
onFileChanged: reload()
}
FileView {
id: userMenuFile
path: root.userMenuPath
watchChanges: true
printErrors: false
onLoaded: { root.userMenuItems = root.parseMenuJsonc(text()); root.rebuildItemsFromSources() }
onLoadFailed: { root.userMenuItems = []; root.rebuildItemsFromSources() }
onFileChanged: reload()
}
// ---------------------------------------------------------------- guards
//
// `when:` (visibility) and `checked:` (✓ marker) are bash expressions the
// shell wasn't allowed to evaluate before the perf rewrite. Now the shell
// batches them into one bash subprocess per (re)load so the open path
// never has to wait on them.
property var whenResults: ({}) // id → true|false (allow visibility)
property var checkedResults: ({}) // id → true|false (show ✓)
function evaluateGuards() {
var script = ""
var ids = Object.keys(root.items)
for (var i = 0; i < ids.length; i++) {
var entry = root.items[ids[i]]
if (!entry) continue
if (entry.when) script += "if " + entry.when + " >/dev/null 2>&1; then echo " + ids[i] + ":w:1; else echo " + ids[i] + ":w:0; fi\n"
if (entry.checked) script += "if " + entry.checked + " >/dev/null 2>&1; then echo " + ids[i] + ":c:1; else echo " + ids[i] + ":c:0; fi\n"
}
if (!script) {
root.whenResults = ({})
root.checkedResults = ({})
return
}
guardProc.collected = ""
guardProc.command = ["bash", "-lc", script]
guardProc.running = true
}
Process {
id: guardProc
property string collected: ""
stdout: SplitParser {
onRead: function(data) { guardProc.collected += data + "\n" }
}
onExited: {
var nextWhen = ({})
var nextChecked = ({})
var lines = guardProc.collected.split("\n")
for (var i = 0; i < lines.length; i++) {
var line = lines[i].trim()
if (!line) continue
var colon = line.lastIndexOf(":")
if (colon < 0) continue
var value = line.substring(colon + 1) === "1"
var rest = line.substring(0, colon)
var tagAt = rest.lastIndexOf(":")
if (tagAt < 0) continue
var id = rest.substring(0, tagAt)
var tag = rest.substring(tagAt + 1)
if (tag === "w") nextWhen[id] = value
else if (tag === "c") nextChecked[id] = value
}
root.whenResults = nextWhen
root.checkedResults = nextChecked
if (root.opened) root.rebuildDisplay()
}
}
PanelWindow {
id: panel
visible: root.opened && root.rowsLoaded
anchors { top: true; bottom: true; left: true; right: true }
color: "transparent"
WlrLayershell.namespace: "omarchy-menu"
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
exclusionMode: ExclusionMode.Ignore
Rectangle {
anchors.fill: parent
color: root.scrim
}
MouseArea {
anchors.fill: parent
onClicked: root.cancel()
}
Rectangle {
id: card
width: root.cardWidth
height: root.cardHeight
radius: root.cornerRadius
anchors.centerIn: parent
color: root.background
border.color: root.border
border.width: Math.max(1, Style.space(2))
MouseArea { anchors.fill: parent; onClicked: {} }
Item {
id: keyCatcher
anchors.fill: parent
focus: true
Keys.priority: Keys.BeforeItem
Keys.onPressed: function(event) {
if (event.key === Qt.Key_Escape) {
if (root.filterText) root.setFilter("")
else root.cancel()
event.accepted = true
} else if (event.key === Qt.Key_Backspace) {
if (root.filterText.length > 0) root.setFilter(root.filterText.slice(0, -1))
else root.goBack()
event.accepted = true
} else if (event.key === Qt.Key_Up) {
root.select(-1)
event.accepted = true
} else if (event.key === Qt.Key_Down) {
root.select(1)
event.accepted = true
} else if (event.key === Qt.Key_PageUp) {
root.select(-6)
event.accepted = true
} else if (event.key === Qt.Key_PageDown) {
root.select(6)
event.accepted = true
} else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter || event.key === Qt.Key_Right) {
if (root.dmenuActive) {
if (root.mode === "input") root.applyDmenuSelection(root.filterText)
else if (displayModel.count > 0) root.activateIndex(root.cursorActive ? root.selectedIndex : 0)
} else if (root.cursorActive) root.activateIndex(root.selectedIndex)
else if (displayModel.count > 0) root.cursorActive = true
event.accepted = true
} else if (event.text && event.text.length === 1 && event.text.charCodeAt(0) >= 32 && event.text.charCodeAt(0) !== 127 && (event.modifiers === Qt.NoModifier || event.modifiers === Qt.ShiftModifier)) {
root.setFilter(root.filterText + event.text)
event.accepted = true
}
}
}
Column {
anchors.fill: parent
anchors.margins: root.contentMargin
spacing: root.contentSpacing
Rectangle {
width: parent.width
height: root.headerHeight
radius: root.cornerRadius
color: "transparent"
border.width: 0
Text {
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: root.filterText || (root.dmenuActive ? (root.dmenuPrompt + "…") : ((root.item(root.activeMenu) ? root.item(root.activeMenu).label : "Go") + "…"))
color: root.foreground
opacity: root.filterText ? 1 : 0.58
font.family: root.fontFamily
font.pixelSize: Style.font.heading
elide: Text.ElideRight
}
}
Item {
width: parent.width
height: root.visibleRowsHeight
ListView {
id: resultList
anchors.fill: parent
model: displayModel
clip: true
spacing: root.rowSpacing
boundsBehavior: Flickable.StopAtBounds
section.property: "section"
section.criteria: ViewSection.FullString
section.delegate: Item {
required property string section
width: ListView.view.width
height: section === "drilldown" ? root.dividerHeight : 0
visible: section === "drilldown"
Rectangle {
anchors.left: parent.left
anchors.leftMargin: Style.space(4)
anchors.right: parent.right
anchors.rightMargin: Style.space(4)
anchors.verticalCenter: parent.verticalCenter
height: Style.spacing.hairline
color: Util.alpha(root.foreground, 0.2)
}
}
delegate: Rectangle {
id: row
required property int index
required property string itemId
required property string kind
required property string icon
required property string label
required property string target
required property string detail
required property string path
required property string action
required property int childCount
readonly property bool hasCursor: root.cursorActive && row.index === root.selectedIndex
readonly property bool hasIcon: row.icon.length > 0
width: ListView.view.width
height: root.rowHeightForDetail(row.detail)
radius: root.cornerRadius
color: row.hasCursor ? root.selectedBackground : "transparent"
border.width: 0
Rectangle {
visible: false
width: Style.space(4)
height: parent.height - Style.space(18)
radius: Math.min(root.cornerRadius, Style.space(4))
color: root.selectedBackground
anchors.left: parent.left
anchors.leftMargin: Style.space(8)
anchors.verticalCenter: parent.verticalCenter
}
Text {
id: iconText
visible: row.hasIcon
text: row.icon
color: row.hasCursor ? root.selectedText : root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.iconLarge
width: Style.space(36)
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
anchors.left: parent.left
anchors.leftMargin: Style.space(8)
y: contentColumn.y + labelText.y + (labelText.height - height) / 2
}
Column {
id: contentColumn
anchors.left: row.hasIcon ? iconText.right : parent.left
anchors.leftMargin: row.hasIcon ? Style.space(6) : Style.space(18)
anchors.right: trail.left
anchors.rightMargin: Style.space(6)
anchors.verticalCenter: parent.verticalCenter
spacing: Style.space(3)
Text {
id: labelText
width: parent.width
text: row.label
color: row.hasCursor ? root.selectedText : root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.heading
font.weight: Font.Medium
elide: Text.ElideRight
}
Text {
width: parent.width
text: row.detail
visible: root.filterText && row.detail.length > 0
color: root.foreground
opacity: 0.52
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
elide: Text.ElideRight
}
}
Row {
id: trail
width: Style.space(14)
anchors.right: parent.right
anchors.rightMargin: Style.space(8)
y: contentColumn.y + labelText.y + (labelText.height - height) / 2
spacing: 0
Text {
visible: false
text: row.childCount
color: root.foreground
opacity: 0.45
font.family: root.fontFamily
font.pixelSize: Style.font.body
anchors.verticalCenter: parent.verticalCenter
}
Text {
text: row.kind === "menu" || row.kind === "link" ? "" : ""
color: row.hasCursor ? root.selectedText : root.foreground
opacity: row.kind === "menu" || row.kind === "link" ? 0.36 : 0
font.family: root.fontFamily
font.pixelSize: Style.font.heading
font.weight: Font.Normal
anchors.verticalCenter: parent.verticalCenter
}
}
MouseArea {
id: mouseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onPositionChanged: {
root.cursorActive = true
root.selectedIndex = row.index
}
onClicked: root.activateIndex(row.index)
}
}
}
Column {
anchors.centerIn: parent
spacing: Style.space(8)
visible: displayModel.count === 0 && root.mode !== "input"
Text {
text: "󰈉"
color: root.selectedText
opacity: 0.8
font.family: root.fontFamily
font.pixelSize: Style.font.displayLarge
horizontalAlignment: Text.AlignHCenter
width: Style.space(320)
}
Text {
text: root.filterText ? "No matches for “" + root.filterText + "”" : "Nothing here yet"
color: root.foreground
opacity: 0.7
font.family: root.fontFamily
font.pixelSize: Style.font.title
horizontalAlignment: Text.AlignHCenter
width: Style.space(320)
}
}
}
Item {
width: parent.width
height: 0
}
}
}
}
}