feat: dynamic menus
This commit is contained in:
26
README.md
26
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).
|
||||
|
||||
31
res/examples/Dyn [dyn]
Executable file
31
res/examples/Dyn [dyn]
Executable 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
BIN
res/examples/Dyn-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 616 B |
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user