import tkinter as tk from tkinter import colorchooser import time import threading from datetime import datetime, timedelta import re import requests import csv import io import os import json try: from zoneinfo import ZoneInfo except Exception: ZoneInfo = None # Get the user's Documents folder (cross-platform) documents_folder = os.path.join(os.path.expanduser("~"), "Documents") # Create your app folder inside Documents app_folder = os.path.join(documents_folder, "RocketLaunchCountdown") os.makedirs(app_folder, exist_ok=True) # Define file paths COUNTDOWN_HTML = os.path.join(app_folder, "countdown.html") GONOGO_HTML = os.path.join(app_folder, "gonogo.html") SHEET_LINK = "" session = requests.Session() appVersion = "0.6.0" SETTINGS_FILE = os.path.join(app_folder, "settings.json") # Default settings DEFAULT_SETTINGS = { "mode": "spreadsheet", "sheet_link": SHEET_LINK, "range_row": 2, "weather_row": 3, "vehicle_row": 4, "column": 12, "hide_mission_name": False, } # default timezone: 'local' uses system local tz, otherwise an IANA name or 'UTC' DEFAULT_SETTINGS.setdefault('timezone', 'local') # Appearance defaults DEFAULT_SETTINGS.setdefault('bg_color', '#000000') DEFAULT_SETTINGS.setdefault('text_color', '#FFFFFF') DEFAULT_SETTINGS.setdefault('font_family', 'Consolas') DEFAULT_SETTINGS.setdefault('mission_font_px', 24) DEFAULT_SETTINGS.setdefault('timer_font_px', 80) DEFAULT_SETTINGS.setdefault('gn_bg_color', '#111111') DEFAULT_SETTINGS.setdefault('gn_border_color', '#FFFFFF') DEFAULT_SETTINGS.setdefault('gn_go_color', '#00FF00') DEFAULT_SETTINGS.setdefault('gn_nogo_color', '#FF0000') DEFAULT_SETTINGS.setdefault('gn_font_px', 20) DEFAULT_SETTINGS.setdefault('appearance_mode', 'dark') # HTML-only appearance defaults (these should not affect the Python GUI) DEFAULT_SETTINGS.setdefault('html_bg_color', DEFAULT_SETTINGS.get('bg_color', '#000000')) DEFAULT_SETTINGS.setdefault('html_text_color', DEFAULT_SETTINGS.get('text_color', '#FFFFFF')) DEFAULT_SETTINGS.setdefault('html_font_family', DEFAULT_SETTINGS.get('font_family', 'Consolas')) DEFAULT_SETTINGS.setdefault('html_mission_font_px', DEFAULT_SETTINGS.get('mission_font_px', 24)) DEFAULT_SETTINGS.setdefault('html_timer_font_px', DEFAULT_SETTINGS.get('timer_font_px', 80)) DEFAULT_SETTINGS.setdefault('html_gn_bg_color', DEFAULT_SETTINGS.get('gn_bg_color', '#111111')) DEFAULT_SETTINGS.setdefault('html_gn_border_color', DEFAULT_SETTINGS.get('gn_border_color', '#FFFFFF')) DEFAULT_SETTINGS.setdefault('html_gn_go_color', DEFAULT_SETTINGS.get('gn_go_color', '#00FF00')) DEFAULT_SETTINGS.setdefault('html_gn_nogo_color', DEFAULT_SETTINGS.get('gn_nogo_color', '#FF0000')) DEFAULT_SETTINGS.setdefault('html_gn_font_px', DEFAULT_SETTINGS.get('gn_font_px', 20)) # Auto-hold times: list of seconds before T at which timer should automatically enter hold DEFAULT_SETTINGS.setdefault('auto_hold_times', []) # A small list of common timezone choices. TIMEZONE_CHOICES = [ 'local', 'UTC', 'US/Eastern', 'US/Central', 'US/Mountain', 'US/Pacific', 'Europe/London', 'Europe/Paris', 'Asia/Tokyo', 'Australia/Sydney' ] def load_settings(): try: if os.path.exists(SETTINGS_FILE): with open(SETTINGS_FILE, 'r', encoding='utf-8') as fh: return json.load(fh) except Exception: pass # ensure default saved save_settings(DEFAULT_SETTINGS) return DEFAULT_SETTINGS.copy() def save_settings(s): try: with open(SETTINGS_FILE, 'w', encoding='utf-8') as fh: json.dump(s, fh, indent=2) except Exception: pass # ------------------------- # Fetch Go/No-Go Data # ------------------------- def fetch_gonogo(): """Fetch Go/No-Go parameters either from configured spreadsheet or return manual button values.""" settings = load_settings() mode = settings.get('mode', 'spreadsheet') # If manual mode, read values from a runtime stash (set by the GUI buttons) if mode == 'buttons': # stored values will be on the app class; fallback to N/A try: return [getattr(fetch_gonogo, 'manual_range', 'N/A'), getattr(fetch_gonogo, 'manual_weather', 'N/A'), getattr(fetch_gonogo, 'manual_vehicle', 'N/A')] except Exception: return ['N/A', 'N/A', 'N/A'] # spreadsheet mode link = settings.get('sheet_link', SHEET_LINK) col = max(1, int(settings.get('column', 12))) - 1 rows = [int(settings.get('range_row', 2)) - 1, int(settings.get('weather_row', 3)) - 1, int(settings.get('vehicle_row', 4)) - 1] try: resp = session.get(link, timeout=3) resp.raise_for_status() reader = csv.reader(io.StringIO(resp.text)) data = list(reader) gonogo = [] for r in rows: val = 'N/A' if 0 <= r < len(data) and len(data[r]) > col: val = data[r][col] gonogo.append(val.strip().upper()) return gonogo except Exception as e: print(f"[ERROR] Failed to fetch Go/No-Go from sheet: {e}") return ["ERROR", "ERROR", "ERROR"] # ------------------------- # Helper for color # ------------------------- def get_status_color(status): """Return color name for a Go/No-Go status string.""" try: s = str(status or '').strip().upper() # normalize to letters only so variants like 'NO GO', 'NO-GO', 'NOGO' match norm = re.sub(r'[^A-Z]', '', s) if norm == 'GO': return 'green' if norm == 'NOGO': return 'red' # fallback: treat unknown/empty as white return 'white' except Exception: return "white" def format_status_display(status): try: s = str(status or '').strip().upper() norm = re.sub(r'[^A-Z]', '', s) if norm == 'GO': return 'GO' if norm == 'NOGO': return 'NO-GO' return s except Exception: return str(status or '') # ------------------------- # Write Countdown HTML # ------------------------- def write_countdown_html(mission_name, timer_text): s = load_settings() # Prefer HTML-specific settings; fall back to GUI appearance settings for backwards compatibility bg = s.get('html_bg_color', s.get('bg_color', '#000000')) text = s.get('html_text_color', s.get('text_color', '#FFFFFF')) font = s.get('html_font_family', s.get('font_family', 'Consolas, monospace')) mission_px = int(s.get('html_mission_font_px', s.get('mission_font_px', 48))) timer_px = int(s.get('html_timer_font_px', s.get('timer_font_px', 120))) # Mission name hidden setting hide_mission_name = s.get("hide_mission_name", False) mission_div_hidden = f'
{mission_name}
' if not hide_mission_name else '' html = f""" {mission_div_hidden}
{timer_text}
""" with open(COUNTDOWN_HTML, "w", encoding="utf-8") as f: f.write(html) # ------------------------- # Write Go/No-Go HTML # ------------------------- def write_gonogo_html(gonogo_values=None): if gonogo_values is None: gonogo_values = ["N/A", "N/A", "N/A"] s = load_settings() # Prefer HTML-specific settings; fall back to GUI appearance settings for backwards compatibility bg = s.get('html_bg_color', s.get('bg_color', '#000000')) text = s.get('html_text_color', s.get('text_color', '#FFFFFF')) font = s.get('html_font_family', s.get('font_family', 'Consolas, monospace')) gn_bg = s.get('html_gn_bg_color', s.get('gn_bg_color', '#111111')) gn_border = s.get('html_gn_border_color', s.get('gn_border_color', '#FFFFFF')) gn_go = s.get('html_gn_go_color', s.get('gn_go_color', '#00FF00')) gn_nogo = s.get('html_gn_nogo_color', s.get('gn_nogo_color', '#FF0000')) gn_px = int(s.get('html_gn_font_px', s.get('gn_font_px', 28))) # normalize and format display values so variants like 'NO GO' become 'NO-GO' disp0 = format_status_display(gonogo_values[0]) disp1 = format_status_display(gonogo_values[1]) disp2 = format_status_display(gonogo_values[2]) n0 = re.sub(r'[^A-Z]', '', (str(gonogo_values[0] or '')).strip().upper()) n1 = re.sub(r'[^A-Z]', '', (str(gonogo_values[1] or '')).strip().upper()) n2 = re.sub(r'[^A-Z]', '', (str(gonogo_values[2] or '')).strip().upper()) html = f"""
Range: {disp0}
Vehicle: {disp2}
Weather: {disp1}
""" with open(GONOGO_HTML, "w", encoding="utf-8") as f: f.write(html) # ------------------------- # Countdown App # ------------------------- class CountdownApp: def __init__(self, root): self.root = root self.root.title(f"RocketLaunchCountdown {appVersion}") self.root.config(bg="black") self.root.attributes("-topmost", True) self.root.geometry("800x615") # State self.running = False self.on_hold = False self.scrubbed = False self.counting_up = False self.target_time = None self.hold_start_time = None self.remaining_time = 0 self.mission_name = "Placeholder Mission" # fetch_gonogo() returns [Range, Weather, Vehicle] to match gonogo.html writer self.gonogo_values = fetch_gonogo() self.last_gonogo_update = time.time() # track which auto-holds we've already triggered for the current run self._auto_hold_triggered = set() # Title self.titletext = tk.Label(root, text=f"RocketLaunchCountdown {appVersion}", font=("Consolas", 24), fg="white", bg="black") self.titletext.pack(pady=(10, 0)) # Display self.text = tk.Label(root, text="T-00:00:00", font=("Consolas", 80, "bold"), fg="white", bg="black") self.text.pack(pady=(0, 5)) # Mission name input frame_top = tk.Frame(root, bg="black") frame_top.pack(pady=5) tk.Label(frame_top, text="Mission Name:", fg="white", bg="black").pack(side="left") self.mission_entry = tk.Entry(frame_top, width=20, font=("Arial", 18)) self.mission_entry.insert(0, self.mission_name) self.mission_entry.pack(side="left") # Mode toggle frame_mode = tk.Frame(root, bg="black") frame_mode.pack(pady=5) self.mode_var = tk.StringVar(value="duration") self.radio_duration = tk.Radiobutton( frame_mode, text="Duration", variable=self.mode_var, value="duration", fg="white", bg="black", selectcolor="black", # makes the dot visible command=self.update_inputs ) self.radio_duration.pack(side="left", padx=5) self.radio_clock = tk.Radiobutton( frame_mode, text="Clock Time", variable=self.mode_var, value="clock", fg="white", bg="black", selectcolor="black", # makes the dot visible command=self.update_inputs ) self.radio_clock.pack(side="left", padx=5) # Duration inputs frame_duration = tk.Frame(root, bg="black") frame_duration.pack(pady=5) tk.Label(frame_duration, text="H", fg="white", bg="black").pack(side="left") self.hours_entry = tk.Entry(frame_duration, width=3, font=("Arial", 18)) self.hours_entry.insert(0, "0") self.hours_entry.pack(side="left", padx=2) tk.Label(frame_duration, text="M", fg="white", bg="black").pack(side="left") self.minutes_entry = tk.Entry(frame_duration, width=3, font=("Arial", 18)) self.minutes_entry.insert(0, "5") self.minutes_entry.pack(side="left", padx=2) tk.Label(frame_duration, text="S", fg="white", bg="black").pack(side="left") self.seconds_entry = tk.Entry(frame_duration, width=3, font=("Arial", 18)) self.seconds_entry.insert(0, "0") self.seconds_entry.pack(side="left", padx=2) # Auto-hold quick button (opens H/M/S dialog) def open_autohold_dialog(): dlg = tk.Toplevel(self.root) dlg.transient(self.root) dlg.title('Set Auto-hold') dlg.geometry('320x110') # theme according to appearance mode ssettings = load_settings() mode_local = ssettings.get('appearance_mode', DEFAULT_SETTINGS.get('appearance_mode', 'dark')) if mode_local == 'dark': dlg_bg = '#000000'; dlg_fg = '#FFFFFF'; entry_bg = '#222222'; btn_bg = '#FFFFFF'; btn_fg = '#000000' else: dlg_bg = '#FFFFFF'; dlg_fg = '#000000'; entry_bg = '#b4b4b4'; btn_bg = '#000000'; btn_fg = '#FFFFFF' dlg.config(bg=dlg_bg) tk.Label(dlg, text='Auto-hold time (H M S):', fg=dlg_fg, bg=dlg_bg).pack(pady=(6,0)) box = tk.Frame(dlg, bg=dlg_bg) box.pack(pady=6) h_entry = tk.Entry(box, width=3, font=('Arial', 12), bg=entry_bg, fg=dlg_fg, insertbackground=dlg_fg) m_entry = tk.Entry(box, width=3, font=('Arial', 12), bg=entry_bg, fg=dlg_fg, insertbackground=dlg_fg) s_entry = tk.Entry(box, width=3, font=('Arial', 12), bg=entry_bg, fg=dlg_fg, insertbackground=dlg_fg) h_entry.pack(side='left', padx=4) tk.Label(box, text='H', fg=dlg_fg, bg=dlg_bg).pack(side='left') m_entry.pack(side='left', padx=4) tk.Label(box, text='M', fg=dlg_fg, bg=dlg_bg).pack(side='left') s_entry.pack(side='left', padx=4) tk.Label(box, text='S', fg=dlg_fg, bg=dlg_bg).pack(side='left') # populate with first configured value if present try: ssettings = load_settings() a = (ssettings.get('auto_hold_times') or []) if a: secs = int(a[0]) hh = secs // 3600; mm = (secs % 3600) // 60; ss = secs % 60 h_entry.insert(0, str(hh)); m_entry.insert(0, str(mm)); s_entry.insert(0, str(ss)) except Exception: pass def do_save(): try: hh = int(h_entry.get() or 0) mm = int(m_entry.get() or 0) ss = int(s_entry.get() or 0) total = max(0, hh*3600 + mm*60 + ss) except Exception: total = 0 try: ssettings = load_settings() # replace with single auto-hold time (list with one element) ssettings['auto_hold_times'] = [int(total)] if total > 0 else [] save_settings(ssettings) # update runtime set so this run will consider the new value self._auto_hold_triggered = set() except Exception: pass dlg.destroy() btnf = tk.Frame(dlg, bg=dlg_bg) btnf.pack(fill='x', pady=6) tk.Button(btnf, text='Save', command=do_save, width=10, bg=btn_bg, fg=btn_fg, activebackground='#444').pack(side='right', padx=6) tk.Button(btnf, text='Cancel', command=dlg.destroy, width=10, bg=btn_bg, fg=btn_fg, activebackground='#444').pack(side='right') # recursively theme dialog to ensure consistency try: self._theme_recursive(dlg, dlg_bg, dlg_fg, btn_bg, btn_fg) except Exception: pass tk.Button(frame_duration, text='Auto-hold...', command=open_autohold_dialog, fg='white', bg='#333', width=12).pack(side='left', padx=8) # Clock time input (separate HH, MM, SS boxes) frame_clock = tk.Frame(root, bg="black") frame_clock.pack(pady=5) tk.Label(frame_clock, text="Clock (HH:MM:SS)", fg="white", bg="black").pack(side="left") self.clock_hours_entry = tk.Entry(frame_clock, width=3, font=("Arial", 18), fg='white', bg='#111', insertbackground='white') self.clock_hours_entry.insert(0, "14") self.clock_hours_entry.pack(side="left", padx=2) tk.Label(frame_clock, text=":", fg="white", bg="black").pack(side="left") self.clock_minutes_entry = tk.Entry(frame_clock, width=3, font=("Arial", 18), fg='white', bg='#111', insertbackground='white') self.clock_minutes_entry.insert(0, "00") self.clock_minutes_entry.pack(side="left", padx=2) tk.Label(frame_clock, text=":", fg="white", bg="black").pack(side="left") self.clock_seconds_entry = tk.Entry(frame_clock, width=3, font=("Arial", 18), fg='white', bg='#111', insertbackground='white') self.clock_seconds_entry.insert(0, "00") self.clock_seconds_entry.pack(side="left", padx=2) # Control buttons frame_buttons = tk.Frame(root, bg="black") frame_buttons.pack(pady=10) self.start_btn = tk.Button(frame_buttons, text="▶ Start", command=self.start, font=("Arial", 14)) self.start_btn.grid(row=0, column=0, padx=5) # Hold and resume share the same position self.hold_btn = tk.Button(frame_buttons, text="⏸ Hold", command=self.hold, font=("Arial", 14)) self.hold_btn.grid(row=0, column=1, padx=5) self.resume_btn = tk.Button(frame_buttons, text="⏵ Resume", command=self.resume, font=("Arial", 14)) self.resume_btn.grid(row=0, column=1, padx=5) self.resume_btn.grid_remove() # hidden at start self.scrub_btn = tk.Button(frame_buttons, text="🚫 Scrub", command=self.scrub, font=("Arial", 14), fg="red") self.scrub_btn.grid(row=0, column=2, padx=5) self.reset_btn = tk.Button(frame_buttons, text="⟳ Reset", command=self.reset, font=("Arial", 14)) self.reset_btn.grid(row=0, column=3, padx=5) # Settings button moved next to control buttons (match size/style) self.settings_btn = tk.Button(frame_buttons, text="Settings", command=self.show_settings_window, font=("Arial", 14), width=10) self.settings_btn.grid(row=0, column=4, padx=6) # Note: gonogo mode switching remains in Settings; manual buttons appear when mode == 'buttons' # Manual Go/No-Go buttons will go next to control buttons self.manual_frame = tk.Frame(root, bg="black") self.manual_frame.pack(pady=6) # Buttons now toggle current state between GO and NOGO self.range_toggle_btn = tk.Button(self.manual_frame, text="Range: Toggle", width=12, command=lambda: self._toggle_manual('range')) self.weather_toggle_btn = tk.Button(self.manual_frame, text="Weather: Toggle", width=12, command=lambda: self._toggle_manual('weather')) self.vehicle_toggle_btn = tk.Button(self.manual_frame, text="Vehicle: Toggle", width=12, command=lambda: self._toggle_manual('vehicle')) # Placeholders; visibility will be controlled by settings self.range_toggle_btn.grid(row=0, column=0, padx=4, pady=2) self.weather_toggle_btn.grid(row=0, column=1, padx=4, pady=2) self.vehicle_toggle_btn.grid(row=0, column=2, padx=4, pady=2) frame_gn = tk.Frame(root, bg="black") frame_gn.pack(pady=10) # Labels displayed: Range, Weather, Vehicle — match write_gonogo_html ordering self.range_label = tk.Label(frame_gn, text="RANGE: N/A", font=("Consolas", 20), fg="white", bg="black") self.range_label.pack() self.weather_label = tk.Label(frame_gn, text="WEATHER: N/A", font=("Consolas", 20), fg="white", bg="black") self.weather_label.pack() self.vehicle_label = tk.Label(frame_gn, text="VEHICLE: N/A", font=("Consolas", 20), fg="white", bg="black") self.vehicle_label.pack() # Footer footer_frame = tk.Frame(root, bg="black") footer_frame.pack(side="bottom", pady=0, fill="x") self.footer_label = tk.Label( footer_frame, text="Made by HamsterSpaceNerd3000", # or whatever you want font=("Consolas", 12), fg="black", bg="white" ) self.footer_label.pack(fill="x") self.update_inputs() # set initial manual button visibility from settings self.update_manual_visibility() # Apply appearance settings at startup so the mission entry and other widgets reflect the saved mode try: self.apply_appearance_settings() except Exception: pass self.update_clock() # ---------------------------- # Settings window # ---------------------------- def show_settings_window(self): settings = load_settings() win = tk.Toplevel(self.root) win.transient(self.root) win.title("Settings") win.geometry("560x275") # apply current appearance mode so the settings window matches the main UI s_local = load_settings() mode_local = s_local.get('appearance_mode', 'dark') if mode_local == 'dark': win_bg = '#000000'; win_text = '#FFFFFF'; btn_bg = '#FFFFFF'; btn_fg = '#000000' else: win_bg = '#FFFFFF'; win_text = '#000000'; btn_bg = '#000000'; btn_fg = '#FFFFFF' win.config(bg=win_bg) # set per-window widget defaults so nested widgets inherit the chosen theme try: win.option_add('*Foreground', win_text) win.option_add('*Background', win_bg) # entry specific defaults win.option_add('*Entry.Background', '#222' if mode_local == 'dark' else '#b4b4b4') win.option_add('*Entry.Foreground', win_text if mode_local == 'dark' else '#000000') except Exception: pass # keep track of this Toplevel so other dialogs can close it if needed try: self.settings_win = win def _clear_settings_ref(evt=None): try: self.settings_win = None except Exception: pass win.bind('', _clear_settings_ref) except Exception: pass # Mode selection frame_mode = tk.Frame(win) frame_mode.config(bg=win_bg) frame_mode.pack(fill='x', pady=8, padx=8) tk.Label(frame_mode, text="Mode:", fg=win_text, bg=win_bg).pack(side='left') mode_var = tk.StringVar(value=settings.get('mode', 'spreadsheet')) tk.Radiobutton(frame_mode, text='Spreadsheet', variable=mode_var, value='spreadsheet', fg=win_text, bg=win_bg, selectcolor=win_bg).pack(side='left', padx=8) tk.Radiobutton(frame_mode, text='Buttons (manual)', variable=mode_var, value='buttons', fg=win_text, bg=win_bg, selectcolor=win_bg).pack(side='left', padx=8) # Spreadsheet config frame_sheet = tk.LabelFrame(win, text='Spreadsheet configuration', fg=win_text, bg=win_bg) frame_sheet.config(bg=win_bg) frame_sheet.pack(fill='x', padx=8, pady=6) tk.Label(frame_sheet, text='Sheet link (CSV export):', fg=win_text, bg=win_bg).pack(anchor='w') # entry background chosen to contrast with window background sheet_entry_bg = '#222' if mode_local == 'dark' else '#b4b4b4' sheet_entry_fg = win_text if mode_local == 'dark' else '#000000' sheet_entry = tk.Entry(frame_sheet, width=80, fg=sheet_entry_fg, bg=sheet_entry_bg, insertbackground=sheet_entry_fg) sheet_entry.pack(fill='x', padx=6, pady=4) sheet_entry.insert(0, settings.get('sheet_link', SHEET_LINK)) # Accept cells in 'L3' format for each parameter cell_frame = tk.Frame(frame_sheet) cell_frame.config(bg=win_bg) cell_frame.pack(fill='x', padx=6, pady=2) tk.Label(cell_frame, text='Range cell (e.g. L3):', fg=win_text, bg=win_bg).grid(row=0, column=0) range_cell_bg = '#222' if mode_local == 'dark' else '#b4b4b4' range_cell_fg = win_text if mode_local == 'dark' else '#000000' range_cell = tk.Entry(cell_frame, width=8, fg=range_cell_fg, bg=range_cell_bg, insertbackground=range_cell_fg) range_cell.grid(row=0, column=1, padx=4) # show as L3 if present, otherwise build from numeric settings try: if 'range_cell' in settings: range_cell.insert(0, settings.get('range_cell')) else: # convert numeric row/column to cell like L3 col = settings.get('column', DEFAULT_SETTINGS['column']) row = settings.get('range_row', DEFAULT_SETTINGS['range_row']) # column number to letters def col_to_letters(n): s = '' while n > 0: n, r = divmod(n - 1, 26) s = chr(ord('A') + r) + s return s range_cell.insert(0, f"{col_to_letters(col)}{row}") except Exception: range_cell.insert(0, f"L3") tk.Label(cell_frame, text='Weather cell (e.g. L4):', fg=win_text, bg=win_bg).grid(row=0, column=2) weather_cell = tk.Entry(cell_frame, width=8, fg=range_cell_fg, bg=range_cell_bg, insertbackground=range_cell_fg) weather_cell.grid(row=0, column=3, padx=4) try: if 'weather_cell' in settings: weather_cell.insert(0, settings.get('weather_cell')) else: col = settings.get('column', DEFAULT_SETTINGS['column']) row = settings.get('weather_row', DEFAULT_SETTINGS['weather_row']) def col_to_letters(n): s = '' while n > 0: n, r = divmod(n - 1, 26) s = chr(ord('A') + r) + s return s weather_cell.insert(0, f"{col_to_letters(col)}{row}") except Exception: weather_cell.insert(0, f"L4") tk.Label(cell_frame, text='Vehicle cell (e.g. L5):', fg=win_text, bg=win_bg).grid(row=0, column=4) vehicle_cell = tk.Entry(cell_frame, width=8, fg=range_cell_fg, bg=range_cell_bg, insertbackground=range_cell_fg) vehicle_cell.grid(row=0, column=5, padx=4) try: if 'vehicle_cell' in settings: vehicle_cell.insert(0, settings.get('vehicle_cell')) else: col = settings.get('column', DEFAULT_SETTINGS['column']) row = settings.get('vehicle_row', DEFAULT_SETTINGS['vehicle_row']) def col_to_letters(n): s = '' while n > 0: n, r = divmod(n - 1, 26) s = chr(ord('A') + r) + s return s vehicle_cell.insert(0, f"{col_to_letters(col)}{row}") except Exception: vehicle_cell.insert(0, f"L5") # Manual buttons config frame_buttons_cfg = tk.LabelFrame(win, text='Manual Go/No-Go (Buttons mode)', fg=win_text, bg=win_bg) frame_buttons_cfg.config(bg=win_bg) frame_buttons_cfg.pack(fill='x', padx=8, pady=6) # (Auto-hold configuration removed from Settings — managed from main UI) # Appearance settings are in a separate window frame_appearance_btn = tk.Frame(win, bg=win_bg) frame_appearance_btn.pack(fill='x', padx=8, pady=6) tk.Button(frame_appearance_btn, text='Appearance...', command=lambda: self.show_appearance_window(), fg=btn_fg, bg=btn_bg, activebackground='#444').pack(side='left') # Timezone selector tz_frame = tk.Frame(frame_sheet, bg=win_bg) tz_frame.pack(fill='x', padx=6, pady=4) tk.Label(tz_frame, text='Timezone:', fg=win_text, bg=win_bg).pack(side='left') tz_var = tk.StringVar(value=settings.get('timezone', DEFAULT_SETTINGS.get('timezone', 'local'))) # OptionMenu with a few choices, but user may edit the text to any IANA name tz_menu = tk.OptionMenu(tz_frame, tz_var, *TIMEZONE_CHOICES) tz_menu.config(fg=win_text, bg=range_cell_bg, activebackground='#333') tz_menu.pack(side='left', padx=6) def set_manual(val_type, val): # store on fetch_gonogo func for now if val_type == 'range': fetch_gonogo.manual_range = val elif val_type == 'weather': fetch_gonogo.manual_weather = val elif val_type == 'vehicle': fetch_gonogo.manual_vehicle = val # helper to set manual and update UI from main app def set_manual_and_update(val_type, val): set_manual(val_type, val) # update labels and write html self.gonogo_values = fetch_gonogo() # update GUI labels immediately self.range_label.config(text=f"RANGE: {self.gonogo_values[0]}", fg=get_status_color(self.gonogo_values[0])) self.weather_label.config(text=f"WEATHER: {self.gonogo_values[1]}", fg=get_status_color(self.gonogo_values[1])) self.vehicle_label.config(text=f"VEHICLE: {self.gonogo_values[2]}", fg=get_status_color(self.gonogo_values[2])) write_gonogo_html(self.gonogo_values) # Save/Cancel def cell_to_rc(cell_str): s = (cell_str or '').strip().upper() if not s: return None, None # split letters and digits letters = '' digits = '' for ch in s: if ch.isalpha(): letters += ch elif ch.isdigit(): digits += ch if not digits: return None, None # convert letters to number col = 0 for ch in letters: col = col * 26 + (ord(ch) - ord('A') + 1) row = int(digits) return row, col def on_save(): # parse cells r_row, r_col = cell_to_rc(range_cell.get()) w_row, w_col = cell_to_rc(weather_cell.get()) v_row, v_col = cell_to_rc(vehicle_cell.get()) # fallbacks if r_row is None: r_row = DEFAULT_SETTINGS['range_row'] if w_row is None: w_row = DEFAULT_SETTINGS['weather_row'] if v_row is None: v_row = DEFAULT_SETTINGS['vehicle_row'] # determine column to use (prefer range column, else weather, else vehicle, else default) col_val = r_col or w_col or v_col or DEFAULT_SETTINGS['column'] new_settings = { 'mode': mode_var.get(), 'sheet_link': sheet_entry.get().strip() or SHEET_LINK, 'range_row': int(r_row), 'weather_row': int(w_row), 'vehicle_row': int(v_row), 'column': int(col_val), # persist the textual cells for convenience 'range_cell': range_cell.get().strip().upper(), 'weather_cell': weather_cell.get().strip().upper(), 'vehicle_cell': vehicle_cell.get().strip().upper(), # persist manual values if present 'manual_range': getattr(fetch_gonogo, 'manual_range', None), 'manual_weather': getattr(fetch_gonogo, 'manual_weather', None), 'manual_vehicle': getattr(fetch_gonogo, 'manual_vehicle', None), 'timezone': tz_var.get(), # preserve appearance settings (edited in Appearance window) 'bg_color': settings.get('bg_color', '#000000'), 'text_color': settings.get('text_color', '#FFFFFF'), 'gn_bg_color': settings.get('gn_bg_color', '#111111'), 'gn_border_color': settings.get('gn_border_color', '#FFFFFF'), 'gn_go_color': settings.get('gn_go_color', '#00FF00'), 'gn_nogo_color': settings.get('gn_nogo_color', '#FF0000'), 'font_family': settings.get('font_family', 'Consolas'), 'mission_font_px': int(settings.get('mission_font_px', 48)), 'timer_font_px': int(settings.get('timer_font_px', 120)), 'gn_font_px': int(settings.get('gn_font_px', 28)) } # Auto-hold editing removed from Settings window; keep existing settings value new_settings['auto_hold_times'] = settings.get('auto_hold_times', []) # preserve the appearance_mode so saving Settings doesn't accidentally remove it try: new_settings['appearance_mode'] = settings.get('appearance_mode', DEFAULT_SETTINGS.get('appearance_mode', 'dark')) except Exception: new_settings['appearance_mode'] = DEFAULT_SETTINGS.get('appearance_mode', 'dark') save_settings(new_settings) # update immediately self.gonogo_values = fetch_gonogo() write_gonogo_html(self.gonogo_values) # update manual visibility in main UI self.update_manual_visibility() # appearance changes are applied only from the Appearance window win.destroy() def on_cancel(): win.destroy() btn_frame = tk.Frame(win, bg=win_bg) btn_frame.pack(fill='x', pady=8) tk.Button(btn_frame, text='Save', command=on_save, fg=btn_fg, bg=btn_bg, activebackground='#444').pack(side='right', padx=8) tk.Button(btn_frame, text='Cancel', command=on_cancel, fg=btn_fg, bg=btn_bg, activebackground='#444').pack(side='right') # ensure the new toplevel gets recursively themed like the main window try: self._theme_recursive(win, win_bg, win_text, btn_bg, btn_fg) except Exception: pass # ---------------------------- # Update input visibility based on mode # ---------------------------- def update_inputs(self): if self.mode_var.get() == "duration": self.hours_entry.config(state="normal") self.minutes_entry.config(state="normal") self.seconds_entry.config(state="normal") self.clock_hours_entry.config(state="disabled") self.clock_minutes_entry.config(state="disabled") self.clock_seconds_entry.config(state="disabled") else: self.hours_entry.config(state="disabled") self.minutes_entry.config(state="disabled") self.seconds_entry.config(state="disabled") self.clock_hours_entry.config(state="normal") self.clock_minutes_entry.config(state="normal") self.clock_seconds_entry.config(state="normal") # ---------------------------- # Manual controls & helpers # ---------------------------- def set_manual(self, which, val): # normalize v = (val or '').strip().upper() if which == 'range': fetch_gonogo.manual_range = v elif which == 'weather': fetch_gonogo.manual_weather = v elif which == 'vehicle': fetch_gonogo.manual_vehicle = v # update GUI and HTML self.gonogo_values = fetch_gonogo() try: self.range_label.config(text=f"RANGE: {self.gonogo_values[0]}", fg=get_status_color(self.gonogo_values[0])) self.weather_label.config(text=f"WEATHER: {self.gonogo_values[1]}", fg=get_status_color(self.gonogo_values[1])) self.vehicle_label.config(text=f"VEHICLE: {self.gonogo_values[2]}", fg=get_status_color(self.gonogo_values[2])) except Exception: pass write_gonogo_html(self.gonogo_values) # persist manual values immediately so they survive restarts try: s = load_settings() s['manual_range'] = getattr(fetch_gonogo, 'manual_range', s.get('manual_range')) s['manual_weather'] = getattr(fetch_gonogo, 'manual_weather', s.get('manual_weather')) s['manual_vehicle'] = getattr(fetch_gonogo, 'manual_vehicle', s.get('manual_vehicle')) save_settings(s) except Exception: pass def update_manual_visibility(self): s = load_settings() mode = s.get('mode', 'spreadsheet') visible = (mode == 'buttons') # show or hide manual frame if visible: self.manual_frame.pack(pady=6) else: self.manual_frame.pack_forget() def apply_appearance_settings(self): """Apply appearance-related settings to the running Tk UI.""" s = load_settings() # If an appearance_mode preset is selected, override specific settings with the preset mode = s.get('appearance_mode', None) if mode == 'dark': s.update({ 'bg_color': '#000000', 'text_color': '#FFFFFF', 'gn_bg_color': '#111111', 'gn_border_color': '#FFFFFF', 'gn_go_color': '#00FF00', 'gn_nogo_color': '#FF0000', 'font_family': 'Consolas', 'mission_font_px': 44, 'timer_font_px': 80, 'gn_font_px': 24 }) elif mode == 'light': s.update({ 'bg_color': '#FFFFFF', 'text_color': '#000000', 'gn_bg_color': '#EEEEEE', 'gn_border_color': '#333333', 'gn_go_color': '#008800', 'gn_nogo_color': '#AA0000', 'font_family': 'Consolas', 'mission_font_px': 44, 'timer_font_px': 80, 'gn_font_px': 24 }) bg = s.get('bg_color', '#000000') text = s.get('text_color', '#FFFFFF') font_family = s.get('font_family', 'Consolas') timer_px = int(s.get('timer_font_px', 100)) mission_px = int(s.get('mission_font_px', 48)) gn_px = int(s.get('gn_font_px', 24)) gn_bg = s.get('gn_bg_color', '#111111') gn_border = s.get('gn_border_color', '#FFFFFF') gn_go = s.get('gn_go_color', '#00FF00') gn_nogo = s.get('gn_nogo_color', '#FF0000') # apply to main window elements try: self.root.config(bg=bg) self.titletext.config(fg=text, bg=bg, font=(font_family, 20)) # timer label self.text.config(fg=text, bg=bg, font=(font_family, timer_px, 'bold')) # GN labels: set bg and font, and color depending on GO/NOGO def style_gn_label(lbl, value): try: lbl.config(bg=bg, font=(font_family, gn_px)) v = (value or '').strip().upper() if v == 'GO': lbl.config(fg=gn_go) elif v in ('NOGO', 'NO-GO'): lbl.config(fg=gn_nogo) else: lbl.config(fg=text) except Exception: pass style_gn_label(self.range_label, getattr(self, 'range_status', None)) style_gn_label(self.weather_label, getattr(self, 'weather', None)) style_gn_label(self.vehicle_label, getattr(self, 'vehicle', None)) # Buttons: invert colors depending on mode # dark mode -> buttons white bg, black text # light mode -> buttons black bg, white text if mode == 'dark': btn_bg = '#FFFFFF' btn_fg = '#000000' active_bg = '#DDDDDD' else: btn_bg = '#000000' btn_fg = '#FFFFFF' active_bg = '#222222' for btn in (self.start_btn, self.hold_btn, self.resume_btn, self.scrub_btn, self.reset_btn, self.settings_btn): try: # preserve scrub button's custom color (red) if set try: cur_fg = btn.cget('fg') except Exception: cur_fg = None if btn is getattr(self, 'scrub_btn', None) and cur_fg: # keep existing foreground (usually red) btn.config(bg=btn_bg, activebackground=active_bg) else: btn.config(bg=btn_bg, fg=btn_fg, activebackground=active_bg) except Exception: pass # Manual toggle buttons for btn in (self.range_toggle_btn, self.weather_toggle_btn, self.vehicle_toggle_btn): try: btn.config(bg=btn_bg, fg=btn_fg) except Exception: pass # manual frame and footer try: self.manual_frame.config(bg=bg) # Footer should invert colors depending on mode: # - dark mode -> white background, black text # - light mode -> black background, white text mode = s.get('appearance_mode', 'dark') if mode == 'dark': footer_bg = '#FFFFFF' footer_fg = '#000000' else: footer_bg = '#000000' footer_fg = '#FFFFFF' try: self.footer_label.config(bg=footer_bg, fg=footer_fg) except Exception: # fall back to generic theme self.footer_label.config(bg=bg, fg=text) except Exception: pass except Exception: pass # Recursively theme frames and common widgets so no frame is left with old colors try: self._theme_recursive(self.root, bg, text, btn_bg, btn_fg) except Exception: pass def update_gn_labels(self, range_val, weather_val, vehicle_val): """Update GN label texts and apply theme-aware styling.""" s = load_settings() gn_px = int(s.get('gn_font_px', 28)) font_family = s.get('font_family', 'Consolas') bg = s.get('bg_color', '#000000') text = s.get('text_color', '#FFFFFF') gn_go = s.get('gn_go_color', '#00FF00') gn_nogo = s.get('gn_nogo_color', '#FF0000') # Range try: display_range = format_status_display(range_val) self.range_label.config(text=f"RANGE: {display_range}", bg=bg, font=(font_family, gn_px)) rv = (range_val or '').strip().upper() rnorm = re.sub(r'[^A-Z]', '', rv) if rnorm == 'GO': self.range_label.config(fg=gn_go) elif rnorm == 'NOGO': self.range_label.config(fg=gn_nogo) else: self.range_label.config(fg=text) except Exception: pass # Weather try: display_weather = format_status_display(weather_val) self.weather_label.config(text=f"WEATHER: {display_weather}", bg=bg, font=(font_family, gn_px)) wv = (weather_val or '').strip().upper() wnorm = re.sub(r'[^A-Z]', '', wv) if wnorm == 'GO': self.weather_label.config(fg=gn_go) elif wnorm == 'NOGO': self.weather_label.config(fg=gn_nogo) else: self.weather_label.config(fg=text) except Exception: pass # Vehicle try: display_vehicle = format_status_display(vehicle_val) self.vehicle_label.config(text=f"VEHICLE: {display_vehicle}", bg=bg, font=(font_family, gn_px)) vv = (vehicle_val or '').strip().upper() vnorm = re.sub(r'[^A-Z]', '', vv) if vnorm == 'GO': self.vehicle_label.config(fg=gn_go) elif vnorm == 'NOGO': self.vehicle_label.config(fg=gn_nogo) else: self.vehicle_label.config(fg=text) except Exception: pass def _theme_recursive(self, widget, bg, text, btn_bg, btn_fg): # load settings so we can theme GN label backgrounds if configured s = load_settings() for child in widget.winfo_children(): # Frame and LabelFrame try: if isinstance(child, (tk.Frame, tk.LabelFrame)): try: child.config(bg=bg) except Exception: pass # Labels: set bg, but don't override GN label fg if isinstance(child, tk.Label): try: # preserve GN label fg colors and don't override the footer label (it has a special inverted style) if child in (getattr(self, 'range_label', None), getattr(self, 'weather_label', None), getattr(self, 'vehicle_label', None)): # GN labels keep fg but should have themed bg child.config(bg=s.get('gn_bg_color', bg)) elif child is getattr(self, 'footer_label', None): # footer_label was already styled by apply_appearance_settings; don't override it here pass else: child.config(bg=bg, fg=text) except Exception: pass # Entries: avoid overriding entries that were explicitly styled (like mission_entry) if isinstance(child, tk.Entry): try: # Set entry bg/fg depending on appearance mode mode_local = s.get('appearance_mode', 'dark') if mode_local == 'dark': child.config(bg='#222222', fg=text, insertbackground=text) else: # light mode entries should contrast with the white background child.config(bg='#b4b4b4', fg='#000000', insertbackground='#000000') except Exception: pass # OptionMenu/Menubutton if isinstance(child, tk.Menubutton): try: child.config(bg=btn_bg, fg=btn_fg, activebackground='#555') except Exception: pass # Radiobutton / Checkbutton if isinstance(child, (tk.Radiobutton, tk.Checkbutton)): try: # selectcolor is the indicator background; set it to match the overall bg for neatness child.config(bg=bg, fg=text, selectcolor=bg, activebackground=bg) except Exception: pass # Buttons: ensure themed background and correct fg if isinstance(child, tk.Button): try: # don't override scrub button's fg if it has a special color if child is getattr(self, 'scrub_btn', None): child.config(bg=btn_bg, activebackground='#555') else: child.config(bg=btn_bg, fg=btn_fg, activebackground='#555') except Exception: pass except Exception: pass # Recurse try: if hasattr(child, 'winfo_children'): self._theme_recursive(child, bg, text, btn_bg, btn_fg) except Exception: pass def show_appearance_window(self): # Re-implemented appearance window with proper theming and layout settings = load_settings() win = tk.Toplevel(self.root) win.transient(self.root) win.title('Appearance') win.geometry('520x475') # derive colors from appearance_mode so the dialog matches the main UI mode_local = settings.get('appearance_mode', 'dark') if mode_local == 'dark': win_bg = '#000000'; win_text = '#FFFFFF'; btn_bg = '#FFFFFF'; btn_fg = '#000000'; entry_bg = '#222'; entry_fg = '#FFFFFF' else: win_bg = '#FFFFFF'; win_text = '#000000'; btn_bg = '#000000'; btn_fg = '#FFFFFF'; entry_bg = '#b4b4b4'; entry_fg = '#000000' win.config(bg=win_bg) tk.Label(win, text='Choose UI mode:', fg=win_text, bg=win_bg).pack(anchor='w', padx=12, pady=(10,0)) mode_var = tk.StringVar(value=settings.get('appearance_mode', 'dark')) modes = ['dark', 'light'] mode_menu = tk.OptionMenu(win, mode_var, *modes) mode_menu.config(fg=win_text, bg=entry_bg, activebackground='#333') mode_menu.pack(anchor='w', padx=12, pady=6) def on_save_mode(): choice = mode_var.get() presets = { 'dark': { 'bg_color': '#000000', 'text_color': '#FFFFFF', 'gn_bg_color': '#111111', 'gn_border_color': '#FFFFFF', 'gn_go_color': '#00FF00', 'gn_nogo_color': '#FF0000', 'font_family': 'Consolas', 'mission_font_px': 24, 'timer_font_px': 80, 'gn_font_px': 20 }, 'light': { 'bg_color': '#FFFFFF', 'text_color': '#000000', 'gn_bg_color': '#EEEEEE', 'gn_border_color': '#333333', 'gn_go_color': '#008800', 'gn_nogo_color': '#AA0000', 'font_family': 'Consolas', 'mission_font_px': 24, 'timer_font_px': 80, 'gn_font_px': 20 } } p = presets.get(choice, {}) s = load_settings() s['appearance_mode'] = choice s.update(p) save_settings(s) try: self.apply_appearance_settings() write_countdown_html(self.mission_name, self.text.cget('text')) write_gonogo_html(self.gonogo_values) except Exception: pass # close appearance window win.destroy() # also close the settings window if it is open try: if getattr(self, 'settings_win', None): try: self.settings_win.destroy() except Exception: pass except Exception: pass def choose_color(entry_widget): try: col = colorchooser.askcolor() if col and col[1]: entry_widget.delete(0, tk.END) entry_widget.insert(0, col[1]) except Exception: pass s = load_settings() html_frame = tk.LabelFrame(win, text='HTML appearance (streaming)', fg=win_text, bg=win_bg) html_frame.config(bg=win_bg) html_frame.pack(fill='x', padx=8, pady=6) # layout HTML appearance fields in a grid tk.Label(html_frame, text='Background:', fg=win_text, bg=win_bg).grid(row=0, column=0, sticky='w', padx=6, pady=4) bg_entry = tk.Entry(html_frame, width=12, fg=entry_fg, bg=entry_bg, insertbackground=entry_fg) bg_entry.grid(row=0, column=1, padx=6, pady=4) bg_entry.insert(0, s.get('html_bg_color', s.get('bg_color', '#000000'))) tk.Button(html_frame, text='Choose', command=lambda: choose_color(bg_entry), fg=btn_fg, bg=btn_bg).grid(row=0, column=2, padx=6) tk.Label(html_frame, text='Text:', fg=win_text, bg=win_bg).grid(row=1, column=0, sticky='w', padx=6, pady=4) text_entry = tk.Entry(html_frame, width=12, fg=entry_fg, bg=entry_bg, insertbackground=entry_fg) text_entry.grid(row=1, column=1, padx=6, pady=4) text_entry.insert(0, s.get('html_text_color', s.get('text_color', '#FFFFFF'))) tk.Button(html_frame, text='Choose', command=lambda: choose_color(text_entry), fg=btn_fg, bg=btn_bg).grid(row=1, column=2, padx=6) tk.Label(html_frame, text='GN GO:', fg=win_text, bg=win_bg).grid(row=2, column=0, sticky='w', padx=6, pady=4) gn_go_entry = tk.Entry(html_frame, width=12, fg=entry_fg, bg=entry_bg, insertbackground=entry_fg) gn_go_entry.grid(row=2, column=1, padx=6, pady=4) gn_go_entry.insert(0, s.get('html_gn_go_color', s.get('gn_go_color', '#00FF00'))) tk.Button(html_frame, text='Choose', command=lambda: choose_color(gn_go_entry), fg=btn_fg, bg=btn_bg).grid(row=2, column=2, padx=6) tk.Label(html_frame, text='GN NO-GO:', fg=win_text, bg=win_bg).grid(row=3, column=0, sticky='w', padx=6, pady=4) gn_nogo_entry = tk.Entry(html_frame, width=12, fg=entry_fg, bg=entry_bg, insertbackground=entry_fg) gn_nogo_entry.grid(row=3, column=1, padx=6, pady=4) gn_nogo_entry.insert(0, s.get('html_gn_nogo_color', s.get('gn_nogo_color', '#FF0000'))) tk.Button(html_frame, text='Choose', command=lambda: choose_color(gn_nogo_entry), fg=btn_fg, bg=btn_bg).grid(row=3, column=2, padx=6) tk.Label(html_frame, text='GN box bg:', fg=win_text, bg=win_bg).grid(row=4, column=0, sticky='w', padx=6, pady=4) gn_box_bg_entry = tk.Entry(html_frame, width=12, fg=entry_fg, bg=entry_bg, insertbackground=entry_fg) gn_box_bg_entry.grid(row=4, column=1, padx=6, pady=4) gn_box_bg_entry.insert(0, s.get('html_gn_bg_color', s.get('gn_bg_color', '#111111'))) tk.Button(html_frame, text='Choose', command=lambda: choose_color(gn_box_bg_entry), fg=btn_fg, bg=btn_bg).grid(row=4, column=2, padx=6) tk.Label(html_frame, text='GN border:', fg=win_text, bg=win_bg).grid(row=5, column=0, sticky='w', padx=6, pady=4) gn_border_entry = tk.Entry(html_frame, width=12, fg=entry_fg, bg=entry_bg, insertbackground=entry_fg) gn_border_entry.grid(row=5, column=1, padx=6, pady=4) gn_border_entry.insert(0, s.get('html_gn_border_color', s.get('gn_border_color', '#FFFFFF'))) tk.Button(html_frame, text='Choose', command=lambda: choose_color(gn_border_entry), fg=btn_fg, bg=btn_bg).grid(row=5, column=2, padx=6) tk.Label(html_frame, text='Font family:', fg=win_text, bg=win_bg).grid(row=6, column=0, sticky='w', padx=6, pady=4) font_entry = tk.Entry(html_frame, width=20, fg=entry_fg, bg=entry_bg, insertbackground=entry_fg) font_entry.grid(row=6, column=1, padx=6, pady=4, columnspan=2, sticky='w') font_entry.insert(0, s.get('html_font_family', s.get('font_family', 'Consolas'))) tk.Label(html_frame, text='Mission px:', fg=win_text, bg=win_bg).grid(row=7, column=0, sticky='w', padx=6, pady=4) mission_px_entry = tk.Entry(html_frame, width=6, fg=entry_fg, bg=entry_bg, insertbackground=entry_fg) mission_px_entry.grid(row=7, column=1, padx=6, pady=4, sticky='w') mission_px_entry.insert(0, str(s.get('html_mission_font_px', s.get('mission_font_px', 24)))) tk.Label(html_frame, text='Timer px:', fg=win_text, bg=win_bg).grid(row=8, column=0, sticky='w', padx=6, pady=4) timer_px_entry = tk.Entry(html_frame, width=6, fg=entry_fg, bg=entry_bg, insertbackground=entry_fg) timer_px_entry.grid(row=8, column=1, padx=6, pady=4, sticky='w') timer_px_entry.insert(0, str(s.get('html_timer_font_px', s.get('timer_font_px', 80)))) # Add a checkbox to hide mission name in HTML output self.hide_mission_name_var = tk.BooleanVar(value=s.get("hide_mission_name", False)) hide_mission_name_cb = tk.Checkbutton(html_frame, text="Hide mission name in HTML output", variable=self.hide_mission_name_var, fg=win_text, bg=win_bg, selectcolor=win_bg, activebackground=win_bg) hide_mission_name_cb.grid(row=9, column=0, columnspan=3, sticky='w', padx=6, pady=4) def save_html_prefs(): try: s_local = load_settings() s_local['html_bg_color'] = bg_entry.get().strip() or s_local.get('html_bg_color') s_local['html_text_color'] = text_entry.get().strip() or s_local.get('html_text_color') s_local['html_gn_go_color'] = gn_go_entry.get().strip() or s_local.get('html_gn_go_color') s_local['html_gn_nogo_color'] = gn_nogo_entry.get().strip() or s_local.get('html_gn_nogo_color') s_local['html_gn_bg_color'] = gn_box_bg_entry.get().strip() or s_local.get('html_gn_bg_color') s_local['html_gn_border_color'] = gn_border_entry.get().strip() or s_local.get('html_gn_border_color') s_local['html_font_family'] = font_entry.get().strip() or s_local.get('html_font_family') s_local["hide_mission_name"] = self.hide_mission_name_var.get() try: s_local['html_mission_font_px'] = int(mission_px_entry.get()) except Exception: pass try: s_local['html_timer_font_px'] = int(timer_px_entry.get()) except Exception: pass save_settings(s_local) write_countdown_html(self.mission_name, self.text.cget('text')) write_gonogo_html(self.gonogo_values) except Exception: pass def reset_html_defaults(): try: s_local = load_settings() s_local['html_bg_color'] = DEFAULT_SETTINGS.get('html_bg_color') s_local['html_text_color'] = DEFAULT_SETTINGS.get('html_text_color') s_local['html_font_family'] = DEFAULT_SETTINGS.get('html_font_family') s_local['html_mission_font_px'] = DEFAULT_SETTINGS.get('html_mission_font_px') s_local['html_timer_font_px'] = DEFAULT_SETTINGS.get('html_timer_font_px') s_local['html_gn_bg_color'] = DEFAULT_SETTINGS.get('html_gn_bg_color') s_local['html_gn_border_color'] = DEFAULT_SETTINGS.get('html_gn_border_color') s_local['html_gn_go_color'] = DEFAULT_SETTINGS.get('html_gn_go_color') s_local['html_gn_nogo_color'] = DEFAULT_SETTINGS.get('html_gn_nogo_color') s_local['html_gn_font_px'] = DEFAULT_SETTINGS.get('html_gn_font_px') save_settings(s_local) # update UI fields bg_entry.delete(0, tk.END); bg_entry.insert(0, s_local['html_bg_color']) text_entry.delete(0, tk.END); text_entry.insert(0, s_local['html_text_color']) gn_go_entry.delete(0, tk.END); gn_go_entry.insert(0, s_local['html_gn_go_color']) gn_nogo_entry.delete(0, tk.END); gn_nogo_entry.insert(0, s_local['html_gn_nogo_color']) gn_box_bg_entry.delete(0, tk.END); gn_box_bg_entry.insert(0, s_local['html_gn_bg_color']) gn_border_entry.delete(0, tk.END); gn_border_entry.insert(0, s_local['html_gn_border_color']) font_entry.delete(0, tk.END); font_entry.insert(0, s_local['html_font_family']) mission_px_entry.delete(0, tk.END); mission_px_entry.insert(0, str(s_local['html_mission_font_px'])) timer_px_entry.delete(0, tk.END); timer_px_entry.insert(0, str(s_local['html_timer_font_px'])) write_countdown_html(self.mission_name, self.text.cget('text')) write_gonogo_html(self.gonogo_values) except Exception: pass html_btns = tk.Frame(html_frame, bg=win_bg) html_btns.grid(row=10, column=0, columnspan=3, pady=6) tk.Button(html_btns, text='Save (HTML only)', command=save_html_prefs, fg=btn_fg, bg=btn_bg).pack(side='right', padx=6) tk.Button(html_btns, text='Reset HTML defaults', command=reset_html_defaults, fg=btn_fg, bg=btn_bg).pack(side='right') btn_frame = tk.Frame(win, bg=win_bg) btn_frame.pack(fill='x', pady=8, padx=8) tk.Button(btn_frame, text='Save', command=on_save_mode, fg=btn_fg, bg=btn_bg).pack(side='right', padx=6) tk.Button(btn_frame, text='Cancel', command=win.destroy, fg=btn_fg, bg=btn_bg).pack(side='right') try: self._theme_recursive(win, win_bg, win_text, btn_bg, btn_fg) except Exception: pass def _toggle_manual(self, which): # get current values (Range, Weather, Vehicle) cur = fetch_gonogo() # map which to index idx_map = {'range': 0, 'weather': 1, 'vehicle': 2} idx = idx_map.get(which, 0) try: cur_val = (cur[idx] or '').strip().upper() except Exception: cur_val = 'N/A' # toggle: if GO -> NOGO, else -> GO new_val = 'NO-GO' if cur_val == 'GO' else 'GO' self.set_manual(which, new_val) # ---------------------------- # Control logic # ---------------------------- def start(self): self.mission_name = self.mission_entry.get().strip() or "Placeholder Mission" self.running = True self.on_hold = False self.scrubbed = False self.counting_up = False self.show_hold_button() try: if self.mode_var.get() == "duration": h = int(self.hours_entry.get()) m = int(self.minutes_entry.get()) s = int(self.seconds_entry.get()) total_seconds = h * 3600 + m * 60 + s else: now = datetime.now() # read separate HH, MM, SS boxes h = int(self.clock_hours_entry.get() or 0) m = int(self.clock_minutes_entry.get() or 0) s = int(self.clock_seconds_entry.get() or 0) # determine timezone from settings ssettings = load_settings() tzname = ssettings.get('timezone', DEFAULT_SETTINGS.get('timezone', 'local')) if ZoneInfo is None or tzname in (None, '', 'local'): # naive local time handling (existing behavior) — use timedelta to roll day target_today = now.replace(hour=h, minute=m, second=s, microsecond=0) if target_today <= now: target_today = target_today + timedelta(days=1) total_seconds = (target_today - now).total_seconds() else: try: tz = ZoneInfo(tzname) # construct aware "now" in that timezone and create the target time now_tz = datetime.now(tz) target = now_tz.replace(hour=h, minute=m, second=s, microsecond=0) # if target already passed in that tz, roll to next day if target <= now_tz: target = target + timedelta(days=1) # compute total seconds using aware-datetime subtraction to avoid epoch mixing total_seconds = (target - now_tz).total_seconds() except Exception: # fallback to naive local behavior target_today = now.replace(hour=h, minute=m, second=s, microsecond=0) if target_today <= now: target_today = target_today + timedelta(days=1) total_seconds = (target_today - now).total_seconds() except Exception: self.text.config(text="Invalid time") write_countdown_html(self.mission_name, "Invalid time") return self.target_time = time.time() + total_seconds self.remaining_time = total_seconds def hold(self): if self.running and not self.on_hold and not self.scrubbed: self.on_hold = True self.hold_start_time = time.time() self.remaining_time = max(0, self.target_time - self.hold_start_time) self.show_resume_button() def resume(self): if self.running and self.on_hold and not self.scrubbed: self.on_hold = False self.target_time = time.time() + self.remaining_time self.show_hold_button() def show_hold_button(self): self.resume_btn.grid_remove() self.hold_btn.grid() def show_resume_button(self): self.hold_btn.grid_remove() self.resume_btn.grid() def scrub(self): self.scrubbed = True self.running = False write_countdown_html(self.mission_name, "SCRUB") self.text.config(text="SCRUB") def reset(self): self.running = False self.on_hold = False self.scrubbed = False self.counting_up = False self.text.config(text="T-00:00:00") write_countdown_html(self.mission_name, "T-00:00:00") self.show_hold_button() # ---------------------------- # Clock updating # ---------------------------- def format_time(self, seconds, prefix="T-"): h = int(seconds // 3600) m = int((seconds % 3600) // 60) s = int(seconds % 60) return f"{prefix}{h:02}:{m:02}:{s:02}" def update_clock(self): now_time = time.time() # Update timer if self.running and not self.scrubbed: if self.on_hold: elapsed = int(now_time - self.hold_start_time) timer_text = self.format_time(elapsed, "H+") elif self.target_time: diff = int(self.target_time - now_time) # auto-hold detection: if configured times include this remaining value, enter hold try: s = load_settings() ah = set(int(x) for x in s.get('auto_hold_times', []) or []) except Exception: ah = set() if diff in ah and diff not in getattr(self, '_auto_hold_triggered', set()): # trigger hold self._auto_hold_triggered.add(diff) self.hold() # show_hold_button/other UI changes handled by hold() # After entering hold, update countdown display via next tick if diff <= 0 and not self.counting_up: self.counting_up = True self.target_time = now_time diff = 0 if self.counting_up: elapsed = int(now_time - self.target_time) timer_text = self.format_time(elapsed, "T+") else: timer_text = self.format_time(diff, "T-") else: timer_text = "T-00:00:00" else: timer_text = self.text.cget("text") self.text.config(text=timer_text) write_countdown_html(self.mission_name, timer_text) # Update Go/No-Go every 10 seconds if now_time - self.last_gonogo_update > 0.1: # fetch_gonogo returns [Range, Weather, Vehicle] self.range_status, self.weather, self.vehicle = fetch_gonogo() # update texts and styles using theme try: self.update_gn_labels(self.range_status, self.weather, self.vehicle) except Exception: # fallback to simple config self.range_label.config(text=f"RANGE: {self.range_status}") self.weather_label.config(text=f"WEATHER: {self.weather}") self.vehicle_label.config(text=f"VEHICLE: {self.vehicle}") self.gonogo_values = [self.range_status, self.weather, self.vehicle] write_gonogo_html(self.gonogo_values) self.last_gonogo_update = now_time self.root.after(200, self.update_clock) if __name__ == "__main__": # Show a small splash/loading GUI while we fetch initial data and write HTML files. def show_splash_and_start(): splash = tk.Tk() splash.title("RocketLaunchCountdown — Initialaization") splash.config(bg="black") splash.geometry("400x175") splash.attributes("-topmost", True) title = tk.Label(splash, text="RocketLaunchCountdown", fg="white", bg="black", font=("Arial", 20, "bold")) title.pack(pady=(10,0)) lbl = tk.Label(splash, text="Loading resources...", fg="white", bg="black", font=("Arial", 14)) lbl.pack(pady=(0,5)) info = tk.Label(splash, text="Fetching Go/No-Go and preparing HTML files.", fg="#ccc", bg="black", font=("Arial", 10)) info.pack() cont_btn = tk.Button(splash, text="Continue", state="disabled", width=12) cont_btn.pack(pady=8) # Footer footer_frame = tk.Frame(splash, bg="black") footer_frame.pack(side="bottom", pady=0, fill="x") # Footer uses inverted colors: white bg/black text in dark mode, black bg/white text in light mode s = load_settings() splash_mode = s.get('appearance_mode', 'dark') if splash_mode == 'dark': splash_footer_bg = '#FFFFFF' splash_footer_fg = '#000000' else: splash_footer_bg = '#000000' splash_footer_fg = '#FFFFFF' footer_label = tk.Label( footer_frame, text="Made by HamsterSpaceNerd3000", font=("Consolas", 12), fg=splash_footer_fg, bg=splash_footer_bg ) footer_label.pack(fill="x") # Shared flag to indicate initialization complete init_state = { 'done': False, 'error': None } def init_worker(): try: # perform the same initial writes you had before gonogo = fetch_gonogo() write_countdown_html("Placeholder Mission", "T-00:00:00") write_gonogo_html(gonogo) init_state['done'] = True except Exception as e: init_state['error'] = str(e) init_state['done'] = True # Start background initialization threading.Thread(target=init_worker, daemon=True).start() def check_init(): if init_state['done']: if init_state['error']: info.config(text=f"Initialization error: {init_state['error']}") else: # show a visible countdown before auto-start; allow Continue to skip AUTO_START_SECONDS = 5 remaining = AUTO_START_SECONDS cont_btn.config(state='normal') def tick(): nonlocal remaining if remaining <= 0: on_continue() return info.config(text=f"Ready — auto-starting in {remaining}...") cont_btn.config(text=f"Continue ({remaining})") remaining -= 1 splash.after(1000, tick) # clicking Continue will immediately proceed cont_btn.config(command=on_continue) tick() return splash.after(200, check_init) def on_continue(): splash.destroy() # now create the real main window root = tk.Tk() app = CountdownApp(root) root.mainloop() # begin polling splash.after(100, check_init) splash.mainloop() show_splash_and_start()