696 lines
20 KiB
Python
Executable file
696 lines
20 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
|
|
import enum
|
|
import threading
|
|
import time
|
|
import i3ipc
|
|
import os
|
|
import signal
|
|
import subprocess
|
|
import logging
|
|
import coloredlogs
|
|
|
|
coloredlogs.install(level='DEBUG', fmt='%(levelname)s %(message)s')
|
|
log = logging.getLogger()
|
|
|
|
|
|
# TODO Allow deletion of Bar, BarGroup and Section for screen changes
|
|
# IDEA Use i3 ipc events rather than relying on xrandr or Xlib (less portable
|
|
# but easier)
|
|
# TODO Optimize to use write() calls instead of string concatenation (writing
|
|
# BarGroup strings should be a good compromise)
|
|
# TODO Use bytes rather than strings
|
|
# TODO Use default colors of lemonbar sometimes
|
|
# TODO Adapt bar height with font height
|
|
# TODO OPTI Static text objects that update its parents if modified
|
|
|
|
|
|
class BarGroupType(enum.Enum):
|
|
LEFT = 0
|
|
RIGHT = 1
|
|
# TODO Middle
|
|
# MID_LEFT = 2
|
|
# MID_RIGHT = 3
|
|
|
|
|
|
class BarStdoutThread(threading.Thread):
|
|
def run(self):
|
|
while Bar.running:
|
|
handle = Bar.process.stdout.readline().strip()
|
|
if not len(handle):
|
|
continue
|
|
if handle not in Bar.actionsH2F:
|
|
log.error("Unknown action: {}".format(handle))
|
|
continue
|
|
function = Bar.actionsH2F[handle]
|
|
function()
|
|
|
|
|
|
class Bar:
|
|
"""
|
|
One bar for each screen
|
|
"""
|
|
|
|
# Constants
|
|
FONTS = ["DejaVu Sans Mono for Powerline", "Font Awesome"]
|
|
FONTSIZE = 10
|
|
|
|
@staticmethod
|
|
def init():
|
|
Bar.running = True
|
|
Section.init()
|
|
|
|
cmd = ['lemonbar', '-b', '-a', '64']
|
|
for font in Bar.FONTS:
|
|
cmd += ["-f", "{}:size={}".format(font, Bar.FONTSIZE)]
|
|
Bar.process = subprocess.Popen(cmd, stdin=subprocess.PIPE,
|
|
stdout=subprocess.PIPE)
|
|
Bar.stdoutThread = BarStdoutThread()
|
|
Bar.stdoutThread.start()
|
|
|
|
# Debug
|
|
Bar(0)
|
|
# Bar(1)
|
|
|
|
@staticmethod
|
|
def stop():
|
|
Bar.running = False
|
|
Bar.process.kill()
|
|
|
|
# TODO This is not really the best way to do it I guess
|
|
os.killpg(os.getpid(), signal.SIGTERM)
|
|
|
|
@staticmethod
|
|
def run():
|
|
Bar.forever()
|
|
i3 = i3ipc.Connection()
|
|
|
|
def doStop(*args):
|
|
Bar.stop()
|
|
print(88)
|
|
|
|
try:
|
|
i3.on('ipc_shutdown', doStop)
|
|
i3.main()
|
|
except BaseException:
|
|
print(93)
|
|
Bar.stop()
|
|
|
|
# Class globals
|
|
everyone = set()
|
|
string = ""
|
|
process = None
|
|
running = False
|
|
|
|
nextHandle = 0
|
|
actionsF2H = dict()
|
|
actionsH2F = dict()
|
|
|
|
@staticmethod
|
|
def getFunctionHandle(function):
|
|
assert callable(function)
|
|
if function in Bar.actionsF2H.keys():
|
|
return Bar.actionsF2H[function]
|
|
|
|
handle = '{:x}'.format(Bar.nextHandle).encode()
|
|
Bar.nextHandle += 1
|
|
|
|
Bar.actionsF2H[function] = handle
|
|
Bar.actionsH2F[handle] = function
|
|
|
|
log.debug("Registered action {} → {}".format(handle, function))
|
|
return handle
|
|
|
|
@staticmethod
|
|
def forever():
|
|
try:
|
|
while True:
|
|
time.sleep(60)
|
|
except BaseException:
|
|
Bar.stop()
|
|
|
|
def __init__(self, screen):
|
|
assert isinstance(screen, int)
|
|
self.screen = "%{S" + str(screen) + "}"
|
|
self.groups = dict()
|
|
|
|
for groupType in BarGroupType:
|
|
group = BarGroup(groupType, self)
|
|
self.groups[groupType] = group
|
|
|
|
self.childsChanged = False
|
|
|
|
self.everyone.add(self)
|
|
|
|
@staticmethod
|
|
def addSectionAll(section, group, screens=None):
|
|
"""
|
|
.. note::
|
|
Add the section before updating it for the first time.
|
|
"""
|
|
assert isinstance(section, Section)
|
|
assert isinstance(group, BarGroupType)
|
|
# TODO screens selection
|
|
for bar in Bar.everyone:
|
|
bar.addSection(section, group=group)
|
|
|
|
def addSection(self, section, group):
|
|
assert isinstance(section, Section)
|
|
assert isinstance(group, BarGroupType)
|
|
self.groups[group].addSection(section)
|
|
|
|
def update(self):
|
|
if self.childsChanged:
|
|
self.string = self.screen
|
|
self.string += self.groups[BarGroupType.LEFT].string
|
|
self.string += self.groups[BarGroupType.RIGHT].string
|
|
|
|
self.childsChanged = False
|
|
|
|
@staticmethod
|
|
def updateAll():
|
|
|
|
if Bar.running:
|
|
Bar.string = ""
|
|
for bar in Bar.everyone:
|
|
bar.update()
|
|
Bar.string += bar.string
|
|
# Color for empty sections
|
|
Bar.string += BarGroup.color(*Section.EMPTY)
|
|
|
|
Bar.process.stdin.write(bytes(Bar.string + '\n', 'utf-8'))
|
|
Bar.process.stdin.flush()
|
|
|
|
|
|
class BarGroup:
|
|
"""
|
|
One for each group of each bar
|
|
"""
|
|
|
|
everyone = set()
|
|
|
|
def __init__(self, groupType, parent):
|
|
assert isinstance(groupType, BarGroupType)
|
|
assert isinstance(parent, Bar)
|
|
|
|
self.groupType = groupType
|
|
self.parent = parent
|
|
|
|
self.sections = list()
|
|
self.string = ''
|
|
self.parts = []
|
|
|
|
#: One of the sections that had their theme or visibility changed
|
|
self.childsThemeChanged = False
|
|
|
|
#: One of the sections that had their text (maybe their size) changed
|
|
self.childsTextChanged = False
|
|
|
|
BarGroup.everyone.add(self)
|
|
|
|
def addSection(self, section):
|
|
self.sections.append(section)
|
|
section.addParent(self)
|
|
|
|
def addSectionAfter(self, sectionRef, section):
|
|
index = self.sections.index(sectionRef)
|
|
self.sections.insert(index + 1, section)
|
|
section.addParent(self)
|
|
|
|
ALIGNS = {BarGroupType.LEFT: "%{l}", BarGroupType.RIGHT: "%{r}"}
|
|
|
|
@staticmethod
|
|
def fgColor(color):
|
|
return "%{F" + (color or '-') + "}"
|
|
|
|
@staticmethod
|
|
def bgColor(color):
|
|
return "%{B" + (color or '-') + "}"
|
|
|
|
@staticmethod
|
|
def color(fg, bg):
|
|
return BarGroup.fgColor(fg) + BarGroup.bgColor(bg)
|
|
|
|
def update(self):
|
|
if self.childsThemeChanged:
|
|
parts = [BarGroup.ALIGNS[self.groupType]]
|
|
|
|
secs = [sec for sec in self.sections if sec.visible]
|
|
lenS = len(secs)
|
|
for s in range(lenS):
|
|
sec = secs[s]
|
|
theme = Section.THEMES[sec.theme]
|
|
if self.groupType == BarGroupType.LEFT:
|
|
oSec = secs[s + 1] if s < lenS - 1 else None
|
|
else:
|
|
oSec = secs[s - 1] if s > 0 else None
|
|
oTheme = Section.THEMES[oSec.theme] \
|
|
if oSec is not None else Section.EMPTY
|
|
|
|
if self.groupType == BarGroupType.LEFT:
|
|
if s == 0:
|
|
parts.append(BarGroup.bgColor(theme[1]))
|
|
parts.append(BarGroup.fgColor(theme[0]))
|
|
parts.append(sec)
|
|
if theme == oTheme:
|
|
parts.append("")
|
|
else:
|
|
parts.append(BarGroup.color(theme[1], oTheme[1]) + "")
|
|
else:
|
|
if theme is oTheme:
|
|
parts.append("")
|
|
else:
|
|
parts.append(BarGroup.fgColor(theme[1]) + "")
|
|
parts.append(BarGroup.color(*theme))
|
|
parts.append(sec)
|
|
|
|
|
|
# TODO OPTI Concatenate successive strings
|
|
self.parts = parts
|
|
|
|
if self.childsTextChanged or self.childsThemeChanged:
|
|
self.string = ""
|
|
for part in self.parts:
|
|
if isinstance(part, str):
|
|
self.string += part
|
|
elif isinstance(part, Section):
|
|
self.string += part.curText
|
|
|
|
self.parent.childsChanged = True
|
|
|
|
self.childsThemeChanged = False
|
|
self.childsTextChanged = False
|
|
|
|
@staticmethod
|
|
def updateAll():
|
|
for group in BarGroup.everyone:
|
|
group.update()
|
|
Bar.updateAll()
|
|
|
|
|
|
class SectionThread(threading.Thread):
|
|
ANIMATION_START = 0.025
|
|
ANIMATION_STOP = 0.001
|
|
ANIMATION_EVOLUTION = 0.9
|
|
def run(self):
|
|
while Section.somethingChanged.wait():
|
|
Section.updateAll()
|
|
animTime = self.ANIMATION_START
|
|
frameTime = time.perf_counter()
|
|
while len(Section.sizeChanging) > 0:
|
|
frameTime += animTime
|
|
curTime = time.perf_counter()
|
|
sleepTime = frameTime - curTime
|
|
time.sleep(sleepTime if sleepTime > 0 else 0)
|
|
Section.updateAll()
|
|
animTime *= self.ANIMATION_EVOLUTION
|
|
if animTime < self.ANIMATION_STOP:
|
|
animTime = self.ANIMATION_STOP
|
|
|
|
|
|
class Section:
|
|
|
|
# TODO Update all of that to base16
|
|
# COLORS = ['#272822', '#383830', '#49483e', '#75715e', '#a59f85', '#f8f8f2',
|
|
# '#f5f4f1', '#f9f8f5', '#f92672', '#fd971f', '#f4bf75', '#a6e22e',
|
|
# '#a1efe4', '#66d9ef', '#ae81ff', '#cc6633']
|
|
COLORS = ['#181818', '#AB4642', '#A1B56C', '#F7CA88', '#7CAFC2', '#BA8BAF',
|
|
'#86C1B9', '#D8D8D8', '#585858', '#AB4642', '#A1B56C', '#F7CA88',
|
|
'#7CAFC2', '#BA8BAF', '#86C1B9', '#F8F8F8']
|
|
FGCOLOR = '#F8F8F2'
|
|
BGCOLOR = '#272822'
|
|
|
|
THEMES = list()
|
|
EMPTY = (FGCOLOR, BGCOLOR)
|
|
|
|
ICON = None
|
|
PERSISTENT = False
|
|
|
|
#: Sections that do not have their destination size
|
|
sizeChanging = set()
|
|
updateThread = SectionThread(daemon=True)
|
|
somethingChanged = threading.Event()
|
|
lastChosenTheme = 0
|
|
|
|
@staticmethod
|
|
def init():
|
|
for t in range(8, 16):
|
|
Section.THEMES.append((Section.COLORS[0], Section.COLORS[t]))
|
|
|
|
Section.updateThread.start()
|
|
|
|
def __init__(self, theme=None):
|
|
#: Displayed section
|
|
#: Note: A section can be empty and displayed!
|
|
self.visible = False
|
|
|
|
if theme is None:
|
|
theme = Section.lastChosenTheme
|
|
Section.lastChosenTheme = (Section.lastChosenTheme + 1) \
|
|
% len(Section.THEMES)
|
|
self.theme = theme
|
|
|
|
#: Displayed text
|
|
self.curText = ''
|
|
#: Displayed text size
|
|
self.curSize = 0
|
|
|
|
#: Destination text
|
|
self.dstText = Text(' ', Text(), ' ')
|
|
#: Destination size
|
|
self.dstSize = 0
|
|
|
|
#: Groups that have this section
|
|
self.parents = set()
|
|
|
|
self.icon = self.ICON
|
|
self.persistent = self.PERSISTENT
|
|
|
|
|
|
def __str__(self):
|
|
try:
|
|
return "<{}><{}>{:01d}{}{:02d}/{:02d}" \
|
|
.format(self.curText, self.dstText,
|
|
self.theme, "+" if self.visible else "-",
|
|
self.curSize, self.dstSize)
|
|
except:
|
|
return super().__str__()
|
|
|
|
def addParent(self, parent):
|
|
self.parents.add(parent)
|
|
|
|
def appendAfter(self, section):
|
|
assert len(self.parents)
|
|
for parent in self.parents:
|
|
parent.addSectionAfter(self, section)
|
|
|
|
def informParentsThemeChanged(self):
|
|
for parent in self.parents:
|
|
parent.childsThemeChanged = True
|
|
|
|
def informParentsTextChanged(self):
|
|
for parent in self.parents:
|
|
parent.childsTextChanged = True
|
|
|
|
def updateText(self, text):
|
|
if isinstance(text, str):
|
|
text = Text(text)
|
|
elif isinstance(text, Text) and not len(text.elements):
|
|
text = None
|
|
|
|
self.dstText[0] = None if (text is None and not self.persistent) else ((' ' + self.icon + ' ') if self.icon else ' ')
|
|
self.dstText[1] = text
|
|
self.dstText[2] = ' ' if self.dstText[1] is not None and len(self.dstText[1]) else None
|
|
|
|
self.dstSize = len(self.dstText)
|
|
self.dstText.setSection(self)
|
|
|
|
if self.curSize == self.dstSize:
|
|
if self.dstSize > 0:
|
|
self.curText = str(self.dstText)
|
|
self.informParentsTextChanged()
|
|
else:
|
|
Section.sizeChanging.add(self)
|
|
Section.somethingChanged.set()
|
|
|
|
def setDecorators(self, **kwargs):
|
|
self.dstText.setDecorators(**kwargs)
|
|
self.curText = str(self.dstText)
|
|
self.informParentsTextChanged()
|
|
Section.somethingChanged.set()
|
|
|
|
def updateTheme(self, theme):
|
|
assert isinstance(theme, int)
|
|
assert theme < len(Section.THEMES)
|
|
if theme == self.theme:
|
|
return
|
|
self.theme = theme
|
|
self.informParentsThemeChanged()
|
|
Section.somethingChanged.set()
|
|
|
|
def updateVisibility(self, visibility):
|
|
assert isinstance(visibility, bool)
|
|
|
|
self.visible = visibility
|
|
self.informParentsThemeChanged()
|
|
Section.somethingChanged.set()
|
|
|
|
@staticmethod
|
|
def fit(text, size):
|
|
t = len(text)
|
|
return text[:size] if t >= size else text + [" "] * (size - t)
|
|
|
|
def update(self):
|
|
# TODO Might profit of a better logic
|
|
if not self.visible:
|
|
self.updateVisibility(True)
|
|
return
|
|
|
|
if self.dstSize > self.curSize:
|
|
self.curSize += 1
|
|
elif self.dstSize < self.curSize:
|
|
self.curSize -= 1
|
|
else:
|
|
# Visibility toggling must be done one step after curSize = 0
|
|
if self.dstSize == 0:
|
|
self.updateVisibility(False)
|
|
Section.sizeChanging.remove(self)
|
|
return
|
|
|
|
self.curText = self.dstText.text(size=self.curSize, pad=True)
|
|
self.informParentsTextChanged()
|
|
|
|
@staticmethod
|
|
def updateAll():
|
|
"""
|
|
Process all sections for text size changes
|
|
"""
|
|
|
|
for sizeChanging in Section.sizeChanging.copy():
|
|
sizeChanging.update()
|
|
|
|
BarGroup.updateAll()
|
|
|
|
Section.somethingChanged.clear()
|
|
|
|
@staticmethod
|
|
def ramp(p, ramp=" ▁▂▃▄▅▆▇█"):
|
|
if p > 1:
|
|
return ramp[-1]
|
|
elif p < 0:
|
|
return ramp[0]
|
|
else:
|
|
return ramp[round(p * (len(ramp)-1))]
|
|
|
|
|
|
class StatefulSection(Section):
|
|
# TODO FEAT Allow to temporary expand the section (e.g. when important change)
|
|
NUMBER_STATES = None
|
|
DEFAULT_STATE = 0
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
Section.__init__(self, *args, **kwargs)
|
|
self.state = self.DEFAULT_STATE
|
|
if hasattr(self, 'onChangeState'):
|
|
self.onChangeState(self.state)
|
|
self.setDecorators(clickLeft=self.incrementState,
|
|
clickRight=self.decrementState)
|
|
|
|
def incrementState(self):
|
|
newState = min(self.state + 1, self.NUMBER_STATES - 1)
|
|
self.changeState(newState)
|
|
|
|
def decrementState(self):
|
|
newState = max(self.state - 1, 0)
|
|
self.changeState(newState)
|
|
|
|
def changeState(self, state):
|
|
assert isinstance(state, int)
|
|
assert state < self.NUMBER_STATES
|
|
self.state = state
|
|
if hasattr(self, 'onChangeState'):
|
|
self.onChangeState(state)
|
|
self.refreshData()
|
|
|
|
class ColorCountsSection(StatefulSection):
|
|
# TODO FEAT Blend colors when not expanded
|
|
# TODO FEAT Blend colors with importance of count
|
|
# TODO FEAT Allow icons instead of counts
|
|
NUMBER_STATES = 3
|
|
COLORABLE_ICON = '?'
|
|
|
|
def __init__(self, theme=None):
|
|
StatefulSection.__init__(self, theme=theme)
|
|
|
|
def fetcher(self):
|
|
counts = self.subfetcher()
|
|
# Nothing
|
|
if not len(counts):
|
|
return None
|
|
# Icon colored
|
|
elif self.state == 0 and len(counts) == 1:
|
|
count, color = counts[0]
|
|
return Text(self.COLORABLE_ICON, fg=color)
|
|
# Icon
|
|
elif self.state == 0 and len(counts) > 1:
|
|
return Text(self.COLORABLE_ICON)
|
|
# Icon + Total
|
|
elif self.state == 1 and len(counts) > 1:
|
|
total = sum([count for count, color in counts])
|
|
return Text(self.COLORABLE_ICON, ' ', total)
|
|
# Icon + Counts
|
|
else:
|
|
text = Text(self.COLORABLE_ICON)
|
|
for count, color in counts:
|
|
text.append(' ', Text(count, fg=color))
|
|
return text
|
|
|
|
|
|
class Text:
|
|
def _setElements(self, elements):
|
|
# TODO OPTI Concatenate consecutrive string
|
|
self.elements = list(elements)
|
|
|
|
def _setDecorators(self, decorators):
|
|
# TODO OPTI Convert no decorator to strings
|
|
self.decorators = decorators
|
|
self.prefix = None
|
|
self.suffix = None
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
self._setElements(args)
|
|
self._setDecorators(kwargs)
|
|
self.section = None
|
|
|
|
def append(self, *args):
|
|
self._setElements(self.elements + list(args))
|
|
|
|
def prepend(self, *args):
|
|
self._setElements(list(args) + self.elements)
|
|
|
|
def setElements(self, *args):
|
|
self._setElements(args)
|
|
|
|
def setDecorators(self, **kwargs):
|
|
self._setDecorators(kwargs)
|
|
|
|
def setSection(self, section):
|
|
assert isinstance(section, Section)
|
|
self.section = section
|
|
for element in self.elements:
|
|
if isinstance(element, Text):
|
|
element.setSection(section)
|
|
|
|
def _genFixs(self):
|
|
if self.prefix is not None and self.suffix is not None:
|
|
return
|
|
|
|
self.prefix = ''
|
|
self.suffix = ''
|
|
|
|
def nest(prefix, suffix):
|
|
self.prefix = self.prefix + '%{' + prefix + '}'
|
|
self.suffix = '%{' + suffix + '}' + self.suffix
|
|
|
|
def getColor(val):
|
|
# TODO Allow themes
|
|
assert isinstance(val, str) and len(val) == 7
|
|
return val
|
|
|
|
def button(number, function):
|
|
handle = Bar.getFunctionHandle(function)
|
|
nest('A' + number + ':' + handle.decode() + ':', 'A' + number)
|
|
|
|
for key, val in self.decorators.items():
|
|
if val is None:
|
|
continue
|
|
if key == 'fg':
|
|
reset = self.section.THEMES[self.section.theme][0]
|
|
nest('F' + getColor(val), 'F' + reset)
|
|
elif key == 'bg':
|
|
reset = self.section.THEMES[self.section.theme][1]
|
|
nest('B' + getColor(val), 'B' + reset)
|
|
elif key == "clickLeft":
|
|
button('1', val)
|
|
elif key == "clickMiddle":
|
|
button('2', val)
|
|
elif key == "clickRight":
|
|
button('3', val)
|
|
elif key == "scrollUp":
|
|
button('4', val)
|
|
elif key == "scrollDown":
|
|
button('5', val)
|
|
else:
|
|
log.warn("Unkown decorator: {}".format(key))
|
|
|
|
def _text(self, size=None, pad=False):
|
|
self._genFixs()
|
|
curString = self.prefix
|
|
curSize = 0
|
|
remSize = size
|
|
|
|
for element in self.elements:
|
|
if element is None:
|
|
continue
|
|
elif isinstance(element, Text):
|
|
newString, newSize = element._text(size=remSize)
|
|
else:
|
|
newString = str(element)
|
|
if remSize is not None:
|
|
newString = newString[:remSize]
|
|
newSize = len(newString)
|
|
|
|
curString += newString
|
|
curSize += newSize
|
|
|
|
if remSize is not None:
|
|
remSize -= newSize
|
|
if remSize <= 0:
|
|
break
|
|
|
|
curString += self.suffix
|
|
|
|
if pad and remSize > 0:
|
|
curString += ' ' * remSize
|
|
curSize += remSize
|
|
|
|
if size is not None:
|
|
if pad:
|
|
assert size == curSize
|
|
else:
|
|
assert size >= curSize
|
|
return curString, curSize
|
|
|
|
def text(self, *args, **kwargs):
|
|
string, size = self._text(*args, **kwargs)
|
|
return string
|
|
|
|
def __str__(self):
|
|
self._genFixs()
|
|
curString = self.prefix
|
|
for element in self.elements:
|
|
if element is None:
|
|
continue
|
|
else:
|
|
curString += str(element)
|
|
curString += self.suffix
|
|
return curString
|
|
|
|
def __len__(self):
|
|
curSize = 0
|
|
for element in self.elements:
|
|
if element is None:
|
|
continue
|
|
elif isinstance(element, Text):
|
|
curSize += len(element)
|
|
else:
|
|
curSize += len(str(element))
|
|
return curSize
|
|
|
|
|
|
def __getitem__(self, index):
|
|
return self.elements[index]
|
|
|
|
def __setitem__(self, index, data):
|
|
self.elements[index] = data
|