Компактный диалог операции и кнопки, которые не уезжают
Пары Switch + Label заменены на Checkbox с встроенной подписью: строка вместо трёх. Поля стали однострочными, чекбоксы дампа встали в ряд, заголовок и имя базы слились в одну строку — диалог ужался с почти целого экрана до 17-18 строк на любом размере консоли. Ряд кнопок пропадал потому, что .dialog с height: auto складывал высоту детей и упирался в max-height, обрезая нижнюю строку. Теперь у диалога операции своя высота, а форма забирает остаток через height: 1fr и скроллится сама. Диалог restore приведён к тому же виду.
This commit is contained in:
+39
-12
@@ -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 {
|
||||
|
||||
+59
-57
@@ -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:
|
||||
|
||||
+31
-3
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user