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.
|
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
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.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
|
||||||
|
|||||||
Reference in New Issue
Block a user