Files
proxmox-snapshot-service/pvesnap/curses_util.py
T
duffyduckandClaude Opus 5 56003a71ab Meldungen am unteren Rand waren kaum lesbar
Sie standen schwarz auf gruen - und zwar mit A_BOLD. Genau das war die Falle:
Fettschrift schaltet im Terminal die Farben 0-7 auf 8-15, aus Schwarz (0) wird
also Hellschwarz (8), sprich Grau. Grau auf Gruen ist Matsch.

    vorher   \e[0;1m \e[30m \e[42m    fett + schwarz auf gruen
    nachher  \e[0;1m \e[37m \e[44m    fett + weiss auf blau

Dabei zeigte sich, dass in derselben Farbe zwei verschiedene Dinge steckten:
die Meldungsleiste ueber die ganze Zeile und Statustexte wie "Zustand:
running". Beide sind jetzt getrennt:

    C_MSG   weiss auf blau     nur die Meldungsleiste, bleibt fett lesbar
    C_OK    gruen auf Vorgabe  Statustexte als Schriftfarbe statt als Block

Alle uebrigen A_BOLD-Stellen haben helle Schrift (weiss, gelb) - die wird
durch Fett heller statt dunkler und war nie betroffen.

Die Aenderung sitzt in curses_util.py und gilt damit fuer alle Oberflaechen:
Konfigurationseditor, Explorer, Wiederherstellung und Transfer-Laufwerke.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 10:17:43 +02:00

234 lines
7.5 KiB
Python

"""Gemeinsame curses-Bausteine fuer Konfigurationseditor und Explorer."""
from __future__ import annotations
import curses
C_HEADER, C_FOOTER, C_SEL, C_WARN, C_OK, C_DIM, C_DIR, C_MARK, C_MSG = range(1, 10)
def init_colors():
try:
curses.start_color()
curses.use_default_colors()
curses.init_pair(C_HEADER, curses.COLOR_WHITE, curses.COLOR_BLUE)
curses.init_pair(C_FOOTER, curses.COLOR_BLACK, curses.COLOR_WHITE)
curses.init_pair(C_SEL, curses.COLOR_BLACK, curses.COLOR_CYAN)
curses.init_pair(C_WARN, curses.COLOR_WHITE, curses.COLOR_RED)
# Gruen als Schriftfarbe, nicht als Hintergrund. Schwarz auf Gruen sah
# im Terminal matschig aus - und mit A_BOLD wird aus Schwarz (Farbe 0)
# Hellschwarz (Farbe 8), also Grau. Grau auf Gruen kann man nicht lesen.
curses.init_pair(C_OK, curses.COLOR_GREEN, -1)
curses.init_pair(C_DIM, -1, -1)
curses.init_pair(C_DIR, curses.COLOR_CYAN, -1)
curses.init_pair(C_MARK, curses.COLOR_YELLOW, -1)
# Meldungsleiste: Weiss auf Blau bleibt auch fett gut lesbar, weil die
# Schrift dabei heller wird statt dunkler.
curses.init_pair(C_MSG, curses.COLOR_WHITE, curses.COLOR_BLUE)
except curses.error:
pass
# ---------------------------------------------------------------------------
# Zeichnen
# ---------------------------------------------------------------------------
def put(win, y, x, text, attr=0):
"""addstr, das bei zu kleinem Terminal nicht abstuerzt."""
height, width = win.getmaxyx()
if y < 0 or y >= height or x >= width:
return
try:
win.addstr(y, x, str(text)[: max(0, width - x - 1)], attr)
except curses.error:
pass
def fill(win, y, text, attr):
height, width = win.getmaxyx()
if y < 0 or y >= height:
return
try:
win.addstr(y, 0, str(text).ljust(width - 1)[: width - 1], attr)
except curses.error:
pass
def fill_row(win, y, x, text, width, attr):
put(win, y, x, str(text).ljust(max(0, width - x - 1))[: max(0, width - x - 1)], attr)
# ---------------------------------------------------------------------------
# Tasten
# ---------------------------------------------------------------------------
def read_key(win):
try:
return win.get_wch()
except curses.error:
return None
except KeyboardInterrupt:
return "\x1b"
def is_enter(key):
return key in ("\n", "\r", curses.KEY_ENTER)
def is_escape(key):
return key == "\x1b"
def is_backspace(key):
return key in ("\x7f", "\b", "\x08", curses.KEY_BACKSPACE)
def keybar(width, items):
"""Tastenleiste als Liste von Zeilen, passend zur Breite des Terminals.
Reicht der Platz nicht fuer eine Zeile, wird auf zwei Zeilen umgebrochen;
bei ganz schmalen Terminals bleiben nur noch die Tasten uebrig.
"""
def join(entries, index):
return " | ".join("%s %s" % (entry[0], entry[index]) for entry in entries)
long_text = join(items, 1)
if len(long_text) + 2 <= width:
return [long_text]
half = (len(items) + 1) // 2
first, second = join(items[:half], 1), join(items[half:], 1)
if max(len(first), len(second)) + 2 <= width:
return [first, second]
short_text = join(items, 2)
if len(short_text) + 2 <= width:
return [short_text]
first, second = join(items[:half], 2), join(items[half:], 2)
if max(len(first), len(second)) + 2 <= width:
return [first, second]
return [" ".join(entry[0] for entry in items)]
# ---------------------------------------------------------------------------
# Dialoge
# ---------------------------------------------------------------------------
def message(win, text, error=False):
height, _ = win.getmaxyx()
attr = curses.color_pair(C_WARN if error else C_MSG) | curses.A_BOLD
fill(win, height - 2, " " + str(text) + " [Taste druecken]", attr)
win.refresh()
read_key(win)
def confirm(win, question):
height, _ = win.getmaxyx()
fill(win, height - 2, " %s [j/n]" % question,
curses.color_pair(C_WARN) | curses.A_BOLD)
win.refresh()
while True:
key = read_key(win)
if key in ("j", "J", "y", "Y"):
return True
if key in ("n", "N") or is_escape(key):
return False
def ask(win, question, answers, default=None):
"""Mehrfachauswahl in einer Zeile. `answers` ist z.B. 'jnak'."""
height, _ = win.getmaxyx()
fill(win, height - 2, " %s" % question, curses.color_pair(C_WARN) | curses.A_BOLD)
win.refresh()
while True:
key = read_key(win)
if isinstance(key, str) and key.lower() in answers:
return key.lower()
if is_escape(key):
return default
def prompt(win, label, initial=""):
"""Einzeilige Eingabe am unteren Rand. None = abgebrochen."""
height, width = win.getmaxyx()
buffer = list(str(initial))
curses.curs_set(1)
try:
while True:
text = "".join(buffer)
label_text = " %s: " % label
visible = width - len(label_text) - 2
shown = text[-visible:] if visible > 0 and len(text) > visible else text
fill(win, height - 2, label_text + shown, curses.color_pair(C_SEL))
try:
win.move(height - 2, min(width - 2, len(label_text) + len(shown)))
except curses.error:
pass
win.refresh()
key = read_key(win)
if key is None:
continue
if is_enter(key):
return "".join(buffer).strip()
if is_escape(key):
return None
if is_backspace(key):
if buffer:
buffer.pop()
continue
if key == "\x15": # Strg-U
buffer = []
continue
if isinstance(key, str) and key.isprintable():
buffer.append(key)
finally:
curses.curs_set(0)
def choose(win, title, entries, current=0):
"""Kleines Auswahlfenster. entries: Liste von (wert, beschriftung)."""
if not entries:
return None
height, width = win.getmaxyx()
box_h = min(len(entries) + 4, height - 2)
box_w = min(max([len(title)] + [len(e[1]) for e in entries]) + 8, width - 2)
top = max(0, (height - box_h) // 2)
left = max(0, (width - box_w) // 2)
box = curses.newwin(box_h, box_w, top, left)
box.keypad(True)
index = max(0, min(current, len(entries) - 1))
scroll = 0
rows = max(1, box_h - 4)
while True:
if index < scroll:
scroll = index
if index >= scroll + rows:
scroll = index - rows + 1
box.erase()
box.box()
put(box, 0, 2, " %s " % title, curses.A_BOLD)
for row in range(rows):
position = scroll + row
if position >= len(entries):
break
attr = curses.color_pair(C_SEL) if position == index else 0
put(box, row + 2, 2, " " + entries[position][1].ljust(box_w - 5), attr)
box.refresh()
key = read_key(box)
if key in (curses.KEY_UP, "k"):
index = (index - 1) % len(entries)
elif key in (curses.KEY_DOWN, "j"):
index = (index + 1) % len(entries)
elif key == curses.KEY_NPAGE:
index = min(len(entries) - 1, index + rows)
elif key == curses.KEY_PPAGE:
index = max(0, index - rows)
elif is_enter(key):
return entries[index][0]
elif is_escape(key) or key == "q":
return None