Skip to content

Instantly share code, notes, and snippets.

@xyzshantaram
Last active October 7, 2021 18:53
Show Gist options
  • Save xyzshantaram/f869808de40c5c763e1929d49277c07c to your computer and use it in GitHub Desktop.
Save xyzshantaram/f869808de40c5c763e1929d49277c07c to your computer and use it in GitHub Desktop.
lemonbar status emitter script
#!/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)
#!/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;
#!/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