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>
295 lines
15 KiB
Python
295 lines
15 KiB
Python
"""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"]
|