- 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,293 @@
|
||||
"""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"]
|
||||
Reference in New Issue
Block a user