mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-02 10:52:22 +00:00
384 lines
16 KiB
Python
384 lines
16 KiB
Python
from __future__ import annotations
|
|
|
|
import getpass
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import textwrap
|
|
from dataclasses import dataclass
|
|
|
|
from .i18n import translate
|
|
|
|
|
|
class UserCancelled(RuntimeError):
|
|
pass
|
|
|
|
|
|
class BackRequested(RuntimeError):
|
|
pass
|
|
|
|
|
|
class RestartWizard(RuntimeError):
|
|
pass
|
|
|
|
|
|
class BacktrackUI:
|
|
"""Replay prior answers when returning to a previous wizard question."""
|
|
|
|
def __init__(self, base):
|
|
self.base = base
|
|
self.answers = []
|
|
self.cursor = 0
|
|
self.previous_back_enabled = getattr(base, 'back_enabled', False)
|
|
base.back_enabled = True
|
|
|
|
def __getattr__(self, name):
|
|
return getattr(self.base, name)
|
|
|
|
def close(self):
|
|
self.base.back_enabled = self.previous_back_enabled
|
|
|
|
def restart(self):
|
|
self.cursor = 0
|
|
|
|
def retry_last(self, error) -> bool:
|
|
"""After an answer the wizard could not accept: say why and ask that
|
|
question again, keeping every answer given before it. False when no
|
|
answer was given yet, so there is nothing to ask again."""
|
|
if not self.answers:
|
|
return False
|
|
text = str(error)
|
|
# What Python says about a number it could not read is not for the user.
|
|
if text.startswith(("invalid literal for int()", "could not convert string to float")):
|
|
text = f"{translate('The value must be a number:')} {text.rsplit(':', 1)[-1].strip()}"
|
|
self.base.message(f"{text}\n\n{translate('Enter the value again.')}")
|
|
self.answers.pop()
|
|
self.cursor = 0
|
|
return True
|
|
|
|
def _call(self, name, *args, **kwargs):
|
|
if self.cursor < len(self.answers):
|
|
saved_name, value = self.answers[self.cursor]
|
|
if saved_name != name:
|
|
self.answers = self.answers[:self.cursor]
|
|
else:
|
|
self.cursor += 1
|
|
return value
|
|
try:
|
|
value = getattr(self.base, name)(*args, **kwargs)
|
|
except BackRequested:
|
|
if self.cursor:
|
|
self.answers = self.answers[:self.cursor - 1]
|
|
self.cursor = 0
|
|
raise RestartWizard()
|
|
raise UserCancelled(translate('Wizard cancelled'))
|
|
self.answers.append((name, value))
|
|
self.cursor += 1
|
|
return value
|
|
|
|
def ask(self, *args, **kwargs):
|
|
return self._call('ask', *args, **kwargs)
|
|
|
|
def password(self, *args, **kwargs):
|
|
return self._call('password', *args, **kwargs)
|
|
|
|
def confirm(self, *args, **kwargs):
|
|
return self._call('confirm', *args, **kwargs)
|
|
|
|
def choose(self, *args, **kwargs):
|
|
return self._call('choose', *args, **kwargs)
|
|
|
|
def checklist(self, *args, **kwargs):
|
|
return self._call('checklist', *args, **kwargs)
|
|
|
|
def detail_menu(self, *args, **kwargs):
|
|
return self._call('detail_menu', *args, **kwargs)
|
|
|
|
def review(self, *args, **kwargs):
|
|
try:
|
|
return self.base.review(*args, **kwargs)
|
|
except BackRequested:
|
|
if self.cursor:
|
|
self.answers = self.answers[:self.cursor - 1]
|
|
self.cursor = 0
|
|
raise RestartWizard()
|
|
raise UserCancelled(translate('Wizard cancelled'))
|
|
|
|
|
|
APP_TITLE = translate("OCI manager Apps (Technology Preview)")
|
|
|
|
|
|
@dataclass
|
|
class TerminalUI:
|
|
title: str = APP_TITLE
|
|
back_enabled: bool = False
|
|
|
|
def ask(self, text: str, default: str | None = None, required: bool = True) -> str:
|
|
suffix = f" [{default}]" if default not in (None, "") else ""
|
|
while True:
|
|
value = input(f"{text}{suffix}: ").strip()
|
|
if self.back_enabled and value == ':back':
|
|
raise BackRequested()
|
|
if value:
|
|
return value
|
|
if default is not None:
|
|
return default
|
|
if not required:
|
|
return ""
|
|
print(translate("This value is required."))
|
|
|
|
def password(self, text: str, required: bool = True) -> str:
|
|
while True:
|
|
value = getpass.getpass(f"{text}: ")
|
|
if self.back_enabled and value == ':back':
|
|
raise BackRequested()
|
|
if not value:
|
|
if not required:
|
|
return ""
|
|
print(translate("This value is required."))
|
|
continue
|
|
repeated = getpass.getpass(f"{translate('Repeat to confirm')}: ")
|
|
if self.back_enabled and repeated == ':back':
|
|
raise BackRequested()
|
|
if value == repeated:
|
|
return value
|
|
print(translate("The values do not match. Enter them again."))
|
|
|
|
def confirm(self, text: str, default: bool = False, title: str | None = None) -> bool:
|
|
suffix = " [Y/n]" if default else " [y/N]"
|
|
value = input(f"{text}{suffix}: ").strip().casefold()
|
|
if self.back_enabled and value == ':back':
|
|
raise BackRequested()
|
|
if not value:
|
|
return default
|
|
return value in {"y", "yes", "s", "si"}
|
|
|
|
def choose(self, text: str, options: list[tuple[str, str]], default: str | None = None,
|
|
title: str | None = None, show_tags: bool = True,
|
|
size: tuple[int, int, int] | None = None, colors: bool = False) -> str | None:
|
|
options = [(tag, re.sub(r"\\Z.", "", label)) for tag, label in options if tag]
|
|
print(text)
|
|
for index, (_, label) in enumerate(options, 1):
|
|
print(f"{index:2}. {label}")
|
|
default_index = next((index for index, (tag, _) in enumerate(options, 1) if tag == default), None)
|
|
selected = self.ask(translate("Selection"), str(default_index) if default_index else None, required=False)
|
|
if selected.isdigit() and 1 <= int(selected) <= len(options):
|
|
return options[int(selected) - 1][0]
|
|
return None
|
|
|
|
def checklist(self, text, options, defaults):
|
|
return [tag for tag, label in options if self.confirm(label, tag in defaults)]
|
|
|
|
def detail_menu(self, text: str, options: list[tuple[str, str]], default: str | None = None,
|
|
title: str | None = None) -> str | None:
|
|
return self.choose(re.sub(r"\\Z.", "", text), options, default, title=title)
|
|
|
|
def message(self, text: str, title: str | None = None) -> None:
|
|
print(f"\n{title or self.title}\n{text}")
|
|
|
|
def review(self, text: str, title: str | None = None, question: str | None = None,
|
|
default: bool = True) -> bool:
|
|
self.message(text, title)
|
|
return self.confirm(question or translate("Continue?"), default)
|
|
|
|
def info(self, text: str) -> None:
|
|
print(text)
|
|
|
|
|
|
# Width of the windows of the installer: questions, choices and notices. The
|
|
# final summary and the page of an application, which show tables, are wider.
|
|
MENU_WIDTH = 72
|
|
|
|
|
|
class ReadableTags(list):
|
|
"""Menu options whose tags the user reads, as a storage, a bridge or a
|
|
device node: they are shown as they are, not numbered."""
|
|
SUMMARY_WIDTH = 78
|
|
DETAIL_WINDOW_WIDTH = 90
|
|
|
|
|
|
def menu_size() -> tuple[int, int, int]:
|
|
"""Height, width and rows of a list of the menu: as tall as the main menu
|
|
of ProxMenux, cut to the terminal."""
|
|
height = max(10, min(24, shutil.get_terminal_size((100, 30)).lines - 2))
|
|
return height, 75, height - 8
|
|
|
|
|
|
@dataclass
|
|
class DialogUI:
|
|
"""dialog widgets with the ProxMenux backtitle, used for every menu shown
|
|
before the installation starts."""
|
|
|
|
title: str = APP_TITLE
|
|
backtitle: str = "ProxMenux"
|
|
back_enabled: bool = False
|
|
|
|
@staticmethod
|
|
def available() -> bool:
|
|
return bool(shutil.which("dialog") and sys.stdin.isatty() and sys.stdout.isatty())
|
|
|
|
def _run(self, widget: list[str], title: str | None = None) -> subprocess.CompletedProcess[str]:
|
|
environment = os.environ.copy()
|
|
# ncurses otherwise waits a full second to distinguish Escape from
|
|
# the prefix of a cursor/function key. Keep a short sequence window,
|
|
# while respecting an explicit terminal-specific setting.
|
|
environment.setdefault("ESCDELAY", "150")
|
|
if environment.get("TERM", "").casefold() in {"", "dumb", "unknown"}:
|
|
environment["TERM"] = "xterm-256color"
|
|
# dialog draws on the terminal and writes the selection to stderr.
|
|
back_widget = ['--extra-button', '--extra-label', translate('Back')] if self.back_enabled and any(
|
|
flag in widget for flag in ('--inputbox', '--passwordbox', '--yesno', '--menu', '--checklist')) else []
|
|
confirmation_labels = ['--yes-label', translate('Yes'), '--no-label', translate('No')] if '--yesno' in widget else []
|
|
result = subprocess.run(
|
|
["dialog", "--no-collapse", "--cancel-label", translate('Cancel'), "--backtitle", self.backtitle, "--title", title or self.title,
|
|
*confirmation_labels, *back_widget, *widget],
|
|
stdout=None, stderr=subprocess.PIPE, text=True, check=False, env=environment,
|
|
)
|
|
if result.returncode == 3 and back_widget:
|
|
raise BackRequested()
|
|
return result
|
|
|
|
@staticmethod
|
|
def _size(text: str, min_height: int, width: int, extra: int = 6) -> tuple[str, str]:
|
|
terminal = shutil.get_terminal_size((100, 30))
|
|
width = max(50, min(width, terminal.columns - 4))
|
|
lines = sum(max(1, len(textwrap.wrap(line, max(20, width - 6)) or [""])) for line in text.splitlines() or [""])
|
|
height = max(min_height, min(lines + extra, terminal.lines - 2))
|
|
return str(height), str(width)
|
|
|
|
def ask(self, text: str, default: str | None = None, required: bool = True, title: str | None = None) -> str:
|
|
while True:
|
|
height, width = self._size(text, 10, MENU_WIDTH, 7)
|
|
result = self._run(["--inputbox", f"\n{text}", height, width, default or ""], title)
|
|
if result.returncode != 0:
|
|
raise UserCancelled(text)
|
|
value = result.stderr.strip()
|
|
if value or not required or default is not None:
|
|
return value or default or ""
|
|
self.message(translate("This value is required."))
|
|
|
|
def password(self, text: str, required: bool = True, title: str | None = None) -> str:
|
|
while True:
|
|
height, width = self._size(text, 10, MENU_WIDTH, 7)
|
|
result = self._run(["--insecure", "--passwordbox", f"\n{text}", height, width], title)
|
|
if result.returncode != 0:
|
|
raise UserCancelled(text)
|
|
value = result.stderr.rstrip("\n")
|
|
if not value:
|
|
if not required:
|
|
return ""
|
|
self.message(translate("This value is required."))
|
|
continue
|
|
repeated = self._run(["--insecure", "--passwordbox", f"\n{translate('Repeat to confirm')}", height, width], title)
|
|
if repeated.returncode != 0:
|
|
raise UserCancelled(text)
|
|
if value == repeated.stderr.rstrip("\n"):
|
|
return value
|
|
self.message(translate("The values do not match. Enter them again."))
|
|
|
|
def confirm(self, text: str, default: bool = False, title: str | None = None) -> bool:
|
|
height, width = self._size(text, 9, MENU_WIDTH, 6)
|
|
widget = ["--yesno", f"\n{text}", height, width]
|
|
if not default:
|
|
widget = ["--defaultno", *widget]
|
|
return self._run(widget, title).returncode == 0
|
|
|
|
def choose(self, text: str, options: list[tuple[str, str]], default: str | None = None,
|
|
title: str | None = None, show_tags: bool = True,
|
|
size: tuple[int, int, int] | None = None, colors: bool = False) -> str | None:
|
|
# A word is an internal value: the menu numbers the options, as the
|
|
# rest of ProxMenux does, and returns the value of the chosen one.
|
|
values: dict[str, str] = {}
|
|
if not isinstance(options, ReadableTags) and any(tag and not tag.isdigit() for tag, _ in options):
|
|
numbered = []
|
|
for tag, label in options:
|
|
if tag:
|
|
values[str(len(values) + 1)] = tag
|
|
if tag == default:
|
|
default = str(len(values))
|
|
tag = str(len(values))
|
|
numbered.append((tag, label))
|
|
options = numbered
|
|
if size:
|
|
widget = ["--colors"] if colors else []
|
|
widget += ["--default-item", default] if default else []
|
|
widget += ["--menu", text, *map(str, size)]
|
|
for tag, label in options:
|
|
widget += [tag, label]
|
|
result = self._run(widget, title)
|
|
selected = result.stderr.strip() if result.returncode == 0 else None
|
|
return values.get(selected, selected) if values and selected else selected
|
|
terminal = shutil.get_terminal_size((100, 30))
|
|
tag_width = max((len(tag) for tag, _ in options), default=0) + 2 if show_tags else 0
|
|
width = min(max(MENU_WIDTH, max((len(label) for _, label in options), default=0) + tag_width + 14,
|
|
max((len(line) for line in text.splitlines()), default=0) + 6),
|
|
terminal.columns - 4, 110)
|
|
text_lines = sum(max(1, len(textwrap.wrap(line, width - 6) or [""])) for line in text.splitlines() or [""])
|
|
height = min(max(len(options) + text_lines + 8, 14), terminal.lines - 2)
|
|
menu_height = max(3, min(len(options), height - text_lines - 8))
|
|
widget = ["--default-item", default] if default else []
|
|
if not show_tags:
|
|
widget.append("--no-tags")
|
|
widget += ["--menu", f"\n{text}", str(height), str(width), str(menu_height)]
|
|
for tag, label in options:
|
|
widget += [tag, label]
|
|
result = self._run(widget, title)
|
|
selected = result.stderr.strip() if result.returncode == 0 else None
|
|
return values.get(selected, selected) if values and selected else selected
|
|
|
|
def detail_menu(self, text: str, options: list[tuple[str, str]], default: str | None = None,
|
|
title: str | None = None) -> str | None:
|
|
"""A tall menu under a block of information. When the information does
|
|
not fit on screen it is shown first in a scrollable box."""
|
|
terminal = shutil.get_terminal_size((100, 30))
|
|
width = min(terminal.columns - 4, DETAIL_WINDOW_WIDTH)
|
|
text_lines = sum(max(1, len(textwrap.wrap(line, width - 6) or [""])) for line in text.splitlines() or [""])
|
|
available = terminal.lines - 2
|
|
needed = text_lines + len(options) + 9
|
|
if needed > available:
|
|
self._run(["--colors", "--msgbox", f"\n{text}", str(available), str(width)], title)
|
|
# On its own it is one more menu, as wide as the others.
|
|
text, needed, width = translate("Select an option"), len(options) + 10, min(width, MENU_WIDTH)
|
|
height = min(available, needed)
|
|
widget = ["--colors"] + (["--default-item", default] if default else [])
|
|
widget += ["--menu", f"\n{text}", str(height), str(width), str(len(options))]
|
|
for tag, label in options:
|
|
widget += [tag, label]
|
|
result = self._run(widget, title)
|
|
return result.stderr.strip() if result.returncode == 0 else None
|
|
|
|
def checklist(self, text, options, defaults, title: str | None = None):
|
|
terminal = shutil.get_terminal_size((100, 30))
|
|
height = min(max(len(options) + 9, 14), terminal.lines - 2)
|
|
widget = ["--separate-output", "--checklist", f"\n{text}", str(height), str(MENU_WIDTH),
|
|
str(max(4, min(len(options), height - 8)))]
|
|
for tag, label in options:
|
|
widget += [tag, label, "on" if tag in defaults else "off"]
|
|
result = self._run(widget, title)
|
|
if result.returncode != 0:
|
|
raise UserCancelled(text)
|
|
return result.stderr.split()
|
|
|
|
def message(self, text: str, title: str | None = None) -> None:
|
|
height, width = self._size(text, 8, MENU_WIDTH, 6)
|
|
self._run(["--msgbox", f"\n{text}", height, width], title)
|
|
|
|
def review(self, text: str, title: str | None = None, question: str | None = None,
|
|
default: bool = True) -> bool:
|
|
body = f"{text}\n\n{question or translate('Continue?')}"
|
|
height, width = self._size(body, 12, SUMMARY_WIDTH, 6)
|
|
widget = ["--yesno", f"\n{body}", height, width]
|
|
if not default:
|
|
widget = ["--defaultno", *widget]
|
|
return self._run(widget, title).returncode == 0
|
|
|
|
def info(self, text: str, title: str | None = None) -> None:
|
|
height, width = self._size(text, 6, MENU_WIDTH, 5)
|
|
self._run(["--infobox", f"\n{text}", height, width], title)
|
|
|
|
|
|
def interactive_ui() -> TerminalUI | DialogUI:
|
|
return DialogUI() if DialogUI.available() else TerminalUI()
|