feat: hidden plugins, managed URLhandlers, RunCmdOnMatch plugin

- Plugins starting with underscore will not be displayed by the
  preference/plugins window.
- This allow to add URLhandlers plugins on-the-fly.
- Add the RunCmdOnMatch plugin, which creates URLhandlers plugins
  based on a regexp/command pair.
- If an URLhandler plugin returns a prepared URL starting with "terminator://",
  then terminator will not try to open it with the URL handler (like xdg-open).
This commit is contained in:
nojhan 2021-02-14 19:34:46 +01:00
parent 5fb7466128
commit 8f0e8add14
3 changed files with 539 additions and 5 deletions

View File

@ -0,0 +1,522 @@
import re
import os
import sys
import subprocess
from gi.repository import Gtk
from gi.repository import GObject
from terminatorlib.util import dbg
import terminatorlib.plugin as plugin
from terminatorlib.config import Config
from terminatorlib.translation import _
from terminatorlib.util import get_config_dir, err, dbg, gerr
(CC_COL_ENABLED, CC_COL_REGEXP, CC_COL_COMMAND) = list(range(0,3))
AVAILABLE = ['RunCmdOnMatchMenu']
# For example, open scripts names outputed by python in Vim at the given line number:
# match = r'\B(/\S+?\.py)\S{2}\sline\s(\d+)' # Python's log file matching
# cmd = "gvim --servername IDE --remote +{1} {0}"
# This class is not useful as is, it needs to be forged through MetaRCOM metaclass to be useful
# (because the API use static class properties).
class RunCmdOnMatch(plugin.URLHandler):
"""Template for a class that run a command when a regexp match something printed on the terminal screen."""
capabilities = ['url_handler']
nameopen = "Open file"
namecopy = "Copy file path"
handler_name = None
match = None
cmd = None
def callback(self, url):
assert(self.__class__.match)
assert(self.__class__.cmd)
try:
found = re.search(self.__class__.match, url)
except Exception as e:
dbg("ERROR while searching in the captured URL: {}".format(e))
return None
if not found:
dbg("ERROR pattern not found")
return None
try:
groups = found.groups()
dbg("Groups: {}".format(groups))
except Exception as e:
dbg("ERROR while accessing groups: {}".format(e))
return None
for group in groups:
if not group:
dbg("ERROR groups not captured correctly: {groups}".format(groups=groups))
return None
try:
runcmd = self.__class__.cmd.format(*groups)
except Exception as e:
err("Exception occured while formating the command: {} {}".format(type(e).__name__, e))
dbg("run: {cmd}".format(cmd=runcmd))
subprocess.run(runcmd.split())
# To avoid the fallback to the default URL handler, use the `terminator://` protocol tag.
# Terminator will not try to open the URL, so any string after is just for debugging.
return "terminator://{cmd}".format(cmd=runcmd)
# This metaclass is used to populate RunCmdOnMatch's static members.
class MetaRCOM(type):
"""A meta-class for creating RunCmdOnMatch plugins on the fly."""
def __new__(cls, name, regexp, cmd):
return super().__new__(cls, name, (RunCmdOnMatch,), {"match":regexp, "cmd":cmd, "handler_name":name})
# Add a contextual menu for opening a preference window to configure regexp/commands.
# FIXME this is essentially the same code than custom_commands, one need to refactor to keep a single codebase
# (and waiting for a plugin's preference window).
class RunCmdOnMatchMenu(plugin.MenuItem):
"""Add custom match/commands preference setting to the terminal menu"""
capabilities = ['terminal_menu']
cmd_list = {}
conf_file = os.path.join(get_config_dir(),"run_cmd_on_match")
def __init__( self):
config = Config()
sections = config.plugin_get_config(self.__class__.__name__)
if not isinstance(sections, dict):
return
noord_cmds = []
for part in sections:
s = sections[part]
if not ("regexp" in s and "command" in s):
dbg("Ignoring section %s" % s)
continue
regexp = s["regexp"]
command = s["command"]
enabled = s["enabled"] and s["enabled"] or False
if "position" in s:
self.cmd_list[int(s["position"])] = {'enabled' : enabled,
'regexp' : regexp,
'command' : command
}
else:
noord_cmds.append(
{'enabled' : enabled,
'regexp' : regexp,
'command' : command
}
)
for cmd in noord_cmds:
self.cmd_list[len(self.cmd_list)] = cmd
self._load_configured_handlers()
def callback(self, menuitems, menu, terminal):
"""Add our menu items to the menu"""
submenus = {}
item = Gtk.MenuItem.new_with_mnemonic(_('_Run command on matches'))
menuitems.append(item)
submenu = Gtk.Menu()
item.set_submenu(submenu)
menuitem = Gtk.MenuItem.new_with_mnemonic(_('_Preferences'))
menuitem.connect("activate", self.configure)
submenu.append(menuitem)
menuitem = Gtk.SeparatorMenuItem()
submenu.append(menuitem)
theme = Gtk.IconTheme.get_default()
def _save_config(self):
config = Config()
config.plugin_del_config(self.__class__.__name__)
i = 0
for command in [ self.cmd_list[key] for key in sorted(self.cmd_list.keys()) ] :
enabled = command['enabled']
regexp = command['regexp']
command = command['command']
item = {}
item['enabled'] = enabled
item['regexp'] = regexp
item['command'] = command
item['position'] = i
config.plugin_set(self.__class__.__name__, regexp, item)
i = i + 1
config.save()
self._load_configured_handlers()
def _load_configured_handlers(self):
"""Forge an URLhandler plugin and hide it in the available ones."""
me = sys.modules[__name__] # Current module.
config = Config()
for key,handler in [ (key,self.cmd_list[key]) for key in sorted(self.cmd_list.keys()) ] :
# Forge a hidden/managed plugin
# (names starting with an underscore will not be displayed in the preference/plugins window).
rcom_name = "_RunCmdOnMatch_{}".format(key) # key is just the index
# Make a handler class.
RCOM = MetaRCOM(rcom_name, handler["regexp"], handler["command"])
# Instanciate the class.
setattr(me, rcom_name, RCOM)
if rcom_name not in AVAILABLE:
AVAILABLE.append(rcom_name)
dbg("add {} to the list of URL handlers: '{}' -> '{}'".format(rcom_name, RCOM.match, RCOM.cmd))
if handler['enabled'] and rcom_name not in config["enabled_plugins"]:
config["enabled_plugins"].append(rcom_name)
config.save()
def _execute(self, widget, data):
command = data['command']
if command[-1] != '\n':
command = command + '\n'
for terminal in data['terminals']:
terminal.vte.feed_child(command.encode())
def configure(self, widget, data = None):
ui = {}
dbox = Gtk.Dialog(
_("Run command on match Configuration"),
None,
Gtk.DialogFlags.MODAL,
(
_("_Cancel"), Gtk.ResponseType.REJECT,
_("_OK"), Gtk.ResponseType.ACCEPT
)
)
dbox.set_transient_for(widget.get_toplevel())
icon_theme = Gtk.IconTheme.get_default()
if icon_theme.lookup_icon('terminator-run-cmd-on-match', 48, 0):
dbox.set_icon_name('terminator-run-cmd-on-match')
else:
dbg('Unable to load Terminator run-cmd-on-match icon')
icon = dbox.render_icon(Gtk.STOCK_DIALOG_INFO, Gtk.IconSize.BUTTON)
dbox.set_icon(icon)
store = Gtk.ListStore(bool, str, str)
for command in [ self.cmd_list[key] for key in sorted(self.cmd_list.keys()) ]:
store.append([command['enabled'], command['regexp'], command['command']])
treeview = Gtk.TreeView(store)
#treeview.connect("cursor-changed", self.on_cursor_changed, ui)
selection = treeview.get_selection()
selection.set_mode(Gtk.SelectionMode.SINGLE)
selection.connect("changed", self.on_selection_changed, ui)
ui['treeview'] = treeview
renderer = Gtk.CellRendererToggle()
renderer.connect('toggled', self.on_toggled, ui)
column = Gtk.TreeViewColumn(_("Enabled"), renderer, active=CC_COL_ENABLED)
treeview.append_column(column)
renderer = Gtk.CellRendererText()
column = Gtk.TreeViewColumn(_("regexp"), renderer, text=CC_COL_REGEXP)
treeview.append_column(column)
renderer = Gtk.CellRendererText()
column = Gtk.TreeViewColumn(_("Command"), renderer, text=CC_COL_COMMAND)
treeview.append_column(column)
scroll_window = Gtk.ScrolledWindow()
scroll_window.set_size_request(500, 250)
scroll_window.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
scroll_window.add_with_viewport(treeview)
hbox = Gtk.HBox()
hbox.pack_start(scroll_window, True, True, 0)
dbox.vbox.pack_start(hbox, True, True, 0)
button_box = Gtk.VBox()
button = Gtk.Button(_("Top"))
button_box.pack_start(button, False, True, 0)
button.connect("clicked", self.on_goto_top, ui)
button.set_sensitive(False)
ui['button_top'] = button
button = Gtk.Button(_("Up"))
button_box.pack_start(button, False, True, 0)
button.connect("clicked", self.on_go_up, ui)
button.set_sensitive(False)
ui['button_up'] = button
button = Gtk.Button(_("Down"))
button_box.pack_start(button, False, True, 0)
button.connect("clicked", self.on_go_down, ui)
button.set_sensitive(False)
ui['button_down'] = button
button = Gtk.Button(_("Last"))
button_box.pack_start(button, False, True, 0)
button.connect("clicked", self.on_goto_last, ui)
button.set_sensitive(False)
ui['button_last'] = button
button = Gtk.Button(_("New"))
button_box.pack_start(button, False, True, 0)
button.connect("clicked", self.on_new, ui)
ui['button_new'] = button
button = Gtk.Button(_("Edit"))
button_box.pack_start(button, False, True, 0)
button.set_sensitive(False)
button.connect("clicked", self.on_edit, ui)
ui['button_edit'] = button
button = Gtk.Button(_("Delete"))
button_box.pack_start(button, False, True, 0)
button.connect("clicked", self.on_delete, ui)
button.set_sensitive(False)
ui['button_delete'] = button
hbox.pack_start(button_box, False, True, 0)
self.dbox = dbox
dbox.show_all()
res = dbox.run()
if res == Gtk.ResponseType.ACCEPT:
self.update_cmd_list(store)
self._save_config()
del(self.dbox)
dbox.destroy()
return
def update_cmd_list(self, store):
iter = store.get_iter_first()
self.cmd_list = {}
i=0
while iter:
(enabled, regexp, command) = store.get(iter,
CC_COL_ENABLED,
CC_COL_REGEXP,
CC_COL_COMMAND)
self.cmd_list[i] = {'enabled' : enabled,
'regexp': regexp,
'command' : command}
iter = store.iter_next(iter)
i = i + 1
def on_toggled(self, widget, path, data):
treeview = data['treeview']
store = treeview.get_model()
iter = store.get_iter(path)
(enabled, regexp, command) = store.get(iter,
CC_COL_ENABLED,
CC_COL_REGEXP,
CC_COL_COMMAND
)
store.set_value(iter, CC_COL_ENABLED, not enabled)
def on_selection_changed(self,selection, data=None):
treeview = selection.get_tree_view()
(model, iter) = selection.get_selected()
data['button_top'].set_sensitive(iter is not None)
data['button_up'].set_sensitive(iter is not None)
data['button_down'].set_sensitive(iter is not None)
data['button_last'].set_sensitive(iter is not None)
data['button_edit'].set_sensitive(iter is not None)
data['button_delete'].set_sensitive(iter is not None)
def _create_command_dialog(self, enabled_var = False, regexp_var = "", command_var = ""):
dialog = Gtk.Dialog(
_("New Command"),
None,
Gtk.DialogFlags.MODAL,
(
_("_Cancel"), Gtk.ResponseType.REJECT,
_("_OK"), Gtk.ResponseType.ACCEPT
)
)
dialog.set_transient_for(self.dbox)
table = Gtk.Table(3, 2)
label = Gtk.Label(label=_("Enabled:"))
table.attach(label, 0, 1, 0, 1)
enabled = Gtk.CheckButton()
enabled.set_active(enabled_var)
table.attach(enabled, 1, 2, 0, 1)
label = Gtk.Label(label=_("regexp:"))
table.attach(label, 0, 1, 1, 2)
regexp = Gtk.Entry()
regexp.set_text(regexp_var)
table.attach(regexp, 1, 2, 1, 2)
label = Gtk.Label(label=_("Command:"))
table.attach(label, 0, 1, 2, 3)
command = Gtk.Entry()
command.set_text(command_var)
table.attach(command, 1, 2, 2, 3)
dialog.vbox.pack_start(table, True, True, 0)
dialog.show_all()
return (dialog,enabled,regexp,command)
def on_new(self, button, data):
(dialog,enabled,regexp,command) = self._create_command_dialog()
res = dialog.run()
item = {}
if res == Gtk.ResponseType.ACCEPT:
item['enabled'] = enabled.get_active()
item['regexp'] = regexp.get_text()
item['command'] = command.get_text()
if item['regexp'] == '' or item['command'] == '':
err = Gtk.MessageDialog(dialog,
Gtk.DialogFlags.MODAL,
Gtk.MessageType.ERROR,
Gtk.ButtonsType.CLOSE,
_("You need to define a regexp and command")
)
err.run()
err.destroy()
else:
# we have a new command
store = data['treeview'].get_model()
iter = store.get_iter_first()
regexp_exist = False
while iter != None:
if store.get_value(iter,CC_COL_REGEXP) == item['regexp']:
regexp_exist = True
break
iter = store.iter_next(iter)
if not regexp_exist:
store.append((item['enabled'], item['regexp'], item['command']))
else:
gerr(_("regexp *%s* already exist") % item['regexp'])
dialog.destroy()
def on_goto_top(self, button, data):
treeview = data['treeview']
selection = treeview.get_selection()
(store, iter) = selection.get_selected()
if not iter:
return
firstiter = store.get_iter_first()
store.move_before(iter, firstiter)
def on_go_up(self, button, data):
treeview = data['treeview']
selection = treeview.get_selection()
(store, iter) = selection.get_selected()
if not iter:
return
tmpiter = store.get_iter_first()
if(store.get_path(tmpiter) == store.get_path(iter)):
return
while tmpiter:
next = store.iter_next(tmpiter)
if(store.get_path(next) == store.get_path(iter)):
store.swap(iter, tmpiter)
break
tmpiter = next
def on_go_down(self, button, data):
treeview = data['treeview']
selection = treeview.get_selection()
(store, iter) = selection.get_selected()
if not iter:
return
next = store.iter_next(iter)
if next:
store.swap(iter, next)
def on_goto_last(self, button, data):
treeview = data['treeview']
selection = treeview.get_selection()
(store, iter) = selection.get_selected()
if not iter:
return
lastiter = iter
tmpiter = store.get_iter_first()
while tmpiter:
lastiter = tmpiter
tmpiter = store.iter_next(tmpiter)
store.move_after(iter, lastiter)
def on_delete(self, button, data):
treeview = data['treeview']
selection = treeview.get_selection()
(store, iter) = selection.get_selected()
if iter:
store.remove(iter)
return
def on_edit(self, button, data):
treeview = data['treeview']
selection = treeview.get_selection()
(store, iter) = selection.get_selected()
if not iter:
return
(dialog,enabled,regexp,command) = self._create_command_dialog(
enabled_var = store.get_value(iter, CC_COL_ENABLED),
regexp_var = store.get_value(iter, CC_COL_REGEXP),
command_var = store.get_value(iter, CC_COL_COMMAND)
)
res = dialog.run()
item = {}
if res == Gtk.ResponseType.ACCEPT:
item['enabled'] = enabled.get_active()
item['regexp'] = regexp.get_text()
item['command'] = command.get_text()
if item['regexp'] == '' or item['command'] == '':
err = Gtk.MessageDialog(dialog,
Gtk.DialogFlags.MODAL,
Gtk.MessageType.ERROR,
Gtk.ButtonsType.CLOSE,
_("You need to define a regexp and a command")
)
err.run()
err.destroy()
else:
tmpiter = store.get_iter_first()
regexp_exist = False
while tmpiter != None:
if store.get_path(tmpiter) != store.get_path(iter) and store.get_value(tmpiter,CC_COL_REGEXP) == item['regexp']:
regexp_exist = True
break
tmpiter = store.iter_next(tmpiter)
if not regexp_exist:
store.set(iter,
CC_COL_ENABLED,item['enabled'],
CC_COL_REGEXP, item['regexp'],
CC_COL_COMMAND, item['command']
)
else:
gerr(_("regexp *%s* already exist") % item['regexp'])
dialog.destroy()

