build / build (push) Canceled after 0s
- /etc/issue shows the Das OS version instead of the kernel's; the server installer carries it into the installed system. - das-server-installer: setfont -C /dev/tty0 before the language menu (sudo runs it in its own pty, so the Cyrillic was drawn as Latin), and floppy drives are no longer offered as install targets. - ncdu on the desktop image and in das-server 0.4.0. - das-shell 0.2.0: each prompt line sets its own color; the dbanner start screen buttons are no longer covered by the key bar; on the Linux console (TERM=linux) dprompt and dbanner draw solid borders and swap the glyphs that no console font has (▊▎▔▁ and others) for ones it has. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
431 lines
16 KiB
Python
431 lines
16 KiB
Python
"""Bash prompt model of dprompt: segments in a frame, turned into PS1 code or a preview.
|
||
|
||
A design is JSON:
|
||
{"name": "das", "frame": "das", "frame_fg": "red", "open": "[", "close": "]",
|
||
"sep": "─", "segments": [{"type": "status"}, {"type": "userhost"}, ...]}
|
||
|
||
Static segments become PS1 escapes (\\u, \\w, \\D{...}). Conditional ones (exit
|
||
status, git branch, venv, jobs, ssh, command output) are computed by a function
|
||
that the PROMPT_COMMAND hook of /usr/lib/das/prompt-lib.sh calls before each
|
||
prompt; PS1 only reads their variables, so branch names are never evaluated as code.
|
||
"""
|
||
|
||
import copy
|
||
import datetime
|
||
import json
|
||
import os
|
||
|
||
from . import DATA_DIR, colors
|
||
from .i18n import t, title
|
||
|
||
FRAMES = ("das", "plain")
|
||
|
||
# type: (Ukrainian name, English name, default options)
|
||
SEGMENTS = {
|
||
"status": ("Код помилки", "Exit status", {"fg": "white", "mode": "mark", "mark": "✗"}),
|
||
"userhost": ("Користувач@хост", "User@host",
|
||
{"fg": "bright_white", "at_fg": "yellow", "at_bold": True,
|
||
"host_fg": "cyan", "host_bold": True, "root_fg": "red", "root_bold": True}),
|
||
"user": ("Користувач", "User", {"fg": "bright_white", "root_fg": "red", "root_bold": True}),
|
||
"host": ("Хост", "Host", {"fg": "cyan", "bold": True, "full": False}),
|
||
"cwd": ("Каталог", "Directory", {"fg": "bright_green", "mode": "full", "depth": 3}),
|
||
"date": ("Дата", "Date", {"fg": "white", "format": "%Y-%m-%d"}),
|
||
"time": ("Час", "Time", {"fg": "white", "seconds": False}),
|
||
"git": ("Гілка git", "Git branch", {"fg": "bright_magenta"}),
|
||
"venv": ("Python venv", "Python venv", {"fg": "bright_blue"}),
|
||
"jobs": ("Фонові задачі", "Background jobs", {"fg": "yellow", "prefix": "jobs:"}),
|
||
"ssh": ("Ознака SSH", "SSH mark", {"fg": "bright_red", "bold": True, "text": "ssh"}),
|
||
"text": ("Текст", "Text", {"fg": None, "text": ""}),
|
||
"cmd": ("Вивід команди", "Command output", {"fg": "white", "cmd": ""}),
|
||
"newline": ("Новий рядок", "New line", {}),
|
||
"symbol": ("Символ $/#", "Symbol $/#", {"fg": "yellow", "char": ""}),
|
||
}
|
||
|
||
# Glyphs missing from the Linux console font
|
||
CONSOLE_GLYPHS = {"╼": ">", "✗": "x", "❯": ">", "➜": ">", "λ": ">", "⚙": "*", "●": "*"}
|
||
|
||
VOID_PS1 = "[\\u@\\h \\W]\\$ "
|
||
|
||
|
||
def segment_name(seg_type):
|
||
uk, en, _ = SEGMENTS[seg_type]
|
||
return t(uk, en)
|
||
|
||
|
||
def new_segment(seg_type):
|
||
seg = {"type": seg_type}
|
||
seg.update(copy.deepcopy(SEGMENTS[seg_type][2]))
|
||
return seg
|
||
|
||
|
||
def normalize(design):
|
||
"""Fills in the defaults, so older or hand-written files keep working."""
|
||
d = copy.deepcopy(design)
|
||
d.setdefault("name", "custom")
|
||
d.setdefault("frame", "das")
|
||
if d["frame"] not in FRAMES:
|
||
d["frame"] = "das"
|
||
d.setdefault("frame_fg", "red")
|
||
d.setdefault("open", "[" if d["frame"] == "das" else "")
|
||
d.setdefault("close", "]" if d["frame"] == "das" else "")
|
||
d.setdefault("sep", "─" if d["frame"] == "das" else " ")
|
||
segs = []
|
||
for seg in d.get("segments", []):
|
||
if seg.get("type") not in SEGMENTS:
|
||
continue
|
||
full = new_segment(seg["type"])
|
||
full.update(seg)
|
||
segs.append(full)
|
||
d["segments"] = segs
|
||
return d
|
||
|
||
|
||
# --- presets and files ------------------------------------------------------
|
||
|
||
def preset_dir():
|
||
return os.path.join(DATA_DIR, "prompt", "presets")
|
||
|
||
|
||
def list_presets():
|
||
"""(name, title) of the shipped presets, "das" first."""
|
||
out = []
|
||
try:
|
||
names = sorted(f[:-5] for f in os.listdir(preset_dir()) if f.endswith(".json"))
|
||
except OSError:
|
||
names = []
|
||
for name in sorted(names, key=lambda n: (n != "das", n)):
|
||
try:
|
||
out.append((name, title(load_preset(name).get("title"), name)))
|
||
except (OSError, ValueError):
|
||
continue
|
||
return out
|
||
|
||
|
||
def load_preset(name):
|
||
return load_file(os.path.join(preset_dir(), name + ".json"))
|
||
|
||
|
||
def load_file(path):
|
||
with open(path, encoding="utf-8") as f:
|
||
return normalize(json.load(f))
|
||
|
||
|
||
def load(spec):
|
||
"""A preset name or a path to a design file."""
|
||
if os.sep in spec or spec.endswith(".json"):
|
||
return load_file(spec)
|
||
return load_preset(spec)
|
||
|
||
|
||
def dumps(design):
|
||
return json.dumps(design, ensure_ascii=False, indent=2) + "\n"
|
||
|
||
|
||
# --- pieces -------------------------------------------------------------------
|
||
# Lit: literal text; Esc: a PS1 escape such as \u; Var: a variable the hook sets;
|
||
# Dyn: pieces shown only when a condition holds. Style is (fg, bold) or None.
|
||
|
||
class Lit:
|
||
def __init__(self, text, style=None):
|
||
self.text, self.style = text, style
|
||
|
||
|
||
class Esc:
|
||
def __init__(self, code, style=None):
|
||
self.code, self.style = code, style
|
||
|
||
|
||
class Var:
|
||
def __init__(self, name, style=None):
|
||
self.name, self.style = name, style
|
||
|
||
|
||
class Dyn:
|
||
def __init__(self, cond, pieces, extra=None):
|
||
self.cond, self.pieces, self.extra = cond, pieces, extra or {}
|
||
|
||
|
||
def _st(seg, prefix=""):
|
||
return (seg.get(prefix + "fg"), bool(seg.get(prefix + "bold")))
|
||
|
||
|
||
def _content(seg, index, root):
|
||
"""(pieces, condition or None, bracketed?) for one segment."""
|
||
kind = seg["type"]
|
||
style = _st(seg)
|
||
if kind == "status":
|
||
inner = [Var("rc", style)] if seg.get("mode") == "code" else [Lit(seg.get("mark") or "✗", style)]
|
||
return inner, "error", True
|
||
if kind == "userhost":
|
||
user = _st(seg, "root_") if root else style
|
||
return [Esc("\\u", user), Lit("@", _st(seg, "at_")), Esc("\\h", _st(seg, "host_"))], None, True
|
||
if kind == "user":
|
||
return [Esc("\\u", _st(seg, "root_") if root else style)], None, True
|
||
if kind == "host":
|
||
return [Esc("\\H" if seg.get("full") else "\\h", style)], None, True
|
||
if kind == "cwd":
|
||
return [Esc("\\W" if seg.get("mode") == "base" else "\\w", style)], None, True
|
||
if kind == "date":
|
||
return [Esc("\\D{%s}" % (seg.get("format") or "%Y-%m-%d"), style)], None, True
|
||
if kind == "time":
|
||
return [Esc("\\t" if seg.get("seconds") else "\\A", style)], None, True
|
||
if kind == "git":
|
||
return [Var("git", style)], "git", True
|
||
if kind == "venv":
|
||
return [Var("venv", style)], "venv", True
|
||
if kind == "jobs":
|
||
return [Lit(seg.get("prefix") or "", style), Var("jobs", style)], "jobs", True
|
||
if kind == "ssh":
|
||
return [Lit(seg.get("text") or "ssh", style)], "ssh", True
|
||
if kind == "text":
|
||
return [Lit(seg.get("text") or "", style)], None, bool(seg.get("bracket", False))
|
||
if kind == "cmd":
|
||
return [Var(f"cmd{index}", style)], f"cmd{index}", True
|
||
if kind == "symbol":
|
||
char = seg.get("char") or ""
|
||
return [Lit(char, style) if char else Esc("\\$", style)], None, False
|
||
raise ValueError(kind)
|
||
|
||
|
||
def pieces(design, root=False):
|
||
"""The prompt as a flat list of pieces."""
|
||
d = normalize(design)
|
||
frame = (d["frame_fg"], False)
|
||
lines = [[]]
|
||
for i, seg in enumerate(d["segments"]):
|
||
if seg["type"] == "newline":
|
||
lines.append([])
|
||
else:
|
||
lines[-1].append((i, seg))
|
||
out = []
|
||
for n, items in enumerate(lines):
|
||
if n:
|
||
out.append(Lit("\n"))
|
||
if d["frame"] == "das" and len(lines) > 1:
|
||
prefix = "┌─" if n == 0 else ("└─╼ " if n == len(lines) - 1 else "├─")
|
||
out.append(Lit(prefix, frame))
|
||
first_static = next((k for k, (i, s) in enumerate(items)
|
||
if _content(s, i, root)[1] is None), len(items))
|
||
for k, (i, seg) in enumerate(items):
|
||
content, cond, bracket = _content(seg, i, root)
|
||
is_symbol = seg["type"] == "symbol"
|
||
group = []
|
||
if bracket and d["open"]:
|
||
group.append(Lit(d["open"], frame))
|
||
group += content
|
||
if bracket and d["close"]:
|
||
group.append(Lit(d["close"], frame))
|
||
sep = Lit(" " if is_symbol else d["sep"], frame) if d["sep"] else None
|
||
if cond is not None:
|
||
# A hidden segment must take its separator with it
|
||
if k < first_static:
|
||
group = group + ([sep] if sep and k + 1 < len(items) else [])
|
||
elif sep:
|
||
group = [sep] + group
|
||
out.append(Dyn(cond, group, {"cmd": seg.get("cmd", "")}))
|
||
else:
|
||
if k > first_static and sep:
|
||
out.append(sep)
|
||
out += group
|
||
out.append(Lit(" "))
|
||
return _merge(out)
|
||
|
||
|
||
def _merge(items):
|
||
"""Joins neighbouring literals of the same style."""
|
||
out = []
|
||
for p in items:
|
||
if isinstance(p, Lit) and out and isinstance(out[-1], Lit) and out[-1].style == p.style:
|
||
out[-1] = Lit(out[-1].text + p.text, p.style)
|
||
else:
|
||
out.append(p)
|
||
return out
|
||
|
||
|
||
# --- bash code ----------------------------------------------------------------
|
||
|
||
def _ps1_text(text):
|
||
return text.replace("\\", "\\\\").replace("$", "\\044").replace("`", "\\140")
|
||
|
||
|
||
def _sq(text):
|
||
return "'" + text.replace("'", "'\\''") + "'"
|
||
|
||
|
||
def _ansi_q(text):
|
||
return text.replace("\\", "\\\\").replace("'", "\\'")
|
||
|
||
|
||
class _Bash:
|
||
def __init__(self, color, console):
|
||
self.color, self.console = color, console
|
||
|
||
def glyphs(self, text):
|
||
if self.console:
|
||
for a, b in CONSOLE_GLYPHS.items():
|
||
text = text.replace(a, b)
|
||
return text
|
||
|
||
def ps1(self, items):
|
||
out, current = [], None
|
||
for p in items:
|
||
style = getattr(p, "style", None)
|
||
if self.color and not isinstance(p, Dyn) and style is not None and style != current:
|
||
out.append("\\[\\e[%sm\\]" % colors.sgr_params(style[0], None, style[1]))
|
||
current = style
|
||
if isinstance(p, Lit):
|
||
out.append(_ps1_text(self.glyphs(p.text)))
|
||
if p.text.endswith("\n"):
|
||
# Readline redraws only the last line of the prompt, so
|
||
# each line has to set its color again
|
||
current = None
|
||
elif isinstance(p, Esc):
|
||
out.append(p.code)
|
||
elif isinstance(p, Dyn):
|
||
out.append("${__das_p_%s}" % p.cond)
|
||
current = None # the dynamic part leaves its own color behind
|
||
if self.color:
|
||
out.append("\\[\\e[0m\\]")
|
||
return "".join(out)
|
||
|
||
def value(self, items):
|
||
"""A bash word with the dynamic part: $'...' literals and "$var"s."""
|
||
out, current = [], None
|
||
for p in items:
|
||
style = getattr(p, "style", None)
|
||
if self.color and style is not None and style != current:
|
||
out.append("$'\\001\\e[%sm\\002'" % colors.sgr_params(style[0], None, style[1]))
|
||
current = style
|
||
if isinstance(p, Lit):
|
||
out.append("$'%s'" % _ansi_q(self.glyphs(p.text)))
|
||
elif isinstance(p, Var):
|
||
out.append('"$__das_%s"' % p.name)
|
||
return "".join(out) or "''"
|
||
|
||
def dynamic(self, items):
|
||
lines = []
|
||
for p in items:
|
||
if not isinstance(p, Dyn):
|
||
continue
|
||
var, val = "__das_p_" + p.cond, self.value(p.pieces)
|
||
test = {
|
||
"error": "[[ $__das_rc != 0 ]]",
|
||
"git": "__das_git_info",
|
||
"venv": "[[ -n ${VIRTUAL_ENV:-} ]] && __das_venv=${VIRTUAL_ENV##*/}",
|
||
"jobs": "__das_jobs_count",
|
||
"ssh": "[[ -n ${SSH_CONNECTION:-} ]]",
|
||
}.get(p.cond)
|
||
if test is None: # command output
|
||
lines.append(f" __das_{p.cond}=$({{ {p.extra['cmd']}; }} 2>/dev/null)")
|
||
test = f"[[ -n $__das_{p.cond} ]]"
|
||
lines.append(f" {var}=")
|
||
lines.append(f" {test} && {var}={val}")
|
||
return lines
|
||
|
||
|
||
def script(design):
|
||
"""The prompt.sh that /etc/bash/bashrc.d/das-prompt.sh sources."""
|
||
d = normalize(design)
|
||
out = [
|
||
f"# Das OS bash prompt \"{d['name']}\", made by dprompt: run dprompt to change it.",
|
||
"# shellcheck shell=bash disable=SC2016",
|
||
]
|
||
cwd = next((s for s in d["segments"] if s["type"] == "cwd"), None)
|
||
if cwd and cwd.get("mode") == "short":
|
||
out.append(f"PROMPT_DIRTRIM={int(cwd.get('depth') or 3)}")
|
||
else:
|
||
out.append("unset PROMPT_DIRTRIM")
|
||
if any(s["type"] == "venv" for s in d["segments"]):
|
||
out.append("VIRTUAL_ENV_DISABLE_PROMPT=1")
|
||
variants = [("[[ $TERM == linux ]]", True, True),
|
||
("__das_has_color", True, False),
|
||
(None, False, False)]
|
||
for n, (test, color, console) in enumerate(variants):
|
||
b = _Bash(color, console)
|
||
user, root = pieces(d, False), pieces(d, True)
|
||
head = "if" if n == 0 else ("elif" if test else "else")
|
||
out.append(f"{head} {test}; then" if test else "else")
|
||
body = b.dynamic(user)
|
||
out.append(" __das_prompt_dynamic() {")
|
||
out += [" " + line for line in body] or [" :"]
|
||
out.append(" }")
|
||
out.append(" if ((EUID == 0)); then")
|
||
out.append(" PS1=" + _sq(b.ps1(root)))
|
||
out.append(" else")
|
||
out.append(" PS1=" + _sq(b.ps1(user)))
|
||
out.append(" fi")
|
||
out.append("fi")
|
||
out.append("case $TERM in xterm* | rxvt* | alacritty | foot*) PS1='\\[\\e]0;\\u@\\h: \\w\\a\\]'$PS1 ;; esac")
|
||
out.append("__das_prompt_install")
|
||
return "\n".join(out) + "\n"
|
||
|
||
|
||
# --- preview ----------------------------------------------------------------------
|
||
|
||
def sample_state(**over):
|
||
now = datetime.datetime.now()
|
||
state = {"user": "das", "host": "dasos", "fqdn": "dasos.lan", "home": "/home/das",
|
||
"cwd": "/home/das/Projects/web", "rc": 0, "git": "", "venv": "", "jobs": 0,
|
||
"ssh": False, "root": False, "cmd": "…", "now": now}
|
||
state.update(over)
|
||
if state["root"]:
|
||
state["user"] = "root"
|
||
return state
|
||
|
||
|
||
def _esc_value(code, s):
|
||
if code == "\\u":
|
||
return s["user"]
|
||
if code == "\\h":
|
||
return s["host"]
|
||
if code == "\\H":
|
||
return s["fqdn"]
|
||
if code in ("\\w", "\\W"):
|
||
home = "/root" if s["root"] else s["home"]
|
||
cwd = s["cwd"] if not s["root"] else "/etc/sv"
|
||
short = "~" + cwd[len(home):] if cwd.startswith(home) else cwd
|
||
return short if code == "\\w" else (os.path.basename(cwd) or "/")
|
||
if code.startswith("\\D{"):
|
||
return s["now"].strftime(code[3:-1])
|
||
if code == "\\A":
|
||
return s["now"].strftime("%H:%M")
|
||
if code == "\\t":
|
||
return s["now"].strftime("%H:%M:%S")
|
||
if code == "\\$":
|
||
return "#" if s["root"] else "$"
|
||
return code
|
||
|
||
|
||
def _cond_holds(cond, s):
|
||
return {"error": s["rc"] != 0, "git": bool(s["git"]), "venv": bool(s["venv"]),
|
||
"jobs": s["jobs"] > 0, "ssh": s["ssh"]}.get(cond, bool(s["cmd"]))
|
||
|
||
|
||
def _var_value(name, s):
|
||
return {"rc": str(s["rc"]), "git": s["git"], "venv": s["venv"],
|
||
"jobs": str(s["jobs"])}.get(name, s["cmd"])
|
||
|
||
|
||
def preview(design, state):
|
||
"""[(text, rich style or "")] of the prompt as the user would see it."""
|
||
out = []
|
||
|
||
def walk(items):
|
||
for p in items:
|
||
style = colors.rich_style(p.style[0], None, p.style[1]) if getattr(p, "style", None) else ""
|
||
if isinstance(p, Lit):
|
||
out.append((p.text, style))
|
||
elif isinstance(p, Esc):
|
||
out.append((_esc_value(p.code, state), style))
|
||
elif isinstance(p, Var):
|
||
out.append((_var_value(p.name, state), style))
|
||
elif isinstance(p, Dyn) and _cond_holds(p.cond, state):
|
||
walk(p.pieces)
|
||
|
||
walk(pieces(design, state["root"]))
|
||
return out
|
||
|
||
|
||
def preview_text(design, state):
|
||
return "".join(text for text, _ in preview(design, state))
|