ssokolow/quicktile

View on GitHub
quicktile/__main__.py

Summary

Maintainability
A
3 hrs
Test Coverage
"""Entry point and main loop

.. todo::
 - Audit all of my in-code TODOs for accuracy and staleness.
 - Move :func:`Wnck.set_client_type` call to a more appropriate place
   (:mod:`quicktile.wm`?)
 - Complete the automated test suite.
 - Finish refactoring the code to be cleaner and more maintainable.
 - Reconsider use of the name
   `-\\-daemonize <../cli.html#cmdoption-quicktile-d>`_. That tends to imply
   self-backgrounding.
 - Decide whether to replace `python-xlib`_ with `xcffib`_
   (the Python equivalent to ``libxcb``). On the one hand, python-xlib looks
   like it'd probably be easier to write an :file:`objects.inv` for at first
   glance. On the other hand, `xcffib`_ binds to the newer XCB API.
 - Implement the secondary major features of WinSplit Revolution (eg.
   process-shape associations, locking/welding window edges, etc.)
 - Consider rewriting :func:`quicktile.commands.cycle_dimensions` to allow
   command-line use to jump to a specific index without actually flickering the
   window through all the intermediate shapes.

.. _python-xlib: https://pypi.org/project/python-xlib/
.. _xcffib: https://pypi.org/project/xcffib/
"""

from __future__ import print_function

__author__ = "Stephan Sokolow (deitarion/SSokolow)"
__license__ = "GNU GPL 2.0 or later"

# Silence PyLint being flat-out wrong about MyPy type annotations and
# complaining about my grouped imports
# pylint: disable=unsubscriptable-object
# pylint: disable=wrong-import-order

import errno, logging, os, signal, sys
from argparse import ArgumentParser

from Xlib.display import Display as XDisplay
from Xlib.error import DisplayConnectionError

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

from . import commands, gtkexcepthook, layout
from .config import load_config, XDG_CONFIG_DIR
from .util import fmt_table, XInitError
from .version import __version__
from .wm import WindowManager

# -- Type-Annotation Imports --
from typing import Dict
from typing import Optional  # NOQA pylint: disable=unused-import
# --

Wnck.set_client_type(Wnck.ClientType.PAGER)


class QuickTileApp:
    """The basic Glib application itself.

    :param commands: The command registry to use to resolve command names.
    :param keys: A dict mapping :func:`Gtk.accelerator_parse` strings to
        command names.
    :param modmask: A modifier mask to prepend to all ``keys``.
    :param winman: The window manager to invoke commands with so they can act.
    """

    def __init__(self, winman: WindowManager,
                 commands: commands.CommandRegistry,
                 keys: Dict[str, str],
                 modmask: str = '',
                 ):
        self.winman = winman
        self.commands = commands
        self._keys = keys or {}
        self._modmask = modmask or ''

    def run(self) -> bool:
        """Initialize keybinding and D-Bus if available, then call
        :func:`Gtk.main`.

        :returns: :any:`False` if none of the supported backends
            were available.
        """

        # Attempt to set up the global hotkey support
        try:
            from . import keybinder  # pylint: disable=C0415
            o_keybinder: Optional[keybinder.KeyBinder] = keybinder.init(
                self._modmask, self._keys, self.commands, self.winman)
        except ImportError:  # pragma: nocover
            o_keybinder = None
            logging.error("Could not find python-xlib. Cannot bind keys.")

        # Attempt to set up the D-Bus API
        try:
            from . import dbus_api  # pylint: disable=C0415
        except ImportError:  # pragma: nocover
            dbus_result = None
            logging.warning("Could not load DBus backend. "
                            "Is python-dbus installed?")
        else:
            dbus_result = dbus_api.init(self.commands, self.winman)

        # If either persistent backend loaded, start the GTK main loop.
        if o_keybinder or dbus_result:
            try:
                Gtk.main()
            except KeyboardInterrupt:
                pass
            return True
        return False

    def show_binds(self) -> None:
        """Print a formatted readout of defined keybindings and the modifier
        mask to stdout.

        .. todo:: Look into moving this keybind pretty-printing into
            :class:`quicktile.keybinder.KeyBinder`
        """

        print("Keybindings defined for use with --daemonize:\n")
        print("Modifier: %s\n" % (self._modmask or '(none)'))
        print(fmt_table(self._keys, ('Key', 'Action')))


def wnck_log_filter(domain: str, level: GLib.LogLevelFlags,
        message: str, userdata: object = None):
    """A custom function for :func:`GLib.log_set_handler` which filters out
    the spurious error about ``_OB_WM_ACTION_UNDECORATE`` being un-handled.

    :param domain: The logging domain. Should be ``Wnck``.
    :param level: The logging level Should be
        :py:attr:`GLib.LogLevelFlags.LEVEL_WARNING`.
    :param message: The error message
    :param userdata: Required by the API but unused.
    """

    if '_OB_WM_ACTION_UNDECORATE' not in message:
        # The "or 0" works around a bug where it's documented as accepting
        # `object` or `None` and says `None` is one of the only valid values
        # if you try to pass `{}`, but it refuses to accept `None`.
        GLib.log_default_handler(domain, level, message, userdata or 0)


