Terminal: Das prompt, dprompt and dbanner (0.9.1)
build / build (push) Successful in 38m26s

- 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:
das
2026-10-21 09:17:10 +03:00
co-authored by Claude Opus 5.5
parent 66ea614e0d
commit a2bffc7fe9
72 changed files with 5506 additions and 23 deletions
@@ -0,0 +1,778 @@
"""dbanner editor: a list of banners with a preview, and the drawing editor."""
import copy
import os
import re
from rich.text import Text
from textual import work
from textual.app import App
from textual.binding import Binding
from textual.containers import Horizontal, Vertical, VerticalScroll
from textual.message import Message
from textual.screen import ModalScreen, Screen
from textual.widgets import (Button, Checkbox, Footer, Header, Input, Label, ListItem, ListView, RadioButton,
RadioSet, Select, Static, TabbedContent, TabPane)
from . import banner, widgets
from .canvas import TOOLS, Canvas
from .i18n import t, title
from .tui_common import AskDialog, Swatches, run_as_root
TOOL_NAMES = {
"pen": ("Олівець", "Pen"), "eraser": ("Гумка", "Eraser"), "line": ("Лінія", "Line"),
"box": ("Рамка", "Box"), "fill": ("Заливка", "Fill"), "text": ("Текст", "Text"),
"bigtext": ("Великі літери", "Big letters"), "field": ("Поле зі змінною", "Field with a value"),
"art": ("Малюнок", "Picture"), "select": ("Виділення", "Select"),
}
TOOL_HELP = {
"pen": ("Клацніть і ведіть мишею; або Enter і стрілки.", "Click and drag; or Enter and the arrows."),
"eraser": ("Стирає клітинки під мишею.", "Clears the cells under the mouse."),
"line": ("Тягніть від початку до кінця.", "Drag from start to end."),
"box": ("Тягніть з кута в кут; вигляд рамки — нижче.", "Drag corner to corner; the box style is below."),
"fill": ("Тягніть прямокутник: заповнить символом.", "Drag a rectangle: fills it with the symbol."),
"text": ("Клацніть і друкуйте; Enter — новий рядок, Esc — кінець.",
"Click and type; Enter for a new line, Esc to stop."),
"bigtext": ("Клацніть, куди поставити великі літери (figlet).", "Click where the big letters go (figlet)."),
"field": ("Тягніть по рядку: місце для {ip}, {hostname}… Клацання по полю — змінити.",
"Drag along a row: room for {ip}, {hostname}… Click a field to change it."),
"art": ("Клацніть, куди вставити логотип Das OS, тризуб чи ваш фрагмент.",
"Click where to put the Das OS logo, the tryzub or your piece."),
"select": ("Тягніть: Ctrl+C копіювати, Ctrl+X вирізати, Ctrl+V вставити біля курсора, Del — стерти.",
"Drag: Ctrl+C copy, Ctrl+X cut, Ctrl+V paste at the cursor, Del clears."),
}
PALETTE = [
"█▓▒░▀▄▌▐■□▪▫",
"─│┌┐└┘├┤┬┴┼",
"═║╔╗╚╝╠╣╦╩╬",
"━┃┏┓┗┛╭╮╰╯╱╲",
"★☆●○◆◇►◄▲▼♥♦",
"#*+=-_|/\\<>~",
"·•°…✓✗☀☁⚡✦❖⬢",
]
BOX_KINDS = [("single", "┌─┐"), ("double", "╔═╗"), ("rounded", "╭─╮"), ("heavy", "┏━┓"),
("ascii", "+-+"), ("hash", "###"), ("block", "███")]
def ansi_text(text):
return Text.from_ansi(text, no_wrap=True)
def unique_draft_name(base):
taken = {n for n, _ in banner.list_drafts()} | {n for n, _ in banner.list_presets()}
base = re.sub(r"[^\w.-]+", "-", base).strip("-") or "banner"
name, n = base, 1
while name in taken:
n += 1
name = f"{base}-{n}"
return name
# --- dialogs ------------------------------------------------------------------
class ArtDialog(ModalScreen):
"""Choose a ready-made picture; dismisses with its design or None."""
DEFAULT_CSS = """
ArtDialog { align: center middle; }
ArtDialog > Horizontal { width: 90%; height: 80%; border: round $accent; background: $surface; }
ArtDialog ListView { width: 40; }
ArtDialog #art_preview { padding: 1 2; }
"""
BINDINGS = [("escape", "cancel", t("Скасувати", "Cancel"))]
def __init__(self):
super().__init__()
self.items = banner.list_art()
def compose(self):
with Horizontal():
yield ListView(*[ListItem(Label(Text(title_))) for _, title_, _ in self.items], id="art_list")
with Vertical():
yield VerticalScroll(Static(id="art_preview"))
yield Button(t("Вставити", "Insert"), variant="primary", id="ok")
def on_list_view_highlighted(self, event):
i = event.list_view.index
if i is not None and i < len(self.items):
d = self.items[i][2]
self.query_one("#art_preview", Static).update(
ansi_text("\n".join(banner.grid_lines(banner.Grid.from_design(d)))))
def on_list_view_selected(self, event):
self._ok()
def on_button_pressed(self, event):
self._ok()
def _ok(self):
i = self.query_one("#art_list", ListView).index
self.dismiss(self.items[i][2] if i is not None and i < len(self.items) else None)
def action_cancel(self):
self.dismiss(None)
def field_dialog(f, new):
variables = ", ".join("{" + k + "}" for k in widgets.PROVIDERS)
fields = [
("text", t(f"Текст зі змінними: {variables}, ваші змінні; [[укр|eng]] — двомовний підпис",
f"Text with values: {variables}, your variables; [[ukr|eng]] for two languages"),
f.get("text", "{hostname}")),
("width", t("Ширина", "Width"), str(f.get("width", 20))),
("align", t("Вирівнювання", "Alignment"),
[(t("ліворуч", "left"), "left"), (t("по центру", "center"), "center"), (t("праворуч", "right"), "right")],
f.get("align", "left")),
("font", t("Великі літери (figlet)", "Big letters (figlet)"),
[(t("ні", "no"), "")] + [(n, n) for n in banner.FIGLET_FONTS], f.get("font", "")),
]
if not new:
fields.append(("delete", t("Видалити поле?", "Delete the field?"),
[(t("ні", "no"), "no"), (t("так, видалити", "yes, delete"), "yes")], "no"))
return AskDialog(t("Нове поле" if new else "Поле", "New field" if new else "Field"), fields)
class Palette(Static):
"""Symbols to click; posts Picked with the symbol."""
class Picked(Message):
def __init__(self, char):
super().__init__()
self.char = char
def on_click(self, event):
row, col = event.y, event.x // 2
if 0 <= row < len(PALETTE) and 0 <= col < len(PALETTE[row]) and event.x % 2 == 0:
self.post_message(self.Picked(PALETTE[row][col]))
# --- the editor -----------------------------------------------------------------
class EditorScreen(Screen):
CSS = """
#tools { width: 40; border-right: solid $primary; padding: 0 1; }
#tools Label.h { text-style: bold; color: $accent; margin-top: 1; }
#help { color: $text-muted; height: auto; }
#palette { height: auto; }
#status { height: 1; color: $text-muted; padding: 0 1; }
RadioSet { width: 100%; }
.buttons { height: auto; }
.buttons Button { margin-right: 1; min-width: 5; }
.row { height: auto; }
.row Input { width: 1fr; }
#fields_list, #widget_list { height: 1fr; min-height: 6; }
#preview_box { padding: 0 1; }
#widget_out { height: auto; max-height: 12; border: round $primary; }
"""
BINDINGS = [
Binding("ctrl+s", "save", t("Зберегти", "Save")),
Binding("ctrl+z", "undo", t("Скасувати дію", "Undo")),
Binding("ctrl+y", "redo", t("Повторити", "Redo")),
Binding("ctrl+r", "resize", t("Розмір", "Size")),
Binding("ctrl+c", "copy", t("Копіювати", "Copy"), show=False),
Binding("ctrl+x", "cut", t("Вирізати", "Cut"), show=False),
Binding("ctrl+v", "paste", t("Вставити", "Paste"), show=False),
Binding("f5", "preview", t("Перегляд", "Preview")),
Binding("ctrl+a", "apply", t("Застосувати", "Apply")),
Binding("escape", "back", t("Назад", "Back")),
]
def __init__(self, design, draft_name):
super().__init__()
self.design = banner.normalize(design)
self.name_ = draft_name
self.grid = banner.Grid.from_design(self.design)
canvas = self.design.get("canvas") or {}
self.grid.resize(max(self.grid.width, canvas.get("width", 0), 60),
max(self.grid.height, canvas.get("height", 0), 10))
self.fields = self.design["fields"]
self.undo_stack, self.redo_stack = [], []
self.dirty = False
# --- layout ------------------------------------------------------------
def compose(self):
yield Header()
with Horizontal():
with VerticalScroll(id="tools"):
yield Label(Text(t("Інструмент", "Tool")), classes="h")
with RadioSet(id="tool"):
for i, tool in enumerate(TOOLS):
yield RadioButton(t(*TOOL_NAMES[tool]), value=i == 0)
yield Static(Text(t(*TOOL_HELP["pen"])), id="help")
yield Label(Text(t("Символ", "Symbol")), classes="h")
yield Palette(id="palette")
with Horizontal(classes="row"):
yield Input("█", max_length=1, id="char")
yield Label(Text(t("Вигляд рамки", "Box style")), classes="h")
yield Select([(f"{sample} {kind}", kind) for kind, sample in BOX_KINDS], value="single",
allow_blank=False, id="box_kind")
yield Label(Text(t("Колір символу", "Symbol color")), classes="h")
yield Swatches("cyan", id="fg")
yield Label(Text(t("Колір тла", "Background")), classes="h")
yield Swatches(None, id="bg")
yield Checkbox(t("Жирний", "Bold"), id="bold")
with TabbedContent(id="tabs"):
with TabPane(t("Малюнок", "Picture"), id="tab_draw"):
yield Canvas(self.grid, self.fields, before=self.snapshot, id="canvas")
yield Static(id="status")
with TabPane(t("Поля", "Fields"), id="tab_fields"):
yield Static(Text(t("Поля показують змінні: ім'я, IP, URL… Додаються інструментом "
"«Поле зі змінною» на полотні.",
"Fields show values: name, IP, URL… Add them with the \"Field with "
"a value\" tool on the canvas.")))
yield ListView(id="fields_list")
with Horizontal(classes="buttons"):
yield Button(t("Змінити", "Edit"), id="field_edit")
yield Button(t("Видалити", "Delete"), id="field_del", variant="error")
with TabPane(t("Віджети", "Widgets"), id="tab_widgets"):
yield Select([(t("під малюнком", "below the picture"), "below"),
(t("праворуч від малюнка", "right of the picture"), "right")],
value=self.design["widgets_position"], allow_blank=False, id="position")
yield ListView(id="widget_list")
with Horizontal(classes="buttons"):
yield Button("↑", id="w_up")
yield Button("↓", id="w_down")
yield Button(t("Вимк/увімк", "Off/on"), id="w_toggle")
yield Button(t("Налаштувати", "Settings"), id="w_edit")
yield Button(t("Запустити", "Run"), id="w_run")
yield Button(t("Видалити", "Delete"), id="w_del", variant="error")
yield Select([(f"{name_} — {title_}", name_) for name_, title_, _ in widgets.available()],
prompt=t("+ готовий віджет", "+ ready-made widget"), id="w_add")
with Horizontal(classes="row"):
yield Input(placeholder=t("своя команда, напр. df -h /data", "own command, e.g. df -h /data"),
id="w_cmd")
yield Button(t("+ команда", "+ command"), id="w_add_cmd")
with Horizontal(classes="row"):
yield Input(placeholder=t("файл з виводом скрипта, напр. ~/scripts/out/hdd",
"file with a script's output, e.g. ~/scripts/out/hdd"), id="w_file")
yield Button(t("+ файл", "+ file"), id="w_add_file")
yield VerticalScroll(Static(id="widget_out_text"), id="widget_out")
with TabPane(t("Змінні", "Variables"), id="tab_vars"):
yield Static(Text(t("Свої змінні для полів: {url}, {title}, {city} (місто для погоди)…",
"Your own values for fields: {url}, {title}, {city} (weather city)…")))
yield VerticalScroll(id="vars_box")
with Horizontal(classes="row"):
yield Input(placeholder=t("назва", "name"), id="var_name")
yield Input(placeholder=t("значення", "value"), id="var_value")
yield Button(t("Додати", "Add"), id="var_add")
yield Static(Text("\n".join(f"{{{k}}} — {t(*v)}" for k, v in widgets.VAR_HELP.items()),
style="dim"))
yield Checkbox(t("Показувати й при вході в консоль (не лише SSH)",
"Also show on console logins (not only SSH)"),
bool(self.design.get("local")), id="local")
with TabPane(t("Перегляд", "Preview"), id="tab_preview"):
yield VerticalScroll(Static(id="preview"), id="preview_box")
yield Footer()
async def on_mount(self):
self.sub_title = self.name_
self.render_palette()
self.refresh_fields()
self.refresh_widgets()
await self.refresh_vars()
self.update_status()
self.query_one(Canvas).focus()
# --- tools panel ------------------------------------------------------------
@property
def canvas(self):
return self.query_one(Canvas)
def render_palette(self):
text = Text()
for row in PALETTE:
for ch in row:
style = "reverse bold" if ch == self.canvas.char else ""
text.append(ch, style=style)
text.append(" ")
text.append("\n")
self.query_one("#palette", Static).update(text)
def on_palette_picked(self, event):
self.set_char(event.char)
def set_char(self, ch):
self.canvas.char = ch
inp = self.query_one("#char", Input)
if inp.value != ch:
inp.value = ch
self.render_palette()
self.update_status()
def on_radio_set_changed(self, event: RadioSet.Changed):
tool = TOOLS[event.index]
self.canvas.tool = tool
self.canvas.anchor = self.canvas.text_origin = None
self.query_one("#help", Static).update(Text(t(*TOOL_HELP[tool])))
self.update_status()
self.canvas.focus()
def on_swatches_picked(self, event: Swatches.Picked):
if event.swatches.id == "fg":
self.canvas.fg = event.color
else:
self.canvas.bg = event.color
self.update_status()
def update_status(self):
c = self.canvas
x, y = c.cursor
style = " ".join(filter(None, ["bold" if c.bold else "", c.fg or "default", ("on " + c.bg) if c.bg else ""]))
text = Text.assemble(
f" {t(*TOOL_NAMES[c.tool])} │ ", (c.char * 3, style), f" │ x={x} y={y} │ "
f"{self.grid.width}×{self.grid.height} │ {t('поля', 'fields')}: {len(self.fields)}")
self.query_one("#status", Static).update(text)
def snapshot(self):
self.undo_stack.append((self.grid.copy(), copy.deepcopy(self.fields)))
del self.undo_stack[:-100]
self.redo_stack.clear()
self.dirty = True
def on_canvas_changed(self, event):
self.update_status()
def on_canvas_request(self, event: Canvas.Request):
if event.tool == "art":
def put(d):
if d:
self.snapshot()
self.grid.stamp(event.x, event.y, banner.Grid.from_design(d))
self.canvas.refresh()
self.app.push_screen(ArtDialog(), put)
elif event.tool == "bigtext":
def put_big(result):
if not result or not result["text"]:
return
lines = banner.figlet(result["text"], result["font"])
self.snapshot()
key = self.canvas.key_now()
for i, line in enumerate(lines):
for j, ch in enumerate(line):
if ch != " ":
self.grid.put(event.x + j, event.y + i, ch, key)
self.canvas.refresh()
self.app.push_screen(AskDialog(t("Великі літери", "Big letters"), [
("text", t("Текст", "Text"), "Das OS"),
("font", t("Шрифт figlet", "figlet font"), [(n, n) for n in banner.FIGLET_FONTS], "standard")]),
put_big)
elif event.tool == "field":
self.edit_field(event.field_index, event.x, event.y, event.width)
def edit_field(self, index, x=0, y=0, width=20):
new = index is None
f = {"x": x, "y": y, "width": max(width, 4) if width > 1 else 20, "text": "{hostname}"} if new \
else self.fields[index]
def done(result):
if not result:
return
self.snapshot()
if result.get("delete") == "yes":
del self.fields[index]
else:
target = dict(f) if new else f
target.update(text=result["text"], align=result["align"],
width=int(result["width"]) if result["width"].isdigit() else 20,
style=self.canvas.key_now() if new else f.get("style", " "))
if result["font"]:
target["font"] = result["font"]
else:
target.pop("font", None)
if new:
self.fields.append(target)
self.canvas.refresh()
self.refresh_fields()
self.update_status()
self.app.push_screen(field_dialog(f, new), done)
# --- inputs ----------------------------------------------------------------------
def on_input_changed(self, event: Input.Changed):
if event.input.id == "char" and event.value:
self.set_char(event.value[0])
elif (event.input.id or "").startswith("var_v_"):
self.design["vars"][event.input.id[6:]] = event.value
self.dirty = True
def on_checkbox_changed(self, event: Checkbox.Changed):
if event.checkbox.id == "bold":
self.canvas.bold = event.value
self.update_status()
elif event.checkbox.id == "local":
self.design["local"] = event.value
self.dirty = True
def on_select_changed(self, event: Select.Changed):
sid = event.select.id
if sid == "box_kind":
self.canvas.box_kind = event.value
elif sid == "position":
self.design["widgets_position"] = event.value
self.dirty = True
elif sid == "w_add" and event.value is not Select.NULL:
self.design["widgets"].append({"type": "builtin", "name": event.value})
event.select.clear()
self.refresh_widgets(len(self.design["widgets"]) - 1)
# --- fields tab ---------------------------------------------------------------------
def refresh_fields(self):
lv = self.query_one("#fields_list", ListView)
lv.clear()
for f in self.fields:
font = f" [{f['font']}]" if f.get("font") else ""
lv.append(ListItem(Label(Text(f"({f['x']},{f['y']}) ×{f.get('width')} {f.get('align')}{font}: "
f"{f.get('text', '')}"))))
# --- widgets tab --------------------------------------------------------------------
def refresh_widgets(self, index=None):
lv = self.query_one("#widget_list", ListView)
lv.clear()
for w in self.design["widgets"]:
mark = "☐" if w.get("enabled") is False else "☑"
mode = widgets.mode_of(w)
info = t("при вході", "at login") if mode == "live" else \
t(f"кеш, кожні {widgets.interval_of(w)} с", f"cached, every {widgets.interval_of(w)} s")
label = Text.assemble(f"{mark} ", widgets.describe(w), (f" ({info})", "dim"))
if w.get("title"):
label.append(f" «{w['title']}»", style="italic")
lv.append(ListItem(Label(label)))
if index is not None:
lv.index = index
def widget_index(self):
i = self.query_one("#widget_list", ListView).index
return i if i is not None and i < len(self.design["widgets"]) else None
@work(thread=True, exclusive=True, group="widget_run")
def run_widget(self, w):
lines = widgets.run(w, widgets.Values(self.design.get("vars")), self.design.get("vars"),
timeout=float(w.get("timeout") or 30))
out = "\n".join(lines) if lines else t("(порожньо: немає даних, помилка або потрібен root)",
"(empty: no data, an error, or it needs root)")
self.app.call_from_thread(self.query_one("#widget_out_text", Static).update, ansi_text(out))
def edit_widget(self, i):
w = self.design["widgets"][i]
def done(result):
if not result:
return
w["title"] = result["title"]
for key in ("interval", "timeout"):
if result[key].isdigit():
w[key] = int(result[key])
else:
w.pop(key, None)
if result["mode"]:
w["mode"] = result["mode"]
else:
w.pop("mode", None)
if w.get("type") == "command":
w["cmd"] = result.get("cmd", w.get("cmd", ""))
self.dirty = True
self.refresh_widgets(i)
fields = [("title", t("Заголовок над виводом (необов'язково)", "Heading above the output (optional)"),
w.get("title", "")),
("mode", t("Коли виконувати", "When to run"),
[(t("як задумано віджетом", "as the widget intends"), ""),
(t("при кожному вході (швидкі)", "at every login (quick ones)"), "live"),
(t("службою das-banner, з кешем (повільні, від root)", "by the das-banner service, cached "
"(slow ones, as root)"), "cached")], w.get("mode", "")),
("interval", t("Оновлювати кеш кожні … секунд", "Refresh the cache every … seconds"),
str(w.get("interval", ""))),
("timeout", t("Тайм-аут, секунд", "Timeout, seconds"), str(w.get("timeout", "")))]
if w.get("type") == "command":
fields.insert(0, ("cmd", t("Команда", "Command"), w.get("cmd", "")))
self.app.push_screen(AskDialog(widgets.describe(w), fields), done)
# --- variables tab -------------------------------------------------------------------
async def refresh_vars(self):
box = self.query_one("#vars_box", VerticalScroll)
await box.remove_children()
rows = []
for key, value in self.design["vars"].items():
rows.append(Horizontal(Label(Text(f"{{{key}}}"), classes="var_label"),
Input(str(value), id="var_v_" + key),
Button("✕", id="var_del_" + key), classes="row"))
await box.mount(*rows)
# --- buttons -----------------------------------------------------------------------------
async def on_button_pressed(self, event: Button.Pressed):
bid = event.button.id or ""
ws = self.design["widgets"]
i = self.widget_index()
if bid == "field_edit":
n = self.query_one("#fields_list", ListView).index
if n is not None and n < len(self.fields):
self.edit_field(n)
elif bid == "field_del":
n = self.query_one("#fields_list", ListView).index
if n is not None and n < len(self.fields):
self.snapshot()
del self.fields[n]
self.refresh_fields()
self.canvas.refresh()
elif bid == "w_add_cmd":
cmd = self.query_one("#w_cmd", Input).value.strip()
if cmd:
ws.append({"type": "command", "cmd": cmd})
self.query_one("#w_cmd", Input).value = ""
self.refresh_widgets(len(ws) - 1)
elif bid == "w_add_file":
path = self.query_one("#w_file", Input).value.strip()
if path:
ws.append({"type": "file", "path": path})
self.query_one("#w_file", Input).value = ""
self.refresh_widgets(len(ws) - 1)
elif bid.startswith("var_del_"):
self.design["vars"].pop(bid[8:], None)
await self.refresh_vars()
elif bid == "var_add":
name = re.sub(r"\W", "", self.query_one("#var_name", Input).value)
if name:
self.design["vars"][name] = self.query_one("#var_value", Input).value
self.query_one("#var_name", Input).value = self.query_one("#var_value", Input).value = ""
await self.refresh_vars()
elif i is not None:
if bid == "w_up" and i > 0:
ws[i - 1], ws[i] = ws[i], ws[i - 1]
self.refresh_widgets(i - 1)
elif bid == "w_down" and i + 1 < len(ws):
ws[i + 1], ws[i] = ws[i], ws[i + 1]
self.refresh_widgets(i + 1)
elif bid == "w_toggle":
ws[i]["enabled"] = ws[i].get("enabled") is False
self.refresh_widgets(i)
elif bid == "w_del":
del ws[i]
self.refresh_widgets(min(i, len(ws) - 1) if ws else None)
elif bid == "w_edit":
self.edit_widget(i)
elif bid == "w_run":
self.query_one("#widget_out_text", Static).update(Text(t("Виконується…", "Running…")))
self.run_widget(ws[i])
return
self.dirty = True
# --- result ------------------------------------------------------------------------------
def current_design(self):
return self.grid.to_design(dict(self.design, fields=self.fields, name=self.name_))
def draft_path(self):
return os.path.join(banner.drafts_dir(), self.name_ + ".json")
def action_save(self):
banner.save(self.current_design(), self.draft_path())
self.dirty = False
self.notify(t(f"Збережено: {self.draft_path()}", f"Saved: {self.draft_path()}"))
return True
def action_undo(self):
if self.undo_stack:
self.redo_stack.append((self.grid.copy(), copy.deepcopy(self.fields)))
self._restore(*self.undo_stack.pop())
def action_redo(self):
if self.redo_stack:
self.undo_stack.append((self.grid.copy(), copy.deepcopy(self.fields)))
self._restore(*self.redo_stack.pop())
def _restore(self, grid, fields):
self.grid.cells, self.grid.styles = grid.cells, grid.styles
self.grid.width, self.grid.height = grid.width, grid.height
self.fields[:] = fields
self.canvas.set_grid(self.grid, self.fields)
self.refresh_fields()
self.update_status()
def action_copy(self):
if self.canvas.copy_selection():
self.notify(t("Скопійовано", "Copied"))
def action_cut(self):
self.canvas.copy_selection(cut=True)
def action_paste(self):
self.canvas.paste()
def action_resize(self):
def done(result):
if result and result["w"].isdigit() and result["h"].isdigit():
self.snapshot()
self.grid.resize(min(int(result["w"]), 300), min(int(result["h"]), 100))
self.canvas.set_grid(self.grid, self.fields)
self.update_status()
self.app.push_screen(AskDialog(t("Розмір полотна", "Canvas size"), [
("w", t("Ширина, символів", "Width, characters"), str(self.grid.width)),
("h", t("Висота, рядків", "Height, lines"), str(self.grid.height))]), done)
def action_preview(self):
self.query_one("#tabs", TabbedContent).active = "tab_preview"
self.query_one("#preview", Static).update(Text(t("Збираю дані…", "Collecting data…")))
self.build_preview(self.current_design())
def on_tabbed_content_tab_activated(self, event):
if event.pane.id == "tab_preview":
self.query_one("#preview", Static).update(Text(t("Збираю дані…", "Collecting data…")))
self.build_preview(self.current_design())
elif event.pane.id == "tab_fields":
self.refresh_fields()
@work(thread=True, exclusive=True, group="preview")
def build_preview(self, design):
values = widgets.Values(design.get("vars"))
blocks = widgets.blocks(design, values, use_cache=False)
text = banner.render(design, values, blocks)
self.app.call_from_thread(self.query_one("#preview", Static).update, ansi_text(text))
def action_apply(self):
self.action_save()
if run_as_root(self.app, ["dbanner", "apply", self.draft_path()]):
self.notify(t("Банер застосовано: його видно при вході по SSH.",
"The banner is applied: it shows at SSH login."), timeout=6)
def action_back(self):
if self.canvas.anchor is not None or self.canvas.selection or self.canvas.text_origin:
self.canvas.anchor = self.canvas.selection = self.canvas.text_origin = None
self.canvas.refresh()
return
if not self.dirty:
self.dismiss(None)
return
def done(result):
if result is None:
return
if result["save"] == "yes":
self.action_save()
self.dismiss(None)
self.app.push_screen(AskDialog(t("Є незбережені зміни", "There are unsaved changes"), [
("save", t("Зберегти чернетку перед виходом?", "Save the draft before leaving?"),
[(t("так, зберегти", "yes, save"), "yes"), (t("ні, відкинути", "no, discard"), "no")], "yes")]), done)
# --- start screen ------------------------------------------------------------------
class StartScreen(Screen):
CSS = """
#list { width: 46; border-right: solid $primary; }
#start_preview { padding: 0 1; }
.buttons { height: 3; dock: bottom; }
.buttons Button { margin-right: 1; }
"""
BINDINGS = [
Binding("enter", "edit", t("Редагувати", "Edit"), show=False),
Binding("e", "edit", t("Редагувати", "Edit")),
Binding("n", "new", t("Новий", "New")),
Binding("a", "apply", t("Застосувати", "Apply")),
Binding("delete", "delete", t("Видалити чернетку", "Delete draft")),
Binding("q", "app.quit", t("Вийти", "Quit")),
]
def compose(self):
yield Header()
with Horizontal():
yield ListView(id="list")
yield VerticalScroll(Static(id="start_preview"))
with Horizontal(classes="buttons"):
yield Button(t("Редагувати копію", "Edit a copy"), id="edit", variant="primary")
yield Button(t("Новий", "New"), id="new")
yield Button(t("Застосувати", "Apply"), id="apply", variant="success")
yield Button(t("Видалити чернетку", "Delete draft"), id="delete", variant="error")
yield Footer()
def on_mount(self):
self.reload()
def on_screen_resume(self):
self.reload()
def reload(self):
# (kind, name, path or None, label)
self.entries = []
if os.path.exists(banner.SYSTEM_FILE):
self.entries.append(("system", "system", banner.SYSTEM_FILE,
t("● Поточний банер системи", "● The system's current banner")))
for name, path in banner.list_drafts():
self.entries.append(("draft", name, path, t("✎ Чернетка: ", "✎ Draft: ") + name))
for name, title_ in banner.list_presets():
self.entries.append(("preset", name, os.path.join(banner.preset_dir(), name + ".json"), title_))
lv = self.query_one("#list", ListView)
index = lv.index or 0
lv.clear()
for kind, name, _, label in self.entries:
lv.append(ListItem(Label(Text(label, style="bold" if kind != "preset" else ""))))
lv.index = min(index, len(self.entries) - 1) if self.entries else None
def selected(self):
i = self.query_one("#list", ListView).index
return self.entries[i] if i is not None and i < len(self.entries) else None
def on_list_view_highlighted(self, event):
entry = self.selected()
if entry:
self.query_one("#start_preview", Static).update(Text(t("Збираю дані…", "Collecting data…")))
self.show_preview(entry[2])
def on_list_view_selected(self, event):
self.action_edit()
@work(thread=True, exclusive=True, group="start_preview")
def show_preview(self, path):
try:
design = banner.load_file(path)
values = widgets.Values(design.get("vars"))
text = banner.render(design, values, widgets.blocks(design, values, use_cache=True))
except (OSError, ValueError) as e:
text = str(e)
self.app.call_from_thread(self.query_one("#start_preview", Static).update, ansi_text(text))
def on_button_pressed(self, event: Button.Pressed):
getattr(self, "action_" + event.button.id)()
def action_edit(self):
entry = self.selected()
if not entry:
return
kind, name, path, _ = entry
design = banner.load_file(path)
draft = name if kind == "draft" else unique_draft_name(design.get("name", name) if kind == "system"
else name + "-my")
self.app.push_screen(EditorScreen(design, draft))
def action_new(self):
def done(result):
if result:
design = {"name": result["name"], "canvas": {"width": 60, "height": 10}}
self.app.push_screen(EditorScreen(design, unique_draft_name(result["name"])))
self.app.push_screen(AskDialog(t("Новий банер", "New banner"),
[("name", t("Назва чернетки", "Draft name"), "my-banner")]), done)
def action_apply(self):
entry = self.selected()
if entry and entry[0] != "system":
if run_as_root(self.app, ["dbanner", "apply", entry[2]]):
self.notify(t("Банер застосовано.", "The banner is applied."))
self.reload()
def action_delete(self):
entry = self.selected()
if entry and entry[0] == "draft":
os.remove(entry[2])
self.notify(t(f"Видалено {entry[1]}", f"Deleted {entry[1]}"))
self.reload()
class BannerApp(App):
TITLE = "dbanner"
SUB_TITLE = t("вітальний банер при вході по SSH", "SSH login banner")
def __init__(self):
# The terminal's own 16 colors: the picture looks as it will at login
super().__init__(ansi_color=True)
def on_mount(self):
self.push_screen(StartScreen())
__all__ = ["BannerApp", "title"]