"""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"]