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>
158 lines
5.5 KiB
Python
158 lines
5.5 KiB
Python
"""Textual pieces shared by the dprompt and dbanner editors."""
|
|
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
|
|
from rich.style import Style
|
|
from rich.text import Text
|
|
from textual import events
|
|
from textual.containers import Horizontal, Vertical
|
|
from textual.message import Message
|
|
from textual.screen import ModalScreen
|
|
from textual.widget import Widget
|
|
from textual.widgets import Button, Input, Label, Select
|
|
|
|
from . import colors
|
|
from .i18n import t
|
|
|
|
NONE = "-" # Select has no None value
|
|
|
|
|
|
def color_options(allow_none=True):
|
|
names = {
|
|
"black": ("чорний", "black"), "red": ("червоний", "red"), "green": ("зелений", "green"),
|
|
"yellow": ("жовтий", "yellow"), "blue": ("синій", "blue"), "magenta": ("пурпуровий", "magenta"),
|
|
"cyan": ("бірюзовий", "cyan"), "white": ("білий", "white"),
|
|
}
|
|
opts = [(t("— типовий —", "— default —"), NONE)] if allow_none else []
|
|
for c in colors.NAMES:
|
|
base = c[7:] if c.startswith("bright_") else c
|
|
label = t(*names[base])
|
|
if c.startswith("bright_"):
|
|
label = t("яскравий ", "bright ") + label
|
|
opts.append((Text.assemble(("██ ", c), label), c))
|
|
return opts
|
|
|
|
|
|
def color_value(value):
|
|
return None if value in (NONE, Select.NULL, None) else value
|
|
|
|
|
|
def to_select(color):
|
|
return color if color in colors.NAMES else NONE
|
|
|
|
|
|
class Swatches(Widget):
|
|
"""A row of the 16 colors (and "none") to click; the chosen one is marked."""
|
|
|
|
DEFAULT_CSS = """
|
|
Swatches { height: 2; width: 36; }
|
|
"""
|
|
|
|
class Picked(Message):
|
|
def __init__(self, swatches, color):
|
|
super().__init__()
|
|
self.swatches, self.color = swatches, color
|
|
|
|
def __init__(self, color=None, allow_none=True, **kw):
|
|
super().__init__(**kw)
|
|
self.color, self.allow_none = color, allow_none
|
|
|
|
def _items(self):
|
|
return ([None] if self.allow_none else []) + list(colors.NAMES)
|
|
|
|
def render(self):
|
|
text = Text()
|
|
for i, c in enumerate(self._items()):
|
|
if i == 9:
|
|
text.append("\n")
|
|
mark = "▣" if c == self.color else "■"
|
|
if c is None:
|
|
text.append("∅ " if c != self.color else "▣ ", Style(bold=True))
|
|
else:
|
|
text.append(mark * 2 + " " if c == self.color else "██ ", Style(color=c))
|
|
return text
|
|
|
|
def on_click(self, event: events.Click):
|
|
items = self._items()
|
|
index = (event.y * 9) + event.x // 3
|
|
if 0 <= event.x // 3 < 9 and 0 <= index < len(items):
|
|
self.color = items[index]
|
|
self.refresh()
|
|
self.post_message(self.Picked(self, self.color))
|
|
|
|
|
|
class AskDialog(ModalScreen):
|
|
"""A few text fields with OK/Cancel; dismisses with {key: value} or None."""
|
|
|
|
DEFAULT_CSS = """
|
|
AskDialog { align: center middle; }
|
|
AskDialog > Vertical { width: 72; height: auto; border: round $accent; background: $surface; padding: 1 2; }
|
|
AskDialog Label { margin-top: 1; }
|
|
AskDialog Horizontal { height: auto; margin-top: 1; align-horizontal: right; }
|
|
AskDialog Button { margin-left: 2; }
|
|
"""
|
|
BINDINGS = [("escape", "cancel", t("Скасувати", "Cancel"))]
|
|
|
|
def __init__(self, heading, fields):
|
|
"""fields: [(key, label, value or a list of (label, value) choices, current)]"""
|
|
super().__init__()
|
|
self.heading, self.fields = heading, fields
|
|
|
|
def compose(self):
|
|
with Vertical():
|
|
yield Label(Text(self.heading, style="bold"))
|
|
for key, label, value, *rest in self.fields:
|
|
yield Label(Text(label))
|
|
if isinstance(value, list):
|
|
yield Select(value, value=rest[0] if rest else value[0][1], allow_blank=False, id="f_" + key)
|
|
else:
|
|
yield Input(str(value), id="f_" + key)
|
|
with Horizontal():
|
|
yield Button(t("Скасувати", "Cancel"), id="cancel")
|
|
yield Button("OK", variant="primary", id="ok")
|
|
|
|
def on_mount(self):
|
|
inputs = self.query(Input)
|
|
if inputs:
|
|
inputs.first().focus()
|
|
|
|
def _result(self):
|
|
return {key: self.query_one("#f_" + key).value for key, *_ in self.fields}
|
|
|
|
def on_button_pressed(self, event: Button.Pressed):
|
|
self.dismiss(self._result() if event.button.id == "ok" else None)
|
|
|
|
def on_input_submitted(self, event: Input.Submitted):
|
|
self.dismiss(self._result())
|
|
|
|
def action_cancel(self):
|
|
self.dismiss(None)
|
|
|
|
|
|
def run_as_root(app, args):
|
|
"""Runs one of our commands through sudo with the TUI suspended, so sudo can
|
|
ask for the password. Returns True on success."""
|
|
exe = "/usr/bin/" + args[0]
|
|
if os.path.exists(exe):
|
|
cmd = [exe] + args[1:]
|
|
else: # from the source tree
|
|
module = {"dprompt": "das_shell.cli_prompt", "dbanner": "das_shell.cli_banner"}[args[0]]
|
|
env = [f"{k}={os.environ[k]}" for k in ("PYTHONPATH", "DAS_SHELL_DATA", "DAS_SHELL_ETC", "DAS_SHELL_CACHE")
|
|
if k in os.environ]
|
|
cmd = ["env"] + env + [sys.executable, "-m", module] + args[1:]
|
|
if os.geteuid() != 0:
|
|
cmd = ["sudo"] + cmd
|
|
with app.suspend():
|
|
print("\n$ " + " ".join(cmd))
|
|
code = subprocess.run(cmd).returncode
|
|
if code:
|
|
input(t("Помилка. Enter — повернутися…", "Failed. Press Enter to go back…"))
|
|
return code == 0
|
|
|
|
|
|
def bar(*buttons):
|
|
row = Horizontal(*buttons, classes="buttons")
|
|
return row
|