From 8f0e8add14883dc618c985963671d9d2e04c8f96 Mon Sep 17 00:00:00 2001 From: nojhan Date: Sun, 14 Feb 2021 19:34:46 +0100 Subject: [PATCH] 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). --- terminatorlib/plugins/run_cmd_on_match.py | 522 ++++++++++++++++++++++ terminatorlib/prefseditor.py | 3 +- terminatorlib/terminal.py | 19 +- 3 files changed, 539 insertions(+), 5 deletions(-) create mode 100644 terminatorlib/plugins/run_cmd_on_match.py diff --git a/terminatorlib/plugins/run_cmd_on_match.py b/terminatorlib/plugins/run_cmd_on_match.py new file mode 100644 index 00000000..e9010203 --- /dev/null +++ b/terminatorlib/plugins/run_cmd_on_match.py @@ -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() diff --git a/terminatorlib/prefseditor.py b/terminatorlib/prefseditor.py index 5d43953f..80c4528e 100755 --- a/terminatorlib/prefseditor.py +++ b/terminatorlib/prefseditor.py @@ -433,7 +433,8 @@ class PrefsEditor: pluginlist = self.registry.get_available_plugins() self.plugins = {} for plugin in pluginlist: - self.plugins[plugin] = self.registry.is_enabled(plugin) + if plugin[0] != "_": # Do not display hidden plugins + self.plugins[plugin] = self.registry.is_enabled(plugin) for plugin in self.plugins: self.pluginiters[plugin] = liststore.append([plugin, diff --git a/terminatorlib/terminal.py b/terminatorlib/terminal.py index c49f6ad2..fa25930d 100644 --- a/terminatorlib/terminal.py +++ b/terminatorlib/terminal.py @@ -953,6 +953,8 @@ class Terminal(Gtk.VBox): url = self.vte.match_check_event(event) if url[0]: self.open_url(url, prepare=True) + else: + dbg("No regex match, discard event.") elif event.button == self.MOUSEBUTTON_MIDDLE: # middleclick should paste the clipboard # try to pass it to vte widget first though @@ -1518,13 +1520,14 @@ class Terminal(Gtk.VBox): registry = plugin.PluginRegistry() registry.load_plugins() plugins = registry.get_plugins_by_capability('url_handler') + dbg("URL handler plugins: {}".format(plugins)) for urlplugin in plugins: if match == self.matches[urlplugin.handler_name]: newurl = urlplugin.callback(url) - if newurl is not None: - dbg('Terminal::prepare_url: URL prepared by \ -%s plugin' % urlplugin.handler_name) + if newurl: # If the plugin returns None, it's a false match. + dbg('URL prepared by %s plugin' \ + % urlplugin.handler_name) url = newurl break except Exception as ex: @@ -1536,8 +1539,15 @@ class Terminal(Gtk.VBox): """Open a given URL, conditionally unpacking it from a VTE match""" if prepare: 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']: dbg("Using custom URL handler: %s" % self.config['custom_url_handler']) @@ -1560,6 +1570,7 @@ class Terminal(Gtk.VBox): import webbrowser webbrowser.open(url) + def paste_clipboard(self, primary=False): """Paste one of the two clipboards""" for term in self.terminator.get_target_terms(self):