""" 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('', lambda e: self._clear_placeholder(self.folder_entry, "Select watch folder...")) self.folder_entry.bind('', 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('', lambda e: self._clear_combo(self.camera_combo, "Select or enter camera...")) self.camera_combo.bind('', 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('', lambda e: self._clear_combo(self.lens_combo, "Select or enter lens/telescope...")) self.lens_combo.bind('', 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('', lambda e: self._clear_combo(self.target_combo, "Enter target name...")) self.target_combo.bind('', 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('', 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('', 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()