feat: dynamic menus

This commit is contained in:
relikd
2026-02-07 11:56:25 +01:00
parent 8053110349
commit d514bfb610
4 changed files with 93 additions and 18 deletions

View File

@@ -12,7 +12,7 @@ A menu bar script executor.
Menuscript adds a status bar menu to call custom (user defined) scripts.
The app reads the content of a directory and adds all executable files to the status menu.
The screenshot above represents the content of the [res/examples](res/examples/) directory.
See [res/examples](res/examples/) directory.
## Usage
@@ -68,5 +68,29 @@ These constant strings are defined:
With this flag, a new `Terminal` window will open and show the activley running script (useful for continuous output like `top` or `netstat -w`, etc.)
- __[inactive]__: Make menu item non-clickable (will appear greyed out)
- __[ignore]__: Do not show menu item (no menu entry)
- __[dyn]__: If set, the script file is responsible to generate its menu items.
See [Dynamic Menus](#dynamic-menus).
#### Dynamic Menus
A single script file can generate a whole menu with multiple menu items ("[dyn]" flag).
As with other scripts, the filename will be used as menu title, in this case a menu folder.
The dynamic script is called with environment variables `ACTION` and `ITEM`.
Action can be one of: `list` (generate menu), `click` (item clicked), or `icon` (should return image data).
- `ACTION=list` is called without `ITEM` env and should return a list of menu items to display.
Just print each menu item on a separate line.
Item titles can use the same [modifier flags](#modifier-flags) as normal script files.
But contrary to normal script files, sort order is defined by the dynamic script (you should not use numbers).
- `ACTION=click` always provides an `ITEM` with the currently clicked menu item title (after removing modifier flags).
Your script should perform the intended action of the menu item.
- `ACTION=icon` may return an icon for any menu item (or return nothing to omit the icon).
`ITEM` is empty for the folder menu icon and set to the menu title if called for an individual item (same as `click` action).
You should return image data, e.g., by printing the content of an image file or returning SVG code.
You could use the icon to display an online status for example.
Keep in mind, that the menu is generated only once after the status menu opens.
Limitations: dynamic menus cannot have dynamic submenus.
Also, dynamic items cannot use the verbose flag (for now).

31
res/examples/Dyn [dyn] Executable file
View File

@@ -0,0 +1,31 @@
#!/bin/sh
status() {
echo '<svg viewBox="0 0 8 8"><circle cx="4" cy="4" r="4" fill="' "$1" '"/></svg>'
}
case $ACTION in
list)
echo "Item 1 [verbose]"
echo "Item 2 (Text Editor) [txt]"
echo "Item 3 [ignore]"
echo "Item 4 [txt]"
echo "🔴 Status"
echo "Time: $(date) [inactive]"
;;
click)
case $ITEM in
"Item 1") echo "First item clicked";;
"Item 2 (Text Editor)") echo "Second item clicked";;
*) echo "else: click >$ITEM<";;
esac;;
icon)
case $ITEM in
"") cat Dyn-icon.png;; # Main menu icon
"Item 1") cat Dyn-icon.png;;
*Status) status green;;
esac;;
*)
# [verbose] is missing the env variables
echo "unsupported action $ACTION, item: $ITEM"
esac

BIN
res/examples/Dyn-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 616 B

View File

