number the options of the OCI menus instead of showing internal values

- Dialog menus show their options numbered, as the rest of ProxMenux does, and return the value of the chosen one, so each description has the whole width in every language.
- A storage, a bridge, a device node and the main OCI menu keep their tags, and number tags and separators are left as they are.
- Test for the numbering, the default option, the kept tags and a cancelled menu.
This commit is contained in:
MacRimi
2026-10-15 22:55:21 +02:00
parent c7480fc4e7
commit abf05493b9
5 changed files with 90 additions and 10 deletions
+3 -3
View File
@@ -29,7 +29,7 @@ from .installer import (
run_remote_install,
run_remote_rclone_mount,
)
from .ui import APP_TITLE, DETAIL_WINDOW_WIDTH, UserCancelled, interactive_ui, menu_size
from .ui import APP_TITLE, DETAIL_WINDOW_WIDTH, ReadableTags, UserCancelled, interactive_ui, menu_size
PROJECT_ROOT = Path(__file__).resolve().parents[2]
@@ -565,8 +565,8 @@ def interactive(catalog: Catalog) -> int:
for number, key in category_by_index.items()]
selection = "search"
while True:
selection = ui.choose(translate("Select a category or search for applications:"), options, selection,
size=MENU_SIZE)
selection = ui.choose(translate("Select a category or search for applications:"), ReadableTags(options),
selection, size=MENU_SIZE)
if selection is None:
return 0
try:
+2 -2
View File
@@ -5,7 +5,7 @@ from pathlib import Path
from . import host
from .i18n import translate
from .ui import UserCancelled
from .ui import ReadableTags, UserCancelled
GPU_NODE = re.compile(r'/dev/dri/(?:renderD|card)[0-9]+|/dev/kfd')
@@ -22,7 +22,7 @@ def choose_usb_device(ui, title, default=None, attached=()):
ui.message(translate('No selectable USB device was found. Type the host node by hand.'))
return ui.ask(manual_prompt, default or '/dev/ttyACM0')
options.append(('manual', translate('Another node, typed by hand')))
selected = ui.choose(title, options, default if default in dict(options) else options[0][0])
selected = ui.choose(title, ReadableTags(options), default if default in dict(options) else options[0][0])
if selected is None:
raise UserCancelled(translate('Device configuration cancelled'))
return ui.ask(manual_prompt, default or '/dev/ttyACM0') if selected == 'manual' else selected
+3 -3
View File
@@ -18,7 +18,7 @@ from typing import Any
from . import host
from . import network as access
from .i18n import source_text, translate
from .ui import DialogUI, TerminalUI, UserCancelled
from .ui import DialogUI, ReadableTags, TerminalUI, UserCancelled
from .custom_mounts import ask_custom_mounts
from .extra_devices import device_permissions
@@ -160,7 +160,7 @@ def ask_storage(ui, text: str, content: str, default: str, mode: str = ADVANCED_
options = [(row["storage"], f"{row.get('type', '')} {host.gib(row.get('avail'))} GB {translate('free')}")
for row in rows]
names = [name for name, _ in options]
selected = ui.choose(text, options, default if default in names else names[0])
selected = ui.choose(text, ReadableTags(options), default if default in names else names[0])
if selected is None:
raise UserCancelled(text)
return selected
@@ -302,7 +302,7 @@ def ask_bridge(ui, text: str, default: str, mode: str = ADVANCED_MODE) -> str:
return ui.ask(text, default)
options = [(row["iface"], row.get("cidr") or (row.get("comments") or "").strip() or "-") for row in rows]
names = [name for name, _ in options]
selected = ui.choose(text, options, default if default in names else names[0])
selected = ui.choose(text, ReadableTags(options), default if default in names else names[0])
if selected is None:
raise UserCancelled(text)
return selected
+22 -2
View File
@@ -190,6 +190,11 @@ class TerminalUI:
# Width of the windows of the installer: questions, choices and notices. The
# final summary and the page of an application, which show tables, are wider.
MENU_WIDTH = 72
class ReadableTags(list):
"""Menu options whose tags the user reads, as a storage, a bridge or a
device node: they are shown as they are, not numbered."""
SUMMARY_WIDTH = 78
DETAIL_WINDOW_WIDTH = 90
@@ -279,6 +284,19 @@ class DialogUI:
def choose(self, text: str, options: list[tuple[str, str]], default: str | None = None,
title: str | None = None, show_tags: bool = True,
size: tuple[int, int, int] | None = None, colors: bool = False) -> str | None:
# A word is an internal value: the menu numbers the options, as the
# rest of ProxMenux does, and returns the value of the chosen one.
values: dict[str, str] = {}
if not isinstance(options, ReadableTags) and any(tag and not tag.isdigit() for tag, _ in options):
numbered = []
for tag, label in options:
if tag:
values[str(len(values) + 1)] = tag
if tag == default:
default = str(len(values))
tag = str(len(values))
numbered.append((tag, label))
options = numbered
if size:
widget = ["--colors"] if colors else []
widget += ["--default-item", default] if default else []
@@ -286,7 +304,8 @@ class DialogUI:
for tag, label in options:
widget += [tag, label]
result = self._run(widget, title)
return result.stderr.strip() if result.returncode == 0 else None
selected = result.stderr.strip() if result.returncode == 0 else None
return values.get(selected, selected) if values and selected else selected
terminal = shutil.get_terminal_size((100, 30))
tag_width = max((len(tag) for tag, _ in options), default=0) + 2 if show_tags else 0
width = min(max(MENU_WIDTH, max((len(label) for _, label in options), default=0) + tag_width + 14,
@@ -302,7 +321,8 @@ class DialogUI:
for tag, label in options:
widget += [tag, label]
result = self._run(widget, title)
return result.stderr.strip() if result.returncode == 0 else None
selected = result.stderr.strip() if result.returncode == 0 else None
return values.get(selected, selected) if values and selected else selected
def detail_menu(self, text: str, options: list[tuple[str, str]], default: str | None = None,
title: str | None = None) -> str | None:
+60
View File
@@ -0,0 +1,60 @@
"""A menu shows numbers, not internal values: the code still receives the
value of the option chosen. A tag the user reads, such as a storage, stays."""
from pathlib import Path
import sys
from types import SimpleNamespace
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
from proxmenux_oci.ui import DialogUI, ReadableTags
SOURCES = [("paste", "Paste its Compose file in the terminal"),
("file", "Read its Compose file from a file of this host"),
("oci-compose-paste", "Paste an oci-compose in the terminal")]
class DialogMenuTagsTests(unittest.TestCase):
def run_menu(self, options, answer, **kwargs):
seen = {}
def run(widget, title):
seen["widget"] = widget
return SimpleNamespace(returncode=0, stderr=answer)
ui = DialogUI()
with patch.object(ui, "_run", side_effect=run):
selected = ui.choose("How is the image described?", options, **kwargs)
menu = seen["widget"][seen["widget"].index("--menu") + 5:]
return selected, menu[0::2], seen["widget"]
def test_word_tags_are_shown_as_numbers_and_the_value_comes_back(self):
selected, tags, _ = self.run_menu(SOURCES, "3", default="file", size=(20, 80, 10))
self.assertEqual(tags, ["1", "2", "3"])
self.assertEqual(selected, "oci-compose-paste")
def test_the_default_follows_its_number(self):
_, _, widget = self.run_menu(SOURCES, "2", default="file")
self.assertEqual(widget[widget.index("--default-item") + 1], "2")
def test_a_tag_the_user_reads_is_kept(self):
storages = [("local-lvm", "lvmthin 669 GB free"), ("local2-lvm", "lvmthin 269 GB free")]
selected, tags, _ = self.run_menu(ReadableTags(storages), "local2-lvm")
self.assertEqual((tags, selected), (["local-lvm", "local2-lvm"], "local2-lvm"))
def test_number_tags_and_separators_are_left_as_they_are(self):
options = [("101", "Jellyfin"), ("", ""), ("205", "Immich")]
selected, tags, _ = self.run_menu(options, "205")
self.assertEqual((tags, selected), (["101", "", "205"], "205"))
def test_cancelling_returns_nothing(self):
ui = DialogUI()
with patch.object(ui, "_run", return_value=SimpleNamespace(returncode=1, stderr="")):
self.assertIsNone(ui.choose("How is the image described?", SOURCES))
if __name__ == "__main__":
unittest.main()