"""dprompt editor: segments on the left, their settings on the right, a live preview below.""" import asyncio import copy import os import tempfile from rich.text import Text from textual.app import App from textual.containers import Horizontal, Vertical, VerticalScroll from textual.widgets import Button, Checkbox, Footer, Header, Input, Label, ListItem, ListView, Select, Static from . import prompt from .cli_prompt import apply as apply_design, current from .i18n import t from .tui_common import NONE, AskDialog, color_options, color_value, run_as_root, to_select # Settings of each segment type: (key, kind, Ukrainian label, English label) COLOR_KEYS = [("fg", "color", "Колір", "Color"), ("bold", "bool", "Жирний", "Bold")] OPTIONS = { "status": COLOR_KEYS + [("mode", "choice:mark=знак ✗|mark=mark ✗;code=код виходу|code=exit code", "Показ", "Shows"), ("mark", "text", "Знак", "Mark")], "userhost": [("fg", "color", "Колір користувача", "User color"), ("bold", "bool", "Жирний", "Bold"), ("at_fg", "color", "Колір @", "@ color"), ("at_bold", "bool", "@ жирне", "Bold @"), ("host_fg", "color", "Колір хоста", "Host color"), ("host_bold", "bool", "Хост жирний", "Bold host"), ("root_fg", "color", "Колір для root", "Color for root"), ("root_bold", "bool", "root жирний", "Bold root")], "user": COLOR_KEYS + [("root_fg", "color", "Колір для root", "Color for root"), ("root_bold", "bool", "root жирний", "Bold root")], "host": COLOR_KEYS + [("full", "bool", "Повне ім'я (\\H)", "Full name (\\H)")], "cwd": COLOR_KEYS + [("mode", "choice:full=повний шлях|full=full path;base=лише остання тека|base=last folder only;" "short=останні N тек|short=last N folders", "Показ", "Shows"), ("depth", "text", "N для «останні N тек»", "N for \"last N folders\"")], "date": COLOR_KEYS + [("format", "text", "Формат (strftime)", "Format (strftime)")], "time": COLOR_KEYS + [("seconds", "bool", "Із секундами", "With seconds")], "git": COLOR_KEYS, "venv": COLOR_KEYS, "jobs": COLOR_KEYS + [("prefix", "text", "Підпис перед числом", "Label before the number")], "ssh": COLOR_KEYS + [("text", "text", "Текст", "Text")], "text": COLOR_KEYS + [("text", "text", "Текст", "Text"), ("bracket", "bool", "У дужках", "In brackets")], "cmd": COLOR_KEYS + [("cmd", "text", "Команда (виконується перед кожним запрошенням)", "Command (runs before every prompt)")], "newline": [], "symbol": COLOR_KEYS + [("char", "text", "Символ (порожньо — $ або # для root)", "Symbol (empty: $, or # for root)")], } STATES = [ (("Звичайне", "Normal"), {}), (("Після помилки, git, venv, задачі, SSH", "After an error, git, venv, jobs, SSH"), {"rc": 1, "git": "main*", "venv": ".venv", "jobs": 1, "ssh": True, "cmd": "42"}), (("root", "root"), {"root": True}), ] def _choices(kind): out = [] for item in kind[7:].split(";"): uk, en = item.split("|") value, uk_label = uk.split("=") out.append((t(uk_label, en.split("=")[1]), value)) return out class PromptApp(App): TITLE = "dprompt" SUB_TITLE = t("конструктор запрошення bash", "bash prompt builder") CSS = """ #main { height: 1fr; } #left { width: 40; border-right: solid $primary; padding: 0 1; } #segments { height: 1fr; } #props { padding: 0 2; } #props Label { margin-top: 1; color: $text-muted; } #preview { height: auto; max-height: 14; border-top: solid $primary; padding: 0 1; } .buttons { height: auto; } .buttons Button { min-width: 5; margin-right: 1; } .h { text-style: bold; color: $accent; margin-top: 1; } """ BINDINGS = [ ("ctrl+s", "apply_me", t("Застосувати для себе", "Apply for me")), ("ctrl+g", "apply_all", t("Для всіх (sudo)", "For everyone (sudo)")), ("ctrl+e", "export", t("Зберегти файл", "Save file")), ("ctrl+q", "quit", t("Вийти", "Quit")), ] def __init__(self, design=None): # The terminal's own 16 colors, as the prompt will use them super().__init__(ansi_color=True) self.design, self.source = (design, None) if design else current() self.design = prompt.normalize(self.design) self.props_lock = asyncio.Lock() # --- layout ------------------------------------------------------------ def compose(self): yield Header() with Horizontal(id="main"): with Vertical(id="left"): yield Label(Text(t("Готові запрошення", "Ready-made prompts")), classes="h") yield Select([(title, name) for name, title in prompt.list_presets()], prompt=t("вибрати…", "choose…"), id="preset") yield Label(Text(t("Сегменти (по порядку)", "Segments (in order)")), classes="h") yield ListView(id="segments") yield Select([(prompt.segment_name(k), k) for k in prompt.SEGMENTS], prompt=t("+ додати сегмент", "+ add a segment"), id="add") with Horizontal(classes="buttons"): yield Button("↑", id="up") yield Button("↓", id="down") yield Button(t("Видалити", "Remove"), id="remove", variant="error") yield VerticalScroll(id="props") yield Static(id="preview") yield Footer() def on_mount(self): self.refresh_segments(0) # --- segments list ------------------------------------------------------- def refresh_segments(self, index=None): lv = self.query_one("#segments", ListView) lv.clear() lv.append(ListItem(Label(Text("⚙ " + t("Рамка й кольори", "Frame and colors"), style="bold")))) for seg in self.design["segments"]: extra = seg.get("text") or seg.get("cmd") or seg.get("char") or "" lv.append(ListItem(Label(Text(prompt.segment_name(seg["type"]) + (f" «{extra}»" if extra else ""))))) if index is not None: lv.index = index self.call_after_refresh(self.show_props) self.update_preview() def selected(self): """Index of the chosen segment, or None for the frame row.""" i = self.query_one("#segments", ListView).index return None if not i else i - 1 async def on_list_view_highlighted(self, event): await self.show_props() # --- properties ---------------------------------------------------------- async def show_props(self): async with self.props_lock: await self._show_props() async def _show_props(self): box = self.query_one("#props", VerticalScroll) await box.remove_children() i = self.selected() widgets = [] if i is None: d = self.design widgets += [Label(Text(t("Вигляд рамки", "Frame"))), Select([(t("Das: ┌─[…]─[…] / └─╼", "Das: ┌─[…]─[…] / └─╼"), "das"), (t("Простий рядок", "Plain line"), "plain")], value=d["frame"], allow_blank=False, id="p_frame"), Label(Text(t("Колір рамки й дужок", "Frame and bracket color"))), Select(color_options(), value=to_select(d["frame_fg"]), allow_blank=False, id="p_frame_fg"), Label(Text(t("Ліва дужка", "Left bracket"))), Input(d["open"], id="p_open"), Label(Text(t("Права дужка", "Right bracket"))), Input(d["close"], id="p_close"), Label(Text(t("Між сегментами", "Between segments"))), Input(d["sep"], id="p_sep")] else: seg = self.design["segments"][i] widgets.append(Label(Text(prompt.segment_name(seg["type"]), style="bold"))) if not OPTIONS[seg["type"]]: widgets.append(Label(Text(t("Без налаштувань.", "Nothing to set.")))) for key, kind, uk, en in OPTIONS[seg["type"]]: wid = "s_" + key if kind == "bool": widgets.append(Checkbox(t(uk, en), bool(seg.get(key)), id=wid)) continue widgets.append(Label(Text(t(uk, en)))) if kind == "color": widgets.append(Select(color_options(), value=to_select(seg.get(key)), allow_blank=False, id=wid)) elif kind.startswith("choice:"): widgets.append(Select(_choices(kind), value=seg.get(key), allow_blank=False, id=wid)) else: widgets.append(Input(str(seg.get(key) if seg.get(key) is not None else ""), id=wid)) for w in widgets: # Events of these widgets belong to this segment, whatever is selected later w.das_target = i await box.mount(*widgets) def _set(self, control, value): if not hasattr(control, "das_target") or not control.is_attached: return i, wid = control.das_target, control.id key = wid[2:] if wid.startswith("p_"): if key == "frame" and value != self.design["frame"]: # Sensible brackets for the new frame self.design.update({"das": {"open": "[", "close": "]", "sep": "─"}, "plain": {"open": "", "close": "", "sep": " "}}[value]) self.design["frame"] = value self.call_later(self.show_props) else: self.design[key] = value elif i is not None and i < len(self.design["segments"]): seg = self.design["segments"][i] if key == "depth": value = int(value) if str(value).isdigit() else 3 seg[key] = value if key in ("text", "cmd", "char"): self.refresh_label(i) self.update_preview() def refresh_label(self, i): seg = self.design["segments"][i] item = self.query_one("#segments", ListView).children[i + 1] extra = seg.get("text") or seg.get("cmd") or seg.get("char") or "" item.query_one(Label).update(Text(prompt.segment_name(seg["type"]) + (f" «{extra}»" if extra else ""))) def on_select_changed(self, event: Select.Changed): wid = event.select.id or "" if wid == "preset": if event.value is not Select.NULL: self.design = prompt.load_preset(event.value) self.refresh_segments(0) elif wid == "add": if event.value is not Select.NULL: i = self.selected() pos = len(self.design["segments"]) if i is None else i + 1 self.design["segments"].insert(pos, prompt.new_segment(event.value)) event.select.clear() self.refresh_segments(pos + 1) elif wid.startswith(("p_", "s_")): value = event.value if wid.endswith("fg"): value = color_value(value) self._set(event.select, value) def on_checkbox_changed(self, event: Checkbox.Changed): self._set(event.checkbox, event.value) def on_input_changed(self, event: Input.Changed): if (event.input.id or "").startswith(("p_", "s_")): self._set(event.input, event.value) def on_button_pressed(self, event: Button.Pressed): i = self.selected() segs = self.design["segments"] if i is None: return if event.button.id == "remove": del segs[i] self.refresh_segments(min(i + 1, len(segs))) elif event.button.id == "up" and i > 0: segs[i - 1], segs[i] = segs[i], segs[i - 1] self.refresh_segments(i) elif event.button.id == "down" and i + 1 < len(segs): segs[i + 1], segs[i] = segs[i], segs[i + 1] self.refresh_segments(i + 2) # --- preview --------------------------------------------------------------- def update_preview(self): out = Text() for n, ((uk, en), over) in enumerate(STATES): if n: out.append("\n") out.append(t(uk, en) + ":\n", style="dim italic") for text, style in prompt.preview(self.design, prompt.sample_state(**over)): out.append(text, style=style or None) out.append("ls", style="default") self.query_one("#preview", Static).update(out) # --- actions ----------------------------------------------------------------- def action_apply_me(self): path = apply_design(self.design, system=False) self.notify(t(f"Записано в {path}. Відкрийте новий термінал або exec bash.", f"Written to {path}. Open a new terminal or run exec bash."), timeout=6) def action_apply_all(self): fd, path = tempfile.mkstemp(prefix="dprompt-", suffix=".json") with os.fdopen(fd, "w", encoding="utf-8") as f: f.write(prompt.dumps(self.design)) os.chmod(path, 0o644) ok = run_as_root(self, ["dprompt", "apply", path, "--system"]) os.remove(path) if ok: self.notify(t("Запрошення для всіх записано в /etc/das/prompt.sh.", "The prompt for everyone is written to /etc/das/prompt.sh."), timeout=6) def action_export(self): def done(result): if not result: return path = os.path.expanduser(result["path"]) try: with open(path, "w", encoding="utf-8") as f: f.write(prompt.dumps(dict(copy.deepcopy(self.design), name=os.path.basename(path)[:-5]))) except OSError as e: self.notify(str(e), severity="error") return self.notify(t(f"Збережено: {path}", f"Saved: {path}")) self.push_screen(AskDialog(t("Зберегти запрошення у файл", "Save the prompt to a file"), [("path", t("Файл (.json)", "File (.json)"), "~/my-prompt.json")]), done) __all__ = ["PromptApp", "NONE"]