build / build (push) Successful in 38m26s
- das-shell 0.1.0: the Das two-line bash prompt by default (replaces the PS1 of Void's skel .bashrc once), dprompt (Textual prompt builder with presets and live preview) and dbanner (SSH login banner editor: canvas tools, fields with values, widgets, das-banner service for cached ones, Das OS logo and tryzub pictures). Python tests in make test. - das-server 0.3.0 depends on das-shell; the server installer mentions dprompt and dbanner; the desktop image includes it. - run-qemu.sh forwards the guest's SSH to localhost:2222. - Hardware fixes: Ventoy menu icon, boot menu titles "Das OS <version>". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
427 lines
15 KiB
Python
427 lines
15 KiB
Python
"""Login banner model of dbanner: a grid of styled characters, fields and widgets.
|
|
|
|
A design is JSON:
|
|
{"name": "...", "title": {"uk": ..., "en": ...}, "local": false,
|
|
"vars": {"url": "https://..."},
|
|
"styles": {"a": {"fg": "blue", "bg": null, "bold": false}, ...},
|
|
"lines": ["####", ...], "style_map": ["aaaa", ...],
|
|
"fields": [{"x": 4, "y": 1, "width": 20, "align": "left", "text": "{ip}", "style": "b"}],
|
|
"widgets_position": "below", "widgets": [{"type": "builtin", "name": "disks"}, ...]}
|
|
|
|
style_map has one style letter per character of lines; a space is the
|
|
terminal's own style. This module only needs the standard library: the
|
|
banner is printed at every SSH login.
|
|
"""
|
|
|
|
import copy
|
|
import json
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import unicodedata
|
|
|
|
from . import DATA_DIR, ETC_DIR, colors, config_dir
|
|
from .i18n import UK, title
|
|
|
|
STYLE_KEYS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
|
ALIGNS = ("left", "center", "right")
|
|
SYSTEM_FILE = os.path.join(ETC_DIR, "banner.json")
|
|
OFF_FILE = os.path.join(ETC_DIR, "banner.off")
|
|
|
|
|
|
def char_width(ch):
|
|
return 2 if unicodedata.east_asian_width(ch) in ("W", "F") else 1
|
|
|
|
|
|
class Grid:
|
|
"""Characters with a style key each; the canvas of the editor."""
|
|
|
|
def __init__(self, width=40, height=8, styles=None):
|
|
self.width, self.height = width, height
|
|
self.cells = [[[" ", " "] for _ in range(width)] for _ in range(height)]
|
|
self.styles = styles if styles is not None else {}
|
|
|
|
# --- conversion -------------------------------------------------------
|
|
@classmethod
|
|
def from_design(cls, design):
|
|
lines = design.get("lines") or []
|
|
smap = design.get("style_map") or []
|
|
width = max([len(line) for line in lines] + [1])
|
|
grid = cls(width, max(len(lines), 1), copy.deepcopy(design.get("styles") or {}))
|
|
for y, line in enumerate(lines):
|
|
keys = smap[y] if y < len(smap) else ""
|
|
for x, ch in enumerate(line):
|
|
key = keys[x] if x < len(keys) else " "
|
|
grid.cells[y][x] = [ch, key if key in grid.styles else " "]
|
|
return grid
|
|
|
|
def to_design(self, design):
|
|
"""Stores the grid into a copy of the design, dropping unused styles."""
|
|
d = copy.deepcopy(design)
|
|
lines, smap = [], []
|
|
for row in self.cells:
|
|
line = "".join(c[0] for c in row)
|
|
keys = "".join(c[1] for c in row)
|
|
# Trailing blank cells carry nothing
|
|
n = len(line)
|
|
while n and line[n - 1] == " " and keys[n - 1] == " ":
|
|
n -= 1
|
|
lines.append(line[:n])
|
|
smap.append(keys[:n])
|
|
while lines and not lines[-1]:
|
|
lines.pop()
|
|
smap.pop()
|
|
used = {k for keys in smap for k in keys} | {f.get("style") for f in d.get("fields", [])}
|
|
d["styles"] = {k: v for k, v in self.styles.items() if k in used}
|
|
d["lines"], d["style_map"] = lines, smap
|
|
d["canvas"] = {"width": self.width, "height": self.height}
|
|
return d
|
|
|
|
def copy(self):
|
|
g = Grid(self.width, self.height, copy.deepcopy(self.styles))
|
|
g.cells = [[list(c) for c in row] for row in self.cells]
|
|
return g
|
|
|
|
# --- styles -------------------------------------------------------------
|
|
def style_key(self, fg=None, bg=None, bold=False):
|
|
"""The key of a style, added when it is new."""
|
|
if not fg and not bg and not bold:
|
|
return " "
|
|
want = {"fg": fg, "bg": bg, "bold": bool(bold)}
|
|
for key, st in self.styles.items():
|
|
if {"fg": st.get("fg"), "bg": st.get("bg"), "bold": bool(st.get("bold"))} == want:
|
|
return key
|
|
for key in STYLE_KEYS:
|
|
if key not in self.styles:
|
|
self.styles[key] = want
|
|
return key
|
|
raise ValueError("too many styles")
|
|
|
|
def style(self, key):
|
|
st = self.styles.get(key) or {}
|
|
return st.get("fg"), st.get("bg"), bool(st.get("bold"))
|
|
|
|
# --- drawing --------------------------------------------------------------
|
|
def inside(self, x, y):
|
|
return 0 <= x < self.width and 0 <= y < self.height
|
|
|
|
def get(self, x, y):
|
|
return self.cells[y][x] if self.inside(x, y) else [" ", " "]
|
|
|
|
def put(self, x, y, ch, key):
|
|
if self.inside(x, y):
|
|
self.cells[y][x] = [ch, key]
|
|
|
|
def resize(self, width, height):
|
|
width, height = max(1, width), max(1, height)
|
|
cells = [[[" ", " "] for _ in range(width)] for _ in range(height)]
|
|
for y in range(min(height, self.height)):
|
|
for x in range(min(width, self.width)):
|
|
cells[y][x] = self.cells[y][x]
|
|
self.width, self.height, self.cells = width, height, cells
|
|
|
|
def text(self, x, y, text, key):
|
|
for i, ch in enumerate(text):
|
|
self.put(x + i, y, ch, key)
|
|
|
|
def line(self, x0, y0, x1, y1, ch, key):
|
|
"""A straight line; horizontal and vertical ones use ch as given."""
|
|
dx, dy = abs(x1 - x0), -abs(y1 - y0)
|
|
sx, sy = (1 if x0 < x1 else -1), (1 if y0 < y1 else -1)
|
|
err = dx + dy
|
|
while True:
|
|
self.put(x0, y0, ch, key)
|
|
if x0 == x1 and y0 == y1:
|
|
return
|
|
e2 = 2 * err
|
|
if e2 >= dy:
|
|
err += dy
|
|
x0 += sx
|
|
if e2 <= dx:
|
|
err += dx
|
|
y0 += sy
|
|
|
|
def box(self, x0, y0, x1, y1, kind, key):
|
|
x0, x1 = sorted((x0, x1))
|
|
y0, y1 = sorted((y0, y1))
|
|
tl, tr, bl, br, h, v = BOXES[kind]
|
|
if x0 == x1 or y0 == y1:
|
|
self.line(x0, y0, x1, y1, v if x0 == x1 else h, key)
|
|
return
|
|
for x in range(x0 + 1, x1):
|
|
self.put(x, y0, h, key)
|
|
self.put(x, y1, h, key)
|
|
for y in range(y0 + 1, y1):
|
|
self.put(x0, y, v, key)
|
|
self.put(x1, y, v, key)
|
|
self.put(x0, y0, tl, key)
|
|
self.put(x1, y0, tr, key)
|
|
self.put(x0, y1, bl, key)
|
|
self.put(x1, y1, br, key)
|
|
|
|
def fill(self, x0, y0, x1, y1, ch, key):
|
|
x0, x1 = sorted((x0, x1))
|
|
y0, y1 = sorted((y0, y1))
|
|
for y in range(y0, y1 + 1):
|
|
for x in range(x0, x1 + 1):
|
|
self.put(x, y, ch, key)
|
|
|
|
def region(self, x0, y0, x1, y1):
|
|
"""A copy of a rectangle as a new grid with the same styles."""
|
|
x0, x1 = sorted((x0, x1))
|
|
y0, y1 = sorted((y0, y1))
|
|
g = Grid(x1 - x0 + 1, y1 - y0 + 1, copy.deepcopy(self.styles))
|
|
for y in range(g.height):
|
|
for x in range(g.width):
|
|
g.cells[y][x] = list(self.get(x0 + x, y0 + y))
|
|
return g
|
|
|
|
def stamp(self, x, y, other, transparent=True):
|
|
"""Draws another grid here; its styles are merged into ours."""
|
|
keymap = {" ": " "}
|
|
for key in {c[1] for row in other.cells for c in row}:
|
|
if key != " ":
|
|
fg, bg, bold = other.style(key)
|
|
keymap[key] = self.style_key(fg, bg, bold)
|
|
for dy, row in enumerate(other.cells):
|
|
for dx, (ch, key) in enumerate(row):
|
|
if transparent and ch == " " and key == " ":
|
|
continue
|
|
self.put(x + dx, y + dy, ch, keymap.get(key, " "))
|
|
|
|
|
|
BOXES = {
|
|
"single": "┌┐└┘─│",
|
|
"double": "╔╗╚╝═║",
|
|
"rounded": "╭╮╰╯─│",
|
|
"heavy": "┏┓┗┛━┃",
|
|
"ascii": "++++-|",
|
|
"hash": "######",
|
|
"block": "██████",
|
|
}
|
|
|
|
|
|
# --- designs ------------------------------------------------------------------
|
|
|
|
def normalize(design):
|
|
d = copy.deepcopy(design)
|
|
d.setdefault("name", "custom")
|
|
d.setdefault("local", False)
|
|
d.setdefault("vars", {})
|
|
d.setdefault("styles", {})
|
|
d.setdefault("lines", [])
|
|
d.setdefault("style_map", [])
|
|
d.setdefault("fields", [])
|
|
d.setdefault("widgets", [])
|
|
if d.get("widgets_position") not in ("below", "right"):
|
|
d["widgets_position"] = "below"
|
|
for f in d["fields"]:
|
|
f.setdefault("align", "left")
|
|
f.setdefault("style", " ")
|
|
f.setdefault("width", len(f.get("text", "")))
|
|
return d
|
|
|
|
|
|
def preset_dir():
|
|
return os.path.join(DATA_DIR, "banner", "presets")
|
|
|
|
|
|
def art_dir():
|
|
return os.path.join(DATA_DIR, "banner", "art")
|
|
|
|
|
|
def drafts_dir():
|
|
return os.path.join(config_dir(), "banner")
|
|
|
|
|
|
def _json_names(directory):
|
|
try:
|
|
return sorted(f[:-5] for f in os.listdir(directory) if f.endswith(".json"))
|
|
except OSError:
|
|
return []
|
|
|
|
|
|
def list_presets():
|
|
out = []
|
|
for name in sorted(_json_names(preset_dir()), key=lambda n: (n != "das-classic", n)):
|
|
try:
|
|
out.append((name, title(load_file(os.path.join(preset_dir(), name + ".json")).get("title"), name)))
|
|
except (OSError, ValueError):
|
|
continue
|
|
return out
|
|
|
|
|
|
def list_drafts():
|
|
return [(n, os.path.join(drafts_dir(), n + ".json")) for n in _json_names(drafts_dir())]
|
|
|
|
|
|
def list_art():
|
|
"""(name, title, design) of the ready-made pictures and the user's own."""
|
|
out = []
|
|
for directory in (art_dir(), os.path.join(config_dir(), "banner-art")):
|
|
for name in _json_names(directory):
|
|
try:
|
|
d = load_file(os.path.join(directory, name + ".json"))
|
|
except (OSError, ValueError):
|
|
continue
|
|
out.append((name, title(d.get("title"), name), d))
|
|
return out
|
|
|
|
|
|
def load_file(path):
|
|
with open(path, encoding="utf-8") as f:
|
|
return normalize(json.load(f))
|
|
|
|
|
|
def load(spec):
|
|
"""A preset name, a draft name or a path to a design file."""
|
|
if os.sep in spec or spec.endswith(".json"):
|
|
return load_file(spec)
|
|
for directory in (preset_dir(), drafts_dir()):
|
|
path = os.path.join(directory, spec + ".json")
|
|
if os.path.exists(path):
|
|
return load_file(path)
|
|
raise FileNotFoundError(spec)
|
|
|
|
|
|
def system_design():
|
|
"""The banner shown at login: /etc/das/banner.json or the das-classic preset."""
|
|
try:
|
|
return load_file(SYSTEM_FILE)
|
|
except (OSError, ValueError):
|
|
return load_file(os.path.join(preset_dir(), "das-classic.json"))
|
|
|
|
|
|
def dumps(design):
|
|
return json.dumps(design, ensure_ascii=False, indent=2) + "\n"
|
|
|
|
|
|
def save(design, path):
|
|
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
|
tmp = path + ".tmp"
|
|
with open(tmp, "w", encoding="utf-8") as f:
|
|
f.write(dumps(design))
|
|
os.replace(tmp, path)
|
|
|
|
|
|
# --- rendering ------------------------------------------------------------------
|
|
|
|
_VAR = re.compile(r"\{([A-Za-z_][A-Za-z0-9_]*)\}")
|
|
_LANG = re.compile(r"\[\[([^|\]]*)\|([^\]]*)\]\]")
|
|
FIGLET_FONTS = ("standard", "small", "big", "slant", "smslant", "mini", "banner", "block",
|
|
"shadow", "smshadow", "lean", "script", "digital")
|
|
|
|
|
|
def expand(text, values):
|
|
"""[[Ukrainian|English]] in the user's language, {name} replaced by its
|
|
value; unknown names stay as they are."""
|
|
text = _LANG.sub(lambda m: m.group(1) if UK else m.group(2), text)
|
|
|
|
def repl(m):
|
|
value = values.get(m.group(1))
|
|
return m.group(0) if value is None else str(value)
|
|
return _VAR.sub(repl, text)
|
|
|
|
|
|
def figlet(text, font):
|
|
"""Big letters by figlet, or the text itself when figlet is missing."""
|
|
try:
|
|
out = subprocess.run(["figlet", "-f", font, "-w", "1000", text], capture_output=True,
|
|
text=True, timeout=2).stdout
|
|
except (OSError, subprocess.SubprocessError):
|
|
out = ""
|
|
lines = out.rstrip("\n").split("\n") if out.strip() else [text]
|
|
while lines and not lines[-1].strip():
|
|
lines.pop()
|
|
return lines
|
|
|
|
|
|
def fit(text, width, align):
|
|
if len(text) > width:
|
|
return text[:max(width - 1, 0)] + "…" if width else ""
|
|
pad = width - len(text)
|
|
if align == "right":
|
|
return " " * pad + text
|
|
if align == "center":
|
|
return " " * (pad // 2) + text + " " * (pad - pad // 2)
|
|
return text + " " * pad
|
|
|
|
|
|
def field_lines(f, values):
|
|
"""The lines a field draws: one, or several for big letters."""
|
|
# An empty value leaves no dangling separator: "{host} · {url}" -> "host"
|
|
text = expand(f.get("text", ""), values).strip(" ·|")
|
|
width, align = int(f.get("width", 0)), f.get("align", "left")
|
|
if f.get("font"):
|
|
big = figlet(text, f["font"])
|
|
if max(len(line) for line in big) <= width:
|
|
# Align the block as a whole, so the letters stay together
|
|
block = max(len(line) for line in big)
|
|
return [fit(fit(line, block, "left"), width, align) for line in big]
|
|
return [fit(text, width, align)]
|
|
return [fit(text, width, align)]
|
|
|
|
|
|
def draw_fields(grid, design, values):
|
|
for f in design.get("fields", []):
|
|
key = f.get("style", " ")
|
|
x, y = int(f.get("x", 0)), int(f.get("y", 0))
|
|
for i, line in enumerate(field_lines(f, values)):
|
|
# Spaces of a field do not cover the picture under it
|
|
for j, ch in enumerate(line):
|
|
if ch != " " or not f.get("transparent", True):
|
|
grid.put(x + j, y + i, ch, key if key in grid.styles else " ")
|
|
|
|
|
|
def grid_lines(grid, color=True):
|
|
"""The grid as terminal lines, trailing blanks removed."""
|
|
out = []
|
|
for row in grid.cells:
|
|
n = len(row)
|
|
while n and row[n - 1][0] == " " and (row[n - 1][1] == " " or not grid.style(row[n - 1][1])[1]):
|
|
n -= 1
|
|
line, current = [], " "
|
|
for ch, key in row[:n]:
|
|
if color and key != current:
|
|
line.append(colors.sgr(*grid.style(key)) if key != " " else colors.RESET)
|
|
current = key
|
|
line.append(ch)
|
|
if color and current != " ":
|
|
line.append(colors.RESET)
|
|
out.append("".join(line))
|
|
while out and not out[-1].strip():
|
|
out.pop()
|
|
return out
|
|
|
|
|
|
def visible_len(text):
|
|
return sum(char_width(c) for c in re.sub(r"\x1b\[[0-9;?]*[A-Za-z]", "", text))
|
|
|
|
|
|
def render(design, values, widget_blocks, color=True):
|
|
"""The whole banner as text: the picture with fields, then the widgets."""
|
|
design = normalize(design)
|
|
grid = Grid.from_design(design)
|
|
if design.get("canvas"):
|
|
grid.resize(max(grid.width, design["canvas"].get("width", 0)), grid.height)
|
|
draw_fields(grid, design, values)
|
|
art = grid_lines(grid, color)
|
|
blocks = [b for b in widget_blocks if b]
|
|
if not color:
|
|
blocks = [[re.sub(r"\x1b\[[0-9;?]*[A-Za-z]", "", line) for line in b] for b in blocks]
|
|
widget_lines = [line for b in blocks for line in b]
|
|
if design["widgets_position"] == "right" and art:
|
|
width = max(visible_len(line) for line in art) + 3
|
|
out = []
|
|
for i in range(max(len(art), len(widget_lines))):
|
|
left = art[i] if i < len(art) else ""
|
|
right = widget_lines[i] if i < len(widget_lines) else ""
|
|
out.append((left + " " * (width - visible_len(left)) + right) if right else left)
|
|
return "\n".join(out) + "\n"
|
|
out = list(art)
|
|
if widget_lines:
|
|
if out:
|
|
out.append("")
|
|
out += widget_lines
|
|
return "\n".join(out) + "\n"
|