diff --git a/src/pganec/tui.py b/src/pganec/tui.py index dfd8944..94691ba 100644 --- a/src/pganec/tui.py +++ b/src/pganec/tui.py @@ -14,7 +14,8 @@ from textual.widgets import Button, Header, Footer, Static, Log, Label, ListView # --- Настройки и инициирование логирования модуля --- logger = logging.getLogger(__name__) -# Кастомный обработчик логирования для Textual + +# --- Кастомный обработчик логирования (для Textual) --- class TextualLogHandler(logging.Handler): """ Обработчик логирования, который направляет записи в виджет Textual Log. @@ -37,56 +38,88 @@ class TextualLogHandler(logging.Handler): self.handleError(record) +# --- Верхнее меню приложения (для Textual) --- class MainMenu(Static): def compose(self) -> ComposeResult: yield Horizontal( - Button(label="Backup (DB -> Dump)", id="backup"), - Button(label="Restore (Dump -> DB)", id="restore"), - Button(label="Copy (DB -> DB)", id="copy", variant="error"), + Button(label="Backup (DB\u2192Dump)", id="backup"), + Button(label="Restore (Dump\u2192DB)", id="restore"), + Button(label="Copy (DB\u2192DB)", id="copy", variant="error"), + Button(label="Service", id="service"), Button(label="Quit", id="quit", variant="error"), id="menu", ) -# Новый простой экран для Backup -class SimpleBackupScreen(Screen): +# --- Виджет для "встроенного" выбора --- +class SelectionWidget(Static): # Наследуем от Static """ - Простой экран для демонстрации перехода при выборе Backup. + Виджет для отображения опций выбора бэкапа внутри основного экрана. """ - - BINDINGS = [ - ("escape", "pop_screen", "Назад"), # Позволяет вернуться на главный экран по Escape - ] + def __init__(self, + app_config: Optional[Dict[str, Any]] = None, + action_type=None, + **kwargs): + super().__init__(**kwargs) + self.app_config = app_config if app_config is not None else {} + self.action_type = action_type # Сохраняем тип действия + # SelectionWidget(Static) def compose(self) -> ComposeResult: - yield Header(name="Экран Бэкапа") # Заголовок для нового экрана - yield Label("Привет, мир! Это экран для бэкапа.", classes="greeting-label") - yield Footer() + if self.action_type == "backup": + yield Label("Выберите BD-сервер для бэкапа:") + if "servers" in self.app_config: + logger.info(f"TUI: Обнаружено {len(self.app_config['servers'])} серверов в конфигурации.") + # ... тут будет список серверов + elif self.action_type == "restore": + yield Label("Выберите том для восстановления:") + if "servers" in self.app_config: + logger.info(f"TUI: Обнаружено {len(self.app_config['targets'])} томов в конфигурации.") + # ... тут будет список томов + elif self.action_type == "copy": + yield Label("Выберите BD-сервер (откуда копировать):") + # ... тут будет список серверов + elif self.action_type == "service": + yield Label("Служебные функции:") + # ... тут будут служебные функции + + + yield Static("Тут будет список чего-то...", classes="placeholder-text") # Временная заглушка + yield Button("Отмена", id="cancel_backup_selection", variant="default") def on_mount(self) -> None: - logger.info(f"Экран '{self.id}' (SimpleBackupScreen) смонтирован.") + logger.info(f"Виджет '{self.id}' (SelectionWidget) смонтирован.") + # Можно сразу установить фокус на первый элемент, если он есть + # 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.") + await self.remove() # Удаляем сам виджет + self.app.selection_widget_active = False # <-- Сброс флага что виджет активен + # Можно также послать сообщение приложению, чтобы оно обновило состояние, если нужно + # self.app.post_message(BackupSelectionCancelled()) + + class PGanecApp(App): CSS = """ - Screen { - layout: vertical; /* Используем вертикальный layout для всего экрана */ - } + Screen { layout: vertical; } /* Вертикальная компоновка для всего приложения */ #main_content_area { /* Новый контейнер для всего, что НЕ прибито к краям */ - /* Этот контейнер будет занимать все доступное пространство - между Header и Log/Footer */ + /* Этот контейнер будет занимать все доступное пространство между Header и Log/Footer */ height: 1fr; /* 1fr означает "одна доля доступного пространства" */ overflow-y: auto; /* Если контент не влезет, появится прокрутка */ } #title { width: 100%; text-align: left; background: purple; padding: 0 1; } #menu { width: 100%; height: 3; align: left top; padding: 0; } - Button { + #menu Button { align: center middle; - width: 25%; + width: 20%; color: orange; background: transparent; margin: 0; padding: 0; } - Button#backup, Button#restore, Button#quit { color: orange; border: round orange; } + Button#backup, Button#restore, Button#service, Button#quit { color: orange; border: round orange; } Button#copy { color: grey; border: round orange; } @@ -95,19 +128,31 @@ class PGanecApp(App): Log#app_log_viewer { dock: bottom; height: 10; - margin-top: 1; border-top: double grey; background: 15%; } Log#app_log_viewer:focus { height: 50%; border-top: solid green; background: 45%; } - /* Стили для нового экрана */ - SimpleBackupScreen .greeting-label { - width: 100%; - text-align: center; - padding: 2 0; /* Немного отступов сверху и снизу */ + /* Стили для встроенного виджета выбора */ + SelectionWidget { + padding: 1; + margin-top: 0; + background: $panel; /* Используем системный цвет панели */ + /* height: auto; /* Высота будет по содержимому */ + } + SelectionWidget .selection-widget-label { + margin-bottom: 1; text-style: bold; } + SelectionWidget .placeholder-text { + padding: 1; + color: $text-muted; + } + SelectionWidget Button { /* Стили для кнопок внутри виджета */ + width: auto; /* Чтобы кнопка была по размеру текста */ + background: 15%; + margin-top: 1; + } Footer { dock: bottom; height: 1; } Header { dock: top; height: 1; } @@ -118,12 +163,16 @@ class PGanecApp(App): ("b", "backup", "Backup"), ("r", "restore", "Restore"), ("c", "copy", "Copy"), + ("s", "service", "Service"), ("q", "quit", "Quit"), ("right", "focus_next"), ("down", "focus_next"), # Стрелочки для навигации (вниз/вправо -- вперед) ("left", "focus_previous"), ("up", "focus_previous"), # Стрелочки для навигации (вверх/влево -- назад) ("enter", "activate"), ] + # Атрибут для хранения ссылки на виджет выбора, если он отображен + selection_widget_instance: Optional[SelectionWidget] = None + def __init__(self, log_level_int: int = logging.INFO, app_config: Dict[str, Any] = None, @@ -137,6 +186,7 @@ class PGanecApp(App): self.log_level_int = log_level_int self.app_config = app_config if app_config is not None else {} # Сохраняем конфигурацию self.early_log_handler = early_log_handler # <--- Сохраняем + self.selection_widget_instance = None # Инициализируем атрибут для виджета выбора logger.debug( f"TUI инициализирован. Log Level: `{logging.getLevelName(self.log_level_int)}`; Config Keys: {list(self.app_config.keys())}" ) @@ -148,19 +198,62 @@ class PGanecApp(App): yield Static("PGanec TUI", id="title") yield MainMenu() # Другие элементы основного интерфейса могут быть здесь + # Место для нашего виджета выбора будет здесь. + # Мы не добавляем его сразу, а будем делать это динамически. + # Можно добавить "якорь" - пустой контейнер, если нужно точное позиционирование + yield Container(id="selection_widget_placeholder") yield Log(id="app_log_viewer", highlight=True) # Виджет лога yield Footer() + async def show_selection_widget(self, action_type: str) -> None: + """Показывает виджет выбора для .""" + # Сначала удаляем старый виджет, если он есть, чтобы не было дублей + if self.selection_widget_instance: + try: + await self.selection_widget_instance.remove() + except Exception as e: # DOMObjectMissingError может возникнуть, если уже удален + logger.debug(f"Ошибка при удалении старого selection_widget_instance (возможно, уже удален): {e}") + self.selection_widget_instance = None + + # Создаем новый экземпляр виджета + self.selection_widget_instance = SelectionWidget( + app_config=self.app_config, + action_type=action_type, + id="select_any" # Даем ему id для возможного поиска + ) + + self.selection_widget_active = True # Выставляем флаг, что виджет активен + # Находим контейнер, куда его добавить. + # Будем добавлять после MainMenu внутри #main_content_area + main_content_area = self.query_one("#main_content_area", Vertical) + main_menu = self.query_one(MainMenu) + + # Добавляем виджет в DOM после MainMenu + # `mount` добавляет виджет и вызывает его on_mount + await main_content_area.mount(self.selection_widget_instance, after=main_menu) + self.selection_widget_instance.focus() # Пытаемся установить фокус + logger.info("SelectionWidget добавлен в DOM.") + + async def on_button_pressed(self, event: Button.Pressed) -> None: + """ + Обработчик нажатия (Enter или Click) кнопки в верхнем меню. + :param event: + :return: + """ button_id = event.button.id if button_id == "backup": - await self.push_screen(SimpleBackupScreen()) + logger.debug("TUI: Enter или Click на кнопку 'Backup' -- Инициализируем виджет выбора.") + await self.show_selection_widget("backup") elif button_id == "restore": - logger.info("Действие 'Restore' пока не реализовано с новым экраном.") - self.app.bell() # Сигнал, что действие не выполнено + logger.debug("TUI: Enter или Click на кнопку 'Restore' -- Инициализируем виджет выбора.") + await self.show_selection_widget("restore") elif button_id == "copy": - logger.warning("Copy action is currently a placeholder for quit.") - await self.action_quit() + logger.debug("TUI: Enter или Click на кнопку 'Copy' -- Инициализируем виджет выбора.") + await self.show_selection_widget("copy") + elif button_id == "service": + logger.debug("TUI: Enter или Click на кнопку 'Service' -- Инициализируем виджет выбора.") + await self.show_selection_widget("service") elif button_id == "quit": await self.action_quit() @@ -186,36 +279,90 @@ class PGanecApp(App): target_logger = logging.getLogger() target_logger.addHandler(textual_handler) # Добавляем наш обработчик - # --- Перенос и сброс ранних логов из MemoryHandler --- + # --- -- Перенос и сброс ранних логов из MemoryHandler -- --- if self.early_log_handler: # Устанавливаем наш textual_handler как цель для MemoryHandler self.early_log_handler.setTarget(textual_handler) # Сбрасываем все накопленные записи self.early_log_handler.flush() - # Закрываем и удаляем MemoryHandler из корневого логгера, т.к. он больше не нужен + # Закрываем и удаляем MemoryHandler из корневого логера, так как он больше не нужен self.early_log_handler.close() logger.debug("TUI: Перенесли и закрыли ранние логи…") target_logger.removeHandler(self.early_log_handler) logger.debug("TUI: Удалили ранний MemoryHandler из корневого логгера…") self.early_log_handler = None # Очищаем ссылку - # --- Конец переноса и сброса --- + # --- -- Конец переноса и сброса -- --- + # --- Конец обжужукивания логирования --- logger.info("TUI-логгер инициализирован") - logger.debug("TUI: DEBUG сообщение для проверки") - logger.warning("TUI: WARNING для проверки") - logger.error("TUI: ERROR тоже для проверки") + logger.debug("test TUI-logger: DEBUG") + logger.warning("test TUI-logger: WARNING") + logger.error("test TUI-logger: ERROR") async def action_activate(self) -> None: + """ + Обработчик нажатия клавиши Enter. + :return: + """ focused = self.focused - if isinstance(focused, Button): - await self.on_button_pressed(Button.Pressed(focused)) + if not focused: + logger.debug("TUI: action_activate вызван, но нет сфокусированного элемента.") + return + else: + if isinstance(focused, Button): + # Проверяем, не является ли кнопка частью нашего виджета выбора + if not isinstance(focused, Button): # Если не кнопка - ничего не делаем + logger.debug("TUI: Что-то нажато (Enter), но это не кнопка.") + return -# Заглушки для других экранов: -class BackupScreen(Static): - def on_mount(self) -> None: - self.update("🛠 Здесь будет экран выбора сервера для бэкапа.") + 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)) -class RestoreScreen(Static): - def on_mount(self) -> None: - self.update("♻️ Здесь будет экран выбора сервера и бэкапа для восстановления.") + # --- Обработчики быстрых клавиш --- + # Для 'q' (Quit) уже есть в BINDINGS срабатывающий метод action_quit, он сработает автоматически (не ясно почему). + async def action_backup(self): + """ + Обработчик нажатия быстрой клавиши 'b' для инициализации виджета создания бэкапа. + :return: + """ + if getattr(self, "selection_widget_active", False): # Проверяем, активен ли виджет выбора + return # Если активен, ничего не делаем (отменяем нажатие клавиши) + self.query_one("#backup", Button).focus() # подсветим кнопку + logger.debug("TUI: Быстрая клавиша 'b' -- Инициализируем виджет.") + await self.show_selection_widget("backup") + + async def action_restore(self): + """ + Обработчик нажатия быстрой клавиши 'r' для восстановления. + :return: + """ + if getattr(self, "selection_widget_active", False): # Проверяем, активен ли виджет выбора + return + self.query_one("#restore", Button).focus() # подсветим кнопку + logger.debug("TUI: Быстрая клавиша 'r' -- Инициализируем виджет.") + await self.show_selection_widget("restore") + + async def action_copy(self): + """ + Обработчик нажатия быстрой клавиши 'c' для копирования. + :return: + """ + if getattr(self, "selection_widget_active", False): # Проверяем, активен ли виджет выбора + return + self.query_one("#copy", Button).focus() + logger.debug("TUI: Быстрая клавиша 'c' -- Инициализируем виджет.") + await self.show_selection_widget("copy") + + async def action_service(self): + """ + Обработчик нажатия быстрой клавиши 's' для сервисных действий. + :return: + """ + if getattr(self, "selection_widget_active", False): # Проверяем, активен ли виджет выбора + return + self.query_one("#service", Button).focus() + logger.debug("TUI: Быстрая клавиша 's' -- Инициализируем виджет.") + await self.show_selection_widget("service")