From 9404d1dd0825d855efa6d3e751e8f9cf9ba96400 Mon Sep 17 00:00:00 2001 From: Shahovalov MIkhail Date: Thu, 20 Aug 2026 05:54:16 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9D=D0=B0=D0=B2=D0=B8=D0=B3=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D1=8F=20=D1=81=D1=82=D1=80=D0=B5=D0=BB=D0=BA=D0=B0=D0=BC?= =?UTF-8?q?=D0=B8=20=D0=BF=D0=BE=20=D0=BF=D0=BE=D0=BB=D1=8F=D0=BC=20=D0=B4?= =?UTF-8?q?=D0=B8=D0=B0=D0=BB=D0=BE=D0=B3=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Оба диалога наследуются от FormScreen с action_walk: у Screen нет готового action_focus_next, поэтому биндинг стрелок на него молча ничего не делал. Вверх и вниз ходят по полям, влево и вправо остаются за курсором внутри поля ввода — биндинги намеренно без priority. --- CLAUDE.md | 4 ++++ README.md | 3 +++ pg_stand_sync_tui.py | 26 ++++++++++++++++++++++---- tests_tui.py | 36 ++++++++++++++++++++++++++++++++++++ 4 files changed, 65 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 52ba299..8150d2a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -88,6 +88,10 @@ matches(text, query) ; общий фильтр для pick() и обоих с установленном textual: TUI посреди CLI-команды сломал бы pipe-сценарии. - Текст `StepError` — `«{what} завершился с кодом {N}»` — менять нельзя: на него завязано глушение ошибок `pg_restore`. +- Диалоги наследуются от `FormScreen` и подмешивают `FORM_NAV`: у `Screen` нет готового + `action_focus_next`, поэтому обход полей стрелками сделан своим `action_walk`. Биндинги без + `priority` — внутри `Input` стрелки вверх/вниз свободны и всплывают до экрана сами, а + влево/вправо остаются за курсором. - Новый `Ctrl`-хоткей добавляется только через `with_ru_layout([...])`: при русской раскладке терминал присылает `ctrl+в` вместо `ctrl+d`, и голый биндинг молча не срабатывает. Полноту дублей проверяет тест, перебирающий `PgSyncApp.BINDINGS`. diff --git a/README.md b/README.md index 0fddd1a..89d43db 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,9 @@ copy config.example.json config.json | `F1` / `F2` / `F3` | помощь / тема / показывать ли вывод утилит | | `Ctrl+C` | выход | +В окне операции: `↑` `↓` (и `Tab`) — переход по полям, `Space` — переключить галочку, +`Ctrl+Enter` — начать, `Esc` — отмена. + Раскладка клавиатуры значения не имеет: каждый `Ctrl`-хоткей продублирован кириллическим двойником по ЙЦУКЕН (`Ctrl+В` = `Ctrl+D`, `Ctrl+Ч` = `Ctrl+X` и так далее). diff --git a/pg_stand_sync_tui.py b/pg_stand_sync_tui.py index b1095a6..be4b9f9 100644 --- a/pg_stand_sync_tui.py +++ b/pg_stand_sync_tui.py @@ -283,6 +283,24 @@ class DumpList(FilterList): # --------------------------------------------------------------------------- модалки +# Стрелки как способ ходить по полям диалога: Tab знают не все, а мышь в консоли не всегда +# под рукой. Без priority — внутри Input стрелки вверх/вниз свободны и всплывают сюда сами. +FORM_NAV = [ + Binding("down", "walk(1)", "ниже", show=False), + Binding("up", "walk(-1)", "выше", show=False), +] + + +class FormScreen(ModalScreen): + """Диалог, по полям которого ходят стрелками: у Screen нет готового action для фокуса.""" + + def action_walk(self, step: int) -> None: + if step > 0: + self.focus_next() + else: + self.focus_previous() + + class PasswordModal(ModalScreen): """getpass под запущенным Textual невозможен: пароль спрашивается только отсюда.""" @@ -310,11 +328,11 @@ class PasswordModal(ModalScreen): self.dismiss(None) -class ConfirmModal(ModalScreen): +class ConfirmModal(FormScreen): """Последний экран перед деструктивной операцией: имя базы, режим и прямая формулировка.""" BINDINGS = [Binding("escape", "cancel", "отмена"), - Binding("ctrl+enter", "start", "начать", priority=True)] + Binding("ctrl+enter", "start", "начать", priority=True), *FORM_NAV] TITLES = {"sync": "Dump + Restore", "dump": "Dump", "restore": "Restore"} @@ -391,7 +409,7 @@ def _split_csv(text: str) -> list: return [part.strip() for part in text.split(",") if part.strip()] -class DbOpModal(ModalScreen): +class DbOpModal(FormScreen): """Что делаем с выбранной базой: параметры дампа и галочка автоподнятия локально. Заменяет собой выбор действия: снятый флаг — просто dump, поднятый — dump + restore. @@ -399,7 +417,7 @@ class DbOpModal(ModalScreen): """ BINDINGS = [Binding("escape", "cancel", "отмена"), - Binding("ctrl+enter", "start", "начать", priority=True)] + Binding("ctrl+enter", "start", "начать", priority=True), *FORM_NAV] def __init__(self, dbs: list, cfg: dict, defaults: dict) -> None: super().__init__() diff --git a/tests_tui.py b/tests_tui.py index e5371e6..43dca2d 100644 --- a/tests_tui.py +++ b/tests_tui.py @@ -998,3 +998,39 @@ def test_op_dialog_fits_and_keeps_buttons_visible(cfg, monkeypatch): await pilot.press("escape") await pilot.pause() asyncio.run(scenario()) + + +def test_arrows_walk_dialog_fields(cfg, monkeypatch): + """В диалоге операции стрелки должны ходить по полям, а не только Tab.""" + app = make_app(cfg, monkeypatch) + + async def scenario(): + async with app.run_test(size=(120, 40)) as pilot: + await settle(app, pilot) + app.dashboard.open_db_op(autorestore=True) + for _ in range(40): + await pilot.pause() + if isinstance(app.screen, tui.DbOpModal): + break + seen = [] + for _ in range(6): + await pilot.press("down") + await pilot.pause() + seen.append(app.focused.id) + assert len(set(seen)) > 1, f"фокус не двигается: {seen}" + + back = app.focused.id + await pilot.press("up") + await pilot.pause() + assert app.focused.id != back # вверх возвращает на предыдущее поле + + # стрелки внутри поля ввода остаются за курсором: значение не должно меняться + field = app.screen.query_one("#compress", Input) + field.focus() + await pilot.pause() + await pilot.press("left", "right") + await pilot.pause() + assert field.value == "6" + await pilot.press("escape") + await pilot.pause() + asyncio.run(scenario())