# -*- 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 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)