Source code for pympress.config

# -*- coding: utf-8 -*-
#
#       config.py
#
#       Copyright 2017 Cimbali <me@cimba.li>
#
#       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
#       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.

"""
:mod:`pympress.config` -- Configuration
---------------------------------------

"""

from __future__ import print_function, unicode_literals

import logging
logger = logging.getLogger(__name__)

import os.path
import json
from collections import deque

try:
    import configparser
except ImportError:
    import ConfigParser as configparser

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

from pympress.util import IS_POSIX, IS_MAC_OS, IS_WINDOWS


try:
    unicode # trigger NameError in python3

    def recursive_unicode_to_str(obj):
        """ Recursively convert unicode to str (for python2)
        Raises NameError in python3 as 'unicode' is undefined

        Args:
            obj (`unicode` or `str` or `dict` or `list`): A unicode string to transform, or a container whose children to transform
        """
        if isinstance(obj, unicode):
            return str(obj)
        elif isinstance(obj, dict):
            return {recursive_unicode_to_str(k):recursive_unicode_to_str(obj[k]) for k in obj}
        elif isinstance(obj, list):
            return [recursive_unicode_to_str(i) for i in obj]
        else:
            return obj

except NameError:
[docs] def recursive_unicode_to_str(obj): return obj
[docs]def layout_from_json(layout_string): """ Load the layout from config, with all strings cast to type `str` (even on python2 where they default to `unicode`) Raises ValueError until python 3.4, json.decoder.JSONDecodeError afterwards, on invalid input. Args: layout_string (`str`): A JSON string to be loaded. """ if not layout_string: raise ValueError('No layout string passed. Ignore this error if you just upgraded pympress or reset your configuration file.') return recursive_unicode_to_str(json.loads(layout_string))
[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 = {} #: Map 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": "p_frame_next", "annotations": "p_frame_annot", "highlight": "scribble_overlay"}
[docs] @staticmethod def path_to_config(): """ Return the OS-specific path to the configuration file. """ if IS_POSIX: conf_dir = os.path.expanduser('~/.config') conf_file_nodir = os.path.expanduser('~/.pympress') conf_file_indir = os.path.expanduser('~/.config/pympress') if os.path.isfile(conf_file_indir): return conf_file_indir elif os.path.isfile(conf_file_nodir): return conf_file_nodir elif os.path.isdir(conf_dir): return conf_file_indir else: return conf_file_nodir else: return os.path.join(os.environ['APPDATA'], 'pympress.ini')
def __init__(config): super(Config, config).__init__() config.add_section('content') config.add_section('presenter') config.add_section('layout') config.add_section('cache') config.add_section('scribble') config.read(config.path_to_config()) if not config.has_option('cache', 'maxpages'): config.set('cache', 'maxpages', '200') if not config.has_option('content', 'xalign'): config.set('content', 'xalign', '0.50') if not config.has_option('content', 'yalign'): config.set('content', 'yalign', '0.50') if not config.has_option('content', 'monitor'): config.set('content', 'monitor', '0') if not config.has_option('content', 'start_blanked'): config.set('content', 'start_blanked', 'off') if not config.has_option('content', 'start_fullscreen'): config.set('content', 'start_fullscreen', 'on') if not config.has_option('presenter', 'monitor'): config.set('presenter', 'monitor', '1') if not config.has_option('presenter', 'start_fullscreen'): config.set('presenter', 'start_fullscreen', 'off') if not config.has_option('presenter', 'pointer'): config.set('presenter', 'pointer', 'red') if not config.has_option('presenter', 'show_bigbuttons'): config.set('presenter', 'show_bigbuttons', 'off') if not config.has_option('presenter', 'show_annotations'): config.set('presenter', 'show_annotations', 'off') if not config.has_option('presenter', 'scroll_number'): config.set('presenter', 'scroll_number', 'off') if not config.has_option('layout', 'notes'): config.set('layout', 'notes', '') if not config.has_option('layout', 'plain'): config.set('layout', 'plain', '') if not config.has_option('layout', 'highlight'): config.set('layout', 'highlight', '') if not config.has_option('scribble', 'color'): config.set('scribble', 'color', Gdk.RGBA(1., 0., 0., 1.).to_string()) if not config.has_option('scribble', 'width'): config.set('scribble', 'width', '8') config.load_window_layouts()
[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, check_item): """ Generic function to toggle some boolean startup configuration. Args: check_item (:class:`~Gtk.:CheckMenuItem`): the check button triggering the call """ window, start_conf = check_item.get_name().split('.') self.set(window, start_conf, 'on' if check_item.get_active() 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`). Contraints 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 abs(sum(w_desc['proportions']) - 1) > 1e-10: raise ValueError('"proportions" must be a list of floats (one per separator), between 0 and 1, at node {}'.format(w_desc)) 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 load_window_layouts(self): """ Parse and validate layouts loaded from config, with fallbacks if needed. """ default_layout = { 'notes': '{"resizeable":true, "orientation":"horizontal", "children":["notes", {"resizeable":false, "children":["current", "next"], "orientation":"vertical"}], "proportions": [0.60, 0.40]}', 'plain': '{"resizeable":true, "orientation":"horizontal", "children":["current", {"resizeable":true, "orientation":"vertical", "children":["next", "annotations"], "proportions":[0.55, 0.45]}], "proportions":[0.67, 0.33]}', 'highlight': '{"resizeable":true, "orientation":"horizontal", "children":["highlight", {"resizeable":true, "orientation":"vertical", "children":["next", "annotations"], "proportions":[0.55, 0.45]}], "proportions":[0.67, 0.33]}' } widget_reqs = { 'notes': (set(self.placeable_widgets.keys()) - {"annotations", "highlight"},), 'plain': (set(self.placeable_widgets.keys()) - {"notes", "highlight"},), 'highlight': ({"highlight"}, set(self.placeable_widgets.keys()) - {"highlight"}) } for layout_name in default_layout: # Log error and keep default layout try: self.layout[layout_name] = layout_from_json(self.get('layout', layout_name)) self.validate_layout(self.layout[layout_name] , *widget_reqs[layout_name]) except ValueError as e: logger.exception('Invalid layout') self.layout[layout_name] = layout_from_json(default_layout[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 position (float between 0 and 1) of its handle 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, 'children': [self.widget_layout_to_tree(c, pane_handle_pos) for c in widget.get_children()], 'orientation': orientation_names[widget.get_orientation()]} elif issubclass(type(widget), Gtk.Paned): proportions = [1] reverse_children = [] orientation = widget.get_orientation() get_size = Gtk.Widget.get_allocated_width if orientation == Gtk.Orientation.HORIZONTAL else 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 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, 'children': [self.widget_layout_to_tree(c, pane_handle_pos) for c in reversed(reverse_children)], 'proportions': proportions, 'orientation': orientation_names[orientation]} raise ValueError('Error serializing layout: widget of type {} is not an expected container or named widget: "{}"'.format(type(widget), name))
[docs] def get_layout(self, layout_name): """ Getter for the `~layout_name` layout. """ return recursive_unicode_to_str(self.layout[layout_name])
[docs] def update_layout(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 position (float between 0 and 1) of its handle """ self.layout[layout_name] = self.widget_layout_to_tree(widget, pane_handle_pos)