#!/usr/bin/env python3
#
# A simple but extensible X11 window manager written in Python.
# Copyright (c) 2018-2019, Hiroyuki Ohsaki.
# All rights reserved.
#
# $Id: xpywm,v 1.58 2019/07/04 15:09:34 ohsaki Exp $
#

# 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 3 of the License, or
# 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, see <https://www.gnu.org/licenses/>.

import os
import re
import subprocess
import sys
import time

from Xlib import X, display, XK

FRAME_WIDTH = 2
FRAME_COLOR = 'aquamarine1'
TITLE_WIDTH = 96
TITLE_HEIGHT = 10
TITLE_FONT = '-schumacher-clean-bold-r-normal--8-80-75-75-c-80-iso646.1991-irv'
TITLE_COLOR = 'aquamarine3'
Y_OFFSET = 8
PNT_OFFSET = 16
DRAG_THRESH = 16
DRAG_MAX_FPS = 10
MIN_WIN_SIZE = 16
BOUNCE_RATIO = 1 / 8
MAX_VSCREEN = 3

EVENT_HANDLER = {
    X.KeyPress: 'handle_keypress',
    X.ButtonPress: 'handle_button_press',
    X.MotionNotify: 'handle_motion_notify',
    X.ButtonRelease: 'handle_button_release',
    X.MapRequest: 'handle_map_request',
    X.ConfigureRequest: 'handle_configure_request',
    X.UnmapNotify: 'handle_unmap_notify',
    X.EnterNotify: 'handle_enter_notify',
    X.DestroyNotify: 'handle_destroy_notify',
    X.MapNotify: 'handle_map_notify',
}

KEYBOARD_HANDLER = {
    'i':
    {'modifier': X.Mod1Mask | X.ControlMask, 'method': 'cb_focus_next_window'},
    'm': {
        'modifier': X.Mod1Mask | X.ControlMask, 'method':
        'cb_raise_or_lower_window'
    },
    'apostrophe':
    {'modifier': X.Mod1Mask | X.ControlMask, 'method': 'cb_maximize_window'},
    'semicolon': {
        'modifier': X.Mod1Mask | X.ControlMask, 'method':
        'cb_maximize_window_vertically'
    },
    'comma':
    {'modifier': X.Mod1Mask | X.ControlMask, 'method': 'layout_all_windows'},
    'period': {
        'modifier': X.Mod1Mask | X.ControlMask, 'method': 'tile_all_windows'
    },
    'z': {
        'modifier': X.Mod1Mask | X.ControlMask, 'method': 'cb_destroy_window'
    },
    'y': {
        'modifier': X.Mod1Mask | X.ControlMask, 'method': 'cb_toggle_vscreen'
    },
    'bracketleft': {
        'modifier': X.Mod1Mask | X.ControlMask, 'method': 'cb_prev_vscreen'
    },
    'bracketright': {
        'modifier': X.Mod1Mask | X.ControlMask, 'method': 'cb_next_vscreen'
    },
    '1': {
        'modifier': X.Mod1Mask | X.ControlMask, 'command':
        '(unset STY; urxvt) &'
    },
    '2': {
        'modifier': X.Mod1Mask | X.ControlMask, 'command':
        'pidof emacs || emacs &'
    },
    '3': {
        'modifier': X.Mod1Mask | X.ControlMask, 'command':
        'pidof firefox || firefox &'
    },
    'F1': {'modifier': X.Mod1Mask, 'method': 'select_vscreen', 'args': 0},
    'F2': {'modifier': X.Mod1Mask, 'method': 'select_vscreen', 'args': 1},
    'F3': {'modifier': X.Mod1Mask, 'method': 'select_vscreen', 'args': 2},
    'F4': {'modifier': X.Mod1Mask, 'method': 'select_vscreen', 'args': 3},
    'F7': {
        'modifier': X.ShiftMask, 'function': 'xrandr_enable_external_output'
    },
    'XF86AudioRaiseVolume': {
        'modifier': X.NONE, 'function': 'audio_raise_volume'
    },
    'XF86AudioLowerVolume': {
        'modifier': X.NONE, 'function': 'audio_lower_volume'
    },
    # for debugging
    'Delete': {'modifier': X.Mod1Mask | X.ControlMask, 'function': 'restart'},
    'equal': {'modifier': X.Mod1Mask | X.ControlMask, 'function': 'exit'},
}

