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[..