Files
Das-OS/srcpkgs/das-shell/files/python/das_shell/prompt_app.py
T
dasandClaude Opus 5.5 6564d1eb17
build / build (push) Canceled after 0s
Hardware fixes: issue version, installer Cyrillic, console TUIs (0.9.2)
- /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>
2026-10-21 19:21:47 +03:00

295 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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, console_mode, 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
console_mode()
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"]