diff --git a/pg_stand_sync.tcss b/pg_stand_sync.tcss index 7182b82..b73a502 100644 --- a/pg_stand_sync.tcss +++ b/pg_stand_sync.tcss @@ -232,9 +232,47 @@ ModalScreen { min-height: 3; } +/* высота фиксирована, форма забирает остаток: иначе длинная форма выдавливала + ряд кнопок за нижнюю границу диалога, и «Начать» становилось не видно */ +.op-dialog { + height: 90%; + max-height: 22; + min-height: 12; +} + .dialog > .form { + height: 1fr; + padding: 0; +} + +.check-row, .field { height: auto; - max-height: 24; + width: 1fr; +} + +.check-row > Checkbox { + margin-right: 2; +} + +.dialog .form Label.inline, .dialog .field Label.inline { + padding: 0 1 0 0; + height: 1; +} + +.dialog .form Input, .dialog .field Input { + width: 1fr; + height: 1; + border: none; + background: $boost; + margin: 0 1 0 0; +} + +.dialog .form Input.num, .dialog .field Input.num { + width: 6; +} + +.dialog .form Input:focus { + background: $panel; } .dialog .section { @@ -259,17 +297,6 @@ ModalScreen { .dialog .form Label { color: $text-muted; - padding: 0 0 0 1; -} - -.dialog .form Input { - width: 1fr; - border: tall $panel; - background: $boost; -} - -.dialog .form Input:focus { - border: tall $primary; } .dialog Collapsible > CollapsibleTitle { diff --git a/pg_stand_sync_tui.py b/pg_stand_sync_tui.py index d8c66fb..b1095a6 100644 --- a/pg_stand_sync_tui.py +++ b/pg_stand_sync_tui.py @@ -25,8 +25,8 @@ from textual.content import Content from textual.message import Message from textual.screen import ModalScreen, Screen from textual.theme import Theme -from textual.widgets import (Button, Collapsible, Footer, Input, Label, OptionList, - ProgressBar, RichLog, Static, Switch) +from textual.widgets import (Button, Checkbox, Collapsible, Footer, Input, Label, + OptionList, ProgressBar, RichLog, Static) from textual.widgets.option_list import Option from textual.worker import WorkerState, get_current_worker @@ -334,16 +334,16 @@ class ConfirmModal(ModalScreen): yield Static("локальные базы — как на стенде (выбрано несколько)", classes="subject") else: - yield Label("локальная база") - yield Input(value=self.spec.target_db, id="target") - with Horizontal(classes="switch-row"): - yield Switch(value=self.recreate, id="recreate") - yield Label("пересоздать (DROP + CREATE)") + with Horizontal(classes="field"): + yield Label("локальная база", classes="inline") + yield Input(value=self.spec.target_db, id="target", compact=True) + yield Checkbox("пересоздать (DROP + CREATE)", value=self.recreate, + id="recreate", compact=True) yield Static(self._danger_text(), id="danger", classes="danger" + ("" if self.recreate else " hidden")) with Horizontal(classes="row"): - yield Button("Отмена", id="cancel") - yield Button("Начать", variant="success", id="start") + yield Button("Отмена", id="cancel", compact=True) + yield Button("Начать", variant="success", id="start", compact=True) def on_mount(self) -> None: # повторяет умолчание confirm_target: при recreate=true согласие не подразумевается @@ -355,7 +355,7 @@ class ConfirmModal(ModalScreen): return (f"локальная база {name} будет удалена (DROP DATABASE) и создана заново — " f"её текущее содержимое пропадёт") - def on_switch_changed(self, event: Switch.Changed) -> None: + def on_checkbox_changed(self, event: Checkbox.Changed) -> None: self.query_one("#danger", Static).set_class(not event.value, "hidden") def on_input_submitted(self) -> None: @@ -376,7 +376,7 @@ class ConfirmModal(ModalScreen): self.notify("имя локальной базы пустое", severity="error") return self.spec.target_db = value - self.spec.no_recreate = not self.query_one("#recreate", Switch).value + self.spec.no_recreate = not self.query_one("#recreate", Checkbox).value self.dismiss(self.spec) def action_cancel(self) -> None: @@ -414,55 +414,57 @@ class DbOpModal(ModalScreen): self.target_db = None if len(dbs) > 1 else (configured or dbs[0]) def compose(self) -> ComposeResult: - with Vertical(classes="dialog"): - yield Static("Операция с базой", classes="title") - yield Static(", ".join(self.dbs), classes="subject") + # Checkbox вместо пары Switch+Label: подпись встроена, строка вместо трёх + with Vertical(classes="dialog op-dialog"): + yield Static(f"Операция с базой · {', '.join(self.dbs)}", classes="title") with VerticalScroll(classes="form"): - with Horizontal(classes="switch-row"): - yield Switch(value=self.autorestore, id="autorestore") - yield Label("поднять на локальной PG сразу после дампа") - + yield Checkbox("поднять на локальной PG сразу после дампа", + value=self.autorestore, id="autorestore", compact=True) yield Static("Дамп", classes="section") - for wid, label, key in (("schema-only", "только схема, без данных", "schema_only"), - ("no-owner", "без владельцев (--no-owner)", "no_owner"), - ("no-priv", "без прав (--no-privileges)", "no_privileges")): - with Horizontal(classes="switch-row"): - yield Switch(value=bool(self.dump_opts.get(key, key != "schema_only")), - id=wid) - yield Label(label) - yield Label("сжатие 0–9") - yield Input(value=str(self.dump_opts.get("compress", 6)), id="compress") + yield Checkbox("только схема, без данных", + value=bool(self.dump_opts.get("schema_only", False)), + id="schema-only", compact=True) + with Horizontal(classes="check-row"): + yield Checkbox("без владельцев", + value=bool(self.dump_opts.get("no_owner", True)), + id="no-owner", compact=True) + yield Checkbox("без прав", + value=bool(self.dump_opts.get("no_privileges", True)), + id="no-priv", compact=True) + yield Label("сжатие", classes="inline") + yield Input(value=str(self.dump_opts.get("compress", 6)), id="compress", + compact=True, classes="num") with Collapsible(title="состав дампа", collapsed=True): - yield Label("только схемы (через запятую)") - yield Input(value=_csv(self.dump_opts.get("schemas")), id="schemas") - yield Label("исключить схемы") - yield Input(value=_csv(self.dump_opts.get("exclude_schemas")), - id="ex-schemas") - yield Label("исключить таблицы") - yield Input(value=_csv(self.dump_opts.get("exclude_tables")), - id="ex-tables") - yield Label("исключить только данные таблиц") - yield Input(value=_csv(self.dump_opts.get("exclude_table_data")), - id="ex-data") + for wid, label, key in ( + ("schemas", "только схемы", "schemas"), + ("ex-schemas", "исключить схемы", "exclude_schemas"), + ("ex-tables", "исключить таблицы", "exclude_tables"), + ("ex-data", "исключить данные таблиц", "exclude_table_data")): + with Horizontal(classes="field"): + yield Label(label, classes="inline") + yield Input(value=_csv(self.dump_opts.get(key)), id=wid, + compact=True, placeholder="через запятую") with Vertical(id="target-block", classes="" if self.autorestore else "hidden"): yield Static("Локальная база", classes="section") - if self.target_db is None: - yield Static("каждая база — в одноимённую локальную", classes="subject") - else: - yield Label("имя") - yield Input(value=self.target_db, id="target") - with Horizontal(classes="switch-row"): - yield Switch(value=self.recreate, id="recreate") - yield Label("пересоздать (DROP + CREATE)") - yield Label("параллельных воркеров restore") - yield Input(value=str(self.restore_opts.get("jobs", 4)), id="jobs") + with Horizontal(classes="field"): + if self.target_db is None: + yield Label("каждая база — в одноимённую локальную", classes="inline") + else: + yield Label("имя", classes="inline") + yield Input(value=self.target_db, id="target", compact=True) + with Horizontal(classes="check-row"): + yield Checkbox("пересоздать (DROP + CREATE)", value=self.recreate, + id="recreate", compact=True) + yield Label("воркеров", classes="inline") + yield Input(value=str(self.restore_opts.get("jobs", 4)), id="jobs", + compact=True, classes="num") yield Static(self._danger_text(), id="danger", classes="danger" + ("" if self.recreate else " hidden")) with Horizontal(classes="row"): - yield Button("Отмена", id="cancel") - yield Button("Начать", variant="success", id="start") + yield Button("Отмена", id="cancel", compact=True) + yield Button("Начать", variant="success", id="start", compact=True) def on_mount(self) -> None: # согласие на DROP не подразумевается: фокус на «Отмена», пока база пересоздаётся @@ -474,11 +476,11 @@ class DbOpModal(ModalScreen): return (f"локальная база {name} будет удалена (DROP DATABASE) и создана заново — " f"её текущее содержимое пропадёт") - def on_switch_changed(self, event: Switch.Changed) -> None: - if event.switch.id == "autorestore": + def on_checkbox_changed(self, event: Checkbox.Changed) -> None: + if event.checkbox.id == "autorestore": self.autorestore = event.value self.query_one("#target-block").set_class(not event.value, "hidden") - elif event.switch.id == "recreate": + elif event.checkbox.id == "recreate": self.recreate = event.value self.query_one("#danger", Static).set_class(not event.value, "hidden") @@ -509,9 +511,9 @@ class DbOpModal(ModalScreen): if compress is None: return dump_opts = { - "schema_only": self.query_one("#schema-only", Switch).value, - "no_owner": self.query_one("#no-owner", Switch).value, - "no_privileges": self.query_one("#no-priv", Switch).value, + "schema_only": self.query_one("#schema-only", Checkbox).value, + "no_owner": self.query_one("#no-owner", Checkbox).value, + "no_privileges": self.query_one("#no-priv", Checkbox).value, "compress": compress, "schemas": _split_csv(self.query_one("#schemas", Input).value), "exclude_schemas": _split_csv(self.query_one("#ex-schemas", Input).value), @@ -526,7 +528,7 @@ class DbOpModal(ModalScreen): if jobs is None: return overrides["restore"] = {"jobs": jobs} - no_recreate = not self.query_one("#recreate", Switch).value + no_recreate = not self.query_one("#recreate", Checkbox).value if self.target_db is not None: target_db = self.query_one("#target", Input).value.strip() if not target_db: diff --git a/tests_tui.py b/tests_tui.py index 37e602f..e5371e6 100644 --- a/tests_tui.py +++ b/tests_tui.py @@ -21,7 +21,7 @@ sys.path.insert(0, str(Path(__file__).parent)) import pg_stand_sync as core import pg_stand_sync_tui as tui -from textual.widgets import Input, Switch +from textual.widgets import Checkbox, Collapsible, Input PY = sys.executable DBS = [("zpas", "1284 MB"), ("zpas_arch", "312 MB"), ("zpas_test", "88 MB"), @@ -763,7 +763,7 @@ def test_db_dialog_carries_dump_options(cfg, monkeypatch): await settle(app, pilot) modal = await _open_db_dialog(app, pilot) assert isinstance(modal, tui.DbOpModal) - modal.query_one("#schema-only", Switch).value = True + modal.query_one("#schema-only", Checkbox).value = True modal.query_one("#compress", Input).value = "0" modal.query_one("#ex-schemas", Input).value = "audit, tmp" modal.action_start() @@ -790,7 +790,7 @@ def test_autorestore_switch_picks_operation(cfg, monkeypatch): async with app.run_test(size=(120, 40)) as pilot: await settle(app, pilot) modal = await _open_db_dialog(app, pilot) - switch = modal.query_one("#autorestore", Switch) + switch = modal.query_one("#autorestore", Checkbox) assert switch.value is True # главный сценарий по умолчанию switch.value = False await pilot.pause() @@ -970,3 +970,31 @@ def test_ru_layout_covers_every_ctrl_hotkey(): if key.startswith("ctrl+") and len(key) == 6 and key[5].isascii(): twin = f"ctrl+{tui.RU_LAYOUT[key[5]]}" assert pairs.get(twin) == action, f"нет двойника для {key}" + + +def test_op_dialog_fits_and_keeps_buttons_visible(cfg, monkeypatch): + """Форма не должна выдавливать ряд кнопок за нижнюю границу диалога.""" + app = make_app(cfg, monkeypatch) + + async def scenario(): + async with app.run_test(size=(80, 24)) 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 + modal = app.screen + modal.query_one(Collapsible).collapsed = False # самый длинный вариант формы + await pilot.pause() + dialog = modal.query_one(".dialog") + start = modal.query_one("#start") + assert dialog.size.height <= 24 + assert start.region.height > 0 + assert dialog.region.y <= start.region.y + assert start.region.bottom <= dialog.region.bottom + form = modal.query_one(".form") + assert form.virtual_size.height > form.size.height # длинное уезжает в скролл + await pilot.press("escape") + await pilot.pause() + asyncio.run(scenario())