"""Entry point, configuration parser, 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 configparser import ConfigParser
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, layout
from .util import fmt_table, XInitError
from .version import __version__
from .wm import WindowManager
# -- Type-Annotation Imports --
from typing import Dict, Union
from typing import Optional # NOQA pylint: disable=unused-import
#: MyPy type alias for fields loaded from config files
CfgDict = Dict[str, Union[str, int, float, bool, None]] # pylint:disable=C0103
# --
#: Location for config files (determined at runtime).
XDG_CONFIG_DIR = os.environ.get('XDG_CONFIG_HOME',
os.path.expanduser('~/.config'))
#: Default content for the configuration file
#:
#: .. todo:: Figure out a way to show :data:`DEFAULTS` documentation but with
#: the structure pretty-printed.
DEFAULTS = {
'general': {
# Use Ctrl+Alt as the default base for key combinations
'ModMask': '<Ctrl><Alt>',
'MovementsWrap': True,
'ColumnCount': 3
},
'keys': {
"KP_Enter": "monitor-switch",
"KP_0": "maximize",
"KP_1": "bottom-left",
"KP_2": "bottom",
"KP_3": "bottom-right",
"KP_4": "left",
"KP_5": "center",
"KP_6": "right",
"KP_7": "top-left",
"KP_8": "top",
"KP_9": "top-right",
"<Shift>KP_1": "move-to-bottom-left",
"<Shift>KP_2": "move-to-bottom",
"<Shift>KP_3": "move-to-bottom-right",
"<Shift>KP_4": "move-to-left",
"<Shift>KP_5": "move-to-center",
"<Shift>KP_6": "move-to-right",
"<Shift>KP_7": "move-to-top-left",
"<Shift>KP_8": "move-to-top",
"<Shift>KP_9": "move-to-top-right",
"V": "vertical-maximize",
"H": "horizontal-maximize",
"C": "move-to-center",
}
} # type: Dict[str, CfgDict]
#: Used for resolving certain keysyms
#:
#: .. todo:: Figure out how to replace :data:`KEYLOOKUP` with a fallback that
#: uses something in `Gtk <http://lazka.github.io/pgi-docs/Gtk-3.0/>`_ or
#: ``python-xlib`` to look up the keysym from the character it types.
KEYLOOKUP = {
',': 'comma',
'.': 'period',
'+': 'plus',
'-': 'minus',
}
Wnck.set_client_type(Wnck.ClientType.PAGER)
[docs]class QuickTileApp(object):
"""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 ''
[docs] 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
except ImportError:
o_keybinder = None # type: Optional[keybinder.KeyBinder]
logging.error("Could not find python-xlib. Cannot bind keys.")
else:
o_keybinder = keybinder.init(
self._modmask, self._keys, self.commands, self.winman)
# Attempt to set up the D-Bus API
try:
from . import dbus_api
except ImportError:
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
else:
return False
[docs] 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')))
[docs]def load_config(path) -> ConfigParser:
"""Load the config file from the given path, applying fixes as needed.
If it does not exist, create it from the configuration defaults.
:param path: The path to load or initialize.
:raises TypeError: Raised if the keys or values in the :ref:`[keys]`
section of the configuration file or what they resolve to via
:any:`KEYLOOKUP` are not :any:`str` instances.
.. todo:: Refactor config parsing. It's an ugly blob.
"""
first_run = not os.path.exists(path)
config = ConfigParser(interpolation=None)
# Make keys case-sensitive because keysyms must be
#
# (``type: ignore`` to squash a false positive for something the Python 3.x
# documentation specifically *recommends* over using RawConfigParser)
config.optionxform = str # type: ignore
config.read(path)
dirty = False
if not config.has_section('general'):
config.add_section('general')
# Change this if you make backwards-incompatible changes to the
# section and key naming in the config file.
config.set('general', 'cfg_schema', '1')
dirty = True
for key, val in DEFAULTS['general'].items():
if not config.has_option('general', key):
config.set('general', key, str(val))
dirty = True
mk_raw = config.get('general', 'ModMask')
modkeys = mk_raw.strip() # pylint: disable=E1101
if ' ' in modkeys and '<' not in modkeys:
modkeys = '<%s>' % '><'.join(modkeys.split())
logging.info("Updating modkeys format:\n %r --> %r", mk_raw, modkeys)
config.set('general', 'ModMask', modkeys)
dirty = True
# Either load the keybindings or use and save the defaults
if config.has_section('keys'):
keymap = dict(config.items('keys')) # type: CfgDict
else:
keymap = DEFAULTS['keys']
config.add_section('keys')
for key, cmd in keymap.items():
if not isinstance(key, str):
raise TypeError("Hotkey name must be a str: {!r}".format(key))
if not isinstance(cmd, str):
raise TypeError("Command name must be a str: {!r}".format(cmd))
config.set('keys', key, cmd)
dirty = True
# Migrate from the deprecated syntax for punctuation keysyms
for key in keymap:
# Look up unrecognized shortkeys in a hardcoded dict and
# replace with valid names like ',' -> 'comma'
if key in KEYLOOKUP:
cmd = keymap[key]
if not isinstance(cmd, str):
raise TypeError("Command name must be a str: {!r}".format(cmd))
logging.warning("Updating config file from deprecated keybind "
"syntax:\n\t%r --> %r", key, KEYLOOKUP[key])
config.remove_option('keys', key)
config.set('keys', KEYLOOKUP[key], cmd)
dirty = True
# Automatically update the old 'middle' command to 'center'
for key in keymap:
if keymap[key] == 'middle':
keymap[key] = cmd = 'center'
logging.warning("Updating old command in config file:"
"\n\tmiddle --> center")
config.set('keys', key, cmd)
dirty = True
if dirty:
with open(path, 'w') as cfg_file:
config.write(cfg_file)
if first_run:
logging.info("Wrote default config file to %s", path)
return config
[docs]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)
[docs]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
[docs]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'))
)(commands.cycle_dimensions)
commands.commands.extra_state = {'config': config}
GLib.log_set_handler('Wnck', GLib.LogLevelFlags.LEVEL_WARNING,
wnck_log_filter)
from . import gtkexcepthook
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__)
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 :