#!/usr/bin/python # Terminator - multiple gnome terminals in one window # Copyright (C) 2006-2008 cmsj@tenshu.net # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, version 2 only. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA """Terminator by Chris Jones """ # import standard python libs import os, sys, string, time, math from optparse import OptionParser import gettext gettext.install ('terminator') # import unix-lib import pwd # import gtk libs # check just in case anyone runs it on a non-gnome system. try: import gobject, gtk, gconf, pango except: print >> sys.stderr, _("You need to install the python bindings for " \ "gobject, gtk, gconf and pango to run Terminator.") sys.exit(1) # import a library for viewing URLs try: # gnome.url_show() is really useful import gnome url_show = gnome.url_show except: # webbrowser.open() is not really useful, but will do as a fallback import webbrowser url_show = webbrowser.open # import vte-bindings try: import vte except: error = gtk.MessageDialog (None, gtk.DIALOG_MODAL, gtk.MESSAGE_ERROR, gtk.BUTTONS_OK, _('You need to install python bindings for libvte ("python-vte" in debian/ubuntu)')) error.run() sys.exit (1) class TerminatorTerm: # Our settings # FIXME: Add commandline and/or gconf options to change these defaults = { 'gt_dir' : '/apps/gnome-terminal', '_profile_dir' : '%s/profiles', 'allow_bold' : True, 'silent_bell' : True, 'background_color' : '#000000', 'background_darkness' : 0.5, 'background_type' : 'solid', 'backspace_binding' : 'ascii-del', 'delete_binding' : 'delete-sequence', 'cursor_blink' : False, 'emulation' : 'xterm', 'font' : 'Serif 10', 'foreground_color' : '#AAAAAA', 'scrollbar_position' : "right", 'scroll_background' : True, 'scroll_on_keystroke' : False, 'scroll_on_output' : False, 'scrollback_lines' : 100, 'focus' : 'sloppy', 'child_restart' : False, 'link_scheme' : '(news|telnet|nttp|file|http|ftp|https)', '_link_user' : '[%s]+(:[%s]+)?', 'link_hostchars' : '-A-Za-z0-9', 'link_userchars' : '-A-Za-z0-9', 'link_passchars' : '-A-Za-z0-9,?;.:/!%$^*&~"#\'', '_palette' : '%s/palette', 'default_palette' : '#000000000000:#CDCD00000000:#0000CDCD0000:#CDCDCDCD0000:#30BF30BFA38E:#A53C212FA53C:#0000CDCDCDCD:#FAFAEBEBD7D7:#404040404040:#FFFF00000000:#0000FFFF0000:#FFFFFFFF0000:#00000000FFFF:#FFFF0000FFFF:#0000FFFFFFFF:#FFFFFFFFFFFF', 'word_chars' : '-A-Za-z0-9,./?%&#:_', 'mouse_autohide' : True, } matches = {} def __init__ (self, terminator, profile = None, command = None): self.defaults['profile_dir'] = self.defaults['_profile_dir']%(self.defaults['gt_dir']) self.defaults['link_user'] = self.defaults['_link_user']%(self.defaults['link_userchars'], self.defaults['link_passchars']) self.terminator = terminator self.gconf_client = gconf.client_get_default () self.command = command if profile == None: profile = self.gconf_client.get_string (self.defaults['gt_dir'] + '/global/default_profile') self.profile = "" profiles = self.gconf_client.get_list (self.defaults['gt_dir'] + '/global/profile_list', 'string') if profile in profiles: self.profile = '%s/%s'%(self.defaults['profile_dir'], profile) else: if profile != "Default" and "Default" in profiles: self.profile = '%s/Default'%(self.defaults['profile_dir']) if not self.profile: print >> sys.stderr, _("Warning: unable to find profile %s. Continue with default values...") % profile self.defaults['palette'] = self.defaults['_palette']%(self.profile) if self.profile: self.gconf_client.add_dir (self.profile, gconf.CLIENT_PRELOAD_RECURSIVE) self.gconf_client.notify_add (self.profile, self.on_gconf_notification) self.gconf_client.add_dir ('/apps/metacity/general', gconf.CLIENT_PRELOAD_RECURSIVE) self.gconf_client.notify_add ('/apps/metacity/general/focus_mode', self.on_gconf_notification) self.clipboard = gtk.clipboard_get (gtk.gdk.SELECTION_CLIPBOARD) self.scrollbar_position = self.reconf ('scrollbar_position') self._vte = vte.Terminal () self._vte.set_size (80, 24) self.reconfigure_vte () self._vte.show () self._box = gtk.HBox () self._box.show () self._scrollbar = gtk.VScrollbar (self._vte.get_adjustment ()) if self.scrollbar_position != "hidden": self._scrollbar.show () if self.scrollbar_position == 'right': packfunc = self._box.pack_start else: packfunc = self._box.pack_end packfunc (self._vte) packfunc (self._scrollbar, False) self._vte.connect ("key-press-event", self.on_vte_key_press) self._vte.connect ("button-press-event", self.on_vte_button_press) self._vte.connect ("popup-menu", self.on_vte_popup_menu) # self._vte.connect ("window-title-changed", self.on_vte_title_change) exit_action = self.gconf_client.get_string (self.profile + "/exit_action") if not exit_action: if self.defaults['child_restart']: exit_action = "restart" else: exit_action = "close" if exit_action == "restart": self._vte.connect ("child-exited", self.spawn_child) if exit_action == "close": self._vte.connect ("child-exited", lambda close_term: self.terminator.closeterm (self)) self._vte.add_events (gtk.gdk.ENTER_NOTIFY_MASK) self._vte.connect ("enter_notify_event", self.on_vte_notify_enter) self.matches['path'] = self._vte.match_add ('((%s://(%s@)?)|(www|ftp)[%s]*\\.)[%s.]+(:[0-9]+)?/[-A-Za-z0-9_$.+!*(),;:@&=?/~#%%]*[^]\'.}>) \t\r\n,\\\]'%(self.defaults['link_scheme'], self.defaults['link_userchars'], self.defaults['link_hostchars'], self.defaults['link_hostchars'])) self.matches['email'] = self._vte.match_add ('(mailto:)?[a-z0-9][a-z0-9.-]*@[a-z0-9][a-z0-9-]*(\\.[a-z0-9][a-z0-9-]*)+') self.spawn_child () def spawn_child (self, event=None): update_records = self.gconf_client.get_bool (self.profile + "/update_records") or True login = self.gconf_client.get_bool (self.profile + "/login_shell") or False if self.command: args = self.command shell = self.command[0] elif self.gconf_client.get_bool (self.profile + "/use_custom_command") == True: args = self.gconf_client.get_string (self.profile + "/custom_command").split () shell = args[0] else: shell = pwd.getpwuid (os.getuid ())[6] args = [os.path.basename (shell)] self._vte.fork_command (command = shell, argv = args, envv = [], loglastlog = login, logwtmp = update_records, logutmp = update_records) def reconf (self, property): value = self.gconf_client.get ('%s/%s'%(self.profile, property)) ret = None if not value: try: ret = self.defaults[property] except: pass else: if value.type == gconf.VALUE_STRING: ret = value.get_string () elif value.type == gconf.VALUE_INT: ret = value.get_int () elif value.type == gconf.VALUE_FLOAT: ret = value.get_float () elif value.type == gconf.VALUE_BOOL: ret = value.get_bool () if ret == None: print >> sys.stderr, _('Unknown value requested. Unable to find in gconf profile or default settings: ') + property sys.exit (1) return ret def reconfigure_vte (self): # Set our emulation self._vte.set_emulation (self.defaults['emulation']) # Set our wordchars self._vte.set_word_chars (self.reconf ('word_chars')) # Set our mouselation self._vte.set_mouse_autohide (self.defaults['mouse_autohide']) # Set our compatibility backspace = self.reconf ('backspace_binding') delete = self.reconf ('delete_binding') # Note, each of the 4 following comments should replace the line beneath it, but the python-vte bindings don't appear to support this constant, so the magic values are being assumed from the C enum :/ if backspace == "ascii-del": # backbind = vte.ERASE_ASCII_BACKSPACE backbind = 2 else: # backbind = vte.ERASE_AUTO_BACKSPACE backbind = 1 if delete == "escape-sequence": # delbind = vte.ERASE_DELETE_SEQUENCE delbind = 3 else: # delbind = vte.ERASE_AUTO delbind = 0 self._vte.set_backspace_binding (backbind) self._vte.set_delete_binding (delbind) # Set our font, preferably from gconf settings if self.gconf_client.get_bool (self.profile + "/use_system_font"): font_name = (self.gconf_client.get_string ("/desktop/gnome/interface/monospace_font_name") or self.defaults['font']) else: font_name = self.reconf ('font') try: self._vte.set_font (pango.FontDescription (font_name)) except: pass # Set our boldness self._vte.set_allow_bold (self.reconf ('allow_bold')) # Set our color scheme, preferably from gconf settings # FIXME: This is wrong, we should be pulling 'palette' out of gconf, but reverting to self.defaults['default_palette'] which means we need to reorganise self.defaults to make this available under the same name as gconf palette = self.reconf ('default_palette') if (not self.profile) or self.gconf_client.get_bool (self.profile + "/use_theme_colors"): fg_color = self._vte.get_style ().text[gtk.STATE_NORMAL] bg_color = self._vte.get_style ().base[gtk.STATE_NORMAL] else: fg_color = gtk.gdk.color_parse (self.reconf ('foreground_color')) bg_color = gtk.gdk.color_parse (self.reconf ('background_color')) # Set our background image, transparency and type background_type = self.reconf ('background_type') if background_type == "solid": self._vte.set_background_image_file ('') self._vte.set_background_transparent (False) if background_type == "image": self._vte.set_background_image_file (self.reconf ('background_image')) self._vte.set_scroll_background (self.reconf ('scroll_background')) self._vte.set_background_transparent (False) if background_type == "transparent": self._vte.set_background_transparent (True) self._vte.set_background_saturation (1 - (self.reconf ('background_darkness'))) colors = palette.split (':') palette = [] for color in colors: if color: palette.append (gtk.gdk.color_parse (color)) self._vte.set_colors (fg_color, bg_color, palette) # Set our cursor blinkiness self._vte.set_cursor_blinks = (self.reconf ('cursor_blink')) # Set our audible belliness silent_bell = self.reconf ('silent_bell') self._vte.set_audible_bell = not silent_bell self._vte.set_visible_bell = silent_bell # Set our scrolliness self._vte.set_scrollback_lines (self.reconf ('scrollback_lines')) self._vte.set_scroll_on_keystroke (self.reconf ('scroll_on_keystroke')) self._vte.set_scroll_on_output (self.reconf ('scroll_on_output')) scrollbar_position = self.reconf ('scrollbar_position') if scrollbar_position != self.scrollbar_position: if scrollbar_position == 'hidden': self._scrollbar.hide () else: self._scrollbar.show () if scrollbar_position == 'right': self._box.remove (self._scrollbar) self._box.remove (self._vte) self._box.pack_start (self._vte) self._box.pack_start (self._scrollbar) elif scrollbar_position == 'left': self._box.remove (self._vte) self._box.remove (self._scrollbar) self._box.pack_start(self._scrollbar) self._box.pack_start(self._vte) self.scrollbar_position = scrollbar_position # Set our sloppiness self.focus = self.gconf_client.get_string ("/apps/metacity/general/focus_mode") or self.defaults['focus'] def on_gconf_notification (self, client, cnxn_id, entry, what): self.reconfigure_vte () def on_vte_button_press (self, term, event): # Left mouse button should transfer focus to this vte widget if event.button == 1: self._vte.grab_focus () return False # Right mouse button should display a context menu if event.button == 3: self.do_popup (event) return True def on_vte_notify_enter (self, term, event): if (self.focus == "sloppy" or self.focus == "mouse"): term.grab_focus () return False def do_scrollbar_toggle (self): if self._scrollbar.get_property ('visible'): self._scrollbar.hide () else: self._scrollbar.show () #keybindings for the individual splited terminals (affects only the #the selected terminal) def on_vte_key_press (self, term, event): keyname = gtk.gdk.keyval_name (event.keyval) mask = gtk.gdk.CONTROL_MASK if (event.state & mask) == mask: if keyname == 'plus': self.zoom (True) return (True) elif keyname == 'minus': self.zoom (False) return (True) # bindings that should be moved to Terminator as they all just call # a function of Terminator. It would be cleaner is TerminatorTerm # has absolutely no reference to Terminator. # N (next) - P (previous) - O (horizontal) - E (vertical) - W (close) mask = gtk.gdk.CONTROL_MASK | gtk.gdk.SHIFT_MASK if (event.state & mask) == mask: if keyname == 'N': self.terminator.go_next (self) return (True) elif keyname == "P": self.terminator.go_prev (self) return (True) elif keyname == 'O': self.terminator.splitaxis (self, False) return (True) elif keyname == 'E': self.terminator.splitaxis (self, True) return (True) elif keyname == 'W': self.terminator.closeterm (self) return (True) elif keyname == 'C': self._vte.copy_clipboard () return (True) elif keyname == 'V': self._vte.paste_clipboard () return (True) if keyname and (keyname == 'Tab' or keyname.endswith('_Tab')): if event.state == gtk.gdk.CONTROL_MASK: self.terminator.go_next (self) return (True) if (event.state & mask) == mask: self.terminator.go_prev (self) return (True) return (False) def zoom (self, zoom_in): pangodesc = self._vte.get_font () fontsize = pangodesc.get_size () if fontsize > pango.SCALE and not zoom_in: fontsize -= pango.SCALE elif zoom_in: fontsize += pango.SCALE pangodesc.set_size (fontsize) self._vte.set_font (pangodesc) def on_vte_popup_menu (self, term): self.do_popup () def do_popup (self, event = None): menu = self.create_popup_menu (event) menu.popup (None, None, None, event.button, event.time) def create_popup_menu (self, event): menu = gtk.Menu () url = None if event: url = self._vte.match_check (int (event.x / self._vte.get_char_width ()), int (event.y / self._vte.get_char_height ())) if url: if url[1] != self.matches['email']: address = url[0] nameopen = _("_Open Link") namecopy = _("_Copy Link Address") else: if url[0][0:7] != "mailto:": address = "mailto:" + url[0] else: address = url[0] nameopen = _("_Send Mail To...") namecopy = _("_Copy Email Address") item = gtk.MenuItem (nameopen) item.connect ("activate", lambda menu_item: url_show (address)) menu.append (item) item = gtk.MenuItem (namecopy) item.connect ("activate", lambda menu_item: self.clipboard.set_text (url[0])) menu.append (item) item = gtk.MenuItem () menu.append (item) item = gtk.ImageMenuItem (gtk.STOCK_COPY) item.connect ("activate", lambda menu_item: 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 menu_item: self._vte.paste_clipboard ()) menu.append (item) item = gtk.MenuItem () menu.append (item) item = gtk.CheckMenuItem (_("Show scrollbar")) item.set_active (self._scrollbar.get_property ('visible')) item.connect ("toggled", lambda menu_item: self.do_scrollbar_toggle ()) menu.append (item) item = gtk.MenuItem () menu.append (item) item = gtk.MenuItem (_("Split H_orizontally")) item.connect ("activate", lambda menu_item: self.terminator.splitaxis (self, False)) menu.append (item) item = gtk.MenuItem (_("Split V_ertically")) item.connect ("activate", lambda menu_item: self.terminator.splitaxis (self, True)) menu.append (item) item = gtk.MenuItem () menu.append (item) item = gtk.ImageMenuItem (gtk.STOCK_CLOSE) item.connect ("activate", lambda menu_item: self.terminator.closeterm (self)) menu.append (item) menu.show_all () return menu def on_vte_title_change(self, vte): vte.set_property ("has-tooltip", True) vte.set_property ("tooltip-text", vte.get_window_title ()) def get_box (self): return self._box class Terminator: def __init__ (self, profile, command = None): self.profile = profile self.gconf_client = gconf.client_get_default () self.command = command self._fullscreen = False self.window = gtk.Window () self.window.set_title ("Terminator") # FIXME: This really shouldn't be a hardcoded path try: self.window.set_icon_from_file ("/usr/share/icons/hicolor/48x48/apps/terminator.png") except: self.icon = self.window.render_icon (gtk.STOCK_DIALOG_INFO, gtk.ICON_SIZE_BUTTON) self.window.set_icon (self.icon) self.window.connect ("key-press-event", self.on_key_press) self.window.connect ("delete_event", self.on_delete_event) self.window.connect ("destroy", self.on_destroy_event) self.window.set_property ('allow-shrink', True) # Start out with just one terminal # FIXME: This should be really be decided from some kind of profile term = (TerminatorTerm (self, self.profile, self.command)) self.term_list = [term] self.window.add (term.get_box ()) self.window.show () def maximize (self): """ Maximize the Terminator.""" self.window.maximize () def toggle_fullscreen (self): """ Toggle the fullscreen state of the window. If it is in fullscreen state, it will be unfullscreened. If it is not, it will be set to fullscreen state. """ if self._fullscreen: self.window.unfullscreen () else: self.window.fullscreen () self._fullscreen = not self._fullscreen def on_delete_event (self, window, event, data=None): if len (self.term_list) == 1: return False # show dialog dialog = gtk.Dialog (_("Close?"), window, gtk.DIALOG_MODAL, (gtk.STOCK_CANCEL, gtk.RESPONSE_REJECT, gtk.STOCK_CLOSE, gtk.RESPONSE_ACCEPT)) dialog.set_has_separator (False) dialog.set_resizable (False) primairy = gtk.Label (_('Close all terminals?')) primairy.set_use_markup (True) primairy.set_alignment (0, 0.5) secundairy = gtk.Label (_("This window has %s terminals open. Closing the window will also close all terminals.") % len(self.term_list)) secundairy.set_line_wrap(True) primairy.set_alignment (0, 0.5) labels = gtk.VBox () labels.pack_start (primairy, False, False, 6) labels.pack_start (secundairy, False, False, 6) image = gtk.image_new_from_stock(gtk.STOCK_DIALOG_WARNING, gtk.ICON_SIZE_DIALOG) image.set_alignment (0.5, 0) box = gtk.HBox() box.pack_start (image, False, False, 6) box.pack_start (labels, False, False, 6) dialog.vbox.pack_start (box, False, False, 12) dialog.show_all () result = dialog.run () dialog.destroy () return not (result == gtk.RESPONSE_ACCEPT) def on_destroy_event (self, widget, data=None): gtk.main_quit () # keybindings for the whole terminal window (affects the main # windows containing the splited terminals) def on_key_press (self, window, event): """ Callback for the window to determine what to do with special keys. Currently handled key-combo's: * F11: toggle fullscreen state of the window. * CTRL - SHIFT - Q: close all terminals """ keyname = gtk.gdk.keyval_name (event.keyval) mask = gtk.gdk.CONTROL_MASK | gtk.gdk.SHIFT_MASK if (keyname == 'F11'): self.toggle_fullscreen () return (True) if (event.state & mask) == mask: if keyname == 'Q': if not self.on_delete_event (window, gtk.gdk.Event (gtk.gdk.DELETE)): self.on_destroy_event (window, gtk.gdk.Event (gtk.gdk.DESTROY)) def splitaxis (self, widget, vertical=True): """ Split the provided widget on the horizontal or vertical axis. """ # create a new terminal and parent pane. terminal = TerminatorTerm (self, self.profile, None) pane = (vertical) and gtk.VPaned () or gtk.HPaned () # get the parent of the provided terminal parent = widget.get_box ().get_parent () if isinstance (parent, gtk.Window): # We have just one term widget.get_box ().reparent (pane) pane.pack1 (widget.get_box (), True, True) pane.pack2 (terminal.get_box (), True, True) parent.add (pane) position = (vertical) and parent.allocation.height or parent.allocation.width if isinstance (parent, gtk.Paned): # We are inside a split term position = (vertical) and widget.get_box().allocation.height or widget.get_box().allocation.width if (widget.get_box () == parent.get_child1 ()): widget.get_box ().reparent (pane) parent.pack1 (pane, True, True) else: widget.get_box ().reparent (pane) parent.pack2 (pane, True, True) pane.pack1 (widget.get_box (), True, True) pane.pack2 (terminal.get_box (), True, True) # show all, set position of the divider pane.show () pane.set_position (position / 2) terminal.get_box ().show () # insert the term reference into the list index = self.term_list.index (widget) self.term_list.insert (index + 1, terminal) # make the new terminal grab the focus terminal._vte.grab_focus () return (terminal) def closeterm (self, widget): parent = widget.get_box ().get_parent () sibling = None if isinstance (parent, gtk.Window): # We are the only term if not self.on_delete_event (parent, gtk.gdk.Event (gtk.gdk.DELETE)): self.on_destroy_event (parent, gtk.gdk.Event (gtk.gdk.DESTROY)) return if isinstance (parent, gtk.Paned): index = self.term_list.index (widget) grandparent = parent.get_parent () # Discover sibling while all objects exist if widget.get_box () == parent.get_child1 (): sibling = parent.get_child2 () if widget.get_box () == parent.get_child2 (): sibling = parent.get_child1 () if not sibling: # something is wrong, give up print >> sys.stderr, "Error: %s is not a child of %s"%(widget, parent) return self.term_list.remove (widget) grandparent.remove (parent) sibling.reparent (grandparent) widget.get_box ().destroy () parent.destroy () if not isinstance (sibling, gtk.Paned): for term in self.term_list: if term.get_box () == sibling: term._vte.grab_focus () break else: if index == 0: index = 1 self.term_list[index - 1]._vte.grab_focus () return def go_next (self, term): current = self.term_list.index (term) next = current if current == len (self.term_list) - 1: next = 0 else: next += 1 self.term_list[next]._vte.grab_focus () def go_prev (self, term): current = self.term_list.index (term) previous = current if current == 0: previous = len (self.term_list) - 1 else: previous -= 1 #self.window.set_title(self.term_list[previous]._vte.get_window_title()) self.term_list[previous]._vte.grab_focus () def execute_cb (option, opt, value, parser): assert value is None value = [] while parser.rargs: arg = parser.rargs[0] value.append (arg) del (parser.rargs[0]) setattr(parser.values, option.dest, value) if __name__ == '__main__': usage = "usage: %prog [options]" parser = OptionParser (usage) parser.add_option ("-d", "--debug", action="store_true", dest="debug", help="Enable debugging information") parser.add_option ("-m", "--maximise", action="store_true", dest="maximise", help="Open the Terminator window maximised") parser.add_option ("-f", "--fullscreen", action="store_true", dest="fullscreen", help="Set the window into fullscreen mode") parser.add_option ("-b", "--borderless", action="store_true", dest="borderless", help="Turn off the window's borders") parser.add_option ("-p", "--profile", dest="profile", help="Specify a GNOME Terminal profile to emulate") parser.add_option ("-e", "--command", dest="command", help="Execute the argument to this option inside the terminal") parser.add_option ("-x", "--execute", dest="execute", action="callback", callback=execute_cb, help="Execute the remainder of the command line inside the terminal") (options, args) = parser.parse_args () if len (args) != 0: parser.error("Expecting zero additional arguments, found: %d"%len (args)) command = [] if (options.command): command.append (options.command) if (options.execute): command = options.execute term = Terminator (options.profile, command) # Set the Terminator in fullscreen state or maximize it. # Fullscreen and maximise are mutually exclusive, with # fullscreen taking precedence over maximise. if options.fullscreen: term.toggle_fullscreen () elif options.maximise: term.maximize () if options.borderless: term.window.set_decorated (False) gtk.main ()