Astro-Session-Watcher/ui/main_window.py
2026-05-07 17:15:56 +03:00

642 lines
No EOL
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
MainWindow - главное окно приложения на PySide6
"""
import sys
import subprocess
import platform
from pathlib import Path
from datetime import datetime
from typing import Optional
from PySide6.QtWidgets import (
QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QGridLayout,
QLabel, QLineEdit, QComboBox, QPushButton, QMenuBar, QMenu,
QMessageBox, QFileDialog, QInputDialog, QFrame, QApplication
)
from PySide6.QtCore import Qt, QTimer, Signal
from PySide6.QtGui import QFont, QIcon, QAction
from services.config_service import ConfigService
from services.session_service import SessionService
from services.watch_service import WatchService
from services.file_service import FileService
class MainWindow(QMainWindow):
"""Главное окно приложения"""
def __init__(self):
super().__init__()
# Сервисы
self.config_service = ConfigService()
self.session_service = SessionService()
self.watch_service = WatchService()
# Переменные состояния
self.running = False
self.file_count = 0
self._blink_timer = None
self._new_object_blink_timer = None
# Настройка окна
self.setWindowTitle("Astro Session Watcher v0.3.0")
self.setMinimumSize(700, 500)
self.resize(800, 550)
self.center_window()
self._create_menu_bar()
self._create_main_content()
self._load_saved_settings()
self._setup_hotkeys()
self._update_file_count_display()
self.setAttribute(Qt.WA_DeleteOnClose)
def center_window(self):
screen = QApplication.primaryScreen().availableGeometry()
self.setGeometry(
(screen.width() - self.width()) // 2,
(screen.height() - self.height()) // 2,
self.width(),
self.height()
)
def _create_menu_bar(self):
menubar = self.menuBar()
# Меню Файл
file_menu = menubar.addMenu("Файл")
select_folder_action = QAction("Выбрать папку...", self)
select_folder_action.setShortcut("Ctrl+O")
select_folder_action.triggered.connect(self.select_folder)
file_menu.addAction(select_folder_action)
file_menu.addSeparator()
equipment_action = QAction("Оборудование...", self)
equipment_action.setShortcut("Ctrl+E")
equipment_action.triggered.connect(self.open_equipment_dialog)
file_menu.addAction(equipment_action)
celestial_action = QAction("Небесные тела...", self)
celestial_action.setShortcut("Ctrl+B")
celestial_action.triggered.connect(self.open_celestial_dialog)
file_menu.addAction(celestial_action)
file_menu.addSeparator()
exit_action = QAction("Выход", self)
exit_action.setShortcut("Ctrl+Q")
exit_action.triggered.connect(self.close)
file_menu.addAction(exit_action)
# Меню Сессия
session_menu = menubar.addMenu("Сессия")
start_action = QAction("Начать наблюдение", self)
start_action.setShortcut("Ctrl+S")
start_action.triggered.connect(self.start)
session_menu.addAction(start_action)
stop_action = QAction("Остановить наблюдение", self)
stop_action.setShortcut("Ctrl+X")
stop_action.triggered.connect(self.stop)
session_menu.addAction(stop_action)
session_menu.addSeparator()
open_folder_action = QAction("Открыть папку сессии", self)
open_folder_action.setShortcut("Ctrl+F")
open_folder_action.triggered.connect(self.open_session_folder)
session_menu.addAction(open_folder_action)
session_menu.addSeparator()
new_object_action = QAction("Новая цель...", self)
new_object_action.setShortcut("Ctrl+Shift+N")
new_object_action.triggered.connect(self.set_new_object)
session_menu.addAction(new_object_action)
# Меню Помощь
help_menu = menubar.addMenu("Помощь")
instructions_action = QAction("Инструкция", self)
instructions_action.setShortcut("F2")
instructions_action.triggered.connect(self.show_instructions)
help_menu.addAction(instructions_action)
help_menu.addSeparator()
about_action = QAction("О программе", self)
about_action.setShortcut("F1")
about_action.triggered.connect(self.show_info)
help_menu.addAction(about_action)
def _create_main_content(self):
central_widget = QWidget()
self.setCentralWidget(central_widget)
main_layout = QVBoxLayout(central_widget)
main_layout.setContentsMargins(20, 20, 20, 20)
main_layout.setSpacing(15)
grid_layout = QGridLayout()
grid_layout.setVerticalSpacing(12)
grid_layout.setHorizontalSpacing(15)
# Row 0: Папка
folder_label = QLabel("Папка:")
folder_label.setFont(QFont("", 10, QFont.Bold))
grid_layout.addWidget(folder_label, 0, 0, Qt.AlignRight | Qt.AlignVCenter)
folder_widget = QWidget()
folder_layout = QHBoxLayout(folder_widget)
folder_layout.setContentsMargins(0, 0, 0, 0)
folder_layout.setSpacing(10)
self.folder_entry = QLineEdit()
self.folder_entry.setPlaceholderText("Выберите папку для отслеживания")
folder_layout.addWidget(self.folder_entry)
self.folder_button = QPushButton("Обзор...")
self.folder_button.setFixedWidth(80)
self.folder_button.clicked.connect(self.select_folder)
folder_layout.addWidget(self.folder_button)
grid_layout.addWidget(folder_widget, 0, 1)
# Row 1: Оборудование
equipment_label = QLabel("Оборудование:")
equipment_label.setFont(QFont("", 10, QFont.Bold))
grid_layout.addWidget(equipment_label, 1, 0, Qt.AlignRight | Qt.AlignVCenter)
equipment_widget = QWidget()
equipment_layout = QHBoxLayout(equipment_widget)
equipment_layout.setContentsMargins(0, 0, 0, 0)
equipment_layout.setSpacing(10)
self.camera_combo = QComboBox()
self.camera_combo.setEditable(False)
equipment_layout.addWidget(self.camera_combo)
self.lens_combo = QComboBox()
self.lens_combo.setEditable(False)
equipment_layout.addWidget(self.lens_combo)
grid_layout.addWidget(equipment_widget, 1, 1)
# Row 2: Цель
target_label = QLabel("Цель:")
target_label.setFont(QFont("", 10, QFont.Bold))
grid_layout.addWidget(target_label, 2, 0, Qt.AlignRight | Qt.AlignVCenter)
target_widget = QWidget()
target_layout = QHBoxLayout(target_widget)
target_layout.setContentsMargins(0, 0, 0, 0)
target_layout.setSpacing(10)
self.object_combo = QComboBox()
self.object_combo.setEditable(True)
self.object_combo.setInsertPolicy(QComboBox.NoInsert)
# Настройка плейсхолдера
self.object_combo.lineEdit().setPlaceholderText("Введите название цели")
# Автодополнение при вводе
self.object_combo.lineEdit().textChanged.connect(self._on_object_text_changed)
target_layout.addWidget(self.object_combo)
self.new_object_button = QPushButton("Новая цель")
self.new_object_button.setFixedWidth(100)
self.new_object_button.setEnabled(False)
self.new_object_button.clicked.connect(self.set_new_object)
target_layout.addWidget(self.new_object_button)
grid_layout.addWidget(target_widget, 2, 1)
# Row 3: Статистика
stats_label = QLabel("Статистика:")
stats_label.setFont(QFont("", 10, QFont.Bold))
grid_layout.addWidget(stats_label, 3, 0, Qt.AlignRight | Qt.AlignVCenter)
self.file_count_label = QLabel("Файлов получено: 0")
self.file_count_label.setFont(QFont("", 11))
grid_layout.addWidget(self.file_count_label, 3, 1, Qt.AlignLeft)
# Row 4: Статус
status_label = QLabel("Статус:")
status_label.setFont(QFont("", 10, QFont.Bold))
grid_layout.addWidget(status_label, 4, 0, Qt.AlignRight | Qt.AlignVCenter)
self.status_label = QLabel("IDLE")
self.status_label.setFont(QFont("", 12, QFont.Bold))
self.status_label.setStyleSheet("color: #666666;")
grid_layout.addWidget(self.status_label, 4, 1, Qt.AlignLeft)
main_layout.addLayout(grid_layout)
separator = QFrame()
separator.setFrameShape(QFrame.HLine)
separator.setStyleSheet("background-color: #333333; max-height: 1px;")
main_layout.addWidget(separator)
buttons_layout = QHBoxLayout()
buttons_layout.setSpacing(15)
buttons_layout.setAlignment(Qt.AlignCenter)
self.start_button = QPushButton("▶ Начать отслеживание")
self.start_button.setFixedSize(180, 35)
self.start_button.setStyleSheet("""
QPushButton {
background-color: #4CAF50;
color: white;
font-weight: bold;
border-radius: 4px;
}
QPushButton:hover {
background-color: #45a049;
}
""")
self.start_button.clicked.connect(self.start)
buttons_layout.addWidget(self.start_button)
self.stop_button = QPushButton("■ Остановить")
self.stop_button.setFixedSize(180, 35)
self.stop_button.setEnabled(False)
self.stop_button.setStyleSheet("""
QPushButton {
background-color: #f44336;
color: white;
font-weight: bold;
border-radius: 4px;
}
QPushButton:hover {
background-color: #d32f2f;
}
""")
self.stop_button.clicked.connect(self.stop)
buttons_layout.addWidget(self.stop_button)
main_layout.addLayout(buttons_layout)
footer_layout = QHBoxLayout()
version_label = QLabel("v0.3.0-alpha")
version_label.setStyleSheet("color: #666666; font-size: 11px;")
footer_layout.addWidget(version_label)
footer_layout.addStretch()
copyright_label = QLabel("Made by Vic Sergeev 2026")
copyright_label.setStyleSheet("color: #666666; font-size: 11px;")
footer_layout.addWidget(copyright_label)
main_layout.addLayout(footer_layout)
def _on_object_text_changed(self, text):
"""Автодополнение при вводе названия цели"""
if not text:
return
# Поиск совпадений в списке небесных тел
celestial_bodies = self.config_service.get_celestial_bodies()
matches = [body for body in celestial_bodies if body.lower().startswith(text.lower())]
if matches and matches[0] != text:
# Временно отключаем сигнал, чтобы избежать рекурсии
self.object_combo.lineEdit().blockSignals(True)
self.object_combo.lineEdit().setText(matches[0])
self.object_combo.lineEdit().setSelection(len(text), len(matches[0]))
self.object_combo.lineEdit().blockSignals(False)
def _load_saved_settings(self):
cameras = self.config_service.get_cameras()
lenses = self.config_service.get_lenses()
celestial_bodies = self.config_service.get_celestial_bodies()
if cameras:
self.camera_combo.addItems(cameras)
last_camera = self.config_service.get_last_camera()
if last_camera and last_camera in cameras:
self.camera_combo.setCurrentText(last_camera)
if lenses:
self.lens_combo.addItems(lenses)
last_lens = self.config_service.get_last_lens()
if last_lens and last_lens in lenses:
self.lens_combo.setCurrentText(last_lens)
if celestial_bodies:
self.object_combo.addItems(celestial_bodies)
last_folder = self.config_service.get_last_watch_folder()
if last_folder:
self.folder_entry.setText(last_folder)
def _setup_hotkeys(self):
pass
def _set_running_state(self, state: bool):
self.running = state
if state:
self.start_button.setEnabled(False)
self.stop_button.setEnabled(True)
self.new_object_button.setEnabled(True)
self.status_label.setText("● ON AIR")
self.status_label.setStyleSheet("color: #ff0000; font-weight: bold;")
self._start_blinking()
self._start_new_object_blinking()
else:
self.start_button.setEnabled(True)
self.stop_button.setEnabled(False)
self.new_object_button.setEnabled(False)
self.status_label.setText("IDLE")
self.status_label.setStyleSheet("color: #666666; font-weight: bold;")
self._stop_blinking()
self._stop_new_object_blinking()
def _start_blinking(self):
self._blink_timer = QTimer()
self._blink_timer.timeout.connect(self._do_blink)
self._blink_timer.start(500)
def _do_blink(self):
if not self.running:
return
current_style = self.status_label.styleSheet()
if "color: #ff0000" in current_style:
self.status_label.setStyleSheet("color: #ffffff; font-weight: bold;")
else:
self.status_label.setStyleSheet("color: #ff0000; font-weight: bold;")
def _stop_blinking(self):
if self._blink_timer:
self._blink_timer.stop()
self._blink_timer = None
self.status_label.setStyleSheet("color: #666666; font-weight: bold;")
def _start_new_object_blinking(self):
self._new_object_blink_timer = QTimer()
self._new_object_blink_timer.timeout.connect(self._do_new_object_blink)
self._new_object_blink_timer.start(500)
def _do_new_object_blink(self):
if not self.running:
return
current_style = self.new_object_button.styleSheet()
if "border: 2px solid red" in current_style:
self.new_object_button.setStyleSheet("")
else:
self.new_object_button.setStyleSheet("border: 2px solid red; border-radius: 4px;")
def _stop_new_object_blinking(self):
if self._new_object_blink_timer:
self._new_object_blink_timer.stop()
self._new_object_blink_timer = None
self.new_object_button.setStyleSheet("")
def _update_file_count_display(self):
if self.running and self.session_service.get_current_object():
current_obj = self.session_service.get_current_object()
self.file_count = current_obj.photo_count
self.file_count_label.setText(f"Файлов получено: {self.file_count}")
QTimer.singleShot(1000, self._update_file_count_display)
def _on_file_received(self, file_path: Path):
"""Обработчик получения нового файла"""
print(f"Обнаружен файл: {file_path}")
if self.session_service.handle_file(file_path):
self.file_count += 1
self.file_count_label.setText(f"Файлов получено: {self.file_count}")
print(f"Файл обработан: {file_path.name}")
else:
print(f"Не удалось обработать файл: {file_path}")
def select_folder(self):
folder = QFileDialog.getExistingDirectory(self, "Выберите папку для отслеживания")
if folder:
self.folder_entry.setText(folder)
self.config_service.set_last_watch_folder(folder)
def start(self):
watch_folder = self.folder_entry.text()
object_name = self.object_combo.currentText()
if not watch_folder:
QMessageBox.critical(self, "Ошибка", "Папка для отслеживания не выбрана")
return
if not object_name:
QMessageBox.critical(self, "Ошибка", "Цель не указана")
return
# Проверка, существует ли объект в списке небесных тел
celestial_bodies = self.config_service.get_celestial_bodies()
if object_name not in celestial_bodies:
reply = QMessageBox.question(self, "Новый объект",
f"Объект '{object_name}' не найден в списке.\nДобавить его в список?",
QMessageBox.Yes | QMessageBox.No)
if reply == QMessageBox.Yes:
self.config_service.add_celestial_body(object_name)
self.object_combo.addItem(object_name)
else:
return
camera = self.camera_combo.currentText()
lens = self.lens_combo.currentText()
if not camera or not lens:
reply = QMessageBox.question(self, "Предупреждение",
"Камера или объектив не выбраны. Продолжить?",
QMessageBox.Yes | QMessageBox.No)
if reply == QMessageBox.No:
return
try:
watch_path = Path(watch_folder)
# Очищаем папку наблюдения от старых файлов
FileService.clear_watch_folder(watch_path)
camera_val = camera if camera else "Unknown"
lens_val = lens if lens else "Unknown"
self.session_service.start_session(watch_path, object_name, camera_val, lens_val)
self.config_service.set_last_camera(camera_val)
self.config_service.set_last_lens(lens_val)
# Запускаем отслеживание
success = self.watch_service.start(watch_path, self._on_file_received)
if not success:
QMessageBox.critical(self, "Ошибка", "Не удалось запустить отслеживание папки")
return
self._set_running_state(True)
print(f"Отслеживание начато! Папка наблюдения: {watch_path}")
print(f"Папка сессии: {self.session_service.get_current_session().session_folder}")
except Exception as e:
QMessageBox.critical(self, "Ошибка", f"Не удалось начать сессию: {e}")
import traceback
traceback.print_exc()
def stop(self):
if not self.running:
return
try:
watch_folder = Path(self.folder_entry.text())
print(f"Остановка сессии. Перемещаем файлы из {watch_folder}")
# Перемещаем все оставшиеся файлы
moved_count = self.watch_service.move_all_existing_files(watch_folder, self._on_file_received)
print(f"Перемещено файлов: {moved_count}")
# Останавливаем отслеживание
self.watch_service.stop()
# Завершаем сессию
session = self.session_service.finish_session()
self._set_running_state(False)
# Показываем диалог завершения
self._show_session_end_dialog(session)
except Exception as e:
QMessageBox.critical(self, "Ошибка", f"Ошибка при завершении сессии: {e}")
import traceback
traceback.print_exc()
def set_new_object(self):
if not self.running:
QMessageBox.critical(self, "Ошибка", "Сессия не активна")
return
# Перемещаем все накопленные файлы в папку текущего объекта
watch_folder = Path(self.folder_entry.text())
moved_count = self.watch_service.move_all_existing_files(watch_folder, self._on_file_received)
if moved_count > 0:
print(f"Перемещено файлов перед сменой объекта: {moved_count}")
new_object, ok = QInputDialog.getText(self, "Новый объект", "Введите название объекта:")
if ok and new_object and new_object.strip():
new_name = new_object.strip()
# Проверка, существует ли объект в списке
celestial_bodies = self.config_service.get_celestial_bodies()
if new_name not in celestial_bodies:
reply = QMessageBox.question(self, "Новый объект",
f"Объект '{new_name}' не найден в списке.\nДобавить его в список?",
QMessageBox.Yes | QMessageBox.No)
if reply == QMessageBox.Yes:
self.config_service.add_celestial_body(new_name)
self.object_combo.addItem(new_name)
else:
return
self.session_service.create_new_object(new_name)
self.object_combo.setCurrentText(new_name)
QMessageBox.information(self, "Успех", f"Объект изменён на: {new_name}")
def open_equipment_dialog(self):
from ui.dialogs.equipment_dialog import EquipmentDialog
dialog = EquipmentDialog(self, self.config_service)
dialog.exec()
self.camera_combo.clear()
self.lens_combo.clear()
self.camera_combo.addItems(self.config_service.get_cameras())
self.lens_combo.addItems(self.config_service.get_lenses())
def open_celestial_dialog(self):
from ui.dialogs.celestial_dialog import CelestialDialog
dialog = CelestialDialog(self, self.config_service)
dialog.exec()
self.object_combo.clear()
self.object_combo.addItems(self.config_service.get_celestial_bodies())
def open_session_folder(self):
if self.running and self.session_service.get_current_session():
folder = self.session_service.get_current_session().session_folder
if folder and folder.exists():
try:
if platform.system() == "Windows":
subprocess.Popen(['explorer', str(folder)])
elif platform.system() == "Darwin":
subprocess.Popen(['open', str(folder)])
else:
subprocess.Popen(['xdg-open', str(folder)])
except Exception as e:
QMessageBox.critical(self, "Ошибка", f"Не удалось открыть папку: {e}")
else:
QMessageBox.critical(self, "Ошибка", "Папка сессии не найдена")
else:
QMessageBox.information(self, "Информация", "Нет активной сессии")
def show_instructions(self):
from ui.dialogs.instructions_dialog import InstructionsDialog
dialog = InstructionsDialog(self)
dialog.exec()
def show_info(self):
QMessageBox.about(self, "О программе",
"Astro Session Watcher\nВерсия: 0.3.0-alpha\n\n"
"Приложение для автоматической сортировки астрофотографий\n\n"
"Особенности:\n"
"• Автоматическое отслеживание новых файлов\n"
"• Сортировка по объектам съёмки\n"
"• Ведение детальных логов\n"
"• Сохранение истории оборудования\n\n"
"Разработчик: Vic Sergeev\n2026")
def _show_session_end_dialog(self, session):
current_object = session.get_current_object()
object_name = current_object.name if current_object else "Unknown"
photo_count = current_object.photo_count if current_object else 0
session_folder = session.session_folder
msg_box = QMessageBox(self)
msg_box.setWindowTitle("Сессия завершена")
msg_box.setIcon(QMessageBox.Information)
msg_box.setText(f"Наблюдение остановлено\n\nСессия для объекта '{object_name}' завершена.\nПолучено файлов: {photo_count}")
msg_box.setInformativeText(f"Папка с данными:\n{session_folder}")
open_folder_btn = msg_box.addButton("📁 Открыть папку", QMessageBox.AcceptRole)
close_btn = msg_box.addButton("Закрыть", QMessageBox.RejectRole)
msg_box.exec()
if msg_box.clickedButton() == open_folder_btn:
if session_folder and session_folder.exists():
try:
if platform.system() == "Windows":
subprocess.Popen(['explorer', str(session_folder)])
elif platform.system() == "Darwin":
subprocess.Popen(['open', str(session_folder)])
else:
subprocess.Popen(['xdg-open', str(session_folder)])
except Exception as e:
QMessageBox.critical(self, "Ошибка", f"Не удалось открыть папку: {e}")
def closeEvent(self, event):
if self.running:
reply = QMessageBox.question(self, "Выход",
"Сессия активна. Остановить сессию и выйти?",
QMessageBox.Yes | QMessageBox.No)
if reply == QMessageBox.Yes:
try:
self.stop()
except:
pass
event.accept()
else:
event.ignore()
else:
event.accept()