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.7.0" SETTINGS_FILE = os.path.join(app_folder, "settings.json") concerns_list = [] # Default settings DEFAULT_SETTINGS = { "mode": "spreadsheet", "sheet_link": SHEET_LINK, "range_row": 2, "weather_row": 3, "vehicle_row": 4, "major_concerns_row": 9, "column": 12, "hide_mission_name": True } # 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_caution_color", "#FFA500") # orange DEFAULT_SETTINGS.setdefault("html_gn_caution_color", "#FFA500") 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", []) DEFAULT_SETTINGS.setdefault("major_concerns_cell", "L9") # How often (seconds) to refresh major concerns from the sheet DEFAULT_SETTINGS.setdefault("concerns_update_interval", 2) # 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: loaded = json.load(fh) # Merge loaded settings with defaults so missing keys get sane values merged = DEFAULT_SETTINGS.copy() if isinstance(loaded, dict): merged.update(loaded) return merged 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 function object (set by the GUI dialog/buttons) try: range_val = getattr(fetch_gonogo, "manual_range", "N/A") weather_val = getattr(fetch_gonogo, "manual_weather", "N/A") vehicle_val = getattr(fetch_gonogo, "manual_vehicle", "N/A") # If values are numeric strings or numbers, we keep them numeric (so formatting + coloring works) # convert empty strings -> "N/A" def normv(v): if v is None: return "N/A" s = str(v).strip() if s == "": return "N/A" return s return [normv(range_val), normv(weather_val), normv(vehicle_val)] 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"] def get_status_color(status): """ Accepts status which may be: - a percentage string/number (e.g. '82', '82.0', 82) - 'GO', 'NO-GO', 'CAUTION' or variants - anything else -> white Returns CSS color names / hex strings. """ try: if status is None: return "white" s = str(status).strip() # Try numeric percentage first try: pct = float(s.replace("%", "")) # Bound it to 0..100 pct = max(0.0, min(100.0, pct)) if pct >= 75.0: return "green" if pct >= 50.0: return "orange" return "red" except Exception: pass # Non-numeric: interpret standard words norm = re.sub(r"[^A-Z]", "", s.upper()) if norm == "GO": return "green" if norm == "NOGO" or norm == "NOGO" or norm == "NOGO": return "red" if norm == "CAUTION" or norm == "CAUT": return "orange" return "white" except Exception: return "white" def format_status_display(status): """ Formats the status for display: - numeric -> 'NN%' (rounded as needed) - 'NO-GO'/'GO' -> canonical formatting - otherwise return original string """ try: if status is None: return "N/A" s = str(status).strip() # numeric try: pct = float(s) # bound and show as integer if whole, else one decimal pct = max(0.0, min(100.0, pct)) if abs(pct - round(pct)) < 0.001: return f"{int(round(pct))}%" return f"{pct:.1f}%" except Exception: pass # canonical text forms norm = re.sub(r"[^A-Z]", "", s.upper()) if norm == "GO": return "GO" if norm == "NOGO": return "NO-GO" if norm == "CAUTION": return "CAUTION" # fallback to raw return s except Exception: return str(status or "") def fetch_major_concerns_from_sheet(): """Fetch major concerns from the same CSV sheet used by fetch_gonogo(). Returns a list of non-empty strings. This mirrors the safe access pattern in `fetch_gonogo()`: - uses `session.get()` to fetch the CSV - respects `major_concerns_cell` (A1-style) if configured - splits multi-item cell values on newlines or semicolons - falls back to `major_concerns_row` and returns non-empty cells from that row """ settings = load_settings() link = settings.get("sheet_link", SHEET_LINK) cell_ref = settings.get("major_concerns_cell", "").upper().strip() def col_letters_to_index(letters): result = 0 for c in letters: result = result * 26 + (ord(c.upper()) - ord('A') + 1) return result - 1 try: resp = session.get(link, timeout=3) resp.raise_for_status() reader = csv.reader(io.StringIO(resp.text)) data = list(reader) try: print(f"[DEBUG] fetch_major_concerns_from_sheet: cell={cell_ref!r}, rows={len(data)}") except Exception: pass concerns = [] if cell_ref: # allow multiple cell refs separated by comma/semicolon/whitespace, e.g. "L9, L10" tokens = [t.strip() for t in re.split(r"[;,\s]+", cell_ref) if t.strip()] for tok in tokens: m = re.match(r"^([A-Z]+)(\d+)$", tok) if not m: continue col_letters, row_str = m.groups() row_idx = int(row_str) - 1 col_idx = col_letters_to_index(col_letters) if 0 <= row_idx < len(data): row = data[row_idx] if 0 <= col_idx < len(row): val = row[col_idx].strip() if val: # split cell contents on newline, semicolon or comma into separate concerns parts = re.split(r"[\r\n,;]+", val) for p in parts: p = p.strip() if p: concerns.append(p) # fallback: use configured major_concerns_row if not concerns: row_idx = int(settings.get("major_concerns_row", DEFAULT_SETTINGS.get("major_concerns_row", 9))) - 1 if 0 <= row_idx < len(data): concerns = [c.strip() for c in data[row_idx] if c.strip()] return concerns if concerns else ["No concerns listed"] except Exception as e: print(f"[ERROR] Failed to fetch major concerns from sheet: {e}") return ["ERROR fetching concerns"] def fetch_major_concerns(): """Compatibility wrapper for existing callers. Calls `fetch_major_concerns_from_sheet()` so other parts of the code that expect `fetch_major_concerns()` continue to work. """ try: return fetch_major_concerns_from_sheet() except Exception: return ["ERROR fetching concerns"] def write_major_concerns_html(concerns_list=None): if concerns_list is None: concerns_list = [] s = load_settings() 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")) font_size = s.get("html_mission_font_px", 32) # adjust size for concerns # Build HTML for the concerns concerns_html = "" if concerns_list: concerns_html += "\n" else: concerns_html = "

