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

Пары 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;
}
/* высота фиксирована, форма забирает остаток: иначе длинная форма выдавливала
ряд кнопок за нижнюю границу диалога, и «Начать» становилось не видно */
.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 {
+57 -55
View File
@@ -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("сжатие 09")
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")
with Horizontal(classes="field"):
if self.target_db is None:
yield Static("каждая база — в одноимённую локальную", classes="subject")
yield Label("каждая база — в одноимённую локальную", classes="inline")
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")
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:
+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_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())