from subprocess import run as subprocess_run
from sys import path as sys_path
from time import sleep
-from typing import Iterable
+from typing import Iterable, NamedTuple, Self
sys_path.append(
str(Path(__file__).resolve().parents[2] / '.local' / 'bin' / 'lib'))
from pactl import pactl_info_default_dev
METADATA = {'version': 1}
+COL_GREEN = '#00DD00'
+COL_GREY = '#AAAAAA'
+COL_RED = '#DD0000'
+COL_WHITE = '#DDDDDD'
+COL_YELLOW = '#DDDD00'
+COL_ESCALATION = (COL_GREEN, COL_YELLOW, COL_RED)
+
CMD_PASTE = 'wl-paste'
CLIP_LEN_MAX = 32
MSG_CLIPBOARD_EMPTY = 'Nothing is copied'
PATH_POWER_SUPPLY = Path('/sys/class/power_supply')
GLOB_BATTERY = 'BAT*'
BATTERY_STATUS_SYMBOLS = {
- 'Charging': '^',
- 'Discharging': 'v',
- 'Not charging': '-',
+ 'Charging': ('^', COL_GREEN),
+ 'Discharging': ('v', COL_RED),
+ 'Not charging': ('-', COL_WHITE),
}
PATH_THERMAL = Path('/sys/class/thermal')
GLOB_THERMAL = 'thermal_zone*/temp'
+class Block(NamedTuple):
+ 'Status bar block attributes.'
+ full_text: str
+ color: str = COL_GREY
+ markup: str = 'none'
+ separator: bool = False
+ separator_block_width: int = 0
+
+ @classmethod
+ def color_escalated(
+ cls,
+ value: int,
+ limits: tuple[int, int]
+ ) -> Self:
+ 'Color value based on limits over COL_ESCALATION.'
+ color = COL_ESCALATION[-1]
+ for idx, limit in enumerate(limits):
+ if (limits[0] > limits[1] and value >= limit)\
+ or (limits[0] < limits[1] and value < limit):
+ color = COL_ESCALATION[idx]
+ break
+ return cls(f'{value}', color)
+
+
def int_at_path(
path: Path
) -> int:
return items.pop()
-def info_clipboard(
- ) -> tuple[str, ...]:
+def blocks_clipboard(
+ ) -> tuple[Block, ...]:
'Produce clipboard contents summary.'
- texts = []
+ blocks = []
args: tuple[str, ...]
for args in (tuple(), ('--primary',)):
+ col_clip = COL_YELLOW
+ brackets = '()'
proc = subprocess_run((CMD_PASTE,) + args,
check=False, capture_output=True)
if proc.returncode:
stderr = proc.stderr.decode('utf8', 'replace').strip()
- clip = '(empty)' if stderr == MSG_CLIPBOARD_EMPTY else '(error)'
+ if stderr == MSG_CLIPBOARD_EMPTY:
+ clip = 'empty'
+ col_clip = COL_GREY
+ else:
+ clip = 'error'
+ col_clip = COL_RED
else:
try:
clip = proc.stdout.decode('utf8').replace('\n', ' ').strip()
+ brackets = '[]'
except UnicodeDecodeError:
- clip = '(binary)'
- if len(clip) > CLIP_LEN_MAX:
- clip = clip[:CLIP_LEN_MAX] + '…'
- texts += [f'{(args[0] if args else CMD_PASTE)}: [{clip}]']
- return tuple(texts)
+ clip = 'binary'
+ if (ellipsed := len(clip) > CLIP_LEN_MAX):
+ clip = clip[:CLIP_LEN_MAX - 1]
+ blocks += [Block(f'{(args[0] if args else CMD_PASTE)} {brackets[0]}'),
+ Block(clip, col_clip)]
+ if ellipsed:
+ blocks += [Block('…', COL_GREEN)]
+ blocks += [Block(brackets[1], separator_block_width=5)]
+ return tuple(blocks)
-def info_audio(
- ) -> str:
+def blocks_audio(
+ ) -> tuple[Block, ...]:
"Produce audio devices' volume, muteness."
- texts = []
+ blocks = []
for dev_type, label in AUDIO_DEVS_LABELS:
dev_info = pactl_info_default_dev(dev_type)
+ is_mute = dev_info['mute']
percentage = first_among_equals(
- item['value_percent'] for item in dev_info['volume'].values())
- texts += [f'{label} {percentage}' + 'M' * int(dev_info['mute'])]
- return ' '.join(texts)
+ item['value_percent'] for item in dev_info['volume'].values()
+ )
+ assert percentage[-1] == '%'
+ percentage = percentage[:-1]
+ blocks += [
+ Block(f'{label} '),
+ Block(f'<s>{percentage}</s>' if is_mute else f'{percentage}',
+ COL_GREY if is_mute else COL_WHITE, 'pango',
+ separator_block_width=5)
+ ]
+ return tuple(blocks)
-def info_battery(
- ) -> str:
+def blocks_battery(
+ ) -> tuple[Block, ...]:
'Produce battery charge, status, health status bar text.'
path_battery = next(PATH_POWER_SUPPLY.glob(GLOB_BATTERY))
- status = BATTERY_STATUS_SYMBOLS.get(
+ status, status_color = BATTERY_STATUS_SYMBOLS.get(
(path_battery / 'status').read_text(encoding='utf8').strip(),
- '?')
+ ('?', COL_RED))
charge_pc, health_pc = -1, -1
for prefix in ('charge', 'energy'):
path_full = path_battery / f'{prefix}_full'
charge_pc = round(now / full * 100)
health_pc = round(full / full_design * 100)
break
- return f'{charge_pc}%{status}{health_pc}%'
+ return (Block.color_escalated(charge_pc, (85, 25)),
+ Block(status, status_color),
+ Block.color_escalated(health_pc, (85, 25)))
-def info_datetime(
- ) -> str:
+def blocks_datetime(
+ ) -> tuple[Block, ...]:
'Produce current date and time.'
- return datetime.now().astimezone().strftime('%Y-%m-%d %H:%M:%S %z/%Z')
+ now = datetime.now().astimezone()
+ return (Block(now.strftime('%Y-%m-%d ')),
+ Block(now.strftime('%H:%M:%S '), COL_WHITE),
+ Block(now.strftime('%z/%Z')))
-def info_layout(
- ) -> str:
+def blocks_layout(
+ ) -> tuple[Block, ...]:
'Produce two-character name of active keyboard layout.'
- return KB_LAYOUTS[first_among_equals(
+ layout = KB_LAYOUTS[first_among_equals(
dev['xkb_active_layout_index']
for dev in json_loads(run_stdout('swaymsg', '-t', 'get_inputs', '-r'))
if 'xkb_active_layout_index' in dev)]
+ return (Block('kb '),
+ Block(layout, COL_WHITE))
-def info_network(
- ) -> tuple[str, ...]:
- 'Produce one status block full_text per connected ethernet/wifi interface.'
+def blocks_network(
+ ) -> tuple[Block, ...]:
+ 'Produce statuses of connected ethernet/wifi interfaces.'
def nmcli_fields(
args: tuple[str, ...]
) -> tuple[tuple[str, ...], ...]:
def dev_info(
device: str,
dev_type: str
- ) -> str:
+ ) -> tuple[str, ...]:
def dev_ip(
) -> str:
for family in ('IP4', 'IP6'):
or not addr.lower().startswith('fe80:')):
return addr
return '?'
-
- info = f'{dev_ip()} {dev_type}'
+ info = [dev_ip()]
if dev_type == 'wifi':
for ssid, signal in (
row[1:] for row in nmcli_fields(
'--rescan', 'no'))
if len(row) == 3
and row[0] == 'yes'):
- info += f' {ssid} {signal}%'
- return info
- texts = tuple(dev_info(row[0], row[1])
- for row in nmcli_fields(('-f', 'DEVICE,TYPE,STATE',
- 'device', 'status'))
- if row[2:3] == ('connected',)
- and row[1] in ('ethernet', 'wifi'))
- return texts if texts else ('offline', )
+ info += [ssid, signal]
+ return tuple(info)
+ blocks = []
+ connections = tuple(tuple(row[:2])
+ for row in nmcli_fields(('-f', 'DEVICE,TYPE,STATE',
+ 'device', 'status'))
+ if row[2:3] == ('connected',)
+ and row[1] in ('ethernet', 'wifi'))
+ for dev, dev_type in connections:
+ info_toks = dev_info(dev, dev_type)
+ assert len(info_toks) in {1, 3}
+ blocks += [Block(f'{dev_type} ')]
+ if len(info_toks) == 3:
+ percentage = int(info_toks[2])
+ blocks += [Block.color_escalated(percentage, (80, 50)),
+ Block('% ')]
+ blocks += [Block('/'.join(info_toks[:2]), COL_WHITE,
+ separator_block_width=5)]
+ return tuple(blocks) if blocks else (Block('offline'), )
-def info_thermal(
- ) -> str:
+
+def blocks_thermal(
+ ) -> tuple[Block, ...]:
'Produce Celsius of current device hotness.'
- return str(round(max(int_at_path(path)
- for path in PATH_THERMAL.glob(GLOB_THERMAL)) / 1000)
- ) + '°C'
+ temperature = round(max(int_at_path(path)
+ for path in PATH_THERMAL.glob(GLOB_THERMAL)) / 1000)
+ return (Block.color_escalated(temperature, (55, 80)),
+ Block('°C'))
-if __name__ == '__main__':
+def print_bar(
+ ) -> None:
+ 'Produce stream of status bar updates.'
print(json_dumps(METADATA))
print('[', end='')
while True:
- print(json_dumps([{'full_text': text}
- for text in info_clipboard() + info_network() + (
- info_battery(),
- info_thermal(),
- info_datetime(),
- info_layout(),
- info_audio())]),
+ blocks: list[Block] = []
+ for blocks_tuple in (blocks_clipboard(),
+ blocks_network(),
+ blocks_battery(),
+ blocks_thermal(),
+ blocks_datetime(),
+ blocks_layout(),
+ blocks_audio()):
+ blocks += list(blocks_tuple[:-1])
+ blocks += [blocks_tuple[-1]._replace(separator=True,
+ separator_block_width=10)]
+ print(json_dumps([block._asdict() for block in blocks]),
end=',\n', flush=True)
sleep(1)
+
+
+if __name__ == '__main__':
+ print_bar()