KEYSYM_TBL = {
    'XF86AudioRaiseVolume': 0x1008ff13, 'XF86AudioLowerVolume': 0x1008ff11
}

LAYOUT_OFFSET = 0
# regexp: [x, y, width, height]
LAYOUT_RULES = {
    r'xterm|rxvt': [.5, .3, .5, .7],
    r'emacs': [0, 0, .5 - LAYOUT_OFFSET, 1],
    r'firefox|chrom(e|ium)|midori':
    [.5 - LAYOUT_OFFSET, 0, .5 + LAYOUT_OFFSET, 1],
    r'pdf|dvi|office|acroread|tgif|mathematica|libre':
    [.5 - LAYOUT_OFFSET, 0, .5 + LAYOUT_OFFSET, 1],
}

# [x, y, width, height] of south-east, north-east, south-west, north-west windows
QUARTER_GEOMETRIES = [[.5, .5, .5, .5], [.5, 0, .5, .5], [0, .5, .5, .5],
                      [0, 0, .5, .5]]

TILE_COUNTS = {
    1: [1, 1],
    2: [2, 1],
    3: [2, 2],
    4: [2, 2],
    5: [3, 2],
    6: [3, 2],
    7: [3, 3],
    8: [3, 3],
    9: [3, 3],
    10: [4, 3],
    11: [4, 3],
    12: [4, 3],
    13: [4, 4],
    14: [4, 4],
    15: [4, 4],
    16: [4, 4],
    17: [5, 4],
    18: [5, 4],
    19: [5, 4],
    20: [5, 4],
    21: [5, 5],
    22: [5, 5],
    23: [5, 5],
    24: [5, 5],
    25: [5, 5],
    26: [6, 5],
    27: [6, 5],
    28: [6, 5],
    29: [6, 5],
    30: [6, 5],
}

def debug(fmt, *args):
    if args:
        msg = '** debug: ' + fmt % args
    else:
        msg = '** debug: ' + fmt
    i = msg.find(' ->')
    if i >= 0:
        msg = msg[0:i] + (' ' * (50 - i)) + msg[i:]
    print(msg, file=sys.stderr)

def error(fmt, *args):
    if args:
        print('** error:', fmt % args, file=sys.stderr)
    else:
        print('** error:', fmt, file=sys.stderr)

def restart():
    debug('restarting %s...', sys.argv[0])
    os.execvp(sys.argv[0], [sys.argv[0]])

def exit():
    debug('terminating...')
    sys.exit()

def xrandr_enable_external_output():
    """if an external display is connected via DP (DisplayPort) or HDMI,
    enable the output with the resolution of 800 x 600 pixels."""
    output = subprocess.getoutput('xrandr')
    m = re.search(r'\n(DP-?\d|HDMI-?\d) connected', output, re.MULTILINE)
    if m:
        output = m.group(1)
        # NOTE: some LCD projectors fails to recognize without turning off
        os.system('xrandr --output {} --off'.format(output))
        os.system('xrandr --output {} --mode 800x600'.format(output))
        return

def get_mixer_level():
    """Return the master playback volume of the default ALSA audio device.
    Volume ranges between 0 and 100."""
    output = subprocess.getoutput('amixer get Master')
    m = re.search(r'Playback.*\[(\d+)%\]', output)
    if m:
        level = int(m.group(1))
        debug('get_mixer_level -> %d', level)
        return level
    else:
        return None

def set_mixer_level(level):
    """Configure the master playback volume of the default ALSA audio device.
    Volume must be specified between 0 and 100."""
    debug('set_mixer_level: %d', level)
    subprocess.getoutput('amixer set Master {}%'.format(level))

def audio_raise_volume(delta=10):
    """Increase the volume of the default mixer by DELTA."""
    debug('audio_raise_volume: %s', delta)
    level = get_mixer_level()
    if level is not None:
        level = max(0, min(level + delta, 100))
        set_mixer_level(level)

def audio_lower_volume(delta=10):
    """Decrease the volume of the default mixer by DELTA."""
    debug('audio_lower_volume: %s', delta)
    audio_raise_volume(-delta)

def load_rcfile():
    """Load and execute the custom RC script in the home directory
    (~/.xpywmrc) if it exists.  The RC script is any (valid) Python script,
    which is loaded after defining all global vaiables.  So, you can freely
    overrwite those definitions."""
    rc_file = '{}/.xpywmrc'.format(os.getenv('HOME'))
    try:
        with open(rc_file) as f:
            code = f.read()
    except FileNotFoundError:
        return None
    try:
        exec(code)
    except:
        error("executing '%s' failed.  aborting...", rc_file)
        exit()

class WindowManager():
    def __init__(self):
        # X server display & screen
        self.display = display.Display()
        self.screen = self.display.screen()
        self.colormap = self.screen.default_colormap

        self.key_handlers = {}
        self.managed_windows = []
        self.exposed_windows = []
        self.current_vscreen = 0
        self.window_vscreen = {}

        self.frame_windows = {}
        self.frame_gc = None

        self.geometries = {}
        self.last_raised_window = None

        self.drag_window = None
        self.drag_button = None
        self.drag_geometry = None
        self.drag_start_xy = None
        self.drag_last_time = 0

    def catch_events(self):
        """Configure the root window to receive all events needed for managing
        windows."""
        mask = (X.SubstructureRedirectMask | X.SubstructureNotifyMask
                | X.EnterWindowMask | X.LeaveWindowMask | X.FocusChangeMask)
        self.screen.root.change_attributes(event_mask=mask)

    def grab_keys(self):
        """Configure the root window to receive key inputs according to the
        key definitions `KEYBOARD_HANDLER'.  Also, the jump table is stored in
        `self.key_handlers'."""
        for string, entry in KEYBOARD_HANDLER.items():
            keysym = XK.string_to_keysym(string)
            # FIXME: use keysymdef/xf86.py
            if not keysym and string in KEYSYM_TBL:
                keysym = KEYSYM_TBL[string]
            keycode = self.display.keysym_to_keycode(keysym)
            if not keycode:
                continue

            modifier = entry.get('modifier', X.NONE)
            self.screen.root.grab_key(keycode, modifier, True, X.GrabModeAsync,
                                      X.GrabModeAsync)
            self.key_handlers[keycode] = entry
            debug('grab_key: %s, %s', string, entry)

    def grab_buttons(self):
        """Configure the root window to receive mouse button events."""
        for button in [1, 3]:
            self.screen.root.grab_button(button, X.Mod1Mask, True,
                                         X.ButtonPressMask, X.GrabModeAsync,
                                         X.GrabModeAsync, X.NONE, X.NONE)
            debug('grab_button: %d', button)

    # ---------------- X wrapper functions
    def is_alive_window(self, window):
        """Check if the window WINDOW do exist."""
        windows = self.screen.root.query_tree().children
        return window in windows

    def get_window_class(self, window):
        """Fetch the WM_CLASS window property of the window WINDOW and return
        the class part of the property.  Return empty string if class is not
        retrieved."""
        try:
            cmd, cls = window.get_wm_class()
        except:
            return ''
        if cls is not None:
            return cls
        else:
            return ''

    def get_window_geometry(self, window):
        """Obtain the geometry and attributes of the window WINDOW.  Return as
        a Xlib.protocol.rq.Struct object.  Valid attributes are x, y, width,
        height, root, depth, border_width, and sequence_number.  Return None
        if the geometry is not retrieved."""
        try:
            return window.get_geometry()
        except:
            return None

    def get_window_attribute(self, window):
        try:
            return window.get_attributes()
        except:
            return None

    def window_shortname(self, window):
        return '0x{:x} [{}]'.format(window.id, self.get_window_class(window))

    def get_screen_size(self):
        """Return the dimension (WIDTH, HEIGHT) of the current screen as a
        tuple in pixels.  If xrandr command exsits and either DP (DisplayPort)
        or HDMI output is active, return its dimensionn instead of the screen
        size of the current X11 display."""
        width, height = self.screen.width_in_pixels, self.screen.height_in_pixels
        output = subprocess.getoutput('xrandr --current')
        # pick the last line including DP- or HDMI-
        m = re.search(r'(DP-?\d|HDMI-?\d) connected (\d+)x(\d+)', output)
        if m:
            width, height = int(m.group(2)), int(m.group(3))
        # limit the screen size if sendscreen/record-desktop is running
        code, output = subprocess.getstatusoutput('pidof -x sendscreen')
        if code == 0:
            width, height = 800, 600
        code, output = subprocess.getstatusoutput('pidof -x record-desktop')
        if code == 0:
            width, height = 800, 600
        debug('get_screen_size -> w:%d h:%d', width, height)
        return width, height

    def get_usable_screen_size(self):
        """Return the dimensionn (WIDTH, HEIGHT) of the usable screen are
        (i.e., the area of the current screen excluding the are for displaying
        status monitor using, for example, xpymon."""
        width, height = self.get_screen_size()
        width -= FRAME_WIDTH * 2
        height -= FRAME_WIDTH * 2 + Y_OFFSET
        debug('get_usable_screen_size -> w:%d h:%d', width, height)
        return width, height

    # ---------------- window manager functions
    def is_managed_window(self, window):
        """Check if the window WINDOW is under the control of the window
        manager."""
        return window in self.managed_windows

    def is_frame_window(self, window):
        """Check if the window WINDOW is one of frame windows."""
        return window in self.frame_windows

    def convert_geomtry(self, x, y, width, height, as_dict=True):
        """Convert a geometry X, Y, WIDTH and HEIGHT from the unit coordinate
        to the pixel coordinate.  For instance, the point (0.5, 1.0) in the
        unit coordinate is mapped to the mid-bottom (i.e., south) of the
        screen.  Return as a tuple by default.  If AS_DICT is True, return as
        a dictionary with keys `x', `y', `width' and `height'."""
        screen_width, screen_height = self.get_usable_screen_size()
        px = FRAME_WIDTH + int(screen_width * x)
        py = Y_OFFSET + FRAME_WIDTH + int(screen_height * y)
        pwidth = int(screen_width * width)
        pheight = int(screen_height * height)
        debug('convert_geomtry: x=%s y=%s w=%s h=%s -> x:%s y:%s w:%s h:%s', x,
              y, width, height, px, py, pwidth, pheight)
        if as_dict:
            return {'x': px, 'y': py, 'width': pwidth, 'height': pheight}
        else:
            return px, py, pwidth, pheight

    def create_frame_windows(self):
        """Create and map a window frame consisting of four windows."""
        debug('create_frame_windows')
        colormap = self.screen.default_colormap
        # create four frame windows
        pixel = colormap.alloc_named_color(FRAME_COLOR).pixel
        for side in ['frame_l', 'frame_r', 'frame_u', 'frame_d']:
            window = self.screen.root.create_window(
                0,
                0,
                16,
                16,
                0,
                self.screen.root_depth,
                X.InputOutput,
                background_pixel=pixel,
                override_redirect=1,
            )
            window.map()
            self.frame_windows[side] = window

        # create title window
        pixel = colormap.alloc_named_color(TITLE_COLOR).pixel
        window = self.screen.root.create_window(
            0,
            0,
            TITLE_WIDTH,
            TITLE_HEIGHT,
            0,
            self.screen.root_depth,
            X.InputOutput,
            background_pixel=pixel,
            override_redirect=1,
        )
        window.map()
        self.frame_windows['title'] = window
        # create GC for title window
        font = self.display.open_font(TITLE_FONT)
        self.frame_gc = window.create_gc(font=font,
                                         foreground=self.screen.black_pixel)

    def draw_frame_windows(self, window):
        """Draw a frame window surrounding a windwow WINDOW."""

        debug('draw_frame_windows: %s', self.window_shortname(window))
        if 'mpv' in self.get_window_class(window):
            self.hide_frame_windows(window)
        else:
            self._draw_frame_windows(window)

    def _draw_frame_windows(self, window):
        geom = self.get_window_geometry(window)
        if geom is None:
            return
        for side in ['frame_l', 'frame_r', 'frame_u', 'frame_d', 'title']:
            x, y, width, height = 0, 0, 0, 0
            if side == 'frame_l':
                x = geom.x - FRAME_WIDTH
                y = geom.y
                width = FRAME_WIDTH
                height = geom.height
            elif side == 'frame_r':
                x = geom.x + geom.width
                y = geom.y
                width = FRAME_WIDTH
                height = geom.height
            elif side == 'frame_u':
                x = geom.x - FRAME_WIDTH
                y = geom.y - FRAME_WIDTH
                width = geom.width + 2 * FRAME_WIDTH
                height = FRAME_WIDTH
            elif side == 'frame_d':
                x = geom.x - FRAME_WIDTH
                y = geom.y + geom.height
                width = geom.width + 2 * FRAME_WIDTH
                height = FRAME_WIDTH
            elif side == 'title':
                x = geom.x + geom.width - TITLE_WIDTH
                y = geom.y + geom.height - TITLE_HEIGHT
                width = TITLE_WIDTH
                height = TITLE_HEIGHT

            win = self.frame_windows[side]
            win.configure(x=x, y=y, width=width, height=height)
            if side == 'title':
                # update title bar
                win.clear_area(0, 0, TITLE_WIDTH, TITLE_HEIGHT)
                name = '{:x}{}'.format(window.id,
                                       self.get_window_class(window))
                xpos = max(0, (TITLE_WIDTH - len(name) * 8) // 2)
                chars = [chr(c).encode() for c in list(name.encode())]
                win.poly_text(self.frame_gc, xpos, 8, chars)

            # NOTE: might be redundant
            win.map()
            win.raise_window()

    def hide_frame_windows(self, window):
        for side in ['frame_l', 'frame_r', 'frame_u', 'frame_d', 'title']:
            win = self.frame_windows[side]
            win.unmap()

    def manage_window(self, window):
        """The window WINDOW is put under the control of the window manager.
        The window is forced to be mapped on the current virtual screen.  The
        geometry of the window is unchnaged."""
        attrs = self.get_window_attribute(window)
        if attrs is None:
            return
        # skip if the window should not be intercepted by window manager
        if attrs.override_redirect:
            return
        # skip if the window is under our control
        if self.is_managed_window(window):
            return

        debug('manage_window: %s', self.window_shortname(window))
        self.managed_windows.append(window)
        self.exposed_windows.append(window)
        self.window_vscreen[window] = self.current_vscreen

        # automatically layout the window if rule is found
        geom_dict = self.find_geometry_by_rules(window)
        if geom_dict is not None:
            window.configure(**geom_dict)

        window.map()
        mask = X.EnterWindowMask | X.LeaveWindowMask
        window.change_attributes(event_mask=mask)

    def unmanage_window(self, window):
        """The window WINDOW leaves from the control of the window manager."""
        if self.is_managed_window(window):
            debug('unmanage_window: %s', self.window_shortname(window))
            if window in self.managed_windows:
                self.managed_windows.remove(window)
            if window in self.exposed_windows:
                self.exposed_windows.remove(window)
            del self.window_vscreen[window]

    def raise_window(self, window):
        """Make the window WINDOW above all other windows."""
        if not self.is_managed_window(window):
            return
        window.configure(stack_mode=X.Above)
        self.last_raised_window = window

    def lower_window(self, window):
        """Lower the window WINDOW among all other windows."""
        if not self.is_managed_window(window):
            return
        window.configure(stack_mode=X.Below)
        if self.last_raised_window == window:
            self.last_raised_window = None

    def raise_or_lower_window(self, window):
        """Raise or lower the window WINDOW.  Toggle the mode of operation at
        every invokation."""
        if self.last_raised_window == window:
            self.lower_window(window)
        else:
            self.raise_window(window)

    def focus_window(self, window):
        """Activate the input to the window WINDOW and the window frame is
        displayed."""
        if not self.is_managed_window(window):
            return

        # FIXME: simple hack to remove missing window
        if not self.is_alive_window(window):
            return

        # FIXME: called two times? might be redundant
        debug('focus_window: %s', self.window_shortname(window))
        window.set_input_focus(X.RevertToParent, 0)
        self.draw_frame_windows(window)

    def focus_next_window(self, window=None):
        """Change the active window from the window WINDOW to the next one.
        The active window is raised and focused.  The pointer is moved to the
        north-west of the window."""
        def _sort_key(window):
            geom = self.get_window_geometry(window)
            if geom is None:
                return 100000000
            else:
                return geom.x * 10000 + geom.y

        # sort active windows with their geometries
        windows = sorted(self.exposed_windows, key=_sort_key)
        try:
            i = windows.index(window)
            next_window = windows[(i + 1) % len(windows)]
        except ValueError:
            if windows:
                next_window = windows[0]
            else:
                return
        next_window.raise_window()
        next_window.warp_pointer(PNT_OFFSET, PNT_OFFSET)
        self.focus_window(next_window)

    def is_maximized(self, window):
        """Check if the window WINDOW seems to have been maximized."""
        geom = self.get_window_geometry(window)
        if geom is None:
            return False
        width, height = self.get_usable_screen_size()
        if geom.x == 0 and geom.width == width:
            return True
        if geom.y == Y_OFFSET and geom.height == height:
            return True
        return False

    def save_window_geometry(self, window):
        """Save the current geometry of the window WINDOW."""
        geom = self.get_window_geometry(window)
        if geom is None:
            return
        self.geometries[window] = {
            'x': geom.x, 'y': geom.y, 'width': geom.width, 'height':
            geom.height
        }

    def load_window_geometry(self, window):
        """Return the saved geometry of the window WINDOW.  If not saved yet,
        return None."""
        return self.geometries.get(window, None)

    def maximize_window(self, window, horizontally=True, vertically=True):
        """Resize the geometry of the window WINDOW to cover the screen
        horizontally and/or vertically."""
        screen_width, screen_height = self.get_usable_screen_size()
        geom = self.get_window_geometry(window)
        if geom is None:
            return
        x, y = geom.x, geom.y
        width, height = geom.width, geom.height
        if horizontally:
            x, width = 0, screen_width
        if vertically:
            y, height = Y_OFFSET, screen_height
        window.configure(x=x, y=y, width=width, height=height)
        self.draw_frame_windows(window)
        window.warp_pointer(PNT_OFFSET, PNT_OFFSET)

    def is_terminal_window(self, window):
        """Check if the window WINDOW seems to be a terminal emulator."""
        cls = self.get_window_class(window)
        return 'xterm' in cls.lower()

    def find_geometry_by_rules(self, window):
        """Look through the configuration variable LAYOUT_RULES and identify
        the desired geometry (x, y, width, and height) of WINDOW.  The geometry is returned as
        a dictionary.  Return None if no rule is found."""
        debug('find_geometry_by_rules: %s', self.window_shortname(window))
        cls = self.get_window_class(window)
        cur_geom = self.get_window_geometry(window)
        if cur_geom is None:
            return None
        screen_width, screen_height = self.get_usable_screen_size()
        for regexp, geom in LAYOUT_RULES.items():
            if re.search(regexp, cls, flags=re.IGNORECASE):
                debug("  rule found -> '%s': %s", regexp, geom)
                # toggle the location of office applications
                if 'office' in regexp and cur_geom.x > screen_width / 4:
                    geom = [0, 0, .5 + LAYOUT_OFFSET, 1]
                return self.convert_geomtry(*geom)
        return None

    def layout_window(self, window, quarter=None):
        """Resize and move the window WINDOW based on predefined rules.  If
        QUARTER is specified, the window is placed so that the exact quarter
        of the screen is occupied.  Otherwise, the geometry is determined
        based on rules specifed by the variable
        `LAYOUT_RULES'."""
        debug('layout_window: %s q=%s', self.window_shortname(window), quarter)
        if quarter is not None:
            geom = QUARTER_GEOMETRIES[quarter % 4]
            window.configure(**self.convert_geomtry(*geom))
            return True
        else:
            geom_dict = self.find_geometry_by_rules(window)
            if geom_dict is not None:
                window.configure(**geom_dict)
                return True
        return False

    # FIXME: should make sure focus is not lost
    def layout_all_windows(self, *args):
        """Resize and move all windows on the current virtual screen according
        to the rules specified in the variable `LAYOUT_RULES'.  However,
        terminal windows are treated differently.  If there exists a single
        terminal window, its geometry is determined by LAYOUT_RULES.  If there
        are multiple terminal windows, every terminal window spans the quarter
        of the screen, and terminal windows are placed from the bottom-right
        corner in the counter-clockwise order."""
        # count the number of terminal windows
        debug('layout_all_windows')
        nterms = sum( [1 for window in self.exposed_windows \
                if self.is_terminal_window(window)])
        term_count = 0
        for window in self.exposed_windows:
            if self.is_terminal_window(window) and nterms >= 2:
                # layout every terminal to span the quarter of the screen
                self.layout_window(window, quarter=term_count)
                term_count += 1
            else:
                # layout according to the predefined rules
                self.layout_window(window)

    def tile_all_windows(self, *args):
        debug('tile_all_windows')
        """Resize and move all windows on the current virtual screen so that
        all windows have the equal size."""
        def _sort_key(window):
            # force Emacs be the last in the window list
            if 'emacs' in self.get_window_class(window).lower():
                return 0x7fffffff
            else:
                # NOTE: new windows have larger IDs?
                return window.id

        windows = sorted(self.exposed_windows, key=_sort_key)
        ncols, nrows = TILE_COUNTS[len(windows)]
        for col in reversed(range(ncols)):
            for row in reversed(range(nrows)):
                if not windows:
                    break

                window = windows.pop(0)
                x = 1 / ncols * col
                y = 1 / nrows * row
                width = 1 / ncols
                height = 1 / nrows

                if not windows:
                    # the last window is stretched to fill the remaining area
                    rest_height = 1 / nrows * row
                    y -= rest_height
                    height += rest_height

                debug('  %s @ (%d, %d) -> x:%s y:%s w:%s h:%s',
                      self.window_shortname(window), col, row, x, y, width,
                      height)
                window.configure(**self.convert_geomtry(x, y, width, height))

    def select_vscreen(self, n):
        """Change the virtual screen to N."""
        debug('select_vscreen: %d', n)
        self.current_vscreen = n
        self.exposed_windows.clear()
        for window in self.managed_windows:
            if self.window_vscreen[window] == n:
                window.map()
                self.exposed_windows.append(window)
            else:
                window.unmap()

    def toggle_vscreen(self, window):
        """Send the window WINDOW to the next virtual screen.  The virtual
        screen is toggled between 0 and 1."""
        vscreen = (self.current_vscreen + 1) % 2
        self.window_vscreen[window] = vscreen
        self.select_vscreen(self.current_vscreen)

    def destroy_window(self, window):
        """Kill the window WINDOW."""
        debug('destroy_window: %s', self.window_shortname(window))
        if self.is_managed_window(window):
            window.destroy()
            self.unmanage_window(window)

    # ---------------- callback functions
    def cb_raise_or_lower_window(self, event):
        window = event.child
        self.raise_or_lower_window(window)

    def cb_focus_next_window(self, event):
        window = event.child
        self.focus_next_window(window)

    def cb_maximize_window(self, event, horizontally=True):
        window = event.child
        attrs = self.get_window_attribute(window)
        if attrs is None:
            return
        # ignore if the window should not be intercepted by window manager
        if attrs.override_redirect:
            return
        if self.is_maximized(window) and self.load_window_geometry(window):
            window.configure(**self.load_window_geometry(window))
            self.draw_frame_windows(window)
            window.warp_pointer(PNT_OFFSET, PNT_OFFSET)
        else:
            self.save_window_geometry(window)
            self.maximize_window(window, horizontally=horizontally)

    def cb_maximize_window_vertically(self, event):
        self.cb_maximize_window(event, horizontally=False)

    def cb_destroy_window(self, event):
        window = event.child
        self.destroy_window(window)

    def cb_toggle_vscreen(self, event):
        window = event.child
        self.toggle_vscreen(window)

    def cb_prev_vscreen(self, event):
        vscreen = self.current_vscreen
        if vscreen > 0:
            self.select_vscreen(vscreen - 1)

    def cb_next_vscreen(self, event):
        vscreen = self.current_vscreen
        if vscreen < MAX_VSCREEN:
            self.select_vscreen(vscreen + 1)

    # ---------------- event handlers
    def handle_keypress(self, event):
        """Event handler for KeyPress events.  Callback functions for every
        key combination are defined in the variable `KEYBOARD_HANDLER', from
        which the jump table (dictionary mapping from a keycode to the
        corresponding action entry is composed and stored in
        `self.key_handlers'."""
        keycode = event.detail
        entry = self.key_handlers.get(keycode, None)
        if not entry:
            return

        debug('handle_keypress: %s -> %s', keycode, entry)
        args = entry.get('args', None)
        if 'method' in entry:
            method = getattr(self, entry['method'], None)
            if method:
                if args is not None:
                    method(args)
                else:
                    method(event)
            else:
                error("unable to call '%s'", entry['method'])
        elif 'function' in entry:
            function = globals().get(entry['function'], None)
            if function:
                if args is not None:
                    function(args)
                else:
                    function()
            else:
                error("unable to call '%s'", entry['function'])
        elif 'command' in entry:
            os.system(entry['command'])

    def handle_button_press(self, event):
        """Initiate window repositioning with the button 1 or window resizing
        with the button 3.  All mouse pointer motion events are captured until
        the button is relased."""
        window = event.child
        self.screen.root.grab_pointer(
            True, X.PointerMotionMask | X.ButtonReleaseMask, X.GrabModeAsync,
            X.GrabModeAsync, X.NONE, X.NONE, 0)
        self.drag_window = window
        self.drag_button = event.detail
        # FIXME: drag_geometry might be None
        self.drag_geometry = self.get_window_geometry(window)
        self.drag_start_xy = event.root_x, event.root_y

    def handle_button_release(self, event):
        """Terminate window repositioning/resizing."""
        self.display.ungrab_pointer(0)

    def _may_switch_virtual_screen(self, x, y):
        """If the pointer position is close enogh to the vertical edges of the
        screen, switch to the adjacent virtual screen.  The pointer position
        is updated as if the pointer crossed the virtual screen edge."""
        screen_width, screen_height = self.get_usable_screen_size()
        # cross window across virtual screen boundary
        if x >= screen_width - DRAG_THRESH:
            if self.current_vscreen < MAX_VSCREEN:
                self.window_vscreen[self.drag_window] += 1
                self.select_vscreen(self.current_vscreen + 1)
                self.screen.root.warp_pointer(int(screen_width * BOUNCE_RATIO),
                                              y)
        elif x <= DRAG_THRESH:
            if self.current_vscreen > 0:
                self.window_vscreen[self.drag_window] -= 1
                self.select_vscreen(self.current_vscreen - 1)
                self.screen.root.warp_pointer(
                    int(screen_width * (1 - BOUNCE_RATIO)), y)

    def handle_motion_notify(self, event):
        """Reposition or resize the current window according to the current
        pointer position.  The maximum rate of repositioning and resizeing is
        bounded by DRAG_MAX_FPS."""
        x, y = event.root_x, event.root_y
        # prevent to reposition window too frequently
        if time.time() - self.drag_last_time <= 1 / DRAG_MAX_FPS:
            return
        self.drag_last_time = time.time()

        dx = x - self.drag_start_xy[0]
        dy = y - self.drag_start_xy[1]
        if self.drag_button == 1:
            # reposition
            self.drag_window.configure(x=self.drag_geometry.x + dx,
                                       y=self.drag_geometry.y + dy)
            # dragging further might switch the virtual screen
            self._may_switch_virtual_screen(x, y)
        else:
            # resize
            self.drag_window.configure(
                width=max(MIN_WIN_SIZE, self.drag_geometry.width + dx),
                height=max(MIN_WIN_SIZE, self.drag_geometry.height + dy))
        self.draw_frame_windows(self.drag_window)

    def handle_map_request(self, event):
        """Event handler for MapRequest events."""
        window = event.window
        self.manage_window(window)
        window.warp_pointer(PNT_OFFSET, PNT_OFFSET)
        self.focus_window(window)

    def handle_unmap_notify(self, event):
        """Event handler for UnmapNotify events."""
        window = event.window
        if window in self.exposed_windows:
            self.unmanage_window(window)

    def handle_map_notify(self, event):
        """Event handler for MapNotify events."""
        window = event.window
        if self.is_frame_window(window):
            return
        self.manage_window(window)

    def handle_enter_notify(self, event):
        """Event handler for EnterNotify events."""
        window = event.window
        if window in self.exposed_windows:
            self.focus_window(window)

    def handle_destroy_notify(self, event):
        """Event handler for DestroyNotify events."""
        window = event.window
        self.unmanage_window(window)

    def handle_configure_request(self, event):
        """Event handler for ConfigureRequest events."""
        window = event.window
        x, y = event.x, event.y
        width, height = event.width, event.height
        mask = event.value_mask
        if mask == 0b1111:
            window.configure(x=x, y=y, width=width, height=height)
        elif mask == 0b1100:
            window.configure(width=width, height=height)
        elif mask == 0b0011:
            window.configure(x=x, y=y)
        elif mask == 0b01000000:
            window.configure(event.stack_mode)

    def event_loop(self):
        """The main event loop of the window manager.  Continuously receive an
        event from the X11 server, and dispatch an appropriate handler if
        possible."""
        while True:
            event = self.display.next_event()
            type = event.type
            if type in EVENT_HANDLER:
                handler = getattr(self, EVENT_HANDLER[type], None)
                if handler:
                    handler(event)

def main():
    load_rcfile()
    wm = WindowManager()
    wm.catch_events()
    wm.grab_keys()
    wm.grab_buttons()

    for child in wm.screen.root.query_tree().children:
        # FIXME: should not skip unmapped windows?
        if child.get_attributes().map_state:
            wm.manage_window(child)

    wm.create_frame_windows()
    wm.select_vscreen(0)
    wm.focus_next_window()
    wm.event_loop()

if __name__ == "__main__":
    main()
