重写为多协议管理器: 6 协议, CLI + curses TUI, 用户级 systemd
This commit is contained in:
1 parent
c43ab4a16e
commit
237b958e41
71 files changed
+4599
-1171
No files matched your search
@@ -0,0 +1 @@
|
||||
"""Full-screen terminal interface."""
|
||||
@@ -0,0 +1,105 @@
|
||||
"""TUI application loop and shared helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import curses
|
||||
import sys
|
||||
|
||||
from .. import constants, protocols
|
||||
from ..errors import SbpError
|
||||
from ..ops import install as install_op
|
||||
from ..ops import status as status_op
|
||||
from ..paths import Paths
|
||||
from .term import Term
|
||||
from .screens import dashboard, logs, protocols as protocols_screen, settings, users
|
||||
|
||||
|
||||
class App:
|
||||
def __init__(self, paths: Paths) -> None:
|
||||
self.paths = paths
|
||||
self.term: Term
|
||||
|
||||
def status(self) -> status_op.Status:
|
||||
return status_op.status(self.paths)
|
||||
|
||||
def main(self, screen) -> None:
|
||||
self.term = Term(screen)
|
||||
while True:
|
||||
status = self.status()
|
||||
if not status.installed:
|
||||
choice = self.term.menu("sbp", ["Install", "Quit"], subtitle="not installed")
|
||||
if choice == 0:
|
||||
self.install_wizard()
|
||||
continue
|
||||
return
|
||||
subtitle = f"{status.host} {'active' if status.active else 'inactive'}"
|
||||
choice = self.term.menu(
|
||||
f"sbp {constants.VERSION}",
|
||||
["Dashboard", "Protocols", "Users", "Settings", "Logs", "Quit"],
|
||||
subtitle=subtitle,
|
||||
)
|
||||
if choice is None or choice == 5:
|
||||
return
|
||||
handler = (
|
||||
dashboard.show,
|
||||
protocols_screen.show,
|
||||
users.show,
|
||||
settings.show,
|
||||
logs.show,
|
||||
)[choice]
|
||||
try:
|
||||
handler(self)
|
||||
except SbpError as exc:
|
||||
self.term.message("Error", exc.message, "error")
|
||||
|
||||
def install_wizard(self) -> None:
|
||||
host = self.term.prompt("Install", "Client-facing host", "")
|
||||
sni = self.term.prompt("Install", "TLS server name", constants.DEFAULT_SNI)
|
||||
if sni is None:
|
||||
return
|
||||
keys = ", ".join(protocols.keys())
|
||||
selection = self.term.prompt("Install", f"Protocols ({keys})", "all")
|
||||
if selection is None:
|
||||
return
|
||||
port_text = self.term.prompt("Install", "First port", str(constants.BASE_PORT))
|
||||
if port_text is None:
|
||||
return
|
||||
try:
|
||||
base_port = int(port_text)
|
||||
except ValueError:
|
||||
self.term.message("Error", f"invalid port: {port_text}", "error")
|
||||
return
|
||||
selected = None if selection.strip().lower() in ("", "all") else [
|
||||
item.strip() for item in selection.split(",") if item.strip()
|
||||
]
|
||||
options = install_op.InstallOptions(
|
||||
host=host or None,
|
||||
sni=sni or None,
|
||||
protocols=selected,
|
||||
base_port=base_port,
|
||||
)
|
||||
|
||||
def action(progress):
|
||||
install_op.install(self.paths, options, progress=progress)
|
||||
for warning in options.warnings:
|
||||
print(f"warning: {warning}")
|
||||
|
||||
self.term.run_external("Install sbp", action)
|
||||
|
||||
|
||||
def run(paths: Paths) -> int:
|
||||
if not sys.stdout.isatty() or not sys.stdin.isatty():
|
||||
print("sbp tui needs an interactive terminal", file=sys.stderr)
|
||||
return 1
|
||||
app = App(paths)
|
||||
try:
|
||||
curses.wrapper(app.main)
|
||||
except curses.error as exc:
|
||||
print(f"error: cannot start the TUI: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
except SbpError as exc:
|
||||
print(f"error: {exc.message}", file=sys.stderr)
|
||||
return exc.code
|
||||
except KeyboardInterrupt:
|
||||
return 130
|
||||
return 0
|
||||
@@ -0,0 +1 @@
|
||||
"""TUI screens."""
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Dashboard: status summary and service control."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ...ops import service as service_op
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..app import App
|
||||
|
||||
|
||||
def _summary(app: "App") -> str:
|
||||
status = app.status()
|
||||
lines = [
|
||||
f"Service {'active' if status.active else 'inactive'}",
|
||||
f"sing-box {status.singbox_version}",
|
||||
f"Address {status.host}",
|
||||
f"SNI {status.sni}",
|
||||
f"TLS {status.tls_mode}",
|
||||
f"Cert until {status.cert_until}",
|
||||
f"Users {status.user_count}",
|
||||
"",
|
||||
"Protocols",
|
||||
]
|
||||
for row in status.protocols:
|
||||
mark = "on " if row.enabled else "off"
|
||||
details = ", ".join(f"{key}={value}" for key, value in row.details)
|
||||
lines.append(f" [{mark}] {row.title:<18} port {row.port:<6} {row.transport} {details}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def show(app: "App") -> None:
|
||||
while True:
|
||||
status = app.status()
|
||||
choice = app.term.menu(
|
||||
"Dashboard",
|
||||
["Show full status", "Start service", "Stop service", "Restart service", "Back"],
|
||||
subtitle=f"{status.host} {'active' if status.active else 'inactive'}",
|
||||
)
|
||||
if choice is None or choice == 4:
|
||||
return
|
||||
if choice == 0:
|
||||
app.term.scroll_text("Status", _summary(app))
|
||||
elif choice == 1:
|
||||
app.term.run_external("Start service", lambda progress: service_op.start(app.paths))
|
||||
elif choice == 2:
|
||||
app.term.run_external("Stop service", lambda progress: service_op.stop(app.paths))
|
||||
elif choice == 3:
|
||||
app.term.run_external("Restart service", lambda progress: service_op.restart(app.paths))
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Journal viewer."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ...ops import service as service_op
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..app import App
|
||||
|
||||
|
||||
def show(app: "App") -> None:
|
||||
while True:
|
||||
choice = app.term.menu("Logs", ["Recent 100 lines", "Recent 500 lines", "Back"])
|
||||
if choice is None or choice == 2:
|
||||
return
|
||||
lines = 100 if choice == 0 else 500
|
||||
app.term.scroll_text(
|
||||
"Logs",
|
||||
"\n".join(service_op.logs(app.paths, lines=lines)) or "no log lines",
|
||||
)
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Protocol list and per-protocol actions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ... import protocols
|
||||
from ...ops import protocols as protocols_op
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..app import App
|
||||
|
||||
|
||||
def _show(value) -> str:
|
||||
if isinstance(value, bool):
|
||||
return "true" if value else "false"
|
||||
return str(value)
|
||||
|
||||
|
||||
def _label(row, protocol) -> str:
|
||||
state = "enabled" if row[1] else "off"
|
||||
details = ", ".join(f"{key}={value}" for key, value in row[3])
|
||||
suffix = f" {details}" if details else ""
|
||||
return f"{protocol.info.title:<18} {state:<8} {row[2]:<6} {protocol.info.transport}{suffix}"
|
||||
|
||||
|
||||
def show(app: "App") -> None:
|
||||
while True:
|
||||
rows = protocols_op.summary(app.paths)
|
||||
labels = [_label(row, protocols.get(row[0])) for row in rows]
|
||||
labels.append("Back")
|
||||
choice = app.term.menu("Protocols", labels, subtitle="select a protocol to manage")
|
||||
if choice is None or choice == len(rows):
|
||||
return
|
||||
_detail(app, rows[choice][0])
|
||||
|
||||
|
||||
def _detail(app: "App", key: str) -> None:
|
||||
while True:
|
||||
row = next(item for item in protocols_op.summary(app.paths) if item[0] == key)
|
||||
protocol = protocols.get(key)
|
||||
enabled = row[1]
|
||||
actions = [
|
||||
"Disable" if enabled else "Enable",
|
||||
"Change port",
|
||||
"Edit options",
|
||||
"Show settings",
|
||||
"Back",
|
||||
]
|
||||
choice = app.term.menu(
|
||||
protocol.info.title,
|
||||
actions,
|
||||
subtitle=f"{'enabled' if enabled else 'off'} port {row[2]} {protocol.info.transport}",
|
||||
)
|
||||
if choice is None or choice == 4:
|
||||
return
|
||||
if choice == 0:
|
||||
if enabled:
|
||||
app.term.run_external("Disable", lambda progress: protocols_op.disable(app.paths, key))
|
||||
else:
|
||||
port_text = app.term.prompt("Enable", "Port (blank = auto)", "")
|
||||
port = int(port_text) if port_text and port_text.isdigit() else None
|
||||
app.term.run_external(
|
||||
"Enable", lambda progress: protocols_op.enable(app.paths, key, port=port)
|
||||
)
|
||||
elif choice == 1:
|
||||
current = str(row[2])
|
||||
value = app.term.prompt("Change port", "Port", current)
|
||||
if value and value.isdigit():
|
||||
app.term.run_external(
|
||||
"Change port", lambda progress: protocols_op.set_port(app.paths, key, int(value))
|
||||
)
|
||||
elif choice == 2:
|
||||
_edit_options(app, key)
|
||||
elif choice == 3:
|
||||
lines = [f"{name}: {value}" for name, value in row[3]] or ["no editable options"]
|
||||
app.term.scroll_text(protocol.info.title, "\n".join(lines))
|
||||
|
||||
|
||||
def _edit_options(app: "App", key: str) -> None:
|
||||
protocol = protocols.get(key)
|
||||
editable = protocol.editable()
|
||||
if not editable:
|
||||
app.term.message("Options", "this protocol has no editable options")
|
||||
return
|
||||
current = protocols_op.options(app.paths, key)
|
||||
labels = [f"{label}: {_show(current.get(name, ''))}" for name, label, _ in editable]
|
||||
labels.append("Back")
|
||||
choice = app.term.menu("Edit options", labels)
|
||||
if choice is None or choice == len(editable):
|
||||
return
|
||||
name, label, _kind = editable[choice]
|
||||
value = app.term.prompt("Edit option", label, _show(current.get(name, "")))
|
||||
if value is None:
|
||||
return
|
||||
app.term.run_external(
|
||||
"Apply option",
|
||||
lambda progress: protocols_op.set_option(app.paths, key, name, value),
|
||||
)
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Server settings, upgrade, and uninstall."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ...ops import install as install_op
|
||||
from ...ops import settings as settings_op
|
||||
from ...ops import uninstall as uninstall_op
|
||||
from ...ops import upgrade as upgrade_op
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..app import App
|
||||
|
||||
ACTIONS = (
|
||||
"Show settings",
|
||||
"Set host",
|
||||
"Set SNI",
|
||||
"Use custom certificate",
|
||||
"Renew certificate",
|
||||
"Upgrade sing-box",
|
||||
"Reinstall",
|
||||
"Uninstall",
|
||||
"Back",
|
||||
)
|
||||
|
||||
|
||||
def show(app: "App") -> None:
|
||||
while True:
|
||||
status = app.status()
|
||||
choice = app.term.menu(
|
||||
"Settings",
|
||||
list(ACTIONS),
|
||||
subtitle=f"{status.host} sni {status.sni}",
|
||||
)
|
||||
if choice is None or choice == 8:
|
||||
return
|
||||
if choice == 0:
|
||||
app.term.scroll_text(
|
||||
"Settings",
|
||||
"\n".join([
|
||||
f"Host {status.host}",
|
||||
f"SNI {status.sni}",
|
||||
f"TLS {status.tls_mode}",
|
||||
f"Cert until {status.cert_until}",
|
||||
f"Config {status.config_path}",
|
||||
f"Created {status.created}",
|
||||
]),
|
||||
)
|
||||
elif choice == 1:
|
||||
value = app.term.prompt("Set host", "Client-facing host", status.host)
|
||||
if value:
|
||||
app.term.run_external(
|
||||
"Set host", lambda progress: settings_op.set_host(app.paths, value)
|
||||
)
|
||||
elif choice == 2:
|
||||
value = app.term.prompt("Set SNI", "TLS server name", status.sni)
|
||||
if value:
|
||||
app.term.run_external(
|
||||
"Set SNI", lambda progress: settings_op.set_sni(app.paths, value)
|
||||
)
|
||||
elif choice == 3:
|
||||
cert = app.term.prompt("Custom certificate", "Certificate path (PEM)", "")
|
||||
if not cert:
|
||||
continue
|
||||
key = app.term.prompt("Custom certificate", "Private key path (PEM)", "")
|
||||
if not key:
|
||||
continue
|
||||
sni = app.term.prompt("Custom certificate", "SNI", status.sni)
|
||||
app.term.run_external(
|
||||
"Use custom certificate",
|
||||
lambda progress: settings_op.set_cert(app.paths, cert, key, sni or None),
|
||||
)
|
||||
elif choice == 4:
|
||||
app.term.run_external(
|
||||
"Renew certificate", lambda progress: settings_op.regenerate_cert(app.paths)
|
||||
)
|
||||
elif choice == 5:
|
||||
app.term.run_external(
|
||||
"Upgrade sing-box", lambda progress: upgrade_op.upgrade(app.paths, progress=progress)
|
||||
)
|
||||
elif choice == 6:
|
||||
if app.term.confirm("Reinstall", "Reinstall sing-box and regenerate the service?"):
|
||||
options = install_op.InstallOptions(force=True)
|
||||
app.term.run_external(
|
||||
"Reinstall",
|
||||
lambda progress: install_op.install(app.paths, options, progress=progress),
|
||||
)
|
||||
elif choice == 7:
|
||||
if app.term.confirm("Uninstall", "Stop and remove the service and binary?"):
|
||||
purge = app.term.confirm(
|
||||
"Uninstall",
|
||||
"Also delete configuration, users, and certificates?",
|
||||
)
|
||||
app.term.run_external(
|
||||
"Uninstall", lambda progress: uninstall_op.uninstall(app.paths, purge=purge)
|
||||
)
|
||||
return
|
||||
@@ -0,0 +1,60 @@
|
||||
"""User list, share links, and client configs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ... import protocols
|
||||
from ...ops import show as show_op
|
||||
from ...ops import users as users_op
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..app import App
|
||||
|
||||
|
||||
def show(app: "App") -> None:
|
||||
while True:
|
||||
users = users_op.list_users(app.paths)
|
||||
labels = [f"{user.name:<20} {user.created[:10]}" for user in users]
|
||||
labels.append("+ Add user")
|
||||
labels.append("Back")
|
||||
choice = app.term.menu("Users", labels, subtitle=f"{len(users)} users")
|
||||
if choice is None or choice == len(users) + 1:
|
||||
return
|
||||
if choice == len(users):
|
||||
name = app.term.prompt("Add user", "User name", "")
|
||||
if name:
|
||||
app.term.run_external("Add user", lambda progress: users_op.add(app.paths, name))
|
||||
continue
|
||||
_detail(app, users[choice].name)
|
||||
|
||||
|
||||
def _detail(app: "App", name: str) -> None:
|
||||
while True:
|
||||
choice = app.term.menu(
|
||||
f"User {name}",
|
||||
["Show share URIs", "Show client config", "Delete user", "Back"],
|
||||
)
|
||||
if choice is None or choice == 3:
|
||||
return
|
||||
if choice == 0:
|
||||
uris = show_op.user_uris(app.paths, name)
|
||||
text = "\n\n".join(f"{key}\n {uri}" for key, uri in uris) or "no enabled protocols"
|
||||
app.term.scroll_text(f"Share URIs: {name}", text)
|
||||
elif choice == 1:
|
||||
enabled = [row.key for row in app.status().protocols if row.enabled]
|
||||
if not enabled:
|
||||
app.term.message("Client config", "no enabled protocols")
|
||||
continue
|
||||
labels = [protocols.get(key).info.title for key in enabled] + ["Back"]
|
||||
picked = app.term.menu("Client config", labels)
|
||||
if picked is None or picked == len(enabled):
|
||||
continue
|
||||
app.term.scroll_text(
|
||||
f"Client config: {name} / {enabled[picked]}",
|
||||
show_op.client_config_json(app.paths, name, enabled[picked]),
|
||||
)
|
||||
elif choice == 2:
|
||||
if app.term.confirm("Delete user", f"Delete {name}? Clients using this user stop working."):
|
||||
app.term.run_external("Delete user", lambda progress: users_op.remove(app.paths, name))
|
||||
return
|
||||
@@ -0,0 +1,279 @@
|
||||
"""Curses widgets: menus, prompts, dialogs, and text paging.
|
||||
|
||||
Only this module touches curses. Screens call these helpers and never draw
|
||||
raw escape sequences themselves.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import curses
|
||||
import textwrap
|
||||
import unicodedata
|
||||
from typing import Callable, Optional
|
||||
|
||||
ACCENT = 1
|
||||
GOOD = 2
|
||||
WARN = 3
|
||||
BAD = 4
|
||||
DIM = 5
|
||||
|
||||
|
||||
def display_width(text: str) -> int:
|
||||
width = 0
|
||||
for char in text:
|
||||
if unicodedata.combining(char):
|
||||
continue
|
||||
width += 2 if unicodedata.east_asian_width(char) in ("W", "F") else 1
|
||||
return width
|
||||
|
||||
|
||||
def truncate(text: str, width: int) -> str:
|
||||
if display_width(text) <= width:
|
||||
return text
|
||||
result = ""
|
||||
used = 0
|
||||
for char in text:
|
||||
size = 2 if unicodedata.east_asian_width(char) in ("W", "F") else 1
|
||||
if used + size > width - 1:
|
||||
break
|
||||
result += char
|
||||
used += size
|
||||
return result + "…"
|
||||
|
||||
|
||||
def pad(text: str, width: int) -> str:
|
||||
return text + " " * max(0, width - display_width(text))
|
||||
|
||||
|
||||
def wrap_display(text: str, width: int) -> list[str]:
|
||||
"""Split text into lines that fit the display width, breaking anywhere."""
|
||||
if width <= 0:
|
||||
return [text]
|
||||
lines: list[str] = []
|
||||
current = ""
|
||||
used = 0
|
||||
for char in text:
|
||||
size = 2 if unicodedata.east_asian_width(char) in ("W", "F") else 1
|
||||
if used + size > width and current:
|
||||
lines.append(current)
|
||||
current = ""
|
||||
used = 0
|
||||
current += char
|
||||
used += size
|
||||
lines.append(current)
|
||||
return lines
|
||||
|
||||
|
||||
class Term:
|
||||
def __init__(self, screen) -> None:
|
||||
self.screen = screen
|
||||
self.has_color = curses.has_colors()
|
||||
if self.has_color:
|
||||
curses.start_color()
|
||||
try:
|
||||
curses.use_default_colors()
|
||||
except curses.error:
|
||||
pass
|
||||
curses.init_pair(ACCENT, curses.COLOR_CYAN, -1)
|
||||
curses.init_pair(GOOD, curses.COLOR_GREEN, -1)
|
||||
curses.init_pair(WARN, curses.COLOR_YELLOW, -1)
|
||||
curses.init_pair(BAD, curses.COLOR_RED, -1)
|
||||
curses.init_pair(DIM, curses.COLOR_WHITE, -1)
|
||||
self.screen.keypad(True)
|
||||
try:
|
||||
curses.curs_set(0)
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
def attr(self, pair: int, extra: int = 0) -> int:
|
||||
if not self.has_color:
|
||||
return extra
|
||||
return curses.color_pair(pair) | extra
|
||||
|
||||
def size(self) -> tuple[int, int]:
|
||||
height, width = self.screen.getmaxyx()
|
||||
return height, width
|
||||
|
||||
def put(self, y: int, x: int, text: str, attr: int = 0) -> None:
|
||||
height, width = self.size()
|
||||
if y < 0 or y >= height or x >= width:
|
||||
return
|
||||
try:
|
||||
self.screen.addstr(y, x, truncate(text, max(0, width - x - 1)), attr)
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
def clear(self) -> None:
|
||||
self.screen.erase()
|
||||
|
||||
def refresh(self) -> None:
|
||||
self.screen.refresh()
|
||||
|
||||
def header(self, title: str, subtitle: str = "") -> int:
|
||||
height, width = self.size()
|
||||
self.put(0, 1, title, self.attr(ACCENT, curses.A_BOLD))
|
||||
if subtitle:
|
||||
self.put(0, max(2, width - display_width(subtitle) - 2), subtitle, self.attr(DIM))
|
||||
self.put(1, 0, "─" * max(1, width - 1), self.attr(DIM))
|
||||
return 3
|
||||
|
||||
def footer(self, text: str) -> None:
|
||||
height, width = self.size()
|
||||
self.put(height - 2, 0, "─" * max(1, width - 1), self.attr(DIM))
|
||||
self.put(height - 1, 1, text, self.attr(DIM))
|
||||
|
||||
def message(self, title: str, text: str, kind: str = "info") -> None:
|
||||
pair = {"info": ACCENT, "good": GOOD, "warn": WARN, "error": BAD}.get(kind, ACCENT)
|
||||
self.clear()
|
||||
height, width = self.size()
|
||||
y = self.header(title)
|
||||
lines: list[str] = []
|
||||
for paragraph in text.splitlines() or [""]:
|
||||
lines.extend(textwrap.wrap(paragraph, max(20, width - 6)) or [""])
|
||||
for line in lines[: max(1, height - 7)]:
|
||||
self.put(y, 3, line, self.attr(pair))
|
||||
y += 1
|
||||
self.footer(" press any key ")
|
||||
self.refresh()
|
||||
self.screen.getch()
|
||||
|
||||
def confirm(self, title: str, text: str) -> bool:
|
||||
items = ["Yes", "No"]
|
||||
return self.menu(title, items, subtitle=text, start=1) == 0
|
||||
|
||||
def prompt(self, title: str, label: str, default: str = "", secret: bool = False) -> Optional[str]:
|
||||
self.clear()
|
||||
height, width = self.size()
|
||||
self.header(title)
|
||||
self.put(3, 3, label)
|
||||
if default and not secret:
|
||||
self.put(3, 3 + display_width(label) + 1, f"[{default}]", self.attr(DIM))
|
||||
self.put(height - 1, 1, "Enter accept Esc cancel", self.attr(DIM))
|
||||
self.refresh()
|
||||
try:
|
||||
curses.curs_set(1)
|
||||
except curses.error:
|
||||
pass
|
||||
if secret:
|
||||
curses.noecho()
|
||||
else:
|
||||
curses.echo()
|
||||
input_x = 3 + display_width(label) + (
|
||||
display_width(default) + 3 if default and not secret else 1
|
||||
)
|
||||
try:
|
||||
self.screen.move(
|
||||
min(3, max(0, height - 2)), min(max(0, input_x), max(0, width - 1))
|
||||
)
|
||||
except curses.error:
|
||||
pass
|
||||
try:
|
||||
raw = self.screen.getstr()
|
||||
except (curses.error, KeyboardInterrupt):
|
||||
raw = b""
|
||||
finally:
|
||||
curses.noecho()
|
||||
try:
|
||||
curses.curs_set(0)
|
||||
except curses.error:
|
||||
pass
|
||||
if raw == b"\x1b" or b"\x1b" in raw:
|
||||
return None
|
||||
value = raw.decode(errors="replace").strip()
|
||||
if value == "" and default:
|
||||
return default
|
||||
return value or None
|
||||
|
||||
def menu(
|
||||
self,
|
||||
title: str,
|
||||
items: list[str],
|
||||
subtitle: str = "",
|
||||
footer: str = "↑/↓ select Enter open q back",
|
||||
start: int = 0,
|
||||
) -> Optional[int]:
|
||||
index = min(max(start, 0), max(0, len(items) - 1))
|
||||
while True:
|
||||
self.clear()
|
||||
height, width = self.size()
|
||||
y = self.header(title, subtitle)
|
||||
visible = max(1, height - 6)
|
||||
offset = max(0, index - visible + 1)
|
||||
for row, item in enumerate(items[offset: offset + visible]):
|
||||
absolute = offset + row
|
||||
selected = absolute == index
|
||||
marker = "▸ " if selected else " "
|
||||
attr = self.attr(ACCENT, curses.A_BOLD) if selected else 0
|
||||
self.put(y + row, 2, marker + item, attr)
|
||||
self.footer(footer)
|
||||
self.refresh()
|
||||
key = self.screen.getch()
|
||||
if key == -1:
|
||||
return None
|
||||
if key in (curses.KEY_UP, ord("k")):
|
||||
index = (index - 1) % len(items)
|
||||
elif key in (curses.KEY_DOWN, ord("j")):
|
||||
index = (index + 1) % len(items)
|
||||
elif key in (10, 13, curses.KEY_ENTER, ord(" ")):
|
||||
return index
|
||||
elif key in (ord("q"), 27):
|
||||
return None
|
||||
elif key == curses.KEY_RESIZE:
|
||||
continue
|
||||
elif ord("1") <= key <= ord("9"):
|
||||
candidate = key - ord("1")
|
||||
if candidate < len(items):
|
||||
return candidate
|
||||
|
||||
def scroll_text(self, title: str, text: str, subtitle: str = "") -> None:
|
||||
height, width = self.size()
|
||||
available = max(1, width - 4)
|
||||
lines: list[str] = []
|
||||
for source in text.splitlines() or [""]:
|
||||
lines.extend(wrap_display(source, available))
|
||||
top = 0
|
||||
while True:
|
||||
self.clear()
|
||||
height, width = self.size()
|
||||
y = self.header(title, subtitle)
|
||||
body = max(1, height - 5)
|
||||
for row in range(body):
|
||||
position = top + row
|
||||
if position >= len(lines):
|
||||
break
|
||||
self.put(y + row, 2, lines[position])
|
||||
self.footer("↑/↓ scroll PgUp/PgDn q back")
|
||||
self.refresh()
|
||||
key = self.screen.getch()
|
||||
if key == -1 or key in (ord("q"), 27):
|
||||
return
|
||||
if key in (curses.KEY_UP, ord("k")):
|
||||
top = max(0, top - 1)
|
||||
elif key in (curses.KEY_DOWN, ord("j")):
|
||||
top = min(max(0, len(lines) - body), top + 1)
|
||||
elif key == curses.KEY_PPAGE:
|
||||
top = max(0, top - body)
|
||||
elif key == curses.KEY_NPAGE:
|
||||
top = min(max(0, len(lines) - body), top + body)
|
||||
|
||||
def run_external(self, title: str, action: Callable[[Callable[[str], None]], None]) -> bool:
|
||||
"""Leave curses, run action, wait for Enter, then restore curses."""
|
||||
try:
|
||||
curses.def_prog_mode()
|
||||
curses.endwin()
|
||||
print(f"\n== {title} ==\n")
|
||||
try:
|
||||
action(lambda message: print(f"==> {message}", flush=True))
|
||||
ok = True
|
||||
except Exception as exc: # noqa: BLE001 - shown to the user
|
||||
print(f"\nerror: {exc}")
|
||||
ok = False
|
||||
print("\npress Enter to return")
|
||||
try:
|
||||
input()
|
||||
except EOFError:
|
||||
pass
|
||||
finally:
|
||||
curses.reset_prog_mode()
|
||||
self.screen.refresh()
|
||||
return ok
|
||||
Reference in new issue
Block a user