home · contact · privacy
Improve sway status bar formatting with colors and pango.
authorPlom Heller <plom@plomlompom.com>
Mon, 21 Sep 2026 04:12:09 +0000 (06:12 +0200)
committerPlom Heller <plom@plomlompom.com>
Mon, 21 Sep 2026 04:12:09 +0000 (06:12 +0200)
home/user/.config/sway/status.py

index 70f44739fe8484cc86eb796373f0b482b0964687..a89214ed1d88b6ab742df9eeb16ff29fd37ff4b1 100755 (executable)
@@ -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'<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'
@@ -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()