Last active
October 7, 2021 18:53
-
-
Save xyzshantaram/f869808de40c5c763e1929d49277c07c to your computer and use it in GitHub Desktop.
lemonbar status emitter script
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env python3 | |
from __future__ import annotations | |
import copy | |
import datetime | |
import os | |
import re | |
import statistics | |
import subprocess | |
import sys | |
import time | |
import traceback | |
from enum import Enum | |
import psutil | |
""" | |
Copyright © 2021 Siddharth Singh | |
The MIT License | |
Permission is hereby granted, free of charge, to any person obtaining a copy | |
of this software and associated documentation files (the "Software"), to deal | |
in the Software without restriction, including without limitation the rights | |
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | |
copies of the Software, and to permit persons to whom the Software is | |
furnished to do so, subject to the following conditions: | |
* The above copyright notice and this permission notice | |
shall be included in all copies or substantial portions | |
of the Software. | |
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | |
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | |
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, | |
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN | |
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. | |
""" | |
""" | |
Dependencies | |
playerctl | |
icons are FontAwesome chars, so you'll need FA5 Brands, FA5 Solid, and FA5 Regular installed | |
the script toggle-show-desktop.sh installed in $HOME/scripts | |
xdotool | |
wmctrl | |
https://github.com/shantaram3013/volume/ installed on your path | |
pavucontrol | |
acpi | |
iwconfig | |
""" | |
def clean_whitespace(text): | |
WHITESPACE = ['\n', '\t'] | |
for char in WHITESPACE: | |
text = text.replace(char, '') | |
return text | |
def get_stdout(cmd): | |
args = cmd.split() | |
output = subprocess.run(args, capture_output=True) | |
return output.returncode, output.stdout.decode('UTF-8') # Return a tuple. | |
def get_lines(cmd): | |
_, string = get_stdout(cmd) | |
ls = string.split('\n') | |
ls = list(map(lambda x: clean_whitespace(x), ls)) | |
return ls | |
def create_button_string(text, actions): | |
def wrap(text, string, idx): return f'%{{A{idx}:{string}:}}{text}%{{A}}' # %{A:string:}text%{A} | |
mappings = { | |
'left': 1, | |
'middle': 2, | |
'right': 3, | |
'scrollup': 4, | |
'scrolldown': 5 | |
} | |
for x in mappings.keys(): | |
if x in actions: | |
text = wrap(text, actions[x] + " &", mappings[x]) # & is to run the process in the background | |
return text | |
# An enum of alignments | |
class Alignment(Enum): | |
LEFT = 'l' | |
CENTER = 'c' | |
RIGHT = 'r' | |
# StatusEmitter is the global emitter class, to which all other emitters you want to add belong | |
class StatusEmitter: | |
FGCOLOR = '#ffffff' | |
BGCOLOR = '#191454' | |
FIELD_SEP = " " | |
def __init__(self): | |
self.emitters = [] | |
def compute(self): | |
emitters_computed = [] | |
for emitter in self.emitters: | |
emitter_string = '' | |
if emitter.align: | |
emitter_string += f'%{{{emitter.align.value}}}' | |
emitter_string += emitter.left + ' ' | |
if emitter.icon: | |
emitter_string += f'{emitter.icon} ' | |
try: | |
emitter_string += emitter.emit() | |
except: | |
emitter_string += 'ERROR' | |
emitter_string += ' ' + emitter.right | |
bgcolor = emitter.bgcolor if emitter.bgcolor else self.BGCOLOR | |
fgcolor = emitter.fgcolor if emitter.fgcolor else self.FGCOLOR | |
emitter_string = ('%%{B%s}' % bgcolor) + emitter_string + ("%%{B%s}" % self.BGCOLOR) | |
emitter_string = ('%%{F%s}' % fgcolor) + emitter_string + ("%%{F%s}" % self.FGCOLOR) | |
emitters_computed.append(emitter_string) | |
status = ''.join(emitters_computed) | |
return status | |
def emit(self): | |
print(self.compute()) | |
def register_emitters(self, *emitters: list | Emitter): | |
self.emitters += [x for x in emitters] | |
def get_emitters(self): | |
return self.emitters | |
# Emitter is the base emitter class. It manages the nitty-gritties of updating things at different times, chiefly. | |
# To create your own emitters, inherit from Emitter and override the compute method (at least.) | |
class Emitter: | |
DELAY = 1000 # millis | |
left = '' | |
right = '' | |
bgcolor = '' | |
fgcolor = '' | |
align = '' | |
icon = '' | |
def __init__(self): | |
self.last_emit = None | |
try: | |
self.cur_value = self.compute() | |
except: | |
self.cur_value = 'ERROR' | |
def compute(self): | |
return "" | |
def emit(self): | |
cur = copy.copy(datetime.datetime.now()) | |
if (self.last_emit is None): | |
diff = datetime.datetime.max - datetime.datetime.min | |
else: | |
diff = (cur - self.last_emit) | |
if diff >= datetime.timedelta(milliseconds=self.DELAY): | |
self.last_emit = cur | |
self.cur_value = self.compute() | |
return self.get_cur() | |
def get_cur(self): | |
return self.cur_value | |
# ScrollingEmitter is a base class for scrolling emitters. Instead of overriding compute as usual, | |
# override long_compute() | |
class ScrollingEmitter(Emitter): | |
DELAY = 200 | |
cur_idx = 0 | |
length = 10 | |
def long_compute(self): | |
return ' a demo of some scrolling text ' | |
def __init__(self, size): | |
Emitter.__init__(self) | |
self.length = size or 10 | |
def compute(self): | |
STRING = self.long_compute() | |
if (len(STRING) < self.length): | |
return STRING | |
slc = STRING[self.cur_idx:self.cur_idx + self.length] | |
self.cur_idx += 1 | |
if (self.cur_idx >= len(STRING) - self.length): # if there are less characters left than the current cursor position... | |
remaining = len(STRING) - self.cur_idx # number of chars left to the end of the string | |
slc = STRING[self.cur_idx:self.cur_idx + remaining] # get all the remaining chars. | |
slc += STRING[:self.length - remaining] # get length - remaining = number of chars more we need to get to 10 chars. | |
self.cur_idx %= len(STRING) | |
return slc | |
# Clock Emitter. Change fmt_string to change the date format (see `man strftime` on Linux) | |
class ClockEmitter(Emitter): | |
icon = '\uf017' # clock | |
DELAY = 500 | |
def compute(self): | |
t = datetime.datetime.now() | |
fmt_string = '%F (%A) %H:%M:%S' | |
hour = t.hour | |
# Change color based on time of day | |
if hour < 6: | |
self.bgcolor = '#632b6c' | |
elif 6 <= hour < 12: | |
self.bgcolor = '#f0888c' | |
elif 12 <= hour < 18: | |
self.bgcolor = '#f28e59' | |
else: | |
self.bgcolor = '#2a0e37' | |
return t.strftime(fmt_string) | |
class TitleEmitter(Emitter): | |
icon = '\uf2d0' # Window icon by default, changes to branded icon based on app | |
DELAY = 500 | |
TITLE_MAX_LEN = 20 | |
TITLE_SHORTENER = '...' | |
def get_window_icon(self, title, pname): | |
icons = { | |
'firefox': '\uf269', | |
'code-oss': '\uf121', | |
'Discord': '\uf392', | |
'urxvt': '\uf120', | |
'chromium': '\uf268', | |
'openbox': '\uf015', | |
'telegram-desktop': '\uf2c6' | |
} | |
subapps = { | |
'weechat': '\uf27a', | |
'WhatsApp': '\uf232', | |
'DuckDuckGo': '\uf002', | |
'YouTube': '\uf167', | |
'GitHub': '\uf09b', | |
'Wikipedia': '\uf266', | |
} | |
for name, icon in subapps.items(): # check subapps first, so app icons don't get superseded by the generic parent icon | |
if name in title: | |
return icon | |
if pname in icons: | |
return icons[pname] | |
return '\uf2d0' | |
def compute(self): | |
_, output = get_stdout('xdotool getwindowfocus getwindowname') | |
_, pid = get_stdout('xdotool getwindowfocus getwindowpid') # xdotool is magic and should be worshipped | |
output = output.strip() | |
pid = pid.strip() | |
if pid != '': # xdotool isn't actually able to find a pid for the root window, which makes sense | |
proc = psutil.Process(int(pid)) # Get the process object | |
pname = proc.name() # get its name | |
else: # if we're in the root window | |
pname = 'openbox' | |
self.icon = self.get_window_icon(output, pname) | |
output = clean_whitespace(output) | |
if len(output) >= self.TITLE_MAX_LEN: # Shorten title if it's longer than the title max length | |
output = output[:(self.TITLE_MAX_LEN - len(self.TITLE_SHORTENER))] + self.TITLE_SHORTENER | |
output = output.replace('%', ' %%') | |
return create_button_string(output, { | |
'middle': '$HOME/scripts/toggle-show-desktop.sh', # toggle desktop | |
'left': 'wmctrl -r \\:ACTIVE\\: -b toggle,maximized_vert,maximized_horz', # maximize/unmaximize focused window | |
'right': 'xdotool windowminimize $(xdotool getwindowfocus getactivewindow)' # minimize focused window | |
}) | |
def __init__(self, length = 30, shortener = '...'): # override emitter default constructor | |
Emitter.__init__(self) | |
self.TITLE_MAX_LEN = length | |
self.TITLE_SHORTENER = shortener | |
class ScrollingTitleEmitter(ScrollingEmitter): | |
icon = '\uf2d0' | |
DELAY = 500 | |
# mostly same as TitleEmitter.compute(), but doesn't do any trimming. | |
def long_compute(self): | |
_, output = get_stdout('xdotool getwindowfocus getwindowname') | |
_, pid = get_stdout('xdotool getwindowfocus getwindowpid') | |
output = output.strip() | |
pid = pid.strip() | |
if pid != '': | |
proc = psutil.Process(int(pid)) | |
pname = proc.name() | |
else: | |
pname = 'openbox' | |
self.icon = TitleEmitter.get_window_icon(self, output, pname) | |
output = clean_whitespace(output) | |
return f' {output} '.replace('%', '') # pad with spaces | |
# Redundant temperature emitter. | |
class TempEmitter(Emitter): | |
icon = '\uf769' | |
DELAY = 30000 | |
def compute(self): | |
temps = [] | |
count = 0 | |
path = '/sys/class/thermal' | |
try: | |
files = os.listdir(path) | |
for file in files: # filename | |
if file.startswith('thermal_zone'): | |
count += 1 | |
with open(os.path.join(path, file, 'temp')) as fp: # /sys/class/thermal/thermal_zone*/temp | |
temp_str = fp.read().strip() | |
temps.append(int(temp_str)) # store temp | |
temp = max(temps) # worst case | |
except: | |
temp = 0 | |
return "ERROR" if temp == 0 else (str(temp) + "C") | |
# Redundant memory emitter. | |
class MemoryEmitter(Emitter): | |
DELAY = 5000 | |
icon = '\uf538' | |
def compute(self): | |
lines = get_lines('free --mega') | |
reqd = list(map(int, lines[1].split()[1:])) # get fields from first to last, skipping the labels. see output | |
# of `free` for a better understanding of this | |
result = reqd[0] - reqd[-1] # Total - free | |
return "%.1fG" % (result/1024) # get result in xx.x GB | |
class VolumeEmitter(Emitter): | |
DELAY = 100 | |
def compute(self): | |
_, output = get_stdout('volume') # volume script | |
string = clean_whitespace(output.replace('%', '%%')) | |
# TODO: stop doing this | |
if '\uf6a9' in string: # check for muted by seeing if the mute icon is present. | |
self.bgcolor = '#663399' | |
else: | |
self.bgcolor = '#007cdf' | |
# button actions to change volume / launch pavucontrol | |
return create_button_string(string, { | |
'left': 'volume mute', | |
'middle': 'pavucontrol', | |
'scrollup': 'volume up', | |
'scrolldown': 'volume down' | |
}) | |
class BatteryEmitter(Emitter): | |
DELAY = 10000 # 10 seconds | |
icon = '\uf240' | |
def compute(self): | |
_, output = get_stdout('acpi --battery') | |
perc_strings = re.findall(r'\d+%', output) | |
percs = list(map(lambda x: int(x.replace('%', '')), perc_strings)) | |
percs.sort() | |
# I have two batteries, so i check the least-charged one's level. | |
perc = percs[0] | |
if perc > 80: | |
# TODO: figure out a better way to do this. | |
# if discharging is present in output we gotta put the correct battery icon. | |
if 'Discharging' in output: | |
self.icon = '\uf240' | |
self.bgcolor = '#55652f' # Colors go from green to yellow to orange to red. | |
elif 60 <= perc < 80: | |
if 'Discharging' in output: | |
self.icon = '\uf241' | |
self.bgcolor = '#4CBB17' | |
elif 40 <= perc < 60: | |
if 'Discharging' in output: | |
self.icon = '\uf242' | |
self.bgcolor = '#fca002' | |
elif 20 <= perc < 40: | |
if 'Discharging' in output: | |
self.icon = '\uf243' | |
self.bgcolor = '#d39426' | |
else: | |
if 'Discharging' in output: | |
self.icon = '\uf244' | |
self.bgcolor = '#DC143C' | |
if 'Discharging' in output: | |
pass # we already handled this case. do nothing. | |
elif output.count('Unknown') == len(percs): | |
# Basically, if the number of fully charged batteries is the same as the number of batteries, set the icon to the plug to denote exclusively ac power | |
self.icon = '\uf1e6' | |
else: | |
self.icon = '\uf0e7' | |
return ' '.join(map(lambda x: str(x) + '%%', percs)) | |
class NetworkEmitter(Emitter): | |
DELAY = 10000 | |
def compute(self): | |
code, _ = get_stdout('ping google.com -c 1') | |
if code == 0: # ping returns 0 on success | |
self.icon = '\uf6ff' # network connected | |
self.bgcolor = '#55652f' # green | |
else: | |
self.icon = '\uf6ff \uf00d' # network disconnected | |
self.bgcolor = '#DC143C' # red | |
return | |
output = get_lines('iwconfig wlp3s0') # change this to match the name of your network interface | |
filtered = list(filter(lambda x: re.match(r'.*Link Quality.*', x), output)) # find lines in output matching 'Link Quality' | |
# (the output of iwconfig will have a 'Link Quality' line with a fraction out of 70) | |
if len(filtered) == 0: | |
# sanity check | |
pass | |
else: | |
self.icon = '\uf1eb' | |
ls = list(map(int, re.findall(r'\d{1,2}\/\d{1,2}', filtered[0])[0].split('/'))) | |
# find strings matching xx/xx | |
# we split it on the slash to get the two numbers | |
return f'{int(ls[0]/ls[1] * 100)}%%' # Convert it to a percentage | |
return '' # we actually either set the icon or return the percentage. returning empty so Python is happy | |
class MusicEmitter(ScrollingEmitter): | |
DELAY = 400 | |
last_playing = '' | |
cur_player = '' | |
def long_compute(self): | |
self.icon = '\uf04d' # stopped icon by default | |
self.bgcolor = '#663399' # purple by default | |
players = get_lines('playerctl -l') # list players | |
if len(players) == 0: | |
return 'No players' | |
statuses = {} | |
for cur_player in players: | |
_, s = get_stdout(f'playerctl --player={cur_player} status') | |
# get status of each player | |
s = clean_whitespace(s) | |
if cur_player: # if it's non-empty after cleaning | |
statuses[cur_player] = s # store it | |
status = '' | |
player = '' | |
stopped = 0 | |
for cur_player, cur_status in statuses.items(): | |
if (cur_status == 'Playing'): # found a player that's playing something | |
player = cur_player | |
status = cur_status # store it | |
break | |
if cur_status == 'Stopped': # found a stopped player | |
stopped += 1 | |
if stopped == len(players): # if all players are stopped, nothing must be playing | |
return 'Nothing playing.' | |
last = list(statuses.items())[-1] | |
if player == '': # if player is empty at this point that means we didn't find anything that was playing, | |
# yet we've not returned so there's something that's not playing, and not stopped, so it must be paused | |
# if the player that was found to be playing last is currently present, we control and represent | |
# the state of that one | |
# if not, we pick the most recently added player | |
player = self.last_playing if self.last_playing in statuses else last[0] | |
# same for the status | |
status = statuses[player] if player in statuses else last[1] | |
# set icons and colors | |
if status == 'Playing': | |
self.bgcolor = '#007cdf' | |
self.icon = '\uf04b' # play icon | |
elif status == 'Paused': | |
self.icon = '\uf04c' # pause icon | |
elif status == 'Stopped': | |
return 'Nothing playing' # we might not need this, idk | |
cmd = 'playerctl --player=%s metadata --format {{title}}' % player | |
# playerctl --player=playername metadata --format {{title}} | |
self.cur_player = player # set the current and last playing players | |
# to this one so we can use them next iteration / for button clicks | |
self.last_playing = player | |
_, title = get_stdout(cmd) | |
title = clean_whitespace(title) | |
# Find and return the track title. The spaces are for scrolling | |
# if there's any percentages that get cut off and break lemonbar | |
# we should clean them | |
return f' {title} '.replace('%', ' percent') | |
# overriding emit method because we need the trimmed text to act as a button | |
def emit(self): | |
return create_button_string(ScrollingEmitter.emit(self), { | |
'left': f'playerctl --player={self.cur_player} play-pause', | |
'middle': f'playerctl --player={self.cur_player} stop' | |
}) | |
# Performance emitter. Alternatively flashes CPU and memory. | |
class PerformanceEmitter(Emitter): | |
DELAY = 2000 | |
update_count = 0 | |
def get_temp(self): | |
temps = [] | |
path = '/sys/class/thermal' | |
files = os.listdir(path) | |
for file in files: | |
if file.startswith('thermal_zone'): | |
with open(os.path.join(path, file, 'temp')) as fp: # /sys/class/thermal/thermal_zone*/temp | |
temp_str = fp.read().strip() | |
temps.append(int(temp_str)) | |
temp = max(temps) / 1000 # assume the worst case | |
if (temp > 55): | |
self.bgcolor = '#DC143C' # hotter than 55c, red | |
else: | |
self.bgcolor = '#007cdf' # cooler than 55c, blue | |
return "ERROR" if temp == 0 else ("%.1fC" % temp) | |
def get_mem(self): | |
lines = get_lines('free --mega') | |
reqd = list(map(int, lines[1].split()[1:])) | |
result = reqd[0] - reqd[-1] | |
self.bgcolor = '#55652f' | |
if result > reqd[0]/3: | |
self.bgcolor = '#FDA50F' # memory more than a third full, yellow | |
elif result > reqd[0] * 1/2: | |
self.bgcolor = '#ffa500' # memory more than half full, orange | |
elif result > reqd[0] * 3/4: | |
self.bgcolor = '#DC143C' # memory more than 3 quarers full, red | |
return "%.2fG" % (result/1024) # xx.xxG | |
def compute(self): | |
self.update_count += 1 | |
self.icon = '' | |
# to flash more things just add dicts to this list | |
ls = [ | |
{ | |
'fn': self.get_mem, | |
'icon': '\uf538' # ram icon | |
}, | |
{ | |
'fn': self.get_temp, | |
'icon': '\uf769' # temp icon | |
} | |
] | |
this_run = ls[self.update_count % len(ls)] | |
string = this_run['fn']() # call the function corresponding to nth array element | |
self.icon = this_run['icon'] # set the right icon | |
return create_button_string( | |
string, | |
{ | |
'middle': 'gnome-system-monitor' # launch gnome system monitor on middle click | |
} | |
) | |
if __name__ == '__main__': | |
# instantiate one of each kind of object we want to add | |
global_emitter = StatusEmitter() | |
perf = PerformanceEmitter() | |
perf.align = Alignment.RIGHT | |
vol = VolumeEmitter() | |
bat = BatteryEmitter() | |
clock = ClockEmitter() | |
clock.align = Alignment.CENTER | |
music = MusicEmitter(20) # music emitter should be 20 chars long at most | |
title = TitleEmitter(35, '...') # must be 35 chars long at most. | |
title.align = Alignment.LEFT | |
net = NetworkEmitter() | |
# in the order you want them to appear. | |
global_emitter.register_emitters(title, clock, music, perf, vol, net, bat) | |
while True: | |
try: | |
global_emitter.emit() | |
except KeyboardInterrupt: # Catch SIGINT and broken pipes, mainly | |
exit(0) | |
except BrokenPipeError: | |
exit(0) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/bin/bash | |
# lemonbar launcher script. | |
# i made this for lemonbar-xft-git from https://gitlab.com/protesilaos/lemonbar-xft, YMMV | |
FONT0="Fantasque Sans Mono:size=12:antialias=true" | |
FONT1="Font Awesome 5 Free Regular" | |
FONT2="Font Awesome 5 Free Solid" | |
FONT3="Font Awesome 5 Brands" | |
GEOMETRY="1844x30+0+0" | |
if pidof lemonbar; then killall -9 lemonbar; fi | |
if [[ -z $BACKEND ]]; then | |
BACKEND="~/scripts/lemon.py" | |
fi | |
sleep 1 && mobile=1 sh -c "$BACKEND | lemonbar -g $GEOMETRY -f '$FONT0' -f '$FONT1' -f '$FONT2' -f '$FONT3' | /bin/sh > /dev/null" & disown; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/bin/bash | |
# from https://askubuntu.com/questions/399248/a-command-for-showing-desktop | |
current_mode="$(wmctrl -m | grep 'showing the desktop')" | |
if [[ "${current_mode##* }" == ON ]]; then | |
wmctrl -k off | |
else | |
wmctrl -k on | |
fi |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment