[IMP] script todo: télémétrie — revenir après commande + Kanban F4 (swimlanes)
- Après exécution d'une commande choisie dans la télémétrie, on propose de REVENIR (r) ou de quitter. En revenant, la vue ET la position du curseur sont RESTAURÉES (chemin porté par chaque nœud/carte ; run_tui prend/rend un `state`). - Vue Kanban : F4 fait défiler la DISPOSITION — columns (une rangée de colonnes) / swimlanes (une rangée par menu de niveau 1) / grid (grille 3 colonnes). F3 bascule Arbre/Kanban. Validé headless : F3/F4 cyclent les dispositions, sélection -> action+état, relance avec `state` -> curseur restauré sur le bon nœud. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
bc0e06137f
commit
6440cec2e1
3 changed files with 222 additions and 57 deletions
|
|
@ -578,26 +578,38 @@ class TODO:
|
|||
return header + t("Command:")
|
||||
|
||||
def _todo_telemetry_tui(self):
|
||||
"""Ouvre le TUI de télémétrie (arbre/Kanban). Si l'utilisateur choisit
|
||||
une commande, on l'exécute au retour (hors du TUI)."""
|
||||
"""Ouvre le TUI de télémétrie (arbre/Kanban). Une commande choisie est
|
||||
exécutée au retour (hors du TUI) ; on propose ensuite de REVENIR (l'état
|
||||
et la position du curseur sont restaurés) ou de quitter."""
|
||||
from script.todo.todo_telemetry import run_tui
|
||||
|
||||
try:
|
||||
action = run_tui()
|
||||
except ImportError:
|
||||
print(t("Install textual for the telemetry TUI (pip)."))
|
||||
return
|
||||
if not action:
|
||||
return
|
||||
method, kwargs = action
|
||||
fn = getattr(self, method, None)
|
||||
if not callable(fn):
|
||||
print(f"{t('Command not found !')} ({method})")
|
||||
return
|
||||
try:
|
||||
fn(**(kwargs or {}))
|
||||
except Exception as exc:
|
||||
print(f"{t('Command failed: ')}{exc}")
|
||||
state = None
|
||||
while True:
|
||||
try:
|
||||
result = run_tui(state=state)
|
||||
except ImportError:
|
||||
print(t("Install textual for the telemetry TUI (pip)."))
|
||||
return
|
||||
if not result:
|
||||
return
|
||||
action, state = result
|
||||
if not action:
|
||||
return # quitté sans choisir de commande
|
||||
method, kwargs = action
|
||||
fn = getattr(self, method, None)
|
||||
if not callable(fn):
|
||||
print(f"{t('Command not found !')} ({method})")
|
||||
else:
|
||||
try:
|
||||
fn(**(kwargs or {}))
|
||||
except Exception as exc:
|
||||
print(f"{t('Command failed: ')}{exc}")
|
||||
# Revenir (curseur restauré) ou quitter ?
|
||||
ans = input(
|
||||
f"\n{t('Back to telemetry (r) or quit (Enter)? ')}"
|
||||
)
|
||||
if ans.strip().lower() not in ("r", "revenir", "o", "oui", "y"):
|
||||
return
|
||||
|
||||
def fill_help_info(self, choices):
|
||||
# Une entrée {"section": "..."} affiche un titre de section SANS
|
||||
|
|
|
|||
|
|
@ -1314,6 +1314,22 @@ TRANSLATIONS = {
|
|||
"fr": "Entrée = exécuter, F3 = vue",
|
||||
"en": "Enter = run, F3 = view",
|
||||
},
|
||||
"Enter = run, F3/F4 = views": {
|
||||
"fr": "Entrée = exécuter, F3/F4 = vues",
|
||||
"en": "Enter = run, F3/F4 = views",
|
||||
},
|
||||
"view": {
|
||||
"fr": "vue",
|
||||
"en": "view",
|
||||
},
|
||||
"No command found.": {
|
||||
"fr": "Aucune commande trouvée.",
|
||||
"en": "No command found.",
|
||||
},
|
||||
"Back to telemetry (r) or quit (Enter)? ": {
|
||||
"fr": "Revenir à la télémétrie (r) ou quitter (Entrée) ? ",
|
||||
"en": "Back to telemetry (r) or quit (Enter)? ",
|
||||
},
|
||||
"Command failed: ": {
|
||||
"fr": "Échec de la commande : ",
|
||||
"en": "Command failed: ",
|
||||
|
|
|
|||
|
|
@ -276,12 +276,23 @@ def _command_columns(tree, paths):
|
|||
return cols
|
||||
|
||||
|
||||
def run_tui(run_app: bool = True):
|
||||
"""TUI de télémétrie : arbre (issu du code) + vue Kanban (F3), sélection
|
||||
d'une commande = exécution. Renvoie (méthode, kwargs) à exécuter, ou None.
|
||||
`run_app=False` renvoie l'app (tests)."""
|
||||
# Modes d'affichage du Kanban (F4 les fait défiler).
|
||||
KANBAN_MODES = ("columns", "swimlanes", "grid")
|
||||
|
||||
|
||||
def run_tui(run_app: bool = True, state: dict | None = None):
|
||||
"""TUI de télémétrie : vue Arbre (issue du code) et vue Kanban (F3), la
|
||||
disposition du Kanban défilant par F4 (colonnes / swimlanes / grille).
|
||||
Sélectionner une COMMANDE = l'exécuter. `state` restaure la vue + le
|
||||
curseur au retour. Renvoie (action|None, state) ; run_app=False -> l'app."""
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.containers import HorizontalScroll, VerticalScroll
|
||||
from textual.containers import (
|
||||
Container,
|
||||
Grid,
|
||||
HorizontalScroll,
|
||||
Vertical,
|
||||
VerticalScroll,
|
||||
)
|
||||
from textual.widgets import (
|
||||
Footer,
|
||||
Header,
|
||||
|
|
@ -294,14 +305,17 @@ def run_tui(run_app: bool = True):
|
|||
|
||||
paths = load().get("paths", {})
|
||||
code_tree = build_code_tree()
|
||||
columns = _command_columns(code_tree, paths) # (label, path, cnt, cmds)
|
||||
state = state or {}
|
||||
|
||||
class CmdItem(ListItem):
|
||||
"""Carte Kanban portant la commande à exécuter."""
|
||||
"""Carte : commande à exécuter + son chemin (pour restaurer le curseur)."""
|
||||
|
||||
def __init__(self, label, method, kwargs):
|
||||
def __init__(self, label, method, kwargs, path):
|
||||
super().__init__(Label(label))
|
||||
self.cmd_method = method
|
||||
self.cmd_kwargs = kwargs or {}
|
||||
self.cmd_path = path
|
||||
|
||||
class Telemetry(App):
|
||||
CSS = """
|
||||
|
|
@ -310,10 +324,14 @@ def run_tui(run_app: bool = True):
|
|||
#kanban { display: none; height: 1fr; }
|
||||
.kcol { width: 40; border: solid $accent; margin: 0 1 0 0; }
|
||||
.ktitle { height: 1; color: $accent; }
|
||||
.lane { height: auto; }
|
||||
.lanetitle { height: 1; color: $secondary; }
|
||||
.kgrid { grid-size: 3; grid-gutter: 1; }
|
||||
"""
|
||||
BINDINGS = [
|
||||
("q", "quit", "Quitter"),
|
||||
("f3", "toggle_view", "Vue arbre/Kanban"),
|
||||
("f3", "toggle_view", "Arbre/Kanban"),
|
||||
("f4", "kanban_layout", "Disposition Kanban"),
|
||||
("e", "expand_all", "Tout déplier"),
|
||||
("r", "reset", "Réinitialiser"),
|
||||
]
|
||||
|
|
@ -321,47 +339,139 @@ def run_tui(run_app: bool = True):
|
|||
def __init__(self):
|
||||
super().__init__()
|
||||
self._action = None
|
||||
self._mode = "tree"
|
||||
self._mode = state.get("mode", "tree")
|
||||
self._kanban_mode = state.get("kanban_mode", "columns")
|
||||
|
||||
# -- helpers de construction de widgets ------------------------------ #
|
||||
def _col_widget(self, label, cnt, cmds):
|
||||
items = [
|
||||
CmdItem(
|
||||
f"· {c['label']}",
|
||||
c.get("method"),
|
||||
c.get("kwargs"),
|
||||
c.get("path"),
|
||||
)
|
||||
for c in cmds
|
||||
]
|
||||
return VerticalScroll(
|
||||
Static(f"{label} ({cnt})", classes="ktitle"),
|
||||
ListView(*items),
|
||||
classes="kcol",
|
||||
)
|
||||
|
||||
def _kanban_layout_widget(self):
|
||||
cols = [
|
||||
self._col_widget(label, cnt, cmds)
|
||||
for (label, _p, cnt, cmds) in columns
|
||||
]
|
||||
if not cols:
|
||||
return Static(t("No command found."))
|
||||
if self._kanban_mode == "grid":
|
||||
return Grid(*cols, classes="kgrid")
|
||||
if self._kanban_mode == "swimlanes":
|
||||
# Une rangée (swimlane) par menu de NIVEAU 1 (Execute, …).
|
||||
groups, order = {}, []
|
||||
for (label, path, cnt, cmds) in columns:
|
||||
parts = path.split(" › ")
|
||||
g = parts[1] if len(parts) > 1 else "TODO"
|
||||
if g not in groups:
|
||||
groups[g] = []
|
||||
order.append(g)
|
||||
groups[g].append((label, cnt, cmds))
|
||||
lanes = []
|
||||
for g in order:
|
||||
lane_cols = [
|
||||
self._col_widget(lb, cn, cm) for (lb, cn, cm) in groups[g]
|
||||
]
|
||||
lanes.append(
|
||||
Vertical(
|
||||
Static(f"━━ {g} ━━", classes="lanetitle"),
|
||||
HorizontalScroll(*lane_cols),
|
||||
classes="lane",
|
||||
)
|
||||
)
|
||||
return VerticalScroll(*lanes)
|
||||
return HorizontalScroll(*cols) # columns
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Header(show_clock=True)
|
||||
yield Static("", id="summary")
|
||||
yield Tree("📍 TODO", id="nav")
|
||||
with HorizontalScroll(id="kanban"):
|
||||
for label, _path, cnt, cmds in _command_columns(
|
||||
code_tree, paths
|
||||
):
|
||||
with VerticalScroll(classes="kcol"):
|
||||
yield Static(f"{label} ({cnt})", classes="ktitle")
|
||||
yield ListView(
|
||||
*[
|
||||
CmdItem(
|
||||
f"· {c['label']}",
|
||||
c.get("method"),
|
||||
c.get("kwargs"),
|
||||
)
|
||||
for c in cmds
|
||||
]
|
||||
)
|
||||
yield Container(id="kanban")
|
||||
yield Footer()
|
||||
|
||||
def on_mount(self):
|
||||
self.title = t("TODO navigation telemetry")
|
||||
self._populate_tree()
|
||||
self._update_summary()
|
||||
# Restaure la vue / la disposition / le curseur au retour.
|
||||
self.run_worker(self._restore())
|
||||
|
||||
async def _restore(self):
|
||||
if self._mode == "kanban":
|
||||
await self._enter_kanban()
|
||||
else:
|
||||
self.query_one("#nav").display = True
|
||||
self.query_one("#kanban").display = False
|
||||
self._focus_tree_path(state.get("path"))
|
||||
self._update_summary()
|
||||
|
||||
# -- vue Kanban ------------------------------------------------------ #
|
||||
async def _enter_kanban(self):
|
||||
box = self.query_one("#kanban", Container)
|
||||
await box.remove_children()
|
||||
await box.mount(self._kanban_layout_widget())
|
||||
self.query_one("#nav").display = False
|
||||
box.display = True
|
||||
self._focus_kanban_path(state.get("path"))
|
||||
|
||||
def _focus_kanban_path(self, path):
|
||||
if not path:
|
||||
return
|
||||
for lv in self.query(ListView):
|
||||
for i, item in enumerate(lv.children):
|
||||
if getattr(item, "cmd_path", None) == path:
|
||||
lv.index = i
|
||||
lv.focus()
|
||||
return
|
||||
|
||||
def _focus_tree_path(self, path):
|
||||
if not path:
|
||||
return
|
||||
tree = self.query_one("#nav", Tree)
|
||||
|
||||
def walk(node):
|
||||
d = getattr(node, "data", None)
|
||||
if isinstance(d, dict) and d.get("path") == path:
|
||||
return node
|
||||
for ch in node.children:
|
||||
found = walk(ch)
|
||||
if found:
|
||||
return found
|
||||
return None
|
||||
|
||||
node = walk(tree.root)
|
||||
if node is not None:
|
||||
tree.move_cursor(node)
|
||||
tree.scroll_to_node(node)
|
||||
|
||||
def _update_summary(self):
|
||||
total = sum(paths.values())
|
||||
src = t("tree from code") if code_tree else t("visited paths only")
|
||||
extra = (
|
||||
f" [{self._kanban_mode}]" if self._mode == "kanban" else ""
|
||||
)
|
||||
self.query_one("#summary", Static).update(
|
||||
f" {total} {t('navigations')} · {len(paths)} {t('menus')} · "
|
||||
f"{src} · {t('Enter = run, F3 = view')}"
|
||||
f"{src} · {t('view')}: {self._mode}{extra} · "
|
||||
f"{t('Enter = run, F3/F4 = views')}"
|
||||
)
|
||||
|
||||
def _populate_tree(self):
|
||||
tree = self.query_one("#nav", Tree)
|
||||
tree.clear()
|
||||
if code_tree is not None:
|
||||
tree.root.data = {"path": "TODO"}
|
||||
tree.root.set_label(f"📍 TODO ({paths.get('TODO', 0)})")
|
||||
self._add_code(tree.root, code_tree["children"], "TODO")
|
||||
else:
|
||||
|
|
@ -373,19 +483,22 @@ def run_tui(run_app: bool = True):
|
|||
|
||||
def _add_code(self, tnode, children, parent_path):
|
||||
for c in children:
|
||||
path = f"{parent_path} › {c['label']}"
|
||||
if c["is_menu"]:
|
||||
path = f"{parent_path} › {c['label']}"
|
||||
child = tnode.add(
|
||||
f"{c['label']} ({paths.get(path, 0)})", expand=True
|
||||
f"{c['label']} ({paths.get(path, 0)})",
|
||||
data={"path": path},
|
||||
expand=True,
|
||||
)
|
||||
self._add_code(child, c["children"], path)
|
||||
else:
|
||||
# Feuille EXÉCUTABLE : la méthode est portée en data.
|
||||
# Feuille EXÉCUTABLE : méthode + chemin portés en data.
|
||||
tnode.add_leaf(
|
||||
f"· {c['label']}",
|
||||
data={
|
||||
"method": c.get("method"),
|
||||
"kwargs": c.get("kwargs"),
|
||||
"path": path,
|
||||
},
|
||||
)
|
||||
|
||||
|
|
@ -396,25 +509,48 @@ def run_tui(run_app: bool = True):
|
|||
child = tnode.add(f"{name} ({node['count']})", expand=True)
|
||||
self._add_visited(child, node["children"])
|
||||
|
||||
def _run(self, method, kwargs):
|
||||
# -- sélection / exécution ------------------------------------------- #
|
||||
def _run(self, method, kwargs, path):
|
||||
if not method:
|
||||
return
|
||||
self._action = (method, kwargs or {})
|
||||
self._exit_state = {
|
||||
"mode": self._mode,
|
||||
"kanban_mode": self._kanban_mode,
|
||||
"path": path,
|
||||
}
|
||||
self.exit()
|
||||
|
||||
def on_tree_node_selected(self, event):
|
||||
data = getattr(event.node, "data", None)
|
||||
if isinstance(data, dict) and data.get("method"):
|
||||
self._run(data["method"], data.get("kwargs"))
|
||||
d = getattr(event.node, "data", None)
|
||||
if isinstance(d, dict) and d.get("method"):
|
||||
self._run(d["method"], d.get("kwargs"), d.get("path"))
|
||||
|
||||
def on_list_view_selected(self, event):
|
||||
if isinstance(event.item, CmdItem):
|
||||
self._run(event.item.cmd_method, event.item.cmd_kwargs)
|
||||
self._run(
|
||||
event.item.cmd_method,
|
||||
event.item.cmd_kwargs,
|
||||
event.item.cmd_path,
|
||||
)
|
||||
|
||||
def action_toggle_view(self):
|
||||
self._mode = "kanban" if self._mode == "tree" else "tree"
|
||||
self.query_one("#nav").display = self._mode == "tree"
|
||||
self.query_one("#kanban").display = self._mode == "kanban"
|
||||
# -- actions --------------------------------------------------------- #
|
||||
async def action_toggle_view(self):
|
||||
if self._mode == "tree":
|
||||
self._mode = "kanban"
|
||||
await self._enter_kanban()
|
||||
else:
|
||||
self._mode = "tree"
|
||||
self.query_one("#kanban").display = False
|
||||
self.query_one("#nav").display = True
|
||||
self._update_summary()
|
||||
|
||||
async def action_kanban_layout(self):
|
||||
if self._mode != "kanban":
|
||||
return
|
||||
i = KANBAN_MODES.index(self._kanban_mode)
|
||||
self._kanban_mode = KANBAN_MODES[(i + 1) % len(KANBAN_MODES)]
|
||||
await self._enter_kanban()
|
||||
self._update_summary()
|
||||
|
||||
def action_expand_all(self):
|
||||
|
|
@ -429,7 +565,8 @@ def run_tui(run_app: bool = True):
|
|||
self.notify(t("Telemetry reset."))
|
||||
|
||||
app = Telemetry()
|
||||
app._exit_state = state
|
||||
if run_app:
|
||||
app.run()
|
||||
return app._action
|
||||
return app._action, getattr(app, "_exit_state", state)
|
||||
return app
|
||||
|
|
|
|||
Loading…
Reference in a new issue