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