@@ -60,7 +60,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
itm.representedObject = entry
itm.image = entry.icon()
if entry.isDir {
if entry.isDir || entry.action == .Dynamic {
itm.submenu = NSMenu(title: entry.url.path)
itm.submenu?.delegate = self
} else if entry.action != .Inactive {
@@ -77,7 +77,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
@objc private func menuItemCallback(sender: NSMenuItem) {
let entry = sender.representedObject as! Entry
let cmd = Exec(file: entry.url, args: nil)
let cmd = Exec(file: entry.url, env: ["ACTION": "click", "ITEM": entry.title])
if entry.action == .Text {
cmd.editor()
} else {
@@ -184,14 +184,13 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
struct Exec {
let file: URL
var args: [String]? = nil
var env: [String: String]? = nil
private func prepare() -> Process {
let proc = Process()
// proc.environment = ["HOME": "$HOME"]
proc.environment = self.env
proc.currentDirectoryURL = self.file.deletingLastPathComponent()
proc.executableURL = self.file
proc.arguments = args
return proc
}
@@ -224,9 +223,13 @@ struct Exec {
}
/// run command and return output
func read() -> String {
let data = toPipe().fileHandleForReading.readDataToEndOfFile()
return String(data: data, encoding: .utf8) ?? ""
func readData() -> Data {
return toPipe().fileHandleForReading.readDataToEndOfFile()
}
/// run command and return output
func readString() -> String {
return String(data: readData(), encoding: .utf8) ?? ""
}
}
@@ -236,6 +239,14 @@ struct Exec {
func listDir(_ path: String) -> [Entry] {
let target = URL(fileURLWithPath: path)
var rv: [Entry] = []
// dynamic menu
if !target.hasDirectoryPath {
Exec(file: target, env: ["ACTION": "list"]).readString().enumerateLines { line, stop in
rv.append(Entry(target, title: line))
}
return rv
}
// else: static menu
for url in (try? FileManager.default.contentsOfDirectory(at: target, includingPropertiesForKeys: [.isDirectoryKey, .isExecutableKey])) ?? [] {
if url.hasDirectoryPath || FileManager.default.isExecutableFile(atPath: url.path) {
rv.append(Entry(url))
@@ -253,6 +264,7 @@ enum ActionFlag {
case Verbose
case Inactive
case Ignore
case Dynamic
static func from(_ filename: inout String) -> Self {
for (key, flag) in [
@@ -260,6 +272,7 @@ enum ActionFlag {
"[verbose]": .Verbose,
"[inactive]": .Inactive,
"[ignore]": .Ignore,
"[dyn]": .Dynamic,
] {
if filename.contains(key) {
filename = filename
@@ -277,21 +290,25 @@ struct Entry {
let url: URL
let title: String
let action: ActionFlag
let hasDynParent: Bool
var isDir: Bool { url.hasDirectoryPath }
init(_ url: URL) {
init(_ url: URL, title: String? = nil) {
self.hasDynParent = title != nil
self.url = url
// TODO: remove file extension?
var fname = url.lastPathComponent
var fname = title ?? url.lastPathComponent
var customOrder = 100
var idx = fname.firstIndex { !$0.isWholeNumber } ?? fname.startIndex
// sort order
if let order = Int(fname[..<idx]) {
customOrder = order
idx = fname[idx..<fname.endIndex].firstIndex { !$0.isWhitespace } ?? idx
idx = fname[idx..<fname.endIndex].firstIndex { !$0.isPunctuation } ?? idx
fname = String(fname[idx..<fname.endIndex])
if !hasDynParent {
var idx = fname.firstIndex { !$0.isWholeNumber } ?? fname.startIndex
// sort order
if let order = Int(fname[..<idx]) {
customOrder = order
idx = fname[idx..<fname.endIndex].firstIndex { !$0.isWhitespace } ?? idx
idx = fname[idx..<fname.endIndex].firstIndex { !$0.isPunctuation } ?? idx
fname = String(fname[idx..<fname.endIndex])
}
}
self.order = customOrder
self.action = ActionFlag.from(&fname)
@@ -311,6 +328,9 @@ struct Entry {
if img == nil {
img = NSImage(named: NSImage.folderName)
}
} else if action == .Dynamic || hasDynParent {
let cmd = Exec(file: self.url, env: ["ACTION": "icon", "ITEM": hasDynParent ? self.title : ""])
img = NSImage(data: cmd.readData())
}
img?.size = NSMakeSize(16, 16)
return img