Source code for pympress.config

# -*- coding: utf-8 -*-
#       Copyright 2017 Cimbali <>
#       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; either version 2 of the License, or
#       (at your option) any later version.
#       This program is distributed in the hope that it will be useful,
#       but WITHOUT ANY WARRANTY; without even the implied warranty of
#       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.
:mod:`pympress.config` -- Configuration

import logging
logger = logging.getLogger(__name__)

import os
import pathlib
import json
import configparser
from collections import deque

import gi
gi.require_version('Gtk', '3.0')
from gi.repository import Gtk, GLib, Gio

from pympress import util

[docs] class Config(configparser.ConfigParser, object): # python 2 fix """ Manage configuration :Get the configuration from its file and store its back. """ #: `dict`-tree of presenter layouts for various modes layout = {} #: `dict`-tree of presenter layouts that are not configurable static_layout = { 'deck-overview': 'deck', } #: `dict` of strings that are the valid representations of widgets from the presenter window #: that can be dynamically rearranged, mapping to their names placeable_widgets = {"notes": "p_frame_notes", "current": "p_frame_cur", "next": "grid_next", "annotations": "p_frame_annot", "highlight": "scribble_overlay"} #: `dict` mapping layout ids to tuples of their expected and optional widgets widget_reqs = { 'notes': (set(placeable_widgets.keys()) - {"annotations", "highlight"}, {"annotations"}), 'plain': (set(placeable_widgets.keys()) - {"notes", "highlight"},), 'note_pages': (set(placeable_widgets.keys()) - {"current", "highlight"},), 'highlight': ({"highlight"}, set(placeable_widgets.keys()) - {"highlight"}), 'highlight_notes': ({"highlight"}, set(placeable_widgets.keys()) - {"highlight"}), } #: `dict` mapping accelerator keys to actions shortcuts = {}
[docs] @staticmethod def path_to_config(search_legacy_locations=False): """ Return the path to the currently used configuration file. Args: search_legacy_locations (`bool`): whether to look in previously used locations Returns: :class:`~pathlib.Path`: The path to the config file to use """ if Config.using_portable_config(): return util.get_portable_config() user_config = util.get_user_config() # migrate old configuration files from previously-used erroneous locations if search_legacy_locations and (util.IS_POSIX or util.IS_MAC_OS) and not user_config.exists(): for legacy_location in ['~/.pympress', '~/.config/pympress']: legacy_location = pathlib.Path(legacy_location).expanduser() if legacy_location.exists(): legacy_location.rename(user_config) return user_config
[docs] @staticmethod def toggle_portable_config(gaction, param=None): """ Create or remove a configuration file for portable installs. The portable install file will be used by default, and deleting it causes the config to fall back to the user profile location. No need to populate the new config file, this will be done on pympress exit. Args: gaction (:class:`~Gio.Action`): the action triggering the call param (:class:`~GLib.Variant`): the parameter as a variant, or None """ portable_config = util.get_portable_config() if portable_config is None: return if Config.using_portable_config(): portable_config.unlink() else: with open(portable_config, 'w'): pass gaction.set_state(GLib.Variant.new_boolean(Config.using_portable_config()))
[docs] @staticmethod def using_portable_config(): """ Checks which configuration file location is in use. Returns: `bool`: `True` iff we are using the portable (i.e. in install dir) location """ portable_config = util.get_portable_config() return portable_config is not None and portable_config.exists()
def __init__(config): # Remove : from delimiters so we can use it in preferences super(Config, config).__init__(delimiters=('=',)) # populate values first from the default config file, then from the proper one config.load_window_layouts() # modify defaults if 'I3SOCK' in os.environ:'Detected i3 window manager, disabled content window starting fullscreen by default') config.set('content', 'start_fullscreen', 'off') all_commands = dict(config.items('shortcuts')).keys() config.upgrade() config.load_window_layouts() for command in all_commands: # NB only parsing commands from defaults parse_ok, action_name, target_value = Gio.Action.parse_detailed_name('app.' + command) if not parse_ok or not Gio.action_name_is_valid(action_name): logger.error('Failed parsing command ' + command) continue parsed_accels = {keys: Gtk.accelerator_parse(keys) for keys in config.get('shortcuts', command).split()} failed = [keys for keys, parsed in parsed_accels.items() if parsed == (0, 0)] if failed: logger.warning('Failed parsing shortcut(s) for "{}": "{}"'.format(command, '", "'.join(failed))) keep_accels = [keys for keys, parsed in parsed_accels.items() if parsed != (0, 0)] if keep_accels: config.shortcuts[command] = keep_accels
[docs] def register_actions(self, builder): """ Register actions that impact the config file only. Args: builder (:class:`pympress.builder.Builder`): a builder to setup the actions """ p_full = self.getboolean('presenter', 'start_fullscreen') c_full = self.getboolean('content', 'start_fullscreen') blank = self.getboolean('content', 'start_blanked') portable = self.using_portable_config() can_port = util.get_portable_config() is not None builder.setup_actions({ 'start-content-fullscreen': dict(activate=self.toggle_start, state=c_full), 'start-presenter-fullscreen': dict(activate=self.toggle_start, state=p_full), 'start-blanked': dict(activate=self.toggle_start, state=blank), 'portable-config': dict(activate=self.toggle_portable_config, state=portable, enabled=can_port), })
[docs] def upgrade(self): """ Update obsolete config options when pympress updates. """ if self.get('presenter', 'pointer') == 'pointer_none': self.set('presenter', 'pointer', 'red') self.set('presenter', 'pointer_mode', 'disabled') if self.has_section('scribble') and self.has_option('scribble', 'color'): self.set('scribble', 'color_9', self.get('scribble', 'color')) self.remove_option('scribble', 'color') self.set('scribble', 'active_pen', '9') if self.has_section('scribble') and self.has_option('scribble', 'width'): self.set('scribble', 'width_9', self.get('scribble', 'width')) self.remove_option('scribble', 'width') self.set('scribble', 'active_pen', '9') if self.has_option('presenter', 'monitor'): self.remove_option('presenter', 'monitor') if self.has_option('content', 'monitor'): self.remove_option('content', 'monitor') if self.has_section('scribble'): for key, val in self.items('scribble'): self.set('highlight', key, val) self.remove_section('scribble') if self.has_section('gst'): for key, val in self.items('gst'): self.set('gstreamer', key, 'on' if key == 'enabled' else val) self.remove_section('gst') # When we went from gtk signal handlers to actions, some renaming had to be done for old, new in { 'next': 'next-page', 'prev': 'prev-page', 'next_label': 'next-label', 'prev_label': 'prev-label', 'hist_back': 'hist-back', 'hist_forward': 'hist-forward', 'first': 'first-page', 'last': 'last-page', 'goto_page': 'goto-page', 'jumpto_label': 'jumpto-label', 'fullscreen_content': 'content-fullscreen', 'fullscreen_presenter': 'presenter-fullscreen', 'pause_timer': 'pause-timer', 'reset_timer': 'reset-timer', 'talk_time': 'edit-talk-time', 'blank_screen': 'blank-screen', 'notes_mode': 'notes-mode', 'swap_screens': 'swap-screens', 'open_file': 'pick-file', 'close_file': 'close-file', 'validate': 'validate-input', 'cancel': 'cancel-input', 'undo_scribble': 'highlight-undo', 'redo_scribble': 'highlight-redo', 'scribble_preset_1': 'highlight-use-pen::1', 'scribble_preset_2': 'highlight-use-pen::2', 'scribble_preset_3': 'highlight-use-pen::3', 'scribble_preset_4': 'highlight-use-pen::4', 'scribble_preset_5': 'highlight-use-pen::5', 'scribble_preset_6': 'highlight-use-pen::6', 'scribble_preset_7': 'highlight-use-pen::7', 'scribble_preset_8': 'highlight-use-pen::8', 'scribble_preset_9': 'highlight-use-pen::9', 'scribble_preset_0': 'highlight-use-pen::eraser', 'toggle_pointermode': 'pointer-mode::toggle', }.items(): shortcut = self.get('shortcuts', old, fallback=None) if shortcut is not None: if old in {'hist_back', 'hist_forward'} and 'backspace' in shortcut.lower(): # In the Gio.Action operations, accelerators have precedence over widgets, which # means it’s very annoying to edit a shortcut if backspace is mapped to anything. logger.warning('Changing shortcut for "{}" to new default instead of keeping backspace'.format(new)) else: self.set('shortcuts', new, shortcut) self.remove_option('shortcuts', old)
[docs] def getlist(self, *args): """ Parse a config value and return the list by splitting the value on commas. i.e. bar = foo,qux returns the list ['foo', 'qux'] Returns: `list`: a config value split into a list. """ return [t.strip() for t in self.get(*args).split(',') if t.strip()]
[docs] def getint(self, *args, **kwargs): """ Wrapper for configparser’s getint to handle parsing errors when a fallback is given. See :meth:`~configparser.Configparser.getint()` """ try: return super(Config, self).getint(*args, **kwargs) except ValueError: if 'fallback' not in kwargs: raise logger.warning(_('Error parsing option from config file {}.{} "{}" to int'.format(*args, self.get(*args))), exc_info=True) return kwargs['fallback']
[docs] def getfloat(self, *args, **kwargs): """ Wrapper for configparser’s to handle parsing errors when a fallback is given. See :meth:`~configparser.Configparser.getfloat()` """ try: return super(Config, self).getfloat(*args, **kwargs) except ValueError: if 'fallback' not in kwargs: raise logger.warning(_('Error parsing option from config file {}.{} "{}" to float') .format(*args, self.get(*args)), exc_info=True) return kwargs['fallback']
[docs] def getboolean(self, *args, **kwargs): """ Wrapper for configparser’s getboolean to handle parsing errors when a fallback is given. :meth:`~configparser.Configparser.getboolean()` """ try: return super(Config, self).getboolean(*args, **kwargs) except ValueError: if 'fallback' not in kwargs: raise logger.warning(_('Error parsing option from config file {}.{} "{}" to bool'.format(*args, self.get(*args))), exc_info=True) return kwargs['fallback']
[docs] def save_config(self): """ Save the configuration to its file. """ # serialize the layouts for layout_name in self.layout: self.set('layout', layout_name, json.dumps(self.layout[layout_name], indent=4)) with open(self.path_to_config(), 'w') as configfile: self.write(configfile)
[docs] def toggle_start(self, gaction, param=None): """ Generic function to toggle some boolean startup configuration. Args: gaction (:class:`~Gio.Action`): the action triggering the call param (:class:`~GLib.Variant`): the parameter as a variant, or None """ # action is named start(-presenter|-content)?-property start, *win, prop = gaction.get_name().split('-') window = win[0] if win else 'content' start_conf = 'start_' + prop new_state = not gaction.get_state().get_boolean() gaction.set_state(GLib.Variant.new_boolean(new_state)) self.set(window, start_conf, 'on' if new_state else 'off')
[docs] def validate_layout(self, layout, expected_widgets, optional_widgets = set()): """ Validate layout: check whether the layout of widgets built from the config string is valid. Args: layout (`dict`): the json-parsed config string expected_widgets (`set`): strings with the names of widgets that have to be used in this layout optional_widgets (`set`): strings with the names of widgets that may or may not be used in this layout Layout must have all self.placeable_widgets (leaves of the tree, as `str`) and only allowed properties on the nodes of the tree (as `dict`). Constraints on the only allowed properties of the nodes are: - resizeable: `bool` (optional, defaults to no), - orientation: `str`, either "vertical" or "horizontal" (mandatory) - children: `list` of size >= 2, containing `str`s or `dict`s (mandatory) - proportions: `list` of `float` with sum = 1, length == len(children), representing the relative sizes of all the resizeable items (if and only if resizeable). """ next_visits = deque([layout]) widget_seen = set() while next_visits: w_desc = next_visits.popleft() if type(w_desc) is str: if w_desc not in expected_widgets and w_desc not in optional_widgets: raise ValueError('Unrecognized widget "{}", pick one of: {}' .format(w_desc, ', '.join(expected_widgets))) elif w_desc in widget_seen: raise ValueError('Duplicate widget "{}", all expected_widgets can only appear once'.format(w_desc)) widget_seen.add(w_desc) elif type(w_desc) is dict: if 'orientation' not in w_desc or w_desc['orientation'] not in ['horizontal', 'vertical']: raise ValueError('"orientation" is mandatory and must be "horizontal" or "vertical" at node {}' .format(w_desc)) elif 'children' not in w_desc or type(w_desc['children']) is not list or len(w_desc['children']) < 2: raise ValueError('"children" is mandatory and must be a list of 2+ items at node {}'.format(w_desc)) elif 'resizeable' in w_desc and type(w_desc['resizeable']) is not bool: raise ValueError('"resizeable" must be boolean at node {}'.format(w_desc)) elif 'proportions' in w_desc: if 'resizeable' not in w_desc or not w_desc['resizeable']: raise ValueError('"proportions" is only valid for resizeable widgets at node {}'.format(w_desc)) elif type(w_desc['proportions']) is not list or \ any(type(n) is not float for n in w_desc['proportions']) or \ len(w_desc['proportions']) != len(w_desc['children']) or \ sum(w_desc['proportions']) < 1e-2: raise ValueError('"proportions" must be a list of floats (one per separator), ' 'that sum to 1, at node {}'.format(w_desc)) elif abs(sum(w_desc['proportions']) - 1) > 1e-10: w_desc['proportions'] = [p / sum(w_desc['proportions']) for p in w_desc['proportions']] next_visits.extend(w_desc['children']) else: raise ValueError('Unexpected type {}, nodes must be dicts or strings, at node {}' .format(type(w_desc), w_desc)) widget_missing = expected_widgets - widget_seen if widget_missing: raise ValueError('Following placeable_widgets were not specified: {}'.format(', '.join(widget_missing)))
[docs] def update_layout_tree(self, layout_name, layout): """ Update the layout named `~layout_name`. Throws ValueError on invalid layouts. Args: layout_name (`str`): the name of the layout to update layout (`dict`): the hierarchy of dictionaries, lists, and strings representing the layout """ self.validate_layout(layout, *self.widget_reqs[layout_name]) self.layout[layout_name] = layout
[docs] def load_window_layouts(self): """ Parse and validate layouts loaded from config, with fallbacks if needed. """ for layout_name in self.widget_reqs: # Log error and keep default layout try: self.update_layout_tree(layout_name, json.loads(self.get('layout', layout_name))) except ValueError: logger.exception('Invalid layout for {}'.format(layout_name))
[docs] def widget_layout_to_tree(self, widget, pane_handle_pos): """ Build a tree representing a widget hierarchy, leaves are strings and nodes are `dict`. Recursive function. See validate_layout() for more info on the tree structure. Args: widget (:class:`~Gtk.Widget`): the widget where to start pane_handle_pos (`dict`): Map of :class:`~Gtk.Paned` to the relative handle position (float in 0..1) Returns: `dict`: A tree of dicts reprensenting the widget hierarchy """ orientation_names = {Gtk.Orientation.HORIZONTAL: 'horizontal', Gtk.Orientation.VERTICAL: 'vertical'} name = widget.get_name() matching_widget_names = [k for k, v in self.placeable_widgets.items() if v == name] if matching_widget_names: return matching_widget_names[0] elif issubclass(type(widget), Gtk.Box): return {'resizeable': False, 'orientation': orientation_names[widget.get_orientation()], 'children': [self.widget_layout_to_tree(c, pane_handle_pos) for c in widget.get_children()]} elif issubclass(type(widget), Gtk.Paned): proportions = [1] reverse_children = [] orientation = widget.get_orientation() if orientation == Gtk.Orientation.HORIZONTAL: get_size = Gtk.Widget.get_allocated_width else: get_size = Gtk.Widget.get_allocated_height while issubclass(type(widget), Gtk.Paned) and orientation == widget.get_orientation(): left_pane = widget.get_child1() right_pane = widget.get_child2() visible = left_pane.get_visible() and right_pane.get_visible() position = widget.get_position() widget_size = get_size(widget) if not visible or widget_size <= 1: # reuse number that was in config initially, otherwise gets overwritten with 0 ratio = pane_handle_pos[widget] else: ratio = float(position) / widget_size pane_handle_pos[widget] = ratio proportions = [ratio] + [(1 - ratio) * p for p in proportions] reverse_children.append(right_pane) widget = left_pane reverse_children.append(left_pane) return {'resizeable': True, 'proportions': proportions, 'orientation': orientation_names[orientation], 'children': [self.widget_layout_to_tree(c, pane_handle_pos) for c in reversed(reverse_children)]} raise ValueError('Error serializing layout: widget of type {} '.format(type(widget)) + 'is not an expected container or named widget: "{}"'.format(name))
[docs] def get_layout(self, layout_name): """ Getter for the `~layout_name` layout. """ try: return self.layout[layout_name] except KeyError: return self.static_layout[layout_name]
[docs] def update_layout_from_widgets(self, layout_name, widget, pane_handle_pos): """ Setter for the notes layout. Args: layout_name (`str`): the name of the layout to update widget (:class:`~Gtk.Widget`): the widget that will contain the layout. pane_handle_pos (`dict`): Map of :class:`~Gtk.Paned` to the relative handle position (float in 0..1) """ if layout_name not in self.static_layout: return self.update_layout_tree(layout_name, self.widget_layout_to_tree(widget, pane_handle_pos))