No major concerns

" html = f"""

Major Concerns

{concerns_html} """ concerns_file = os.path.join(app_folder, "major_concerns.html") with open(concerns_file, "w", encoding="utf-8") as f: f.write(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("countdown_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) def write_gonogo_html(gonogo_values=None): if gonogo_values is None: gonogo_values = ["N/A", "N/A", "N/A"] s = load_settings() 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_px = int(s.get("html_gn_font_px", s.get("gn_font_px", 28))) # Prepare display text and color for each item disp = [format_status_display(v) for v in gonogo_values] def pick_css_color(v): return get_status_color(v) c0 = pick_css_color(gonogo_values[0]) c1 = pick_css_color(gonogo_values[1]) c2 = pick_css_color(gonogo_values[2]) # Inline style approach per box to ensure color picks up numeric/word cases html = f"""
Range: {disp[0]}
Vehicle: {disp[2]}
Weather: {disp[1]}
""" 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.major_concerns = fetch_major_concerns() write_major_concerns_html(self.major_concerns) self.last_gonogo_update = time.time() # track last time we refreshed major concerns self.last_concerns_update = time.time() # track which auto-holds we've already triggered for the current run self._auto_hold_triggered = set() # count mode self.count_mode = tk.StringVar(value="T-") # Title self.titletext = tk.Label( root, text=f"RocketLaunchCountdown {appVersion}", font=("Consolas", 24), fg="white", bg="black", ) self.titletext.pack(pady=(10, 0)) # Display frame_count = tk.Frame(root, bg="black") frame_count.pack(pady=(0, 5)) self.text = tk.Label(frame_count, text="T-00:00:00", font=("Consolas", 80, "bold"), fg="white", bg="black") self.text.pack(side="left", padx=(0, 10)) # Static label above the button self.mode_label = tk.Label(frame_count, text="T-/L- Toggle", font=("Consolas", 14), fg="white", bg="black") self.mode_label.pack(side="top", pady=(0, 5)) # Toggle button below the label self.toggle_mode_btn = tk.Button(frame_count, text="↺", command=self.toggle_count_mode, font=("Arial", 14), width=3) self.toggle_mode_btn.pack(side="top") 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) # 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"), ) # new button to open manual percentage dialog self.manual_percent_btn = tk.Button( self.manual_frame, text="Manual Percentages...", width=18, command=self.open_manual_percent_dialog, ) self.manual_percent_btn.grid(row=1, column=0, columnspan=3, pady=(6,0)) # 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() self.concerns_label = tk.Label( frame_gn, text=F"Major Concerns: {self.major_concerns}" , font=("Consolas", 14), fg="white", bg="black" ) self.concerns_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() def open_manual_percent_dialog(self): """Open dialog to set manual percentages for Range, Weather, Vehicle. Stores values on fetch_gonogo function so fetch_gonogo() reads them.""" dlg = tk.Toplevel(self.root) dlg.transient(self.root) dlg.title("Manual Percentages (0-100)") dlg.geometry("360x180") ssettings = load_settings() mode_local = ssettings.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="Weather (%)", fg=dlg_fg, bg=dlg_bg).pack(pady=(8, 0)) w_entry = tk.Entry(dlg, bg=entry_bg, fg=dlg_fg, insertbackground=dlg_fg) w_entry.pack() # populate with existing manual values (if any) try: w = getattr(fetch_gonogo, "manual_weather", "") w_entry.insert(0, str(w)) except Exception: pass def do_save(): def clean_val(x): try: if x is None: return "" s = str(x).strip() if s == "": return "" # try to parse numeric; if numeric, store as number-ish string f = float(s) # clamp 0..100 f = max(0.0, min(100.0, f)) # store as int if whole if abs(f - round(f)) < 0.001: return str(int(round(f))) return f"{f:.1f}" except Exception: # store raw (allow GO/NOGO legacy) return s try: fetch_gonogo.manual_weather = clean_val(w_entry.get()) except Exception: pass # update local labels immediately self.gonogo_values = fetch_gonogo() self._refresh_gonogo_labels() dlg.destroy() btnf = tk.Frame(dlg, bg=dlg_bg) btnf.pack(fill="x", pady=8) tk.Button(btnf, text="Save", command=do_save, width=10, bg=btn_bg, fg=btn_fg).pack( side="right", padx=8 ) tk.Button(btnf, text="Cancel", command=dlg.destroy, width=10, bg=btn_bg, fg=btn_fg).pack( side="right" ) def _refresh_gonogo_labels(self): # reads current gonogo_values and updates the three label widgets with color try: vals = self.gonogo_values # in case gonogo_values is stale, fetch again if not vals or len(vals) < 3: vals = fetch_gonogo() self.gonogo_values = vals # format and apply colors r_disp = format_status_display(vals[0]) w_disp = format_status_display(vals[1]) v_disp = format_status_display(vals[2]) self.range_label.config(text=f"RANGE: {r_disp}", fg=get_status_color(vals[0])) self.weather_label.config(text=f"WEATHER: {w_disp}", fg=get_status_color(vals[1])) self.vehicle_label.config(text=f"VEHICLE: {v_disp}", fg=get_status_color(vals[2])) # also write out the html so external display updates write_gonogo_html(vals) self.last_gonogo_update = time.time() except Exception: pass # ---------------------------- # 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") tk.Label( cell_frame, text="Major concerns cell (e.g. L6):", fg=win_text, bg=win_bg ).grid(row=1, column=0, sticky="w") major_concerns_cell = tk.Entry( cell_frame, width=8, fg=range_cell_fg, bg=range_cell_bg, insertbackground=range_cell_fg, ) major_concerns_cell.grid(row=1, column=1, padx=4) try: if "major_concerns_cell" in settings: major_concerns_cell.insert(0, settings.get("major_concerns_cell")) else: col = settings.get("column", DEFAULT_SETTINGS["column"]) row = settings.get("major_concerns_row", DEFAULT_SETTINGS["major_concerns_row"]) def col_to_letters(n): s = "" while n > 0: n, r = divmod(n - 1, 26) s = chr(ord("A") + r) + s return s major_concerns_cell.insert(0, f"{col_to_letters(col)}{row}") except Exception: # fall back to the canonical default setting (keeps UI/defaults consistent) major_concerns_cell.insert(0, settings.get("major_concerns_cell", DEFAULT_SETTINGS.get("major_concerns_cell", "L9"))) # 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 major concerns cell if provided "major_concerns_cell": major_concerns_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", "countdown_text_color": "#FFFFFF", "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", "countdown_text_color": "#FFFFFF", "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) ) color = get_status_color(weather_val) self.weather_label.config(fg=color) 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) 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.show_hold_button() self._auto_hold_triggered = set() 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() 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) ssettings = load_settings() tzname = ssettings.get("timezone", DEFAULT_SETTINGS.get("timezone", "local")) if ZoneInfo is None or tzname in (None, "", "local"): 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) now_tz = datetime.now(tz) target = now_tz.replace(hour=h, minute=m, second=s, microsecond=0) if target <= now_tz: target = target + timedelta(days=1) total_seconds = (target - now_tz).total_seconds() except Exception: 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 now_time = time.time() mode = self.count_mode.get() if mode in ("T-", "L-"): # countdown self.target_time = now_time + total_seconds self.counting_up = False self.remaining_time = total_seconds elif mode in ("T+", "L+"): # count up self.target_time = now_time - total_seconds self.counting_up = True self.remaining_time = total_seconds # Immediately update display #self.update_clock() 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 prefix = self.count_mode.get() if hasattr(self, "count_mode") else "T-" reset_text = f"{prefix}00:00:00" self.text.config(text=reset_text) write_countdown_html(self.mission_name, reset_text) 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}" # Countdown mode toggle def toggle_count_mode(self): current = self.count_mode.get() next_mode = { "T-": "L-", "T+": "L+", "L-": "T-", "L+": "T+", }.get(current, "T-") self.count_mode.set(next_mode) if not self.running: self.reset() 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) prefix = self.count_mode.get() # Flip mode from T-/L- to T+/L+ if needed if prefix == "T-": self.count_mode.set("T+") elif prefix == "L-": self.count_mode.set("L+") prefix = self.count_mode.get() timer_text = self.format_time(elapsed, prefix) else: prefix = self.count_mode.get() timer_text = self.format_time(diff, prefix) else: prefix = self.count_mode.get() timer_text = self.format_time(diff, prefix) 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 # Update Major Concerns on a configurable interval try: s = load_settings() interval = float(s.get("concerns_update_interval", DEFAULT_SETTINGS.get("concerns_update_interval", 10))) except Exception: interval = DEFAULT_SETTINGS.get("concerns_update_interval", 10) if now_time - getattr(self, "last_concerns_update", 0) > interval: try: concerns = fetch_major_concerns() # update runtime state and HTML self.major_concerns = concerns write_major_concerns_html(concerns) # update GUI label concisely try: display = ", ".join(concerns) if isinstance(concerns, (list, tuple)) else str(concerns) # shorten if very long if len(display) > 200: display = display[:197] + "..." self.concerns_label.config(text=f"Major Concerns: {display}") except Exception: pass except Exception as e: print(f"[DEBUG] failed to refresh major concerns: {e}") self.last_concerns_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()