Skip to content

Instantly share code, notes, and snippets.

@avih
Created February 2, 2017 14:07
Show Gist options
  • Star 16 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save avih/bee746200b5712220b8bd2f230e535de to your computer and use it in GitHub Desktop.
Save avih/bee746200b5712220b8bd2f230e535de to your computer and use it in GitHub Desktop.
flexible mpv context menu using Tcl/Tk, for Windows too!
--[[ *************************************************************
* Context menu for mpv using Tcl/Tk. Mostly proof of concept.
* Avi Halachmi (:avih) https://github.com/avih
*
* Features:
* - Simple construction: ["<some text>", "<mpv-command>"] is a complete menu item.
* - Possibly dynamic menu items and commands, disabled items, separators.
* - Possibly keeping the menu open after clicking an item (via re-launch).
* - Hacky pseudo sub menus. Really, this is an ugly hack.
* - Reasonably well behaved/integrated considering it's an external application.
*
* TODO-ish:
* - Proper sub menus (TBD protocol, tk relaunch), tooltips, other widgets (not).
* - Possibly different menus for different bindings or states.
*
* Setup:
* - Make sure Tcl/Tk is installed and `wish` is accessible and works.
* - Alternatively, configure `interpreter` below to `tclsh`, which may work smoother.
* - For windows, download a zip from http://www.tcl3d.org/html/appTclkits.html
* extract and then rename to wish.exe and put it at the path or at the mpv.exe dir.
* - Or, tclsh/wish from git/msys2(mingw) works too - set `interpreter` below.
* - Put context.lua (this file) and context.tcl at the mpv scripts dir.
* - Add a key/mouse binding at input.conf, e.g. "MOUSE_BTN2 script_message contextmenu"
* - Once it works, configure the context_menu items below to your liking.
*
* 2017-02-02 - Version 0.1 - initial version
*
***************************************************************
--]]
--[[ ************ CONFIG: start ************ ]]--
-- context_menu is an array of items, where each item is an array of:
-- - Display string or a function which returns such string, or "-" for separator.
-- - Command string or a function which is executed on click. Empty to disable/gray.
-- - Optional re-launch: a submenu array, or true to "keep" the same menu open.
function noop() end
local prop_native = mp.get_property_native
local context_menu = {
{function() return prop_native("mute") and "Un-mute" or "Mute" end, "cycle mute"},
{"* Volume Up", "add volume 10", true},
{"* Volume Down", "add volume -10", true},
{function() return "[ Volume: " .. tostring(math.floor(prop_native("volume"))) .. " ]" end},
{"-"},
{"* Size: orig / 2", "set window-scale 0.5", true},
{"* Size: orig 1:1", "set window-scale 1.0", true},
{"* Size: orig x 2", "set window-scale 2.0", true},
{"-"},
{"Pseudo sub-menu -->", noop, {
{"* Press space with the mouse!", "keypress SPACE", true},
{"GOTO 0", "set time-pos 0"},
{"Another pseudo sub-menu -->", noop, {
{"Yay!", "show_text Yay!"},
{"* Yay+!", function() mp.osd_message("Yay! " .. tostring(math.random())) end, true},
}},
}},
{"-"},
{"Quit watch-later", "quit-watch-later"},
{"Quit", "quit"},
{"-"},
{"Dismiss", noop},
}
local verbose = false -- true -> dump console messages also without -v
local interpreter = "wish"; -- tclsh/wish/full-path
local menuscript = mp.find_config_file("scripts/context.tcl")
--[[ ************ CONFIG: end ************ ]]--
function info(x) mp.msg[verbose and "info" or "verbose"](x) end
local utils = require 'mp.utils'
local function do_menu(items, x, y)
local args = {interpreter, menuscript, tostring(x), tostring(y)}
for i = 1, #items do
local item = items[i]
args[#args+1] = (type(item[1]) == "string") and item[1] or item[1]()
args[#args+1] = item[2] and tostring(i) or ""
end
local ret = utils.subprocess({
args = args,
cancellable = true
})
if (ret.status ~= 0) then
mp.osd_message("Something happened ...")
return
end
info("ret: " .. ret.stdout)
local res = utils.parse_json(ret.stdout)
x = tonumber(res.x)
y = tonumber(res.y)
res.rv = tonumber(res.rv)
if (res.rv == -1) then
info("Context menu cancelled")
return
end
local item = items[res.rv]
if (not (item and item[2])) then
mp.msg.error("Unknown menu item index: " .. tostring(res.rv))
return
end
-- run the command
if (type(item[2]) == "string") then
mp.command(item[2])
else
item[2]()
end
-- re-launch
if (item[3]) then
if (type(item[3]) ~= "boolean") then
items = item[3] -- sub-menu, launch at mouse position
x = -1
y = -1
end
-- Break direct recursion with async, stack overflow can come quick.
-- Also allow to un-congest the events queue.
mp.add_timeout(0, function() do_menu(items, x, y) end)
end
end
mp.register_script_message("contextmenu", function()
do_menu(context_menu, -1, -1)
end)
# #############################################################
# Context menu constructed via CLI args. Mostly proof of concept.
# Avi Halachmi (:avih) https://github.com/avih
#
# Developed for and used in conjunction with context.lua - context-menu for mpv.
# See context.lua for more info.
#
# 2017-02-02 - Version 0.1 - initial version
# #############################################################
# Required when launching via tclsh, no-op when launching via wish
package require Tk
# Remove the main window from the host window manager
wm withdraw .
if { $::argc < 4 } {
puts "Usage: context.tcl x y item1 rv1 [item2 rv2 ...]"
exit 1
}
# construct the menu from argv:
# - First pair is absolute x, y menu position, or under the mouse if -1, -1
# - The rest of the pairs are display-string, return-value-on-click.
# If the return value is empty then the display item is disabled, but if the
# display is "-" (and empty rv) then a separator is added instead of an item.
# - For now, return-value is expected to be a number, and -1 is reserved for cancel.
#
# On item-click/menu-dismissed, we print a json object to stdout with the
# keys x, y (menu absolute position) and rv (return value) - all numbers.
set RV_CANCEL -1
set m [menu .popupMenu -tearoff 0]
set first 1
foreach {disp rv} $::argv {
if {$first} {
set pos_x $disp
set pos_y $rv
set first 0
continue
}
if {$rv == ""} {
if {$disp == "-"} {
$m add separator
} else {
$m add command -state disabled -label "$disp"
}
} else {
$m add command -label "$disp" -command "done $rv"
}
}
# Read the absolute mouse pointer position if we're not given a pos via argv
if {$pos_x == -1 && $pos_y == -1} {
set pos_x [winfo pointerx .]
set pos_y [winfo pointery .]
}
proc done {rv} {
puts -nonewline "{\"x\":\"$::pos_x\", \"y\":\"$::pos_y\", \"rv\":\"$rv\"}"
exit
}
# Seemingly, on both windows and linux, "cancelled" is reached after the click but
# before the menu command is executed and _a_sync to it. Therefore we wait a bit to
# allow the menu command to execute first (and exit), and if it didn't, we exit here.
proc cancelled {} {
after 100 {done $::RV_CANCEL}
}
# Calculate the menu position relative to the Tk window
set win_x [expr {$pos_x - [winfo rootx .]}]
set win_y [expr {$pos_y - [winfo rooty .]}]
# Launch the popup menu
tk_popup .popupMenu $win_x $win_y
# On Windows tk_popup is synchronous and so we exit when it closes, but on Linux
# it's async and so we need to bind to the <Unmap> event (<Destroyed> or
# <FocusOut> don't work as expected, e.g. when clicking elsewhere even if the
# popup disappears. <Leave> works but it's an unexpected behavior for a menu).
# Note: if we don't catch the right event, we'd have a zombie process since no
# window. Equally important - the script will not exit.
# Note: untested on macOS (macports' tk requires xorg. meh).
if {$tcl_platform(platform) == "windows"} {
cancelled
} else {
bind .popupMenu <Unmap> cancelled
}
@carmanaught
Copy link

carmanaught commented Jul 19, 2017

This was really useful as a basis for creating a right-click menu and was easier to figure out how to modify than the mpvmenu python script. I did end up forking this and heavily reworked it into mpvcontextmenu in case it helps you, particularly if you end up looking at adding cascading sub-menus rather than the pseudo ones.

Also, is there a specific license for this code? I'd like to ensure my forking this doesn't run afoul of any relevant license.

@avih
Copy link
Author

avih commented Jul 29, 2017

Also, is there a specific license for this code? I'd like to ensure my forking this doesn't run afoul of any relevant license.

Let's say MIT :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment