# -*- coding: utf-8 -*-
#
# util.py
#
# Copyright 2009, 2010 Thomas Jost <thomas.jost@gmail.com>
# Copyright 2015 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.util` -- various utility functions
-------------------------------------------------
"""
import logging
logger = logging.getLogger(__name__)
import contextlib
import subprocess
import importlib
import gettext
import os
import sys
import ctypes
import pathlib
if sys.version_info >= (3, 9):
# Using parts introduced in 3.9
import importlib.resources as importlib_resources
else:
# Backport dependency
import importlib_resources
IS_POSIX = os.name == 'posix'
IS_MAC_OS = sys.platform == 'darwin'
IS_WINDOWS = os.name == 'nt'
if IS_WINDOWS:
try:
import winreg
except ImportError:
import _winreg as winreg
#: A :class:`~contextlib.ExitStack` containing all entered importlib context managers for used resources
_opened_resources = contextlib.ExitStack()
def __get_resource_path(*path_parts):
""" Return the path to a resource, ensuring it was made available as a file for the duration of the program.
Args:
name (`tuple` of `str`): The directories and filename that constitute the path
to the resource, relative to the pympress distribution
Returns:
:class:`~pathlib.Path`: The path to the resource
"""
# Introduced in 3.9
resource = importlib_resources.files('pympress').joinpath(*path_parts)
return _opened_resources.enter_context(importlib_resources.as_file(resource))
[docs]
def close_opened_resources():
""" Close all importlib context managers for resources that we needed over the program lifetime. """
_opened_resources.close()
[docs]
def get_translation(domain):
""" Returns a gettext translation object.
This re-implements gettext’s translation() and find() to allow using a python 3.9 Traversable as localedir
Returns:
:class:`~gettext.NullTranslations`: A gettext translation object with the strings for the domain loaded
"""
localedir = importlib_resources.files('pympress').joinpath('share', 'locale')
for envar in ('LANGUAGE', 'LC_ALL', 'LC_MESSAGES', 'LANG'):
enval = os.environ.get(envar)
if enval:
break
else:
return gettext.NullTranslations()
# now normalize and expand the languages
for lang in (lang for nelang in enval.split(':') for lang in gettext._expand_lang(nelang)):
file = localedir.joinpath(lang, 'LC_MESSAGES', domain + '.mo')
if file.is_file():
break
else:
return gettext.NullTranslations()
with file.open(mode='rb') as fp:
return gettext.GNUTranslations(fp)
[docs]
def get_portable_config():
""" Returns the path to the configuration file for a portable install (i.e. in the install root).
May return None if the install root is not a real directory (e.g. in a zip file).
Returns:
:class:`~pathlib.Path` or `None`: The path to the portable configuration file.
"""
try:
return __get_resource_path('pympress.conf')
except FileNotFoundError:
return None
[docs]
def get_default_config():
""" Returns the path to the configuration file containing the defaults.
Returns:
:class:`~pathlib.Path`: The path to the portable configuration file.
"""
return __get_resource_path('share', 'defaults.conf')
[docs]
def get_user_config():
""" Returns the path to the configuration file in the user config directory
Returns:
:class:`~pathlib.Path`: path to the user configuration file.
"""
if IS_WINDOWS:
base_dir = pathlib.Path(os.getenv('APPDATA'))
elif IS_MAC_OS:
base_dir = pathlib.Path('~/Library/Preferences').expanduser()
else:
base_dir = pathlib.Path(os.getenv('XDG_CONFIG_HOME', '~/.config')).expanduser()
if not base_dir.exists():
base_dir.mkdir(parents=True)
return base_dir.joinpath('pympress' + ('.ini' if IS_WINDOWS else ''))
[docs]
def load_style_provider(style_provider):
""" Load the css and in a style provider
Args:
style_provider (:class:`~Gtk.CssProvider`): The style provider in which to load CSS
Returns:
:class:`~Gtk.CssProvider`: The style provider with CSS loaded
"""
style_provider.load_from_path(str(__get_resource_path('share', 'css', 'default.css')))
return style_provider
[docs]
def get_icon_path(name):
""" Get the path for an image from pympress' resources
Args:
name (`str`): The name of the icon to load
Returns:
`str`: The path to the icon to load
"""
return str(__get_resource_path('share', 'pixmaps', name))
[docs]
def get_ui_resource_file(name, ext='.glade'):
""" Load an UI definition file from pympress' resources
Args:
name (`str`): The name of the UI to load
ext (`str`): The extension of the file
Returns:
`str`: The full path to the glade file
"""
return str(__get_resource_path('share', 'xml', name + ext))
[docs]
def list_icons():
""" List the icons from pympress' resources.
Returns:
`list` of `str`: The paths to the icons in the pixmaps directory
"""
return [get_icon_path('pympress-{}.png'.format(size)) for size in (16, 22, 24, 32, 48, 64)]
[docs]
def get_log_path():
""" Returns the appropriate path to the log file in the user app dirs.
Returns:
:class:`~pathlib.Path`: path to the log file.
"""
if IS_WINDOWS:
base_dir = pathlib.Path(os.getenv('LOCALAPPDATA', os.getenv('APPDATA')))
elif IS_MAC_OS:
base_dir = pathlib.Path('~/Library/Logs').expanduser()
else:
base_dir = pathlib.Path(os.getenv('XDG_CACHE_HOME', '~/.cache')).expanduser()
if not base_dir.exists():
base_dir.mkdir(parents=True)
return base_dir.joinpath('pympress.log')
[docs]
def fileopen(f):
""" Call the right function to open files, based on the platform.
Args:
f (path-like): path to the file to open
"""
if IS_WINDOWS:
os.startfile(f)
elif IS_MAC_OS:
subprocess.call(['open', str(f)])
else:
subprocess.call(['xdg-open', str(f)])
[docs]
def introspect_flag_value(flags_class, nick, fallback):
""" Get the value of a flag from its class, given a value’s name (or nick)
Introspection technique (in particular __flags_values__ dict) inspired from pygtkcompat.
This is needed because there is no typelib for libgstplayback.
Args:
flags_class (a `~type` inheriting from :class:`~Gobject.GFlags`): the flags class to introspect
nick (`str`): a name or nick of the flag value that should be returned
fallback (`int`): the documented flag value, if lookup fails
"""
try:
flag_values = flags_class.__flags_values__
except AttributeError:
return fallback
for value, flag in flag_values.items():
if nick in flag.value_nicks or nick in flag.value_names:
return value
else:
return fallback
[docs]
def hard_set_screensaver(disabled):
""" Enable or disable the screensaver.
Args:
disabled (`bool`): if `True`, indicates that the screensaver must be disabled; otherwise it will be enabled
"""
if IS_MAC_OS:
# On Mac OS X we can use caffeinate to prevent the display from sleeping
if disabled:
if hard_set_screensaver.caffeinate_process is None or hard_set_screensaver.caffeinate_process.poll():
hard_set_screensaver.caffeinate_process = subprocess.Popen(['caffeinate', '-d', '-w', str(os.getpid())])
else:
if hard_set_screensaver.caffeinate_process and not hard_set_screensaver.caffeinate_process.poll():
hard_set_screensaver.caffeinate_process.kill()
hard_set_screensaver.caffeinate_process.poll()
hard_set_screensaver.caffeinate_process = None
elif IS_WINDOWS:
try:
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, r'Control Panel\Desktop', 0,
winreg.KEY_QUERY_VALUE | winreg.KEY_SET_VALUE) as key:
if disabled:
value, regtype = winreg.QueryValueEx(key, "ScreenSaveActive")
assert regtype == winreg.REG_SZ, 'Unexpected RegType when modifying ScreenSaveActive'
hard_set_screensaver.dpms_was_enabled = (value == "1")
if hard_set_screensaver.dpms_was_enabled:
winreg.SetValueEx(key, "ScreenSaveActive", 0, winreg.REG_SZ, "0")
elif hard_set_screensaver.dpms_was_enabled:
winreg.SetValueEx(key, "ScreenSaveActive", 0, winreg.REG_SZ, "1")
except (OSError, PermissionError, AssertionError):
logger.exception(_("access denied when trying to access screen saver settings in registry!"))
elif IS_POSIX:
logger.warning(_("Should not require hard enable/disable screensaver on Linux"))
else:
logger.warning(_("Unsupported OS: can't enable/disable screensaver"))
#: remember DPMS setting before we change it
hard_set_screensaver.dpms_was_enabled = None
#: A :class:`~subprocess.Popen` object to track the child caffeinate process
hard_set_screensaver.caffeinate_process = None
[docs]
class NoMonitorPositions(Exception):
""" The Exception we raise when there is no way of figuring out the monitor position of windows """
pass
[docs]
class ScreenArea(object):
""" Convenience class to represent monitors or windows in terms of the area (position and size) they use on screen
This is similar to :class:`~Gdk.Monitor`, but necessary as we want to handle “mirrored” monitors as if they were a
single monitor, and only use “extended” monitors as target for content window position and/or fullscreening.
"""
[docs]
def most_intersection(self, candidates):
""" Find the rectangle that intersects most with `~rect` in `~candidates`
Args:
candidates (iterable of `ScreenArea`s): The monitor areas to check for intersection
Returns:
`ScreenArea`: The best candidate screen area, i.e. that has the largest intersection
"""
areas = []
for geom in candidates:
intersection = geom.intersection(self)
if intersection is None:
areas.append(-1) # Not even 0 for a common bound
elif intersection.equal(self):
return geom
else:
areas.append(intersection.width * intersection.height)
else:
return candidates[areas.index(max(areas))]
[docs]
def least_intersection(self, candidates):
""" Find the rectangle that intersects least with `~rect` in `~candidates`
Args:
candidates (iterable of `ScreenArea`s): The monitor areas to check for intersection
Returns:
`ScreenArea`: The best candidate screen area, i.e. that has the smallest intersection
"""
areas = []
for geom in candidates:
intersection = self.intersection(geom)
if intersection is None:
return geom
else:
areas.append(intersection.width * intersection.height)
else:
return candidates[areas.index(min(areas))]
def __init__(self, obj):
if isinstance(obj, tuple):
self.x, self.y, self.width, self.height = obj
else:
self.x, self.y, self.width, self.height = obj.x, obj.y, obj.width, obj.height
def __repr__(self):
""" Return a complete representation of the object """
return 'ScreenArea(at {} size {})'.format((self.x, self.y), (self.width, self.height))
[docs]
def intersection(self, other):
""" Compute the intersection of 2 screen areas
Args:
other (`ScreenArea`): The screen area to compare with
Returns:
`ScreenArea` or `None`: An area representing the intersection, or `None` if there is no intersection
"""
if self.x + self.width < other.x or self.x > other.x + other.width:
return None
if self.y + self.height < other.y or self.y > other.y + other.height:
return None
x = max(self.x, other.x)
w = min(self.x + self.width, other.x + other.width) - x
y = max(self.y, other.y)
h = min(self.y + self.height, other.y + other.height) - y
return ScreenArea((x, y, w, h))
[docs]
def equal(self, other):
""" Check whether 2 areas cover the exact same space
Args:
other (`ScreenArea`): The screen area to compare with
Returns:
`bool`: `True` iff the areas are identical
"""
return (self.x, self.y, self.width, self.height) == (other.x, other.y, other.width, other.height)
[docs]
def contains(self, other):
""" Check whether this area contains `~other`
Args:
other (`ScreenArea`): The screen area to compare with
Returns:
`bool`: `True` iff the area is contained
"""
intersection = self.intersection(other)
return intersection is not None and intersection.equal(self)
[docs]
def intersects(self, other):
""" Check whether this area intersects `~other`
Args:
other (`ScreenArea`): The screen area to compare with
Returns:
`bool`: `True` iff the areas have an intersection
"""
return self.intersection(other) is None
[docs]
class Monitor(ScreenArea):
""" A specialised `~ScreenArea` representing a monitor, with an descriptive string and a monitor number """
#: A `str` to represent a user-friendly name for the monitor
name = ''
#: An `int` that identifies the monitor in :class:`~Gdk.Display`
monitor_number = -1
def __init__(self, obj, id_=None, num=None):
super(Monitor, self).__init__(obj)
self.name = id_
self.monitor_number = num
def __repr__(self):
""" Return a complete representation of the object """
return 'Monitor({} at {} size {})'.format(self.name, (self.x, self.y), (self.width, self.height))
[docs]
@staticmethod
def lookup_monitors(display, *windows):
""" Get the info on the monitors
Args:
display (:class:`~Gdk.Display`): the current screen
*windows (`tuple` of :class:`~Gtk.Window`): windows for which to look up the monitor position
Returns:
`tuple` of `Monitor`: The monitors for each window, followed by the best monitors for presenter and content
"""
# Helpful for debugging
monitors = [display.get_monitor(n) for n in range(display.get_n_monitors())]
mon_names = ['{} {}'.format(mon.get_manufacturer() or 'Unknown manufacturer',
mon.get_model() or 'Unknown model') for mon in monitors]
all_geom = [Monitor(mon.get_geometry(), name, n) for n, (mon, name) in enumerate(zip(monitors, mon_names))]
# Remove duplicate monitors (“mirrored”)
all_geom = [rect for n, rect in enumerate(all_geom) if not any(rect.equal(other) for other in all_geom[:n])]
# Remove monitors whose area is entirely contained in that of another monitor. NB: union() computes intersection
all_geom = [rect for n, rect in enumerate(all_geom)
if all(not other.contains(rect) for other in all_geom[:n] + all_geom[n + 1:])]
# We have a global positioning system
if any(win.get_position() != (0, 0) for win in windows):
pos = [ScreenArea(win.get_position() + win.get_size()).most_intersection(all_geom) for win in windows]
# We have access to Gdk Windows
elif all(win.get_window() is not None for win in windows):
pos = [ScreenArea(mon.get_geometry()).most_intersection(all_geom) for mon in (
display.get_monitor_at_window(win.get_window()) for win in windows
)]
else:
raise NoMonitorPositions()
# Figure out which monitor is best for presenter view: embedded panel on laptops, primary, or just first in list
prim_area = all_geom[0]
for mon in monitors:
if mon.get_model() is None:
continue
model = mon.get_model().upper()
if any(model.startswith(embedded) for embedded in {'LVDS', 'IDP', 'EDP', 'LCD', 'DSI'}):
prim_area = ScreenArea(mon.get_geometry())
break
elif mon.is_primary():
# NB. there may be 0 primaries. Don’t break as we prefer to identify an embedded screen.
prim_area = ScreenArea(mon.get_geometry())
return (*pos, prim_area.most_intersection(all_geom), prim_area.least_intersection(all_geom))
[docs]
def make_windows_dpi_aware():
""" Set to avoid blurriness issues on High-DPI resolutions with scaling. """
if not IS_WINDOWS:
return
if hasattr(ctypes.windll.shcore, 'SetProcessDpiAwareness'):
ctypes.windll.shcore.SetProcessDpiAwareness(2)
else:
ctypes.windll.user32.SetProcessDPIAware()
##
# Local Variables:
# mode: python
# indent-tabs-mode: nil
# py-indent-offset: 4
# fill-column: 80
# end: