From: Plom Heller Date: Mon, 21 Sep 2026 04:12:09 +0000 (+0200) Subject: Improve sway status bar formatting with colors and pango. X-Git-Url: https://plomlompom.com/repos/foo.html?a=commitdiff_plain;h=f7be641d92a65de20ca19b69b57b1c03c2abcde0;p=confplom Improve sway status bar formatting with colors and pango. --- diff --git a/home/user/.config/sway/status.py b/home/user/.config/sway/status.py index 70f4473..a89214e 100755 --- a/home/user/.config/sway/status.py +++ b/home/user/.config/sway/status.py @@ -7,13 +7,20 @@ from re import compile as re_compile 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' @@ -26,15 +33,39 @@ AUDIO_DEVS_LABELS = (('sink', 'vol'), 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: @@ -61,47 +92,68 @@ def first_among_equals[T]( 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'{percentage}' 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' @@ -112,27 +164,34 @@ def info_battery( 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, ...], ...]: @@ -144,7 +203,7 @@ def info_network( def dev_info( device: str, dev_type: str - ) -> str: + ) -> tuple[str, ...]: def dev_ip( ) -> str: for family in ('IP4', 'IP6'): @@ -160,8 +219,7 @@ def info_network( 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( @@ -170,34 +228,58 @@ def info_network( '--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()