Source code for zensols.rend.darwin

"""macOS bindings for displaying.

"""
__author__ = 'Paul Landes'

from typing import Dict, Sequence, Set, Tuple, Union, TYPE_CHECKING
from dataclasses import dataclass, field
from enum import Enum, auto
import logging
import textwrap
import re
from pathlib import Path
from zensols.config import ConfigFactory
from . import (
    RenderFileError, LocationType, Size, Extent, Location, Presentation, Browser
)

logger = logging.getLogger(__name__)


[docs] class ApplescriptError(RenderFileError): """Raised for macOS errors. """ pass
[docs] class ErrorType(Enum): """Types of errors raised by :class:`.ApplescriptError`. """ ignore = auto() warning = auto() error = auto()
[docs] @dataclass class DarwinBrowser(Browser): config_factory: ConfigFactory = field() """The configuration factory used to create a default :class:`.Browser` instance for URL viewing. """ script_paths: Dict[str, Path] = field() """The applescript file paths used for managing show apps (``Preview.app`` and ``Safari.app``). """ web_extensions: Set[str] = field() """Extensions that indicate to use Safari.app rather than Preview.app.""" applescript_warns: Dict[str, str] = field() """A set of string warning messages to log instead raise as an :class:`.ApplicationError`. """ update_page: Union[bool, int] = field(default=False) """How to update the page in Preview.app after the window displays. If ``True``, then record page before refresh, then go to the page after rendered. This is helpful when the PDF has changed and preview goes back to the first page. If this is a number, then go to that page number in Preview.app. """ switch_back_app: str = field(default=None) """The application to activate (focus) after the resize is complete.""" mangle_url: bool = field(default=False) """Whether to add ending ``/`` neede by Safari on macOS.""" def __post_init__(self): # try to install the applescript module if possible self._assert_applescript() # raise error now so BrowserManager can recover import applescript def _assert_applescript(self): from zensols.util import ( PackageResource, PackageManager, PackageRequirement ) package: str = 'applescript' pr = PackageResource(package) if logger.isEnabledFor(logging.DEBUG): logger.debug(f'{package} installed: {pr.installed}, ' + f'available: {pr.available}') if not pr.installed: if logger.isEnabledFor(logging.INFO): logger.info(f'attempting to install {package}') pm = PackageManager() pm.install(PackageRequirement.from_spec(package)) def _get_error_type(self, res: 'applescript.Result') -> ErrorType: err: str = res.err for warn, error_type in self.applescript_warns.items(): if err.find(warn) > -1: return ErrorType[error_type] return ErrorType.error def _exec(self, cmd: str, app: str = None) -> str: import applescript ret: applescript.Result if app is None: ret = applescript.run(cmd) else: ret = applescript.tell.app(app, cmd) if ret.code != 0: err_type: ErrorType = self._get_error_type(ret) cmd_str: str = textwrap.shorten(cmd, 40) msg: str = f'Could not invoke <{cmd_str}>: {ret.err} ({ret.code})' if err_type == ErrorType.warning: logger.warning(msg) elif err_type == ErrorType.error: raise ApplescriptError(msg) elif logger.isEnabledFor(logging.DEBUG): logger.debug(f'script output: <{ret.err}>') return ret.out
[docs] def get_show_script(self, name: str) -> str: """The applescript content used for managing app ``name``.""" with open(self.script_paths[name]) as f: return f.read()
def _invoke_open_script(self, name: str, arg: str, extent: Extent, func: str = None, add_quotes: bool = True, is_file: bool = False): """Invoke applescript. :param name: the key of the script in :obj:`script_paths` :param arg: the first argument to pass to the applescript (URL or file name) :param exent: the bounds to set on the raised window """ show_script: str = self.get_show_script(name) qstr: str = '"' if add_quotes else '' update_page: str page_num: str = 'null' if isinstance(self.update_page, bool): update_page = str(self.update_page).lower() page_num = 'null' else: update_page = 'true' page_num = str(self.update_page) func: str = f'show{name.capitalize()}' if func is None else func file_form: str if is_file: # add single quote for files with spaces in the name file_form = f"{qstr}'{arg}'{qstr}" else: file_form = f'{qstr}{arg}{qstr}' fn = (f'{func}({file_form}, {extent.x}, {extent.y}, ' + f'{extent.width}, {extent.height}, {update_page}, {page_num})') cmd = (show_script + '\n' + fn) if logger.isEnabledFor(logging.DEBUG): path: Path = self.script_paths[name] logger.debug(f'invoking "{fn}" from {path}') self._exec(cmd) self._switch_back() def _switch_back(self): """Optionally active an application after running the show-script, which is usually the previous running application. """ if self.switch_back_app is not None: self._exec(f'tell application "{self.switch_back_app}" to activate') def _get_screen_size(self) -> Size: bstr: str = self._exec('bounds of window of desktop', 'Finder') bounds: Sequence[int] = tuple(map(int, re.split(r'\s*,\s*', bstr))) width, height = bounds[2:] return Size(width, height) def _safari_compliant_url(self, url: str) -> str: if self.mangle_url and not url.endswith('/'): url = url + '/' return url def _show_file(self, path: Path, extent: Extent): self._invoke_open_script('preview', str(path.absolute()), extent, is_file=True) def _show_url(self, url: str, extent: Extent): url = self._safari_compliant_url(url) self._invoke_open_script('safari', url, extent) def _show_urls(self, urls: Tuple[str], extent: Extent): def map_url(url: str) -> str: url = self._safari_compliant_url(url) return f'"{url}"' url_str: str = ','.join(map(map_url, urls)) url_str = "{" + url_str + "}" self._invoke_open_script( name='safari-multi', arg=url_str, func='showSafariMulti', extent=extent, add_quotes=False)
[docs] def show(self, presentation: Presentation): def map_loc(loc: Location) -> Location: if loc.is_file_url or loc.type == LocationType.file: path: Path = loc.path if path.suffix[1:] in self.web_extensions: loc.coerce_type(LocationType.url) return loc extent: Extent = presentation.extent urls: Tuple[str] = None locs: Tuple[Location] = tuple(map(map_loc, presentation.locations)) if len(locs) > 1: loc_set: Set[LocationType] = set(map(lambda lc: lc.type, locs)) if len(loc_set) != 1 or next(iter(loc_set)) != LocationType.file: urls = tuple(map(lambda loc: loc.url, locs)) if urls is not None: self._show_urls(urls, extent) else: loc: Location for loc in presentation.locations: if loc.type == LocationType.file: self._show_file(loc.path, extent) else: if loc.is_file_url: self._show_file(loc.path, extent) else: self._show_url(loc.url, extent)