├── .travis.yml ├── README.md ├── examples └── example_001.py ├── lemonbar_manager.py └── setup.py /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - '3.5' 4 | install: true 5 | script: true 6 | deploy: 7 | provider: pypi 8 | username: __token__ 9 | password: 10 | secure: > 11 | V3L1ANxSDzWpSN9GxKsdjuXcQG7JrLTdVt5x8A8s7UayXpdJ0ZHFXsf2ADJUbO1mgKhfzsM 12 | 9asZ0tG5orvjdEBWk4JvpW8853jUKflKRCuPbhjx0ADF22KRL2vzueUXMg8/ji4/M+V8mwQ 13 | rABGe2mfGynXnUZZkNA8UCAwWwdjtwUdgDgOzK4R2+alNGl5BLJfizNv9rF5MtJ0TGZj1sC 14 | seEztnRVz5lXDZuXNVhsLRcSS0lZzxgsbJoSej03MGutDm0T/ntsfGy+AhWbVMueLjnnD3V 15 | M5hTAglm9BfzdJb0KmOBANRmhg9DK5kRCG6BgWjFZv9O7cHgdOSmfSzag1jzqI8X00L7joW 16 | ZFuGi1MXh8zu/BioSVjsae527U2k4iR3WPA1RehYTRTPDOdMe+Q0WZVUX9CJJjXDMdn2U3A 17 | XsYxdm1gDHQ2eDCtUWIW4q+r6/XYlKgw5qKuMSY6IePslqyw5R++HkFF08ijwtl/1XDTmTv 18 | 5G7GGHZFuIJotoGylXZOqeROcyKNKLw+yGo0+Jcnz1VMIQ9sohm0bOmc3yjDT6ldcf15m4t 19 | Aheb2To+7xktFtkitVTyLLAym52zBJuY+NEonf/jUwlIGTSqvpopI0vvOqOZ95n8CHswTNJ 20 | eQ35qd7F60ff4IVOq0a7dqyv74DmU0+c/gH/XcKZ3Ifo= 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Lemonbar Manager 2 | ================ 3 | 4 | A couple of simple Python classes to manage reading from and writing to 5 | [Lemonbar][lemonbar]. 6 | 7 | Modules can be either, **time** based (i.e. update every N seconds), **select** 8 | based (i.e. update when a file descriptor (or anything that [select][select] can 9 | handle) becomes readable) or **event** based (i.e. when something on the bar is 10 | clicked). 11 | 12 | **This allows us to do away with polling in a lot of cases!** 13 | 14 | No modules included, they're simple to write, so get to it ;) 15 | 16 | Installation 17 | ------------ 18 | 19 | Install with: 20 | `pip3 install lemonbar-manager` 21 | 22 | A Basic Bar 23 | ----------- 24 | 25 | ```python 26 | from lemonbar_manager import Manager, Module 27 | 28 | # Create a simple "Hello World" module 29 | class HelloWorld(Module): 30 | def output(self): 31 | """Output a constant string to the bar. 32 | """ 33 | return 'Hello World' 34 | 35 | # Define the modules to put on the bar (in order) 36 | modules = ( 37 | HelloWorld(), 38 | ) 39 | 40 | # The command used to launch lemonbar 41 | command = ( 42 | 'lemonbar', 43 | '-b', # Dock at the bottom of the screen 44 | '-g', 'x25', # Set the height of the bar 45 | '-u', '2', # Set the underline thickness 46 | ) 47 | 48 | # Run the bar with the given modules 49 | with Manager(command, modules) as mgr: 50 | mgr.loop() 51 | ``` 52 | 53 | Take a look through the [examples directory][examples] for a more in-depth look 54 | at what's possible. 55 | 56 | [select]: https://docs.python.org/3.7/library/select.html#select.select 57 | [lemonbar]: https://github.com/LemonBoy/bar 58 | [examples]: examples 59 | -------------------------------------------------------------------------------- /examples/example_001.py: -------------------------------------------------------------------------------- 1 | from collections import OrderedDict 2 | import re 3 | import subprocess as sp 4 | from time import time, strftime 5 | 6 | from lemonbar_manager import Module, Manager 7 | 8 | 9 | class Const(Module): 10 | def __init__(self, value): 11 | """A constant value. 12 | 13 | Parameters: 14 | value (str): The value to output to the bar. 15 | """ 16 | super().__init__() 17 | self._value = value 18 | 19 | def output(self): 20 | return self._value 21 | 22 | 23 | class Launcher(Module): 24 | def __init__(self, label, command): 25 | """A simple clickable element to launch a program. 26 | 27 | Parameters: 28 | label (str): The string to write to the bar. 29 | command (list): The command to execute (with Popen) 30 | """ 31 | super().__init__() 32 | 33 | self._label = label 34 | self._command = command 35 | self._event_name = '{}_click'.format(self._label) 36 | 37 | def output(self): 38 | return '%{A:' + self._event_name + ':}' + self._label + '%{A}' 39 | 40 | def handle_event(self, event): 41 | """Handle out click event. 42 | 43 | Parameters: 44 | event (str): The event name. 45 | """ 46 | if event != self._event_name: # Ignore the event if it's not ours 47 | return 48 | 49 | sp.Popen(self._command) 50 | 51 | 52 | class Clock(Module): 53 | def __init__(self): 54 | """A simple clock. 55 | 56 | The clock can be clicked and will switch to the date for a period of 57 | time. 58 | """ 59 | super().__init__() 60 | self.wait_time = 60 # How often to update this module 61 | self._toggled_at = 0 # When the clock was toggled 62 | 63 | def output(self): 64 | # If the clock has been toggled for more than a certain period of time 65 | if self._toggled_at and time() - self._toggled_at > 5: 66 | self._toggled_at = None # Automatically toggle back to the clock 67 | 68 | if self._toggled_at: 69 | return '%{A:toggle_clock:}\uf0ee ' + strftime('%d/%m/%Y') + '%{A}' 70 | else: 71 | return '%{A:toggle_clock:}\uf150 ' + strftime('%H:%M') + '%{A}' 72 | 73 | def handle_event(self, event): 74 | if event != 'toggle_clock': 75 | return 76 | 77 | self._toggled_at = None if self._toggled_at else time() 78 | self.last_update = 0 # Invalidate the cache 79 | 80 | 81 | class Volume(Module): 82 | def __init__(self, device): 83 | """A simple ALSA volume control. 84 | 85 | Parameters: 86 | device (str): The name of the ALSA device to control. 87 | """ 88 | super().__init__() 89 | self._device = device 90 | 91 | self._regex = re.compile(r'(\d{1,3})%') # For parsing ALSA output 92 | self._increments = 20 # The resolution of our volume control 93 | 94 | self._current_level = self._get_level() 95 | 96 | def _parse_amixer(self, data): 97 | """Parse the output from the amixer command. 98 | 99 | Parameters:: 100 | data (str): The output from amixer. 101 | 102 | Returns: 103 | int: An integer between 0 and 100 (inclusive) representing the 104 | volume level. 105 | """ 106 | levels = [int(level) for level in re.findall(self._regex, data)] 107 | return sum(levels) / (len(levels) * 100) 108 | 109 | def _get_level(self): 110 | """Get the current volume level for the device. 111 | 112 | Returns: 113 | int: An integer between 0 and 100 (inclusive) representing the 114 | volume level. 115 | """ 116 | process = sp.run( 117 | ['amixer', 'get', self._device], 118 | stdout=sp.PIPE, 119 | encoding='UTF-8') 120 | 121 | return self._parse_amixer(process.stdout) 122 | 123 | def _set_level(self, percent): 124 | """Set the volume level for the device. 125 | 126 | Parameters: 127 | percent (int): An integer between 0 and 100 (inclusive). 128 | """ 129 | process = sp.run( 130 | ['amixer', 'set', 'Master', '{:.0f}%'.format(percent)], 131 | stdout=sp.PIPE, 132 | encoding='UTF-8') 133 | 134 | def output(self): 135 | output = ['\uF57F'] 136 | for i in range(self._increments + 1): 137 | output.append('%{{A:set_volume_{}_{}:}}'.format(self._device, i)) 138 | 139 | if round(self._increments*self._current_level) >= i: 140 | output.append('--') 141 | else: 142 | output.append(' ') 143 | 144 | output.append('%{A}') 145 | 146 | output.append(' \uF57E') 147 | 148 | return ''.join(output) 149 | 150 | def handle_event(self, event): 151 | if not event.startswith('set_volume_{}_'.format(self._device)): 152 | return 153 | 154 | level = int(event[event.rindex('_')+1:]) 155 | percent = (level / self._increments) * 100 156 | 157 | self._set_level(percent) 158 | self._current_level = percent 159 | self.last_update = 0 # Invalidate the cache to force a redraw 160 | 161 | 162 | class BSPWM(Module): 163 | def __init__(self, monitor): 164 | """A BSPWM desktop indicator. 165 | 166 | Parameters: 167 | monitor (str): The name of the monitor to show the desktop status 168 | for. 169 | """ 170 | super().__init__() 171 | 172 | # Subscribe to BSPWM events and make the `Manager` class wait on it's 173 | # stdout before updating the module. 174 | self._subscription_process = sp.Popen( 175 | ['bspc', 'subscribe'], stdout=sp.PIPE, encoding='UTF-8') 176 | self.readables = [self._subscription_process.stdout] 177 | 178 | self._monitor = monitor 179 | 180 | # The different format strings use to display the stauts of the desktops 181 | self._formats = { 182 | 'O': '%{{+u}}%{{+o}} {} %{{-u}}%{{-o}}', # Focused, Occupied 183 | 'F': '%{{+u}}%{{+o}} {} %{{-u}}%{{-o}}', # Focused, Free 184 | 'U': '%{{B#CF6A4C}} {} %{{B-}}', # Focused, Urgent 185 | 186 | 'o': '%{{B#222}} {} %{{B-}}', # Unfocused, Occupied 187 | 'f': ' {} ', # Unfocused, Free 188 | 'u': '%{{B#F00}} {} %{{B-}}', # Unfocused, Urgent 189 | } 190 | 191 | def _parse_event(self, event): 192 | """Parse a BSPWM event. 193 | 194 | Parameters: 195 | event (str): The BSPWM event. 196 | 197 | Returns: 198 | OrderedDict: Keys are desktop names, values are the status. 199 | """ 200 | desktops = OrderedDict() 201 | 202 | event = event.lstrip('W') 203 | items = event.split(':') 204 | 205 | on_monitor = False 206 | 207 | for item in items: 208 | k, v = item[0], item[1:] 209 | 210 | if k in 'Mm': 211 | on_monitor = v == self._monitor 212 | elif on_monitor and k in 'OoFfUu': 213 | desktops[v] = k 214 | 215 | return desktops 216 | 217 | def output(self): 218 | event = self.readables[0].readline().strip() 219 | 220 | desktops = self._parse_event(event) 221 | 222 | output = [] 223 | for desktop, state in desktops.items(): 224 | output.append('%{{A:focus_desktop_{}:}}'.format(desktop)) 225 | output.append(self._formats[state].format(desktop)) 226 | output.append('%{A}') 227 | 228 | return ''.join(output) 229 | 230 | def handle_event(self, event): 231 | if not event.startswith('focus_desktop_'): 232 | return 233 | 234 | desktop = event[event.rindex('_')+1:] 235 | sp.Popen(['bspc', 'desktop', '--focus', '{}.local'.format(desktop)]) 236 | 237 | 238 | # Define the modules to put on the bar (in order) 239 | modules = ( 240 | Const('%{Sf}%{l}'), 241 | BSPWM('DVI-D-0'), 242 | Const('%{r}'), 243 | Clock(), 244 | Const(' '), 245 | 246 | Const('%{Sl}%{l}'), 247 | BSPWM('DP-1'), 248 | Const('%{c}'), 249 | Volume('Master'), 250 | Const('%{r}'), 251 | Launcher(' \uF425 ', ['shutdown', '-h', 'now']) 252 | ) 253 | 254 | 255 | # Lemonbar command 256 | command = ( 257 | 'lemonbar', 258 | '-b', 259 | '-a', '40', 260 | '-g', 'x25', 261 | '-B', '#111111', 262 | '-F', '#B2B2B2', 263 | '-U', '#D8AD4C', 264 | '-u', '2', 265 | '-o', '1', # Push Noto down 1px 266 | '-o', '-1', # Pull Material Desicn Icons up 1px 267 | '-f', 'Noto Sans Display,Noto Sans Disp ExtLt', 268 | '-f', 'Material Design Icons' 269 | ) 270 | 271 | # Run the bar with the given modules 272 | with Manager(command, modules) as mgr: 273 | mgr.loop() 274 | -------------------------------------------------------------------------------- /lemonbar_manager.py: -------------------------------------------------------------------------------- 1 | import io 2 | import logging 3 | import subprocess as sp 4 | from select import select 5 | import time 6 | 7 | 8 | LOGGER = logging.getLogger(__name__) 9 | 10 | 11 | class Module: 12 | def __init__(self): 13 | """The base class each module should inherit from. 14 | 15 | You should override either `self.readable` or `self.wait_time`. 16 | 17 | If you want to wait for one or more files handle to become readable, set 18 | `self.readables` to a list of file handles. 19 | 20 | If you want to update regularly (time based), set `self.wait_time` to 21 | the interval (in seconds) to waid between updates. 22 | """ 23 | self.readables = [] 24 | self.wait_time = 86400 # Default, 1 day 25 | self.last_update = 0 26 | self.cache = None 27 | 28 | def select(self): 29 | """Get the readables from `self.readables` that are currently readable. 30 | 31 | Returns: 32 | readables (list): A list of readables that are currently readable. 33 | """ 34 | readables, _, _ = select(self.readables, [], [], 0) 35 | return readables 36 | 37 | def handle_event(self, event): 38 | """This will be called when events are fired. 39 | 40 | This recieved events from ALL modules, so you need to check the `event` 41 | parameter to ensure it's the event you want to handle. 42 | 43 | Parameters: 44 | event (str): The name of the event that was fired. 45 | """ 46 | 47 | def output(self): 48 | """The main output method of the module. 49 | 50 | This is what will be output onto the bar. 51 | 52 | Returns: 53 | str: The data to send to the bar. 54 | """ 55 | return '' 56 | 57 | 58 | class Manager: 59 | def __init__(self, args, modules): 60 | """Run lemonbar with the specified modules. 61 | 62 | The process is launched with the input piped and with the correct 63 | encoding automatically set. 64 | 65 | Parameters: 66 | args (list): The full command used to launch lemonbar. 67 | modules (list): A list of generators. 68 | 69 | Returns: 70 | subprocess.Popen: An object representing the lemonbar process. 71 | """ 72 | LOGGER.debug('Starting Process') 73 | self._lemonbar = sp.Popen( 74 | args, stdin=sp.PIPE, stdout=sp.PIPE, encoding='UTF-8') 75 | 76 | self._modules = modules 77 | 78 | def __enter__(self): 79 | LOGGER.debug('Entering context manager') 80 | return self 81 | 82 | def __exit__(self, *args, **kwargs): 83 | LOGGER.debug('Killing process and exiting context manager') 84 | self._lemonbar.kill() 85 | 86 | def _wait(self, rlist, wait_time): 87 | """Wait until an object in `rlist` is readable or `wait_time` is up. 88 | 89 | Parameter: 90 | rlist (list): A list of readables for `select` to monitor. 91 | wait_time (float): The maximum amount of time to wait. 92 | 93 | Returns: 94 | list: A list of readables that are ready to read. 95 | """ 96 | early_execution = False 97 | 98 | LOGGER.info('Waiting {} seconds'.format(wait_time)) 99 | 100 | if len(rlist) > 0: 101 | readables, _, _ = select(rlist, [], [], wait_time) 102 | LOGGER.info('{} readables ready for reading'.format(len(readables))) 103 | else: 104 | LOGGER.info('There are no readables to wait for') 105 | readables = [] 106 | time.sleep(wait_time) 107 | 108 | return readables 109 | 110 | def _run_modules(self, readables): 111 | """Run the modules ready for updating. 112 | 113 | Parameters: 114 | readables (list): A list of readables returned by `select` that are 115 | ready for reading. 116 | """ 117 | LOGGER.debug('Updating modules') 118 | 119 | now = time.time() 120 | for module in self._modules: 121 | time_delta = now - module.last_update 122 | 123 | module_readables = [ 124 | readable for readable in module.readables 125 | if readable in readables] 126 | 127 | if module_readables: 128 | LOGGER.info('Updating readable module "{}"'.format(module)) 129 | value = module.output() 130 | module.last_update = now 131 | elif not module_readables and module.wait_time and ( 132 | module.last_update == 0 or time_delta > module.wait_time): 133 | LOGGER.info('Updating time based module "{}"'.format(module)) 134 | value = module.output() 135 | module.last_update = now 136 | elif module.cache is None: 137 | LOGGER.info('Using blank value for module "{}"'.format(module)) 138 | # If the module waits on a readable and we've not had the first 139 | # read yet, we just return an empty string 140 | value = '' 141 | module.last_update = now 142 | else: 143 | LOGGER.info('Using cached value for module "{}"'.format(module)) 144 | value = module.cache 145 | 146 | LOGGER.debug('Sending "{}" to lemonbar'.format(value)) 147 | self._lemonbar.stdin.write(value) 148 | module.cache = value 149 | 150 | self._lemonbar.stdin.write('\n') 151 | self._lemonbar.stdin.flush() 152 | 153 | def _calculate_wait(self, last_loop_time, interrupted): 154 | min_wait_time = min([ 155 | module.wait_time for module in self._modules if module.wait_time]) 156 | 157 | LOGGER.debug('Minimum wait time is {}'.format(min_wait_time)) 158 | 159 | invalidated = any([ 160 | module.last_update == 0 for module in self._modules]) 161 | 162 | if invalidated: 163 | wait_time = 0 164 | elif interrupted: 165 | wait_time = min_wait_time - last_loop_time 166 | else: 167 | wait_time = min_wait_time 168 | 169 | wait_time = max(0, wait_time) 170 | 171 | LOGGER.debug('Wait time is {}'.format(wait_time)) 172 | return wait_time 173 | 174 | def loop(self): 175 | """The main program loop. 176 | 177 | Invoke this method when you want to start updating the bar. 178 | """ 179 | event_pipe = self._lemonbar.stdout 180 | 181 | start_time = time.time() 182 | end_time = start_time 183 | interrupted = False # Whether the loop was interrupted by a readable 184 | 185 | # TODO: Determine whether this should be inside the loop. For my use, 186 | # it's fine here, but do people want to swap out readables at will? 187 | rlist = [] 188 | for module in self._modules: 189 | rlist.extend(module.readables) 190 | rlist.append(event_pipe) # Wait for events coming from lemonbar too 191 | 192 | while True: 193 | last_loop_time = end_time - start_time 194 | start_time = time.time() 195 | 196 | wait_time = self._calculate_wait(last_loop_time, interrupted) 197 | 198 | readables = self._wait(rlist, wait_time) 199 | interrupted = len(readables) > 0 200 | 201 | self._run_modules(readables) 202 | 203 | if event_pipe in readables: 204 | event = event_pipe.readline().rstrip() 205 | LOGGER.info('Handling event "{}"'.format(event)) 206 | for module in self._modules: 207 | module.handle_event(event) 208 | 209 | end_time = time.time() 210 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | 4 | setup( 5 | name='lemonbar-manager', 6 | version='1.0.1', 7 | author='Vimist', 8 | description='A management framework for Lemonbar', 9 | url='https://github.com/vimist/lemonbar-manager', 10 | py_modules=['lemonbar_manager'] 11 | ) 12 | --------------------------------------------------------------------------------