"""PySimpleGUI-based desktop GUI for DslrDirector. This file provides a simpler, single-file GUI implementation using PySimpleGUI instead of Tkinter. It mirrors the core functionality: Animations tab (home, countdown, sharing), Communication tab for BLE controls (scan/connect/disconnect), and a Logs tab with a refresh button. It reuses the project's existing persistence helpers (load_animation, save_animation, load_comm, save_comm) and imports the optional BleComm module if available. Logo handling: use Pillow (if available) to apply the same un-matte/defringe and 30%-scale pipeline. On Windows the code will compose the processed image onto the panel background color and save as data/ata_logo_out.png so that the displayed image has no halo issues. Run with: python src/gui_psg.py (from project root, venv recommended) """ import os import sys import json import threading import time import PySimpleGUI as sg # Some PySimpleGUI builds (private PyPI message) may present an unexpected API. # Verify the module exposes the expected elements; otherwise fall back to the # existing Tkinter GUI so the user still gets a working UI and gets guidance. try: if not hasattr(sg, 'Text') or not hasattr(sg, 'Window'): raise ImportError('PySimpleGUI API appears incompatible') except Exception as _psg_err: msg = ( "PySimpleGUI import succeeded but the installed package does not expose the\n" "expected API (Text/Window). This can happen with a private or mismatched\n" "PySimpleGUI build.\n\n" "Recommended fix:\n" " python -m pip uninstall PySimpleGUI\n" " python -m pip cache purge\n" " python -m pip install --upgrade --extra-index-url https://PySimpleGUI.net/install PySimpleGUI\n\n" "Falling back to the Tkinter GUI (src/gui.py) for now...\n" ) try: # write to log if available with open(os.path.join(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')), 'data', 'gui_start.log'), 'a', encoding='utf-8') as lf: lf.write(f"{time.strftime('%Y-%m-%d %H:%M:%S')} PSG: {msg}\n") except Exception: pass print(msg) try: # Ensure repo root is on sys.path so 'src' package imports work when running as a script repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) if repo_root not in sys.path: sys.path.insert(0, repo_root) # Launch existing Tk GUI as a fallback from src.gui import DSLRGui app = DSLRGui() app.mainloop() # clean exit raise SystemExit(0) except SystemExit: raise except Exception as e: # if fallback also fails, log and re-raise try: with open(os.path.join(repo_root, 'data', 'gui_start.log'), 'a', encoding='utf-8') as lf: lf.write(f"{time.strftime('%Y-%m-%d %H:%M:%S')} PSG fallback error: {e}\n") except Exception: pass raise # reuse helpers from existing module try: import src.app as appmod import importlib BleComm = importlib.import_module('src.BleComm') except Exception: appmod = None BleComm = None REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) DATA_DIR = os.path.join(REPO_ROOT, 'data') CONFIG_PATH = os.path.join(DATA_DIR, 'config.json') COMM_PATH = os.path.join(DATA_DIR, 'comm.json') GUI_LOG = os.path.join(REPO_ROOT, 'data', 'gui_start.log') def _write_gui_log(msg: str): try: with open(GUI_LOG, 'a', encoding='utf-8') as f: f.write(f"{time.strftime('%Y-%m-%d %H:%M:%S')} {msg}\n") except Exception: pass # reuse persistence helpers if available in appmod by importing functions from gui.py try: from src.gui import load_animation, save_animation, load_comm, save_comm except Exception: # fallback implementations (simple read/write) def load_animation(): try: if not os.path.exists(CONFIG_PATH): return {} with open(CONFIG_PATH, 'r', encoding='utf-8') as f: data = json.load(f) or {} return data.get('animation-events', {}) except Exception: return {} def save_animation(ae: dict): try: base = {} if os.path.exists(CONFIG_PATH): try: with open(CONFIG_PATH, 'r', encoding='utf-8') as f: base = json.load(f) or {} except Exception: base = {} base['animation-events'] = ae with open(CONFIG_PATH, 'w', encoding='utf-8') as f: json.dump(base, f, indent=2) return True except Exception: return False def load_comm(): try: if not os.path.exists(COMM_PATH): return {} with open(COMM_PATH, 'r', encoding='utf-8') as f: return json.load(f) or {} except Exception: return {} def save_comm(cfg: dict): try: with open(COMM_PATH, 'w', encoding='utf-8') as f: json.dump(cfg, f, indent=2) return True except Exception: return False # Image processing pipeline (Pillow optional) PIL_AVAILABLE = False try: from PIL import Image PIL_AVAILABLE = True except Exception: Image = None def process_logo_for_psg(path: str, panel_hex='#252535'): """Open path, un-matte/defringe, resize to 30%, and return a filepath to a PNG. On Windows compose onto panel background and save to DATA_DIR/ata_logo_out.png and return that path. If Pillow not available, return original path. """ if not path or not os.path.exists(path): return None if not PIL_AVAILABLE: return path try: img = Image.open(path).convert('RGBA') # crop transparent border try: alpha = img.getchannel('A') bbox = alpha.getbbox() if bbox: img = img.crop(bbox) except Exception: pass # (simple) un-matte: sample border matte and reverse for semi-alpha try: alpha = img.getchannel('A') w, h = img.size px = img.load() matte_samples = [] for x in range(w): if alpha.getpixel((x, 0)) == 0: matte_samples.append(px[x, 0][:3]) if alpha.getpixel((x, h - 1)) == 0: matte_samples.append(px[x, h - 1][:3]) for y in range(h): if alpha.getpixel((0, y)) == 0: matte_samples.append(px[0, y][:3]) if alpha.getpixel((w - 1, y)) == 0: matte_samples.append(px[w - 1, y][:3]) if matte_samples: mr = int(sum(s[0] for s in matte_samples) / len(matte_samples)) mg = int(sum(s[1] for s in matte_samples) / len(matte_samples)) mb = int(sum(s[2] for s in matte_samples) / len(matte_samples)) matte = (mr, mg, mb) new = Image.new('RGBA', img.size) for yy in range(h): for xx in range(w): r, g, b, a = img.getpixel((xx, yy)) if a == 0 or a == 255: new.putpixel((xx, yy), (r, g, b, a)) else: af = a / 255.0 rr = int(round((r - (1 - af) * matte[0]) / max(af, 1e-6))) gg = int(round((g - (1 - af) * matte[1]) / max(af, 1e-6))) bb = int(round((b - (1 - af) * matte[2]) / max(af, 1e-6))) rr = max(0, min(255, rr)) gg = max(0, min(255, gg)) bb = max(0, min(255, bb)) new.putpixel((xx, yy), (rr, gg, bb, a)) img = new except Exception: pass # conservative defringe try: alpha_cut = 32 color_tol = 30 w2, h2 = img.size px2 = img.load() matte_rgb = None if hasattr(img, 'info') and 'background' in img.info: matte_rgb = img.info['background'] if matte_rgb is None and len(matte_samples) > 0: matte_rgb = matte if matte_rgb is None: matte_rgb = (30, 30, 43) removed = 0 for yy in range(h2): for xx in range(w2): r, g, b, a = px2[xx, yy] if 0 < a <= alpha_cut: dr = r - matte_rgb[0] dg = g - matte_rgb[1] db = b - matte_rgb[2] dist = (dr * dr + dg * dg + db * db) ** 0.5 if dist <= color_tol: px2[xx, yy] = (0, 0, 0, 0) removed += 1 except Exception: pass # resize to 30% sw, sh = img.size new_w = max(1, int(sw * 0.3)) new_h = max(1, int(sh * 0.3)) img_resized = img.resize((new_w, new_h), Image.LANCZOS) # On Windows, compose to opaque background and save out_path = os.path.join(DATA_DIR, 'ata_logo_out.png') try: if os.name == 'nt': def hex_to_rgb(h): h = h.lstrip('#') return tuple(int(h[i:i+2], 16) for i in (0, 2, 4)) try: panel_rgb = hex_to_rgb(panel_hex) except Exception: panel_rgb = (37, 37, 53) bg = Image.new('RGBA', img_resized.size, panel_rgb + (255,)) composed = Image.alpha_composite(bg, img_resized) composed.save(out_path, format='PNG') _write_gui_log(f'PSG Logo: wrote composed PNG to {out_path}') return out_path else: img_resized.save(out_path, format='PNG') _write_gui_log(f'PSG Logo: wrote PNG to {out_path}') return out_path except Exception: return None except Exception: return None # Cosmetic theme/colors for a modern dark look def _apply_psg_theme(): # define core palette bg = '#12121a' panel = '#1e1e2b' fg = '#e6e6f0' accent = '#4fb0c6' # Add a named theme mapping to PySimpleGUI try: sg.LOOK_AND_FEEL_TABLE['DslrDark'] = { 'BACKGROUND': bg, 'TEXT': fg, 'INPUT': '#262636', 'TEXT_INPUT': fg, 'SCROLL': '#2b2b36', 'BUTTON': accent, 'PROGRESS': ('#01826B', '#D0D0D0'), 'BORDER': 1, 'SLIDER_DEPTH': 0, 'PROGRESS_DEPTH': 0 } sg.theme('DslrDark') sg.set_options(element_padding=(6, 6), button_color=(fg, panel), font=('Segoe UI', 11)) except Exception: try: sg.theme('Dark') except Exception: pass _apply_psg_theme() # Build the PySimpleGUI layout def build_layout(): anim = load_animation() comm = load_comm() # Anim tab layout anim_layout = [ [sg.Text('Home')], [sg.Radio('Animation', 'home_mode', default=(anim.get('home-state', 'animation') == 'animation'), key='-HOME_ANIM-'), sg.Radio('Solid', 'home_mode', default=(anim.get('home-state') == 'solid'), key='-HOME_SOLID-')], [sg.Text('Anim ID'), sg.Input(anim.get('home-anim', '0'), key='-HOME_ID-', size=(8,1))], [sg.Text('Color'), sg.Input(anim.get('home-color', '#000000'), key='-HOME_COLOR-', size=(12,1)), sg.Button('Pick', key='-HOME_PICK-')], [sg.HorizontalSeparator()], [sg.Text('Countdown')], [sg.Radio('Animation', 'count_mode', default=(anim.get('countdown-state', 'animation') == 'animation'), key='-COUNT_ANIM-'), sg.Radio('Solid', 'count_mode', default=(anim.get('countdown-state') == 'solid'), key='-COUNT_SOLID-')], [sg.Text('Anim ID'), sg.Input(anim.get('countdown-anim', '0'), key='-COUNT_ID-', size=(8,1))], [sg.Text('Color'), sg.Input(anim.get('countdown-color', '#ffffff'), key='-COUNT_COLOR-', size=(12,1)), sg.Button('Pick', key='-COUNT_PICK-')], [sg.HorizontalSeparator()], [sg.Text('Sharing')], [sg.Radio('Animation', 'share_mode', default=(anim.get('sharing-state', 'animation') == 'animation'), key='-SHARE_ANIM-'), sg.Radio('Solid', 'share_mode', default=(anim.get('sharing-state') == 'solid'), key='-SHARE_SOLID-')], [sg.Text('Anim ID'), sg.Input(anim.get('sharing-anim', '0'), key='-SHARE_ID-', size=(8,1))], [sg.Text('Color'), sg.Input(anim.get('sharing-color', '#0000ff'), key='-SHARE_COLOR-', size=(12,1)), sg.Button('Pick', key='-SHARE_PICK-')], [sg.Button('Save Animations', key='-SAVE_ANIM-')] ] # Comm (BLE) layout comm_name = comm.get('ble', {}).get('device_name', '') if isinstance(comm, dict) else '' comm_addr = comm.get('ble', {}).get('address', '') if isinstance(comm, dict) else '' comm_filter = comm.get('ble', {}).get('filter_name', '') if isinstance(comm, dict) else '' comm_layout = [ [sg.Text('Device Name'), sg.Input(comm_name, key='-COMM_NAME-', size=(30,1))], [sg.Text('Address/ID'), sg.Input(comm_addr, key='-COMM_ADDR-', size=(30,1))], [sg.Text('Filter'), sg.Input(comm_filter, key='-COMM_FILTER-', size=(30,1))], [sg.Button('Scan', key='-SCAN-', button_color=('white', '#2f313a'), size=(10,1)), sg.Button('Connect', key='-CONNECT-', button_color=('white', '#4fb0c6'), size=(10,1)), sg.Button('Disconnect', key='-DISCONNECT-', button_color=('white', '#a64b4b'), size=(10,1))], [sg.Listbox(values=[], size=(60,6), key='-SCAN_LIST-')] ] logs_layout = [ [sg.Multiline('', size=(80, 20), key='-LOG_TEXT-')], [sg.Button('Refresh Logs', key='-REFRESH_LOGS-', button_color=('white', '#4fb0c6'))] ] tab_group = [[sg.Tab('Animations', anim_layout), sg.Tab('Communication', comm_layout), sg.Tab('Logs', logs_layout)]] # load logo (attempt processed path) logo_candidates = [ os.path.join(REPO_ROOT, 'static', 'images', 'ata_logo.png'), os.path.join(os.getcwd(), 'static', 'images', 'ata_logo.png'), os.path.join(DATA_DIR, 'ata_logo.png') ] logo_path = None for p in logo_candidates: if p and os.path.exists(p): logo_path = p break if logo_path: processed = process_logo_for_psg(logo_path) else: processed = None # Header: left logo, centered title/subtitle if processed and os.path.exists(processed): logo_el = sg.Image(processed, pad=(6, 6)) else: logo_el = sg.Text('', size=(12, 1)) title_col = [[sg.Text('DSLR Director', font=('Segoe UI', 22, 'bold'), justification='center')], [sg.Text('Control animations and BLE from your desktop', font=('Segoe UI', 10), text_color='#cbd6dc', justification='center')]] header_frame = [ sg.Column([[logo_el]], vertical_alignment='center', element_justification='left', pad=(8, 8)), sg.VerticalSeparator(), sg.Column(title_col, vertical_alignment='center', element_justification='center', expand_x=True, pad=(8, 8)) ] # Slightly larger tab group with padding layout = [ header_frame, [sg.HorizontalSeparator()], [sg.TabGroup(tab_group, key='-TABS-', expand_x=True, expand_y=True, pad=(6, 8))], [sg.Button('Exit', button_color=('white', '#2f313a'))] ] return layout def run(): layout = build_layout() window = sg.Window('DslrDirector - PySimpleGUI', layout, finalize=True) # Background: attempt to start BleComm if present try: if BleComm is not None and hasattr(BleComm, 'start'): BleComm.start() except Exception: pass while True: event, values = window.read(timeout=200) if event == sg.WIN_CLOSED or event == 'Exit': break if event == '-SAVE_ANIM-': ae = {} ae['home-state'] = 'animation' if values.get('-HOME_ANIM-') else 'solid' ae['home-color'] = values.get('-HOME_COLOR-', '#000000') try: ae['home-anim'] = int(values.get('-HOME_ID-', '0')) except Exception: ae['home-anim'] = 0 ae['countdown-state'] = 'animation' if values.get('-COUNT_ANIM-') else 'solid' ae['countdown-color'] = values.get('-COUNT_COLOR-', '#ffffff') try: ae['countdown-anim'] = int(values.get('-COUNT_ID-', '0')) except Exception: ae['countdown-anim'] = 0 ae['sharing-state'] = 'animation' if values.get('-SHARE_ANIM-') else 'solid' ae['sharing-color'] = values.get('-SHARE_COLOR-', '#0000ff') try: ae['sharing-anim'] = int(values.get('-SHARE_ID-', '0')) except Exception: ae['sharing-anim'] = 0 ok = save_animation(ae) if ok: sg.popup('Saved', 'Animations saved to config.json') else: sg.popup('Error', 'Failed to save animations') if event == '-SCAN-': window['-SCAN_LIST-'].update([]) def _scan(): try: res = BleComm.scan(values.get('-COMM_FILTER-', '') if BleComm else []) items = [] for d in res: name = d.get('name') or '' addr = d.get('address') or d.get('id') or '' items.append(f"{name} | {addr}") window['-SCAN_LIST-'].update(items) except Exception as e: window['-SCAN_LIST-'].update([f"Scan failed: {e}"]) threading.Thread(target=_scan, daemon=True).start() if event == '-CONNECT-': addr = values.get('-COMM_ADDR-', '').strip() name = values.get('-COMM_NAME-', '').strip() base = load_comm() base['ble'] = base.get('ble', {}) if addr: base['ble']['address'] = addr if name: base['ble']['device_name'] = name base['ble']['filter_name'] = values.get('-COMM_FILTER-', '') save_comm(base) def _do(): try: res = BleComm.connect(addr or name) sg.popup('Connect', f'Connect result: {res}') except Exception as e: sg.popup('Connect failed', str(e)) threading.Thread(target=_do, daemon=True).start() if event == '-DISCONNECT-': def _do(): try: res = BleComm.disconnect() sg.popup('Disconnect', f'Disconnect result: {res}') except Exception as e: sg.popup('Disconnect failed', str(e)) threading.Thread(target=_do, daemon=True).start() if event == '-REFRESH_LOGS-': content = '' if appmod is not None and hasattr(appmod, 'tail_log'): content = appmod.tail_log(200) else: lf = os.path.join(REPO_ROOT, 'dslrbooth_triggers.log') if os.path.exists(lf): with open(lf, 'r', encoding='utf-8', errors='replace') as f: content = ''.join(f.readlines()[-200:]) window['-LOG_TEXT-'].update(content) window.close() if __name__ == '__main__': try: _write_gui_log('PSG GUI: starting') except Exception: pass run()