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