[ADD] migration: announce the countdown, and cycle three panel states
Every prompt now carries « ⏱5s » before the question. One that looks like it waits forever does not invite you to step away, which is the whole point of auto-run. The same clock reports the answer afterwards: it announces the wait, then says what it decided. Enter on the auto-run question already meant no; it now says so, with an explicit default instead of a deduction. And the flag is cleared before asking — an ERPLIBRE_AUTO_EXECUTE inherited from the shell would have answered yes on someone's behalf, and that answer commits every later one. « p » cycles three states rather than toggling one: a plain toggle freed four lines, while what takes the room is the left COLUMN — on a server log every character of width counts. All → no summary → detail only, and back. The state is named in the sub-title, which stays visible in all three: a panel that vanishes without a word reads as a broken screen. --- FR --- [ADD] migration : annoncer le compte à rebours, et trois états de panneaux Chaque invite porte désormais « ⏱5s » devant la question. Une invite qui a l'air d'attendre indéfiniment n'invite pas à s'absenter, et c'est pourtant tout l'intérêt du mode auto. Le même chronomètre rend compte ensuite. Entrée sur la question d'auto-exécution voulait déjà dire non ; elle le DIT maintenant, par un défaut explicite plutôt qu'une déduction. Et le drapeau est effacé avant de demander : une variable héritée du shell aurait répondu « oui » à la place de quelqu'un. « p » enchaîne trois états au lieu d'en basculer un : ce qui prend la place, c'est la colonne de gauche. Tout → sans résumé → détail seul, et l'on revient. L'état est nommé dans le sous-titre. Assisted-by: Claude Opus 5
This commit is contained in:
parent
e981500c94
commit
6dff7aa304
6 changed files with 274 additions and 26 deletions
|
|
@ -86,12 +86,15 @@ def ask(prompt, default="", seconds=None):
|
|||
return input(prompt) or default
|
||||
import select
|
||||
|
||||
sys.stdout.write(prompt)
|
||||
attente = delay() if seconds is None else seconds
|
||||
# DIRE qu'on répondra tout seul, et dans combien de temps. Une invite
|
||||
# qui a l'air d'attendre indéfiniment n'invite pas à s'absenter, et
|
||||
# c'est pourtant tout l'intérêt du mode auto. Devant l'invite plutôt
|
||||
# qu'après : elle se lit avant qu'on décide de partir.
|
||||
sys.stdout.write(f"⏱{attente:g}s {prompt}")
|
||||
sys.stdout.flush()
|
||||
try:
|
||||
ready, _, _ = select.select(
|
||||
[sys.stdin], [], [], delay() if seconds is None else seconds
|
||||
)
|
||||
ready, _, _ = select.select([sys.stdin], [], [], attente)
|
||||
except (OSError, ValueError):
|
||||
# stdin n'est pas sélectionnable : on ne devine pas, on demande.
|
||||
return input("") or default
|
||||
|
|
|
|||
|
|
@ -154,6 +154,64 @@ def pane_text(dct, row, colour=False, show_log=True):
|
|||
return "\n".join(lignes)
|
||||
|
||||
|
||||
PANELS = ("all", "no-summary", "detail")
|
||||
|
||||
|
||||
def panel_label(state):
|
||||
"""Le nom de l'état courant, tel qu'il s'affiche dans le sous-titre."""
|
||||
return {
|
||||
"all": t("summary + list + detail"),
|
||||
"no-summary": t("list + detail"),
|
||||
"detail": t("detail only"),
|
||||
}[PANELS[state % len(PANELS)]]
|
||||
|
||||
|
||||
def panel_visibility(state):
|
||||
"""(résumé visible, liste visible) pour cet état.
|
||||
|
||||
Trois états, du plus complet au plus dépouillé. Un simple bascule ne
|
||||
libérait que quatre lignes ; ce qui prend la place, c'est la COLONNE de
|
||||
gauche — et sur un journal de serveur, chaque caractère gagné en
|
||||
largeur compte.
|
||||
"""
|
||||
etat = PANELS[state % len(PANELS)]
|
||||
return etat == "all", etat != "detail"
|
||||
|
||||
|
||||
def current_row(lst_row, index):
|
||||
"""La ligne choisie, ou None quand il n'y en a aucune."""
|
||||
return lst_row[index] if lst_row and 0 <= index < len(lst_row) else None
|
||||
|
||||
|
||||
def reread(path):
|
||||
"""Relire la progression, ou None si l'on ne sait pas d'où."""
|
||||
return status.read(path) if path else None
|
||||
|
||||
|
||||
def apply_panels(app):
|
||||
"""Poser la visibilité des panneaux, et NOMMER l'état choisi.
|
||||
|
||||
Le sous-titre reste visible dans les trois : un panneau qui disparaît
|
||||
sans un mot se lit comme un écran cassé.
|
||||
"""
|
||||
resume, liste = panel_visibility(app.panel_state)
|
||||
app.query_one("#head").display = resume
|
||||
app.query_one("#left").display = liste
|
||||
app.sub_title = panel_label(app.panel_state)
|
||||
|
||||
|
||||
def apply_width(app, delta):
|
||||
"""Déplacer la séparation, bornée des deux côtés.
|
||||
|
||||
Une colonne de zéro ne se retrouve plus ; une qui mange tout l'écran ne
|
||||
laisse rien à lire.
|
||||
"""
|
||||
app.left_width = max(
|
||||
app.LEFT_MIN, min(app.LEFT_MAX, app.left_width + delta)
|
||||
)
|
||||
app.query_one("#left").styles.width = app.left_width
|
||||
|
||||
|
||||
def build_app(dct, path=None):
|
||||
"""Textual est importé ICI, pas au chargement du module.
|
||||
|
||||
|
|
@ -172,7 +230,7 @@ def build_app(dct, path=None):
|
|||
("q,escape", "quit", t("Quit")),
|
||||
("r", "refresh", t("Refresh")),
|
||||
("l", "toggle_log", t("Logs")),
|
||||
("p", "toggle_head", t("Panel")),
|
||||
("p", "cycle_panels", t("Panels")),
|
||||
("plus,equal", "wider", t("Wider")),
|
||||
("minus,underscore", "narrower", t("Narrower")),
|
||||
]
|
||||
|
|
@ -189,6 +247,7 @@ def build_app(dct, path=None):
|
|||
self.index = 0
|
||||
self.show_log = True
|
||||
self.left_width = 46
|
||||
self.panel_state = 0
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Header()
|
||||
|
|
@ -202,6 +261,7 @@ def build_app(dct, path=None):
|
|||
def on_mount(self):
|
||||
self.title = t("Migration state")
|
||||
self._fill_table()
|
||||
apply_panels(self)
|
||||
self._show()
|
||||
|
||||
def _fill_table(self):
|
||||
|
|
@ -214,7 +274,7 @@ def build_app(dct, path=None):
|
|||
self.query_one("#head", Static).update(head_text(self.dct))
|
||||
|
||||
def _show(self):
|
||||
row = self.lst_row[self.index] if self.lst_row else None
|
||||
row = current_row(self.lst_row, self.index)
|
||||
# `from_ansi` fait DEUX choses : il rend les couleurs, et il
|
||||
# traite le reste comme du texte LITTÉRAL. Sans lui, une
|
||||
# commande contenant « [1] » passait pour du balisage Rich et
|
||||
|
|
@ -239,9 +299,10 @@ def build_app(dct, path=None):
|
|||
cela, il fallait le fermer et le rouvrir pour voir le palier
|
||||
suivant.
|
||||
"""
|
||||
if not self.path:
|
||||
neuf = reread(self.path)
|
||||
if neuf is None:
|
||||
return
|
||||
self.dct = status.read(self.path)
|
||||
self.dct = neuf
|
||||
self.lst_row = rows(self.dct)
|
||||
self.index = min(self.index, max(0, len(self.lst_row) - 1))
|
||||
self._fill_table()
|
||||
|
|
@ -251,23 +312,26 @@ def build_app(dct, path=None):
|
|||
self.show_log = not self.show_log
|
||||
self._show()
|
||||
|
||||
def action_toggle_head(self):
|
||||
head = self.query_one("#head", Static)
|
||||
head.display = not head.display
|
||||
def action_cycle_panels(self):
|
||||
"""Trois états, du plus complet au plus dépouillé.
|
||||
|
||||
Un simple bascule ne libérait que quatre lignes. Ce qui prend
|
||||
la place, c'est la COLONNE de gauche : sur un journal de
|
||||
serveur, chaque caractère gagné en largeur compte. On enchaîne
|
||||
donc tout → sans résumé → détail seul, et l'on revient.
|
||||
|
||||
L'état est écrit dans le sous-titre, qui reste visible dans les
|
||||
trois : un panneau qui disparaît sans un mot se lit comme un
|
||||
écran cassé.
|
||||
"""
|
||||
self.panel_state = (self.panel_state + 1) % len(PANELS)
|
||||
apply_panels(self)
|
||||
|
||||
def action_wider(self):
|
||||
self._resize(self.LEFT_STEP)
|
||||
apply_width(self, self.LEFT_STEP)
|
||||
|
||||
def action_narrower(self):
|
||||
self._resize(-self.LEFT_STEP)
|
||||
|
||||
def _resize(self, delta):
|
||||
# Bornée des deux côtés : une colonne de zéro ne se retrouve
|
||||
# plus, et une qui mange tout l'écran ne laisse rien à lire.
|
||||
self.left_width = max(
|
||||
self.LEFT_MIN, min(self.LEFT_MAX, self.left_width + delta)
|
||||
)
|
||||
self.query_one("#left", DataTable).styles.width = self.left_width
|
||||
apply_width(self, -self.LEFT_STEP)
|
||||
|
||||
return StatusApp(dct, path)
|
||||
|
||||
|
|
|
|||
|
|
@ -5675,6 +5675,22 @@ TRANSLATIONS = {
|
|||
"fr": "durée",
|
||||
"en": "duration",
|
||||
},
|
||||
"Panels": {
|
||||
"fr": "Panneaux",
|
||||
"en": "Panels",
|
||||
},
|
||||
"summary + list + detail": {
|
||||
"fr": "résumé + liste + détail",
|
||||
"en": "summary + list + detail",
|
||||
},
|
||||
"list + detail": {
|
||||
"fr": "liste + détail",
|
||||
"en": "list + detail",
|
||||
},
|
||||
"detail only": {
|
||||
"fr": "détail seul",
|
||||
"en": "detail only",
|
||||
},
|
||||
"Clean the database before testing the pages?": {
|
||||
"fr": "Nettoyer la base avant de tester les pages ?",
|
||||
"en": "Clean the database before testing the pages?",
|
||||
|
|
|
|||
|
|
@ -802,10 +802,16 @@ class TodoUpgrade:
|
|||
Posée AVANT le choix de version, donc avant la première décision :
|
||||
activée, elle vaut pour toutes les suivantes, celle-là comprise.
|
||||
"""
|
||||
# L'auto-exécution ne s'active JAMAIS d'elle-même : on efface le
|
||||
# drapeau avant de poser la question, sinon une variable héritée du
|
||||
# shell ferait répondre « oui » à la place de quelqu'un — et cette
|
||||
# réponse-là engage toutes les suivantes.
|
||||
auto_ask.export(False)
|
||||
answer = (
|
||||
input(
|
||||
auto_ask.ask(
|
||||
f"💬 {t('Auto-run: take the default answer after')}"
|
||||
f" {self.AUTO_DELAY} {t('seconds?')} (y/N) : "
|
||||
f" {self.AUTO_DELAY} {t('seconds?')} (y/N) : ",
|
||||
default="n",
|
||||
)
|
||||
.strip()
|
||||
.lower()
|
||||
|
|
|
|||
|
|
@ -1090,6 +1090,72 @@ class TestSeparatingCommandsFromLogs(Base):
|
|||
)
|
||||
|
||||
|
||||
class TestTheThreePanelStates(Base):
|
||||
"""Un simple bascule ne libérait que quatre lignes.
|
||||
|
||||
Ce qui prend la place, c'est la COLONNE de gauche — et sur un journal
|
||||
de serveur, chaque caractère gagné en largeur compte. D'où trois états,
|
||||
du plus complet au plus dépouillé.
|
||||
"""
|
||||
|
||||
def test_the_first_state_shows_everything(self):
|
||||
self.assertEqual(tui.panel_visibility(0), (True, True))
|
||||
|
||||
def test_the_second_drops_the_summary(self):
|
||||
self.assertEqual(tui.panel_visibility(1), (False, True))
|
||||
|
||||
def test_the_third_leaves_the_detail_alone(self):
|
||||
self.assertEqual(tui.panel_visibility(2), (False, False))
|
||||
|
||||
def test_it_comes_back_round(self):
|
||||
# Sans retour, le troisième état serait un cul-de-sac : la liste
|
||||
# cachée, on ne peut plus changer d'entrée.
|
||||
self.assertEqual(tui.panel_visibility(3), tui.panel_visibility(0))
|
||||
|
||||
def test_there_are_exactly_three(self):
|
||||
self.assertEqual(len(tui.PANELS), 3)
|
||||
|
||||
def test_each_state_has_a_name(self):
|
||||
# Un panneau qui disparaît sans un mot se lit comme un écran cassé.
|
||||
noms = {tui.panel_label(i) for i in range(3)}
|
||||
self.assertEqual(len(noms), 3)
|
||||
for nom in noms:
|
||||
self.assertTrue(nom.strip())
|
||||
|
||||
def test_p_is_bound_to_the_cycle(self):
|
||||
app = tui.build_app(progression())
|
||||
action = [
|
||||
entree[1] for entree in app.BINDINGS if "p" in entree[0].split(",")
|
||||
]
|
||||
self.assertEqual(action, ["cycle_panels"])
|
||||
|
||||
def test_the_name_is_shown_where_it_stays_visible(self):
|
||||
# Le sous-titre est dans l'en-tête de Textual, qui reste affiché
|
||||
# dans les trois états — contrairement au panneau de résumé.
|
||||
import inspect
|
||||
|
||||
source = inspect.getsource(tui.apply_panels)
|
||||
self.assertIn("app.sub_title", source)
|
||||
|
||||
|
||||
class TestTheGuardsThatCameOutOfTheClass(Base):
|
||||
def test_no_row_selected_is_not_an_error(self):
|
||||
self.assertIsNone(tui.current_row([], 0))
|
||||
|
||||
def test_an_index_past_the_end_is_not_an_error(self):
|
||||
# Un rafraîchissement peut raccourcir la liste sous le curseur.
|
||||
self.assertIsNone(tui.current_row([{"a": 1}], 5))
|
||||
|
||||
def test_rereading_without_a_path_yields_nothing(self):
|
||||
self.assertIsNone(tui.reread(None))
|
||||
|
||||
def test_the_width_stays_between_its_bounds(self):
|
||||
app = tui.build_app(progression())
|
||||
self.assertLess(app.LEFT_MIN, app.LEFT_MAX)
|
||||
self.assertGreaterEqual(app.left_width, app.LEFT_MIN)
|
||||
self.assertLessEqual(app.left_width, app.LEFT_MAX)
|
||||
|
||||
|
||||
class TestTheKeyboard(Base):
|
||||
"""Ce que l'écran promet dans son pied de page doit exister."""
|
||||
|
||||
|
|
@ -1137,9 +1203,12 @@ class TestTheKeyboard(Base):
|
|||
"""
|
||||
import inspect
|
||||
|
||||
source = inspect.getsource(tui.build_app)
|
||||
self.assertIn("status.read(self.path)", source)
|
||||
self.assertIn("self.lst_row = rows(self.dct)", source)
|
||||
# La relecture est sortie de la classe pour la rendre testable :
|
||||
# on vérifie donc les DEUX moitiés, la lecture et le remontage.
|
||||
self.assertIn("status.read(path)", inspect.getsource(tui.reread))
|
||||
self.assertIn(
|
||||
"self.lst_row = rows(self.dct)", inspect.getsource(tui.build_app)
|
||||
)
|
||||
|
||||
def test_refreshing_without_a_path_does_nothing(self):
|
||||
app = tui.build_app(progression(), path=None)
|
||||
|
|
|
|||
|
|
@ -224,6 +224,96 @@ class TestTheCountdownSpeaksThePromptsLanguage(EnvCase):
|
|||
self.assertIn("Entrée", texte)
|
||||
|
||||
|
||||
class TestTheQuestionThatCannotAnswerItself(EnvCase):
|
||||
"""Celle-là engage toutes les suivantes : elle ne se prend pas seule.
|
||||
|
||||
Une variable `ERPLIBRE_AUTO_EXECUTE` héritée du shell ferait répondre
|
||||
« oui » à la place de quelqu'un, et cette réponse-là décide de toutes
|
||||
les autres pendant des heures.
|
||||
"""
|
||||
|
||||
def answer(self, typed):
|
||||
import builtins
|
||||
|
||||
obj = TodoUpgrade.__new__(TodoUpgrade)
|
||||
original = builtins.input
|
||||
builtins.input = lambda prompt="": typed
|
||||
self.addCleanup(setattr, builtins, "input", original)
|
||||
with contextlib.redirect_stdout(io.StringIO()):
|
||||
obj.prompt_auto_execute()
|
||||
return obj.auto_execute
|
||||
|
||||
def test_enter_says_no(self):
|
||||
self.assertFalse(self.answer(""))
|
||||
|
||||
def test_the_default_is_written_down_not_deduced(self):
|
||||
# `answer == "y"` donnait le bon résultat par accident ; un défaut
|
||||
# explicite dit ce qu'on veut, et se relit.
|
||||
import inspect
|
||||
|
||||
source = inspect.getsource(TodoUpgrade.prompt_auto_execute)
|
||||
self.assertIn('default="n"', source)
|
||||
|
||||
def test_an_inherited_flag_cannot_switch_it_on(self):
|
||||
os.environ[auto_ask.ENV_ENABLED] = "1"
|
||||
os.environ[auto_ask.ENV_DELAY] = "5"
|
||||
self.assertFalse(self.answer(""))
|
||||
|
||||
def test_the_inherited_flag_is_cleared_before_asking(self):
|
||||
import inspect
|
||||
|
||||
source = inspect.getsource(TodoUpgrade.prompt_auto_execute)
|
||||
self.assertLess(
|
||||
source.index("auto_ask.export(False)"),
|
||||
source.index("auto_ask.ask("),
|
||||
)
|
||||
|
||||
def test_yes_still_says_yes(self):
|
||||
self.assertTrue(self.answer("y"))
|
||||
|
||||
|
||||
class TestEveryPromptAnnouncesTheCountdown(EnvCase):
|
||||
"""Une invite qui a l'air d'attendre indéfiniment n'invite pas à
|
||||
s'absenter — et c'est pourtant tout l'intérêt du mode auto."""
|
||||
|
||||
def ask(self, default="d", seconds=5):
|
||||
import select
|
||||
|
||||
original = select.select
|
||||
select.select = lambda r, w, x, t: ([], [], [])
|
||||
self.addCleanup(setattr, select, "select", original)
|
||||
auto_ask.export(True, seconds)
|
||||
out = io.StringIO()
|
||||
with contextlib.redirect_stdout(out):
|
||||
auto_ask.ask("💬 q : ", default=default)
|
||||
return out.getvalue()
|
||||
|
||||
def test_the_delay_is_announced_before_the_question(self):
|
||||
texte = self.ask()
|
||||
self.assertIn("⏱5s", texte)
|
||||
self.assertLess(texte.index("⏱5s"), texte.index("💬"))
|
||||
|
||||
def test_it_shows_the_real_delay(self):
|
||||
self.assertIn("⏱2s", self.ask(seconds=2))
|
||||
|
||||
def test_nothing_is_announced_outside_auto_mode(self):
|
||||
import builtins
|
||||
|
||||
auto_ask.export(False)
|
||||
original = builtins.input
|
||||
vu = []
|
||||
builtins.input = lambda prompt="": vu.append(prompt) or "x"
|
||||
self.addCleanup(setattr, builtins, "input", original)
|
||||
auto_ask.ask("💬 q : ", default="d")
|
||||
self.assertNotIn("⏱", vu[0])
|
||||
|
||||
def test_the_clock_announces_AND_reports(self):
|
||||
# Le même symbole des deux côtés : il annonce l'attente, puis rend
|
||||
# compte de la réponse. Deux symboles feraient deux mécanismes.
|
||||
texte = self.ask()
|
||||
self.assertEqual(texte.count("⏱"), 2)
|
||||
|
||||
|
||||
class TestNoPromptOfTheMigrationCanHang(unittest.TestCase):
|
||||
"""Un `input()` nu ne sait rien du mode auto : il attend, pour toujours.
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue