build / build (push) Canceled after 0s
- /etc/issue shows the Das OS version instead of the kernel's; the server installer carries it into the installed system. - das-server-installer: setfont -C /dev/tty0 before the language menu (sudo runs it in its own pty, so the Cyrillic was drawn as Latin), and floppy drives are no longer offered as install targets. - ncdu on the desktop image and in das-server 0.4.0. - das-shell 0.2.0: each prompt line sets its own color; the dbanner start screen buttons are no longer covered by the key bar; on the Linux console (TERM=linux) dprompt and dbanner draw solid borders and swap the glyphs that no console font has (▊▎▔▁ and others) for ones it has. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
188 lines
7.1 KiB
Python
188 lines
7.1 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
|
||
from .prompt import CONSOLE_GLYPHS as PROMPT_CONSOLE_GLYPHS
|
||
|
||
NONE = "-" # Select has no None value
|
||
|
||
# The Linux console's fonts hold at most 512 glyphs (cyr-sun16, Terminus: none has
|
||
# the eighth blocks ▊▎▔▁ Textual draws buttons, inputs and lists with), so the
|
||
# missing ones come out as squares. There we draw with what the fonts do have.
|
||
CONSOLE = os.environ.get("TERM") == "linux"
|
||
CONSOLE_BORDERS = ("tall", "wide", "panel", "tab", "hkey", "vkey", "round", "heavy", "dashed", "inner", "outer")
|
||
CONSOLE_GLYPHS = str.maketrans({
|
||
**PROMPT_CONSOLE_GLYPHS,
|
||
"▁": "▄", "▂": "▄", "▃": "▄", "▅": "▄", "▆": "▄", "▇": "█", "▔": "▀", "▉": "█",
|
||
"▊": "▌", "▋": "▌", "▍": "▌", "▎": "▌", "▏": "▌", "▕": "▐",
|
||
"━": "─", "╸": "─", "╺": "─", "┃": "│", "┏": "┌", "┓": "┐", "┗": "└", "┛": "┘",
|
||
"╭": "┌", "╮": "┐", "╰": "└", "╯": "┘", "╱": "/", "╲": "\\",
|
||
"—": "-", "…": ".", "×": "x", "✕": "x", "✓": "v", "☑": "x", "○": "o", "⭘": "o", "◢": "▀",
|
||
})
|
||
|
||
|
||
def console_mode():
|
||
"""On the Linux console: swaps the borders and glyphs its fonts lack. Call
|
||
before the app starts."""
|
||
if not CONSOLE:
|
||
return
|
||
from textual import _border
|
||
from textual.drivers.linux_driver import LinuxDriver
|
||
for kind in CONSOLE_BORDERS:
|
||
_border.BORDER_CHARS[kind] = _border.BORDER_CHARS["solid"]
|
||
_border.BORDER_LOCATIONS[kind] = _border.BORDER_LOCATIONS["solid"]
|
||
_border.get_box.cache_clear()
|
||
write = LinuxDriver.write
|
||
LinuxDriver.write = lambda self, data: write(self, data.translate(CONSOLE_GLYPHS))
|
||
|
||
|
||
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
|