DslrDirector/temp/gui_psg.py
2025-09-29 00:46:38 -07:00

498 lines
20 KiB
Python

"""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()