From 834b1061ac7c817e8fecf2a9d3eb13254648b958 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 12 Aug 2026 01:30:11 -0400 Subject: [PATCH] [ADD] tui qemu: customise one VM without touching the others MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three resource fields applied to the whole fleet. With a single machine needing 16 G, the other eight got it too. A scope selector settles it: all VMs, or the row targeted in the plan. The x1..x4 profile stays global, multiplying what each image asks for; under "selected VM" the fields are absolute, hence active even outside the custom profile. The F2 modal already existed but was undiscoverable, and its cursor was unusable: the table never held focus. It stays, the toggle now focuses the table, F4 returns a VM to the shared profile, and the plan marks customised rows with a ✎ -- without it, two rows with different resources have no explanation on screen. One defect found along the way: clear() reset the cursor to the top on every redraw, and the plan recomputes on every keystroke. You picked debian, typed the value, the table redrew, and the next entry landed on ubuntu with nothing to show for it. --- FR --- Les trois champs de ressources s'appliquaient à tout le parc. Une seule machine ayant besoin de 16 G, il fallait les donner aux huit autres. Un sélecteur de portée tranche : toutes les VM, ou la ligne visée dans le plan. Le profil x1..x4 reste global, il multiplie ce que demande chaque image ; en portée « une seule » les champs valent en absolu, et sont donc actifs même hors profil personnalisé. La modale F2 existait déjà mais restait introuvable, et son curseur était inutilisable : le tableau n'avait jamais le focus. Elle demeure, la bascule lui donne le focus, F4 rend une VM au profil commun, et le plan marque d'un ✎ ce qui a été personnalisé — sans marque, deux lignes aux ressources différentes n'ont aucune explication à l'écran. Un défaut au passage : « clear() » ramenait le curseur en tête à chaque redessin, et le plan se recalcule à chaque frappe. On choisissait debian, on tapait la valeur, le tableau se redessinait, et la saisie suivante partait sur ubuntu sans que rien ne le montre. Assisted-by: Claude Opus 5 --- script/todo/qemu_deploy_form.py | 170 ++++++++++++++++++++++++++++++-- script/todo/todo_i18n.py | 20 ++++ 2 files changed, 181 insertions(+), 9 deletions(-) diff --git a/script/todo/qemu_deploy_form.py b/script/todo/qemu_deploy_form.py index edc53bc..33087b8 100644 --- a/script/todo/qemu_deploy_form.py +++ b/script/todo/qemu_deploy_form.py @@ -370,6 +370,7 @@ def run_deploy_form(ctx, run_app: bool = True): BINDINGS = [ ("f5", "deploy", t("Deploy")), ("f2", "edit_vm", t("Edit VM")), + ("f4", "clear_vm", t("Reset VM")), ("f3", "preview", t("Preview")), ("f6", "select_all", t("All")), ("f7", "select_main", t("Main versions")), @@ -384,6 +385,15 @@ def run_deploy_form(ctx, run_app: bool = True): self.custom = {} self._free = {} self.overrides = {} + # « all » : les champs de ressources nourrissent le profil. + # « one » : ils écrivent une surcharge pour la ligne du plan. + self.scope = "all" + # Vrai pendant qu'on repositionne les widgets nous-mêmes : sans ce + # verrou, remettre une liste à vide déclencherait on_select_changed, + # qui réécrirait une surcharge — une boucle qui se nourrit seule. + self._syncing = False + # Dernier rang connu du curseur du plan. + self._last_row = 0 self.vms = [] self.rows = [] @@ -450,6 +460,18 @@ def run_deploy_form(ctx, run_app: bool = True): classes="freeval", disabled=True, ) + # Portée des TROIS champs ci-dessus. Le profil x1..x4 reste + # global par nature — il multiplie ce que demande chaque + # image. Ces valeurs-ci, elles, peuvent ne viser qu'une VM : + # une seule machine a besoin de 16 G, pas les huit autres. + # + # Le mode « une seule » rend les champs actifs même hors + # profil personnalisé : on y saisit une valeur absolue, pas + # un multiplicateur. + with RadioSet(id="f_scope"): + yield RadioButton(t("Apply to all VMs"), value=True) + yield RadioButton(t("Apply to the selected VM only")) + yield Static("", id="scopetarget") # Serveur par défaut : c'est ce que sert une image cloud, # et GNOME ajoute une à deux heures sur une architecture # émulée. Le plan annonce le surcoût disque. @@ -560,6 +582,7 @@ def run_deploy_form(ctx, run_app: bool = True): t("Status"), ) self._reload_catalog(first_load=True) + self._sync_res_fields() # -- catalogue et recalcul ------------------------------------- # def _entries(self): @@ -614,8 +637,16 @@ def run_deploy_form(ctx, run_app: bool = True): if self._desktop(): grow += desktop_disk self.rows = plan_rows(self.vms, domains, grow) + # Le plan doit MONTRER qu'une VM a été personnalisée : sans marque, + # deux lignes aux ressources différentes n'ont aucune explication à + # l'écran, et la surcharge est oubliée à la relecture. Le drapeau + # vit sur la ligne d'affichage, jamais sur la VM : celle-ci part + # telle quelle dans la spec, que la CLI produit à l'identique. + for row, entry in zip(self.rows, entries): + row["custom"] = bool(self.overrides.get(entry_key(entry))) self._render_plan() self._render_mise() + self.query_one("#scopetarget", Static).update(self._scope_label()) def _render_mise(self): """Grise le choix quand aucune VM retenue n'est servie par mise, @@ -671,6 +702,11 @@ def run_deploy_form(ctx, run_app: bool = True): def _render_plan(self): table = self.query_one("#plan", DataTable) + # « clear() » ramène le curseur en tête. Le plan étant recalculé à + # CHAQUE frappe, la ligne visée serait perdue entre deux saisies : + # on choisit debian, on tape 16384, le tableau se redessine, et la + # valeur suivante partirait sur ubuntu sans que rien ne le montre. + keep = table.cursor_row table.clear() # Le type ne concerne QUE les VM réellement créées : une VM déjà # définie n'est pas retouchée, lui afficher « GNOME » laisserait @@ -681,7 +717,7 @@ def run_deploy_form(ctx, run_app: bool = True): icon = {"new": "", "exists": "⏭ ", "orphan": "❌ "}[r["state"]] status = kind if r["state"] == "new" else f"{icon}{r['note']}" table.add_row( - vm["name"], + f"{vm['name']} ✎" if r.get("custom") else vm["name"], vm["distro"], vm["version"], vm["arch"], @@ -690,6 +726,10 @@ def run_deploy_form(ctx, run_app: bool = True): f"{r['disk_gb']}G", status, ) + if self.rows: + # Borné : décocher une entrée raccourcit la liste, et un rang + # devenu hors bornes ferait perdre la ligne visée pour de bon. + table.move_cursor(row=min(max(keep, 0), len(self.rows) - 1)) if not self.rows: # Rien de coché : un total à zéro n'apprend rien, on dit # plutôt comment remplir la liste. @@ -727,12 +767,94 @@ def run_deploy_form(ctx, run_app: bool = True): elif event.radio_set.id == "f_profile": index = event.radio_set.pressed_index self.profile = "custom" if index == 4 else str(index + 1) - custom = self.profile == "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._sync_res_fields() self._recompute() + elif event.radio_set.id == "f_scope": + self.scope = ( + "one" if event.radio_set.pressed_index == 1 else "all" + ) + # Les champs repartent à vide : ils décrivent désormais une + # AUTRE cible, et y laisser la valeur précédente ferait croire + # qu'elle s'y applique déjà. + self._reset_res_widgets() + self._sync_res_fields() + if self.scope == "one": + # Sans focus, le curseur du tableau reste invisible et on + # modifierait une ligne qu'on ne voit pas désignée. + self.query_one("#plan", DataTable).focus() + self._recompute() + + # -- portée des ressources -------------------------------------- # + def _cursor_index(self): + """Rang de la ligne visée dans le plan, ou None.""" + index = self.query_one("#plan", DataTable).cursor_row + return index if 0 <= index < len(self.vms) else None + + def _cursor_key(self): + """Identité de catalogue de la ligne visée, ou None. C'est elle qui + indexe les surcharges, pas le rang : cocher une autre distro ne doit + pas déplacer une personnalisation d'une VM à l'autre.""" + index = self._cursor_index() + if index is None: + return None + return entry_key(self._selected_entries()[index]) + + def _scope_label(self): + """Ce que la saisie va toucher, dit en toutes lettres.""" + if self.scope != "one": + n = len(self.overrides) + if not n: + return "" + return f" ✎ {n} {t('VM(s) customised (F4 resets one)')}" + index = self._cursor_index() + if index is None: + return f" ⚠ {t('Pick a line in the plan (Tab, then arrows)')}" + vm = self.vms[index] + return ( + f" ✎ {vm['name']} — {vm['vcpus']} vCPU · " + f"{vm['ram']} Mo · {vm['disk']}" + ) + + def _sync_res_fields(self) -> None: + """Active les trois champs quand ils ont un sens, et redit la cible. + + Portée « toutes » : ils ne servent que le profil personnalisé, comme + avant. Portée « une seule » : ils valent toujours, puisqu'on y saisit + la valeur absolue d'une VM et non un multiplicateur.""" + live = self.scope == "one" or self.profile == "custom" + for field, (sel, _inp) in RES_FIELDS.items(): + self.query_one(sel, Select).disabled = not live + self._show_free(field, live and self._free.get(field)) + self.query_one("#scopetarget", Static).update(self._scope_label()) + + def _reset_res_widgets(self) -> None: + """Remet listes et saisies à vide SANS déclencher d'écriture.""" + self._syncing = True + try: + for field, (sel, inp) in RES_FIELDS.items(): + self.query_one(sel, Select).value = SELECT_NULL + self.query_one(inp, Input).value = "" + self._free[field] = False + self._show_free(field, False) + finally: + self._syncing = False + + def _set_resource(self, field, value) -> None: + """Écrit une ressource là où la portée le demande.""" + if self.scope != "one": + self.custom[field] = value + return + key = self._cursor_key() + if key is None: + return + if value in ("", 0): + # Saisie vidée ou invalide : on RETIRE la surcharge au lieu + # d'écrire un zéro, qui donnerait une VM à 0 vCPU. + self.overrides.get(key, {}).pop(field, None) + if not self.overrides.get(key): + self.overrides.pop(key, None) + else: + self.overrides.setdefault(key, {})[field] = value def _show_free(self, field, visible) -> None: """Montre ou cache la saisie libre d'une ressource.""" @@ -744,6 +866,8 @@ def run_deploy_form(ctx, run_app: bool = True): self._recompute() def on_select_changed(self, event) -> None: + if self._syncing: + return field = SELECT_TO_FIELD.get(event.select.id) if not field: return @@ -756,7 +880,7 @@ def run_deploy_form(ctx, run_app: bool = True): elif event.value is not SELECT_NULL: self._free[field] = False self._show_free(field, False) - self.custom[field] = event.value + self._set_resource(field, event.value) self._recompute() def _apply_free(self, field) -> None: @@ -764,16 +888,32 @@ def run_deploy_form(ctx, run_app: bool = True): 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 "" + self._set_resource(field, parse_disk(raw) or "") else: - self.custom[field] = positive_int(raw, 0) + self._set_resource(field, positive_int(raw, 0)) def on_input_changed(self, event) -> None: + if self._syncing: + return field = INPUT_TO_FIELD.get(event.input.id) if field: self._apply_free(field) self._recompute() + def on_data_table_row_highlighted(self, event) -> None: + """Changer de ligne change la cible : on repart de champs vides, + pour ne pas reporter par mégarde le réglage de la VM précédente. + + Le rang précédent est mémorisé plutôt que testé sur un verrou : + replacer le curseur après un redessin poste le même message, et + Textual le délivre APRÈS que le verrou soit retombé.""" + if event.cursor_row == self._last_row: + return + self._last_row = event.cursor_row + if self.scope == "one": + self._reset_res_widgets() + self._sync_res_fields() + def on_checkbox_changed(self, event) -> None: if event.checkbox.id == "f_install": self._recompute() # le disque annoncé inclut le +5 G ERPLibre @@ -810,6 +950,18 @@ def run_deploy_form(ctx, run_app: bool = True): self.push_screen(EditVMScreen(dict(self.vms[index])), apply) + def action_clear_vm(self) -> None: + """Rend la VM visée au profil commun. Sans cette sortie, une + personnalisation posée par erreur ne se défaisait qu'en rouvrant le + formulaire.""" + key = self._cursor_key() + if key is None or key not in self.overrides: + return + self.overrides.pop(key) + self._reset_res_widgets() + self._sync_res_fields() + self._recompute() + def _form_values(self): install = None if self.query_one("#f_install", Checkbox).value and profiles: diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 43019ce..c59292d 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -2139,6 +2139,26 @@ TRANSLATIONS = { "fr": "Éditer la VM", "en": "Edit VM", }, + "Reset VM": { + "fr": "Réinit. VM", + "en": "Reset VM", + }, + "Apply to all VMs": { + "fr": "Appliquer à toutes les VM", + "en": "Apply to all VMs", + }, + "Apply to the selected VM only": { + "fr": "Appliquer à la VM sélectionnée seulement", + "en": "Apply to the selected VM only", + }, + "VM(s) customised (F4 resets one)": { + "fr": "VM personnalisée(s) (F4 en réinitialise une)", + "en": "VM(s) customised (F4 resets one)", + }, + "Pick a line in the plan (Tab, then arrows)": { + "fr": "Choisissez une ligne du plan (Tab, puis les flèches)", + "en": "Pick a line in the plan (Tab, then arrows)", + }, "Preview": { "fr": "Aperçu", "en": "Preview",