View File

@ -433,6 +433,7 @@ class PrefsEditor:
pluginlist = self.registry.get_available_plugins() pluginlist = self.registry.get_available_plugins()
self.plugins = {} self.plugins = {}
for plugin in pluginlist: for plugin in pluginlist:
if plugin[0] != "_": # Do not display hidden plugins
self.plugins[plugin] = self.registry.is_enabled(plugin) self.plugins[plugin] = self.registry.is_enabled(plugin)
for plugin in self.plugins: for plugin in self.plugins:

View File

@ -953,6 +953,8 @@ class Terminal(Gtk.VBox):
url = self.vte.match_check_event(event) url = self.vte.match_check_event(event)
if url[0]: if url[0]:
self.open_url(url, prepare=True) self.open_url(url, prepare=True)
else:
dbg("No regex match, discard event.")
elif event.button == self.MOUSEBUTTON_MIDDLE: elif event.button == self.MOUSEBUTTON_MIDDLE:
# middleclick should paste the clipboard # middleclick should paste the clipboard
# try to pass it to vte widget first though # try to pass it to vte widget first though
@ -1518,13 +1520,14 @@ class Terminal(Gtk.VBox):
registry = plugin.PluginRegistry() registry = plugin.PluginRegistry()
registry.load_plugins() registry.load_plugins()
plugins = registry.get_plugins_by_capability('url_handler') plugins = registry.get_plugins_by_capability('url_handler')
dbg("URL handler plugins: {}".format(plugins))
for urlplugin in plugins: for urlplugin in plugins:
if match == self.matches[urlplugin.handler_name]: if match == self.matches[urlplugin.handler_name]:
newurl = urlplugin.callback(url) newurl = urlplugin.callback(url)
if newurl is not None: if newurl: # If the plugin returns None, it's a false match.
dbg('Terminal::prepare_url: URL prepared by \ dbg('URL prepared by %s plugin' \
%s plugin' % urlplugin.handler_name) % urlplugin.handler_name)
url = newurl url = newurl
break break
except Exception as ex: except Exception as ex:
@ -1536,8 +1539,15 @@ class Terminal(Gtk.VBox):
"""Open a given URL, conditionally unpacking it from a VTE match""" """Open a given URL, conditionally unpacking it from a VTE match"""
if prepare: if prepare:
url = self.prepare_url(url) url = self.prepare_url(url)
dbg('open_url: URL: %s (prepared: %s)' % (url, prepare)) dbg('URL: %s (prepared: %s)' % (url, prepare))
# If the URL opening is managed by the plugin: do nothing.
# (plugins can indicate they manage the URL opening by returning a "terminator://" URI).
if url.split(":")[0] == "terminator":
dbg("URL opening is managed by the plugin, do nothing more.")
return
# Else, call the URL handler.
if self.config['use_custom_url_handler']: if self.config['use_custom_url_handler']:
dbg("Using custom URL handler: %s" % dbg("Using custom URL handler: %s" %
self.config['custom_url_handler']) self.config['custom_url_handler'])
@ -1560,6 +1570,7 @@ class Terminal(Gtk.VBox):
import webbrowser import webbrowser
webbrowser.open(url) webbrowser.open(url)
def paste_clipboard(self, primary=False): def paste_clipboard(self, primary=False):
"""Paste one of the two clipboards""" """Paste one of the two clipboards"""
for term in self.terminator.get_target_terms(self): for term in self.terminator.get_target_terms(self):