Astro-Session-Watcher/ui/main_window.py

579 lines
No EOL
24 KiB
Python

"""
MainWindow - главное окно приложения на tkinter
"""
import tkinter as tk
from tkinter import ttk, messagebox, filedialog, simpledialog
from pathlib import Path
from threading import Thread
import subprocess
import platform
from datetime import datetime
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:
"""Главное окно приложения"""
def __init__(self, root):
self.root = root
self.root.title("Astro Session Watcher v0.4.0")
self.root.geometry("800x550")
self.root.minsize(700, 500)
self.center_window()
# Сервисы
self.config_service = ConfigService()
self.session_service = SessionService()
self.watch_service = WatchService()
# Переменные состояния
self.running = False
self.file_count = 0
self.current_target = ""
self.current_session_folder = ""
self._blink_active = False
# Стили
self._setup_styles()
# Создаём интерфейс
self._create_menu_bar()
self._create_main_content()
self._load_saved_settings()
self._setup_hotkeys()
# Обновление счётчика
self._update_file_count_display()
# Обработчик закрытия
self.root.protocol("WM_DELETE_WINDOW", self._on_closing)
def center_window(self):
self.root.update_idletasks()
x = (self.root.winfo_screenwidth() // 2) - (self.root.winfo_width() // 2)
y = (self.root.winfo_screenheight() // 2) - (self.root.winfo_height() // 2)
self.root.geometry(f'+{x}+{y}')
def _setup_styles(self):
style = ttk.Style()
style.theme_use('clam')
# Тёмная тема
style.configure('.', background='#1e1e1e', foreground='#e0e0e0')
style.configure('TLabel', background='#1e1e1e', foreground='#e0e0e0')
style.configure('TFrame', background='#1e1e1e')
style.configure('TLabelframe', background='#1e1e1e', foreground='#e0e0e0')
style.configure('TLabelframe.Label', background='#1e1e1e', foreground='#e0e0e0')
# Кнопки
style.configure('TButton', background='#3c3c3c', foreground='#e0e0e0', borderwidth=1)
style.map('TButton',
background=[('active', '#4c4c4c')],
foreground=[('active', '#ffffff')])
# Поля ввода
style.configure('TEntry', fieldbackground='#3c3c3c', foreground='#e0e0e0')
style.configure('TCombobox', fieldbackground='#3c3c3c', foreground='#e0e0e0')
# Специальные кнопки
style.configure('Green.TButton', background='#4CAF50', foreground='white')
style.map('Green.TButton', background=[('active', '#45a049')])
style.configure('Red.TButton', background='#f44336', foreground='black')
style.map('Red.TButton', background=[('active', '#d32f2f')])
self.root.configure(bg='#1e1e1e')
def _create_menu_bar(self):
menubar = tk.Menu(self.root)
self.root.config(menu=menubar)
# File menu
file_menu = tk.Menu(menubar, tearoff=0)
menubar.add_cascade(label="File", menu=file_menu)
file_menu.add_command(label="Select Folder...", command=self.select_folder, accelerator="Ctrl+O")
file_menu.add_separator()
file_menu.add_command(label="Equipment...", command=self.open_equipment_dialog, accelerator="Ctrl+E")
file_menu.add_command(label="Celestial Bodies...", command=self.open_celestial_dialog, accelerator="Ctrl+B")
file_menu.add_separator()
file_menu.add_command(label="Exit", command=self._on_closing, accelerator="Ctrl+Q")
# Session menu
session_menu = tk.Menu(menubar, tearoff=0)
menubar.add_cascade(label="Session", menu=session_menu)
session_menu.add_command(label="Start Tracking", command=self.start, accelerator="Ctrl+S")
session_menu.add_command(label="Stop", command=self.stop, accelerator="Ctrl+X")
session_menu.add_separator()
session_menu.add_command(label="Open Session Folder", command=self.open_session_folder, accelerator="Ctrl+F")
session_menu.add_separator()
session_menu.add_command(label="New Target...", command=self.set_new_object, accelerator="Ctrl+Shift+N")
session_menu.add_separator()
session_menu.add_command(label="Calibration Frames...", command=self.open_calibration_dialog, accelerator="Ctrl+K")
# Help menu
help_menu = tk.Menu(menubar, tearoff=0)
menubar.add_cascade(label="Help", menu=help_menu)
help_menu.add_command(label="Instructions", command=self.show_instructions, accelerator="F2")
help_menu.add_separator()
help_menu.add_command(label="About", command=self.show_info, accelerator="F1")
def _create_main_content(self):
# Main frame
main_frame = ttk.Frame(self.root, padding="20")
main_frame.pack(fill="both", expand=True)
# Grid layout
main_frame.grid_columnconfigure(0, weight=0, minsize=100)
main_frame.grid_columnconfigure(1, weight=1)
# Row 0: Folder
ttk.Label(main_frame, text="Folder:", font=('Segoe UI', 10, 'bold')).grid(row=0, column=0, sticky='w', pady=5)
folder_frame = ttk.Frame(main_frame)
folder_frame.grid(row=0, column=1, sticky='ew', pady=5)
folder_frame.grid_columnconfigure(0, weight=1)
self.folder_entry = ttk.Entry(folder_frame)
self.folder_entry.grid(row=0, column=0, sticky='ew', padx=(0, 10))
self.folder_entry.insert(0, "Select watch folder...")
self.folder_entry.bind('<FocusIn>', lambda e: self._clear_placeholder(self.folder_entry, "Select watch folder..."))
self.folder_entry.bind('<FocusOut>', lambda e: self._restore_placeholder(self.folder_entry, "Select watch folder..."))
self.browse_btn = ttk.Button(folder_frame, text="Browse...", width=10, command=self.select_folder)
self.browse_btn.grid(row=0, column=1)
# Row 1: Equipment
ttk.Label(main_frame, text="Equipment:", font=('Segoe UI', 10, 'bold')).grid(row=1, column=0, sticky='w', pady=5)
equipment_frame = ttk.Frame(main_frame)
equipment_frame.grid(row=1, column=1, sticky='ew', pady=5)
equipment_frame.grid_columnconfigure(0, weight=1)
equipment_frame.grid_columnconfigure(1, weight=1)
self.camera_combo = ttk.Combobox(equipment_frame, values=[])
self.camera_combo.grid(row=0, column=0, sticky='ew', padx=(0, 10))
self.camera_combo.set("Select or enter camera...")
self.camera_combo.bind('<FocusIn>', lambda e: self._clear_combo(self.camera_combo, "Select or enter camera..."))
self.camera_combo.bind('<FocusOut>', lambda e: self._restore_combo(self.camera_combo, "Select or enter camera..."))
self.lens_combo = ttk.Combobox(equipment_frame, values=[])
self.lens_combo.grid(row=0, column=1, sticky='ew')
self.lens_combo.set("Select or enter lens/telescope...")
self.lens_combo.bind('<FocusIn>', lambda e: self._clear_combo(self.lens_combo, "Select or enter lens/telescope..."))
self.lens_combo.bind('<FocusOut>', lambda e: self._restore_combo(self.lens_combo, "Select or enter lens/telescope..."))
# Row 2: Target
ttk.Label(main_frame, text="Target:", font=('Segoe UI', 10, 'bold')).grid(row=2, column=0, sticky='w', pady=5)
target_frame = ttk.Frame(main_frame)
target_frame.grid(row=2, column=1, sticky='ew', pady=5)
target_frame.grid_columnconfigure(0, weight=1)
self.target_combo = ttk.Combobox(target_frame, values=[])
self.target_combo.grid(row=0, column=0, sticky='ew', padx=(0, 10))
self.target_combo.set("Enter target name...")
self.target_combo.bind('<FocusIn>', lambda e: self._clear_combo(self.target_combo, "Enter target name..."))
self.target_combo.bind('<FocusOut>', lambda e: self._restore_combo(self.target_combo, "Enter target name..."))
self.new_target_btn = ttk.Button(target_frame, text="New Target", width=12, command=self.set_new_object)
self.new_target_btn.grid(row=0, column=1)
self.new_target_btn.configure(state='disabled')
# Row 3: Statistics
ttk.Label(main_frame, text="Statistics:", font=('Segoe UI', 10, 'bold')).grid(row=3, column=0, sticky='w', pady=5)
self.stats_label = ttk.Label(main_frame, text="Files received: 0", font=('Segoe UI', 11))
self.stats_label.grid(row=3, column=1, sticky='w', pady=5)
# Row 4: Status
ttk.Label(main_frame, text="Status:", font=('Segoe UI', 10, 'bold')).grid(row=4, column=0, sticky='w', pady=5)
self.status_label = ttk.Label(main_frame, text="IDLE", font=('Segoe UI', 12, 'bold'), foreground='#666666')
self.status_label.grid(row=4, column=1, sticky='w', pady=5)
# Separator
separator = ttk.Separator(main_frame, orient='horizontal')
separator.grid(row=5, column=0, columnspan=2, sticky='ew', pady=15)
# Buttons
buttons_frame = ttk.Frame(main_frame)
buttons_frame.grid(row=6, column=0, columnspan=2, pady=10)
self.start_btn = ttk.Button(buttons_frame, text="▶ Start Tracking", width=18, command=self.start, style='Green.TButton')
self.start_btn.pack(side='left', padx=10)
self.stop_btn = ttk.Button(buttons_frame, text="■ Stop", width=12, command=self.stop, style='Red.TButton')
self.stop_btn.pack(side='left', padx=10)
self.stop_btn.configure(state='disabled')
# Footer
footer_frame = ttk.Frame(self.root)
footer_frame.pack(side='bottom', fill='x', padx=20, pady=(0, 10))
ttk.Label(footer_frame, text="v0.4.0-alpha", foreground='#666666').pack(side='left')
ttk.Label(footer_frame, text="Made by Vic Sergeev 2026", foreground='#666666').pack(side='right')
def _clear_placeholder(self, entry, placeholder):
if entry.get() == placeholder:
entry.delete(0, 'end')
def _restore_placeholder(self, entry, placeholder):
if entry.get() == '':
entry.insert(0, placeholder)
def _clear_combo(self, combo, placeholder):
if combo.get() == placeholder:
combo.set('')
def _restore_combo(self, combo, placeholder):
if combo.get() == '':
combo.set(placeholder)
def _load_saved_settings(self):
cameras = self.config_service.get_cameras()
lenses = self.config_service.get_lenses()
telescopes = self.config_service.get_telescopes()
celestial_bodies = self.config_service.get_celestial_bodies()
all_optics = []
for lens in lenses:
all_optics.append(lens)
for telescope in telescopes:
all_optics.append(telescope)
if cameras:
self.camera_combo['values'] = cameras
last_camera = self.config_service.get_last_camera()
if last_camera and last_camera in cameras:
self.camera_combo.set(last_camera)
if all_optics:
self.lens_combo['values'] = all_optics
last_lens = self.config_service.get_last_lens()
if last_lens and last_lens in all_optics:
self.lens_combo.set(last_lens)
if celestial_bodies:
self.target_combo['values'] = celestial_bodies
last_folder = self.config_service.get_last_watch_folder()
if last_folder:
self.folder_entry.delete(0, 'end')
self.folder_entry.insert(0, last_folder)
def _setup_hotkeys(self):
def on_key(event):
if event.state & 0x4: # Ctrl
if event.keysym == 'o':
self.select_folder()
elif event.keysym == 'e':
self.open_equipment_dialog()
elif event.keysym == 'b':
self.open_celestial_dialog()
elif event.keysym == 's':
self.start()
elif event.keysym == 'x':
self.stop()
elif event.keysym == 'f':
self.open_session_folder()
elif event.keysym == 'k':
self.open_calibration_dialog()
elif event.state & 0x6: # Ctrl+Shift
if event.keysym == 'N':
self.set_new_object()
elif event.keysym == 'F1':
self.show_info()
elif event.keysym == 'F2':
self.show_instructions()
self.root.bind_all('<Key>', on_key)
def _set_running_state(self, state):
self.running = state
if state:
self.start_btn.configure(state='disabled')
self.stop_btn.configure(state='normal')
self.new_target_btn.configure(state='normal')
self.status_label.configure(text="● ON AIR", foreground='#ff0000')
self._start_blinking()
else:
self.start_btn.configure(state='normal')
self.stop_btn.configure(state='disabled')
self.new_target_btn.configure(state='disabled')
self.status_label.configure(text="IDLE", foreground='#666666')
self._stop_blinking()
def _start_blinking(self):
self._blink_active = True
self._do_blink()
def _do_blink(self):
if not self._blink_active or not self.running:
return
current = self.status_label.cget('foreground')
new_color = '#ffffff' if current == '#ff0000' else '#ff0000'
self.status_label.configure(foreground=new_color)
self.root.after(500, self._do_blink)
def _stop_blinking(self):
self._blink_active = False
self.status_label.configure(foreground='#666666')
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.stats_label.configure(text=f"Files received: {self.file_count}")
self.root.after(1000, self._update_file_count_display)
def _on_file_received(self, file_path: Path):
if self.session_service.handle_file(file_path):
self.file_count += 1
self.stats_label.configure(text=f"Files received: {self.file_count}")
print(f"File processed: {file_path.name}")
def select_folder(self):
folder = filedialog.askdirectory(title="Select watch folder")
if folder:
self.folder_entry.delete(0, 'end')
self.folder_entry.insert(0, folder)
self.config_service.set_last_watch_folder(folder)
def start(self):
watch_folder = self.folder_entry.get()
target_name = self.target_combo.get()
camera = self.camera_combo.get()
lens = self.lens_combo.get()
# Skip placeholders
if watch_folder == "Select watch folder...":
watch_folder = ""
if target_name == "Enter target name...":
target_name = ""
if camera == "Select or enter camera...":
camera = ""
if lens == "Select or enter lens/telescope...":
lens = ""
if not watch_folder:
messagebox.showerror("Error", "Please select a folder to watch!", parent=self.root)
return
if not target_name:
messagebox.showerror("Error", "Please enter a target name!", parent=self.root)
return
celestial_bodies = self.config_service.get_celestial_bodies()
if target_name not in celestial_bodies:
reply = messagebox.askyesno("New Target",
f"Target '{target_name}' not found in list.\nAdd it to the list?",
parent=self.root)
if reply:
self.config_service.add_celestial_body(target_name)
self.target_combo['values'] = self.config_service.get_celestial_bodies()
self.target_combo.set(target_name)
else:
return
if not camera or not lens:
reply = messagebox.askyesno("Warning", "Camera or lens not selected. Continue?",
parent=self.root)
if not reply:
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, target_name, camera_val, lens_val)
self.current_target = target_name
self.current_session_folder = str(self.session_service.get_current_session().session_folder)
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:
messagebox.showerror("Error", "Failed to start watching folder!", parent=self.root)
return
self._set_running_state(True)
except Exception as e:
messagebox.showerror("Error", f"Failed to start session: {e}", parent=self.root)
import traceback
traceback.print_exc()
def stop(self):
if not self.running:
return
try:
watch_folder = Path(self.folder_entry.get())
self.watch_service.move_all_existing_files(watch_folder, self._on_file_received)
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:
messagebox.showerror("Error", f"Error stopping session: {e}", parent=self.root)
def set_new_object(self):
if not self.running:
messagebox.showerror("Error", "Session is not active!", parent=self.root)
return
watch_folder = Path(self.folder_entry.get())
self.watch_service.move_all_existing_files(watch_folder, self._on_file_received)
dialog = tk.Toplevel(self.root)
dialog.title("New Target")
dialog.geometry("400x150")
dialog.transient(self.root)
dialog.grab_set()
ttk.Label(dialog, text="Enter new target name:", font=('Segoe UI', 11)).pack(pady=20)
entry = ttk.Entry(dialog, width=40)
entry.pack(pady=10)
entry.focus()
def confirm():
new_target = entry.get().strip()
if new_target:
dialog.destroy()
self._create_new_target(new_target)
else:
messagebox.showwarning("Warning", "Please enter a target name!", parent=dialog)
ttk.Button(dialog, text="OK", command=confirm).pack(pady=10)
dialog.bind('<Return>', lambda e: confirm())
self.root.wait_window(dialog)
def _create_new_target(self, new_name):
celestial_bodies = self.config_service.get_celestial_bodies()
if new_name not in celestial_bodies:
reply = messagebox.askyesno("New Target",
f"Target '{new_name}' not found in list.\nAdd it to the list?",
parent=self.root)
if reply:
self.config_service.add_celestial_body(new_name)
self.target_combo['values'] = self.config_service.get_celestial_bodies()
else:
return
self.session_service.create_new_object(new_name)
self.target_combo.set(new_name)
self.file_count = 0
self.stats_label.configure(text="Files received: 0")
def open_equipment_dialog(self):
from ui.dialogs.equipment_dialog import EquipmentDialog
dialog = EquipmentDialog(self.root, self.config_service)
self.root.wait_window(dialog)
# Refresh comboboxes
cameras = self.config_service.get_cameras()
lenses = self.config_service.get_lenses()
telescopes = self.config_service.get_telescopes()
all_optics = lenses + telescopes
self.camera_combo['values'] = cameras
self.lens_combo['values'] = all_optics
def open_celestial_dialog(self):
from ui.dialogs.celestial_dialog import CelestialDialog
dialog = CelestialDialog(self.root, self.config_service)
self.root.wait_window(dialog)
celestial_bodies = self.config_service.get_celestial_bodies()
self.target_combo['values'] = celestial_bodies
def open_calibration_dialog(self):
from ui.dialogs.calibration_dialog import CalibrationDialog
dialog = CalibrationDialog(self.root, self.config_service)
self.root.wait_window(dialog)
def open_session_folder(self):
if self.running and self.current_session_folder:
try:
if platform.system() == "Windows":
subprocess.Popen(['explorer', self.current_session_folder])
elif platform.system() == "Darwin":
subprocess.Popen(['open', self.current_session_folder])
else:
subprocess.Popen(['xdg-open', self.current_session_folder])
except Exception as e:
messagebox.showerror("Error", f"Failed to open folder: {e}", parent=self.root)
else:
messagebox.showinfo("Info", "No active session", parent=self.root)
def show_instructions(self):
from ui.dialogs.instructions_dialog import InstructionsDialog
InstructionsDialog(self.root)
def show_info(self):
messagebox.showinfo("About",
"Astro Session Watcher v0.4.0\n\n"
"Application for astrophotographers\n\n"
"Features:\n"
"• Automatic file tracking\n"
"• Sorting by targets\n"
"• Session logging\n"
"• Equipment management\n\n"
"Made by Vic Sergeev\n2026",
parent=self.root)
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
dialog = tk.Toplevel(self.root)
dialog.title("Session Completed")
dialog.geometry("500x250")
dialog.transient(self.root)
dialog.grab_set()
ttk.Label(dialog, text="✅ Session finished!", font=('Segoe UI', 14, 'bold')).pack(pady=15)
ttk.Label(dialog, text=f"Target: {object_name}").pack()
ttk.Label(dialog, text=f"Files received: {photo_count}").pack()
ttk.Label(dialog, text=f"Saved to: {session_folder}", wraplength=450).pack(pady=10)
def open_folder():
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:
messagebox.showerror("Error", f"Failed to open folder: {e}", parent=dialog)
dialog.destroy()
def close():
dialog.destroy()
btn_frame = ttk.Frame(dialog)
btn_frame.pack(pady=20)
ttk.Button(btn_frame, text="Open Folder", command=open_folder).pack(side='left', padx=10)
ttk.Button(btn_frame, text="Close", command=close).pack(side='left', padx=10)
def _on_closing(self):
if self.running:
reply = messagebox.askyesno("Exit", "Session is active. Stop session and exit?",
parent=self.root)
if reply:
self.stop()
self.root.destroy()
else:
self.root.destroy()