diff --git a/src/pganec/tui.py b/src/pganec/tui.py index 94691ba..7bb2fa7 100644 --- a/src/pganec/tui.py +++ b/src/pganec/tui.py @@ -10,10 +10,16 @@ from textual.css.query import DOMQuery from textual.reactive import reactive from textual.screen import Screen from textual.widgets import Button, Header, Footer, Static, Log, Label, ListView, ListItem, OptionList +from textual.widget import Widget +from typing import TYPE_CHECKING +if TYPE_CHECKING: + from .tui import PGanecApp # --- Настройки и инициирование логирования модуля --- logger = logging.getLogger(__name__) +# --- КОНФИГ_ПЕРЕМЕННЕЫ которые использоваться в приложении (возможно стоит перенести в другой файл) --- +DB_FOR_BACKUP = "db_4b" # --- Кастомный обработчик логирования (для Textual) --- class TextualLogHandler(logging.Handler): @@ -42,7 +48,7 @@ class TextualLogHandler(logging.Handler): class MainMenu(Static): def compose(self) -> ComposeResult: yield Horizontal( - Button(label="Backup (DB\u2192Dump)", id="backup"), + Button(label="Backup (DB\u2192Dump)", id="backup", classes="main-menu-button"), Button(label="Restore (Dump\u2192DB)", id="restore"), Button(label="Copy (DB\u2192DB)", id="copy", variant="error"), Button(label="Service", id="service"), @@ -53,8 +59,18 @@ class MainMenu(Static): # --- Виджет для "встроенного" выбора --- class SelectionWidget(Static): # Наследуем от Static """ - Виджет для отображения опций выбора бэкапа внутри основного экрана. + Виджет для отображения опций выбора бекапа внутри основного экрана. """ + @property + def app(self) -> "PGanecApp": + """ + Возвращает ссылку на приложение PGanecApp , к которому принадлежит этот виджет. Нужно для доступа к методам + и состоянию приложения (и статического анализатора, т.к. класс PGanecApp описан ниже и "отсюда не виден"). + :return: + """ + return super().app # type: ignore + + def __init__(self, app_config: Optional[Dict[str, Any]] = None, action_type=None, @@ -62,18 +78,40 @@ class SelectionWidget(Static): # Наследуем от Static super().__init__(**kwargs) self.app_config = app_config if app_config is not None else {} self.action_type = action_type # Сохраняем тип действия + self._button_id_to_data_map: Dict[str, Any] = {} # Карта для хранения значений опций # SelectionWidget(Static) def compose(self) -> ComposeResult: + # options: List[Tuple[str, str]] = [] # Список опций для выбора + self._button_id_to_data_map.clear() # Очищаем карту значений + num_count = 1 if self.action_type == "backup": yield Label("Выберите BD-сервер для бэкапа:") + # ... тут будет список серверов if "servers" in self.app_config: logger.info(f"TUI: Обнаружено {len(self.app_config['servers'])} серверов в конфигурации.") - # ... тут будет список серверов + for server_conf in self.app_config["servers"]: + server_name = server_conf.get("name") + button_id = server_conf.get("id") + if not button_id: + button_id = f"server_{num_count:02d}" # Генерируем временный id, если не указан + logger.warning( + f"Сервер '{server_name}' не имеет 'id' в yaml-конфигурации. Установлен id='{button_id}'.") + # continue # пропускаем? + if button_id in self._button_id_to_data_map: + logger.error( + f"TUI: Дублирующийся ID кнопки '{button_id}' для сервера '{server_name}' из yaml-конфигурации. ПРОРУСКАЕМ.") + continue + # Добавляем кнопку для каждого сервера + yield Button(label=f"{num_count:02d}: {server_name}", id=button_id, classes="list-button") + self._button_id_to_data_map[button_id] = {"type": DB_FOR_BACKUP, "config": server_conf} + num_count += 1 + elif self.action_type == "restore": yield Label("Выберите том для восстановления:") - if "servers" in self.app_config: + if "targets" in self.app_config: logger.info(f"TUI: Обнаружено {len(self.app_config['targets'])} томов в конфигурации.") + yield Static("Тут будет список...", classes="placeholder-text") # Временная заглушка # ... тут будет список томов elif self.action_type == "copy": yield Label("Выберите BD-сервер (откуда копировать):") @@ -82,22 +120,54 @@ class SelectionWidget(Static): # Наследуем от Static yield Label("Служебные функции:") # ... тут будут служебные функции + # Кнопка "Отмена" в конце любого списка + yield Button(label=f"{num_count:02d}: Отмена", id="cancel_widget", classes="list-button") - yield Static("Тут будет список чего-то...", classes="placeholder-text") # Временная заглушка - yield Button("Отмена", id="cancel_backup_selection", variant="default") def on_mount(self) -> None: + """ + Вызывается после монтирования виджета в DOM. + :return: + """ logger.info(f"Виджет '{self.id}' (SelectionWidget) смонтирован.") + try: + # Пытаемся установить фокус на первую кнопку-опцию + # Ищем первую кнопку с классом "list-button" внутри этого виджета + first_option_button = self.query_one("Button.list-button", Button) + first_option_button.focus() + logger.debug(f"TUI: SelectionWidget: Фокус на кнопке-опцию: `{first_option_button.id}`") + except Exception as e: + # except DOMQuery.DoesNotExist: + logger.error(f"SelectionWidget: Ошибка при установке фокуса в Виджете: {e}") + # logger.warning(f"SelectionWidget: OptionList не найден для установки фокуса (action_type: {self.action_type}).") + # Можно сразу установить фокус на первый элемент, если он есть # self.query_one(OptionList).focus() или self.query_one(Button).focus() + async def on_button_pressed(self, event: Button.Pressed) -> None: - if event.button.id == "cancel_backup_selection": - logger.info("Нажата отмена в SelectionWidget.") + button_id = event.button.id + button_data = self._button_id_to_data_map.get(button_id) # <--- Извлекаем данные по ID кнопки + + logger.info(f"SelectionWidget ({self.action_type}): нажата кнопка-опция id='{button_id}', data={button_data}") + + if button_data: + action_type_from_data = button_data.get("type") + if action_type_from_data == DB_FOR_BACKUP: + server_config = button_data.get("config") # <--- Получаем полную конфигурацию сервера + logger.info( + f"SelectionWidget ({self.action_type}): выбран сервер {server_config.get('name') if isinstance(server_config, dict) else server_config}. Далее - выбор БД/дампа.") + # Тут будет логика для перехода к следующему шагу, используя server_config + self.app.bell() + # ... другие обработчики ... + + if event.button.id == "cancel_widget": + logger.debug("Нажата 'Отмена' в SelectionWidget.") await self.remove() # Удаляем сам виджет self.app.selection_widget_active = False # <-- Сброс флага что виджет активен # Можно также послать сообщение приложению, чтобы оно обновило состояние, если нужно # self.app.post_message(BackupSelectionCancelled()) + self.app.set_main_menu_enabled(True) # Включаем верхнее меню обратно @@ -109,7 +179,7 @@ class PGanecApp(App): height: 1fr; /* 1fr означает "одна доля доступного пространства" */ overflow-y: auto; /* Если контент не влезет, появится прокрутка */ } - #title { width: 100%; text-align: left; background: purple; padding: 0 1; } + #title { width: 100%; text-align: left; background: purple; padding: 0; } #menu { width: 100%; height: 3; align: left top; padding: 0; } #menu Button { align: center middle; @@ -140,19 +210,21 @@ class PGanecApp(App): background: $panel; /* Используем системный цвет панели */ /* height: auto; /* Высота будет по содержимому */ } - SelectionWidget .selection-widget-label { - margin-bottom: 1; - text-style: bold; - } + SelectionWidget .placeholder-text { - padding: 1; + padding: 0; color: $text-muted; } - SelectionWidget Button { /* Стили для кнопок внутри виджета */ - width: auto; /* Чтобы кнопка была по размеру текста */ - background: 15%; - margin-top: 1; + Button.list-button { /* Стили для кнопок внутри виджета */ + width: auto; + height: 1; + align: left middle; + border: hidden; + background: transparent; + color: $text; + margin-left: 0; padding-left: 1; } + Button#cancel_widget {margin-left: -1; color: red; } /* Отдельный стиль для кнопки "Отмена" */ Footer { dock: bottom; height: 1; } Header { dock: top; height: 1; } @@ -205,6 +277,18 @@ class PGanecApp(App): yield Log(id="app_log_viewer", highlight=True) # Виджет лога yield Footer() + + def set_main_menu_enabled(self, enabled: bool): + """ + Включает или отключает кнопки верхнего меню + :param enabled: + :return: + """ + menu = self.query_one(MainMenu) + for btn in menu.query(Button): + btn.disabled = not enabled + + async def show_selection_widget(self, action_type: str) -> None: """Показывает виджет выбора для .""" # Сначала удаляем старый виджет, если он есть, чтобы не было дублей @@ -223,6 +307,7 @@ class PGanecApp(App): ) self.selection_widget_active = True # Выставляем флаг, что виджет активен + self.set_main_menu_enabled(False) # Отключаем верхнее меню # Находим контейнер, куда его добавить. # Будем добавлять после MainMenu внутри #main_content_area main_content_area = self.query_one("#main_content_area", Vertical) @@ -238,7 +323,7 @@ class PGanecApp(App): async def on_button_pressed(self, event: Button.Pressed) -> None: """ Обработчик нажатия (Enter или Click) кнопки в верхнем меню. - :param event: + :param event: Полученное событие нажатия кнопки на верхнем меню. :return: """ button_id = event.button.id @@ -316,10 +401,10 @@ class PGanecApp(App): logger.debug("TUI: Что-то нажато (Enter), но это не кнопка.") return - if focused.parent and isinstance(focused.parent, SelectionWidget): - await focused.parent.on_button_pressed(Button.Pressed(focused)) - else: - await self.on_button_pressed(Button.Pressed(focused)) + # if focused.parent and isinstance(focused.parent, SelectionWidget): + # await focused.parent.on_button_pressed(Button.Pressed(focused)) + # else: + # await self.on_button_pressed(Button.Pressed(focused)) # --- Обработчики быстрых клавиш --- # Для 'q' (Quit) уже есть в BINDINGS срабатывающий метод action_quit, он сработает автоматически (не ясно почему).