├── .gitignore ├── LICENSE ├── README.rst ├── examples ├── README.rst ├── bars.yaml ├── config.yaml ├── gadgets.yaml ├── gestures.yaml ├── hotkeys.yaml └── rules.yaml ├── multi.sh ├── runtests ├── runtilenol ├── setup.py ├── tests ├── __init__.py ├── layouts │ ├── __init__.py │ └── test_tile.py ├── test_cairo.py ├── test_classify.py └── test_xcb.py ├── tilenol.desktop ├── tilenol ├── __init__.py ├── __main__.py ├── classify.py ├── commands.py ├── config.py ├── event.py ├── events.py ├── ewmh.py ├── ext │ └── __init__.py ├── gadgets │ ├── __init__.py │ ├── base.py │ ├── menu.py │ └── tabs.py ├── gestures.py ├── groups.py ├── icccm.py ├── keyregistry.py ├── layout │ ├── __init__.py │ ├── base.py │ ├── examples.py │ └── tile.py ├── listkeys.py ├── main.py ├── mouseregistry.py ├── options.py ├── randr.py ├── screen.py ├── theme.py ├── util.py ├── widgets │ ├── __init__.py │ ├── bar.py │ ├── base.py │ ├── battery.py │ ├── clock.py │ ├── gesture.py │ ├── graph.py │ ├── groupbox.py │ ├── title.py │ ├── tray.py │ └── yahoo_weather.py ├── window.py └── xcb │ ├── __init__.py │ ├── auth.py │ ├── core.py │ ├── keysymparse.py │ ├── pixbuf.py │ ├── proto.py │ ├── shm.py │ └── xmlparse.py └── vagga.yaml /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | *.pyc 3 | *.pyo 4 | /build 5 | *~ 6 | .*swp 7 | /.vagga 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012 Paul Colomiets 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Tilenol 2 | ======= 3 | 4 | Tilenol is a tiling manager. It's similar in look and feel to qtile, but 5 | has much different implementation and configuration. 6 | 7 | Features 8 | -------- 9 | 10 | * Tiling WM, includes floating window support 11 | 12 | * Written in pure python, simple small and extensible 13 | 14 | * Configured with yaml files 15 | 16 | * Includes hooks for python code if needed 17 | 18 | * Supports multiple screens, with auto-update 19 | 20 | * It's reparenting WM (so works with Java) 21 | 22 | * Includes asynchronous main loop so no widgets can block entire WM 23 | 24 | * Includes dmenu-like thing: 25 | 26 | * Starts instantly without skipping first few keystrokes 27 | 28 | * Some fuzzy matching is implemented, search not only with a prefix 29 | 30 | * Has rich window classification rules to make windows floating and to put them 31 | into right places 32 | 33 | * Tabs for window navigation (works for any layout) 34 | 35 | 36 | Dependencies 37 | ------------ 38 | 39 | * python3 40 | * python-greenlet 41 | * xcb-proto (package containing `/usr/share/xcb/*.xml`) 42 | * zorro (http://github.com/tailhook/zorro) 43 | * pycairo from git (git://git.cairographics.org/git/pycairo) 44 | * python-yaml 45 | 46 | .. note:: 47 | 48 | Tilenol includes pure-python implementation of xcb, so only xml files are 49 | needed 50 | 51 | 52 | Running 53 | ------- 54 | 55 | After installing python package. You may want to copy ``examples/*.yaml`` into 56 | ``/etc/xdg/tilenol`` or ``~/.config/tilenol`` before starting, as tilenol is 57 | non-functional without a configuration. 58 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /examples/README.rst: -------------------------------------------------------------------------------- 1 | Tilenol Configuration Examples 2 | ------------------------------ 3 | 4 | These configurations examples are fully functional. You may want to put them 5 | into ``/etc/xdg/tilenol`` and override only files you need in 6 | ``~/.config/tilenol``. 7 | 8 | -------------------------------------------------------------------------------- /examples/bars.yaml: -------------------------------------------------------------------------------- 1 | - screen: 0 2 | position: top 3 | left: 4 | - Groupbox: 5 | first_letter: yes 6 | - ----------- 7 | - Gesture 8 | - Icon 9 | - Title 10 | right: 11 | - ----------- 12 | - Battery 13 | - CPUGraph 14 | - MemoryGraph 15 | - SwapGraph 16 | - ----------- 17 | - Systray 18 | - ----------- 19 | - Clock 20 | -------------------------------------------------------------------------------- /examples/config.yaml: -------------------------------------------------------------------------------- 1 | # Main cofiguration file 2 | # 3 | # it's used to configure groups, although can be used to extend or 4 | # override some configuration given in another file 5 | 6 | auto-screen-configuration: yes 7 | screen-dpi: 96 8 | 9 | groups: 10 | - 1: Tile 11 | - 2: Max 12 | - 3: Tile 13 | - 4: Tile 14 | - 5: Tile 15 | - gimp: Gimp 16 | - im: IM_Twitter 17 | - max: Max 18 | 19 | # Extra layouts to be turned on on demand 20 | # 21 | # extra_layouts: 22 | # - TestTilenol 23 | 24 | # Extra hotkeys 25 | # 26 | # keys: 27 | # # restart tilenol in emergency way 28 | # : env shell killall -QUIT tilenol 29 | # # execute a gadget defined below 30 | # : search_command_line show 31 | 32 | # Override bars with single toolbar having only Clock 33 | # 34 | # bars: 35 | # - screen: 0 36 | # position: top 37 | # left: 38 | # - Clock 39 | 40 | # Extra rules: the one to test tilenol itself on TestTilenol layout 41 | # 42 | # rules: 43 | # Xephyr: 44 | # - layout-properties: 45 | # stack: xephyr 46 | 47 | # Override or add extra gadgets 48 | # 49 | # gadgets: 50 | # find_window: 51 | # =: FindWindow 52 | # max_lines: 20 # more lines total 53 | # search_command_line: 54 | # =: SelectExecutable 55 | # max_lines: 100 # a screenful of commands for easier search 56 | -------------------------------------------------------------------------------- /examples/gadgets.yaml: -------------------------------------------------------------------------------- 1 | # This one pops up a menu to select executable from ``PATH`` 2 | # then it runs a command with a shell, so can be used to run complex 3 | # command-lines too 4 | command_line: SelectExecutable 5 | 6 | # This one pops a menu to select a layout for the current screen 7 | select_layout: SelectLayout 8 | 9 | # FindWindow gadget searches for window by it's title and switches to that 10 | # workspace. (Switching layout to show that window is not implemented at the 11 | # moment) 12 | find_window: FindWindow 13 | 14 | # Rename window gadget allows to change window title 15 | rename_window: RenameWindow 16 | 17 | # Tabs gadget shows list of windows on the left of the screen, for the groups 18 | # specified in `groups` parameter 19 | tabs: 20 | =: Tabs 21 | width: 200 22 | # no groups enabled by default, you can use hotkey to toggle 23 | #groups: [1, 2, im] 24 | 25 | -------------------------------------------------------------------------------- /examples/gestures.yaml: -------------------------------------------------------------------------------- 1 | 3f-left: emul button 8 2 | 3f-right: emul button 9 3 | 4f-down: screen toggle_bars 4 | 4f-up: window toggle_border 5 | 4f-left: groups switch_prev 6 | 4f-right: groups switch_next 7 | -------------------------------------------------------------------------------- /examples/hotkeys.yaml: -------------------------------------------------------------------------------- 1 | # Shortcuts in this file written in manner, similar to vim mappings. There are 2 | # modifiers: 3 | # 4 | # * W - Mod4 (usually a Win key) 5 | # * S - Shift 6 | # * C - Ctrl 7 | # 8 | # Keys with modifiers are prepended with modifier key and a dash: 9 | # 10 | # * W-1 -- Mod4 and 1 digit 11 | # * WS-a -- Mod4 + Shift + letter a 12 | # 13 | # Note: for shift modifier you must not uppercase letter specified 14 | # 15 | # Keys are called with their Xkeysym spelling. The rule of thumb is: letters 16 | # and digits are spelled as is, and other keys you can look at: 17 | # 18 | # * /usr/include/X11/keysymdef.h 19 | # * /usr/include/X11/XF86keysym.h (for multimedia keys) 20 | # 21 | # Every command in this file is space separated sequence of: 22 | # 23 | # object command [arg1 [arg2 ...]] 24 | # 25 | # For example ``env`` object is in charge for running a command, so the 26 | # following executes a terminal command: 27 | : env exec terminal 28 | # The only way to discover which command supports which object at the moment 29 | # is grep for `cmd_*` methods in the respective classes 30 | 31 | 32 | # Menu gadgets, they are configured in ``gadgets.yaml`` 33 | : command_line show 34 | : select_layout show 35 | : find_window show 36 | : rename_window show 37 | : tabs toggle 38 | 39 | # Group switch commands 40 | # They are not provided automatically, but hopefully you don't create them 41 | # too often 42 | : groups switch 1 43 | : groups move_window_to 1 44 | : groups switch 2 45 | : groups move_window_to 2 46 | : groups switch 3 47 | : groups move_window_to 3 48 | : groups switch 4 49 | : groups move_window_to 4 50 | : groups switch 5 51 | : groups move_window_to 5 52 | : groups switch gimp 53 | : groups move_window_to gimp 54 | : groups switch im 55 | : groups move_window_to im 56 | : groups switch max 57 | : groups move_window_to max 58 | 59 | # Current layout, window and screen commands 60 | : layout up 61 | : layout down 62 | : layout left 63 | : layout right 64 | : group focus_next 65 | : group focus_prev 66 | : window close 67 | : window kill 68 | : window make_tiled 69 | : tilenol restart 70 | : screen.0 focus 71 | : screen.1 focus 72 | : screen.2 focus 73 | : screen toggle_bars 74 | : window toggle_border 75 | 76 | # Multimedia Keys 77 | : env exec amixer sset Master toggle 78 | : env exec amixer sset Master 5%- 79 | : env exec amixer sset Master 5%+ 80 | : env exec mpc toggle 81 | : env exec mpc volume -5 82 | : env exec mpc volume +5 83 | 84 | 85 | -------------------------------------------------------------------------------- /examples/rules.yaml: -------------------------------------------------------------------------------- 1 | global: 2 | 3 | # different kinds of windows, which should not be tiled 4 | - match-type: 5 | - UTILITY 6 | - NOTIFICATION 7 | - TOOLBAR 8 | - SPLASH 9 | layout-properties: 10 | floating: yes 11 | 12 | # move transient (dialog) windows to the group of their respective owner 13 | - has-property: WM_TRANSIENT_FOR 14 | move-to-group-of: WM_TRANSIENT_FOR 15 | 16 | # customizations for IM laout 17 | - match-role: 18 | - buddy_list # for pidgin 19 | - roster # for gajim 20 | layout-properties: 21 | stack: roster 22 | 23 | 24 | ### GIMP ### 25 | # 26 | # This is customization for Gimp layout 27 | # 28 | # Note, we need to set `floating: no` for some gimp windows, since they are 29 | # utility windows 30 | 31 | Gimp: 32 | - move-to-group: g 33 | - match-role: gimp-toolbox 34 | layout-properties: 35 | stack: toolbox 36 | floating: no 37 | - match-role: gimp-dock 38 | layout-properties: 39 | stack: dock 40 | floating: no 41 | 42 | 43 | ### OPEN OFFICE ### 44 | 45 | # Unfortunately there is no good way to distinguish from different kinds 46 | # of open-office windows, so we do all of them floating, except DocumentWindow 47 | 48 | VCLSalFrame: 49 | - layout-properties: 50 | floating: yes 51 | 52 | VCLSalFrame.DocumentWindow: 53 | - layout-properties: 54 | floating: no 55 | 56 | 57 | ### JAVA ### 58 | # 59 | # Java apps seem to set hints to the size of window, when user resized it 60 | # it doesn't work nice, since user can never make windows bigger any more 61 | # 62 | # So we just ignore hints to java windows 63 | 64 | sun-awt-X11: 65 | - ignore-hints: yes 66 | 67 | ### Skype ### 68 | # 69 | # Skype declares that it supports WM_TAKE_FOCUS, but in fact it does not 70 | # so let's just ignore it 71 | skype: 72 | - ignore-protocols: WM_TAKE_FOCUS 73 | 74 | 75 | -------------------------------------------------------------------------------- /multi.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | exec startx /usr/bin/python3 runtilenol --log-stdout -- /usr/bin/Xephyr :20 +xinerama +extension RANDR -screen 800x500 -origin 0,500 -screen 800x500+0+500 -host-cursor 3 | -------------------------------------------------------------------------------- /runtests: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | exec startx $(which python3) -m unittest discover $* -- $(which Xvfb) :13 -screen 0 800x600x24 3 | -------------------------------------------------------------------------------- /runtilenol: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | from tilenol.__main__ import main 4 | 5 | main() 6 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from distutils.core import setup 2 | 3 | setup(name='Tilenol', 4 | version='0.1', 5 | description='Window manager written in python with greenlets', 6 | author='Paul Colomiets', 7 | author_email='paul@colomiets.name', 8 | url='http://github.com/tailhook/tilenol', 9 | classifiers=[ 10 | 'Programming Language :: Python :: 3', 11 | 'License :: OSI Approved :: MIT License', 12 | ], 13 | packages=[ 14 | 'tilenol', 15 | 'tilenol.xcb', 16 | 'tilenol.layout', 17 | 'tilenol.widgets', 18 | 'tilenol.ext', 19 | 'tilenol.gadgets', 20 | ], 21 | scripts=['runtilenol'], 22 | ) 23 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tailhook/tilenol/3b71f6600d437a4e5f167315683e7f0137cd3788/tests/__init__.py -------------------------------------------------------------------------------- /tests/layouts/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tailhook/tilenol/3b71f6600d437a4e5f167315683e7f0137cd3788/tests/layouts/__init__.py -------------------------------------------------------------------------------- /tests/layouts/test_tile.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | try: 3 | from unittest import mock # builtin mock in 3.3 4 | except ImportError: 5 | import mock 6 | from tilenol.xcb import Rectangle 7 | 8 | 9 | class Window(object): 10 | 11 | def set_bounds(self, rect): 12 | assert isinstance(rect, Rectangle) 13 | self.rect = rect 14 | 15 | 16 | class TestTile(unittest.TestCase): 17 | 18 | def wmock(self): 19 | w = mock.Mock() 20 | w.lprops.stack = None 21 | return w 22 | 23 | @mock.patch('tilenol.event.Event') 24 | def testTile(self, event): 25 | from tilenol.layout.examples import Tile 26 | lay = Tile() 27 | lay.set_bounds(Rectangle(0, 0, 800, 600)) 28 | w1 = self.wmock() 29 | lay.add(w1) 30 | lay.layout() 31 | w1.set_bounds.assert_called_with(Rectangle(0, 0, 800, 600)) 32 | w1.reset_mock() 33 | w2 = self.wmock() 34 | lay.add(w2) 35 | lay.layout() 36 | w1.set_bounds.assert_called_with(Rectangle(0, 0, 600, 600)) 37 | w2.set_bounds.assert_called_with(Rectangle(600, 0, 200, 600)) 38 | w1.reset_mock() 39 | w2.reset_mock() 40 | w3 = self.wmock() 41 | lay.add(w3) 42 | lay.layout() 43 | w1.set_bounds.assert_called_with(Rectangle(0, 0, 600, 600)) 44 | w2.set_bounds.assert_called_with(Rectangle(600, 0, 200, 300)) 45 | w3.set_bounds.assert_called_with(Rectangle(600, 300, 200, 300)) 46 | 47 | @mock.patch('tilenol.event.Event') 48 | def testPixels(self, event): 49 | from tilenol.layout import Split, Stack, TileStack 50 | class Tile(Split): 51 | class left(TileStack): 52 | size = 128 53 | limit = 1 54 | class right(TileStack): 55 | weight = 2 56 | min_size = 300 57 | lay = Tile() 58 | lay.set_bounds(Rectangle(0, 0, 800, 600)) 59 | w1 = self.wmock() 60 | lay.add(w1) 61 | w2 = self.wmock() 62 | lay.add(w2) 63 | lay.layout() 64 | w1.set_bounds.assert_called_with(Rectangle(0, 0, 128, 600)) 65 | w2.set_bounds.assert_called_with(Rectangle(128, 0, 672, 600)) 66 | lay.set_bounds(Rectangle(0, 0, 400, 300)) 67 | lay.layout() 68 | w1.set_bounds.assert_called_with(Rectangle(0, 0, 133, 300)) 69 | w2.set_bounds.assert_called_with(Rectangle(133, 0, 267, 300)) 70 | 71 | @mock.patch('tilenol.event.Event') 72 | def testOnlyPixels(self, event): 73 | from tilenol.layout import Split, Stack, TileStack 74 | class Tile(Split): 75 | class left(TileStack): 76 | size = 2 77 | limit = 1 78 | class right(TileStack): 79 | size = 3 80 | lay = Tile() 81 | lay.set_bounds(Rectangle(0, 0, 800, 600)) 82 | w1 = self.wmock() 83 | lay.add(w1) 84 | w2 = self.wmock() 85 | lay.add(w2) 86 | lay.layout() 87 | w1.set_bounds.assert_called_with(Rectangle(0, 0, 400, 600)) 88 | w2.set_bounds.assert_called_with(Rectangle(400, 0, 400, 600)) 89 | 90 | 91 | -------------------------------------------------------------------------------- /tests/test_cairo.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import cairo 3 | from .test_xcb import xcbtest 4 | 5 | 6 | class TestConn(unittest.TestCase): 7 | 8 | @xcbtest('xproto') 9 | def testPicture(self, conn): 10 | from tilenol.xcb.core import Core, Rectangle 11 | core = Core(conn) 12 | img = cairo.ImageSurface(cairo.FORMAT_ARGB32, 128, 128) 13 | ctx = cairo.Context(img) 14 | ctx.set_source_rgb(1, 0, 0) 15 | ctx.move_to(64, 0) 16 | ctx.line_to(128, 128) 17 | ctx.line_to(0, 128) 18 | ctx.fill() 19 | conn.connection() 20 | win = core.create_toplevel( 21 | bounds=Rectangle(10, 10, 100, 100), 22 | border=1, 23 | klass=core.WindowClass.InputOutput, 24 | params={ 25 | core.CW.BackPixel: conn.init_data['roots'][0]['white_pixel'], 26 | core.CW.EventMask: 27 | core.EventMask.Exposure | core.EventMask.KeyPress, 28 | }) 29 | core.raw.MapWindow(window=win) 30 | for ev in conn.get_events(): 31 | if ev.__class__.__name__ == 'ExposeEvent' and ev.window == win: 32 | break 33 | gc = conn.new_xid() 34 | core.raw.CreateGC( 35 | cid=gc, 36 | drawable=conn.init_data['roots'][0]['root'], 37 | params={}, 38 | ) 39 | assert len(bytes(img)) == 128*128*4, len(bytes(img)) 40 | core.raw.PutImage( 41 | format=core.ImageFormat.ZPixmap, 42 | drawable=win, 43 | gc=gc, 44 | width=128, 45 | height=128, 46 | dst_x=0, 47 | dst_y=0, 48 | left_pad=0, 49 | depth=24, 50 | data=bytes(img), 51 | ) 52 | core.raw.GetAtomName(atom=1) # ensures putimage error will be printed 53 | 54 | -------------------------------------------------------------------------------- /tests/test_classify.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | try: 3 | from unittest import mock # builtin mock in 3.3 4 | except ImportError: 5 | import mock 6 | 7 | 8 | class TestClassify(unittest.TestCase): 9 | 10 | 11 | def setUp(self): 12 | from tilenol.classify import Classifier, all_actions, all_conditions 13 | cl = Classifier() 14 | cl.add_rule([all_conditions['match-type']('utility')], 15 | [all_actions['layout-properties'](floating=True)]) 16 | cl.add_rule([], 17 | [all_actions['layout-properties'](floating=False)], 18 | klass="Gimp") 19 | self.cl = cl 20 | 21 | def testFloat(self): 22 | win = mock.Mock() 23 | win.lprops.floating = None 24 | win.xcore.atom._NET_WM_WINDOW_TYPE_UTILITY = 123456 25 | win.props = { 26 | '_NET_WM_WINDOW_TYPE': (123, 123456), 27 | } 28 | self.cl.apply(win) 29 | self.assertEqual(win.lprops.floating, True) 30 | 31 | def testNoFloat(self): 32 | win = mock.Mock() 33 | win.lprops.floating = None 34 | win.xcore.atom._NET_WM_WINDOW_TYPE_UTILITY = 123456 35 | win.props = { 36 | '_NET_WM_WINDOW_TYPE': (456,), 37 | } 38 | self.cl.apply(win) 39 | self.assertEqual(win.lprops.floating, None) 40 | 41 | def testGimp(self): 42 | win = mock.Mock() 43 | win.lprops.floating = None 44 | win.xcore.atom._NET_WM_WINDOW_TYPE_UTILITY = 123456 45 | win.props = { 46 | '_NET_WM_WINDOW_TYPE': (123, 123456), 47 | 'WM_CLASS': 'gimp\0Gimp\0', 48 | } 49 | self.cl.apply(win) 50 | self.assertEqual(win.lprops.floating, False) 51 | 52 | def testGimp26(self): 53 | win = mock.Mock() 54 | win.lprops.floating = None 55 | win.xcore.atom._NET_WM_WINDOW_TYPE_UTILITY = 123456 56 | win.props = { 57 | '_NET_WM_WINDOW_TYPE': (123, 123456), 58 | 'WM_CLASS': 'gimp-2.6\0Gimp-2.6\0', 59 | } 60 | self.cl.apply(win) 61 | self.assertEqual(win.lprops.floating, False) 62 | -------------------------------------------------------------------------------- /tests/test_xcb.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import os 3 | from functools import wraps 4 | 5 | 6 | def xcbtest(*protos): 7 | def wrapper(fun): 8 | @wraps(fun) 9 | def wrapper(self): 10 | from tilenol.xcb import Proto, Connection 11 | from zorro import Hub 12 | err = [] 13 | hub = Hub() 14 | def real_test(): 15 | pr = Proto() 16 | for i in protos: 17 | pr.load_xml(i) 18 | conn = Connection(pr) 19 | fun(self, conn) 20 | @hub.run 21 | def test(): 22 | try: 23 | real_test() 24 | except Exception as e: 25 | err.append(e) 26 | if err: 27 | raise err[0] 28 | return wrapper 29 | return wrapper 30 | 31 | 32 | class TestConn(unittest.TestCase): 33 | 34 | @xcbtest('xproto') 35 | def testAtom(self, conn): 36 | xproto = conn.proto.subprotos['xproto'] 37 | a1 = conn.do_request(xproto.requests['InternAtom'], 38 | only_if_exists=False, name="_ZXCB")['atom'] 39 | self.assertTrue(a1 > 100) 40 | self.assertTrue(isinstance(a1, int)) 41 | a2 = conn.do_request(xproto.requests['InternAtom'], 42 | only_if_exists=True, name="WM_CLASS")['atom'] 43 | self.assertEqual(a2, 67) 44 | self.assertNotEqual(a1, a2) 45 | 46 | @xcbtest('xproto') 47 | def testMoreAtoms(self, conn): 48 | xproto = conn.proto.subprotos['xproto'] 49 | totalatom = "TESTTESTTESTTESTTEST" 50 | for i in range(1, len(totalatom)): 51 | n = conn.do_request(xproto.requests['InternAtom'], 52 | only_if_exists=False, name=totalatom[:i])['atom'] 53 | self.assertTrue(n > 200) 54 | self.assertTrue(isinstance(n, int)) 55 | 56 | 57 | @xcbtest('xproto') 58 | def testXid(self, conn): 59 | conn.connection() 60 | xid1 = conn.new_xid() 61 | xid2 = conn.new_xid() 62 | self.assertTrue(xid2 > xid1) 63 | self.assertTrue(isinstance(xid1, int)) 64 | 65 | 66 | class TestWrapper(unittest.TestCase): 67 | 68 | @xcbtest('xproto') 69 | def testAtoms(self, conn): 70 | from tilenol.xcb.core import Core 71 | core = Core(conn) 72 | self.assertEqual(core.atom.WM_CLASS, 67) 73 | self.assertEqual(core.atom.WM_CLASS.name, 'WM_CLASS') 74 | self.assertEqual(repr(core.atom.WM_CLASS), '') 75 | a1 = conn.do_request(conn.proto.requests['InternAtom'], 76 | only_if_exists=False, name="_ZXCB")['atom'] 77 | self.assertTrue(a1 > 200) 78 | self.assertEquals(core.atom._ZXCB, a1) 79 | 80 | @xcbtest('xproto') 81 | def testAtoms(self, conn): 82 | from tilenol.xcb.core import Core 83 | core = Core(conn) 84 | self.assertEqual(core.EventMask.Exposure, 32768) 85 | self.assertEqual(core.CW.BackPixel, 2) 86 | self.assertEqual(repr(core.CW.BackPixel), '') 87 | 88 | @xcbtest('xproto') 89 | def testRaw(self, conn): 90 | from tilenol.xcb.core import Core 91 | core = Core(conn) 92 | a2 = core.raw.InternAtom(only_if_exists=True, name="WM_CLASS")['atom'] 93 | self.assertEqual(a2, 67) 94 | 95 | @xcbtest('xproto') 96 | def testWin(self, conn): 97 | from tilenol.xcb.core import Core, Rectangle 98 | core = Core(conn) 99 | conn.connection() 100 | win = core.create_toplevel( 101 | bounds=Rectangle(10, 10, 100, 100), 102 | border=1, 103 | klass=core.WindowClass.InputOutput, 104 | params={ 105 | core.CW.BackPixel: conn.init_data['roots'][0]['white_pixel'], 106 | core.CW.EventMask: 107 | core.EventMask.Exposure | core.EventMask.KeyPress, 108 | }) 109 | core.raw.MapWindow(window=win) 110 | for ev in conn.get_events(): 111 | if ev.__class__.__name__ == 'ExposeEvent' and ev.window == win: 112 | break 113 | attr = core.raw.GetWindowAttributes(window=win) 114 | self.assertTrue(attr['map_state']) 115 | 116 | 117 | -------------------------------------------------------------------------------- /tilenol.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Type=XSession 3 | Exec=runtilenol 4 | TryExec=runtilenol 5 | Name=Tilenol 6 | Comment=A simple tiling window manager written in python 7 | -------------------------------------------------------------------------------- /tilenol/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tailhook/tilenol/3b71f6600d437a4e5f167315683e7f0137cd3788/tilenol/__init__.py -------------------------------------------------------------------------------- /tilenol/__main__.py: -------------------------------------------------------------------------------- 1 | import os, os.path 2 | import logging, logging.handlers 3 | 4 | from zorro import Hub 5 | 6 | from .options import get_options 7 | from .main import Tilenol 8 | 9 | 10 | def main(): 11 | ap = get_options() 12 | options = ap.parse_args() 13 | if options.log_stdout: 14 | logging.basicConfig(level=logging.WARNING) 15 | else: 16 | os.environ.setdefault('XDG_CACHE_HOME', os.path.expanduser('~/.cache')) 17 | dir = os.path.expandvars('$XDG_CACHE_HOME/tilenol') 18 | if not os.path.exists(dir): 19 | os.makedirs(dir) 20 | root = logging.getLogger() 21 | filename = os.path.expandvars('$XDG_CACHE_HOME/tilenol/tilenol.log') 22 | root.addHandler(logging.handlers.RotatingFileHandler( 23 | filename, 24 | maxBytes=1 << 20, # 1Mb 25 | backupCount=1, 26 | )) 27 | try: 28 | print("Logging is redirected to", filename) 29 | except IOError: 30 | pass # no console to write to, that's ok 31 | hub = Hub() 32 | hub.run(Tilenol(options).run) 33 | 34 | if __name__ == '__main__': 35 | main() 36 | -------------------------------------------------------------------------------- /tilenol/classify.py: -------------------------------------------------------------------------------- 1 | from zorro.di import di 2 | 3 | from .ewmh import match_type 4 | 5 | 6 | class Classifier(object): 7 | 8 | def __init__(self): 9 | self.global_rules = [] 10 | self.class_rules = {} 11 | 12 | def add_rule(self, conditions, actions, klass=None): 13 | if klass is None: 14 | self.global_rules.append((conditions, actions)) 15 | else: 16 | if klass not in self.class_rules: 17 | self.class_rules[klass] = [] 18 | self.class_rules[klass].append((conditions, actions)) 19 | 20 | 21 | def apply(self, win): 22 | for conditions, actions in self.global_rules: 23 | if all(cond(win) for cond in conditions): 24 | for act in actions: 25 | act(win) 26 | for klass in self._split_class(win.props.get('WM_CLASS', '')): 27 | for conditions, actions in self.class_rules.get(klass, ''): 28 | if all(cond(win) for cond in conditions): 29 | for act in actions: 30 | act(win) 31 | 32 | @staticmethod 33 | def _split_class(cls): 34 | for name in cls.split('\0'): 35 | if not name: 36 | continue 37 | yield name 38 | while '-' in name: 39 | name, _ = name.rsplit('-', 1) 40 | yield name 41 | 42 | 43 | def match_role(*roles): 44 | def checker(win): 45 | for typ in roles: 46 | if typ == win.props.get('WM_WINDOW_ROLE'): 47 | return True 48 | return checker 49 | 50 | 51 | def has_property(*properties): 52 | def checker(win): 53 | for prop in properties: 54 | if prop in win.props: 55 | return True 56 | return checker 57 | 58 | 59 | def layout_properties(**kw): 60 | def setter(win): 61 | for k, v in kw.items(): 62 | setattr(win.lprops, k, v) 63 | return setter 64 | 65 | 66 | def ignore_hints(ignore): 67 | def setter(win): 68 | win.ignore_hints = True 69 | return setter 70 | 71 | 72 | def ignore_protocols(*ignore): 73 | def setter(win): 74 | win.ignore_protocols.update(ignore) 75 | win.protocols -= win.ignore_protocols 76 | return setter 77 | 78 | 79 | def move_to_group_of(prop): 80 | def setter(win): 81 | wid = win.props[prop][0] 82 | other = di(win)['event-dispatcher'].all_windows[wid] 83 | win.lprops.group = other.lprops.group 84 | return setter 85 | 86 | 87 | def move_to_group(group): 88 | def setter(win): 89 | gman = di(win)['group-manager'] 90 | if group in gman.by_name: 91 | win.lprops.group = gman.groups.index(gman.by_name[group]) 92 | return setter 93 | 94 | 95 | all_conditions = { 96 | 'match-role': match_role, 97 | 'match-type': match_type, 98 | 'has-property': has_property, 99 | } 100 | all_actions = { 101 | 'layout-properties': layout_properties, 102 | 'ignore-hints': ignore_hints, 103 | 'ignore-protocols': ignore_protocols, 104 | 'move-to-group-of': move_to_group_of, 105 | 'move-to-group': move_to_group, 106 | } 107 | -------------------------------------------------------------------------------- /tilenol/commands.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | from functools import partial 3 | import subprocess 4 | import logging 5 | 6 | from zorro.di import has_dependencies, dependency 7 | 8 | from .event import Event 9 | from .xcb import Core 10 | 11 | 12 | log = logging.getLogger(__name__) 13 | 14 | 15 | class Events(dict): 16 | 17 | def __missing__(self, key): 18 | res = self[key] = Event('changed.'+key) 19 | return res 20 | 21 | 22 | class CommandDispatcher(dict): 23 | 24 | def __init__(self): 25 | self.events = Events() 26 | 27 | def __setitem__(self, name, value): 28 | super().__setitem__(name, value) 29 | ev = self.events.get(name) 30 | if ev is not None: 31 | ev.emit() 32 | 33 | def call(self, obj, meth, *args): 34 | getattr(self[obj], 'cmd_' + meth)(*args) 35 | 36 | def callback(self, *args): 37 | return partial(self.call, *args) 38 | 39 | 40 | class EnvCommands(object): 41 | 42 | def cmd_exec(self, *args): 43 | subprocess.Popen(args) 44 | 45 | def cmd_shell(self, *args): 46 | subprocess.Popen(args, shell=True) 47 | 48 | def cmd_backlight_inc(self, *args, **kw): 49 | self.backlight(1, *args, **kw) 50 | 51 | def cmd_backlight_dec(self, *args, **kw): 52 | self.backlight(-1, *args, **kw) 53 | 54 | def backlight(self, inc, device_name=None, steps=10, 55 | basedir='/sys/class/backlight'): 56 | if device_name is None: 57 | for device_name in os.listdir(basedir): 58 | if os.path.isdir(os.path.join(basedir, device_name)): 59 | break 60 | else: 61 | log.warning("No backlight device found") 62 | return 63 | 64 | with open(os.path.join(basedir, 65 | device_name, 'actual_brightness'), 'rt') as f: 66 | curvalue = int(f.read()) 67 | with open(os.path.join(basedir, 68 | device_name, 'max_brightness'), 'rt') as f: 69 | maxvalue = int(f.read()) 70 | step_no = int(curvalue / (maxvalue/steps)+0.99) + inc 71 | print("DEVICE_NAME", device_name, curvalue, maxvalue, step_no) 72 | if step_no <= 0: 73 | val = 0 74 | elif step_no > steps - 1: 75 | val = maxvalue 76 | else: 77 | val = int(step_no * (maxvalue/steps)) 78 | with open(os.path.join(basedir, 79 | device_name, 'brightness'), 'wt') as f: 80 | f.write(str(val)) 81 | 82 | @has_dependencies 83 | class EmulCommands(object): 84 | 85 | keyregistry = dependency(object, 'key-registry') # circ dependency 86 | commander = dependency(CommandDispatcher, 'commander') 87 | xcore = dependency(Core, 'xcore') 88 | 89 | def cmd_key(self, keystr): 90 | mod, sym = self.keyregistry.parse_key(keystr) 91 | code = self.xcore.keysym_to_keycode[sym][0] 92 | self.xcore.xtest.FakeInput( 93 | type=2, 94 | detail=code, 95 | time=0, 96 | root=self.xcore.root_window, 97 | rootX=0, 98 | rootY=0, 99 | deviceid=0, 100 | ) 101 | self.xcore.xtest.FakeInput( 102 | type=3, 103 | detail=code, 104 | time=100, 105 | root=self.xcore.root_window, 106 | rootX=0, 107 | rootY=0, 108 | deviceid=0, 109 | ) 110 | 111 | def cmd_button(self, num): 112 | num = int(num) 113 | self.xcore.xtest.FakeInput( 114 | type=4, 115 | detail=num, 116 | time=0, 117 | root=self.xcore.root_window, 118 | rootX=0, 119 | rootY=0, 120 | deviceid=0, 121 | ) 122 | self.xcore.xtest.FakeInput( 123 | type=5, 124 | detail=num, 125 | time=30, 126 | root=self.xcore.root_window, 127 | rootX=0, 128 | rootY=0, 129 | deviceid=0, 130 | ) 131 | 132 | 133 | 134 | -------------------------------------------------------------------------------- /tilenol/config.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | import shlex 3 | import re 4 | import logging 5 | import math 6 | from itertools import chain 7 | 8 | 9 | log = logging.getLogger(__name__) 10 | separator = re.compile('^[-_]+$') 11 | NoDefault = object() 12 | 13 | 14 | class PathGen(object): 15 | 16 | def __init__(self, dir_env, dir_default, *, 17 | dirs_env=None, 18 | dirs_default=None, 19 | extensions=('.json', '.yaml')): 20 | dir = os.environ.get(dir_env, dir_default) 21 | lst = [os.path.expanduser(dir)] 22 | if dirs_env is not None or dirs_default is not None: 23 | dirs = os.environ.get(dirs_env, dirs_default).split(':') 24 | lst.extend(filter(bool, dirs)) 25 | self.dirs = lst 26 | self.extensions = extensions 27 | 28 | def find_file(self, name, required=True): 29 | for dir in self.dirs: 30 | for ext in self.extensions: 31 | fname = os.path.join(dir, name + ext) 32 | if os.path.exists(fname): 33 | return fname 34 | if required: 35 | raise RuntimeError("File {!r} not found".format( 36 | os.path.join(self.dirs[0], name + '.yaml'))) 37 | 38 | def get_config(self, name, data=NoDefault): 39 | fname = self.find_file('tilenol/'+name, required=data is NoDefault) 40 | if fname is None: 41 | if data is not NoDefault: 42 | return data 43 | return 44 | if fname.endswith('.json'): 45 | with open(fname, 'rt') as f: 46 | import json 47 | return json.load(f) 48 | elif fname.endswith('.yaml'): 49 | with open(fname, 'rb') as f: 50 | import yaml 51 | return yaml.load(f) 52 | if data is not NoDefault: 53 | return data 54 | return 55 | 56 | 57 | class Config(object): 58 | 59 | def __init__(self): 60 | self.config = PathGen( 61 | dir_env='XDG_CONFIG_HOME', 62 | dir_default='~/.config', 63 | dirs_env='XDG_CONFIG_DIRS', 64 | dirs_default='/etc/xdg', 65 | ) 66 | self.data = PathGen( 67 | dir_env="XDG_DATA_HOME", 68 | dir_default="~/.local/share", 69 | dirs_env="XDG_DATA_DIRS", 70 | dirs_default="/usr/local/share:/usr/share") 71 | self.cache = PathGen("XDG_CACHE_HOME", "~/.cache") 72 | self.runtime = PathGen("XDG_RUNTIME_DIR", "~/.cache/tilenol") 73 | config = self.config.get_config('config', {}) 74 | 75 | config.setdefault('auto-screen-configuration', True) 76 | config.setdefault('screen-dpi', 96) 77 | 78 | self.data = config 79 | 80 | def __getitem__(self, name): 81 | return self.data[name] 82 | 83 | 84 | def init_extensions(self): 85 | from tilenol import ext 86 | for i, path in enumerate(self.config.dirs): 87 | ext.__path__.insert(i, os.path.join(path, 'tilenol', 'ext')) 88 | ext.__path__.insert(-1, '/usr/share/tilenol/site-extensions') 89 | 90 | def get_extension_class(self, name, 91 | module_name, 92 | default_module, 93 | base_class, 94 | default_value=None): 95 | try: 96 | mod = __import__('tilenol.ext.'+module_name, globals(), {}, ['*']) 97 | except ImportError: 98 | mod = None 99 | if '.' in name: 100 | module, cname = name.split('.', 1) 101 | try: 102 | mod = __import__('tilenol.ext.' + module, 103 | globals(), {}, ['*']) 104 | res = getattr(mod, cname) 105 | except (ImportError, AttributeError, ValueError): 106 | log.warning('Class %r is not available', name) 107 | return default_value 108 | else: 109 | try: 110 | res = getattr(mod, name) 111 | except AttributeError: 112 | try: 113 | res = getattr(default_module, name) 114 | except AttributeError: 115 | return default_value 116 | if not issubclass(res, base_class): 117 | log.warning("Class %s is subclassed from wrong class") 118 | return default_value 119 | return res 120 | 121 | def keys(self): 122 | for k, v in self.config.get_config('hotkeys', {}).items(): 123 | yield k, self._command(v) 124 | 125 | @staticmethod 126 | def _command(cmd): 127 | if isinstance(cmd, str): 128 | return shlex.split(cmd) 129 | else: 130 | return cmd 131 | 132 | def gestures(self): 133 | from tilenol.gestures import directions 134 | settings = { # global settings are absolute values 135 | 'detect-distance': 50, 136 | 'commit-distance': 600, 137 | 'char': "▲", 138 | } 139 | supported_gestures = {'{}f-{}'.format(fing, dir) 140 | for fing in range(2, 6) for dir in directions} 141 | gest = self.config.get_config('gestures', {}) 142 | gest.update(self.data.get('gestures', {})) 143 | if 'settings' in gest: 144 | settings.update(gest['settings']) 145 | res = {} 146 | for k, v in gest.items(): 147 | if k == 'settings': 148 | continue 149 | elif k in supported_gestures: 150 | f, dir = k.split('-') 151 | val = {} 152 | val.update(settings) 153 | if 'action' not in v and '=' not in v: 154 | val['action'] = self._command(v) 155 | else: 156 | val.update(v) 157 | if '=' in v: 158 | val['action'] = self._command(v['=']) 159 | else: 160 | val['action'] = self._command(val['action']) 161 | val['condition'] = directions[dir] 162 | res[k] = val 163 | else: 164 | log.warning('Gesture %r is not supported', k) 165 | return res 166 | 167 | def theme(self): 168 | from tilenol.theme import Theme 169 | theme = Theme() 170 | if self.data.get('theme'): 171 | theme.update_from(self.config.get_config('themes/' 172 | + self.data['theme'], {})) 173 | theme.update_from(self.config.get_config('theme-customize', {})) 174 | theme.update_from(self.data.get('theme-customize', {})) 175 | return theme 176 | 177 | @staticmethod 178 | def _pairs(yaml): 179 | """Returns pairs either in order listed in yaml 180 | if it was defined like ordered mapping, or just in arbitrary 181 | order of dict iteration 182 | """ 183 | if isinstance(yaml, (list, tuple)): 184 | for item in yaml: 185 | for k, v in item.items(): 186 | yield k, v 187 | else: 188 | for k, v in yaml.items(): 189 | yield k, v 190 | 191 | def groups(self): 192 | from tilenol.groups import Group 193 | groups = [] 194 | if 'groups' in self.data: 195 | from tilenol.layout import examples, Layout 196 | for name, lname in self._pairs(self.data['groups']): 197 | lay = self.get_extension_class(lname, 198 | module_name='layouts', 199 | default_module=examples, 200 | base_class=Layout, 201 | default_value=examples.Tile) 202 | groups.append(Group(str(name), lay)) 203 | else: 204 | from tilenol.layout import Tile 205 | for i in range(10): 206 | groups.append(Group(str(i), Tile)) 207 | return groups 208 | 209 | def all_layouts(self): 210 | if hasattr(self, '_all_layouts'): 211 | return self._all_layouts 212 | self._all_layouts = layouts = {} 213 | from tilenol.layout import examples, Layout 214 | if 'groups' in self.data: 215 | for name, lname in self._pairs(self.data['groups']): 216 | lay = self.get_extension_class(lname, 217 | module_name='layouts', 218 | default_module=examples, 219 | base_class=Layout, 220 | default_value=examples.Tile) 221 | layouts[lname] = lay 222 | if 'extra_layouts' in self.data: 223 | for lname in self.data['extra_layouts']: 224 | lay = self.get_extension_class(lname, 225 | module_name='layouts', 226 | default_module=examples, 227 | base_class=Layout, 228 | default_value=examples.Tile) 229 | layouts[lname] = lay 230 | return layouts 231 | 232 | def bars(self): 233 | bars = self.data.get('bars') 234 | if not bars: 235 | bars = self.config.get_config('bars', {}) 236 | from tilenol import widgets 237 | for binfo in bars: 238 | w = [] 239 | for winfo in reversed(binfo.pop('right', ())): 240 | if isinstance(winfo, dict): 241 | for typ, params in winfo.items(): 242 | break 243 | else: 244 | typ = winfo 245 | params = {} 246 | if separator.match(typ): 247 | typ = 'Sep' 248 | params['right'] = True 249 | wclass = self.get_extension_class(typ, 250 | module_name='widgets', 251 | default_module=widgets, 252 | base_class=widgets.base.Widget) 253 | if wclass is not None: 254 | w.append(wclass(**params)) 255 | for winfo in binfo.pop('left', ()): 256 | if isinstance(winfo, dict): 257 | for typ, params in winfo.items(): 258 | break 259 | else: 260 | typ = winfo 261 | params = {} 262 | if separator.match(typ): 263 | typ = 'Sep' 264 | wclass = self.get_extension_class(typ, 265 | module_name='widgets', 266 | default_module=widgets, 267 | base_class=widgets.base.Widget) 268 | if wclass is not None: 269 | w.append(wclass(**params)) 270 | sno = int(binfo.pop('screen', 0)) 271 | bar = widgets.Bar( w, **binfo) 272 | yield sno, bar 273 | 274 | def rules(self): 275 | from tilenol.classify import all_conditions, all_actions 276 | for cls, rules in chain( 277 | self.config.get_config('rules', {}).items(), 278 | self.data.get('rules', {}), 279 | ): 280 | if cls == 'global': 281 | cls = None 282 | for rule in rules: 283 | cond = [] 284 | act = [] 285 | for k, v in rule.items(): 286 | if isinstance(v, list): 287 | args = v 288 | kw = {} 289 | elif isinstance(v, dict): 290 | args = {} 291 | kw = v 292 | else: 293 | args = (v,) 294 | kw = {} 295 | if k in all_conditions: 296 | cond.append(all_conditions[k](*args, **kw)) 297 | elif k in all_actions: 298 | act.append(all_actions[k](*args, **kw)) 299 | else: 300 | raise NotImplementedError(k) 301 | if not act: 302 | raise NotImplementedError("Empty actions {!r}" 303 | .format(rule)) 304 | yield cls, cond, act 305 | 306 | 307 | def gadgets(self): 308 | from tilenol import gadgets 309 | for name, gadget in chain( 310 | self.config.get_config('gadgets', {}).items(), 311 | self.data.get('gadgets', {}), 312 | ): 313 | if isinstance(gadget, str): 314 | clsname = gadget 315 | kw = {} 316 | else: 317 | clsname = gadget['='] # YAMLy convention 318 | kw = gadget.copy() 319 | kw.pop('=') 320 | 321 | try: 322 | cls = getattr(gadgets, clsname) 323 | except AttributeError: 324 | log.warning("Gadget %s is not available", clsname) 325 | continue 326 | 327 | yield name, cls(**kw) 328 | -------------------------------------------------------------------------------- /tilenol/event.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from zorro import Condition, gethub 4 | 5 | 6 | log = logging.getLogger(__name__) 7 | 8 | 9 | class Event(object): 10 | 11 | def __init__(self, name=None): 12 | self.name = name 13 | self._listeners = [] 14 | self._worker = None 15 | 16 | def listen(self, fun): 17 | self._listeners.append(fun) 18 | 19 | def unlisten(self, fun): 20 | self._listeners.remove(fun) 21 | 22 | def emit(self): 23 | log.debug("Emitting event %r", self.name) 24 | if self._worker is None and self._listeners: 25 | self._worker = gethub().do_spawn(self._do_work) 26 | 27 | def _do_work(self): 28 | try: 29 | log.debug("Processing event %r", self.name) 30 | for l in self._listeners: 31 | l() 32 | finally: 33 | self._worker = None 34 | 35 | 36 | -------------------------------------------------------------------------------- /tilenol/events.py: -------------------------------------------------------------------------------- 1 | import struct 2 | import logging 3 | 4 | from zorro.di import di, has_dependencies, dependency 5 | 6 | from .keyregistry import KeyRegistry 7 | from .mouseregistry import MouseRegistry 8 | from .window import Window 9 | from .xcb import Core, Rectangle, XError 10 | from .groups import GroupManager 11 | from .commands import CommandDispatcher 12 | from .classify import Classifier 13 | from .screen import ScreenManager 14 | from .event import Event 15 | from .config import Config 16 | from . import randr 17 | 18 | 19 | log = logging.getLogger(__name__) 20 | 21 | 22 | @has_dependencies 23 | class EventDispatcher(object): 24 | 25 | keys = dependency(KeyRegistry, 'key-registry') 26 | mouse = dependency(MouseRegistry, 'mouse-registry') 27 | xcore = dependency(Core, 'xcore') 28 | groupman = dependency(GroupManager, 'group-manager') 29 | screenman = dependency(ScreenManager, 'screen-manager') 30 | classifier = dependency(Classifier, 'classifier') 31 | config = dependency(Config, 'config') 32 | 33 | def __init__(self): 34 | self.windows = {} 35 | self.frames = {} 36 | self.all_windows = {} 37 | self.active_field = None 38 | self.mapping_notify = Event('mapping_notify') 39 | self.mapping_notify.listen(self._mapping_notify_delayed) 40 | 41 | def dispatch(self, ev): 42 | meth = getattr(self, 'handle_'+ev.__class__.__name__, None) 43 | if meth: 44 | meth(ev) 45 | else: 46 | log.warning("Unknown event ``%r''", ev) 47 | 48 | def register_window(self, win): 49 | self.all_windows[win.wid] = win 50 | 51 | def handle_KeyPressEvent(self, ev): 52 | if not self.keys.dispatch_event(ev): 53 | if self.active_field: 54 | self.active_field.handle_keypress(ev) 55 | 56 | def handle_KeyReleaseEvent(self, ev): 57 | pass # nothing to do at the moment 58 | 59 | def handle_ButtonPressEvent(self, ev): 60 | self.mouse.dispatch_button_press(ev) 61 | 62 | def handle_ButtonReleaseEvent(self, ev): 63 | self.mouse.dispatch_button_release(ev) 64 | 65 | def handle_MotionNotifyEvent(self, ev): 66 | self.mouse.dispatch_motion(ev) 67 | 68 | def handle_MapRequestEvent(self, ev): 69 | try: 70 | win = self.windows[ev.window] 71 | except KeyError: 72 | log.warning("Configure request for non-existent window %r", 73 | ev.window) 74 | else: 75 | win.want.visible = True 76 | if win.frame is None: 77 | frm = win.create_frame() 78 | self.frames[frm.wid] = frm 79 | self.all_windows[frm.wid] = frm 80 | win.reparent_frame() 81 | if not hasattr(win, 'group'): 82 | self.classifier.apply(win) 83 | self.groupman.add_window(win) 84 | elif win.group.visible: 85 | win.show() 86 | 87 | def handle_EnterNotifyEvent(self, ev): 88 | if self.mouse.drag: 89 | return 90 | try: 91 | win = self.frames[ev.event] 92 | except KeyError: 93 | log.warning("Enter notify for non-existent window %r", ev.event) 94 | else: 95 | if ev.mode != self.xcore.NotifyMode.Grab: 96 | if hasattr(win, 'pointer_enter'): 97 | win.pointer_enter() 98 | if self.active_field: 99 | return 100 | if(win.props.get("WM_HINTS") is None 101 | or win.props.get('WM_HINTS')[0] & 1): 102 | win.focus() 103 | 104 | def handle_LeaveNotifyEvent(self, ev): 105 | if self.mouse.drag: 106 | return 107 | try: 108 | win = self.frames[ev.event] 109 | except KeyError: 110 | log.warning("Leave notify for non-existent window %r", ev.event) 111 | else: 112 | if ev.mode != self.xcore.NotifyMode.Grab: 113 | if hasattr(win, 'pointer_leave'): 114 | win.pointer_leave() 115 | 116 | def handle_MapNotifyEvent(self, ev): 117 | try: 118 | win = self.all_windows[ev.window] 119 | except KeyError: 120 | log.warning("Map notify for non-existent window %r", 121 | ev.window) 122 | else: 123 | if hasattr(win, 'group') and win.group.visible: 124 | win.real.visible = True 125 | if win.frame: 126 | win.frame.show() 127 | 128 | def handle_UnmapNotifyEvent(self, ev): 129 | if ev.event not in self.frames: 130 | return # do not need to track unmapping of unmanaged windows 131 | try: 132 | win = self.windows[ev.window] 133 | except KeyError: 134 | log.warning("Unmap notify for non-existent window %r", 135 | ev.window) 136 | else: 137 | win.real.visible = False 138 | win.done.visible = False 139 | if win.frame: 140 | win.ewmh.hiding_window(win) 141 | win.frame.hide() 142 | # According to the docs here should be reparenting of windows 143 | # to the root window, but that doesn't work well 144 | if hasattr(win, 'group'): 145 | win.group.remove_window(win) 146 | 147 | def handle_FocusInEvent(self, ev): 148 | if(ev.event == self.xcore.root_window 149 | and ev.mode not in (self.xcore.NotifyMode.Grab, 150 | self.xcore.NotifyMode.Ungrab) 151 | and ev.detail == getattr(self.xcore.NotifyDetail, 'None')): 152 | self.xcore.raw.SetInputFocus( 153 | focus=self.xcore.root_window, 154 | revert_to=self.xcore.InputFocus.PointerRoot, 155 | time=self.xcore.last_time, 156 | ) 157 | return 158 | try: 159 | win = self.all_windows[ev.event] 160 | except KeyError: 161 | log.warning("Focus request for non-existent window %r", 162 | ev.event) 163 | else: 164 | if(ev.mode not in (self.xcore.NotifyMode.Grab, 165 | self.xcore.NotifyMode.Ungrab) 166 | and ev.detail != self.xcore.NotifyDetail.Pointer): 167 | win.focus_in() 168 | 169 | def handle_FocusOutEvent(self, ev): 170 | try: 171 | win = self.all_windows[ev.event] 172 | except KeyError: 173 | log.warning("Focus request for non-existent window %r", 174 | ev.event) 175 | else: 176 | if(ev.mode not in (self.xcore.NotifyMode.Grab, 177 | self.xcore.NotifyMode.Ungrab) 178 | and ev.detail != self.xcore.NotifyDetail.Pointer): 179 | win.focus_out() 180 | 181 | def handle_CreateNotifyEvent(self, ev): 182 | win = di(self).inject(Window.from_notify(ev)) 183 | if win.wid in self.windows: 184 | log.warning("Create notify for already existent window %r", 185 | win.wid) 186 | # TODO(tailhook) clean up old window 187 | if win.wid in self.all_windows: 188 | return 189 | win.done.size = win.want.size 190 | self.xcore.raw.ChangeWindowAttributes(window=win, params={ 191 | self.xcore.CW.EventMask: self.xcore.EventMask.PropertyChange 192 | }) 193 | self.windows[win.wid] = win 194 | self.all_windows[win.wid] = win 195 | try: 196 | for name in self.xcore.raw.ListProperties(window=win)['atoms']: 197 | win.update_property(name) 198 | except XError: 199 | log.warning("Window destroyed immediately %d", win.wid) 200 | 201 | def handle_ConfigureNotifyEvent(self, ev): 202 | pass 203 | 204 | def handle_ReparentNotifyEvent(self, ev): 205 | pass 206 | 207 | def handle_DestroyNotifyEvent(self, ev): 208 | try: 209 | win = self.all_windows.pop(ev.window) 210 | except KeyError: 211 | log.warning("Destroy notify for non-existent window %r", 212 | ev.window) 213 | else: 214 | self.windows.pop(win.wid, None) 215 | self.frames.pop(win.wid, None) 216 | if hasattr(win, 'group'): 217 | win.group.remove_window(win) 218 | win.destroyed() 219 | 220 | def handle_ConfigureRequestEvent(self, ev): 221 | try: 222 | win = self.windows[ev.window] 223 | except KeyError: 224 | log.warning("Configure request for non-existent window %r", 225 | ev.window) 226 | else: 227 | win.update_size_request(ev) 228 | 229 | def handle_PropertyNotifyEvent(self, ev): 230 | try: 231 | win = self.windows[ev.window] 232 | except KeyError: 233 | log.warning("Property notify event for non-existent window %r", 234 | ev.window) 235 | else: 236 | win.update_property(ev.atom) 237 | 238 | def handle_ExposeEvent(self, ev): 239 | try: 240 | win = self.all_windows[ev.window] 241 | except KeyError: 242 | log.warning("Expose event for non-existent window %r", 243 | ev.window) 244 | else: 245 | win.expose(Rectangle(ev.x, ev.y, ev.width, ev.height)) 246 | 247 | def handle_ClientMessageEvent(self, ev): 248 | type = self.xcore.atom[ev.type] 249 | # import struct 250 | # print("ClientMessage", ev, repr(type), struct.unpack('<5L', ev.data)) 251 | win = self.all_windows[ev.window] 252 | if hasattr(win, 'client_message'): 253 | win.client_message(ev) 254 | else: 255 | log.warning("Unhandled client message %r %r %r", 256 | ev, type, struct.unpack('<5L', ev.data)) 257 | 258 | def handle_ScreenChangeNotifyEvent(self, ev): 259 | # We only poll for events and use Xinerama for screen querying 260 | # because some drivers (nvidia) doesn't provide xrandr data 261 | # correctly 262 | if self.config['auto-screen-configuration']: 263 | if randr.check_screens(self.xcore): 264 | randr.configure_outputs(self.xcore, 265 | self.config['screen-dpi']/25.4) 266 | info = self.xcore.xinerama.QueryScreens()['screen_info'] 267 | self.screenman.update(list( 268 | Rectangle(scr['x_org'], scr['y_org'], 269 | scr['width'], scr['height']) for scr in info)) 270 | self.groupman.check_screens() 271 | 272 | def handle_NotifyEvent(self, ev): # Xrandr events are reported here 273 | log.warning("Notify event %r", ev) 274 | 275 | def handle_MappingNotifyEvent(self, ev): 276 | self.mapping_notify.emit() 277 | 278 | def _mapping_notify_delayed(self): 279 | self.keys.reconfigure_keys() 280 | 281 | -------------------------------------------------------------------------------- /tilenol/ewmh.py: -------------------------------------------------------------------------------- 1 | import struct 2 | from zorro.di import di, has_dependencies, dependency 3 | 4 | from tilenol.xcb import Core, Rectangle 5 | 6 | 7 | @has_dependencies 8 | class Ewmh(object): 9 | 10 | xcore = dependency(Core, 'xcore') 11 | dispatcher = dependency(object, 'event-dispatcher') 12 | 13 | def __zorro_di_done__(self): 14 | self.window = Window(self.xcore.create_toplevel( 15 | Rectangle(0, 0, 1, 1), 16 | klass=self.xcore.WindowClass.InputOnly, 17 | params={})) 18 | di(self).inject(self.window) 19 | self.xcore.raw.ChangeProperty( 20 | window=self.xcore.root_window, 21 | mode=self.xcore.PropMode.Replace, 22 | property=self.xcore.atom._NET_SUPPORTING_WM_CHECK, 23 | type=self.xcore.atom.WINDOW, 24 | format=32, 25 | data_len=1, 26 | data=struct.pack('') 94 | def do_bs(self): 95 | if not self.sel_width: 96 | if self.sel_start <= 0: 97 | return 98 | self.sel_start -= 1 99 | self.sel_width += 1 100 | self._clearsel() 101 | 102 | @key('') 103 | def do_del(self): 104 | if not self.sel_width: 105 | self.sel_width += 1 106 | self._clearsel() 107 | 108 | @key('') 109 | def do_left(self): 110 | if not self.sel_width: 111 | self.sel_start -= 1 112 | self.sel_width = 0 113 | 114 | @key('') 115 | def do_right(self): 116 | if self.sel_width: 117 | self.sel_start += self.sel_width 118 | self.sel_width = 0 119 | else: 120 | self.sel_start += 1 121 | 122 | @key('') 123 | def do_submit(self): 124 | if 'submit' in self.events: 125 | self.events['submit'].emit() 126 | 127 | @key('') 128 | def do_complete(self): 129 | if 'complete' in self.events: 130 | self.events['complete'].emit() 131 | 132 | @key('') 133 | def do_close(self): 134 | if 'close' in self.events: 135 | self.events['close'].emit() 136 | 137 | def draw(self, canvas): 138 | sx, sy, tw, th, ax, ay = canvas.text_extents(self.value) 139 | one = self.value[0:self.sel_start] 140 | two = self.value[self.sel_start:self.sel_start+self.sel_width] 141 | three = self.value[self.sel_start+self.sel_width:] 142 | self.theme.font.apply(canvas) 143 | canvas.move_to(self.theme.padding.left, 144 | self.theme.line_height - self.theme.padding.bottom) 145 | canvas.set_source(self.theme.text_pat) 146 | canvas.show_text(one) 147 | x, y = canvas.get_current_point() 148 | canvas.fill() 149 | if two: 150 | sx, sy, tw, th, ax, ay = canvas.text_extents(two) 151 | canvas.set_source(self.theme.selection) 152 | canvas.rectangle(x, self.theme.padding.top, 153 | tw, self.theme.line_height - self.theme.padding.bottom) 154 | canvas.fill() 155 | canvas.move_to(x, y) 156 | canvas.set_source(self.theme.selection_text_pat) 157 | canvas.show_text(two) 158 | x, y = canvas.get_current_point() 159 | canvas.fill() 160 | else: 161 | canvas.set_source(self.theme.cursor_pat) 162 | canvas.rectangle(x, self.theme.padding.top, 163 | 1, self.theme.line_height - self.theme.padding.bottom) 164 | canvas.fill() 165 | canvas.move_to(x, y) 166 | canvas.set_source(self.theme.text_pat) 167 | canvas.show_text(three) 168 | canvas.fill() 169 | -------------------------------------------------------------------------------- /tilenol/gadgets/menu.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import shlex 4 | import subprocess 5 | from itertools import islice 6 | from operator import itemgetter 7 | 8 | from zorro.di import has_dependencies, dependency, di 9 | 10 | from .base import GadgetBase, TextField 11 | from tilenol.commands import CommandDispatcher 12 | from tilenol.window import DisplayWindow 13 | from tilenol.events import EventDispatcher 14 | from tilenol.event import Event 15 | from tilenol.config import Config 16 | from tilenol.ewmh import get_title 17 | 18 | 19 | @has_dependencies 20 | class Select(GadgetBase): 21 | 22 | commander = dependency(CommandDispatcher, 'commander') 23 | dispatcher = dependency(EventDispatcher, 'event-dispatcher') 24 | 25 | def __init__(self, max_lines=10): 26 | self.window = None 27 | self.max_lines = max_lines 28 | self.redraw = Event('menu.redraw') 29 | self.redraw.listen(self._redraw) 30 | self.submit_ev = Event('menu.submit') 31 | self.submit_ev.listen(self._submit) 32 | self.complete = Event('menu.complete') 33 | self.complete.listen(self._complete) 34 | self.close = Event('menu.close') 35 | self.close.listen(self._close) 36 | 37 | def __zorro_di_done__(self): 38 | self.line_height = self.theme.menu.line_height 39 | 40 | def cmd_show(self): 41 | if self.window: 42 | self.cmd_hide() 43 | self._current_items = self.items() 44 | show_lines = min(len(self._current_items) + 1, self.max_lines) 45 | h = self.theme.menu.line_height 46 | self.height = h*self.max_lines 47 | bounds = self.commander['screen'].bounds._replace(height=h) 48 | self._img = self.xcore.pixbuf(bounds.width, h) 49 | wid = self.xcore.create_toplevel(bounds, 50 | klass=self.xcore.WindowClass.InputOutput, 51 | params={ 52 | self.xcore.CW.BackPixel: self.theme.menu.background, 53 | self.xcore.CW.OverrideRedirect: True, 54 | self.xcore.CW.EventMask: 55 | self.xcore.EventMask.FocusChange 56 | | self.xcore.EventMask.EnterWindow 57 | | self.xcore.EventMask.LeaveWindow 58 | | self.xcore.EventMask.KeymapState 59 | | self.xcore.EventMask.KeyPress, 60 | }) 61 | self.window = di(self).inject(DisplayWindow(wid, self.draw, 62 | focus_out=self._close)) 63 | self.dispatcher.all_windows[wid] = self.window 64 | self.dispatcher.frames[wid] = self.window # dirty hack 65 | self.window.show() 66 | self.window.focus() 67 | self.text_field = di(self).inject(TextField(self.theme.menu, events={ 68 | 'draw': self.redraw, 69 | 'submit': self.submit_ev, 70 | 'complete': self.complete, 71 | 'close': self.close, 72 | })) 73 | self.dispatcher.active_field = self.text_field 74 | self._redraw() 75 | 76 | def cmd_hide(self): 77 | self._close() 78 | 79 | def draw(self, rect=None): 80 | self._img.draw(self.window) 81 | 82 | def match_lines(self, value): 83 | matched = set() 84 | for line, res in self._current_items: 85 | if line in matched: continue 86 | if line.startswith(value): 87 | matched.add(line) 88 | yield (line, 89 | [(1, line[:len(value)]), (0, line[len(value):])], 90 | res) 91 | ncval = value.lower() 92 | for line, res in self._current_items: 93 | if line in matched: continue 94 | if line.lower().startswith(value): 95 | matched.add(line) 96 | yield (line, 97 | [(1, line[:len(value)]), (0, line[len(value):])], 98 | res) 99 | for line, res in self._current_items: 100 | if line in matched: continue 101 | if ncval in line.lower(): 102 | matched.add(line) 103 | opcodes = [] 104 | for pt in re.compile('((?i)'+re.escape(value)+')').split(line): 105 | opcodes.append((pt.lower() == ncval, pt)) 106 | yield (line, opcodes, res) 107 | 108 | def _redraw(self): 109 | if not self.window and not self.text_field: 110 | return 111 | lines = list(islice(self.match_lines(self.text_field.value), 112 | self.max_lines)) 113 | newh = (len(lines)+1)*self.line_height 114 | if newh != self.height: 115 | # don't need to render, need resize 116 | self.height = newh 117 | bounds = self.commander['screen'].bounds._replace(height=newh) 118 | self._img = self.xcore.pixbuf(bounds.width, newh) 119 | self.window.set_bounds(bounds) 120 | ctx = self._img.context() 121 | ctx.set_source(self.theme.menu.background_pat) 122 | ctx.rectangle(0, 0, self._img.width, self._img.height) 123 | ctx.fill() 124 | sx, sy, _, _, ax, ay = ctx.text_extents(self.text_field.value) 125 | self.text_field.draw(ctx) 126 | th = self.theme.menu 127 | pad = th.padding 128 | y = self.line_height 129 | for text, opcodes, value in lines: 130 | ctx.move_to(pad.left, y + self.line_height - pad.bottom) 131 | for op, tx in opcodes: 132 | ctx.set_source(th.highlight_pat if op else th.text_pat) 133 | ctx.show_text(tx) 134 | y += self.line_height 135 | self.draw() 136 | 137 | def _submit(self): 138 | input = self.text_field.value 139 | matched = None 140 | value = None 141 | for matched, opcodes, value in self.match_lines(input): 142 | break 143 | self.submit(input, matched, value) 144 | self._close() 145 | 146 | def _close(self): 147 | if self.window: 148 | self.window.destroy() 149 | self.window = None 150 | if self.dispatcher.active_field == self.text_field: 151 | self.dispatcher.active_field = None 152 | self.text_field = None 153 | 154 | def _complete(self): 155 | text, _, val = next(iter(self.match_lines(self.text_field.value))) 156 | self.text_field.value = text 157 | self.text_field.sel_start = len(text) 158 | self.text_field.sel_width = 0 159 | self.redraw.emit() 160 | 161 | 162 | class SelectExecutable(Select): 163 | 164 | def __init__(self, *, 165 | env_var='PATH', 166 | update_cmd='bash -lc ${env_var}', 167 | **kw): 168 | super().__init__(**kw) 169 | self.env_var = env_var 170 | self.paths = list(filter(bool, map(str.strip, 171 | os.environ.get(self.env_var, '').split(':')))) 172 | if update_cmd: 173 | self.update_cmd = shlex.split(update_cmd.format_map(self.__dict__)) 174 | 175 | def items(self): 176 | names = set() 177 | for i in self.paths: 178 | try: 179 | lst = os.listdir(i) 180 | except OSError: 181 | continue 182 | names.update((fn, fn) for fn in lst) 183 | return sorted(names) 184 | 185 | def cmd_refresh(self): 186 | data = subprocess.check_output(self.update_cmd) 187 | self.paths = filter(bool, map(str.strip, 188 | data.decode('ascii').split(':'))) 189 | 190 | def submit(self, input, matched, value): 191 | self.commander['env'].cmd_shell(input) 192 | 193 | 194 | @has_dependencies 195 | class SelectLayout(Select): 196 | 197 | config = dependency(Config, 'config') 198 | 199 | def items(self): 200 | return sorted(self.config.all_layouts().items(), key=itemgetter(0)) 201 | 202 | def submit(self, input, matched, value): 203 | self.commander['group'].cmd_set_layout(matched) 204 | 205 | 206 | @has_dependencies 207 | class FindWindow(Select): 208 | 209 | commander = dependency(CommandDispatcher, 'commander') 210 | 211 | def items(self): 212 | items = [] 213 | for g in self.commander['groups'].groups: 214 | for win in g.all_windows: 215 | t = (get_title(win) 216 | or win.props.get('WM_ICON_NAME') 217 | or win.props.get('WM_CLASS')) 218 | items.append((t, win)) 219 | return sorted(items, key=itemgetter(0)) 220 | 221 | def submit(self, input, matched, value): 222 | self.commander['groups'].cmd_switch(value.group.name) 223 | 224 | 225 | @has_dependencies 226 | class RenameWindow(Select): 227 | 228 | commander = dependency(CommandDispatcher, 'commander') 229 | 230 | def items(self): 231 | win = self._target_window 232 | titles = [ 233 | win.props.get("_NET_WM_VISIBLE_NAME"), 234 | win.props.get("_NET_WM_NAME"), 235 | win.props.get("WM_NAME"), 236 | win.props.get("WM_ICON_NAME"), 237 | win.props.get("WM_CLASS").replace('\0', ' '), 238 | win.props.get("WM_WINDOW_ROLE"), 239 | ] 240 | res = [] 241 | for t in titles: 242 | if not t: continue 243 | if res and res[-1][0] == t: continue 244 | res.append((t, t)) 245 | return res 246 | 247 | def submit(self, input, matched, value): 248 | self._target_window.set_property('_NET_WM_VISIBLE_NAME', input) 249 | 250 | def cmd_show(self): 251 | self._target_window = self.commander['window'] 252 | super().cmd_show() 253 | 254 | def _close(self): 255 | super()._close() 256 | if hasattr(self, '_target_window'): 257 | del self._target_window 258 | 259 | def cmd_clear_name(self): 260 | self.commander['window'].set_property('_NET_WM_VISIBLE_NAME', None) 261 | -------------------------------------------------------------------------------- /tilenol/gadgets/tabs.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | 3 | from tilenol.screen import ScreenManager 4 | from tilenol.events import EventDispatcher 5 | from tilenol.ewmh import get_title 6 | from tilenol.xcb import Core as XCore, Rectangle 7 | from tilenol.commands import CommandDispatcher 8 | from tilenol.event import Event 9 | from tilenol.theme import Theme 10 | from tilenol.window import DisplayWindow, Window 11 | from tilenol.icccm import is_window_urgent 12 | from .base import GadgetBase 13 | 14 | from zorro.di import di, has_dependencies, dependency 15 | 16 | WindowState = namedtuple('WindowState', 17 | ('title', 'icon', 'active', 'urgent', 'win')) 18 | 19 | 20 | @has_dependencies 21 | class State(object): 22 | 23 | commander = dependency(CommandDispatcher, 'commander') 24 | 25 | def __init__(self, gr): 26 | self._state = None 27 | self._group = gr 28 | 29 | def dirty(self): 30 | return self._state != self._read() 31 | 32 | def update(self): 33 | nval = self._read() 34 | if nval != self._state: 35 | self._state = nval 36 | return True 37 | 38 | def _read(self): 39 | cur = self.commander.get('window') 40 | gr = self._group 41 | subs = list(gr.current_layout.sublayouts()) 42 | res = [] 43 | for sec in subs: 44 | wins = sec.windows 45 | if wins: 46 | title = getattr(sec, 'title', sec.__class__.__name__) 47 | sect = [title] 48 | for win in wins: 49 | sect.append(self._winstate(win, cur)) 50 | res.append(sect) 51 | if gr.floating_windows: 52 | sect = ['floating'] 53 | for win in gr.floating_windows: 54 | sect.append(self._winstate(win, cur)) 55 | return res 56 | 57 | def _winstate(self, win, cur): 58 | return WindowState( 59 | title=get_title(win) or win.props.get("WM_CLASS") or hex(win), 60 | icon=getattr(win, 'icons', None), 61 | active=win is cur, 62 | urgent=is_window_urgent(win), 63 | win=win, 64 | ) 65 | 66 | @property 67 | def sections(self): 68 | return self._state 69 | 70 | 71 | @has_dependencies 72 | class LeftBar(object): 73 | 74 | xcore = dependency(XCore, 'xcore') 75 | theme = dependency(Theme, 'theme') 76 | dispatcher = dependency(EventDispatcher, 'event-dispatcher') 77 | commander = dependency(CommandDispatcher, 'commander') 78 | 79 | def __init__(self, screen, width, groups, states): 80 | self.screen = screen 81 | self.width = width 82 | self._cairo = None 83 | self._img = None 84 | self.redraw = Event('leftbar.redraw') 85 | self.redraw.listen(self._redraw) 86 | self.repaint = Event('leftbar.repaint') 87 | self.repaint.listen(self._paint) 88 | self.screen.add_group_hook(self._group_hook) 89 | self.visible = False 90 | self.groups = groups 91 | self.states = states 92 | self._drawn_group = None 93 | 94 | def __zorro_di_done__(self): 95 | wid = self.xcore.create_toplevel(Rectangle(0, 0, 1, 1), 96 | klass=self.xcore.WindowClass.InputOutput, 97 | params={ 98 | self.xcore.CW.BackPixel: self.theme.menu.background, 99 | self.xcore.CW.OverrideRedirect: True, 100 | self.xcore.CW.EventMask: self.xcore.EventMask.Exposure, 101 | }) 102 | self.window = di(self).inject(DisplayWindow(wid, self.paint)) 103 | self.dispatcher.all_windows[wid] = self.window 104 | self.window.show() 105 | if self.screen.group: 106 | self._group_hook() 107 | self.commander.events['window'].listen(self._check_redraw) 108 | Window.any_window_changed.listen(self._check_redraw) 109 | 110 | def paint(self, rect): 111 | self.repaint.emit() 112 | 113 | def _check_redraw(self): 114 | st = self.states.get(self.screen.group) 115 | if st is None or st.dirty: 116 | self.redraw.emit() 117 | 118 | def set_bounds(self, rect): 119 | self.bounds = rect 120 | self.window.set_bounds(rect) 121 | self._cairo = None 122 | self._img = None 123 | 124 | def _draw_section(self, title, y): 125 | ctx = self._cairo 126 | theme = self.theme.tabs 127 | ctx.set_source(theme.section_color_pat) 128 | sx, sy, tw, th, ax, ay = ctx.text_extents(title) 129 | y += theme.section_padding.top + th 130 | x = self.width - theme.section_padding.right - tw 131 | ctx.move_to(x, y) 132 | ctx.show_text(title) 133 | y += theme.section_padding.bottom 134 | return y 135 | 136 | def _draw_win(self, win, y): 137 | ctx = self._cairo 138 | theme = self.theme.tabs 139 | sx, sy, tw, th, ax, ay = ctx.text_extents(win.title) 140 | fh = th + theme.padding.top + theme.padding.bottom 141 | # Background 142 | if win.active: 143 | ctx.set_source(theme.active_bg_pat) 144 | elif win.urgent: 145 | ctx.set_source(theme.urgent_bg_pat) 146 | else: 147 | ctx.set_source(theme.inactive_bg_pat) 148 | ctx.move_to(self.width, y) 149 | ctx.line_to(theme.margin.left + theme.border_radius, y) 150 | ctx.curve_to(theme.margin.left + theme.border_radius/3, y, 151 | theme.margin.left, y + theme.border_radius/3, 152 | theme.margin.left, y + theme.border_radius) 153 | ctx.line_to(theme.margin.left, y + fh - theme.border_radius) 154 | ctx.curve_to(theme.margin.left, y + fh - theme.border_radius/3, 155 | theme.margin.left + theme.border_radius/3, y + fh, 156 | theme.margin.left + theme.border_radius, y + fh) 157 | ctx.line_to(self.width, y + fh) 158 | ctx.close_path() 159 | ctx.fill() 160 | # Icon 161 | if getattr(win.win, 'icons', None): 162 | win.win.draw_icon(ctx, 163 | theme.margin.left + theme.padding.left, 164 | y + (th + theme.padding.top + theme.padding.bottom 165 | - theme.icon_size)//2, 166 | theme.icon_size) 167 | # Title 168 | if win.active: 169 | ctx.set_source(theme.active_title_pat) 170 | elif win.urgent: 171 | ctx.set_source(theme.urgent_title_pat) 172 | else: 173 | ctx.set_source(theme.inactive_title_pat) 174 | x = theme.margin.left + theme.padding.left 175 | x += theme.icon_size + theme.icon_spacing 176 | ctx.move_to(x, y + theme.padding.top + th) 177 | y += th + theme.spacing + theme.padding.top + theme.padding.bottom 178 | ctx.show_text(win.title) 179 | ctx.fill() 180 | return y 181 | 182 | def _paint(self): 183 | if self._img: 184 | self._img.draw(self.window) 185 | 186 | def _redraw(self): 187 | if not self.visible: 188 | return 189 | gr = self.screen.group 190 | st = self.states.get(gr) 191 | if st is None: 192 | st = self.states[gr] = di(self).inject(State(gr)) 193 | if not st.update() and self._img and self._drawn_group == gr: 194 | return 195 | if self._img is None: 196 | self._img = self.xcore.pixbuf(self.width, self.bounds.height) 197 | self._cairo = self._img.context() 198 | theme = self.theme.tabs 199 | ctx = self._cairo 200 | ctx.set_source(theme.background_pat) 201 | ctx.rectangle(0, 0, self._img.width, self._img.height) 202 | ctx.fill() 203 | theme.font.apply(ctx) 204 | y = theme.margin.top 205 | for sec in st.sections: 206 | for win in sec: 207 | if isinstance(win, str): 208 | if len(st.sections) > 1: 209 | y = self._draw_section(win, y) 210 | else: 211 | y = self._draw_win(win, y) 212 | self._drawn_group = gr 213 | self.repaint.emit() 214 | 215 | def _group_hook(self): 216 | ngr = self.screen.group 217 | if ngr.name in self.groups: 218 | self.show() 219 | else: 220 | self.hide() 221 | self.redraw.emit() 222 | 223 | def show(self): 224 | if not self.visible: 225 | self.visible = True 226 | self.window.show() 227 | self.screen.slice_left(self) 228 | 229 | def hide(self): 230 | if self.visible: 231 | self.visible = False 232 | self.screen.unslice_left(self) 233 | self.window.hide() 234 | self._cairo = None 235 | self._img = None 236 | 237 | 238 | @has_dependencies 239 | class Tabs(GadgetBase): 240 | 241 | screens = dependency(ScreenManager, 'screen-manager') 242 | commander = dependency(CommandDispatcher, 'commander') 243 | 244 | def __init__(self, width=256, groups=()): 245 | self.bars = {} 246 | self.groups = set(groups) 247 | self.width = width 248 | self.states = {} 249 | 250 | def __zorro_di_done__(self): 251 | for s in self.screens.screens: 252 | bar = di(self).inject(LeftBar(s, self.width, 253 | self.groups, self.states)) 254 | self.bars[s] = bar 255 | if s.group.name in self.groups: 256 | s.slice_left(bar) 257 | 258 | def cmd_toggle(self): 259 | gr = self.commander['group'] 260 | if gr.name in self.groups: 261 | self.groups.remove(gr.name) 262 | else: 263 | self.groups.add(gr.name) 264 | self._update_bars() 265 | 266 | def cmd_show(self): 267 | gr = self.commander['group'] 268 | self.groups.add(gr.name) 269 | self._update_bars() 270 | 271 | def cmd_hide(self): 272 | if gr.name in self.groups: 273 | self.groups.remove(gr.name) 274 | self._update_bars() 275 | 276 | def _update_bars(self): 277 | for s, bar in self.bars.items(): 278 | if s.group.name in self.groups: 279 | bar.show() 280 | else: 281 | bar.hide() 282 | 283 | -------------------------------------------------------------------------------- /tilenol/gestures.py: -------------------------------------------------------------------------------- 1 | import ctypes 2 | from math import atan2, pi, sqrt 3 | from collections import defaultdict 4 | 5 | from zorro import gethub, sleep 6 | from zorro.util import marker_object 7 | from zorro.di import has_dependencies, dependency 8 | 9 | from tilenol.xcb import shm 10 | from .event import Event 11 | from .commands import CommandDispatcher 12 | from .config import Config 13 | 14 | 15 | GRAD = pi/180 16 | directions = { 17 | 'up': lambda a, min=-160*GRAD, max=160*GRAD: a < min or a > max, 18 | 'upright': lambda a, max=160*GRAD, min=110*GRAD: min <= a <= max, 19 | 'right': lambda a, max=110*GRAD, min=70*GRAD: min < a < max, 20 | 'downright': lambda a, max=70*GRAD, min=20*GRAD: min <= a <= max, 21 | 'down': lambda a, max=20*GRAD, min=-20*GRAD: min < a < max, 22 | 'downleft': lambda a, max=-20*GRAD, min=-70*GRAD: min <= a <= max, 23 | 'left': lambda a, max=-70*GRAD, min=-110*GRAD: min < a < max, 24 | 'upleft': lambda a, max=-110*GRAD, min=-160*GRAD: min <= a <= max, 25 | } 26 | SYNAPTICS_SHM = 23947 27 | START = marker_object('gestures.START') 28 | PARTIAL = marker_object('gestures.PARTIAL') 29 | FULL = marker_object('gestures.FULL') 30 | COMMIT = marker_object('gestures.COMMIT') 31 | UNDO = marker_object('gestures.UNDO') 32 | CANCEL = marker_object('gestures.CANCEL') 33 | 34 | 35 | class SynapticsSHM(ctypes.Structure): 36 | _fields_ = [ 37 | ('version', ctypes.c_int), 38 | # Current device state 39 | ('x', ctypes.c_int), 40 | ('y', ctypes.c_int), 41 | ('z', ctypes.c_int), # pressure 42 | ('numFingers', ctypes.c_int), 43 | ('fingerWidth', ctypes.c_int), 44 | ('left', ctypes.c_int), # buttons 45 | ('right', ctypes.c_int), 46 | ('up', ctypes.c_int), 47 | ('down', ctypes.c_int), 48 | ('multi', ctypes.c_bool * 8), 49 | ('middle', ctypes.c_bool), 50 | ] 51 | 52 | 53 | @has_dependencies 54 | class Gestures(object): 55 | 56 | config = dependency(Config, 'config') 57 | commander = dependency(CommandDispatcher, 'commander') 58 | 59 | def __init__(self): 60 | self.callbacks = defaultdict(list) 61 | 62 | def __zorro_di_done__(self): 63 | self.cfg = self.config.gestures() 64 | gethub().do_spawnhelper(self._shm_checker) 65 | 66 | def add_callback(self, name, fun): 67 | self.callbacks[name].append(fun) 68 | 69 | @property 70 | def active_gestures(self): 71 | return self.cfg.keys() 72 | 73 | def _shm_checker(self): 74 | shmid = shm.shmget(SYNAPTICS_SHM, ctypes.sizeof(SynapticsSHM), 0) 75 | if shmid < 0: 76 | raise RuntimeError("No synaptics driver loaded") 77 | addr = shm.shmat(shmid, None, shm.SHM_RDONLY) 78 | if addr > (1 << 63): # (int)addr < 0 79 | raise RuntimeError("Can't attach SHM") 80 | try: 81 | struct = ctypes.cast(addr, ctypes.POINTER(SynapticsSHM)) 82 | self._shm_loop(struct) 83 | finally: 84 | shm.shmdt(addr) 85 | 86 | def _shm_loop(self, ptr): 87 | while True: 88 | sleep(0.2) 89 | struct = ptr[0] 90 | if struct.numFingers >= 2: 91 | initialx = struct.x 92 | initialy = struct.y 93 | initialf = struct.numFingers 94 | gesture_prefix = '{}f-'.format(initialf) 95 | full = False 96 | name = None 97 | while initialf == struct.numFingers: 98 | sleep(0.05) 99 | dx = struct.x - initialx 100 | dy = struct.y - initialy 101 | angle = atan2(dx, dy) 102 | dist = sqrt(dx*dx + dy*dy) 103 | for name, cfg in self.cfg.items(): 104 | if not name.startswith(gesture_prefix): 105 | continue 106 | cond = cfg['condition'] 107 | if cond(angle) and dist > cfg['detect-distance']: 108 | callbacks = self.callbacks[name] 109 | break 110 | else: 111 | continue 112 | break 113 | else: 114 | continue 115 | for f in callbacks: 116 | f(name, 0, START, cfg) 117 | 118 | while initialf == struct.numFingers: 119 | sleep(0.05) 120 | dx = struct.x - initialx 121 | dy = struct.y - initialy 122 | angle = atan2(dx, dy) 123 | dist = sqrt(dx*dx + dy*dy) 124 | percent = dist / cfg['commit-distance'] 125 | full = percent >= 1 126 | if not cond(angle) or dist < cfg['detect-distance']: 127 | for f in callbacks: 128 | f(name, percent, UNDO, cfg) 129 | else: 130 | state = FULL if full else PARTIAL 131 | for f in callbacks: 132 | f(name, percent, state, cfg) 133 | 134 | if full: 135 | for f in callbacks: 136 | f(name, 1, COMMIT, cfg) 137 | self.commander.callback(*cfg['action'])() 138 | else: 139 | for f in callbacks: 140 | f(name, 0, CANCEL, cfg) 141 | 142 | 143 | 144 | -------------------------------------------------------------------------------- /tilenol/groups.py: -------------------------------------------------------------------------------- 1 | from itertools import chain 2 | 3 | from zorro.di import has_dependencies, dependency, di 4 | 5 | from .screen import ScreenManager 6 | from .event import Event 7 | from .icccm import is_window_urgent 8 | from .commands import CommandDispatcher 9 | from .config import Config 10 | 11 | 12 | @has_dependencies 13 | class GroupManager(object): 14 | 15 | screenman = dependency(ScreenManager, 'screen-manager') 16 | commander = dependency(CommandDispatcher, 'commander') 17 | 18 | def __init__(self, groups): 19 | self.groups = list(groups) 20 | self.by_name = {g.name: g for g in self.groups} 21 | self.group_changed = Event('group-manager.group_changed') 22 | for g in self.groups: 23 | g.manager = self 24 | 25 | def __zorro_di_done__(self): 26 | # TODO(tailhook) implement several screens 27 | for g in self.groups: 28 | di(self).inject(g) 29 | self.current_groups = {} 30 | for i, s in enumerate(self.screenman.screens): 31 | gr = self.groups[i] 32 | self.current_groups[s] = gr 33 | gr.screen = s 34 | gr.show() 35 | if i == 0: 36 | self.commander['group'] = gr 37 | self.commander['screen'] = s 38 | self.commander['layout'] = g.current_layout 39 | 40 | def check_screens(self): 41 | for s, gr in list(self.current_groups.items()): 42 | if s not in self.screenman.screens: 43 | gr.hide() 44 | gr.screen = None 45 | del self.current_groups[s] 46 | for i, s in enumerate(self.screenman.screens): 47 | if getattr(s, 'group', None): 48 | continue 49 | for gr in self.groups: 50 | if not gr.screen: 51 | break 52 | else: 53 | return # no free groups 54 | self.current_groups[s] = gr 55 | gr.screen = s 56 | gr.show() 57 | 58 | def add_window(self, win): 59 | if(isinstance(win.lprops.group, int) 60 | and win.lprops.group < len(self.groups)): 61 | ngr = self.groups[win.lprops.group] 62 | elif 'group' in self.commander: 63 | ngr = self.commander['group'] 64 | else: 65 | ngr = self.current_groups[self.screenman.screens[0]] 66 | ngr.add_window(win) 67 | win.lprops.group = self.groups.index(ngr) 68 | 69 | def cmd_switch(self, name): 70 | ngr = self.by_name[name] 71 | ogr = self.commander['group'] 72 | if ngr is ogr: 73 | return 74 | if ngr in self.current_groups.values(): 75 | ogr.screen, ngr.screen = ngr.screen, ogr.screen 76 | self.current_groups[ngr.screen] = ngr 77 | self.current_groups[ogr.screen] = ogr 78 | else: 79 | ogr.hide() 80 | s = ogr.screen 81 | ogr.screen = None 82 | self.current_groups[s] = ngr 83 | ngr.screen = s 84 | ngr.show() 85 | self.commander['group'] = ngr 86 | self.commander['layout'] = ngr.current_layout 87 | self.commander['screen'] = ngr.screen 88 | self.group_changed.emit() 89 | 90 | def cmd_switch_next(self): 91 | ogr = self.commander['group'] 92 | n = self.groups.index(ogr) 93 | if n+1 < len(self.groups): 94 | self.cmd_switch(self.groups[n+1].name) 95 | 96 | def cmd_switch_prev(self): 97 | ogr = self.commander['group'] 98 | n = self.groups.index(ogr) 99 | if n > 0: 100 | self.cmd_switch(self.groups[n-1].name) 101 | 102 | def cmd_move_window_to(self, name): 103 | ngr = self.by_name[name] 104 | if 'window' not in self.commander: 105 | return 106 | win = self.commander['window'] 107 | if ngr is win.group: 108 | return 109 | win.group.remove_window(win) 110 | win.hide() 111 | ngr.add_window(win) 112 | win.lprops.group = self.groups.index(ngr) 113 | 114 | 115 | @has_dependencies 116 | class Group(object): 117 | 118 | commander = dependency(CommandDispatcher, 'commander') 119 | config = dependency(Config, 'config') 120 | 121 | def __init__(self, name, layout_class): 122 | self.name = name 123 | self.default_layout = layout_class 124 | self.current_layout = layout_class() 125 | self.current_layout.group = self 126 | self.floating_windows = [] 127 | self.all_windows = [] 128 | self._screen = None 129 | 130 | def __zorro_di_done__(self): 131 | di(self).inject(self.current_layout) 132 | 133 | def __repr__(self): 134 | return '<{} {}>'.format(self.__class__.__name__, self.name) 135 | 136 | @property 137 | def empty(self): 138 | return not self.all_windows 139 | 140 | @property 141 | def visible(self): 142 | return bool(self.screen) 143 | 144 | def focus(self): 145 | all = list(self.current_layout.all_visible_windows()) 146 | all.extend(self.floating_windows) 147 | if all: 148 | all[0].focus() 149 | else: 150 | self.commander['layout'] = self.current_layout 151 | self.commander['group'] = self 152 | self.commander['screen'] = self.screen 153 | self.manager.group_changed.emit() 154 | 155 | def add_window(self, win): 156 | if win.lprops.floating: 157 | # Ensure that floating windows are always above others 158 | win.frame.restack(win.xcore.StackMode.Above) 159 | self.floating_windows.append(win) 160 | if self.screen: 161 | win.show() 162 | else: 163 | # Ensure that non-floating windows are always below floating 164 | win.frame.restack(win.xcore.StackMode.Below) 165 | self.current_layout.add(win) 166 | win.group = self 167 | self.all_windows.append(win) 168 | self.check_focus() 169 | 170 | def remove_window(self, win): 171 | assert win.group == self 172 | if win in self.floating_windows: 173 | self.floating_windows.remove(win) 174 | else: 175 | self.current_layout.remove(win) 176 | self.all_windows.remove(win) 177 | del win.group 178 | 179 | def hide(self): 180 | self.current_layout.hide() 181 | for win in self.floating_windows: 182 | win.hide() 183 | 184 | @property 185 | def screen(self): 186 | return self._screen 187 | 188 | @screen.setter 189 | def screen(self, screen): 190 | if self._screen: 191 | self._screen.updated.unlisten(self.update_size) 192 | self._screen = screen 193 | if screen is not None: 194 | screen.set_group(self) 195 | screen.updated.listen(self.update_size) 196 | self.set_bounds(screen.inner_bounds) 197 | 198 | def update_size(self): 199 | self.set_bounds(self.screen.inner_bounds) 200 | 201 | def set_bounds(self, rect): 202 | self.current_layout.set_bounds(rect) 203 | #for win in self.floating_windows: 204 | # win.set_screen(rect) 205 | 206 | def show(self): 207 | self.current_layout.show() 208 | for win in self.floating_windows: 209 | # TODO(tailhook) fix bounds when showing at different screen 210 | #win.set_screen(self.bounds) 211 | win.show() 212 | self.check_focus() 213 | 214 | def check_focus(self): 215 | if 'window' not in self.commander: 216 | try: 217 | win = next(iter(chain( 218 | self.current_layout.all_visible_windows(), 219 | self.floating_windows, 220 | ))) 221 | except StopIteration: 222 | pass 223 | else: 224 | win.frame.focus() 225 | 226 | @property 227 | def has_urgent_windows(self): 228 | return any(is_window_urgent(win) for win in self.all_windows) 229 | 230 | def cmd_focus_next(self): 231 | all = list(self.current_layout.all_visible_windows()) 232 | all.extend(self.floating_windows) 233 | try: 234 | win = self.commander['window'] 235 | except KeyError: 236 | nwin = all[0] 237 | else: 238 | idx = all.index(win) 239 | if idx + 1 >= len(all): 240 | nwin = all[0] 241 | else: 242 | nwin = all[idx+1] 243 | nwin.frame.focus() 244 | 245 | def cmd_focus_prev(self): 246 | all = list(self.current_layout.all_visible_windows()) 247 | all.extend(self.floating_windows) 248 | try: 249 | win = self.commander['window'] 250 | except KeyError: 251 | nwin = all[-1] 252 | else: 253 | idx = all.index(win) 254 | if idx > 0: 255 | nwin = all[idx - 1] 256 | else: 257 | nwin = all[-1] 258 | nwin.frame.focus() 259 | 260 | def cmd_set_layout(self, name): 261 | if self.screen: 262 | self.hide() 263 | lay = self.config.all_layouts()[name] 264 | self.current_layout = di(self).inject(lay()) 265 | self.current_layout.group = self 266 | for win in self.all_windows: 267 | if not win.lprops.floating: 268 | self.current_layout.add(win) 269 | if self.screen: 270 | self.set_bounds(self.screen.inner_bounds) 271 | self.show() 272 | -------------------------------------------------------------------------------- /tilenol/icccm.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | from fractions import Fraction 3 | 4 | 5 | USPosition = (1 << 0) # user specified x, y */ 6 | USSize = (1 << 1) # user specified width, height */ 7 | PPosition = (1 << 2) # program specified position */ 8 | PSize = (1 << 3) # program specified size */ 9 | PMinSize = (1 << 4) # program specified minimum size */ 10 | PMaxSize = (1 << 5) # program specified maximum size */ 11 | PResizeInc = (1 << 6) # program specified resize increments */ 12 | PAspect = (1 << 7) # program specified min and max aspect ratios */ 13 | PBaseSize = (1 << 8) 14 | PWinGravity = (1 << 9) 15 | 16 | InputHint = 1 17 | StateHint = 2 18 | IconPixmapHint = 4 19 | IconWindowHint = 8 20 | IconPositionHint = 16 21 | IconMaskHint = 32 22 | WindowGroupHint = 64 23 | MessageHint = 128 24 | UrgencyHint = 256 25 | 26 | 27 | class SizeHints(namedtuple('_SizeHints', ('flags', 'p1', 'p2', 'p3', 'p4', 28 | 'min_width', 'min_height', 29 | 'max_width', 'max_height', 30 | 'width_inc', 'height_inc', 'max_aspect_num', 31 | 'max_aspect_denom', 'base_width', 'base_height', 'win_gravity'))): 32 | __slots__ = () 33 | 34 | 35 | class SizeHints(object): 36 | 37 | @classmethod 38 | def from_property(self, type, arr): 39 | assert type.name == 'WM_SIZE_HINTS' 40 | flags = arr[0] 41 | hints = SizeHints() 42 | if flags & PMinSize: 43 | hints.min_width = arr[5] 44 | hints.min_height = arr[6] 45 | if flags & PMaxSize: 46 | hints.max_width = arr[7] 47 | hints.max_height = arr[8] 48 | if flags & PResizeInc: 49 | hints.width_inc = arr[9] 50 | hints.height_inc = arr[10] 51 | if flags & PAspect: 52 | hints.min_aspect = Fraction(arr[11], arr[12]) 53 | hints.max_aspect = Fraction(arr[13], arr[14]) 54 | if flags & PBaseSize: 55 | hints.base_width = arr[15] 56 | hints.base_height = arr[16] 57 | if flags & PWinGravity: 58 | hints.win_gravity = arr[17] 59 | return hints 60 | 61 | 62 | def is_window_urgent(win): 63 | hints = win.props.get('WM_HINTS') 64 | if hints is None: 65 | return False 66 | return bool(hints[0] & UrgencyHint) 67 | 68 | 69 | def is_window_needs_input(win): 70 | hints = win.props.get('WM_HINTS') 71 | if hints is None: 72 | return False 73 | return bool(hints[0] & InputHint) 74 | 75 | -------------------------------------------------------------------------------- /tilenol/keyregistry.py: -------------------------------------------------------------------------------- 1 | import re 2 | import logging 3 | 4 | from zorro.di import has_dependencies, dependency 5 | 6 | from .xcb import Keysyms, Core 7 | from .config import Config 8 | from .commands import CommandDispatcher 9 | 10 | 11 | hotkey_re = re.compile('^<[^>]+>|.$') 12 | log = logging.getLogger(__name__) 13 | 14 | 15 | @has_dependencies 16 | class KeyRegistry(object): 17 | 18 | keysyms = dependency(Keysyms, 'keysyms') 19 | xcore = dependency(Core, 'xcore') 20 | config = dependency(Config, 'config') 21 | commander = dependency(CommandDispatcher, 'commander') 22 | 23 | def __init__(self): 24 | self.keys = {} 25 | 26 | def configure_hotkeys(self): 27 | self.xcore.init_keymap() 28 | for key, cmd in self.config.keys(): 29 | self.add_key(key, self.commander.callback(*cmd)) 30 | self.register_keys(self.xcore.root_window) 31 | 32 | def reconfigure_keys(self): 33 | if self.keys: 34 | self.unregister_keys(self.xcore.root_window) 35 | self.keys = {} 36 | self.configure_hotkeys() 37 | 38 | def parse_key(self, keystr): 39 | mod = 0 40 | if keystr[0] == '<': 41 | keystr = keystr[1:-1] 42 | if '-' in keystr: 43 | mstr, sym = keystr.split('-') 44 | if 'S' in mstr: 45 | mod |= self.xcore.ModMask.Shift 46 | if 'C' in mstr: 47 | mod |= self.xcore.ModMask.Control 48 | if 'W' in mstr: 49 | mod |= getattr(self.xcore.ModMask, '4') 50 | else: 51 | sym = keystr 52 | else: 53 | sym = keystr 54 | if sym.lower() != sym: 55 | mod = self.xcore.ModMask.Shift 56 | sym = sym.lower() 57 | code = self.keysyms.name_to_code[sym] 58 | return mod, code 59 | 60 | def add_key(self, keystr, handler): 61 | m = hotkey_re.match(keystr) 62 | if not m: 63 | raise ValueError(keystr) 64 | try: 65 | modmask, keysym = self.parse_key(m.group(0)) 66 | except (KeyError, ValueError): 67 | log.error("Can't parse key %r", m.group(0)) 68 | else: 69 | self.keys[modmask, keysym] = handler 70 | 71 | def init_modifiers(self): 72 | # TODO(tailhook) probably calculate them instead of hardcoding 73 | caps = self.xcore.ModMask.Lock # caps lock 74 | num = getattr(self.xcore.ModMask, '2') # mod2 is usually numlock 75 | mode = getattr(self.xcore.ModMask, '5') # mod5 is usually mode_switch 76 | self.extra_modifiers = [0, 77 | caps, 78 | num, 79 | mode, 80 | caps|num, 81 | num|mode, 82 | caps|num|mode, 83 | ] 84 | self.modifiers_mask = ~(caps|num|mode) 85 | 86 | def register_keys(self, win): 87 | self.init_modifiers() 88 | for mod, key in self.keys: 89 | kcodes = self.xcore.keysym_to_keycode[key] 90 | if not kcodes: 91 | log.error("No mapping for key %r", 92 | self.keysyms.code_to_name[key]) 93 | continue 94 | for kcode in kcodes: 95 | for extra in self.extra_modifiers: 96 | self.xcore.raw.GrabKey( 97 | owner_events=False, 98 | grab_window=win, 99 | modifiers=mod|extra, 100 | key=kcode, 101 | keyboard_mode=self.xcore.GrabMode.Async, 102 | pointer_mode=self.xcore.GrabMode.Async, 103 | ) 104 | 105 | def unregister_keys(self, win): 106 | self.xcore.raw.UngrabKey( 107 | grab_window=win, 108 | modifiers=self.xcore.ModMask.Any, 109 | key=self.xcore.Grab.Any, 110 | ) 111 | 112 | def dispatch_event(self, event): 113 | try: 114 | kcode = self.xcore.keycode_to_keysym[event.detail] 115 | handler = self.keys[event.state & self.modifiers_mask, kcode] 116 | except KeyError: 117 | return False 118 | else: 119 | try: 120 | handler() 121 | except Exception as e: 122 | log.exception("Error handling keypress %r", event, 123 | exc_info=(type(e), e, e.__traceback__)) 124 | 125 | -------------------------------------------------------------------------------- /tilenol/layout/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import Layout 2 | from .tile import Split, Stack, TileStack 3 | -------------------------------------------------------------------------------- /tilenol/layout/base.py: -------------------------------------------------------------------------------- 1 | from collections import OrderedDict 2 | 3 | from zorro.di import di, has_dependencies, dependency 4 | 5 | from tilenol.event import Event 6 | 7 | 8 | class LayoutMeta(type): 9 | 10 | @classmethod 11 | def __prepare__(cls, name, bases): 12 | return OrderedDict() 13 | 14 | def __init__(cls, name, bases, dic): 15 | cls.fields = list(dic.keys()) 16 | 17 | 18 | @has_dependencies 19 | class Layout(metaclass=LayoutMeta): 20 | 21 | def __init__(self): 22 | self.visible = False 23 | self.relayout = Event('layout.relayout') 24 | self.relayout.listen(self.check_relayout) 25 | 26 | def check_relayout(self): 27 | if self.visible: 28 | self.layout() 29 | self.group.check_focus() 30 | 31 | @classmethod 32 | def get_defined_classes(cls, base): 33 | res = OrderedDict() 34 | for k in cls.fields: 35 | v = getattr(cls, k) 36 | if isinstance(v, type) and issubclass(v, base): 37 | res[k] = v 38 | return res 39 | 40 | def dirty(self): 41 | self.relayout.emit() 42 | 43 | def all_visible_windows(self): 44 | for i in getattr(self, 'visible_windows', ()): 45 | yield i 46 | sub = getattr(self, 'sublayouts', None) 47 | if sub: 48 | for s in sub(): 49 | for i in s.visible_windows: 50 | yield i 51 | 52 | def hide(self): 53 | self.visible = False 54 | for i in self.all_visible_windows(): 55 | i.hide() 56 | 57 | def show(self): 58 | self.visible = True 59 | for i in self.all_visible_windows(): 60 | i.show() 61 | -------------------------------------------------------------------------------- /tilenol/layout/examples.py: -------------------------------------------------------------------------------- 1 | from .tile import Split, Stack, TileStack 2 | 3 | 4 | class Tile(Split): 5 | 6 | class left(Stack): 7 | weight = 3 8 | priority = 0 9 | limit = 1 10 | 11 | class right(TileStack): 12 | pass 13 | 14 | 15 | class Max(Split): 16 | 17 | class main(Stack): 18 | tile = False 19 | 20 | 21 | class InstantMsg(Split): 22 | 23 | class left(TileStack): # or maybe not tiled ? 24 | weight = 3 25 | 26 | class roster(Stack): 27 | limit = 1 28 | priority = 0 # probably roster created first 29 | 30 | 31 | class Gimp(Split): 32 | 33 | class toolbox(Stack): 34 | limit = 1 35 | size = 184 36 | 37 | class main(Stack): 38 | weight = 4 39 | priority = 0 40 | 41 | class dock(Stack): 42 | limit = 1 43 | size = 324 44 | -------------------------------------------------------------------------------- /tilenol/layout/tile.py: -------------------------------------------------------------------------------- 1 | from collections import OrderedDict 2 | from math import floor 3 | from functools import wraps 4 | 5 | from zorro.di import has_dependencies, dependency, di 6 | 7 | from tilenol.xcb import Rectangle 8 | from tilenol.commands import CommandDispatcher 9 | from . import Layout 10 | 11 | 12 | class BaseStack(object): 13 | """Single stack for tile layout 14 | 15 | It's customized by subclassing, not by instantiating 16 | 17 | :var size: size (e.g. width) of stack in pixels, if None weight is used 18 | :var min_size: minimum size of stack to start to ignore pixel sizes 19 | :var weight: the bigger weight is, the bigger part of screen this stack 20 | occupies (if width is unspecified or screen size is too small) 21 | :var limit: limit number of windows inside the stack 22 | :var priority: windows are placed into stacks with smaller priority first 23 | """ 24 | size = None 25 | min_size = 32 26 | weight = 1 27 | limit = None 28 | priority = 100 29 | 30 | def __init__(self, parent): 31 | self.parent = parent 32 | self.windows = [] 33 | self.box = Rectangle(0, 0, 100, 100) 34 | 35 | @property 36 | def empty(self): 37 | return not self.windows 38 | 39 | @property 40 | def full(self): 41 | return self.limit is not None and len(self.windows) >= self.limit 42 | 43 | def shift_up(self): 44 | self.windows.append(self.windows.pop(0)) 45 | self.parent.dirty() 46 | 47 | def shift_down(self): 48 | self.windows.insert(0, self.windows.pop()) 49 | self.parent.dirty() 50 | 51 | 52 | class Stack(BaseStack): 53 | """Single window visibility stack""" 54 | 55 | def __init__(self, parent): 56 | super().__init__(parent) 57 | self.pointer = 0 58 | 59 | @property 60 | def visible_windows(self): 61 | if not self.windows: 62 | return 63 | if self.pointer >= len(self.windows): 64 | self.pointer = 0 65 | yield self.windows[self.pointer] 66 | 67 | def add(self, win): 68 | win.lprops.stack = self.__class__.__name__ 69 | self.windows.insert(0, win) 70 | self.parent.dirty() 71 | 72 | def remove(self, win): 73 | if self.windows[0] is win: 74 | self.parent.dirty() 75 | del win.lprops.stack 76 | self.windows.remove(win) 77 | 78 | def layout(self): 79 | if not self.windows: 80 | return 81 | if self.pointer >= len(self.windows): 82 | self.pointer = 0 83 | win = self.windows[self.pointer] 84 | win.set_bounds(self.box) 85 | win.show() 86 | for i in self.windows: 87 | if i is win: continue 88 | i.hide() 89 | 90 | def shift_up(self): 91 | self.pointer -= 1 92 | if self.pointer < 0: 93 | self.pointer = len(self.windows)-1 94 | self.parent.dirty() 95 | 96 | def shift_down(self): 97 | self.pointer += 1 98 | if self.pointer >= len(self.windows): 99 | self.pointer = 0 100 | self.parent.dirty() 101 | 102 | 103 | class TileStack(BaseStack): 104 | """Tiling stack""" 105 | vertical = True 106 | 107 | @property 108 | def visible_windows(self): 109 | return self.windows 110 | 111 | def add(self, win): 112 | win.lprops.stack = self.__class__.__name__ 113 | self.windows.append(win) 114 | self.parent.dirty() 115 | 116 | def remove(self, win): 117 | del win.lprops.stack 118 | self.windows.remove(win) 119 | self.parent.dirty() 120 | 121 | def layout(self): 122 | vc = len(self.windows) 123 | if self.vertical: 124 | rstart = start = self.box.y 125 | else: 126 | rstart = start = self.box.x 127 | for n, w in enumerate(self.windows, 1): 128 | if self.vertical: 129 | end = rstart + int(floor(n/vc*self.box.height)) 130 | w.set_bounds(Rectangle( 131 | self.box.x, start, self.box.width, end-start)) 132 | else: 133 | end = rstart + int(floor(n/vc*self.box.width)) 134 | w.set_bounds(Rectangle( 135 | start, self.box.y, end-start, self.box.height)) 136 | w.show() 137 | start = end 138 | 139 | 140 | def stackcommand(fun): 141 | @wraps(fun) 142 | def wrapper(self, *args): 143 | try: 144 | win = self.commander['window'] 145 | except KeyError: 146 | return 147 | stack = win.lprops.stack 148 | if stack is None: 149 | return 150 | return fun(self, self.stacks[stack], win, *args) 151 | return wrapper 152 | 153 | 154 | @has_dependencies 155 | class Split(Layout): 156 | """Split layout 157 | 158 | It's customized by subclassing, not by instantiating. Class definition 159 | should consist of at least one stack 160 | 161 | :var fixed: whether to skip empty stacks or reserve place for them 162 | """ 163 | fixed = False 164 | vertical = True 165 | 166 | commander = dependency(CommandDispatcher, 'commander') 167 | 168 | def __init__(self): 169 | super().__init__() 170 | self.auto_stacks = [] 171 | self.stack_list = [] 172 | self.stacks = {} 173 | for stack_class in self.get_defined_classes(BaseStack).values(): 174 | stack = stack_class(self) 175 | self.stacks[stack.__class__.__name__] = stack 176 | self.stack_list.append(stack) 177 | if stack.priority is not None: 178 | self.auto_stacks.append(stack) 179 | self.auto_stacks.sort(key=lambda s: s.priority) 180 | 181 | def set_bounds(self, bounds): 182 | self.bounds = bounds 183 | self.dirty() 184 | 185 | def _assign_boxes(self, box): 186 | if self.fixed: 187 | all_stacks = self.stack_list 188 | else: 189 | all_stacks = [s for s in self.stack_list if not s.empty] 190 | curw = 0 191 | if self.vertical: 192 | rstart = start = box.x 193 | totalpx = box.width 194 | else: 195 | rstart = start = box.y 196 | totalpx = box.height 197 | totpx = sum((s.size or s.min_size) for s in all_stacks) 198 | totw = sum(s.weight for s in all_stacks if s.size is None) 199 | skip_pixels = totpx > totalpx or (not totw and totpx != totalpx) 200 | if skip_pixels: 201 | totw = sum(s.weight for s in all_stacks) 202 | else: 203 | totalpx -= sum(s.size for s in all_stacks if s.size is not None) 204 | pxoff = 0 205 | for s in all_stacks: 206 | if s.size is not None and not skip_pixels: 207 | end = start + s.size 208 | pxoff += s.size 209 | else: 210 | curw += s.weight 211 | end = rstart + pxoff + int(floor(curw/totw*totalpx)) 212 | if self.vertical: 213 | s.box = Rectangle(start, box.y, end-start, box.height) 214 | else: 215 | s.box = Rectangle(box.x, start, box.width, end-start) 216 | start = end 217 | 218 | def add(self, win): # layout API 219 | if win.lprops.stack is not None: 220 | s = self.stacks.get(win.lprops.stack) 221 | if s is not None and not s.full: 222 | s.add(win) 223 | return True 224 | for s in self.auto_stacks: 225 | if not s.full: 226 | s.add(win) 227 | return True 228 | return False # no empty stacks, reject it, so it will be floating 229 | 230 | def remove(self, win): # layout API 231 | self.stacks[win.lprops.stack].remove(win) 232 | 233 | def sublayouts(self): # layout focus API 234 | return self.stacks.values() 235 | 236 | def layout(self): 237 | self._assign_boxes(self.bounds) 238 | for s in self.stack_list: 239 | s.layout() 240 | 241 | def swap_window(self, source, target, win): 242 | if target.full: 243 | other = target.windows[0] 244 | target.remove(other) 245 | source.remove(win) 246 | target.add(win) 247 | source.add(other) 248 | else: 249 | source.remove(win) 250 | target.add(win) 251 | 252 | @stackcommand 253 | def cmd_up(self, stack, win): 254 | if self.vertical: 255 | stack.shift_up() 256 | else: 257 | idx = self.stack_list.index(stack) 258 | if idx > 0: 259 | self.swap_window(stack, self.stack_list[idx-1], win) 260 | 261 | @stackcommand 262 | def cmd_down(self, stack, win): 263 | if self.vertical: 264 | stack.shift_down() 265 | else: 266 | idx = self.stack_list.index(stack) 267 | if idx < len(self.stacks)-1: 268 | self.swap_window(stack, self.stack_list[idx+1], win) 269 | 270 | @stackcommand 271 | def cmd_left(self, stack, win): 272 | if not self.vertical: 273 | stack.shift_up() 274 | else: 275 | idx = self.stack_list.index(stack) 276 | if idx > 0: 277 | self.swap_window(stack, self.stack_list[idx-1], win) 278 | 279 | @stackcommand 280 | def cmd_right(self, stack, win): 281 | if not self.vertical: 282 | stack.shift_down() 283 | else: 284 | idx = self.stack_list.index(stack) 285 | if idx < len(self.stacks)-1: 286 | self.swap_window(stack, self.stack_list[idx+1], win) 287 | 288 | -------------------------------------------------------------------------------- /tilenol/listkeys.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from zorro import Hub 4 | from tilenol.xcb import Connection, Proto 5 | from tilenol.xcb.core import Core 6 | from tilenol.xcb.keysymparse import Keysyms 7 | 8 | 9 | def list_keysyms(options, xcore, keysyms, **kw): 10 | syms = [] 11 | for ksym, kcode in xcore.keysym_to_keycode.items(): 12 | try: 13 | kname = keysyms.code_to_name[ksym] 14 | except KeyError: 15 | print("Can't find name for", ksym, file=sys.stderr) 16 | else: 17 | syms.append(kname) 18 | for sym in sorted(syms): 19 | if options.debug: 20 | num = keysyms.name_to_code[sym] 21 | print(sym, 'ksym:', num, 'codes:', 22 | ','.join(map(str, xcore.keysym_to_keycode[num]))) 23 | else: 24 | print(sym) 25 | 26 | 27 | def get_options(): 28 | import argparse 29 | ap = argparse.ArgumentParser() 30 | ap.add_argument('--keysyms', dest='action', 31 | help="Show all keysyms available for binding (without modifiers)", 32 | action='store_const', const='keysyms', default='keysyms') 33 | ap.add_argument('-d', '--debug', 34 | help="Print more debugging info", 35 | dest='debug', action='store_true', default=False) 36 | return ap 37 | 38 | 39 | def main(): 40 | ap = get_options() 41 | options = ap.parse_args() 42 | 43 | hub = Hub() 44 | @hub.run 45 | def main(): 46 | proto = Proto() 47 | proto.load_xml('xproto') 48 | core = Core(Connection(proto)) 49 | core.init_keymap() 50 | ksyms = Keysyms() 51 | ksyms.load_default() 52 | 53 | if options.action == 'keysyms': 54 | list_keysyms(options, xcore=core, keysyms=ksyms) 55 | 56 | if __name__ == '__main__': 57 | main() 58 | -------------------------------------------------------------------------------- /tilenol/main.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | import sys 3 | import subprocess 4 | import os.path 5 | import signal 6 | import logging 7 | 8 | from zorro.di import DependencyInjector, di, has_dependencies, dependency 9 | from zorro import gethub 10 | from zorro import dns 11 | 12 | from .xcb import Connection, Proto, Core, Keysyms, Rectangle, XError 13 | from .keyregistry import KeyRegistry 14 | from .mouseregistry import MouseRegistry 15 | from .ewmh import Ewmh 16 | from .window import Root, Window 17 | from .events import EventDispatcher 18 | from .commands import CommandDispatcher, EnvCommands, EmulCommands 19 | from .config import Config 20 | from .groups import Group, GroupManager 21 | from .screen import ScreenManager 22 | from .classify import Classifier 23 | from .theme import Theme 24 | from . import randr 25 | from .gestures import Gestures 26 | 27 | 28 | log = logging.getLogger(__name__) 29 | 30 | 31 | def child_handler(sig, frame): 32 | while True: 33 | try: 34 | pid, result = os.waitpid(-1, os.WNOHANG) 35 | if pid is 0: 36 | break 37 | except OSError: 38 | break 39 | 40 | 41 | def inplace_restart(): 42 | if 'TILENOL_CMDLINE' in os.environ: 43 | import shlex 44 | args = shlex.split(os.environ['TILENOL_CMDLINE']) 45 | os.execvp(args[0], args) 46 | else: 47 | # don't trust sys.argv, doesn't work for nixos because of double-wrapping 48 | with open('/proc/self/cmdline', 'rb') as f: 49 | args = f.read().split(b'\0') 50 | # last arg is empty 51 | args.pop() 52 | os.execv(args[0], args) 53 | 54 | 55 | def quit_handler(_sig, _frame): 56 | inplace_restart() 57 | 58 | @has_dependencies 59 | class Tilenol(object): 60 | 61 | xcore = dependency(Core, 'xcore') 62 | dispatcher = dependency(EventDispatcher, 'event-dispatcher') 63 | config = dependency(Config, 'config') 64 | commander = dependency(CommandDispatcher, 'commander') 65 | 66 | def __init__(self, options): 67 | pass 68 | # extract options needed 69 | 70 | 71 | def register_gadgets(self): 72 | inj = di(self) 73 | for name, inst in self.config.gadgets(): 74 | inj.inject(inst) 75 | self.commander[name] = inst 76 | 77 | def run(self): 78 | signal.signal(signal.SIGCHLD, child_handler) 79 | signal.signal(signal.SIGQUIT, quit_handler) 80 | 81 | proto = Proto() 82 | proto.load_xml('xproto') 83 | proto.load_xml('xtest') 84 | proto.load_xml('xinerama') 85 | proto.load_xml('shm') 86 | proto.load_xml('randr') 87 | self.conn = conn = Connection(proto) 88 | conn.connection() 89 | self.root_window = Root(conn.init_data['roots'][0]['root']) 90 | 91 | inj = DependencyInjector() 92 | inj['dns'] = dns.Resolver(dns.Config.system_config()) 93 | gethub().dns_resolver = inj['dns'] 94 | inj['xcore'] = xcore = Core(conn) 95 | inj['keysyms'] = keysyms = Keysyms() 96 | keysyms.load_default() 97 | 98 | 99 | cfg = inj['config'] = inj.inject(Config()) 100 | cfg.init_extensions() 101 | 102 | # Hack, but this only makes GetScreenInfo work 103 | xcore.randr._proto.requests['GetScreenInfo'].reply.items['rates'].code\ 104 | = compile('0', 'XPROTO', 'eval') 105 | if cfg['auto-screen-configuration']: 106 | if randr.check_screens(xcore): 107 | randr.configure_outputs(xcore, cfg['screen-dpi']/25.4) 108 | 109 | inj['theme'] = inj.inject(cfg.theme()) 110 | inj['commander'] = cmd = inj.inject(CommandDispatcher()) 111 | if hasattr(xcore, 'randr'): 112 | NM = xcore.randr.NotifyMask 113 | # We only poll for events and use Xinerama for screen querying 114 | # because some drivers (nvidia) doesn't provide xrandr data 115 | # correctly 116 | xcore.randr.SelectInput( 117 | window=xcore.root_window, 118 | enable=NM.ScreenChange | NM.CrtcChange | NM.OutputChange | NM.OutputProperty) 119 | 120 | if hasattr(xcore, 'xinerama'): 121 | info = xcore.xinerama.QueryScreens()['screen_info'] 122 | screenman = inj['screen-manager'] = ScreenManager([ 123 | Rectangle(scr['x_org'], scr['y_org'], 124 | scr['width'], scr['height']) 125 | for scr in info]) 126 | else: 127 | screenman = inj['screen-manager'] = ScreenManager([Rectangle(0, 0, 128 | xcore.root['width_in_pixels'], 129 | xcore.root['height_in_pixels'])]) 130 | inj.inject(screenman) 131 | 132 | cmd['tilenol'] = self 133 | keys = KeyRegistry() 134 | inj['key-registry'] = inj.inject(keys) 135 | mouse = MouseRegistry() 136 | inj['mouse-registry'] = inj.inject(mouse) 137 | inj['gestures'] = inj.inject(Gestures()) 138 | 139 | gman = inj.inject(GroupManager(map(inj.inject, cfg.groups()))) 140 | cmd['groups'] = gman 141 | inj['group-manager'] = gman 142 | 143 | rules = inj['classifier'] = inj.inject(Classifier()) 144 | for cls, cond, act in cfg.rules(): 145 | rules.add_rule(cond, act, klass=cls) 146 | 147 | eman = inj.inject(EventDispatcher()) 148 | eman.all_windows[self.root_window.wid] = self.root_window 149 | inj['event-dispatcher'] = eman 150 | inj['ewmh'] = Ewmh() 151 | inj.inject(inj['ewmh']) 152 | 153 | inj.inject(self) 154 | 155 | cmd['env'] = EnvCommands() 156 | cmd['emul'] = inj.inject(EmulCommands()) 157 | 158 | # Register hotkeys as mapping notify can be skipped on inplace restart 159 | keys.configure_hotkeys() 160 | 161 | mouse.init_buttons() 162 | mouse.register_buttons(self.root_window) 163 | self.setup_events() 164 | 165 | for screen_no, bar in cfg.bars(): 166 | inj.inject(bar) 167 | if screen_no < len(screenman.screens): 168 | scr = screenman.screens[screen_no] 169 | if bar.position == 'bottom': 170 | scr.add_bottom_bar(bar) 171 | else: 172 | scr.add_top_bar(bar) 173 | bar.create_window() 174 | scr.updated.listen(bar.redraw.emit) 175 | 176 | self.register_gadgets() 177 | 178 | self.catch_windows() 179 | self.loop() 180 | 181 | def catch_windows(self): 182 | cnotify = self.xcore.proto.events['CreateNotify'].type 183 | mnotify = self.xcore.proto.events['MapRequest'].type 184 | for w in self.xcore.raw.QueryTree(window=self.root_window)['children']: 185 | if w == self.root_window or w in self.dispatcher.all_windows: 186 | continue 187 | try: 188 | attr = self.xcore.raw.GetWindowAttributes(window=w) 189 | except XError: # TODO(pc) check for error code 190 | continue 191 | if attr['class'] == self.xcore.WindowClass.InputOnly: 192 | continue 193 | geom = self.xcore.raw.GetGeometry(drawable=w) 194 | self.dispatcher.handle_CreateNotifyEvent(cnotify(0, 195 | window=w, 196 | parent=self.root_window.wid, 197 | x=geom['x'], 198 | y=geom['y'], 199 | width=geom['width'], 200 | height=geom['height'], 201 | border_width=geom['border_width'], 202 | override_redirect=attr['override_redirect'], 203 | )) 204 | win = self.dispatcher.windows[w] 205 | if(attr['map_state'] != self.xcore.MapState.Unmapped 206 | and not attr['override_redirect']): 207 | self.dispatcher.handle_MapRequestEvent(mnotify(0, 208 | parent=self.root_window.wid, 209 | window=w, 210 | )) 211 | 212 | def setup_events(self): 213 | EM = self.xcore.EventMask 214 | self.xcore.raw.ChangeWindowAttributes( 215 | window=self.root_window, 216 | params={ 217 | self.xcore.CW.EventMask: EM.StructureNotify 218 | | EM.SubstructureNotify 219 | | EM.SubstructureRedirect 220 | | EM.FocusChange 221 | }) 222 | attr = self.xcore.raw.GetWindowAttributes(window=self.root_window) 223 | if not (attr['your_event_mask'] & EM.SubstructureRedirect): 224 | print("Probably another window manager is running", file=sys.stderr) 225 | return 226 | 227 | def loop(self): 228 | for i in self.xcore.get_events(): 229 | try: 230 | self.dispatcher.dispatch(i) 231 | except Exception: 232 | log.exception("Error handling event %r", i) 233 | 234 | def cmd_restart(self): 235 | inplace_restart() 236 | -------------------------------------------------------------------------------- /tilenol/mouseregistry.py: -------------------------------------------------------------------------------- 1 | from zorro.di import has_dependencies, dependency 2 | 3 | from .xcb import Core, Rectangle 4 | from .commands import CommandDispatcher 5 | 6 | 7 | class Drag(object): 8 | 9 | start_distance = 5 10 | 11 | def __init__(self, win, x, y): 12 | self.win = win 13 | if self.win.frame: 14 | self.win = self.win.frame 15 | self.drag_started = False 16 | self.start_x = x 17 | self.start_y = y 18 | 19 | def moved_to(self, x, y): 20 | if self.drag_started: 21 | self.motion(x + self.x, y + self.y) 22 | self.update_hint() 23 | else: 24 | dist = abs(self.start_x - x) + abs(self.start_y - y) 25 | if dist > self.start_distance: 26 | self.drag_started = True 27 | if not self.win.content.lprops.floating: 28 | self.win.content.make_floating() 29 | self.hint = self.win.add_hint() 30 | self.start(self.start_x, self.start_y) 31 | self.motion(x + self.x, y + self.y) 32 | self.update_hint() 33 | 34 | 35 | def update_hint(self): 36 | sz = self.win.done.size 37 | txt = '{0.x}, {0.y} {0.width}x{0.height}'.format(sz) 38 | if hasattr(self.win, 'content'): 39 | hints = self.win.content.want.hints 40 | else: 41 | hints = self.win.want.hints 42 | if hints: 43 | bw = getattr(hints, 'base_width', 0) 44 | bh = getattr(hints, 'base_height', 0) 45 | if hasattr(hints, 'width_inc'): 46 | if hasattr(hints, 'height_inc'): 47 | txt += '\n{} cols {} rows'.format( 48 | (sz.width - bw)//hints.width_inc, 49 | (sz.height - bh)//hints.height_inc, 50 | ) 51 | else: 52 | txt += '\n{} cols'.format((sz.width - bw)//hints.width_inc) 53 | elif hasattr(hints, 'height_inc'): 54 | txt += '\n{} rows'.format((sz.height - bh)//hints.height_inc) 55 | self.hint.set_text(txt) 56 | hsz = self.hint.done.size 57 | wsz = self.win.done.size 58 | self.hint.set_bounds(Rectangle( 59 | (wsz.width - hsz.width)//2, 60 | (wsz.height - hsz.height)//2, 61 | hsz.width, hsz.height)) 62 | 63 | def stop(self): 64 | if hasattr(self, 'hint'): 65 | self.hint.destroy() 66 | 67 | 68 | class DragMove(Drag): 69 | 70 | def start(self, x, y): 71 | sz = self.win.done.size 72 | self.x = sz.x - x 73 | self.y = sz.y - y 74 | 75 | def motion(self, x, y): 76 | sz = self.win.done.size 77 | self.win.set_bounds(Rectangle(x, y, sz.width, sz.height)) 78 | 79 | 80 | class DragSizeBottomRight(Drag): 81 | 82 | def start(self, x, y): 83 | sz = self.win.done.size 84 | self.x = sz.width - x 85 | self.y = sz.height - y 86 | 87 | def motion(self, x, y): 88 | sz = self.win.done.size 89 | self.win.set_bounds(Rectangle(sz.x, sz.y, x, y)) 90 | 91 | 92 | class DragSizeTopRight(Drag): 93 | 94 | def start(self, x, y): 95 | sz = self.win.done.size 96 | self.x = sz.width - x 97 | self.y = sz.y - y 98 | self.bottom = sz.height + sz.y 99 | 100 | def motion(self, x, y): 101 | sz = self.win.done.size 102 | self.win.set_bounds(Rectangle(sz.x, y, x, self.bottom - y)) 103 | 104 | class DragSizeBottomLeft(Drag): 105 | 106 | def start(self, x, y): 107 | sz = self.win.done.size 108 | self.x = sz.x - x 109 | self.y = sz.height - y 110 | self.right = sz.x + sz.width 111 | 112 | def motion(self, x, y): 113 | sz = self.win.done.size 114 | self.win.set_bounds(Rectangle(x, sz.y, self.right - x, y)) 115 | 116 | 117 | class DragSizeTopLeft(Drag): 118 | 119 | def start(self, x, y): 120 | sz = self.win.done.size 121 | self.x = sz.x - x 122 | self.y = sz.y - y 123 | self.bottom = sz.height + sz.y 124 | self.right = sz.width + sz.x 125 | 126 | def motion(self, x, y): 127 | sz = self.win.done.size 128 | self.win.set_bounds(Rectangle(x, y, self.right - x, self.bottom - y)) 129 | 130 | 131 | @has_dependencies 132 | class MouseRegistry(object): 133 | 134 | core = dependency(Core, 'xcore') 135 | commander = dependency(CommandDispatcher, 'commander') 136 | 137 | drag_classes = { # (is_right, is_bottom): Class 138 | (True, True): DragSizeBottomRight, 139 | (True, False): DragSizeTopRight, 140 | (False, False): DragSizeTopLeft, 141 | (False, True): DragSizeBottomLeft, 142 | } 143 | 144 | def __init__(self): 145 | self.drag = None 146 | 147 | def init_buttons(self): 148 | self.mouse_buttons = [ 149 | (getattr(self.core.ModMask, '4'), 1), 150 | (getattr(self.core.ModMask, '4'), 3), 151 | ] 152 | 153 | def init_modifiers(self): 154 | # TODO(tailhook) probably calculate them instead of hardcoding 155 | caps = self.core.ModMask.Lock # caps lock 156 | num = getattr(self.core.ModMask, '2') # mod2 is usually numlock 157 | mode = getattr(self.core.ModMask, '5') # mod5 is usually mode_switch 158 | self.extra_modifiers = [0, 159 | caps, 160 | num, 161 | mode, 162 | caps|num, 163 | num|mode, 164 | caps|num|mode, 165 | ] 166 | self.modifiers_mask = ~(caps|num|mode) 167 | 168 | def register_buttons(self, win): 169 | self.init_modifiers() 170 | for mod, button in self.mouse_buttons: 171 | for extra in self.extra_modifiers: 172 | self.core.raw.GrabButton( 173 | modifiers=mod|extra, 174 | button=button, 175 | owner_events=True, 176 | grab_window=win, 177 | event_mask=self.core.EventMask.ButtonRelease 178 | | self.core.EventMask.PointerMotion, 179 | confine_to=0, 180 | keyboard_mode=self.core.GrabMode.Async, 181 | pointer_mode=self.core.GrabMode.Async, 182 | cursor=0, # TODO(tailhook) make apropriate cursor 183 | ) 184 | 185 | def dispatch_button_press(self, ev): 186 | if 'pointer_window' not in self.commander: 187 | return 188 | win = self.commander['pointer_window'] 189 | if win.lprops.floating: 190 | win.frame.restack(self.core.StackMode.TopIf) 191 | if ev.detail == 1: 192 | self.drag = DragMove(win, ev.root_x, ev.root_y) 193 | elif ev.detail == 3: 194 | sz = win.done.size 195 | right = (ev.root_x - sz.x) * 2 >= sz.width 196 | bottom = (ev.root_y - sz.y) * 2 >= sz.height 197 | self.drag = self.drag_classes[right, bottom]( 198 | win, ev.root_x, ev.root_y) 199 | 200 | def dispatch_button_release(self, ev): 201 | if not self.drag: 202 | return 203 | self.drag.moved_to(ev.root_x, ev.root_y) 204 | self.drag.stop() 205 | self.drag = None 206 | 207 | def dispatch_motion(self, ev): 208 | if not self.drag: 209 | return 210 | self.drag.moved_to(ev.root_x, ev.root_y) 211 | 212 | -------------------------------------------------------------------------------- /tilenol/options.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | 3 | def get_options(): 4 | ap = argparse.ArgumentParser() 5 | ap.add_argument('--log-stdout', default=False, action='store_true', 6 | help="Enable logging to stdout (default ~/.cache/tilenol/tilenol.log)") 7 | return ap 8 | -------------------------------------------------------------------------------- /tilenol/randr.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import pprint 3 | import struct 4 | 5 | from zorro import Hub 6 | from tilenol.xcb import Connection, Proto 7 | from tilenol.xcb.core import Core 8 | 9 | 10 | def print_screen(core): 11 | # Strange hack because either wrong spec or wrong X server 12 | # We don't care about refresh rates any way 13 | scr = core.randr.GetScreenInfo(window=core.root_window) 14 | pprint.pprint(scr) 15 | scr = core.randr.GetScreenResources(window=core.root_window) 16 | pprint.pprint(scr) 17 | 18 | 19 | def print_screen_size_range(core): 20 | scr = core.randr.GetScreenSizeRange(window=core.root_window) 21 | pprint.pprint(scr) 22 | 23 | 24 | def print_xinerama(core): 25 | scr = core.xinerama.QueryScreens(window=core.root_window) 26 | pprint.pprint(scr) 27 | 28 | 29 | def print_crtc(core): 30 | scr = core.randr.GetScreenResources(window=core.root_window) 31 | allinfo = {} 32 | for crtc in scr['crtcs']: 33 | cinfo = core.randr.GetCrtcInfo( 34 | crtc=crtc, 35 | config_timestamp=scr['config_timestamp'], 36 | ) 37 | allinfo[crtc] = cinfo 38 | pprint.pprint(allinfo) 39 | 40 | def print_crtc_extra(core): 41 | scr = core.randr.GetScreenResources(window=core.root_window) 42 | allinfo = {} 43 | for crtc in scr['crtcs']: 44 | cinfo = { 45 | 'info': core.randr.GetCrtcInfo( 46 | crtc=crtc, 47 | config_timestamp=scr['config_timestamp'], 48 | ), 49 | 'panning': core.randr.GetPanning(crtc=crtc), 50 | 'transform': core.randr.GetCrtcTransform(crtc=crtc), 51 | 'gamma': core.randr.GetCrtcGamma(crtc=crtc), 52 | } 53 | allinfo[crtc] = cinfo 54 | pprint.pprint(allinfo) 55 | 56 | 57 | def print_output(core): 58 | scr = core.randr.GetScreenResources(window=core.root_window) 59 | allinfo = {} 60 | for output in scr['outputs']: 61 | oinfo = core.randr.GetOutputInfo( 62 | output=output, 63 | config_timestamp=scr['config_timestamp'], 64 | ) 65 | oinfo['name'] = bytes(oinfo['name']).decode('utf-8') 66 | allinfo[output] = oinfo 67 | pprint.pprint(allinfo) 68 | 69 | 70 | def print_providers(core): 71 | scr = core.randr.GetProviders(window=core.root_window) 72 | allinfo = {} 73 | for provider in scr['providers']: 74 | pinfo = core.randr.GetProviderInfo(provider=provider) 75 | allinfo[provider] = pinfo 76 | pprint.pprint(allinfo) 77 | 78 | 79 | def disable_output(core, output): 80 | scr = core.randr.GetScreenResources(window=core.root_window) 81 | sinfo = core.randr.GetScreenInfo(window=core.root_window) 82 | anames = {} 83 | for oid in scr['outputs']: 84 | oinfo = core.randr.GetOutputInfo( 85 | output=oid, 86 | config_timestamp=scr['config_timestamp'], 87 | ) 88 | anames[bytes(oinfo['name']).decode('utf-8')] = oid 89 | if output not in anames: 90 | print("No such output", file=sys.stderr) 91 | oid = anames[output] 92 | for crtc in scr['crtcs']: 93 | cinfo = core.randr.GetCrtcInfo( 94 | crtc=crtc, 95 | config_timestamp=scr['config_timestamp'], 96 | ) 97 | if oid in cinfo['outputs']: 98 | res = core.randr.SetCrtcConfig( 99 | crtc=crtc, 100 | timestamp=0, 101 | config_timestamp=scr['config_timestamp'], 102 | x=cinfo['x'], 103 | y=cinfo['y'], 104 | mode=0, 105 | rotation=cinfo['rotation'], 106 | outputs=bytes([o for o in cinfo['outputs'] if o != oid]), 107 | ) 108 | core.randr.SetOutputPrimary( 109 | window=core.root_window, 110 | output=scr['outputs'][0], 111 | ) 112 | 113 | 114 | def print_output_properties(core): 115 | scr = core.randr.GetScreenResources(window=core.root_window) 116 | allinfo = {} 117 | for output in scr['outputs']: 118 | lst = core.randr.ListOutputProperties(output=output) 119 | props = {} 120 | for atom in lst['atoms']: 121 | props[core.atom[atom].name] = core.randr.GetOutputProperty( 122 | output=output, 123 | property=atom, 124 | type=core.atom.Any, 125 | long_offset=0, 126 | long_length=128, 127 | delete=0, 128 | pending=0, 129 | ) 130 | allinfo[output] = props 131 | pprint.pprint(allinfo) 132 | 133 | def check_screens(core): 134 | scr = core.randr.GetScreenResources(window=core.root_window) 135 | allmapped = set() 136 | for crtc in scr['crtcs']: 137 | cinfo = core.randr.GetCrtcInfo( 138 | crtc=crtc, 139 | config_timestamp=scr['config_timestamp'], 140 | ) 141 | allmapped.update(cinfo['outputs']) 142 | for oid in scr['outputs']: 143 | oinfo = core.randr.GetOutputInfo( 144 | output=oid, 145 | config_timestamp=scr['config_timestamp'], 146 | ) 147 | oname = bytes(oinfo['name']).decode('utf-8') 148 | if oinfo['connection'] == 0 and oid not in allmapped: 149 | print("CONNECTED", oname) 150 | return True # connected screen 151 | if oinfo['connection'] != 0 and oid in allmapped: 152 | print("DISCON", oname) 153 | return True # disconnected screen 154 | return False 155 | 156 | def configure_outputs(core, ppm=3.78): 157 | core.raw.GrabServer() 158 | try: 159 | sinfo = core.randr.GetScreenInfo(window=core.root_window) 160 | scr = core.randr.GetScreenResources(window=core.root_window) 161 | modes = {m['id']:m for m in scr['modes']} 162 | updates = [] 163 | width = 0 164 | height = 0 165 | mm_width = 0 166 | mm_height = 0 167 | crtc_index = 0 168 | for idx, oid in enumerate(scr['outputs']): 169 | oinfo = core.randr.GetOutputInfo( 170 | output=oid, 171 | config_timestamp=scr['config_timestamp'], 172 | ) 173 | oname = bytes(oinfo['name']).decode('utf-8') 174 | if oinfo['connection'] == 0: 175 | crtc = scr['crtcs'][crtc_index] 176 | crtc_index += 1 177 | cinfo = core.randr.GetCrtcInfo( 178 | crtc=crtc, 179 | config_timestamp=scr['config_timestamp'], 180 | ) 181 | mid = oinfo['modes'][0] 182 | updates.append(dict( 183 | crtc=crtc, 184 | timestamp=0, 185 | config_timestamp=scr['config_timestamp'], 186 | x=width, 187 | y=0, 188 | mode=oinfo['modes'][0], 189 | rotation=cinfo['rotation'], 190 | outputs=struct.pack(' len(screens): 29 | scr = self.screens.pop() 30 | idx = len(self.screens) 31 | del self.commander['screen.{}'.format(idx)] 32 | for i, s in enumerate(self.screens): 33 | s.set_bounds(screens[i]) 34 | while len(self.screens) < len(screens): 35 | idx = len(self.screens) 36 | scr = Screen() 37 | scr.set_bounds(screens[idx]) 38 | self.screens.append(scr) 39 | self.commander['screen.{}'.format(idx)] = scr 40 | 41 | 42 | class Screen(object): 43 | 44 | def __init__(self): 45 | self.topbars = [] 46 | self.bottombars = [] 47 | self.leftslices = [] 48 | self.rightslices = [] 49 | self.updated = Event('screen.updated') 50 | self.bars_visible = True 51 | self.group_hooks = [] 52 | 53 | def add_group_hook(self, fun): 54 | self.group_hooks.append(fun) 55 | 56 | def remove_group_hook(self, fun): 57 | self.group_hooks.remove(fun) 58 | 59 | def set_group(self, group): 60 | self.group = group 61 | for h in self.group_hooks: 62 | h() 63 | 64 | def cmd_focus(self): 65 | self.group.focus() 66 | 67 | def set_bounds(self, rect): 68 | self.bounds = rect 69 | x, y, w, h = rect 70 | if self.bars_visible: 71 | for bar in self.topbars: 72 | bar.set_bounds(Rectangle(x, y, w, bar.height)) 73 | y += bar.height 74 | h -= bar.height 75 | for bar in self.bottombars: 76 | h -= bar.height 77 | bar.set_bounds(Rectangle(x, y+h, w, bar.height)) 78 | for gadget in self.leftslices: 79 | gadget.set_bounds(Rectangle(x, y, gadget.width, h)) 80 | x += gadget.width 81 | w -= gadget.width 82 | for gadget in self.rightslices: 83 | w -= gadget.width 84 | gadget.set_bounds(Rectangle(x+w, y, gadget.width, h)) 85 | self.inner_bounds = Rectangle(x, y, w, h) 86 | self.updated.emit() 87 | 88 | def all_bars(self): 89 | for bar in self.topbars: 90 | yield bar 91 | for bar in self.bottombars: 92 | yield bar 93 | 94 | def add_top_bar(self, bar): 95 | if bar not in self.topbars: 96 | self.topbars.append(bar) 97 | self.set_bounds(self.bounds) 98 | 99 | def add_bottom_bar(self, bar): 100 | if bar not in self.bottombars: 101 | self.bottombars.append(bar) 102 | self.set_bounds(self.bounds) 103 | 104 | def slice_left(self, obj): 105 | if obj not in self.leftslices: 106 | self.leftslices.append(obj) 107 | self.set_bounds(self.bounds) 108 | 109 | def unslice_left(self, obj): 110 | if obj in self.leftslices: 111 | self.leftslices.remove(obj) 112 | self.set_bounds(self.bounds) 113 | 114 | def slice_right(self, obj): 115 | if obj not in self.rightslices: 116 | self.rightslices.append(obj) 117 | self.set_bounds(self.bounds) 118 | 119 | def unslice_right(self, obj): 120 | if obj in self.rightslices: 121 | self.leftslices.remove(obj) 122 | self.set_bounds(self.bounds) 123 | 124 | def cmd_toggle_bars(self): 125 | if self.bars_visible: 126 | self.cmd_hide_bars() 127 | else: 128 | self.cmd_show_bars() 129 | 130 | def cmd_hide_bars(self): 131 | for bar in self.all_bars(): 132 | bar.window.hide() 133 | self.bars_visible = False 134 | self.inner_bounds = self.bounds 135 | self.updated.emit() 136 | 137 | def cmd_show_bars(self): 138 | for bar in self.all_bars(): 139 | bar.window.show() 140 | self.bars_visible = True 141 | self.set_bounds(self.bounds) 142 | 143 | -------------------------------------------------------------------------------- /tilenol/theme.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | 3 | from cairo import SolidPattern 4 | 5 | 6 | Padding = namedtuple('Padding', 'top right bottom left') 7 | 8 | 9 | class SubTheme(object): 10 | 11 | def __init__(self, name): 12 | self.name = name 13 | 14 | def set_color(self, name, num): 15 | setattr(self, name, num) 16 | setattr(self, name+'_pat', SolidPattern( 17 | (num >> 16) / 255.0, 18 | ((num >> 8) & 0xff) / 255.0, 19 | (num & 0xff) / 255.0, 20 | )) 21 | 22 | def update_from(self, dic): 23 | for k, v in dic.items(): 24 | k = k.replace('-', '_') 25 | old = getattr(self, k, None) 26 | if old is None: 27 | return 28 | if isinstance(old, int): 29 | if hasattr(self, k+'_pat'): 30 | self.set_color(k, int(v)) 31 | else: 32 | setattr(self, k, int(v)) 33 | elif isinstance(old, Padding): 34 | setattr(self, k, Padding(*v)) 35 | elif isinstance(old, Font): 36 | if isinstance(v, (list, tuple)): 37 | f = Font(*v) 38 | elif isinstance(v, str): 39 | f = Font(v, old.size) 40 | elif isinstance(v, int): 41 | f = Font(old.face, v) 42 | elif isinstance(v, dict): 43 | f = Font(**v) 44 | else: 45 | raise NotImplemented("Invalid font {!r}".format(v)) 46 | setattr(self, k, f) 47 | else: 48 | setattr(self, k, v) 49 | 50 | 51 | class Font(object): 52 | 53 | def __init__(self, face, size): 54 | self.face = face 55 | self.size = size 56 | 57 | def apply(self, ctx): 58 | ctx.select_font_face(self.face) 59 | ctx.set_font_size(self.size) 60 | 61 | 62 | class Theme(SubTheme): 63 | 64 | def __init__(self): 65 | 66 | blue = 0x4c4c99 67 | dark_blue = 0x191933 68 | orange = 0x99994c 69 | gray = 0x808080 70 | red = 0x994c4c 71 | black = 0x000000 72 | white = 0xffffff 73 | 74 | self.window = SubTheme('window') 75 | self.window.border_width = 2 76 | self.window.set_color('active_border', blue) 77 | self.window.set_color('inactive_border', gray) 78 | self.window.set_color('background', black) 79 | 80 | self.bar = SubTheme('bar') 81 | self.bar.set_color('background', black) 82 | self.bar.font = Font('Consolas', 18) 83 | self.bar.box_padding = Padding(2, 2, 2, 2) 84 | self.bar.text_padding = Padding(2, 4, 7, 4) 85 | self.bar.icon_spacing = 2 86 | self.bar.set_color('text_color', white) 87 | self.bar.set_color('dim_color', gray) 88 | self.bar.set_color('bright_color', orange) 89 | self.bar.set_color('active_border', blue) 90 | self.bar.set_color('subactive_border', gray) 91 | self.bar.set_color('urgent_border', red) 92 | self.bar.border_width = 2 93 | self.bar.height = 24 94 | self.bar.set_color('graph_color', blue) 95 | self.bar.set_color('graph_fill_color', dark_blue) 96 | self.bar.graph_line_width = 2 97 | self.bar.separator_width = 1 98 | self.bar.set_color('separator_color', gray) 99 | 100 | self.menu = SubTheme('bar') 101 | self.menu.set_color('background', gray) 102 | self.menu.set_color('text', white) 103 | self.menu.set_color('highlight', blue) 104 | self.menu.set_color('selection', blue) 105 | self.menu.set_color('selection_text', white) 106 | self.menu.set_color('cursor', black) 107 | self.menu.font = Font('Consolas', 18) 108 | self.menu.padding = Padding(2, 4, 7, 4) 109 | self.menu.line_height = 24 110 | 111 | self.hint = SubTheme('hint') 112 | self.hint.font = Font('Consolas', 18) 113 | self.hint.set_color('background', black) 114 | self.hint.set_color('border_color', gray) 115 | self.hint.set_color('text_color', white) 116 | self.hint.border_width = 2 117 | self.hint.padding = Padding(5, 6, 9, 6) 118 | 119 | self.tabs = SubTheme('tabs') 120 | self.tabs.font = Font('Monofur', 12) 121 | self.tabs.set_color('background', black) 122 | self.tabs.set_color('inactive_title', gray) 123 | self.tabs.set_color('inactive_bg', black) 124 | self.tabs.set_color('active_title', white) 125 | self.tabs.set_color('active_bg', blue) 126 | self.tabs.set_color('urgent_title', white) 127 | self.tabs.set_color('urgent_bg', orange) 128 | self.tabs.section_font = Font('Monofur', 12) 129 | self.tabs.set_color('section_color', gray) 130 | self.tabs.padding = Padding(5, 6, 6, 4) 131 | self.tabs.margin = Padding(4, 4, 4, 4) 132 | self.tabs.border_radius = 5 133 | self.tabs.spacing = 2 134 | self.tabs.icon_size = 14 135 | self.tabs.icon_spacing = 6 136 | self.tabs.section_padding = Padding(6, 2, 2, 2) 137 | 138 | def update_from(self, dic): 139 | for key, sub in self.__dict__.items(): 140 | if isinstance(sub, SubTheme) and key in dic: 141 | sub.update_from(dic[key]) 142 | 143 | -------------------------------------------------------------------------------- /tilenol/util.py: -------------------------------------------------------------------------------- 1 | from zorro.http import HTTPClient 2 | from zorro import gethub 3 | 4 | from urllib.parse import splittype, splithost, urlencode 5 | 6 | 7 | class RequestError(Exception): 8 | """URL cannot be fetched""" 9 | 10 | 11 | def fetchurl(url, query=None): 12 | if query is not None: 13 | assert '?' not in url, ("Either include query in url" 14 | "or pass as parameter, but not both") 15 | url += '?' + urlencode(query) 16 | proto, tail = splittype(url) 17 | if proto != 'http': 18 | raise RuntimeError("Unsupported protocol HTTP") 19 | host, tail = splithost(tail) 20 | ip = gethub().dns_resolver.gethostbyname(host) 21 | cli = HTTPClient(ip) 22 | resp = cli.request(tail, headers={'Host': host}) 23 | if resp.status.endswith('200 OK'): 24 | return resp.body 25 | raise RequestError(resp.status, resp) 26 | -------------------------------------------------------------------------------- /tilenol/widgets/__init__.py: -------------------------------------------------------------------------------- 1 | from .bar import Bar 2 | from .groupbox import Groupbox 3 | from .clock import Clock 4 | from .base import Sep 5 | from .tray import Systray 6 | from .title import Title, Icon 7 | from .graph import CPUGraph, MemoryGraph, SwapGraph, NetGraph, HDDGraph 8 | from .battery import Battery 9 | from .yahoo_weather import YahooWeather 10 | from .gesture import Gesture 11 | -------------------------------------------------------------------------------- /tilenol/widgets/bar.py: -------------------------------------------------------------------------------- 1 | from math import ceil 2 | 3 | import cairo 4 | from zorro.di import di, has_dependencies, dependency 5 | 6 | from tilenol.xcb import Core, Rectangle 7 | from tilenol.window import DisplayWindow 8 | from tilenol.events import EventDispatcher 9 | from tilenol.event import Event 10 | from tilenol.theme import Theme 11 | 12 | 13 | @has_dependencies 14 | class Bar(object): 15 | 16 | xcore = dependency(Core, 'xcore') 17 | dispatcher = dependency(EventDispatcher, 'event-dispatcher') 18 | theme = dependency(Theme, 'theme') 19 | 20 | 21 | def __init__(self, widgets, position='top'): 22 | self.widgets = widgets 23 | self.position = position 24 | self.bounds = None 25 | self.window = None 26 | self.redraw = Event('bar.redraw') 27 | self.redraw.listen(self.expose) 28 | 29 | def __zorro_di_done__(self): 30 | bar = self.theme.bar 31 | self.height = bar.height 32 | self.background = bar.background_pat 33 | inj = di(self).clone() 34 | inj['bar'] = self 35 | for w in self.widgets: 36 | w.height = self.height 37 | inj.inject(w) 38 | 39 | def set_bounds(self, rect): 40 | self.bounds = rect 41 | self.width = rect.width 42 | stride = self.xcore.bitmap_stride 43 | self.img = self.xcore.pixbuf(self.width, self.height) 44 | self.cairo = self.img.context() 45 | if self.window and not self.window.set_bounds(rect): 46 | self.redraw.emit() 47 | 48 | def create_window(self): 49 | EM = self.xcore.EventMask 50 | CW = self.xcore.CW 51 | self.window = DisplayWindow(self.xcore.create_toplevel(self.bounds, 52 | klass=self.xcore.WindowClass.InputOutput, 53 | params={ 54 | CW.EventMask: EM.Exposure | EM.SubstructureNotify, 55 | CW.OverrideRedirect: True, 56 | }), self.expose) 57 | self.window.want.size = self.bounds 58 | di(self).inject(self.window) 59 | self.dispatcher.register_window(self.window) 60 | self.window.show() 61 | 62 | def expose(self, rect=None): 63 | # TODO(tailhook) set clip region to specified rectangle 64 | self.cairo.set_source(self.background) 65 | self.cairo.rectangle(0, 0, self.width, self.height) 66 | self.cairo.fill() 67 | l = 0 68 | r = self.width 69 | for i in self.widgets: 70 | self.cairo.save() 71 | self.cairo.rectangle(l, 0, r-l, self.height) 72 | self.cairo.clip() 73 | l, r = i.draw(self.cairo, l, r) 74 | self.cairo.restore() 75 | self.img.draw(self.window) 76 | 77 | 78 | -------------------------------------------------------------------------------- /tilenol/widgets/base.py: -------------------------------------------------------------------------------- 1 | from abc import abstractmethod, ABCMeta 2 | from collections import namedtuple 3 | 4 | from cairo import SolidPattern 5 | from zorro.di import has_dependencies, dependency 6 | 7 | from .bar import Bar 8 | from tilenol.theme import Theme 9 | 10 | 11 | @has_dependencies 12 | class Widget(metaclass=ABCMeta): 13 | 14 | bar = dependency(Bar, 'bar') 15 | stretched = False 16 | 17 | def __init__(self, right=False): 18 | self.right = right 19 | 20 | @abstractmethod 21 | def draw(self, canvas, left, right): 22 | return left, right 23 | 24 | 25 | @has_dependencies 26 | class Sep(Widget): 27 | 28 | theme = dependency(Theme, 'theme') 29 | 30 | def __zorro_di_done__(self): 31 | bar = self.theme.bar 32 | self.padding = bar.box_padding 33 | self.color = bar.separator_color_pat 34 | self.line_width = bar.separator_width 35 | 36 | def draw(self, canvas, l, r): 37 | if self.right: 38 | x = r - self.padding.right - 0.5 39 | r -= self.padding.left + self.padding.right 40 | else: 41 | x = l + self.padding.left + 0.5 42 | l += self.padding.left + self.padding.right 43 | canvas.set_source(self.color) 44 | canvas.set_line_width(self.line_width) 45 | canvas.move_to(x, self.padding.top) 46 | canvas.line_to(x, self.height - self.padding.bottom) 47 | canvas.stroke() 48 | return l, r 49 | -------------------------------------------------------------------------------- /tilenol/widgets/battery.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | import threading 3 | import time 4 | import errno 5 | 6 | 7 | from cairo import SolidPattern 8 | from zorro.di import has_dependencies, dependency 9 | 10 | from .base import Widget 11 | from tilenol.theme import Theme 12 | 13 | 14 | BATTERY_PATH = '/sys/class/power_supply' 15 | 16 | MIN_IN_HOUR = 60 17 | 18 | ENERGY_NOW = 0 19 | ENERGY_FULL = 1 20 | POWER_NOW = 2 21 | ENERGY_FILES = [ 'energy_now', 'energy_full', 'power_now' ] 22 | CHARGE_FILES = [ 'charge_now', 'charge_full', 'current_now' ] 23 | 24 | 25 | class BatteryStatus: 26 | def __init__( self, path ): 27 | # set up the initial battery charge files - these will change as required 28 | self.files = ENERGY_FILES 29 | self.path = path 30 | 31 | def read_battery( self ): 32 | try: # pick the battery charge files which is appropriate to your machine 33 | self._now = float( self.get_file( self.files[ ENERGY_NOW ] ) ) 34 | except IOError as e: # FileNotFoundError: in python 3.3 35 | if e.errno == errno.ENOENT: # file not found 36 | self.files = ENERGY_FILES if self.files == CHARGE_FILES else CHARGE_FILES 37 | self._now = float( self.get_file( self.files[ ENERGY_NOW ] ) ) 38 | else: 39 | raise 40 | except OSError: # device not found? - make up a value 41 | self._now = 1000 42 | 43 | try: # ...but sometimes, plugging in/out the power causes something to fail, if so make it up, 44 | self._full = float( self.get_file( self.files[ ENERGY_FULL ] ) ) 45 | self._power = float( self.get_file( self.files[ POWER_NOW ] ) ) 46 | self._status = self.get_file( 'status' ).lower() 47 | except OSError: 48 | self._full = self._now 49 | self._power = self._now*MIN_IN_HOUR 50 | self._status = 'unknown' 51 | 52 | def get_file( self, name ): 53 | with open( os.path.join( self.path, name ), 'rt' ) as f: 54 | return f.read().strip() 55 | 56 | @property 57 | def charge( self ): 58 | return self._now/self._full 59 | 60 | @property 61 | def time_to_full( self ): 62 | return int( ( self._full - self._now )/self._power*MIN_IN_HOUR ) 63 | 64 | @property 65 | def time_to_empty( self ): 66 | return int( self._now/self._power*MIN_IN_HOUR ) 67 | 68 | @property 69 | def is_charging( self ): 70 | return self._status == 'charging' 71 | 72 | @property 73 | def is_unknown_status( self ): 74 | return self._status == 'unknown' 75 | 76 | @property 77 | def has_full_charge( self ): 78 | return self.charge > 0.99 or self._power == 0 79 | 80 | 81 | @has_dependencies 82 | class Battery(Widget): 83 | 84 | theme = dependency(Theme, 'theme') 85 | 86 | def __init__(self, *, which="BAT0", right=False): 87 | super().__init__(right=right) 88 | self.text = '--' 89 | path = os.path.join(BATTERY_PATH, which ) 90 | self.data = BatteryStatus( path ) 91 | 92 | def __zorro_di_done__(self): 93 | bar = self.theme.bar 94 | self.font = bar.font 95 | self.color = bar.text_color_pat 96 | self.padding = bar.text_padding 97 | # Reading battery can be immensely slow (more than 3 seconds) 98 | # so we update it in thread 99 | self.thread = threading.Thread(target=self.read_loop) 100 | self.thread.daemon = True 101 | self.thread.start() 102 | 103 | def read_loop(self): 104 | while True: 105 | self.format_battery_msg() 106 | time.sleep(10) 107 | 108 | def format_battery_msg(self): 109 | self.data.read_battery() 110 | if not self.data.is_unknown_status: 111 | txt = '{:.0%}'.format( self.data.charge ) 112 | if not self.data.has_full_charge: 113 | tm, sign = ( self.data.time_to_full, '+' ) if self.data.is_charging else ( self.data.time_to_empty, '-' ) 114 | hour = tm // MIN_IN_HOUR 115 | min = tm % MIN_IN_HOUR 116 | txt += ' {} {:02d}:{:02d}'.format( sign, hour, min ) 117 | self.text = txt 118 | else: 119 | self.text = '--' 120 | 121 | def draw(self, canvas, l, r): 122 | self.font.apply(canvas) 123 | canvas.set_source(self.color) 124 | _, _, w, h, _, _ = canvas.text_extents(self.text) 125 | if self.right: 126 | x = r - self.padding.right - w 127 | r -= self.padding.left + self.padding.right + w 128 | else: 129 | x = l + self.padding.left 130 | l += self.padding.left + self.padding.right + w 131 | canvas.move_to(x, self.height - self.padding.bottom) 132 | canvas.show_text(self.text) 133 | return l, r 134 | -------------------------------------------------------------------------------- /tilenol/widgets/clock.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import time 3 | 4 | from zorro.di import dependency, has_dependencies 5 | from zorro import gethub, sleep 6 | from cairo import SolidPattern 7 | 8 | from .base import Widget 9 | from tilenol.theme import Theme 10 | 11 | 12 | @has_dependencies 13 | class Clock(Widget): 14 | 15 | theme = dependency(Theme, 'theme') 16 | 17 | def __init__(self, *, format="%H:%M:%S %d.%m.%Y", right=False): 18 | super().__init__(right=right) 19 | self.format = format 20 | 21 | def __zorro_di_done__(self): 22 | bar = self.theme.bar 23 | self.font = bar.font 24 | self.color = bar.text_color_pat 25 | self.padding = bar.text_padding 26 | gethub().do_spawnhelper(self._update_time) 27 | 28 | def _update_time(self): 29 | while True: 30 | tts = 1.0 - (time.time() % 1.0) 31 | if tts < 0.001: 32 | tts = 1 33 | sleep(tts) 34 | self.bar.redraw.emit() 35 | 36 | def _time(self): 37 | return datetime.datetime.now().strftime(self.format) 38 | 39 | def draw(self, canvas, l, r): 40 | self.font.apply(canvas) 41 | canvas.set_source(self.color) 42 | tm = self._time() 43 | _, _, w, h, _, _ = canvas.text_extents(tm) 44 | if self.right: 45 | x = r - self.padding.right - w 46 | r -= self.padding.left + self.padding.right + w 47 | else: 48 | x = l + self.padding.left 49 | l += self.padding.left + self.padding.right + w 50 | canvas.move_to(x, self.height - self.padding.bottom) 51 | canvas.show_text(tm) 52 | return l, r 53 | -------------------------------------------------------------------------------- /tilenol/widgets/gesture.py: -------------------------------------------------------------------------------- 1 | from math import pi 2 | 3 | from zorro.di import dependency, has_dependencies 4 | 5 | from .base import Widget 6 | from tilenol.theme import Theme 7 | from tilenol import gestures as G 8 | 9 | 10 | GRAD = pi/180 11 | rotations = { 12 | 'up': 0*GRAD, 13 | 'upright': 45*GRAD, 14 | 'right': 90*GRAD, 15 | 'downright': 135*GRAD, 16 | 'down': 180*GRAD, 17 | 'downleft': -135*GRAD, 18 | 'left': -90*GRAD, 19 | 'upleft': -45*GRAD, 20 | } 21 | 22 | 23 | @has_dependencies 24 | class Gesture(Widget): 25 | 26 | theme = dependency(Theme, 'theme') 27 | gestures = dependency(G.Gestures, 'gestures') 28 | 29 | def __init__(self, *, gestures=None, right=False): 30 | super().__init__(right=right) 31 | self.format = format 32 | self.gesture_names = gestures 33 | self.state = (None, None, None, None) 34 | 35 | def __zorro_di_done__(self): 36 | bar = self.theme.bar 37 | self.font = bar.font 38 | self.color = bar.text_color_pat 39 | self.background = bar.background_pat 40 | self.dig = bar.bright_color_pat 41 | self.inactive_color = bar.dim_color_pat 42 | self.padding = bar.text_padding 43 | self.gwidth = self.height - self.padding.top - self.padding.bottom 44 | if self.gesture_names is None: 45 | for name in self.gestures.active_gestures: 46 | self.gestures.add_callback(name, self._update_gesture) 47 | else: 48 | for name in self.gesture_names: 49 | self.gestures.add_callback(name, self._update_gesture) 50 | 51 | def _update_gesture(self, name, percent, state, cfg): 52 | px = min(int(percent*self.gwidth), self.gwidth) 53 | st = (name, px, state, cfg) 54 | if self.state != st: 55 | if state in {G.CANCEL, G.COMMIT}: 56 | self.state = (None, None, None, None) 57 | else: 58 | self.state = st 59 | self.bar.redraw.emit() 60 | 61 | def draw(self, canvas, l, r): 62 | name, offset, state, cfg = self.state 63 | if not name: 64 | return l, r 65 | fin, dir = name.split('-') 66 | nfin = int(fin[:-1]) 67 | char = cfg['char'] 68 | self.font.apply(canvas) 69 | if state == G.FULL: 70 | canvas.set_source(self.color) 71 | else: 72 | canvas.set_source(self.inactive_color) 73 | _, _, w, h, _, _ = canvas.text_extents(char) 74 | if self.right: 75 | x = r - self.padding.right - w 76 | r -= self.padding.left + self.padding.right + w 77 | else: 78 | x = l + self.padding.left 79 | l += self.padding.left + self.padding.right + w 80 | cx = x + w/2 81 | cy = self.padding.top + self.gwidth/2 82 | canvas.translate(cx+1, cy) 83 | canvas.rotate(rotations[dir]) 84 | canvas.translate(-cx, -cy) 85 | canvas.translate(0, self.gwidth/2 - offset) 86 | canvas.move_to(x, self.height - self.padding.bottom) 87 | canvas.show_text(char) 88 | return l, r 89 | -------------------------------------------------------------------------------- /tilenol/widgets/graph.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | import cairo 4 | from zorro import gethub, sleep 5 | from zorro.di import has_dependencies, dependency 6 | 7 | from .base import Widget 8 | from tilenol.theme import Theme 9 | 10 | 11 | @has_dependencies 12 | class _Graph(Widget): 13 | fixed_upper_bound = False 14 | 15 | theme = dependency(Theme, 'theme') 16 | 17 | def __init__(self, samples=60, position='bottom', right=False): 18 | super().__init__(right=right) 19 | self.samples = samples 20 | self.position = position 21 | self.values = [0] * self.samples 22 | self.maxvalue = 0 23 | 24 | def __zorro_di_done__(self): 25 | bar = self.theme.bar 26 | self.padding = bar.box_padding 27 | self.graph_color = bar.graph_color_pat 28 | self.fill_color = bar.graph_fill_color_pat 29 | self.line_width = bar.graph_line_width 30 | gethub().do_spawnhelper(self._update_handler) 31 | 32 | def _update_handler(self): 33 | while True: 34 | tts = 1.0 - (time.time() % 1.0) 35 | if tts < 0.001: 36 | tts = 1 37 | sleep(tts) 38 | self.update() 39 | self.bar.redraw.emit() 40 | 41 | def draw(self, canvas, l, r): 42 | canvas.set_line_join(cairo.LINE_JOIN_ROUND) 43 | canvas.set_source(self.graph_color) 44 | canvas.set_line_width(self.line_width) 45 | h = self.height - self.padding.top - self.padding.bottom 46 | k = h / (self.maxvalue or 1) 47 | if self.position == 'top': 48 | y = 0 + self.padding.top 49 | else: 50 | y = self.height - self.padding.bottom 51 | if self.right: 52 | start = current = r - self.padding.right - self.samples 53 | else: 54 | start = current = l + self.padding.left 55 | canvas.move_to(current, y - self.values[-1] * k) 56 | for val in reversed(self.values): 57 | canvas.line_to(current, y - val * k) 58 | current += 1 59 | canvas.stroke_preserve() 60 | if self.position == 'top': 61 | y -= 1 62 | canvas.line_to(current, y + self.line_width / 2.0) 63 | canvas.line_to(start, y + self.line_width / 2.0) 64 | canvas.set_source(self.fill_color) 65 | canvas.fill() 66 | if self.right: 67 | return l, r - self.padding.left - self.padding.right - self.samples 68 | else: 69 | return l + self.padding.left + self.padding.right + self.samples, r 70 | 71 | def push(self, value): 72 | if self.position == 'top': 73 | value = -value 74 | self.values.insert(0, value) 75 | self.values.pop() 76 | if not self.fixed_upper_bound: 77 | if self.position == 'top': 78 | self.maxvalue = -min(self.values) 79 | else: 80 | self.maxvalue = max(self.values) 81 | self.bar.redraw.emit() 82 | 83 | 84 | class CPUGraph(_Graph): 85 | fixed_upper_bound = True 86 | 87 | def __init__(self, samples=60, position='bottom', right=False): 88 | super().__init__(samples=samples, position=position, right=right) 89 | self.maxvalue = 100 90 | self.oldvalues = self._getvalues() 91 | 92 | def _getvalues(self): 93 | with open('/proc/stat') as file: 94 | all_cpus = next(file) 95 | name, user, nice, sys, idle, iowait, tail = all_cpus.split(None, 6) 96 | return int(user), int(nice), int(sys), int(idle) 97 | 98 | def update(self): 99 | nval = self._getvalues() 100 | oval = self.oldvalues 101 | busy = (nval[0] + nval[1] + nval[2] - oval[0] - oval[1] - oval[2]) 102 | total = busy + nval[3] - oval[3] 103 | if total: 104 | # sometimes this value is zero for unknown reason (time shift?) 105 | # we just skip the value, because it gives us no info about 106 | # cpu load, if it's zero 107 | self.push(busy * 100.0 / total) 108 | self.oldvalues = nval 109 | 110 | 111 | def get_meminfo(): 112 | with open('/proc/meminfo') as file: 113 | val = {} 114 | for line in file: 115 | key, tail = line.split(':') 116 | uv = tail.split() 117 | val[key] = int(uv[0]) 118 | return val 119 | 120 | 121 | class MemoryGraph(_Graph): 122 | fixed_upper_bound = True 123 | 124 | def __init__(self, samples=60, position='bottom', right=False): 125 | super().__init__(samples=samples, position=position, right=right) 126 | self.oldvalues = self._getvalues() 127 | self.maxvalue = self.oldvalues['MemTotal'] 128 | 129 | def _getvalues(self): 130 | return get_meminfo() 131 | 132 | def update(self): 133 | val = self._getvalues() 134 | self.push(val['MemTotal'] - val['MemFree'] - val['Inactive']) 135 | 136 | 137 | class SwapGraph(_Graph): 138 | fixed_upper_bound = True 139 | 140 | def __init__(self, samples=60, position='bottom', right=False): 141 | super().__init__(samples=samples, position=position, right=right) 142 | self.oldvalues = self._getvalues() 143 | self.maxvalue = self.oldvalues['SwapTotal'] 144 | 145 | def _getvalues(self): 146 | return get_meminfo() 147 | 148 | def update(self): 149 | val = self._getvalues() 150 | swap = val['SwapTotal'] - val['SwapFree'] - val['SwapCached'] 151 | self.push(swap) 152 | 153 | 154 | class NetGraph(_Graph): 155 | def __init__( 156 | self, interface='eth0', direction='down', 157 | samples=60, position='bottom', right=False): 158 | super().__init__(samples=samples, position=position, right=right) 159 | self.filename = '/sys/class/net/{interface}/statistics/{type}'.format( 160 | interface=interface, 161 | type=direction == 'down' and 'rx_bytes' or 'tx_bytes' 162 | ) 163 | self.oldvalues = 0 164 | self.oldvalues = self._getvalues() 165 | 166 | def _getvalues(self): 167 | try: 168 | with open(self.filename) as file: 169 | val = int(file.read()) 170 | rval = val - self.oldvalues 171 | self.oldvalues = val 172 | return rval 173 | except IOError: 174 | return 0 175 | 176 | def update(self): 177 | val = self._getvalues() 178 | self.push(val) 179 | 180 | 181 | from os import statvfs 182 | 183 | 184 | class HDDGraph(_Graph): 185 | fixed_upper_bound = True 186 | 187 | def __init__( 188 | self, path='/', type='used', 189 | samples=60, position='bottom', right=False): 190 | super().__init__(samples=samples, position=position, right=right) 191 | self.path = path 192 | self.type = type 193 | stats = statvfs(self.path) 194 | self.maxvalue = stats.f_blocks * stats.f_frsize 195 | 196 | def _getvalues(self): 197 | stats = statvfs(self.path) 198 | if self.type == 'used': 199 | return (stats.f_blocks - stats.f_bfree) * stats.f_frsize 200 | else: 201 | return stats.f_bavail * stats.f_frsize 202 | 203 | def update(self): 204 | val = self._getvalues() 205 | self.push(val) 206 | -------------------------------------------------------------------------------- /tilenol/widgets/groupbox.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | 3 | from cairo import LINE_JOIN_ROUND 4 | from zorro.di import di, dependency, has_dependencies 5 | 6 | from tilenol.groups import GroupManager 7 | from tilenol.commands import CommandDispatcher 8 | from .base import Widget 9 | from tilenol.theme import Theme 10 | from tilenol.window import Window 11 | 12 | 13 | GroupState = namedtuple( 14 | 'GroupState', 15 | ('name', 'empty', 'active', 'visible', 'urgent') 16 | ) 17 | 18 | 19 | @has_dependencies 20 | class State(object): 21 | 22 | commander = dependency(CommandDispatcher, 'commander') 23 | gman = dependency(GroupManager, 'group-manager') 24 | 25 | def __init__(self): 26 | self._state = None 27 | 28 | def dirty(self): 29 | return self._state != self._read() 30 | 31 | def update(self): 32 | nval = self._read() 33 | if nval != self._state: 34 | self._state = nval 35 | return True 36 | 37 | def _read(self): 38 | cur = self.commander.get('group') 39 | visgr = self.gman.current_groups.values() 40 | return tuple(GroupState(g.name, g.empty, g is cur, g in visgr, 41 | g.has_urgent_windows) 42 | for g in self.gman.groups) 43 | 44 | @property 45 | def groups(self): 46 | return self._state 47 | 48 | 49 | @has_dependencies 50 | class Groupbox(Widget): 51 | 52 | theme = dependency(Theme, 'theme') 53 | 54 | def __init__(self, *, filled=False, first_letter=False, right=False): 55 | super().__init__(right=right) 56 | self.filled = filled 57 | self.first_letter = first_letter 58 | 59 | def __zorro_di_done__(self): 60 | self.state = di(self).inject(State()) 61 | bar = self.theme.bar 62 | self.font = bar.font 63 | self.inactive_color = bar.dim_color_pat 64 | self.urgent_color = bar.bright_color_pat 65 | self.active_color = bar.text_color_pat 66 | self.selected_color = bar.active_border_pat 67 | self.subactive_color = bar.subactive_border_pat 68 | self.padding = bar.text_padding 69 | self.border_width = bar.border_width 70 | self.state.gman.group_changed.listen(self.bar.redraw.emit) 71 | Window.any_window_changed.listen(self.check_state) 72 | 73 | def check_state(self): 74 | if self.state.dirty: 75 | self.bar.redraw.emit() 76 | 77 | def draw(self, canvas, l, r): 78 | self.state.update() 79 | assert not self.right, "Sorry, right not implemented" 80 | self.font.apply(canvas) 81 | canvas.set_line_join(LINE_JOIN_ROUND) 82 | canvas.set_line_width(self.border_width) 83 | x = l 84 | between = self.padding.right + self.padding.left 85 | for gs in self.state.groups: 86 | gname = gs.name 87 | if self.first_letter: 88 | gname = gname[0] 89 | sx, sy, w, h, ax, ay = canvas.text_extents(gname) 90 | if gs.active: 91 | canvas.set_source(self.selected_color) 92 | if self.filled: 93 | canvas.rectangle(x, 0, ax + between, self.height) 94 | canvas.fill() 95 | else: 96 | canvas.rectangle( 97 | x + 2, 2, ax + between - 4, self.height - 4 98 | ) 99 | canvas.stroke() 100 | elif gs.visible: 101 | canvas.set_source(self.subactive_color) 102 | if self.filled: 103 | canvas.rectangle(x, 0, ax + between, self.height) 104 | canvas.fill() 105 | else: 106 | canvas.rectangle( 107 | x + 2, 2, ax + between - 4, self.height - 4 108 | ) 109 | canvas.stroke() 110 | if gs.urgent: 111 | canvas.set_source(self.urgent_color) 112 | elif gs.empty: 113 | canvas.set_source(self.inactive_color) 114 | else: 115 | canvas.set_source(self.active_color) 116 | canvas.move_to(x + self.padding.left, 117 | self.height - self.padding.bottom) 118 | canvas.show_text(gname) 119 | x += ax + between 120 | return x, r 121 | -------------------------------------------------------------------------------- /tilenol/widgets/title.py: -------------------------------------------------------------------------------- 1 | from zorro.di import di, has_dependencies, dependency 2 | from cairo import SolidPattern 3 | import cairo 4 | 5 | from .base import Widget 6 | from tilenol.commands import CommandDispatcher 7 | from tilenol.theme import Theme 8 | from tilenol.ewmh import get_title 9 | 10 | 11 | @has_dependencies 12 | class Title(Widget): 13 | 14 | dispatcher = dependency(CommandDispatcher, 'commander') 15 | theme = dependency(Theme, 'theme') 16 | 17 | stretched = True 18 | 19 | def __zorro_di_done__(self): 20 | bar = self.theme.bar 21 | self.color = bar.text_color_pat 22 | self.font = bar.font 23 | self.padding = bar.text_padding 24 | self.dispatcher.events['window'].listen(self.window_changed) 25 | self.oldwin = None 26 | 27 | def window_changed(self): 28 | if self.oldwin is not None: 29 | self.oldwin.property_changed.unlisten(self.bar.redraw.emit) 30 | win = self.dispatcher.get('window', None) 31 | if win is not None: 32 | win.property_changed.listen(self.bar.redraw.emit) 33 | self.oldwin = win 34 | self.bar.redraw.emit() 35 | 36 | def draw(self, canvas, l, r): 37 | win = self.dispatcher.get('window', None) 38 | if not win: 39 | return r, r 40 | canvas.set_source(self.color) 41 | self.font.apply(canvas) 42 | canvas.move_to(l + self.padding.left, 43 | self.height - self.padding.bottom) 44 | canvas.show_text(get_title(win) or '') 45 | return r, r 46 | 47 | 48 | @has_dependencies 49 | class Icon(Widget): 50 | 51 | dispatcher = dependency(CommandDispatcher, 'commander') 52 | theme = dependency(Theme, 'theme') 53 | 54 | def __zorro_di_done__(self): 55 | self.padding = self.theme.bar.box_padding 56 | self.dispatcher.events['window'].listen(self.window_changed) 57 | self.oldwin = None 58 | 59 | def window_changed(self): 60 | if self.oldwin is not None: 61 | self.oldwin.property_changed.unlisten(self.bar.redraw.emit) 62 | win = self.dispatcher.get('window', None) 63 | if win is not None: 64 | win.property_changed.listen(self.bar.redraw.emit) 65 | self.oldwin = win 66 | self.bar.redraw.emit() 67 | 68 | def draw(self, canvas, l, r): 69 | win = self.dispatcher.get('window', None) 70 | if not win or not getattr(win, 'icons', None): 71 | return l, r 72 | h = self.height - self.padding.bottom - self.padding.top 73 | if self.right: 74 | x = r - self.padding.right - h 75 | else: 76 | x = l + self.padding.left 77 | win.draw_icon(canvas, x, self.padding.top, h) 78 | if self.right: 79 | return l, r - h - self.padding.left - self.padding.right 80 | else: 81 | return l + h + self.padding.left + self.padding.right, r 82 | 83 | 84 | -------------------------------------------------------------------------------- /tilenol/widgets/tray.py: -------------------------------------------------------------------------------- 1 | import struct 2 | 3 | from zorro.di import di, dependency, has_dependencies 4 | 5 | from .base import Widget 6 | from tilenol.xcb import Core, Rectangle 7 | from tilenol.events import EventDispatcher 8 | from tilenol.window import ClientMessageWindow, Window, Rectangle 9 | from tilenol.theme import Theme 10 | 11 | 12 | class TrayIcon(Window): 13 | 14 | def destroyed(self): 15 | super().destroyed() 16 | self.systray.remove(self) 17 | 18 | 19 | @has_dependencies 20 | class Systray(Widget): 21 | 22 | xcore = dependency(Core, 'xcore') 23 | dispatcher = dependency(EventDispatcher, 'event-dispatcher') 24 | theme = dependency(Theme, 'theme') 25 | 26 | def __init__(self, *, right=False): 27 | super().__init__(right=right) 28 | self.icons = [] 29 | 30 | def __zorro_di_done__(self): 31 | self.padding = self.theme.bar.box_padding 32 | self.spacing = self.theme.bar.icon_spacing 33 | self.create_window() 34 | 35 | def create_window(self): 36 | self.window = di(self).inject(ClientMessageWindow( 37 | self.xcore.create_toplevel( 38 | Rectangle(0, 0, 1, 1), 39 | klass=self.xcore.WindowClass.InputOnly, 40 | params={}), 41 | self.systray_message)) 42 | self.window.show() 43 | self.dispatcher.register_window(self.window) 44 | self.xcore.raw.SetSelectionOwner( 45 | owner=self.window.wid, 46 | selection=self.xcore.atom._NET_SYSTEM_TRAY_S0, 47 | time=0, 48 | ) 49 | self.xcore.send_event('ClientMessage', 50 | self.xcore.EventMask.StructureNotify, 51 | self.xcore.root_window, 52 | window=self.xcore.root_window, 53 | type=self.xcore.atom.MANAGER, 54 | format=32, 55 | data=struct.pack(' 1: 120 | woeid = data['results']['place'][0]['woeid'] 121 | else: 122 | woeid = data['results']['place']['woeid'] 123 | except Exception as e: 124 | log.exception("Error fetching woeid", exc_info=e) 125 | sleep(60) 126 | else: 127 | if woeid is None: 128 | sleep(60) 129 | return woeid 130 | 131 | def fetch(self): 132 | try: 133 | data = fetchurl(self.uri) 134 | xml = ET.fromstring(data.decode('ascii')) 135 | except Exception as e: 136 | log.exception("Error fetching weather info", exc_info=e) 137 | return None 138 | data = dict() 139 | for tag in self.tags_to_fetch: 140 | elem = xml.find('.//{%s}%s' % (WEATHER_NS, tag)) 141 | for attr, val in elem.attrib.items(): 142 | data['{0}_{1}'.format(tag, attr)] = val 143 | return data 144 | 145 | def draw(self, canvas, l, r): 146 | self.font.apply(canvas) 147 | _, _, w, h, _, _ = canvas.text_extents(self.text) 148 | if self.image: 149 | iw = self.image.get_width() 150 | ih = self.image.get_height() 151 | imh = self.height 152 | imw = int(iw/ih*imh + 0.5) 153 | scale = ih/imh 154 | if self.right: 155 | x = r - w - self.padding.right - imw 156 | else: 157 | x = l 158 | y = 0 159 | pat = cairo.SurfacePattern(self.image) 160 | pat.set_matrix(cairo.Matrix( 161 | xx=scale, yy=scale, 162 | x0=-x*scale, y0=-y*scale)) 163 | pat.set_filter(cairo.FILTER_BEST) 164 | canvas.set_source(pat) 165 | canvas.rectangle(x, 0, imw, imh) 166 | canvas.fill() 167 | else: 168 | imw = 0 169 | canvas.set_source(self.color) 170 | if self.right: 171 | x = r - self.padding.right - w 172 | r -= self.padding.left + self.padding.right + w + imw 173 | else: 174 | x = l + self.padding.left 175 | l += self.padding.left + self.padding.right + w + imw 176 | canvas.move_to(x, self.height - self.padding.bottom) 177 | canvas.show_text(self.text) 178 | return l, r 179 | -------------------------------------------------------------------------------- /tilenol/xcb/__init__.py: -------------------------------------------------------------------------------- 1 | from .xmlparse import Proto 2 | from .proto import Connection, XError 3 | from .core import Core, Rectangle 4 | from .keysymparse import Keysyms 5 | -------------------------------------------------------------------------------- /tilenol/xcb/auth.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | import struct 3 | from collections import namedtuple 4 | 5 | 6 | Auth = namedtuple('Auth', 'family address number name data') 7 | 8 | 9 | def read_auth(filename=os.path.expanduser("~/.Xauthority")): 10 | def rstr(): 11 | val, = struct.unpack('>H', f.read(2)) 12 | return f.read(val) 13 | with open(filename, 'rb') as f: 14 | while True: 15 | head = f.read(2) 16 | if not head: 17 | break 18 | family, = struct.unpack(''.format(self.__class__.__name__, self.name, self) 47 | 48 | class Atom(Const): 49 | pass 50 | 51 | 52 | class AtomWrapper(object): 53 | 54 | def __init__(self, connection, proto): 55 | self._conn = connection 56 | self.proto = proto 57 | self._atoms = {} 58 | for k, v in self.proto.enums['Atom'].items(): 59 | atom = Atom(v, k) 60 | self._atoms[v] = atom 61 | setattr(self, k, atom) 62 | 63 | def __getattr__(self, name): 64 | assert name.isidentifier() 65 | props = self._conn.do_request(self.proto.requests['InternAtom'], 66 | only_if_exists=False, 67 | name=name, 68 | ) 69 | atom = Atom(props['atom'], name) 70 | self._atoms[props['atom']] = atom 71 | setattr(self, name, atom) 72 | return atom 73 | 74 | def __getitem__(self, value): 75 | try: 76 | return self._atoms[value] 77 | except KeyError: 78 | props = self._conn.do_request(self.proto.requests['GetAtomName'], 79 | atom=value) 80 | atom = Atom(value, props['name']) 81 | self._atoms[value] = atom 82 | setattr(self, props['name'], atom) 83 | return atom 84 | 85 | 86 | class EnumWrapper(object): 87 | 88 | def __init__(self, enums): 89 | for k, v in enums.items(): 90 | setattr(self, k, Const(v, k)) 91 | 92 | 93 | class RawWrapper(object): 94 | 95 | def __init__(self, conn, proto, opcode=None): 96 | self._conn = conn 97 | self._proto = proto 98 | self._opcode = opcode 99 | 100 | def __getattr__(self, name): 101 | return partial(self._conn.do_request, 102 | self._proto.requests[name], _opcode=self._opcode) 103 | 104 | 105 | class Core(object): 106 | 107 | def __init__(self, connection): 108 | self._conn = connection 109 | self._conn.connection() 110 | self.proto = connection.proto.subprotos['xproto'] 111 | self.atom = AtomWrapper(connection, self.proto) 112 | self.raw = RawWrapper(connection, self.proto) 113 | for k, lst in self.proto.enums.items(): 114 | setattr(self, k, EnumWrapper(lst)) 115 | for k, v in connection.proto.subprotos.items(): 116 | if not v.extension: 117 | continue 118 | ext = connection.query_extension(k) 119 | if not ext['present']: 120 | continue 121 | rw = RawWrapper(self._conn, v, ext['major_opcode']) 122 | setattr(self, k, rw) 123 | for ename, lst in v.enums.items(): 124 | setattr(rw, ename, EnumWrapper(lst)) 125 | self.root = self._conn.init_data['roots'][0] 126 | self.root_window = self.root['root'] 127 | pad = self._conn.init_data['bitmap_format_scanline_pad'] 128 | assert pad % 8 == 0 129 | self.bitmap_stride = pad//8 130 | self.current_event = None 131 | self.last_event = None 132 | self.last_time = 0 133 | self._event_iterator = self._events() 134 | 135 | def init_keymap(self): 136 | self.keycode_to_keysym = {} 137 | self.shift_keycode_to_keysym = {} 138 | self.keysym_to_keycode = defaultdict(list) 139 | idata = self._conn.init_data 140 | mapping = self.raw.GetKeyboardMapping( 141 | first_keycode=idata['min_keycode'], 142 | count=idata['max_keycode'] - idata['min_keycode'], 143 | ) 144 | mapiter = iter(mapping['keysyms']) 145 | for row in zip(range(idata['min_keycode'], idata['max_keycode']), 146 | *(mapiter for i in range(mapping['keysyms_per_keycode']))): 147 | self.keycode_to_keysym[row[0]] = row[1] 148 | self.shift_keycode_to_keysym[row[0]] = row[2] 149 | self.keysym_to_keycode[row[1]].append(row[0]) 150 | 151 | caps = self.ModMask.Lock # caps lock 152 | num = getattr(self.ModMask, '2') # mod2 is usually numlock 153 | mode = getattr(self.ModMask, '5') # mod5 is usually mode_switch 154 | self.modifiers_mask = ~(caps|num|mode) 155 | 156 | def create_toplevel(self, bounds, border=0, klass=None, params={}): 157 | return self.create_window(bounds, 158 | border=border, 159 | klass=klass, 160 | parent=self.root_window, 161 | params=params) 162 | 163 | def create_window(self, bounds, border=0, klass=None, parent=0, params={}): 164 | wid = self._conn.new_xid() 165 | root = self.root 166 | self.raw.CreateWindow(**{ 167 | 'wid': wid, 168 | 'root': root['root'], 169 | 'depth': 0, 170 | 'parent': parent or root['root'], 171 | 'visual': 0, 172 | 'x': bounds.x, 173 | 'y': bounds.y, 174 | 'width': bounds.width, 175 | 'height': bounds.height, 176 | 'border_width': border, 177 | 'class': klass, 178 | 'params': params, 179 | }) 180 | return wid 181 | 182 | def send_event(self, event_type, event_mask, dest, **kw): 183 | etype = self.proto.events[event_type] 184 | buf = bytearray([etype.number]) 185 | etype.write_to(buf, kw) 186 | buf[2:2] = b'\x00\x00' 187 | buf += b'\x00'*(32 - len(buf)) 188 | self.raw.SendEvent( 189 | propagate=False, 190 | destination=dest, 191 | event_mask=event_mask, 192 | event=buf, 193 | ) 194 | 195 | 196 | def get_property(self, win, name): 197 | result = self.raw.GetProperty( 198 | delete=False, 199 | window=win, 200 | property=name, 201 | type=self.atom.Any, 202 | long_offset=0, 203 | long_length=65536) 204 | typ = self.atom[result['type']] 205 | if result['format'] == 0: 206 | return typ, None 207 | elif typ in (self.atom.STRING, self.atom.UTF8_STRING): 208 | return typ, result['value'].decode('utf-8', 'replace') 209 | return typ, struct.unpack('<{}{}'.format( 210 | len(result['value']) // fmtlen[result['format']], 211 | fmtchar[result['format']]), 212 | result['value']) 213 | 214 | def _events(self): 215 | for i in self._conn.get_events(): 216 | try: 217 | self.current_event = i 218 | self.last_event = i 219 | if hasattr(i, 'time'): 220 | self.last_time = i.time 221 | yield i 222 | finally: 223 | self.current_event = None 224 | 225 | def get_events(self): 226 | return self._event_iterator 227 | 228 | def pixbuf(self, width, height): 229 | if width*height < 1024: 230 | return Pixbuf(width, height, self) 231 | elif hasattr(self, 'shm') and ShmPixbuf: 232 | return ShmPixbuf(width, height, self) 233 | elif hasattr(self, 'bigreq') or width*height*4 < 260000: 234 | return Pixbuf(width, height, self) 235 | 236 | @cached_property 237 | def pixbuf_gc(self): 238 | res = self._conn.new_xid() 239 | self.raw.CreateGC( 240 | cid=res, 241 | drawable=self.root_window, 242 | params={}, 243 | ) 244 | return res 245 | 246 | 247 | 248 | -------------------------------------------------------------------------------- /tilenol/xcb/keysymparse.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import logging 4 | 5 | log = logging.getLogger(__name__) 6 | keysym_re = re.compile( 7 | r"^#define\s+(XF86)?XK_(\w+)\s+" 8 | r"(?:(0x[a-fA-F0-9]+)|_EVDEV\((0x[0-9a-fA-F]+)\))" 9 | ) 10 | 11 | 12 | class Keysyms(object): 13 | __slots__ = ('name_to_code', 'code_to_name', '__dict__') 14 | 15 | def __init__(self): 16 | self.name_to_code = {} 17 | self.code_to_name = {} 18 | 19 | def add_from_file(self, filename): 20 | with open(filename, 'rt') as f: 21 | for line in f: 22 | m = keysym_re.match(line) 23 | if not m: 24 | continue 25 | name = (m.group(1) or '') + m.group(2) 26 | 27 | if m.group(3): 28 | try: 29 | code = int(m.group(3), 0) 30 | except ValueError: 31 | log.warn("Bad code %r for key %r", code, name) 32 | continue 33 | elif m.group(4): 34 | try: 35 | code = int(m.group(4), 0) 36 | except ValueError: 37 | log.warn("Bad code %r for evdev key %r", code, name) 38 | continue 39 | else: 40 | continue 41 | 42 | self.__dict__[name] = code 43 | self.name_to_code[name] = code 44 | self.code_to_name[code] = name 45 | 46 | def load_default(self): 47 | xproto_dir = os.environ.get("XPROTO_DIR", "/usr/include/X11") 48 | self.add_from_file(xproto_dir + '/keysymdef.h') 49 | self.add_from_file(xproto_dir + '/XF86keysym.h') 50 | 51 | -------------------------------------------------------------------------------- /tilenol/xcb/pixbuf.py: -------------------------------------------------------------------------------- 1 | import cairo 2 | 3 | 4 | class PixbufBase(object): 5 | 6 | def __init__(self, image, xcore): 7 | self._image = image 8 | self.width = image.get_width() 9 | self.height = image.get_height() 10 | self._context = cairo.Context(self._image) 11 | self.xcore = xcore 12 | 13 | def context(self): 14 | return self._context 15 | 16 | 17 | class Pixbuf(object): 18 | 19 | def __init__(self, width, height, xcore): 20 | # TODO(tailhook) round up to a scanline 21 | super().__init__(cairo.ImageSurface( 22 | cairo.FORMAT_ARGB32, width, height)) 23 | 24 | def draw(self, target, x=0, y=0): 25 | self.xcore.raw.PutImage( 26 | format=self.xcore.ImageFormat.ZPixmap, 27 | drawable=target, 28 | gc=self.xcore.pixbuf_gc, 29 | width=self._image.get_width(), 30 | height=self._image.get_height(), 31 | dst_x=x, 32 | dst_y=y, 33 | left_pad=0, 34 | depth=24, 35 | data=self._image, 36 | ) 37 | -------------------------------------------------------------------------------- /tilenol/xcb/shm.py: -------------------------------------------------------------------------------- 1 | import cairo 2 | 3 | import ctypes 4 | from .pixbuf import PixbufBase 5 | 6 | 7 | IPC_CREAT = 0o1000 8 | IPC_PRIVATE = 0 9 | IPC_RMID = 0 10 | SHM_RDONLY = 0o10000 11 | SHM_RND = 0o20000 12 | SHM_REMAP = 0o40000 13 | SHM_EXEC = 0o100000 14 | 15 | try: 16 | rt = ctypes.CDLL('librt.so', use_errno=True) 17 | shmget = rt.shmget 18 | shmget.argtypes = [ctypes.c_int, ctypes.c_size_t, ctypes.c_int] 19 | shmget.restype = ctypes.c_int 20 | shmat = rt.shmat 21 | shmat.argtypes = [ctypes.c_int, 22 | ctypes.POINTER(ctypes.c_void_p), ctypes.c_int] 23 | shmat.restype = ctypes.c_void_p 24 | shmdt = rt.shmdt 25 | shmdt.argtypes = [ctypes.c_void_p] 26 | shmdt.restype = ctypes.c_int 27 | shmctl = rt.shmctl 28 | shmctl.argtypes = [ctypes.c_int, ctypes.c_int, ctypes.c_void_p] 29 | shmctl.restype = ctypes.c_int 30 | except (OSError, AttributeError): 31 | raise ImportError("Shared memory is not supported") 32 | 33 | 34 | class ShmPixbuf(PixbufBase): 35 | 36 | def __init__(self, width, height, conn): 37 | # TODO(tailhook) round up to a scanline 38 | size = width*height*4 39 | self.shmid = shmget(IPC_PRIVATE, size, IPC_CREAT|0o777) 40 | self.addr = shmat(self.shmid, None, 0) 41 | super().__init__(cairo.ImageSurface.create_for_data( 42 | (ctypes.c_char * size).from_address(self.addr), 43 | cairo.FORMAT_ARGB32, width, height, width*4), conn) 44 | self.shmseg = conn._conn.new_xid() 45 | conn.shm.Attach( 46 | shmseg=self.shmseg, 47 | shmid=self.shmid, 48 | read_only=True, 49 | ) 50 | 51 | def draw(self, target, x=0, y=0): 52 | self.xcore.shm.PutImage( 53 | drawable=target, 54 | gc=self.xcore.pixbuf_gc, 55 | src_x=0, 56 | src_y=0, 57 | src_width=self._image.get_width(), 58 | src_height=self._image.get_height(), 59 | total_width=self._image.get_width(), 60 | total_height=self._image.get_height(), 61 | dst_x=x, 62 | dst_y=y, 63 | depth=24, 64 | format=self.xcore.ImageFormat.ZPixmap, 65 | send_event=0, 66 | shmseg=self.shmseg, 67 | offset=0, 68 | ) 69 | 70 | def __del__(self): 71 | self._image.finish() 72 | shmdt(self.addr) 73 | shmctl(self.shmid, IPC_RMID, 0) 74 | del self.shmid 75 | del self.addr 76 | self.xcore.shm.Detach(shmseg=self.shmseg) 77 | -------------------------------------------------------------------------------- /vagga.yaml: -------------------------------------------------------------------------------- 1 | containers: 2 | xvfb: 3 | auto-clean: true 4 | setup: 5 | - !Ubuntu trusty 6 | - !UbuntuUniverse 7 | - !Install [xinit, xvfb, libcairo2, xcb-proto] 8 | - !BuildDeps [libcairo2-dev, git, ca-certificates] 9 | - !PipConfig { dependencies: true } 10 | - !Py3Install 11 | - greenlet 12 | - zorro 13 | - git+git://git.cairographics.org/git/pycairo 14 | - !Sh "ln -s \ 15 | /usr/local/lib/python3.4/dist-packages/cairo/_cairo.cpython-34m.so \ 16 | /usr/lib/python3/dist-packages/cairo" 17 | volumes: 18 | /tmp: !Tmpfs 19 | size: 100Mi 20 | mode: 0o1777 21 | subdirs: 22 | .X11-unix: 23 | /tmp/.X11-unix: !BindRW /volumes/X11 24 | 25 | commands: 26 | test: !Command 27 | container: xvfb 28 | environ: { HOME: /tmp } 29 | run: [./runtests] 30 | --------------------------------------------------------------------------------