# TerminatorConfig - layered config classes # Copyright (C) 2006-2010 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 Classes relating to configuration >>> DEFAULTS['global_config']['focus'] 'click' >>> config = Config() >>> config['focus'] = 'sloppy' >>> config['focus'] 'sloppy' >>> DEFAULTS['global_config']['focus'] 'click' >>> config2 = Config() >>> config2['focus'] 'sloppy' >>> config2['focus'] = 'click' >>> config2['focus'] 'click' >>> config['focus'] 'click' >>> config['geometry_hinting'].__class__.__name__ 'bool' >>> plugintest = {} >>> plugintest['foo'] = 'bar' >>> config.plugin_set_config('testplugin', plugintest) >>> config.plugin_get_config('testplugin') {'foo': 'bar'} >>> config.plugin_get('testplugin', 'foo') 'bar' >>> config.plugin_get('testplugin', 'foo', 'new') 'bar' >>> config.plugin_get('testplugin', 'algo') Traceback (most recent call last): ... KeyError: 'ConfigBase::get_item: unknown key algo' >>> config.plugin_get('testplugin', 'algo', 1) 1 >>> config.plugin_get('anothertestplugin', 'algo', 500) 500 >>> config.get_profile() 'default' >>> config.set_profile('my_first_new_testing_profile') >>> config.get_profile() 'my_first_new_testing_profile' >>> config.del_profile('my_first_new_testing_profile') >>> config.get_profile() 'default' >>> config.list_profiles().__class__.__name__ 'list' >>> config.options_set({}) >>> config.options_get() {} >>> """ import os from copy import copy from configobj import ConfigObj, flatten_errors from validate import Validator from .borg import Borg from .util import dbg, err, DEBUG, get_config_dir, dict_diff from gi.repository import Gio DEFAULTS = { 'global_config': { 'dbus' : True, 'focus' : 'click', 'handle_size' : -1, 'geometry_hinting' : False, 'window_state' : 'normal', 'borderless' : False, 'extra_styling' : True, 'tab_position' : 'top', 'broadcast_default' : 'group', 'close_button_on_tab' : True, 'hide_tabbar' : False, 'scroll_tabbar' : False, 'homogeneous_tabbar' : True, 'hide_from_taskbar' : False, 'always_on_top' : False, 'hide_on_lose_focus' : False, 'sticky' : False, 'use_custom_url_handler': False, 'custom_url_handler' : '', 'disable_real_transparency' : False, 'title_hide_sizetext' : False, 'title_transmit_fg_color' : '#ffffff', 'title_transmit_bg_color' : '#c80003', 'title_receive_fg_color' : '#ffffff', 'title_receive_bg_color' : '#0076c9', 'title_inactive_fg_color' : '#000000', 'title_inactive_bg_color' : '#c0bebf', 'inactive_color_offset': 0.8, 'enabled_plugins' : ['LaunchpadBugURLHandler', 'LaunchpadCodeURLHandler', 'APTURLHandler'], 'suppress_multiple_term_dialog': False, 'always_split_with_profile': False, 'title_use_system_font' : True, 'title_font' : 'Sans 9', 'putty_paste_style' : False, 'smart_copy' : True, }, 'keybindings': { 'zoom_in' : 'plus', 'zoom_out' : 'minus', 'zoom_normal' : '0', 'new_tab' : 't', 'cycle_next' : 'Tab', 'cycle_prev' : 'Tab', 'go_next' : 'n', 'go_prev' : 'p', 'go_up' : 'Up', 'go_down' : 'Down', 'go_left' : 'Left', 'go_right' : 'Right', 'rotate_cw' : 'r', 'rotate_ccw' : 'r', 'split_horiz' : 'o', 'split_vert' : 'e', 'close_term' : 'w', 'copy' : 'c', 'paste' : 'v', 'toggle_scrollbar' : 's', 'search' : 'f', 'page_up' : '', 'page_down' : '', 'page_up_half' : '', 'page_down_half' : '', 'line_up' : '', 'line_down' : '', 'close_window' : 'q', 'resize_up' : 'Up', 'resize_down' : 'Down', 'resize_left' : 'Left', 'resize_right' : 'Right', 'move_tab_right' : 'Page_Down', 'move_tab_left' : 'Page_Up', 'toggle_zoom' : 'x', 'scaled_zoom' : 'z', 'next_tab' : 'Page_Down', 'prev_tab' : 'Page_Up', 'switch_to_tab_1' : '', 'switch_to_tab_2' : '', 'switch_to_tab_3' : '', 'switch_to_tab_4' : '', 'switch_to_tab_5' : '', 'switch_to_tab_6' : '', 'switch_to_tab_7' : '', 'switch_to_tab_8' : '', 'switch_to_tab_9' : '', 'switch_to_tab_10' : '', 'full_screen' : 'F11', 'reset' : 'r', 'reset_clear' : 'g', 'hide_window' : 'a', 'group_all' : 'g', 'group_all_toggle' : '', 'ungroup_all' : 'g', 'group_tab' : 't', 'group_tab_toggle' : '', 'ungroup_tab' : 't', 'new_window' : 'i', 'new_terminator' : 'i', 'broadcast_off' : 'o', 'broadcast_group' : 'g', 'broadcast_all' : 'a', 'insert_number' : '1', 'insert_padded' : '0', 'edit_window_title': 'w', 'edit_tab_title' : 'a', 'edit_terminal_title': 'x', 'layout_launcher' : 'l', 'next_profile' : '', 'previous_profile' : '', 'help' : 'F1' }, 'profiles': { 'default': { 'allow_bold' : True, 'audible_bell' : False, 'visible_bell' : False, 'urgent_bell' : False, 'icon_bell' : True, 'background_color' : '#000000', 'background_darkness' : 0.5, 'background_type' : 'solid', 'backspace_binding' : 'ascii-del', 'delete_binding' : 'escape-sequence', 'color_scheme' : 'grey_on_black', 'cursor_blink' : True, 'cursor_shape' : 'block', 'cursor_color' : '', 'cursor_color_fg' : True, 'term' : 'xterm-256color', 'colorterm' : 'truecolor', 'font' : 'Mono 10', 'foreground_color' : '#aaaaaa', 'show_titlebar' : True, 'scrollbar_position' : "right", 'scroll_background' : True, 'scroll_on_keystroke' : True, 'scroll_on_output' : False, 'scrollback_lines' : 500, 'scrollback_infinite' : False, 'exit_action' : 'close', 'palette' : '#2e3436:#cc0000:#4e9a06:#c4a000:\ #3465a4:#75507b:#06989a:#d3d7cf:#555753:#ef2929:#8ae234:#fce94f:\ #729fcf:#ad7fa8:#34e2e2:#eeeeec', 'word_chars' : '-,./?%&#:_', 'mouse_autohide' : True, 'login_shell' : False, 'use_custom_command' : False, 'custom_command' : '', 'use_system_font' : True, 'use_theme_colors' : False, 'bold_is_bright' : False, 'encoding' : 'UTF-8', 'active_encodings' : ['UTF-8', 'ISO-8859-1'], 'focus_on_close' : 'auto', 'force_no_bell' : False, 'cycle_term_tab' : True, 'copy_on_selection' : False, 'rewrap_on_resize' : True, 'split_to_group' : False, 'autoclean_groups' : True, 'http_proxy' : '', 'ignore_hosts' : ['localhost','127.0.0.0/8','*.local'], }, }, 'layouts': { 'default': { 'window0': { 'type': 'Window', 'parent': '' }, 'child1': { 'type': 'Terminal', 'parent': 'window0' } } }, 'plugins': { }, } class Config(object): """Class to provide a slightly richer config API above ConfigBase""" base = None profile = None system_mono_font = None system_prop_font = None system_focus = None inhibited = None def __init__(self, profile='default'): self.base = ConfigBase() self.set_profile(profile) self.inhibited = False self.connect_gsetting_callbacks() def __getitem__(self, key, default=None): """Look up a configuration item""" return(self.base.get_item(key, self.profile, default=default)) def __setitem__(self, key, value): """Set a particular configuration item""" return(self.base.set_item(key, value, self.profile)) def get_profile(self): """Get our profile""" return(self.profile) def set_profile(self, profile, force=False): """Set our profile (which usually means change it)""" options = self.options_get() if not force and options and options.profile and profile == 'default': dbg('overriding default profile to %s' % options.profile) profile = options.profile dbg('Config::set_profile: Changing profile to %s' % profile) self.profile = profile if profile not in self.base.profiles: dbg('Config::set_profile: %s does not exist, creating' % profile) self.base.profiles[profile] = copy(DEFAULTS['profiles']['default']) def add_profile(self, profile): """Add a new profile""" return(self.base.add_profile(profile)) def del_profile(self, profile): """Delete a profile""" if profile == self.profile: # FIXME: We should solve this problem by updating terminals when we # remove a profile err('Config::del_profile: Deleting in-use profile %s.' % profile) self.set_profile('default') if profile in self.base.profiles: del(self.base.profiles[profile]) options = self.options_get() if options and options.profile == profile: options.profile = None self.options_set(options) def rename_profile(self, profile, newname): """Rename a profile""" if profile in self.base.profiles: self.base.profiles[newname] = self.base.profiles[profile] del(self.base.profiles[profile]) if profile == self.profile: self.profile = newname def list_profiles(self): """List all configured profiles""" return(list(self.base.profiles.keys())) def add_layout(self, name, layout): """Add a new layout""" return(self.base.add_layout(name, layout)) def replace_layout(self, name, layout): """Replace an existing layout""" return(self.base.replace_layout(name, layout)) def del_layout(self, layout): """Delete a layout""" if layout in self.base.layouts: del(self.base.layouts[layout]) def rename_layout(self, layout, newname): """Rename a layout""" if layout in self.base.layouts: self.base.layouts[newname] = self.base.layouts[layout] del(self.base.layouts[layout]) def list_layouts(self): """List all configured layouts""" return(list(self.base.layouts.keys())) def connect_gsetting_callbacks(self): """Get system settings and create callbacks for changes""" dbg("GSetting connects for system changes") # Have to preserve these to self, or callbacks don't happen self.gsettings_interface=Gio.Settings.new('org.gnome.desktop.interface') self.gsettings_interface.connect("changed::font-name", self.on_gsettings_change_event) self.gsettings_interface.connect("changed::monospace-font-name", self.on_gsettings_change_event) self.gsettings_wm=Gio.Settings.new('org.gnome.desktop.wm.preferences') self.gsettings_wm.connect("changed::focus-mode", self.on_gsettings_change_event) def get_system_prop_font(self): """Look up the system font""" if self.system_prop_font is not None: return(self.system_prop_font) elif 'org.gnome.desktop.interface' not in Gio.Settings.list_schemas(): return else: gsettings=Gio.Settings.new('org.gnome.desktop.interface') value = gsettings.get_value('font-name') if value: self.system_prop_font = value.get_string() else: self.system_prop_font = "Sans 10" return(self.system_prop_font) def get_system_mono_font(self): """Look up the system font""" if self.system_mono_font is not None: return(self.system_mono_font) elif 'org.gnome.desktop.interface' not in Gio.Settings.list_schemas(): return else: gsettings=Gio.Settings.new('org.gnome.desktop.interface') value = gsettings.get_value('monospace-font-name') if value: self.system_mono_font = value.get_string() else: self.system_mono_font = "Mono 10" return(self.system_mono_font) def get_system_focus(self): """Look up the system focus setting""" if self.system_focus is not None: return(self.system_focus) elif 'org.gnome.desktop.interface' not in Gio.Settings.list_schemas(): return else: gsettings=Gio.Settings.new('org.gnome.desktop.wm.preferences') value = gsettings.get_value('focus-mode') if value: self.system_focus = value.get_string() return(self.system_focus) def on_gsettings_change_event(self, settings, key): """Handle a gsetting change event""" dbg('GSetting change event received. Invalidating caches') self.system_focus = None self.system_font = None self.system_mono_font = None # Need to trigger a reconfigure to change active terminals immediately if "Terminator" not in globals(): from .terminator import Terminator Terminator().reconfigure() def save(self): """Cause ConfigBase to save our config to file""" if self.inhibited is True: return(True) else: return(self.base.save()) def inhibit_save(self): """Prevent calls to save() being honoured""" self.inhibited = True def uninhibit_save(self): """Allow calls to save() to be honoured""" self.inhibited = False def options_set(self, options): """Set the command line options""" self.base.command_line_options = options def options_get(self): """Get the command line options""" return(self.base.command_line_options) def plugin_get(self, pluginname, key, default=None): """Get a plugin config value, if doesn't exist return default if specified """ return(self.base.get_item(key, plugin=pluginname, default=default)) def plugin_set(self, pluginname, key, value): """Set a plugin config value""" return(self.base.set_item(key, value, plugin=pluginname)) def plugin_get_config(self, plugin): """Return a whole config tree for a given plugin""" return(self.base.get_plugin(plugin)) def plugin_set_config(self, plugin, tree): """Set a whole config tree for a given plugin""" return(self.base.set_plugin(plugin, tree)) def plugin_del_config(self, plugin): """Delete a whole config tree for a given plugin""" return(self.base.del_plugin(plugin)) def layout_get_config(self, layout): """Return a layout""" return(self.base.get_layout(layout)) def layout_set_config(self, layout, tree): """Set a layout""" return(self.base.set_layout(layout, tree)) class ConfigBase(Borg): """Class to provide access to our user configuration""" loaded = None whined = None sections = None global_config = None profiles = None keybindings = None plugins = None layouts = None command_line_options = None def __init__(self): """Class initialiser""" Borg.__init__(self, self.__class__.__name__) self.prepare_attributes() from . import optionparse self.command_line_options = optionparse.options self.load() def prepare_attributes(self): """Set up our borg environment""" if self.loaded is None: self.loaded = False if self.whined is None: self.whined = False if self.sections is None: self.sections = ['global_config', 'keybindings', 'profiles', 'layouts', 'plugins'] if self.global_config is None: self.global_config = copy(DEFAULTS['global_config']) if self.profiles is None: self.profiles = {} self.profiles['default'] = copy(DEFAULTS['profiles']['default']) if self.keybindings is None: self.keybindings = copy(DEFAULTS['keybindings']) if self.plugins is None: self.plugins = {} if self.layouts is None: self.layouts = {} for layout in DEFAULTS['layouts']: self.layouts[layout] = copy(DEFAULTS['layouts'][layout]) def defaults_to_configspec(self): """Convert our tree of default values into a ConfigObj validation specification""" configspecdata = {} keymap = { 'int': 'integer', 'str': 'string', 'bool': 'boolean', } section = {} for key in DEFAULTS['global_config']: keytype = DEFAULTS['global_config'][key].__class__.__name__ value = DEFAULTS['global_config'][key] if keytype in keymap: keytype = keymap[keytype] elif keytype == 'list': value = 'list(%s)' % ','.join(value) keytype = '%s(default=%s)' % (keytype, value) if key == 'custom_url_handler': keytype = 'string(default="")' section[key] = keytype configspecdata['global_config'] = section section = {} for key in DEFAULTS['keybindings']: value = DEFAULTS['keybindings'][key] if value is None or value == '': continue section[key] = 'string(default=%s)' % value configspecdata['keybindings'] = section section = {} for key in DEFAULTS['profiles']['default']: keytype = DEFAULTS['profiles']['default'][key].__class__.__name__ value = DEFAULTS['profiles']['default'][key] if keytype in keymap: keytype = keymap[keytype] elif keytype == 'list': value = 'list(%s)' % ','.join(value) if keytype == 'string': value = '"%s"' % value keytype = '%s(default=%s)' % (keytype, value) section[key] = keytype configspecdata['profiles'] = {} configspecdata['profiles']['__many__'] = section section = {} section['type'] = 'string' section['parent'] = 'string' section['profile'] = 'string(default=default)' section['command'] = 'string(default="")' section['position'] = 'string(default="")' section['size'] = 'list(default=list(-1,-1))' configspecdata['layouts'] = {} configspecdata['layouts']['__many__'] = {} configspecdata['layouts']['__many__']['__many__'] = section configspecdata['plugins'] = {} configspec = ConfigObj(configspecdata) if DEBUG == True: configspec.write(open('/tmp/terminator_configspec_debug.txt', 'wb')) return(configspec) def load(self): """Load configuration data from our various sources""" if self.loaded is True: dbg('ConfigBase::load: config already loaded') return if self.command_line_options: if not self.command_line_options.config: self.command_line_options.config = os.path.join(get_config_dir(), 'config') filename = self.command_line_options.config else: filename = os.path.join(get_config_dir(), 'config') dbg('looking for config file: %s' % filename) try: configfile = open(filename, 'r') except Exception as ex: if not self.whined: err('ConfigBase::load: Unable to open %s (%s)' % (filename, ex)) self.whined = True return # If we have successfully loaded a config, allow future whining self.whined = False try: configspec = self.defaults_to_configspec() parser = ConfigObj(configfile, configspec=configspec) validator = Validator() result = parser.validate(validator, preserve_errors=True) except Exception as ex: err('Unable to load configuration: %s' % ex) return if result != True: err('ConfigBase::load: config format is not valid') for (section_list, key, _other) in flatten_errors(parser, result): if key is not None: err('[%s]: %s is invalid' % (','.join(section_list), key)) else: err('[%s] missing' % ','.join(section_list)) else: dbg('config validated successfully') for section_name in self.sections: dbg('ConfigBase::load: Processing section: %s' % section_name) section = getattr(self, section_name) if section_name == 'profiles': for profile in parser[section_name]: dbg('ConfigBase::load: Processing profile: %s' % profile) if section_name not in section: # FIXME: Should this be outside the loop? section[profile] = copy(DEFAULTS['profiles']['default']) section[profile].update(parser[section_name][profile]) elif section_name == 'plugins': if section_name not in parser: continue for part in parser[section_name]: dbg('ConfigBase::load: Processing %s: %s' % (section_name, part)) section[part] = parser[section_name][part] elif section_name == 'layouts': for layout in parser[section_name]: dbg('ConfigBase::load: Processing %s: %s' % (section_name, layout)) if layout == 'default' and \ parser[section_name][layout] == {}: continue section[layout] = parser[section_name][layout] elif section_name == 'keybindings': if section_name not in parser: continue for part in parser[section_name]: dbg('ConfigBase::load: Processing %s: %s' % (section_name, part)) if parser[section_name][part] == 'None': section[part] = None else: section[part] = parser[section_name][part] else: try: section.update(parser[section_name]) except KeyError as ex: dbg('ConfigBase::load: skipping missing section %s' % section_name) self.loaded = True def reload(self): """Force a reload of the base config""" self.loaded = False self.load() def save(self): """Save the config to a file""" dbg('ConfigBase::save: saving config') parser = ConfigObj(encoding='utf-8') parser.indent_type = ' ' for section_name in ['global_config', 'keybindings']: dbg('ConfigBase::save: Processing section: %s' % section_name) section = getattr(self, section_name) parser[section_name] = dict_diff(DEFAULTS[section_name], section) parser['profiles'] = {} for profile in self.profiles: dbg('ConfigBase::save: Processing profile: %s' % profile) parser['profiles'][profile] = dict_diff( DEFAULTS['profiles']['default'], self.profiles[profile]) parser['layouts'] = {} for layout in self.layouts: dbg('ConfigBase::save: Processing layout: %s' % layout) parser['layouts'][layout] = self.layouts[layout] parser['plugins'] = {} for plugin in self.plugins: dbg('ConfigBase::save: Processing plugin: %s' % plugin) parser['plugins'][plugin] = self.plugins[plugin] config_dir = get_config_dir() if not os.path.isdir(config_dir): os.makedirs(config_dir) try: temp_file = self.command_line_options.config + '.tmp' with open(temp_file, 'wb') as fh: parser.write(fh) os.rename(temp_file, self.command_line_options.config) except Exception as ex: err('ConfigBase::save: Unable to save config: %s' % ex) def get_item(self, key, profile='default', plugin=None, default=None): """Look up a configuration item""" if profile not in self.profiles: # Hitting this generally implies a bug profile = 'default' if key in self.global_config: dbg('ConfigBase::get_item: %s found in globals: %s' % (key, self.global_config[key])) return(self.global_config[key]) elif key in self.profiles[profile]: dbg('ConfigBase::get_item: %s found in profile %s: %s' % ( key, profile, self.profiles[profile][key])) return(self.profiles[profile][key]) elif key == 'keybindings': return(self.keybindings) elif plugin and plugin in self.plugins and key in self.plugins[plugin]: dbg('ConfigBase::get_item: %s found in plugin %s: %s' % ( key, plugin, self.plugins[plugin][key])) return(self.plugins[plugin][key]) elif default: return default else: raise KeyError('ConfigBase::get_item: unknown key %s' % key) def set_item(self, key, value, profile='default', plugin=None): """Set a configuration item""" dbg('ConfigBase::set_item: Setting %s=%s (profile=%s, plugin=%s)' % (key, value, profile, plugin)) if key in self.global_config: self.global_config[key] = value elif key in self.profiles[profile]: self.profiles[profile][key] = value elif key == 'keybindings': self.keybindings = value elif plugin is not None: if plugin not in self.plugins: self.plugins[plugin] = {} self.plugins[plugin][key] = value else: raise KeyError('ConfigBase::set_item: unknown key %s' % key) return(True) def get_plugin(self, plugin): """Return a whole tree for a plugin""" if plugin in self.plugins: return(self.plugins[plugin]) def set_plugin(self, plugin, tree): """Set a whole tree for a plugin""" self.plugins[plugin] = tree def del_plugin(self, plugin): """Delete a whole tree for a plugin""" if plugin in self.plugins: del self.plugins[plugin] def add_profile(self, profile): """Add a new profile""" if profile in self.profiles: return(False) self.profiles[profile] = copy(DEFAULTS['profiles']['default']) return(True) def add_layout(self, name, layout): """Add a new layout""" if name in self.layouts: return(False) self.layouts[name] = layout return(True) def replace_layout(self, name, layout): """Replaces a layout with the given name""" if not name in self.layouts: return(False) self.layouts[name] = layout return(True) def get_layout(self, layout): """Return a layout""" if layout in self.layouts: return(self.layouts[layout]) else: err('layout does not exist: %s' % layout) def set_layout(self, layout, tree): """Set a layout""" self.layouts[layout] = tree