def argparser() -> ArgumentParser:
    """:class:`argparse.ArgumentParser` definition that is compatible with
        `sphinxcontrib.autoprogram
        <https://sphinxcontrib-autoprogram.readthedocs.io/en/stable/>`_"""
    parser = ArgumentParser(description='Window Tiling addon for X11-based '
        'desktops')
    parser.add_argument('-V', '--version', action='version',
            version="%%(prog)s v%s" % __version__)
    parser.add_argument('-d', '--daemonize', action="store_true",
        default=False, help="Attempt to set up global "
        "keybindings using python-xlib and a D-Bus service using dbus-python. "
        "Exit if neither succeeds.")
    parser.add_argument('-b', '--bindkeys', action="store_true",
        dest="daemonize", default=False, help="Old alias for --daemonize")
    parser.add_argument('--debug', action="store_true", default=False,
        help="Display debug messages")
    parser.add_argument('--no-excepthook', action="store_true",
        default=False, help="Disable the error-handling dialog to allow for "
        "use in unattended scripting.")
    parser.add_argument('--no-workarea', action="store_true",
        default=False, help="No effect. Retained for compatibility.")
    parser.add_argument('command', action="store", nargs="*",
        help="Window-tiling command to execute")

    help_group = parser.add_argument_group("Additional Help")
    help_group.add_argument('--show-bindings', action="store_true",
        default=False, help="List all configured keybinds")
    help_group.add_argument('--show-actions', action="store_true",
        default=False, help="List valid arguments for use without --daemonize")

    return parser


def main() -> None:
    """setuptools-compatible entry point

    :raises XInitError: Failed to connect to the X server.

    .. todo:: :func:`quicktile.__main__.main` is an overly complex blob and
        needs to be refactored.
    .. todo:: Rearchitect so the hack with registering
        :func:`quicktile.commands.cycle_dimensions` inside
        :func:`quicktile.__main__.main` isn't necessary.
    .. todo:: Rework ``python-xlib`` failure model so QuickTile will know to
        exit if all keybinding attempts failed and D-Bus also couldn't
        be bound.
    """
    parser = argparser()
    args = parser.parse_args()

    # Set up the output verbosity
    logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO,
                        format='%(levelname)s: %(message)s')

    cfg_path = os.path.join(XDG_CONFIG_DIR, 'quicktile.cfg')
    first_run = not os.path.exists(cfg_path)
    config = load_config(cfg_path)

    commands.cycle_dimensions = commands.commands.add_many(
        layout.make_winsplit_positions(
            config.getint('general', 'ColumnCount'),
            config.getfloat('general', 'MarginX_Percent') / 100,
            config.getfloat('general', 'MarginY_Percent') / 100
        )
    )(commands.cycle_dimensions)
    commands.commands.extra_state = {'config': config}

    GLib.log_set_handler('Wnck', GLib.LogLevelFlags.LEVEL_WARNING,
        wnck_log_filter)

    if not args.no_excepthook:
        gtkexcepthook.enable()

    try:
        x_display = XDisplay()
    except (UnicodeDecodeError, DisplayConnectionError) as err:
        raise XInitError("python-xlib failed with %s when asked to open"
                        " a connection to the X server. Cannot bind keys."
                        "\n\tIt's unclear why this happens, but it is"
                        " usually fixed by deleting your ~/.Xauthority"
                        " file and rebooting."
                        % err.__class__.__name__)

    # Work around a "the Gtk.Application ::startup handler does it for you" bug
    if not Gtk.init_check():
        raise XInitError("Gtk failed to connect to the X server. Exiting.")

    try:
        winman = WindowManager(x_display=x_display)
    except XInitError as err:
        logging.critical("%s", err)
        sys.exit(1)

    app = QuickTileApp(winman,
                       commands.commands,
                       keys=dict(config.items('keys')),
                       modmask=config.get('general', 'ModMask'))

    if args.show_bindings:
        app.show_binds()
    if args.show_actions:
        print(commands.commands)

    if args.daemonize:
        # Restore PyGTK-like Ctrl+C behaviour for easy development
        signal.signal(signal.SIGINT, signal.SIG_DFL)

        if not app.run():
            logging.critical("Neither the Xlib nor the D-Bus backends were "
                             "available")
            sys.exit(errno.ELIBACC)
    elif not first_run:
        if args:
            winman.screen.force_update()

            for arg in args.command:
                commands.commands.call(arg, winman)
            while Gtk.events_pending():
                Gtk.main_iteration()
        elif not args.show_actions and not args.show_bindings:
            print(commands.commands)
            print("\nUse --help for a list of valid options.")
            sys.exit(errno.ENOENT)


if __name__ == '__main__':
    main()

# vim: set sw=4 sts=4 expandtab :