Astro-Session-Watcher/ui/main_window.py

579 lines
24 KiB
Python
Raw Permalink Normal View History

2026-05-07 17:15:56 +03:00
"""
MainWindow - главное окно приложения на tkinter
2026-05-07 17:15:56 +03:00
"""
import tkinter as tk
from tkinter import ttk, messagebox, filedialog, simpledialog
from pathlib import Path
from threading import Thread
2026-05-07 17:15:56 +03:00
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:
2026-05-07 17:15:56 +03:00
"""Главное окно приложения"""
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()
2026-05-07 17:15:56 +03:00
# Сервисы
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
2026-05-07 17:15:56 +03:00
# Стили
self._setup_styles()
2026-05-07 17:15:56 +03:00
# Создаём интерфейс
2026-05-07 17:15:56 +03:00
self._create_menu_bar()
self._create_main_content()
self._load_saved_settings()
self._setup_hotkeys()
# Обновление счётчика
2026-05-07 17:15:56 +03:00
self._update_file_count_display()
# Обработчик закрытия
self.root.protocol("WM_DELETE_WINDOW", self._on_closing)
2026-05-07 17:15:56 +03:00
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}')
2026-05-07 17:15:56 +03:00
def _setup_styles(self):
style = ttk.Style()
style.theme_use('clam')
2026-05-07 17:15:56 +03:00
# Тёмная тема
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')
2026-05-07 17:15:56 +03:00
# Кнопки
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')])
2026-05-07 17:15:56 +03:00
style.configure('Red.TButton', background='#f44336', foreground='black')
style.map('Red.TButton', background=[('active', '#d32f2f')])
2026-05-07 17:15:56 +03:00
self.root.configure(bg='#1e1e1e')
2026-05-07 17:15:56 +03:00
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")
2026-05-07 17:15:56 +03:00
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)
2026-05-07 17:15:56 +03:00
def _load_saved_settings(self):
cameras = self.config_service.get_cameras()
lenses = self.config_service.get_lenses()
telescopes = self.config_service.get_telescopes()
2026-05-07 17:15:56 +03:00
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)
2026-05-07 17:15:56 +03:00
if cameras:
self.camera_combo['values'] = cameras
2026-05-07 17:15:56 +03:00
last_camera = self.config_service.get_last_camera()
if last_camera and last_camera in cameras:
self.camera_combo.set(last_camera)
2026-05-07 17:15:56 +03:00
if all_optics:
self.lens_combo['values'] = all_optics
2026-05-07 17:15:56 +03:00
last_lens = self.config_service.get_last_lens()
if last_lens and last_lens in all_optics:
self.lens_combo.set(last_lens)
2026-05-07 17:15:56 +03:00
if celestial_bodies:
self.target_combo['values'] = celestial_bodies
2026-05-07 17:15:56 +03:00
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)
2026-05-07 17:15:56 +03:00
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):
2026-05-07 17:15:56 +03:00
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')
2026-05-07 17:15:56 +03:00
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')
2026-05-07 17:15:56 +03:00
self._stop_blinking()
def _start_blinking(self):
self._blink_active = True
self._do_blink()
2026-05-07 17:15:56 +03:00
def _do_blink(self):
if not self._blink_active or not self.running:
2026-05-07 17:15:56 +03:00
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)
2026-05-07 17:15:56 +03:00
def _stop_blinking(self):
self._blink_active = False
self.status_label.configure(foreground='#666666')
2026-05-07 17:15:56 +03:00
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)
2026-05-07 17:15:56 +03:00
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}")
2026-05-07 17:15:56 +03:00
def select_folder(self):
folder = filedialog.askdirectory(title="Select watch folder")
2026-05-07 17:15:56 +03:00
if folder:
self.folder_entry.delete(0, 'end')
self.folder_entry.insert(0, folder)
2026-05-07 17:15:56 +03:00
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 = ""
2026-05-07 17:15:56 +03:00
if not watch_folder:
messagebox.showerror("Error", "Please select a folder to watch!", parent=self.root)
2026-05-07 17:15:56 +03:00
return
if not target_name:
messagebox.showerror("Error", "Please enter a target name!", parent=self.root)
2026-05-07 17:15:56 +03:00
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)
2026-05-07 17:15:56 +03:00
else:
return
if not camera or not lens:
reply = messagebox.askyesno("Warning", "Camera or lens not selected. Continue?",
parent=self.root)
if not reply:
2026-05-07 17:15:56 +03:00
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)
2026-05-07 17:15:56 +03:00
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)
2026-05-07 17:15:56 +03:00
return
self._set_running_state(True)
except Exception as e:
messagebox.showerror("Error", f"Failed to start session: {e}", parent=self.root)
2026-05-07 17:15:56 +03:00
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)
2026-05-07 17:15:56 +03:00
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)
2026-05-07 17:15:56 +03:00
def set_new_object(self):
if not self.running:
messagebox.showerror("Error", "Session is not active!", parent=self.root)
2026-05-07 17:15:56 +03:00
return
watch_folder = Path(self.folder_entry.get())
self.watch_service.move_all_existing_files(watch_folder, self._on_file_received)
2026-05-07 17:15:56 +03:00
dialog = tk.Toplevel(self.root)
dialog.title("New Target")
dialog.geometry("400x150")
dialog.transient(self.root)
dialog.grab_set()
2026-05-07 17:15:56 +03:00
ttk.Label(dialog, text="Enter new target name:", font=('Segoe UI', 11)).pack(pady=20)
2026-05-07 17:15:56 +03:00
entry = ttk.Entry(dialog, width=40)
entry.pack(pady=10)
entry.focus()
2026-05-07 17:15:56 +03:00
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)
2026-05-07 17:15:56 +03:00
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")
2026-05-07 17:15:56 +03:00
def open_equipment_dialog(self):
from ui.dialogs.equipment_dialog import EquipmentDialog
dialog = EquipmentDialog(self.root, self.config_service)
self.root.wait_window(dialog)
2026-05-07 17:15:56 +03:00
# 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
2026-05-07 17:15:56 +03:00
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
2026-05-07 17:15:56 +03:00
def open_calibration_dialog(self):
from ui.dialogs.calibration_dialog import CalibrationDialog
dialog = CalibrationDialog(self.root, self.config_service)
self.root.wait_window(dialog)
2026-05-07 17:15:56 +03:00
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)
2026-05-07 17:15:56 +03:00
else:
messagebox.showinfo("Info", "No active session", parent=self.root)
2026-05-07 17:15:56 +03:00
def show_instructions(self):
from ui.dialogs.instructions_dialog import InstructionsDialog
InstructionsDialog(self.root)
2026-05-07 17:15:56 +03:00
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)
2026-05-07 17:15:56 +03:00
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()
2026-05-07 17:15:56 +03:00
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)
2026-05-07 17:15:56 +03:00
def open_folder():
2026-05-07 17:15:56 +03:00
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()
2026-05-07 17:15:56 +03:00
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):
2026-05-07 17:15:56 +03:00
if self.running:
reply = messagebox.askyesno("Exit", "Session is active. Stop session and exit?",
parent=self.root)
if reply:
self.stop()
self.root.destroy()
2026-05-07 17:15:56 +03:00
else:
self.root.destroy()