From abf05493b9226913d72b6d9e529d053ff71708f5 Mon Sep 17 00:00:00 2001 From: MacRimi Date: Thu, 15 Oct 2026 22:55:21 +0200 Subject: [PATCH] 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. --- oci/src/proxmenux_oci/cli.py | 6 +-- oci/src/proxmenux_oci/extra_devices.py | 4 +- oci/src/proxmenux_oci/installer.py | 6 +-- oci/src/proxmenux_oci/ui.py | 24 ++++++++++- oci/tests/test_dialog_menu_tags.py | 60 ++++++++++++++++++++++++++ 5 files changed, 90 insertions(+), 10 deletions(-) create mode 100644 oci/tests/test_dialog_menu_tags.py diff --git a/oci/src/proxmenux_oci/cli.py b/oci/src/proxmenux_oci/cli.py index 144b5c03..4e9025d9 100644 --- a/oci/src/proxmenux_oci/cli.py +++ b/oci/src/proxmenux_oci/cli.py @@ -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: diff --git a/oci/src/proxmenux_oci/extra_devices.py b/oci/src/proxmenux_oci/extra_devices.py index e8689e7a..bb005543 100644 --- a/oci/src/proxmenux_oci/extra_devices.py +++ b/oci/src/proxmenux_oci/extra_devices.py @@ -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 diff --git a/oci/src/proxmenux_oci/installer.py b/oci/src/proxmenux_oci/installer.py index 7a093f75..a10f92c5 100644 --- a/oci/src/proxmenux_oci/installer.py +++ b/oci/src/proxmenux_oci/installer.py @@ -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 diff --git a/oci/src/proxmenux_oci/ui.py b/oci/src/proxmenux_oci/ui.py index ef528bdc..8238d3c2 100644 --- a/oci/src/proxmenux_oci/ui.py +++ b/oci/src/proxmenux_oci/ui.py @@ -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: diff --git a/oci/tests/test_dialog_menu_tags.py b/oci/tests/test_dialog_menu_tags.py new file mode 100644 index 00000000..af1d9a41 --- /dev/null +++ b/oci/tests/test_dialog_menu_tags.py @@ -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()