Add PL/EN i18n with runtime toggle (L key) and --lang flag

All UI strings translated: section titles, popups, status messages,
help screen, hints. Switch language live with L or start with --lang pl.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
lukasz@orzechowski.eu
2026-03-24 00:39:28 +01:00
parent e0d3a48240
commit a40332b569

View File

@@ -26,6 +26,132 @@ AMD_GPU_PATH = "/sys/class/drm"
REFRESH_INTERVAL = 1.0
# ─── i18n ───────────────────────────────────────────────────────────
LANGS = ["en", "pl"]
current_lang = "en"
TRANSLATIONS = {
# Header
"up": {"en": "Up", "pl": "Czas"},
"load": {"en": "Load", "pl": "Obc."},
"procs": {"en": "Procs", "pl": "Proc"},
"run": {"en": "run", "pl": "dzia."},
# Sections
"memory": {"en": "MEMORY", "pl": "PAMIEC"},
"network": {"en": "NETWORK", "pl": "SIEC"},
"disk": {"en": "DISK", "pl": "DYSK"},
"gpu": {"en": "GPU", "pl": "GPU"},
"temps": {"en": "TEMPS", "pl": "TEMP"},
"processes": {"en": "PROCESSES", "pl": "PROCESY"},
# Disk
"read": {"en": "Read", "pl": "Odczyt"},
"write": {"en": "Write", "pl": "Zapis"},
# Network
"total": {"en": "Total", "pl": "Suma"},
# Process table
"filter": {"en": "filter", "pl": "filtr"},
"name": {"en": "NAME", "pl": "NAZWA"},
# Process hints
"hint_sort": {"en": "sort", "pl": "sort."},
"hint_reverse": {"en": "reverse", "pl": "odwroc"},
"hint_filter": {"en": "filter", "pl": "filtr"},
"hint_signals": {"en": "signals", "pl": "sygnaly"},
"hint_details": {"en": "details", "pl": "szczeg."},
"hint_nice": {"en": "nice", "pl": "priorytet"},
"hint_quit": {"en": "quit", "pl": "wyjscie"},
# Kill menu
"send_signal_to": {"en": "Send signal to", "pl": "Wyslij sygnal do"},
"send": {"en": "send", "pl": "wyslij"},
"cancel": {"en": "cancel", "pl": "anuluj"},
"apply": {"en": "apply", "pl": "zastosuj"},
# Details
"proc_details": {"en": "Process Details", "pl": "Szczegoly procesu"},
"created": {"en": "Created", "pl": "Utworzony"},
"connections": {"en": "Connections", "pl": "Polaczenia"},
"close": {"en": "close", "pl": "zamknij"},
# Renice
"nice_value": {"en": "Nice value (-20..19)", "pl": "Priorytet (-20..19)"},
# Filter bar
"filter_label": {"en": "Filter", "pl": "Filtr"},
# Status messages
"no_proc_selected": {"en": "No process selected", "pl": "Nie wybrano procesu"},
"sent_to": {"en": "Sent {sig} to {name} (PID {pid})", "pl": "Wyslano {sig} do {name} (PID {pid})"},
"proc_not_found": {"en": "Process {pid} not found", "pl": "Proces {pid} nie znaleziony"},
"perm_denied": {"en": "Permission denied: {name} (PID {pid})", "pl": "Brak uprawnien: {name} (PID {pid})"},
"set_nice": {"en": "Set nice {val} for {name} (PID {pid})", "pl": "Ustawiono priorytet {val} dla {name} (PID {pid})"},
"invalid_nice": {"en": "Invalid nice value", "pl": "Nieprawidlowy priorytet"},
# Help
"help_title": {"en": "SYSTATS - Keyboard Shortcuts", "pl": "SYSTATS - Skroty klawiszowe"},
"help_close": {"en": "Press h, F1 or ? to close", "pl": "Nacisnij h, F1 lub ? aby zamknac"},
"help_nav": {"en": "NAVIGATION", "pl": "NAWIGACJA"},
"help_down": {"en": "Move cursor down", "pl": "Kursor w dol"},
"help_up": {"en": "Move cursor up", "pl": "Kursor w gore"},
"help_pgdn": {"en": "Scroll down 20", "pl": "Przewin 20 w dol"},
"help_pgup": {"en": "Scroll up 20", "pl": "Przewin 20 w gore"},
"help_home": {"en": "Go to top", "pl": "Na poczatek"},
"help_end": {"en": "Go to bottom", "pl": "Na koniec"},
"help_sorting": {"en": "SORTING", "pl": "SORTOWANIE"},
"help_sort_cpu": {"en": "Sort by CPU%", "pl": "Sortuj wg CPU%"},
"help_sort_mem": {"en": "Sort by MEM%", "pl": "Sortuj wg MEM%"},
"help_sort_pid": {"en": "Sort by PID", "pl": "Sortuj wg PID"},
"help_sort_name": {"en": "Sort by name", "pl": "Sortuj wg nazwy"},
"help_reverse": {"en": "Reverse sort order", "pl": "Odwroc sortowanie"},
"help_proc": {"en": "PROCESS MGMT", "pl": "ZARZ. PROCESAMI"},
"help_sigterm": {"en": "Send SIGTERM to process", "pl": "Wyslij SIGTERM"},
"help_sigkill": {"en": "Send SIGKILL to process", "pl": "Wyslij SIGKILL"},
"help_sigmenu": {"en": "Signal menu (choose signal)", "pl": "Menu sygnalow"},
"help_nice_pos": {"en": "Renice process", "pl": "Zmien priorytet"},
"help_nice_neg": {"en": "Renice process (negative)", "pl": "Zmien priorytet (ujemny)"},
"help_details": {"en": "Process details", "pl": "Szczegoly procesu"},
"help_filter": {"en": "Filter processes", "pl": "Filtruj procesy"},
"help_general": {"en": "GENERAL", "pl": "OGOLNE"},
"help_help": {"en": "Toggle this help", "pl": "Pokaz/ukryj pomoc"},
"help_lang": {"en": "Switch language (EN/PL)", "pl": "Zmien jezyk (EN/PL)"},
"help_quit": {"en": "Quit", "pl": "Wyjscie"},
}
def t(key, **kwargs):
"""Get translated string."""
s = TRANSLATIONS.get(key, {}).get(current_lang, key)
if kwargs:
s = s.format(**kwargs)
return s
def get_help_lines():
return [
(t("help_nav"), ""),
("j / Down", t("help_down")),
("k / Up", t("help_up")),
("PgDn", t("help_pgdn")),
("PgUp", t("help_pgup")),
("Home", t("help_home")),
("End", t("help_end")),
("", ""),
(t("help_sorting"), ""),
("c", t("help_sort_cpu")),
("m", t("help_sort_mem")),
("p", t("help_sort_pid")),
("n", t("help_sort_name")),
("r", t("help_reverse")),
("", ""),
(t("help_proc"), ""),
("T", t("help_sigterm")),
("K", t("help_sigkill")),
("F9", t("help_sigmenu")),
("+ / F8", t("help_nice_pos")),
("- / F7", t("help_nice_neg")),
("d / F5 / Enter", t("help_details")),
("/ / f", t("help_filter")),
("", ""),
(t("help_general"), ""),
("h / F1 / ?", t("help_help")),
("L", t("help_lang")),
("q / Q / Esc", t("help_quit")),
]
# History length for sparklines
HIST_LEN = 60
@@ -437,37 +563,6 @@ C_POPUP_BG = 12
C_POPUP_HL = 13
HELP_LINES = [
("NAVIGATION", ""),
("j / Down", "Move cursor down"),
("k / Up", "Move cursor up"),
("PgDn", "Scroll down 20"),
("PgUp", "Scroll up 20"),
("Home", "Go to top"),
("End", "Go to bottom"),
("", ""),
("SORTING", ""),
("c", "Sort by CPU%"),
("m", "Sort by MEM%"),
("p", "Sort by PID"),
("n", "Sort by name"),
("r", "Reverse sort order"),
("", ""),
("PROCESS MGMT", ""),
("T", "Send SIGTERM to process"),
("K", "Send SIGKILL to process"),
("F9", "Signal menu (choose signal)"),
("+ / F8", "Renice process"),
("- / F7", "Renice process (negative)"),
("d / F5 / Enter", "Process details"),
("/ / f", "Filter processes"),
("", ""),
("GENERAL", ""),
("h / F1 / ?", "Toggle this help"),
("q / Q / Esc", "Quit"),
]
class ProcessManager:
"""Interactive process management: kill, nice, filter, details."""
@@ -515,36 +610,36 @@ class ProcessManager:
def kill_selected(self, processes, sig):
proc = self.get_selected_proc(processes)
if not proc:
self._set_status("No process selected")
self._set_status(t("no_proc_selected"))
return
pid = proc.get("pid")
name = proc.get("name", "?")
try:
os.kill(pid, sig)
sig_name = signal.Signals(sig).name
self._set_status(f"Sent {sig_name} to {name} (PID {pid})")
self._set_status(t("sent_to", sig=sig_name, name=name, pid=pid))
except ProcessLookupError:
self._set_status(f"Process {pid} not found")
self._set_status(t("proc_not_found", pid=pid))
except PermissionError:
self._set_status(f"Permission denied: {name} (PID {pid})")
self._set_status(t("perm_denied", name=name, pid=pid))
except Exception as e:
self._set_status(f"Error: {e}")
def renice_selected(self, processes, nice_val):
proc = self.get_selected_proc(processes)
if not proc:
self._set_status("No process selected")
self._set_status(t("no_proc_selected"))
return
pid = proc.get("pid")
name = proc.get("name", "?")
try:
p = psutil.Process(pid)
p.nice(nice_val)
self._set_status(f"Set nice {nice_val} for {name} (PID {pid})")
self._set_status(t("set_nice", val=nice_val, name=name, pid=pid))
except psutil.NoSuchProcess:
self._set_status(f"Process {pid} not found")
self._set_status(t("proc_not_found", pid=pid))
except psutil.AccessDenied:
self._set_status(f"Permission denied: {name} (PID {pid})")
self._set_status(t("perm_denied", name=name, pid=pid))
except Exception as e:
self._set_status(f"Error: {e}")
@@ -665,9 +760,9 @@ class Renderer:
d = self.data
hostname = os.uname().nodename
kernel = os.uname().release
header = f" SYSTATS | {hostname} | Linux {kernel} | Up: {d.uptime} "
header += f"| Load: {d.load_avg[0]:.2f} {d.load_avg[1]:.2f} {d.load_avg[2]:.2f} "
header += f"| Procs: {d.proc_count} ({d.proc_running} run) "
header = f" SYSTATS | {hostname} | Linux {kernel} | {t('up')}: {d.uptime} "
header += f"| {t('load')}: {d.load_avg[0]:.2f} {d.load_avg[1]:.2f} {d.load_avg[2]:.2f} "
header += f"| {t('procs')}: {d.proc_count} ({d.proc_running} {t('run')}) "
brand = " Electric Entropy Lab "
header = header.ljust(w - len(brand))[:w - len(brand)]
safe_addstr(self.scr, y, 0, header, curses.color_pair(C_HEADER) | curses.A_BOLD)
@@ -715,7 +810,7 @@ class Renderer:
def _draw_memory(self, y, x, w):
d = self.data
box_h = 5
draw_box(self.scr, y, x, box_h, w, "MEMORY")
draw_box(self.scr, y, x, box_h, w, t("memory"))
bar_w = w - 4
iy = y + 1
@@ -742,7 +837,7 @@ class Renderer:
def _draw_network(self, y, x, w):
d = self.data
box_h = 6
draw_box(self.scr, y, x, box_h, w, "NETWORK")
draw_box(self.scr, y, x, box_h, w, t("network"))
iy = y + 1
bar_w = w - 4
@@ -764,7 +859,7 @@ class Renderer:
# Net total
total = d.prev_net
safe_addstr(self.scr, iy, x + 2,
f"Total RX: {fmt_bytes_short(total.bytes_recv)} TX: {fmt_bytes_short(total.bytes_sent)}",
f"{t('total')} RX: {fmt_bytes_short(total.bytes_recv)} TX: {fmt_bytes_short(total.bytes_sent)}",
curses.color_pair(C_DIM))
iy += 1
@@ -790,12 +885,12 @@ class Renderer:
d = self.data
num_parts = min(len(d.partitions), 4)
box_h = 3 + num_parts * 2
draw_box(self.scr, y, x, box_h, w, "DISK")
draw_box(self.scr, y, x, box_h, w, t("disk"))
iy = y + 1
bar_w = w - 4
# IO rates
io_str = f"Read: {fmt_bytes_short(d.disk_read_rate)}/s Write: {fmt_bytes_short(d.disk_write_rate)}/s"
io_str = f"{t('read')}: {fmt_bytes_short(d.disk_read_rate)}/s {t('write')}: {fmt_bytes_short(d.disk_write_rate)}/s"
safe_addstr(self.scr, iy, x + 2, io_str, curses.color_pair(C_ACCENT))
iy += 1
@@ -814,7 +909,7 @@ class Renderer:
def _draw_gpu(self, y, x, w):
d = self.data
box_h = 2 + len(d.gpus) * 4
draw_box(self.scr, y, x, box_h, w, "GPU")
draw_box(self.scr, y, x, box_h, w, t("gpu"))
iy = y + 1
bar_w = w - 4
@@ -858,7 +953,7 @@ class Renderer:
return y
box_h = min(2 + len(entries), 8)
draw_box(self.scr, y, x, box_h, w, "TEMPS")
draw_box(self.scr, y, x, box_h, w, t("temps"))
iy = y + 1
cols = max(1, w // 30)
@@ -893,13 +988,13 @@ class Renderer:
processes = pm.filtered_processes(d.processes)
# Title with keybindings
filter_info = f" filter: '{pm.filter_text}'" if pm.filter_text else ""
title = f"PROCESSES [{d.process_sort}] F9:kill F7/F8:nice F5:details /:filter{filter_info}"
filter_info = f" {t('filter')}: '{pm.filter_text}'" if pm.filter_text else ""
title = f"{t('processes')} [{d.process_sort}] F9:kill F7/F8:{t('hint_nice')} F5:{t('hint_details')} /:{t('hint_filter')}{filter_info}"
draw_box(self.scr, y, x, box_h, w, title)
iy = y + 1
# Header
hdr = f"{'PID':>7s} {'USER':<10s} {'CPU%':>6s} {'MEM%':>6s} {'THR':>4s} {'RES':>8s} {'S':1s} {'NAME'}"
hdr = f"{'PID':>7s} {'USER':<10s} {'CPU%':>6s} {'MEM%':>6s} {'THR':>4s} {'RES':>8s} {'S':1s} {t('name')}"
safe_addstr(self.scr, iy, x + 1, hdr[:w - 2], curses.color_pair(C_HEADER))
iy += 1
@@ -946,7 +1041,7 @@ class Renderer:
safe_addstr(self.scr, status_y, x + 2, status[:w - 4],
curses.color_pair(C_CRIT) | curses.A_BOLD)
else:
hint = "q:quit c/m/p/n:sort r:reverse /:filter K:KILL T:TERM F9:signals d:details +/-:nice"
hint = f"q:{t('hint_quit')} c/m/p/n:{t('hint_sort')} r:{t('hint_reverse')} /:{t('hint_filter')} K:KILL T:TERM F9:{t('hint_signals')} d:{t('hint_details')} +/-:{t('hint_nice')}"
safe_addstr(self.scr, status_y, x + 2, hint[:w - 4], curses.color_pair(C_DIM))
# Scrollbar indicator
@@ -982,7 +1077,7 @@ class Renderer:
safe_addstr(self.scr, my + row, mx, " " * menu_w, curses.color_pair(C_POPUP_BG))
# Title
title = f" Send signal to {proc_name}({proc_pid}) "
title = f" {t('send_signal_to')} {proc_name}({proc_pid}) "
safe_addstr(self.scr, my, mx, title[:menu_w].center(menu_w),
curses.color_pair(C_POPUP_HL) | curses.A_BOLD)
safe_addstr(self.scr, my + 1, mx, "\u2500" * menu_w, curses.color_pair(C_POPUP_BG))
@@ -996,7 +1091,8 @@ class Renderer:
safe_addstr(self.scr, row_y, mx + 1, f" {name} ".ljust(menu_w - 2),
curses.color_pair(C_POPUP_BG))
safe_addstr(self.scr, my + menu_h - 1, mx, " Enter:send Esc:cancel ".center(menu_w),
safe_addstr(self.scr, my + menu_h - 1, mx,
f" Enter:{t('send')} Esc:{t('cancel')} ".center(menu_w),
curses.color_pair(C_POPUP_BG) | curses.A_DIM)
def _draw_details_popup(self, y, x, w, avail_h):
@@ -1015,7 +1111,7 @@ class Renderer:
for row in range(popup_h):
safe_addstr(self.scr, my + row, mx, " " * popup_w, curses.color_pair(C_POPUP_BG))
title = f" Process Details: {details['name']} (PID {details['pid']}) "
title = f" {t('proc_details')}: {details['name']} (PID {details['pid']}) "
safe_addstr(self.scr, my, mx, title[:popup_w].center(popup_w),
curses.color_pair(C_POPUP_HL) | curses.A_BOLD)
safe_addstr(self.scr, my + 1, mx, "\u2500" * popup_w, curses.color_pair(C_POPUP_BG))
@@ -1027,8 +1123,8 @@ class Renderer:
f" CPU: {details['cpu_percent']:.1f}% MEM: {details['memory_percent']:.1f}%",
f" RSS: {details['rss']} VMS: {details['vms']}",
f" IO R: {details['io_read']} IO W: {details['io_write']}",
f" FDs: {details['num_fds']} Connections: {details['connections']}",
f" Created: {details['create_time']}",
f" FDs: {details['num_fds']} {t('connections')}: {details['connections']}",
f" {t('created')}: {details['create_time']}",
f" Exe: {details.get('exe', '?')}",
f" CWD: {details.get('cwd', '?')}",
f" Cmd: {details.get('cmdline', '?')}",
@@ -1038,7 +1134,8 @@ class Renderer:
safe_addstr(self.scr, my + 2 + i, mx, line[:popup_w].ljust(popup_w),
curses.color_pair(C_POPUP_BG))
safe_addstr(self.scr, my + popup_h - 1, mx, " Press Esc or d to close ".center(popup_w),
safe_addstr(self.scr, my + popup_h - 1, mx,
f" Esc/d: {t('close')} ".center(popup_w),
curses.color_pair(C_POPUP_BG) | curses.A_DIM)
def _draw_nice_dialog(self, y, x, w, avail_h):
@@ -1059,15 +1156,16 @@ class Renderer:
curses.color_pair(C_POPUP_HL) | curses.A_BOLD)
safe_addstr(self.scr, my + 1, mx, "\u2500" * popup_w, curses.color_pair(C_POPUP_BG))
safe_addstr(self.scr, my + 2, mx + 2,
f"Nice value (-20..19): {pm.nice_value}_".ljust(popup_w - 4),
f"{t('nice_value')}: {pm.nice_value}_".ljust(popup_w - 4),
curses.color_pair(C_POPUP_BG))
safe_addstr(self.scr, my + 4, mx,
" Enter:apply Esc:cancel ".center(popup_w),
f" Enter:{t('apply')} Esc:{t('cancel')} ".center(popup_w),
curses.color_pair(C_POPUP_BG) | curses.A_DIM)
def _draw_help(self, h, w):
help_lines = get_help_lines()
popup_w = min(w - 4, 52)
popup_h = min(h - 2, len(HELP_LINES) + 4)
popup_h = min(h - 2, len(help_lines) + 4)
mx = (w - popup_w) // 2
my = (h - popup_h) // 2
@@ -1076,12 +1174,12 @@ class Renderer:
safe_addstr(self.scr, my + row, mx, " " * popup_w, curses.color_pair(C_POPUP_BG))
# Title
title = " SYSTATS - Keyboard Shortcuts "
title = f" {t('help_title')} "
safe_addstr(self.scr, my, mx, title.center(popup_w),
curses.color_pair(C_POPUP_HL) | curses.A_BOLD)
safe_addstr(self.scr, my + 1, mx, "\u2500" * popup_w, curses.color_pair(C_POPUP_BG))
for i, (key, desc) in enumerate(HELP_LINES):
for i, (key, desc) in enumerate(help_lines):
row_y = my + 2 + i
if row_y >= my + popup_h - 1:
break
@@ -1097,12 +1195,12 @@ class Renderer:
curses.color_pair(C_POPUP_BG))
safe_addstr(self.scr, my + popup_h - 1, mx,
" Press h, F1 or ? to close ".center(popup_w),
f" {t('help_close')} ".center(popup_w),
curses.color_pair(C_POPUP_BG) | curses.A_DIM)
def _draw_filter_bar(self, y, x, w):
pm = self.proc_mgr
bar = f" Filter: {pm.filter_text}_ "
bar = f" {t('filter_label')}: {pm.filter_text}_ "
safe_addstr(self.scr, y, x + 1, bar.ljust(w - 2)[:w - 2],
curses.color_pair(C_POPUP_HL) | curses.A_BOLD)
@@ -1165,7 +1263,7 @@ def main(stdscr):
val = max(-20, min(19, val))
pm.renice_selected(processes, val)
except ValueError:
pm._set_status("Invalid nice value")
pm._set_status(t("invalid_nice"))
pm.show_nice_dialog = False
pm.nice_value = ""
elif key in (curses.KEY_BACKSPACE, 127, 8):
@@ -1201,6 +1299,10 @@ def main(stdscr):
break
elif key in (ord("h"), ord("?"), curses.KEY_F1):
renderer.show_help = True
elif key == ord("L"):
global current_lang
idx = LANGS.index(current_lang)
current_lang = LANGS[(idx + 1) % len(LANGS)]
elif key == ord("c"):
data.process_sort = "cpu"
data.process_sort_reverse = True
@@ -1260,6 +1362,12 @@ def main(stdscr):
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="systats - Terminal system monitor | Electric Entropy Lab")
parser.add_argument("--lang", choices=LANGS, default="en",
help="UI language: en (English) or pl (Polski)")
args = parser.parse_args()
current_lang = args.lang
try:
curses.wrapper(main)
except KeyboardInterrupt: