diff --git a/README.md b/README.md
index 7a0db09..200a82a 100644
--- a/README.md
+++ b/README.md
@@ -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).
diff --git a/res/examples/Dyn [dyn] b/res/examples/Dyn [dyn]
new file mode 100755
index 0000000..09abd20
--- /dev/null
+++ b/res/examples/Dyn [dyn]
@@ -0,0 +1,31 @@
+#!/bin/sh
+
+status() {
+ echo ''
+}
+
+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
diff --git a/res/examples/Dyn-icon.png b/res/examples/Dyn-icon.png
new file mode 100644
index 0000000..e0633a1
Binary files /dev/null and b/res/examples/Dyn-icon.png differ
diff --git a/src/main.swift b/src/main.swift
index 26c4d2e..28d4b56 100755
--- a/src/main.swift
+++ b/src/main.swift
@@ -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[..