- 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>
This commit is contained in:
@@ -0,0 +1,426 @@
|
||||
"""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"
|
||||
Reference in New Issue
Block a user