Компактный диалог операции и кнопки, которые не уезжают

Пары Switch + Label заменены на Checkbox с встроенной подписью: строка
вместо трёх. Поля стали однострочными, чекбоксы дампа встали в ряд,
заголовок и имя базы слились в одну строку — диалог ужался с почти
целого экрана до 17-18 строк на любом размере консоли.

Ряд кнопок пропадал потому, что .dialog с height: auto складывал высоту
детей и упирался в max-height, обрезая нижнюю строку. Теперь у диалога
операции своя высота, а форма забирает остаток через height: 1fr и
скроллится сама. Диалог restore приведён к тому же виду.
This commit is contained in:
2026-08-20 05:36:01 +03:00
parent 9fdb02c72a
commit e8fd19714a
3 changed files with 129 additions and 72 deletions
+39 -12
View File
@@ -232,9 +232,47 @@ ModalScreen {
min-height: 3; min-height: 3;
} }
/* высота фиксирована, форма забирает остаток: иначе длинная форма выдавливала
ряд кнопок за нижнюю границу диалога, и «Начать» становилось не видно */
.op-dialog {
height: 90%;
max-height: 22;
min-height: 12;
}
.dialog > .form { .dialog > .form {
height: 1fr;
padding: 0;
}
.check-row, .field {
height: auto; 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 { .dialog .section {
@@ -259,17 +297,6 @@ ModalScreen {
.dialog .form Label { .dialog .form Label {
color: $text-muted; 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 { .dialog Collapsible > CollapsibleTitle {
+57 -55
View File
@@ -25,8 +25,8 @@ from textual.content import Content
from textual.message import Message from textual.message import Message
from textual.screen import ModalScreen, Screen from textual.screen import ModalScreen, Screen
from textual.theme import Theme from textual.theme import Theme
from textual.widgets import (Button, Collapsible, Footer, Input, Label, OptionList, from textual.widgets import (Button, Checkbox, Collapsible, Footer, Input, Label,
ProgressBar, RichLog, Static, Switch) OptionList, ProgressBar, RichLog, Static)
from textual.widgets.option_list import Option from textual.widgets.option_list import Option
from textual.worker import WorkerState, get_current_worker from textual.worker import WorkerState, get_current_worker
@@ -334,16 +334,16 @@ class ConfirmModal(ModalScreen):
yield Static("локальные базы — как на стенде (выбрано несколько)", yield Static("локальные базы — как на стенде (выбрано несколько)",
classes="subject") classes="subject")
else: else:
yield Label("локальная база") with Horizontal(classes="field"):
yield Input(value=self.spec.target_db, id="target") yield Label("локальная база", classes="inline")
with Horizontal(classes="switch-row"): yield Input(value=self.spec.target_db, id="target", compact=True)
yield Switch(value=self.recreate, id="recreate") yield Checkbox("пересоздать (DROP + CREATE)", value=self.recreate,
yield Label("пересоздать (DROP + CREATE)") id="recreate", compact=True)
yield Static(self._danger_text(), id="danger", yield Static(self._danger_text(), id="danger",
classes="danger" + ("" if self.recreate else " hidden")) classes="danger" + ("" if self.recreate else " hidden"))
with Horizontal(classes="row"): with Horizontal(classes="row"):
yield Button("Отмена", id="cancel") yield Button("Отмена", id="cancel", compact=True)
yield Button("Начать", variant="success", id="start") yield Button("Начать", variant="success", id="start", compact=True)
def on_mount(self) -> None: def on_mount(self) -> None:
# повторяет умолчание confirm_target: при recreate=true согласие не подразумевается # повторяет умолчание confirm_target: при recreate=true согласие не подразумевается
@@ -355,7 +355,7 @@ class ConfirmModal(ModalScreen):
return (f"локальная база {name} будет удалена (DROP DATABASE) и создана заново — " return (f"локальная база {name} будет удалена (DROP DATABASE) и создана заново — "
f"её текущее содержимое пропадёт") 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") self.query_one("#danger", Static).set_class(not event.value, "hidden")
def on_input_submitted(self) -> None: def on_input_submitted(self) -> None:
@@ -376,7 +376,7 @@ class ConfirmModal(ModalScreen):
self.notify("имя локальной базы пустое", severity="error") self.notify("имя локальной базы пустое", severity="error")
return return
self.spec.target_db = value 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) self.dismiss(self.spec)
def action_cancel(self) -> None: 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]) self.target_db = None if len(dbs) > 1 else (configured or dbs[0])
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
with Vertical(classes="dialog"): # Checkbox вместо пары Switch+Label: подпись встроена, строка вместо трёх
yield Static("Операция с базой", classes="title") with Vertical(classes="dialog op-dialog"):
yield Static(", ".join(self.dbs), classes="subject") yield Static(f"Операция с базой · {', '.join(self.dbs)}", classes="title")
with VerticalScroll(classes="form"): with VerticalScroll(classes="form"):
with Horizontal(classes="switch-row"): yield Checkbox("поднять на локальной PG сразу после дампа",
yield Switch(value=self.autorestore, id="autorestore") value=self.autorestore, id="autorestore", compact=True)
yield Label("поднять на локальной PG сразу после дампа")
yield Static("Дамп", classes="section") yield Static("Дамп", classes="section")
for wid, label, key in (("schema-only", "только схема, без данных", "schema_only"), yield Checkbox("только схема, без данных",
("no-owner", "без владельцев (--no-owner)", "no_owner"), value=bool(self.dump_opts.get("schema_only", False)),
("no-priv", "без прав (--no-privileges)", "no_privileges")): id="schema-only", compact=True)
with Horizontal(classes="switch-row"): with Horizontal(classes="check-row"):
yield Switch(value=bool(self.dump_opts.get(key, key != "schema_only")), yield Checkbox("без владельцев",
id=wid) value=bool(self.dump_opts.get("no_owner", True)),
yield Label(label) id="no-owner", compact=True)
yield Label("сжатие 09") yield Checkbox("без прав",
yield Input(value=str(self.dump_opts.get("compress", 6)), id="compress") 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): with Collapsible(title="состав дампа", collapsed=True):
yield Label("только схемы (через запятую)") for wid, label, key in (
yield Input(value=_csv(self.dump_opts.get("schemas")), id="schemas") ("schemas", "только схемы", "schemas"),
yield Label("исключить схемы") ("ex-schemas", "исключить схемы", "exclude_schemas"),
yield Input(value=_csv(self.dump_opts.get("exclude_schemas")), ("ex-tables", "исключить таблицы", "exclude_tables"),
id="ex-schemas") ("ex-data", "исключить данные таблиц", "exclude_table_data")):
yield Label("исключить таблицы") with Horizontal(classes="field"):
yield Input(value=_csv(self.dump_opts.get("exclude_tables")), yield Label(label, classes="inline")
id="ex-tables") yield Input(value=_csv(self.dump_opts.get(key)), id=wid,
yield Label("исключить только данные таблиц") compact=True, placeholder="через запятую")
yield Input(value=_csv(self.dump_opts.get("exclude_table_data")),
id="ex-data")
with Vertical(id="target-block", with Vertical(id="target-block",
classes="" if self.autorestore else "hidden"): classes="" if self.autorestore else "hidden"):
yield Static("Локальная база", classes="section") yield Static("Локальная база", classes="section")
with Horizontal(classes="field"):
if self.target_db is None: if self.target_db is None:
yield Static("каждая база — в одноимённую локальную", classes="subject") yield Label("каждая база — в одноимённую локальную", classes="inline")
else: else:
yield Label("имя") yield Label("имя", classes="inline")
yield Input(value=self.target_db, id="target") yield Input(value=self.target_db, id="target", compact=True)
with Horizontal(classes="switch-row"): with Horizontal(classes="check-row"):
yield Switch(value=self.recreate, id="recreate") yield Checkbox("пересоздать (DROP + CREATE)", value=self.recreate,
yield Label("пересоздать (DROP + CREATE)") id="recreate", compact=True)
yield Label("параллельных воркеров restore") yield Label("воркеров", classes="inline")
yield Input(value=str(self.restore_opts.get("jobs", 4)), id="jobs") yield Input(value=str(self.restore_opts.get("jobs", 4)), id="jobs",
compact=True, classes="num")
yield Static(self._danger_text(), id="danger", yield Static(self._danger_text(), id="danger",
classes="danger" + ("" if self.recreate else " hidden")) classes="danger" + ("" if self.recreate else " hidden"))
with Horizontal(classes="row"): with Horizontal(classes="row"):
yield Button("Отмена", id="cancel") yield Button("Отмена", id="cancel", compact=True)
yield Button("Начать", variant="success", id="start") yield Button("Начать", variant="success", id="start", compact=True)
def on_mount(self) -> None: def on_mount(self) -> None:
# согласие на DROP не подразумевается: фокус на «Отмена», пока база пересоздаётся # согласие на DROP не подразумевается: фокус на «Отмена», пока база пересоздаётся
@@ -474,11 +476,11 @@ class DbOpModal(ModalScreen):
return (f"локальная база {name} будет удалена (DROP DATABASE) и создана заново — " return (f"локальная база {name} будет удалена (DROP DATABASE) и создана заново — "
f"её текущее содержимое пропадёт") f"её текущее содержимое пропадёт")
def on_switch_changed(self, event: Switch.Changed) -> None: def on_checkbox_changed(self, event: Checkbox.Changed) -> None:
if event.switch.id == "autorestore": if event.checkbox.id == "autorestore":
self.autorestore = event.value self.autorestore = event.value
self.query_one("#target-block").set_class(not event.value, "hidden") 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.recreate = event.value
self.query_one("#danger", Static).set_class(not event.value, "hidden") self.query_one("#danger", Static).set_class(not event.value, "hidden")
@@ -509,9 +511,9 @@ class DbOpModal(ModalScreen):
if compress is None: if compress is None:
return return
dump_opts = { dump_opts = {
"schema_only": self.query_one("#schema-only", Switch).value, "schema_only": self.query_one("#schema-only", Checkbox).value,
"no_owner": self.query_one("#no-owner", Switch).value, "no_owner": self.query_one("#no-owner", Checkbox).value,
"no_privileges": self.query_one("#no-priv", Switch).value, "no_privileges": self.query_one("#no-priv", Checkbox).value,
"compress": compress, "compress": compress,
"schemas": _split_csv(self.query_one("#schemas", Input).value), "schemas": _split_csv(self.query_one("#schemas", Input).value),
"exclude_schemas": _split_csv(self.query_one("#ex-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: if jobs is None:
return return
overrides["restore"] = {"jobs": jobs} 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: if self.target_db is not None:
target_db = self.query_one("#target", Input).value.strip() target_db = self.query_one("#target", Input).value.strip()
if not target_db: if not target_db:
+31 -3
View File
@@ -21,7 +21,7 @@ sys.path.insert(0, str(Path(__file__).parent))
import pg_stand_sync as core import pg_stand_sync as core
import pg_stand_sync_tui as tui import pg_stand_sync_tui as tui
from textual.widgets import Input, Switch from textual.widgets import Checkbox, Collapsible, Input
PY = sys.executable PY = sys.executable
DBS = [("zpas", "1284 MB"), ("zpas_arch", "312 MB"), ("zpas_test", "88 MB"), 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) await settle(app, pilot)
modal = await _open_db_dialog(app, pilot) modal = await _open_db_dialog(app, pilot)
assert isinstance(modal, tui.DbOpModal) 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("#compress", Input).value = "0"
modal.query_one("#ex-schemas", Input).value = "audit, tmp" modal.query_one("#ex-schemas", Input).value = "audit, tmp"
modal.action_start() 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: async with app.run_test(size=(120, 40)) as pilot:
await settle(app, pilot) await settle(app, pilot)
modal = await _open_db_dialog(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 # главный сценарий по умолчанию assert switch.value is True # главный сценарий по умолчанию
switch.value = False switch.value = False
await pilot.pause() 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(): if key.startswith("ctrl+") and len(key) == 6 and key[5].isascii():
twin = f"ctrl+{tui.RU_LAYOUT[key[5]]}" twin = f"ctrl+{tui.RU_LAYOUT[key[5]]}"
assert pairs.get(twin) == action, f"нет двойника для {key}" 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())