Drop walker + elephant

This commit is contained in:
David Heinemeier Hansson
2026-05-19 20:54:20 +02:00
parent 7fe472bf8a
commit abf0a68b54
64 changed files with 1024 additions and 1033 deletions
+10 -1
View File
@@ -13,6 +13,7 @@ User-installed plugins live alongside these conceptually but on disk under
|---------------|---------------------------|-----------|---------------------------------------|
| Bar | `omarchy.bar` | `bar` | `bar/Bar.qml` |
| Bar settings | `omarchy.settings` | `panel` | `settings/SettingsPanel.qml` |
| App launcher | `omarchy.app-launcher` | `overlay` | `app-launcher/AppLauncher.qml` |
| Image picker | `omarchy.image-picker` | `overlay` | `image-picker/ImagePicker.qml` |
| Emoji picker | `omarchy.emoji-picker` | `overlay` | `emoji-picker/EmojiPicker.qml` |
| Clipboard mgr | `omarchy.clipboard-picker`| `overlay` | `clipboard-picker/ClipboardPicker.qml`|
@@ -43,6 +44,14 @@ Visual editor for the bar layout. Summoned by
- dynamic per-widget settings forms that write inline back to the
corresponding shell.json entry
## App launcher
Quickshell-powered app launcher. It uses Quickshell's native
`DesktopEntries` model for discovery/activation and renders inside the
long-running shell with the legacy launcher card dimensions, colors, row
spacing, icon sizing, and keyboard behavior. Summoned directly over shell IPC
by the `SUPER + SPACE` binding and the Omarchy menu Apps row.
## Image picker
Fullscreen image-grid selector overlay. Used by `omarchy-menu-images`
@@ -87,7 +96,7 @@ runs inside the long-lived `omarchy-shell` process, replacing the old
## Omarchy menu
Quickshell-powered replacement for the legacy Walker-driven `omarchy-menu`.
Quickshell-powered Omarchy command menu.
The menu UI lives in `menu/Menu.qml` as a first-party `menu` plugin and is
summoned through the shell (`omarchy-shell shell summon omarchy.menu ...`),
so it shares the long-running `omarchy-shell` process instead of starting a
+434
View File
@@ -0,0 +1,434 @@
import Quickshell
import Quickshell.Wayland
import Quickshell.Widgets
import QtQuick
import qs.Commons
Item {
id: root
property string omarchyPath: Quickshell.env("OMARCHY_PATH")
property var shell: null
property var manifest: null
property bool opened: false
property string placeholder: "\uf002 Search..."
property string filterText: ""
property int selectedIndex: 0
property bool cursorActive: true
property bool hoverArmed: false
property var filteredEntries: []
property color accent: Color.menu.selected
property color background: Color.menu.background
property color foreground: Color.menu.text
property color border: foreground
property string fontFamily: Quickshell.env("OMARCHY_MENU_FONT") || "monospace"
property int cardWidth: 644
property int cardHeight: 400
property int contentMargin: 20
property int contentSpacing: 10
property int searchHeight: 44
property int rowHeight: 50
property int iconSlotWidth: 44
property int iconSize: 24
readonly property int listHeight: cardHeight - contentMargin * 2 - searchHeight - contentSpacing
function open(payloadJson) {
var payload = ({})
try { payload = JSON.parse(payloadJson || "{}") } catch (e) { payload = ({}) }
root.placeholder = payload.placeholder || "\uf002 Search..."
root.cardWidth = Math.max(300, Number(payload.width || 644))
var requestedListHeight = Number(payload.listHeight || payload.maxHeight || 0)
root.cardHeight = requestedListHeight > 0
? root.contentMargin * 2 + root.searchHeight + root.contentSpacing + requestedListHeight
: 400
root.opened = true
root.filterText = payload.query || ""
root.selectedIndex = 0
root.cursorActive = true
root.hoverArmed = false
root.rebuildDisplay()
Qt.callLater(function() { keyCatcher.forceActiveFocus() })
}
function close() {
root.opened = false
}
function dismiss() {
root.opened = false
if (root.shell && typeof root.shell.hide === "function")
root.shell.hide((root.manifest && root.manifest.id) || "omarchy.app-launcher")
}
function withAlpha(color, alpha) {
return Qt.rgba(color.r, color.g, color.b, alpha)
}
function fileUrl(path) {
return "file://" + String(path).split("/").map(encodeURIComponent).join("/")
}
function iconSource(icon) {
var value = String(icon || "")
if (value.length === 0) return Quickshell.iconPath("application-x-executable", true)
if (value.indexOf("file://") === 0 || value.indexOf("image://") === 0) return value
if (value.charAt(0) === "/") return root.fileUrl(value)
return Quickshell.iconPath(value, true)
}
function entryName(entry) {
return String((entry && entry.name) || (entry && entry.id) || "")
}
function entrySubtext(entry) {
return String((entry && entry.genericName) || "")
}
function entrySortKey(entry) {
return String((entry && entry.id) || root.entryName(entry)).toLowerCase()
}
function entrySearchText(entry) {
if (!entry) return ""
var keywords = ""
try { keywords = (entry.keywords || []).join(" ") } catch (e) { keywords = "" }
return [entry.name, entry.genericName, entry.comment, keywords, entry.id].join(" ").toLowerCase()
}
function isHiddenEntry(entry) {
var id = String((entry && entry.id) || "")
return id === "avahi-discover" || id === "bssh" || id === "bvnc"
}
function fuzzyScore(entry, query) {
var q = String(query || "").trim().toLowerCase()
if (!q) return 0
var name = root.entryName(entry).toLowerCase()
var id = String((entry && entry.id) || "").toLowerCase()
var haystack = root.entrySearchText(entry)
var directName = name.indexOf(q)
var directId = id.indexOf(q)
if (directName === 0) return 10000 - name.length
if (directId === 0) return 9500 - id.length
if (directName > 0) return 8000 - directName * 10 - name.length
if (directId > 0) return 7600 - directId * 10 - id.length
var hayIndex = haystack.indexOf(q)
if (hayIndex >= 0) return 6000 - hayIndex
var pos = 0
var first = -1
var last = -1
for (var i = 0; i < haystack.length && pos < q.length; i++) {
if (haystack.charAt(i) !== q.charAt(pos)) continue
if (first < 0) first = i
last = i
pos++
}
if (pos !== q.length) return -1
return 3000 - (last - first) - haystack.length * 0.01
}
function sortedEntries(query) {
var values = DesktopEntries.applications.values || []
var q = String(query || "").trim()
var rows = []
for (var i = 0; i < values.length; i++) {
var entry = values[i]
if (!entry || entry.noDisplay || root.isHiddenEntry(entry)) continue
var name = root.entryName(entry)
if (!name) continue
var score = root.fuzzyScore(entry, q)
if (score < 0) continue
rows.push({ entry: entry, score: score, key: root.entrySortKey(entry), name: name.toLowerCase() })
}
rows.sort(function(a, b) {
if (q && a.score !== b.score) return b.score - a.score
if (a.key < b.key) return -1
if (a.key > b.key) return 1
if (a.name < b.name) return -1
if (a.name > b.name) return 1
return 0
})
return rows
}
function rebuildDisplay() {
displayModel.clear()
var rows = root.sortedEntries(root.filterText)
var entries = []
var count = Math.min(rows.length, 256)
for (var i = 0; i < count; i++) {
var entry = rows[i].entry
entries.push(entry)
displayModel.append({
name: root.entryName(entry),
subtext: root.entrySubtext(entry),
icon: String(entry.icon || "")
})
}
root.filteredEntries = entries
if (displayModel.count === 0) root.selectedIndex = 0
else if (root.selectedIndex >= displayModel.count) root.selectedIndex = displayModel.count - 1
else if (root.selectedIndex < 0) root.selectedIndex = 0
Qt.callLater(function() {
if (displayModel.count > 0) resultList.positionViewAtIndex(root.selectedIndex, ListView.Contain)
})
}
function setFilter(nextFilter) {
root.filterText = nextFilter
root.selectedIndex = 0
root.cursorActive = true
root.rebuildDisplay()
}
function select(delta) {
if (displayModel.count === 0) return
root.cursorActive = true
root.selectedIndex = (root.selectedIndex + delta + displayModel.count) % displayModel.count
resultList.positionViewAtIndex(root.selectedIndex, ListView.Contain)
}
function activateIndex(index) {
if (index < 0 || index >= root.filteredEntries.length) return
var entry = root.filteredEntries[index]
if (!entry) return
root.dismiss()
entry.execute()
}
ListModel { id: displayModel }
Connections {
target: DesktopEntries.applications
function onValuesChanged() {
if (root.opened) root.rebuildDisplay()
}
}
PanelWindow {
id: panel
visible: root.opened
anchors { top: true; bottom: true; left: true; right: true }
color: "transparent"
WlrLayershell.namespace: "omarchy-app-launcher"
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
exclusionMode: ExclusionMode.Ignore
Rectangle {
anchors.fill: parent
color: root.withAlpha(root.background, 0.5)
}
MouseArea {
anchors.fill: parent
onClicked: root.dismiss()
}
Rectangle {
id: card
width: Math.min(root.cardWidth, panel.width - Style.gapsOut * 2)
height: Math.min(root.cardHeight, panel.height - Style.gapsOut * 2)
radius: Style.cornerRadius
anchors.centerIn: parent
color: root.withAlpha(root.background, 0.95)
border.color: root.border
border.width: 2
clip: true
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.length > 0) root.setFilter("")
else root.dismiss()
event.accepted = true
} else if (event.key === Qt.Key_Backspace) {
if (root.filterText.length > 0) root.setFilter(root.filterText.slice(0, -1))
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_Home) {
if (displayModel.count > 0) {
root.cursorActive = true
root.selectedIndex = 0
resultList.positionViewAtIndex(root.selectedIndex, ListView.Contain)
}
event.accepted = true
} else if (event.key === Qt.Key_End) {
if (displayModel.count > 0) {
root.cursorActive = true
root.selectedIndex = displayModel.count - 1
resultList.positionViewAtIndex(root.selectedIndex, ListView.Contain)
}
event.accepted = true
} else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
root.activateIndex(root.selectedIndex)
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.searchHeight
radius: 0
color: root.background
Text {
anchors.left: parent.left
anchors.leftMargin: 10
anchors.right: parent.right
anchors.rightMargin: 10
anchors.verticalCenter: parent.verticalCenter
text: root.filterText || root.placeholder
color: root.foreground
opacity: root.filterText ? 1 : 0.5
font.family: root.fontFamily
font.pixelSize: 18
elide: Text.ElideRight
}
}
Item {
width: parent.width
height: parent.height - root.searchHeight - root.contentSpacing
ListView {
id: resultList
anchors.fill: parent
model: displayModel
clip: true
spacing: 0
boundsBehavior: Flickable.StopAtBounds
delegate: Rectangle {
id: row
required property int index
required property string name
required property string subtext
required property string icon
readonly property bool hasCursor: root.cursorActive && row.index === root.selectedIndex
width: ListView.view.width
height: root.rowHeight
radius: 0
color: row.hasCursor ? root.withAlpha(root.foreground, 0.07) : "transparent"
Item {
id: iconSlot
anchors.left: parent.left
anchors.leftMargin: 14
anchors.verticalCenter: parent.verticalCenter
width: root.iconSlotWidth
height: parent.height
IconImage {
id: appIcon
anchors.centerIn: parent
implicitSize: root.iconSize
width: root.iconSize
height: root.iconSize
source: root.iconSource(row.icon)
asynchronous: true
mipmap: true
}
Text {
anchors.centerIn: parent
visible: appIcon.status === Image.Error
text: "?"
color: row.hasCursor ? root.accent : root.foreground
font.family: root.fontFamily
font.pixelSize: 18
}
}
Text {
anchors.left: iconSlot.right
anchors.leftMargin: 14
anchors.right: parent.right
anchors.rightMargin: 14
anchors.verticalCenter: parent.verticalCenter
text: row.name
color: row.hasCursor ? root.accent : root.foreground
font.family: root.fontFamily
font.pixelSize: 18
elide: Text.ElideRight
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onPositionChanged: function(mouse) {
root.hoverArmed = true
root.cursorActive = true
root.selectedIndex = row.index
}
onContainsMouseChanged: if (containsMouse && root.hoverArmed) {
root.cursorActive = true
root.selectedIndex = row.index
}
onClicked: {
root.cursorActive = true
root.selectedIndex = row.index
root.activateIndex(row.index)
}
}
}
}
Text {
anchors.top: parent.top
anchors.left: parent.left
anchors.leftMargin: 14
visible: displayModel.count === 0
text: "No Results"
color: root.foreground
font.family: root.fontFamily
font.pixelSize: 18
}
}
}
}
}
}
+15
View File
@@ -0,0 +1,15 @@
{
"schemaVersion": 1,
"id": "omarchy.app-launcher",
"name": "App launcher",
"version": "1.0.0",
"author": "Omarchy",
"description": "A Quickshell-powered application launcher",
"kinds": [
"overlay"
],
"keepLoaded": true,
"entryPoints": {
"overlay": "AppLauncher.qml"
}
}
@@ -7,12 +7,15 @@ import qs.Commons
Item {
id: root
property string omarchyPath: Quickshell.env("OMARCHY_PATH")
property bool opened: false
property string filterText: ""
property int selectedIndex: 0
property bool cursorActive: false
property var items: []
property var history: []
property string historyPath: Quickshell.env("HOME") + "/.local/state/omarchy/clipboard-history.json"
property string captureScript: root.omarchyPath + "/shell/scripts/clipboard-capture.sh"
property color accent: Color.menu.selected
property color background: Color.menu.background
property color foreground: Color.menu.text
@@ -31,12 +34,7 @@ Item {
root.filterText = ""
root.selectedIndex = 0
root.cursorActive = false
// Trigger fetch
fetchProc.collected = ""
fetchProc.command = ["bash", "-lc", "elephant query --json 'clipboard;;100'"]
fetchProc.running = true
root.rebuildDisplay()
Qt.callLater(function() { keyCatcher.forceActiveFocus() })
}
@@ -53,35 +51,111 @@ Item {
return Qt.rgba(color.r, color.g, color.b, alpha)
}
function shellQuote(value) {
return "'" + String(value || "").replace(/'/g, "'\\''") + "'"
}
function fileUrl(path) {
return "file://" + String(path).split("/").map(encodeURIComponent).join("/")
}
function normalizeEntry(value) {
if (typeof value === "string") {
return value.length > 0 ? { type: "text", text: value } : null
}
if (!value || typeof value !== "object") return null
var type = String(value.type || value.kind || "")
if (type === "text") {
var text = String(value.text || "")
return text.length > 0 ? { type: "text", text: text } : null
}
if (type === "image") {
var path = String(value.path || "")
if (!path) return null
return {
type: "image",
path: path,
mime: String(value.mime || "image/png")
}
}
return null
}
function entryKey(entry) {
if (!entry) return ""
if (entry.type === "image") return "image:" + String(entry.path || "")
return "text:" + String(entry.text || "")
}
function loadHistory(raw) {
try {
var parsed = JSON.parse(String(raw || "[]"))
var next = []
if (Array.isArray(parsed)) {
for (var i = 0; i < parsed.length; i++) {
var entry = root.normalizeEntry(parsed[i])
if (entry) next.push(entry)
}
}
root.history = next
} catch (e) {
root.history = []
}
if (root.opened) root.rebuildDisplay()
}
function saveHistory() {
historyFile.setText(JSON.stringify(root.history.slice(0, 100), null, 2) + "\n")
}
function addClipboardEntry(entry) {
var normalized = root.normalizeEntry(entry)
if (!normalized) return
var key = root.entryKey(normalized)
var next = [normalized]
for (var i = 0; i < root.history.length && next.length < 100; i++) {
var existing = root.normalizeEntry(root.history[i])
if (!existing || root.entryKey(existing) === key) continue
next.push(existing)
}
root.history = next
root.saveHistory()
if (root.opened) root.rebuildDisplay()
}
function addClipboardJson(line) {
var raw = String(line || "").trim()
if (!raw) return
try { root.addClipboardEntry(JSON.parse(raw)) } catch (e) {}
}
function rebuildDisplay() {
var query = root.filterText.trim().toLowerCase()
displayModel.clear()
var outCount = 0
for (var i = 0; i < root.items.length; i++) {
var entry = root.items[i]
var isPassword = (entry.meta === "password" || entry.preview_type === "password")
var textMatch = false
if (isPassword) {
textMatch = false // Passwords shouldn't match plain text search queries
} else {
textMatch = (entry.preview && entry.preview.toLowerCase().indexOf(query) >= 0)
}
if (!query || textMatch) {
displayModel.append({
identifier: entry.identifier,
previewText: entry.preview_type === "text" ? entry.preview.replace(/\n/g, " ") : "",
previewImage: entry.preview_type === "file" ? ("file://" + entry.preview) : "",
previewType: entry.preview_type || "text",
isPassword: isPassword,
index: outCount
})
outCount++
if (outCount >= 50) break
}
for (var i = 0; i < root.history.length; i++) {
var entry = root.normalizeEntry(root.history[i])
if (!entry) continue
var isImage = entry.type === "image"
var searchable = isImage ? ("image " + String(entry.mime || "")) : String(entry.text || "")
if (query && searchable.toLowerCase().indexOf(query) < 0) continue
displayModel.append({
entryType: entry.type,
fullText: isImage ? "" : String(entry.text || ""),
previewText: isImage ? "Image" : String(entry.text || "").replace(/\s+/g, " "),
previewImage: isImage ? root.fileUrl(entry.path) : "",
path: isImage ? String(entry.path || "") : "",
mime: isImage ? String(entry.mime || "image/png") : "text/plain",
index: outCount
})
outCount++
if (outCount >= 50) break
}
if (displayModel.count === 0) selectedIndex = 0
@@ -114,40 +188,60 @@ Item {
function activateIndex(index) {
if (index < 0 || index >= displayModel.count) return
var row = displayModel.get(index)
root.applySelected(row.identifier)
root.applySelected(row)
}
function applySelected(identifier) {
if (!identifier) return
function applySelected(row) {
if (!row) return
root.opened = false
var escId = identifier.replace(/'/g, "'\\''")
Quickshell.execDetached(["bash", "-lc", "elephant activate 'clipboard;" + escId + ";copy;;'; sleep 0.15; wtype -M shift -k Insert -m shift 2>/dev/null || true"])
if (row.entryType === "image") {
Quickshell.execDetached(["bash", "-lc", "wl-copy --type " + root.shellQuote(row.mime) + " < " + root.shellQuote(row.path) + "; sleep 0.15; wtype -M shift -k Insert -m shift 2>/dev/null || true"])
} else if (row.fullText) {
Quickshell.execDetached(["bash", "-lc", "printf %s " + root.shellQuote(row.fullText) + " | wl-copy; sleep 0.15; wtype -M shift -k Insert -m shift 2>/dev/null || true"])
}
}
Component.onCompleted: initProc.running = true
ListModel { id: displayModel }
FileView {
id: historyFile
path: root.historyPath
watchChanges: true
atomicWrites: true
printErrors: false
onLoaded: root.loadHistory(text())
onLoadFailed: root.loadHistory("[]")
onFileChanged: reload()
}
Process {
id: fetchProc
property string collected: ""
stdout: SplitParser {
onRead: function(data) { fetchProc.collected += data + "\n" }
}
id: initProc
command: ["bash", "-lc", "mkdir -p ~/.local/state/omarchy"]
onExited: {
var lines = fetchProc.collected.split("\n")
var newItems = []
for (var i = 0; i < lines.length; i++) {
var line = lines[i].trim()
if (!line) continue
try {
var parsed = JSON.parse(line)
if (parsed && parsed.item) {
newItems.push(parsed.item)
}
} catch(e) {}
}
root.items = newItems
root.rebuildDisplay()
currentProc.command = [root.captureScript]
currentProc.running = true
watchProc.command = ["wl-paste", "--watch", root.captureScript]
watchProc.running = true
}
}
Process {
id: currentProc
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: root.addClipboardJson(text)
}
}
Process {
id: watchProc
stdout: SplitParser {
onRead: function(data) { root.addClipboardJson(data) }
}
}
PanelWindow {
id: panel
visible: root.opened
@@ -261,10 +355,10 @@ Item {
delegate: Rectangle {
required property int index
required property string identifier
required property string entryType
required property string previewText
required property string previewType
required property bool isPassword
required property string fullText
required property string previewImage
readonly property bool hasCursor: root.cursorActive && index === root.selectedIndex
@@ -275,33 +369,33 @@ Item {
border.color: hasCursor ? Style.hoverBorderFor(root.foreground, root.accent) : "transparent"
border.width: hasCursor ? Style.hoverBorderWidth : 0
Rectangle {
visible: false
width: Style.space(4)
height: parent.height - Style.space(18)
radius: Math.min(root.cornerRadius, Style.space(4))
color: root.accent
anchors.left: parent.left
anchors.leftMargin: Style.space(8)
anchors.verticalCenter: parent.verticalCenter
}
Item {
Row {
anchors.fill: parent
anchors.leftMargin: Style.space(12)
anchors.rightMargin: Style.space(12)
anchors.topMargin: Style.space(8)
anchors.bottomMargin: Style.space(8)
spacing: Style.space(10)
Image {
visible: parent.parent.entryType === "image"
width: visible ? parent.height : 0
height: parent.height
source: parent.parent.previewImage
fillMode: Image.PreserveAspectFit
asynchronous: true
smooth: true
}
Text {
width: parent.width
width: parent.width - (parent.parent.entryType === "image" ? parent.height + parent.spacing : 0)
height: parent.height
text: parent.parent.isPassword ? "••••••••" : (parent.parent.previewType === "text" ? parent.parent.previewText : "Image")
text: parent.parent.previewText
color: parent.parent.hasCursor ? Style.hoverStateColor(root.foreground, root.accent) : root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.title
font.italic: parent.parent.previewType === "file" || parent.parent.isPassword
opacity: (parent.parent.previewType === "file" || parent.parent.isPassword) ? 0.6 : 1.0
font.italic: parent.parent.entryType === "image"
opacity: parent.parent.entryType === "image" ? 0.72 : 1.0
elide: Text.ElideRight
wrapMode: Text.NoWrap
verticalAlignment: Text.AlignVCenter
@@ -309,7 +403,6 @@ Item {
}
MouseArea {
id: mouseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
@@ -335,13 +428,13 @@ Item {
border.width: Style.normalBorderWidth
clip: true
property var activeRow: root.cursorActive && displayModel.count > 0 && root.selectedIndex >= 0 && root.selectedIndex < displayModel.count ? displayModel.get(root.selectedIndex) : null
property var activeRow: displayModel.count > 0 && root.selectedIndex >= 0 && root.selectedIndex < displayModel.count ? displayModel.get(root.selectedIndex) : null
Text {
visible: parent.activeRow && parent.activeRow.previewType === "text"
visible: parent.activeRow && parent.activeRow.entryType === "text"
anchors.fill: parent
anchors.margins: Style.space(16)
text: parent.activeRow ? (parent.activeRow.isPassword ? "••••••••" : parent.activeRow.previewText) : ""
text: parent.activeRow ? parent.activeRow.fullText : ""
color: root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.title
@@ -351,11 +444,13 @@ Item {
}
Image {
visible: parent.activeRow && parent.activeRow.previewType === "file"
visible: parent.activeRow && parent.activeRow.entryType === "image"
anchors.fill: parent
anchors.margins: Style.space(16)
source: parent.activeRow ? parent.activeRow.previewImage : ""
fillMode: Image.PreserveAspectFit
asynchronous: true
smooth: true
}
}
}
@@ -376,7 +471,7 @@ Item {
}
Text {
text: root.items.length === 0 ? "Clipboard is empty" : "No matches for “" + root.filterText + "”"
text: root.history.length === 0 ? "Clipboard is empty" : "No matches for “" + root.filterText + "”"
color: root.foreground
opacity: 0.7
font.family: root.fontFamily
+161 -9
View File
@@ -18,10 +18,14 @@ Item {
var payload = ({})
try { payload = JSON.parse(payloadJson || "{}") } catch (e) { payload = ({}) }
root.pendingInitialMenu = payload.initialMenu || payload.menu || "root"
if (payload.fontFamily) root.fontFamily = payload.fontFamily
root.openExistingMenu(root.pendingInitialMenu)
if (payload.mode === "select" || payload.mode === "input") {
root.openDmenu(payload)
} else {
root.pendingInitialMenu = payload.initialMenu || payload.menu || "root"
root.openExistingMenu(root.pendingInitialMenu)
}
}
function close() {
@@ -37,6 +41,15 @@ Item {
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: ""
@@ -64,14 +77,40 @@ Item {
property int dividerHeight: Style.space(17)
property bool searchDivider: false
property int layoutSerial: 0
property int cardWidth: Math.min((root.activeMenu === "trigger.capture.screenrecord" || root.activeMenu === "style.font") ? Style.space(520) : Style.space(300), panel.width - Style.gapsOut * 2)
property int visibleRowsHeight: rowListHeight(layoutSerial, displayModel.count, filterText, searchDivider)
property int cardHeight: Math.min(Math.max(Style.space(220), contentMargin * 2 + headerHeight + contentSpacing + visibleRowsHeight), panel.height - Style.gapsOut * 2)
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 withAlpha(color, alpha) {
return Qt.rgba(color.r, color.g, color.b, alpha)
}
function shellQuote(value) {
return "'" + String(value || "").replace(/'/g, "'\\''") + "'"
}
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", ": > " + root.shellQuote(activeDoneFile)]
} else {
resultProc.command = ["bash", "-lc", "printf '%s\\n' " + root.shellQuote(selection) + " > " + root.shellQuote(activeSelectionFile) + "; : > " + root.shellQuote(activeDoneFile)]
}
resultProc.running = true
}
function rowHeightForDetail(detail) {
return root.filterText && detail ? root.detailRowHeight : root.baseRowHeight
}
@@ -94,6 +133,20 @@ Item {
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
}
@@ -458,7 +511,52 @@ Item {
}
}
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
@@ -540,7 +638,7 @@ Item {
root.filterText = nextFilter
root.selectedIndex = 0
root.cursorActive = false
if (root.filterText.trim()) root.loadProvidersForSearch()
if (!root.dmenuActive && root.filterText.trim()) root.loadProvidersForSearch()
root.rebuildDisplay()
}
@@ -571,6 +669,16 @@ Item {
}
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)
@@ -583,6 +691,13 @@ Item {
}
}
function applyDmenuSelection(value) {
applySerial = requestSerial
opened = false
filterText = ""
root.finishRequest(value)
}
function applySelected(id, action) {
if (!id) { cancel(); return }
applySerial = requestSerial
@@ -592,12 +707,17 @@ Item {
}
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 = ""
@@ -610,6 +730,27 @@ Item {
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 = false
opened = true
rebuildDisplay()
Qt.callLater(function() { keyCatcher.forceActiveFocus() })
}
ListModel { id: displayModel }
// ----------------------------------------------------------- IPC surface
@@ -697,6 +838,14 @@ Item {
}
}
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.
@@ -840,7 +989,10 @@ Item {
if (!root.filterText) root.goBack()
event.accepted = true
} else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter || event.key === Qt.Key_Right) {
if (root.cursorActive) root.activateIndex(root.selectedIndex)
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)) {
@@ -866,7 +1018,7 @@ Item {
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: root.filterText || ((root.item(root.activeMenu) ? root.item(root.activeMenu).label : "Go") + "…")
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
@@ -1034,7 +1186,7 @@ Item {
Column {
anchors.centerIn: parent
spacing: Style.space(8)
visible: displayModel.count === 0
visible: displayModel.count === 0 && root.mode !== "input"
Text {
text: "󰈉"