From bfb9eeaea46fca299908ec33d113f0bbeaa9c333 Mon Sep 17 00:00:00 2001 From: SirBlobby Date: Wed, 19 Aug 2026 14:42:48 -0400 Subject: [PATCH] Widen the Omarchy menu via a cloned plugin Claude-Session: https://claude.ai/code/session_014ADhvRXuiTvrXSbConxUey --- hypr/monitors.lua | 4 + install.sh | 1 + omarchy/README.md | 19 +- omarchy/plugins/blob.menu/BarWidget.qml | 24 + omarchy/plugins/blob.menu/Menu.qml | 1420 +++++++++++++++++++++++ omarchy/plugins/blob.menu/MenuModel.js | 510 ++++++++ omarchy/plugins/blob.menu/manifest.json | 26 + omarchy/shell.json | 10 +- 8 files changed, 2007 insertions(+), 7 deletions(-) create mode 100644 omarchy/plugins/blob.menu/BarWidget.qml create mode 100644 omarchy/plugins/blob.menu/Menu.qml create mode 100644 omarchy/plugins/blob.menu/MenuModel.js create mode 100644 omarchy/plugins/blob.menu/manifest.json diff --git a/hypr/monitors.lua b/hypr/monitors.lua index 083ba86..bdc95d9 100644 --- a/hypr/monitors.lua +++ b/hypr/monitors.lua @@ -8,6 +8,10 @@ -- Monitor 1 - C24 curved, DisplayPort via DP->HDMI adapter. -- NOTE: the DP2HDMI adapter caps this panel at 1920x1080@60 (no 75Hz mode). +-- Both externals stay at scale 1. Hyprland's fractional scaling works in +-- 1/120 steps and needs whole pixels on both axes, so 1.0 and 1.2 are +-- adjacent valid scales on a 1920x1080 panel - nothing sits between them. +-- Apparent size is tuned with `omarchy display text size` instead. hl.monitor({ output = "DP-1", mode = "1920x1080@60", position = "0x0", scale = 1 }) -- Monitor 2 - Sceptre F24, HDMI, native 75Hz. diff --git a/install.sh b/install.sh index 196e872..c29acef 100755 --- a/install.sh +++ b/install.sh @@ -204,6 +204,7 @@ check_file "$SCRIPT_DIR/hypr/xdph.conf" "$HOME_DIR/.config/hypr/xdph.conf" "hypr check_file "$SCRIPT_DIR/omarchy/shell.json" "$HOME_DIR/.config/omarchy/shell.json" "omarchy/shell.json" || check_status=1 check_file "$SCRIPT_DIR/omarchy/extensions/omarchy-menu.jsonc" "$HOME_DIR/.config/omarchy/extensions/omarchy-menu.jsonc" "omarchy/extensions/omarchy-menu.jsonc" || check_status=1 check_file "$SCRIPT_DIR/omarchy/plugins/blob.workspaces/Workspaces.qml" "$HOME_DIR/.config/omarchy/plugins/blob.workspaces/Workspaces.qml" "omarchy/plugins/blob.workspaces" || check_status=1 +check_file "$SCRIPT_DIR/omarchy/plugins/blob.menu/Menu.qml" "$HOME_DIR/.config/omarchy/plugins/blob.menu/Menu.qml" "omarchy/plugins/blob.menu" || check_status=1 check_file "$SCRIPT_DIR/omarchy/hooks/theme-set" "$HOME_DIR/.config/omarchy/hooks/theme-set" "omarchy/hooks/theme-set" || check_status=1 check_file "$SCRIPT_DIR/omarchy/themed/zen.css.tpl" "$HOME_DIR/.config/omarchy/themed/zen.css.tpl" "omarchy/themed/zen.css.tpl" || check_status=1 check_file "$SCRIPT_DIR/ags/app.ts" "$HOME_DIR/.config/ags/app.ts" "ags/app.ts" || check_status=1 diff --git a/omarchy/README.md b/omarchy/README.md index ac7ebd9..cdf695a 100644 --- a/omarchy/README.md +++ b/omarchy/README.md @@ -53,11 +53,20 @@ A command module with no `exec` key is a static icon; add `exec` and ## Cloned plugins -`plugins/blob.workspaces/` is a clone of `omarchy.workspaces`, made with -`omarchy plugin clone`. The stock widget hardcodes workspaces 1-5 as always -visible and has no setting for it, so the clone changes that list to 1-9 to -match the old waybar `persistent-workspaces`. Cloning also switches the bar -to the cloned id, which is why `shell.json` says `blob.workspaces`. +Both were made with `omarchy plugin clone`, which copies a built-in plugin, +disables the original, and points the bar at the copy - hence the `blob.` ids +in `shell.json`. + +`plugins/blob.workspaces/` clones `omarchy.workspaces`. The stock widget +hardcodes workspaces 1-5 as always visible and has no setting for it, so the +clone changes that list to 1-9 to match the old waybar `persistent-workspaces`. + +`plugins/blob.menu/` clones `omarchy.menu` to widen it. `cardWidth` in +`Menu.qml` is hardcoded at `Style.space(300)`; the clone raises it to 440, so +the apps menu (Super + Space) and the root menu (Super + Alt + Space) are both +wider. The two oversized menus (screen recording, font picker) keep their own +520 and are untouched. `omarchy-menu` still targets `omarchy.menu` on the CLI - +the manifest records `clonedFrom`, and the shell routes those calls here. Re-clone after an Omarchy update if the upstream widget gains something worth picking up: `omarchy plugin clone omarchy.workspaces`, then re-apply the diff --git a/omarchy/plugins/blob.menu/BarWidget.qml b/omarchy/plugins/blob.menu/BarWidget.qml new file mode 100644 index 0000000..8fa7e1b --- /dev/null +++ b/omarchy/plugins/blob.menu/BarWidget.qml @@ -0,0 +1,24 @@ +import QtQuick +import qs.Ui + +BarWidget { + id: root + moduleName: "omarchy.menu" + + implicitWidth: button.implicitWidth + implicitHeight: button.implicitHeight + + WidgetButton { + id: button + anchors.fill: parent + bar: root.bar + text: "\ue900" + fontFamily: "omarchy" + horizontalMargin: 7.5 + onPressed: function(button) { + if (!root.bar) return + if (button === Qt.RightButton) root.bar.run("xdg-terminal-exec") + else root.bar.run("omarchy-shell shell toggle omarchy.menu '{\"menu\":\"root\"}'") + } + } +} diff --git a/omarchy/plugins/blob.menu/Menu.qml b/omarchy/plugins/blob.menu/Menu.qml new file mode 100644 index 0000000..c686f61 --- /dev/null +++ b/omarchy/plugins/blob.menu/Menu.qml @@ -0,0 +1,1420 @@ +import Quickshell +import Quickshell.Io +import Quickshell.Wayland +import QtQuick +import qs.Commons +import qs.Ui +import "MenuModel.js" as MenuModel + +Item { + id: root + + // Injected by omarchy-shell when this plugin is summoned. + property string omarchyPath: Quickshell.env("OMARCHY_PATH") + property var shell: null + property var manifest: null + + // 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 + + // Shared application engine (entries, hidden filters, icons, launch, + // removal), owned by the shell and also used by the standalone launcher. + readonly property var appLibrary: root.shell ? root.shell.appLibrary : null + property bool deleteConfirmOpen: false + property var deleteTarget: null + onOpenedChanged: if (!opened) { deleteConfirmOpen = false; deleteTarget = null } + // 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 var borderSpec: Border.surfaceSpec("menu", "border", border, Math.max(1, Style.space(2))) + property color scrim: Color.menu.scrim + property color selectedBackground: Color.menu.selectedBackground + property color selectedText: Color.menu.selectedText + property color selectedBorder: Color.menu.selectedBorder + property var selectedBorderSpec: Border.surfaceSpec("menu", "selected-border", selectedBorder, 0) + readonly property real rowReservedBorderLeft: Border.left(selectedBorderSpec) + readonly property real rowReservedBorderRight: Border.right(selectedBorderSpec) + 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) + // How much of the first hidden row stays visible at the fold — enough to + // read as a cut-off row rather than a bottom border. + property int rowPeek: Math.round(baseRowHeight * 0.55) + 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(440)), 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(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", "-c", ": > " + Util.shellQuote(activeDoneFile)] + } else { + resultProc.command = ["bash", "-c", "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 + + Util.execDetached(command) + } + + // Menu rows only surface their detail while a search is narrowing them; + // dmenu rows carry caller-supplied subtext that must always be visible. + function rowHeightForDetail(detail) { + return (root.filterText || root.dmenuActive) && detail ? root.detailRowHeight : root.baseRowHeight + } + + // Height the card can devote to rows before running off the screen — or + // past the frozen top edge once a search has pinned the card in place. + // Uses panel.cardTop rather than effectiveCardTop: the centered top is + // derived from the card height, which this value feeds. + function availableRowsHeight() { + var top = panel.cardTop >= 0 ? panel.cardTop : Style.gapsOut + var available = panel.height - top - Style.gapsOut - root.contentMargin * 2 - root.headerHeight - root.contentSpacing + // The starting menu sets the ceiling along with the offset: drilling into + // a longer submenu scrolls behind the fold instead of growing the card. + if (panel.maxRowsHeight >= 0) available = Math.min(available, panel.maxRowsHeight) + // A card that swallows the whole screen reads as a page, not a menu. + return Math.min(available, Math.round(panel.height * 0.7)) + } + + // When every row fits, the list gets its full height. When they don't, + // the card must end mid-row: a clipped row is what tells the eye there is + // more below the fold, so never come out even on a row boundary. + function foldedListHeight(totals, available) { + var count = totals.length + if (count === 0) return root.baseRowHeight + if (totals[count - 1] <= available) return totals[count - 1] + + var peek = root.rowPeek + var full = 0 + while (full < count && totals[full] <= available) full++ + while (full > 1 && totals[full - 1] + root.rowSpacing + peek > available) full-- + if (full < 1) return Math.max(available, root.baseRowHeight) + + return totals[full - 1] + root.rowSpacing + peek + } + + function rowListHeight(_serial, _count, _filter, _divider) { + if (displayModel.count === 0) return root.baseRowHeight + + var totals = [] + var total = 0 + var previousSection = "" + + for (var i = 0; i < displayModel.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 + totals.push(total) + } + + return foldedListHeight(totals, availableRowsHeight()) + } + + function dmenuRowListHeight(_serial, _count, _filter) { + if (root.mode === "input") return 0 + if (displayModel.count === 0) return root.baseRowHeight + + var available = availableRowsHeight() + if (root.dmenuMaxHeight > 0) available = Math.min(available, Style.space(root.dmenuMaxHeight)) + + var totals = [] + var total = 0 + for (var i = 0; i < displayModel.count; i++) { + if (i > 0) total += root.rowSpacing + total += root.rowHeightForDetail(displayModel.get(i).detail) + totals.push(total) + } + + return foldedListHeight(totals, available) + } + + 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 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`. A `volatile` provider + // re-runs every time its submenu is entered, so a font installed since the + // shell started shows up without restarting it. + 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: "", + volatile: true, + actionFor: function(value) { return "omarchy-font-set " + Util.shellQuote(value) } + }, + "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 "omarchy-powerprofiles-set autodetect " + Util.shellQuote(value) } + } + }) + + function slugify(value) { + return MenuModel.slugify(value) + } + + // The apps provider is QML-native: rows come from the shared AppLibrary + // (DesktopEntries) instead of a bash enumeration, so they carry image + // icons, launch feedback, and uninstall support like the launcher. + function mergeAppRows() { + if (!root.appLibrary) return + + var rows = root.appLibrary.sortedEntries("") + var appRows = [] + for (var j = 0; j < rows.length; j++) { + var entry = rows[j].entry + var appId = String(entry.id || "") + if (!appId) continue + var subtext = root.appLibrary.entrySubtext(entry) + var aliases = subtext ? [subtext] : [] + try { + if (entry.keywords && typeof entry.keywords.join === "function") aliases = aliases.concat(entry.keywords) + } catch (e) { } + appRows.push({ + id: "apps." + appId, + parent: "apps", + kind: "app", + icon: "", + appIcon: String(entry.icon || ""), + appId: appId, + label: root.appLibrary.entryName(entry), + title: "", + target: "", + description: subtext, + action: "", + provider: "", + aliases: aliases, + when: "", + checked: "", + order: 0 + }) + } + + var merged = MenuModel.mergeAppRows(root.items, root.itemOrder, appRows) + root.items = merged.items + root.itemOrder = merged.itemOrder + if (root.opened) root.rebuildDisplay() + } + + function startProviderForMenu(id) { + var entry = root.item(id) + if (!entry || !entry.provider || root.providersLoaded[id]) return + if (entry.provider === "apps") { + root.providersLoaded[id] = true + root.mergeAppRows() + 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 lines = String(rows || "").split("\n") + var providerRows = [] + var takenIds = ({}) + 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 + // Distinct values can slugify alike — Fira Code and Fira-Code both give + // fira-code — and a repeated id is dropped, which would silently lose a + // row from the list. Nudge it until it is the row's own. + var rowId = menuId + "." + root.slugify(value) + while (takenIds[rowId]) rowId += "-" + takenIds[rowId] = true + + providerRows.push({ + id: rowId, + parent: menuId, + kind: "action", + icon: (value === current) ? "✓" : (spec.icon || ""), + label: label, + title: "", + target: "", + description: "", + action: spec.actionFor(value), + provider: "", + aliases: [], + when: "", + checked: "", + order: 0 + }) + } + var merged = MenuModel.swapProviderRows(root.items, root.itemOrder, menuId, providerRows) + root.items = merged.items + root.itemOrder = merged.itemOrder + if (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 + } + } + + // Entering a submenu is the one moment a volatile list is worth paying for + // again: it may have been reshaped by the last pick from it. Search doesn't + // invalidate, or every keystroke would restart the same enumeration. + function invalidateVolatileProvider(id) { + var entry = root.item(id) + var spec = entry && entry.provider ? root.providers[entry.provider] : null + if (spec && spec.volatile) root.providersLoaded[id] = false + } + + function loadProviderForMenu(id) { + var entry = root.item(id) + if (!entry || !entry.provider || root.providersLoaded[id]) return + + // Native providers don't touch providerProc, so they never need to queue. + if (entry.provider === "apps") { + root.startProviderForMenu(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) + } + + // Guarded items are hidden when their `when:` evaluates false. Static + // submenus are also hidden when none of their descendants are visible; + // provider-backed menus stay visible because their rows load on demand. + function isVisible(entry) { + return MenuModel.isVisible(root.items, root.itemOrder, root.whenResults, entry) + } + + // 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 descriptionTextMatches(query, text) { + return MenuModel.descriptionTextMatches(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++) { + // An option is "