[ADD] tui qemu: free values for vCPU, RAM and disk

The CLI already took a typed value -- a letter picks a suggestion, a
digit stands for itself. The form, though, locked the custom profile
into its three dropdowns.

Each now gets a final "free value..." entry revealing an input below it.
An invalid entry overwrites nothing: the resource falls back to the
catalog value instead of freezing the view.

The 3 vCPU preset was missing too, between 2 and 4; it comes from the
same constant, so both interfaces gain it together.

--- FR ---

La CLI acceptait déjà une valeur tapée — une lettre choisit une
suggestion, un chiffre vaut pour lui-même. Le formulaire, lui, enfermait
le profil personnalisé dans ses trois listes déroulantes.

Chacune reçoit donc un dernier choix, « valeur libre… », qui révèle une
saisie sous elle. Une entrée invalide n'écrase rien : la ressource
retombe sur celle du catalogue plutôt que de bloquer la vue.

Le préréglage 3 vCPU manquait aussi, entre 2 et 4 ; il vient de la même
constante, donc les deux interfaces l'offrent ensemble.

Assisted-by: Claude Opus 5
This commit is contained in:
Mathieu Benoit 2026-08-11 06:57:49 -04:00
parent 36fc4e319a
commit 7650710295
3 changed files with 86 additions and 10 deletions

View file

@ -245,6 +245,16 @@ def run_deploy_form(ctx, run_app: bool = True):
result = {"spec": None}
AUTO = "__auto__"
# Dernier choix de chaque liste de ressources : il ne porte pas de valeur,
# il révèle la saisie libre placée juste dessous.
FREE = "__free__"
RES_FIELDS = {
"vcpus": ("#f_vcpus", "#c_vcpus"),
"ram": ("#f_ram", "#c_ram"),
"disk": ("#f_disk", "#c_disk"),
}
SELECT_TO_FIELD = {sel[1:]: f for f, (sel, _i) in RES_FIELDS.items()}
INPUT_TO_FIELD = {inp[1:]: f for f, (_s, inp) in RES_FIELDS.items()}
def entry_label(e):
star = " *" if e.get("default") else ""
@ -330,6 +340,7 @@ def run_deploy_form(ctx, run_app: bool = True):
.grouptitle { color: $accent; text-style: bold; padding: 1 0 0 0; }
SelectionList { height: 10; border: solid $panel; }
RadioSet { height: auto; layout: horizontal; }
.freeval { display: none; }
#reslabel { color: $text-muted; }
EditVMScreen { align: center middle; }
#editbox {
@ -364,6 +375,7 @@ def run_deploy_form(ctx, run_app: bool = True):
self.arch = ctx.get("native") or arches[0]
self.profile = "1"
self.custom = {}
self._free = {}
self.overrides = {}
self.vms = []
self.rows = []
@ -385,27 +397,52 @@ def run_deploy_form(ctx, run_app: bool = True):
for label in ("x1", "x2", "x3", "x4"):
yield RadioButton(label, value=label == "x1")
yield RadioButton(t("custom"))
# Chaque ressource offre ses suggestions plus « libre… »,
# qui révèle une saisie. C'est l'équivalent TUI de la règle
# de la CLI : une lettre choisit une suggestion, un chiffre
# vaut pour lui-même.
yield Select(
[(str(c), c) for c in ctx["cpu_presets"]],
[(str(c), c) for c in ctx["cpu_presets"]]
+ [(t("free value…"), FREE)],
prompt=t("vCPU"),
id="f_vcpus",
disabled=True,
)
yield Input(
placeholder=t("vCPU"),
id="c_vcpus",
classes="freeval",
disabled=True,
)
yield Select(
[
(f"{m} ({m // 1024}G)", m)
for m in ctx["ram_presets"]
],
]
+ [(t("free value…"), FREE)],
prompt=t("RAM (MB)"),
id="f_ram",
disabled=True,
)
yield Input(
placeholder=t("RAM (MB)"),
id="c_ram",
classes="freeval",
disabled=True,
)
yield Select(
[(d, d) for d in ctx["disk_presets"]],
[(d, d) for d in ctx["disk_presets"]]
+ [(t("free value…"), FREE)],
prompt=t("Disk"),
id="f_disk",
disabled=True,
)
yield Input(
placeholder=t("Disk (e.g. 250G, 1.5T)"),
id="c_disk",
classes="freeval",
disabled=True,
)
yield Static("ERPLibre", classes="grouptitle")
yield Checkbox(
t("Install ERPLibre"),
@ -588,19 +625,50 @@ def run_deploy_form(ctx, run_app: bool = True):
index = event.radio_set.pressed_index
self.profile = "custom" if index == 4 else str(index + 1)
custom = self.profile == "custom"
for wid in ("#f_vcpus", "#f_ram", "#f_disk"):
self.query_one(wid, Select).disabled = not custom
for field, (sel, inp) in RES_FIELDS.items():
self.query_one(sel, Select).disabled = not custom
free = custom and self._free.get(field)
self._show_free(field, free)
self._recompute()
def _show_free(self, field, visible) -> None:
"""Montre ou cache la saisie libre d'une ressource."""
widget = self.query_one(RES_FIELDS[field][1], Input)
widget.display = bool(visible)
widget.disabled = not visible
def on_selection_list_selected_changed(self, event) -> None:
self._recompute()
def on_select_changed(self, event) -> None:
mapping = {"f_vcpus": "vcpus", "f_ram": "ram", "f_disk": "disk"}
field = mapping.get(event.select.id)
field = SELECT_TO_FIELD.get(event.select.id)
if not field:
return
if event.value is FREE:
# La valeur retenue est celle de la saisie, pas ce choix-ci.
self._free[field] = True
self._show_free(field, True)
self.query_one(RES_FIELDS[field][1], Input).focus()
self._apply_free(field)
elif event.value is not SELECT_NULL:
self._free[field] = False
self._show_free(field, False)
self.custom[field] = event.value
self._recompute()
def _apply_free(self, field) -> None:
"""Relit la saisie libre. Une valeur invalide n'écrase rien : le
profil retombe alors sur celle du catalogue."""
raw = self.query_one(RES_FIELDS[field][1], Input).value.strip()
if field == "disk":
self.custom[field] = parse_disk(raw) or ""
else:
self.custom[field] = positive_int(raw, 0)
def on_input_changed(self, event) -> None:
field = INPUT_TO_FIELD.get(event.input.id)
if field:
if event.value is not SELECT_NULL:
self.custom[field] = event.value
self._apply_free(field)
self._recompute()
def on_checkbox_changed(self, event) -> None:

View file

@ -4507,7 +4507,7 @@ class TODO:
# KVM autorise plus de vCPU que de cœurs (surengagement) : on prévient
# plutôt que d'écrêter, contrairement au multiplicateur x1..x4 qui, lui,
# est un calcul automatique et se borne aux cœurs de l'hôte.
_QEMU_CPU_PRESETS = (1, 2, 4, 6, 8, 16, 24, 32)
_QEMU_CPU_PRESETS = (1, 2, 3, 4, 6, 8, 16, 24, 32)
@staticmethod
def _plural(word, count):

View file

@ -1973,6 +1973,14 @@ TRANSLATIONS = {
"fr": "RAM (Mo)",
"en": "RAM (MB)",
},
"free value…": {
"fr": "valeur libre…",
"en": "free value…",
},
"Disk (e.g. 250G, 1.5T)": {
"fr": "Disque (ex. 250G, 1,5T)",
"en": "Disk (e.g. 250G, 1.5T)",
},
"all archs": {
"fr": "toutes",
"en": "all",