erplibre/script/analyse/check_module_dependency_tui.py

345 lines
11 KiB
Python
Raw Permalink Normal View History

# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Les modules d'une base et leurs dépendances, en plein écran.
Une base porte trois à six cents modules. Le rapport texte les liste tous
et la question qu'on se pose — « celui-ci, qui en dépend » — se trouve
quelque part au milieu de plusieurs milliers de lignes.
Les données viennent de `check_module_dependency`, comme le rapport
texte : un seul assemblage, donc une seule vérité sur la même base.
"""
import os
import sys
sys.path.append(
os.path.normpath(os.path.join(os.path.dirname(__file__), "..", ".."))
)
from script.analyse import check_module_dependency as dependency # noqa: E402
try:
from script.todo.todo_i18n import t
except Exception: # pragma: no cover - repli si i18n indisponible
def t(key: str) -> str:
return key
CSS = """
Screen { layout: vertical; }
#head { height: 3; padding: 0 1; background: $panel; color: $text; }
#body { height: 1fr; }
#left { width: 46; border-right: solid $accent; }
#pane { width: 1fr; padding: 0 1; }
#find { display: none; }
#find.visible { display: block; }
"""
def filter_label(filtre):
return {
"all": t("all modules"),
"installed": t("installed only"),
"pending": t("in progress"),
"application": t("apps only"),
"absent": t("not installed"),
"broken": t("broken dependencies"),
}.get(filtre, filtre)
def mode_label(mode):
"""Le nom du mode courant, pour le sous-titre.
Un panneau qui change sans dire pourquoi se lit comme un écran cassé.
"""
if mode is None:
return t("summary")
return t(dependency.TITRE_DETAIL.get(mode, mode))
def subtitle(mode, filtre, motif):
morceaux = [mode_label(mode), filter_label(filtre)]
if motif:
morceaux.append(f"« {motif} »")
return " · ".join(morceaux)
def matching(lst_row, motif):
"""Les lignes dont le nom contient `motif`, sans égard à la casse."""
if not motif:
return lst_row
bas = motif.lower()
return [row for row in lst_row if bas in row["name"].lower()]
def next_mode(mode):
"""Le mode suivant : résumé → les quatre relations → résumé."""
suite = (None,) + dependency.DETAILS
courant = mode if mode in suite else None
return suite[(suite.index(courant) + 1) % len(suite)]
def toggle_filter(courant, cible):
"""Aller à `cible` — ou en revenir si l'on y est déjà.
Une touche qui ne fait qu'ALLER piège : arrivé sur « en cours », il
faudrait se souvenir de quelle autre touche ramène à la liste
entière. La même touche fait donc l'aller et le retour.
"""
return "all" if courant == cible else cible
def next_filter(filtre):
"""Le filtre suivant, en boucle."""
suite = dependency.FILTRES
index = suite.index(filtre) if filtre in suite else 0
return suite[(index + 1) % len(suite)]
def current_name(lst_row, index):
"""Le module sous le curseur, ou None si la liste est vide.
`index` peut dépasser : la table garde son curseur d'avant quand la
liste raccourcit, et lire hors bornes ferait tomber l'écran sur une
IndexError au moment précis où l'on filtre.
"""
if not lst_row or index is None or not 0 <= index < len(lst_row):
return None
return lst_row[index]["name"]
def cursor_for(lst_row, garde):
"""Où replacer le curseur pour retrouver `garde`. None si parti.
Changer de filtre ramenait le curseur en tête, donc on perdait le
module qu'on lisait — c'est-à-dire la raison même du filtre.
"""
if not garde:
return None
for index, row in enumerate(lst_row):
if row["name"] == garde:
return index
return None
def populate(table, lst_row, garde=None):
"""Remplir la table, puis y remettre le curseur sur `garde`.
Prend la table plutôt que l'App : rien ici ne dépend de Textual sauf
trois appels de méthode, donc un faux objet suffit à l'éprouver.
"""
table.clear()
for row in lst_row:
table.add_row(row["label"][:40], row["detail"])
place = cursor_for(lst_row, garde)
if place is not None:
table.move_cursor(row=place)
return place
def hide_find(champ, table):
"""Refermer la recherche et rendre le clavier à la liste.
Le `focus()` explicite n'est pas une précaution : sans lui, Textual
donne le clavier au premier widget focalisable, et c'est le champ de
recherche — « display: none » ne le retire pas de ce choix-là. On
ouvrait donc l'écran en tapant dans une boîte invisible, et « d » ne
faisait rien. (La TABULATION, elle, saute bien un widget caché :
mesuré, d'où l'absence de `can_focus` ici — une ligne qu'on ne peut
pas faire échouer n'a rien à faire dans le fichier.)
Ne vide PAS le champ : l'appelant décide s'il efface le motif, et
c'est cet effacement qui redéclenche le filtrage.
"""
champ.remove_class("visible")
table.focus()
def build_app(rapport):
"""Textual est importé ICI : le module reste testable sans lui."""
from rich.text import Text
from textual import on
from textual.app import App, ComposeResult
from textual.containers import Horizontal, VerticalScroll
from textual.widgets import DataTable, Footer, Header, Input, Static
class DependencyApp(App):
CSS = globals()["CSS"]
BINDINGS = [
("q", "quit", t("Quit")),
("escape", "leave", t("Quit")),
("d", "cycle_detail", t("Dependencies")),
("f", "cycle_filter", t("Filter")),
("i", "set_filter('installed')", t("Installed")),
("u", "set_filter('pending')", t("In progress")),
("t", "set_filter('application')", t("Apps")),
("slash", "find", t("Search")),
]
def __init__(self, rapport):
super().__init__()
self.rapport = rapport
self.mode = None
self.filtre = "all"
self.motif = ""
self.lst_row = []
def compose(self) -> ComposeResult:
yield Header()
yield Static("", id="head")
with Horizontal(id="body"):
yield DataTable(id="left", cursor_type="row")
with VerticalScroll(id="pane"):
yield Static("", id="content")
yield Input(placeholder=t("module name…"), id="find")
yield Footer()
def on_mount(self):
self.title = t("Modules and their dependencies")
table = self.query_one("#left", DataTable)
table.add_columns(t("module"), "↓↑")
hide_find(self.query_one("#find", Input), table)
self.query_one("#head", Static).update(
dependency.head_text(self.rapport)
)
self._fill()
def _fill(self):
"""Reconstruire la liste, en gardant le module sous le curseur.
Sans cela, changer de filtre ramène le curseur en tête et l'on
perd le module qu'on était en train de lire — c'est-à-dire la
raison pour laquelle on a filtré.
"""
garde = self._current_name()
self.lst_row = matching(
dependency.rows(self.rapport, self.filtre), self.motif
)
populate(self.query_one("#left", DataTable), self.lst_row, garde)
self._show()
def _current_name(self):
table = self.query_one("#left", DataTable)
return current_name(self.lst_row, table.cursor_row)
def _show(self):
self.sub_title = subtitle(self.mode, self.filtre, self.motif)
self.query_one("#content", Static).update(
Text.from_ansi(
dependency.pane_text(
self.rapport,
self._current_name(),
mode=self.mode,
colour=True,
)
)
)
def action_cycle_detail(self):
"""Parcourir les quatre relations, puis revenir au résumé.
« ce dont il dépend » et « ce qui en dépend » répondent à deux
questions opposées, et les fermetures disent l'ampleur réelle :
retirer un module en entraîne parfois trente.
"""
self.mode = next_mode(self.mode)
self._show()
def action_cycle_filter(self):
self.filtre = next_filter(self.filtre)
self._fill()
def action_set_filter(self, cible):
"""Sauter droit à un filtre. `f` reste là pour les parcourir."""
self.filtre = toggle_filter(self.filtre, cible)
self._fill()
def action_find(self):
champ = self.query_one("#find", Input)
champ.add_class("visible")
champ.focus()
def action_leave(self):
"""Échap referme la recherche ; il ne quitte que sinon.
Quitter parce qu'on renonce à une recherche serait une
surprise coûteuse : on a parfois filtré trois mille modules
pour arriver là.
"""
champ = self.query_one("#find", Input)
if not champ.has_class("visible"):
self.exit()
return
# Vider le champ suffit à tout refaire : l'événement
# `Input.Changed` remet le motif à zéro et reconstruit la
# liste. Le refaire ici à la main donnerait deux chemins pour
# le même état, dont un seul serait jamais éprouvé.
champ.value = ""
hide_find(champ, self.query_one("#left", DataTable))
@on(Input.Changed, "#find")
def _find_changed(self, event):
self.motif = event.value.strip()
self._fill()
@on(Input.Submitted, "#find")
def _find_submitted(self, event):
# Le champ reste VISIBLE : il porte le motif en cours, et le
# cacher laisserait une liste filtrée sans dire par quoi.
self.query_one("#left", DataTable).focus()
@on(DataTable.RowHighlighted, "#left")
def _row_changed(self, event):
self._show()
return DependencyApp(rapport)
def in_event_loop():
"""Une boucle asyncio tourne-t-elle déjà dans CE processus ?"""
import asyncio
try:
asyncio.get_running_loop()
except RuntimeError:
return False
return True
def run_tui(rapport, run_app=True):
"""Ouvrir l'écran. False si l'on n'a pas pu — et alors on DIT pourquoi."""
if not rapport or rapport.get("unavailable"):
return False
if not rapport.get("modules"):
return False
if not sys.stdout.isatty():
print(f"ℹ️ {t('Not a terminal: showing the text report instead.')}")
return False
try:
from script.todo import textual_setup
except Exception:
textual_setup = None
if textual_setup and not textual_setup.ensure():
return False
try:
app = build_app(rapport)
except ImportError:
print(
f"ℹ️ {t('Textual is missing from this interpreter:')}"
f" {sys.executable}"
)
return False
if not run_app:
return app
if in_event_loop():
print(
f"ℹ️ {t('Already inside a running screen: open it in its own')}"
f" {t('process instead.')}"
)
return False
app.run()
return True