Files
Das-OS/srcpkgs/das-shell/files/python/das_shell/tui_common.py
T
dasandClaude Opus 5.5 6564d1eb17
build / build (push) Canceled after 0s
Hardware fixes: issue version, installer Cyrillic, console TUIs (0.9.2)
- /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>
2026-10-21 19:21:47 +03:00

188 lines
7.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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