Навигация стрелками по полям диалогов

Оба диалога наследуются от FormScreen с action_walk: у Screen нет
готового action_focus_next, поэтому биндинг стрелок на него молча
ничего не делал. Вверх и вниз ходят по полям, влево и вправо остаются
за курсором внутри поля ввода — биндинги намеренно без priority.
This commit is contained in:
2026-08-20 05:54:16 +03:00
parent e8fd19714a
commit 9404d1dd08
4 changed files with 65 additions and 4 deletions
+4
View File
@@ -88,6 +88,10 @@ matches(text, query) ; общий фильтр для pick() и обоих с
установленном textual: TUI посреди CLI-команды сломал бы pipe-сценарии. установленном textual: TUI посреди CLI-команды сломал бы pipe-сценарии.
- Текст `StepError``«{what} завершился с кодом {N}»` — менять нельзя: на него завязано - Текст `StepError``«{what} завершился с кодом {N}»` — менять нельзя: на него завязано
глушение ошибок `pg_restore`. глушение ошибок `pg_restore`.
- Диалоги наследуются от `FormScreen` и подмешивают `FORM_NAV`: у `Screen` нет готового
`action_focus_next`, поэтому обход полей стрелками сделан своим `action_walk`. Биндинги без
`priority` — внутри `Input` стрелки вверх/вниз свободны и всплывают до экрана сами, а
влево/вправо остаются за курсором.
- Новый `Ctrl`-хоткей добавляется только через `with_ru_layout([...])`: при русской раскладке - Новый `Ctrl`-хоткей добавляется только через `with_ru_layout([...])`: при русской раскладке
терминал присылает `ctrl+в` вместо `ctrl+d`, и голый биндинг молча не срабатывает. Полноту терминал присылает `ctrl+в` вместо `ctrl+d`, и голый биндинг молча не срабатывает. Полноту
дублей проверяет тест, перебирающий `PgSyncApp.BINDINGS`. дублей проверяет тест, перебирающий `PgSyncApp.BINDINGS`.
+3
View File
@@ -67,6 +67,9 @@ copy config.example.json config.json
| `F1` / `F2` / `F3` | помощь / тема / показывать ли вывод утилит | | `F1` / `F2` / `F3` | помощь / тема / показывать ли вывод утилит |
| `Ctrl+C` | выход | | `Ctrl+C` | выход |
В окне операции: `↑` `↓``Tab`) — переход по полям, `Space` — переключить галочку,
`Ctrl+Enter` — начать, `Esc` — отмена.
Раскладка клавиатуры значения не имеет: каждый `Ctrl`-хоткей продублирован кириллическим Раскладка клавиатуры значения не имеет: каждый `Ctrl`-хоткей продублирован кириллическим
двойником по ЙЦУКЕН (`Ctrl+В` = `Ctrl+D`, `Ctrl+Ч` = `Ctrl+X` и так далее). двойником по ЙЦУКЕН (`Ctrl+В` = `Ctrl+D`, `Ctrl+Ч` = `Ctrl+X` и так далее).
+22 -4
View File
@@ -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): class PasswordModal(ModalScreen):
"""getpass под запущенным Textual невозможен: пароль спрашивается только отсюда.""" """getpass под запущенным Textual невозможен: пароль спрашивается только отсюда."""
@@ -310,11 +328,11 @@ class PasswordModal(ModalScreen):
self.dismiss(None) self.dismiss(None)
class ConfirmModal(ModalScreen): class ConfirmModal(FormScreen):
"""Последний экран перед деструктивной операцией: имя базы, режим и прямая формулировка.""" """Последний экран перед деструктивной операцией: имя базы, режим и прямая формулировка."""
BINDINGS = [Binding("escape", "cancel", "отмена"), 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"} 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()] return [part.strip() for part in text.split(",") if part.strip()]
class DbOpModal(ModalScreen): class DbOpModal(FormScreen):
"""Что делаем с выбранной базой: параметры дампа и галочка автоподнятия локально. """Что делаем с выбранной базой: параметры дампа и галочка автоподнятия локально.
Заменяет собой выбор действия: снятый флаг — просто dump, поднятый — dump + restore. Заменяет собой выбор действия: снятый флаг — просто dump, поднятый — dump + restore.
@@ -399,7 +417,7 @@ class DbOpModal(ModalScreen):
""" """
BINDINGS = [Binding("escape", "cancel", "отмена"), 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: def __init__(self, dbs: list, cfg: dict, defaults: dict) -> None:
super().__init__() super().__init__()
+36
View File
@@ -998,3 +998,39 @@ def test_op_dialog_fits_and_keeps_buttons_visible(cfg, monkeypatch):
await pilot.press("escape") await pilot.press("escape")
await pilot.pause() await pilot.pause()
asyncio.run(scenario()) 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())