#!/usr/bin/python # Terminator by Chris Jones # GPL v2 only """terminal.py - classes necessary to provide Terminal widgets""" import sys import os import pygtk pygtk.require('2.0') import gtk import gobject import subprocess import re from version import APP_NAME from util import dbg, err, gerr import util from config import Config from cwd import get_default_cwd from newterminator import Terminator from titlebar import Titlebar from searchbar import Searchbar from translation import _ try: import vte except ImportError: gerr('You need to install python bindings for libvte') sys.exit(1) # pylint: disable-msg=R0904 class Terminal(gtk.VBox): """Class implementing the VTE widget and its wrappings""" __gsignals__ = { 'close-term': (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, ()), 'title-change': (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, (gobject.TYPE_STRING,)), 'enumerate': (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, (gobject.TYPE_INT,)), 'group-tab': (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, ()), 'ungroup-tab': (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, ()), 'ungroup-all': (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, ()), 'split-horiz': (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, ()), 'split-vert': (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, ()), 'tab-new': (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, ()), 'tab-top-new': (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, ()), } TARGET_TYPE_VTE = 8 terminator = None vte = None terminalbox = None titlebar = None searchbar = None group = None cwd = None command = None clipboard = None pid = None matches = None config = None default_encoding = None composite_support = None def __init__(self): """Class initialiser""" gtk.VBox.__init__(self) self.__gobject_init__() self.terminator = Terminator() self.connect('enumerate', self.terminator.do_enumerate) self.connect('group-tab', self.terminator.group_tab) self.connect('ungroup-tab', self.terminator.ungroup_tab) self.matches = {} self.config = Config() self.cwd = get_default_cwd() self.clipboard = gtk.clipboard_get(gtk.gdk.SELECTION_CLIPBOARD) self.vte = vte.Terminal() self.vte.set_size(80, 24) self.vte._expose_data = None if not hasattr(self.vte, "set_opacity") or not hasattr(self.vte, "is_composited"): self.composite_support = False self.vte.show() self.default_encoding = self.vte.get_encoding() self.update_url_matches(self.config['try_posix_regexp']) self.terminalbox = self.create_terminalbox() self.titlebar = Titlebar() self.titlebar.connect_icon(self.on_group_button_press) self.titlebar.connect('edit-done', self.on_edit_done) self.connect('title-change', self.titlebar.set_terminal_title) self.searchbar = Searchbar() self.show() self.pack_start(self.titlebar, False) self.pack_start(self.terminalbox) self.pack_end(self.searchbar) self.connect_signals() os.putenv('COLORTERM', 'gnome-terminal') env_proxy = os.getenv('http_proxy') if not env_proxy: if self.config['http_proxy'] and self.config['http_proxy'] != '': os.putenv('http_proxy', self.config['http_proxy']) def create_terminalbox(self): """Create a GtkHBox containing the terminal and a scrollbar""" terminalbox = gtk.HBox() scrollbar = gtk.VScrollbar(self.vte.get_adjustment()) position = self.config['scrollbar_position'] if position not in ('hidden', 'disabled'): scrollbar.show() if position == 'left': func = terminalbox.pack_end else: func = terminalbox.pack_start func(self.vte) func(scrollbar, False) terminalbox.show() return(terminalbox) def update_url_matches(self, posix = True): """Update the regexps used to match URLs""" userchars = "-A-Za-z0-9" passchars = "-A-Za-z0-9,?;.:/!%$^*&~\"#'" hostchars = "-A-Za-z0-9" pathchars = "-A-Za-z0-9_$.+!*(),;:@&=?/~#%'\"" schemes = "(news:|telnet:|nntp:|file:/|https?:|ftps?:|webcal:)" user = "[" + userchars + "]+(:[" + passchars + "]+)?" urlpath = "/[" + pathchars + "]*[^]'.}>) \t\r\n,\\\"]" if posix: dbg ('update_url_matches: Trying POSIX URL regexps') lboundry = "[[:<:]]" rboundry = "[[:>:]]" else: # GNU dbg ('update_url_matches: Trying GNU URL regexps') lboundry = "\\<" rboundry = "\\>" self.matches['full_uri'] = self.vte.match_add(lboundry + schemes + "//(" + user + "@)?[" + hostchars +".]+(:[0-9]+)?(" + urlpath + ")?" + rboundry + "/?") if self.matches['full_uri'] == -1: if posix: err ('update_url_matches: POSIX match failed, trying GNU') self.update_url_matches(posix = False) else: err ('update_url_matches: Failed adding URL match patterns') else: self.matches['voip'] = self.vte.match_add(lboundry + '(callto:|h323:|sip:)' + "[" + userchars + "+][" + userchars + ".]*(:[0-9]+)?@?[" + pathchars + "]+" + rboundry) self.matches['addr_only'] = self.vte.match_add (lboundry + "(www|ftp)[" + hostchars + "]*\.[" + hostchars + ".]+(:[0-9]+)?(" + urlpath + ")?" + rboundry + "/?") self.matches['email'] = self.vte.match_add (lboundry + "(mailto:)?[a-zA-Z0-9][a-zA-Z0-9.+-]*@[a-zA-Z0-9]\ [a-zA-Z0-9-]*\.[a-zA-Z0-9][a-zA-Z0-9-]+\ [.a-zA-Z0-9-]*" + rboundry) self.matches['nntp'] = self.vte.match_add (lboundry + '''news:[-A-Z\^_a-z{|}~!"#$%&'()*+,./0-9;:=?`]+@\ [-A-Za-z0-9.]+(:[0-9]+)?''' + rboundry) # if the url looks like a Launchpad changelog closure entry # LP: #92953 - make it a url to http://bugs.launchpad.net self.matches['launchpad'] = self.vte.match_add ( '\\bLP:? #?[0-9]+\\b') def connect_signals(self): """Connect all the gtk signals and drag-n-drop mechanics""" self.vte.connect('key-press-event', self.on_keypress) self.vte.connect('button-press-event', self.on_buttonpress) self.vte.connect('popup-menu', self.popup_menu) srcvtetargets = [("vte", gtk.TARGET_SAME_APP, self.TARGET_TYPE_VTE)] dsttargets = [("vte", gtk.TARGET_SAME_APP, self.TARGET_TYPE_VTE), ('text/plain', 0, 0), ('STRING', 0, 0), ('COMPOUND_TEXT', 0, 0)] for (widget, mask) in [ (self.vte, gtk.gdk.CONTROL_MASK | gtk.gdk.BUTTON3_MASK), (self.titlebar, gtk.gdk.CONTROL_MASK)]: widget.drag_source_set(mask, srcvtetargets, gtk.gdk.ACTION_MOVE) self.vte.drag_dest_set(gtk.DEST_DEFAULT_MOTION | gtk.DEST_DEFAULT_HIGHLIGHT | gtk.DEST_DEFAULT_DROP, dsttargets, gtk.gdk.ACTION_MOVE) for widget in [self.vte, self.titlebar]: widget.connect('drag-begin', self.on_drag_begin, self) widget.connect('drag-data-get', self.on_drag_data_get, self) self.vte.connect('drag-motion', self.on_drag_motion, self) self.vte.connect('drag-data-received', self.on_drag_data_received, self) if self.config['copy_on_selection']: self.vte.connect('selection-changed', lambda widget: self.vte.copy_clipboard()) if self.composite_support: self.vte.connect('composited-changed', self.on_composited_changed) self.vte.connect('window-title-changed', self.on_vte_title_change) self.vte.connect('grab-focus', self.on_vte_focus) self.vte.connect('focus-out-event', self.on_vte_focus_out) self.vte.connect('focus-in-event', self.on_vte_focus_in) self.vte.connect('resize-window', self.on_resize_window) self.vte.connect('size-allocate', self.on_vte_size_allocate) if self.config['exit_action'] == 'restart': self.vte.connect('child-exited', self.spawn_child) elif self.config['exit_action'] in ('close', 'left'): self.vte.connect('child-exited', lambda x: self.emit('close-term')) self.vte.add_events(gtk.gdk.ENTER_NOTIFY_MASK) self.vte.connect('enter_notify_event', self.on_vte_notify_enter) self.vte.connect_after('realize', self.reconfigure) self.vte.connect_after('realize', self.spawn_child) def create_popup_group_menu(self, widget, event = None): """Pop up a menu for the group widget""" if event: button = event.button time = event.time else: button = 0 time = 0 menu = self.populate_group_menu() menu.show_all() menu.popup(None, None, self.position_popup_group_menu, button, time, widget) return(True) def populate_group_menu(self): """Fill out a group menu""" menu = gtk.Menu() groupitem = None item = gtk.MenuItem(_('Assign to group...')) item.connect('activate', self.create_group) menu.append(item) if len(self.terminator.groups) > 0: groupitem = gtk.RadioMenuItem(groupitem, _('None')) groupitem.set_active(self.group == None) groupitem.connect('activate', self.set_group, None) menu.append(groupitem) for group in self.terminator.groups: item = gtk.RadioMenuItem(groupitem, group, False) item.set_active(self.group == group) item.connect('toggled', self.set_group, group) menu.append(item) groupitem = item if self.group != None or len(self.terminator.groups) > 0: menu.append(gtk.MenuItem()) if self.group != None: item = gtk.MenuItem(_('Remove group %s') % self.group) item.connect('activate', self.ungroup, self.group) menu.append(item) if util.has_ancestor(self, gtk.Notebook): item = gtk.MenuItem(_('G_roup all in tab')) item.connect('activate', lambda x: self.emit('group_tab')) menu.append(item) if len(self.terminator.groups) > 0: item = gtk.MenuItem(_('Ungr_oup all in tab')) item.connect('activate', lambda x: self.emit('ungroup_tab')) menu.append(item) if len(self.terminator.groups) > 0: item = gtk.MenuItem(_('Remove all groups')) item.connect('activate', lambda x: self.emit('ungroup-all')) menu.append(item) if self.group != None: menu.append(gtk.MenuItem()) item = gtk.MenuItem(_('Close group %s') % self.group) item.connect('activate', lambda x: self.terminator.closegroupedterms(self)) menu.append(item) menu.append(gtk.MenuItem()) groupitem = None for key, value in {_('Broadcast off'):'off', _('Broadcast group'):'group', _('Broadcast all'):'all'}.items(): groupitem = gtk.RadioMenuItem(groupitem, key) groupitem.set_active(self.terminator.groupsend == self.terminator.groupsend_type[value]) groupitem.connect('activate', self.set_groupsend, self.terminator.groupsend_type[value]) menu.append(groupitem) menu.append(gtk.MenuItem()) item = gtk.CheckMenuItem(_('Split to this group')) item.set_active(self.terminator.splittogroup) item.connect('toggled', lambda x: self.do_splittogroup_toggle()) menu.append(item) item = gtk.CheckMenuItem(_('Autoclean groups')) item.set_active(self.terminator.autocleangroups) item.connect('toggled', lambda x: self.do_autocleangroups_toggle()) menu.append(item) menu.append(gtk.MenuItem()) item = gtk.MenuItem(_('Insert terminal number')) item.connect('activate', lambda x: self.emit('enumerate', False)) menu.append(item) item = gtk.MenuItem(_('Insert padded terminal number')) item.connect('activate', lambda x: self.emit('enumerate', True)) menu.append(item) return(menu) def position_popup_group_menu(self, menu, widget): """Calculate the position of the group popup menu""" screen_w = gtk.gdk.screen_width() screen_h = gtk.gdk.screen_height() widget_win = widget.get_window() widget_x, widget_y = widget_win.get_origin() widget_w, widget_h = widget_win.get_size() menu_w, menu_h = menu.size_request() if widget_y + widget_h + menu_h > screen_h: menu_y = max(widget_y - menu_h, 0) else: menu_y = widget_y + widget_h return(widget_x, menu_y, 1) def create_group(self, item): """Create a new group""" pass def set_groupsend(self, widget, value): """Set the groupsend mode""" # FIXME: Can we think of a smarter way of doing this than poking? self.terminator.groupsend = value def do_splittogroup_toggle(self): """Toggle the splittogroup mode""" # FIXME: Can we think of a smarter way of doing this than poking? self.terminator.splittogroup = not self.terminator.splittogroup def do_autocleangroups_toggle(self): """Toggle the autocleangroups mode""" # FIXME: Can we think of a smarter way of doing this than poking? self.terminator.autocleangroups = not self.terminator.autocleangroups def reconfigure(self, widget=None): """Reconfigure our settings""" pass def get_window_title(self): """Return the window title""" return(self.vte.get_window_title() or str(self.command)) def on_group_button_press(self, widget, event): """Handler for the group button""" if event.button == 1: self.create_popup_group_menu(widget, event) return(False) def on_keypress(self, widget, event): """Handler for keyboard events""" pass def on_buttonpress(self, widget, event): """Handler for mouse events""" # Any button event should grab focus widget.grab_focus() if event.button == 1: # Ctrl+leftclick on a URL should open it if event.state & gtk.gdk.CONTROL_MASK == gtk.gdk.CONTROL_MASK: url = self.check_for_url(event) if url: self.open_url(url, prepare=True) elif event.button == 2: # middleclick should paste the clipboard self.paste_clipboard(True) return(True) elif event.button == 3: # rightclick should display a context menu if Ctrl is not pressed if event.state & gtk.gdk.CONTROL_MASK == 0: self.popup_menu(widget, event) return(True) return(False) def popup_menu(self, widget, event=None): """Display the context menu""" menu = gtk.Menu() url = None button = None time = None if event: url = self.check_for_url(event) button = event.button time = event.time if url: if url[1] == self.matches['email']: nameopen = _('_Send email to...') namecopy = _('_Copy email address') elif url[1] == self.matches['voip']: nameopen = _('Ca_ll VoIP address') namecopy = _('_Copy VoIP address') else: nameopen = _('_Open link') namecopy = _('_Copy address') icon = gtk.image_new_from_stock(gtk.STOCK_JUMP_TO, gtk.ICON_SIZE_MENU) item = gtk.ImageMenuItem(nameopen) item.set_property('image', icon) item.connect('activate', lambda x: self.open_url(url, True)) menu.append(item) item = gtk.MenuItem(namecopy) item.connect('activate', lambda x: self.clipboard.set_text(url[0])) menu.append(item) menu.append(gtk.MenuItem()) item = gtk.ImageMenuItem(gtk.STOCK_COPY) item.connect('activate', lambda x: self.vte.copy_clipboard()) item.set_sensitive(self.vte.get_has_selection()) menu.append(item) item = gtk.ImageMenuItem(gtk.STOCK_PASTE) item.connect('activate', lambda x: self.paste_clipboard()) menu.append(item) menu.append(gtk.MenuItem()) #FIXME: These split/tab items should be conditional on not being zoomed if True: item = gtk.ImageMenuItem('Split H_orizontally') image = gtk.Image() image.set_from_icon_name(APP_NAME + '_horiz', gtk.ICON_SIZE_MENU) item.set_image(image) if hasattr(item, 'set_always_show_image'): item.set_always_show_image(True) item.connect('activate', lambda x: self.emit('split-horiz')) menu.append(item) item = gtk.ImageMenuItem('Split V_ertically') image = gtk.Image() image.set_from_icon_name(APP_NAME + '_vert', gtk.ICON_SIZE_MENU) item.set_image(image) if hasattr(item, 'set_always_show_image'): item.set_always_show_image(True) item.connect('activate', lambda x: self.emit('split-vert')) menu.append(item) item = gtk.MenuItem(_('Open _Tab')) item.connect('activate', lambda x: self.emit('tab-new')) menu.append(item) if self.config['extreme_tabs']: item = gtk.MenuItem(_('Open top level tab')) item.connect('activate', lambda x: self.emit('tab-top-new')) menu.append(item) menu.append(gtk.MenuItem()) item = gtk.ImageMenuItem(gtk.STOCK_CLOSE) item.connect('activate', lambda x: self.emit('close-term')) menu.append(item) menu.append(gtk.MenuItem()) # FIXME: Add menu items for (un)zoom, (un)maximise, (un)showing # scrollbar, (un)showing titlebar, profile editing, encodings menu.show_all() menu.popup(None, None, None, button, time) return(True) def on_drag_begin(self, widget, drag_context, data): pass def on_drag_data_get(self, widget, drag_context, selection_data, info, time, data): pass def on_drag_motion(self, widget, drag_context, x, y, time, data): pass def on_drag_data_received(self, widget, drag_context, x, y, selection_data, info, time, data): pass def on_vte_title_change(self, widget): self.emit('title-change', self.get_window_title()) def on_vte_focus(self, widget): pass def on_vte_focus_out(self, widget, event): pass def on_vte_focus_in(self, widget, event): pass def on_edit_done(self, widget): """A child widget is done editing a label, return focus to VTE""" self.vte.grab_focus() def on_resize_window(self): pass def on_vte_size_allocate(self, widget, allocation): self.titlebar.update_terminal_size(self.vte.get_column_count(), self.vte.get_row_count()) pass def on_vte_notify_enter(self, term, event): pass def hide_titlebar(self): self.titlebar.hide() def show_titlebar(self): self.titlebar.show() def spawn_child(self, widget=None): update_records = self.config['update_records'] login = self.config['login_shell'] args = [] shell = None command = None if self.config['use_custom_command']: command = self.config['custom_command'] shell = util.shell_lookup() if self.config['login_shell']: args.insert(0, "-%s" % shell) else: args.insert(0, shell) if command is not None: args += ['-c', command] if shell is None: self.vte.feed(_('Unable to find a shell')) return(-1) os.putenv('WINDOWID', '%s' % self.vte.get_parent_window().xid) self.pid = self.vte.fork_command(command=shell, argv=args, envv=[], loglastlog=login, logwtmp=update_records, logutmp=update_records, directory=self.cwd) self.command = shell self.on_vte_title_change(self.vte) self.titlebar.update() if self.pid == -1: self.vte.feed(_('Unable to start shell:') + shell) return(-1) def check_for_url(self, event): """Check if the mouse is over a URL""" return (self.vte.match_check(int(event.x / self.vte.get_char_width()), int(event.y / self.vte.get_char_height()))) def prepare_url(self, urlmatch): """Prepare a URL from a VTE match""" url = urlmatch[0] match = urlmatch[1] if match == self.matches['email'] and url[0:7] != 'mailto:': url = 'mailto:' + url elif match == self.matches['addr_only'] and url[0:3] == 'ftp': url = 'ftp://' + url elif match == self.matches['addr_only']: url = 'http://' + url elif match == self.matches['launchpad']: for item in re.findall(r'[0-9]+', url): url = 'https://bugs.launchpad.net/bugs/%s' % item return(url) else: return(url) def open_url(self, url, prepare=False): """Open a given URL, conditionally unpacking it from a VTE match""" if prepare == True: url = self.prepare_url(url) dbg('open_url: URL: %s (prepared: %s)' % (url, prepare)) try: subprocess.Popen(['xdg-open', url]) except OSError: dbg('open_url: xdg-open failed') try: self.terminator.url_show(url) except: dbg('open_url: url_show failed. Giving up') def paste_clipboard(self, primary=False): """Paste one of the two clipboards""" for term in self.terminator.get_target_terms(): if primary: term.vte.paste_primary() else: term.vte.paste_clipboard() self.vte.grab_focus() def feed(self, text): """Feed the supplied text to VTE""" self.vte.feed_child(text) gobject.type_register(Terminal) # vim: set expandtab ts=4 sw=4: