From acbd767d0c44d878974bf72260c8e8bfe209235c Mon Sep 17 00:00:00 2001 From: cotore-game <102813037+cotore-game@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:36:16 +0900 Subject: [PATCH 1/5] fix: keep TUI session alive across dialogs and command waits --- libexec/mcserver-kit/config-tui.sh | 17 +- libexec/mcserver-kit/home-tui.sh | 28 +- libexec/mcserver-kit/i18n.sh | 18 + libexec/mcserver-kit/server-properties-tui.sh | 21 +- libexec/mcserver-kit/tui-session.py | 365 ++++++++++++++++++ share/mcserver-kit/locales/en.json | 5 + share/mcserver-kit/locales/ja.json | 5 + tests/run-tests.sh | 1 + tests/test-tui-session.py | 226 +++++++++++ 9 files changed, 656 insertions(+), 30 deletions(-) create mode 100644 libexec/mcserver-kit/tui-session.py create mode 100644 tests/test-tui-session.py diff --git a/libexec/mcserver-kit/config-tui.sh b/libexec/mcserver-kit/config-tui.sh index 1ac46b7..be55e68 100755 --- a/libexec/mcserver-kit/config-tui.sh +++ b/libexec/mcserver-kit/config-tui.sh @@ -4,6 +4,9 @@ set -Eeuo pipefail SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +if [[ -z "${MCSERVER_KIT_TUI_SOCKET:-}" && -t 0 && -t 1 ]]; then + exec python3 "${SCRIPT_DIR}/tui-session.py" run bash "${BASH_SOURCE[0]}" "$@" +fi CONFIG_FILE="${MCSERVER_KIT_CONFIG:-${HOME}/.config/mcserver-compose-kit/config.yml}" TEMPLATE_DIR="${MCSERVER_KIT_MCID_TEMPLATE_DIR:-${HOME}/.config/mcserver-compose-kit/mcid-templates}" CONFIG_VALUE="${SCRIPT_DIR}/config-value.py" @@ -57,7 +60,7 @@ edit_owner() { local value current="$(python3 "$CONFIG_VALUE" get "$CONFIG_FILE" owner minecraft_id)" while true; do - value="$(whiptail --title "$(tr tui.owner_title)" --inputbox "$(tr tui.owner_prompt)" 10 60 "$current" 3>&1 1>&2 2>&3)" || return + value="$(whiptail --title "$(tr tui.owner_title)" --inputbox "$(tr tui.owner_prompt)" 10 60 "$current" 3>&1 1>&2 2>&3)" || return 0 if [[ "$value" =~ ^[A-Za-z0-9_]{3,16}$ ]]; then python3 "$CONFIG_VALUE" set "$CONFIG_FILE" owner minecraft_id "$value" whiptail --msgbox "$(tr tui.owner_saved "$value")" 8 60 @@ -79,7 +82,7 @@ template_references() { create_template() { local name while true; do - name="$(whiptail --title "$(tr tui.template_create)" --inputbox "$(tr tui.template_name)" 10 60 3>&1 1>&2 2>&3)" || return + name="$(whiptail --title "$(tr tui.template_create)" --inputbox "$(tr tui.template_name)" 10 60 3>&1 1>&2 2>&3)" || return 0 if [[ ! "$name" =~ ^[A-Za-z0-9_-]+$ ]]; then whiptail --msgbox "$(tr tui.template_name_invalid)" 8 60 continue @@ -98,7 +101,7 @@ add_mcid() { local path="$1" local value while true; do - value="$(whiptail --title "$(tr tui.mcid_add)" --inputbox "$(tr tui.mcid_prompt)" 10 60 3>&1 1>&2 2>&3)" || return + value="$(whiptail --title "$(tr tui.mcid_add)" --inputbox "$(tr tui.mcid_prompt)" 10 60 3>&1 1>&2 2>&3)" || return 0 if [[ ! "$value" =~ ^[A-Za-z0-9_]{3,16}$ ]]; then whiptail --msgbox "$(tr input.mcid_rules)" 8 60 continue @@ -127,7 +130,7 @@ remove_mcid() { for index in "${!lines[@]}"; do items+=("$((index + 1))" "${lines[$index]}") done - selected="$(dialog_menu "$(tr tui.mcid_remove)" "$(tr tui.select_mcid)" "${items[@]}")" || return + selected="$(dialog_menu "$(tr tui.mcid_remove)" "$(tr tui.select_mcid)" "${items[@]}")" || return 0 temporary="$(mktemp "${path}.XXXXXX")" awk -v remove="$selected" 'NR != remove' "$path" >"$temporary" chmod 600 "$temporary" @@ -156,7 +159,7 @@ manage_template() { add "$(tr tui.mcid_add)" \ remove "$(tr tui.mcid_remove)" \ delete "$(tr tui.delete)" \ - back "$(tr tui.back)")" || return + back "$(tr tui.back)")" || return 0 case "$action" in view) whiptail --title "$name" --textbox "$path" 20 70 ;; add) add_mcid "$path" ;; @@ -181,7 +184,7 @@ manage_templates() { done shopt -u nullglob items+=(__create "$(tr tui.template_create)" __back "$(tr tui.back)") - choice="$(dialog_menu "$(tr tui.templates_title)" "$(tr tui.select_template)" "${items[@]}")" || return + choice="$(dialog_menu "$(tr tui.templates_title)" "$(tr tui.select_template)" "${items[@]}")" || return 0 case "$choice" in __create) create_template ;; __back) return ;; @@ -196,7 +199,7 @@ main_menu() { choice="$(dialog_menu "Minecraft Server Kit" "$(tr tui.main_prompt)" \ owner "$(tr tui.owner_menu)" \ templates "$(tr tui.templates_menu)" \ - exit "$(tr tui.exit)")" || return + exit "$(tr tui.exit)")" || return 0 case "$choice" in owner) edit_owner ;; templates) manage_templates ;; diff --git a/libexec/mcserver-kit/home-tui.sh b/libexec/mcserver-kit/home-tui.sh index d88a8e9..7096394 100755 --- a/libexec/mcserver-kit/home-tui.sh +++ b/libexec/mcserver-kit/home-tui.sh @@ -4,6 +4,9 @@ set -Eeuo pipefail SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +if [[ -z "${MCSERVER_KIT_TUI_SOCKET:-}" && -t 0 && -t 1 ]]; then + exec python3 "${SCRIPT_DIR}/tui-session.py" run bash "${BASH_SOURCE[0]}" "$@" +fi CONFIG_FILE="${MCSERVER_KIT_CONFIG:-${HOME}/.config/mcserver-compose-kit/config.yml}" ROOT_DIR="${MCSERVER_KIT_ROOT:-$(cd -- "${SCRIPT_DIR}/../.." && pwd)}" CONFIG_VALUE="${SCRIPT_DIR}/config-value.py" @@ -119,17 +122,10 @@ pause_for_enter() { run_and_show() { local title="$1" shift - local output pid result=0 frame_index=0 - local frames=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏') + local output result=0 new_temp_file output - "$@" >"$output" 2>&1 & - pid=$! - while kill -0 "$pid" 2>/dev/null; do - whiptail --title "$title" --infobox "$(tr home.processing "${frames[frame_index]}" "$title")" 8 72 - frame_index=$(((frame_index + 1) % ${#frames[@]})) - sleep 0.15 - done - wait "$pid" || result=$? + whiptail --title "$title" --infobox "$(tr home.processing '⠋' "$title")" 8 72 + "$@" >"$output" 2>&1 || result=$? if [[ ! -s "$output" ]]; then if ((result == 0)); then tr home.completed >"$output" @@ -159,16 +155,18 @@ server_action_menu() { open-data "$(tr home.open_data)" \ open-server "$(tr home.open_server)" \ back "$(tr tui.back)" \ - 3>&1 1>&2 2>&3)" || return + 3>&1 1>&2 2>&3)" || return 0 case "$choice" in start | stop | restart | status) run_and_show "$(tr "home.${choice}") · $id" "${SCRIPT_DIR}/server-manager.sh" server "$id" "$choice" ;; logs) + tui_terminal_suspend clear printf '%s\n\n' "$(tr home.logs_return_hint)" "${SCRIPT_DIR}/server-manager.sh" server "$id" logs || true pause_for_enter + tui_terminal_resume ;; properties) "${SCRIPT_DIR}/server-manager.sh" server "$id" properties || true @@ -209,7 +207,7 @@ servers_menu() { shopt -u nullglob fi items+=(__back "$(tr tui.back)") - selected="$(whiptail --title "$(tr home.servers)" --menu "$(tr home.select_server)" 23 82 15 "${items[@]}" 3>&1 1>&2 2>&3)" || return + selected="$(whiptail --title "$(tr home.servers)" --menu "$(tr home.select_server)" 23 82 15 "${items[@]}" 3>&1 1>&2 2>&3)" || return 0 [[ "$selected" == __back ]] && return server_action_menu "$selected" "${root}/${selected}" done @@ -221,7 +219,7 @@ language_menu() { selected="$(whiptail --title "$(tr home.language)" --radiolist "$(tr home.language_choose)" 13 66 2 \ en English "$([[ "$current" == en ]] && printf ON || printf OFF)" \ ja '日本語' "$([[ "$current" == ja ]] && printf ON || printf OFF)" \ - 3>&1 1>&2 2>&3)" || return + 3>&1 1>&2 2>&3)" || return 0 "${SCRIPT_DIR}/lang.sh" "--${selected}" >/dev/null I18N_MESSAGES=() load_messages "$selected" @@ -269,13 +267,15 @@ main() { update "$(tr home.update)" \ help "$(tr home.help)" \ exit "$(tr home.exit)" \ - 3>&1 1>&2 2>&3)" || return + 3>&1 1>&2 2>&3)" || return 0 case "$choice" in servers) servers_menu ;; create) + tui_terminal_suspend clear "${SCRIPT_DIR}/create-server.sh" || true pause_for_enter + tui_terminal_resume ;; config) "${SCRIPT_DIR}/config-tui.sh" || true ;; templates) "${SCRIPT_DIR}/config-tui.sh" templates || true ;; diff --git a/libexec/mcserver-kit/i18n.sh b/libexec/mcserver-kit/i18n.sh index 6b9c31f..df77bc7 100644 --- a/libexec/mcserver-kit/i18n.sh +++ b/libexec/mcserver-kit/i18n.sh @@ -5,6 +5,24 @@ I18N_SHARE_DIR="${MCSERVER_KIT_SHARE_DIR:-${I18N_ROOT}/share/mcserver-kit}" I18N_LOCALE_DIR="${I18N_SHARE_DIR}/locales" declare -A I18N_MESSAGES=() +# A home session owns the terminal. Dialog subprocesses exchange only data, +# so OK/Cancel never restores the shell while the next screen is being loaded. +if [[ -n "${MCSERVER_KIT_TUI_SOCKET:-}" ]]; then + whiptail() { + python3 "${I18N_ROOT}/libexec/mcserver-kit/tui-session.py" client "$@" + } +fi + +tui_terminal_suspend() { + [[ -z "${MCSERVER_KIT_TUI_SOCKET:-}" ]] || + python3 "${I18N_ROOT}/libexec/mcserver-kit/tui-session.py" client --suspend +} + +tui_terminal_resume() { + [[ -z "${MCSERVER_KIT_TUI_SOCKET:-}" ]] || + python3 "${I18N_ROOT}/libexec/mcserver-kit/tui-session.py" client --resume +} + detect_language() { local requested="${MCSERVER_KIT_LANG:-}" local language_file="${MCSERVER_KIT_LANGUAGE_FILE:-${HOME}/.config/mcserver-compose-kit/language}" diff --git a/libexec/mcserver-kit/server-properties-tui.sh b/libexec/mcserver-kit/server-properties-tui.sh index 79052b2..f803fc9 100755 --- a/libexec/mcserver-kit/server-properties-tui.sh +++ b/libexec/mcserver-kit/server-properties-tui.sh @@ -4,6 +4,9 @@ set -Eeuo pipefail SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +if [[ -z "${MCSERVER_KIT_TUI_SOCKET:-}" && -t 0 && -t 1 ]]; then + exec python3 "${SCRIPT_DIR}/tui-session.py" run bash "${BASH_SOURCE[0]}" "$@" +fi SERVER_ID="${1-}" SERVER_DIR="${2-}" SERVER_ENV="${SERVER_DIR}/server.env" @@ -91,7 +94,7 @@ edit_boolean() { selected="$(whiptail --title "$label" --radiolist "$label" 12 64 2 \ true "$(tr properties.enabled)" "$([[ "${current,,}" == true ]] && printf ON || printf OFF)" \ false "$(tr properties.disabled)" "$([[ "${current,,}" == false ]] && printf ON || printf OFF)" \ - 3>&1 1>&2 2>&3)" || return + 3>&1 1>&2 2>&3)" || return 0 setting_set "$key" "$selected" } @@ -102,14 +105,14 @@ edit_choice() { for value in "$@"; do items+=("$value" "$([[ "$value" == "$current" ]] && tr properties.selected || printf ' ')") done - value="$(whiptail --title "$label" --menu "$label" 17 72 9 "${items[@]}" 3>&1 1>&2 2>&3)" || return + value="$(whiptail --title "$label" --menu "$label" 17 72 9 "${items[@]}" 3>&1 1>&2 2>&3)" || return 0 setting_set "$key" "$value" } edit_number() { local key="$1" label="$2" current="$3" minimum="$4" maximum="$5" value while true; do - value="$(whiptail --title "$label" --inputbox "$label (${minimum}-${maximum})" 10 68 "$current" 3>&1 1>&2 2>&3)" || return + value="$(whiptail --title "$label" --inputbox "$label (${minimum}-${maximum})" 10 68 "$current" 3>&1 1>&2 2>&3)" || return 0 if [[ "$value" =~ ^[0-9]+$ ]] && ((value >= minimum && value <= maximum)); then setting_set "$key" "$value" return @@ -120,7 +123,7 @@ edit_number() { edit_text() { local key="$1" label="$2" current="$3" value - value="$(whiptail --title "$label" --inputbox "$label" 11 76 "$current" 3>&1 1>&2 2>&3)" || return + value="$(whiptail --title "$label" --inputbox "$label" 11 76 "$current" 3>&1 1>&2 2>&3)" || return 0 setting_set "$key" "$value" } @@ -129,7 +132,7 @@ edit_mcid_list() { local entries=() current="$(setting_get "$key" '')" while true; do - value="$(whiptail --title "$label" --inputbox "$(tr properties.mcid_list_hint)" 11 76 "$current" 3>&1 1>&2 2>&3)" || return + value="$(whiptail --title "$label" --inputbox "$(tr properties.mcid_list_hint)" 11 76 "$current" 3>&1 1>&2 2>&3)" || return 0 normalized='' IFS=',' read -ra entries <<<"$value" for entry in "${entries[@]}"; do @@ -154,7 +157,7 @@ edit_url() { local current value current="$(setting_get RESOURCE_PACK '')" while true; do - value="$(whiptail --title "$(tr properties.resource_pack_url)" --inputbox "$(tr properties.resource_pack_url_hint)" 11 76 "$current" 3>&1 1>&2 2>&3)" || return + value="$(whiptail --title "$(tr properties.resource_pack_url)" --inputbox "$(tr properties.resource_pack_url_hint)" 11 76 "$current" 3>&1 1>&2 2>&3)" || return 0 if [[ -z "$value" || "$value" =~ ^https:// ]]; then setting_set RESOURCE_PACK "$value" return @@ -167,7 +170,7 @@ edit_uuid() { local current value current="$(setting_get RESOURCE_PACK_ID '')" while true; do - value="$(whiptail --title "$(tr properties.resource_pack_id)" --inputbox "$(tr properties.resource_pack_id_hint)" 11 76 "$current" 3>&1 1>&2 2>&3)" || return + value="$(whiptail --title "$(tr properties.resource_pack_id)" --inputbox "$(tr properties.resource_pack_id_hint)" 11 76 "$current" 3>&1 1>&2 2>&3)" || return 0 if [[ -z "$value" || "$value" =~ ^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$ ]]; then setting_set RESOURCE_PACK_ID "${value,,}" return @@ -180,7 +183,7 @@ edit_sha1() { local current value current="$(setting_get RESOURCE_PACK_SHA1 '')" while true; do - value="$(whiptail --title "$(tr properties.resource_pack_sha1)" --inputbox "$(tr properties.resource_pack_sha1_hint)" 11 76 "$current" 3>&1 1>&2 2>&3)" || return + value="$(whiptail --title "$(tr properties.resource_pack_sha1)" --inputbox "$(tr properties.resource_pack_sha1_hint)" 11 76 "$current" 3>&1 1>&2 2>&3)" || return 0 if [[ -z "$value" || "$value" =~ ^[0-9a-fA-F]{40}$ ]]; then setting_set RESOURCE_PACK_SHA1 "${value,,}" return @@ -242,7 +245,7 @@ finish() { import_properties() { local output result - whiptail --yesno "$(tr home.import_confirm "$SERVER_ID")" 12 76 || return + whiptail --yesno "$(tr home.import_confirm "$SERVER_ID")" 12 76 || return 0 output="$(mktemp)" if "${SCRIPT_DIR}/server-manager.sh" server "$SERVER_ID" import-properties >"$output" 2>&1; then if [[ -s "$output" ]]; then diff --git a/libexec/mcserver-kit/tui-session.py b/libexec/mcserver-kit/tui-session.py new file mode 100644 index 0000000..53ea9d5 --- /dev/null +++ b/libexec/mcserver-kit/tui-session.py @@ -0,0 +1,365 @@ +#!/usr/bin/env python3 +"""One terminal session for shell-driven dialogs, including time between dialogs. + +The shell remains the controller. Local socket requests replace short-lived +whiptail processes; curses owns the screen until the controller exits. +""" +from __future__ import annotations + +import curses +import json +import os +from pathlib import Path +import select +import signal +import socket +import subprocess +import sys +import tempfile +import time +import unicodedata + +KINDS = ("menu", "radiolist", "inputbox", "passwordbox", "yesno", "msgbox", "textbox", "infobox") +FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏" + + +def clipped(text: str, width: int) -> str: + result, used = [], 0 + for char in text: + if unicodedata.category(char).startswith("C"): + continue + size = 0 if unicodedata.combining(char) else (2 if unicodedata.east_asian_width(char) in "WF" else 1) + if used + size > width: + break + result.append(char) + used += size + return "".join(result) + + +def lines(text: str, width: int) -> list[str]: + result = [] + for paragraph in text.split("\n"): + if not paragraph: + result.append("") + continue + while paragraph: + part = clipped(paragraph, width) + if not part: + break + result.append(part) + paragraph = paragraph[len(part):] + return result or [""] + + +def parse_dialog(args: list[str]) -> dict: + kind = next((kind for kind in KINDS if "--" + kind in args), None) + if kind is None: + raise ValueError("Unsupported dialog: " + " ".join(args)) + start = args.index("--" + kind) + prompt = args[start + 1] + if kind == "textbox": + prompt = Path(prompt).read_text(encoding="utf-8", errors="replace") + remaining = args[start + (5 if kind in ("menu", "radiolist") else 4):] + items = [] + if kind in ("menu", "radiolist"): + stride = 3 if kind == "radiolist" else 2 + if len(remaining) % stride: + raise ValueError("Invalid menu items") + items = [remaining[i:i + stride] for i in range(0, len(remaining), stride)] + title = args[args.index("--title") + 1] if "--title" in args else "mcserver-kit" + selected = next((i for i, item in enumerate(items) if len(item) == 3 and item[2] == "ON"), 0) + return dict(kind=kind, title=title, prompt=prompt, items=items, selected=selected, + value=remaining[0] if kind in ("inputbox", "passwordbox") and remaining else "") + + +def translations(language: str) -> dict: + root = Path(__file__).resolve().parents[2] + share = Path(os.environ.get("MCSERVER_KIT_SHARE_DIR", root / "share/mcserver-kit")) + messages = json.loads((share / "locales/en.json").read_text(encoding="utf-8")) + if language.replace("-", "").replace("_", "").isalnum(): + path = share / "locales" / (language + ".json") + if path.is_file(): + messages.update(json.loads(path.read_text(encoding="utf-8"))) + return messages + + +def startup_language() -> str: + requested = os.environ.get("MCSERVER_KIT_LANG") or os.environ.get("MCSERVER_KIT_ACTIVE_LANG") + if not requested: + path = Path(os.environ.get("MCSERVER_KIT_LANGUAGE_FILE", + str(Path.home() / ".config/mcserver-compose-kit/language"))) + try: + requested = path.read_text(encoding="utf-8").splitlines()[0] + except (OSError, IndexError, UnicodeError): + requested = "en" + return requested.split("_", 1)[0].split("-", 1)[0] + + +def client(args: list[str]) -> int: + with socket.socket(socket.AF_UNIX) as connection: + connection.connect(os.environ["MCSERVER_KIT_TUI_SOCKET"]) + request = {"args": args, "language": os.environ.get("MCSERVER_KIT_ACTIVE_LANG", "en")} + connection.sendall(json.dumps(request).encode() + b"\n") + reply = bytearray() + while not reply.endswith(b"\n"): + block = connection.recv(65536) + if not block: + raise RuntimeError("TUI session closed") + reply.extend(block) + response = json.loads(reply) + # Match whiptail's output contract for the existing shell redirections. + sys.stderr.write(response.get("value", "")) + return response["status"] + + +class Session: + def __init__(self, screen, listener, process): + self.screen, self.listener, self.process = screen, listener, process + self.connection = None + self.dialog = None + self.suspended = False + self.messages = translations(startup_language()) + self.title = "mcserver-kit" + self.index = self.scroll = self.focus = self.cursor = 0 + self.value = "" + self.frame = 0 + self.error_status = 0 + self.sigint_handler = signal.getsignal(signal.SIGINT) + curses.set_escdelay(25) + self.screen.timeout(100) + self.screen.keypad(True) + curses.curs_set(0) + if curses.has_colors(): + curses.start_color() + curses.use_default_colors() + curses.init_pair(1, curses.COLOR_WHITE, curses.COLOR_BLUE) + + def message(self, key): + return self.messages["tui.session." + key] + + def reply(self, status=0, value=""): + if self.connection: + try: + self.connection.sendall(json.dumps({"status": status, "value": value}).encode() + b"\n") + except (BrokenPipeError, ConnectionResetError): + pass + self.connection.close() + self.connection = None + self.dialog = None + self.focus = 0 + self.error_status = 0 + + def accept(self): + connection, _ = self.listener.accept() + connection.settimeout(2) + data = bytearray() + try: + while not data.endswith(b"\n"): + block = connection.recv(65536) + if not block: + raise ValueError("Incomplete dialog request") + data.extend(block) + if len(data) > 1024 * 1024: + raise ValueError("Dialog request too large") + request = json.loads(data) + self.connection = connection + self.messages = translations(request.get("language", "en")) + args = request["args"] + if args == ["--suspend"]: + # Legacy line-oriented create/log screens keep this alternate + # screen, but temporarily own terminal input and output. + self.suspended = True + signal.signal(signal.SIGINT, signal.SIG_IGN) + curses.nocbreak() + curses.echo() + curses.curs_set(1) + self.reply() + return + if args == ["--resume"]: + self.suspended = False + signal.signal(signal.SIGINT, self.sigint_handler) + curses.noecho() + curses.cbreak() + curses.curs_set(0) + self.screen.touchwin() + self.reply() + return + self.dialog = parse_dialog(args) + self.title = self.dialog["title"] + self.index = self.dialog["selected"] + self.value = self.dialog["value"] + self.cursor = len(self.value) + self.scroll = self.focus = 0 + if self.dialog["kind"] == "infobox": + self.reply() + except (OSError, ValueError, KeyError, IndexError) as error: + self.connection = connection + self.error_status = 2 + self.title = self.messages.get("common.error", "Error") + self.dialog = dict(kind="msgbox", prompt=str(error), items=[]) + self.scroll = self.focus = 0 + finally: + if connection.fileno() != -1: + connection.settimeout(None) + + def put(self, row, column, text, attr=0): + height, width = self.screen.getmaxyx() + if 0 <= row < height - 1 and 0 <= column < width - 1: + try: + self.screen.addstr(row, column, clipped(text, width - column - 1), attr) + except curses.error: + pass + + def draw(self): + height, width = self.screen.getmaxyx() + # erase + refresh lets curses diff frames; never clear/reset the terminal. + self.screen.erase() + if height < 10 or width < 35: + self.put(0, 0, self.message("small")) + self.screen.refresh() + return + self.screen.box() + self.put(1, 3, self.title, curses.A_BOLD) + if self.dialog is None: + self.put(height // 2, 4, FRAMES[self.frame % len(FRAMES)] + " " + self.message("loading")) + self.screen.refresh() + return + dialog = self.dialog + kind = dialog["kind"] + content = lines(dialog["prompt"], width - 8) + limit = min(len(content), max(3, height // 3)) if kind in ("menu", "radiolist", "inputbox", "passwordbox") else height - 7 + if kind in ("textbox", "msgbox", "yesno"): + content = content[self.scroll:] + for number, text in enumerate(content[:limit]): + self.put(3 + number, 4, text) + row = 4 + min(limit, len(content)) + selected_attr = curses.color_pair(1) if curses.has_colors() else curses.A_REVERSE + if kind in ("menu", "radiolist"): + visible = max(1, height - row - 4) + first = max(0, self.index - visible + 1) + for number, item in enumerate(dialog["items"][first:first + visible]): + position = first + number + self.put(row + number, 4, f"{item[0]} {item[1]}", + selected_attr if position == self.index else 0) + elif kind in ("inputbox", "passwordbox"): + visible = self.value if kind == "inputbox" else "*" * len(self.value) + # Keep the insertion point visible for long input, including Japanese. + prefix = visible[:self.cursor] + while len(clipped(prefix, width - 12)) < len(prefix): + prefix = prefix[1:] + start = self.cursor - len(prefix) + entry = visible[start:self.cursor] + "│" + visible[self.cursor:] + self.put(row, 4, "[" + entry + "]", selected_attr if self.focus == 0 else 0) + self.put(height - 3, 4, self.message("ok"), selected_attr if not self.focus else 0) + if kind not in ("msgbox", "textbox"): + self.put(height - 3, width // 2, self.message("cancel"), selected_attr if self.focus else 0) + self.put(height - 2, 3, self.message("hint")) + self.screen.refresh() + + def key(self, key): + if self.dialog is None: + return # Do not queue keys pressed during work into the next dialog. + dialog = self.dialog + kind = dialog["kind"] + if key == "\x1b": + self.reply(1) + elif key == "\t": + self.focus = 1 - self.focus if kind not in ("textbox", "msgbox") else 0 + elif key in ("\n", "\r", curses.KEY_ENTER): + if self.focus: + self.reply(1) + else: + value = self.value if kind in ("inputbox", "passwordbox") else "" + if kind in ("menu", "radiolist") and dialog["items"]: + value = dialog["items"][self.index][0] + self.reply(self.error_status, value) + elif kind in ("menu", "radiolist"): + if key in (curses.KEY_UP, curses.KEY_DOWN): + self.index = max(0, min(len(dialog["items"]) - 1, + self.index + (1 if key == curses.KEY_DOWN else -1))) + elif key in (curses.KEY_LEFT, curses.KEY_RIGHT): + self.focus = 1 - self.focus + elif kind in ("inputbox", "passwordbox") and not self.focus: + if key in (curses.KEY_BACKSPACE, "\x7f", "\b"): + self.value = self.value[:max(0, self.cursor - 1)] + self.value[self.cursor:] + self.cursor = max(0, self.cursor - 1) + elif key == curses.KEY_DC: + self.value = self.value[:self.cursor] + self.value[self.cursor + 1:] + elif key == curses.KEY_LEFT: + self.cursor = max(0, self.cursor - 1) + elif key == curses.KEY_RIGHT: + self.cursor = min(len(self.value), self.cursor + 1) + elif key == curses.KEY_HOME: + self.cursor = 0 + elif key == curses.KEY_END: + self.cursor = len(self.value) + elif isinstance(key, str) and key.isprintable(): + self.value = self.value[:self.cursor] + key + self.value[self.cursor:] + self.cursor += len(key) + elif key in (curses.KEY_LEFT, curses.KEY_RIGHT) and kind == "yesno": + self.focus = 1 - self.focus + elif key in (curses.KEY_DOWN, curses.KEY_NPAGE, curses.KEY_UP, curses.KEY_PPAGE): + total = len(lines(dialog["prompt"], max(1, self.screen.getmaxyx()[1] - 8))) + delta = 10 if key in (curses.KEY_NPAGE, curses.KEY_PPAGE) else 1 + self.scroll = max(0, min(total - 1, self.scroll + (delta if key in (curses.KEY_DOWN, curses.KEY_NPAGE) else -delta))) + + def run(self): + while self.process.poll() is None: + if self.connection is None and select.select([self.listener], [], [], 0)[0]: + self.accept() + if self.suspended: + time.sleep(0.05) + continue + self.frame += 1 + self.draw() + try: + self.key(self.screen.get_wch()) + except curses.error: + pass + self.reply(1) + return self.process.returncode + + +def run_session(command): + with tempfile.TemporaryDirectory(prefix="mcserver-kit-ui-") as directory: + endpoint = str(Path(directory) / "session.sock") + with socket.socket(socket.AF_UNIX) as listener: + listener.bind(endpoint) + listener.listen() + environment = dict(os.environ, MCSERVER_KIT_TUI_SOCKET=endpoint) + # The screen is initialized before the controller can issue requests. + def start(screen): + process = subprocess.Popen(command, env=environment) + try: + return Session(screen, listener, process).run() + finally: + if process.poll() is None: + process.terminate() + try: + process.wait(timeout=3) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + previous = signal.getsignal(signal.SIGTERM) + previous_int = signal.getsignal(signal.SIGINT) + def terminate(signum, _frame): + raise SystemExit(128 + signum) + signal.signal(signal.SIGTERM, terminate) + try: + return curses.wrapper(start) + finally: + signal.signal(signal.SIGTERM, previous) + signal.signal(signal.SIGINT, previous_int) + + +def main(): + if sys.argv[1] == "client": + return client(sys.argv[2:]) + try: + return run_session(sys.argv[2:]) + except KeyboardInterrupt: + return 130 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/share/mcserver-kit/locales/en.json b/share/mcserver-kit/locales/en.json index 5bcd815..d82de40 100644 --- a/share/mcserver-kit/locales/en.json +++ b/share/mcserver-kit/locales/en.json @@ -1,4 +1,9 @@ { + "tui.session.loading": "Loading / processing…", + "tui.session.hint": "↑↓ Select / scroll · Tab Switch button · Enter Confirm / return · Esc Cancel", + "tui.session.ok": "< OK >", + "tui.session.cancel": "< Cancel >", + "tui.session.small": "Enlarge the terminal (at least 35 × 10).", "cli.description": "Minecraft Java distribution-world server toolkit", "cli.usage": "Usage:", "cli.commands": "Commands:", diff --git a/share/mcserver-kit/locales/ja.json b/share/mcserver-kit/locales/ja.json index 887f652..ef7e509 100644 --- a/share/mcserver-kit/locales/ja.json +++ b/share/mcserver-kit/locales/ja.json @@ -1,4 +1,9 @@ { + "tui.session.loading": "読み込み・処理中…", + "tui.session.hint": "↑↓ 選択・スクロール · Tab ボタン切替 · Enter 決定・戻る · Esc キャンセル", + "tui.session.ok": "< 決定 >", + "tui.session.cancel": "< キャンセル >", + "tui.session.small": "端末を広げてください(最低35列×10行)。", "cli.description": "Minecraft Java配布ワールド用サーバー作成ツール", "cli.usage": "使い方:", "cli.commands": "コマンド:", diff --git a/tests/run-tests.sh b/tests/run-tests.sh index 2833167..3730ffe 100755 --- a/tests/run-tests.sh +++ b/tests/run-tests.sh @@ -950,6 +950,7 @@ WHIPTAIL } main() { + PYTHONDONTWRITEBYTECODE=1 python3 "${REPO_ROOT}/tests/test-tui-session.py" TEST_TEMP_DIR="$(mktemp -d)" trap cleanup EXIT diff --git a/tests/test-tui-session.py b/tests/test-tui-session.py new file mode 100644 index 0000000..dd7ceae --- /dev/null +++ b/tests/test-tui-session.py @@ -0,0 +1,226 @@ +"""Persistent terminal session regressions; no Docker or user server changes.""" +import curses +import fcntl +import importlib.util +import json +import os +from pathlib import Path +import pty +import select +import struct +import subprocess +import sys +import tempfile +import termios +import time +import unittest +from unittest.mock import Mock, patch + +ROOT = Path(__file__).resolve().parents[1] +TOOL = ROOT / "libexec/mcserver-kit/tui-session.py" +spec = importlib.util.spec_from_file_location("tui_session", TOOL) +ui = importlib.util.module_from_spec(spec) +spec.loader.exec_module(ui) + + +class DialogTests(unittest.TestCase): + def test_menu_and_radio(self): + menu = ui.parse_dialog(["--title", "Test", "--menu", "Choose", "20", "70", "10", "a", "Alpha", "b", "Beta"]) + self.assertEqual(menu["items"], [["a", "Alpha"], ["b", "Beta"]]) + radio = ui.parse_dialog(["--radiolist", "Choose", "12", "70", "2", "en", "English", "OFF", "ja", "日本語", "ON"]) + self.assertEqual(radio["selected"], 1) + + def test_input_and_unsupported_options(self): + self.assertEqual(ui.parse_dialog(["--inputbox", "Value", "10", "70", "日本語"])["value"], "日本語") + with self.assertRaises(ValueError): + ui.parse_dialog(["--unsupported"]) + with self.assertRaises(ValueError): + ui.parse_dialog(["--menu", "Choose", "20", "70", "10", "missing label"]) + + def test_display_width_and_blank_lines(self): + self.assertEqual(ui.clipped("日本abc", 5), "日本a") + self.assertEqual(ui.lines("a\n\nb", 10), ["a", "", "b"]) + self.assertEqual(ui.clipped("\x1btest", 20), "test") + + def test_japanese_backspace_and_cursor(self): + session = object.__new__(ui.Session) + session.dialog = {"kind": "inputbox"} + session.value = "日本語" + session.cursor = 3 + session.focus = 0 + session.key(curses.KEY_BACKSPACE) + session.key("文") + self.assertEqual(session.value, "日本文") + session.key(curses.KEY_LEFT) + session.key("新") + self.assertEqual(session.value, "日本新文") + + def test_cancel_does_not_confirm(self): + session = object.__new__(ui.Session) + session.dialog = {"kind": "yesno"} + session.focus = 0 + session.reply = Mock() + session.key("\t") + session.key("\n") + session.reply.assert_called_once_with(1) + + def test_loading_discards_keys(self): + session = object.__new__(ui.Session) + session.dialog = None + session.reply = Mock() + session.key("\n") + session.reply.assert_not_called() + + def test_initial_loading_uses_selected_language(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "language" + path.write_text("ja\n") + with patch.dict(os.environ, {"MCSERVER_KIT_LANGUAGE_FILE": str(path)}, clear=True): + self.assertEqual(ui.startup_language(), "ja") + os.environ["MCSERVER_KIT_LANG"] = "en_US" + self.assertEqual(ui.startup_language(), "en") + + +class TerminalTests(unittest.TestCase): + def run_terminal(self, body, interact, expected_status=0): + with tempfile.TemporaryDirectory() as temp: + workspace = Path(temp) + script = workspace / "controller.sh" + script.write_text( + 'set -eu\nsource "' + str(ROOT / "libexec/mcserver-kit/i18n.sh") + '"\nload_messages\n' + body, + encoding="utf-8", + ) + master, slave = pty.openpty() + fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", 32, 100, 0, 0)) + environment = dict(os.environ, TERM="xterm-256color", MCSERVER_KIT_LANG="en", + MCSERVER_KIT_ACTIVE_LANG="en", TEST_UI_DIR=temp, + PYTHONDONTWRITEBYTECODE="1") + process = subprocess.Popen([sys.executable, str(TOOL), "run", "bash", str(script)], + stdin=slave, stdout=slave, stderr=slave, env=environment) + os.close(slave) + output = bytearray() + + def receive(marker=None, duration=5): + deadline = time.monotonic() + duration + while time.monotonic() < deadline: + if select.select([master], [], [], 0.05)[0]: + try: + chunk = os.read(master, 65536) + except OSError: + break + if not chunk: + break + output.extend(chunk) + if marker is not None and marker in output: + return + if marker is not None: + self.assertIn(marker, output, output.decode(errors="replace")) + + try: + interact(master, workspace, output, receive) + receive(duration=0.5) + process.wait(timeout=5) + receive(duration=0.2) + self.assertEqual(process.returncode, expected_status, output.decode(errors="replace")) + self.assertEqual(output.count(b"\x1b[?1049h"), 1, "enter alternate screen once") + self.assertEqual(output.count(b"\x1b[?1049l"), 1, "leave only at final exit") + self.assertEqual(output.count(b"\x1b[2J"), 1, "do not clear the whole screen between dialogs") + finally: + if process.poll() is None: + process.kill() + process.wait() + os.close(master) + + def test_menu_wait_result_and_cancel_keep_screen(self): + body = """ +choice="$(whiptail --title FIRST --menu Choose 20 70 2 one First two Second 3>&1 1>&2 2>&3)" +printf '%s' "$choice" >"$TEST_UI_DIR/choice" +sleep 0.7 +whiptail --title RESULT --msgbox Finished 10 70 +if whiptail --title CANCEL --yesno Confirm 10 70; then exit 7; fi +""" + def interact(master, workspace, output, receive): + receive(b"FIRST") + os.write(master, b"\r") + receive(b"Loading") + self.assertNotIn(b"\x1b[?1049l", output) + receive(b"RESULT") + self.assertEqual((workspace / "choice").read_text(), "one") + self.assertNotIn(b"\x1b[?1049l", output) + os.write(master, b"\r") + receive(b"CANCEL") + os.write(master, b"\t\r") + self.run_terminal(body, interact) + + def test_unicode_input_and_line_mode_handoff(self): + body = """ +value="$(whiptail --title INPUT --inputbox Value 10 70 3>&1 1>&2 2>&3)" +printf '%s' "$value" >"$TEST_UI_DIR/value" +tui_terminal_suspend +printf '\nRAW_INPUT_READY\n' +read -r raw +printf '%s' "$raw" >"$TEST_UI_DIR/raw" +tui_terminal_resume +whiptail --title RETURNED --msgbox Finished 10 70 +""" + def interact(master, workspace, output, receive): + receive(b"INPUT") + os.write(master, "日本語".encode() + b"\x7f" + "文".encode() + b"\r") + receive(b"RAW_INPUT_READY") + self.assertEqual((workspace / "value").read_text(), "日本文") + self.assertNotIn(b"\x1b[?1049l", output) + os.write(master, b"hello\n") + receive(b"RETURNED") + self.assertEqual((workspace / "raw").read_text(), "hello") + os.write(master, b"\r") + self.run_terminal(body, interact) + + def test_slow_home_list_and_server_return(self): + body = """ +mkdir -p "$TEST_UI_DIR/servers/demo" "$TEST_UI_DIR/bin" "$TEST_UI_DIR/cache" +printf 'services: {}\\n' >"$TEST_UI_DIR/servers/demo/compose.yaml" +printf 'paths:\\n server_root: "%s/servers"\\n' "$TEST_UI_DIR" >"$TEST_UI_DIR/config.yml" +printf '%s\\nv1.1.3\\n' "$(date +%s)" >"$TEST_UI_DIR/cache/update-check" +printf '#!/bin/sh\\nsleep 0.2\\necho "[]"\\n' >"$TEST_UI_DIR/bin/docker" +chmod +x "$TEST_UI_DIR/bin/docker" +export PATH="$TEST_UI_DIR/bin:$PATH" +export MCSERVER_KIT_CONFIG="$TEST_UI_DIR/config.yml" +export MCSERVER_KIT_UPDATE_CACHE_DIR="$TEST_UI_DIR/cache" +exec bash """ + str(ROOT / "libexec/mcserver-kit/home-tui.sh") + """ +""" + def interact(master, workspace, output, receive): + receive(b"Choose what you want", duration=8) + os.write(master, b"\r") # Home -> server list + receive(b"Select a server", duration=8) + os.write(master, b"\r") # List -> demo + receive(b"Current status", duration=8) + before = len(output) + os.write(master, b"\x1b") # Cancel -> list, slow state retrieval + receive(duration=0.6) + self.assertNotIn(b"\x1b[?1049l", output) + self.assertIn(b"Loading", output[before:]) + receive(duration=1) + os.write(master, b"\x1b") # Cancel -> home + receive(duration=2) + os.write(master, b"\x1b") # Exit home + self.run_terminal(body, interact) + + def test_password_hidden_and_failure_restores_terminal(self): + body = """ +value="$(whiptail --title SECRET --passwordbox Password 10 70 3>&1 1>&2 2>&3)" +printf '%s' "$value" >"$TEST_UI_DIR/secret" +whiptail --title FAILED --msgbox "Command failed" 10 70 +exit 7 +""" + def interact(master, workspace, output, receive): + receive(b"SECRET") + os.write(master, b"not-for-display\r") + receive(b"FAILED") + self.assertEqual((workspace / "secret").read_text(), "not-for-display") + self.assertNotIn(b"not-for-display", output) + os.write(master, b"\r") + self.run_terminal(body, interact, expected_status=7) + + +if __name__ == "__main__": + unittest.main() From 1bd6f8ad15cf53fb916c11db64cc49f408bb899c Mon Sep 17 00:00:00 2001 From: cotore-game <102813037+cotore-game@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:36:16 +0900 Subject: [PATCH 2/5] chore: prepare v1.1.4 TUI hotfix --- README-JA.md | 6 +++++- README.md | 6 +++++- VERSION | 2 +- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/README-JA.md b/README-JA.md index e58b51b..01cab8f 100644 --- a/README-JA.md +++ b/README-JA.md @@ -47,7 +47,7 @@ curl -fsSL https://raw.githubusercontent.com/cotore-game/mcserver-compose-kit/ma ```bash curl -fsSL https://raw.githubusercontent.com/cotore-game/mcserver-compose-kit/main/install.sh | \ - bash -s -- --version v1.1.3 + bash -s -- --version v1.1.4 ``` インストーラーはリリースの圧縮ファイルを取得し、SHA-256を検証して`~/.local/share/mcserver-compose-kit`へ配置します。また、`~/.local/bin`用のPATH設定を管理ブロックとして`~/.bashrc`へ追加します。 @@ -93,6 +93,10 @@ mcserver-kit ホーム画面から、サーバー作成・管理、全体設定、MCIDテンプレート、言語、動作環境診断、ヘルプへ進めます。矢印キーで選択し、Enterで決定します。 +メニュー間では同じ端末画面を維持します。Dockerの状態取得や次の画面の読み込み中も枠を残して点字スピナーを表示し、進捗率やプログレスバーは表示しません。Tabで決定・キャンセルを切り替え、Escで前のメニューへ戻ります(ホームでは終了)。処理結果は閉じるまで表示され、矢印キーやPage Up/Downでスクロールできます。 + +新規作成と追尾ログは、同じ端末セッション内で従来の行単位の画面を使います。表示された案内に従ってメニューへ戻ってください。常駐描画にはUbuntuのPythonに含まれる標準ライブラリのcursesを使用し、pipパッケージの追加は不要です。 + インストールされているツールのバージョンは次で確認できます。 ```bash diff --git a/README.md b/README.md index 73a85f9..b942aff 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ Install a specific release: ```bash curl -fsSL https://raw.githubusercontent.com/cotore-game/mcserver-compose-kit/main/install.sh | \ - bash -s -- --version v1.1.3 + bash -s -- --version v1.1.4 ``` The installer downloads the release archive, verifies its SHA-256 checksum, and installs the program under `~/.local/share/mcserver-compose-kit`. It also adds a managed PATH block for `~/.local/bin` to `~/.bashrc`. @@ -92,6 +92,10 @@ mcserver-kit The dashboard includes server creation and management, global settings, MCID templates, language selection, diagnostics, and help. Use the arrow keys to select an item and Enter to open it. +The TUI keeps one terminal session open between menus. While Docker state or the next screen is loading, the frame stays visible with a braille spinner; no percentage or progress bar is shown. Tab switches between OK and Cancel, and Esc returns to the previous menu (or exits at the home screen). Results stay open until dismissed; use the arrow keys or Page Up/Down to scroll. + +Server creation and live logs still use their line-oriented screens inside the same terminal session. Follow their return instructions to get back to the menus. The persistent renderer uses Python's standard-library curses module, available with Ubuntu's Python installation; no pip packages are needed. + Check the installed toolkit version with: ```bash diff --git a/VERSION b/VERSION index 781dcb0..65087b4 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.3 +1.1.4 From 4f0b70fc818207b0dd2fce900721823b88764da0 Mon Sep 17 00:00:00 2001 From: cotore-game <102813037+cotore-game@users.noreply.github.com> Date: Wed, 30 Sep 2026 00:11:27 +0900 Subject: [PATCH 3/5] revert: move persistent TUI redesign out of v1.1.4 --- README-JA.md | 6 +- README.md | 6 +- VERSION | 2 +- libexec/mcserver-kit/config-tui.sh | 17 +- libexec/mcserver-kit/home-tui.sh | 28 +- libexec/mcserver-kit/i18n.sh | 18 - libexec/mcserver-kit/server-properties-tui.sh | 21 +- libexec/mcserver-kit/tui-session.py | 365 ------------------ share/mcserver-kit/locales/en.json | 5 - share/mcserver-kit/locales/ja.json | 5 - tests/run-tests.sh | 1 - tests/test-tui-session.py | 226 ----------- 12 files changed, 33 insertions(+), 667 deletions(-) delete mode 100644 libexec/mcserver-kit/tui-session.py delete mode 100644 tests/test-tui-session.py diff --git a/README-JA.md b/README-JA.md index 01cab8f..e58b51b 100644 --- a/README-JA.md +++ b/README-JA.md @@ -47,7 +47,7 @@ curl -fsSL https://raw.githubusercontent.com/cotore-game/mcserver-compose-kit/ma ```bash curl -fsSL https://raw.githubusercontent.com/cotore-game/mcserver-compose-kit/main/install.sh | \ - bash -s -- --version v1.1.4 + bash -s -- --version v1.1.3 ``` インストーラーはリリースの圧縮ファイルを取得し、SHA-256を検証して`~/.local/share/mcserver-compose-kit`へ配置します。また、`~/.local/bin`用のPATH設定を管理ブロックとして`~/.bashrc`へ追加します。 @@ -93,10 +93,6 @@ mcserver-kit ホーム画面から、サーバー作成・管理、全体設定、MCIDテンプレート、言語、動作環境診断、ヘルプへ進めます。矢印キーで選択し、Enterで決定します。 -メニュー間では同じ端末画面を維持します。Dockerの状態取得や次の画面の読み込み中も枠を残して点字スピナーを表示し、進捗率やプログレスバーは表示しません。Tabで決定・キャンセルを切り替え、Escで前のメニューへ戻ります(ホームでは終了)。処理結果は閉じるまで表示され、矢印キーやPage Up/Downでスクロールできます。 - -新規作成と追尾ログは、同じ端末セッション内で従来の行単位の画面を使います。表示された案内に従ってメニューへ戻ってください。常駐描画にはUbuntuのPythonに含まれる標準ライブラリのcursesを使用し、pipパッケージの追加は不要です。 - インストールされているツールのバージョンは次で確認できます。 ```bash diff --git a/README.md b/README.md index b942aff..73a85f9 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ Install a specific release: ```bash curl -fsSL https://raw.githubusercontent.com/cotore-game/mcserver-compose-kit/main/install.sh | \ - bash -s -- --version v1.1.4 + bash -s -- --version v1.1.3 ``` The installer downloads the release archive, verifies its SHA-256 checksum, and installs the program under `~/.local/share/mcserver-compose-kit`. It also adds a managed PATH block for `~/.local/bin` to `~/.bashrc`. @@ -92,10 +92,6 @@ mcserver-kit The dashboard includes server creation and management, global settings, MCID templates, language selection, diagnostics, and help. Use the arrow keys to select an item and Enter to open it. -The TUI keeps one terminal session open between menus. While Docker state or the next screen is loading, the frame stays visible with a braille spinner; no percentage or progress bar is shown. Tab switches between OK and Cancel, and Esc returns to the previous menu (or exits at the home screen). Results stay open until dismissed; use the arrow keys or Page Up/Down to scroll. - -Server creation and live logs still use their line-oriented screens inside the same terminal session. Follow their return instructions to get back to the menus. The persistent renderer uses Python's standard-library curses module, available with Ubuntu's Python installation; no pip packages are needed. - Check the installed toolkit version with: ```bash diff --git a/VERSION b/VERSION index 65087b4..781dcb0 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.4 +1.1.3 diff --git a/libexec/mcserver-kit/config-tui.sh b/libexec/mcserver-kit/config-tui.sh index be55e68..1ac46b7 100755 --- a/libexec/mcserver-kit/config-tui.sh +++ b/libexec/mcserver-kit/config-tui.sh @@ -4,9 +4,6 @@ set -Eeuo pipefail SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" -if [[ -z "${MCSERVER_KIT_TUI_SOCKET:-}" && -t 0 && -t 1 ]]; then - exec python3 "${SCRIPT_DIR}/tui-session.py" run bash "${BASH_SOURCE[0]}" "$@" -fi CONFIG_FILE="${MCSERVER_KIT_CONFIG:-${HOME}/.config/mcserver-compose-kit/config.yml}" TEMPLATE_DIR="${MCSERVER_KIT_MCID_TEMPLATE_DIR:-${HOME}/.config/mcserver-compose-kit/mcid-templates}" CONFIG_VALUE="${SCRIPT_DIR}/config-value.py" @@ -60,7 +57,7 @@ edit_owner() { local value current="$(python3 "$CONFIG_VALUE" get "$CONFIG_FILE" owner minecraft_id)" while true; do - value="$(whiptail --title "$(tr tui.owner_title)" --inputbox "$(tr tui.owner_prompt)" 10 60 "$current" 3>&1 1>&2 2>&3)" || return 0 + value="$(whiptail --title "$(tr tui.owner_title)" --inputbox "$(tr tui.owner_prompt)" 10 60 "$current" 3>&1 1>&2 2>&3)" || return if [[ "$value" =~ ^[A-Za-z0-9_]{3,16}$ ]]; then python3 "$CONFIG_VALUE" set "$CONFIG_FILE" owner minecraft_id "$value" whiptail --msgbox "$(tr tui.owner_saved "$value")" 8 60 @@ -82,7 +79,7 @@ template_references() { create_template() { local name while true; do - name="$(whiptail --title "$(tr tui.template_create)" --inputbox "$(tr tui.template_name)" 10 60 3>&1 1>&2 2>&3)" || return 0 + name="$(whiptail --title "$(tr tui.template_create)" --inputbox "$(tr tui.template_name)" 10 60 3>&1 1>&2 2>&3)" || return if [[ ! "$name" =~ ^[A-Za-z0-9_-]+$ ]]; then whiptail --msgbox "$(tr tui.template_name_invalid)" 8 60 continue @@ -101,7 +98,7 @@ add_mcid() { local path="$1" local value while true; do - value="$(whiptail --title "$(tr tui.mcid_add)" --inputbox "$(tr tui.mcid_prompt)" 10 60 3>&1 1>&2 2>&3)" || return 0 + value="$(whiptail --title "$(tr tui.mcid_add)" --inputbox "$(tr tui.mcid_prompt)" 10 60 3>&1 1>&2 2>&3)" || return if [[ ! "$value" =~ ^[A-Za-z0-9_]{3,16}$ ]]; then whiptail --msgbox "$(tr input.mcid_rules)" 8 60 continue @@ -130,7 +127,7 @@ remove_mcid() { for index in "${!lines[@]}"; do items+=("$((index + 1))" "${lines[$index]}") done - selected="$(dialog_menu "$(tr tui.mcid_remove)" "$(tr tui.select_mcid)" "${items[@]}")" || return 0 + selected="$(dialog_menu "$(tr tui.mcid_remove)" "$(tr tui.select_mcid)" "${items[@]}")" || return temporary="$(mktemp "${path}.XXXXXX")" awk -v remove="$selected" 'NR != remove' "$path" >"$temporary" chmod 600 "$temporary" @@ -159,7 +156,7 @@ manage_template() { add "$(tr tui.mcid_add)" \ remove "$(tr tui.mcid_remove)" \ delete "$(tr tui.delete)" \ - back "$(tr tui.back)")" || return 0 + back "$(tr tui.back)")" || return case "$action" in view) whiptail --title "$name" --textbox "$path" 20 70 ;; add) add_mcid "$path" ;; @@ -184,7 +181,7 @@ manage_templates() { done shopt -u nullglob items+=(__create "$(tr tui.template_create)" __back "$(tr tui.back)") - choice="$(dialog_menu "$(tr tui.templates_title)" "$(tr tui.select_template)" "${items[@]}")" || return 0 + choice="$(dialog_menu "$(tr tui.templates_title)" "$(tr tui.select_template)" "${items[@]}")" || return case "$choice" in __create) create_template ;; __back) return ;; @@ -199,7 +196,7 @@ main_menu() { choice="$(dialog_menu "Minecraft Server Kit" "$(tr tui.main_prompt)" \ owner "$(tr tui.owner_menu)" \ templates "$(tr tui.templates_menu)" \ - exit "$(tr tui.exit)")" || return 0 + exit "$(tr tui.exit)")" || return case "$choice" in owner) edit_owner ;; templates) manage_templates ;; diff --git a/libexec/mcserver-kit/home-tui.sh b/libexec/mcserver-kit/home-tui.sh index 7096394..d88a8e9 100755 --- a/libexec/mcserver-kit/home-tui.sh +++ b/libexec/mcserver-kit/home-tui.sh @@ -4,9 +4,6 @@ set -Eeuo pipefail SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" -if [[ -z "${MCSERVER_KIT_TUI_SOCKET:-}" && -t 0 && -t 1 ]]; then - exec python3 "${SCRIPT_DIR}/tui-session.py" run bash "${BASH_SOURCE[0]}" "$@" -fi CONFIG_FILE="${MCSERVER_KIT_CONFIG:-${HOME}/.config/mcserver-compose-kit/config.yml}" ROOT_DIR="${MCSERVER_KIT_ROOT:-$(cd -- "${SCRIPT_DIR}/../.." && pwd)}" CONFIG_VALUE="${SCRIPT_DIR}/config-value.py" @@ -122,10 +119,17 @@ pause_for_enter() { run_and_show() { local title="$1" shift - local output result=0 + local output pid result=0 frame_index=0 + local frames=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏') new_temp_file output - whiptail --title "$title" --infobox "$(tr home.processing '⠋' "$title")" 8 72 - "$@" >"$output" 2>&1 || result=$? + "$@" >"$output" 2>&1 & + pid=$! + while kill -0 "$pid" 2>/dev/null; do + whiptail --title "$title" --infobox "$(tr home.processing "${frames[frame_index]}" "$title")" 8 72 + frame_index=$(((frame_index + 1) % ${#frames[@]})) + sleep 0.15 + done + wait "$pid" || result=$? if [[ ! -s "$output" ]]; then if ((result == 0)); then tr home.completed >"$output" @@ -155,18 +159,16 @@ server_action_menu() { open-data "$(tr home.open_data)" \ open-server "$(tr home.open_server)" \ back "$(tr tui.back)" \ - 3>&1 1>&2 2>&3)" || return 0 + 3>&1 1>&2 2>&3)" || return case "$choice" in start | stop | restart | status) run_and_show "$(tr "home.${choice}") · $id" "${SCRIPT_DIR}/server-manager.sh" server "$id" "$choice" ;; logs) - tui_terminal_suspend clear printf '%s\n\n' "$(tr home.logs_return_hint)" "${SCRIPT_DIR}/server-manager.sh" server "$id" logs || true pause_for_enter - tui_terminal_resume ;; properties) "${SCRIPT_DIR}/server-manager.sh" server "$id" properties || true @@ -207,7 +209,7 @@ servers_menu() { shopt -u nullglob fi items+=(__back "$(tr tui.back)") - selected="$(whiptail --title "$(tr home.servers)" --menu "$(tr home.select_server)" 23 82 15 "${items[@]}" 3>&1 1>&2 2>&3)" || return 0 + selected="$(whiptail --title "$(tr home.servers)" --menu "$(tr home.select_server)" 23 82 15 "${items[@]}" 3>&1 1>&2 2>&3)" || return [[ "$selected" == __back ]] && return server_action_menu "$selected" "${root}/${selected}" done @@ -219,7 +221,7 @@ language_menu() { selected="$(whiptail --title "$(tr home.language)" --radiolist "$(tr home.language_choose)" 13 66 2 \ en English "$([[ "$current" == en ]] && printf ON || printf OFF)" \ ja '日本語' "$([[ "$current" == ja ]] && printf ON || printf OFF)" \ - 3>&1 1>&2 2>&3)" || return 0 + 3>&1 1>&2 2>&3)" || return "${SCRIPT_DIR}/lang.sh" "--${selected}" >/dev/null I18N_MESSAGES=() load_messages "$selected" @@ -267,15 +269,13 @@ main() { update "$(tr home.update)" \ help "$(tr home.help)" \ exit "$(tr home.exit)" \ - 3>&1 1>&2 2>&3)" || return 0 + 3>&1 1>&2 2>&3)" || return case "$choice" in servers) servers_menu ;; create) - tui_terminal_suspend clear "${SCRIPT_DIR}/create-server.sh" || true pause_for_enter - tui_terminal_resume ;; config) "${SCRIPT_DIR}/config-tui.sh" || true ;; templates) "${SCRIPT_DIR}/config-tui.sh" templates || true ;; diff --git a/libexec/mcserver-kit/i18n.sh b/libexec/mcserver-kit/i18n.sh index df77bc7..6b9c31f 100644 --- a/libexec/mcserver-kit/i18n.sh +++ b/libexec/mcserver-kit/i18n.sh @@ -5,24 +5,6 @@ I18N_SHARE_DIR="${MCSERVER_KIT_SHARE_DIR:-${I18N_ROOT}/share/mcserver-kit}" I18N_LOCALE_DIR="${I18N_SHARE_DIR}/locales" declare -A I18N_MESSAGES=() -# A home session owns the terminal. Dialog subprocesses exchange only data, -# so OK/Cancel never restores the shell while the next screen is being loaded. -if [[ -n "${MCSERVER_KIT_TUI_SOCKET:-}" ]]; then - whiptail() { - python3 "${I18N_ROOT}/libexec/mcserver-kit/tui-session.py" client "$@" - } -fi - -tui_terminal_suspend() { - [[ -z "${MCSERVER_KIT_TUI_SOCKET:-}" ]] || - python3 "${I18N_ROOT}/libexec/mcserver-kit/tui-session.py" client --suspend -} - -tui_terminal_resume() { - [[ -z "${MCSERVER_KIT_TUI_SOCKET:-}" ]] || - python3 "${I18N_ROOT}/libexec/mcserver-kit/tui-session.py" client --resume -} - detect_language() { local requested="${MCSERVER_KIT_LANG:-}" local language_file="${MCSERVER_KIT_LANGUAGE_FILE:-${HOME}/.config/mcserver-compose-kit/language}" diff --git a/libexec/mcserver-kit/server-properties-tui.sh b/libexec/mcserver-kit/server-properties-tui.sh index f803fc9..79052b2 100755 --- a/libexec/mcserver-kit/server-properties-tui.sh +++ b/libexec/mcserver-kit/server-properties-tui.sh @@ -4,9 +4,6 @@ set -Eeuo pipefail SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" -if [[ -z "${MCSERVER_KIT_TUI_SOCKET:-}" && -t 0 && -t 1 ]]; then - exec python3 "${SCRIPT_DIR}/tui-session.py" run bash "${BASH_SOURCE[0]}" "$@" -fi SERVER_ID="${1-}" SERVER_DIR="${2-}" SERVER_ENV="${SERVER_DIR}/server.env" @@ -94,7 +91,7 @@ edit_boolean() { selected="$(whiptail --title "$label" --radiolist "$label" 12 64 2 \ true "$(tr properties.enabled)" "$([[ "${current,,}" == true ]] && printf ON || printf OFF)" \ false "$(tr properties.disabled)" "$([[ "${current,,}" == false ]] && printf ON || printf OFF)" \ - 3>&1 1>&2 2>&3)" || return 0 + 3>&1 1>&2 2>&3)" || return setting_set "$key" "$selected" } @@ -105,14 +102,14 @@ edit_choice() { for value in "$@"; do items+=("$value" "$([[ "$value" == "$current" ]] && tr properties.selected || printf ' ')") done - value="$(whiptail --title "$label" --menu "$label" 17 72 9 "${items[@]}" 3>&1 1>&2 2>&3)" || return 0 + value="$(whiptail --title "$label" --menu "$label" 17 72 9 "${items[@]}" 3>&1 1>&2 2>&3)" || return setting_set "$key" "$value" } edit_number() { local key="$1" label="$2" current="$3" minimum="$4" maximum="$5" value while true; do - value="$(whiptail --title "$label" --inputbox "$label (${minimum}-${maximum})" 10 68 "$current" 3>&1 1>&2 2>&3)" || return 0 + value="$(whiptail --title "$label" --inputbox "$label (${minimum}-${maximum})" 10 68 "$current" 3>&1 1>&2 2>&3)" || return if [[ "$value" =~ ^[0-9]+$ ]] && ((value >= minimum && value <= maximum)); then setting_set "$key" "$value" return @@ -123,7 +120,7 @@ edit_number() { edit_text() { local key="$1" label="$2" current="$3" value - value="$(whiptail --title "$label" --inputbox "$label" 11 76 "$current" 3>&1 1>&2 2>&3)" || return 0 + value="$(whiptail --title "$label" --inputbox "$label" 11 76 "$current" 3>&1 1>&2 2>&3)" || return setting_set "$key" "$value" } @@ -132,7 +129,7 @@ edit_mcid_list() { local entries=() current="$(setting_get "$key" '')" while true; do - value="$(whiptail --title "$label" --inputbox "$(tr properties.mcid_list_hint)" 11 76 "$current" 3>&1 1>&2 2>&3)" || return 0 + value="$(whiptail --title "$label" --inputbox "$(tr properties.mcid_list_hint)" 11 76 "$current" 3>&1 1>&2 2>&3)" || return normalized='' IFS=',' read -ra entries <<<"$value" for entry in "${entries[@]}"; do @@ -157,7 +154,7 @@ edit_url() { local current value current="$(setting_get RESOURCE_PACK '')" while true; do - value="$(whiptail --title "$(tr properties.resource_pack_url)" --inputbox "$(tr properties.resource_pack_url_hint)" 11 76 "$current" 3>&1 1>&2 2>&3)" || return 0 + value="$(whiptail --title "$(tr properties.resource_pack_url)" --inputbox "$(tr properties.resource_pack_url_hint)" 11 76 "$current" 3>&1 1>&2 2>&3)" || return if [[ -z "$value" || "$value" =~ ^https:// ]]; then setting_set RESOURCE_PACK "$value" return @@ -170,7 +167,7 @@ edit_uuid() { local current value current="$(setting_get RESOURCE_PACK_ID '')" while true; do - value="$(whiptail --title "$(tr properties.resource_pack_id)" --inputbox "$(tr properties.resource_pack_id_hint)" 11 76 "$current" 3>&1 1>&2 2>&3)" || return 0 + value="$(whiptail --title "$(tr properties.resource_pack_id)" --inputbox "$(tr properties.resource_pack_id_hint)" 11 76 "$current" 3>&1 1>&2 2>&3)" || return if [[ -z "$value" || "$value" =~ ^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$ ]]; then setting_set RESOURCE_PACK_ID "${value,,}" return @@ -183,7 +180,7 @@ edit_sha1() { local current value current="$(setting_get RESOURCE_PACK_SHA1 '')" while true; do - value="$(whiptail --title "$(tr properties.resource_pack_sha1)" --inputbox "$(tr properties.resource_pack_sha1_hint)" 11 76 "$current" 3>&1 1>&2 2>&3)" || return 0 + value="$(whiptail --title "$(tr properties.resource_pack_sha1)" --inputbox "$(tr properties.resource_pack_sha1_hint)" 11 76 "$current" 3>&1 1>&2 2>&3)" || return if [[ -z "$value" || "$value" =~ ^[0-9a-fA-F]{40}$ ]]; then setting_set RESOURCE_PACK_SHA1 "${value,,}" return @@ -245,7 +242,7 @@ finish() { import_properties() { local output result - whiptail --yesno "$(tr home.import_confirm "$SERVER_ID")" 12 76 || return 0 + whiptail --yesno "$(tr home.import_confirm "$SERVER_ID")" 12 76 || return output="$(mktemp)" if "${SCRIPT_DIR}/server-manager.sh" server "$SERVER_ID" import-properties >"$output" 2>&1; then if [[ -s "$output" ]]; then diff --git a/libexec/mcserver-kit/tui-session.py b/libexec/mcserver-kit/tui-session.py deleted file mode 100644 index 53ea9d5..0000000 --- a/libexec/mcserver-kit/tui-session.py +++ /dev/null @@ -1,365 +0,0 @@ -#!/usr/bin/env python3 -"""One terminal session for shell-driven dialogs, including time between dialogs. - -The shell remains the controller. Local socket requests replace short-lived -whiptail processes; curses owns the screen until the controller exits. -""" -from __future__ import annotations - -import curses -import json -import os -from pathlib import Path -import select -import signal -import socket -import subprocess -import sys -import tempfile -import time -import unicodedata - -KINDS = ("menu", "radiolist", "inputbox", "passwordbox", "yesno", "msgbox", "textbox", "infobox") -FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏" - - -def clipped(text: str, width: int) -> str: - result, used = [], 0 - for char in text: - if unicodedata.category(char).startswith("C"): - continue - size = 0 if unicodedata.combining(char) else (2 if unicodedata.east_asian_width(char) in "WF" else 1) - if used + size > width: - break - result.append(char) - used += size - return "".join(result) - - -def lines(text: str, width: int) -> list[str]: - result = [] - for paragraph in text.split("\n"): - if not paragraph: - result.append("") - continue - while paragraph: - part = clipped(paragraph, width) - if not part: - break - result.append(part) - paragraph = paragraph[len(part):] - return result or [""] - - -def parse_dialog(args: list[str]) -> dict: - kind = next((kind for kind in KINDS if "--" + kind in args), None) - if kind is None: - raise ValueError("Unsupported dialog: " + " ".join(args)) - start = args.index("--" + kind) - prompt = args[start + 1] - if kind == "textbox": - prompt = Path(prompt).read_text(encoding="utf-8", errors="replace") - remaining = args[start + (5 if kind in ("menu", "radiolist") else 4):] - items = [] - if kind in ("menu", "radiolist"): - stride = 3 if kind == "radiolist" else 2 - if len(remaining) % stride: - raise ValueError("Invalid menu items") - items = [remaining[i:i + stride] for i in range(0, len(remaining), stride)] - title = args[args.index("--title") + 1] if "--title" in args else "mcserver-kit" - selected = next((i for i, item in enumerate(items) if len(item) == 3 and item[2] == "ON"), 0) - return dict(kind=kind, title=title, prompt=prompt, items=items, selected=selected, - value=remaining[0] if kind in ("inputbox", "passwordbox") and remaining else "") - - -def translations(language: str) -> dict: - root = Path(__file__).resolve().parents[2] - share = Path(os.environ.get("MCSERVER_KIT_SHARE_DIR", root / "share/mcserver-kit")) - messages = json.loads((share / "locales/en.json").read_text(encoding="utf-8")) - if language.replace("-", "").replace("_", "").isalnum(): - path = share / "locales" / (language + ".json") - if path.is_file(): - messages.update(json.loads(path.read_text(encoding="utf-8"))) - return messages - - -def startup_language() -> str: - requested = os.environ.get("MCSERVER_KIT_LANG") or os.environ.get("MCSERVER_KIT_ACTIVE_LANG") - if not requested: - path = Path(os.environ.get("MCSERVER_KIT_LANGUAGE_FILE", - str(Path.home() / ".config/mcserver-compose-kit/language"))) - try: - requested = path.read_text(encoding="utf-8").splitlines()[0] - except (OSError, IndexError, UnicodeError): - requested = "en" - return requested.split("_", 1)[0].split("-", 1)[0] - - -def client(args: list[str]) -> int: - with socket.socket(socket.AF_UNIX) as connection: - connection.connect(os.environ["MCSERVER_KIT_TUI_SOCKET"]) - request = {"args": args, "language": os.environ.get("MCSERVER_KIT_ACTIVE_LANG", "en")} - connection.sendall(json.dumps(request).encode() + b"\n") - reply = bytearray() - while not reply.endswith(b"\n"): - block = connection.recv(65536) - if not block: - raise RuntimeError("TUI session closed") - reply.extend(block) - response = json.loads(reply) - # Match whiptail's output contract for the existing shell redirections. - sys.stderr.write(response.get("value", "")) - return response["status"] - - -class Session: - def __init__(self, screen, listener, process): - self.screen, self.listener, self.process = screen, listener, process - self.connection = None - self.dialog = None - self.suspended = False - self.messages = translations(startup_language()) - self.title = "mcserver-kit" - self.index = self.scroll = self.focus = self.cursor = 0 - self.value = "" - self.frame = 0 - self.error_status = 0 - self.sigint_handler = signal.getsignal(signal.SIGINT) - curses.set_escdelay(25) - self.screen.timeout(100) - self.screen.keypad(True) - curses.curs_set(0) - if curses.has_colors(): - curses.start_color() - curses.use_default_colors() - curses.init_pair(1, curses.COLOR_WHITE, curses.COLOR_BLUE) - - def message(self, key): - return self.messages["tui.session." + key] - - def reply(self, status=0, value=""): - if self.connection: - try: - self.connection.sendall(json.dumps({"status": status, "value": value}).encode() + b"\n") - except (BrokenPipeError, ConnectionResetError): - pass - self.connection.close() - self.connection = None - self.dialog = None - self.focus = 0 - self.error_status = 0 - - def accept(self): - connection, _ = self.listener.accept() - connection.settimeout(2) - data = bytearray() - try: - while not data.endswith(b"\n"): - block = connection.recv(65536) - if not block: - raise ValueError("Incomplete dialog request") - data.extend(block) - if len(data) > 1024 * 1024: - raise ValueError("Dialog request too large") - request = json.loads(data) - self.connection = connection - self.messages = translations(request.get("language", "en")) - args = request["args"] - if args == ["--suspend"]: - # Legacy line-oriented create/log screens keep this alternate - # screen, but temporarily own terminal input and output. - self.suspended = True - signal.signal(signal.SIGINT, signal.SIG_IGN) - curses.nocbreak() - curses.echo() - curses.curs_set(1) - self.reply() - return - if args == ["--resume"]: - self.suspended = False - signal.signal(signal.SIGINT, self.sigint_handler) - curses.noecho() - curses.cbreak() - curses.curs_set(0) - self.screen.touchwin() - self.reply() - return - self.dialog = parse_dialog(args) - self.title = self.dialog["title"] - self.index = self.dialog["selected"] - self.value = self.dialog["value"] - self.cursor = len(self.value) - self.scroll = self.focus = 0 - if self.dialog["kind"] == "infobox": - self.reply() - except (OSError, ValueError, KeyError, IndexError) as error: - self.connection = connection - self.error_status = 2 - self.title = self.messages.get("common.error", "Error") - self.dialog = dict(kind="msgbox", prompt=str(error), items=[]) - self.scroll = self.focus = 0 - finally: - if connection.fileno() != -1: - connection.settimeout(None) - - def put(self, row, column, text, attr=0): - height, width = self.screen.getmaxyx() - if 0 <= row < height - 1 and 0 <= column < width - 1: - try: - self.screen.addstr(row, column, clipped(text, width - column - 1), attr) - except curses.error: - pass - - def draw(self): - height, width = self.screen.getmaxyx() - # erase + refresh lets curses diff frames; never clear/reset the terminal. - self.screen.erase() - if height < 10 or width < 35: - self.put(0, 0, self.message("small")) - self.screen.refresh() - return - self.screen.box() - self.put(1, 3, self.title, curses.A_BOLD) - if self.dialog is None: - self.put(height // 2, 4, FRAMES[self.frame % len(FRAMES)] + " " + self.message("loading")) - self.screen.refresh() - return - dialog = self.dialog - kind = dialog["kind"] - content = lines(dialog["prompt"], width - 8) - limit = min(len(content), max(3, height // 3)) if kind in ("menu", "radiolist", "inputbox", "passwordbox") else height - 7 - if kind in ("textbox", "msgbox", "yesno"): - content = content[self.scroll:] - for number, text in enumerate(content[:limit]): - self.put(3 + number, 4, text) - row = 4 + min(limit, len(content)) - selected_attr = curses.color_pair(1) if curses.has_colors() else curses.A_REVERSE - if kind in ("menu", "radiolist"): - visible = max(1, height - row - 4) - first = max(0, self.index - visible + 1) - for number, item in enumerate(dialog["items"][first:first + visible]): - position = first + number - self.put(row + number, 4, f"{item[0]} {item[1]}", - selected_attr if position == self.index else 0) - elif kind in ("inputbox", "passwordbox"): - visible = self.value if kind == "inputbox" else "*" * len(self.value) - # Keep the insertion point visible for long input, including Japanese. - prefix = visible[:self.cursor] - while len(clipped(prefix, width - 12)) < len(prefix): - prefix = prefix[1:] - start = self.cursor - len(prefix) - entry = visible[start:self.cursor] + "│" + visible[self.cursor:] - self.put(row, 4, "[" + entry + "]", selected_attr if self.focus == 0 else 0) - self.put(height - 3, 4, self.message("ok"), selected_attr if not self.focus else 0) - if kind not in ("msgbox", "textbox"): - self.put(height - 3, width // 2, self.message("cancel"), selected_attr if self.focus else 0) - self.put(height - 2, 3, self.message("hint")) - self.screen.refresh() - - def key(self, key): - if self.dialog is None: - return # Do not queue keys pressed during work into the next dialog. - dialog = self.dialog - kind = dialog["kind"] - if key == "\x1b": - self.reply(1) - elif key == "\t": - self.focus = 1 - self.focus if kind not in ("textbox", "msgbox") else 0 - elif key in ("\n", "\r", curses.KEY_ENTER): - if self.focus: - self.reply(1) - else: - value = self.value if kind in ("inputbox", "passwordbox") else "" - if kind in ("menu", "radiolist") and dialog["items"]: - value = dialog["items"][self.index][0] - self.reply(self.error_status, value) - elif kind in ("menu", "radiolist"): - if key in (curses.KEY_UP, curses.KEY_DOWN): - self.index = max(0, min(len(dialog["items"]) - 1, - self.index + (1 if key == curses.KEY_DOWN else -1))) - elif key in (curses.KEY_LEFT, curses.KEY_RIGHT): - self.focus = 1 - self.focus - elif kind in ("inputbox", "passwordbox") and not self.focus: - if key in (curses.KEY_BACKSPACE, "\x7f", "\b"): - self.value = self.value[:max(0, self.cursor - 1)] + self.value[self.cursor:] - self.cursor = max(0, self.cursor - 1) - elif key == curses.KEY_DC: - self.value = self.value[:self.cursor] + self.value[self.cursor + 1:] - elif key == curses.KEY_LEFT: - self.cursor = max(0, self.cursor - 1) - elif key == curses.KEY_RIGHT: - self.cursor = min(len(self.value), self.cursor + 1) - elif key == curses.KEY_HOME: - self.cursor = 0 - elif key == curses.KEY_END: - self.cursor = len(self.value) - elif isinstance(key, str) and key.isprintable(): - self.value = self.value[:self.cursor] + key + self.value[self.cursor:] - self.cursor += len(key) - elif key in (curses.KEY_LEFT, curses.KEY_RIGHT) and kind == "yesno": - self.focus = 1 - self.focus - elif key in (curses.KEY_DOWN, curses.KEY_NPAGE, curses.KEY_UP, curses.KEY_PPAGE): - total = len(lines(dialog["prompt"], max(1, self.screen.getmaxyx()[1] - 8))) - delta = 10 if key in (curses.KEY_NPAGE, curses.KEY_PPAGE) else 1 - self.scroll = max(0, min(total - 1, self.scroll + (delta if key in (curses.KEY_DOWN, curses.KEY_NPAGE) else -delta))) - - def run(self): - while self.process.poll() is None: - if self.connection is None and select.select([self.listener], [], [], 0)[0]: - self.accept() - if self.suspended: - time.sleep(0.05) - continue - self.frame += 1 - self.draw() - try: - self.key(self.screen.get_wch()) - except curses.error: - pass - self.reply(1) - return self.process.returncode - - -def run_session(command): - with tempfile.TemporaryDirectory(prefix="mcserver-kit-ui-") as directory: - endpoint = str(Path(directory) / "session.sock") - with socket.socket(socket.AF_UNIX) as listener: - listener.bind(endpoint) - listener.listen() - environment = dict(os.environ, MCSERVER_KIT_TUI_SOCKET=endpoint) - # The screen is initialized before the controller can issue requests. - def start(screen): - process = subprocess.Popen(command, env=environment) - try: - return Session(screen, listener, process).run() - finally: - if process.poll() is None: - process.terminate() - try: - process.wait(timeout=3) - except subprocess.TimeoutExpired: - process.kill() - process.wait() - previous = signal.getsignal(signal.SIGTERM) - previous_int = signal.getsignal(signal.SIGINT) - def terminate(signum, _frame): - raise SystemExit(128 + signum) - signal.signal(signal.SIGTERM, terminate) - try: - return curses.wrapper(start) - finally: - signal.signal(signal.SIGTERM, previous) - signal.signal(signal.SIGINT, previous_int) - - -def main(): - if sys.argv[1] == "client": - return client(sys.argv[2:]) - try: - return run_session(sys.argv[2:]) - except KeyboardInterrupt: - return 130 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/share/mcserver-kit/locales/en.json b/share/mcserver-kit/locales/en.json index d82de40..5bcd815 100644 --- a/share/mcserver-kit/locales/en.json +++ b/share/mcserver-kit/locales/en.json @@ -1,9 +1,4 @@ { - "tui.session.loading": "Loading / processing…", - "tui.session.hint": "↑↓ Select / scroll · Tab Switch button · Enter Confirm / return · Esc Cancel", - "tui.session.ok": "< OK >", - "tui.session.cancel": "< Cancel >", - "tui.session.small": "Enlarge the terminal (at least 35 × 10).", "cli.description": "Minecraft Java distribution-world server toolkit", "cli.usage": "Usage:", "cli.commands": "Commands:", diff --git a/share/mcserver-kit/locales/ja.json b/share/mcserver-kit/locales/ja.json index ef7e509..887f652 100644 --- a/share/mcserver-kit/locales/ja.json +++ b/share/mcserver-kit/locales/ja.json @@ -1,9 +1,4 @@ { - "tui.session.loading": "読み込み・処理中…", - "tui.session.hint": "↑↓ 選択・スクロール · Tab ボタン切替 · Enter 決定・戻る · Esc キャンセル", - "tui.session.ok": "< 決定 >", - "tui.session.cancel": "< キャンセル >", - "tui.session.small": "端末を広げてください(最低35列×10行)。", "cli.description": "Minecraft Java配布ワールド用サーバー作成ツール", "cli.usage": "使い方:", "cli.commands": "コマンド:", diff --git a/tests/run-tests.sh b/tests/run-tests.sh index 3730ffe..2833167 100755 --- a/tests/run-tests.sh +++ b/tests/run-tests.sh @@ -950,7 +950,6 @@ WHIPTAIL } main() { - PYTHONDONTWRITEBYTECODE=1 python3 "${REPO_ROOT}/tests/test-tui-session.py" TEST_TEMP_DIR="$(mktemp -d)" trap cleanup EXIT diff --git a/tests/test-tui-session.py b/tests/test-tui-session.py deleted file mode 100644 index dd7ceae..0000000 --- a/tests/test-tui-session.py +++ /dev/null @@ -1,226 +0,0 @@ -"""Persistent terminal session regressions; no Docker or user server changes.""" -import curses -import fcntl -import importlib.util -import json -import os -from pathlib import Path -import pty -import select -import struct -import subprocess -import sys -import tempfile -import termios -import time -import unittest -from unittest.mock import Mock, patch - -ROOT = Path(__file__).resolve().parents[1] -TOOL = ROOT / "libexec/mcserver-kit/tui-session.py" -spec = importlib.util.spec_from_file_location("tui_session", TOOL) -ui = importlib.util.module_from_spec(spec) -spec.loader.exec_module(ui) - - -class DialogTests(unittest.TestCase): - def test_menu_and_radio(self): - menu = ui.parse_dialog(["--title", "Test", "--menu", "Choose", "20", "70", "10", "a", "Alpha", "b", "Beta"]) - self.assertEqual(menu["items"], [["a", "Alpha"], ["b", "Beta"]]) - radio = ui.parse_dialog(["--radiolist", "Choose", "12", "70", "2", "en", "English", "OFF", "ja", "日本語", "ON"]) - self.assertEqual(radio["selected"], 1) - - def test_input_and_unsupported_options(self): - self.assertEqual(ui.parse_dialog(["--inputbox", "Value", "10", "70", "日本語"])["value"], "日本語") - with self.assertRaises(ValueError): - ui.parse_dialog(["--unsupported"]) - with self.assertRaises(ValueError): - ui.parse_dialog(["--menu", "Choose", "20", "70", "10", "missing label"]) - - def test_display_width_and_blank_lines(self): - self.assertEqual(ui.clipped("日本abc", 5), "日本a") - self.assertEqual(ui.lines("a\n\nb", 10), ["a", "", "b"]) - self.assertEqual(ui.clipped("\x1btest", 20), "test") - - def test_japanese_backspace_and_cursor(self): - session = object.__new__(ui.Session) - session.dialog = {"kind": "inputbox"} - session.value = "日本語" - session.cursor = 3 - session.focus = 0 - session.key(curses.KEY_BACKSPACE) - session.key("文") - self.assertEqual(session.value, "日本文") - session.key(curses.KEY_LEFT) - session.key("新") - self.assertEqual(session.value, "日本新文") - - def test_cancel_does_not_confirm(self): - session = object.__new__(ui.Session) - session.dialog = {"kind": "yesno"} - session.focus = 0 - session.reply = Mock() - session.key("\t") - session.key("\n") - session.reply.assert_called_once_with(1) - - def test_loading_discards_keys(self): - session = object.__new__(ui.Session) - session.dialog = None - session.reply = Mock() - session.key("\n") - session.reply.assert_not_called() - - def test_initial_loading_uses_selected_language(self): - with tempfile.TemporaryDirectory() as directory: - path = Path(directory) / "language" - path.write_text("ja\n") - with patch.dict(os.environ, {"MCSERVER_KIT_LANGUAGE_FILE": str(path)}, clear=True): - self.assertEqual(ui.startup_language(), "ja") - os.environ["MCSERVER_KIT_LANG"] = "en_US" - self.assertEqual(ui.startup_language(), "en") - - -class TerminalTests(unittest.TestCase): - def run_terminal(self, body, interact, expected_status=0): - with tempfile.TemporaryDirectory() as temp: - workspace = Path(temp) - script = workspace / "controller.sh" - script.write_text( - 'set -eu\nsource "' + str(ROOT / "libexec/mcserver-kit/i18n.sh") + '"\nload_messages\n' + body, - encoding="utf-8", - ) - master, slave = pty.openpty() - fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", 32, 100, 0, 0)) - environment = dict(os.environ, TERM="xterm-256color", MCSERVER_KIT_LANG="en", - MCSERVER_KIT_ACTIVE_LANG="en", TEST_UI_DIR=temp, - PYTHONDONTWRITEBYTECODE="1") - process = subprocess.Popen([sys.executable, str(TOOL), "run", "bash", str(script)], - stdin=slave, stdout=slave, stderr=slave, env=environment) - os.close(slave) - output = bytearray() - - def receive(marker=None, duration=5): - deadline = time.monotonic() + duration - while time.monotonic() < deadline: - if select.select([master], [], [], 0.05)[0]: - try: - chunk = os.read(master, 65536) - except OSError: - break - if not chunk: - break - output.extend(chunk) - if marker is not None and marker in output: - return - if marker is not None: - self.assertIn(marker, output, output.decode(errors="replace")) - - try: - interact(master, workspace, output, receive) - receive(duration=0.5) - process.wait(timeout=5) - receive(duration=0.2) - self.assertEqual(process.returncode, expected_status, output.decode(errors="replace")) - self.assertEqual(output.count(b"\x1b[?1049h"), 1, "enter alternate screen once") - self.assertEqual(output.count(b"\x1b[?1049l"), 1, "leave only at final exit") - self.assertEqual(output.count(b"\x1b[2J"), 1, "do not clear the whole screen between dialogs") - finally: - if process.poll() is None: - process.kill() - process.wait() - os.close(master) - - def test_menu_wait_result_and_cancel_keep_screen(self): - body = """ -choice="$(whiptail --title FIRST --menu Choose 20 70 2 one First two Second 3>&1 1>&2 2>&3)" -printf '%s' "$choice" >"$TEST_UI_DIR/choice" -sleep 0.7 -whiptail --title RESULT --msgbox Finished 10 70 -if whiptail --title CANCEL --yesno Confirm 10 70; then exit 7; fi -""" - def interact(master, workspace, output, receive): - receive(b"FIRST") - os.write(master, b"\r") - receive(b"Loading") - self.assertNotIn(b"\x1b[?1049l", output) - receive(b"RESULT") - self.assertEqual((workspace / "choice").read_text(), "one") - self.assertNotIn(b"\x1b[?1049l", output) - os.write(master, b"\r") - receive(b"CANCEL") - os.write(master, b"\t\r") - self.run_terminal(body, interact) - - def test_unicode_input_and_line_mode_handoff(self): - body = """ -value="$(whiptail --title INPUT --inputbox Value 10 70 3>&1 1>&2 2>&3)" -printf '%s' "$value" >"$TEST_UI_DIR/value" -tui_terminal_suspend -printf '\nRAW_INPUT_READY\n' -read -r raw -printf '%s' "$raw" >"$TEST_UI_DIR/raw" -tui_terminal_resume -whiptail --title RETURNED --msgbox Finished 10 70 -""" - def interact(master, workspace, output, receive): - receive(b"INPUT") - os.write(master, "日本語".encode() + b"\x7f" + "文".encode() + b"\r") - receive(b"RAW_INPUT_READY") - self.assertEqual((workspace / "value").read_text(), "日本文") - self.assertNotIn(b"\x1b[?1049l", output) - os.write(master, b"hello\n") - receive(b"RETURNED") - self.assertEqual((workspace / "raw").read_text(), "hello") - os.write(master, b"\r") - self.run_terminal(body, interact) - - def test_slow_home_list_and_server_return(self): - body = """ -mkdir -p "$TEST_UI_DIR/servers/demo" "$TEST_UI_DIR/bin" "$TEST_UI_DIR/cache" -printf 'services: {}\\n' >"$TEST_UI_DIR/servers/demo/compose.yaml" -printf 'paths:\\n server_root: "%s/servers"\\n' "$TEST_UI_DIR" >"$TEST_UI_DIR/config.yml" -printf '%s\\nv1.1.3\\n' "$(date +%s)" >"$TEST_UI_DIR/cache/update-check" -printf '#!/bin/sh\\nsleep 0.2\\necho "[]"\\n' >"$TEST_UI_DIR/bin/docker" -chmod +x "$TEST_UI_DIR/bin/docker" -export PATH="$TEST_UI_DIR/bin:$PATH" -export MCSERVER_KIT_CONFIG="$TEST_UI_DIR/config.yml" -export MCSERVER_KIT_UPDATE_CACHE_DIR="$TEST_UI_DIR/cache" -exec bash """ + str(ROOT / "libexec/mcserver-kit/home-tui.sh") + """ -""" - def interact(master, workspace, output, receive): - receive(b"Choose what you want", duration=8) - os.write(master, b"\r") # Home -> server list - receive(b"Select a server", duration=8) - os.write(master, b"\r") # List -> demo - receive(b"Current status", duration=8) - before = len(output) - os.write(master, b"\x1b") # Cancel -> list, slow state retrieval - receive(duration=0.6) - self.assertNotIn(b"\x1b[?1049l", output) - self.assertIn(b"Loading", output[before:]) - receive(duration=1) - os.write(master, b"\x1b") # Cancel -> home - receive(duration=2) - os.write(master, b"\x1b") # Exit home - self.run_terminal(body, interact) - - def test_password_hidden_and_failure_restores_terminal(self): - body = """ -value="$(whiptail --title SECRET --passwordbox Password 10 70 3>&1 1>&2 2>&3)" -printf '%s' "$value" >"$TEST_UI_DIR/secret" -whiptail --title FAILED --msgbox "Command failed" 10 70 -exit 7 -""" - def interact(master, workspace, output, receive): - receive(b"SECRET") - os.write(master, b"not-for-display\r") - receive(b"FAILED") - self.assertEqual((workspace / "secret").read_text(), "not-for-display") - self.assertNotIn(b"not-for-display", output) - os.write(master, b"\r") - self.run_terminal(body, interact, expected_status=7) - - -if __name__ == "__main__": - unittest.main() From dc68dfa74dad55201093a14f21747e68f4a61792 Mon Sep 17 00:00:00 2001 From: cotore-game <102813037+cotore-game@users.noreply.github.com> Date: Wed, 30 Sep 2026 00:18:19 +0900 Subject: [PATCH 4/5] fix: keep command progress dialog open without redesigning menus --- libexec/mcserver-kit/home-tui.sh | 13 +- libexec/mcserver-kit/tui-progress.py | 138 ++++++++++++++++++ tests/run-tests.sh | 3 +- tests/test-tui-progress.py | 211 +++++++++++++++++++++++++++ 4 files changed, 354 insertions(+), 11 deletions(-) create mode 100644 libexec/mcserver-kit/tui-progress.py create mode 100644 tests/test-tui-progress.py diff --git a/libexec/mcserver-kit/home-tui.sh b/libexec/mcserver-kit/home-tui.sh index d88a8e9..a47624c 100755 --- a/libexec/mcserver-kit/home-tui.sh +++ b/libexec/mcserver-kit/home-tui.sh @@ -119,17 +119,10 @@ pause_for_enter() { run_and_show() { local title="$1" shift - local output pid result=0 frame_index=0 - local frames=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏') + local output result=0 new_temp_file output - "$@" >"$output" 2>&1 & - pid=$! - while kill -0 "$pid" 2>/dev/null; do - whiptail --title "$title" --infobox "$(tr home.processing "${frames[frame_index]}" "$title")" 8 72 - frame_index=$(((frame_index + 1) % ${#frames[@]})) - sleep 0.15 - done - wait "$pid" || result=$? + python3 "${SCRIPT_DIR}/tui-progress.py" --output "$output" --title "$title" \ + --message "$(tr home.processing '{spinner}' "$title")" -- "$@" || result=$? if [[ ! -s "$output" ]]; then if ((result == 0)); then tr home.completed >"$output" diff --git a/libexec/mcserver-kit/tui-progress.py b/libexec/mcserver-kit/tui-progress.py new file mode 100644 index 0000000..e4de452 --- /dev/null +++ b/libexec/mcserver-kit/tui-progress.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +"""Keep a single progress dialog open while a non-interactive command runs. + +Only the waiting screen uses curses. Menus and result dialogs remain whiptail. +""" +from __future__ import annotations + +import argparse +import curses +import os +from pathlib import Path +import signal +import subprocess +import sys +import unicodedata + +FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏" + + +def clipped(text: str, width: int) -> str: + result, used = [], 0 + for char in text: + if unicodedata.category(char).startswith("C"): + continue + size = 0 if unicodedata.combining(char) else (2 if unicodedata.east_asian_width(char) in "WF" else 1) + if used + size > width: + break + result.append(char) + used += size + return "".join(result) + + +def stop_command(process): + # Include grandchildren, not just the shell launching Docker or curl. + if process.poll() is None: + try: + os.killpg(process.pid, signal.SIGTERM) + except ProcessLookupError: + process.wait() + return + try: + process.wait(timeout=3) + except subprocess.TimeoutExpired: + os.killpg(process.pid, signal.SIGKILL) + process.wait() + + +def execute(screen, command, output, title, message): + window = None + size = None + if screen is not None: + curses.curs_set(0) + screen.timeout(100) + + def draw(frame): + nonlocal window, size + if screen is None: + return + current = screen.getmaxyx() + if current != size: + size = current + rows, columns = size + screen.erase() + screen.noutrefresh() + if rows < 4 or columns < 12: + window = None + else: + height, width = min(8, rows), min(72, columns) + window = curses.newwin(height, width, (rows - height) // 2, (columns - width) // 2) + window.box() + window.addstr(0, 2, clipped(" " + title + " ", width - 4)) + if window is not None: + height, width = window.getmaxyx() + row = min(3, height - 2) + window.move(row, 2) + window.addstr(clipped(message.replace("{spinner}", frame), width - 4)) + window.noutrefresh() + curses.doupdate() + + draw(FRAMES[0]) # Show the dialog before launching a potentially slow command. + with subprocess.Popen(command, stdin=subprocess.DEVNULL, stdout=output, + stderr=subprocess.STDOUT, start_new_session=True) as process: + try: + frame = 0 + while process.poll() is None: + if screen is None: + try: + process.wait(timeout=0.1) + except subprocess.TimeoutExpired: + pass + else: + draw(FRAMES[frame % len(FRAMES)]) + screen.getch() # Consume keys; they must not dismiss the next result. + frame += 1 + if screen is not None: + curses.flushinp() + return process.returncode if process.returncode >= 0 else 128 - process.returncode + finally: + stop_command(process) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--output", required=True) + parser.add_argument("--title", required=True) + parser.add_argument("--message", required=True) + parser.add_argument("command", nargs=argparse.REMAINDER) + args = parser.parse_args() + command = args.command[1:] if args.command[:1] == ["--"] else args.command + if not command: + parser.error("a command is required after --") + + def interrupted(signum, _frame): + raise InterruptedError(signum) + + previous = signal.signal(signal.SIGTERM, interrupted) + try: + with Path(args.output).open("w", encoding="utf-8") as output: + try: + if sys.stdin.isatty() and sys.stdout.isatty(): + return curses.wrapper(execute, command, output, args.title, args.message) + # CI and redirected invocations have no interactive display. + return execute(None, command, output, args.title, args.message) + except KeyboardInterrupt: + print("Command interrupted (SIGINT).", file=output) + return 130 + except InterruptedError: + print("Command interrupted (SIGTERM).", file=output) + return 143 + except (OSError, curses.error) as error: + print(f"Could not complete command: {error}", file=output) + return 1 + finally: + signal.signal(signal.SIGTERM, previous) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/run-tests.sh b/tests/run-tests.sh index 2833167..e51af15 100755 --- a/tests/run-tests.sh +++ b/tests/run-tests.sh @@ -529,7 +529,7 @@ CURL MCSERVER_KIT_UPDATE_CACHE_DIR="${temp_dir}/home-transition/cache" \ MCSERVER_KIT_TEST_EVENTS="$event_log" MCSERVER_KIT_TEST_MENU_COUNT="$menu_count" \ bash "${REPO_ROOT}/mcserver-kit" home)" - assert_equal 'present' "$(grep -Fq 'loading' "$event_log" && printf present)" 'update checks retain a progress dialog' + assert_equal 'absent' "$(grep -Fq 'loading' "$event_log" && printf present || printf absent)" 'progress no longer repeatedly opens whiptail infobox' assert_equal 'present' "$(grep -Fq 'result' "$event_log" && printf present)" 'update result is shown in a persistent dialog' assert_equal 'menu' "$(tail -n 1 "$event_log")" 'the home menu returns after viewing the update result' } @@ -950,6 +950,7 @@ WHIPTAIL } main() { + PYTHONDONTWRITEBYTECODE=1 python3 "${REPO_ROOT}/tests/test-tui-progress.py" TEST_TEMP_DIR="$(mktemp -d)" trap cleanup EXIT diff --git a/tests/test-tui-progress.py b/tests/test-tui-progress.py new file mode 100644 index 0000000..6c3c05b --- /dev/null +++ b/tests/test-tui-progress.py @@ -0,0 +1,211 @@ +"""Progress-only hotfix tests using real pseudo-terminals, without Docker.""" +import fcntl +import importlib.util +import json +import os +from pathlib import Path +import pty +import select +import signal +import struct +import subprocess +import sys +import tempfile +import termios +import time +import unittest + +ROOT = Path(__file__).resolve().parents[1] +TOOL = ROOT / "libexec/mcserver-kit/tui-progress.py" +spec = importlib.util.spec_from_file_location("tui_progress", TOOL) +progress = importlib.util.module_from_spec(spec) +spec.loader.exec_module(progress) + + +class ProgressTests(unittest.TestCase): + def setUp(self): + self.workspace = tempfile.TemporaryDirectory() + self.addCleanup(self.workspace.cleanup) + self.root = Path(self.workspace.name) + self.output = self.root / "output" + + def arguments(self, code): + return [sys.executable, str(TOOL), "--output", str(self.output), + "--title", "Test", "--message", "{spinner} Processing...", + "--", sys.executable, "-c", code] + + def test_character_width_and_controls(self): + self.assertEqual(progress.clipped("日本abc", 5), "日本a") + self.assertEqual(progress.clipped("\x1btext\n", 20), "text") + + def test_output_and_error_share_file_and_preserve_status(self): + result = subprocess.run(self.arguments( + "import sys; print('stdout'); print('stderr', file=sys.stderr); sys.exit(7)" + ), capture_output=True, timeout=5) + self.assertEqual(result.returncode, 7) + self.assertEqual(result.stdout, b"") + self.assertEqual(result.stderr, b"") + self.assertIn("stdout", self.output.read_text()) + self.assertIn("stderr", self.output.read_text()) + + def test_empty_success_does_not_invent_logs(self): + result = subprocess.run(self.arguments("pass"), capture_output=True, timeout=5) + self.assertEqual(result.returncode, 0) + self.assertEqual(self.output.read_bytes(), b"") + + def test_missing_command_is_reported(self): + args = self.arguments("pass") + args[args.index("--") + 1:] = [str(self.root / "not-a-command")] + result = subprocess.run(args, capture_output=True, timeout=5) + self.assertEqual(result.returncode, 1) + self.assertIn("Could not complete command", self.output.read_text()) + + def test_signal_exit_is_not_reported_as_success(self): + result = subprocess.run(self.arguments( + "import os, signal; os.kill(os.getpid(), signal.SIGTERM)" + ), capture_output=True, timeout=5) + self.assertEqual(result.returncode, 143) + + def test_command_is_executed_once(self): + counter = self.root / "counter" + code = f"from pathlib import Path; p=Path({str(counter)!r}); p.write_text(p.read_text()+'x' if p.exists() else 'x')" + subprocess.run(self.arguments(code), check=True, capture_output=True, timeout=5) + self.assertEqual(counter.read_text(), "x") + + def test_terminal_stays_open_until_command_finishes(self): + master, slave = pty.openpty() + fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", 28, 94, 0, 0)) + process = subprocess.Popen( + self.arguments("import time; print('PRIVATE-COMMAND-OUTPUT', flush=True); time.sleep(0.9)"), + stdin=slave, stdout=slave, stderr=slave, + env=dict(os.environ, TERM="xterm-256color"), + ) + os.close(slave) + received = bytearray() + + def receive(duration): + deadline = time.monotonic() + duration + while time.monotonic() < deadline: + if select.select([master], [], [], 0.05)[0]: + try: + received.extend(os.read(master, 65536)) + except OSError: + break + + try: + deadline = time.monotonic() + 5 + while b"Processing" not in received and time.monotonic() < deadline: + receive(0.05) + self.assertIn(b"Processing", received) + receive(0.3) + self.assertIsNone(process.poll()) + self.assertNotIn(b"\x1b[?1049l", received, "must not return to shell between spinner frames") + self.assertNotIn(b"PRIVATE-COMMAND-OUTPUT", received, "logs belong to the later result dialog") + self.assertEqual(received.count(b"\x1b[2J"), 1, "must not clear the screen for each frame") + self.assertGreaterEqual(sum(frame.encode() in received for frame in progress.FRAMES), 2) + receive(1) + process.wait(timeout=3) + receive(0.1) + self.assertEqual(process.returncode, 0) + self.assertEqual(received.count(b"\x1b[?1049h"), 1) + self.assertEqual(received.count(b"\x1b[?1049l"), 1) + self.assertIn("PRIVATE-COMMAND-OUTPUT", self.output.read_text()) + finally: + if process.poll() is None: + process.terminate() + process.wait(timeout=5) + os.close(master) + + def test_termination_stops_command_group(self): + marker = self.root / "finished" + ready = self.root / "ready" + child_pid = self.root / "child-pid" + code = ( + "import os, time; from pathlib import Path; " + f"Path({str(child_pid)!r}).write_text(str(os.getpid())); " + f"Path({str(ready)!r}).touch(); time.sleep(3); Path({str(marker)!r}).touch()" + ) + process = subprocess.Popen(self.arguments(code), stdout=subprocess.PIPE, stderr=subprocess.PIPE) + try: + deadline = time.monotonic() + 5 + while not ready.exists() and time.monotonic() < deadline: + time.sleep(0.02) + self.assertTrue(ready.exists()) + process.send_signal(signal.SIGTERM) + process.communicate(timeout=5) + self.assertEqual(process.returncode, 143) + self.assertFalse(marker.exists()) + with self.assertRaises(ProcessLookupError): + os.kill(int(child_pid.read_text()), 0) + finally: + if process.poll() is None: + process.kill() + process.communicate() + + + + def test_home_keeps_result_dialog_and_error_title(self): + server = self.root / "servers/demo" + server.mkdir(parents=True) + (server / "compose.yaml").write_text("services: {}\n") + config = self.root / "config.yml" + config.write_text(f"paths:\n server_root: {self.root / 'servers'}\n") + fake_bin = self.root / "bin" + fake_bin.mkdir() + queue = self.root / "queue.json" + events = self.root / "events.jsonl" + whiptail = fake_bin / "whiptail" + whiptail.write_text("""#!/usr/bin/env python3 +import json, os, pathlib, sys +args = sys.argv[1:] +queue = pathlib.Path(os.environ["TEST_QUEUE"]) +events = pathlib.Path(os.environ["TEST_EVENTS"]) +if "--menu" in args: + items = json.loads(queue.read_text()) + sys.stderr.write(items.pop(0)) + queue.write_text(json.dumps(items)) +elif "--textbox" in args: + path = pathlib.Path(args[args.index("--textbox") + 1]) + title = args[args.index("--title") + 1] + with events.open("a") as file: + file.write(json.dumps({"title": title, "text": path.read_text()}) + "\\n") +elif "--infobox" in args: + raise SystemExit("Unexpected old infobox loop") +""") + whiptail.chmod(0o755) + docker = fake_bin / "docker" + docker.write_text("""#!/bin/sh +case "$*" in + 'compose ps -a --format json') echo '[]' ;; + 'compose config --quiet') exit 0 ;; + 'compose up -d') echo command-output; echo command-diagnostic >&2; exit "$TEST_COMMAND_STATUS" ;; +esac +""") + docker.chmod(0o755) + cache = self.root / "cache" + cache.mkdir() + version = (ROOT / "VERSION").read_text().strip() + (cache / "update-check").write_text(f"{int(time.time())}\nv{version}\n") + for status in (0, 7): + with self.subTest(status=status): + queue.write_text(json.dumps(["servers", "demo", "start", "back", "__back", "exit"])) + events.write_text("") + result = subprocess.run( + ["bash", str(ROOT / "mcserver-kit"), "home"], + capture_output=True, text=True, timeout=40, + env=dict(os.environ, PATH=f"{fake_bin}:{os.environ['PATH']}", + MCSERVER_KIT_LANG="en", MCSERVER_KIT_TUI_TEST="true", + MCSERVER_KIT_CONFIG=str(config), MCSERVER_KIT_UPDATE_CACHE_DIR=str(cache), + TEST_QUEUE=str(queue), TEST_EVENTS=str(events), + TEST_COMMAND_STATUS=str(status)), + ) + self.assertEqual(result.returncode, 0, result.stderr) + records = [json.loads(line) for line in events.read_text().splitlines()] + self.assertEqual(len(records), 1) + self.assertIn("command-output", records[0]["text"]) + self.assertIn("command-diagnostic", records[0]["text"]) + self.assertEqual(records[0]["title"] == "Error", status != 0) + self.assertEqual(json.loads(queue.read_text()), []) + +if __name__ == "__main__": + unittest.main() From 78f0ee4d81bc095802b0fdc08a0d489faeb3ad91 Mon Sep 17 00:00:00 2001 From: cotore-game <102813037+cotore-game@users.noreply.github.com> Date: Wed, 30 Sep 2026 00:18:19 +0900 Subject: [PATCH 5/5] chore: prepare focused v1.1.4 progress fix --- README-JA.md | 4 +++- README.md | 4 +++- VERSION | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/README-JA.md b/README-JA.md index e58b51b..fa7c993 100644 --- a/README-JA.md +++ b/README-JA.md @@ -47,7 +47,7 @@ curl -fsSL https://raw.githubusercontent.com/cotore-game/mcserver-compose-kit/ma ```bash curl -fsSL https://raw.githubusercontent.com/cotore-game/mcserver-compose-kit/main/install.sh | \ - bash -s -- --version v1.1.3 + bash -s -- --version v1.1.4 ``` インストーラーはリリースの圧縮ファイルを取得し、SHA-256を検証して`~/.local/share/mcserver-compose-kit`へ配置します。また、`~/.local/bin`用のPATH設定を管理ブロックとして`~/.bashrc`へ追加します。 @@ -93,6 +93,8 @@ mcserver-kit ホーム画面から、サーバー作成・管理、全体設定、MCIDテンプレート、言語、動作環境診断、ヘルプへ進めます。矢印キーで選択し、Enterで決定します。 +サーバー操作や更新確認の実行中は、ひとつの待機画面を開いたまま点字スピナーを表示します。コマンドの出力は一時ファイルに蓄積し、終了後にエラーも含めて表示します。アニメーションのたびに画面を開閉する方式は廃止しました。メニューやボタンの配置・操作は従来どおりで、メニュー間の画面遷移自体を刷新する修正ではありません。 + インストールされているツールのバージョンは次で確認できます。 ```bash diff --git a/README.md b/README.md index 73a85f9..368346f 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ Install a specific release: ```bash curl -fsSL https://raw.githubusercontent.com/cotore-game/mcserver-compose-kit/main/install.sh | \ - bash -s -- --version v1.1.3 + bash -s -- --version v1.1.4 ``` The installer downloads the release archive, verifies its SHA-256 checksum, and installs the program under `~/.local/share/mcserver-compose-kit`. It also adds a managed PATH block for `~/.local/bin` to `~/.bashrc`. @@ -92,6 +92,8 @@ mcserver-kit The dashboard includes server creation and management, global settings, MCID templates, language selection, diagnostics, and help. Use the arrow keys to select an item and Enter to open it. +While server actions or update checks run, a single waiting dialog shows a braille spinner. Command output is collected in a temporary file and displayed afterward, including errors. The waiting dialog no longer opens and closes for each animation frame. Menus, buttons, and navigation retain the existing layout; this fix does not redesign transitions between menus. + Check the installed toolkit version with: ```bash diff --git a/VERSION b/VERSION index 781dcb0..65087b4 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.3 +1.1.4