556 lines
22 KiB
Python
556 lines
22 KiB
Python
|
|
#!/usr/bin/env python3
|
|||
|
|
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
|||
|
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
|||
|
|
"""Écran de périmètre pour « Transform data », en TUI.
|
|||
|
|
|
|||
|
|
Deux décisions y sont prises, que les invites textuelles ne savent pas
|
|||
|
|
poser :
|
|||
|
|
|
|||
|
|
- QUELLES LIGNES nomment les colonnes. Le moteur les mesure, mais la
|
|||
|
|
mesure se trompe dans les deux sens et l'opérateur doit pouvoir la
|
|||
|
|
contredire — y compris en désignant PLUSIEURS lignes, un export portant
|
|||
|
|
souvent une ligne de catégorie au-dessus de la ligne de champs.
|
|||
|
|
- QUELLES COLONNES rester intactes. Sur un classeur ordinaire, neuf
|
|||
|
|
colonnes du même type, du même compte et sans bornes ne se départagent
|
|||
|
|
par rien d'autre que des VALEURS D'EXEMPLE, et taper des indices à
|
|||
|
|
l'aveugle dans une invite n'est pas une réponse.
|
|||
|
|
|
|||
|
|
- run_transform_form(ctx, run_app=True) : rend une spec, `None` si
|
|||
|
|
l'opérateur annule, `{}` pour retomber sur les invites textuelles.
|
|||
|
|
|
|||
|
|
Le contrat a TROIS valeurs et non deux : rendre `None` là où l'appelant
|
|||
|
|
attend `{}` supprimerait le repli textuel en silence.
|
|||
|
|
|
|||
|
|
`ctx` est de la donnée PURE, bâtie par `contexte_depuis_rapport()` à
|
|||
|
|
partir du JSON de `--report` déjà en main : aucune entrée-sortie et aucun
|
|||
|
|
sous-processus depuis l'affichage.
|
|||
|
|
"""
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
from script.todo.todo_i18n import t
|
|||
|
|
except Exception: # pragma: no cover - repli si i18n indisponible
|
|||
|
|
|
|||
|
|
def t(key: str) -> str:
|
|||
|
|
return key
|
|||
|
|
|
|||
|
|
|
|||
|
|
# Les colonnes des deux tableaux : clé i18n et largeur. Les en-têtes de
|
|||
|
|
# `SIGNES` ne passent pas par la traduction — ce sont des signes, non des
|
|||
|
|
# mots, et les faire passer par `t()` les inscrit dans tout audit de clé
|
|||
|
|
# manquante.
|
|||
|
|
#
|
|||
|
|
# `None` laisse la colonne s'ajuster à son contenu, et ne va qu'en
|
|||
|
|
# DERNIÈRE place : ce qui dépasse le volet y sort par la droite sans rien
|
|||
|
|
# masquer. Une colonne libre au MILIEU pousse hors de l'écran celles qui
|
|||
|
|
# la suivent — trois exemples de quarante caractères font cent vingt-six
|
|||
|
|
# colonnes, et les mesures, qui sont la raison de montrer ces lignes,
|
|||
|
|
# disparaissaient derrière elles.
|
|||
|
|
SIGNES = ("", "#")
|
|||
|
|
COLONNES_DU_PERIMETRE = (
|
|||
|
|
("", 3),
|
|||
|
|
("#", 4),
|
|||
|
|
("column label", 22),
|
|||
|
|
("column type", 9),
|
|||
|
|
("filled", 8),
|
|||
|
|
("distinct", 9),
|
|||
|
|
("examples", None),
|
|||
|
|
)
|
|||
|
|
COLONNES_SONDEES = (
|
|||
|
|
("", 3),
|
|||
|
|
("row no", 6),
|
|||
|
|
# `pleines` était calculé et montré à personne. C'est le seul signal
|
|||
|
|
# qui sépare un titre d'une ligne de champs quand les mesures se
|
|||
|
|
# taisent — une feuille sans en-tête retenu n'en a pas — et il les
|
|||
|
|
# sépare par un nombre : 1 pour le titre, 0 pour la ligne blanche,
|
|||
|
|
# la largeur pour les champs.
|
|||
|
|
("filled", 7),
|
|||
|
|
("first values", 28),
|
|||
|
|
("measures", None),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Le marqueur d'une colonne, à l'écran. Un caractère chacun : un glyphe
|
|||
|
|
# double-largeur décalerait la colonne suivante.
|
|||
|
|
MARQUE_EN_PORTEE = "[ ]"
|
|||
|
|
MARQUE_INTACTE = "[x]"
|
|||
|
|
MARQUE_PLANCHER = "[!]"
|
|||
|
|
MARQUE_CORRIGEE = "*"
|
|||
|
|
MARQUE_DU_LOT = "="
|
|||
|
|
|
|||
|
|
# La légende, ici et nulle part ailleurs. Elle était écrite dans
|
|||
|
|
# `compose` et RECOPIÉE dans son test, qui éprouvait donc sa propre copie :
|
|||
|
|
# une marque ajoutée sans être expliquée passait au vert.
|
|||
|
|
TEXTE_LEGENDE = (
|
|||
|
|
"[x] untouched · [!] floored · * corrected · = from the batch table"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def contexte_depuis_rapport(rapport, memoire=None):
|
|||
|
|
"""Le contexte de l'écran, tiré du rapport du moteur.
|
|||
|
|
|
|||
|
|
Pure transformation de données : c'est ce qui rend l'écran testable
|
|||
|
|
sans fichier, et ce qui garantit qu'il n'ouvre rien lui-même.
|
|||
|
|
|
|||
|
|
`memoire` porte ce qu'une table de lot se rappelle — les lignes
|
|||
|
|
d'en-tête qu'un fichier PRÉCÉDENT a fait corriger. Elle arrive
|
|||
|
|
PRÉ-COCHÉE et marquée, jamais appliquée en silence : une réponse
|
|||
|
|
fausse appliquée sans être vue est exactement comment une erreur
|
|||
|
|
gagne tout un lot.
|
|||
|
|
"""
|
|||
|
|
memoire = memoire or {}
|
|||
|
|
return {
|
|||
|
|
"fichier": rapport.get("chemin", ""),
|
|||
|
|
"format": rapport.get("format", ""),
|
|||
|
|
"feuilles": [
|
|||
|
|
{
|
|||
|
|
"nom": feuille.get("nom", ""),
|
|||
|
|
"lignes": feuille.get("lignes", 0),
|
|||
|
|
"colonnes_n": feuille.get("colonnes_n", 0),
|
|||
|
|
"lignes_entete": sorted(
|
|||
|
|
{
|
|||
|
|
int(n)
|
|||
|
|
for n in (
|
|||
|
|
memoire[feuille.get("nom", "")]
|
|||
|
|
if feuille.get("nom", "") in memoire
|
|||
|
|
else (feuille.get("lignes_entete") or [])
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
),
|
|||
|
|
"ligne_champs": feuille.get("ligne_champs"),
|
|||
|
|
"entete_declaree": bool(feuille.get("entete_declaree")),
|
|||
|
|
# D'où vient l'empan pré-coché : la table d'un lot, ou la
|
|||
|
|
# mesure de ce fichier. L'écran le MARQUE, sans quoi une
|
|||
|
|
# réponse héritée et un verdict se lisent pareil.
|
|||
|
|
"entete_memorisee": feuille.get("nom", "") in memoire,
|
|||
|
|
"lignes_sondees": list(feuille.get("lignes_sondees") or []),
|
|||
|
|
"colonnes": [
|
|||
|
|
{
|
|||
|
|
"index": colonne.get("index"),
|
|||
|
|
"etiquette": colonne.get("etiquette") or "",
|
|||
|
|
"type": colonne.get("type", ""),
|
|||
|
|
"remplies": colonne.get("remplies", 0),
|
|||
|
|
"distinctes": colonne.get("distinctes", 0),
|
|||
|
|
"exemples": list(colonne.get("exemples") or []),
|
|||
|
|
"plancher": bool(colonne.get("plancher")),
|
|||
|
|
}
|
|||
|
|
for colonne in (feuille.get("colonnes") or [])
|
|||
|
|
],
|
|||
|
|
}
|
|||
|
|
for feuille in (rapport.get("feuilles") or [])
|
|||
|
|
],
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def libelle_de_colonne(colonne, marque):
|
|||
|
|
"""Les six cellules d'une ligne de colonne, prêtes à afficher.
|
|||
|
|
|
|||
|
|
`‹sans libellé›` plutôt qu'un blanc : c'est le cas de la majorité des
|
|||
|
|
colonnes d'un export réel, et un blanc se lit comme une erreur
|
|||
|
|
d'affichage plutôt que comme un fait.
|
|||
|
|
"""
|
|||
|
|
return (
|
|||
|
|
marque,
|
|||
|
|
str(colonne["index"]),
|
|||
|
|
colonne["etiquette"] or t("‹no label›"),
|
|||
|
|
colonne["type"],
|
|||
|
|
str(colonne["remplies"]),
|
|||
|
|
str(colonne["distinctes"]),
|
|||
|
|
" · ".join(colonne["exemples"]),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def libelle_de_ligne_sondee(ligne, cochee):
|
|||
|
|
"""Les cinq cellules d'une ligne sondée.
|
|||
|
|
|
|||
|
|
Les mesures sont montrées pour que l'opérateur voie POURQUOI la
|
|||
|
|
mesure a tranché avant de la contredire — contredire un verdict qu'on
|
|||
|
|
ne voit pas est un pari.
|
|||
|
|
"""
|
|||
|
|
mesure = ligne.get("mesure") or {}
|
|||
|
|
return (
|
|||
|
|
MARQUE_INTACTE if cochee else MARQUE_EN_PORTEE,
|
|||
|
|
str(ligne["numero"]),
|
|||
|
|
str(ligne.get("pleines", "")),
|
|||
|
|
" · ".join(ligne.get("apercu") or []),
|
|||
|
|
(
|
|||
|
|
"%s %.2f %s %.2f %s %.2f"
|
|||
|
|
% (
|
|||
|
|
t("sig type"),
|
|||
|
|
mesure.get("contraste", 0.0),
|
|||
|
|
t("off-col"),
|
|||
|
|
mesure.get("hors_colonne", 0.0),
|
|||
|
|
t("shape"),
|
|||
|
|
mesure.get("accord", 0.0),
|
|||
|
|
)
|
|||
|
|
if mesure
|
|||
|
|
else ""
|
|||
|
|
),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def cle_de_colonne(colonne):
|
|||
|
|
"""Ce par quoi une réponse DÉSIGNE cette colonne : son INDEX.
|
|||
|
|
|
|||
|
|
Pas son étiquette, bien que ce soit ce que l'invite textuelle emploie.
|
|||
|
|
Corriger l'empan d'en-tête RENOMME les colonnes — la ligne de champs
|
|||
|
|
change, donc les étiquettes aussi — et une réponse portée par
|
|||
|
|
l'étiquette tombait alors sur une autre colonne, ou sur aucune, sans
|
|||
|
|
que rien ne le dise. L'index ne bouge pas.
|
|||
|
|
|
|||
|
|
L'ambiguïté qui oblige l'invite à préférer l'étiquette — « 1 » désigne
|
|||
|
|
à la fois la colonne étiquetée « 1 » et la première — n'existe pas
|
|||
|
|
ici : l'écran tient l'objet colonne, il ne tape pas une chaîne. La
|
|||
|
|
spec les porte donc dans deux clés distinctes.
|
|||
|
|
"""
|
|||
|
|
return colonne["index"]
|
|||
|
|
|
|||
|
|
|
|||
|
|
def basculer(ensemble, valeur):
|
|||
|
|
"""Ajouter ou retirer, et dire ce qui en résulte.
|
|||
|
|
|
|||
|
|
Séparé du rendu pour être éprouvable : les méthodes de l'écran
|
|||
|
|
appellent celle-ci PUIS redessinent, si bien que la décision se teste
|
|||
|
|
sans monter un seul widget.
|
|||
|
|
"""
|
|||
|
|
if valeur in ensemble:
|
|||
|
|
ensemble.discard(valeur)
|
|||
|
|
return False
|
|||
|
|
ensemble.add(valeur)
|
|||
|
|
return True
|
|||
|
|
|
|||
|
|
|
|||
|
|
def spec_depuis_etat(ctx, intactes, entetes, corrigees=()):
|
|||
|
|
"""La spec rendue à l'appelant, en types SÉRIALISABLES.
|
|||
|
|
|
|||
|
|
Un `set` et un dict à clés tuple sont refusés par `json.dumps`, et
|
|||
|
|
ces valeurs traversent le sous-processus du moteur : ce sont donc des
|
|||
|
|
listes triées, et des dicts à clés `str`.
|
|||
|
|
|
|||
|
|
`entetes_par_feuille` porte CHAQUE feuille, y compris celles que
|
|||
|
|
l'opérateur n'a pas touchées : c'est ce qui a été montré à l'écran, et
|
|||
|
|
c'est là-dessus qu'il a consenti. Un empan vide y veut dire « pas
|
|||
|
|
d'en-tête », là où une feuille ABSENTE voudrait dire « mesure-la ».
|
|||
|
|
|
|||
|
|
`entetes_corrigees` nomme les seules feuilles qu'il a VRAIMENT
|
|||
|
|
changées, et c'est la seule chose qu'une table de lot doit retenir :
|
|||
|
|
retenir un empan mesuré l'imposerait au fichier suivant même si sa
|
|||
|
|
propre mesure disait autre chose — la mesure d'un fichier deviendrait
|
|||
|
|
la loi de tout le lot.
|
|||
|
|
"""
|
|||
|
|
return {
|
|||
|
|
"entetes_corrigees": sorted(corrigees),
|
|||
|
|
"colonnes_intactes_index_par_feuille": {
|
|||
|
|
nom: sorted(rangs) for nom, rangs in intactes.items() if rangs
|
|||
|
|
},
|
|||
|
|
"entetes_par_feuille": {
|
|||
|
|
feuille["nom"]: sorted(entetes.get(feuille["nom"], set()))
|
|||
|
|
for feuille in ctx["feuilles"]
|
|||
|
|
},
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def run_transform_form(ctx, run_app: bool = True):
|
|||
|
|
"""Écran de périmètre. Rend une spec, None si annulé, {} pour les
|
|||
|
|
invites textuelles. `run_app=False` rend l'instance sans la lancer
|
|||
|
|
(tests headless)."""
|
|||
|
|
# TOUS les imports textual ICI : le CLI importe ce module pour ses
|
|||
|
|
# fonctions pures, et les libellés des BINDINGS ne doivent être
|
|||
|
|
# évalués qu'à l'appel, après le choix de la langue.
|
|||
|
|
from textual.app import App, ComposeResult
|
|||
|
|
from textual.containers import Horizontal, Vertical
|
|||
|
|
from rich.text import Text
|
|||
|
|
from textual.widgets import DataTable, Footer, Header, OptionList, Static
|
|||
|
|
from textual.widgets.option_list import Option
|
|||
|
|
|
|||
|
|
def brut(valeur):
|
|||
|
|
"""Une chaîne rendue TELLE QUELLE, sans balisage console.
|
|||
|
|
|
|||
|
|
Rich lit `[x]` comme une balise de style et l'AVALE : la case
|
|||
|
|
cochée n'apparaissait jamais, ni dans le tableau ni dans la
|
|||
|
|
légende, et l'écran perdait son seul retour visuel. `[ ]` et
|
|||
|
|
`[!]` survivaient, ce qui rendait le défaut illisible — la case
|
|||
|
|
vide s'affichait, la case cochée disparaissait.
|
|||
|
|
Ça ne concerne pas que les marques : la colonne des exemples
|
|||
|
|
porte des valeurs du fichier, et l'une qui contient des crochets
|
|||
|
|
serait mangée ou interprétée comme un style.
|
|||
|
|
"""
|
|||
|
|
return Text(str(valeur))
|
|||
|
|
|
|||
|
|
resultat = {"spec": None}
|
|||
|
|
# Par feuille : les clés de colonne laissées intactes, et l'empan
|
|||
|
|
# d'en-tête. L'empan part de ce que le moteur a MESURÉ ; les cases de
|
|||
|
|
# colonne partent vides, une réponse n'étant jamais pré-cochée.
|
|||
|
|
intactes = {f["nom"]: set() for f in ctx["feuilles"]}
|
|||
|
|
entetes = {
|
|||
|
|
f["nom"]: set(f["lignes_entete"] or ()) for f in ctx["feuilles"]
|
|||
|
|
}
|
|||
|
|
corrigees = set()
|
|||
|
|
|
|||
|
|
class Perimetre(App):
|
|||
|
|
CSS = """
|
|||
|
|
#tete { height: auto; padding: 0 1; color: $text-muted; }
|
|||
|
|
#feuilles { width: 30; border: solid $panel; }
|
|||
|
|
#colonnes { height: 3fr; min-height: 4; border: solid $accent; }
|
|||
|
|
#sondees { height: 2fr; min-height: 4; border: solid $panel; }
|
|||
|
|
#legende { height: auto; color: $text-muted; padding: 0 1; }
|
|||
|
|
"""
|
|||
|
|
BINDINGS = [
|
|||
|
|
("space", "basculer", t("Toggle the current row")),
|
|||
|
|
("f2", "intacte", t("Leave this column untouched")),
|
|||
|
|
("f4", "rendre", t("Clear this sheet")),
|
|||
|
|
("f5", "accepter", t("Accept")),
|
|||
|
|
("f9", "invites", t("Fall back to text prompts")),
|
|||
|
|
("escape", "annuler", t("Cancel")),
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
def __init__(self):
|
|||
|
|
super().__init__()
|
|||
|
|
self.rang_feuille = 0
|
|||
|
|
# Poser `highlighted` ÉMET l'événement de surbrillance,
|
|||
|
|
# délivré PLUS TARD : avant que les tableaux soient dans le
|
|||
|
|
# DOM au montage, et après qu'un verrou temporel se serait
|
|||
|
|
# relâché. D'où deux parades distinctes — tolérer un DOM
|
|||
|
|
# incomplet, et ne redessiner que si la feuille a VRAIMENT
|
|||
|
|
# changé. Mesuré sans la seconde : 327 rendus au montage et
|
|||
|
|
# 728 par touche, soit deux secondes par frappe.
|
|||
|
|
self._pret = False
|
|||
|
|
|
|||
|
|
# -- montage ---------------------------------------------------- #
|
|||
|
|
def compose(self) -> ComposeResult:
|
|||
|
|
yield Header()
|
|||
|
|
# Le CHEMIN du fichier, qui peut porter des crochets.
|
|||
|
|
yield Static(
|
|||
|
|
brut(" %s · %s" % (ctx["fichier"], ctx["format"])),
|
|||
|
|
id="tete",
|
|||
|
|
)
|
|||
|
|
with Horizontal():
|
|||
|
|
yield OptionList(id="feuilles")
|
|||
|
|
with Vertical():
|
|||
|
|
yield DataTable(id="colonnes")
|
|||
|
|
yield DataTable(id="sondees")
|
|||
|
|
yield Static(brut(" %s" % t(TEXTE_LEGENDE)), id="legende")
|
|||
|
|
yield Footer()
|
|||
|
|
|
|||
|
|
def on_mount(self) -> None:
|
|||
|
|
self.title = t("Transform — anonymisation scope")
|
|||
|
|
liste = self.query_one("#feuilles", OptionList)
|
|||
|
|
for feuille in ctx["feuilles"]:
|
|||
|
|
liste.add_option(
|
|||
|
|
Option(
|
|||
|
|
brut(self._resume_feuille(feuille)), id=feuille["nom"]
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
colonnes = self.query_one("#colonnes", DataTable)
|
|||
|
|
colonnes.cursor_type = "row"
|
|||
|
|
self._poser_colonnes(colonnes, COLONNES_DU_PERIMETRE)
|
|||
|
|
sondees = self.query_one("#sondees", DataTable)
|
|||
|
|
sondees.cursor_type = "row"
|
|||
|
|
self._poser_colonnes(sondees, COLONNES_SONDEES)
|
|||
|
|
self._pret = True
|
|||
|
|
if ctx["feuilles"]:
|
|||
|
|
liste.highlighted = 0
|
|||
|
|
self._remplir()
|
|||
|
|
|
|||
|
|
@staticmethod
|
|||
|
|
def _poser_colonnes(tableau, colonnes):
|
|||
|
|
"""Les colonnes d'un tableau, avec leur largeur.
|
|||
|
|
|
|||
|
|
`add_columns` ne prend pas de largeur : chaque colonne se pose
|
|||
|
|
donc une par une.
|
|||
|
|
"""
|
|||
|
|
for cle, largeur in colonnes:
|
|||
|
|
libelle = cle if cle in SIGNES else t(cle)
|
|||
|
|
tableau.add_column(libelle, width=largeur)
|
|||
|
|
|
|||
|
|
# -- rendu ------------------------------------------------------ #
|
|||
|
|
def _feuille(self):
|
|||
|
|
if not ctx["feuilles"]:
|
|||
|
|
return None
|
|||
|
|
return ctx["feuilles"][self.rang_feuille]
|
|||
|
|
|
|||
|
|
@staticmethod
|
|||
|
|
def _resume_feuille(feuille):
|
|||
|
|
"""« Ventes 1 * » — le rang de la ligne de champs, ou « - ».
|
|||
|
|
|
|||
|
|
L'astérisque dit que l'opérateur a corrigé la mesure : sans
|
|||
|
|
lui, une correction et un verdict se lisent pareil.
|
|||
|
|
"""
|
|||
|
|
empan = sorted(entetes.get(feuille["nom"], set()))
|
|||
|
|
rang = str(max(empan)) if empan else "-"
|
|||
|
|
if feuille["nom"] in corrigees:
|
|||
|
|
marque = " " + MARQUE_CORRIGEE
|
|||
|
|
elif feuille.get("entete_memorisee"):
|
|||
|
|
# Hérité d'un fichier du même lot : montré, non subi.
|
|||
|
|
marque = " " + MARQUE_DU_LOT
|
|||
|
|
else:
|
|||
|
|
marque = ""
|
|||
|
|
return " %-20s %s%s" % (feuille["nom"][:20], rang, marque)
|
|||
|
|
|
|||
|
|
def _widget(self, selecteur, genre):
|
|||
|
|
"""Le widget s'il est DÉJÀ dans le DOM, sinon None.
|
|||
|
|
|
|||
|
|
Textual délivre ses messages de façon asynchrone : un
|
|||
|
|
événement de surbrillance émis au montage arrive avant que
|
|||
|
|
les tableaux soient montés, et `query_one` y lève. Tolérer
|
|||
|
|
l'absence vaut mieux qu'un verrou temporel, qui suppose un
|
|||
|
|
ordre que rien ne garantit.
|
|||
|
|
"""
|
|||
|
|
trouves = self.query(selecteur)
|
|||
|
|
return trouves.first(genre) if trouves else None
|
|||
|
|
|
|||
|
|
def _remplir(self):
|
|||
|
|
feuille = self._feuille()
|
|||
|
|
if feuille is None or not self._pret:
|
|||
|
|
return
|
|||
|
|
colonnes = self._widget("#colonnes", DataTable)
|
|||
|
|
sondees_w = self._widget("#sondees", DataTable)
|
|||
|
|
liste_w = self._widget("#feuilles", OptionList)
|
|||
|
|
if colonnes is None or sondees_w is None or liste_w is None:
|
|||
|
|
return
|
|||
|
|
garde = colonnes.cursor_row
|
|||
|
|
colonnes.clear()
|
|||
|
|
for colonne in feuille["colonnes"]:
|
|||
|
|
if colonne["plancher"]:
|
|||
|
|
marque = MARQUE_PLANCHER
|
|||
|
|
elif cle_de_colonne(colonne) in intactes[feuille["nom"]]:
|
|||
|
|
marque = MARQUE_INTACTE
|
|||
|
|
else:
|
|||
|
|
marque = MARQUE_EN_PORTEE
|
|||
|
|
colonnes.add_row(
|
|||
|
|
*(brut(c) for c in libelle_de_colonne(colonne, marque))
|
|||
|
|
)
|
|||
|
|
if 0 <= garde < len(feuille["colonnes"]):
|
|||
|
|
colonnes.move_cursor(row=garde)
|
|||
|
|
|
|||
|
|
sondees = sondees_w
|
|||
|
|
garde = sondees.cursor_row
|
|||
|
|
sondees.clear()
|
|||
|
|
for ligne in feuille["lignes_sondees"]:
|
|||
|
|
sondees.add_row(
|
|||
|
|
*(
|
|||
|
|
brut(c)
|
|||
|
|
for c in libelle_de_ligne_sondee(
|
|||
|
|
ligne,
|
|||
|
|
ligne["numero"] in entetes[feuille["nom"]],
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
if 0 <= garde < len(feuille["lignes_sondees"]):
|
|||
|
|
sondees.move_cursor(row=garde)
|
|||
|
|
|
|||
|
|
def _rafraichir_les_feuilles(self):
|
|||
|
|
"""Le résumé d'une feuille change quand son EMPAN change.
|
|||
|
|
|
|||
|
|
Hors du rendu des tableaux, et appelé seulement par ce qui
|
|||
|
|
touche l'empan : reconstruire la liste réémet la surbrillance,
|
|||
|
|
et le faire à chaque rendu bouclait.
|
|||
|
|
"""
|
|||
|
|
liste = self._widget("#feuilles", OptionList)
|
|||
|
|
if liste is None:
|
|||
|
|
return
|
|||
|
|
rang = liste.highlighted
|
|||
|
|
liste.clear_options()
|
|||
|
|
for autre in ctx["feuilles"]:
|
|||
|
|
liste.add_option(
|
|||
|
|
Option(brut(self._resume_feuille(autre)), id=autre["nom"])
|
|||
|
|
)
|
|||
|
|
if rang is not None:
|
|||
|
|
liste.highlighted = rang
|
|||
|
|
|
|||
|
|
# -- navigation ------------------------------------------------- #
|
|||
|
|
def on_option_list_option_highlighted(self, event) -> None:
|
|||
|
|
if not self._pret:
|
|||
|
|
return
|
|||
|
|
noms = [f["nom"] for f in ctx["feuilles"]]
|
|||
|
|
if event.option.id not in noms:
|
|||
|
|
return
|
|||
|
|
rang = noms.index(event.option.id)
|
|||
|
|
# LA garde : reconstruire la liste réémet cet événement avec
|
|||
|
|
# le MÊME rang. Ne redessiner que sur un changement réel coupe
|
|||
|
|
# la boucle à sa racine, là où un verrou temporel échoue,
|
|||
|
|
# l'événement étant délivré après son relâchement.
|
|||
|
|
if rang == self.rang_feuille:
|
|||
|
|
return
|
|||
|
|
self.rang_feuille = rang
|
|||
|
|
self._remplir()
|
|||
|
|
|
|||
|
|
# -- décisions -------------------------------------------------- #
|
|||
|
|
def basculer_colonne(self, rang):
|
|||
|
|
"""Une colonne PLANCHÉIÉE ne se coche pas.
|
|||
|
|
|
|||
|
|
Le plancher passe avant la réponse dans l'ordre des gardes :
|
|||
|
|
une case qui ne changerait rien serait un mensonge.
|
|||
|
|
"""
|
|||
|
|
feuille = self._feuille()
|
|||
|
|
if feuille is None or not (0 <= rang < len(feuille["colonnes"])):
|
|||
|
|
return
|
|||
|
|
colonne = feuille["colonnes"][rang]
|
|||
|
|
if colonne["plancher"]:
|
|||
|
|
self.notify(t("This column is floored: no answer applies."))
|
|||
|
|
return
|
|||
|
|
basculer(intactes[feuille["nom"]], cle_de_colonne(colonne))
|
|||
|
|
self._remplir()
|
|||
|
|
|
|||
|
|
def basculer_ligne(self, rang):
|
|||
|
|
feuille = self._feuille()
|
|||
|
|
sondees = feuille["lignes_sondees"] if feuille else []
|
|||
|
|
if not (0 <= rang < len(sondees)):
|
|||
|
|
return
|
|||
|
|
basculer(entetes[feuille["nom"]], sondees[rang]["numero"])
|
|||
|
|
corrigees.add(feuille["nom"])
|
|||
|
|
self._remplir()
|
|||
|
|
self._rafraichir_les_feuilles()
|
|||
|
|
|
|||
|
|
def action_basculer(self) -> None:
|
|||
|
|
"""L'espace agit sur le panneau qui a le focus."""
|
|||
|
|
focus = self.focused
|
|||
|
|
if focus is None:
|
|||
|
|
return
|
|||
|
|
if focus is self._widget("#sondees", DataTable):
|
|||
|
|
self.basculer_ligne(focus.cursor_row)
|
|||
|
|
elif focus is self._widget("#colonnes", DataTable):
|
|||
|
|
self.basculer_colonne(focus.cursor_row)
|
|||
|
|
|
|||
|
|
def action_intacte(self) -> None:
|
|||
|
|
colonnes = self._widget("#colonnes", DataTable)
|
|||
|
|
if colonnes is not None:
|
|||
|
|
self.basculer_colonne(colonnes.cursor_row)
|
|||
|
|
|
|||
|
|
def action_rendre(self) -> None:
|
|||
|
|
"""Rendre la feuille à ce que le moteur a mesuré."""
|
|||
|
|
feuille = self._feuille()
|
|||
|
|
if feuille is None:
|
|||
|
|
return
|
|||
|
|
intactes[feuille["nom"]] = set()
|
|||
|
|
entetes[feuille["nom"]] = set(feuille["lignes_entete"] or ())
|
|||
|
|
corrigees.discard(feuille["nom"])
|
|||
|
|
self._remplir()
|
|||
|
|
self._rafraichir_les_feuilles()
|
|||
|
|
|
|||
|
|
def action_accepter(self) -> None:
|
|||
|
|
resultat["spec"] = spec_depuis_etat(
|
|||
|
|
ctx, intactes, entetes, corrigees
|
|||
|
|
)
|
|||
|
|
self.exit()
|
|||
|
|
|
|||
|
|
def action_invites(self) -> None:
|
|||
|
|
"""Rendre `{}` et non `None` : l'appelant distingue
|
|||
|
|
« poser les questions » de « annuler »."""
|
|||
|
|
resultat["spec"] = {}
|
|||
|
|
self.exit()
|
|||
|
|
|
|||
|
|
def action_annuler(self) -> None:
|
|||
|
|
resultat["spec"] = None
|
|||
|
|
self.exit()
|
|||
|
|
|
|||
|
|
app = Perimetre()
|
|||
|
|
# Lus par les tests headless, qui pilotent l'app sans écran.
|
|||
|
|
app._resultat = resultat
|
|||
|
|
app._intactes = intactes
|
|||
|
|
app._entetes = entetes
|
|||
|
|
app._corrigees = corrigees
|
|||
|
|
if not run_app:
|
|||
|
|
return app
|
|||
|
|
app.run()
|
|||
|
|
return resultat["spec"]
|