"""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))